Compare commits

..
Author SHA1 Message Date
morluto ff5af6a7cf fix(plugins): resolve review findings and integrate main 2026-10-14 07:51:46 +08:00
morluto 3f17e75b4b test(release): split plugin conformance groups 2026-10-14 03:02:59 +08:00
morluto 7cb6c30039 fix(deps): keep package lockfiles aligned 2026-10-14 03:01:07 +08:00
morluto 9544c80eba fix(release): align shrinkwrap and release lock checks 2026-10-14 02:59:41 +08:00
morlutoandGitHub f45cb4b92a Merge branch 'main' into codex/review-fixes-pr-1977 2026-10-14 02:45:55 +08:00
OpenAI Codex bc6eb789ce fix(plugins): publish npm source dependencies 2026-10-14 02:23:13 +08:00
morluto 9de80b4deb fix(plugins): separate source and package launcher modules 2026-10-14 01:34:42 +08:00
morluto a85bfed8dc fix(plugins): validate Codex companion launch metadata 2026-10-14 01:23:15 +08:00
morluto b7dabdbc4f chore(licenses): refresh npm dependency inventory 2026-10-13 23:36:26 +08:00
OpenAI Codex 9d22eea7d2 fix(plugins): stop fallback server process trees 2026-10-13 23:33:15 +08:00
morluto 45bae55787 fix(plugins): load Codex MCP server from root config 2026-10-13 23:13:34 +08:00
morluto 987cb3b96b chore: sync captured review base for PR 2815 2026-10-13 23:09:50 +08:00
morluto 961f410094 chore(plugins): sync the captured final review base 2026-10-13 21:50:53 +08:00
morluto 802a1b0b75 test(plugins): guard packaged launcher ownership and checkout identity 2026-10-13 21:47:29 +08:00
morluto 42ce2c75ab fix(plugin): keep the shared launcher owner outside shipped source trees 2026-10-13 21:47:15 +08:00
morluto 3206274e0b fix(plugins): reuse the portable Node package-runner bootstrap 2026-10-13 21:45:00 +08:00
morluto 3457e65be5 docs(plugins): preserve contributor readiness and recovery guidance 2026-10-13 21:41:20 +08:00
morluto 11404c05f4 fix(plugins): distribute canonical skills from the exact npm release 2026-10-13 21:41:20 +08:00
GamerDubzandClaude Opus 5.5 a74d0502b9 docs(skill): use the plugin's pinned version for package-runner checks
The plugin recovery section left the package-runner version to the skill's
generic @latest fallback, so a doctor run could describe a newer release
than the server an installed plugin starts. Tell the agent to use the
version pinned in the plugin manifest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:16:31 +02:00
GamerDubzandClaude Opus 5.5 351ad8282a docs(plugin): check engine readiness through the connected plugin server
The plugin guidance said a package-runner doctor report describes the
server the plugin starts. Doctor reads the invoking shell's environment,
while the plugin server inherits the client's, so the two can disagree
about settings such as GHIDRA_INSTALL_DIR. Point plugin users and the
bundled recovery guide at binary_session tool availability, and say what
the terminal check does and does not cover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:09:04 +02:00
morluto 07680f66e2 fix(release): preserve contributor version-block conformance checks 2026-10-13 20:56:14 +08:00
morluto eaa9fe5cd5 test(release): validate every owned documentation version block 2026-10-13 20:55:14 +08:00
GamerDubzandClaude Opus 5.5 9d658d767a test(release): check every versioned example block in the installation guide
The conformance test read only the first Release Please version block, so
adding the plugin readiness block ahead of the MCP Registry example left
the registry example unchecked. Check every block, and require each known
example to sit inside exactly one, so a stale version or a lost marker in
any of them fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:52:24 +02:00
morluto 22de1ff010 chore(plugin): reconcile main while retaining release decisions 2026-10-13 20:31:51 +08:00
GamerDubzandClaude Opus 5.5 2fb79a99c8 fix(release): keep the launched package name when comparing plugin manifests
The version-only comparison blanked the whole launch argument, so a release
that swapped rea-agents for another package passed the notes check as a
version bump. Mask only the version after the last "@" and test a changed
package name.

The plugin readiness command in the installation guide now uses the pinned
release version, so its report describes the server the plugin starts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 08:51:56 +02:00
GamerDubzandClaude Opus 5.5 2da5e86289 fix(release): allow only version changes to plugin manifests after the notes target
The publication history check admitted the plugin manifests whole, so a
release could change plugin behavior, such as the bundled skill path or the
server environment, and still pass as release-only. Compare each manifest
across the release with its version and pinned server launch blanked, and
treat any other difference as a product change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:25:24 +02:00
GamerDubz 6ebae721f6 Merge remote-tracking branch 'origin/main' into feat/claude-code-plugin
# Conflicts:
#	docs/installation.md
2026-10-12 18:12:04 +02:00
GamerDubz 677997bd59 Merge remote-tracking branch 'origin/main' into feat/claude-code-plugin 2026-10-12 15:06:48 +02:00
GamerDubzandClaude Opus 5.5 b22d5245b1 fix(release): require the full package specifier in plugin launch validation
The checkpoint stripped an optional rea-agents@ prefix before comparing,
so a manifest launching a bare release number passed even though npx
would fetch a package named after that number. Compare the whole
specifier and report the launched value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:48:37 +02:00
GamerDubzandClaude Opus 5.5 5806c94008 fix(release): reject release candidates with stale plugin manifests
The publication checkpoint validated package, lockfile, server and
generated metadata versions but not the plugin manifests, so a candidate
could be tagged with a manifest version or pinned server launch left at
the previous release. Validate both fields in both manifests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:31:47 +02:00
GamerDubzandClaude Opus 5.5 c224ce482a docs(skill): make connection recovery aware of plugin installs
The bundled skill said installing it never registers the MCP server and
sent every missing-tools case to rea doctor --client and rea setup. For a
plugin-only install those report missing and setup would add a second rea
server. Tell the agent how to recognise a plugin install and to use the
client's own plugin and MCP listings instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:07:49 +02:00
GamerDubzandClaude Opus 5.5 102661ca6f fix(plugin): pin the plugin server launch to the release version
Both manifests started `rea-agents@latest`, so a server start could run a
different package version from the installed plugin and bundled skill, and
would execute whatever npm's latest tag pointed at. Pin the launch to the
manifest version and have Release Please rewrite it with each release.

Release Please reserializes JSON extra-files with JSON.stringify, which the
formatter would reject in every release PR. Store both manifests in that
form, exempt them from the formatter, and assert the form in conformance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:50:02 +02:00
GamerDubzandClaude Opus 5.5 2f134aaa7e docs(plugin): run the plugin readiness check through npx
A plugin-only install has no rea executable on PATH, so the documented
rea doctor command failed with "command not found". Show the package
runner form instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:09:56 +02:00
GamerDubzandClaude Opus 5.5 e8e265a0bc docs(plugin): tell plugin users how to check readiness without rea setup
rea doctor's client and skill checks only read state that rea setup
manages, so a plugin-only install reports missing and recommends the
second registration the plugin section warns against. Say so, and point
plugin users at the client's own plugin listing and provider-scoped
doctor checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:00:14 +02:00
GamerDubzandClaude Opus 5.5 d3359ebef0 fix(plugin): give the Codex plugin server REA's 30-second startup timeout
The Codex manifest left startup_timeout_sec at the client default, unlike
the registration rea setup writes, so a cold npx start could be cut off
before tools appeared. Carry the owned timeout in the Codex server entry
and assert it against MCP_STARTUP_POLICY.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:50:28 +02:00
GamerDubzandClaude Opus 5.5 2aea370e1c fix(plugin): declare the capabilities Codex plugin validation requires
Codex's plugin validator rejects a manifest whose interface omits
capabilities. Declare them in .codex-plugin/plugin.json and assert the
required interface fields in the release configuration test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:37:44 +02:00
GamerDubzandClaude Opus 5.5 a34e48b35b test(release): cover plugin manifests in the publication history check
The release-only allowlist gained both plugin manifests without a test
naming them. The publication history test now commits both manifests in
the release commit, so it fails with "Product changed after notes target"
if either path is dropped from the allowlist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:37:10 +02:00
GamerDubz d206a19c7d Merge remote-tracking branch 'origin/main' into feat/claude-code-plugin 2026-10-12 05:18:13 +02:00
GamerDubzandClaude Opus 5.5 15a5e08338 fix(release): treat the plugin manifests as release-version metadata
Release PRs now bump .claude-plugin/plugin.json and .codex-plugin/plugin.json,
but the publication history check rejected them as post-notes product
changes and the release configuration test still expected the previous
extra-files list. Admit both manifests in the release-only allowlist, expect
them in the Release Please configuration, and assert that each manifest
version matches package.json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:07:33 +02:00
GamerDubzandClaude Opus 5.5 4c945f570d feat(plugin): add a Codex plugin manifest and self-hosted marketplaces
Add .codex-plugin/plugin.json so Codex loads REA with its own display
metadata, and make the repository a single-plugin marketplace for both
clients through .agents/plugins/marketplace.json and
.claude-plugin/marketplace.json. Users can now add morluto/rea as a
marketplace and install the plugin without waiting for a directory listing.

Release Please bumps the Codex manifest version alongside the Claude one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:01:17 +02:00
GamerDubzandClaude Opus 5.5 6649e8e7ba feat(plugin): make the repository installable as a Claude Code plugin
Add .claude-plugin/plugin.json at the repository root so Claude Code plugin
directories and marketplaces can list REA. The manifest bundles the authored
reverse-engineer-anything skill from .agents/skills/ and registers the
existing stdio server through npx; it adds no second distribution artifact.

