Compare commits

..
Author SHA1 Message Date
morluto 8f947c859c fix(apple): qualify rpath findings for conditional searches 2026-10-08 23:52:43 +08:00
morluto a446f2a413 chore: sync branch with latest main 2026-10-08 23:52:43 +08:00
morluto 5be3eeb1e6 fix(apple): distinguish dyld logging from path overrides 2026-10-08 23:46:06 +08:00
morluto 0cbef126a5 chore: merge main and reconcile CI projections 2026-10-08 23:45:07 +08:00
morluto 4adca806f9 fix(ci): restore generated projections in test shards 2026-10-08 23:32:34 +08:00
morluto 22e6110acb fix(apple): preserve modern cache subtype metadata 2026-10-08 23:26:33 +08:00
morluto 8289ede5db chore: synchronize dyld cache branch with latest main guidance 2026-10-08 23:16:56 +08:00
morluto e4d61d401d fix(apple): distinguish cache loadability and propagate search uncertainty 2026-10-08 23:16:28 +08:00
4e98543e90 fix(apple): admit only compatible dyld subcache mappings (#1090)
UUID, architecture, and cache VM offset are one admission decision, so a
matching UUID cannot publish contradictory mappings. A shared-cache trace
hit also requires the mapped bytes to parse as a Mach-O the process can load.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-10-08 22:51:06 +08:00
morluto 03bbecd941 chore: merge main into feat/dyld-shared-cache 2026-10-08 21:26:50 +08:00
morluto 2645851adf chore: merge main into feat/dyld-shared-cache 2026-10-08 21:22:08 +08:00
morluto d39543145e fix(apple): validate complete image mappings with indexed VM lookup 2026-10-08 21:13:43 +08:00
morluto b23f8288b4 fix(apple): validate cache file kinds and combined VM ranges 2026-10-08 20:52:20 +08:00
morluto cb8d0f37f4 chore: merge main into feat/dyld-shared-cache 2026-10-08 19:57:47 +08:00
morluto 6b99236c36 fix(apple): disclose untraversed cache dependencies in trace results 2026-10-08 19:55:51 +08:00
morluto fb999f84b8 refactor(apple): normalize host paths at the filesystem boundary 2026-10-08 19:09:40 +08:00
morluto fe3cac8aa8 fix(apple): normalize simulator cache platforms without losing raw fields 2026-10-08 18:19:37 +08:00
morluto 17f2bc7b0d fix(apple): discover cache companions from canonical main file 2026-10-08 17:46:43 +08:00
morluto f4f82179d9 fix(apple): preserve selected cache path in trace evidence 2026-10-08 17:22:52 +08:00
morluto 0f6dd6966c chore: merge main into feat/dyld-shared-cache 2026-10-08 17:02:53 +08:00
morluto 92fcab2c68 fix(apple): retain caller-selected cache path in results 2026-10-08 16:51:39 +08:00
morluto dbc7ebc2b6 fix(apple): recheck cache identity around digest reads 2026-10-08 16:36:44 +08:00
morluto d4ba8f661b fix(apple): map Intel version-min to simulators; fail truncated cache hashes 2026-10-08 16:23:05 +08:00
github-actions[bot] 24c8e13909 chore: regenerate generated metadata 2026-10-08 08:12:50 +00:00
morluto e1597a6f3d fix(apple): reject empty caches and bind digests to resolution traces 2026-10-08 16:06:51 +08:00
morluto d5921db039 chore: merge main into feat/dyld-shared-cache 2026-10-08 15:53:51 +08:00
morluto 663f4c4dc5 fix(apple): decode legacy cache-type polarity by header generation 2026-10-08 15:47:47 +08:00
morluto d081ffbdce fix(apple): harden dyld cache reads, mappings, and resolution findings 2026-10-08 15:36:31 +08:00
Kaoru0822andClaude Opus 5.5 cd622e0e95 Merge branch 'feat/dylib-resolution' into feat/dyld-shared-cache
Bring in #927's merge of main (#1037 at skill version 30):
- README_ja/ar/ko/zh: keep the merged tables and count
  inspect_dyld_shared_cache in application workflows (13 -> 14).
- Skill: #927 now uses version 31, so this branch moves to 32.
- Regenerate the product catalog, conformance manifest, skill digest and
  package metadata (138 tools).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:33:22 +00:00
Kaoru0822andClaude Opus 5.5 f2f83c7499 Merge branch 'feat/dylib-resolution' into feat/dyld-shared-cache
Bring in #927's merge of main (#964 at skill version 29):
- README_ja/ar/ko/zh: keep the merged tables and count
  inspect_dyld_shared_cache in application workflows (13 -> 14).
- Skill: #927 now uses version 30, so this branch moves to 31.
- Regenerate the product catalog, conformance manifest, skill digest and
  package metadata (137 tools).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:25:40 +00:00
Kaoru0822andClaude Opus 5.5 d180d8667d Merge branch 'feat/dylib-resolution' into feat/dyld-shared-cache
Bring in #927's merge of main and resolve conflicts:
- README.md: main's simplified English README has no tool-family table.
- README_ja/ar/ko/zh: keep main's reworded tables and count
  inspect_dyld_shared_cache in application workflows (13 -> 14).
- Skill: #927 now uses version 29, so this branch moves to 30.
- Regenerate the product catalog, conformance manifest, skill digest and
  package metadata (136 tools). The MCP tool catalog is now built rather
  than committed (#997), so it is removed here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 04:52:57 +00:00
Kaoru0822 3d6a31ed41 Merge branch 'feat/dylib-resolution' into feat/dyld-shared-cache 2026-10-07 14:49:09 +00:00
Kaoru0822andClaude Opus 5.5 09344aa33d chore(apple): drop an unused DylibCandidate re-export
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:21:12 +00:00
Kaoru0822 8e53f794f1 Merge branch 'feat/dylib-resolution' into feat/dyld-shared-cache
# Conflicts:
#	README.md
#	README_ar.md
#	README_ja.md
#	README_ko.md
#	README_zh.md
#	docs/product-catalog.json
#	docs/verification/managed-conformance-manifest.json
#	skills/reverse-engineer-anything/SKILL.md
#	src/generatedMcpToolCatalog.ts
2026-10-07 14:13:29 +00:00
Kaoru0822andClaude Opus 5.5 858aa9fd7d fix(apple): hash the dyld cache through the handle its header was read from
main_file_sha256 now comes from the main cache file's open handle, so a path
replaced during inspection cannot attach another file's digest to the parsed
header and image list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:35:58 +00:00
Kaoru0822 ed40d2e001 Merge branch 'feat/dylib-resolution' into feat/dyld-shared-cache 2026-10-07 13:33:40 +00:00
Kaoru0822andClaude Opus 5.5 3b4969e3dc Merge branch 'feat/dylib-resolution' into feat/dyld-shared-cache
Carry #952's Apple ownership into the cache work: the cache reader and
fixture move to src/artifacts/apple/, the cache facts, platforms and
resolution schema to src/domain/apple/, the cache service to
src/application/apple/, and the lane to scripts/verify/apple/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:32:48 +00:00
Kaoru0822andClaude Opus 5.5 1d943c5eec Merge branch 'feat/dylib-resolution' into feat/dyld-shared-cache
Carry conditional-load propagation, parsed-image digest binding, and
segment-wise root resolution into the shared-cache trace. Also:

- Make cache compatibility directional: an arm64e cache serves arm64
  processes and an x86_64h cache serves x86_64 ones, not the reverse.
- Require the complete 24-byte LC_BUILD_VERSION with its declared tool
  records, and the 16-byte LC_VERSION_MIN_* command, before recording a
  platform.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:00:59 +00:00
Kaoru0822andClaude Opus 5.5 305474df6c fix(apple): gate shared-cache answers on platform and verified subcaches
- Use a supplied cache for a process only when it serves the process's CPU
  family and one of its LC_BUILD_VERSION or LC_VERSION_MIN platforms matches
  the cache's platform or alternate platform; slices now report platforms.
  A limitation names each root the cache was not used for, and why.
- Treat an image listed in the cache but mapped in a missing or
  UUID-mismatched subcache as undetermined, list it in
  coverage.unverified_shared_cache_images, and mark coverage partial.
- Keep a cache hit conditional when an earlier candidate is undeterminable.
- Open a development cache's named subcaches beside its base name.
- Describe absolute paths as not evaluated only when no cache is supplied.
- Preflight dyld_info in verify:dyld-shared-cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:36:43 +00:00
Kaoru0822 202661f83b Merge branch 'feat/dylib-resolution' into feat/dyld-shared-cache
# Conflicts:
#	docs/product-catalog.json
#	docs/verification/managed-conformance-manifest.json
#	skills/reverse-engineer-anything/SKILL.md
#	src/generatedMcpToolCatalog.ts
2026-10-07 12:26:24 +00:00
Kaoru0822 22beae9768 Merge remote-tracking branch 'origin/feat/dylib-resolution' into feat/dyld-shared-cache
# Conflicts:
#	docs/product-catalog.json
#	docs/verification/managed-conformance-manifest.json
#	skills/reverse-engineer-anything/SKILL.md
#	src/domain/dylibResolution.test.ts
#	src/domain/dylibResolution.ts
#	src/domain/dylibResolutionFindings.ts
#	src/generatedMcpToolCatalog.ts
2026-10-07 11:52:10 +00:00
Kaoru0822andClaude Opus 5.5 ade11fa02d feat(apple): inspect dyld shared caches and resolve system paths
macOS 11 and later ship system libraries only inside the dyld shared
cache. trace_dylib_resolution therefore left every /usr/lib and /System
load undetermined, and REA had no way to see what a cache contains.

inspect_dyld_shared_cache (CLI inspect-dyld-shared-cache) reads a cache
file set selected by path, without an open target. Its pure TypeScript
reader parses modern and legacy dyld_cache_header layouts and reports:
- UUID, architecture, platform, OS version, Mac Catalyst alternate,
  cache type, shared region, max slide;
- every VM mapping;
- each subcache by file suffix, checked by UUID. A missing or
  mismatched subcache makes coverage partial;
- the complete image list.
For each requested install path it reads that image's load commands
through the cache mappings with the existing Mach-O reader.

trace_dylib_resolution accepts an optional shared_cache. Absolute
candidates found in a cache that serves the process's CPU family resolve
with the new outcome and status `shared-cache`. That status also takes
precedence over later in-bundle fallbacks such as a back-deployed
libswiftCore. The result names the cache UUID and OS version it used.

verify:dyld-shared-cache checks every image in the host cache (3,649 on
macOS 26.6), with dependencies and their re-export, weak, upward and
delay-init attributes, against `dyld_info -all_dyld_cache
-linked_dylibs`. It also checks CLI/MCP parity, and that each
shared-cache edge in the fixture trace is a library dyld actually loads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:43:15 +00:00
571 changed files with 9546 additions and 31466 deletions
+1 -2
View File
@@ -61,8 +61,7 @@ limits, security/privacy/containment implications, and meaningful proof gaps. --
- Breaking changes or migration steps:
<!-- For ! / BREAKING CHANGE: show a previously valid call/configuration,
its new behavior and migration, and why compatibility/deprecation cannot
preserve it. Every release increments minor, including breaking changes;
keep breaking markers and migration notes so callers can assess compatibility.
preserve it. Compatible fixes/additions should use patch/minor semantics.
See docs/releasing.md. -->
- Real Hopper/Ghidra, browser, or OS coverage:
- Package or release metadata impact:
+4 -25
View File
@@ -102,12 +102,8 @@ jobs:
with:
name: rea-dist
path: dist
- name: Restore generated outputs from downloaded build
run: |
node scripts/generate-mcp-tool-catalog.mjs
node scripts/generate-skill-metadata.mjs
node scripts/generate-product-catalog.mjs
node scripts/generate-completion-ledger.mjs
- name: Generate projections from downloaded build
run: npm run build:projections
- run: npm run test:ci:shard:run -- --shard=${{ matrix.shard }}
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
@@ -321,6 +317,7 @@ jobs:
- run: npm run verify:keyed-archive
- run: npm run verify:asset-catalog
- run: npm run verify:macos-bundle
- run: npm run verify:dyld-shared-cache
- name: Native installer and compiled NIB hierarchy regressions
run: npm run test:focused -- tests/boundary/filesystem/installScript.test.ts tests/boundary/filesystem/nibNativeHierarchy.test.ts tests/boundary/filesystem/interfaceBuilderBundle.test.ts
- name: Real binary metadata and Interface Builder integration
@@ -329,12 +326,10 @@ jobs:
run: npm run test:focused -- tests/boundary/cli/nativePlistCli.test.ts tests/boundary/cli/nativePlistPrototypeKeyCli.test.ts tests/boundary/cli/nativePlistSelectionFailureCli.test.ts
- name: Native Swift demangling CLI and MCP regressions
run: npm run test:focused -- tests/boundary/cli/nativeDemangleCli.test.ts tests/boundary/mcp/nativeDemangleMcp.test.ts
- name: Native signature target binding and CLI/MCP recovery regressions
run: npm run test:focused -- tests/boundary/providers/native/nativeSignatureTargetBinding.test.ts tests/boundary/cli/nativeSignatureCli.test.ts tests/boundary/mcp/nativeSignatureTargetBindingMcp.test.ts
- name: Process filesystem snapshot descriptor regressions
run: npm run test:focused -- src/process/capture/FilesystemSnapshot.test.ts
- name: Native Darwin ownership and process cleanup regressions
run: npm run test:focused -- tests/boundary/process/darwinProcessRunTokenReader.test.ts tests/boundary/process/processCaptureLifecycle.test.ts tests/boundary/process/processCli.test.ts tests/boundary/mcp/sessionMcp.test.ts
run: npm run test:focused -- tests/boundary/process/darwinProcessRunTokenReader.test.ts tests/boundary/process/processCaptureLifecycle.test.ts tests/boundary/cli/processCli.test.ts tests/boundary/mcp/sessionMcp.test.ts
reconstruction-readiness:
name: Reconstruction readiness
@@ -452,7 +447,6 @@ jobs:
tests/boundary/filesystem/windowsNpxRegistration.test.ts
src/application/ClientRegistrationIdentity.test.ts
tests/boundary/filesystem/webScriptManifestPaths.test.ts
tests/boundary/process/ProcessEvidence.host.test.ts
tests/acceptance/webModuleCli.test.ts
tests/acceptance/webSourceLocationCli.test.ts
tests/boundary/cli/referenceSourceImportStatus.test.ts
@@ -476,21 +470,6 @@ jobs:
tests/conformance/providers/ghidra/realGhidraWindowsWorkflow.test.ts
- run: npm run verify:windows-native
- run: npm run verify:package:windows
- name: Verify native directory identity on Node 24
run: >-
npx vitest run --coverage.enabled=false
tests/boundary/filesystem/directoryArtifactReaderOpen.test.ts
tests/boundary/filesystem/javascriptPackagePrecedence.test.ts
tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts
- uses: actions/setup-node@v4
with:
node-version: 22.19.0
- name: Verify native directory identity on Node 22
run: >-
npx vitest run --coverage.enabled=false
tests/boundary/filesystem/directoryArtifactReaderOpen.test.ts
tests/boundary/filesystem/javascriptPackagePrecedence.test.ts
tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts
- uses: actions/upload-artifact@v4
if: always()
with:
+2 -20
View File
@@ -1,8 +1,6 @@
name: Release
on:
push:
branches: [main]
workflow_dispatch:
inputs:
release_branch:
@@ -20,27 +18,12 @@ env:
HUSKY: "0"
concurrency:
group: release-${{ inputs.release_branch || github.ref_name }}
group: release-${{ inputs.release_branch }}
cancel-in-progress: false
jobs:
release-proposal:
name: Update release proposal
if: github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: googleapis/release-please-action@v4
with:
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
target-branch: main
skip-github-release: true
release-please:
name: Release Please
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
permissions:
contents: write
@@ -230,8 +213,7 @@ jobs:
if [ "${published_version}" = "\"${version}\"" ]; then
echo "rea-agents@${version} is already published; verifying the existing package."
else
tag="$(node scripts/release-npm-tag.mjs "${version}")"
npm publish --access public --tag "${tag}"
npm publish --access public
fi
- name: Verify published CLI from npm
-7
View File
@@ -50,13 +50,6 @@ Thumbs.db
# Generated website download
/website/public/examples/notes-example.zip
# Local Cloudflare development state and credentials
/website/.wrangler/
/website/.dev.vars*
/website/.env*
!/website/.dev.vars.example
!/website/.env*.example
# Build-generated MCP tool catalog (regenerated by `npm run build`; never commit)
src/generatedMcpToolCatalog.ts
+1 -5
View File
@@ -60,8 +60,6 @@ Keep portable evidence and scenario validation distinct from host-native executi
Preserve meaningful failure reasons through application and adapter layers. Malformed input is distinct from an unsupported target, unavailable provider, or host operating-system permission denial. Diagnostics should identify the failed constraint and the target or lifecycle request it applies to. Recovery advice must address that reason and point to an available workflow; generic catches must not erase actionable validation details. Preserve collected observations on execution failure independently of whether cleanup succeeds.
Preserve partial native facts. A shared schema must not require one provider's full metadata before another provider can report the facts it observes. Keep observed classifications and endpoints alongside explicit unknowns for unsupported flags; never invent missing flags or discard known facts to satisfy an all-or-nothing availability shape. Before claiming a native API lacks a capability, inspect the installed API and probe representative producer objects. Exercise the resulting partial representation through CLI, MCP, and composed results such as function dossiers.
Leave meaningful target, action, capture, and output choices to the agent. A selected operation already expresses intent; do not require approval booleans or repeated permission declarations. Trace each setting to its consumer: remove ignored options and single-value confirmations, derive built-in lifecycle behavior, and supply defaults for omitted optional metadata. Report actual effects and limitations where they help interpret results rather than asking callers to restate them.
Verify the representation consumed at the next boundary. Internal validator success does not establish JSON Schema validity or client compatibility: validate advertised input and output schemas against their declared dialect after SDK conversion, and keep generated contracts aligned. Use representative producer data and regressions that exercise the failed behavior. Platform and provider support claims require the corresponding real workflow; package startup, capability probes, and mock transport tests establish only their narrower claims. Report unverified coverage explicitly.
@@ -92,8 +90,6 @@ See [docs/tool-design.md](docs/tool-design.md) for the design checklist. When us
## Testing Guidelines
Prioritize real end-to-end workflows, boundary integration, and golden producer data. Keep a focused module test when it covers a distinct failure or semantic case absent from those workflows; test paths do not establish depth. See [docs/testing.md](docs/testing.md) for pruning and classification rules.
Name tests `*.test.ts`. Use Vitest and production seams (`tests/fixtures/`) rather than module mocks. Domain tests assert pure behavior; adapter tests use fake launcher/socket seams; MCP tests connect with the client SDK version pinned in `package.json`. Preserve the canonical tool inventory defined by `TOOL_CONTRACTS` and verified through `CATALOG_IDENTITY` and generated product metadata. Cover malformed input, cancellation, lifecycle cleanup, and actual format, protocol, host-permission, and target-identity boundaries. Do not add tests that merely freeze arbitrary caps or prescribed call sequences. Real Hopper, Ghidra, browser, managed conformance, and any real managed-tool claims cannot be replaced by mocks; use the corresponding `verify:*` command.
Keep tool catalogs complete and self-describing; prefer capability- and session-scoped availability over schema truncation. Serialized bytes alone do not measure agent usability or model context cost.
@@ -106,7 +102,7 @@ Development requires Node.js 24.18.0 and npm 11.16.0 (`.nvmrc` and `packageManag
## Commit & Pull Request Guidelines
Use Conventional Commit subjects because Release Please derives changelogs from them. Release Please uses `always-bump-minor`: every release increments minor, including breaking changes. Examples: `feat: add historical source import`, `fix(process): stop timers after exit`, and `docs: update architecture`. Use `!` or a `BREAKING CHANGE:` footer for breaking changes. Pull request titles must follow the same format because squash merges use the title as the release commit. Pull requests should describe contract or behavior changes, list verification commands, link issues, and include sanitized MCP examples when schemas change. State whether real Hopper/Ghidra verification was performed. Never commit binaries, Hopper or Ghidra project documents, credentials, `dist/`, `node_modules/`, or local planning artifacts (e.g. `.codex/`).
Use Conventional Commit subjects because Release Please derives versions and changelogs from them. Examples: `feat: add historical source import`, `fix(process): stop timers after exit`, and `docs: update architecture`. Use `!` or a `BREAKING CHANGE:` footer for breaking changes. Pull request titles must follow the same format because squash merges use the title as the release commit. Pull requests should describe contract or behavior changes, list verification commands, link issues, and include sanitized MCP examples when schemas change. State whether real Hopper/Ghidra verification was performed. Never commit binaries, Hopper or Ghidra project documents, credentials, `dist/`, `node_modules/`, or local planning artifacts (e.g. `.codex/`).
<!-- BEGIN:turborepo-agent-rules -->
+9 -15
View File
@@ -39,14 +39,10 @@ use `npm run docs:generate`; `docs:build` generates them before building the web
Pull requests run `npm run docs:check`. `.github/workflows/pages.yml` is a
manual VitePress build and does not publish. The public site at
<https://rea.tools/> serves `website/public/` through Cloudflare Workers. See
the [website README](website/README.md#cloudflare-workers) for previews and
manual deployments.
The separate GitHub Pages host uses only the manual
`.github/workflows/website-pages.yml` workflow on `main`. The repository's
**Settings → Pages → Build and deployment → Source** must be set to
**GitHub Actions** before deploying there.
<https://morluto.github.io/rea/> is published only by the manual website
workflow on `main`. The repository's **Settings → Pages → Build and
deployment → Source** must be set to **GitHub Actions** before the first
deployment.
## Development feedback and PR verification
@@ -142,13 +138,11 @@ Describe the behavior change and verification performed in the pull request. Nev
## Maintainer release checklist
Use the [checkpoint release guide](docs/releasing.md). Main pushes automatically
refresh a release proposal with the next version and changelog. Keep this preview
PR unmerged until it is superseded by the published release's metadata sync.
Publication starts from an explicit `release/VERSION` branch cut at a recorded
application commit. Manually prepare its bot PR, wait for its exact-head CI and
review, merge into that frozen branch, and manually publish through the official
Release workflow. Main pushes cannot publish packages. Both publishers build
Use the [checkpoint release guide](docs/releasing.md). Releases start from an
explicit `release/VERSION` branch cut at a recorded commit. Manually prepare
the bot PR, wait for its exact-head CI and review, merge into that frozen
branch, and manually publish through the official Release workflow. Main
pushes do not update release PRs or publish packages. Both publishers build
the exact SHA tagged by Release Please.
Keep new implementation commits on main for the next release. The workflow
+11 -11
View File
@@ -18,7 +18,7 @@
<a href="https://trendshift.io/repositories/82054?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-82054" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/82054" alt="morluto%2Frea | Trendshift" width="250" height="55"/></a>
**[Website](https://rea.tools/) · [Guides](https://rea.tools/guides/) · [Showcases](https://rea.tools/showcase/)**
**[Website](https://morluto.github.io/rea/) · [Guides](https://morluto.github.io/rea/guides/) · [Showcases](https://morluto.github.io/rea/showcase/)**
[Quick start](#quick-start) · [How REA works](#how-rea-works) · [What you can analyze](#what-you-can-analyze) · [Showcases](#showcases) · [FAQ](#faq) · [Documentation](#documentation)
@@ -54,7 +54,7 @@ REA connects your agent to tools for inspecting native binaries, JavaScript and
Setup registers REA with your agent and installs matching workflow instructions. Native analysis can use an existing Hopper or Ghidra installation; setup can optionally install Hopper with approval. Static JavaScript analysis needs neither engine.
> **[Visit the REA website](https://rea.tools/)** for setup instructions, illustrated guides, and real case studies.
> **[Visit the REA website](https://morluto.github.io/rea/)** for setup instructions, illustrated guides, and real case studies.
## Quick start
@@ -70,7 +70,7 @@ Choose your agents, review the proposed changes, and approve them. Setup adds
REA's MCP server and matching workflow instructions, with backups of existing
configuration. Restart your agent afterward.
Setup supports Claude Code, Codex, Cursor, Gemini CLI, Grok Build, Grok Bot and
Setup supports Claude Code, Codex, Cursor, Gemini CLI and
[other agents](docs/installation.md#supported-agents). See
[installation and setup](docs/installation.md) for provider configuration and
manual MCP registration.
@@ -149,12 +149,12 @@ Additional tools and host support depend on the target:
| Target | What REA returns | Requirements and guide |
| ---------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
| Native binaries | Pseudocode, assembly, strings, symbols, calls and references | Hopper, Ghidra or IDA; [native analysis](https://rea.tools/guides/native/) |
| Native binaries | Pseudocode, assembly, strings, symbols, calls and references | Hopper, Ghidra or IDA; [native analysis](https://morluto.github.io/rea/guides/native/) |
| Offline ELF layout | Sections, segments, original symbols/relocations and static mitigation candidates | Caller-supplied pwntools on Linux x64; [binary diagnostics](docs/binary-diagnostics.md) |
| EVM bytecode | Dispatch selectors, byte offsets, inferred arguments and mutability | Local raw/hex carrier; [offline EVM guide](docs/evm-bytecode.md) |
| Recorded Linux crashes | Raw notes, every recorded thread's registers/signals and optional mapping candidates | Caller-supplied pwntools; optional GDB/pwndbg; [recorded crashes](docs/recorded-crashes.md) |
| JavaScript / Electron | Modules, imports, source maps, routes, IPC and native add-on relationships | Node.js and npm; [application analysis](https://rea.tools/guides/javascript/) |
| Websites | Page structure, scripts, network observations and requested screenshots | A Chrome-family browser; [browser analysis](https://rea.tools/guides/browser/) |
| JavaScript / Electron | Modules, imports, source maps, routes, IPC and native add-on relationships | Node.js and npm; [application analysis](https://morluto.github.io/rea/guides/javascript/) |
| Websites | Page structure, scripts, network observations and requested screenshots | A Chrome-family browser; [browser analysis](https://morluto.github.io/rea/guides/browser/) |
| Saved network captures | Requests, responses, exposed payloads and source locations | HAR; mitmdump on Linux for native mitmproxy captures; [capture guide](docs/web-network-captures.md) |
| .NET assemblies | Metadata, CIL instructions, declared native dependencies and build comparisons | Static inspection; [managed-code guide](docs/managed-code-analysis.md) |
| Android APKs | Manifest declarations, classes, decompiled methods and references | Headless JADX and a full JDK on Linux/macOS; [Android guide](docs/android-analysis.md) |
@@ -185,7 +185,7 @@ Follow a sound call into its position-to-pan helper, inspect the instructions,
and turn incomplete pseudocode into C. The reconstruction passes 3,205
original-x86 cases and reproduces all 63 compiled function bytes.
[Read the case study](https://rea.tools/showcase/dx-ball/) ·
[Read the case study](https://morluto.github.io/rea/showcase/dx-ball/) ·
[Reconstruction repository](https://github.com/N0zoM1z0/dx-ball)
### Notion: trace the Electron clipboard bridge
@@ -193,7 +193,7 @@ original-x86 cases and reproduces all 63 compiled function bytes.
Find the renderer's clipboard API, follow it through preload and IPC into the
main process, and inspect the rich clipboard format.
[Read the case study](https://rea.tools/showcase/notion/)
[Read the case study](https://morluto.github.io/rea/showcase/notion/)
### TH04: recover a DOS bullet-ring calculation
@@ -201,7 +201,7 @@ Inspect the original PC-98 game's 16-bit instructions, recover the fixed and
aimed angle calculations, and compare the reconstructed C++ with the
historical compiler output.
[Read the case study](https://rea.tools/showcase/th04/) ·
[Read the case study](https://morluto.github.io/rea/showcase/th04/) ·
[Reconstruction repository](https://github.com/N0zoM1z0/th04)
If you've used REA on something interesting, we'd love to see it. Share your
@@ -289,7 +289,7 @@ with your REA version, target type, steps to reproduce and error output.
## Documentation
Start with the website's [worked guides](https://rea.tools/guides/).
Start with the website's [worked guides](https://morluto.github.io/rea/guides/).
For exact options, prerequisites and result contracts:
- [Installation and setup](docs/installation.md): agent registration, provider configuration, updates and uninstall.
@@ -313,7 +313,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and checks,
## Project links
[Website](https://rea.tools/) · [npm](https://www.npmjs.com/package/rea-agents) · [skills.sh](https://skills.sh/morluto/rea/reverse-engineer-anything) · [Issues](https://github.com/morluto/rea/issues) · [Security](SECURITY.md)
[Website](https://morluto.github.io/rea/) · [npm](https://www.npmjs.com/package/rea-agents) · [skills.sh](https://skills.sh/morluto/rea/reverse-engineer-anything) · [Issues](https://github.com/morluto/rea/issues) · [Security](SECURITY.md)
## Star history
+247 -179
View File
@@ -2,286 +2,354 @@
[English](README.md) · [简体中文](README_zh.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · **العربية**
# REA: هندسة عكسية لأي شيء
# REA: اعكس هندسة أي شيء
### خادم MCP واحد للهندسة العكسية للملفات الثنائية والتطبيقات والسلوك أثناء التشغيل.
### وسيط MCP واحد للهندسة العكسية عبر الملفات التنفيذية والتطبيقات وسلوك وقت التشغيل.
**وجدت ميزة تعجبك؟ افهم طريقة عملها حتى مستوى الشيفرة الثنائية.**
**اعثر على ميزة تعجبك. افهم آلية عملها. ابنها بالطريقة التي تريدها.**
[![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog)
[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](#منصة-أدوات-التحقيق)
[![Node.js 22+](https://img.shields.io/badge/Node.js-22.19%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
[![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything)
[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE)
[![Discord](https://img.shields.io/discord/1556595354999332884?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/GkcryMnJDM)
<a href="https://trendshift.io/repositories/82054?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-82054" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/82054" alt="morluto%2Frea | Trendshift" width="250" height="55"/></a>
**[الموقع](https://rea.tools/) · [الأدلة](https://rea.tools/guides/) · [دراسات الحالة](https://rea.tools/showcase/)**
**[الموقع (بالإنجليزية)](https://morluto.github.io/rea/) · [أدلة الاستخدام](https://morluto.github.io/rea/guides/) · [دراسة حالة DX-Ball](https://morluto.github.io/rea/showcase/dx-ball/)**
[البدء السريع](#البدء-السريع) · [كيف يعمل REA](#كيف-يعمل-rea) · [ما الذي يمكنك تحليله](#ما-الذي-يمكنك-تحليله) · [دراسات الحالة](#دراسات-الحالة) · [الأسئلة الشائعة](#الأسئلة-الشائعة) · [التوثيق](#التوثيق)
[البدء السريع](#البدء-السريع) · [حالة الدعم](#حالة-الدعم) · [من الملف التنفيذي إلى السلوك](#من-الملف-التنفيذي-إلى-السلوك) · [منصة أدوات التحقيق](#منصة-أدوات-التحقيق) · [خطة العمل](#خطة-العمل) · [كيف يعمل؟](#كيف-يعمل)
<code>npx rea-agents setup</code>
<br />
<img src="docs/assets/rea-hopper-analysis.png" alt="REA يشغّل جسر التحليل داخل Hopper لفحص ملف ثنائي للشيفرة الأصلية" width="1200" />
<br />
<table aria-label="مجتمع REA">
<table aria-label="REA community">
<tr>
<td align="center" width="360">
<a href="https://discord.gg/GkcryMnJDM">
<img src="docs/assets/discord.svg" height="42" alt="Discord" /><br />
<strong>انضم إلى مجتمع الهندسة العكسية</strong>
</a><br />
<sub>Discord · أسئلة وأجوبة · مشاركة النتائج</sub>
<sub>Discord · أسئلة وأجوبة · شارك أعمالك</sub>
</td>
</tr>
</table>
<br />
<code>npx rea-agents setup</code>
<br />
<img src="docs/assets/rea-hopper-analysis.png" alt="REA يشغّل جسر التحليل داخل Hopper لفحص ملف تنفيذي أصلي" width="1200" />
</div>
---
هل وجدت في تطبيق ميزة تريد إضافتها إلى منتجك؟ اطلب من وكيلك استقصاءها باستخدام REA. يستطيع فحص التطبيق من دون شيفرته المصدرية، وشرح طريقة عمل الميزة وعرض الأدلة، ثم بناء نسخة تناسب مشروعك.
هل وجدت في تطبيق ميزة تريدها في منتجك؟ اطلب من وكيلك استقصاءها باستخدام REA. يستطيع فحص التطبيق من دون شيفرته المصدرية، وشرح طريقة عمل الميزة وعرض الأدلة، ثم بناء نسخة تناسب مشروعك.
يربط REA وكيلك بأدوات لفحص الملفات الثنائية للشيفرة الأصلية، وتطبيقات JavaScript وElectron، وتجميعات .NET، والمواقع. يمكنك أيضًا استخدام الأدوات نفسها من الطرفية. يجري التحليل محليًا، وتتضمن النتائج الأدلة والقيود التي تستند إليها كل خلاصة.
يوفر REA أدوات لتحليل الملفات التنفيذية الأصلية وتطبيقات JavaScript وElectron وتجميعات .NET والمواقع. يمكنك استخدامها من وكيلك أو من الطرفية. يعمل التحليل محليًا، وتتضمن النتائج الأدلة والقيود.
يسجّل الإعداد REA لدى وكيلك ويثبّت تعليمات سير العمل المطابقة. يمكن لتحليل الشيفرة الأصلية استخدام تثبيت موجود من Hopper أو Ghidra؛ ويمكن للإعداد أيضًا تثبيت Hopper اختياريًا بعد موافقتك. لا يحتاج التحليل الثابت لـ JavaScript إلى أي من المحركين.
يضبط Setup إعدادات الوكيل ويربطه بتثبيت موجود من Hopper أو Ghidra، ويثبّت سير عمل REA الموجّه. ويمكنه تثبيت Hopper بعد موافقتك. ولا يحتاج التحليل الثابت لتطبيقات JavaScript إلى أي محرك.
> **[زر موقع REA](https://rea.tools/)** للاطلاع على تعليمات الإعداد والأدلة المصوّرة ودراسات الحالة الفعلية.
## اطلب من وكيلك مباشرة
## البدء السريع
### إعداد وكيلك
بعد تثبيت Node.js وnpm، نفّذ:
```bash
npx rea-agents setup
```
اختر الوكلاء، وراجع التغييرات المقترحة، ثم وافق عليها. يضيف الإعداد خادم MCP الخاص بـ REA وتعليمات سير العمل المطابقة، مع الاحتفاظ بنسخ احتياطية من الإعدادات الموجودة. أعد تشغيل وكيلك بعد ذلك.
يدعم الإعداد Claude Code وCodex وCursor وGemini CLI وGrok Build وGrok Bot و[وكلاء آخرين](docs/installation.md#supported-agents). راجع [التثبيت والإعداد](docs/installation.md) لتهيئة موفّري التحليل وتسجيل MCP يدويًا.
### اطلب من وكيلك
بعد [الإعداد](#البدء-السريع)، أعد تشغيل الوكيل ثم اطلب:
```text
استقصِ طريقة عمل البحث في تطبيق Notes، واعرض الأدلة، ثم ابنِ ميزة مشابهة لمشروعي.
```
استبدل Notes بالتطبيق المستهدف وحدّد الميزة التي تريد فهمها.
استبدل Notes بالتطبيق الذي تريد فهمه، أو اطلب نظرة عامة أولًا.
### استخدام الطرفية
## من الملف التنفيذي إلى السلوك
افحص مجلد تطبيق JavaScript/Electron بعد فك حزمه، أو ملف ASAR:
| فك الترجمة | الفهم | إعادة الإنشاء |
| ---------------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------ |
| استخرج الشيفرة شبه المصدرية، والتعليمات، والسلاسل النصية، والرموز | تتبع تدفق التحكم والمراجع المتبادلة والاستدعاءات والأنواع | حوّل ما تعلمه الوكيل إلى ميزة تناسب تقنياتك وواجهتك ومتطلباتك |
يوضح REA كيف وصل إلى نتائجه. وهو لا يدّعي استعادة الشيفرة المصدرية الأصلية أو استنساخ التطبيق تلقائيًا.
## لماذا REA؟
| | |
| ------------------------ | ---------------------------------------------------------------------------- |
| **مصمم للوكلاء** | اسأل عما يفعله التطبيق ودع الوكيل يفحصه بدلًا من التخمين. |
| **CLI وMCP** | استخدم قدرات الهندسة العكسية نفسها من الطرفية أو الوكيل. |
| **إعداد موجّه** | اضبط وكيلك، واربط أداة تحليل موجودة، أو ثبّت Hopper بموافقتك. |
| **من الفهم إلى الشيفرة** | افهم الميزة ثم ابنِ نسختك الخاصة في جلسة البرمجة نفسها. |
| **محلي حسب التصميم** | يجري التحليل على مضيفك المحلي المدعوم، ولا يرفع REA التطبيق إلى خدمة مستضافة. |
| **يحافظ على السياق** | استقصِ عدة تطبيقات دون بدء التحليل من جديد عند كل سؤال. |
## البدء السريع
### تشغيل الإعداد (موصى به)
اربط REA بوكيلك:
```bash
npx rea-agents setup
```
يعرض Setup أولاً قائمة متعددة الاختيار للوكلاء الذين تريد ربطهم. تكون تسجيلات REA الموجودة محددة افتراضياً، ولا يؤدي اكتشاف عميل جديد وحده إلى تحديده؛ ويمكنك اختيار عميل غير مهيأ. راجع المسارات والتغييرات المحددة قبل الموافقة. يثبت Setup سير عمل REA للوكلاء المحددين افتراضياً. Hopper خيار منفصل يتطلب موافقة مستقلة، ويمكن أيضاً حفظ مسارات تثبيت Ghidra الموجودة.
يعرض التغييرات قبل تطبيقها وينشئ نسخة احتياطية من الإعدادات الموجودة. راجع [دليل التثبيت والإعداد](docs/installation.md) للمتطلبات والخيارات الإضافية.
### الاستخدام مع وكيل
بعد الإعداد، أعد تشغيل الوكيل واشرح التطبيق أو الميزة التي تريد استقصاءها. يدعم REA Claude Code وClaude Desktop وCodex وCursor وGemini CLI وWindsurf وDevin وOpenCode وAntigravity وGitHub Copilot CLI وCommand Code وVS Code. تكون تسجيلات REA الموجودة محددة افتراضياً، ويجب اختيار العملاء المكتشفين الآخرين يدوياً. يمكن للوكلاء الآخرين استخدام إعداد MCP أدناه.
يمكن تشغيل Hopper في الوضع التجريبي. إذا ظهرت رسالة عند التشغيل الأول، فاختر التجربة أو أدخل ترخيصًا موجودًا.
### مهارة مساعد البرمجة (اختياري)
أضف المهارة إلى مساعد البرمجة للحصول على سياق أغنى:
```bash
npx skills add morluto/rea --skill reverse-engineer-anything
```
توفر المهارة سير عمل التحقيق في REA. نفّذ الإعداد أعلاه لربط REA بالوكيل وضبط أدوات التحليل. يثبّت الإعداد افتراضيًا مهارة مطابقة للإصدار؛ وهذا الأمر يثبّت نسخة المستودع.
لشجرة JavaScript/Electron المستخرجة أو ملف ASAR، لا يلزم إعداد MCP ولا محرك أصلي:
```bash
npx -y rea-agents@latest analyze-javascript-application /absolute/path/to/app --json
```
تتضمن النتيجة الوحدات وعمليات الاستيراد وحدود Electron والأدلة المتعلقة بها. استبدل المسار بمسار هدفك، مثل `"D:/apps/example"` على Windows.
استبدل المسار بهدفك (مثل `"D:/apps/example"` على Windows). يدعم سير العمل الثابت هذه المسارات، بينما يتطلب التحليل الأصلي محركه الخاص.
لتثبيت أمر `rea` للاستخدام المعتاد:
### تثبيت أمر rea
ثبت واجهة سطر الأوامر:
```bash
curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash
```
يجب تثبيت Node.js وnpm مسبقًا. عند تشغيله من طرفية، يثبت البرنامج `rea` ويبدأ الإعداد.
يمكنك بدلًا من ذلك التثبيت باستخدام npm ثم تشغيل الإعداد:
```bash
npm install --global rea-agents
rea --help
rea setup
```
لتحليل الشيفرة الأصلية، هيّئ موفّرًا أولًا. راجع [دليل CLI وEvidence](docs/cli.md) لأوامر تحليل الشيفرة الأصلية، واختيار الموفّر، واللقطات، والاستخدام في السكربتات.
### المتطلبات
### تحديث REA
- macOS 12 أو أحدث
- Ubuntu 24.04+ أو Fedora 41+ أو Arch Linux بنواة 64 بت أو CachyOS
- Node.js 22.x (>=22.19) أو 24.x (>=24.11) أو 26+ وnpm
يتطور REA بسرعة، وتتضمن الإصدارات الجديدة إصلاحات متكررة للأخطاء. حافظ على تحديث نسختك.
يتطلب تحليل الملفات التنفيذية الأصلية Hopper أو Ghidra. Hopper برنامج منفصل، وللوضع التجريبي قيود يحددها المورّد، لكن الترخيص المدفوع ليس إلزاميًا.
لواجهة CLI المثبّتة عبر npm:
يدعم Ghidra نظامي Linux x64 وmacOS x64/arm64. ثبت Ghidra 12.1.x وJDK الكامل بنواة 64 بت الذي يعلنه ذلك التثبيت (`application.java.min` حتى `application.java.max`) بصورة منفصلة، ثم اضبط REA لاستخدامهما. تتطلب إصدارات 12.1 الحالية JDK 21 أو أحدث ولا تحدد حدًا أعلى. جرى التحقق من الجسر مع Ghidra 12.1.4 وJDK 21. يحتاج macOS أيضًا إلى أداة فك الترجمة الأصلية المطابقة لمعمارية الجهاز.
يستطيع Setup التحقق من التثبيت وحفظ المسارات؛ ولا يثبت أو يحدّث Ghidra أو Java أو Node.js أو npm أو Homebrew.
يتضمن فرع main والحزمة npm 4.1.0 دعمًا تجريبيًا لـ Windows x64 Ghidra P0 لتطبيقات PE الأصلية x86-64 (غير المُدارة وغير DLL) على NTFS محلي، مع ضوابط Job Object وقوائم DACL الخاصة وقبول المسارات المجمّعة. تحقق من [حد الإصدار](docs/installation.md#released-package-and-main) قبل توقع ذلك من حزمة npm أقدم. راجع [دليل Windows Ghidra P0](docs/windows-ghidra-p0.md) للمتطلبات والنطاق الموثق.
### تشخيص المشكلات
يشخّص `npx -y rea-agents@latest doctor` الجهاز والاعتماديات وأدوات التحليل وإعدادات الوكيل من دون تغييرها. أضف `--json` للحصول على تشخيص منظم.
على Linux، يفضّل REA الملف التنفيذي `/opt/hopper/bin/Hopper`، وإذا لم يكن متاحًا يتحقق تلقائيًا من `~/.local/share/rea/hopper/bin/Hopper`. استخدم `HOPPER_LAUNCHER_PATH` للمسارات الأخرى. إذا كان الملف موجودًا لكن محرك التحليل لا يظهر، فشغّل `ldd /absolute/path/to/Hopper | grep 'not found'` على مسار Hopper الفعلي للتحقق من المكتبات الناقصة. راجع [دليل Hopper](docs/installation.md#hopper).
### التحديث والإزالة
- يحدّث `rea update` تثبيت REA الحالي.
- يزيل `rea uninstall` تسجيلات الوكلاء وملفات سير العمل التي يديرها REA، ويترك Hopper مثبتًا.
- يزيل `rea uninstall --purge-data` أيضًا ذاكرة REA المؤقتة وحالته. استخدمه فقط إذا أردت حذف هذه البيانات.
## حالة الدعم
تُوصف قدرات المستودع الحالية ومتطلبات الأنظمة في [دليل الدعم الإنجليزي](README.md#current-status). قد يسبق فرع main [إصدار npm](docs/installation.md#released-package-and-main).
- يوفر Ghidra 25 عملية للقراءة فقط على Linux x64 وmacOS x64/arm64 وحدود Windows x64 P0 التجريبية. يدعم Linux/macOS أيضًا تعليقات الدوال الذرية على مستوى الجلسة. نظام Windows P0 للقراءة فقط، ولا يوفر Ghidra أي تحكم في الواجهة.
- يتطلب الفحص الثابت لنظام Android تجهيز JADX/Java بشكل منفصل؛ ويغطي التحقق بالموفر الحقيقي نظام Linux. راجع [تحليل Android](docs/android-analysis.md).
- تحدد طلبات المتصفح وElectron والعمليات هدفها وإجراءاتها ودورة حياتها مباشرة، وتعتمد على وصول المضيف الفعلي دون منح أذونات منفصلة من REA. ما زالت كتابة إعدادات Setup وتثبيت Hopper تتطلب الموافقة على خطتها الدقيقة.
- يصف `rea capabilities` عمليات جلسة الملف التنفيذي لا كل أدوات التطبيقات ووقت التشغيل. راجع قائمة أدوات MCP الكاملة والدليل ذا الصلة.
## استقصاء كامل بطلب واحد
بعد الإعداد، يمكنك أن تطلب من وكيلك:
> افتح التطبيق، واكتشف آلية عمل البحث من دون اتصال، واشرح تدفق التحكم، ثم ابنِ نسخة لمشروعي باستخدام TypeScript وSQLite.
يمكن للوكيل تحويل ذلك إلى سير عمل قابل للتحقق:
```text
فتح الهدف
→ انتظار اكتمال تحليل Hopper
→ جرد المعماريات والرموز والسلاسل النصية
→ تحديد نقاط الدخول والدوال المهمة
→ تتبع الاستدعاءات والمراجع المتبادلة وتدفق التحكم
→ فك ترجمة التنفيذ ذي الصلة
→ تلخيص السلوك والقيود والحالات الطرفية
→ بناء الميزة بما يلائم تقنيات المشروع ومتطلباته
```
## ما الذي يمكنك فعله؟
### فهم تطبيق لا تملك مصدره
اكشف نقاط الدخول، وأسماء الفئات، والسلاسل النصية، والأطر المستخدمة، ومسارات الميزات من الملف التنفيذي وحده.
### بناء ميزة تعجبك بطريقتك
استقصِ الميزة، وافهم سلوكها، ثم ابنِ نسخة ملائمة لمنتجك وتقنياتك ومتطلباتك.
### توثيق بروتوكول أو تنسيق غير موثق
تتبع الموزعات، والثوابت، والمقارنات، والقراءة والكتابة لفهم الرسائل وتنسيقات الملفات وآلات الحالات.
### إعادة إنشاء سلوك متوافق
حوّل منطقًا مفكوك الترجمة إلى مواصفات واضحة، ثم نفذ بديلًا نظيفًا تحكمه اختبارات السلوك الملحوظ.
### تدقيق منطق أمني حساس
افحص التحقق من الإدخال، والتشفير، والصلاحيات، والتخزين، ومسارات الأخطاء من دون رفع الملف التنفيذي إلى خدمة بعيدة.
## منصة أدوات التحقيق
| عائلة الأدوات | العدد | الاستخدام |
| ---------------------- | ----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| الفحص الأصلي | 41 | الدوال والشيفرة شبه المصدرية والتعليمات والسلاسل والرموز والاستدعاءات والمراجع والتعليقات وقراءة البايتات ومواضع الملفات |
| سير عمل التحقيق | 14 | نظرات عامة على التطبيقات وملفات الدوال وواجهات API الأصلية والتوزيع وفك الترجمة الدفعي وتتبع الميزات ومسارات الاستدعاء ومخططاته واكتشاف Swift وObjective-C |
| أدوات الملفات الثنائية | 10 | بيانات Mach-O والتوقيعات وplist والمعماريات وأسماء Swift ومراقبة الاستدعاءات عبر LLDB على macOS؛ تخطيط ELF وأدلة الحماية الثابتة وسجلات الأعطال على Linux |
| مخطط المخرجات | 7 | جرد الدلائل والحزم وملفات Interface Builder المترجمة وكتالوجات موارد Apple وتحليل تحميل Mach-O dylib والاستخراج واستدلال واجهات EVM دون اتصال |
| PE/CLI المُدار | 7 | هوية تجميعات .NET وبياناتها الوصفية وتعليمات CIL والاعتماديات الأصلية واستيراد إعادة البناء ومقارنة الإصدارات |
| البرامج الثابتة | 2 | فحص مناطق البرامج الثابتة على Linux واستخراجها الصريح |
| Android APK | 5 | بيانات الحزمة وmanifest والبحث عن الأصناف وقوائم الأعضاء وفك ترجمة الأساليب والمراجع الثابتة |
| مراقبة المتصفح | 12 | بنية الصفحات وبيانات الشبكة والبرامج النصية وخرائط المصدر واكتشاف WebMCP والصور ومقارنة الالتقاط |
| تحليل Electron | 5 | مراقبة العارضين وخريطة التطبيق الثابتة والمواءمة بين نتائج الثبات والتشغيل |
| وقت تشغيل JavaScript | 2 | اكتشاف أهداف Node/Electron Inspector ومواضع البرامج وأحداث سياقات التنفيذ |
| سير عمل التطبيقات | 14 | تصدير النصوص البرمجية الملتقطة، فحص ذاكرة dyld المشتركة، تتبع الميزات عبر الطبقات ومقارنة الإصدارات ومواءمة المصدر التاريخي ومقارنة أشكال القيم المعادة والتحقق من إعادة الإنشاء |
| مساحة العمل والملاحظة | 21 | الجلسات وحزم الأدلة وسياق التنقل ومقارنة العمليات والمخرجات والدوال وتسجيل الأسئلة غير المحسومة |
## خطة العمل
يشمل العمل التالي توسيع التحقق من الأهداف الأصلية، ومقارنة إصدارات JavaScript و.NET، ومراقبة وقت التشغيل والتحقق من إعادة الإنشاء. يتيح Setup بالفعل اختيار تكامل الوكيل وتثبيت Hopper. تثبيت أدوات تحليل أخرى ما زال عملًا لاحقًا. راجع [خطة التثبيت](docs/roadmap.md) و[تقييم أدوات التحليل](docs/provider-evaluation.md).
## استخدام REA مع وكلاء برمجة آخرين
يدعم Setup Claude Code وClaude Desktop وCodex وCursor وGemini CLI وWindsurf وDevin وOpenCode وAntigravity وGitHub Copilot CLI وCommand Code وVS Code. تكون تسجيلات REA الموجودة محددة افتراضياً، ويجب اختيار العملاء المكتشفين الآخرين يدوياً. يمكن لأي وكيل يدعم خوادم MCP المحلية الاتصال باستخدام الإعداد التالي:
<!-- x-release-please-start-version -->
```json
{
"mcpServers": {
"rea": {
"command": "npx",
"args": ["-y", "rea-agents@6.0.0", "mcp"]
}
}
}
```
<!-- x-release-please-end -->
## كيف يعمل؟
```mermaid
flowchart LR
Human[المستخدم] --> Agent[وكيل البرمجة]
Agent --> REA[REA]
Human -->|CLI| REA
REA --> Hopper[عمليات تحليل Hopper]
Hopper --> App[تطبيقك]
REA --> Ghidra[تحليل Ghidra للقراءة فقط<br/>جرد + تحليل الدوال]
Ghidra --> App
```
يستخدم CLI وخادم MCP سير عمل التحليل نفسه. تحرر أوامر الطرفية جلسة الجسر الخاصة بها عند انتهائها، بينما يمكن لجلسة الوكيل الحفاظ على الاتصال أثناء الاستقصاء. إغلاق جلسة REA لا يغلق تطبيق Hopper الذي يستخدمه المستخدم.
## أوامر CLI
سير عمل الوكيل أعلاه هو أسهل طريقة لاستخدام REA. للحصول على نظرة سريعة على تطبيق من الطرفية:
```bash
rea update
npx -y rea-agents@latest analyze /Applications/Notes.app
npx -y rea-agents@latest compare /absolute/path/to/left-evidence.json /absolute/path/to/right-evidence.json
```
لتحديث تسجيلات الوكلاء والمهارة، نفّذ أمر الإعداد الذي يعرضه التحديث.
شغّل `npx -y rea-agents@latest --help` لمعرفة خيارات فك الترجمة المباشر والخيارات الأخرى.
إذا كنت تستخدم `npx`، فحدّث إعداد وكيلك بالأمر التالي:
يمكن لـ REA فتح مجلد `.app` على Mac مباشرة. إذا لم يعثر الوكيل على التطبيق، فأخبره بمكان تثبيته.
```bash
npx rea-agents@latest setup
```
## سلوك Hopper
راجع تغييرات الإعداد وأعد تشغيل وكيلك. لأوامر CLI التي تنفّذها مرة واحدة، استخدم `npx rea-agents@latest` متبوعًا بالأمر المطلوب.
يشغّل REA برنامج Hopper عند الحاجة. على macOS قد تظهر نافذته في المقدمة أو يحتاج أحد الحوارات إلى استجابة منك. لا يمكن ضمان بقاء النافذة في الخلفية.
## كيف يعمل REA
يحدد REA تنسيق الهدف ومعماريته لتجنب حوارات الاختيار المعتادة. عند إغلاق الجلسة، يوقف الجسر وينظف موارده الخاصة، لكنه لا يغلق تطبيق Hopper الذي تستخدمه.
يستدعي وكيلك REA عبر MCP لفحص الهدف وتتبع الشيفرة ذات الصلة. يعيد REA النتائج مع أدلتها. يستخدمها الوكيل لطرح أسئلة متابعة، أو شرح السلوك، أو كتابة تنفيذ واختباره. تستخدم أوامر CLI سير العمل نفسه.
## الأمان والخصوصية
![مسار الاستقصاء في REA: يسأل وكيلك عن هدف محلي، ويفحصه REA ويتتبعه بأدوات التحليل، ثم يستخدم الوكيل الشيفرة والمراجع والجوانب غير المحسومة التي أعادتها الأدوات لشرح السلوك وتنفيذه واختباره.](website/public/assets/figures/rea-investigation-flow.svg)
[افتح الشكل بحجمه الكامل](website/public/assets/figures/rea-investigation-flow.svg).
<a id="current-status"></a>
## ما الذي يمكنك تحليله
يتطلب REA إصدار Node.js 22.x (>=22.19) أو 24.x (>=24.11) أو 26+، إضافة إلى npm. تعتمد الأدوات الإضافية والأنظمة المضيفة المدعومة على الهدف:
| الهدف | ما يعيده REA | المتطلبات والدليل |
| ------------------------------- | ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| الملفات الثنائية للشيفرة الأصلية | شيفرة شبه برمجية، وتعليمات تجميع، وسلاسل نصية، ورموز، واستدعاءات، ومراجع | Hopper أو Ghidra أو IDA؛ [تحليل الشيفرة الأصلية](https://rea.tools/guides/native/) |
| بنية ELF دون تشغيل | الأقسام والمقاطع والرموز ومعلومات إعادة التموضع الأصلية، وآليات الحماية المحتملة المستدلّ عليها بالفحص الثابت | pwntools يوفّره المستدعي على Linux x64؛ [تشخيص الملفات الثنائية](docs/binary-diagnostics.md) |
| شيفرة EVM البايتية | محددات التوجيه، وإزاحات البايتات، والمعاملات المستنتجة، وقابلية تغيير الحالة | مدخل محلي يحمل بايتات خامًا أو تمثيلًا سداسيًا عشريًا؛ [دليل EVM دون تشغيل](docs/evm-bytecode.md) |
| أعطال Linux المسجّلة | سجلات note الخام، ومسجّلات المعالج/الإشارات لكل خيط مسجّل، وترشيحات اختيارية لخرائط الذاكرة | pwntools يوفّره المستدعي؛ GDB/pwndbg اختياريان؛ [الأعطال المسجّلة](docs/recorded-crashes.md) |
| JavaScript / Electron | الوحدات، وعمليات الاستيراد، وخرائط المصدر، والمسارات، وIPC، وعلاقات إضافات الشيفرة الأصلية | Node.js وnpm؛ [تحليل التطبيقات](https://rea.tools/guides/javascript/) |
| المواقع | بنية الصفحة، والسكربتات، وملاحظات الشبكة، ولقطات الشاشة المطلوبة | متصفح من عائلة Chrome؛ [تحليل المتصفح](https://rea.tools/guides/browser/) |
| تسجيلات الشبكة المحفوظة | الطلبات، والاستجابات، ومحتويات الحمولة المتاحة، ومواضعها في المصدر | HAR؛ وmitmdump على Linux لتسجيلات mitmproxy بصيغتها الأصلية؛ [دليل تسجيلات الشبكة](docs/web-network-captures.md) |
| تجميعات .NET | البيانات الوصفية، وتعليمات CIL، واعتماديات الشيفرة الأصلية المعلنة، ومقارنات البناء | فحص ثابت؛ [دليل الشيفرة المُدارة](docs/managed-code-analysis.md) |
| حزم Android APK | تصريحات ملف manifest، والأصناف، والدوال الناتجة عن فك الترجمة، والمراجع | JADX دون واجهة رسومية وJDK كامل على Linux/macOS؛ [دليل Android](docs/android-analysis.md) |
| البرامج الثابتة | المناطق، ونتائج الاستخراج، وما يُمرّر إلى تحليل الشيفرة الأصلية | Binwalk / Unblob على Linux؛ [دليل البرامج الثابتة](docs/firmware-analysis.md) |
| الحزم والموارد | قوائم الملفات، والبصمات، وملفات plist، وبنية حزم Apple، والموارد المستخرجة | [دليل تحليل الملفات وJavaScript](docs/javascript-artifact-reconstruction.md)، [تطبيقات Apple](docs/apple-application-analysis.md) |
| سلوك العمليات | مخرجات الطرفية، والتفاعلات، وملاحظات الخروج ونظام الملفات، ومقارنات التشغيل | Linux/macOS مع PTY أصلي؛ [تسجيل سلوك العمليات](docs/process-capture.md) |
يقرأ الفحص الثابت لـ JavaScript و.NET الملفات المقدّمة دون تشغيل التطبيق. يشغّل التسجيل أثناء التنفيذ الهدف المحدد أو يتفاعل معه بصلاحيات مستخدمك؛ ويشرح كل دليل للتسجيل أثناء التنفيذ آثاره.
<a id="choosing-a-deep-analysis-provider"></a>
تختلف صيغ ملفات الشيفرة الأصلية والأنظمة المضيفة المدعومة باختلاف الموفّر. راجع [إعداد Hopper وGhidra](docs/installation.md#hopper)، و[دليل IDA](docs/ida-provider.md)، و[دعم Ghidra التجريبي على Windows](docs/windows-ghidra-p0.md). يدعم Ghidra أيضًا [تحليل DOS ذي 16 بت](docs/ghidra-dos.md). لاختيار الموفّر، راجع [دليل CLI](docs/cli.md#choose-a-provider). تحقّق من [توفر الميزات في الإصدارات](docs/installation.md#released-package-and-main) للميزات المضافة بعد أحدث إصدار على npm.
## دراسات الحالة
### DX-Ball: إعادة بناء حساب توزيع الصوت بين القناتين
تتبّع استدعاء صوت إلى الدالة المساعدة التي تحوّل الموضع إلى توزيع بين القناتين، وافحص التعليمات، وحوّل الشيفرة شبه البرمجية غير المكتملة إلى C. تجتاز إعادة البناء 3,205 حالات اختبار على x86 الأصلي، وتعيد إنتاج جميع بايتات الدالة المترجمة البالغ عددها 63 بايتًا.
[اقرأ دراسة الحالة](https://rea.tools/showcase/dx-ball/) ·
[مستودع إعادة البناء](https://github.com/N0zoM1z0/dx-ball)
### Notion: تتبّع جسر الحافظة في Electron
اعثر على API الحافظة في عملية العرض، وتتبّعه عبر preload وIPC إلى العملية الرئيسية، وافحص صيغة الحافظة ذات التنسيق الغني.
[اقرأ دراسة الحالة](https://rea.tools/showcase/notion/)
### TH04: استعادة حساب حلقة المقذوفات في DOS
افحص تعليمات اللعبة الأصلية على PC-98 ذات 16 بت، واستعد حساب الزوايا الثابتة والموجّهة نحو الهدف، وقارن شيفرة C++ المعاد بناؤها بمخرجات المترجم المستخدم آنذاك.
[اقرأ دراسة الحالة](https://rea.tools/showcase/th04/) ·
[مستودع إعادة البناء](https://github.com/N0zoM1z0/th04)
إذا استخدمت REA لفحص هدف مثير للاهتمام، فنود الاطلاع عليه. شارك حالتك في [issue](https://github.com/morluto/rea/issues) أو [pull request](https://github.com/morluto/rea/pulls)، مع ذكر الهدف، وسؤالك، وكيف ساعدك REA، وما توصلت إليه.
- يبقى التحليل محليًا بين REA وموفر التحليل المختار عبر مقابس محلية خاصة، ومع IDA عبر تسجيل MCP المحلي المضبوط.
- لا يرفع REA الأهداف إلى خدمة مستضافة.
- تعمل أدوات التحليل والأهداف المطلقة بصلاحيات المستخدم نفسه؛ وما زال التقاط واجهة النظام على macOS يعتمد على الوصول إلى تسهيلات الاستخدام وتسجيل الشاشة. ولا ينفّذ التحليل الثابت للـ JavaScript الوحدات المستخرجة.
- تستخدم جلسات Ghidra مشروعًا مؤقتًا معزولًا ولا تفتح مشاريع Ghidra التي يملكها المستخدم أو تعدّلها.
- راجع الملفات التنفيذية غير الموثوقة واعزلها كما تفعل مع أي مدخل أصلي قد يكون ضارًا.
- أبلغ عن الثغرات وفق [سياسة الأمان](SECURITY.md)، وليس عبر قضية عامة.
## الأسئلة الشائعة
<details>
<summary><strong>أي وكلاء يمكنهم استخدام REA؟</strong></summary>
<summary><strong>هل يتضمن REA محرك فك ترجمة خاصًا به؟</strong></summary>
أي وكيل يدعم خوادم MCP المحلية. يهيّئ الإعداد [الوكلاء المدعومين](docs/installation.md#supported-agents)؛ ويمكن للعملاء الآخرين استخدام [تسجيل MCP يدويًا](docs/installation.md#mcp-registry).
لا. يستطيع Setup تثبيت Hopper، لكنه يظل برنامجًا منفصلًا بترخيص خاص به. يوفر REA واجهة CLI وخادم MCP وسير العمل المخصص للوكلاء.
</details>
<details>
<summary><strong>هل أحتاج إلى Hopper أو Ghidra أو IDA؟</strong></summary>
<summary><strong>هل يجب أن يكون Hopper مفتوحًا مسبقًا؟</strong></summary>
يستخدم تحليل الشيفرة الأصلية المتعمق أحدها. يعمل الفحص الثابت لـ JavaScript و.NET دون محرك لتحليل الشيفرة الأصلية. يستطيع الإعداد تثبيت Hopper بعد الموافقة؛ أما Ghidra وIDA فيستخدمان تثبيتاتك الموجودة. راجع [إعداد الموفّرين](docs/installation.md#hopper).
لا. يستطيع REA تشغيله. لكن Hopper قد يصبح مرئيًا أو يطلب إدخالًا عند الحاجة إلى قرار تفاعلي.
</details>
<details>
<summary><strong>هل أحتاج إلى تشغيل Hopper أولًا؟</strong></summary>
<summary><strong>هل يعمل REA على Linux أو Windows؟</strong></summary>
يشغّل REA برنامج Hopper عندما تحتاج العملية إليه. على macOS، قد تظهر عند التشغيل الأول نافذة تطلب اختيار وضع العرض التجريبي أو تفعيل الترخيص. راجع [تشغيل Hopper واستكشاف الأخطاء](docs/installation.md#launcher-paths-and-troubleshooting).
يدعم REA نظام macOS 12+ وUbuntu 24.04+ وFedora 41+ وArch Linux بنواة 64 بت وCachyOS. يعمل تحليل Ghidra للقراءة فقط على Linux x64 وmacOS x64/arm64 مع Ghidra 12.1.x وJDK الكامل الذي يعلنه التثبيت (JDK 21 أو أحدث دون حد أعلى في إصدارات 12.1 الحالية)، إضافة إلى حد Windows x64 P0 التجريبي لتطبيقات PE الأصلية x86-64 على NTFS محلي؛ راجع [دليل Windows Ghidra P0](docs/windows-ghidra-p0.md) للنطاق الموثق.
</details>
<details>
<summary><strong>ماذا يفعل تثبيت المهارة من skills.sh؟</strong></summary>
<summary><strong>هل يمكنني تحليل قواعد Hopper المحفوظة؟</strong></summary>
توفّر المهارة تعليمات الاستقصاء لوكيلك. استخدم `rea setup` لتسجيل خادم MCP الخاص بـ REA وتثبيت التعليمات المطابقة، ثم أعد تشغيل وكيلك. راجع [تثبيت المهارة وحدها](docs/installation.md#skill-only-installation).
نعم. يدعم وقت التشغيل الملفات التنفيذية وقواعد `.hop`.
</details>
<details>
<summary><strong>ما الشيفرة التي يعيدها REA؟</strong></summary>
## التطوير
يعيد تحليل الشيفرة الأصلية شيفرة شبه برمجية وتعليمات تجميع. يستعيد تحليل JavaScript/Electron الوحدات والعلاقات بينها. يستخدم وكيلك هذه النتائج لكتابة تنفيذ واختباره؛ وتقدّم [دراسات الحالة](#دراسات-الحالة) أمثلة عملية.
راجع [CONTRIBUTING.md](CONTRIBUTING.md) لإعداد بيئة التطوير وبنية المشروع والاختبارات وإرشادات الإصدار.
</details>
## المشروع
<details>
<summary><strong>هل يرفع REA تطبيقي؟</strong></summary>
- [حزمة npm](https://www.npmjs.com/package/rea-agents)
- [GitHub](https://github.com/morluto/rea)
- [القضايا والطلبات](https://github.com/morluto/rea/issues)
- [Hopper Disassembler](https://www.hopperapp.com/)
- [سياسة الأمان](SECURITY.md)
يحلّل REA الأهداف محليًا. يتلقى وكيلك نتائج الأدوات، ولموفّر النموذج الذي يستخدمه سياسة بيانات خاصة به.
## تاريخ النجوم
</details>
🎉 **وصلنا إلى 20,000 نجمة على GitHub، شكرًا لكم!**
<details>
<summary><strong>ماذا أفعل إذا واجهت خطأ؟</strong></summary>
حدّث أولًا؛ فقد يكون إصدار حديث قد أصلح المشكلة بالفعل.
لواجهة CLI المثبّتة عبر npm:
```bash
rea update
```
لإعداد الوكيل عبر `npx`:
```bash
npx rea-agents@latest setup
```
إذا كنت تستخدم وكيلًا، فأكمل [تحديث الإعداد](#تحديث-rea) وأعد تشغيله. أعد محاولة المهمة نفسها. إذا استمرت المشكلة، [افتح issue](https://github.com/morluto/rea/issues) يتضمن إصدار REA، ونوع الهدف، وخطوات إعادة الإنتاج، ومخرجات الخطأ.
</details>
## التوثيق
ابدأ بـ [الأدلة العملية](https://rea.tools/guides/) على الموقع. للخيارات الدقيقة والمتطلبات وعقود النتائج:
- [التثبيت والإعداد](docs/installation.md): تسجيل الوكلاء، وتهيئة الموفّرين، والتحديثات، وإلغاء التثبيت.
- [الجاهزية واستكشاف الأخطاء](docs/installation.md#check-readiness-for-your-task): تشخيص وكيل أو محرك تحليل محدد.
- [CLI وEvidence](docs/cli.md): الأوامر، واختيار الموفّر، واللقطات، والاستيراد/التصدير، وحالات الخروج.
- [عقود MCP](docs/mcp-contracts.md) و[تعليمات الوكلاء](docs/mcp-prompts.md): نتائج الأدوات، والجلسات، والاستقصاءات الموجّهة.
- [كتالوج الأدوات](docs/mcp-contracts.md#generated-catalog): قائمة الأدوات والموفّرين وأوامر CLI المولّدة أثناء البناء.
- [خطة العمل](docs/roadmap.md): الأعمال المخطط لها ومتابعة القدرات.
أبلغ عن الثغرات وفق [SECURITY.md](SECURITY.md).
## المساهمة
نرحّب بمساعدتك في تطوير REA! [افتح issue](https://github.com/morluto/rea/issues) للإبلاغ عن خطأ أو اقتراح ميزة، أو [أرسل pull request](https://github.com/morluto/rea/pulls) لتحسين الشيفرة أو التوثيق.
راجع [CONTRIBUTING.md](CONTRIBUTING.md) لإعداد بيئة التطوير والفحوص، و[دليل الاختبار](docs/testing.md) لمسارات التحقق، و[خريطة البنية](docs/architecture.mermaid) لهيكل المشروع.
## روابط المشروع
[الموقع](https://rea.tools/) · [npm](https://www.npmjs.com/package/rea-agents) · [skills.sh](https://skills.sh/morluto/rea/reverse-engineer-anything) · [Issues](https://github.com/morluto/rea/issues) · [الأمان](SECURITY.md)
## سجل النجوم
🎉 **20,000 نجمة على GitHub — شكرًا لكم!**
شكرًا لكل من يستخدم REA، ويبلّغ عن الأخطاء، ويختبر إصدارات البناء، ويساهم بالإصلاحات.
شكرًا لكل من يستخدم REA ويبلّغ عن الأخطاء ويختبر الإصدارات ويساهم في إصلاحها.
<a href="https://www.star-history.com/?repos=morluto%2Frea&amp;type=date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=morluto/rea&amp;type=date&amp;theme=dark&amp;legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=morluto/rea&amp;type=date" />
<img alt="سجل نجوم REA على GitHub" src="https://api.star-history.com/chart?repos=morluto/rea&amp;type=date" />
<img alt="تاريخ نجوم REA على GitHub" src="https://api.star-history.com/chart?repos=morluto/rea&amp;type=date" />
</picture>
</a>
## إخلاء المسؤولية
يوفّر REA أدوات لأبحاث الهندسة العكسية والتحليل وإعادة البناء المشروعة. أنت مسؤول عن الحصول على أي تفويض مطلوب والالتزام بالقوانين المعمول بها. لا يؤيد المشروع الاستخدام غير القانوني أو غير المصرّح به.
## الترخيص
[MIT](LICENSE)
+273 -175
View File
@@ -4,33 +4,24 @@
# REA:あらゆるものをリバースエンジニアリング
### バイナリ、アプリケーション、実行時の動作を、ひとつの MCP でリバースエンジニアリング。
### バイナリ、アプリ、実行時の動作をひとつの MCP でリバースエンジニアリング。
**気に入った機能を見つけたら、その仕組みをバイナリのレベルまで理解する。**
[![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog)
[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](#調査ツールカタログ)
[![Node.js 22+](https://img.shields.io/badge/Node.js-22.19%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
[![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything)
[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE)
[![Discord](https://img.shields.io/discord/1556595354999332884?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/GkcryMnJDM)
<a href="https://trendshift.io/repositories/82054?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-82054" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/82054" alt="morluto%2Frea | Trendshift" width="250" height="55"/></a>
**[ウェブサイト](https://rea.tools/) · [ガイド](https://rea.tools/guides/) · [事例](https://rea.tools/showcase/)**
**[ウェブサイト(英語)](https://morluto.github.io/rea/) · [ガイド](https://morluto.github.io/rea/guides/) · [DX-Ball の事例](https://morluto.github.io/rea/showcase/dx-ball/)**
[クイックスタート](#クイックスタート) · [REA の仕組み](#rea-の仕組み) · [分析できる対象](#分析できる対象) · [事例](#事例) · [よくある質問](#よくある質問) · [ドキュメント](#ドキュメント)
[クイックスタート](#クイックスタート) · [現在の対応状況](#現在の対応状況) · [バイナリから動作へ](#バイナリから動作へ) · [調査ツールカタログ](#調査ツールカタログ) · [ロードマップ](#ロードマップ) · [仕組み](#仕組み)
<code>npx rea-agents setup</code>
<br />
<img src="docs/assets/rea-hopper-analysis.png" alt="REA が Hopper 内で分析ブリッジを起動し、ネイティブバイナリを調べる様子" width="1200" />
<br />
<table aria-label="REA コミュニティ">
<table aria-label="REA community">
<tr>
<td align="center" width="360">
<a href="https://discord.gg/GkcryMnJDM">
@@ -44,231 +35,342 @@
<br />
<code>npx rea-agents setup</code>
<br />
<img src="docs/assets/rea-hopper-analysis.png" alt="Hopper 内で解析ブリッジを起動し、ネイティブバイナリを調べる REA" width="1200" />
</div>
---
アプリに、自分の製品にも取り入れたい機能を見つけましたか?エージェントに REA で調査を依頼してください。ソースコードがなくてもアプリを調べ、機能の仕組みを説明し、根拠を示したうえで、あなたのプロジェクト向けに実装できます。
自分のプロダクトにも欲しい機能を他のアプリで見つけたら、REA を使ってエージェントに調査を依頼できます。エージェントはソースコードがなくてもアプリを調べ、機能の仕組みを説明して根拠を示し、あなたのプロジェクト向けの実装まで進められます。
REA はエージェントを分析ツールにつなぎ、ネイティブバイナリ、JavaScript や Electron のアプリ、.NET アセンブリ、ウェブサイトを調べられるようにします。同じツールをターミナルからも使えます。分析はローカルで実行され、結果には各結論を裏付ける根拠と制限事項が含まれます。
REA は、ネイティブバイナリ、JavaScript/Electron アプリ、.NET アセンブリ、Web サイトを調べるためのツールをエージェントに提供します。同じツールはターミナルからも使えます。解析はローカルで実行され、結果には各結論の根拠と制限が含まれます。
セットアップはエージェントに REA を登録し、対応するワークフローの手順をインストールします。ネイティブ分析には既存の Hopper または Ghidra を使えます。承認すれば、セットアップで Hopper をインストールすることもできます。静的な JavaScript 分析には、どちらのエンジンも必要ありません。
セットアップは REA をエージェントに登録し、対応するワークフロー手順もインストールします。ネイティブ解析には既存の Hopper または Ghidra を使えます。承認すれば、セットアップで Hopper をインストールすることもできます。静的な JavaScript 解析にはどちらのエンジンも不要です。
> **[REA のウェブサイト](https://rea.tools/)**で、セットアップ手順、図解ガイド、実際の事例を確認できます。
## エージェントに頼むだけ
[セットアップ](#クイックスタート)後にエージェントを再起動し、次のように依頼します:
```text
メモアプリの検索機能を調べて根拠を示し、私のプロジェクト向けに同様の機能を実装してください。
```
「メモアプリ」を調べたいアプリに置き換えてください。まずは概要の調査を依頼することもできます。
## バイナリから動作へ
| 逆コンパイル | 理解 | 再現 |
| -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| ネイティブアプリや実行ファイルから、プロシージャ、疑似コード、アセンブリ、文字列、シンボル、セグメント、メタデータを復元します。 | 呼び出し元、呼び出し先、クロスリファレンス、コールグラフをたどり、機能やアルゴリズムの実際の動作を説明できる状態にします。 | エージェントが得た知見を、あなたの技術スタック、画面、要件に合うプロダクト機能へ変えます。 |
REA は結論に至った根拠を示します。元のソースコードを復元できる、あるいはアプリ全体を自動的に複製できるとは主張しません。
## REA を選ぶ理由
| | |
| -------------------------- | -------------------------------------------------------------------------------------------------------- |
| **エージェント向け** | アプリの動作を質問すると、エージェントが推測ではなく実際に調べて答えます。 |
| **CLI と MCP** | 同じリバースエンジニアリング機能を、ターミナルからもエージェントからも使えます。 |
| **ガイド付きセットアップ** | エージェントの設定、既存の解析ツールへの接続、承認したうえでの Hopper のインストールに対応します。 |
| **洞察からコードへ** | 機能を理解したら、同じコーディングセッションで自分のプロダクト向けに実装できます。 |
| **ローカルで解析** | 解析は対応するローカルホストで実行されます。REA がアプリをホスト型の解析サービスへ送ることはありません。 |
| **コンテキストを維持** | 質問のたびに解析をやり直すことなく、複数のアプリを続けて調査できます。 |
## クイックスタート
### エージェントを設定する
### セットアップを実行(推奨)
Node.js と npm がインストールされている環境で実行します。
REA をエージェントで使えるように設定します:
```bash
npx rea-agents setup
```
使うエージェントを選び、予定されている変更を確認して承認してください。セットアップは REA の MCP サーバーと対応するワークフローの手順を追加し、既存の設定をバックアップします。完了後にエージェントを再起動してください。
セットアップでは、まず REA を使うエージェントを選び(複数選択可)、具体的なパスと変更内容を確認してから承認します。既存の REA 登録は最初から選択されています。新しく検出されたエージェントは選択肢に表示されますが、検出されただけでは選択されません。選択したエージェントには、MCP 接続と REA のガイド付きワークフローが追加されます。Hopper のインストールは別の任意項目で、個別に承認が必要です。既存の Ghidra のインストール先を登録することもできます。
セットアップは Claude Code、Codex、Cursor、Gemini CLI、Grok Build、Grok Bot、および[その他のエージェント](docs/installation.md#supported-agents)に対応しています。プロバイダーの設定と MCP の手動登録については、[インストールとセットアップ](docs/installation.md)を参照してください。
セットアップは変更を適用する前に内容を表示し、既存の設定をバックアップします。要件とセットアップのオプションは[インストールガイド](docs/installation.md)を参照してください。
### エージェントに依頼する
### エージェントで使う(推奨)
```text
Notes アプリの検索機能の仕組みを調べ、根拠を示して、私のプロジェクトにも同様の機能を実装してください。
セットアップ後にエージェントを再起動し、理解したいアプリや機能を[説明してください](#エージェントに頼むだけ)。Hopper はデモモードでも動作します。初回起動の画面が表示されたら、デモを選ぶか既存のライセンスを入力してください。
REA は Claude Code、Claude Desktop、Codex、Cursor、Gemini CLI、Windsurf、Devin、OpenCode、Antigravity、GitHub Copilot CLI、Command Code、VS Code に対応しています。セットアップでは既存の REA 登録が最初から選択され、それ以外の検出済みエージェントは選択するまで対象になりません。その他のエージェントは[手動の MCP 設定](#他のコーディングエージェントで使う)を使えます。
### AI コーディングアシスタント向けスキル(任意)
AI コーディングアシスタントにスキルを追加すると、より豊富なコンテキストを利用できます:
```bash
npx skills add morluto/rea --skill reverse-engineer-anything
```
Notes を対象のアプリに置き換え、理解したい機能を指定してください。
このスキルは REA の調査ワークフローを提供します。REA をエージェントに接続して解析ツールを設定するには、上記のセットアップを実行してください。セットアップは既定でバージョンの一致したスキルをインストールします。このコマンドはリポジトリ版のスキルをインストールします。
### ターミナルを使う
### ターミナルで最初の結果を得る
展開済みの JavaScript/Electron アプリのディレクトリ、または ASAR を調べます。
展開済みの JavaScript/Electron アプリのディレクトリまたは ASAR に対して、次を実行します:
```bash
npx -y rea-agents@latest analyze-javascript-application /absolute/path/to/app --json
```
結果にはモジュール、インポート、Electron の境界と、それらの根拠が含まれます。パスは対象に合わせて変更してください。Windows では、たとえば `"D:/apps/example"` を指定します。
パスは調査対象に置き換えてください(Windows では `"D:/apps/example"` など)。MCP の設定、Hopper、Ghidra を必要とせず、アプリを実行することもなく、Evidence、復元したグラフ、制限、未解決事項をその場で返します。ネイティブアプリの場合は、先にエンジンを設定してから、そのアプリのパスを指定して `analyze` を使います。`doctor` は問題を診断したいときに使うもので、解析のたびに実行する必要はありません。
日常的に使う場合は、`rea` コマンドをインストールできます。
### 作業に合わせて準備状況を確認する
オプションなしの `rea doctor` は連携全体の監査です。検出したすべてのエージェント登録、インストール済みのスキル、すべての任意の解析エンジンを確認するため、今の作業には支障がなくても `healthy: false` を報告することがあります。作業に合わせて確認範囲を指定してください:
| 作業 | 準備状況の確認 |
| ------------------------------------------ | ---------------------------------------------------------------------------------------------- |
| 静的な JavaScript/Electron 解析 | 不要です。`analyze-javascript-application` を直接実行します。 |
| 特定の解析エンジンのトラブルシューティング | `rea doctor --provider ghidra --json`(または `hopper`、`ida`) |
| 特定のエージェントの MCP 登録を確認 | `rea doctor --client codex --json`([クライアント ID](docs/installation.md#supported-agents)) |
| インストール済みのスキルを確認 | `rea doctor --skill --json` |
範囲を指定したレポートでは、`healthy` と終了ステータスを決めるのは `scope_checks` だけです。それ以外の項目は `informational_checks` に表示され、今の作業のために修正する必要はありません。たとえば、使っていないエンジンが見つからなくても対処は不要です。
ネイティブ対象に対応するエンジンが複数インストールされている場合、REA は自動ではどれも選びません。対象を開くと `code: "capability_unavailable"` と `details.selection_reason: "ambiguous"` で失敗し、`details.candidate_ids` に候補が示されます。CLI の `--provider` か `open_binary` の `provider_id` で一度選ぶか、常用する設定として `REA_ANALYSIS_PROVIDER` を指定してください。詳しくは[英語版の解説](README.md#choosing-a-deep-analysis-provider)を参照してください。
### rea コマンドをインストール
コマンドラインツールをインストールします:
```bash
curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash
```
Node.js と npm を先にインストールしておいてください。ターミナルで実行すると、インストーラーは `rea` を追加してセットアップを開始します。
npm でインストールしてから、セットアップを実行することもできます:
```bash
npm install --global rea-agents
rea setup
```
### 要件
静的な JavaScript 解析に必要なのは Node.js と npm だけです。ホストと外部ツールの前提条件は使うワークフローによって異なり、ネイティブ解析プロバイダーの対応プラットフォームは各ガイドで説明しています。
- macOS 12 以降
- Ubuntu 24.04+、Fedora 41+、64 ビット Arch Linux、または CachyOS
- Node.js 22.x (>=22.19)、24.x (>=24.11)、または 26+
- npm(特定のバージョンは不要で、REA が npm をインストールすることもありません)
ネイティブバイナリの詳細な解析には Hopper、Ghidra、IDA Pro のいずれかが必要です。Hopper は独自のライセンスを持つ別製品で、デモ版でもベンダーが定める制限の範囲で解析できます。Ghidra と IDA は、利用者が用意したものを使います。
Ghidra は Linux x64 と macOS x64/arm64 に対応しています。Ghidra 12.1.x と、そのインストールが宣言する 64 ビットの完全な JDK(`application.java.min` から `application.java.max` まで)を別途インストールし、REA で使うように設定してください。現行の 12.1 リリースは JDK 21 以降を必要とし、上限はありません。ブリッジは Ghidra 12.1.4 と JDK 21 で検証しています。macOS では、ホストのアーキテクチャに合うネイティブデコンパイラーも必要です。
```bash
export GHIDRA_INSTALL_DIR=/absolute/path/to/ghidra_12.1.4_PUBLIC
export JAVA_HOME=/absolute/path/to/jdk-21 # java と javac が PATH から解決できる場合は省略可
rea doctor --provider ghidra --json
rea setup
```
セットアップはこれらのインストールを確認し、承認後にパスを保存します。Ghidra、Java、Node.js、npm、Homebrew のインストールや更新は行いません。
IDA Pro は、すでに動作している [mrexodia/ida-pro-mcp](https://github.com/mrexodia/ida-pro-mcp) の MCP 登録を再利用します。開いている GUI の対象を読み取り専用で解析するか(`attached`、既定)、データベースのスーパーバイザーで指定したバイナリをヘッドレスで開いて解析します(`headless`)。REA が attached の GUI データベースを保存したり閉じたりすることはありません。最初の実環境での検証は、Windows の GUI と Windows x64 のヘッドレス IDA 9.3 が対象です。設定方法と対応範囲は [IDA プロバイダーガイド](docs/ida-provider.md)を参照してください。
```bash
export REA_IDA_MCP_CONFIG=/absolute/path/to/ida-mcp.json
rea function /absolute/path/to/program main --provider ida --json
```
リポジトリの main と npm 4.1.0 には、ローカル NTFS 上のネイティブ x86-64 PE アプリケーション(マネージドコードと DLL を除く)を対象とする、実験的な Windows x64 Ghidra P0 対応が含まれています。Job Object、プライベート DACL、パスの受け入れ検査の各制御も同梱されています。古い npm パッケージでこの機能を使えるか確認するには[リリース境界](docs/installation.md#released-package-and-main)を、前提条件と検証済みの範囲は [Windows Ghidra P0](docs/windows-ghidra-p0.md) を参照してください。
### トラブルシューティング
`npx -y rea-agents@latest doctor` は、ホスト、依存関係、解析ツール、エージェント設定を変更せずに確認します。構造化された診断結果が必要な場合は `--json` を追加してください。
Linux では実行可能な `/opt/hopper/bin/Hopper` を優先し、利用できない場合は `~/.local/share/rea/hopper/bin/Hopper` を自動的に確認します。それ以外の場所にインストールした場合は `HOPPER_LAUNCHER_PATH` を設定してください。ファイルがあるのに解析エンジンが見つからないと報告される場合は、実際の Hopper のパスに対して `ldd /absolute/path/to/Hopper | grep 'not found'` を実行し、不足しているライブラリを確認してください。詳しくは [Hopper ガイド](docs/installation.md#hopper)を参照してください。
### 更新とアンインストール
- `rea update` は現在の REA インストールを更新します。
- `rea uninstall` は REA が管理するエージェント登録とスキルだけを削除します。Hopper、Node.js、Evidence ファイル、キャプチャ、無関係なスキル、他の MCP サーバーは残ります。
- `rea uninstall --purge-data` は `~/.rea/cache` と `~/.rea/state` も削除します。それらを消したい場合にだけ使ってください。
## 現在の対応状況
リポジトリの現在の機能とプラットフォーム要件は[英語の対応状況ガイド](README.md#current-status)で説明しています。main は [npm リリース](docs/installation.md#released-package-and-main)より先行する場合があります。
- Ghidra は Linux x64、macOS x64/arm64、実験的な Windows x64 P0 境界で 25 種類の読み取り専用操作を提供します。Linux と macOS では、セッション内での関数名とエントリーコメントのアトミックな編集にも対応します。Windows P0 には変更の権限がなく、Ghidra には REA から操作できる GUI 機能はありません。
- IDA アダプターは、ドキュメントに記載された読み取り専用の関数・文字列操作を提供します。
- 静的な Android APK の検査は Linux と macOS に対応し、別途用意したヘッドレス JADX と完全な JDK が必要です。現在のメタデータブリッジは macOS arm64 で検証しています。詳しくは [Android 解析](docs/android-analysis.md)を参照してください。
- ブラウザー、Electron、プロセスの各リクエストは対象、操作、ライフサイクルを直接指定し、ホストで実際に許可されたアクセスの範囲で動作します。REA 側で別途権限を付与する必要はありません。セットアップによる設定の書き込みと Hopper のインストールには、引き続き具体的な計画の承認が必要です。
- `rea capabilities` と `rea providers` はバイナリセッションのプロバイダーと補助操作を説明するもので、ブラウザー、Android、アプリのすべてのワークフローの一覧ではありません。MCP の機能全体は、接続中の MCP ツール一覧と `binary_session` が示すツールの利用可否で確認してください。
## ひとつのプロンプトで調査を完結
```text
メモアプリをリバースエンジニアリングし、オフライン検索機能の仕組みを説明して、
TypeScript と SQLite を使って私のプロジェクト向けに実装してください。
```
| 手順 | エージェントの処理 | REA ツール |
| ---: | ------------------------------ | ---------------------------------------------------------------- |
| 1 | バイナリを開いて識別 | `open_binary`, `binary_overview` |
| 2 | オフライン検索の手掛かりを探す | `search_strings`, `search_procedures`, `list_names` |
| 3 | 手掛かりを実行コードに結び付け | `find_xrefs_to_name`, `xrefs`, `procedure_callers` |
| 4 | 関連する制御フローを復元 | `get_call_graph`, `procedure_callees`, `procedure_info` |
| 5 | 関連する処理を逆コンパイル | `procedure_pseudo_code`, `procedure_assembly`, `batch_decompile` |
| 6 | プロジェクトに機能を実装 | 技術スタック、プロダクト、要件に合わせたコード |
手順 1〜5 のアプリ解析は REA が担当します。手順 6 は、アプリについて得た知見をもとに、エージェントが通常の編集ツールとテストツールで行います。
## エージェントにできること
- 気に入った機能を調査し、自分のプロダクトに合わせた形で実装する。
- ソースコードが手に入らない機能の仕組みを説明する。
- アプリの認証、保存、更新、ネットワーク処理の流れを復元する。
- 文書化されていない形式やインターフェースについて、文書化に必要な構造を復元する。
- 文字列やシンボルから、疑わしい動作を実装しているコードまでたどる。
- 復元した動作を、プロダクト機能、テスト、移行メモ、移植版、相互運用できる代替実装に変える。
- マングルされたシンボルをひとつずつ手作業で読み解かなくても、Swift と Objective-C のメタデータを解析する。
- Hopper に名前、コメント、ブックマークを残し、人とエージェントの解析を互いに活かす。
## 調査ツールカタログ
| ツール分類 | 数 | 用途 |
| -------------------- | --: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| ネイティブ検査 | 41 | 関数、疑似コード、アセンブリ、文字列、シンボル、呼び出し、参照、注釈、バイト読み取り、ファイルオフセット |
| 調査ワークフロー | 14 | アプリ概要、関数ドシエ、ネイティブ API とディスパッチ、一括逆コンパイル、機能トレース、コールパス、コールグラフ、Swift と Objective-C の検出 |
| ネイティブバイナリ | 10 | macOS の Mach-O メタデータ、署名、plist、アーキテクチャ、Swift シンボル、LLDB による呼び出し観測;Linux の ELF レイアウト、静的な保護機構の証拠と記録済みクラッシュ |
| 成果物グラフ | 7 | ディレクトリとパッケージの目録、コンパイル済み Interface Builder ファイル、Apple アセットカタログ、Mach-O dylib のロード解決、抽出、オフライン EVM インターフェース推定 |
| マネージド PE/CLI | 7 | .NET の識別情報、メタデータ、CIL 命令、ネイティブ依存関係、再構築結果のインポート、ビルド比較 |
| ファームウェア | 2 | Linux ファームウェア領域の検査と明示的な展開 |
| Android APK | 5 | パッケージと manifest の宣言、クラス検索、メンバー一覧、メソッドの逆コンパイル、静的な被参照 |
| ブラウザー観察 | 12 | ページ構成、ネットワークメタデータ、スクリプト、ソースマップ、WebMCP の検出、スクリーンショット、キャプチャ比較 |
| Electron 解析 | 5 | レンダラーの観察、静的なアプリのマッピング、静的結果と実行時結果の照合 |
| JavaScript 実行時 | 2 | Node/Electron Inspector の対象検出、スクリプトの位置、実行コンテキストのイベント |
| アプリワークフロー | 14 | 取得した Web スクリプトのエクスポート、Android/Apple の目録の投影、dyld 共有キャッシュの検査、層をまたぐ機能トレース、ビルド比較、過去のソースとの対応付け、静的な戻り値形状の比較、再構築の検証 |
| ワークスペースと観察 | 21 | セッション、Evidence バンドル、ナビゲーションの文脈、プロセス・成果物・関数の比較、未解決事項の記録 |
## ロードマップ
[現在の対応状況](#現在の対応状況)は提供済みの機能です。今後の作業は次のとおりです。
- **現在:** ツール、プロバイダー、セットアップの選択肢、バージョンの変更に合わせてドキュメントを正確に保ち、Hopper と Ghidra で検証するネイティブバイナリのアーキテクチャと間接呼び出しの範囲を広げます。
- **次:** 機能トレースに静的抽出と実行時の観察を追加し、難読化された .NET アセンブリの比較を改善し、プロセス、プロトコル、ファイルシステム、再接続、バージョン比較の範囲を広げます。
- **その後:** ブラウザーと Electron のシナリオ操作を増やし、LLDB や Frida などによるネイティブアプリの実行時観察と、他の解析ツールや対象の評価を進めます。
セットアップでは、すでにエージェント連携と Hopper のインストールを選べます。その他の解析ツールのインストールへの対応は今後の作業です。[インストールのロードマップ](docs/roadmap.md)と[解析ツールの評価](docs/provider-evaluation.md)を参照してください。
## 他のコーディングエージェントで使う
セットアップは Claude Code、Claude Desktop、Codex、Cursor、Gemini CLI、Windsurf、Devin、OpenCode、Antigravity、GitHub Copilot CLI、Command Code、VS Code に対応しています。既存の REA 登録は最初から選択され、それ以外の検出済みエージェントは選択するまで対象になりません。ローカル MCP サーバーに対応するエージェントであれば、次の設定でも接続できます。
<!-- x-release-please-start-version -->
```json
{
"mcpServers": {
"rea": {
"command": "npx",
"args": ["-y", "rea-agents@6.0.0", "mcp"]
}
}
}
```
<!-- x-release-please-end -->
常用する登録では、パッケージのバージョンを 1 つに固定してください。`rea setup` はその固定バージョンを維持し、同梱のスキルも同時に更新します。
## 仕組み
```mermaid
flowchart LR
Agent["コーディングエージェント"] --> REA["REA<br/>CLI + MCP"]
Terminal["ターミナル"] --> REA
REA --> Hopper["Hopper プロバイダー"]
REA --> Ghidra["Ghidra プロバイダー<br/>インベントリ + 関数解析 + 注釈"]
REA --> Ida["IDA MCP プロバイダー<br/>attached GUI または headless データベース"]
Hopper --> App["あなたのアプリ"]
Ghidra --> App
Ida --> App
```
CLI と MCP サーバーは、同じアプリケーションワークフローと Evidence の契約を使います。ターミナルのコマンドは完了すると自身のブリッジセッションを解放し、エージェントのセッションは調査中の対象と Evidence を保持できます。REA のセッションを閉じても、利用中の Hopper アプリは終了しません。
## CLI
上のエージェントワークフローが、REA を使う最も簡単な方法です。ターミナルからアプリの概要を一度だけ調べる場合は、次を実行します。
```bash
npx -y rea-agents@latest analyze /Applications/Notes.app
```
直接の逆コンパイルやその他のオプションは、`npx -y rea-agents@latest --help` で確認できます。
グローバルな `rea` コマンドとしてもインストールできます。
```bash
npm install --global rea-agents
rea --help
rea mcp
```
ネイティブ分析には、先にプロバイダーの設定が必要です。ネイティブ分析のコマンド、プロバイダーの選択、スナップショット、スクリプトでの利用は、[CLI と Evidence のガイド](docs/cli.md)を参照してください。
REA は Mac の `.app` フォルダーを直接開けます。エージェントがアプリを名前で見つけられない場合は、インストール場所を伝えてください。
### REA を更新する
## Hopper アプリの動作
REA は更新が速く、新しいリリースには頻繁にバグ修正が含まれます。インストールしたバージョンを最新に保ってください。
REA は操作に必要になった時点で Hopper を起動します。Hopper のランチャーは内部でアプリをアクティブにするため、REA がバックグラウンドでの起動を要求しても、macOS では Hopper のウィンドウやダイアログが前面に出る場合があります。デモやライセンスの画面では操作が必要になることがあります。
npm でインストールした CLI の場合:
Hopper は解析リクエストを一度に 1 つずつ処理します。待機をキャンセルしても、Hopper 内ですでに実行中の処理は止まりません。処理中かどうかはセッションが報告します。セッションを閉じると REA のブリッジと一時ソケットディレクトリを削除しますが、利用中の Hopper アプリは終了しません。後片付けを確認できなかった場合、`close_binary` は `cleanup_incomplete` と対象のリソースを報告します。
```bash
rea update
```
## セキュリティモデル
エージェントの登録とスキルを更新するには、更新処理が表示するセットアップコマンドを実行してください。
解析はローカルで実行されます。REA は Hopper や Ghidra とは認証付きのプライベートなローカルソケットで、IDA とは設定済みのローカル MCP 登録を通じて通信します。Ghidra セッションは隔離された一時プロジェクトを使い、ユーザーの Ghidra プロジェクトを開いたり変更したりしません。
`npx` を使っている場合は、次のコマンドでエージェントの設定を更新します。
これはサンドボックスではありません。解析ツールと起動した対象は現在のユーザー権限で動作し、同じ OS ユーザー権限で動く悪意のあるプロセスは防げません。Windows Ghidra P0 では、ネイティブの Job Object、保護されたプライベート DACL、ハンドルベースの受け入れ検査を自動的に使います。脆弱性は [SECURITY.md](SECURITY.md) に記載された非公開の手順で報告してください。
```bash
npx rea-agents@latest setup
```
## FAQ
設定の変更を確認し、エージェントを再起動してください。単発の CLI 操作では、`npx rea-agents@latest` に続けてコマンドを指定します。
<details><summary><strong>Hopper を先に起動しておく必要はありますか?</strong></summary>
## REA の仕組み
エージェントは MCP 経由で REA を呼び出し、対象を調べて関連するコードを追跡します。REA は調査結果と根拠を返します。エージェントはそれを使って追加の質問をしたり、動作を説明したり、実装を書いてテストしたりします。CLI コマンドも同じワークフローを使います。
![REA の調査フロー:エージェントがローカルの対象について質問し、REA が分析ツールで調査・追跡します。エージェントは返されたコード、参照、未解明の点を使い、説明、実装、テストを行います。](website/public/assets/figures/rea-investigation-flow.svg)
[原寸大の図を開く](website/public/assets/figures/rea-investigation-flow.svg)。
<a id="current-status"></a>
## 分析できる対象
REA には Node.js 22.x(>=22.19)、24.x(>=24.11)、または 26+ と npm が必要です。追加のツールや対応ホストは、対象によって異なります。
| 対象 | REA が返すもの | 要件とガイド |
| -------------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| ネイティブバイナリ | 疑似コード、アセンブリ、文字列、シンボル、呼び出し、参照 | Hopper、Ghidra、または IDA;[ネイティブ分析](https://rea.tools/guides/native/) |
| オフライン ELF レイアウト | セクション、セグメント、元のシンボル/再配置情報、静的解析によるセキュリティ対策の候補 | Linux x64 で利用者が用意する pwntools;[バイナリ診断](docs/binary-diagnostics.md) |
| EVM バイトコード | ディスパッチセレクター、バイトオフセット、推定された引数と状態変更の可否 | ローカルの生バイト/16 進数の入力ファイル;[オフライン EVM ガイド](docs/evm-bytecode.md) |
| 記録済みの Linux クラッシュ | 生の note レコード、記録された全スレッドのレジスター/シグナル、任意のマッピング候補 | 利用者が用意する pwntools;GDB/pwndbg は任意;[記録済みクラッシュ](docs/recorded-crashes.md) |
| JavaScript / Electron | モジュール、インポート、ソースマップ、ルート、IPC、ネイティブアドオンの関係 | Node.js と npm;[アプリケーション分析](https://rea.tools/guides/javascript/) |
| ウェブサイト | ページ構造、スクリプト、ネットワークの観測結果、依頼されたスクリーンショット | Chrome 系ブラウザー;[ブラウザー分析](https://rea.tools/guides/browser/) |
| 保存済みのネットワークキャプチャ | リクエスト、レスポンス、取得可能なペイロード、ソース内の位置 | HAR;mitmproxy ネイティブ形式のキャプチャには Linux 上の mitmdump;[キャプチャガイド](docs/web-network-captures.md) |
| .NET アセンブリ | メタデータ、CIL 命令、宣言されたネイティブ依存関係、ビルドの比較 | 静的な検査;[マネージドコードのガイド](docs/managed-code-analysis.md) |
| Android APK | マニフェストの宣言、クラス、逆コンパイルされたメソッド、参照 | Linux/macOS 上のヘッドレス JADX と完全な JDK;[Android ガイド](docs/android-analysis.md) |
| ファームウェア | 領域、抽出結果、ネイティブ分析への引き継ぎ | Linux 上の Binwalk / Unblob;[ファームウェアガイド](docs/firmware-analysis.md) |
| パッケージとリソース | ファイル一覧、ダイジェスト、plist、Apple バンドルの構造、抽出されたリソース | [アーティファクトと JavaScript のガイド](docs/javascript-artifact-reconstruction.md)、[Apple アプリケーション](docs/apple-application-analysis.md) |
| プロセスの動作 | ターミナル出力、対話、終了とファイルシステムの観測結果、実行の比較 | ネイティブ PTY を備えた Linux/macOS;[プロセスキャプチャ](docs/process-capture.md) |
静的な JavaScript と .NET の検査は、指定されたファイルを読み取り、アプリケーションを実行しません。実行時キャプチャは、あなたのユーザー権限で対象を実行したり、対象とやり取りしたりします。それぞれの実行時ガイドに、どのような操作が行われるかを記載しています。
<a id="choosing-a-deep-analysis-provider"></a>
対応するネイティブ形式とホストは、プロバイダーによって異なります。[Hopper と Ghidra のセットアップ](docs/installation.md#hopper)、[IDA ガイド](docs/ida-provider.md)、[Windows 向け Ghidra の実験的対応](docs/windows-ghidra-p0.md)を参照してください。Ghidra は [16 ビット DOS 分析](docs/ghidra-dos.md)にも対応しています。プロバイダーの選択は [CLI ガイド](docs/cli.md#choose-a-provider)を参照してください。最新の npm リリース以降に追加された機能については、[リリースでの利用可否](docs/installation.md#released-package-and-main)を確認してください。
## 事例
### DX-Ball:音声のパン計算を再構築する
音声の呼び出しから位置をパンに変換する補助関数をたどり、命令を調べ、不完全な疑似コードを C に書き直します。再構築した実装は、元の x86 に対する 3,205 ケースの検証を通過し、コンパイル後の関数の全 63 バイトを再現しています。
[事例を読む](https://rea.tools/showcase/dx-ball/) ·
[再構築リポジトリ](https://github.com/N0zoM1z0/dx-ball)
### Notion:Electron のクリップボードブリッジを追跡する
レンダラーのクリップボード API を見つけ、preload と IPC を経由してメインプロセスまでたどり、リッチなクリップボード形式を調べます。
[事例を読む](https://rea.tools/showcase/notion/)
### TH04:DOS の弾幕リングの計算を復元する
元の PC-98 ゲームの 16 ビット命令を調べ、固定角度と自機を狙う角度の計算を復元し、再構築した C++ を当時のコンパイラーの出力と比較します。
[事例を読む](https://rea.tools/showcase/th04/) ·
[再構築リポジトリ](https://github.com/N0zoM1z0/th04)
REA で興味深い対象を調べたら、ぜひ共有してください。[issue](https://github.com/morluto/rea/issues) または [pull request](https://github.com/morluto/rea/pulls) に、対象、調べたかったこと、REA がどう役立ったか、何が分かったかを記載してください。
## よくある質問
<details>
<summary><strong>どのエージェントで REA を使えますか?</strong></summary>
ローカル MCP サーバーに対応するエージェントなら使えます。セットアップは[対応エージェント](docs/installation.md#supported-agents)を設定します。その他のクライアントでは、[MCP の手動登録](docs/installation.md#mcp-registry)を利用できます。
いいえ。REA は必要になった時点で Hopper を起動します。すでに起動している Hopper でも使えますが、既存の GUI ドキュメントには接続せず、新しい解析ドキュメントを開きます。
</details>
<details>
<summary><strong>Hopper、Ghidra、IDA は必要ですか?</strong></summary>
<details><summary><strong>REA に Hopper は含まれていますか?</strong></summary>
深いネイティブ分析には、そのいずれかを使います。静的な JavaScript と .NET の検査には、ネイティブ分析エンジンは必要ありません。セットアップは承認後に Hopper をインストールできます。Ghidra と IDA は既存のインストールを使います。[プロバイダーのセットアップ](docs/installation.md#hopper)を参照してください。
含まれていません。セットアップで Hopper をインストールできますが、Hopper は独自のライセンスを持つ別製品です。REA は、エージェントが Hopper を使えるようにする CLI、MCP サーバー、ワークフローを提供します。
</details>
<details>
<summary><strong>先に Hopper を起動する必要はありますか?</strong></summary>
<details><summary><strong>REA は Ghidra や Java をインストールしたり同梱したりしますか?</strong></summary>
REA は必要な操作の際に Hopper を起動します。macOS では、初回起動時にデモモードを選ぶか、ライセンスを有効化するよう求められることがあります。[Hopper の起動とトラブルシューティング](docs/installation.md#launcher-paths-and-troubleshooting)を参照してください。
いいえ。REA は既存の Ghidra のインストールに接続します。[要件](#要件)にある Ghidra と Java のパスを設定してから、セットアップを実行してください。
</details>
<details>
<summary><strong>skills.sh からスキルをインストールすると何ができますか?</strong></summary>
<details><summary><strong>アプリはアップロードされますか?</strong></summary>
スキルはエージェントに調査の手順を提供します。`rea setup` で REA の MCP サーバーを登録し、対応する手順をインストールしてから、エージェントを再起動してください。[スキルのみのインストール](docs/installation.md#skill-only-installation)を参照してください。
REA にホスト型の解析サービスはありません。現在のプロバイダーは、成果物の解析と動作のキャプチャをローカルで行います。エージェントやモデルの提供元には独自のデータポリシーがあるため、別途確認してください。
</details>
<details>
<summary><strong>REA はどのようなコードを返しますか?</strong></summary>
<details><summary><strong>元のソースコードを復元できますか?</strong></summary>
ネイティブ分析は疑似コードとアセンブリを返します。JavaScript/Electron 分析はモジュールとその関係を復元します。エージェントはその結果を使って実装を書き、テストします。[事例](#事例)に具体的な手順があります。
元のソースコードを確実に復元できるデコンパイラーはありません。REA はエージェントに疑似コード、アセンブリ、シンボル、文字列、メタデータ、それらの関係を提供し、観察された動作の説明や互換性のある再現に役立てられるようにします。
</details>
<details>
<summary><strong>REA はアプリをアップロードしますか?</strong></summary>
## 開発
REA は対象をローカルで分析します。エージェントはツールの結果を受け取り、そのモデルプロバイダーには独自のデータポリシーがあります。
</details>
<details>
<summary><strong>バグに遭遇したらどうすればよいですか?</strong></summary>
まず更新してください。最近のリリースですでに修正されている可能性があります。
npm でインストールした CLI の場合:
```bash
rea update
```
`npx` でエージェントを設定している場合:
```bash
npx rea-agents@latest setup
```
エージェントを使っている場合は、[設定の更新](#rea-を更新する)を完了し、再起動してください。同じタスクをもう一度試し、問題が続く場合は [issue を作成](https://github.com/morluto/rea/issues)して、REA のバージョン、対象の種類、再現手順、エラー出力を記載してください。
</details>
## ドキュメント
まずはウェブサイトの[実践ガイド](https://rea.tools/guides/)を参照してください。正確なオプション、前提条件、結果の契約については、以下を参照してください。
- [インストールとセットアップ](docs/installation.md):エージェントの登録、プロバイダーの設定、更新、アンインストール。
- [準備状況の確認とトラブルシューティング](docs/installation.md#check-readiness-for-your-task):特定のエージェントや分析エンジンを診断。
- [CLI と Evidence](docs/cli.md):コマンド、プロバイダーの選択、スナップショット、インポート/エクスポート、終了ステータス。
- [MCP 契約](docs/mcp-contracts.md)と[エージェント向けプロンプト](docs/mcp-prompts.md):ツールの結果、セッション、ガイド付き調査。
- [ツールカタログ](docs/mcp-contracts.md#generated-catalog):ビルド時に生成されるツール、プロバイダー、CLI コマンドの一覧。
- [ロードマップ](docs/roadmap.md):計画中の作業と機能の進捗。
脆弱性の報告は [SECURITY.md](SECURITY.md) に従ってください。
## 貢献する
REA への貢献を歓迎します![issue を作成](https://github.com/morluto/rea/issues)してバグや機能の提案を報告したり、[pull request を送信](https://github.com/morluto/rea/pulls)してコードやドキュメントを改善したりできます。
開発環境とチェックは [CONTRIBUTING.md](CONTRIBUTING.md)、検証レーンは[テストガイド](docs/testing.md)、プロジェクト構造は[アーキテクチャ図](docs/architecture.mermaid)を参照してください。
## プロジェクトリンク
[ウェブサイト](https://rea.tools/) · [npm](https://www.npmjs.com/package/rea-agents) · [skills.sh](https://skills.sh/morluto/rea/reverse-engineer-anything) · [Issues](https://github.com/morluto/rea/issues) · [セキュリティ](SECURITY.md)
開発環境、アーキテクチャ、テスト、リリース手順は [CONTRIBUTING.md](CONTRIBUTING.md) を参照してください。
## スター履歴
🎉 **GitHub スター 20,000 件、ありがとうございます!**
🎉 **GitHub スターが 20,000 を突破しました。ありがとうございます!**
REA の利用、バグ報告、ビルドのテスト、修正への貢献に感謝します。
REA を使い、問題を報告し、ビルドをテストし、修正を届けてくださる皆さんに感謝します。
<a href="https://www.star-history.com/?repos=morluto%2Frea&amp;type=date">
<picture>
@@ -278,10 +380,6 @@ REA の利用、バグ報告、ビルドのテスト、修正への貢献に感
</picture>
</a>
## 免責事項
REA は、合法的なリバースエンジニアリングの研究、分析、再構築のためのツールを提供します。必要な許可を得て、適用される法律に従う責任は利用者にあります。このプロジェクトは違法または無許可の利用を支持しません。
## ライセンス
[MIT](LICENSE)
+229 -182
View File
@@ -4,271 +4,322 @@
# REA: 무엇이든 리버스 엔지니어링
### 하나의 MCP로 바이너리, 애플리케이션, 런타임 동작을 리버스 엔지니어링합니다.
### 에이전트로 앱의 동작부터 네이티브 바이너리까지 조사하세요.
**마음에 드는 기능을 발견했다면, 바이너리 수준까지 작동 원리를 이해하세요.**
**마음에 드는 기능을 찾고, 작동 방식을 이해하고, 원하는 방식으로 구현하세요.**
[![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog)
[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](#조사-도구-카탈로그)
[![Node.js 22+](https://img.shields.io/badge/Node.js-22.19%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
[![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything)
[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE)
[![Discord](https://img.shields.io/discord/1556595354999332884?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/GkcryMnJDM)
<a href="https://trendshift.io/repositories/82054?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-82054" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/82054" alt="morluto%2Frea | Trendshift" width="250" height="55"/></a>
**[웹사이트](https://rea.tools/) · [가이드](https://rea.tools/guides/) · [사례](https://rea.tools/showcase/)**
**[웹사이트(영문)](https://morluto.github.io/rea/) · [가이드](https://morluto.github.io/rea/guides/) · [DX-Ball 사례](https://morluto.github.io/rea/showcase/dx-ball/)**
[빠른 시작](#빠른-시작) · [REA의 작동 방식](#rea의-작동-방식) · [분석할 수 있는 대상](#분석할-수-있는-대상) · [사례](#사례) · [자주 묻는 질문](#자주-묻는-질문) · [문서](#문서)
[빠른 시작](#빠른-시작) · [현재 지원 범위](#현재-지원-범위) · [바이너리에서 동작까지](#바이너리에서-동작까지) · [조사 도구 카탈로그](#조사-도구-카탈로그) · [로드맵](#로드맵) · [작동 방식](#작동-방식)
<code>npx rea-agents setup</code>
<br />
<img src="docs/assets/rea-hopper-analysis.png" alt="REA가 Hopper 안에서 분석 브리지를 실행해 네이티브 바이너리를 검사하는 모습" width="1200" />
<br />
<table aria-label="REA 커뮤니티">
<table aria-label="REA community">
<tr>
<td align="center" width="360">
<a href="https://discord.gg/GkcryMnJDM">
<img src="docs/assets/discord.svg" height="42" alt="Discord" /><br />
<strong>리버스 엔지니어링 커뮤니티에 참여하세요</strong>
</a><br />
<sub>Discord · 질문과 답변 · 결과 공유</sub>
<sub>Discord · 질문과 답변 · 작업 공유</sub>
</td>
</tr>
</table>
<br />
<code>npx rea-agents setup</code>
<br />
<img src="docs/assets/rea-hopper-analysis.png" alt="Hopper에서 분석 브리지를 시작하고 네이티브 바이너리를 조사하는 REA" width="1200" />
</div>
---
앱에서 내 제품에도 넣고 싶은 기능을 발견했나요? 에이전트에게 REA로 조사해 달라고 요청하세요. 소스 코드가 없어도 앱을 검사하고, 기능의 작동 원리를 설명하고, 근거를 보여 주고, 프로젝트에 맞는 기능을 구현할 수 있습니다.
앱에서 마음에 드는 기능을 찾았다면 에이전트에게 REA로 조사해 달라고 요청하세요. 소스 코드가 없어도 앱을 분석하고, 기능의 동작과 근거를 설명한 뒤, 프로젝트에 맞는 기능을 구현할 수 있습니다.
REA는 에이전트를 분석 도구에 연결해 네이티브 바이너리, JavaScript와 Electron 앱, .NET 어셈블리, 웹사이트를 검사할 수 있게 합니다. 터미널에서도 같은 도구를 사용할 수 있습니다. 분석은 로컬에서 실행되며, 결과에는 각 결론의 근거와 한계가 포함됩니다.
REA는 네이티브 바이너리, JavaScript/Electron 앱, .NET 어셈블리, 웹사이트 분석 도구를 제공합니다. 에이전트와 터미널에서 같은 도구를 사용할 수 있습니다. 분석은 로컬에서 실행되며 결과에는 근거와 제한 사항이 포함됩니다.
설정 과정은 에이전트에 REA를 등록하고 해당 버전에 맞는 워크플로 지침을 설치합니다. 네이티브 분석에는 기존 Hopper 또는 Ghidra 설치를 사용할 수 있습니다. 승인하면 설정 과정에서 Hopper를 설치할 수도 있습니다. 정적 JavaScript 분석에는 두 엔진 모두 필요하지 않습니다.
Setup은 에이전트를 설정하고 기존 Hopper 또는 Ghidra 설치에 연결합니다. 승인 후 Hopper를 설치할 수도 있습니다.
> **[REA 웹사이트](https://rea.tools/)**에서 설정 방법, 그림으로 설명한 가이드, 실제 사례를 확인하세요.
## 에이전트에게 바로 요청하기
[설정](#빠른-시작)을 마친 뒤 에이전트를 다시 시작하고 요청하세요.
```text
메모 앱의 검색 기능이 어떻게 동작하는지 조사하고 근거를 보여 주세요.
그다음 제 프로젝트에 비슷한 기능을 구현해 주세요.
```
메모 앱 대신 조사할 앱을 지정하거나 먼저 개요를 요청할 수 있습니다.
## 바이너리에서 동작까지
| 디컴파일 | 이해 | 재현 |
| ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| 네이티브 앱이나 실행 파일에서 프로시저, 의사 코드, 어셈블리, 문자열, 심볼, 세그먼트, 메타데이터를 복구합니다. | 호출자, 피호출자, 상호 참조, 호출 그래프를 따라 기능이나 알고리즘의 실제 동작을 설명합니다. | 에이전트가 배운 내용을 사용자의 기술 스택, 화면, 요구 사항에 맞는 제품 기능으로 만듭니다. |
REA는 분석을 바이너리 증거에 근거하게 합니다. 원본 소스 코드를 복원하거나 앱 전체를 자동으로 복제한다고 주장하지 않습니다.
## REA를 사용하는 이유
| | |
| ------------------- | --------------------------------------------------------------------------------------------------- |
| **에이전트용** | 앱의 동작에 대해 질문하고 추측 대신 에이전트가 조사하게 합니다. |
| **CLI와 MCP** | 터미널과 에이전트에서 동일한 리버스 엔지니어링 기능을 사용합니다. |
| **안내식 설정** | 에이전트 설정, 기존 분석 도구 연결, 승인 후 Hopper 설치를 지원합니다. |
| **통찰에서 코드로** | 기능을 이해한 뒤 같은 코딩 세션에서 자신의 제품에 맞는 버전을 구현합니다. |
| **로컬 분석** | 분석은 지원되는 로컬 호스트에서 실행되며 REA는 바이너리를 호스팅 분석 서비스에 업로드하지 않습니다. |
| **컨텍스트 유지** | 질문마다 분석을 처음부터 시작하지 않고 여러 앱을 연속으로 조사합니다. |
## 빠른 시작
### 에이전트 설정
### 설정 실행하기(권장)
Node.js와 npm이 설치된 환경에서 다음 명령을 실행하세요.
REA를 에이전트에 연결하세요.
```bash
npx rea-agents setup
```
에이전트를 선택하고, 예정된 변경 사항을 검토한 뒤 승인하세요. 설정 과정은 기존 설정을 백업하고 REA의 MCP 서버와 버전에 맞는 워크플로 지침을 추가합니다. 완료 후 에이전트를 다시 시작하세요.
Setup은 먼저 연결할 에이전트를 여러 개 선택하도록 안내합니다. 기존 REA 등록은 기본 선택되며, 감지만 된 클라이언트는 자동 선택되지 않습니다. 아직 설정되지 않은 클라이언트도 직접 선택할 수 있습니다. 정확한 경로와 변경 사항을 검토한 뒤 승인하세요. 선택한 에이전트에는 기본적으로 REA 워크플로가 설치됩니다. Hopper는 별도의 선택 사항이며 따로 승인해야 합니다. 기존 Ghidra 경로도 등록할 수 있습니다.
설정은 Claude Code, Codex, Cursor, Gemini CLI, Grok Build, Grok Bot 및 [다른 에이전트](docs/installation.md#supported-agents)를 지원합니다. 제공자 설정과 수동 MCP 등록은 [설치 및 설정](docs/installation.md)을 참고하세요.
변경 전에 계획을 표시하고 기존 설정을 백업합니다. 요구 사항과 추가 옵션은 [설치 안내](docs/installation.md)를 참고하세요.
### 에이전트에게 요청
### 에이전트에서 사용하기
```text
Notes 앱의 검색 기능이 어떻게 작동하는지 조사하고, 근거를 보여 준 다음, 내 프로젝트에 비슷한 기능을 구현해 줘.
설정 후 에이전트를 다시 시작하고 조사할 앱이나 기능을 설명하세요. REA는 Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf, Devin, OpenCode, Antigravity, GitHub Copilot CLI, Command Code, VS Code를 지원합니다. 기존 REA 등록은 기본 선택되고, 그 밖의 감지된 클라이언트는 직접 선택해야 합니다. 다른 에이전트는 아래 MCP 설정을 사용할 수 있습니다.
Hopper는 데모 모드로 사용할 수 있습니다. 첫 실행 안내가 나오면 데모를 선택하거나 기존 라이선스를 입력하세요.
### AI 코딩 어시스턴트용 스킬(선택)
AI 코딩 어시스턴트에 스킬을 추가하면 더 풍부한 컨텍스트를 얻을 수 있습니다.
```bash
npx skills add morluto/rea --skill reverse-engineer-anything
```
Notes를 대상 앱으로 바꾸고 이해하고 싶은 기능을 지정하세요.
이 스킬은 REA의 조사 워크플로를 제공합니다. 위 Setup을 실행해 REA를 에이전트에 연결하고 분석 도구를 설정하세요. Setup은 기본적으로 버전에 맞는 스킬을 설치합니다. 이 명령은 저장소 버전의 스킬을 설치합니다.
### 터미널 사용
압축을 푼 JavaScript/Electron 앱 디렉터리 또는 ASAR를 검사합니다.
압축을 해제한 JavaScript/Electron 앱 트리나 ASAR라면 MCP 설정이나 네이티브 엔진 없이 바로 분석할 수 있습니다.
```bash
npx -y rea-agents@latest analyze-javascript-application /absolute/path/to/app --json
```
결과에는 모듈, 임포트, Electron 경계와 관련 근거가 포함됩니다. 경로를 분석할 대상으로 바꾸세요. Windows에서는 `"D:/apps/example"` 같은 경로를 사용할 수 있습니다.
경로를 조사 대상의 경로로 바꾸세요(Windows에서는 `"D:/apps/example"` 같은 경로). 이 정적 워크플로에서는 이런 경로도 지원됩니다. 네이티브 분석에는 전용 엔진 설정이 필요합니다.
자주 사용할 경우 `rea` 명령을 설치하세요.
### rea 명령 설치하기
명령줄 도구를 설치하세요.
```bash
curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash
```
Node.js와 npm이 먼저 설치되어 있어야 합니다. 터미널에서 실행하면 설치 프로그램이 `rea`를 설치하고 설정을 시작합니다.
npm으로 설치한 뒤 설정을 실행할 수도 있습니다.
```bash
npm install --global rea-agents
rea setup
```
### 요구 사항
- macOS 12 이상
- Ubuntu 24.04+, Fedora 41+, 64비트 Arch Linux 또는 CachyOS
- Node.js 22.x (>=22.19), 24.x (>=24.11) 또는 26+와 npm
네이티브 바이너리 분석에는 Hopper 또는 Ghidra가 필요합니다. Hopper는 별도 소프트웨어입니다. 데모에는 공급업체의 제한이 있지만 유료 라이선스가 필수는 아닙니다.
Ghidra는 Linux x64와 macOS x64/arm64를 지원합니다. Ghidra 12.1.x와 그 설치본이 선언한 완전한 64비트 JDK(`application.java.min`부터 `application.java.max`까지)를 별도로 설치한 뒤 REA에서 사용하도록 설정하세요. 현재 12.1 릴리스는 JDK 21 이상을 요구하고 상한은 없습니다. 브리지는 Ghidra 12.1.4와 JDK 21에서 검증됩니다. macOS에서는 호스트 아키텍처에 맞는 네이티브 디컴파일러도 필요합니다.
Setup은 설치를 확인하고 경로를 저장할 수 있습니다. Ghidra, Java, Node.js, npm, Homebrew를 설치하거나 업데이트하지 않습니다.
저장소 main과 npm 4.1.0에는 로컬 NTFS상의 네이티브 x86-64 PE 애플리케이션(비관리, 비 DLL 대상)을 위한 실험적인 Windows x64 Ghidra P0 지원이 포함되어 있습니다. Job Object, 비공개 DACL, 경로 승인 제어가 함께 제공됩니다. 이전 npm 패키지에 이 기능이 있을 것으로 기대하기 전에 [릴리스 경계](docs/installation.md#released-package-and-main)를 확인하세요. 전제 조건과 검증 범위는 [Windows Ghidra P0](docs/windows-ghidra-p0.md)를 참고하세요.
### 문제 해결
`npx -y rea-agents@latest doctor`는 호스트, 의존성, 분석 도구, 에이전트 설정을 변경 없이 확인합니다. 구조화된 진단에는 `--json`을 추가하세요.
Linux에서는 실행 가능한 `/opt/hopper/bin/Hopper`를 우선 사용하고, 사용할 수 없으면 `~/.local/share/rea/hopper/bin/Hopper`를 자동으로 확인합니다. 다른 위치에는 `HOPPER_LAUNCHER_PATH`를 설정하세요. 파일이 있는데도 분석 엔진이 없다고 하면 실제 Hopper 경로에 대해 `ldd /absolute/path/to/Hopper | grep 'not found'`를 실행해 누락된 라이브러리를 확인하세요. 자세한 내용은 [Hopper 안내](docs/installation.md#hopper)를 참고하세요.
### 업데이트와 제거
- `rea update`는 현재 REA 설치를 업데이트합니다.
- `rea uninstall`은 REA가 관리하는 에이전트 등록과 워크플로 파일을 제거합니다. Hopper는 유지합니다.
- `rea uninstall --purge-data`는 REA 캐시와 상태도 삭제합니다. 해당 데이터를 지우려는 경우에만 사용하세요.
## 현재 지원 범위
저장소의 현재 기능과 플랫폼 요구 사항은 [영문 지원 가이드](README.md#current-status)에 설명되어 있습니다. main은 [npm 릴리스](docs/installation.md#released-package-and-main)보다 앞선 내용이 포함될 수 있습니다.
- Ghidra는 Linux x64, macOS x64/arm64, 실험적인 Windows x64 P0 경계에서 25개의 읽기 전용 작업을 제공합니다. Linux/macOS에서는 원자적 세션 함수 주석도 추가로 지원합니다. Windows P0는 읽기 전용이며 Ghidra에는 GUI 제어가 없습니다.
- 정적 Android 검사는 별도로 준비한 JADX/Java가 필요합니다. 실제 제공자 검증은 Linux를 대상으로 합니다. [Android 분석](docs/android-analysis.md)을 참고하세요.
- 브라우저, Electron, 프로세스 요청은 대상·동작·수명 주기를 직접 지정하며 호스트의 실제 접근에 의존합니다. 별도의 REA 권한 승인은 필요 없습니다. Setup의 설정 쓰기와 Hopper 설치는 여전히 정확한 계획에 대한 승인이 필요합니다.
- `rea capabilities`는 모든 앱/런타임 도구의 목록이 아니라 바이너리 세션 작업을 설명합니다. 연결된 MCP 도구 목록과 관련 가이드를 참고하세요.
## 하나의 프롬프트로 전체 조사
```text
메모 앱을 리버스 엔지니어링해 오프라인 검색 기능의 작동 방식을 설명한 다음,
TypeScript와 SQLite를 사용해 제 프로젝트에 맞는 버전을 구현해 주세요.
```
| 단계 | 에이전트 작업 | REA 도구 |
| ---: | ----------------------- | ---------------------------------------------------------------- |
| 1 | 바이너리 열기 및 식별 | `open_binary`, `binary_overview` |
| 2 | 오프라인 검색 단서 찾기 | `search_strings`, `search_procedures`, `list_names` |
| 3 | 단서를 실행 코드에 연결 | `find_xrefs_to_name`, `xrefs`, `procedure_callers` |
| 4 | 제어 흐름 재구성 | `get_call_graph`, `procedure_callees`, `procedure_info` |
| 5 | 관련 루틴 디컴파일 | `procedure_pseudo_code`, `procedure_assembly`, `batch_decompile` |
| 6 | 프로젝트에 기능 구현 | 기술 스택, 제품, 요구 사항에 맞는 코드 |
REA는 1~5단계의 바이너리 분석을 처리합니다. 6단계는 에이전트가 일반 파일 편집 및 테스트 도구로 수행합니다.
## 에이전트가 할 수 있는 일
- 소스 코드가 없는 기능의 동작을 설명합니다.
- 앱의 인증, 저장소, 업데이트 또는 네트워크 흐름을 재구성합니다.
- 문서화되지 않은 형식이나 인터페이스의 구조를 복구합니다.
- 문자열이나 심볼에서 의심스러운 동작을 구현한 코드까지 추적합니다.
- 한 세션에서 두 앱 버전을 전환하며 구현 경로를 비교합니다.
- 마음에 드는 기능을 조사하고 자신의 제품에 맞는 버전으로 구현합니다.
- 복구한 동작을 제품 기능, 테스트, 마이그레이션 문서, 포팅 또는 상호 운용 가능한 대체품으로 변환합니다.
- Swift 및 Objective-C 메타데이터를 분석합니다.
- Hopper에 이름, 주석, 북마크를 남겨 사람과 에이전트의 분석을 연결합니다.
## 조사 도구 카탈로그
| 도구 분류 | 수 | 용도 |
| ----------------- | --: | ---------------------------------------------------------------------------------------------------------------------------------------- |
| 네이티브 검사 | 41 | 함수, 의사 코드, 어셈블리, 문자열, 심볼, 호출, 참조, 주석, 바이트 읽기, 파일 오프셋 |
| 조사 워크플로 | 14 | 앱 개요, 함수 기록서, 네이티브 API와 디스패치, 일괄 디컴파일, 기능 추적, 호출 경로, 호출 그래프, Swift와 Objective-C 탐색 |
| 네이티브 바이너리 | 10 | macOS의 Mach-O 메타데이터, 서명, plist, 아키텍처, Swift 심볼과 LLDB 호출 관찰; Linux의 ELF 레이아웃, 정적 보호 증거와 기록된 크래시 |
| 아티팩트 그래프 | 7 | 디렉터리와 패키지 목록, 컴파일된 Interface Builder 파일, Apple 애셋 카탈로그, Mach-O dylib 로드 해석, 추출, 오프라인 EVM 인터페이스 추론 |
| 관리 PE/CLI | 7 | .NET 식별, 메타데이터, CIL 명령어, 네이티브 종속성, 재구성 가져오기, 빌드 비교 |
| 펌웨어 | 2 | Linux 펌웨어 영역 검사 및 명시적 추출 |
| Android APK | 5 | 패키지와 manifest 선언, 클래스 검색, 멤버 목록, 메서드 디컴파일, 정적 참조 |
| 브라우저 관찰 | 12 | 페이지 구조, 네트워크 메타데이터, 스크립트, 소스 맵, WebMCP 탐색, 스크린샷, 캡처 비교 |
| Electron 분석 | 5 | 렌더러 관찰, 정적 앱 매핑, 정적·런타임 결과 연결 |
| JavaScript 런타임 | 2 | Node/Electron Inspector 대상 탐색, 스크립트 위치, 실행 컨텍스트 이벤트 |
| 앱 워크플로 | 14 | 캡처한 웹 스크립트 내보내기, dyld 공유 캐시 검사, 계층 간 기능 추적, 빌드 비교, 히스토리 소스 매핑, 정적 반환 구조 비교, 재구현 검증 |
| 작업 공간과 관찰 | 21 | 세션, 근거 번들, 탐색 컨텍스트, 프로세스·아티팩트·함수 비교, 미해결 항목 기록 |
## 로드맵
다음 작업은 네이티브 대상 검증 확대, JavaScript와 .NET의 버전 비교, 런타임 관찰과 재구현 검증입니다. Setup은 에이전트 연결과 Hopper 설치 선택을 이미 지원합니다. 다른 분석 도구 설치는 향후 작업입니다. [설치 로드맵](docs/roadmap.md)과 [분석 도구 평가](docs/provider-evaluation.md)를 참고하세요.
## 다른 코딩 에이전트에서 사용하기
Setup은 Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf, Devin, OpenCode, Antigravity, GitHub Copilot CLI, Command Code, VS Code를 지원합니다. 기존 REA 등록은 기본 선택되고, 그 밖의 감지된 클라이언트는 직접 선택해야 합니다. 로컬 MCP 서버를 지원하는 에이전트는 다음 설정으로 연결할 수 있습니다.
<!-- x-release-please-start-version -->
```json
{
"mcpServers": {
"rea": {
"command": "npx",
"args": ["-y", "rea-agents@6.0.0", "mcp"]
}
}
}
```
<!-- x-release-please-end -->
## 작동 방식
```mermaid
flowchart LR
Agent["코딩 에이전트"] --> REA["REA<br/>CLI + MCP"]
Terminal["터미널"] --> REA
REA --> Hopper["Hopper 분석 작업"]
Hopper --> App["사용자의 앱"]
REA --> Ghidra["Ghidra 읽기 전용 분석<br/>인벤토리 + 함수 분석"]
Ghidra --> App
```
CLI와 MCP 서버는 같은 분석 워크플로를 사용합니다. 터미널 명령은 완료 후 자신의 브리지 세션을 해제하고, 에이전트 세션은 조사 중 연결을 유지할 수 있습니다. REA 세션을 닫아도 사용자가 이용 중인 Hopper 앱은 종료하지 않습니다.
## CLI
위의 에이전트 워크플로가 REA를 사용하는 가장 쉬운 방법입니다. 터미널에서 앱을 한 번 빠르게 살펴보려면 다음을 실행하세요.
```bash
npx -y rea-agents@latest analyze /Applications/Notes.app
```
직접 디컴파일하는 방법과 기타 옵션은 `npx -y rea-agents@latest --help`에서 확인할 수 있습니다.
전역 `rea` 명령으로 설치할 수도 있습니다.
```bash
npm install --global rea-agents
rea --help
rea mcp
```
네이티브 분석에는 먼저 제공자를 설정해야 합니다. 네이티브 명령, 제공자 선택, 스냅샷, 스크립트 사용은 [CLI 및 Evidence 가이드](docs/cli.md)를 참고하세요.
REA는 macOS의 `.app` 폴더를 직접 열 수 있습니다. 에이전트가 앱을 찾지 못하면 설치 위치를 알려 주세요.
### REA 업데이트
## Hopper 앱 동작
REA는 빠르게 바뀌며 새 릴리스에는 버그 수정이 자주 포함됩니다. 설치된 버전을 최신으로 유지하세요.
REA는 필요할 때 Hopper를 시작합니다. Hopper 런처는 내부적으로 앱을 활성화하므로 대상을 열 때 Hopper가 다른 창 앞으로 나타날 수 있습니다. REA는 백그라운드 시작을 요청하지만 항상 뒤에 머무르는 것을 보장할 수 없습니다.
npm으로 설치한 CLI에서는 다음 명령을 사용합니다.
명시적인 형식과 아키텍처 인자로 일반적인 FAT/ARM 선택 대화상자를 피하지만, 다른 Hopper 또는 macOS 대화상자에는 사람이 응답해야 할 수 있습니다. 세션 종료 시 브리지와 소켓 디렉터리를 제거하지만 사용자가 작업 중인 Hopper 앱은 종료하지 않습니다.
```bash
rea update
```
## 보안 모델
에이전트 등록과 스킬을 갱신하려면 업데이트 과정에서 출력된 설정 명령을 실행하세요.
각 세션은 무작위 capability token과 현재 사용자 전용 Unix 소켓을 사용합니다. Ghidra 세션은 격리된 임시 프로젝트도 사용하며 사용자 소유 Ghidra 프로젝트를 열거나 수정하지 않습니다. 이는 샌드박스가 아니며 같은 운영 체제 사용자로 실행되는 악성 프로세스를 방어하지 않습니다. 신뢰할 수 없는 바이너리를 열면 선택한 로컬 공급자가 현재 사용자 권한으로 분석합니다. 취약점은 [SECURITY.md](SECURITY.md)의 비공개 절차로 신고하세요.
`npx`를 사용한다면 다음 명령으로 에이전트 설정을 갱신하세요.
## FAQ
```bash
npx rea-agents@latest setup
```
<details><summary><strong>Hopper를 미리 실행해야 하나요?</strong></summary>
설정 변경 사항을 검토하고 에이전트를 다시 시작하세요. 일회성 CLI 작업에는 `npx rea-agents@latest` 뒤에 필요한 명령을 붙여 실행하세요.
## REA의 작동 방식
에이전트는 MCP를 통해 REA를 호출하여 대상을 검사하고 관련 코드를 추적합니다. REA는 조사 결과와 근거를 반환합니다. 에이전트는 이를 바탕으로 추가 질문을 하고, 동작을 설명하거나, 구현을 작성하고 테스트합니다. CLI 명령도 같은 워크플로를 사용합니다.
![REA 조사 흐름: 에이전트가 로컬 대상에 대해 질문하면 REA가 분석 도구로 대상을 검사하고 추적합니다. 에이전트는 반환된 코드, 참조, 확인되지 않은 사항을 이용해 설명하고 구현하고 테스트합니다.](website/public/assets/figures/rea-investigation-flow.svg)
[원본 크기의 그림 열기](website/public/assets/figures/rea-investigation-flow.svg).
<a id="current-status"></a>
## 분석할 수 있는 대상
REA에는 Node.js 22.x(>=22.19), 24.x(>=24.11) 또는 26+와 npm이 필요합니다. 추가 도구와 호스트 지원은 대상에 따라 다릅니다.
| 대상 | REA가 반환하는 내용 | 요구 사항 및 가이드 |
| --------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| 네이티브 바이너리 | 의사 코드, 어셈블리, 문자열, 심볼, 호출, 참조 | Hopper, Ghidra 또는 IDA; [네이티브 분석](https://rea.tools/guides/native/) |
| 오프라인 ELF 레이아웃 | 섹션, 세그먼트, 원본 심볼/재배치 정보, 정적 분석으로 얻은 보안 완화 기법 후보 | Linux x64에서 호출자가 제공하는 pwntools; [바이너리 진단](docs/binary-diagnostics.md) |
| EVM 바이트코드 | 디스패치 선택자, 바이트 오프셋, 추론한 인자와 상태 변경 가능 여부 | 로컬 원시 바이트/16진수 입력 파일; [오프라인 EVM 가이드](docs/evm-bytecode.md) |
| 기록된 Linux 크래시 | 원시 note 레코드, 기록된 모든 스레드의 레지스터/시그널, 선택적 매핑 후보 | 호출자가 제공하는 pwntools; GDB/pwndbg는 선택 사항; [기록된 크래시](docs/recorded-crashes.md) |
| JavaScript / Electron | 모듈, 임포트, 소스 맵, 라우트, IPC, 네이티브 애드온 관계 | Node.js와 npm; [애플리케이션 분석](https://rea.tools/guides/javascript/) |
| 웹사이트 | 페이지 구조, 스크립트, 네트워크 관찰 결과, 요청한 스크린샷 | Chrome 계열 브라우저; [브라우저 분석](https://rea.tools/guides/browser/) |
| 저장된 네트워크 캡처 | 요청, 응답, 접근 가능한 페이로드, 소스 위치 | HAR; mitmproxy 네이티브 캡처에는 Linux의 mitmdump; [캡처 가이드](docs/web-network-captures.md) |
| .NET 어셈블리 | 메타데이터, CIL 명령, 선언된 네이티브 의존성, 빌드 비교 | 정적 검사; [관리 코드 가이드](docs/managed-code-analysis.md) |
| Android APK | 매니페스트 선언, 클래스, 디컴파일된 메서드, 참조 | Linux/macOS의 헤드리스 JADX와 전체 JDK; [Android 가이드](docs/android-analysis.md) |
| 펌웨어 | 영역, 추출 결과, 네이티브 분석으로 전달할 정보 | Linux의 Binwalk / Unblob; [펌웨어 가이드](docs/firmware-analysis.md) |
| 패키지 및 리소스 | 파일 목록, 다이제스트, plist, Apple 번들 구조, 추출한 리소스 | [아티팩트 및 JavaScript 가이드](docs/javascript-artifact-reconstruction.md), [Apple 애플리케이션](docs/apple-application-analysis.md) |
| 프로세스 동작 | 터미널 출력, 상호작용, 종료 및 파일 시스템 관찰 결과, 실행 비교 | 네이티브 PTY를 지원하는 Linux/macOS; [프로세스 캡처](docs/process-capture.md) |
정적 JavaScript 및 .NET 검사는 제공된 파일을 읽으며 애플리케이션을 실행하지 않습니다. 런타임 캡처는 사용자의 권한으로 선택한 대상을 실행하거나 대상과 상호작용합니다. 각 런타임 가이드에서 그 영향을 설명합니다.
<a id="choosing-a-deep-analysis-provider"></a>
지원하는 네이티브 형식과 호스트는 제공자마다 다릅니다. [Hopper 및 Ghidra 설정](docs/installation.md#hopper), [IDA 가이드](docs/ida-provider.md), [실험적 Windows Ghidra 지원](docs/windows-ghidra-p0.md)을 참고하세요. Ghidra는 [16비트 DOS 분석](docs/ghidra-dos.md)도 지원합니다. 제공자 선택은 [CLI 가이드](docs/cli.md#choose-a-provider)를 참고하세요. 최신 npm 릴리스 이후 추가된 기능은 [릴리스에서의 사용 가능 여부](docs/installation.md#released-package-and-main)를 확인하세요.
## 사례
### DX-Ball: 사운드 패닝 계산 재구현
사운드 호출에서 위치를 패닝 값으로 변환하는 보조 함수까지 추적하고, 명령을 검사하여 불완전한 의사 코드를 C로 옮깁니다. 재구현한 코드는 원본 x86에 대한 3,205개 테스트 케이스를 통과했으며, 컴파일된 함수의 63바이트 전체를 재현합니다.
[사례 읽기](https://rea.tools/showcase/dx-ball/) ·
[재구현 저장소](https://github.com/N0zoM1z0/dx-ball)
### Notion: Electron 클립보드 브리지 추적
렌더러의 클립보드 API를 찾고, preload와 IPC를 거쳐 메인 프로세스까지 추적한 뒤, 서식 있는 클립보드 형식을 검사합니다.
[사례 읽기](https://rea.tools/showcase/notion/)
### TH04: DOS 원형 탄막 계산 복원
원본 PC-98 게임의 16비트 명령을 검사하고 고정 각도와 조준 각도의 계산을 복원합니다. 재구현한 C++를 당시 컴파일러의 출력과 비교합니다.
[사례 읽기](https://rea.tools/showcase/th04/) ·
[재구현 저장소](https://github.com/N0zoM1z0/th04)
REA로 흥미로운 대상을 분석했다면 알려 주세요. [issue](https://github.com/morluto/rea/issues) 또는 [pull request](https://github.com/morluto/rea/pulls)에 대상, 조사하려던 질문, REA가 어떻게 도움이 되었는지, 무엇을 알아냈는지를 담아 공유하세요.
## 자주 묻는 질문
<details>
<summary><strong>어떤 에이전트에서 REA를 사용할 수 있나요?</strong></summary>
로컬 MCP 서버를 지원하는 에이전트라면 사용할 수 있습니다. 설정 과정은 [지원하는 에이전트](docs/installation.md#supported-agents)를 구성합니다. 다른 클라이언트는 [수동 MCP 등록](docs/installation.md#mcp-registry)을 이용할 수 있습니다.
아니요. REA가 필요할 때 시작합니다. Hopper가 실행 중이어도 사용할 수 있지만 기존 GUI 문서에 연결하는 대신 새 분석 문서를 엽니다.
</details>
<details>
<summary><strong>Hopper, Ghidra 또는 IDA가 필요한가요?</strong></summary>
<details><summary><strong>REA에 Hopper가 포함되나요?</strong></summary>
심층 네이티브 분석에는 이 중 하나를 사용합니다. 정적 JavaScript 및 .NET 검사에는 네이티브 분석 엔진이 필요하지 않습니다. 설정 과정에서 승인 후 Hopper를 설치할 수 있으며, Ghidra와 IDA는 기존 설치를 사용합니다. [제공자 설정](docs/installation.md#hopper)을 참고하세요.
아니요. Setup이 Hopper를 설치할 수 있지만 Hopper는 자체 라이선스가 필요한 별도 소프트웨어입니다. REA는 CLI, MCP 서버, 에이전트용 워크플로를 제공합니다.
</details>
<details>
<summary><strong>Hopper를 먼저 실행해야 하나요?</strong></summary>
<details><summary><strong>바이너리가 업로드되나요?</strong></summary>
REA는 작업에 필요할 때 Hopper를 실행합니다. macOS에서는 처음 실행할 때 데모 모드를 선택하거나 라이선스를 활성화하라는 대화 상자가 나타날 수 있습니다. [Hopper 실행 및 문제 해결](docs/installation.md#launcher-paths-and-troubleshooting)을 참고하세요.
REA에는 호스팅 분석 서비스가 없습니다. 로컬 Unix 소켓을 통해 Hopper를 조작합니다. 에이전트나 모델 공급자의 데이터 정책은 별도로 확인하세요.
</details>
<details>
<summary><strong>skills.sh에서 설치하는 스킬은 어떤 역할을 하나요?</strong></summary>
<details><summary><strong>원본 소스 코드를 복구할 수 있나요?</strong></summary>
스킬은 에이전트에게 조사 지침을 제공합니다. `rea setup`으로 REA의 MCP 서버를 등록하고 버전에 맞는 지침을 설치한 뒤 에이전트를 다시 시작하세요. [스킬만 설치하기](docs/installation.md#skill-only-installation)를 참고하세요.
보장할 수 없습니다. REA는 의사 코드, 어셈블리, 심볼, 문자열, 메타데이터 및 관계를 제공하여 에이전트가 관찰된 동작을 설명하거나 호환되게 재현하도록 돕습니다.
</details>
<details>
<summary><strong>REA는 어떤 코드를 반환하나요?</strong></summary>
## 개발
네이티브 분석은 의사 코드와 어셈블리를 반환합니다. JavaScript/Electron 분석은 모듈과 그 관계를 복원합니다. 에이전트는 이 결과를 사용해 구현을 작성하고 테스트합니다. [사례](#사례)에서 구체적인 예를 볼 수 있습니다.
</details>
<details>
<summary><strong>REA가 내 앱을 업로드하나요?</strong></summary>
REA는 대상을 로컬에서 분석합니다. 에이전트는 도구 결과를 받으며, 에이전트의 모델 제공자에는 별도의 데이터 정책이 있습니다.
</details>
<details>
<summary><strong>버그가 발생하면 어떻게 해야 하나요?</strong></summary>
먼저 업데이트하세요. 최근 릴리스에서 이미 수정되었을 수 있습니다.
npm으로 설치한 CLI에서는 다음 명령을 사용합니다.
```bash
rea update
```
`npx`로 에이전트를 설정했다면 다음 명령을 사용합니다.
```bash
npx rea-agents@latest setup
```
에이전트를 사용 중이라면 [설정 갱신](#rea-업데이트)을 완료하고 다시 시작하세요. 같은 작업을 다시 시도하세요. 문제가 계속되면 REA 버전, 대상 유형, 재현 단계, 오류 출력을 포함해 [issue를 등록](https://github.com/morluto/rea/issues)하세요.
</details>
## 문서
웹사이트의 [실습 가이드](https://rea.tools/guides/)부터 읽어 보세요. 정확한 옵션, 사전 요구 사항, 결과 계약은 다음 문서를 참고하세요.
- [설치 및 설정](docs/installation.md): 에이전트 등록, 제공자 설정, 업데이트, 제거.
- [준비 상태 확인 및 문제 해결](docs/installation.md#check-readiness-for-your-task): 특정 에이전트 또는 분석 엔진 진단.
- [CLI 및 Evidence](docs/cli.md): 명령, 제공자 선택, 스냅샷, 가져오기/내보내기, 종료 상태.
- [MCP 계약](docs/mcp-contracts.md) 및 [에이전트 프롬프트](docs/mcp-prompts.md): 도구 결과, 세션, 안내에 따른 조사.
- [도구 카탈로그](docs/mcp-contracts.md#generated-catalog): 빌드 시 생성되는 도구, 제공자, CLI 명령 목록.
- [로드맵](docs/roadmap.md): 계획된 작업과 기능 진행 상황.
취약점은 [SECURITY.md](SECURITY.md)에 따라 보고하세요.
## 기여
REA에 도움을 보태 주세요! [issue를 등록](https://github.com/morluto/rea/issues)해 버그나 기능을 제안하거나, [pull request를 보내](https://github.com/morluto/rea/pulls) 코드와 문서를 개선할 수 있습니다.
개발 환경과 검사 항목은 [CONTRIBUTING.md](CONTRIBUTING.md), 검증 절차는 [테스트 가이드](docs/testing.md), 프로젝트 구조는 [아키텍처 지도](docs/architecture.mermaid)를 참고하세요.
## 프로젝트 링크
[웹사이트](https://rea.tools/) · [npm](https://www.npmjs.com/package/rea-agents) · [skills.sh](https://skills.sh/morluto/rea/reverse-engineer-anything) · [Issues](https://github.com/morluto/rea/issues) · [보안](SECURITY.md)
개발 환경, 아키텍처, 테스트, 릴리스 지침은 [CONTRIBUTING.md](CONTRIBUTING.md)를 참고하세요.
## 스타 기록
🎉 **GitHub 스타 20,000개, 감사합니다!**
REA를 사용하고, 버그를 보고하고, 빌드를 테스트하고, 수정에 기여해 주신 모든 분께 감사드립니다.
REA를 사용하고, 버그를 제보하고, 빌드를 테스트하고, 수정에 기여해 주신 모든 분께 감사드립니다.
<a href="https://www.star-history.com/?repos=morluto%2Frea&amp;type=date">
<picture>
@@ -278,10 +329,6 @@ REA를 사용하고, 버그를 보고하고, 빌드를 테스트하고, 수정
</picture>
</a>
## 면책 조항
REA는 합법적인 리버스 엔지니어링 연구, 분석, 재구성을 위한 도구를 제공합니다. 필요한 권한을 얻고 관련 법률을 준수할 책임은 사용자에게 있습니다. 이 프로젝트는 불법적이거나 허가받지 않은 사용을 지지하지 않습니다.
## 라이선스
[MIT](LICENSE)
+230 -182
View File
@@ -2,35 +2,26 @@
[English](README.md) · **简体中文** · [日本語](README_ja.md) · [한국어](README_ko.md) · [العربية](README_ar.md)
# REA:逆向分析一切
# REA:逆向工程一切
### 通过一个 MCP 服务,逆向分析二进制文件、应用程序和运行时行为。
### 使用智能体逆向工程任何程序,从应用行为到原生二进制文件。
**看到喜欢的功能,弄清它的工作原理,深入到二进制层面。**
**看到喜欢的功能。理解它的原理。按照你的方式实现。**
[![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog)
[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](#调查工具目录)
[![Node.js 22+](https://img.shields.io/badge/Node.js-22.19%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
[![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything)
[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE)
[![Discord](https://img.shields.io/discord/1556595354999332884?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/GkcryMnJDM)
<a href="https://trendshift.io/repositories/82054?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-82054" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/82054" alt="morluto%2Frea | Trendshift" width="250" height="55"/></a>
**[网站](https://rea.tools/) · [指南](https://rea.tools/guides/) · [案例](https://rea.tools/showcase/)**
**[网站(英文)](https://morluto.github.io/rea/) · [使用指南](https://morluto.github.io/rea/guides/) · [DX-Ball 案例](https://morluto.github.io/rea/showcase/dx-ball/)**
[快速开始](#快速开始) · [REA 的工作原理](#rea-的工作原理) · [可以分析什么](#可以分析什么) · [案例](#案例) · [常见问题](#常见问题) · [文档](#文档)
[快速开始](#快速开始) · [当前状态](#当前状态) · [从二进制到行为](#从二进制到行为) · [调查工具目录](#调查工具目录) · [路线图](#路线图) · [工作原理](#工作原理)
<code>npx rea-agents setup</code>
<br />
<img src="docs/assets/rea-hopper-analysis.png" alt="REA 在 Hopper 中启动分析桥,检查原生二进制文件" width="1200" />
<br />
<table aria-label="REA 社区">
<table aria-label="REA community">
<tr>
<td align="center" width="360">
<a href="https://discord.gg/GkcryMnJDM">
@@ -44,244 +35,301 @@
<br />
<code>npx rea-agents setup</code>
<br />
<img src="docs/assets/rea-hopper-analysis.png" alt="REA 在 Hopper 中启动分析桥并检查原生二进制文件" width="1200" />
</div>
---
在应用中看到一个想加入自己产品的功能?让智能体用 REA 调查它。即使没有源代码,智能体也可以检查应用、解释功能的工作方式、展示证据,并为你的项目实现类似功能。
看到某个应用中想加入自己产品的功能?让智能体用 REA 调查它。即使没有源代码,智能体也可以检查应用、解释功能的工作方式、展示证据,并为你的项目实现类似功能。
REA 将智能体连接到分析工具,用于检查原生二进制文件、JavaScript 和 Electron 应用、.NET 程序集以及网站。你也可以在终端中使用同样的工具。分析在本机运行,结果包含支撑各项结论的证据和相关限制。
REA 提供原生二进制、JavaScript 与 Electron 应用、.NET 程序集和网站的分析工具。你可以从智能体或终端使用它们。分析在本机运行,结果会说明证据和限制。
设置流程会向智能体注册 REA,并安装匹配的工作流指引。原生分析可以使用已有的 Hopper 或 Ghidra;设置流程也可以在你批准后安装 Hopper。静态 JavaScript 分析不需要这两个引擎。
Setup 可以配置智能体、连接已安装的 Hopper 或 Ghidra,也可以在你批准后安装 Hopper。
> **[访问 REA 网站](https://rea.tools/)**,查看设置说明、图解指南和真实案例。
## 直接询问智能体
完成[设置](#快速开始)后,重启智能体并提出问题:
```text
解释“备忘录”应用的搜索功能,展示证据,然后为我的项目实现类似功能。
```
把“备忘录”替换成你想了解的应用,也可以先请求概览。
## 从二进制到行为
| 反编译 | 理解 | 重建 |
| ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
| 打开原生应用或可执行文件,恢复过程、伪代码、汇编、字符串、符号、段和元数据。 | 沿调用者、被调用者、交叉引用和调用图追踪,直到智能体能够解释功能或算法的实际工作方式。 | 将智能体学到的内容变成适合你的技术栈、界面和需求的产品功能。 |
REA 让调查始终以二进制证据为依据。它不会声称能恢复原始源代码,也不会自动克隆整个应用。
## 为什么选择 REA
| | |
| ---------------- | -------------------------------------------------------------------- |
| **为智能体设计** | 直接询问应用的行为,让智能体去检查,而不是猜测。 |
| **CLI 与 MCP** | 在终端或智能体中使用同一套逆向工程能力。 |
| **引导式设置** | 配置智能体、连接已有分析工具,或在你批准后安装 Hopper。 |
| **从洞察到代码** | 理解功能后,在同一次编程会话中构建属于你自己的版本。 |
| **本地运行** | 分析在受支持的本机系统上运行;REA 不会把二进制上传到托管式分析服务。 |
| **保留上下文** | 连续调查多个应用,无需为每个问题重新开始整个分析过程。 |
## 快速开始
### 设置智能体
### 运行设置(推荐)
安装 Node.js 和 npm 后,运行:
配置 REA 与智能体的连接:
```bash
npx rea-agents setup
```
选择智能体,检查计划中的变更并批准。设置流程会添加 REA 的 MCP 服务和匹配的工作流指引,并备份已有配置。完成后重启智能体。
Setup 首先让你多选要连接的智能体。已有 REA 注册默认选中;仅被检测到的客户端不会自动选中,也可以手动选择尚未配置的客户端。查看具体路径和变更后再批准。所选智能体默认安装 REA 工作流;Hopper 是单独的可选操作,需要单独批准。Setup 也可以记录现有 Ghidra 的路径。
设置流程支持 Claude Code、Codex、Cursor、Gemini CLI、Grok Build、Grok Bot 和[其他智能体](docs/installation.md#supported-agents)。提供方配置和手动注册 MCP 的方法见[安装与设置](docs/installation.md)。
Setup 会先展示变更并备份已有配置。要求与更多选项见[安装与设置](docs/installation.md)。
### 询问智能体
### 使用智能体
```text
弄清 Notes 应用的搜索功能如何工作,展示证据,并为我的项目实现类似功能。
设置完成后,重启智能体并描述你想了解的应用或功能。REA 支持 Claude Code、Claude Desktop、Codex、Cursor、Gemini CLI、Windsurf、Devin、OpenCode、Antigravity、GitHub Copilot CLI、Command Code 和 VS Code。已有 REA 注册默认选中;其他检测到的客户端需手动选择。其他智能体可以使用下方的 MCP 配置。
Hopper 支持演示模式。如果首次启动时出现提示,选择演示模式或输入已有许可证。
### AI 编程助手技能(可选)
为你的 AI 编程助手添加 skill,以获得更丰富的上下文:
```bash
npx skills add morluto/rea --skill reverse-engineer-anything
```
把 Notes 替换为目标应用,并描述你想了解的功能。
该 skill 提供 REA 的调查工作流。先运行上文的 setup,把 REA 连接到你的智能体并配置分析工具。Setup 默认会安装版本匹配的 skill;这条命令安装的是仓库版本。
### 使用终端
检查已解包的 JavaScript/Electron 应用目录或 ASAR 文件:
对于已解包的 JavaScript/Electron 应用目录或 ASAR,无需 MCP 配置,也无需原生引擎:
```bash
npx -y rea-agents@latest analyze-javascript-application /absolute/path/to/app --json
```
结果包含模块、导入、Electron 边界及相关证据。将路径替换为你的目标路径,例如 Windows 上的 `"D:/apps/example"`。
把路径换成你的目标(例如 Windows 上的 `"D:/apps/example"`)。这条静态流程支持此类路径。原生分析仍需要先配置对应的分析引擎。
如果需要经常使用,可以安装 `rea` 命令:
### 安装 rea 命令
安装命令行工具:
```bash
curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash
```
需要预先安装 Node.js 和 npm。在终端中运行时,安装程序会安装 `rea` 并启动设置。
也可以用 npm 安装,再运行设置:
```bash
npm install --global rea-agents
rea setup
```
### 系统要求
- macOS 12 或更高版本
- Ubuntu 24.04+、Fedora 41+、64 位 Arch Linux 或 CachyOS
- Node.js 22.x (>=22.19)、24.x (>=24.11) 或 26+,以及 npm
原生二进制分析需要 Hopper 或 Ghidra。Hopper 是独立软件;演示模式有厂商规定的限制,不要求购买许可证。
Ghidra 支持 Linux x64 和 macOS x64/arm64。单独安装 Ghidra 12.1.x,以及该安装在 `application.java.min` 到 `application.java.max` 中声明的完整 64 位 JDK,然后配置 REA 使用它们。当前 12.1 发行版要求 JDK 21 或更新版本,并且不设置上限。桥接在 Ghidra 12.1.4 和 JDK 21 上完成验证。macOS 还需要对应架构的原生反编译器。
Setup 可以验证安装并保存路径,不会安装或升级 Ghidra、Java、Node.js、npm 或 Homebrew。
仓库 main 分支与 npm 4.1.0 已包含实验性的 Windows x64 Ghidra P0 支持,适用于本地 NTFS 上的原生 x86-64 PE 应用(非托管、非 DLL),并附带 Job Object、私有 DACL 与路径准入控制。请先确认[发布边界](docs/installation.md#released-package-and-main),再假定旧版 npm 包具备该能力。详见 [Windows Ghidra P0](docs/windows-ghidra-p0.md) 的前置条件与已验证范围。
### 故障排除
运行 `npx -y rea-agents@latest doctor`,检查主机、依赖、分析工具和智能体配置。该命令不会修改文件。添加 `--json` 可获取结构化诊断。
Linux 上 REA 会优先使用可执行的 `/opt/hopper/bin/Hopper`;如果不可用,则自动检查 `~/.local/share/rea/hopper/bin/Hopper`。其他路径可通过 `HOPPER_LAUNCHER_PATH` 指定。如果文件存在但仍报告缺少分析引擎,请对实际 Hopper 路径运行 `ldd /absolute/path/to/Hopper | grep 'not found'` 检查缺少的系统库。安装详情见 [Hopper 指南](docs/installation.md#hopper)。
### 更新与卸载
- `rea update` 更新当前 REA 安装。
- `rea uninstall` 移除 REA 管理的智能体配置项和工作流文件,保留 Hopper。
- `rea uninstall --purge-data` 还会删除 REA 的缓存和状态。仅在需要移除这些数据时使用。
## 当前状态
仓库当前能力与平台要求见[英文支持指南](README.md#current-status)。main 分支可能领先于 [npm 发布版](docs/installation.md#released-package-and-main)。
- Ghidra 在 Linux x64、macOS x64/arm64 以及实验性的 Windows x64 P0 边界上提供 25 个只读操作。Linux/macOS 额外支持原子化的会话级函数注释。Windows P0 为只读;Ghidra 不提供 GUI 控制。
- 静态 Android 检查需要自备 JADX/Java,前置条件独立;真机验证覆盖 Linux。详见 [Android 分析](docs/android-analysis.md)。
- 浏览器、Electron 与进程请求需直接声明目标、动作与生命周期,并依赖宿主的实际访问权限,无需 REA 另行授权。Setup 的配置写入与 Hopper 安装仍需逐项批准确切计划。
- `rea capabilities` 描述的是二进制会话操作,而非全部应用/运行时工具。请以已连接 MCP 工具列表与相关指南为准。
## 一个提示词,完成一次完整调查
```text
逆向工程“备忘录”应用,找到离线搜索功能的工作方式,解释其控制流,
并使用 TypeScript 和 SQLite 为我的项目构建一个版本。
```
| 步骤 | 智能体的操作 | REA 工具 |
| ---: | ---------------------- | ---------------------------------------------------------------- |
| 1 | 打开并识别二进制文件 | `open_binary`, `binary_overview` |
| 2 | 搜索可能的离线搜索线索 | `search_strings`, `search_procedures`, `list_names` |
| 3 | 将线索连接到可执行代码 | `find_xrefs_to_name`, `xrefs`, `procedure_callers` |
| 4 | 重建相关控制流 | `get_call_graph`, `procedure_callees`, `procedure_info` |
| 5 | 反编译相关程序 | `procedure_pseudo_code`, `procedure_assembly`, `batch_decompile` |
| 6 | 在你的项目中构建该功能 | 适合你的技术栈、产品和需求的代码 |
REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常规文件编辑与测试工具完成。
## 智能体可以完成什么
- 在没有源代码时解释某项功能的实现方式。
- 重建应用的身份验证、存储、更新或网络流程。
- 恢复足够的结构,以记录未公开的格式或接口。
- 从字符串或符号追踪到实现可疑行为的代码。
- 在一个会话中切换两个应用版本并比较实现路径。
- 调查你喜欢的功能,并为自己的产品构建量身定制的版本。
- 将恢复的行为转换为产品功能、测试、迁移说明、移植代码或互操作替代品。
- 分析 Swift 和 Objective-C 元数据。
- 在 Hopper 中留下名称、注释与书签,使人与智能体的分析互相增强。
## 调查工具目录
| 工具类别 | 数量 | 用途 |
| ----------------- | ---: | -------------------------------------------------------------------------------------------------------------------- |
| 原生检查 | 41 | 函数、伪代码、汇编、字符串、符号、调用、引用、注释、字节读取与文件偏移 |
| 调查工作流 | 14 | 应用概览、函数档案、原生 API 与分发、批量反编译、功能追踪、调用路径与调用图、Swift 与 Objective-C 发现 |
| 原生二进制工具 | 10 | macOS 的 Mach-O 元数据、签名、plist、架构、Swift 符号与 LLDB 调用观察;Linux 的 ELF 布局、静态保护证据与历史崩溃记录 |
| 产物图 | 7 | 目录与软件包清单、编译后的 Interface Builder 文件、Apple 资源目录、Mach-O dylib 加载解析、提取与离线 EVM 接口推断 |
| 托管 PE/CLI | 7 | .NET 程序集身份、元数据、CIL 指令、原生依赖声明、重建导入与构建比较 |
| 固件 | 2 | Linux 固件区域检查与显式提取 |
| Android APK | 5 | 包与 manifest 声明、类搜索、成员清单、方法反编译与静态引用 |
| 浏览器观察 | 12 | 页面结构、网络元数据、脚本、来源映射、WebMCP 发现、截图与捕获比较 |
| Electron 分析 | 5 | 渲染进程观察、静态应用映射、静态/运行时结果关联 |
| JavaScript 运行时 | 2 | Node/Electron Inspector 目标发现、脚本位置与执行上下文事件 |
| 应用工作流 | 14 | 捕获的网站脚本导出、dyld 共享缓存检查、跨层功能追踪、构建比较、历史源码映射、静态返回结构比较与重建验证 |
| 工作区与观察 | 21 | 会话、证据包、导航上下文、进程/产物/函数比较与待解决问题跟踪 |
## 路线图
接下来的工作包括更多原生目标验证、JavaScript 与 .NET 跨版本比较,以及运行时观察和重建验证。当前设置已支持选择智能体集成和 Hopper 安装;安装其他分析工具仍是后续工作。详见[安装路线图](docs/roadmap.md)和[分析工具评估](docs/provider-evaluation.md)。
## 与其他编程智能体一起使用
Setup 支持 Claude Code、Claude Desktop、Codex、Cursor、Gemini CLI、Windsurf、Devin、OpenCode、Antigravity、GitHub Copilot CLI、Command Code 和 VS Code。已有 REA 注册默认选中;其他检测到的客户端需手动选择。任何支持本地 MCP 服务器的智能体都可以使用以下配置连接 REA。
<!-- x-release-please-start-version -->
```json
{
"mcpServers": {
"rea": {
"command": "npx",
"args": ["-y", "rea-agents@6.0.0", "mcp"]
}
}
}
```
<!-- x-release-please-end -->
## 工作原理
```mermaid
flowchart LR
Agent["编程智能体"] --> REA["REA<br/>CLI + MCP"]
Terminal["终端"] --> REA
REA --> Hopper["Hopper 分析操作"]
Hopper --> App["你的应用"]
REA --> Ghidra["Ghidra 只读分析<br/>清单 + 函数分析"]
Ghidra --> App
```
CLI 与 MCP 服务器使用相同的分析流程。终端命令完成后释放自己创建的桥接会话;智能体会话可以在调查期间保持连接。关闭 REA 会话不会退出用户正在使用的 Hopper 应用。
## CLI
上面的智能体工作流是使用 REA 最简单的方式。如果只想在终端中快速了解一个应用:
```bash
npx -y rea-agents@latest analyze /Applications/Notes.app
```
运行 `npx -y rea-agents@latest --help` 查看直接反编译和其他选项。
也可以全局安装 `rea` 命令:
```bash
npm install --global rea-agents
rea --help
rea mcp
```
原生分析需要先配置分析提供方。原生命令、提供方选择、快照和脚本用法见 [CLI 与 Evidence 指南](docs/cli.md)。
REA 可以直接打开 Mac 的 `.app` 文件夹。如果智能体找不到应用,请告诉它应用安装在哪里。
### 更新 REA
## Hopper 应用行为
REA 更新较快,新版本经常包含错误修复。请保持安装的版本为最新。
REA 会在需要时启动 Hopper,无需预先运行。Hopper 启动器内部会激活应用,因此打开目标时 Hopper 可能出现在其他窗口前。REA 会请求 macOS 在后台启动 Hopper,但无法保证窗口始终位于后台。
通过 npm 安装的 CLI 使用:
REA 会推导明确的格式和架构参数,以避免常见的 FAT 与 ARM 选择对话框。其他 Hopper 或 macOS 对话框仍可能需要人工响应。关闭 REA 会话会终止桥并删除私有套接字目录,但不会退出用户正在使用的 Hopper 应用。
```bash
rea update
```
## 安全模型
要更新智能体注册和 skill,请运行更新命令输出的设置命令。
每个桥会话都使用随机能力令牌和仅限当前用户的 Unix 套接字。诊断保留本机路径、摘要和错误位置等排查信息,同时移除凭据和认证令牌。Ghidra 会话还使用隔离的临时项目,不会打开或修改用户自己的 Ghidra 项目。
如果使用 `npx`,通过以下命令更新智能体设置:
```bash
npx rea-agents@latest setup
```
检查设置变更并重启智能体。单次 CLI 操作可使用 `npx rea-agents@latest`,后接所需命令。
## REA 的工作原理
智能体通过 MCP 调用 REA,检查目标并追踪相关代码。REA 返回发现及对应证据。智能体据此提出后续问题、解释行为,或编写并测试实现。CLI 命令使用同样的工作流。
![REA 调查流程:智能体询问本地目标的行为,REA 使用分析工具检查和追踪目标,智能体再利用返回的代码、引用和未知项进行解释、实现与测试。](website/public/assets/figures/rea-investigation-flow.svg)
[打开完整尺寸的图示](website/public/assets/figures/rea-investigation-flow.svg)。
<a id="current-status"></a>
## 可以分析什么
REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。额外工具和主机支持取决于分析目标:
| 目标 | REA 返回的内容 | 要求与指南 |
| --------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| 原生二进制文件 | 伪代码、汇编、字符串、符号、调用和引用 | Hopper、Ghidra 或 IDA;[原生分析](https://rea.tools/guides/native/) |
| 离线 ELF 布局 | 节、段、原始符号/重定位信息及静态防护机制候选项 | Linux x64 上由调用方提供的 pwntools;[二进制诊断](docs/binary-diagnostics.md) |
| EVM 字节码 | 分派选择器、字节偏移、推断的参数和状态可变性 | 本地原始字节/十六进制输入载体;[离线 EVM 指南](docs/evm-bytecode.md) |
| 已记录的 Linux 崩溃 | 原始 note 记录、每个已记录线程的寄存器/信号及可选的映射候选项 | 调用方提供的 pwntools;可选的 GDB/pwndbg;[已记录崩溃](docs/recorded-crashes.md) |
| JavaScript / Electron | 模块、导入、source map、路由、IPC 和原生扩展关系 | Node.js 和 npm;[应用分析](https://rea.tools/guides/javascript/) |
| 网站 | 页面结构、脚本、网络观察结果和按请求获取的截图 | Chrome 系浏览器;[浏览器分析](https://rea.tools/guides/browser/) |
| 已保存的网络捕获 | 请求、响应、可访问的载荷和来源位置 | HAR;原生 mitmproxy 捕获需要 Linux 上的 mitmdump;[捕获指南](docs/web-network-captures.md) |
| .NET 程序集 | 元数据、CIL 指令、声明的原生依赖和构建对比 | 静态检查;[托管代码指南](docs/managed-code-analysis.md) |
| Android APK | 清单声明、类、反编译的方法和引用 | Linux/macOS 上的无界面 JADX 和完整 JDK;[Android 指南](docs/android-analysis.md) |
| 固件 | 区域、提取结果和转交原生分析的内容 | Linux 上的 Binwalk / Unblob;[固件指南](docs/firmware-analysis.md) |
| 软件包与资源 | 文件清单、摘要、plist、Apple bundle 结构和提取的资源 | [制品与 JavaScript 指南](docs/javascript-artifact-reconstruction.md)、[Apple 应用](docs/apple-application-analysis.md) |
| 进程行为 | 终端输出、交互、退出和文件系统观察结果,以及运行对比 | 支持原生 PTY 的 Linux/macOS;[进程捕获](docs/process-capture.md) |
静态 JavaScript 和 .NET 检查读取提供的文件,不运行应用。运行时捕获会以你的用户权限运行目标或与之交互;各运行时指南说明了具体影响。
<a id="choosing-a-deep-analysis-provider"></a>
不同提供方支持的原生格式和主机系统有所不同。请参阅 [Hopper 与 Ghidra 设置](docs/installation.md#hopper)、[IDA 指南](docs/ida-provider.md)和[实验性 Windows Ghidra 支持](docs/windows-ghidra-p0.md)。Ghidra 也支持 [16 位 DOS 分析](docs/ghidra-dos.md)。提供方选择见 [CLI 指南](docs/cli.md#choose-a-provider)。对于最新 npm 版本发布后新增的功能,请查看[发布可用性](docs/installation.md#released-package-and-main)。
## 案例
### DX-Ball:重建声像计算
沿声音调用追踪到根据位置计算声像的辅助函数,检查指令,将不完整的伪代码转为 C。重建结果通过了 3,205 个原始 x86 测试用例,并复现了编译后函数的全部 63 个字节。
[阅读案例](https://rea.tools/showcase/dx-ball/) ·
[重建项目仓库](https://github.com/N0zoM1z0/dx-ball)
### Notion:追踪 Electron 剪贴板桥
找到渲染进程的剪贴板 API,沿 preload 和 IPC 追踪到主进程,并检查富格式剪贴板数据。
[阅读案例](https://rea.tools/showcase/notion/)
### TH04:恢复 DOS 环形子弹的计算
检查原始 PC-98 游戏的 16 位指令,恢复固定角度和瞄准角度的计算,并将重建的 C++ 与当年的编译器输出进行对比。
[阅读案例](https://rea.tools/showcase/th04/) ·
[重建项目仓库](https://github.com/N0zoM1z0/th04)
如果你用 REA 分析了有趣的目标,我们很想了解。欢迎通过 [issue](https://github.com/morluto/rea/issues) 或 [pull request](https://github.com/morluto/rea/pulls) 分享案例,说明目标、你的问题、REA 如何提供帮助,以及你的发现。
这不是沙箱,也无法防御以同一操作系统用户身份运行的恶意进程。打开不可信二进制文件会让所选本地提供商以当前用户权限进行解析和分析。请按照 [SECURITY.md](SECURITY.md) 中的私密流程报告漏洞。
## 常见问题
<details>
<summary><strong>哪些智能体可以使用 REA?</strong></summary>
<details><summary><strong>Hopper 是否需要提前运行?</strong></summary>
任何支持本地 MCP 服务的智能体都可以使用。设置流程会配置[受支持的智能体](docs/installation.md#supported-agents);其他客户端可通过[手动注册 MCP](docs/installation.md#mcp-registry) 使用。
不需要。REA 会在操作需要时启动 Hopper。Hopper 已经运行时也可以使用,但 REA 会打开新的分析文档,不会连接并复用现有 GUI 文档。
</details>
<details>
<summary><strong>需要 Hopper、Ghidra 或 IDA 吗?</strong></summary>
<details><summary><strong>REA 是否包含 Hopper?</strong></summary>
深入的原生分析需要其中一个引擎。静态 JavaScript 和 .NET 检查不需要原生分析引擎。设置流程可以在你批准后安装 Hopper;Ghidra 和 IDA 则使用已有安装。见[提供方设置](docs/installation.md#hopper)。
不包含。Setup 可以为你安装 Hopper,但 Hopper 仍是需要单独授权的软件。REA 提供 CLI、MCP 服务器和面向智能体的工作流。
</details>
<details>
<summary><strong>需要先启动 Hopper 吗?</strong></summary>
<details><summary><strong>REA 会上传我的二进制文件吗?</strong></summary>
REA 会在操作需要时启动 Hopper。在 macOS 上,首次运行时可能会出现对话框,让你选择演示模式或激活许可证。见 [Hopper 启动与故障排查](docs/installation.md#launcher-paths-and-troubleshooting)。
REA 不提供托管分析服务,而是通过本地 Unix 套接字把操作交给 Hopper。你的智能体或模型服务商可能有自己的数据政策,请单独核查。
</details>
<details>
<summary><strong>从 skills.sh 安装 skill 有什么作用?</strong></summary>
<details><summary><strong>REA 能恢复原始源代码吗?</strong></summary>
skill 为智能体提供调查指引。使用 `rea setup` 注册 REA 的 MCP 服务并安装匹配的指引,然后重启智能体。见[仅安装 skill](docs/installation.md#skill-only-installation)。
不能保证。REA 提供伪代码、汇编、符号、字符串、元数据和关系,智能体可据此解释或兼容地重建观察到的行为。
</details>
<details>
<summary><strong>REA 返回什么代码?</strong></summary>
## 开发
原生分析返回伪代码和汇编。JavaScript/Electron 分析恢复模块及其关系。智能体利用这些发现编写并测试实现;[案例](#案例)提供了完整示例。
</details>
<details>
<summary><strong>REA 会上传我的应用吗?</strong></summary>
REA 在本机分析目标。智能体会接收工具结果,其模型提供商有自己的数据政策。
</details>
<details>
<summary><strong>遇到错误时该怎么办?</strong></summary>
先更新;最近的版本可能已经修复了问题。
通过 npm 安装的 CLI 使用:
```bash
rea update
```
通过 `npx` 设置智能体时使用:
```bash
npx rea-agents@latest setup
```
如果正在使用智能体,请完成[设置更新](#更新-rea)并重启智能体。重试同一任务。如果问题仍然存在,请[提交 issue](https://github.com/morluto/rea/issues),附上 REA 版本、目标类型、复现步骤和错误输出。
</details>
## 文档
可以先阅读网站上的[实操指南](https://rea.tools/guides/)。具体选项、前提条件和结果契约见:
- [安装与设置](docs/installation.md):智能体注册、提供方配置、更新和卸载。
- [就绪检查与故障排查](docs/installation.md#check-readiness-for-your-task):诊断特定智能体或分析引擎。
- [CLI 与 Evidence](docs/cli.md):命令、提供方选择、快照、导入/导出和退出状态。
- [MCP 契约](docs/mcp-contracts.md)和[智能体提示词](docs/mcp-prompts.md):工具结果、会话和引导式调查。
- [工具目录](docs/mcp-contracts.md#generated-catalog):构建时生成的工具、提供方和 CLI 命令清单。
- [路线图](docs/roadmap.md):计划中的工作和能力跟踪。
请按照 [SECURITY.md](SECURITY.md) 报告漏洞。
## 参与贡献
欢迎帮助改进 REA!你可以[提交 issue](https://github.com/morluto/rea/issues) 报告错误或建议功能,也可以[提交 pull request](https://github.com/morluto/rea/pulls) 改进代码或文档。
开发设置和检查见 [CONTRIBUTING.md](CONTRIBUTING.md),验证流程见[测试指南](docs/testing.md),项目结构见[架构图](docs/architecture.mermaid)。
## 项目链接
[网站](https://rea.tools/) · [npm](https://www.npmjs.com/package/rea-agents) · [skills.sh](https://skills.sh/morluto/rea/reverse-engineer-anything) · [Issues](https://github.com/morluto/rea/issues) · [安全](SECURITY.md)
开发环境、架构、测试和发布说明请参阅 [CONTRIBUTING.md](CONTRIBUTING.md)。
## Star 历史
🎉 **GitHub Star 达到 20,000 个,感谢大家!**
🎉 **GitHub Star 突破 20,000,感谢大家!**
感谢每一位使用 REA、报告错误、测试构建和贡献修复的朋友。
感谢每一位使用 REA、反馈问题、测试构建和贡献修复的朋友。
<a href="https://www.star-history.com/?repos=morluto%2Frea&amp;type=date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=morluto/rea&amp;type=date&amp;theme=dark&amp;legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=morluto/rea&amp;type=date" />
<img alt="REA 的 GitHub Star 历史" src="https://api.star-history.com/chart?repos=morluto/rea&amp;type=date" />
<img alt="REA GitHub Star 历史" src="https://api.star-history.com/chart?repos=morluto/rea&amp;type=date" />
</picture>
</a>
## 免责声明
REA 为合法的逆向工程研究、分析和重建提供工具。你有责任取得所需授权并遵守适用法律。项目不支持非法或未经授权的使用。
## 许可证
[MIT](LICENSE)
+45 -136
View File
@@ -52,8 +52,6 @@ import ghidra.app.decompiler.ClangCaseToken;
import ghidra.app.decompiler.ClangNode;
import ghidra.framework.Application;
import ghidra.program.model.address.Address;
import ghidra.program.model.address.AddressIterator;
import ghidra.program.model.address.AddressSet;
import ghidra.program.model.address.AddressSpace;
import ghidra.program.model.address.AddressRange;
import ghidra.program.model.address.AddressRangeIterator;
@@ -725,7 +723,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
if (!direction.equals("incoming") && !direction.equals("outgoing")) {
throw new RequestFailure("invalid_request", "direction must be incoming or outgoing");
}
List<Reference> references = collectReferences(function, direction);
InstructionScan scan = scanInstructions(function, Integer.MAX_VALUE);
List<Reference> references = collectReferences(scan.instructions, direction);
JsonArray edges = new JsonArray();
for (Reference reference : references) {
edges.add(referenceEdge(reference));
@@ -736,7 +735,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
result.add("references", edges);
result.addProperty("reference_kinds_available", true);
JsonArray unresolvedCalls = new JsonArray();
if (direction.equals("outgoing")) for (Instruction instruction : scanInstructions(function, Integer.MAX_VALUE).instructions) {
if (direction.equals("outgoing")) for (Instruction instruction : scan.instructions) {
if (!instruction.getFlowType().isCall()) continue;
boolean resolved = false;
for (Reference reference : instruction.getReferencesFrom()) if (reference.getReferenceType().isCall()) resolved = true;
@@ -1008,11 +1007,6 @@ public final class ReaGhidraBridge extends HeadlessScript {
Address entry = function.getEntryPoint();
if (function.isExternal() || !currentProgram.getMemory().contains(entry))
throw new RequestFailure("invalid_request", "Annotations require a local function entry: " + canonicalAddress(entry));
for (String field : List.of("name", "comment", "inline_comment")) {
if (params.has(field)) validateAnnotationText(requireText(params, field), field);
}
String leafName = params.has("name")
? annotationLeafName(function, requireString(params, "name")) : null;
int transaction = currentProgram.startTransaction("REA function annotations");
boolean commit = false;
try {
@@ -1026,7 +1020,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
}
}
if (params.has("name"))
function.setName(leafName, SourceType.USER_DEFINED);
function.setName(requireString(params, "name"), SourceType.USER_DEFINED);
monitor.checkCancelled();
invalidateAnalysisCaches();
JsonObject readback = annotationReadback(function);
@@ -1034,7 +1028,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
if (!params.has(field)) continue;
String requested = requireText(params, field);
JsonElement measured = readback.get(field);
String expected = field.equals("name") ? leafName : requested.isEmpty() ? null : requested;
String expected = !field.equals("name") && requested.isEmpty() ? null : requested;
String observed = field.equals("name") ? function.getName() :
(measured.isJsonNull() ? null : measured.getAsString());
if (!java.util.Objects.equals(expected, observed))
@@ -1064,22 +1058,6 @@ public final class ReaGhidraBridge extends HeadlessScript {
}
}
private String annotationLeafName(Function function, String requested) {
// Fully qualified readback must be usable as an idempotent rename input.
if (requested.equals(procedureName(function))) return function.getName();
String namespace = function.getParentNamespace().isGlobal()
? "" : function.getParentNamespace().getName(true);
String prefix = namespace + "::";
if (!namespace.isEmpty() && requested.startsWith(prefix)) {
String leaf = requested.substring(prefix.length());
if (!leaf.isEmpty()) return leaf;
throw new RequestFailure("invalid_function_name",
"Function name has an empty leaf within namespace " + namespace + " at " +
canonicalAddress(function.getEntryPoint()) + ": " + requested);
}
return requested;
}
private static String requireText(JsonObject params, String field) {
JsonElement value = params.get(field);
if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString())
@@ -1087,21 +1065,6 @@ public final class ReaGhidraBridge extends HeadlessScript {
return value.getAsString();
}
private static void validateAnnotationText(String text, String field) {
for (int index = 0; index < text.length(); index++) {
char character = text.charAt(index);
if (character == 0)
throw new RequestFailure("invalid_request", "Annotation " + field + " contains NUL at UTF-16 index " + index + "; Ghidra's decompiler cannot marshal NUL text");
if (Character.isHighSurrogate(character) && index + 1 < text.length() &&
Character.isLowSurrogate(text.charAt(index + 1))) {
index++;
continue;
}
if (Character.isSurrogate(character))
throw new RequestFailure("invalid_request", "Annotation " + field + " contains an unpaired Unicode surrogate at UTF-16 index " + index + "; Ghidra cannot preserve it as annotation text");
}
}
private JsonObject analyzeFunction(JsonObject params) throws Exception {
requireKeys(params, Set.of("procedure"));
Function function = resolveProcedure(requireString(params, "procedure"));
@@ -1111,11 +1074,11 @@ public final class ReaGhidraBridge extends HeadlessScript {
if (pseudocode == null) {
pseudocode = "";
}
List<Reference> incomingReferences = collectReferences(function, "incoming")
List<Reference> incomingReferences = collectReferences(scan.instructions, "incoming")
.stream()
.filter(reference -> !function.getBody().contains(reference.getFromAddress()))
.toList();
List<Reference> outgoingReferences = collectReferences(function, "outgoing");
List<Reference> outgoingReferences = collectReferences(scan.instructions, "outgoing");
JsonArray incoming = referenceEdges(incomingReferences);
JsonArray outgoing = referenceEdges(outgoingReferences);
JsonArray comments = comments(scan.instructions);
@@ -1508,7 +1471,6 @@ public final class ReaGhidraBridge extends HeadlessScript {
: regexMatcher(expression, caseSensitive);
JsonArray items = new JsonArray();
for (InventoryItem item : inventory) {
monitor.checkCancelled();
if (!matcher.matches(item.value)) {
continue;
}
@@ -1619,37 +1581,33 @@ public final class ReaGhidraBridge extends HeadlessScript {
}
private Function resolveProcedure(String value) throws Exception {
// Explicit address syntax wins; an unprefixed identifier is first an
// exact symbol name, even when every character is a hexadecimal digit.
boolean explicitAddress = value.matches("(?i)^0x[0-9a-f]+$") ||
value.matches("(?i)^(?:[a-z0-9._~-]|%[0-9a-f]{2})+:0x[0-9a-f]+$");
if (!explicitAddress) {
List<Function> matches = new ArrayList<>();
for (FunctionEntry entry : procedures()) {
monitor.checkCancelled();
Function function = entry.function;
Symbol symbol = function.getSymbol();
String qualified = symbol == null ? function.getName() : symbol.getName(true);
if (function.getName().equals(value) || qualified.equals(value)) {
matches.add(function);
}
Address address = tryParseAddress(value);
if (address != null) {
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
if (function == null && address.isMemoryAddress()) {
function = currentProgram.getFunctionManager().getFunctionContaining(address);
}
if (matches.size() > 1)
throw new RequestFailure("ambiguous", "Ghidra procedure name is ambiguous: " + value +
"; select an exact entry address from " + matches.stream()
.map(function -> canonicalAddress(function.getEntryPoint())).toList());
if (matches.size() == 1) return matches.get(0);
if (function == null) {
throw new RequestFailure("not_found", "No procedure exists at the requested address");
}
return function;
}
// Retain legacy bare-hex address input only when no symbol matches.
Address address = explicitAddress ? parseReaAddress(value) : tryParseAddress(value);
if (address == null)
throw new RequestFailure("not_found", "Unknown Ghidra procedure name or address: " + value);
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
if (function == null && address.isMemoryAddress())
function = currentProgram.getFunctionManager().getFunctionContaining(address);
if (function == null)
throw new RequestFailure("not_found", "No procedure exists at the requested address: " + value);
return function;
List<Function> matches = new ArrayList<>();
for (FunctionEntry entry : procedures()) {
Function function = entry.function;
Symbol symbol = function.getSymbol();
String qualified = symbol == null ? function.getName() : symbol.getName(true);
if (function.getName().equals(value) || qualified.equals(value)) {
matches.add(function);
}
}
if (matches.isEmpty()) {
throw new RequestFailure("not_found", "Unknown Ghidra procedure name");
}
if (matches.size() != 1) {
throw new RequestFailure("ambiguous", "Ghidra procedure name is ambiguous");
}
return matches.get(0);
}
private JsonObject nativeApiBoundary(
@@ -2572,32 +2530,21 @@ public final class ReaGhidraBridge extends HeadlessScript {
}
private List<Reference> collectReferences(
Function function,
List<Instruction> instructions,
String direction) throws Exception {
// References can target instruction interiors or originate in embedded
// data. Use actual body ownership, including an external's exact entry.
AddressSet scope = new AddressSet(function.getBody());
scope.add(function.getEntryPoint());
boolean outgoing = direction.equals("outgoing");
AddressIterator addresses = outgoing
? currentProgram.getReferenceManager().getReferenceSourceIterator(scope, true)
: currentProgram.getReferenceManager().getReferenceDestinationIterator(scope, true);
TreeMap<String, Reference> observed = new TreeMap<>();
while (addresses.hasNext()) {
for (Instruction instruction : instructions) {
monitor.checkCancelled();
Address address = addresses.next();
if (outgoing) {
if (direction.equals("outgoing")) {
for (Reference reference : currentProgram.getReferenceManager()
.getReferencesFrom(address)) {
monitor.checkCancelled();
.getReferencesFrom(instruction.getAddress())) {
addReference(observed, reference);
}
}
else {
ReferenceIterator iterator = currentProgram.getReferenceManager()
.getReferencesTo(address);
.getReferencesTo(instruction.getAddress());
while (iterator.hasNext()) {
monitor.checkCancelled();
addReference(observed, iterator.next());
}
}
@@ -3000,7 +2947,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
private Address parseReaAddress(String value) {
Address address = tryParseAddress(value);
if (address == null) {
throw new RequestFailure("invalid_request", "Unknown or invalid Ghidra address: " + value);
throw new RequestFailure("invalid_request", "Unknown or invalid Ghidra address");
}
return address;
}
@@ -3009,12 +2956,12 @@ public final class ReaGhidraBridge extends HeadlessScript {
try {
String spaceName = null;
String offset = value;
int separator = value.toLowerCase(Locale.ROOT).lastIndexOf(":0x");
int separator = value.lastIndexOf(":0x");
if (separator >= 0) {
spaceName = decodeAddressSpace(value.substring(0, separator));
offset = value.substring(separator + 3);
}
else if (value.startsWith("0x") || value.startsWith("0X")) {
else if (value.startsWith("0x")) {
offset = value.substring(2);
}
if (!offset.matches("[0-9A-Fa-f]+")) {
@@ -3023,17 +2970,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
AddressSpace space = spaceName == null
? currentProgram.getAddressFactory().getDefaultAddressSpace()
: currentProgram.getAddressFactory().getAddressSpace(spaceName);
if (space == null) return null;
Address address = space.getAddress(offset);
BigInteger requestedOffset = new BigInteger(offset, 16);
BigInteger parsedOffset = new BigInteger(Long.toUnsignedString(address.getOffset()));
if (!requestedOffset.equals(parsedOffset))
throw new RequestFailure("invalid_request",
"Ghidra address offset cannot be represented without truncation in space " +
space.getName() + ": requested " + value + ", parsed " + canonicalAddress(address));
return address;
return space == null ? null : space.getAddress(offset);
}
catch (RequestFailure failure) { throw failure; }
catch (Exception exception) {
return null;
}
@@ -3117,29 +3055,9 @@ public final class ReaGhidraBridge extends HeadlessScript {
);
}
catch (PatternSyntaxException exception) {
// Java wraps compiler stack exhaustion in PatternSyntaxException
// without retaining its cause. This producer reason is not bad syntax.
if (exception.getDescription().equals("Stack overflow during pattern compilation"))
throw regexStackExhausted(expression, "compiling");
throw new RequestFailure("invalid_request", "Invalid regex pattern " + expression + ": " + exception.getDescription() + " at index " + exception.getIndex());
throw new RequestFailure("invalid_request", "Invalid regex pattern");
}
catch (StackOverflowError exhausted) {
throw regexStackExhausted(expression, "compiling");
}
return value -> {
try {
return pattern.matcher(value).find();
}
catch (StackOverflowError exhausted) {
throw regexStackExhausted(expression, "matching");
}
};
}
private static RequestFailure regexStackExhausted(String expression, String stage) {
return new RequestFailure("regex_stack_exhausted",
"Ghidra's Java regex engine exhausted its stack while " + stage +
" pattern " + expression + ". Use literal mode or simplify the regex; the session remains usable.");
return value -> pattern.matcher(value).find();
}
private static SessionDescriptor readDescriptor(Path path) throws IOException {
@@ -3196,7 +3114,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
private void requireDocument(JsonObject params) {
String document = optionalString(params, "document");
if (document != null && !document.equals(currentProgram.getName())) {
throw new RequestFailure("not_found", "Unknown Ghidra Program identity: " + document + "; active program: " + currentProgram.getName());
throw new RequestFailure("not_found", "Unknown Ghidra Program identity");
}
}
@@ -3308,16 +3226,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
private static void writeResponse(BufferedWriter writer, JsonObject response) throws IOException {
String encoded = GSON.toJson(response);
// Gson leaves UTF-16 surrogates literal. Escape them in JSON so even
// diagnostics quoting ill-formed caller text remain lossless UTF-8.
int start = 0;
for (int index = 0; index < encoded.length(); index++) {
if (!Character.isSurrogate(encoded.charAt(index))) continue;
writer.write(encoded, start, index - start);
writer.write("\\u" + Integer.toHexString(encoded.charAt(index)));
start = index + 1;
}
writer.write(encoded, start, encoded.length() - start);
writer.write(encoded);
writer.newLine();
writer.flush();
}
+97 -322
View File
@@ -8,13 +8,12 @@ API access on this thread: moving dispatch to a worker can deadlock Hopper.
import json
import hmac
import os
import re
import socket
import struct
from typing import Any, Optional, Protocol, Sequence
BAD_ADDRESSES = (-1, 0xFFFFFFFFFFFFFFFF, None)
# The native label setter truncates NSString names at this UTF-16 extent.
HOPPER_SYMBOL_NAME_UTF16_UNITS = 1024
_selected_document = None
_rea_session_document = None
_search_inventory_cache = {}
_pseudocode_cache = {}
@@ -90,11 +89,9 @@ def _session_document():
global _rea_session_document
documents = _api().documents()
if _rea_session_document is not None:
# Hopper recreates Python wrappers; equality compares native handles.
# Retain our bound wrapper so cache identity stays stable across calls.
return (
_rea_session_document
if any(document == _rea_session_document for document in documents)
if any(document is _rea_session_document for document in documents)
else None
)
return _bind_session_document(documents)
@@ -177,89 +174,46 @@ def _json_safe(value):
def _document(name=None):
"""Keep observations and mutations bound to the active REA target."""
documents = _api().documents()
session_document = _session_document()
if session_document is None:
raise CapabilityUnavailableError("The active REA target's Hopper document is no longer open; reopen the target with open_binary")
"""Resolve an explicit or session-selected document without changing Hopper UI."""
global _selected_document
api = _api()
documents = api.documents()
if name is not None:
# A document name is display metadata; scope it to the bound native
# handle even when another open target happens to have the same name.
if session_document.getDocumentName() == name:
return session_document
if any(candidate.getDocumentName() == name for candidate in documents):
raise InvalidRequestError("Hopper document belongs to another target; use open_binary to select that target")
matches = [candidate for candidate in documents if candidate.getDocumentName() == name]
if len(matches) == 1:
return matches[0]
if len(matches) > 1:
current = api.current_document()
if current is not None and current.getDocumentName() == name:
return current
raise InvalidRequestError("Hopper document name is ambiguous")
raise InvalidRequestError("Unknown Hopper document")
return session_document
def _api_text(value, field):
"""Validate text before passing it to Hopper's native UTF-8 string API."""
if not isinstance(value, str):
raise InvalidRequestError("%s must be a string" % field)
if "\0" in value:
raise InvalidRequestError("%s contains a NUL character, which Hopper's Python API cannot represent" % field)
try:
value.encode("utf-8")
except UnicodeEncodeError as error:
raise InvalidRequestError("%s contains an unpaired Unicode surrogate, which Hopper's Python API cannot represent" % field) from error
return value
def _validate_name_targets(document, names):
"""Keep Hopper's unique-label moves within the explicitly selected addresses."""
targets = {address for _, address, _ in names}
if len(targets) != len(names):
raise InvalidRequestError("Batch rename selects the same address more than once; use one canonical address per target")
labels = set()
for _, address, name in names:
_segment(document, address)
if not name:
continue
if name in labels:
raise InvalidRequestError("Batch rename assigns the same name more than once: %s" % name)
labels.add(name)
owner = document.getAddressForName(name)
if owner not in BAD_ADDRESSES and owner != address and owner not in targets:
raise InvalidRequestError("Name %s is already assigned to %s; include that address in the batch with a replacement name" % (name, _hex(owner)))
def _symbol_name(value, field):
"""Reject native label truncation without altering the caller's name."""
value = _api_text(value, field)
if len(value.encode("utf-16-le")) // 2 > HOPPER_SYMBOL_NAME_UTF16_UNITS:
raise InvalidRequestError("%s exceeds Hopper's %s UTF-16 code-unit symbol-name limit" % (field, HOPPER_SYMBOL_NAME_UTF16_UNITS))
return value
def _name_matches(document, address, name):
observed = _segment(document, address).getNameAtAddress(address)
return observed == name or (not name and observed is None)
if _selected_document is not None:
for candidate in documents:
if candidate.getDocumentName() == _selected_document:
return candidate
session_document = _session_document()
if session_document is not None:
return session_document
current = api.current_document()
if current is None:
raise CapabilityUnavailableError("No Hopper document is loaded")
return current
def _address(document, value=None):
"""Keep explicit hexadecimal coordinates distinct from symbol names."""
"""Resolve hexadecimal addresses first, then fall back to Hopper symbol names."""
if value is None:
return document.getCurrentAddress()
_api_text(value, "Address")
if value.lower().startswith("0x"):
try:
address = int(value, 16)
except ValueError as error:
raise InvalidRequestError("Invalid hexadecimal Hopper address: %s" % value) from error
else:
# Names such as `add` and `face` are valid hexadecimal strings too.
# Prefer an observed symbol, retaining bare-hex compatibility only
# when Hopper has no symbol with the supplied name.
address = document.getAddressForName(value)
if address in BAD_ADDRESSES:
try:
address = int(value, 16)
except ValueError as error:
raise InvalidRequestError("Unknown Hopper address or name: %s" % value) from error
if not 0 <= address < 0xFFFFFFFFFFFFFFFF:
raise InvalidRequestError("Address is outside Hopper's usable unsigned 64-bit range: %s" % value)
return address
if not isinstance(value, str):
raise InvalidRequestError("Address must be a string")
try:
return int(value, 16)
except ValueError:
result = document.getAddressForName(value)
if result in BAD_ADDRESSES:
raise InvalidRequestError("Unknown Hopper address or name")
return result
def _segment(document, address):
@@ -277,76 +231,6 @@ def _procedure(document, value=None):
return result
def _original_file_offset(document, offset):
"""Translate Hopper's image-relative mapping using the observed loaded header."""
if offset > 9007199254740991:
raise CapabilityUnavailableError("Provider file offset exceeds the exact JSON integer range")
path = globals().get("REA_ORIGINAL_EXECUTABLE_PATH") or document.getExecutableFilePath()
if not path:
raise CapabilityUnavailableError("Hopper did not identify the original executable for file-offset verification")
try:
with open(path, "rb") as source:
size = os.fstat(source.fileno()).st_size
header = source.read(8)
formats = {
b"\xca\xfe\xba\xbe": (">", False),
b"\xbe\xba\xfe\xca": ("<", False),
b"\xca\xfe\xba\xbf": (">", True),
b"\xbf\xba\xfe\xca": ("<", True),
}
fat = formats.get(header[:4])
if fat is None:
if offset >= size:
raise InvalidRequestError("Mapped offset is outside the original executable file at %s" % path)
return offset, path, 0
if len(header) != 8:
raise CapabilityUnavailableError("Original FAT executable has a truncated header at %s" % path)
byte_order, wide = fat
count = struct.unpack(byte_order + "I", header[4:])[0]
stride = 32 if wide else 20
table_end = 8 + count * stride
if count == 0 or table_end > size:
raise CapabilityUnavailableError("Original FAT executable has an invalid architecture table at %s" % path)
header_address = document.getAddressFromFileOffset(0)
reader = getattr(document, "readBytes", None)
if header_address in BAD_ADDRESSES or document.getSegmentAtAddress(header_address) is None or not callable(reader):
raise CapabilityUnavailableError("Hopper did not expose a readable loaded Mach-O header to identify its FAT slice")
loaded = reader(header_address, 12)
if not isinstance(loaded, (bytes, bytearray)) or len(loaded) != 12:
raise CapabilityUnavailableError("Hopper did not expose the loaded Mach-O header to identify its FAT slice")
loaded_order = {b"\xce\xfa\xed\xfe": "<", b"\xcf\xfa\xed\xfe": "<", b"\xfe\xed\xfa\xce": ">", b"\xfe\xed\xfa\xcf": ">"}.get(bytes(loaded[:4]))
if loaded_order is None:
raise CapabilityUnavailableError("Hopper's loaded header is not a supported Mach-O image")
identity = struct.unpack(loaded_order + "II", loaded[4:12])
selected = None
for index in range(count):
source.seek(8 + index * stride)
entry = source.read(stride)
if len(entry) != stride:
raise CapabilityUnavailableError("Original FAT executable architecture table changed during verification")
cpu, subtype, start, length, alignment = struct.unpack(byte_order + ("IIQQI" if wide else "IIIII"), entry[:28] if wide else entry)
if length < 12 or start < table_end or start > size or length > size - start or alignment > 52 or start % (1 << alignment):
raise CapabilityUnavailableError("Original FAT executable has an invalid slice range at index %s" % index)
if (cpu, subtype) != identity:
continue
source.seek(start)
if source.read(12) != bytes(loaded):
raise CapabilityUnavailableError("FAT slice declaration disagrees with Hopper's loaded header")
if selected is not None:
raise CapabilityUnavailableError("Multiple FAT slices match Hopper's loaded header; original-file mapping is ambiguous")
selected = (start, length)
if selected is None:
raise CapabilityUnavailableError("No original FAT slice matches Hopper's loaded header")
start, length = selected
if offset >= length:
raise InvalidRequestError("Mapped offset is outside Hopper's loaded FAT slice")
if start + offset > 9007199254740991:
raise CapabilityUnavailableError("Original file offset exceeds the exact JSON integer range")
return start + offset, path, start
except OSError as error:
raise CapabilityUnavailableError("Cannot read the original executable for file-offset verification at %s: %s" % (path, error)) from error
def _procedure_name(procedure):
entry = procedure.getEntryPoint()
return procedure.getSegment().getNameAtAddress(entry) or _hex(entry)
@@ -365,18 +249,14 @@ def _procedure_identity(procedure):
def _procedure_locals(procedure):
"""Preserve public local-variable names and observed stack displacements."""
result = []
for local in procedure.getLocalVariableList():
name = local.name()
displacement = local.displacement()
result.append({
"description": "%s (stack displacement %s)" % (name if name is not None else "Unnamed local", displacement),
"name": name,
"stack_displacement": str(displacement) if displacement is not None else None,
"""Project opaque Hopper local-variable objects into an exact public shape."""
return [
{
"description": str(local),
"provenance": "hopper-public-python-api",
})
return result
}
for local in procedure.getLocalVariableList()
]
def _containing_procedure(document, address):
@@ -407,22 +287,6 @@ def _instruction_addresses(procedure):
return result
def _native_calls(procedure, direction):
references = procedure.getAllCallees() if direction == "outgoing" else procedure.getAllCallers()
kinds = {0: "none", 1: "unknown", 2: "direct", 3: "objective_c"}
calls = {}
unresolved = []
for reference in references:
source, target, kind = reference.fromAddress(), reference.toAddress(), reference.type()
if source in BAD_ADDRESSES:
continue
if target in BAD_ADDRESSES:
unresolved.append({"address": _hex(source), "reason": "Native CallReference has no resolved target (type %s)" % kind})
continue
calls[(source, target)] = {"classification": kinds.get(kind, "unknown"), "provider_type": kind, "provenance": "hopper-public-python-api:CallReference"}
return calls, sorted(unresolved, key=lambda item: int(item["address"], 16))
def _procedure_references(document, params):
procedure = _procedure(document, params.get("procedure"))
direction = params.get("direction", "outgoing")
@@ -435,8 +299,6 @@ def _procedure_references(document, params):
references = segment.getReferencesFromAddress(address) if direction == "outgoing" else segment.getReferencesOfAddress(address)
for reference in references:
edges.add((address, reference) if direction == "outgoing" else (reference, address))
calls, unresolved = _native_calls(procedure, direction)
edges.update(calls)
ordered = sorted(edges)
items = []
for source, target in ordered:
@@ -447,15 +309,16 @@ def _procedure_references(document, params):
"target_address": _hex(target),
"source_procedure": _procedure_identity(source_procedure) if source_procedure is not None else None,
"target_procedure": _procedure_identity(target_procedure) if target_procedure is not None else None,
"kind": _unavailable("Hopper exposes partial CallReference classification, but not detailed reference flags"),
**({"call": calls[(source, target)]} if (source, target) in calls else {}),
"kind": _unavailable("Hopper's public Python API does not classify reference kinds"),
})
return {
"procedure": _procedure_identity(procedure), "direction": direction,
"reference_kinds_available": False,
# These are native CallReference objects, not proof that every unresolved
# dispatch site has been enumerated by Hopper.
"unresolved_calls": unresolved,
# The public Hopper API returns observed references but does not expose
# enough flow metadata to enumerate calls with no resolved target.
# Keep the required list field explicit; the provider records this as
# unknown coverage rather than claiming the list is exhaustive.
"unresolved_calls": [],
"references": items,
}
@@ -470,61 +333,11 @@ def _procedure_map(document):
def _strings(document):
return {address: record["value"] for address, record in _string_inventory(document).items()}
def _string_record(segment, address, display):
"""Read one native typed object, retaining its shortened display separately."""
length = segment.getObjectLength(address)
end = segment.getStartingAddress() + segment.getLength()
if isinstance(length, bool) or not isinstance(length, int) or length <= 0 or length > end - address:
raise CapabilityUnavailableError("Hopper reported an invalid string object extent at %s" % _hex(address))
raw = segment.readBytes(address, length)
if not isinstance(raw, (bytes, bytearray)) or len(raw) != length:
raise CapabilityUnavailableError("Hopper could not read the complete string object at %s" % _hex(address))
kind = segment.getTypeAtAddress(address)
encodings = ("utf-8", "latin-1") if kind == segment.TYPE_ASCII else ("utf-16-le", "utf-16-be") if kind == segment.TYPE_UNICODE else ()
# Native display escaping is not invertible (a literal backslash-n and a
# newline can render alike). Decode byte strings directly; for UTF-16,
# compare rendered candidates to establish an unambiguous byte order.
candidates = []
for encoding in encodings:
terminator = b"\x00\x00" if encoding.startswith("utf-16") else b"\x00"
terminated = raw.endswith(terminator)
payload = raw[:-len(terminator)] if terminated else raw
try:
value = payload.decode(encoding)
except UnicodeDecodeError:
continue
record = {"value": value, "provider_value": display, "string": {"encoding": encoding, "encoding_status": "inferred", "termination": "present_or_not_required" if terminated else "missing", "byte_length": length}}
if kind == segment.TYPE_ASCII:
return record
rendered = value.replace('"', '\\"').replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
if rendered == display or (display.endswith("\u2026") and display[:-1] and rendered.startswith(display[:-1])):
candidates.append(record)
if candidates and len({candidate["value"] for candidate in candidates}) == 1:
return candidates[0]
raise CapabilityUnavailableError("Hopper's string display cannot be reconciled with its typed bytes at %s; inspect the bytes and encoding explicitly" % _hex(address))
def _string_inventory(document, addresses=None):
key = (id(document), "string_objects")
objects = _search_inventory_cache.get(key)
if objects is None:
objects = {address: (segment, display) for segment in document.getSegmentsList() for display, address in segment.getStringsList()}
_search_inventory_cache[key] = objects
key = (id(document), "string_records")
records = _search_inventory_cache.setdefault(key, {})
selected = sorted(objects if addresses is None else objects.keys() & addresses)
for address in selected:
name = _hex(address)
if name not in records:
segment, display = objects[address]
try:
records[name] = _string_record(segment, address, display)
except CapabilityUnavailableError as error:
records[name] = {"value": display, "provider_value": display, "decoding": _unavailable(str(error))}
return {_hex(address): records[_hex(address)] for address in selected}
result = {}
for segment in document.getSegmentsList():
for value, address in segment.getStringsList():
result[_hex(address)] = value
return result
def _invalidate_search_inventory(document):
@@ -584,14 +397,18 @@ def _search_results(document, kind, params):
needle = pattern if case_sensitive else pattern.casefold()
matches = lambda value: needle in (value if case_sensitive else value.casefold())
else:
raise CapabilityUnavailableError("Regex matching requires REA's supervised worker; the native bridge only executes literal searches")
try:
expression = re.compile(pattern, 0 if case_sensitive else re.IGNORECASE)
except (re.error, OverflowError) as error:
raise InvalidRequestError("Invalid regex pattern") from error
matches = lambda value: expression.search(value) is not None
selected = []
for item in _search_inventory(document, kind):
if not matches(item[1]):
continue
selected.append(item)
return [{"address": address, **_string_inventory(document)[address]} for address, _ in selected] if kind == "string" else [
return [
{
"address": address,
"value": value,
@@ -712,10 +529,6 @@ def _analyze_function(document, params):
incoming = []
outgoing = []
procedure_addresses = set(addresses)
outgoing_calls, unresolved_calls = _native_calls(procedure, "outgoing")
incoming_calls, _ = _native_calls(procedure, "incoming")
calls = {**incoming_calls, **outgoing_calls}
edges.update(calls)
for source, target in sorted(edges):
source_procedure, _ = _containing_procedure(document, source)
target_procedure, _ = _containing_procedure(document, target)
@@ -724,14 +537,16 @@ def _analyze_function(document, params):
"target_address": _hex(target),
"source_procedure": _procedure_identity(source_procedure) if source_procedure is not None else None,
"target_procedure": _procedure_identity(target_procedure) if target_procedure is not None else None,
"kind": _unavailable("Hopper exposes partial CallReference classification, but not detailed reference flags"),
**({"call": calls[(source, target)]} if (source, target) in calls else {}),
"kind": _unavailable("Hopper's public Python API does not classify reference kinds"),
}
if target in procedure_addresses and source not in procedure_addresses:
incoming.append(item)
if source in procedure_addresses:
outgoing.append(item)
string_map = {int(address, 16): record for address, record in _string_inventory(document, {int(edge["target_address"], 16) for edge in outgoing}).items()}
string_map = {
int(address, 16): value
for address, value in _search_inventory(document, "string")
}
name_map = {
int(address, 16): value
for address, value in _search_inventory(document, "name")
@@ -741,7 +556,7 @@ def _analyze_function(document, params):
for edge in outgoing:
target = int(edge["target_address"], 16)
if target in string_map:
referenced_strings.append({"address": edge["target_address"], **string_map[target], "source_address": edge["source_address"]})
referenced_strings.append({"address": edge["target_address"], "value": string_map[target], "source_address": edge["source_address"]})
if target in name_map:
referenced_names.append({"address": edge["target_address"], "value": name_map[target], "source_address": edge["source_address"]})
comments.sort(key=lambda item: (int(item["address"], 16), item["kind"]))
@@ -759,14 +574,13 @@ def _analyze_function(document, params):
"callers": callers, "callees": callees,
"incoming_references": incoming,
"outgoing_references": outgoing,
"unresolved_calls": unresolved_calls,
"referenced_strings": referenced_strings,
"referenced_names": referenced_names,
"basic_blocks": blocks,
"limitations": [
"Native CallReference classifications are observed where available; detailed reference flags remain unknown.",
"Hopper's public Python API does not classify reference kinds.",
"Hopper's public Python API does not expose equivalent external or thunk classification in this dossier.",
"Native calls without resolved target addresses are not represented as edges; enumeration beyond reported CallReference objects is unknown.",
"Unresolved indirect calls without reported target addresses are not represented as call edges.",
"Pseudocode and assembly are provider-specific representations, not original source.",
],
}
@@ -774,6 +588,7 @@ def _analyze_function(document, params):
def _dispatch(method, params):
"""Dispatch only the closed operation set implemented by REA's public tools."""
global _selected_document
if method == "health":
return {"name": "REA Hopper bridge", "version": "1.0.0", "run_id": REA_RUN_ID}
if method in ("shutdown", "shutdown_document"):
@@ -807,9 +622,14 @@ def _dispatch(method, params):
"document_closed": document_closed,
}
if method == "list_documents":
return [document.getDocumentName() for document in _api().documents()]
return [document.getDocumentName() for document in Document.getAllDocuments()]
if method == "current_document":
return _document().getDocumentName()
if method == "set_current_document":
document = _document(params.get("document"))
_selected_document = document.getDocumentName()
return _selected_document
document = _document(params.get("document"))
if method in EXHAUSTIVE_ANALYSIS_METHODS:
_api().require_analysis_complete(document, method)
@@ -830,17 +650,10 @@ def _dispatch(method, params):
"Hopper's public Python API does not expose readBytes"
)
address = _address(document, params.get("address"))
segment = _segment(document, address)
# Document.readBytes delegates to one Segment and returns False when
# the requested range crosses its end. Preserve the readable prefix.
available = segment.getStartingAddress() + segment.getLength() - address
value = reader(address, min(length, available))
if value is False or value is None:
raise InvalidRequestError("Hopper could not read the requested byte range at %s" % _hex(address))
value = reader(address, length)
if not isinstance(value, (bytes, bytearray)):
raise CapabilityUnavailableError(
"Hopper readBytes returned %s for the requested %s-byte range at %s"
% (type(value).__name__, length, _hex(address))
"Hopper's public Python API returned an unsupported byte representation"
)
data = bytes(value)
return {
@@ -865,17 +678,7 @@ def _dispatch(method, params):
or offset < 0
):
raise InvalidRequestError("Address has no authoritative file-offset mapping")
inverse = getattr(document, "getAddressFromFileOffset", None)
if not callable(inverse):
raise CapabilityUnavailableError(
"Hopper's public Python API cannot verify the file-offset mapping by reverse lookup"
)
# Synthetic memory can report offset zero, which belongs to the actual
# file header. An integer alone does not prove source-file provenance.
if inverse(offset) != address:
raise InvalidRequestError("Address has no authoritative file-offset mapping: reverse lookup disagrees at %s" % _hex(address))
original_offset, source_path, image_base = _original_file_offset(document, offset)
return {"address": _hex(address), "file_offset": original_offset, "provider_file_offset": offset, "image_base_file_offset": image_base, "source_path": source_path}
return {"address": _hex(address), "file_offset": offset}
if method == "resolve_containing_procedure":
address = _address(document, params.get("address"))
procedure, reason = _containing_procedure(document, address)
@@ -891,13 +694,8 @@ def _dispatch(method, params):
return _procedure_name(_procedure(document))
if method == "goto_address":
address = _address(document, params.get("address"))
segment = _segment(document, address)
expected = segment.getInstructionStart(address)
document.moveCursorAtAddress(address)
observed = document.getCurrentAddress()
if observed != (address if expected in BAD_ADDRESSES else expected):
raise InvalidRequestError("Hopper cursor did not reach the requested object at %s; observed %s" % (_hex(address), _hex(observed)))
return _hex(observed)
return _hex(address)
if method in ("address_name", "comment", "inline_comment", "xrefs"):
target = _address(document, params.get("address"))
segment = _segment(document, target)
@@ -910,22 +708,12 @@ def _dispatch(method, params):
return [_hex(value) for value in segment.getReferencesOfAddress(target)]
if method in ("next_address", "prev_address"):
target = _address(document, params.get("address"))
segment = _segment(document, target)
if method == "next_address":
start = segment.getInstructionStart(target)
if start in BAD_ADDRESSES:
raise InvalidRequestError("No analyzed object exists at the requested address")
length = segment.getObjectLength(start)
if length in BAD_ADDRESSES or length <= 0:
raise InvalidRequestError("No next analyzed object exists at the requested address")
result = start + length
result = target + max(1, document.getObjectLength(target))
else:
previous_segment = document.getSegmentAtAddress(target - 1) if target > 0 else None
if previous_segment is None:
raise InvalidRequestError("No previous address exists in mapped memory")
result = previous_segment.getInstructionStart(target - 1)
if result in BAD_ADDRESSES or document.getSegmentAtAddress(result) is None:
raise InvalidRequestError("No adjacent analyzed object exists in mapped memory")
result = document.getInstructionStart(max(0, target - 1))
if result in BAD_ADDRESSES:
raise InvalidRequestError("No adjacent address")
return _hex(result)
if method == "list_segments":
result = []
@@ -959,9 +747,12 @@ def _dispatch(method, params):
if method == "list_procedures":
return [{"address": address, "value": value} for address, value in _search_inventory(document, "procedure")]
if method == "list_strings":
values = dict(_search_inventory(document, "string"))
requested = params.get("address")
values = _string_inventory(document, None if requested is None else {_address(document, requested)})
return [{"address": address, **record} for address, record in values.items()]
if requested is not None:
key = _hex(_address(document, requested))
values = {key: values[key]} if key in values else {}
return [{"address": address, "value": value} for address, value in values.items()]
if method == "list_names":
result = dict(_search_inventory(document, "name"))
requested = params.get("address")
@@ -999,51 +790,35 @@ def _dispatch(method, params):
}
if method == "set_address_name":
address = _address(document, params.get("address"))
name = _symbol_name(params["name"], "Name")
_validate_name_targets(document, [(params.get("address"), address, name)])
try:
result = document.setNameAtAddress(address, name)
result = document.setNameAtAddress(address, params["name"])
finally:
_invalidate_search_inventory(document)
_invalidate_pseudocode(document)
return bool(result) and _name_matches(document, address, name)
return result
if method == "set_addresses_names":
# Validate all destinations before applying any annotation. A malformed
# later address must not discard the result of an earlier mutation.
names = [(key, _address(document, key), _symbol_name(value, "Name for %s" % key)) for key, value in params["names"].items()]
_validate_name_targets(document, names)
try:
result = {key: document.setNameAtAddress(address, value) for key, address, value in names}
result = {key: document.setNameAtAddress(_address(document, key), value) for key, value in params["names"].items()}
finally:
_invalidate_search_inventory(document)
_invalidate_pseudocode(document)
return {key: bool(result[key]) and _name_matches(document, address, value) for key, address, value in names}
return result
if method in ("set_comment", "set_inline_comment"):
address = _address(document, params.get("address"))
comment = _api_text(params["comment"], "Comment")
segment = _segment(document, address)
setter = segment.setCommentAtAddress if method == "set_comment" else segment.setInlineCommentAtAddress
getter = segment.getCommentAtAddress if method == "set_comment" else segment.getInlineCommentAtAddress
setter(address, comment)
setter(address, params["comment"])
_invalidate_pseudocode(document)
observed = getter(address)
return observed == comment or (comment == "" and observed is None)
return getter(address) == params["comment"]
if method == "list_bookmarks":
return [{"address": _hex(item), "name": document.getBookmarkName(item)} for item in document.getBookmarks()]
if method == "set_bookmark":
address = _address(document, params.get("address"))
_segment(document, address)
name = params.get("name")
if name is not None:
_api_text(name, "Bookmark name")
document.setBookmarkAtAddress(address, name)
document.setBookmarkAtAddress(address, params.get("name"))
return document.hasBookmarkAtAddress(address)
if method == "unset_bookmark":
address = _address(document, params.get("address"))
# Permit removing a legacy orphan bookmark, but do not report success
# for an unmapped address with no bookmark to remove.
if not document.hasBookmarkAtAddress(address):
_segment(document, address)
document.removeBookmarkAtAddress(address)
return not document.hasBookmarkAtAddress(address)
raise InvalidRequestError("Unknown bridge method")
@@ -1138,14 +913,14 @@ def _diagnostic_type(error):
def _safe_diagnostic(error):
"""Preserve bridge validation reasons while excluding transport credentials."""
"""Project one exception without retaining provider or credential text."""
diagnostic_type = _diagnostic_type(error)
if diagnostic_type == "capability_unavailable":
message = str(error)
elif diagnostic_type == "authorization":
message = "Invalid bridge capability"
elif diagnostic_type == "invalid_request":
message = str(error)
message = "Invalid Hopper bridge request"
else:
message = "%s: Hopper bridge operation failed" % type(error).__name__
return {"code": -32000, "message": message, "type": diagnostic_type}
+73 -544
View File
@@ -3,18 +3,12 @@
REA imports this module into `lldb --batch` and runs `rea_trace <config.json>`.
It launches one owned process, stops at the requested functions and
Objective-C methods, records each entry from registers and memory reads only,
continues, and kills the process once it exits, the event or aggregate resource
limit is reached, or the duration elapses. Target output is drained through
bounded FIFOs. No expression is evaluated and no target code runs on the
tracer's behalf. The result is written as JSON to the configured path.
continues, and kills the process once it exits, the event limit is reached or
the duration elapses. No expression is evaluated and no target code runs on
the tracer's behalf. The result is written as JSON to the configured path.
"""
import errno
import hashlib
import json
import os
import select
import threading
import time
import lldb
@@ -28,218 +22,9 @@ ARGUMENT_REGISTERS = {
MAX_SELECTOR_BYTES = 1024
MAX_REPORTED_LOCATIONS = 64
MAX_OTHER_STOPS = 256
MAX_TRACE_BYTES = 8 * 1024 * 1024
MAX_TRACE_FRAMES = 65_536
MAX_OBSERVATION_JOURNAL_BYTES = MAX_TRACE_BYTES
MAX_BREAKPOINT_LOCATION_BYTES = 8 * 1024 * 1024
MAX_TARGET_OUTPUT_BYTES = 1024 * 1024
OUTPUT_DRAIN_BYTES_PER_PASS = 1024 * 1024
OUTPUT_EOF_WAIT_SECONDS = 1.0
OUTPUT_READER_JOIN_SECONDS = 0.5
LISTENER_NAME = "rea-native-call-observation"
def _can_retain_trace(current_bytes, current_frames, event_bytes, event_frames):
"""Whether a complete event fits both aggregate trace budgets."""
return (
current_bytes + event_bytes <= MAX_TRACE_BYTES
and current_frames + event_frames <= MAX_TRACE_FRAMES
)
def _estimated_json_size(value):
"""Estimate JSON size without encoding potentially oversized values."""
if isinstance(value, str):
# json.dumps defaults to ensure_ascii=True; supplementary characters
# can become two six-byte Unicode escapes.
return len(value) * 12 + 2
if value is None or isinstance(value, (bool, int, float)):
return 32
if isinstance(value, dict):
return 2 + sum(
len(key) * 6 + 4 + _estimated_json_size(item)
for key, item in value.items()
)
if isinstance(value, (list, tuple)):
return 2 + sum(_estimated_json_size(item) + 1 for item in value)
return 64
def _fits_trace_budget(value, remaining_bytes):
return _estimated_json_size(value) <= remaining_bytes
class _ObservationJournal:
"""Flush a bounded JSONL prefix so observations survive tracer failure."""
def __init__(self, handle, max_bytes=MAX_OBSERVATION_JOURNAL_BYTES):
self.handle = handle
self.max_bytes = max_bytes
self.bytes = 0
def append(self, record):
encoded = (json.dumps(record) + "\n").encode("utf-8")
if self.bytes + len(encoded) > self.max_bytes:
return False
self.handle.write(encoded)
self.handle.flush()
self.bytes += len(encoded)
return True
def _set_launch_flags(launch):
launch.SetLaunchFlags(
launch.GetLaunchFlags()
| lldb.eLaunchFlagDebug
| lldb.eLaunchFlagStopAtEntry
)
def _hash_file(path):
digest = hashlib.sha256()
with open(path, "rb") as target_file:
while True:
chunk = target_file.read(1024 * 1024)
if not chunk:
break
digest.update(chunk)
return digest.hexdigest()
def _file_identity(path):
info = os.stat(path)
return str(info.st_dev), str(info.st_ino)
def _module_identity(target):
module = target.FindModule(target.GetExecutable())
if not module.IsValid():
return None, None, None
spec = module.GetFileSpec()
path = str(spec) if spec.IsValid() else None
identity = None
if path:
try:
identity = _file_identity(path)
except OSError:
pass
return path, identity, module.GetUUIDString() or None
def _wait_for_identity_ack(config):
"""Hold the target at entry while the host adapter captures its start identity."""
path = config.get("identity_ack_path")
if not path:
return
while True:
try:
with open(path, "r") as handle:
return handle.read(32).strip()
except FileNotFoundError:
time.sleep(0.025)
class _OutputPipe:
"""Drain a target FIFO while retaining only its bounded output prefix."""
def __init__(self, fifo_path, capture_path, max_bytes):
os.mkfifo(fifo_path, 0o600)
self.path = fifo_path
try:
self.fd = os.open(fifo_path, os.O_RDONLY | os.O_NONBLOCK)
except Exception:
os.unlink(fifo_path)
raise
try:
self.capture = open(capture_path, "wb")
except Exception:
os.close(self.fd)
os.unlink(fifo_path)
raise
self.max_bytes = max_bytes
self.bytes = 0
self.truncated = False
self.closed = False
self.stop_reader = threading.Event()
self.reader_thread = None
self.reader_error = None
self.launched = False
self.eof = threading.Event()
self.complete = False
def start(self):
if self.reader_thread is None:
self.reader_thread = threading.Thread(target=self._reader_loop, daemon=True)
self.reader_thread.start()
def _reader_loop(self):
try:
while not self.stop_reader.is_set():
readable, _, _ = select.select([self.fd], [], [], 0.05)
if not readable:
continue
if self.drain(OUTPUT_DRAIN_BYTES_PER_PASS) == 0:
# A FIFO read end is readable at EOF before the target opens it.
time.sleep(0.01)
except Exception as error:
self.reader_error = error
self.stop_reader.set()
def mark_launched(self):
self.launched = True
def drain(self, max_bytes=OUTPUT_DRAIN_BYTES_PER_PASS):
consumed = 0
while consumed < max_bytes:
try:
chunk = os.read(self.fd, min(65_536, max_bytes - consumed))
except OSError as error:
if error.errno in (errno.EAGAIN, errno.EWOULDBLOCK):
return consumed
raise
if not chunk:
if self.launched:
self.eof.set()
return consumed
consumed += len(chunk)
self.bytes += len(chunk)
remaining = max(0, self.max_bytes - self.capture.tell())
if remaining:
self.capture.write(chunk[:remaining])
if len(chunk) > remaining:
self.truncated = True
def close(self, expected_exit=False):
if self.closed:
return
if expected_exit and self.launched:
self.eof.wait(OUTPUT_EOF_WAIT_SECONDS)
self.stop_reader.set()
if self.reader_thread is not None:
self.reader_thread.join(OUTPUT_READER_JOIN_SECONDS)
stopped = self.reader_thread is None or not self.reader_thread.is_alive()
if stopped:
try:
self.drain(OUTPUT_DRAIN_BYTES_PER_PASS)
finally:
try:
self.capture.close()
finally:
os.close(self.fd)
self.closed = True
else:
# A daemon reader may still be unwinding a blocked filesystem write.
# Leave its handles alone rather than racing that thread at shutdown.
self.closed = True
self.complete = bool(
stopped
and (not self.launched or (expected_exit and self.eof.is_set()))
and self.reader_error is None
)
if not self.complete:
self.truncated = True
def _hex(value):
return "0x%x" % value
@@ -249,7 +34,7 @@ def _location(address, target):
module = address.GetModule()
symbol = address.GetSymbol()
file_address = address.GetFileAddress()
location = {
return {
"load_address": _hex(address.GetLoadAddress(target)),
"file_address": None
if file_address == lldb.LLDB_INVALID_ADDRESS
@@ -258,7 +43,6 @@ def _location(address, target):
"module_path": str(module.GetFileSpec()) if module.IsValid() else None,
"symbol": symbol.GetName() if symbol.IsValid() else None,
}
return location
def _no_files():
@@ -323,9 +107,10 @@ def _wanted(spec, symbol):
return symbol.startswith("-[" if method_type == "instance" else "+[")
def _record(context, thread, index, sequence, location, remaining_bytes):
def _record(context, thread, index, sequence):
target, process, config = context["target"], context["process"], context["config"]
frame = thread.GetFrameAtIndex(0)
location = _location(frame.GetPCAddress(), target)
symbol = location["symbol"] or ""
if not _wanted(config["breakpoints"][index], symbol):
return None
@@ -348,14 +133,7 @@ def _record(context, thread, index, sequence, location, remaining_bytes):
receiver_class = _dynamic_class(
target, frame.FindRegister(names[0]).GetValueAsUnsigned()
)
estimated_base = (
_estimated_json_size(location)
+ _estimated_json_size(receiver_class)
+ _estimated_json_size(selector)
+ 2048
)
if estimated_base > remaining_bytes:
raise ValueError("trace event exceeds aggregate byte budget")
frames = min(thread.GetNumFrames(), 1 + config["backtrace_frames"])
record = {
"sequence": sequence,
"elapsed_ms": round((time.monotonic() - context["started"]) * 1000, 3),
@@ -364,7 +142,10 @@ def _record(context, thread, index, sequence, location, remaining_bytes):
"receiver_class": receiver_class,
"selector": selector,
"registers": registers,
"backtrace": [],
"backtrace": [
_location(thread.GetFrameAtIndex(depth).GetPCAddress(), target)
for depth in range(1, frames)
],
}
record.update(location)
return record
@@ -379,118 +160,37 @@ def _handle_stop(context, events, other_stops):
index = context["breakpoint_index"].get(thread.GetStopReasonDataAtIndex(0))
if index is None:
continue
frame = thread.GetFrameAtIndex(0)
location = _location(frame.GetPCAddress(), context["target"])
symbol = location["symbol"] or ""
if not _wanted(context["config"]["breakpoints"][index], symbol):
continue
frame_count = max(
0,
min(thread.GetNumFrames() - 1, context["config"]["backtrace_frames"]),
)
event_frame_count = 1 + frame_count
if not _can_retain_trace(
context["retained_trace_bytes"],
context["retained_frames"],
0,
event_frame_count,
):
return "resource-limit"
try:
remaining_bytes = MAX_TRACE_BYTES - context["retained_trace_bytes"]
record = _record(
context, thread, index, len(events), location, remaining_bytes
)
except ValueError:
return "resource-limit"
record = _record(context, thread, index, len(events))
if record is not None:
if not _fits_trace_budget(record, remaining_bytes):
return "resource-limit"
record_bytes = len(json.dumps(record).encode("utf-8"))
if context["retained_trace_bytes"] + record_bytes > MAX_TRACE_BYTES:
return "resource-limit"
for depth in range(1, frame_count + 1):
try:
location = _location(
thread.GetFrameAtIndex(depth).GetPCAddress(), context["target"]
)
except ValueError:
return "resource-limit"
if (
_estimated_json_size(location) + record_bytes + 1
> MAX_TRACE_BYTES - context["retained_trace_bytes"]
):
return "resource-limit"
location_bytes = len(json.dumps(location).encode("utf-8"))
addition = location_bytes + (1 if record["backtrace"] else 0)
if (
context["retained_trace_bytes"]
+ record_bytes
+ addition
> MAX_TRACE_BYTES
):
return "resource-limit"
record["backtrace"].append(location)
record_bytes += addition
journal = context.get("observation_journal")
if journal is not None and not journal.append(
{"kind": "event", "event": record}
):
return "resource-limit"
events.append(record)
context["retained_frames"] += event_frame_count
context["retained_trace_bytes"] += record_bytes
if len(events) >= context["config"]["max_events"]:
return "event-limit"
elif reason in (lldb.eStopReasonSignal, lldb.eStopReasonException):
stop_description = thread.GetStopDescription(256)
journal = context.get("observation_journal")
if journal is not None and not journal.append(
{"kind": "other-stop", "reason": stop_description}
):
return "resource-limit"
other_stops.append(stop_description)
other_stops.append(thread.GetStopDescription(256))
if len(other_stops) >= MAX_OTHER_STOPS:
return "stop-limit"
return None
def _breakpoint_report(target, created, config):
def _breakpoint_report(target, created):
report = []
retained_location_bytes = 0
locations_truncated = False
for index, breakpoints in enumerate(created):
locations = []
count = 0
for breakpoint in breakpoints:
count += breakpoint.GetNumLocations()
for position in range(breakpoint.GetNumLocations()):
address = breakpoint.GetLocationAtIndex(position).GetAddress()
location = _location(address, target)
if not _wanted(config["breakpoints"][index], location["symbol"] or ""):
continue
count += 1
if len(locations) < MAX_REPORTED_LOCATIONS:
remaining_bytes = (
MAX_BREAKPOINT_LOCATION_BYTES - retained_location_bytes
locations.append(
_location(breakpoint.GetLocationAtIndex(position).GetAddress(), target)
)
if not _fits_trace_budget(location, remaining_bytes):
locations_truncated = True
continue
location_bytes = len(json.dumps(location).encode("utf-8"))
addition = location_bytes + (1 if locations else 0)
if addition > remaining_bytes:
locations_truncated = True
continue
locations.append(location)
retained_location_bytes += addition
report.append({"index": index, "location_count": count, "locations": locations})
return report, locations_truncated
return report
def _launch_info(target, config, listener):
launch = lldb.SBLaunchInfo(config["arguments"])
launch.SetListener(listener)
_set_launch_flags(launch)
environment = target.GetEnvironment()
for key, value in config["environment"].items():
environment.Set(key, value, True)
@@ -517,245 +217,74 @@ def trace(debugger, config):
lldb.SBDebugger.SetInternalVariable(
"target.skip-prologue", "false", debugger.GetInstanceName()
)
try:
before_digest = _hash_file(config["executable"])
before_identity = _file_identity(config["executable"])
except OSError as error:
return {"status": "target-integrity-error", "error": str(error)}
if before_digest != config["expected_sha256"]:
return {
"status": "target-integrity-error",
"error": "selected executable digest changed before LLDB target creation",
}
error = lldb.SBError()
target = debugger.CreateTarget(
config["executable"], config["architecture"], None, False, error
)
if not target.IsValid():
return {"status": "target-error", "error": error.GetCString()}
module_path_before, module_file_identity_before, module_uuid_before = (
_module_identity(target)
)
if (
module_file_identity_before is not None
and module_file_identity_before != before_identity
):
return {
"status": "target-integrity-error",
"error": "LLDB executable module resolved to a different pre-launch file identity",
}
created = [_create_breakpoints(target, spec) for spec in config["breakpoints"]]
breakpoint_index = {}
for index, breakpoints in enumerate(created):
for breakpoint in breakpoints:
breakpoint_index[breakpoint.GetID()] = index
listener = lldb.SBListener(LISTENER_NAME)
output_budget = config.get("max_output_bytes", MAX_TARGET_OUTPUT_BYTES)
stdout = _OutputPipe(
config["stdout_path"], config["stdout_capture_path"], output_budget
)
try:
stderr = _OutputPipe(
config["stderr_path"], config["stderr_capture_path"], output_budget
)
except Exception:
stdout.close()
raise
stdout.start()
stderr.start()
observation_journal = None
started = time.monotonic()
process = None
try:
observation_path = config.get("observation_path")
if observation_path:
observation_journal = _ObservationJournal(
open(observation_path, "wb")
)
process = target.Launch(_launch_info(target, config, listener), error)
if not error.Success() or not process.IsValid():
stdout.close()
stderr.close()
return {"status": "launch-error", "error": error.GetCString()}
stdout.mark_launched()
stderr.mark_launched()
# Publish the PID before any post-launch file or module inspection. The
# process is stopped at entry and remains there until identity_ack.
with open(config["pid_path"], "w") as handle:
handle.write(str(process.GetProcessID()))
identity_ack = _wait_for_identity_ack(config)
if identity_ack != "readable":
process.Kill()
_wait_for_exit(listener, process, 5)
expected_exit = process.GetState() == lldb.eStateExited
stdout.close(expected_exit=expected_exit)
stderr.close(expected_exit=expected_exit)
return {
"status": "target-identity-unavailable",
"error": "host could not establish the launched process identity; target was stopped",
}
try:
after_digest = _hash_file(config["executable"])
after_identity = _file_identity(config["executable"])
except OSError as identity_error:
process.Kill()
stdout.close(expected_exit=process.GetState() == lldb.eStateExited)
stderr.close(expected_exit=process.GetState() == lldb.eStateExited)
return {"status": "target-integrity-error", "error": str(identity_error)}
if after_digest != config["expected_sha256"] or after_identity != before_identity:
process.Kill()
_wait_for_exit(listener, process, 5)
stdout.close(expected_exit=process.GetState() == lldb.eStateExited)
stderr.close(expected_exit=process.GetState() == lldb.eStateExited)
return {
"status": "target-integrity-error",
"error": "selected executable digest or file identity changed during LLDB launch",
}
module_path_after, module_file_identity_after, module_uuid_after = (
_module_identity(target)
)
if (
module_file_identity_after is not None
and module_file_identity_after != before_identity
):
process.Kill()
_wait_for_exit(listener, process, 5)
stdout.close(expected_exit=process.GetState() == lldb.eStateExited)
stderr.close(expected_exit=process.GetState() == lldb.eStateExited)
return {
"status": "target-integrity-error",
"error": "LLDB executable module resolved to a different file identity",
}
if (
module_uuid_before is not None
and module_uuid_after is not None
and module_uuid_before != module_uuid_after
):
process.Kill()
_wait_for_exit(listener, process, 5)
stdout.close(expected_exit=process.GetState() == lldb.eStateExited)
stderr.close(expected_exit=process.GetState() == lldb.eStateExited)
return {
"status": "target-integrity-error",
"error": "LLDB executable module identity changed during launch",
}
context = {
"target": target,
"process": process,
"config": config,
"started": started,
"breakpoint_index": breakpoint_index,
"retained_frames": 0,
"retained_trace_bytes": 0,
"observation_journal": observation_journal,
}
events = []
other_stops = []
outcome = None
deadline = started + config["duration_ms"] / 1000
event = lldb.SBEvent()
while outcome is None:
if stdout.reader_error is not None:
raise RuntimeError("stdout capture failed: %s" % stdout.reader_error)
if stderr.reader_error is not None:
raise RuntimeError("stderr capture failed: %s" % stderr.reader_error)
if time.monotonic() >= deadline:
outcome = "duration-elapsed"
break
if not listener.WaitForEvent(1, event):
continue
if not lldb.SBProcess.EventIsProcessEvent(event):
continue
state = lldb.SBProcess.GetStateFromEvent(event)
if state == lldb.eStateExited:
outcome = "exited"
elif state == lldb.eStateStopped and not lldb.SBProcess.GetRestartedFromEvent(event):
outcome = _handle_stop(context, events, other_stops)
if outcome is None:
process.Continue()
pid = process.GetProcessID()
exit_status = None
exit_description = None
if outcome == "exited":
exit_status = process.GetExitStatus()
exit_description = process.GetExitDescription()
else:
process.Kill()
_wait_for_exit(listener, process, 5)
process_exited = process.GetState() == lldb.eStateExited
stdout.close(expected_exit=process_exited)
stderr.close(expected_exit=process_exited)
launch_identity_stable = (
module_path_before is not None
and module_path_after == module_path_before
and module_file_identity_before == before_identity
and module_file_identity_after == module_file_identity_before
and module_uuid_before is not None
and module_uuid_after == module_uuid_before
)
breakpoint_report, breakpoint_locations_truncated = _breakpoint_report(
target, created, config
)
return {
"status": "traced",
"version": lldb.SBDebugger.GetVersionString().splitlines()[0],
"pid": pid,
"outcome": outcome,
"exit_status": exit_status,
"exit_description": exit_description,
"killed": outcome != "exited",
"terminated": process.GetState() == lldb.eStateExited,
"elapsed_ms": round((time.monotonic() - started) * 1000, 3),
"breakpoints": breakpoint_report,
"breakpoint_locations_truncated": breakpoint_locations_truncated,
"events": events,
"resource_limit_reached": outcome == "resource-limit",
"target_output": {
"stdout_bytes": stdout.bytes,
"stderr_bytes": stderr.bytes,
"stdout_truncated": stdout.truncated,
"stderr_truncated": stderr.truncated,
"stdout_complete": stdout.complete,
"stderr_complete": stderr.complete,
},
"target_identity": {
"selected_file_sha256": after_digest,
"loaded_image_sha256": None,
"file_device": after_identity[0],
"file_inode": after_identity[1],
"module_path": module_path_after,
"module_uuid": module_uuid_after,
# This records matching path/module identity observations. LLDB
# does not expose the exact mapped bytes, so it never proves a digest.
"stable": launch_identity_stable,
},
"other_stops": other_stops,
}
finally:
if observation_journal is not None:
try:
observation_journal.handle.close()
except Exception:
pass
if process is not None:
try:
if process.IsValid() and process.GetState() != lldb.eStateExited:
process.Kill()
_wait_for_exit(listener, process, 5)
except Exception:
pass
for output_pipe in (stdout, stderr):
try:
output_pipe.close(
expected_exit=(
process is not None
and process.IsValid()
and process.GetState() == lldb.eStateExited
)
)
except Exception:
pass
process = target.Launch(_launch_info(target, config, listener), error)
if not error.Success() or not process.IsValid():
return {"status": "launch-error", "error": error.GetCString()}
with open(config["pid_path"], "w") as handle:
handle.write(str(process.GetProcessID()))
context = {
"target": target,
"process": process,
"config": config,
"started": started,
"breakpoint_index": breakpoint_index,
}
events = []
other_stops = []
outcome = None
deadline = started + config["duration_ms"] / 1000
event = lldb.SBEvent()
while outcome is None:
if time.monotonic() >= deadline:
outcome = "duration-elapsed"
break
if not listener.WaitForEvent(1, event):
continue
if not lldb.SBProcess.EventIsProcessEvent(event):
continue
state = lldb.SBProcess.GetStateFromEvent(event)
if state == lldb.eStateExited:
outcome = "exited"
elif state == lldb.eStateStopped and not lldb.SBProcess.GetRestartedFromEvent(event):
outcome = _handle_stop(context, events, other_stops)
if outcome is None:
process.Continue()
pid = process.GetProcessID()
exit_status = None
exit_description = None
if outcome == "exited":
exit_status = process.GetExitStatus()
exit_description = process.GetExitDescription()
else:
process.Kill()
_wait_for_exit(listener, process, 5)
return {
"status": "traced",
"version": lldb.SBDebugger.GetVersionString().splitlines()[0],
"pid": pid,
"outcome": outcome,
"exit_status": exit_status,
"exit_description": exit_description,
"killed": outcome != "exited",
"terminated": process.GetState() == lldb.eStateExited,
"elapsed_ms": round((time.monotonic() - started) * 1000, 3),
"breakpoints": _breakpoint_report(target, created),
"events": events,
"other_stops": other_stops,
}
def run(debugger, command, result, internal_dict):
-273
View File
@@ -1,273 +0,0 @@
"""Source-owned tests for the LLDB bridge's bounded retention helpers."""
from io import BytesIO
import importlib.util
import json
import os
import sys
import tempfile
import types
import unittest
fake_lldb = sys.modules.setdefault("lldb", types.ModuleType("lldb"))
fake_lldb.LLDB_INVALID_ADDRESS = -1
fake_lldb.eLaunchFlagDebug = 2
fake_lldb.eLaunchFlagStopAtEntry = 4
spec = importlib.util.spec_from_file_location(
"rea_lldb_tracer", os.path.join(os.path.dirname(__file__), "rea_lldb_tracer.py")
)
tracer = importlib.util.module_from_spec(spec)
spec.loader.exec_module(tracer)
class NativeTraceBudgetTests(unittest.TestCase):
def test_journal_checks_exact_byte_budget_before_writing(self):
record = {"kind": "other-stop", "reason": "signal"}
encoded_size = len((tracer.json.dumps(record) + "\n").encode("utf-8"))
stream = BytesIO()
journal = tracer._ObservationJournal(stream, max_bytes=encoded_size - 1)
self.assertFalse(journal.append(record))
self.assertEqual(stream.getvalue(), b"")
self.assertEqual(journal.bytes, 0)
def test_journal_appends_each_row_once_and_flushes_it(self):
class CountingStream(BytesIO):
writes = 0
flushes = 0
def write(self, value):
self.writes += 1
return super().write(value)
def flush(self):
self.flushes += 1
return super().flush()
stream = CountingStream()
journal = tracer._ObservationJournal(stream)
journal.append({"kind": "event", "event": {"sequence": 0}})
journal.append({"kind": "other-stop", "reason": "signal"})
self.assertEqual((stream.writes, stream.flushes), (2, 2))
self.assertEqual(journal.bytes, len(stream.getvalue()))
def test_aggregate_trace_budget_admits_exact_boundary_and_rejects_next_record(self):
self.assertTrue(
tracer._can_retain_trace(
tracer.MAX_TRACE_BYTES - 10,
tracer.MAX_TRACE_FRAMES - 2,
10,
2,
)
)
self.assertFalse(
tracer._can_retain_trace(
tracer.MAX_TRACE_BYTES - 10,
tracer.MAX_TRACE_FRAMES - 2,
11,
2,
)
)
self.assertFalse(
tracer._can_retain_trace(
tracer.MAX_TRACE_BYTES - 10,
tracer.MAX_TRACE_FRAMES - 2,
10,
3,
)
)
def test_oversized_producer_string_is_rejected_before_serialization(self):
self.assertTrue(tracer._fits_trace_budget("method", 1024))
self.assertFalse(tracer._fits_trace_budget("m" * 2048, 1024))
self.assertGreater(
tracer._estimated_json_size("🙂"), len('"🙂"'.encode("utf-8"))
)
def test_launch_flags_preserve_default_debug_and_stop_at_entry(self):
class Launch:
flags = 10
def GetLaunchFlags(self):
return self.flags
def SetLaunchFlags(self, flags):
self.flags = flags
launch = Launch()
tracer._set_launch_flags(launch)
self.assertEqual(launch.flags, 14)
def test_output_pipe_counts_all_bytes_and_keeps_only_the_prefix(self):
with tempfile.TemporaryDirectory() as directory:
fifo = os.path.join(directory, "stdout.fifo")
capture = os.path.join(directory, "stdout.capture")
sink = tracer._OutputPipe(fifo, capture, 5)
sink.start()
sink.mark_launched()
writer = os.open(fifo, os.O_WRONLY)
try:
for index in range(100):
chunk = (b"hello world" if index == 0 else b"x" * 4096)
os.write(writer, chunk)
finally:
os.close(writer)
sink.drain()
sink.close(expected_exit=True)
with open(capture, "rb") as retained:
self.assertEqual(retained.read(), b"hello")
self.assertEqual(sink.bytes, 11 + 99 * 4096)
self.assertTrue(sink.truncated)
self.assertTrue(sink.complete)
def test_output_pipe_reports_incomplete_capture_after_unexpected_close(self):
with tempfile.TemporaryDirectory() as directory:
fifo = os.path.join(directory, "stdout.fifo")
capture = os.path.join(directory, "stdout.capture")
sink = tracer._OutputPipe(fifo, capture, 5)
sink.start()
sink.mark_launched()
sink.close(expected_exit=False)
self.assertFalse(sink.complete)
self.assertTrue(sink.truncated)
def test_selector_location_report_uses_the_requested_method_type(self):
class Symbol:
def IsValid(self):
return True
def GetName(self):
return "+[Greeter shared]"
class FileSpec:
def GetFilename(self):
return "Fixture"
def __str__(self):
return "/tmp/Fixture"
class Module:
def IsValid(self):
return True
def GetFileSpec(self):
return FileSpec()
class Address:
def GetModule(self):
return Module()
def GetSymbol(self):
return Symbol()
def GetFileAddress(self):
return 1
def GetLoadAddress(self, target):
return 2
class Location:
def GetAddress(self):
return Address()
class Breakpoint:
def GetNumLocations(self):
return 1
def GetLocationAtIndex(self, index):
return Location()
report, locations_truncated = tracer._breakpoint_report(
object(),
[[Breakpoint()]],
{
"breakpoints": [
{
"kind": "objc-method",
"selector": "shared",
"method_type": "instance",
}
]
},
)
self.assertEqual(report[0]["location_count"], 0)
self.assertEqual(report[0]["locations"], [])
self.assertFalse(locations_truncated)
def test_breakpoint_report_bounds_aggregate_location_json(self):
tracer.lldb.LLDB_INVALID_ADDRESS = -1
symbol_name = (
"-[VeryLongButPlausibleObjectiveCController "
"performTransactionWithRequestIdentifier:accountContext:"
"securityPolicy:completionHandler:]"
)
module_path = (
"/Applications/Enterprise Product.app/Contents/Frameworks/"
"EnterpriseSubsystem.framework/Versions/A/EnterpriseSubsystem"
)
class Symbol:
def IsValid(self):
return True
def GetName(self):
return symbol_name
class FileSpec:
def GetFilename(self):
return "EnterpriseSubsystem"
def __str__(self):
return module_path
class Module:
def IsValid(self):
return True
def GetFileSpec(self):
return FileSpec()
class Address:
def GetModule(self):
return Module()
def GetSymbol(self):
return Symbol()
def GetFileAddress(self):
return 0x123456789
def GetLoadAddress(self, target):
return 0x7123456789
class Location:
def GetAddress(self):
return Address()
class Breakpoint:
def GetNumLocations(self):
return 64
def GetLocationAtIndex(self, index):
return Location()
breakpoint_count = 1000
created = [[Breakpoint()] for _ in range(breakpoint_count)]
config = {"breakpoints": [{"kind": "objc-method"}] * breakpoint_count}
report, locations_truncated = tracer._breakpoint_report(
object(), created, config
)
retained_bytes = sum(
len(json.dumps(location).encode("utf-8"))
+ (1 if index > 0 else 0)
for row in report
for index, location in enumerate(row["locations"])
)
self.assertTrue(locations_truncated)
self.assertEqual(sum(row["location_count"] for row in report), 64_000)
self.assertLess(sum(len(row["locations"]) for row in report), 64_000)
self.assertLessEqual(retained_bytes, tracer.MAX_BREAKPOINT_LOCATION_BYTES)
if __name__ == "__main__":
unittest.main()
-4
View File
@@ -43,10 +43,6 @@ def inspect_core(path, cache):
raise CoreFailure("unsupported-target", "Initial recorded-core profile requires ELF64 little-endian.")
if len(content) < 64:
raise CoreFailure("format", "Selected ELF64 core header is truncated.")
if content[6] != 1:
raise CoreFailure("format", "ELF identification version is not EV_CURRENT.")
if int.from_bytes(content[20:24], "little") != 1:
raise CoreFailure("format", "ELF header version is not EV_CURRENT.")
# Linux commonly emits ELFOSABI_NONE (0), also accepted as GNU/Linux (3).
# Other declared OSABIs must never select Linux note ctypes.
if content[7] not in (0, 3) or content[8] != 0:
+1 -8
View File
@@ -111,14 +111,7 @@ rea project-android-application-graph '{"inventory_evidence":[<inventory_artifac
The projection reports exact component paths and hashes, runtime-family hints,
and path-based bridge hypotheses. It does not decode DEX or claim observed
runtime calls. Bridge hypotheses form a DEX/JVM-class by native-library
Cartesian product. The projection computes the product and exact serialized
candidate-array size before creating candidate objects, then retains a
deterministic prefix within a 2 MiB UTF-8 JSON budget. If candidates are omitted,
`bridge_candidate_coverage` reports total, emitted, and omitted counts; overall
coverage is `partial` while component arrays still include every component
from the supplied inventory pages. A path pair does not establish that a
managed declaration names, loads, or calls the native library.
runtime calls.
## Resource and lifecycle limits
+78 -41
View File
@@ -20,27 +20,16 @@ rea project-apple-application-graph '{"inventory_evidence":[<inventory_artifact
}
```
The result includes every component in each inventory category. Bridge
hypotheses are the path-based JavaScript/native pairs within each application
root. To keep their Cartesian expansion bounded, the projection limits the
serialized candidate array to 2 MiB. It computes the pair count and
exact UTF-8 JSON size before creating candidate objects, then retains a
deterministic prefix when the byte budget is exceeded. The result's
`bridge_candidate_coverage` reports total, emitted, and omitted pair counts;
overall coverage becomes `partial` and a limitation explains the omission.
Duplicate inventory pages are merged by artifact identity and occurrence path;
their Evidence IDs remain attached as source Evidence.
For IPA inventories, archive components outside every application root remain
in the component lists. Their JavaScript and native candidates are grouped with
other unrooted components, preserving the inventory projection's path pairing
semantics without attributing them to an application bundle.
The result includes every component in each inventory category and every
JavaScript-to-native bridge candidate pair. It has no caller-selected component
budget, prefix truncation, or omitted-count fields. Duplicate inventory pages
are merged by artifact identity and occurrence path; their Evidence IDs remain
attached as source Evidence.
Coverage is `complete-within-inventory` when the supplied pages reconstruct the
complete authenticated inventory, and `partial` otherwise. A partial result
states that absence is unknown. Component arrays remain complete when candidate
hypotheses are omitted; component arrays still include every component from
the supplied inventory pages, and overall coverage is `partial` in that case.
states that absence is unknown. Component and bridge-candidate arrays are still
the complete projection of the inventory that was supplied.
## Application roots
@@ -164,15 +153,16 @@ Resolution follows dyld:
Each edge lists every candidate path tried, with its outcome: `resolved`,
`absent`, `not-mach-o`, `malformed`, `unsupported` (recognized Mach-O outside
parser coverage, such as big-endian images), `architecture-missing`,
`outside-target`, `escapes-target`, or `undetermined`. Slices follow dyld's
`outside-target`, `shared-cache`, `escapes-target`, or `undetermined`. Slices follow dyld's
graded architectures, so an `x86_64h` process also loads `x86_64`. The edge's `resolution` is one of:
| Status | Meaning |
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `resolved` | An image inside the analyzed root loads, and no earlier candidate was undeterminable. |
| `conditional` | An image inside the root resolves, but an earlier candidate outside the root (for example `/usr/lib/swift`) would win if it exists on the running system. |
| `unresolved` | Every candidate is definitively absent or unusable inside the root. |
| `undetermined` | No candidate resolves inside the root, and some candidate lies outside it or depends on the environment. |
| Status | Meaning |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `resolved` | An image inside the analyzed root loads, and no earlier candidate was undeterminable. |
| `conditional` | An image inside the root, or in the supplied shared cache, resolves, but an earlier candidate that REA cannot decide (for example `/opt/vendor/lib` or `/usr/lib/swift` without a cache) would win if it exists on the running system. |
| `unresolved` | Every candidate is definitively absent or unusable inside the root. |
| `undetermined` | No candidate resolves inside the root, and some candidate lies outside it or depends on the environment. |
| `shared-cache` | An absolute install path, or an `@rpath` candidate outside the root, was found in the dyld shared cache passed as `shared_cache` (CLI `--shared-cache`). dyld loads the cached image, so later in-root candidates are unused. |
A `conditional` image is still traced, because it is the image the analyzed
root supplies. Every edge below it has `loader_conditional: true`, and a
@@ -188,23 +178,70 @@ cache rather than on disk, so REA does not check them against the host.
- `required-load-unresolved`, `weak-load-unresolved`, and `lazy-load-unresolved`.
`LC_LAZY_LOAD_DYLIB` dependencies are resolved but not traversed, because dyld
loads them on first use; an unresolved one is not a launch failure.
- `earlier-rpath-candidate-absent`: a compatible Mach-O placed at an earlier
modeled search path could take precedence, subject to unmodeled search inputs
and code-signing library validation. These findings do not establish which
image dyld will load; use `inspect_signature` for signing metadata.
- `dyld-environment-present`: preserves observed `LC_DYLD_ENVIRONMENT` entries.
Only an executable root supplies process settings; entries on library and
other non-executable roots do not alter resolution or coverage. Applicable
executable image-selection overrides are not modeled.
Empty dyld settings have different consumers. Modern dyld appends a slash and
image name to ordinary and fallback directory entries, so an empty entry can
search `/child.dylib` or `/Foo.framework/Foo`. REA retains uncertainty for those
unchecked paths; an empty fallback does not preempt an ordinary-search hit.
Empty versioned-directory lists do not scan a directory, and empty suffixes
only repeat the original candidate, so they do not add search uncertainty.
Reported command values are retained in all cases.
- `earlier-rpath-candidate-absent`: a Mach-O placed at an earlier search path
would load first. Whether code-signing library validation would reject it is
not evaluated; use `inspect_signature`.
- `dyld-environment-present`: `LC_DYLD_ENVIRONMENT` search paths are not modeled.
`verify:macos-bundle` checks the parser against `otool -l` for every traced
image. It also compares the predicted load order of two process roots with the
images dyld actually loads (`DYLD_PRINT_LIBRARIES`).
With `shared_cache`, absolute candidates are looked up in that cache's image
list. A process uses the cache only when both of these hold:
- the cache serves its CPU family (an arm64e cache serves arm64 and arm64e);
- one of its `LC_BUILD_VERSION` or `LC_VERSION_MIN_*` platforms (reported per
slice as `platforms`) matches the cache's platform or alternate platform.
An iOS cache extracted from an IPSW is therefore not used for a macOS
executable. A limitation names each process root the cache was not used for,
and why. The result's `shared_cache` names the cache path, UUID, architecture
and OS version. Cached images are not traversed.
An image listed in the cache but mapped in a missing or UUID-mismatched
subcache is `undetermined`. It is listed in
`coverage.unverified_shared_cache_images` and makes coverage partial. Without a
cache, system paths stay `undetermined`.
## dyld shared cache
`inspect_dyld_shared_cache` reads a dyld shared cache file set selected by path.
On current macOS this is
`/System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/dyld_shared_cache_arm64e`;
a cache extracted from an IPSW also works. It runs without an open target.
```sh
rea inspect-dyld-shared-cache /System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/dyld_shared_cache_arm64e --image /usr/lib/libSystem.B.dylib --json
```
```json
{
"name": "inspect_dyld_shared_cache",
"arguments": {
"cache_path": "/System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/dyld_shared_cache_arm64e",
"images": ["/usr/lib/libSystem.B.dylib"]
}
}
```
The result reports:
- the magic, architecture, UUID, platform and OS version, any alternate platform
(Mac Catalyst), cache type, the development/production subtype for multi-caches
(including its raw ID when unknown), shared region and maximum slide;
- every VM mapping, with the file that holds it;
- every subcache by file suffix, with its UUID checked against the main cache.
A missing or mismatched subcache makes coverage `partial`, and images mapped
there are `unmapped`;
- the complete image list with addresses.
For each requested install path, the tool reads that image's load commands
(install name, dependencies with their re-export, weak, upward and delayed-init
flags, and rpaths) through the cache mappings. A path the cache does not
contain is `absent`. Older single-file caches are read from their legacy header
fields.
Nothing is executed, slid or extracted. Slide info, local symbols and the
cache's code signature are not parsed. The Evidence subject is the main cache
file, identified by its digest.
+2 -4
View File
@@ -157,11 +157,9 @@ coverage. It reports excluded items and any values omitted or truncated by
validation; it does not drop records to meet an internal collection quota.
Disallowed-origin entries are filtered before retention.
CDP discovery, WebSocket connection, and commands continue until the peer responds or the caller cancels the operation; REA has no internal byte, message-count, or pending-command quota for these exchanges. Without caller cancellation, a stalled peer can leave an operation waiting. On cancellation, REA closes only its own WebSocket, waiting up to one second before terminating it. Malformed unsolicited events and malformed replies to pending commands poison the connection so later commands fail closed. When a correlated reply reports a session ID, it must match the command's selected session. An optional method is reported unavailable only when the browser rejects it as unknown; other command rejections retain their reported failure.
CDP discovery, WebSocket connection, and commands continue until the peer responds or the caller cancels the operation; REA has no internal byte, message-count, or pending-command quota for these exchanges. Without caller cancellation, a stalled peer can leave an operation waiting. On cancellation, REA closes only its own WebSocket, waiting up to one second before terminating it. Malformed unsolicited protocol events poison the connection so later commands fail closed.
Network and console coverage starts only after REA attaches and enables the relevant CDP domains. `prior_activity_available` is always `false`; absence from these arrays is not evidence that an event never occurred. If CDP reuses a request ID without supplying redirect metadata, REA retains the predecessor observation and marks network coverage incomplete.
Source maps are not fetched by inspection. Bundle analysis fetches them only when `fetch_source_maps` is selected, with exact-origin redirect checks and no credentials, cookies, or referrer. Each fetch operation has a 30-second deadline and a 64 MiB aggregate streamed-response budget across redirects. A deadline or byte-limit failure remains explicitly unavailable and makes bundle completeness partial. Every requested map, mapping, original source, AST node, and finding from parsed content is returned inline. Original source artifacts with `.ts`, `.tsx`, `.mts`, or `.cts` suffixes carry `text/typescript`; other source names retain `text/javascript`.
Network and console coverage starts only after REA attaches and enables the relevant CDP domains. `prior_activity_available` is always `false`; absence from these arrays is not evidence that an event never occurred. Source maps are not fetched by inspection. Bundle analysis fetches them only when `fetch_source_maps` is selected, with exact-origin redirect checks and no credentials, cookies, or referrer. Every requested map, mapping, original source, AST node, and finding from parsed content is returned inline; failed maps remain explicitly unavailable and make bundle completeness partial. Original source artifacts with `.ts`, `.tsx`, `.mts`, or `.cts` suffixes carry `text/typescript`; other source names retain `text/javascript`.
## Non-goals and threat model
+3 -12
View File
@@ -86,18 +86,9 @@ rea capture-electron-scenario scenario.json --json
The result records action status and targets, correlated app/window/WebContents,
preload, session, navigation, shell, permission, popup, download, protocol,
native-addon, process, and IPC timeline events. Event retention has a 16 MiB
accounting budget for full channel strings, argument-shape arrays, and an
estimated object-graph cost. Admission computes an exact JSON byte length
incrementally, then charges twice that size plus 256 bytes per event and 8 bytes
per argument shape. The doubled JSON charge covers the timeline and IPC views;
the additional charge estimates their in-process object and array storage. This
is a resource estimate, not an exact V8 heap measurement. An event that does not
fit is dropped whole. The result reports the estimate, serialized-byte upper
bound, dropped event families, and
observed/dropped totals for IPC and runtime events. Retained sequence numbers
keep their original capture identities. Payload values are never retained.
Renderer crash/restart and deep-link actions are synthetic
native-addon, process, and IPC timeline events. IPC channels are capped at
1,024 characters and argument-shape metadata at 32 entries; values are never
retained. Renderer crash/restart and deep-link actions are synthetic
scenario controls; their attempted and observed outcomes remain in the
timeline. The active hook blocks and records external shell/navigation,
permission, download, popup, updater, and OS-integration effects. The timeline
+2 -3
View File
@@ -129,9 +129,8 @@ tree semantics, vulnerability scanning, emulation or hardware runtime capture.
Unblob one worker. No automatic parallel extraction engines.
- **120-second** deadline; inherited **1 GiB per-process address-space** limit,
120 CPU seconds and zero core-dump allowance through `prlimit`.
- JSON reports: at most **8 MiB** and valid UTF-8; malformed encoding and
oversized reports fail without repairing or truncating producer data.
Diagnostic output is bounded with exact captured byte counts.
- JSON reports: at most **8 MiB**; oversized reports fail without a truncated
success. Diagnostic output is bounded with exact captured byte counts.
- Default staging budget: **256 MiB** and **10,000 entries**, configurable using
`max_output_bytes` / `max_output_files` (CLI `--max-output-bytes` /
`--max-output-files`). Depth defaults to 3, allowed range 1–10.
+2 -6
View File
@@ -1,7 +1,7 @@
# DOS MZ and COM analysis with Ghidra
REA can analyze DOS MZ executables and explicitly selected COM images through the bring-your-own Ghidra adapter.
The Linux x64 and macOS arm64 verification lanes exercise actual 16-bit disassembly and
The Linux x64 verification lane exercises actual 16-bit disassembly and
decompilation through the CLI and stdio MCP. Provider admission accepts Ghidra
12.1.x and that installation's declared JDK range. The verification lane uses
Ghidra 12.1.4 and JDK 21; no DOS emulator or cross-compiler is required.
@@ -87,10 +87,6 @@ address for subsequent lookups and leaves instruction decoding context with
Ghidra. These coordinates are analysis load addresses, not file offsets or
observations of a running DOS system.
Offsets that Ghidra would truncate to a different address are rejected before
reading or annotating a function. This also applies to explicit or encoded
address-space names; leading zeros preserve the numeric offset.
`procedure_info` and function dossiers include `body` evidence:
```json
@@ -154,7 +150,7 @@ are separate from the initialized MZ module. Raw COM files and DOS extenders
without an admitted MZ real-mode entry are outside this import boundary.
DOS MZ is available through Ghidra, with no Hopper support claim. It is outside
the Windows native PE P0 boundary. macOS x64 DOS verification remains unverified.
the Windows native PE P0 boundary. macOS DOS verification remains unverified.
## Real verification
+4 -78
View File
@@ -135,27 +135,12 @@ Setup can configure these clients for REA's local MCP server:
| GitHub Copilot CLI | `copilot_cli` |
| Command Code | `commandcode` |
| VS Code | `vscode` |
| Grok Build | `grok_build` |
| Grok Bot | `grok_bot` |
For OpenCode, setup writes the V1 `mcp.rea` entry, which OpenCode V1 and V2
both load. If the configuration already uses OpenCode V2's native
`mcp.servers` table, setup registers REA there instead and replaces any earlier
`mcp.rea` entry from REA.
Grok Build loads `[mcp_servers.rea]` from `$GROK_HOME/config.toml`, or from
`~/.grok/config.toml` when `GROK_HOME` is unset. Setup edits that server
table, `[mcp_servers.rea.env]`, and a root `disabled_mcp_servers` entry that
names `rea`. It sets `startup_timeout_sec = 30` and leaves every other name
in that list. The shared skill installed under `~/.agents/skills` is already
on Grok Build's skill path.
Grok Bot is detected from `~/.grokbot`, or from `SAND_DATA_ROOT` when that
value is an absolute path. Setup writes `mcpServers.rea` to `mcp.json` in that
directory. The desktop app keeps the connectors it actually runs in the
signed-in account. That file is the registration to add there; setup does not
call the account API.
## Review setup changes
`rea setup` first offers the supported agents in a multi-select. Existing REA
@@ -224,7 +209,7 @@ rea setup --yes --all-detected --install-hopper --json
Setup pins package-runner MCP registrations to the exact installed REA version,
installs the matching skill and on-demand references in the same plan, and adds
`startup_timeout_sec = 30` for Codex and Grok Build. `rea update` installs the exact resolved
`startup_timeout_sec = 30` for Codex. `rea update` installs the exact resolved
release into the npm prefix that owns the running package, then checks the new
executable's version before reporting success. It does not reopen onboarding.
Release lookup and installation both use npm's configured registry.
@@ -329,57 +314,8 @@ Hopper serializes analysis requests. Cancelling a wait can leave provider work
running, which the session reports. Successful decompilation is cached until
a relevant rename or comment changes it.
Analysis and annotation calls stay bound to the active target's native Hopper
document, even when GUI focus changes or other documents have the same display
name. Use `open_binary` to change targets. Byte reads stop at a segment boundary
and return the readable prefix with `complete: false`. File-offset mapping checks
the reverse lookup and original executable bounds; synthetic external-symbol
memory has no original file offset. For FAT Mach-O, offsets refer to the original
container file. Results retain Hopper's image-relative offset, the observed slice
base, and the source executable path.
Loader selection reads the Mach-O container header, including FAT files with a
single architecture. For FAT64, REA validates the architecture table and selected
Mach-O header, prepares a private thin image, and loads it with Hopper's native
Mach-O loader. It checks the entire source's SHA-256 while copying the slice;
source identity and reported offsets still refer to the original container.
An ambiguous architecture subtype requires an explicitly extracted thin image;
REA does not guess. Configured loader arguments remain explicit overrides.
FAT64 is distinct from the CPU architecture: FAT32 containers can contain 64-bit
executable images. This preparation avoids the Raw Binary loader dialog observed
with native FAT64 loading on Hopper 6.1.0-demo.
The prepared image and its owned document close together, including on MCP exit;
if document closure is unconfirmed, REA retains the backing image and reports
`cleanup_incomplete` with its path. Ordinary documents retain their existing
MCP-exit behavior.
Startup deadlines report missing bridge readiness and preserve the launcher
outcome; a successful helper exit does not prove that a loader dialog completed.
Cursor navigation returns the observed object start when Hopper snaps an interior
address; adjacent-object navigation rejects unmapped inputs and document ends.
Native API text rejects NUL characters and unpaired Unicode surrogates before
annotation changes. Renames preserve unselected label owners; use a batch with
all affected addresses to move or swap existing labels explicitly. Every rename
destination must be mapped, and native symbol names must fit Hopper's 1024 UTF-16
code-unit limit. Oversized names fail before any batch edits; bookmarks and
literal string results are not subject to that symbol-name limit. Rename success
requires exact final readback. New bookmarks must point into mapped memory;
existing legacy bookmarks outside it can still be removed.
String results read each native typed object's complete bytes, retain the original
provider display in `provider_value`, and report its encoding, byte length, and
termination. `encoding_status: inferred` distinguishes REA's decoding from an
observed source encoding. Hopper can split long literals into adjacent
unterminated objects; search matches each object's decoded bytes independently.
Undecodable objects retain native display text with `decoding.available: false`
and a reason, so one uncertain object does not block unrelated inspections.
Function dossiers retain this same string evidence. Native call edges retain
Hopper's partial `CallReference` classification and exact endpoints; detailed
reference flags remain unavailable rather than being invented.
Regex searches use ECMAScript Unicode syntax in a cancellable worker with a
five-second matching deadline. Deadline or cancellation stops matching while
leaving the Hopper API available. Literal mode retains Unicode casefold matching.
Closing or switching a target closes its bound Hopper document, shuts down REA's
bridge and removes its temporary socket directory while preserving the Hopper
application and unrelated documents. A `cleanup_incomplete`
Closing a session shuts down REA's bridge and removes its temporary socket
directory while preserving the Hopper application. A `cleanup_incomplete`
result identifies resources whose cleanup could not be verified.
### Hopper in CI
@@ -468,17 +404,7 @@ home/cache/config/temp paths. REA passes `-readOnly`, `-deleteProject`, uses
Ghidra's default analysis and resource settings, and loads its packaged Java
bridge via `-scriptPath`; it never opens an existing user project. Linux and
macOS use a current-user-only local bridge socket and descriptor. The
project remains under the selected temporary directory. If its Unix socket
pathname would exceed the host's byte limit, REA allocates a separate mode-0700
socket directory under `/tmp` and removes it on close, cancellation, or failure.
Diagnostics retain the actual endpoint and both owned directories.
On macOS, REA starts the inspected JVM directly using Ghidra's own LaunchSupport
configuration. Apple platform shell wrappers hide their environments from
ownership inspection, so retaining those wrappers would prevent verified
process-group cancellation during startup.
If ownership remains unverifiable, it reports the reason and retains the process
supervisor and private runtime instead of removing files beneath a live provider.
The experimental Windows transport uses authenticated IPv4 loopback with a
experimental Windows transport uses authenticated IPv4 loopback with a
private native-owned bearer descriptor and Job Object process ownership.
Operations begin only after default auto-analysis completes. `open_binary`
@@ -125,13 +125,6 @@ SHA-256 digests, inventory IDs, ASAR container identity, and `.asar.unpacked`
status. Direct ASAR inputs and filesystem-backed ASAR files nested beneath a
directory are supported.
JavaScript sources (`.js`, `.jsx`, `.mjs`, and `.cjs`) and TypeScript sources
(`.ts`, `.tsx`, `.mts`, and `.cts`) are parsed as inert text; analysis does not
execute them.
JavaScript and HTML source ranges retain an initial UTF-8 BOM as one UTF-16
code unit, matching the original bytes identified by the artifact digest.
If an ASAR declares an unpacked companion entry but the corresponding
`<archive>.unpacked` file is absent from the operator-supplied artifact set, REA
keeps the ASAR occurrence with `hash_status: unavailable`, records an explicit
@@ -140,9 +133,6 @@ missing bytes. This allows static JavaScript/Electron reconstruction to proceed
for the embedded files while preserving the missing native/resource bytes as an
unknown instead of silently treating them as absent or verified.
Plain `.ts` artifact sources use TypeScript syntax without JSX, including
angle-bracket type assertions. `.tsx` and `.jsx` retain JSX parsing.
Selected bounded text is then parsed as inert data to recover:
- `package.json` metadata and declared main or renderer entrypoints;
@@ -166,12 +156,6 @@ Selected bounded text is then parsed as inert data to recover:
- utility-process entrypoints and native `.node` binding requests without
parsing or executing the add-on.
Overloaded `.open` calls use lexical receiver facts: ambient browser window and
document receivers are treated as browsing-context or document operations,
while locally shadowed receivers can still contribute network endpoint
candidates. A template recovered with a parser error and no cooked value stays
dynamic; its raw spelling is not treated as a valid JavaScript string.
Each recovered bundle module retains the exact factory-source digest. A complete
bounded AST also receives a `babel-ast-v1` structural fingerprint that ignores
ordinary identifier names while retaining syntax, literals, operators, object
@@ -233,22 +217,6 @@ These safeguards are not caller-selectable output budgets. Source-map parsing
reports truncation when its format or parser safety boundary is reached. The
application graph has no aggregate node, edge, root, or observation prefix cap.
Semantic evaluation retains at most 256 distinct primitive candidates per
expression and at most 1 MiB of worst-case JSON string bytes across a normalized
primitive value's string candidates. The byte budget estimates six JSON bytes
per UTF-16 code unit, the expansion bound for escaped strings, and bounds both
retained candidate strings and temporary canonical-key serialization. This is
an in-memory semantic allocation bound independent of CLI or MCP transport
budgets. Source literal bytes remain in the parsed source and artifact evidence;
when a normalized semantic value exceeds the bound, its value is unknown and
coverage is partial. Expression
evaluation and provenance walks stop after 256 nested levels. Union products
and string lengths are checked before allocating combinations or concatenated
strings. When a boundary is reached, the value stays unknown, semantic coverage
becomes partial, and graph evidence retains the expression location and typed
limiting reason. The number of unresolved alternatives is unknown; it is not
reported as an exact omission count.
Byte, entry, path, and graph-shape bounds are hard limits. The parse deadline is
checked before and between bounded parsing and traversal phases; the synchronous
Babel and JSON parser calls cannot be preempted mid-call, so their input byte
@@ -297,5 +265,3 @@ document base and query/fragment rules. CommonJS module lookups retain extension
and directory resolution.
Unresolved HTML references retain their declaration, source range, and resolution
reason in the renderer observations.
HTML script source ranges follow the HTML parser across LF, CRLF, and bare CR
line endings, preserving UTF-16 columns.
+6 -12
View File
@@ -119,18 +119,12 @@ targets belong to the same Electron application.
## Capture and determinism
Capture retains valid script and execution-context metadata within an 8 MiB
aggregate budget, including conservative estimates for UTF-16 strings, record
and collection overhead, and projected URL fields. Each CDP message also has a
16 MiB transport limit. A message over that limit fails with `payload_limit`;
when the aggregate budget is reached, later metadata events are dropped and
the partial result reports `events_dropped`, `truncated: true`, and
`retained_metadata_budget_exceeded`. Repeated context transitions update the
current context record instead of accumulating duplicate objects. Scripts are
validated after capture, deduplicated by stable metadata, and canonically
sorted. Wall-clock timestamps and protocol script IDs are excluded from the
durable result, so identical inputs and captured metadata produce the same
Evidence ID.
Every observation records its time window and returns all valid script and
execution-context events received during it. Per-location protocol validation
remains in force. Scripts are validated after capture, deduplicated by stable
metadata, and canonically sorted. Wall-clock timestamps and protocol script IDs
are excluded from the durable result, so identical inputs and captured metadata
produce the same Evidence ID.
Inspector attach is inherently incomplete. Enabling `Debugger` reports known
and uncollected scripts, but scripts collected before attachment may be
-6
View File
@@ -137,12 +137,6 @@ function implements a specific managed method.
## Evidence record shape
Member comparison, application graph projection, and managed/native verification
authenticate supplied inspection Evidence and check that any subject SHA-256
matches the normalized artifact SHA-256. A valid Evidence ID alone does not
establish that those two identities agree. Evidence without a subject remains
usable, with no subject digest available to cross-check.
Every planned operation returns a provider result and Evidence with four
commitment groups:
+2 -18
View File
@@ -3,8 +3,8 @@
## Generated catalog
Run `npm run build:cached` in a source checkout to generate the machine-readable
catalog at `docs/public/product-catalog.json`. Documentation deployments serve
the same file at [/rea/product-catalog.json](/rea/product-catalog.json). PR CI retains it with the packaged
catalog at `docs/public/product-catalog.json`. The [catalog](product-catalog.json)
is also included in documentation builds. PR CI retains it with the packaged
skill and portable conformance projections in the `generated-docs` artifact.
These outputs describe the exact source revision being built; they are not
checked-in snapshots. For a running server, `binary_session` remains the
@@ -78,25 +78,9 @@ Provider calls receive the request cancellation signal. Artifact traversal,
hashing, version comparisons, Hopper requests, and process capture
check the same signal. Cancellation is distinct from timeout. A cleanup failure
uses `cleanup_incomplete` and lists only the owned resource kinds that remain.
Native call tracing and process capture retain available observations in
`details.partial_observation` on failure, including when cleanup succeeds.
The observation reports its partial coverage; cleanup details describe host
state separately from the execution failure.
Derived comparisons and reconstruction verification yield before computation
and before publication, so cancellation cannot race with successful Evidence.
`analyze_javascript_application` also yields between reconstruction phases and
during graph/result sealing, cross-graph binding checks, Evidence JSON validation,
and canonical hashing. Its final result validation reuses exact graphs whose
owned constructors validated and completely sealed them; imported graphs still
receive full schema and commitment checks.
Cancellation observed before completion returns `cancelled` and prevents the
provisional result from entering the session ledger; prior Evidence stays usable.
Single-file parsing, graph construction, and validation of imported graphs
still run synchronously, so control messages can wait for those
phases to release the event loop. A rejected client promise alone does not
establish that the server has stopped its work.
CLI calls work without a progress token and translate SIGINT into the same
AbortSignal used by providers. Existing controlled-process cleanup and provider
shutdown rules still apply; REA never kills a process it cannot prove it owns.
+5 -116
View File
@@ -12,12 +12,6 @@ input file and include the observed lipo slice offset for universal binaries.
If that offset is unavailable, segment evidence locations are omitted with an
explicit limitation; architecture inventory locations remain available.
Native Xcode command execution uses shared process supervision with a 60-second
deadline and a 64 MiB aggregate stdout/stderr budget. Timeout, cancellation,
stream failure, and output exhaustion retain the captured output, exit details,
and cleanup outcome in the error result. Truncated output is never parsed as a
complete observation.
- `trace_dylib_resolution` / `rea trace-dylib-resolution <app-or-mach-o>`
parses Mach-O load commands in TypeScript and follows dyld's path expansion
for every executable in an app bundle, or for one Mach-O within its directory.
@@ -43,21 +37,6 @@ complete observation.
connections and controller/class names. `verify:interface-builder` compiles
a source-owned AppKit XIB with `ibtool`. Storyboards require an installed
iOS platform; unsupported archive forms remain explicit.
XML plist archives accept UTF-8 and BOM-marked UTF-16 in either byte order.
The shared keyed-archive and Interface Builder decoder consumes an initial
BOM and rejects malformed byte sequences, unsupported encoding declarations,
and declarations that disagree with the detected encoding. It never substitutes
UTF-8 for a declared encoding it cannot process. Archive and evidence digests
still identify the original serialized bytes. Interface Builder decoding
limits aggregate input archives to 32 MiB.
Before decoding, it also bounds the serialized structure: expanded binary
plist references, NIB table records, XML elements and text, plus their
projected representations must fit the 256 MiB aggregate decode budget.
This is a format-derived representation estimate, not a process-wide heap
ceiling.
Archives beyond either aggregate budget are omitted with their paths and an
explicitly partial `archive_decode` facet; previously decoded documents and
their identities remain available.
- `inspect_native_dispatch_metadata` /
`rea inspect-native-dispatch-metadata <app-or-binary>` prefers a validated macOS Mach-O byte reader. It decodes
64-bit little-endian Objective-C class/metaclass records, superclass pointers,
@@ -87,54 +66,6 @@ complete observation.
Ghidra supplies three exact-object operations:
Ghidra accepts hexadecimal addresses with or without a `0x` prefix, in either
letter case, and explicit address-space coordinates such as `EXTERNAL:0x1`.
Results use canonical lowercase hexadecimal offsets. For a procedure identifier,
an explicit `0x` or address-space prefix selects an address; otherwise an exact
database symbol name takes precedence over a bare hexadecimal address. Thus a
function renamed to `dead` remains selectable by name. Use the names returned
by the inventory, including their namespaces and any platform symbol prefix.
Overloads can share a fully qualified name; ambiguity errors return every
matching entry address so the caller can select the intended function directly.
`resolve_containing_procedure` also resolves an exact external entry. Its empty
body remains explicit; nearby external addresses do not inherit that identity.
Ghidra function references cover the complete function-body AddressSet and its
exact entry, including references into instruction interiors and references
from embedded data. Addresses inside an enclosing span but outside the owned
body are excluded. `procedure_references` returns internal and external edges;
`analyze_function` omits incoming edges whose sources belong to the same function.
The reference collector's semantics participate in the analysis profile, so
older snapshots cannot replay the former incomplete results under this profile.
Ghidra regex searches use Java Pattern semantics. If compiling or matching a
pattern exhausts the engine's stack, REA returns a resource constraint and
preserves the active database and annotations. Retry in literal mode or simplify
the regex; successful literal searches retain complete matching strings.
Cancelling an active request terminates the ephemeral database and discards its
annotations. REA retains the selected target and imports the original artifact
again on the next Ghidra query.
Opening the same target and profile again retains the live database, including
annotations. It does not make those edits eligible for an immutable snapshot.
Snapshot closes drain earlier provider requests before saving and keep later
requests waiting until the save and close finish. A concurrent successful edit
therefore rejects the snapshot save and leaves the edited session open.
After metadata edits, snapshot saves and imports remain unavailable in that
session. Use `export_evidence_bundle` to retain the observations, close without
`snapshot_path`, and reopen the target before importing or saving a snapshot.
Ghidra checks its private import copy against the artifact digest selected by
`open_binary`. If the source changes before that copy is acquired, the query
reports `artifact_changed` with the selected path and both digests. Reopen the
stable target to acquire its current identity. An already imported database
continues to describe its captured bytes even if the original path is changed
or removed; this identity failure does not mark the Ghidra installation unavailable.
On Linux and macOS, an unreadable source reports `access_denied`; a removed or
nonregular replacement reports `artifact_changed`. Source copying is cancellable
and never replaces an existing private snapshot.
```bash
rea inspect-native-instruction <binary> <address> --provider ghidra
rea resolve-native-call-targets <binary> <call-site> --provider ghidra
@@ -257,42 +188,16 @@ arguments. The receiver class comes from LLDB's Objective-C runtime reader
without running target code; no expression is evaluated, and REA never
attaches to a process it did not launch.
The run ends when the process exits, `max_events` entries are recorded,
`duration_ms` elapses, or the aggregate trace resource budget is exhausted.
Bounded termination reports partial coverage. REA checks process termination
using a launch-time OS start identity; if ownership or cleanup cannot be
verified, the result or error preserves that uncertainty. The result reports:
The run ends when the process exits, `max_events` entries are recorded, or
`duration_ms` elapses. In the last two cases the process is killed, and REA
confirms that it is gone. The result reports:
- the outcome, exit status and captured stdout/stderr (1 MiB kept per stream
while excess bytes are drained and counted without growing capture files).
Each stream reports `complete`; when draining could not finish, its byte
count is an observed lower bound and truncation is explicit;
- caller-selected arguments, working directory, and environment overrides;
- `selected_file_sha256` and LLDB module identity observations. These checks do
not pin the executable against concurrent mutation; `loaded_image_sha256`
remains unknown;
- the outcome, exit status and captured stdout/stderr (1 MiB kept per stream);
- signal or exception stops;
- every breakpoint's resolved locations; a breakpoint that matched no loaded
code makes coverage partial;
- limitations.
The bridge limits retained event JSON to 8 MiB and caller frames to 65,536
across the run. It checks admission before retaining another complete event;
`resource-limit` reports which observations could not be completed without
silently changing the requested event or frame settings.
Resolved breakpoint locations have a separate aggregate 8 MiB metadata budget.
If their details exceed it, `location_count` retains the full observed match
count, coverage is partial, and `breakpoint_locations_truncated` is true. This
post-run metadata limit does not imply that the target was killed.
Accepted entries and signal stops are also flushed to a bounded 8 MiB journal.
If cancellation, timeout, capture failure, or a later bridge error prevents a
full result, the error retains this evidence in `details.partial_observation`
with partial coverage. Available output prefixes remain inline; their byte
counts are observed lower bounds when final drain counters are unavailable.
The original failure classification and cleanup uncertainty remain separate.
An interrupted journal row is ignored with an explicit limitation.
Only entries are observed: return values, floating-point and stack arguments,
and inlined or `objc_direct` calls are not. The target runs with the current
user's permissions and may change files, show UI or use the network.
@@ -312,17 +217,8 @@ protected runtime DACLs, and handle-based path admission. See [Windows Ghidra P0
[issue #527](https://github.com/morluto/rea/issues/527).
On Linux and macOS, `annotate_native_function` atomically edits a function name
and entry comments in the ephemeral database, returning refreshed analysis
without changing executable bytes. Annotation text must contain no NUL or
unpaired Unicode surrogate; a rejection identifies the field and UTF-16 index
and leaves every annotation unchanged. CRLF, supplementary Unicode characters,
and combining characters are preserved. Windows P0 remains read-only. Ghidra has no
without changing executable bytes. Windows P0 remains read-only. Ghidra has no
GUI authority, and REA never falls back automatically to Hopper.
Ghidra name edits preserve the existing namespace. Supply either a leaf name
such as `renamed` or a fully qualified name in that namespace, such as
`alpha::renamed`. The returned qualified name can be reused as an idempotent
rename input. Edits retain the existing namespace; other namespace-like text
remains literal leaf-name text. A qualified name with an empty leaf is rejected
before any comment or name is changed.
- `npm run verify:ghidra`: host-native debug/stripped targets, native type layout,
instruction/call facts, value dependencies and process/project cleanup.
@@ -342,10 +238,3 @@ before any comment or name is changed.
macOS ARM64 is the real host verified during this implementation. Admission of
macOS Intel does not claim an Intel verification run. Unsupported metadata and
unresolved runtime/value semantics remain visible in results.
## Interface Builder hierarchy coverage
A keyed-archive hierarchy UID without an object-table entry marks the hierarchy
as partial while preserving decoded objects and known links. UID 0 remains
archived nil and does not count as a missing reference. Archive and evidence
digests identify the original serialized bytes.
+2 -15
View File
@@ -108,14 +108,6 @@ and process residual unknowns. Successful cleanup verifies the owned group;
it does not attribute those unrelated processes. Related or otherwise unexplained
unreadable processes still prevent successful cleanup.
On macOS, changing a Node process's `process.title` can make its run token
unreadable. Node documents that [setting the title overwrites argv memory](https://nodejs.org/download/release/v24.18.0/docs/api/process.html#processtitle).
With the pinned toolchain, a live child retained a readable start identity
while its token became unavailable after this change; npm changes its title
as well. A newly started unreadable process can prevent verified cleanup even
after the selected command exits. REA preserves this uncertainty and leaves
that process untouched.
## Compare two captures
Compare saved capture Evidence with:
@@ -138,12 +130,7 @@ causal evidence. Trace comparison returns `unknown` when a capture lacks the
required complete event journal or contains relevant residual unknowns.
Each capture carries commitments for the selected scenario, executable,
comparison contract, and normalization rules. The comparison contract covers
the working directory, explicit environment, filesystem observation paths,
terminal size, scripted events, timeouts, limits, and normalization; the
executable and its arguments may differ. Comparison rejects captures whose
comparison contracts differ and names the differing fields, so run both
scenarios from the same absolute working directory and observation paths.
Captures are local Evidence files; keep
comparison contract, and normalization rules. Comparison rejects captures
whose comparison contracts differ. Captures are local Evidence files; keep
their source artifacts and invocation context available when interpreting a
difference.
+18 -36
View File
@@ -1,25 +1,15 @@
# Releasing from a checkpoint
Pushes to main automatically open or refresh a Release Please proposal with
the next version and changelog. This proposal cannot create a GitHub release
or publish packages. It previews the next release as main continues changing.
Publication uses an explicit source checkpoint. A maintainer selects a frozen
release branch and dispatches preparation and publication, so main can keep
accepting changes without changing the candidate being tested.
REA releases use an explicit source checkpoint. Main can continue accepting
changes while a release is tested and published. The Release workflow runs
only when a maintainer dispatches it; main pushes do not refresh release PRs.
## 1. Select the source
Use the next minor version proposed by Release Please, including releases with
Choose the next version from the unreleased Conventional Commits, including
breaking changes. Record the full source SHA and create `release/VERSION` at
that commit. The selected version and Release Please's proposed version must
agree before publication.
Use the automatic main proposal to review the suggested version and notes.
Keep that proposal unmerged: select the application commit from main, create
the frozen branch, and prepare its own release PR using the steps below.
Merging the proposal into main would advance the release baseline before
publication. The reviewed frozen-branch PR is the publication candidate.
that commit. The version is a maintainer choice; Release Please's proposed
version must agree before publication.
`release/VERSION` also sets the expected version for the workflow. Use an exact
SemVer such as `release/6.1.0` or `release/6.1.0-rc.1`, without build metadata.
@@ -28,21 +18,19 @@ registry metadata or changelog disagrees with that version before creating a tag
### Compatibility and version selection
REA uses Release Please's `always-bump-minor` strategy: every release increments
minor and resets patch, including releases with breaking changes. For example,
the next release after 6.0.0 is 6.1.0. Version numbers use the SemVer format,
but a minor increment does not promise backward compatibility. Review the
changelog's breaking-change section and migration notes before upgrading.
REA uses the default SemVer strategy: compatible fixes increment patch;
compatible capabilities increment minor; incompatible public behavior increments
major. Adding tools, internal refactoring and correcting behavior outside the
supported contract do not by themselves require a major release.
The supported public surface includes documented CLI commands/options, MCP tool
names and input/result contracts, saved evidence formats and supported runtime
requirements. Before adding `!` or a `BREAKING CHANGE` footer, a PR must identify
a previously valid call or configuration that will fail or change meaning,
explain why compatibility cannot be preserved, and give its migration. Prefer
optional additions, compatibility adapters and a documented deprecation period.
Keep breaking markers so the bot includes migration notes without incrementing
major. Maintainers review this impact before merging; the bot parses markers
and cannot infer compatibility.
optional additions, compatibility adapters and a documented deprecation period;
group deliberate removals into a planned major release. Maintainers review this
impact before merging; the bot parses markers and cannot infer compatibility.
For example, adding an optional inspection tool is a minor change. Rejecting a
relative MCP path previously accepted by the public contract is breaking;
@@ -92,11 +80,9 @@ and the PR titles in GitHub's default merge messages. The report preserves the
original merge subject alongside the extracted Conventional Commit title.
This matters after merging a side-branch release back into a newer main:
Release Please's chronological history cutoff can omit unreleased mainline
commits. Under `always-bump-minor`, unreleased breaking markers allow a minor
increment and still require references in the new release's breaking-change
migration section. Checkpoints using the default strategy retain the major
increment requirement. Other omitted entries are reported for review.
Historical notes from an older release cannot satisfy
commits. Unreleased breaking markers require a major increment and references
in the new release's breaking-change migration section. Other omitted entries
are reported for review. Historical notes from an older release cannot satisfy
the new release's migration check. This audits declared markers and references;
it does not prove API compatibility or the quality of a migration explanation.
@@ -129,8 +115,6 @@ gh workflow run release.yml --ref release/5.0.0 \
Publication creates the release from the merged bot PR without preparing or
updating another PR. Both npm and MCP Registry jobs check out Release Please's
exact release SHA. They do not build the current main tip or a mutable branch.
Stable versions publish to npm's `latest` tag; prerelease versions publish to
the `next` tag so they cannot replace the stable install by default.
The publish dispatch runs from the frozen release branch so npm's provenance
records the actual release commit. Before Release Please creates a tag, the
workflow accepts only `prepare` or `publish` and requires the selected branch
@@ -177,10 +161,8 @@ git fetch origin main --tags
git merge-base --is-ancestor rea-agents-5.0.0 origin/main
```
Close the superseded main proposal before merging the synchronization PR.
That merge's main push opens a proposal for the following release using the
updated baseline. Future publication repeats the checkpoint procedure;
automatic proposals do not move frozen branches.
Close any superseded rolling release PR. Future releases repeat the checkpoint
procedure from main; never resume automatic release-PR refreshes on main pushes.
Preparation generates and validates the catalog, portable conformance
projections, and packaged skill from the candidate checkout without committing
them. The checkpoint validator binds tracked version authority to the source
+2 -2
View File
@@ -22,7 +22,7 @@ installation changes and requires approval before writing or installing.
## Planned work
The [website guides](https://rea.tools/guides/) and
The [website guides](https://morluto.github.io/rea/guides/) and
[generated catalog](product-catalog.json) describe available workflows.
Current development priorities are:
@@ -67,7 +67,7 @@ REA setup lets you select agent integration and optional Hopper installation.
It installs the bundled workflow, configures detected agents, and can save
verified paths for an existing Ghidra installation. It configures Claude Code,
Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf, Devin, OpenCode, Antigravity,
GitHub Copilot CLI, Command Code, VS Code, Grok Build, and Grok Bot using each client's configuration format.
GitHub Copilot CLI, Command Code, and VS Code using each client's configuration format.
Ghidra analysis supports Linux x64 and macOS x64/arm64 with Ghidra 12.1.x and the
64-bit full JDK that installation declares. Current 12.1 releases require JDK 21
+16 -151
View File
@@ -7,10 +7,8 @@ semantic cases that these workflows cannot reliably reproduce. A test's path
or suite name does not establish its behavioral depth.
Avoid tests that mirror getters, enum mappings, fixture helpers, or prescribed
call sequences, or assert cache object identity without a caller-visible contract.
Prefer representative workflows over a Cartesian matrix when rows cross the
same boundary. Retain combinations that exercise a distinct interaction, such as
filtering within a full-output envelope. Keep distinct command handlers,
call sequences. Prefer one representative workflow over a Cartesian matrix
when every row crosses the same boundary. Keep distinct command handlers,
producer representations, failure reasons, and lifecycle states covered.
Consolidate their shared setup without hiding the inputs or expected evidence.
@@ -23,34 +21,6 @@ tests consume a production module, trace CLI/MCP and verifier imports,
including imports of compiled files, and remove abandoned scaffolding with its
tests when it has no runtime consumer.
When a test expects a `Result` to succeed, throw its error before asserting the
returned value. An `.ok` assertion immediately before the same throwing guard
adds no coverage and hides the typed failure. A removed parser matrix can leave
one golden malformed-input case and a real filesystem lifecycle that proves
configure, update, readback, and removal; an add-only journey does not cover the
whole lifecycle.
Protocol fixtures must model the commands they support and their producer reply
shapes. Reject unmodeled commands instead of returning a generic success; an
empty result is appropriate only for a supported command with no result fields.
Keep transport-only in-memory MCP sessions in the MCP boundary lane and direct
filesystem workflows in the filesystem boundary lane. Real process-tree capture
belongs in the serial process boundary lane, including captures invoked through
MCP. Report unavailable capture authority as a named skip rather than returning
from the test without assertions. Reserve acceptance paths for journeys
through the compiled public CLI or MCP runtime. Calling a compiled private
workflow or injecting a provider still establishes its narrower integration
claim, even if a directory name calls it acceptance.
Emit a verifier's final success report only after awaited cleanup. Observe final
process lineage after closing its owned resources.
The Vitest runner releases cached native process inspectors during awaited
worker teardown. Fork termination does not run Node's normal exit hooks; a
per-file teardown would instead retire shared inspectors before later files
can use them.
Measure slow files before removing capacity regressions. Optimize repeated
process startup, fixture construction, and production algorithms when they
dominate runtime. Keep at least one input beyond each formerly failing size or
@@ -128,6 +98,12 @@ an XML graph golden. `verify:asset-catalog` compiles source-owned colors with
`actool`, invokes real `assetutil`, then checks CLI/MCP results, exact catalog
digest, every raw metadata field, pagination and malformed input rejection.
Neither artifact workflow requires Hopper or launches it. Both run in macOS CI.
`verify:dyld-shared-cache` reads the host's dyld shared cache. It checks that
the image list, and every image's dependencies with their attributes, equal
`dyld_info -all_dyld_cache -linked_dylibs`, and that `inspect-dyld-shared-cache`
agrees between CLI and stdio MCP. It then traces the clang-built fixture app
with `--shared-cache` and checks that every `shared-cache` edge names a library
dyld actually loads under `DYLD_PRINT_LIBRARIES`.
`verify:macos-bundle` needs only macOS with Command Line Tools. It compiles a
source-owned app with `clang`: a versioned framework, XPC services, an app
extension, a login item, a privileged helper, launchd plists, and a helper tool,
@@ -143,12 +119,6 @@ covers `trace-dylib-resolution`:
- for the main executable and an XPC service, a predicted load order equal to
the images dyld actually loads under `DYLD_PRINT_LIBRARIES`.
The lane also compiles executable/library pairs with empty embedded directory,
versioned-path, and suffix settings. It checks present and removed dependencies
through CLI and MCP, and compares actual `DYLD_PRINT_SEARCHING` diagnostics for
root-level candidates. These cases distinguish an empty search directory from
an empty versioned scan or a suffix that only repeats the original path.
It runs in macOS CI.
Apple artifact verifiers live in `scripts/verify/apple/`, with the macOS bundle
@@ -174,12 +144,6 @@ case. Real capture tests preserve actual descendant and cleanup checks.
The existing Apple job also exercises the relocated filesystem snapshot identity,
cancellation and descriptor cleanup regressions on macOS.
Prepare the native inspector before cases that measure producer output or exit
behavior; keep startup deadlines and cancellation in distinct cases. Run real
process-capture verification separately from package or build checks. On macOS,
new npm processes can become token-unreadable after changing their display
title and prevent verified cleanup during a capture's ownership sweep.
MCP SDK transport tests with recording providers remain integration tests.
They are useful for schema drift and failure projection but do not prove that
Hopper, Ghidra or another substituted engine works. `verify:package` proves
@@ -187,46 +151,6 @@ packaging/install behavior and fake-provider integration; use the corresponding
real-provider lanes for engine claims. Real Apple dispatch and Interface
Builder verifiers currently prove format integration through production readers.
`verify:hopper` exercises an installed Hopper through the production stdio MCP
server and CLI. It checks source-owned call chains, CFG edges, references and
complete large inventories, then probes unnamed bookmarks, annotation clearing,
batch validation before mutation, malformed addresses and regexes, segment-end
partial reads, and synthetic file-offset rejection. Advertised schemas are
validated in their JSON Schema dialect and successful replies are checked against
their advertised output schemas. Navigation checks cover interior-object cursor
readback and mapped-memory boundaries. Annotation probes verify invalid native
text and duplicate destinations/names before mutation, preserve unselected label
owners, and exercise explicit batch label swaps. Function locals retain observed
names and stack displacements. Graph probes check symbol/interior-address parity
and a source-owned recursive cycle; literal tracing preserves complete queries
and whitespace. Disposable binary copies prove that switching and closing
actually removes the native document, and that CLI byte, function-dossier,
literal-trace results and invalid-address diagnostics agree with MCP.
No provider is mocked in this lane.
Real search probes cover Unicode names, literal metacharacters, case and regex
modes, annotation cache invalidation, complete native fragments of long literals
checked against byte reads, escaped UTF-8/control text, byte-preserving Latin-1
decoding, and Hopper's UTF-16 symbol-name truncation boundary. It verifies native
CallReference classifications across reference and dossier results, retains long
string fragment metadata in dossiers, and exercises pathological regex deadline
and cancellation followed by successful requests in the same native session. When the macOS
Objective-C fixture is present, the lane also verifies native UTF-16 string objects
and their inferred encodings against their actual bytes.
Unmapped annotation destinations and
oversized later batch names fail before any earlier edit is applied.
The Linux demo lane remains a separate `verify:hopper:linux` command.
`verify:hopper:fat` is a separate macOS lane requiring installed Hopper and the
existing Xcode clang/lipo toolchain. It compiles arm64/x86-64 thin executables and
one- and two-slice FAT32 containers, verifies exact/interior address mappings
against bytes in the original files, checks CLI/MCP parity, and checks owned
runtime cleanup. `verify:hopper:fat64` additionally checks FAT64 preparation through
Hopper's native Mach-O loader, source-container mappings, profile identity,
malformed and ambiguous slice rejection, and temporary-image lifetime. Both
lanes have been verified on Hopper 6.1.0-demo; this establishes REA's prepared
FAT64 workflow, not native FAT64-loader support.
Cross-architecture fixture compilation is not required by `verify:hopper`.
Golden tests use immutable captured text inputs with producer/source provenance
under `tests/fixtures/golden/`. Expected results are reviewed for the semantic
claim; capture commands do not automatically approve new expected outputs.
@@ -301,57 +225,6 @@ The host-native Ghidra lane also verifies native value tracing through the
production CLI and a separate stdio MCP process. It compares complete dependency
graphs, validates Evidence and upstream/workflow profiles, checks capability
discovery, and closes the MCP session. No provider or transport is mocked.
It also validates every advertised input/output JSON Schema and the exercised
MCP outputs, probes address spelling and name/address ambiguity, and checks
direct versus targetless calls, byte-read completeness, invalid input diagnostics,
CLI/MCP parity, atomic annotation rollback, refreshed inventories, unchanged
executable bytes, and discarded edits after reopen. A deliberately long temporary
path exercises private Unix socket allocation and cleanup, including cancellation
after a real headless process launches. Native annotation probes reject NUL and
unpaired Unicode surrogates without partial edits or a broken bridge, preserve
supported Unicode and control text, and check lossless malformed-text diagnostics.
Memory-to-file mapping is checked against original artifact bytes.
The fixture also stores a pointer one byte past a function entry; exact
xrefs, raw procedure references, and CLI/MCP dossiers must retain that data edge.
A valid legacy snapshot reconstructs the former omitted edge under its older
profile; CLI and MCP must reject that binding with a mismatch reason and
recovery advice. The rejected open must preserve the active live session.
Exact external entries must resolve while retaining an empty body; unknown
external addresses remain unresolved and external annotations are rejected.
An adversarial regex over a full 12 KB literal must report stack exhaustion as
a resource constraint, preserve live annotations, and allow complete literal
searches afterward; CLI and MCP must agree on both results and recovery advice.
Real snapshot lifecycle checks retain edited API results as Evidence while
rejecting immutable snapshot saves and imports before and after a repeated
open of the same target. They verify unchanged live annotations and run identity,
absent rejected output files, an unchanged source snapshot, and successful
snapshot import/save after closing and recreating the database. The pristine
snapshot is written by an independent real CLI session.
They also start a real annotation and snapshot close concurrently: the edit
must succeed, the snapshot must be rejected without creating a file, and the
edited session must remain usable until explicitly closed.
Source-admission probes change a caller-owned fixture after `open_binary` but
before the first Ghidra query. They require an actionable `artifact_changed`
error preserving both digests and the selected path, unchanged provider
availability, failed-copy cleanup, and successful recovery after reopening.
After import, deleting that source must preserve the captured database identity.
Instruction inspection and containing-function lookup also agree across
hexadecimal case, leading zeros, and encoded default address-space spellings.
The same source-acquisition workflow exercises missing and directory-replaced
inputs, plus real read-permission denial on a non-root host. Root runs report
that permission-denial check as unverified. A focused filesystem integration
check retains selected-platform routing and exclusive creation; two copy/digest
checks duplicated by the real workflow have been removed.
Namespace annotation probes compile a separate host C fixture with C++ ABI
symbols, avoiding a C++ runtime prerequisite. Real Ghidra demangling supplies
duplicate leaf names in two top-level namespaces and a nested namespace. The
workflow verifies leaf and qualified renames, repeated reuse of fully qualified
readback, lookup by the returned name, literal namespace-like leaf names,
rejection of empty qualified leaf names without changing comments, CLI
behavior, and independent CLI/MCP database ownership.
Malformed annotation readback, memory completeness, and inventory data remain separate
SDK/provider integration cases; success from a real
provider cannot establish rejection of a contradictory provider response.
The Linux switch lane checks dense, sparse-with-holes, shared-body, nonzero,
negative, and nonexact JSON integer labels plus a comparison-only control.
@@ -417,7 +290,7 @@ are separate proof levels.
Synthetic producer regressions run independently:
```sh
npm run test:focused -- tests/boundary/process/jadxIntegration.test.ts tests/boundary/process/androidAnalysisMcp.test.ts
npm run test:focused -- tests/boundary/android/jadxIntegration.test.ts tests/boundary/mcp/androidAnalysisMcp.test.ts
```
## Optional NativeAOT Ghidra analysis
@@ -489,18 +362,14 @@ a DOS emulator or compiler, then checks real 16-bit decoding, segment
relocation, near/far calls, decompilation, disjoint function body ranges,
stable CLI/MCP observations, unchanged source bytes, and owned process/project
cleanup. Raw p-code address-space selector tokens are reported separately from
the stable observation comparison. Linux x64 and macOS arm64 are verified;
macOS x64 remains unverified. This lane is separate from host-native and optional cross-format
the stable observation comparison. Linux x64 is verified; macOS DOS remains
unverified. This lane is separate from host-native and optional cross-format
verification. See [DOS analysis](ghidra-dos.md).
`npm run verify:ghidra:com` uses a generated headerless fixture with no compiler,
DOS emulator or game data. It exercises explicit admission, BinaryLoader entry
preparation, measured register context, whole-file byte readback, source offsets,
unmapped PSP/partial reads, actual decompilation, CLI/MCP parity and owned cleanup.
Both segmented-address lanes reject oversized default, explicit-space and encoded-space
coordinates through real reads, function queries and annotation attempts. Rejected
annotations must preserve the live function dossier; CLI and MCP must report the
truncation constraint, while leading-zero coordinates still resolve correctly.
It has the same Ghidra/JDK prerequisites as the MZ lane. Neither lane claims DOS
runtime or PC-98 device execution.
@@ -600,9 +469,7 @@ provider evidence.
Each Vitest project uses up to two workers, bounded by available host parallelism.
Default-group projects can run together. Only `process-boundary` runs in the
later sequence group and serializes its files because process-tree observations
share host sampling resources. Tests that launch the real process harness belong
in `tests/boundary/process/`, including CLI command and environment tests.
Acceptance and process-global files retain isolated
share host sampling resources. Acceptance and process-global files retain isolated
forks but are not serialized; isolation does not imply serial scheduling. CI uses
the same per-project worker bound.
The pure domain/contracts and recording-port service projects share one worker
@@ -671,19 +538,17 @@ conformance/vtable fixture.
Both commands reject a changed executable digest and clean up the fixture
process and helper. These lanes require an interactive macOS desktop. See [native investigation](native-investigation.md)
`npm run verify:native-calls` needs macOS with Command Line Tools (`clang`,
`lldb`, `codesign`, `nm`) and Developer Mode enabled for unattended debugger
access. Preflight reports disabled Developer Mode without changing host settings.
It compiles `tests/conformance/native/calls.m` and
`npm run verify:native-calls` needs only macOS with Command Line Tools (`clang`,
`lldb`, `codesign`, `nm`). It compiles `tests/conformance/native/calls.m` and
runs `observe-native-calls` through the CLI and stdio MCP. It checks:
- the receiver class, selector and argument registers of every entry, and that
breakpoint addresses equal `nm`'s symbol addresses;
- captured stdout and an environment override, plus a 2 MiB flood on each
output stream with bounded retained prefixes and exact drained-byte counts;
- captured stdout and an environment override;
- the event-limit and duration outcomes, with the process confirmed gone;
- that a hardened-runtime copy is refused with `debugger-attach-denied`, and
that the same copy signed with `get-task-allow` is traced.
for the exact ABI, authority, graph and observation boundaries.
### Firmware adapters
+1 -3
View File
@@ -20,9 +20,7 @@ Results retain:
- The selected target, frame and document loader identity; local armed/end timestamps and separate backend monotonic coverage timestamps.
- Every admitted producer function/block range, exclusive end offset and execution count in UTF-16 code units, with verified bounds when exact text is available. Nested/overlapping counts are preserved and must not be summed.
- Main-document script inventory, exact session script IDs, resource start coordinates, sourceURL/source-map declarations, producer hash and independently computed UTF-8 text digest.
- Armed-window requests from the selected main frame and selected origins, complete reported initiator objects (including stack hierarchy/descriptions), derived callsites, exact script-ID associations and unresolved async parent IDs. Source association does not establish UI causality. Callsite and listener `line_number`/`column_number` values are CDP's zero-based lines and UTF-16 columns in the resource named by `url`. With a `script_id`, that is the script's enclosing resource: the script file for an external script, or the HTML document for an inline script. `resource_start` is where the script text starts in that resource. A callsite with an empty `script_id` comes from the request's reported initiator rather than a script call frame, such as markup that the parser read in a document or a module import in a script file. It still locates a position in `url`, but has no session-script association, so it cannot be joined to captured source or `resource_start`. `trace_web_source_location` takes one-based, script-relative lines and zero-based UTF-16 columns.
For an external script, where `url` is the script file, use `line = line_number + 1` and keep `column = column_number`; this also applies to an empty-`script_id` initiator position in a script file. For an inline script, which requires a `script_id` and its `resource_start`, use `line = line_number - resource_start.line_number + 1`. Subtract `resource_start.column_number` from `column_number` only when `line_number === resource_start.line_number`; keep the reported column on later lines. Columns remain zero-based. This conversion requires known source-start and column coordinates; preserve unavailable coordinates as unknown.
- Armed-window requests from the selected main frame and selected origins, complete reported initiator objects (including stack hierarchy/descriptions), derived callsites, exact script-ID associations and unresolved async parent IDs. Source association does not establish UI causality.
The coverage counter interval starts at backend acceptance before the armed receipt and ends at the resetting sample after the local timer. Counts may include execution during these command intervals; request events are restricted to the locally armed window. The result retains both backend timestamps and the distinct local clock.
-16
View File
@@ -84,22 +84,6 @@ are made.
## Verification
Browser bundle collection uses absolute section offsets for nested inline maps.
An enclosing column offset contributes only when the nested section starts on
that enclosing section's first generated line. Parent boundaries still clip
preceding browser mappings when a nested section starts with a gap or has no
child leaves. Raw map text, original source identities and the dedicated decoder
profile are preserved.
Browser collection accepts up to 64 MiB of map responses per inspection and
preflights indexed sections, sources, names, generated rows and mapping segments
against a 262144-record decode budget before upstream flattening. Indexed maps
are limited to 64 nested levels. These structural limits bound decoded object
growth while preserving large embedded `sourcesContent` strings within the
response-byte budget; exceeding a decoder limit returns a `fetch_failed` item.
Malformed UTF-8 map responses are reported as invalid rather than repaired with
replacement characters.
Focused tests cover duplicate mappings, indexed offsets/overlap/local indexes,
32-bit VLQ overflow, nullable content, UTF-16/CRLF positions, immutable artifact
identity, cancellation, late output closure, cleanup failures, SDK schemas and
+1 -6
View File
@@ -1,11 +1,6 @@
{
"$schema": "./node_modules/knip/schema.json",
"entry": [
"src/main.ts",
"src/cli.ts",
"src/contracts/errorSchemas.ts",
"tests/processOwnershipRunner.ts"
],
"entry": ["src/main.ts", "src/cli.ts", "src/contracts/errorSchemas.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.mjs"],
"vitepress": {
"entry": ["docs/.vitepress/config.ts"]
+3 -3
View File
@@ -87,7 +87,8 @@
"mcp-catalog:check": "node scripts/generate-mcp-tool-catalog.mjs --check",
"prebuild": "npm run deps:check && npm run metadata:check",
"build": "node scripts/run-exclusive.mjs build npm run build:unlocked",
"build:unlocked": "node scripts/clean-build-output.mjs && tsc -p tsconfig.build.json && node scripts/generate-mcp-tool-catalog.mjs && node scripts/generate-skill-metadata.mjs && node scripts/generate-product-catalog.mjs && node scripts/generate-completion-ledger.mjs",
"build:unlocked": "node scripts/clean-build-output.mjs && tsc -p tsconfig.build.json && npm run build:projections",
"build:projections": "node scripts/generate-mcp-tool-catalog.mjs && node scripts/generate-skill-metadata.mjs && node scripts/generate-product-catalog.mjs && node scripts/generate-completion-ledger.mjs",
"precheck": "npm run deps:check",
"check": "turbo run typecheck lint format:check knip metadata:check",
"precheck:fast": "npm run deps:check",
@@ -138,11 +139,10 @@
"typecheck": "tsc --noEmit",
"verify:test-temp-hygiene": "npm run build:cached && node scripts/verify-test-temp-hygiene.mjs",
"verify:hopper": "npm run verify:fixtures && npm run build:cached && node scripts/verify-real-hopper.mjs",
"verify:hopper:fat": "npm run build:cached && node scripts/verify-real-hopper-fat.mjs",
"verify:hopper:fat64": "npm run build:cached && node scripts/verify-real-hopper-fat.mjs --fat64",
"verify:apple-dispatch": "npm run build:cached && node scripts/verify/apple/dispatch.mjs",
"verify:native-ui": "npm run build:cached && node scripts/verify-native-ui.mjs",
"verify:keyed-archive": "npm run build:cached && node scripts/verify/apple/keyed-archive.mjs",
"verify:dyld-shared-cache": "npm run build:cached && node scripts/verify/apple/dyld-shared-cache.mjs",
"verify:macos-bundle": "npm run build:cached && node scripts/verify/apple/macos-bundle.mjs",
"verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs",
"verify:ida": "npm run build:cached && node scripts/verify-real-ida.mjs",
-1
View File
@@ -1,7 +1,6 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"release-type": "node",
"versioning": "always-bump-minor",
"include-v-in-tag": false,
"changelog-sections": [
{ "type": "feat", "section": "Features", "hidden": false },
+18 -14
View File
@@ -61,7 +61,7 @@ const createElectronActiveBoundaryPatches = ({
const target = typeof args[0] === "string" ? args[0] : null;
recordRuntime("preload", "context-bridge-exposure", "attempted", {
target,
argument_shape_values: args,
argument_shapes: args.map((value) => shape(value)),
process_type: processType(),
});
try {
@@ -110,7 +110,7 @@ const createElectronActiveBoundaryPatches = ({
event === "will-download" ? "blocked" : "observed",
{
target: identity(args[0], "webContents"),
argument_shape_values: args,
argument_shapes: args.map((value) => shape(value)),
error: event === "will-download",
},
);
@@ -124,7 +124,7 @@ const createElectronActiveBoundaryPatches = ({
(webContents, permission, callback) => {
recordRuntime("permission", "permission-request", "blocked", {
target: identity(webContents, "webContents"),
argument_shape_values: [permission],
argument_shapes: [shape(permission)],
error: true,
});
if (typeof callback === "function") callback(false);
@@ -137,7 +137,7 @@ const createElectronActiveBoundaryPatches = ({
return originalPermissionCheck.call(this, (webContents, permission) => {
recordRuntime("permission", "permission-check", "blocked", {
target: identity(webContents, "webContents"),
argument_shape_values: [permission],
argument_shapes: [shape(permission)],
error: true,
});
return false;
@@ -148,7 +148,7 @@ const createElectronActiveBoundaryPatches = ({
session.setCertificateVerifyProc = function blockedCertificate() {
return originalCertificate.call(this, (request, callback) => {
recordRuntime("permission", "certificate-error", "blocked", {
argument_shape_values: [request],
argument_shapes: [shape(request)],
error: true,
});
if (typeof callback === "function") callback(-2);
@@ -168,14 +168,14 @@ const createElectronActiveBoundaryPatches = ({
} catch {
recordRuntime("popup-attempt", "window-open-handler", "failed", {
target: contentsId,
argument_shape_values: args,
argument_shapes: args.map((value) => shape(value)),
error: true,
});
return { action: "deny" };
}
recordRuntime("popup-attempt", "window-open-handler", "blocked", {
target: contentsId,
argument_shape_values: args,
argument_shapes: args.map((value) => shape(value)),
error: true,
});
return { action: "deny" };
@@ -223,7 +223,7 @@ const createElectronActiveBoundaryPatches = ({
method === "relaunch" ? "updater" : "protocol",
method,
"blocked",
{ argument_shape_values: args, error: true },
{ argument_shapes: args.map((value) => shape(value)), error: true },
);
return method === "setAsDefaultProtocolClient" ? false : undefined;
};
@@ -250,7 +250,11 @@ const createElectronActiveBoundaryPatches = ({
recordRuntime("native-addon", "dlopen", "attempted", {
artifact_path: typeof filename === "string" ? filename : null,
artifact_sha256: digest,
argument_shape_value_groups: [[module, filename], args],
argument_shapes: [
shape(module),
shape(filename),
...args.map((value) => shape(value)),
],
});
try {
const result = original.call(this, module, filename, ...args);
@@ -276,7 +280,7 @@ const createElectronActiveBoundaryPatches = ({
if (typeof original !== "function") return;
utilityProcess.fork = function patchedFork(...args) {
recordRuntime("process-lifecycle", "utility-process-fork", "attempted", {
argument_shape_values: args,
argument_shapes: args.map((value) => shape(value)),
process_type: "main",
});
const child = original.apply(this, args);
@@ -300,7 +304,7 @@ const createElectronActiveBoundaryPatches = ({
recordRuntime("process-lifecycle", `utility.${event}`, "observed", {
target: childId,
process_type: "utility",
argument_shape_values: eventArgs,
argument_shapes: eventArgs.map((value) => shape(value)),
});
});
if (child && typeof child.postMessage === "function") {
@@ -340,7 +344,7 @@ const createElectronActiveBoundaryPatches = ({
if (typeof original !== "function") continue;
shell[method] = function blockedShellCall(...args) {
recordRuntime("shell-attempt", method, "blocked", {
argument_shape_values: args,
argument_shapes: args.map((value) => shape(value)),
error: true,
});
return Promise.reject(new Error("External shell effects are blocked"));
@@ -355,7 +359,7 @@ const createElectronActiveBoundaryPatches = ({
if (typeof original !== "function") continue;
childProcess[method] = function patchedChildProcess(...args) {
recordRuntime("process-lifecycle", `child.${method}`, "attempted", {
argument_shape_values: args,
argument_shapes: args.map((value) => shape(value)),
});
const child = original.apply(this, args);
const childId = identity(child, "child");
@@ -365,7 +369,7 @@ const createElectronActiveBoundaryPatches = ({
patchEmitter(child, (event, eventArgs) =>
recordRuntime("process-lifecycle", `child.${event}`, "observed", {
target: childId,
argument_shape_values: eventArgs,
argument_shapes: eventArgs.map((value) => shape(value)),
}),
);
return child;
+17 -199
View File
@@ -16,19 +16,9 @@ const ipcEventKinds = new Set([
"ipc-renderer-post-message",
]);
const events = [];
// Budget serialized data and estimated retained object/array storage together.
const retentionBudgetBytes = 16 * 1024 * 1024;
const snapshotEnvelopeReserveBytes = 1024;
let estimatedRetainedBytes = 0;
let serializedBytes = 0;
let observed = 0;
let observedIpc = 0;
let observedRuntime = 0;
let dropped = 0;
let droppedIpc = 0;
let droppedRuntime = 0;
const droppedFamilies = new Map();
const droppedRoles = new Map();
let sequence = 0;
let correlationSequence = 0;
@@ -53,73 +43,6 @@ const shape = (value, depth = 0) => {
}
};
// Measure JSON strings without allocating an encoded copy of a caller value.
const jsonStringBytes = (value) => {
let bytes = 2;
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code === 0x22 || code === 0x5c) bytes += 2;
else if (
code === 0x08 ||
code === 0x09 ||
code === 0x0a ||
code === 0x0c ||
code === 0x0d
)
bytes += 2;
else if (code <= 0x1f) bytes += 6;
else if (code >= 0xd800 && code <= 0xdbff) {
const next = value.charCodeAt(index + 1);
if (next >= 0xdc00 && next <= 0xdfff) {
bytes += 4;
index += 1;
} else bytes += 6;
} else if (code >= 0xdc00 && code <= 0xdfff) bytes += 6;
else if (code <= 0x7f) bytes += 1;
else if (code <= 0x7ff) bytes += 2;
else bytes += 3;
if (bytes > retentionBudgetBytes) return bytes;
}
return bytes;
};
const jsonScalarBytes = (value) => {
if (value === null) return 4;
if (typeof value === "string") return jsonStringBytes(value);
if (typeof value === "boolean") return value ? 4 : 5;
return String(value).length;
};
const eventFamily = (kind, processTypeValue) => {
if (kind === "preload" && processTypeValue === "main")
return "preload-configuration";
if (kind.startsWith("ipc-renderer"))
return processTypeValue === "renderer" ? "renderer-ipc" : "ipc";
return ipcEventKinds.has(kind) ? "ipc" : kind;
};
const noteDropped = (kind, processTypeValue, isIpc) => {
dropped += 1;
if (isIpc) droppedIpc += 1;
else droppedRuntime += 1;
const family = eventFamily(kind, processTypeValue);
droppedFamilies.set(family, (droppedFamilies.get(family) ?? 0) + 1);
if (typeof processTypeValue === "string")
droppedRoles.set(
processTypeValue,
(droppedRoles.get(processTypeValue) ?? 0) + 1,
);
if (kind === "window-lifecycle")
droppedRoles.set("window", (droppedRoles.get("window") ?? 0) + 1);
if (kind === "web-contents-lifecycle" || kind === "navigation")
droppedRoles.set(
"web_contents",
(droppedRoles.get("web_contents") ?? 0) + 1,
);
if (kind === "preload" && processTypeValue === "preload")
droppedRoles.set("preload", (droppedRoles.get("preload") ?? 0) + 1);
};
const isAllowedNavigation = (value) => {
if (typeof value !== "string") return true;
try {
@@ -160,8 +83,7 @@ const frameIdentity = (event) => {
const record = (event) => {
observed += 1;
const isIpc = ipcEventKinds.has(event.kind);
if (isIpc) observedIpc += 1;
if (ipcEventKinds.has(event.kind)) observedIpc += 1;
else observedRuntime += 1;
const raw = {
sequence: ++sequence,
@@ -187,15 +109,10 @@ const record = (event) => {
error: false,
...event,
};
const shapeGroups = Array.isArray(raw.argument_shape_value_groups)
? raw.argument_shape_value_groups.filter(Array.isArray)
: Array.isArray(raw.argument_shape_values)
? [raw.argument_shape_values]
: [];
const existingShapes = Array.isArray(raw.argument_shapes)
const argumentShapes = Array.isArray(raw.argument_shapes)
? raw.argument_shapes
: [];
const retainedEvent = {
events.push({
...raw,
correlation_id: raw.correlation_id,
event: typeof raw.event === "string" ? raw.event : null,
@@ -204,7 +121,9 @@ const record = (event) => {
receiver: typeof raw.receiver === "string" ? raw.receiver : null,
frame: typeof raw.frame === "string" ? raw.frame : null,
target: typeof raw.target === "string" ? raw.target : null,
argument_shapes: [],
argument_shapes: argumentShapes.map((value) =>
typeof value === "string" ? value : "unknown",
),
result_shape:
typeof raw.result_shape === "string" ? raw.result_shape : null,
process_type:
@@ -213,91 +132,8 @@ const record = (event) => {
typeof raw.artifact_path === "string" ? raw.artifact_path : null,
artifact_sha256:
typeof raw.artifact_sha256 === "string" ? raw.artifact_sha256 : null,
};
delete retainedEvent.argument_shape_values;
delete retainedEvent.argument_shape_value_groups;
// All event values are JSON scalars except argument_shapes. Measure them
// incrementally so large channels, paths, or shape lists never require a
// full JSON.stringify temporary allocation before the budget decision.
let eventBytes = 2;
let argumentShapeCount = 0;
const eventKeys = Object.keys(retainedEvent);
for (let index = 0; index < eventKeys.length; index += 1) {
const key = eventKeys[index];
const value = retainedEvent[key];
if (index > 0) eventBytes += 1;
eventBytes += jsonStringBytes(key) + 1;
if (key === "argument_shapes") {
eventBytes += 2;
let shapeOverflow = false;
const countShape = (argument) => {
const encodedShapeBytes = jsonStringBytes(shape(argument));
eventBytes += encodedShapeBytes + (argumentShapeCount === 0 ? 0 : 1);
argumentShapeCount += 1;
shapeOverflow =
eventBytes + estimatedRetainedBytes + snapshotEnvelopeReserveBytes >
retentionBudgetBytes;
};
for (const group of shapeGroups)
for (const argument of group) {
countShape(argument);
if (shapeOverflow) break;
}
if (shapeOverflow) {
noteDropped(raw.kind, retainedEvent.process_type, isIpc);
return null;
}
for (const valueShape of existingShapes)
if (!shapeOverflow) {
eventBytes +=
jsonStringBytes(
typeof valueShape === "string" ? valueShape : "unknown",
) + (argumentShapeCount++ === 0 ? 0 : 1);
shapeOverflow =
eventBytes + estimatedRetainedBytes + snapshotEnvelopeReserveBytes >
retentionBudgetBytes;
}
if (shapeOverflow) {
noteDropped(raw.kind, retainedEvent.process_type, isIpc);
return null;
}
} else eventBytes += jsonScalarBytes(value);
if (
eventBytes + estimatedRetainedBytes + snapshotEnvelopeReserveBytes >
retentionBudgetBytes
) {
noteDropped(raw.kind, retainedEvent.process_type, isIpc);
return null;
}
}
// Reserve room for the only fields mutated after recording: result_shape and
// error. The shape vocabulary's longest value is "undefined".
const reservedMutationBytes = retainedEvent.result_shape === null ? 7 : 0;
// Include one byte for the event-array separator and reserve space for the
// snapshot's counters and JSON delimiters.
// One retained event, one defensive snapshot copy, and up to two serialized
// result views (timeline plus IPC), with per-object and per-array-slot costs.
const eventEstimateBytes = eventBytes * 3 + 512 + argumentShapeCount * 16;
const retainedEventBytes = eventEstimateBytes + reservedMutationBytes + 1;
if (
estimatedRetainedBytes + retainedEventBytes + snapshotEnvelopeReserveBytes >
retentionBudgetBytes
) {
noteDropped(raw.kind, retainedEvent.process_type, isIpc);
return null;
}
for (const group of shapeGroups)
for (const argument of group)
retainedEvent.argument_shapes.push(shape(argument));
for (const valueShape of existingShapes)
retainedEvent.argument_shapes.push(
typeof valueShape === "string" ? valueShape : "unknown",
);
events.push(retainedEvent);
estimatedRetainedBytes += retainedEventBytes;
serializedBytes += eventBytes + reservedMutationBytes + 1;
return retainedEvent;
});
return events.at(-1);
};
const recordRuntime = (kind, event, phase, details = {}) =>
@@ -309,7 +145,7 @@ const recordIpc = (kind, channel, args, details = {}) =>
return record({
kind,
channel: typeof channel === "string" ? channel : null,
argument_shape_values: values,
argument_shapes: values.map((value) => shape(value)),
...details,
});
})();
@@ -386,7 +222,7 @@ const patchNavigationMethods = (webContents, contentsId) => {
webContents[method] = function patchedNavigation(...args) {
recordRuntime("navigation", method, "attempted", {
target: contentsId,
argument_shape_values: args,
argument_shapes: args.map((value) => shape(value)),
});
if (method === "loadURL" && !isAllowedNavigation(args[0])) {
recordRuntime("navigation", method, "blocked", {
@@ -518,7 +354,7 @@ const patchWebContents = (webContents, windowId) => {
recordRuntime("navigation", event, "blocked", {
target: contentsId,
sender: windowId,
argument_shape_values: args,
argument_shapes: args.map((value) => shape(value)),
error: true,
});
return;
@@ -527,7 +363,7 @@ const patchWebContents = (webContents, windowId) => {
recordRuntime(kind, event, "observed", {
target: contentsId,
sender: windowId,
argument_shape_values: args,
argument_shapes: args.map((value) => shape(value)),
error: kind === "error",
});
if (event === "did-create-window") {
@@ -552,7 +388,7 @@ const patchBrowserWindow = (electron) => {
const targetId = identity(window, "window");
recordRuntime("window-lifecycle", "created", "completed", {
target: targetId,
argument_shape_values: args,
argument_shapes: args.map((value) => shape(value)),
});
const preload = args[0]?.webPreferences?.preload;
if (typeof preload === "string")
@@ -591,7 +427,7 @@ try {
"observed",
{
target: "app",
argument_shape_values: args,
argument_shapes: args.map((value) => shape(value)),
process_type: "main",
},
),
@@ -608,14 +444,14 @@ try {
boundaries.patchChildProcess();
process.once("uncaughtException", (cause, origin) => {
recordRuntime("process-lifecycle", "uncaught-exception", "observed", {
argument_shape_values: [cause, origin],
argument_shapes: [shape(cause), shape(origin)],
error: true,
});
throw cause;
});
process.once("unhandledRejection", (reason, promise) => {
recordRuntime("process-lifecycle", "unhandled-rejection", "observed", {
argument_shape_values: [reason, promise],
argument_shapes: [shape(reason), shape(promise)],
error: true,
});
throw reason;
@@ -625,25 +461,7 @@ try {
}
globalThis.__reaElectronActiveSnapshot = () => ({
events: events.map((event) => ({
...event,
argument_shapes: [...event.argument_shapes],
})),
retention_budget_bytes: retentionBudgetBytes,
estimated_retained_bytes: estimatedRetainedBytes,
event_serialized_byte_upper_bound: serializedBytes,
retained: events.length,
dropped,
dropped_ipc: droppedIpc,
dropped_runtime: droppedRuntime,
dropped_event_families: [...droppedFamilies].map(([family, count]) => ({
family,
count,
})),
dropped_event_roles: [...droppedRoles].map(([role, count]) => ({
role,
count,
})),
events: events.slice(),
observed,
observed_ipc: observedIpc,
observed_runtime: observedRuntime,
+8 -31
View File
@@ -4,7 +4,7 @@ import { access } from "node:fs/promises";
import { isAbsolute } from "node:path";
/** Generate a real compiler map and serve only source-owned fixture code. */
export async function startBrowserSourceMapSite(options = {}) {
export async function startBrowserSourceMapSite() {
const command = process.env.REA_WEB_SOURCE_MAP_COMPILER;
if (!command || !isAbsolute(command))
throw new Error(
@@ -19,40 +19,19 @@ export async function startBrowserSourceMapSite(options = {}) {
const compiled = await compiler
.transform(original, {
loader: "ts",
sourcefile: options.sourcefile ?? "../src/fixture.ts?v=7#original",
sourcefile: "../src/fixture.ts?v=7#original",
sourcemap: "external",
minify: true,
format: "iife",
target: "es2022",
})
.finally(() => compiler.stop());
const prefixes = options.prefixes ?? [];
const generated =
prefixes.join("") +
compiled.code +
(options.sourceMapAnnotation
? "\n//# sourceMappingURL=/maps/app.js.map"
: "");
let indexed = JSON.parse(compiled.map);
for (const prefix of prefixes.toReversed()) {
const offset = textPosition(prefix, prefix.length);
indexed = {
version: 3,
sections: [
{
offset: { line: offset.line - 1, column: offset.column },
map: indexed,
},
],
};
}
const map = prefixes.length === 0 ? compiled.map : JSON.stringify(indexed);
const token = '"source-map proof"';
const generatedOffset = generated.indexOf(token);
const generatedOffset = compiled.code.indexOf(token);
const originalOffset = original.indexOf(token);
if (generatedOffset < 0 || originalOffset < 0)
throw new Error("Compiler fixture marker missing");
const position = textPosition(generated, generatedOffset);
const position = textPosition(compiled.code, generatedOffset);
const originalPosition = textPosition(original, originalOffset);
let mapRequests = 0;
const server = createServer((request, response) => {
@@ -63,12 +42,12 @@ export async function startBrowserSourceMapSite(options = {}) {
if (request.url === "/app.js?build=7")
return response
.writeHead(200, { "Content-Type": "text/javascript" })
.end(generated);
.end(compiled.code);
if (request.url?.startsWith("/maps/app.js.map")) {
mapRequests += 1;
return response
.writeHead(200, { "Content-Type": "application/json" })
.end(map);
.end(compiled.map);
}
response.writeHead(404).end("Unselected");
});
@@ -79,10 +58,8 @@ export async function startBrowserSourceMapSite(options = {}) {
return {
origin: `http://127.0.0.1:${address.port}`,
original,
generated,
map,
leafCode: compiled.code,
leafMap: compiled.map,
generated: compiled.code,
map: compiled.map,
position,
originalPosition,
originalOffset,
+3 -3
View File
@@ -17,9 +17,9 @@ for (const argument of arguments_)
throw new Error(`Unknown MCP tool catalog option: ${argument}`);
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
// This build-generated source supports schema and provider conformance tests;
// production MCP tools are registered from their canonical contracts directly.
// Keep the test metadata portable and regenerate it with `npm run build`.
// The build-generated catalog is a portable package artifact that is not
// committed to git. Keep its canonical provider metadata independent of the
// machine running the generator. It is regenerated by `npm run build`.
const CATALOG_PLATFORM = "darwin";
const sessionToolNames = new Set([
...TOOL_CONTRACTS.filter(({ kind }) => kind === "session").map(
+13 -20
View File
@@ -14,23 +14,13 @@ const environment = () => ({
});
/** Execute the compiled CLI with production providers and no test adapters. */
export async function artifactCli(
command,
target,
arguments_ = [],
options = {},
) {
return (await artifactCliEvidence(command, target, arguments_, options))
export async function artifactCli(command, target, arguments_ = []) {
return (await artifactCliEvidence(command, target, arguments_))
.normalized_result;
}
/** Execute the compiled CLI and return its complete Evidence record. */
export async function artifactCliEvidence(
command,
target,
arguments_ = [],
{ timeoutMs = 60000 } = {},
) {
export async function artifactCliEvidence(command, target, arguments_ = []) {
const { stdout } = await promisify(execFile)(
process.execPath,
[
@@ -40,7 +30,7 @@ export async function artifactCliEvidence(
...arguments_,
"--json",
],
{ env: environment(), timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 },
{ env: environment(), timeout: 60000, maxBuffer: 16 * 1024 * 1024 },
);
const evidence = JSON.parse(stdout);
assert.equal(evidence.error, undefined, stdout);
@@ -48,7 +38,7 @@ export async function artifactCliEvidence(
return evidence;
}
/** Run a real stdio MCP subprocess, owning and closing its transport. */
/** Run a real stdio MCP subprocess, owning and closing its transport; open `target` unless it is null. */
export async function withArtifactMcp(target, verify) {
const transport = new StdioClientTransport({
command: process.execPath,
@@ -59,11 +49,14 @@ export async function withArtifactMcp(target, verify) {
const client = new Client({ name: "artifact-real-e2e", version: "1" });
try {
await client.connect(transport);
const opened = await client.callTool({
name: "open_binary",
arguments: { path: target },
});
assert.notEqual(opened.isError, true, JSON.stringify(opened));
// Target-free tools pass `null` and need no open session.
if (target !== null) {
const opened = await client.callTool({
name: "open_binary",
arguments: { path: target },
});
assert.notEqual(opened.isError, true, JSON.stringify(opened));
}
await verify(client);
} finally {
await client.close();
@@ -1,238 +0,0 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { TraceMap, eachMapping } from "@jridgewell/trace-mapping";
import { CdpBrowserProvider } from "../../dist/browser/CdpBrowserProvider.js";
import { CdpConnection } from "../../dist/browser/CdpConnection.js";
import { waitForBrowserDevtoolsPort } from "../../dist/browser/BrowserProcessStartup.js";
import { parseEvidence } from "../../dist/domain/evidence.js";
import { traceSourceMap } from "../../dist/javascript/sourceMaps/TraceSourceMap.js";
import { startBrowserSourceMapSite } from "../fixtures/browser-source-map-site.mjs";
import {
startRuntimeFixtureBrowser,
closeRuntimeFixtureResources,
} from "./browser-runtime-fixture-lifecycle.mjs";
const cases = [
{ name: "same-line nested column control", prefixes: ["\n\n ", " "] },
{ name: "zero outer column control", prefixes: ["\n\n", "\n "] },
{ name: "nested line resets outer column", prefixes: ["\n\n ", "\n"] },
{
name: "nested line retains its own column",
prefixes: ["\n\n ", "\n "],
},
];
/** Verify compiler-produced nested maps through an actual owned Chrome and public CLI. */
export async function verifyBrowserNestedSourceMaps(
executable,
entrypoint = fileURLToPath(new URL("../rea.mjs", import.meta.url)),
) {
const results = [];
for (const scenario of cases) {
try {
const proof = await verifyCase(executable, entrypoint, scenario);
results.push({ name: scenario.name, status: "pass", ...proof });
} catch (cause) {
results.push({
name: scenario.name,
status: "fail",
phase: cause.qualificationPhase ?? "native-producer-admission",
proof: cause.qualificationReceipt ?? null,
error: String(cause),
});
}
}
// Preserve every affected owner and control even when BEFORE fails.
process.stdout.write(
`${JSON.stringify({ nested_source_map_cases: results })}\n`,
);
assert.equal(results.filter(({ status }) => status === "fail").length, 0);
return { cases: results, actual_chrome: true, public_cli: true };
}
async function verifyCase(executable, entrypoint, scenario) {
const site = await startBrowserSourceMapSite({
prefixes: scenario.prefixes,
sourceMapAnnotation: true,
sourcefile: "../src/nested-fixture.ts",
});
let fixture;
let connection;
let primaryError;
let stderr = "";
let phase = "native-map-admission";
let browserVersion;
let expected;
let observed;
try {
const mapUrl = `${site.origin}/maps/app.js.map`;
// This independent existing decoder already composes valid nested offsets.
// It is a before-product admission control, not the expected browser oracle.
const point = traceSourceMap(site.map, mapUrl, site.position);
assert.equal(point.matches.length, 1);
assert.equal(
point.matches[0].original_position.line,
site.originalPosition.line,
);
assert.equal(
point.matches[0].original_position.column,
site.originalPosition.column,
);
expected = compilerMappingsAtPhysicalPositions(site, mapUrl);
assert.ok(
expected.length > 0,
"Native compiler leaf must carry point mappings",
);
phase = "owned-browser-startup";
fixture = await startRuntimeFixtureBrowser(executable, site.origin);
fixture.browser.stderr.on("data", (chunk) => {
if (stderr.length < 65536) stderr += chunk;
});
const port = await waitForBrowserDevtoolsPort({
child: fixture.browser,
executable,
activePortPath: join(fixture.profile, "DevToolsActivePort"),
stderr: () => stderr,
timeoutMs: 20_000,
});
const endpoint = `http://127.0.0.1:${port}`;
const provider = new CdpBrowserProvider();
const targets = await provider.listTargets({
cdp_endpoint: endpoint,
allowed_origins: [site.origin],
});
if (!targets.ok) throw targets.error;
const target = targets.value.targets[0];
assert.ok(target, "Owned fixture target missing");
const pages = await (await fetch(`${endpoint}/json/list`)).json();
const page = pages.find(({ id }) => id === target.target_id);
assert.ok(page, "Owned CDP fixture page missing");
connection = await CdpConnection.connect(
page.webSocketDebuggerUrl,
"inspect_web_page",
);
await ready(connection);
browserVersion = await connection.send("Browser.getVersion");
phase = "public-cli";
const { stdout } = await promisify(execFile)(
process.execPath,
[
entrypoint,
"analyze-web-bundle",
endpoint,
target.target_id,
"--allowed-origins",
site.origin,
"--fetch-source-maps",
"--observation-ms",
"0",
"--json",
],
{
env: { ...process.env, REA_LOG_LEVEL: "silent" },
timeout: 45_000,
maxBuffer: 16 * 1024 * 1024,
},
);
const evidence = parseEvidence(JSON.parse(stdout));
assert.equal(evidence.operation, "analyze_web_bundle");
const maps = evidence.normalized_result.observations.source_maps;
const item = maps.items.find(({ artifact }) => artifact?.text === site.map);
assert.ok(
item,
"Public browser analysis omitted the exact compiler-derived map",
);
phase = "public-map-assertions";
observed = item?.mappings;
assert.equal(item.status, "included");
assert.deepEqual(item.mappings, expected);
assert.equal(item.artifact.sha256, sha(site.map));
assert.equal(item.artifact.bytes, Buffer.byteLength(site.map));
assert.equal(item.original_sources[0].artifact.sha256, sha(site.original));
assert.ok(
site.mapRequests() > 0,
"Actual browser workflow did not fetch the declared map",
);
return {
compiler: site.compiler,
browser: browserVersion.product,
generated_sha256: sha(site.generated),
source_map_sha256: sha(site.map),
original_sha256: sha(site.original),
mappings: expected.length,
native_decoder_control: true,
};
} catch (cause) {
const failure = new Error(`Nested source-map ${phase}: ${String(cause)}`, {
cause,
});
failure.qualificationPhase = phase;
failure.qualificationReceipt = {
compiler: site.compiler,
browser: browserVersion?.product ?? null,
generated_sha256: sha(site.generated),
leaf_map_sha256: sha(site.leafMap),
source_map_sha256: sha(site.map),
original_sha256: sha(site.original),
expected_first_mapping: expected?.[0] ?? null,
observed_first_mapping: observed?.[0] ?? null,
};
primaryError = failure;
throw failure;
} finally {
await closeRuntimeFixtureResources(
[() => connection?.close(), () => fixture?.close(), () => site.close()],
primaryError,
);
}
}
function compilerMappingsAtPhysicalPositions(site, mapUrl) {
const expected = [];
const prefixLength = site.generated.indexOf(site.leafCode);
assert.ok(prefixLength >= 0);
eachMapping(new TraceMap(site.leafMap, mapUrl), (mapping) => {
if (
mapping.source === null ||
mapping.originalLine === null ||
mapping.originalColumn === null
)
return;
const leafLines = site.leafCode.split("\n");
const before = leafLines.slice(0, mapping.generatedLine - 1).join("\n");
const leafOffset =
before.length +
(mapping.generatedLine > 1 ? 1 : 0) +
mapping.generatedColumn;
const physical = site.generated
.slice(0, prefixLength + leafOffset)
.split("\n");
expected.push({
generated_line: physical.length,
generated_column: physical.at(-1).length,
source: mapping.source,
original_line: mapping.originalLine,
original_column: mapping.originalColumn,
name: mapping.name ?? null,
});
});
return expected;
}
async function ready(connection) {
for (let attempt = 0; attempt < 100; attempt += 1) {
const result = await connection.send("Runtime.evaluate", {
expression: 'document.body.dataset.ready === "source-map proof"',
returnByValue: true,
});
if (result.result.value === true) return;
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error("Owned compiler fixture did not execute in actual Chrome");
}
const sha = (text) => createHash("sha256").update(text).digest("hex");
+5 -2
View File
@@ -243,7 +243,8 @@ export const providerCatalog = (sources) => {
contracts: sources.applicationContracts.APPLICATION_TOOL_CONTRACTS.filter(
({ name }) =>
name !== "project_android_application_graph" &&
name !== "project_apple_application_graph",
name !== "project_apple_application_graph" &&
name !== "inspect_dyld_shared_cache",
),
},
{
@@ -255,7 +256,9 @@ export const providerCatalog = (sources) => {
{
identity: sources.artifactProviders.APPLE_APPLICATION_PROVIDER,
contracts: sources.applicationContracts.APPLICATION_TOOL_CONTRACTS.filter(
({ name }) => name === "project_apple_application_graph",
({ name }) =>
name === "project_apple_application_graph" ||
name === "inspect_dyld_shared_cache",
),
},
{
+29 -24
View File
@@ -8,14 +8,21 @@ const README_PATHS = [
"README_ko.md",
"README_ar.md",
];
const readmeLinkTargets = (content) =>
[...content.matchAll(/\]\(([^)\s]+)\)/gu)]
.map((match) => match[1] ?? "")
.filter(
(target) =>
!target.startsWith("#") && !/^README(?:_[a-z]+)?\.md$/u.test(target),
)
.sort();
const tableCounts = (content, path, expectedCounts) => {
const lines = content.split(/\r?\n/u);
for (const [index, line] of lines.entries()) {
if (!/^\|\s*-/u.test(line)) continue;
const counts = [];
for (const row of lines.slice(index + 1)) {
if (!row.trim().startsWith("|")) break;
const count = Number(row.split("|")[2]?.trim());
if (!Number.isInteger(count)) break;
counts.push(count);
}
if (counts.length === expectedCounts.length) return counts;
}
throw new Error(`Missing tool-family inventory table in ${path}`);
};
const requireText = (issues, path, content, expected) => {
if (!content.includes(expected)) issues.push(`${path}: missing ${expected}`);
@@ -64,34 +71,32 @@ export const documentationFactIssues = async (root, catalog) => {
const issues = await skillReferenceIssues(
join(root, "skills/reverse-engineer-anything"),
);
const readmes = new Map();
const expectedCounts = catalog.tools.families.map(({ count }) => count);
for (const path of README_PATHS) {
const content = await readFile(join(root, path), "utf8");
readmes.set(path, content);
requireText(issues, path, content, "MCP-tool_catalog");
if (path === "README.md") {
requireText(
issues,
path,
content,
"(docs/installation.md#supported-agents)",
);
requireText(issues, path, content, "(docs/installation.md#mcp-registry)");
requireText(issues, path, content, "(docs/installation.md");
requireText(
issues,
path,
content,
"(docs/mcp-contracts.md#generated-catalog)",
);
continue;
}
for (const client of catalog.setup_clients)
requireText(issues, path, content, client.display_name);
try {
const actualCounts = tableCounts(content, path, expectedCounts);
if (JSON.stringify(actualCounts) !== JSON.stringify(expectedCounts))
issues.push(
`${path}: tool family counts ${JSON.stringify(actualCounts)} do not match ${JSON.stringify(expectedCounts)}`,
);
} catch (cause) {
issues.push(cause instanceof Error ? cause.message : String(cause));
}
}
const canonicalLinks = readmeLinkTargets(readmes.get("README.md") ?? "");
for (const path of README_PATHS.slice(1))
if (
JSON.stringify(readmeLinkTargets(readmes.get(path) ?? "")) !==
JSON.stringify(canonicalLinks)
)
issues.push(`${path}: documentation links differ from README.md`);
const installationPath = "docs/installation.md";
const installation = await readFile(join(root, installationPath), "utf8");
@@ -1,184 +0,0 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import { writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { parseBinaryTarget } from "../../dist/application/BinaryTargetResolver.js";
import { createAnalysisProfile } from "../../dist/domain/analysisProfile.js";
import {
createAnalysisSnapshotEntry,
parseAnalysisSnapshot,
serializeAnalysisSnapshot,
snapshotBinding,
snapshotTarget,
} from "../../dist/domain/analysisSnapshot.js";
import { createEvidence } from "../../dist/domain/evidence.js";
import { createEvidenceBundle } from "../../dist/domain/evidenceBundle.js";
import { requireMcpResult } from "./mcp-verifier-results.mjs";
/** Reject a valid older snapshot whose instruction-only collector omitted a real edge. */
export async function verifyLegacyGhidraReferenceSnapshot(
client,
{ target, procedure, omittedEdge, entrypoint, env },
) {
const bundle = requireMcpResult(
await client.callTool({
name: "get_evidence_bundle",
arguments: {},
}),
"get_evidence_bundle",
);
const observed = bundle.records.find(
(record) =>
record.operation === "analyze_function" &&
record.parameters.procedure === procedure,
);
assert.ok(observed);
const { function_references: coverage, ...legacyParameters } =
observed.analysis_profile.parameters;
assert.equal(
typeof coverage,
"string",
"Reference semantics must participate in snapshot identity",
);
const legacyProfile = createAnalysisProfile(
observed.provider,
legacyParameters,
);
assert.notEqual(legacyProfile.digest, observed.analysis_profile.digest);
const parsed = await parseBinaryTarget(
target.path,
process.cwd(),
process.arch,
);
if (!parsed.ok) throw parsed.error;
const nativeTarget = parsed.value;
const result = structuredClone(observed.normalized_result);
// Reconstruct the observed pre-fix omission; all other data
// comes from the real provider and the file has a fully valid Evidence binding.
result.incoming_references = result.incoming_references.filter(
(edge) =>
edge.source_address !== omittedEdge.source_address ||
edge.target_address !== omittedEdge.target_address,
);
assert.equal(
result.incoming_references.length,
observed.normalized_result.incoming_references.length - 1,
);
const evidence = createEvidence(nativeTarget, observed.provider, {
operation: observed.operation,
parameters: observed.parameters,
result,
rawResult: result,
analysisProfile: legacyProfile,
limitations: observed.limitations,
locations: observed.locations,
});
const binding = snapshotBinding(legacyProfile);
const identity = snapshotTarget(nativeTarget);
const snapshot = parseAnalysisSnapshot({
target: identity,
binding,
entries: [
createAnalysisSnapshotEntry({
target: identity,
binding,
operation: observed.operation,
parameters: observed.parameters,
execution: {
result,
rawResult: result,
provider: observed.provider,
analysisProfile: legacyProfile,
limitations: observed.limitations,
locations: observed.locations,
subject: nativeTarget,
},
}),
],
evidence_bundle: createEvidenceBundle([evidence]),
});
assert.equal(
snapshot.entries.length,
1,
"The legacy query must remain valid and replayable under its own profile",
);
const path = join(env.TMPDIR, `legacy-reference-${randomUUID()}.json`);
await writeFile(path, serializeAnalysisSnapshot(snapshot), { flag: "wx" });
try {
const session = requireMcpResult(
await client.callTool({ name: "binary_session", arguments: {} }),
"binary_session",
);
const reply = await client.callTool({
name: "open_binary",
arguments: {
path: target.path,
provider_id: "ghidra",
snapshot_path: path,
},
});
assert.equal(reply.isError, true);
const mcpRejection = reply.structuredContent.error;
assert.equal(mcpRejection.code, "evidence_integrity_mismatch");
assert.match(mcpRejection.details.reason, /profile_mismatch/u);
assert.match(
mcpRejection.remediation.action,
/without this snapshot.*fresh snapshot/u,
);
assert.deepEqual(
requireMcpResult(
await client.callTool({ name: "binary_session", arguments: {} }),
"binary_session",
),
session,
"A rejected snapshot must preserve the selected session",
);
const retained = requireMcpResult(
await client.callTool({
name: "analyze_function",
arguments: { procedure },
}),
"analyze_function",
);
assert.ok(
retained.incoming_references.some(
(edge) =>
edge.source_address === omittedEdge.source_address &&
edge.target_address === omittedEdge.target_address,
),
"A rejected legacy snapshot must not replace complete live analysis",
);
await assert.rejects(
promisify(execFile)(
process.execPath,
[
entrypoint,
"function",
target.path,
procedure,
"--provider",
"ghidra",
"--snapshot",
path,
"--json",
],
{ env, timeout: 240000, maxBuffer: 72 * 1024 * 1024 },
),
(error) => {
assert.equal(error.code, 1);
const rejection = JSON.parse(error.stdout);
assert.equal(rejection.code, "evidence_integrity_mismatch");
assert.equal(rejection.details.reason, mcpRejection.details.reason);
assert.equal(
rejection.remediation.action,
mcpRejection.remediation.action,
);
return true;
},
);
} finally {
await rm(path);
}
}
+8 -51
View File
@@ -1,8 +1,5 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, readdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { Client } from "@modelcontextprotocol/client";
@@ -11,7 +8,6 @@ import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { parseEvidence } from "../../dist/domain/evidence.js";
import { analysisProfileSchema } from "../../dist/domain/analysisProfile.js";
import { nativeValueTraceSchema } from "../../dist/domain/native/nativeValueTrace.js";
import { verifyGhidraBoundaries } from "./real-ghidra-boundaries.mjs";
const defaultEntrypoint = fileURLToPath(new URL("../rea.mjs", import.meta.url));
const environment = () => ({
@@ -76,33 +72,6 @@ export async function verifyNativeValueE2e(
procedure,
globalAddress,
{ entrypoint = defaultEntrypoint } = {},
) {
const workspace = await mkdtemp(join(tmpdir(), "rea-native-values-e2e-"));
const runtime = join(
workspace,
`runtime${process.platform === "darwin" ? " with spaces" : ""}-${"x".repeat(100)}`,
);
await mkdir(runtime);
const env = { ...environment(), TMPDIR: runtime };
try {
return await verifyNativeValueAdapters(
target,
procedure,
globalAddress,
entrypoint,
env,
);
} finally {
await rm(workspace, { recursive: true, force: true });
}
}
async function verifyNativeValueAdapters(
target,
procedure,
globalAddress,
entrypoint,
env,
) {
const { stdout } = await promisify(execFile)(
process.execPath,
@@ -121,7 +90,7 @@ async function verifyNativeValueAdapters(
"5000",
"--json",
],
{ env, timeout: 240000, maxBuffer: 72 * 1024 * 1024 },
{ env: environment(), timeout: 240000, maxBuffer: 72 * 1024 * 1024 },
);
const evidence = parseEvidence(JSON.parse(stdout));
assert.equal(evidence.operation, "trace_native_values");
@@ -145,17 +114,16 @@ async function verifyNativeValueAdapters(
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env,
env: environment(),
stderr: "pipe",
});
const client = new Client({ name: "native-values-real-e2e", version: "1" });
let boundaries;
try {
await client.connect(transport);
const opened = await client.callTool(
{
name: "open_binary",
arguments: { path: target.path, provider_id: "ghidra" },
arguments: { path: target.path, provider: "ghidra" },
},
{ timeout: 180000 },
);
@@ -200,11 +168,14 @@ async function verifyNativeValueAdapters(
analyzed.structuredContent?.evidence,
);
assert.equal(functionEvidence.operation, "analyze_function");
assert.equal(functionEvidence.provider.id, "ghidra");
assert.equal(functionEvidence.provider.id, "rea-workflow");
const functionProfile = analysisProfileSchema.parse(
functionEvidence.analysis_profile,
);
assert.deepEqual(functionProfile, upstreamProfile);
assert.deepEqual(
functionProfile.parameters.upstream_analysis_profile,
upstreamProfile,
);
assert.deepEqual(
functionEvidence.normalized_result,
analyzed.structuredContent?.result,
@@ -248,12 +219,6 @@ async function verifyNativeValueAdapters(
evidence,
);
}
boundaries = await verifyGhidraBoundaries(client, {
target,
procedure,
entrypoint,
env,
});
const closed = await client.callTool({
name: "close_binary",
arguments: {},
@@ -264,13 +229,6 @@ async function verifyNativeValueAdapters(
arguments: {},
});
assert.equal(status.structuredContent?.result?.open, false);
assert.deepEqual(
(await readdir(env.TMPDIR)).filter((name) =>
name.startsWith("rea-ghidra-"),
),
[],
"Ghidra left runtime files after close",
);
} finally {
await client.close();
await transport.close();
@@ -281,7 +239,6 @@ async function verifyNativeValueAdapters(
stdio_mcp: true,
inline_evidence: true,
direct_function_comparison: true,
boundaries,
nodes: cliGraph.total_nodes,
edges: cliGraph.total_edges,
decompilations: cliGraph.decompilations,
@@ -1,85 +0,0 @@
import assert from "node:assert/strict";
/** Exercise segmented-address truncation through real CLI/MCP reads and edits. */
export async function verifyGhidraAddressBoundaries(client, entry, cli) {
const query = async (name, args) => {
const reply = await client.callTool(
{ name, arguments: args },
{ timeout: 240000 },
);
assert.notEqual(reply.isError, true, JSON.stringify(reply));
return reply.structuredContent.result;
};
const before = await query("analyze_function", { procedure: entry });
// The segmented parser used to discard the high 32 bits and alias this entry.
const overflow = (0x100000000n + BigInt(entry)).toString(16);
const representations = [
`0x${overflow}`,
`0X${overflow}`,
overflow,
`ram:0x${overflow}`,
`%72am:0x${overflow}`,
];
for (const address of representations) {
for (const [name, args] of [
["read_bytes", { address, length: 1 }],
["inspect_native_instruction", { address }],
["resolve_containing_procedure", { address }],
["procedure_info", { procedure: address }],
[
"annotate_native_function",
{
procedure: address,
name: "MUST_NOT_ALIAS",
comment: "MUST NOT WRITE",
},
],
]) {
const reply = await client.callTool(
{ name, arguments: args },
{ timeout: 240000 },
);
assert.equal(reply.isError, true, `${name} accepted ${address}`);
const error = reply.structuredContent?.error;
assert.equal(error?.code, "invalid_request", JSON.stringify(reply));
assert.equal(error.details.operation, name);
const issues = JSON.stringify(error.details.issues);
assert.match(issues, /cannot be represented without truncation/u);
assert.ok(issues.includes(address), issues);
assert.equal(reply.structuredContent.evidence, undefined);
}
}
assert.deepEqual(
await query("analyze_function", { procedure: entry }),
before,
"Rejected oversized annotations changed the aliased function",
);
// Leading zeros and an encoded explicit space preserve the requested offset.
const padded = `%72am:0x0000${BigInt(entry).toString(16)}`;
assert.deepEqual(
await query("read_bytes", { address: padded, length: 1 }),
await query("read_bytes", { address: entry, length: 1 }),
);
for (const [command, flags, operation] of [
["read-bytes", ["--length", "1"], "read_bytes"],
[
"annotate-native-function",
["--name", "MUST_NOT_ALIAS"],
"annotate_native_function",
],
]) {
let failure;
try {
await cli(command, representations[0], flags);
} catch (error) {
assert.equal(error.code, 1);
failure = JSON.parse(error.stdout);
}
assert.equal(failure?.code, "invalid_request");
assert.equal(failure.details.operation, operation);
assert.match(
JSON.stringify(failure.details.issues),
/cannot be represented without truncation/u,
);
}
}
-838
View File
@@ -1,838 +0,0 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import { access, readFile, readdir, stat } from "node:fs/promises";
import { dirname, join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { promisify } from "node:util";
import Ajv from "ajv/dist/2020.js";
import addFormats from "ajv-formats";
import { mcpTextValue, requireMcpResult } from "./mcp-verifier-results.mjs";
import { verifyLegacyGhidraReferenceSnapshot } from "./ghidra-reference-snapshot-e2e.mjs";
import { verifyGhidraSnapshotLifecycle } from "./real-ghidra-snapshot-lifecycle.mjs";
import { verifyGhidraTargetAdmission } from "./real-ghidra-target-admission.mjs";
import { verifyGhidraNamespaceAnnotations } from "./real-ghidra-namespace-annotations.mjs";
/** Probe real Ghidra location, annotation and error contracts through public adapters. */
export async function verifyGhidraBoundaries(
client,
{ target, procedure, entrypoint, env },
) {
const options = { timeout: 180000 };
const catalog = await client.listTools();
const ajv = new Ajv({ strict: false });
addFormats(ajv);
const validators = new Map();
for (const tool of catalog.tools) {
for (const key of ["inputSchema", "outputSchema"])
assert.ok(
ajv.validateSchema(tool[key]),
`${tool.name} ${key}: ${JSON.stringify(ajv.errors)}`,
);
validators.set(tool.name, ajv.compile(tool.outputSchema));
}
let successfulCalls = 0;
let rejectedCalls = 0;
const call = async (name, args = {}) => {
const reply = await client.callTool({ name, arguments: args }, options);
const result = requireMcpResult(reply, name);
const validate = validators.get(name);
assert.ok(validate, `${name} missing from catalog`);
assert.ok(
validate(reply.structuredContent),
`${name}: ${JSON.stringify(validate.errors)}`,
);
assert.deepEqual(reply.structuredContent, JSON.parse(mcpTextValue(reply)));
successfulCalls++;
return result;
};
const invalid = async (name, args, diagnostic) => {
const reply = await client.callTool({ name, arguments: args }, options);
assert.equal(
reply.isError,
true,
`${name} accepted ${JSON.stringify(args)}`,
);
if (diagnostic !== undefined) {
const error = reply.structuredContent?.error;
assert.equal(error?.code, "invalid_request");
assert.equal(error?.details?.operation, name);
assert.match(JSON.stringify(error.details.issues), diagnostic);
}
rejectedCalls++;
return reply.structuredContent?.error;
};
const cli = async (command, value, flags = []) => {
const { stdout } = await promisify(execFile)(
process.execPath,
[
entrypoint,
command,
target.path,
value,
"--provider",
"ghidra",
"--json",
...flags,
],
{ env, timeout: 240000, maxBuffer: 72 * 1024 * 1024 },
);
return JSON.parse(stdout).normalized_result;
};
const original = await call("analyze_function", { procedure });
const roots = (await readdir(env.TMPDIR)).filter((name) =>
name.startsWith("rea-ghidra-"),
);
assert.equal(roots.length, 1, "Expected one owned Ghidra runtime");
const runtimeRoot = join(env.TMPDIR, roots[0]);
const { endpoint_path: socketPath } = JSON.parse(
await readFile(join(runtimeRoot, "ownership.json"), "utf8"),
);
const socketRoot = dirname(socketPath);
assert.ok(
Buffer.byteLength(join(runtimeRoot, "bridge.sock"), "utf8") > 107,
"Fixture must exceed Unix socket path capacity",
);
assert.ok(Buffer.byteLength(socketPath, "utf8") <= 103);
assert.notEqual(socketRoot, runtimeRoot);
assert.equal((await stat(socketRoot)).mode & 0o777, 0o700);
const address = original.procedure.address;
const name = original.procedure.name;
const procedures = await call("list_procedures");
const external = procedures.find((item) => item.procedure.external);
assert.ok(external, "Source fixture lacks an external function");
const externalDossier = await call("analyze_function", {
procedure: external.address,
});
const externalEntry = await call("resolve_containing_procedure", {
address: external.address,
});
assert.equal(externalEntry.found, true);
assert.equal(externalEntry.procedure.address, external.address);
assert.equal(externalEntry.procedure.classification.external, true);
assert.deepEqual(
externalEntry.procedure.body,
externalDossier.procedure.body,
);
assert.equal(externalEntry.procedure.body.contains_entry, false);
assert.deepEqual(externalEntry.procedure.body.ranges, []);
const externalSpace = external.address.slice(
0,
external.address.indexOf(":"),
);
const lastExternalOffset = procedures
.filter((item) => item.procedure.external)
.map((item) => BigInt(item.address.slice(item.address.indexOf(":") + 1)))
.reduce((maximum, offset) => (offset > maximum ? offset : maximum), 0n);
const unknownExternal = `${externalSpace}:0x${(lastExternalOffset + 1n).toString(16)}`;
assert.deepEqual(
await call("resolve_containing_procedure", { address: unknownExternal }),
{
query_address: unknownExternal,
found: false,
procedure: null,
reason: "outside_segments",
},
);
assert.deepEqual(
(await cli("function", external.address)).procedure,
externalDossier.procedure,
"CLI and MCP must preserve external identity without inventing body bytes",
);
await invalid(
"annotate_native_function",
{ procedure: external.address, name: "rea_external_edit" },
/Annotations require a local function entry/u,
);
const names = await call("list_names");
const leaf = names.find((item) =>
item.value.endsWith("rea_ghidra_inventory_leaf"),
);
const pointer = names.find((item) =>
item.value.endsWith("rea_ghidra_interior_pointer"),
);
assert.ok(
leaf && pointer,
"Source fixture lacks the interior function pointer",
);
const interiorTarget = `0x${(BigInt(leaf.address) + 1n).toString(16)}`;
assert.ok(
(await call("xrefs", { address: interiorTarget })).includes(
pointer.address,
),
);
const incoming = await call("procedure_references", {
procedure: leaf.address,
direction: "incoming",
});
const interiorEdge = incoming.references.find(
(edge) =>
edge.source_address === pointer.address &&
edge.target_address === interiorTarget,
);
assert.ok(
interiorEdge,
"Procedure references omitted an observed interior-body edge",
);
assert.equal(interiorEdge.target_procedure?.address, leaf.address);
assert.equal(interiorEdge.source_procedure, null);
assert.equal(interiorEdge.kind.data, true);
const leafDossier = await call("analyze_function", {
procedure: leaf.address,
});
assert.ok(
leafDossier.incoming_references.some(
(edge) =>
edge.source_address === pointer.address &&
edge.target_address === interiorTarget,
),
);
assert.deepEqual(
(await cli("function", leaf.value)).incoming_references,
leafDossier.incoming_references,
"CLI and MCP must retain the same interior-body references",
);
await verifyLegacyGhidraReferenceSnapshot(client, {
target,
procedure: leaf.address,
omittedEdge: interiorEdge,
entrypoint,
env,
});
const baseline = await call("inspect_native_instruction", { address });
assert.equal(baseline.status, "decoded");
const bytes = await call("read_bytes", { address, length: baseline.length });
assert.equal(bytes.bytes_hex, baseline.bytes);
assert.equal((await call("read_bytes", { address })).requested_bytes, 256);
const mapped = await call("address_to_file_offset", { address });
assert.equal(mapped.address, address);
assert.equal(
(await readFile(target.path))
.subarray(mapped.file_offset, mapped.file_offset + baseline.length)
.toString("hex"),
bytes.bytes_hex,
"Memory-to-file mapping must identify the bytes in the caller's artifact",
);
const largeRead = await call("read_bytes", {
address,
length: Number.MAX_SAFE_INTEGER,
});
assert.equal(largeRead.requested_bytes, Number.MAX_SAFE_INTEGER);
assert.equal(largeRead.complete, false);
assert.ok(largeRead.returned_bytes >= baseline.length);
assert.equal(largeRead.bytes_hex.length, largeRead.returned_bytes * 2);
for (const spelling of [
address.toUpperCase(),
address.slice(2).toUpperCase(),
`ram:${address.toUpperCase()}`,
]) {
assert.deepEqual(
await call("inspect_native_instruction", { address: spelling }),
baseline,
);
assert.deepEqual(
await call("read_bytes", { address: spelling, length: baseline.length }),
bytes,
);
assert.equal(await call("address_name", { address: spelling }), name);
assert.equal(
await call("procedure_address", { procedure: spelling }),
address,
);
assert.equal(
(await call("resolve_containing_procedure", { address: spelling }))
.procedure.address,
address,
);
}
assert.deepEqual(
await cli("inspect-native-instruction", address.toUpperCase()),
baseline,
);
const interior = `0x${(BigInt(address) + 1n).toString(16)}`;
assert.equal(
(await call("inspect_native_instruction", { address: interior })).status,
"not-instruction-boundary",
);
assert.equal(
(await call("inspect_native_instruction", { address: "0x0" })).status,
"outside-memory",
);
assert.equal(
(await call("resolve_native_call_targets", { address })).status,
"not-call",
);
const directEdge = original.outgoing_references.find(
(edge) => edge.kind.call && !edge.kind.computed,
);
assert.ok(directEdge, "Source fixture lacks a direct call");
const direct = await call("resolve_native_call_targets", {
address: directEdge.source_address,
});
assert.equal(direct.status, "direct");
assert.ok(
direct.targets.some(
(candidate) =>
candidate.address === directEdge.target_address &&
candidate.status === "direct",
),
);
const indirectProcedure = (await call("list_procedures")).find((item) =>
item.value.endsWith("rea_ghidra_inventory_indirect"),
);
assert.ok(indirectProcedure);
const references = await call("procedure_references", {
procedure: indirectProcedure.address,
});
assert.ok(
references.unresolved_calls.length > 0,
"Targetless calls must remain visible",
);
for (const site of references.unresolved_calls) {
const unresolved = await call("resolve_native_call_targets", {
address: site.address,
});
assert.equal(unresolved.status, "unresolved");
assert.deepEqual(unresolved.targets, []);
assert.ok(unresolved.limitations.length > 0);
}
const strings = await call("list_strings");
assert.ok(strings.length > 0);
assert.deepEqual(
await call("list_strings", { address: strings[0].address }),
[strings[0]],
);
const marker = strings.find(
(item) => item.value === "REA_GHIDRA_INVENTORY_ENTRY",
);
assert.ok(marker);
assert.ok(
(
await call("search_strings", { pattern: marker.value.toLowerCase() })
).some((item) => item.address === marker.address),
);
assert.equal(
(
await call("search_strings", {
pattern: marker.value.toLowerCase(),
case_sensitive: true,
})
).some((item) => item.address === marker.address),
false,
);
assert.equal(
(await call("inspect_native_instruction", { address: strings[0].address }))
.status,
"data",
);
await invalid(
"inspect_native_instruction",
{ address: "not-an-address" },
/hexadecimal/u,
);
await invalid(
"inspect_native_instruction",
{ address: `missing:${address}` },
/missing:0x/u,
);
await invalid(
"address_name",
{ address: `0x${(BigInt(address) + 2n ** 64n).toString(16)}` },
/invalid Ghidra address/u,
);
await invalid(
"procedure_address",
{ procedure: "rea_missing_procedure" },
/rea_missing_procedure/u,
);
await invalid(
"search_procedures",
{ pattern: "[", mode: "regex" },
/Invalid regex pattern.*index/u,
);
await invalid(
"list_procedures",
{ document: "rea_missing_program" },
/rea_missing_program.*active program/u,
);
// JSON permits escaped lone surrogates. Error replies must preserve them
// without letting the Java UTF-8 writer terminate the provider session.
await invalid(
"procedure_address",
{ procedure: "missing_\ud800" },
/missing_\\ud800/u,
);
await invalid(
"list_procedures",
{ document: "missing_\udfff" },
/missing_\\udfff/u,
);
for (const [operation, args] of [
["read_bytes", { address, length: 0 }],
["list_procedures", { limit: 100 }],
["list_documents", { extra: true }],
["xrefs", {}],
["procedure_info", { procedure: address, extra: true }],
])
await invalid(operation, args);
for (const changes of [
{},
{ name: "" },
{ comment: null },
{ name: "entry", approve: true },
])
await invalid("annotate_native_function", {
procedure: address,
...changes,
});
let cliError;
try {
await cli("search", "[", ["--kind", "procedures", "--mode", "regex"]);
} catch (error) {
assert.equal(error.code, 1);
cliError = JSON.parse(error.stdout);
}
assert.equal(cliError?.code, "invalid_request");
assert.equal(cliError?.details?.operation, "search_procedures");
assert.match(
JSON.stringify(cliError?.details?.issues),
/Invalid regex pattern.*index/u,
);
// Prime both inventories before editing so stale names cannot hide in caches.
await call("list_names");
await call("list_procedures");
const changes = {
procedure: address,
name: "dead",
comment: "Regular\n註記",
inline_comment: "Inline\n註記",
};
const updated = await call("annotate_native_function", changes);
assert.deepEqual(updated.annotations, {
address,
name: changes.name,
comment: changes.comment,
inline_comment: changes.inline_comment,
});
const regexPattern = "^(a|aa)*b$";
const regexFailure = await client.callTool(
{
name: "search_strings",
arguments: { pattern: regexPattern, mode: "regex" },
},
options,
);
assert.equal(regexFailure.isError, true);
const regexError = regexFailure.structuredContent.error;
assert.equal(regexError.code, "resource_constraint");
assert.equal(regexError.details.operation, "search_strings");
assert.equal(regexError.details.resource, "memory");
assert.match(
regexError.remediation.action,
/literal mode or simplify the regex/u,
);
assert.match(
regexError.details.reason,
/exhausted its stack.*Use literal mode/u,
);
rejectedCalls++;
const nestedPattern = "(".repeat(12000) + "a" + ")".repeat(12000);
const compileFailure = await client.callTool(
{
name: "search_procedures",
arguments: { pattern: nestedPattern, mode: "regex" },
},
options,
);
assert.equal(compileFailure.isError, true);
const compileError = compileFailure.structuredContent.error;
assert.equal(compileError.code, "resource_constraint");
assert.equal(compileError.details.operation, "search_procedures");
assert.match(compileError.details.reason, /stack while compiling pattern/u);
assert.equal(compileError.remediation.action, regexError.remediation.action);
rejectedCalls++;
assert.deepEqual(
await call("analyze_function", { procedure: address }),
updated.dossier,
"Regex stack exhaustion must preserve the live annotation database",
);
assert.equal(
(await call("binary_session")).capabilities.find(
(item) => item.operation === "search_strings",
).available,
true,
);
assert.ok(
(await call("search_procedures", { pattern: changes.name })).some(
(item) => item.address === address,
),
"Regex compiler exhaustion must preserve edited names and search availability",
);
const longLiteral = "a".repeat(3 * 16 ** 3) + "!";
const longString = strings.find((item) => item.value === longLiteral);
assert.ok(longString, "The provider must retain the full regression literal");
const literalMatches = await call("search_strings", { pattern: longLiteral });
assert.deepEqual(literalMatches, [
{ address: longString.address, value: longLiteral },
]);
assert.deepEqual(await cli("search", longLiteral), literalMatches);
await assert.rejects(
cli("search", regexPattern, ["--mode", "regex"]),
(error) => {
assert.equal(error.code, 1);
const rejected = JSON.parse(error.stdout);
assert.equal(rejected.code, regexError.code);
assert.equal(rejected.details.reason, regexError.details.reason);
assert.equal(rejected.remediation.action, regexError.remediation.action);
return true;
},
);
assert.deepEqual(updated.effects, {
scope: "session-analysis-database",
source_bytes_modified: false,
persists_after_close: false,
});
assert.equal(updated.dossier.procedure.name, changes.name);
assert.equal(
await call("procedure_address", { procedure: changes.name }),
address,
);
assert.equal(
(await call("procedure_info", { procedure: changes.name })).entrypoint,
address,
);
assert.equal(
(await call("read_function_instructions", { procedure: changes.name }))
.procedure.address,
address,
);
assert.equal(
(await call("analyze_function", { procedure: changes.name })).procedure
.name,
changes.name,
);
assert.equal(await call("address_name", { address }), changes.name);
for (const inventory of ["list_names", "list_procedures"])
assert.ok(
(await call(inventory)).some(
(item) => item.address === address && item.value === changes.name,
),
);
assert.ok(
(
await call("search_procedures", { pattern: "dead", case_sensitive: true })
).some((item) => item.address === address),
);
for (const field of ["name", "comment", "inline_comment"]) {
for (const text of ["bad\0text", "bad\ud800text", "bad\udffftext"]) {
await invalid(
"annotate_native_function",
{
procedure: address,
name: "MUST_ROLL_BACK",
comment: "MUST ROLL BACK",
inline_comment: "MUST ROLL BACK",
[field]: text,
},
new RegExp(
`Annotation ${field}.*(?:NUL|unpaired Unicode surrogate).*index`,
"u",
),
);
assert.deepEqual(
(
await call("annotate_native_function", {
procedure: address,
name: changes.name,
})
).annotations,
updated.annotations,
"Invalid native text must leave every annotation unchanged",
);
}
}
const unicodeText =
"CRLF\r\nastral 🧪 decomposed e\u0301 control \u0001\b\v\f\u001f noncharacter \ufffe\uffff";
const unicode = await call("annotate_native_function", {
procedure: address,
comment: unicodeText,
inline_comment: unicodeText,
});
assert.equal(unicode.annotations.comment, unicodeText);
assert.equal(unicode.annotations.inline_comment, unicodeText);
assert.ok(unicode.dossier.comments.some((item) => item.text === unicodeText));
await call("annotate_native_function", changes);
await invalid(
"annotate_native_function",
{
procedure: changes.name,
name: "invalid name\n",
comment: "MUST ROLL BACK",
inline_comment: "MUST ROLL BACK",
},
/Invalid function name/u,
);
const retained = await call("annotate_native_function", {
procedure: changes.name,
name: changes.name,
});
assert.deepEqual(retained.annotations, updated.annotations);
const cleared = await call("annotate_native_function", {
procedure: changes.name,
comment: "",
inline_comment: "",
});
assert.equal(cleared.annotations.comment, null);
assert.equal(cleared.annotations.inline_comment, null);
assert.equal(cleared.annotations.name, changes.name);
assert.deepEqual(
await call("read_bytes", { address, length: baseline.length }),
bytes,
);
assert.equal(
createHash("sha256")
.update(await readFile(target.path))
.digest("hex"),
target.sha256,
);
const cliUpdated = await cli("annotate-native-function", name, [
"--name",
changes.name,
"--comment",
changes.comment,
"--inline-comment",
changes.inline_comment,
]);
assert.deepEqual(cliUpdated.annotations, updated.annotations);
assert.deepEqual(cliUpdated.effects, updated.effects);
for (const renamed of ["0xordinary", "probe::qualified", "🧪probe"]) {
await call("annotate_native_function", {
procedure: address,
name: renamed,
});
assert.equal(
await call("procedure_address", { procedure: renamed }),
address,
);
assert.equal(await call("address_name", { address }), renamed);
}
for (const selected of [address, indirectProcedure.address]) {
await call("annotate_native_function", {
procedure: selected,
name: "rea_ambiguous",
});
}
const ambiguous = await invalid(
"procedure_address",
{ procedure: "rea_ambiguous" },
/ambiguous.*select an exact entry address/u,
);
for (const selected of [address, indirectProcedure.address]) {
assert.ok(
JSON.stringify(ambiguous.details.issues).includes(selected),
"Ambiguity diagnostics must retain every matching entry address",
);
assert.equal(
await call("procedure_address", { procedure: selected }),
selected,
);
}
const snapshotLifecycle = await verifyGhidraSnapshotLifecycle(
client,
target,
await call("analyze_function", { procedure: address }),
cli,
);
successfulCalls += snapshotLifecycle.successfulCalls;
rejectedCalls += snapshotLifecycle.rejectedCalls;
const targetAdmission = await verifyGhidraTargetAdmission({
call,
reject: invalid,
target,
entry: address,
runtimeRoot: env.TMPDIR,
});
await verifyGhidraNamespaceAnnotations({
call,
reject: invalid,
target,
entrypoint,
env,
});
await assert.rejects(access(socketRoot), { code: "ENOENT" });
await assert.rejects(access(runtimeRoot), { code: "ENOENT" });
assert.equal(await call("procedure_address", { procedure: name }), address);
assert.equal(await call("address_name", { address }), name);
assert.equal(
await call("procedure_address", { procedure: indirectProcedure.value }),
indirectProcedure.address,
);
assert.deepEqual(
(await call("analyze_function", { procedure: address })).comments,
original.comments,
);
assert.equal(
createHash("sha256")
.update(await readFile(target.path))
.digest("hex"),
target.sha256,
);
await verifyStartupCancellation(client, call, target, env, options);
return {
mocked: false,
catalog_tools: catalog.tools.length,
successful_calls: successfulCalls,
rejected_calls: rejectedCalls,
cli_mcp_parity: true,
mutation_rollback: true,
annotation_native_text_validation: true,
lossless_unicode_transport: true,
exact_external_entry_resolution: true,
recoverable_regex_stack_exhaustion: true,
complete_function_body_references: true,
ambiguity_candidates_inline: true,
legacy_reference_snapshot_rejected: true,
mutation_snapshot_lifecycle: true,
concurrent_annotation_snapshot_close: true,
changed_source_admission_and_recovery: true,
source_permission_denial_verified: targetAdmission.permissionDenied,
missing_and_nonregular_source_rejected: true,
imported_source_identity_retained: true,
equivalent_instruction_address_spellings: true,
qualified_annotation_name_roundtrip: true,
source_immutable: true,
reopen_discards_edits: true,
long_tmpdir_private_socket_cleanup: true,
real_startup_cancellation_cleanup: true,
};
}
async function verifyStartupCancellation(client, call, target, env, options) {
await call("close_binary");
await call("open_binary", { path: target.path, provider_id: "ghidra" });
// The composite provider selects the target lazily. Use a fresh search input
// so persisted immutable snapshots cannot bypass actual provider startup.
const request = {
name: "search_procedures",
arguments: { pattern: `startup-cancellation-${randomUUID()}` },
};
const controller = new AbortController();
let settled = false;
let outcome;
const pending = client
.callTool(request, { ...options, signal: controller.signal })
.then(
(reply) => {
settled = true;
outcome = reply;
return { reply };
},
(error) => {
settled = true;
outcome = String(error);
return { error };
},
);
const deadline = Date.now() + 30000;
let launched;
try {
while (Date.now() < deadline && !settled) {
const roots = (await readdir(env.TMPDIR)).filter((name) =>
name.startsWith("rea-ghidra-"),
);
if (roots.length === 1) {
const root = join(env.TMPDIR, roots[0]);
try {
const ownership = JSON.parse(
await readFile(join(root, "ownership.json"), "utf8"),
);
const endpointPath = ownership.endpoint_path;
// Observe the JVM inside the owned process group before the bridge
// publishes its socket. A log file can appear after analysis finishes.
const { stdout } = await promisify(execFile)("ps", [
"-axo",
"pid=,pgid=,comm=",
]);
const javaProcess = stdout.split("\n").find((line) => {
const fields = line.trim().split(/\s+/u);
return (
Number(fields[1]) === ownership.process_group_id &&
/(?:^|\/)java$/u.test(fields.slice(2).join(" "))
);
});
if (javaProcess === undefined) {
await delay(25);
continue;
}
const javaPid = Number(javaProcess.trim().split(/\s+/u)[0]);
await assert.rejects(access(endpointPath), { code: "ENOENT" });
process.kill(ownership.pid, 0);
launched = {
root,
socketRoot: dirname(endpointPath),
pid: ownership.pid,
javaPid,
};
break;
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
}
await delay(25);
}
assert.ok(
launched,
`No real headless process was observed before cancellation: ${JSON.stringify(outcome)}`,
);
assert.equal(
settled,
false,
"Cancellation must interrupt pending provider work",
);
} finally {
controller.abort();
await pending;
}
const cleanupDeadline = Date.now() + 30000;
while (Date.now() < cleanupDeadline) {
const processGone = [launched.pid, launched.javaPid].every((pid) => {
try {
process.kill(pid, 0);
return false;
} catch (error) {
if (error.code === "ESRCH") return true;
throw error;
}
});
try {
await access(launched.root);
} catch (error) {
if (error.code === "ENOENT" && processGone) break;
if (error.code === "ENOENT") {
await delay(25);
continue;
}
throw error;
}
await delay(25);
}
await assert.rejects(access(launched.root), { code: "ENOENT" });
await assert.rejects(access(launched.socketRoot), { code: "ENOENT" });
assert.throws(() => process.kill(launched.pid, 0), { code: "ESRCH" });
assert.throws(() => process.kill(launched.javaPid, 0), { code: "ESRCH" });
assert.equal(
(await call("binary_session")).open,
true,
"Cancellation must preserve the selected target",
);
assert.deepEqual(
await call(request.name, request.arguments),
[],
"A cancelled startup must allow a fresh provider query",
);
}
@@ -1,151 +0,0 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
/** Exercise qualified annotation names against real demangled C++ ABI symbols. */
export async function verifyGhidraNamespaceAnnotations({
call,
reject,
target,
entrypoint,
env,
}) {
const exec = promisify(execFile);
const compiler = process.env.REA_CC ?? "cc";
try {
await exec(compiler, ["--version"], { env, timeout: 30000 });
} catch (cause) {
throw new Error(
`Ghidra namespace annotation verification requires a working host C compiler '${compiler}'.`,
{ cause },
);
}
const workspace = await mkdtemp(join(tmpdir(), "rea-ghidra-namespaces-"));
const source = fileURLToPath(
new URL("../../tests/conformance/ghidra/namespaces.c", import.meta.url),
);
const path = join(workspace, "namespaces");
try {
await exec(compiler, ["-O0", "-fno-inline", source, "-o", path], {
env,
timeout: 30000,
});
const bytes = await readFile(path);
const sha256 = createHash("sha256").update(bytes).digest("hex");
await call("close_binary");
await call("open_binary", { path, provider_id: "ghidra" });
const procedures = await call("list_procedures");
const namespaces = ["alpha", "beta", "outer::inner"];
const entries = namespaces.map((namespace) => {
const procedure = procedures.find(
({ value }) => value === `${namespace}::same`,
);
assert.ok(
procedure,
`Missing demangled fixture function ${namespace}::same`,
);
return { namespace, address: procedure.address };
});
const ambiguous = await reject("procedure_address", { procedure: "same" });
assert.equal(ambiguous.code, "invalid_request");
for (const { address } of entries)
assert.ok(JSON.stringify(ambiguous.details.issues).includes(address));
for (const { namespace, address } of entries) {
const leaf = await call("annotate_native_function", {
procedure: address,
name: "renamed",
comment: "Preserve this comment",
inline_comment: "Preserve this inline comment",
});
assert.equal(leaf.annotations.name, `${namespace}::renamed`);
const qualified = await call("annotate_native_function", {
procedure: address,
name: `${namespace}::qualified`,
});
assert.equal(qualified.annotations.name, `${namespace}::qualified`);
for (let repeat = 0; repeat < 2; repeat++) {
const replay = await call("annotate_native_function", {
procedure: qualified.annotations.name,
name: qualified.annotations.name,
});
assert.deepEqual(replay.annotations, qualified.annotations);
assert.equal(replay.dossier.procedure.address, address);
}
assert.equal(
await call("procedure_address", {
procedure: qualified.annotations.name,
}),
address,
);
for (const name of ["other::literal", `${namespace}::child::literal`]) {
const literal = await call("annotate_native_function", {
procedure: address,
name,
});
const expected = name.startsWith(`${namespace}::`)
? name
: `${namespace}::${name}`;
assert.equal(literal.annotations.name, expected);
const repeated = await call("annotate_native_function", {
procedure: expected,
name: expected,
});
assert.deepEqual(repeated.annotations, literal.annotations);
}
await call("annotate_native_function", {
procedure: address,
name: qualified.annotations.name,
});
const failure = await reject("annotate_native_function", {
procedure: address,
name: `${namespace}::`,
comment: "MUST NOT REPLACE",
inline_comment: "MUST NOT REPLACE",
});
assert.equal(failure.code, "invalid_request");
assert.deepEqual(failure.details.issues[0].path, ["name"]);
assert.match(failure.details.issues[0].message, /empty leaf/u);
const unchanged = await call("annotate_native_function", {
procedure: address,
name: qualified.annotations.name,
});
assert.deepEqual(unchanged.annotations, qualified.annotations);
}
const { stdout } = await exec(
process.execPath,
[
entrypoint,
"annotate-native-function",
path,
"alpha::same",
"--name",
"alpha::cli_renamed",
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 240000, maxBuffer: 72 * 1024 * 1024 },
);
assert.equal(
JSON.parse(stdout).normalized_result.annotations.name,
"alpha::cli_renamed",
);
assert.equal(
(await call("analyze_function", { procedure: entries[0].address }))
.procedure.name,
"alpha::qualified",
"The independent CLI session must not alter the active MCP database",
);
assert.deepEqual(await readFile(path), bytes);
assert.equal((await call("binary_session")).sha256, sha256);
await call("close_binary");
await call("open_binary", { path: target.path, provider_id: "ghidra" });
} finally {
await rm(workspace, { recursive: true, force: true });
}
}
@@ -1,180 +0,0 @@
import assert from "node:assert/strict";
import { access, mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parseAnalysisSnapshot } from "../../dist/domain/analysisSnapshot.js";
import { parseEvidenceBundle } from "../../dist/domain/evidenceBundle.js";
import { requireMcpResult } from "./mcp-verifier-results.mjs";
/** Keep mutable Ghidra observations out of immutable snapshots across target opens. */
export async function verifyGhidraSnapshotLifecycle(
client,
target,
edited,
cli,
) {
const workspace = await mkdtemp(join(tmpdir(), "rea-ghidra-snapshot-proof-"));
let successfulCalls = 0;
let rejectedCalls = 0;
const call = async (name, args = {}) => {
const result = requireMcpResult(
await client.callTool({ name, arguments: args }, { timeout: 180000 }),
name,
);
successfulCalls++;
return result;
};
const rejectSnapshot = async (name, args) => {
const reply = await client.callTool({ name, arguments: args });
assert.equal(
reply.isError,
true,
`${name} accepted a mutated snapshot binding`,
);
const error = reply.structuredContent?.error;
assert.equal(
error?.code,
"evidence_integrity_mismatch",
JSON.stringify(reply),
);
assert.match(error.message, /after analysis metadata mutations/u);
assert.match(
error.remediation.action,
/close.*without saving a snapshot/iu,
);
assert.match(error.remediation.action, /export_evidence_bundle/u);
rejectedCalls++;
};
try {
const entry = edited.procedure.address;
const snapshotPath = join(workspace, "original.json");
// The CLI has its own unmodified database and writes the production snapshot format.
const original = await cli("function", entry, ["--snapshot", snapshotPath]);
const snapshotBytes = await readFile(snapshotPath);
const snapshot = parseAnalysisSnapshot(
JSON.parse(snapshotBytes.toString("utf8")),
);
assert.equal(snapshot.target.sha256, target.sha256);
assert.notEqual(original.procedure.name, edited.procedure.name);
const runId = (await call("binary_session")).analysis_run.run_id;
const api = await call("inspect_native_api", { procedure: entry });
assert.equal(api.procedure.name, edited.procedure.name);
for (const repeatedOpen of [false, true]) {
if (repeatedOpen)
await call("open_binary", { path: target.path, provider_id: "ghidra" });
const destination = join(
workspace,
repeatedOpen ? "repeated.json" : "edited.json",
);
await rejectSnapshot("close_binary", { snapshot_path: destination });
await assert.rejects(access(destination), { code: "ENOENT" });
assert.equal((await call("binary_session")).analysis_run.run_id, runId);
assert.deepEqual(
await call("analyze_function", { procedure: entry }),
edited,
);
}
await rejectSnapshot("open_binary", {
path: target.path,
provider_id: "ghidra",
snapshot_path: snapshotPath,
});
assert.deepEqual(await readFile(snapshotPath), snapshotBytes);
assert.equal((await call("binary_session")).analysis_run.run_id, runId);
assert.deepEqual(
await call("analyze_function", { procedure: entry }),
edited,
);
// Mutable results remain exportable as observations, with their mutation Evidence.
const bundlePath = join(workspace, "observations.json");
await call("export_evidence_bundle", { path: bundlePath });
const bundle = parseEvidenceBundle(
JSON.parse(await readFile(bundlePath, "utf8")),
);
assert.ok(
bundle.records.some(
(record) => record.operation === "annotate_native_function",
),
);
assert.ok(
bundle.records.some(
(record) =>
record.operation === "inspect_native_api" &&
record.normalized_result.procedure.name === edited.procedure.name,
),
);
await call("close_binary");
await call("open_binary", {
path: target.path,
provider_id: "ghidra",
snapshot_path: snapshotPath,
});
const restored = await call("analyze_function", { procedure: entry });
assert.deepEqual(restored.procedure, original.procedure);
assert.deepEqual(restored.comments, original.comments);
assert.notEqual((await call("binary_session")).analysis_run.run_id, runId);
const freshPath = join(workspace, "fresh.json");
await call("close_binary", { snapshot_path: freshPath });
const fresh = parseAnalysisSnapshot(
JSON.parse(await readFile(freshPath, "utf8")),
);
assert.ok(
!fresh.evidence_bundle.records.some(
(record) =>
record.operation === "annotate_native_function" ||
(record.operation === "inspect_native_api" &&
record.normalized_result.procedure.name === edited.procedure.name),
),
);
await call("open_binary", {
path: target.path,
provider_id: "ghidra",
snapshot_path: freshPath,
});
await verifyConcurrentSnapshotClose({
call,
rejectSnapshot,
workspace,
target,
entry,
original,
});
return { successfulCalls, rejectedCalls };
} finally {
await rm(workspace, { recursive: true, force: true });
}
}
async function verifyConcurrentSnapshotClose({
call,
rejectSnapshot,
workspace,
target,
entry,
original,
}) {
const destination = join(workspace, "concurrent.json");
const annotation = call("annotate_native_function", {
procedure: entry,
name: "rea_concurrent_snapshot_edit",
comment:
"A successful edit must invalidate a concurrently requested snapshot.",
});
const close = rejectSnapshot("close_binary", { snapshot_path: destination });
const [updated] = await Promise.all([annotation, close]);
await assert.rejects(access(destination), { code: "ENOENT" });
assert.equal((await call("binary_session")).open, true);
assert.deepEqual(
await call("analyze_function", { procedure: entry }),
updated.dossier,
);
// Discard the ephemeral edit and leave the caller with a pristine database.
await call("close_binary");
await call("open_binary", { path: target.path, provider_id: "ghidra" });
assert.deepEqual(
(await call("analyze_function", { procedure: entry })).procedure,
original.procedure,
);
}
@@ -1,191 +0,0 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import {
chmod,
mkdir,
mkdtemp,
readFile,
realpath,
readdir,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
/** Verify changed-source admission and retained private database identity through MCP. */
export async function verifyGhidraTargetAdmission({
call,
reject,
target,
entry,
runtimeRoot,
}) {
const workspace = await mkdtemp(
join(tmpdir(), "rea-ghidra-source-admission-"),
);
const source = join(workspace, "caller-selected.%PATH%");
try {
const original = await readFile(target.path);
await writeFile(source, original);
await call("close_binary");
const opened = await call("open_binary", {
path: source,
provider_id: "ghidra",
});
assert.equal(opened.sha256, target.sha256);
const selectedPath = await realpath(source);
assert.equal(opened.path, selectedPath);
const before = await call("binary_session");
const changed = Buffer.concat([
original,
Buffer.from("REA_SOURCE_CHANGED_AFTER_OPEN"),
]);
const changedSha256 = createHash("sha256").update(changed).digest("hex");
await writeFile(source, changed);
const failure = await reject("analyze_function", { procedure: entry });
assert.equal(failure.code, "artifact_changed");
assert.equal(failure.category, "integrity_mismatch");
assert.equal(failure.details.operation, "analyze_function");
assert.equal(failure.details.path, selectedPath);
assert.ok(failure.details.reason.includes(target.sha256));
assert.ok(failure.details.reason.includes(changedSha256));
assert.match(failure.remediation.action, /open_binary/u);
assert.doesNotMatch(failure.remediation.action, /doctor/u);
const after = await call("binary_session");
assert.equal(after.analysis_run.run_id, before.analysis_run.run_id);
assert.equal(after.sha256, target.sha256);
assert.equal(
after.capabilities.find(
({ operation }) => operation === "analyze_function",
)?.available,
true,
"An artifact identity mismatch must not mark the installed provider unavailable",
);
assert.deepEqual(
(await readdir(runtimeRoot)).filter((name) =>
name.startsWith("rea-ghidra-"),
),
[],
"Failed admission must remove the private snapshot without launching Ghidra",
);
assert.deepEqual(await readFile(source), changed);
const permissionDenied = await verifySourceReadFailures({
call,
reject,
source,
bytes: changed,
entry,
runtimeRoot,
});
const reopened = await call("open_binary", {
path: source,
provider_id: "ghidra",
});
assert.equal(reopened.sha256, changedSha256);
assert.notEqual(
(await call("binary_session")).analysis_run.run_id,
before.analysis_run.run_id,
);
const dossier = await call("analyze_function", { procedure: entry });
const instruction = await call("inspect_native_instruction", {
address: entry,
});
assert.equal(dossier.procedure.address, entry);
assert.equal(instruction.status, "decoded");
for (const address of [
entry.toUpperCase(),
entry.slice(2),
`0x0000${entry.slice(2)}`,
`ram:${entry}`,
`%72%61%6D:${entry}`,
]) {
assert.deepEqual(
await call("inspect_native_instruction", { address }),
instruction,
);
assert.equal(
(await call("resolve_containing_procedure", { address })).procedure
.address,
entry,
);
}
// Imported facts belong to the captured bytes even if the caller's path disappears.
await rm(source);
assert.deepEqual(
await call("analyze_function", { procedure: entry }),
dossier,
);
assert.deepEqual(
await call("inspect_native_instruction", { address: entry }),
instruction,
);
assert.equal((await call("binary_session")).sha256, changedSha256);
await call("close_binary");
await call("open_binary", { path: target.path, provider_id: "ghidra" });
return { permissionDenied };
} finally {
await rm(workspace, { recursive: true, force: true });
}
}
async function verifySourceReadFailures({
call,
reject,
source,
bytes,
entry,
runtimeRoot,
}) {
const permissionDenied =
typeof process.getuid === "function" && process.getuid() !== 0;
const scenarios = [
"missing",
"directory",
...(permissionDenied ? ["permission"] : []),
];
for (const scenario of scenarios) {
await call("open_binary", { path: source, provider_id: "ghidra" });
const before = await call("binary_session");
if (scenario === "permission") await chmod(source, 0);
else {
await rm(source);
if (scenario === "directory") await mkdir(source);
}
try {
const failure = await reject("analyze_function", { procedure: entry });
assert.equal(
failure.code,
scenario === "permission" ? "access_denied" : "artifact_changed",
);
assert.equal(failure.details.path, before.path);
assert.equal(failure.details.operation, "analyze_function");
if (scenario === "permission") {
assert.ok(["EACCES", "EPERM"].includes(failure.details.system_code));
assert.match(failure.remediation.action, /read access/u);
} else assert.match(failure.remediation.action, /open_binary/u);
assert.doesNotMatch(failure.remediation.action, /doctor/u);
const after = await call("binary_session");
assert.equal(after.analysis_run.run_id, before.analysis_run.run_id);
assert.equal(after.sha256, before.sha256);
assert.equal(
after.capabilities.find(
({ operation }) => operation === "analyze_function",
)?.available,
true,
);
assert.deepEqual(
(await readdir(runtimeRoot)).filter((name) =>
name.startsWith("rea-ghidra-"),
),
[],
);
} finally {
if (scenario === "permission") await chmod(source, 0o600);
await rm(source, { recursive: true, force: true });
await writeFile(source, bytes);
}
}
return permissionDenied;
}
-471
View File
@@ -1,471 +0,0 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { copyFile, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { promisify } from "node:util";
import Ajv from "ajv/dist/2020.js";
import addFormats from "ajv-formats";
import { mcpTextValue, requireMcpResult } from "./mcp-verifier-results.mjs";
import { verifyHopperNavigationAndText } from "./real-hopper-navigation.mjs";
import { verifyHopperWorkflows } from "./real-hopper-workflows.mjs";
import {
verifyHopperSearch,
verifyHopperRegexIsolation,
} from "./real-hopper-search.mjs";
/** Exercise real Hopper annotation and malformed-input boundaries over MCP. */
export async function verifyHopperBoundaryContracts(
client,
options,
procedure,
targetPath,
) {
const catalog = await client.listTools();
const ajv = new Ajv({ strict: false });
addFormats(ajv);
const validators = new Map();
for (const tool of catalog.tools) {
assert.ok(
ajv.validateSchema(tool.inputSchema),
`${tool.name} input schema`,
);
assert.ok(tool.outputSchema, `${tool.name} omitted its output schema`);
assert.ok(
ajv.validateSchema(tool.outputSchema),
`${tool.name} output schema`,
);
validators.set(tool.name, ajv.compile(tool.outputSchema));
}
let successfulCalls = 0;
let rejectedCalls = 0;
const rejectedOperations = new Set();
const call = async (name, args = {}) => {
const reply = await client.callTool({ name, arguments: args }, options);
const result = requireMcpResult(reply, name);
const validate = validators.get(name);
assert.ok(validate, `${name} is missing from tools/list`);
assert.ok(
validate(reply.structuredContent),
`${name}: ${JSON.stringify(validate.errors)}`,
);
assert.deepEqual(reply.structuredContent, JSON.parse(mcpTextValue(reply)));
successfulCalls += 1;
return result;
};
const invalid = async (name, args, reason, failedOperation = name) => {
const reply = await client.callTool({ name, arguments: args }, options);
assert.equal(reply.isError, true, `${name} accepted invalid input`);
const { error } = JSON.parse(mcpTextValue(reply));
assert.equal(error.code, "invalid_request");
assert.equal(error.category, "invalid_input");
assert.equal(error.details.operation, failedOperation);
assert.match(error.message, reason);
rejectedCalls += 1;
rejectedOperations.add(name);
};
const address = procedure.address;
const originalName = await call("address_name", { address });
const originalComment = await call("comment", { address });
const originalInlineComment = await call("inline_comment", { address });
const activeDocument = await call("current_document");
const foreignDocument = (await call("list_documents")).find(
(name) => name !== activeDocument,
);
const navigationAndText = await verifyHopperNavigationAndText(
call,
invalid,
address,
);
const workflows = await verifyHopperWorkflows(call, invalid, procedure);
const search = {
...(await verifyHopperSearch(call, invalid, procedure)),
...(await verifyHopperRegexIsolation(client, options, call, address)),
};
try {
if (foreignDocument !== undefined) {
await invalid(
"read_bytes",
{ address, length: 8, document: foreignDocument },
/belongs to another target/u,
);
assert.equal(await call("current_document"), activeDocument);
}
for (const label of [undefined, "", "REA boundary bookmark 测试"]) {
assert.equal(
await call("set_bookmark", {
address,
...(label === undefined ? {} : { name: label }),
}),
true,
);
const bookmarks = await call("list_bookmarks");
const bookmark = bookmarks.find((item) => item.address === address);
assert.ok(bookmark, "set_bookmark did not create a bookmark");
assert.equal(bookmark.name, label || null);
const context = await call("inspect_address_context", { address });
assert.equal(context.bookmarks.state, "available");
assert.deepEqual(context.bookmarks.value, [bookmark]);
assert.equal(await call("unset_bookmark", { address }), true);
assert.ok(
!(await call("list_bookmarks")).some(
(item) => item.address === address,
),
);
}
for (const [setter, getter] of [
["set_comment", "comment"],
["set_inline_comment", "inline_comment"],
]) {
const comment = "REA boundary comment 测试";
assert.equal(await call(setter, { address, comment }), true);
assert.equal(await call(getter, { address }), comment);
assert.equal(await call(setter, { address, comment: "" }), true);
assert.equal(await call(getter, { address }), null);
}
const inline = "REA inline\nsecond line";
const exact = await call("set_inline_comment", {
address,
comment: inline,
});
const observed = await call("inline_comment", { address });
assert.equal(
exact,
observed === inline,
"inline normalization must remain visible",
);
await invalid(
"set_addresses_names",
{
names: {
[address]: "rea_boundary_should_not_be_applied",
__rea_missing_batch_address__: "missing",
},
},
/Unknown Hopper address or name: __rea_missing_batch_address__/u,
);
assert.equal(await call("address_name", { address }), originalName);
assert.equal(
(await call("procedure_info", { procedure: address })).name,
originalName,
);
const renamed = "rea_boundary_renamed";
assert.equal(
await call("set_addresses_names", { names: { [address]: renamed } }).then(
(value) => value[address],
),
true,
);
assert.equal(await call("address_name", { address }), renamed);
const matches = await call("search_procedures", {
pattern: renamed,
case_sensitive: true,
});
assert.ok(
matches.some(
(item) => item.address === address && item.value === renamed,
),
);
assert.equal(
(await call("analyze_function", { procedure: address })).procedure.name,
renamed,
);
assert.equal(
await call("set_address_name", { address, name: "add" }),
true,
);
assert.equal(
await call("procedure_address", { procedure: "add" }),
address,
);
assert.equal(
(await call("procedure_info", { procedure: "add" })).entrypoint,
address,
);
assert.equal(
(await call("read_bytes", { address: "add", length: 8 })).address,
address,
);
assert.equal(
await call("procedure_address", { procedure: address.slice(2) }),
address,
);
for (const query of ["-1", "0x10000000000000000", "0xffffffffffffffff"]) {
for (const name of [
"read_bytes",
"address_to_file_offset",
"resolve_containing_procedure",
"procedure_info",
]) {
await invalid(
name,
name === "procedure_info"
? { procedure: query }
: {
address: query,
...(name === "read_bytes" ? { length: 8 } : {}),
},
/usable unsigned 64-bit range/u,
);
}
}
await invalid(
"read_bytes",
{ address: "0x0", length: 8 },
/Address is outside every segment/u,
);
await invalid(
"procedure_info",
{ procedure: address, document: "__rea_missing_document__" },
/Unknown Hopper document/u,
);
await invalid(
"search_procedures",
{ pattern: "[", mode: "regex" },
/Invalid regex pattern/u,
);
// Invalid calls must leave the same authenticated bridge usable.
assert.equal(
(await call("read_bytes", { address, length: 8 })).returned_bytes,
8,
);
const mapping = await call("address_to_file_offset", { address });
const bytes = await call("read_bytes", { address, length: 8 });
const file = await readFile(targetPath);
assert.equal(
bytes.bytes_hex,
file
.subarray(mapping.file_offset, mapping.file_offset + 8)
.toString("hex"),
);
const segments = await call("list_segments");
const containing = segments.find(
(segment) =>
BigInt(segment.start) <= BigInt(address) &&
BigInt(address) < BigInt(segment.end),
);
assert.ok(containing, "fixture procedure has no containing segment");
const lastByte = `0x${(BigInt(containing.end) - 1n).toString(16)}`;
const prefix = await call("read_bytes", { address: lastByte, length: 8 });
assert.equal(prefix.returned_bytes, 1);
assert.equal(prefix.bytes_hex.length, 2);
assert.equal(prefix.complete, false);
const synthetic = segments.find(
(segment) => segment.name === "External Symbols",
);
assert.ok(synthetic, "fixture omitted synthetic external-symbol memory");
await invalid(
"address_to_file_offset",
{ address: synthetic.start },
/no authoritative file-offset mapping/u,
);
const health = (await call("binary_session")).provider_operation_health;
assert.equal(
health.state,
"idle",
"a live GUI bridge must remain healthy after its launcher helper exits",
);
} finally {
await call("set_address_name", { address, name: originalName ?? "" });
await call("set_comment", { address, comment: originalComment ?? "" });
await call("set_inline_comment", {
address,
comment: originalInlineComment ?? "",
});
await call("unset_bookmark", { address });
}
return {
schemaCount: catalog.tools.length,
successfulCalls,
rejectedCalls,
rejectedOperations: [...rejectedOperations],
foreignDocumentVerified: foreignDocument !== undefined,
navigationAndText,
workflows,
search,
};
}
/** Prove actual document disposal and shared CLI/MCP behavior on disposable copies. */
export async function verifyHopperLifecycleAndCli(client, options, targets) {
const directory = await realpath(
await mkdtemp(join(tmpdir(), "hopper-boundary-")),
);
const suffix = basename(directory);
const primary = join(directory, `primary-${suffix}`);
const secondary = join(directory, `secondary-${suffix}`);
const call = async (name, args = {}) =>
requireMcpResult(
await client.callTool({ name, arguments: args }, options),
name,
);
const cli = promisify(execFile);
const runCli = (args) => {
const pending = cli(process.execPath, args, {
timeout: 180_000,
maxBuffer: 1_048_576,
});
if (pending.child.pid !== undefined)
targets.ownedProcessIds.add(pending.child.pid);
return pending;
};
try {
await copyFile(targets.primary, primary);
await copyFile(targets.secondary, secondary);
await call("open_binary", { path: primary });
const primaryProcedures = await call("list_procedures");
const entry = primaryProcedures.find((item) =>
item.value.endsWith("rea_entry"),
);
assert.ok(entry, "primary fixture omitted its entry procedure");
const expectedFunction = await call("analyze_function", {
procedure: entry.value,
});
const firstDocument = await call("current_document");
await call("open_binary", { path: secondary });
const secondDocument = await call("current_document");
assert.notEqual(firstDocument, secondDocument);
assert.ok(
!(await call("list_documents")).includes(firstDocument),
"target switching retained the old native document",
);
const procedures = await call("list_procedures");
await verifyBatchCancellation(client, options, procedures);
const address = procedures[0].address;
const expected = await call("read_bytes", {
address,
length: 8,
document: secondDocument,
});
await call("open_binary", { path: primary });
const query = "REA_C_LEAF __rea_missing_phrase__";
const expectedTrace = await call("trace_feature", { query });
assert.deepEqual(expectedTrace.matches, []);
await call("close_binary");
const dispatcher = new URL("../rea.mjs", import.meta.url).pathname;
const common = [
"--length",
"8",
"--provider",
"hopper",
"--format",
"json",
];
const { stdout } = await runCli([
dispatcher,
"read-bytes",
secondary,
address,
...common,
]);
const evidence = JSON.parse(stdout);
assert.equal(evidence.provider.id, "hopper");
assert.equal(evidence.subject.local_path, secondary);
assert.deepEqual(evidence.normalized_result, expected);
const traced = await runCli([
dispatcher,
"trace",
primary,
query,
"--provider",
"hopper",
"--format",
"json",
]);
const traceEvidence = JSON.parse(traced.stdout);
assert.equal(traceEvidence.subject.local_path, primary);
assert.deepEqual(traceEvidence.normalized_result, expectedTrace);
const analyzed = await runCli([
dispatcher,
"function",
primary,
entry.value,
"--provider",
"hopper",
"--format",
"json",
]);
assert.deepEqual(
JSON.parse(analyzed.stdout).normalized_result,
expectedFunction,
);
let failure;
try {
await runCli([
dispatcher,
"read-bytes",
secondary,
"0x10000000000000000",
...common,
]);
} catch (error) {
assert.equal(error.code, 1);
failure = JSON.parse(error.stdout);
}
assert.ok(failure, "CLI accepted an overflowing address");
assert.equal(failure.code, "invalid_request");
assert.equal(failure.category, "invalid_input");
assert.match(failure.message, /usable unsigned 64-bit range/u);
await call("open_binary", { path: primary });
assert.ok(
!(await call("list_documents")).includes(secondDocument),
"MCP or CLI cleanup retained the closed native document",
);
await call("close_binary");
return {
targetSwitchDisposedDocument: true,
cliByteParity: true,
cliInvalidAddress: true,
cliLiteralTraceParity: true,
cliFunctionDossierParity: true,
callerCancellationRecovered: true,
closedDocumentAbsent: true,
};
} finally {
await call("close_binary");
await rm(directory, { recursive: true, force: true });
}
}
const verifyBatchCancellation = async (client, options, procedures) => {
const controller = new AbortController();
let cancelled = false;
await assert.rejects(
client.callTool(
{
name: "batch_decompile",
arguments: { addresses: procedures.map((item) => item.address) },
},
{
...options,
signal: controller.signal,
onprogress: (update) => {
options.onprogress?.(update);
// Cancel between real decompilations, after Hopper served one item.
if (update.message?.includes("Hopper bridge completed request")) {
cancelled = true;
controller.abort();
}
},
},
),
/aborted/u,
);
assert.ok(cancelled, "cancellation did not follow a real Hopper response");
const recovered = requireMcpResult(
await client.callTool({ name: "list_procedures", arguments: {} }, options),
"list_procedures after cancellation",
);
assert.deepEqual(recovered, procedures);
const status = requireMcpResult(
await client.callTool({ name: "binary_session", arguments: {} }, options),
"binary_session after cancellation",
);
assert.equal(status.provider_operation_health.state, "idle");
};
-6
View File
@@ -1,6 +0,0 @@
/** Observe concrete Hopper runtime resources for a before/after cleanup check. */
export function snapshotHopperRuntime(
parent: string,
targetLeaseDirectory: string,
ownedProcessIds?: ReadonlySet<number>,
): Promise<Set<string>>;
-52
View File
@@ -1,52 +0,0 @@
import { lstat, readFile, readdir } from "node:fs/promises";
import { join } from "node:path";
/** Observe session paths and target leases without treating their shared parent as a session. */
export async function snapshotHopperRuntime(
parent,
targetLeaseDirectory,
ownedProcessIds,
) {
const resources = new Set();
for (const entry of await readdir(parent, { withFileTypes: true })) {
const path = join(parent, entry.name);
if (!entry.name.startsWith("rea-") || path === targetLeaseDirectory)
continue;
if (
ownedProcessIds !== undefined &&
(!entry.isDirectory() ||
!(await belongsToOwnedProcess(path, ownedProcessIds)))
)
continue;
resources.add(path);
}
const leaseRoot = await lstat(targetLeaseDirectory).catch(missingIsAbsent);
if (leaseRoot === undefined) return resources;
if (!leaseRoot.isDirectory() || leaseRoot.isSymbolicLink()) {
resources.add(targetLeaseDirectory);
return resources;
}
const leases = await readdir(targetLeaseDirectory).catch(missingIsAbsent);
for (const name of leases ?? [])
resources.add(join(targetLeaseDirectory, name));
return resources;
}
const missingIsAbsent = (cause) => {
if (cause?.code === "ENOENT") return undefined;
throw cause;
};
const belongsToOwnedProcess = async (path, ownedProcessIds) => {
const encoded = await readFile(join(path, "ownership.json"), "utf8").catch(
missingIsAbsent,
);
if (encoded === undefined) return false;
const ownership = JSON.parse(encoded);
return (
ownership !== null &&
typeof ownership === "object" &&
Number.isSafeInteger(ownership.parent_pid) &&
ownedProcessIds.has(ownership.parent_pid)
);
};
+1 -15
View File
@@ -21,26 +21,12 @@ export async function loadRealHopperFixtureTargets(manifestPath) {
bindFixtureArtifact(root, large),
],
);
const objc = manifest.fixtures.find((fixture) => fixture?.name === "objc");
const unicodeArtifact =
objc === undefined
? undefined
: await bindFixtureArtifact(
root,
fixtureEntry(manifest.fixtures, "objc"),
);
requireDistinctArtifacts([
primaryArtifact,
secondaryArtifact,
largeArtifact,
...(unicodeArtifact === undefined ? [] : [unicodeArtifact]),
]);
requireDistinctArtifacts([primaryArtifact, secondaryArtifact, largeArtifact]);
return {
manifestPath: canonicalManifest,
primary: primaryArtifact,
secondary: secondaryArtifact,
large: largeArtifact,
...(unicodeArtifact === undefined ? {} : { unicode: unicodeArtifact }),
oracle,
largeOracle,
};
-226
View File
@@ -1,226 +0,0 @@
import assert from "node:assert/strict";
/** Verify native cursor readback and reject unrepresentable text before mutations. */
export async function verifyHopperNavigationAndText(call, invalid, address) {
const cursor = await call("current_address");
const procedures = await call("list_procedures");
const other = procedures.find((item) => item.address !== address);
assert.ok(other, "fixture omitted a second procedure for batch validation");
const originalName = await call("address_name", { address });
const originalComment = await call("comment", { address });
const originalInline = await call("inline_comment", { address });
const originalBookmark = (await call("list_bookmarks")).find(
(item) => item.address === address,
);
try {
await verifyNavigation(call, invalid, address, {
originalName,
originalComment,
originalInline,
});
await verifyAnnotationText(call, invalid, { address, other, originalName });
return {
cursorReadback: true,
interiorObjectNavigation: true,
unmappedNavigationRejected: true,
nativeTextValidationBeforeMutation: true,
mappedAnnotationValidationBeforeMutation: true,
unselectedLabelOwnersPreserved: true,
explicitLabelSwap: true,
observedLocalMetadata: true,
};
} finally {
await call("goto_address", { address: cursor });
await call("set_comment", { address, comment: originalComment ?? "" });
await call("set_inline_comment", {
address,
comment: originalInline ?? "",
});
if (originalBookmark === undefined)
await call("unset_bookmark", { address });
else
await call("set_bookmark", {
address,
...(originalBookmark.name === null
? {}
: { name: originalBookmark.name }),
});
}
}
async function verifyNavigation(
call,
invalid,
address,
{ originalName, originalComment, originalInline },
) {
const instructions = await call("read_function_instructions", {
procedure: address,
});
const info = await call("procedure_info", { procedure: address });
assert.ok(info.locals.length > 0, "fixture omitted real stack locals");
for (const local of info.locals) {
assert.equal(typeof local.name, "string");
assert.match(local.stack_displacement, /^-?\d+$/u);
assert.ok(local.description.includes(local.name));
assert.ok(local.description.includes(local.stack_displacement));
assert.doesNotMatch(local.description, /object at 0x/u);
}
const dossier = await call("analyze_function", { procedure: address });
assert.deepEqual(dossier.procedure.locals, info.locals);
const coordinates = instructions.instructions.map(
(line) => /^0x[0-9a-f]+/u.exec(line)?.[0],
);
assert.equal(coordinates[0], address);
assert.ok(coordinates[1], "fixture omitted a second instruction");
const interior = `0x${(BigInt(address) + 1n).toString(16)}`;
assert.equal(await call("goto_address", { address: interior }), address);
assert.equal(await call("current_address"), address);
assert.equal(
await call("next_address", { address: interior }),
coordinates[1],
);
assert.equal(await call("prev_address", { address: interior }), address);
for (const unmapped of ["0x0", "0xfffffffffffffffe"]) {
for (const operation of ["goto_address", "next_address", "prev_address"])
await invalid(
operation,
{ address: unmapped },
/Address is outside every segment/u,
);
assert.equal(await call("current_address"), address);
for (const operation of [
"set_address_name",
"set_bookmark",
"unset_bookmark",
"set_comment",
"set_inline_comment",
])
await invalid(
operation,
{
address: unmapped,
...(operation === "unset_bookmark"
? {}
: operation.endsWith("comment")
? { comment: "must_not_apply" }
: { name: "must_not_apply" }),
},
/Address is outside every segment/u,
);
await invalid(
"set_addresses_names",
{ names: { [address]: "must_not_apply", [unmapped]: "unmapped" } },
/Address is outside every segment/u,
);
assert.equal(await call("address_name", { address }), originalName);
assert.equal(await call("comment", { address }), originalComment);
assert.equal(await call("inline_comment", { address }), originalInline);
assert.ok(
!(await call("list_bookmarks")).some((item) => item.address === unmapped),
);
}
const segments = await call("list_segments");
const first = segments.reduce((left, right) =>
BigInt(left.start) < BigInt(right.start) ? left : right,
);
const last = segments.reduce((left, right) =>
BigInt(left.end) > BigInt(right.end) ? left : right,
);
await invalid(
"prev_address",
{ address: first.start },
/No previous address exists in mapped memory/u,
);
await invalid(
"next_address",
{ address: `0x${(BigInt(last.end) - 1n).toString(16)}` },
/No adjacent analyzed object exists in mapped memory/u,
);
}
async function verifyAnnotationText(
call,
invalid,
{ address, other, originalName },
) {
const comment = "REA representation boundary 测试";
await call("set_comment", { address, comment });
await call("set_inline_comment", { address, comment });
await call("set_bookmark", { address, name: comment });
for (const [text, reason] of [
["before\0after", /contains a NUL character/u],
["before\ud800after", /contains an unpaired Unicode surrogate/u],
]) {
for (const operation of ["procedure_info", "read_bytes"])
await invalid(
operation,
operation === "procedure_info"
? { procedure: text }
: { address: text, length: 1 },
reason,
);
for (const operation of ["set_address_name", "set_bookmark"])
await invalid(operation, { address, name: text }, reason);
for (const operation of ["set_comment", "set_inline_comment"])
await invalid(operation, { address, comment: text }, reason);
await invalid(
"set_addresses_names",
{ names: { [address]: "must_not_apply", [other.address]: text } },
reason,
);
assert.equal(await call("address_name", { address }), originalName);
assert.equal(
await call("address_name", { address: other.address }),
other.value,
);
assert.equal(await call("comment", { address }), comment);
assert.equal(await call("inline_comment", { address }), comment);
assert.equal(
(await call("list_bookmarks")).find((item) => item.address === address)
?.name,
comment,
);
}
await invalid(
"set_address_name",
{ address, name: other.value },
/already assigned to/u,
);
await invalid(
"set_addresses_names",
{ names: { [address]: other.value } },
/already assigned to/u,
);
await invalid(
"set_addresses_names",
{ names: { [address]: "duplicate", [other.address]: "duplicate" } },
/assigns the same name more than once/u,
);
await invalid(
"set_addresses_names",
{ names: { [address]: "first", [address.slice(2)]: "second" } },
/selects the same address more than once/u,
);
assert.equal(await call("address_name", { address }), originalName);
assert.equal(
await call("address_name", { address: other.address }),
other.value,
);
try {
const swapped = await call("set_addresses_names", {
names: { [address]: other.value, [other.address]: originalName },
});
assert.equal(swapped[address], true);
assert.equal(swapped[other.address], true);
assert.equal(await call("address_name", { address }), other.value);
assert.equal(
await call("address_name", { address: other.address }),
originalName,
);
} finally {
await call("set_addresses_names", {
names: { [address]: originalName, [other.address]: other.value },
});
}
}
-287
View File
@@ -1,287 +0,0 @@
import assert from "node:assert/strict";
import { mcpTextValue } from "./mcp-verifier-results.mjs";
/** Exercise literal/regex search, Unicode identities and cache invalidation in Hopper. */
export async function verifyHopperSearch(call, invalid, procedure) {
const address = procedure.address;
const original = await call("address_name", { address });
try {
await verifySymbolNames(call, invalid, address);
await verifySearchModes(call, address);
} finally {
await call("set_address_name", { address, name: original });
}
assert.deepEqual(
await call("search_procedures", {
pattern: original,
mode: "literal",
case_sensitive: true,
}),
[{ address, value: original }],
);
return {
unicodeIdentity: true,
literalMetacharacters: true,
longValuePreserved: true,
symbolUtf16LimitValidatedBeforeMutation: true,
regexAndCaseModes: true,
annotationCacheInvalidation: true,
escapedUtf8AndByteStrings: true,
};
}
async function verifySymbolNames(call, invalid, address) {
const unicode = "REA_é_e\u0301_😀_Straße_ſ_İ";
const extentNames = ["x".repeat(1024), "é".repeat(1024), "😀".repeat(512)];
for (const name of ["REA_a.b[0]", unicode, ...extentNames]) {
assert.equal(await call("set_address_name", { address, name }), true);
assert.equal(await call("address_name", { address }), name);
assert.deepEqual(await call("list_names", { address }), [
{ address, value: name },
]);
assert.deepEqual(
await call("search_procedures", {
pattern: name,
mode: "literal",
case_sensitive: true,
}),
[{ address, value: name }],
);
assert.equal(await call("procedure_address", { procedure: name }), address);
}
assert.deepEqual(
await call("search_procedures", { pattern: unicode, mode: "literal" }),
[],
);
const other = (await call("list_procedures")).find(
(item) => item.address !== address,
);
assert.ok(other);
for (const name of ["x".repeat(1025), "é".repeat(1025), "😀".repeat(513)]) {
await invalid(
"set_address_name",
{ address, name },
/UTF-16 code-unit symbol-name limit/u,
);
await invalid(
"set_addresses_names",
{ names: { [other.address]: "must_not_apply", [address]: name } },
/UTF-16 code-unit symbol-name limit/u,
);
assert.equal(await call("address_name", { address }), extentNames.at(-1));
assert.equal(
await call("address_name", { address: other.address }),
other.value,
);
}
}
async function verifySearchModes(call, address) {
await call("set_address_name", { address, name: "REA_Search_Upper" });
assert.deepEqual(
await call("search_procedures", {
pattern: "rea_search_upper",
mode: "literal",
case_sensitive: false,
}),
[{ address, value: "REA_Search_Upper" }],
);
assert.deepEqual(
await call("search_procedures", {
pattern: "rea_search_upper",
mode: "literal",
case_sensitive: true,
}),
[],
);
assert.deepEqual(
await call("search_procedures", {
pattern: "^REA_Search_(?:Upper|Lower)$",
mode: "regex",
case_sensitive: true,
}),
[{ address, value: "REA_Search_Upper" }],
);
const strings = await call("list_strings");
const long = "REA_LONG_LITERAL_" + "x".repeat(4096) + "needle";
const longItem = strings.find((item) =>
item.value.startsWith("REA_LONG_LITERAL_"),
);
assert.ok(longItem, "fixture omitted its real long literal");
assert.ok(long.startsWith(longItem.value));
assert.ok(longItem.provider_value.endsWith("…"));
assert.equal(longItem.string.termination, "missing");
const literalProcedure = (await call("list_procedures")).find((item) =>
item.value.endsWith("rea_long_literal_procedure"),
);
assert.ok(literalProcedure);
const dossier = await call("analyze_function", {
procedure: literalProcedure.address,
});
const referenced = dossier.referenced_strings.find(
(item) => item.address === longItem.address,
);
assert.ok(referenced, "function dossier omitted its long literal reference");
const { source_address, ...stringRecord } = referenced;
assert.ok(
dossier.outgoing_references.some(
(edge) =>
edge.source_address === source_address &&
edge.target_address === longItem.address,
),
);
assert.deepEqual(
stringRecord,
longItem,
"function dossier discarded typed string provenance",
);
const fragments = strings.filter((item) => {
const offset = BigInt(item.address) - BigInt(longItem.address);
return offset >= 0n && offset < BigInt(Buffer.byteLength(long));
});
assert.equal(fragments.map((item) => item.value).join(""), long);
for (const fragment of fragments) {
assert.equal(
Buffer.byteLength(fragment.value),
fragment.string.byte_length -
(fragment.string.termination === "missing" ? 0 : 1),
);
const bytes = await call("read_bytes", {
address: fragment.address,
length: fragment.string.byte_length,
});
const expected = Buffer.from(
fragment.value + (fragment.string.termination === "missing" ? "" : "\0"),
);
assert.equal(bytes.bytes_hex, expected.toString("hex"));
}
assert.deepEqual(
await call("search_strings", {
pattern: "needle",
mode: "literal",
case_sensitive: true,
}),
[fragments.at(-1)],
);
const expected = strings.filter((item) =>
/^REA_C_(?:ENTRY|LEAF)$/u.test(item.value),
);
assert.equal(expected.length, 2);
assert.deepEqual(
await call("search_strings", {
pattern: "^REA_C_(?:ENTRY|LEAF)$",
mode: "regex",
case_sensitive: true,
}),
expected,
);
await verifyHopperStringObjects(call, [
{ value: "REA_UTF8_é_😀", encoding: "utf-8" },
{ value: 'REA_ESCAPED_"\\line\nend\t\r', encoding: "utf-8" },
{ value: "REA_LITERAL_BACKSLASH_\\n", encoding: "utf-8" },
{ value: "REA_LITERAL_…", encoding: "utf-8" },
{ value: "REA_LATIN1_ÿ", encoding: "latin-1" },
]);
}
/** Compare native string identities and values with the source fixture's bytes. */
export async function verifyHopperStringObjects(call, expectations) {
const strings = await call("list_strings");
for (const { value, encoding } of expectations) {
const item = strings.find((candidate) => candidate.value === value);
assert.ok(
item,
`Hopper omitted the decoded string ${JSON.stringify(value)}`,
);
assert.equal(item.string.encoding, encoding);
assert.equal(item.string.encoding_status, "inferred");
assert.equal(item.string.termination, "present_or_not_required");
const expectedBytes = Buffer.from(
value + "\0",
encoding.replaceAll("-", ""),
);
assert.equal(item.string.byte_length, expectedBytes.length);
const bytes = await call("read_bytes", {
address: item.address,
length: expectedBytes.length,
});
assert.equal(bytes.bytes_hex, expectedBytes.toString("hex"));
assert.deepEqual(await call("list_strings", { address: item.address }), [
item,
]);
assert.deepEqual(
await call("search_strings", {
pattern: value,
mode: "literal",
case_sensitive: true,
}),
[item],
);
}
assert.deepEqual(
await call("search_strings", { pattern: "\\xC3\\xA9", mode: "literal" }),
[],
);
return {
sourceBytesVerified: true,
inferredEncodingReported: true,
displayEscapesExcludedFromMatching: true,
};
}
/** A failed or cancelled pathological regex must leave the real Hopper API usable. */
export async function verifyHopperRegexIsolation(
client,
options,
call,
address,
) {
const original = await call("address_name", { address });
const pathological = "a".repeat(40) + "!";
try {
await call("set_address_name", { address, name: pathological });
const reply = await client.callTool(
{
name: "search_procedures",
arguments: { pattern: "(a+)+$", mode: "regex", case_sensitive: true },
},
options,
);
assert.equal(reply.isError, true);
const { error } = JSON.parse(mcpTextValue(reply));
assert.equal(error.code, "resource_constraint");
assert.equal(error.details.operation, "search_procedures");
assert.equal(error.details.resource, "cpu");
assert.equal(await call("address_name", { address }), pathological);
const controller = new AbortController();
const pending = client.callTool(
{
name: "search_procedures",
arguments: { pattern: "(a+)+$", mode: "regex", case_sensitive: true },
},
{ ...options, signal: controller.signal },
);
const cancelled = pending.catch((cause) => cause);
// The same small inventory has already completed once; leave the pathological
// matching worker running before cancelling the second caller.
await new Promise((resolve) => setTimeout(resolve, 750));
controller.abort();
await cancelled;
assert.equal(await call("address_name", { address }), pathological);
assert.deepEqual(
await call("search_procedures", {
pattern: pathological,
mode: "literal",
case_sensitive: true,
}),
[{ address, value: pathological }],
);
} finally {
await call("set_address_name", { address, name: original });
}
return {
regexDeadlinePreservesSession: true,
regexCancellationPreservesSession: true,
};
}
+6 -51
View File
@@ -1,4 +1,3 @@
import assert from "node:assert/strict";
import { requireFunctionDossierOracle } from "../../dist/application/RealHopperAssertions.js";
/** Require truthful tri-state permissions from real Hopper memory regions. */
@@ -58,9 +57,7 @@ export function requireBridgeDiagnostic(chunks) {
(diagnostic) =>
diagnostic.type === "bridge-diagnostic" &&
diagnostic.category === "invalid_request" &&
diagnostic.message.includes(
"Unknown Hopper address or name: __rea_missing_procedure__",
),
diagnostic.message === "Invalid Hopper bridge request",
)
)
throw new Error("The MCP runtime omitted the correlated Hopper diagnostic");
@@ -123,44 +120,6 @@ export async function verifyRealHopperFixture({
require_assembly: true,
},
);
const calls = entryDossier.outgoing_references.filter(
(edge) => edge.call?.classification === "direct",
);
assert.ok(
calls.some((edge) => edge.target_address === branch.address),
"Hopper native direct-call classification was discarded",
);
const observed = normalizedResult(
await client.callTool(
{
name: "procedure_references",
arguments: { procedure: entry.address, direction: "outgoing" },
},
options,
),
"procedure_references native calls",
);
assert.deepEqual(entryDossier.unresolved_calls, observed.unresolved_calls);
for (const call of calls) {
assert.equal(call.call.provider_type, 2);
assert.equal(
call.call.provenance,
"hopper-public-python-api:CallReference",
);
assert.equal(
call.kind.available,
false,
"partial call metadata invented detailed reference flags",
);
assert.deepEqual(
observed.references.find(
(edge) =>
edge.source_address === call.source_address &&
edge.target_address === call.target_address,
)?.call,
call.call,
);
}
const branchDossier = requireFunctionDossierOracle(
await analyzeFixtureProcedure(client, options, branch, normalizedResult),
{
@@ -194,13 +153,7 @@ export async function verifyRealHopperFixture({
}
/** Reject malformed, unsafe, or unexpected failure diagnostics from MCP. */
export function requireSafeDiagnostics(
chunks,
expectedErrorTools = ["procedure_info"],
expectedErrorTags = ["HopperRemoteError"],
) {
const expected = new Set(expectedErrorTools);
const tags = new Set(expectedErrorTags);
export function requireSafeDiagnostics(chunks) {
const lines = chunks
.join("")
.split("\n")
@@ -220,8 +173,10 @@ export function requireSafeDiagnostics(
typeof diagnostic.level !== "number" ||
diagnostic.level >= 50 ||
(diagnostic.level >= 40 &&
!tags.has(diagnostic.errorTag) &&
!(expected.has(diagnostic.tool) && diagnostic.status === "error"))
diagnostic.errorTag !== "HopperRemoteError" &&
!(
diagnostic.tool === "procedure_info" && diagnostic.status === "error"
))
)
throw new Error(`The MCP runtime emitted unsafe stderr: ${line}`);
}
-86
View File
@@ -1,86 +0,0 @@
import assert from "node:assert/strict";
/** Ground literal matching and graph identity in a real provider's function chain. */
export async function verifyHopperWorkflows(call, invalid, procedure) {
const address = procedure.address;
const originalName = await call("address_name", { address });
const callees = await call("procedure_callees", { procedure: address });
assert.ok(callees.length > 0, "fixture omitted a reachable call target");
const goal = callees[0];
const goalName = await call("address_name", { address: goal });
assert.equal(typeof goalName, "string");
const canonicalPath = await call("trace_call_path", { start: address, goal });
assert.equal(canonicalPath.goal_status, "reached");
assert.deepEqual(
await call("trace_call_path", { start: originalName, goal: goalName }),
canonicalPath,
);
assert.deepEqual(
await call("trace_call_path", {
start: address.toUpperCase(),
goal: `0x${(BigInt(goal) + 1n).toString(16)}`,
}),
canonicalPath,
);
const graph = await call("get_call_graph", { address });
assert.deepEqual(
await call("get_call_graph", { address: originalName }),
graph,
);
assert.deepEqual(
await call("get_call_graph", { address: address.toUpperCase() }),
graph,
);
const procedures = await call("list_procedures");
const cycle = procedures.filter((item) =>
/rea_cycle_[ab]$/u.test(item.value),
);
assert.equal(cycle.length, 2, "fixture omitted its source-owned call cycle");
const cycleGraph = await call("get_call_graph", { address: cycle[0].value });
const cycleNodes = Object.values(cycleGraph).flat();
assert.equal(cycleNodes.length, 2);
assert.deepEqual(
cycleNodes.map((node) => node.address).sort(),
cycle.map((item) => item.address).sort(),
);
for (const node of cycleNodes) {
assert.equal(node.status, "ok");
assert.deepEqual(
node.calls,
cycle
.filter((item) => item.address !== node.address)
.map((item) => item.address),
);
}
await invalid(
"trace_call_path",
{ start: "__rea_missing_procedure__", goal: "__rea_missing_procedure__" },
/Unknown Hopper address or name/u,
"procedure_address",
);
const missingPhrase = await call("trace_feature", {
query: "REA_C_LEAF __rea_missing_phrase__",
});
assert.deepEqual(missingPhrase.matches, []);
assert.deepEqual(missingPhrase.references, []);
const phrase = "REA boundary phrase";
try {
await call("set_address_name", { address, name: phrase });
const traced = await call("trace_feature", { query: phrase });
assert.deepEqual(traced.matches, [
{ type: "procedure", address, value: phrase },
]);
const spaced = await call("trace_feature", { query: ` ${phrase} ` });
assert.deepEqual(spaced.matches, []);
assert.deepEqual(spaced.references, []);
} finally {
await call("set_address_name", { address, name: originalName });
}
return {
canonicalGraphIdentities: true,
sourceOwnedCallCycle: true,
symbolAndInteriorGoalParity: true,
invalidSelfPathRejected: true,
completeLiteralQueryPreserved: true,
};
}
-2
View File
@@ -5,7 +5,6 @@ const shaSchema = z.string().regex(/^[a-f0-9]{40}$/u);
const versionSchema = z.object({ version: z.string() });
const manifestSchema = z.object({ ".": z.string() });
const sectionsSchema = z.object({
versioning: z.string().optional(),
"changelog-sections": z.array(
z.object({ type: z.string(), hidden: z.boolean().optional() }),
),
@@ -298,7 +297,6 @@ export async function inspectReleaseCheckpoint(git, options) {
).filter((commit) => commit.breaking || visibleTypes.has(commit.type));
const breaking = commits.filter((commit) => commit.breaking);
if (
configuration.versioning !== "always-bump-minor" &&
breaking.length > 0 &&
major(baselineVersion) > 0 &&
major(expectedVersion) <= major(baselineVersion)
-9
View File
@@ -1,9 +0,0 @@
import semver from "semver";
const version = process.argv[2];
if (version === undefined || semver.valid(version) !== version) {
console.error("Expected an exact SemVer version.");
process.exitCode = 1;
} else {
process.stdout.write(semver.prerelease(version) === null ? "latest" : "next");
}
+3 -8
View File
@@ -13,7 +13,6 @@ if (!executable)
const profile = await mkdtemp(join(tmpdir(), "rea-browser-dom-"));
const site = await startBrowserVerifierSite();
let context;
let report;
try {
context = await chromium.launchPersistentContext(profile, {
executablePath: executable,
@@ -44,15 +43,11 @@ try {
process.argv[2],
page.url(),
);
report = {
...result,
browser: context.browser().version(),
native_oracle: true,
verified: true,
};
process.stdout.write(
`${JSON.stringify({ ...result, browser: context.browser().version(), native_oracle: true, verified: true })}\n`,
);
} finally {
await context?.close();
await site.close();
await rm(profile, { recursive: true, force: true });
}
process.stdout.write(`${JSON.stringify(report)}\n`);
+1 -3
View File
@@ -2,7 +2,6 @@
import { access } from "node:fs/promises";
import { isAbsolute } from "node:path";
import { verifyBrowserSourceMap } from "./lib/browser-source-map-e2e.mjs";
import { verifyBrowserNestedSourceMaps } from "./lib/browser-nested-source-map-e2e.mjs";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
const executable = process.env.REA_BROWSER_EXECUTABLE;
if (!executable || !isAbsolute(executable))
@@ -12,7 +11,6 @@ if (!executable || !isAbsolute(executable))
await access(executable);
const run = createVerifierRun();
const proof = await verifyBrowserSourceMap(executable, process.argv[2]);
const nested = await verifyBrowserNestedSourceMaps(executable, process.argv[2]);
process.stdout.write(
`${JSON.stringify({ ...proof, nested_source_maps: nested, verifier_run: await completeVerifierRun(run), verified: true })}\n`,
`${JSON.stringify({ ...proof, verifier_run: await completeVerifierRun(run), verified: true })}\n`,
);
+19 -71
View File
@@ -27,14 +27,6 @@ for (const tool of ["clang", "lldb", "codesign", "nm"])
{ cause },
);
}
const developerMode = await exec("/usr/sbin/DevToolsSecurity", ["-status"]);
if (
!/enabled/iu.test(developerMode.stdout) ||
/disabled/iu.test(developerMode.stdout)
)
throw new Error(
"Native call observation verification requires macOS Developer Mode; DevToolsSecurity reports it disabled.",
);
const SOURCE = fileURLToPath(
new URL("../tests/conformance/native/calls.m", import.meta.url),
@@ -54,7 +46,6 @@ const INPUT = {
argument_registers: 4,
backtrace_frames: 1,
};
const CLI_TIMEOUT_MS = 180_000;
/** Pointer-free facts that two runs of the same scenario must share. */
const stable = (result) =>
@@ -77,23 +68,17 @@ const symbolAddress = async (binary, symbol) => {
};
const checkScenario = async (binary) => {
const result = await artifactCli(
"observe-native-calls",
binary,
[JSON.stringify(INPUT)],
{ timeoutMs: CLI_TIMEOUT_MS },
);
const result = await artifactCli("observe-native-calls", binary, [
JSON.stringify(INPUT),
]);
assert.equal(result.process.outcome, "exited");
assert.equal(result.process.exit_status, 0);
assert.equal(result.process.terminated, true);
assert.match(result.process.stdout.text, /^world x0 40\n/u);
assert.match(result.process.stdout.text, /marker observed\n$/u);
assert.equal(result.process.stdout.complete, true);
assert.equal(result.process.stderr.complete, true);
assert.deepEqual(result.coverage, {
status: "complete",
event_limit_reached: false,
resource_limit_reached: false,
unresolved_breakpoints: [],
});
const greet = result.events.filter(({ symbol }) => symbol === GREET);
@@ -130,59 +115,25 @@ const alive = (pid) => {
};
const checkBounds = async (binary) => {
const flooded = await artifactCli(
"observe-native-calls",
binary,
[
JSON.stringify({
breakpoints: [{ kind: "function", name: "rea_never_called" }],
arguments: ["--flood"],
duration_ms: 30_000,
}),
],
{ timeoutMs: CLI_TIMEOUT_MS },
);
assert.equal(flooded.process.outcome, "exited");
assert.equal(flooded.process.stdout.bytes, 2 * 1024 * 1024);
assert.equal(flooded.process.stderr.bytes, 2 * 1024 * 1024);
assert.equal(flooded.process.stdout.text.length, 1024 * 1024);
assert.equal(flooded.process.stderr.text.length, 1024 * 1024);
assert.equal(flooded.process.stdout.text, "O".repeat(1024 * 1024));
assert.equal(flooded.process.stderr.text, "E".repeat(1024 * 1024));
assert.equal(flooded.process.stdout.truncated, true);
assert.equal(flooded.process.stderr.truncated, true);
assert.equal(flooded.process.stdout.complete, true);
assert.equal(flooded.process.stderr.complete, true);
const limited = await artifactCli(
"observe-native-calls",
binary,
[
JSON.stringify({
breakpoints: [{ kind: "function", name: "nanosleep" }],
arguments: ["--wait"],
max_events: 2,
duration_ms: 30_000,
}),
],
{ timeoutMs: CLI_TIMEOUT_MS },
);
const limited = await artifactCli("observe-native-calls", binary, [
JSON.stringify({
breakpoints: [{ kind: "function", name: "nanosleep" }],
arguments: ["--wait"],
max_events: 2,
duration_ms: 30_000,
}),
]);
assert.equal(limited.process.outcome, "event-limit");
assert.equal(limited.events.length, 2);
assert.equal(limited.process.terminated, true);
assert.equal(alive(limited.process.pid), false);
const elapsed = await artifactCli(
"observe-native-calls",
binary,
[
JSON.stringify({
breakpoints: [{ kind: "function", name: "rea_call_add" }],
arguments: ["--wait"],
duration_ms: 1_500,
}),
],
{ timeoutMs: CLI_TIMEOUT_MS },
);
const elapsed = await artifactCli("observe-native-calls", binary, [
JSON.stringify({
breakpoints: [{ kind: "function", name: "rea_call_add" }],
arguments: ["--wait"],
duration_ms: 1_500,
}),
]);
assert.equal(elapsed.process.outcome, "duration-elapsed");
assert.equal(elapsed.events.length, 0);
assert.equal(alive(elapsed.process.pid), false);
@@ -199,10 +150,7 @@ const cliFailure = async (binary) => {
JSON.stringify(INPUT),
"--json",
],
{
env: { ...process.env, REA_LOG_LEVEL: "silent" },
timeout: CLI_TIMEOUT_MS,
},
{ env: { ...process.env, REA_LOG_LEVEL: "silent" }, timeout: 120_000 },
);
} catch (cause) {
return JSON.parse(cause.stdout);
+3 -14
View File
@@ -15,7 +15,6 @@ if (process.platform !== "darwin")
const root = await mkdtemp(join(tmpdir(), "rea-ui-fixture-"));
let child;
let fixturePid;
let report;
try {
const childRetrievalTest = join(root, "child-retrieval-test");
await promisify(execFile)("/usr/bin/xcrun", [
@@ -188,18 +187,9 @@ try {
);
if (mismatch.ok || !mismatch.error.message.includes("target-mismatch"))
throw new Error("Real helper did not reject changed target bytes");
report = {
ok: true,
verification_status: observation.ok ? "passed" : "permission-boundary-only",
positive_e2e: observation.ok,
observation: observation.ok
? "captured-selected-fixture-window"
: "os-permission-denial-verified",
scenario: scenarioStatus,
permission_failure: observation.ok ? null : observation.error.message,
mismatch_rejected: true,
target_owned: true,
};
process.stdout.write(
`${JSON.stringify({ ok: true, verification_status: observation.ok ? "passed" : "permission-boundary-only", positive_e2e: observation.ok, observation: observation.ok ? "captured-selected-fixture-window" : "os-permission-denial-verified", scenario: scenarioStatus, permission_failure: observation.ok ? null : observation.error.message, mismatch_rejected: true, target_owned: true })}\n`,
);
} finally {
if (fixturePid !== undefined) {
try {
@@ -215,4 +205,3 @@ try {
}
await rm(root, { recursive: true, force: true });
}
process.stdout.write(`${JSON.stringify(report)}\n`);
-2
View File
@@ -79,8 +79,6 @@ export async function verifyPackageEnvironment({
CLAUDE_CONFIG_DIR: home,
CODEX_HOME: codexDir,
COPILOT_HOME: join(home, ".copilot"),
GROK_HOME: join(home, ".grok"),
SAND_DATA_ROOT: join(home, ".grokbot"),
OPENCODE_CONFIG: undefined,
XDG_CONFIG_HOME: join(home, ".config"),
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
+10 -2
View File
@@ -6,6 +6,7 @@ import { exec, json, run } from "./lib/verify-package-core.mjs";
/** Install the packed CLI globally with and without optional dependencies and verify PTY degradation. */
export async function verifyPackageInstall({
root,
tarball,
prefix,
workspace,
@@ -13,7 +14,14 @@ export async function verifyPackageInstall({
}) {
await exec(
"npm",
["install", "--global", "--ignore-scripts", "--prefix", prefix, tarball],
[
"install",
"--global",
"--ignore-scripts",
"--prefix",
prefix,
join(root, tarball),
],
{ env: environment },
);
const cli = join(prefix, "bin", "rea");
@@ -53,7 +61,7 @@ export async function verifyPackageInstall({
"--omit=optional",
"--prefix",
noOptionalPrefix,
tarball,
join(root, tarball),
],
{ env: environment },
);
+9 -24
View File
@@ -1,4 +1,3 @@
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { CATALOG_IDENTITY } from "../dist/catalogIdentity.js";
@@ -7,23 +6,15 @@ import { verifyPackedBridge } from "./verify-packed-bridge.mjs";
/** Create a tarball and assert production packaging constraints. */
export async function verifyPackagePack({ root, workspace }) {
const packDirectory = join(workspace, "pack");
await mkdir(packDirectory);
const tarball = join(
packDirectory,
(
await exec(
"npm",
["pack", "--silent", "--pack-destination", packDirectory],
{
cwd: root,
},
)
).stdout.trim(),
);
const packedFiles = (await exec("tar", ["-tf", tarball])).stdout.split("\n");
const tarball = (
await exec("npm", ["pack", "--silent"], { cwd: root })
).stdout.trim();
const packedFiles = (
await exec("tar", ["-tf", join(root, tarball)])
).stdout.split("\n");
const packedManifest = JSON.parse(
(await exec("tar", ["-xOf", tarball, "package/package.json"])).stdout,
(await exec("tar", ["-xOf", join(root, tarball), "package/package.json"]))
.stdout,
);
if (
packedManifest.scripts?.postinstall !== undefined ||
@@ -42,16 +33,10 @@ export async function verifyPackagePack({ root, workspace }) {
throw new Error(
"package included authored skill sources instead of only the generated bundle",
);
if (
packedFiles.some((path) =>
/^package\/(?:dist|src)\/generatedMcpToolCatalog\./u.test(path),
)
)
throw new Error("package included the test-only generated MCP catalog");
const skill = (
await exec("tar", [
"-xOf",
tarball,
join(root, tarball),
"package/skills/reverse-engineer-anything/SKILL.md",
])
).stdout;
-2
View File
@@ -101,8 +101,6 @@ export async function verifyPackageUpdate({
CLAUDE_CONFIG_DIR: home,
CODEX_HOME: join(home, ".codex"),
COPILOT_HOME: join(home, ".copilot"),
GROK_HOME: join(home, ".grok"),
SAND_DATA_ROOT: join(home, ".grokbot"),
XDG_CONFIG_HOME: join(home, ".config"),
OPENCODE_CONFIG: join(home, ".config", "opencode", "opencode.json"),
npm_config_prefix: prefix,
+6 -40
View File
@@ -34,7 +34,6 @@ let tarball;
let mcpStartup;
let mcpModuleLoading;
let update;
let report;
try {
({ tarball } = await verifyPackagePack({ root, workspace }));
@@ -45,6 +44,7 @@ try {
referenceRoot,
});
const { cli, packageRunnerCli } = await verifyPackageInstall({
root,
tarball,
prefix: environmentData.prefix,
workspace,
@@ -58,7 +58,7 @@ try {
"node_modules",
PRODUCT_IDENTITY.packageName,
),
tarball,
tarball: join(root, tarball),
workspace,
environment: environmentData.environment,
});
@@ -133,44 +133,10 @@ try {
evidenceRoot,
artifactArchive,
});
report = {
cli: true,
analysisCli: true,
artifactCli: true,
managedCli: true,
managedReconstructionCli: true,
managedNativeVerificationCli: true,
managedApplicationGraphCli: true,
evidenceCli: true,
incurMcpCommand: PRODUCT_IDENTITY.mcpCommand,
lifecycleScriptsRequired: false,
doctor: "platform-appropriate",
setup: supportedSetupHost
? "planned-then-idempotent"
: "unsupported-host-rejected",
setupPlanReadOnly: supportedSetupHost,
existingHopperPreserved: supportedSetupHost,
clients: supportedSetupHost ? 4 : 0,
backupReadback: supportedSetupHost,
failureRecovery: supportedSetupHost,
configSymlinkLifecycle: supportedSetupHost,
skill: supportedSetupHost,
skillReferences: supportedSetupHost,
mcpTools: TOOL_CONTRACTS.length,
mcpPrompts: prompts.names.length,
promptCompletion: true,
promptCompletionLifecycle: true,
evidenceMcp: true,
targetFree: true,
targetLifecycle: true,
boundedRegexBridge: true,
update,
mcpStartup,
mcpModuleLoading,
};
process.stdout.write(
`${JSON.stringify({ verifier_run: await completeVerifierRun(verifierRun), cli: true, analysisCli: true, artifactCli: true, managedCli: true, managedReconstructionCli: true, managedNativeVerificationCli: true, managedApplicationGraphCli: true, evidenceCli: true, incurMcpCommand: PRODUCT_IDENTITY.mcpCommand, lifecycleScriptsRequired: false, doctor: "platform-appropriate", setup: supportedSetupHost ? "planned-then-idempotent" : "unsupported-host-rejected", setupPlanReadOnly: supportedSetupHost, existingHopperPreserved: supportedSetupHost, clients: supportedSetupHost ? 4 : 0, backupReadback: supportedSetupHost, failureRecovery: supportedSetupHost, configSymlinkLifecycle: supportedSetupHost, skill: supportedSetupHost, skillReferences: supportedSetupHost, mcpTools: TOOL_CONTRACTS.length, mcpPrompts: prompts.names.length, promptCompletion: true, promptCompletionLifecycle: true, evidenceMcp: true, targetFree: true, targetLifecycle: true, boundedRegexBridge: true, update, mcpStartup, mcpModuleLoading })}\n`,
);
} finally {
if (tarball) await rm(join(root, tarball), { force: true });
await rm(workspace, { recursive: true, force: true });
}
process.stdout.write(
`${JSON.stringify({ verifier_run: await completeVerifierRun(verifierRun), ...report })}\n`,
);
+2 -11
View File
@@ -127,6 +127,7 @@ const environment = {
process.env.ComSpec ??
join(process.env.SystemRoot ?? "C:\\Windows", "System32", "cmd.exe")
).replaceAll("\\", "/"),
REA_ANALYSIS_PROVIDER: "ghidra",
REA_LOG_LEVEL: "error",
GHIDRA_HEADLESS_MAXMEM: "512m",
NODE_OPTIONS: "--max-old-space-size=512",
@@ -290,17 +291,7 @@ try {
expected.every((name) => report.mcpOperations.includes(name)),
"Packaged verification omitted an admitted operation.",
);
const session = await call("binary_session", {});
assert.equal(
session.analysis_provider_binding?.selection_source,
"auto-single-candidate",
"Packaged MCP verification must exercise automatic provider selection.",
);
assert.equal(
session.analysis_provider_binding?.provider?.id,
"ghidra",
"The sole available Windows analysis provider must be Ghidra.",
);
await call("binary_session", {});
await call("close_binary", {});
await assertCleanup();
} finally {
+1 -1
View File
@@ -18,7 +18,7 @@ export async function verifyPackedBridge({
throw new Error("package omitted the Hopper bridge");
if (!packedFiles.includes(packedGhidraBridge))
throw new Error("package omitted the Ghidra bridge");
await exec("tar", ["-xf", tarball, "-C", workspace]);
await exec("tar", ["-xf", join(root, tarball), "-C", workspace]);
const ghidraSource = await readFile(
join(workspace, packedGhidraBridge),
"utf8",
+5 -21
View File
@@ -18,12 +18,9 @@ const version = process.argv[2];
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(version ?? ""))
throw new Error("Usage: node scripts/verify-published-package.mjs <version>");
const canaryRoot = await mkdtemp(join(tmpdir(), "rea-published-canary-"));
const serverEnvironment = {
...process.env,
npm_config_cache: join(canaryRoot, "npm-cache"),
};
const serverEnvironment = { ...process.env };
delete serverEnvironment.HOPPER_TARGET_PATH;
const canaryRoot = await mkdtemp(join(tmpdir(), "rea-published-canary-"));
const transport = new StdioClientTransport({
command: "npm",
args: [
@@ -94,29 +91,16 @@ process.stdout.write(
);
async function verifyPublishedUpdate({ version: targetVersion, canaryRoot }) {
const previousVersion = await findPreviousPublishedVersion(targetVersion);
const prefix = join(canaryRoot, "update-prefix");
const home = join(canaryRoot, "update-home");
const environment = {
...process.env,
HOME: home,
USERPROFILE: home,
APPDATA: join(home, "AppData", "Roaming"),
CLAUDE_CONFIG_DIR: home,
CODEX_HOME: join(home, ".codex"),
COPILOT_HOME: join(home, ".copilot"),
GROK_HOME: join(home, ".grok"),
SAND_DATA_ROOT: join(home, ".grokbot"),
XDG_CONFIG_HOME: join(home, ".config"),
OPENCODE_CONFIG: join(home, ".config", "opencode", "opencode.json"),
npm_config_cache: join(canaryRoot, "update-cache"),
npm_config_prefix: prefix,
PATH: `${join(prefix, "bin")}${delimiter}${process.env.PATH ?? ""}`,
};
const previousVersion = await findPreviousPublishedVersion(
targetVersion,
canaryRoot,
environment,
);
await execFileAsync(
"npm",
@@ -209,11 +193,11 @@ if (result.status === "failed") process.exitCode = 1;
};
}
async function findPreviousPublishedVersion(targetVersion, cwd, environment) {
async function findPreviousPublishedVersion(targetVersion) {
const { stdout } = await execFileAsync(
"npm",
["view", "rea-agents", "versions", "--json"],
{ cwd, env: environment, maxBuffer: 16 * 1024 * 1024 },
{ maxBuffer: 16 * 1024 * 1024 },
);
const versions = JSON.parse(stdout);
const previous = versions
+29 -33
View File
@@ -52,7 +52,6 @@ const profile = await mkdtemp(join(tmpdir(), "rea-real-browser-"));
const site = await startBrowserVerifierSite();
let browser;
let pageProxy;
let report;
try {
browser = spawn(
executable,
@@ -339,32 +338,35 @@ try {
target_id: target,
});
const moduleTrace = await verifyBrowserModules(executable);
report = {
browser: observed.value.browser.product,
endpoint,
target,
domNodes: observed.value.dom.nodes.length,
accessibilityNodes: observed.value.accessibility.nodes.length,
scripts: observed.value.scripts.items.length,
networkRequests: observed.value.network.requests.length,
consoleEvents: observed.value.console.events.length,
websocketEvents: observed.value.network.websocket_events.length,
bundleScripts: bundle.value.capture.scripts_analyzed,
sourceMaps: bundle.value.observations.source_maps.processed,
sessionEvents: session.value.timeline.length,
pageScopedTransport: true,
screenshotBytes: screenshot.value.artifact.bytes,
largeScreenshot,
popupEvents,
networkEvidence,
scriptExport,
moduleTrace,
domDestinations,
browserScenarioCli: true,
browserScenarioAttachCleanup: "disconnected-external",
browserScenarioLaunchCleanup: "terminated-owned-process",
verified: true,
};
process.stdout.write(
`${JSON.stringify({
verifier_run: await completeVerifierRun(verifierRun),
browser: observed.value.browser.product,
endpoint,
target,
domNodes: observed.value.dom.nodes.length,
accessibilityNodes: observed.value.accessibility.nodes.length,
scripts: observed.value.scripts.items.length,
networkRequests: observed.value.network.requests.length,
consoleEvents: observed.value.console.events.length,
websocketEvents: observed.value.network.websocket_events.length,
bundleScripts: bundle.value.capture.scripts_analyzed,
sourceMaps: bundle.value.observations.source_maps.processed,
sessionEvents: session.value.timeline.length,
pageScopedTransport: true,
screenshotBytes: screenshot.value.artifact.bytes,
largeScreenshot,
popupEvents,
networkEvidence,
scriptExport,
moduleTrace,
domDestinations,
browserScenarioCli: true,
browserScenarioAttachCleanup: "disconnected-external",
browserScenarioLaunchCleanup: "terminated-owned-process",
verified: true,
})}\n`,
);
} finally {
if (pageProxy !== undefined) await pageProxy.close();
if (browser !== undefined) await stopProcess(browser);
@@ -376,12 +378,6 @@ try {
retryDelay: 100,
});
}
process.stdout.write(
`${JSON.stringify({
verifier_run: await completeVerifierRun(verifierRun),
...report,
})}\n`,
);
async function verifyPageScopedTransport(provider, proxy, origin) {
const target = await pageTarget(provider, proxy.endpoint);
+2 -7
View File
@@ -255,7 +255,7 @@ export async function assertSession(
session.provider.version !== SUPPORTED_GHIDRA_VERSION ||
session.profile_digest !== expectedDigest ||
session.target.sha256 !== expectedTargetSha256 ||
session.read_only !== false ||
session.read_only !== true ||
session.analysis_complete !== true ||
session.analysis_timed_out !== false ||
session.capabilities.length !== GHIDRA_SESSION_CAPABILITIES.length ||
@@ -279,12 +279,7 @@ export function assertRuntimeCoordinates(coordinates) {
}
export async function assertCleanup(coordinates) {
for (const name of [
"runtime_root",
"endpoint_path",
"project_root",
"socket_root",
]) {
for (const name of ["runtime_root", "endpoint_path", "project_root"]) {
const path = coordinates[name];
if (typeof path === "string" && (await exists(path)))
throw new Error(`Ghidra cleanup left ${name}: ${path}`);
-3
View File
@@ -14,7 +14,6 @@ import { functionDossierSchema } from "../dist/domain/hopperValues.js";
import { inspectGhidraInstallation } from "../dist/ghidra/GhidraInstallation.js";
import { buildDosComFixture } from "../tests/conformance/ghidra/dos-com-fixture.mjs";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
import { verifyGhidraAddressBoundaries } from "./lib/real-ghidra-address-boundaries.mjs";
// This lane intentionally has no game paths, binary seeds or compiler dependency.
if (process.argv.length !== 2)
@@ -229,7 +228,6 @@ try {
);
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: "",
@@ -296,7 +294,6 @@ try {
cli_mcp_parity: true,
},
original_unchanged: true,
address_truncation_rejected: true,
limitations: image.limitations,
};
} catch (error) {
+4 -10
View File
@@ -24,7 +24,6 @@ import {
import { inspectGhidraInstallation } from "../dist/ghidra/GhidraInstallation.js";
import { buildDosMzFixture } from "../tests/conformance/ghidra/dos-mz-fixture.mjs";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
import { verifyGhidraAddressBoundaries } from "./lib/real-ghidra-address-boundaries.mjs";
// This lane intentionally has no game paths, binary seeds or compiler dependency.
if (process.argv.length !== 2)
@@ -100,7 +99,7 @@ try {
});
const target = await call("open_binary", {
path: targetPath,
provider_id: "ghidra",
provider: "ghidra",
});
opened = true;
assert.equal(target.format, "dos-mz");
@@ -368,11 +367,6 @@ try {
arguments: { procedure: "0xffffffffffffffff" },
});
assert.equal(invalid.isError, true, "Unmapped address did not fail");
await verifyGhidraAddressBoundaries(
client,
identities.entry.address,
cliEvidence,
);
const cliWindow = await cliEvidence("instructions", identities.entry.address);
assert.deepEqual(
cliWindow.normalized_result,
@@ -434,7 +428,6 @@ try {
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.",
@@ -569,8 +562,9 @@ async function evidenceCall(name, arguments_) {
normalized_result: result.structuredContent?.result,
});
assert.equal(evidence.subject.digest.sha256, sha256);
assert.equal(evidence.provider.id, "ghidra");
assertProfile(evidence.analysis_profile);
assert.equal(evidence.provider.id, "rea-workflow");
assert.equal(evidence.analysis_profile.provider.id, "rea-workflow");
assertProfile(evidence.analysis_profile.parameters.upstream_analysis_profile);
return evidence;
}
async function cliEvidence(command, procedure, options = []) {
+1 -1
View File
@@ -214,7 +214,7 @@ async function verifyTarget(path, sha256, fixtures, variant) {
});
const target = await call(client, "open_binary", {
path,
provider_id: "ghidra",
provider: "ghidra",
});
opened = true;
assert.equal(target.sha256, sha256);
+24 -25
View File
@@ -43,7 +43,6 @@ import {
} from "./verify-real-ghidra-function.mjs";
const exec = promisify(execFile);
const verifierRun = createVerifierRun();
let report;
const installDir = process.env.GHIDRA_INSTALL_DIR;
if (installDir === undefined || !isAbsolute(installDir))
throw new Error(
@@ -266,33 +265,33 @@ try {
const customPath = process.env.GHIDRA_TARGET_PATH;
const custom =
customPath === undefined ? null : await verifyTarget(customPath, "custom");
report = {
ok: true,
provider: { id: "ghidra", version: SUPPORTED_GHIDRA_VERSION },
verification_lane: aarch64JumpTableOnly
? "aarch64-jump-table"
: crossFormat
? "cross-format"
: "host-native",
fixture_sources:
crossFormat || aarch64JumpTableOnly
? [sourcePath, crossFormatSourcePath]
: [sourcePath],
fixtures: [debug, stripped, layout, ...crossResults].map(summary),
malformed_target: "rejected-before-provider-start",
native_api_cli: aarch64JumpTableOnly
? (crossResults[0]?.native_api_cli ?? null)
: debug.native_api_cli,
native_value_e2e: debug.native_values,
custom_target: custom === null ? null : summary(custom),
cleanup: "complete",
};
process.stdout.write(
`${JSON.stringify({
verifier_run: await completeVerifierRun(verifierRun),
ok: true,
provider: { id: "ghidra", version: SUPPORTED_GHIDRA_VERSION },
verification_lane: aarch64JumpTableOnly
? "aarch64-jump-table"
: crossFormat
? "cross-format"
: "host-native",
fixture_sources:
crossFormat || aarch64JumpTableOnly
? [sourcePath, crossFormatSourcePath]
: [sourcePath],
fixtures: [debug, stripped, layout, ...crossResults].map(summary),
malformed_target: "rejected-before-provider-start",
native_api_cli: aarch64JumpTableOnly
? (crossResults[0]?.native_api_cli ?? null)
: debug.native_api_cli,
native_value_e2e: debug.native_values,
custom_target: custom === null ? null : summary(custom),
cleanup: "complete",
})}\n`,
);
} finally {
await rm(fixtureRoot, { recursive: true, force: true });
}
process.stdout.write(
`${JSON.stringify({ verifier_run: await completeVerifierRun(verifierRun), ...report })}\n`,
);
function nativeFixtureTarget(platform, architecture) {
const supportedTargets = {
-416
View File
@@ -1,416 +0,0 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import {
access,
mkdtemp,
readFile,
realpath,
rm,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { constants } from "node:fs";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import Ajv from "ajv/dist/2020.js";
import addFormats from "ajv-formats";
import { mcpTextValue, requireMcpResult } from "./lib/mcp-verifier-results.mjs";
import { snapshotHopperRuntime } from "./lib/real-hopper-cleanup.mjs";
import { HOPPER_TARGET_LEASE_DIRECTORY } from "../dist/hopper/HopperTargetLease.js";
import { parseConfig } from "../dist/config.js";
const run = promisify(execFile);
const verifyFat64 = process.argv[2] === "--fat64";
assert.deepEqual(process.argv.slice(2), verifyFat64 ? ["--fat64"] : []);
if (process.platform !== "darwin" || !["arm64", "x64"].includes(process.arch))
throw new Error(
"Hopper FAT verification requires macOS arm64 or x64 and its installed clang/lipo toolchain",
);
const config = parseConfig(process.env);
if (!config.ok) throw config.error;
await access(config.value.hopperLauncherPath, constants.X_OK).catch((cause) => {
throw new Error(
`Hopper FAT verification requires the configured executable: ${config.value.hopperLauncherPath}`,
{ cause },
);
});
const toolPaths = {};
for (const command of ["clang", "lipo"])
toolPaths[command] = (
await run("xcrun", ["--find", command]).catch((cause) => {
throw new Error(`Hopper FAT verification requires ${command}`, { cause });
})
).stdout.trim();
const sdk = (
await run("xcrun", ["--sdk", "macosx", "--show-sdk-path"]).catch((cause) => {
throw new Error(
"Hopper FAT verification requires the installed macOS SDK",
{ cause },
);
})
).stdout.trim();
const directory = await realpath(await mkdtemp(join(tmpdir(), "hopper-fat-")));
const source = fileURLToPath(
new URL("../tests/conformance/c/fixture.c", import.meta.url),
);
const arm = join(directory, "c-arm64");
const intel = join(directory, "c-x86_64");
const fat = join(directory, "c-fat");
const fat64 = join(directory, "c-fat64");
const singleFat = join(directory, "c-single-fat");
const owned = new Set();
const extraFat64 = [];
const invalidFat64 = [];
let preparedImagePath;
const before = await snapshotHopperRuntime(
"/tmp",
HOPPER_TARGET_LEASE_DIRECTORY,
);
const client = new Client({ name: "hopper-fat-verifier", version: "1" });
const transport = new StdioClientTransport({
command: process.execPath,
args: ["dist/main.js"],
env: {
...process.env,
REA_ANALYSIS_PROVIDER: "hopper",
HOPPER_LOADER_ARGS_JSON: "[]",
},
stderr: "pipe",
});
transport.stderr?.on("data", (chunk) => process.stderr.write(chunk));
const ajv = new Ajv({ strict: false });
addFormats(ajv);
const observations = [];
try {
await run(toolPaths.clang, [
"-isysroot",
sdk,
"-arch",
"arm64",
"-O0",
"-fno-inline",
source,
"-o",
arm,
]);
await run(toolPaths.clang, [
"-isysroot",
sdk,
"-arch",
"x86_64",
"-O0",
"-fno-inline",
source,
"-o",
intel,
]);
await run(toolPaths.lipo, ["-create", arm, intel, "-output", fat]);
if (verifyFat64)
await run(toolPaths.lipo, [
"-create",
arm,
intel,
"-fat64",
"-output",
fat64,
]);
await run(toolPaths.lipo, [
"-create",
process.arch === "arm64" ? arm : intel,
"-output",
singleFat,
]);
if (verifyFat64) {
const original = await readFile(fat64);
const swapped = Buffer.from(original);
swapped.writeUInt32BE(0xbfbafeca, 0);
swapped.writeUInt32LE(original.readUInt32BE(4), 4);
for (let index = 0; index < original.readUInt32BE(4); index++) {
const offset = 8 + index * 32;
for (const field of [0, 4, 24, 28])
swapped.writeUInt32LE(
original.readUInt32BE(offset + field),
offset + field,
);
for (const field of [8, 16])
swapped.writeBigUInt64LE(
original.readBigUInt64BE(offset + field),
offset + field,
);
}
const little = join(directory, "c-fat64-little-endian");
await writeFile(little, swapped);
extraFat64.push(little);
const single = join(directory, "c-single-fat64");
await run(toolPaths.lipo, [
"-create",
process.arch === "arm64" ? arm : intel,
"-fat64",
"-output",
single,
]);
extraFat64.push(single);
for (const [name, reason, mutate] of [
[
"reserved",
/nonzero reserved field/u,
(bytes) => bytes.writeUInt32BE(1, 36),
],
[
"range",
/extends beyond the file/u,
(bytes) => bytes.writeBigUInt64BE(BigInt(bytes.length), 24),
],
[
"identity",
/disagrees with its Mach-O header/u,
(bytes) => bytes.writeUInt32BE(bytes.readUInt32BE(12) + 1, 12),
],
[
"overlap",
/slices overlap/u,
(bytes) => {
const cpu = process.arch === "arm64" ? 0x100000c : 0x1000007;
const base = bytes.readUInt32BE(8) === cpu ? 8 : 40;
bytes.copy(bytes, base === 8 ? 40 : 8, base, base + 32);
},
],
]) {
const bytes = Buffer.from(original);
mutate(bytes);
const path = join(directory, `c-fat64-invalid-${name}`);
await writeFile(path, bytes);
invalidFat64.push({ path, reason });
}
const hostBytes = await readFile(process.arch === "arm64" ? arm : intel);
const secondOffset = Math.ceil((16384 + hostBytes.length) / 16384) * 16384;
const ambiguous = Buffer.alloc(secondOffset + hostBytes.length);
ambiguous.writeUInt32BE(0xcafebabf, 0);
ambiguous.writeUInt32BE(2, 4);
for (const [index, offset] of [16384, secondOffset].entries()) {
const base = 8 + index * 32;
ambiguous.writeUInt32BE(hostBytes.readUInt32LE(4), base);
ambiguous.writeUInt32BE(hostBytes.readUInt32LE(8), base + 4);
ambiguous.writeBigUInt64BE(BigInt(offset), base + 8);
ambiguous.writeBigUInt64BE(BigInt(hostBytes.length), base + 16);
ambiguous.writeUInt32BE(14, base + 24);
hostBytes.copy(ambiguous, offset);
}
const path = join(directory, "c-fat64-ambiguous");
await writeFile(path, ambiguous);
invalidFat64.push({ path, reason: /selection.*ambiguous/u });
}
await client.connect(transport);
assert.ok(transport.pid !== null);
owned.add(transport.pid);
const catalog = await client.listTools();
const call = async (name, args = {}) => {
const reply = await client.callTool(
{ name, arguments: args },
{ timeout: 180_000 },
);
const result = requireMcpResult(reply, name);
const schema = catalog.tools.find(
(tool) => tool.name === name,
)?.outputSchema;
assert.ok(schema, `${name} omitted its output schema`);
assert.equal(
ajv.validate(schema, reply.structuredContent),
true,
JSON.stringify(ajv.errors),
);
return result;
};
const cliMappings = new Map();
for (const path of [
arm,
intel,
fat,
singleFat,
...(verifyFat64 ? [fat64, ...extraFat64] : []),
]) {
console.error(`Verifying Hopper source-file mappings for ${path}`);
const file = await readFile(path);
const sourceHash = createHash("sha256").update(file).digest("hex");
await call("open_binary", { path });
const procedures = await call("list_procedures");
const entry = procedures.find((item) => item.value.endsWith("rea_entry"));
assert.ok(entry, "compiler fixture omitted rea_entry");
for (const address of [
entry.address,
`0x${(BigInt(entry.address) + 1n).toString(16)}`,
]) {
const mapping = await call("address_to_file_offset", { address });
const bytes = await call("read_bytes", { address, length: 16 });
assert.equal(
mapping.file_offset,
mapping.image_base_file_offset + mapping.provider_file_offset,
);
assert.equal(mapping.source_path, path);
assert.equal(bytes.complete, true);
assert.equal(
file
.subarray(mapping.file_offset, mapping.file_offset + 16)
.toString("hex"),
bytes.bytes_hex,
);
if (
path === fat ||
path === singleFat ||
path === fat64 ||
extraFat64.includes(path)
) {
assert.ok(
mapping.image_base_file_offset > 0,
"FAT mapping omitted its physical slice offset",
);
assert.notEqual(
file
.subarray(
mapping.provider_file_offset,
mapping.provider_file_offset + 16,
)
.toString("hex"),
bytes.bytes_hex,
"fixture did not distinguish image-relative and original-file coordinates",
);
} else assert.equal(mapping.image_base_file_offset, 0);
if (path === fat || path === fat64) {
cliMappings.set(path, { mapping, address });
}
observations.push({ path, address, ...mapping, matched_bytes: 16 });
}
if (path === fat64 || extraFat64.includes(path)) {
const session = await call("binary_session");
assert.equal(session.path, path);
assert.equal(session.sha256, sourceHash);
const profile = session.analysis_provider_binding.analysis_profile;
assert.equal(
profile.parameters.prepared_image.method,
"fat64_thin_slice",
);
assert.equal(
profile.parameters.prepared_image.source_offset,
observations.at(-1).image_base_file_offset,
);
const document = await call("current_document");
await call("set_comment", {
address: entry.address,
comment: "source-bound FAT64 preparation",
});
await call("open_binary", { path });
assert.deepEqual(
(await call("binary_session")).analysis_provider_binding
.analysis_profile,
profile,
);
assert.equal(await call("current_document"), document);
assert.equal(
await call("comment", { address: entry.address }),
"source-bound FAT64 preparation",
);
}
assert.equal(
createHash("sha256")
.update(await readFile(path))
.digest("hex"),
sourceHash,
"native analysis modified source executable bytes",
);
await call("close_binary");
}
for (const { path, reason } of invalidFat64) {
const reply = await client.callTool({
name: "open_binary",
arguments: { path },
});
assert.equal(reply.isError, true, "malformed FAT64 unexpectedly launched");
assert.match(mcpTextValue(reply), reason);
}
if (verifyFat64) {
await call("open_binary", { path: fat64 });
await call("list_procedures");
const resources = [
...(await snapshotHopperRuntime(
"/tmp",
HOPPER_TARGET_LEASE_DIRECTORY,
owned,
)),
].filter((path) => !before.has(path));
for (const root of resources) {
const image = join(root, "image.macho");
if (
await access(image).then(
() => true,
() => false,
)
)
preparedImagePath = image;
}
assert.ok(
preparedImagePath,
"prepared native image was not owned by this session",
);
await client.close();
await transport.close();
await assert.rejects(access(preparedImagePath), { code: "ENOENT" });
}
for (const [path, expected] of cliMappings) {
const pending = run(
process.execPath,
[
"scripts/rea.mjs",
"address-to-file-offset",
path,
expected.address,
"--provider",
"hopper",
"--format",
"json",
],
{
timeout: 180_000,
env: { ...process.env, HOPPER_LOADER_ARGS_JSON: "[]" },
},
);
assert.ok(pending.child.pid !== undefined);
owned.add(pending.child.pid);
const cli = JSON.parse((await pending).stdout);
assert.deepEqual(cli.normalized_result, expected.mapping);
}
} finally {
await client.close();
await transport.close();
await rm(directory, { recursive: true, force: true });
}
const retained = [
...(await snapshotHopperRuntime(
"/tmp",
HOPPER_TARGET_LEASE_DIRECTORY,
owned,
)),
].filter((path) => !before.has(path));
assert.deepEqual(
retained,
[],
"FAT verification retained owned Hopper sessions or leases",
);
console.log(
JSON.stringify(
{
observations,
cliMcpParity: true,
fat64: verifyFat64 ? "verified" : "not_run",
malformedFat64Rejected: invalidFat64.length,
preparedImageRemovedOnMcpShutdown: preparedImagePath !== undefined,
cleanShutdown: true,
},
null,
2,
),
);
+32 -112
View File
@@ -1,5 +1,5 @@
import { execFile } from "node:child_process";
import { tmpdir } from "node:os";
import { readdir } from "node:fs/promises";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
@@ -12,10 +12,8 @@ import {
requirePseudocode,
} from "../dist/application/RealHopperAssertions.js";
import { HOPPER_PROVIDER_IDENTITY } from "../dist/hopper/HopperProvider.js";
import { HOPPER_TARGET_LEASE_DIRECTORY } from "../dist/hopper/HopperTargetLease.js";
import { REA_WORKFLOW_PROVIDER } from "../dist/application/InvestigationProviders.js";
import { loadRealHopperFixtureTargets } from "./lib/real-hopper-fixture.mjs";
import { verifyHopperStringObjects } from "./lib/real-hopper-search.mjs";
import {
requireBridgeDiagnostic,
requireBridgeProgress,
@@ -35,12 +33,7 @@ import { openAndVerifyLargeFixture } from "./lib/real-hopper-exhaustive-search.m
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
import { requireHopperSelection } from "./lib/real-hopper-selection.mjs";
import { verifyHopperInventories } from "./lib/real-hopper-inventory.mjs";
import {
verifyHopperBoundaryContracts,
verifyHopperLifecycleAndCli,
} from "./lib/real-hopper-boundaries.mjs";
import { startUnrelatedHopperSentinel } from "./lib/unrelated-hopper-sentinel.mjs";
import { snapshotHopperRuntime } from "./lib/real-hopper-cleanup.mjs";
const execFileAsync = promisify(execFile);
const verifierRun = createVerifierRun();
const timeout = 180_000;
@@ -54,12 +47,9 @@ const parseServerArgs = (encoded) => {
return parsed;
};
const runtimeParent = process.platform === "darwin" ? "/tmp" : tmpdir();
const sessionsBefore = await snapshotHopperRuntime(
runtimeParent,
HOPPER_TARGET_LEASE_DIRECTORY,
const sessionsBefore = new Set(
(await readdir("/tmp")).filter((name) => name.startsWith("rea-")),
);
const ownedProcessIds = new Set();
const textValue = mcpTextValue;
const requireSuccessfulTool = requireMcpResult;
@@ -251,7 +241,7 @@ let stderrBytes = 0;
const stderrChunks = [];
transport.stderr?.on("data", (chunk) => {
stderrBytes += chunk.length;
stderrChunks.push(chunk.toString("utf8"));
if (stderrBytes <= 16_384) stderrChunks.push(chunk.toString("utf8"));
});
const client = new Client({ name: "real-hopper-verifier", version: "1.0.0" });
const unrelatedHopper = await startUnrelatedHopperSentinel();
@@ -260,9 +250,6 @@ let summary;
try {
await client.connect(transport);
if (transport.pid === null)
throw new Error("The verifier did not capture its MCP process identity");
ownedProcessIds.add(transport.pid);
const progressUpdates = [];
const options = {
timeout,
@@ -309,7 +296,6 @@ try {
{ name: "list_documents", arguments: {} },
options,
);
requireSuccessfulTool(documents, "list_documents");
requireBridgeProgress(progressUpdates);
const rejectedProcedure = await client.callTool(
{
@@ -359,10 +345,6 @@ try {
requireSuccessfulTool(overview, "binary_overview"),
"binary_overview",
);
if (firstOverview.document !== currentDocument)
throw new Error(
"binary_overview did not identify the selected Hopper document",
);
const firstAnalysis = await verifyCurrentTarget(client, options);
const fixtureAnalysis = await verifyRealHopperFixture({
client,
@@ -371,12 +353,6 @@ try {
oracle: fixtureTargets.oracle,
normalizedResult: requireSuccessfulTool,
});
const boundaryContracts = await verifyHopperBoundaryContracts(
client,
options,
fixtureAnalysis.procedures.entry,
targetA,
);
const switched = await client.callTool(
{ name: "open_binary", arguments: { path: targetB } },
options,
@@ -405,24 +381,11 @@ try {
await client.callTool({ name: "list_documents", arguments: {} }, options),
"list_documents after target switch",
);
const secondDocument = await requireCurrentDocument(
client,
options,
documentsAfterTargetSwitch,
requireSuccessfulTool,
);
if (verifiedSecondOverview.document !== secondDocument)
throw new Error(
"binary_overview retained another document after target switch",
);
const expectedDocumentsAfterTargetSwitch = firstDocuments.length;
if (
!Array.isArray(documentsAfterTargetSwitch) ||
documentsAfterTargetSwitch.length !== expectedDocumentsAfterTargetSwitch
documentsAfterTargetSwitch.length !== firstDocuments.length + 1
)
throw new Error(
"Target switching did not replace the previous Hopper document",
);
throw new Error("A distinct target did not receive one Hopper document");
const reopenedTarget = await client.callTool(
{ name: "open_binary", arguments: { path: targetB } },
options,
@@ -459,47 +422,20 @@ try {
"Hopper's bundled MCP server was running during verification",
);
}
const diagnosticCount = requireSafeDiagnostics(stderrChunks);
requireBridgeDiagnostic(stderrChunks);
const closed = await client.callTool(
{ name: "close_binary", arguments: {} },
options,
);
if (closed.isError === true) throw new Error(textValue(closed));
let unicodeStrings = null;
if (fixtureTargets.unicode !== undefined) {
const call = async (name, args = {}) =>
requireSuccessfulTool(
await client.callTool({ name, arguments: args }, options),
name,
);
await call("open_binary", { path: fixtureTargets.unicode.path });
unicodeStrings = await verifyHopperStringObjects(call, [
{ value: "REA_UTF16_é_😀", encoding: "utf-16-le" },
{ value: 'REA_UTF16_ESCAPED_é_"\\line\nend\t\r', encoding: "utf-16-le" },
{ value: "\nREA_UTF16_LEADING_é", encoding: "utf-16-le" },
]);
await call("close_binary");
}
const lifecycleAndCli = await verifyHopperLifecycleAndCli(client, options, {
primary: targetA,
secondary: targetB,
ownedProcessIds,
});
const closedSession = requireMcpResult(
await fullSessionStatus(),
"binary_session",
);
if (closedSession.open !== false)
throw new Error("The real session remained open after close_binary");
const diagnosticCount = requireSafeDiagnostics(
stderrChunks,
[
"procedure_info",
"batch_decompile",
...boundaryContracts.rejectedOperations,
],
["HopperRemoteError", "HopperCancelledError", "AnalysisCancelledError"],
);
requireBridgeDiagnostic(stderrChunks);
summary = {
toolCount: actualNames.length,
@@ -508,9 +444,6 @@ try {
segmentCount: firstOverview.segment_count,
analyses: [firstAnalysis, secondAnalysis],
fixtureAnalysis,
unicodeStrings,
boundaryContracts,
lifecycleAndCli,
inventoryCounts,
largeInventory,
fixtureManifest: fixtureTargets.manifestPath,
@@ -539,48 +472,35 @@ try {
}
await new Promise((resolve) => setTimeout(resolve, 500));
const sessionsAfter = [
...(await snapshotHopperRuntime(
runtimeParent,
HOPPER_TARGET_LEASE_DIRECTORY,
ownedProcessIds,
)),
]
.filter((path) => !sessionsBefore.has(path))
.sort();
const sessionsAfter = (await readdir("/tmp")).filter(
(name) => name.startsWith("rea-") && !sessionsBefore.has(name),
);
if (sessionsAfter.length > 0) {
await writeVerificationReport({
...summary,
unrelatedHopperSurvived,
cleanShutdown: false,
retainedRuntimePaths: sessionsAfter,
});
throw new Error("The MCP runtime leaked a bridge session directory");
}
const afterCloseProcesses = await execFileAsync("ps", [
"-ax",
"-o",
"command=",
]);
if (
afterCloseProcesses.stdout
.split("\n")
.some((line) => line.includes("node dist/main.js"))
) {
throw new Error(
`The MCP runtime retained Hopper resources: ${sessionsAfter.join(", ")}`,
"The TypeScript MCP server remained alive after client close",
);
}
if (summary === undefined)
throw new Error("Real-Hopper verification did not produce a summary");
const completedVerifierRun = await completeVerifierRun(verifierRun);
if (
completedVerifierRun.process_lineage.status !== "verified" ||
completedVerifierRun.process_lineage.descendants.length !== 0
)
throw new Error(
"The verifier's process lineage did not prove owned child cleanup",
);
await writeVerificationReport({
verifier_run: completedVerifierRun,
...summary,
unrelatedHopperSurvived,
cleanShutdown: true,
});
async function writeVerificationReport(report) {
await new Promise((resolve, reject) => {
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`, (cause) => {
await new Promise((resolve, reject) => {
process.stdout.write(
`${JSON.stringify({ verifier_run: completedVerifierRun, ...summary, unrelatedHopperSurvived, cleanShutdown: true }, null, 2)}\n`,
(cause) => {
if (cause) reject(cause);
else resolve();
});
});
}
},
);
});
+2 -4
View File
@@ -10,7 +10,6 @@ const execute = promisify(execFile);
const root = resolve(import.meta.dirname, "..");
const temporary = await mkdtemp(join(tmpdir(), "rea-test-discovery-"));
let discovered;
try {
const output = join(temporary, "discovery.json");
await execute(
@@ -59,9 +58,8 @@ try {
) {
console.error(JSON.stringify(report, null, 2));
process.exitCode = 1;
} else discovered = report.discovered;
} else
console.log(`Discovered all ${report.discovered} test files exactly once.`);
} finally {
await rm(temporary, { recursive: true, force: true });
}
if (discovered !== undefined)
console.log(`Discovered all ${discovered} test files exactly once.`);
+3 -3
View File
@@ -59,6 +59,9 @@ try {
}
if (failures.length > 0) throw new Error(failures.join("\n\n"));
process.stdout.write(
"Test temporary-directory hygiene verified: no owned residues remain.\n",
);
} finally {
await rm(isolatedTemporaryRoot, {
recursive: true,
@@ -67,6 +70,3 @@ try {
retryDelay: 25,
});
}
process.stdout.write(
"Test temporary-directory hygiene verified: no owned residues remain.\n",
);
+14 -19
View File
@@ -27,7 +27,6 @@ const verifierRun = createVerifierRun();
const root = process.cwd();
const workspace = await mkdtemp(join(tmpdir(), "rea-windows-package-"));
const prefix = join(workspace, "prefix");
let report;
try {
const packed = JSON.parse(
@@ -108,13 +107,9 @@ try {
HOME: home,
USERPROFILE: home,
APPDATA: join(home, "AppData", "Roaming"),
CLAUDE_CONFIG_DIR: home,
CODEX_HOME: join(home, ".codex"),
XDG_CONFIG_HOME: join(home, ".config"),
OPENCODE_CONFIG: join(home, ".config", "opencode", "opencode.jsonc"),
COPILOT_HOME: join(home, ".copilot"),
GROK_HOME: join(home, ".grok"),
SAND_DATA_ROOT: join(home, ".grokbot"),
};
await mkdir(join(home, ".config", "opencode"), { recursive: true });
await writeFile(
@@ -217,23 +212,23 @@ try {
environment,
});
report = {
ok: true,
platform: process.platform,
package: packageResult.filename,
tools: toolCount,
ghidra_bridge: "present",
agent_setup: ["opencode", "vscode", "copilot_cli"],
setup_idempotent: true,
uninstall: true,
update,
};
process.stdout.write(
`${JSON.stringify({
verifier_run: await completeVerifierRun(verifierRun),
ok: true,
platform: process.platform,
package: packageResult.filename,
tools: toolCount,
ghidra_bridge: "present",
agent_setup: ["opencode", "vscode", "copilot_cli"],
setup_idempotent: true,
uninstall: true,
update,
})}\n`,
);
} finally {
await rm(workspace, { recursive: true, force: true });
}
process.stdout.write(
`${JSON.stringify({ verifier_run: await completeVerifierRun(verifierRun), ...report })}\n`,
);
function npm(arguments_, cwd) {
const npmExecPath = process.env.npm_execpath;
+3 -11
View File
@@ -21,7 +21,6 @@ await exec("/usr/bin/xcrun", ["--find", "actool"]);
await exec("/usr/bin/xcrun", ["--find", "clang"]);
await exec("/usr/bin/assetutil", ["--version"]);
const root = await mkdtemp(join(tmpdir(), "rea-asset-e2e-"));
let report;
try {
const app = join(root, "Fixture.app");
const resources = join(app, "Contents", "Resources");
@@ -99,16 +98,9 @@ try {
});
assert.equal(invalid.isError, true);
});
report = {
ok: true,
mocked: false,
cli: true,
stdio_mcp: true,
records: raw.length,
pagination: true,
malformed_input_rejected: true,
};
process.stdout.write(
`${JSON.stringify({ ok: true, mocked: false, cli: true, stdio_mcp: true, records: raw.length, pagination: true, malformed_input_rejected: true })}\n`,
);
} finally {
await rm(root, { recursive: true, force: true });
}
process.stdout.write(`${JSON.stringify(report)}\n`);
+3 -3
View File
@@ -142,7 +142,6 @@ const checkSwift = (metadata, mode) => {
};
const root = await mkdtemp(join(tmpdir(), "rea-dispatch-"));
let report;
try {
const results = [];
for (const mode of LINK_MODES) {
@@ -198,8 +197,9 @@ try {
});
}
}
report = { ok: true, target_executed: false, fixtures: results };
process.stdout.write(
`${JSON.stringify({ ok: true, target_executed: false, fixtures: results })}\n`,
);
} finally {
await rm(root, { recursive: true, force: true });
}
process.stdout.write(`${JSON.stringify(report)}\n`);
-223
View File
@@ -1,223 +0,0 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import { promisify } from "node:util";
import {
artifactCli,
artifactMcpResult,
withArtifactMcp,
} from "../../lib/artifact-e2e.mjs";
const exec = promisify(execFile);
const SEARCH_CASES = [
{
setting: "DYLD_FALLBACK_LIBRARY_PATH=",
image: "library",
found: "resolved",
missing: "undetermined",
foundCoverage: "partial",
missingCoverage: "partial",
rootSearch: true,
},
{
setting: "DYLD_FALLBACK_LIBRARY_PATH=:",
image: "library",
found: "resolved",
missing: "undetermined",
foundCoverage: "partial",
missingCoverage: "partial",
rootSearch: true,
},
{
setting: "DYLD_LIBRARY_PATH=",
image: "library",
found: "conditional",
missing: "undetermined",
foundCoverage: "partial",
missingCoverage: "partial",
rootSearch: true,
},
{
setting: "DYLD_FALLBACK_FRAMEWORK_PATH=",
image: "framework",
found: "resolved",
missing: "undetermined",
foundCoverage: "complete",
missingCoverage: "partial",
rootSearch: true,
},
{
setting: "DYLD_FRAMEWORK_PATH=",
image: "framework",
found: "conditional",
missing: "undetermined",
foundCoverage: "partial",
missingCoverage: "partial",
rootSearch: true,
},
{
setting: "DYLD_VERSIONED_LIBRARY_PATH=",
image: "library",
found: "resolved",
missing: "unresolved",
foundCoverage: "complete",
missingCoverage: "complete",
rootSearch: false,
},
{
setting: "DYLD_VERSIONED_LIBRARY_PATH=:",
image: "library",
found: "resolved",
missing: "unresolved",
foundCoverage: "complete",
missingCoverage: "complete",
rootSearch: false,
},
{
setting: "DYLD_VERSIONED_FRAMEWORK_PATH=",
image: "framework",
found: "resolved",
missing: "unresolved",
foundCoverage: "complete",
missingCoverage: "complete",
rootSearch: false,
},
{
setting: "DYLD_IMAGE_SUFFIX=:",
image: "library",
found: "resolved",
missing: "unresolved",
foundCoverage: "complete",
missingCoverage: "complete",
rootSearch: false,
},
{
setting: "DYLD_IMAGE_SUFFIX=:_debug:",
image: "library",
found: "conditional",
missing: "undetermined",
foundCoverage: "partial",
missingCoverage: "partial",
rootSearch: false,
},
];
const verifyTrace = async (program, expected) => {
const { setting, installName, status, coverage } = expected;
const trace = await artifactCli("trace-dylib-resolution", program);
const edge = trace.edges.find(
({ install_name }) => install_name === installName,
);
assert.equal(edge?.resolution.status, status, setting);
assert.equal(trace.coverage.status, coverage, setting);
assert.deepEqual(
trace.images.find(({ path }) => path === "program")?.slices[0]
?.dyld_environment,
[setting],
);
await withArtifactMcp(program, async (client) => {
assert.deepEqual(
await artifactMcpResult(client, "trace_dylib_resolution"),
trace,
);
});
};
const missingRuntime = async (program) => {
try {
await exec(program, [], {
env: { PATH: "/usr/bin:/bin", DYLD_PRINT_SEARCHING: "1" },
});
} catch (cause) {
assert.equal(typeof cause?.stderr, "string");
assert.ok(cause.stderr.includes("Library not loaded:"), cause.stderr);
return cause.stderr;
}
assert.fail(
"The required dependency was removed, but the executable launched",
);
};
const verifyCase = async (directory, source, sample) => {
const {
setting,
installName,
found,
missing,
foundCoverage,
missingCoverage,
rootSearch,
} = sample;
const library = join(directory, installName.slice("@loader_path/".length));
const program = join(directory, "program");
await mkdir(dirname(library), { recursive: true });
await exec("/usr/bin/xcrun", [
"clang",
"-dynamiclib",
source.library,
"-o",
library,
`-Wl,-install_name,${installName}`,
]);
await exec("/usr/bin/xcrun", [
"clang",
source.main,
library,
"-o",
program,
`-Wl,-dyld_env,${setting}`,
]);
await exec(program, [], { env: { PATH: "/usr/bin:/bin" } });
await verifyTrace(program, {
setting,
installName,
status: found,
coverage: foundCoverage,
});
await rm(library);
const searches = await missingRuntime(program);
const rootCandidate = installName.replace("@loader_path/", "/");
assert.equal(searches.includes(`"${rootCandidate}"`), rootSearch, searches);
await verifyTrace(program, {
setting,
installName,
status: missing,
coverage: missingCoverage,
});
};
/** Check empty embedded settings against real dyld search diagnostics and CLI/MCP results. */
export async function verifyDyldEnvironment(root) {
const directory = join(root, "dyld-environment");
await mkdir(directory);
const source = {
main: join(directory, "main.c"),
library: join(directory, "child.c"),
};
await writeFile(
source.main,
"int value(void); int main(void) { return value() == 7 ? 0 : 1; }",
);
await writeFile(source.library, "int value(void) { return 7; }");
// A per-workspace name avoids collisions with real files in filesystem root.
const name = `rea-${basename(root)}-child`;
const libraryName = `@loader_path/${name}.dylib`;
const frameworkName = `@loader_path/${name}.framework/${name}`;
for (const [index, { image, ...sample }] of SEARCH_CASES.entries()) {
await verifyCase(join(directory, String(index)), source, {
...sample,
installName: image === "framework" ? frameworkName : libraryName,
});
}
return {
settings: SEARCH_CASES.length,
dependency_states: ["present", "missing"],
cli: true,
stdio_mcp: true,
real_dyld: true,
};
}
+222
View File
@@ -0,0 +1,222 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { access, mkdtemp, open, realpath, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { DyldSharedCache } from "../../../dist/artifacts/apple/DyldSharedCacheReader.js";
import {
artifactCli,
artifactMcpResult,
withArtifactMcp,
} from "../../lib/artifact-e2e.mjs";
import {
buildMacosBundleFixture,
preflightMacosBundleFixture,
} from "../../fixtures/apple/macos-bundle.mjs";
const exec = promisify(execFile);
await preflightMacosBundleFixture();
try {
await exec("/usr/bin/xcrun", ["--find", "dyld_info"]);
} catch (cause) {
throw new Error(
"dyld shared cache verification requires dyld_info from Command Line Tools (xcrun --find dyld_info failed)",
{ cause },
);
}
/** The cache this host's dyld uses for native processes. */
const hostCache = async () => {
const names =
process.arch === "arm64"
? ["dyld_shared_cache_arm64e"]
: ["dyld_shared_cache_x86_64h", "dyld_shared_cache_x86_64"];
for (const directory of [
"/System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld",
"/System/Library/dyld",
])
for (const name of names)
try {
await access(join(directory, name));
return join(directory, name);
} catch {
// Try the next documented location.
}
throw new Error("No dyld shared cache was found in the documented locations");
};
/** `dyld_info -all_dyld_cache -linked_dylibs`: path -> "attributes|load path" lines. */
const dyldInfoImages = async () => {
const { stdout } = await exec(
"/usr/bin/xcrun",
["dyld_info", "-all_dyld_cache", "-linked_dylibs"],
{ maxBuffer: 256 * 1024 * 1024 },
);
const images = new Map();
let current;
for (const line of stdout.split("\n")) {
const head = /^(\/.+) \[(\S+)\]:$/u.exec(line);
if (head !== null) {
current = [];
images.set(head[1], current);
continue;
}
const dependency = /^\s{8}(.*?)\s*(\/\S.*)$/u.exec(line);
if (dependency !== null && current !== undefined)
current.push(
`${dependency[1].split(/\s+/u).filter(Boolean).sort().join(",")}|${dependency[2]}`,
);
}
return images;
};
const attributes = (dependency) =>
[
dependency.reexport ? "re-export" : null,
dependency.weak ? "weak-link" : null,
dependency.upward ? "upward" : null,
dependency.delayed_init ? "delay-init" : null,
]
.filter((value) => value !== null)
.sort()
.join(",");
const cachePath = await hostCache();
const root = await mkdtemp(join(tmpdir(), "rea-dyld-cache-"));
try {
const expected = await dyldInfoImages();
const cache = await DyldSharedCache.open(cachePath);
let compared = 0;
try {
assert.deepEqual(
cache.images.map(({ path }) => path).sort(),
[...expected.keys()].sort(),
"image list differs from dyld_info",
);
for (const [path, dependencies] of expected) {
const read = await cache.imageFacts(path);
assert.equal(read?.facts.status, "parsed", path);
assert.deepEqual(
read.facts.slices[0].dependencies.map(
(dependency) =>
`${attributes(dependency)}|${dependency.install_name}`,
),
dependencies,
`${path} dependencies differ from dyld_info`,
);
compared += 1;
}
} finally {
await cache.close();
}
const inspected = await artifactCli("inspect-dyld-shared-cache", cachePath, [
"--image",
"/usr/lib/libSystem.B.dylib",
]);
assert.equal(inspected.images_total, expected.size);
assert.equal(inspected.coverage.status, "complete");
assert.equal(inspected.inspected_images[0].status, "parsed");
// Check the producer header directly, independently of REA's header projection.
const handle = await open(cachePath, "r");
try {
const header = Buffer.alloc(0x1cc);
const { bytesRead } = await handle.read(header, 0, header.length, 0);
const multi = header.readBigUInt64LE(0x68) === 2n;
const hasSubtype =
bytesRead === header.length && header.readUInt32LE(0x10) >= header.length;
const subtype = multi && hasSubtype ? header.readUInt32LE(0x1c8) : null;
assert.deepEqual(
inspected.cache_subtype,
subtype === null
? null
: {
id: subtype,
name:
subtype === 0
? "development"
: subtype === 1
? "production"
: null,
},
"multi-cache subtype differs from the producer header",
);
} finally {
await handle.close();
}
await withArtifactMcp(null, async (client) => {
const viaMcp = await artifactMcpResult(
client,
"inspect_dyld_shared_cache",
{
cache_path: cachePath,
images: ["/usr/lib/libSystem.B.dylib"],
},
);
assert.deepEqual(viaMcp, inspected);
});
const { app } = await buildMacosBundleFixture(root);
const trace = await artifactCli("trace-dylib-resolution", app, [
"--shared-cache",
cachePath,
]);
assert.equal(trace.shared_cache.uuid, inspected.uuid);
const main = "Contents/MacOS/MacFixture";
const cached = trace.edges.filter(
({ root: edgeRoot, resolution }) =>
edgeRoot === main && resolution.status === "shared-cache",
);
assert.ok(
cached.some(
({ install_name: name }) => name === "/usr/lib/libSystem.B.dylib",
),
);
assert.ok(
trace.edges.every(({ install_name: name, resolution }) =>
name.startsWith("/usr/lib/") || name.startsWith("/System/")
? resolution.status === "shared-cache"
: true,
),
"a system install path was not found in the host cache",
);
const { stderr } = await exec(join(app, main), [], {
env: { PATH: "/usr/bin:/bin", DYLD_PRINT_LIBRARIES: "1" },
});
const loaded = new Set(
stderr
.split("\n")
.map((line) => /^dyld\[\d+\]: <[0-9A-F-]+> (.+)$/u.exec(line)?.[1])
.filter((path) => path !== undefined),
);
for (const { resolution } of cached)
assert.ok(
loaded.has(resolution.image),
`${resolution.image} was not loaded by dyld`,
);
process.stdout.write(
`${JSON.stringify({
ok: true,
mocked: false,
cli: true,
stdio_mcp: true,
cache: {
architecture: inspected.architecture,
os_version: inspected.os_version,
images: inspected.images_total,
type: inspected.cache_type,
subtype: inspected.cache_subtype,
},
images_matching_dyld_info: compared,
trace_shared_cache_edges: cached.length,
dyld_loaded_every_cached_edge: true,
host: await realpath(cachePath),
})}\n`,
);
} finally {
await rm(root, { recursive: true, force: true });
}
+15 -15
View File
@@ -21,7 +21,6 @@ const sourceRoot = fileURLToPath(
new URL("../../../tests/conformance/interface-builder/", import.meta.url),
);
let report;
try {
await mkdir(join(appPath, "Contents", "MacOS"), { recursive: true });
await mkdir(resourcesPath, { recursive: true });
@@ -84,20 +83,21 @@ try {
);
assert.equal(decoded.graph.truncated, false);
report = {
ok: true,
fixture: "compiled-appkit-nib",
provider: execution.value.provider,
target_sha256: decoded.target_sha256,
archive_sha256: decoded.documents[0].archive_sha256,
objects: decoded.documents[0].object_count,
connections: decoded.documents[0].connection_count,
recovered_control: button.name,
recovered_action: action.name,
hierarchy_complete: decoded.documents[0].hierarchy_complete,
truncated: decoded.graph.truncated,
};
process.stdout.write(
`${JSON.stringify({
ok: true,
fixture: "compiled-appkit-nib",
provider: execution.value.provider,
target_sha256: decoded.target_sha256,
archive_sha256: decoded.documents[0].archive_sha256,
objects: decoded.documents[0].object_count,
connections: decoded.documents[0].connection_count,
recovered_control: button.name,
recovered_action: action.name,
hierarchy_complete: decoded.documents[0].hierarchy_complete,
truncated: decoded.graph.truncated,
})}\n`,
);
} finally {
await rm(fixtureRoot, { recursive: true, force: true });
}
process.stdout.write(`${JSON.stringify(report)}\n`);
+3 -16
View File
@@ -17,7 +17,6 @@ if (process.platform !== "darwin")
"Real Foundation keyed archive verification requires macOS and Xcode Swift tools",
);
const root = await mkdtemp(join(tmpdir(), "rea-keyed-fixture-"));
let report;
try {
const archive = join(root, "model.plist");
await promisify(execFile)("/usr/bin/xcrun", [
@@ -151,21 +150,9 @@ try {
[{ source: null, path: ["UID"], target: 1, status: "resolved" }],
);
}
report = {
ok: true,
mocked: false,
cli: true,
stdio_mcp: true,
xml_golden: true,
uid_named_root: true,
format: graph.archive_format,
objects: graph.total_objects,
references: graph.total_references,
shared_identity: true,
cyclic_identity: true,
target_classes_instantiated_by_reader: false,
};
process.stdout.write(
`${JSON.stringify({ ok: true, mocked: false, cli: true, stdio_mcp: true, xml_golden: true, uid_named_root: true, format: graph.archive_format, objects: graph.total_objects, references: graph.total_references, shared_identity: true, cyclic_identity: true, target_classes_instantiated_by_reader: false })}\n`,
);
} finally {
await rm(root, { recursive: true, force: true });
}
process.stdout.write(`${JSON.stringify(report)}\n`);
+12 -15
View File
@@ -14,7 +14,6 @@ import {
buildMacosBundleFixture,
preflightMacosBundleFixture,
} from "../../fixtures/apple/macos-bundle.mjs";
import { verifyDyldEnvironment } from "./dyld-environment.mjs";
import { verifyDylibResolution } from "./macos-dylib-e2e.mjs";
const exec = promisify(execFile);
@@ -65,7 +64,6 @@ const EXPECTED_BUNDLES = [
["Contents/XPCServices/Svc.xpc", "xpc-service", "macos-deep", "."],
];
let report;
try {
const { app } = await buildMacosBundleFixture(root);
const zip = join(root, "MacFixture.zip");
@@ -154,20 +152,19 @@ try {
const { stdout: attached } = await exec("/usr/bin/hdiutil", ["info"]);
assert.ok(!attached.includes(dmg), "DMG remained attached after inventory");
const dylibs = await verifyDylibResolution(app);
const dyldEnvironment = await verifyDyldEnvironment(root);
report = {
ok: true,
mocked: false,
cli: true,
stdio_mcp: true,
containers: containers.map(({ format }) => format),
bundles: EXPECTED_BUNDLES.length,
dmg_detached: true,
dylib_resolution: dylibs,
dyld_environment: dyldEnvironment,
};
process.stdout.write(
`${JSON.stringify({
ok: true,
mocked: false,
cli: true,
stdio_mcp: true,
containers: containers.map(({ format }) => format),
bundles: EXPECTED_BUNDLES.length,
dmg_detached: true,
dylib_resolution: dylibs,
})}\n`,
);
} finally {
await rm(root, { recursive: true, force: true });
}
process.stdout.write(`${JSON.stringify(report)}\n`);
+11 -11
View File
@@ -34,7 +34,6 @@ const sampleRss = () => {
};
const sampler = setInterval(sampleRss, 25);
sampler.unref();
let finalReport;
try {
const evidence = createEvidence(
@@ -113,15 +112,17 @@ try {
assert.equal(digest, expectedHash.digest("hex"));
assert.equal((await stat(path)).size, expectedBytes);
sampleRss();
finalReport = {
json_output_verified: true,
format,
output_bytes: expectedBytes,
engine_string_limit: constants.MAX_STRING_LENGTH,
output_sha256: digest,
evidence_id: evidence.evidence_id,
rss: { method: "node-memoryUsage-rss-sampled", peak_bytes: peakRss },
};
process.stdout.write(
`${JSON.stringify({
json_output_verified: true,
format,
output_bytes: expectedBytes,
engine_string_limit: constants.MAX_STRING_LENGTH,
output_sha256: digest,
evidence_id: evidence.evidence_id,
rss: { method: "node-memoryUsage-rss-sampled", peak_bytes: peakRss },
})}\n`,
);
} finally {
clearInterval(sampler);
if (destination !== undefined && !destination.closed) {
@@ -131,4 +132,3 @@ try {
}
await rm(workspace, { recursive: true, force: true });
}
process.stdout.write(`${JSON.stringify(finalReport)}\n`);
+8 -8
View File
@@ -49,7 +49,6 @@ const call = async (name, args) => {
};
const fingerprint = (bytes) => createHash("sha256").update(bytes).digest("hex");
const counts = [];
let finalReport;
try {
const fixtures = await buildRecoveryFixtures(
root,
@@ -129,18 +128,19 @@ try {
});
}
}
finalReport = {
engine: "wakaru 1.13.0",
toolchains: fixtures.toolchains,
results: counts,
verified: true,
};
console.log(
JSON.stringify({
engine: "wakaru 1.13.0",
toolchains: fixtures.toolchains,
results: counts,
verified: 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) {
assert.deepEqual(result.options, {
+58 -56
View File
@@ -34,8 +34,6 @@ import {
const verifierRun = createVerifierRun();
const workspace = await mkdtemp(join(tmpdir(), "rea-managed-conformance-"));
let completionReportInput;
let finalReport;
const {
fixture,
fixtureBytes,
@@ -380,61 +378,65 @@ try {
const manifestSelfTest = await runManagedAppManifestSelfTest();
const operatorManifest = await runOptionalManagedAppManifest();
const ilspyOracle = await runOptionalIlspyOracle();
completionReportInput = {
modern,
framework,
nativeFunctionTarget,
readyToRun,
obfuscated,
left,
right,
nativeOnly,
malformed,
manifestSelfTest,
operatorManifest,
ilspyOracle,
};
finalReport = {
verified:
11 + (operatorManifest === null ? 0 : 1) + (ilspyOracle === null ? 0 : 1),
managedSurfaces: [
"inspect_managed_artifact",
"inspect_managed_members",
"inspect_managed_native_boundaries",
"compare_managed_members",
"verify_managed_native_boundaries",
"import_managed_reconstruction",
"project_managed_application_graph",
],
coverage: [
"modern-dotnet-anycpu",
"dotnet-framework-x86-pinvoke",
"x64-ready-to-run-native-body",
"managed-native-verification",
"managed-application-graph-projection",
"unicode-obfuscated-identifiers",
"decompiler-reconstruction-import",
"mvid-and-token-drift",
"not-managed",
"malformed-metadata",
"operator-local-managed-manifest",
...(ilspyOracle === null ? [] : ["ilspy-reconstruction-oracle"]),
],
managedAppManifest: {
env: "REA_MANAGED_APP_MANIFEST_PATH",
selfTest: manifestSelfTest,
operator: operatorManifest ?? { configured: false },
const completionReport = createManagedCompletionReport(
{
modern,
framework,
nativeFunctionTarget,
readyToRun,
obfuscated,
left,
right,
nativeOnly,
malformed,
manifestSelfTest,
operatorManifest,
ilspyOracle,
},
ilspyOracle: ilspyOracle ?? {
env: "REA_ILSPY_CMD_PATH",
configured: false,
},
};
await completeVerifierRun(verifierRun),
);
process.stdout.write(
`${JSON.stringify({
verified:
11 +
(operatorManifest === null ? 0 : 1) +
(ilspyOracle === null ? 0 : 1),
managedSurfaces: [
"inspect_managed_artifact",
"inspect_managed_members",
"inspect_managed_native_boundaries",
"compare_managed_members",
"verify_managed_native_boundaries",
"import_managed_reconstruction",
"project_managed_application_graph",
],
coverage: [
"modern-dotnet-anycpu",
"dotnet-framework-x86-pinvoke",
"x64-ready-to-run-native-body",
"managed-native-verification",
"managed-application-graph-projection",
"unicode-obfuscated-identifiers",
"decompiler-reconstruction-import",
"mvid-and-token-drift",
"not-managed",
"malformed-metadata",
"operator-local-managed-manifest",
...(ilspyOracle === null ? [] : ["ilspy-reconstruction-oracle"]),
],
managedAppManifest: {
env: "REA_MANAGED_APP_MANIFEST_PATH",
selfTest: manifestSelfTest,
operator: operatorManifest ?? { configured: false },
},
ilspyOracle: ilspyOracle ?? {
env: "REA_ILSPY_CMD_PATH",
configured: false,
},
completionReport,
})}\n`,
);
} finally {
await rm(workspace, { recursive: true, force: true });
}
finalReport.completionReport = createManagedCompletionReport(
completionReportInput,
await completeVerifierRun(verifierRun),
);
process.stdout.write(`${JSON.stringify(finalReport)}\n`);
+1 -1
View File
@@ -31,7 +31,7 @@ When tools are absent or registration is stale:
`npx -y rea-agents@latest doctor --client codex --json`. Substitute the current
supported client: `claude_code`, `claude_desktop`, `codex`, `cursor`,
`gemini_cli`, `windsurf`, `devin`, `opencode`, `antigravity`, `copilot_cli`,
`commandcode`, `vscode`, `grok_build`, or `grok_bot`. If the client is unknown, use `doctor --json` and
`commandcode`, or `vscode`. If the client is unknown, use `doctor --json` and
inspect its registration results before choosing a setup scope.
2. Distinguish the reason. Missing, malformed, or stale registration needs a
scoped configuration repair. An aligned registration with no tools in the
@@ -103,8 +103,10 @@ 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.
`architecture`. Pass `shared_cache` (the host cache or one from an IPSW) to
resolve system paths to `shared-cache`; without it they stay undetermined.
Use `inspect_dyld_shared_cache` with that cache path to list its images and read
a cached library's dependencies without an open target.
Use `extract_artifact` when materialized files are needed. It takes no arguments
and materializes all regular files into a fresh temporary directory chosen by
+74 -12
View File
@@ -3,11 +3,13 @@ import type {
AnalysisOperation,
AnalysisOperationPort,
} from "./AnalysisProvider.js";
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
import {
AnalysisCapabilityUnavailableError,
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import { type AnalysisError } from "../domain/analysisErrorBase.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { analysisBookmarkSchema } from "../domain/hopperValues.js";
import { resolveAnalysisDocument } from "./AnalysisDocument.js";
import { parseDocuments } from "../domain/hopperValues.js";
import { err, ok, type Result } from "../domain/result.js";
type Facet =
@@ -86,11 +88,7 @@ export const inspectAddressContext = async (
input: { readonly address: string; readonly document?: string | undefined },
signal?: AbortSignal,
): Promise<Result<JsonValue, AnalysisError>> => {
const document = await resolveAnalysisDocument(
analysis,
input.document,
signal,
);
const document = await resolveDocument(analysis, input.document, signal);
if (!document.ok) return document;
const args = parameters(document.value, input.address);
const operations = [
@@ -134,6 +132,62 @@ export const inspectAddressContext = async (
});
};
const resolveDocument = async (
analysis: AnalysisOperationPort,
document: string | undefined,
signal: AbortSignal | undefined,
): Promise<Result<string, AnalysisError>> => {
if (document !== undefined) return ok(document);
const current = await analysis.execute(
"current_document",
{},
executionOptions(signal),
);
if (!current.ok) {
if (!(current.error instanceof AnalysisCapabilityUnavailableError))
return current;
const listed = await analysis.execute(
"list_documents",
{},
executionOptions(signal),
);
if (!listed.ok) return listed;
const parsed = parseDocuments(listed.value.result);
if (!parsed.ok)
return err(
new AnalysisOutputError(
"list_documents",
"expected one document identity when resolving a headless provider",
{ cause: parsed.error },
),
);
if (parsed.value.length !== 1)
return err(
new AnalysisOutputError(
"list_documents",
`expected exactly one document identity for a provider without current-document selection, received ${parsed.value.length}`,
),
);
const [documentName] = parsed.value;
if (documentName === undefined)
return err(
new AnalysisOutputError(
"list_documents",
"provider returned no document identity",
),
);
return ok(documentName);
}
return typeof current.value.result === "string"
? ok(current.value.result)
: err(
new AnalysisOutputError(
"current_document",
"expected a document identity string",
),
);
};
const facet = (
result: Result<AnalysisExecution, AnalysisError> | undefined,
operation: AnalysisOperation,
@@ -189,17 +243,25 @@ const matchingBookmarks = (value: JsonValue, address: string): Facet => {
remediation:
"Retry list_bookmarks directly and report the provider output shape if it remains malformed.",
};
const parsed = analysisBookmarkSchema.array().safeParse(value);
if (!parsed.success)
if (!value.every(isBookmark))
return {
state: "unavailable",
reason:
"list_bookmarks returned malformed entries; expected an address string and a string or null name.",
"list_bookmarks returned malformed entries; expected address and name strings.",
remediation:
"Retry list_bookmarks directly and report the provider output shape if it remains malformed.",
};
return {
state: "available",
value: parsed.data.filter((bookmark) => bookmark.address === address),
value: value.filter((bookmark) => bookmark.address === address),
};
};
const isBookmark = (
value: JsonValue,
): value is { readonly address: string; readonly name: string } =>
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
typeof value.address === "string" &&
typeof value.name === "string";
-72
View File
@@ -1,72 +0,0 @@
import type { AnalysisOperationPort } from "./AnalysisProvider.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { parseDocuments } from "../domain/hopperValues.js";
import { err, ok, type Result } from "../domain/result.js";
const executionOptions = (signal: AbortSignal | undefined) =>
signal === undefined ? undefined : { signal };
/** Resolve an explicit or provider-selected document without guessing among candidates. */
export const resolveAnalysisDocument = async (
analysis: AnalysisOperationPort,
document: string | undefined,
signal: AbortSignal | undefined,
): Promise<Result<string, AnalysisError>> => {
if (document !== undefined) return ok(document);
if (signal?.aborted === true)
return err(new AnalysisCancelledError("current_document"));
const current = await analysis.execute(
"current_document",
{},
executionOptions(signal),
);
if (!current.ok) {
if (!(current.error instanceof AnalysisCapabilityUnavailableError))
return current;
const listed = await analysis.execute(
"list_documents",
{},
executionOptions(signal),
);
if (!listed.ok) return listed;
const parsed = parseDocuments(listed.value.result);
if (!parsed.ok)
return err(
new AnalysisOutputError(
"list_documents",
"expected one document identity when resolving a headless provider",
{ cause: parsed.error },
),
);
if (parsed.value.length !== 1)
return err(
new AnalysisOutputError(
"list_documents",
`expected exactly one document identity for a provider without current-document selection, received ${parsed.value.length}`,
),
);
const [documentName] = parsed.value;
if (documentName === undefined || documentName.length === 0)
return err(
new AnalysisOutputError(
"list_documents",
"provider returned no document identity",
),
);
return ok(documentName);
}
return typeof current.value.result === "string" &&
current.value.result.length > 0
? ok(current.value.result)
: err(
new AnalysisOutputError(
"current_document",
"expected a nonempty document identity string",
),
);
};
-3
View File
@@ -14,7 +14,6 @@ import { jsonValueSchema } from "../domain/jsonValue.js";
import type { ProgressReporter } from "./ProgressReporter.js";
import type { ProviderRejectionCode } from "../contracts/providerSelection.js";
import type { ProcessLineageObservation } from "../process/ProcessOwnership.js";
import type { ProviderOperationHealth } from "../domain/providerOperationHealth.js";
export interface ExecutionOptions {
readonly signal?: AbortSignal;
@@ -100,8 +99,6 @@ export interface AnalysisClient extends AnalysisOperationPort {
runtimeLineageSnapshots?(): readonly ProviderRuntimeLineageSnapshot[];
/** Current provider work that may outlive a timed-out or cancelled caller. */
requestActivitySnapshots?(): readonly ProviderRequestActivitySnapshot[];
/** Current operational health for the active provider bridge. */
operationHealthSnapshot?(): ProviderOperationHealth | undefined;
/** Close with a typed result when the provider can verify cleanup. */
closeWithOutcome?(
options?: Pick<ExecutionOptions, "progress"> & {

Some files were not shown because too many files have changed in this diff Show More