Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9d4d8301f | ||
|
|
1be370543f | ||
|
|
e0fd8207a1 | ||
|
|
da0970672d | ||
|
|
676959ecb5 | ||
|
|
b7df5b7805 | ||
|
|
4aa2b60c1d | ||
|
|
4e17be5705 | ||
|
|
bb19c9477e | ||
|
|
f608127635 | ||
|
|
957069d14d | ||
|
|
93091abb4d | ||
|
|
c57f1b74e0 | ||
|
|
3427593daf | ||
|
|
bddeb634b5 | ||
|
|
3b92a2bced | ||
|
|
8b9940a7b5 | ||
|
|
b6d9acca63 | ||
|
|
2d4a910fcd | ||
|
|
35e5508fd0 | ||
|
|
69d8afefc1 | ||
|
|
c55ae3b9ec | ||
|
|
166dcd21d0 | ||
|
|
c182f72ad3 | ||
|
|
6cc45e8dc9 | ||
|
|
e20da1f205 | ||
|
|
4a6c17ce83 | ||
|
|
c9f49db907 | ||
|
|
b912b117f0 | ||
|
|
f72931d9dd | ||
|
|
5cbf06c7ad | ||
|
|
11f27deddf | ||
|
|
bef4321afb | ||
|
|
3f1a77f68e | ||
|
|
880cca1bca | ||
|
|
f85056d83e | ||
|
|
527b76ef78 | ||
|
|
ce73c3fed2 | ||
|
|
430c297ca4 | ||
|
|
c3f2b8e774 | ||
|
|
bc2cd8b874 | ||
|
|
63012eeb60 | ||
|
|
5fd0db37d9 | ||
|
|
e7cbfe04fd | ||
|
|
522c2d0ecb | ||
|
|
5ead2cfbc4 | ||
|
|
3a4a63f70a | ||
|
|
58ef8ac3aa | ||
|
|
be3cfbffdf | ||
|
|
11fc3b9815 | ||
|
|
78f4848d70 | ||
|
|
c13c9d41f8 | ||
|
|
ad1f6b508f | ||
|
|
6aa6350c80 | ||
|
|
f7954a8fd6 | ||
|
|
de9593ad48 | ||
|
|
1260cacc67 |
@@ -1,28 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
NODE_VERSION="24.18.0"
|
||||
NODE_SHA256="55aa7153f9d88f28d765fcdad5ae6945b5c0f98a36881703817e4c450fa76742"
|
||||
export PATH="/usr/local/bin:${PATH}"
|
||||
|
||||
if [[ "$(/usr/local/bin/node -v 2>/dev/null || true)" != "v${NODE_VERSION}" ]]; then
|
||||
tmp="$(mktemp -d)"
|
||||
curl -fsSL -o "${tmp}/node.tar.xz" "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz"
|
||||
echo "${NODE_SHA256} ${tmp}/node.tar.xz" | sha256sum -c -
|
||||
sudo tar -xJf "${tmp}/node.tar.xz" -C /usr/local --strip-components=1
|
||||
rm -rf "${tmp}"
|
||||
fi
|
||||
|
||||
hash -r
|
||||
|
||||
for cmd in node npm npx corepack; do
|
||||
if [[ -x "/usr/local/bin/${cmd}" ]]; then
|
||||
sudo ln -sfn "/usr/local/bin/${cmd}" "/usr/local/cargo/bin/${cmd}"
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -f "${HOME}/.bashrc" ]] && ! grep -qF '# rea-dev-node' "${HOME}/.bashrc"; then
|
||||
printf '\n# rea-dev-node\nexport PATH="/usr/local/bin:$PATH"\n' >> "${HOME}/.bashrc"
|
||||
fi
|
||||
|
||||
npm ci
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"name": "REA",
|
||||
"install": "bash .cursor/cloud-agent-install.sh"
|
||||
}
|
||||
@@ -277,7 +277,7 @@ jobs:
|
||||
- run: npm run verify:keyed-archive
|
||||
- run: npm run verify:asset-catalog
|
||||
- name: Native compiled NIB hierarchy regressions
|
||||
run: npm run test:focused -- src/application/NibNativeHierarchy.test.ts
|
||||
run: npm run test:focused -- tests/boundary/filesystem/nibNativeHierarchy.test.ts tests/boundary/filesystem/interfaceBuilderBundle.test.ts
|
||||
- name: Real binary metadata and Interface Builder integration
|
||||
run: npm run verify:apple-dispatch && npm run verify:interface-builder
|
||||
- name: Native plist CLI selection regressions
|
||||
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
- "scripts/verify-real-browser.mjs"
|
||||
- "scripts/verify-browser-network.mjs"
|
||||
- "scripts/verify-browser-scripts.mjs"
|
||||
- "scripts/verify-browser-dom-destinations.mjs"
|
||||
- "scripts/lib/browser-*.mjs"
|
||||
- "scripts/fixtures/browser-network-site.mjs"
|
||||
- "scripts/fixtures/browser-script-site.mjs"
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
name: Real JavaScript recovery
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/real-javascript-recovery.yml"
|
||||
- "src/javascript/recovery/**"
|
||||
- "src/domain/javascript*.ts"
|
||||
- "src/application/JavaScriptRecovery*.ts"
|
||||
- "src/composition/javascriptRecovery.ts"
|
||||
- "src/contracts/javascriptRecoveryToolContracts.ts"
|
||||
- "src/cli/javascriptRecoveryCommands.ts"
|
||||
- "src/cli/javascriptApplicationAnalysis.ts"
|
||||
- "src/server/registerJavaScriptRecoveryTool.ts"
|
||||
- "src/server/createServer.ts"
|
||||
- "src/server/sessionAvailabilityPolicy.ts"
|
||||
- "src/application/CapabilityInventory.ts"
|
||||
- "src/cli.ts"
|
||||
- "src/cliCommandNames.ts"
|
||||
- "src/contracts/toolContracts.ts"
|
||||
- "src/contracts/toolEffects.ts"
|
||||
- "scripts/rea.mjs"
|
||||
- "src/process/**"
|
||||
- "src/artifacts/SafeOutputTree.ts"
|
||||
- "scripts/verify-javascript-recovery.mjs"
|
||||
- "scripts/lib/javascript-recovery-*.mjs"
|
||||
- "scripts/fixtures/javascript-recovery/**"
|
||||
- "third_party/wakaru"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: javascript-recovery-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
HUSKY: "0"
|
||||
NODE_OPTIONS: "--max-old-space-size=768"
|
||||
RAYON_NUM_THREADS: "1"
|
||||
|
||||
jobs:
|
||||
real-javascript-recovery:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Fetch pinned Wakaru release into the job workspace
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/rea-wakaru"
|
||||
curl --fail --location --retry 3 https://github.com/pionxzh/wakaru/releases/download/v1.13.0/wakaru-linux-x64.tar.gz -o "$RUNNER_TEMP/rea-wakaru/release.tar.gz"
|
||||
tar -xzf "$RUNNER_TEMP/rea-wakaru/release.tar.gz" -C "$RUNNER_TEMP/rea-wakaru" wakaru
|
||||
printf '%s %s\n' e8c7ca052974604197389f6ace7464e4dd24dce64e2730971521bd6606ec9c9e "$RUNNER_TEMP/rea-wakaru/wakaru" | sha256sum --check
|
||||
- name: Install isolated fixture compilers
|
||||
run: npm install --prefix "$RUNNER_TEMP/rea-fixture-compilers" --ignore-scripts --no-audit --no-fund esbuild@0.25.10 webpack@5.101.3
|
||||
- run: npm run build:cached
|
||||
- run: npm run verify:javascript:recovery
|
||||
env:
|
||||
REA_WAKARU_COMMAND: ${{ runner.temp }}/rea-wakaru/wakaru
|
||||
REA_JAVASCRIPT_FIXTURE_TOOLS: ${{ runner.temp }}/rea-fixture-compilers
|
||||
@@ -10,3 +10,6 @@
|
||||
[submodule "third_party/ghidra-nativeaot"]
|
||||
path = third_party/ghidra-nativeaot
|
||||
url = https://github.com/Washi1337/ghidra-nativeaot.git
|
||||
[submodule "third_party/wakaru"]
|
||||
path = third_party/wakaru
|
||||
url = https://github.com/pionxzh/wakaru.git
|
||||
|
||||
-116
@@ -34,92 +34,6 @@
|
||||
"no-var-requires": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"src/{contracts,domain,server}/**/*.ts",
|
||||
"src/application/**/*.ts"
|
||||
],
|
||||
"excludeFiles": ["src/application/runtime.ts"],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"group": [
|
||||
"**/ghidra/**",
|
||||
"**/hopper/**",
|
||||
"**/ida/**",
|
||||
"**/android/**",
|
||||
"**/firmware/**"
|
||||
],
|
||||
"message": "Provider imports belong behind typed ports or in production composition."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["src/{contracts,domain}/**/*.ts"],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"group": [
|
||||
"**/application/**",
|
||||
"**/server/**",
|
||||
"**/cli/**",
|
||||
"**/main/**",
|
||||
"**/cli.*",
|
||||
"**/main.*",
|
||||
"**/android/**",
|
||||
"**/artifacts/**",
|
||||
"**/browser/**",
|
||||
"**/dotnet/**",
|
||||
"**/firmware/**",
|
||||
"**/ghidra/**",
|
||||
"**/hopper/**",
|
||||
"**/ida/**",
|
||||
"**/native/**",
|
||||
"**/process/**",
|
||||
"**/reference/**",
|
||||
"**/windows/**",
|
||||
"**/composition/**"
|
||||
],
|
||||
"message": "Domain and contracts depend inward; use typed ports for application, caller and provider boundaries."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["src/application/**/*.ts"],
|
||||
"excludeFiles": ["src/application/runtime.ts"],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"group": [
|
||||
"**/ghidra/**",
|
||||
"**/hopper/**",
|
||||
"**/ida/**",
|
||||
"**/android/**",
|
||||
"**/firmware/**",
|
||||
"**/composition/**"
|
||||
],
|
||||
"message": "Application workflows use typed ports; production construction belongs in composition."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["tests/**/*.ts", "src/**/*.test.ts", "src/**/*.fixture.ts"],
|
||||
"rules": {
|
||||
@@ -134,36 +48,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["src/domain/**/*.{fixture,test}.ts"],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"group": [
|
||||
"**/application/**",
|
||||
"**/server/**",
|
||||
"**/process/**",
|
||||
"**/browser/**",
|
||||
"**/ghidra/**",
|
||||
"**/hopper/**",
|
||||
"**/ida/**",
|
||||
"**/replay/**",
|
||||
"**/cli.*",
|
||||
"**/main.*",
|
||||
"**/composition/**",
|
||||
"**/android/**",
|
||||
"**/firmware/**"
|
||||
],
|
||||
"message": "Domain tests may import only their owning domain and inward dependencies."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["src/application/**/*.test.ts"],
|
||||
"rules": {
|
||||
|
||||
@@ -93,7 +93,7 @@ Keep tool catalogs complete and self-describing; prefer capability- and session-
|
||||
|
||||
## Cursor Cloud specific instructions
|
||||
|
||||
Development requires Node.js 24.18.0 and npm 11.16.0 (`.nvmrc` and `packageManager`). The Cloud Agent image places an older `node` on `PATH` ahead of a normal install. `.cursor/cloud-agent-install.sh` installs that toolchain under `/usr/local` and prepends `/usr/local/bin` for login shells. Confirm `node -v` is `v24.18.0` before installing dependencies.
|
||||
Development requires Node.js 24.18.0 and npm 11.16.0 (`.nvmrc` and `packageManager`). The Cloud Agent image places an older `node` on `PATH` ahead of a normal install. Environment setup installs the pinned toolchain under `/usr/local` and prepends `/usr/local/bin` for login shells. Confirm `node -v` is `v24.18.0` before installing dependencies.
|
||||
|
||||
`npm ci` installs locked dependencies. `npm run build:cached` produces the CLI and MCP server. `npm run check:fast` is the pre-push typecheck and lint. Hopper, Ghidra, and IDA are optional bring-your-own providers. JavaScript analysis and the deterministic Vitest suites do not need them. `rea doctor` reports those engines as missing until they are configured.
|
||||
|
||||
|
||||
@@ -95,6 +95,40 @@ MCP setup, Hopper, Ghidra, or executing the application. For a native app, confi
|
||||
its engine first, then use `analyze` with that app's path. Run `doctor` when you
|
||||
need diagnosis; it is not a prerequisite for each analysis.
|
||||
|
||||
### Check readiness for the task at hand
|
||||
|
||||
`rea doctor` without options is an audit of the whole integration. It checks
|
||||
every detected agent registration, the installed skill, and every optional
|
||||
analysis engine, so it can report `healthy: false` while your current task works.
|
||||
Choose a readiness scope for the work you are doing:
|
||||
|
||||
| Task | Readiness check |
|
||||
| ----------------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| Static JavaScript/Electron analysis | None. Run `analyze-javascript-application` directly. |
|
||||
| Troubleshoot one analysis engine | `rea doctor --provider ghidra --json` (or `hopper`, `ida`) |
|
||||
| Check one agent's MCP registration | `rea doctor --client codex --json` (see [client IDs](docs/installation.md#supported-agents)) |
|
||||
| Check the installed skill | `rea doctor --skill --json` |
|
||||
|
||||
A scoped report has `scope.mode: "explicit"`. Only `scope_checks` determine
|
||||
`healthy` and the exit status. Everything else is listed in
|
||||
`informational_checks`; you don't need to fix it for this task. For example, a
|
||||
missing engine you are not using needs no repair. `environment_healthy` still
|
||||
summarizes the full audit. `--target` adds a target check, but without a scope
|
||||
option the report remains audit-wide.
|
||||
|
||||
When more than one installed engine supports a native target, REA does not pick
|
||||
one: opening the target fails with `code: "capability_unavailable"`,
|
||||
`details.selection_reason: "ambiguous"`, and the providers in
|
||||
`details.candidate_ids`. Choose one once: pass `--provider` on
|
||||
the CLI or `provider_id` on `open_binary`, or set `REA_ANALYSIS_PROVIDER` as a
|
||||
standing preference. An explicit selector overrides the environment variable.
|
||||
The session keeps that choice and never falls back to another engine.
|
||||
Recovery depends on `details.selection_reason`. For `ambiguous`, choose one of
|
||||
the candidates. For `provider_unavailable`, the engine you selected needs repair,
|
||||
so run `rea doctor --provider <id> --json` and follow its remediation. Neither
|
||||
reason means you have to install every engine. See
|
||||
[Choosing a deep-analysis provider](#choosing-a-deep-analysis-provider).
|
||||
|
||||
### Install the rea command
|
||||
|
||||
Install the command-line interface:
|
||||
@@ -129,7 +163,7 @@ Deep native binary analysis requires [Hopper](https://www.hopperapp.com/), [Ghid
|
||||
|
||||
Firmware region inspection and explicit extraction use caller-supplied Binwalk and Unblob on Linux. See [Firmware analysis](docs/firmware-analysis.md) for setup, provenance, resource limits and native handoff.
|
||||
|
||||
Static APK analysis uses a separately supplied headless JADX JAR and Java, with no emulator or APK execution. See [Android analysis](docs/android-analysis.md) for setup, CLI/MCP operations, coverage and public test fixtures.
|
||||
Static APK analysis uses a separately supplied headless JADX JAR and Java, with no emulator or APK execution. See [Android analysis](docs/android-analysis.md) for setup, CLI/MCP operations, coverage and public test fixtures. Authenticated IPA inventory Evidence can be projected with [Apple application analysis](docs/apple-application-analysis.md).
|
||||
|
||||
Repository main and npm 4.1.0 include experimental Windows x64 Ghidra support for native x86-64 PE applications on local NTFS, with bundled Job Object, private-DACL, and path-admission controls. Check the [release boundary](docs/installation.md#released-package-and-main) before expecting this from an older npm package. See [Windows Ghidra P0](docs/windows-ghidra-p0.md) for prerequisites and verified scope.
|
||||
|
||||
@@ -166,7 +200,7 @@ REA defaults `HOPPER_LAUNCHER_PATH` to `/Applications/Hopper Disassembler.app/Co
|
||||
|
||||
### Ghidra analysis provider
|
||||
|
||||
Already use Ghidra? REA can connect it to your agent on Linux x64 or macOS x64/arm64. It requires **Ghidra 12.1.4** and a **64-bit JDK 21**. On macOS, your Ghidra installation must also include the native decompiler for your architecture.
|
||||
Already use Ghidra? REA can connect it to your agent on Linux x64 or macOS x64/arm64. It accepts **Ghidra 12.1.x** and the **64-bit full JDK** that installation declares (`application.java.min` through `application.java.max`). Current 12.1 releases require JDK 21 or newer and set no maximum. The bridge is verified with Ghidra 12.1.4 and JDK 21. On macOS, your Ghidra installation must also include the native decompiler for your architecture.
|
||||
|
||||
Set the installation paths, then run setup:
|
||||
|
||||
@@ -350,20 +384,20 @@ See [native investigation](docs/native-investigation.md) for keyed archives, ins
|
||||
|
||||
## Tool catalog for investigation
|
||||
|
||||
| Tool family | Count | Examples |
|
||||
| ------------------------- | ----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Native inspection | 41 | functions, pseudocode, assembly, strings, symbols, calls, references, annotations, byte reads, and file offsets |
|
||||
| Investigation workflows | 14 | app overviews, function dossiers, native APIs and dispatch, batch decompilation, feature traces, call paths, call graphs, Swift and Objective-C discovery |
|
||||
| Native macOS utilities | 7 | Mach-O metadata, code signatures, plists, architectures, and Swift demangling without launching Hopper |
|
||||
| Artifact graph | 5 | directory and package inventories, compiled Interface Builder files, Apple asset catalogs, and extraction |
|
||||
| Managed PE/CLI | 7 | .NET identity, metadata, CIL instructions, native dependencies, reconstruction imports, and build comparisons |
|
||||
| Firmware | 2 | Linux firmware region inspection and explicit extraction |
|
||||
| Android APK | 5 | package and manifest declarations, class search, member inventories, method decompilation, and incoming static references |
|
||||
| Browser observation | 9 | page structure, network metadata, scripts, source maps, WebMCP discovery, screenshots, and capture comparisons |
|
||||
| Electron analysis | 5 | renderer observation, static app mapping, and static/runtime reconciliation |
|
||||
| JavaScript runtime | 2 | Node/Electron Inspector target discovery, script locations, and execution-context events |
|
||||
| Application workflows | 8 | captured website script export; cross-layer feature traces, build comparisons, historical source mapping, static return-shape comparison, and reconstruction checks |
|
||||
| Workspace and observation | 21 | sessions, evidence bundles, navigation context, process/artifact/function comparisons, and open-question tracking |
|
||||
| Tool family | Count | Examples |
|
||||
| ------------------------- | ----: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Native inspection | 41 | functions, pseudocode, assembly, strings, symbols, calls, references, annotations, byte reads, and file offsets |
|
||||
| Investigation workflows | 14 | app overviews, function dossiers, native APIs and dispatch, batch decompilation, feature traces, call paths, call graphs, Swift and Objective-C discovery |
|
||||
| Native macOS utilities | 7 | Mach-O metadata, code signatures, plists, architectures, and Swift demangling without launching Hopper |
|
||||
| Artifact graph | 5 | directory and package inventories, compiled Interface Builder files, Apple asset catalogs, and extraction |
|
||||
| Managed PE/CLI | 7 | .NET identity, metadata, CIL instructions, native dependencies, reconstruction imports, and build comparisons |
|
||||
| Firmware | 2 | Linux firmware region inspection and explicit extraction |
|
||||
| Android APK | 5 | package and manifest declarations, class search, member inventories, method decompilation, and incoming static references |
|
||||
| Browser observation | 9 | page structure, network metadata, scripts, source maps, WebMCP discovery, screenshots, and capture comparisons |
|
||||
| Electron analysis | 5 | renderer observation, static app mapping, and static/runtime reconciliation |
|
||||
| JavaScript runtime | 2 | Node/Electron Inspector target discovery, script locations, and execution-context events |
|
||||
| Application workflows | 11 | captured website script export; Android/Apple inventory projections; cross-layer feature traces, build comparisons, historical source mapping, static return-shape comparison, and reconstruction checks |
|
||||
| Workspace and observation | 21 | sessions, evidence bundles, navigation context, process/artifact/function comparisons, and open-question tracking |
|
||||
|
||||
The public interface describes what the agent is trying to learn. Providers decide how to answer. macOS utilities handle common semantic inspection without launching Hopper; Hopper handles deeper native analysis; the process harness records direct behavioral captures.
|
||||
|
||||
@@ -383,6 +417,11 @@ Static Android APK inspection is verified on Linux with headless JADX; see [Andr
|
||||
and tracing tools. Source URLs, frame or transaction references, competing
|
||||
versions, and missing-source states remain inline. See
|
||||
[captured website scripts](docs/website-script-export.md).
|
||||
Recover readable modules from selected local bundles with
|
||||
`recover_javascript_sources` / `rea recover-javascript-sources`, then pass
|
||||
the returned `analysis_input` to static analysis. This optional Linux x64
|
||||
adapter requires caller-supplied Wakaru 1.13.0; see
|
||||
[JavaScript source recovery](docs/javascript-recovery.md).
|
||||
|
||||
- **Electron and Node runtime observation:** Inspect selected Electron pages or attach to a Node/Electron V8 Inspector target. Inspector observation records script locations and execution-context events; it does not infer imports, IPC activity, or which modules executed. See [runtime observation](docs/javascript-runtime-observation.md).
|
||||
- **.NET assemblies:** Inspect metadata and CIL instructions, compare builds, and check declared native dependencies without loading or running the assembly. Imported decompiler output is labeled as analyst inference. See [managed-code analysis](docs/managed-code-analysis.md).
|
||||
@@ -742,3 +781,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and contribution ch
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
## Star history
|
||||
|
||||
<a href="https://www.star-history.com/?repos=morluto%2Frea&type=date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=morluto/rea&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=morluto/rea&type=date" />
|
||||
<img alt="REA GitHub star history" src="https://api.star-history.com/chart?repos=morluto/rea&type=date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
+3
-3
@@ -141,7 +141,7 @@ rea setup
|
||||
|
||||
يتطلب تحليل الملفات التنفيذية الأصلية Hopper أو Ghidra. Hopper برنامج منفصل، وللوضع التجريبي قيود يحددها المورّد، لكن الترخيص المدفوع ليس إلزاميًا.
|
||||
|
||||
يدعم Ghidra نظامي Linux x64 وmacOS x64/arm64. ثبت Ghidra 12.1.4 وJDK 21 الكامل بنواة 64 بت بصورة منفصلة، ثم اضبط REA لاستخدامهما. يحتاج macOS أيضًا إلى أداة فك الترجمة الأصلية المطابقة لمعمارية الجهاز.
|
||||
يدعم Ghidra نظامي Linux x64 وmacOS x64/arm64. ثبت Ghidra 12.1.x وJDK الكامل بنواة 64 بت الذي يعلنه ذلك التثبيت (`application.java.min` حتى `application.java.max`) بصورة منفصلة، ثم اضبط REA لاستخدامهما. تتطلب إصدارات 12.1 الحالية JDK 21 أو أحدث ولا تحدد حدًا أعلى. جرى التحقق من الجسر مع Ghidra 12.1.4 وJDK 21. يحتاج macOS أيضًا إلى أداة فك الترجمة الأصلية المطابقة لمعمارية الجهاز.
|
||||
|
||||
يستطيع Setup التحقق من التثبيت وحفظ المسارات؛ ولا يثبت أو يحدّث Ghidra أو Java أو Node.js أو npm أو Homebrew.
|
||||
|
||||
@@ -223,7 +223,7 @@ rea setup
|
||||
| مراقبة المتصفح | 9 | بنية الصفحات وبيانات الشبكة والبرامج النصية وخرائط المصدر واكتشاف WebMCP والصور ومقارنة الالتقاط |
|
||||
| تحليل Electron | 5 | مراقبة العارضين وخريطة التطبيق الثابتة والمواءمة بين نتائج الثبات والتشغيل |
|
||||
| وقت تشغيل JavaScript | 2 | اكتشاف أهداف Node/Electron Inspector ومواضع البرامج وأحداث سياقات التنفيذ |
|
||||
| سير عمل التطبيقات | 8 | تصدير النصوص البرمجية الملتقطة، تتبع الميزات عبر الطبقات ومقارنة الإصدارات ومواءمة المصدر التاريخي ومقارنة أشكال القيم المعادة والتحقق من إعادة الإنشاء |
|
||||
| سير عمل التطبيقات | 11 | تصدير النصوص البرمجية الملتقطة، تتبع الميزات عبر الطبقات ومقارنة الإصدارات ومواءمة المصدر التاريخي ومقارنة أشكال القيم المعادة والتحقق من إعادة الإنشاء |
|
||||
| مساحة العمل والملاحظة | 21 | الجلسات وحزم الأدلة وسياق التنقل ومقارنة العمليات والمخرجات والدوال وتسجيل الأسئلة غير المحسومة |
|
||||
|
||||
## خطة العمل
|
||||
@@ -311,7 +311,7 @@ npx -y rea-agents@latest compare /absolute/path/to/left-evidence.json /absolute/
|
||||
<details>
|
||||
<summary><strong>هل يعمل REA على Linux أو Windows؟</strong></summary>
|
||||
|
||||
يدعم REA نظام macOS 12+ وUbuntu 24.04+ وFedora 41+ وArch Linux بنواة 64 بت. يعمل تحليل Ghidra للقراءة فقط على Linux x64 وmacOS x64/arm64 مع Ghidra 12.1.4 وJDK 21 الكامل، إضافة إلى حد Windows x64 P0 التجريبي لتطبيقات PE الأصلية x86-64 على NTFS محلي؛ راجع [دليل Windows Ghidra P0](docs/windows-ghidra-p0.md) للنطاق الموثق.
|
||||
يدعم REA نظام macOS 12+ وUbuntu 24.04+ وFedora 41+ وArch Linux بنواة 64 بت. يعمل تحليل Ghidra للقراءة فقط على Linux x64 وmacOS x64/arm64 مع Ghidra 12.1.x وJDK الكامل الذي يعلنه التثبيت (JDK 21 أو أحدث دون حد أعلى في إصدارات 12.1 الحالية)، إضافة إلى حد Windows x64 P0 التجريبي لتطبيقات PE الأصلية x86-64 على NTFS محلي؛ راجع [دليل Windows Ghidra P0](docs/windows-ghidra-p0.md) للنطاق الموثق.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+2
-2
@@ -141,7 +141,7 @@ rea setup
|
||||
|
||||
ネイティブバイナリ解析には Hopper または Ghidra が必要です。Hopper は別製品です。デモにはベンダー所定の制限がありますが、有料ライセンスは必須ではありません。
|
||||
|
||||
Ghidra は Linux x64 と macOS x64/arm64 に対応します。Ghidra 12.1.4 と完全な 64 ビット JDK 21 を別途インストールし、REA で使うように設定してください。macOS ではホストのアーキテクチャに合うネイティブデコンパイラーも必要です。
|
||||
Ghidra は Linux x64 と macOS x64/arm64 に対応します。Ghidra 12.1.x と、そのインストールが宣言する完全な 64 ビット JDK(`application.java.min` から `application.java.max` まで)を別途インストールし、REA で使うように設定してください。現行の 12.1 リリースは JDK 21 以降を要求し、上限は設定しません。ブリッジは Ghidra 12.1.4 と JDK 21 で検証しています。macOS ではホストのアーキテクチャに合うネイティブデコンパイラーも必要です。
|
||||
|
||||
Setup はインストールを確認し、パスを保存できます。Ghidra、Java、Node.js、npm、Homebrew のインストールや更新は行いません。
|
||||
|
||||
@@ -212,7 +212,7 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ
|
||||
| ブラウザー観察 | 9 | ページ構成、ネットワークメタデータ、スクリプト、ソースマップ、WebMCP 検出、スクリーンショット、キャプチャ比較 |
|
||||
| Electron 解析 | 5 | レンダラー観察、静的アプリマッピング、静的/実行時結果の対応付け |
|
||||
| JavaScript 実行時 | 2 | Node/Electron Inspector 対象の検出、スクリプト位置、実行コンテキストイベント |
|
||||
| アプリワークフロー | 8 | 取得した Web スクリプトのエクスポート、層をまたぐ機能トレース、ビルド比較、履歴ソース対応、静的戻り値形状の比較、再実装検証 |
|
||||
| アプリワークフロー | 11 | 取得した Web スクリプトのエクスポート、層をまたぐ機能トレース、ビルド比較、履歴ソース対応、静的戻り値形状の比較、再実装検証 |
|
||||
| ワークスペースと観察 | 21 | セッション、証跡バンドル、ナビゲーション文脈、プロセス・成果物・関数比較、未解決事項の記録 |
|
||||
|
||||
## ロードマップ
|
||||
|
||||
+2
-2
@@ -142,7 +142,7 @@ rea setup
|
||||
|
||||
네이티브 바이너리 분석에는 Hopper 또는 Ghidra가 필요합니다. Hopper는 별도 소프트웨어입니다. 데모에는 공급업체의 제한이 있지만 유료 라이선스가 필수는 아닙니다.
|
||||
|
||||
Ghidra는 Linux x64와 macOS x64/arm64를 지원합니다. Ghidra 12.1.4와 완전한 64비트 JDK 21을 별도로 설치한 뒤 REA가 사용하도록 설정하세요. macOS에서는 호스트 아키텍처에 맞는 네이티브 디컴파일러도 필요합니다.
|
||||
Ghidra는 Linux x64와 macOS x64/arm64를 지원합니다. Ghidra 12.1.x와 그 설치본이 선언한 완전한 64비트 JDK(`application.java.min`부터 `application.java.max`까지)를 별도로 설치한 뒤 REA가 사용하도록 설정하세요. 현재 12.1 릴리스는 JDK 21 이상을 요구하고 상한은 없습니다. 브리지는 Ghidra 12.1.4와 JDK 21에서 검증됩니다. macOS에서는 호스트 아키텍처에 맞는 네이티브 디컴파일러도 필요합니다.
|
||||
|
||||
Setup은 설치를 확인하고 경로를 저장할 수 있습니다. Ghidra, Java, Node.js, npm, Homebrew를 설치하거나 업데이트하지 않습니다.
|
||||
|
||||
@@ -213,7 +213,7 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이
|
||||
| 브라우저 관찰 | 9 | 페이지 구조, 네트워크 메타데이터, 스크립트, 소스 맵, WebMCP 탐색, 스크린샷, 캡처 비교 |
|
||||
| Electron 분석 | 5 | 렌더러 관찰, 정적 앱 매핑, 정적·런타임 결과 연결 |
|
||||
| JavaScript 런타임 | 2 | Node/Electron Inspector 대상 탐색, 스크립트 위치, 실행 컨텍스트 이벤트 |
|
||||
| 앱 워크플로 | 8 | 캡처한 웹 스크립트 내보내기, 계층 간 기능 추적, 빌드 비교, 히스토리 소스 매핑, 정적 반환 구조 비교, 재구현 검증 |
|
||||
| 앱 워크플로 | 11 | 캡처한 웹 스크립트 내보내기, 계층 간 기능 추적, 빌드 비교, 히스토리 소스 매핑, 정적 반환 구조 비교, 재구현 검증 |
|
||||
| 작업 공간과 관찰 | 21 | 세션, 근거 번들, 탐색 컨텍스트, 프로세스·아티팩트·함수 비교, 미해결 항목 기록 |
|
||||
|
||||
## 로드맵
|
||||
|
||||
+2
-2
@@ -141,7 +141,7 @@ rea setup
|
||||
|
||||
原生二进制分析需要 Hopper 或 Ghidra。Hopper 是独立软件;演示模式有厂商规定的限制,不要求购买许可证。
|
||||
|
||||
Ghidra 支持 Linux x64 和 macOS x64/arm64。单独安装 Ghidra 12.1.4 和完整的 64 位 JDK 21,然后配置 REA 使用它们。macOS 还需要对应架构的原生反编译器。
|
||||
Ghidra 支持 Linux x64 和 macOS x64/arm64。单独安装 Ghidra 12.1.x,以及该安装在 `application.java.min` 到 `application.java.max` 中声明的完整 64 位 JDK,然后配置 REA 使用它们。当前 12.1 发行版要求 JDK 21 或更新版本,并且不设置上限。桥接在 Ghidra 12.1.4 和 JDK 21 上完成验证。macOS 还需要对应架构的原生反编译器。
|
||||
|
||||
Setup 可以验证安装并保存路径,不会安装或升级 Ghidra、Java、Node.js、npm 或 Homebrew。
|
||||
|
||||
@@ -212,7 +212,7 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
|
||||
| 浏览器观察 | 9 | 页面结构、网络元数据、脚本、来源映射、WebMCP 发现、截图与捕获比较 |
|
||||
| Electron 分析 | 5 | 渲染进程观察、静态应用映射、静态/运行时结果关联 |
|
||||
| JavaScript 运行时 | 2 | Node/Electron Inspector 目标发现、脚本位置与执行上下文事件 |
|
||||
| 应用工作流 | 8 | 捕获的网站脚本导出、跨层功能追踪、构建比较、历史源码映射、静态返回结构比较与重建验证 |
|
||||
| 应用工作流 | 11 | 捕获的网站脚本导出、跨层功能追踪、构建比较、历史源码映射、静态返回结构比较与重建验证 |
|
||||
| 工作区与观察 | 21 | 会话、证据包、导航上下文、进程/产物/函数比较与待解决问题跟踪 |
|
||||
|
||||
## 路线图
|
||||
|
||||
@@ -88,8 +88,24 @@ selected overload. Native/abstract methods with no decompiled body report
|
||||
These operations do not execute the APK or provide split APK/AAB handling,
|
||||
signature validation, full resource-table semantics, native-library analysis or
|
||||
Android runtime capture. Existing artifact inventory/extraction tools can supply
|
||||
archive evidence; `project_android_application_graph` remains a separate,
|
||||
execution-free inventory projection.
|
||||
archive evidence. Project that Evidence with the execution-free inventory tool:
|
||||
|
||||
```sh
|
||||
rea project-android-application-graph '{"inventory_evidence":[<inventory_artifact Evidence>]}'
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "project_android_application_graph",
|
||||
"arguments": {
|
||||
"inventory_evidence": ["<inventory_artifact Evidence>"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The projection reports exact component paths and hashes, runtime-family hints,
|
||||
and path-based bridge hypotheses. It does not decode DEX or claim observed
|
||||
runtime calls.
|
||||
|
||||
## Resource and lifecycle limits
|
||||
|
||||
|
||||
@@ -6,6 +6,19 @@ execution-free: it reports exact paths, content hashes, detected formats,
|
||||
runtime-family hints, and path-based bridge hypotheses. It does not parse plist
|
||||
or CMS semantics or claim observed runtime calls.
|
||||
|
||||
```sh
|
||||
rea project-apple-application-graph '{"inventory_evidence":[<inventory_artifact Evidence>]}'
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "project_apple_application_graph",
|
||||
"arguments": {
|
||||
"inventory_evidence": ["<inventory_artifact Evidence>"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The result includes every component in each inventory category and every
|
||||
JavaScript-to-native bridge candidate pair. It has no caller-selected component
|
||||
budget, prefix truncation, or omitted-count fields. Duplicate inventory pages
|
||||
|
||||
@@ -16,6 +16,8 @@ graph TD
|
||||
APP["src/application/<br/>Shared CLI/MCP workflows<br/>enhanced analysis and investigations"]
|
||||
SESSION["SessionProviderRouter + BinarySession<br/>one immutable deep binding per target<br/>disjoint auxiliary routing"]
|
||||
REGISTRY["AnalysisProviderRegistry<br/>sorted deep candidates<br/>deterministic selection, no fallback"]
|
||||
RECORDS["src/application/investigation/<br/>composed InvestigationRecords + narrow ports<br/>EvidenceLedger and Unknown ownership"]
|
||||
SNAPSHOT["BinarySessionRecords + AnalysisSnapshotCache<br/>binary target/profile snapshots<br/>compatibility facade and post-commit notifications"]
|
||||
JS_RECON["src/application/ + src/artifacts/<br/>JavaScript artifact reconstruction<br/>directory/ASAR reads<br/>AST-only bundle projection"]
|
||||
end
|
||||
|
||||
@@ -75,6 +77,11 @@ graph TD
|
||||
CLI --> ERRORS
|
||||
APP --> SESSION
|
||||
SESSION --> REGISTRY
|
||||
SESSION --> SNAPSHOT
|
||||
SNAPSHOT --> RECORDS
|
||||
APP -->|record callbacks| RECORDS
|
||||
SERVER -->|narrow record ports| RECORDS
|
||||
RECORDS --> DOMAIN
|
||||
REGISTRY --> HOPPER
|
||||
REGISTRY --> GHIDRA
|
||||
REGISTRY --> IDA
|
||||
|
||||
@@ -22,6 +22,17 @@ REA can attach to a user-owned Chrome-family browser through the Chrome DevTools
|
||||
Target discovery returns the complete in-scope target array in one result.
|
||||
|
||||
Electron `file://` pages use a separate provider and target boundary; see [electron-observation.md](electron-observation.md).
|
||||
|
||||
## Provider authorities
|
||||
|
||||
CDP page capture, V8 Inspector observation, and Playwright scenarios stay
|
||||
separate providers because their authorities differ: CDP and V8 attach to an
|
||||
already-running target and never launch, drive, evaluate, or mutate it, while
|
||||
Playwright providers own and drive the runtime (launch browsers, run actions,
|
||||
capture step snapshots). Merging them would mix attach-only and owned-process
|
||||
lifecycles in one contract. What they share instead is one exclusion
|
||||
vocabulary (`BrowserExclusionReason`): every denial reason in every stack maps
|
||||
onto it, even where a wire schema keeps a narrower historical bucket.
|
||||
Existing static application Evidence and passive web/Electron captures can be
|
||||
combined later through
|
||||
[JavaScript static/runtime reconciliation](javascript-runtime-reconciliation.md).
|
||||
|
||||
@@ -13,7 +13,7 @@ there is no new universal provider interface or fixed module manifest.
|
||||
| Producer parsing and external tool protocols | provider adapters | Keep behind the relevant typed port |
|
||||
| Shared analyst workflows | application | Share between CLI and MCP |
|
||||
| Concrete provider construction | binary runtime, composition, MCP startup | Consolidate one capability at a time in production composition |
|
||||
| Investigation Evidence and Unknowns | BinarySessionRecords | Extract one composed record owner after caller migration |
|
||||
| Investigation Evidence and Unknowns | InvestigationRecords/EvidenceLedger | Consume narrow read/write/atomic record ports |
|
||||
| Target/profile snapshots and invalidation | BinarySessionRecords/BinarySession | Keep binary-owned |
|
||||
| Subprocess ownership and host primitives | process/windows | Reuse; preserve cancellation and cleanup |
|
||||
| Public translation | CLI/MCP adapters | Delegate to shared workflows and named contracts |
|
||||
@@ -60,6 +60,21 @@ provider implementations. Production composition is a deliberate outer boundary.
|
||||
Other existing adapter imports will migrate incrementally; do not widen a lint
|
||||
exception to conceal new protocol code inside application workflows.
|
||||
|
||||
`npm run verify:module-boundaries` parses repository-owned source imports,
|
||||
reexports, type imports and literal dynamic imports, then checks their resolved
|
||||
source layer. Nested names such as `domain/android` are domain code; `src/android`
|
||||
is the provider adapter. The check runs with lint, including cached static gates.
|
||||
It covers tracked and untracked non-ignored TypeScript source and skips cached
|
||||
paths removed during a move. Pass exact source paths for a focused check.
|
||||
|
||||
Pure source and its tests keep inward dependencies. Application/server provider
|
||||
construction guards cover the migrated deep, Android, firmware, JavaScript recovery and observation
|
||||
implementations. The existing binary runtime is an exact temporary composition
|
||||
exception; shared browser capture/export helpers remain admitted while their
|
||||
ownership is reviewed. Test lane restrictions still use Oxlint. Producer-dependent
|
||||
browser and managed comparison tests live in boundary lanes with their original
|
||||
fixtures and assertions. This is a development guard, not runtime authorization.
|
||||
|
||||
Select verification by the claim changed: schema/composition, package, real
|
||||
engine or host-native workflow. Record host/target prerequisites and source-owned
|
||||
fixtures. Run heavy lanes serially with bounded workers/heaps and reuse existing
|
||||
@@ -93,3 +108,84 @@ providers. Composition may import their implementations. Binary composition is
|
||||
still at its existing entrypoint while its direct-analysis callers are investigated.
|
||||
This concrete pilot does not introduce a shared provider interface or module
|
||||
manifest.
|
||||
|
||||
## Independent optional observation adapters
|
||||
|
||||
Browser observation, browser scenarios, Electron observation, Electron scenarios
|
||||
and V8 Inspector observation now have separate factories in `src/composition/`.
|
||||
CLI commands reuse them; MCP startup dynamically imports each factory independently.
|
||||
`OptionalObservationProviders` describes these five existing typed ports only. It
|
||||
is provisional startup wiring, not an extensible plugin manifest or a universal
|
||||
provider interface.
|
||||
|
||||
A failed import or constructor retains the successful peers. The complete tool
|
||||
catalog remains advertised. Session availability reports the failed adapter and
|
||||
its actual reason, and affected handlers return the existing capability-unavailable
|
||||
error. Explicit loading failures take precedence over configured availability.
|
||||
Passive Electron and active Electron failures are independent; static JavaScript
|
||||
analysis and runtime reconciliation remain usable without either adapter. CLI
|
||||
Electron commands also load only their selected runtime factory at execution.
|
||||
|
||||
Source identities are available without importing the optional implementations.
|
||||
Registration acquires no engines, probes no endpoints, changes no permissions and
|
||||
installs nothing. Core transport failures and shutdown still use their existing
|
||||
lifecycle. Fault-injection coverage uses the production loading/startup path and
|
||||
actual MCP SDK calls; recording ports establish composition behavior, while the
|
||||
real Inspector lane separately establishes runtime behavior.
|
||||
|
||||
## Investigation record ownership pilot
|
||||
|
||||
`src/application/investigation/InvestigationRecords.ts` owns one existing
|
||||
EvidenceLedger per runtime. The production session composition constructs that
|
||||
owner explicitly; the direct BinarySession constructor retains a fresh default
|
||||
for compatibility. There is no second ledger, global store, persistence or new
|
||||
retention policy.
|
||||
|
||||
The ledger, Unknown Evidence helper and the ledger's two original behavior tests
|
||||
now live beside the owner in `src/application/investigation/`. Bundle validation
|
||||
and record-owner consumers follow the new paths; retained-reference access still
|
||||
uses the narrow record ports. The move preserves implementation bodies and test
|
||||
assertions. Binary snapshot/cache files remain with their existing owner.
|
||||
|
||||
EvidenceReader, EvidenceWriter and EvidenceUnknownWriter describe existing
|
||||
read/write/atomic callback needs. UnknownRegistryPort preserves optimistic
|
||||
revisions and consistency verification. Non-binary MCP registrars use these
|
||||
record types without depending on the binary execution/lifecycle surface.
|
||||
BinarySessionPort retains its compatible record methods through composition.
|
||||
|
||||
BinarySessionRecords remains the snapshot owner and compatibility facade. It
|
||||
passes the actual active target explicitly for recordUnknown/updateUnknown;
|
||||
recordEvidenceWithUnknown keeps its target-free mutation subject. Snapshot import
|
||||
merges records without notifying midway, then emits after the cache commits.
|
||||
Bundle import retains the full change delta so Unknown-only changes notify even
|
||||
when recordsAdded is zero. Clearing resets records, cache and invalidation before
|
||||
notification; observer failures remain best effort after a committed mutation.
|
||||
|
||||
Close and failed-cleanup clearing, ordinary target-switch retention, detached
|
||||
reads, Evidence identities and deterministic bundle transfer keep their existing
|
||||
behavior. Snapshots still require an active target and concrete analysis profile;
|
||||
extracting investigation ownership does not make them a target-free record store.
|
||||
#721 remains responsible for any future lifetime-policy change.
|
||||
|
||||
## Auxiliary source declaration pilot
|
||||
|
||||
Artifact, macOS native and managed static metadata now belongs to each adapter's
|
||||
`*ProviderMetadata.ts` module. The implementation and
|
||||
`src/composition/auxiliaryAnalysisProviders.ts` reuse the same identity and exact
|
||||
capability descriptors. The declarations reference existing AnalysisProvider
|
||||
loaders; they cover these three disjoint auxiliary implementations only. Deep
|
||||
candidate selection and the five typed observation ports remain separate.
|
||||
|
||||
The binary runtime reads these source declarations while auxiliary construction
|
||||
stays lazy. The separate managed-only entrypoint retains direct construction for
|
||||
its execution-free workflow. The MCP catalog generator reads declarations without
|
||||
constructing implementations, using its canonical macOS platform; actual runtime
|
||||
host restrictions remain explicit.
|
||||
Discovery and binary operation-kind routing now consume canonical source contracts
|
||||
rather than their generated JSON artifact. No declaration imports its generated
|
||||
output. Generated catalog facts and public schemas stay unchanged.
|
||||
|
||||
All observation registrars resolve contracts by name. An isolated compiled-process
|
||||
test reverses presentation arrays and performs actual MCP SDK calls to verify
|
||||
advertised schemas and handler meaning. Reordering metadata must not select a
|
||||
different operation, schema, provider or deep-provider priority.
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
# Firmware analysis
|
||||
|
||||
REA integrates **Binwalk 3.1.0** for region inspection and **Unblob 26.6.4** for
|
||||
explicit extraction. This first firmware milestone is verified on Linux x64.
|
||||
REA accepts **Binwalk 3.1.x** for region inspection and **Unblob 26.6.x** for
|
||||
explicit extraction. The report parsers are verified with Binwalk 3.1.0 and
|
||||
Unblob 26.6.4. Another build on those lines is accepted, the observed version
|
||||
is reported, and a limitation records that the parser was not verified against
|
||||
that build. This first firmware milestone is verified on Linux x64.
|
||||
No device, root privilege, mount, emulation or execution of extracted programs
|
||||
is needed. Arbitrary firmware bytes do not pass native executable admission;
|
||||
select a returned ELF separately for supported Ghidra analysis.
|
||||
@@ -16,7 +19,8 @@ export REA_UNBLOB_COMMAND=/absolute/path/to/unblob
|
||||
```
|
||||
|
||||
REA does not install these tools, their dependencies or system packages. It
|
||||
checks the actual tool version when executing a request. Inspection and
|
||||
checks the tool's `--version` banner when executing a request. Binwalk must
|
||||
print `binwalk <version>` and Unblob must print the version alone. Inspection and
|
||||
extraction have independent prerequisites; one does not require the other.
|
||||
Both require util-linux `prlimit`, normally `/usr/bin/prlimit`; override its
|
||||
absolute path with `REA_FIRMWARE_PRLIMIT_COMMAND` if needed.
|
||||
@@ -161,7 +165,7 @@ Additional lanes are explicit, so the base lane stays small:
|
||||
REA_FIRMWARE_VERIFY_EXT4=1 npm run fixtures:firmware
|
||||
REA_FIRMWARE_VERIFY_EXT4=1 npm run verify:firmware
|
||||
|
||||
# Existing Ghidra 12.1.4 and Java required.
|
||||
# Existing Ghidra 12.1.x and its declared JDK required.
|
||||
REA_FIRMWARE_VERIFY_GHIDRA=1 npm run verify:firmware
|
||||
```
|
||||
|
||||
|
||||
+3
-3
@@ -2,9 +2,9 @@
|
||||
|
||||
REA can analyze DOS MZ executables and explicitly selected COM images through the bring-your-own Ghidra adapter.
|
||||
The Linux x64 verification lane exercises actual 16-bit disassembly and
|
||||
decompilation through the CLI and stdio MCP. It requires the same Ghidra
|
||||
12.1.4 and 64-bit JDK 21 installation as other Ghidra sessions; no DOS emulator
|
||||
or cross-compiler is required.
|
||||
decompilation through the CLI and stdio MCP. Provider admission accepts Ghidra
|
||||
12.1.x and that installation's declared JDK range. The verification lane uses
|
||||
Ghidra 12.1.4 and JDK 21; no DOS emulator or cross-compiler is required.
|
||||
|
||||
## Open and inspect
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ source notices are retained. No upstream source or algorithms are rewritten.
|
||||
|
||||
The initial tested layout is .NET runtime **8.0.22**, NativeAOT RTR **9.1**,
|
||||
x86-64 ELF and native Windows PE **targets analyzed on Linux x64**, using
|
||||
Ghidra **12.1.4** and JDK **21**. Other layouts/architectures/hosts are unsupported.
|
||||
Ghidra **12.1.4** and JDK **21**. The build accepts any Ghidra 12.1.x release and
|
||||
javac 21 or newer, and it compiles with `--release 21`. Real verification of
|
||||
this layout remains the Ghidra 12.1.4 and JDK 21 pair. Other NativeAOT layouts,
|
||||
architectures, and hosts are unsupported.
|
||||
Windows Ghidra P0 has no database mutation authority. A PE/CLI or ReadyToRun
|
||||
assembly should use `inspect_managed_artifact`; it is not NativeAOT.
|
||||
|
||||
|
||||
+15
-5
@@ -108,7 +108,9 @@ npx -y rea-agents@latest analyze-javascript-application /absolute/path/to/app --
|
||||
```
|
||||
|
||||
It returns the complete Evidence record directly and requires no native engine.
|
||||
Provider failures in doctor do not prevent unrelated target-free tools.
|
||||
Provider failures in doctor do not prevent unrelated target-free tools. To check
|
||||
readiness for one task instead of auditing every integration, see
|
||||
[Check readiness for the task at hand](../README.md#check-readiness-for-the-task-at-hand).
|
||||
|
||||
## Supported agents
|
||||
|
||||
@@ -129,6 +131,11 @@ Setup can configure these clients for REA's local MCP server:
|
||||
| Command Code | `commandcode` |
|
||||
| VS Code | `vscode` |
|
||||
|
||||
For OpenCode, setup writes the V1 `mcp.rea` entry, which OpenCode V1 and V2
|
||||
both load. If the configuration already uses OpenCode V2's native
|
||||
`mcp.servers` table, setup registers REA there instead and replaces any earlier
|
||||
`mcp.rea` entry from REA.
|
||||
|
||||
## Review setup changes
|
||||
|
||||
`rea setup` first offers the supported agents in a multi-select. Existing REA
|
||||
@@ -264,7 +271,10 @@ copying license secrets into logs, or killing unrelated Hopper processes.
|
||||
|
||||
REA connects to an existing Ghidra installation on Linux x64, macOS x64/arm64,
|
||||
or experimental Windows x64 P0.
|
||||
It requires Ghidra 12.1.4 and a 64-bit full JDK 21. On macOS, the installation
|
||||
It accepts Ghidra 12.1.x and the 64-bit full JDK declared by that installation's
|
||||
`application.java.min` and `application.java.max`. Current 12.1 releases require
|
||||
JDK 21 or newer and set no maximum. The bridge is verified with Ghidra 12.1.4
|
||||
and JDK 21. On macOS, the installation
|
||||
must include the native decompiler for the host architecture; REA does not
|
||||
build it or change Gatekeeper quarantine settings.
|
||||
|
||||
@@ -310,9 +320,9 @@ installation remains unavailable on Windows. Setup never installs Ghidra,
|
||||
Java, or Python. It preserves valid detected Ghidra/JDK settings in agent
|
||||
registrations. See [Windows Ghidra P0](windows-ghidra-p0.md) for provider diagnostics.
|
||||
|
||||
Doctor validates the platform, architecture, application version,
|
||||
`support/analyzeHeadless` or `support/analyzeHeadless.bat`, Java
|
||||
version/bitness, and the presence of `javac`/`javac.exe`.
|
||||
Doctor validates the platform, architecture, Ghidra 12.1.x application version,
|
||||
`support/analyzeHeadless` or `support/analyzeHeadless.bat`, the installation's
|
||||
Java major range, 64-bit JDK bitness, and the presence of `javac`/`javac.exe`.
|
||||
When Java is found through `PATH`, setup records its observed JDK home so GUI
|
||||
MCP clients do not depend on an incidental shell path. Setup shows every exact
|
||||
environment entry in its plan, writes only after approval, and never downloads,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# JavaScript source recovery
|
||||
|
||||
`recover_javascript_sources` / `recover-javascript-sources` derives readable
|
||||
modules from one explicitly selected UTF-8 JavaScript file or bundle. Use it on
|
||||
files returned by `export_web_scripts` or other local scripts. The operation
|
||||
never executes the selected or recovered application code.
|
||||
|
||||
## Caller-supplied engine
|
||||
|
||||
The initial adapter is verified on Linux x64 with Wakaru **1.13.0**. Set
|
||||
`REA_WAKARU_COMMAND` to the absolute path of an executable supplied by the caller.
|
||||
The adapter uses util-linux `prlimit` (default `/usr/bin/prlimit`; override with
|
||||
an absolute `REA_JAVASCRIPT_PRLIMIT_COMMAND`). REA does not install either tool.
|
||||
Engine configuration is checked only when recovery is requested; other REA
|
||||
capabilities remain available without it.
|
||||
|
||||
Unmodified upstream source is retained in `third_party/wakaru`, pinned to
|
||||
`6070266d24b32951fa2fe1d2dad5b4313bd5c81b` (v1.13.0, Apache-2.0).
|
||||
The result reports the executed binary's exact path, SHA-256 and observed version.
|
||||
The audited source revision is distinct from `executed_source_revision: null`:
|
||||
a version string cannot prove which source produced a caller-supplied binary.
|
||||
|
||||
## Recover and analyze
|
||||
|
||||
```sh
|
||||
rea recover-javascript-sources /tmp/web-scripts/app.js /tmp/recovered-app --json
|
||||
rea analyze-javascript-application /tmp/recovered-app/modules --json
|
||||
```
|
||||
|
||||
MCP input:
|
||||
|
||||
```json
|
||||
{
|
||||
"path": "/tmp/web-scripts/app.js",
|
||||
"output_directory": "/tmp/recovered-app"
|
||||
}
|
||||
```
|
||||
|
||||
The output directory must be absent, absolute, and have an existing parent.
|
||||
The source is snapshotted in a private workspace, and the engine writes only to
|
||||
its own staging directory. Reported module paths, input identities, UTF-8 byte
|
||||
ranges, regular files and emitted map representations are validated before
|
||||
publication. REA writes verified bytes to an exclusively owned output tree and
|
||||
rolls it back on failure. It also checks that the original source and engine
|
||||
bytes remain unchanged before committing. The existing output is never overwritten.
|
||||
|
||||
Published contents:
|
||||
|
||||
- `inputs/bundle.js`: exact original snapshot; its digest binds the Evidence.
|
||||
- `modules/`: derived modules and any upstream-emitted source maps.
|
||||
- `reports/stdout.json` and `reports/provenance.json`: exact producer reports.
|
||||
- `manifest.json`: identities, modules, transform choices, warnings and limitations.
|
||||
|
||||
The inline result includes complete file paths/digests/sizes, extraction ranges,
|
||||
warnings and `analysis_input`, directly usable as the arguments to
|
||||
`analyze_javascript_application`. Raw Evidence retains selected command arguments,
|
||||
reported input paths, exit status, stderr and reports; temporary paths are recorded
|
||||
as historical execution coordinates after their workspace is removed.
|
||||
|
||||
## Transform choices and interpretation
|
||||
|
||||
`extraction_mode` defaults to `structural` (Wakaru `--unpack=strict`). `heuristic`
|
||||
uses `--unpack=auto`; `inspection` uses `--unpack=inspect` and can return finer,
|
||||
non-executable regions. `rewrite_level` accepts `minimal`, `standard` (default)
|
||||
and `aggressive`. Maps and provenance are requested in every operation.
|
||||
|
||||
Structural extraction can fall back to a single file. In the real fixtures,
|
||||
webpack 5 yields two modules; the esbuild scope-hoisted fixture remains one
|
||||
unknown-format script. Returned module counts and detected formats report those
|
||||
actual outcomes. A valid report with failed transformations returns `partial`
|
||||
with original warnings and files. An unusable report, inconsistent provenance,
|
||||
escaping path, symlink or missing module is an error, with owned cleanup.
|
||||
|
||||
Evidence confidence is `derived`. Extraction ranges are half-open **UTF-8 byte
|
||||
offsets in the original snapshot**, rather than rewritten line positions or a
|
||||
coverage assertion. Source maps are preserved without claiming independently
|
||||
verified mapping accuracy. Original variable names and arbitrary runtime
|
||||
equivalence remain unknown. No network fetching occurs.
|
||||
|
||||
## Resource policy and verification
|
||||
|
||||
Each operation uses one worker, a 1 GiB address-space ceiling and a 120-second
|
||||
deadline; it accepts up to 64 MiB of input and 128 MiB/10000 entries of staged
|
||||
output, with 8 MiB per diagnostic/report stream. Exceeding a limit is an explicit
|
||||
failure with rollback. These limits apply per operation and do not claim aggregate
|
||||
host containment across concurrent callers.
|
||||
|
||||
See [testing.md](testing.md#javascript-source-recovery) for the focused real
|
||||
Wakaru CLI/MCP lane and its source-owned compiler fixtures. Other host platforms
|
||||
and Bun/Vue recovery are not claimed by this adapter.
|
||||
@@ -150,7 +150,7 @@ actions may change application data or trigger network activity.
|
||||
|
||||
## Provider and verification boundaries
|
||||
|
||||
Install Ghidra 12.1.4 and a full 64-bit JDK 21 separately, then configure REA to
|
||||
Install a Ghidra 12.1.x release and the 64-bit full JDK it declares, then configure REA to
|
||||
use them. Ghidra analysis supports Linux x64 and macOS x64/arm64; macOS requires
|
||||
the matching native decompiler. Experimental Windows x64 P0 admits native
|
||||
x86-64 PE applications on local NTFS using bundled Job Object ownership,
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"skill_version": "26"
|
||||
},
|
||||
"tools": {
|
||||
"total": 126,
|
||||
"total": 129,
|
||||
"families": [
|
||||
{
|
||||
"id": "direct",
|
||||
@@ -179,7 +179,7 @@
|
||||
{
|
||||
"id": "application",
|
||||
"surface": "application-workflow",
|
||||
"count": 8,
|
||||
"count": 11,
|
||||
"tools": [
|
||||
"build_reconstruction_obligation_ledger",
|
||||
"compare_application_versions",
|
||||
@@ -187,6 +187,9 @@
|
||||
"compare_source_to_bundle",
|
||||
"evaluate_reconstruction_coverage",
|
||||
"export_web_scripts",
|
||||
"project_android_application_graph",
|
||||
"project_apple_application_graph",
|
||||
"recover_javascript_sources",
|
||||
"trace_application_feature",
|
||||
"trace_javascript_semantics"
|
||||
]
|
||||
@@ -350,6 +353,18 @@
|
||||
"observe_native_ui"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "rea-android-application",
|
||||
"name": "REA Android application workflows",
|
||||
"version": "1",
|
||||
"capabilities": ["project_android_application_graph"]
|
||||
},
|
||||
{
|
||||
"id": "rea-apple-application",
|
||||
"name": "REA Apple application workflows",
|
||||
"version": "1",
|
||||
"capabilities": ["project_apple_application_graph"]
|
||||
},
|
||||
{
|
||||
"id": "rea-artifact-graph",
|
||||
"name": "REA safe artifact graph provider",
|
||||
@@ -462,6 +477,12 @@
|
||||
"name": "Unblob",
|
||||
"version": "26.6.4",
|
||||
"capabilities": ["extract_firmware"]
|
||||
},
|
||||
{
|
||||
"id": "wakaru",
|
||||
"name": "Wakaru",
|
||||
"version": "1.13.0",
|
||||
"capabilities": ["recover_javascript_sources"]
|
||||
}
|
||||
],
|
||||
"setup_clients": [
|
||||
@@ -539,7 +560,7 @@
|
||||
}
|
||||
],
|
||||
"cli": {
|
||||
"primary_count": 81,
|
||||
"primary_count": 84,
|
||||
"commands": [
|
||||
"address-to-file-offset",
|
||||
"analyze",
|
||||
@@ -604,10 +625,13 @@
|
||||
"observe-javascript-runtime",
|
||||
"observe-native-ui",
|
||||
"observe-web-session",
|
||||
"project-android-application-graph",
|
||||
"project-apple-application-graph",
|
||||
"project-managed-application-graph",
|
||||
"providers",
|
||||
"read-bytes",
|
||||
"reconcile-javascript-runtime",
|
||||
"recover-javascript-sources",
|
||||
"resolve-native-call-targets",
|
||||
"search",
|
||||
"search-android-classes",
|
||||
@@ -632,15 +656,15 @@
|
||||
},
|
||||
"runtime_catalog": {
|
||||
"counts": {
|
||||
"cli_commands": 81,
|
||||
"mcp_tools": 126,
|
||||
"cli_commands": 84,
|
||||
"mcp_tools": 129,
|
||||
"mcp_prompts": 6
|
||||
},
|
||||
"digests": {
|
||||
"tools_sha256": "569db1182f19869a3bdb22cf217bc75526f317b59635c8866d0e62741077cd52",
|
||||
"tools_sha256": "53ac19960ec7b9907a63951a4e4d8eff02a6c41ad04a4611d61370b8511cc783",
|
||||
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
|
||||
"combined_sha256": "e21455dd7d64af94e0b07aff55ab82f03a37e46729ece51be38b50083d2d5b55",
|
||||
"providers_sha256": "7949ddcf2eb5ba41b022bb3dfa1951ab96dbf5640285fa7fd5d35b0e4a5bdab3"
|
||||
"combined_sha256": "856ce0f99cc6e858f3317e40cec27c2cfd479ae7ff6a8d9cf695504d6166b64d",
|
||||
"providers_sha256": "6e1de7ee30b9b1ff633abfa4fd2b3848bb58e6e76a2ed196aa01836a202e1941"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
This guide describes repository main. Check the [released package boundary](installation.md#released-package-and-main) when using npm.
|
||||
|
||||
Ghidra read-only analysis is available on Linux x64 and macOS x64/arm64.
|
||||
Install Ghidra 12.1.4 and a full 64-bit JDK 21 separately, then configure REA to
|
||||
use them. macOS installations need the matching native decompiler.
|
||||
Install a Ghidra 12.1.x release and the 64-bit full JDK that release declares,
|
||||
then configure REA to use them. Current 12.1 releases require JDK 21 or newer
|
||||
and set no maximum. The bridge is verified with Ghidra 12.1.4 and JDK 21.
|
||||
macOS installations need the matching native decompiler.
|
||||
|
||||
REA imports one target into a temporary project and exposes 25 read-only
|
||||
operations after analysis completes. They cover inventories, search,
|
||||
@@ -53,9 +55,11 @@ follow.
|
||||
|
||||
## Shipped foundation boundary
|
||||
|
||||
`GHIDRA_INSTALL_DIR` must identify an extracted official 12.1.4 release;
|
||||
optional `JAVA_HOME` must identify a 64-bit full JDK 21, otherwise doctor probes
|
||||
`java`/`javac` or `java.exe`/`javac.exe` from `PATH`. Supported Linux and macOS
|
||||
`GHIDRA_INSTALL_DIR` must identify an extracted Ghidra 12.1.x release. Optional
|
||||
`JAVA_HOME` must identify a 64-bit full JDK inside that installation's
|
||||
`application.java.min` and `application.java.max` (JDK 21 or newer, with no
|
||||
maximum, when the release leaves those at the 12.1 defaults). Otherwise doctor
|
||||
probes `java`/`javac` or `java.exe`/`javac.exe` from `PATH`. Supported Linux and macOS
|
||||
hosts accept compatible ELF, PE, and Mach-O executable targets. Host admission
|
||||
and target compatibility are separate checks. Windows x64 P0 admits only native,
|
||||
non-managed, non-DLL x86-64 PE applications on fixed local NTFS, with the packaged
|
||||
|
||||
+4
-2
@@ -45,8 +45,10 @@ verified paths for an existing Ghidra installation. It configures Claude Code,
|
||||
Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf, Devin, OpenCode, Antigravity,
|
||||
GitHub Copilot CLI, Command Code, and VS Code using each client's configuration format.
|
||||
|
||||
Ghidra analysis supports Linux x64 and macOS x64/arm64 with Ghidra 12.1.4 and a
|
||||
64-bit full JDK 21. macOS also requires the matching native decompiler. The
|
||||
Ghidra analysis supports Linux x64 and macOS x64/arm64 with Ghidra 12.1.x and the
|
||||
64-bit full JDK that installation declares. Current 12.1 releases require JDK 21
|
||||
or newer and set no maximum; the bridge is verified with Ghidra 12.1.4 and JDK 21.
|
||||
macOS also requires the matching native decompiler. The
|
||||
adapter exposes thirteen inventory operations and twelve function-analysis
|
||||
operations, for 25 read-only operations total. Linux and macOS additionally support
|
||||
atomic function-name and entry-comment edits with refreshed analysis; metadata
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
# Test suite audit
|
||||
|
||||
## Scope and priority
|
||||
|
||||
The expanded audit on 2026-10-04 reviewed every one of the 355 Vitest files
|
||||
present at its start: 82 domain/contracts, 56 application/composition, 147
|
||||
adapter/configuration/process/filesystem/CLI files, and 70 MCP/acceptance/
|
||||
conformance/evaluation files. Three review passes checked the proposed removals
|
||||
against existing stronger coverage and checked the final changes for lost
|
||||
assertions. The remaining suite has 351 files.
|
||||
|
||||
Prefer full real-provider E2E, then integration across schemas and transports,
|
||||
then real-data goldens. Remove lower-level duplication once the replacement
|
||||
asserts the same behavior. Keep distinct failure regressions that a successful
|
||||
E2E cannot exercise. Do not lower coverage thresholds to accommodate pruning.
|
||||
|
||||
## Changes made
|
||||
|
||||
| Previous test or assertion | Decision | Replacement or retained claim |
|
||||
| ----------------------------------------------------------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Asset application tests using fabricated `.car` bytes and injected utility JSON | Remove two plumbing cases | `verify:asset-catalog`: real actool → assetutil → CLI and stdio MCP, including digest, full metadata, pagination and invalid input |
|
||||
| Keyed archive positive binary/XML objects assembled with the same plist library as the reader | Remove two synthetic success cases | Captured Foundation XML plus reviewed complete object/reference graph golden; real Foundation binary/XML → CLI and stdio MCP |
|
||||
| Generic contract schema/annotation/example loops and output-map inventory comparisons | Consolidate into one SDK wire test | Assert exported schemas, guidance, effect annotations and valid examples on the actual advertised MCP catalog |
|
||||
| Ghidra function operation enum membership | Remove | Canonical catalog, provider handshake and actual operation roundtrips establish admission; malformed provider results remain covered |
|
||||
| Asset page assertion pinning `10_001` | Remove | Invalid-page rejection through real MCP; malformed utility data still covered |
|
||||
| Suggested prompt tool positions | Remove incidental ordering assertions | Keep authority, evidence, untrusted input and preparation constraints |
|
||||
| Package “acceptance” assertions pinning CI matrix/shard values and fake launcher command arrays | Remove configuration-shaped cases | Actual package/install verifier remains; retain a compact release verification-before-publish and registry-canary static guard |
|
||||
| Package failure classifier under acceptance | Move to package evaluation | Preserve all malformed/unrelated-failure rejection cases; do not call them E2E |
|
||||
| Native value graph checks conditional on success | Strengthen | Require success before pagination/ambiguity assertions; unexpected errors can no longer pass |
|
||||
| Native UI verifier treating permission denial as a positive pass | Separate outcomes | Default lane requires real capture/action success; opt-in permissions lane explicitly reports no positive E2E proof |
|
||||
|
||||
Ten previous test cases were removed or consolidated; three replacement
|
||||
Vitest cases cover the captured goldens and release guard. The two real artifact
|
||||
E2E scripts add product-level evidence outside the deterministic Vitest count.
|
||||
|
||||
## Exhaustive follow-up
|
||||
|
||||
The follow-up removes or consolidates another 31 test declarations, including
|
||||
three redundant test files. Parameterized declarations and executed cases have
|
||||
different counts; the figures above count source declarations. It also repairs
|
||||
assertions that could pass without exercising their stated behavior.
|
||||
|
||||
| Area | Cleanup | Stronger retained evidence |
|
||||
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| Configuration | Merge duplicate target/loader tests and preserve custom launcher, explicit provider and actionable error diagnostics | Independent malformed inputs; each capability flag is rejected separately |
|
||||
| Browser scenarios | Remove fake launch success and expression-selected artifact success | Real Chromium scenarios, actual artifacts, CLI capture and owned/external cleanup in `verify:browser` |
|
||||
| CLI policy | Remove duplicate confirmation helper cases | Compiled CLI proves project files stay unchanged until `--yes`; distinct interactive choices remain |
|
||||
| Provider capabilities | Remove numerical catalog freezes; consolidate Hopper byte-read discovery | Exact canonical capability identities, admission, mutation effects and detached descriptors |
|
||||
| Evidence ledger | Merge reordered-key checks into serialized bundle import; remove one-record quota claims | Atomic import, conflicts, residual revisions, actual large collections and byte regressions |
|
||||
| MCP batching and concurrency | Replace maximum concurrency and empty indistinguishable responses | Every distinct ordered batch result; concurrently gated requests complete in reverse order with matching payloads |
|
||||
| MCP inventories and graphs | Remove traversal/inventory call order and redundant catalog/prose checks | Exact complete cyclic graph, full inline inventories, canonical SDK catalog and all-handler roundtrips |
|
||||
| Authority and lifecycle | Attempt the refused Ghidra mutation; require successful setup, call and switch; close twice | Typed refusal with zero mutation dispatch, restored target calls and exact owned-resource cleanup |
|
||||
| Process and provider output | Remove incidental environment read and command option order/defaults | PID/ownership validation, exact disabled-policy failure and complete decoded response above 10 MiB |
|
||||
| Conformance and CI guards | Merge identical replay runs; remove duplicate missing-row fault, trivial hash/self equality and incidental worker/step counts | Distinct faulty evidence, digest order, actual test discovery, file existence and release/runner authority |
|
||||
| Pure schemas | Remove fabricated comparison-dimension injection and impossible long boundary success | Real artifact comparison and SDK coverage evaluation with returned boundary identity |
|
||||
|
||||
The removed long boundary selector test only parsed a 1,000-character request;
|
||||
workspace boundary identities admit at most 200 characters, so that positive
|
||||
could never represent a successful evaluation. The actual MCP evaluation stays
|
||||
covered. One unused ledger serialization helper was also retired after removing
|
||||
its only test consumer; no CLI or MCP contract changed.
|
||||
|
||||
No further immediately safe pruning was identified in the final review. Retained
|
||||
replacement candidates below are coverage gaps, rather than permission to delete
|
||||
unique tests. Test-only domain scaffolding also stays pending a separate decision
|
||||
about the corresponding source behavior; deleting those tests alone would leave
|
||||
unverified code and fail unused-export checks.
|
||||
|
||||
## Stronger replacements from the closing pass
|
||||
|
||||
Three more synthetic success cases were removed. Native value composition now
|
||||
runs through the production CLI and a separate stdio MCP subprocess with real
|
||||
Ghidra, using a source-built native fixture. It checks argument/parameter/return
|
||||
relationships, the fixture's global operand, authenticated Evidence, upstream
|
||||
and workflow analysis profiles, identical CLI/MCP graphs, discovery availability
|
||||
and successful MCP session closure. Ambiguity, cancellation, target changes,
|
||||
missing flow and bounded-work regressions remain in the focused suite.
|
||||
|
||||
That replacement found missing CLI workflow routing for `trace_native_values`
|
||||
and missing workflow profile classification. The routing and discovery now use
|
||||
the composed operation's actual provider prerequisites; workflow Evidence commits
|
||||
to the upstream Ghidra profile.
|
||||
|
||||
The lone hand-built client-document equality test is replaced by a real TOML
|
||||
filesystem transaction: reversed environment keys leave the original bytes
|
||||
unchanged and create no backup. A real ZIP entry with an application root longer
|
||||
than 5,000 characters replaces injected schema-only positive data. Inventory
|
||||
completeness tests partition actual scans into authenticated partial observations
|
||||
and require exact assembled results and every source citation, including more
|
||||
than 100 citations. These are consumer merge tests, not provider pagination
|
||||
claims. Replay aggregation now covers all four statuses and retains actual runner
|
||||
diagnostics; it does not claim to execute a real sandbox. Retained pixel-metric
|
||||
fixtures now write correct PNG chunk checksums.
|
||||
|
||||
The all-zero screenshot buffer is replaced by a real Chromium PNG of 9,467,940
|
||||
bytes, transported intact through CLI and stdio MCP and decoded by the production
|
||||
pixel comparator. The verifier configures the SDK client's receive buffer because
|
||||
its 10 MiB default is smaller than the inline JSON response. This does not claim
|
||||
large comparison-request transport coverage. A real SDK initialize request,
|
||||
captured during a successful production stdio handshake, replaces the JSON-RPC
|
||||
decoder's field-count-only oracle with exact field/value expectations.
|
||||
|
||||
The closing pass also removed assertions tied to internal call order, defaults,
|
||||
private cache sizes, duplicated polling and fabricated schema extensions. Unique
|
||||
malformed-input, security-capacity and cleanup regressions remain.
|
||||
|
||||
## Real process regression found
|
||||
|
||||
The full suite exposed a renderer ordering race. Publishing a terminal output
|
||||
journal entry could synchronously trigger a reactive resize before the preceding
|
||||
output was queued for rendering. Queue that output first. The existing real
|
||||
source-owned interactive PTY test now also requires the first resized frame to
|
||||
contain the output that triggered the resize, preserving the original event
|
||||
order rather than sorting or changing timestamps to conceal it.
|
||||
|
||||
## Coverage kept
|
||||
|
||||
Malformed archives, broken and cyclic references, unsupported metadata pointers,
|
||||
relative Objective-C method layouts, ambiguous call candidates, digest mismatch,
|
||||
cancellation, host permission denial, bounded output and owned-resource cleanup
|
||||
remain covered. These cases provide evidence beyond a successful real-tool run.
|
||||
The hand-assembled relative method fixture stays until compiler-produced
|
||||
relative-list fixtures establish equivalent assertions.
|
||||
|
||||
## Remaining priorities
|
||||
|
||||
1. Extend the real Ghidra CLI/stdio MCP journey from value tracing to dispatch
|
||||
and UI handlers before pruning their hand-authored composition success cases.
|
||||
Native value tracing now establishes full transport parity.
|
||||
2. Real binary dispatch and nib → full product transports, with normalized
|
||||
semantic goldens. The compiler-backed readers already run in the new Apple
|
||||
CI lane; synthetic pointer/layout rejection tests remain useful.
|
||||
3. An approved interactive macOS runner for native UI capture/actions, including
|
||||
stdio MCP scenarios. Hosted runners without permissions cannot supply this
|
||||
proof. No arbitrary application's windows should be opened for this lane.
|
||||
4. Real macOS/Linux Ghidra CI runners with bring-your-own installations. This
|
||||
change adds Apple artifact CI without installing Java or Ghidra.
|
||||
5. Classify fake-runner replay/application acceptance accurately and replace
|
||||
repeated happy paths only after real sandbox journeys assert the same claims.
|
||||
The replay workflow's allow-unavailable mode must not imply executed replay.
|
||||
6. Replace repeated-array schema ceiling tests with real producer/transport
|
||||
regressions before deleting them. Existing integration tests do not yet
|
||||
establish their large-input guarantees.
|
||||
|
||||
Some static guards protect irreversible release authority rather than product
|
||||
behavior. Keep these few guards explicit and separate from E2E evidence.
|
||||
|
||||
## Verification after the expanded audit
|
||||
|
||||
- `npm run check:pr`: 351 files passed; 1,794 tests passed and two skipped.
|
||||
Typecheck, lint, formatting, unused-code and generated-document gates passed.
|
||||
- `npm run check:fast`: passed; 70 lint warnings and zero errors.
|
||||
- `npm run verify:browser`: actual Chromium observation and scenario CLI routes,
|
||||
retained artifacts, owned/external lifecycle checks and large CLI/stdio MCP PNG
|
||||
capture passed.
|
||||
- `npm run verify:ghidra`: macOS ARM64 with Ghidra 12.1.4/JDK 21 passed, including
|
||||
real native value tracing through CLI and stdio MCP: 171 nodes, 298 edges and
|
||||
six decompilations.
|
||||
- `npm run verify:asset-catalog`: real actool/assetutil → CLI and stdio MCP,
|
||||
complete records, pagination and malformed input checks passed.
|
||||
- `npm run verify:keyed-archive`: real Foundation binary/XML → CLI and stdio MCP,
|
||||
full graph golden, shared/cyclic identity and malformed scope checks passed.
|
||||
|
||||
- `npm run verify:fixtures`: seven source-built host fixtures, source/artifact
|
||||
digests and exhaustive inventory checks passed.
|
||||
|
||||
No Hopper windows were opened for this audit. The closing pass runs real Ghidra
|
||||
on macOS ARM64; earlier Hopper evidence remains separate. No new hosted CI success
|
||||
is claimed.
|
||||
+24
-4
@@ -106,9 +106,6 @@ It uses an installed browser and accepts an optional installed REA entrypoint.
|
||||
The complete `verify:browser` lane also exercises passive script export through
|
||||
both public adapters. See [website script export](website-script-export.md).
|
||||
|
||||
The [test suite audit](test-suite-audit.md) records the pruning decisions,
|
||||
replacement evidence and remaining priorities.
|
||||
|
||||
## Real-toolchain verification lanes
|
||||
|
||||
Each real-toolchain command must require only the host tools needed to prove
|
||||
@@ -118,6 +115,11 @@ commands. Check prerequisites before starting expensive work and name the
|
||||
missing command, target, and lane in any failure message. A lane must not imply
|
||||
that a host or target is covered when it was skipped.
|
||||
|
||||
Provider admission accepts Ghidra 12.1.x and the JDK range declared by that
|
||||
installation (`application.java.min` through `application.java.max`). Current
|
||||
12.1 releases require JDK 21 or newer and set no maximum. The lanes below still
|
||||
prove behavior on the verified Ghidra 12.1.4 and JDK 21 build.
|
||||
|
||||
| Ghidra lane | Supported runner/target | Additional local tools |
|
||||
| ------------------------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| `npm run verify:ghidra` | Linux x64 ELF or macOS x64/arm64 Mach-O | Host C compiler, Ghidra 12.1.4, and full JDK 21 |
|
||||
@@ -179,6 +181,8 @@ kept under ignored `_reference/`. No Gradle build, Android SDK, emulator or
|
||||
application execution is required. The lane compares real CLI/MCP package,
|
||||
class search, class inventory, method decompilation and incoming references.
|
||||
See [Android analysis](android-analysis.md) for boundaries and resource budgets.
|
||||
Authenticated IPA inventory projection is documented in
|
||||
[Apple application analysis](apple-application-analysis.md).
|
||||
|
||||
Synthetic producer regressions run independently:
|
||||
|
||||
@@ -399,9 +403,25 @@ for the exact ABI, authority, graph and observation boundaries.
|
||||
`npm run fixtures:firmware` uses existing Python 3 and a host C compiler to make
|
||||
an ignored gzip/USTAR firmware fixture and independent offset/hash oracle.
|
||||
`npm run verify:firmware` requires caller-supplied Binwalk 3.1.0, Unblob 26.6.4
|
||||
and util-linux prlimit on Linux. It verifies CLI/MCP parity, selected ranges,
|
||||
and util-linux prlimit on Linux. The provider also accepts other 3.1.x and
|
||||
26.6.x builds and reports them as unverified; this lane proves the audited
|
||||
releases. It verifies CLI/MCP parity, selected ranges,
|
||||
unknown chunks, depth limits and extracted child digests. The optional
|
||||
`REA_FIRMWARE_VERIFY_EXT4=1` lane requires existing mke2fs/debugfs; the separate
|
||||
`REA_FIRMWARE_VERIFY_GHIDRA=1` lane checks a selected host ELF through real Ghidra.
|
||||
Neither optional toolchain is a base-lane prerequisite. See
|
||||
[firmware analysis](firmware-analysis.md) for limits and unverified formats.
|
||||
|
||||
### JavaScript source recovery
|
||||
|
||||
Build once with `npm run build:cached`, then run `npm run verify:javascript:recovery`.
|
||||
This focused lane requires Linux x64, util-linux `prlimit`,
|
||||
`REA_WAKARU_COMMAND` pointing to the official Wakaru 1.13.0 Linux x64 binary,
|
||||
and `REA_JAVASCRIPT_FIXTURE_TOOLS` pointing to an isolated npm prefix containing
|
||||
esbuild 0.25.10 and webpack 5.101.3. No global installation is required.
|
||||
The lane compiles source-owned fixtures, exercises CLI and stdio MCP, verifies
|
||||
published bytes and provenance, feeds recovered modules into existing analysis,
|
||||
and compares a finite set of known fixture results. It does not establish
|
||||
arbitrary recovered-application equivalence. CI installs these prerequisites only
|
||||
in `.github/workflows/real-javascript-recovery.yml`; the existing `real-browser`
|
||||
lane uses real Chrome for browser capture and website workflows.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"manifest_id": "ecm_d68a1575bdaa1a044ccce8e27c5dd335612f909bc81c52ce3089b576fd772940",
|
||||
"manifest_id": "ecm_ae079950d6d61294a0940a34379f6a3c1cb26e249557ca62ba72aa1e755886e1",
|
||||
"verifier": {
|
||||
"id": "rea-managed-conformance",
|
||||
"version": "1"
|
||||
@@ -13,7 +13,7 @@
|
||||
"skill_digests": [
|
||||
{
|
||||
"skill_id": "reverse-engineer-anything",
|
||||
"sha256": "d2d971cc67bb0f7b80b4e39382a6bd1146445ed1dbf52043f89e2da2985c97ba"
|
||||
"sha256": "ee03e19730d1dde6ab46b767120b090074f59166868cfd9f547e2b6d217627b7"
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
|
||||
@@ -13,8 +13,8 @@ additional permission flag or degraded mode is required.
|
||||
|
||||
- Windows 10+ x64 with fixed local NTFS target and temporary volumes;
|
||||
- Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+;
|
||||
- an operator-installed official Ghidra 12.1.4 distribution;
|
||||
- a 64-bit full JDK 21;
|
||||
- an operator-installed Ghidra 12.1.x distribution (verified with 12.1.4);
|
||||
- a 64-bit full JDK inside that installation's declared Java range (JDK 21 or newer, with no maximum, for current 12.1 releases);
|
||||
- an explicit native, non-managed, non-DLL x86-64 PE application; and
|
||||
- the 25 read-only Ghidra inventory, memory, and function-analysis operations.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "./node_modules/knip/schema.json",
|
||||
"entry": ["src/main.ts", "src/cli.ts", "src/contracts/errorSchemas.ts"],
|
||||
"project": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.mjs"],
|
||||
"ignoreDependencies": ["electron"],
|
||||
"ignoreDependencies": ["electron", "esbuild", "webpack"],
|
||||
"ignoreExportsUsedInFile": true,
|
||||
"rules": {
|
||||
"files": "error",
|
||||
|
||||
Generated
+4
-17
@@ -31,7 +31,7 @@
|
||||
"playwright-core": "1.58.2",
|
||||
"plist": "5.0.0",
|
||||
"semver": "7.8.5",
|
||||
"smol-toml": "1.7.0",
|
||||
"smol-toml": "1.9.0",
|
||||
"write-file-atomic": "7.0.0",
|
||||
"ws": "8.21.0",
|
||||
"zod": "4.4.3"
|
||||
@@ -3892,19 +3892,6 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/knip/node_modules/smol-toml": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz",
|
||||
"integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/cyyynthia"
|
||||
}
|
||||
},
|
||||
"node_modules/knip/node_modules/yaml": {
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
|
||||
@@ -5196,9 +5183,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/smol-toml": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz",
|
||||
"integrity": "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==",
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz",
|
||||
"integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
|
||||
+6
-4
@@ -84,8 +84,8 @@
|
||||
"build:cached": "turbo run build",
|
||||
"format": "oxfmt .",
|
||||
"format:check": "oxfmt --check .",
|
||||
"lint": "oxlint",
|
||||
"lint:fix": "oxlint --fix",
|
||||
"lint": "oxlint && npm run verify:module-boundaries",
|
||||
"lint:fix": "oxlint --fix && npm run verify:module-boundaries",
|
||||
"knip": "knip",
|
||||
"jscpd": "jscpd src/ tests/",
|
||||
"scan:todos": "node scripts/scan-todos.mjs",
|
||||
@@ -177,7 +177,9 @@
|
||||
"build:fixtures:nativeaot": "node scripts/generate-nativeaot-fixtures.mjs",
|
||||
"verify:ghidra:nativeaot": "npm run build:cached && node scripts/verify-real-ghidra-nativeaot.mjs",
|
||||
"verify:browser:scripts": "npm run build:cached && node scripts/verify-browser-scripts.mjs",
|
||||
"verify:test-discovery": "node scripts/verify-test-discovery.mjs"
|
||||
"verify:test-discovery": "node scripts/verify-test-discovery.mjs",
|
||||
"verify:javascript:recovery": "node scripts/verify-javascript-recovery.mjs",
|
||||
"verify:module-boundaries": "node scripts/verify-module-boundaries.mjs"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}": [
|
||||
@@ -209,7 +211,7 @@
|
||||
"playwright-core": "1.58.2",
|
||||
"plist": "5.0.0",
|
||||
"semver": "7.8.5",
|
||||
"smol-toml": "1.7.0",
|
||||
"smol-toml": "1.9.0",
|
||||
"write-file-atomic": "7.0.0",
|
||||
"ws": "8.21.0",
|
||||
"zod": "4.4.3"
|
||||
|
||||
@@ -20,14 +20,17 @@ const source = resolve(
|
||||
const install = process.env.GHIDRA_INSTALL_DIR;
|
||||
if (!install)
|
||||
throw new Error(
|
||||
"NativeAOT build lane requires existing GHIDRA_INSTALL_DIR (Ghidra 12.1.4).",
|
||||
"NativeAOT build lane requires existing GHIDRA_INSTALL_DIR (Ghidra 12.1.x).",
|
||||
);
|
||||
const properties = await readFile(
|
||||
join(install, "Ghidra/application.properties"),
|
||||
"utf8",
|
||||
);
|
||||
if (!/^application.version=12\.1\.4\s*$/m.test(properties))
|
||||
throw new Error("NativeAOT build lane requires Ghidra 12.1.4.");
|
||||
const ghidraVersion = propertyValue(properties, "application.version");
|
||||
if (!ghidraReleaseLineAccepted(ghidraVersion))
|
||||
throw new Error(
|
||||
`NativeAOT build lane accepts Ghidra 12.1.x (verified with 12.1.4). Observed application.version=${ghidraVersion ?? "missing"}.`,
|
||||
);
|
||||
const output = resolve(
|
||||
process.env.REA_NATIVEAOT_BUILD_ROOT ??
|
||||
join(root, "_reference/nativeaot-integration/extension"),
|
||||
@@ -47,9 +50,11 @@ const run = (command, args) => {
|
||||
return result.stdout.trim();
|
||||
};
|
||||
const javac = java ? join(java, "javac") : "javac";
|
||||
if (!/^javac 21(?:\.|$)/u.test(run(javac, ["-version"])))
|
||||
const javacBanner = capture(javac, ["-version"]);
|
||||
const javacMajor = Number(/^javac (\d+)/mu.exec(javacBanner)?.[1]);
|
||||
if (!Number.isInteger(javacMajor) || javacMajor < 21)
|
||||
throw new Error(
|
||||
"NativeAOT build lane requires an existing JDK 21 javac; REA does not install or upgrade Java.",
|
||||
`NativeAOT build lane requires javac 21 or newer and compiles with --release 21. Observed: ${javacBanner || "no version"}. REA does not install or upgrade Java.`,
|
||||
);
|
||||
const revision = run("git", ["-C", source, "rev-parse", "HEAD"]);
|
||||
if (
|
||||
@@ -93,6 +98,8 @@ try {
|
||||
run(javac, [
|
||||
"-J-Xmx512m",
|
||||
"-J-XX:ActiveProcessorCount=1",
|
||||
"--release",
|
||||
"21",
|
||||
`@${join(output, "javac.args")}`,
|
||||
]);
|
||||
await writeFile(
|
||||
@@ -119,7 +126,7 @@ try {
|
||||
jar,
|
||||
sha256,
|
||||
upstream_revision: revision,
|
||||
ghidra_version: "12.1.4",
|
||||
ghidra_version: ghidraVersion,
|
||||
integration_api: 1,
|
||||
},
|
||||
null,
|
||||
@@ -131,10 +138,41 @@ try {
|
||||
jar,
|
||||
sha256,
|
||||
upstream_revision: revision,
|
||||
ghidra_version: "12.1.4",
|
||||
ghidra_version: ghidraVersion,
|
||||
integration_api: 1,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await rm(classes, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/** Read one application.properties value. Empty and missing values stay unknown. */
|
||||
function propertyValue(content, name) {
|
||||
for (const line of content.split(/\r?\n/u)) {
|
||||
const separator = line.indexOf("=");
|
||||
if (separator < 0 || line.slice(0, separator).trim() !== name) continue;
|
||||
const value = line.slice(separator + 1).trim();
|
||||
return value.length === 0 ? null : value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Same 12.1 line as GhidraInstallationPolicy, including a distro suffix. */
|
||||
function ghidraReleaseLineAccepted(raw) {
|
||||
const match = /^(\d+)\.(\d+)(?:\.(\d+))?([-+].*)?$/u.exec(raw ?? "");
|
||||
return match !== null && Number(match[1]) === 12 && Number(match[2]) === 1;
|
||||
}
|
||||
|
||||
function capture(command, args) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
timeout: 120_000,
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
});
|
||||
if (result.error || result.status !== 0)
|
||||
throw new Error(
|
||||
`NativeAOT build prerequisite/action ${command} failed: ${result.error?.message ?? ""}\n${result.stdout ?? ""}${result.stderr ?? ""}`,
|
||||
);
|
||||
return `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { greet, total } from "./helpers.mjs";
|
||||
|
||||
globalThis.recoveryFixture = { greet, total };
|
||||
@@ -0,0 +1,6 @@
|
||||
export function greet(name) {
|
||||
return `Hello ${name ?? "world"}`;
|
||||
}
|
||||
export function total(items) {
|
||||
return items.reduce((sum, value) => sum + value, 0);
|
||||
}
|
||||
@@ -7,9 +7,7 @@ import { McpServer } from "@modelcontextprotocol/server";
|
||||
|
||||
import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js";
|
||||
import { MANAGED_WORKFLOW_TOOL_CONTRACTS } from "../dist/contracts/managedWorkflowToolContracts.js";
|
||||
import { ArtifactProvider } from "../dist/artifacts/ArtifactProvider.js";
|
||||
import { ManagedStaticProvider } from "../dist/dotnet/ManagedStaticProvider.js";
|
||||
import { NativeMacOSProvider } from "../dist/native/NativeMacOSProvider.js";
|
||||
import { auxiliaryAnalysisProviderDeclarations } from "../dist/composition/auxiliaryAnalysisProviders.js";
|
||||
import { toolRegistrationOptions } from "../dist/server/toolRegistrationOptions.js";
|
||||
import { ensureGeneratedFile } from "./lib/generated-file.mjs";
|
||||
|
||||
@@ -88,14 +86,9 @@ async function sdkToolCatalog() {
|
||||
await Promise.allSettled([client.close(), server.close()]);
|
||||
}
|
||||
}
|
||||
const auxiliaryProviders = [
|
||||
new ArtifactProvider(CATALOG_PLATFORM),
|
||||
new NativeMacOSProvider(undefined, CATALOG_PLATFORM),
|
||||
new ManagedStaticProvider(),
|
||||
].map((provider) => ({
|
||||
identity: provider.identity(),
|
||||
capabilities: provider.capabilities(),
|
||||
}));
|
||||
const auxiliaryProviders = auxiliaryAnalysisProviderDeclarations(
|
||||
CATALOG_PLATFORM,
|
||||
).map(({ identity, capabilities }) => ({ identity, capabilities }));
|
||||
|
||||
const canonicalizeJson = (value) => {
|
||||
if (Array.isArray(value)) return value.map(canonicalizeJson);
|
||||
|
||||
@@ -67,6 +67,8 @@ export const toolFamilyCatalog = (sources) => {
|
||||
contracts: [
|
||||
...sources.applicationContracts.APPLICATION_TOOL_CONTRACTS,
|
||||
...sources.webScriptContracts.WEB_SCRIPT_TOOL_CONTRACTS,
|
||||
...sources.javascriptRecoveryContracts
|
||||
.JAVASCRIPT_RECOVERY_TOOL_CONTRACTS,
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -185,12 +187,33 @@ export const providerCatalog = (sources) => {
|
||||
{
|
||||
identity:
|
||||
sources.artifactProviders.JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER,
|
||||
contracts: sources.applicationContracts.APPLICATION_TOOL_CONTRACTS,
|
||||
contracts: sources.applicationContracts.APPLICATION_TOOL_CONTRACTS.filter(
|
||||
({ name }) =>
|
||||
name !== "project_android_application_graph" &&
|
||||
name !== "project_apple_application_graph",
|
||||
),
|
||||
},
|
||||
{
|
||||
identity: sources.artifactProviders.ANDROID_APPLICATION_PROVIDER,
|
||||
contracts: sources.applicationContracts.APPLICATION_TOOL_CONTRACTS.filter(
|
||||
({ name }) => name === "project_android_application_graph",
|
||||
),
|
||||
},
|
||||
{
|
||||
identity: sources.artifactProviders.APPLE_APPLICATION_PROVIDER,
|
||||
contracts: sources.applicationContracts.APPLICATION_TOOL_CONTRACTS.filter(
|
||||
({ name }) => name === "project_apple_application_graph",
|
||||
),
|
||||
},
|
||||
{
|
||||
identity: sources.artifactProviders.WEB_SCRIPT_EXPORT_PROVIDER,
|
||||
contracts: sources.webScriptContracts.WEB_SCRIPT_TOOL_CONTRACTS,
|
||||
},
|
||||
{
|
||||
identity: sources.javascriptRecoveryProvider.WAKARU_PROVIDER_IDENTITY,
|
||||
contracts:
|
||||
sources.javascriptRecoveryContracts.JAVASCRIPT_RECOVERY_TOOL_CONTRACTS,
|
||||
},
|
||||
]
|
||||
.map(({ identity, contracts }) => ({
|
||||
id: identity.id,
|
||||
|
||||
@@ -55,11 +55,14 @@ const SOURCE_PATHS = {
|
||||
"dist/contracts/javascriptRuntimeObservationToolContracts.js",
|
||||
applicationContracts: "dist/contracts/applicationToolContracts.js",
|
||||
webScriptContracts: "dist/contracts/webScriptToolContracts.js",
|
||||
javascriptRecoveryContracts:
|
||||
"dist/contracts/javascriptRecoveryToolContracts.js",
|
||||
javascriptRecoveryProvider: "dist/javascript/recovery/WakaruRelease.js",
|
||||
supportedClients: "dist/application/SupportedClients.js",
|
||||
hopperProvider: "dist/hopper/HopperProvider.js",
|
||||
ghidraProvider: "dist/ghidra/GhidraProvider.js",
|
||||
idaProvider: "dist/ida/IdaProviderCapabilities.js",
|
||||
nativeProvider: "dist/native/NativeMacOSProvider.js",
|
||||
nativeProvider: "dist/native/NativeMacOSProviderMetadata.js",
|
||||
artifactProviders: "dist/application/InvestigationProviders.js",
|
||||
browserProvider: "dist/browser/CdpBrowserProvider.js",
|
||||
browserScenarioProvider: "dist/browser/PlaywrightBrowserScenarioProvider.js",
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/** Compile source-owned fixtures using explicitly supplied, pinned toolchains. */
|
||||
export async function buildRecoveryFixtures(root, prefix) {
|
||||
if (!prefix)
|
||||
throw new Error(
|
||||
"verify:javascript:recovery requires REA_JAVASCRIPT_FIXTURE_TOOLS pointing to the isolated esbuild/webpack installation",
|
||||
);
|
||||
const require = createRequire(join(prefix, "package.json"));
|
||||
const esbuild = require("esbuild");
|
||||
const webpack = require("webpack");
|
||||
const webpackVersion = require("webpack/package.json").version;
|
||||
if (esbuild.version !== "0.25.10" || webpackVersion !== "5.101.3")
|
||||
throw new Error(
|
||||
"Fixture compilers must be esbuild 0.25.10 and webpack 5.101.3",
|
||||
);
|
||||
const source = fileURLToPath(
|
||||
new URL("../fixtures/javascript-recovery/entry.mjs", import.meta.url),
|
||||
);
|
||||
const webpackOutput = join(root, "webpack");
|
||||
await mkdir(webpackOutput);
|
||||
await new Promise((resolve, reject) => {
|
||||
const compiler = webpack({
|
||||
mode: "production",
|
||||
entry: source,
|
||||
parallelism: 1,
|
||||
optimization: { concatenateModules: false },
|
||||
output: { path: webpackOutput, filename: "bundle.js" },
|
||||
});
|
||||
compiler.run((error, stats) => {
|
||||
compiler.close((closeError) => {
|
||||
if (error || closeError) return reject(error ?? closeError);
|
||||
if (!stats || stats.hasErrors())
|
||||
return reject(new Error(stats?.toString() ?? "No webpack stats"));
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
const esbuildPath = join(root, "esbuild.js");
|
||||
await esbuild.build({
|
||||
entryPoints: [source],
|
||||
outfile: esbuildPath,
|
||||
bundle: true,
|
||||
format: "iife",
|
||||
minify: true,
|
||||
logLevel: "silent",
|
||||
});
|
||||
// One self-contained source fixture avoids unresolved imports in the oracle.
|
||||
const helperSource = await readFile(
|
||||
fileURLToPath(
|
||||
new URL("../fixtures/javascript-recovery/helpers.mjs", import.meta.url),
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
const plain = await esbuild.transform(
|
||||
helperSource.replaceAll("export ", "") +
|
||||
"\nglobalThis.recoveryFixture = {greet, total};",
|
||||
{ minify: true },
|
||||
);
|
||||
const minifiedPath = join(root, "minified.js");
|
||||
await writeFile(minifiedPath, plain.code);
|
||||
if (plain.code.length === 0)
|
||||
throw new Error("Expected a real minifier output");
|
||||
return {
|
||||
files: [
|
||||
{
|
||||
name: "minified",
|
||||
path: minifiedPath,
|
||||
expectedFormat: "unknown",
|
||||
expectedModules: 1,
|
||||
},
|
||||
{
|
||||
name: "webpack",
|
||||
path: join(webpackOutput, "bundle.js"),
|
||||
expectedFormat: "webpack5",
|
||||
expectedModules: 2,
|
||||
},
|
||||
{
|
||||
name: "esbuild-scope-hoisted",
|
||||
path: esbuildPath,
|
||||
expectedFormat: "unknown",
|
||||
expectedModules: 1,
|
||||
},
|
||||
{
|
||||
name: "heuristic-aggressive",
|
||||
path: minifiedPath,
|
||||
expectedFormat: "unknown",
|
||||
expectedModules: 1,
|
||||
options: { extraction_mode: "heuristic", rewrite_level: "aggressive" },
|
||||
},
|
||||
{
|
||||
name: "inspection-minimal",
|
||||
path: join(webpackOutput, "bundle.js"),
|
||||
expectedFormat: "webpack5",
|
||||
expectedModules: 2,
|
||||
options: { extraction_mode: "inspection", rewrite_level: "minimal" },
|
||||
},
|
||||
],
|
||||
toolchains: { esbuild: esbuild.version, webpack: webpackVersion },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/** One dependency crossing a settled source ownership boundary. */
|
||||
export interface ModuleBoundaryViolation {
|
||||
readonly file: string;
|
||||
readonly line: number;
|
||||
readonly specifier: string;
|
||||
readonly target: string;
|
||||
readonly boundary:
|
||||
| "pure-layer"
|
||||
| "application-composition"
|
||||
| "provider-construction";
|
||||
}
|
||||
|
||||
/** Check resolved source ownership, including type imports and reexports. */
|
||||
export function inspectModuleBoundaries(
|
||||
file: string,
|
||||
source: string,
|
||||
root: string,
|
||||
): readonly ModuleBoundaryViolation[];
|
||||
@@ -0,0 +1,100 @@
|
||||
import { parse } from "@babel/parser";
|
||||
import { traverseFast } from "@babel/types";
|
||||
import { isAbsolute, relative, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const PURE_LAYERS = new Set(["domain", "contracts"]);
|
||||
const MIGRATED_PROVIDER_ROOTS = new Set([
|
||||
"android",
|
||||
"firmware",
|
||||
"ghidra",
|
||||
"hopper",
|
||||
"ida",
|
||||
"javascript",
|
||||
]);
|
||||
|
||||
const repositoryPath = (root, path) =>
|
||||
relative(root, path).replaceAll("\\", "/");
|
||||
|
||||
const literalValue = (node) => {
|
||||
if (node?.type === "StringLiteral") return node.value;
|
||||
if (node?.type === "TemplateLiteral" && node.expressions.length === 0)
|
||||
return node.quasis[0]?.value.cooked ?? undefined;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const importSpecifier = (node) => {
|
||||
switch (node.type) {
|
||||
case "ImportDeclaration":
|
||||
case "ExportNamedDeclaration":
|
||||
case "ExportAllDeclaration":
|
||||
case "ImportExpression":
|
||||
case "TSImportType":
|
||||
return literalValue(node.source);
|
||||
case "TSImportEqualsDeclaration":
|
||||
return node.moduleReference.type === "TSExternalModuleReference"
|
||||
? literalValue(node.moduleReference.expression)
|
||||
: undefined;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const sourceTarget = (file, specifier) => {
|
||||
if (specifier.startsWith("file:")) return fileURLToPath(specifier);
|
||||
if (isAbsolute(specifier)) return resolve(specifier);
|
||||
return specifier.startsWith(".")
|
||||
? fileURLToPath(new URL(specifier, pathToFileURL(file)))
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const failedBoundary = (file, target) => {
|
||||
if (!file.startsWith("src/") || !target.startsWith("src/")) return undefined;
|
||||
const layer = file.split("/")[1];
|
||||
const targetLayer = target.split("/")[1];
|
||||
if (PURE_LAYERS.has(layer)) {
|
||||
if (
|
||||
(/^src\/[^/]+\//u.test(target) && !PURE_LAYERS.has(targetLayer)) ||
|
||||
/^src\/(?:cli|main)\./u.test(target)
|
||||
)
|
||||
return "pure-layer";
|
||||
}
|
||||
if (file === "src/application/runtime.ts") return undefined;
|
||||
if (layer === "application" && targetLayer === "composition")
|
||||
return "application-composition";
|
||||
if (
|
||||
(layer === "application" || layer === "server") &&
|
||||
(MIGRATED_PROVIDER_ROOTS.has(targetLayer) ||
|
||||
/^src\/browser\/[^/]*Provider\.[^/]+$/u.test(target))
|
||||
)
|
||||
return "provider-construction";
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Check resolved source ownership, including type imports and reexports. */
|
||||
export const inspectModuleBoundaries = (file, source, root) => {
|
||||
const absoluteFile = resolve(root, file);
|
||||
const importer = repositoryPath(root, absoluteFile);
|
||||
const tree = parse(source, {
|
||||
sourceType: "module",
|
||||
plugins: ["typescript"],
|
||||
});
|
||||
const violations = [];
|
||||
traverseFast(tree, (node) => {
|
||||
const specifier = importSpecifier(node);
|
||||
if (specifier === undefined) return;
|
||||
const resolved = sourceTarget(absoluteFile, specifier);
|
||||
if (resolved === undefined) return;
|
||||
const target = repositoryPath(root, resolved);
|
||||
const boundary = failedBoundary(importer, target);
|
||||
if (boundary !== undefined)
|
||||
violations.push({
|
||||
file: importer,
|
||||
line: node.loc?.start.line ?? 1,
|
||||
specifier,
|
||||
target,
|
||||
boundary,
|
||||
});
|
||||
});
|
||||
return violations;
|
||||
};
|
||||
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { parseEvidence } from "../dist/domain/evidence.js";
|
||||
import { javascriptRecoveryResultSchema } from "../dist/domain/javascriptRecovery.js";
|
||||
import { buildRecoveryFixtures } from "./lib/javascript-recovery-fixtures.mjs";
|
||||
import { mcpTextValue } from "./lib/mcp-verifier-results.mjs";
|
||||
|
||||
const OFFICIAL_BINARY_SHA256 =
|
||||
"e8c7ca052974604197389f6ace7464e4dd24dce64e2730971521bd6606ec9c9e";
|
||||
if (!process.env.REA_WAKARU_COMMAND)
|
||||
throw new Error(
|
||||
"Provide REA_WAKARU_COMMAND for the official Wakaru 1.13.0 Linux x64 binary",
|
||||
);
|
||||
const entrypoint =
|
||||
process.argv[2] ?? fileURLToPath(new URL("./rea.mjs", import.meta.url));
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-real-recovery-"));
|
||||
const environment = { ...process.env, REA_LOG_LEVEL: "silent" };
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env: environment,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const client = new Client({ name: "real-recovery", version: "1" });
|
||||
const run = promisify(execFile);
|
||||
const cli = async (...args) => {
|
||||
const { stdout } = await run(
|
||||
process.execPath,
|
||||
[entrypoint, ...args, "--json"],
|
||||
{ env: environment, timeout: 150000, maxBuffer: 32 * 1024 * 1024 },
|
||||
);
|
||||
return parseEvidence(JSON.parse(stdout));
|
||||
};
|
||||
const call = async (name, args) => {
|
||||
const result = await client.callTool({ name, arguments: args }, undefined, {
|
||||
timeout: 150000,
|
||||
});
|
||||
assert.notEqual(result.isError, true, mcpTextValue(result));
|
||||
return parseEvidence(JSON.parse(mcpTextValue(result)).evidence);
|
||||
};
|
||||
const fingerprint = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||
const counts = [];
|
||||
try {
|
||||
const fixtures = await buildRecoveryFixtures(
|
||||
root,
|
||||
process.env.REA_JAVASCRIPT_FIXTURE_TOOLS,
|
||||
);
|
||||
await client.connect(transport);
|
||||
for (const fixture of fixtures.files) {
|
||||
for (const surface of ["cli", "stdio_mcp"]) {
|
||||
const output = join(root, `${fixture.name}-${surface}`);
|
||||
const flags =
|
||||
fixture.options === undefined
|
||||
? []
|
||||
: [
|
||||
"--extraction-mode",
|
||||
fixture.options.extraction_mode,
|
||||
"--rewrite-level",
|
||||
fixture.options.rewrite_level,
|
||||
];
|
||||
const evidence =
|
||||
surface === "cli"
|
||||
? await cli(
|
||||
"recover-javascript-sources",
|
||||
fixture.path,
|
||||
output,
|
||||
...flags,
|
||||
)
|
||||
: await call("recover_javascript_sources", {
|
||||
path: fixture.path,
|
||||
output_directory: output,
|
||||
...fixture.options,
|
||||
});
|
||||
const result = javascriptRecoveryResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
await assertRecoveredArtifacts(evidence, result, fixture);
|
||||
const analysis =
|
||||
surface === "cli"
|
||||
? await cli(
|
||||
"analyze-javascript-application",
|
||||
result.analysis_input.input_path,
|
||||
)
|
||||
: await call("analyze_javascript_application", result.analysis_input);
|
||||
assert.equal(analysis.normalized_result.statistics.parse_failures, 0);
|
||||
assert.equal(
|
||||
analysis.normalized_result.statistics.parsed_javascript_files,
|
||||
result.modules.length,
|
||||
);
|
||||
const recoveredEntry =
|
||||
result.modules.find(
|
||||
(module) => module.reported_filename === "entry.js",
|
||||
) ?? result.modules[0];
|
||||
assert.ok(recoveredEntry);
|
||||
const oracle =
|
||||
'await import((await import("node:url")).pathToFileURL(process.argv[1])); const f=globalThis.recoveryFixture; console.log(JSON.stringify([f.greet(null),f.greet("REA"),f.total([1,2,3])]));';
|
||||
for (const selected of fixture.options?.extraction_mode === "inspection"
|
||||
? []
|
||||
: [fixture.path, recoveredEntry.artifact.path]) {
|
||||
const execution = await run(
|
||||
process.execPath,
|
||||
["--input-type=module", "-e", oracle, selected],
|
||||
{ env: environment, timeout: 10000 },
|
||||
);
|
||||
assert.deepEqual(JSON.parse(execution.stdout), [
|
||||
"Hello world",
|
||||
"Hello REA",
|
||||
6,
|
||||
]);
|
||||
}
|
||||
counts.push({
|
||||
fixture: fixture.name,
|
||||
surface,
|
||||
modules: result.modules.length,
|
||||
maps: result.modules.filter((module) => module.source_map !== null)
|
||||
.length,
|
||||
finite_fixture_oracle:
|
||||
fixture.options?.extraction_mode !== "inspection",
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
engine: "wakaru 1.13.0",
|
||||
toolchains: fixtures.toolchains,
|
||||
results: counts,
|
||||
verified: true,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertRecoveredArtifacts(evidence, result, fixture) {
|
||||
assert.deepEqual(result.options, {
|
||||
extraction_mode: "structural",
|
||||
rewrite_level: "standard",
|
||||
...fixture.options,
|
||||
});
|
||||
if (fixture.options?.extraction_mode === "inspection")
|
||||
assert.equal(result.reported_safety, "inspection-only");
|
||||
assert.equal(result.engine.executable.sha256, OFFICIAL_BINARY_SHA256);
|
||||
assert.equal(result.engine.executed_source_revision, null);
|
||||
assert.equal(evidence.confidence, "derived");
|
||||
assert.equal(result.runtime_equivalence, "unknown");
|
||||
assert.equal(result.status, "complete");
|
||||
assert.equal(result.reported_format, fixture.expectedFormat);
|
||||
assert.equal(result.modules.length, fixture.expectedModules);
|
||||
assert.equal(result.source.sha256, fingerprint(await readFile(fixture.path)));
|
||||
for (const file of [
|
||||
result.source.published_copy,
|
||||
result.report,
|
||||
result.provenance,
|
||||
result.manifest,
|
||||
...result.modules.flatMap((module) => [
|
||||
module.artifact,
|
||||
...(module.source_map ? [module.source_map] : []),
|
||||
]),
|
||||
]) {
|
||||
const bytes = await readFile(file.path);
|
||||
assert.equal(bytes.length, file.bytes);
|
||||
assert.equal(fingerprint(bytes), file.sha256);
|
||||
}
|
||||
for (const module of result.modules) {
|
||||
assert.equal(module.provenance.original_sha256, result.source.sha256);
|
||||
assert.equal(module.provenance.reported_input, result.source.snapshot_path);
|
||||
for (const range of module.provenance.byte_ranges)
|
||||
assert.ok(range.start <= range.end && range.end <= result.source.bytes);
|
||||
if (module.source_map) {
|
||||
const map = JSON.parse(await readFile(module.source_map.path, "utf8"));
|
||||
for (const source of map.sources)
|
||||
assert.equal(
|
||||
resolve(dirname(module.source_map.path), source),
|
||||
result.source.published_copy.path,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { inspectModuleBoundaries } from "./lib/module-boundaries.mjs";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
const root = process.cwd();
|
||||
const selected = process.argv.slice(2);
|
||||
for (const file of selected)
|
||||
if (!file.endsWith(".ts"))
|
||||
throw new TypeError(`Expected an exact TypeScript source file: ${file}`);
|
||||
const files =
|
||||
selected.length > 0
|
||||
? selected
|
||||
: (
|
||||
await execute(
|
||||
"git",
|
||||
[
|
||||
"ls-files",
|
||||
"-z",
|
||||
"--cached",
|
||||
"--others",
|
||||
"--exclude-standard",
|
||||
"--",
|
||||
"src",
|
||||
],
|
||||
{ cwd: root, maxBuffer: 8 * 1024 * 1024 },
|
||||
)
|
||||
).stdout.split("\0");
|
||||
|
||||
let checked = 0;
|
||||
for (const file of new Set(files.filter((path) => path.endsWith(".ts")))) {
|
||||
let source;
|
||||
try {
|
||||
source = await readFile(resolve(root, file), "utf8");
|
||||
} catch (cause) {
|
||||
if (selected.length === 0 && cause.code === "ENOENT") continue;
|
||||
throw cause;
|
||||
}
|
||||
checked += 1;
|
||||
try {
|
||||
for (const violation of inspectModuleBoundaries(file, source, root)) {
|
||||
console.error(
|
||||
`${violation.file}:${violation.line}: ${violation.boundary}: ${violation.specifier} resolves to ${violation.target}`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} catch (cause) {
|
||||
console.error(`Could not inspect ${file}: ${cause.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
if (process.exitCode !== 1)
|
||||
console.log(`Checked resolved module boundaries in ${checked} source files.`);
|
||||
@@ -413,9 +413,16 @@ async function verifyPageScopedTransport(provider, proxy, origin) {
|
||||
target_id: target,
|
||||
observation_ms: 5_000,
|
||||
}),
|
||||
{
|
||||
progress: {
|
||||
report(event) {
|
||||
if (event.phase === "browser_observation" && event.completed === 1)
|
||||
proxy.disconnectClients();
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
await delay(100);
|
||||
proxy.disconnectClients();
|
||||
const disconnected = await disconnectedPromise;
|
||||
if (
|
||||
!disconnected.ok ||
|
||||
|
||||
@@ -3,8 +3,8 @@ 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: "26"
|
||||
tool_count: 126
|
||||
catalog_digest: "e21455dd7d64af94e0b07aff55ab82f03a37e46729ece51be38b50083d2d5b55"
|
||||
tool_count: 129
|
||||
catalog_digest: "856ce0f99cc6e858f3317e40cec27c2cfd479ae7ff6a8d9cf695504d6166b64d"
|
||||
---
|
||||
|
||||
# REA
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
jadxXrefsSchema,
|
||||
normalizeJadxText,
|
||||
} from "./JadxProtocol.js";
|
||||
import { JADX_RELEASE } from "./JadxRelease.js";
|
||||
|
||||
/** The adapter consumes tool payloads, never provider RPC details outside this layer. */
|
||||
export interface JadxToolPort {
|
||||
@@ -246,7 +247,7 @@ const inspectMethod = async (
|
||||
throw new AnalysisCapabilityUnavailableError(
|
||||
"jadx",
|
||||
request.operation,
|
||||
`JADX 0.7.1 smali fallback joins all same-name overloads for ${summary.full_name}.${request.input.method_name}; it cannot establish source for only overload ${index}.`,
|
||||
`JADX ${JADX_RELEASE.version} smali fallback joins all same-name overloads for ${summary.full_name}.${request.input.method_name}; it cannot establish source for only overload ${index}.`,
|
||||
);
|
||||
return androidResultSchemas.inspect_android_method.parse({
|
||||
engine,
|
||||
@@ -306,7 +307,7 @@ const traceReferences = async (
|
||||
throw new AnalysisCapabilityUnavailableError(
|
||||
"jadx",
|
||||
request.operation,
|
||||
`JADX 0.7.1 references select the first same-name method; ${summary.full_name}.${methodName} has ${candidates.length} overloads. No unambiguous reference result is available.`,
|
||||
`JADX ${JADX_RELEASE.version} references select the first same-name method; ${summary.full_name}.${methodName} has ${candidates.length} overloads. No unambiguous reference result is available.`,
|
||||
);
|
||||
const expectedTarget =
|
||||
methodName === undefined
|
||||
|
||||
@@ -3,6 +3,7 @@ import { constants } from "node:fs";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
|
||||
import type { AndroidOperation } from "../domain/androidAnalysis.js";
|
||||
import { JADX_JAR_CONFIGURATION_REMEDIATION } from "./JadxRelease.js";
|
||||
|
||||
/** Caller-supplied tools, resolved only when an Android operation is selected. */
|
||||
export interface JadxConfiguration {
|
||||
@@ -27,9 +28,7 @@ export const resolveJadxConfiguration = async (
|
||||
"JAVA_HOME must select an existing JDK by absolute path; omit it to use java on PATH.",
|
||||
);
|
||||
if (jar === undefined || !isAbsolute(jar))
|
||||
throw unavailable(
|
||||
"Set REA_JADX_MCP_JAR to the absolute path of a caller-supplied jadx-headless-mcp 0.7.1 JAR. REA does not download or install it.",
|
||||
);
|
||||
throw unavailable(JADX_JAR_CONFIGURATION_REMEDIATION);
|
||||
const java =
|
||||
environment.JAVA_HOME === undefined
|
||||
? "java"
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "./AndroidTargetSnapshot.js";
|
||||
import { resolveJadxConfiguration } from "./JadxConfiguration.js";
|
||||
import type { JadxLauncher } from "./JadxMcpTransport.js";
|
||||
import { JADX_HEAP_XMX_ARGUMENT } from "./JadxRelease.js";
|
||||
import { JadxSession } from "./JadxSession.js";
|
||||
|
||||
const OPERATION_TIMEOUT_MS = 120_000;
|
||||
@@ -203,7 +204,7 @@ export class JadxProvider implements AndroidAnalysisPort {
|
||||
{
|
||||
command: configuration.java,
|
||||
arguments: [
|
||||
"-Xmx512m",
|
||||
JADX_HEAP_XMX_ARGUMENT,
|
||||
"-XX:ActiveProcessorCount=1",
|
||||
"-jar",
|
||||
engine.path,
|
||||
@@ -216,7 +217,9 @@ export class JadxProvider implements AndroidAnalysisPort {
|
||||
],
|
||||
cwd: root.path,
|
||||
hostEnvironment: this.environment,
|
||||
env: { _JAVA_OPTIONS: "-Xmx512m -XX:ActiveProcessorCount=1" },
|
||||
env: {
|
||||
_JAVA_OPTIONS: `${JADX_HEAP_XMX_ARGUMENT} -XX:ActiveProcessorCount=1`,
|
||||
},
|
||||
},
|
||||
this.launcher,
|
||||
);
|
||||
|
||||
@@ -6,6 +6,15 @@ export const JADX_RELEASE = Object.freeze({
|
||||
url: "https://github.com/1013503897/jadx-headless-mcp/releases/download/v0.7.1/jadx-headless-mcp-0.7.1-all.jar",
|
||||
});
|
||||
|
||||
/** JVM heap ceiling applied to every owned JADX worker process. */
|
||||
export const JADX_HEAP_LIMIT_MIB = 512;
|
||||
|
||||
/** JVM `-Xmx` argument matching {@link JADX_HEAP_LIMIT_MIB}. */
|
||||
export const JADX_HEAP_XMX_ARGUMENT = `-Xmx${JADX_HEAP_LIMIT_MIB}m`;
|
||||
|
||||
/** Remediation when `REA_JADX_MCP_JAR` is missing or not an absolute path. */
|
||||
export const JADX_JAR_CONFIGURATION_REMEDIATION = `Set REA_JADX_MCP_JAR to the absolute path of a caller-supplied jadx-headless-mcp ${JADX_RELEASE.version} JAR. REA does not download or install it.`;
|
||||
|
||||
/** Producing engine identity used by Evidence and the generated product catalog. */
|
||||
export const JADX_PROVIDER_IDENTITY = Object.freeze({
|
||||
id: "jadx",
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
parseJadxJson,
|
||||
} from "./JadxProtocol.js";
|
||||
import {
|
||||
JADX_HEAP_LIMIT_MIB,
|
||||
JADX_RELEASE,
|
||||
JADX_PROVIDER_IDENTITY,
|
||||
JADX_LIMITATIONS,
|
||||
@@ -79,12 +80,12 @@ export class JadxSession {
|
||||
);
|
||||
const engine = {
|
||||
name: "jadx-headless-mcp",
|
||||
version: "0.7.1",
|
||||
version: JADX_RELEASE.version,
|
||||
artifact_sha256: jarHash,
|
||||
source_revision:
|
||||
jarHash === JADX_RELEASE.sha256 ? JADX_RELEASE.revision : null,
|
||||
worker_count: 1,
|
||||
heap_limit_mib: 512,
|
||||
heap_limit_mib: JADX_HEAP_LIMIT_MIB,
|
||||
} as const;
|
||||
const result = await analyzeJadxRequest(tools, request, loaded, engine);
|
||||
return createAnalysisExecution(result, JADX_PROVIDER_IDENTITY, {
|
||||
|
||||
@@ -18,12 +18,14 @@ import type {
|
||||
ProviderIdentity,
|
||||
} from "./AnalysisProvider.js";
|
||||
import type { AnalysisOperation } from "./AnalysisProvider.js";
|
||||
import { GENERATED_MCP_TOOL_CATALOG } from "../generatedMcpToolCatalog.js";
|
||||
import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/officialToolContracts.js";
|
||||
import { ENHANCED_TOOL_CONTRACTS } from "../contracts/enhancedToolContracts.js";
|
||||
import type { BinarySessionPort } from "./BinarySessionPort.js";
|
||||
import {
|
||||
SessionProviderRouter,
|
||||
type SessionProviderRoute,
|
||||
} from "./SessionProviderRouter.js";
|
||||
import { InvestigationRecords } from "./investigation/InvestigationRecords.js";
|
||||
import { BinarySessionRecords } from "./BinarySessionRecords.js";
|
||||
import { binarySessionStatus } from "./BinarySessionStatus.js";
|
||||
import {
|
||||
@@ -38,14 +40,10 @@ import {
|
||||
import { closeAnalysisClient } from "./AnalysisClientCleanup.js";
|
||||
export type { BinarySessionPort } from "./BinarySessionPort.js";
|
||||
const OFFICIAL_OPERATIONS: ReadonlySet<string> = new Set(
|
||||
GENERATED_MCP_TOOL_CATALOG.filter(
|
||||
({ kind }) => kind === "official-proxy",
|
||||
).map(({ name }) => name),
|
||||
OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name),
|
||||
);
|
||||
const ENHANCED_OPERATIONS: ReadonlySet<string> = new Set(
|
||||
GENERATED_MCP_TOOL_CATALOG.filter(({ kind }) => kind === "enhanced").map(
|
||||
({ name }) => name,
|
||||
),
|
||||
ENHANCED_TOOL_CONTRACTS.map(({ name }) => name),
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -78,8 +76,11 @@ export class BinarySession
|
||||
>();
|
||||
readonly #availabilityListeners = new Set<() => void | Promise<void>>();
|
||||
|
||||
constructor(providerRouter: SessionProviderRouter) {
|
||||
super();
|
||||
constructor(
|
||||
providerRouter: SessionProviderRouter,
|
||||
records: InvestigationRecords = new InvestigationRecords(),
|
||||
) {
|
||||
super(records);
|
||||
this.#providerRouter = providerRouter;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { InvestigationRecords } from "./investigation/InvestigationRecords.js";
|
||||
import type { AnalysisProvider } from "./AnalysisProvider.js";
|
||||
import { AnalysisProviderRegistry } from "./AnalysisProviderRegistry.js";
|
||||
import { BinarySession } from "./BinarySession.js";
|
||||
@@ -15,4 +16,5 @@ export const composeBinarySession = (
|
||||
registry instanceof SessionProviderRouter
|
||||
? registry
|
||||
: SessionProviderRouter.selectable(registry, auxiliaryProviders),
|
||||
new InvestigationRecords(),
|
||||
);
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
import type { InvestigationRecordPort } from "./investigation/InvestigationRecordPort.js";
|
||||
import type { ExecutableFormatHint } from "../domain/dosCom.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import type { EvidenceBundle } from "../domain/evidenceBundle.js";
|
||||
import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js";
|
||||
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
|
||||
import type { AnalysisProviderSelector } from "../contracts/providerSelection.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import type { EvidenceIntegrityError } from "../domain/evidenceErrors.js";
|
||||
import type { UnknownRegistryError } from "../domain/unknownRegistryError.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import type {
|
||||
RecordUnknownInput,
|
||||
ResidualUnknown,
|
||||
UnknownStatus,
|
||||
UpdateUnknownInput,
|
||||
} from "../domain/residualUnknown.js";
|
||||
|
||||
import type {
|
||||
AnalysisOperation,
|
||||
AnalysisOperationPort,
|
||||
@@ -24,7 +16,8 @@ import type {
|
||||
} from "./AnalysisProvider.js";
|
||||
|
||||
/** Target lifecycle used by CLI and MCP without exposing a concrete provider. */
|
||||
export interface BinarySessionPort extends AnalysisOperationPort {
|
||||
export interface BinarySessionPort
|
||||
extends AnalysisOperationPort, InvestigationRecordPort {
|
||||
open(
|
||||
path: string,
|
||||
options?: {
|
||||
@@ -42,40 +35,10 @@ export interface BinarySessionPort extends AnalysisOperationPort {
|
||||
): Promise<Result<null, AnalysisError>>;
|
||||
status(): JsonValue;
|
||||
activeTarget(): BinaryTarget | undefined;
|
||||
recordEvidence(
|
||||
evidence: Evidence,
|
||||
): Result<"added" | "duplicate", EvidenceIntegrityError>;
|
||||
hasEvidence(evidenceId: string): boolean;
|
||||
evidenceById(evidenceId: string): Evidence | undefined;
|
||||
exportEvidenceBundle(): EvidenceBundle;
|
||||
importEvidenceBundle(bundle: unknown): Result<number, EvidenceIntegrityError>;
|
||||
exportAnalysisSnapshot(): Result<AnalysisSnapshot, AnalysisError>;
|
||||
importAnalysisSnapshot(
|
||||
snapshot: AnalysisSnapshot,
|
||||
): Result<number, AnalysisError>;
|
||||
recordUnknown(
|
||||
input: RecordUnknownInput,
|
||||
): Result<ResidualUnknown, AnalysisError>;
|
||||
recordEvidenceWithUnknown(
|
||||
evidence: Evidence,
|
||||
input: RecordUnknownInput,
|
||||
): Result<ResidualUnknown | null, AnalysisError>;
|
||||
updateUnknown(
|
||||
input: UpdateUnknownInput,
|
||||
): Result<ResidualUnknown, AnalysisError>;
|
||||
listUnknowns(filters?: {
|
||||
readonly status?: UnknownStatus;
|
||||
readonly severity?: ResidualUnknown["severity"];
|
||||
readonly domain?: string;
|
||||
}): ResidualUnknown[];
|
||||
verifyUnknownResolution(unknownId: string): Result<
|
||||
{
|
||||
readonly valid: boolean;
|
||||
readonly truthVerified: boolean;
|
||||
readonly unknown: ResidualUnknown;
|
||||
},
|
||||
UnknownRegistryError
|
||||
>;
|
||||
providerIdentity(operation?: AnalysisOperation): ProviderIdentity;
|
||||
analysisProfile(
|
||||
operation?: AnalysisOperation,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
|
||||
import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import { createEvidence, type Evidence } from "../domain/evidence.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import type { EvidenceBundle } from "../domain/evidenceBundle.js";
|
||||
import { evidenceBundleForTarget } from "../domain/evidenceBundle.js";
|
||||
import { EvidenceIntegrityError } from "../domain/evidenceErrors.js";
|
||||
@@ -19,25 +19,24 @@ import type {
|
||||
AnalysisOperation,
|
||||
} from "./AnalysisProvider.js";
|
||||
import { AnalysisSnapshotCache } from "./AnalysisSnapshotCache.js";
|
||||
import { EvidenceLedger } from "./EvidenceLedger.js";
|
||||
import {
|
||||
UNKNOWN_REGISTRY_PROVIDER,
|
||||
unknownEvidenceLinks,
|
||||
unknownMutationEvidence,
|
||||
} from "./UnknownEvidence.js";
|
||||
import { InvestigationRecords } from "./investigation/InvestigationRecords.js";
|
||||
|
||||
export interface ActiveAnalysisBinding {
|
||||
readonly target: BinaryTarget;
|
||||
readonly profile: AnalysisProfileCommitment | null;
|
||||
}
|
||||
|
||||
/** Owns session evidence, snapshots, and residual unknowns. */
|
||||
/** Binary snapshot owner and compatibility facade for composed investigation records. */
|
||||
export abstract class BinarySessionRecords {
|
||||
readonly #evidence = new EvidenceLedger();
|
||||
readonly #records: InvestigationRecords;
|
||||
readonly #snapshot = new AnalysisSnapshotCache();
|
||||
#snapshotInvalidated = false;
|
||||
readonly #snapshotListeners = new Set<() => void | Promise<void>>();
|
||||
|
||||
constructor(records: InvestigationRecords = new InvestigationRecords()) {
|
||||
this.#records = records;
|
||||
}
|
||||
|
||||
/** Observe changes to the mutable current analysis snapshot resource. */
|
||||
onAnalysisSnapshotChanged(listener: () => void | Promise<void>): () => void {
|
||||
this.#snapshotListeners.add(listener);
|
||||
@@ -47,27 +46,27 @@ export abstract class BinarySessionRecords {
|
||||
recordEvidence(
|
||||
evidence: Evidence,
|
||||
): Result<"added" | "duplicate", EvidenceIntegrityError> {
|
||||
const recorded = this.#evidence.record(evidence);
|
||||
const recorded = this.#records.recordEvidence(evidence);
|
||||
if (recorded.ok && recorded.value === "added") this.#emitSnapshotChanged();
|
||||
return recorded;
|
||||
}
|
||||
|
||||
hasEvidence(evidenceId: string): boolean {
|
||||
return this.#evidence.has(evidenceId);
|
||||
return this.#records.hasEvidence(evidenceId);
|
||||
}
|
||||
|
||||
evidenceById(evidenceId: string): Evidence | undefined {
|
||||
return this.#evidence.get(evidenceId);
|
||||
return this.#records.evidenceById(evidenceId);
|
||||
}
|
||||
|
||||
exportEvidenceBundle(): EvidenceBundle {
|
||||
return this.#evidence.export();
|
||||
return this.#records.exportEvidenceBundle();
|
||||
}
|
||||
|
||||
importEvidenceBundle(
|
||||
bundle: unknown,
|
||||
): Result<number, EvidenceIntegrityError> {
|
||||
const imported = this.#evidence.import(bundle);
|
||||
const imported = this.#records.mergeEvidenceBundle(bundle);
|
||||
if (!imported.ok) return imported;
|
||||
if (imported.value.changed) this.#emitSnapshotChanged();
|
||||
return ok(imported.value.recordsAdded);
|
||||
@@ -95,8 +94,11 @@ export abstract class BinarySessionRecords {
|
||||
target,
|
||||
profile,
|
||||
target === undefined
|
||||
? this.#evidence.export()
|
||||
: evidenceBundleForTarget(this.#evidence.export(), target.sha256),
|
||||
? this.#records.exportEvidenceBundle()
|
||||
: evidenceBundleForTarget(
|
||||
this.#records.exportEvidenceBundle(),
|
||||
target.sha256,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -116,7 +118,7 @@ export abstract class BinarySessionRecords {
|
||||
? undefined
|
||||
: { target: active.target, profile: active.profile },
|
||||
(bundle) => {
|
||||
const imported = this.#evidence.import(bundle);
|
||||
const imported = this.#records.mergeEvidenceBundle(bundle);
|
||||
return imported.ok ? ok(imported.value.recordsAdded) : imported;
|
||||
},
|
||||
);
|
||||
@@ -175,7 +177,7 @@ export abstract class BinarySessionRecords {
|
||||
}
|
||||
|
||||
protected clearSessionRecords(): void {
|
||||
this.#evidence.clear();
|
||||
this.#records.clear();
|
||||
this.#snapshot.clear();
|
||||
this.#snapshotInvalidated = false;
|
||||
this.#emitSnapshotChanged();
|
||||
@@ -202,10 +204,7 @@ export abstract class BinarySessionRecords {
|
||||
input: RecordUnknownInput,
|
||||
): Result<ResidualUnknown, AnalysisError> {
|
||||
const target = this.activeAnalysisBinding()?.target;
|
||||
const recorded = this.#evidence.recordUnknown(
|
||||
input,
|
||||
unknownMutationEvidence(target, input),
|
||||
);
|
||||
const recorded = this.#records.recordUnknown(input, target);
|
||||
if (recorded.ok) this.#emitSnapshotChanged();
|
||||
return recorded;
|
||||
}
|
||||
@@ -214,11 +213,7 @@ export abstract class BinarySessionRecords {
|
||||
evidence: Evidence,
|
||||
input: RecordUnknownInput,
|
||||
): Result<ResidualUnknown | null, AnalysisError> {
|
||||
const recorded = this.#evidence.recordWithUnknown(
|
||||
evidence,
|
||||
input,
|
||||
unknownMutationEvidence(undefined, input),
|
||||
);
|
||||
const recorded = this.#records.recordEvidenceWithUnknown(evidence, input);
|
||||
if (recorded.ok) this.#emitSnapshotChanged();
|
||||
return recorded;
|
||||
}
|
||||
@@ -227,22 +222,7 @@ export abstract class BinarySessionRecords {
|
||||
input: UpdateUnknownInput,
|
||||
): Result<ResidualUnknown, AnalysisError> {
|
||||
const target = this.activeAnalysisBinding()?.target;
|
||||
const evidence = createEvidence(target, UNKNOWN_REGISTRY_PROVIDER, {
|
||||
predicateType: "rea.residual-unknown-mutation",
|
||||
operation: "update_unknown",
|
||||
parameters: {
|
||||
unknown_id: input.unknown_id,
|
||||
expected_revision: input.expected_revision,
|
||||
},
|
||||
result: { action: "update", status: input.status },
|
||||
confidence: "derived",
|
||||
authority: "analyst-inference",
|
||||
evidenceLinks: unknownEvidenceLinks(input),
|
||||
limitations: [
|
||||
"Registry mutation evidence records analyst intent, not proof of the answer.",
|
||||
],
|
||||
});
|
||||
const updated = this.#evidence.updateUnknown(input, evidence);
|
||||
const updated = this.#records.updateUnknown(input, target);
|
||||
if (updated.ok) this.#emitSnapshotChanged();
|
||||
return updated;
|
||||
}
|
||||
@@ -254,7 +234,7 @@ export abstract class BinarySessionRecords {
|
||||
readonly domain?: string;
|
||||
} = {},
|
||||
): ResidualUnknown[] {
|
||||
return this.#evidence.listUnknowns(filters);
|
||||
return this.#records.listUnknowns(filters);
|
||||
}
|
||||
|
||||
verifyUnknownResolution(unknownId: string): Result<
|
||||
@@ -265,6 +245,6 @@ export abstract class BinarySessionRecords {
|
||||
},
|
||||
UnknownRegistryError
|
||||
> {
|
||||
return this.#evidence.verifyUnknownResolution(unknownId);
|
||||
return this.#records.verifyUnknownResolution(unknownId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import { execFile } from "node:child_process";
|
||||
import { extname, isAbsolute, join, resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { parse as parsePlist } from "plist";
|
||||
|
||||
import { isPathWithinRoot } from "../domain/localPath.js";
|
||||
import { BinaryTargetError } from "../domain/configurationErrors.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
@@ -264,14 +266,21 @@ const resolveAppBundle = async (
|
||||
}
|
||||
};
|
||||
|
||||
/** Decode the top-level executable name with an XML parser, not a pattern. */
|
||||
const parseXmlPlistExecutable = (plist: string): string => {
|
||||
const match =
|
||||
/<key>\s*CFBundleExecutable\s*<\/key>\s*<string>([^<]+)<\/string>/u.exec(
|
||||
plist,
|
||||
);
|
||||
if (match?.[1] === undefined)
|
||||
const value = parsePlist(plist);
|
||||
const executable =
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
!(value instanceof Date) &&
|
||||
!(value instanceof Uint8Array) &&
|
||||
Object.hasOwn(value, "CFBundleExecutable")
|
||||
? value.CFBundleExecutable
|
||||
: undefined;
|
||||
if (typeof executable !== "string")
|
||||
throw new Error("CFBundleExecutable is missing");
|
||||
return decodeXml(match[1]);
|
||||
return executable;
|
||||
};
|
||||
|
||||
const readBinaryPlistExecutable = async (plistPath: string): Promise<string> =>
|
||||
@@ -295,14 +304,6 @@ const isSafeExecutableName = (name: string): boolean =>
|
||||
!name.includes("\0") &&
|
||||
!/[/\\]/u.test(name);
|
||||
|
||||
const decodeXml = (value: string): string =>
|
||||
value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll(""", '"')
|
||||
.replaceAll("'", "'");
|
||||
|
||||
const readExecutableMetadata = async (
|
||||
handle: FileHandle,
|
||||
hostArchitecture: NodeJS.Architecture,
|
||||
|
||||
@@ -333,3 +333,25 @@ describe("capability inventory: caller guidance", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("optional JavaScript recovery availability", () => {
|
||||
it("keeps optional recovery target-free and does not disable other static workflows", () => {
|
||||
expect(entry("recover_javascript_sources", status())).toMatchObject({
|
||||
available: false,
|
||||
reason: "provider_missing",
|
||||
remediation: expect.stringContaining("REA_WAKARU_COMMAND"),
|
||||
});
|
||||
expect(
|
||||
entry("recover_javascript_sources", status(), {
|
||||
processCaptureEnabled: true,
|
||||
javascriptRecoveryEnabled: true,
|
||||
}),
|
||||
).toMatchObject({
|
||||
available: true,
|
||||
surface: "application",
|
||||
});
|
||||
expect(entry("analyze_javascript_application", status())).toMatchObject({
|
||||
available: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
OptionalProviderLoadFailures,
|
||||
OptionalProviderLoadFailure,
|
||||
} from "./OptionalObservationProviders.js";
|
||||
|
||||
import {
|
||||
providerCapability,
|
||||
@@ -10,7 +14,7 @@ import {
|
||||
} from "../contracts/toolOutputSchemaPrimitives.js";
|
||||
import type { ToolKind } from "../contracts/toolContractTypes.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { GENERATED_MCP_TOOL_CATALOG } from "../generatedMcpToolCatalog.js";
|
||||
import { TOOL_CONTRACTS } from "../contracts/toolContracts.js";
|
||||
import {
|
||||
clientRequirementsFor,
|
||||
NO_CLIENT_FEATURES,
|
||||
@@ -28,9 +32,11 @@ type ToolAvailabilityReason = "available" | ToolUnavailabilityReason;
|
||||
type ProviderDescriptor = ProviderCapability;
|
||||
export type AvailabilityPolicy = {
|
||||
readonly processCaptureEnabled: boolean;
|
||||
readonly optionalProviderLoadFailures?: OptionalProviderLoadFailures;
|
||||
readonly firmwareInspectionEnabled?: boolean;
|
||||
readonly firmwareExtractionEnabled?: boolean;
|
||||
readonly androidAnalysisEnabled?: boolean;
|
||||
readonly javascriptRecoveryEnabled?: boolean;
|
||||
readonly browserObservationEnabled?: boolean;
|
||||
readonly browserScenarioEnabled?: boolean;
|
||||
readonly electronObservationEnabled?: boolean;
|
||||
@@ -126,7 +132,7 @@ export const buildCapabilityInventory = (
|
||||
const descriptors = new Map<string, ProviderDescriptor>(
|
||||
status.capabilities.map((descriptor) => [descriptor.operation, descriptor]),
|
||||
);
|
||||
return GENERATED_MCP_TOOL_CATALOG.map((contract): ToolAvailability => {
|
||||
return TOOL_CONTRACTS.map((contract): ToolAvailability => {
|
||||
const availability = availabilityFor({
|
||||
name: contract.name,
|
||||
kind: contract.kind,
|
||||
@@ -186,6 +192,12 @@ const availabilityFor = (context: AvailabilityContext): Availability => {
|
||||
return navigationContextAvailability(context.descriptors);
|
||||
const javascriptApplication = javascriptApplicationAvailability(context);
|
||||
if (javascriptApplication !== null) return javascriptApplication;
|
||||
const failure = optionalProviderFailureFor(context);
|
||||
if (failure !== undefined)
|
||||
return {
|
||||
reason: "provider_unavailable",
|
||||
remediation: `Adapter ${failure.providerId} could not load: ${failure.reason}. Repair the adapter installation and restart REA; other capabilities remain available.`,
|
||||
};
|
||||
const workflowAvailability = workflowAvailabilityFor(context);
|
||||
if (workflowAvailability !== null) return workflowAvailability;
|
||||
const targetDecision = targetAvailability(context);
|
||||
@@ -193,6 +205,23 @@ const availabilityFor = (context: AvailabilityContext): Availability => {
|
||||
return providerAvailability(context);
|
||||
};
|
||||
|
||||
const optionalProviderFailureFor = ({
|
||||
name,
|
||||
kind,
|
||||
policy,
|
||||
}: AvailabilityContext): OptionalProviderLoadFailure | undefined => {
|
||||
const failures = policy.optionalProviderLoadFailures;
|
||||
if (name === "capture_browser_scenario")
|
||||
return failures?.browserScenarioCapture;
|
||||
if (kind === "browser-provider") return failures?.browserObservation;
|
||||
if (name === "capture_electron_scenario")
|
||||
return failures?.electronActiveObservation;
|
||||
if (kind === "electron-provider") return failures?.electronObservation;
|
||||
if (kind === "runtime-provider")
|
||||
return failures?.javascriptRuntimeObservation;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const javascriptApplicationAvailability = ({
|
||||
name,
|
||||
}: AvailabilityContext): Availability | null => {
|
||||
@@ -208,6 +237,14 @@ const workflowAvailabilityFor = ({
|
||||
kind,
|
||||
policy,
|
||||
}: AvailabilityContext): Availability | null => {
|
||||
if (name === "recover_javascript_sources")
|
||||
return policy.javascriptRecoveryEnabled === true
|
||||
? { reason: "available", remediation: null }
|
||||
: {
|
||||
reason: "provider_missing",
|
||||
remediation:
|
||||
"On Linux x64, provide an absolute REA_WAKARU_COMMAND for Wakaru 1.13.0 and util-linux prlimit. No binary target is required.",
|
||||
};
|
||||
if (kind === "firmware-provider") {
|
||||
const enabled =
|
||||
name === "inspect_firmware_regions"
|
||||
@@ -218,7 +255,7 @@ const workflowAvailabilityFor = ({
|
||||
: {
|
||||
reason: "provider_missing",
|
||||
remediation:
|
||||
"On Linux, provide an absolute REA_BINWALK_COMMAND (Binwalk 3.1.0) or REA_UNBLOB_COMMAND (Unblob 26.6.4) path and util-linux prlimit. Extraction also requires the selected format’s external extractor.",
|
||||
"On Linux, provide an absolute REA_BINWALK_COMMAND (Binwalk 3.1.x, verified with 3.1.0) or REA_UNBLOB_COMMAND (Unblob 26.6.x, verified with 26.6.4) path and util-linux prlimit. Extraction also requires the selected format’s external extractor.",
|
||||
};
|
||||
}
|
||||
if (kind === "android-provider")
|
||||
|
||||
@@ -22,7 +22,13 @@ describe.each([
|
||||
|
||||
expect(
|
||||
parseClientConfiguration(`${bom}${JSON.stringify(document)}`, format),
|
||||
).toEqual({ document, servers: document[serversKey], serversKey });
|
||||
).toEqual({
|
||||
document,
|
||||
servers: document[serversKey],
|
||||
serversPath: [serversKey],
|
||||
dialect: format,
|
||||
legacyServers: {},
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["", "\uFEFF"])(
|
||||
@@ -70,12 +76,16 @@ describe.each([
|
||||
else servers.rea = registration;
|
||||
const document = { ...parsed.document, [serversKey]: servers };
|
||||
|
||||
text = serializeClientConfiguration(document, format, text, "rea");
|
||||
text = serializeClientConfiguration(document, format, text, [
|
||||
[serversKey, "rea"],
|
||||
]);
|
||||
|
||||
expect(parseClientConfiguration(text, format)).toEqual({
|
||||
document,
|
||||
servers,
|
||||
serversKey,
|
||||
serversPath: [serversKey],
|
||||
dialect: format,
|
||||
legacyServers: {},
|
||||
});
|
||||
expect(Buffer.from(text.slice(0, prefix.length))).toEqual(
|
||||
Buffer.from(prefix),
|
||||
@@ -92,14 +102,11 @@ describe.each([
|
||||
});
|
||||
|
||||
describe("TOML client configuration BOM handling", () => {
|
||||
it("retains the TOML parser's existing leading-BOM rejection", () => {
|
||||
it.each(["", "\uFEFF"])("parses a document with prefix %j", (bom) => {
|
||||
const text = '[mcp_servers.rea]\ncommand = "rea"\n';
|
||||
|
||||
expect(parseClientConfiguration(text, "toml").servers).toEqual({
|
||||
expect(parseClientConfiguration(`${bom}${text}`, "toml").servers).toEqual({
|
||||
rea: { command: "rea" },
|
||||
});
|
||||
expect(() => parseClientConfiguration(`\uFEFF${text}`, "toml")).toThrow(
|
||||
"Invalid TOML document",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,11 +14,27 @@ import type { SetupClient } from "./SupportedClients.js";
|
||||
export type ClientConfigurationFormat = NonNullable<SetupClient["format"]>;
|
||||
export type ClientServersKey = "mcp_servers" | "mcpServers" | "mcp" | "servers";
|
||||
|
||||
/**
|
||||
* Registration entry dialect. OpenCode V2 reads V1 `mcp.<name>` entries, but a
|
||||
* document already using the native `mcp.servers` table takes native entries.
|
||||
*/
|
||||
export type ClientRegistrationDialect =
|
||||
| ClientConfigurationFormat
|
||||
| "opencode_v2";
|
||||
|
||||
/** Validated client document and its server table, preserving unrelated settings. */
|
||||
export interface ClientConfigurationDocument {
|
||||
readonly document: Record<string, unknown>;
|
||||
/** Server table that registrations are written to. */
|
||||
readonly servers: Record<string, unknown>;
|
||||
readonly serversKey: ClientServersKey;
|
||||
/** Key path of `servers` in `document`. */
|
||||
readonly serversPath: readonly string[];
|
||||
readonly dialect: ClientRegistrationDialect;
|
||||
/**
|
||||
* OpenCode V1 server members beside a native V2 `mcp.servers` table. OpenCode
|
||||
* still loads them, and a native entry with the same name takes precedence.
|
||||
*/
|
||||
readonly legacyServers: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const objectSchema = z.record(z.string(), z.unknown());
|
||||
@@ -77,6 +93,9 @@ const parseDocument = (
|
||||
return objectSchema.parse(document);
|
||||
};
|
||||
|
||||
/** Members of OpenCode V2's `mcp` object that are not server names. */
|
||||
const OPENCODE_V2_MCP_SETTINGS = new Set(["servers", "timeout"]);
|
||||
|
||||
/** Parse a client document and reject malformed roots or MCP server tables. */
|
||||
export const parseClientConfiguration = (
|
||||
text: string,
|
||||
@@ -88,7 +107,75 @@ export const parseClientConfiguration = (
|
||||
const serversKey = clientConfigurationServersKey(format);
|
||||
const value = document[serversKey];
|
||||
const servers = value === undefined ? {} : objectSchema.parse(value);
|
||||
return { document, servers, serversKey };
|
||||
const native = servers.servers;
|
||||
// A V1 server named `servers` has a string `type` discriminator; the V2
|
||||
// table may instead hold a server object named `type`.
|
||||
if (
|
||||
format !== "opencode" ||
|
||||
native === undefined ||
|
||||
(typeof native === "object" &&
|
||||
native !== null &&
|
||||
"type" in native &&
|
||||
typeof native.type === "string")
|
||||
)
|
||||
return {
|
||||
document,
|
||||
servers,
|
||||
serversPath: [serversKey],
|
||||
dialect: format,
|
||||
legacyServers: {},
|
||||
};
|
||||
return {
|
||||
document,
|
||||
servers: objectSchema.parse(native),
|
||||
serversPath: [serversKey, "servers"],
|
||||
dialect: "opencode_v2",
|
||||
legacyServers: Object.fromEntries(
|
||||
Object.entries(servers).filter(
|
||||
([name]) => !OPENCODE_V2_MCP_SETTINGS.has(name),
|
||||
),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
/** The registration a client loads for `name`, including OpenCode V1 entries. */
|
||||
export const effectiveClientServer = (
|
||||
parsed: ClientConfigurationDocument,
|
||||
name: string,
|
||||
): unknown =>
|
||||
Object.hasOwn(parsed.servers, name)
|
||||
? parsed.servers[name]
|
||||
: parsed.legacyServers[name];
|
||||
|
||||
/**
|
||||
* Return `parsed.document` with `servers` at its server table. A legacy OpenCode
|
||||
* V1 member named in `removeLegacy` is removed beside a native V2 table.
|
||||
*/
|
||||
export const withClientServers = (
|
||||
parsed: ClientConfigurationDocument,
|
||||
servers: Record<string, unknown>,
|
||||
removeLegacy?: string,
|
||||
): Record<string, unknown> => {
|
||||
const [key = "", nested] = parsed.serversPath;
|
||||
if (nested === undefined) return { ...parsed.document, [key]: servers };
|
||||
const outer = { ...objectSchema.parse(parsed.document[key]) };
|
||||
if (removeLegacy !== undefined && !OPENCODE_V2_MCP_SETTINGS.has(removeLegacy))
|
||||
delete outer[removeLegacy];
|
||||
return { ...parsed.document, [key]: { ...outer, [nested]: servers } };
|
||||
};
|
||||
|
||||
/** Read the value at a key path, or `undefined` when any member is absent. */
|
||||
const valueAt = (
|
||||
document: Record<string, unknown>,
|
||||
path: readonly string[],
|
||||
): unknown => {
|
||||
let current: unknown = document;
|
||||
for (const key of path) {
|
||||
const object = objectSchema.safeParse(current);
|
||||
if (!object.success || !Object.hasOwn(object.data, key)) return undefined;
|
||||
current = object.data[key];
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
/** Serialize a validated client document, retaining JSONC comments. */
|
||||
@@ -96,33 +183,43 @@ export const serializeClientConfiguration = (
|
||||
document: Record<string, unknown>,
|
||||
format: ClientConfigurationFormat | undefined,
|
||||
originalText?: string,
|
||||
editedServerName?: string,
|
||||
editedPaths?: readonly (readonly string[])[],
|
||||
): string => {
|
||||
if (format === undefined || format === "unsupported")
|
||||
throw new TypeError("client does not have a supported MCP config format");
|
||||
if (format === "toml") return stringifyToml(document);
|
||||
if (originalText !== undefined && editedServerName !== undefined) {
|
||||
const serversKey = clientConfigurationServersKey(format);
|
||||
const servers = objectSchema.parse(document[serversKey] ?? {});
|
||||
const editedValue = Object.hasOwn(servers, editedServerName)
|
||||
? servers[editedServerName]
|
||||
: undefined;
|
||||
const edits = modify(
|
||||
if (originalText !== undefined && editedPaths !== undefined)
|
||||
return editedPaths.reduce(
|
||||
(text, path) =>
|
||||
applyEdits(
|
||||
text,
|
||||
modify(text, [...path], valueAt(document, path), {
|
||||
formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" },
|
||||
}),
|
||||
),
|
||||
originalText,
|
||||
[serversKey, editedServerName],
|
||||
editedValue,
|
||||
{
|
||||
formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" },
|
||||
},
|
||||
);
|
||||
return applyEdits(originalText, edits);
|
||||
}
|
||||
return `${JSON.stringify(document, null, 2)}\n`;
|
||||
};
|
||||
|
||||
/** Key path of a named registration in the server table. */
|
||||
export const clientServerPath = (
|
||||
parsed: ClientConfigurationDocument,
|
||||
name: string,
|
||||
): string[] => [...parsed.serversPath, name];
|
||||
|
||||
/** Key path of a present OpenCode V1 entry beside a native V2 table. */
|
||||
export const legacyClientServerPath = (
|
||||
parsed: ClientConfigurationDocument,
|
||||
name: string,
|
||||
): string[] | undefined =>
|
||||
Object.hasOwn(parsed.legacyServers, name)
|
||||
? [parsed.serversPath[0] ?? "mcp", name]
|
||||
: undefined;
|
||||
|
||||
/** Build the stdio entry shape expected by one client's configuration dialect. */
|
||||
export const clientRegistrationEntry = (
|
||||
format: ClientConfigurationFormat,
|
||||
format: ClientRegistrationDialect,
|
||||
command: readonly string[],
|
||||
environment: Readonly<Record<string, string>>,
|
||||
): Record<string, unknown> => {
|
||||
@@ -137,6 +234,15 @@ export const clientRegistrationEntry = (
|
||||
? {}
|
||||
: { environment: { ...environment } }),
|
||||
};
|
||||
case "opencode_v2":
|
||||
// V2 connects servers unless `disabled` is true.
|
||||
return {
|
||||
type: "local",
|
||||
command: [...command],
|
||||
...(Object.keys(environment).length === 0
|
||||
? {}
|
||||
: { environment: { ...environment } }),
|
||||
};
|
||||
case "vscode":
|
||||
return {
|
||||
type: "stdio",
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { parseClientConfiguration } from "./ClientConfigurationDocument.js";
|
||||
import {
|
||||
effectiveClientServer,
|
||||
parseClientConfiguration,
|
||||
} from "./ClientConfigurationDocument.js";
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
@@ -85,8 +88,10 @@ export const readClientRegistrationStatuses = async (
|
||||
continue;
|
||||
try {
|
||||
const content = await readFile(client.configPath, "utf8");
|
||||
const { servers } = parseClientConfiguration(content, client.format);
|
||||
const raw = servers[PRODUCT_IDENTITY.mcpServerKey];
|
||||
const raw = effectiveClientServer(
|
||||
parseClientConfiguration(content, client.format),
|
||||
PRODUCT_IDENTITY.mcpServerKey,
|
||||
);
|
||||
if (raw === undefined) {
|
||||
statuses.push(
|
||||
unavailableStatus(client.name, client.configPath, "missing"),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { SUPPORTED_NODE_VERSION_PROSE } from "../domain/runtimeVersion.js";
|
||||
import type { ClientRegistrationStatus } from "./ClientRegistrationStatus.js";
|
||||
import { createDoctorHostFixture as host } from "./Doctor.fixture.js";
|
||||
import { runDoctor } from "./Doctor.js";
|
||||
@@ -19,7 +21,7 @@ describe("doctor", () => {
|
||||
result.checks.map(({ name, remediation }) => [name, remediation]),
|
||||
),
|
||||
).toEqual({
|
||||
node: "Install Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+.",
|
||||
node: `Install ${SUPPORTED_NODE_VERSION_PROSE}.`,
|
||||
host: "REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, 64-bit Arch Linux, and the experimental Windows x64 Ghidra P0 boundary.",
|
||||
hopper: "Run rea setup to install Hopper, or set HOPPER_LAUNCHER_PATH.",
|
||||
target: "Supply a readable local app or program path.",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { homedir } from "node:os";
|
||||
|
||||
import { supportsNodeVersion } from "../domain/runtimeVersion.js";
|
||||
import {
|
||||
SUPPORTED_NODE_VERSION_PROSE,
|
||||
supportsNodeVersion,
|
||||
} from "../domain/runtimeVersion.js";
|
||||
import { linuxHopperLauncherPath } from "./LinuxHopper.js";
|
||||
import type {
|
||||
DoctorCheck,
|
||||
@@ -92,7 +95,7 @@ export const doctorHealthy = (
|
||||
|
||||
const nodeCheck = (host: DoctorHost): DoctorCheck =>
|
||||
check("node", supportsNodeVersion(host.nodeVersion), host.nodeVersion, {
|
||||
remediation: "Install Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+.",
|
||||
remediation: `Install ${SUPPORTED_NODE_VERSION_PROSE}.`,
|
||||
classification: "missing_dependency",
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { compareBundles } from "../domain/bundleComparison.js";
|
||||
import { EvidenceLedger } from "./EvidenceLedger.js";
|
||||
import { EvidenceLedger } from "./investigation/EvidenceLedger.js";
|
||||
import {
|
||||
readEvidenceBundle,
|
||||
writeEvidenceBundle,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { evidenceInputSchema } from "../contracts/evidenceInputContracts.js";
|
||||
import { JAVASCRIPT_FEATURE_TRACE_EXAMPLE } from "../contracts/javascriptApplicationWorkflowExamples.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { EvidenceLedger } from "./EvidenceLedger.js";
|
||||
import { EvidenceLedger } from "./investigation/EvidenceLedger.js";
|
||||
import { resolveEvidenceInput } from "./EvidenceInputResolver.js";
|
||||
|
||||
const evidence = JAVASCRIPT_FEATURE_TRACE_EXAMPLE.application;
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../domain/javascriptApplicationAnalysis.js";
|
||||
import { matchJavaScriptApplicationVersions } from "../domain/javascriptApplicationVersionKeys.js";
|
||||
import { findApplicationFeatureSeeds } from "../domain/javascriptFeatureSeed.js";
|
||||
import { traceApplicationFeatureInputSchema } from "../domain/javascriptFeatureTraceSchemas.js";
|
||||
import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js";
|
||||
|
||||
const analyzeSource = async (
|
||||
source: string,
|
||||
extraFiles: Readonly<Record<string, string>> = {},
|
||||
) => {
|
||||
const inputPath = await createTestTempDirectory("rea-js-empty-literal-");
|
||||
await writeFile(join(inputPath, "app.js"), `${source}\n`);
|
||||
for (const [path, text] of Object.entries(extraFiles))
|
||||
await writeFile(join(inputPath, path), text);
|
||||
const result = await analyzeJavaScriptApplication({
|
||||
input_path: inputPath,
|
||||
format: "directory",
|
||||
});
|
||||
if (!result.ok)
|
||||
throw new Error(`Expected analysis success: ${result.error.message}`);
|
||||
return javascriptApplicationAnalysisResultSchema.parse(
|
||||
result.value.normalized_result,
|
||||
);
|
||||
};
|
||||
|
||||
it.each([
|
||||
"fetch('');",
|
||||
"new WebSocket('');",
|
||||
"navigator.serviceWorker.register('');",
|
||||
"new Worker('');",
|
||||
"const x = require('');",
|
||||
"import y from ''; y();",
|
||||
"import('');",
|
||||
"localStorage.getItem('');",
|
||||
"localStorage.setItem('', 1);",
|
||||
"indexedDB.open('');",
|
||||
"win.loadFile('');",
|
||||
"win.loadURL('');",
|
||||
"app.get('', handler);",
|
||||
"process.on('', () => 1);",
|
||||
"const { EventEmitter } = require('events'); new EventEmitter().on('', () => 1);",
|
||||
"const { spawn } = require('child_process'); spawn('/bin/x').on('', () => 1);",
|
||||
"const { ipcMain } = require('electron'); ipcMain.handle('', () => 1);",
|
||||
"const { ipcRenderer } = require('electron'); ipcRenderer.invoke('');",
|
||||
"const { contextBridge } = require('electron'); contextBridge.exposeInMainWorld('', {});",
|
||||
])("analyzes the legal empty string literal in %s", async (source) => {
|
||||
await expect(analyzeSource(source)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("keeps exact values for empty literals without synthetic application labels", async () => {
|
||||
const output = await analyzeSource(
|
||||
"fetch(''); fetch('\"\"'); fetch('/api'); process.on('', () => 1);",
|
||||
);
|
||||
const endpoints = output.graph.nodes
|
||||
.filter(({ kind }) => kind === "endpoint")
|
||||
.map(({ identity, observations }) => ({
|
||||
key: identity.strategy === "artifact-local-key" ? identity.key : null,
|
||||
labels: observations.map(({ label }) => label),
|
||||
values: observations.map(({ properties }) => properties.value),
|
||||
}));
|
||||
// The empty endpoint has no display label, so it cannot be confused with
|
||||
// a literal two-quote endpoint; both keep exact identities.
|
||||
expect(endpoints).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ key: "", labels: [null], values: [""] },
|
||||
{ key: '""', labels: ['""'], values: ['""'] },
|
||||
{ key: "/api", labels: ["/api"], values: ["/api"] },
|
||||
]),
|
||||
);
|
||||
expect(endpoints).toHaveLength(3);
|
||||
expect(
|
||||
output.semantic_graph?.nodes
|
||||
.filter(({ kind }) => kind === "event")
|
||||
.map(({ label, properties }) => ({
|
||||
label,
|
||||
eventName: properties.event_name,
|
||||
})),
|
||||
).toEqual([{ label: '""', eventName: "" }]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ version: 3, sources: [""], names: [], mappings: "AAAA" },
|
||||
{
|
||||
version: 3,
|
||||
sections: [
|
||||
{
|
||||
offset: { line: 0, column: 0 },
|
||||
map: { version: 3, sources: [""], names: [], mappings: "AAAA" },
|
||||
},
|
||||
],
|
||||
},
|
||||
])("projects an empty source-map source name: %j", async (map) => {
|
||||
const output = await analyzeSource("//# sourceMappingURL=app.js.map", {
|
||||
"app.js.map": JSON.stringify(map),
|
||||
});
|
||||
const originals = output.graph.nodes
|
||||
.filter(({ kind }) => kind === "source-module")
|
||||
.map(({ identity, observations }) => ({
|
||||
originalSource:
|
||||
identity.strategy === "source-map-original"
|
||||
? identity.original_source
|
||||
: null,
|
||||
sources: observations.map(({ properties }) => properties.source),
|
||||
}));
|
||||
expect(originals).toEqual([{ originalSource: "", sources: [""] }]);
|
||||
});
|
||||
|
||||
it("keeps empty and two-quote source-map names distinct", async () => {
|
||||
const output = await analyzeSource("//# sourceMappingURL=app.js.map", {
|
||||
"app.js.map": JSON.stringify({
|
||||
version: 3,
|
||||
sources: ["", '""'],
|
||||
names: [],
|
||||
mappings: "AAAA,CCAA",
|
||||
}),
|
||||
});
|
||||
expect(
|
||||
output.graph.nodes
|
||||
.filter(({ kind }) => kind === "source-module")
|
||||
.map(({ identity }) =>
|
||||
identity.strategy === "source-map-original"
|
||||
? identity.original_source
|
||||
: null,
|
||||
)
|
||||
.sort(),
|
||||
).toEqual(["", '""']);
|
||||
});
|
||||
|
||||
it("does not match an empty endpoint with a literal two-quote seed", async () => {
|
||||
const output = await analyzeSource("fetch(''); fetch('\"\"');");
|
||||
const endpoints = new Map(
|
||||
output.graph.nodes
|
||||
.filter(({ kind }) => kind === "endpoint")
|
||||
.map((node) => [
|
||||
node.node_id,
|
||||
node.identity.strategy === "artifact-local-key"
|
||||
? node.identity.key
|
||||
: null,
|
||||
]),
|
||||
);
|
||||
const matches = findApplicationFeatureSeeds(output.graph.nodes, {
|
||||
kind: "string",
|
||||
value: '""',
|
||||
match: "exact",
|
||||
case_sensitive: true,
|
||||
}).filter(({ node_id: nodeId }) => endpoints.has(nodeId));
|
||||
expect(matches.map(({ node_id: nodeId }) => endpoints.get(nodeId))).toEqual([
|
||||
'""',
|
||||
]);
|
||||
});
|
||||
|
||||
const IPC_SOURCE =
|
||||
"const { ipcMain } = require('electron'); ipcMain.handle('', () => 1); ipcMain.handle('\"\"', () => 2);";
|
||||
|
||||
const ipcChannels = (
|
||||
nodes: Awaited<ReturnType<typeof analyzeSource>>["graph"]["nodes"],
|
||||
) =>
|
||||
new Map(
|
||||
nodes
|
||||
.filter(({ kind }) => kind === "ipc-channel")
|
||||
.map((node) => [node.node_id, node.observations[0]?.properties.channel]),
|
||||
);
|
||||
|
||||
it("selects an empty IPC channel with an exact empty seed", async () => {
|
||||
const output = await analyzeSource(IPC_SOURCE);
|
||||
const channels = ipcChannels(output.graph.nodes);
|
||||
const matches = findApplicationFeatureSeeds(output.graph.nodes, {
|
||||
kind: "channel",
|
||||
value: "",
|
||||
match: "exact",
|
||||
case_sensitive: true,
|
||||
});
|
||||
expect(matches.map(({ node_id: nodeId }) => channels.get(nodeId))).toEqual([
|
||||
"",
|
||||
]);
|
||||
});
|
||||
|
||||
const seedSchema = traceApplicationFeatureInputSchema.shape.seed;
|
||||
const validateAdvertisedSeed = new Ajv2020({
|
||||
strict: false,
|
||||
validateFormats: false,
|
||||
}).compile(z.toJSONSchema(seedSchema, { io: "input" }));
|
||||
|
||||
it.each([
|
||||
[{ kind: "channel", value: "", match: "exact" }, true],
|
||||
[{ kind: "string", value: "", match: "exact" }, true],
|
||||
[{ kind: "channel", value: "/x" }, true],
|
||||
[{ kind: "string", value: "" }, false],
|
||||
[{ kind: "channel", value: "" }, false],
|
||||
[{ kind: "channel", value: "", match: "contains" }, false],
|
||||
[{ kind: "node-id", value: "", match: "exact" }, false],
|
||||
] as const)(
|
||||
"validates empty seed %j at runtime and in the advertised schema",
|
||||
(seed, accepted) => {
|
||||
expect(seedSchema.safeParse(seed).success).toBe(accepted);
|
||||
expect(validateAdvertisedSeed(seed)).toBe(accepted);
|
||||
},
|
||||
);
|
||||
|
||||
it("pairs the same empty IPC channel across application versions", async () => {
|
||||
const left = await analyzeSource(IPC_SOURCE);
|
||||
const right = await analyzeSource(`${IPC_SOURCE} // v2`);
|
||||
const leftChannels = ipcChannels(left.graph.nodes);
|
||||
const rightChannels = ipcChannels(right.graph.nodes);
|
||||
const matching = matchJavaScriptApplicationVersions(
|
||||
left.graph.nodes,
|
||||
right.graph.nodes,
|
||||
);
|
||||
expect(
|
||||
matching.pairs
|
||||
.filter(({ left: node }) => node.kind === "ipc-channel")
|
||||
.map(({ left: leftNode, right: rightNode }) => [
|
||||
leftChannels.get(leftNode.node_id),
|
||||
rightChannels.get(rightNode.node_id),
|
||||
]),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
["", ""],
|
||||
['""', '""'],
|
||||
]),
|
||||
);
|
||||
expect(matching.unmatchedLeft.map(({ kind }) => kind)).not.toContain(
|
||||
"ipc-channel",
|
||||
);
|
||||
});
|
||||
|
||||
it("selects an empty context-bridge API with an exact empty seed", async () => {
|
||||
const output = await analyzeSource(
|
||||
"const { contextBridge } = require('electron'); contextBridge.exposeInMainWorld('', {}); contextBridge.exposeInMainWorld('\"\"', {});",
|
||||
);
|
||||
const apis = new Map(
|
||||
output.graph.nodes
|
||||
.filter(({ kind }) => kind === "context-bridge-api")
|
||||
.map((node) => [node.node_id, node.observations[0]?.properties.api_key]),
|
||||
);
|
||||
const matches = findApplicationFeatureSeeds(output.graph.nodes, {
|
||||
kind: "api",
|
||||
value: "",
|
||||
match: "exact",
|
||||
case_sensitive: true,
|
||||
});
|
||||
expect(matches.map(({ node_id: nodeId }) => apis.get(nodeId))).toEqual([""]);
|
||||
});
|
||||
@@ -75,7 +75,12 @@ export const analyzeJavaScriptApplicationValidated = async (
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof ArtifactReaderFailure)
|
||||
return err(
|
||||
new ArtifactOperationError(OPERATION, cause.reason, cause.details),
|
||||
new ArtifactOperationError(
|
||||
OPERATION,
|
||||
cause.reason,
|
||||
cause.details,
|
||||
cause.message,
|
||||
),
|
||||
);
|
||||
if (cause instanceof z.ZodError)
|
||||
return err(
|
||||
|
||||
@@ -11,8 +11,8 @@ export class JavaScriptArtifactGraphAccumulator {
|
||||
readonly #edges = new Map<string, ApplicationEdge>();
|
||||
|
||||
/** Create or merge one canonical node. */
|
||||
addNode(input: unknown): ApplicationNode {
|
||||
const created = createJavaScriptApplicationNode(input);
|
||||
addNode<Input extends DisplayableNodeInput>(input: Input): ApplicationNode {
|
||||
const created = createJavaScriptApplicationNode(displayableNode(input));
|
||||
const existing = this.#nodes.get(created.node_id);
|
||||
if (existing === undefined) {
|
||||
this.#nodes.set(created.node_id, created);
|
||||
@@ -64,3 +64,25 @@ const observationInput = (
|
||||
} = observation;
|
||||
return input;
|
||||
};
|
||||
|
||||
/** Node fields whose display form is normalized before validation. */
|
||||
interface DisplayableNodeInput {
|
||||
readonly observations: readonly { readonly label: string | null }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Static findings can carry legal empty strings, such as `fetch("")`,
|
||||
* `require("")`, or a source map's `sources: [""]`. Labels must be nonempty,
|
||||
* so an empty value has no label rather than synthetic text that a real value
|
||||
* could share; literal feature seeds match labels. Identities and observation
|
||||
* properties keep the exact value.
|
||||
*/
|
||||
const displayableNode = (
|
||||
input: DisplayableNodeInput,
|
||||
): DisplayableNodeInput => ({
|
||||
...input,
|
||||
observations: input.observations.map((observation) => ({
|
||||
...observation,
|
||||
label: observation.label === "" ? null : observation.label,
|
||||
})),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import type { JavaScriptRecoveryInput } from "../domain/javascriptRecovery.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import type {
|
||||
AnalysisExecution,
|
||||
ExecutionOptions,
|
||||
} from "./AnalysisProvider.js";
|
||||
|
||||
/** Typed boundary for upstream source recovery without exposing its protocol. */
|
||||
export interface JavaScriptRecoveryPort {
|
||||
recover(
|
||||
input: JavaScriptRecoveryInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>>;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import type {
|
||||
JavaScriptRecoveryInput,
|
||||
JavaScriptRecoveryResult,
|
||||
} from "../domain/javascriptRecovery.js";
|
||||
import { createAnalysisExecution } from "./AnalysisProvider.js";
|
||||
import { JavaScriptRecoveryService } from "./JavaScriptRecoveryService.js";
|
||||
|
||||
const artifact = { path: "/tmp/bundle.js", sha256: "a".repeat(64), bytes: 20 };
|
||||
const recoveryResult = (): JavaScriptRecoveryResult => ({
|
||||
source: {
|
||||
...artifact,
|
||||
snapshot_path: "/tmp/work/inputs/bundle.js",
|
||||
published_copy: artifact,
|
||||
},
|
||||
engine: {
|
||||
id: "fixture-recovery",
|
||||
version: "1",
|
||||
executable: artifact,
|
||||
audited_source_revision: "pinned",
|
||||
executed_source_revision: null,
|
||||
},
|
||||
options: { extraction_mode: "structural", rewrite_level: "standard" },
|
||||
status: "complete",
|
||||
detected_formats: [],
|
||||
reported_safety: "normal",
|
||||
reported_strategy: "structural",
|
||||
reported_format: "unknown",
|
||||
total: 0,
|
||||
failed: 0,
|
||||
modules: [],
|
||||
warnings: [],
|
||||
report: artifact,
|
||||
provenance: artifact,
|
||||
manifest: artifact,
|
||||
analysis_input: null,
|
||||
runtime_equivalence: "unknown",
|
||||
limitations: ["Not executed"],
|
||||
});
|
||||
const providerIdentity = {
|
||||
id: "fixture-recovery",
|
||||
name: "Fixture recovery",
|
||||
version: "1",
|
||||
};
|
||||
const input = { path: artifact.path, output_directory: "/tmp/output" };
|
||||
|
||||
describe("JavaScript recovery workflow", () => {
|
||||
it("passes normalized requests to a typed port and returns derived Evidence", async () => {
|
||||
const received: JavaScriptRecoveryInput[] = [];
|
||||
const service = new JavaScriptRecoveryService({
|
||||
recover: async (request) => {
|
||||
received.push(request);
|
||||
return ok(
|
||||
createAnalysisExecution(recoveryResult(), providerIdentity, {
|
||||
subject: { ...artifact, format: "javascript" },
|
||||
limitations: ["Not executed"],
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
const result = await service.recover(input);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(received).toEqual([
|
||||
{ ...input, extraction_mode: "structural", rewrite_level: "standard" },
|
||||
]);
|
||||
expect(result.value).toMatchObject({
|
||||
operation: "recover_javascript_sources",
|
||||
confidence: "derived",
|
||||
authority: "shipped-artifact",
|
||||
provider: providerIdentity,
|
||||
});
|
||||
});
|
||||
it("preserves a missing provider reason", async () => {
|
||||
const failure = new AnalysisCapabilityUnavailableError(
|
||||
"fixture-recovery",
|
||||
"recover_javascript_sources",
|
||||
"fixture missing engine",
|
||||
);
|
||||
const result = await new JavaScriptRecoveryService({
|
||||
recover: async () => err(failure),
|
||||
}).recover(input);
|
||||
expect(result).toEqual(err(failure));
|
||||
});
|
||||
it.each([
|
||||
"invalid-result",
|
||||
"missing-subject",
|
||||
"subject-mismatch",
|
||||
"engine-mismatch",
|
||||
])("rejects %s returned across the port", async (mode) => {
|
||||
const service = new JavaScriptRecoveryService({
|
||||
recover: async () =>
|
||||
ok(
|
||||
createAnalysisExecution(
|
||||
mode === "invalid-result" ? {} : recoveryResult(),
|
||||
mode === "engine-mismatch"
|
||||
? { ...providerIdentity, id: "another-engine" }
|
||||
: providerIdentity,
|
||||
mode === "missing-subject"
|
||||
? {}
|
||||
: {
|
||||
subject: {
|
||||
path:
|
||||
mode === "subject-mismatch" ? "/wrong.js" : artifact.path,
|
||||
sha256: artifact.sha256,
|
||||
format: "javascript" as const,
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
});
|
||||
const result = await service.recover(input);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("expected invalid port output");
|
||||
expect(result.error._tag).toBe("AnalysisOutputError");
|
||||
});
|
||||
it.each(["path", "output_directory"])(
|
||||
"rejects relative %s before acquiring the recovery engine",
|
||||
async (field) => {
|
||||
let invoked = false;
|
||||
const service = new JavaScriptRecoveryService({
|
||||
recover: async () => {
|
||||
invoked = true;
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"fixture",
|
||||
"recover_javascript_sources",
|
||||
"engine missing",
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
const result = await service.recover({
|
||||
...input,
|
||||
[field]: "relative.js",
|
||||
});
|
||||
expect(invoked).toBe(false);
|
||||
if (result.ok) throw new Error("expected invalid path");
|
||||
expect(result.error._tag).toBe("AnalysisInputError");
|
||||
expect(result.error).toMatchObject({
|
||||
issues: [{ path: [field], reason: "invalid_format" }],
|
||||
});
|
||||
},
|
||||
);
|
||||
it("rejects malformed and pre-cancelled input before acquiring the port", async () => {
|
||||
const service = new JavaScriptRecoveryService({
|
||||
recover: async () => {
|
||||
throw new Error("port must not execute");
|
||||
},
|
||||
});
|
||||
const invalid = await service.recover({});
|
||||
if (invalid.ok) throw new Error("expected malformed input");
|
||||
expect(invalid.error._tag).toBe("AnalysisInputError");
|
||||
const cancelled = await service.recover(input, {
|
||||
signal: AbortSignal.abort(),
|
||||
});
|
||||
if (cancelled.ok) throw new Error("expected cancellation");
|
||||
expect(cancelled.error._tag).toBe("AnalysisCancelledError");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { isAbsolute } from "node:path";
|
||||
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { createEvidence, type Evidence } from "../domain/evidence.js";
|
||||
import {
|
||||
javascriptRecoveryInputSchema,
|
||||
javascriptRecoveryResultSchema,
|
||||
} from "../domain/javascriptRecovery.js";
|
||||
import { jsonObjectSchema } from "../domain/jsonValue.js";
|
||||
import { projectInputIssues } from "../domain/inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type { ExecutionOptions } from "./AnalysisProvider.js";
|
||||
import type { JavaScriptRecoveryPort } from "./JavaScriptRecoveryPort.js";
|
||||
|
||||
const OPERATION = "recover_javascript_sources";
|
||||
|
||||
/** Shared CLI/MCP parsing and Evidence for derived JavaScript artifacts. */
|
||||
export class JavaScriptRecoveryService {
|
||||
constructor(readonly provider: JavaScriptRecoveryPort) {}
|
||||
|
||||
/** Recover sources and bind the result to its original immutable input. */
|
||||
async recover(
|
||||
input: unknown,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
if (options?.signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
const parsed = javascriptRecoveryInputSchema.safeParse(input);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
new AnalysisInputError(
|
||||
OPERATION,
|
||||
{ cause: parsed.error },
|
||||
projectInputIssues(parsed.error.issues, input),
|
||||
),
|
||||
);
|
||||
const invalidPaths = (["path", "output_directory"] as const).filter(
|
||||
(field) => !isAbsolute(parsed.data[field]),
|
||||
);
|
||||
if (invalidPaths.length > 0)
|
||||
return err(
|
||||
new AnalysisInputError(
|
||||
OPERATION,
|
||||
undefined,
|
||||
invalidPaths.map((field) => ({
|
||||
path: [field],
|
||||
reason: "invalid_format",
|
||||
message: `${field} must be an absolute filesystem path on this host.`,
|
||||
})),
|
||||
),
|
||||
);
|
||||
const execution = await this.provider.recover(parsed.data, options);
|
||||
if (!execution.ok) return execution;
|
||||
const result = javascriptRecoveryResultSchema.safeParse(
|
||||
execution.value.result,
|
||||
);
|
||||
if (!result.success || execution.value.subject === null)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"Recovery did not return a valid result bound to the original source",
|
||||
),
|
||||
);
|
||||
if (
|
||||
result.data.source.path !== parsed.data.path ||
|
||||
execution.value.subject.path !== result.data.source.path ||
|
||||
execution.value.subject.sha256 !== result.data.source.sha256 ||
|
||||
execution.value.provider.id !== result.data.engine.id ||
|
||||
execution.value.provider.version !== result.data.engine.version
|
||||
)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"Recovery source or engine identity disagrees with its Evidence binding",
|
||||
),
|
||||
);
|
||||
return ok(
|
||||
createEvidence(execution.value.subject, execution.value.provider, {
|
||||
operation: OPERATION,
|
||||
parameters: jsonObjectSchema.parse(parsed.data),
|
||||
result: result.data,
|
||||
rawResult: execution.value.rawResult,
|
||||
locations: execution.value.locations,
|
||||
limitations: execution.value.limitations,
|
||||
confidence: "derived",
|
||||
authority: "shipped-artifact",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -349,6 +349,48 @@ it.each([
|
||||
);
|
||||
},
|
||||
);
|
||||
it("keeps exact empty environment keys distinct from dynamic keys", () => {
|
||||
const graph = graphFor(`
|
||||
const empty = process.env[''];
|
||||
const named = process.env.HOME;
|
||||
const dynamic = process.env[name];
|
||||
`);
|
||||
const sources = graph.nodes
|
||||
.filter(({ kind }) => kind === "config-source")
|
||||
.map(({ label, properties }) => ({ label, key: properties.key }));
|
||||
expect(sources).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ label: '""', key: "" },
|
||||
{ label: "HOME", key: "HOME" },
|
||||
{ label: "environment", key: null },
|
||||
]),
|
||||
);
|
||||
expect(sources).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("labels exact empty method names without inventing computed names", () => {
|
||||
const graph = graphFor(`
|
||||
const target = {
|
||||
['']() { return 42; },
|
||||
['""']() { return 2; },
|
||||
[key]() { return 1; },
|
||||
};
|
||||
`);
|
||||
const functions = graph.nodes
|
||||
.filter(({ kind }) => kind === "function")
|
||||
.map(({ label, properties }) => ({ label, name: properties.name }));
|
||||
// Both exact names display as `""`; only `properties.name` tells them apart.
|
||||
expect(functions).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ label: '""', name: "" },
|
||||
{ label: '""', name: '""' },
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
functions.filter(({ label }) => label?.startsWith("[computed@")),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"Promise.resolve(1)",
|
||||
"(Promise.resolve(1) as Promise<number>)",
|
||||
|
||||
@@ -167,6 +167,8 @@ const projectFile = (
|
||||
location: callable.location,
|
||||
label: callable.name,
|
||||
functionNodeId: null,
|
||||
// The label is display text; keep the exact name, which may be "".
|
||||
properties: { name: callable.name },
|
||||
},
|
||||
state,
|
||||
),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { JavaScriptApplicationGraph } from "../domain/javascriptApplicationGraph.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { javascriptDisplayText } from "../domain/javascriptAstValues.js";
|
||||
import {
|
||||
createJavaScriptSemanticGraphNode,
|
||||
createJavaScriptSemanticGraphRelation,
|
||||
@@ -77,7 +78,7 @@ export const constructSemanticGraphNode = (
|
||||
},
|
||||
function_node_id: input.functionNodeId,
|
||||
application_node_ids: matchingApplicationNodeIds(file, input, state),
|
||||
label: input.label,
|
||||
label: input.label === null ? null : javascriptDisplayText(input.label),
|
||||
properties: input.properties ?? {},
|
||||
evidence: observedSemanticEvidence(file, input.location),
|
||||
});
|
||||
|
||||
@@ -116,7 +116,7 @@ export const semanticPropertySlot = (
|
||||
kind: "property-slot",
|
||||
roleKey: `property:${objectBindingId}:${name}`,
|
||||
location: null,
|
||||
label: name === "" ? '""' : name,
|
||||
label: name,
|
||||
functionNodeId:
|
||||
context.bindingNodes.get(objectBindingId)?.function_node_id ?? null,
|
||||
properties: {
|
||||
|
||||
@@ -40,12 +40,12 @@ export class LazyAnalysisProvider implements AnalysisProvider {
|
||||
this.#loadProvider = options.load;
|
||||
}
|
||||
|
||||
/** Return generated provider identity without loading its implementation. */
|
||||
/** Return source-declared provider identity without loading its implementation. */
|
||||
identity(): ProviderIdentity {
|
||||
return this.#providerIdentity;
|
||||
}
|
||||
|
||||
/** Return generated capability metadata without loading its implementation. */
|
||||
/** Return source-declared capability metadata without loading its implementation. */
|
||||
capabilities(): readonly CapabilityDescriptor[] {
|
||||
return this.#capabilityDescriptors;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { BrowserObservationPort } from "./BrowserObservationPort.js";
|
||||
import type { BrowserScenarioCapturePort } from "./BrowserScenarioCapturePort.js";
|
||||
import type { ElectronObservationPort } from "./ElectronObservationPort.js";
|
||||
import type { ElectronActiveObservationPort } from "./ElectronActiveObservationPort.js";
|
||||
import type { JavaScriptRuntimeObservationPort } from "./JavaScriptRuntimeObservationPort.js";
|
||||
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
|
||||
|
||||
/** Existing observation ports loaded independently at MCP startup. */
|
||||
export interface OptionalObservationProviders {
|
||||
readonly browserObservation?: BrowserObservationPort;
|
||||
readonly browserScenarioCapture?: BrowserScenarioCapturePort;
|
||||
readonly electronObservation?: ElectronObservationPort;
|
||||
readonly electronActiveObservation?: ElectronActiveObservationPort;
|
||||
readonly javascriptRuntimeObservation?: JavaScriptRuntimeObservationPort;
|
||||
}
|
||||
|
||||
/** A module failed before its port could be constructed; no engine was opened. */
|
||||
export interface OptionalProviderLoadFailure {
|
||||
readonly providerId: string;
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
export type OptionalProviderLoadFailures = Readonly<
|
||||
Partial<
|
||||
Record<keyof OptionalObservationProviders, OptionalProviderLoadFailure>
|
||||
>
|
||||
>;
|
||||
|
||||
/** Successful ports and failed peers from one independent loading attempt. */
|
||||
export interface OptionalProviderLoadResult extends OptionalObservationProviders {
|
||||
readonly optionalProviderLoadFailures?: OptionalProviderLoadFailures;
|
||||
}
|
||||
|
||||
/** Preserve the module's failure reason through the existing tool error algebra. */
|
||||
export const optionalProviderUnavailable = (
|
||||
failure: OptionalProviderLoadFailure,
|
||||
operation: string,
|
||||
): AnalysisCapabilityUnavailableError =>
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
failure.providerId,
|
||||
operation,
|
||||
`Adapter could not load: ${failure.reason}`,
|
||||
);
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { SUPPORTED_NODE_VERSION_PROSE } from "../domain/runtimeVersion.js";
|
||||
import { FakeSetupHost, options } from "./Setup.fixture.js";
|
||||
import { runSetup } from "./Setup.js";
|
||||
|
||||
@@ -122,7 +123,7 @@ describe("setup scoped readiness", () => {
|
||||
const nodeHost = new FakeSetupHost();
|
||||
nodeHost.nodeVersion = "20.0.0";
|
||||
expect((await runSetup(options(true), nodeHost)).remediation).toBe(
|
||||
"Install Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+ and rerun setup.",
|
||||
`Install ${SUPPORTED_NODE_VERSION_PROSE} and rerun setup.`,
|
||||
);
|
||||
|
||||
const macHost = new FakeSetupHost();
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import {
|
||||
clientRegistrationEntry,
|
||||
clientConfigurationValuesEqual,
|
||||
clientServerPath,
|
||||
legacyClientServerPath,
|
||||
parseClientConfiguration,
|
||||
serializeClientConfiguration,
|
||||
withClientServers,
|
||||
type ClientConfigurationDocument,
|
||||
type ClientRegistrationDialect,
|
||||
} from "./ClientConfigurationDocument.js";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
@@ -82,20 +86,13 @@ const configureClientDocument = async (
|
||||
void cause;
|
||||
return { status: "failed", reason: "readback" };
|
||||
}
|
||||
const { document, servers, serversKey } = parsed;
|
||||
const desired = clientConfigurationDesired(
|
||||
client,
|
||||
environment,
|
||||
command,
|
||||
format,
|
||||
parsed.dialect,
|
||||
);
|
||||
if (
|
||||
clientConfigurationValuesEqual(
|
||||
servers[PRODUCT_IDENTITY.mcpServerKey],
|
||||
desired,
|
||||
)
|
||||
)
|
||||
return { status: "unchanged" };
|
||||
if (registrationCurrent(parsed, desired)) return { status: "unchanged" };
|
||||
const backupPath =
|
||||
original === undefined ? undefined : `${client.configPath}.rea.backup`;
|
||||
if (
|
||||
@@ -103,20 +100,25 @@ const configureClientDocument = async (
|
||||
!(await preserveConfigBackup(transactionPath, backupPath))
|
||||
)
|
||||
return { status: "failed", reason: "backup" };
|
||||
document[serversKey] = {
|
||||
...servers,
|
||||
[PRODUCT_IDENTITY.mcpServerKey]: desired,
|
||||
};
|
||||
// A native OpenCode V2 entry replaces REA's legacy V1 entry, which would
|
||||
// otherwise conflict with it.
|
||||
const legacyPath = legacyClientServerPath(
|
||||
parsed,
|
||||
PRODUCT_IDENTITY.mcpServerKey,
|
||||
);
|
||||
const document = withClientServers(
|
||||
parsed,
|
||||
{ ...parsed.servers, [PRODUCT_IDENTITY.mcpServerKey]: desired },
|
||||
PRODUCT_IDENTITY.mcpServerKey,
|
||||
);
|
||||
try {
|
||||
await mkdir(dirname(client.configPath), { recursive: true });
|
||||
await writeFileAtomic(
|
||||
transactionPath,
|
||||
serializeClientConfiguration(
|
||||
document,
|
||||
format,
|
||||
original,
|
||||
PRODUCT_IDENTITY.mcpServerKey,
|
||||
),
|
||||
serializeClientConfiguration(document, format, original, [
|
||||
clientServerPath(parsed, PRODUCT_IDENTITY.mcpServerKey),
|
||||
...(legacyPath === undefined ? [] : [legacyPath]),
|
||||
]),
|
||||
{
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
@@ -132,12 +134,7 @@ const configureClientDocument = async (
|
||||
await readFile(transactionPath, "utf8"),
|
||||
format,
|
||||
);
|
||||
if (
|
||||
!clientConfigurationValuesEqual(
|
||||
readback.servers[PRODUCT_IDENTITY.mcpServerKey],
|
||||
desired,
|
||||
)
|
||||
) {
|
||||
if (!registrationCurrent(readback, desired)) {
|
||||
await restoreConfig(transactionPath, original);
|
||||
return { status: "failed", reason: "readback" };
|
||||
}
|
||||
@@ -159,18 +156,17 @@ export const clientConfigurationAligned = async (
|
||||
providerEnvironment: SetupProviderEnvironment,
|
||||
command: readonly string[],
|
||||
): Promise<boolean> => {
|
||||
const desired = clientConfigurationDesired(
|
||||
client,
|
||||
providerEnvironment,
|
||||
command,
|
||||
client.format,
|
||||
);
|
||||
try {
|
||||
const original = await readFile(client.configPath, "utf8");
|
||||
const { servers } = parseClientConfiguration(original, client.format);
|
||||
return clientConfigurationValuesEqual(
|
||||
servers[PRODUCT_IDENTITY.mcpServerKey],
|
||||
desired,
|
||||
const parsed = parseClientConfiguration(original, client.format);
|
||||
return registrationCurrent(
|
||||
parsed,
|
||||
clientConfigurationDesired(
|
||||
client,
|
||||
providerEnvironment,
|
||||
command,
|
||||
parsed.dialect,
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
// Unreadable configuration is treated as not aligned so setup repairs it.
|
||||
@@ -206,20 +202,15 @@ export const inspectClientConfiguration = async (
|
||||
"The configuration file could not be read. Check its permissions before rerunning setup.",
|
||||
};
|
||||
}
|
||||
const desired = clientConfigurationDesired(
|
||||
client,
|
||||
providerEnvironment,
|
||||
command,
|
||||
client.format,
|
||||
);
|
||||
try {
|
||||
const { servers } = parseClientConfiguration(original, client.format);
|
||||
if (
|
||||
clientConfigurationValuesEqual(
|
||||
servers[PRODUCT_IDENTITY.mcpServerKey],
|
||||
desired,
|
||||
)
|
||||
)
|
||||
const parsed = parseClientConfiguration(original, client.format);
|
||||
const desired = clientConfigurationDesired(
|
||||
client,
|
||||
providerEnvironment,
|
||||
command,
|
||||
parsed.dialect,
|
||||
);
|
||||
if (registrationCurrent(parsed, desired))
|
||||
return { status: "already_current" };
|
||||
} catch (cause: unknown) {
|
||||
// Malformed configuration is reported with the invalid remediation.
|
||||
@@ -263,11 +254,21 @@ const restoreConfig = async (
|
||||
}
|
||||
};
|
||||
|
||||
/** Whether REA's entry matches and no conflicting legacy entry remains. */
|
||||
const registrationCurrent = (
|
||||
parsed: ClientConfigurationDocument,
|
||||
desired: unknown,
|
||||
): boolean =>
|
||||
clientConfigurationValuesEqual(
|
||||
parsed.servers[PRODUCT_IDENTITY.mcpServerKey],
|
||||
desired,
|
||||
) && !Object.hasOwn(parsed.legacyServers, PRODUCT_IDENTITY.mcpServerKey);
|
||||
|
||||
const clientConfigurationDesired = (
|
||||
client: SetupClient,
|
||||
providerEnvironment: SetupProviderEnvironment,
|
||||
command: readonly string[],
|
||||
format: NonNullable<SetupClient["format"]> | undefined,
|
||||
format: ClientRegistrationDialect | undefined,
|
||||
) => {
|
||||
const environment = Object.fromEntries(
|
||||
Object.entries(providerEnvironment).sort(([left], [right]) =>
|
||||
|
||||
@@ -3,7 +3,10 @@ import { homedir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { supportsNodeVersion } from "../domain/runtimeVersion.js";
|
||||
import {
|
||||
SUPPORTED_NODE_VERSION_PROSE,
|
||||
supportsNodeVersion,
|
||||
} from "../domain/runtimeVersion.js";
|
||||
import { runDoctor, systemDoctorHost, type DoctorHost } from "./Doctor.js";
|
||||
import { installLinuxHopper, readLinuxDistribution } from "./LinuxHopper.js";
|
||||
import { installMacHopper } from "./MacHopper.js";
|
||||
@@ -63,7 +66,7 @@ export const hostRemediation = async (
|
||||
installHopper: boolean,
|
||||
): Promise<string | undefined> => {
|
||||
if (!supportsNodeVersion(host.nodeVersion))
|
||||
return "Install Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+ and rerun setup.";
|
||||
return `Install ${SUPPORTED_NODE_VERSION_PROSE} and rerun setup.`;
|
||||
if (!installHopper) return undefined;
|
||||
if (host.platform !== "darwin" && host.platform !== "linux")
|
||||
return "REA supports Hopper on macOS and selected 64-bit Linux distributions.";
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import {
|
||||
parseClientConfiguration,
|
||||
serializeClientConfiguration,
|
||||
type ClientServersKey,
|
||||
clientServerPath,
|
||||
legacyClientServerPath,
|
||||
withClientServers,
|
||||
type ClientConfigurationDocument,
|
||||
} from "./ClientConfigurationDocument.js";
|
||||
import { copyFile, lstat, readFile, realpath, rm } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
@@ -141,15 +144,9 @@ const removeClient = async (
|
||||
"Configuration could not be read. Check file permissions, then rerun uninstall.",
|
||||
);
|
||||
}
|
||||
let document: Record<string, unknown>;
|
||||
let key: ClientServersKey;
|
||||
let servers: Record<string, unknown>;
|
||||
let parsed: ClientConfigurationDocument;
|
||||
try {
|
||||
({
|
||||
document,
|
||||
servers,
|
||||
serversKey: key,
|
||||
} = parseClientConfiguration(original, client.format));
|
||||
parsed = parseClientConfiguration(original, client.format);
|
||||
} catch (cause: unknown) {
|
||||
void cause;
|
||||
return item(
|
||||
@@ -158,18 +155,34 @@ const removeClient = async (
|
||||
`Configuration is not valid ${client.format === "toml" ? "TOML" : client.format === "opencode" ? "JSONC" : "JSON"} and was not changed. Repair it, then rerun uninstall.`,
|
||||
);
|
||||
}
|
||||
const registration = servers[PRODUCT_IDENTITY.mcpServerKey];
|
||||
if (registration === undefined)
|
||||
const name = PRODUCT_IDENTITY.mcpServerKey;
|
||||
// Beside a native OpenCode V2 table, OpenCode also loads a V1 `mcp.rea`.
|
||||
const legacyPath = legacyClientServerPath(parsed, name);
|
||||
const hasNative = Object.hasOwn(parsed.servers, name);
|
||||
if (!hasNative && legacyPath === undefined)
|
||||
return item(client.name, "skipped", "REA registration is absent.");
|
||||
if (!isOwnedRegistration(registration, client))
|
||||
const removeNative =
|
||||
hasNative && isOwnedRegistration(parsed.servers[name], client);
|
||||
const removeLegacy =
|
||||
legacyPath !== undefined &&
|
||||
isOwnedRegistration(parsed.legacyServers[name], client);
|
||||
if (!removeNative && !removeLegacy)
|
||||
return item(
|
||||
client.name,
|
||||
"retained",
|
||||
"The rea-named registration is not REA-owned.",
|
||||
);
|
||||
const remaining = { ...servers };
|
||||
delete remaining[PRODUCT_IDENTITY.mcpServerKey];
|
||||
document[key] = remaining;
|
||||
const remaining = { ...parsed.servers };
|
||||
if (removeNative) delete remaining[name];
|
||||
const document = withClientServers(
|
||||
parsed,
|
||||
remaining,
|
||||
removeLegacy ? name : undefined,
|
||||
);
|
||||
const editedPaths = [
|
||||
...(removeNative ? [clientServerPath(parsed, name)] : []),
|
||||
...(removeLegacy ? [legacyPath] : []),
|
||||
];
|
||||
const backupPath = `${client.configPath}.rea.backup`;
|
||||
try {
|
||||
await fileSystem.copy(transactionPath, backupPath);
|
||||
@@ -188,14 +201,17 @@ const removeClient = async (
|
||||
document,
|
||||
client.format,
|
||||
original,
|
||||
PRODUCT_IDENTITY.mcpServerKey,
|
||||
editedPaths,
|
||||
),
|
||||
);
|
||||
const readback = parseClientConfiguration(
|
||||
await fileSystem.readText(transactionPath),
|
||||
client.format,
|
||||
);
|
||||
if (PRODUCT_IDENTITY.mcpServerKey in readback.servers)
|
||||
if (
|
||||
(removeNative && Object.hasOwn(readback.servers, name)) ||
|
||||
(removeLegacy && Object.hasOwn(readback.legacyServers, name))
|
||||
)
|
||||
throw new Error("registration readback mismatch");
|
||||
return item(
|
||||
client.name,
|
||||
|
||||
@@ -5,7 +5,10 @@ import { z } from "zod";
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
|
||||
import { readClientRegistrationStatuses } from "./ClientRegistrationStatus.js";
|
||||
import { parseClientConfiguration } from "./ClientConfigurationDocument.js";
|
||||
import {
|
||||
effectiveClientServer,
|
||||
parseClientConfiguration,
|
||||
} from "./ClientConfigurationDocument.js";
|
||||
import { supportedClients } from "./SupportedClients.js";
|
||||
import type { SetupAction } from "./SetupTypes.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
@@ -60,7 +63,7 @@ export const existingMaintenanceScope = async (
|
||||
continue;
|
||||
const client = supported.find(({ name }) => name === registration.client);
|
||||
if (client === undefined) continue;
|
||||
const { servers } = parseClientConfiguration(
|
||||
const parsed = parseClientConfiguration(
|
||||
await readFile(client.configPath, "utf8"),
|
||||
client.format,
|
||||
);
|
||||
@@ -69,7 +72,7 @@ export const existingMaintenanceScope = async (
|
||||
enabled: z.boolean().optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
})
|
||||
.parse(servers[PRODUCT_IDENTITY.mcpServerKey]);
|
||||
.parse(effectiveClientServer(parsed, PRODUCT_IDENTITY.mcpServerKey));
|
||||
if (enabled.enabled !== false && enabled.disabled !== true)
|
||||
clients.push(client.name);
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,19 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { EvidenceLedger } from "./EvidenceLedger.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
import { createEvidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
createEvidenceBundle,
|
||||
parseEvidenceBundle,
|
||||
serializeEvidenceBundle,
|
||||
} from "../domain/evidenceBundle.js";
|
||||
} from "../../domain/evidenceBundle.js";
|
||||
import {
|
||||
createResidualUnknown,
|
||||
recordUnknownInputSchema,
|
||||
updateUnknownInputSchema,
|
||||
type RecordUnknownInput,
|
||||
type ResidualUnknown,
|
||||
} from "../domain/residualUnknown.js";
|
||||
} from "../../domain/residualUnknown.js";
|
||||
|
||||
const provider = { id: "fixture", name: "Fixture", version: "1" };
|
||||
const ledger = (): EvidenceLedger => new EvidenceLedger();
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
import { createEvidenceBundle } from "../domain/evidenceBundle.js";
|
||||
import { recordUnknownInputSchema } from "../domain/residualUnknown.js";
|
||||
import type { BinaryTarget } from "../../domain/binaryTarget.js";
|
||||
import { createEvidence } from "../../domain/evidence.js";
|
||||
import { createEvidenceBundle } from "../../domain/evidenceBundle.js";
|
||||
import { recordUnknownInputSchema } from "../../domain/residualUnknown.js";
|
||||
import { EvidenceLedger } from "./EvidenceLedger.js";
|
||||
|
||||
const TARGET: BinaryTarget = {
|
||||
@@ -5,10 +5,10 @@ import {
|
||||
parseEvidenceBundle,
|
||||
validateResidualUnknownAddition,
|
||||
type EvidenceBundle,
|
||||
} from "../domain/evidenceBundle.js";
|
||||
import { EvidenceIntegrityError } from "../domain/evidenceErrors.js";
|
||||
import { UnknownRegistryError } from "../domain/unknownRegistryError.js";
|
||||
import { parseEvidence, type Evidence } from "../domain/evidence.js";
|
||||
} from "../../domain/evidenceBundle.js";
|
||||
import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
|
||||
import { UnknownRegistryError } from "../../domain/unknownRegistryError.js";
|
||||
import { parseEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
createResidualUnknown,
|
||||
updateResidualUnknown,
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
type ResidualUnknown,
|
||||
type UnknownStatus,
|
||||
type UpdateUnknownInput,
|
||||
} from "../domain/residualUnknown.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
} from "../../domain/residualUnknown.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
|
||||
type EvidenceLedgerFailure = EvidenceIntegrityError;
|
||||
type RecordResult = Result<"added" | "duplicate", EvidenceLedgerFailure>;
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Evidence } from "../../domain/evidence.js";
|
||||
import type { EvidenceBundle } from "../../domain/evidenceBundle.js";
|
||||
import type { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type { UnknownRegistryError } from "../../domain/unknownRegistryError.js";
|
||||
import type {
|
||||
RecordUnknownInput,
|
||||
ResidualUnknown,
|
||||
UnknownStatus,
|
||||
UpdateUnknownInput,
|
||||
} from "../../domain/residualUnknown.js";
|
||||
import type { Result } from "../../domain/result.js";
|
||||
|
||||
/** Detached investigation Evidence reads without target execution or lifecycle authority. */
|
||||
export interface EvidenceReader {
|
||||
hasEvidence(evidenceId: string): boolean;
|
||||
evidenceById(evidenceId: string): Evidence | undefined;
|
||||
exportEvidenceBundle(): EvidenceBundle;
|
||||
}
|
||||
|
||||
/** Idempotent investigation Evidence writes without target execution. */
|
||||
export interface EvidenceWriter {
|
||||
recordEvidence(
|
||||
evidence: Evidence,
|
||||
): Result<"added" | "duplicate", EvidenceIntegrityError>;
|
||||
}
|
||||
|
||||
/** Atomic derived Evidence and linked Unknown writes. */
|
||||
export interface EvidenceUnknownWriter {
|
||||
recordEvidenceWithUnknown(
|
||||
evidence: Evidence,
|
||||
input: RecordUnknownInput,
|
||||
): Result<ResidualUnknown | null, AnalysisError>;
|
||||
}
|
||||
|
||||
/** Optimistic Unknown revisions and consistency verification. */
|
||||
export interface UnknownRegistryPort {
|
||||
recordUnknown(
|
||||
input: RecordUnknownInput,
|
||||
): Result<ResidualUnknown, AnalysisError>;
|
||||
updateUnknown(
|
||||
input: UpdateUnknownInput,
|
||||
): Result<ResidualUnknown, AnalysisError>;
|
||||
listUnknowns(filters?: {
|
||||
readonly status?: UnknownStatus;
|
||||
readonly severity?: ResidualUnknown["severity"];
|
||||
readonly domain?: string;
|
||||
}): ResidualUnknown[];
|
||||
verifyUnknownResolution(unknownId: string): Result<
|
||||
{
|
||||
readonly valid: boolean;
|
||||
readonly truthVerified: boolean;
|
||||
readonly unknown: ResidualUnknown;
|
||||
},
|
||||
UnknownRegistryError
|
||||
>;
|
||||
}
|
||||
|
||||
/** Compatibility record surface; binary execution and snapshots remain separate. */
|
||||
export interface InvestigationRecordPort
|
||||
extends
|
||||
EvidenceReader,
|
||||
EvidenceWriter,
|
||||
EvidenceUnknownWriter,
|
||||
UnknownRegistryPort {
|
||||
importEvidenceBundle(bundle: unknown): Result<number, EvidenceIntegrityError>;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
ANALYSIS_SNAPSHOT_TARGET as target,
|
||||
ANALYSIS_SNAPSHOT_PROVIDER as provider,
|
||||
} from "../../domain/analysisSnapshot.fixture.js";
|
||||
import { createEvidence } from "../../domain/evidence.js";
|
||||
import { createEvidenceBundle } from "../../domain/evidenceBundle.js";
|
||||
import {
|
||||
recordUnknownInputSchema,
|
||||
updateUnknownInputSchema,
|
||||
} from "../../domain/residualUnknown.js";
|
||||
import { unknownMutationEvidence } from "./UnknownEvidence.js";
|
||||
import { InvestigationRecords } from "./InvestigationRecords.js";
|
||||
|
||||
const unknownInput = (question: string) =>
|
||||
recordUnknownInputSchema.parse({
|
||||
question,
|
||||
severity: "medium",
|
||||
domain: "investigation",
|
||||
required_authority: "analyst-inference",
|
||||
required_confidence: "derived",
|
||||
required_environment: null,
|
||||
recommended_probes: [],
|
||||
relationships: [],
|
||||
});
|
||||
const evidence = createEvidence(undefined, provider, {
|
||||
operation: "inspect-fixture",
|
||||
parameters: {},
|
||||
result: true,
|
||||
confidence: "derived",
|
||||
authority: "analyst-inference",
|
||||
});
|
||||
|
||||
describe("investigation record ownership", () => {
|
||||
it("keeps identity, detached reads and independent stores without a binary target", () => {
|
||||
const records = new InvestigationRecords();
|
||||
expect(records.recordEvidence(evidence)).toEqual({
|
||||
ok: true,
|
||||
value: "added",
|
||||
});
|
||||
expect(records.recordEvidence(evidence)).toEqual({
|
||||
ok: true,
|
||||
value: "duplicate",
|
||||
});
|
||||
const read = records.evidenceById(evidence.evidence_id);
|
||||
if (read === undefined) throw new Error("Missing recorded Evidence");
|
||||
Reflect.set(read, "operation", "forged");
|
||||
expect(records.evidenceById(evidence.evidence_id)).toEqual(evidence);
|
||||
expect(new InvestigationRecords().hasEvidence(evidence.evidence_id)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves explicit Unknown subjects and target-free atomic mutation subjects", () => {
|
||||
const records = new InvestigationRecords();
|
||||
const input = unknownInput("Which target supplies this observation?");
|
||||
const bound = records.recordUnknown(input, target);
|
||||
expect(bound).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
scope_digest: target.sha256,
|
||||
mutation_evidence_ids: [
|
||||
unknownMutationEvidence(target, input).evidence_id,
|
||||
],
|
||||
},
|
||||
});
|
||||
const independent = records.recordEvidenceWithUnknown(evidence, input);
|
||||
expect(independent).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
scope_digest: null,
|
||||
mutation_evidence_ids: [
|
||||
unknownMutationEvidence(undefined, input).evidence_id,
|
||||
],
|
||||
},
|
||||
});
|
||||
const mutation = records.evidenceById(
|
||||
unknownMutationEvidence(undefined, input).evidence_id,
|
||||
);
|
||||
expect(mutation?.subject).toBeNull();
|
||||
expect(records.listUnknowns()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("rolls back Evidence when the linked Unknown fails validation", () => {
|
||||
const records = new InvestigationRecords();
|
||||
const invalid = {
|
||||
...unknownInput("Is the missing Evidence present?"),
|
||||
supporting_evidence_ids: [`ev_${"f".repeat(64)}`],
|
||||
};
|
||||
expect(records.recordEvidenceWithUnknown(evidence, invalid).ok).toBe(false);
|
||||
expect(records.hasEvidence(evidence.evidence_id)).toBe(false);
|
||||
expect(records.exportEvidenceBundle()).toEqual(createEvidenceBundle([]));
|
||||
});
|
||||
|
||||
it("preserves optimistic revisions and the explicit update subject", () => {
|
||||
const records = new InvestigationRecords();
|
||||
const created = records.recordUnknown(
|
||||
unknownInput("Can this revision be updated?"),
|
||||
target,
|
||||
);
|
||||
if (!created.ok) throw created.error;
|
||||
const input = updateUnknownInputSchema.parse({
|
||||
...created.value,
|
||||
expected_revision: created.value.revision,
|
||||
status: "investigating",
|
||||
});
|
||||
const updated = records.updateUnknown(input, target);
|
||||
expect(updated).toMatchObject({
|
||||
ok: true,
|
||||
value: { revision: 2, scope_digest: target.sha256 },
|
||||
});
|
||||
const bundle = records.exportEvidenceBundle();
|
||||
expect(
|
||||
bundle.records.find(({ operation }) => operation === "update_unknown")
|
||||
?.subject?.digest.sha256,
|
||||
).toBe(target.sha256);
|
||||
expect(records.updateUnknown(input, target).ok).toBe(false);
|
||||
expect(records.exportEvidenceBundle()).toEqual(bundle);
|
||||
});
|
||||
|
||||
it("reports Unknown-only bundle changes and clears the single record owner", () => {
|
||||
const source = new InvestigationRecords();
|
||||
expect(
|
||||
source.recordUnknown(
|
||||
unknownInput("Can imported Unknowns notify observers?"),
|
||||
).ok,
|
||||
).toBe(true);
|
||||
const bundle = source.exportEvidenceBundle();
|
||||
const records = new InvestigationRecords();
|
||||
expect(
|
||||
records.mergeEvidenceBundle(createEvidenceBundle(bundle.records)).ok,
|
||||
).toBe(true);
|
||||
expect(records.mergeEvidenceBundle(bundle)).toEqual({
|
||||
ok: true,
|
||||
value: { recordsAdded: 0, unknownsAdded: 1, changed: true },
|
||||
});
|
||||
expect(records.mergeEvidenceBundle(bundle)).toEqual({
|
||||
ok: true,
|
||||
value: { recordsAdded: 0, unknownsAdded: 0, changed: false },
|
||||
});
|
||||
records.clear();
|
||||
expect(records.exportEvidenceBundle()).toEqual(createEvidenceBundle([]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { BinaryTarget } from "../../domain/binaryTarget.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import type { EvidenceBundle } from "../../domain/evidenceBundle.js";
|
||||
import type { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type { UnknownRegistryError } from "../../domain/unknownRegistryError.js";
|
||||
import type {
|
||||
RecordUnknownInput,
|
||||
ResidualUnknown,
|
||||
UnknownStatus,
|
||||
UpdateUnknownInput,
|
||||
} from "../../domain/residualUnknown.js";
|
||||
import type { Result } from "../../domain/result.js";
|
||||
import { EvidenceLedger, type EvidenceImportDelta } from "./EvidenceLedger.js";
|
||||
import {
|
||||
UNKNOWN_REGISTRY_PROVIDER,
|
||||
unknownEvidenceLinks,
|
||||
unknownMutationEvidence,
|
||||
} from "./UnknownEvidence.js";
|
||||
import type {
|
||||
EvidenceReader,
|
||||
EvidenceWriter,
|
||||
EvidenceUnknownWriter,
|
||||
UnknownRegistryPort,
|
||||
} from "./InvestigationRecordPort.js";
|
||||
|
||||
/** One investigation's validated record owner, independent of binary execution. */
|
||||
export class InvestigationRecords
|
||||
implements
|
||||
EvidenceReader,
|
||||
EvidenceWriter,
|
||||
EvidenceUnknownWriter,
|
||||
UnknownRegistryPort
|
||||
{
|
||||
readonly #evidence = new EvidenceLedger();
|
||||
|
||||
/** Record immutable Evidence idempotently; reject conflicting content. */
|
||||
recordEvidence(
|
||||
evidence: Evidence,
|
||||
): Result<"added" | "duplicate", EvidenceIntegrityError> {
|
||||
return this.#evidence.record(evidence);
|
||||
}
|
||||
|
||||
/** Check one semantic Evidence ID without exposing stored values. */
|
||||
hasEvidence(evidenceId: string): boolean {
|
||||
return this.#evidence.has(evidenceId);
|
||||
}
|
||||
|
||||
/** Return a detached Evidence value for one semantic ID. */
|
||||
evidenceById(evidenceId: string): Evidence | undefined {
|
||||
return this.#evidence.get(evidenceId);
|
||||
}
|
||||
|
||||
/** Export a detached deterministic bundle of Evidence and Unknown revisions. */
|
||||
exportEvidenceBundle(): EvidenceBundle {
|
||||
return this.#evidence.export();
|
||||
}
|
||||
|
||||
/** Atomically merge a bundle and report changes for the owner's post-commit observers. */
|
||||
mergeEvidenceBundle(
|
||||
bundle: unknown,
|
||||
): Result<EvidenceImportDelta, EvidenceIntegrityError> {
|
||||
return this.#evidence.import(bundle);
|
||||
}
|
||||
|
||||
/** Clear records when the owning runtime applies its existing lifecycle policy. */
|
||||
clear(): void {
|
||||
this.#evidence.clear();
|
||||
}
|
||||
|
||||
/** Record an Unknown with an explicitly supplied Evidence subject, if any. */
|
||||
recordUnknown(
|
||||
input: RecordUnknownInput,
|
||||
target?: BinaryTarget,
|
||||
): Result<ResidualUnknown, AnalysisError> {
|
||||
const recorded = this.#evidence.recordUnknown(
|
||||
input,
|
||||
unknownMutationEvidence(target, input),
|
||||
);
|
||||
return recorded;
|
||||
}
|
||||
|
||||
/** Commit derived Evidence and its Unknown atomically without an implicit subject. */
|
||||
recordEvidenceWithUnknown(
|
||||
evidence: Evidence,
|
||||
input: RecordUnknownInput,
|
||||
): Result<ResidualUnknown | null, AnalysisError> {
|
||||
const recorded = this.#evidence.recordWithUnknown(
|
||||
evidence,
|
||||
input,
|
||||
unknownMutationEvidence(undefined, input),
|
||||
);
|
||||
return recorded;
|
||||
}
|
||||
|
||||
/** Update an optimistic revision with an explicitly supplied Evidence subject. */
|
||||
updateUnknown(
|
||||
input: UpdateUnknownInput,
|
||||
target?: BinaryTarget,
|
||||
): Result<ResidualUnknown, AnalysisError> {
|
||||
const evidence = createEvidence(target, UNKNOWN_REGISTRY_PROVIDER, {
|
||||
predicateType: "rea.residual-unknown-mutation",
|
||||
operation: "update_unknown",
|
||||
parameters: {
|
||||
unknown_id: input.unknown_id,
|
||||
expected_revision: input.expected_revision,
|
||||
},
|
||||
result: { action: "update", status: input.status },
|
||||
confidence: "derived",
|
||||
authority: "analyst-inference",
|
||||
evidenceLinks: unknownEvidenceLinks(input),
|
||||
limitations: [
|
||||
"Registry mutation evidence records analyst intent, not proof of the answer.",
|
||||
],
|
||||
});
|
||||
const updated = this.#evidence.updateUnknown(input, evidence);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Return detached current Unknown revisions matching the selected filters. */
|
||||
listUnknowns(
|
||||
filters: {
|
||||
readonly status?: UnknownStatus;
|
||||
readonly severity?: ResidualUnknown["severity"];
|
||||
readonly domain?: string;
|
||||
} = {},
|
||||
): ResidualUnknown[] {
|
||||
return this.#evidence.listUnknowns(filters);
|
||||
}
|
||||
|
||||
/** Verify registry consistency without asserting the answer is true. */
|
||||
verifyUnknownResolution(unknownId: string): Result<
|
||||
{
|
||||
readonly valid: boolean;
|
||||
readonly truthVerified: boolean;
|
||||
readonly unknown: ResidualUnknown;
|
||||
},
|
||||
UnknownRegistryError
|
||||
> {
|
||||
return this.#evidence.verifyUnknownResolution(unknownId);
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -1,10 +1,10 @@
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import { createEvidence, type Evidence } from "../domain/evidence.js";
|
||||
import type { BinaryTarget } from "../../domain/binaryTarget.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import type {
|
||||
RecordUnknownInput,
|
||||
UpdateUnknownInput,
|
||||
} from "../domain/residualUnknown.js";
|
||||
import type { ProviderIdentity } from "./AnalysisProvider.js";
|
||||
} from "../../domain/residualUnknown.js";
|
||||
import type { ProviderIdentity } from "../AnalysisProvider.js";
|
||||
|
||||
/** Provider identity for analyst-authored residual-unknown mutations. */
|
||||
export const UNKNOWN_REGISTRY_PROVIDER: ProviderIdentity = {
|
||||
@@ -4,10 +4,9 @@ import { HopperProvider } from "../hopper/HopperProvider.js";
|
||||
import { GhidraProvider } from "../ghidra/GhidraProvider.js";
|
||||
import { IdaProvider } from "../ida/IdaProvider.js";
|
||||
import { silentLogger, type Logger } from "../logger.js";
|
||||
import { GENERATED_AUXILIARY_PROVIDERS } from "../generatedMcpToolCatalog.js";
|
||||
import { auxiliaryAnalysisProviderDeclarations } from "../composition/auxiliaryAnalysisProviders.js";
|
||||
import { AnalysisProviderRegistry } from "./AnalysisProviderRegistry.js";
|
||||
import { composeBinarySession } from "./BinarySessionComposition.js";
|
||||
import { nativeHostCapabilities } from "../native/NativeHostCapabilities.js";
|
||||
import { LazyAnalysisProvider } from "./LazyAnalysisProvider.js";
|
||||
import { ManagedStaticProvider } from "../dotnet/ManagedStaticProvider.js";
|
||||
import { SessionProviderRouter } from "./SessionProviderRouter.js";
|
||||
@@ -24,47 +23,14 @@ export const createBinarySession = (
|
||||
const hopper = new HopperProvider(config, logger);
|
||||
const ghidra = new GhidraProvider(config, logger);
|
||||
const ida = new IdaProvider(config);
|
||||
const auxiliary = new Map(
|
||||
GENERATED_AUXILIARY_PROVIDERS.map((provider) => [
|
||||
provider.identity.id,
|
||||
provider,
|
||||
]),
|
||||
);
|
||||
const lazyProvider = (
|
||||
id: string,
|
||||
load: ConstructorParameters<typeof LazyAnalysisProvider>[0]["load"],
|
||||
) => {
|
||||
const generated = auxiliary.get(id);
|
||||
if (generated === undefined)
|
||||
throw new TypeError(`Missing generated provider metadata for ${id}`);
|
||||
return new LazyAnalysisProvider({
|
||||
...generated,
|
||||
capabilities: nativeHostCapabilities(generated.capabilities),
|
||||
load,
|
||||
});
|
||||
};
|
||||
return composeBinarySession(
|
||||
new AnalysisProviderRegistry(
|
||||
[hopper, ghidra, ida],
|
||||
config.analysisProvider,
|
||||
),
|
||||
[
|
||||
lazyProvider("rea-artifact-graph", async () => {
|
||||
const { ArtifactProvider } =
|
||||
await import("../artifacts/ArtifactProvider.js");
|
||||
return new ArtifactProvider();
|
||||
}),
|
||||
lazyProvider("native-macos", async () => {
|
||||
const { NativeMacOSProvider } =
|
||||
await import("../native/NativeMacOSProvider.js");
|
||||
return new NativeMacOSProvider();
|
||||
}),
|
||||
lazyProvider("rea-dotnet-static", async () => {
|
||||
const { ManagedStaticProvider } =
|
||||
await import("../dotnet/ManagedStaticProvider.js");
|
||||
return new ManagedStaticProvider();
|
||||
}),
|
||||
],
|
||||
auxiliaryAnalysisProviderDeclarations().map(
|
||||
(declaration) => new LazyAnalysisProvider(declaration),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export const normalizeArtifactPath = (input: string): string => {
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
"Artifact path is absolute or unsafe",
|
||||
`Artifact path is absolute or unsafe: ${JSON.stringify(input)}`,
|
||||
);
|
||||
const normalized = input.normalize("NFC").replace(/\/+$/u, "");
|
||||
const parts = normalized.split("/");
|
||||
@@ -23,7 +23,10 @@ export const normalizeArtifactPath = (input: string): string => {
|
||||
parts.some((part) => part === "" || part === "." || part === "..") ||
|
||||
posix.normalize(normalized) !== normalized
|
||||
)
|
||||
throw new ArtifactReaderFailure("path", "Artifact path is not normalized");
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
`Artifact path is not normalized: ${JSON.stringify(input)}`,
|
||||
);
|
||||
return normalized;
|
||||
};
|
||||
|
||||
@@ -45,7 +48,7 @@ export class ArtifactPathRegistry {
|
||||
if (child !== undefined && child.spelling !== part)
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
`Artifact path collision: ${path}`,
|
||||
`Artifact path collision: ${path} differs only in case from ${[...parts.slice(0, index), child.spelling].join("/")}`,
|
||||
);
|
||||
if (child === undefined) {
|
||||
child = { spelling: part, kind: undefined, children: new Map() };
|
||||
|
||||
@@ -27,54 +27,20 @@ import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { interfaceBuilderLimitsSchema } from "../domain/interfaceBuilderGraph.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
import { ARTIFACT_GRAPH_PROVIDER } from "../application/InvestigationProviders.js";
|
||||
import {
|
||||
ARTIFACT_PROVIDER_IDENTITY as IDENTITY,
|
||||
artifactCapabilities,
|
||||
} from "./ArtifactProviderMetadata.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
import { createArtifactInspection } from "../domain/artifactInspection.js";
|
||||
import { resolveArtifactIntegrityPolicy } from "../application/ArtifactInventory/policy.js";
|
||||
|
||||
const IDENTITY: ProviderIdentity = Object.freeze(ARTIFACT_GRAPH_PROVIDER);
|
||||
|
||||
/** Read-only inventory and exclusively owned extraction provider. */
|
||||
export class ArtifactProvider implements AnalysisProvider {
|
||||
readonly #capabilities: readonly CapabilityDescriptor[];
|
||||
|
||||
constructor(platform: NodeJS.Platform = process.platform) {
|
||||
this.#capabilities = Object.freeze(
|
||||
ARTIFACT_ANALYSIS_OPERATIONS.map((operation) => {
|
||||
const common = {
|
||||
provider: IDENTITY,
|
||||
operation,
|
||||
effects: Object.freeze({
|
||||
mutatesArtifact: false,
|
||||
launchesProcess:
|
||||
operation !== "decode_interface_builder" &&
|
||||
operation !== "inspect_keyed_archive",
|
||||
mayShowUi: false,
|
||||
mayAccessNetwork: false,
|
||||
mayWriteFilesystem:
|
||||
operation === "extract_artifact" ||
|
||||
operation === "inspect_artifact" ||
|
||||
operation === "inventory_artifact",
|
||||
changesPermissions: false,
|
||||
requiresRoot: false,
|
||||
}),
|
||||
limitations: Object.freeze([
|
||||
"DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",
|
||||
"ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only.",
|
||||
]),
|
||||
};
|
||||
return Object.freeze(
|
||||
operation === "inspect_asset_catalog" && platform !== "darwin"
|
||||
? {
|
||||
...common,
|
||||
available: false as const,
|
||||
availabilityCode: "unsupported_host" as const,
|
||||
reason: "Apple asset catalogs require macOS assetutil.",
|
||||
}
|
||||
: { ...common, available: true as const, reason: null },
|
||||
);
|
||||
}),
|
||||
);
|
||||
this.#capabilities = artifactCapabilities(platform);
|
||||
}
|
||||
|
||||
identity(): ProviderIdentity {
|
||||
@@ -343,7 +309,12 @@ const translateFailure = (
|
||||
cause: unknown,
|
||||
): AnalysisError => {
|
||||
if (cause instanceof ArtifactReaderFailure)
|
||||
return new ArtifactOperationError(operation, cause.reason, cause.details);
|
||||
return new ArtifactOperationError(
|
||||
operation,
|
||||
cause.reason,
|
||||
cause.details,
|
||||
cause.message,
|
||||
);
|
||||
return new ArtifactOperationError(operation, "io");
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type {
|
||||
CapabilityDescriptor,
|
||||
ProviderIdentity,
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import { ARTIFACT_GRAPH_PROVIDER } from "../application/InvestigationProviders.js";
|
||||
import { ARTIFACT_ANALYSIS_OPERATIONS } from "../contracts/artifactToolContracts.js";
|
||||
|
||||
/** Identity of artifact inventory and owned extraction observations. */
|
||||
export const ARTIFACT_PROVIDER_IDENTITY: ProviderIdentity = Object.freeze(
|
||||
ARTIFACT_GRAPH_PROVIDER,
|
||||
);
|
||||
const IDENTITY = ARTIFACT_PROVIDER_IDENTITY;
|
||||
|
||||
/** Declare artifact coverage without constructing readers or acquiring resources. */
|
||||
export const artifactCapabilities = (
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): readonly CapabilityDescriptor[] =>
|
||||
Object.freeze(
|
||||
ARTIFACT_ANALYSIS_OPERATIONS.map((operation) => {
|
||||
const common = {
|
||||
provider: IDENTITY,
|
||||
operation,
|
||||
effects: Object.freeze({
|
||||
mutatesArtifact: false,
|
||||
launchesProcess:
|
||||
operation !== "decode_interface_builder" &&
|
||||
operation !== "inspect_keyed_archive",
|
||||
mayShowUi: false,
|
||||
mayAccessNetwork: false,
|
||||
mayWriteFilesystem:
|
||||
operation === "extract_artifact" ||
|
||||
operation === "inspect_artifact" ||
|
||||
operation === "inventory_artifact",
|
||||
changesPermissions: false,
|
||||
requiresRoot: false,
|
||||
}),
|
||||
limitations: Object.freeze([
|
||||
"DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",
|
||||
"ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only.",
|
||||
]),
|
||||
};
|
||||
return Object.freeze(
|
||||
operation === "inspect_asset_catalog" && platform !== "darwin"
|
||||
? {
|
||||
...common,
|
||||
available: false as const,
|
||||
availabilityCode: "unsupported_host" as const,
|
||||
reason: "Apple asset catalogs require macOS assetutil.",
|
||||
}
|
||||
: { ...common, available: true as const, reason: null },
|
||||
);
|
||||
}),
|
||||
);
|
||||
@@ -3,6 +3,7 @@ import { TextDecoder } from "node:util";
|
||||
import { parse, parseBinary } from "plist";
|
||||
import { z } from "zod";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { projectPlistValue } from "../domain/plistValue.js";
|
||||
import {
|
||||
keyedArchiveInputSchema,
|
||||
keyedArchiveResultSchema,
|
||||
@@ -50,27 +51,11 @@ const decodeXmlPlistText = (bytes: Buffer): string => {
|
||||
return text;
|
||||
};
|
||||
|
||||
const normalizePlist = (value: unknown, depth = 0): JsonValue => {
|
||||
if (depth > 128) throw new RangeError("Plist exceeds 128 nesting levels");
|
||||
if (value instanceof Uint8Array)
|
||||
return {
|
||||
$plist_type: "data",
|
||||
base64: Buffer.from(value).toString("base64"),
|
||||
};
|
||||
if (value instanceof Date)
|
||||
return { $plist_type: "date", iso8601: value.toISOString() };
|
||||
if (Array.isArray(value))
|
||||
return value.map((item: unknown) => normalizePlist(item, depth + 1));
|
||||
if (value !== null && typeof value === "object")
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [
|
||||
key,
|
||||
normalizePlist(item, depth + 1),
|
||||
]),
|
||||
);
|
||||
return z
|
||||
.union([z.string(), z.number().finite(), z.boolean(), z.null()])
|
||||
.parse(value);
|
||||
const normalizePlist = (value: unknown): JsonValue => {
|
||||
const projected = projectPlistValue(value);
|
||||
if (projected.unknownRealCount > 0)
|
||||
throw new RangeError("Keyed archive contains a non-finite real");
|
||||
return projected.value;
|
||||
};
|
||||
|
||||
/** Read exactly one regular, contained bundle entry without following symlinks. */
|
||||
|
||||
@@ -64,11 +64,8 @@ import { captureCdpScreenshot } from "./CdpScreenshot.js";
|
||||
import { comparePngScreenshots } from "./PngVisualDiff.js";
|
||||
|
||||
/** Public identity committed by passive browser observations. */
|
||||
export const CDP_BROWSER_PROVIDER_IDENTITY: ProviderIdentity = Object.freeze({
|
||||
id: "rea-cdp-browser",
|
||||
name: "REA Chrome DevTools Protocol observation provider",
|
||||
version: "2",
|
||||
});
|
||||
export { CDP_BROWSER_PROVIDER_IDENTITY } from "./providerIdentities.js";
|
||||
import { CDP_BROWSER_PROVIDER_IDENTITY } from "./providerIdentities.js";
|
||||
const IDENTITY = CDP_BROWSER_PROVIDER_IDENTITY;
|
||||
const CLEANUP_DOMAINS = ["Network", "Debugger", "Runtime", "Page"] as const;
|
||||
|
||||
|
||||
@@ -30,11 +30,8 @@ import { inspectCdpElectronPage } from "./CdpElectronInspection.js";
|
||||
import { authorizedElectronFile } from "./ElectronFileScope.js";
|
||||
|
||||
/** Public identity committed by passive Electron observations. */
|
||||
export const CDP_ELECTRON_PROVIDER_IDENTITY: ProviderIdentity = Object.freeze({
|
||||
id: "rea-cdp-electron",
|
||||
name: "REA Electron file-page CDP observation provider",
|
||||
version: "1",
|
||||
});
|
||||
export { CDP_ELECTRON_PROVIDER_IDENTITY } from "./providerIdentities.js";
|
||||
import { CDP_ELECTRON_PROVIDER_IDENTITY } from "./providerIdentities.js";
|
||||
const IDENTITY = CDP_ELECTRON_PROVIDER_IDENTITY;
|
||||
|
||||
/** Passive Electron provider for local file pages exposed by loopback CDP. */
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
authorizeRuntimeLocation,
|
||||
inspectorExclusionKey,
|
||||
} from "./JavaScriptRuntimeScope.js";
|
||||
|
||||
describe("runtime location authorization", () => {
|
||||
it("reports canonical exclusion reasons per denial case", async () => {
|
||||
expect(await authorizeRuntimeLocation("not a url")).toEqual({
|
||||
allowed: false,
|
||||
reason: "unsupported_url",
|
||||
});
|
||||
expect(await authorizeRuntimeLocation("ftp://example.test/app.js")).toEqual(
|
||||
{
|
||||
allowed: false,
|
||||
reason: "unsupported_url",
|
||||
},
|
||||
);
|
||||
expect(
|
||||
await authorizeRuntimeLocation("file:///outside-approved-scope.js"),
|
||||
).toEqual({ allowed: false, reason: "not_approved" });
|
||||
});
|
||||
|
||||
it("collapses every canonical denial onto the fixed wire bucket", () => {
|
||||
expect(inspectorExclusionKey("unsupported_url")).toBe(
|
||||
"unsupported_location",
|
||||
);
|
||||
expect(inspectorExclusionKey("not_approved")).toBe("unsupported_location");
|
||||
expect(inspectorExclusionKey("disallowed_origin")).toBe(
|
||||
"unsupported_location",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
browserOriginSchema,
|
||||
sanitizeBrowserUrl,
|
||||
} from "../domain/browserObservation.js";
|
||||
import type { BrowserExclusionReason } from "../domain/browserCompleteness.js";
|
||||
import type {
|
||||
JavaScriptRuntimeLocation,
|
||||
JavaScriptRuntimeTargetLocation,
|
||||
@@ -14,9 +15,23 @@ export type RuntimeLocationDecision =
|
||||
| { readonly allowed: true; readonly location: JavaScriptRuntimeLocation }
|
||||
| {
|
||||
readonly allowed: false;
|
||||
readonly reason: "unsupported_location";
|
||||
readonly reason: BrowserExclusionReason;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wire bucket for inspector script exclusions. The V8 observation schemas
|
||||
* fix the `unsupported_location` key, so every canonical denial reason maps
|
||||
* onto it; the reason itself stays available on the decision for logs and
|
||||
* limitations text.
|
||||
*/
|
||||
export const inspectorExclusionKey = (
|
||||
reason: BrowserExclusionReason,
|
||||
): "unsupported_location" => {
|
||||
// Every canonical denial collapses onto the fixed V8 wire bucket.
|
||||
void reason;
|
||||
return "unsupported_location";
|
||||
};
|
||||
|
||||
/** Resolve a protocol location exposed by the explicitly selected Inspector endpoint. */
|
||||
export const authorizeRuntimeLocation = async (
|
||||
value: string,
|
||||
@@ -29,7 +44,7 @@ export const authorizeRuntimeLocation = async (
|
||||
if (value.startsWith("file:")) {
|
||||
const filePath = await authorizedElectronFile(value);
|
||||
return filePath === undefined
|
||||
? { allowed: false, reason: "unsupported_location" }
|
||||
? { allowed: false, reason: "not_approved" }
|
||||
: { allowed: true, location: { kind: "file", file_path: filePath } };
|
||||
}
|
||||
let url: URL;
|
||||
@@ -38,13 +53,13 @@ export const authorizeRuntimeLocation = async (
|
||||
} catch (cause: unknown) {
|
||||
// Non-URL input is outside the authorized runtime scope.
|
||||
void cause;
|
||||
return { allowed: false, reason: "unsupported_location" };
|
||||
return { allowed: false, reason: "unsupported_url" };
|
||||
}
|
||||
if (!["http:", "https:"].includes(url.protocol))
|
||||
return { allowed: false, reason: "unsupported_location" };
|
||||
return { allowed: false, reason: "unsupported_url" };
|
||||
const parsedOrigin = browserOriginSchema.safeParse(url.origin);
|
||||
if (!parsedOrigin.success)
|
||||
return { allowed: false, reason: "unsupported_location" };
|
||||
return { allowed: false, reason: "unsupported_url" };
|
||||
return {
|
||||
allowed: true,
|
||||
location: {
|
||||
@@ -64,7 +79,7 @@ export const authorizeRuntimeTargetLocation = async (
|
||||
readonly allowed: true;
|
||||
readonly location: JavaScriptRuntimeTargetLocation;
|
||||
}
|
||||
| { readonly allowed: false; readonly reason: "unsupported_location" }
|
||||
| { readonly allowed: false; readonly reason: BrowserExclusionReason }
|
||||
> => {
|
||||
if (
|
||||
context.type !== "node" ||
|
||||
|
||||
@@ -41,12 +41,8 @@ const lazyPlaywrightFactory: BrowserScenarioSessionFactory = {
|
||||
},
|
||||
};
|
||||
|
||||
export const PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY: ProviderIdentity =
|
||||
Object.freeze({
|
||||
id: "rea-playwright-browser-scenario",
|
||||
name: "REA Playwright browser scenario capture provider",
|
||||
version: "1",
|
||||
});
|
||||
export { PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY } from "./providerIdentities.js";
|
||||
import { PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY } from "./providerIdentities.js";
|
||||
|
||||
type SnapshotKind = BrowserScenario["capture"]["after_each_step"][number];
|
||||
const SNAPSHOT_KINDS = [
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user