Release Please now bumps the manifest version with each release, which is
when installed plugins refresh the bundled skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:53:19 +02:00
103 changed files with 5527 additions and 2705 deletions
+21
View File
@@ -0,0 +1,21 @@
{
"name": "rea",
"interface": {
"displayName": "REA"
},
"plugins": [
{
"name": "rea",
"source": {
"source": "npm",
"package": "rea-agents",
"version": "6.4.0"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Developer Tools"
}
]
}
@@ -2,7 +2,7 @@
name: reverse-engineer-anything
description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
metadata:
version: "42"
version: "41"
---
# REA
@@ -17,6 +17,9 @@ readiness or provider commands.
Installing this skill supplies instructions; it does not register the REA MCP
server, install analysis engines, or add tools to an already-running agent.
The exception is a Claude Code or Codex plugin install: the plugin bundles
this skill and registers its own `rea` server, outside the state that
`rea setup` and `rea doctor` manage.
If REA tools are available and their registration is not known to be stale,
proceed directly to the target. Do not run diagnostics or setup before every
@@ -104,10 +107,6 @@ explicit path or endpoint and do not need it.
consult the NativeAOT workflow in `references/native-and-artifacts.md` for
optional metadata recovery and its supported host/layout boundary.
- User-owned browser page already open: `list_browser_targets`.
- Browser action the agent must perform (click, type, navigate):
`capture_browser_scenario` with declared actions. See
[runtime observation](references/runtime-observation.md) for connect/launch
and the focused follow-up browser tools.
- Fiddler Everywhere traffic or a native `.saz` capture: read the
[Fiddler workflow](references/runtime-observation.md#fiddler-everywhere-traffic-debugging).
Inspect saved SAZ with `inspect_web_network_capture` and `format: "saz"`;
@@ -327,8 +326,7 @@ as complete while required questions remain open.
[references/javascript-applications.md](references/javascript-applications.md)
- Android APK declarations, classes, methods, and static references:
[references/android-applications.md](references/android-applications.md)
- Browser/Electron observation (passive and scenario) and static/runtime
reconciliation:
- Passive browser/Electron observation and static/runtime reconciliation:
[references/runtime-observation.md](references/runtime-observation.md)
- Evidence paging, comparisons, residual unknowns, and verification:
[references/evidence-workflows.md](references/evidence-workflows.md)
@@ -13,7 +13,7 @@ main and an older release expose the same arguments.
| Observation | Meaning and next step |
| --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| No REA tools in the active session | Inspect registration with `rea doctor --client codex --json`. A skill installation alone does not register MCP. |
| No REA tools in the active session | If REA was installed as a client plugin, follow the plugin section below. Otherwise inspect registration with `rea doctor --client codex --json`. A skill installation alone does not register MCP. |
| Missing, malformed or stale registration | Prepare a scoped setup plan; apply only authorized changes with backups. |
| Aligned registration, but no connected tools | Restart/reconnect the client. Doctor checks registration; it cannot establish the current agent's connection. Inspect the client's launch error if reconnection fails. |
| `MCP tool call requires approval, but approval policy is never` | Codex rejected the call before REA executed it. Do not install an engine or repeat setup to solve this client-policy decision. See the approval section below. |
@@ -34,6 +34,33 @@ answers part of the question, and name the unavailable evidence separately.
Do not substitute another target. Label observations from ordinary shell tools
as such; only actual REA output supplies REA Evidence.
## REA installed as a client plugin
A Claude Code or Codex plugin owns its `rea` server and this skill. Doctor's
`--client` and `--skill` checks and `rea setup` read and write only
setup-owned state, so for a plugin-only install they report `missing`, and
applying setup would add a second `rea` server. If this skill was loaded from
a plugin, or the client lists a `rea` plugin, do not prepare a setup plan for
missing tools. Instead:
- Check that the plugin is installed and enabled with the client's own
listing: `claude plugin list` and `/mcp` in Claude Code, `codex plugin list`
in Codex.
- Restart or reconnect the client, then read its launch error for the `rea`
server if tools are still absent. The first start downloads the pinned
package through `npx` and can exceed the client's startup timeout; a second
start uses the cache.
- Check analysis engines through the connected server's
`binary_session.result.tool_availability`. `rea doctor --provider <id> --json`
from a shell reports that shell's environment, which can differ from the
environment the client gave the plugin server. If you still run it through
the package runner, use the version pinned in the plugin manifest's server
entry (`rea-agents@<version>`), not `@latest`, so it matches the server the
plugin starts.
Use the setup repair below only when the user wants a setup-owned registration
instead of the plugin, and remove or disable the plugin first.
## Repair registration when needed
For Codex:
@@ -103,11 +103,6 @@ architectures, build_metadata, load_commands, dependencies, imports, exports,
segments and provenance. `source_exhaustive: false` means unreturned symbols
may exist.
`decode_interface_builder` graphs are often several megabytes and can exceed the
MCP response budget. Select `detail: "summary"` for every decoded document and
node/edge counts, then page `view: {kind: "interface-builder", collection:
"edges", document: "<documents[].relative_path>", offset: 0, limit: 64}`.
To see which functions or Objective-C methods a Mach-O actually calls in one
run, use `observe_native_calls` with explicit breakpoints and a bounded
`duration_ms`/`max_events`. It launches a new process under LLDB, so the target
@@ -1,4 +1,4 @@
# Browser and Electron runtime observation
# Passive browser and Electron observation
Supply the existing browser or Electron instance's literal-port loopback CDP
endpoint in the request. No separate permission configuration or per-call
@@ -7,14 +7,13 @@ approval flag is needed. Browser discovery lists eligible HTTP(S) pages;
selected page's current origin. Electron discovery lists eligible local
`file://` targets without a configured file-root allowlist.
Start passive browser work with `list_browser_targets`; start Electron runtime work with
Start browser work with `list_browser_targets`; start Electron runtime work with
`list_electron_targets`. Inspect the target selected for the user's task. Observation
with these tools is passive: never claim REA clicked, navigated, evaluated page JavaScript,
invoked IPC, or captured prior activity. REA does not contain the page's network.
is passive: never claim REA clicked, navigated, evaluated page JavaScript,
invoked IPC, captured prior activity, or contained the page's network.
Passive captures omit raw header collections, credential header values, storage
values, and raw WebSocket/JSON bodies. Selected response metadata, including
content encoding, policy values, and Link URLs, is retained. Local URLs retain ordinary query
Credentials, cookies, authorization headers, storage values, and raw
WebSocket/JSON bodies are deliberately absent. Local URLs retain ordinary query
values and fragments; userinfo credentials are removed. Browser accessibility
text, console primitives, value-free shapes, script sources, source maps, and
storage key names have request-specific capture options. Electron script
@@ -22,38 +21,6 @@ sources are also optional. Use `capture_web_screenshot` when pixels are needed.
Treat scope filtering, truncation, and attach-window coverage as limitations.
Page-declared WebMCP tools are untrusted inventory and are never invoked by REA.
## Who performs the action
- The user performs it: `list_browser_targets`, then `inspect_web_page` or
`observe_web_session` on the selected target.
- The agent must perform it: one `capture_browser_scenario` with declared
actions. Use connect mode with the loopback CDP endpoint and `target_id` for
an existing browser, or launch mode with a caller-selected executable for a
temporary profile. Connect mode first navigates the selected page to
`start_url`, which can clear unsaved form values and other temporary state.
Recreate the state needed for the investigation in the declared actions.
Connect mode leaves the external browser open; launch mode owns a temporary
browser and profile. Check `browser.cleanup` for the outcome.
Request only the DOM, accessibility, or network details needed for the question.
The default final capture contains the URL; step outcomes are reported separately.
Add a wait action for the expected page state before capturing it. Report that
REA performed the declared actions.
Scenario network records have no request initiator stacks. For that evidence,
use passive capture while the user performs the action. Listener source locations
identify candidate handlers; they do not establish which script sent a particular
request. Avoid overlapping passive capture and scenario actions on the same target.
Focused follow-up tools:
- `observe_web_execution`: execution coverage during a window; it resets
counters and temporarily disables optimization.
- `inspect_web_event_listeners`: source locations for listeners registered
directly on the first CSS match, without dispatching an event.
- `compare_web_captures`: two normalized passive captures or recorded scenarios.
- `discover_webmcp_tools`: inspect page-declared WebMCP inventory; never invoke
the discovered tools.
Use `reconcile_javascript_runtime` only after static application Evidence and
passive browser/Electron Evidence exist. Prefer captured-source digest identity;
report path/digest disagreement as a mismatch and preserve ambiguity. Explicit
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "rea",
"description": "Reverse engineer anything with agents, from app behavior down to native binaries.",
"owner": {
"name": "morluto",
"url": "https://github.com/morluto"
},
"plugins": [
{
"name": "rea",
"description": "Reverse engineer anything from your terminal or agent with one CLI and MCP server.",
"source": {
"source": "npm",
"package": "rea-agents",
"version": "6.4.0"
},
"category": "development"
}
]
}
+28
View File
@@ -0,0 +1,28 @@
{
"name": "rea",
"version": "6.4.0",
"description": "Reverse engineer anything from your terminal or agent with one CLI and MCP server.",
"author": {
"name": "morluto",
"url": "https://github.com/morluto"
},
"homepage": "https://rea.tools/",
"repository": "https://github.com/morluto/rea",
"license": "MIT",
"keywords": [
"reverse-engineering",
"binary-analysis",
"decompiler",
"mcp-server"
],
"skills": "./skills",
"mcpServers": {
"rea": {
"command": "node",
"args": [
"${CLAUDE_PLUGIN_ROOT}/scripts/plugin-mcp-launcher.mjs",
"6.4.0"
]
}
}
}
+38
View File
@@ -0,0 +1,38 @@
{
"name": "rea",
"version": "6.4.0",
"description": "Reverse engineer anything from your terminal or agent with one CLI and MCP server.",
"author": {
"name": "morluto",
"url": "https://github.com/morluto"
},
"homepage": "https://rea.tools/",
"repository": "https://github.com/morluto/rea",
"license": "MIT",
"keywords": [
"reverse-engineering",
"binary-analysis",
"decompiler",
"mcp-server"
],
"skills": "./skills",
"mcpServers": "./.mcp.json",
"interface": {
"displayName": "REA",
"shortDescription": "Reverse engineer anything with agents",
"longDescription": "Use REA to explain shipped behavior from native binaries, managed assemblies, Electron and JavaScript bundles, packages, firmware, and browser targets, with evidence that separates observations from inferences.",
"developerName": "morluto",
"category": "Developer Tools",
"capabilities": [
"Interactive",
"Read",
"Write"
],
"websiteURL": "https://rea.tools/",
"defaultPrompt": [
"Use REA to explain how this binary implements a feature.",
"Use REA to compare two versions of this application.",
"Use REA to recover the sources from this JavaScript bundle."
]
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ runs:
shell: bash
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
LOCKFILES_HASH: ${{ hashFiles('package-lock.json', 'npm-shrinkwrap.json', '.npmrc') }}
LOCKFILES_HASH: ${{ hashFiles('npm-shrinkwrap.json', '.npmrc') }}
run: |
cache_path="$(npm config get cache)"
runtime_arch="$(node -p 'process.arch')"
@@ -21,7 +21,7 @@ runs:
if: inputs.build_cache_artifact == ''
shell: bash
env:
BUILD_CACHE_PREFIX: rea-portable-build-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.npmrc', '.nvmrc', 'package-lock.json') }}
BUILD_CACHE_PREFIX: rea-portable-build-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.npmrc', '.nvmrc', 'npm-shrinkwrap.json') }}
run: |
npm run --silent test:prepare -- --dry=json |
node --input-type=module -e '
+2 -2
View File
@@ -133,9 +133,9 @@ jobs:
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: .turbo/cache/
key: rea-static-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc', '.npmrc', 'package-lock.json') }}-${{ github.sha }}
key: rea-static-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc', '.npmrc', 'npm-shrinkwrap.json') }}-${{ github.sha }}
# Turbo validates each task's inputs after restoring this snapshot.
restore-keys: rea-static-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc', '.npmrc', 'package-lock.json') }}-
restore-keys: rea-static-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc', '.npmrc', 'npm-shrinkwrap.json') }}-
- name: Validate workflow syntax and expressions
if: needs.changes.outputs.workflows == 'true'
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 -shellcheck= -pyflakes=
+1 -1
View File
@@ -25,7 +25,7 @@ on:
- "scripts/lib/mcp-verifier-results.mjs"
- "scripts/rea.mjs"
- "package.json"
- "package-lock.json"
- "npm-shrinkwrap.json"
- ".nvmrc"
permissions:
+10
View File
@@ -0,0 +1,10 @@
{
"mcpServers": {
"rea": {
"command": "node",
"args": ["./scripts/plugin-mcp-launcher.mjs", "6.4.0"],
"cwd": ".",
"startup_timeout_sec": 30
}
}
}
+3 -1
View File
@@ -9,7 +9,9 @@
"docs/api/**",
"node_modules/**",
"CHANGELOG.md",
"package-lock.json",
"npm-shrinkwrap.json",
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
"_reference/**",
"third_party/**"
]
+3
View File
@@ -131,6 +131,9 @@ npm ci
npm run check:fast
```
`npm-shrinkwrap.json` is the sole dependency lock for development and the
published plugins. Do not add a root `package-lock.json`; npm would ignore it.
`npm ci` installs the exact dependencies and prepares the Husky hooks without
building the project. Run `npm run build:cached` when you need the standalone
CLI or MCP server; it compiles the runtime and bundles the authored skill.
+1 -1
View File
@@ -15,7 +15,7 @@ notices supplied with the software you actually obtain or redistribute.
| Material | Use and licensing records |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| npm dependencies | [Complete inventory](licenses/npm-dependencies.txt) of every entry in `package-lock.json`, including transitive, optional platform, development, and website dependencies. [Runtime notices](licenses/npm-runtime-notices.txt) preserve the copyright, license, and NOTICE documents from integrity-verified release archives, plus the MIT notice for Rolldown's generated runtime helpers. Development dependencies are inventoried but are not all distributed in REA's npm package. |
| npm dependencies | [Complete inventory](licenses/npm-dependencies.txt) of every entry in `npm-shrinkwrap.json`, including transitive, optional platform, development, and website dependencies. [Runtime notices](licenses/npm-runtime-notices.txt) preserve the copyright, license, and NOTICE documents from integrity-verified release archives, plus the MIT notice for Rolldown's generated runtime helpers. Development dependencies are inventoried but are not all distributed in REA's npm package. |
| NativeAOT parser | REA's derived parser uses the MIT-licensed [ghidra-nativeaot](https://github.com/Washi1337/ghidra-nativeaot/tree/effeb734fc570c32650f88b159608979dc7b423e) work of Washi and the NativeAOT layout definitions in [dotnet/runtime](https://github.com/dotnet/runtime/tree/a3b2d40328be0be3f8dd43f0e5cc925b8827b044). Preserve [Washi's MIT notice](licenses/nativeaot-parser-MIT.txt) and the [.NET Foundation MIT notice](licenses/dotnet-runtime-MIT.txt). |
| EVM analysis | `evmole@0.9.3`, MIT, copyright 2023 Maxim Andreev. The published npm archive omits its license document; REA retains the [original upstream license](third_party/evmole/LICENSE) and [provenance](third_party/evmole/README.md). It is also included in the runtime notices. |
| Windows native addon | The release addon is compiled with MinGW-w64 and statically links GCC runtime libraries. Preserve the [MinGW-w64 runtime notices](licenses/MinGW-w64-runtime.txt), [GPLv3 text](licenses/GPL-3.0.txt), and [GCC Runtime Library Exception 3.1](licenses/GCC-Runtime-Library-Exception-3.1.txt). The exception governs eligible compiled combinations; it does not relicense the runtime itself under MIT. The Node-API build headers are from Node 22.19.0; its [original license and component notices](licenses/Node-22.19.0-LICENSE.txt) are retained. Node itself is supplied by the user. Snapshot provenance and release-toolchain review requirements are in [licenses/README.md](licenses/README.md). |
+94 -279
View File
@@ -81,7 +81,6 @@ import ghidra.program.model.listing.Function;
import ghidra.program.model.listing.FunctionIterator;
import ghidra.program.model.listing.Instruction;
import ghidra.program.model.listing.InstructionIterator;
import ghidra.program.model.listing.Listing;
import ghidra.program.model.listing.Variable;
import ghidra.program.model.mem.MemoryBlock;
import ghidra.program.model.mem.MemoryBlockSourceInfo;
@@ -406,9 +405,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
case "xrefs" -> xrefs(request.params);
case "analyze_function" -> {
FunctionAnalysisObservation observation = analyzeFunctionObservation(request.params);
JsonObject result = functionObservation(observation.dossier(), observation.limitations());
result.add("relationship_coverage", observation.relationshipCoverage());
yield result;
yield functionObservation(observation.dossier(), observation.limitations());
}
case "set_address_name", "set_addresses_names" -> {
if (!descriptor.transport.equals("unix-socket"))
@@ -1019,19 +1016,12 @@ public final class ReaGhidraBridge extends HeadlessScript {
for (Function item : ordered) {
result.add(canonicalAddress(item.getEntryPoint()));
}
if (callers) {
JsonObject observation = functionObservation(result, noReturnCallLimitations(
return functionObservation(
result,
noReturnCallLimitations(
currentProgram.getListing().getInstructions(function.getBody(), true)
));
observation.add("relationship_coverage", callRelationshipCoverage(null,
unrepresentedIncomingCalls(function, observed)));
monitor.checkCancelled();
return observation;
}
CallBoundaryObservation calls = callBoundaryObservation(
function, currentProgram.getListing().getInstructions(function.getBody(), true), observed, false, null
)
);
return callRelationshipObservation(result, calls);
}
private JsonObject procedureInfo(JsonObject params) throws Exception {
@@ -1081,10 +1071,18 @@ public final class ReaGhidraBridge extends HeadlessScript {
result.addProperty("direction", direction);
result.add("references", edges);
result.addProperty("reference_kinds_available", true);
JsonArray unresolvedCalls = direction.equals("outgoing")
? callBoundaryObservation(
function, currentProgram.getListing().getInstructions(function.getBody(), true), null, true, references
).unresolvedCalls() : new JsonArray();
JsonArray unresolvedCalls = new JsonArray();
if (direction.equals("outgoing")) for (Instruction instruction : scanInstructions(function, Integer.MAX_VALUE).instructions) {
if (!instruction.getFlowType().isCall()) continue;
boolean resolved = false;
for (Reference reference : instruction.getReferencesFrom()) if (reference.getReferenceType().isCall()) resolved = true;
if (!resolved) {
JsonObject site = new JsonObject();
site.addProperty("address", canonicalAddress(instruction.getAddress()));
site.addProperty("reason", "Instruction decoder identifies a call but ReferenceManager supplies no call destination");
unresolvedCalls.add(site);
}
}
result.add("unresolved_calls", unresolvedCalls);
return result;
}
@@ -1492,28 +1490,16 @@ public final class ReaGhidraBridge extends HeadlessScript {
InstructionScan scan = scanInstructions(function, Integer.MAX_VALUE);
DecompileResults decompilation = decompile(function);
String pseudocode = decompiledText(decompilation);
JsonObject availability = new JsonObject();
availability.add("pseudocode", facetAvailability(
pseudocode != null,
function.isExternal() ? "Ghidra does not decompile external functions."
: function.getBody().isEmpty() ? "Ghidra has no function body to decompile."
: "Ghidra completed decompilation without returning pseudocode."
));
if (pseudocode == null) {
pseudocode = "";
}
List<Reference> bodyIncoming = collectReferences(function, "incoming");
List<Reference> incomingReferences = bodyIncoming.stream()
List<Reference> incomingReferences = collectReferences(function, "incoming")
.stream()
.filter(reference -> !function.getBody().contains(reference.getFromAddress()))
.toList();
List<Reference> outgoingReferences = collectReferences(function, "outgoing");
JsonArray incoming = referenceEdges(incomingReferences);
JsonArray outgoing = referenceEdges(outgoingReferences);
JsonArray comments = comments(function);
Set<Function> callerFunctions = function.getCallingFunctions(monitor);
JsonArray callers = procedureIdentities(callerFunctions);
Set<Function> calleeFunctions = function.getCalledFunctions(monitor);
JsonArray callees = procedureIdentities(calleeFunctions);
JsonArray comments = comments(scan.instructions);
JsonArray callers = procedureIdentities(function.getCallingFunctions(monitor));
JsonArray callees = procedureIdentities(function.getCalledFunctions(monitor));
JsonArray referencedStrings = referencedStrings(outgoingReferences);
JsonArray referencedNames = referencedNames(outgoingReferences);
JsonArray blocks = basicBlockValues(function);
@@ -1525,7 +1511,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
procedure.add("locals", functionLocals(function));
JsonObject result = new JsonObject();
result.add("procedure", procedure);
result.addProperty("pseudocode", pseudocode);
result.addProperty("pseudocode", pseudocode == null ? "" : pseudocode);
result.add("assembly", assembly);
result.add("comments", comments);
result.add("callers", callers);
@@ -1534,6 +1520,20 @@ public final class ReaGhidraBridge extends HeadlessScript {
result.add("outgoing_references", outgoing);
result.add("referenced_strings", referencedStrings);
result.add("referenced_names", referencedNames);
JsonObject facets = new JsonObject();
JsonObject pseudocodeAvailability = new JsonObject();
pseudocodeAvailability.addProperty("available", pseudocode != null);
if (pseudocode == null) {
pseudocodeAvailability.addProperty("reason", "Ghidra did not supply pseudocode.");
}
facets.add("pseudocode", pseudocodeAvailability);
for (String facet : List.of("assembly", "comments", "callers", "callees",
"references", "strings_names", "cfg")) {
JsonObject available = new JsonObject();
available.addProperty("available", true);
facets.add(facet, available);
}
result.add("facet_availability", facets);
boolean swift = swiftProgram();
boolean aarch64 = currentProgram.getLanguageID().getIdAsString().startsWith("AARCH64:");
SwiftStringLiteralDecode swiftLiterals = swift && aarch64 ? swiftStringLiterals(function) : null;
@@ -1547,15 +1547,6 @@ public final class ReaGhidraBridge extends HeadlessScript {
nativeApiBoundary(function, decompilation)
);
result.add("native_value_flow", nativeValueFlow(decompilation));
// Successful full queries distinguish collected empty sets from missing facts.
// Availability does not claim that Ghidra resolved every runtime relationship.
for (String facet : List.of("comments", "callers", "callees", "references", "strings_names", "cfg")) {
availability.add(facet, facetAvailability(true, null));
}
availability.add("assembly", facetAvailability(
!scan.truncated(), "The function instruction scan was truncated."
));
result.add("facet_availability", availability);
JsonArray limitations = new JsonArray();
limitations.add(
"Unresolved computed or indirect flows without target addresses are not represented as reference edges."
@@ -1583,10 +1574,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
"Swift String literal decoding is implemented for AArch64 register pairs only; no Swift literals were decoded for this architecture."
);
}
CallBoundaryObservation calls = callBoundaryObservation(
function, scan.instructions, calleeFunctions, true, outgoingReferences);
result.add("unresolved_calls", calls.unresolvedCalls());
for (String limitation : calls.limitations()) {
List<String> boundaryLimitations = noReturnCallLimitations(scan.instructions);
for (String limitation : boundaryLimitations) {
limitations.add(limitation);
}
if (decompilation != null && decompilation.getErrorMessage() != null &&
@@ -1594,14 +1583,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
limitations.add("Ghidra decompiler diagnostic: " + decompilation.getErrorMessage());
}
result.add("limitations", limitations);
monitor.checkCancelled();
int missingCallers = 0;
for (Reference reference : bodyIncoming) {
monitor.checkCancelled();
if (unrepresentedCaller(reference, callerFunctions)) missingCallers++;
}
return new FunctionAnalysisObservation(result, calls.limitations(),
callRelationshipCoverage(calls, missingCallers));
return new FunctionAnalysisObservation(result, boundaryLimitations);
}
private JsonObject nativeValueFlow(DecompileResults decompilation) throws Exception {
@@ -3305,200 +3287,52 @@ public final class ReaGhidraBridge extends HeadlessScript {
return result;
}
private static JsonObject facetAvailability(boolean available, String reason) {
JsonObject result = new JsonObject();
result.addProperty("available", available);
if (!available) result.addProperty("reason", reason);
return result;
}
private JsonObject callRelationshipObservation(JsonElement value, CallBoundaryObservation calls)
throws Exception {
monitor.checkCancelled();
JsonObject result = functionObservation(value, calls.limitations());
result.add("relationship_coverage", callRelationshipCoverage(calls, null));
return result;
}
private static JsonObject callRelationshipCoverage(
CallBoundaryObservation calls, Integer missingCallers) {
JsonObject coverage = completeResolvedCallCoverage();
Integer omitted = calls == null ? null : calls.unrepresentedCalleeCalls();
JsonArray directions = new JsonArray();
if (missingCallers != null && missingCallers > 0) directions.add("callers");
String callerLimitation = missingCallers != null && missingCallers > 0
? "Ghidra's caller inventory omits sources of " + missingCallers +
" call reference(s) into this function's body." : null;
if ((calls != null && omitted == null) || (calls == null && missingCallers == null)) {
coverage.addProperty("status", "unknown");
String reason = calls == null ? "No call inventory was examined."
: "The callee inventory was not examined alongside decoded call targets.";
coverage.addProperty("reason", reason + (callerLimitation == null ? "" : " " + callerLimitation));
if (calls == null && missingCallers == null) directions.add("callers");
directions.add("callees");
coverage.add("limited_directions", directions);
return coverage;
}
JsonArray limitations = new JsonArray();
if (callerLimitation != null) limitations.add(callerLimitation);
if (omitted != null && omitted > 0) {
limitations.add("Ghidra's callee inventory omits targets of " + omitted +
" call reference(s) or decoded call(s) without destinations.");
directions.add("callees");
}
if (!limitations.isEmpty()) {
coverage.addProperty("status", "partial");
coverage.add("limitations", limitations);
coverage.add("limited_directions", directions);
}
return coverage;
}
private static JsonObject completeResolvedCallCoverage() {
JsonObject coverage = new JsonObject();
coverage.addProperty("status", "complete");
JsonArray diagnostics = new JsonArray();
diagnostics.add(
"Only resolved Ghidra call references are returned; unresolved computed or indirect calls remain unknown, while function classifications distinguish thunks and externals."
);
coverage.add("diagnostics", diagnostics);
return coverage;
}
private boolean unrepresentedCaller(Reference reference, Set<Function> callers) {
if (!reference.getReferenceType().isCall()) return false;
Function caller = containingFunction(reference.getFromAddress());
return caller == null || !callers.contains(caller);
}
private int unrepresentedIncomingCalls(Function function, Set<Function> callers)
throws Exception {
int[] omitted = { 0 };
visitReferences(function, "incoming", reference -> {
if (unrepresentedCaller(reference, callers)) omitted[0]++;
});
return omitted[0];
}
private List<String> noReturnCallLimitations(Iterable<Instruction> instructions)
throws Exception {
List<String> limitations = new ArrayList<>();
for (Instruction instruction : instructions) {
monitor.checkCancelled();
String limitation = terminalCallLimitation(instruction);
if (limitation != null) limitations.add(limitation);
}
return limitations;
}
private CallBoundaryObservation callBoundaryObservation(
Function function, Iterable<Instruction> instructions, Set<Function> returnedCallees,
boolean includeUnresolvedSites, List<Reference> observedReferences)
throws Exception {
List<String> limitations = new ArrayList<>();
CallBoundaryReferences calls = new CallBoundaryReferences(returnedCallees, includeUnresolvedSites);
if (observedReferences == null) visitReferences(function, "outgoing", calls::observe);
else for (Reference reference : observedReferences) {
monitor.checkCancelled();
calls.observe(reference);
}
for (Instruction instruction : instructions) {
monitor.checkCancelled();
String limitation = terminalCallLimitation(instruction);
if (limitation != null) limitations.add(limitation);
if (!instruction.getFlowType().isCall()) continue;
boolean destination = false;
for (Reference reference : instruction.getReferencesFrom()) {
if (!reference.getReferenceType().isCall()) continue;
destination = true;
break;
if (!instruction.getFlowType().isCall() || !instruction.getFlowType().isTerminal()) {
continue;
}
if (!destination) {
calls.unrepresented++;
calls.unresolved(instruction.getAddress(),
"Instruction decoder identifies a call but ReferenceManager supplies no call destination");
}
}
return new CallBoundaryObservation(List.copyOf(limitations),
returnedCallees == null ? null : calls.unrepresented, calls.unresolvedCalls());
}
private final class CallBoundaryReferences {
private final Set<Function> returnedCallees;
private final TreeMap<Address, String> unresolved;
private int unrepresented;
CallBoundaryReferences(Set<Function> returnedCallees, boolean includeUnresolvedSites) {
this.returnedCallees = returnedCallees;
unresolved = includeUnresolvedSites ? new TreeMap<>() : null;
}
void observe(Reference reference) {
if (!reference.getReferenceType().isCall()) return;
// Reference identity follows the same containing-function owner as referenceEdge.
Function target = containingFunction(reference.getToAddress());
if (returnedCallees != null && (target == null || !returnedCallees.contains(target)))
unrepresented++;
if (target == null) unresolved(reference.getFromAddress(),
"A call destination has no known containing Ghidra procedure");
}
void unresolved(Address address, String reason) {
if (unresolved != null) unresolved.put(address, reason);
}
JsonArray unresolvedCalls() {
if (unresolved == null) return null;
JsonArray result = new JsonArray();
for (var item : unresolved.entrySet()) {
JsonObject site = new JsonObject();
site.addProperty("address", canonicalAddress(item.getKey()));
site.addProperty("reason", item.getValue());
result.add(site);
}
return result;
}
}
private String terminalCallLimitation(Instruction instruction) {
if (!instruction.getFlowType().isCall() || !instruction.getFlowType().isTerminal())
return null;
Address[] destinations = instruction.getFlows();
StringBuilder limitation = new StringBuilder();
limitation.append("Ghidra Listing reports a terminal call at ")
.append(canonicalAddress(instruction.getAddress()));
if (destinations.length == 0) {
limitation.append(" with no resolved callee; its no-return basis is unknown.");
}
else {
limitation.append(" to ");
for (int index = 0; index < destinations.length; index++) {
if (index > 0) limitation.append(", ");
Address destination = destinations[index];
limitation.append(canonicalAddress(destination));
Function target = currentProgram.getFunctionManager().getFunctionAt(destination);
if (target == null) {
limitation.append(" (callee identity unknown)");
}
else {
limitation.append(" ").append(procedureName(target));
limitation.append(" (external=").append(target.isExternal());
limitation.append(", thunk=").append(target.isThunk());
limitation.append(", hasNoReturn=").append(target.hasNoReturn()).append(")");
}
}
limitation.append("; fallthrough is excluded by Ghidra's flow model.");
Function target = destinations.length == 1
? currentProgram.getFunctionManager().getFunctionAt(destinations[0])
: null;
if (target != null && target.hasNoReturn()) {
limitation.append(" This records Ghidra's FunctionManager flag, not an independent verification that the callee cannot return.");
Address[] destinations = instruction.getFlows();
StringBuilder limitation = new StringBuilder();
limitation.append("Ghidra Listing reports a terminal call at ")
.append(canonicalAddress(instruction.getAddress()));
if (destinations.length == 0) {
limitation.append(" with no resolved callee; its no-return basis is unknown.");
}
else {
limitation.append(" The reason for the terminal flow is not established by this observation.");
limitation.append(" to ");
for (int index = 0; index < destinations.length; index++) {
if (index > 0) limitation.append(", ");
Address destination = destinations[index];
limitation.append(canonicalAddress(destination));
Function target = currentProgram.getFunctionManager().getFunctionAt(destination);
if (target == null) {
limitation.append(" (callee identity unknown)");
}
else {
limitation.append(" ").append(procedureName(target));
limitation.append(" (external=").append(target.isExternal());
limitation.append(", thunk=").append(target.isThunk());
limitation.append(", hasNoReturn=").append(target.hasNoReturn()).append(")");
}
}
limitation.append("; fallthrough is excluded by Ghidra's flow model.");
Function target = destinations.length == 1
? currentProgram.getFunctionManager().getFunctionAt(destinations[0])
: null;
if (target != null && target.hasNoReturn()) {
limitation.append(" This records Ghidra's FunctionManager flag, not an independent verification that the callee cannot return.");
}
else {
limitation.append(" The reason for the terminal flow is not established by this observation.");
}
}
limitations.add(limitation.toString());
}
return limitation.toString();
return limitations;
}
private static String renderAssembly(List<Instruction> instructions) {
@@ -3524,27 +3358,15 @@ public final class ReaGhidraBridge extends HeadlessScript {
private List<Reference> collectReferences(
Function function,
String direction) throws Exception {
TreeMap<String, Reference> observed = new TreeMap<>();
visitReferences(function, direction, reference -> addReference(observed, reference));
List<Reference> result = new ArrayList<>(observed.values());
result.sort(REFERENCE_ORDER);
return result;
}
@FunctionalInterface
private interface ReferenceVisitor {
void accept(Reference reference) throws Exception;
}
private void visitReferences(Function function, String direction, ReferenceVisitor visitor)
throws Exception {
// References can target instruction interiors or originate in embedded
// data. Use actual body ownership, including an external's exact entry.
AddressSet scope = functionObservationScope(function);
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()) {
monitor.checkCancelled();
Address address = addresses.next();
@@ -3552,7 +3374,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
for (Reference reference : currentProgram.getReferenceManager()
.getReferencesFrom(address)) {
monitor.checkCancelled();
if (!reference.isEntryPointReference()) visitor.accept(reference);
addReference(observed, reference);
}
}
else {
@@ -3560,14 +3382,19 @@ public final class ReaGhidraBridge extends HeadlessScript {
.getReferencesTo(address);
while (iterator.hasNext()) {
monitor.checkCancelled();
Reference reference = iterator.next();
if (!reference.isEntryPointReference()) visitor.accept(reference);
addReference(observed, iterator.next());
}
}
}
List<Reference> result = new ArrayList<>(observed.values());
result.sort(REFERENCE_ORDER);
return result;
}
private static void addReference(TreeMap<String, Reference> observed, Reference reference) {
if (reference.isEntryPointReference()) {
return;
}
String key = canonicalAddress(reference.getFromAddress()) + "\u0000" +
canonicalAddress(reference.getToAddress()) + "\u0000" +
reference.getReferenceType().getName() + "\u0000" +
@@ -3576,12 +3403,6 @@ public final class ReaGhidraBridge extends HeadlessScript {
observed.putIfAbsent(key, reference);
}
private static AddressSet functionObservationScope(Function function) {
AddressSet scope = new AddressSet(function.getBody());
scope.add(function.getEntryPoint());
return scope;
}
private JsonArray referenceEdges(List<Reference> references) {
JsonArray result = new JsonArray();
for (Reference reference : references) {
@@ -3735,28 +3556,23 @@ public final class ReaGhidraBridge extends HeadlessScript {
return result;
}
private JsonArray comments(Function function) throws Exception {
Listing listing = currentProgram.getListing();
AddressSet scope = functionObservationScope(function);
AddressIterator addresses = listing.getCommentAddressIterator(scope, true);
private static JsonArray comments(List<Instruction> instructions) {
TreeMap<String, JsonObject> observed = new TreeMap<>();
while (addresses.hasNext()) {
monitor.checkCancelled();
Address address = addresses.next();
for (Instruction instruction : instructions) {
for (CommentType type : CommentType.values()) {
String text = listing.getComment(type, address);
String text = instruction.getComment(type);
if (text == null || text.isEmpty()) {
continue;
}
String kind = type == CommentType.EOL ? "inline" : "comment";
JsonObject item = new JsonObject();
item.addProperty("address", canonicalAddress(address));
item.addProperty("address", canonicalAddress(instruction.getAddress()));
item.addProperty("kind", kind);
item.addProperty("text", text);
String key = canonicalAddress(address) + "\u0000" +
String key = canonicalAddress(instruction.getAddress()) + "\u0000" +
kind + "\u0000" + type.name() + "\u0000" + text;
observed.put(key, item);
}
}
}
JsonArray result = new JsonArray();
observed.values().forEach(result::add);
@@ -4692,8 +4508,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
private record InventoryItem(Address address, String value, JsonObject facts) {}
private record AddressNameRequest(String inputAddress, Address address, String name) {}
private record FunctionEntry(Function function, InventoryItem item) {}
private record FunctionAnalysisObservation(JsonObject dossier, List<String> limitations, JsonObject relationshipCoverage) {}
private record CallBoundaryObservation(List<String> limitations, Integer unrepresentedCalleeCalls, JsonArray unresolvedCalls) {}
private record FunctionAnalysisObservation(JsonObject dossier, List<String> limitations) {}
private record SwiftStringLiteralDecode(JsonArray literals, JsonArray omissions) {}
private record SwiftLiteralLayout(
String representation,
-15
View File
@@ -265,21 +265,6 @@ The user-level MCP paths follow the editor's VS Code layout. Trae's official
documentation describes the raw JSON editor without specifying those paths;
loading this registration in Trae remains unverified.
## WorkBuddy
For [WorkBuddy](https://www.workbuddy.cn/docs/workbuddy/From-Beginner-to-Expert-Guide/Function-Description/MCP-Guide), setup writes `mcpServers.rea` into the
global `~/.workbuddy/mcp.json` (a standard `mcpServers` JSON file at the user
home root on every platform). Entries carry `command`, `args`, and `env`;
stdio is the default transport. WorkBuddy discovers personal skills from
`~/.workbuddy/skills` (`%userprofile%\.workbuddy\skills` on Windows), so
setup installs its skill copy there.
The global MCP path is a plain JSON file; setup preserves every other server
and top-level key when it upserts `rea`. WorkBuddy also reads a project-level
`<project>/.workbuddy/mcp.json`, which setup does not touch: it registers the
user-level file only. Loading this registration and real REA tool calls
through WorkBuddy are verified.
## ZCode
For [ZCode](https://zcode.z.ai/en/docs/mcp-services), setup registers `rea` in the nested `mcp.servers` table of the
+80 -3
View File
@@ -288,12 +288,11 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s
| Qoder | `qoder` |
| Factory Droid | `droid` |
| Trae | `trae` |
| WorkBuddy | `workbuddy` |
| ZCode | `zcode` |
| DeepSeek Harness | `dsh` |
`dsh`, `kimi`, and `workbuddy` require a release containing them. The registry
check above does not cover them.
`dsh` and `kimi` require a release containing them. The registry check above does
not cover them.
See [client configuration and troubleshooting](client-configuration.md) for
configuration paths, profiles, trust policies, and schema-limit workarounds.
@@ -806,6 +805,84 @@ read-only operations, digest identity, and runtime cleanup. The separate
ordinary user. It requires the built Windows native bundle and an existing
Ghidra/JDK installation; neither is inferred from startup alone.
## Agent plugins and marketplaces
REA's npm package is the shared Claude Code and Codex plugin artifact. It
contains `.claude-plugin/plugin.json`, `.codex-plugin/plugin.json`, and the
canonical generated `skills/` tree. Both manifests select `./skills` and run a plain Node bootstrap pinned to
that exact package version. The client must have a supported standalone `node`
on its PATH. Claude expands its documented `CLAUDE_PLUGIN_ROOT` in the script
argument; Codex resolves `cwd: "."` from the installed plugin root before
launching the relative script. The shared bootstrap preserves the pinned
package-runner arguments and stdio, handles Windows npm shims, and uses an owned
empty working directory so an unbuilt local checkout cannot shadow the exact
registry release. The existing release skill
generator also pins CLI recovery commands to the same version.
The repository's single-plugin marketplaces are declared in
`.claude-plugin/marketplace.json` and `.agents/plugins/marketplace.json`.
Both select an exact `rea-agents` npm version instead of mutable source-checkout
contents. Release Please updates their selectors alongside the plugin manifests.
No plugin-only publication or generated skill files committed to Git are needed.
This distribution becomes installable after the first qualified npm release
containing these plugin manifests. Earlier published packages are not changed
by this source proposal. For local source development, run `npm ci && npm run build:cached` to generate
the runtime and packaged skill before loading the repository as a plugin.
On Windows, also build the Windows x64 native addon following the
[Windows source-checkout guide](https://github.com/morluto/rea/blob/main/native/windows/README.md#build);
the registry fallback requires that addon and the built process supervisor to
own and clean up the package-runner process tree. The source plugin always
launches its pinned registry release, even when the checkout has been built. The authored
`.agents/skills/` tree is not the plugin's shipped skill layout.
In Claude Code:
```text
/plugin marketplace add morluto/rea
/plugin install rea@rea
```
In Codex, add the marketplace and then install REA from the plugin list:
```bash
codex plugin marketplace add morluto/rea
```
Each release updates both manifest versions and their pinned server version
together, which is when installed copies refresh the bundled skill and move to
the new server. A plugin does not run `rea setup`, so provider
configuration still follows the sections above. Use the plugin or a
`rea setup` registration for a client, not both, to avoid two `rea` servers.
`rea doctor` does not see plugin-owned state. Its client and skill checks read
only the registrations and skill locations that `rea setup` manages, so for a
plugin-only install `rea doctor --client <id>` and `rea doctor --skill` report
`missing` and recommend `rea setup`. Do not follow that remediation. Check the
plugin with the client instead (`claude plugin list` and `/mcp` in Claude
Code, `codex plugin list` in Codex).
For analysis engines, ask the connected server: have the agent call
`binary_session` and read `result.tool_availability`, which reports what the
server the plugin started can use, with reasons and remediation. See
[MCP contracts](mcp-contracts.md).
A plugin does not put `rea` on your `PATH`. To check an engine from a
terminal, use the package runner at the version the plugin pins:
<!-- x-release-please-start-version -->
```bash
npx -y rea-agents@6.4.0 doctor --provider ghidra --json
```
<!-- x-release-please-end -->
If your installed plugin is older, use the version in its manifest instead.
That report describes the terminal's environment, not the plugin server's.
The server inherits the client's environment, so a setting such as
`GHIDRA_INSTALL_DIR` that exists only in your terminal does not reach it.
## MCP Registry
REA is published in the official MCP Registry as `io.github.morluto/rea`. Registry
+1 -13
View File
@@ -193,8 +193,7 @@ after target content was read.
## Retained application Evidence inputs
`analyze_javascript_application`, `inspect_binary_layout`, `inspect_artifact`,
`inspect_wasm_artifact`, `inspect_macho`, and `decode_interface_builder` accept
`detail`:
`inspect_wasm_artifact`, and `inspect_macho` accept `detail`:
`complete` (default) returns the complete analysis Evidence; `summary` records
that complete Evidence in the session and returns the derived
`inspect_analysis_view` summary Evidence instead. Its
@@ -277,17 +276,6 @@ symbols (Electron Framework: 2.5 MB of imports and exports, 3.3 KB as a
summary), so select `detail: "summary"` on the first call. Delivery detail
never reaches the native tools or changes the source Evidence ID.
Completed `decode_interface_builder` Evidence supports a summary and
`view: {kind: "interface-builder", collection: "nodes", offset: 0, limit: 64}`.
Collections are `documents`, `nodes`, `edges`, and `coverage`; for nodes and
edges an optional `document` keeps rows whose evidence names that exact decoded
`relative_path`, and an undecoded path is refused. The summary keeps every
decoded document, the provider, node-kind, edge-relation and coverage-status
counts, and whether the decoded graph is incomplete. Each page repeats `source_truncated`. A complete
app graph often exceeds the default MCP response budget (XLD.app: 6.7 MB), so
select `detail: "summary"` on the first call. Delivery detail never reaches the
decoder or changes the source Evidence ID.
`trace_application_feature`, `trace_javascript_semantics`,
`compare_application_versions`, `compare_source_to_bundle`, and
`compare_javascript_export_shapes` accept complete inline application Evidence
+1 -10
View File
@@ -319,16 +319,7 @@ complete observation.
digest are preserved; key interpretation follows the pinned plist decoder.
- `decode_interface_builder` / `rea decode-interface-builder <app>` reads
compiled storyboard and nib resources into objects, outlets, actions,
connections and controller/class names. A full app graph is often several
megabytes (XLD.app: 6.7 MB, about 2M tokens, which also exceeds the default
MCP response budget), so MCP callers should pass `detail: "summary"`. The
summary keeps every decoded document and node/edge/coverage counts and
retains the complete Evidence; page rows with `inspect_analysis_view`
`view: {kind: "interface-builder", collection: "nodes", document:
"Contents/Resources/Base.lproj/MainMenu.nib", offset: 0, limit: 64}`.
`document` selects rows whose evidence names that exact
`documents[].relative_path` (nodes and edges only); `source_truncated` keeps
graph incompleteness visible on every page. `verify:interface-builder` compiles
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.
+25 -22
View File
@@ -20,7 +20,7 @@
**[웹사이트](https://rea.tools/ko/) · [가이드](https://rea.tools/ko/guides/) · [사례](https://rea.tools/ko/showcase/)**
[빠른 시작](#빠른-시작) · [REA의 작동 방식](#rea의-작동-방식) · [분석할 수 있는 대상](#분석할-수-있는-대상) · [사례](#사례) · [자주 묻는 질문](#자주-묻는-질문) · [문서](#문서)
[빠른 시작](#빠른-시작) · [REA의 작동 방식](#rea의-작동-방식) · [분석할 수 있는 대상](#분석할-수-있는-대상) · [사례](#사례) · [자주 묻는 질문](#자주-묻는-질문) · [GNU objdump 및 Rizin 도구](../reverse-engineering-tools.md) · [문서](#문서)
<code>npx rea-agents setup</code>
@@ -56,7 +56,7 @@
REA는 에이전트를 분석 도구에 연결해 네이티브 바이너리, JavaScript와 Electron 앱, .NET 어셈블리, 웹사이트를 검사할 수 있게 합니다. 터미널에서도 같은 도구를 사용할 수 있습니다. 분석은 로컬에서 실행되며, 결과에는 각 결론의 근거와 한계가 포함됩니다.
설정 과정은 에이전트에 REA를 등록하고 해당 버전에 맞는 워크플로 지침을 설치합니다. 네이티브 분석에는 기존 Hopper, Ghidra 또는 IDA 설치를 사용할 수 있습니다. 승인하면 설정 과정에서 Hopper를 설치할 수도 있습니다. 정적 JavaScript 분석에는 네이티브 분석 엔진이 필요하지 않습니다.
설정 과정은 에이전트에 REA를 등록하고 해당 버전에 맞는 워크플로 지침을 설치합니다. 네이티브 분석에는 기존 Hopper 또는 Ghidra 설치를 사용할 수 있습니다. 승인하면 설정 과정에서 Hopper를 설치할 수도 있습니다. 정적 JavaScript 분석에는 두 엔진 모두 필요하지 않습니다.
> **[REA 웹사이트](https://rea.tools/ko/)**에서 설정 방법, 그림으로 설명한 가이드, 실제 사례를 확인하세요.
@@ -105,14 +105,12 @@ rea --help
REA는 빠르게 바뀌며 새 릴리스에는 버그 수정이 자주 포함됩니다. 설치된 버전을 최신으로 유지하세요.
`npm install --global` 또는 `bun add --global`로 설치한 CLI에서는 다음 명령을 사용합니다.
npm으로 설치한 CLI에서는 다음 명령을 사용합니다.
```bash
rea update
```
Bun 전역 업데이트에는 Bun 1.2.17 이상이 필요합니다. 이전 버전의 Bun을 사용한다면 `rea update`를 실행하기 전에 Bun을 직접 업그레이드하세요.
에이전트 등록과 스킬을 갱신하려면 업데이트 과정에서 출력된 설정 명령을 실행하세요.
`npx`를 사용한다면 다음 명령으로 에이전트 설정을 갱신하세요.
@@ -121,7 +119,7 @@ Bun 전역 업데이트에는 Bun 1.2.17 이상이 필요합니다. 이전 버
npx rea-agents@latest setup
```
설정 변경 사항을 검토하고 에이전트를 다시 시작하세요. 일회성 CLI 작업에는 `npx rea-agents@latest` 뒤에 필요한 명령을 붙여 실행하세요. Bun을 사용한다면 위 명령의 `npx rea-agents@latest`를 `bunx rea-agents@latest`로 바꿔 실행하세요.
설정 변경 사항을 검토하고 에이전트를 다시 시작하세요. 일회성 CLI 작업에는 `npx rea-agents@latest` 뒤에 필요한 명령을 붙여 실행하세요.
## REA의 작동 방식
@@ -141,16 +139,14 @@ REA에는 Node.js 22.x(>=22.19), 24.x(>=24.11) 또는 26+와 npm이 필요합니
| --------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| 네이티브 바이너리 | 의사 코드, 어셈블리, 문자열, 심볼, 호출, 참조 | Hopper, Ghidra 또는 IDA; [네이티브 분석](https://rea.tools/ko/guides/native/) |
| 오프라인 ELF 레이아웃 | 섹션, 세그먼트, 원본 심볼/재배치 정보, 정적 분석으로 얻은 보안 완화 기법 후보 | Linux x64에서 호출자가 제공하는 pwntools; [바이너리 진단](../binary-diagnostics.md) |
| Go 바이너리 | 내장된 컴파일러 버전, 모듈, `replace` 지시어, 빌드 설정 | REA 내장 ELF/PE/thin Mach-O 정적 검사; Go 설치 불필요 |
| Rust 바이너리 | cargo-auditable이 내장한 크레이트 이름, 버전, 소스, 의존성 관계 | REA 내장 ELF/PE/thin Mach-O 정적 검사; [Rust 바이너리](../rust-binaries.md) |
| WASM 아티팩트 | 유효성 검사, 섹션, import/export의 정확한 WAT 형식, 디코딩된 WAT | 호출자 제공 WABT 1.0.42; `inspect_wasm_artifact` |
| EVM 바이트코드 | 디스패치 선택자, 바이트 오프셋, 추론한 인자와 상태 변경 가능 여부 | Linux x64의 로컬 원시 바이트/16진수 입력 파일; [오프라인 EVM 가이드](../evm-bytecode.md) |
| SQLite 데이터베이스 | 테이블, 열, 인덱스, 뷰, 트리거, 선택한 일반 테이블의 행 | 로컬 데이터베이스/WAL 스냅샷; Node.js 24.15 이상의 24.x 또는 26 이상(네이티브 SQLite 한도 적용) |
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
| 기록된 Linux 크래시 | 원시 note 레코드, 기록된 모든 스레드의 레지스터/시그널, 선택적 매핑 후보 | Linux x64에서 호출자가 제공하는 pwntools; GDB/pwndbg는 선택 사항; [기록된 크래시](../recorded-crashes.md) |
| JavaScript / Electron | 모듈, 임포트, 소스 맵, 라우트, IPC, 네이티브 애드온 관계 | Node.js와 npm; [애플리케이션 분석](https://rea.tools/ko/guides/javascript/) |
| 웹사이트 | 페이지 구조, 스크립트, 네트워크 관찰 결과, 요청한 스크린샷 | Chrome 계열 브라우저; [브라우저 분석](https://rea.tools/ko/guides/browser/) |
| 저장된 네트워크 캡처 | 요청, 응답, 접근 가능한 페이로드, 소스 위치 | HAR 또는 Fiddler Everywhere SAZ; mitmproxy 네이티브 캡처에는 Linux의 mitmdump; [캡처 가이드](../web-network-captures.md) |
| .NET 어셈블리 | 메타데이터, CIL 명령, 선언된 네이티브 의존성, 빌드 비교 | 별도 도구 불필요(정적 검사만); [관리 코드 가이드](../managed-code-analysis.md) |
| 저장된 네트워크 캡처 | 요청, 응답, 접근 가능한 페이로드, 소스 위치 | HAR; mitmproxy 네이티브 캡처에는 Linux의 mitmdump; [캡처 가이드](../web-network-captures.md) |
| 패킷 캡처 | 엔드포인트, DNS, TLS SNI, 프로토콜 분포, HTTP 요청 | 호출자 제공 tshark 및 capinfos; [pcap 가이드](../pcap-analysis.md) |
| .NET 어셈블리 | 메타데이터, CIL 명령, 선언된 네이티브 의존성, 빌드 비교 | 정적 검사; [관리 코드 가이드](../managed-code-analysis.md) |
| Android APK | 매니페스트 선언, 클래스, 디컴파일된 메서드, 참조 | Linux/macOS/Windows x64의 헤드리스 JADX와 전체 JDK; [Android 가이드](../android-analysis.md) |
| Android 기기 | 기기, 패키지, 프로세스, 로그, 덤프, 화면 캡처, APK/파일 전송 | 호출자 제공 adb(에뮬레이터 또는 실제 기기); [ADB 가이드](../adb-device-analysis.md) |
| JEB 프로젝트 | 엔진 기반 프로젝트 유닛, 아티팩트 해시, 타입/메서드 의사코드 | 호출자가 시작하고 MCP를 제공하는 JEB 클라이언트(REA_JEB_MCP_URL로 선택); [JEB 가이드](../jeb-analysis.md) |
@@ -158,18 +154,15 @@ REA에는 Node.js 22.x(>=22.19), 24.x(>=24.11) 또는 26+와 npm이 필요합니
| Flutter 앱 | Dart 스냅샷 해시, ABI별 라이브러리 해시, 엔진 빌드 출처 | 없음 (정적 APK 파싱); [Flutter 가이드](../flutter-analysis.md) |
| 펌웨어 | 영역, 추출 결과, 네이티브 분석으로 전달할 정보 | Linux의 Binwalk / Unblob; [펌웨어 가이드](../firmware-analysis.md) |
| 기록된 패킷 | 선택 필드, 반복 값, 필터 및 디코딩 규칙 | Linux: TShark 4.x + prlimit; [캡처 가이드](../packet-captures.md) |
| 패킷 요약 | 엔드포인트, DNS, TLS SNI, 프로토콜 분포, HTTP 요청 | 호출자 제공 tshark 및 capinfos(Linux에서 검증됨); [pcap 가이드](../pcap-analysis.md) |
| 기록된 논리 신호 | 디코더 주석, 샘플 범위, 진단 정보 | Linux: sigrok-cli 0.7.2 + prlimit; [신호 기록 가이드](../signal-recordings.md) |
| 패키지 및 리소스 | 파일 목록, 다이제스트, plist, Apple 번들 구조, 추출한 리소스 | [아티팩트 및 JavaScript 가이드](../javascript-artifact-reconstruction.md), [Apple 애플리케이션](../apple-application-analysis.md) |
| 프로세스 동작 | 터미널 출력, 상호작용, 종료 및 파일 시스템 관찰 결과, 실행 비교 | 네이티브 PTY를 지원하는 Linux/macOS; [프로세스 캡처](../process-capture.md) |
정적 JavaScript 및 .NET 검사는 제공된 파일을 읽으며 애플리케이션을 실행하지 않습니다. 런타임 캡처는 사용자의 권한으로 선택한 대상을 실행하거나 대상과 상호작용합니다. 각 런타임 가이드에서 그 영향을 설명합니다.
오프라인 네이티브 바이너리 검사에는 호출자가 제공하는 GNU objdump와 Rizin도 사용할 수 있습니다. 제공자 설정, CLI 명령, Evidence의 한계는 [리버스 엔지니어링 도구 가이드](../reverse-engineering-tools.md)를 참고하세요. REA는 objdump와 Rizin을 설치하거나 업데이트하지 않습니다.
<a id="choosing-a-deep-analysis-provider"></a>
지원하는 네이티브 형식과 호스트는 제공자마다 다릅니다. [Hopper 및 Ghidra 설정](../installation.md#hopper), [IDA 가이드](../ida-provider.md)(선택 사항인 네이티브 MCP 브리지 포함), [실험적 Windows Ghidra 지원](../windows-ghidra-p0.md)을 참고하세요. Ghidra는 [16비트 DOS 분석](../ghidra-dos.md)도 지원합니다. 큰 바이너리를 분석할 때는 `REA_GHIDRA_STARTUP_TIMEOUT_MS` 또는 `REA_HOPPER_STARTUP_TIMEOUT_MS`로 제공자의 시작 제한 시간을 늘리세요. 제공자 선택은 [CLI 가이드](../cli.md#choose-a-provider)를 참고하세요. 최신 npm 릴리스 이후 추가된 기능은 [릴리스에서의 사용 가능 여부](../installation.md#released-package-and-main)를 확인하세요.
지원하는 네이티브 형식과 호스트는 제공자마다 다릅니다. [Hopper 및 Ghidra 설정](../installation.md#hopper), [IDA 가이드](../ida-provider.md), [실험적 Windows Ghidra 지원](../windows-ghidra-p0.md)을 참고하세요. Ghidra는 [16비트 DOS 분석](../ghidra-dos.md)도 지원합니다. 제공자 선택은 [CLI 가이드](../cli.md#choose-a-provider)를 참고하세요. 최신 npm 릴리스 이후 추가된 기능은 [릴리스에서의 사용 가능 여부](../installation.md#released-package-and-main)를 확인하세요.
## 사례
@@ -216,7 +209,7 @@ REA로 흥미로운 대상을 분석했다면 알려 주세요. [issue](https://
<details>
<summary><strong>Hopper를 먼저 실행해야 하나요?</strong></summary>
REA는 작업에 필요할 때 Hopper를 실행합니다. macOS에서는 처음 실행할 때 데모 모드를 선택하거나 라이선스를 활성화하라는 대화 상자가 나타날 수 있습니다. 첫 CLI/MCP 작업이나 무인 작업을 실행하기 전에 같은 사용자 세션에서 이 대화 상자를 완료하세요. 필요하면 Hopper를 직접 여세요. [Hopper 실행 및 문제 해결](../installation.md#launcher-paths-and-troubleshooting)을 참고하세요.
REA는 작업에 필요할 때 Hopper를 실행합니다. macOS에서는 처음 실행할 때 데모 모드를 선택하거나 라이선스를 활성화하라는 대화 상자가 나타날 수 있습니다. [Hopper 실행 및 문제 해결](../installation.md#launcher-paths-and-troubleshooting)을 참고하세요.
</details>
@@ -246,7 +239,7 @@ REA는 대상을 로컬에서 분석합니다. 에이전트는 도구 결과를
먼저 업데이트하세요. 최근 릴리스에서 이미 수정되었을 수 있습니다.
`npm install --global` 또는 `bun add --global`로 설치한 CLI에서는 다음 명령을 사용합니다.
npm으로 설치한 CLI에서는 다음 명령을 사용합니다.
```bash
rea update
@@ -293,10 +286,12 @@ REA를 사용하고, 버그를 보고하고, 기능을 요청하고, 빌드를
책임 있는 사용에 관한 안내는 [DISCLAIMER.md](../../DISCLAIMER.md)를 참고하세요.
불법적이거나 허가되지 않은 사용에 대한 프로젝트의 입장은 모든 REA 버전, 배포 채널, 플랫폼에 적용되며, 각 사본에는 해당 라이선스가 그대로 유지됩니다.
REA는 법을 준수하는 연구, 교육, 상호운용성 확보, 소프트웨어 보존, 그리고 본인이 소유하거나 조사 권한을 가진 소프트웨어의 분석을 위한 도구입니다.
The project's position on illegal or unauthorized use covers all REA
versions, distribution channels, and platforms; each copy retains its
applicable licenses.
## 기여
REA에 도움을 보태 주세요! [issue를 등록](https://github.com/morluto/rea/issues)해 버그나 기능을 제안하거나, [pull request를 보내](https://github.com/morluto/rea/pulls) 코드와 문서를 개선할 수 있습니다.
@@ -307,6 +302,14 @@ REA에 도움을 보태 주세요! [issue를 등록](https://github.com/morluto/
[MIT](../../LICENSE)
REA가 작성한 코드는 MIT 라이선스를 따릅니다. 제3자 자료에는 각자의 라이선스와 저작권 고지가 그대로 유지됩니다. 의존성 목록, 보존된 라이선스 원문, 외부 도구 약관은 [제3자 고지](../../THIRD_PARTY_NOTICES.md)를 참고하세요.
REA-authored code is licensed under MIT. Third-party material retains its own
licenses and copyright notices; see [Third-party notices](../../THIRD_PARTY_NOTICES.md)
for the dependency inventory, preserved license texts, and external-tool terms.
IDA Pro / Hex-Rays 디컴파일러, Hopper, JEB 등 상용 도구는 해당 라이선스에 따라 **합법적으로 취득하고 사용 권한을 갖춰야 합니다.** REA는 어떤 도구의 라이선스, 키, 활성화 권한도 부여하지 않습니다. 체험판과 데모는 사용자, 기능, 자동화, 출력 공유 제한을 포함한 공급업체 약관을 따릅니다. 크랙 버전, 도난 키, 무단 키 공유, 라이선스 제한 우회는 허용되지 않습니다. 이 안내는 MIT 라이선스나 제3자 라이선스를 변경하지 않습니다.
**You must legally obtain and be authorized to use commercial tools**, including
IDA Pro / Hex-Rays decompilers, Hopper, and JEB, under their applicable licenses.
REA grants no tool license, key, or activation entitlement. Trials and demos
remain subject to vendor terms, including user, feature, automation, and output
sharing restrictions. Cracked copies, stolen keys, unauthorized key sharing,
and bypassing license restrictions are not authorized. This guidance does not
amend MIT or third-party licenses.
+5 -2
View File
@@ -240,8 +240,11 @@ Review and test this synchronization PR, then use a merge commit so the release
tag remains in main's ancestry. Keep the released tag unchanged.
The synchronization must include `.release-please-manifest.json`,
`package.json`, both root versions in `package-lock.json`, `CHANGELOG.md`,
`server.json`, and the versioned documentation examples. Run
`package.json`, both root versions in `npm-shrinkwrap.json`, `CHANGELOG.md`,
`server.json`, both plugin manifests (`.claude-plugin/plugin.json` and
`.codex-plugin/plugin.json`), the Codex server declaration (`.mcp.json`), both
marketplaces (`.claude-plugin/marketplace.json` and
`.agents/plugins/marketplace.json`), and the versioned documentation examples. Run
`npm run docs:generate`, `npm run docs:check`, and the release configuration
tests against the combined tree. After the merge, verify that the released
tag is an ancestor of main:
+1 -1
View File
@@ -111,7 +111,7 @@ or acquire that reduction automatically.
Use the repository's pinned Node/npm and locked dependencies. Build each selected
revision, then copy its `dist/`, `bridge/`, `scripts/`, `skills/`, `package.json`
and `package-lock.json` to a separate runtime directory before editing production
and `npm-shrinkwrap.json` to a separate runtime directory before editing production
sources. That copy needs the matching locked dependencies. The initial frozen
baseline is `42b0ed126df7d5b908ac369715378d5e519051b7`; this study does not
silently substitute a newer `main` or the previous Code Mode experiment.
@@ -216,7 +216,7 @@ def main():
'authority': args.authority,
'runtime_files_sha256': runtime_digest,
'runtime_dispatcher_sha256': file_digest(repository / 'scripts/rea.mjs'),
'package_lock_sha256': file_digest(repository / 'package-lock.json'),
'package_lock_sha256': file_digest(repository / 'npm-shrinkwrap.json'),
'experiment_sources_sha256': {
name: hashlib.sha256((experiment / name).read_bytes()).hexdigest()
for name in ['agent-trial.py', 'codex_context.py', 'provider-environment.json']
+1 -1
View File
@@ -147,7 +147,7 @@ const fingerprints = {
resolve(values["runtime-root"], "scripts/rea.mjs"),
),
package_lock_sha256: await fileSha(
resolve(values["runtime-root"], "package-lock.json"),
resolve(values["runtime-root"], "npm-shrinkwrap.json"),
),
};
const clientVersion = JSON.parse(
@@ -109,7 +109,7 @@ class ContextCaptureTests(unittest.TestCase):
runtime = root / 'runtime'
(runtime / 'scripts').mkdir(parents=True)
(runtime / 'scripts' / 'rea.mjs').write_text('')
(runtime / 'package-lock.json').write_text('{}')
(runtime / 'npm-shrinkwrap.json').write_text('{}')
auth_home = root / 'source-auth'
auth_home.mkdir()
(auth_home / 'auth.json').write_text('synthetic-credential')
File diff suppressed because it is too large Load Diff
+2333 -197
View File
File diff suppressed because it is too large Load Diff
+6 -5
View File
@@ -47,6 +47,7 @@
"packageManager": "npm@11.16.0",
"files": [
"dist",
"npm-shrinkwrap.json",
"install.sh",
"bridge/hopper_bridge.py",
"bridge/cutter/rea_bridge.py",
@@ -67,12 +68,16 @@
"bridge/android/ReaJadxBridge.java",
"bridge/mitmproxy/capture.py",
"scripts/rea.mjs",
"scripts/plugin-mcp-launcher.mjs",
"scripts/rea-android-workspace-guardian.mjs",
"scripts/electron-active-hook.cjs",
"scripts/electron-active-hook-boundaries.cjs",
"scripts/hopper-demo-x11.py",
"scripts/prepack.mjs",
"scripts/prepare.mjs",
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
".mcp.json",
"skills",
"README.md",
"DISCLAIMER.md",
@@ -351,11 +356,6 @@
"yaml": "2.9.0",
"zod": "4.4.3"
},
"overrides": {
"vitepress": {
"vite": "6.4.4"
}
},
"devDependencies": {
"@fast-check/vitest": "0.5.0",
"@stryker-mutator/core": "10.0.0",
@@ -373,6 +373,7 @@
"lint-staged": "16.4.0",
"oxfmt": "0.72.0",
"oxlint": "1.87.0",
"release-please": "17.3.0",
"rolldown": "1.2.12",
"turbo": "2.11.7",
"typescript": "7.0.2",
+30
View File
@@ -31,6 +31,36 @@
"type": "json",
"path": "server.json",
"jsonpath": "$.packages[0].version"
},
{
"type": "json",
"path": ".claude-plugin/plugin.json",
"jsonpath": "$.version"
},
{
"type": "json",
"path": ".claude-plugin/plugin.json",
"jsonpath": "$.mcpServers.rea.args[1]"
},
{
"type": "json",
"path": ".codex-plugin/plugin.json",
"jsonpath": "$.version"
},
{
"type": "json",
"path": ".mcp.json",
"jsonpath": "$.mcpServers.rea.args[1]"
},
{
"type": "json",
"path": ".claude-plugin/marketplace.json",
"jsonpath": "$.plugins[0].source.version"
},
{
"type": "json",
"path": ".agents/plugins/marketplace.json",
"jsonpath": "$.plugins[0].source.version"
}
]
}
+3 -3
View File
@@ -17,7 +17,7 @@ const installedPackageKey = (name) => `node_modules/${name}`;
export const dependencyInstallProblems = (rootLock, installedLock) => {
const rootPackage = rootLock.packages?.[""];
if (rootPackage === undefined)
return ["package-lock.json does not contain a root package entry"];
return ["npm-shrinkwrap.json does not contain a root package entry"];
const installedPackages = installedLock.packages ?? {};
return Object.keys(directDependencies(rootPackage))
.sort()
@@ -26,7 +26,7 @@ export const dependencyInstallProblems = (rootLock, installedLock) => {
const expected = rootLock.packages?.[key]?.version;
const actual = installedPackages[key]?.version;
if (expected === undefined)
return [`${name}: missing resolved version in package-lock.json`];
return [`${name}: missing resolved version in npm-shrinkwrap.json`];
if (actual === undefined)
return [`${name}: missing from the installed dependency lockfile`];
return actual === expected
@@ -39,7 +39,7 @@ const main = async () => {
const root = process.cwd();
try {
const [rootLock, installedLock] = await Promise.all([
readJson(resolve(root, "package-lock.json")),
readJson(resolve(root, "npm-shrinkwrap.json")),
readJson(resolve(root, "node_modules/.package-lock.json")),
]);
const problems = dependencyInstallProblems(rootLock, installedLock);
+2 -2
View File
@@ -14,7 +14,7 @@ const packageJson = JSON.parse(
await readFile(join(root, "package.json"), "utf8"),
);
const lock = JSON.parse(
await readFile(join(root, "package-lock.json"), "utf8"),
await readFile(join(root, "npm-shrinkwrap.json"), "utf8"),
);
const skill = await readFile(
join(root, ".agents/skills/reverse-engineer-anything/SKILL.md"),
@@ -29,7 +29,7 @@ const versionOf = (name) => {
throw new Error(`Missing locked ${name} version`);
return value;
};
const source = `/** Generated from package.json and package-lock.json; do not edit. */
const source = `/** Generated from package.json and npm-shrinkwrap.json; do not edit. */
export const PACKAGE_METADATA = {
name: ${JSON.stringify(packageJson.name)},
version: ${JSON.stringify(packageJson.version)}, // x-release-please-version
+2 -2
View File
@@ -11,7 +11,7 @@ for (const argument of arguments_)
throw new Error(`Unknown inventory option: ${argument}`);
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const lock = JSON.parse(
await readFile(join(root, "package-lock.json"), "utf8"),
await readFile(join(root, "npm-shrinkwrap.json"), "utf8"),
);
const entries = Object.entries(lock.packages)
.filter(([path]) => path !== "")
@@ -38,7 +38,7 @@ const runtimeFingerprint = createHash("sha256")
.digest("hex");
const source = [
"REA npm dependency inventory",
"Prepared from the root package-lock.json; refresh this inventory when locked dependencies change.",
"Prepared from the root npm-shrinkwrap.json; refresh this inventory when locked dependencies change.",
"License identifiers are upstream lockfile declarations, not a legal assessment.",
"Runtime includes transitive and optional platform packages; development includes build, test and website tooling.",
`Locked package entries: ${entries.length}; runtime: ${runtime.length}.`,
-82
View File
@@ -215,27 +215,6 @@ async function verifyNativeValueAdapters(
const functionEvidence = parseEvidence(analyzed.structuredContent);
assert.equal(functionEvidence.operation, "analyze_function");
assert.equal(functionEvidence.provider.id, "ghidra");
assert.deepEqual(
functionEvidence.normalized_result.facet_availability,
Object.fromEntries(
[
"pseudocode",
"assembly",
"comments",
"callers",
"callees",
"references",
"strings_names",
"cfg",
].map((facet) => [facet, { available: true }]),
),
);
assert.equal(functionEvidence.relationship_coverage?.status, "complete");
assert.ok(
functionEvidence.relationship_coverage.diagnostics.some((diagnostic) =>
diagnostic.includes("indirect calls remain unknown"),
),
);
const functionProfile = analysisProfileSchema.parse(
functionEvidence.analysis_profile,
);
@@ -268,7 +247,6 @@ async function verifyNativeValueAdapters(
assert.ok(
comparisonEvidence.evidence_links.includes(functionEvidence.evidence_id),
);
await verifyUnresolvedCallEvidence(client);
const bundle = await client.callTool({
name: "get_evidence_bundle",
arguments: {},
@@ -329,63 +307,3 @@ async function verifyNativeValueAdapters(
unknowns: cliGraph.unknowns.length,
};
}
async function verifyUnresolvedCallEvidence(client) {
const inventory = await client.callTool({
name: "list_procedures",
arguments: {},
});
assert.notEqual(inventory.isError, true, JSON.stringify(inventory));
const procedures = parseEvidence(
inventory.structuredContent,
).normalized_result;
const indirect = procedures.find((item) =>
item.value.endsWith("rea_ghidra_inventory_indirect"),
);
const leaf = procedures.find((item) =>
item.value.endsWith("rea_ghidra_inventory_leaf"),
);
assert.ok(
indirect && leaf,
"Source fixture lacks the unresolved callback and its possible target",
);
const analyzed = await client.callTool(
{ name: "analyze_function", arguments: { procedure: indirect.address } },
{ timeout: 180000 },
);
assert.notEqual(analyzed.isError, true, JSON.stringify(analyzed));
const evidence = parseEvidence(analyzed.structuredContent);
assert.equal(evidence.relationship_coverage?.status, "partial");
assert.ok(evidence.normalized_result.unresolved_calls.length > 0);
const traced = await client.callTool(
{
name: "trace_call_path",
arguments: {
start: indirect.address,
goal: leaf.address,
direction: "forward",
},
},
{ timeout: 180000 },
);
assert.notEqual(traced.isError, true, JSON.stringify(traced));
const trace = parseEvidence(traced.structuredContent).normalized_result;
assert.equal(trace.goal_status, "unknown");
assert.equal(trace.truncated, true);
assert.ok(trace.residual_unknowns.length > 0);
const compared = await client.callTool(
{
name: "compare_functions",
arguments: {
left: analyzed.structuredContent,
right: analyzed.structuredContent,
},
},
{ timeout: 180000 },
);
assert.notEqual(compared.isError, true, JSON.stringify(compared));
assert.equal(
parseEvidence(compared.structuredContent).normalized_result.status,
"unknown",
);
}
+1 -1
View File
@@ -136,7 +136,7 @@ export async function verifyGhidraBoundaries(
assert.deepEqual(externalDossier.callees, []);
assert.deepEqual(externalDossier.facet_availability.pseudocode, {
available: false,
reason: "Ghidra does not decompile external functions.",
reason: "Ghidra did not supply pseudocode.",
});
for (const facet of [
"assembly",
+59 -3
View File
@@ -4,6 +4,37 @@ import { z } from "zod";
const shaSchema = z.string().regex(/^[a-f0-9]{40}$/u);
const versionSchema = z.object({ version: z.string() });
const manifestSchema = z.object({ ".": z.string() });
const PLUGIN_MANIFEST_PATHS = [
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
];
const inlinePluginManifestSchema = versionSchema.extend({
mcpServers: z.object({
rea: z.object({
command: z.literal("node"),
args: z.tuple([
z.literal("${CLAUDE_PLUGIN_ROOT}/scripts/plugin-mcp-launcher.mjs"),
z.string(),
]),
}),
}),
});
const codexPluginManifestSchema = versionSchema.extend({
mcpServers: z.literal("./.mcp.json"),
});
const codexServerConfigSchema = z.object({
mcpServers: z.object({
rea: z.object({
command: z.literal("node"),
args: z.tuple([
z.literal("./scripts/plugin-mcp-launcher.mjs"),
z.string(),
]),
cwd: z.literal("."),
startup_timeout_sec: z.number().int().positive(),
}),
}),
});
const sectionsSchema = z.object({
versioning: z.string().optional(),
"changelog-sections": z.array(
@@ -161,14 +192,14 @@ async function validateCandidate(git, sha, version, commits) {
const lock = await readJson(
git,
sha,
"package-lock.json",
"npm-shrinkwrap.json",
versionSchema.extend({
packages: z.object({ "": versionSchema }),
}),
);
versions.push(
["package-lock.json", lock.version],
["package-lock.json packages.root", lock.packages[""].version],
["npm-shrinkwrap.json", lock.version],
["npm-shrinkwrap.json packages.root", lock.packages[""].version],
);
const server = await readJson(
git,
@@ -189,6 +220,21 @@ async function validateCandidate(git, sha, version, commits) {
["server.json", server.version],
["server.json rea-agents package", npmPackage.version],
);
// A stale manifest keeps the previous plugin cache key or launches the
// previous server, so both are version authority like server.json.
const launches = [];
for (const path of PLUGIN_MANIFEST_PATHS) {
const manifest =
path === ".codex-plugin/plugin.json"
? await readJson(git, sha, path, codexPluginManifestSchema)
: await readJson(git, sha, path, inlinePluginManifestSchema);
versions.push([path, manifest.version]);
const server =
path === ".codex-plugin/plugin.json"
? await readJson(git, sha, ".mcp.json", codexServerConfigSchema)
: manifest;
launches.push([path, server.mcpServers.rea.args[1]]);
}
// Catalogs are generated and checked from this candidate by the release job;
// they are not version authority stored in the immutable source tree.
const generated = await git([
@@ -210,6 +256,16 @@ async function validateCandidate(git, sha, version, commits) {
);
}
}
// Plugin JSON stores only a scalar version. The launcher owns the fixed
// package name and constructs the complete registry specifier at runtime.
const requiredLaunch = version;
for (const [location, launched] of launches) {
if (launched !== requiredLaunch) {
throw new Error(
`${location}@${sha} launches version ${launched ?? "no version"}; release branch requires ${requiredLaunch}.`,
);
}
}
const notes = releaseNotes(
await git(["show", `${sha}:CHANGELOG.md`]),
version,
+65 -1
View File
@@ -365,6 +365,60 @@ export function verifyReleaseNotes(raw, text, body) {
};
}
const VERSION_ONLY_PLUGIN_METADATA = new Set([
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
".mcp.json",
".claude-plugin/marketplace.json",
".agents/plugins/marketplace.json",
]);
/** Serialize plugin metadata with only its release-owned version fields blanked. */
function pluginMetadataWithoutVersions(path, text) {
const manifest = JSON.parse(text);
if (manifest === null || typeof manifest !== "object") return text;
if (path.endsWith("marketplace.json")) {
if (Array.isArray(manifest.plugins))
for (const plugin of manifest.plugins)
if (
plugin?.name === "rea" &&
plugin?.source?.source === "npm" &&
plugin.source.package === "rea-agents" &&
typeof plugin.source.version === "string"
)
plugin.source.version = "";
return JSON.stringify(manifest);
}
if ("version" in manifest) manifest.version = "";
const launch = manifest.mcpServers?.rea?.args;
// Only the scalar pinned version is release-owned; the launcher owns the
// fixed package name and the executable/path remain product behavior.
if (Array.isArray(launch) && typeof launch[1] === "string") launch[1] = "";
return JSON.stringify(manifest);
}
/**
* Plugin metadata also carries behavior, so a release may change only
* its version, pinned server launch, and exact REA marketplace version. Any other difference, an unreadable
* side, or a manifest added by the release counts as a product change.
*/
async function changesOnlyPluginVersions(run, path, from, to) {
try {
return (
pluginMetadataWithoutVersions(
path,
await run("git", ["show", `${from}:${path}`]),
) ===
pluginMetadataWithoutVersions(
path,
await run("git", ["show", `${to}:${path}`]),
)
);
} catch {
return false;
}
}
/** Reject stale inventories and product changes made after the editorial target. */
export async function verifyInventoryHistory(run, raw, source) {
const inventory = releaseInventorySchema.parse(raw);
@@ -416,6 +470,7 @@ export async function verifyInventoryHistory(run, raw, source) {
".release-please-manifest.json",
"package.json",
"package-lock.json",
"npm-shrinkwrap.json",
"server.json",
"src/generatedPackageMetadata.ts",
"docs/installation.md",
@@ -433,7 +488,16 @@ export async function verifyInventoryHistory(run, raw, source) {
)
.split("\0")
.filter(Boolean);
const product = paths.filter((path) => !allowed.has(path));
const product = [];
for (const path of paths) {
if (allowed.has(path)) continue;
if (
VERSION_ONLY_PLUGIN_METADATA.has(path) &&
(await changesOnlyPluginVersions(run, path, inventory.target, resolved))
)
continue;
product.push(path);
}
if (product.length)
throw new Error(
`Product changed after notes target; regenerate inventory: ${product.join(", ")}`,
+274
View File
@@ -0,0 +1,274 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { access, mkdtemp, rm } from "node:fs/promises";
import { constants, tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
// Keep the package name at the launcher boundary so release automation updates
// only a scalar version in plugin JSON, never a compound package specifier.
// Git plugins launch the registry release. Windows source plugins additionally
// require a build and native addon for the owned process supervisor.
const [version, ...extra] = process.argv.slice(2);
if (
extra.length !== 0 ||
!/^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/u.test(version ?? "")
) {
process.stderr.write(
"REA plugin requires one exact rea-agents@VERSION pin.\n",
);
process.exitCode = 1;
} else {
await launch(`rea-agents@${version}`);
}
async function launch(packageSpecifier) {
// npm installs already carry the release-pinned runtime. Run it in this
// process so the plugin keeps the caller's working directory and the
// runtime retains ownership of provider and worker cleanup.
const { default: bundledPackage } = await import("../package.json", {
with: { type: "json" },
});
const sourceCheckout = await isSourceCheckout();
let bundledRuntimeMatchesPin =
!sourceCheckout &&
packageSpecifier === `${bundledPackage.name}@${bundledPackage.version}`;
if (bundledRuntimeMatchesPin) {
try {
await access(new URL("../dist/mcp/main.js", import.meta.url));
} catch (cause) {
if (!isMissing(cause)) throw cause;
bundledRuntimeMatchesPin = false;
}
}
if (bundledRuntimeMatchesPin) {
process.argv = [
process.execPath,
fileURLToPath(new URL("./rea.mjs", import.meta.url)),
"mcp",
];
await import("./rea.mjs");
return;
}
const runnerModule = await import(
sourceCheckout
? "../src/application/PackageRunnerCommand.mjs"
: "../dist/application/PackageRunnerCommand.mjs"
);
const { packageRunnerCommand } = runnerModule;
const [command, ...args] = packageRunnerCommand(
"npx",
packageSpecifier,
process.platform,
);
// npm can satisfy the exact pin from a same-version local checkout. An
// empty owned cwd prevents a Git-installed plugin or caller project from
// shadowing the registry package with unbuilt/different local files.
let workingDirectory;
try {
workingDirectory = await mkdtemp(join(tmpdir(), "rea-plugin-launcher-"));
} catch (cause) {
process.stderr.write(
`REA plugin could not create its working directory: ${cause.message}\n`,
);
process.exitCode = 1;
return;
}
if (process.platform === "win32") {
await launchWindowsFallback(command, args, workingDirectory);
return;
}
const child = spawn(command, args, {
cwd: workingDirectory,
// A dedicated POSIX process group gives this launcher ownership of the
// package runner and any server it starts.
detached: true,
stdio: "inherit",
shell: false,
});
let spawnFailed = false;
const signals = new Map();
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
const forward = () => {
if (child.exitCode !== null || child.signalCode !== null) return;
try {
if (process.platform !== "win32" && child.pid !== undefined)
process.kill(-child.pid, signal);
else child.kill(signal);
} catch (cause) {
if (!isMissingProcess(cause))
process.stderr.write(
`REA plugin could not forward ${signal}: ${cause.message}\n`,
);
}
};
signals.set(signal, forward);
process.on(signal, forward);
}
const stopForwarding = () => {
for (const [signal, listener] of signals) process.off(signal, listener);
};
child.once("error", (cause) => {
spawnFailed = true;
stopForwarding();
process.stderr.write(
`REA plugin could not start ${command}: ${cause.message}\n`,
);
process.exitCode = 1;
});
child.once("close", async (code, signal) => {
stopForwarding();
let cleanupFailed = false;
try {
await rm(workingDirectory, { recursive: true });
} catch (cause) {
cleanupFailed = true;
process.stderr.write(
`REA plugin retained its working directory at ${workingDirectory}: ${cause.message}\n`,
);
}
if (spawnFailed) return;
if (signal !== null && process.platform !== "win32") {
// Preserve signal termination after the owned child has closed its streams.
process.kill(process.pid, signal);
} else {
process.exitCode =
code === 0 && cleanupFailed
? 1
: (code ?? 128 + (constants.signals[signal] ?? 1));
}
});
}
async function isSourceCheckout() {
// Published artifacts exclude src/. Also reject Git worktrees (whose .git is
// a file) and source exports where Git metadata has been stripped.
for (const path of ["../.git", "../src"]) {
try {
await access(new URL(path, import.meta.url));
return true;
} catch (cause) {
if (!isMissing(cause)) throw cause;
}
}
return false;
}
async function launchWindowsFallback(command, args, workingDirectory) {
let spawnOwnedProviderProcess;
try {
({ spawnOwnedProviderProcess } =
await import("../dist/process/ProviderProcess.js"));
} catch (cause) {
await rm(workingDirectory, { recursive: true }).catch(() => {});
process.stderr.write(
`REA Windows source plugins require npm ci && npm run build:cached and the Windows x64 native addon before loading the plugin. The owned process supervisor is unavailable: ${cause.message}\n`,
);
process.exitCode = 1;
return;
}
let owned;
try {
owned = await spawnOwnedProviderProcess({
command,
arguments: args,
runId: randomUUID(),
cwd: workingDirectory,
stdin: "pipe",
platform: "win32",
});
} catch (cause) {
await rm(workingDirectory, { recursive: true }).catch(() => {});
process.stderr.write(
`REA plugin could not start the owned Windows process tree: ${cause.message}\n`,
);
process.exitCode = 1;
return;
}
const child = owned.process;
child.stdout?.pipe(process.stdout);
child.stderr?.pipe(process.stderr);
if (child.stdin === null || child.stdin === undefined) {
const cleanup = await owned.cleanup?.();
if (cleanup?.cleaned === true)
await rm(workingDirectory, { recursive: true }).catch(() => {});
else
process.stderr.write(
`REA plugin retained its Windows process ownership directory at ${workingDirectory} because cleanup was not verified.\n`,
);
process.stderr.write(
"REA plugin Windows process supervisor did not provide protocol stdin.\n",
);
process.exitCode = 1;
return;
}
process.stdin.pipe(child.stdin);
let cleanupFailed = false;
let cleanupPromise;
const signals = new Map();
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
const forward = () => {
if (child.exitCode !== null || child.signalCode !== null) return;
cleanupPromise ??= owned
.cleanup?.()
.then((result) => {
if (!result.cleaned) {
cleanupFailed = true;
process.stderr.write(
`REA plugin could not verify Windows process-tree cleanup: ${result.reason}\n`,
);
}
})
.catch((cause) => {
cleanupFailed = true;
process.stderr.write(
`REA plugin Windows process-tree cleanup failed: ${cause.message}\n`,
);
});
};
signals.set(signal, forward);
process.on(signal, forward);
}
child.once("error", (cause) => {
process.stderr.write(
`REA plugin Windows process supervisor failed: ${cause.message}\n`,
);
process.exitCode = 1;
});
child.once("close", async (code) => {
for (const [signal, listener] of signals) process.off(signal, listener);
await cleanupPromise;
if (!cleanupFailed) {
try {
await rm(workingDirectory, { recursive: true });
} catch (cause) {
cleanupFailed = true;
process.stderr.write(
`REA plugin retained its working directory at ${workingDirectory}: ${cause.message}\n`,
);
}
}
process.exitCode = code === 0 && cleanupFailed ? 1 : (code ?? 1);
});
}
function isMissing(cause) {
return (
typeof cause === "object" &&
cause !== null &&
"code" in cause &&
cause.code === "ENOENT"
);
}
function isMissingProcess(cause) {
return (
typeof cause === "object" &&
cause !== null &&
"code" in cause &&
cause.code === "ESRCH"
);
}
+10 -5
View File
@@ -3,13 +3,14 @@ import {
requireMcpToolError,
} from "./lib/mcp-verifier-results.mjs";
import assert from "node:assert/strict";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { readFile, rm } from "node:fs/promises";
import { readFile, realpath, rm } from "node:fs/promises";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { PRODUCT_IDENTITY } from "../dist/identity.js";
import { MCP_STARTUP_POLICY } from "../dist/mcpStartupPolicy.js";
import { parseEvidence } from "../dist/domain/evidence.js";
import * as prompts from "./verify-package-prompts.mjs";
@@ -246,7 +247,7 @@ const verifyMcpInlineArtifactEvidence = async (
}
};
/** Connect to the packaged MCP server and verify catalog and Evidence composition. */
/** Check CLI diagnostics, then verify catalog and Evidence through the installed plugin. */
export async function verifyPackageMcp({
cli,
environment,
@@ -267,9 +268,13 @@ export async function verifyPackageMcp({
diagnosed.inventory?.tools?.expected
)
throw new Error("packaged production MCP doctor failed");
const scriptsDirectory = dirname(await realpath(cli));
const transport = new StdioClientTransport({
command: cli,
args: ["mcp"],
command: process.execPath,
args: [
join(scriptsDirectory, "plugin-mcp-launcher.mjs"),
PRODUCT_IDENTITY.packageVersion,
],
env: {
...environment,
},
+70
View File
@@ -25,6 +25,23 @@ export async function verifyPackagePack({ root, workspace }) {
const packedManifest = JSON.parse(
(await exec("tar", ["-xOf", tarball, "package/package.json"])).stdout,
);
const packedLock = JSON.parse(
(await exec("tar", ["-xOf", tarball, "package/npm-shrinkwrap.json"]))
.stdout,
);
if (
packedManifest.overrides !== undefined ||
![2, 3].includes(packedLock.lockfileVersion) ||
packedLock.version !== packedManifest.version ||
packedLock.packages?.[""].version !== packedManifest.version ||
JSON.stringify(packedLock.packages?.[""].dependencies) !==
JSON.stringify(packedManifest.dependencies) ||
JSON.stringify(packedLock.packages?.[""].optionalDependencies) !==
JSON.stringify(packedManifest.optionalDependencies)
)
throw new Error(
"package is not installable under the Claude plugin dependency policy",
);
if (
packedManifest.scripts?.postinstall !== undefined ||
packedManifest.dependencies?.["node-pty"] !== undefined ||
@@ -42,6 +59,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) => path.startsWith("package/src/")))
throw new Error(
"Packaged runtime must not masquerade as a source checkout",
);
if (packedFiles.some((path) => path.startsWith("package/.cache/")))
throw new Error("package included development cache or test metadata");
const skill = (
@@ -60,6 +81,55 @@ export async function verifyPackagePack({ root, workspace }) {
throw new Error(
"packaged skill metadata did not match the catalog shipped in the package",
);
for (const path of [
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
]) {
const manifest = JSON.parse(
(await exec("tar", ["-xOf", tarball, `package/${path}`])).stdout,
);
const serverConfig = path.startsWith(".codex")
? JSON.parse(
(await exec("tar", ["-xOf", tarball, "package/.mcp.json"])).stdout,
)
: manifest;
const expectedArgs = [
`${path.startsWith(".claude") ? "${CLAUDE_PLUGIN_ROOT}/" : "./"}scripts/plugin-mcp-launcher.mjs`,
packedManifest.version,
];
if (
manifest.version !== packedManifest.version ||
manifest.skills !== "./skills" ||
(path.startsWith(".codex")
? manifest.mcpServers !== "./.mcp.json" ||
serverConfig.mcpServers?.rea?.cwd !== "." ||
serverConfig.mcpServers?.rea?.startup_timeout_sec !== 30
: false) ||
serverConfig.mcpServers?.rea?.command !== "node" ||
JSON.stringify(serverConfig.mcpServers?.rea?.args) !==
JSON.stringify(expectedArgs)
)
throw new Error(
`Packaged plugin ${path} does not match the exact runtime and canonical skill release`,
);
}
if (!packedFiles.includes("package/.mcp.json"))
throw new Error("Packaged Codex MCP server declaration is missing");
for (const path of [
"scripts/plugin-mcp-launcher.mjs",
"dist/application/PackageRunnerCommand.mjs",
])
if (!packedFiles.includes(`package/${path}`))
throw new Error(
`Packaged plugin launcher dependency is missing: ${path}`,
);
if (
skill.includes(`${packedManifest.name}@latest`) ||
!skill.includes(`${packedManifest.name}@${packedManifest.version}`)
)
throw new Error(
"Packaged plugin skill recovery is not pinned to the shipped runtime",
);
if (
packedFiles.some(
(path) => path.includes("__pycache__") || path.endsWith(".pyc"),
@@ -2,6 +2,7 @@ import { basename, isAbsolute, resolve } from "node:path";
import { valid } from "semver";
import { PRODUCT_IDENTITY } from "../identity.js";
import { packageRunnerCommand } from "./PackageRunnerCommand.mjs";
/** Package runners whose pinned launcher setup can register. */
export type PackageRunner = "npx" | "bunx";
@@ -53,14 +54,12 @@ export const withoutPackageRunnerIdentity = (
export const packageRunnerRegistrationCommand = (
runner: PackageRunner,
platform: NodeJS.Platform = process.platform,
): readonly string[] => {
const command =
runner === "npx"
? ["npx", "-y", PRODUCT_IDENTITY.registrationPackageSpecifier, "mcp"]
: ["bunx", PRODUCT_IDENTITY.registrationPackageSpecifier, "mcp"];
// Both runners may be .cmd shims on Windows, which clients cannot spawn directly.
return platform === "win32" ? ["cmd.exe", "/d", "/c", ...command] : command;
};
): readonly string[] =>
packageRunnerCommand(
runner,
PRODUCT_IDENTITY.registrationPackageSpecifier,
platform,
);
/** Resolve a pinned npx launcher that stdio clients can spawn on the host. */
export const npxRegistrationCommand = (
@@ -0,0 +1,6 @@
/** Exact host argv for a pinned registry package's MCP entry point. */
export function packageRunnerCommand(
runner: "npx" | "bunx",
packageSpecifier: string,
platform: NodeJS.Platform,
): readonly string[];
+16
View File
@@ -0,0 +1,16 @@
/** Host argv shared by setup registrations and the source-only plugin launcher. */
export const packageRunnerCommand = (runner, packageSpecifier, platform) => {
if (runner !== "npx" && runner !== "bunx")
throw new Error("Unsupported package runner");
// Windows cmd.exe consumes these tokens itself. Package registrations use
// exact registry references, never shell expressions or arbitrary operands.
if (!/^[@A-Za-z0-9_./+-]+$/u.test(packageSpecifier))
throw new Error("Package runner requires a shell-safe registry reference");
const command =
runner === "npx"
? ["npx", "-y", packageSpecifier, "mcp"]
: ["bunx", packageSpecifier, "mcp"];
// npm's .cmd shim is not a native executable. Disable cmd AutoRun, matching
// the setup registration policy, and keep all dynamic values out of syntax.
return platform === "win32" ? ["cmd.exe", "/d", "/c", ...command] : command;
};
-8
View File
@@ -606,14 +606,6 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
markerPath: traeUserDirectory,
format: "json",
},
{
name: "workbuddy",
displayName: "WorkBuddy",
skillPath: [".workbuddy", "skills"],
configPath: [".workbuddy", "mcp.json"],
markerPath: [".workbuddy"],
format: "json",
},
{
name: "grok_build",
displayName: "Grok Build",
File diff suppressed because one or more lines are too long
+1 -7
View File
@@ -89,17 +89,11 @@ export const ARTIFACT_TOOL_CONTRACTS = [
),
artifact(
"decode_interface_builder",
"Decode compiled storyboard and nib archives inside the active Apple app bundle, including keyed property lists and NIBArchive object tables, into a bounded object, hierarchy, and connection graph. This is static archive parsing; unrecognized archive classes and unresolved code handlers remain explicit unknowns. A full app graph is often several megabytes; detail summary retains it and returns documents with node, edge and coverage counts, then page rows, optionally one document's, with inspect_analysis_view kind interface-builder.",
"Decode compiled storyboard and nib archives inside the active Apple app bundle, including keyed property lists and NIBArchive object tables, into a bounded object, hierarchy, and connection graph. This is static archive parsing; unrecognized archive classes and unresolved code handlers remain explicit unknowns.",
z.strictObject({
max_documents: z.number().int().min(1).max(64).default(64),
max_objects: z.number().int().min(1).max(20_000).default(20_000),
max_connections: z.number().int().min(1).max(40_000).default(40_000),
detail: analysisDetailSchema
.unwrap()
.optional()
.describe(
"Omitted or complete returns the complete decoded graph Evidence. summary retains that complete Evidence in this session and returns its inspect_analysis_view summary; use normalized_result.parent_evidence_id for interface-builder pages.",
),
}),
),
artifact(
+1 -7
View File
@@ -2,7 +2,6 @@ import { z } from "zod";
import { byteSearchResultSchema } from "../domain/native/bytePattern.js";
import {
artifactInventorySummaryViewResultSchema,
interfaceBuilderSummaryViewResultSchema,
machoSummaryViewResultSchema,
managedArtifactSummaryViewResultSchema,
} from "../domain/analysisView/analysisView.js";
@@ -405,12 +404,7 @@ export const artifactOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
]),
),
extract_artifact: evidenceResultOf(artifactExtractionResultSchema),
decode_interface_builder: evidenceResultOf(
z.union([
interfaceBuilderAnalysisSchema,
interfaceBuilderSummaryViewResultSchema,
]),
),
decode_interface_builder: evidenceResultOf(interfaceBuilderAnalysisSchema),
inspect_keyed_archive: evidenceResultOf(keyedArchiveResultSchema),
inspect_asset_catalog: evidenceResultOf(appleAssetCatalogResultSchema),
trace_dylib_resolution: evidenceResultOf(dylibResolutionResultSchema),
+1 -31
View File
@@ -62,13 +62,6 @@ import {
machoViewRequestSchema,
projectMachoView,
} from "./machoView.js";
import {
interfaceBuilderSummarySchema,
interfaceBuilderViewContextSchema,
interfaceBuilderViewItemsSchema,
interfaceBuilderViewRequestSchema,
projectInterfaceBuilderView,
} from "./interfaceBuilderView.js";
import {
projectWebNetworkCaptureView,
captureViewRequestSchema,
@@ -213,7 +206,6 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [
inventoryViewSchema,
wasmViewRequestSchema,
machoViewRequestSchema,
interfaceBuilderViewRequestSchema,
evmViewRequestSchema,
managedViewRequestSchema,
androidViewRequestSchema,
@@ -334,7 +326,6 @@ const viewResultBase = {
"inventory_artifact",
"inspect_wasm_artifact",
"inspect_macho",
"decode_interface_builder",
"inspect_evm_interface",
"inspect_managed_artifact",
"inspect_web_network_capture",
@@ -391,13 +382,6 @@ export const machoSummaryViewResultSchema = z.strictObject({
summary: machoSummarySchema,
});
/** Decoded documents and graph counts for Interface Builder's selected delivery. */
export const interfaceBuilderSummaryViewResultSchema = z.strictObject({
...summaryViewResultBase,
parent_operation: z.literal("decode_interface_builder"),
summary: interfaceBuilderSummarySchema,
});
/** Exact inferred-interface summary for EVM's caller-selected delivery. */
export const evmInterfaceSummaryViewResultSchema = z.strictObject({
...summaryViewResultBase,
@@ -460,7 +444,6 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
inventorySummarySchema,
wasmSummarySchema,
machoSummarySchema,
interfaceBuilderSummarySchema,
evmSummarySchema,
managedSummarySchema,
captureSummarySchema,
@@ -531,14 +514,6 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
...machoViewContextSchema.shape,
parent_operation: z.literal("inspect_macho"),
}),
z.strictObject({
kind: z.literal("interface-builder"),
view: interfaceBuilderViewRequestSchema,
items: interfaceBuilderViewItemsSchema,
...viewResultBase,
...interfaceBuilderViewContextSchema.shape,
parent_operation: z.literal("decode_interface_builder"),
}),
z.strictObject({
kind: z.literal("evm"),
view: evmViewRequestSchema,
@@ -636,10 +611,6 @@ export type UnsignedAnalysisView =
>
| Omit<Extract<AnalysisViewResult, { readonly kind: "wasm" }>, "view_digest">
| Omit<Extract<AnalysisViewResult, { readonly kind: "macho" }>, "view_digest">
| Omit<
Extract<AnalysisViewResult, { readonly kind: "interface-builder" }>,
"view_digest"
>
| Omit<Extract<AnalysisViewResult, { readonly kind: "evm" }>, "view_digest">
| Omit<Extract<AnalysisViewResult, { readonly kind: "qlog" }>, "view_digest">
| Omit<Extract<AnalysisViewResult, { readonly kind: "pcap" }>, "view_digest">
@@ -703,7 +674,7 @@ const unsupportedParent = (
new AnalysisUnsupportedTargetError(
INSPECT_ANALYSIS_VIEW_OPERATION,
parentArtifactPath(parent),
`Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function, artifact inventory/inspection, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package, Dart AOT, or web network capture Evidence.`,
`Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function, artifact inventory/inspection, WASM, Mach-O, EVM, managed artifact, Android package, Dart AOT, or web network capture Evidence.`,
);
const parentArtifactPath = (parent: AnalysisViewParent): string => {
@@ -731,7 +702,6 @@ const parentArtifactPath = (parent: AnalysisViewParent): string => {
const retainedViewProjectors = new Map<string, typeof projectWasmArtifactView>([
["inspect_wasm_artifact", projectWasmArtifactView],
["inspect_macho", projectMachoView],
["decode_interface_builder", projectInterfaceBuilderView],
["inspect_evm_interface", projectEvmInterfaceView],
["inspect_managed_artifact", projectManagedArtifactView],
["inspect_android_package", projectAndroidPackageView],
@@ -1,25 +0,0 @@
import type { z } from "zod";
import type { AnalysisError } from "../analysisErrorBase.js";
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
import { err, ok, type Result } from "../result.js";
import type { AnalysisViewParent } from "./analysisView.js";
/** Parse a parent's result with its owning schema; issues point into the view source. */
export const parseAnalysisViewResult = <Schema extends z.ZodType>(
schema: Schema,
parent: AnalysisViewParent,
): Result<z.output<Schema>, AnalysisError> => {
const parsed = schema.safeParse(parent.normalizedResult);
if (parsed.success) return ok(parsed.data);
return err(
analysisInputErrorFromIssues(
"inspect_analysis_view",
parsed.error.issues.map((issue) => ({
...issue,
path: ["source", "normalized_result", ...issue.path],
})),
{ source: { normalized_result: parent.normalizedResult } },
{ cause: parsed.error },
),
);
};
+15 -4
View File
@@ -1,8 +1,8 @@
import { z } from "zod";
import type { AnalysisError } from "../analysisErrorBase.js";
import { androidResultSchemas } from "../android/androidAnalysis.js";
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
import { err, ok, type Result } from "../result.js";
import { parseAnalysisViewResult } from "./analysisViewParent.js";
import {
analysisViewInputError,
type AnalysisViewParent,
@@ -53,8 +53,19 @@ export const projectAndroidPackageView = (
},
]),
);
const parsed = parseAnalysisViewResult(packageSchema, parent);
if (!parsed.ok) return parsed;
const parsed = packageSchema.safeParse(parent.normalizedResult);
if (!parsed.success)
return err(
analysisInputErrorFromIssues(
"inspect_analysis_view",
parsed.error.issues.map((issue) => ({
...issue,
path: ["source", "normalized_result", ...issue.path],
})),
{ source: { normalized_result: parent.normalizedResult } },
{ cause: parsed.error },
),
);
// Engine artifact_sha256 identifies the JAR, never the APK. A missing
// authenticated APK subject cannot be repaired using the engine identity.
if (parent.subject == null)
@@ -68,7 +79,7 @@ export const projectAndroidPackageView = (
},
]),
);
const report = parsed.value;
const report = parsed.data;
const shared = {
parent_evidence_id: parent.evidenceId,
parent_operation: "inspect_android_package" as const,
+15 -4
View File
@@ -1,8 +1,8 @@
import { z } from "zod";
import type { AnalysisError } from "../analysisErrorBase.js";
import { flutterResultSchemas } from "../flutter/flutterBuildAnalysis.js";
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
import { err, ok, type Result } from "../result.js";
import { parseAnalysisViewResult } from "./analysisViewParent.js";
import {
analysisViewInputError,
type AnalysisViewParent,
@@ -59,9 +59,20 @@ export const projectDartAotView = (
},
]),
);
const parsed = parseAnalysisViewResult(aotSchema, parent);
if (!parsed.ok) return parsed;
const report = parsed.value;
const parsed = aotSchema.safeParse(parent.normalizedResult);
if (!parsed.success)
return err(
analysisInputErrorFromIssues(
"inspect_analysis_view",
parsed.error.issues.map((issue) => ({
...issue,
path: ["source", "normalized_result", ...issue.path],
})),
{ source: { normalized_result: parent.normalizedResult } },
{ cause: parsed.error },
),
);
const report = parsed.data;
const { package_uris, dart_uris, class_like_tokens, ...pool } =
report.string_pool;
const string_pool = {
+15 -4
View File
@@ -2,8 +2,8 @@ import { z } from "zod";
import type { AnalysisError } from "../analysisErrorBase.js";
import { uniqueSorted } from "../canonicalOrdering.js";
import { evmInterfaceSchema } from "../evm/evmInterface.js";
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
import { err, ok, type Result } from "../result.js";
import { parseAnalysisViewResult } from "./analysisViewParent.js";
import {
analysisViewInputError,
type AnalysisViewParent,
@@ -60,9 +60,20 @@ export const projectEvmInterfaceView = (
},
]),
);
const parsed = parseAnalysisViewResult(evmInterfaceSchema, parent);
if (!parsed.ok) return parsed;
const report = parsed.value;
const parsed = evmInterfaceSchema.safeParse(parent.normalizedResult);
if (!parsed.success)
return err(
analysisInputErrorFromIssues(
"inspect_analysis_view",
parsed.error.issues.map((issue) => ({
...issue,
path: ["source", "normalized_result", ...issue.path],
})),
{ source: { normalized_result: parent.normalizedResult } },
{ cause: parsed.error },
),
);
const report = parsed.data;
if (
parent.subject == null ||
parent.subject.format !== "file" ||
@@ -1,168 +0,0 @@
import { describe, expect, it } from "vitest";
import {
projectAnalysisView,
type AnalysisViewParent,
} from "./analysisView.js";
const target = "a".repeat(64);
const evidence = (artifact_path: string) => [
{
kind: "interface_builder_resource" as const,
description: "compiled document",
location: { address: null, file_offset: null },
artifact_path,
artifact_sha256: "b".repeat(64),
},
];
const node = (id: string, kind: string, document: string) => ({
id,
kind,
name: id,
location: null,
attributes: {},
evidence: evidence(document),
});
const main = "Contents/Resources/Main.nib";
const second = "Contents/Resources/Second.nib";
const document = (relative_path: string) => ({
relative_path,
archive_sha256: "b".repeat(64),
document_kind: "nib" as const,
object_count: 2,
connection_count: 1,
hierarchy_complete: true,
});
const report = (truncated = false) => ({
target_sha256: target,
documents: [document(main), document(second)],
graph: {
target_sha256: target,
provider: { id: "rea-ib", version: "1", tool_version: "1" },
nodes: [
node("main:root", "scene", main),
node("main:button", "control", main),
node("second:root", "scene", second),
],
edges: [
{
id: "main:edge",
from: "main:root",
to: "main:button",
relation: "contains",
resolution: "observed",
evidence: evidence(main),
limitations: [],
},
],
coverage: [
{
facet: `objects:${main}`,
status: "complete",
reason: null,
examined: 2,
omitted: 0,
},
],
truncated,
},
limitations: ["Recognized archive fields only."],
});
const parent = (
normalizedResult: unknown = report(),
sha256 = target,
): AnalysisViewParent => ({
evidenceId: `ev_${"c".repeat(64)}`,
operation: "decode_interface_builder",
normalizedResult,
subject: {
name: "Fixture",
digest: { sha256 },
format: "mach-o",
architecture: "arm64",
local_path: "/Applications/Fixture.app/Contents/MacOS/Fixture",
},
limitations: [],
});
describe("Interface Builder analysis views", () => {
it("summarizes documents and graph counts without rows", () => {
const projected = projectAnalysisView(parent(), { kind: "summary" });
if (!projected.ok) throw projected.error;
expect(projected.value).toMatchObject({
kind: "summary",
parent_operation: "decode_interface_builder",
artifact: { sha256: target },
summary: {
documents: report().documents,
available_counts: { nodes: 3, edges: 1, coverage: 1 },
node_kinds: { control: 1, scene: 2 },
edge_relations: { contains: 1 },
coverage_statuses: { complete: 1 },
truncated: false,
},
coverage: { examined: 7, total: 7 },
});
expect(JSON.stringify(projected.value)).not.toContain("main:button");
});
it("pages one document's nodes and keeps decoder truncation visible", () => {
const projected = projectAnalysisView(parent(), {
kind: "interface-builder",
collection: "nodes",
document: main,
offset: 1,
limit: 5,
});
if (!projected.ok) throw projected.error;
expect(projected.value).toMatchObject({
kind: "interface-builder",
items: [node("main:button", "control", main)],
source_truncated: false,
coverage: { status: "page", examined: 1, total: 2, exhausted: true },
});
const truncated = projectAnalysisView(parent(report(true)), {
kind: "interface-builder",
collection: "coverage",
offset: 0,
limit: 64,
});
if (!truncated.ok) throw truncated.error;
expect(truncated.value).toMatchObject({
items: report().graph.coverage,
source_truncated: true,
unknowns: expect.arrayContaining([
expect.stringContaining("The decoded graph is incomplete"),
]),
});
});
it("refuses unknown documents, other views and mismatched targets", () => {
const unknown = projectAnalysisView(parent(), {
kind: "interface-builder",
collection: "edges",
document: "Contents/Resources/Missing.nib",
offset: 0,
limit: 64,
});
if (unknown.ok) throw new Error("expected an unknown-document refusal");
expect(unknown.error).toMatchObject({
issues: [{ path: ["view", "document"] }],
});
const wrongKind = projectAnalysisView(parent(), {
kind: "macho",
facet: "imports",
offset: 0,
limit: 64,
});
if (wrongKind.ok) throw new Error("expected a view-kind refusal");
expect(wrongKind.error).toMatchObject({ issues: [{ path: ["view"] }] });
const mismatched = projectAnalysisView(parent(report(), "d".repeat(64)), {
kind: "summary",
});
if (mismatched.ok) throw new Error("expected a subject refusal");
expect(mismatched.error).toMatchObject({
issues: [{ path: ["source", "subject"] }],
});
});
});
@@ -1,248 +0,0 @@
import { z } from "zod";
import type { AnalysisError } from "../analysisErrorBase.js";
import { uniqueSorted } from "../canonicalOrdering.js";
import {
interfaceBuilderAnalysisSchema,
interfaceBuilderDocumentSchema,
} from "../apple/interfaceBuilderGraph.js";
import {
nativeInvestigationCoverageSchema,
nativeInvestigationEdgeSchema,
nativeInvestigationNodeSchema,
} from "../native/nativeInvestigationGraph.js";
import { err, ok, type Result } from "../result.js";
import { parseAnalysisViewResult } from "./analysisViewParent.js";
import {
analysisViewInputError,
type AnalysisViewParent,
type AnalysisViewRequest,
type UnsignedAnalysisView,
} from "./analysisView.js";
import {
completeWithinViewCoverage,
pageViewCoverage,
} from "./analysisViewCoverage.js";
const page = {
kind: z.literal("interface-builder"),
offset: z.number().int().nonnegative().default(0),
limit: z.number().int().positive().default(64),
} as const;
/** Original decoded rows; graph rows can be narrowed to one decoded document. */
export const interfaceBuilderViewRequestSchema = z.discriminatedUnion(
"collection",
[
z.strictObject({
...page,
collection: z.enum(["nodes", "edges"]),
document: z
.string()
.min(1)
.optional()
.describe(
"Exact documents[].relative_path; selects rows whose evidence names that archive.",
),
}),
z.strictObject({
...page,
collection: z.enum(["documents", "coverage"]),
}),
],
);
export const interfaceBuilderViewItemsSchema = z.union([
z.array(interfaceBuilderDocumentSchema),
z.array(nativeInvestigationNodeSchema),
z.array(nativeInvestigationEdgeSchema),
z.array(nativeInvestigationCoverageSchema),
]);
/** The decoder's own truncation, independent of page exhaustion. */
export const interfaceBuilderViewContextSchema = z.strictObject({
source_truncated: z.boolean(),
});
const countsSchema = z.record(z.string(), z.number().int().positive());
/** Every decoded document with graph counts; rows and evidence stay retained. */
export const interfaceBuilderSummarySchema = z.strictObject({
target_sha256: interfaceBuilderAnalysisSchema.shape.target_sha256,
provider: interfaceBuilderAnalysisSchema.shape.graph.shape.provider,
documents: interfaceBuilderAnalysisSchema.shape.documents,
available_counts: z.strictObject({
nodes: z.number().int().nonnegative(),
edges: z.number().int().nonnegative(),
coverage: z.number().int().nonnegative(),
}),
node_kinds: countsSchema,
edge_relations: countsSchema,
coverage_statuses: countsSchema,
truncated: z.boolean(),
limitation_count: z.number().int().nonnegative(),
});
const tally = (values: readonly string[]): Record<string, number> => {
const counts = new Map<string, number>();
for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
return Object.fromEntries(
[...counts].sort(([left], [right]) => (left < right ? -1 : 1)),
);
};
type InterfaceBuilderReport = z.output<typeof interfaceBuilderAnalysisSchema>;
/** Admit a well-formed report bound to its recorded target, if one exists. */
const parseInterfaceBuilderReport = (
parent: AnalysisViewParent,
): Result<InterfaceBuilderReport, AnalysisError> => {
const parsed = parseAnalysisViewResult(
interfaceBuilderAnalysisSchema,
parent,
);
if (!parsed.ok) return parsed;
const report = parsed.value;
if (
report.graph.target_sha256 !== report.target_sha256 ||
(parent.subject != null &&
parent.subject.digest.sha256 !== report.target_sha256)
)
return err(
analysisViewInputError([
{
path: ["source", "subject"],
reason: "invalid_value",
message:
"Interface Builder parent Evidence subject and graph must identify the report's target SHA-256.",
},
]),
);
return ok(report);
};
/** Every decoded document with graph tallies; rows stay in the retained parent. */
const summarizeInterfaceBuilder = (
report: InterfaceBuilderReport,
limitationCount: number,
) => {
const { graph } = report;
const total =
report.documents.length +
graph.nodes.length +
graph.edges.length +
graph.coverage.length;
return {
summary: {
target_sha256: report.target_sha256,
provider: graph.provider,
documents: report.documents,
available_counts: {
nodes: graph.nodes.length,
edges: graph.edges.length,
coverage: graph.coverage.length,
},
node_kinds: tally(graph.nodes.map((node) => node.kind)),
edge_relations: tally(graph.edges.map((edge) => edge.relation)),
coverage_statuses: tally(graph.coverage.map((row) => row.status)),
truncated: graph.truncated,
limitation_count: limitationCount,
},
coverage: completeWithinViewCoverage(total, total),
};
};
/** Project retained decode_interface_builder Evidence without reopening archives. */
export const projectInterfaceBuilderView = (
parent: AnalysisViewParent,
view: AnalysisViewRequest,
): Result<UnsignedAnalysisView, AnalysisError> => {
if (view.kind !== "summary" && view.kind !== "interface-builder")
return err(
analysisViewInputError([
{
path: ["view"],
reason: "invalid_value",
message:
"Interface Builder decoding supports summary and interface-builder views.",
},
]),
);
const parsed = parseInterfaceBuilderReport(parent);
if (!parsed.ok) return parsed;
const report = parsed.value;
const { graph } = report;
const limitations = uniqueSorted([
...parent.limitations,
...report.limitations,
]);
const shared = {
parent_evidence_id: parent.evidenceId,
parent_operation: "decode_interface_builder" as const,
parent_digest: parent.evidenceId.slice(3),
artifact: {
path: parent.subject?.local_path ?? "",
sha256: report.target_sha256,
},
limitations,
unknowns: [
"Rows describe recognized archive objects and connections only; unrecognized classes, fields and runtime-created UI remain unknown.",
...(graph.truncated
? [
"The decoded graph is incomplete (see coverage and limitations for the cause); page exhaustion does not establish absence of undecoded objects or connections.",
]
: []),
],
};
if (view.kind === "summary")
return ok({
kind: "summary",
view,
...shared,
...summarizeInterfaceBuilder(report, limitations.length),
});
const document =
view.collection === "nodes" || view.collection === "edges"
? view.document
: undefined;
if (
document !== undefined &&
!report.documents.some((row) => row.relative_path === document)
)
return err(
analysisViewInputError([
{
path: ["view", "document"],
reason: "invalid_value",
message:
"Select an exact documents[].relative_path from this decoded report.",
},
]),
);
const inDocument = <
Row extends { readonly evidence: readonly { artifact_path: unknown }[] },
>(
candidates: readonly Row[],
): readonly Row[] =>
document === undefined
? candidates
: candidates.filter((row) =>
row.evidence.some((evidence) => evidence.artifact_path === document),
);
const rows =
view.collection === "documents"
? report.documents
: view.collection === "coverage"
? graph.coverage
: view.collection === "nodes"
? inDocument(graph.nodes)
: inDocument(graph.edges);
const items = rows.slice(view.offset, view.offset + view.limit);
return ok({
kind: "interface-builder",
view,
items,
...shared,
source_truncated: graph.truncated,
coverage: pageViewCoverage(view.offset, items.length, rows.length),
});
};
+15 -4
View File
@@ -2,9 +2,9 @@ import { z } from "zod";
import type { AnalysisError } from "../analysisErrorBase.js";
import { uniqueSorted } from "../canonicalOrdering.js";
import { isMachOFormat } from "../binaryTargetTypes.js";
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
import { inspectMachoSchema } from "../native/nativeInspection.js";
import { err, ok, type Result } from "../result.js";
import { parseAnalysisViewResult } from "./analysisViewParent.js";
import {
analysisViewInputError,
type AnalysisViewParent,
@@ -93,8 +93,19 @@ type MachoReport = z.output<typeof inspectMachoSchema>;
const parseMachoReport = (
parent: AnalysisViewParent,
): Result<MachoReport, AnalysisError> => {
const parsed = parseAnalysisViewResult(inspectMachoSchema, parent);
if (!parsed.ok) return parsed;
const parsed = inspectMachoSchema.safeParse(parent.normalizedResult);
if (!parsed.success)
return err(
analysisInputErrorFromIssues(
"inspect_analysis_view",
parsed.error.issues.map((issue) => ({
...issue,
path: ["source", "normalized_result", ...issue.path],
})),
{ source: { normalized_result: parent.normalizedResult } },
{ cause: parsed.error },
),
);
if (parent.subject != null && !isMachOFormat(parent.subject.format))
return err(
analysisViewInputError([
@@ -106,7 +117,7 @@ const parseMachoReport = (
},
]),
);
return ok(parsed.value);
return ok(parsed.data);
};
/** Counts replace rows; every collection caveat stays in the limitations. */
+16 -6
View File
@@ -1,12 +1,12 @@
import { z } from "zod";
import type { AnalysisError } from "../analysisErrorBase.js";
import { uniqueSorted } from "../canonicalOrdering.js";
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
import {
managedArtifactInspectionSchema,
managedMemberInspectionSchema,
} from "../managed/managedArtifact.js";
import { err, ok, type Result } from "../result.js";
import { parseAnalysisViewResult } from "./analysisViewParent.js";
import {
analysisViewInputError,
type AnalysisViewParent,
@@ -67,12 +67,22 @@ export const projectManagedArtifactView = (
},
]),
);
const parsed = parseAnalysisViewResult(
managedArtifactInspectionSchema,
parent,
const parsed = managedArtifactInspectionSchema.safeParse(
parent.normalizedResult,
);
if (!parsed.ok) return parsed;
const report = parsed.value;
if (!parsed.success)
return err(
analysisInputErrorFromIssues(
"inspect_analysis_view",
parsed.error.issues.map((issue) => ({
...issue,
path: ["source", "normalized_result", ...issue.path],
})),
{ source: { normalized_result: parent.normalizedResult } },
{ cause: parsed.error },
),
);
const report = parsed.data;
const identity_scope = {
token_identity: "build-local" as const,
requires_artifact_sha256: report.artifact.sha256,
+15 -4
View File
@@ -2,11 +2,11 @@ import { z } from "zod";
import type { AnalysisError } from "../analysisErrorBase.js";
import { uniqueSorted } from "../canonicalOrdering.js";
import { err, ok, type Result } from "../result.js";
import { parseAnalysisViewResult } from "./analysisViewParent.js";
import {
wasmArtifactSchema,
wasmArtifactMatchesSubject,
} from "../wasm/wasmArtifact.js";
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
import {
analysisViewInputError,
type AnalysisViewParent,
@@ -81,9 +81,20 @@ export const projectWasmArtifactView = (
},
]),
);
const parsed = parseAnalysisViewResult(wasmArtifactSchema, parent);
if (!parsed.ok) return parsed;
const report = parsed.value;
const parsed = wasmArtifactSchema.safeParse(parent.normalizedResult);
if (!parsed.success)
return err(
analysisInputErrorFromIssues(
"inspect_analysis_view",
parsed.error.issues.map((issue) => ({
...issue,
path: ["source", "normalized_result", ...issue.path],
})),
{ source: { normalized_result: parent.normalizedResult } },
{ cause: parsed.error },
),
);
const report = parsed.data;
if (
!wasmArtifactMatchesSubject(
report.artifact,
@@ -1,8 +1,8 @@
import { z } from "zod";
import type { AnalysisError } from "../analysisErrorBase.js";
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
import type { JsonValue } from "../jsonValue.js";
import { err, ok, type Result } from "../result.js";
import { parseAnalysisViewResult } from "./analysisViewParent.js";
import {
webNetworkCaptureRecordSchema,
webNetworkCaptureBinaryDescriptorSchema,
@@ -118,9 +118,20 @@ const preview = (record: WebNetworkCaptureRecord) => {
const parseCaptureParent = (
parent: AnalysisViewParent,
): Result<WebNetworkCapture, AnalysisError> => {
const parsed = parseAnalysisViewResult(webNetworkCaptureSchema, parent);
if (!parsed.ok) return parsed;
const report = parsed.value;
const parsed = webNetworkCaptureSchema.safeParse(parent.normalizedResult);
if (!parsed.success)
return err(
analysisInputErrorFromIssues(
"inspect_analysis_view",
parsed.error.issues.map((issue) => ({
...issue,
path: ["source", "normalized_result", ...issue.path],
})),
{ source: { normalized_result: parent.normalizedResult } },
{ cause: parsed.error },
),
);
const report = parsed.data;
if (
report.artifact.sha256 !== null &&
(parent.subject == null ||
-36
View File
@@ -337,42 +337,6 @@ describe("call path topology and capacity", () => {
});
});
describe("directional call relationship coverage", () => {
it.each([
{
directions: ["callers"] as ["callers"],
status: "not_found",
exhaustive: true,
},
{
directions: ["callees"] as ["callees"],
status: "unknown",
exhaustive: false,
},
{
directions: ["callers", "callees"] as ["callers", "callees"],
status: "unknown",
exhaustive: false,
},
])(
"uses $directions coverage for the forward search",
({ directions, status, exhaustive }) => {
const limitation = "The inventory omitted a reference.";
const relationshipCoverage: RelationshipCoverage = {
status: "partial",
limitations: [limitation],
limited_directions: directions,
};
const result = run([
observe("0x1000", ["0x2000"], "rea-workflow", { relationshipCoverage }),
observe("0x2000", [], "rea-workflow", { relationshipCoverage }),
]);
expect(result).toMatchObject({ status, search_scope: { exhaustive } });
expect(result.limitations).toContain(limitation);
},
);
});
describe("explicit call relationship coverage", () => {
it("accumulates 150,000 partial reasons without argument-stack exhaustion", () => {
const result = run([
+10 -8
View File
@@ -1,4 +1,3 @@
import { relationshipCoverageLimitations } from "./relationshipCoverage.js";
import {
callPathInputSchema,
callPathResultSchema,
@@ -11,7 +10,7 @@ import {
parseFunctionEvidence,
type FunctionSnapshot,
} from "./functionDossierEvidence.js";
import { isCallerOnlyIncompleteCoverage } from "./functionCallCoverage.js";
import { isCallerOnlyUnknownCoverage } from "./functionCallCoverage.js";
import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js";
/** Minimal directed caller-to-callee adjacency for call-path search. */
@@ -241,12 +240,15 @@ const inspectSearch = ({
const coverage = snapshot.evidence.relationship_coverage;
for (const limitation of snapshot.limitations)
limitations.push(limitation);
if (coverage !== null) {
for (const limitation of relationshipCoverageLimitations(coverage))
if (coverage?.status === "partial")
for (const limitation of coverage.limitations)
limitations.push(limitation);
for (const diagnostic of coverage.diagnostics ?? [])
limitations.push(diagnostic);
}
else if (coverage?.status === "unknown")
limitations.push(coverage.reason);
for (const diagnostic of coverage !== null && "diagnostics" in coverage
? coverage.diagnostics
: [])
limitations.push(diagnostic);
// The requested goal is a terminal for this search. Its outgoing edges
// cannot add a path to the goal, so its dossier limits are contextual
// but do not make the searched start-to-goal closure incomplete.
@@ -266,7 +268,7 @@ const inspectSearch = ({
coverage === null
? snapshot.limitations.length > 0
: coverage.status !== "complete" &&
!isCallerOnlyIncompleteCoverage(coverage)
!isCallerOnlyUnknownCoverage(coverage)
)
incompleteExpandedCoverage = true;
} else if (node === goal) {
+7 -33
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import {
combineFunctionRelationshipCoverage,
isCallerOnlyIncompleteCoverage,
isCallerOnlyUnknownCoverage,
} from "./functionCallCoverage.js";
import type { RelationshipCoverage } from "./relationshipCoverage.js";
@@ -22,14 +22,14 @@ const cappedCallees: RelationshipCoverage = {
};
const complete: RelationshipCoverage = { status: "complete" };
describe("isCallerOnlyIncompleteCoverage", () => {
describe("isCallerOnlyUnknownCoverage", () => {
it("detects unknown callers beside complete callees", () => {
const combined = combineFunctionRelationshipCoverage(
unknownCallers,
complete,
);
expect(combined.status).toBe("unknown");
expect(isCallerOnlyIncompleteCoverage(combined)).toBe(true);
expect(isCallerOnlyUnknownCoverage(combined)).toBe(true);
});
it("rejects unknown callees beside complete callers", () => {
@@ -37,7 +37,7 @@ describe("isCallerOnlyIncompleteCoverage", () => {
complete,
unknownCallees,
);
expect(isCallerOnlyIncompleteCoverage(combined)).toBe(false);
expect(isCallerOnlyUnknownCoverage(combined)).toBe(false);
});
it("rejects unknown coverage on both sides", () => {
@@ -45,7 +45,7 @@ describe("isCallerOnlyIncompleteCoverage", () => {
unknownCallers,
unknownCallees,
);
expect(isCallerOnlyIncompleteCoverage(combined)).toBe(false);
expect(isCallerOnlyUnknownCoverage(combined)).toBe(false);
});
it("rejects unknown callers beside IDA-capped callees", () => {
@@ -54,37 +54,11 @@ describe("isCallerOnlyIncompleteCoverage", () => {
cappedCallees,
);
expect(combined.status).toBe("unknown");
expect(isCallerOnlyIncompleteCoverage(combined)).toBe(false);
expect(isCallerOnlyUnknownCoverage(combined)).toBe(false);
});
it("rejects complete coverage", () => {
const combined = combineFunctionRelationshipCoverage(complete, complete);
expect(isCallerOnlyIncompleteCoverage(combined)).toBe(false);
});
it("retains caller-only partial coverage without relying on limitation wording", () => {
const combined = combineFunctionRelationshipCoverage(
{ status: "partial", limitations: ["Missing incoming references."] },
complete,
);
expect(combined).toMatchObject({ limited_directions: ["callers"] });
expect(isCallerOnlyIncompleteCoverage(combined)).toBe(true);
});
it("uses explicit directions even when explanation text contains direction labels", () => {
expect(
isCallerOnlyIncompleteCoverage({
status: "unknown",
reason: "Callers: unavailable. Callees: diagnostic text.",
limited_directions: ["callers"],
}),
).toBe(true);
expect(
isCallerOnlyIncompleteCoverage({
status: "unknown",
reason: "Callers: unavailable.",
limited_directions: ["callees"],
}),
).toBe(false);
expect(isCallerOnlyUnknownCoverage(combined)).toBe(false);
});
});
+42 -39
View File
@@ -1,7 +1,4 @@
import {
relationshipCoverageLimitations,
type RelationshipCoverage,
} from "./relationshipCoverage.js";
import type { RelationshipCoverage } from "./relationshipCoverage.js";
import type { FunctionDossier } from "./hopperValues.js";
import { uniqueSorted } from "./canonicalOrdering.js";
@@ -18,25 +15,25 @@ export const combineFunctionRelationshipCoverage = (
{ direction: "callees" as const, coverage: callees },
];
const diagnostics = uniqueSorted(
reads.flatMap(({ coverage }) => coverage?.diagnostics ?? []),
reads.flatMap(({ coverage }) =>
coverage !== undefined && "diagnostics" in coverage
? coverage.diagnostics
: [],
),
);
const details = diagnostics.length > 0 ? { diagnostics } : {};
const callersLimited = callers?.status !== "complete";
const calleesLimited = callees?.status !== "complete";
const limited_directions: Extract<
RelationshipCoverage,
{ status: "partial" }
>["limited_directions"] = callersLimited
? calleesLimited
? ["callers", "callees"]
: ["callers"]
: ["callees"];
const reasons = reads.flatMap(({ direction, coverage }) => {
const label =
direction === "callers" ? callerCoveragePrefix : calleeCoveragePrefix;
return relationshipCoverageLimitations(coverage).map(
(reason) => `${label}${reason}`,
);
const limitations =
coverage === undefined
? ["Provider did not report relationship coverage."]
: coverage.status === "unknown"
? [coverage.reason]
: coverage.status === "partial"
? coverage.limitations
: [];
return limitations.map((reason) => `${label}${reason}`);
});
if (
reads.some(
@@ -46,48 +43,54 @@ export const combineFunctionRelationshipCoverage = (
return {
status: "unknown",
reason: uniqueSorted(reasons).join(" "),
limited_directions,
...details,
};
if (reasons.length > 0)
return {
status: "partial",
limitations: uniqueSorted(reasons),
limited_directions,
...details,
};
return { status: "complete", ...details };
};
/**
* Forward searches depend on callee coverage. Preserve the historical
* unknown-reason encoding when reading observations without explicit directions.
* Whether combined relationship coverage is unknown only on the caller side.
* The combine helper above prefixes each direction's reasons, so a reason
* that carries the caller prefix and no callee prefix records observed
* callees with unobservable callers. Forward (callee-edge) searches stay
* exhaustive under that caveat; the caller reason remains a limitation.
*/
export const isCallerOnlyIncompleteCoverage = (
export const isCallerOnlyUnknownCoverage = (
coverage: RelationshipCoverage,
): boolean => {
if (coverage.status === "complete") return false;
if (coverage.limited_directions !== undefined)
return !coverage.limited_directions.some(
(direction) => direction === "callees",
);
return (
coverage.status === "unknown" &&
coverage.reason.startsWith(callerCoveragePrefix) &&
!coverage.reason.includes(calleeCoveragePrefix)
);
};
): boolean =>
coverage.status === "unknown" &&
coverage.reason.startsWith(callerCoveragePrefix) &&
!coverage.reason.includes(calleeCoveragePrefix);
/** Explain why two resolved call sets cannot establish call-set equality. */
export const functionCallCoverageLimitations = (
coverage: RelationshipCoverage | null,
dossier: Pick<FunctionDossier, "unresolved_calls">,
): string[] => {
return uniqueSorted([
...relationshipCoverageLimitations(coverage),
...(coverage?.status === "complete" ? [] : (coverage?.diagnostics ?? [])),
const limitations =
coverage === null
? ["Provider did not report relationship coverage."]
: coverage.status === "unknown"
? [
coverage.reason,
...("diagnostics" in coverage ? coverage.diagnostics : []),
]
: coverage.status === "partial"
? [
...coverage.limitations,
...("diagnostics" in coverage ? coverage.diagnostics : []),
]
: [];
limitations.push(
...(dossier.unresolved_calls ?? []).map(
({ address, reason }) => `Unresolved call at ${address}: ${reason}`,
),
]);
);
return uniqueSorted(limitations);
};
@@ -1,40 +0,0 @@
import { describe, expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
const endpoints = (source: string) =>
analyzeJavaScriptStaticSource(source).endpoints.map(
({ kind, value, mechanism }) => ({ kind, value, mechanism }),
);
describe("server-sent event endpoints", () => {
it("reports EventSource URLs like WebSocket URLs", () => {
expect(
endpoints(`
new WebSocket("wss://chat.example/ws");
new EventSource("/api/chat/stream");
new window.EventSource("https://chat.example/events", { withCredentials: true });
`),
).toEqual([
{
kind: "network",
value: "wss://chat.example/ws",
mechanism: "call:WebSocket",
},
{
kind: "network",
value: "/api/chat/stream",
mechanism: "call:EventSource",
},
{
kind: "network",
value: "https://chat.example/events",
mechanism: "call:window.EventSource",
},
]);
});
it("keeps a dynamic EventSource URL unreported", () => {
expect(endpoints("new EventSource(url);")).toEqual([]);
});
});
@@ -1,34 +0,0 @@
import { describe, expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
const endpoints = (source: string) =>
analyzeJavaScriptStaticSource(source).endpoints.map(
({ kind, value, mechanism }) => ({ kind, value, mechanism }),
);
describe("beacon endpoints", () => {
it("reports navigator.sendBeacon URLs", () => {
expect(
endpoints(`
navigator.sendBeacon("/api/telemetry", payload);
window.navigator.sendBeacon("https://metrics.example/collect");
`),
).toEqual([
{
kind: "network",
value: "/api/telemetry",
mechanism: "call:navigator.sendBeacon",
},
{
kind: "network",
value: "https://metrics.example/collect",
mechanism: "call:window.navigator.sendBeacon",
},
]);
});
it("keeps a dynamic beacon URL unreported", () => {
expect(endpoints("navigator.sendBeacon(url, payload);")).toEqual([]);
});
});
@@ -554,13 +554,7 @@ export const endpointArgument = (
receiverFact?: JavaScriptOpenReceiverFact,
): string | undefined => {
if (receiverFact === "window") return undefined;
if (
name === "fetch" ||
name.endsWith(".fetch") ||
name === "WebSocket" ||
name === "EventSource" ||
name.endsWith(".EventSource")
)
if (name === "fetch" || name.endsWith(".fetch") || name === "WebSocket")
return stringValue(args[0]);
if (name.endsWith(".open"))
return xhrOpenUrl(name, args[0], args[1], receiverFact);
@@ -580,9 +574,6 @@ export const endpointArgument = (
? undefined
: value;
}
// navigator.sendBeacon(url, data) queues a POST to its first argument.
if (name === "sendBeacon" || name.endsWith(".sendBeacon"))
return stringValue(args[0]);
if (name.endsWith("loadURL")) return stringValue(args[0]);
return undefined;
};
+6 -33
View File
@@ -1,51 +1,24 @@
import { z } from "zod";
const diagnostics = z.array(z.string().min(1)).min(1).exactOptional();
const completeCoverage = z.strictObject({
status: z.literal("complete"),
diagnostics,
});
const limitedDirections = z.union([
z.tuple([z.literal("callers")]).check(z.minLength(1), z.maxLength(1)),
z.tuple([z.literal("callees")]).check(z.minLength(1), z.maxLength(1)),
z
.tuple([z.literal("callers"), z.literal("callees")])
.check(z.minLength(2), z.maxLength(2)),
]);
const diagnostics = z.array(z.string().min(1)).min(1);
const completeCoverage = z.strictObject({ status: z.literal("complete") });
const partialCoverage = z.strictObject({
status: z.literal("partial"),
limitations: z.array(z.string().min(1)).min(1),
limited_directions: limitedDirections.exactOptional(),
diagnostics,
});
const unknownCoverage = z.strictObject({
status: z.literal("unknown"),
reason: z.string().min(1),
limited_directions: limitedDirections.exactOptional(),
diagnostics,
});
/** Explicit coverage state for a provider's observed caller/callee set. */
export const relationshipCoverageSchema = z.discriminatedUnion("status", [
export const relationshipCoverageSchema = z.union([
completeCoverage,
completeCoverage.extend({ diagnostics }),
partialCoverage,
partialCoverage.extend({ diagnostics }),
unknownCoverage,
unknownCoverage.extend({ diagnostics }),
]);
export type RelationshipCoverage = z.infer<typeof relationshipCoverageSchema>;
/** Reasons the observed relationship inventory cannot establish completeness. */
export const relationshipCoverageLimitations = (
coverage: RelationshipCoverage | null | undefined,
): readonly string[] => {
switch (coverage?.status) {
case undefined:
return ["Provider did not report relationship coverage."];
case "complete":
return [];
case "partial":
return coverage.limitations;
case "unknown":
return [coverage.reason];
}
};
+2 -2
View File
@@ -1,9 +1,9 @@
/** Generated from package.json and package-lock.json; do not edit. */
/** Generated from package.json and npm-shrinkwrap.json; do not edit. */
export const PACKAGE_METADATA = {
name: "rea-agents",
version: "6.4.0", // x-release-please-version
serverSdkVersion: "2.3.1",
clientSdkVersion: "2.3.1",
coreSdkVersion: "2.3.1",
skillVersion: "42",
skillVersion: "41",
} as const;
+2 -4
View File
@@ -40,8 +40,6 @@ import {
NATIVE_AOT_PROFILE_CONTRACT_REVISION,
} from "../domain/native/nativeAotPe.js";
const callRelationshipCoverage = "function-body-call-coverage-v5";
/**
* The processor language REA pins for this target, or undefined when Ghidra may
* select from the header. The committed profile and the session readback both
@@ -127,7 +125,7 @@ export const resolveGhidraAnalysisProfile = async (
load_image_observations: "source-mappings-entry-context-v2",
function_body_evidence: "complete-inclusive-ranges-v1",
function_references: "complete-body-and-entry-reference-manager-v2",
call_relationship_coverage: callRelationshipCoverage,
call_relationship_coverage: "resolved-call-reference-diagnostics-v1",
function_dossier_coverage:
"observed-facets-and-call-relationships-v1",
string_inventory_evidence: "defined-data-coverage-v1",
@@ -224,7 +222,7 @@ export const resolveGhidraAnalysisProfile = async (
load_image_observations: "source-mappings-entry-context-v2",
function_body_evidence: "complete-inclusive-ranges-v1",
function_references: "complete-body-and-entry-reference-manager-v2",
call_relationship_coverage: callRelationshipCoverage,
call_relationship_coverage: "resolved-call-reference-diagnostics-v1",
function_dossier_coverage: "observed-facets-and-call-relationships-v1",
string_inventory_evidence: "defined-data-coverage-v1",
location_resolution: "explicit-address-exact-entry-symbol-first-v3",
-113
View File
@@ -43,120 +43,7 @@ const parseFunctionValueFixture = (
return parsed.ok ? { ok: true as const, value: parsed.value.value } : parsed;
};
describe("Ghidra unresolved call locations", () => {
it("rejects a noncanonical unresolved call site rather than retaining unverifiable location evidence", () => {
expect(
parseGhidraFunctionResult("analyze_function", {
value: {
...functionDossierSchema.parse(ghidraFunctionDossier()),
unresolved_calls: [
{ address: "not-an-address", reason: "No call destination." },
],
},
limitations: [],
relationship_coverage: {
status: "partial",
limitations: ["An unresolved call remains."],
},
}),
).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } });
});
});
describe("Ghidra function-analysis result values", () => {
it("retains unavailable pseudocode instead of treating its empty representation as observed source", () => {
const availability = {
pseudocode: {
available: false,
reason: "Ghidra does not decompile external functions.",
},
comments: { available: true },
};
const parsed = parseGhidraFunctionResult("analyze_function", {
value: {
...functionDossierSchema.parse(ghidraFunctionDossier()),
pseudocode: "",
facet_availability: availability,
},
limitations: [],
});
expect(parsed).toMatchObject({
ok: true,
value: { value: { pseudocode: "", facet_availability: availability } },
});
});
it.each([
{ status: "complete", diagnostics: ["Resolved static references only."] },
{
status: "partial",
limitations: ["Only the first instruction range was examined."],
},
{ status: "unknown", reason: "Relationship inventory was unavailable." },
{
status: "partial",
limitations: ["Missing callers."],
limited_directions: ["callers"],
},
{
status: "unknown",
reason: "Missing callees.",
limited_directions: ["callees"],
},
])("preserves observed $status relationship coverage", (coverage) => {
const parsed = parseGhidraFunctionResult("analyze_function", {
value: ghidraFunctionDossier(),
limitations: [],
relationship_coverage: coverage,
});
expect(parsed).toMatchObject({
ok: true,
value: { relationshipCoverage: coverage },
});
});
it("preserves omitted relationship coverage for legacy observations", () => {
const parsed = parseGhidraFunctionResult("analyze_function", {
value: ghidraFunctionDossier(),
limitations: [],
});
if (!parsed.ok) throw parsed.error;
expect(parsed.value).not.toHaveProperty("relationshipCoverage");
});
});
describe("Ghidra relationship metadata validation", () => {
it.each([
{ status: "partial", limitations: [] },
{ status: "unknown", reason: "" },
{ status: "complete", limitations: ["Truncated."] },
{ status: "partial", limitations: ["Truncated."], limited_directions: [] },
{
status: "partial",
limitations: ["Truncated."],
limited_directions: ["callers", "callers"],
},
{ status: "unknown", reason: "Missing.", limited_directions: ["incoming"] },
{ status: "complete", limited_directions: ["callers"] },
])("rejects malformed relationship coverage %j", (coverage) => {
expect(
parseGhidraFunctionResult("analyze_function", {
value: ghidraFunctionDossier(),
limitations: [],
relationship_coverage: coverage,
}),
).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } });
});
it("rejects relationship metadata on unrelated function operations", () => {
expect(
parseGhidraFunctionResult("procedure_assembly", {
value: "ret",
limitations: [],
relationship_coverage: { status: "complete" },
}),
).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } });
});
it("preserves file-offset resolution in Evidence locations and rejects inexact offsets", () => {
const selection = {
file_offset: 0x210,
+1 -16
View File
@@ -27,10 +27,6 @@ import {
import { nativeApiBoundarySchema } from "../domain/native/nativeApiBoundary.js";
import { nativeValueFlowSchema } from "../domain/native/nativeValueFlow.js";
import type { EvidenceLocation } from "../domain/evidence.js";
import {
relationshipCoverageSchema,
type RelationshipCoverage,
} from "../domain/relationshipCoverage.js";
import { err, ok, type Result } from "../domain/result.js";
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
import {
@@ -311,7 +307,6 @@ const ghidraFunctionDossier = functionDossierSchema
...value.comments.map(({ address }) => address),
...value.callers.map(({ address }) => address),
...value.callees.map(({ address }) => address),
...(value.unresolved_calls ?? []).map(({ address }) => address),
...value.incoming_references.flatMap(referenceAddresses),
...value.outgoing_references.flatMap(referenceAddresses),
...value.referenced_strings.flatMap(({ address, source_address }) => [
@@ -424,7 +419,6 @@ const functionBoundaryObservationOperations: ReadonlySet<GhidraFunctionOperation
const functionObservationEnvelope = z.strictObject({
value: z.unknown(),
limitations: z.array(z.string()),
relationship_coverage: relationshipCoverageSchema.exactOptional(),
selection: z
.strictObject({
file_offset: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
@@ -438,7 +432,6 @@ export interface ParsedGhidraFunctionResult {
readonly value: JsonValue;
readonly limitations: readonly string[];
readonly locations?: readonly EvidenceLocation[];
readonly relationshipCoverage?: RelationshipCoverage;
}
/** Require exact, bounded Java-bridge function output before creating Evidence. */
@@ -449,7 +442,6 @@ export const parseGhidraFunctionResult = (
let observation: {
readonly value: unknown;
readonly limitations: readonly string[];
readonly relationship_coverage?: RelationshipCoverage;
readonly selection?: z.infer<
typeof functionObservationEnvelope
>["selection"];
@@ -458,17 +450,13 @@ export const parseGhidraFunctionResult = (
const envelope = functionObservationEnvelope.safeParse(value);
if (
!envelope.success ||
(operation !== "analyze_function" &&
operation !== "procedure_callers" &&
operation !== "procedure_callees" &&
envelope.data.relationship_coverage !== undefined) ||
(operation !== "procedure_pseudo_code" &&
envelope.data.selection !== undefined)
)
return err(
new AnalysisOutputError(
operation,
"Ghidra bridge returned an invalid function observation envelope for this operation",
"Ghidra bridge omitted the function observation envelope",
envelope.success ? undefined : { cause: envelope.error },
),
);
@@ -481,9 +469,6 @@ export const parseGhidraFunctionResult = (
? ok({
value: jsonValueSchema.parse(parsed.data),
limitations: observation.limitations,
...(observation.relationship_coverage === undefined
? {}
: { relationshipCoverage: observation.relationship_coverage }),
...(observation.selection === undefined
? {}
: {
+2 -11
View File
@@ -633,7 +633,7 @@ describe("Ghidra client projection", () => {
expect(resolved.value.profile.parameters).toMatchObject({
language_id: "auto-from-header",
compiler_spec_id: "auto-default",
call_relationship_coverage: "function-body-call-coverage-v5",
call_relationship_coverage: "resolved-call-reference-diagnostics-v1",
function_dossier_coverage: "observed-facets-and-call-relationships-v1",
});
@@ -870,16 +870,7 @@ describe("Ghidra relationship coverage", () => {
Promise.resolve(
ok(
operation === "procedure_callees"
? {
value: ["0x2000"],
limitations: [],
relationship_coverage: {
status: "complete",
diagnostics: [
"Only resolved Ghidra call references are returned; unresolved computed or indirect calls remain unknown, while function classifications distinguish thunks and externals.",
],
},
}
? { value: ["0x2000"], limitations: [] }
: { value: [], limitations: [] },
),
),
+17
View File
@@ -2,6 +2,7 @@ import type {
CapabilityDescriptor,
ProviderIdentity,
} from "../application/AnalysisProvider.js";
import type { RelationshipCoverage } from "../domain/relationshipCoverage.js";
import {
hasWindowsNativeAuthority,
windowsNativeAuthorityUnavailableReason,
@@ -43,6 +44,22 @@ const stringInventoryLimitation =
const searchLimitation =
"Regex mode uses Java Pattern semantics. Matcher or compiler stack exhaustion returns a resource constraint without closing the session; literal mode avoids regex recursion and returns complete matching values.";
const resolvedCallReferenceLimitation =
"Only resolved Ghidra call references are returned; unresolved computed or indirect calls remain unknown, while function classifications distinguish thunks and externals.";
/** Relationship-only caveats belong to each observed caller/callee result. */
export const relationshipCoverageFor = (
operation: string,
): RelationshipCoverage | undefined =>
operation === "procedure_callers" ||
operation === "procedure_callees" ||
operation === "analyze_function"
? {
status: "complete",
diagnostics: [resolvedCallReferenceLimitation],
}
: undefined;
/** Limitation text for one admitted operation, including the common base. */
export const limitationsFor = (operation: string): readonly string[] => {
const common = [
+2 -5
View File
@@ -34,7 +34,6 @@ import {
} from "../domain/analysisErrorCore.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import type { EvidenceLocation } from "../domain/evidence.js";
import type { RelationshipCoverage } from "../domain/relationshipCoverage.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { err, ok, type Result } from "../domain/result.js";
@@ -87,6 +86,7 @@ import {
healthLimitations,
windowsP0Limitations,
limitationsFor,
relationshipCoverageFor,
} from "./GhidraProviderCapabilities.js";
import type { GhidraSessionError } from "./GhidraSessionError.js";
import {
@@ -733,8 +733,8 @@ export const createGhidraProviderClient = (input: {
rawResult: JsonValue,
observationLimitations: readonly string[] = [],
locations: readonly EvidenceLocation[] = [],
relationshipCoverage?: RelationshipCoverage,
) => {
const relationshipCoverage = relationshipCoverageFor(operation);
return createAnalysisExecution(normalized, committedProfile.provider, {
rawResult,
locations,
@@ -838,14 +838,12 @@ export const createGhidraProviderClient = (input: {
let normalized: JsonValue;
let observationLimitations: readonly string[] = [];
let observationLocations: readonly EvidenceLocation[] = [];
let relationshipCoverage: RelationshipCoverage | undefined;
if (isGhidraFunctionOperation(operation)) {
const result = parseGhidraFunctionResult(operation, called.value);
if (!result.ok) return result;
normalized = result.value.value;
observationLimitations = result.value.limitations;
observationLocations = result.value.locations ?? [];
relationshipCoverage = result.value.relationshipCoverage;
} else {
const result = parseGhidraInventoryResult(operation, called.value);
if (!result.ok) return result;
@@ -941,7 +939,6 @@ export const createGhidraProviderClient = (input: {
called.value,
observationLimitations,
observationLocations,
relationshipCoverage,
),
);
},
+37 -121
View File
@@ -11,7 +11,6 @@ import { silentLogger } from "../logger.js";
import { GhidraProvider } from "./GhidraProvider.js";
import { GhidraSessionError } from "./GhidraSessionError.js";
import { GHIDRA_SESSION_CAPABILITIES } from "./GhidraSessionValues.js";
import type { RelationshipCoverage } from "../domain/relationshipCoverage.js";
const TARGET: BinaryTarget = {
path: "/tmp/ghidra-cache-fixture",
@@ -24,7 +23,7 @@ const TARGET: BinaryTarget = {
// Exercise the real provider descriptors, profile, input/output projection and
// session cache; only the installed Ghidra process is replaced at its seam.
const fixture = (version = "12.1.4", coverage?: RelationshipCoverage) => {
const fixture = (version = "12.1.4") => {
const config = parseConfig({ GHIDRA_INSTALL_DIR: "/opt/ghidra" });
if (!config.ok) throw config.error;
const calls: string[] = [];
@@ -114,13 +113,7 @@ const fixture = (version = "12.1.4", coverage?: RelationshipCoverage) => {
return ok(
operation === "procedure_pseudo_code" ||
operation === "analyze_function"
? {
value,
limitations: [],
...(operation === "analyze_function" && coverage !== undefined
? { relationship_coverage: coverage }
: {}),
}
? { value, limitations: [] }
: value,
);
},
@@ -140,119 +133,42 @@ const open = async (session: ReturnType<typeof fixture>["session"]) => {
expect((await session.openResolvedTarget(preview.value)).ok).toBe(true);
};
describe("Ghidra relationship coverage replay", () => {
it.each([
undefined,
{ status: "complete", diagnostics: ["Resolved static references only."] },
{ status: "partial", limitations: ["Instruction coverage was truncated."] },
{ status: "unknown", reason: "Relationship inventory was unavailable." },
{
status: "partial",
limitations: ["Missing callers."],
limited_directions: ["callers"],
},
] satisfies (RelationshipCoverage | undefined)[])(
"preserves relationship coverage %j through live execution, cache and snapshot replay",
async (coverage) => {
const initial = fixture("12.1.4", coverage);
await open(initial.session);
const parameters = { procedure: "0x1000" };
for (let i = 0; i < 2; i++) {
const execution = await initial.session.execute(
"analyze_function",
parameters,
);
if (!execution.ok) throw execution.error;
expect(execution.value.relationshipCoverage).toEqual(coverage);
}
expect(initial.calls).toEqual(["analyze_function"]);
const snapshot = initial.session.exportAnalysisSnapshot();
if (!snapshot.ok) throw snapshot.error;
await initial.session.close();
const replay = fixture();
expect(replay.session.importAnalysisSnapshot(snapshot.value).ok).toBe(
true,
);
await open(replay.session);
const execution = await replay.session.execute(
"analyze_function",
parameters,
);
if (!execution.ok) throw execution.error;
expect(execution.value.relationshipCoverage ?? undefined).toEqual(
coverage,
);
expect(replay.calls).toEqual([]);
await replay.session.close();
},
);
});
describe("Ghidra snapshot profile admission", () => {
it.each([
{
parameter: "instruction_references",
current: "exact-address-incoming-v1",
legacy: undefined,
},
{
parameter: "call_relationship_coverage",
current: "function-body-call-coverage-v5",
legacy: "resolved-call-reference-diagnostics-v1",
},
{
parameter: "call_relationship_coverage",
current: "function-body-call-coverage-v5",
legacy: "decoded-call-target-coverage-v2",
},
{
parameter: "call_relationship_coverage",
current: "function-body-call-coverage-v5",
legacy: "decoded-call-target-coverage-v3",
},
{
parameter: "call_relationship_coverage",
current: "function-body-call-coverage-v5",
legacy: "function-body-call-coverage-v4",
},
])(
"refuses snapshots with outdated $parameter semantics",
async ({ parameter, current, legacy: prior }) => {
const initial = fixture();
await open(initial.session);
const snapshot = initial.session.exportAnalysisSnapshot();
if (!snapshot.ok) throw snapshot.error;
await initial.session.close();
const profile = snapshot.value.binding.analysis_profile;
expect(profile.parameters[parameter]).toBe(current);
const legacy = {
...snapshot.value,
binding: {
...snapshot.value.binding,
analysis_profile: createAnalysisProfile(profile.provider, {
...Object.fromEntries(
Object.entries(profile.parameters).filter(
([key]) => key !== parameter,
),
),
...(prior === undefined ? {} : { [parameter]: prior }),
}),
},
};
const replay = fixture();
const imported = replay.session.importAnalysisSnapshot(legacy);
if (!imported.ok) throw imported.error;
expect(await replay.session.previewTarget(TARGET)).toMatchObject({
ok: false,
error: { _tag: "EvidenceIntegrityError" },
});
expect(replay.calls).toEqual([]);
await replay.session.close();
},
);
});
describe("Ghidra snapshot cache", () => {
it("refuses snapshots whose profile predates typed incoming references", async () => {
const initial = fixture();
await open(initial.session);
const snapshot = initial.session.exportAnalysisSnapshot();
if (!snapshot.ok) throw snapshot.error;
await initial.session.close();
const profile = snapshot.value.binding.analysis_profile;
expect(profile.parameters.instruction_references).toBe(
"exact-address-incoming-v1",
);
const legacy = {
...snapshot.value,
binding: {
...snapshot.value.binding,
analysis_profile: createAnalysisProfile(
profile.provider,
Object.fromEntries(
Object.entries(profile.parameters).filter(
([key]) => key !== "instruction_references",
),
),
),
},
};
const replay = fixture();
const imported = replay.session.importAnalysisSnapshot(legacy);
if (!imported.ok) throw imported.error;
expect(await replay.session.previewTarget(TARGET)).toMatchObject({
ok: false,
error: { _tag: "EvidenceIntegrityError" },
});
expect(replay.calls).toEqual([]);
await replay.session.close();
});
it.each([
{ operation: "analyze_function", parameters: { procedure: "0x1000" } },
{
+29 -19
View File
@@ -1,36 +1,39 @@
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
import {
ARTIFACT_TOOL_CONTRACTS,
artifactInspectionInputSchema,
} from "../contracts/artifactToolContracts.js";
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
import {
requireAnalysisViewRetention,
summarizeRetainedAnalysis,
} from "../application/analysisView/AnalysisViewService.js";
import { createArtifactExtractionDestination } from "../application/artifacts/ArtifactExtractionDestination.js";
import { ok } from "../domain/result.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import type { Logger } from "pino";
import { registerEvidenceTools } from "./registerEvidenceTools.js";
import { artifactInspectionResultSchema } from "../domain/artifactInspection.js";
import {
retainedSummaryDelivery,
withoutDeliveryDetail,
type EvidenceToolFamilyOptions,
} from "./retainedSummaryDelivery.js";
/** Operations whose MCP delivery may be a retained summary view. */
const SUMMARY_DELIVERY: ReadonlySet<string> = new Set([
"inspect_artifact",
"decode_interface_builder",
]);
/** inspect_artifact keeps an explicit detail in its Evidence parameters. */
const decodeWithoutDelivery = withoutDeliveryDetail(
new Set(["decode_interface_builder"]),
);
/** Register deterministic artifact inventory and safe extraction operations. */
export const registerArtifactTools = (
server: EvidenceMcpServer,
options: EvidenceToolFamilyOptions,
options: {
readonly logger: Logger;
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
readonly evidenceById?: EvidenceLookup | undefined;
readonly withAdmittedAnalysis: import("./analysisAdmission.js").WithAdmittedAnalysis;
},
): void => {
const retention = {
recordEvidence: options.recordEvidence,
evidenceById: options.evidenceById,
};
registerEvidenceTools(server, ARTIFACT_TOOL_CONTRACTS, {
...options,
...retainedSummaryDelivery(SUMMARY_DELIVERY, options),
parametersForExecution: (operation, parameters) => {
if (operation === "inspect_artifact") {
const { detail: _detail, ...execution } =
@@ -39,9 +42,16 @@ export const registerArtifactTools = (
}
return operation === "extract_artifact"
? { ...parameters, output_root: createArtifactExtractionDestination() }
: decodeWithoutDelivery(operation, parameters);
: parameters;
},
parametersForEvidence: decodeWithoutDelivery,
preflight: (operation, parameters) =>
operation === "inspect_artifact" && parameters.detail === "summary"
? requireAnalysisViewRetention(operation, retention)
: ok(null),
projectEvidence: (operation, parameters, evidence) =>
operation === "inspect_artifact" && parameters.detail === "summary"
? summarizeRetainedAnalysis(evidence, retention)
: ok(evidence),
sourceEvidence: (operation, result) =>
operation === "inspect_artifact"
? artifactInspectionResultSchema
+11 -2
View File
@@ -1,4 +1,6 @@
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
import {
requireAnalysisViewRetention,
summarizeRetainedAnalysis,
@@ -6,17 +8,24 @@ import {
import type { AnalysisOperationPort } from "../application/AnalysisProvider.js";
import { MANAGED_TOOL_CONTRACTS } from "../contracts/managed/managedToolContracts.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { err, ok } from "../domain/result.js";
import type { Logger } from "pino";
import { registerEvidenceTools } from "./registerEvidenceTools.js";
import type { EvidenceToolFamilyOptions } from "./retainedSummaryDelivery.js";
import { runManagedProviderExecution } from "../composition/directAnalysis.js";
import { isManagedToolName } from "../contracts/managed/managedToolContracts.js";
/** Register execution-free managed PE/CLI inspection. */
export const registerManagedTools = (
server: EvidenceMcpServer,
options: EvidenceToolFamilyOptions,
options: {
readonly logger: Logger;
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
readonly evidenceById?: EvidenceLookup | undefined;
readonly withAdmittedAnalysis: import("./analysisAdmission.js").WithAdmittedAnalysis;
},
): void => {
let selectedManagedPath: string | undefined;
const analysisForAdmission = (
+38 -9
View File
@@ -1,25 +1,54 @@
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
import { NATIVE_TOOL_CONTRACTS } from "../contracts/native/nativeToolContracts.js";
import { registerEvidenceTools } from "./registerEvidenceTools.js";
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
import {
retainedSummaryDelivery,
withoutDeliveryDetail,
type EvidenceToolFamilyOptions,
} from "./retainedSummaryDelivery.js";
requireAnalysisViewRetention,
summarizeRetainedAnalysis,
} from "../application/analysisView/AnalysisViewService.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { ok } from "../domain/result.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import type { Logger } from "pino";
import { registerEvidenceTools } from "./registerEvidenceTools.js";
const SUMMARY_DELIVERY: ReadonlySet<string> = new Set(["inspect_macho"]);
/** Delivery detail selects a response shape; it never reaches the tools or Evidence identity. */
const withoutDelivery = (
operation: string,
parameters: Readonly<Record<string, JsonValue>>,
): Readonly<Record<string, JsonValue>> => {
if (operation !== "inspect_macho") return parameters;
const { detail: _detail, ...selected } = parameters;
return selected;
};
/** Register macOS native contracts; Mach-O inspection can deliver a retained summary. */
export const registerNativeTools = (
server: EvidenceMcpServer,
options: EvidenceToolFamilyOptions,
options: {
readonly logger: Logger;
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
readonly evidenceById?: EvidenceLookup | undefined;
readonly withAdmittedAnalysis: import("./analysisAdmission.js").WithAdmittedAnalysis;
},
): void => {
const withoutDelivery = withoutDeliveryDetail(SUMMARY_DELIVERY);
const retention = {
recordEvidence: options.recordEvidence,
evidenceById: options.evidenceById,
};
registerEvidenceTools(server, NATIVE_TOOL_CONTRACTS, {
...options,
...retainedSummaryDelivery(SUMMARY_DELIVERY, options),
parametersForExecution: withoutDelivery,
parametersForEvidence: withoutDelivery,
preflight: (operation, parameters) =>
operation === "inspect_macho" && parameters.detail === "summary"
? requireAnalysisViewRetention(operation, retention)
: ok(null),
projectEvidence: (operation, parameters, evidence) =>
operation === "inspect_macho" && parameters.detail === "summary"
? summarizeRetainedAnalysis(evidence, retention)
: ok(evidence),
});
};
-81
View File
@@ -1,81 +0,0 @@
import { analysisDetailSchema } from "../contracts/analysisDetail.js";
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
import {
requireAnalysisViewRetention,
summarizeRetainedAnalysis,
} from "../application/analysisView/AnalysisViewService.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import type { Evidence } from "../domain/evidence.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { err, ok } from "../domain/result.js";
import type { Logger } from "pino";
import type { WithAdmittedAnalysis } from "./analysisAdmission.js";
/** Registration inputs shared by provider-backed Evidence tool families. */
export interface EvidenceToolFamilyOptions {
readonly logger: Logger;
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
readonly evidenceById?: EvidenceLookup | undefined;
readonly withAdmittedAnalysis: WithAdmittedAnalysis;
}
type Parameters = Readonly<Record<string, JsonValue>>;
/**
* Summary delivery for the named operations: refuse it before analysis when
* this server retains no Evidence, then return the retained summary view.
*/
export const retainedSummaryDelivery = (
operations: ReadonlySet<string>,
options: Pick<EvidenceToolFamilyOptions, "recordEvidence" | "evidenceById">,
) => {
const retention = {
recordEvidence: options.recordEvidence,
evidenceById: options.evidenceById,
};
const selected = (operation: string, parameters: Parameters): boolean =>
operations.has(operation) && parameters.detail === "summary";
return {
preflight: (operation: string, parameters: Parameters) => {
if (!operations.has(operation)) return ok(null);
const detail = analysisDetailSchema
.unwrap()
.optional()
.safeParse(parameters.detail);
if (!detail.success)
return err(
new AnalysisInputError(operation, { cause: detail.error }, [
{
path: ["detail"],
reason: "invalid_value",
expected: ["complete", "summary"],
},
]),
);
return detail.data === "summary"
? requireAnalysisViewRetention(operation, retention)
: ok(null);
},
projectEvidence: (
operation: string,
parameters: Parameters,
evidence: Evidence,
) =>
selected(operation, parameters)
? summarizeRetainedAnalysis(evidence, retention)
: ok(evidence),
};
};
/** Delivery detail selects a response shape; it never reaches tools or Evidence identity. */
export const withoutDeliveryDetail =
(operations: ReadonlySet<string>) =>
(operation: string, parameters: Parameters): Parameters => {
if (!operations.has(operation)) return parameters;
const { detail: _detail, ...selected } = parameters;
return selected;
};
+22 -40
View File
@@ -279,44 +279,26 @@ it("preserves bounded instruction coverage separately from using-node coverage",
});
});
it.each([
"not-a-dex-descriptor",
"(Lbad(name;)V",
"(Lbad)name;)V",
"()Lbad(name;",
"()Lbad)name;",
])(
"rejects malformed DEX prototype %s at the adapter boundary",
async (descriptor) => {
const invalidSummary = {
...summary,
methods: summary.methods.map((method) => ({
...method,
dex_descriptor: descriptor,
})),
};
await expect(
analyzeJadxRequest(
{
json: () => Promise.resolve(invalidSummary),
text: () => Promise.reject(new Error("unexpected text call")),
},
{
operation: "inspect_android_class",
input: { path: "/fixture.apk", class_name: "fixture.Target" },
},
{} as never,
engine,
),
).rejects.toMatchObject({
_tag: "AnalysisOutputError",
cause: {
issues: expect.arrayContaining([
expect.objectContaining({
path: ["methods", 0, "dex_descriptor"],
}),
]),
it("rejects malformed method descriptors at the Android adapter boundary", async () => {
const invalidSummary = {
...summary,
methods: summary.methods.map((method) => ({
...method,
dex_descriptor: "not-a-dex-descriptor",
})),
};
await expect(
analyzeJadxRequest(
{
json: () => Promise.resolve(invalidSummary),
text: () => Promise.reject(new Error("unexpected text call")),
},
});
},
);
{
operation: "inspect_android_class",
input: { path: "/fixture.apk", class_name: "fixture.Target" },
},
{} as never,
engine,
),
).rejects.toThrow();
});
@@ -130,7 +130,7 @@ printf '%s\n' 'C:\Users\runner with spaces\AppData\Local\npm-cache'
"${{ github.event.repository.default_branch }}",
);
expect(policy?.env?.LOCKFILES_HASH).toBe(
"${{ hashFiles('package-lock.json', 'npm-shrinkwrap.json', '.npmrc') }}",
"${{ hashFiles('npm-shrinkwrap.json', '.npmrc') }}",
);
expect(policy?.run).toContain(
'cache_key="npm-download-${RUNNER_OS}-${runtime_arch}-${LOCKFILES_HASH}"',
@@ -634,7 +634,7 @@ it.each([
{ code: true, package: true, evm: true, browser: true },
],
[
"package-lock.json",
"npm-shrinkwrap.json",
{ code: true, package: true, evm: true, browser: true },
],
[
@@ -67,7 +67,6 @@ describe("client configuration filesystem lifecycle", () => {
"kimi",
"vscode",
"trae",
"workbuddy",
"grok_build",
"omp",
"pi",
@@ -23,7 +23,7 @@ const fixture = async (
const root = await createTestTempDirectory("rea-deps-");
await mkdir(join(root, "node_modules"));
await writeFile(
join(root, "package-lock.json"),
join(root, "npm-shrinkwrap.json"),
JSON.stringify({
packages: {
"": {
@@ -103,7 +103,7 @@ describe("dependency install freshness", () => {
expect(seeded.status, `${seeded.stdout}${seeded.stderr}`).toBe(0);
const rootLock = asRecord(
JSON.parse(await readFile(join(root, "package-lock.json"), "utf8")),
JSON.parse(await readFile(join(root, "npm-shrinkwrap.json"), "utf8")),
);
const lockPackages = asRecord(rootLock?.packages);
const rootPackage = asRecord(lockPackages?.[""]);
@@ -191,7 +191,7 @@ const createBuildCacheFixture = async (): Promise<string> => {
throw new Error("package is missing its cached build command");
const paths = [
".nvmrc",
"package-lock.json",
"npm-shrinkwrap.json",
"turbo.json",
"tsconfig.json",
"tsconfig.build.json",
@@ -1,5 +1,5 @@
import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterAll, expect, it } from "vitest";
import { z } from "zod";
@@ -19,6 +19,32 @@ const verifier = fileURLToPath(
new URL("../../../scripts/verify-release-checkpoint.mjs", import.meta.url),
);
const pluginManifestPaths = [
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
] as const;
const pluginManifest = (version: string, launch = version) => ({
version,
mcpServers: {
rea: {
command: "node",
args: ["${CLAUDE_PLUGIN_ROOT}/scripts/plugin-mcp-launcher.mjs", launch],
},
},
});
const codexServerConfig = (version: string, launch = version) => ({
mcpServers: {
rea: {
command: "node",
args: ["./scripts/plugin-mcp-launcher.mjs", launch],
cwd: ".",
startup_timeout_sec: 30,
},
},
});
async function metadata(
directory: string,
version: string,
@@ -27,9 +53,17 @@ async function metadata(
) {
await mkdir(join(directory, "docs"), { recursive: true });
await mkdir(join(directory, "src"), { recursive: true });
for (const path of pluginManifestPaths)
await mkdir(join(directory, dirname(path)), { recursive: true });
const files = {
".claude-plugin/plugin.json": pluginManifest(version),
".codex-plugin/plugin.json": {
version,
mcpServers: "./.mcp.json",
},
".mcp.json": codexServerConfig(version),
"package.json": { name: "rea-agents", version },
"package-lock.json": { version, packages: { "": { version } } },
"npm-shrinkwrap.json": { version, packages: { "": { version } } },
".release-please-manifest.json": { ".": version },
"server.json": {
version,
@@ -534,7 +568,7 @@ it("validates candidate options before inspecting the working directory", async
});
it.each([
"package-lock.json",
"npm-shrinkwrap.json",
"server.json",
"src/generatedPackageMetadata.ts",
])("rejects inconsistent version metadata in %s", async (path) => {
@@ -545,7 +579,7 @@ it.each([
path.endsWith(".ts")
? 'export const METADATA = {\n version: "5.1.0",\n};\n'
: JSON.stringify(
path === "package-lock.json"
path === "npm-shrinkwrap.json"
? { version: "6.0.0", packages: { "": { version: "5.1.0" } } }
: path === "server.json"
? {
@@ -565,6 +599,51 @@ it.each([
});
});
it.each(
pluginManifestPaths.map((path) => ({
path,
stale: "version",
manifest:
path === ".codex-plugin/plugin.json"
? { version: "5.1.0", mcpServers: "./.mcp.json" }
: pluginManifest("5.1.0"),
failure: "declares 5.1.0; release branch requires 6.0.0",
})),
)("rejects a plugin $stale in $path", async ({ path, manifest, failure }) => {
const f = await fixture();
await candidate(f, "6.0.0");
await writeFile(join(f.directory, path), JSON.stringify(manifest));
await git(f.directory, ["add", "."]);
await git(f.directory, ["commit", "-m", "chore: stale plugin manifest"]);
await expect(verify(f)).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining(failure),
});
});
it.each([
{
path: ".claude-plugin/plugin.json",
manifest: pluginManifest("6.0.0", "5.1.0"),
},
{
path: ".mcp.json",
manifest: codexServerConfig("6.0.0", "5.1.0"),
},
])("reads the release pin from $path", async ({ path, manifest }) => {
const f = await fixture();
await candidate(f, "6.0.0");
await writeFile(join(f.directory, path), JSON.stringify(manifest));
await git(f.directory, ["add", "."]);
await git(f.directory, ["commit", "-m", "chore: stale plugin launch"]);
await expect(verify(f)).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining(
`launches version 5.1.0; release branch requires 6.0.0`,
),
});
});
it("checks a publish merge using its pre-release first parent baseline", async () => {
const f = await fixture();
const sourceSha = await candidate(f, "6.0.0");
+182 -2
View File
@@ -6,7 +6,7 @@ import {
readFile,
writeFile,
} from "node:fs/promises";
import { delimiter, join } from "node:path";
import { delimiter, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterAll, beforeAll, expect, it } from "vitest";
import { z } from "zod";
@@ -43,6 +43,59 @@ interface ReleaseNotesSeed {
let seed: ReleaseNotesSeed | undefined;
let seedWorkspaceRoot: string | undefined;
const pluginManifests = [
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
] as const;
const pluginServerConfig = ".mcp.json";
const pluginMarketplaces = [
".claude-plugin/marketplace.json",
".agents/plugins/marketplace.json",
] as const;
const pluginMarketplace = (version: string, packageName = "rea-agents") => ({
plugins: [
{ name: "rea", source: { source: "npm", package: packageName, version } },
],
});
const pluginServer = (version: string, command = "node") => ({
mcpServers: {
rea: {
command,
args: ["./scripts/plugin-mcp-launcher.mjs", version],
startup_timeout_sec: 30,
},
},
});
const pluginManifest = (
version: string,
{ skills = ["./skill"], command = "node" } = {},
) => ({
version,
skills,
mcpServers: {
rea: {
command,
args: ["${CLAUDE_PLUGIN_ROOT}/scripts/plugin-mcp-launcher.mjs", version],
},
},
});
const pluginManifestFor = (
path: (typeof pluginManifests)[number],
version: string,
options: { skills?: string[]; command?: string } = {},
) =>
path === ".codex-plugin/plugin.json"
? {
version,
skills: options.skills ?? ["./skill"],
mcpServers: "./.mcp.json",
}
: pluginManifest(version, options);
beforeAll(async () => {
const workspace = await createTestWorkspace("rea-release-notes-seed-");
seedWorkspaceRoot = workspace.root;
@@ -58,6 +111,24 @@ beforeAll(async () => {
join(directory, ".release-please-manifest.json"),
JSON.stringify({ ".": "6.3.0" }),
);
for (const path of pluginManifests) {
await mkdir(join(directory, dirname(path)), { recursive: true });
await writeFile(
join(directory, path),
JSON.stringify(pluginManifestFor(path, "6.3.0")),
);
}
await writeFile(
join(directory, pluginServerConfig),
JSON.stringify(pluginServer("6.3.0")),
);
for (const path of pluginMarketplaces) {
await mkdir(join(directory, dirname(path)), { recursive: true });
await writeFile(
join(directory, path),
JSON.stringify(pluginMarketplace("6.3.0")),
);
}
await git(directory, ["add", "."]);
await git(directory, ["commit", "-m", "chore: baseline"]);
const base = await git(directory, ["rev-parse", "HEAD"]);
@@ -368,9 +439,30 @@ it("rejects lost grouped credits, fabricated references, bots, and stale PR bodi
it("binds publication to complete recorded history and the reviewed base", async () => {
const f = await fixture();
await git(f.directory, ["add", "CHANGELOG.md"]);
for (const path of pluginManifests)
await writeFile(
join(f.directory, path),
JSON.stringify(pluginManifestFor(path, "6.4.0")),
);
await writeFile(
join(f.directory, pluginServerConfig),
JSON.stringify(pluginServer("6.4.0")),
);
for (const path of pluginMarketplaces)
await writeFile(
join(f.directory, path),
JSON.stringify(pluginMarketplace("6.4.0")),
);
await git(f.directory, [
"add",
"CHANGELOG.md",
...pluginManifests,
pluginServerConfig,
...pluginMarketplaces,
]);
await git(f.directory, ["commit", "-m", "chore: release notes"]);
const source = await git(f.directory, ["rev-parse", "HEAD"]);
// Release PRs bump the plugin manifest version and pinned launch only.
await f.invoke(["check", "--manifest", f.manifest, "--source", source]);
await writeFile(join(f.directory, "product.txt"), "later product change");
await git(f.directory, ["add", "product.txt"]);
@@ -391,6 +483,74 @@ it("binds publication to complete recorded history and the reviewed base", async
});
});
it.each(pluginManifests)(
"treats a release that changes the skill path in %s as a product change",
async (path) => {
const f = await fixture();
await writeFile(
join(f.directory, path),
JSON.stringify(
pluginManifestFor(path, "6.4.0", { skills: ["./another-skill"] }),
),
);
await git(f.directory, ["add", "CHANGELOG.md", path]);
await git(f.directory, ["commit", "-m", "chore: release notes"]);
await expect(
f.invoke(["check", "--manifest", f.manifest, "--source", "HEAD"]),
).rejects.toMatchObject({
stderr: expect.stringContaining(
`Product changed after notes target; regenerate inventory: ${path}`,
),
});
},
);
it("accepts Release Please lockfile refreshes without allowing product changes", async () => {
const f = await fixture();
await writeFile(
join(f.directory, "package-lock.json"),
'{"lockfileVersion":3}',
);
await writeFile(
join(f.directory, "npm-shrinkwrap.json"),
'{"lockfileVersion":3}',
);
await git(f.directory, [
"add",
"CHANGELOG.md",
"package-lock.json",
"npm-shrinkwrap.json",
]);
await git(f.directory, ["commit", "-m", "chore: release lockfiles"]);
await f.invoke(["check", "--manifest", f.manifest, "--source", "HEAD"]);
});
it.each([
{
path: ".claude-plugin/plugin.json",
content: pluginManifest("6.4.0", { command: "other-launcher" }),
},
{
path: pluginServerConfig,
content: pluginServer("6.4.0", "other-launcher"),
},
])(
"keeps launcher behavior in $path outside version-only metadata",
async ({ path, content }) => {
const f = await fixture();
await writeFile(join(f.directory, path), JSON.stringify(content));
await git(f.directory, ["add", "CHANGELOG.md", path]);
await git(f.directory, ["commit", "-m", "chore: release notes"]);
await expect(
f.invoke(["check", "--manifest", f.manifest, "--source", "HEAD"]),
).rejects.toMatchObject({
stderr: expect.stringContaining(
`Product changed after notes target; regenerate inventory: ${path}`,
),
});
},
);
it("checks the live merged PR and exact Git snapshot before either publication route", async () => {
const f = await fixture();
await mkdir(join(f.directory, "docs/releases"), { recursive: true });
@@ -536,3 +696,23 @@ it("fails closed on partial GitHub errors and non-advancing pagination without s
});
await expect(access(output)).rejects.toMatchObject({ code: "ENOENT" });
});
it.each(pluginMarketplaces)(
"treats a marketplace package change in %s as product behavior",
async (path) => {
const f = await fixture();
await writeFile(
join(f.directory, path),
JSON.stringify(pluginMarketplace("6.4.0", "another-package")),
);
await git(f.directory, ["add", "CHANGELOG.md", path]);
await git(f.directory, ["commit", "-m", "chore: release notes"]);
await expect(
f.invoke(["check", "--manifest", f.manifest, "--source", "HEAD"]),
).rejects.toMatchObject({
stderr: expect.stringContaining(
`Product changed after notes target; regenerate inventory: ${path}`,
),
});
},
);
@@ -78,7 +78,7 @@ const writeTaskFixture = async (
}),
);
await writeFile(
join(root, "package-lock.json"),
join(root, "npm-shrinkwrap.json"),
JSON.stringify({
name: "rea-static-task-cache-fixture",
version: "1.0.0",
@@ -288,3 +288,38 @@ it("invalidates checker task hashes for Node, ignore, and VitePress config chang
`${task} must ignore vitest.config.ts`,
).toBe(initial.get(task));
});
it.each(["compile", "typecheck"])(
"invalidates %s for JavaScript owners, declarations, and the authoritative lock",
async (task) => {
const root = await createTestTempDirectory("rea-mjs-cache-inputs-");
const cache = await createTestTempDirectory("rea-mjs-cache-store-");
await mkdir(join(root, "src/application"), { recursive: true });
await writeTaskFixture(root, [task], {
[task]: 'node -e "process.exit(0)"',
});
const owner = join(root, "src/application/PackageRunnerCommand.mjs");
const declaration = join(
root,
"src/application/PackageRunnerCommand.d.mts",
);
await writeFile(owner, "export const command = 1;\n");
await writeFile(declaration, "export declare const command: number;\n");
await workflowGit(root, ["init", "--quiet"]);
await workflowGit(root, ["add", "."]);
const initial = taskHash(root, task, cache);
expect(taskHash(root, task, cache)).toBe(initial);
await writeFile(owner, "export const command = 2;\n");
expect(taskHash(root, task, cache)).not.toBe(initial);
await writeFile(owner, "export const command = 1;\n");
await writeFile(declaration, "export declare const command: string;\n");
expect(taskHash(root, task, cache)).not.toBe(initial);
await writeFile(declaration, "export declare const command: number;\n");
expect(taskHash(root, task, cache)).toBe(initial);
const lockPath = join(root, "npm-shrinkwrap.json");
const lock = JSON.parse(await readFile(lockPath, "utf8"));
lock.packages["node_modules/fixture"] = { version: "1.0.0" };
await writeFile(lockPath, JSON.stringify(lock));
expect(taskHash(root, task, cache)).not.toBe(initial);
},
);
@@ -28,7 +28,7 @@ test("generates locked inventory and rejects stale notice metadata or preserved
optional: true,
};
await writeFile(
resolve(root, "package-lock.json"),
resolve(root, "npm-shrinkwrap.json"),
JSON.stringify({
packages: {
"": { name: "fixture", version: "1.0.0" },
@@ -1,159 +0,0 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js";
import { npxRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js";
import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js";
import { skillDestinations } from "../../../src/application/SetupSkill.js";
import { supportedClients } from "../../../src/application/SupportedClients.js";
import { systemUninstallHost } from "../../../src/application/Uninstall.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js";
/**
* The registration command setup writes on the host running the tests. Windows
* wraps it in `cmd.exe /d /c`, so the expected entry follows the host too.
*/
const command = npxRegistrationCommand(process.platform);
beforeEach(clearClientLocationEnvironment);
afterEach(() => vi.unstubAllEnvs());
const workbuddyPath = (
home: string,
platform: NodeJS.Platform = process.platform,
env: Parameters<typeof supportedClients>[2] = {},
) =>
supportedClients(home, platform, env).find(
({ name }) => name === "workbuddy",
);
describe("WorkBuddy configuration paths", () => {
it("resolves the global mcp.json under the home root", () => {
expect(workbuddyPath("/home/a")?.configPath).toBe(
join("/home/a", ".workbuddy", "mcp.json"),
);
expect(workbuddyPath("/Users/a")?.markerPath).toBe(
join("/Users/a", ".workbuddy"),
);
expect(workbuddyPath("C:\\Users\\a", "win32")?.configPath).toBe(
join("C:\\Users\\a", ".workbuddy", "mcp.json"),
);
});
it("resolves the registration format as the plain mcpServers JSON dialect", () => {
expect(workbuddyPath("/home/a")?.format).toBe("json");
});
it("resolves the personal skills directory under the home root", () => {
expect(
skillDestinations("/home/a", ["workbuddy"]).map(({ path }) => path),
).toEqual([
join("/home/a", ".workbuddy", "skills", "reverse-engineer-anything"),
]);
expect(
skillDestinations("C:\\Users\\a", ["workbuddy"], {}, "win32").map(
({ path }) => path,
),
).toEqual([
join("C:\\Users\\a", ".workbuddy", "skills", "reverse-engineer-anything"),
]);
});
});
describe("WorkBuddy JSON registration", () => {
const workbuddyClient = async () => {
const home = await createTestTempDirectory("rea-workbuddy-");
const client = workbuddyPath(home, process.platform);
if (client?.markerPath === undefined)
throw new Error("missing WorkBuddy client");
await mkdir(client.markerPath, { recursive: true });
return { home, client };
};
it("upserts rea into mcpServers while preserving unrelated settings", async () => {
const { home, client } = await workbuddyClient();
await writeFile(
client.configPath,
`{
"mcpServers": {
"other": { "command": "other-server" }
}
}`,
);
expect(
(await configureClientConfiguration(client, {}, command)).status,
).toBe("configured");
const parsed = JSON.parse(await readFile(client.configPath, "utf8"));
expect(parsed.mcpServers.other).toEqual({ command: "other-server" });
expect(parsed.mcpServers.rea).toEqual({
command: command[0],
args: command.slice(1),
});
expect(await configureClientConfiguration(client, {}, command)).toEqual({
status: "unchanged",
});
expect(
(
await readClientRegistrationStatuses(home, undefined, {
platform: process.platform,
environment: {},
})
).find(({ client }) => client === "workbuddy"),
).toMatchObject({ state: "aligned", command });
});
it("creates the mcpServers table in an existing mcp.json without servers", async () => {
const { client } = await workbuddyClient();
await writeFile(
client.configPath,
JSON.stringify({ unrelated: { setting: true } }, null, 2),
);
expect(
(await configureClientConfiguration(client, {}, command)).status,
).toBe("configured");
const parsed = JSON.parse(await readFile(client.configPath, "utf8"));
expect(parsed.unrelated).toEqual({ setting: true });
expect(parsed.mcpServers.rea).toEqual({
command: command[0],
args: command.slice(1),
});
});
it("registers into an empty mcp.json", async () => {
const { client } = await workbuddyClient();
await writeFile(client.configPath, "");
expect(
(await configureClientConfiguration(client, {}, command)).status,
).toBe("configured");
const parsed = JSON.parse(await readFile(client.configPath, "utf8"));
expect(parsed.mcpServers.rea).toEqual({
command: command[0],
args: command.slice(1),
});
});
it("removes only the rea entry on uninstall", async () => {
const { home, client } = await workbuddyClient();
await writeFile(
client.configPath,
JSON.stringify(
{ mcpServers: { other: { command: "other-server" } } },
null,
2,
),
);
await configureClientConfiguration(client, {}, command);
expect((await systemUninstallHost(home).removeClient(client)).status).toBe(
"removed",
);
const parsed = JSON.parse(await readFile(client.configPath, "utf8"));
expect(parsed.mcpServers.other).toEqual({ command: "other-server" });
expect(parsed.mcpServers.rea).toBeUndefined();
});
});
@@ -1,138 +0,0 @@
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { Ajv2020 } from "ajv/dist/2020.js";
import { expect, it } from "vitest";
import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js";
import { createEvidence } from "../../../src/domain/evidence.js";
import { functionDossierSchema } from "../../../src/domain/hopperValues.js";
import type { RelationshipCoverage } from "../../../src/domain/relationshipCoverage.js";
import { createServer } from "../../../src/server/createServer.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
const observe = (relationshipCoverage: RelationshipCoverage) => {
const template = FUNCTION_COMPARISON_EXAMPLE.left;
if (template.subject === null)
throw new Error("Expected artifact-bound fixture");
const dossier = functionDossierSchema.parse(template.normalized_result);
return createEvidence(
{
path: template.subject.local_path,
sha256: template.subject.digest.sha256,
format: template.subject.format,
},
template.provider,
{
operation: "analyze_function",
parameters: template.parameters,
result: { ...dossier, callees: [], unresolved_calls: [] },
confidence: template.confidence,
authority: template.authority,
relationshipCoverage,
},
);
};
it("preserves directional gaps through advertised schemas and public path reconstruction", async () => {
const session = createTestBinarySession(() => {
throw new Error("Offline call paths must not launch a provider");
});
const server = createServer({ kind: "session", session });
const client = new Client({ name: "function-call-directions", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const advertised = (await client.listTools()).tools.find(
({ name }) => name === "build_call_path",
);
if (advertised === undefined)
throw new Error("Missing build_call_path contract");
const ajv = new Ajv2020({ strict: false, validateFormats: false });
expect(ajv.validateSchema(advertised.inputSchema)).toBe(true);
const validate = ajv.compile(advertised.inputSchema);
for (const { directions, status, exhaustive } of [
{
directions: ["callers"] as ["callers"],
status: "not_found",
exhaustive: true,
},
{
directions: ["callees"] as ["callees"],
status: "unknown",
exhaustive: false,
},
{
directions: ["callers", "callees"] as ["callers", "callees"],
status: "unknown",
exhaustive: false,
},
]) {
const limitation =
"An observed call reference is absent from the inventory.";
const coverage: RelationshipCoverage = {
status: "partial",
limitations: [limitation],
limited_directions: directions,
};
const observation = observe(coverage);
expect(session.recordEvidence(observation).ok).toBe(true);
const input = {
functions: [observation],
start: { address: "0x1000" },
goal: { address: "0x9999" },
};
expect(validate(input), JSON.stringify(validate.errors)).toBe(true);
for (const limited_directions of [
[],
["callers", "callers"],
["callees", "callees"],
["callers", "callees", "callers"],
["incoming"],
])
expect(
validate({
...input,
functions: [
{
...observation,
relationship_coverage: {
...coverage,
limited_directions,
},
},
],
}),
).toBe(false);
const result = await client.callTool({
name: "build_call_path",
arguments: input,
});
if (result.isError) throw new Error(JSON.stringify(result));
expect(result.structuredContent).toMatchObject({
normalized_result: {
status,
search_scope: { exhaustive },
limitations: expect.arrayContaining([limitation]),
},
});
const comparison = await client.callTool({
name: "compare_functions",
arguments: { left: observation, right: observation },
});
if (comparison.isError) throw new Error(JSON.stringify(comparison));
expect(comparison.structuredContent).toMatchObject({
normalized_result: {
dimensions: expect.arrayContaining([
expect.objectContaining({ dimension: "calls", status: "unknown" }),
]),
},
});
// Each scenario owns its Evidence and residual-unknown ledger.
const closed = await session.close();
if (!closed.ok) throw closed.error;
}
} finally {
await Promise.allSettled([session.close(), client.close(), server.close()]);
}
});
+11 -21
View File
@@ -15,17 +15,9 @@ import {
type GhidraFunctionOperation,
} from "../../../src/ghidra/GhidraFunctionValues.js";
it.each([
{ pseudocodeAvailable: true, reportsCoverage: true },
{ pseudocodeAvailable: false, reportsCoverage: true },
{ pseudocodeAvailable: true, reportsCoverage: false },
{ pseudocodeAvailable: false, reportsCoverage: false },
])(
"compares observed empty Ghidra facets with pseudocode=$pseudocodeAvailable and coverage=$reportsCoverage",
async ({ pseudocodeAvailable, reportsCoverage }) => {
const coverage = reportsCoverage
? { status: "complete" as const }
: undefined;
it.each([true, false])(
"compares observed empty Ghidra facets while preserving pseudocode availability (%s)",
async (pseudocodeAvailable) => {
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
Object.assign(dossier, {
comments: [],
@@ -55,13 +47,7 @@ it.each([
if (operation !== "analyze_function")
throw new Error(`Unexpected operation: ${operation}`);
return Promise.resolve(
ok({
value: jsonValueSchema.parse(dossier),
limitations: [],
...(coverage === undefined
? {}
: { relationship_coverage: coverage }),
}),
ok({ value: jsonValueSchema.parse(dossier), limitations: [] }),
);
},
);
@@ -75,7 +61,9 @@ it.each([
harness.session,
analyzed.structuredContent,
);
expect(evidence.relationship_coverage).toEqual(coverage ?? null);
expect(evidence.relationship_coverage).toMatchObject({
status: "complete",
});
expect(evidence.normalized_result).toEqual(dossier);
const reply = await harness.mcp.callTool({
name: "compare_functions",
@@ -98,7 +86,7 @@ it.each([
pseudocode: pseudocodeAvailable ? "unchanged" : "unknown",
assembly: "unchanged",
comments: "unchanged",
calls: reportsCoverage ? "unchanged" : "unknown",
calls: "unchanged",
references: "unchanged",
strings_names: "unchanged",
cfg: "unchanged",
@@ -108,7 +96,9 @@ it.each([
evidence.parameters,
);
if (!replay.ok) throw replay.error;
expect(replay.value.relationshipCoverage).toEqual(coverage);
expect(replay.value.relationshipCoverage).toEqual(
evidence.relationship_coverage,
);
expect(harness.calls).toEqual(["analyze_function"]);
} finally {
await harness.close();
@@ -1,164 +0,0 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { z } from "zod";
import { interfaceBuilderAnalysisSchema } from "../../../src/domain/apple/interfaceBuilderGraph.js";
import { thinMach } from "../../../src/domain/binaryTarget.fixture.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { mcpTest } from "../../support/mcp/mcpFixture.js";
import { openProductionTarget } from "../../support/mcp/productionSession.js";
const evidenceOf = <Schema extends z.ZodType>(schema: Schema) =>
z.object({
evidence_id: z.string(),
parameters: z.record(z.string(), z.unknown()),
normalized_result: schema,
});
const summarySchema = z.object({
kind: z.literal("summary"),
parent_evidence_id: z.string(),
summary: z.object({
documents: z.array(z.unknown()),
available_counts: z.object({
nodes: z.number(),
edges: z.number(),
coverage: z.number(),
}),
}),
});
const pageSchema = z.object({
kind: z.literal("interface-builder"),
items: z.array(z.unknown()),
coverage: z.object({ next_offset: z.number().nullable() }),
});
/** A two-document app; each nib is a real rooted UIKit outlet archive. */
const fixtureApp = async (): Promise<string> => {
const app = join(
await createTestTempDirectory("rea-ib-summary-mcp-"),
"Fixture.app",
);
const contents = join(app, "Contents");
const resources = join(contents, "Resources");
await mkdir(resources, { recursive: true });
await mkdir(join(contents, "MacOS"));
await writeFile(
join(contents, "MacOS", "Fixture"),
thinMach(0xfeedfacf, 0x0100000c),
);
await writeFile(
join(contents, "Info.plist"),
"<plist><dict><key>CFBundleExecutable</key><string>Fixture</string></dict></plist>",
);
const nib = await readFile(
join(process.cwd(), "tests/fixtures/interface-builder/UIKitOutlet.xml"),
);
await writeFile(join(resources, "Main.nib"), nib);
await writeFile(join(resources, "Second.nib"), nib);
return app;
};
mcpTest(
"retains complete Interface Builder Evidence and pages one document's rows",
async ({ mcp, onTestFinished }) => {
const app = await fixtureApp();
const client = await openProductionTarget(mcp, onTestFinished, app);
const invalidDetail = await client.callTool({
name: "decode_interface_builder",
arguments: { detail: "brief" },
});
expect(invalidDetail.isError).toBe(true);
expect(invalidDetail.content).toEqual([
expect.objectContaining({
type: "text",
text: expect.stringMatching(/Input validation error:.*detail/su),
}),
]);
// Summary first: its own retained parent must serve every later page.
const summarized = await client.callTool({
name: "decode_interface_builder",
arguments: { detail: "summary" },
});
expect(summarized.isError, JSON.stringify(summarized.content)).not.toBe(
true,
);
const summary = evidenceOf(summarySchema).parse(
summarized.structuredContent,
).normalized_result;
const source = {
kind: "retained-evidence",
evidence_id: summary.parent_evidence_id,
};
const readAll = async (view: Record<string, unknown>) => {
const rows: unknown[] = [];
let offset: number | null = 0;
while (offset !== null) {
const paged = await client.callTool({
name: "inspect_analysis_view",
arguments: {
source,
view: { kind: "interface-builder", ...view, offset, limit: 2 },
},
});
expect(paged.isError, JSON.stringify(paged.content)).not.toBe(true);
const page = evidenceOf(pageSchema).parse(
paged.structuredContent,
).normalized_result;
rows.push(...page.items);
offset = page.coverage.next_offset;
}
return rows;
};
const main = "Contents/Resources/Main.nib";
const edges = await readAll({ collection: "edges" });
const mainNodes = await readAll({ collection: "nodes", document: main });
// Only then compare with a complete call made for reference.
const complete = await client.callTool({
name: "decode_interface_builder",
arguments: {},
});
expect(complete.isError, JSON.stringify(complete.content)).not.toBe(true);
const full = evidenceOf(interfaceBuilderAnalysisSchema).parse(
complete.structuredContent,
);
expect(full.parameters).not.toHaveProperty("detail");
const report = full.normalized_result;
expect(report.documents).toHaveLength(2);
// Delivery detail never reaches the decoder or the source Evidence ID.
expect(summary.parent_evidence_id).toBe(full.evidence_id);
expect(summary.summary.documents).toEqual(report.documents);
expect(summary.summary.available_counts).toEqual({
nodes: report.graph.nodes.length,
edges: report.graph.edges.length,
coverage: report.graph.coverage.length,
});
expect(edges).toEqual(report.graph.edges);
const expectedMainNodes = report.graph.nodes.filter(({ evidence }) =>
evidence.some(({ artifact_path }) => artifact_path === main),
);
expect(expectedMainNodes.length).toBeGreaterThan(0);
expect(expectedMainNodes.length).toBeLessThan(report.graph.nodes.length);
expect(mainNodes).toEqual(expectedMainNodes);
const unknownDocument = await client.callTool({
name: "inspect_analysis_view",
arguments: {
source,
view: {
kind: "interface-builder",
collection: "nodes",
document: "Contents/Resources/Missing.nib",
},
},
});
expect(parseMcpToolError(unknownDocument)).toMatchObject({
error: { code: "invalid_request" },
});
},
);
@@ -1,24 +1,40 @@
import { silentLogger } from "../../../src/logger.js";
import { expect } from "vitest";
import { z } from "zod";
import { createBinarySession } from "../../../src/composition/binary.js";
import { parseConfig } from "../../../src/config/parseConfig.js";
import { keyedArchiveResultSchema } from "../../../src/domain/apple/keyedArchive.js";
import { createServer } from "../../../src/server/createServer.js";
import {
archiveNumberMetadataCases,
expectKeyedArchiveNumberMetadata,
keyedArchiveNumberMetadataFixture,
} from "../../fixtures/keyedArchiveNumberMetadata.js";
import { mcpTest } from "../../support/mcp/mcpFixture.js";
import { openProductionTarget } from "../../support/mcp/productionSession.js";
mcpTest.for(archiveNumberMetadataCases)(
"MCP preserves numeric meaning for $name",
async (item, { mcp, onTestFinished }) => {
const fixture = await keyedArchiveNumberMetadataFixture(item);
const client = await openProductionTarget(
mcp,
onTestFinished,
fixture.path,
const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" });
if (!configured.ok) throw configured.error;
const session = createBinarySession(
configured.value,
silentLogger,
process.env,
);
onTestFinished(async () => {
await session.close();
});
const client = await mcp.connect(
createServer({ kind: "session", session }),
);
const opened = await client.callTool({
name: "open_binary",
arguments: { path: fixture.path },
});
expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true);
const result = await client.callTool({
name: "inspect_keyed_archive",
arguments: {},
@@ -1,23 +1,39 @@
import { expect } from "vitest";
import { z } from "zod";
import { createBinarySession } from "../../../src/composition/binary.js";
import { parseConfig } from "../../../src/config/parseConfig.js";
import { keyedArchiveResultSchema } from "../../../src/domain/apple/keyedArchive.js";
import { silentLogger } from "../../../src/logger.js";
import { createServer } from "../../../src/server/createServer.js";
import {
expectKeyedArchivePrototypeKeys,
keyedArchivePrototypeKeyFixture,
} from "../../fixtures/keyedArchivePrototypeKeys.js";
import { mcpTest } from "../../support/mcp/mcpFixture.js";
import { openProductionTarget } from "../../support/mcp/productionSession.js";
mcpTest(
"MCP retains ordinary evidence with formatted XML prototype keys",
async ({ mcp, onTestFinished }) => {
const fixture = await keyedArchivePrototypeKeyFixture();
const client = await openProductionTarget(
mcp,
onTestFinished,
fixture.path,
const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" });
if (!configured.ok) throw configured.error;
const session = createBinarySession(
configured.value,
silentLogger,
process.env,
);
onTestFinished(async () => {
await session.close();
});
const client = await mcp.connect(
createServer({ kind: "session", session }),
);
const opened = await client.callTool({
name: "open_binary",
arguments: { path: fixture.path },
});
expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true);
const result = await client.callTool({
name: "inspect_keyed_archive",
arguments: {},
+22 -2
View File
@@ -1,12 +1,15 @@
import { silentLogger } from "../../../src/logger.js";
import { copyFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect } from "vitest";
import { z } from "zod";
import { createBinarySession } from "../../../src/composition/binary.js";
import { parseConfig } from "../../../src/config/parseConfig.js";
import { createServer } from "../../../src/server/createServer.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { mcpTest } from "../../support/mcp/mcpFixture.js";
import { openProductionTarget } from "../../support/mcp/productionSession.js";
const demangledSchema = z.object({
normalized_result: z.object({
@@ -27,7 +30,24 @@ describe.skipIf(process.platform !== "darwin")(
"tool",
);
await copyFile("/usr/bin/true", target);
const client = await openProductionTarget(mcp, onTestFinished, target);
const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" });
if (!configured.ok) throw configured.error;
const session = createBinarySession(
configured.value,
silentLogger,
process.env,
);
onTestFinished(async () => {
await session.close();
});
const client = await mcp.connect(
createServer({ kind: "session", session }),
);
const opened = await client.callTool({
name: "open_binary",
arguments: { path: target },
});
expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true);
const called = await client.callTool({
name: "demangle_swift",
@@ -1,9 +1,12 @@
import { silentLogger } from "../../../src/logger.js";
import { copyFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect } from "vitest";
import { z } from "zod";
import { createBinarySession } from "../../../src/composition/binary.js";
import { parseConfig } from "../../../src/config/parseConfig.js";
import { inspectMachoSchema } from "../../../src/domain/native/nativeInspection.js";
import { createServer } from "../../../src/server/createServer.js";
import { thinMach } from "../../../src/domain/binaryTarget.fixture.js";
@@ -13,7 +16,6 @@ import { ok } from "../../../src/domain/result.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { mcpTest } from "../../support/mcp/mcpFixture.js";
import { openProductionTarget } from "../../support/mcp/productionSession.js";
const evidenceOf = <Schema extends z.ZodType>(schema: Schema) =>
z.object({
@@ -52,7 +54,24 @@ describe.skipIf(process.platform !== "darwin")(
"ls",
);
await copyFile("/bin/ls", target);
const client = await openProductionTarget(mcp, onTestFinished, target);
const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" });
if (!configured.ok) throw configured.error;
const session = createBinarySession(
configured.value,
silentLogger,
process.env,
);
onTestFinished(async () => {
await session.close();
});
const client = await mcp.connect(
createServer({ kind: "session", session }),
);
const opened = await client.callTool({
name: "open_binary",
arguments: { path: target },
});
expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true);
const complete = await client.callTool({
name: "inspect_macho",
+3 -1
View File
@@ -94,7 +94,9 @@ const statusCapability = (
describe("server and catalog identity", () => {
it("derives package and SDK versions from canonical package metadata", async () => {
const packageJson = JSON.parse(await readFile("package.json", "utf8"));
const packageLock = JSON.parse(await readFile("package-lock.json", "utf8"));
const packageLock = JSON.parse(
await readFile("npm-shrinkwrap.json", "utf8"),
);
expect(PACKAGE_METADATA).toMatchObject({
name: packageJson.name,
version: packageJson.version,
@@ -0,0 +1,237 @@
import { spawn } from "node:child_process";
import { copyFile, lstat, mkdir, readFile, writeFile } from "node:fs/promises";
import { delimiter, join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, it, onTestFinished } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const launcher = fileURLToPath(
new URL("../../../scripts/plugin-mcp-launcher.mjs", import.meta.url),
);
const start = (path: string, version = "6.4.0-rc.1", entry = launcher) => {
const child = spawn(process.execPath, [entry, version], {
env: {
...process.env,
PATH: `${path}${delimiter}${process.env.PATH ?? ""}`,
},
stdio: "pipe",
});
let stdout = "";
let stderr = "";
child.stdout.on(
"data",
(bytes: Buffer) => (stdout += bytes.toString("utf8")),
);
child.stderr.on(
"data",
(bytes: Buffer) => (stderr += bytes.toString("utf8")),
);
const closed = new Promise<{
code: number | null;
signal: NodeJS.Signals | null;
stdout: string;
stderr: string;
}>((resolve, reject) => {
child.once("error", reject);
child.once("close", (code, signal) =>
resolve({ code, signal, stdout, stderr }),
);
});
onTestFinished(async () => {
if (child.exitCode === null && child.signalCode === null)
child.kill("SIGKILL");
await closed;
});
return { child, closed };
};
const fakeRunner = async (body: string) => {
const root = await createTestTempDirectory("rea-plugin-launcher-");
const bin = join(root, "plugin with spaces", "bin");
await mkdir(bin, { recursive: true });
await writeFile(join(bin, "npx"), `#!${process.execPath}\n${body}\n`, {
mode: 0o755,
});
return bin;
};
it.skipIf(process.platform === "win32")(
"passes the exact pin and protocol streams through the source-only plugin launcher",
async () => {
const bin = await fakeRunner(`
process.stderr.write(JSON.stringify(process.argv.slice(2)));
process.stdin.pipe(process.stdout);
process.stdin.on("end", () => { process.exitCode = 7; });
`);
const { child, closed } = start(bin, "6.4.0-rc.1");
child.stdin.end('{"jsonrpc":"2.0","id":1}\n');
expect(await closed).toEqual({
code: 7,
signal: null,
stdout: '{"jsonrpc":"2.0","id":1}\n',
stderr: '["-y","rea-agents@6.4.0-rc.1","mcp"]',
});
},
);
it.skipIf(process.platform === "win32")(
"forwards termination to the live package-runner child before exiting",
async () => {
const bin = await fakeRunner(`
process.on("SIGTERM", () => { process.stderr.write("stopped"); process.exit(0); });
process.stdout.write("ready");
process.stdin.resume();
`);
const { child, closed } = start(bin);
await new Promise<void>((resolve, reject) => {
child.stdout.once("data", () => resolve());
child.once("error", reject);
});
child.kill("SIGTERM");
expect(await closed).toMatchObject({ code: 0, stderr: "stopped" });
},
);
it.skipIf(process.platform === "win32")(
"stops a package runner and its long-lived server with stdin still open",
async () => {
const root = await createTestTempDirectory("rea-plugin-process-tree-");
const runnerMarker = join(root, "runner-stopped");
const serverMarker = join(root, "server-stopped");
const bin = await fakeRunner(`
const { appendFileSync } = require("node:fs");
const { spawn } = require("node:child_process");
const server = spawn(process.execPath, ["-e", ${JSON.stringify(`
const { appendFileSync } = require("node:fs");
process.on("SIGTERM", () => {
appendFileSync(${JSON.stringify(serverMarker)}, "stopped\\n");
process.exit(0);
});
process.stdin.resume();
process.stdout.write("server-ready\\n");
`)}], { stdio: "inherit" });
let stopping = false;
process.on("SIGTERM", () => {
stopping = true;
appendFileSync(${JSON.stringify(runnerMarker)}, "stopped\\n");
});
process.stdin.resume();
server.once("close", () => { if (stopping) process.exit(0); });
`);
const { child, closed } = start(bin);
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("package server did not become ready")),
5_000,
);
child.stdout.on("data", (bytes: Buffer) => {
if (bytes.toString("utf8").includes("server-ready\n")) {
clearTimeout(timeout);
resolve();
}
});
child.once("error", reject);
});
// Keep the protocol pipe open: shutdown must reach the package runner and
// its server process independently of EOF on stdin.
child.kill("SIGTERM");
expect(await closed).toMatchObject({ code: 0, signal: null });
await expect(readFile(runnerMarker, "utf8")).resolves.toBe("stopped\n");
await expect(readFile(serverMarker, "utf8")).resolves.toBe("stopped\n");
},
);
it.skipIf(process.platform === "win32")(
"isolates local package resolution and removes the owned runner directory",
async () => {
const bin = await fakeRunner("process.stdout.write(process.cwd());");
const { child, closed } = start(bin);
child.stdin.end();
const result = await closed;
expect(result.code).toBe(0);
expect(result.stdout).toContain("rea-plugin-launcher-");
expect(result.stdout).not.toBe(process.cwd());
await expect(lstat(result.stdout)).rejects.toMatchObject({
code: "ENOENT",
});
},
);
it("rejects a shell expression instead of treating it as the pinned package", async () => {
const root = await createTestTempDirectory("rea-plugin-invalid-pin-");
const { child, closed } = start(root, "6.4.0&echo wrong");
child.stdin.end();
expect(await closed).toMatchObject({
code: 1,
stdout: "",
stderr: "REA plugin requires one exact rea-agents@VERSION pin.\n",
});
});
const pluginFixture = async () => {
const root = await createTestTempDirectory("rea-plugin-release-origin-");
await mkdir(join(root, "scripts"));
await mkdir(join(root, "dist/mcp"), { recursive: true });
const entry = join(root, "scripts/plugin-mcp-launcher.mjs");
await copyFile(launcher, entry);
await writeFile(
join(root, "package.json"),
JSON.stringify({
name: "rea-agents",
version: "6.4.0",
type: "module",
}),
);
await writeFile(join(root, "dist/mcp/main.js"), "");
await writeFile(
join(root, "scripts/rea.mjs"),
'process.stdout.write("bundled-runtime");',
);
return { root, entry };
};
it
.skipIf(process.platform === "win32")
.each(["directory", "worktree file", "exported source"])(
"uses the registry pin even when a %s checkout has same-version dist",
async (kind) => {
const { root, entry } = await pluginFixture();
await mkdir(join(root, "src/application"), { recursive: true });
await copyFile(
fileURLToPath(
new URL(
"../../../src/application/PackageRunnerCommand.mjs",
import.meta.url,
),
),
join(root, "src/application/PackageRunnerCommand.mjs"),
);
if (kind === "directory") await mkdir(join(root, ".git"));
else if (kind === "worktree file")
await writeFile(join(root, ".git"), "gitdir: /fixture/worktree");
const bin = await fakeRunner(
"process.stdout.write(JSON.stringify(process.argv.slice(2)));",
);
const { child, closed } = start(bin, "6.4.0", entry);
child.stdin.end();
expect(await closed).toMatchObject({
code: 0,
stdout: '["-y","rea-agents@6.4.0","mcp"]',
stderr: "",
});
},
);
it("uses the matching installed runtime without another package-runner install", async () => {
const { root, entry } = await pluginFixture();
const { child, closed } = start(root, "6.4.0", entry);
child.stdin.end();
expect(await closed).toMatchObject({
code: 0,
stdout: "bundled-runtime",
stderr: "",
});
});
@@ -12,8 +12,6 @@ import ghidra.program.model.listing.FlowOverride;
import ghidra.program.model.listing.Function;
import ghidra.program.model.listing.Instruction;
import ghidra.program.model.symbol.SourceType;
import ghidra.program.model.symbol.Reference;
import ghidra.program.model.symbol.RefType;
import java.io.File;
import java.lang.reflect.Method;
import java.nio.file.Files;
@@ -22,12 +20,9 @@ import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.ArrayList;
/** Injects reported analysis flags into imported ELF/Mach-O functions; never executes target code. */
public class ReaNoReturnProbe extends GhidraScript {
private record MemoryCallReference(Address source, Address target, RefType type,
SourceType sourceType, int operandIndex, boolean primary) {}
private final JsonArray checks = new JsonArray();
private final Map<Function, Address> originalEnds = new LinkedHashMap<>();
private final Map<Function, String> originalSignatures = new LinkedHashMap<>();
@@ -90,114 +85,6 @@ public class ReaNoReturnProbe extends GhidraScript {
return instance;
}
private void knownInteriorCall(GhidraScript bridge) throws Exception {
Function caller = function("rea_tls_chain");
Function target = function("rea_tls");
Address interior = target.getEntryPoint().add(1);
require("interior_target_is_owned", currentProgram.getFunctionManager().getFunctionAt(interior) == null &&
target.equals(currentProgram.getFunctionManager().getFunctionContaining(interior)));
Instruction call = null;
for (Instruction instruction : currentProgram.getListing().getInstructions(caller.getBody(), true)) {
if (instruction.getFlowType().isCall()) { call = instruction; break; }
}
if (call == null) throw new IllegalStateException("Missing real call instruction");
var references = currentProgram.getReferenceManager();
List<Reference> originals = new ArrayList<>();
List<MemoryCallReference> saved = new ArrayList<>();
for (Reference reference : call.getReferencesFrom()) {
if (!reference.getReferenceType().isCall()) continue;
originals.add(reference);
saved.add(new MemoryCallReference(reference.getFromAddress(), reference.getToAddress(),
reference.getReferenceType(), reference.getSource(), reference.getOperandIndex(), reference.isPrimary()));
}
require("original_call_reference_present", !originals.isEmpty());
Reference changed = null;
try {
for (Reference reference : originals) references.delete(reference);
changed = references.addMemoryReference(call.getAddress(), interior,
RefType.UNCONDITIONAL_CALL, SourceType.USER_DEFINED, 0);
require("interior_target_omitted_by_callee_getter", !caller.getCalledFunctions(monitor).contains(target));
JsonObject query = new JsonObject();
query.addProperty("document", currentProgram.getName());
query.addProperty("procedure", caller.getName());
query.addProperty("direction", "outgoing");
Method read = bridge.getClass().getDeclaredMethod("procedureReferences", JsonObject.class);
read.setAccessible(true);
JsonObject output = (JsonObject) read.invoke(bridge, query);
require("interior_reference_keeps_known_procedure", output.getAsJsonArray("references").asList().stream().anyMatch(
edge -> !edge.getAsJsonObject().get("target_procedure").isJsonNull() &&
edge.getAsJsonObject().getAsJsonObject("target_procedure").get("name").getAsString().equals(target.getName())));
require("known_reference_is_not_also_unresolved", output.getAsJsonArray("unresolved_calls").isEmpty());
query.remove("direction");
Method callees = bridge.getClass().getDeclaredMethod("procedureCalls", JsonObject.class, boolean.class);
callees.setAccessible(true);
JsonObject inventory = (JsonObject) callees.invoke(bridge, query, false);
require("omitted_callee_inventory_stays_partial", inventory.getAsJsonObject("relationship_coverage").get("status").getAsString().equals("partial"));
require("omitted_callee_direction_is_explicit",
inventory.getAsJsonObject("relationship_coverage").getAsJsonArray("limited_directions").toString().equals("[\"callees\"]"));
require("interior_caller_omitted_by_entry_getter", !target.getCallingFunctions(monitor).contains(caller));
query.addProperty("procedure", target.getName());
JsonObject incoming = (JsonObject) callees.invoke(bridge, query, true);
require("off_entry_caller_inventory_stays_partial",
incoming.getAsJsonObject("relationship_coverage").get("status").getAsString().equals("partial"));
require("off_entry_caller_direction_is_explicit",
incoming.getAsJsonObject("relationship_coverage").getAsJsonArray("limited_directions").toString().equals("[\"callers\"]"));
JsonObject dossierQuery = new JsonObject();
dossierQuery.addProperty("procedure", target.getName());
Method analyze = bridge.getClass().getDeclaredMethod("analyzeFunctionObservation", JsonObject.class);
analyze.setAccessible(true);
Object observation = analyze.invoke(bridge, dossierQuery);
Method coverage = observation.getClass().getDeclaredMethod("relationshipCoverage");
coverage.setAccessible(true);
require("dossier_preserves_caller_only_gap",
((JsonObject) coverage.invoke(observation)).getAsJsonArray("limited_directions").toString().equals("[\"callers\"]"));
} finally {
if (changed != null) references.delete(changed);
for (MemoryCallReference reference : saved) {
Reference restored = references.addMemoryReference(reference.source(), reference.target(),
reference.type(), reference.sourceType(), reference.operandIndex());
references.setPrimary(restored, reference.primary());
}
}
}
private void nonCallInstructionReference(GhidraScript bridge) throws Exception {
Function caller = function("rea_tls_chain");
Function target = function("rea_memcpy");
Instruction source = null;
for (Instruction instruction : currentProgram.getListing().getInstructions(caller.getBody(), true)) {
if (!instruction.getFlowType().isCall()) { source = instruction; break; }
}
if (source == null) throw new IllegalStateException("Missing non-call instruction");
var references = currentProgram.getReferenceManager();
Reference added = references.addMemoryReference(source.getAddress(), target.getEntryPoint().add(1),
RefType.UNCONDITIONAL_CALL, SourceType.USER_DEFINED, -1);
try {
require("non_call_reference_target_omitted", !caller.getCalledFunctions(monitor).contains(target));
JsonObject query = new JsonObject();
query.addProperty("document", currentProgram.getName());
query.addProperty("procedure", caller.getName());
Method read = bridge.getClass().getDeclaredMethod("procedureCalls", JsonObject.class, boolean.class);
read.setAccessible(true);
JsonObject output = (JsonObject) read.invoke(bridge, query, false);
require("non_call_instruction_reference_stays_partial",
output.getAsJsonObject("relationship_coverage").get("status").getAsString().equals("partial"));
query.addProperty("direction", "outgoing");
Method edges = bridge.getClass().getDeclaredMethod("procedureReferences", JsonObject.class);
edges.setAccessible(true);
JsonObject known = (JsonObject) edges.invoke(bridge, query);
require("non_call_known_reference_is_not_unresolved", known.getAsJsonArray("unresolved_calls").isEmpty());
references.delete(added);
Address unknown = currentProgram.getMemory().getMaxAddress().add(0x1000);
added = references.addMemoryReference(source.getAddress(), unknown,
RefType.UNCONDITIONAL_CALL, SourceType.USER_DEFINED, -1);
JsonObject unresolved = (JsonObject) edges.invoke(bridge, query);
require("non_call_unknown_reference_keeps_site", unresolved.getAsJsonArray("unresolved_calls").size() == 1);
} finally {
references.delete(added);
}
}
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
@@ -277,8 +164,6 @@ public class ReaNoReturnProbe extends GhidraScript {
Object output = pseudocode.invoke(bridge, query);
require("recovered_pseudocode", output instanceof JsonObject value &&
value.get("value").getAsString().contains("rea_after_call"));
nonCallInstructionReference(bridge);
knownInteriorCall(bridge);
} finally {
((ghidra.app.decompiler.DecompInterface) field.get(bridge)).dispose();
}
+253 -11
View File
@@ -1,13 +1,28 @@
import { readFile } from "node:fs/promises";
import { access, readFile } from "node:fs/promises";
import { GenericJson } from "release-please/build/src/updaters/generic-json.js";
import { Version } from "release-please/build/src/version.js";
import { describe, expect, it } from "vitest";
import { SUPPORTED_NODE_VERSION_RANGE } from "../../src/domain/runtimeVersion.js";
import { MCP_STARTUP_POLICY } from "../../src/mcpStartupPolicy.js";
const readJson = async (path: string): Promise<unknown> =>
JSON.parse(await readFile(path, "utf8")) as unknown;
const versionedDocumentation = ["docs/installation.md"] as const;
// Each file lists a fragment of every example Release Please must keep
// current, so a lost marker is caught as well as a stale version.
const versionedDocumentation = [
{
path: "docs/installation.md",
examples: ["doctor --provider ghidra --json", '"command": "npx"'],
},
] as const;
const pluginManifests = [
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
] as const;
const pluginServerConfig = ".mcp.json";
describe("release configuration", () => {
it("keeps doctor and setup runtime support aligned with package engines", async () => {
@@ -24,7 +39,7 @@ describe("release configuration", () => {
packages: {
".": {
"extra-files": [
...versionedDocumentation.map((path) => ({
...versionedDocumentation.map(({ path }) => ({
type: "generic",
path,
})),
@@ -42,6 +57,35 @@ describe("release configuration", () => {
path: "server.json",
jsonpath: "$.packages[0].version",
},
...pluginManifests.flatMap((path) => [
{
type: "json",
path,
jsonpath: "$.version",
},
...(path.startsWith(".claude")
? [
{
type: "json",
path,
jsonpath: "$.mcpServers.rea.args[1]",
},
]
: []),
]),
{
type: "json",
path: pluginServerConfig,
jsonpath: "$.mcpServers.rea.args[1]",
},
...[
".claude-plugin/marketplace.json",
".agents/plugins/marketplace.json",
].map((path) => ({
type: "json",
path,
jsonpath: "$.plugins[0].source.version",
})),
],
},
},
@@ -49,18 +93,27 @@ describe("release configuration", () => {
});
it.each(versionedDocumentation)(
"marks the versioned package example in %s",
async (path) => {
"marks every versioned package example in $path",
async ({ path, examples }) => {
const content = await readFile(path, "utf8");
const packageJson = (await readJson("package.json")) as {
version: string;
};
const versionBlock =
/<!-- x-release-please-start-version -->[\s\S]*?<!-- x-release-please-end -->/u.exec(
content,
)?.[0];
const versionBlocks = [
...content.matchAll(
/<!-- x-release-please-start-version -->[\s\S]*?<!-- x-release-please-end -->/gu,
),
].map(([block]) => block);
expect(versionBlock).toContain(`rea-agents@${packageJson.version}`);
for (const example of examples)
expect(
versionBlocks.filter((block) => block.includes(example)),
example,
).toHaveLength(1);
for (const block of versionBlocks)
expect(block.match(/rea-agents@[^\s"`]+/gu)).toEqual([
`rea-agents@${packageJson.version}`,
]);
},
);
@@ -71,10 +124,13 @@ describe("release configuration", () => {
expect(await readJson(".release-please-manifest.json")).toEqual({
".": packageJson.version,
});
expect(await readJson("package-lock.json")).toMatchObject({
expect(await readJson("npm-shrinkwrap.json")).toMatchObject({
version: packageJson.version,
packages: { "": { version: packageJson.version } },
});
await expect(access("package-lock.json")).rejects.toMatchObject({
code: "ENOENT",
});
const changelog = await readFile("CHANGELOG.md", "utf8");
expect(/^## \[([^\]]+)\]/mu.exec(changelog)?.[1]).toBe(packageJson.version);
});
@@ -120,3 +176,189 @@ describe("release configuration", () => {
});
});
});
describe("plugin manifests", () => {
it("distributes both plugins with generated canonical skills from the exact npm release", async () => {
const pkg = (await readJson("package.json")) as {
version: string;
files: string[];
};
expect(pkg.files).toEqual(
expect.arrayContaining([
...pluginManifests,
pluginServerConfig,
"skills",
]),
);
for (const path of pluginManifests)
expect(await readJson(path)).toMatchObject({ skills: "./skills" });
for (const path of [
".claude-plugin/marketplace.json",
".agents/plugins/marketplace.json",
])
expect(await readJson(path)).toMatchObject({
plugins: [
{
source: {
source: "npm",
package: "rea-agents",
version: pkg.version,
},
},
],
});
});
it.each(pluginManifests)(
"pins the plugin manifest %s and Codex's server config path",
async (path) => {
const packageJson = (await readJson("package.json")) as {
name: string;
version: string;
};
expect(await readJson(path)).toMatchObject({
version: packageJson.version,
...(path.startsWith(".codex")
? { mcpServers: `./${pluginServerConfig}` }
: {}),
});
},
);
});
describe("plugin release pins", () => {
it("advances every plugin pin through Release Please JSON extra-files", async () => {
const packageJson = (await readJson("package.json")) as {
version: string;
};
const currentVersion = Version.parse(packageJson.version);
const nextVersion = `${currentVersion.major}.${currentVersion.minor + 1}.0`;
const version = Version.parse(nextVersion);
const config = (await readJson("release-please-config.json")) as {
packages: {
".": {
"extra-files": Array<{
type: string;
path: string;
jsonpath?: string;
}>;
};
};
};
const updated = new Map<string, string>();
for (const extraFile of config.packages["."]["extra-files"]) {
if (extraFile.type !== "json" || extraFile.jsonpath === undefined)
continue;
const content =
updated.get(extraFile.path) ?? (await readFile(extraFile.path, "utf8"));
updated.set(
extraFile.path,
new GenericJson(extraFile.jsonpath, version).updateContent(content),
);
}
const updatedJson = new Map<string, unknown>(
[...updated].map(
([path, content]) => [path, JSON.parse(content) as unknown] as const,
),
);
expect(updatedJson.get(".claude-plugin/plugin.json")).toMatchObject({
version: nextVersion,
mcpServers: {
rea: {
args: [
"${CLAUDE_PLUGIN_ROOT}/scripts/plugin-mcp-launcher.mjs",
nextVersion,
],
},
},
});
expect(updatedJson.get(".codex-plugin/plugin.json")).toMatchObject({
version: nextVersion,
});
expect(updatedJson.get(pluginServerConfig)).toMatchObject({
mcpServers: {
rea: {
args: ["./scripts/plugin-mcp-launcher.mjs", nextVersion],
startup_timeout_sec: 30,
},
},
});
for (const path of [
".claude-plugin/marketplace.json",
".agents/plugins/marketplace.json",
])
expect(updatedJson.get(path)).toMatchObject({
plugins: [
{
source: {
source: "npm",
package: "rea-agents",
version: nextVersion,
},
},
],
});
});
it("pins the plugin server launch from the shared MCP declaration", async () => {
const packageJson = (await readJson("package.json")) as {
version: string;
};
expect(await readJson(pluginServerConfig)).toMatchObject({
mcpServers: {
rea: {
command: "node",
args: ["./scripts/plugin-mcp-launcher.mjs", packageJson.version],
cwd: ".",
},
},
});
});
it.each(pluginManifests)(
"stores %s in the form Release Please rewrites it to",
async (path) => {
// Release Please reserializes JSON extra-files, so any other layout
// would change formatting in every release PR.
const content = await readFile(path, "utf8");
expect(content).toBe(`${JSON.stringify(JSON.parse(content), null, 2)}
`);
},
);
it("declares the interface fields Codex plugin validation requires", async () => {
const manifest = (await readJson(".codex-plugin/plugin.json")) as {
interface: Record<string, unknown>;
};
for (const field of [
"displayName",
"shortDescription",
"longDescription",
"developerName",
"category",
])
expect(manifest.interface[field]).toEqual(expect.stringMatching(/\S/u));
expect(manifest.interface.capabilities).toEqual(
expect.arrayContaining([expect.stringMatching(/\S/u)]),
);
expect(manifest.interface.defaultPrompt).toEqual(
expect.arrayContaining([expect.stringMatching(/\S/u)]),
);
});
it("carries the owned Codex startup timeout in the companion server entry", async () => {
expect(await readJson(pluginServerConfig)).toMatchObject({
mcpServers: {
rea: {
startup_timeout_sec: MCP_STARTUP_POLICY.codexStartupTimeoutSeconds,
},
},
});
});
});

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