chore: sync main for native IDA verification

The native IDA follow-up is being verified on Windows IDA Pro 9.3. IDA Free is not used in this follow-up.
This commit is contained in:
N0zoM1z0 committed 2026-10-13 00:47:56 +08:00
commit fb34ccbec7
85 files changed
+4562 -480

No files matched your search

+3
View File
@@ -1,5 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Contribution scope and integration requirements
url: https://github.com/morluto/rea/blob/main/CONTRIBUTING.md#contribution-scope
about: Check supported capabilities, excluded workflows, and the fail-closed integration rule before proposing a feature.
- name: REA Documentation
url: https://github.com/morluto/rea#readme
about: Read the README and AGENTS.md for usage guides and configuration details.
+55 -13
View File
@@ -1,5 +1,5 @@
name: Design discussion
description: Resolve an REA architecture, provider, or contract question before implementation
description: Discuss an REA architecture, provider, contract, or scope decision
title: "[Design] "
labels:
- enhancement
@@ -9,7 +9,12 @@ body:
attributes:
value: |
Use this for a substantial provider, public/persisted contract, or
authority-boundary decision. Keep one decision per discussion.
authority-boundary decision. One question and a short explanation are
enough to start. Choose "Not sure yet" where needed; maintainers can help
with technical details, alternatives, and verification.
Read the [contribution scope](https://github.com/morluto/rea/blob/main/CONTRIBUTING.md#contribution-scope).
Unresolved boundaries block integration, not opening a discussion.
- type: checkboxes
id: before-submitting
@@ -18,14 +23,16 @@ body:
options:
- label: I searched the open and closed issues and pull requests for a related discussion.
required: true
- label: I removed credentials, tokens, private paths, and sensitive target data.
- label: I understand the [contribution scope](https://github.com/morluto/rea/blob/main/CONTRIBUTING.md#contribution-scope) must be reviewed before integration; open questions are welcome here.
required: true
- label: This discussion does not include credentials, private samples, or sensitive target data.
required: true
- type: textarea
id: decision
attributes:
label: Decision needed
description: State the exact question maintainers need to resolve.
description: What would you like maintainers to help decide? A short question is enough.
placeholder: Should REA ... or ...?
validations:
required: true
@@ -34,23 +41,57 @@ body:
id: context-evidence
attributes:
label: Context and evidence
description: Explain why the decision is needed now. Separate observed behavior, derived evidence, and inference.
description: Explain the problem or motivation. If you have evidence, distinguish observed behavior from ideas or guesses.
validations:
required: true
- type: dropdown
id: target-interaction
attributes:
label: Expected interaction with targets
description: >-
Select all known effects, including upstream tool behavior. Add "Not sure
yet" if anything is unclear. Use "No target interaction" only on its own.
These selections help discussion and do not establish scope approval.
multiple: true
options:
- Read existing files or captured data
- Launch or attach to a process
- Inject instrumentation or run scripts/code
- Change process memory or registers
- Write or change files/configuration
- Contact network services
- Recover protected code or work around protections
- No target interaction (documentation or usability only)
- Not sure yet
validations:
required: true
- type: textarea
id: boundary-invariants
attributes:
label: Boundary and invariants
description: Identify the owning layer, provider/bridge boundary, artifact or evidence invariants, and any process or authorization constraints.
validations:
required: true
label: Boundary details (optional)
description: >-
If known, clarify target selection, selected effects, provider boundaries,
non-goals, or evidence invariants. Leave blank if you need help identifying
them. Do not include private samples or proof-of-authorization documents.
- type: textarea
id: options
attributes:
label: Options and tradeoffs
description: Compare a small number of viable approaches, including platform, compatibility, safety, evidence, and maintenance costs.
description: Optional approaches or tradeoffs. One idea is enough; maintainers can help compare alternatives.
- type: dropdown
id: verification-readiness
attributes:
label: Verification available so far
description: Pick the closest match. An idea without samples or test results is welcome.
options:
- Idea only; I need help defining checks
- I can suggest a sample or manual check
- I have a prototype or observed results
- Not sure yet
validations:
required: true
@@ -58,9 +99,10 @@ body:
id: success-criteria
attributes:
label: Success criteria and open questions
description: State what outcome would settle the decision and list only unresolved questions that could change the choice.
validations:
required: true
description: >-
Optional expected output, sample, possible check, or unresolved question.
A complete test plan is not required to start; maintainers and implementers
will resolve material verification gaps before integration.
- type: textarea
id: references
+64 -11
View File
@@ -8,9 +8,13 @@ body:
- type: markdown
attributes:
value: |
Keep one primary capability or workflow outcome per request. For a
substantial provider, public contract, or architecture change, explain
the decision maintainers need to make before implementation.
Describe one problem and the outcome you would like. Short answers are
welcome. Choose "Not sure yet" wherever needed; you do not need code, a
finished design, or a complete test plan to propose an idea.
Read the [contribution scope](https://github.com/morluto/rea/blob/main/CONTRIBUTING.md#contribution-scope).
Maintainers can help resolve scope and technical questions. Unresolved
boundaries block integration, not opening a request for discussion.
- type: checkboxes
id: before-submitting
@@ -19,7 +23,9 @@ body:
options:
- label: I searched the open and closed issues and pull requests for a related proposal.
required: true
- label: I removed credentials, tokens, private paths, and sensitive target data.
- label: I understand the [contribution scope](https://github.com/morluto/rea/blob/main/CONTRIBUTING.md#contribution-scope) must be reviewed before integration; open questions are welcome here.
required: true
- label: This request does not include credentials, private samples, or sensitive target data.
required: true
- type: dropdown
@@ -36,6 +42,7 @@ body:
- MCP or agent integration
- Documentation
- Other
- Not sure yet
validations:
required: true
@@ -43,7 +50,7 @@ body:
id: current-limitation
attributes:
label: Current limitation
description: What user or agent workflow is blocked, ambiguous, or unnecessarily difficult today?
description: What are you trying to understand or do, and what is difficult today? A short example is enough.
validations:
required: true
@@ -51,23 +58,69 @@ body:
id: use-cases
attributes:
label: Use cases
description: Give concrete applications, targets, callers, or investigation workflows. Explain why current tools are insufficient.
validations:
required: true
description: Optional additional examples of applications, targets, or callers. Leave blank if covered above.
- type: textarea
id: desired-outcome
attributes:
label: Desired outcome and success criteria
description: Describe observable behavior, inputs, outputs, evidence, and compatibility criteria that would make this useful.
description: What would a useful result look like? Describe the output or behavior you want; implementation details are optional.
placeholder: For a selected APK, show whether it is packed and include the detection evidence.
validations:
required: true
- type: dropdown
id: target-interaction
attributes:
label: Expected interaction with targets
description: >-
Select all known effects, including upstream tool behavior. Add "Not sure
yet" if anything is unclear. Use "No target interaction" only on its own.
These selections help discussion and do not establish scope approval.
multiple: true
options:
- Read existing files or captured data
- Launch or attach to a process
- Inject instrumentation or run scripts/code
- Change process memory or registers
- Write or change files/configuration
- Contact network services
- Recover protected code or work around protections
- No target interaction (documentation or usability only)
- Not sure yet
validations:
required: true
- type: textarea
id: boundaries
attributes:
label: Authority boundaries and non-goals
description: Call out process execution, target mutation, provider licensing, evidence provenance, supported platforms, and work that should remain out of scope.
label: Target and scope details (optional)
description: >-
If known, name the target/platform, clarify selected effects, or list a
non-goal or scope question. You can leave this blank; maintainers can help.
Do not include private samples or proof-of-authorization documents.
- type: dropdown
id: verification-readiness
attributes:
label: Verification available so far
description: Pick the closest match. An idea without samples or test results is welcome.
options:
- Idea only; I need help defining checks
- I can suggest a sample or manual check
- I have a prototype or observed results
- Not sure yet
validations:
required: true
- type: textarea
id: verification
attributes:
label: Verification details (optional)
description: >-
Share an expected output, a possible check, or existing results if you have
them. Self-owned or appropriately licensed samples can help, but are not
required to open a request. Do not attach private or sensitive data.
- type: textarea
id: alternatives
@@ -11,7 +11,7 @@ runs:
id: plan
shell: bash
env:
BUILD_CACHE_PREFIX: rea-portable-build-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.npmrc') }}
BUILD_CACHE_PREFIX: rea-portable-build-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.npmrc', '.nvmrc', 'package-lock.json') }}
run: |
npm run --silent test:prepare -- --dry=json |
node --input-type=module -e '
@@ -25,6 +25,7 @@ runs:
}).sort();
const hash = createHash("sha256").update(hashes.join("\n")).digest("hex");
console.log(`key=${process.env.BUILD_CACHE_PREFIX}-${hash}`);
console.log(`prefix=${process.env.BUILD_CACHE_PREFIX}-`);
' >> "$GITHUB_OUTPUT"
- name: Restore portable build outputs
if: inputs.save != 'true'
@@ -32,9 +33,11 @@ runs:
with:
path: .turbo/cache/
key: ${{ steps.plan.outputs.key }}
restore-keys: ${{ steps.plan.outputs.prefix }}
- name: Restore and save portable build outputs
if: inputs.save == 'true'
uses: actions/cache@v4
with:
path: .turbo/cache/
key: ${{ steps.plan.outputs.key }}
restore-keys: ${{ steps.plan.outputs.prefix }}
+29 -3
View File
@@ -19,17 +19,39 @@ describe the use case and observable outcome. -->
<!-- Explain the chosen approach, why it fits REA's architecture, and what is
intentionally not included. -->
Follow the [contribution scope](https://github.com/morluto/rea/blob/main/CONTRIBUTING.md#contribution-scope).
Unresolved scope, authority, side effects, or required verification block
integration. Link the required maintainer scope decision for new providers or
capabilities involving target execution, injection, mutation, or protection
recovery; ordinary fixes within existing boundaries may state "not applicable".
Draft PRs may leave the scope decision pending and the final review checklist
unchecked. Maintainers can help resolve open questions.
## Contract and boundary impact
<!-- Complete the applicable lines; use "none" or "not applicable" explicitly. -->
<!-- Short answers are welcome. Complete applicable lines; mark unresolved facts as "unknown".
Use "none" or "not applicable" only when established. -->
- Semantic owner and earliest changed stage:
- Contribution scope fit and linked maintainer decision:
- CLI and MCP/tool-catalog contract:
- Provider, bridge, target-format, or platform compatibility:
- Evidence, artifact, provenance, or reconstruction contract:
- Process execution, authorization, cleanup, or containment impact:
- Target selection, authority, upstream behavior, and cleanup details (if applicable):
- Generated metadata (`docs/public/product-catalog.json`), package, or installation impact (CI generates and validates ignored catalogs and `skills/`; edit authored instructions in `.agents/skills/reverse-engineer-anything/` and never commit build outputs):
Expected effects (check known effects; use documentation-only on its own).
Unchecked items do not establish absence; leave uncertainties visible.
- [ ] Read existing files or captured data.
- [ ] Launch or attach to a process.
- [ ] Inject instrumentation or run scripts/code.
- [ ] Change process memory, registers, files, or configuration.
- [ ] Contact network services.
- [ ] Recover protected code or work around protections.
- [ ] Documentation or usability only; no target interaction.
- [ ] Not sure yet; investigation or maintainer guidance needed.
## Evidence and regression coverage
<!-- State whether evidence is executed, source-derived, or proposed. For fixes,
@@ -68,9 +90,13 @@ limits, security/privacy/containment implications, and meaningful proof gaps. --
- Package or release metadata impact:
- Security, privacy, process, or containment review:
## Review checklist
## Before merge
<!-- Draft PRs may leave pending items unchecked. Resolve material scope and
authority questions before integration. -->
- [ ] This PR addresses a concrete problem or an agreed enhancement.
- [ ] Material scope and authority questions are resolved, and any required maintainer scope decision covers the final diff.
- [ ] The PR has one focused outcome and the title follows `type(scope): outcome`.
- [ ] Related issue is linked, or the reason for not linking one is stated above.
- [ ] Tests cover changed observable behavior and meaningful failure paths.
+29 -27
View File
@@ -94,6 +94,9 @@ jobs:
run: npm run check:ci
- if: needs.changes.outputs.code == 'true'
run: npm run verify:test-discovery
- name: Check LLDB producer retention and launch contracts
if: needs.changes.outputs.code == 'true'
run: npm run test:python
- if: needs.changes.outputs.code != 'true'
run: npm run format:check
@@ -168,7 +171,7 @@ jobs:
continue-on-error: true
uses: actions/download-artifact@v4
with:
name: test-results
name: test-timings
run-id: ${{ steps.history.outputs.run-id }}
github-token: ${{ github.token }}
path: .cache/ci-test-history
@@ -203,14 +206,10 @@ jobs:
if: ${{ always() }}
runs-on: ubuntu-latest
steps:
- name: Require successful test shards
- name: Require successful change classification
env:
CHANGES_RESULT: ${{ needs.changes.result }}
CODE_REQUIRED: ${{ needs.changes.outputs.code }}
SHARD_RESULT: ${{ needs.test-shard.result }}
run: |
test "${CHANGES_RESULT}" = "success"
test "${CODE_REQUIRED}" != "true" || test "${SHARD_RESULT}" = "success"
run: test "${CHANGES_RESULT}" = "success"
- uses: actions/checkout@v4
if: needs.changes.outputs.code == 'true' && success()
- uses: actions/setup-node@v4
@@ -227,6 +226,7 @@ jobs:
pattern: vitest-report-*
merge-multiple: true
- name: Merge coverage, JUnit, and timing reports
id: merge
if: needs.changes.outputs.code == 'true' && success()
run: >-
npm run test:ci:merge --
@@ -240,27 +240,13 @@ jobs:
--outputFile.junit=test-results.xml
--outputFile.json=test-results.json
- name: Summarize slowest test files
if: needs.changes.outputs.code == 'true' && success()
shell: bash
run: |
node <<'NODE' >> "${GITHUB_STEP_SUMMARY}"
const report = JSON.parse(require("node:fs").readFileSync("test-results.json", "utf8"));
const files = (report.testResults ?? [])
.map((result) => ({
name: result.name,
duration: Math.max(0, (result.endTime ?? result.startTime) - result.startTime),
}))
.sort((left, right) => right.duration - left.duration)
.slice(0, 20);
console.log("## Slowest deterministic test files\n");
console.log("| File | Duration (ms) |");
console.log("| --- | ---: |");
for (const file of files) {
console.log(`| \`${file.name}\` | ${file.duration} |`);
}
NODE
if: needs.changes.outputs.code == 'true' && !cancelled() && hashFiles('test-results.json') != ''
env:
SHARD_RESULT: ${{ needs.test-shard.result }}
MERGE_RESULT: ${{ steps.merge.outcome }}
run: node scripts/ci/test-report.mjs
- uses: actions/upload-artifact@v4
if: needs.changes.outputs.code == 'true' && always()
if: needs.changes.outputs.code == 'true' && !cancelled() && hashFiles('test-results.json') != ''
with:
name: test-results
path: |
@@ -269,6 +255,21 @@ jobs:
coverage/
if-no-files-found: error
retention-days: 30
- name: Publish successful timing history
if: needs.changes.outputs.code == 'true' && success() && needs.test-shard.result == 'success'
uses: actions/upload-artifact@v4
with:
name: test-timings
path: .cache/ci-test-history/test-results.json
include-hidden-files: true
if-no-files-found: error
retention-days: 30
- name: Require successful test shards
if: ${{ !cancelled() }}
env:
CODE_REQUIRED: ${{ needs.changes.outputs.code }}
SHARD_RESULT: ${{ needs.test-shard.result }}
run: test "${CODE_REQUIRED}" != "true" || test "${SHARD_RESULT}" = "success"
conformance-fixtures:
name: Source-built conformance fixtures
@@ -503,6 +504,7 @@ jobs:
tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts
tests/conformance/providers/ghidra/windowsCapabilities.test.ts
src/ghidra/GhidraInstallation.test.ts
tests/boundary/filesystem/ghidraJavaProbe.test.ts
tests/boundary/providers/ghidra/ghidraTransport.test.ts
tests/boundary/providers/ghidra/ghidraTargetSnapshot.test.ts
tests/boundary/providers/ghidra/ghidraLauncher.test.ts
+27
View File
@@ -4,6 +4,8 @@
REA is a local-only reverse-engineering tool with shared CLI/MCP workflows. Distinguish observations, derivations, inferences, and unknowns; preserve Evidence provenance and session semantics through both adapters. Provider support and setup live in [README.md](README.md#choosing-a-deep-analysis-provider) and its linked guides.
Follow the [contribution scope and fail-closed integration rule](CONTRIBUTING.md#contribution-scope). Do not integrate capabilities while material scope, authority, side-effect, or required verification questions remain unresolved. For new providers or capabilities involving target execution, injection, mutation, or protection recovery, seek the required maintainer scope decision before substantial implementation for integration. Welcome early proposals, small exploratory prototypes, and draft PRs that help resolve questions; they do not authorize integration.
Ghidra uses ephemeral databases without modifying executable bytes or controlling a GUI; Windows Ghidra P0 has no mutation authority. IDA analysis is read-only; never save or close an attached GUI database.
Configuration changes must be additive and idempotent, with backups. Installers must not install or upgrade unrelated software, including Homebrew, Node.js, npm, Java, or Ghidra. Ghidra and IDA are bring-your-own. `rea setup` must show its planned changes and require approval before writing files or installing Hopper.
@@ -26,6 +28,31 @@ Parse unknown boundary values and model expected failures with the tagged error
Validate advertised JSON Schemas after SDK conversion against their declared dialect and actual clients. Bind handlers to named contracts with exact types; catalog ordering must never select an operation or schema.
## Performance
Design analysis workflows and agent interfaces for efficiency from the first
implementation. Correctness, useful answers, Evidence provenance, caller choices,
compatibility and safe resource bounds remain required.
Optimize in this order:
1. Agent tokens and context. Return the smallest sufficient answer with
necessary provenance, coverage and limitations. Use task selection,
summaries, paging and retained Evidence where supported; honor requested
detail and keep complete Evidence accessible. Count the whole task,
including schemas, extra calls and retries.
2. Analysis time. Reuse validated results and shared work. Cache keys must
cover result-affecting inputs; define invalidation and lifetime. Validate
freshness before reporting cached live state as current.
3. CPU and memory. Avoid unnecessary work and copies; bound concurrency,
retained state and serialization growth.
For changes affecting these costs, measure representative completed tasks and
compare equivalent workflows before and after where available. Report token
usage, elapsed time, CPU time and peak process-family memory. Separate cold
and warm runs, label estimates, and state unmeasured costs. Fewer bytes alone
do not establish fewer tokens or faster analysis.
## Development
Use [.nvmrc](.nvmrc) and `package.json#packageManager` for the pinned toolchain. Cursor Cloud may place older Node.js first on `PATH`; environment setup installs the pinned toolchain under `/usr/local/bin`.
+104
View File
@@ -17,8 +17,108 @@ targets; optional adapter failures must preserve successful peers. Run
`npm run verify:test-discovery` after adding or moving tests. Resolved import
boundaries are checked by `npm run verify:module-boundaries`, included in lint.
For analysis workflows and agent interfaces, follow the
[performance priorities](AGENTS.md#performance). Explain the selected output
scope, reuse/cache decisions, and relevant before/after measurements when these
costs change.
When adding or changing an MCP tool, follow the [tool design guide](docs/tool-design.md) and preserve the canonical contracts and generated catalog.
## Contribution scope
REA focuses on software structure, runtime behavior, and evidence through
general-purpose reverse-engineering workflows. Evaluate a proposal by its actual
capabilities, side effects, defaults, and complete workflow. A tool name or a
reference to vulnerability research, malware analysis, or CTFs does not determine
whether it belongs in REA.
Supported contributions include format inspection, ordinary archive and standard
compression extraction, disassembly, decompilation, cross-references, general
static deobfuscation, vulnerability analysis, and evidence reporting. Static
protection detection is distinct from defeating that protection. Security fixes
and isolated regression tests remain in scope.
Runtime investigation needs explicit boundaries. Launching or attaching to a
process, injecting instrumentation, running scripts, and changing memory or
registers require review of target selection, execution and mutation authority,
provenance, resource limits, cancellation, and cleanup. General Frida or debugger
support is not inherently read-only, and existing support does not authorize
every workflow built on it. General-purpose interfaces are not classified as
dedicated bypass workflows solely because caller-provided code could be misused;
their execution and mutation authority must still be reviewed.
REA does not integrate or officially distribute:
- Purpose-built workflows to defeat commercial packing or code-virtualization
protections and automatically recover protected code.
- Anti-debugging or anti-instrumentation evasion, or DRM, license, anti-cheat, or
access-control bypass workflows.
- Exploit or attack-payload automation, credential theft, covert persistence,
unauthorized access, or bulk attack workflows.
These boundaries apply to first-party providers, adapters, plugins, bundled
scripts, defaults, examples, and documentation. Moving a capability into an
optional dependency or plugin does not exempt it. Review what the integration
exposes, including upstream defaults and raw-command or script execution paths.
Ordinary extraction and general static deobfuscation are not excluded merely
because the input is packed or obfuscated; assess added bypass or execution
behavior separately.
This is a product and maintenance boundary, not a judgment about a contributor's
intentions or a declaration that all excluded research is unlawful. Contributors
must have the rights and authorization needed for their work. A local-only design
or an "authorized use only" disclaimer does not establish containment or settle
legal and scope questions.
### Fail-closed integration
If scope fit, target or authorization boundaries, side effects, or the verification
needed to establish those boundaries remain materially unresolved, **do not merge,
enable, or include the capability in official packages or distributions**. Keep
the proposal pending, narrow it, or decline it. Record unknowns explicitly; do not
turn them into "none" or assumed safety. Passing CI, completed implementation,
upstream popularity, or silence from maintainers is not scope approval.
For a new provider or a capability involving target execution, injection,
mutation, or protection recovery, seek an explicit maintainer scope decision in
the linked issue or design discussion before substantial implementation intended
for integration. Small exploratory prototypes and draft PRs may help inform that
decision; they do not authorize integration. The decision must cover the final
proposed behavior; a scope expansion needs a new decision. Routine fixes and
documentation changes within existing boundaries do not need a separate advance
decision, but must not silently expand those boundaries.
### Starting a proposal
To open a feature request or design discussion, describe the problem and desired
outcome or decision. Short answers and the forms' "Not sure yet" options are
welcome. You do not need code, a complete design or test plan, legal analysis, or
private proof-of-authorization documents to start a discussion. Share what you
know and leave optional details blank when you need help.
Form selections guide triage; they are not proof of authorization or scope
approval. Unselected effects must not be treated as established absence.
Maintainers and implementers work together to resolve the following before
integration:
- The concrete analyst question, requested inputs and outputs, and why existing
REA operations are insufficient.
- Why the workflow fits this scope, its non-goals, and any protection recovery,
bypass, or attack automation it would expose.
- The selected targets and every execution, injection, mutation, network, or
persistence effect, including behavior inherited from upstream tools.
- How the boundaries and outcome will be verified with self-owned or appropriately
licensed samples; identify real CLI/MCP and provider coverage, cleanup and
resource checks, and remaining unknowns. Do not publish private binaries,
credentials, or sensitive target data; follow [SECURITY.md](SECURITY.md) for
private vulnerability reports.
Mixed proposals should separate acceptable analysis capabilities from excluded
workflows so each can be decided independently. An issue may discuss unresolved
questions; opening an issue or PR does not authorize integration. This is a
maintainer integration gate, not a per-call confirmation requirement or a sandbox
guarantee.
## Development setup
Use the development toolchain pinned in `.nvmrc` and
@@ -87,6 +187,10 @@ CI diagnosis. Packaging/setup/distribution changes additionally require
`npm run verify:package` and `npm pack --dry-run`; provider changes require the
matching real-provider lane. State which host/provider workflows ran.
`check:pr` reuses build and static-check outputs while executing the test suite
fresh, including the fast Python producer checks. `docs:check` also runs the
onboarding example consistency suite for README-only changes.
Pre-commit formats and lints staged files; pre-push runs `check:fast`.
### Generated files and command ownership
+5
View File
@@ -148,6 +148,11 @@ existing analysis families directly, for example
- Cancellation during a package pull preserves caller-selected output. The
error includes partial observations identifying completed artifacts and the
interrupted local path; remaining APKs were not attempted.
- Bugreports collect into isolated staging and publish with exclusive creation;
filename collisions select the next numeric suffix. If publication fails, the
completed archive remains in staging. The error's partial observation gives
its recoverable `local_path`, the original `adb_reported_path`, and an unknown
digest status. Later operations and shutdown preserve this retained evidence.
- Failed package acquisition rollback retains its directory identity and retries
cleanup before later work and during shutdown. Cleanup refuses a replacement
directory. Terminal shutdown stops admission and drains active operations.
+4 -2
View File
@@ -123,8 +123,10 @@ Select a verified native child for the existing tools:
```
Then use `analyze_function`, `inspect_native_api` or the other native primitives.
Native architecture and executable validation still apply. This milestone does
not add MIPS/PPC/RISC-V decompilation, raw flash load profiles, bootloader/device
Native architecture and executable validation still apply. Generic
[MIPS ELF](ghidra-mips.md) and [RISC-V ELF](ghidra-riscv.md) lanes are separate
documents with their own admission boundaries. This milestone does
not add PPC decompilation, raw flash load profiles, bootloader/device
tree semantics, vulnerability scanning, emulation or hardware runtime capture.
## Resource and lifecycle policy
+105
View File
@@ -0,0 +1,105 @@
# RISC-V ELF analysis through Ghidra
This lane extends native ELF analysis for `EM_RISCV` targets. It does not
execute RISC-V code, emulate a boot sequence, or rehost firmware. It addresses
[#2063](https://github.com/morluto/rea/issues/2063) and follows the same
lane shape as [generic MIPS support](ghidra-mips.md).
## Admission boundary
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, 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`,
`EF_RISCV_TSO`, or any bit outside `EF_RISCV_RVC | EF_RISCV_FLOAT_ABI`. These are
header declarations, not proof that every instruction conforms to that ISA.
Provider selection returns the specific unsupported constraint rather than
guessing a compatible profile:
| Declaration | Outcome |
| ----------------------------------------------- | --------------------------------------------------------------------------- |
| Little-endian ELF64 `ET_EXEC`, double-float ABI | admitted, `RISCV:LE:64:default` + `gcc` |
| Little-endian ELF32 `ET_EXEC`, double-float ABI | admitted, `RISCV:LE:32:default` + `gcc` |
| Big-endian | refused; Ghidra 12.1.4 ships no big-endian RISC-V language |
| `ET_REL`, `ET_DYN`, other `e_type` | refused with the declared value |
| `EF_RISCV_RVE` | refused; the embedded 15-register ABI needs its own processor specification |
| `EF_RISCV_TSO` | refused; the total-store-order memory model is unverified here |
| Soft, single, or quad float ABI | refused; the pinned generic compiler specification models double-float |
| Reserved or newer `e_flags` bits | refused with the offending bits named |
`e_type` is the only load-shape declaration this lane reads. A fixed-address
`ET_EXEC` declaration does **not** establish static linkage: `PT_INTERP` and
dynamic dependencies are not inspected, and the session records that boundary as
a limitation rather than inferring a load model.
## Why the language is pinned
Ghidra's header-based selection is not reliable for RV32. The 12.1.4
`riscv.opinion` pins `variant="default"` for the 64-bit constraint and leaves the
32-bit constraint unpinned with the comment that the loader "must choose between
'default' and 'AndeStar' variants". Measured against the installed release tree:
| Fixture | Ghidra auto-selection |
| ----------------------------------------------- | ----------------------------- |
| RV64 ELF, `e_flags 0x5` (RVC, double-float ABI) | `RISCV:LE:64:default:gcc` |
| RV32 ELF, `e_flags 0x5`, no Andes declaration | `RISCV:LE:32:AndeStar_v5:gcc` |
So an ordinary RV32 image lands on an Andes processor model unless the language
is stated. REA therefore pins `RISCV:LE:32:default` or `RISCV:LE:64:default`
with the `gcc` compiler specification per class, records
`language_selection: provider-pinned-v1`, and refuses a caller-selected
`REA_GHIDRA_LANGUAGE_ID` or `REA_GHIDRA_COMPILER_SPEC_ID` that disagrees with the
pin without changing the requested override. An override that matches the pin is
accepted and recorded as `configured-v1`.
## Evidence
The lane identity is `elf32-exec-le-double-float-generic-v1` or
`elf64-exec-le-double-float-generic-v1`, and `riscv_elf` retains `elf_class`,
`byte_order`, `type`, and the raw `flags` alongside the read-only booleans
`float_abi_double`, `rvc_declared`, `rve_declared`, and `tso_declared`. Those
fields report declarations the header makes; none of them is evidence of the
original compiler, of instruction conformance, or of a floating-point calling
convention beyond the ABI the pinned language models.
## Verification and limits
Ghidra 12.1.4 PUBLIC with a 64-bit JDK 21+ is required, and on macOS the release
tree ships no host decompiler binary, so `Decompiler/os/<platform>/decompile` and
`sleigh` must come from your own build; see
[installation](installation.md#ghidra).
Run `npm run verify:ghidra:riscv` with an existing Ghidra 12.1.4 installation,
JDK 21+, and a RISC-V-capable Clang, LLD and LLVM readelf. The verifier acquires
no tools and never executes target code. Select installed tools explicitly when
they are not on `PATH`:
```sh
GHIDRA_INSTALL_DIR=/path/to/ghidra_12.1.4_PUBLIC \
JAVA_HOME=/path/to/jdk-21 \
REA_RISCV_CLANG=/path/to/llvm/bin/clang \
REA_RISCV_LLD=/path/to/lld/bin/ld.lld \
REA_RISCV_READELF=/path/to/llvm/bin/llvm-readelf \
npm run verify:ghidra:riscv
```
The lane builds the committed freestanding C and assembly sources into RV32 and
RV64 `ET_EXEC` fixtures. Independent LLVM readelf observations establish ELF
declarations and symbol addresses. Public CLI and stdio MCP workflows verify
function enumeration, decompilation, pinned language/compiler identity, decoded
probe bytes, global data, and direct calls. It validates advertised MCP output
schemas and both JSON representations, saves and reopens snapshots, rejects
header-mutated soft-float and unsupported high-bit declarations, and checks
unchanged source bytes and owned cleanup. Failures retain the diagnostic
workspace; successful runs remove it and report the observed process lineage.
Module tests cover admission and malformed headers. Call-graph completeness,
string inventories, dynamic linkage, Andes or RVE/TSO images, and big-endian
targets are **not** established by this lane. `.riscv.attributes` ISA extensions
are not inspected; the generic language pin remains an analysis convention.
+2
View File
@@ -1,3 +1,5 @@
<div id="native-ui-and-dispatch-investigation"></div>
# Native binary investigation
Use a native provider to discover functions, inspect instructions and pseudocode,
+1 -1
View File
@@ -36,7 +36,7 @@ check project-owned conformance fixtures and fail-closed report behavior.
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `discover-classify` | target-free startup, artifact inventory, compatible workflow selection, and exact visible limits |
| `diagnose-environment` | broken runtime separated from analysis failure, version skew detected, and revision changed after recovery |
| `declare-request` | exact target, operation, and lifecycle are carried by the request before launch |
| `acquire-authority` | missing scope reported, narrow grant resumed, expansion reprompted, and denial launched nothing |
| `static-analysis` | native and JavaScript routing plus preserved ambiguity, truncation, unsupported syntax, and residual unknowns |
| `reactive-scenarios` | predicate-driven interaction, descendant settlement, Electron correlation, and cancellation cleanup Evidence |
| `compare-authority-candidate` | explicit partial-order/finite-trace semantics, primary divergence localization, and unknown for incomplete comparison |
+27 -9
View File
@@ -19,11 +19,12 @@ behavior, and complete CI evidence before merging.
| `npm run test:boundary` | Boundary, CLI boundary, MCP boundary, process boundary, and process-global projects |
| `npm run test:mcp` | MCP boundary project |
| `npm run test:acceptance` | Complete compiled CLI and MCP acceptance workflows |
| `npm run test:python` | LLDB producer retention and launch contracts with a fake LLDB API; no native debugger required |
| `npm run test:watch` | Dirty source tests in watch mode, without build |
| `npm run test:watch:all` | Changed tests from every project; builds at startup, so rebuild after production edits before relying on compiled tests |
| `npm run check:changed` | Cached typecheck/lint and branch-related source feedback |
| `npm run check:pr` | Opt-in complete local deterministic gate and generated-file checks |
| `npm run docs:check` | Generated-document validation from current source and build outputs |
| `npm run docs:check` | Generated-document validation and fresh onboarding example consistency checks |
For example:
@@ -97,6 +98,12 @@ runner. Preserve simultaneous launches where concurrency is itself under test.
Build/documentation writers hold checkout-local output locks. Tests do not
hold a broad command lock; `check:pr` finishes tests before document validation.
Turbo caches deterministic preparation and static checks, but `test:run` always
executes. Repository files do not fully describe installed tool contents or host
state. A previous test pass therefore cannot replace current validation, even
when the source tree is unchanged. `check:pr` also runs the fast Python producer
suite; CI runs it in the static job for code changes. These producer tests do not
establish real LLDB engine coverage.
Vitest's persistent compile cache remains disabled. The `cliTest` fixture shares
a temporary Node compile cache among its fresh CLI subprocesses. The worker
@@ -247,17 +254,25 @@ Coverage does not replace named boundary or real-provider scenarios.
CI runs six native Vitest shards without retries. Each shard emits a blob
report; the merge job produces aggregate coverage plus JUnit and JSON timing
reports, uploads them together, and writes the slowest files to the workflow
summary. Static checks, documentation, build, package verification, and
reports even when a shard fails, uploads available diagnostics, and writes the
slowest files to the workflow summary. Partial reports are marked diagnostic
only. Failed, cancelled, or missing selected shards still fail the required gate,
and aggregate coverage thresholds remain enforced by the merge command.
Only successful complete merges publish the small `test-timings` artifact, with
checkout-relative paths; the build job downloads that artifact for the sequencer
instead of the coverage/JUnit bundle. Missing history falls back to native
sharding. Static checks, documentation, build, package verification, and
real-system lanes remain separate jobs so one kind of evidence cannot stand in
for another.
The build job saves portable Turbo outputs across runs. Eight Linux real-system
lanes restore this cache without waiting for the build job. The shared action
derives its key from `test:prepare --dry=json` task hashes, runner OS and
architecture, and `.npmrc`; test-only changes can reuse compiled outputs. Every
architecture, `.npmrc`, `.nvmrc`, and the lockfile; test-only changes can reuse compiled outputs. Every
build and verification command still runs, so Turbo checks task inputs and
rebuilds misses. There is no prefix fallback to accumulate older build outputs.
rebuilds misses. A restore prefix with the same platform and toolchain permits
partial task reuse when one generator's inputs change; Turbo still invalidates
that generator's entry. Native outputs are not stored in this cache.
Native package jobs, the Windows curated lane, and the Apple artifact lane reuse
the same workflow's Turbo cache for portable compiled JavaScript, skills, and
@@ -266,9 +281,10 @@ commands, which restore matching outputs or rebuild on a cache miss. Native
artifacts, dependency installs, package archives, and runtime verification remain
host-local. Keep platform-dependent outputs out of this shared cache.
The PR acceptance target is a median `npm run check:pr` wall time below three
minutes across three warm-build runs on the benchmark host. Keep Vitest caches
cold unless separately identified. A PR that touches packaging or real-system
Record cold and warm preparation separately from fresh test execution when
benchmarking the optional `npm run check:pr` gate. Compare multiple runs on the
same host, including subprocess and queue costs; a cached pass is not a test
runtime measurement. A PR that touches packaging or real-system
behavior requires the applicable `verify:*` lanes; packaging and installation
changes also require `npm run verify:package`. The full-gate benchmark measures
that explicit lane, not the routine iteration requirement.
@@ -292,6 +308,7 @@ prove behavior on the verified Ghidra 12.1.4 and JDK 21 build.
| `npm run verify:ghidra` | Linux x64/arm64 ELF or macOS x64/arm64 Mach-O | Host C compiler, Ghidra 12.1.4, and full JDK 21 |
| `npm run verify:ghidra:seeds` | POSIX; raw x86 fixture; seed ordering, BSS labels, analyzer readback and unknown-name rejection | Ghidra 12.1.4 and full JDK 21 |
| `npm run verify:ghidra:swift` | macOS x64/arm64 Mach-O | Host Swift compiler, Ghidra 12.1.4, and full JDK 21 |
| `npm run verify:ghidra:riscv` | Linux/macOS x64/arm64 host; source-owned RV32/RV64 ELF through CLI and MCP | RISC-V Clang, LLD, LLVM readelf, Ghidra 12.1.4, and full JDK 21 |
| `npm run verify:ghidra:switch` | Linux x64 ELF; GCC/Clang optimized and stripped switch fixtures | GCC, Clang, GNU nm/objdump/strip, Ghidra 12.1.4, and full JDK 21 |
| `npm run verify:ghidra:aarch64-jump-table` | Any supported Ghidra host; AArch64 ELF; signed/unsigned byte/halfword tables; host ARM64 Mach-O | Clang with AArch64 target support, Ghidra 12.1.4, and full JDK 21 |
| `npm run verify:ghidra:cross-format` | Any supported Ghidra host; also analyzes AArch64 ELF, x86-64 PE, and x86-64 Mach-O | `clang`, LLD, and `lld-link` in addition to host-lane prerequisites |
@@ -684,7 +701,8 @@ not make native host acceptance unavailable.
Pull requests that change only root `README*.md` files, `docs/`, `AGENTS.md`,
`CONTRIBUTING.md`, `CHANGELOG.md`, `SECURITY.md`, `native/windows/README.md`,
or the GitHub issue and pull-request templates run formatting and
generated-document validation without the source-test shards or native package
generated-document validation, including the small onboarding example consistency
suite, without the source-test shards or native package
lanes. Classification compares the PR head with its merge base, so later
base-branch changes do not expand that scope. Static and coverage aggregate jobs
remain present and fail if classification fails. Every code change retains the
+6 -4
View File
@@ -48,10 +48,12 @@ satisfy. A plan without execution does not establish runtime behavior.
- Use strict inputs with explicit target identity and required fields. Preserve
meaningful target, action, capture, and output choices; remove ignored options
and repeated permission declarations.
- Return complete evidence inline, with artifact/provider identity, source
locations, Evidence references, and relevant confidence and limitations.
Missing coverage is unknown, not absence. Avoid extra lookups solely to
understand the answer or its provenance.
- Return the requested facts inline with artifact/provider identity, source
locations, Evidence references, coverage, and relevant confidence and
limitations. Use supported selection, summaries or views when they answer
the question; preserve access to complete source Evidence and honor existing
contracts and requested detail. Missing coverage is unknown, not absence.
Avoid extra lookups solely to understand the answer or its provenance.
- Keep observed, derived, and inferred relationships distinct. Preserve
unresolved edges and partial provider facts instead of requiring every
provider to supply the same metadata.
@@ -7,6 +7,10 @@ JSON measurements belong outside the checkout. This document keeps the measured
findings, verification scope and revision references; the scripts and `corpus.json`
keep the reproduction inputs. Generated `results/` is ignored.
Follow-up: [native PE/DOS entry views and real model verification](native-entry-views.md)
records the post-merge native continuation, including a small-function case where
four selected replies cost more bytes than the complete dossier.
The 12 detailed checkpoint receipts were archived byte-for-byte before removal
from the final PR tree. The archive `review-receipts-00d51e0fc-20261012` is in the
private experiment lab outside the checkout; its `archive-index.json` records
+15 -1
View File
@@ -30,6 +30,7 @@ const sameSettings = [
"provider_search_path_sha256",
"provider_environment_policy_sha256",
"measure_source_sha256",
"native_workflow_source_sha256",
"package_lock_sha256",
"node_version",
"sdk_client_version",
@@ -40,7 +41,9 @@ const sameSettings = [
const settingDifferences = sameSettings.filter(
(key) =>
!isDeepStrictEqual(left[key], right[key]) ||
(key === "provider_search_path_sha256" &&
((key === "provider_search_path_sha256" ||
(key === "native_workflow_source_sha256" &&
typeof left.native_question === "string")) &&
!/^[a-f0-9]{64}$/.test(left[key] ?? "")),
);
// Producer/view IDs identify different delivery observations; a named recovery
@@ -54,6 +57,17 @@ const facts = (answer) =>
const a = facts(leftAnswer);
const b = facts(rightAnswer);
const runSpecific = [];
for (const [side, answer] of [
["left", leftAnswer],
["right", rightAnswer],
]) {
if (Object.hasOwn(answer, "evidence_ids"))
runSpecific.push({
side,
path: ["evidence_ids"],
value: answer.evidence_ids,
});
}
// Binwalk assigns a new opaque region UUID in each invocation. Preserve both
// original lookup values in this report; compare the observed region facts
// separately. The product and original replies retain those IDs unchanged.
@@ -116,6 +116,36 @@
"derivation": "selected regular ZIP member ShareX.HelpersLib.dll; no target execution"
}
},
{
"id": "sharex.exe",
"file": "ShareX.exe",
"bytes": 198656,
"sha256": "46af1ccc514b395cb703633abb5a505a771942b7db9b1915f31aeea541b3685f",
"provenance": {
"project": "ShareX native executable component",
"version": "19.0.2",
"upstreamFile": "ShareX-19.0.2-portable.zip",
"url": "https://github.com/ShareX/ShareX/releases/download/v19.0.2/ShareX-19.0.2-portable.zip",
"transport_sha256": "967b5d838df5ce25fb5fd7cbd16f1bd797427e2df16907b022b74a4bc0fb9d54",
"transport_bytes": 168796487,
"derivation": "selected regular ZIP member ShareX.exe; PE32+ x86-64 with no CLR directory; no target execution"
}
},
{
"id": "freedos.edlin.exe",
"file": "EDLIN.EXE",
"bytes": 31492,
"sha256": "96b2b6d543e2d8bdc83e313756444e103339eb5ee2c1229abe3607539b31ad6e",
"provenance": {
"project": "FreeDOS EDLIN",
"version": "2.24",
"upstreamFile": "edlin.zip",
"url": "https://www.ibiblio.org/pub/micro/pc-stuff/freedos/files/repositories/1.4/base/edlin.zip",
"transport_sha256": "9db8a0b50bc4be5c2358cd0fa1fb6fc77342c6d5175a6b1084547c370e428f8a",
"transport_bytes": 377009,
"derivation": "selected regular ZIP member BIN/EDLIN.EXE; original UPX-compressed DOS MZ; APPINFO/EDLIN.LSM reports version; no execution or unpacking"
}
},
{
"id": "vscode.tree-sitter.wasm",
"file": "tree-sitter.wasm",
+32 -116
View File
@@ -1,16 +1,14 @@
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import {
mkdir,
open,
readFile,
readdir,
stat,
writeFile,
} from "node:fs/promises";
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { parseArgs } from "node:util";
import {
NATIVE_ARTIFACTS,
measureNativeEntry,
nativeEntrySeed,
} from "./native-entry.mjs";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { Ajv2020 } from "ajv/dist/2020.js";
@@ -38,10 +36,15 @@ if (
!isAbsolute(values.path) ||
!isAbsolute(values.output) ||
!["complete", "summary"].includes(values.delivery) ||
!["entry", "entry-and-main"].includes(values["native-question"]) ||
!["entry", "entry-and-main", "entry-views"].includes(
values["native-question"],
) ||
!["inventory", "selected"].includes(values["javascript-question"]) ||
!["default", "selected"].includes(values.schemas) ||
(values.artifact !== "jq" && values["native-question"] !== "entry") ||
(!NATIVE_ARTIFACTS.includes(values.artifact) &&
values["native-question"] !== "entry") ||
(values.artifact !== "jq" &&
values["native-question"] === "entry-and-main") ||
(values.artifact !== "obsidian.asar" &&
values["javascript-question"] !== "inventory")
)
@@ -118,6 +121,9 @@ await writeFile(
);
const fingerprints = {
measure_source_sha256: await fileSha(fileURLToPath(import.meta.url)),
native_workflow_source_sha256: await fileSha(
fileURLToPath(new URL("./native-entry.mjs", import.meta.url)),
),
runtime_files_sha256: sha(JSON.stringify(runtimeFiles)),
runtime_dispatcher_sha256: await fileSha(
resolve(values["runtime-root"], "scripts/rea.mjs"),
@@ -545,111 +551,19 @@ const workflows = {
evidence_ids: [info.evidence_id],
};
},
jq: async () => {
const file = await open(values.path, "r");
let header;
try {
header = (await file.read(Buffer.alloc(32), 0, 32, 0)).buffer;
} finally {
await file.close();
}
if (header.subarray(0, 6).toString("hex") !== "7f454c460201")
throw new Error("Expected ELF64 little endian entry seed");
const address = `0x${header.readBigUInt64LE(24).toString(16)}`;
await call("open_binary", { path: values.path, provider_id: "ghidra" });
try {
const containing = await call("resolve_containing_procedure", {
address,
});
const info = await call("analyze_function", { procedure: address });
const n = normalized(info);
// This exact application entry is materialized in the pinned jq entry's
// observed MOV RDI, 0x401f00 instruction, not an original-source claim.
let main;
const mainEvidenceIds = [];
if (values["native-question"] === "entry-and-main") {
let sourceId;
try {
const completed = await call("analyze_function", {
procedure: "0x401f00",
});
sourceId = completed.evidence_id;
} catch (error) {
const reference =
error instanceof ToolFailure &&
error.value?.error?.code === "resource_constraint"
? error.value.error.details?.reported_limits?.evidence_reference
: undefined;
if (reference?.kind !== "retained-evidence") throw error;
sourceId = reference.evidence_id;
}
mainEvidenceIds.push(sourceId);
main = {};
for (const facet of [
"procedure",
"pseudocode",
"referenced_strings",
"callees",
"callers",
"basic_blocks",
]) {
let offset = 0;
const items = [];
const pages = [];
do {
const selected = await call("inspect_analysis_view", {
source: { kind: "retained-evidence", evidence_id: sourceId },
view: {
kind: "native",
facet,
offset,
limit:
facet === "pseudocode"
? 65536
: facet === "basic_blocks"
? 1
: 64,
},
});
mainEvidenceIds.push(selected.evidence_id);
const v = normalized(selected);
items.push(v.item);
pages.push({
coverage: v.coverage,
limitations: v.limitations,
unknowns: v.unknowns,
});
if (v.coverage.exhausted || facet === "basic_blocks") break;
if (
v.coverage.next_offset === null ||
v.coverage.next_offset <= offset
)
throw new Error("Native view did not advance");
offset = v.coverage.next_offset;
} while (true);
main[facet] = { items, pages };
}
}
return {
artifact_sha256: targetSha(info),
entry_address: address,
containing_procedure: normalized(containing),
procedure: n.procedure,
pseudocode_prefix: n.pseudocode.slice(0, 512),
caller_count: n.callers.length,
callee_count: n.callees.length,
...(main === undefined ? {} : { main }),
analysis_profile: info.analysis_profile,
evidence_ids: [
containing.evidence_id,
info.evidence_id,
...mainEvidenceIds,
],
};
} finally {
await call("close_binary", {});
}
},
...Object.fromEntries(
NATIVE_ARTIFACTS.map((artifact) => [
artifact,
async () =>
measureNativeEntry({
seed: await nativeEntrySeed(artifact, values.path),
values,
call,
inputSha,
ToolFailure,
}),
]),
),
};
try {
@@ -740,7 +654,9 @@ const report = {
artifact_sha256_after: finalInputSha,
delivery: values.delivery,
sdk_schema_mode: values.schemas,
native_question: values.artifact === "jq" ? values["native-question"] : null,
native_question: NATIVE_ARTIFACTS.includes(values.artifact)
? values["native-question"]
: null,
javascript_question:
values.artifact === "obsidian.asar" ? values["javascript-question"] : null,
provider_settings_sha256: sha(JSON.stringify(selectedProviders)),
@@ -0,0 +1,178 @@
# Native PE and DOS entry-view checkpoint
Continuation of [#2016](https://github.com/morluto/rea/issues/2016) after
#2122 and #2193 merged. Real Ghidra observations and real Codex CLI answers
support the selected native facets on these shipped inputs. They do not justify
an automatic native summary policy: the four selected replies are larger than
the complete dossier for the small PE entry, and smaller for the DOS entry.
The initial complete analysis still runs and is delivered in both cases.
This checkpoint changes experiment scripts, corpus inputs and grading only.
Existing production CLI/MCP contracts, defaults and session behavior are unchanged.
## Inputs and runtime
Production runtime: main `10400fdb57e4226c5ce15af17968a664f35498ce`,
including the merged WASM summary work and subsequent main changes. Node
24.18.0/npm 11.16.0, locked dependencies, actual Ghidra 12.1.4 on Linux.
Default auto-analysis remains enabled, with a 768 MiB JVM ceiling and one
active processor. The whole process family uses the existing one-CPU,
4 GiB hard/3 GiB early-stop, no-swap, bounded-task resource scope.
| Shipped artifact | Selected bytes | SHA-256 | Static scope |
| ---------------------------------- | -------------: | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
| ShareX 19.0.2 `ShareX.exe` | 198,656 | `46af1ccc514b395cb703633abb5a505a771942b7db9b1915f31aeea541b3685f` | Native PE32+ x86-64 component with no CLR directory; entry `0x1400125c0` derived from original image base/RVA |
| FreeDOS EDLIN 2.24 `BIN/EDLIN.EXE` | 31,492 | `96b2b6d543e2d8bdc83e313756444e103339eb5ee2c1229abe3607539b31ad6e` | Original UPX-compressed DOS MZ; admitted entry `0x10000`, independently verified static load image |
Acquisition URLs, archive sizes/digests and exact regular ZIP members are in
[corpus.json](corpus.json). EDLIN's package `APPINFO/EDLIN.LSM` reports its
version and UPX compression. No binary was executed, unpacked or committed.
The independent PE header does not establish Windows runtime behavior.
DOS load-image verification and unpacking-stub pseudocode do not recover the
editor's application logic or establish DOS execution. DOS COM is still untested.
The SDK and frozen model runtime share the exact `dist/` and `bridge/` file
table digest:
`e2b67eb0966072fd8b159edfab074cbaf1db9bfc0ac9c57d25ba1435cc0fdbcf`.
The model copy was independently checked against all 1,375 compiled files and
30 bridge files. Runtime copies and detailed receipts remain outside Git.
## Public SDK observations
The workflow opens the selected target with Ghidra, resolves the entry,
calls `analyze_function` once, reads retained procedure/pseudocode/callers/callees
facets, and closes the ephemeral session. DOS obtains its address from
`inspect_native_load_image` before resolving the procedure. Each facet is checked
against the complete producer facts, original parent ID, target digest and distinct
derived Evidence ID. The actual SDK validates named advertised output contracts.
| Entry | Complete dossier JSON bytes | Four facet replies combined | Workflow seconds | First provider query seconds | Last-observed family CPU seconds | Sampled family peak MiB |
| ------------ | --------------------------: | --------------------------: | ---------------: | ---------------------------: | -------------------------------: | ----------------------: |
| ShareX PE | 50,936 | 68,257 | 63.123 | 57.490 | 63.660 | 1,197.85 |
| EDLIN DOS MZ | 224,670 | 70,539 | 26.436 | 21.366 | 26.899 | 939.25 |
These are exploratory observations, not a paired implementation performance
cohort. JSON byte counts include the SDK tool-result representations, excluding
catalog discovery and wire framing; they are not model token counts. Four PE
facets cost about 34% more bytes than that dossier. The DOS facets cost about
69% fewer bytes **when reading already-retained Evidence**. Their first analysis
still returned the full dossier; summing both paths increases first-task traffic.
The first provider query includes the observable wait for the imported Program
and its analysis, not an independently measured engine-only stage.
The observed PE entry contains 18 analyzed bytes, zero recorded callers and two
resolved callees. Ghidra's terminal-call caveat remains explicit. The DOS entry
contains 53 analyzed bytes and zero recorded callers/callees; overlapping
instruction and abnormal stack-frame warnings remain in the pseudocode.
Neither zero count establishes absence of unresolved runtime calls.
An earlier PE probe completed production analysis and two facets, then failed
its harness assertion: a pseudocode page is `{text, unit}`, not a string. That
failed attempt remains archived and is excluded from successful workflow figures.
The corrected exploratory SDK driver fingerprints are
`4876c0ecaa84069ddc508e29649715c6c914bd3c4960a76fa9724b5992123699`
and native-helper `76023ad45cd1edab57dcd4a8ccf29a9b8a8378029ff2d9711ae35c73940e80d6`.
The published helper in `6d7f6885c` removes redundant scopes; replaying the actual
PE/DOS replies reproduces every answer field and the same 9/10 calls. Recorded
real jq entry and entry/main replies also preserve their selected facts, profile
and 4/12-call ordering. These replays are driver regression checks, not fresh
provider runs or timing measurements.
## Real Codex CLI observations
Three serial runs used Codex CLI 0.161.0, requested alias `gpt-5.6-terra`, high
reasoning, temporary isolated profiles and the frozen modified REA MCP runtime.
Resolved server-side model identity is not inferred. The same explicit seven-tool
subset was enabled: open, containing-procedure resolution, dossier, analysis view,
load image, retained Evidence discovery and close. This is not full-catalog tool
selection verification. Questions are [PE](questions/native-pe.txt) and
[DOS](questions/native-dos.txt).
| Trial | Total input | Cached input | Uncached input | Output | Seconds | Calls / failed |
| ----- | ----------: | -----------: | -------------: | -----: | ------: | -------------: |
| PE 1 | 451,337 | 399,616 | 51,721 | 3,820 | 130.073 | 12 / 2 |
| DOS 1 | 730,578 | 658,944 | 71,634 | 3,310 | 134.435 | 9 / 0 |
| PE 2 | 498,322 | 429,056 | 69,266 | 3,038 | 192.373 | 9 / 1 |
All three answers match the selected original digest, address, procedure
classification/signature/locals, inclusive body ranges, body byte count,
pseudocode prefix and relationship counts. Actual Evidence references were
observed in the responses; target bytes remained unchanged, close completed and
the copied profiles were removed. DOS additionally matches the verified load
status and every supplied load-image metadata field. Manual review preserves
static/packed-target and terminal-flow qualifications.
The first grader expected only particular nested JSON shapes. It rejected PE's
equivalent text-page object/inclusive-range label and could not grade DOS's
explicit `entry @ 0x10000` display. The updated grader compares exact source
values for those representations without parsing displays into lookup identities.
It still rejects wrong text units, returned lengths, body endpoints and missing
facts. Original answers and first grading attempts remain archived; regrading
does not repeat model calls or change their tokens/times.
The PE agent hit the advertised-but-unimplemented native summary recipe: both
trials attempted `view: {kind: "summary"}`; the first also tried the invalid
native facet `summary`. Those failed calls and all subsequent recovery/repeated
reads remain included. The public SDK independently reproduced `invalid_request`
for native summary on both inputs. This pre-existing contract-description mismatch
is recorded in `/tmp/rea-analysis-efficiency-bugs-20261012.md` for team repair,
as requested; this branch does not repair it.
Model sampled peaks were 1,318.74 / 1,017.70 / 1,234.92 MiB, with last-observed
family CPU 72.956 / 71.534 / 74.089 seconds. SDK/model scopes did not abort and
their last-observed limit-event counters were zero. Final cgroup counters remained
unavailable after scope teardown: the strict resource-qualified overall grades
remain unverified. The selected fact/lifecycle checks pass; no universal latency,
OOM absence or CPU/memory saving is inferred from these samples.
## Reproduction and next decision
Use the pinned toolchain and build the selected production revision. Keep its
runtime copy, matching dependencies, provider settings, binaries and receipts
outside the checkout, as described in [README.md](README.md).
```sh
python3 experiments/analysis-efficiency/resource-run.py -- \
node experiments/analysis-efficiency/measure.mjs \
--runtime-root /absolute/frozen-runtime --revision EXACT_PRODUCTION_SHA \
--artifact sharex.exe --path /absolute/ShareX.exe \
--providers /absolute/explicit-providers.json \
--native-question entry-views --schemas selected \
--output /absolute/new-native-measurement
```
For DOS select `--artifact freedos.edlin.exe` and the original extracted
`EDLIN.EXE`. `--native-question entry` retains the existing compact experimental
answer; `entry-views` performs additional verification reads and the explicit
summary refusal probe. It does not select summary delivery from `analyze_function`.
The helper fingerprint participates in comparison, and missing native helper
fingerprints are not treated as a comparable current driver.
The reviewed `entry-views` answer separates selected facet facts from run-specific
references: `evidence_ids.retained_views` retains each facet's original view and
parent ID, while `retained_views` holds its item, coverage, limitations and unknowns.
DOS load-image facts retain their source ID and must match the selected carrier
digest before their entry address can be used; a stale result closes the session
without querying a procedure.
Derived view IDs are checked against every prior producer reference as well as
other derived views. DOS model grades require the actual completed load-image
response and its reported source ID; status, digest, entry and supplied metadata
are checked against that observation. Missing, failed or unreferenced responses
cannot establish load-image verification.
The comparator preserves both runs' reference maps and compares the facts. Existing
`entry`/`entry-and-main` answers keep their ID arrays. Historical SDK receipts above
retain their original pre-review representation; no replies or measurements were
rewritten, and no provider/model runs were repeated for this representation fix.
Relevant validation includes the real workflows above, recorded-reply regression,
grader negative probes, JavaScript/Python syntax, formatting/diff checks and
`npm run check:fast`. The cold type/lint gate passed in 112.602 seconds with
sampled peak 3,220,914,176 bytes and five last-observed `memory.high` events;
this gate is not a performance-qualified experiment. Unrelated domain suites,
packaging and earlier model cohorts were not rerun for this experiment-only change.
The next useful comparison is question-specific native primitives versus a
complete dossier, with identical requested facts and actual model trials.
Larger PE functions and another unpacked DOS/COM application can extend the
coverage progressively. Preserve existing complete defaults and solve the
summary-description bug separately before recommending a native summary route.
@@ -0,0 +1,272 @@
import { open, readFile } from "node:fs/promises";
import { isDeepStrictEqual } from "node:util";
export const NATIVE_ARTIFACTS = ["jq", "sharex.exe", "freedos.edlin.exe"];
const normalized = (value) => value.normalized_result;
const retainedEntryViews = async (
call,
info,
inputSha,
ToolFailure,
sourceEvidenceIds,
) => {
const n = normalized(info);
const retainedViews = [];
const retainedViewEvidence = [];
const observedEvidenceIds = new Set([info.evidence_id, ...sourceEvidenceIds]);
let nativeSummaryOutcome, summaryEvidenceId;
for (const facet of ["procedure", "pseudocode", "callers", "callees"]) {
const selected = await call("inspect_analysis_view", {
source: { kind: "retained-evidence", evidence_id: info.evidence_id },
view: {
kind: "native",
facet,
offset: 0,
limit: facet === "pseudocode" ? 512 : 64,
},
});
const view = normalized(selected);
if (observedEvidenceIds.has(selected.evidence_id))
throw new Error("Retained native view reused an Evidence identity");
const expected =
facet === "procedure"
? n.procedure
: facet === "pseudocode"
? { text: n.pseudocode.slice(0, 512), unit: "utf16-code-units" }
: n[facet].slice(0, 64);
if (
!isDeepStrictEqual(view.item, expected) ||
view.parent_evidence_id !== info.evidence_id ||
view.artifact.sha256 !== inputSha
)
throw new Error(
"Retained native view changed the selected source facts or identity",
);
observedEvidenceIds.add(selected.evidence_id);
retainedViews.push({
facet,
item: view.item,
coverage: view.coverage,
limitations: view.limitations,
unknowns: view.unknowns,
});
retainedViewEvidence.push({
facet,
evidence_id: selected.evidence_id,
parent_evidence_id: view.parent_evidence_id,
});
}
// Exercise the advertised summary recipe separately. A known refusal
// is recorded as such; it does not validate a nonexistent capability.
try {
const summary = await call("inspect_analysis_view", {
source: { kind: "retained-evidence", evidence_id: info.evidence_id },
view: { kind: "summary" },
});
if (observedEvidenceIds.has(summary.evidence_id))
throw new Error("Native summary reused an Evidence identity");
nativeSummaryOutcome = { status: "supported" };
summaryEvidenceId = summary.evidence_id;
} catch (error) {
if (
!(error instanceof ToolFailure) ||
error.value?.error?.code !== "invalid_request"
)
throw error;
nativeSummaryOutcome = {
status: "unsupported",
error: error.value.error,
};
}
return {
retainedViews,
retainedViewEvidence,
nativeSummaryOutcome,
summaryEvidenceId,
};
};
const retainedJqMain = async (call, ToolFailure) => {
// This exact application entry is materialized in the pinned jq entry's
// observed MOV RDI, 0x401f00 instruction, not an original-source claim.
let main;
const mainEvidenceIds = [];
let sourceId;
try {
const completed = await call("analyze_function", {
procedure: "0x401f00",
});
sourceId = completed.evidence_id;
} catch (error) {
const reference =
error instanceof ToolFailure &&
error.value?.error?.code === "resource_constraint"
? error.value.error.details?.reported_limits?.evidence_reference
: undefined;
if (reference?.kind !== "retained-evidence") throw error;
sourceId = reference.evidence_id;
}
mainEvidenceIds.push(sourceId);
main = {};
for (const facet of [
"procedure",
"pseudocode",
"referenced_strings",
"callees",
"callers",
"basic_blocks",
]) {
let offset = 0;
const items = [];
const pages = [];
do {
const selected = await call("inspect_analysis_view", {
source: { kind: "retained-evidence", evidence_id: sourceId },
view: {
kind: "native",
facet,
offset,
limit:
facet === "pseudocode" ? 65536 : facet === "basic_blocks" ? 1 : 64,
},
});
mainEvidenceIds.push(selected.evidence_id);
const v = normalized(selected);
items.push(v.item);
pages.push({
coverage: v.coverage,
limitations: v.limitations,
unknowns: v.unknowns,
});
if (v.coverage.exhausted || facet === "basic_blocks") break;
if (v.coverage.next_offset === null || v.coverage.next_offset <= offset)
throw new Error("Native view did not advance");
offset = v.coverage.next_offset;
} while (true);
main[facet] = { items, pages };
}
return { main, mainEvidenceIds };
};
export const measureNativeEntry = async ({
seed,
values,
call,
inputSha,
ToolFailure,
}) => {
await call("open_binary", { path: values.path, provider_id: "ghidra" });
try {
// DOS addresses follow the actual admitted load image. A file header
// offset is not a provider address or proof of runtime execution.
let loadImage, loadImageEvidenceId;
if (seed === undefined) {
const imageEvidence = await call("inspect_native_load_image", {});
loadImage = normalized(imageEvidence);
if (loadImage.status !== "verified")
throw new Error(
"The shipped DOS load image was not independently verified",
);
if (loadImage.target_sha256 !== inputSha)
throw new Error("The DOS load image belongs to a different target");
loadImageEvidenceId = imageEvidence.evidence_id;
seed = loadImage.entry.linear_address;
}
const address = seed;
const containing = await call("resolve_containing_procedure", {
address,
});
const info = await call("analyze_function", { procedure: address });
const n = normalized(info);
const selected =
values["native-question"] === "entry-views"
? await retainedEntryViews(call, info, inputSha, ToolFailure, [
containing.evidence_id,
...(loadImageEvidenceId === undefined ? [] : [loadImageEvidenceId]),
])
: {};
const { main, mainEvidenceIds = [] } =
values["native-question"] === "entry-and-main"
? await retainedJqMain(call, ToolFailure)
: {};
return {
artifact_sha256: info.subject?.digest.sha256,
entry_address: address,
containing_procedure: normalized(containing),
procedure: n.procedure,
pseudocode_prefix: n.pseudocode.slice(0, 512),
caller_count: n.callers.length,
callee_count: n.callees.length,
...(main === undefined ? {} : { main }),
analysis_profile: info.analysis_profile,
...(loadImage === undefined ? {} : { load_image: loadImage }),
...(selected.retainedViews === undefined
? {}
: {
retained_views: selected.retainedViews,
native_summary_outcome: selected.nativeSummaryOutcome,
}),
evidence_ids:
selected.retainedViews === undefined
? [
containing.evidence_id,
info.evidence_id,
...mainEvidenceIds,
...(loadImageEvidenceId === undefined
? []
: [loadImageEvidenceId]),
]
: {
containing_procedure: containing.evidence_id,
function_dossier: info.evidence_id,
...(loadImageEvidenceId === undefined
? {}
: { native_load_image: loadImageEvidenceId }),
retained_views: selected.retainedViewEvidence,
...(selected.summaryEvidenceId === undefined
? {}
: { native_summary: selected.summaryEvidenceId }),
},
};
} finally {
await call("close_binary", {});
}
};
export const nativeEntrySeed = async (artifact, path) => {
if (artifact === "jq") {
const file = await open(path, "r");
let header;
try {
header = (await file.read(Buffer.alloc(32), 0, 32, 0)).buffer;
} finally {
await file.close();
}
if (header.subarray(0, 6).toString("hex") !== "7f454c460201")
throw new Error("Expected ELF64 little endian entry seed");
return `0x${header.readBigUInt64LE(24).toString(16)}`;
}
if (artifact === "sharex.exe") {
const bytes = await readFile(path);
const pe = bytes.readUInt32LE(0x3c);
const optional = pe + 24;
if (
bytes.subarray(pe, pe + 4).toString("hex") !== "50450000" ||
bytes.readUInt16LE(pe + 4) !== 0x8664 ||
bytes.readUInt16LE(optional) !== 0x20b ||
bytes.readUInt32LE(optional + 108) <= 14 ||
bytes.readUInt32LE(optional + 112 + 14 * 8) !== 0 ||
bytes.readUInt32LE(optional + 116 + 14 * 8) !== 0
)
throw new Error(
"Expected the pinned native PE32+ entry seed without CLR metadata",
);
const address =
bytes.readBigUInt64LE(optional + 24) +
BigInt(bytes.readUInt32LE(optional + 16));
return `0x${address.toString(16)}`;
}
if (artifact === "freedos.edlin.exe") return undefined;
throw new Error("Select a pinned native artifact");
};
@@ -0,0 +1 @@
Statically inspect {{artifact_path}} using only the registered REA tools and Ghidra. This is the original UPX-compressed FreeDOS EDLIN 2.24 DOS MZ executable; inspect its admitted load image and entry stub, without running or unpacking it. Keep default auto-analysis enabled. Open the target with provider_id ghidra, call inspect_native_load_image, and use its verified entry.linear_address as the seed. Resolve the containing procedure, analyze that procedure once, and close the ephemeral session after obtaining the facts. If a result is clipped, use retained native facets without reanalysis. Report exact target SHA-256, entry_address, containing_procedure, complete procedure metadata including classification, signature, locals and inclusive body ranges, the first 512 UTF-16 units of pseudocode, caller_count, callee_count, real Evidence IDs and limitations. Preserve missing/partial facts and terminal-flow caveats. Verified static import and unpacking-stub pseudocode do not prove DOS execution or recovery of the editor's application logic. Do not execute targets, launch a GUI, or use shell/file tools/network or other agents. Final JSON fields: artifact_sha256, load_image_status, entry_address, containing_procedure, procedure, pseudocode_prefix, caller_count, callee_count, evidence_ids, limitations. Stop when these facts and cleanup are supported; report unsupported outcomes explicitly.
@@ -0,0 +1 @@
Statically inspect {{artifact_path}} using only the registered REA tools and Ghidra. The independently read, pinned PE32+ header reports image base 0x140000000 and entry RVA 0x125c0, so use address 0x1400125c0 as the analyst seed. This is a native x86-64 Windows PE component with no CLR directory, inspected on Linux; do not claim Windows runtime behavior. Keep default auto-analysis enabled. Open the target with provider_id ghidra, resolve the containing procedure, analyze that procedure once, and close the ephemeral session after obtaining the facts. If a result is clipped, use retained native facets without reanalysis. Report exact target SHA-256, entry_address, containing_procedure, complete procedure metadata including classification, signature, locals and inclusive body ranges, the first 512 UTF-16 units of pseudocode, caller_count, callee_count, real Evidence IDs and limitations. Preserve terminal-flow caveats and unresolved static edges; recovered pseudocode is not original source. Do not execute targets, launch a GUI, or use shell/file tools/network or other agents. Final JSON fields: artifact_sha256, entry_address, containing_procedure, procedure, pseudocode_prefix, caller_count, callee_count, evidence_ids, limitations. The seed applies only to the pinned ShareX.exe in corpus.json. Stop when these facts and cleanup are supported.
@@ -182,18 +182,61 @@ def main():
} for row in rows], key=lambda row: row['node_id'])
check('all_selected_module_identities', select(answer.get('modules', [])), select(reference['modules']))
elif domain == 'native':
for key in ['entry_address', 'pseudocode_prefix', 'caller_count', 'callee_count']:
for key in ['entry_address', 'caller_count', 'callee_count']:
check(key, answer.get(key), reference[key])
containing = answer['containing_procedure']
prefix = answer.get('pseudocode_prefix')
if isinstance(prefix, dict) and prefix.get('unit') == 'utf16-code-units':
text = prefix.get('text')
if 'requested' in prefix:
check('pseudocode_prefix.requested', prefix['requested'], 512)
if 'returned' in prefix:
check('pseudocode_prefix.returned', prefix['returned'], len(reference['pseudocode_prefix'].encode('utf-16-le')) // 2)
prefix = text
check('pseudocode_prefix', prefix, reference['pseudocode_prefix'])
containing = answer.get('containing_procedure')
expected_containing = reference['containing_procedure']['procedure']
for key in ['address', 'name']:
check('containing_procedure.' + key, containing.get(key, containing.get('procedure', {}).get(key)), expected_containing[key])
procedure = answer['procedure']
if isinstance(containing, str):
# Compare the explicitly reported display to the known source;
# never parse a display name into a canonical lookup identity.
check('containing_procedure.display', containing,
expected_containing['name'] + ' @ ' + expected_containing['address'])
else:
containing = containing if isinstance(containing, dict) else {}
nested = containing.get('procedure')
nested = nested if isinstance(nested, dict) else {}
for key in ['address', 'name']:
check('containing_procedure.' + key, containing.get(key, nested.get(key)), expected_containing[key])
procedure = answer.get('procedure')
procedure = procedure if isinstance(procedure, dict) else {}
expected = reference['procedure']
for key in ['address', 'name', 'classification', 'signature', 'locals']:
check('procedure.' + key, procedure.get(key), expected[key])
check('procedure.body_ranges', procedure.get('body_ranges', procedure.get('body', {}).get('ranges')), expected['body']['ranges'])
check('procedure.total_bytes', procedure.get('total_bytes', procedure.get('body', {}).get('total_bytes')), expected['body']['total_bytes'])
body = procedure.get('body')
body = body if isinstance(body, dict) else {}
check('procedure.body_ranges', procedure.get('body_ranges', body.get('ranges', body.get('ranges_inclusive'))), expected['body']['ranges'])
check('procedure.total_bytes', procedure.get('total_bytes', body.get('total_bytes')), expected['body']['total_bytes'])
if 'load_image' in reference:
sources = [source for source in producer_results.get('inspect_native_load_image', [])
if isinstance(source, dict)
and source.get('operation') == 'inspect_native_load_image'
and isinstance(source.get('evidence_id'), str)
and source.get('evidence_id') in reported_ids
and isinstance(source.get('normalized_result'), dict)]
checks['load_image_source_observed_and_referenced'] = bool(sources)
load = sources[-1]['normalized_result'] if sources else {}
entry = load.get('entry')
entry = entry if isinstance(entry, dict) else {}
check('load_image.target_sha256', load.get('target_sha256'), reference['artifact_sha256'])
check('load_image.source_status', load.get('status'), reference['load_image']['status'])
check('load_image.source_entry', entry.get('linear_address'), reference['entry_address'])
status = answer.get('load_image_status')
check('load_image_status', status.get('status') if isinstance(status, dict) else status, load.get('status'))
if isinstance(status, dict):
for key in ['format', 'load_segment', 'header_bytes', 'module_bytes', 'overlay_bytes']:
if key in status:
check('load_image.' + key, status[key], load.get(key))
if 'entry_linear_address' in status:
check('load_image.entry_linear_address', status['entry_linear_address'], entry.get('linear_address'))
calls = summary['completed_mcp_calls']
checks['default_analysis_not_disabled'] = all(not call['arguments'].get('skip_auto_analysis', False) for call in calls if call['tool'] == 'open_binary')
checks['session_close_completed'] = any(call['tool'] == 'close_binary' and call['status'] == 'completed' for call in calls)
@@ -248,6 +291,7 @@ def main():
and events['memory.events'].get('high') == 0
)
report = {
'review_source_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
'domain': domain, 'checks': checks, 'all_selected_checks_pass': all(checks.values()),
'differences': differences, 'tool_errors': tool_errors, 'summary': summary,
'fingerprints': {key: value for key, value in settings.items() if 'sha256' in key or key in ['runtime_revision', 'enabled_tools', 'authority']},
+22 -14
View File
@@ -25,6 +25,14 @@ fail() {
exit 1
}
# Bash can return status 0 while parsing an incomplete [[ condition. Keep that
# grammar in a constant quoted expression so truncated downloads fail while
# parsing the outer brace group. eval parses only this literal; the arguments
# are expanded as data by the conditional, not interpolated into shell code.
matches() {
eval '[[ "$1" =~ $2 ]]'
}
# Resolve the current host's Node.js tarball label pair; a label we cannot map
# is kept verbatim so the operator can still read it. Falls back on the raw
# `uname` outputs rather than inventing a label the Node.js release index does
@@ -95,41 +103,41 @@ main() {
esac
done
if [[ "$verbose" == true ]]; then
if [ "$verbose" = true ]; then
set -x
fi
platform="$(uname -s)"
[[ "$platform" == "Darwin" || "$platform" == "Linux" ]] || fail "REA supports macOS and selected 64-bit Linux distributions."
[ "$platform" = "Darwin" ] || [ "$platform" = "Linux" ] || fail "REA supports macOS and selected 64-bit Linux distributions."
command -v curl >/dev/null 2>&1 || fail "curl is required. Install curl, then rerun this installer."
command -v node >/dev/null 2>&1 || fail "Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+ is required; install it from https://nodejs.org and retry."
command -v npm >/dev/null 2>&1 || fail "npm is required; install it with Node.js and retry."
node_version="$(node -p 'process.versions.node' 2>/dev/null || true)"
IFS=. read -r node_major node_minor _node_patch <<<"$node_version"
[[ "$node_major" =~ ^[0-9]+$ && "$node_minor" =~ ^[0-9]+$ ]] || fail "the active Node.js version could not be read. Check that node works and is on PATH, then retry."
if ! [[ "$_node_patch" =~ ^[0-9]+(\+[0-9A-Za-z.-]+)?$ ]] ||
matches "$node_major" '^[0-9]+$' && matches "$node_minor" '^[0-9]+$' || fail "the active Node.js version could not be read. Check that node works and is on PATH, then retry."
if ! matches "$_node_patch" '^[0-9]+(\+[0-9A-Za-z.-]+)?$' ||
! ((node_major == 22 && node_minor >= 19 || node_major == 24 && node_minor >= 11 || node_major >= 26)); then
fail_node_version "$node_version"
fi
if [[ -n "$version" ]]; then
[[ "$version" =~ ^${version_pattern}$ ]] || fail "version must be an exact semantic version."
if [ -n "$version" ]; then
matches "$version" "^${version_pattern}$" || fail "version must be an exact semantic version."
else
release_json="$(curl -fsSL --proto '=https' --tlsv1.2 --retry 3 -H 'Accept: application/vnd.github+json' "https://api.github.com/repos/$REPOSITORY/releases/latest")" || fail "the latest REA release could not be resolved. Check network access or pass --version VERSION, then retry."
tag="$(printf '%s' "$release_json" | node -e 'let s="";process.stdin.on("data",c=>s+=c).on("end",()=>{const v=JSON.parse(s).tag_name;if(typeof v!=="string")process.exit(1);process.stdout.write(v)})' 2>/dev/null)" || fail "the release response was invalid. Retry later or pass --version VERSION."
[[ "$tag" =~ ^rea-agents-(${version_pattern})$ ]] || fail "the latest release tag was invalid. Retry later or pass --version VERSION."
matches "$tag" "^rea-agents-(${version_pattern})$" || fail "the latest release tag was invalid. Retry later or pass --version VERSION."
version="${BASH_REMATCH[1]}"
fi
# npm's exact-version resolver cleans build metadata from the requested version,
# so it may install the base release and make the post-install identity check fail.
if [[ "$version" =~ \+ ]]; then
if matches "$version" '\+'; then
fail "npm cannot install an exact version with build metadata. Pass a version without build metadata, then retry."
fi
install_args=(install --global)
if [[ "$platform" == "Linux" ]]; then
if [ "$platform" = "Linux" ]; then
install_args+=(--prefix "$HOME/.local")
install_bin="$HOME/.local/bin/rea"
else
@@ -145,21 +153,21 @@ main() {
printf ' %q' npm "${install_args[@]}"
printf '\n'
printf ' Binary: %s\n' "$install_bin"
printf ' Setup: %s\n' "$([[ "$start_setup" == true ]] && printf 'start when a terminal is available' || printf 'skipped')"
printf ' Setup: %s\n' "$([ "$start_setup" = true ] && printf 'start when a terminal is available' || printf 'skipped')"
if [[ "$dry_run" == true ]]; then
if [ "$dry_run" = true ]; then
printf 'Dry run complete; no changes made.\n'
exit 0
fi
printf 'Installing %s@%s...\n' "$PACKAGE" "$version"
npm "${install_args[@]}" || fail "npm could not install REA. Check registry access and npm permissions, then retry."
[[ -x "$install_bin" ]] || fail "npm completed without installing the rea command. Check the npm global bin directory and PATH, then retry."
[ -x "$install_bin" ] || fail "npm completed without installing the rea command. Check the npm global bin directory and PATH, then retry."
installed_version="$("$install_bin" --version 2>/dev/null | tr -d '[:space:]')" || fail "the installed REA version could not be read. Reinstall the requested version, then retry."
[[ "$installed_version" == "$version" ]] || fail "installed version $installed_version does not match $version. Reinstall the requested version, then retry."
[ "$installed_version" = "$version" ] || fail "installed version $installed_version does not match $version. Reinstall the requested version, then retry."
printf 'REA %s is installed.\n' "$version"
if [[ "$start_setup" == true && -r /dev/tty && -w /dev/tty && -t 1 ]]; then
if [ "$start_setup" = true ] && [ -r /dev/tty ] && [ -w /dev/tty ] && [ -t 1 ]; then
printf 'Starting REA setup...\n'
"$install_bin" setup </dev/tty >/dev/tty
else
+4 -2
View File
@@ -110,7 +110,7 @@
"precheck:test": "npm run deps:check",
"check:test": "node scripts/run-exclusive.mjs artifacts turbo run typecheck lint:built format:check knip metadata:check test:run",
"precheck:pr": "npm run deps:check",
"check:pr": "node scripts/run-exclusive.mjs artifacts turbo run typecheck lint:built format:check knip metadata:check test:run && npm run docs:check && npm run jscpd && npm run scan:todos",
"check:pr": "node scripts/run-exclusive.mjs artifacts turbo run typecheck lint:built format:check knip metadata:check test:run && npm run test:python && npm run docs:check && npm run jscpd && npm run scan:todos",
"precheck:ci": "npm run deps:check",
"check:ci": "node scripts/run-exclusive.mjs artifacts turbo run typecheck lint:built format:check knip metadata:check jscpd scan:todos && npm run verify:architecture-guards",
"build:cached": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run artifacts:skills",
@@ -128,6 +128,7 @@
"test": "npm run test:with-build",
"test:with-build": "npm run test:prepare && vitest run",
"test:run": "vitest run",
"test:python": "python3 -m unittest bridge/native/test_rea_lldb_tracer.py -v",
"test:local": "node scripts/run-development-tests.mjs local",
"test:fast": "vitest run --project domain --project services --project adapters --project composition --project conformance --project evaluation",
"pretest:boundary": "npm run test:prepare",
@@ -169,6 +170,7 @@
"verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs",
"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: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",
@@ -231,7 +233,7 @@
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs",
"docs:generate": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts npm run docs:prepare",
"docs:check": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run docs:check:built",
"docs:check": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run docs:check:built && npm run test:local -- tests/conformance/readmeConsistency.test.ts",
"docs:check:built": "node scripts/generate-package-metadata.mjs --check && node scripts/generate-skill-metadata.mjs --check && node scripts/generate-product-catalog.mjs --check && node scripts/generate-completion-ledger.mjs --check && node scripts/check-doc-facts.mjs && node scripts/generate-error-schema.mjs --check",
"docs:error-schema": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run artifacts:error-schema",
"prepack": "node scripts/prepack.mjs",
+1
View File
@@ -64,6 +64,7 @@ export const SCOPES = {
"tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts",
"tests/conformance/providers/ghidra/windowsCapabilities.test.ts",
"src/ghidra/GhidraInstallation.test.ts",
"tests/boundary/filesystem/ghidraJavaProbe.test.ts",
"tests/boundary/providers/ghidra/ghidraTransport.test.ts",
"tests/boundary/providers/ghidra/ghidraTargetSnapshot.test.ts",
"tests/boundary/providers/ghidra/ghidraLauncher.test.ts",
+73
View File
@@ -0,0 +1,73 @@
import { appendFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { isAbsolute, join, relative, resolve, sep } from "node:path";
import { z } from "zod";
const root = process.cwd();
const historyDirectory = join(root, ".cache/ci-test-history");
const historyPath = join(historyDirectory, "test-results.json");
// Never let a failed or partial rerun leave a previous successful history file.
await rm(historyPath, { force: true });
const report = z
.object({
success: z.boolean(),
numFailedTests: z.number().int().nonnegative(),
testResults: z.array(
z.object({
name: z.string().min(1),
startTime: z.number().finite(),
endTime: z.number().finite(),
}),
),
})
.parse(JSON.parse(await readFile("test-results.json", "utf8")));
const files = report.testResults.map(({ name, startTime, endTime }) => {
const path = relative(root, resolve(root, name));
if (
path === "" ||
path === ".." ||
path.startsWith(".." + sep) ||
isAbsolute(path)
)
throw new Error(`Test report path is outside the checkout: ${name}`);
const duration = endTime - startTime;
if (!Number.isFinite(duration) || duration < 0)
throw new Error(`Test report has an invalid duration: ${name}`);
return { name: path.split(sep).join("/"), startTime: 0, endTime: duration };
});
const complete = process.env.SHARD_RESULT === "success";
const passed =
complete &&
process.env.MERGE_RESULT === "success" &&
report.success &&
report.numFailedTests === 0;
const summary = [
complete ? "## Merged test diagnostics" : "## Partial test diagnostics",
"",
`Shard result: ${process.env.SHARD_RESULT ?? "unknown"}; merge result: ${process.env.MERGE_RESULT ?? "unknown"}.`,
passed
? "Successful timing history is available."
: "This report is diagnostic only and is not a successful timing baseline.",
"",
"| Slowest file | Duration (ms) |",
"| --- | ---: |",
...files
.toSorted((left, right) => right.endTime - left.endTime)
.slice(0, 20)
.map(
({ name, endTime }) => `| ${name.replaceAll("|", "\\|")} | ${endTime} |`,
),
"",
].join("\n");
if (process.env.GITHUB_STEP_SUMMARY)
await appendFile(process.env.GITHUB_STEP_SUMMARY, summary);
else console.log(summary);
if (passed) {
await mkdir(historyDirectory, { recursive: true });
await writeFile(
historyPath,
JSON.stringify({ success: true, numFailedTests: 0, testResults: files }),
);
}
@@ -36,16 +36,11 @@ export async function verifyGhidraSnapshotLifecycle(
);
const error = requireMcpToolError(reply);
assert.equal(
error?.code,
error.code,
"evidence_integrity_mismatch",
JSON.stringify(reply),
);
assert.match(error.message, /after analysis metadata mutations/u);
assert.match(
error.remediation.action,
/close.*without saving a snapshot/iu,
);
assert.match(error.remediation.action, /export_evidence_bundle/u);
assert.equal(error.retryable, false);
rejectedCalls++;
};
try {
+391
View File
@@ -0,0 +1,391 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import {
mkdtemp,
mkdir,
readFile,
readdir,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
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 Ajv from "ajv/dist/2020.js";
import addFormats from "ajv-formats";
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
import { parseAnalysisSnapshot } from "../dist/domain/analysisSnapshot.js";
import { parseEvidence } from "../dist/domain/evidence.js";
import { functionDossierSchema } from "../dist/domain/hopperValues.js";
import { inspectGhidraInstallation } from "../dist/ghidra/GhidraInstallation.js";
import { buildRiscvFixture } from "../tests/conformance/ghidra/riscv-fixture.mjs";
import {
mcpTextValue,
requireMcpOperationResult,
requireMcpToolError,
} 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-riscv.mjs");
if (!["linux", "darwin"].includes(process.platform))
throw new Error("This optional lane requires a Linux/macOS Ghidra host.");
if (process.env.GHIDRA_INSTALL_DIR === undefined)
throw new Error("Missing prerequisite: caller-supplied GHIDRA_INSTALL_DIR");
for (const key of ["REA_GHIDRA_LANGUAGE_ID", "REA_GHIDRA_COMPILER_SPEC_ID"])
if (process.env[key] !== undefined)
throw new Error(
`Unset ${key}: this lane verifies provider-pinned selection.`,
);
const installation = inspectGhidraInstallation({
environment: process.env,
installDir: process.env.GHIDRA_INSTALL_DIR,
...(process.env.JAVA_HOME === undefined
? {}
: { javaHome: process.env.JAVA_HOME }),
});
assert.equal(installation.status, "available", JSON.stringify(installation));
assert.equal(installation.providerVersion, "12.1.4");
const execute = promisify(execFile);
const entrypoint = fileURLToPath(new URL("./rea.mjs", import.meta.url));
const run = createVerifierRun();
const workspace = await mkdtemp(join(tmpdir(), "rea-riscv-proof-"));
const runtime = join(workspace, "runtime");
await mkdir(runtime);
const env = {
PATH: process.env.PATH ?? "/usr/bin:/bin",
TMPDIR: runtime,
REA_LOG_LEVEL: "silent",
REA_ANALYSIS_PROVIDER: "ghidra",
REA_PROCESS_RUN_ID: run.run_id,
GHIDRA_INSTALL_DIR: installation.installDir,
JAVA_HOME: installation.javaHome,
};
for (const key of ["GHIDRA_HEADLESS_MAXMEM", "GHIDRA_MAXMEM"])
if (process.env[key] !== undefined) env[key] = process.env[key];
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env,
stderr: "pipe",
});
const client = new Client({ name: "rea-real-riscv-proof", version: "1" });
let stderr = "";
transport.stderr?.on("data", (chunk) => {
stderr = (stderr + chunk.toString()).slice(-65536);
});
const reports = [];
let failure;
try {
await client.connect(transport);
const catalog = await client.listTools();
const ajv = new Ajv({ strict: false });
addFormats(ajv);
const validators = new Map();
for (const tool of catalog.tools) {
assert.ok(
ajv.validateSchema(tool.outputSchema),
`${tool.name}: invalid output schema`,
);
const existing =
typeof tool.outputSchema.$id === "string"
? ajv.getSchema(tool.outputSchema.$id)
: undefined;
validators.set(tool.name, existing ?? ajv.compile(tool.outputSchema));
}
for (const bits of [32, 64]) {
const fixture = await buildRiscvFixture(workspace, bits);
const resolved = await parseBinaryTarget(fixture.path);
if (!resolved.ok) throw resolved.error;
assert.equal(resolved.value.architecture, "riscv");
assert.equal(resolved.value.sha256, fixture.sha256);
assert.deepEqual(resolved.value.riscv, fixture.riscv);
const expectedLanguage = `RISCV:LE:${bits}:default`;
const assertProfile = (profile) => {
assert.equal(profile.provider.id, "ghidra");
assert.equal(profile.provider.version, installation.providerVersion);
const parameters = profile.parameters;
assert.equal(parameters.language_id, expectedLanguage);
assert.equal(parameters.compiler_spec_id, "gcc");
assert.equal(parameters.language_selection, "provider-pinned-v1");
assert.equal(
parameters.riscv_support_lane,
`elf${bits}-exec-le-double-float-generic-v1`,
);
assert.deepEqual(parameters.riscv_elf, {
elf_class: bits,
byte_order: "little",
type: 2,
flags: 5,
float_abi_double: true,
rvc_declared: true,
rve_declared: false,
tso_declared: false,
});
};
const assertEvidence = (evidence) => {
assert.equal(evidence.provider.id, "ghidra");
assert.equal(evidence.subject.architecture, "riscv");
assert.equal(evidence.subject.digest.sha256, fixture.sha256);
assertProfile(evidence.analysis_profile);
};
const call = async (name, args = {}) => {
const reply = await client.callTool(
{ name, arguments: args },
{ timeout: 240000 },
);
const projected = requireMcpOperationResult(reply, name);
const textual = JSON.parse(mcpTextValue(reply));
assert.deepEqual(reply.structuredContent, textual);
const validate = validators.get(name);
assert.ok(validate, `${name} missing from advertised catalog`);
assert.ok(
validate(textual),
`${name}: ${JSON.stringify(validate.errors)}`,
);
if (!["open_binary", "close_binary", "binary_session"].includes(name))
assertEvidence(parseEvidence(textual));
return projected;
};
const opened = await call("open_binary", {
path: fixture.path,
provider_id: "ghidra",
});
assert.equal(opened.architecture, "riscv");
assert.equal(opened.sha256, fixture.sha256);
const image = await call("inspect_native_load_image");
assert.equal(image.observations.language_id, expectedLanguage);
assert.equal(image.observations.compiler_spec_id, "gcc");
assert.ok(image.observations.source_files.length > 0);
for (const source of image.observations.source_files) {
assert.equal(source.original_sha256, fixture.sha256);
assert.equal(source.modified_sha256, fixture.sha256);
}
const procedures = await call("list_procedures");
for (const name of [
"rea_riscv_entry",
"rea_riscv_leaf",
"rea_riscv_float",
"rea_riscv_probe",
]) {
const procedure = procedures.find((value) => value.value === name);
assert.ok(procedure, `${name} missing from Ghidra inventory`);
assert.equal(BigInt(procedure.address), BigInt(fixture.symbols[name]));
}
const probe = await call("inspect_native_instruction", {
address: fixture.symbols.rea_riscv_probe,
});
assert.equal(probe.status, "decoded");
assert.equal(probe.architecture, expectedLanguage);
assert.equal(probe.length, 4);
assert.equal(probe.bytes, "13051501"); // addi a0, a0, 17
const mapping = await call("address_to_file_offset", {
address: fixture.symbols.rea_riscv_probe,
});
const sourceBytes = await readFile(fixture.path);
assert.equal(
sourceBytes
.subarray(mapping.file_offset, mapping.file_offset + 4)
.toString("hex"),
probe.bytes,
);
const global = await call("read_bytes", {
address: fixture.symbols.rea_riscv_global,
length: 4,
});
assert.equal(global.bytes_hex, "07000000");
const callees = await call("procedure_callees", {
procedure: fixture.symbols.rea_riscv_entry,
});
for (const name of ["rea_riscv_leaf", "rea_riscv_probe"])
assert.ok(
callees.some(
(address) => BigInt(address) === BigInt(fixture.symbols[name]),
),
);
const dossier = functionDossierSchema.parse(
await call("analyze_function", {
procedure: fixture.symbols.rea_riscv_entry,
}),
);
assert.ok(dossier.pseudocode.trim().length > 0);
const floatCode = await call("procedure_pseudo_code", {
procedure: fixture.symbols.rea_riscv_float,
});
assert.ok(typeof floatCode === "string" && floatCode.trim().length > 0);
assert.match(floatCode, /\bdouble\b/u);
const refusals = [];
for (const [flags, reason] of [
[1, "EF_RISCV_FLOAT_ABI_DOUBLE"],
[0x80000005, "(0x80000000)"],
]) {
// Header-mutated copies test admission; they are not new compiler claims.
const rejectedPath = join(
workspace,
`rv${bits}-flags-${flags.toString(16)}.elf`,
);
const rejectedBytes = Buffer.from(sourceBytes);
rejectedBytes.writeUInt32LE(flags, bits === 32 ? 36 : 48);
await writeFile(rejectedPath, rejectedBytes);
const reply = await client.callTool(
{
name: "open_binary",
arguments: { path: rejectedPath, provider_id: "ghidra" },
},
{ timeout: 60000 },
);
const error = requireMcpToolError(reply);
assert.ok(JSON.stringify(error).includes(reason), JSON.stringify(error));
await assert.rejects(
execute(
process.execPath,
[
entrypoint,
"function",
rejectedPath,
"rea_riscv_entry",
"--provider",
"ghidra",
"--json",
],
{
env,
timeout: 60000,
maxBuffer: 1024 * 1024,
},
),
(cause) =>
Number.isInteger(cause.code) &&
cause.code !== 0 &&
`${cause.stdout}\n${cause.stderr}`.includes(reason),
);
refusals.push({ flags, reason });
}
const snapshotPath = join(workspace, `rv${bits}.snapshot.json`);
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, "riscv");
assert.equal(snapshot.target.sha256, fixture.sha256);
assertProfile(snapshot.binding.analysis_profile);
for (const record of snapshot.evidence_bundle.records)
assertEvidence(parseEvidence(record));
await call("open_binary", {
path: fixture.path,
provider_id: "ghidra",
snapshot_path: snapshotPath,
});
assert.deepEqual(await call("list_procedures"), procedures);
await call("close_binary");
for (const [command, arguments_] of [
["search", ["rea_riscv_", "--kind", "procedures"]],
["function", ["rea_riscv_entry"]],
]) {
const cli = await execute(
process.execPath,
[
entrypoint,
command,
fixture.path,
...arguments_,
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
);
const evidence = parseEvidence(JSON.parse(cli.stdout));
assertEvidence(evidence);
if (command === "search")
for (const name of [
"rea_riscv_entry",
"rea_riscv_leaf",
"rea_riscv_float",
"rea_riscv_probe",
])
assert.ok(
evidence.normalized_result.some((value) => value.value === name),
);
else {
const cliDossier = functionDossierSchema.parse(
evidence.normalized_result,
);
assert.equal(cliDossier.procedure.address, dossier.procedure.address);
assert.ok(cliDossier.pseudocode.trim().length > 0);
}
}
assert.equal(
createHash("sha256")
.update(await readFile(fixture.path))
.digest("hex"),
fixture.sha256,
);
reports.push({
...fixture,
language: expectedLanguage,
procedures: procedures.length,
refusals,
cli: true,
stdio_mcp: true,
snapshot_replay: true,
original_unchanged: true,
});
}
} catch (cause) {
failure = cause;
}
for (const close of [() => client.close(), () => transport.close()]) {
try {
await close();
} catch (cause) {
failure = new AggregateError(
failure === undefined ? [cause] : [failure, cause],
"Verification or cleanup failed",
);
}
}
const completed = await completeVerifierRun(run);
if (
completed.process_lineage.status !== "verified" ||
completed.process_lineage.descendants.length !== 0
)
throw new Error(`Cleanup unverified; retained workspace: ${workspace}`, {
cause: failure,
});
if (failure !== undefined)
throw new Error(
`RISC-V verification failed; retained workspace: ${workspace}; MCP stderr: ${stderr}`,
{ cause: failure },
);
assert.deepEqual(
await readdir(runtime),
[],
`Runtime resources survived server shutdown; retained workspace: ${workspace}`,
);
await rm(workspace, { recursive: true, force: true });
console.log(
JSON.stringify(
{
verifier_run: completed,
lane: "ghidra-riscv",
mocked: false,
host: { platform: process.platform, architecture: process.arch },
provider: { id: "ghidra", version: installation.providerVersion },
fixtures: reports,
cleanup: "complete",
},
null,
2,
),
);
+4 -4
View File
@@ -1086,10 +1086,10 @@ it("rejects mistyped intent extras before touching the device", async () => {
it("collects a device bugreport archive with its digest", async () => {
const { binary, root } = await writeAdbStub(
(root) => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$3" = "bugreport" ]; then
printf 'bugreport-bytes' > "${join(root, "bugreport test.zip")}"
printf 'Bug report copied to ${join(root, "bugreport test.zip")}\\n'
printf 'bugreport-bytes' > "$4/bugreport test.zip"
printf 'Bug report copied to %s/bugreport test.zip\\n' "$4"
exit 0
fi
exit 1`,
@@ -1103,7 +1103,7 @@ exit 1`,
local_path: join(root, "bugreport test.zip"),
bytes: 15,
sha256: digest,
adb_reported_path: join(root, "bugreport test.zip"),
adb_reported_path: expect.stringContaining(".rea-bugreport-"),
});
});
+224 -88
View File
@@ -2,14 +2,16 @@ import { createHash } from "node:crypto";
import { constants as fsConstants } from "node:fs";
import {
access,
copyFile,
lstat,
mkdtemp,
mkdir,
rmdir,
rm,
stat,
writeFile,
} from "node:fs/promises";
import { join, resolve } from "node:path";
import { basename, dirname, extname, join, resolve } from "node:path";
import type {
ExecutionOptions,
@@ -20,7 +22,10 @@ import {
createAnalysisExecution,
type AnalysisExecution,
} from "../application/AnalysisProvider.js";
import type { AdbRequest } from "../domain/adb/adbDeviceAnalysis.js";
import type {
AdbAcquiredFilePartialObservation,
AdbRequest,
} from "../domain/adb/adbDeviceAnalysis.js";
import {
AnalysisArtifactChangedError,
AnalysisCancelledError,
@@ -305,30 +310,56 @@ const refuseExistingTarget = async (
return undefined;
};
/** Publish an ADB-produced archive without replacing an existing caller file. */
const publishBugreport = async (
source: string,
directory: string,
): Promise<string> => {
const fileName = basename(source);
const extension = extname(fileName);
const stem =
extension === "" ? fileName : fileName.slice(0, -extension.length);
for (let suffix = 0; ; suffix += 1) {
const destination = join(
directory,
suffix === 0 ? fileName : `${stem} (${String(suffix)})${extension}`,
);
try {
await copyFile(source, destination, fsConstants.COPYFILE_EXCL);
return destination;
} catch (cause: unknown) {
if (cause instanceof Error && "code" in cause && cause.code === "EEXIST")
continue;
throw cause;
}
}
};
export interface AdbProviderOptions {
readonly environment?: Readonly<Record<string, string | undefined>>;
}
interface PendingPackageDirectoryCleanup {
interface PendingOutputDirectoryCleanup {
readonly operation: "pull_adb_package" | "collect_adb_bugreport";
readonly path: string;
readonly identity: PackageDirectoryIdentity | undefined;
readonly identity: OutputDirectoryIdentity | undefined;
readonly primary: AnalysisError;
readonly remove: () => Promise<void>;
}
interface PackageDirectoryIdentity {
interface OutputDirectoryIdentity {
readonly dev: number;
readonly ino: number;
readonly mode: number;
}
const removeOwnedPackageDirectory = async (
const removeOwnedOutputDirectory = async (
path: string,
identity: PackageDirectoryIdentity | undefined,
identity: OutputDirectoryIdentity | undefined,
): Promise<void> => {
if (identity === undefined)
throw new Error(
`Cannot verify the created package directory identity; refusing to remove ${path}`,
`Cannot verify the created output directory identity; refusing to remove ${path}`,
);
let current: Awaited<ReturnType<typeof lstat>>;
try {
@@ -346,7 +377,7 @@ const removeOwnedPackageDirectory = async (
current.mode !== identity.mode
)
throw new Error(
`The created package directory was replaced or changed; refusing to remove ${path}`,
`The created output directory was replaced or changed; refusing to remove ${path}`,
);
await rm(path, { recursive: true, force: true });
};
@@ -363,7 +394,7 @@ const reservePackageDestinations = async (
): Promise<AnalysisOutputError | undefined> => {
const reservations: {
readonly path: string;
readonly identity: PackageDirectoryIdentity;
readonly identity: OutputDirectoryIdentity;
}[] = [];
for (const entry of entries) {
const path = join(packageDirectory, entry.file_name);
@@ -424,12 +455,12 @@ const reservePackageDestinations = async (
*/
export class AdbProvider {
private readonly environment: Readonly<Record<string, string | undefined>>;
readonly #pendingPackageDirectoryCleanup =
new Set<PendingPackageDirectoryCleanup>();
readonly #pendingOutputDirectoryCleanup =
new Set<PendingOutputDirectoryCleanup>();
readonly #activeOperations = new Set<Promise<void>>();
#closing = false;
#closePromise: Promise<void> | undefined;
#packageCleanupPromise: Promise<void> | undefined;
#outputCleanupPromise: Promise<void> | undefined;
constructor(options: AdbProviderOptions = {}) {
this.environment = options.environment ?? process.env;
@@ -462,13 +493,13 @@ export class AdbProvider {
}
}
/** Retry removal of package directories retained after failed rollback. */
/** Retry removal of owned output directories retained after failed cleanup. */
close(): Promise<void> {
this.#closing = true;
if (this.#closePromise !== undefined) return this.#closePromise;
const closing = (async () => {
await Promise.all(this.#activeOperations);
await this.#retryPackageDirectoryCleanup();
await this.#retryOutputDirectoryCleanup();
})().finally(() => {
if (this.#closePromise === closing) this.#closePromise = undefined;
});
@@ -476,23 +507,23 @@ export class AdbProvider {
return this.#closePromise;
}
#retryPackageDirectoryCleanup(): Promise<void> {
if (this.#packageCleanupPromise !== undefined)
return this.#packageCleanupPromise;
const cleanup = this.#performPackageDirectoryCleanup().finally(() => {
if (this.#packageCleanupPromise === cleanup)
this.#packageCleanupPromise = undefined;
#retryOutputDirectoryCleanup(): Promise<void> {
if (this.#outputCleanupPromise !== undefined)
return this.#outputCleanupPromise;
const cleanup = this.#performOutputDirectoryCleanup().finally(() => {
if (this.#outputCleanupPromise === cleanup)
this.#outputCleanupPromise = undefined;
});
this.#packageCleanupPromise = cleanup;
this.#outputCleanupPromise = cleanup;
return cleanup;
}
async #performPackageDirectoryCleanup(): Promise<void> {
async #performOutputDirectoryCleanup(): Promise<void> {
const failures: AnalysisError[] = [];
for (const owner of this.#pendingPackageDirectoryCleanup) {
for (const owner of this.#pendingOutputDirectoryCleanup) {
try {
await owner.remove();
this.#pendingPackageDirectoryCleanup.delete(owner);
this.#pendingOutputDirectoryCleanup.delete(owner);
} catch (cause: unknown) {
const cleanup = new ProviderCleanupError(
ADB_PROVIDER_IDENTITY.id,
@@ -500,13 +531,13 @@ export class AdbProvider {
{
reason: cause instanceof Error ? cause.message : String(cause),
},
{ operation: "pull_adb_package", cause },
{ operation: owner.operation, cause },
);
failures.push(
analysisErrorWithCleanupFailure(
owner.primary,
cleanup,
"pull_adb_package",
owner.operation,
),
);
}
@@ -515,20 +546,22 @@ export class AdbProvider {
if (failures.length > 1)
throw new ProviderCleanupError(
ADB_PROVIDER_IDENTITY.id,
[...this.#pendingPackageDirectoryCleanup].map(({ path }) => path),
[...this.#pendingOutputDirectoryCleanup].map(({ path }) => path),
{
reason:
"Failed package output directories remain after cleanup retry",
reason: "Failed ADB output directories remain after cleanup retry",
operations: [...this.#pendingOutputDirectoryCleanup].map(
({ operation }) => operation,
),
failures: failures.map(({ message, cleanupResources }) => ({
message,
resources: [...cleanupResources],
})),
},
{
operation: "pull_adb_package",
operation: "close_binary",
cause: new AggregateError(
failures,
"Multiple ADB package rollback cleanups failed",
"Multiple ADB output directory cleanups failed",
),
},
);
@@ -546,16 +579,16 @@ export class AdbProvider {
});
this.#activeOperations.add(active);
try {
if (this.#pendingPackageDirectoryCleanup.size > 0) {
if (this.#pendingOutputDirectoryCleanup.size > 0) {
try {
await this.#retryPackageDirectoryCleanup();
await this.#retryOutputDirectoryCleanup();
} catch (cause: unknown) {
return err(
cause instanceof AnalysisError
? cause
: new ProviderCleanupError(
ADB_PROVIDER_IDENTITY.id,
[...this.#pendingPackageDirectoryCleanup].map(
[...this.#pendingOutputDirectoryCleanup].map(
({ path }) => path,
),
{
@@ -1245,7 +1278,7 @@ export class AdbProvider {
if (!directory.ok) return directory;
const packageDirectory = join(directory.value, packageName);
let packageDirectoryCreated = false;
let packageDirectoryIdentity: PackageDirectoryIdentity | undefined;
let packageDirectoryIdentity: OutputDirectoryIdentity | undefined;
try {
if (
await lstat(packageDirectory).then(
@@ -1275,17 +1308,18 @@ export class AdbProvider {
{ cause },
);
if (!packageDirectoryCreated) return err(primary);
const owner: PendingPackageDirectoryCleanup = {
const owner: PendingOutputDirectoryCleanup = {
operation,
path: packageDirectory,
identity: packageDirectoryIdentity,
primary,
remove: () =>
removeOwnedPackageDirectory(
removeOwnedOutputDirectory(
packageDirectory,
packageDirectoryIdentity,
),
};
this.#pendingPackageDirectoryCleanup.add(owner);
this.#pendingOutputDirectoryCleanup.add(owner);
const cleanup = new ProviderCleanupError(
ADB_PROVIDER_IDENTITY.id,
[packageDirectory],
@@ -1299,12 +1333,13 @@ export class AdbProvider {
const failOwnedPackage = async (
primary: AnalysisError,
): Promise<Result<AnalysisExecution, AnalysisError>> => {
const owner: PendingPackageDirectoryCleanup = {
const owner: PendingOutputDirectoryCleanup = {
operation,
path: packageDirectory,
identity: packageDirectoryIdentity,
primary,
remove: () =>
removeOwnedPackageDirectory(
removeOwnedOutputDirectory(
packageDirectory,
packageDirectoryIdentity,
),
@@ -1312,7 +1347,7 @@ export class AdbProvider {
try {
await owner.remove();
} catch (cause: unknown) {
this.#pendingPackageDirectoryCleanup.add(owner);
this.#pendingOutputDirectoryCleanup.add(owner);
const cleanup = new ProviderCleanupError(
ADB_PROVIDER_IDENTITY.id,
[packageDirectory],
@@ -2084,48 +2119,71 @@ export class AdbProvider {
input.output_directory,
);
if (!directory.ok) return directory;
const collected = await runAdb(
operation,
input.serial,
selection.binary,
adbArguments(input.serial, ["bugreport", directory.value]),
{
timeoutMs: BUGREPORT_TIMEOUT_MS,
maxOutputBytes: BUGREPORT_MAX_OUTPUT_BYTES,
},
signal,
);
if (!collected.ok) return collected;
const reportedPath = parseBugreportCopiedPath(collected.value.stdout);
if (reportedPath === null)
return err(
new AnalysisProtocolError(
"adb bugreport did not report an archive path; the build may use an unsupported format",
),
);
let stagingDirectory: string;
let stagingIdentity: OutputDirectoryIdentity | undefined;
let retainedObservation: AdbAcquiredFilePartialObservation | undefined;
let publishedPath: string | undefined;
try {
const digest = await hashAdbFile(reportedPath, operation, signal);
return ok(
createAnalysisExecution(
{
client,
serial: input.serial,
local_path: reportedPath,
bytes: digest.bytes,
sha256: digest.sha256,
adb_reported_path: reportedPath,
},
provider,
{
limitations: [
"The archive is device-generated: dumpstate, dumpsys, and logcat content is the device's own report",
],
},
),
stagingDirectory = await mkdtemp(
join(directory.value, ".rea-bugreport-"),
);
} catch (cause) {
if (cause instanceof AnalysisCancelledError) {
cause.retainPartialObservation({
return err(
new AnalysisOutputError(
operation,
"The bugreport staging directory could not be created",
{ cause },
),
);
}
const collectedResult = await (async (): Promise<
Result<AnalysisExecution, AnalysisError>
> => {
let reportedPath: string | null = null;
try {
const created = await lstat(stagingDirectory);
if (!created.isDirectory() || created.isSymbolicLink())
throw new Error(
`The created bugreport staging output is not a regular directory: ${stagingDirectory}`,
);
stagingIdentity = {
dev: created.dev,
ino: created.ino,
mode: created.mode,
};
const collected = await runAdb(
operation,
input.serial,
selection.binary,
adbArguments(input.serial, ["bugreport", stagingDirectory]),
{
timeoutMs: BUGREPORT_TIMEOUT_MS,
maxOutputBytes: BUGREPORT_MAX_OUTPUT_BYTES,
},
signal,
);
if (!collected.ok) return collected;
reportedPath = parseBugreportCopiedPath(collected.value.stdout);
if (reportedPath === null)
return err(
new AnalysisProtocolError(
"adb bugreport did not report an archive path; the build may use an unsupported format",
),
);
if (dirname(resolve(reportedPath)) !== stagingDirectory)
return err(
new AnalysisProtocolError(
`adb bugreport reported an archive outside its staging directory: ${reportedPath}`,
),
);
const archive = await lstat(reportedPath);
if (!archive.isFile() || archive.isSymbolicLink())
return err(
new AnalysisProtocolError(
`adb bugreport did not report a regular archive file: ${reportedPath}`,
),
);
retainedObservation = {
provider_id: "adb",
operation,
result: {
@@ -2135,16 +2193,94 @@ export class AdbProvider {
adb_reported_path: reportedPath,
digest_status: "unknown",
},
});
return err(cause);
}
return err(
new AnalysisOutputError(
};
try {
publishedPath = await publishBugreport(reportedPath, directory.value);
} catch (cause) {
return err(
new AnalysisOutputError(
operation,
`The reported bugreport archive could not be published: ${reportedPath}`,
{ cause, partialObservation: retainedObservation },
),
);
}
retainedObservation = {
...retainedObservation,
result: { ...retainedObservation.result, local_path: publishedPath },
};
const digest = await hashAdbFile(publishedPath, operation, signal);
const report = {
client,
serial: input.serial,
local_path: publishedPath,
bytes: digest.bytes,
sha256: digest.sha256,
adb_reported_path: reportedPath,
};
retainedObservation = {
provider_id: "adb",
operation,
result: report,
};
return ok(
createAnalysisExecution(report, provider, {
limitations: [
"The archive is device-generated: dumpstate, dumpsys, and logcat content is the device's own report",
],
}),
);
} catch (cause) {
if (cause instanceof AnalysisCancelledError) {
if (retainedObservation !== undefined)
cause.retainPartialObservation(retainedObservation);
return err(cause);
}
return err(
new AnalysisOutputError(
operation,
`The reported bugreport archive could not be read back: ${reportedPath ?? stagingDirectory}`,
{ cause, partialObservation: retainedObservation },
),
);
}
})();
// A completed acquisition becomes caller evidence even if publication fails.
// Its staging directory must not enter automatic cleanup or cleanup retries.
if (retainedObservation !== undefined && publishedPath === undefined)
return collectedResult;
try {
await removeOwnedOutputDirectory(stagingDirectory, stagingIdentity);
} catch (cause) {
const primary = collectedResult.ok
? new AnalysisOutputError(
operation,
"The archive was published, but its staging directory could not be removed",
)
: collectedResult.error;
if (retainedObservation !== undefined)
primary.retainPartialObservation(retainedObservation);
this.#pendingOutputDirectoryCleanup.add({
operation,
path: stagingDirectory,
identity: stagingIdentity,
primary,
remove: () =>
removeOwnedOutputDirectory(stagingDirectory, stagingIdentity),
});
return err(
analysisErrorWithCleanupFailure(
primary,
new ProviderCleanupError(
ADB_PROVIDER_IDENTITY.id,
[stagingDirectory],
{ reason: cause instanceof Error ? cause.message : String(cause) },
{ operation, cause },
),
operation,
`The reported bugreport archive could not be read back: ${reportedPath}`,
{ cause },
),
);
}
return collectedResult;
}
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { parseClientConfiguration } from "./ClientConfigurationDocument.js";
describe("parseClientConfiguration shape diagnostics", () => {
it.each([
["null", "null"],
["array", "[]"],
["string", '"x"'],
] as const)("names a non-object JSON root (%s)", (_label, text) => {
expect(() => parseClientConfiguration(text, "json")).toThrow(
new TypeError("The configuration root must be an object"),
);
});
it.each([
["null", "null"],
["array", "[]"],
["string", '"x"'],
] as const)(
'names a non-object "mcpServers" value (%s)',
(_label, servers) => {
expect(() =>
parseClientConfiguration(`{"mcpServers":${servers},"keep":1}`, "json"),
).toThrow(new TypeError('"mcpServers" must be an object'));
},
);
it("still rejects unparsable JSON with SyntaxError", () => {
expect(() => parseClientConfiguration("not-json", "json")).toThrow(
SyntaxError,
);
});
});
+10 -16
View File
@@ -107,18 +107,21 @@ const parseDocument = (
format: ClientConfigurationFormat,
): Record<string, unknown> => {
if (format === "toml" || format === "grok")
return objectSchema.parse(parseToml(text));
return requireObject(parseToml(text), "The configuration root");
// Pi loads `mcp.json` with a strict `JSON.parse` and no BOM tolerance, so
// comments, trailing commas, a BOM, or an empty existing file must be
// rejected instead of registering into a document Pi cannot load.
if (format === "pi") return objectSchema.parse(JSON.parse(text));
if (format === "pi")
return requireObject(JSON.parse(text), "The configuration root");
// An empty file, which some clients create before any server is added,
// holds no settings to preserve.
if (isEmptyClientConfigurationText(text)) return {};
// Hermes stores MCP servers in `mcp_servers` inside a YAML document.
if (format === "hermes") return objectSchema.parse(parseYaml(text));
if (format === "hermes")
return requireObject(parseYaml(text), "The configuration root");
// ZCode loads nonempty native files with strict JSON.parse.
if (format === "zcode") return objectSchema.parse(JSON.parse(text));
if (format === "zcode")
return requireObject(JSON.parse(text), "The configuration root");
const errors: ParseError[] = [];
// Accept a UTF-8 BOM without shifting diagnostics or editing the original text.
const jsonText = text.startsWith("\uFEFF") ? ` ${text.slice(1)}` : text;
@@ -128,12 +131,13 @@ const parseDocument = (
throw new SyntaxError(
`Invalid JSON/JSONC at offset ${firstError.offset}: ${printParseErrorCode(firstError.error)}`,
);
return objectSchema.parse(document);
return requireObject(document, "The configuration root");
};
/** A parsed configuration whose structure, not its syntax, cannot hold MCP servers. */
export class ClientConfigurationShapeError extends TypeError {}
/** Validate and copy own properties, excluding non-record YAML values and parser-injected prototypes. */
const requireObject = (
value: unknown,
description: string,
@@ -174,17 +178,7 @@ export const parseClientConfiguration = (
throw new TypeError("client does not have a supported MCP config format");
// DeepSeek Harness stores a Cordis patch list, not a server table.
if (format === "dsh") return parseDshCordisDocument(text);
let document: Record<string, unknown>;
try {
document = parseDocument(text, format);
} catch (cause: unknown) {
if (cause instanceof z.ZodError)
throw new ClientConfigurationShapeError(
"The configuration root must be an object",
{ cause },
);
throw cause;
}
const document = parseDocument(text, format);
const serversKey = clientConfigurationServersKey(format);
const value = document[serversKey];
const servers =
+1 -1
View File
@@ -289,7 +289,7 @@ const readClientConfiguration = async (
"failed",
cause instanceof ClientConfigurationShapeError
? `Configuration was not changed: ${cause.message}. Repair it, then rerun uninstall.`
: `Configuration is not valid ${client.format === "toml" || client.format === "grok" ? "TOML" : client.format === "opencode" ? "JSONC" : "JSON"} and was not changed. Repair it, then rerun uninstall.`,
: `Configuration is not valid ${client.format === "toml" || client.format === "grok" ? "TOML" : client.format === "opencode" ? "JSONC" : client.format === "hermes" || client.format === "dsh" ? "YAML" : "JSON"} and was not changed. Repair it, then rerun uninstall.`,
),
);
}
@@ -1,3 +1,6 @@
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, it } from "vitest";
import type { FridaInstrumentationPort } from "./FridaInstrumentationPort.js";
@@ -102,6 +105,66 @@ it.each(["startSession", "instrument"] as const)(
},
);
it.each([
[join(tmpdir(), "rea-missing-frida-script.js"), "no file exists there"],
[tmpdir(), "it is not a regular file"],
])(
"rejects the unreadable script file %s before starting Frida",
async (path, reason) => {
let starts = 0;
const provider = createProvider({
async startSession() {
starts += 1;
return { ok: true, value: createSession() };
},
});
const service = new FridaInstrumentationService(provider);
const result = await service.instrument({
mode: "spawn",
deviceId: "local",
program: "/tmp/fixture",
source: { sourceKind: "file", path },
durationMs: 1,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toBeInstanceOf(AnalysisInputError);
expect((result.error as AnalysisInputError).issues).toEqual([
{
path: ["path"],
reason: "invalid_value",
message: `Cannot read Frida script file ${path}: ${reason}.`,
},
]);
expect(starts).toBe(0);
},
);
it("reports cancellation before checking a file script source", async () => {
const service = new FridaInstrumentationService(createProvider());
const controller = new AbortController();
controller.abort(new Error("cancelled before call"));
const result = await service.instrument(
{
mode: "spawn",
deviceId: "local",
program: "/tmp/fixture",
source: {
sourceKind: "file",
path: join(tmpdir(), "rea-missing-frida-script.js"),
},
durationMs: 1,
},
controller.signal,
);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error._tag).toBe("AnalysisCancelledError");
});
it("does not resume a spawned target when cancellation arrives during script loading", async () => {
const controller = new AbortController();
let resumes = 0;
@@ -1,11 +1,15 @@
import { constants } from "node:fs";
import { access, stat } from "node:fs/promises";
import { setTimeout as delay } from "node:timers/promises";
import { createEvidence, type Evidence } from "../../domain/evidence.js";
import { jsonObjectSchema, jsonValueSchema } from "../../domain/jsonValue.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import {
AnalysisAccessDeniedError,
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisInputError,
} from "../../domain/analysisErrorCore.js";
import { err, ok, type Result } from "../../domain/result.js";
import type {
@@ -19,6 +23,64 @@ import type {
StartFridaSessionInput,
} from "./FridaInstrumentationPort.js";
const unreadableScriptFile = async (
source: FridaScriptSource,
): Promise<AnalysisError | undefined> => {
if (source.sourceKind !== "file") return undefined;
let reason: string;
let cause: unknown;
try {
if (!(await stat(source.path)).isFile())
reason = "it is not a regular file";
else {
await access(source.path, constants.R_OK);
return undefined;
}
} catch (failure: unknown) {
cause = failure;
const code =
failure instanceof Error && "code" in failure ? failure.code : undefined;
if (code === "EACCES" || code === "EPERM")
return new AnalysisAccessDeniedError(
"instrument_with_frida",
source.path,
code,
{ cause },
);
reason =
code === "ENOENT" ? "no file exists there" : String(code ?? failure);
}
return new AnalysisInputError(
"instrument_with_frida",
cause === undefined ? undefined : { cause },
[
{
path: ["path"],
reason: "invalid_value",
message: `Cannot read Frida script file ${source.path}: ${reason}.`,
},
],
);
};
/**
* Reject an unreadable script file before any attach or spawn effect;
* cancellation before or during the check still wins.
*/
const rejectBeforeStart = async (
source: FridaScriptSource,
signal: AbortSignal | undefined,
): Promise<AnalysisError | undefined> => {
const cancelled = () =>
new AnalysisCancelledError(
"instrument_with_frida",
signal?.reason === undefined ? {} : { cause: signal.reason },
);
if (signal?.aborted) return cancelled();
const unreadable = await unreadableScriptFile(source);
return signal?.aborted ? cancelled() : unreadable;
};
/** Shared CLI/MCP workflows for Frida's device and instrumentation operations. */
export class FridaInstrumentationService {
readonly #provider: FridaInstrumentationPort;
@@ -163,6 +225,8 @@ export class FridaInstrumentationService {
AnalysisError
>
> {
const rejected = await rejectBeforeStart(input.source, signal);
if (rejected !== undefined) return err(rejected);
const started = await this.#startSession(
input,
signal,
+65 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { build, buildBinary } from "plist";
import { build, buildBinary, type PlistValue } from "plist";
import { decodeKeyedArchiveBytes } from "./KeyedArchiveReader.js";
import { deeplyNestedXmlPlist } from "../../../tests/fixtures/keyedArchiveXmlNesting.js";
const archive = {
$archiver: "NSKeyedArchiver",
@@ -323,3 +324,66 @@ describe("binary plist reference preflight", () => {
);
});
});
describe("XML plist nesting preflight", () => {
const selection = { offset: 0, limit: 1 };
/** A valid XML NSKeyedArchiver envelope whose object 1 nests `depth` dictionaries. */
const deeplyNestedArchive = (depth: number) => {
let payload: PlistValue = "leaf";
for (let level = 0; level < depth; level += 1) payload = { k: payload };
return Buffer.from(
build({
$archiver: "NSKeyedArchiver",
$version: 100000,
$objects: ["$null", payload],
$top: { root: { UID: 1 } },
}),
);
};
/** Follow the single-member chain of a decoded object to its deepest value. */
const deepestValue = (value: unknown): unknown => {
let cursor = value;
while (
cursor !== null &&
typeof cursor === "object" &&
!Array.isArray(cursor) &&
"k" in cursor
) {
cursor = (cursor as Record<string, unknown>).k;
}
return cursor;
};
it.each([129, 200, 3000, 4000])(
"rejects %i-level XML nesting as the documented resource limit",
(depth) => {
// Recursive decoding exceeds the call stack before the projection limit
// can report it, so the decoder must reject the nesting up front with
// the same bounded-resource error an in-reach document would produce.
expect(() =>
decodeKeyedArchiveBytes(
Buffer.from(deeplyNestedXmlPlist(depth)),
selection,
),
).toThrow(new RangeError("Plist exceeds 128 nesting levels"));
},
);
it("decodes a valid XML keyed archive exactly at the nesting limit", () => {
const graph = decodeKeyedArchiveBytes(deeplyNestedArchive(126), {
root: "root",
offset: 1,
limit: 1,
});
expect(graph.objects).toHaveLength(1);
expect(deepestValue(graph.objects[0]?.value)).toBe("leaf");
});
it("rejects one nesting level past the limit before decoding", () => {
expect(() =>
decodeKeyedArchiveBytes(deeplyNestedArchive(127), selection),
).toThrow(new RangeError("Plist exceeds 128 nesting levels"));
});
});
+12 -10
View File
@@ -709,7 +709,7 @@ export type AdbPackagePullPartialObservation = {
readonly result: z.infer<typeof adbResultSchemas.pull_adb_package>;
};
/** A completed acquisition whose digest read was cancelled before verification. */
/** Acquired file facts retained when publication, digest verification or cleanup fails. */
export type AdbAcquiredFilePartialObservation =
| {
readonly provider_id: "adb";
@@ -727,13 +727,15 @@ export type AdbAcquiredFilePartialObservation =
| {
readonly provider_id: "adb";
readonly operation: "collect_adb_bugreport";
readonly result: {
readonly client: z.infer<
typeof adbResultSchemas.collect_adb_bugreport
>["client"];
readonly serial: string;
readonly local_path: string;
readonly adb_reported_path: string;
readonly digest_status: "unknown";
};
readonly result:
| z.infer<typeof adbResultSchemas.collect_adb_bugreport>
| {
readonly client: z.infer<
typeof adbResultSchemas.collect_adb_bugreport
>["client"];
readonly serial: string;
readonly local_path: string;
readonly adb_reported_path: string;
readonly digest_status: "unknown";
};
};
+6 -1
View File
@@ -2,7 +2,12 @@ import { z } from "zod";
import type { JsonValue } from "../jsonValue.js";
const MAX_PLIST_DEPTH = 128;
/**
* Maximum plist value nesting the JSON projection supports. The XML decode
* preflight shares this limit so oversized documents are rejected before any
* recursive pass rather than during one.
*/
export const MAX_PLIST_DEPTH = 128;
/** A decoded plist projected into JSON, with values JSON cannot express. */
export interface ProjectedPlistValue {
+98
View File
@@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import { parseExecutableHeader } from "./binaryTarget.js";
const header = (
little: boolean,
bits: 32 | 64 = 32,
flags = 0x5,
type = 2,
machine = 243,
): Buffer => {
const bytes = Buffer.alloc(bits === 32 ? 52 : 64);
bytes.set([0x7f, 0x45, 0x4c, 0x46, bits === 32 ? 1 : 2, little ? 1 : 2, 1]);
if (little) {
bytes.writeUInt16LE(type, 16);
bytes.writeUInt16LE(machine, 18);
bytes.writeUInt32LE(flags, bits === 32 ? 36 : 48);
} else {
bytes.writeUInt16BE(type, 16);
bytes.writeUInt16BE(machine, 18);
bytes.writeUInt32BE(flags, bits === 32 ? 36 : 48);
}
return bytes;
};
describe("RISC-V ELF header boundaries", () => {
it.each([
[true, 32],
[false, 32],
[true, 64],
[false, 64],
] as const)(
"retains class, endian, e_type and e_flags (little=%s, class=%s)",
(little, bits) => {
const parsed = parseExecutableHeader(header(little, bits), "x64");
if (!parsed.ok) throw new Error(parsed.error);
expect(parsed.value).toEqual({
format: "elf",
architecture: "riscv",
availableArchitectures: ["riscv"],
riscv: {
elfClass: bits,
byteOrder: little ? "little" : "big",
type: 2,
flags: 0x5,
},
});
},
);
it("reads ELF64 flags at their own offset", () => {
const bytes = header(true, 64);
bytes.writeUInt32LE(0xdeadbeef, 36);
bytes.writeUInt32LE(0x00000014, 48);
const parsed = parseExecutableHeader(bytes, "x64");
if (!parsed.ok) throw new Error(parsed.error);
expect(parsed.value).toMatchObject({
architecture: "riscv",
riscv: { elfClass: 64, flags: 0x14 },
});
});
it("does not attach ELF metadata to a family that declares none", () => {
const parsed = parseExecutableHeader(header(true, 64, 0x5, 2, 62), "x64");
if (!parsed.ok) throw new Error(parsed.error);
expect(parsed.value).toEqual({
format: "elf",
architecture: "x86_64",
availableArchitectures: ["x86_64"],
});
});
it("rejects a truncated header before reading flags", () => {
const parsed = parseExecutableHeader(header(true).subarray(0, 39), "x64");
expect(parsed).toEqual({
ok: false,
error:
"truncated ELF header: an ELF32 header needs 52 bytes; the file has 39",
});
});
it("does not turn invalid byte order into little-endian RISC-V", () => {
const bytes = header(true);
bytes[5] = 0;
expect(parseExecutableHeader(bytes, "x64")).toEqual({
ok: false,
error: "unsupported ELF endianness",
});
});
it("keeps an unknown e_machine outside the recognized family set", () => {
expect(parseExecutableHeader(header(true, 32, 0x5, 2, 244), "x64")).toEqual(
{
ok: false,
error: "unsupported ELF architecture",
},
);
});
});
+45 -34
View File
@@ -1,6 +1,9 @@
import type {
BinaryArchitecture,
ElfFamilyMetadata,
HeaderArchitecture,
MipsElfMetadata,
RiscvElfMetadata,
UniversalMachOSlice,
} from "./binaryTargetTypes.js";
import { err, ok, type Result } from "./result.js";
@@ -15,31 +18,28 @@ export type ExecutableMetadata =
readonly availableArchitectures: readonly "mips"[];
readonly mips: MipsElfMetadata;
}
| {
readonly format: "elf";
readonly architecture: "riscv";
readonly availableArchitectures: readonly "riscv"[];
readonly riscv: RiscvElfMetadata;
}
| {
readonly format: "mach-o" | "elf" | "dos-mz" | "dos-com";
readonly architecture: Exclude<BinaryArchitecture, "mips">;
readonly availableArchitectures: readonly Exclude<
BinaryArchitecture,
"mips"
>[];
readonly architecture: HeaderArchitecture;
readonly availableArchitectures: readonly HeaderArchitecture[];
readonly universalSlice?: never;
}
| {
readonly format: "mach-o-universal";
readonly architecture: Exclude<BinaryArchitecture, "mips">;
readonly availableArchitectures: readonly Exclude<
BinaryArchitecture,
"mips"
>[];
readonly architecture: HeaderArchitecture;
readonly availableArchitectures: readonly HeaderArchitecture[];
readonly universalSlice: UniversalMachOSlice;
}
| {
readonly format: "pe";
readonly architecture: Exclude<BinaryArchitecture, "mips">;
readonly availableArchitectures: readonly Exclude<
BinaryArchitecture,
"mips"
>[];
readonly architecture: HeaderArchitecture;
readonly availableArchitectures: readonly HeaderArchitecture[];
readonly executableRole:
| "application"
| "shared-library"
@@ -115,9 +115,9 @@ const parseFatMachO = (
const entrySize = is64 ? 32 : 20;
if (count === 0 || count > 128 || bytes.length < 8 + count * entrySize)
return err("truncated or invalid FAT architecture table");
const architectures: Exclude<BinaryArchitecture, "mips">[] = [];
const architectures: HeaderArchitecture[] = [];
const slices = new Map<
Exclude<BinaryArchitecture, "mips">,
HeaderArchitecture,
{ readonly offset: bigint; readonly size: bigint }
>();
for (let index = 0; index < count; index += 1) {
@@ -191,20 +191,31 @@ const parseElf = (
const machine = little ? bytes.readUInt16LE(18) : bytes.readUInt16BE(18);
const architecture = elfArchitecture(machine);
if (architecture === undefined) return err("unsupported ELF architecture");
if (architecture === "mips")
return ok({
format: "elf",
architecture,
availableArchitectures: [architecture],
mips: {
elfClass: bytes[4] === 1 ? 32 : 64,
byteOrder: little ? "little" : "big",
type: little ? bytes.readUInt16LE(16) : bytes.readUInt16BE(16),
flags: little
? bytes.readUInt32LE(bytes[4] === 1 ? 36 : 48)
: bytes.readUInt32BE(bytes[4] === 1 ? 36 : 48),
},
});
if (architecture === "mips" || architecture === "riscv") {
const elfClass = bytes[4] === 1 ? 32 : 64;
const flagsOffset = elfClass === 32 ? 36 : 48;
const metadata: ElfFamilyMetadata = {
elfClass,
byteOrder: little ? "little" : "big",
type: little ? bytes.readUInt16LE(16) : bytes.readUInt16BE(16),
flags: little
? bytes.readUInt32LE(flagsOffset)
: bytes.readUInt32BE(flagsOffset),
};
return architecture === "mips"
? ok({
format: "elf",
architecture,
availableArchitectures: [architecture],
mips: metadata,
})
: ok({
format: "elf",
architecture,
availableArchitectures: [architecture],
riscv: metadata,
});
}
return ok({
format: "elf",
architecture,
@@ -299,9 +310,7 @@ const peManagedStatus = (
);
};
const machArchitecture = (
cpu: number,
): Exclude<BinaryArchitecture, "mips"> | undefined => {
const machArchitecture = (cpu: number): HeaderArchitecture | undefined => {
switch (cpu) {
case 7:
return "x86";
@@ -327,6 +336,8 @@ const elfArchitecture = (machine: number): BinaryArchitecture | undefined => {
return "arm";
case 183:
return "arm64";
case 243: // EM_RISCV: XLEN, byte order and e_flags remain separate ELF facts.
return "riscv";
}
return undefined;
};
+48 -7
View File
@@ -5,15 +5,44 @@ export const BINARY_ARCHITECTURES = [
"arm",
"arm64",
"mips",
"riscv",
] as const;
export type BinaryArchitecture = (typeof BINARY_ARCHITECTURES)[number];
/** Raw ELF facts; family recognition does not establish ISA or ABI support. */
export interface MipsElfMetadata {
/**
* ELF header facts for a family whose ISA and ABI are not established by the
* family name alone. Consumers admit a lane from these declarations rather than
* re-reading the header.
*/
export interface ElfFamilyMetadata {
readonly elfClass: 32 | 64;
readonly byteOrder: "little" | "big";
readonly type: number;
readonly flags: number;
}
/** Families whose parsed target variant carries `ElfFamilyMetadata`. */
export const ELF_METADATA_ARCHITECTURES = [
"mips",
"riscv",
] as const satisfies readonly BinaryArchitecture[];
export type ElfMetadataArchitecture =
(typeof ELF_METADATA_ARCHITECTURES)[number];
/** Families whose ISA and ABI need a separate ELF declaration to be admitted. */
export const isElfMetadataArchitecture = (
architecture: BinaryArchitecture,
): architecture is ElfMetadataArchitecture =>
(ELF_METADATA_ARCHITECTURES as readonly string[]).includes(architecture);
/** Recognized family with no ELF metadata member; the header alone is the fact. */
export type HeaderArchitecture = Exclude<
BinaryArchitecture,
ElfMetadataArchitecture
>;
/** Raw ELF facts; family recognition does not establish ISA or ABI support. */
export interface MipsElfMetadata extends ElfFamilyMetadata {
/** Undefined until inspected; null means no ABI record was observed. */
readonly abiFlags?: MipsAbiFlags | null;
/** Unavailable declaration sources; missing records there remain unknown. */
@@ -44,6 +73,12 @@ export interface MipsAbiFlags {
readonly flags2: number;
}
/**
* ELF class and flags retain the width and ABI declarations used by this lane.
* ISA extensions in `.riscv.attributes` are not inspected by this header parser.
*/
export type RiscvElfMetadata = ElfFamilyMetadata;
/** Byte range of the selected slice inside its universal Mach-O container. */
export interface UniversalMachOSlice {
readonly offset: number;
@@ -67,11 +102,8 @@ type NonExecutableMetadata = {
type ExecutableTarget = BinaryTargetIdentity & {
readonly kind: "executable";
readonly architecture: Exclude<BinaryArchitecture, "mips">;
readonly availableArchitectures: readonly Exclude<
BinaryArchitecture,
"mips"
>[];
readonly architecture: HeaderArchitecture;
readonly availableArchitectures: readonly HeaderArchitecture[];
};
/**
@@ -88,6 +120,15 @@ export type BinaryTarget =
readonly executableRole?: never;
readonly managed?: never;
})
| (BinaryTargetIdentity & {
readonly kind: "executable";
readonly format: "elf";
readonly architecture: "riscv";
readonly availableArchitectures: readonly "riscv"[];
readonly riscv: RiscvElfMetadata;
readonly executableRole?: never;
readonly managed?: never;
})
| (ExecutableTarget & {
readonly format: "mach-o" | "elf" | "dos-mz" | "dos-com";
readonly universalSlice?: never;
+2 -2
View File
@@ -1,6 +1,6 @@
import type { BinaryArchitecture } from "./binaryTargetTypes.js";
import type { HeaderArchitecture } from "./binaryTargetTypes.js";
export type PeArchitecture = Exclude<BinaryArchitecture, "mips">;
export type PeArchitecture = HeaderArchitecture;
/** A decoded COFF machine and whether it carried a ReadyToRun OS override. */
export interface PeMachine {
+113
View File
@@ -98,6 +98,119 @@ it("preserves OpenStep dictionary and array autodetection", () => {
});
});
/** The single-member chain of a nested dictionary, down to its deepest value. */
const deepestValue = (value: unknown): unknown => {
let cursor = value;
while (
cursor !== null &&
typeof cursor === "object" &&
!Array.isArray(cursor) &&
"k" in cursor
) {
cursor = (cursor as Record<string, unknown>).k;
}
return cursor;
};
/** The single-member chain of a nested array, down to its deepest value. */
const deepestEntry = (value: unknown): unknown => {
let cursor = value;
while (Array.isArray(cursor) && cursor.length === 1) cursor = cursor[0];
return cursor;
};
const nestedDictionaries = (depth: number) =>
plist(
"<dict><key>k</key>".repeat(depth) +
"<string>x</string>" +
"</dict>".repeat(depth),
);
const nestedArrays = (depth: number) =>
plist(
"<array>".repeat(depth) + "<string>x</string>" + "</array>".repeat(depth),
);
it("rejects XML nesting beyond the projection limit with the limit diagnostic", () => {
// `plist` and the prototype-key traversal recurse once per level, so the
// depth check must report the same bounded-resource error as the projection
// instead of letting those recursive passes exhaust the call stack.
const limit = new RangeError("Plist exceeds 128 nesting levels");
expect(() => parseXmlPropertyList(nestedDictionaries(129))).toThrow(limit);
expect(() => parseXmlPropertyList(nestedArrays(129))).toThrow(limit);
expect(() => parseXmlPropertyList(nestedDictionaries(3000))).toThrow(limit);
expect(() => parseXmlPropertyList(nestedArrays(4000))).toThrow(limit);
});
it("decodes XML property lists exactly at the projection nesting limit", () => {
expect(
deepestValue(parseXmlPropertyList(nestedDictionaries(128)).value),
).toBe("x");
expect(deepestEntry(parseXmlPropertyList(nestedArrays(128)).value)).toBe("x");
expect(
parseXmlPropertyList(
plist("<array>".repeat(128) + "<array/>" + "</array>".repeat(128)),
).value,
).toBeDefined();
});
it("does not count markup inside comments, CDATA, or the doctype as nesting", () => {
const text = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd" [<!ENTITY deep "<dict/>">]>
<plist version="1.0"><dict>
<key>a</key><string><![CDATA[<dict><array><dict>]]></string>
<!-- <dict><key>k</key> -->
<key>b</key><array><!-- <array> --><string>x</string></array>
</dict></plist>`;
expect(parseXmlPropertyList(text)).toEqual({
value: { a: "<dict><array><dict>", b: ["x"] },
omittedPrototypeKeys: 0,
});
});
it.each(['"', "'"])(
"counts containers with quoted XML delimiters using %s attributes",
(quote) => {
const opening = `<dict note=${quote}/>${quote}><key>k</key>`;
const text = plist(
opening.repeat(3000) + "<string>x</string>" + "</dict>".repeat(3000),
);
expect(() => parseXmlPropertyList(text)).toThrow(
new RangeError("Plist exceeds 128 nesting levels"),
);
expect(
parseXmlPropertyList(
plist(
"<array>" + `<dict note=${quote}>${quote}/>`.repeat(130) + "</array>",
),
).value,
).toEqual(Array.from({ length: 130 }, () => ({})));
},
);
it("ignores complete DOCTYPE internal subsets when checking value depth", () => {
const text = `<!DOCTYPE plist [
<!ENTITY first "unused">
<!-- <dict><array> -->
<!ENTITY second "${"<dict>".repeat(130)}">
]><plist><string>x</string></plist>`;
expect(parseXmlPropertyList(text)).toEqual({
value: "x",
omittedPrototypeKeys: 0,
});
});
it("bounds nested plist wrappers before recursive decoding", () => {
const text =
"<plist>".repeat(3000) + "<string>x</string>" + "</plist>".repeat(3000);
expect(() => parseXmlPropertyList(text)).toThrow(
new RangeError("Plist exceeds 128 nesting levels"),
);
expect(
parseXmlPropertyList(plist("<plist><string>x</string></plist>")).value,
).toEqual(["x"]);
});
it.each(["\n", "\r\n", "\r", "\u0085", "\u2028", "\u2029"])(
"preserves unrelated source values around %j line endings",
(newline) => {
+60 -7
View File
@@ -1,6 +1,13 @@
import { DOMParser, normalizeLineEndings } from "@xmldom/xmldom";
import {
DOMParser,
normalizeLineEndings,
type Document,
type Node,
} from "@xmldom/xmldom";
import { parse } from "plist";
import { MAX_PLIST_DEPTH } from "./apple/plistValue.js";
/** The dictionary key REA results cannot carry: validation strips it. */
const PROTOTYPE_KEY = "__proto__";
@@ -88,18 +95,64 @@ export const parseXmlPropertyList = (
// by app Info.plist resolution. Only XML needs the key-element substitution.
const first = text.trimStart()[0];
if (first === "{" || first === "(") return omitPrototypeKeys(parse(text));
// Normalize only the literal line endings the pinned decoder normalizes.
// Preserve character references in the source: serializing a DOM would turn
// e.g. an encoded carriage return into a literal newline on the second parse.
const normalized = normalizeLineEndings(text);
// The pinned XML parser is iterative. Reuse its document for the depth
// preflight and key lookup so quoted attributes and declarations have the
// same meaning at both boundaries, before plist's recursive value decode.
const document = new DOMParser().parseFromString(normalized, "text/xml");
assertXmlPlistDepthWithinLimit(document);
const { substituted, placeholder } = substitutePrototypeKeys(
normalizeLineEndings(text),
normalized,
document,
);
return withoutKey(parse(substituted), placeholder);
};
/** Elements whose children the pinned plist value decoder visits recursively. */
const PLIST_CONTAINER_ELEMENTS = new Set(["plist", "dict", "array"]);
/**
* Reject XML plists nested deeper than the projection supports before any
* recursive value pass runs. `plist` and the prototype-key traversal recurse once
* per nesting level, so a deeply nested document exhausts the call stack
* before the projection limit can report the resource bound. The failure
* matches `projectPlistValue` so callers classify it as a limit failure.
*
* Depth counts values the way the projection does: the root value is level 0
* and every container member is one level deeper. A dictionary key names a
* member instead of nesting one, and a self-closing element is the value at
* its own level. The outer plist envelope is unwrapped by the decoder;
* nested plist elements decode as arrays and must count toward the bound.
*/
const assertXmlPlistDepthWithinLimit = (document: Document): void => {
const root = document.documentElement;
if (root === null) return;
const pending: Array<{ node: Node; depth: number }> = [
{ node: root, depth: -1 },
];
while (pending.length > 0) {
const entry = pending.pop();
if (entry === undefined) break;
const { node, depth } = entry;
// Schedule only the next sibling, keeping traversal storage proportional
// to depth rather than the number of members in a wide container.
if (node.nextSibling !== null)
pending.push({ node: node.nextSibling, depth });
if (node.nodeType !== node.ELEMENT_NODE) continue;
if (node.nodeName !== "key" && depth > MAX_PLIST_DEPTH)
throw new RangeError(
`Plist exceeds ${String(MAX_PLIST_DEPTH)} nesting levels`,
);
if (PLIST_CONTAINER_ELEMENTS.has(node.nodeName) && node.firstChild !== null)
pending.push({ node: node.firstChild, depth: depth + 1 });
}
};
/** Locate keys with the same XML parser and first-child semantics as plist. */
const substitutePrototypeKeys = (text: string) => {
// Normalize only the literal line endings the pinned decoder normalizes.
// Preserve character references in the source: serializing a DOM would turn
// e.g. an encoded carriage return into a literal newline on the second parse.
const document = new DOMParser().parseFromString(text, "text/xml");
const substitutePrototypeKeys = (text: string, document: Document) => {
const keys = Array.from(document.getElementsByTagName("key"));
const decodedKeys = new Set(keys.map((key) => key.firstChild?.nodeValue));
let placeholder = "__rea_prototype_key__";
+5 -2
View File
@@ -1,6 +1,9 @@
import { z } from "zod";
import type { BinaryArchitecture } from "./binaryTargetTypes.js";
import type {
BinaryArchitecture,
HeaderArchitecture,
} from "./binaryTargetTypes.js";
import type { ExecutableFormatHint } from "./dosCom.js";
import { err, ok, type Result } from "./result.js";
@@ -55,7 +58,7 @@ export const validateRawBinaryLength = (
/** Admitted headerless load identity used by target resolution and profiles. */
export interface RawBinaryAdmission {
readonly architecture: Exclude<BinaryArchitecture, "mips">;
readonly architecture: HeaderArchitecture;
readonly entryPoint: string;
readonly entryPointSource: string;
}
+27 -7
View File
@@ -17,6 +17,10 @@ import {
ghidraMipsProfileParameters,
ghidraMipsSharedLanguageOverride,
} from "./GhidraMipsProfile.js";
import {
ghidraRiscvLanguageOverride,
ghidraRiscvProfileParameters,
} from "./GhidraRiscvProfile.js";
import { ghidraRawBinaryLanguage } from "./GhidraRawBinaryLanguage.js";
import {
@@ -32,6 +36,17 @@ import {
NATIVE_AOT_PROFILE_CONTRACT_REVISION,
} from "../domain/native/nativeAotPe.js";
/**
* The processor language REA pins for this target, or undefined when Ghidra may
* select from the header. The committed profile and the session readback both
* compare against this single owner rather than re-deriving a lane.
*/
export const ghidraPinnedLanguage = (
target: BinaryTarget,
): GhidraLanguageOverride | undefined =>
ghidraMipsSharedLanguageOverride(target) ??
ghidraRiscvLanguageOverride(target);
/** Resolve version-bound, deterministic semantics before Ghidra imports a target. */
export const resolveGhidraAnalysisProfile = async (
target: BinaryTarget,
@@ -136,22 +151,26 @@ export const resolveGhidraAnalysisProfile = async (
const dosMz = target.format === "dos-mz";
const dosCom = target.format === "dos-com";
const dos = dosMz || dosCom;
const mipsLanguage = ghidraMipsSharedLanguageOverride(target);
const pinnedLanguage = ghidraPinnedLanguage(target);
if (
mipsLanguage !== undefined &&
pinnedLanguage !== undefined &&
languageOverride !== undefined &&
(languageOverride.languageId !== mipsLanguage.languageId ||
(languageOverride.languageId !== pinnedLanguage.languageId ||
(languageOverride.compilerSpecId !== undefined &&
languageOverride.compilerSpecId !== mipsLanguage.compilerSpecId))
languageOverride.compilerSpecId !== pinnedLanguage.compilerSpecId))
)
return err(
new AnalysisUnsupportedTargetError(
"resolve_analysis_profile",
target.path,
"The caller-selected processor or compiler specification is outside the verified endian-matched MIPS32 shared-image profile. The requested override has not been changed.",
`The caller-selected processor or compiler specification is outside the verified ${
target.architecture === "riscv"
? "generic little-endian RISC-V"
: "endian-matched MIPS32 shared-image"
} profile. The requested override has not been changed.`,
),
);
const selectedLanguage = languageOverride ?? mipsLanguage;
const selectedLanguage = languageOverride ?? pinnedLanguage;
// DOS imports already force an admitted loader, language, and context.
if (dos && languageOverride !== undefined)
return Promise.resolve(
@@ -227,7 +246,7 @@ export const resolveGhidraAnalysisProfile = async (
? "auto-default"
: (selectedLanguage.compilerSpecId ?? "language-default"),
...(languageOverride === undefined
? mipsLanguage === undefined
? pinnedLanguage === undefined
? {}
: { language_selection: "provider-pinned-v1" }
: { language_selection: "configured-v1" }),
@@ -251,6 +270,7 @@ export const resolveGhidraAnalysisProfile = async (
: {}),
...analyzerParameters,
...ghidraMipsProfileParameters(target),
...ghidraRiscvProfileParameters(target),
...(pspExtension?.ok === true
? {
mips_support_lane: "psp-elf32-exec-eabi32-allegrex-v2",
+4 -1
View File
@@ -22,6 +22,7 @@ const WINDOWS_INSTALL = "C:\\tools\\ghidra_12.1.4_PUBLIC";
const WINDOWS_PROPERTIES = `${WINDOWS_INSTALL}\\Ghidra\\application.properties`;
const WINDOWS_HEADLESS = `${WINDOWS_INSTALL}\\support\\analyzeHeadless.bat`;
const WINDOWS_DECOMPILER = `${WINDOWS_INSTALL}\\Ghidra\\Features\\Decompiler\\os\\win_x86_64\\decompile.exe`;
const JAVA: GhidraJavaObservation = {
version: "21.0.11",
major: SUPPORTED_GHIDRA_JAVA_MAJOR,
@@ -43,7 +44,9 @@ const host = (
...overrides,
});
afterEach(() => vi.unstubAllEnvs());
afterEach(() => {
vi.unstubAllEnvs();
});
it("probes the selected Java environment without reading conflicting ambient settings", () => {
vi.stubEnv("REA_GHIDRA_CONTEXT", "ambient");
+10 -1
View File
@@ -734,7 +734,16 @@ const systemGhidraInstallationHost = (): GhidraInstallationHost => ({
probeJava(command, environment) {
const observed = spawnSync(
command,
["-XX:-UsePerfData", "-XshowSettings:properties", "-version"],
[
"-Dstdout.encoding=UTF-8",
"-Dstderr.encoding=UTF-8",
// JDK 17/18 use the legacy names; JDK 19+ use the names above.
"-Dsun.stdout.encoding=UTF-8",
"-Dsun.stderr.encoding=UTF-8",
"-XX:-UsePerfData",
"-XshowSettings:properties",
"-version",
],
{
encoding: "utf8",
env: environment,
+1
View File
@@ -66,6 +66,7 @@ const SUPPORTED_ARCHITECTURES = new Set([
"arm",
"arm64",
"mips",
"riscv",
]);
/** Ghidra candidate backed by an isolated ephemeral headless import. */
+4 -2
View File
@@ -14,6 +14,7 @@ import {
ghidraSeedLimitations,
} from "./GhidraAnalysisSeeds.js";
import {
ghidraPinnedLanguage,
ghidraProfileDisabledAnalyzers,
ghidraProfileLanguageOverride,
sameGhidraDisabledAnalyzers,
@@ -61,10 +62,10 @@ import {
ghidraMipsSoftFloatFailure,
ghidraMipsRelocatableLimitations,
ghidraMipsSharedLimitations,
ghidraMipsSharedLanguageOverride,
ghidraMipsSoftFloatLimitations,
isGhidraMipsSoftFloatTarget,
} from "./GhidraMipsProfile.js";
import { ghidraRiscvLimitations } from "./GhidraRiscvProfile.js";
import { unverifiedGhidraBuildLimitation } from "./GhidraInstallationPolicy.js";
import { GhidraHeadlessLauncher } from "./GhidraLauncher.js";
import { attestGhidraNativeLoadImage } from "./GhidraLoadImageAttest.js";
@@ -453,7 +454,7 @@ export const createGhidraProviderClient = (input: {
if (
!sameGhidraLanguageOverride(
languageOverride,
config.ghidraLanguageOverride ?? ghidraMipsSharedLanguageOverride(target),
config.ghidraLanguageOverride ?? ghidraPinnedLanguage(target),
)
)
return unavailableClient(
@@ -523,6 +524,7 @@ export const createGhidraProviderClient = (input: {
: []),
...ghidraMipsRelocatableLimitations(target),
...ghidraMipsSharedLimitations(target),
...ghidraRiscvLimitations(target),
];
const providerLimitations =
installation.platform === "win32"
+3
View File
@@ -1,5 +1,6 @@
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import { ghidraMipsUnsupportedReason } from "./GhidraMipsProfile.js";
import { ghidraRiscvUnsupportedReason } from "./GhidraRiscvProfile.js";
/** Allegrex's ELF machine declaration, not a filename or inferred instruction. */
export const isGhidraPspTarget = (target: BinaryTarget): boolean =>
@@ -13,6 +14,8 @@ export const isGhidraPspTarget = (target: BinaryTarget): boolean =>
export const ghidraProcessorUnsupportedReason = (
target: BinaryTarget,
): string | null => {
if (target.kind === "executable" && target.architecture === "riscv")
return ghidraRiscvUnsupportedReason(target);
if (
target.kind !== "executable" ||
target.architecture !== "mips" ||
+2 -2
View File
@@ -1,4 +1,4 @@
import type { BinaryArchitecture } from "../domain/binaryTargetTypes.js";
import type { HeaderArchitecture } from "../domain/binaryTargetTypes.js";
/** Bound Ghidra language for a caller-supplied raw-binary architecture. */
export interface GhidraRawBinaryLanguage {
@@ -13,7 +13,7 @@ export interface GhidraRawBinaryLanguage {
* Big-endian, Thumb-only, and Cortex-M variants are out of scope.
*/
export const ghidraRawBinaryLanguage = (
architecture: Exclude<BinaryArchitecture, "mips">,
architecture: HeaderArchitecture,
): GhidraRawBinaryLanguage => {
switch (architecture) {
case "x86":
+256
View File
@@ -0,0 +1,256 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { parseConfig } from "../config/parseConfig.js";
import type {
BinaryTarget,
RiscvElfMetadata,
} from "../domain/binaryTargetTypes.js";
import { silentLogger } from "../logger.js";
import { HopperProvider } from "../hopper/HopperProvider.js";
import { hopperLoaderArgsForTarget } from "../hopper/HopperAnalysisProfile.js";
import { IdaProvider } from "../ida/IdaProvider.js";
import { GhidraProvider } from "./GhidraProvider.js";
import { ghidraRiscvLimitations } from "./GhidraRiscvProfile.js";
import type { GhidraInstallationHost } from "./GhidraInstallation.js";
import {
ghidraPinnedLanguage,
ghidraProfileLanguageOverride,
sameGhidraLanguageOverride,
} from "./GhidraAnalysisProfile.js";
// Abbreviated from Ghidra 12.1.4 Processors/RISCV/data/languages/riscv.ldefs.
// The release tree ships no big-endian or ELF-class-mismatched RISC-V language.
const RISCV_LDEFS = `<?xml version="1.0" encoding="UTF-8"?>
<language_definitions>
<language processor="RISCV"
endian="little"
size="64"
variant="default"
version="1.4"
slafile="riscv.lp64d.sla"
processorspec="RV64.pspec"
id="RISCV:LE:64:default">
<description>RISC-V 64 little default</description>
<compiler name="gcc" spec="riscv64-fp.cspec" id="gcc"/>
</language>
<language processor="RISCV"
endian="little"
size="32"
variant="default"
version="1.4"
slafile="riscv.ilp32d.sla"
processorspec="RV32.pspec"
id="RISCV:LE:32:default">
<description>RISC-V 32 little default</description>
<compiler name="gcc" spec="riscv32-fp.cspec" id="gcc"/>
</language>
</language_definitions>
`;
// Fake installation inspection tests admission/profile contracts, not Ghidra.
const host: GhidraInstallationHost = {
platform: "linux",
architecture: "x64",
readText: () => "application.version=12.1.4\n",
executable: () => true,
probeJava: () => ({
version: "21.0.11",
major: 21,
home: "/jdk-21",
bits: 64,
runtime: "jdk",
}),
};
const configOf = (
installDir: string,
environment: Record<string, string> = {},
) => {
const parsed = parseConfig({
GHIDRA_INSTALL_DIR: installDir,
...environment,
});
if (!parsed.ok) throw parsed.error;
return parsed.value;
};
const target = (change: Partial<RiscvElfMetadata> = {}): BinaryTarget => ({
path: "/fixture.elf",
sha256: "a".repeat(64),
kind: "executable",
format: "elf",
architecture: "riscv",
availableArchitectures: ["riscv"],
riscv: {
elfClass: 64,
byteOrder: "little",
type: 2,
flags: 0x5,
...change,
},
});
let installDir = "";
beforeAll(async () => {
installDir = await mkdtemp(join(tmpdir(), "rea-ghidra-riscv-languages-"));
const languages = join(installDir, "Ghidra/Processors/RISCV/data/languages");
await mkdir(languages, { recursive: true });
await writeFile(join(languages, "riscv.ldefs"), RISCV_LDEFS);
});
afterAll(async () => {
await rm(installDir, { recursive: true, force: true });
});
const ghidra = (environment: Record<string, string> = {}) =>
new GhidraProvider(configOf(installDir, environment), silentLogger, {}, host);
describe("bounded Ghidra RISC-V admission", () => {
it("pins the generic language per width instead of accepting the header guess", async () => {
const provider = ghidra();
const rv64 = target();
const rv32 = target({ elfClass: 32, flags: 0x4 });
expect(provider.inspectTargetSupport(rv64).status).toBe("supported");
expect(provider.inspectTargetSupport(rv32).status).toBe("supported");
expect(ghidraPinnedLanguage(rv64)).toEqual({
languageId: "RISCV:LE:64:default",
compilerSpecId: "gcc",
});
expect(ghidraPinnedLanguage(rv32)).toEqual({
languageId: "RISCV:LE:32:default",
compilerSpecId: "gcc",
});
const wide = await provider.resolveAnalysisProfile(rv64);
const narrow = await provider.resolveAnalysisProfile(rv32);
if (!wide.ok || !narrow.ok)
throw new Error("expected admitted RISC-V profiles");
expect(wide.value.profile?.parameters.riscv_elf).toEqual({
elf_class: 64,
byte_order: "little",
type: 2,
flags: 5,
float_abi_double: true,
rvc_declared: true,
rve_declared: false,
tso_declared: false,
});
expect(wide.value.profile?.parameters.riscv_support_lane).toBe(
"elf64-exec-le-double-float-generic-v1",
);
expect(narrow.value.profile?.parameters.riscv_support_lane).toBe(
"elf32-exec-le-double-float-generic-v1",
);
expect(narrow.value.profile?.parameters.riscv_elf).toMatchObject({
elf_class: 32,
rvc_declared: false,
});
expect(wide.value.profile?.digest).not.toBe(narrow.value.profile?.digest);
});
it("commits the pinned language the session readback expects", async () => {
// A pinned lane has one owner: the committed profile and the open_binary
// readback must agree without either re-deriving a different family's pin.
const provider = ghidra();
for (const value of [target(), target({ elfClass: 32 })]) {
const resolved = await provider.resolveAnalysisProfile(value);
if (!resolved.ok || resolved.value.profile === null)
throw new Error("expected a RISC-V profile");
expect(
sameGhidraLanguageOverride(
ghidraProfileLanguageOverride(resolved.value.profile.parameters),
ghidraPinnedLanguage(value),
),
).toBe(true);
}
});
it.each([
[{ byteOrder: "big" }, "little-endian"],
[{ type: 3 }, "fixed-address ET_EXEC only"],
[{ type: 1 }, "fixed-address ET_EXEC only"],
[{ type: 0xffa0 }, "e_type 65440"],
[{ flags: 0xc }, "EF_RISCV_RVE"],
[{ flags: 0x14 }, "EF_RISCV_TSO"],
[{ flags: 0x1 }, "soft-float convention"],
[{ flags: 0x3 }, "single-float"],
[{ flags: 0x7 }, "quad-float"],
[{ flags: 0x8000_0005 }, "(0x80000000)"],
[{ flags: 0x24 }, "bits outside the verified lane"],
] satisfies [Partial<RiscvElfMetadata>, string][])(
"refuses an unverified RISC-V interpretation %j",
async (change, reason) => {
const provider = ghidra();
const value = target(change);
expect(provider.inspectTargetSupport(value)).toMatchObject({
status: "unsupported",
code: "architecture_unsupported",
reason: expect.stringContaining(reason),
});
expect(await provider.resolveAnalysisProfile(value)).toEqual(
expect.objectContaining({ ok: false }),
);
expect(ghidraPinnedLanguage(value)).toBeUndefined();
},
);
it("refuses a caller-selected processor outside the verified generic lane", async () => {
const resolved = await ghidra({
REA_GHIDRA_LANGUAGE_ID: "RISCV:LE:32:AndeStar_v5",
}).resolveAnalysisProfile(target({ elfClass: 32 }));
expect(resolved.ok).toBe(false);
if (resolved.ok) throw new Error("expected a refusal");
expect(resolved.error.message).toContain("generic little-endian RISC-V");
});
it("keeps a caller-selected language that matches the pin as configured", async () => {
const resolved = await ghidra({
REA_GHIDRA_LANGUAGE_ID: "RISCV:LE:64:default",
REA_GHIDRA_COMPILER_SPEC_ID: "gcc",
}).resolveAnalysisProfile(target());
if (!resolved.ok || resolved.value.profile === null)
throw new Error("expected a configured profile");
expect(resolved.value.profile.parameters.language_selection).toBe(
"configured-v1",
);
expect(resolved.value.profile.parameters.language_id).toBe(
"RISCV:LE:64:default",
);
});
it("states the linkage claim boundary in the lane limitations", () => {
const notes = ghidraRiscvLimitations(target());
expect(notes).toHaveLength(3);
expect(notes.join(" ")).toContain("does not establish static linkage");
expect(notes[0]).toContain("RISCV:LE:64:default");
expect(
ghidraRiscvLimitations({
path: "/other.elf",
sha256: "b".repeat(64),
kind: "executable",
format: "elf",
architecture: "x86_64",
availableArchitectures: ["x86_64"],
}),
).toEqual([]);
});
it("keeps RISC-V out of unverified Hopper and IDA adapter lanes", () => {
const hopper = new HopperProvider(
configOf("/hopper"),
silentLogger,
{},
"linux",
);
expect(hopper.inspectTargetSupport(target()).status).toBe("unsupported");
expect(hopperLoaderArgsForTarget(target()).ok).toBe(false);
expect(hopperLoaderArgsForTarget(target())).toEqual(
expect.objectContaining({
ok: false,
error: expect.objectContaining({
message: expect.stringContaining("RISC-V"),
}),
}),
);
const ida = new IdaProvider(configOf("/ida"), {});
expect(ida.inspectTargetSupport(target()).status).toBe("unsupported");
});
});
+129
View File
@@ -0,0 +1,129 @@
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import type { JsonValue } from "../domain/jsonValue.js";
import type { GhidraLanguageOverride } from "../config/ghidraLanguageOverride.js";
/**
* Ghidra's generic RISC-V languages model the double-float ABI: the shipped
* `riscv.ldefs` maps `RISCV:LE:{32,64}:default` to the `riscv{32,64}-fp.cspec`
* compiler specification. Soft, single and quad ABI declarations therefore need
* their own verification rather than inheriting this lane.
*/
const EF_RISCV_RVC = 0x0001;
const EF_RISCV_FLOAT_ABI = 0x0006;
const EF_RISCV_FLOAT_ABI_DOUBLE = 0x0004;
const EF_RISCV_RVE = 0x0008;
const EF_RISCV_TSO = 0x0010;
const ET_REL = 1;
const ET_EXEC = 2;
const ET_DYN = 3;
/**
* Bits this lane reads. Anything else in `e_flags` — reserved today or defined by
* a newer psABI — is refused rather than assumed compatible.
*/
const EF_RISCV_DECLARED_BITS = EF_RISCV_RVC | EF_RISCV_FLOAT_ABI;
/** Header declarations outside the verified generic-language lane. */
const riscvUnsupportedFloatAbi = (abi: number): string =>
abi === 0
? "unspecified (soft-float convention)"
: abi === 0x0002
? "single-float"
: "quad-float";
/**
* Keep family recognition separate from the deliberately bounded Ghidra lane.
*
* Ghidra 12.1.4 selects `RISCV:LE:32:AndeStar_v5` for an ordinary RV32 ELF: its
* `riscv.opinion` pins `variant="default"` for the 64-bit constraint and leaves
* the 32-bit one with the comment that the loader "must choose between 'default'
* and 'AndeStar' variants". The lane therefore pins the language instead of
* accepting the header-based guess.
*/
export const ghidraRiscvUnsupportedReason = (
target: BinaryTarget,
): string | null => {
if (target.kind !== "executable" || target.architecture !== "riscv")
return null;
const metadata = target.riscv;
if (target.format !== "elf")
return "RISC-V analysis requires ELF header metadata; a family name alone does not select an ISA or ABI.";
if (metadata.byteOrder !== "little")
return "This Ghidra RISC-V lane supports little-endian images only; Ghidra 12.1.4 ships no big-endian RISC-V language.";
if (metadata.elfClass !== 32 && metadata.elfClass !== 64)
return "This Ghidra RISC-V lane requires ELF32 or ELF64; the class decides which generic language is pinned.";
if ((metadata.flags & EF_RISCV_RVE) !== 0)
return "EF_RISCV_RVE declares the embedded 15-register ABI, which needs its own processor specification and compiler verification.";
if ((metadata.flags & EF_RISCV_TSO) !== 0)
return "EF_RISCV_TSO declares the total-store-order memory model; this lane verifies the baseline model only.";
if (metadata.type !== ET_EXEC)
return `This Ghidra RISC-V lane supports fixed-address ET_EXEC only; ${
metadata.type === ET_REL
? "relocatable"
: metadata.type === ET_DYN
? "shared and position-independent"
: `e_type ${metadata.type}`
} images need separate load verification. ET_EXEC alone does not establish static linkage, which this lane does not inspect.`;
const abi = metadata.flags & EF_RISCV_FLOAT_ABI;
if (abi !== EF_RISCV_FLOAT_ABI_DOUBLE)
return `This Ghidra RISC-V lane requires the EF_RISCV_FLOAT_ABI_DOUBLE declaration (${riscvUnsupportedFloatAbi(
abi,
)} observed); the pinned generic language uses the double-float compiler specification.`;
const unsupportedFlags = (metadata.flags & ~EF_RISCV_DECLARED_BITS) >>> 0;
if (unsupportedFlags !== 0)
return `RISC-V e_flags declares bits outside the verified lane (0x${unsupportedFlags.toString(16)}); reserved or newer declarations are not treated as compatible with this profile.`;
return null;
};
/** Pin a language only for declarations admitted by the same lane boundary. */
export const ghidraRiscvLanguageOverride = (
target: BinaryTarget,
): GhidraLanguageOverride | undefined =>
target.kind === "executable" &&
target.architecture === "riscv" &&
ghidraRiscvUnsupportedReason(target) === null
? {
languageId: `RISCV:LE:${target.riscv.elfClass}:default`,
compilerSpecId: "gcc",
}
: undefined;
/** Commit source ELF interpretation without confusing target ISA with host CPU. */
export const ghidraRiscvProfileParameters = (
target: BinaryTarget,
): Readonly<Record<string, JsonValue>> =>
target.kind === "executable" &&
target.architecture === "riscv" &&
target.riscv !== undefined
? {
riscv_elf: {
elf_class: target.riscv.elfClass,
byte_order: target.riscv.byteOrder,
type: target.riscv.type,
flags: target.riscv.flags,
float_abi_double:
(target.riscv.flags & EF_RISCV_FLOAT_ABI) ===
EF_RISCV_FLOAT_ABI_DOUBLE,
rvc_declared: (target.riscv.flags & EF_RISCV_RVC) !== 0,
rve_declared: (target.riscv.flags & EF_RISCV_RVE) !== 0,
tso_declared: (target.riscv.flags & EF_RISCV_TSO) !== 0,
},
riscv_support_lane:
target.riscv.elfClass === 32
? "elf32-exec-le-double-float-generic-v1"
: "elf64-exec-le-double-float-generic-v1",
}
: {};
/** The pinned language is an analysis convention, not proof of the producer. */
export const ghidraRiscvLimitations = (
target: BinaryTarget,
): readonly string[] =>
target.kind === "executable" && target.architecture === "riscv"
? [
`RISC-V ELF: REA pins Ghidra's generic \`RISCV:LE:${target.riscv.elfClass}:default\` language with the \`gcc\` compiler specification instead of accepting the loader's header-based choice, which selects \`RISCV:LE:32:AndeStar_v5\` for ordinary RV32 images on Ghidra 12.1.4.`,
"The pinned generic language models the double-float ABI declared by `e_flags`; the declaration is recorded as an ELF fact and is not evidence of the original compiler or of verified instruction semantics.",
"Admission reads `e_type` only: a fixed-address ET_EXEC declaration does not establish static linkage, and program headers (`PT_INTERP`, dynamic dependencies) are not inspected by this lane.",
]
: [];
+15 -6
View File
@@ -8,8 +8,10 @@ import type {
import { createAnalysisProfile } from "../domain/analysisProfile.js";
import {
isMachOFormat,
isElfMetadataArchitecture,
type BinaryTarget,
type BinaryArchitecture,
type ElfMetadataArchitecture,
type HeaderArchitecture,
} from "../domain/binaryTargetTypes.js";
import {
AnalysisCancelledError,
@@ -101,6 +103,15 @@ export const resolveHopperAnalysisProfile = async (
});
};
/** Ghidra owns these lanes; Hopper names the family it declines rather than guessing. */
const HOPPER_DECLINED_ARCHITECTURE_LABELS: Record<
ElfMetadataArchitecture,
string
> = {
mips: "MIPS",
riscv: "RISC-V",
};
/** Derive Hopper's complete non-interactive CLI loader selection. */
export const hopperLoaderArgsForTarget = (
target: BinaryTarget,
@@ -113,12 +124,12 @@ export const hopperLoaderArgsForTarget = (
if (target.kind !== "executable")
return err(new ProviderAdapterError("hopper", "resolve_analysis_profile"));
const architecture = target.architecture;
if (architecture === "mips")
if (isElfMetadataArchitecture(architecture))
return err(
new AnalysisCapabilityUnavailableError(
"hopper",
"resolve_analysis_profile",
"REA's Hopper adapter does not admit MIPS targets. Select a verified Ghidra MIPS profile instead.",
`REA's Hopper adapter does not admit ${HOPPER_DECLINED_ARCHITECTURE_LABELS[architecture]} targets. Select a verified Ghidra ${HOPPER_DECLINED_ARCHITECTURE_LABELS[architecture]} profile instead.`,
),
);
const flag = hopperArchitectureFlag(architecture);
@@ -151,9 +162,7 @@ export const hopperLoaderArgsForTarget = (
}
};
const hopperArchitectureFlag = (
architecture: Exclude<BinaryArchitecture, "mips">,
): string => {
const hopperArchitectureFlag = (architecture: HeaderArchitecture): string => {
switch (architecture) {
case "x86":
return "--intel-32";
+5 -1
View File
@@ -4,6 +4,7 @@ import { z } from "zod";
import {
isMachOFormat,
isElfMetadataArchitecture,
type BinaryTarget,
} from "../domain/binaryTargetTypes.js";
import { BinaryTargetError } from "../domain/configurationErrors.js";
@@ -47,7 +48,10 @@ export const resolveHopperMachOImage = async (
),
);
if (signal?.aborted) return err(new AnalysisCancelledError("open_binary"));
if (!isMachOFormat(target.format) || target.architecture === "mips")
if (
!isMachOFormat(target.format) ||
isElfMetadataArchitecture(target.architecture)
)
return invalid("Hopper FAT64 image preparation requires a Mach-O target");
let file: FileHandle;
try {
+8 -3
View File
@@ -16,7 +16,10 @@ import type {
} from "../application/AnalysisProvider.js";
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
import type { AppConfig } from "../config/types.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import {
isElfMetadataArchitecture,
type BinaryTarget,
} from "../domain/binaryTargetTypes.js";
import type { JsonValue } from "../domain/jsonValue.js";
import type { Logger } from "pino";
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
@@ -118,11 +121,13 @@ export class HopperProvider implements AnalysisProviderCandidate {
reason: null,
diagnostics,
};
if (target.architecture === "mips")
if (isElfMetadataArchitecture(target.architecture))
return {
status: "unsupported",
code: "architecture_unsupported",
reason: "REA's Hopper adapter does not admit MIPS targets.",
reason: `REA's Hopper adapter does not admit ${
target.architecture === "mips" ? "MIPS" : "RISC-V"
} targets.`,
diagnostics,
};
if (target.format === "raw-binary")
+11 -4
View File
@@ -1,7 +1,10 @@
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import { createAnalysisProfile } from "../domain/analysisProfile.js";
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import {
isElfMetadataArchitecture,
type BinaryTarget,
} from "../domain/binaryTargetTypes.js";
import { err, ok } from "../domain/result.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import type {
@@ -102,12 +105,16 @@ export class IdaProvider implements AnalysisProviderCandidate {
target_kind: target.kind,
target_format: target.format,
};
if (target.kind === "executable" && target.architecture === "mips")
if (
target.kind === "executable" &&
isElfMetadataArchitecture(target.architecture)
)
return {
status: "unsupported",
code: "architecture_unsupported",
reason:
"REA's IDA adapter has no verified MIPS lane. Select a supported Ghidra profile instead.",
reason: `REA's IDA adapter has no verified ${
target.architecture === "mips" ? "MIPS" : "RISC-V"
} lane. Select a supported Ghidra profile instead.`,
diagnostics,
};
if (target.kind !== "executable")
+10 -2
View File
@@ -662,6 +662,7 @@ export const observeSettlement = async (
}
const started = Date.now();
let consecutiveEmpty = 0;
let sawLive = false;
let deadlineReached = false;
while (!deadlineReached) {
onLiveProgress?.();
@@ -685,13 +686,20 @@ export const observeSettlement = async (
recordEvent("lifecycle", 1);
return { state: "quiesced", elapsed_ms: Date.now() - started };
}
} else consecutiveEmpty = 0;
} else {
consecutiveEmpty = 0;
sawLive = true;
}
deadlineReached = Date.now() - started >= settleMs;
if (deadlineReached) break;
await delay(50, undefined, { signal });
}
recordEvent("lifecycle", 1);
return { state: "alive_at_deadline", elapsed_ms: Date.now() - started };
// A lone empty observation proves neither liveness nor quiescence; only an
// observed live member justifies alive_at_deadline.
return sawLive
? { state: "alive_at_deadline", elapsed_ms: Date.now() - started }
: { state: "unverifiable", elapsed_ms: Date.now() - started };
};
/** Name why an attempt is unverified; a blank message never reaches the capture schema. */
@@ -30,7 +30,7 @@ type CaptureOutcome =
| { readonly kind: "partial"; readonly partial: unknown };
const captureScenario = (
mode: "cooperative" | "ignoring" | "descendant",
mode: "cooperative" | "ignoring" | "delayed-ignoring" | "descendant",
root: string,
additions: CaptureScenario = {},
finalExitCode?: number,
@@ -453,11 +453,13 @@ captureTest(
"rea-finalization-cli-cancel-",
);
const cliScenarioPath = join(cliRoot, "scenario.json");
// Match the other finalization cases' observation window. The old 300 ms
// deadline could signal the fixture before it installed its handlers.
await writeFile(
cliScenarioPath,
JSON.stringify(
captureScenario("ignoring", cliRoot, {
timeout_ms: 300,
captureScenario("delayed-ignoring", cliRoot, {
timeout_ms: 1_500,
finalization_ms: 20_000,
}),
),
@@ -527,18 +529,36 @@ captureTest(
const mcpRoot = await createTestTempDirectory(
"rea-finalization-mcp-cancel-",
);
const { client } = await connectLocalToolsMcp();
const { client, call: callTool } = await connectLocalToolsMcp();
const controller = new AbortController();
const call = client.callTool(
{
name: "capture_process_scenario",
arguments: captureScenario("ignoring", mcpRoot, {
timeout_ms: 300,
finalization_ms: 20_000,
}),
},
{ signal: controller.signal },
let reportStarted: (() => void) | undefined;
const captureStarted = new Promise<void>((resolve) => {
reportStarted = resolve;
});
const call = callTool(
"capture_process_scenario",
captureScenario("delayed-ignoring", mcpRoot, {
timeout_ms: 1_500,
finalization_ms: 20_000,
}),
{ signal: controller.signal, onprogress: () => reportStarted?.() },
);
// Observe rejection immediately: a failed readiness wait must not leave a
// pending request to reject unhandled when the fixture closes its client.
// The original promise remains available for the cancellation assertion.
const observedCall = call.catch(() => undefined);
onTestFinished(async () => {
controller.abort();
await observedCall;
});
// Start the fixture's readiness wait after the server begins this capture;
// SDK schema preparation before dispatch is not target startup time.
await Promise.race([
captureStarted,
call.then(() => {
throw new Error("MCP capture completed before reporting startup");
}),
]);
const mcpFixturePid = await waitForFinalization(mcpRoot);
const mcpStarted = Date.now();
controller.abort(new Error("cancel finalization acceptance fixture"));
@@ -548,7 +568,7 @@ captureTest(
).rejects.toThrow("cancel finalization acceptance fixture");
await waitForProcessExit(mcpFixturePid);
await expect(
client.listTools(),
client.listTools(undefined, { cacheMode: "refresh" }),
"the server accepts a follow-up request after cancelling the capture",
).resolves.toMatchObject({ tools: expect.any(Array) });
expect(
@@ -0,0 +1,154 @@
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { expect } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
const cases = [
{
client: "cursor",
path: ".cursor/mcp.json",
text: "[]",
diagnostic: "The configuration root must be an object",
},
{
client: "cursor",
path: ".cursor/mcp.json",
text: "null",
diagnostic: "The configuration root must be an object",
},
{
client: "cursor",
path: ".cursor/mcp.json",
text: '{"mcpServers":null,"keep":1}',
diagnostic: '"mcpServers" must be an object',
},
{
client: "cursor",
path: ".cursor/mcp.json",
text: '{"mcpServers":"x","keep":1}',
diagnostic: '"mcpServers" must be an object',
},
{
client: "pi",
path: ".pi/agent/mcp.json",
text: '{"mcpServers":[]}',
diagnostic: '"mcpServers" must be an object',
},
{
client: "codex",
path: ".codex/config.toml",
text: 'mcp_servers = "x"\n',
diagnostic: '"mcp_servers" must be an object',
},
{
client: "opencode",
path: ".config/opencode/opencode.json",
text: '{"mcp":{"servers":null}}',
diagnostic: '"mcp.servers" must be an object',
},
{
client: "hermes",
path: ".hermes/config.yaml",
text: "- item\n",
diagnostic: "The configuration root must be an object",
},
{
client: "hermes",
path: ".hermes/config.yaml",
text: "mcp_servers: null\nkeep: 1\n",
diagnostic: '"mcp_servers" must be an object',
},
{
client: "cursor",
path: ".cursor/mcp.json",
text: "not-json",
diagnostic: "Configuration is not valid JSON",
syntax: "JSON",
},
{
client: "hermes",
path: ".hermes/config.yaml",
text: "mcp_servers: [\n",
diagnostic: "Configuration is not valid YAML",
syntax: "YAML",
},
{
client: "dsh",
path: ".dsh/cordis.patch.yml",
text: "- insert: [\n",
diagnostic: "Configuration is not valid YAML",
syntax: "YAML",
},
] as const;
for (const scenario of cases)
cliTest(
`setup and uninstall explain ${scenario.client} configuration ${scenario.text}`,
async ({ cli }) => {
const home = await createTestTempDirectory(
"rea-client-config-diagnostic-",
);
const config = join(home, scenario.path);
await mkdir(dirname(config), { recursive: true });
await writeFile(config, scenario.text);
const environment = {
HOME: home,
USERPROFILE: home,
XDG_CONFIG_HOME: join(home, ".config"),
HERMES_HOME: join(home, ".hermes"),
DSH_HOME: join(home, ".dsh"),
};
const setup = await cli.run({
arguments: [
"setup",
"--client",
scenario.client,
"--skill=false",
"--yes",
"--json",
],
cwd: home,
environment,
});
expect(setup.exitCode).toBe(1);
expect(setup.json).toMatchObject({
status: "needs_human",
plannedActions: [],
appliedActions: [],
});
if ("syntax" in scenario) {
expect(setup.json).toMatchObject({ remediation: expect.any(String) });
if (scenario.syntax === "JSON")
expect(setup.stdout).toContain("Invalid JSON/JSONC");
} else {
expect(setup.json).toMatchObject({
remediation: expect.stringContaining(scenario.diagnostic),
});
expect(setup.stdout).not.toContain("invalid_type");
}
const uninstall = await cli.run({
arguments: ["uninstall", "--json"],
cwd: home,
environment,
});
expect(uninstall.exitCode).toBe(1);
expect(uninstall.json).toMatchObject({
status: "failed",
items: expect.arrayContaining([
expect.objectContaining({
name: scenario.client,
status: "failed",
detail: expect.stringContaining(scenario.diagnostic),
}),
]),
});
if (!("syntax" in scenario))
expect(uninstall.stdout).not.toContain("not valid JSON");
expect(await readFile(config, "utf8")).toBe(scenario.text);
expect(await readdir(dirname(config))).toEqual([
scenario.path.split("/").at(-1),
]);
},
);
@@ -0,0 +1,78 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { build } from "plist";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { deeplyNestedXmlPlist } from "../../fixtures/keyedArchiveXmlNesting.js";
import { cliTest } from "../../support/cli/cliFixture.js";
for (const [name, xml] of [
["dictionaries", deeplyNestedXmlPlist(3000)],
[
"quoted attributes",
deeplyNestedXmlPlist(3000).replaceAll("<dict>", '<dict note="/>">'),
],
[
"plist wrappers",
"<plist>".repeat(3000) + "<string>x</string>" + "</plist>".repeat(3000),
],
] as const) {
cliTest(
`compiled CLI reports deeply nested XML ${name} as the decode limit`,
async ({ cli }) => {
const root = await createTestTempDirectory("rea-keyed-cli-depth-");
const path = join(root, "Deep.plist");
await writeFile(path, xml);
const output = await cli.run({
arguments: ["inspect-keyed-archive", path, "--json"],
environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" },
});
expect(output.exitCode, output.stderr).toBe(1);
expect(output.json).toMatchObject({
code: "truncated",
category: "truncated",
details: {
reason: "limit",
truncated: true,
detail:
"Cannot decode selected Foundation keyed archive: Plist exceeds 128 nesting levels",
},
});
},
);
}
cliTest(
"compiled CLI decodes a shallow archive with opaque DOCTYPE markup",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-keyed-cli-doctype-");
const path = join(root, "Shallow.plist");
const archive = build({
$archiver: "NSKeyedArchiver",
$version: 100000,
$objects: ["$null", "leaf"],
$top: { root: { UID: 1 } },
});
await writeFile(
path,
archive.replace(
/<!DOCTYPE[^>]*>/u,
`<!DOCTYPE plist [<!ENTITY first "unused"><!ENTITY second "${"<dict>".repeat(130)}">]>`,
),
);
const output = await cli.run({
arguments: ["inspect-keyed-archive", path, "--json"],
environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" },
});
expect(output.exitCode, output.stderr).toBe(0);
expect(parseEvidence(output.json).normalized_result).toMatchObject({
archive_format: "xml-plist",
objects: expect.arrayContaining([
expect.objectContaining({ value: "leaf" }),
]),
});
},
);
@@ -0,0 +1,336 @@
import { execFile } from "node:child_process";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { promisify } from "node:util";
import { expect, it } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const execute = promisify(execFile);
// A recording port checks the experimental producer/consumer representation.
// Real provider verification remains in the separate native experiment receipts.
const generate = `
import {measureNativeEntry} from './experiments/analysis-efficiency/native-entry.mjs';
const sha='0'.repeat(64);
const duplicate=process.argv[1]==='duplicate';
const stale=process.argv[1]==='stale-dos';
const collision=process.argv[1];
const dos=process.argv[1]==='dos'||stale||collision==='load-collision';
const dossier={procedure:{address:'0x1000'},pseudocode:'return;',callers:[],callees:[]};
class ToolFailure extends Error {}
const build=async digit=>{
const parent='ev_'+digit.repeat(63)+'0';
let ordinal=0;
const call=async(name,args)=>{
if(stale)process.stdout.write(name+'\\n');
if(name==='open_binary'||name==='close_binary')return {};
if(name==='inspect_native_load_image')return {evidence_id:'ev_'+digit.repeat(63)+'6',normalized_result:{status:'verified',target_sha256:stale?'f'.repeat(64):sha,entry:{linear_address:'0x1000'}}};
if(name==='resolve_containing_procedure')return {evidence_id:'ev_'+digit.repeat(63)+'7',normalized_result:{found:true}};
if(name==='analyze_function')return {evidence_id:parent,subject:{digest:{sha256:sha}},normalized_result:dossier};
const id='ev_'+digit.repeat(63)+(collision==='containing-collision'?7:collision==='load-collision'?6:duplicate?1:++ordinal);
if(args.view.kind==='summary')return {evidence_id:id};
const facet=args.view.facet;
const item=facet==='pseudocode'?{text:dossier.pseudocode,unit:'utf16-code-units'}:dossier[facet];
return {evidence_id:id,normalized_result:{parent_evidence_id:parent,artifact:{sha256:sha},item,coverage:{exhausted:true},limitations:[],unknowns:[]}};
};
return measureNativeEntry({seed:dos?undefined:'0x1000',values:{path:'/selected/input','native-question':'entry-views'},call,inputSha:sha,ToolFailure});
};
process.stdout.write(JSON.stringify([await build('1'),await build('2')]));
`;
it.each([
"observed",
"missing",
"errored",
"wrong-target",
"wrong-entry",
"unreferenced",
])(
"binds the DOS model grade to its observed load image: %s",
async (scenario) => {
// Synthetic transcripts exercise grading; they are not real-provider or
// resource measurements. Real DOS transcripts are replayed separately.
const root = await createTestTempDirectory("rea-dos-grader-");
const trial = join(root, "trial");
const referenceDirectory = join(root, "reference");
await mkdir(trial);
await mkdir(referenceDirectory);
const sha = "0".repeat(64);
const imageId = "ev_" + "1".repeat(64);
const procedureId = "ev_" + "2".repeat(64);
const procedure = {
address: "0x1000",
name: "entry",
classification: "observed",
signature: null,
locals: [],
body: { ranges: [], total_bytes: 0 },
};
const loadImage = {
status: "verified",
target_sha256: sha,
entry: { linear_address: "0x1000" },
};
const reference = {
artifact_sha256: sha,
entry_address: "0x1000",
caller_count: 0,
callee_count: 0,
pseudocode_prefix: "return;",
containing_procedure: { procedure },
procedure,
load_image: loadImage,
};
const answer = {
...reference,
load_image_status: "verified",
evidence_ids: [
procedureId,
...(["missing", "errored", "unreferenced"].includes(scenario)
? []
: [imageId]),
],
};
const event = (tool: string, status: string, value: unknown) => ({
type: "item.completed",
item: {
type: "mcp_tool_call",
tool,
status,
arguments: {},
result: { structured_content: value },
},
});
const events = [
event("analyze_function", "completed", { evidence_id: procedureId }),
];
if (scenario !== "missing")
events.push(
event(
"inspect_native_load_image",
scenario === "errored" ? "failed" : "completed",
scenario === "errored"
? { error: { code: "unsupported_target" } }
: {
operation: "inspect_native_load_image",
evidence_id: imageId,
normalized_result: {
...loadImage,
target_sha256:
scenario === "wrong-target" ? "f".repeat(64) : sha,
entry: {
linear_address:
scenario === "wrong-entry" ? "0x2000" : "0x1000",
},
},
},
),
);
const resourceLog = join(root, "resource.log");
const output = join(root, "review.json");
for (const [path, value] of [
[
join(trial, "summary.json"),
{
completed_mcp_calls: [
{ tool: "close_binary", status: "completed", arguments: {} },
],
},
],
[join(trial, "trial-settings.json"), {}],
[join(trial, "cleanup.json"), { profile_removed: true }],
[join(trial, "answer.txt"), answer],
[join(referenceDirectory, "answer.json"), reference],
[
resourceLog,
{
resource_scope: "synthetic-grader-fixture",
abort: null,
resource_counters_verified: true,
resource_events_final: {
"memory.events": { high: 0, max: 0, oom: 0, oom_kill: 0 },
"pids.events": { max: 0 },
},
},
],
] as const)
await writeFile(path, JSON.stringify(value));
await writeFile(
join(trial, "events.jsonl"),
events.map((value) => JSON.stringify(value)).join("\n"),
);
const command = execute("python3", [
resolve("experiments/analysis-efficiency/review-model.py"),
"--domain",
"native",
"--trial",
trial,
"--reference",
referenceDirectory,
"--resource-log",
resourceLog,
"--output",
output,
]);
if (scenario === "observed") await command;
else await expect(command).rejects.toMatchObject({ code: 1 });
const report = JSON.parse(await readFile(output, "utf8"));
expect(report.all_selected_checks_pass).toBe(scenario === "observed");
if (["missing", "errored", "unreferenced"].includes(scenario))
expect(report.checks.load_image_source_observed_and_referenced).toBe(
false,
);
if (scenario === "wrong-target")
expect(report.checks["load_image.target_sha256"]).toBe(false);
if (scenario === "wrong-entry")
expect(report.checks["load_image.source_entry"]).toBe(false);
},
);
it("retains the DOS load-image source for its facts and derived entry seed", async () => {
const { stdout } = await execute(process.execPath, [
"--input-type=module",
"-e",
generate,
"dos",
]);
const [answer] = JSON.parse(stdout);
expect(answer).toMatchObject({
entry_address: "0x1000",
load_image: { status: "verified", entry: { linear_address: "0x1000" } },
evidence_ids: { native_load_image: "ev_" + "1".repeat(63) + "6" },
});
});
it("rejects a stale DOS load image before using its entry and closes the session", async () => {
await expect(
execute(process.execPath, [
"--input-type=module",
"-e",
generate,
"stale-dos",
]),
).rejects.toMatchObject({
stdout: "open_binary\ninspect_native_load_image\nclose_binary\n",
stderr: expect.stringContaining(
"The DOS load image belongs to a different target",
),
});
});
it.each(["containing-collision", "load-collision"])(
"rejects a retained view that reuses an earlier producer identity: %s",
async (collision) => {
await expect(
execute(process.execPath, [
"--input-type=module",
"-e",
generate,
collision,
]),
).rejects.toMatchObject({
stderr: expect.stringContaining(
"Retained native view reused an Evidence identity",
),
});
},
);
it("rejects a producer that reuses a view identity across facets", async () => {
await expect(
execute(process.execPath, [
"--input-type=module",
"-e",
generate,
"duplicate",
]),
).rejects.toMatchObject({
stderr: expect.stringContaining(
"Retained native view reused an Evidence identity",
),
});
});
it.each(["references", "item", "coverage", "limitations", "unknowns"])(
"separates native references while comparing %s exactly",
async (change) => {
const { stdout: generated } = await execute(process.execPath, [
"--input-type=module",
"-e",
generate,
]);
const [leftAnswer, rightAnswer] = JSON.parse(generated);
if (change === "item")
rightAnswer.retained_views[0].item.address = "0x1001";
if (change === "coverage")
rightAnswer.retained_views[0].coverage.exhausted = false;
if (change === "limitations")
rightAnswer.retained_views[0].limitations.push("partial");
if (change === "unknowns")
rightAnswer.retained_views[0].unknowns.push("unresolved");
const root = await createTestTempDirectory("rea-native-comparison-");
const left = join(root, "left");
const right = join(root, "right");
const summary = {
artifact: "sharex.exe",
native_question: "entry-views",
provider_search_path_sha256: "0".repeat(64),
native_workflow_source_sha256: "1".repeat(64),
calls: [],
failure: null,
};
for (const [directory, answer] of [
[left, leftAnswer],
[right, rightAnswer],
]) {
await mkdir(directory);
await writeFile(join(directory, "summary.json"), JSON.stringify(summary));
await writeFile(join(directory, "answer.json"), JSON.stringify(answer));
}
const output = join(root, "comparison.json");
const command = execute(process.execPath, [
resolve("experiments/analysis-efficiency/compare.mjs"),
"--left",
left,
"--right",
right,
"--output",
output,
]);
if (change !== "references") {
await expect(command).rejects.toMatchObject({
code: 1,
stdout: expect.stringContaining('"retained_views"'),
});
return;
}
const { stdout } = await command;
expect(JSON.parse(stdout)).toMatchObject({
settings_match: true,
facts_match: true,
preserved_run_specific_values: [
{
side: "left",
path: ["evidence_ids"],
value: leftAnswer.evidence_ids,
},
{
side: "right",
path: ["evidence_ids"],
value: rightAnswer.evidence_ids,
},
],
});
expect(leftAnswer.evidence_ids).not.toEqual(rightAnswer.evidence_ids);
expect(leftAnswer.evidence_ids.retained_views[0]).toMatchObject({
evidence_id: "ev_" + "1".repeat(64),
parent_evidence_id: "ev_" + "1".repeat(63) + "0",
});
expect(leftAnswer.evidence_ids.native_summary).toBe(
"ev_" + "1".repeat(63) + "5",
);
},
);
@@ -312,6 +312,14 @@ it.each([
"tests/boundary/process/darwinProcessRunTokenReader.test.ts",
{ code: true, apple: true, package: false, runtime: false },
],
[
"tests/boundary/cli/nativePlistCli.test.ts",
{ code: true, apple: true, package: false, runtime: false },
],
[
"tests/boundary/filesystem/windowsNpxRegistration.test.ts",
{ code: true, package: true, runtime: true, apple: false },
],
[
"src/process/ProcessOwnership.test.ts",
{ code: true, package: true, apple: false, evm: false, browser: false },
@@ -0,0 +1,174 @@
import { execFile } from "node:child_process";
import { readFile, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { expect, it } from "vitest";
import { parse } from "yaml";
import { z } from "zod";
import { parseTestDurations } from "../../ciTestSequencer.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const execute = promisify(execFile);
const script = new URL("../../../scripts/ci/test-report.mjs", import.meta.url);
const stepsSchema = z.array(
z.object({
name: z.string().optional(),
run: z.string().optional(),
uses: z.string().optional(),
if: z.string().optional(),
env: z.record(z.string(), z.string()).optional(),
with: z.record(z.string(), z.unknown()).optional(),
}),
);
const workflow = async () =>
z
.object({
jobs: z.object({
test: z.object({ steps: stepsSchema }),
build: z.object({ steps: stepsSchema }),
docs: z.object({ steps: stepsSchema }),
static: z.object({ steps: stepsSchema }),
}),
})
.parse(
parse(
await readFile(
new URL("../../../.github/workflows/ci.yml", import.meta.url),
"utf8",
),
),
).jobs;
it("collects failed-shard diagnostics before enforcing the shard result", async () => {
const steps = (await workflow()).test!.steps;
const merge = steps.findIndex((step) => step.run?.includes("test:ci:merge"));
const upload = steps.findIndex((step) => step.with?.name === "test-results");
const gate = steps.findIndex(
(step) => step.env?.SHARD_RESULT && step.run?.includes("test "),
);
expect(merge).toBeGreaterThan(0);
expect(upload).toBeGreaterThan(merge);
expect(gate).toBeGreaterThan(upload);
expect(steps[merge]?.if).not.toContain("needs.test-shard.result");
const command = steps[gate]?.run;
if (command === undefined) throw new Error("Missing shard result gate");
for (const result of ["failure", "cancelled", "skipped", ""]) {
await expect(
execute("bash", ["-c", command], {
env: { ...process.env, CODE_REQUIRED: "true", SHARD_RESULT: result },
}),
).rejects.toMatchObject({ code: 1 });
}
await expect(
execute("bash", ["-c", command], {
env: { ...process.env, CODE_REQUIRED: "false", SHARD_RESULT: "skipped" },
}),
).resolves.toMatchObject({ stderr: "" });
});
it("keeps diagnosis available after merge failure and publishes only complete successful history", async () => {
const steps = (await workflow()).test!.steps;
const diagnosis = steps.find((step) => step.with?.name === "test-results");
expect(diagnosis?.if).toContain("!cancelled()");
expect(diagnosis?.if).not.toContain("success()");
const history = steps.find((step) => step.with?.name === "test-timings");
expect(history?.if).toContain("success()");
expect(history?.if).toContain("needs.test-shard.result == 'success'");
expect(history?.with?.path).toBe(".cache/ci-test-history/test-results.json");
const download = (await workflow()).build!.steps.find(
(step) => step.name === "Download previous timing report",
);
expect(download?.with?.name).toBe(history?.with?.name);
});
it("runs the existing onboarding and Python producer guards from their selected CI owners", async () => {
const jobs = await workflow();
const scripts = z
.object({ scripts: z.record(z.string(), z.string()) })
.parse(
JSON.parse(
await readFile(
new URL("../../../package.json", import.meta.url),
"utf8",
),
),
).scripts;
expect(
jobs.docs.steps.some((step) => step.run === "npm run docs:check"),
).toBe(true);
expect(scripts["docs:check"]).toContain(
"tests/conformance/readmeConsistency.test.ts",
);
const producer = jobs.static.steps.find(
(step) => step.run === "npm run test:python",
);
expect(producer?.if).toBe("needs.changes.outputs.code == 'true'");
expect(scripts["test:python"]).toContain(
"bridge/native/test_rea_lldb_tracer.py",
);
});
it("exports portable timing history and removes it on a failed, partial, or invalid rerun", async () => {
const root = await createTestTempDirectory("rea-ci-report-");
const summary = join(root, "summary.md");
const history = join(root, ".cache/ci-test-history/test-results.json");
const report = {
success: true,
numFailedTests: 0,
testResults: [
{
name: join(root, "tests/slow.test.ts"),
startTime: 10,
endTime: 110,
assertionResults: [{ large: "not timing data" }],
},
{ name: join(root, "tests/fast.test.ts"), startTime: 20, endTime: 30 },
],
};
const run = async (value: unknown, shards = "success", merge = "success") => {
await writeFile(join(root, "test-results.json"), JSON.stringify(value));
await writeFile(summary, "");
return execute(process.execPath, [fileURLToPath(script)], {
cwd: root,
env: {
...process.env,
GITHUB_STEP_SUMMARY: summary,
SHARD_RESULT: shards,
MERGE_RESULT: merge,
},
});
};
await run(report);
const contents = await readFile(history, "utf8");
expect(contents).not.toContain("assertionResults");
expect(contents).not.toContain(root);
expect(
parseTestDurations(contents, resolve(root, "different-checkout")),
).toEqual(
new Map([
["tests/slow.test.ts", 100],
["tests/fast.test.ts", 10],
]),
);
for (const [value, shards, merge] of [
[{ ...report, success: false, numFailedTests: 1 }, "failure", "failure"],
[report, "cancelled", "success"],
[report, "success", "failure"],
] as const) {
await run(report);
await run(value, shards, merge);
await expect(readFile(history)).rejects.toMatchObject({ code: "ENOENT" });
expect(await readFile(summary, "utf8")).toContain("diagnostic only");
}
await run(report);
await expect(
run({
...report,
testResults: [{ name: "../outside.test.ts", startTime: 1, endTime: 3 }],
}),
).rejects.toMatchObject({ code: 1 });
await expect(readFile(history)).rejects.toMatchObject({ code: "ENOENT" });
});
@@ -1661,8 +1661,9 @@ describe("uninstall configuration preflight", () => {
});
it.each([
['{"mcpServers": null}', '"mcpServers" must be an object'],
['{"mcpServers":null,"keep":1}', '"mcpServers" must be an object'],
["[]", "The configuration root must be an object"],
['{"mcpServers":"x"}', '"mcpServers" must be an object'],
])(
"names the structural problem in valid JSON %s",
async (content, problem) => {
@@ -1679,12 +1680,14 @@ describe("uninstall configuration preflight", () => {
},
});
const result = await runUninstall(false, systemUninstallHost(home));
expect(result.status).toBe("failed");
expect(result.items).toContainEqual({
name: "cursor",
status: "failed",
detail: `Configuration was not changed: ${problem}. Repair it, then rerun uninstall.`,
});
expect(await readFile(config, "utf8")).toBe(content);
await expect(readFile(`${config}.rea.backup`, "utf8")).rejects.toThrow();
},
);
@@ -0,0 +1,82 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, expect, it, vi } from "vitest";
import { inspectGhidraInstallation } from "../../../src/ghidra/GhidraInstallation.js";
import { SUPPORTED_GHIDRA_VERSION } from "../../../src/ghidra/GhidraInstallationPolicy.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const { mockSpawnSync } = vi.hoisted(() => ({ mockSpawnSync: vi.fn() }));
vi.mock("node:child_process", async (importOriginal) => ({
...(await importOriginal<typeof import("node:child_process")>()),
spawnSync: mockSpawnSync,
}));
afterEach(() => mockSpawnSync.mockReset());
it.each([
{ major: 17, fullJdk: true },
{ major: 21, fullJdk: true },
{ major: 21, fullJdk: false },
])(
"preserves the reported Unicode Java home and classifies JDK $major (full JDK: $fullJdk)",
async ({ major, fullJdk }) => {
const root = await createTestTempDirectory("rea-java-probe-");
const configuredHome = join(root, "selected-jdk");
const reportedHome = join(root, "Réa逆向", `JDK ${String(major)}`);
const installDir = join(root, "ghidra");
await mkdir(join(installDir, "Ghidra"), { recursive: true });
await writeFile(
join(installDir, "Ghidra", "application.properties"),
`application.version=${SUPPORTED_GHIDRA_VERSION}\napplication.java.min=17\n`,
);
if (fullJdk) {
await mkdir(join(reportedHome, "bin"), { recursive: true });
await writeFile(
join(
reportedHome,
"bin",
process.platform === "win32" ? "javac.exe" : "javac",
),
"compiler presence fixture",
{ mode: 0o755 },
);
}
const version = `${String(major)}.0.11`;
mockSpawnSync.mockReturnValue({
stdout: "",
stderr: ` java.version = ${version}\n java.home = ${reportedHome}\n sun.arch.data.model = 64\n`,
status: 0,
});
const inspection = inspectGhidraInstallation({
environment: {},
installDir,
javaHome: configuredHome,
});
expect(inspection.javaHome).toBe(reportedHome);
expect(inspection.javaVersion).toBe(version);
expect(inspection.checks.find(({ name }) => name === "java")).toMatchObject(
{
status: fullJdk ? "passed" : "failed",
detail: expect.stringContaining(fullJdk ? "; JDK;" : "; runtime only;"),
},
);
expect(mockSpawnSync).toHaveBeenCalledExactlyOnceWith(
join(
configuredHome,
"bin",
process.platform === "win32" ? "java.exe" : "java",
),
expect.arrayContaining([
"-Dstdout.encoding=UTF-8",
"-Dstderr.encoding=UTF-8",
"-Dsun.stdout.encoding=UTF-8",
"-Dsun.stderr.encoding=UTF-8",
]),
expect.objectContaining({ encoding: "utf8" }),
);
},
);
@@ -6,9 +6,15 @@ import { parse as parseYaml } from "yaml";
import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js";
import { npxRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js";
import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js";
import {
configureClientConfiguration,
inspectClientConfiguration,
} from "../../../src/application/SetupClientConfiguration.js";
import { supportedClients } from "../../../src/application/SupportedClients.js";
import { systemUninstallHost } from "../../../src/application/Uninstall.js";
import {
runUninstall,
systemUninstallHost,
} from "../../../src/application/Uninstall.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js";
@@ -90,18 +96,75 @@ describe("Hermes configuration paths", () => {
});
});
describe("Hermes YAML registration", () => {
const hermesClient = async () => {
const home = await createTestTempDirectory("rea-hermes-");
const client = supportedClients(home, process.platform, {}).find(
({ name }) => name === "hermes",
);
if (client?.markerPath === undefined)
throw new Error("missing Hermes client");
await mkdir(client.markerPath, { recursive: true });
return { home, client };
};
const hermesClient = async () => {
const home = await createTestTempDirectory("rea-hermes-");
const client = supportedClients(home, process.platform, {}).find(
({ name }) => name === "hermes",
);
if (client?.markerPath === undefined)
throw new Error("missing Hermes client");
await mkdir(client.markerPath, { recursive: true });
return { home, client };
};
describe("Hermes YAML shape diagnostics", () => {
it.each(
[
"!!set {rea: null}",
"!!omap [{rea: {command: rea, args: [mcp]}}]",
"!!timestamp 2001-12-15",
"!!binary cmVh",
].flatMap((value) => [
{
original: `${value}\n`,
problem: "The configuration root must be an object",
},
{
original: `mcp_servers: ${value}\n`,
problem: '"mcp_servers" must be an object',
},
]),
)(
"blocks setup and uninstall before changes for tagged non-record YAML: $original",
async ({ original, problem }) => {
const { home, client } = await hermesClient();
const cursor = join(home, ".cursor", "mcp.json");
const cursorOriginal =
'{"mcpServers":{"rea":{"command":"rea","args":["mcp"]}}}';
await mkdir(join(home, ".cursor"));
await writeFile(cursor, cursorOriginal);
await writeFile(client.configPath, original);
expect(
await inspectClientConfiguration(client, {}, command),
).toMatchObject({
status: "invalid",
remediation: `Configuration ${client.configPath} is malformed: ${problem}`,
});
expect(await configureClientConfiguration(client, {}, command)).toEqual({
status: "failed",
reason: "readback",
});
const result = await runUninstall(
false,
systemUninstallHost(home, undefined, {}),
);
expect(result.status).toBe("failed");
expect(result.items).toContainEqual({
name: "hermes",
status: "failed",
detail: `Configuration was not changed: ${problem}. Repair it, then rerun uninstall.`,
});
expect(await readFile(client.configPath, "utf8")).toBe(original);
expect(await readFile(cursor, "utf8")).toBe(cursorOriginal);
await expect(
readFile(`${client.configPath}.rea.backup`, "utf8"),
).rejects.toThrow();
await expect(readFile(`${cursor}.rea.backup`, "utf8")).rejects.toThrow();
},
);
});
describe("Hermes YAML registration", () => {
it("writes mcp_servers, preserves comments, reads back, and uninstalls", async () => {
const { home, client } = await hermesClient();
await writeFile(
@@ -334,13 +334,18 @@ describe("native macOS installer", { timeout: 20_000 }, () => {
});
describe("installer semantic version parsing", { timeout: 20_000 }, () => {
it.each(["1.2.3-01"])(
it.each([
"1.2.3-01",
"1.2.3-$(printf unexpected)",
'1.2.3-"; printf unexpected; #',
])(
"rejects malformed semantic versions before invoking npm: %s",
async (version) => {
const fixture = await createFixture();
await expect(
runInstaller(fixture, ["--version", version, "--dry-run"]),
).rejects.toMatchObject({
stdout: "",
stderr:
"REA installation failed: version must be an exact semantic version.\n",
});
@@ -0,0 +1,29 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { inspectBundleKeyedArchive } from "../../../src/artifacts/apple/KeyedArchiveReader.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { deeplyNestedXmlPlist } from "../../fixtures/keyedArchiveXmlNesting.js";
it.each([129, 3000])(
"reports XML nesting of %i levels as the keyed archive decode limit",
async (depth) => {
const root = await createTestTempDirectory("rea-keyed-depth-");
await writeFile(join(root, "Deep.plist"), deeplyNestedXmlPlist(depth));
const rejected = await inspectBundleKeyedArchive({
bundlePath: root,
targetSha256: "a".repeat(64),
parameters: { path: "Deep.plist" },
platform: "linux",
}).catch((cause: unknown) => cause);
// Recursive decoding exhausts the call stack before the projection limit
// can report it, so the limit diagnostic must come from the preflight.
expect(rejected).toMatchObject({
reason: "limit",
message:
"Cannot decode selected Foundation keyed archive: Plist exceeds 128 nesting levels",
});
},
);
+89 -1
View File
@@ -5,7 +5,11 @@ import { describe, expect, it } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { resolveClientConfigTransactionPath } from "../../../src/application/ClientConfigPath.js";
import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js";
import { readConfigurationFile } from "../../../src/application/ClientConfigurationFiles.js";
import {
configureClientConfiguration,
inspectClientConfiguration,
} from "../../../src/application/SetupClientConfiguration.js";
import { PRODUCT_IDENTITY } from "../../../src/identity.js";
const pinnedPackage = PRODUCT_IDENTITY.registrationPackageSpecifier;
@@ -330,3 +334,87 @@ describe("JSON client configuration migration and provider paths", () => {
).rejects.toThrow();
});
});
describe("JSON client configuration shape diagnostics", () => {
it.each([
{
format: "json" as const,
original:
'{"__proto__":{"mcpServers":{"rea":{"command":"rea","args":["mcp"]}}}}',
serversKey: "mcpServers",
},
{
format: "opencode" as const,
original:
'{"mcp":{"__proto__":{"servers":{"rea":{"type":"local","command":["rea","mcp"]}}}}}',
serversKey: "mcp",
},
])(
"registers an own server instead of trusting inherited $format settings",
async ({ format, original, serversKey }) => {
const directory = await createTestTempDirectory("rea-setup-prototype-");
const configPath = join(directory, "mcp.json");
const client = {
name: format === "json" ? "cursor" : "opencode",
configPath,
format,
};
await writeFile(configPath, original);
expect(
await inspectClientConfiguration(client, {}, ["rea", "mcp"]),
).toMatchObject({ status: "update" });
expect(
await configureClientConfiguration(client, {}, ["rea", "mcp"]),
).toMatchObject({ status: "configured" });
const document = JSON.parse(await readFile(configPath, "utf8"));
expect(Object.hasOwn(document, serversKey)).toBe(true);
expect(Object.hasOwn(document[serversKey], "rea")).toBe(true);
expect(document[serversKey].rea.command).toEqual(
format === "json" ? "rea" : ["rea", "mcp"],
);
const originalDocument = JSON.parse(original);
expect(
format === "json" ? document.__proto__ : document[serversKey].__proto__,
).toEqual(
format === "json"
? originalDocument.__proto__
: originalDocument[serversKey].__proto__,
);
expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original);
expect(
await inspectClientConfiguration(client, {}, ["rea", "mcp"]),
).toMatchObject({ status: "already_current" });
},
);
it.each([
['{"mcpServers":null,"keep":1}', '"mcpServers" must be an object'],
["[]", "The configuration root must be an object"],
['{"mcpServers":"x"}', '"mcpServers" must be an object'],
] as const)(
"names structural shape problems in setup remediation: %s",
async (original, problem) => {
const directory = await createTestTempDirectory("rea-setup-shape-");
const configPath = join(directory, "mcp.json");
const client = {
name: "cursor",
configPath,
format: "json" as const,
};
await writeFile(configPath, original);
const read = await readConfigurationFile(configPath, "json");
expect(read.ok).toBe(false);
if (read.ok) throw new Error("expected malformed configuration");
expect(read.error.detail).toBe(
`Configuration ${configPath} is malformed: ${problem}`,
);
expect(
await inspectClientConfiguration(client, {}, ["rea", "mcp"]),
).toEqual({
status: "invalid",
remediation: `Configuration ${configPath} is malformed: ${problem}`,
});
expect(await readFile(configPath, "utf8")).toBe(original);
},
);
});
@@ -0,0 +1,108 @@
import { execFile } from "node:child_process";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { expect, it } from "vitest";
import { z } from "zod";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { workflowGit } from "../../support/workflowGit.js";
const execute = promisify(execFile);
it.skipIf(process.platform === "android")(
"reruns test validation after repository or external input changes while reusing preparation",
async () => {
const config = z
.object({
tasks: z.record(z.string(), z.record(z.string(), z.unknown())),
})
.parse(
JSON.parse(
await readFile(
new URL("../../../turbo.json", import.meta.url),
"utf8",
),
),
);
const testTask = config.tasks["test:run"];
const dependencies = z.array(z.string()).parse(testTask?.dependsOn);
const root = await createTestTempDirectory("rea-test-cache-");
const external = await createTestTempDirectory("rea-test-dependency-");
const dependency = join(external, "provider.json");
const scripts: Record<string, string> = { "test:run": "node check.mjs" };
const tasks: Record<string, unknown> = { "test:run": testTask };
for (const [index, task] of dependencies.entries()) {
scripts[task] = `node prepare.mjs ${index}`;
tasks[task] = {
inputs: ["artifact-input.txt"],
outputs: [`prepared-${index}.txt`],
};
}
await writeFile(
join(root, "package.json"),
JSON.stringify({
name: "rea-cache-fixture",
version: "1.0.0",
private: true,
type: "module",
packageManager: "[email protected]",
scripts,
}),
);
await writeFile(
join(root, "package-lock.json"),
JSON.stringify({
name: "rea-cache-fixture",
version: "1.0.0",
lockfileVersion: 3,
packages: { "": { name: "rea-cache-fixture", version: "1.0.0" } },
}),
);
await writeFile(join(root, "turbo.json"), JSON.stringify({ tasks }));
await writeFile(
join(root, "artifact-input.txt"),
"unchanged preparation input",
);
await writeFile(join(root, "README_zh.md"), "aligned");
await writeFile(dependency, "healthy");
await writeFile(
join(root, "prepare.mjs"),
'import { appendFileSync } from "node:fs"; appendFileSync(`prepared-${process.argv[2]}.txt`, "prepared\\n");',
);
await writeFile(
join(root, "check.mjs"),
`import { readFileSync } from "node:fs"; if(readFileSync("README_zh.md","utf8") !== "aligned" || readFileSync(${JSON.stringify(dependency)},"utf8") !== "healthy") throw new Error("current input is invalid");`,
);
await workflowGit(root, ["init", "--initial-branch=main"]);
await workflowGit(root, ["add", "."]);
await workflowGit(root, ["commit", "-m", "test: seed cache fixture"]);
const run = () =>
execute(
process.execPath,
[
join(process.cwd(), "node_modules/turbo/bin/turbo"),
"run",
"test:run",
"--output-logs=errors-only",
],
{ cwd: root, env: { ...process.env, TURBO_DAEMON: "false" } },
);
await run();
await writeFile(join(root, "README_zh.md"), "wrong command");
await expect(run()).rejects.toMatchObject({ code: 1 });
await writeFile(join(root, "README_zh.md"), "aligned");
await run();
await writeFile(dependency, "faulty");
await expect(run()).rejects.toMatchObject({ code: 1 });
await expect(run()).rejects.toMatchObject({ code: 1 });
await writeFile(dependency, "healthy");
await run();
for (const index of dependencies.keys()) {
expect(await readFile(join(root, `prepared-${index}.txt`), "utf8")).toBe(
"prepared\n",
);
}
},
);
@@ -0,0 +1,73 @@
import { spawn } from "node:child_process";
import { describe, expect, it } from "vitest";
import { observeSettlement } from "../../../src/process/capture/ProcessCaptureLifecycle.js";
import { observeOwnedProcessGroups } from "../../../src/process/ProcessOwnershipObservation.js";
/** Process group id that cannot exist on any supported host. */
const ABSENT_GROUP_ID = 4_294_967_295;
const observeGroup = async (groupId: number) =>
observeOwnedProcessGroups("settlement-deadline-probe", [groupId]);
describe("settlement deadline classification", () => {
it("does not report a single empty observation as alive_at_deadline", async () => {
expect(
(await observeGroup(ABSENT_GROUP_ID)).get(ABSENT_GROUP_ID)?.state,
).toBe("empty");
const settlement = await observeSettlement(
"settlement-single-empty",
[ABSENT_GROUP_ID],
0,
() => undefined,
"linux",
);
expect(settlement.state).toBe("unverifiable");
});
it("still reports observed liveness as alive_at_deadline", async () => {
const runId = "settlement-live-group";
// A detached child leads its own process group and carries the run token,
// so the group observes as a genuinely owned live member.
const child = spawn("sleep", ["30"], {
detached: true,
stdio: "ignore",
env: { ...process.env, REA_PROCESS_RUN_ID: runId },
});
child.unref();
try {
// Wait until the child is actually listed before asserting on it.
const started = Date.now();
while (
(await observeOwnedProcessGroups(runId, [child.pid!])).get(child.pid!)
?.state !== "alive"
) {
if (Date.now() - started > 5_000)
throw new Error("live fixture group never observed as alive");
await new Promise((resolve) => setTimeout(resolve, 50));
}
const settlement = await observeSettlement(
runId,
[child.pid!],
0,
() => undefined,
"linux",
);
expect(settlement.state).toBe("alive_at_deadline");
} finally {
child.kill("SIGKILL");
}
});
it("still reports two consecutive empty observations as quiesced", async () => {
const settlement = await observeSettlement(
"settlement-two-empty",
[ABSENT_GROUP_ID],
1_000,
() => undefined,
"linux",
);
expect(settlement.state).toBe("quiesced");
});
});
@@ -0,0 +1,266 @@
import { createHash } from "node:crypto";
import {
copyFile,
mkdtemp,
readFile,
readdir,
rm,
writeFile,
} from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import { afterEach, expect, test, vi } from "vitest";
import { AdbProvider } from "../../../../src/adb/AdbProvider.js";
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
import {
execFileBytes,
execFileOutput,
} from "../../../../src/process/ExecFileOutput.js";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
// Exercise real filesystem publication; the Android process reply is simulated.
vi.mock(
"../../../../src/process/ExecFileOutput.js",
async (importOriginal) => ({
...(await importOriginal<
typeof import("../../../../src/process/ExecFileOutput.js")
>()),
execFileBytes: vi.fn(),
execFileOutput: vi.fn(),
}),
);
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return {
...actual,
copyFile: vi.fn(actual.copyFile),
mkdtemp: vi.fn(actual.mkdtemp),
rm: vi.fn(actual.rm),
};
});
afterEach(() => vi.restoreAllMocks());
const fixture = async (duringCollection?: (root: string) => Promise<void>) => {
const root = await createTestTempDirectory("rea-adb-bugreport-");
const binary = join(root, "adb");
let reportedPath = "";
vi.mocked(execFileOutput).mockImplementation(async (_binary, args) => {
expect(args).toEqual(["version"]);
return {
stdout: `Android Debug Bridge version 1.0.41\nVersion 34.0.5-stub\nInstalled as ${binary}\n`,
stderr: "",
};
});
vi.mocked(execFileBytes).mockImplementation(async (_binary, args) => {
expect(args.slice(0, 3)).toEqual(["-s", "device", "bugreport"]);
const staging = args[3]!;
reportedPath = join(staging, "bugreport test.zip");
await writeFile(reportedPath, "bugreport-bytes");
await duringCollection?.(root);
return {
stdout: Buffer.from(`Bug report copied to ${reportedPath}\n`),
stderr: Buffer.alloc(0),
};
});
const provider = new AdbProvider({ environment: { REA_ADB_PATH: binary } });
return {
root,
provider,
reportedPath: () => reportedPath,
request: {
operation: "collect_adb_bugreport" as const,
input: { serial: "device", output_directory: root },
},
};
};
test.each(["before", "during"] as const)(
"preserves a same-name caller archive created %s collection",
async (when) => {
const existing = async (root: string) => {
await writeFile(join(root, "bugreport test.zip"), "existing evidence");
await writeFile(join(root, "bugreport test (1).zip"), "other evidence");
};
const f = await fixture(when === "during" ? existing : undefined);
if (when === "before") await existing(f.root);
const result = await f.provider.execute(f.request);
if (!result.ok) throw result.error;
const localPath = join(f.root, "bugreport test (2).zip");
expect(result.value.result).toMatchObject({
local_path: localPath,
adb_reported_path: f.reportedPath(),
bytes: 15,
sha256: createHash("sha256").update("bugreport-bytes").digest("hex"),
});
expect(await readFile(join(f.root, "bugreport test.zip"), "utf8")).toBe(
"existing evidence",
);
expect(await readFile(join(f.root, "bugreport test (1).zip"), "utf8")).toBe(
"other evidence",
);
expect(await readFile(localPath, "utf8")).toBe("bugreport-bytes");
expect((await readdir(f.root)).sort()).toEqual([
"bugreport test (1).zip",
"bugreport test (2).zip",
"bugreport test.zip",
]);
await f.provider.close();
},
);
test("retains a completed archive after failed publication and later provider work", async () => {
const f = await fixture();
const existing = join(f.root, "bugreport test.zip");
await writeFile(existing, "existing evidence");
const publicationFailure = Object.assign(new Error("no space left"), {
code: "ENOSPC",
});
vi.mocked(copyFile).mockRejectedValueOnce(publicationFailure);
const failed = await f.provider.execute(f.request);
if (failed.ok) throw new Error("expected publication failure");
const retainedPath = f.reportedPath();
expect(failed.error._tag).toBe("AnalysisOutputError");
expect(failed.error.cause).toBe(publicationFailure);
expect(
projectAnalysisError(failed.error).details?.partial_observation,
).toMatchObject({
provider_id: "adb",
operation: f.request.operation,
result: {
serial: "device",
local_path: retainedPath,
adb_reported_path: retainedPath,
digest_status: "unknown",
},
});
expect(await readFile(retainedPath, "utf8")).toBe("bugreport-bytes");
expect(await readFile(existing, "utf8")).toBe("existing evidence");
// New work and terminal cleanup must not reclaim the retained acquisition.
const next = await f.provider.execute(f.request);
if (!next.ok) throw next.error;
expect(next.value.result).toMatchObject({
local_path: join(f.root, "bugreport test (1).zip"),
bytes: 15,
sha256: createHash("sha256").update("bugreport-bytes").digest("hex"),
});
await f.provider.close();
expect(await readFile(retainedPath, "utf8")).toBe("bugreport-bytes");
expect(await readFile(existing, "utf8")).toBe("existing evidence");
expect(await readFile(join(f.root, "bugreport test (1).zip"), "utf8")).toBe(
"bugreport-bytes",
);
expect((await readdir(f.root)).sort()).toEqual(
[
basename(dirname(retainedPath)),
"bugreport test.zip",
"bugreport test (1).zip",
].sort(),
);
});
test("retains final and producer paths when digest verification is cancelled", async () => {
const f = await fixture();
const controller = new AbortController();
const actualCopy =
await vi.importActual<typeof import("node:fs/promises")>(
"node:fs/promises",
);
vi.mocked(copyFile).mockImplementationOnce(async (...args) => {
await actualCopy.copyFile(...args);
controller.abort();
});
const result = await f.provider.execute(f.request, {
signal: controller.signal,
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.partialObservation).toMatchObject({
operation: f.request.operation,
result: {
local_path: join(f.root, "bugreport test.zip"),
adb_reported_path: f.reportedPath(),
digest_status: "unknown",
},
});
expect(await readFile(join(f.root, "bugreport test.zip"), "utf8")).toBe(
"bugreport-bytes",
);
expect(await readdir(f.root)).toEqual(["bugreport test.zip"]);
await f.provider.close();
});
test("returns staging allocation failures without invoking device collection", async () => {
const f = await fixture();
vi.mocked(mkdtemp).mockRejectedValueOnce(new Error("staging denied"));
const result = await f.provider.execute(f.request);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected output failure");
expect(result.error._tag).toBe("AnalysisOutputError");
expect(execFileBytes).not.toHaveBeenCalled();
await f.provider.close();
});
test("retains published evidence and retries failed staging cleanup", async () => {
const f = await fixture();
vi.mocked(rm).mockRejectedValueOnce(new Error("cleanup denied"));
const result = await f.provider.execute(f.request);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected cleanup failure");
expect(result.error.cleanupResources).toContain(dirname(f.reportedPath()));
expect(result.error.partialObservation).toMatchObject({
operation: f.request.operation,
result: {
local_path: join(f.root, "bugreport test.zip"),
adb_reported_path: f.reportedPath(),
bytes: 15,
sha256: createHash("sha256").update("bugreport-bytes").digest("hex"),
},
});
await f.provider.close();
expect(await readdir(f.root)).toEqual(["bugreport test.zip"]);
});
test("refuses an archive reported outside the owned staging directory", async () => {
const f = await fixture();
const existing = join(f.root, "bugreport test.zip");
await writeFile(existing, "existing evidence");
vi.mocked(execFileBytes).mockResolvedValueOnce({
stdout: Buffer.from(`Bug report copied to ${existing}\n`),
stderr: Buffer.alloc(0),
});
const result = await f.provider.execute(f.request);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected protocol refusal");
expect(result.error._tag).toBe("AnalysisProtocolError");
expect(await readFile(existing, "utf8")).toBe("existing evidence");
expect(await readdir(f.root)).toEqual(["bugreport test.zip"]);
await f.provider.close();
});
test("preserves the primary protocol failure when staging cleanup also fails", async () => {
const f = await fixture();
vi.mocked(execFileBytes).mockResolvedValueOnce({
stdout: Buffer.from("unsupported completion format\n"),
stderr: Buffer.alloc(0),
});
vi.mocked(rm).mockRejectedValueOnce(new Error("cleanup denied"));
const result = await f.provider.execute(f.request);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected protocol and cleanup failure");
expect(result.error.cause).toMatchObject({
errors: [
{
_tag: "AnalysisProtocolError",
message: expect.stringContaining("did not report an archive path"),
},
expect.any(Error),
],
});
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.cleanupResources).toHaveLength(1);
await f.provider.close();
expect(await readdir(f.root)).toEqual([]);
});
@@ -374,9 +374,7 @@ describe("evidence metadata imports and snapshot cache", () => {
expect(session.exportAnalysisSnapshot()).toMatchObject({
ok: false,
error: {
message: expect.stringContaining(
"Analysis snapshots are unavailable because analysis metadata may have changed",
),
_tag: "EvidenceIntegrityError",
},
});
expect((await session.open(target)).ok).toBe(true);
@@ -384,9 +382,7 @@ describe("evidence metadata imports and snapshot cache", () => {
expect(session.importAnalysisSnapshot(baseline.value)).toMatchObject({
ok: false,
error: {
userMessage: expect.stringContaining(
"Close the active target without saving a snapshot",
),
_tag: "EvidenceIntegrityError",
},
});
expect(session.exportEvidenceBundle().records).toContainEqual(relocated);
+18
View File
@@ -0,0 +1,18 @@
/* Source-owned freestanding analysis fixture. Never execute this target. */
volatile unsigned int rea_riscv_global = 7;
extern unsigned int rea_riscv_probe(unsigned int value);
__attribute__((noinline)) unsigned int rea_riscv_leaf(unsigned int value)
{
return value * 3u + rea_riscv_global;
}
__attribute__((noinline)) double rea_riscv_float(double left, double right)
{
return left + right;
}
__attribute__((noinline)) unsigned int rea_riscv_entry(unsigned int selector)
{
return rea_riscv_probe(rea_riscv_leaf(selector + 1u));
}
+8
View File
@@ -3,6 +3,14 @@
These source-owned fixtures are compiled at verification time; generated
binaries and Ghidra projects are never committed.
- `../c/riscv.c` and `riscv-probe.S` produce source-owned RV32/RV64 ELF images
with double-float ABI declarations and an independently known instruction.
`npm run verify:ghidra:riscv` exercises real CLI/MCP discovery, decompilation,
output schemas, profile and snapshot identity, refusal diagnostics, immutable
source bytes, and owned cleanup. It requires BYO Ghidra 12.1.4/JDK 21+ and
RISC-V Clang/LLD/LLVM readelf; `REA_RISCV_CLANG`, `REA_RISCV_LLD` and
`REA_RISCV_READELF` select existing tool commands.
- `inventory.c` produces separate x86-64 debug and stripped ELF executables.
It covers imports, an external `puts` function, linker thunks, source symbols,
direct calls, a targetless callback call, two referenced strings, and a
@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
const execute = promisify(execFile);
/** Build and independently inspect a source-owned ELF; never execute it. */
export async function buildRiscvFixture(directory, bits) {
assert.ok(bits === 32 || bits === 64);
const compiler = process.env.REA_RISCV_CLANG ?? "clang";
const linker = process.env.REA_RISCV_LLD ?? "ld.lld";
const reader = process.env.REA_RISCV_READELF ?? "llvm-readelf";
const source = fileURLToPath(new URL("../c/riscv.c", import.meta.url));
const probe = fileURLToPath(new URL("./riscv-probe.S", import.meta.url));
const path = join(directory, `rv${bits}.elf`);
const args = [
`--target=riscv${bits}-unknown-elf`,
`-march=rv${bits}imafdc`,
`-mabi=${bits === 32 ? "ilp32d" : "lp64d"}`,
`--ld-path=${linker}`,
"-nostdlib",
"-static",
"-fno-pic",
"-O0",
"-g",
"-Wl,-e,rea_riscv_entry",
"-Wl,--build-id=none",
source,
probe,
"-o",
path,
];
const options = { timeout: 10000, maxBuffer: 1024 * 1024 };
const compilerVersion = await execute(compiler, ["--version"], options);
const linkerVersion = await execute(linker, ["--version"], options);
const readerVersion = await execute(reader, ["--version"], options);
await execute(compiler, args, { ...options, timeout: 60000 });
const { stdout, stderr } = await execute(reader, ["-h", "-s", "-W", path], {
...options,
env: { ...process.env, LC_ALL: "C" },
});
assert.equal(stderr.trim(), "", "Unexpected ELF reader diagnostics");
const field = (expression, label) => {
const matches = [...stdout.matchAll(expression)];
assert.equal(matches.length, 1, `Missing or ambiguous readelf ${label}`);
return matches[0][1].trim();
};
assert.equal(field(/^\s*Class:\s*(.+)$/gmu, "class"), `ELF${bits}`);
assert.match(field(/^\s*Data:\s*(.+)$/gmu, "byte order"), /little endian/u);
assert.match(field(/^\s*Type:\s*(.+)$/gmu, "type"), /^EXEC\b/u);
assert.match(field(/^\s*Machine:\s*(.+)$/gmu, "machine"), /^RISC-V\b/u);
const flags = Number(field(/^\s*Flags:\s*(0x[\da-f]+).*$/gmu, "flags"));
assert.equal(flags, 5, "Compiler did not declare RVC and double-float ABI");
const symbols = {};
for (const name of [
"rea_riscv_entry",
"rea_riscv_leaf",
"rea_riscv_float",
"rea_riscv_probe",
"rea_riscv_global",
]) {
const value = field(
new RegExp(
`^\\s*\\d+:\\s+([\\da-f]+)\\s+\\d+\\s+(?:FUNC|OBJECT)\\s+GLOBAL\\s+\\S+\\s+\\d+\\s+${name}\\s*$`,
"gmu",
),
name,
);
symbols[name] = `0x${BigInt(`0x${value}`).toString(16)}`;
}
return {
path,
sha256: createHash("sha256")
.update(await readFile(path))
.digest("hex"),
riscv: { elfClass: bits, byteOrder: "little", type: 2, flags },
symbols,
toolchain: {
compiler: { command: compiler, version: compilerVersion.stdout.trim() },
linker: { command: linker, version: linkerVersion.stdout.trim() },
reader: { command: reader, version: readerVersion.stdout.trim() },
arguments: args,
},
sources: await Promise.all(
[source, probe].map(async (sourcePath) => ({
path: sourcePath,
sha256: createHash("sha256")
.update(await readFile(sourcePath))
.digest("hex"),
})),
),
};
}
+9
View File
@@ -0,0 +1,9 @@
/* Fixed instruction oracle, independent of the compiler's C code generation. */
.section .text
.option norvc
.global rea_riscv_probe
.type rea_riscv_probe, @function
rea_riscv_probe:
addi a0, a0, 17
ret
.size rea_riscv_probe, .-rea_riscv_probe
+9
View File
@@ -0,0 +1,9 @@
/** A well-formed XML plist of N nested dictionaries, each with one key. */
export const deeplyNestedXmlPlist = (depth: number): string =>
'<?xml version="1.0" encoding="UTF-8"?>\n' +
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n' +
'<plist version="1.0">\n' +
"<dict><key>k</key>".repeat(depth) +
"<string>x</string>" +
"</dict>".repeat(depth) +
"\n</plist>\n";
+13 -3
View File
@@ -1,4 +1,7 @@
import { Client } from "@modelcontextprotocol/client";
import {
Client,
type CallToolRequestOptions,
} from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { Ajv2020 } from "ajv/dist/2020.js";
import type { ValidateFunction } from "ajv";
@@ -69,12 +72,19 @@ export async function connectLocalToolsMcp() {
validatedSchemas.add(serialized);
}
const definitions = new Map(tools.map((tool) => [tool.name, tool]));
const call = async (name: string, arguments_: Record<string, unknown>) => {
const call = async (
name: string,
arguments_: Record<string, unknown>,
options?: Pick<CallToolRequestOptions, "signal" | "onprogress">,
) => {
const definition = definitions.get(name);
if (definition !== undefined) compileValidator(definition.inputSchema);
const response = await client.callTool(
{ name, arguments: arguments_ },
definition === undefined ? undefined : { toolDefinition: definition },
{
...options,
...(definition === undefined ? {} : { toolDefinition: definition }),
},
);
if (
response.structuredContent !== undefined &&
+7 -1
View File
@@ -1,6 +1,7 @@
import { spawn } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { fileURLToPath } from "node:url";
// Writes a periodic report, then behaves by mode:
@@ -8,11 +9,16 @@ import { fileURLToPath } from "node:url";
// and exits cleanly.
// - "ignoring": stays alive and swallows SIGTERM and SIGINT, so only SIGKILL
// ends the process.
// - "delayed-ignoring": installs those handlers after a slow startup.
// - "exits": exits on its own at once.
// - "descendant": cooperates while leaving a SIGTERM-ignoring child for owned cleanup.
// An optional third argument is the exit code a cooperative finalizer uses.
const [mode, directory, finalExitCode = "0"] = process.argv.slice(2);
// Make the cancellation case exercise startup beyond its old 300 ms capture
// window deterministically, rather than relying on a busy CI runner.
if (mode === "delayed-ignoring") await delay(500);
writeFileSync(join(directory, "pid.txt"), String(process.pid));
writeFileSync(
join(directory, "periodic.json"),
@@ -31,7 +37,7 @@ if (mode === "descendant") {
);
}
if (mode === "ignoring") {
if (mode === "ignoring" || mode === "delayed-ignoring") {
process.on("SIGTERM", () => {
writeFileSync(join(directory, "sigterm-received"), "received");
});
+1 -20
View File
@@ -104,31 +104,12 @@
]
},
"test:run": {
"cache": false,
"dependsOn": [
"artifacts:mcp-catalog",
"artifacts:product-catalog",
"artifacts:managed-evidence",
"artifacts:skills"
],
"inputs": [
".nvmrc",
"package.json",
"package-lock.json",
"src/**",
"tests/**",
"scripts/**",
"bridge/**",
"vitest.config.ts",
{
"mode": "dependencyOutputs",
"globs": [
".cache/mcp-tool-catalog.json",
"skills/**",
"docs/public/product-catalog.json",
"docs/verification/managed-conformance-manifest.json",
"docs/verification/managed-conformance-ledger.json"
]
}
]
},
"docs:check:built": {