Compare commits

..
374 changed files with 2595 additions and 19997 deletions
@@ -46,7 +46,7 @@ rea setup --client codex --dry-run --json
Use the current supported client's ID: `claude_code`, `claude_desktop`, `codex`,
`cursor`, `gemini_cli`, `windsurf`, `devin`, `opencode`, `antigravity`,
`copilot_cli`, `commandcode`, `qwen_code`, `vscode`, `trae`, `grok_build`, `omp`, `pi`,
`hermes`, `qoder`, `droid`, `zcode`, `dsh`, or `grok_bot`. If unknown, inspect `doctor --json` before choosing a
`hermes`, `qoder`, `zcode`, `dsh`, or `grok_bot`. If unknown, inspect `doctor --json` before choosing a
scope. `grok_bot` uses a chat registration step rather than local `mcp.json`.
For unsupported clients, use version-pinned manual stdio registration or CLI.
@@ -224,13 +224,10 @@ context. PSP/stack/device state remains unmodeled. See [DOS guide](https://githu
For other headerless images (embedded firmware without ELF/Mach-O/PE/MZ
headers), open with `format: "raw-binary"`, `architecture` (`x86`, `x86_64`,
`arm`, `arm64`, or `6502`), and `entry_point` (`0x` hex). File offset 0 is mapped at
`arm`, or `arm64`), and `entry_point` (`0x` hex). File offset 0 is mapped at
that address. Hopper and IDA refuse this interpretation; Ghidra uses
`BinaryLoader` with a little-endian generic language. Big-endian, Thumb-only,
and Cortex-M variants are unsupported. `6502` selects NMOS 6502; the entry and
complete file mapping must fit within `0x0000..0xffff`. 65C02, 65816, cartridge
containers, bank switching, and mapper semantics are unsupported. Windows
Ghidra P0 admits raw images only for `6502`. See [raw binary guide](https://github.com/morluto/rea/blob/main/docs/raw-binary.md).
and Cortex-M variants are unsupported. See [raw binary guide](https://github.com/morluto/rea/blob/main/docs/raw-binary.md).
### Function annotations in Ghidra
@@ -12,7 +12,7 @@ runs:
steps:
- name: Download portable build outputs from this workflow run
if: inputs.build_cache_artifact != ''
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
uses: actions/download-artifact@v4
with:
name: ${{ inputs.build_cache_artifact }}
path: .turbo/cache/
@@ -39,14 +39,14 @@ runs:
' >> "$GITHUB_OUTPUT"
- name: Restore portable build outputs
if: inputs.build_cache_artifact == '' && inputs.save != 'true'
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache/restore@v4
with:
path: .turbo/cache/
key: ${{ steps.plan.outputs.key }}
restore-keys: ${{ steps.plan.outputs.prefix }}
- name: Restore and save portable build outputs
if: inputs.build_cache_artifact == '' && inputs.save == 'true'
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@v4
with:
path: .turbo/cache/
key: ${{ steps.plan.outputs.key }}
+3 -3
View File
@@ -20,11 +20,11 @@ jobs:
timeout-minutes: 5
steps:
# Consume only API data and execute batch code from the trusted default branch.
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/checkout@v4
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Record queue, execution, and matched step timing
@@ -36,7 +36,7 @@ jobs:
- name: Upload CI timing artifacts
if: always() && hashFiles('.cache/ci-job-timings/*') != ''
continue-on-error: true
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@v4
with:
name: ci-job-timings-batch
path: .cache/ci-job-timings/
+49 -75
View File
@@ -63,11 +63,11 @@ jobs:
matrix: ${{ steps.plan.outputs.matrix }}
test_selection: ${{ steps.plan.outputs.test_selection }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/checkout@v4
with:
ref: ${{ inputs.source_sha || github.sha }}
fetch-depth: 0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Bind qualification to the caller revision
@@ -118,8 +118,8 @@ jobs:
env:
CHANGES_RESULT: ${{ needs.changes.result }}
run: test "${CHANGES_RESULT}" = "success"
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
@@ -138,7 +138,7 @@ jobs:
run: npm run format:check
- name: Upload complete static diagnostics
if: always() && needs.changes.outputs.code == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@v4
with:
name: static-check-reports
path: .cache/static-checks/
@@ -157,13 +157,13 @@ jobs:
matrix:
shard: [1/6, 2/6, 3/6, 4/6, 5/6, 6/6]
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
- uses: actions/download-artifact@v4
with:
name: rea-test-runtime
path: .
@@ -177,7 +177,7 @@ jobs:
run: npm run test:run -- --reporter=default --reporter=blob --shard=${{ matrix.shard }}
env:
REA_CI_TEST_HISTORY: .cache/ci-test-history/test-results.json
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: vitest-report-${{ strategy.job-index }}
@@ -208,8 +208,8 @@ jobs:
contents: read
actions: read
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
@@ -231,13 +231,13 @@ jobs:
- name: Download previous timing report
if: steps.history.outputs.run-id != ''
continue-on-error: true
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
uses: actions/download-artifact@v4
with:
name: test-timings
run-id: ${{ steps.history.outputs.run-id }}
github-token: ${{ github.token }}
path: .cache/ci-test-history
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
- uses: actions/upload-artifact@v4
with:
name: rea-test-runtime
path: |
@@ -254,7 +254,7 @@ jobs:
# test:prepare caches only portable JS, skill, and catalog outputs.
# Native package jobs still run their build and prepack checks; Turbo
# restores matching outputs or rebuilds when their inputs differ.
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
- uses: actions/upload-artifact@v4
with:
name: rea-build-cache
path: .turbo/cache/
@@ -280,16 +280,16 @@ jobs:
env:
CHANGES_RESULT: ${{ needs.changes.result }}
run: test "${CHANGES_RESULT}" = "success"
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/checkout@v4
if: needs.changes.outputs.code == 'true' && success()
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/setup-node@v4
if: needs.changes.outputs.code == 'true' && success()
with:
node-version-file: .nvmrc
cache: npm
- if: needs.changes.outputs.code == 'true' && success()
run: npm ci
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
- uses: actions/download-artifact@v4
if: needs.changes.outputs.code == 'true' && success()
with:
path: .vitest/blob
@@ -330,7 +330,7 @@ jobs:
CI_TEST_SELECTION: ${{ needs.changes.outputs.test_selection }}
CI_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: node scripts/ci/test-report.mjs
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
- uses: actions/upload-artifact@v4
if: needs.changes.outputs.code == 'true' && !cancelled() && hashFiles('test-results.json') != ''
with:
name: test-results
@@ -345,7 +345,7 @@ jobs:
retention-days: 30
- name: Publish successful timing history
if: needs.changes.outputs.code == 'true' && needs.changes.outputs.tier == 'full' && success() && needs.test-shard.result == 'success'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@v4
with:
name: test-timings
path: .cache/ci-test-history/test-results.json
@@ -366,7 +366,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/checkout@v4
- name: Verify fixtures from a clean checkout
run: npm run verify:fixtures
@@ -377,8 +377,8 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Prepare cross compiler
@@ -395,27 +395,12 @@ jobs:
- run: npm run build:windows-native
- run: npm run verify:windows-native:artifact
- run: node scripts/build-windows-native.mjs --inspection-fixture
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
- uses: actions/upload-artifact@v4
with:
name: windows-native-inspection-fixture
path: .cache/windows-native-inspection-fixture/
if-no-files-found: error
# The process boundary observations are independent of the addon: they
# report the caller's and child's own token, environment and breakaway
# results from a separate executable. Cross-build it in the lane that
# already has MinGW so the Windows job can observe them too.
- name: Build Windows process boundary fixture
run: |
mkdir -p .cache/windows-process-boundary
x86_64-w64-mingw32-g++ -municode -std=c++17 -O2 -static -Wall -Wextra \
-o .cache/windows-process-boundary/process-boundary.exe \
tests/fixtures/windows/processBoundary.cc -ladvapi32
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: windows-process-boundary-fixture
path: .cache/windows-process-boundary/
if-no-files-found: error
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
- uses: actions/upload-artifact@v4
with:
name: windows-native-controls
path: native/windows/build/
@@ -438,8 +423,8 @@ jobs:
NODE_OPTIONS: "--max-old-space-size=1024"
UV_THREADPOOL_SIZE: "2"
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
@@ -453,11 +438,11 @@ jobs:
assert.equal(process.arch, process.env.EXPECTED_CI_ARCHITECTURE);
console.log(JSON.stringify({ platform: process.platform, architecture: process.arch, node: process.version }));"
- run: npm ci
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
- uses: actions/download-artifact@v4
with:
name: rea-build-cache
path: .turbo/cache/
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
- uses: actions/download-artifact@v4
with:
name: windows-native-controls
path: native/windows/build/
@@ -478,7 +463,7 @@ jobs:
run: npm run verify:inspector
- name: Retain the exact qualified release package
if: needs.changes.outputs.tier == 'full' && matrix.platform == 'linux' && matrix.architecture == 'x64'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@v4
with:
name: verified-release-package
path: .cache/verified-package/
@@ -494,13 +479,13 @@ jobs:
runs-on: macos-15
timeout-minutes: 15
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
- uses: actions/download-artifact@v4
with:
name: rea-build-cache
path: .turbo/cache/
@@ -542,8 +527,8 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
@@ -554,7 +539,7 @@ jobs:
run: |
set -o pipefail
npm run verify:readiness | tee readiness-summary.json
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: reconstruction-readiness
@@ -571,18 +556,18 @@ jobs:
runs-on: windows-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
- uses: actions/download-artifact@v4
with:
name: rea-build-cache
path: .turbo/cache/
- run: npm run build:cached
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
- uses: actions/download-artifact@v4
with:
name: windows-native-controls
path: native/windows/build/
@@ -611,9 +596,7 @@ jobs:
tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts
tests/boundary/filesystem/setupUnavailableProvider.test.ts
tests/boundary/filesystem/windowsNpxRegistration.test.ts
tests/boundary/android/workspaceGuardianRetry.test.ts
tests/acceptance/setup/homeDirectoryFallback.test.ts
tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts
tests/boundary/filesystem/webScriptManifestPaths.test.ts
tests/boundary/process/ProcessEvidence.host.test.ts
tests/acceptance/webModuleCli.test.ts
@@ -638,17 +621,8 @@ jobs:
src/application/Update.test.ts
tests/process-global/updateMaintenance.test.ts
tests/conformance/providers/ghidra/realGhidraWindowsWorkflow.test.ts
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: windows-process-boundary-fixture
path: .cache/windows-process-boundary/
# Passing the fixture adds the breakaway, caller/child token, environment
# semantics and ancestor reparse observations, which the lane skips
# entirely when it is absent.
- run: >-
npm run verify:windows-native -- .
"${{ github.workspace }}\.cache\windows-process-boundary\process-boundary.exe"
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
- run: npm run verify:windows-native
- uses: actions/download-artifact@v4
with:
name: windows-native-inspection-fixture
path: .cache/windows-native-inspection-fixture/
@@ -667,7 +641,7 @@ jobs:
tests/boundary/filesystem/directoryArtifactReaderOpen.test.ts
tests/boundary/filesystem/javascriptPackagePrecedence.test.ts
tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/setup-node@v4
with:
node-version: 22.19.0
- name: Verify native path boundaries on Node 22
@@ -677,7 +651,7 @@ jobs:
tests/boundary/filesystem/javascriptPackagePrecedence.test.ts
tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts
tests/boundary/process/sourceMapUncHosts.test.ts
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
- uses: actions/upload-artifact@v4
if: always()
with:
name: windows-capabilities
@@ -691,8 +665,8 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
@@ -700,7 +674,7 @@ jobs:
- run: npm run docs:check
- name: Generation leaves authored files unchanged
run: git diff --exit-code
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
- uses: actions/upload-artifact@v4
with:
name: generated-docs
path: |
@@ -807,8 +781,8 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Require every selected lane to pass
@@ -819,7 +793,7 @@ jobs:
run: node scripts/ci/required.mjs
- name: Upload full qualification receipt artifact
if: needs.changes.outputs.tier == 'full' && success()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@v4
with:
name: ${{ steps.gate.outputs.receipt_name }}
path: ${{ steps.gate.outputs.receipt_path }}
+4 -4
View File
@@ -19,16 +19,16 @@ jobs:
DOTNET_PROCESSOR_COUNT: "1"
COMPlus_GCHeapHardLimit: "30000000"
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "8.0.416"
- name: Build benign fixture and independent byte oracle
run: node scripts/generate-nativeaot-fixtures.mjs
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
- uses: actions/upload-artifact@v4
with:
name: nativeaot-win-x64-${{ github.event.pull_request.head.sha || github.sha }}
path: |
+2 -2
View File
@@ -19,8 +19,8 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
+2 -2
View File
@@ -28,8 +28,8 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
+2 -2
View File
@@ -21,8 +21,8 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
+2 -4
View File
@@ -22,8 +22,8 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
@@ -43,5 +43,3 @@ jobs:
echo "REA_VERIFY_SOLC_MODULE=${evm_fixture_root}/node_modules/solc/index.js" >> "${GITHUB_ENV}"
- name: Verify source-owned compiled bytecode through public CLI and MCP
run: npm run verify:evm:interface
- name: Verify content-hash-free and CBOR-free owned compiler artifacts
run: npm run verify:solidity:metadata
+8 -28
View File
@@ -16,8 +16,8 @@ jobs:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Prepare cross compiler
@@ -31,25 +31,11 @@ jobs:
sudo apt-get install --no-install-recommends -y g++-mingw-w64-x86-64
- run: npm run build:windows-native
- run: npm run verify:windows-native:artifact
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
- uses: actions/upload-artifact@v4
with:
name: real-ghidra-windows-native
path: native/windows/build/
if-no-files-found: error
# The boundary observations need a separate Windows executable, which the
# native lane skips when it is absent. Cross-build it here, where MinGW is
# already installed, so the proof run observes them too.
- name: Build Windows process boundary fixture
run: |
mkdir -p .cache/windows-process-boundary
x86_64-w64-mingw32-g++ -municode -std=c++17 -O2 -static -Wall -Wextra \
-o .cache/windows-process-boundary/process-boundary.exe \
tests/fixtures/windows/processBoundary.cc -ladvapi32
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: real-ghidra-windows-process-boundary
path: .cache/windows-process-boundary/
if-no-files-found: error
verify:
name: Real Ghidra 12.1.4 Windows x64 PE proof
@@ -59,23 +45,17 @@ jobs:
runs-on: [self-hosted, Windows, x64, ghidra-12-1-2]
timeout-minutes: 20
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
- run: npm ci
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
- uses: actions/download-artifact@v4
with:
name: real-ghidra-windows-native
path: native/windows/build/
- run: npm run verify:windows-native:artifact
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: real-ghidra-windows-process-boundary
path: .cache/windows-process-boundary/
- run: >-
npm run verify:windows-native -- .
"${{ github.workspace }}\.cache\windows-process-boundary\process-boundary.exe"
- run: npm run verify:windows-native
- name: Verify all admitted Windows Ghidra operations
env:
REA_ANALYSIS_PROVIDER: ghidra
@@ -108,7 +88,7 @@ jobs:
env:
REA_ANALYSIS_PROVIDER: ghidra
run: npm run verify:ghidra:windows:package -- --x86 --dll | Tee-Object -FilePath windows-ghidra-x86-dll-package-proof.log
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
- uses: actions/upload-artifact@v4
if: always()
with:
name: windows-ghidra-proof
+3 -3
View File
@@ -44,12 +44,12 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
- uses: actions/setup-go@v5
with:
go-version: "1.26.1"
cache: false
+4 -4
View File
@@ -15,8 +15,8 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 25
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
@@ -36,8 +36,8 @@ jobs:
runs-on: [self-hosted, Linux, hopper]
timeout-minutes: 20
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
+2 -2
View File
@@ -15,8 +15,8 @@ jobs:
runs-on: [self-hosted, macOS, hopper]
timeout-minutes: 20
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
@@ -23,8 +23,8 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
+2 -2
View File
@@ -43,8 +43,8 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
+2 -2
View File
@@ -25,8 +25,8 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
+2 -2
View File
@@ -36,8 +36,8 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
+3 -3
View File
@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/checkout@v4
- name: Download the pinned Termux app and verify its release checksum
shell: bash
run: |
@@ -38,7 +38,7 @@ jobs:
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Build and capture a page inside Termux
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
uses: reactivecircus/android-emulator-runner@4c44018e59b437e86cdfc41da381398f93ed8808 # v2
with:
api-level: 30
arch: x86_64
@@ -50,7 +50,7 @@ jobs:
script: bash scripts/verify/termux/emulator.sh
- name: Upload Termux and Android diagnostics
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@v4
with:
name: termux-browser-diagnostics
path: ${{ runner.temp }}/rea-termux/diagnostics
@@ -22,8 +22,8 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
+2 -2
View File
@@ -17,8 +17,8 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
+2 -2
View File
@@ -17,8 +17,8 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
+13 -13
View File
@@ -48,7 +48,7 @@ jobs:
echo "frozen=true" >> "$GITHUB_OUTPUT"
fi
- uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1
- uses: googleapis/release-please-action@v4
if: steps.editorial.outputs.frozen != 'true'
with:
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
@@ -70,13 +70,13 @@ jobs:
sha: ${{ steps.release.outputs.sha }}
steps:
- name: Check out reviewed release merge
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
package-manager-cache: false
@@ -104,7 +104,7 @@ jobs:
node scripts/release-notes.mjs publication --repository "$GH_REPO" \
--source "$SOURCE_SHA" --pr "$RELEASE_PR"
- uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1
- uses: googleapis/release-please-action@v4
id: release
with:
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
@@ -191,14 +191,14 @@ jobs:
fi
- name: Check out release controller and Git history
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Set up Node.js for checkpoint validation
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
package-manager-cache: false
@@ -226,7 +226,7 @@ jobs:
node scripts/release-notes.mjs publication --repository "$GH_REPO" \
--source "$SOURCE_SHA"
- uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1
- uses: googleapis/release-please-action@v4
id: release
with:
# A dedicated credential starts PR CI automatically; the built-in
@@ -255,7 +255,7 @@ jobs:
- name: Check out release pull request
if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
uses: actions/checkout@v6
with:
ref: ${{ fromJSON(steps.release.outputs.pr).headBranchName }}
fetch-depth: 0
@@ -263,7 +263,7 @@ jobs:
- name: Set up Node.js for generated documentation
if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
package-manager-cache: false
@@ -314,12 +314,12 @@ jobs:
contents: read
id-token: write
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- uses: actions/checkout@v6
with:
ref: ${{ needs.merged-release.outputs.sha || needs.release-please.outputs.sha }}
persist-credentials: false
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
package-manager-cache: false
@@ -328,7 +328,7 @@ jobs:
- run: npm ci
- name: Download the exact qualified package
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
uses: actions/download-artifact@v4
with:
name: verified-release-package
path: .cache/verified-package/
@@ -392,7 +392,7 @@ jobs:
contents: read
id-token: write
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- uses: actions/checkout@v6
with:
ref: ${{ needs.merged-release.outputs.sha || needs.release-please.outputs.sha }}
persist-credentials: false
+3 -3
View File
@@ -22,14 +22,14 @@ jobs:
# The repository-pinned Node 24 version also runs the complete test suite.
node: ["22.x", "24.15.0", "24.x", "26.x"]
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
check-latest: true
cache: npm
- run: npm ci
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
- uses: actions/download-artifact@v4
with:
name: rea-test-runtime
path: .
+2 -2
View File
@@ -14,9 +14,9 @@ jobs:
timeout-minutes: 10
steps:
- name: Check out the repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
uses: actions/checkout@v4
- name: Set up Python for website assets
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
+9 -9
View File
@@ -47,9 +47,9 @@ jobs:
exit 1
fi
- name: Check out the repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
uses: actions/checkout@v4
- name: Set up Python for website assets
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
@@ -74,9 +74,9 @@ jobs:
)
PY
- name: Configure Pages
uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0
uses: actions/configure-pages@v5
- name: Upload the website
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0
uses: actions/upload-pages-artifact@v4
with:
path: website/_site
@@ -95,15 +95,15 @@ jobs:
steps:
- name: Check out publishing configuration
if: inputs.target == 'both'
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
uses: actions/checkout@v4
- name: Set up Node.js for Wrangler
if: inputs.target == 'both'
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Download the verified website
if: inputs.target == 'both'
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
uses: actions/download-artifact@v4
with:
name: github-pages
path: ${{ runner.temp }}/website-archive
@@ -114,7 +114,7 @@ jobs:
tar -xf "$RUNNER_TEMP/website-archive/artifact.tar" -C "$RUNNER_TEMP/rea-website-public"
- name: Publish the website to Cloudflare
if: inputs.target == 'both'
uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3
uses: cloudflare/wrangler-action@v4
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
@@ -123,7 +123,7 @@ jobs:
command: deploy --cwd website --env production --assets "${{ runner.temp }}/rea-website-public"
- name: Publish to GitHub Pages
id: deployment
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5
uses: actions/deploy-pages@v4
- name: Verify the selected published revisions
env:
PAGES_URL: ${{ steps.deployment.outputs.page_url }}
-15
View File
@@ -142,21 +142,6 @@ caches deterministic builds and static checks across Git worktrees. After a pack
Repository invariants live in [AGENTS.md](AGENTS.md). Keep provider-specific
protocols in adapters and shared CLI/MCP workflows in the application layer.
### Windows source checkouts
The published npm package includes the Windows x64 native addon and its
manifest. A source checkout does not: `native/windows/build/` is ignored by
Git, and neither `npm ci` nor `npm run build:cached` (or `npm run build`)
produces `rea-windows-x64.node` or `manifest.json` in that directory.
Windows workflows and tests that require native controls need these artifacts;
without them, those controls are unavailable even after a successful runtime build.
Follow the [Windows native build guide](native/windows/README.md#build) before
running those workflows from source. The artifact build requires Linux,
MinGW-w64, and `tar`; the build script does not run on Windows. See that guide
for the build and artifact-verification commands and the separate real Windows
verification prerequisites. Keep the generated addon and manifest uncommitted.
## Documentation website
The VitePress site uses the Markdown files in `docs/`. Run `npm run docs:dev`
+1 -1
View File
@@ -322,7 +322,7 @@ Report vulnerabilities through [SECURITY.md](SECURITY.md).
## Star history
🎉 **180,000 GitHub stars — thank you!**
🎉 **170,000 GitHub stars — thank you!**
Thanks to everyone using REA, reporting bugs, requesting features, testing builds, and contributing fixes.
+9 -107
View File
@@ -3,14 +3,8 @@ import com.google.gson.*;
import io.modelcontextprotocol.kotlin.sdk.server.*;
import io.modelcontextprotocol.kotlin.sdk.types.*;
import jadx.api.*;
import jadx.api.plugins.input.data.ICodeReader;
import jadx.api.plugins.input.data.IMethodRef;
import jadx.api.plugins.input.insns.InsnIndexType;
import jadx.api.plugins.input.insns.Opcode;
import jadx.core.dex.info.MethodInfo;
import jadx.core.dex.nodes.*;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.CountDownLatch;
import java.util.stream.Collectors;
@@ -85,17 +79,11 @@ class ReaJadxBridge {
ordered.sort(Comparator.comparing(MethodNode::getAlias));
List<MethodSnapshot> methods = ordered.stream().map(method -> {
String name = method.getAlias();
String shortId = method.getMethodInfo().getShortId();
String rawName = method.getMethodInfo().getName();
if (!shortId.startsWith(rawName + "("))
throw new IllegalStateException("JADX method ID has no raw-name prefix: " + shortId);
String descriptor = shortId.substring(rawName.length());
String signature = name + "(" + method.getArgTypes().stream()
.map(Object::toString).collect(Collectors.joining(", "))
+ "): " + method.getReturnType().toString();
return new MethodSnapshot(name, shortId, Map.of(
return new MethodSnapshot(name, method.getMethodInfo().getShortId(), Map.of(
"name", name, "signature", signature,
"dex_descriptor", descriptor,
"is_constructor", method.isConstructor(), "def_pos", 0));
}).toList();
List<Map<String, Object>> fields = node.getFields().stream()
@@ -152,57 +140,6 @@ class ReaJadxBridge {
"smali", true, List.of("static_initializer_disassembly"));
}
// The local transport admits 8 MiB frames. Reserve 1 KiB for the fixed
// JSON-RPC/CallToolResult envelope and generated numeric request id, and
// account for JSON text being escaped again inside TextContent.
private static final int MAX_XREF_TEXT_BYTES = 8 * 1024 * 1024 - 1024;
private static int escapedJsonBytes(Object value) {
return JSON.toJson(JSON.toJson(value)).getBytes(StandardCharsets.UTF_8).length;
}
private static final class InvokeBudget {
long remaining;
InvokeBudget(long remaining) { this.remaining = Math.max(0, remaining); }
boolean admit(Map<String, Object> site) {
// Check a conservative two-layer escape bound before serialization.
long bound = 256;
for (Object value : site.values())
if (value instanceof String text) bound += 12L * text.length();
if (bound > remaining) return false;
long bytes = escapedJsonBytes(site) - 2L + 1L;
if (bytes > remaining) return false;
remaining -= bytes;
return true;
}
}
private record DirectInvokes(List<Map<String, Object>> sites, boolean exhaustive) {}
/** Raw DEX invokes within one JADX using method; offsets are code units from its code start. */
private static DirectInvokes directInvokes(JavaMethod source, MethodInfo target, InvokeBudget budget) {
MethodNode node = source.getMethodNode();
ICodeReader code = node.getCodeReader();
if (code == null) return null;
List<Map<String, Object>> sites = new ArrayList<>();
boolean[] exhaustive = {true};
code.visitInstructions(insn -> {
insn.decode();
if (insn.getIndexType() != InsnIndexType.METHOD_REF) return;
Opcode opcode = insn.getOpcode();
if (!opcode.name().startsWith("INVOKE_")) return;
IMethodRef ref = insn.getIndexAsMethod();
ref.load();
if (!MethodInfo.fromRef(node.root(), ref).getRawFullId().equals(target.getRawFullId())) return;
Map<String, Object> site = Map.of(
"dex_entry", node.getInputFileName(),
"source_raw_method_id", node.getMethodInfo().getRawFullId(),
"dex_code_unit_offset", insn.getOffset(),
"dex_method_index", insn.getIndex(),
"opcode", opcode.name());
if (budget.admit(site)) sites.add(site);
else exhaustive[0] = false;
});
return new DirectInvokes(sites, exhaustive[0]);
}
private CallToolResult handle(String name, JsonObject args) {
if (name.equals("rea_jvm_status")) return json(Map.of(
"engine_reported_version", BuildInfo.VERSION,
@@ -236,52 +173,17 @@ class ReaJadxBridge {
}
List<JavaNode> uses;
String target = string(args, "class_name");
MethodSnapshot selectedMethod = null;
int overloadIndex = 0;
if (name.equals("get_xrefs_to_class")) uses = cls.node().getUseIn();
else {
List<MethodSnapshot> overloads = methods(cls, args);
if (overloads.size() != 1 && !args.containsKey("overload_index"))
throw new InputFailure("overload_index", "Select one of " + overloads.size() + " method overloads");
overloadIndex = integer(args, "overload_index", 0);
if (overloadIndex >= overloads.size())
throw new InputFailure("overload_index", "overload_index out of range: " + overloadIndex);
selectedMethod = overloads.get(overloadIndex);
target += "." + selectedMethod.name();
uses = method(cls, selectedMethod).getUseIn();
if (overloads.size() != 1) throw new InputFailure("method_name", "Method references require a unique overload");
target += "." + overloads.get(0).name();
uses = method(cls, overloads.get(0)).getUseIn();
}
// REA consumes all references without materializing source line information.
MethodInfo targetInfo = selectedMethod == null ? null : method(cls, selectedMethod).getMethodNode().getMethodInfo();
List<Map<String, Object>> items = uses.stream().map(node -> {
Map<String, Object> item = new LinkedHashMap<>(current.describeUsage(node, false));
if (targetInfo != null && node instanceof JavaMethod) {
item.put("direct_invokes", List.of());
item.put("direct_invokes_exhaustive", false);
}
return item;
}).toList();
Map<String, Object> result = new LinkedHashMap<>(Map.of("target", target,
"total", uses.size(), "count", items.size(), "resolve_line", false, "items", items));
if (selectedMethod != null) {
result.put("overload_index", overloadIndex);
result.put("reported_signature", selectedMethod.summary().get("signature"));
}
if (targetInfo != null) {
InvokeBudget budget = new InvokeBudget(MAX_XREF_TEXT_BYTES - escapedJsonBytes(result));
for (int index = 0; index < uses.size(); index++) {
if (!(uses.get(index) instanceof JavaMethod source)) continue;
DirectInvokes invokes = directInvokes(source, targetInfo, budget);
Map<String, Object> item = items.get(index);
if (invokes == null) {
item.remove("direct_invokes");
item.remove("direct_invokes_exhaustive");
} else {
item.put("direct_invokes", invokes.sites());
item.put("direct_invokes_exhaustive", invokes.exhaustive());
}
}
}
return json(result);
List<Map<String, Object>> items = uses.stream().map(node -> current.describeUsage(node, false)).toList();
return json(Map.of("target", target, "total", uses.size(), "count", items.size(),
"resolve_line", false, "items", items));
}
public static void main(String[] args) throws Exception {
@@ -315,7 +217,7 @@ class ReaJadxBridge {
List<String> fields = switch (name) {
case "list_classes" -> List.of("offset", "limit");
case "get_method_body" -> List.of("class_name", "method_name", "overload_index");
case "get_xrefs_to_method" -> List.of("class_name", "method_name", "overload_index");
case "get_xrefs_to_method" -> List.of("class_name", "method_name");
default -> List.of("class_name");
};
Map<String, kotlinx.serialization.json.JsonElement> properties = new LinkedHashMap<>();
@@ -324,7 +226,7 @@ class ReaJadxBridge {
case "list_classes" -> "Indexed pages of all top-level class names, with offset and limit.";
case "get_class_summary" -> "Parsed member metadata for one exact class name; no source generation.";
case "get_method_body" -> "Source for an exact class and stable same-name overload index; static initializers use smali to preserve folded field initialization.";
default -> "Complete static using-node inventory for an exact class or selected method overload; one node may contain multiple invoke instructions, and source lines are unresolved.";
default -> "Complete static references to an exact class or unique method; source lines are unresolved.";
};
Tool narrowed = new Tool(name, new ToolSchema(null, new JsonObject(properties),
tool.getInputSchema().getRequired(), null), description, null, null, null, null, null, null);
+2 -388
View File
@@ -13,8 +13,6 @@ import java.net.UnixDomainSocketAddress;
import java.nio.channels.Channels;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
@@ -24,11 +22,8 @@ import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.PosixFilePermissions;
import java.security.MessageDigest;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.Iterator;
@@ -87,7 +82,6 @@ import ghidra.program.model.address.SegmentedAddress;
import ghidra.program.model.reloc.Relocation;
import java.io.ByteArrayOutputStream;
import ghidra.program.model.symbol.Namespace;
import ghidra.program.model.symbol.FlowType;
import ghidra.program.model.symbol.RefType;
import ghidra.program.model.symbol.Reference;
import ghidra.program.model.symbol.ReferenceIterator;
@@ -106,9 +100,6 @@ import ghidra.program.model.pcode.PcodeBlock;
import ghidra.program.model.pcode.PcodeBlockBasic;
import ghidra.program.model.pcode.Varnode;
import ghidra.program.model.pcode.VarnodeAST;
import ghidra.program.util.ContextEvaluatorAdapter;
import ghidra.program.util.SymbolicPropogator;
import ghidra.program.util.VarnodeContext;
public final class ReaGhidraBridge extends HeadlessScript {
private static final int MAX_HIGH_PCODE_OPS = 3000;
@@ -813,7 +804,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
return functionObservation(result, boundaryLimitations);
}
private JsonObject procedureCalls(JsonObject params, boolean callers) throws Exception {
private JsonArray procedureCalls(JsonObject params, boolean callers) throws Exception {
requireKeys(params, Set.of("document", "procedure"));
requireDocument(params);
Function function = resolveProcedure(requireString(params, "procedure"));
@@ -826,12 +817,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
for (Function item : ordered) {
result.add(canonicalAddress(item.getEntryPoint()));
}
return functionObservation(
result,
noReturnCallLimitations(
currentProgram.getListing().getInstructions(function.getBody(), true)
)
);
return result;
}
private JsonObject procedureInfo(JsonObject params) throws Exception {
@@ -1333,13 +1319,6 @@ public final class ReaGhidraBridge extends HeadlessScript {
result.add("outgoing_references", outgoing);
result.add("referenced_strings", referencedStrings);
result.add("referenced_names", referencedNames);
boolean swift = swiftProgram();
boolean aarch64 = currentProgram.getLanguageID().getIdAsString().startsWith("AARCH64:");
SwiftStringLiteralDecode swiftLiterals = swift && aarch64 ? swiftStringLiterals(function) : null;
if (swiftLiterals != null) {
result.add("swift_string_literals", swiftLiterals.literals());
result.add("swift_string_literal_omissions", swiftLiterals.omissions());
}
result.add("basic_blocks", blocks);
result.add(
"native_api",
@@ -1359,20 +1338,6 @@ public final class ReaGhidraBridge extends HeadlessScript {
limitations.add(
"Synthetic Ghidra entry-point references without actionable memory sources are omitted."
);
if (swift && aarch64) {
limitations.add(
"Swift String literals are decoded from constant register pairs at calls, returns and paired stores; literals stored one word at a time, chosen by conditional selects, passed through unresolved jumps, or built at runtime are not reported."
);
if (swiftLiterals.omissions().size() > 0) {
limitations.add(
"Swift String literal records listed in swift_string_literal_omissions could not be retained or decoded within this dossier's remaining heap budget; later symbolic flows remain unexamined."
);
}
} else if (swift) {
limitations.add(
"Swift String literal decoding is implemented for AArch64 register pairs only; no Swift literals were decoded for this architecture."
);
}
List<String> boundaryLimitations = noReturnCallLimitations(scan.instructions);
for (String limitation : boundaryLimitations) {
limitations.add(limitation);
@@ -3426,349 +3391,6 @@ public final class ReaGhidraBridge extends HeadlessScript {
return result;
}
private boolean swiftProgram() {
for (MemoryBlock block : currentProgram.getMemory().getBlocks()) {
String name = block.getName();
if (name.startsWith("__swift5_") || name.startsWith("swift5_")) return true;
}
return false;
}
/**
* Swift passes a String as two words: small literals carry their bytes in the words and
* immortal large literals carry their count and a biased pointer to the bytes. Ghidra keeps
* neither as a data reference, so decode constant pairs where Swift consumes them: argument
* and return registers at calls, tail calls and returns, and both registers of one STP.
* The canonical empty string is a small literal with a zero count.
*/
private SwiftStringLiteralDecode swiftStringLiterals(Function function) throws Exception {
Register[] registers = new Register[31];
for (int index = 0; index < registers.length; index++) {
registers[index] = currentProgram.getRegister("x" + index);
}
TreeMap<String, JsonObject> observed = new TreeMap<>();
TreeMap<String, JsonObject> omitted = new TreeMap<>();
SwiftLiteralBudget budget = new SwiftLiteralBudget();
Map<String, Boolean> selectedValues = new HashMap<>();
SymbolicPropogator propagator = new SymbolicPropogator(currentProgram, false);
propagator.flowConstants(function.getEntryPoint(), function.getBody(), new ContextEvaluatorAdapter() {
@Override
public boolean evaluateContextBefore(VarnodeContext context, Instruction instruction) {
// Returning true ends only the current flow, so every queued flow checks the shared stop.
if (budget.stopped()) return true;
for (Register[] pair : swiftStringPairs(function, instruction, registers)) {
Long word = registerConstant(context, pair[0]);
Long object = registerConstant(context, pair[1]);
if (word == null || object == null) continue;
SwiftLiteralLayout layout = swiftLiteralLayout(word, object);
if (layout == null) continue;
// A selected word can combine with a path-dependent ordinary move. Cache the
// provenance question per site/register, charging retained cache state to the
// same dossier budget before allocating it.
boolean selected = false;
boolean provenanceOverCapacity = false;
for (Register register : pair) {
String provenanceKey = canonicalAddress(instruction.getAddress()) + "\u0000" +
register.getBaseRegister().getName();
Boolean value = selectedValues.get(provenanceKey);
if (value == null) {
if (!budget.takeState()) {
provenanceOverCapacity = true;
break;
}
value = selectedValue(function, instruction, register);
selectedValues.put(provenanceKey, value);
}
if (value) {
selected = true;
break;
}
}
if (selected) continue;
// Forks can reach one site with different constants, so the words are part of the key.
String key = canonicalAddress(instruction.getAddress()) + "\u0000" + pair[0].getName() +
"\u0000" + pair[1].getName() + "\u0000" + Long.toUnsignedString(word, 16) +
"\u0000" + Long.toUnsignedString(object, 16);
if (observed.containsKey(key) || omitted.containsKey(key)) continue;
// Every record draws on the budget; the first one that does not fit is kept as an
// omission and decoding stops, so later sites are never examined.
String value = layout.smallValue();
boolean overCapacity = provenanceOverCapacity || !budget.take(layout.byteLength());
if (!overCapacity && value == null) {
try {
value = swiftLargeLiteralText(layout);
} catch (OutOfMemoryError exhausted) {
overCapacity = true;
}
}
if (value == null && !overCapacity) continue;
JsonObject item = overCapacity
? swiftStringOmission(layout, word, object)
: swiftStringItem(value, layout, word, object);
item.addProperty("source_address", canonicalAddress(instruction.getAddress()));
JsonArray pairRegisters = new JsonArray();
pairRegisters.add(pair[0].getName());
pairRegisters.add(pair[1].getName());
item.add("registers", pairRegisters);
(overCapacity ? omitted : observed).put(key, item);
if (overCapacity) {
budget.stop();
return true;
}
}
return false;
}
}, false, monitor);
JsonArray literals = new JsonArray();
observed.values().forEach(literals::add);
JsonArray omissions = new JsonArray();
omitted.values().forEach(omissions::add);
return new SwiftStringLiteralDecode(literals, omissions);
}
private static List<Register[]> swiftStringPairs(
Function function,
Instruction instruction,
Register[] registers
) {
List<Register[]> pairs = new ArrayList<>();
if (instruction.getMnemonicString().equals("stp")) {
Register first = instruction.getRegister(0);
Register second = instruction.getRegister(1);
if (first != null && second != null && first.getBitLength() == 64 && second.getBitLength() == 64) {
pairs.add(new Register[] {first, second});
}
return pairs;
}
FlowType flow = instruction.getFlowType();
boolean returns = false;
for (PcodeOp operation : instruction.getPcode()) {
if (operation.getOpcode() == PcodeOp.RETURN) returns = true;
}
// A jump is a proven tail call only when it has resolved targets and all leave the
// body. A targetless computed jump can still dispatch inside the function.
Address[] targets = instruction.getFlows();
boolean tailCall = flow.isJump() && !flow.isConditional() && targets.length > 0 &&
Arrays.stream(targets).allMatch(target -> !function.getBody().contains(target));
if (!flow.isCall() && !returns && !tailCall) return pairs;
// A String occupies two consecutive registers among x0-x7, or x0 and x1 when returned.
int lastFirstRegister = returns ? 0 : 6;
for (int index = 0; index <= lastFirstRegister; index++) {
pairs.add(new Register[] {registers[index], registers[index + 1]});
}
return pairs;
}
private static Long registerConstant(VarnodeContext context, Register register) {
Varnode value = context.getRegisterVarnodeValue(register);
return value != null && context.isConstant(value) ? value.getOffset() : null;
}
// Search every predecessor path for the reaching definitions, following register copies,
// since a select copied into an argument register or defined before a branch forks just as
// independently as one written there directly.
private boolean selectedValue(Function function, Instruction use, Register register) {
ArrayDeque<Instruction> instructions = new ArrayDeque<>();
ArrayDeque<Register> targets = new ArrayDeque<>();
Set<String> visited = new HashSet<>();
for (Instruction predecessor : predecessors(function, use)) {
instructions.push(predecessor);
targets.push(register.getBaseRegister());
}
while (!instructions.isEmpty()) {
Instruction instruction = instructions.pop();
Register target = targets.pop();
if (!visited.add(canonicalAddress(instruction.getAddress()) + "\u0000" + target.getName())) continue;
boolean defines = false;
for (Object result : instruction.getResultObjects()) {
if (result instanceof Register written && written.getBaseRegister().equals(target)) defines = true;
}
Register next = target;
if (defines) {
PcodeOp[] operations = instruction.getPcode();
for (PcodeOp operation : operations) {
if (operation.getOpcode() == PcodeOp.CBRANCH && operation.getInput(0).isConstant()) return true;
}
Register source = copiedRegister(operations);
if (source == null) continue;
next = source.getBaseRegister();
}
for (Instruction predecessor : predecessors(function, instruction)) {
instructions.push(predecessor);
targets.push(next);
}
}
return false;
}
private List<Instruction> predecessors(Function function, Instruction instruction) {
List<Instruction> result = new ArrayList<>();
Address from = instruction.getFallFrom();
if (from != null) {
Instruction previous = currentProgram.getListing().getInstructionAt(from);
if (previous != null) result.add(previous);
}
for (Reference reference : currentProgram.getReferenceManager().getReferencesTo(instruction.getAddress())) {
if (!reference.getReferenceType().isFlow() || reference.getReferenceType().isCall()) continue;
if (!function.getBody().contains(reference.getFromAddress())) continue;
Instruction source = currentProgram.getListing().getInstructionAt(reference.getFromAddress());
if (source != null) result.add(source);
}
return result;
}
private Register copiedRegister(PcodeOp[] operations) {
if (operations.length != 1) return null;
int opcode = operations[0].getOpcode();
Varnode input = operations[0].getInput(0);
if ((opcode != PcodeOp.COPY && opcode != PcodeOp.INT_ZEXT) || input == null || !input.isRegister()) return null;
return currentProgram.getRegister(input);
}
/** Validate the ABI layout of a pair; a small literal is decoded here, a large one only located. */
private SwiftLiteralLayout swiftLiteralLayout(long word, long object) {
int discriminator = (int) (object >>> 60);
if (discriminator == 0xE || discriminator == 0xA) {
int count = (int) ((object >>> 56) & 0xF);
byte[] bytes = new byte[15];
for (int index = 0; index < 8; index++) bytes[index] = (byte) (word >>> (8 * index));
for (int index = 0; index < 7; index++) bytes[8 + index] = (byte) (object >>> (8 * index));
for (int index = count; index < bytes.length; index++) {
if (bytes[index] != 0) return null;
}
// Count zero is the canonical empty string (object 0xE000000000000000, word 0).
String value = swiftStringText(bytes, count, discriminator == 0xE);
return value == null ? null : new SwiftLiteralLayout("small", null, count, true, value);
}
int flags = (int) (word >>> 60);
long count = word & 0xFFFFFFFFFFFFL;
// Immortal literals: ASCII and NFC or neither, tail allocated, with a pointer biased by 32.
if ((object >>> 56) != 0x80 || (flags != 0xD && flags != 0x1) || ((word >>> 48) & 0xFFF) != 0) {
return null;
}
try {
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
.getAddress((object & 0x00FFFFFFFFFFFFFFL) + 0x20);
MemoryBlock block = currentProgram.getMemory().getBlock(address);
// The bytes and their terminator must lie in one initialized, non-executable block,
// and Java arrays bound one read.
if (block == null || !block.isInitialized() || block.isExecute() ||
count >= block.getEnd().subtract(address) + 1 || count >= Integer.MAX_VALUE) {
return null;
}
return new SwiftLiteralLayout("large", address, count, flags == 0xD, null);
} catch (Exception unaddressable) {
return null;
}
}
private String swiftLargeLiteralText(SwiftLiteralLayout layout) {
int count = (int) layout.byteLength();
byte[] bytes = new byte[count + 1];
try {
if (currentProgram.getMemory().getBytes(layout.address(), bytes) != bytes.length) return null;
} catch (Exception unreadable) {
return null;
}
return bytes[count] == 0 ? swiftStringText(bytes, count, layout.ascii()) : null;
}
/**
* Every literal record of one dossier draws on half of the heap that was free when decoding
* began. Text is charged at the worst case per UTF-8 byte: two bytes of UTF-16 text, then up
* to six escaped JSON characters ("\\u0000") held as UTF-16 in Gson's growing writer buffer
* (up to twice its length), again in the returned String, and once more as UTF-8 on the
* socket. Each record also pays a fixed cost for its map key, JSON members, addresses, words
* and their serialized form, so many empty or small literals are bounded too. Retained
* provenance-cache entries draw on that same fixed-state allowance.
*/
private static final class SwiftLiteralBudget {
private static final long BYTES_PER_LITERAL_BYTE = 2 + 6 * 2 * 2 + 6 * 2 + 6;
private static final long BYTES_PER_RECORD = 4096;
private long remaining;
private boolean stopped;
SwiftLiteralBudget() {
Runtime runtime = Runtime.getRuntime();
remaining = Math.max(0, (runtime.maxMemory() - runtime.totalMemory() + runtime.freeMemory()) / 2);
}
boolean takeState() {
if (remaining < BYTES_PER_RECORD) return false;
remaining -= BYTES_PER_RECORD;
return true;
}
boolean take(long bytes) {
if (remaining < BYTES_PER_RECORD ||
bytes > (remaining - BYTES_PER_RECORD) / BYTES_PER_LITERAL_BYTE) {
return false;
}
remaining -= BYTES_PER_RECORD + bytes * BYTES_PER_LITERAL_BYTE;
return true;
}
void stop() {
stopped = true;
}
boolean stopped() {
return stopped;
}
}
// Swift String values may contain any scalar, including controls; JSON escapes them.
private static String swiftStringText(byte[] bytes, int length, boolean ascii) {
if (length < 0 || length > bytes.length) return null;
for (int index = 0; index < length; index++) {
if (ascii && bytes[index] < 0) return null;
}
try {
return StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes, 0, length)).toString();
} catch (CharacterCodingException invalid) {
return null;
}
}
private static JsonObject swiftStringOmission(SwiftLiteralLayout layout, long word, long object) {
JsonObject item = new JsonObject();
item.addProperty("representation", layout.representation());
if (layout.address() == null) {
item.add("address", JsonNull.INSTANCE);
} else {
item.addProperty("address", canonicalAddress(layout.address()));
}
item.addProperty("byte_length", layout.byteLength());
item.addProperty("reason", "capacity");
item.add("words", swiftStringWords(word, object));
return item;
}
private static JsonObject swiftStringItem(
String value,
SwiftLiteralLayout layout,
long word,
long object
) {
JsonObject item = new JsonObject();
item.addProperty("value", value);
item.addProperty("representation", layout.representation());
if (layout.address() == null) {
item.add("address", JsonNull.INSTANCE);
} else {
item.addProperty("address", canonicalAddress(layout.address()));
}
item.add("words", swiftStringWords(word, object));
return item;
}
private static JsonArray swiftStringWords(long word, long object) {
JsonArray words = new JsonArray();
words.add("0x" + Long.toUnsignedString(word, 16));
words.add("0x" + Long.toUnsignedString(object, 16));
return words;
}
private static String exclusiveAddress(Address inclusive) {
Address next = inclusive.next();
if (next != null && next.getAddressSpace().equals(inclusive.getAddressSpace())) {
@@ -4308,14 +3930,6 @@ public final class ReaGhidraBridge extends HeadlessScript {
private record AddressNameRequest(String inputAddress, Address address, String name) {}
private record FunctionEntry(Function function, InventoryItem item) {}
private record FunctionAnalysisObservation(JsonObject dossier, List<String> limitations) {}
private record SwiftStringLiteralDecode(JsonArray literals, JsonArray omissions) {}
private record SwiftLiteralLayout(
String representation,
Address address,
long byteLength,
boolean ascii,
String smallValue
) {}
private record InstructionScan(List<Instruction> instructions, boolean truncated) {}
private record SessionDescriptor(
String transport,
+2 -15
View File
@@ -1,8 +1,6 @@
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Program;
import ghidra.program.model.mem.MemoryBlock;
import ghidra.program.model.mem.MemoryBlockSourceInfo;
/** Seed the caller-supplied raw-binary entry before default auto-analysis. */
public final class ReaGhidraPrepareRaw extends GhidraScript {
@@ -23,19 +21,8 @@ public final class ReaGhidraPrepareRaw extends GhidraScript {
);
}
Address entry = currentProgram.getAddressFactory().getDefaultAddressSpace().getAddress(entryHex);
MemoryBlock block = entry == null ? null : currentProgram.getMemory().getBlock(entry);
boolean fileStart = false;
if (block != null) {
// Processor-defined zero page/stack blocks can precede the imported file.
for (MemoryBlockSourceInfo source : block.getSourceInfos()) {
if (source.getFileBytes().isPresent() && source.getFileBytesOffset() == 0 &&
entry.equals(source.getMinAddress())) {
fileStart = true;
break;
}
}
}
if (!fileStart) {
Address start = currentProgram.getMinAddress();
if (entry == null || !entry.equals(start)) {
throw new IllegalArgumentException(
"Raw binary import must map file offset 0 at the caller-supplied entry"
);
+13 -39
View File
@@ -86,7 +86,6 @@ rea inspect-android-method /path/Example.apk example.MainActivity onCreate
rea inspect-android-method /path/Example.apk example.MainActivity select --overload-index 1
rea trace-android-references /path/Example.apk example.MainActivity
rea trace-android-references /path/Example.apk example.MainActivity --method-name onCreate
rea trace-android-references /path/Example.apk example.MainActivity --method-name select --overload-index 1
```
Equivalent MCP methods are `inspect_android_package`, `search_android_classes`,
@@ -107,10 +106,9 @@ Every request includes `path`. Class/method operations use `class_name`,
```
Use `inspect_android_class` to choose an overload. REA rejects an ambiguous
method request rather than silently selecting the first match. Method reference
queries accept the same `overload_index`; the bridge resolves the selected
method node and the result reports its index and displayed signature. These
indices are tied to the current artifact and engine, not stable across builds.
method request rather than silently selecting the first match. The engine's
method reference operation cannot select overloads, so REA reports that boundary
as unsupported when multiple same-name methods exist.
The engine's smali fallback also joins same-name overloads; REA rejects that
fallback for an overloaded method instead of attributing all bodies to one
@@ -139,28 +137,14 @@ the same source byte limit and explicit truncation reporting.
full reported display types, fields and inner class names. Synthetic members
that code generation hides can appear in the inventory; it stays consistent
after method decompilation within the retained session.
Exact DEX prototypes are reported from frozen JADX method metadata as
`dex_descriptor`, for example `(I)V`. Display names can be JADX aliases;
the descriptor alone is not a complete raw DEX method ID. Overload indices
are tied to this artifact and engine, not stable identities across builds.
Exact DEX descriptors are unknown. Overload indices are tied to this artifact
and engine, not stable identities across different builds.
- Method inspection reports Java or smali, fallback markers and complete/partial
returned text. Decompiled source is a derived representation. A complete text
result does not establish complete semantics or a byte-identical reconstruction.
- Reference results are incoming static relationships between provider nodes.
`reference_unit=using_node` means `total` counts containing JADX nodes, not
individual invoke instructions; two calls from one method can appear as one
entry. `coverage=complete` covers this node inventory only. For a method
reference whose using node has raw code, `direct_invokes` lists matching DEX
invoke instructions with their DEX entry, raw source method ID, code-unit
offset, method index and opcode. An empty array means no direct invoke was
matched in that node; `null` means raw code was unavailable or the using node
was not a method. This list does not establish complete call-site coverage
outside JADX's using-node inventory or through reflection and dynamic loading.
A virtual or interface invoke also does not establish its runtime implementation.
Source lines and the containing node's own `dex_offset` remain unknown.
Method-reference results also carry the selected `dex_descriptor`;
class-reference results report `null`.
These relationships are not observed runtime calls.
Source lines and DEX instruction offsets remain unknown. They are not observed
runtime calls.
- Original requests, APK SHA-256, actual engine JAR SHA-256 and raw producer
responses remain in Evidence. Source revision is populated only for bytes
matching the audited release JAR.
@@ -226,11 +210,9 @@ REA never writes to the original APK or launches target code.
On Windows, stdin writes preserve stream backpressure and run outside the Node
event loop. Cancellation terminates the retained job, including descendants,
before workspace cleanup. On Windows, a detached REA helper owns the private
workspace's native directory handle. An abrupt CLI/server owner termination
closes the Java job and the helper's input pipe; the helper then removes the
workspace by handle. The real Windows lane checks both effects. If the helper
also dies or the host loses power before it finishes, private files can remain.
before workspace cleanup. An abrupt CLI/server process termination closes the
job and stops Java, but cannot run JavaScript workspace cleanup; temporary
workspace files can remain after that forced exit.
If Java cannot start the bridge, REA reports the selected executable and retained
startup diagnostics. Select a full JDK through `JAVA_HOME` or `PATH`, and use
@@ -293,20 +275,12 @@ The original APK and host Java configuration remain unchanged.
The same lane runs on Windows x64 with the bundled native controls and a full
JDK. It checks CLI/MCP results, real MCP cancellation and disconnect, and Java
exit and workspace removal after abrupt CLI owner termination. Windows forced
termination is checked separately from the POSIX CLI SIGTERM handler. A
synthetic two-owner test confirms that one owner's exit does not remove the
other owner's workspace. The lane does not claim a structured cancellation
result or power-loss recovery.
exit after abrupt CLI owner termination. Windows forced termination is checked
separately from the POSIX CLI SIGTERM handler; it does not claim a structured
cancellation result or workspace removal after the owner is forcibly killed.
The download script verifies fixed SHA-256 values, reuses matching files and
refuses to overwrite a different existing file. APK/JAR files live under ignored
`_reference/` and are excluded from package contents. The lane exercises actual
REA CLI and MCP results with the real engine, separately from synthetic protocol
regressions. It needs no Gradle build, emulator, Android SDK or Ghidra.
Direct invoke sites retain exact DEX code-unit offsets separately from each
containing node's unknown `dex_offset`. `direct_invokes_exhaustive: false`
means instruction rows were bounded by the local bridge frame budget; `null`
means the producer did not establish instruction coverage. Using-node totals
and coverage do not imply complete instruction evidence.
+2 -4
View File
@@ -98,10 +98,8 @@ section, symbol, mitigation or linkage views. The default `complete` detail and
CLI output are unchanged.
Complete results have a 32 MiB input, 64 MiB reply, 1 MiB combined diagnostics
and 30-second owned command deadline. `REA_PWNTOOLS_MAX_INPUT_BYTES` raises the
input bound alone, up to 1 GiB; the reply and deadline bounds are unchanged. The
reply budget applies to the decoder record; the CLI/MCP Evidence envelope adds
copies and encoding overhead. The
and 30-second owned command deadline. The reply budget applies to the decoder
record; the CLI/MCP Evidence envelope adds copies and encoding overhead. The
pinned MCP SDK defaults to a 10 MiB receive buffer. Large complete results need
a caller-configured `StdioClientTransport({ maxBufferSize: ... })` and a matching
server `REA_MCP_MAX_RESPONSE_BYTES`. See [MCP delivery budgets](mcp-contracts.md#tool-results).
+1 -22
View File
@@ -80,27 +80,6 @@ google-chrome \
REA does not launch, own, or terminate this browser. Use a dedicated profile and stop it yourself when the investigation is complete. Do not expose the debugging port on a non-loopback interface.
Other Chromium-family browsers, such as Chromium or Brave, work the same way
with the same two flags. On macOS, run the executable inside the application
bundle, for example
`"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"` or
`"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"`. Fedora installs
Chromium as `chromium-browser`.
REA needs the browser's HTTP discovery endpoint on a loopback port. A browser
driven over `--remote-debugging-pipe` or a private channel, for example by an
automation SDK, exposes no such endpoint, so `list_browser_targets` has nothing
to attach to. Connection failures report `endpoint_unreachable`; a reachable
HTTP service that returns a non-200 or non-JSON discovery response reports
`invalid_endpoint_response`.
Reproduce that flow in a Chromium-family browser started with
`--remote-debugging-port`.
In a minimal Linux container, headed Chromium under Xvfb can keep running while
its DevTools port never opens. Install the GLES, EGL, and Mesa DRI libraries
(on Fedora: `libglvnd-gles`, `mesa-libEGL`, and `mesa-dri-drivers`), then
restart the browser.
On Linux, a reachable but locked desktop keyring can stall Chrome before its
first navigation. For a dedicated investigation profile, add
`--password-store=basic` to bypass that keyring. Empty or placeholder frame URLs
@@ -228,7 +207,7 @@ commands fail closed. A correlated reply's session ID must match the command's
selected session. An optional method is unavailable only when the browser
rejects it as unknown; other command rejections retain their reported failure.
Network and console coverage starts only after REA attaches and enables the relevant CDP domains. `prior_activity_available` is always `false`; absence from these arrays is not evidence that an event never occurred. Scripts are different: scripts the page parsed before REA attached are still reported under `scripts`. A page that loaded before attach can therefore list its scripts while `network.requests` lacks the requests that fetched them. If CDP reuses a request ID without supplying redirect metadata, REA retains the predecessor observation and marks network coverage incomplete.
Network and console coverage starts only after REA attaches and enables the relevant CDP domains. `prior_activity_available` is always `false`; absence from these arrays is not evidence that an event never occurred. If CDP reuses a request ID without supplying redirect metadata, REA retains the predecessor observation and marks network coverage incomplete.
Source maps are not fetched by inspection. Bundle analysis fetches them only when `fetch_source_maps` is selected, with exact-origin redirect checks and no credentials, cookies, or referrer. Each fetch operation has a 30-second deadline and a 64 MiB aggregate streamed-response budget across redirects. A deadline or byte-limit failure remains explicitly unavailable and makes bundle completeness partial. Source-map decoding also bounds raw resolved identities to 64 MiB and aggregate inline evidence to 32 MiB. Failed maps preserve their failure context when it fits; if another context cannot fit, collection stops with the full requested count, the returned processed count, and an explicit unknown-results limitation. Retained mappings and original sources are returned inline. See [source-location limits](web-source-location.md) for the structural decoder bounds. Original source artifacts with `.ts`, `.tsx`, `.mts`, or `.cts` suffixes carry `text/typescript`; other source names retain `text/javascript`.
+1 -3
View File
@@ -174,9 +174,7 @@ rea analyze /absolute/path/to/program --provider ghidra --snapshot /absolute/pat
rea analyze /absolute/path/to/program --provider ghidra --snapshot /absolute/path/to/analysis/program.json
```
An exact CLI cache hit is normally read before starting a provider process.
On Windows, Ghidra first admits the selected source through its native snapshot
owner, so replay can start that provider.
An exact CLI cache hit is read before starting a provider process.
In MCP, `open_binary` accepts `snapshot_path` to import a snapshot atomically
for its matching target; an MCP provider may still start before returning a
cached result. `close_binary` accepts `snapshot_path` and optional
+2 -82
View File
@@ -33,71 +33,6 @@ schema constraints; REA still validates complete canonical inputs. Full-profile
`gpt-5.4` metadata is also verified locally. Live model API acceptance and
Windows remain unverified; see [native client verification](testing.md).
## Claude Desktop
Setup registers `mcpServers.rea` in `claude_desktop_config.json`: under
`~/Library/Application Support/Claude` on macOS, under
`$XDG_CONFIG_HOME/Claude` (default `~/.config/Claude`) on Linux, and on Windows
in the file the installed app reads:
- Without Claude Desktop's MSIX package storage, setup uses
`%APPDATA%\Claude\claude_desktop_config.json`.
- The MSIX build (package family `Claude_pzs8sxrjxfjjc`, installed under
`C:\Program Files\WindowsApps\Claude_<version>_x64__pzs8sxrjxfjjc`)
virtualizes AppData writes. Once it has saved its configuration, it reads
the package-private copy
`%LOCALAPPDATA%\Packages\Claude_pzs8sxrjxfjjc\LocalCache\Roaming\Claude\claude_desktop_config.json`
and ignores the `%APPDATA%` file. Setup, doctor and uninstall use that
private copy when it exists. Before the package has saved one, they use the
`%APPDATA%` file it still reads; with neither file, setup creates the
private copy.
- If `%LOCALAPPDATA%\Packages` holds a `Claude_<publisher ID>` package from
another publisher, or cannot be read, REA cannot establish which file Claude
Desktop reads. Setup, doctor and uninstall report that instead of falling
back to `%APPDATA%`. Doctor then reports `claude_desktop` as invalid, and
uninstall stops before removing anything for any client.
Quit Claude Desktop fully before approving setup; closing its window is not
enough. A running Claude Desktop can rewrite its configuration from memory and
drop the registration. This was observed with the Windows MSIX build. Start
Claude Desktop again after setup. If the package saves its private copy after
setup planned to write the `%APPDATA%` file, setup makes no change and asks you
to rerun it.
A process that the MSIX app started, such as a terminal in Claude Desktop's
Code tab, sees the package's private files at their `%APPDATA%` paths even
though it reports no package identity. Setup compares one of those files at
both paths. When it finds that redirected view, the app is running, so setup
and uninstall refuse to change Claude Desktop's configuration; rerun them from
a terminal that Claude Desktop did not start. If the package has no private
file to compare, setup and uninstall cannot rule that out and also refuse; open
Claude Desktop once if it has never run, quit it fully, then rerun. Doctor
reads the same file from either kind of process.
The same process also writes everything under `%APPDATA%` and
`%LOCALAPPDATA%` into the package's private storage, where other apps never
read it. From that redirected view, setup and uninstall refuse to change other
clients' files there (VS Code, Trae, Devin and Hermes) and skill directories
there. `npm install -g` run from such a process installs into the package's
`LocalCache\Roaming\npm`, which other processes cannot see at the `%APPDATA%`
path. Setup refuses to register a launcher stored in any MSIX package's
private storage. Install REA from an ordinary terminal and rerun setup there.
Uninstall removes REA's entry from both the private copy and the `%APPDATA%`
file, including registrations that earlier releases wrote to `%APPDATA%`.
REA recognizes only the MSIX package. A non-MSIX Claude Desktop installed
beside it, which would read the `%APPDATA%` file, is not detected.
The package identity, both file locations, the redirected view, and these
setup and doctor results were observed on Windows 11 with the MSIX build
2.31226.1.0, using read-only `doctor` and `setup --dry-run` runs both inside
and outside the app. On that host, setup run from an ordinary terminal with
the app quit wrote the package-private copy; after the app restarted, the
entry was still present, doctor reported it aligned, and Claude Desktop's MCP
log showed REA starting and answering `tools/list`. Uninstall against the real
app is unverified.
## Claude Code
Claude Code normally defers MCP tool definitions through tool search. It
@@ -230,14 +165,6 @@ For [Qoder](https://docs.qoder.com/cli/mcp-reference), setup writes
from `~/.qoder/skills` (following `QODER_CONFIG_DIR` when set), so setup
installs its skill copy there.
## Factory Droid
For [Factory Droid](https://docs.factory.com/harness/mcp), setup writes
`mcpServers.rea` into the user-level `~/.factory/mcp.json`. Entries carry
`command`, `args`, and `env`. Droid discovers personal skills from
`~/.factory/skills` ([skills](https://docs.factory.com/harness/skills)), so
setup installs its skill copy there.
## Trae
For [Trae](https://docs.trae.ai/ide/add-mcp-servers), setup writes
@@ -282,15 +209,8 @@ replaces) the REA entry in the home patch `$DSH_HOME/cordis.patch.yml`
patches and comments. `rea doctor --client dsh` checks that same entry id.
`rea uninstall` removes it by scanning supported clients; uninstall takes no
`--client` flag. The home patch applies to every Harness profile, so the
registration is written once and no per-profile edit is needed.
Setup, doctor and uninstall resolve `DSH_HOME` and `DSH_AGENTS_HOME` as Harness
does: unset, empty or whitespace-only values use `~/.dsh` and `~/.agents`,
respectively. Bare `~`, `~/` and `~\` expand to the user home; other relative
values resolve against the command's working directory. Nonblank values retain
their original spaces, and the agents root is independent of the Harness home.
Run REA and Harness from the same working directory when using relative overrides.
The written entry is:
registration is written once and no per-profile edit is needed. The written
entry is:
```yaml
- insert:
+2 -14
View File
@@ -18,26 +18,14 @@ pass `-- chat` to check plain chat with REA enabled. Qwen also reads the full
result that its client offloads, and Pi exercises default codemode execution.
Use `REA_VERIFY_RUNTIME_ROOT` to select a production-only installed REA package.
Receipts retain client versions, result digests, host coverage and owned-process
cleanup. These lanes use a deterministic loopback model and do not prove live
model-provider compatibility. Pi and Hermes remain POSIX-only. Qwen, Pi and Hermes
cleanup. These POSIX lanes use a deterministic loopback model, so they do not
prove live model-provider or native Windows compatibility. Qwen, Pi and Hermes
bind both `HOME` and `USERPROFILE` to the disposable account. Qwen and Pi discover
the shared personal skill through their default search paths without an explicit
skill setting; Hermes discovers it through the selected `HERMES_HOME`.
Set `REA_VERIFY_HERMES_STICKY_PROFILE=1` to exercise native Hermes selection of
a named sticky profile with `HERMES_HOME` still pointing to its root.
The Qwen lane also supports native Windows (verified with Qwen Code 0.25.1 and
Node 24.19.0 for both `chat` and `call`). With Node 24.18.0, the same `call`
journey reached full Evidence readback but the client aborted during exit with
`UV_HANDLE_CLOSING`; that run remains a failure, not a compatibility claim.
Select the npm install's
`node_modules/@qwen-code/qwen-code/cli-entry.js` with `REA_VERIFY_QWEN_COMMAND`,
because `execFile` cannot launch the `qwen.cmd` shim directly. A selected `.js`,
`.mjs` or `.cjs` entry point runs under the verifier's Node. Windows path checks
use native separators; persisted output is read through Qwen's real shell tool
with a shell-independent encoded Node payload. Windows receipts report process
lineage as unavailable when the verifier has no retained native Job Object lease.
`verify:gemini-client` requires an installed Gemini CLI (verified with
`@google/gemini-cli@0.63.0` on POSIX and native Windows); select it with
`REA_VERIFY_GEMINI_COMMAND`. A selected `.js`, `.mjs` or `.cjs` entry point runs
-24
View File
@@ -39,18 +39,6 @@ license](https://github.com/morluto/rea/blob/main/third_party/evmole/README.md)
- Upstream also returns terminal metadata during selector recovery. Its reported
values remain in `raw_result`; integer precision and non-text-key coverage are
unknown in the JS binding. Full bytecode retains the original encoded metadata.
- `bytecode.metadata` independently interprets a plausible terminal CBOR root
map from the selected bytes. Integers use decimal strings; byte strings use hex,
and scalar keys retain their types. The recorded offset/length addresses the
original bytecode, so summary delivery does not copy an opaque CBOR payload.
Nested containers have partial scalar coverage; unsupported or malformed
candidates report `not-decoded` with a diagnostic. Decoding is bounded to 64
container levels and the terminal payload's encoded size.
- An unavailable trailer does not prove metadata absence or Solidity origin.
Compiler-version bytes/text are metadata declarations, not verified compiler,
source-file or deployed-contract identity. Historical omission parses as a null
unknown facet without changing the original Evidence record. Metadata/source
URLs are not fetched.
- Selectors, argument strings and state mutability are **inferences**. Function
names/signatures are not looked up. Optimized code may produce different
mutability candidates; recovered types are not a verified source ABI.
@@ -137,15 +125,3 @@ The real verification lane traces representative raw/hex CLI workflows and check
that only declared Node/prlimit launchers and process ownership inspection run.
Socket traces permit local stdio socket pairs and reject attempted network requests.
These observations verify those fixtures; they are not a general network sandbox.
For the independent metadata parser, `npm run verify:solidity:metadata` uses an
existing caller-selected solc 0.8.30 module:
```sh
REA_VERIFY_SOLC_MODULE=/absolute/path/to/solc/index.js npm run verify:solidity:metadata
```
It compiles the owned fixture with unoptimized, viaIR, content-hash-free and
CBOR-free settings, verifies the observed trailer facts and reports local decode
timings. No contract is executed. This lane does not establish the complete
Linux EVM provider's CLI/MCP, resource-limit or cleanup behavior.
+7 -31
View File
@@ -22,28 +22,6 @@ lane. MIPS16 shared images have the separate bounded profile below; other shared
or compressed images remain unsupported. Their family may be identified, but provider selection returns the
specific unsupported constraint rather than guessing a compatible profile.
A second admission covers recordless legacy firmware images
([#1620](https://github.com/morluto/rea/issues/1620)): ELF32 `ET_EXEC` headers
declaring `EF_MIPS_ARCH_32` or `EF_MIPS_ARCH_32R2` with an unspecified ABI
field, `EF_MIPS_ABI2` clear, and an _inspected_ absence of any ABI flags
record (a target whose record was merely not observed stays refused). For
these images o32 is an analysis assumption derived from the ELF32/ET_EXEC
structure, recorded as `mips_abi_basis` in the profile and stated as a
limitation in Evidence, never a declared fact; floating-point ABI semantics
stay unverified. Ghidra's own header auto-selection picks a 64-bit language
for exactly these files, so the lane pins `-processor MIPS:<LE|BE>:32:default`
through a `provider-pinned-v1` profile selection and checks the
bridge-reported `language_id` after import. A caller-selected
`REA_GHIDRA_LANGUAGE_ID`/`REA_GHIDRA_COMPILER_SPEC_ID` still wins over the
pin; combining the pinned profile with an environment override is a typed
refusal. A header that declares an ABI field but carries no record is still
refused. Two further boundaries apply: an inspection that could not examine
every ABI declaration source (for example a carved ELF whose section table
extends past the captured bytes without a program-header record) does not
establish the recordless admission, and an `EF_MIPS_ARCH_32` header that
_declares_ an ABI is refused rather than relabeled, because the declared-ABI
lane is verified for `EF_MIPS_ARCH_32R2` headers only.
Explicit `EF_MIPS_FP64` and `EF_MIPS_NAN2008` declarations are refused. Their
absence is insufficient: Clang can emit identical `e_flags` for FP32, FP64 and
soft-float targets. Target resolution also reads the 24-byte ABI flags record
@@ -62,14 +40,12 @@ absent or supply a default ABI.
The field layout follows
[`Elf_MIPS_ABIFlags_v0`](https://github.com/bminor/glibc/blob/master/elf/elf.h).
Provider admission on the declared-ABI lane above requires record version 0,
MIPS32r2, 32-bit GPRs, no CPR2, no ISA extension, ASE or unknown general
flags, and one of two floating-point declarations: 32-bit CPR1 with a
double-precision or FPXX ABI, or no CPR1 with the soft-float ABI. Other
combinations, including single-float, FP64 and unspecified ABIs, are refused.
Missing records remain unknown rather than defaulting to a compatible ABI, and
the declared-ABI lane refuses them; the recordless legacy admission above is
the only path that admits a recordless image. Unknown record
Provider admission requires record version 0, MIPS32r2, 32-bit GPRs, no CPR2,
no ISA extension, ASE or unknown general flags, and one of two floating-point
declarations: 32-bit CPR1 with a double-precision or FPXX ABI, or no CPR1 with
the soft-float ABI. Other combinations, including single-float, FP64 and
unspecified ABIs, are refused. Missing records remain unknown rather than
defaulting to a compatible ABI, and this lane refuses them. Unknown record
values are retained for diagnosis but do not gain provider support. These checks
bind the declared interpretation; the fixtures do not establish full
floating-point behavioral conformance.
@@ -135,7 +111,7 @@ MIPS header and ABI record facts are committed in the analysis profile so
interpretation changes invalidate profile-bound snapshots. The support-lane
identity is versioned independently of file identity. Evidence, lifecycle
outputs and saved snapshots preserve the `mips` family. Hopper and IDA adapters
do not implicitly gain MIPS support. Windows Ghidra P0 does not admit MIPS targets.
do not implicitly gain MIPS support. Windows Ghidra P0 remains PE x86/x86-64 only.
### Soft-float prototype
+3 -4
View File
@@ -131,10 +131,9 @@ overview merely because its source artifact is unchanged.
Function extents and instruction completeness refer to Ghidra's analyzed body.
An inferred no-return call can terminate that body before subsequent bytes.
Pseudocode, assembly, procedure info, instruction windows, callee and caller
reads, and dossiers report observed terminal call sites in Evidence limitations,
including resolved callee names, addresses, and FunctionManager flags when
available. Instruction windows
Pseudocode, assembly, procedure info, instruction windows, and dossiers report
observed terminal call sites in Evidence limitations, including resolved callee
names, addresses, and FunctionManager flags when available. Instruction windows
and dossiers also retain these warnings in their result limitations. An
unresolved callee or unestablished terminal-flow basis remains explicit. These
observations qualify the analysis; they do not prove that a no-return flag is
+2 -2
View File
@@ -11,8 +11,8 @@ ELF `EM_RISCV` (243) is retained as the provider-neutral `riscv` family. The
target also retains the raw ELF declarations that decide a lane: class, byte
order, `e_type`, and `e_flags`. Recognition does not mean every provider or
RISC-V variant is supported. Existing PE, managed and Mach-O architecture sets
are unchanged. Headerless inputs use the separate [raw-binary admission
boundary](raw-binary.md); raw RISC-V images are not supported.
are unchanged, and caller-supplied headerless raw-binary entry still accepts only
its existing four families.
The Ghidra lane admits little-endian ELF32 and ELF64 `ET_EXEC` images that
declare `EF_RISCV_FLOAT_ABI_DOUBLE` in `e_flags`, with no `EF_RISCV_RVE`,
-13
View File
@@ -4,15 +4,6 @@ REA adapts an existing IDA MCP server into its native analysis tools. Existing I
Choose one lifecycle mode in an upstream MCP JSON registration. Set `REA_IDA_MCP_CONFIG` to that file, then select `ida` once through `REA_ANALYSIS_PROVIDER`, the CLI's `--provider ida`, or MCP's `open_binary.provider_id`. No provider startup occurs during discovery.
## Supported upstream versions
Each mode accepts one upstream tool profile, checked against `tools/list` before any analysis runs. Upstream's `main` branch has since moved to 2.0.0, which renamed the GUI tools and dropped `idb_close`, so it serves neither profile. Installing from that branch fails at startup with the missing tool names in the error diagnostics.
| Mode | Upstream profile | Known working |
| -------- | ------------------- | ------------------------------------------------------ |
| attached | legacy GUI tools | 1.4.0 |
| headless | database supervisor | builds serving `idb_open`, `idb_list`, and `idb_close` |
## Analyze an already-open GUI target
The attached profile supports upstream **1.4.0 legacy tools**, including `get_metadata`, `list_functions`, and `decompile_function`. Copy the upstream registration's `command`, `args`, and optional `env` into a JSON file. An existing `mcpServers` object with an `ida-pro-mcp` entry is also accepted. For example:
@@ -97,8 +88,6 @@ REA copies the admitted binary into a private temporary directory, checks its di
One REA session owns one database. Stdio registrations default `IDA_MCP_MAX_WORKERS` to `1`; the explicit upstream `--max-workers 1` also bounds a supervisor. Database size and engine memory depend on the target and upstream; REA does not impose an engine memory limit. The original binary is never executed or overwritten. Optional `workspaceRoot` selects an existing absolute temporary-directory parent on the REA host. Ensure it has space for the input copy and IDA working files.
When a completed `idb_open` response reports success or is otherwise ambiguous, but no active owned private worker can be verified, REA retains the private workspace even if `idb_list` appears empty. A later close can retry verification if the original owned worker becomes discoverable. A completed explicit `idb_open` rejection retains the existing no-worker cleanup path. This is a local admission/ownership safeguard and **does not** provide a process-termination receipt or change the legacy reaped-worker protocol.
Headless cleanup uses the upstream database lifecycle. Abruptly terminating REA, its proxy, or the supervisor does not establish worker release. Prefer `close_binary` for MCP sessions; after an interrupted process, inspect upstream session inventory and retained workspace diagnostics before removing working files. This adapter does not claim Windows Job Object containment for the upstream detached workers.
## Connect an agent once
@@ -137,8 +126,6 @@ For guided registration, set `REA_IDA_MCP_CONFIG` before `rea setup --client <cl
All profiles support procedure/string inventory and literal or regex search, procedure resolution, pseudocode, assembly, raw function instructions, resolved callees, incoming address references, and a function dossier. Inventories and modern pseudocode/assembly follow upstream pagination. Upstream limits on modern xrefs and callees are retained as explicit truncation limitations. In the modern headless profile, targets that are not proven x86-only use processor-neutral `callgraph` edges instead of the x86 instruction checks in upstream `callees` (mrexodia/ida-pro-mcp#549). REA requests depth zero: the pinned upstream emits the root's outgoing edges without scanning child functions, then REA batches function-name lookup for those addresses. The graph covers recognized function destinations only; imports and other non-function targets remain unknown. A server without `callgraph` returns an empty relationship list with an explicit coverage limitation. Graph and path workflows retain those limitations alongside observed edges. A failed or inconsistent graph reply remains an error. A transport-level truncated preview is rejected rather than reported as complete evidence; the error preserves upstream retrieval guidance.
The modern headless profile also supports `resolve_containing_procedure` and `list_documents`. An address resolves through upstream `lookup_funcs` to the canonical entry of the function that contains it; the body stays unavailable like other IDA identities. When no function contains it, REA reads the database-scoped minimal `survey_binary` and reports `not_in_procedure` for an address inside one of its segments (ends are exclusive) and `outside_segments` otherwise. A missing, truncated or inconsistent survey fails the read instead of guessing a reason. `list_documents` returns the single admitted program path, so `inspect_address_context` and `trace_feature` need no explicit document. `trace_feature` marks a trace truncated, with a residual unknown for that address, when upstream caps the references it returned. The legacy and native profiles do not declare these operations.
The legacy profile additionally supports direct callers. Its producer may report call-site or interior addresses; REA resolves the reported function symbol to its canonical entry. The modern and native profiles do not prove direct callers from its code/data xref labels, so that capability is unavailable and the dossier marks it unknown.
Complete body ranges, typed reference edges, CFG, referenced data, source locals, comment kinds, and database revision are unavailable. Empty unsupported dossier facets mean **unknown**, not observed absence. `binary_overview`, whole-symbol inventory, mutation, debugging, arbitrary Python forwarding, and requested GUI navigation are outside this adapter's coverage. Check session availability before composing a workflow that needs those facets.
+2 -8
View File
@@ -20,9 +20,7 @@ npx rea-agents setup
Choose the agents to configure, review the proposed paths and changes, and
approve the plan. npm's package-download approval and REA's setup approval are
separate. Setup asks before changing agent configuration or installing Hopper.
Restart the agents named in the completion message. Quit Claude Desktop fully
before approving, because a running app can overwrite the registration; on
Windows, see [where Claude Desktop reads its configuration](client-configuration.md#claude-desktop).
Restart the agents named in the completion message.
For the latest published release, use `npx rea-agents@latest setup`.
[Package-runner and global-installation options](#package-runners-and-global-installation)
@@ -285,7 +283,6 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s
| Pi | `pi` |
| Hermes | `hermes` |
| Qoder | `qoder` |
| Factory Droid | `droid` |
| Trae | `trae` |
| ZCode | `zcode` |
| DeepSeek Harness | `dsh` |
@@ -515,10 +512,7 @@ and `set_addresses_names` name a function entry or any other mapped address
session metadata edits leave executable bytes unchanged and are discarded on close. GUI controls
require Hopper; Windows P0 remains read-only.
Windows P0 admits native x86 and x86-64 PE applications and DLLs, plus explicitly
selected [raw NMOS 6502 images](raw-binary.md#nmos-6502) with a caller-supplied
entry at file offset 0, on fixed local NTFS volumes. Other raw CPU families remain
unsupported on Windows.
Windows P0 admits native x86 and x86-64 PE applications and DLLs on fixed local NTFS volumes.
The npm package bundles native Job Object ownership, protected private runtime
DACLs, and handle-based path admission; no separate addon installation is needed.
See the [Windows Ghidra P0 guide](windows-ghidra-p0.md) for verified scope and
-19
View File
@@ -208,25 +208,6 @@ arrays, and later explicit data properties and methods replace earlier object
spread references. A known array prefix does not expose values from a following
spread. Accessors, custom or uncertain iteration, unknown computed keys, and
unknown spread lengths retain conservative uncertainty.
Getter-local aliases of `this`, including direct destructuring and assignments,
retain their receiver when a returned shared reference is written. These writes
invalidate the earlier literal instead of claiming the getter executed or the
new value is known. Straight-line alias reads retain their capture order;
uncertain control flow remains conservative. A binding outside the getter or
`this` in a nested ordinary function is not attributed to the getter's receiver.
Private reads keep receiver projections separate from the private value's path.
Receiver projections follow known data fields initialized from `this` or earlier
self-referencing fields; static fields retain their declaring class identity.
An alias cycle that repeatedly selects deeper children retains escape uncertainty
at its recovered roots rather than claiming a fixed number of loop iterations.
Writes through an object getter's returned receiver retain that uncertainty
when the object is an array element, nested inside mixed object/array
containers, or assigned to a previously declared identifier. Saving the getter
result in an alias does not make its old field values immutable. Known array
indices include holes; an element after a spread has only a minimum possible
index. Unrelated properties and known array prefixes remain precise, and a
getter read without a write does not invalidate the receiver's fields.
Nested callable returns are not assigned to their parent callable. Projected
graph observations carry source ranges but never source text.
+8 -4
View File
@@ -352,10 +352,14 @@ creation. Arguments follow each actual callable invocation rather than property
chain depth; rebinding retains the original bound receiver.
Visibly replaced invocation intrinsics produce unknown effects instead
of using their former call semantics; captured function aliases retain their
earlier identity. Unexamined targets remain unresolved. `Reflect.set` examines
its explicit receiver as well as its target, retaining unknown receiver effects.
Creating an unused binding does not itself mutate an intrinsic. Variable
redeclarations retain a single unconditional initializer
earlier identity. Invocation member reads are checked separately from the earlier
function capture. Comma-expression callees use their final value while all
preceding expressions remain in the effect traversal. Call effects are
classified after callee and argument effects have been collected. Unexamined
targets remain unresolved. `Reflect.set` examines its explicit receiver as well
as its target, retaining unknown receiver effects. Creating an unused binding
does not itself mutate an intrinsic. Variable redeclarations retain a single
unconditional initializer
when they introduce no assignment or second initializer.
Stable identifier chains to fresh object, array, function,
undecorated class or RegExp syntax keep those targets separate from intrinsic
-22
View File
@@ -147,28 +147,6 @@ opposite observation's selection unknown, and application graphs report their
selected scope as partial. Neither makes an absence claim about the whole
assembly. Older member Evidence without `selection` remains readable.
### Edge target declaring types
`member_refs[].parent_name`, `call_edges[].target_declaring_type`, and
`field_accesses[].field_declaring_type` name the type that declares each
target. Referenced spec signatures validate encoded type coordinates against
the metadata tables, including nested type arguments and custom modifiers.
TypeRef resolution scopes are checked transitively with cycle detection;
readable names survive malformed scopes as partial evidence.
TypeDef and TypeRef names use the namespace-qualified `full_name`
form, and nested types keep bare names. A generic-instantiation TypeSpec
parent names its generic type, such as ``System.Collections.Generic.List`1``.
Other well-formed TypeSpec forms and ModuleRef or MethodDef parents stay
`null`. A `method-spec` call edge reports the name and declaring type of the
generic method it instantiates. A field access is named only by a field or a
field-signature MemberRef. TypeSpec and MethodSpec blobs are decoded with the
member-signature grammar, and a MethodSpec must instantiate a generic method
with as many type arguments as it declares. Malformed or unsupported rows and
blobs, and arity mismatches, make coverage partial. A readable MethodSpec
method column that names a method, or a TypeRef whose resolution scope is
invalid, keeps its name.
Older member Evidence without these fields reads them as `null`: unknown.
## Analysis objective
Start by identifying the artifact with `inspect_managed_artifact`, then inspect
+2 -5
View File
@@ -476,11 +476,8 @@ the tool fails with a `debugger-attach-denied` reason.
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/arm64 and macOS x64/arm64; each installation requires
the matching native decompiler. Experimental Windows x64 P0 admits native
x86 and x86-64 PE applications and DLLs, plus explicitly selected
[raw NMOS 6502 images](raw-binary.md#nmos-6502) with a caller-supplied load address,
on fixed local NTFS volumes. Other raw CPU families remain unsupported on Windows.
The boundary uses bundled Job Object ownership, protected runtime DACLs, and
handle-based path admission. See [Windows Ghidra P0](windows-ghidra-p0.md) and
x86 and x86-64 PE applications and DLLs on local NTFS using bundled Job Object ownership,
protected runtime DACLs, and handle-based path admission. See [Windows Ghidra P0](windows-ghidra-p0.md) and
[issue #527](https://github.com/morluto/rea/issues/527).
On Linux and macOS, `annotate_native_function` atomically edits a function name
and entry comments in the ephemeral database, returning refreshed analysis
+10 -53
View File
@@ -25,12 +25,10 @@ rea inspect-native-load-image /absolute/path/to/ecu.bin --target-format raw-bina
}
```
`architecture` is one of `x86`, `x86_64`, `arm`, `arm64`, or `6502`. `entry_point` is a
`architecture` is one of `x86`, `x86_64`, `arm`, or `arm64`. `entry_point` is a
hexadecimal address of the form `0x…`. The complete file is mapped starting at
that address, and Ghidra seeds an `entry` function there before auto-analysis.
ARM and AArch64 entries must be 4-byte aligned; x86 and 6502 entries may be byte-aligned.
For 6502, the entry and complete mapped file must fit within `0x0000..0xffff`;
images that cross the 16-bit boundary are rejected before provider startup.
ARM and AArch64 entries must be 4-byte aligned; x86 entries may be byte-aligned.
`open_binary` and an open `binary_session` return the canonical `entry_point`
for raw targets so callers can verify the selected mapping directly.
Omitted `format` keeps header-based detection; architecture and entry without
@@ -39,13 +37,12 @@ Omitted `format` keeps header-based detection; architecture and entry without
Ghidra imports a private snapshot with `BinaryLoader` and these little-endian
generic languages and compiler specifications:
| architecture | language_id | compiler_spec_id |
| ------------ | -------------------- | ---------------- |
| x86 | `x86:LE:32:default` | `gcc` |
| x86_64 | `x86:LE:64:default` | `gcc` |
| arm | `ARM:LE:32:v7` | `default` |
| arm64 | `AARCH64:LE:64:v8A` | `default` |
| 6502 | `6502:LE:16:default` | `default` |
| architecture | language_id | compiler_spec_id |
| ------------ | ------------------- | ---------------- |
| x86 | `x86:LE:32:default` | `gcc` |
| x86_64 | `x86:LE:64:default` | `gcc` |
| arm | `ARM:LE:32:v7` | `default` |
| arm64 | `AARCH64:LE:64:v8A` | `default` |
Compiler specifications select analysis conventions; they do not identify the
original compiler or ABI of a headerless target. Ghidra's x86 languages do not
@@ -60,51 +57,11 @@ The mapped start is independent of Ghidra's program image base, which remains
`0x0` for BinaryLoader. Ghidra may report additional language-defined entry
points, including unmapped ARM vectors. Load-image verification requires the
caller-supplied entry and preserves those other entries as observations.
For 6502, BinaryLoader maps the file before adding its language-defined zero-page
and stack blocks. Conflicting default blocks are skipped; low-address files stay
in the default address space rather than an overlay. Non-conflicting uninitialized
blocks remain observations, not file-backed bytes.
Independent raw verification streams the complete private snapshot digest and
extent with bounded memory and supports cancellation during the read.
It also requires the source-backed block to be initialized, loaded, and
non-overlay in the bound language's default address space. Contradictory mapping
observations are retained with `mismatch`, even when the source hashes agree.
Hopper and IDA refuse raw-binary targets. Windows Ghidra P0 admits native PE
applications/DLLs and explicitly selected raw 6502 images. Other raw CPU families
remain outside the Windows boundary. Native path admission, private DACLs, Job
Object ownership, immutable snapshots, and the no-mutation policy still apply.
## NMOS 6502
```bash
rea function /absolute/path/to/image.bin 0x8001 --target-format raw-binary --architecture 6502 --entry-point 0x8001 --provider ghidra --json
rea inspect-native-load-image /absolute/path/to/image.bin --target-format raw-binary --architecture 6502 --entry-point 0x8001 --provider ghidra --json
```
Select only headerless NMOS 6502 code with a known entry at file offset 0.
The built-in Ghidra processor supplies static instructions, functions,
pseudocode, and references. Evidence preserves `architecture: "6502"`, the
selected language, entry, file digest, and verified source mapping. Register and
calling conventions are analysis conventions, not a recovered source ABI.
This lane does not interpret iNES headers, cartridge mappers, bank switching,
or other console-specific memory layouts. 65C02 and 65816 programs need different
processor semantics and are not admitted as those variants. REA does not infer
the CPU from the file suffix, guess the reset vector, or emulate the target.
`npm run verify:ghidra:6502` assembles and links the source-owned fixture with
`ca65` and `ld65` (tested from cc65 tag `V2.19`, whose tools report `V2.18`).
Set `REA_CA65` and `REA_LD65` to explicit executable paths if needed. The lane
checks real CLI/MCP load-image parity, mapped bytes, decoded calls, decompilation,
invalid mappings, unchanged source bytes and runtime cleanup.
Low-memory checks include a file spanning the zero-page/stack boundary at `0xff`
and a complete 64-KiB image at `0x0`, with verified default-space mappings through
both CLI and MCP. On Windows, the lane also checks that a caller-selected
junction path is rejected at snapshot admission through CLI and MCP, with
unchanged source bytes and complete runtime cleanup. Windows requires the
matching native addon; the POSIX provider path has not been exercised by the
Windows run.
Hopper and IDA refuse raw-binary targets. Windows Ghidra P0 continues to admit
native PE applications and DLLs only; raw-binary targets are unsupported.
## Limitations
+3 -7
View File
@@ -72,13 +72,9 @@ debugger context is `observed`.
Initial Linux x64 limits: 32 MiB input, 64 MiB decoder reply, 1 MiB diagnostics per
stage, 30 seconds owned wall deadline per stage, 3 GiB address-space soft ceiling
and 30 seconds CPU soft ceiling. `REA_PWNTOOLS_MAX_INPUT_BYTES` raises the input
bound alone, up to 1 GiB, and returns `provider_unavailable` when it is not a
decimal integer in range; the reply, diagnostic and deadline bounds stay fixed, so a
large core that outruns them still fails explicitly instead of partially. Tighter
inherited soft/hard limits are retained; hard limits are not raised. Complete
oversized results fail explicitly. These process limits do not constitute a sandbox
for caller-supplied engine code.
and 30 seconds CPU soft ceiling. Tighter inherited soft/hard limits are retained;
hard limits are not raised. Complete oversized results fail explicitly. These
process limits do not constitute a sandbox for caller-supplied engine code.
Resource exit statuses need matching private failure-branch markers. Unmarked
launcher failures retain their actual status/output and unknown resource cause.
-33
View File
@@ -1,33 +0,0 @@
# Implementation plan
1. Use cborg 6.1.3 (Apache-2.0) as a pinned format decoder, with a bounded
tokenizer at its owning adapter boundary. Decode maps as Map, preserve integer
precision and strictly validate emitted text bytes before conversion.
2. Add the portable metadata-result schema in src/domain/evm/. Use exact byte
ranges into the already retained bytecode rather than copying an opaque CBOR
hex payload into every selected summary.
3. Add src/evm/SolidityMetadata.ts to interpret the terminal length/root-map
candidate. Return unavailable, decoded, partial or not-decoded facts; keep
unsupported nested values explicit instead of recursively expanding them.
4. Compute the facts in EvmoleInterfaceWorker from its selected bytes. The host
provider validates ranges/identity and projects the additive metadata field.
Preserve raw engine output without rewriting its unknown-precision metadata.
5. Ensure the canonical EVM bytecode shape carries the optional field into complete
and summary Evidence. Include decoding profile, coverage and limitations.
6. Add producer, worker, historical contract and public MCP boundary coverage.
Compile an owned Solidity fixture with a pinned caller-supplied solc module,
and verify its CBOR compiler version. Run relevant static/architecture checks.
7. Extend the existing real Linux CLI/MCP verifier with independent metadata
assertions. Run the compiler-setting matrix in its CI lane and classify its
verifier as EVM-owned, with a production CI-planner regression.
No new execution provider or target authority is introduced: a format parser is
added inside the existing EVM worker. Linux x64 provider admission, target-free
semantics, cleanup, limits and complete original Evidence remain unchanged.
No metadata JSON or source URL is automatically acquired.
The parser bounds nesting to 64 and token/collection growth to the selected
terminal payload's byte count (the format's terminal length is at most 65535).
Every data item consumes encoded bytes; broad declared collection lengths cannot
justify allocation without that lower bound. Expanded results still pass the
worker's existing 16 MiB output check.
-43
View File
@@ -1,43 +0,0 @@
# Solidity terminal metadata inspection
Issue: https://github.com/morluto/rea/issues/2388
## Outcome
The existing EVM interface operation should retain independently decoded terminal
CBOR candidate facts from the exact selected bytes instead of relying on precision
unknowns in the engine's reported metadata. This increment covers trailer ranges,
bounded decoding, exact integer/non-text scalar key evidence and compiler-version
representations.
## Requirements
- Preserve the existing carrier/decoded identities and original full bytecode.
- Report a plausible terminal root-map candidate separately from proof of Solidity
origin. Missing or non-map trailers remain unavailable, not proven absent.
- Interpret scalar root-map keys/values losslessly: integers as decimal strings,
bytes as hex, strings, booleans and null. Preserve undecoded/unsupported values
through the original bytecode plus exact candidate offset/length.
- Report partial and not-decoded coverage, parser/profile failures and unsupported
CBOR forms explicitly. Do not silently coerce unknown keys into object properties.
- Interpret admitted solc release/prerelease representations only from observed
entries; preserve ambiguity if duplicate compiler-version keys occur.
- Keep the additive field compatible with historical Evidence and retain
it through selected summary delivery and public CLI/MCP.
Historical omission parses as a null unknown facet without mutating the source.
- Bound nesting before decoder recursion and collection lengths before allocation.
Keep parsing inside the existing owned worker and its resource/output budgets.
- Do not fetch metadata or source content, contact RPC or execute contract code.
## Acceptance and limits
Owned compiler fixtures and malformed/synthetic trailers cover integers beyond
Number precision, non-text keys, nested/uninterpreted values, invalid UTF-8,
invalid lengths, unsupported tags and nesting bounds. Public boundary tests
preserve the field and older omission. A real pinned solc artifact verifies actual
metadata facts; the existing Linux-only EVM host profile remains unchanged.
## Deferred issue criteria
Binding a supplied metadata JSON to IPFS/Swarm commitments is a later independent
increment of #2388. This PR must reference rather than close the whole issue.
@@ -1,8 +0,0 @@
# Tasks
- [x] Define single-feature requirements and implementation plan before code.
- [x] Add the pinned decoder and bounded owner boundary.
- [x] Implement lossless scalar metadata facts and compiler-version evidence.
- [x] Integrate worker/provider contracts and summary/historical compatibility.
- [x] Verify producer, worker and public boundary behavior with owned fixtures.
- [x] Run relevant repository checks and prepare a separately scoped PR.
@@ -1,43 +0,0 @@
# Verification
After integrating upstream c462a4ebd, 47 tests passed
across the parser, domain contract, application service, compiled worker and MCP
boundary (five files). Compiled-worker goldens include BOM text, partial nested
values, unavailable trailers, malformed UTF-8 and unsupported floats. Five
upstream adapter tests also passed after its stricter integer validation landed.
All six static tasks and architecture guards
passed. After integration, test discovery found all 1004 files exactly once.
The CI-planner selection passed all 85 tests, including the new EVM-verifier case.
The parser regressions cover exact large integers and typed keys, malformed UTF-8,
duplicate keys, excessive nesting, unsupported tags/floats, length bounds and
historical omission. A real defect in the decoder's default text handling removed
an initial U+FEFF. The owning tokenizer now validates and decodes the retained
text bytes with BOM preservation; independent fixtures check both values and
compiler-key identity.
The owned fixture was compiled with caller-selected solc 0.8.30, using unoptimized,
optimized via-IR, content-hash-free and CBOR-free settings. All four metadata
checks passed again after integration. No contract was executed. Local first decode
took approximately 0.81 ms or less. The originally reported approximately 2.92 ms
for 100 warm iterations included equality assertions and is not a decode-only
measurement. The verifier now measures parsing separately from those assertions;
updated warm timings have not been collected. These historical figures describe
these fixtures and this host, not a general workload.
Peak process RSS
included the compiler (299536 KiB); complete process-family CPU/memory and agent
token costs were not measured.
Compiled worker tests exercise unchanged EVMole WASM on this macOS host. MCP tests
use the production server and contracts with an injected provider seam. The full
Linux-only provider CLI/MCP, resource-limit and cleanup lane remains unverified
here. Metadata JSON commitment binding remains deferred under issue #2388.
The Linux CLI/MCP verifier asserts exact compiler bytes, declared version and
candidate ranges for its existing owned raw/hex artifacts. Its CI lane additionally
runs the new compiler-setting matrix. A regression using the actual CI planner
checks that edits to the new verifier select the EVM lane.
The later merge/review correction adds decoded-summary and omitted historical
metadata coverage. Tests and provider reruns for that correction were skipped at
the maintainer's request; the earlier runs above do not validate the changed head.
+1 -13
View File
@@ -682,9 +682,7 @@ use `--second-procedure rea_fixture_subtract --second-marker='- 13'`.
The lane invokes the production CLI dispatcher and connects the pinned MCP
client SDK to the production REA server. It verifies function Evidence and
CLI/MCP parity, inventory/search, pseudocode, instructions, xrefs, malformed
input, original-input preservation, and lifecycle cleanup. The headless
profile also checks containing procedures, the admitted program as the
document, address context, and feature tracing. For headless
input, original-input preservation, and lifecycle cleanup. For headless
analysis it confirms that the owned database IDs disappear from upstream
discovery and private workspaces are removed. For attached analysis it confirms
the existing GUI target remains reachable with the same input identity.
@@ -734,16 +732,6 @@ truncation constraint, while leading-zero coordinates still resolve correctly.
It has the same Ghidra/JDK prerequisites as the MZ lane. Neither lane claims DOS
runtime or PC-98 device execution.
`npm run verify:ghidra:6502` uses `ca65` and `ld65` to build the source-owned
NMOS 6502 fixture from `tests/conformance/ghidra/6502.s` and `6502.cfg`. Set
`REA_CA65` and `REA_LD65` for non-PATH installations (tested with cc65 `V2.19`).
With an installed Ghidra 12.1.4 and full JDK, it verifies real CLI/stdio MCP
mapping parity, exact source bytes, functions, a direct call reference,
decompilation, 16-bit overflow rejection without replacing the active session,
and runtime cleanup. Windows additionally requires the matching native addon;
its verifier lineage remains explicitly unavailable outside the provider's
retained Job Object, as in the other Windows lanes.
`npm run verify:ghidra:raw` uses a generated ARM little-endian headerless image
through the compiled CLI and stdio MCP server. With the same Ghidra/JDK
prerequisites, it checks preparation and bridge startup, verified load mappings,
+3 -11
View File
@@ -17,16 +17,14 @@ additional permission flag or degraded mode is required.
- Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+;
- 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 x86 or x86-64 PE application/DLL, or a
[raw NMOS 6502 image](raw-binary.md#nmos-6502) with a caller-supplied entry; and
- an explicit native, non-managed x86 or x86-64 PE application or DLL; and
- the 25 read-only Ghidra inventory, memory, and function-analysis operations.
Loaded memory reads and file offsets preserve Ghidra's observed source mappings.
PE load-image inspection returns an explicit unsupported attestation status.
Independent verification covers admitted DOS MZ, explicit COM, and explicit
[raw-binary imports](raw-binary.md) on their supported hosts, including the
admitted raw NMOS 6502 mapping on Windows. Windows P0 admission remains limited
to native PE and raw NMOS 6502 images. For x64 PE
[raw-binary imports](raw-binary.md) on their supported hosts. This does not
extend Windows P0 admission beyond native PE. For x64 PE
NativeAOT RTR 9.1, the read-only snapshot parser can report metadata through
`inspect_native_load_image` and `inspect_native_data_type` without a JAR or
database mutation. A real Windows-host NativeAOT Ghidra run is still required
@@ -37,12 +35,6 @@ state, annotations, other target architectures and formats, and Hopper-only
operations remain unavailable through this provider. Managed PE/CLI inspection
uses its separate execution-free provider.
Raw 6502 imports retain the same native filesystem and process ownership
requirements. The complete image must fit in the 16-bit address space; no
console-specific cartridge loader, CPU variant, or bank-switching model is
implied. Run `npm run verify:ghidra:6502` with caller-installed `ca65`/`ld65`
to check the source-built fixture through real CLI and MCP workflows.
REA does not install or upgrade Ghidra, Java, Python, npm, Node.js, Hopper, or a
compiler. The adapter uses the packaged Java `HeadlessScript`; Python and
PyGhidra are not prerequisites. Users do not build the native addon.
@@ -94,9 +94,6 @@ def main():
if undeclared:
raise ValueError('Declare inherited provider settings explicitly in --providers: '
+ ', '.join(undeclared) + '. Values are not logged.')
source_auth_home = (Path(os.environ['CODEX_HOME']) if 'CODEX_HOME' in os.environ
else Path.home() / '.codex')
source_auth = source_auth_home / 'auth.json'
args.output.mkdir(parents=True, exist_ok=False, mode=0o700)
runtime_files, runtime_digest = runtime_fingerprint(repository)
(args.output / 'runtime-files.json').write_text(json.dumps(runtime_files))
@@ -106,6 +103,7 @@ def main():
workspace = profile / 'workspace'
for directory in [trial_codex_home, account, workspace]:
directory.mkdir(mode=0o700)
source_auth = Path(os.environ.get('CODEX_HOME', str(Path.home() / '.codex'))) / 'auth.json'
auth = trial_codex_home / 'auth.json'
started = time.monotonic()
timeout = False
@@ -71,9 +71,7 @@ class ContextCaptureTests(unittest.TestCase):
self.assertNotIn(marker, text)
self.assertIn('caller-selected evidence and paths', text)
self.assertIn('original-response', text)
# Windows mode bits do not describe access control; this is a POSIX assertion.
if os.name == 'posix':
self.assertEqual((output / 'codex-tool-context.jsonl').stat().st_mode & 0o777, 0o600)
self.assertEqual((output / 'codex-tool-context.jsonl').stat().st_mode & 0o777, 0o600)
def test_missing_rollouts_are_unknown_and_malformed_lines_remain_partial(self):
with tempfile.TemporaryDirectory() as directory:
@@ -126,7 +124,6 @@ class ContextCaptureTests(unittest.TestCase):
patch.object(agent.subprocess, 'check_output', return_value='v24.18.0\n'), \
patch.object(agent.subprocess, 'Popen', return_value=child), \
patch.object(agent, 'capture_tool_context', side_effect=OSError('fixture capture failure')), \
patch.object(agent.Path, 'home', side_effect=AssertionError('Explicit CODEX_HOME must not resolve the default home')), \
patch('builtins.print'):
with self.assertRaisesRegex(OSError, 'fixture capture failure'):
agent.main()
@@ -1,128 +0,0 @@
# Windows reconciliation of recorded POSIX paths
Follow-up to [#2422](https://github.com/morluto/rea/pull/2422), including
@Aurantium-47's diagnosis of the host-dependent relative-path operation.
## Source pins and real data
The initial reproduction and passive producer used main
fa4b8288655188426222773ead2ccbdaf76334fd. Before branching, main was updated
to 8b181716aca2cb467be499cba226abfb5ae19141. The owning matcher and its
affected reconciliation consumers were unchanged between those revisions.
The newer baseline again reproduced both errors on the real Windows host.
Input: npm 11.16.0's published lib/utils/validate-lockfile.js, 1,023 bytes,
SHA-256 2295e2dac1c5816e1eae2d417804960ebbef17b64a0a44db4f3483ce6e6d9526.
The installed copy was independently checked against the exact module in the
[official registry package](https://registry.npmjs.org/npm/11.16.0).
The 2,945,850-byte tarball matched its published SHA-512 SRI before reading
that member; no archive extraction or installation occurred, and the tarball
was not retained.
Two actual Linux filesystem directories, Runtime/App and Runtime/app,
held byte-identical module copies and had distinct directory identities.
A controlled Node 24.18.0 process loaded the public module and ran its real
function against 587 locked dependency records: compatible trees returned zero
errors, and one controlled version mismatch returned one error. A passive REA
Inspector capture recorded 114 loaded scripts, including the selected module.
The provider did not evaluate, pause, resume or read source. The owned process
exited normally. Function execution is established by the harness receipt;
REA's passive script metadata establishes loaded status.
Production static analysis inspected both directories plus an independent
Static/public-module copy. The latter prevents implicit root matching from
hiding the explicit mapping result. Complete static/runtime Evidence was then
consumed by the actual **Windows** CLI and SDK 2.3.1 stdio MCP server, using
Windows 11 10.0.22631.0 and official Windows x64 Node 24.18.0.
No Electron GUI, IDA or Ghidra engine was used.
## Correctness and provenance
| Windows reconciliation | Baseline | Candidate |
| -------------------------------------- | ------------------------------ | ----------------------------------- |
| Exact POSIX input root | Matched, artifact path | Same complete normalized result |
| Case-mismatched POSIX input root | Incorrectly matched and loaded | Unknown; static load states unknown |
| Exact explicit POSIX mapping | Matched, operator file mapping | Same complete normalized result |
| Case-mismatched explicit POSIX mapping | Incorrectly matched and loaded | Unknown; static load states unknown |
All four CLI and same-connection MCP normalized results agree. The input
Evidence, recorded runtime facts and caller-selected root spellings are
preserved. Rejected location membership does not establish that a module was
absent or unexecuted. The operation remains offline and performs no target or
filesystem inspection.
The shared matcher now chooses posix.relative after identifying POSIX input;
Windows coordinates continue to use win32.relative. Both location matching
and active-layer scope use this owner. The obsolete host-default import is
removed; no downstream repair or new path abstraction is introduced.
The related suite passed 102 tests: 15 workflow tests, 85 CI scope tests and
two curated-path checks. Four simulated Electron Evidence controls cover
POSIX mismatch/exact matching, Windows case-insensitive matching, the resulting
asset load state and retained mapping spelling. They are service regressions,
not real Electron-provider verification. The existing workflow test file is
registered in both the Windows execution lane and its package-change scope.
## Delivery and resource observations
Probes ran serially at BelowNormal priority with one CPU and 512 MiB Node heap
settings for CLI/server/client work. Heap settings do not bound aggregate memory.
Four corrected CLI exit-time observations reported 7.343–15.827 seconds CPU and
322,308–324,280 KiB peak working set, excluding the probe parent. Wall times
ranged from 7.785 to 17.601 seconds. There is no paired resource baseline.
One complete MCP trial connected in 18,296 ms and discovered 189 tools in
875 ms (2,588,336 serialized bytes). The four subsequent same-connection calls
took 879, 320, 353 and 422 ms. Their serialized replies were 918,133 / 913,765 /
918,571 / 914,171 bytes; inputs totalled 681,312 bytes. Discovery plus these
inputs and replies totalled 6,934,288 bytes, excluding initialization, framing
and notifications. This is delivery volume, not model token usage. Actual
model context and total model task cost were not measured.
A process-generation sampler recorded 233 observations over 25,671 ms:
15,797 ms aggregate observed CPU as a lower bound and a peak sampled working-set
sum of 491,597,824 bytes (about 469 MiB). Exited-process tails can be missed;
shared pages can be counted twice. This excludes the observer and is not an
exact physical-memory peak. The server wrote four structured completion records
to stderr; tool replies and normalized results passed the recorded checks.
Cold/warm caches and observer differences were not normalized. No latency,
CPU, memory or model-token improvement is claimed. The fix adds no lookup I/O,
engine invocation, retry or cache. Complete default replies remain available;
only compact selected findings and hashes were sent to the investigating agent.
## Main integration replay
Runtime cb0d8a9fef2b907cff2ba27f1e017493e1173eb2, rebased onto main
73dd8d6ed, again passed the 101 related tests and all four real Windows CLI/MCP
cases using the unchanged older producer Evidence. Positive results and original
runtime/layer facts remain identical. Typecheck, lint, dependency and metadata
checks passed; all five changed files passed formatting independently.
The full format gate at that main revision failed on the unchanged upstream
GhidraDoctor.test.ts and GhidraSnapshotCache.test.ts files. The PR does not
repair those unrelated baseline files. Resource figures above refer to the
earlier candidate; the integration replay does not establish a speedup.
Review found that owning only the Windows test path would still skip that lane
on a later implementation-only edit. The matcher implementation is now also
registered in package scope. A source-only classifier case verifies that it
selects both Windows package and JavaScript recovery lanes; 85 scope checks and
two curated-path checks pass. Runtime code and the real-data checks are unchanged.
The original production fix merged as
[1a1b67c88](https://github.com/morluto/rea/commit/1a1b67c88) while this follow-up
was open. The follow-up was then rebased onto main fb28ec153 and contains only
the stronger workflow assertions, Windows lane ownership, scope classification
and this verification record. The updated 131 related tests, two representative
Ghidra boundary tests, seven IDA input-thinning tests, 24 semantic-structure
tests, 16 required-lane checks, 39 release-checkpoint tests and 11 dyld CLI
tests passed after the rebase (230 total).
Upstream a0317cf73 corrected the invalid FAT fixture and consolidated unrelated
duplicated helpers; e05d3399f aligned the older parameter-provenance assertion
with the upstream semantics. Those changes, the threshold change and the new
CI qualification tiers are part of main, outside this follow-up's diff.
Upstream 747e72991 restored release preflight coverage for reusable workflow
jobs. The independently reviewed, fully green #2755 fixture correction merged
as fb28ec153 and restored real x64 dyld CLI admission without altering the
original same-named arm64e assertions.
No production source is duplicated by the follow-up.
+1 -6
View File
@@ -2,7 +2,7 @@ REA npm dependency inventory
Prepared from the root package-lock.json; refresh this inventory when locked dependencies change.
License identifiers are upstream lockfile declarations, not a legal assessment.
Runtime includes transitive and optional platform packages; development includes build, test and website tooling.
Locked package entries: 587; runtime: 175.
Locked package entries: 586; runtime: 174.
Full runtime copyright/license/NOTICE texts: npm-runtime-notices.txt.
@algolia/abtesting@1.25.0 | development | MIT
@@ -1430,11 +1430,6 @@ canonicalize@3.0.0 | runtime | Apache-2.0
Package: https://www.npmjs.com/package/canonicalize/v/3.0.0
Archive: https://registry.npmjs.org/canonicalize/-/canonicalize-3.0.0.tgz
Integrity: sha512-yYLfHyDMIXRyRqsKBRLX023riFLpXY2YOfdtqKXZRZy9qsfOJ9U+4F9YZL7MEzL5+ziN2x2nlBvY/Voi3EBljA==
cborg@6.1.3 | runtime | Apache-2.0
Lockfile path: node_modules/cborg
Package: https://www.npmjs.com/package/cborg/v/6.1.3
Archive: https://registry.npmjs.org/cborg/-/cborg-6.1.3.tgz
Integrity: sha512-Kb/wD/AFuVcqvVjR+HYNOjNiuhVJ2C+RWxQE2Yqq2WVlVkRjdvVLkkM6QsL3NMsBRh+rgNc+aXmcraA+X0HObQ==
ccount@2.0.1 | development | MIT
Lockfile path: node_modules/ccount
Package: https://www.npmjs.com/package/ccount/v/2.0.1
+1 -229
View File
@@ -1,7 +1,7 @@
REA third-party npm runtime copyright, license and NOTICE texts
Collected from integrity-verified locked npm archives. Release-specific supplements and upstream notice gaps are identified in their entries.
External engines are separately obtained; see THIRD_PARTY_NOTICES.md.
Runtime dependency fingerprint (SHA-256): 63d5b55e027a0cfb3e77989ca337aca905f1652d10cfbfc4d4dd876d3ecd4f3a
Runtime dependency fingerprint (SHA-256): 5de9cf97e7c327da1892efeff30b40507e4c7f8e496b5d305b8016c162bc15e3
===== @babel/helper-string-parser@8.0.0 =====
Declared license: MIT
@@ -2456,234 +2456,6 @@ Scope: npm runtime dependency, including optional platform packages.
limitations under the License.
===== cborg@6.1.3 =====
Declared license: Apache-2.0
Archive: https://registry.npmjs.org/cborg/-/cborg-6.1.3.tgz
Integrity: sha512-Kb/wD/AFuVcqvVjR+HYNOjNiuhVJ2C+RWxQE2Yqq2WVlVkRjdvVLkkM6QsL3NMsBRh+rgNc+aXmcraA+X0HObQ==
Scope: npm runtime dependency, including optional platform packages.
--- package/LICENSE ---
Copyright 2020 Rod Vagg
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
--- Apache-2.0 full license (licenses/Apache-2.0.txt) ---
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
===== clean-git-ref@2.0.1 =====
Declared license: Apache-2.0
Archive: https://registry.npmjs.org/clean-git-ref/-/clean-git-ref-2.0.1.tgz
+7 -16
View File
@@ -90,10 +90,8 @@ without this lane reports Windows native controls as unavailable.
root and ancestor leases for retry; a partially removed runtime accepts only
cleanup, and its registry entry remains until removal succeeds. Normal close, cancellation,
startup failure, and timeout release provider resources. Abrupt owner death
kills the owned job. The Android provider additionally uses a detached helper
that owns its private runtime directory and cleans it when the owner pipe
closes. Other providers can leave private runtime files after owner death;
helper death and host power loss are not transactional deletion.
kills the owned job; it can leave private runtime files for later operator
cleanup and does not claim transactional deletion after a crash.
Windows failures retain their constraint, requested coordinate, Win32 code,
and system message. Unsupported filesystems and reparse paths are distinct
@@ -117,10 +115,8 @@ the supported device/UNC namespaces; existing admission checks still apply.
The bearer-token descriptor remains under its immutable private lease until
`runtime_close` on Windows; POSIX removes the descriptor after the bridge reads
it. Job ownership guarantees process termination on owner death. Android's
detached workspace helper provides a separate cleanup owner for that provider,
but simultaneous helper death or power loss can still leave private files.
SUBST drive aliases and changing DOS-device
it. Job ownership guarantees process termination on owner death, not deletion
of private files after a crash. SUBST drive aliases and changing DOS-device
namespaces are outside the verified P0 scope and are not detected as a separate
alias policy. Mounted-folder paths that report a reparse tag are rejected by
component admission; broader namespace variants remain unverified. Preserve
@@ -167,14 +163,9 @@ V8 heap flags cannot bound (limit notification, peak commit at the limit, all
members settled, with the notification present in the first native exit report
and the wrapper's exit event), and a control under the same limit that
completes normally.
A separately built `tests/fixtures/windows/processBoundary.cc` executable adds
breakaway, caller/child token and environment semantics observations and an
in-place ancestor reparse attempt. The lane skips all of them when the
executable is absent, so both Windows workflows cross-build it in the same
MinGW job that builds their artifact and pass it: the capability and package
lane in `.github/workflows/ci.yml`, and the real Ghidra proof run in
`.github/workflows/real-ghidra-windows.yml`. Build it locally with any Windows
C++ compiler, or the same way they do, and pass its path:
An optional independently built
`tests/fixtures/windows/processBoundary.cc` executable adds breakaway and
caller/child token observations and an in-place ancestor reparse attempt:
```powershell
node scripts/verify-windows-native.mjs . C:\fixtures\process-boundary.exe
-10
View File
@@ -24,7 +24,6 @@
"ajv": "8.20.0",
"ajv-formats": "3.0.1",
"canonicalize": "3.0.0",
"cborg": "6.1.3",
"cross-spawn": "7.0.6",
"diff": "9.0.0",
"evmole": "0.9.3",
@@ -4182,15 +4181,6 @@
"node": ">=18"
}
},
"node_modules/cborg": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/cborg/-/cborg-6.1.3.tgz",
"integrity": "sha512-Kb/wD/AFuVcqvVjR+HYNOjNiuhVJ2C+RWxQE2Yqq2WVlVkRjdvVLkkM6QsL3NMsBRh+rgNc+aXmcraA+X0HObQ==",
"license": "Apache-2.0",
"bin": {
"cborg": "lib/bin.js"
}
},
"node_modules/ccount": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
-4
View File
@@ -62,7 +62,6 @@
"bridge/android/ReaJadxBridge.java",
"bridge/mitmproxy/capture.py",
"scripts/rea.mjs",
"scripts/rea-android-workspace-guardian.mjs",
"scripts/electron-active-hook.cjs",
"scripts/electron-active-hook-boundaries.cjs",
"scripts/hopper-demo-x11.py",
@@ -175,7 +174,6 @@
"verify:ghidra:seeds": "npm run build:cached && node scripts/verify/ghidra/seeds.mjs",
"verify:ghidra:mips": "npm run build:cached && node scripts/verify-real-ghidra-mips.mjs",
"verify:ghidra:riscv": "npm run build:cached && node scripts/verify-real-ghidra-riscv.mjs",
"verify:ghidra:6502": "npm run build:cached && node scripts/verify-real-ghidra-6502.mjs",
"verify:ghidra:mips-shared": "npm run build:cached && node scripts/verify-real-ghidra-mips-shared.mjs",
"verify:ghidra:mips-relocatable": "npm run build:cached && node scripts/verify-real-ghidra-mips-relocatable.mjs",
"verify:ghidra:swift": "npm run build:cached && node scripts/verify-real-ghidra-swift.mjs",
@@ -271,7 +269,6 @@
"verify:wasm:package": "npm run build:cached && node scripts/verify-packaged-wasm-artifact.mjs",
"verify:wasm:artifact": "npm run build:cached && node scripts/verify-wasm-artifact.mjs",
"verify:evm:interface": "npm run build:cached && node scripts/verify-evm-interface.mjs",
"verify:solidity:metadata": "npm run build:cached && node scripts/verify-solidity-metadata.mjs",
"verify:recorded:crash": "npm run build:cached && node scripts/verify-recorded-crash.mjs",
"mcp-catalog:check:built": "node scripts/generate-mcp-tool-catalog.mjs --check",
"compile": "node scripts/clean-build-output.mjs && tsc -p tsconfig.build.json && node scripts/bundle-runtime.mjs",
@@ -311,7 +308,6 @@
"ajv": "8.20.0",
"ajv-formats": "3.0.1",
"canonicalize": "3.0.0",
"cborg": "6.1.3",
"cross-spawn": "7.0.6",
"diff": "9.0.0",
"evmole": "0.9.3",
-6
View File
@@ -55,12 +55,7 @@ export const SCOPES = {
"tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts",
"tests/boundary/filesystem/setupUnavailableProvider.test.ts",
"tests/boundary/filesystem/windowsNpxRegistration.test.ts",
"tests/boundary/android/workspaceGuardianRetry.test.ts",
"src/android/JadxWorkspaceRoot.ts",
"scripts/rea-android-workspace-guardian.mjs",
"tests/acceptance/setup/homeDirectoryFallback.test.ts",
"tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts",
"src/domain/javascript/javascriptRuntimeStaticCandidates.ts",
"tests/boundary/filesystem/webScriptManifestPaths.test.ts",
"tests/boundary/process/ProcessEvidence.host.test.ts",
"tests/acceptance/webModuleCli.test.ts",
@@ -221,7 +216,6 @@ export const SCOPES = {
"src/artifacts/readStableArtifact*.ts",
"src/artifacts/ArtifactReader.ts",
"scripts/verify-evm-interface.mjs",
"scripts/verify-solidity-metadata.mjs",
"scripts/fixtures/evm-interface.sol",
"third_party/evmole/**",
],
-9
View File
@@ -1,9 +0,0 @@
export function assertFeatureTrace(
trace: unknown,
procedure: { readonly address: string },
): void;
export function findMappedNonFunction(
strings: readonly { readonly address: string }[],
resolveAt: (address: string) => Promise<unknown>,
): Promise<string | undefined>;
-52
View File
@@ -1,52 +0,0 @@
import assert from "node:assert/strict";
const NEGATIVE_REASONS = new Set(["not_in_procedure", "outside_segments"]);
/**
* Assert the `trace_feature` result of the real IDA lane for one selected
* procedure. The query is a substring search, so the trace may also match other
* procedures and strings; each reference is checked against its own matched
* target, and a source outside every function (a data reference) is valid.
*/
export function assertFeatureTrace(trace, procedure) {
assert.ok(
trace.matches.some(
({ type, address }) =>
type === "procedure" && address === procedure.address,
),
"The trace omitted the selected procedure's match.",
);
assert.equal(typeof trace.truncated, "boolean");
const matched = new Set(trace.matches.map(({ address }) => address));
for (const reference of trace.references) {
assert.ok(
matched.has(reference.target_address),
`A reference targets ${reference.target_address}, which is not a match.`,
);
const { containing_procedure: containing } = reference;
if (containing.found)
assert.equal(typeof containing.procedure.address, "string");
else assert.ok(NEGATIVE_REASONS.has(containing.reason));
}
}
/**
* Find a string that IDA maps outside every function. A string inside a
* function chunk (inline data in code) is valid and skipped, so the strings are
* tried in order. Any other answer must be "not in a procedure": IDA lists only
* mapped strings, so an unmapped answer is a containment defect and fails the
* lane. Returns the address, or undefined when every string is inside a function.
*/
export async function findMappedNonFunction(strings, resolveAt) {
for (const { address } of strings) {
const resolved = await resolveAt(address);
if (resolved.found) continue;
assert.equal(
resolved.reason,
"not_in_procedure",
`The listed string at ${address} resolved as ${resolved.reason}.`,
);
return address;
}
return undefined;
}
@@ -1,46 +0,0 @@
#!/usr/bin/env node
import { setTimeout as delay } from "node:timers/promises";
import { WindowsPrivateRuntime } from "../dist/windows/WindowsPrivateRuntime.js";
if (process.platform !== "win32" || process.argv.length !== 3)
throw new Error(
"Android workspace guardian requires Windows and one parent directory",
);
// This process owns the native directory handle. Detached process creation
// keeps it alive when the REA owner is terminated without running JS cleanup.
const runtime = WindowsPrivateRuntime.create(process.argv[2], "rea-android-");
const ready = await new Promise((resolve) => {
process.stdout.once("error", () => resolve(false));
process.stdout.write(
JSON.stringify({
path: runtime.observation.path,
volumeSerial: runtime.observation.volumeSerial,
fileId: runtime.observation.fileId,
privateDacl: runtime.observation.privateDacl,
}) + "\n",
(error) => resolve(error == null),
);
});
// The owner holds the write end until close/death. A failed readiness pipe
// also grants cleanup authority, including owner death during startup.
if (ready) {
for await (const _ of process.stdin) {
// No commands are accepted; only EOF grants cleanup authority.
}
} else {
process.stdin.destroy();
}
// A failed native walk retains the directory identity lease for a later retry.
// Keep this process alive so a transient sharing violation cannot orphan it.
for (;;) {
try {
await runtime.close();
process.exit(0);
} catch {
await delay(1000);
}
}
-20
View File
@@ -225,26 +225,6 @@ try {
for (const mode of ["cli", "mcp"]) {
const report = await inspect(mode, path, encoding);
assert.equal(report.bytecode.hex, hex);
assert.equal(report.bytecode.metadata.status, "decoded");
assert.deepEqual(report.bytecode.metadata.compiler_version, {
representation: "release-bytes",
value: "0.8.30",
});
assert.equal(
report.bytecode.metadata.bytecode_offset +
report.bytecode.metadata.cbor_bytes +
2,
Buffer.from(hex, "hex").length,
);
assert.ok(
report.bytecode.metadata.entries.some(
({ key, value }) =>
key.kind === "text" &&
key.value === "solc" &&
value.kind === "bytes" &&
value.value === "00081e",
),
);
assert.deepEqual(
report.functions
.filter(({ dispatch }) => dispatch === "abi")
-1
View File
@@ -76,7 +76,6 @@ export async function verifyPackageEnvironment({
HOME: home,
USERPROFILE: home,
APPDATA: join(home, "AppData", "Roaming"),
LOCALAPPDATA: join(home, "AppData", "Local"),
CLAUDE_CONFIG_DIR: home,
CODEX_HOME: codexDir,
COPILOT_HOME: join(home, ".copilot"),
-1
View File
@@ -98,7 +98,6 @@ export async function verifyPackageUpdate({
HOME: home,
USERPROFILE: home,
APPDATA: join(home, "AppData", "Roaming"),
LOCALAPPDATA: join(home, "AppData", "Local"),
CLAUDE_CONFIG_DIR: home,
CODEX_HOME: join(home, ".codex"),
COPILOT_HOME: join(home, ".copilot"),
-1
View File
@@ -118,7 +118,6 @@ async function verifyPublishedUpdate({ version: targetVersion, canaryRoot }) {
HOME: home,
USERPROFILE: home,
APPDATA: join(home, "AppData", "Roaming"),
LOCALAPPDATA: join(home, "AppData", "Local"),
CLAUDE_CONFIG_DIR: home,
CODEX_HOME: join(home, ".codex"),
COPILOT_HOME: join(home, ".copilot"),
+17 -30
View File
@@ -8,7 +8,7 @@ import {
realpath,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve, sep } from "node:path";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { createHash } from "node:crypto";
import { exec } from "./lib/verify-package-core.mjs";
@@ -18,6 +18,10 @@ import {
toolContract,
} from "../dist/contracts/toolContracts.js";
assert(
process.platform !== "win32",
"The Qwen real-client verifier currently requires a POSIX host; native Windows is unverified.",
);
const repo = fileURLToPath(new URL("..", import.meta.url));
const runtimeRoot = process.env.REA_VERIFY_RUNTIME_ROOT ?? repo;
const lab = await mkdtemp(join(tmpdir(), "rea-qwen-client-"));
@@ -28,13 +32,6 @@ assert(
"Usage: node scripts/verify-qwen-client.mjs [chat|call]",
);
const command = process.env.REA_VERIFY_QWEN_COMMAND ?? "qwen";
// npm exposes only qwen.cmd on Windows. Resolve a selected JavaScript entry
// before the verifier switches to its disposable workspace.
const entryPoint = /\.(c|m)?js$/iu.test(command) ? resolve(command) : undefined;
const launch = (args) =>
entryPoint === undefined
? [command, args]
: [process.execPath, [entryPoint, ...args]];
const verifier = createVerifierRun();
const profile = join(lab, "selected profile");
const account = join(lab, "account");
@@ -57,18 +54,14 @@ const baseEnvironment = {
let clientVersion;
try {
clientVersion = (
await exec(...launch(["--version"]), {
await exec(command, ["--version"], {
env: baseEnvironment,
timeout: 30_000,
})
).stdout.trim();
} catch (cause) {
const windowsHint =
process.platform === "win32"
? " On Windows, select node_modules/@qwen-code/qwen-code/cli-entry.js."
: "";
throw new Error(
`Qwen client preflight failed for ${command}; install Qwen Code or set REA_VERIFY_QWEN_COMMAND.${windowsHint} ${String(cause)}`,
`Qwen client preflight failed for ${command}; install Qwen Code or set REA_VERIFY_QWEN_COMMAND. ${String(cause)}`,
);
}
const original = JSON.stringify(
@@ -100,8 +93,8 @@ for (const step of [
assert(
result.plannedActions.every(
(a) =>
a.target.startsWith(profile + sep) ||
a.target.startsWith(sharedSkills + sep),
a.target.startsWith(profile + "/") ||
a.target.startsWith(sharedSkills + "/"),
),
);
await writeFile(
@@ -170,14 +163,7 @@ const fixture = await createOpenAiModelFixture({
skillSeen ||= JSON.stringify(body.messages ?? []).includes(
"reverse-engineer-anything",
);
const messageText = (body.messages ?? [])
.map((message) =>
typeof message.content === "string"
? message.content
: (message.content ?? []).map((part) => part.text ?? "").join("\n"),
)
.join("\n");
selectedSkillLoaded ||= messageText.includes(
selectedSkillLoaded ||= JSON.stringify(body.messages ?? []).includes(
join(sharedSkills, "reverse-engineer-anything"),
);
const toolText = JSON.stringify(
@@ -257,7 +243,9 @@ const fixture = await createOpenAiModelFixture({
const invoke = tools.find((t) => t.name === "tool_call");
const skill = tools.find((t) => t.name === "skill");
const shell = tools.find((t) => t.name === "run_shell_command");
const persisted = messageText.match(/Full output saved to: ([^\r\n]+)/u);
const persisted = toolText.match(
/Full output saved to: ([^\\"\n]+)(?:\\n|\n)/u,
);
const call = (name, args) => ({
role: "assistant",
content: null,
@@ -280,14 +268,13 @@ const fixture = await createOpenAiModelFixture({
artifactPath = persisted[1];
assert(
(await realpath(artifactPath)).startsWith(
(await realpath(runtime)) + sep,
(await realpath(runtime)) + "/",
),
);
const script = `const fs=require('node:fs');const e=JSON.parse(fs.readFileSync(${JSON.stringify(artifactPath)},'utf8'));console.log(JSON.stringify({REA_CLIENT_FIXTURE:JSON.stringify(e.normalized_result).includes('REA_CLIENT_FIXTURE'),evidence_id:e.evidence_id,graph_nodes:e.normalized_result.graph.nodes.length}));`;
// Keep paths and JSON quotes out of shell syntax on Windows and POSIX.
const encodedScript = Buffer.from(script).toString("base64");
const quote = (value) => "'" + value.replaceAll("'", "'\\''") + "'";
message = call(shell.name, {
command: `node -e "eval(Buffer.from('${encodedScript}','base64').toString('utf8'))"`,
command: `node -e ${quote(script)}`,
description:
"Read the complete saved REA Evidence and verify the fixture facts.",
});
@@ -342,7 +329,7 @@ const args = [
let response;
let failure;
try {
response = await exec(...launch(args), {
response = await exec(command, args, {
env,
cwd: workspace,
timeout: 120_000,
-479
View File
@@ -1,479 +0,0 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import {
mkdir,
mkdtemp,
readFile,
readdir,
rm,
symlink,
unlink,
writeFile,
} from "node:fs/promises";
import { join } 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 { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
if (process.argv.length !== 2)
throw new Error("Usage: node scripts/verify-real-ghidra-6502.mjs");
if (process.env.GHIDRA_INSTALL_DIR === undefined)
throw new Error(
"Set GHIDRA_INSTALL_DIR to a bring-your-own Ghidra installation.",
);
const exec = promisify(execFile);
const run = createVerifierRun();
const root = fileURLToPath(new URL("..", import.meta.url));
const ca65 = process.env.REA_CA65 ?? "ca65";
const ld65 = process.env.REA_LD65 ?? "ld65";
const toolchain = {};
for (const [name, command] of Object.entries({ ca65, ld65 })) {
const version = await exec(command, ["--version"], { timeout: 10000 });
toolchain[name] = (version.stdout + version.stderr).trim();
}
await mkdir(join(root, "build"), { recursive: true });
const workspace = await mkdtemp(join(root, "build", "rea-6502-proof-"));
const runtime = join(workspace, "runtime");
await mkdir(runtime);
const path = join(workspace, "image.bin");
const object = join(workspace, "image.o");
const entrypoint = join(root, "scripts", "rea.mjs");
const env = {
...process.env,
TMPDIR: runtime,
TMP: runtime,
TEMP: runtime,
REA_LOG_LEVEL: "silent",
REA_ANALYSIS_PROVIDER: "ghidra",
REA_PROCESS_RUN_ID: run.run_id,
};
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env,
stderr: "pipe",
});
const client = new Client({ name: "rea-real-6502-proof", version: "1" });
let stderr = "";
transport.stderr?.on("data", (chunk) => {
stderr = (stderr + chunk.toString()).slice(-65536);
});
let report;
try {
await exec(
ca65,
[join(root, "tests/conformance/ghidra/6502.s"), "-o", object],
{ timeout: 10000 },
);
await exec(
ld65,
["-C", join(root, "tests/conformance/ghidra/6502.cfg"), "-o", path, object],
{ timeout: 10000 },
);
const bytes = await readFile(path);
// Independently known opcodes and link addresses from the source fixture.
assert.equal(bytes.toString("hex"), "a907201080c90ad003a92a60a9006018690360");
const sha256 = createHash("sha256").update(bytes).digest("hex");
await client.connect(transport);
const opened = await call("open_binary", {
path,
format: "raw-binary",
architecture: "6502",
entry_point: "0x008001",
provider_id: "ghidra",
});
assert.equal(opened.architecture, "6502");
assert.equal(opened.entry_point, "0x8001");
const status = await call("binary_session");
const image = evidence(await call("inspect_native_load_image"), sha256);
assert.equal(
image.normalized_result.status,
"verified",
JSON.stringify(image.normalized_result),
);
assert.equal(image.normalized_result.architecture, "6502");
assert.equal(image.normalized_result.base_address, "0x8001");
assert.equal(image.normalized_result.language_id, "6502:LE:16:default");
assert.equal(image.normalized_result.compiler_spec_id, "default");
assert.equal(image.normalized_result.file_bytes, bytes.length);
const memory = evidence(
await call("read_bytes", { address: "0x8001", length: bytes.length }),
sha256,
);
assert.equal(memory.normalized_result.bytes_hex, bytes.toString("hex"));
const procedures = evidence(await call("list_procedures"), sha256);
for (const address of ["0x8001", "0x8010"])
assert.ok(
procedures.normalized_result.some(
(procedure) => procedure.address === address,
),
);
const instruction = evidence(
await call("inspect_native_instruction", { address: "0x8003" }),
sha256,
);
assert.equal(instruction.normalized_result.mnemonic.toUpperCase(), "JSR");
assert.ok(
instruction.normalized_result.references.some(
(reference) => reference.call && reference.target_address === "0x8010",
),
);
const pseudo = evidence(
await call("procedure_pseudo_code", { procedure: "0x8010" }),
sha256,
);
assert.match(pseudo.normalized_result, /\+\s*(?:3|0x3|'\\x03')/u);
const filePseudo = evidence(
await call("procedure_pseudo_code", { procedure: "file-offset:0xf" }),
sha256,
);
assert.equal(filePseudo.normalized_result, pseudo.normalized_result);
assert.ok(
filePseudo.locations.some(
(location) => location.kind === "file-offset" && location.offset === 15,
),
);
const rejected = await client.callTool({
name: "open_binary",
arguments: {
path,
format: "raw-binary",
architecture: "6502",
entry_point: "0xffff",
},
});
assert.equal(rejected.isError, true);
assert.match(JSON.stringify(rejected.content), /6502 address width/u);
const preserved = await call("binary_session");
assert.equal(preserved.analysis_run.run_id, status.analysis_run.run_id);
assert.equal(preserved.entry_point, "0x8001");
await call("close_binary");
await client.ping();
const { stdout } = await exec(
process.execPath,
[
entrypoint,
"inspect-native-load-image",
path,
"--target-format",
"raw-binary",
"--architecture",
"6502",
"--entry-point",
"0x8001",
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
);
const cli = parseEvidence(JSON.parse(stdout));
assert.deepEqual(cli.normalized_result, image.normalized_result);
assert.equal(cli.analysis_profile.digest, image.analysis_profile.digest);
assert.equal(cli.subject.digest.sha256, sha256);
const fileCli = await exec(
process.execPath,
[
entrypoint,
"decompile",
path,
"--file-offset=0xf",
"--target-format",
"raw-binary",
"--architecture",
"6502",
"--entry-point",
"0x8001",
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
);
const fileCliEvidence = parseEvidence(JSON.parse(fileCli.stdout));
assert.equal(fileCliEvidence.normalized_result, filePseudo.normalized_result);
assert.deepEqual(fileCliEvidence.locations, filePseudo.locations);
assert.equal(fileCliEvidence.subject.digest.sha256, sha256);
assert.deepEqual(await readFile(path), bytes);
assert.deepEqual(await readdir(runtime), []);
const lowMemory = [];
// BinaryLoader maps file bytes before trying processor-defined blocks. Prove
// that overlapping zero-page/stack definitions never overlay the selected image.
for (const [entry, fullAddressSpace] of [
[0xff, false],
[0, true],
]) {
const lowPath = join(workspace, `low-${entry}.bin`);
await exec(
ld65,
[
"-C",
join(root, "tests/conformance/ghidra/6502.cfg"),
"--start-addr",
String(entry),
"-o",
lowPath,
object,
],
{ timeout: 10000 },
);
const linked = await readFile(lowPath);
const expectedCode = Buffer.from(
"a907200000c90ad003a92a60a9006018690360",
"hex",
);
expectedCode.writeUInt16LE(entry + 15, 3);
assert.deepEqual(linked, expectedCode);
const lowBytes = fullAddressSpace
? Buffer.concat([linked, Buffer.alloc(65536 - linked.length)])
: linked;
await writeFile(lowPath, lowBytes);
const lowSha256 = createHash("sha256").update(lowBytes).digest("hex");
const lowEntry = `0x${entry.toString(16)}`;
const lowOpened = await call("open_binary", {
path: lowPath,
format: "raw-binary",
architecture: "6502",
entry_point: lowEntry,
provider_id: "ghidra",
});
assert.equal(lowOpened.entry_point, lowEntry);
const lowImage = evidence(
await call("inspect_native_load_image"),
lowSha256,
);
assert.equal(
lowImage.normalized_result.status,
"verified",
JSON.stringify(lowImage.normalized_result),
);
assert.equal(lowImage.normalized_result.base_address, lowEntry);
assert.equal(lowImage.normalized_result.file_bytes, lowBytes.length);
const fileMappings =
lowImage.normalized_result.observations.mappings.filter(
(mapping) => mapping.source_file_index !== null,
);
assert.equal(fileMappings.length, 1);
assert.equal(
fileMappings[0].address_space,
lowImage.normalized_result.observations.default_address_space,
);
assert.equal(fileMappings[0].overlay, false);
assert.equal(fileMappings[0].start, lowEntry);
assert.equal(fileMappings[0].length, lowBytes.length);
const lowRead = evidence(
await call("read_bytes", { address: lowEntry, length: linked.length }),
lowSha256,
);
assert.equal(lowRead.normalized_result.bytes_hex, linked.toString("hex"));
const lowCall = evidence(
await call("inspect_native_instruction", {
address: `0x${(entry + 2).toString(16)}`,
}),
lowSha256,
);
assert.equal(lowCall.normalized_result.mnemonic.toUpperCase(), "JSR");
assert.ok(
lowCall.normalized_result.references.some(
(reference) =>
reference.call &&
reference.target_address === `0x${(entry + 15).toString(16)}`,
),
);
await call("close_binary");
const lowCli = await exec(
process.execPath,
[
entrypoint,
"inspect-native-load-image",
lowPath,
"--target-format",
"raw-binary",
"--architecture",
"6502",
"--entry-point",
lowEntry,
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
);
const lowCliEvidence = evidence(JSON.parse(lowCli.stdout), lowSha256);
assert.deepEqual(
lowCliEvidence.normalized_result,
lowImage.normalized_result,
);
assert.equal(
lowCliEvidence.analysis_profile.digest,
lowImage.analysis_profile.digest,
);
assert.deepEqual(await readFile(lowPath), lowBytes);
assert.deepEqual(await readdir(runtime), []);
lowMemory.push({
entry: lowEntry,
bytes: lowBytes.length,
sha256: lowSha256,
});
}
let windowsReparseAdmission = "not-applicable";
if (process.platform === "win32") {
const junction = join(workspace, "linked");
await symlink(workspace, junction, "junction");
try {
const linkedPath = join(junction, "image.bin");
// Reopening the same bytes through a junction must replace the direct session.
await call("open_binary", {
path,
format: "raw-binary",
architecture: "6502",
entry_point: "0x8001",
provider_id: "ghidra",
});
const directImage = evidence(
await call("inspect_native_load_image"),
sha256,
);
assert.equal(directImage.normalized_result.status, "verified");
// Canonical file identity must not bypass admission of the new path.
await call("open_binary", {
path: linkedPath,
format: "raw-binary",
architecture: "6502",
entry_point: "0x8001",
provider_id: "ghidra",
});
// open_binary selects a lazy session; the first analysis admits the snapshot.
const linkedImage = await client.callTool(
{ name: "inspect_native_load_image", arguments: {} },
{ timeout: 240000 },
);
assert.equal(linkedImage.isError, true);
assert.match(
JSON.stringify(linkedImage),
/Reparse path component is unsupported/u,
);
await call("close_binary");
const snapshotPath = join(workspace, "direct-snapshot.json");
const cliArgs = (selectedPath) => [
entrypoint,
"analyze",
selectedPath,
"--target-format",
"raw-binary",
"--architecture",
"6502",
"--entry-point",
"0x8001",
"--provider",
"ghidra",
"--snapshot",
snapshotPath,
"--json",
];
const saved = await exec(process.execPath, cliArgs(path), {
env,
timeout: 240000,
maxBuffer: 16 * 1024 * 1024,
});
assert.equal(
evidence(JSON.parse(saved.stdout), sha256).operation,
"binary_overview",
);
const savedSnapshot = JSON.parse(await readFile(snapshotPath, "utf8"));
assert.ok(
savedSnapshot.workflow_entries.some(
({ operation }) => operation === "binary_overview",
),
);
assert.deepEqual(await readdir(runtime), []);
// Replaying a matching snapshot must still reach native path admission.
await assert.rejects(
exec(process.execPath, cliArgs(linkedPath), {
env,
timeout: 240000,
maxBuffer: 16 * 1024 * 1024,
}),
(cause) => {
assert.match(
cause.stdout + cause.stderr,
/Reparse path component is unsupported/u,
);
return true;
},
);
assert.deepEqual(await readFile(path), bytes);
assert.deepEqual(await readdir(runtime), []);
windowsReparseAdmission = "cli-snapshot-mcp-rejected";
} finally {
await unlink(junction);
}
}
report = {
ok: true,
mocked: false,
lane: "ghidra-6502",
host: process.platform,
toolchain,
provider: image.provider,
target: {
sha256,
bytes: bytes.length,
architecture: "6502",
entry: "0x8001",
},
cli_mcp_load_image_parity: true,
instructions_and_call_reference: true,
decompilation: true,
file_offset_decompilation_cli_mcp: true,
invalid_mapping_preserves_session: true,
low_memory_cli_mcp_parity: lowMemory,
windows_reparse_admission: windowsReparseAdmission,
source_unchanged: true,
runtime_cleanup: "complete",
};
} catch (cause) {
if (stderr) process.stderr.write(stderr);
throw cause;
} finally {
try {
await client.close();
} finally {
await transport.close();
}
}
const verifierRun = await completeVerifierRun(run);
if (process.platform !== "win32") {
assert.equal(verifierRun.process_lineage.status, "verified");
assert.deepEqual(verifierRun.process_lineage.descendants, []);
}
// Keep the workspace for diagnosis unless verification and cleanup both pass.
await rm(workspace, { recursive: true, force: true });
process.stdout.write(
JSON.stringify({ verifier_run: verifierRun, ...report }) + "\n",
);
async function call(name, args = {}) {
const result = await client.callTool(
{ name, arguments: args },
{ timeout: 240000 },
);
assert.notEqual(result.isError, true, JSON.stringify(result));
if (["open_binary", "binary_session", "close_binary"].includes(name))
return requireMcpOperationResult(result, name);
return result.structuredContent;
}
function evidence(value, sha256) {
const result = parseEvidence(value);
assert.equal(result.subject.digest.sha256, sha256);
assert.equal(result.subject.architecture, "6502");
assert.equal(result.subject.format, "raw-binary");
return result;
}
-152
View File
@@ -12,7 +12,6 @@ import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
import {
inspectMipsReadelf,
inspectLegacyMipsReadelf,
assertMipsLoadedImage,
assertMipsProbe,
assertMipsGlobal,
@@ -123,157 +122,6 @@ async function verifySoftFloat(call, symbols, byteOrder) {
const reports = [];
let primaryFailure;
try {
if (!psp) {
// Recordless legacy admission: ABI field 0, no ABI flags record, pinned
// 32-bit language. Independent GNU readelf checks the header interpretation.
for (const byteOrder of ["little", "big"]) {
const fixture = await buildMipsFixture(workspace, byteOrder, "hard", {
legacy: true,
});
const legacy = await inspectLegacyMipsReadelf(fixture.path, byteOrder);
const resolved = await parseBinaryTarget(fixture.path);
if (!resolved.ok) throw resolved.error;
assert.equal(resolved.value.mips.flags, 0x50000001);
assert.equal(resolved.value.mips.abiFlags, null);
assert.deepEqual(
resolved.value.mips.limitations ?? [],
legacy.limitations,
"REA legacy ABI-record absence interpretation disagrees with GNU readelf",
);
assert.equal(resolved.value.sha256, fixture.sha256);
const snapshotPath = join(workspace, `${byteOrder}-legacy.snapshot.json`);
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env,
stderr: "pipe",
});
const client = new Client({
name: "rea-real-mips-legacy-proof",
version: "1",
});
let stderr = "";
transport.stderr?.on("data", (chunk) => {
stderr = (stderr + chunk.toString()).slice(-65536);
});
const call = async (name, args = {}) => {
const reply = await client.callTool(
{ name, arguments: args },
undefined,
{ timeout: 180000 },
);
return requireMcpOperationResult(reply, name);
};
try {
await client.connect(transport);
const selected = await call("open_binary", {
path: fixture.path,
provider_id: "ghidra",
});
assert.equal(selected.architecture, "mips");
assert.equal(selected.sha256, fixture.sha256);
const image = await call("inspect_native_load_image");
assertMipsLoadedImage(image.observations, byteOrder, fixture.sha256);
// The pinned import language is the checked one, not an auto-selection.
assert.equal(
image.observations.language_id,
`MIPS:${byteOrder === "little" ? "LE" : "BE"}:32:default`,
);
const procedures = await call("list_procedures");
const entry = procedures.find((p) => p.value === "rea_mips_entry");
assert.ok(entry, "Entry function missing from legacy inventory");
assert.equal(
BigInt(entry.address),
BigInt(legacy.symbols.rea_mips_entry),
);
const probe = await call("inspect_native_instruction", {
address: legacy.symbols.rea_mips_probe,
});
const probeBranch = await call("inspect_native_instruction", {
address: `0x${(BigInt(legacy.symbols.rea_mips_probe) + 4n).toString(16)}`,
});
assertMipsProbe(
probe,
probeBranch,
byteOrder,
legacy.symbols.rea_mips_probe,
);
const dossier = functionDossierSchema.parse(
await call("analyze_function", { procedure: entry.address }),
);
assert.ok(dossier.pseudocode.trim().length > 0);
const saved = await call("close_binary", {
snapshot_path: snapshotPath,
});
assert.ok(saved.evidence_records > 0);
const snapshot = parseAnalysisSnapshot(
JSON.parse(await readFile(snapshotPath, "utf8")),
);
assert.equal(snapshot.target.architecture, "mips");
const parameters = snapshot.binding.analysis_profile.parameters;
assert.equal(
parameters.language_id,
`MIPS:${byteOrder === "little" ? "LE" : "BE"}:32:default`,
);
assert.equal(parameters.language_selection, "provider-pinned-v1");
assert.equal(
parameters.mips_support_lane,
"elf32-exec-arch32-legacy-pinned-v1",
);
assert.equal(parameters.mips_abi_basis, "inferred-o32-assumption-v1");
assert.ok(
snapshot.evidence_bundle.records.some((record) =>
parseEvidence(record).limitations.some((item) =>
item.includes("analysis assumption"),
),
),
"Legacy o32 assumption limitation missing from retained Evidence",
);
assert.ok(
snapshot.evidence_bundle.records.some((record) =>
parseEvidence(record).limitations.some((item) =>
item.includes("Floating-point ABI semantics are unverified"),
),
),
"Legacy floating-point uncertainty missing from retained Evidence",
);
await call("open_binary", {
path: fixture.path,
provider_id: "ghidra",
snapshot_path: snapshotPath,
});
assert.deepEqual(await call("list_procedures"), procedures);
await call("close_binary");
} catch (cause) {
throw new Error(
`MIPS legacy ${byteOrder} verification failed; MCP stderr: ${stderr}`,
{ cause },
);
} finally {
try {
await client.close();
} finally {
await transport.close();
}
}
reports.push({
...fixture,
independent_reader: legacy,
variant: "recordless-legacy-pinned",
effective_language: `MIPS:${
byteOrder === "little" ? "LE" : "BE"
}:32:default`,
procedures: "verified-through-snapshot",
checks: [
"recordless header admitted with inferred o32 assumption",
"pinned 32-bit language checked after import",
"legacy limitations retained in Evidence",
"snapshot round-trip and profile parameters",
],
status: "passed",
});
}
}
for (const [byteOrder, floatAbi] of psp
? [["little", "hard"]]
: [
-68
View File
@@ -45,13 +45,8 @@ const transport = new StdioClientTransport({
const source = fileURLToPath(
new URL("../tests/conformance/swift/type-inventory.swift", import.meta.url),
);
const literalSource = fileURLToPath(
new URL("../tests/conformance/swift/string-literals.swift", import.meta.url),
);
const binary = join(root, "swift-inventory");
const literalBinary = join(root, "swift-literals");
const reports = [];
let literalReport;
try {
await execute("swiftc", ["-O", "-g", source, "-o", binary], {
timeout: 60_000,
@@ -59,19 +54,6 @@ try {
await mkdir(join(root, "copied"));
const copied = join(root, "copied", "swift-inventory");
await copyFile(binary, copied);
// Decoding covers AArch64 register pairs, so build that slice on any Mac host.
await execute(
"swiftc",
[
"-O",
"-target",
"arm64-apple-macosx13.0",
literalSource,
"-o",
literalBinary,
],
{ timeout: 60_000 },
);
await client.connect(transport);
for (const [variant, path] of [
["debug", binary],
@@ -150,7 +132,6 @@ try {
await call("close_binary", {});
}
}
literalReport = await verifyStringLiterals();
} finally {
await client.close();
await transport.close();
@@ -162,7 +143,6 @@ console.log(
ok: true,
provider: "ghidra",
fixtures: reports,
swift_string_literals: literalReport,
verifier_run: await completeVerifierRun(run),
},
null,
@@ -179,51 +159,3 @@ async function call(name, arguments_) {
toolContract(name).outputSchema.parse(response.structuredContent);
return requireMcpOperationResult(response, name);
}
// Literal text lives only in register constants, so it must come from the decoded pairs.
async function verifyStringLiterals() {
await call("open_binary", { path: literalBinary, provider_id: "ghidra" });
try {
const procedures = await call("list_procedures", {});
const expected = {
smallLiteral: [["SleepDisabled", "small"]],
largeLiteral: [["The power setting could not be changed.", "large"]],
unicodeLiterals: [
["café", "small"],
["Résumé of the naïve décor, written twice", "large"],
],
emptyAndControlLiterals: [
["", "small"],
["tab\there", "small"],
],
returnedLiteral: [["Holding awake", "small"]],
};
const decoded = {};
for (const [name, literals] of Object.entries(expected)) {
const procedure = procedures.find(({ value }) => value.includes(name));
assert(procedure, `${name} is missing from the procedure inventory`);
const dossier = await call("analyze_function", {
procedure: procedure.address,
});
const observed = dossier.swift_string_literals;
assert(Array.isArray(observed), `${name} recorded no Swift literals`);
assert.deepEqual(dossier.swift_string_literal_omissions, []);
assert.deepEqual(
observed.map(({ value, representation }) => [value, representation]),
literals,
);
for (const literal of observed) {
assert.deepEqual(literal.registers, ["x0", "x1"]);
assert.equal(
literal.address === null,
literal.representation === "small",
`${literal.value} storage does not match its representation`,
);
}
decoded[name] = observed.length;
}
return decoded;
} finally {
await call("close_binary", {});
}
}
+1 -60
View File
@@ -1,8 +1,4 @@
#!/usr/bin/env node
import {
assertFeatureTrace,
findMappedNonFunction,
} from "./lib/ida-feature-trace.mjs";
import { requireMcpToolError } from "./lib/mcp-verifier-results.mjs";
import assert from "node:assert/strict";
@@ -10,7 +6,7 @@ import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { access, writeFile } from "node:fs/promises";
import { basename, dirname, join, resolve } from "node:path";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { parseArgs, promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
@@ -265,61 +261,6 @@ const mcp = async (cliDossier) => {
break;
}
}
if (config.mode === "headless") {
const documents = observe(await call("list_documents", {}));
assert.equal(documents.length, 1);
assert.equal(basename(documents[0]), basename(target));
assert.ok(
!workspaces.has(dirname(documents[0])),
"list_documents reported the private workspace copy.",
);
const resolveAt = async (address) =>
observe(await call("resolve_containing_procedure", { address }));
const instructionAddresses = dossier.assembly
.map((line) => /^(0x[0-9a-f]+)/iu.exec(line)?.[1])
.filter((address) => address !== undefined);
const queries = [
dossier.procedure.address,
...instructionAddresses.slice(1, 2),
];
for (const queried of queries) {
const containing = await resolveAt(queried);
assert.equal(containing.found, true);
assert.equal(containing.procedure.address, dossier.procedure.address);
assert.equal(containing.procedure.name, dossier.procedure.name);
}
const unmapped = await resolveAt("0xffffffffffffff00");
assert.equal(unmapped.found, false);
assert.equal(unmapped.reason, "outside_segments");
const strings = observe(await call("list_strings", {}));
const mappedString = await findMappedNonFunction(strings, resolveAt);
if (mappedString === undefined)
report.unverified.push(
"Mapped non-function containment: no string of the target is mapped outside every function.",
);
const context = await client.callTool({
name: "inspect_address_context",
arguments: { address: dossier.procedure.address },
});
assert.notEqual(context.isError, true);
const contextResult = context.structuredContent.result;
assert.equal(contextResult.document, documents[0]);
assert.equal(contextResult.procedure.state, "available");
assert.equal(
contextResult.procedure.value.procedure.address,
dossier.procedure.address,
);
const traced = await client.callTool({
name: "trace_feature",
arguments: { query: dossier.procedure.name },
});
assert.notEqual(traced.isError, true);
const trace = traced.structuredContent.normalized_result;
assertFeatureTrace(trace, dossier.procedure);
report.checks.push(
"headless containing procedures, the admitted program as the document, address context, and the procedure match and references of feature tracing",
);
}
if (values["expected-string"] === undefined)
report.unverified.push(
"String inventory/search correctness: no independent expected literal supplied.",
-105
View File
@@ -1,105 +0,0 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { isAbsolute } from "node:path";
import { pathToFileURL } from "node:url";
import { performance } from "node:perf_hooks";
import { inspectSolidityMetadata } from "../dist/evm/SolidityMetadata.js";
const compilerModule = process.env.REA_VERIFY_SOLC_MODULE;
if (!isAbsolute(compilerModule ?? ""))
throw new Error(
"Select an absolute caller-installed solc 0.8.30 module with REA_VERIFY_SOLC_MODULE.",
);
const solc = (await import(pathToFileURL(compilerModule).href)).default;
assert.equal(solc.version(), "0.8.30+commit.73712a01.Emscripten.clang");
const source = await readFile(
new URL("./fixtures/evm-interface.sol", import.meta.url),
"utf8",
);
const cases = [
{ name: "unoptimized", optimizer: false, viaIR: false, metadata: {} },
{ name: "viaIR", optimizer: true, viaIR: true, metadata: {} },
{
name: "without-content-hash",
optimizer: false,
viaIR: false,
metadata: { bytecodeHash: "none" },
},
{
name: "without-CBOR",
optimizer: false,
viaIR: false,
metadata: { appendCBOR: false },
},
];
const reports = [];
for (const fixture of cases) {
const compiled = JSON.parse(
solc.compile(
JSON.stringify({
language: "Solidity",
sources: { "fixture.sol": { content: source } },
settings: {
optimizer: { enabled: fixture.optimizer },
viaIR: fixture.viaIR,
metadata: fixture.metadata,
outputSelection: { "*": { "*": ["evm.deployedBytecode.object"] } },
},
}),
),
);
assert.ok(
!(compiled.errors ?? []).some((error) => error.severity === "error"),
JSON.stringify(compiled.errors),
);
const bytes = Buffer.from(
compiled.contracts["fixture.sol"].InterfaceFixture.evm.deployedBytecode
.object,
"hex",
);
const cpu = process.cpuUsage();
const started = performance.now();
const parsed = inspectSolidityMetadata(bytes);
const firstMs = performance.now() - started;
const firstCpu = process.cpuUsage(cpu);
if (fixture.metadata.appendCBOR === false)
assert.equal(parsed.status, "unavailable");
else {
assert.equal(parsed.status, "decoded");
assert.deepEqual(parsed.compiler_version, {
representation: "release-bytes",
value: "0.8.30",
});
assert.equal(parsed.bytecode_offset + parsed.cbor_bytes + 2, bytes.length);
if (fixture.metadata.bytecodeHash === "none")
assert.deepEqual(
parsed.entries.map(({ key }) => key),
[{ kind: "text", value: "solc" }],
);
}
const warmResults = new Array(100);
const warmStart = performance.now();
for (let index = 0; index < warmResults.length; index++)
warmResults[index] = inspectSolidityMetadata(bytes);
const warmMs = performance.now() - warmStart;
for (const result of warmResults) assert.deepEqual(result, parsed);
reports.push({
fixture: fixture.name,
bytes: bytes.length,
status: parsed.status,
first_decode_ms: firstMs,
first_decode_cpu_us: firstCpu,
warm_100_decode_ms: warmMs,
});
}
console.log(
JSON.stringify({
ok: true,
compiler_version: solc.version(),
contract_execution: "not-performed",
cases: reports,
process_peak_rss_kib: process.resourceUsage().maxRSS,
measurements:
"In-process decode timings; peak RSS includes the compiler. End-to-end Linux provider/CLI costs and agent tokens were not measured.",
}),
);
+2 -2
View File
@@ -27,9 +27,9 @@ if (
);
const offset = bytes.length >= 64 ? bytes.readUInt32LE(0x3c) : bytes.length;
if (
bytes.toString("latin1", 0, 2) !== "MZ" ||
bytes.toString("ascii", 0, 2) !== "MZ" ||
offset > bytes.length - 26 ||
bytes.toString("latin1", offset, offset + 4) !== "PE\0\0" ||
bytes.toString("ascii", offset, offset + 4) !== "PE\0\0" ||
bytes.readUInt16LE(offset + 4) !== 0x8664 ||
bytes.readUInt16LE(offset + 24) !== 0x20b
)
-3
View File
@@ -52,10 +52,8 @@ try {
"bridge/ghidra/ReaGhidraBridge.java",
"native/windows/build/manifest.json",
"native/windows/build/rea-windows-x64.node",
"dist/android/JadxWorkspaceRoot.js",
"dist/main.js",
"dist/cli.js",
"scripts/rea-android-workspace-guardian.mjs",
"scripts/rea.mjs",
])
if (!packagedPaths.has(required))
@@ -110,7 +108,6 @@ try {
HOME: home,
USERPROFILE: home,
APPDATA: join(home, "AppData", "Roaming"),
LOCALAPPDATA: join(home, "AppData", "Local"),
CLAUDE_CONFIG_DIR: home,
CODEX_HOME: join(home, ".codex"),
XDG_CONFIG_HOME: join(home, ".config"),
+1 -50
View File
@@ -173,50 +173,6 @@ try {
assert.deepEqual(structured.normalized_result, evidence.normalized_result);
console.log(`PASS MCP/CLI parity ${task.name}`);
}
const overloadedInput = {
path,
class_name: "androidx.activity.ComponentActivity",
method_name: "setContentView",
overload_index: 0,
};
const overloadedCli = await execute(
process.execPath,
[
entrypoint,
"trace-android-references",
path,
overloadedInput.class_name,
"--method-name",
overloadedInput.method_name,
"--overload-index",
"0",
"--format",
"json",
],
{
cwd: repository,
env: environment,
timeout: 150_000,
maxBuffer: 8 * 1024 * 1024,
},
);
const overloadedEvidence = parseEvidence(JSON.parse(overloadedCli.stdout));
assert.equal(overloadedEvidence.normalized_result.overload_index, 0);
assert.equal(
overloadedEvidence.normalized_result.reported_signature,
"setContentView(int): void",
);
assert.equal(overloadedEvidence.normalized_result.total, 1);
const overloadedMcp = await client.callTool(
{ name: "trace_android_references", arguments: overloadedInput },
{ timeout: 150_000 },
);
assert.notEqual(overloadedMcp.isError, true, JSON.stringify(overloadedMcp));
assert.deepEqual(
parseEvidence(overloadedMcp.structuredContent).normalized_result,
overloadedEvidence.normalized_result,
);
console.log("PASS real overloaded method references through CLI/MCP");
} catch (cause) {
process.stderr.write(stderr);
throw cause;
@@ -249,7 +205,7 @@ assert.equal(method.body_status, "available");
assert.equal(method.source.status, "complete");
assert.ok(method.source.text.includes("getIntent()"));
assert.ok(method.source.text.includes("setListAdapter"));
assert.equal(method.method.dex_descriptor, "(Landroid/os/Bundle;)V");
assert.equal(method.method.dex_descriptor, null);
const references = cliResults.get("trace_android_references").normalized_result;
assert.ok(references.total > 0);
assert.ok(
@@ -308,8 +264,3 @@ if (process.env.REA_ANDROID_REPORT !== undefined)
JSON.stringify(report, null, 2) + "\n",
);
console.log(JSON.stringify(report, null, 2));
assert.notEqual(
windowsLifecycle?.cli_abrupt_owner_exit_workspace_cleanup,
false,
"Android workspace remained after abrupt owner exit",
);
-112
View File
@@ -1,112 +0,0 @@
import assert from "node:assert/strict";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { parseEvidence } from "../../../dist/domain/evidence.js";
import { hashAndroidFile } from "./digest.mjs";
/** The public result keeps one using node and exposes its two raw DEX invokes. */
export const verifyAndroidDirectInvokes = async ({
apk,
dexEntry,
repository,
entrypoint,
environment,
execute,
}) => {
const digest = await hashAndroidFile(apk);
const check = (value) => {
const evidence = parseEvidence(value);
assert.equal(evidence.subject.digest.sha256, digest);
assert.equal(evidence.operation, "trace_android_references");
const result = evidence.normalized_result;
assert.equal(result.class_name, "fixture.Target");
assert.equal(result.method_name, "mark");
assert.equal(result.dex_descriptor, "()V");
assert.equal(result.reference_unit, "using_node");
assert.equal(result.total, 1);
assert.equal(result.references.length, 1);
const source = result.references[0];
assert.equal(source.reported_full_name, "fixture.Caller.twice");
assert.deepEqual(
source.direct_invokes.map((site) => site.dex_code_unit_offset),
[0, 3],
);
for (const site of source.direct_invokes) {
assert.equal(site.dex_entry, dexEntry);
assert.equal(site.source_raw_method_id, "fixture.Caller.twice()V");
assert.equal(site.opcode, "INVOKE_STATIC");
}
assert.equal(
source.direct_invokes[0].dex_method_index,
source.direct_invokes[1].dex_method_index,
);
return evidence;
};
const cliResponse = await execute(
process.execPath,
[
entrypoint,
"trace-android-references",
apk,
"fixture.Target",
"--method-name",
"mark",
"--format",
"json",
],
{
cwd: repository,
env: environment,
timeout: 150_000,
maxBuffer: 8 * 1024 * 1024,
},
);
const cli = check(JSON.parse(cliResponse.stdout));
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
cwd: repository,
env: environment,
stderr: "pipe",
});
let stderr = "";
transport.stderr?.on("data", (chunk) => {
stderr += chunk.toString();
});
const client = new Client({
name: "rea-real-android-direct-invokes",
version: "1",
});
try {
await client.connect(transport, { timeout: 30_000 });
const response = await client.callTool(
{
name: "trace_android_references",
arguments: {
path: apk,
class_name: "fixture.Target",
method_name: "mark",
},
},
{ timeout: 150_000 },
);
assert.notEqual(response.isError, true, JSON.stringify(response));
const mcp = check(response.structuredContent);
assert.deepEqual(mcp.normalized_result, cli.normalized_result);
assert.deepEqual(mcp.parameters, cli.parameters);
assert.deepEqual(mcp.provider, cli.provider);
} catch (cause) {
process.stderr.write(stderr);
throw cause;
} finally {
await client.close();
}
assert.equal(
await hashAndroidFile(apk),
digest,
"Static inspection changed fixture bytes",
);
console.log(
`PASS real CLI/MCP direct-invoke offsets from ${dexEntry}, parity and unchanged APK`,
);
};
-25
View File
@@ -3,7 +3,6 @@ import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { delimiter, join, resolve } from "node:path";
import { verifyAndroidInitializers } from "./initializers.mjs";
import { verifyAndroidDirectInvokes } from "./direct-invokes.mjs";
/** Prove cold metadata stays unmaterialized and overload identities survive code generation. */
export const verifyAndroidMetadata = async ({
@@ -61,7 +60,6 @@ export const verifyAndroidMetadata = async ({
apk,
resolve(repository, "tests/fixtures/android/static-initializers"),
classes,
resolve(repository, "tests/fixtures/android/direct-invokes"),
],
{ timeout: 120_000, maxBuffer: 8 * 1024 * 1024 },
);
@@ -73,19 +71,12 @@ export const verifyAndroidMetadata = async ({
result.stdout,
/INITIALIZER_REGRESSION_PASS folded=2 bounded=true/u,
);
assert.match(
result.stdout,
/DIRECT_INVOKE_REGRESSION_PASS nodes=1 offsets=0,3 cross_dex=true/u,
);
console.log(
"PASS real cold metadata, full generic types and stable overloaded method selection",
);
console.log(
"PASS real folded and mixed static initializers, absent bodies and bounded smali",
);
console.log(
"PASS real raw DEX direct-invoke offsets and cross-DEX source identity",
);
await verifyAndroidInitializers({
apk: resolve(classes, "initializers.apk"),
repository,
@@ -93,22 +84,6 @@ export const verifyAndroidMetadata = async ({
environment,
execute,
});
await verifyAndroidDirectInvokes({
apk: resolve(classes, "direct-invokes.apk"),
dexEntry: "classes.dex",
repository,
entrypoint,
environment,
execute,
});
await verifyAndroidDirectInvokes({
apk: resolve(classes, "direct-invokes-multidex.apk"),
dexEntry: "classes2.dex",
repository,
entrypoint,
environment,
execute,
});
} finally {
await rm(classes, { recursive: true, force: true });
}
@@ -1,105 +0,0 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createInterface } from "node:readline";
import { setTimeout as delay } from "node:timers/promises";
import { fileURLToPath } from "node:url";
import { createJadxWorkspaceRoot } from "../../../dist/android/JadxWorkspaceRoot.js";
const source = fileURLToPath(import.meta.url);
const marker = "REA synthetic guardian isolation test\n";
const exists = (path) =>
stat(path).then(
() => true,
() => false,
);
if (process.argv[1] === source && process.argv[2] === "owner") {
const root = await createJadxWorkspaceRoot();
await writeFile(join(root.path, "synthetic.txt"), marker);
process.stdout.write(JSON.stringify({ path: root.path }) + "\n");
await new Promise(() => {});
}
const bounded = async (promise, milliseconds, label) => {
let timer;
try {
return await Promise.race([
promise,
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(label)), milliseconds);
}),
]);
} finally {
clearTimeout(timer);
}
};
const launch = async () => {
const child = spawn(process.execPath, [source, "owner"], {
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
let stderr = "";
child.stderr.on("data", (chunk) => {
stderr = (stderr + chunk.toString()).slice(-8192);
});
const exit = new Promise((resolve, reject) => {
child.once("close", resolve);
child.once("error", reject);
});
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
try {
const ready = await bounded(
lines[Symbol.asyncIterator]().next(),
20000,
`Android guardian owner readiness timed out: ${stderr}`,
);
assert.equal(ready.done, false, stderr);
const { path } = JSON.parse(ready.value);
assert.equal(typeof path, "string");
assert.equal(await exists(join(path, "synthetic.txt")), true);
return { child, exit, path };
} catch (cause) {
child.kill();
await bounded(exit, 15000, "Android guardian owner did not exit");
throw cause;
}
};
const stop = async ({ child, exit, path }) => {
assert.equal(child.kill(), true);
await bounded(exit, 15000, "Android guardian owner did not exit");
for (let attempt = 0; attempt < 100; attempt++) {
if (!(await exists(path))) return;
await delay(100);
}
throw new Error(`Android guardian retained ${path} after owner death`);
};
/** Two independent owners must never delete one another's private workspace. */
export async function verifyWindowsGuardianIsolation() {
assert.equal(process.platform, "win32");
const first = await launch();
let second;
try {
second = await launch();
assert.notEqual(first.path, second.path);
await stop(first);
assert.equal(
await exists(join(second.path, "synthetic.txt")),
true,
"First owner deleted the second owner's workspace",
);
await stop(second);
console.log("PASS Windows Android guardian two-owner cleanup isolation");
} finally {
for (const owner of [first, second]) {
if (owner === undefined) continue;
if (owner.child.exitCode === null && owner.child.signalCode === null)
owner.child.kill();
await bounded(owner.exit, 15000, "Android guardian owner did not exit");
}
}
}
+4 -40
View File
@@ -17,7 +17,6 @@ import {
resolveJadxConfigurationInputs,
validateJadxConfiguration,
} from "../../../dist/android/JadxConfiguration.js";
import { verifyWindowsGuardianIsolation } from "./windows-guardian-isolation.mjs";
/** Verify real Windows cancellation, disconnect and abrupt CLI owner exit. */
export async function verifyWindowsAndroidLifecycle(options) {
@@ -25,16 +24,15 @@ export async function verifyWindowsAndroidLifecycle(options) {
await verifyJavaSelection(options.environment);
const cancellation = await verifyMcp(options, "cancel");
await verifyMcp(options, "disconnect");
const abruptWorkspaceRemoved = await verifyCliOwnerExit(options);
await verifyWindowsGuardianIsolation();
await verifyCliOwnerExit(options);
return {
mcp_cancelled_engine_exited: true,
mcp_cancelled_workspace_removed: true,
mcp_ping_and_next_call: true,
mcp_disconnect_engine_exited: true,
cli_abrupt_owner_exit_engine_exited: true,
cli_abrupt_owner_exit_workspace_cleanup: abruptWorkspaceRemoved,
guardian_two_owner_isolation: true,
// A forced Windows process exit cannot execute JavaScript workspace cleanup.
cli_abrupt_owner_exit_workspace_cleanup: false,
observed_resources: cancellation,
java_home_and_quoted_case_insensitive_path: true,
};
@@ -163,7 +161,6 @@ async function verifyCliOwnerExit({
path,
repository,
}) {
const baseline = new Set(await runtimeRoots());
const child = spawn(
process.execPath,
[entrypoint, "inspect-android-package", path, "--format", "json"],
@@ -188,40 +185,7 @@ async function verifyCliOwnerExit({
await deadline(exited, 15000);
await observer.verifyExit();
assert.equal(output, "", "Aborted CLI published successful Evidence");
const created = (await runtimeRoots()).filter(
(name) => !baseline.has(name),
);
assert.ok(
created.length <= 1,
"Concurrent Android workspaces make abrupt cleanup observation ambiguous",
);
if (created.length === 0) {
console.log(
"PASS Windows CLI abrupt owner exit: real Java job and workspace cleanup",
);
return true;
}
const ownedName = created[0];
try {
await waitFor(
async () => !(await runtimeRoots()).includes(ownedName),
5000,
"Android workspace remained after abrupt owner exit",
);
} catch (cause) {
if (
!(cause instanceof Error) ||
cause.message !== "Android workspace remained after abrupt owner exit"
)
throw cause;
}
const removed = !(await runtimeRoots()).includes(ownedName);
console.log(
removed
? "PASS Windows CLI abrupt owner exit: real Java job and workspace cleanup"
: "OBSERVED Windows CLI abrupt owner exit: Java job cleaned, workspace residue remains",
);
return removed;
console.log("PASS Windows CLI abrupt owner exit: real Java job cleanup");
} finally {
if (child.exitCode === null && child.signalCode === null) child.kill();
await deadline(exited, 15000);
+203 -297
View File
@@ -1,8 +1,15 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import * as fs from "node:fs/promises";
import {
access,
mkdir,
mkdtemp,
readFile,
rm,
symlink,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import * as nodePath from "node:path";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
@@ -11,348 +18,247 @@ import { firmwareResultSchemas } from "../../../dist/domain/firmware/firmwareAna
import { parseEvidence } from "../../../dist/domain/evidence.js";
import { hashFirmwareFile } from "../../../dist/firmware/FirmwareFiles.js";
const execFileAsync = promisify(execFile);
const { join, resolve, dirname } = nodePath;
const CALL_TIMEOUT_MS = 150_000;
const CONNECT_TIMEOUT_MS = 30_000;
const SEVENZIP_TIMEOUT_MS = 10_000;
const MAX_STDOUT_BYTES = 16 * 1024 * 1024;
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
const ENTRYPOINT = resolve(
const exec = promisify(execFile);
const repository = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
const entrypoint = resolve(
process.env.REA_FIRMWARE_TEST_ENTRYPOINT ??
join(REPO_ROOT, "scripts/rea.mjs"),
join(repository, "scripts/rea.mjs"),
);
const FIXTURE_ROOT = resolve(
process.env.REA_FIRMWARE_FIXTURE_ROOT ??
join(REPO_ROOT, "_reference/firmware-integration/generated"),
);
// ---------------------------------------------------------------------------
// Prerequisite checks
// ---------------------------------------------------------------------------
async function checkPrerequisites() {
if (process.platform !== "linux") {
if (process.platform !== "linux")
throw new Error(
"verify:firmware requires Linux; other hosts are not verified",
);
for (const variable of ["REA_BINWALK_COMMAND", "REA_UNBLOB_COMMAND"]) {
const path = process.env[variable];
if (path === undefined)
throw new Error(
"verify:firmware requires Linux; other hosts are not verified",
`verify:firmware requires ${variable}; see docs/firmware-analysis.md`,
);
}
for (const name of ["REA_BINWALK_COMMAND", "REA_UNBLOB_COMMAND"]) {
const value = process.env[name];
if (value === undefined) {
throw new Error(
`verify:firmware requires ${name}; see docs/firmware-analysis.md`,
);
}
await fs.access(value);
}
try {
await execFileAsync("7z", ["i"], { timeout: SEVENZIP_TIMEOUT_MS });
} catch (cause) {
throw new Error(
"verify:firmware prerequisite unavailable: Unblob's gzip/USTAR fixture extraction requires 7z on PATH",
{ cause },
);
}
try {
await fs.access(join(FIXTURE_ROOT, "oracle.json"));
} catch (cause) {
throw new Error(
"verify:firmware requires generated fixtures; run npm run fixtures:firmware",
{ cause },
);
}
await access(path);
}
await checkPrerequisites();
// ---------------------------------------------------------------------------
// Shared state
// ---------------------------------------------------------------------------
const oracle = JSON.parse(
await fs.readFile(join(FIXTURE_ROOT, "oracle.json"), "utf8"),
try {
await exec("7z", ["i"], { timeout: 10_000 });
} catch (cause) {
throw new Error(
"verify:firmware prerequisite unavailable: Unblob's gzip/USTAR fixture extraction requires 7z on PATH",
{ cause },
);
}
const fixtureRoot = resolve(
process.env.REA_FIRMWARE_FIXTURE_ROOT ??
join(repository, "_reference/firmware-integration/generated"),
);
const firmwarePath = join(FIXTURE_ROOT, "firmware.bin");
assert.equal(await hashFirmwareFile(firmwarePath), oracle.sha256);
const workDir = await fs.mkdtemp(join(tmpdir(), "rea-real-firmware-"));
const baseEnv = Object.fromEntries(
Object.entries(process.env).filter(([, v]) => v !== undefined),
);
const mcpClient = new Client({
name: "rea-real-firmware-verifier",
version: "1",
await access(join(fixtureRoot, "oracle.json")).catch((cause) => {
throw new Error(
"verify:firmware requires generated fixtures; run npm run fixtures:firmware",
{ cause },
);
});
// ---------------------------------------------------------------------------
// Invocation helpers
// ---------------------------------------------------------------------------
async function runCli(args, env = baseEnv) {
const { stdout } = await execFileAsync(
const oracle = JSON.parse(
await readFile(join(fixtureRoot, "oracle.json"), "utf8"),
);
const path = join(fixtureRoot, "firmware.bin");
assert.equal(await hashFirmwareFile(path), oracle.sha256);
const root = await mkdtemp(join(tmpdir(), "rea-real-firmware-"));
const environment = Object.fromEntries(
Object.entries(process.env).filter(([, value]) => value !== undefined),
);
const client = new Client({ name: "rea-real-firmware-verifier", version: "1" });
const cli = async (args, env = environment) => {
const response = await exec(
process.execPath,
[ENTRYPOINT, ...args, "--format", "json"],
[entrypoint, ...args, "--format", "json"],
{
env,
cwd: REPO_ROOT,
timeout: CALL_TIMEOUT_MS,
maxBuffer: MAX_STDOUT_BYTES,
cwd: repository,
timeout: 150_000,
maxBuffer: 16 * 1024 * 1024,
},
);
return parseEvidence(JSON.parse(stdout));
}
async function callTool(name, args) {
const result = await mcpClient.callTool(
return parseEvidence(JSON.parse(response.stdout));
};
const call = async (name, args) => {
const response = await client.callTool(
{ name, arguments: args },
{ timeout: CALL_TIMEOUT_MS },
{ timeout: 150_000 },
);
assert.notEqual(result.isError, true, JSON.stringify(result));
assert.ok(result.structuredContent !== undefined);
return result.structuredContent;
}
// ---------------------------------------------------------------------------
// Result verifiers
// ---------------------------------------------------------------------------
function verifyRegions(evidence) {
assert.notEqual(response.isError, true, JSON.stringify(response));
assert.ok(response.structuredContent !== undefined);
return response.structuredContent;
};
const verifyRegions = (evidence) => {
assert.equal(evidence.subject.digest.sha256, oracle.sha256);
const parsed = firmwareResultSchemas.inspect_firmware_regions.parse(
const result = firmwareResultSchemas.inspect_firmware_regions.parse(
evidence.normalized_result,
);
const gzipRegion = parsed.regions.find(
(r) => r.signature === "gzip" && r.offset === oracle.offset,
const gzip = result.regions.find(
(region) => region.signature === "gzip" && region.offset === oracle.offset,
);
assert.ok(
gzipRegion !== undefined,
gzip !== undefined,
"Binwalk must find the independently generated gzip offset",
);
assert.equal(gzipRegion.reported_size, oracle.length);
assert.equal(gzipRegion.size_basis, "provider_reported_validation_unknown");
// Strip provider-specific ids so CLI and MCP results compare equal.
return parsed.regions.map((region) => {
const { provider_id: _ignored, ...rest } = region;
return rest;
});
}
async function verifyFiles(evidence) {
const parsed = firmwareResultSchemas.extract_firmware.parse(
assert.equal(gzip.reported_size, oracle.length);
assert.equal(gzip.size_basis, "provider_reported_validation_unknown");
return result.regions.map(
({ provider_id: _provider_id, ...region }) => region,
);
};
const verifyFiles = async (evidence) => {
const result = firmwareResultSchemas.extract_firmware.parse(
evidence.normalized_result,
);
assert.ok(parsed.chunks.some((c) => c.handler === "gzip"));
assert.ok(result.chunks.some((chunk) => chunk.handler === "gzip"));
for (const [suffix, expected] of Object.entries(oracle.files)) {
const entry = parsed.files.find((f) =>
f.relative_path.endsWith(`/${suffix}`),
const file = result.files.find((candidate) =>
candidate.relative_path.endsWith(`/${suffix}`),
);
assert.ok(entry !== undefined, `Missing extracted ${suffix}`);
assert.equal(entry.sha256, expected.sha256);
assert.equal(entry.size, expected.size);
assert.equal(await hashFirmwareFile(entry.path), expected.sha256);
assert.equal(entry.original_file_range, null);
assert.equal(entry.runtime_address, null);
assert.ok(file !== undefined, `Missing extracted ${suffix}`);
assert.equal(file.sha256, expected.sha256);
assert.equal(file.size, expected.size);
assert.equal(await hashFirmwareFile(file.path), expected.sha256);
assert.equal(file.original_file_range, null);
assert.equal(file.runtime_address, null);
}
return parsed;
}
const toContentIdentity = (parsed) =>
parsed.files.map(({ relative_path, sha256, size }) => ({
relative_path,
sha256,
size,
}));
// ---------------------------------------------------------------------------
// Verification stages
// ---------------------------------------------------------------------------
async function stageCli() {
const regionsEvidence = await runCli([
"inspect-firmware-regions",
firmwarePath,
]);
const regions = verifyRegions(regionsEvidence);
const extractEvidence = await runCli([
return result;
};
try {
const inspected = await cli(["inspect-firmware-regions", path]);
const cliRegions = verifyRegions(inspected);
const extracted = await cli([
"extract-firmware",
firmwarePath,
join(workDir, "cli-output"),
path,
join(root, "cli-output"),
]);
const files = await verifyFiles(extractEvidence);
assert.equal(files.coverage, "partial");
const cliFiles = await verifyFiles(extracted);
assert.equal(cliFiles.coverage, "partial");
assert.ok(
files.chunks.some(
(c) =>
c.handler === null && c.range.offset === 0 && c.range.length === 64,
cliFiles.chunks.some(
(chunk) =>
chunk.handler === null &&
chunk.range.offset === 0 &&
chunk.range.length === 64,
),
);
return { regions, files };
}
async function connectMcp() {
const transport = new StdioClientTransport({
command: process.execPath,
args: [ENTRYPOINT, "mcp"],
cwd: REPO_ROOT,
env: baseEnv,
stderr: "pipe",
await client.connect(
new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
cwd: repository,
env: environment,
stderr: "pipe",
}),
{ timeout: 30_000 },
);
const inspectedMcp = await call("inspect_firmware_regions", { path });
const mcpEvidence = parseEvidence(inspectedMcp);
assert.deepEqual(verifyRegions(mcpEvidence), cliRegions);
const extractedMcp = await call("extract_firmware", {
path,
output_directory: join(root, "mcp-output"),
});
await mcpClient.connect(transport, { timeout: CONNECT_TIMEOUT_MS });
}
async function stageMcp(cli) {
const regionsRaw = await callTool("inspect_firmware_regions", {
path: firmwarePath,
});
assert.deepEqual(verifyRegions(parseEvidence(regionsRaw)), cli.regions);
const extractRaw = await callTool("extract_firmware", {
path: firmwarePath,
output_directory: join(workDir, "mcp-output"),
});
const mcpFiles = await verifyFiles(parseEvidence(extractRaw));
assert.deepEqual(toContentIdentity(mcpFiles), toContentIdentity(cli.files));
const rangedRaw = await callTool("extract_firmware", {
path: firmwarePath,
output_directory: join(workDir, "selected-output"),
const mcpFiles = await verifyFiles(parseEvidence(extractedMcp));
const contentIdentity = (result) =>
result.files.map(({ relative_path, sha256, size }) => ({
relative_path,
sha256,
size,
}));
assert.deepEqual(contentIdentity(mcpFiles), contentIdentity(cliFiles));
const selected = await call("extract_firmware", {
path,
output_directory: join(root, "selected-output"),
range: { offset: oracle.offset, length: oracle.length },
});
const ranged = await verifyFiles(parseEvidence(rangedRaw));
assert.equal(ranged.selection.offset, oracle.offset);
assert.equal(ranged.selection.length, oracle.length);
const depthRaw = await callTool("extract_firmware", {
path: firmwarePath,
output_directory: join(workDir, "depth-output"),
const selectedFiles = await verifyFiles(parseEvidence(selected));
assert.equal(selectedFiles.selection.offset, oracle.offset);
assert.equal(selectedFiles.selection.length, oracle.length);
const depth = await call("extract_firmware", {
path,
output_directory: join(root, "depth-output"),
max_depth: 1,
});
const depthParsed = firmwareResultSchemas.extract_firmware.parse(
depthRaw.normalized_result,
const depthResult = firmwareResultSchemas.extract_firmware.parse(
depth.normalized_result,
);
assert.equal(depthParsed.coverage, "partial");
assert.ok(depthParsed.depth_limited_paths.length > 0);
assert.equal(depthResult.coverage, "partial");
assert.ok(depthResult.depth_limited_paths.length > 0);
console.log(
"PASS real Binwalk/Unblob: CLI, MCP, independent offsets/digests, range extraction, unknown chunks and depth limits",
);
return mcpFiles;
}
async function stageExt4() {
const ext4Path = join(FIXTURE_ROOT, "ext4.bin");
await fs.access(ext4Path);
const raw = await callTool("extract_firmware", {
path: ext4Path,
output_directory: join(workDir, "ext4-output"),
});
const parsed = firmwareResultSchemas.extract_firmware.parse(
raw.normalized_result,
);
assert.ok(parsed.chunks.some((c) => c.handler === "extfs"));
assert.equal(parsed.coverage, "complete");
for (const [suffix, expected] of Object.entries(oracle.files)) {
const match = parsed.files.find((f) =>
f.relative_path.endsWith(`/${suffix}`),
if (process.env.REA_FIRMWARE_VERIFY_EXT4 === "1") {
const ext4 = join(fixtureRoot, "ext4.bin");
await access(ext4);
const response = await call("extract_firmware", {
path: ext4,
output_directory: join(root, "ext4-output"),
});
const result = firmwareResultSchemas.extract_firmware.parse(
response.normalized_result,
);
assert.equal(match?.sha256, expected.sha256);
assert.ok(result.chunks.some((chunk) => chunk.handler === "extfs"));
assert.equal(result.coverage, "complete");
for (const [suffix, expected] of Object.entries(oracle.files))
assert.equal(
result.files.find((file) => file.relative_path.endsWith(`/${suffix}`))
?.sha256,
expected.sha256,
);
console.log("PASS real ext4 extraction with debugfs; no mounts");
// Preserve the process-ownership utility while withholding all extractors.
const withoutExtractors = join(root, "without-extractors");
await access("/bin/ps");
await mkdir(withoutExtractors);
await symlink("/bin/ps", join(withoutExtractors, "ps"));
const missing = await cli(
["extract-firmware", ext4, join(root, "missing-extractor-output")],
{ ...environment, PATH: withoutExtractors },
);
const partial = firmwareResultSchemas.extract_firmware.parse(
missing.normalized_result,
);
assert.equal(partial.coverage, "partial");
assert.ok(JSON.stringify(partial.diagnostics).includes("debugfs"));
console.log(
"PASS real missing-extractor diagnostics retained despite Unblob exit 1",
);
}
console.log("PASS real ext4 extraction with debugfs; no mounts");
// Keep the process-ownership utility available but hide every extractor.
const emptyBin = join(workDir, "without-extractors");
await fs.access("/bin/ps");
await fs.mkdir(emptyBin);
await fs.symlink("/bin/ps", join(emptyBin, "ps"));
const degraded = await runCli(
["extract-firmware", ext4Path, join(workDir, "missing-extractor-output")],
{ ...baseEnv, PATH: emptyBin },
);
const degradedParsed = firmwareResultSchemas.extract_firmware.parse(
degraded.normalized_result,
);
assert.equal(degradedParsed.coverage, "partial");
assert.ok(JSON.stringify(degradedParsed.diagnostics).includes("debugfs"));
console.log(
"PASS real missing-extractor diagnostics retained despite Unblob exit 1",
);
}
async function stageGhidra(mcpFiles) {
if (process.env.GHIDRA_INSTALL_DIR === undefined) {
throw new Error(
"Firmware Ghidra handoff requires GHIDRA_INSTALL_DIR and Java; see docs/ghidra.md",
if (process.env.REA_FIRMWARE_VERIFY_GHIDRA === "1") {
if (process.env.GHIDRA_INSTALL_DIR === undefined)
throw new Error(
"Firmware Ghidra handoff requires GHIDRA_INSTALL_DIR and Java; see docs/ghidra.md",
);
const child = mcpFiles.files.find((file) =>
file.relative_path.endsWith("/usr/bin/probe"),
);
assert.ok(child !== undefined);
await call("open_binary", { path: child.path, provider_id: "ghidra" });
const dossier = await call("analyze_function", {
procedure: "firmware_probe",
});
const evidence = parseEvidence(dossier);
assert.equal(
evidence.subject.digest.sha256,
oracle.files["usr/bin/probe"].sha256,
);
const pseudocode = await call("procedure_pseudo_code", {
procedure: "firmware_probe",
});
assert.equal(parseEvidence(pseudocode).provider.id, "ghidra");
assert.ok(
JSON.stringify(evidence.normalized_result).includes("firmware_probe"),
);
await call("close_binary", {});
console.log(
"PASS real Ghidra handoff of the selected extracted host ELF, bound to its digest",
);
}
const probe = mcpFiles.files.find((f) =>
f.relative_path.endsWith("/usr/bin/probe"),
);
assert.ok(probe !== undefined);
await callTool("open_binary", { path: probe.path, provider_id: "ghidra" });
const dossierRaw = await callTool("analyze_function", {
procedure: "firmware_probe",
});
const dossier = parseEvidence(dossierRaw);
assert.equal(
dossier.subject.digest.sha256,
oracle.files["usr/bin/probe"].sha256,
);
const pseudoRaw = await callTool("procedure_pseudo_code", {
procedure: "firmware_probe",
});
assert.equal(parseEvidence(pseudoRaw).provider.id, "ghidra");
assert.ok(
JSON.stringify(dossier.normalized_result).includes("firmware_probe"),
);
await callTool("close_binary", {});
console.log(
"PASS real Ghidra handoff of the selected extracted host ELF, bound to its digest",
);
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
try {
const cli = await stageCli();
await connectMcp();
const mcpFiles = await stageMcp(cli);
if (process.env.REA_FIRMWARE_VERIFY_EXT4 === "1") await stageExt4();
if (process.env.REA_FIRMWARE_VERIFY_GHIDRA === "1")
await stageGhidra(mcpFiles);
assert.equal(
await hashFirmwareFile(firmwarePath),
await hashFirmwareFile(path),
oracle.sha256,
"Original firmware bytes must remain unchanged",
);
} finally {
await mcpClient.close();
await fs.rm(workDir, { recursive: true, force: true });
await client.close();
await rm(root, { recursive: true, force: true });
}
-30
View File
@@ -810,36 +810,6 @@ exit 1`,
expect(result.error.message).toContain("overwrite");
});
it.each([
["offline", false, "error: device offline"],
["unauthorized", false, "error: device unauthorized"],
["offline", true, "error: device offline"],
] as const)(
"reports the %s device state when the push existence probe cannot run (overwrite %s)",
async (state, overwrite, stderr) => {
const { binary, root } = await writeAdbStub(
(stubRoot) => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ]; then printf '%s\\n' "$3" >> "${stubRoot}/calls"; printf '%s\\n' "${stderr}" >&2; exit 1; fi`,
);
const localFile = join(root, "payload.bin");
await writeFile(localFile, "x");
const projected = await refusedBy(providerOver(binary), {
operation: "push_adb_file",
input: {
serial: "emulator-5554",
local_path: localFile,
device_path: "/data/local/tmp/payload.bin",
overwrite,
},
});
// Existence is unknown, so nothing is claimed about the path and no push runs.
expect(projected).toMatchObject({ code: "unsupported_target" });
expect(projected.message).toContain(state);
expect(projected.message).not.toContain("already exists");
expect(await readFile(join(root, "calls"), "utf8")).toBe("shell\n");
},
);
it("reports a local push source that does not exist", async () => {
const { binary } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
+10 -8
View File
@@ -1623,15 +1623,17 @@ export class AdbProvider {
{ timeoutMs: SETTINGS_TIMEOUT_MS, maxOutputBytes: 1024 },
signal,
);
// `test -e` exits nonzero with no adb diagnosis when the path is absent.
// Any other probe failure leaves existence unknown, so it is reported as
// is rather than read as an existing path or followed by a push.
if (
!existsProbe.ok &&
!(existsProbe.error instanceof AnalysisProtocolError)
)
let deviceExists: boolean;
if (existsProbe.ok) deviceExists = true;
else if (existsProbe.error instanceof AnalysisProtocolError)
deviceExists = false;
else if (existsProbe.error instanceof AnalysisTimeoutError)
return existsProbe;
const deviceExists = existsProbe.ok;
else if (existsProbe.error instanceof AnalysisCancelledError)
return existsProbe;
else if (existsProbe.error instanceof AnalysisResourceConstraintError)
return existsProbe;
else deviceExists = true; // authorization/refusal states surface on push instead
if (deviceExists && !input.overwrite)
return err(
new AnalysisOutputError(
+7 -32
View File
@@ -45,7 +45,7 @@ const methodsOf = (summary: ClassSummary) => {
reported_signature: method.signature,
overload_index: index,
is_constructor: method.is_constructor,
dex_descriptor: method.dex_descriptor,
dex_descriptor: null,
};
});
};
@@ -314,25 +314,11 @@ const traceReferences = async (
): Promise<JsonValue> => {
const methodName = request.input.method_name;
const candidates = matchingMethods(summary, request);
if (methodName === undefined && request.input.overload_index !== undefined)
inputFailure(
request,
"overload_index",
"Method name is required for an overload index.",
);
if (candidates.length > 1 && request.input.overload_index === undefined)
inputFailure(
request,
"overload_index",
`Select one of ${candidates.length} overloads: ${candidates.map((method) => `${method.overload_index}: ${method.reported_signature}`).join("; ")}`,
);
const index = request.input.overload_index ?? 0;
const selected = methodName === undefined ? undefined : candidates[index];
if (methodName !== undefined && selected === undefined)
inputFailure(
request,
"overload_index",
`Index ${index} is outside the ${candidates.length} matching overloads.`,
if (candidates.length > 1)
throw new AnalysisCapabilityUnavailableError(
"jadx",
request.operation,
`References require a unique method name; ${summary.full_name}.${methodName} has ${candidates.length} overloads. No unambiguous reference result is available.`,
);
const expectedTarget =
methodName === undefined
@@ -343,9 +329,7 @@ const traceReferences = async (
methodName === undefined ? "get_xrefs_to_class" : "get_xrefs_to_method",
{
class_name: summary.full_name,
...(methodName === undefined
? {}
: { method_name: methodName, overload_index: index }),
...(methodName === undefined ? {} : { method_name: methodName }),
limit: 2_147_483_647,
resolve_line: false,
},
@@ -353,9 +337,6 @@ const traceReferences = async (
);
if (
xrefs.target !== expectedTarget ||
(selected !== undefined &&
(xrefs.overload_index !== index ||
xrefs.reported_signature !== selected.reported_signature)) ||
xrefs.total !== xrefs.count ||
xrefs.count !== xrefs.items.length
)
@@ -367,9 +348,6 @@ const traceReferences = async (
engine,
class_name: summary.full_name,
method_name: methodName ?? null,
overload_index: selected?.overload_index ?? null,
reported_signature: selected?.reported_signature ?? null,
dex_descriptor: selected?.dex_descriptor ?? null,
total: xrefs.total,
references: xrefs.items.map((reference) => ({
kind: reference.kind,
@@ -379,10 +357,7 @@ const traceReferences = async (
top_class: reference.top_class,
source_line: null,
dex_offset: null,
direct_invokes: reference.direct_invokes ?? null,
direct_invokes_exhaustive: reference.direct_invokes_exhaustive ?? null,
})),
coverage: "complete",
reference_unit: "using_node",
});
};
-16
View File
@@ -2,7 +2,6 @@ import { z } from "zod";
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import type { AndroidOperation } from "../domain/android/androidAnalysis.js";
import { dexDescriptorSchema } from "../domain/android/androidAnalysis.js";
const count = z.number().int().nonnegative();
/** Selector failures authored by REA's bridge, separate from arbitrary engine errors. */
@@ -51,7 +50,6 @@ export const jadxClassSchema = z.object({
z.object({
name: z.string(),
signature: z.string(),
dex_descriptor: dexDescriptorSchema,
is_constructor: z.boolean(),
def_pos: z.number().int(),
}),
@@ -74,8 +72,6 @@ export const jadxMethodSchema = z.object({
});
export const jadxXrefsSchema = z.object({
target: z.string(),
overload_index: count.optional(),
reported_signature: z.string().optional(),
total: count,
count,
resolve_line: z.literal(false),
@@ -87,18 +83,6 @@ export const jadxXrefsSchema = z.object({
containing_class: z.string(),
top_class: z.string(),
def_pos: z.number().int(),
direct_invokes_exhaustive: z.boolean().optional(),
direct_invokes: z
.array(
z.object({
dex_entry: z.string().min(1),
source_raw_method_id: z.string().min(1),
dex_code_unit_offset: count,
dex_method_index: count,
opcode: z.string().regex(/^INVOKE_/u),
}),
)
.optional(),
}),
),
});
+12 -23
View File
@@ -18,10 +18,7 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { AndroidRequest } from "../domain/android/androidAnalysis.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import { err, ok, type Result } from "../domain/result.js";
import {
createJadxWorkspaceRoot,
type JadxWorkspaceRoot,
} from "./JadxWorkspaceRoot.js";
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
import {
snapshotAndroidEngine,
snapshotAndroidTarget,
@@ -155,7 +152,7 @@ const hashAndroidSource = async (
interface PendingCleanup {
session: JadxSession | undefined;
root: JadxWorkspaceRoot | undefined;
root: PrivateRuntimeRoot | undefined;
readonly previousError: AnalysisError | undefined;
}
@@ -214,7 +211,7 @@ const cleanup = async (
interface RetainedSession {
readonly key: string;
readonly root: JadxWorkspaceRoot;
readonly root: PrivateRuntimeRoot;
readonly session: JadxSession;
readonly snapshot: string;
readonly jarHash: string;
@@ -328,7 +325,7 @@ export class JadxProvider implements AndroidAnalysisPort {
options?.signal === undefined
? timeout
: AbortSignal.any([options.signal, timeout]);
let root: JadxWorkspaceRoot | undefined;
let root: PrivateRuntimeRoot | undefined;
let session: JadxSession | undefined;
try {
if (target.format !== "apk")
@@ -392,24 +389,20 @@ export class JadxProvider implements AndroidAnalysisPort {
request.operation,
signal,
);
root = await createJadxWorkspaceRoot();
const workspaceSignal =
root.failureSignal === undefined
? signal
: AbortSignal.any([signal, root.failureSignal]);
root = await PrivateRuntimeRoot.create({ prefix: "rea-android-" });
const engine = await snapshotAndroidEngine(
configuration.jar,
jarHash,
root.path,
request.operation,
workspaceSignal,
signal,
);
const snapshot = await snapshotAndroidTarget(
target.path,
target.sha256,
root.path,
request.operation,
workspaceSignal,
signal,
);
const bridge = join(root.path, "ReaJadxBridge.java");
await copyFile(BRIDGE_SOURCE, bridge);
@@ -417,7 +410,7 @@ export class JadxProvider implements AndroidAnalysisPort {
const copiedBridgeHash = await hashAndroidSource(
bridge,
request.operation,
workspaceSignal,
signal,
);
if (copiedBridgeHash !== bridgeHash)
throw androidSourceChanged(
@@ -425,7 +418,7 @@ export class JadxProvider implements AndroidAnalysisPort {
BRIDGE_SOURCE,
undefined,
);
workspaceSignal.throwIfAborted();
signal.throwIfAborted();
session = new JadxSession(
{
command: configuration.java,
@@ -437,7 +430,7 @@ export class JadxProvider implements AndroidAnalysisPort {
],
cwd: root.path,
hostEnvironment: this.environment,
signal: workspaceSignal,
signal,
},
this.launcher,
);
@@ -452,10 +445,6 @@ export class JadxProvider implements AndroidAnalysisPort {
}
root = retained.root;
session = retained.session;
const activeSignal =
root.failureSignal === undefined
? signal
: AbortSignal.any([signal, root.failureSignal]);
const { snapshot } = retained;
const outcome = ok(
await session.execute({
@@ -464,10 +453,10 @@ export class JadxProvider implements AndroidAnalysisPort {
snapshot,
jarHash: retained.jarHash,
bridgeHash: retained.bridgeHash,
signal: activeSignal,
signal,
}),
);
activeSignal.throwIfAborted();
signal.throwIfAborted();
this.#retained = retained;
return outcome;
} catch (cause) {
+1 -2
View File
@@ -25,8 +25,7 @@ export const JADX_PROVIDER_IDENTITY = Object.freeze({
/** Audited engine coverage, kept outside portable Android semantics. */
export const JADX_LIMITATIONS = [
"Static decompiler observations do not establish runtime behavior; the APK is never executed.",
"Method signatures use provider display types; DEX prototypes come from frozen JADX metadata. Display names may be aliases, and overload indices are local to this engine and artifact.",
"Method signatures use provider display types; exact DEX descriptors and instruction offsets are unavailable. Overload indices are local to this engine and artifact.",
"Class inventories use parsed metadata before code generation, including synthetic members that may be omitted from decompiled source. Source positions remain unknown.",
"Incoming reference totals count JADX using nodes. Direct invoke entries within a using method report raw DEX code-unit offsets and method indexes, but coverage is limited to those nodes and directly matched invokes; it does not establish every possible dynamic or reflective call site.",
"Manifest summary fields are extracted by the upstream engine; decoded XML is retained for verification. APK signatures, split APKs, native libraries and Android runtime capture are not verified by this provider.",
] as const;
-224
View File
@@ -1,224 +0,0 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { lstat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { win32 } from "node:path";
import { fileURLToPath } from "node:url";
import { z } from "zod";
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
/** Match the native owner's ordinary spelling only for extended local drive paths. */
const localDrivePath = (path: string): string =>
path.replace(/^\\\\\?\\(?=[A-Za-z]:\\)/u, "");
const guardianSource = fileURLToPath(
new URL("../../scripts/rea-android-workspace-guardian.mjs", import.meta.url),
);
const readySchema = z.strictObject({
path: z.string(),
volumeSerial: z.string().regex(/^[a-f0-9]{16}$/u),
fileId: z.string().regex(/^[a-f0-9]{32}$/u),
privateDacl: z.literal(true),
});
/** The Android provider needs a path and a cleanup owner, regardless of host. */
export interface JadxWorkspaceRoot {
readonly path: string;
readonly failureSignal?: AbortSignal;
close(): Promise<void>;
}
type GuardianExit = {
readonly code: number | null;
readonly signal: NodeJS.Signals | null;
readonly error?: Error;
};
const boundedWait = async <T>(
promise: Promise<T>,
milliseconds: number,
label: string,
): Promise<T> => {
let timer: NodeJS.Timeout | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error(label)), milliseconds);
}),
]);
} finally {
clearTimeout(timer);
}
};
/** A detached guardian retains native cleanup ownership without keeping its caller alive. */
const releaseGuardianReferences = (
child: ChildProcessWithoutNullStreams,
): void => {
child.stdin.destroy();
child.stdout.destroy();
child.stderr.destroy();
child.unref();
};
class WindowsJadxWorkspaceRoot implements JadxWorkspaceRoot {
readonly #failure = new AbortController();
readonly failureSignal = this.#failure.signal;
readonly #child: ChildProcessWithoutNullStreams;
readonly #exit: Promise<GuardianExit>;
readonly #diagnostics: () => string;
#closing = false;
#closePromise: Promise<void> | undefined;
private constructor(
readonly path: string,
child: ChildProcessWithoutNullStreams,
exit: Promise<GuardianExit>,
diagnostics: () => string,
) {
this.#child = child;
this.#exit = exit;
this.#diagnostics = diagnostics;
void this.#exit.then((result) => {
if (!this.#closing)
this.#failure.abort(
new Error(
`Android workspace guardian exited before cleanup: ${result.error?.message ?? result.code ?? result.signal ?? "unknown"}`,
),
);
});
}
static async create(): Promise<WindowsJadxWorkspaceRoot> {
const parent = tmpdir();
const child = spawn(process.execPath, [guardianSource, parent], {
detached: true,
windowsHide: true,
stdio: ["pipe", "pipe", "pipe"],
env: {
SystemRoot: process.env.SystemRoot,
WINDIR: process.env.WINDIR,
TEMP: parent,
TMP: parent,
},
});
let diagnostics = "";
child.stderr.on("data", (chunk: Buffer) => {
diagnostics = (diagnostics + chunk.toString()).slice(-8192);
});
const exit = new Promise<GuardianExit>((resolve) => {
child.once("error", (error: Error) =>
resolve({ code: null, signal: null, error }),
);
child.once("close", (code, signal) => resolve({ code, signal }));
});
const ready = new Promise<string>((resolve, reject) => {
let frame = "";
child.stdout.on("data", (chunk: Buffer) => {
frame += chunk.toString();
if (frame.length > 4096) {
reject(
new Error("Android workspace guardian readiness exceeded 4 KiB"),
);
return;
}
const end = frame.indexOf("\n");
if (end !== -1) resolve(frame.slice(0, end));
});
child.stdout.once("error", reject);
});
try {
const line = await boundedWait(
Promise.race([
ready,
exit.then((result) => {
throw new Error(
`Android workspace guardian exited during startup: ${result.error?.message ?? result.code ?? result.signal ?? "unknown"}; ${diagnostics}`,
);
}),
]),
15000,
"Android workspace guardian startup timed out",
);
const observation = readySchema.parse(JSON.parse(line));
if (
win32.dirname(localDrivePath(observation.path)).toLowerCase() !==
localDrivePath(win32.resolve(parent)).toLowerCase() ||
!/^rea-android-[a-f0-9]{32}$/u.test(win32.basename(observation.path))
)
throw new Error(
"Android workspace guardian returned an unexpected root",
);
child.stdout.resume();
return new WindowsJadxWorkspaceRoot(
observation.path,
child,
exit,
() => diagnostics,
);
} catch (cause) {
if (!child.stdin.destroyed) child.stdin.end();
try {
await boundedWait(
exit,
30000,
"Android workspace guardian did not settle after startup failure",
);
} catch (cleanupFailure) {
throw new AggregateError(
[cause, cleanupFailure],
"Android workspace guardian startup and cleanup failed",
);
} finally {
releaseGuardianReferences(child);
}
throw cause;
}
}
close(): Promise<void> {
this.#closing = true;
this.#closePromise ??= this.#finish().catch((cause: unknown) => {
this.#closePromise = undefined;
throw cause;
});
return this.#closePromise;
}
async #finish(): Promise<void> {
if (!this.#child.stdin.destroyed) this.#child.stdin.end();
let result: GuardianExit;
try {
result = await boundedWait(
this.#exit,
120000,
`Android workspace guardian cleanup timed out for ${this.path}`,
);
} catch (cause) {
releaseGuardianReferences(this.#child);
throw cause;
}
if (result.error !== undefined || result.code !== 0)
throw new Error(
`Android workspace guardian cleanup failed for ${this.path}: ${result.error?.message ?? result.code ?? result.signal ?? "unknown"}; ${this.#diagnostics()}`,
);
try {
await lstat(this.path);
} catch (cause) {
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
return;
throw cause;
}
throw new Error(
`Android workspace guardian exited but retained ${this.path}`,
);
}
}
/** POSIX retains its in-process root; Windows keeps a detached native owner. */
export const createJadxWorkspaceRoot = (): Promise<JadxWorkspaceRoot> =>
process.platform === "win32"
? WindowsJadxWorkspaceRoot.create()
: PrivateRuntimeRoot.create({ prefix: "rea-android-" });
+10 -271
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import {
APKTOOL_COMMAND_ENV,
@@ -10,43 +10,29 @@ import {
resolveApktoolCommand,
} from "./ApktoolConfiguration.js";
const lookup = vi.hoisted(() => ({ stat: vi.fn() }));
vi.mock("node:fs/promises", async (importOriginal) => ({
...(await importOriginal<typeof import("node:fs/promises")>()),
stat: lookup.stat,
}));
beforeEach(() => {
lookup.stat
.mockReset()
.mockRejectedValue(Object.assign(new Error("missing"), { code: "ENOENT" }));
});
describe("resolveApktoolCommand", () => {
it("prefers an absolute REA_APKTOOL_COMMAND", async () => {
it("prefers an absolute REA_APKTOOL_COMMAND", () => {
expect(
await resolveApktoolCommand(
{
[APKTOOL_COMMAND_ENV]: "/usr/local/bin/apktool",
},
"linux",
),
resolveApktoolCommand({
[APKTOOL_COMMAND_ENV]: "/usr/local/bin/apktool",
}),
).toEqual({
command: "/usr/local/bin/apktool",
commandSource: "environment",
});
});
it("leaves POSIX PATH lookup to process creation", async () => {
expect(await resolveApktoolCommand({}, "linux")).toEqual({
it("falls back to PATH resolution without the environment key", () => {
expect(resolveApktoolCommand({})).toEqual({
command: "apktool",
commandSource: "path",
});
});
it("rejects a relative environment path explicitly", async () => {
await expect(
it("rejects a relative environment path explicitly", () => {
expect(() =>
resolveApktoolCommand({ [APKTOOL_COMMAND_ENV]: "bin/apktool" }),
).rejects.toThrow(/must be an absolute path/u);
).toThrow(/must be an absolute path/u);
});
});
@@ -133,250 +119,3 @@ describe("apktoolInvocation", () => {
});
});
});
describe("Windows PATH launcher selection", () => {
const environment = {
Path: '"C:\\Selected Tools";C:\\Other Tools',
PathExt: ".EXE;.CMD;.BAT",
ComSpec: "C:\\Windows\\System32\\cmd.exe",
NoDefaultCurrentDirectoryInExePath: "",
};
const files = (...paths: string[]) => {
const selected = new Set(paths.map((path) => path.toLowerCase()));
lookup.stat.mockImplementation(async (path: string) => {
if (selected.has(path.toLowerCase())) return { isFile: () => true };
throw Object.assign(new Error("missing"), { code: "ENOENT" });
});
};
it.each([".BAT", ".CMD"])(
"resolves a default %s launcher before choosing cmd.exe for version and decode",
async (extension) => {
const command = `C:\\Selected Tools\\apktool${extension}`;
files(command);
const selection = await resolveApktoolCommand(environment, "win32");
expect(selection).toEqual({ command, commandSource: "path" });
for (const arguments_ of [
["--version"],
["d", "-o", "C:\\output folder", "C:\\target.apk"],
]) {
const invocation = apktoolInvocation(
selection,
arguments_,
environment,
"win32",
);
expect(invocation).toEqual({
command: environment.ComSpec,
arguments: [
"/d",
"/e:on",
"/v:off",
"/s",
"/c",
`"${[command, ...arguments_].map((token) => `"${token}"`).join(" ")}"`,
],
windowsVerbatimArguments: true,
});
}
},
);
it("honors PATH directory order before extension order", async () => {
files("C:\\Selected Tools\\apktool.CMD", "C:\\Other Tools\\apktool.EXE");
expect(await resolveApktoolCommand(environment, "win32")).toMatchObject({
command: "C:\\Selected Tools\\apktool.CMD",
});
});
it("honors the selected PATHEXT ordering", async () => {
files("C:\\Selected Tools\\apktool.CMD", "C:\\Selected Tools\\apktool.EXE");
expect(
await resolveApktoolCommand(
{ ...environment, PathExt: ".CMD;.EXE" },
"win32",
),
).toMatchObject({ command: "C:\\Selected Tools\\apktool.CMD" });
});
it("keeps native executable arguments outside cmd.exe interpretation", async () => {
const command = "C:\\Selected Tools\\apktool.EXE";
files(command);
const selection = await resolveApktoolCommand(environment, "win32");
const arguments_ = ["d", "C:\\input & literal.apk"];
expect(
apktoolInvocation(selection, arguments_, environment, "win32"),
).toEqual({
command,
arguments: arguments_,
windowsVerbatimArguments: false,
});
});
it("preserves explicit launcher spelling without searching PATH", async () => {
const command = "C:\\selected alias\\APKTOOL.cmd";
expect(
await resolveApktoolCommand(
{ ...environment, [APKTOOL_COMMAND_ENV]: command },
"win32",
),
).toEqual({ command, commandSource: "environment" });
expect(lookup.stat).not.toHaveBeenCalled();
});
it("does not inspect the current directory when the caller disables it", async () => {
await expect(
resolveApktoolCommand(environment, "win32"),
).rejects.toMatchObject({ code: "command_missing" });
expect(
lookup.stat.mock.calls.every(
([path]) =>
String(path).startsWith("C:\\Selected Tools\\") ||
String(path).startsWith("C:\\Other Tools\\"),
),
).toBe(true);
});
it("does not turn an explicitly empty PATHEXT into default extensions", async () => {
files("C:\\Selected Tools\\apktool.EXE");
await expect(
resolveApktoolCommand({ ...environment, PathExt: "" }, "win32"),
).rejects.toMatchObject({ code: "command_missing" });
expect(lookup.stat).not.toHaveBeenCalled();
});
it("skips directories without treating them as executable launchers", async () => {
files("C:\\Other Tools\\apktool.EXE");
const stat = lookup.stat.getMockImplementation()!;
lookup.stat.mockImplementation(async (path: string) =>
path === "C:\\Selected Tools\\apktool.EXE"
? { isFile: () => false }
: stat(path),
);
expect(await resolveApktoolCommand(environment, "win32")).toMatchObject({
command: "C:\\Other Tools\\apktool.EXE",
});
});
it("preserves inspection failures instead of reporting a missing command or selecting a later file", async () => {
lookup.stat.mockRejectedValue(
Object.assign(new Error("denied"), { code: "EACCES" }),
);
const failure = await resolveApktoolCommand(environment, "win32").catch(
(cause: unknown) => cause,
);
expect(failure).toBeInstanceOf(ApktoolConfigurationFailure);
if (!(failure instanceof ApktoolConfigurationFailure)) throw failure;
expect(failure).toMatchObject({
code: "command_unresolved",
diagnostics: {
candidate_path: "C:\\Selected Tools\\apktool.EXE",
filesystem_error: "EACCES",
},
});
expect(apktoolConfigurationAvailability(failure)).toMatchObject({
code: "not_configured",
});
expect(lookup.stat).toHaveBeenCalledTimes(1);
});
it("honors cancellation before inspecting launchers", async () => {
const controller = new AbortController();
controller.abort();
await expect(
resolveApktoolCommand(environment, "win32", controller.signal),
).rejects.toMatchObject({ _tag: "AnalysisCancelledError" });
expect(lookup.stat).not.toHaveBeenCalled();
});
});
it("does not select a launcher when cancellation arrives during lookup", async () => {
const controller = new AbortController();
lookup.stat.mockImplementation(async () => {
controller.abort();
return { isFile: () => true };
});
await expect(
resolveApktoolCommand(
{
PATH: "C:\\Tools",
PATHEXT: ".CMD",
NoDefaultCurrentDirectoryInExePath: "1",
},
"win32",
controller.signal,
),
).rejects.toMatchObject({ _tag: "AnalysisCancelledError" });
expect(lookup.stat).toHaveBeenCalledTimes(1);
});
it.each(["EXE", ".CMD\\..\\outside.exe", ".CMD\0"])(
"rejects malformed PATHEXT %j before path lookup",
async (PATHEXT) => {
await expect(
resolveApktoolCommand({ PATH: "C:\\Tools", PATHEXT }, "win32"),
).rejects.toMatchObject({
code: "command_unresolved",
diagnostics: { invalid_extension: PATHEXT },
});
expect(lookup.stat).not.toHaveBeenCalled();
},
);
it("cancels a pending filesystem lookup and owns its eventual rejection", async () => {
const controller = new AbortController();
let started!: () => void;
let rejectLookup!: (cause: Error) => void;
const pendingStat = new Promise((_resolve, reject) => {
rejectLookup = reject;
});
const observed = new Promise<void>((resolve) => {
started = resolve;
});
lookup.stat.mockImplementation(() => {
started();
return pendingStat;
});
const pending = resolveApktoolCommand(
{
PATH: "C:\\Tools",
PATHEXT: ".CMD",
NODEFAULTCURRENTDIRECTORYINEXEPATH: "1",
},
"win32",
controller.signal,
);
await observed;
controller.abort();
await expect(pending).rejects.toMatchObject({
_tag: "AnalysisCancelledError",
});
rejectLookup(new Error("filesystem completed after cancellation"));
await Promise.resolve();
expect(lookup.stat).toHaveBeenCalledTimes(1);
});
it("bounds the entire pending PATH lookup by the selection deadline", async () => {
vi.useFakeTimers();
try {
lookup.stat.mockReturnValue(new Promise(() => {}));
const failure = expect(
resolveApktoolCommand(
{
PATH: "C:\\Tools",
PATHEXT: ".CMD",
NODEFAULTCURRENTDIRECTORYINEXEPATH: "1",
},
"win32",
),
).rejects.toMatchObject({
_tag: "AnalysisTimeoutError",
timeoutMs: 20_000,
});
await vi.advanceTimersByTimeAsync(20_000);
await failure;
expect(lookup.stat).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
+20 -118
View File
@@ -1,12 +1,5 @@
import {
AnalysisCancelledError,
AnalysisTimeoutError,
} from "../domain/analysisErrorCore.js";
import { isAbsolute, win32 } from "node:path";
import { stat } from "node:fs/promises";
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import { ABORTED, waitForAbortable } from "../process/AbortablePromise.js";
import { ProviderStartupDeadline } from "../process/ProviderDeadline.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { isAbsolute } from "node:path";
import {
execFileOutput,
@@ -34,119 +27,30 @@ export interface ApktoolCommandSelection {
}
/**
* Preserve an explicit launcher, or resolve the default Windows command before
* choosing native versus batch process creation. POSIX keeps execFile's PATH
* lookup. The resolved selection is reused for the version and decode runs.
* Resolve the caller-supplied apktool launcher. `REA_APKTOOL_COMMAND` must
* be absolute so the selection stays explicit; without it, apktool is
* resolved from `PATH` at run time. Both the upstream wrapper script and
* `java -jar` launcher scripts work: REA only runs `<command> --version`
* and `<command> d ...`.
*/
export const resolveApktoolCommand = async (
export const resolveApktoolCommand = (
environment: Readonly<Record<string, string | undefined>>,
platform: NodeJS.Platform = process.platform,
signal?: AbortSignal,
): Promise<ApktoolCommandSelection> => {
if (signal?.aborted === true)
throw new AnalysisCancelledError("inspect_apktool_client");
const selectedEnvironment = snapshotEnvironment(environment, platform);
const configured = selectedEnvironment[APKTOOL_COMMAND_ENV];
if (configured !== undefined && configured.trim() !== "") {
if (
!(platform === "win32"
? win32.isAbsolute(configured)
: isAbsolute(configured))
)
throw new ApktoolConfigurationFailure(
"not_absolute",
`${APKTOOL_COMMAND_ENV} must be an absolute path to the apktool launcher`,
{ configured_command: configured },
);
return { command: configured, commandSource: "environment" };
}
if (platform !== "win32")
): ApktoolCommandSelection => {
const configured = environment[APKTOOL_COMMAND_ENV];
if (configured === undefined || configured.trim() === "")
return { command: "apktool", commandSource: "path" };
const extensions = (selectedEnvironment.PATHEXT ?? ".EXE;.COM;.BAT;.CMD")
.split(";")
.filter((extension) => extension !== "");
for (const extension of extensions)
if (!extension.startsWith(".") || /[\\/:\0]/u.test(extension))
throw new ApktoolConfigurationFailure(
"command_unresolved",
"Windows PATHEXT must contain filename extensions, not paths",
{
selected_command: "apktool",
command_source: "path",
invalid_extension: extension,
},
);
const directories = (selectedEnvironment.PATH ?? "")
.split(";")
.filter((directory) => directory !== "");
if (selectedEnvironment.NODEFAULTCURRENTDIRECTORYINEXEPATH === undefined)
directories.unshift(process.cwd());
// A single deadline owns the whole PATH search, including filesystem waits.
const deadline = new ProviderStartupDeadline(VERSION_TIMEOUT_MS, signal);
const lookupInterruption = () =>
deadline.interruption === "cancelled"
? new AnalysisCancelledError("inspect_apktool_client")
: new AnalysisTimeoutError("inspect_apktool_client", VERSION_TIMEOUT_MS, {
diagnostics: { phase: "windows_path_lookup" },
});
try {
for (const entry of directories) {
const directory =
entry.startsWith('"') && entry.endsWith('"')
? entry.slice(1, -1)
: entry;
for (const extension of extensions) {
if (deadline.interruption !== undefined) throw lookupInterruption();
const candidate = win32.resolve(directory, `apktool${extension}`);
try {
const metadata = await waitForAbortable(
stat(candidate),
deadline.signal,
);
if (metadata === ABORTED || deadline.interruption !== undefined)
throw lookupInterruption();
if (metadata.isFile())
return { command: candidate, commandSource: "path" };
} catch (cause: unknown) {
if (deadline.interruption !== undefined) throw lookupInterruption();
const code =
typeof cause === "object" && cause !== null
? Reflect.get(cause, "code")
: undefined;
if (code === "ENOENT" || code === "ENOTDIR") continue;
throw new ApktoolConfigurationFailure(
"command_unresolved",
`The PATH-selected apktool launcher could not be inspected: ${candidate}`,
{
selected_command: "apktool",
command_source: "path",
candidate_path: candidate,
...(typeof code === "string" ? { filesystem_error: code } : {}),
},
{ cause },
);
}
}
}
} finally {
deadline.dispose();
}
throw new ApktoolConfigurationFailure(
"command_missing",
"The apktool launcher was not found on the selected Windows PATH/PATHEXT",
{
selected_command: "apktool",
command_source: "path",
remediation: `Set ${APKTOOL_COMMAND_ENV} to an absolute apktool launcher, or add its directory and extension to PATH and PATHEXT`,
},
);
if (!isAbsolute(configured))
throw new ApktoolConfigurationFailure(
"not_absolute",
`${APKTOOL_COMMAND_ENV} must be an absolute path to the apktool launcher`,
{ configured_command: configured },
);
return { command: configured, commandSource: "environment" };
};
export type ApktoolConfigurationFailureCode =
| "not_absolute"
| "command_missing"
| "command_unresolved"
| "launcher_unsupported"
| "version_unresolved";
@@ -217,7 +121,7 @@ export const inspectApktoolClient = async (
environment: Readonly<Record<string, string | undefined>> = process.env,
platform: NodeJS.Platform = process.platform,
): Promise<ApktoolClientIdentity> => {
const hostEnvironment = snapshotEnvironment(environment, platform);
const hostEnvironment = { ...process.env, ...environment };
let invocation: ApktoolInvocation;
try {
invocation = apktoolInvocation(
@@ -291,9 +195,7 @@ const rejectionCodeOf = (
): ProviderRejectionCode =>
code === "command_missing"
? "executable_missing"
: code === "not_absolute" ||
code === "launcher_unsupported" ||
code === "command_unresolved"
: code === "not_absolute" || code === "launcher_unsupported"
? "not_configured"
: "version_unresolved";
+15 -40
View File
@@ -51,7 +51,6 @@ import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js";
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import { WindowsBatchCommandError } from "../process/WindowsBatchCommand.js";
import {
APKTOOL_COMMAND_ENV,
ApktoolConfigurationFailure,
apktoolConfigurationAvailability,
apktoolInvocation,
@@ -156,9 +155,10 @@ const runApktool = async (
): Promise<Result<{ stdout: string; stderr: string }, AnalysisError>> => {
if (signal?.aborted === true)
return err(new AnalysisCancelledError(operation));
const hostEnvironment = { ...process.env, ...environment };
let invocation: ReturnType<typeof apktoolInvocation>;
try {
invocation = apktoolInvocation(selection, arguments_, environment);
invocation = apktoolInvocation(selection, arguments_, hostEnvironment);
} catch (cause) {
if (!(cause instanceof WindowsBatchCommandError)) throw cause;
const reason = `The apktool batch launcher cannot receive REA's decode paths through cmd.exe: ${cause.message}`;
@@ -178,7 +178,7 @@ const runApktool = async (
arguments: invocation.arguments,
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
runId: randomUUID(),
hostEnvironment: environment,
hostEnvironment,
},
{
timeoutMs: limits.timeoutMs,
@@ -317,9 +317,7 @@ export interface ApktoolProviderOptions {
* (`--no-src`) because Java-source recovery belongs to the JADX family.
*/
export class ApktoolProvider {
private readonly selectedEnvironment:
| Readonly<Record<string, string | undefined>>
| undefined;
private readonly environment: Readonly<Record<string, string | undefined>>;
private readonly workspaces = new Set<string>();
private readonly removeWorkspace: (path: string) => Promise<void>;
@@ -330,25 +328,14 @@ export class ApktoolProvider {
#closePromise: Promise<void> | undefined;
constructor(options: ApktoolProviderOptions = {}) {
this.selectedEnvironment =
options.environment === undefined
? undefined
: snapshotEnvironment(options.environment);
this.environment = snapshotEnvironment(options.environment ?? process.env);
this.removeWorkspace =
options.removeWorkspace ??
((path) => rm(path, { recursive: true, force: true }));
}
private operationEnvironment(): Readonly<NodeJS.ProcessEnv> {
const ambient = snapshotEnvironment(process.env);
if (this.selectedEnvironment === undefined) return ambient;
// Child inheritance is sampled per operation. An explicit map keeps its
// launcher choice authoritative, including an omitted command selecting PATH.
return snapshotEnvironment({
...ambient,
...this.selectedEnvironment,
[APKTOOL_COMMAND_ENV]: this.selectedEnvironment[APKTOOL_COMMAND_ENV],
});
private selection(): ApktoolCommandSelection {
return resolveApktoolCommand(this.environment);
}
/** Probe the selected apktool launcher; decodes nothing. */
@@ -367,14 +354,9 @@ export class ApktoolProvider {
async #inspectAvailability(
signal: AbortSignal,
): Promise<ProviderAvailability> {
const environment = this.operationEnvironment();
try {
const selection = await resolveApktoolCommand(
environment,
process.platform,
signal,
);
await inspectApktoolClient(selection, signal, environment);
const selection = this.selection();
await inspectApktoolClient(selection, signal, this.environment);
throwIfCancelled("inspect_apktool_client", signal);
return {
status: "available",
@@ -424,22 +406,17 @@ export class ApktoolProvider {
): Promise<Result<AnalysisExecution, AnalysisError>> {
if (signal?.aborted === true)
return err(new AnalysisCancelledError(request.operation));
const environment = this.operationEnvironment();
let selection: ApktoolCommandSelection;
let identity: ApktoolClientIdentity;
try {
selection = await resolveApktoolCommand(
environment,
process.platform,
selection = this.selection();
identity = await inspectApktoolClient(
selection,
signal,
this.environment,
);
identity = await inspectApktoolClient(selection, signal, environment);
} catch (cause) {
if (
cause instanceof AnalysisCancelledError ||
cause instanceof AnalysisTimeoutError
)
return err(cause);
if (cause instanceof AnalysisCancelledError) return err(cause);
if (cause instanceof ApktoolConfigurationFailure) {
return err(
providerConfigurationRefusal(
@@ -472,7 +449,6 @@ export class ApktoolProvider {
return this.decodeResources(
request.operation,
selection,
environment,
client,
provider,
request.input,
@@ -524,7 +500,6 @@ export class ApktoolProvider {
private async decodeResources(
operation: "decode_android_resources",
selection: ApktoolCommandSelection,
environment: Readonly<NodeJS.ProcessEnv>,
client: {
command: string;
command_source: string;
@@ -653,7 +628,7 @@ export class ApktoolProvider {
maxOutputBytes: DECODE_MAX_OUTPUT_BYTES,
},
signal,
environment,
this.environment,
(owner) => this.#pendingCommandOwners.set(owner, root),
);
if (!decoded.ok) {
-11
View File
@@ -50,13 +50,6 @@ export interface AnalysisExecution {
readonly limitations: readonly string[];
/** Provider-owned completeness assessment for caller/callee observations. */
readonly relationshipCoverage?: RelationshipCoverage;
/**
* Whether the provider capped an `xrefs` read: true when it reported a
* truncation, false when it explicitly reported none. Absent when it reports
* nothing, which is not a completeness claim. Never part of Evidence or tool
* output.
*/
readonly referencesTruncated?: boolean;
readonly locations: readonly EvidenceLocation[];
readonly subject: EvidenceSubjectTarget | null;
}
@@ -66,7 +59,6 @@ interface CreateAnalysisExecutionOptions {
readonly analysisProfile?: AnalysisProfileCommitment;
readonly limitations?: readonly string[];
readonly relationshipCoverage?: RelationshipCoverage;
readonly referencesTruncated?: boolean;
readonly locations?: readonly EvidenceLocation[];
readonly subject?: EvidenceSubjectTarget;
}
@@ -111,9 +103,6 @@ export function createAnalysisExecution(
options.relationshipCoverage,
),
}),
...(options.referencesTruncated === undefined
? {}
: { referencesTruncated: options.referencesTruncated }),
locations: [...(options.locations ?? [])],
subject: options.subject ?? null,
};
+5 -7
View File
@@ -76,8 +76,6 @@ export const parseBinaryTarget = async (
await access(candidate, constants.R_OK);
throwIfTargetResolutionCancelled(signal);
const canonical = await realpath(candidate);
// Windows native admission must inspect the caller-selected reparse components.
const sourcePath = process.platform === "win32" ? candidate : canonical;
throwIfTargetResolutionCancelled(signal);
const resolved = await resolveAppBundle(canonical);
throwIfTargetResolutionCancelled(signal);
@@ -115,7 +113,7 @@ export const parseBinaryTarget = async (
);
return ok({
path,
sourcePath,
sourcePath: canonical,
sha256: await sha256Handle(handle, path, metadata, signal),
kind: "executable",
format: "raw-binary",
@@ -154,7 +152,7 @@ export const parseBinaryTarget = async (
);
return ok({
path,
sourcePath,
sourcePath: canonical,
sha256: await sha256Handle(handle, path, metadata, signal),
kind: "executable",
format: "dos-com",
@@ -168,7 +166,7 @@ export const parseBinaryTarget = async (
)
return ok({
path,
sourcePath,
sourcePath: process.platform === "win32" ? candidate : canonical,
sha256: await sha256Handle(handle, path, metadata, signal),
kind: "database",
format: "analysis-database",
@@ -177,7 +175,7 @@ export const parseBinaryTarget = async (
if (artifactFormat !== undefined) {
const identity = {
path,
sourcePath,
sourcePath: process.platform === "win32" ? candidate : canonical,
sha256: await sha256Handle(handle, path, metadata, signal),
};
return isArchiveFormat(artifactFormat)
@@ -197,7 +195,7 @@ export const parseBinaryTarget = async (
);
return ok({
path,
sourcePath,
sourcePath: process.platform === "win32" ? candidate : canonical,
...bundle,
sha256: await sha256Handle(handle, path, metadata, signal),
kind: "executable",
@@ -497,71 +497,3 @@ it("reports host denial before target mismatch but keeps composed workflow prere
).reason,
).toBe("target_required");
});
describe("trace_feature requirements", () => {
const capabilities = (operations: readonly string[]) =>
operations.map((operation) => ({
operation,
available: true,
reason: null,
}));
it("needs the search operations the workflow actually calls", () => {
expect(
entry(
"trace_feature",
status({
open: true,
kind: "executable",
capabilities: capabilities([
"search_strings",
"search_procedures",
"xrefs",
"resolve_containing_procedure",
]),
}),
),
).toMatchObject({ available: true, reason: "available" });
});
it.each(["search_strings", "search_procedures"])(
"is unavailable without %s",
(missing) => {
const operations = [
"search_strings",
"search_procedures",
"xrefs",
"resolve_containing_procedure",
].filter((operation) => operation !== missing);
expect(
entry(
"trace_feature",
status({
open: true,
kind: "executable",
capabilities: capabilities(operations),
}),
),
).toMatchObject({ available: false, reason: "provider_missing" });
},
);
it("is unavailable when only the list operations exist", () => {
expect(
entry(
"trace_feature",
status({
open: true,
kind: "executable",
capabilities: capabilities([
"list_strings",
"list_procedures",
"xrefs",
"resolve_containing_procedure",
]),
}),
),
).toMatchObject({ available: false, reason: "provider_missing" });
});
});
+2 -3
View File
@@ -121,8 +121,8 @@ const ENHANCED_REQUIREMENTS: Readonly<Record<string, readonly string[]>> = {
analyze_function: ["analyze_function"],
inspect_native_api: ["analyze_function"],
trace_feature: [
"search_strings",
"search_procedures",
"list_strings",
"list_procedures",
"xrefs",
"resolve_containing_procedure",
],
@@ -141,7 +141,6 @@ const FRIDA_OPERATION_NAMES: ReadonlySet<string> = new Set(
/** Native contracts defined only for an active Mach-O executable. */
const MACHO_TARGET_OPERATIONS: ReadonlySet<string> = new Set([
"inspect_signature",
"inspect_macho",
"list_architectures",
"observe_native_calls",
@@ -1,341 +0,0 @@
import { basename, dirname, join, sep } from "node:path";
import { describe, expect, it } from "vitest";
import {
CLAUDE_DESKTOP_PACKAGE_FAMILY,
resolveWindowsClaudeDesktopLocation,
type ClaudeDesktopLocationFileSystem,
} from "./ClaudeDesktopConfigLocation.js";
import {
packagePrivateLauncherBlocker,
packagePrivateStorageFamily,
redirectedWriteBlocker,
type MsixPackageView,
} from "./MsixPackageStorage.js";
const home = join("/profile", "a");
const env = {
APPDATA: join(home, "AppData", "Roaming"),
LOCALAPPDATA: join(home, "AppData", "Local"),
};
const packages = join(env.LOCALAPPDATA, "Packages");
const appDataConfig = join(env.APPDATA, "Claude", "claude_desktop_config.json");
const privateConfig = join(
packages,
CLAUDE_DESKTOP_PACKAGE_FAMILY,
"LocalCache",
"Roaming",
"Claude",
"claude_desktop_config.json",
);
const failure = (code: string): Error =>
Object.assign(new Error(`${code}: injected`), { code });
const file = { isFile: () => true };
const privateDirectory = dirname(privateConfig);
const privatePreferences = join(privateDirectory, "Preferences");
const appDataDirectory = dirname(appDataConfig);
/** Package storage holding `privateFiles`; realpath decides the process view. */
const packagedFileSystem = (
realpath: ClaudeDesktopLocationFileSystem["realpath"],
privateFiles: readonly string[] = [privateConfig],
): ClaudeDesktopLocationFileSystem => ({
readdir: (path) => {
if (path === packages) return [CLAUDE_DESKTOP_PACKAGE_FAMILY];
if (path === privateDirectory)
return privateFiles.map((name) => basename(name));
throw failure("ENOENT");
},
lstat: (path) => {
if (!privateFiles.includes(path)) throw failure("ENOENT");
return file;
},
realpath,
});
describe("Claude Desktop MSIX process view", () => {
it("recognizes a process that sees the private copy at the AppData path", () => {
const location = resolveWindowsClaudeDesktopLocation(
home,
env,
packagedFileSystem((path) =>
path === appDataConfig ? privateConfig : path,
),
);
expect(location).toEqual({
ok: true,
value: expect.objectContaining({
configPath: privateConfig,
msixPackage: expect.objectContaining({ processView: "redirected" }),
}),
});
});
it("treats an absent AppData file as an unredirected process", () => {
const location = resolveWindowsClaudeDesktopLocation(
home,
env,
packagedFileSystem((path) => {
if (path === appDataConfig) throw failure("ENOENT");
return path;
}),
);
expect(location.ok && location.value.msixPackage?.processView).toBe(
"direct",
);
});
it("recognizes redirection through another private file before the configuration exists", () => {
const location = resolveWindowsClaudeDesktopLocation(
home,
env,
packagedFileSystem(
(path) =>
path === join(appDataDirectory, "Preferences")
? privatePreferences
: path,
[privatePreferences],
),
);
expect(location.ok && location.value.configPath).toBe(privateConfig);
expect(location.ok && location.value.msixPackage?.processView).toBe(
"redirected",
);
});
it("continues after a listed private file disappears", () => {
const fs = packagedFileSystem(
(path) =>
path === join(appDataDirectory, "Preferences")
? privatePreferences
: path,
[privatePreferences],
);
const location = resolveWindowsClaudeDesktopLocation(home, env, {
...fs,
readdir: (path) =>
path === privateDirectory
? ["vanished", "Preferences"]
: fs.readdir(path),
});
expect(location.ok && location.value.msixPackage?.processView).toBe(
"redirected",
);
});
it("keeps the view unknown when the package has no private files", () => {
const location = resolveWindowsClaudeDesktopLocation(
home,
env,
packagedFileSystem((path) => path, []),
);
expect(location.ok && location.value.msixPackage?.processView).toBe(
"unknown",
);
});
it("keeps the view unknown when the AppData path cannot be resolved", () => {
const location = resolveWindowsClaudeDesktopLocation(
home,
env,
packagedFileSystem((path) => {
if (path === appDataConfig) throw failure("EACCES");
return path;
}),
);
expect(location.ok && location.value.msixPackage?.processView).toBe(
"unknown",
);
});
});
describe("unresolved Claude Desktop package identity", () => {
it("reports unreadable package storage instead of assuming AppData", () => {
const location = resolveWindowsClaudeDesktopLocation(home, env, {
readdir: () => {
throw failure("EACCES");
},
lstat: () => file,
realpath: (path) => path,
});
expect(location).toEqual({
ok: false,
error: {
path: packages,
message: expect.stringContaining("could not be inspected"),
},
});
});
it("reports an uninspectable private copy instead of choosing a file", () => {
const location = resolveWindowsClaudeDesktopLocation(home, env, {
readdir: () => [CLAUDE_DESKTOP_PACKAGE_FAMILY],
lstat: () => {
throw failure("EPERM");
},
realpath: (path) => path,
});
expect(location.ok).toBe(false);
expect(!location.ok && location.error.path).toBe(privateConfig);
});
it("matches the package family case-insensitively, as Windows does", () => {
const location = resolveWindowsClaudeDesktopLocation(home, env, {
readdir: () => [CLAUDE_DESKTOP_PACKAGE_FAMILY.toUpperCase()],
lstat: () => {
throw failure("ENOENT");
},
realpath: (path) => path,
});
expect(location.ok && location.value.msixPackage?.packageFamilyName).toBe(
CLAUDE_DESKTOP_PACKAGE_FAMILY.toUpperCase(),
);
});
});
describe("package-private REA launchers", () => {
const launcher = join(
env.APPDATA,
"npm",
"node_modules",
"rea-agents",
"scripts",
"rea.mjs",
);
const stored = join(
packages,
CLAUDE_DESKTOP_PACKAGE_FAMILY,
"LocalCache",
"Roaming",
"npm",
"node_modules",
"rea-agents",
"scripts",
"rea.mjs",
);
it("names the package that privately stores a path", () => {
expect(packagePrivateStorageFamily(stored, packages)).toBe(
CLAUDE_DESKTOP_PACKAGE_FAMILY,
);
expect(packagePrivateStorageFamily(launcher, packages)).toBeUndefined();
expect(
packagePrivateStorageFamily(
join(packages, CLAUDE_DESKTOP_PACKAGE_FAMILY, "LocalState", "x"),
packages,
),
).toBeUndefined();
});
it("blocks a launcher that only the package's processes can see", () => {
const blocker = packagePrivateLauncherBlocker(
[join("/Program Files", "nodejs", "node.exe"), launcher, "mcp"],
packages,
(path) => (path === launcher ? stored : path),
);
expect(blocker).toContain(stored);
expect(blocker).toContain(CLAUDE_DESKTOP_PACKAGE_FAMILY);
expect(blocker).toContain("npm install -g rea-agents");
});
it("compares the launcher with the canonical package storage path", () => {
const shortProfilePackages = join(
"/profile",
"A~1",
"AppData",
"Local",
"Packages",
);
const blocker = packagePrivateLauncherBlocker(
[launcher, "mcp"],
shortProfilePackages,
(path) =>
path === shortProfilePackages
? packages
: path === launcher
? stored
: path,
);
expect(blocker).toContain(CLAUDE_DESKTOP_PACKAGE_FAMILY);
});
it("allows ordinary and unresolvable launchers", () => {
expect(
packagePrivateLauncherBlocker(
["cmd.exe", "/d", "/c", "npx", "-y", "rea-agents@6.4.0", "mcp"],
packages,
() => {
throw new Error("package runners are not resolved");
},
),
).toBeUndefined();
expect(
packagePrivateLauncherBlocker([launcher, "mcp"], packages, () => {
throw failure("ENOENT");
}),
).toBeUndefined();
});
});
describe("writes from a redirected AppData view", () => {
const view: MsixPackageView = {
packageFamilyName: CLAUDE_DESKTOP_PACKAGE_FAMILY,
privateConfigPath: privateConfig,
appDataConfigPath: appDataConfig,
virtualizedRoots: [env.APPDATA, env.LOCALAPPDATA],
processView: "redirected",
};
const vscode = join(env.APPDATA, "Code", "User", "mcp.json");
const hermes = join(env.LOCALAPPDATA, "hermes", "config.yaml");
const codex = join(home, ".codex", "config.toml");
it("refuses only targets under the package's virtualized roots", () => {
const blocker = redirectedWriteBlocker(
view,
[vscode, codex, hermes],
"setup",
);
expect(blocker).toContain(`${vscode}, ${hermes}`);
expect(blocker).not.toContain(codex);
expect(blocker).toContain("Rerun setup from a terminal");
expect(redirectedWriteBlocker(view, [codex], "setup")).toBeUndefined();
});
it("compares aliases through the existing parent of a new destination", () => {
const alias = join(home, "skill-alias");
const target = join(alias, "skills", "reverse-engineer-anything");
const blocker = redirectedWriteBlocker(
view,
[target],
"uninstall",
(path) => {
if (path === alias) return join(env.APPDATA, "dsh");
if (path.startsWith(`${alias}${sep}`)) throw failure("ENOENT");
return path;
},
);
expect(blocker).toContain(target);
expect(blocker).toContain("Rerun uninstall");
});
it("keeps canonicalization failures visible instead of assuming a target is outside AppData", () => {
expect(
redirectedWriteBlocker(view, [codex], "setup", () => {
throw failure("EACCES");
}),
).toContain("Cannot establish the MSIX write location");
});
it("allows every target without positive evidence of redirection", () => {
for (const processView of ["direct", "unknown"] as const)
expect(
redirectedWriteBlocker({ ...view, processView }, [vscode], "uninstall"),
).toBeUndefined();
expect(
redirectedWriteBlocker(undefined, [vscode], "setup"),
).toBeUndefined();
});
});

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