Compare commits

...
Author SHA1 Message Date
N0zoM1z0 10caef99e7 fix(ghidra): import HashMap before bridge compilation
The Swift literal decoder introduced by #2049 instantiates HashMap without
importing java.util.HashMap. Ghidra compiles the entire bridge before serving
operations, so the missing import prevents x64 PE sessions from starting too.

Verified on Windows with JDK 21.0.12.1 and Ghidra 12.1.4: all nine packaged
Java sources compile against 195 API jars, and public MCP opens a real Windows
where.exe target and returns its binary overview. Target bytes remain unchanged.
The MCP smoke uses runtime 1ca5fd38b with the patched bridge from fd65e278b;
the current full runtime build is blocked by unrelated Android JsonValue typing.
2026-10-13 22:58:41 +08:00
fd65e278be fix(setup): resolve the configuration MSIX Claude Desktop reads (#2426)
* fix(setup): resolve the configuration MSIX Claude Desktop reads

On Windows, setup, doctor and uninstall always used
%APPDATA%\Claude\claude_desktop_config.json. The MSIX build of Claude
Desktop (package family Claude_pzs8sxrjxfjjc) virtualizes AppData writes
and reads its package-private copy under
%LOCALAPPDATA%\Packages\<family>\LocalCache\Roaming\Claude once it exists.
Setup from an ordinary process therefore wrote a file the app never reads,
and doctor reported that file healthy.

Resolve the Claude Desktop location once, in supportedClients(): the
private copy when it exists, else the AppData file the package still
reads, else a new private copy. An unrecognized Claude_* publisher or
unreadable package storage is reported instead of falling back to AppData.

Compare one of the package's private files with its AppData path through
realpath.native to learn whether the running package started this
process. From that redirected view, or when no file can be compared,
setup and uninstall refuse to change Claude Desktop's configuration,
because the running app rewrites it from memory. From a redirected view
they also refuse writes under %APPDATA% and %LOCALAPPDATA% for other
clients and skills, which would land in the package's private storage.

Setup refuses to register a launcher stored in any MSIX package's
private storage, discloses which file the package reads, and tells the
user to quit Claude Desktop fully on every platform. Configuration fails
with target_changed if the package switched to its private copy after
the plan was shown. Uninstall removes REA from both files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(setup): record the verified MSIX Claude Desktop registration

On the Windows 11 host with the MSIX build 2.31226.1.0, setup from an
ordinary terminal with the app quit wrote the package-private copy. After
the app restarted, the entry persisted, doctor reported it aligned, and
Claude Desktop's MCP log showed REA starting and answering tools/list.
Uninstall against the real app remains unverified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* style(setup): format MSIX review corrections

* fix(setup): carry the admitted legacy path through uninstall removal

clientRegistrationRemoval returned a removeLegacy boolean, and removeClient
recomputed the legacy path separately, so TypeScript could not prove the
path existed when removeLegacy was true and `tsc --noEmit` failed in
Uninstall.ts. The decision now returns the admitted legacy path itself;
preflight and removal consume that one value.

The alias regression matched new-destination children with a "/"
separator while building paths with path.join, so it failed on Windows.
It now uses path.sep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(setup): split MSIX Claude Desktop lifecycle cases under the function length limit

The uninstall cases pushed the registration lifecycle describe past the 150-line lint limit. Group them separately, with the AppData registration case kept among the registration cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 22:04:22 +08:00
5996d8160f fix(native): bound captured output carried by native command failures (#2764)
* fix(native): bound captured output carried by native command failures

The native command runner retains up to 64 MiB of output because
successful analysis needs it complete, and failureCapture copied all of
it into the failure. An inspect_plist whose plutil output exceeded the
budget therefore returned a 67 MB resource_constraint error whose
details.captured_output.stdout repeated the whole retained output.

Keep a 64 KiB UTF-8-safe excerpt per stream in failure diagnostics,
mark them truncated, and keep the observed stdout/stderr byte totals.
Successful captures are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(native): bound codesign failures and cover excerpt cuts

translateCodeSignExitFailure wrapped an accepted nonzero codesign capture
into a NativeCommandFailure by copying both complete streams, so
inspect_signature failures still bypassed the diagnostic bound. Route it
through the shared failureCapture builder; accepted output stays
complete until the provider decides it is a failure.

Add a focused runner test for an ordinary nonzero exit with stdout and
stderr just above the excerpt size and a two-byte character straddling
the cut, and a provider test for the codesign path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(native): build excerpt test output inside the child process

The output strings embedded in one node -e argument totalled about 133
KB, above Linux's 128 KiB per-argument limit, so spawn failed with E2BIG
on the CI runner. Generate the repeated output in the child instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:03:19 -07:00
c938f97fb2 fix(android): clean Windows workspaces after owner termination (#2440)
* fix(android): clean Windows workspaces after owner termination

* fix(android): retain Windows workspace guardian on cleanup failure

* fix(android): preserve released guardian pipe state on cleanup retry

* fix(android): normalize extended local guardian parent paths

---------

Co-authored-by: LcokHcak <268763621+LcokHcak@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 22:03:16 +08:00
c45d912c91 feat(android): expose exact DEX descriptors and direct invoke sites (#2439)
* feat(android): expose precise method references and DEX invoke sites

* fix(android): bound invoke evidence without losing reference inventory

* perf(android): skip invoke admission for class references

---------

Co-authored-by: LcokHcak <268763621+LcokHcak@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 22:02:47 +08:00
ce748be2f3 fix(process): order interaction differences by their dispatch time (#2739)
* fix(process): order interaction differences by their dispatch time

first_divergence ranks candidate differences by the earliest recorded
time of the differing observations, but eventTime read only `at_ms`.
Interaction events record their time as `dispatched_at_ms`
(and `scheduled_at_ms`), so a changed interaction always ranked as
untimed. An interaction outcome that differed at 6 ms therefore lost to
a process sample that differed at 500 ms.

Give each comparison dimension its recorded time field. Interactions use
the dispatch time, when their outcome was observed; the other
dimensions keep `at_ms`. An earlier process difference still wins.

Fixes #2645

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(process): require ordered interaction dispatch times in captures

Comparison ranks a changed interaction by its dispatch time, while
firstCollectionDivergence returns the first differing interaction by
sequence. The capture parser checked only that interaction sequences are
contiguous, so an imported capture whose dispatch times decrease could
make that first-by-sequence difference later than another differing
interaction.

Interactions are dispatched in sequence and their times come from the
monotonic capture clock, so require non-decreasing dispatch times at
parsing, as frames, rendered frames, process samples and filesystem
checkpoints already require for their timestamps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:02:28 -07:00
173da755cd fix(reconstruction): reject contradictory function comparison status (#2798)
* fix(reconstruction): reject contradictory function comparison status

* test(reconstruction): cover imported function match precedence

---------

Co-authored-by: maxazure <405711+maxazure@users.noreply.github.com>
2026-10-13 22:01:41 +08:00
1eca600515 fix(apktool): resolve Windows PATH launchers before invocation (#2802)
* fix(apktool): resolve Windows PATH launchers before invocation

Resolve the default launcher through the selected PATH/PATHEXT before choosing native process creation or the existing conservative cmd.exe wrapper. Reuse the resulting selection and one canonical environment for the version probe and decode.

Follow-up to the release #1693 review of #1777. Source inspected and formatted; tests, builds, check reruns and real Windows Apktool execution were skipped at maintainer request.

* fix(apktool): bound lookup and retain only selected environment

Source inspection and targeted formatting only. Tests, builds, lint, typechecks and provider workloads were not run at the user's explicit request.

* fix(apktool): use supported timeout diagnostics

Retain lookup phase through AnalysisErrorOptions diagnostics while preserving the operation and timeout identity. Source inspection and targeted formatting only; tests, checks, builds and provider workloads were skipped at the user request.

---------

Co-authored-by: morluto <morluto@users.noreply.github.com>
2026-10-13 22:00:47 +08:00
ae9a7f34e1 fix(process): report no remaining time after a provider deadline fires (#2720)
Node timers can fire while performance.now() is still a fraction of a
millisecond short of the monotonic deadline. ProviderStartupDeadline then
reported interruption "timeout" and an aborted signal while remainingMs()
returned ceil(0.x) = 1, so polling loops guarded by remainingMs() > 0
(Hopper and Ghidra startup) could run past an elapsed deadline, and the
real-timer test "aborts a bounded wait when its real monotonic deadline
expires" failed intermittently in CI (expected 1 to be 0).

Once the deadline's own timer has classified a timeout, report zero
remaining time. A deterministic regression test fires the timer with the
monotonic clock 0.4 ms short of the deadline.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 06:59:57 -07:00
96d1f0f3fb fix(cli): refuse scenario steps on passive native UI observation (#2712)
observe-native-ui and capture-native-ui-scenario were registered in one
loop with a shared option schema and description. The passive command
therefore advertised `--steps` ("Observe or run a scenario"), accepted
any value, and then dispatched observe_native_ui without it, so a
caller who selected the wrong command never learned that no scenario ran.

Register each command against its own contract. Observation declares
only the passive scope options, so `--steps` is rejected as an unknown
flag; the scenario command keeps its JSON step decoding and validation.
Each command now has its own description.

Fixes #2674

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 06:59:33 -07:00
beca34f522 fix(ida): report missing tools when a compatibility profile mismatches (#2307)
A profile mismatch reached the caller as a bare AnalysisProtocolError,
which projects to `unreadable_output` with a generic message. The error
already named every missing tool, but none of that detail survived, so
the only way to identify the cause was to log the JSON-RPC traffic and
notice the exchange stopping after `tools/list`.

Throw a ProviderAdapterError instead, carrying the mode, the expected
profile and the missing tool names through the diagnostics channel that
already surfaces in `details`. Document which upstream versions each
profile accepts, since upstream `main` now serves neither.

Refs #2301

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 21:58:36 +08:00
48a683ac8c feat(evm): inspect lossless Solidity terminal CBOR metadata (#2790)
feat(evm): retain lossless terminal CBOR metadata

Refs morluto/rea#2388

Co-authored-by: Matheus Breguêz <matbrgz@gmail.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-13 21:55:30 +08:00
a3daf8a07b fix(ci): run the Windows native lane with its process boundary fixture (#2336)
* fix(ci): run the Windows native lane with its process boundary fixture

`verify-windows-native.mjs` takes the boundary executable as its second
argument and skips every observation that needs it when the argument is
absent. CI passed no argument, so the breakaway denial, caller and child
token comparison, Windows environment semantics and in-place ancestor
reparse attempt have never run there; they only ran for an operator who
built the fixture by hand, which `native/windows/README.md` described as
optional.

The fixture is an ordinary Windows executable independent of the addon,
so the lane that already installs MinGW to cross-build the artifact
cross-builds it too and uploads it beside the existing inspection
fixture. The Windows job downloads it and passes its path, matching how
that inspection fixture already crosses the same job boundary.

No observation, assertion or threshold changes; this only stops four of
them from being silently skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(ci): pass the process boundary fixture in the real Ghidra Windows lane

`real-ghidra-windows.yml` also runs the native lane without the boundary
executable, so its proof run skipped the same breakaway, caller and child
token, environment semantics and ancestor reparse observations. Its
artifact job already installs MinGW, so it cross-builds and uploads the
fixture the same way, and the self-hosted Windows job downloads it and
passes its path.

This makes the README's claim about both Windows workflows true rather
than narrowing it to one lane.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(process): report no remaining time after a provider deadline fires

Node timers can fire while performance.now() is still a fraction of a
millisecond short of the monotonic deadline. ProviderStartupDeadline then
reported interruption "timeout" and an aborted signal while remainingMs()
returned ceil(0.x) = 1, so polling loops guarded by remainingMs() > 0
(Hopper and Ghidra startup) could run past an elapsed deadline, and the
real-timer test "aborts a bounded wait when its real monotonic deadline
expires" failed intermittently in CI (expected 1 to be 0).

Once the deadline's own timer has classified a timeout, report zero
remaining time. A deterministic regression test fires the timer with the
monotonic clock 0.4 ms short of the deadline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit dfdaa0ca74cc84a45dcb186422a0f51aa61bc427)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: morluto <morluto@users.noreply.github.com>
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
2026-10-13 21:55:17 +08:00
9680da76c8 perf(evidence): reuse sealed bundle reads in compare and export (#2715)
* perf(evidence): reuse sealed bundle reads in compare and export

`rea compare`, the compare_bundles MCP tool and `rea evidence-export`
read bundles through the mutable reader, so each consumer
re-authenticated every record again: compareBundles parsed both bundles
a second time and bundleDigest's serializeEvidenceBundle a third time;
export's writer parsed the source once more before serializing.

These consumers only read the bundle, so read it through the existing
sealed reader (readImmutableEvidenceBundle). Later parses hit the
immutable bundle cache. Ordinary mutable reads, kept deliberately by
#1721, are unchanged. Output is byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(process): report no remaining time after a provider deadline fires

Node timers can fire while performance.now() is still a fraction of a
millisecond short of the monotonic deadline. ProviderStartupDeadline then
reported interruption "timeout" and an aborted signal while remainingMs()
returned ceil(0.x) = 1, so polling loops guarded by remainingMs() > 0
(Hopper and Ghidra startup) could run past an elapsed deadline, and the
real-timer test "aborts a bounded wait when its real monotonic deadline
expires" failed intermittently in CI (expected 1 to be 0).

Once the deadline's own timer has classified a timeout, report zero
remaining time. A deterministic regression test fires the timer with the
monotonic clock 0.4 ms short of the deadline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 21:50:25 +08:00
46f51ee6cc fix(flutter): name a missing or non-file APK selection (#2727)
* fix(flutter): name a missing or non-file APK selection

identify_flutter_build and inspect_dart_aot reported a missing path or a
directory as "The APK could not be read as a package: <path>: ENOENT ...",
which blames the package bytes for a selection mistake. Report
"Selected APK does not exist" for ENOENT/ENOTDIR and "Selected APK is not
a regular file" for a non-regular selection, matching the SQLite
inspector's wording. The issue path and invalid_format reason are
unchanged; package format failures keep their existing message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(filesystem): classify unopenable non-regular selections at admission

A Unix socket cannot be opened at all: open() fails with ENXIO on Linux
and EOPNOTSUPP on macOS (surfaced by libuv as "Unknown system error
-102") before openRegularFile can stat the descriptor, so such a
selection escaped NonRegularFileReadError and the Flutter provider still
blamed the package bytes.

When open fails, classify the path by its own metadata (lstat when
symlinks are rejected, stat otherwise): an existing non-regular file
becomes NonRegularFileReadError. Missing paths, permission failures,
regular files, and the O_NOFOLLOW ELOOP rejection keep their original
errors. The Flutter selection test now covers a real Unix socket.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(filesystem): reclassify only unopenable file-type errors at admission

Checking the path's metadata after any open failure turned EACCES on an
unreadable directory into NonRegularFileReadError, so callers reported
invalid input instead of access_denied. Reclassify only errors that mean
the file type cannot be opened (ENXIO, EOPNOTSUPP, ENOTSUP, matched by
errno because libuv has no name for macOS EOPNOTSUPP 102); permission,
symlink-rejection and resource failures keep their original errors.

Run the Unix socket selection check in its own test, skipped on Windows
where listen() takes named pipes, and keep the missing-path and
directory checks cross-platform.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(filesystem): keep cancellation that arrives during the non-regular probe

A caller abort while the post-open metadata probe ran was replaced by
NonRegularFileReadError, so callers such as parseBinaryTarget reported an
input-format error instead of cancellation. Recheck the signal before
substituting the non-regular classification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:49:23 +08:00
c22d7cd756 fix(browser): keep malformed Content-Length declarations unknown (#2773)
* fix(browser): keep malformed Content-Length declarations unknown

Response metadata normalized Content-Length with Number(), truncation and
clamping, so "-5", "0x10", "1e3" and "+12" became observed lengths 0, 16,
1000 and 12. RFC 9110 section 8.6 defines the field as 1*DIGIT; any other
spelling declares no length. Parse only decimal digits within the safe
integer range and keep every other value null.

Fixes #2596

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(browser): cover the largest accepted Content-Length

Pin the safe-integer boundary beside the overflow case so a future change
cannot reject the largest representable declared length unnoticed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:48:55 +08:00
4c79127fcb fix(sqlite): classify a missing database path as an invalid value (#2775)
* fix(sqlite): classify a missing database path as an invalid value

The snapshot reported an absent selected database as an artifact path
failure, which the adapter projects as invalid_format alongside malformed
paths and content. The snapshot owns the absence observation, so it now
returns an invalid_value input issue on path directly, matching the
adapter's existing ENOENT/ENOTDIR classification.

Fixes #2614

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(sqlite): classify a missing database path as an invalid value

The snapshot reported an absent selected database as an artifact path
failure, which the adapter projects as invalid_format alongside malformed
paths and content. The snapshot owns the absence observation, so it now
returns an invalid_value input issue on path directly, matching the
adapter's existing ENOENT/ENOTDIR classification.

Fixes #2614

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 21:48:39 +08:00
c8e85b962a fix(javascript): recover child-process options without args (#2781)
* docs: celebrate 180,000 GitHub stars 🎉

* fix(javascript): recover child-process options without args

* fix(javascript): preserve execFile callback overload options

Choose execFile options by the second argument object shape rather than total argument count, preserving callback overloads.

Source inspection and git diff --check; tests/builds/typechecks/CI reruns skipped at explicit user request.

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 21:46:27 +08:00
jbernousandGitHub fc0dbe7f9d fix(server): mark retained partial-observation errors as target content (#2398)
Pass the tool contract's `returnsTargetContent` to
`toRecordedPartialErrorToolResult` in the shared Evidence handler, matching
the Electron call site.

The call site that records a retained partial observation was added without
the argument required by the untrusted target-content framing, so the
delivery neither marked the result nor counted the provenance marker in its
wire budget. The type gate failed on main with
`contractToolHandler.ts(69,38): error TS2554`, which also blocked every
compiled boundary test lane.
2026-10-13 21:46:14 +08:00
23f72141d5 fix(javascript): keep configuration setting reads out of endpoints (#2402)
* fix(javascript): keep configuration setting reads out of endpoints

Static endpoint extraction reads the first literal argument of any call
named get as an HTTP request path unless the receiver is a provable keyed
collection. VS Code extension bundles read settings through
`workspace.getConfiguration(section).get(key)`, so every setting key
became a network endpoint: the bundled Git extension's dist/main.js
reported 173 network endpoints, nearly all of them setting names such as
`autofetchPeriod` and `blame.statusBarItem.enabled`.

The lexical receiver classifier now records a `configuration` fact for a
`.get` call whose receiver is a `getConfiguration(...)` call, inline or
through a binding whose only visible write is one unconditional
initializer. Minifiers emit `let` for these bindings, so the proof relies
on the recorded writes rather than the declaration kind, as tRPC client
root resolution already does. Endpoint extraction skips non-URL literals
for that fact in both application and web bundle analysis. Reassigned,
conditional, and shadowed receivers, URL or path literals, and other
clients such as `api.get("users")` keep their endpoints.

The receiver fact union was duplicated in three modules; consumers now
import the classifier's type.

On the Git extension bundle, network endpoints drop from 173 to 7 with
unchanged analysis time (2.1 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(javascript): require the workspace host for configuration receivers

A bare or differently scoped `getConfiguration(...)` may be an
application's own HTTP client factory, so `.get("users")` on its result
lost its endpoint. Only `workspace.getConfiguration(...)` and
`<x>.workspace.getConfiguration(...)` now produce the configuration fact.
Minifiers keep property names, so the bundled VS Code Git extension still
reports 7 network endpoints instead of 173.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(javascript): recognize default VS Code namespace imports

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 21:45:07 +08:00
5d12a0b4b6 test(web): cover nullable HTTP captures through CDP and CLI (#1834)
* fix(browser): preserve unknown HTTP response comparison fields

* fix(browser): retain proven request inventory changes

* fix(javascript): preserve Electron identity and avoid quadratic property scans

Share visible Electron mutations with wrapper expansion, index static object
literal properties once, and align MCP assertions with current contracts.

* fix(javascript): track writes through symbolic Electron aliases

Resolve mutation targets through the same factories, module tables, containers,
and conditional origins as call analysis. Share mutation coverage between
direct and wrapper scans while isolating origin caches and preserving exact
property paths, local rebinding semantics, and analysis resource limits.

* fix(javascript): preserve container slots and imported mutation origins

* perf(javascript): defer container mutation replay until property reads

* fix(javascript): preserve captured object identities and write order

* fix(javascript): preserve ordered mutation origins

* fix(javascript): honor property mutation constraints

* test(web): narrow PR to HTTP and CLI comparison regressions

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 21:44:08 +08:00
9958754f6c fix(process): preserve discovery ownership across cleanup retries (#2643)
Bind retained descriptors and runtime resources to their admitting operation until cleanup succeeds, then rebind newly admitted work. Verify failed retries, successful recovery, shutdown and CLI projection.

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
2026-10-13 13:43:06 +00:00
a4b8e9ccee feat(ghidra): admit recordless legacy MIPS32 ELFs with a pinned 32-bit language (#1898)
* feat(ghidra): admit recordless legacy MIPS32 ELFs with a pinned 32-bit language

Firmware built with pre-ABI-flags GNU toolchains declares EF_MIPS_ARCH_32
with an unspecified ABI field and no PT_MIPS_ABIFLAGS record. The lane
refused them before provider selection, and Ghidra's own header
auto-selection picks a 64-bit R6 language for exactly these files.

Admit ELF32 ET_EXEC headers with EF_MIPS_ARCH_32 or EF_MIPS_ARCH_32R2,
an unspecified ABI field, EF_MIPS_ABI2 clear and no observed ABI flags
record. o32 is recorded as an inferred analysis assumption
(mips_abi_basis), floating-point ABI semantics stay unverified, and the
existing refusals (FP64, NAN2008, ASEs, machine variants, ELF64) are
unchanged. The profile pins -processor MIPS:<LE|BE>:32:default through a
provider-pinned-v1 selection the bridge-reported language_id is checked
against after import; a caller-selected REA_GHIDRA_LANGUAGE_ID still
wins. The lane identity is versioned
elf32-exec-arch32-legacy-pinned-v1.

verify:ghidra:mips gains a recordless legacy round for both byte orders
that strips the ABI record declarations from a real mips32 fixture,
checks REA's interpretation against GNU readelf, asserts the pinned
language after import and the retained assumption limitations through a
snapshot round-trip.

Fixes #1620

* fix(ghidra): require an inspected absence for the recordless MIPS lane

Address review: undefined ABI flags means the record was not inspected,
not that none exists. Only an inspected absence (null) establishes the
recordless legacy admission; a merely unobserved record keeps its
specific refusal. Also document the legacy admission and its pinned
language in the MIPS provider guide.

* fix(ghidra): tighten the recordless MIPS admission boundaries

Address review: a recordless-looking header whose ABI-record inspection
left declaration sources unexamined (carved ELF with a partial section
table and no program-header record) no longer establishes the recordless
admission; it is refused with the inspection limitation retained. An
EF_MIPS_ARCH_32 header that declares an ABI is refused rather than
relabeled through the 32R2 declared-ABI lane.

* fix(ghidra): reject EF_MIPS_ABI_ON32 from the recordless MIPS lane

Address review: bit 0x40 declares the old n32 ABI. A recordless header
carrying it was admitted with the inferred-o32 assumption even though
the header itself declares n32; the recordless shape now requires the
bit clear and refuses it with a specific constraint.

* fix(ghidra): keep recordless relocatable MIPS objects off the legacy lane

The recordless admission now requires ET_EXEC: the lane and its
verification cover executable firmware images, and a recordless ET_REL
object must stay on the declared-ABI path instead of inheriting the
inferred-o32 assumption under an executable lane id. The ABI-record
requirement paragraph is scoped to the declared-ABI lane so it no longer
contradicts the recordless admission documented above it.

* fix(ghidra): name the ET_EXEC scope for recordless objects and probe the legacy lane

A recordless relocatable object is not an incomplete inspection, so its
refusal now names the ET_EXEC scope of the legacy admission instead of
claiming unavailable ABI headers remain unknown. The recordless legacy
verifier round reads the probe pair and applies the full assertMipsProbe
oracle like the standard round, and its report keeps the independent
GNU readelf receipt beside the fixture facts.

* fix(ghidra): retain the concrete ELF32 legacy compiler pin

* fix(ghidra): bind legacy profiles to fully admitted targets

---------

Co-authored-by: BaaRai <baarai@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 21:42:38 +08:00
c6503edfc4 ci: pin GitHub Actions to full commit SHAs (#1901)
* ci: pin GitHub Actions to full commit SHAs

Replace every third-party `uses: owner/repo@vN` reference in
.github/workflows with the 40-character commit SHA that the tag
currently resolves to, keeping the exact release as a trailing comment.
Each pin was checked with `git ls-remote --tags`: the floating major tag
and the commented release dereference to the same commit. Local
reusable workflows are unchanged.

Re-pin reactivecircus/android-emulator-runner from the SHA of the
annotated `v2` tag object (4c44018e) to the commit it points to
(a421e438, v2.38.0); Actions resolves both, but only the commit SHA is
an immutable reference that tooling can compare with the release.

Update the Windows Ghidra workflow trust-boundary test to require
SHA-pinned setup-node and upload-artifact references instead of the
previous floating tags.

Validation: actionlint v1.7.7 (-shellcheck= -pyflakes=, as in CI), YAML
parse of all workflows, no remaining unpinned third-party `uses:`, and
the workflow boundary/conformance tests.

* style(ci): format action identity assertion

Retain formatting edits produced before the user stopped validation.

Source inspection and git diff --check only. Tests, builds and typechecks skipped by explicit user request. GitHub review writes paused globally.

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 21:38:46 +08:00
5dd2ce3125 fix(ida): let one-shot commands exit after retaining cleanup (#1841)
* fix(ida): let one-shot commands exit after retaining cleanup

When a headless IDA request outlives timeoutMs (for example idb_open on
a large binary still running auto-analysis), REA cannot prove the
worker stopped and correctly retains the upstream connection and
private workspace. The retained stdio child and its pipes stayed
referenced, so a one-shot CLI printed its cleanup_incomplete result and
then never exited, keeping the supervisor, worker and database alive.

Detach the retained connection's child process and pipes from the event
loop when cleanup fails. A long-lived MCP server can still retry
through the retained connection; a one-shot CLI exits with status 1,
and the supervisor observes end of input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ida): report an unverified upstream worker after failed cleanup

Upstream ida-pro-mcp spawns headless workers in their own session and
deliberately leaves them running when its supervisor exits, so neither
a retained connection nor REA exiting proves that a timed-out worker
stopped. When cleanup fails before the owned worker's release is
confirmed, the cleanup diagnostics now name its private session and
state that it may still be running until it finishes and reaches its
idle timeout. Failures after a confirmed release, or before any open
was attempted, do not report a worker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ida): name the inventory sessions that block worker release

When idb_close confirmed the requested worker gone but the follow-up
inventory still found a different peer on the private input, the
upstream_worker diagnostic named the released session and could
misdirect manual cleanup. Record the session ids of the inventory
entries that actually blocked release and report those; fall back to
the requested private session only when no inventory evidence exists
(an incomplete open or an unanswered request). The field becomes
session_ids, since more than one entry can block release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ida): keep blocker evidence and backend in cleanup diagnostics

The release diagnostic reset its observed blockers on every close, so a
retry whose inventory was unavailable fell back to the already-released
session; and it kept only session ids, describing a blocking GUI
session with detached-worker guidance.

Blocker evidence is now replaced only by a successful inventory, and
the diagnostic becomes upstream_sessions with each session's id and
backend, with guidance that distinguishes workers from GUI sessions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ida): stop naming a worker after its close was confirmed

When idb_close confirmed the requested worker's release but the
following inventory failed, no blocker evidence existed yet and the
diagnostic fell back to the confirmed-absent worker. After a confirmed
close the diagnostic now reports an empty session list, meaning any
remaining same-input sessions are unknown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ida): group cleanup diagnostic cases in their own suite

The added cases pushed the reaped-worker describe block past the
150-line limit enforced by lint:built.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ida): keep ownership and pre-close peers in cleanup diagnostics

Blocking sessions dropped the parsed ownership fact, so an external
unowned worker looked the same as REA's own; and recovery inventory
recorded same-input peers only when the requested worker was missing,
so a peer seen beside it was lost if the following close failed.

Each reported session now carries owned (null when no inventory has
reported it), and recovery records every same-input session until a
later successful inventory replaces that evidence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ida): drop a confirmed worker from retained cleanup blockers

Recovery inventory now records the requested worker beside any peers,
so once idb_close confirmed its release a failing follow-up inventory
still reported it as unreleased. Remove the confirmed session from the
retained blockers at close confirmation and keep the remaining peers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ida): retry cleanup through a detached stdio connection

The retry coverage used a recording connection whose detach() only
counts calls, so it could not show that detaching keeps a retained
connection usable. Run a real stdio supervisor in a long-lived host,
fail the first post-close inventory, and require the retry through the
same detached connection to confirm release and remove the workspace.
The test fails if detach() destroys the pipes instead of unref-ing them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ida): close the stdio supervisor in failed retry tests

Merge main and give its new IdaNativeProtocol connection double the
required detach() method. The real-stdio retry test now closes its
connection in an unconditional teardown, so a failing assertion cannot
leave the supervisor waiting on stdin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ida): share the cleanup diagnostic expectation in session tests

Repeated upstream_sessions assertions and the unrelated live peer
literal pushed repository duplication over the jscpd threshold. Build
the expected diagnostic with one helper and reuse one peer constant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ida): distinguish observed ownership from unreported input bindings

* fix(ida): keep requested-ID conflicts in recovery cleanup

Recovery inventory kept only same-input entries, unlike the startup and
post-close filters. After an explicitly rejected open, an entry under
the requested session ID on another input was dropped, so cleanup could
close the connection, remove the private workspace and report success
while that session stayed registered. Match the requested ID as well so
the entry is retained as a blocker and cleanup stays incomplete.

Also split the cleanup diagnostics suite under the lint:built function
length limit, and make two startup-inventory fixtures pass a defined
session id so the test file typechecks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Xingchuan Ma <xingchuan.ma@msn.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: morluto <morluto@users.noreply.github.com>
2026-10-13 21:36:52 +08:00
Jose CamargoandGitHub 811bd9f635 docs(contributing): explain Windows native prerequisites for source checkouts (#2295) 2026-10-13 21:34:59 +08:00
Danish Rafie EkaputraandGitHub 9e019ad27d feat(clients): register Factory Droid during setup (#2309) 2026-10-13 21:34:38 +08:00
117bd23dd5 test(clients): verify Qwen Code on native Windows (#2103)
Co-authored-by: AICodeLion <190163814+AICodeLion@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 21:27:45 +08:00
Saim ShafiqueandGitHub 6637161e02 fix(ghidra): warn when callee/caller reads stop at a truncated function body (#2360)
procedure_callees and procedure_callers returned a bare address list, so an
inferred no-return call that truncates Ghidra's function body silently dropped
real edges (fixes #2326). Wrap both views in the function-observation envelope
with noReturnCallLimitations, matching procedure_info, procedure_assembly,
procedure_pseudo_code, read_function_instructions, and analyze_function
(precedent: 69a8b3a95). Version the boundary-observation contract to
ghidra-terminal-call-limitations-v2 so older snapshots cannot replay
unqualified results.

Validation: red->green unit test for the envelope requirement, MCP boundary
evidence test extended for both views (limitations retained through Evidence
and snapshot replay), 8 harness test files + ghidra behavior tests green,
check:fast, docs:check, verify:test-discovery green. Real Ghidra host lane
unavailable here (no Ghidra installed); Java change mirrors the existing
procedureInfo call pattern.
2026-10-13 21:26:57 +08:00
dea770d5d9 fix(javascript): retain getter receivers through local aliases (#2778)
* fix(javascript): retain getter receivers through local aliases

* fix(javascript): resolve projected private getter receivers

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 21:26:13 +08:00
48b92e1be1 fix(go): classify JVM class files instead of universal Mach-O for CAFEBABE bytes (#2458)
* test(go): pin JVM class files as unsupported Go images

The regression fails on main: readGoBinaryImage(javaClassFile()) reports
"Universal/fat Mach-O containers require selecting a thin slice" for a
JVM class file whose bytes 4..7 are minor/major version, not nfat_arch.

* fix(go): classify JVM class files instead of universal Mach-O

Route the four FAT magics through the shared readFatContainerHeader and
classifyFatContainerIdentity facts added for the same disambiguation in
the artifact and target lanes (921bb9575). Keep the thin-slice failure
only when the container identity is confirmed Mach-O; a JVM class file
now falls through to the generic only-ELF/PE/thin-Mach-O failure.

* test(go): pin universal Mach-O thin-slice failure with FAT fixtures

Cover the kept behavior with the repository's own fatImage/machoImage
builders (narrow and FAT64 tables) so the regression also proves real
universal containers keep the slice-selection failure.

* test(go): cover little-endian FAT magics in the universal container control

* fix(go): require positive universal Mach-O identity

Require positive Mach-O identity before reporting a universal thin-slice remedy; malformed FAT-shaped bytes are not proof. Add the JVM constant-pool CPU lookalike to the existing fixture control.

Source inspection and git diff --check; tests/builds/typechecks/CI reruns skipped at explicit user request.

* fix(go): retain malformed FAT header diagnostics

Preserve the owning FAT header parser reason for malformed headers while requiring positive Mach-O proof before the universal-slice remedy.

Source inspection and git diff --check only. Tests, builds and typechecks skipped by explicit user request. GitHub review writes paused globally.

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 21:25:32 +08:00
003707f712 fix(verification): align Linux Hopper setup lane with current contracts (#1942)
* fix(verification): align Linux Hopper setup lane with current contracts

* fix(verification): retain orphan checks on clean Linux runners

* fix(verification): detect Hopper through alternate launcher paths

* fix(verification): report identified Hopper leaks before proc errors

* fix(verification): distinguish Hopper string recognition from byte fidelity

* fix(verification): report full literal decoding coverage explicitly

* test(verification): require a clean host before orphan checks

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 21:24:40 +08:00
dd4bfed614 fix(javascript): keep ASAR-named files without an ASAR header opaque (#2347)
Inventory expands an ASAR-named filesystem file only when its bytes are
an ASAR. JavaScript reconstruction expanded it on the suffix alone, so
a directory holding any other file named *.asar failed the whole
analysis with an invalid ASAR header.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 21:23:58 +08:00
xingzhouandGitHub 84ec40f1af fix(browser): distinguish SVG links from HTML XLink attributes (#2480)
* fix(browser): respect SVG desc and title HTML integration points

* fix(browser): preserve moved SVG XLink destinations

* fix(browser): bound concurrent SVG identity reads
2026-10-13 21:23:33 +08:00
MasonGitHubDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com>morluto
5eb296649d fix(native): refuse inspect_signature on non-Mach-O targets (#2262)
* fix(native): refuse inspect_signature on non-Mach-O targets

This provider's signature inspection covers Mach-O code signatures only.
On a xar pkg, codesign's "code object is not signed" verdict cannot see
the archive-level signature layer, so a signed or invalidly-signed package
was reported as signed:false with "Artifact is not signed." Match the
existing inspect_macho/list_architectures gate and report
capability_unavailable instead of an unsigned claim for a layer that was
never evaluated.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(native): scope the signature-gate comment to this provider

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(native): align signature capability discovery with Mach-O support

Bind inspect_signature availability to Mach-O targets in capability discovery and align the public tool description.

Source inspection and git diff --check; tests/builds/typechecks/CI reruns skipped at explicit user request.

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 21:22:42 +08:00
cae7620b77 fix(apple): report a recognized connector that cannot name its owner (#2484)
* fix(apple): report a connector that cannot name its owner as an omission

A connector is recognized by class name and then dropped by an early
`continue` when its owner cannot be resolved. Nothing recorded the drop, so
connection coverage derived `complete` with `examined: 0` and `omitted: 0`
for a connector that was present, recognized and discarded.

Both producers of that coverage contract are affected and are changed
together, because they share the facet and the reason value:

- the keyed-archive parser records the drop as `unresolvedConnections` and
  reports it through the `connections:<path>` facet, the document
  `connection_count` and the graph omission total;
- the NIBArchive projection in the artifact adapter recorded nothing at all,
  and the projected dictionary carried no trace of the dropped connector for
  a downstream consumer to find. Its count is taken where the connector is
  discarded and plumbed through the same per-document omission channel that
  already carries incomplete hierarchies, so the reason follows the cause.

An unresolved source cannot be represented as a connection at all, because
connection identity requires a non-empty source; that is unlike an unresolved
destination, which the graph already retains as an unresolved edge.
`connection_limit_reached` stays reserved for connections dropped by the hard
cap and `serialized_view_hierarchy_incomplete` for hierarchy omissions.

`connection_count` also includes the unresolved connectors. That field already
counts connectors the parser recognized but could not emit, because
connections dropped by the hard cap are counted there too, so unresolved
connectors follow the existing rule rather than a new one.

Verified with 98 focused Interface Builder and catalog tests, check:fast and
verify:test-discovery on macOS arm64, and through the public CLI: a rooted
archive holding one ownerless UIRuntimeOutletConnection reported complete with
examined 0 and omitted 0 before this change and reports partial with the reason
connection_unresolved and omitted 1 after it.

* fix(apple): count ownerless NIBArchive connectors and keep the limit reason

Greptile found two defects in the NIBArchive half of this change.

The document summary was left unchanged, so the same recognized connector
counted as one in a keyed archive and zero in a NIBArchive. The dictionary
parser only sees connectors that were projected, so the omission recorded
where the connector was dropped is added to the matching document here.

The facet reason was overwritten unconditionally, so a NIBArchive holding both
a connection dropped by the limit and an ownerless connector reported
connection_unresolved and lost the cause a higher limit could recover. The
existing reason is kept when the graph already named one, which is the
precedence the keyed-archive producer applies.

Verified with `npm run test:focused` over the bundle suite (42 passed, 1
skipped), `npm run lint` (0 errors) and `npm run check:fast` on macOS arm64.
Reverting the reason precedence alone fails only the limit case; reverting the
document count fails the count assertion in both cases.

The new case moved into its own describe block because the enclosing callback
crossed the repository's max-lines-per-function bound otherwise.

---------

Co-authored-by: 吴佳浩 <wujiahao@supremind.com>
2026-10-13 21:19:17 +08:00
c44d17fd3c fix(pwntools): let callers widen the artifact input bound (#2486)
* fix(pwntools): let callers widen the artifact input bound

The pwntools adapters rejected every artifact over 32 MiB before any
analysis ran, and nothing in configuration, environment, CLI or MCP
could raise that bound, so real recorded core dumps stayed unanalyzable.

Resolve the bound from REA_PWNTOOLS_MAX_INPUT_BYTES in front of the
shipped default. An absent value keeps 32 MiB, and a value outside
1..1073741824 bytes is refused as unusable provider configuration before
the artifact is read instead of falling back silently.

* fix(pwntools): name the bound variable in its own refusal

An unusable REA_PWNTOOLS_MAX_INPUT_BYTES travelled through the shared
engine-absence helper, which always stamped configuration_key
REA_PWNTOOLS_PYTHON, so a caller following that field was pointed at the
Python executable instead of the bound it had just set. The guide also
described the refusal as an invalid request while the decoder reports
provider_unavailable.

Let the failure context carry the key under review and pass the bound's
own name at that call site, then state the code callers actually receive
in the guide. Assert the key on the projected rejection so the engine
default and the bound override each stay attributed to the right setting.

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 21:18:12 +08:00
FengCiandGitHub 59d33a2733 fix(native): keep lipo path echoes out of FAT records (#2605)
* fix(native): keep lipo path echoes out of FAT records

Parse FAT records only after separating the exact echoed operand path. Pass the same lipo operand at all three consumers and cover multiline paths through parser, slice-reader and compiled CLI regressions.

* fix(native): use parsed lipo framing for container offsets

* test(native): use explicit FAT fixtures for CLI regressions
2026-10-13 21:16:49 +08:00
11268fe241 perf(artifacts): share sealed inventory and report in inspect_artifact (#2690)
inspect_artifact parsed a fresh inventory and inspection report but left
both mutable, so each later Evidence boundary cloned and re-digested the
complete payload: createArtifactInspection re-authenticated the nested
inventory Evidence, createAnalysisExecution re-parsed the report, and
recordEvidence re-authenticated the outer Evidence.

Seal both exclusively owned values with freezeJsonSnapshot so those
boundaries take the existing immutable-snapshot path. Output is
byte-identical.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:16:29 +08:00
e46bed73f1 fix(managed): decode the CIL no. prefix (#2241)
The CIL opcode table held five of the six ECMA-335 prefix opcodes and
omitted no. (FE 19 <unsigned int8>, III.2.2). A method body using it was
reported as malformed with "Unsupported CIL opcode 0xfe19", losing its
instruction counts and normalized IL digest.

Decode it with its one-byte check mask and treat it as a prefix, so it
shares an instruction boundary with the opcode it modifies.


Claude-Session: https://claude.ai/code/session_012pRGskbPGjT444CgJfuhhj

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-13 21:16:00 +08:00
ece15ed6a1 fix(electron): identify each unresolved scenario path (#2279)
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 21:15:33 +08:00
97dc6581f4 fix(dotnet): name the instruction when a CIL operand leaves the method body (#2697)
The decoder already reports "Instruction X at IL offset N leaves method
body" when an operand's computed end passes the IL body, but only after
the operand was read. Operand readers use Buffer reads, so an operand
cut off by the body end threw a RangeError first. The catch-all stored
Node's message ("Attempt to access memory outside buffer bounds" or an
offset RangeError) as body.issue, dropping the instruction and offset.
A body ending in a lone 0xFE two-byte opcode prefix failed the same way.

Treat a RangeError from the operand read as the operand leaving the body
so the existing diagnostic applies, and check the second opcode byte
before reading it. Malformed status, partial coverage and earlier
decoded instructions are unchanged.

Fixes #2677

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 06:14:31 -07:00
cdc41fc86c fix(managed): classify ReadyToRun images without ILONLY as modern .NET (#2075)
* fix(managed): classify ReadyToRun images without ILONLY as modern .NET

crossgen2 clears COMIMAGE_FLAGS_ILONLY and sets IL_LIBRARY on ReadyToRun output. inspect_managed_artifact checked the clear ILONLY flag before the ReadyToRun signature, so real ReadyToRun assemblies were reported as runtime_family mixed-clr-native and implementation cpp-cli-mixed, and cil-and-ready-to-run was unreachable for crossgen2 output. Check the ReadyToRun signature first; a clear ILONLY flag without it still reports mixed CLR/native code.

* test(managed): exhaust capacity through inspected assembly references

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 21:13:18 +08:00
记得吃中饭andGitHub 9c14999efd feat(ghidra): support raw NMOS 6502 analysis (#2079)
* feat(ghidra): admit raw NMOS 6502 analysis

* test(ghidra): require verified POSIX verifier cleanup

* test(ghidra): verify 6502 low-memory raw imports

* docs(skill): document raw NMOS 6502 support

* fix(ghidra): preserve requested Windows raw source paths

* docs(ghidra): include raw 6502 in Windows installation scope

* test(ghidra): verify Windows raw junction rejection through CLI and MCP

* fix(validation): make fixtures portable and preserve explicit Codex home

* fix(ghidra): attest raw mapping state and address space

* fix(ghidra): align diagnostics and regression groups

* fix(ghidra): bind raw attestation to language address space

* chore(ghidra): avoid verifier insertion conflict

* docs(ghidra): align linked raw admission guides

* fix(ghidra): retain 6502 verifier workspace on failure

* test(ghidra): isolate Windows authority doctor fixture

* fix(binary): re-admit Windows raw paths on session reopen

* test(binary): require fresh provider execution after path change

* Constrain 6502 to raw lifecycle and snapshot targets

* Bypass saved reads for unadmitted Windows paths

* Guard direct CLI snapshot replay on Windows paths

* fix(snapshot): gate Windows Ghidra replay on native admission

* fix(snapshot): constrain nested 6502 subjects to raw images

* test: make dyld slice fixtures portable
2026-10-13 21:13:01 +08:00
352c46aa75 fix(javascript): keep CommonJS require file URLs unresolved (#2077)
* fix(javascript): keep CommonJS require file URLs unresolved

require("file:///dep.cjs") was reported as a resolved dependency of
the inventoried dep.cjs although Node rejects the string with
MODULE_NOT_FOUND. The shared module-specifier resolver decoded file URLs
before the declared CommonJS loader semantics.

File-URL decoding now applies only to non-require module specifiers, so a
CommonJS file URL reports external with its declared string and source
evidence preserved. ESM file URLs and ordinary relative require paths
keep their established lookup.

* test(javascript): consolidate CommonJS file URL public workflow coverage

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 21:12:50 +08:00
91830f1e0e feat(managed): name the declaring type of call and field targets (#2163)
* feat(managed): name the declaring type of call and field targets

Call edges named external targets by member name alone (Join, ToJson,
WriteAllText), and method-spec edges had no name at all, so an analyst could
not tell which library a method calls or find every use of a type without a
decompiler. Field accesses through MemberRefs had no name either.

Name each TypeDef, TypeRef, and generic-instantiation TypeSpec once, in the
namespace-qualified form TypeDef full_name uses, and add member_refs
parent_name, call_edges target_declaring_type, and field_accesses
field_declaring_type. A method-spec target reports the generic method it
instantiates. Fields, member references, methods, and method specs now
resolve through one token-keyed MemberCore lookup instead of three maps.
Evidence recorded before these fields reads them as null.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(managed): validate spec blobs and member kinds before naming targets

Address review findings on declaring-type naming:

- A generic instantiation must name a TypeDef or TypeRef, so a TypeSpec
  base is reported as malformed instead of followed. Naming no longer
  recurses.
- TypeSpec and MethodSpec instantiation blobs are decoded with the
  member-signature grammar. Malformed blobs and out-of-range TypeDef rows
  are reported against their owning row and make coverage partial.
- A MethodSpec reads both columns. An invalid Method coded index is
  reported; a readable Method keeps its name beside a malformed
  instantiation.
- MemberCore carries its signature kind, so a field operand is named only
  by a field or field-signature MemberRef. This replaces the token-table
  guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(managed): validate MethodSpec arity and report unsupported spec blobs

- A MethodSpec now checks that its method signature is generic and that
  the instantiation supplies as many type arguments as it declares. A
  non-generic target, a field reference, or a count mismatch is reported
  as invalid-row; the readable method keeps its name.
- Unsupported TypeSpec and MethodSpec forms raise an owner-scoped
  unsupported-signature issue instead of reading as an intentional
  unknown.
- MethodSpec reading moves to ManagedMethodSpecs.ts, and its tests get
  their own describe block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(managed): reject non-method signature headers and invalid TypeRef scopes

The method signature header reader now owns the calling-convention rule
that only FNPTR applied: FIELD, LOCAL_SIG, PROPERTY, and GENERICINST
headers, the reserved bit, and EXPLICITTHIS without HASTHIS are malformed.
A MethodSpec whose method has a PROPERTY header is no longer accepted as a
generic method, and member signatures with such headers no longer decode
as methods.

The type-name lookup validates a TypeRef's ResolutionScope coded index and
reports an out-of-range scope as invalid-row, keeping the readable name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(managed): hold member rows to MethodDefSig and FieldSig headers

Method signature headers are now checked in context. MethodDef and
MemberRef rows accept only the MethodDefSig/MethodRefSig headers: DEFAULT,
VARARG, or GENERIC with DEFAULT. FNPTR keeps the unmanaged conventions
compilers emit for function pointers. A field signature must be exactly
FIELD, so a flagged FIELD header is malformed and never names a field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(managed): accept MethodSpec arity only from a fully decoded signature

The arity check read only the target's signature header, so a truncated
generic method signature passed. Under type selection a MethodDef target
outside the selection is never decoded elsewhere, so coverage stayed
complete. The arity now comes from a transient full decode, and only a
decoded method signature supplies it. signature() keeps its raw length
and digest around the shared decodeSignature.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(managed): decode each MethodSpec target signature once

edgeTargets caches the generic arity, including a failed decode, by
target method token, so many instantiations of one generic method share
a single signature decode. Unity.InputSystem has 472 distinct MethodSpecs
over about 133 target methods.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(managed): validate spec type operands and transitive reference scopes

* fix(managed): retain readable names when a scope row is unreadable

* test(managed): pin owner-scoped spec operand and scope-chain checks

e59217c83 validates spec type operands inside specSignature, so a
malformed generic TypeSpec base is now invalid-row under its owning
TypeSpec row; the three stale expectations follow that. New cases cover
a TypeSpec argument and a MethodSpec argument that name a missing TypeRef,
and a TypeRef whose ResolutionScope names itself. The scope cases move to
their own describe block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(managed): keep field targets from naming MethodSpecs

A MethodSpec over a field-signature MemberRef inherited kind "field", so
an ldsfld on that MethodSpec published a field name and declaring type
for malformed IL. A MethodSpec now borrows its target's name only when
the target is not a field; the arity check still reports it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(managed): reuse owner-decoded signatures for MethodSpec arity

MemberRef signatures, and MethodDef signatures in the type selection, are
already fully decoded, but the MethodSpec arity check decoded them again
and admitted a second transient allocation, which could exhaust the
budget. MemberCore now carries the owner's genericArity, and only a target
nothing decoded, such as an unselected MethodDef, falls back to the raw
signature. signatureGenericArity is the one rule for both paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 21:12:15 +08:00
df04f99df0 fix(readiness): deduplicate stage-level missing-evidence findings (#2203)
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 21:10:56 +08:00
d2e16b2a49 fix(sqlite): attribute unknown column selections to columns (#2774)
Every worker "selection" rejection was projected onto the table argument,
including an exact column name that does not exist in an existing table.
Give column rejections their own worker reason and map each input reason
to the caller argument it requires correcting. Table rejections keep the
table path.

Fixes #2613

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:03:14 +08:00
928b4d5fe4 fix(plist): decode formatted XML dictionary keys (#1843)
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 20:59:02 +08:00
jhuang-twandGitHub 4ea24f9767 fix(ida): retain private input after ambiguous open (#1845)
fix(ida): retain private input after unverified open
2026-10-13 20:58:51 +08:00
2a61deafc6 fix(adb): report the device state when a push cannot probe the path (#2354)
A push without overwrite read any unclassified failure of its existence
probe as an existing path, so an offline or unauthorized device was
reported as "the device path already exists". With overwrite it pushed
after a probe that had not established anything. The probe's own
refusal is now returned in both cases.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:56:41 +08:00
Mykola DementievandGitHub 5f8af4ee5f feat(ida): resolve containing procedures and report the admitted program as the document (#2374)
* test(mcp): expect trace_feature to report references the provider truncated

A provider that reports a capped xrefs read makes the trace truncated with a residual unknown for that target. An explicit complete report and a provider that reports nothing leave the trace as before.

* fix(mcp): report references a provider truncated in trace_feature

Executions gain an optional flag for a capped xrefs read, set by the IDA adapter from the upstream cap. trace_feature marks the trace truncated with a residual unknown for that target only when the provider reports a truncation; a provider that reports nothing keeps its previous result. The trace_feature workflow profile is revised so earlier results are not replayed.

* refactor(ida): report reference truncation whenever the runner observed an xrefs cap

* test(mcp): cover session forwarding, per-target state, native reports and failures of the trace coverage

* test(mcp): format the session forwarding test

* test(ida): expect headless IDA to resolve containing procedures from lookups and segment facts

Cover positive and negative containment, segment boundaries including addresses above 2^53, failures of the lookup and of the segment survey, cancellation, input validation and the admitted program as the only document.

* test(ida): expect feature tracing and address context to work on headless IDA

Run trace_feature, including a capped reference read, and inspect_address_context with and without an explicit document through the IDA session client.

* test(mcp): expect trace_feature availability to follow the search operations it calls

* fix(mcp): derive trace_feature availability from the search operations it calls

* feat(ida): resolve containing procedures and report the admitted program as the document

The modern headless profile resolves an address through the function lookup and, when no function contains it, tells a mapped address from an unmapped one using the database-scoped minimal survey. A survey that is missing, truncated or inconsistent fails the read. list_documents reports the single admitted program. The attached profiles do not declare either operation.

* docs(ida): describe containing procedures, the admitted document and truncated feature traces

* test(ida): verify containing procedures, the admitted document and feature tracing on real IDA

* docs(testing): list the headless IDA workflows the real lane verifies

* test(ida): make each survey failure case isolate a single defect

* test(ida): pin large addresses, segment order, document identity, attached refusals and each trace_feature prerequisite

* test(mcp): expect a repeated reference source to be resolved once in trace_feature

* fix(mcp): resolve the containing procedure of a repeated reference source once

* test(ida): trace the fixture procedure and derive probe addresses from its facts in the real lane

* test(process): format processCaptureLifecycle.test.ts after the merge

* test(ida): declare the alignment each synthetic universal slice offset satisfies

* refactor(ida): move the real lane's feature trace assertions into a helper

* test(ida): expect the real lane's feature trace check to accept references to every matched address

* fix(ida): check each trace reference against its own matched address in the real lane

* test(ida): pin that the new operations reject a document other than the admitted program

* refactor(ida): move the real lane's mapped non-function probe into a helper

* test(ida): expect the real lane's mapped data probe to skip strings inside functions

* fix(ida): try the target's strings in order for the mapped non-function probe

* test(ida): declare the real lane helper types for the verifier tests

* test(ida): expect the real lane to reject a listed string that resolves as unmapped

* fix(ida): fail the real lane when a listed string resolves as unmapped
2026-10-13 20:56:20 +08:00
e0bd31c18c fix(ghidra): bound the Java version probe output (#2361)
The Java version probe captured its output with maxBuffer set to Infinity, so a
chatty or hostile JVM selected as the Ghidra launcher could grow the captured
stdout/stderr without limit and exhaust REA's memory during installation
discovery. Bound the capture to 1 MiB, matching the budget already used for
other Java listings. A probe that exceeds the budget yields no observation and
is reported through the existing failed-probe path, the same one the Java check
already uses whenever the probe cannot produce an observation.

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-10-13 20:55:40 +08:00
8e6f37e2a0 fix(formats): preserve high bits in remaining magic-byte checks (#2384)
* fix(formats): preserve high bits in remaining magic-byte checks

Node's "ascii" decoder masks the high bit, so byte-for-byte signature
comparisons accepted lookalikes such as CD DA ("MZ" | 0x80) or a
high-bit "bplist00"/"NIBArchive" header. ac57c88ef fixed this for the
shared DOS/MZ reader; apply the same latin1 decoding to the remaining
signature checks: NativeAOT and Windows native loader MZ/PE, binary
plist and NIBArchive detection, and PNG IHDR checks.

Display-name decodes (section and metadata stream names) are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* style(windows): format signature admission regressions

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 20:54:37 +08:00
43738bec57 fix(targets): retain unsupported PE machine in diagnostics (#2448)
Assisted-by: OpenAI GPT-6 <noreply@openai.com>

Co-authored-by: Aurantium-47 <280805072+Aurantium-47@users.noreply.github.com>
2026-10-13 20:52:17 +08:00
03ab521db3 fix(process): resolve POSIX backslash basenames through PATH (#2471)
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 20:51:44 +08:00
0ca70e121c fix(ghidra): pre-create the launch-support config directory (#2470)
* fix(ghidra): pre-create the launch-support config directory

Ghidra's launch support resolves its JDK before the headless run starts and
persists that choice under `<config>/ghidra/<release directory>`. Inside REA's
private runtime a child JVM cannot create those directories, so the first
Windows Ghidra query aborted at ghidra.launch.AppConfig.saveJavaHome with:

    java.io.IOException: Failed to create directory:
    ...\config\ghidra\ghidra_12.1.4_PUBLIC
        at ghidra.launch.AppConfig.saveJavaHome(AppConfig.java:145)
        at LaunchSupport.findJavaHome(LaunchSupport.java:210)

LaunchSupport then fell back to its interactive prompt, read EOF, and reported
"JDK 21+ (64-bit) could not be found and must be manually chosen!", so the
provider failed with "Ghidra exited before bridge startup" even though
`rea doctor` reported a valid Ghidra installation and JDK.

Create the release directory while this process still owns the runtime, leaving
Ghidra only a file write inside it. The release coordinate comes from the
installation's Ghidra/application.properties (application.version plus
application.release.name) with the extraction directory name as a fallback. On
Windows each level goes through the native private-runtime owner, which opens
every existing component and creates only the final one.

Verified on Windows x64 with Ghidra 12.1.4, a 64-bit Temurin JDK 21, and REA's
packaged native addon: `rea inspect <x64 PE DLL> --provider ghidra` and the
equivalent MCP `binary_overview` call both complete and report 21 segments,
1436 procedures, and 2761 strings. Windows Ghidra remains read-only P0; this
change does not extend that boundary.

* style(ghidra): format launch configuration regressions

* fix(ghidra): reuse Java Properties parsing for launch release identity

---------

Co-authored-by: AbdulAzizShanShi <441650742@QQ.COM>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 20:51:18 +08:00
f63674cac0 docs(browser): clarify attach coverage and Chromium setup (#2287)
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 20:44:41 +08:00
ChaoqiandGitHub d3a55720fc fix(setup): keep Hermes config numbers as written (#2368)
Setup and uninstall re-printed every number in an existing Hermes
config.yaml from a JavaScript number. Hermes reads the file as YAML 1.1
with exact integers, so a Discord channel ID lost its low digits, an
octal mode such as 0600 became 600, and 0.0000001 became 1e-7, which
YAML 1.1 does not define as a float. Two IDs that round to the same
double were also rejected as duplicate keys, so setup could not
register at all.

Hermes YAML parsing and editing move to HermesConfigurationYaml. A parsed
number is written with its source spelling while that spelling still
reads as the node's value, the rule yaml's own boolean tag follows, and
two keys are duplicates only when both their value and their spelling
match. Parsed values stay JavaScript numbers.
2026-10-13 20:44:17 +08:00
36132d3e17 fix(managed): classify 32-bit-preferred assemblies as platform neutral (#2342)
The CLI header's 32BITREQUIRED and 32BITPREFERRED bits are one two-bit
field. Reading 32BITREQUIRED on its own reported Prefer32Bit assemblies
(flags 0x00020003) as x86-only with requires_32bit true, and left
anycpu-prefer-32 reachable only through the reserved combination.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:43:39 +08:00
1ca5fd38b4 fix(cli)!: keep a selected JSON null in --filter-output output (#2333)
createCliResultOutput rendered an explicit null selected by
--filter-output as the empty document {}, the same output it uses when
the selection has no value at all. On the streamed JSON/JSONL path,
a consumer could not distinguish a selected null from a missing field.

Write a selected null as null in streamed JSON and JSONL. This also
preserves an unfiltered top-level null. The {} fallback stays for
undefined, functions and symbols. The --full-output envelope already
carried data: null for a selected null. The --token-count path still
hands results that fit the formatted-string limit back to Incur and
prints 0 for a selected null. Other formats and Incur-owned result
paths are unchanged.

Consumers must accept JSON null for a present null and distinguish it
from a missing selection. Use --full-output if an object envelope is
required. Keeping the old shared {} representation would continue
hiding this distinction.


Claude-Session: https://claude.ai/code/session_019TTGAv6fe859ebbCLLfLHP

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:43:20 +08:00
Saim ShafiqueandGitHub 84cf7791a8 fix(call-path): keep caller-only unknown coverage from blocking not_found (#2371)
* fix(call-path): keep caller-only unknown coverage from blocking not_found

build_call_path expands only callee edges, so relationship coverage that
is unknown solely because callers are unobservable (the modern headless
IDA profile) no longer marks the searched callee closure incomplete.
The caller caveat stays visible in limitations, and callee-side unknowns
still yield unknown. Fixes #2322.

* test(call-path): cover IDA-capped callees beside unknown callers

Greptile review noted the poll-timing coverage only exercised unknown
callees, leaving the partial callees IDA returns at its 500-result cap
untested. Add the combined unknown-callers/partial-callees case:
isCallerOnlyUnknownCoverage must stay false and buildCallPath must keep
search_scope.exhaustive false, so a future change cannot silently report
not_found when IDA has omitted callees.
2026-10-13 20:42:40 +08:00
ec4f4ddc58 fix(ghidra): read UTF-8 signed language definitions (#2772)
Buffer#toString kept a leading UTF-8 byte order mark as U+FEFF, so the
strict XML parser rejected a valid .ldefs file as content outside the root
element and aborted the whole language catalog read. XML 1.0 section 4.3.3
defines the mark as an encoding signature; decode with TextDecoder, which
consumes it. Malformed XML is still rejected.

Fixes #2533

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:41:28 +08:00
8823c55b0c feat(ghidra): decode Swift String literals in function dossiers (#2049)
* feat(ghidra): decode Swift String literals in function dossiers

Swift passes a String as two words. Small literals keep their UTF-8 bytes
inside those words, and immortal large literals keep a count plus a pointer
to their bytes biased by 32. Ghidra creates no data reference for either, so
analyze_function reported no strings for Swift code.

On AArch64 programs that contain Swift metadata, the Ghidra bridge now runs
constant propagation over the function and decodes register pairs where
Swift consumes a String: consecutive argument registers at calls and tail
calls, x0 and x1 at returns, and both registers of one STP. Large literals
must resolve to terminated bytes inside one initialized block that is not
executable. A pair whose registers both come from conditional selects is
skipped, because Ghidra forks each select without correlating the
condition and could otherwise pair words from different cases.

Decoded values are a derivation from the ABI layout rather than observed
references, so they appear in a new optional swift_string_literals field
with their source instruction, registers and raw words, and in a matching
inspect_analysis_view facet. referenced_strings is unchanged.

Refs #2030

* fix(ghidra): bound Swift literal reads and compare decoded strings

Large immortal literals are measured against the remaining heap before
allocation, and a failed fit stays visible as a limitation. The canonical
empty string is kept. Small and large literals now require matching
address nullability, and function comparison treats decoded literals as
string evidence without equating a missing record to an empty one.

* fix(ghidra): budget Swift literal decoding and tighten use sites

Large literals now draw on one heap budget per dossier, measured once
before decoding, instead of a separate check per literal. A literal that
does not fit is listed in a new swift_string_literal_omissions field
with its address, byte length and reason, so consumers read omissions
from structured data rather than from limitation text.

strings_names comparison checks referenced strings and names first and
reports a change there even when Swift literal coverage differs; it
returns unknown only when those facts match and literal coverage is
absent on one side or partial.

A computed jump counts as a tail call only when it has no resolved
target inside the function, so jump tables no longer qualify, and a
return requires a RETURN p-code operation, so traps no longer qualify.

Decoded literals keep control characters, which Swift strings may
contain and JSON escapes. The Swift verifier builds the literal fixture
for arm64 on any Mac host and now also covers the empty string and a
literal containing a tab.

* fix(ghidra): charge escaped JSON and follow copied selects

The Swift literal budget now charges the worst case per UTF-8 byte: UTF-16
text, six escaped JSON characters held in Gson's growing buffer and in the
returned String, and the UTF-8 bytes written to the socket. Literals made
of control characters can no longer exceed the reserved heap.

A jump counts as a tail call only when every resolved target lies outside
the function. The conditional select check follows register copies back
to their definition, so selects copied into argument registers are
recognized like selects written there directly.

strings_names reports a change when both dossiers decoded different
literals at the same use site, even when either side has omissions; it
returns unknown only when no such use site disagrees.

* fix(ghidra): bound literal records and trace selects across branches

Every Swift literal record now pays a fixed cost against the dossier
budget in addition to its text, so many empty or small literals are
bounded as well. The first record that does not fit is kept as an
omission and decoding stops there, which keeps the omission record
itself bounded; omissions now describe small or large literals.

The conditional select check searches every predecessor path for the
reaching definitions, following register copies, so selects made
before a branch into the use block are recognized.

A dossier records Swift literals and their omissions together. Coverage
reads omissions first, and strings_names compares every use site that
neither side omitted: a decoded literal is known where it was recorded,
and an absence is known only in a dossier whose decoding examined every
use site.

* fix(ghidra): stop every flow at the literal budget and route omissions

Ending one flow from the evaluator leaves other queued flows running, so
the budget now records a shared stop that every flow checks before it
examines another instruction. A record that exceeds the budget is routed
by that capacity result rather than by whether text was decoded, so a
small literal over budget becomes an omission instead of a literal.

When coverage is partial, the per site comparison keeps each literal's
representation as well as its value, matching the full strings_names
projection.

* fix(ghidra): keep unresolved Swift tail-call sites unknown

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 20:41:10 +08:00
YsopwnableandGitHub 2f3dee3fcf test(javascript): verify Windows path case and load scope (#2635)
Select the Windows curated lane for edits to the shared runtime path matcher and its workflow suite. Strengthen the existing POSIX/Windows mapping controls with matching basis, coherent target/script coordinates, caller spelling and static load-state assertions.

Follow-up to #2422; thanks to @Aurantium-47 for identifying the recorded-path grammar issue.

Verified with actual Windows 11 x64, official Node 24.18.0 and production CLI/MCP workflows using published npm 11.16.0 module Evidence. Four exact/mismatched implicit/explicit mapping cases pass with unchanged normalized hashes. The capture provider was Node Inspector; no IDA, Ghidra or Electron GUI verification is claimed.

230 focused tests, all eight CI static tasks, architecture guards and documentation checks pass after rebasing onto main fb28ec153. The unrelated release-checkpoint and dyld fixture corrections were integrated from upstream; no patches to those files are included here. Final Windows MCP replay at e6e86946d preserves all four normalized hashes, with no owned Node processes remaining. Resource probes ran serially at BelowNormal priority with one CPU and 512 MiB Node heaps; calls took 251–620 ms and the sampled process-family working-set sum peaked at 499,380,224 bytes. Source pins, provenance and measurement limits are recorded in experiments/analysis-efficiency/windows-posix-reconciliation.md.

All 34 jobs in current-head CI run 38391921571 passed, including the Windows capability/package lane, all six portable test shards, JavaScript recovery and the required aggregate. Current-head Codex and Greptile reviews are clean; both prior scope-selection findings were fixed and resolved.
2026-10-13 20:37:48 +08:00
d853744969 fix: preserve process identities in pwntools cleanup errors (#2284)
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 20:28:31 +08:00
fcfa5b7e88 fix(artifacts): distinguish JVM classes from universal Mach-O files (#2327)
* test(artifacts): reproduce JVM class inventory misclassification

* fix(artifacts): validate FAT declarations before classifying executables

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 20:27:38 +08:00
a8b50ea147 refactor(firmware): split verification into staged functions (#2713)
Refactor firmware verifier into staged functions

Restructure scripts/verify/firmware/analysis.mjs for readability without changing behavior.

- Move prerequisite checks into checkPrerequisites()
- Split the main flow into stageCli, stageMcp, stageExt4 and stageGhidra
- Extract timeouts and size limits into named constants
- Rename cli/call helpers to runCli/callTool

Checks, order of operations, error messages and env vars are unchanged.

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 20:25:58 +08:00
To3akaRinandGitHub eeeb4ca379 fix(javascript): preserve container ownership for getter mutations (#2765) 2026-10-13 20:10:39 +08:00
ChaoqiandGitHub f974353c32 fix(javascript): keep paths after __dirname inside the source directory (#2366)
Node's path.join does not reset at an absolute segment, so
path.join(__dirname, '/preload.js') is <dir>/preload.js. The static path
projection skipped the __dirname anchor and joined the remaining parts,
which turned that common spelling into the root path /preload.js. In an
app whose main script lives in src/, the BrowserWindow preload was then
reported not-found, or resolved to an unrelated preload.js at the artifact
root, and the utilityProcess.fork target was never resolved. String
concatenation had the same defect: path.dirname(__filename) +
'/preload.js' projected to /preload.js, and __dirname + '/preload.js'
stayed unknown.

A source directory now contributes "." to a join, so path.join(__dirname)
projects to "." like path.resolve(__dirname). A concatenation that starts
with __dirname or the source file's dirname keeps text that begins with a
separator inside that directory; other text extends the directory's own
name and stays unknown. The empty-string projection of
path.dirname(__filename), which produced the wrong concatenation, is
removed, so a bare path.dirname(__filename) is unknown like a bare
__dirname.
2026-10-13 20:10:10 +08:00
cd2ad1f240 fix(artifacts): report malformed ZIP archives as format failures (#2683)
* fix(artifacts): report malformed ZIP archives as format failures

zip.js signals archive faults (missing end-of-central-directory record,
unrecognized format, overlapping members, corrupt member data,
unsupported compression) as plain Errors. ZipArtifactReader passed them
through untyped, so the artifact provider's fallback reported every one
as `io` with no detail and told the caller to "check file access".

Type these faults at the ZIP reader boundary: structural and data
corruption become `format` failures and unsupported archive features
become `unavailable`, each carrying the zip.js reason as detail. This
covers enumeration, the getData rejection, and the WritableStream abort
path that delivers corrupt-data errors first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(artifacts): share the ZIP inventory failure assertion

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:07:28 +08:00
Iven KooandGitHub f90382756d fix(native): keep bounded trace edges and depth consistent (#2758) 2026-10-13 20:06:50 +08:00
Shenrui MaandGitHub 68ecdcd7a1 fix(reference): preserve directory intent when resolving source imports (#2770)
Keep directory requests from becoming unresolved or pointing at sibling files
when constructing reference-source dependency graphs. Canonicalize index
candidates and preserve directory intent while retaining ordinary lookup rules.

Constraint: Preserve current TypeScript candidate objects and exact-before-Unicode lookup
Rejected: Reorder all file probes before index probes | Changes ordinary CommonJS candidate priority
Confidence: high
Scope-risk: narrow
Tested: Public CLI regression fails on base; focused reference and resolver suites pass; npm run check
Not-tested: Full suite on refreshed base; earlier full run had 12 baseline-reproduced failures
2026-10-13 20:06:19 +08:00
Guangqi ZhangandGitHub 1dd6c2dff9 fix(setup): resolve DeepSeek Harness home overrides (#2461) 2026-10-13 20:05:31 +08:00
Liu JieandGitHub c95315d2ce fix(cli): reject unterminated output filter slices (#2315) 2026-10-13 20:04:33 +08:00
Kaoru0822andGitHub fb28ec1537 test(apple): keep the same-named slice dyld fixture openable on x64 hosts (#2755)
Add a plain x86_64 slice to the dyld fixture while retaining both same-named arm64e slices and their independent environment assertions.

The original fixture was rejected by real x64 CLI admission before tracing. The contributing PR's complete Linux CI run passes all 30 jobs, including the portable suite. Codex and Greptile reviews completed without findings; no unresolved threads remain.

This restores the main test baseline needed for #2635. The change is limited to test data and preserves the original author's contribution.

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
2026-10-13 19:31:18 +08:00
morluto 490a285070 ci: reuse current-run builds across provider lanes 2026-10-13 19:30:50 +08:00
morluto fa0a9b2d32 ci: observe affected-owner test plans against full results 2026-10-13 19:21:09 +08:00
morluto c8d861f976 ci: scope timing helpers to portable verification 2026-10-13 19:12:26 +08:00
morluto bd2c202dd9 fix(ci): reject incomplete verification plans 2026-10-13 19:06:31 +08:00
morluto 9f3f392eb8 ci: bound core job execution time
Limit classification to five minutes, static/build/docs/report jobs to ten minutes, and each portable shard to thirty minutes. The saved successful-job sample peaked at 392 seconds per shard and 61 seconds for other core jobs, leaving generous setup and coverage headroom. Runner queue waiting is unaffected.
2026-10-13 19:00:47 +08:00
morluto f0f09950f3 ci: finish main validation and balance complete test costs 2026-10-13 18:54:05 +08:00
morluto 3af8f847f0 ci: fix PR metadata triggers and defer expensive runner work 2026-10-13 18:38:46 +08:00
morluto 747e729914 test(release): validate only the selected preflight job
Scope the steps schema to the release job whose preflight is executed, allowing unrelated reusable-workflow jobs such as qualification. Preserve all four valid and invalid metadata execution checks.

Reproduced on main ec3db3745: 35 passed, 4 failed. After the fix: 104 release and CI boundary tests pass; check:fast and format:check pass.
2026-10-13 18:24:40 +08:00
记得吃中饭andGitHub 2995ec6910 fix(ghidra): resolve file offsets in decompile (#2101)
* fix(native): expose loaded image bases in overviews

* test(ghidra): retain resources when verifier cleanup fails

* docs(ghidra): spell out file-offset address mapping

* fix(ci): separate and retry NativeAOT dependency restoration

* fix(test): resolve CI script file URLs on Windows

* fix(native): retain conflict observations and actionable CLI guidance

* fix(fixtures): keep NativeAOT retry logs credential-safe

* fix(fixtures): redact authenticated URLs at diagnostic creation

* fix(ghidra): restrict source-coordinate observations to decompilation

* fix(cli): identify conflicting file-offset selectors accurately

* fix(ghidra): bind file offsets to admitted target sources

* fix(ghidra): preserve resolved file-offset failure context

* test(ghidra): verify ELF address and file-offset round trips

* test(nativeaot): align diagnostic regressions with fixture host support
2026-10-13 18:23:17 +08:00
Yijie LuandGitHub ec3db37457 fix(native): preserve literal line breaks in signing identifiers (#2107)
* fix(native): preserve literal line breaks in signing identifiers

* fix(native): infer codesign framing without a matching path
2026-10-13 18:22:34 +08:00
N0zoM1z0 f1f5582ce8 fix(website): clarify translated evidence and runtime observations 2026-10-13 18:21:09 +08:00
N0zoM1z0 3d78a57043 docs(website): publish model essay in all supported languages 2026-10-13 18:21:09 +08:00
N0zoM1z0 b06751b703 docs(website): simplify reconstruction evidence in model essay
Explain each case through the obstacle, useful hypothesis and verified result. Remove byte totals, relocation counts and trial counts from the main narrative; preserve commit references and the constant-value example that explains the oracle defect. Make the experiment table and compiler terminology easier to follow.

Validation: website.py check, formatting, preserved source links, and eight article layout checks across both preview paths and light/dark themes.
2026-10-13 18:21:09 +08:00
N0zoM1z0 806ff57567 docs(website): use straight quotes in model essay
Normalize the three quoted phrases to straight English quotation marks. Website checks and formatting pass; the local preview serves the revised punctuation.
2026-10-13 18:21:09 +08:00
N0zoM1z0 ca07373e44 docs(website): improve model essay transitions and narrative flow
Connect the reconstruction cases to the questions about specialization, retrieval and feedback-driven search. Replace fragmented sentence sequences with explicit causal links and smooth the progression into oracle design and the proposed comparison.

Validation: website.py check, formatting, preserved source links and evidence figures, and eight desktop/mobile light/dark layout checks at both preview paths.
2026-10-13 18:21:09 +08:00
N0zoM1z0 8583a190c0 fix(website): keep omitted language selectors free of whitespace
Indent the optional language selector with its value so a nonindexable draft does not generate a line containing only spaces. Indexed-page markup is unchanged.

Validation: website.py check passes all 12 maintenance regressions; synchronization changes only the draft header blank line.
2026-10-13 18:21:09 +08:00
N0zoM1z0 fe496bd1cf docs(website): add essay on reverse engineering models
Add an English review draft connecting Touhou reconstruction evidence with model specialization, repository knowledge and feedback-driven learning. Preserve model attribution and historical verification scope in supporting notes, and translate the new Blog listing for all existing editions.

Validation: website.py check, focused formatting checks, and 32 Chromium cases covering both preview paths, light/dark themes, responsive layouts, translated indexes and no-JavaScript reading.
2026-10-13 18:21:09 +08:00
Yijie LuandGitHub f8e95799f9 fix(artifacts): preserve NIB field and class identities (#2106) 2026-10-13 18:20:54 +08:00
HarlyandGitHub 6c816af119 fix(managed): preserve negative zero in CIL operands (#2437) 2026-10-13 18:19:44 +08:00
morluto ab02f3c8d6 ci: reduce reporter fan-out and preserve shard history 2026-10-13 18:16:10 +08:00
ChaoqiandGitHub 9928f357fb fix(artifacts): read ASCII control bytes in legacy ZIP names as ASCII (#2365)
* fix(artifacts): read ASCII control bytes in legacy ZIP names as ASCII

ZIP names without the UTF-8 flag were decoded with zip.js's CP437 table,
which maps bytes 0x01-0x1F and 0x7F to IBM PC display glyphs. A folder
zipped on macOS with a custom icon therefore listed and extracted
`Folder/Icon\r` as `Folder/Icon♪`, a name that is not in the archive and
differs from REA's directory inventory of the same folder. Tabs became `○`
and DEL became `⌂`.

Decode legacy names in REA's existing decodeText hook with the Unicode
CP437 mapping that Python's zipfile also uses: bytes below 0x80 as ASCII,
and the upper half with the same table zip.js uses.

Windows file names cannot contain U+0001-U+001F, so a Windows extraction
destination now refuses such a path with a typed path failure before
creating anything, as it already does for ':'.

* test(artifacts): check extracted legacy ZIP control names on disk

The control-name cases checked only the logical path that inventory
reports. Extraction writes through SafeOutputTree, so a change that
renamed the extracted files would still pass those cases.

Extract `Icon\r` and `é\t\u007F.a`, then compare the reported
relative_path, the directory listing, and the file contents. The new
cases skip on Windows, which refuses these names.
safeOutputTree.test.ts covers that refusal.
2026-10-13 18:05:20 +08:00
morluto d13c9f840d ci: reduce redundant compatibility and qualification work
Select SQLite compatibility by explicit source, fixture, lifecycle, and script ownership while retaining every version in full qualification. Share the canonical scope inventory between planning and the required gate.

Reuse recent exact-SHA full qualification only for hourly schedules, with trusted successful-run metadata and receipts. Force a daily refresh, reject stale original runs even after retries, and never renew proof from reused validation.

Report attempt-specific queue, execution, and matched step timing in a separate completion workflow using trusted default-branch code and read-only API access. Preserve raw metadata and unknown timings without delaying validation or publication.

Validation: 177 focused tests; typecheck/lint; knip; actionlint 1.7.7; all 1046 tests discovered; live 37-job API report smoke. Remote workflow scheduling and real-provider matrices remain CI verification.
2026-10-13 17:46:04 +08:00
0cf224e762 fix(javascript): retain distinct chunk registration callback metadata (#2737)
Co-authored-by: LouisChen <3361008+chqcjq@users.noreply.github.com>
2026-10-13 17:42:41 +08:00
52d4ce6ced fix(apple): derive dyld environment findings from each traced slice (#2733)
deriveFindings looked up each traced root's slice again by architecture
name. A universal executable with arm64e and arm64e.x1 slices reports
both as "arm64e", so both roots resolved to the first slice: a later
slice's LC_DYLD_ENVIRONMENT produced no finding, or the first slice's
command was reported twice. The per-slice dyld_environment arrays were
already correct.

traceDylibLoading already has the exact slice when it records a root, so
pass it to deriveFindings instead of re-deriving it from the name. The
public roots and findings shapes are unchanged.

Fixes #2661

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:42:09 +08:00
442 changed files with 52568 additions and 2647 deletions
@@ -46,7 +46,7 @@ rea setup --client codex --dry-run --json
Use the current supported client's ID: `claude_code`, `claude_desktop`, `codex`,
`cursor`, `gemini_cli`, `windsurf`, `devin`, `opencode`, `antigravity`,
`copilot_cli`, `commandcode`, `qwen_code`, `vscode`, `trae`, `grok_build`, `omp`, `pi`,
`hermes`, `qoder`, `zcode`, `dsh`, or `grok_bot`. If unknown, inspect `doctor --json` before choosing a
`hermes`, `qoder`, `droid`, `zcode`, `dsh`, or `grok_bot`. If unknown, inspect `doctor --json` before choosing a
scope. `grok_bot` uses a chat registration step rather than local `mcp.json`.
For unsupported clients, use version-pinned manual stdio registration or CLI.
@@ -119,6 +119,8 @@ loaded bytes, file mappings, relocations and the entry against the immutable
snapshot. Keep `mismatch` and `unsupported` explicit. Verified import covers the
reported static image, not runtime DOS or PC-98 hardware behavior.
For Ghidra decompilation, `procedure_pseudo_code` also accepts `procedure: "file-offset:0x..."`; CLI `rea decompile <path> --file-offset=0x... --provider ghidra` resolves the source byte offset internally. An unresolved 0x-prefixed numeric procedure selector also falls back to a unique file mapping. Existing symbols and analysis addresses win; multiple mappings fail as ambiguous. Preserve the returned file-offset and address locations and raw selection observations. ELF virtual addresses are distinct from file offsets; never assume an arbitrary offset plus image base is an analysis address.
Use `read_bytes` for initialized analysis memory and `address_to_file_offset` to
anchor one observed address to original file bytes. Loader fixups can change a
word; a source offset does not imply byte equality. Complete function body ranges
@@ -222,10 +224,13 @@ context. PSP/stack/device state remains unmodeled. See [DOS guide](https://githu
For other headerless images (embedded firmware without ELF/Mach-O/PE/MZ
headers), open with `format: "raw-binary"`, `architecture` (`x86`, `x86_64`,
`arm`, or `arm64`), and `entry_point` (`0x` hex). File offset 0 is mapped at
`arm`, `arm64`, or `6502`), and `entry_point` (`0x` hex). File offset 0 is mapped at
that address. Hopper and IDA refuse this interpretation; Ghidra uses
`BinaryLoader` with a little-endian generic language. Big-endian, Thumb-only,
and Cortex-M variants are unsupported. See [raw binary guide](https://github.com/morluto/rea/blob/main/docs/raw-binary.md).
and Cortex-M variants are unsupported. `6502` selects NMOS 6502; the entry and
complete file mapping must fit within `0x0000..0xffff`. 65C02, 65816, cartridge
containers, bank switching, and mapper semantics are unsupported. Windows
Ghidra P0 admits raw images only for `6502`. See [raw binary guide](https://github.com/morluto/rea/blob/main/docs/raw-binary.md).
### Function annotations in Ghidra
@@ -1,14 +1,24 @@
name: Portable build cache
description: Reuse Turbo build outputs without serializing verification jobs.
description: Restore portable Turbo build outputs from the current run or cross-run cache.
inputs:
save:
description: Save newly built outputs after the job succeeds.
default: "false"
build_cache_artifact:
description: Reuse portable Turbo outputs uploaded by the current workflow run.
default: ""
runs:
using: composite
steps:
- name: Download portable build outputs from this workflow run
if: inputs.build_cache_artifact != ''
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: ${{ inputs.build_cache_artifact }}
path: .turbo/cache/
- name: Resolve portable build task hashes
id: plan
if: inputs.build_cache_artifact == ''
shell: bash
env:
BUILD_CACHE_PREFIX: rea-portable-build-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.npmrc', '.nvmrc', 'package-lock.json') }}
@@ -28,15 +38,15 @@ runs:
console.log(`prefix=${process.env.BUILD_CACHE_PREFIX}-`);
' >> "$GITHUB_OUTPUT"
- name: Restore portable build outputs
if: inputs.save != 'true'
uses: actions/cache/restore@v4
if: inputs.build_cache_artifact == '' && inputs.save != 'true'
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
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
if: inputs.build_cache_artifact == '' && inputs.save == 'true'
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: .turbo/cache/
key: ${{ steps.plan.outputs.key }}
+18
View File
@@ -0,0 +1,18 @@
name: Full PR qualification
on:
pull_request:
branches: [main, "release/*"]
types: [labeled]
permissions:
contents: read
actions: read
jobs:
qualification:
name: Qualify labeled PR revision
if: github.event.label.name == 'ci:full'
uses: ./.github/workflows/ci.yml
with:
source_sha: ${{ github.sha }}
+46
View File
@@ -0,0 +1,46 @@
name: CI timings
on:
schedule:
- cron: "17 * * * *"
workflow_dispatch:
concurrency:
group: ci-timings-${{ github.repository }}
cancel-in-progress: false
permissions:
contents: read
actions: read
jobs:
report:
name: Collect recent completed workflow attempts
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
# Consume only API data and execute batch code from the trusted default branch.
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
- name: Record queue, execution, and matched step timing
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
CI_REPOSITORY: ${{ github.repository }}
run: node scripts/ci/job-timings-batch.mjs
- name: Upload CI timing artifacts
if: always() && hashFiles('.cache/ci-job-timings/*') != ''
continue-on-error: true
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: ci-job-timings-batch
path: .cache/ci-job-timings/
include-hidden-files: true
if-no-files-found: error
overwrite: true
retention-days: 14
+179 -110
View File
@@ -6,12 +6,13 @@ on:
pull_request:
branches: [main, "release/*"]
schedule:
- cron: "23 * * * *"
- cron: "23 0-2,4-23 * * *"
- cron: "23 3 * * *"
workflow_dispatch:
workflow_call:
inputs:
source_sha:
description: Exact release source; must match the caller revision.
description: Exact qualification source; must match the caller revision.
required: true
type: string
@@ -20,40 +21,28 @@ permissions:
concurrency:
# Complete qualification finishes even while main receives more pushes.
# Main feedback also finishes, coalescing newer pushes to one pending run.
# Scheduled/manual main runs coalesce to one active and the latest pending;
# release qualification has its own exact-revision group.
# release and label-triggered qualification use exact-revision groups.
group: ci-${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || inputs.source_sha != '' || contains(github.event.pull_request.labels.*.name, 'ci:full')) && 'qualification' || 'feedback' }}-${{ inputs.source_sha || github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name != 'schedule' && github.event_name != 'workflow_dispatch' && inputs.source_sha == '' && !contains(github.event.pull_request.labels.*.name, 'ci:full') }}
cancel-in-progress: ${{ github.event_name == 'pull_request' && inputs.source_sha == '' && !contains(github.event.pull_request.labels.*.name, 'ci:full') }}
env:
HUSKY: "0"
jobs:
pr-title:
name: Conventional PR title
if: github.event_name == 'pull_request' && inputs.source_sha == ''
runs-on: ubuntu-latest
steps:
- name: Validate title for Release Please
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: >-
node -e 'const title = process.env.PR_TITLE ?? "";
const conventional = /^(?:feat|fix|docs|refactor|perf|test|build|ci|chore|revert)(?:\([a-z0-9._/-]+\))?!?: .+/u;
if (!conventional.test(title)) {
console.error("PR title must use Conventional Commits, for example: feat: add historical source import");
process.exit(1);
}'
changes:
name: Classify changes
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
actions: read
outputs:
tier: ${{ steps.plan.outputs.tier }}
qualification_run_id: ${{ steps.qualification.outputs.run_id }}
code: ${{ steps.plan.outputs.code }}
sqlite: ${{ steps.plan.outputs.sqlite }}
runtime: ${{ steps.plan.outputs.runtime }}
package: ${{ steps.plan.outputs.package }}
apple: ${{ steps.plan.outputs.apple }}
@@ -72,15 +61,16 @@ jobs:
nativeaot: ${{ steps.plan.outputs.nativeaot }}
website: ${{ steps.plan.outputs.website }}
matrix: ${{ steps.plan.outputs.matrix }}
test_selection: ${{ steps.plan.outputs.test_selection }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
ref: ${{ inputs.source_sha || github.sha }}
fetch-depth: 0
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
- name: Bind release qualification to the caller revision
- name: Bind qualification to the caller revision
if: inputs.source_sha != ''
env:
SOURCE_SHA: ${{ inputs.source_sha }}
@@ -96,6 +86,17 @@ jobs:
CI_REPOSITORY: ${{ github.repository }}
CI_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: node scripts/ci/baseline.mjs
- name: Admit scheduled full qualification
id: qualification
if: github.event_name == 'schedule' && inputs.source_sha == ''
env:
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ github.sha }}
CI_REPOSITORY: ${{ github.repository }}
CI_REPOSITORY_ID: ${{ github.repository_id }}
CI_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
CI_FORCE_FULL: ${{ github.event.schedule == '23 3 * * *' }}
run: node scripts/ci/qualification.mjs
- name: Select verification lanes
id: plan
env:
@@ -103,24 +104,29 @@ jobs:
BASELINE_REASON: ${{ steps.baseline.outputs.reason }}
HEAD_SHA: ${{ inputs.source_sha || github.event.pull_request.head.sha || github.sha }}
CI_FULL: ${{ inputs.source_sha != '' || contains(github.event.pull_request.labels.*.name, 'ci:full') }}
CI_SKIP_FULL: ${{ steps.qualification.outputs.run_full == 'false' }}
run: node scripts/ci/plan.mjs
static:
name: Static checks
needs: changes
if: ${{ !cancelled() }}
if: ${{ !cancelled() && needs.changes.outputs.tier != 'reused' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Require successful change classification
env:
CHANGES_RESULT: ${{ needs.changes.result }}
run: test "${CHANGES_RESULT}" = "success"
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- name: Validate workflow syntax and expressions
if: needs.changes.outputs.workflows == 'true'
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 -shellcheck= -pyflakes=
- if: needs.changes.outputs.code == 'true'
run: npm run check:ci
- if: needs.changes.outputs.code == 'true'
@@ -132,7 +138,7 @@ jobs:
run: npm run format:check
- name: Upload complete static diagnostics
if: always() && needs.changes.outputs.code == 'true'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: static-check-reports
path: .cache/static-checks/
@@ -145,18 +151,19 @@ jobs:
needs: [changes, build]
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
shard: [1/6, 2/6, 3/6, 4/6, 5/6, 6/6]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: rea-test-runtime
path: .
@@ -170,7 +177,7 @@ jobs:
run: npm run test:run -- --reporter=default --reporter=blob --shard=${{ matrix.shard }}
env:
REA_CI_TEST_HISTORY: .cache/ci-test-history/test-results.json
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: ${{ !cancelled() }}
with:
name: vitest-report-${{ strategy.job-index }}
@@ -183,14 +190,26 @@ jobs:
build:
name: Build package
needs: changes
if: needs.changes.outputs.code == 'true'
# Reusable provider workflows consume this run's portable build artifact,
# even when a workflow-only edit selects the lane without portable tests.
if: >-
needs.changes.outputs.code == 'true' ||
needs.changes.outputs.browser == 'true' ||
needs.changes.outputs.binary_layout == 'true' ||
needs.changes.outputs.evm == 'true' ||
needs.changes.outputs.javascript_recovery == 'true' ||
needs.changes.outputs.recorded_crash == 'true' ||
needs.changes.outputs.web_network == 'true' ||
needs.changes.outputs.web_runtime == 'true' ||
needs.changes.outputs.web_source_map == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
actions: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
@@ -200,29 +219,25 @@ jobs:
with:
save: ${{ github.event_name != 'pull_request' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
- run: npm run test:prepare
- name: Find successful default-branch timing history
- name: Find available trusted full-suite timing history
id: history
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
CI_REPOSITORY: ${{ github.repository }}
CI_REPOSITORY_ID: ${{ github.repository_id }}
CI_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
run_id="$(gh api --method GET \
"repos/${CI_REPOSITORY}/actions/workflows/ci.yml/runs" \
-f branch="${CI_DEFAULT_BRANCH}" -f event=schedule -f status=success -f per_page=1 \
--jq '.workflow_runs[0].id // empty')"
echo "run-id=${run_id}" >> "${GITHUB_OUTPUT}"
run: node scripts/ci/test-history.mjs
- name: Download previous timing report
if: steps.history.outputs.run-id != ''
continue-on-error: true
uses: actions/download-artifact@v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: test-timings
run-id: ${{ steps.history.outputs.run-id }}
github-token: ${{ github.token }}
path: .cache/ci-test-history
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: rea-test-runtime
path: |
@@ -239,7 +254,7 @@ jobs:
# test:prepare caches only portable JS, skill, and catalog outputs.
# Native package jobs still run their build and prepack checks; Turbo
# restores matching outputs or rebuilds when their inputs differ.
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: rea-build-cache
path: .turbo/cache/
@@ -248,30 +263,33 @@ jobs:
retention-days: 7
sqlite-runtime:
needs: [changes, build]
if: needs.changes.outputs.code == 'true'
# Preserve all selected coverage, but admit expensive host/provider work
# only after static validation. Portable tests still provide early feedback.
needs: [changes, static, build]
if: needs.changes.outputs.sqlite == 'true'
uses: ./.github/workflows/sqlite-runtime.yml
test:
name: Portable tests
needs: [changes, test-shard]
if: ${{ !cancelled() }}
if: ${{ !cancelled() && needs.changes.outputs.tier != 'reused' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Require successful change classification
env:
CHANGES_RESULT: ${{ needs.changes.result }}
run: test "${CHANGES_RESULT}" = "success"
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
if: needs.changes.outputs.code == 'true' && success()
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
if: needs.changes.outputs.code == 'true' && success()
with:
node-version-file: .nvmrc
cache: npm
- if: needs.changes.outputs.code == 'true' && success()
run: npm ci
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: needs.changes.outputs.code == 'true' && success()
with:
path: .vitest/blob
@@ -289,6 +307,7 @@ jobs:
--reporter=default
--reporter=junit
--reporter=json
--reporter=./scripts/ci/test-timing-reporter.mjs
--outputFile.junit=test-results.xml
--outputFile.json=test-results.json
- name: Merge fast-suite JUnit and timing reports
@@ -299,28 +318,34 @@ jobs:
--reporter=default
--reporter=junit
--reporter=json
--reporter=./scripts/ci/test-timing-reporter.mjs
--outputFile.junit=test-results.xml
--outputFile.json=test-results.json
- name: Summarize slowest test files
if: needs.changes.outputs.code == 'true' && !cancelled() && hashFiles('test-results.json') != ''
if: needs.changes.outputs.code == 'true' && !cancelled() && hashFiles('test-timings.json') != ''
env:
SHARD_RESULT: ${{ needs.test-shard.result }}
MERGE_RESULT: ${{ needs.changes.outputs.tier == 'full' && steps.merge.outcome || steps.merge-fast.outcome }}
CI_VERIFY_TEST_INVENTORY: "true"
CI_TEST_SELECTION: ${{ needs.changes.outputs.test_selection }}
CI_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: node scripts/ci/test-report.mjs
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: needs.changes.outputs.code == 'true' && !cancelled() && hashFiles('test-results.json') != ''
with:
name: test-results
path: |
test-results.xml
test-results.json
test-timings.json
coverage/
.cache/ci-test-selection/report.json
include-hidden-files: true
if-no-files-found: error
retention-days: 30
- name: Publish successful timing history
if: needs.changes.outputs.code == 'true' && needs.changes.outputs.tier == 'full' && success() && needs.test-shard.result == 'success'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: test-timings
path: .cache/ci-test-history/test-results.json
@@ -336,24 +361,24 @@ jobs:
conformance-fixtures:
name: Source-built conformance fixtures
needs: changes
needs: [changes, static]
if: needs.changes.outputs.fixtures == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Verify fixtures from a clean checkout
run: npm run verify:fixtures
windows-native-build:
name: Build bundled Windows x64 native controls
needs: changes
needs: [changes, static]
if: needs.changes.outputs.runtime == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
- name: Prepare cross compiler
@@ -370,12 +395,27 @@ jobs:
- run: npm run build:windows-native
- run: npm run verify:windows-native:artifact
- run: node scripts/build-windows-native.mjs --inspection-fixture
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: windows-native-inspection-fixture
path: .cache/windows-native-inspection-fixture/
if-no-files-found: error
- uses: actions/upload-artifact@v4
# The process boundary observations are independent of the addon: they
# report the caller's and child's own token, environment and breakaway
# results from a separate executable. Cross-build it in the lane that
# already has MinGW so the Windows job can observe them too.
- name: Build Windows process boundary fixture
run: |
mkdir -p .cache/windows-process-boundary
x86_64-w64-mingw32-g++ -municode -std=c++17 -O2 -static -Wall -Wextra \
-o .cache/windows-process-boundary/process-boundary.exe \
tests/fixtures/windows/processBoundary.cc -ladvapi32
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: windows-process-boundary-fixture
path: .cache/windows-process-boundary/
if-no-files-found: error
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: windows-native-controls
path: native/windows/build/
@@ -383,7 +423,7 @@ jobs:
package-e2e:
name: Native package baseline (${{ matrix.platform }} ${{ matrix.architecture }})
needs: [changes, build, windows-native-build]
needs: [changes, static, build, windows-native-build]
if: needs.changes.outputs.runtime == 'true'
strategy:
fail-fast: false
@@ -398,8 +438,8 @@ jobs:
NODE_OPTIONS: "--max-old-space-size=1024"
UV_THREADPOOL_SIZE: "2"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
@@ -413,15 +453,17 @@ jobs:
assert.equal(process.arch, process.env.EXPECTED_CI_ARCHITECTURE);
console.log(JSON.stringify({ platform: process.platform, architecture: process.arch, node: process.version }));"
- run: npm ci
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: rea-build-cache
path: .turbo/cache/
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: windows-native-controls
path: native/windows/build/
- run: npm run verify:windows-native:artifact
- name: Verify NativeAOT diagnostic applicability on this host
run: npm run test:focused -- tests/evaluation/package/nativeAotDiagnostics.test.ts
- name: Resolve npm cache before verifier HOME isolation
id: npm-cache
run: |
@@ -436,7 +478,7 @@ jobs:
run: npm run verify:inspector
- name: Retain the exact qualified release package
if: needs.changes.outputs.tier == 'full' && matrix.platform == 'linux' && matrix.architecture == 'x64'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: verified-release-package
path: .cache/verified-package/
@@ -447,18 +489,18 @@ jobs:
apple-artifacts:
name: Real Apple artifact CLI/MCP E2E
needs: [changes, build]
needs: [changes, static, build]
if: needs.changes.outputs.apple == 'true'
runs-on: macos-15
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: rea-build-cache
path: .turbo/cache/
@@ -495,13 +537,13 @@ jobs:
reconstruction-readiness:
name: Reconstruction readiness
needs: changes
needs: [changes, static]
if: needs.changes.outputs.readiness == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
@@ -512,7 +554,7 @@ jobs:
run: |
set -o pipefail
npm run verify:readiness | tee readiness-summary.json
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: ${{ !cancelled() }}
with:
name: reconstruction-readiness
@@ -524,23 +566,23 @@ jobs:
windows-curated:
name: Windows capability and package lane
needs: [changes, build, windows-native-build]
needs: [changes, static, build, windows-native-build]
if: needs.changes.outputs.package == 'true'
runs-on: windows-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: rea-build-cache
path: .turbo/cache/
- run: npm run build:cached
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: windows-native-controls
path: native/windows/build/
@@ -569,7 +611,9 @@ jobs:
tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts
tests/boundary/filesystem/setupUnavailableProvider.test.ts
tests/boundary/filesystem/windowsNpxRegistration.test.ts
tests/boundary/android/workspaceGuardianRetry.test.ts
tests/acceptance/setup/homeDirectoryFallback.test.ts
tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts
tests/boundary/filesystem/webScriptManifestPaths.test.ts
tests/boundary/process/ProcessEvidence.host.test.ts
tests/acceptance/webModuleCli.test.ts
@@ -594,8 +638,17 @@ jobs:
src/application/Update.test.ts
tests/process-global/updateMaintenance.test.ts
tests/conformance/providers/ghidra/realGhidraWindowsWorkflow.test.ts
- run: npm run verify:windows-native
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: windows-process-boundary-fixture
path: .cache/windows-process-boundary/
# Passing the fixture adds the breakaway, caller/child token, environment
# semantics and ancestor reparse observations, which the lane skips
# entirely when it is absent.
- run: >-
npm run verify:windows-native -- .
"${{ github.workspace }}\.cache\windows-process-boundary\process-boundary.exe"
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: windows-native-inspection-fixture
path: .cache/windows-native-inspection-fixture/
@@ -614,7 +667,7 @@ jobs:
tests/boundary/filesystem/directoryArtifactReaderOpen.test.ts
tests/boundary/filesystem/javascriptPackagePrecedence.test.ts
tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22.19.0
- name: Verify native path boundaries on Node 22
@@ -624,7 +677,7 @@ jobs:
tests/boundary/filesystem/javascriptPackagePrecedence.test.ts
tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts
tests/boundary/process/sourceMapUncHosts.test.ts
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: always()
with:
name: windows-capabilities
@@ -636,9 +689,10 @@ jobs:
needs: changes
if: needs.changes.outputs.docs == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
@@ -646,7 +700,7 @@ jobs:
- run: npm run docs:check
- name: Generation leaves authored files unchanged
run: git diff --exit-code
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: generated-docs
path: |
@@ -655,63 +709,69 @@ jobs:
skills/reverse-engineer-anything
if-no-files-found: error
workflows:
name: Validate workflows
needs: changes
if: needs.changes.outputs.workflows == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate workflow syntax and expressions
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 -shellcheck= -pyflakes=
browser:
needs: changes
needs: [changes, static, build]
if: needs.changes.outputs.browser == 'true'
uses: ./.github/workflows/real-browser.yml
with:
build_cache_artifact: rea-build-cache
binary_layout:
needs: changes
needs: [changes, static, build]
if: needs.changes.outputs.binary_layout == 'true'
uses: ./.github/workflows/real-binary-layout.yml
with:
build_cache_artifact: rea-build-cache
evm:
needs: changes
needs: [changes, static, build]
if: needs.changes.outputs.evm == 'true'
uses: ./.github/workflows/real-evm-interface.yml
with:
build_cache_artifact: rea-build-cache
javascript_recovery:
needs: changes
needs: [changes, static, build]
if: needs.changes.outputs.javascript_recovery == 'true'
uses: ./.github/workflows/real-javascript-recovery.yml
with:
build_cache_artifact: rea-build-cache
recorded_crash:
needs: changes
needs: [changes, static, build]
if: needs.changes.outputs.recorded_crash == 'true'
uses: ./.github/workflows/real-recorded-crash.yml
with:
build_cache_artifact: rea-build-cache
web_network:
needs: changes
needs: [changes, static, build]
if: needs.changes.outputs.web_network == 'true'
uses: ./.github/workflows/real-web-network-captures.yml
with:
build_cache_artifact: rea-build-cache
web_runtime:
needs: changes
needs: [changes, static, build]
if: needs.changes.outputs.web_runtime == 'true'
uses: ./.github/workflows/real-web-runtime.yml
with:
build_cache_artifact: rea-build-cache
web_source_map:
needs: changes
needs: [changes, static, build]
if: needs.changes.outputs.web_source_map == 'true'
uses: ./.github/workflows/real-web-source-map.yml
with:
build_cache_artifact: rea-build-cache
nativeaot:
needs: changes
needs: [changes, static]
if: needs.changes.outputs.nativeaot == 'true'
uses: ./.github/workflows/nativeaot-fixtures.yml
website:
needs: changes
needs: [changes, static]
if: needs.changes.outputs.website == 'true'
uses: ./.github/workflows/website-check.yml
@@ -721,7 +781,6 @@ jobs:
needs:
[
changes,
pr-title,
static,
build,
test-shard,
@@ -734,7 +793,6 @@ jobs:
reconstruction-readiness,
windows-curated,
docs,
workflows,
browser,
binary_layout,
evm,
@@ -749,11 +807,22 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
- name: Require every selected lane to pass
id: gate
env:
CI_JOBS: ${{ toJSON(needs) }}
CI_QUALIFICATION_DIRECTORY: .cache/ci-qualification
run: node scripts/ci/required.mjs
- name: Upload full qualification receipt artifact
if: needs.changes.outputs.tier == 'full' && success()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: ${{ steps.gate.outputs.receipt_name }}
path: ${{ steps.gate.outputs.receipt_path }}
if-no-files-found: error
overwrite: true
retention-days: 7
+6 -5
View File
@@ -12,22 +12,23 @@ jobs:
name: Build Windows x64 NativeAOT fixture
# .NET 8.0.22's bundled linker discovery supports VS 2019/2022.
runs-on: windows-2022
timeout-minutes: 15
# Two bounded restore attempts and one compile can each take five minutes.
timeout-minutes: 20
env:
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
DOTNET_PROCESSOR_COUNT: "1"
COMPlus_GCHeapHardLimit: "30000000"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
- uses: actions/setup-dotnet@v4
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: "8.0.416"
- name: Build benign fixture and independent byte oracle
run: node scripts/generate-nativeaot-fixtures.mjs
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: nativeaot-win-x64-${{ github.event.pull_request.head.sha || github.sha }}
path: |
+2 -2
View File
@@ -19,8 +19,8 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
+29
View File
@@ -0,0 +1,29 @@
name: PR title
on:
pull_request:
branches: [main, "release/*"]
types: [opened, synchronize, reopened, edited]
permissions: {}
concurrency:
group: pr-title-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
pr-title:
name: Conventional PR title
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Validate title for Release Please
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: >-
node -e 'const title = process.env.PR_TITLE ?? "";
const conventional = /^(?:feat|fix|docs|refactor|perf|test|build|ci|chore|revert)(?:\([a-z0-9._/-]+\))?!?: .+/u;
if (!conventional.test(title)) {
console.error("PR title must use Conventional Commits, for example: feat: add historical source import");
process.exit(1);
}'
+10 -2
View File
@@ -8,6 +8,12 @@ on:
type: boolean
default: false
workflow_call:
inputs:
build_cache_artifact:
description: Portable build cache artifact from the calling CI run.
required: false
type: string
default: ""
permissions:
contents: read
@@ -22,13 +28,15 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- uses: ./.github/actions/portable-build-cache
with:
build_cache_artifact: ${{ inputs.build_cache_artifact }}
- name: Acquire exact upstream libraries in an isolated runner environment
shell: bash
run: |
+10 -2
View File
@@ -2,6 +2,12 @@ name: Real browser verification
on:
workflow_call:
inputs:
build_cache_artifact:
description: Portable build cache artifact from the calling CI run.
required: false
type: string
default: ""
workflow_dispatch:
env:
@@ -15,13 +21,15 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- uses: ./.github/actions/portable-build-cache
with:
build_cache_artifact: ${{ inputs.build_cache_artifact }}
- run: npm run verify:browser
env:
REA_BROWSER_EXECUTABLE: /usr/bin/google-chrome
+12 -2
View File
@@ -3,6 +3,12 @@ name: Real offline EVM interface
on:
workflow_dispatch:
workflow_call:
inputs:
build_cache_artifact:
description: Portable build cache artifact from the calling CI run.
required: false
type: string
default: ""
permissions:
contents: read
@@ -16,13 +22,15 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- uses: ./.github/actions/portable-build-cache
with:
build_cache_artifact: ${{ inputs.build_cache_artifact }}
- name: Acquire unchanged fixture compiler in isolated runner storage
shell: bash
run: |
@@ -35,3 +43,5 @@ jobs:
echo "REA_VERIFY_SOLC_MODULE=${evm_fixture_root}/node_modules/solc/index.js" >> "${GITHUB_ENV}"
- name: Verify source-owned compiled bytecode through public CLI and MCP
run: npm run verify:evm:interface
- name: Verify content-hash-free and CBOR-free owned compiler artifacts
run: npm run verify:solidity:metadata
+28 -8
View File
@@ -16,8 +16,8 @@ jobs:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
- name: Prepare cross compiler
@@ -31,11 +31,25 @@ jobs:
sudo apt-get install --no-install-recommends -y g++-mingw-w64-x86-64
- run: npm run build:windows-native
- run: npm run verify:windows-native:artifact
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: real-ghidra-windows-native
path: native/windows/build/
if-no-files-found: error
# The boundary observations need a separate Windows executable, which the
# native lane skips when it is absent. Cross-build it here, where MinGW is
# already installed, so the proof run observes them too.
- name: Build Windows process boundary fixture
run: |
mkdir -p .cache/windows-process-boundary
x86_64-w64-mingw32-g++ -municode -std=c++17 -O2 -static -Wall -Wextra \
-o .cache/windows-process-boundary/process-boundary.exe \
tests/fixtures/windows/processBoundary.cc -ladvapi32
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: real-ghidra-windows-process-boundary
path: .cache/windows-process-boundary/
if-no-files-found: error
verify:
name: Real Ghidra 12.1.4 Windows x64 PE proof
@@ -45,17 +59,23 @@ jobs:
runs-on: [self-hosted, Windows, x64, ghidra-12-1-2]
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
- run: npm ci
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: real-ghidra-windows-native
path: native/windows/build/
- run: npm run verify:windows-native:artifact
- run: npm run verify:windows-native
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: real-ghidra-windows-process-boundary
path: .cache/windows-process-boundary/
- run: >-
npm run verify:windows-native -- .
"${{ github.workspace }}\.cache\windows-process-boundary\process-boundary.exe"
- name: Verify all admitted Windows Ghidra operations
env:
REA_ANALYSIS_PROVIDER: ghidra
@@ -88,7 +108,7 @@ jobs:
env:
REA_ANALYSIS_PROVIDER: ghidra
run: npm run verify:ghidra:windows:package -- --x86 --dll | Tee-Object -FilePath windows-ghidra-x86-dll-package-proof.log
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: always()
with:
name: windows-ghidra-proof
+3 -3
View File
@@ -44,12 +44,12 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- uses: actions/setup-go@v5
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version: "1.26.1"
cache: false
+4 -4
View File
@@ -15,8 +15,8 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
@@ -36,8 +36,8 @@ jobs:
runs-on: [self-hosted, Linux, hopper]
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
+2 -2
View File
@@ -15,8 +15,8 @@ jobs:
runs-on: [self-hosted, macOS, hopper]
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
+10 -2
View File
@@ -2,6 +2,12 @@ name: Real JavaScript recovery
on:
workflow_call:
inputs:
build_cache_artifact:
description: Portable build cache artifact from the calling CI run.
required: false
type: string
default: ""
workflow_dispatch:
permissions:
@@ -17,13 +23,15 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- uses: ./.github/actions/portable-build-cache
with:
build_cache_artifact: ${{ inputs.build_cache_artifact }}
- name: Fetch pinned Wakaru release into the job workspace
shell: bash
run: |
+2 -2
View File
@@ -43,8 +43,8 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
+10 -2
View File
@@ -3,6 +3,12 @@ name: Real recorded crash evidence
on:
workflow_dispatch:
workflow_call:
inputs:
build_cache_artifact:
description: Portable build cache artifact from the calling CI run.
required: false
type: string
default: ""
permissions:
contents: read
@@ -19,13 +25,15 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- uses: ./.github/actions/portable-build-cache
with:
build_cache_artifact: ${{ inputs.build_cache_artifact }}
- name: Install GDB on the disposable CI runner
shell: bash
run: |
+2 -2
View File
@@ -36,8 +36,8 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
+3 -3
View File
@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Download the pinned Termux app and verify its release checksum
shell: bash
run: |
@@ -38,7 +38,7 @@ jobs:
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Build and capture a page inside Termux
uses: reactivecircus/android-emulator-runner@4c44018e59b437e86cdfc41da381398f93ed8808 # v2
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
with:
api-level: 30
arch: x86_64
@@ -50,7 +50,7 @@ jobs:
script: bash scripts/verify/termux/emulator.sh
- name: Upload Termux and Android diagnostics
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: termux-browser-diagnostics
path: ${{ runner.temp }}/rea-termux/diagnostics
@@ -3,6 +3,12 @@ name: Real historical web network captures
on:
workflow_dispatch:
workflow_call:
inputs:
build_cache_artifact:
description: Portable build cache artifact from the calling CI run.
required: false
type: string
default: ""
permissions:
contents: read
@@ -16,8 +22,8 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
@@ -25,6 +31,8 @@ jobs:
run: npm install --global npm@11.16.0
- run: npm ci
- uses: ./.github/actions/portable-build-cache
with:
build_cache_artifact: ${{ inputs.build_cache_artifact }}
- name: Acquire fixed upstream native profile in runner temporary storage
shell: bash
run: |
+10 -2
View File
@@ -1,6 +1,12 @@
name: Real website runtime attribution
on:
workflow_call:
inputs:
build_cache_artifact:
description: Portable build cache artifact from the calling CI run.
required: false
type: string
default: ""
workflow_dispatch:
permissions:
contents: read
@@ -11,13 +17,15 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- uses: ./.github/actions/portable-build-cache
with:
build_cache_artifact: ${{ inputs.build_cache_artifact }}
- run: npm run verify:browser:runtime
env:
REA_BROWSER_EXECUTABLE: /usr/bin/google-chrome
+10 -2
View File
@@ -1,6 +1,12 @@
name: Real website source-map verification
on:
workflow_call:
inputs:
build_cache_artifact:
description: Portable build cache artifact from the calling CI run.
required: false
type: string
default: ""
workflow_dispatch:
permissions:
contents: read
@@ -11,13 +17,15 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- uses: ./.github/actions/portable-build-cache
with:
build_cache_artifact: ${{ inputs.build_cache_artifact }}
- name: Install the pinned compiler into an isolated fixture directory
run: npm install --prefix "$RUNNER_TEMP/rea-web-source-map-compiler" --no-audit --no-fund --package-lock=false esbuild@0.25.10
- run: npm run verify:browser:source-maps
+13 -13
View File
@@ -48,7 +48,7 @@ jobs:
echo "frozen=true" >> "$GITHUB_OUTPUT"
fi
- uses: googleapis/release-please-action@v4
- uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1
if: steps.editorial.outputs.frozen != 'true'
with:
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
@@ -70,13 +70,13 @@ jobs:
sha: ${{ steps.release.outputs.sha }}
steps:
- name: Check out reviewed release merge
uses: actions/checkout@v6
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@v6
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version-file: .nvmrc
package-manager-cache: false
@@ -104,7 +104,7 @@ jobs:
node scripts/release-notes.mjs publication --repository "$GH_REPO" \
--source "$SOURCE_SHA" --pr "$RELEASE_PR"
- uses: googleapis/release-please-action@v4
- uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1
id: release
with:
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
@@ -191,14 +191,14 @@ jobs:
fi
- name: Check out release controller and Git history
uses: actions/checkout@v6
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
ref: ${{ github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Set up Node.js for checkpoint validation
uses: actions/setup-node@v6
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version-file: .nvmrc
package-manager-cache: false
@@ -226,7 +226,7 @@ jobs:
node scripts/release-notes.mjs publication --repository "$GH_REPO" \
--source "$SOURCE_SHA"
- uses: googleapis/release-please-action@v4
- uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1
id: release
with:
# A dedicated credential starts PR CI automatically; the built-in
@@ -255,7 +255,7 @@ jobs:
- name: Check out release pull request
if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'
uses: actions/checkout@v6
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
ref: ${{ fromJSON(steps.release.outputs.pr).headBranchName }}
fetch-depth: 0
@@ -263,7 +263,7 @@ jobs:
- name: Set up Node.js for generated documentation
if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'
uses: actions/setup-node@v6
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version-file: .nvmrc
package-manager-cache: false
@@ -314,12 +314,12 @@ jobs:
contents: read
id-token: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
ref: ${{ needs.merged-release.outputs.sha || needs.release-please.outputs.sha }}
persist-credentials: false
- uses: actions/setup-node@v6
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version-file: .nvmrc
package-manager-cache: false
@@ -328,7 +328,7 @@ jobs:
- run: npm ci
- name: Download the exact qualified package
uses: actions/download-artifact@v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: verified-release-package
path: .cache/verified-package/
@@ -392,7 +392,7 @@ jobs:
contents: read
id-token: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
ref: ${{ needs.merged-release.outputs.sha || needs.release-please.outputs.sha }}
persist-credentials: false
+3 -3
View File
@@ -22,14 +22,14 @@ jobs:
# The repository-pinned Node 24 version also runs the complete test suite.
node: ["22.x", "24.15.0", "24.x", "26.x"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: ${{ matrix.node }}
check-latest: true
cache: npm
- run: npm ci
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: rea-test-runtime
path: .
+2 -2
View File
@@ -14,9 +14,9 @@ jobs:
timeout-minutes: 10
steps:
- name: Check out the repository
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Set up Python for website assets
uses: actions/setup-python@v5
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
cache: pip
+9 -9
View File
@@ -47,9 +47,9 @@ jobs:
exit 1
fi
- name: Check out the repository
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Set up Python for website assets
uses: actions/setup-python@v5
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
cache: pip
@@ -74,9 +74,9 @@ jobs:
)
PY
- name: Configure Pages
uses: actions/configure-pages@v5
uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0
- name: Upload the website
uses: actions/upload-pages-artifact@v4
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0
with:
path: website/_site
@@ -95,15 +95,15 @@ jobs:
steps:
- name: Check out publishing configuration
if: inputs.target == 'both'
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Set up Node.js for Wrangler
if: inputs.target == 'both'
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
- name: Download the verified website
if: inputs.target == 'both'
uses: actions/download-artifact@v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: github-pages
path: ${{ runner.temp }}/website-archive
@@ -114,7 +114,7 @@ jobs:
tar -xf "$RUNNER_TEMP/website-archive/artifact.tar" -C "$RUNNER_TEMP/rea-website-public"
- name: Publish the website to Cloudflare
if: inputs.target == 'both'
uses: cloudflare/wrangler-action@v4
uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
@@ -123,7 +123,7 @@ jobs:
command: deploy --cwd website --env production --assets "${{ runner.temp }}/rea-website-public"
- name: Publish to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5
- name: Verify the selected published revisions
env:
PAGES_URL: ${{ steps.deployment.outputs.page_url }}
+1
View File
@@ -36,6 +36,7 @@ coverage/
.vitest/
.vitest-reports/
test-results.xml
test-timings.json
.turbo/
.cache/
docs/.vitepress/cache/
+15
View File
@@ -142,6 +142,21 @@ caches deterministic builds and static checks across Git worktrees. After a pack
Repository invariants live in [AGENTS.md](AGENTS.md). Keep provider-specific
protocols in adapters and shared CLI/MCP workflows in the application layer.
### Windows source checkouts
The published npm package includes the Windows x64 native addon and its
manifest. A source checkout does not: `native/windows/build/` is ignored by
Git, and neither `npm ci` nor `npm run build:cached` (or `npm run build`)
produces `rea-windows-x64.node` or `manifest.json` in that directory.
Windows workflows and tests that require native controls need these artifacts;
without them, those controls are unavailable even after a successful runtime build.
Follow the [Windows native build guide](native/windows/README.md#build) before
running those workflows from source. The artifact build requires Linux,
MinGW-w64, and `tar`; the build script does not run on Windows. See that guide
for the build and artifact-verification commands and the separate real Windows
verification prerequisites. Keep the generated addon and manifest uncommitted.
## Documentation website
The VitePress site uses the Markdown files in `docs/`. Run `npm run docs:dev`
+1 -1
View File
@@ -322,7 +322,7 @@ Report vulnerabilities through [SECURITY.md](SECURITY.md).
## Star history
🎉 **170,000 GitHub stars — thank you!**
🎉 **180,000 GitHub stars — thank you!**
Thanks to everyone using REA, reporting bugs, requesting features, testing builds, and contributing fixes.
+107 -9
View File
@@ -3,8 +3,14 @@ import com.google.gson.*;
import io.modelcontextprotocol.kotlin.sdk.server.*;
import io.modelcontextprotocol.kotlin.sdk.types.*;
import jadx.api.*;
import jadx.api.plugins.input.data.ICodeReader;
import jadx.api.plugins.input.data.IMethodRef;
import jadx.api.plugins.input.insns.InsnIndexType;
import jadx.api.plugins.input.insns.Opcode;
import jadx.core.dex.info.MethodInfo;
import jadx.core.dex.nodes.*;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.CountDownLatch;
import java.util.stream.Collectors;
@@ -79,11 +85,17 @@ class ReaJadxBridge {
ordered.sort(Comparator.comparing(MethodNode::getAlias));
List<MethodSnapshot> methods = ordered.stream().map(method -> {
String name = method.getAlias();
String shortId = method.getMethodInfo().getShortId();
String rawName = method.getMethodInfo().getName();
if (!shortId.startsWith(rawName + "("))
throw new IllegalStateException("JADX method ID has no raw-name prefix: " + shortId);
String descriptor = shortId.substring(rawName.length());
String signature = name + "(" + method.getArgTypes().stream()
.map(Object::toString).collect(Collectors.joining(", "))
+ "): " + method.getReturnType().toString();
return new MethodSnapshot(name, method.getMethodInfo().getShortId(), Map.of(
return new MethodSnapshot(name, shortId, Map.of(
"name", name, "signature", signature,
"dex_descriptor", descriptor,
"is_constructor", method.isConstructor(), "def_pos", 0));
}).toList();
List<Map<String, Object>> fields = node.getFields().stream()
@@ -140,6 +152,57 @@ class ReaJadxBridge {
"smali", true, List.of("static_initializer_disassembly"));
}
// The local transport admits 8 MiB frames. Reserve 1 KiB for the fixed
// JSON-RPC/CallToolResult envelope and generated numeric request id, and
// account for JSON text being escaped again inside TextContent.
private static final int MAX_XREF_TEXT_BYTES = 8 * 1024 * 1024 - 1024;
private static int escapedJsonBytes(Object value) {
return JSON.toJson(JSON.toJson(value)).getBytes(StandardCharsets.UTF_8).length;
}
private static final class InvokeBudget {
long remaining;
InvokeBudget(long remaining) { this.remaining = Math.max(0, remaining); }
boolean admit(Map<String, Object> site) {
// Check a conservative two-layer escape bound before serialization.
long bound = 256;
for (Object value : site.values())
if (value instanceof String text) bound += 12L * text.length();
if (bound > remaining) return false;
long bytes = escapedJsonBytes(site) - 2L + 1L;
if (bytes > remaining) return false;
remaining -= bytes;
return true;
}
}
private record DirectInvokes(List<Map<String, Object>> sites, boolean exhaustive) {}
/** Raw DEX invokes within one JADX using method; offsets are code units from its code start. */
private static DirectInvokes directInvokes(JavaMethod source, MethodInfo target, InvokeBudget budget) {
MethodNode node = source.getMethodNode();
ICodeReader code = node.getCodeReader();
if (code == null) return null;
List<Map<String, Object>> sites = new ArrayList<>();
boolean[] exhaustive = {true};
code.visitInstructions(insn -> {
insn.decode();
if (insn.getIndexType() != InsnIndexType.METHOD_REF) return;
Opcode opcode = insn.getOpcode();
if (!opcode.name().startsWith("INVOKE_")) return;
IMethodRef ref = insn.getIndexAsMethod();
ref.load();
if (!MethodInfo.fromRef(node.root(), ref).getRawFullId().equals(target.getRawFullId())) return;
Map<String, Object> site = Map.of(
"dex_entry", node.getInputFileName(),
"source_raw_method_id", node.getMethodInfo().getRawFullId(),
"dex_code_unit_offset", insn.getOffset(),
"dex_method_index", insn.getIndex(),
"opcode", opcode.name());
if (budget.admit(site)) sites.add(site);
else exhaustive[0] = false;
});
return new DirectInvokes(sites, exhaustive[0]);
}
private CallToolResult handle(String name, JsonObject args) {
if (name.equals("rea_jvm_status")) return json(Map.of(
"engine_reported_version", BuildInfo.VERSION,
@@ -173,17 +236,52 @@ class ReaJadxBridge {
}
List<JavaNode> uses;
String target = string(args, "class_name");
MethodSnapshot selectedMethod = null;
int overloadIndex = 0;
if (name.equals("get_xrefs_to_class")) uses = cls.node().getUseIn();
else {
List<MethodSnapshot> overloads = methods(cls, args);
if (overloads.size() != 1) throw new InputFailure("method_name", "Method references require a unique overload");
target += "." + overloads.get(0).name();
uses = method(cls, overloads.get(0)).getUseIn();
if (overloads.size() != 1 && !args.containsKey("overload_index"))
throw new InputFailure("overload_index", "Select one of " + overloads.size() + " method overloads");
overloadIndex = integer(args, "overload_index", 0);
if (overloadIndex >= overloads.size())
throw new InputFailure("overload_index", "overload_index out of range: " + overloadIndex);
selectedMethod = overloads.get(overloadIndex);
target += "." + selectedMethod.name();
uses = method(cls, selectedMethod).getUseIn();
}
// REA consumes all references without materializing source line information.
List<Map<String, Object>> items = uses.stream().map(node -> current.describeUsage(node, false)).toList();
return json(Map.of("target", target, "total", uses.size(), "count", items.size(),
"resolve_line", false, "items", items));
MethodInfo targetInfo = selectedMethod == null ? null : method(cls, selectedMethod).getMethodNode().getMethodInfo();
List<Map<String, Object>> items = uses.stream().map(node -> {
Map<String, Object> item = new LinkedHashMap<>(current.describeUsage(node, false));
if (targetInfo != null && node instanceof JavaMethod) {
item.put("direct_invokes", List.of());
item.put("direct_invokes_exhaustive", false);
}
return item;
}).toList();
Map<String, Object> result = new LinkedHashMap<>(Map.of("target", target,
"total", uses.size(), "count", items.size(), "resolve_line", false, "items", items));
if (selectedMethod != null) {
result.put("overload_index", overloadIndex);
result.put("reported_signature", selectedMethod.summary().get("signature"));
}
if (targetInfo != null) {
InvokeBudget budget = new InvokeBudget(MAX_XREF_TEXT_BYTES - escapedJsonBytes(result));
for (int index = 0; index < uses.size(); index++) {
if (!(uses.get(index) instanceof JavaMethod source)) continue;
DirectInvokes invokes = directInvokes(source, targetInfo, budget);
Map<String, Object> item = items.get(index);
if (invokes == null) {
item.remove("direct_invokes");
item.remove("direct_invokes_exhaustive");
} else {
item.put("direct_invokes", invokes.sites());
item.put("direct_invokes_exhaustive", invokes.exhaustive());
}
}
}
return json(result);
}
public static void main(String[] args) throws Exception {
@@ -217,7 +315,7 @@ class ReaJadxBridge {
List<String> fields = switch (name) {
case "list_classes" -> List.of("offset", "limit");
case "get_method_body" -> List.of("class_name", "method_name", "overload_index");
case "get_xrefs_to_method" -> List.of("class_name", "method_name");
case "get_xrefs_to_method" -> List.of("class_name", "method_name", "overload_index");
default -> List.of("class_name");
};
Map<String, kotlinx.serialization.json.JsonElement> properties = new LinkedHashMap<>();
@@ -226,7 +324,7 @@ class ReaJadxBridge {
case "list_classes" -> "Indexed pages of all top-level class names, with offset and limit.";
case "get_class_summary" -> "Parsed member metadata for one exact class name; no source generation.";
case "get_method_body" -> "Source for an exact class and stable same-name overload index; static initializers use smali to preserve folded field initialization.";
default -> "Complete static references to an exact class or unique method; source lines are unresolved.";
default -> "Complete static using-node inventory for an exact class or selected method overload; one node may contain multiple invoke instructions, and source lines are unresolved.";
};
Tool narrowed = new Tool(name, new ToolSchema(null, new JsonObject(properties),
tool.getInputSchema().getRequired(), null), description, null, null, null, null, null, null);
+500 -17
View File
@@ -13,6 +13,8 @@ import java.net.UnixDomainSocketAddress;
import java.nio.channels.Channels;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
@@ -22,8 +24,11 @@ import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.PosixFilePermissions;
import java.security.MessageDigest;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.Iterator;
@@ -82,6 +87,7 @@ import ghidra.program.model.address.SegmentedAddress;
import ghidra.program.model.reloc.Relocation;
import java.io.ByteArrayOutputStream;
import ghidra.program.model.symbol.Namespace;
import ghidra.program.model.symbol.FlowType;
import ghidra.program.model.symbol.RefType;
import ghidra.program.model.symbol.Reference;
import ghidra.program.model.symbol.ReferenceIterator;
@@ -100,12 +106,17 @@ import ghidra.program.model.pcode.PcodeBlock;
import ghidra.program.model.pcode.PcodeBlockBasic;
import ghidra.program.model.pcode.Varnode;
import ghidra.program.model.pcode.VarnodeAST;
import ghidra.program.util.ContextEvaluatorAdapter;
import ghidra.program.util.SymbolicPropogator;
import ghidra.program.util.VarnodeContext;
public final class ReaGhidraBridge extends HeadlessScript {
private static final int MAX_HIGH_PCODE_OPS = 3000;
private static final int MAX_HIGH_PCODE_INPUTS_PER_OP = 64;
private static final int MAX_HIGH_PCODE_DEF_USE_EDGES = 12000;
private static final Gson GSON = new GsonBuilder().serializeNulls().create();
private final Map<FileBytes, Boolean> targetSourceIdentity = new IdentityHashMap<>();
private String admittedTargetSha256;
private static final Set<String> DESCRIPTOR_KEYS = Set.of(
"transport",
"endpoint_path",
@@ -207,6 +218,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
"Ghidra imported-byte digest does not match the admitted target"
);
}
admittedTargetSha256 = descriptor.targetSha256;
// GhidraScript wraps run() in a transaction. End it before serving so
// each edit owns a real outer transaction and can roll back immediately.
end(true);
@@ -633,7 +645,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
return result;
}
private JsonObject addressToFileOffset(JsonObject params) {
private JsonObject addressToFileOffset(JsonObject params) throws Exception {
requireKeys(params, Set.of("document", "address"));
requireDocument(params);
Address address = requireAddress(params, "address");
@@ -643,14 +655,15 @@ public final class ReaGhidraBridge extends HeadlessScript {
Long fileOffset = null;
int mappings = 0;
for (MemoryBlockSourceInfo source : block.getSourceInfos()) {
if (!source.contains(address) || source.getFileBytes().isEmpty()) continue;
if (!source.contains(address) || source.getFileBytes().isEmpty() ||
!isTargetSource(source.getFileBytes().get())) continue;
long offset = source.getFileBytesOffset(address);
if (offset < 0) continue;
fileOffset = offset;
mappings++;
}
if (mappings != 1)
throw new RequestFailure("invalid_request", "Address has " + mappings + " authoritative source mappings: " + canonicalAddress(address));
throw new RequestFailure("invalid_request", "Address has " + mappings + " authoritative target-file mappings: " + canonicalAddress(address));
JsonObject result = new JsonObject();
result.addProperty("address", canonicalAddress(address));
result.addProperty("file_offset", fileOffset);
@@ -673,14 +686,96 @@ public final class ReaGhidraBridge extends HeadlessScript {
private JsonElement procedurePseudocode(JsonObject params) throws Exception {
requireKeys(params, Set.of("document", "procedure"));
requireDocument(params);
Function function = resolveProcedure(requireString(params, "procedure"));
String value = decompiledText(decompile(function));
return functionObservation(
value == null ? JsonNull.INSTANCE : GSON.toJsonTree(value),
noReturnCallLimitations(
currentProgram.getListing().getInstructions(function.getBody(), true)
)
);
String selector = requireString(params, "procedure");
JsonObject selection = new JsonObject();
Function function = resolveDecompileProcedure(selector, selection);
try {
String value = decompiledText(decompile(function));
JsonObject result = functionObservation(
value == null ? JsonNull.INSTANCE : GSON.toJsonTree(value),
noReturnCallLimitations(
currentProgram.getListing().getInstructions(function.getBody(), true)
)
);
if (selection.size() > 0) result.add("selection", selection);
return result;
} catch (RequestFailure failure) {
if (selection.size() == 0) throw failure;
// Preserve completed resolution facts without changing the backend's
// failure code or suggesting that the successful mapping was invalid.
throw new RequestFailure(failure.code, "Selector " + selector +
" mapped to analysis address " + selection.get("mapped_address").getAsString() +
" and procedure " + canonicalAddress(function.getEntryPoint()) +
"; " + failure.getMessage());
}
}
private Function resolveDecompileProcedure(String selector, JsonObject selection) throws Exception {
String prefix = "file-offset:";
boolean explicitOffset = selector.startsWith(prefix);
if (!explicitOffset) {
try {
return resolveProcedure(selector);
} catch (RequestFailure failure) {
// Existing names/addresses win. Only an unresolved numeric input
// can fall back to source coordinates; ambiguity never falls back.
if (!failure.code.equals("not_found") || !selector.matches("0x[0-9a-fA-F]+"))
throw failure;
}
}
String coordinate = explicitOffset ? selector.substring(prefix.length()) : selector;
if (!coordinate.matches("0x[0-9a-fA-F]+"))
throw new RequestFailure("invalid_request", "File offsets require hexadecimal file-offset:0x... syntax");
BigInteger parsed = new BigInteger(coordinate.substring(2), 16);
if (parsed.compareTo(BigInteger.valueOf(9007199254740991L)) > 0)
throw new RequestFailure("invalid_request", "File offset exceeds the exact JSON integer range");
long offset = parsed.longValueExact();
monitor.checkCancelled();
// Ghidra's convenience lookup spans every FileBytes and uses coordinates
// local to each object. Only a complete, digest-matched target source
// establishes that the caller's offset belongs to the admitted file.
Set<Address> mappedAddresses = new java.util.LinkedHashSet<>();
for (MemoryBlock block : currentProgram.getMemory().getBlocks()) {
for (MemoryBlockSourceInfo source : block.getSourceInfos()) {
monitor.checkCancelled();
FileBytes backing = source.getFileBytes().orElse(null);
if (backing == null || !isTargetSource(backing)) continue;
Address mapped = source.locateAddressForFileOffset(offset);
if (mapped != null) mappedAddresses.add(mapped);
}
}
List<Address> candidates = new ArrayList<>(mappedAddresses);
if (candidates.size() > 1)
throw new RequestFailure("ambiguous", "File offset " + coordinate +
" has multiple loaded mappings: " + candidates.stream().map(ReaGhidraBridge::canonicalAddress).toList() +
"; select an explicit analysis address");
if (candidates.isEmpty())
throw new RequestFailure("not_found", "File offset " + coordinate +
" has no loaded source mapping for the admitted target file; Ghidra image base: " + canonicalAddress(currentProgram.getImageBase()) +
". Inspect mappings with rea inspect-native-load-image <path> --provider ghidra --json or MCP inspect_native_load_image.");
Address mapped = candidates.get(0);
Function function = functionAtOrContaining(mapped);
if (function == null)
throw new RequestFailure("not_found", "File offset " + coordinate +
" maps to analysis address " + canonicalAddress(mapped) +
", but no procedure starts at or contains that address in the current Ghidra analysis. " +
"Choose an observed procedure with CLI rea search <path> . --kind procedures --mode regex --provider ghidra --json or MCP list_procedures.");
selection.addProperty("file_offset", offset);
selection.addProperty("mapped_address", canonicalAddress(mapped));
selection.addProperty("procedure_address", canonicalAddress(function.getEntryPoint()));
return function;
}
private boolean isTargetSource(FileBytes source) throws Exception {
Boolean observed = targetSourceIdentity.get(source);
if (observed != null) return observed;
// Original FileBytes are immutable even when analysis memory is edited.
// Partial/auxiliary sources remain inspectable but cannot establish an
// original-target offset from their object-local coordinates alone.
boolean matched = source.getFileOffset() == 0 &&
constantTimeEquals(hashFileBytes(source, false), admittedTargetSha256);
targetSourceIdentity.put(source, matched);
return matched;
}
private JsonElement procedureAssembly(JsonObject params) throws Exception {
@@ -718,7 +813,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
return functionObservation(result, boundaryLimitations);
}
private JsonArray procedureCalls(JsonObject params, boolean callers) throws Exception {
private JsonObject procedureCalls(JsonObject params, boolean callers) throws Exception {
requireKeys(params, Set.of("document", "procedure"));
requireDocument(params);
Function function = resolveProcedure(requireString(params, "procedure"));
@@ -731,7 +826,12 @@ public final class ReaGhidraBridge extends HeadlessScript {
for (Function item : ordered) {
result.add(canonicalAddress(item.getEntryPoint()));
}
return result;
return functionObservation(
result,
noReturnCallLimitations(
currentProgram.getListing().getInstructions(function.getBody(), true)
)
);
}
private JsonObject procedureInfo(JsonObject params) throws Exception {
@@ -1233,6 +1333,13 @@ public final class ReaGhidraBridge extends HeadlessScript {
result.add("outgoing_references", outgoing);
result.add("referenced_strings", referencedStrings);
result.add("referenced_names", referencedNames);
boolean swift = swiftProgram();
boolean aarch64 = currentProgram.getLanguageID().getIdAsString().startsWith("AARCH64:");
SwiftStringLiteralDecode swiftLiterals = swift && aarch64 ? swiftStringLiterals(function) : null;
if (swiftLiterals != null) {
result.add("swift_string_literals", swiftLiterals.literals());
result.add("swift_string_literal_omissions", swiftLiterals.omissions());
}
result.add("basic_blocks", blocks);
result.add(
"native_api",
@@ -1252,6 +1359,20 @@ public final class ReaGhidraBridge extends HeadlessScript {
limitations.add(
"Synthetic Ghidra entry-point references without actionable memory sources are omitted."
);
if (swift && aarch64) {
limitations.add(
"Swift String literals are decoded from constant register pairs at calls, returns and paired stores; literals stored one word at a time, chosen by conditional selects, passed through unresolved jumps, or built at runtime are not reported."
);
if (swiftLiterals.omissions().size() > 0) {
limitations.add(
"Swift String literal records listed in swift_string_literal_omissions could not be retained or decoded within this dossier's remaining heap budget; later symbolic flows remain unexamined."
);
}
} else if (swift) {
limitations.add(
"Swift String literal decoding is implemented for AArch64 register pairs only; no Swift literals were decoded for this architecture."
);
}
List<String> boundaryLimitations = noReturnCallLimitations(scan.instructions);
for (String limitation : boundaryLimitations) {
limitations.add(limitation);
@@ -1833,14 +1954,25 @@ public final class ReaGhidraBridge extends HeadlessScript {
Address address = explicitAddress ? parseReaAddress(value) : tryParseAddress(value);
if (address == null)
throw new RequestFailure("not_found", "Unknown Ghidra procedure name or address: " + value);
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
if (function == null && address.isMemoryAddress())
function = currentProgram.getFunctionManager().getFunctionContaining(address);
Function function = functionAtOrContaining(address);
if (function == null)
throw new RequestFailure("not_found", "No procedure exists at the requested address: " + value);
// Other procedure operations retain their analysis-address semantics.
throw new RequestFailure("not_found", "No procedure exists at the requested address: " + value +
"; Ghidra image base: " + canonicalAddress(currentProgram.getImageBase()) +
". Choose an observed analysis address. CLI: rea search <path> . --kind procedures --mode regex --provider ghidra --json; " +
"MCP: list_procedures. Inspect the loaded base and source mappings with CLI rea analyze <path> --provider ghidra --json " +
"and rea inspect-native-load-image <path> --provider ghidra --json, or MCP binary_overview and inspect_native_load_image. " +
"Repeat the original target options. For decompile, select source coordinates explicitly with --file-offset=0x... or MCP procedure file-offset:0x....");
return function;
}
private Function functionAtOrContaining(Address address) {
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
return function == null && address.isMemoryAddress()
? currentProgram.getFunctionManager().getFunctionContaining(address)
: function;
}
private JsonObject nativeApiBoundary(
Function function,
DecompileResults decompilation
@@ -3294,6 +3426,349 @@ public final class ReaGhidraBridge extends HeadlessScript {
return result;
}
private boolean swiftProgram() {
for (MemoryBlock block : currentProgram.getMemory().getBlocks()) {
String name = block.getName();
if (name.startsWith("__swift5_") || name.startsWith("swift5_")) return true;
}
return false;
}
/**
* Swift passes a String as two words: small literals carry their bytes in the words and
* immortal large literals carry their count and a biased pointer to the bytes. Ghidra keeps
* neither as a data reference, so decode constant pairs where Swift consumes them: argument
* and return registers at calls, tail calls and returns, and both registers of one STP.
* The canonical empty string is a small literal with a zero count.
*/
private SwiftStringLiteralDecode swiftStringLiterals(Function function) throws Exception {
Register[] registers = new Register[31];
for (int index = 0; index < registers.length; index++) {
registers[index] = currentProgram.getRegister("x" + index);
}
TreeMap<String, JsonObject> observed = new TreeMap<>();
TreeMap<String, JsonObject> omitted = new TreeMap<>();
SwiftLiteralBudget budget = new SwiftLiteralBudget();
Map<String, Boolean> selectedValues = new HashMap<>();
SymbolicPropogator propagator = new SymbolicPropogator(currentProgram, false);
propagator.flowConstants(function.getEntryPoint(), function.getBody(), new ContextEvaluatorAdapter() {
@Override
public boolean evaluateContextBefore(VarnodeContext context, Instruction instruction) {
// Returning true ends only the current flow, so every queued flow checks the shared stop.
if (budget.stopped()) return true;
for (Register[] pair : swiftStringPairs(function, instruction, registers)) {
Long word = registerConstant(context, pair[0]);
Long object = registerConstant(context, pair[1]);
if (word == null || object == null) continue;
SwiftLiteralLayout layout = swiftLiteralLayout(word, object);
if (layout == null) continue;
// A selected word can combine with a path-dependent ordinary move. Cache the
// provenance question per site/register, charging retained cache state to the
// same dossier budget before allocating it.
boolean selected = false;
boolean provenanceOverCapacity = false;
for (Register register : pair) {
String provenanceKey = canonicalAddress(instruction.getAddress()) + "\u0000" +
register.getBaseRegister().getName();
Boolean value = selectedValues.get(provenanceKey);
if (value == null) {
if (!budget.takeState()) {
provenanceOverCapacity = true;
break;
}
value = selectedValue(function, instruction, register);
selectedValues.put(provenanceKey, value);
}
if (value) {
selected = true;
break;
}
}
if (selected) continue;
// Forks can reach one site with different constants, so the words are part of the key.
String key = canonicalAddress(instruction.getAddress()) + "\u0000" + pair[0].getName() +
"\u0000" + pair[1].getName() + "\u0000" + Long.toUnsignedString(word, 16) +
"\u0000" + Long.toUnsignedString(object, 16);
if (observed.containsKey(key) || omitted.containsKey(key)) continue;
// Every record draws on the budget; the first one that does not fit is kept as an
// omission and decoding stops, so later sites are never examined.
String value = layout.smallValue();
boolean overCapacity = provenanceOverCapacity || !budget.take(layout.byteLength());
if (!overCapacity && value == null) {
try {
value = swiftLargeLiteralText(layout);
} catch (OutOfMemoryError exhausted) {
overCapacity = true;
}
}
if (value == null && !overCapacity) continue;
JsonObject item = overCapacity
? swiftStringOmission(layout, word, object)
: swiftStringItem(value, layout, word, object);
item.addProperty("source_address", canonicalAddress(instruction.getAddress()));
JsonArray pairRegisters = new JsonArray();
pairRegisters.add(pair[0].getName());
pairRegisters.add(pair[1].getName());
item.add("registers", pairRegisters);
(overCapacity ? omitted : observed).put(key, item);
if (overCapacity) {
budget.stop();
return true;
}
}
return false;
}
}, false, monitor);
JsonArray literals = new JsonArray();
observed.values().forEach(literals::add);
JsonArray omissions = new JsonArray();
omitted.values().forEach(omissions::add);
return new SwiftStringLiteralDecode(literals, omissions);
}
private static List<Register[]> swiftStringPairs(
Function function,
Instruction instruction,
Register[] registers
) {
List<Register[]> pairs = new ArrayList<>();
if (instruction.getMnemonicString().equals("stp")) {
Register first = instruction.getRegister(0);
Register second = instruction.getRegister(1);
if (first != null && second != null && first.getBitLength() == 64 && second.getBitLength() == 64) {
pairs.add(new Register[] {first, second});
}
return pairs;
}
FlowType flow = instruction.getFlowType();
boolean returns = false;
for (PcodeOp operation : instruction.getPcode()) {
if (operation.getOpcode() == PcodeOp.RETURN) returns = true;
}
// A jump is a proven tail call only when it has resolved targets and all leave the
// body. A targetless computed jump can still dispatch inside the function.
Address[] targets = instruction.getFlows();
boolean tailCall = flow.isJump() && !flow.isConditional() && targets.length > 0 &&
Arrays.stream(targets).allMatch(target -> !function.getBody().contains(target));
if (!flow.isCall() && !returns && !tailCall) return pairs;
// A String occupies two consecutive registers among x0-x7, or x0 and x1 when returned.
int lastFirstRegister = returns ? 0 : 6;
for (int index = 0; index <= lastFirstRegister; index++) {
pairs.add(new Register[] {registers[index], registers[index + 1]});
}
return pairs;
}
private static Long registerConstant(VarnodeContext context, Register register) {
Varnode value = context.getRegisterVarnodeValue(register);
return value != null && context.isConstant(value) ? value.getOffset() : null;
}
// Search every predecessor path for the reaching definitions, following register copies,
// since a select copied into an argument register or defined before a branch forks just as
// independently as one written there directly.
private boolean selectedValue(Function function, Instruction use, Register register) {
ArrayDeque<Instruction> instructions = new ArrayDeque<>();
ArrayDeque<Register> targets = new ArrayDeque<>();
Set<String> visited = new HashSet<>();
for (Instruction predecessor : predecessors(function, use)) {
instructions.push(predecessor);
targets.push(register.getBaseRegister());
}
while (!instructions.isEmpty()) {
Instruction instruction = instructions.pop();
Register target = targets.pop();
if (!visited.add(canonicalAddress(instruction.getAddress()) + "\u0000" + target.getName())) continue;
boolean defines = false;
for (Object result : instruction.getResultObjects()) {
if (result instanceof Register written && written.getBaseRegister().equals(target)) defines = true;
}
Register next = target;
if (defines) {
PcodeOp[] operations = instruction.getPcode();
for (PcodeOp operation : operations) {
if (operation.getOpcode() == PcodeOp.CBRANCH && operation.getInput(0).isConstant()) return true;
}
Register source = copiedRegister(operations);
if (source == null) continue;
next = source.getBaseRegister();
}
for (Instruction predecessor : predecessors(function, instruction)) {
instructions.push(predecessor);
targets.push(next);
}
}
return false;
}
private List<Instruction> predecessors(Function function, Instruction instruction) {
List<Instruction> result = new ArrayList<>();
Address from = instruction.getFallFrom();
if (from != null) {
Instruction previous = currentProgram.getListing().getInstructionAt(from);
if (previous != null) result.add(previous);
}
for (Reference reference : currentProgram.getReferenceManager().getReferencesTo(instruction.getAddress())) {
if (!reference.getReferenceType().isFlow() || reference.getReferenceType().isCall()) continue;
if (!function.getBody().contains(reference.getFromAddress())) continue;
Instruction source = currentProgram.getListing().getInstructionAt(reference.getFromAddress());
if (source != null) result.add(source);
}
return result;
}
private Register copiedRegister(PcodeOp[] operations) {
if (operations.length != 1) return null;
int opcode = operations[0].getOpcode();
Varnode input = operations[0].getInput(0);
if ((opcode != PcodeOp.COPY && opcode != PcodeOp.INT_ZEXT) || input == null || !input.isRegister()) return null;
return currentProgram.getRegister(input);
}
/** Validate the ABI layout of a pair; a small literal is decoded here, a large one only located. */
private SwiftLiteralLayout swiftLiteralLayout(long word, long object) {
int discriminator = (int) (object >>> 60);
if (discriminator == 0xE || discriminator == 0xA) {
int count = (int) ((object >>> 56) & 0xF);
byte[] bytes = new byte[15];
for (int index = 0; index < 8; index++) bytes[index] = (byte) (word >>> (8 * index));
for (int index = 0; index < 7; index++) bytes[8 + index] = (byte) (object >>> (8 * index));
for (int index = count; index < bytes.length; index++) {
if (bytes[index] != 0) return null;
}
// Count zero is the canonical empty string (object 0xE000000000000000, word 0).
String value = swiftStringText(bytes, count, discriminator == 0xE);
return value == null ? null : new SwiftLiteralLayout("small", null, count, true, value);
}
int flags = (int) (word >>> 60);
long count = word & 0xFFFFFFFFFFFFL;
// Immortal literals: ASCII and NFC or neither, tail allocated, with a pointer biased by 32.
if ((object >>> 56) != 0x80 || (flags != 0xD && flags != 0x1) || ((word >>> 48) & 0xFFF) != 0) {
return null;
}
try {
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
.getAddress((object & 0x00FFFFFFFFFFFFFFL) + 0x20);
MemoryBlock block = currentProgram.getMemory().getBlock(address);
// The bytes and their terminator must lie in one initialized, non-executable block,
// and Java arrays bound one read.
if (block == null || !block.isInitialized() || block.isExecute() ||
count >= block.getEnd().subtract(address) + 1 || count >= Integer.MAX_VALUE) {
return null;
}
return new SwiftLiteralLayout("large", address, count, flags == 0xD, null);
} catch (Exception unaddressable) {
return null;
}
}
private String swiftLargeLiteralText(SwiftLiteralLayout layout) {
int count = (int) layout.byteLength();
byte[] bytes = new byte[count + 1];
try {
if (currentProgram.getMemory().getBytes(layout.address(), bytes) != bytes.length) return null;
} catch (Exception unreadable) {
return null;
}
return bytes[count] == 0 ? swiftStringText(bytes, count, layout.ascii()) : null;
}
/**
* Every literal record of one dossier draws on half of the heap that was free when decoding
* began. Text is charged at the worst case per UTF-8 byte: two bytes of UTF-16 text, then up
* to six escaped JSON characters ("\\u0000") held as UTF-16 in Gson's growing writer buffer
* (up to twice its length), again in the returned String, and once more as UTF-8 on the
* socket. Each record also pays a fixed cost for its map key, JSON members, addresses, words
* and their serialized form, so many empty or small literals are bounded too. Retained
* provenance-cache entries draw on that same fixed-state allowance.
*/
private static final class SwiftLiteralBudget {
private static final long BYTES_PER_LITERAL_BYTE = 2 + 6 * 2 * 2 + 6 * 2 + 6;
private static final long BYTES_PER_RECORD = 4096;
private long remaining;
private boolean stopped;
SwiftLiteralBudget() {
Runtime runtime = Runtime.getRuntime();
remaining = Math.max(0, (runtime.maxMemory() - runtime.totalMemory() + runtime.freeMemory()) / 2);
}
boolean takeState() {
if (remaining < BYTES_PER_RECORD) return false;
remaining -= BYTES_PER_RECORD;
return true;
}
boolean take(long bytes) {
if (remaining < BYTES_PER_RECORD ||
bytes > (remaining - BYTES_PER_RECORD) / BYTES_PER_LITERAL_BYTE) {
return false;
}
remaining -= BYTES_PER_RECORD + bytes * BYTES_PER_LITERAL_BYTE;
return true;
}
void stop() {
stopped = true;
}
boolean stopped() {
return stopped;
}
}
// Swift String values may contain any scalar, including controls; JSON escapes them.
private static String swiftStringText(byte[] bytes, int length, boolean ascii) {
if (length < 0 || length > bytes.length) return null;
for (int index = 0; index < length; index++) {
if (ascii && bytes[index] < 0) return null;
}
try {
return StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes, 0, length)).toString();
} catch (CharacterCodingException invalid) {
return null;
}
}
private static JsonObject swiftStringOmission(SwiftLiteralLayout layout, long word, long object) {
JsonObject item = new JsonObject();
item.addProperty("representation", layout.representation());
if (layout.address() == null) {
item.add("address", JsonNull.INSTANCE);
} else {
item.addProperty("address", canonicalAddress(layout.address()));
}
item.addProperty("byte_length", layout.byteLength());
item.addProperty("reason", "capacity");
item.add("words", swiftStringWords(word, object));
return item;
}
private static JsonObject swiftStringItem(
String value,
SwiftLiteralLayout layout,
long word,
long object
) {
JsonObject item = new JsonObject();
item.addProperty("value", value);
item.addProperty("representation", layout.representation());
if (layout.address() == null) {
item.add("address", JsonNull.INSTANCE);
} else {
item.addProperty("address", canonicalAddress(layout.address()));
}
item.add("words", swiftStringWords(word, object));
return item;
}
private static JsonArray swiftStringWords(long word, long object) {
JsonArray words = new JsonArray();
words.add("0x" + Long.toUnsignedString(word, 16));
words.add("0x" + Long.toUnsignedString(object, 16));
return words;
}
private static String exclusiveAddress(Address inclusive) {
Address next = inclusive.next();
if (next != null && next.getAddressSpace().equals(inclusive.getAddressSpace())) {
@@ -3833,6 +4308,14 @@ public final class ReaGhidraBridge extends HeadlessScript {
private record AddressNameRequest(String inputAddress, Address address, String name) {}
private record FunctionEntry(Function function, InventoryItem item) {}
private record FunctionAnalysisObservation(JsonObject dossier, List<String> limitations) {}
private record SwiftStringLiteralDecode(JsonArray literals, JsonArray omissions) {}
private record SwiftLiteralLayout(
String representation,
Address address,
long byteLength,
boolean ascii,
String smallValue
) {}
private record InstructionScan(List<Instruction> instructions, boolean truncated) {}
private record SessionDescriptor(
String transport,
+15 -2
View File
@@ -1,6 +1,8 @@
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Program;
import ghidra.program.model.mem.MemoryBlock;
import ghidra.program.model.mem.MemoryBlockSourceInfo;
/** Seed the caller-supplied raw-binary entry before default auto-analysis. */
public final class ReaGhidraPrepareRaw extends GhidraScript {
@@ -21,8 +23,19 @@ public final class ReaGhidraPrepareRaw extends GhidraScript {
);
}
Address entry = currentProgram.getAddressFactory().getDefaultAddressSpace().getAddress(entryHex);
Address start = currentProgram.getMinAddress();
if (entry == null || !entry.equals(start)) {
MemoryBlock block = entry == null ? null : currentProgram.getMemory().getBlock(entry);
boolean fileStart = false;
if (block != null) {
// Processor-defined zero page/stack blocks can precede the imported file.
for (MemoryBlockSourceInfo source : block.getSourceInfos()) {
if (source.getFileBytes().isPresent() && source.getFileBytesOffset() == 0 &&
entry.equals(source.getMinAddress())) {
fileStart = true;
break;
}
}
}
if (!fileStart) {
throw new IllegalArgumentException(
"Raw binary import must map file offset 0 at the caller-supplied entry"
);
+39 -13
View File
@@ -86,6 +86,7 @@ rea inspect-android-method /path/Example.apk example.MainActivity onCreate
rea inspect-android-method /path/Example.apk example.MainActivity select --overload-index 1
rea trace-android-references /path/Example.apk example.MainActivity
rea trace-android-references /path/Example.apk example.MainActivity --method-name onCreate
rea trace-android-references /path/Example.apk example.MainActivity --method-name select --overload-index 1
```
Equivalent MCP methods are `inspect_android_package`, `search_android_classes`,
@@ -106,9 +107,10 @@ Every request includes `path`. Class/method operations use `class_name`,
```
Use `inspect_android_class` to choose an overload. REA rejects an ambiguous
method request rather than silently selecting the first match. The engine's
method reference operation cannot select overloads, so REA reports that boundary
as unsupported when multiple same-name methods exist.
method request rather than silently selecting the first match. Method reference
queries accept the same `overload_index`; the bridge resolves the selected
method node and the result reports its index and displayed signature. These
indices are tied to the current artifact and engine, not stable across builds.
The engine's smali fallback also joins same-name overloads; REA rejects that
fallback for an overloaded method instead of attributing all bodies to one
@@ -137,14 +139,28 @@ the same source byte limit and explicit truncation reporting.
full reported display types, fields and inner class names. Synthetic members
that code generation hides can appear in the inventory; it stays consistent
after method decompilation within the retained session.
Exact DEX descriptors are unknown. Overload indices are tied to this artifact
and engine, not stable identities across different builds.
Exact DEX prototypes are reported from frozen JADX method metadata as
`dex_descriptor`, for example `(I)V`. Display names can be JADX aliases;
the descriptor alone is not a complete raw DEX method ID. Overload indices
are tied to this artifact and engine, not stable identities across builds.
- Method inspection reports Java or smali, fallback markers and complete/partial
returned text. Decompiled source is a derived representation. A complete text
result does not establish complete semantics or a byte-identical reconstruction.
- Reference results are incoming static relationships between provider nodes.
Source lines and DEX instruction offsets remain unknown. They are not observed
runtime calls.
`reference_unit=using_node` means `total` counts containing JADX nodes, not
individual invoke instructions; two calls from one method can appear as one
entry. `coverage=complete` covers this node inventory only. For a method
reference whose using node has raw code, `direct_invokes` lists matching DEX
invoke instructions with their DEX entry, raw source method ID, code-unit
offset, method index and opcode. An empty array means no direct invoke was
matched in that node; `null` means raw code was unavailable or the using node
was not a method. This list does not establish complete call-site coverage
outside JADX's using-node inventory or through reflection and dynamic loading.
A virtual or interface invoke also does not establish its runtime implementation.
Source lines and the containing node's own `dex_offset` remain unknown.
Method-reference results also carry the selected `dex_descriptor`;
class-reference results report `null`.
These relationships are not observed runtime calls.
- Original requests, APK SHA-256, actual engine JAR SHA-256 and raw producer
responses remain in Evidence. Source revision is populated only for bytes
matching the audited release JAR.
@@ -210,9 +226,11 @@ REA never writes to the original APK or launches target code.
On Windows, stdin writes preserve stream backpressure and run outside the Node
event loop. Cancellation terminates the retained job, including descendants,
before workspace cleanup. An abrupt CLI/server process termination closes the
job and stops Java, but cannot run JavaScript workspace cleanup; temporary
workspace files can remain after that forced exit.
before workspace cleanup. On Windows, a detached REA helper owns the private
workspace's native directory handle. An abrupt CLI/server owner termination
closes the Java job and the helper's input pipe; the helper then removes the
workspace by handle. The real Windows lane checks both effects. If the helper
also dies or the host loses power before it finishes, private files can remain.
If Java cannot start the bridge, REA reports the selected executable and retained
startup diagnostics. Select a full JDK through `JAVA_HOME` or `PATH`, and use
@@ -275,12 +293,20 @@ The original APK and host Java configuration remain unchanged.
The same lane runs on Windows x64 with the bundled native controls and a full
JDK. It checks CLI/MCP results, real MCP cancellation and disconnect, and Java
exit after abrupt CLI owner termination. Windows forced termination is checked
separately from the POSIX CLI SIGTERM handler; it does not claim a structured
cancellation result or workspace removal after the owner is forcibly killed.
exit and workspace removal after abrupt CLI owner termination. Windows forced
termination is checked separately from the POSIX CLI SIGTERM handler. A
synthetic two-owner test confirms that one owner's exit does not remove the
other owner's workspace. The lane does not claim a structured cancellation
result or power-loss recovery.
The download script verifies fixed SHA-256 values, reuses matching files and
refuses to overwrite a different existing file. APK/JAR files live under ignored
`_reference/` and are excluded from package contents. The lane exercises actual
REA CLI and MCP results with the real engine, separately from synthetic protocol
regressions. It needs no Gradle build, emulator, Android SDK or Ghidra.
Direct invoke sites retain exact DEX code-unit offsets separately from each
containing node's unknown `dex_offset`. `direct_invokes_exhaustive: false`
means instruction rows were bounded by the local bridge frame budget; `null`
means the producer did not establish instruction coverage. Using-node totals
and coverage do not imply complete instruction evidence.
+4 -2
View File
@@ -98,8 +98,10 @@ section, symbol, mitigation or linkage views. The default `complete` detail and
CLI output are unchanged.
Complete results have a 32 MiB input, 64 MiB reply, 1 MiB combined diagnostics
and 30-second owned command deadline. The reply budget applies to the decoder
record; the CLI/MCP Evidence envelope adds copies and encoding overhead. The
and 30-second owned command deadline. `REA_PWNTOOLS_MAX_INPUT_BYTES` raises the
input bound alone, up to 1 GiB; the reply and deadline bounds are unchanged. The
reply budget applies to the decoder record; the CLI/MCP Evidence envelope adds
copies and encoding overhead. The
pinned MCP SDK defaults to a 10 MiB receive buffer. Large complete results need
a caller-configured `StdioClientTransport({ maxBufferSize: ... })` and a matching
server `REA_MCP_MAX_RESPONSE_BYTES`. See [MCP delivery budgets](mcp-contracts.md#tool-results).
+22 -1
View File
@@ -80,6 +80,27 @@ google-chrome \
REA does not launch, own, or terminate this browser. Use a dedicated profile and stop it yourself when the investigation is complete. Do not expose the debugging port on a non-loopback interface.
Other Chromium-family browsers, such as Chromium or Brave, work the same way
with the same two flags. On macOS, run the executable inside the application
bundle, for example
`"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"` or
`"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"`. Fedora installs
Chromium as `chromium-browser`.
REA needs the browser's HTTP discovery endpoint on a loopback port. A browser
driven over `--remote-debugging-pipe` or a private channel, for example by an
automation SDK, exposes no such endpoint, so `list_browser_targets` has nothing
to attach to. Connection failures report `endpoint_unreachable`; a reachable
HTTP service that returns a non-200 or non-JSON discovery response reports
`invalid_endpoint_response`.
Reproduce that flow in a Chromium-family browser started with
`--remote-debugging-port`.
In a minimal Linux container, headed Chromium under Xvfb can keep running while
its DevTools port never opens. Install the GLES, EGL, and Mesa DRI libraries
(on Fedora: `libglvnd-gles`, `mesa-libEGL`, and `mesa-dri-drivers`), then
restart the browser.
On Linux, a reachable but locked desktop keyring can stall Chrome before its
first navigation. For a dedicated investigation profile, add
`--password-store=basic` to bypass that keyring. Empty or placeholder frame URLs
@@ -207,7 +228,7 @@ commands fail closed. A correlated reply's session ID must match the command's
selected session. An optional method is unavailable only when the browser
rejects it as unknown; other command rejections retain their reported failure.
Network and console coverage starts only after REA attaches and enables the relevant CDP domains. `prior_activity_available` is always `false`; absence from these arrays is not evidence that an event never occurred. If CDP reuses a request ID without supplying redirect metadata, REA retains the predecessor observation and marks network coverage incomplete.
Network and console coverage starts only after REA attaches and enables the relevant CDP domains. `prior_activity_available` is always `false`; absence from these arrays is not evidence that an event never occurred. Scripts are different: scripts the page parsed before REA attached are still reported under `scripts`. A page that loaded before attach can therefore list its scripts while `network.requests` lacks the requests that fetched them. If CDP reuses a request ID without supplying redirect metadata, REA retains the predecessor observation and marks network coverage incomplete.
Source maps are not fetched by inspection. Bundle analysis fetches them only when `fetch_source_maps` is selected, with exact-origin redirect checks and no credentials, cookies, or referrer. Each fetch operation has a 30-second deadline and a 64 MiB aggregate streamed-response budget across redirects. A deadline or byte-limit failure remains explicitly unavailable and makes bundle completeness partial. Source-map decoding also bounds raw resolved identities to 64 MiB and aggregate inline evidence to 32 MiB. Failed maps preserve their failure context when it fits; if another context cannot fit, collection stops with the full requested count, the returned processed count, and an explicit unknown-results limitation. Retained mappings and original sources are returned inline. See [source-location limits](web-source-location.md) for the structural decoder bounds. Original source artifacts with `.ts`, `.tsx`, `.mts`, or `.cts` suffixes carry `text/typescript`; other source names retain `text/javascript`.
+3 -1
View File
@@ -174,7 +174,9 @@ rea analyze /absolute/path/to/program --provider ghidra --snapshot /absolute/pat
rea analyze /absolute/path/to/program --provider ghidra --snapshot /absolute/path/to/analysis/program.json
```
An exact CLI cache hit is read before starting a provider process.
An exact CLI cache hit is normally read before starting a provider process.
On Windows, Ghidra first admits the selected source through its native snapshot
owner, so replay can start that provider.
In MCP, `open_binary` accepts `snapshot_path` to import a snapshot atomically
for its matching target; an MCP provider may still start before returning a
cached result. `close_binary` accepts `snapshot_path` and optional
+82 -2
View File
@@ -33,6 +33,71 @@ schema constraints; REA still validates complete canonical inputs. Full-profile
`gpt-5.4` metadata is also verified locally. Live model API acceptance and
Windows remain unverified; see [native client verification](testing.md).
## Claude Desktop
Setup registers `mcpServers.rea` in `claude_desktop_config.json`: under
`~/Library/Application Support/Claude` on macOS, under
`$XDG_CONFIG_HOME/Claude` (default `~/.config/Claude`) on Linux, and on Windows
in the file the installed app reads:
- Without Claude Desktop's MSIX package storage, setup uses
`%APPDATA%\Claude\claude_desktop_config.json`.
- The MSIX build (package family `Claude_pzs8sxrjxfjjc`, installed under
`C:\Program Files\WindowsApps\Claude_<version>_x64__pzs8sxrjxfjjc`)
virtualizes AppData writes. Once it has saved its configuration, it reads
the package-private copy
`%LOCALAPPDATA%\Packages\Claude_pzs8sxrjxfjjc\LocalCache\Roaming\Claude\claude_desktop_config.json`
and ignores the `%APPDATA%` file. Setup, doctor and uninstall use that
private copy when it exists. Before the package has saved one, they use the
`%APPDATA%` file it still reads; with neither file, setup creates the
private copy.
- If `%LOCALAPPDATA%\Packages` holds a `Claude_<publisher ID>` package from
another publisher, or cannot be read, REA cannot establish which file Claude
Desktop reads. Setup, doctor and uninstall report that instead of falling
back to `%APPDATA%`. Doctor then reports `claude_desktop` as invalid, and
uninstall stops before removing anything for any client.
Quit Claude Desktop fully before approving setup; closing its window is not
enough. A running Claude Desktop can rewrite its configuration from memory and
drop the registration. This was observed with the Windows MSIX build. Start
Claude Desktop again after setup. If the package saves its private copy after
setup planned to write the `%APPDATA%` file, setup makes no change and asks you
to rerun it.
A process that the MSIX app started, such as a terminal in Claude Desktop's
Code tab, sees the package's private files at their `%APPDATA%` paths even
though it reports no package identity. Setup compares one of those files at
both paths. When it finds that redirected view, the app is running, so setup
and uninstall refuse to change Claude Desktop's configuration; rerun them from
a terminal that Claude Desktop did not start. If the package has no private
file to compare, setup and uninstall cannot rule that out and also refuse; open
Claude Desktop once if it has never run, quit it fully, then rerun. Doctor
reads the same file from either kind of process.
The same process also writes everything under `%APPDATA%` and
`%LOCALAPPDATA%` into the package's private storage, where other apps never
read it. From that redirected view, setup and uninstall refuse to change other
clients' files there (VS Code, Trae, Devin and Hermes) and skill directories
there. `npm install -g` run from such a process installs into the package's
`LocalCache\Roaming\npm`, which other processes cannot see at the `%APPDATA%`
path. Setup refuses to register a launcher stored in any MSIX package's
private storage. Install REA from an ordinary terminal and rerun setup there.
Uninstall removes REA's entry from both the private copy and the `%APPDATA%`
file, including registrations that earlier releases wrote to `%APPDATA%`.
REA recognizes only the MSIX package. A non-MSIX Claude Desktop installed
beside it, which would read the `%APPDATA%` file, is not detected.
The package identity, both file locations, the redirected view, and these
setup and doctor results were observed on Windows 11 with the MSIX build
2.31226.1.0, using read-only `doctor` and `setup --dry-run` runs both inside
and outside the app. On that host, setup run from an ordinary terminal with
the app quit wrote the package-private copy; after the app restarted, the
entry was still present, doctor reported it aligned, and Claude Desktop's MCP
log showed REA starting and answering `tools/list`. Uninstall against the real
app is unverified.
## Claude Code
Claude Code normally defers MCP tool definitions through tool search. It
@@ -165,6 +230,14 @@ For [Qoder](https://docs.qoder.com/cli/mcp-reference), setup writes
from `~/.qoder/skills` (following `QODER_CONFIG_DIR` when set), so setup
installs its skill copy there.
## Factory Droid
For [Factory Droid](https://docs.factory.com/harness/mcp), setup writes
`mcpServers.rea` into the user-level `~/.factory/mcp.json`. Entries carry
`command`, `args`, and `env`. Droid discovers personal skills from
`~/.factory/skills` ([skills](https://docs.factory.com/harness/skills)), so
setup installs its skill copy there.
## Trae
For [Trae](https://docs.trae.ai/ide/add-mcp-servers), setup writes
@@ -209,8 +282,15 @@ replaces) the REA entry in the home patch `$DSH_HOME/cordis.patch.yml`
patches and comments. `rea doctor --client dsh` checks that same entry id.
`rea uninstall` removes it by scanning supported clients; uninstall takes no
`--client` flag. The home patch applies to every Harness profile, so the
registration is written once and no per-profile edit is needed. The written
entry is:
registration is written once and no per-profile edit is needed.
Setup, doctor and uninstall resolve `DSH_HOME` and `DSH_AGENTS_HOME` as Harness
does: unset, empty or whitespace-only values use `~/.dsh` and `~/.agents`,
respectively. Bare `~`, `~/` and `~\` expand to the user home; other relative
values resolve against the command's working directory. Nonblank values retain
their original spaces, and the agents root is independent of the Harness home.
Run REA and Harness from the same working directory when using relative overrides.
The written entry is:
```yaml
- insert:
+14 -2
View File
@@ -18,14 +18,26 @@ pass `-- chat` to check plain chat with REA enabled. Qwen also reads the full
result that its client offloads, and Pi exercises default codemode execution.
Use `REA_VERIFY_RUNTIME_ROOT` to select a production-only installed REA package.
Receipts retain client versions, result digests, host coverage and owned-process
cleanup. These POSIX lanes use a deterministic loopback model, so they do not
prove live model-provider or native Windows compatibility. Qwen, Pi and Hermes
cleanup. These lanes use a deterministic loopback model and do not prove live
model-provider compatibility. Pi and Hermes remain POSIX-only. Qwen, Pi and Hermes
bind both `HOME` and `USERPROFILE` to the disposable account. Qwen and Pi discover
the shared personal skill through their default search paths without an explicit
skill setting; Hermes discovers it through the selected `HERMES_HOME`.
Set `REA_VERIFY_HERMES_STICKY_PROFILE=1` to exercise native Hermes selection of
a named sticky profile with `HERMES_HOME` still pointing to its root.
The Qwen lane also supports native Windows (verified with Qwen Code 0.25.1 and
Node 24.19.0 for both `chat` and `call`). With Node 24.18.0, the same `call`
journey reached full Evidence readback but the client aborted during exit with
`UV_HANDLE_CLOSING`; that run remains a failure, not a compatibility claim.
Select the npm install's
`node_modules/@qwen-code/qwen-code/cli-entry.js` with `REA_VERIFY_QWEN_COMMAND`,
because `execFile` cannot launch the `qwen.cmd` shim directly. A selected `.js`,
`.mjs` or `.cjs` entry point runs under the verifier's Node. Windows path checks
use native separators; persisted output is read through Qwen's real shell tool
with a shell-independent encoded Node payload. Windows receipts report process
lineage as unavailable when the verifier has no retained native Job Object lease.
`verify:gemini-client` requires an installed Gemini CLI (verified with
`@google/gemini-cli@0.63.0` on POSIX and native Windows); select it with
`REA_VERIFY_GEMINI_COMMAND`. A selected `.js`, `.mjs` or `.cjs` entry point runs
+24
View File
@@ -39,6 +39,18 @@ license](https://github.com/morluto/rea/blob/main/third_party/evmole/README.md)
- Upstream also returns terminal metadata during selector recovery. Its reported
values remain in `raw_result`; integer precision and non-text-key coverage are
unknown in the JS binding. Full bytecode retains the original encoded metadata.
- `bytecode.metadata` independently interprets a plausible terminal CBOR root
map from the selected bytes. Integers use decimal strings; byte strings use hex,
and scalar keys retain their types. The recorded offset/length addresses the
original bytecode, so summary delivery does not copy an opaque CBOR payload.
Nested containers have partial scalar coverage; unsupported or malformed
candidates report `not-decoded` with a diagnostic. Decoding is bounded to 64
container levels and the terminal payload's encoded size.
- An unavailable trailer does not prove metadata absence or Solidity origin.
Compiler-version bytes/text are metadata declarations, not verified compiler,
source-file or deployed-contract identity. Historical omission parses as a null
unknown facet without changing the original Evidence record. Metadata/source
URLs are not fetched.
- Selectors, argument strings and state mutability are **inferences**. Function
names/signatures are not looked up. Optimized code may produce different
mutability candidates; recovered types are not a verified source ABI.
@@ -125,3 +137,15 @@ The real verification lane traces representative raw/hex CLI workflows and check
that only declared Node/prlimit launchers and process ownership inspection run.
Socket traces permit local stdio socket pairs and reject attempted network requests.
These observations verify those fixtures; they are not a general network sandbox.
For the independent metadata parser, `npm run verify:solidity:metadata` uses an
existing caller-selected solc 0.8.30 module:
```sh
REA_VERIFY_SOLC_MODULE=/absolute/path/to/solc/index.js npm run verify:solidity:metadata
```
It compiles the owned fixture with unoptimized, viaIR, content-hash-free and
CBOR-free settings, verifies the observed trailer facts and reports local decode
timings. No contract is executed. This lane does not establish the complete
Linux EVM provider's CLI/MCP, resource-limit or cleanup behavior.
+31 -7
View File
@@ -22,6 +22,28 @@ lane. MIPS16 shared images have the separate bounded profile below; other shared
or compressed images remain unsupported. Their family may be identified, but provider selection returns the
specific unsupported constraint rather than guessing a compatible profile.
A second admission covers recordless legacy firmware images
([#1620](https://github.com/morluto/rea/issues/1620)): ELF32 `ET_EXEC` headers
declaring `EF_MIPS_ARCH_32` or `EF_MIPS_ARCH_32R2` with an unspecified ABI
field, `EF_MIPS_ABI2` clear, and an _inspected_ absence of any ABI flags
record (a target whose record was merely not observed stays refused). For
these images o32 is an analysis assumption derived from the ELF32/ET_EXEC
structure, recorded as `mips_abi_basis` in the profile and stated as a
limitation in Evidence, never a declared fact; floating-point ABI semantics
stay unverified. Ghidra's own header auto-selection picks a 64-bit language
for exactly these files, so the lane pins `-processor MIPS:<LE|BE>:32:default`
through a `provider-pinned-v1` profile selection and checks the
bridge-reported `language_id` after import. A caller-selected
`REA_GHIDRA_LANGUAGE_ID`/`REA_GHIDRA_COMPILER_SPEC_ID` still wins over the
pin; combining the pinned profile with an environment override is a typed
refusal. A header that declares an ABI field but carries no record is still
refused. Two further boundaries apply: an inspection that could not examine
every ABI declaration source (for example a carved ELF whose section table
extends past the captured bytes without a program-header record) does not
establish the recordless admission, and an `EF_MIPS_ARCH_32` header that
_declares_ an ABI is refused rather than relabeled, because the declared-ABI
lane is verified for `EF_MIPS_ARCH_32R2` headers only.
Explicit `EF_MIPS_FP64` and `EF_MIPS_NAN2008` declarations are refused. Their
absence is insufficient: Clang can emit identical `e_flags` for FP32, FP64 and
soft-float targets. Target resolution also reads the 24-byte ABI flags record
@@ -40,12 +62,14 @@ absent or supply a default ABI.
The field layout follows
[`Elf_MIPS_ABIFlags_v0`](https://github.com/bminor/glibc/blob/master/elf/elf.h).
Provider admission requires record version 0, MIPS32r2, 32-bit GPRs, no CPR2,
no ISA extension, ASE or unknown general flags, and one of two floating-point
declarations: 32-bit CPR1 with a double-precision or FPXX ABI, or no CPR1 with
the soft-float ABI. Other combinations, including single-float, FP64 and
unspecified ABIs, are refused. Missing records remain unknown rather than
defaulting to a compatible ABI, and this lane refuses them. Unknown record
Provider admission on the declared-ABI lane above requires record version 0,
MIPS32r2, 32-bit GPRs, no CPR2, no ISA extension, ASE or unknown general
flags, and one of two floating-point declarations: 32-bit CPR1 with a
double-precision or FPXX ABI, or no CPR1 with the soft-float ABI. Other
combinations, including single-float, FP64 and unspecified ABIs, are refused.
Missing records remain unknown rather than defaulting to a compatible ABI, and
the declared-ABI lane refuses them; the recordless legacy admission above is
the only path that admits a recordless image. Unknown record
values are retained for diagnosis but do not gain provider support. These checks
bind the declared interpretation; the fixtures do not establish full
floating-point behavioral conformance.
@@ -111,7 +135,7 @@ MIPS header and ABI record facts are committed in the analysis profile so
interpretation changes invalidate profile-bound snapshots. The support-lane
identity is versioned independently of file identity. Evidence, lifecycle
outputs and saved snapshots preserve the `mips` family. Hopper and IDA adapters
do not implicitly gain MIPS support. Windows Ghidra P0 remains PE x86/x86-64 only.
do not implicitly gain MIPS support. Windows Ghidra P0 does not admit MIPS targets.
### Soft-float prototype
+78 -3
View File
@@ -41,6 +41,80 @@ does not prove text is absent. Both list and search Evidence carry this
limitation. When an address is known, use `read_bytes` and `xrefs` to inspect
bytes and references.
## Loaded addresses and file offsets
`rea analyze <path> --provider ghidra --json` and MCP `binary_overview` report
the observed loaded `image_base` in `normalized_result`. They retain each
segment's reported `image_base` and `address_space`. A provider that does not
report a base, or whose segments disagree, omits the overview field, following
the existing contract on main. REA does not substitute zero or the first
segment's address. All reported bases and address spaces remain visible in the
complete segment inventory, including conflicting observations.
`function` and most MCP procedure selectors use procedure names or
provider analysis addresses. Ghidra `decompile` / MCP `procedure_pseudo_code`
also accept source-file byte offsets. Existing symbols and analysis addresses
take precedence; a `0x`-prefixed number that resolves to no procedure falls back
to its observed source mapping. Use `--file-offset=0x...` on the CLI or
`procedure: "file-offset:0x..."` through MCP to select file coordinates explicitly.
Multiple mapping candidates fail as ambiguous rather than selecting a bank or
address space. A successful conversion retains the file offset, mapped address
and procedure entry in raw Evidence and `locations`.
If an offset maps to loaded target bytes but no procedure starts at or contains
the mapped address, the failure preserves both the original file offset and
analysis address. This differs from an unmapped offset and does not recommend
retrying the same offset syntax.
If the function resolves but the backend fails or cancels decompilation, the
failure retains the selector, mapped address and procedure entry without
changing the backend failure code.
Both mapping directions require a complete original `FileBytes` source whose
SHA-256 matches the admitted target. Auxiliary files and partial source objects
remain visible in the load-image inventory but do not establish offsets in the
original target. Ghidra's object-local offsets are not silently treated as
original-file coordinates. Original-source identity is checked once per source
object per session; analysis-memory edits do not change the original bytes.
ELF virtual addresses and file offsets are
different coordinates. Ghidra can rebase a PIE ELF during import: in the
source-owned verification fixture, ELF entry `0x1013` becomes Ghidra procedure
`0x101013` with image base `0x100000`. That example is not a general conversion
rule.
Use `list_procedures` to select an observed procedure address. To relate file
bytes to loaded memory, call MCP `inspect_native_load_image` or:
```sh
rea search <path> . --kind procedures --mode regex --provider ghidra --json
rea inspect-native-load-image <path> --provider ghidra --json
rea decompile <path> <observed-procedure-address> --provider ghidra --json
rea decompile <path> --file-offset=0x1013 --provider ghidra --json
rea address-to-file-offset <path> <observed-procedure-address> --provider ghidra --json
```
The load-image result preserves source-file indexes, file offsets, address
spaces, and inclusive mapping ranges. Follow the mapping for the selected
source file and region; adding `image_base` to an arbitrary file offset is not
valid. For a linear byte-addressed, file-backed mapping, a file offset `F` in
`[mapping.file_offset, mapping.file_offset + mapping.length)` maps to
`mapping.start + (F - mapping.file_offset)` in the reported `address_space`.
If several mappings cover the same file offset, keep those candidates distinct
and select the procedure from the observed inventory. Uninitialized regions may
have no file backing. ELF mappings are observed
without independent format-specific load-image verification, as the result's
status and limitations state.
An unsuccessful numeric procedure lookup reports the current Ghidra image base
and gives the CLI commands above alongside the corresponding MCP operations.
Repeat any target-format, architecture, and entry-point options used to open the
target. Other procedure operations keep their analysis-address semantics;
file-offset fallback applies only to decompilation.
Overview workflow revision 3 binds this output to snapshot identity. Older
serialized overview Evidence remains readable; it is not replayed as a current
overview merely because its source artifact is unchanged.
## Admitted function-analysis semantics
| Concern | Ghidra contract |
@@ -57,9 +131,10 @@ bytes and references.
Function extents and instruction completeness refer to Ghidra's analyzed body.
An inferred no-return call can terminate that body before subsequent bytes.
Pseudocode, assembly, procedure info, instruction windows, and dossiers report
observed terminal call sites in Evidence limitations, including resolved callee
names, addresses, and FunctionManager flags when available. Instruction windows
Pseudocode, assembly, procedure info, instruction windows, callee and caller
reads, and dossiers report observed terminal call sites in Evidence limitations,
including resolved callee names, addresses, and FunctionManager flags when
available. Instruction windows
and dossiers also retain these warnings in their result limitations. An
unresolved callee or unestablished terminal-flow basis remains explicit. These
observations qualify the analysis; they do not prove that a no-return flag is
+2 -2
View File
@@ -11,8 +11,8 @@ ELF `EM_RISCV` (243) is retained as the provider-neutral `riscv` family. The
target also retains the raw ELF declarations that decide a lane: class, byte
order, `e_type`, and `e_flags`. Recognition does not mean every provider or
RISC-V variant is supported. Existing PE, managed and Mach-O architecture sets
are unchanged, and caller-supplied headerless raw-binary entry still accepts only
its existing four families.
are unchanged. Headerless inputs use the separate [raw-binary admission
boundary](raw-binary.md); raw RISC-V images are not supported.
The Ghidra lane admits little-endian ELF32 and ELF64 `ET_EXEC` images that
declare `EF_RISCV_FLOAT_ABI_DOUBLE` in `e_flags`, with no `EF_RISCV_RVE`,
+13
View File
@@ -4,6 +4,15 @@ REA adapts an existing IDA MCP server into its native analysis tools. Existing I
Choose one lifecycle mode in an upstream MCP JSON registration. Set `REA_IDA_MCP_CONFIG` to that file, then select `ida` once through `REA_ANALYSIS_PROVIDER`, the CLI's `--provider ida`, or MCP's `open_binary.provider_id`. No provider startup occurs during discovery.
## Supported upstream versions
Each mode accepts one upstream tool profile, checked against `tools/list` before any analysis runs. Upstream's `main` branch has since moved to 2.0.0, which renamed the GUI tools and dropped `idb_close`, so it serves neither profile. Installing from that branch fails at startup with the missing tool names in the error diagnostics.
| Mode | Upstream profile | Known working |
| -------- | ------------------- | ------------------------------------------------------ |
| attached | legacy GUI tools | 1.4.0 |
| headless | database supervisor | builds serving `idb_open`, `idb_list`, and `idb_close` |
## Analyze an already-open GUI target
The attached profile supports upstream **1.4.0 legacy tools**, including `get_metadata`, `list_functions`, and `decompile_function`. Copy the upstream registration's `command`, `args`, and optional `env` into a JSON file. An existing `mcpServers` object with an `ida-pro-mcp` entry is also accepted. For example:
@@ -88,6 +97,8 @@ REA copies the admitted binary into a private temporary directory, checks its di
One REA session owns one database. Stdio registrations default `IDA_MCP_MAX_WORKERS` to `1`; the explicit upstream `--max-workers 1` also bounds a supervisor. Database size and engine memory depend on the target and upstream; REA does not impose an engine memory limit. The original binary is never executed or overwritten. Optional `workspaceRoot` selects an existing absolute temporary-directory parent on the REA host. Ensure it has space for the input copy and IDA working files.
When a completed `idb_open` response reports success or is otherwise ambiguous, but no active owned private worker can be verified, REA retains the private workspace even if `idb_list` appears empty. A later close can retry verification if the original owned worker becomes discoverable. A completed explicit `idb_open` rejection retains the existing no-worker cleanup path. This is a local admission/ownership safeguard and **does not** provide a process-termination receipt or change the legacy reaped-worker protocol.
Headless cleanup uses the upstream database lifecycle. Abruptly terminating REA, its proxy, or the supervisor does not establish worker release. Prefer `close_binary` for MCP sessions; after an interrupted process, inspect upstream session inventory and retained workspace diagnostics before removing working files. This adapter does not claim Windows Job Object containment for the upstream detached workers.
## Connect an agent once
@@ -126,6 +137,8 @@ For guided registration, set `REA_IDA_MCP_CONFIG` before `rea setup --client <cl
All profiles support procedure/string inventory and literal or regex search, procedure resolution, pseudocode, assembly, raw function instructions, resolved callees, incoming address references, and a function dossier. Inventories and modern pseudocode/assembly follow upstream pagination. Upstream limits on modern xrefs and callees are retained as explicit truncation limitations. In the modern headless profile, targets that are not proven x86-only use processor-neutral `callgraph` edges instead of the x86 instruction checks in upstream `callees` (mrexodia/ida-pro-mcp#549). REA requests depth zero: the pinned upstream emits the root's outgoing edges without scanning child functions, then REA batches function-name lookup for those addresses. The graph covers recognized function destinations only; imports and other non-function targets remain unknown. A server without `callgraph` returns an empty relationship list with an explicit coverage limitation. Graph and path workflows retain those limitations alongside observed edges. A failed or inconsistent graph reply remains an error. A transport-level truncated preview is rejected rather than reported as complete evidence; the error preserves upstream retrieval guidance.
The modern headless profile also supports `resolve_containing_procedure` and `list_documents`. An address resolves through upstream `lookup_funcs` to the canonical entry of the function that contains it; the body stays unavailable like other IDA identities. When no function contains it, REA reads the database-scoped minimal `survey_binary` and reports `not_in_procedure` for an address inside one of its segments (ends are exclusive) and `outside_segments` otherwise. A missing, truncated or inconsistent survey fails the read instead of guessing a reason. `list_documents` returns the single admitted program path, so `inspect_address_context` and `trace_feature` need no explicit document. `trace_feature` marks a trace truncated, with a residual unknown for that address, when upstream caps the references it returned. The legacy and native profiles do not declare these operations.
The legacy profile additionally supports direct callers. Its producer may report call-site or interior addresses; REA resolves the reported function symbol to its canonical entry. The modern and native profiles do not prove direct callers from its code/data xref labels, so that capability is unavailable and the dossier marks it unknown.
Complete body ranges, typed reference edges, CFG, referenced data, source locals, comment kinds, and database revision are unavailable. Empty unsupported dossier facets mean **unknown**, not observed absence. `binary_overview`, whole-symbol inventory, mutation, debugging, arbitrary Python forwarding, and requested GUI navigation are outside this adapter's coverage. Check session availability before composing a workflow that needs those facets.
+8 -2
View File
@@ -20,7 +20,9 @@ npx rea-agents setup
Choose the agents to configure, review the proposed paths and changes, and
approve the plan. npm's package-download approval and REA's setup approval are
separate. Setup asks before changing agent configuration or installing Hopper.
Restart the agents named in the completion message.
Restart the agents named in the completion message. Quit Claude Desktop fully
before approving, because a running app can overwrite the registration; on
Windows, see [where Claude Desktop reads its configuration](client-configuration.md#claude-desktop).
For the latest published release, use `npx rea-agents@latest setup`.
[Package-runner and global-installation options](#package-runners-and-global-installation)
@@ -283,6 +285,7 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s
| Pi | `pi` |
| Hermes | `hermes` |
| Qoder | `qoder` |
| Factory Droid | `droid` |
| Trae | `trae` |
| ZCode | `zcode` |
| DeepSeek Harness | `dsh` |
@@ -512,7 +515,10 @@ and `set_addresses_names` name a function entry or any other mapped address
session metadata edits leave executable bytes unchanged and are discarded on close. GUI controls
require Hopper; Windows P0 remains read-only.
Windows P0 admits native x86 and x86-64 PE applications and DLLs on fixed local NTFS volumes.
Windows P0 admits native x86 and x86-64 PE applications and DLLs, plus explicitly
selected [raw NMOS 6502 images](raw-binary.md#nmos-6502) with a caller-supplied
entry at file offset 0, on fixed local NTFS volumes. Other raw CPU families remain
unsupported on Windows.
The npm package bundles native Job Object ownership, protected private runtime
DACLs, and handle-based path admission; no separate addon installation is needed.
See the [Windows Ghidra P0 guide](windows-ghidra-p0.md) for verified scope and
+19
View File
@@ -208,6 +208,25 @@ arrays, and later explicit data properties and methods replace earlier object
spread references. A known array prefix does not expose values from a following
spread. Accessors, custom or uncertain iteration, unknown computed keys, and
unknown spread lengths retain conservative uncertainty.
Getter-local aliases of `this`, including direct destructuring and assignments,
retain their receiver when a returned shared reference is written. These writes
invalidate the earlier literal instead of claiming the getter executed or the
new value is known. Straight-line alias reads retain their capture order;
uncertain control flow remains conservative. A binding outside the getter or
`this` in a nested ordinary function is not attributed to the getter's receiver.
Private reads keep receiver projections separate from the private value's path.
Receiver projections follow known data fields initialized from `this` or earlier
self-referencing fields; static fields retain their declaring class identity.
An alias cycle that repeatedly selects deeper children retains escape uncertainty
at its recovered roots rather than claiming a fixed number of loop iterations.
Writes through an object getter's returned receiver retain that uncertainty
when the object is an array element, nested inside mixed object/array
containers, or assigned to a previously declared identifier. Saving the getter
result in an alias does not make its old field values immutable. Known array
indices include holes; an element after a spread has only a minimum possible
index. Unrelated properties and known array prefixes remain precise, and a
getter read without a write does not invalidate the receiver's fields.
Nested callable returns are not assigned to their parent callable. Projected
graph observations carry source ranges but never source text.
+22
View File
@@ -147,6 +147,28 @@ opposite observation's selection unknown, and application graphs report their
selected scope as partial. Neither makes an absence claim about the whole
assembly. Older member Evidence without `selection` remains readable.
### Edge target declaring types
`member_refs[].parent_name`, `call_edges[].target_declaring_type`, and
`field_accesses[].field_declaring_type` name the type that declares each
target. Referenced spec signatures validate encoded type coordinates against
the metadata tables, including nested type arguments and custom modifiers.
TypeRef resolution scopes are checked transitively with cycle detection;
readable names survive malformed scopes as partial evidence.
TypeDef and TypeRef names use the namespace-qualified `full_name`
form, and nested types keep bare names. A generic-instantiation TypeSpec
parent names its generic type, such as ``System.Collections.Generic.List`1``.
Other well-formed TypeSpec forms and ModuleRef or MethodDef parents stay
`null`. A `method-spec` call edge reports the name and declaring type of the
generic method it instantiates. A field access is named only by a field or a
field-signature MemberRef. TypeSpec and MethodSpec blobs are decoded with the
member-signature grammar, and a MethodSpec must instantiate a generic method
with as many type arguments as it declares. Malformed or unsupported rows and
blobs, and arity mismatches, make coverage partial. A readable MethodSpec
method column that names a method, or a TypeRef whose resolution scope is
invalid, keeps its name.
Older member Evidence without these fields reads them as `null`: unknown.
## Analysis objective
Start by identifying the artifact with `inspect_managed_artifact`, then inspect
+5 -2
View File
@@ -476,8 +476,11 @@ the tool fails with a `debugger-attach-denied` reason.
Install a Ghidra 12.1.x release and the 64-bit full JDK it declares, then configure REA to
use them. Ghidra analysis supports Linux x64/arm64 and macOS x64/arm64; each installation requires
the matching native decompiler. Experimental Windows x64 P0 admits native
x86 and x86-64 PE applications and DLLs on local NTFS using bundled Job Object ownership,
protected runtime DACLs, and handle-based path admission. See [Windows Ghidra P0](windows-ghidra-p0.md) and
x86 and x86-64 PE applications and DLLs, plus explicitly selected
[raw NMOS 6502 images](raw-binary.md#nmos-6502) with a caller-supplied load address,
on fixed local NTFS volumes. Other raw CPU families remain unsupported on Windows.
The boundary uses bundled Job Object ownership, protected runtime DACLs, and
handle-based path admission. See [Windows Ghidra P0](windows-ghidra-p0.md) and
[issue #527](https://github.com/morluto/rea/issues/527).
On Linux and macOS, `annotate_native_function` atomically edits a function name
and entry comments in the ephemeral database, returning refreshed analysis
+53 -10
View File
@@ -25,10 +25,12 @@ rea inspect-native-load-image /absolute/path/to/ecu.bin --target-format raw-bina
}
```
`architecture` is one of `x86`, `x86_64`, `arm`, or `arm64`. `entry_point` is a
`architecture` is one of `x86`, `x86_64`, `arm`, `arm64`, or `6502`. `entry_point` is a
hexadecimal address of the form `0x…`. The complete file is mapped starting at
that address, and Ghidra seeds an `entry` function there before auto-analysis.
ARM and AArch64 entries must be 4-byte aligned; x86 entries may be byte-aligned.
ARM and AArch64 entries must be 4-byte aligned; x86 and 6502 entries may be byte-aligned.
For 6502, the entry and complete mapped file must fit within `0x0000..0xffff`;
images that cross the 16-bit boundary are rejected before provider startup.
`open_binary` and an open `binary_session` return the canonical `entry_point`
for raw targets so callers can verify the selected mapping directly.
Omitted `format` keeps header-based detection; architecture and entry without
@@ -37,12 +39,13 @@ Omitted `format` keeps header-based detection; architecture and entry without
Ghidra imports a private snapshot with `BinaryLoader` and these little-endian
generic languages and compiler specifications:
| architecture | language_id | compiler_spec_id |
| ------------ | ------------------- | ---------------- |
| x86 | `x86:LE:32:default` | `gcc` |
| x86_64 | `x86:LE:64:default` | `gcc` |
| arm | `ARM:LE:32:v7` | `default` |
| arm64 | `AARCH64:LE:64:v8A` | `default` |
| architecture | language_id | compiler_spec_id |
| ------------ | -------------------- | ---------------- |
| x86 | `x86:LE:32:default` | `gcc` |
| x86_64 | `x86:LE:64:default` | `gcc` |
| arm | `ARM:LE:32:v7` | `default` |
| arm64 | `AARCH64:LE:64:v8A` | `default` |
| 6502 | `6502:LE:16:default` | `default` |
Compiler specifications select analysis conventions; they do not identify the
original compiler or ABI of a headerless target. Ghidra's x86 languages do not
@@ -57,11 +60,51 @@ The mapped start is independent of Ghidra's program image base, which remains
`0x0` for BinaryLoader. Ghidra may report additional language-defined entry
points, including unmapped ARM vectors. Load-image verification requires the
caller-supplied entry and preserves those other entries as observations.
For 6502, BinaryLoader maps the file before adding its language-defined zero-page
and stack blocks. Conflicting default blocks are skipped; low-address files stay
in the default address space rather than an overlay. Non-conflicting uninitialized
blocks remain observations, not file-backed bytes.
Independent raw verification streams the complete private snapshot digest and
extent with bounded memory and supports cancellation during the read.
It also requires the source-backed block to be initialized, loaded, and
non-overlay in the bound language's default address space. Contradictory mapping
observations are retained with `mismatch`, even when the source hashes agree.
Hopper and IDA refuse raw-binary targets. Windows Ghidra P0 continues to admit
native PE applications and DLLs only; raw-binary targets are unsupported.
Hopper and IDA refuse raw-binary targets. Windows Ghidra P0 admits native PE
applications/DLLs and explicitly selected raw 6502 images. Other raw CPU families
remain outside the Windows boundary. Native path admission, private DACLs, Job
Object ownership, immutable snapshots, and the no-mutation policy still apply.
## NMOS 6502
```bash
rea function /absolute/path/to/image.bin 0x8001 --target-format raw-binary --architecture 6502 --entry-point 0x8001 --provider ghidra --json
rea inspect-native-load-image /absolute/path/to/image.bin --target-format raw-binary --architecture 6502 --entry-point 0x8001 --provider ghidra --json
```
Select only headerless NMOS 6502 code with a known entry at file offset 0.
The built-in Ghidra processor supplies static instructions, functions,
pseudocode, and references. Evidence preserves `architecture: "6502"`, the
selected language, entry, file digest, and verified source mapping. Register and
calling conventions are analysis conventions, not a recovered source ABI.
This lane does not interpret iNES headers, cartridge mappers, bank switching,
or other console-specific memory layouts. 65C02 and 65816 programs need different
processor semantics and are not admitted as those variants. REA does not infer
the CPU from the file suffix, guess the reset vector, or emulate the target.
`npm run verify:ghidra:6502` assembles and links the source-owned fixture with
`ca65` and `ld65` (tested from cc65 tag `V2.19`, whose tools report `V2.18`).
Set `REA_CA65` and `REA_LD65` to explicit executable paths if needed. The lane
checks real CLI/MCP load-image parity, mapped bytes, decoded calls, decompilation,
invalid mappings, unchanged source bytes and runtime cleanup.
Low-memory checks include a file spanning the zero-page/stack boundary at `0xff`
and a complete 64-KiB image at `0x0`, with verified default-space mappings through
both CLI and MCP. On Windows, the lane also checks that a caller-selected
junction path is rejected at snapshot admission through CLI and MCP, with
unchanged source bytes and complete runtime cleanup. Windows requires the
matching native addon; the POSIX provider path has not been exercised by the
Windows run.
## Limitations
+7 -3
View File
@@ -72,9 +72,13 @@ debugger context is `observed`.
Initial Linux x64 limits: 32 MiB input, 64 MiB decoder reply, 1 MiB diagnostics per
stage, 30 seconds owned wall deadline per stage, 3 GiB address-space soft ceiling
and 30 seconds CPU soft ceiling. Tighter inherited soft/hard limits are retained;
hard limits are not raised. Complete oversized results fail explicitly. These
process limits do not constitute a sandbox for caller-supplied engine code.
and 30 seconds CPU soft ceiling. `REA_PWNTOOLS_MAX_INPUT_BYTES` raises the input
bound alone, up to 1 GiB, and returns `provider_unavailable` when it is not a
decimal integer in range; the reply, diagnostic and deadline bounds stay fixed, so a
large core that outruns them still fails explicitly instead of partially. Tighter
inherited soft/hard limits are retained; hard limits are not raised. Complete
oversized results fail explicitly. These process limits do not constitute a sandbox
for caller-supplied engine code.
Resource exit statuses need matching private failure-branch markers. Unmarked
launcher failures retain their actual status/output and unknown resource cause.
+33
View File
@@ -0,0 +1,33 @@
# Implementation plan
1. Use cborg 6.1.3 (Apache-2.0) as a pinned format decoder, with a bounded
tokenizer at its owning adapter boundary. Decode maps as Map, preserve integer
precision and strictly validate emitted text bytes before conversion.
2. Add the portable metadata-result schema in src/domain/evm/. Use exact byte
ranges into the already retained bytecode rather than copying an opaque CBOR
hex payload into every selected summary.
3. Add src/evm/SolidityMetadata.ts to interpret the terminal length/root-map
candidate. Return unavailable, decoded, partial or not-decoded facts; keep
unsupported nested values explicit instead of recursively expanding them.
4. Compute the facts in EvmoleInterfaceWorker from its selected bytes. The host
provider validates ranges/identity and projects the additive metadata field.
Preserve raw engine output without rewriting its unknown-precision metadata.
5. Ensure the canonical EVM bytecode shape carries the optional field into complete
and summary Evidence. Include decoding profile, coverage and limitations.
6. Add producer, worker, historical contract and public MCP boundary coverage.
Compile an owned Solidity fixture with a pinned caller-supplied solc module,
and verify its CBOR compiler version. Run relevant static/architecture checks.
7. Extend the existing real Linux CLI/MCP verifier with independent metadata
assertions. Run the compiler-setting matrix in its CI lane and classify its
verifier as EVM-owned, with a production CI-planner regression.
No new execution provider or target authority is introduced: a format parser is
added inside the existing EVM worker. Linux x64 provider admission, target-free
semantics, cleanup, limits and complete original Evidence remain unchanged.
No metadata JSON or source URL is automatically acquired.
The parser bounds nesting to 64 and token/collection growth to the selected
terminal payload's byte count (the format's terminal length is at most 65535).
Every data item consumes encoded bytes; broad declared collection lengths cannot
justify allocation without that lower bound. Expanded results still pass the
worker's existing 16 MiB output check.
+43
View File
@@ -0,0 +1,43 @@
# Solidity terminal metadata inspection
Issue: https://github.com/morluto/rea/issues/2388
## Outcome
The existing EVM interface operation should retain independently decoded terminal
CBOR candidate facts from the exact selected bytes instead of relying on precision
unknowns in the engine's reported metadata. This increment covers trailer ranges,
bounded decoding, exact integer/non-text scalar key evidence and compiler-version
representations.
## Requirements
- Preserve the existing carrier/decoded identities and original full bytecode.
- Report a plausible terminal root-map candidate separately from proof of Solidity
origin. Missing or non-map trailers remain unavailable, not proven absent.
- Interpret scalar root-map keys/values losslessly: integers as decimal strings,
bytes as hex, strings, booleans and null. Preserve undecoded/unsupported values
through the original bytecode plus exact candidate offset/length.
- Report partial and not-decoded coverage, parser/profile failures and unsupported
CBOR forms explicitly. Do not silently coerce unknown keys into object properties.
- Interpret admitted solc release/prerelease representations only from observed
entries; preserve ambiguity if duplicate compiler-version keys occur.
- Keep the additive field compatible with historical Evidence and retain
it through selected summary delivery and public CLI/MCP.
Historical omission parses as a null unknown facet without mutating the source.
- Bound nesting before decoder recursion and collection lengths before allocation.
Keep parsing inside the existing owned worker and its resource/output budgets.
- Do not fetch metadata or source content, contact RPC or execute contract code.
## Acceptance and limits
Owned compiler fixtures and malformed/synthetic trailers cover integers beyond
Number precision, non-text keys, nested/uninterpreted values, invalid UTF-8,
invalid lengths, unsupported tags and nesting bounds. Public boundary tests
preserve the field and older omission. A real pinned solc artifact verifies actual
metadata facts; the existing Linux-only EVM host profile remains unchanged.
## Deferred issue criteria
Binding a supplied metadata JSON to IPFS/Swarm commitments is a later independent
increment of #2388. This PR must reference rather than close the whole issue.
@@ -0,0 +1,8 @@
# Tasks
- [x] Define single-feature requirements and implementation plan before code.
- [x] Add the pinned decoder and bounded owner boundary.
- [x] Implement lossless scalar metadata facts and compiler-version evidence.
- [x] Integrate worker/provider contracts and summary/historical compatibility.
- [x] Verify producer, worker and public boundary behavior with owned fixtures.
- [x] Run relevant repository checks and prepare a separately scoped PR.
@@ -0,0 +1,43 @@
# Verification
After integrating upstream c462a4ebd, 47 tests passed
across the parser, domain contract, application service, compiled worker and MCP
boundary (five files). Compiled-worker goldens include BOM text, partial nested
values, unavailable trailers, malformed UTF-8 and unsupported floats. Five
upstream adapter tests also passed after its stricter integer validation landed.
All six static tasks and architecture guards
passed. After integration, test discovery found all 1004 files exactly once.
The CI-planner selection passed all 85 tests, including the new EVM-verifier case.
The parser regressions cover exact large integers and typed keys, malformed UTF-8,
duplicate keys, excessive nesting, unsupported tags/floats, length bounds and
historical omission. A real defect in the decoder's default text handling removed
an initial U+FEFF. The owning tokenizer now validates and decodes the retained
text bytes with BOM preservation; independent fixtures check both values and
compiler-key identity.
The owned fixture was compiled with caller-selected solc 0.8.30, using unoptimized,
optimized via-IR, content-hash-free and CBOR-free settings. All four metadata
checks passed again after integration. No contract was executed. Local first decode
took approximately 0.81 ms or less. The originally reported approximately 2.92 ms
for 100 warm iterations included equality assertions and is not a decode-only
measurement. The verifier now measures parsing separately from those assertions;
updated warm timings have not been collected. These historical figures describe
these fixtures and this host, not a general workload.
Peak process RSS
included the compiler (299536 KiB); complete process-family CPU/memory and agent
token costs were not measured.
Compiled worker tests exercise unchanged EVMole WASM on this macOS host. MCP tests
use the production server and contracts with an injected provider seam. The full
Linux-only provider CLI/MCP, resource-limit and cleanup lane remains unverified
here. Metadata JSON commitment binding remains deferred under issue #2388.
The Linux CLI/MCP verifier asserts exact compiler bytes, declared version and
candidate ranges for its existing owned raw/hex artifacts. Its CI lane additionally
runs the new compiler-setting matrix. A regression using the actual CI planner
checks that edits to the new verifier select the EVM lane.
The later merge/review correction adds decoded-summary and omitted historical
metadata coverage. Tests and provider reruns for that correction were skipped at
the maintainer's request; the earlier runs above do not validate the changed head.
+181 -20
View File
@@ -263,18 +263,62 @@ CI owns coverage; aggregate and domain/contract thresholds are maintained in
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 JUnit and JSON timing reports, plus aggregate
coverage during full qualification. It retains available
report; the merge job produces JUnit and JSON diagnostics, a module timing
report, plus aggregate coverage during full qualification. It retains available
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 in full qualification.
Only successful complete full-tier 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
instead of the coverage/JUnit bundle. Timing weights come from Vitest module
diagnostics and include tests and hooks, worker preparation, environment,
imports, and setup. Import timings already include transform waits, which are
not added again. The weights describe file costs under the measured worker and
coverage settings; their sum is not workflow elapsed time. The diagnostic
artifact retains the phase breakdown, while shard history keeps only each
file's total. Legacy test-body-only history and missing history fall 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.
for another. Workflow syntax and expression validation runs inside the required
static-check job when workflow changes select it, avoiding a separate runner.
Native, provider, fixture, SQLite compatibility, and website lanes wait for
static checks to succeed before allocating their runners. This avoids spending
those runner slots on revisions that already fail static validation. Portable
test shards and documentation checks remain independent of static results, so
they can still provide diagnostic feedback. The required gate rejects skipped
selected lanes when static checks fail; passing revisions retain all selected
coverage. This ordering reduces peak demand on invalid revisions but can add
static-check waiting to otherwise passing runs; it is not a measured reduction
in end-to-end latency.
Core jobs have execution limits of five minutes for classification, ten minutes
for static checks, builds, docs, and report merging, and thirty minutes per
portable test shard. These limits leave headroom for cold setup and full coverage
while preventing a hung job from holding the active main run for hours. They do
not bound runner queue waiting. Timeouts still fail the required gate.
The separate `CI timings` workflow batches recent completed CI and Release runs
hourly or on manual dispatch. It uploads `ci-job-timings-batch` with raw API pages and
a normalized point-in-time report for each collected workflow attempt, plus a
batch manifest describing examined coverage, failures, and limits. Overlapping
lookback windows may collect an attempt again; identify records by run ID and
attempt. Each batch examines at most 100 completed runs per workflow created in
the preceding 24 hours, with a four-minute API collection budget. It collects the
latest attempt of each selected run and does not claim an exhaustive archive.
It runs the reporter from the trusted default branch with read-only permissions,
treating workflow runs as API data. It does not allocate a new reporter job for
every completed, cancelled, or skipped workflow. Reports separate queue
waiting (`created_at` to `started_at`) from completed execution and report
percentiles plus matched `npm ci` and artifact-step durations.
Queue excludes dependency waiting before job creation and does not establish
whether account concurrency or runner availability caused the wait. Queued jobs with
placeholder start times, skipped jobs, and missing or invalid timestamps remain
unknown. The reporter targets each selected run and attempt explicitly.
Summed job elapsed time is parallel work, not workflow wall time, CPU time,
memory usage, or billing. Step names identify samples, not complete transfer
costs. Its queue, execution, and failures are independent of the required gate
and publication. CPU time and peak process-family memory are not measured by this API.
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
@@ -638,7 +682,9 @@ use `--second-procedure rea_fixture_subtract --second-marker='- 13'`.
The lane invokes the production CLI dispatcher and connects the pinned MCP
client SDK to the production REA server. It verifies function Evidence and
CLI/MCP parity, inventory/search, pseudocode, instructions, xrefs, malformed
input, original-input preservation, and lifecycle cleanup. For headless
input, original-input preservation, and lifecycle cleanup. The headless
profile also checks containing procedures, the admitted program as the
document, address context, and feature tracing. For headless
analysis it confirms that the owned database IDs disappear from upstream
discovery and private workspaces are removed. For attached analysis it confirms
the existing GUI target remains reachable with the same input identity.
@@ -688,6 +734,16 @@ truncation constraint, while leading-zero coordinates still resolve correctly.
It has the same Ghidra/JDK prerequisites as the MZ lane. Neither lane claims DOS
runtime or PC-98 device execution.
`npm run verify:ghidra:6502` uses `ca65` and `ld65` to build the source-owned
NMOS 6502 fixture from `tests/conformance/ghidra/6502.s` and `6502.cfg`. Set
`REA_CA65` and `REA_LD65` for non-PATH installations (tested with cc65 `V2.19`).
With an installed Ghidra 12.1.4 and full JDK, it verifies real CLI/stdio MCP
mapping parity, exact source bytes, functions, a direct call reference,
decompilation, 16-bit overflow rejection without replacing the active session,
and runtime cleanup. Windows additionally requires the matching native addon;
its verifier lineage remains explicitly unavailable outside the provider's
retained Job Object, as in the other Windows lanes.
`npm run verify:ghidra:raw` uses a generated ARM little-endian headerless image
through the compiled CLI and stdio MCP server. With the same Ghidra/JDK
prerequisites, it checks preparation and bridge startup, verified load mappings,
@@ -698,6 +754,44 @@ specifications, independent mappings, unchanged bytes, and decompilation of a
known return value. AArch64 remains covered by deterministic tests rather than
this real-provider lane. See [raw binary analysis](raw-binary.md).
`npm run verify:ghidra:address-context` runs on Linux with a caller-installed
Ghidra, full JDK, and an x86-64 ELF GCC toolchain (`CC` selects another compatible
compiler). It builds
`tests/conformance/c/pie-address-context.c` as a PIE ELF without executing it.
The compiled CLI and real stdio MCP workflow must expose the measured image
base, retain segment address spaces, agree on the overview, resolve explicit and
numeric file offsets, and reject unmapped analysis addresses with actionable
guidance. It checks the reverse `address-to-file-offset` mapping, file-coordinate
Evidence locations, and the suggested CLI search and load-image commands.
The verifier compares mapped bytes with the source file and checks source
identity, session preservation after failed lookup, owned runtime cleanup, and
verified empty process lineage. This lane does not establish Windows or macOS
real-provider coverage.
For source-identity boundaries that a normal single-file import cannot create,
`tests/conformance/ghidra/ReaFileOffsetSourceProbe.java` exercises the production
bridge against real Ghidra `FileBytes`. It rejects auxiliary and partial sources,
checks the original target's round trip, and retains ambiguity when duplicate
target bytes have distinct loaded mappings. Injected decompiler failures and
cancellation verify that completed source/function resolution facts and backend
failure codes survive; these injections do not establish natural engine failures.
On Windows, after creating the
source-owned PE fixture, run from the repository root:
```powershell
node scripts/create-ghidra-windows-fixture.mjs
$proofRoot = Join-Path $env:TEMP 'rea-file-offset-source-proof'
New-Item -ItemType Directory -Path $proofRoot -Force | Out-Null
Copy-Item bridge/ghidra/ReaGhidraBridge.java, bridge/ghidra/ReaGhidraExtensions.java, tests/conformance/ghidra/ReaFileOffsetSourceProbe.java -Destination $proofRoot
& "$env:GHIDRA_INSTALL_DIR/support/analyzeHeadless.bat" $proofRoot source-proof -import "$PWD/build/fixtures/rea-ghidra-windows.exe" -scriptPath $proofRoot -postScript ReaFileOffsetSourceProbe.java -deleteProject
```
Require the `REA_FILE_OFFSET_SOURCE_OK` marker and no script errors; Ghidra may
exit successfully even when a post-script fails. The probe edits only its
temporary analysis database and does not execute the fixture. This producer
regression supplements public CLI/MCP coverage and does not establish Linux
host behavior or native process-supervision controls.
## Apple Interface Builder archives
`npm run verify:interface-builder` compiles the source-owned AppKit XIB into a
@@ -723,6 +817,27 @@ remain present and fail if classification fails. Every code change retains the
complete six-shard Linux suite; individual test files are not selected by
imports or filenames.
Ordinary PRs also produce an **observational** test-selection plan. The candidate
leaf-adapter ownership table lives in `scripts/ci/test-selection.mjs`, separately
from provider-job scopes. It proposes keeping affected owners plus every unowned
test, including all CLI/MCP, acceptance, composition, conformance, and process
tests. Shared inputs, unknown paths, missing owner tests, and removed tests fall
back to the complete inventory. Renames account for both paths. Main, scheduled,
manual, and `ci:full` runs always propose the full suite.
All six shards still execute the complete suite. The existing report merge
compares the proposal with actual module outcomes and retains
`.cache/ci-test-selection/report.json` in `test-results`, including selected and
omitted paths, changed input paths, missed failures, unobserved files, scheduling weights, PR head and
executed merge SHA. This adds no test reruns or runner jobs. Missing, duplicate,
skipped, interrupted, or unhandled results cannot establish a complete comparison;
even a complete passing comparison is not proof of ownership completeness.
Before enabling omissions, review representative full-run comparisons and
cross-owner consumers, including failures, and measure completed CI feedback and
runner demand. There is no automatic promotion or switch to skip tests. The
initial proposal measures only leaf producer-test omissions; it does not promise
the savings of a broader affected-test planner.
`scripts/ci/plan.mjs` selects lanes using the ownership table in
`scripts/ci/scopes.mjs`. Provider implementation changes run their real-provider
verifiers and the Linux x64 installed-package/Inspector baseline. The `apple`
@@ -737,9 +852,19 @@ lane. Edits to verifier entrypoints run only by manual, licensed or self-hosted
workflows run static checks and the Linux suite. Website changes run website
checks. Provider workflow changes validate the workflow and run that provider's
verifier. Manual-provider/Android workflow changes run workflow validation only.
The four-version SQLite compatibility matrix has its own `sqlite` scope for
SQLite implementation, contracts, CLI/MCP registration, fixtures, and shared
runtime boundaries. SQLite and server-lifecycle test edits and the SQLite npm
scripts also select it. Unrelated code edits retain the complete portable suite
on the pinned Node version without starting four additional compatibility jobs.
Full qualification retains every SQLite version.
Shared process/contract/CLI boundaries, dependencies, other packaging inputs,
unknown paths, and changes to the planner select the full baseline. Package
script-only edits select their owning verifier when its entrypoint is
unknown paths, and changes to the planner select the full baseline.
CI timing collection, timing history, and test-report helper edits retain static
checks and the complete portable suite without selecting unrelated host/provider
lanes. Classification, qualification, gate, release receipt, and unknown CI
scripts keep the broad fallback.
Package script-only edits select their owning verifier when its entrypoint is
identifiable; unknown or shared script changes select everything. Android
build-script edits retain the Linux suite and workflow validation; real Android
verification stays manual. Renames account for both paths.
@@ -760,23 +885,46 @@ Every code change still runs all six portable shards, without coverage
instrumentation in this fast tier. This deliberately avoids relying on an
import graph for generated files, filesystem inputs, subprocesses, or bridges.
Full qualification runs hourly at minute 23, on manual dispatch, and for the
exact release revision before publication. It includes aggregate coverage,
every portable provider lane, and all native hosts below. A `ci:full` PR label
selects full qualification on the next PR run; manual dispatch can request it
immediately. Fast-tier broad fallbacks retain all owner lanes and the three-host
Scheduled qualification is admitted hourly at minute 23. A forced full run at
03:23 UTC refreshes runner and dependency drift every day. Other hourly runs
can reuse a successful full-qualification receipt for the exact source SHA
from the same repository's trusted CI workflow on its default branch. Both the
receipt and original workflow run must be less than 24 hours old, so retrying an
old gate cannot renew stale proof. Missing, expired, failed, foreign, or uncertain proof runs full
validation; API lookup failure also runs full. Lookups examine at most 100 named
receipts and three candidate runs within a 20-second budget. Reused runs select
no validation lanes, identify the original run, and produce no new receipt, so
reuse cannot indefinitely extend proof freshness. They make no fresh provider
observation. Manual dispatch, `ci:full`, and exact release qualification always
run full validation.
Full qualification includes aggregate coverage,
every portable provider lane, and all native hosts below. Adding `ci:full`
immediately starts the separate `Full PR qualification` workflow against the
label event's merge revision; subsequent PR revisions also select the full tier
in normal CI while that label remains. Unrelated labels do not start validation
jobs or replace the main CI checks. Manual dispatch also requests full validation.
Fast-tier broad fallbacks retain all owner lanes and the three-host
package matrix; Intel macOS and coverage remain full-tier obligations. Each
classification job records its selection and broad-fallback reasons in the run
summary. Representative Git-diff cases live in
`tests/boundary/filesystem/ciChangeScope.test.ts`.
`CI required` always runs and checks classification and every selected lane,
rejecting failed, cancelled, or unexpectedly skipped work. Branch protection
should require this aggregate, `Static checks`, and `Portable tests`. Fast runs
cancel superseded feedback for the same PR or main branch. Scheduled/manual
qualification has a separate concurrency group: active work finishes and only
the latest pending run is retained. Release qualification is grouped by its
exact source SHA and cannot be cancelled by newer main pushes. Provider and
rejecting failed, cancelled, or unexpectedly skipped work. It rejects missing or
unknown tiers and requires every canonical scope to be explicitly `true` or
`false`; incomplete classification cannot silently omit verification lanes.
Branch protection
should require this aggregate, `Static checks`, `Portable tests`, and
`Conventional PR title`. Title validation runs separately on PR opening,
reopening, commits, and edits; correcting a title refreshes only this lightweight
check. It reads title text through an environment variable, executes no PR code,
and has no token permissions. Its separate concurrency group cannot cancel
test runs. Fast PR runs cancel superseded feedback for the same PR. Main push
runs keep one active run and the latest pending run for that branch.
Scheduled/manual qualification has a separate concurrency group: active work
finishes and only the latest pending run is retained. Release qualification uses
its exact source SHA and cannot be cancelled by newer main pushes. Provider and
website lanes inherit their caller's scheduling; reusable
workflows have no independent cancellation group. Direct manual lane dispatches
run independently on disposable hosted runners. Publication
@@ -830,8 +978,11 @@ catalog, and managed-conformance metadata. Each shard still installs the locked
dependencies and runs its assigned tests; full qualification enables coverage. It no longer
regenerates the same metadata independently.
The build job also retrieves the merged timing report from the last successful
default-branch scheduled qualification and includes that same report in each shard's snapshot.
The build job also finds an available `test-timings` artifact from a successful
trusted default-branch scheduled or manual qualification and includes that same
report in each shard's snapshot. The bounded lookup verifies the artifact's
producer; newer schedules that reuse qualification without producing timings do
not hide the last full run's artifact.
CI assigns discovered files by historical duration, with a deterministic
fallback weight for new files, rather than dividing only by file count. Every
discovered file still belongs to exactly one shard; worker limits and project
@@ -843,6 +994,16 @@ timing data is reused; live provider results are never cached. Turbo outputs are
restored by task hash, with trusted default-branch runs as cache writers and PRs
as restore-only consumers. npm caches hold downloads, not `node_modules`.
The eight automatic browser, binary-layout, EVM, JavaScript-recovery,
recorded-crash, and web provider lanes download `rea-build-cache` from their own
run after the build and static jobs succeed. Their existing build commands still
validate task hashes and rebuild mismatched inputs; provider observations always
run. Provider-workflow-only edits also admit the build without enabling source
test shards. Direct provider dispatches retain the cross-run restore-only cache.
This removes repeated compilation on fresh source revisions, but adds the build
as a prerequisite: a delayed build can delay provider admission. Compare queue
and execution times before claiming an end-to-end speedup.
These native baseline checks complement the Linux source-test shards and the
separate Apple-artifact and real-provider lanes. Actual Hopper, Ghidra, IDA,
browser and managed-tool claims require their corresponding verification lanes.
+11 -3
View File
@@ -17,14 +17,16 @@ additional permission flag or degraded mode is required.
- Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+;
- an operator-installed Ghidra 12.1.x distribution (verified with 12.1.4);
- a 64-bit full JDK inside that installation's declared Java range (JDK 21 or newer, with no maximum, for current 12.1 releases);
- an explicit native, non-managed x86 or x86-64 PE application or DLL; and
- an explicit native, non-managed x86 or x86-64 PE application/DLL, or a
[raw NMOS 6502 image](raw-binary.md#nmos-6502) with a caller-supplied entry; and
- the 25 read-only Ghidra inventory, memory, and function-analysis operations.
Loaded memory reads and file offsets preserve Ghidra's observed source mappings.
PE load-image inspection returns an explicit unsupported attestation status.
Independent verification covers admitted DOS MZ, explicit COM, and explicit
[raw-binary imports](raw-binary.md) on their supported hosts. This does not
extend Windows P0 admission beyond native PE. For x64 PE
[raw-binary imports](raw-binary.md) on their supported hosts, including the
admitted raw NMOS 6502 mapping on Windows. Windows P0 admission remains limited
to native PE and raw NMOS 6502 images. For x64 PE
NativeAOT RTR 9.1, the read-only snapshot parser can report metadata through
`inspect_native_load_image` and `inspect_native_data_type` without a JAR or
database mutation. A real Windows-host NativeAOT Ghidra run is still required
@@ -35,6 +37,12 @@ state, annotations, other target architectures and formats, and Hopper-only
operations remain unavailable through this provider. Managed PE/CLI inspection
uses its separate execution-free provider.
Raw 6502 imports retain the same native filesystem and process ownership
requirements. The complete image must fit in the 16-bit address space; no
console-specific cartridge loader, CPU variant, or bank-switching model is
implied. Run `npm run verify:ghidra:6502` with caller-installed `ca65`/`ld65`
to check the source-built fixture through real CLI and MCP workflows.
REA does not install or upgrade Ghidra, Java, Python, npm, Node.js, Hopper, or a
compiler. The adapter uses the packaged Java `HeadlessScript`; Python and
PyGhidra are not prerequisites. Users do not build the native addon.
@@ -94,6 +94,9 @@ def main():
if undeclared:
raise ValueError('Declare inherited provider settings explicitly in --providers: '
+ ', '.join(undeclared) + '. Values are not logged.')
source_auth_home = (Path(os.environ['CODEX_HOME']) if 'CODEX_HOME' in os.environ
else Path.home() / '.codex')
source_auth = source_auth_home / 'auth.json'
args.output.mkdir(parents=True, exist_ok=False, mode=0o700)
runtime_files, runtime_digest = runtime_fingerprint(repository)
(args.output / 'runtime-files.json').write_text(json.dumps(runtime_files))
@@ -103,7 +106,6 @@ def main():
workspace = profile / 'workspace'
for directory in [trial_codex_home, account, workspace]:
directory.mkdir(mode=0o700)
source_auth = Path(os.environ.get('CODEX_HOME', str(Path.home() / '.codex'))) / 'auth.json'
auth = trial_codex_home / 'auth.json'
started = time.monotonic()
timeout = False
@@ -71,7 +71,9 @@ class ContextCaptureTests(unittest.TestCase):
self.assertNotIn(marker, text)
self.assertIn('caller-selected evidence and paths', text)
self.assertIn('original-response', text)
self.assertEqual((output / 'codex-tool-context.jsonl').stat().st_mode & 0o777, 0o600)
# Windows mode bits do not describe access control; this is a POSIX assertion.
if os.name == 'posix':
self.assertEqual((output / 'codex-tool-context.jsonl').stat().st_mode & 0o777, 0o600)
def test_missing_rollouts_are_unknown_and_malformed_lines_remain_partial(self):
with tempfile.TemporaryDirectory() as directory:
@@ -124,6 +126,7 @@ class ContextCaptureTests(unittest.TestCase):
patch.object(agent.subprocess, 'check_output', return_value='v24.18.0\n'), \
patch.object(agent.subprocess, 'Popen', return_value=child), \
patch.object(agent, 'capture_tool_context', side_effect=OSError('fixture capture failure')), \
patch.object(agent.Path, 'home', side_effect=AssertionError('Explicit CODEX_HOME must not resolve the default home')), \
patch('builtins.print'):
with self.assertRaisesRegex(OSError, 'fixture capture failure'):
agent.main()
@@ -0,0 +1,128 @@
# Windows reconciliation of recorded POSIX paths
Follow-up to [#2422](https://github.com/morluto/rea/pull/2422), including
@Aurantium-47's diagnosis of the host-dependent relative-path operation.
## Source pins and real data
The initial reproduction and passive producer used main
fa4b8288655188426222773ead2ccbdaf76334fd. Before branching, main was updated
to 8b181716aca2cb467be499cba226abfb5ae19141. The owning matcher and its
affected reconciliation consumers were unchanged between those revisions.
The newer baseline again reproduced both errors on the real Windows host.
Input: npm 11.16.0's published lib/utils/validate-lockfile.js, 1,023 bytes,
SHA-256 2295e2dac1c5816e1eae2d417804960ebbef17b64a0a44db4f3483ce6e6d9526.
The installed copy was independently checked against the exact module in the
[official registry package](https://registry.npmjs.org/npm/11.16.0).
The 2,945,850-byte tarball matched its published SHA-512 SRI before reading
that member; no archive extraction or installation occurred, and the tarball
was not retained.
Two actual Linux filesystem directories, Runtime/App and Runtime/app,
held byte-identical module copies and had distinct directory identities.
A controlled Node 24.18.0 process loaded the public module and ran its real
function against 587 locked dependency records: compatible trees returned zero
errors, and one controlled version mismatch returned one error. A passive REA
Inspector capture recorded 114 loaded scripts, including the selected module.
The provider did not evaluate, pause, resume or read source. The owned process
exited normally. Function execution is established by the harness receipt;
REA's passive script metadata establishes loaded status.
Production static analysis inspected both directories plus an independent
Static/public-module copy. The latter prevents implicit root matching from
hiding the explicit mapping result. Complete static/runtime Evidence was then
consumed by the actual **Windows** CLI and SDK 2.3.1 stdio MCP server, using
Windows 11 10.0.22631.0 and official Windows x64 Node 24.18.0.
No Electron GUI, IDA or Ghidra engine was used.
## Correctness and provenance
| Windows reconciliation | Baseline | Candidate |
| -------------------------------------- | ------------------------------ | ----------------------------------- |
| Exact POSIX input root | Matched, artifact path | Same complete normalized result |
| Case-mismatched POSIX input root | Incorrectly matched and loaded | Unknown; static load states unknown |
| Exact explicit POSIX mapping | Matched, operator file mapping | Same complete normalized result |
| Case-mismatched explicit POSIX mapping | Incorrectly matched and loaded | Unknown; static load states unknown |
All four CLI and same-connection MCP normalized results agree. The input
Evidence, recorded runtime facts and caller-selected root spellings are
preserved. Rejected location membership does not establish that a module was
absent or unexecuted. The operation remains offline and performs no target or
filesystem inspection.
The shared matcher now chooses posix.relative after identifying POSIX input;
Windows coordinates continue to use win32.relative. Both location matching
and active-layer scope use this owner. The obsolete host-default import is
removed; no downstream repair or new path abstraction is introduced.
The related suite passed 102 tests: 15 workflow tests, 85 CI scope tests and
two curated-path checks. Four simulated Electron Evidence controls cover
POSIX mismatch/exact matching, Windows case-insensitive matching, the resulting
asset load state and retained mapping spelling. They are service regressions,
not real Electron-provider verification. The existing workflow test file is
registered in both the Windows execution lane and its package-change scope.
## Delivery and resource observations
Probes ran serially at BelowNormal priority with one CPU and 512 MiB Node heap
settings for CLI/server/client work. Heap settings do not bound aggregate memory.
Four corrected CLI exit-time observations reported 7.343–15.827 seconds CPU and
322,308–324,280 KiB peak working set, excluding the probe parent. Wall times
ranged from 7.785 to 17.601 seconds. There is no paired resource baseline.
One complete MCP trial connected in 18,296 ms and discovered 189 tools in
875 ms (2,588,336 serialized bytes). The four subsequent same-connection calls
took 879, 320, 353 and 422 ms. Their serialized replies were 918,133 / 913,765 /
918,571 / 914,171 bytes; inputs totalled 681,312 bytes. Discovery plus these
inputs and replies totalled 6,934,288 bytes, excluding initialization, framing
and notifications. This is delivery volume, not model token usage. Actual
model context and total model task cost were not measured.
A process-generation sampler recorded 233 observations over 25,671 ms:
15,797 ms aggregate observed CPU as a lower bound and a peak sampled working-set
sum of 491,597,824 bytes (about 469 MiB). Exited-process tails can be missed;
shared pages can be counted twice. This excludes the observer and is not an
exact physical-memory peak. The server wrote four structured completion records
to stderr; tool replies and normalized results passed the recorded checks.
Cold/warm caches and observer differences were not normalized. No latency,
CPU, memory or model-token improvement is claimed. The fix adds no lookup I/O,
engine invocation, retry or cache. Complete default replies remain available;
only compact selected findings and hashes were sent to the investigating agent.
## Main integration replay
Runtime cb0d8a9fef2b907cff2ba27f1e017493e1173eb2, rebased onto main
73dd8d6ed, again passed the 101 related tests and all four real Windows CLI/MCP
cases using the unchanged older producer Evidence. Positive results and original
runtime/layer facts remain identical. Typecheck, lint, dependency and metadata
checks passed; all five changed files passed formatting independently.
The full format gate at that main revision failed on the unchanged upstream
GhidraDoctor.test.ts and GhidraSnapshotCache.test.ts files. The PR does not
repair those unrelated baseline files. Resource figures above refer to the
earlier candidate; the integration replay does not establish a speedup.
Review found that owning only the Windows test path would still skip that lane
on a later implementation-only edit. The matcher implementation is now also
registered in package scope. A source-only classifier case verifies that it
selects both Windows package and JavaScript recovery lanes; 85 scope checks and
two curated-path checks pass. Runtime code and the real-data checks are unchanged.
The original production fix merged as
[1a1b67c88](https://github.com/morluto/rea/commit/1a1b67c88) while this follow-up
was open. The follow-up was then rebased onto main fb28ec153 and contains only
the stronger workflow assertions, Windows lane ownership, scope classification
and this verification record. The updated 131 related tests, two representative
Ghidra boundary tests, seven IDA input-thinning tests, 24 semantic-structure
tests, 16 required-lane checks, 39 release-checkpoint tests and 11 dyld CLI
tests passed after the rebase (230 total).
Upstream a0317cf73 corrected the invalid FAT fixture and consolidated unrelated
duplicated helpers; e05d3399f aligned the older parameter-provenance assertion
with the upstream semantics. Those changes, the threshold change and the new
CI qualification tiers are part of main, outside this follow-up's diff.
Upstream 747e72991 restored release preflight coverage for reusable workflow
jobs. The independently reviewed, fully green #2755 fixture correction merged
as fb28ec153 and restored real x64 dyld CLI admission without altering the
original same-named arm64e assertions.
No production source is duplicated by the follow-up.
+6 -1
View File
@@ -2,7 +2,7 @@ REA npm dependency inventory
Prepared from the root package-lock.json; refresh this inventory when locked dependencies change.
License identifiers are upstream lockfile declarations, not a legal assessment.
Runtime includes transitive and optional platform packages; development includes build, test and website tooling.
Locked package entries: 586; runtime: 174.
Locked package entries: 587; runtime: 175.
Full runtime copyright/license/NOTICE texts: npm-runtime-notices.txt.
@algolia/abtesting@1.25.0 | development | MIT
@@ -1430,6 +1430,11 @@ canonicalize@3.0.0 | runtime | Apache-2.0
Package: https://www.npmjs.com/package/canonicalize/v/3.0.0
Archive: https://registry.npmjs.org/canonicalize/-/canonicalize-3.0.0.tgz
Integrity: sha512-yYLfHyDMIXRyRqsKBRLX023riFLpXY2YOfdtqKXZRZy9qsfOJ9U+4F9YZL7MEzL5+ziN2x2nlBvY/Voi3EBljA==
cborg@6.1.3 | runtime | Apache-2.0
Lockfile path: node_modules/cborg
Package: https://www.npmjs.com/package/cborg/v/6.1.3
Archive: https://registry.npmjs.org/cborg/-/cborg-6.1.3.tgz
Integrity: sha512-Kb/wD/AFuVcqvVjR+HYNOjNiuhVJ2C+RWxQE2Yqq2WVlVkRjdvVLkkM6QsL3NMsBRh+rgNc+aXmcraA+X0HObQ==
ccount@2.0.1 | development | MIT
Lockfile path: node_modules/ccount
Package: https://www.npmjs.com/package/ccount/v/2.0.1
+229 -1
View File
@@ -1,7 +1,7 @@
REA third-party npm runtime copyright, license and NOTICE texts
Collected from integrity-verified locked npm archives. Release-specific supplements and upstream notice gaps are identified in their entries.
External engines are separately obtained; see THIRD_PARTY_NOTICES.md.
Runtime dependency fingerprint (SHA-256): 5de9cf97e7c327da1892efeff30b40507e4c7f8e496b5d305b8016c162bc15e3
Runtime dependency fingerprint (SHA-256): 63d5b55e027a0cfb3e77989ca337aca905f1652d10cfbfc4d4dd876d3ecd4f3a
===== @babel/helper-string-parser@8.0.0 =====
Declared license: MIT
@@ -2456,6 +2456,234 @@ Scope: npm runtime dependency, including optional platform packages.
limitations under the License.
===== cborg@6.1.3 =====
Declared license: Apache-2.0
Archive: https://registry.npmjs.org/cborg/-/cborg-6.1.3.tgz
Integrity: sha512-Kb/wD/AFuVcqvVjR+HYNOjNiuhVJ2C+RWxQE2Yqq2WVlVkRjdvVLkkM6QsL3NMsBRh+rgNc+aXmcraA+X0HObQ==
Scope: npm runtime dependency, including optional platform packages.
--- package/LICENSE ---
Copyright 2020 Rod Vagg
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
--- Apache-2.0 full license (licenses/Apache-2.0.txt) ---
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
===== clean-git-ref@2.0.1 =====
Declared license: Apache-2.0
Archive: https://registry.npmjs.org/clean-git-ref/-/clean-git-ref-2.0.1.tgz
+16 -7
View File
@@ -90,8 +90,10 @@ without this lane reports Windows native controls as unavailable.
root and ancestor leases for retry; a partially removed runtime accepts only
cleanup, and its registry entry remains until removal succeeds. Normal close, cancellation,
startup failure, and timeout release provider resources. Abrupt owner death
kills the owned job; it can leave private runtime files for later operator
cleanup and does not claim transactional deletion after a crash.
kills the owned job. The Android provider additionally uses a detached helper
that owns its private runtime directory and cleans it when the owner pipe
closes. Other providers can leave private runtime files after owner death;
helper death and host power loss are not transactional deletion.
Windows failures retain their constraint, requested coordinate, Win32 code,
and system message. Unsupported filesystems and reparse paths are distinct
@@ -115,8 +117,10 @@ the supported device/UNC namespaces; existing admission checks still apply.
The bearer-token descriptor remains under its immutable private lease until
`runtime_close` on Windows; POSIX removes the descriptor after the bridge reads
it. Job ownership guarantees process termination on owner death, not deletion
of private files after a crash. SUBST drive aliases and changing DOS-device
it. Job ownership guarantees process termination on owner death. Android's
detached workspace helper provides a separate cleanup owner for that provider,
but simultaneous helper death or power loss can still leave private files.
SUBST drive aliases and changing DOS-device
namespaces are outside the verified P0 scope and are not detected as a separate
alias policy. Mounted-folder paths that report a reparse tag are rejected by
component admission; broader namespace variants remain unverified. Preserve
@@ -163,9 +167,14 @@ V8 heap flags cannot bound (limit notification, peak commit at the limit, all
members settled, with the notification present in the first native exit report
and the wrapper's exit event), and a control under the same limit that
completes normally.
An optional independently built
`tests/fixtures/windows/processBoundary.cc` executable adds breakaway and
caller/child token observations and an in-place ancestor reparse attempt:
A separately built `tests/fixtures/windows/processBoundary.cc` executable adds
breakaway, caller/child token and environment semantics observations and an
in-place ancestor reparse attempt. The lane skips all of them when the
executable is absent, so both Windows workflows cross-build it in the same
MinGW job that builds their artifact and pass it: the capability and package
lane in `.github/workflows/ci.yml`, and the real Ghidra proof run in
`.github/workflows/real-ghidra-windows.yml`. Build it locally with any Windows
C++ compiler, or the same way they do, and pass its path:
```powershell
node scripts/verify-windows-native.mjs . C:\fixtures\process-boundary.exe
+10
View File
@@ -24,6 +24,7 @@
"ajv": "8.20.0",
"ajv-formats": "3.0.1",
"canonicalize": "3.0.0",
"cborg": "6.1.3",
"cross-spawn": "7.0.6",
"diff": "9.0.0",
"evmole": "0.9.3",
@@ -4181,6 +4182,15 @@
"node": ">=18"
}
},
"node_modules/cborg": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/cborg/-/cborg-6.1.3.tgz",
"integrity": "sha512-Kb/wD/AFuVcqvVjR+HYNOjNiuhVJ2C+RWxQE2Yqq2WVlVkRjdvVLkkM6QsL3NMsBRh+rgNc+aXmcraA+X0HObQ==",
"license": "Apache-2.0",
"bin": {
"cborg": "lib/bin.js"
}
},
"node_modules/ccount": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
+5
View File
@@ -62,6 +62,7 @@
"bridge/android/ReaJadxBridge.java",
"bridge/mitmproxy/capture.py",
"scripts/rea.mjs",
"scripts/rea-android-workspace-guardian.mjs",
"scripts/electron-active-hook.cjs",
"scripts/electron-active-hook-boundaries.cjs",
"scripts/hopper-demo-x11.py",
@@ -174,6 +175,7 @@
"verify:ghidra:seeds": "npm run build:cached && node scripts/verify/ghidra/seeds.mjs",
"verify:ghidra:mips": "npm run build:cached && node scripts/verify-real-ghidra-mips.mjs",
"verify:ghidra:riscv": "npm run build:cached && node scripts/verify-real-ghidra-riscv.mjs",
"verify:ghidra:6502": "npm run build:cached && node scripts/verify-real-ghidra-6502.mjs",
"verify:ghidra:mips-shared": "npm run build:cached && node scripts/verify-real-ghidra-mips-shared.mjs",
"verify:ghidra:mips-relocatable": "npm run build:cached && node scripts/verify-real-ghidra-mips-relocatable.mjs",
"verify:ghidra:swift": "npm run build:cached && node scripts/verify-real-ghidra-swift.mjs",
@@ -182,6 +184,7 @@
"verify:ghidra:dos": "npm run build:cached && node scripts/verify-real-ghidra-dos.mjs",
"verify:ghidra:com": "npm run build:cached && node scripts/verify-real-ghidra-com.mjs",
"verify:ghidra:raw": "npm run build:cached && node scripts/verify-real-ghidra-raw.mjs",
"verify:ghidra:address-context": "npm run build:cached && node scripts/verify-real-ghidra-address-context.mjs",
"verify:ghidra:aarch64-jump-table": "npm run build:cached && node scripts/verify-real-ghidra.mjs --aarch64-jump-table",
"verify:interface-builder": "npm run build:cached && node scripts/verify/apple/interface-builder.mjs",
"verify:ghidra:cross-format": "npm run build:cached && node scripts/verify-real-ghidra.mjs --cross-format",
@@ -268,6 +271,7 @@
"verify:wasm:package": "npm run build:cached && node scripts/verify-packaged-wasm-artifact.mjs",
"verify:wasm:artifact": "npm run build:cached && node scripts/verify-wasm-artifact.mjs",
"verify:evm:interface": "npm run build:cached && node scripts/verify-evm-interface.mjs",
"verify:solidity:metadata": "npm run build:cached && node scripts/verify-solidity-metadata.mjs",
"verify:recorded:crash": "npm run build:cached && node scripts/verify-recorded-crash.mjs",
"mcp-catalog:check:built": "node scripts/generate-mcp-tool-catalog.mjs --check",
"compile": "node scripts/clean-build-output.mjs && tsc -p tsconfig.build.json && node scripts/bundle-runtime.mjs",
@@ -307,6 +311,7 @@
"ajv": "8.20.0",
"ajv-formats": "3.0.1",
"canonicalize": "3.0.0",
"cborg": "6.1.3",
"cross-spawn": "7.0.6",
"diff": "9.0.0",
"evmole": "0.9.3",
+268
View File
@@ -0,0 +1,268 @@
import { execFile } from "node:child_process";
import { appendFile, mkdir, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { collectJobTimings } from "./job-timings.mjs";
const execFileAsync = promisify(execFile);
const OUTPUT_DIRECTORY = join(".cache", "ci-job-timings");
const MAX_BUFFER = 16 * 1024 * 1024;
const API_TIMEOUT_MS = 30_000;
const WORKFLOWS = [
{ id: "ci.yml", name: "CI" },
{ id: "release.yml", name: "Release" },
];
const LOOKBACK_MS = 24 * 60 * 60 * 1_000;
const MAX_RUNS_PER_WORKFLOW = 100;
const TIME_BUDGET_MS = 240_000;
/** Collect a bounded recent sample of completed CI and Release attempts. */
export async function collectJobTimingsBatch({
repository,
outputDirectory = OUTPUT_DIRECTORY,
observedAt = new Date().toISOString(),
env = process.env,
now = Date.now,
collect = collectJobTimings,
} = {}) {
const identity = validateRepository(repository);
const started = now();
const cutoff = new Date(started - LOOKBACK_MS).toISOString();
const manifest = {
schemaVersion: 1,
observedAt,
repository: identity,
selection: {
workflows: WORKFLOWS,
status: "completed",
createdAfter: cutoff,
lookbackHours: 24,
overlap: "Each hourly run re-reads the preceding 24 hours.",
pagination:
"Run listings use the first 100; jobs are paginated within the remaining time and 16 MiB response limits.",
maxRunsPerWorkflow: MAX_RUNS_PER_WORKFLOW,
timeBudgetMs: TIME_BUDGET_MS,
exhaustive: false,
},
coverage: {
workflowsQueried: [],
runsListed: 0,
selectedRuns: 0,
collectedRuns: 0,
},
truncation: [],
errors: [],
runs: [],
elapsedMs: 0,
};
const budgetExpired = () => now() - started >= TIME_BUDGET_MS;
const selected = [];
for (const workflow of WORKFLOWS) {
if (budgetExpired()) {
manifest.truncation.push("Time budget reached while listing workflows.");
break;
}
try {
const response = await apiJson(
`repos/${identity}/actions/workflows/${workflow.id}/runs?status=completed&created=%3E%3D${encodeURIComponent(cutoff)}&per_page=${String(MAX_RUNS_PER_WORKFLOW)}&page=1`,
env,
started,
now,
);
if (!Array.isArray(response.workflow_runs))
throw new Error("run listing must contain a workflow_runs array");
manifest.coverage.workflowsQueried.push(workflow.name);
manifest.coverage.runsListed += response.workflow_runs.length;
if (response.workflow_runs.length >= MAX_RUNS_PER_WORKFLOW)
manifest.truncation.push(
`${workflow.name} run listing reached the 100-run first-page limit.`,
);
for (const run of response.workflow_runs.slice(
0,
MAX_RUNS_PER_WORKFLOW,
)) {
if (
run.status !== "completed" ||
!Number.isSafeInteger(run.id) ||
!Number.isSafeInteger(run.run_attempt)
) {
manifest.errors.push({
scope: `workflow:${workflow.name}`,
runId: run.id ?? null,
message:
"Run record missing completed status, numeric id, or numeric latest attempt.",
});
continue;
}
if (run.created_at && Date.parse(run.created_at) < Date.parse(cutoff))
continue;
selected.push({
id: run.id,
runAttempt: run.run_attempt,
workflow: workflow.name,
conclusion: run.conclusion ?? null,
createdAt: run.created_at ?? null,
});
}
} catch (cause) {
manifest.errors.push({
scope: `workflow:${workflow.name}`,
message: errorMessage(cause),
});
}
}
selected.sort(
(left, right) =>
(Date.parse(right.createdAt ?? "") || 0) -
(Date.parse(left.createdAt ?? "") || 0),
);
manifest.coverage.selectedRuns = selected.length;
for (const run of selected) {
if (budgetExpired()) {
manifest.truncation.push(
"Time budget reached before all selected runs were collected.",
);
break;
}
const runRecord = {
...run,
status: "pending",
rawPath: null,
reportPath: null,
error: null,
};
manifest.runs.push(runRecord);
const runDirectory = join(
outputDirectory,
"runs",
`${String(run.id)}-${String(run.runAttempt)}`,
);
try {
const result = await collect({
repository: identity,
runId: String(run.id),
runAttempt: String(run.runAttempt),
outputDirectory: runDirectory,
env,
timeoutMs: Math.max(
1,
Math.min(API_TIMEOUT_MS, TIME_BUDGET_MS - (now() - started)),
),
});
runRecord.status = "collected";
runRecord.rawPath = result.rawPath;
runRecord.reportPath = result.reportPath;
manifest.coverage.collectedRuns += 1;
} catch (cause) {
runRecord.status = "error";
runRecord.error = errorMessage(cause);
manifest.errors.push({
scope: "run",
runId: run.id,
runAttempt: run.runAttempt,
message: runRecord.error,
});
}
}
if (budgetExpired() && manifest.runs.length < manifest.coverage.selectedRuns)
manifest.truncation.push(
"Time budget reached; selected runs remain unattempted.",
);
return await writeManifest(outputDirectory, manifest, started, now, env);
}
function validateRepository(repository) {
if (
typeof repository !== "string" ||
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository)
)
throw new Error(
"CI_REPOSITORY must be an owner/name repository identifier",
);
return repository;
}
async function apiJson(endpoint, env, started, now) {
const timeout = Math.max(
1,
Math.min(API_TIMEOUT_MS, TIME_BUDGET_MS - (now() - started)),
);
const { stdout } = await execFileAsync("gh", ["api", endpoint], {
env,
encoding: "utf8",
maxBuffer: MAX_BUFFER,
timeout,
});
try {
return JSON.parse(stdout);
} catch (cause) {
throw new Error("GitHub Actions API returned malformed JSON", { cause });
}
}
async function writeManifest(outputDirectory, manifest, started, now, env) {
manifest.elapsedMs = Math.max(0, now() - started);
if (
manifest.elapsedMs >= TIME_BUDGET_MS &&
!manifest.truncation.some((item) => item.includes("Time budget"))
)
manifest.truncation.push("Time budget reached.");
await mkdir(outputDirectory, { recursive: true });
const manifestPath = join(outputDirectory, "batch-manifest.json");
await writeFile(
manifestPath,
`${JSON.stringify(manifest, null, 2)}\n`,
"utf8",
);
if (env.GITHUB_STEP_SUMMARY)
await appendBatchSummary(env.GITHUB_STEP_SUMMARY, manifest, manifestPath);
return { manifestPath, manifest };
}
async function appendBatchSummary(summaryPath, manifest, manifestPath) {
const collected = manifest.runs.filter(
({ status }) => status === "collected",
).length;
const failed = manifest.runs.filter(
({ status }) => status === "error",
).length;
const lines = [
"### Batched CI timing snapshot",
"",
`Observed ${manifest.observedAt}; collected ${String(collected)} of ${String(manifest.coverage.selectedRuns)} selected recent completed runs (${String(failed)} run errors).`,
`Coverage uses the first ${String(MAX_RUNS_PER_WORKFLOW)} runs per workflow created in the preceding 24 hours and a ${String(TIME_BUDGET_MS / 1_000)} second total budget. This sample is bounded and is not exhaustive.`,
`Manifest: \`${manifestPath}\`.`,
...manifest.truncation.map((message) => `- Truncated: ${message}`),
...manifest.errors.map(
({ scope, message }) => `- Error (${scope}): ${message}`,
),
"",
];
await appendFile(summaryPath, lines.join("\n"), "utf8");
}
function errorMessage(cause) {
return cause instanceof Error ? cause.message : String(cause);
}
if (
process.argv[1] !== undefined &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
try {
const result = await collectJobTimingsBatch({
repository: process.env.CI_REPOSITORY,
outputDirectory: process.env.CI_JOB_TIMINGS_DIRECTORY ?? OUTPUT_DIRECTORY,
});
process.stdout.write(
`Wrote batch manifest for ${String(result.manifest.coverage.collectedRuns)} collected runs to ${result.manifestPath}\n`,
);
} catch (cause) {
process.stderr.write(`CI timing batch failed: ${errorMessage(cause)}\n`);
process.exitCode = 1;
}
}
+354
View File
@@ -0,0 +1,354 @@
import { execFile } from "node:child_process";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
const execFileAsync = promisify(execFile);
const MAX_BUFFER = 16 * 1024 * 1024;
const TIMEOUT_MS = 30_000;
const OUTPUT_DIRECTORY = join(".cache", "ci-job-timings");
const STEP_COSTS = [
{ key: "npmCi", pattern: /\bnpm ci\b/iu },
{ key: "artifactUpload", pattern: /upload.*artifact/iu },
{ key: "artifactDownload", pattern: /download.*artifact/iu },
];
/** Fetch and summarize one workflow attempt without turning telemetry into a gate. */
export async function collectJobTimings({
repository,
runId,
runAttempt,
outputDirectory = OUTPUT_DIRECTORY,
summaryPath,
observedAt = new Date().toISOString(),
env = process.env,
timeoutMs = TIMEOUT_MS,
}) {
const identity = validateIdentity({ repository, runId, runAttempt });
const endpoint = `repos/${identity.repository}/actions/runs/${identity.runId}/attempts/${identity.runAttempt}/jobs?per_page=100`;
const { stdout } = await execFileAsync(
"gh",
["api", "--paginate", "--slurp", endpoint],
{ env, encoding: "utf8", maxBuffer: MAX_BUFFER, timeout: timeoutMs },
);
const rawPath = join(outputDirectory, "jobs.json");
const reportPath = join(outputDirectory, "report.json");
await mkdir(outputDirectory, { recursive: true });
await writeFile(rawPath, stdout, "utf8");
const pages = parsePages(stdout);
const jobs = pages.flatMap(({ jobs: pageJobs }, index) =>
validateJobs(pageJobs, index),
);
const report = normalizeReport({ identity, jobs, observedAt });
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
if (summaryPath !== undefined)
await appendSummary(summaryPath, report, rawPath, reportPath);
return { rawPath, reportPath, report };
}
function validateIdentity({ repository, runId, runAttempt }) {
if (
typeof repository !== "string" ||
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository)
)
throw new Error(
"CI_REPOSITORY must be an owner/name repository identifier",
);
return {
repository,
runId: parsePositiveInteger(runId, "CI_TIMING_RUN_ID"),
runAttempt: parsePositiveInteger(runAttempt, "CI_TIMING_RUN_ATTEMPT"),
};
}
function parsePositiveInteger(value, name) {
if (typeof value === "string" && /^[1-9]\d*$/u.test(value)) {
const parsed = Number(value);
if (Number.isSafeInteger(parsed)) return parsed;
}
throw new Error(`${name} must be a positive safe integer`);
}
function parsePages(stdout) {
let pages;
try {
pages = JSON.parse(stdout);
} catch (cause) {
throw new Error("GitHub jobs API returned malformed JSON", { cause });
}
if (!Array.isArray(pages) || pages.length === 0)
throw new Error("GitHub jobs API slurp must contain a nonempty page array");
for (const page of pages)
if (
typeof page !== "object" ||
page === null ||
Array.isArray(page) ||
!Array.isArray(page.jobs)
)
throw new Error("GitHub jobs API page must contain a jobs array");
return pages;
}
function validateJobs(jobs, pageIndex) {
return jobs.map((job, jobIndex) => {
if (
typeof job !== "object" ||
job === null ||
Array.isArray(job) ||
typeof job.name !== "string" ||
job.name.length === 0 ||
typeof job.status !== "string"
)
throw new Error(
`GitHub jobs API page ${String(pageIndex + 1)} job ${String(jobIndex + 1)} is malformed`,
);
return job;
});
}
function normalizeReport({ identity, jobs, observedAt }) {
const normalizedJobs = jobs.map(normalizeJob);
const queues = normalizedJobs
.map(({ queue }) => queue.seconds)
.filter((value) => value !== null);
const completedExecutions = normalizedJobs
.filter(
({ status, conclusion }) =>
status === "completed" && conclusion !== "skipped",
)
.map(({ execution }) => execution.seconds)
.filter((value) => value !== null);
const completedDurationSumSeconds = completedExecutions.reduce(
(sum, seconds) => sum + seconds,
0,
);
const completedExecutionUnknown = normalizedJobs.filter(
({ status, conclusion, execution }) =>
status === "completed" &&
conclusion !== "skipped" &&
execution.seconds === null,
).length;
const skipped = normalizedJobs.filter(
({ conclusion }) => conclusion === "skipped",
).length;
const costs = Object.fromEntries(STEP_COSTS.map(({ key }) => [key, []]));
for (const job of normalizedJobs)
for (const step of job.steps)
if (step.costKey !== null)
costs[step.costKey].push({ job: job.name, ...step });
return {
schemaVersion: 1,
run: {
...identity,
observedAt,
snapshot:
normalizedJobs.length > 0 &&
normalizedJobs.every(({ status }) => status === "completed")
? "completed job status snapshot; unknown timestamps remain unmeasured"
: "partial; job statuses may still be queued or in progress",
},
counts: {
jobs: normalizedJobs.length,
completed: normalizedJobs.filter(({ status }) => status === "completed")
.length,
skipped,
queueUnknown: normalizedJobs.filter(({ queue }) => queue.seconds === null)
.length,
executionUnknown: normalizedJobs.filter(
({ execution }) => execution.seconds === null,
).length,
completedExecutionUnknown,
},
aggregates: {
queueSeconds: percentileSummary(queues),
completedExecutionSeconds: percentileSummary(completedExecutions),
summedCompletedJobElapsedSeconds: {
value:
completedExecutions.length === 0
? null
: round(completedDurationSumSeconds),
knownCount: completedExecutions.length,
unknownCount: completedExecutionUnknown,
label:
"sum of known completed job elapsed durations; incomplete when timestamps are unknown; not CPU time, billing, or workflow wall time",
},
},
jobs: normalizedJobs,
stepCostInterpretation:
"Matched install/artifact steps by step name; these are not total artifact transfer costs.",
stepCosts: costs,
};
}
function normalizeJob(job) {
const skipped = job.conclusion === "skipped";
const hasStarted = ["in_progress", "completed"].includes(job.status);
const queue = skipped
? unknownDuration("job was skipped")
: !hasStarted
? unknownDuration("job start is not established by its status")
: duration(job.created_at, job.started_at);
const execution = skipped
? unknownDuration("job was skipped")
: job.status === "completed"
? duration(job.started_at, job.completed_at)
: unknownDuration("job is not completed");
const steps = Array.isArray(job.steps) ? job.steps.map(normalizeStep) : [];
return {
id: Number.isSafeInteger(job.id) && job.id > 0 ? job.id : null,
url: stringOrNull(job.html_url),
name: job.name,
workflowName: stringOrNull(job.workflow_name),
status: job.status,
conclusion: stringOrNull(job.conclusion),
createdAt: stringOrNull(job.created_at),
startedAt: stringOrNull(job.started_at),
completedAt: stringOrNull(job.completed_at),
queue,
execution,
steps,
};
}
function normalizeStep(step) {
if (
typeof step !== "object" ||
step === null ||
Array.isArray(step) ||
typeof step.name !== "string"
)
throw new Error("GitHub jobs API contains a malformed job step");
const cost = STEP_COSTS.find(({ pattern }) => pattern.test(step.name));
return {
name: step.name,
costKey: cost?.key ?? null,
duration: duration(step.started_at, step.completed_at),
};
}
function duration(startValue, endValue) {
if (typeof startValue !== "string" || typeof endValue !== "string")
return unknownDuration("timestamp unavailable");
const start = Date.parse(startValue);
const end = Date.parse(endValue);
if (!Number.isFinite(start) || !Number.isFinite(end))
return unknownDuration("invalid timestamp");
const milliseconds = end - start;
if (!Number.isFinite(milliseconds) || milliseconds < 0)
return unknownDuration("timestamps out of order");
return { seconds: round(milliseconds / 1_000), reason: null };
}
function unknownDuration(reason) {
return { seconds: null, reason };
}
function percentileSummary(values) {
return {
count: values.length,
p50: nearestRank(values, 0.5),
p95: nearestRank(values, 0.95),
unit: "seconds",
};
}
function nearestRank(values, percentile) {
if (values.length === 0) return null;
const sorted = [...values].sort((left, right) => left - right);
return round(sorted[Math.ceil(percentile * sorted.length) - 1]);
}
function round(value) {
return Math.round(value * 1_000) / 1_000;
}
function stringOrNull(value) {
return typeof value === "string" ? value : null;
}
async function appendSummary(summaryPath, report, rawPath, reportPath) {
await mkdir(dirname(summaryPath), { recursive: true });
const rows = [
["Queue", report.aggregates.queueSeconds],
["Completed execution", report.aggregates.completedExecutionSeconds],
]
.map(
([label, item]) =>
`| ${label} | ${display(item.p50)} | ${display(item.p95)} | ${String(item.count)} |`,
)
.join("\n");
const jobRows = report.jobs
.map(
(job) =>
`| ${escapeCell(job.name)} | ${job.status} | ${job.conclusion ?? "unknown"} | ${display(job.queue.seconds)} | ${job.queue.seconds === null ? job.queue.reason : ""} | ${display(job.execution.seconds)} | ${job.execution.seconds === null ? job.execution.reason : ""} |`,
)
.join("\n");
const costRows = STEP_COSTS.map(({ key }) => {
const samples = report.stepCosts[key];
const values = samples
.map(({ duration: item }) => item.seconds)
.filter((value) => value !== null);
return `| ${key} | ${String(samples.length)} | ${display(nearestRank(values, 0.5))} | ${samples.filter(({ duration: item }) => item.seconds === null).length} |`;
}).join("\n");
const text = [
"### CI job timing snapshot",
"",
`Observed at ${report.run.observedAt} for run ${String(report.run.runId)}, attempt ${String(report.run.runAttempt)}. This is a point-in-time snapshot of fetched jobs. Queue starts at job creation and excludes preceding dependency waiting. Missing, queued, in-progress, invalid, or out-of-order timestamps are reported as unknown. Elapsed durations are not CPU time or billing data.`,
"",
`Fetched ${String(report.counts.jobs)} jobs; ${String(report.counts.completed)} are completed (${String(report.counts.skipped)} skipped); ${String(report.counts.queueUnknown)} queue durations and ${String(report.counts.executionUnknown)} execution durations (${String(report.counts.completedExecutionUnknown)} for non-skipped completed jobs) are unknown.`,
"",
"| Metric | p50 (s) | p95 (s) | Samples |",
"| --- | ---: | ---: | ---: |",
rows,
"",
"| Job | Status | Conclusion | Queue (s) | Queue detail | Execution (s) | Execution detail |",
"| --- | --- | --- | ---: | --- | ---: | --- |",
jobRows,
"",
`Sum of known completed job elapsed durations: **${display(report.aggregates.summedCompletedJobElapsedSeconds.value)} s** across ${String(report.aggregates.summedCompletedJobElapsedSeconds.knownCount)} known and ${String(report.aggregates.summedCompletedJobElapsedSeconds.unknownCount)} unknown jobs; this is not CPU time, billing, or workflow wall time.`,
"",
"Matched install/artifact steps by name; these are not total artifact transfer costs.",
"",
"| Matched step group | Samples | p50 (s) | Unknown |",
"| --- | ---: | ---: | ---: |",
costRows,
"",
`Reports: \`${rawPath}\`, \`${reportPath}\`.`,
"",
].join("\n");
await writeFile(summaryPath, text, { encoding: "utf8", flag: "a" });
}
function display(value) {
return value === null ? "unknown" : String(value);
}
function escapeCell(value) {
return value.replaceAll("|", "\\|").replaceAll("\n", " ");
}
if (
process.argv[1] !== undefined &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
try {
const result = await collectJobTimings({
repository: process.env.CI_REPOSITORY,
runId: process.env.CI_TIMING_RUN_ID,
runAttempt: process.env.CI_TIMING_RUN_ATTEMPT,
outputDirectory: process.env.CI_JOB_TIMINGS_DIRECTORY ?? OUTPUT_DIRECTORY,
summaryPath: process.env.GITHUB_STEP_SUMMARY,
});
process.stdout.write(
`Wrote ${result.report.jobs.length} job timing records to ${result.reportPath}\n`,
);
} catch (cause) {
process.stderr.write(
`CI timing telemetry failed: ${cause instanceof Error ? cause.message : String(cause)}\n`,
);
process.exitCode = 1;
}
}
+45 -25
View File
@@ -1,30 +1,20 @@
import { execFileSync } from "node:child_process";
import { appendFileSync } from "node:fs";
import { matchesGlob } from "node:path";
import { MANUAL_VERIFIERS, SCOPES } from "./scopes.mjs";
import { CI_FLAGS as flags, MANUAL_VERIFIERS, SCOPES } from "./scopes.mjs";
import { planTestSelection } from "./test-selection.mjs";
const flags = [
...new Set([
"code",
"runtime",
"package",
"apple",
"fixtures",
"readiness",
"docs",
"workflows",
...Object.keys(SCOPES),
]),
];
// Test files select the native-host lanes that run them explicitly. Provider
// scopes remain implementation-owned, so test-only edits stay off those lanes.
const NATIVE_TEST_OWNERS = ["apple", "package"];
// Test files select the host/runtime lanes that execute their focused suites.
const HOST_TEST_OWNERS = ["apple", "package", "sqlite"];
// Actionlint validates workflow syntax only. The semantic suite asserts these
// workflows' release, publication and self-hosted runner safeguards.
const GUARDED_WORKFLOWS = new Set([
".github/workflows/pr-title.yml",
".github/workflows/ci-full-pr.yml",
".github/workflows/release.yml",
".github/workflows/real-ghidra-windows.yml",
".github/workflows/sqlite-runtime.yml",
".github/workflows/ci-timings.yml",
]);
// Sources compiled by build-conformance-fixtures.mjs. The readiness journey
// analyzes the built C fixture as well as its own applications.
@@ -36,6 +26,15 @@ const FIXTURE_SOURCES = [
"tests/conformance/versions/**",
];
const READINESS_SOURCES = ["tests/conformance/readiness/**"];
// These reporters affect CI output only; they do not select a host or provider
// verification lane when changed on their own.
const PORTABLE_CI_REPORTERS = new Set([
"scripts/ci/job-timings.mjs",
"scripts/ci/job-timings-batch.mjs",
"scripts/ci/test-report.mjs",
"scripts/ci/test-timing-reporter.mjs",
"scripts/ci/test-history.mjs",
]);
const matchesAny = (file, patterns) =>
patterns.some((pattern) => matchesGlob(file, pattern));
const selected = Object.fromEntries(flags.map((flag) => [flag, false]));
@@ -63,6 +62,10 @@ function classifyPackage(base, head) {
]),
].filter((key) => beforeScripts?.[key] !== afterScripts?.[key]);
for (const script of changed) {
if (script === "test:sqlite" || script === "test:sqlite:run") {
selected.sqlite = true;
continue;
}
if (
/^(test(?::|$)|check(?::|$)|lint(?::|$)|format(?::|$)|knip$|jscpd$)/.test(
script,
@@ -110,14 +113,24 @@ function classifyPackage(base, head) {
const event = process.env.GITHUB_EVENT_NAME;
if (!["pull_request", "push", "schedule", "workflow_dispatch"].includes(event))
throw new Error(`Unsupported CI event: ${event}`);
const tier =
const requestedFull =
event === "schedule" ||
event === "workflow_dispatch" ||
process.env.CI_FULL === "true"
? "full"
: "fast";
process.env.CI_FULL === "true";
const tier =
event === "schedule" &&
process.env.CI_SKIP_FULL === "true" &&
process.env.CI_FULL !== "true"
? "reused"
: requestedFull
? "full"
: "fast";
let files = [];
if (tier === "full") {
if (tier === "reused") {
reasons.push(
"Recent exact-SHA full qualification reused; no fresh provider observation",
);
} else if (tier === "full") {
full("Full baseline requested by event or ci:full label");
} else if (event === "pull_request" || event === "push") {
const head = process.env.HEAD_SHA;
@@ -162,7 +175,7 @@ if (tier === "full") {
}
if (/^(src|tests)\/.*\.(?:test|spec)\.[cm]?[jt]sx?$/.test(file)) {
selected.code = true;
for (const owner of NATIVE_TEST_OWNERS)
for (const owner of HOST_TEST_OWNERS)
selected[owner] ||= matchesAny(file, SCOPES[owner]);
continue;
}
@@ -197,6 +210,7 @@ if (tier === "full") {
}
if (matchesAny(file, READINESS_SOURCES)) selected.readiness = true;
if (file.startsWith("tests/")) continue;
if (PORTABLE_CI_REPORTERS.has(file)) continue;
if (
/^(src\/process\/|src\/filesystem\/|src\/contracts\/tool|src\/server\/createServer|src\/cli\.ts|scripts\/ci\/)/.test(
file,
@@ -237,11 +251,17 @@ const matrix = {
]
: [linux],
};
const plan = { ...selected, tier, matrix, files, reasons };
const testSelection = planTestSelection(files, event, tier);
const plan = { ...selected, tier, matrix, files, reasons, testSelection };
if (process.env.GITHUB_OUTPUT)
appendFileSync(
process.env.GITHUB_OUTPUT,
Object.entries({ ...selected, tier, matrix: JSON.stringify(matrix) })
Object.entries({
...selected,
tier,
matrix: JSON.stringify(matrix),
test_selection: JSON.stringify(testSelection),
})
.map(([key, value]) => `${key}=${value}\n`)
.join(""),
);
+176
View File
@@ -0,0 +1,176 @@
import { execFileSync } from "node:child_process";
import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const ARTIFACT_NAME = "ci-full-qualification-v1";
const MAX_AGE_MS = 24 * 60 * 60 * 1000;
const positiveId = (value) =>
(typeof value === "string" || typeof value === "number") &&
/^[1-9]\d*$/.test(String(value)) &&
Number.isSafeInteger(Number(value));
const commitSha = (value) =>
typeof value === "string" && /^[0-9a-f]{40}$/.test(value);
/** Record only after the required gate has verified every full-tier obligation. */
export function recordQualification(directory) {
const sourceSha = process.env.GITHUB_SHA;
const runId = process.env.GITHUB_RUN_ID;
const runAttempt = process.env.GITHUB_RUN_ATTEMPT;
const repository = process.env.GITHUB_REPOSITORY;
if (
!commitSha(sourceSha) ||
!positiveId(runId) ||
!positiveId(runAttempt) ||
!repository
)
throw new Error(
"Full qualification receipt requires source and workflow identity",
);
const checkoutSha = execFileSync("git", ["rev-parse", "HEAD"], {
encoding: "utf8",
}).trim();
if (checkoutSha !== sourceSha)
throw new Error("Qualification checkout differs from workflow source");
mkdirSync(directory, { recursive: true });
const path = resolve(directory, "receipt.json");
writeFileSync(
path,
`${JSON.stringify(
{
schemaVersion: 1,
tier: "full",
sourceSha,
repository,
runId: Number(runId),
runAttempt: Number(runAttempt),
qualifiedAt: new Date().toISOString(),
},
null,
2,
)}\n`,
);
if (process.env.GITHUB_OUTPUT)
appendFileSync(
process.env.GITHUB_OUTPUT,
`receipt_name=${ARTIFACT_NAME}\nreceipt_path=${path}\n`,
);
}
function admit() {
if (process.env.CI_FORCE_FULL === "true")
return {
run_full: true,
reason: "Forced daily runner and dependency refresh",
};
const repository = process.env.CI_REPOSITORY;
const repositoryId = Number(process.env.CI_REPOSITORY_ID);
const branch = process.env.CI_DEFAULT_BRANCH;
const head = process.env.HEAD_SHA;
if (!repository || !positiveId(repositoryId) || !branch || !commitSha(head))
return {
run_full: true,
reason: "Qualification lookup identity is incomplete",
};
const now = Date.now();
const deadline = now + 20_000;
const api = (...arguments_) => {
const remaining = deadline - Date.now();
if (remaining <= 0)
throw new Error("Qualification lookup budget exhausted");
return JSON.parse(
execFileSync("gh", ["api", "--method", "GET", ...arguments_], {
encoding: "utf8",
timeout: Math.min(15_000, remaining),
maxBuffer: 8 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
}),
);
};
try {
const { artifacts } = api(
`repos/${repository}/actions/artifacts`,
"-f",
`name=${ARTIFACT_NAME}`,
"-F",
"per_page=100",
);
if (!Array.isArray(artifacts))
throw new Error("Missing qualification artifact inventory");
const candidates = artifacts.filter((artifact) => {
const lineage = artifact?.workflow_run;
const age = now - Date.parse(artifact?.created_at);
return (
artifact?.name === ARTIFACT_NAME &&
artifact?.expired === false &&
typeof artifact?.created_at === "string" &&
Number.isFinite(age) &&
age >= 0 &&
age < MAX_AGE_MS &&
positiveId(lineage?.id) &&
String(lineage.id) !== process.env.GITHUB_RUN_ID &&
lineage?.repository_id === repositoryId &&
lineage?.head_repository_id === repositoryId &&
lineage?.head_branch === branch &&
lineage?.head_sha === head
);
});
// Artifact metadata narrows the search before run lookups. Never scan an
// unbounded history or treat a missing receipt as a successful qualification.
for (const artifact of candidates.slice(0, 3)) {
const run = api(
`repos/${repository}/actions/runs/${artifact.workflow_run.id}`,
);
// An old run can upload a new artifact when only its gate is rerun.
// Bound the original run age as well so retries cannot renew stale proof.
const runAge = Date.now() - Date.parse(run.created_at);
if (
typeof run.created_at === "string" &&
Number.isFinite(runAge) &&
runAge >= 0 &&
runAge < MAX_AGE_MS &&
run.id === artifact.workflow_run.id &&
run.status === "completed" &&
run.conclusion === "success" &&
run.path === ".github/workflows/ci.yml" &&
["schedule", "workflow_dispatch"].includes(run.event) &&
run.head_sha === head &&
run.head_branch === branch &&
run.head_repository?.full_name === repository
) {
return {
run_full: false,
run_id: run.id,
reason: `Reusing full qualification from run ${run.id}; no fresh provider observation`,
};
}
}
return {
run_full: true,
reason: "No trusted full qualification for this SHA within 24 hours",
};
} catch {
return {
run_full: true,
reason: "Qualification lookup unavailable; running full validation",
};
}
}
if (resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) {
const decision = admit();
if (process.env.GITHUB_OUTPUT)
appendFileSync(
process.env.GITHUB_OUTPUT,
Object.entries(decision)
.map(([key, value]) => `${key}=${value}\n`)
.join(""),
);
if (process.env.GITHUB_STEP_SUMMARY)
appendFileSync(
process.env.GITHUB_STEP_SUMMARY,
`### Scheduled qualification\n\n${decision.reason}.\n`,
);
console.log(JSON.stringify(decision));
}
+40 -6
View File
@@ -1,18 +1,46 @@
import { CI_FLAGS } from "./scopes.mjs";
import { recordQualification } from "./qualification.mjs";
const jobs = JSON.parse(process.env.CI_JOBS);
const outputs = jobs.changes.outputs;
const required = ["changes", "static", "test"];
const outputs = jobs?.changes?.outputs;
if (!["fast", "full", "reused"].includes(outputs?.tier))
throw new Error(
"CI classification requires a valid tier: fast, full, or reused",
);
const invalidFlags = CI_FLAGS.filter(
(flag) => outputs[flag] !== "true" && outputs[flag] !== "false",
);
if (invalidFlags.length)
throw new Error(
`CI classification requires explicit true/false scope outputs: ${invalidFlags.join(", ")}`,
);
const selectedScopes = CI_FLAGS.filter((flag) => outputs[flag] === "true");
const reused = outputs.tier === "reused";
if (
reused &&
(process.env.GITHUB_EVENT_NAME !== "schedule" ||
!/^[1-9]\d*$/.test(outputs.qualification_run_id ?? "") ||
!Number.isSafeInteger(Number(outputs.qualification_run_id)) ||
selectedScopes.length !== 0)
)
throw new Error(
"Reused qualification requires a scheduled admission receipt and no selected lanes",
);
if (outputs.tier === "full" && selectedScopes.length !== CI_FLAGS.length)
throw new Error("Full qualification requires every canonical CI scope");
const required = reused ? ["changes"] : ["changes", "static", "test"];
const lanes = {
code: ["build", "test-shard", "sqlite-runtime"],
code: ["build", "test-shard"],
runtime: ["windows-native-build", "package-e2e"],
package: ["windows-curated"],
sqlite: ["sqlite-runtime"],
apple: ["apple-artifacts"],
fixtures: ["conformance-fixtures"],
readiness: ["reconstruction-readiness"],
docs: ["docs"],
workflows: ["static"],
};
for (const [scope, value] of Object.entries(outputs)) {
if (value === "true") required.push(...(lanes[scope] ?? [scope]));
}
for (const scope of selectedScopes) required.push(...(lanes[scope] ?? [scope]));
const failed = [...new Set(required)].filter(
(name) => jobs[name]?.result !== "success",
);
@@ -23,3 +51,9 @@ for (const [name, job] of Object.entries(jobs)) {
if (failed.length)
throw new Error(`CI lanes did not pass: ${failed.join(", ")}`);
console.log("Every selected CI lane passed");
if (reused)
console.log(
`Qualification reused from run ${outputs.qualification_run_id}; no fresh provider observation`,
);
if (outputs.tier === "full" && process.env.CI_QUALIFICATION_DIRECTORY)
recordQualification(process.env.CI_QUALIFICATION_DIRECTORY);
+70
View File
@@ -55,7 +55,12 @@ export const SCOPES = {
"tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts",
"tests/boundary/filesystem/setupUnavailableProvider.test.ts",
"tests/boundary/filesystem/windowsNpxRegistration.test.ts",
"tests/boundary/android/workspaceGuardianRetry.test.ts",
"src/android/JadxWorkspaceRoot.ts",
"scripts/rea-android-workspace-guardian.mjs",
"tests/acceptance/setup/homeDirectoryFallback.test.ts",
"tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts",
"src/domain/javascript/javascriptRuntimeStaticCandidates.ts",
"tests/boundary/filesystem/webScriptManifestPaths.test.ts",
"tests/boundary/process/ProcessEvidence.host.test.ts",
"tests/acceptance/webModuleCli.test.ts",
@@ -85,6 +90,55 @@ export const SCOPES = {
"tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts",
"tests/boundary/process/sourceMapUncHosts.test.ts",
],
// Native SQLite runtime compatibility for the producer, public adapters,
// fixtures, shared lifecycle, and the package script that defines its suite.
sqlite: [
"src/sqlite/**",
"src/domain/sqlite/**",
"src/contracts/sqlite/**",
"src/application/sqlite/**",
"src/composition/sqlite.ts",
"src/cli/sqliteCommands.ts",
"src/server/registerSqliteTools.ts",
"src/application/DirectAnalysisStatus*.ts",
"src/application/CapabilityInventory*.ts",
"src/application/AnalysisProvider*.ts",
"src/contracts/toolContracts.ts",
"src/contracts/toolContractTypes.ts",
"src/contracts/toolEffects*.ts",
"src/contracts/toolOutputSchemaPrimitives.ts",
"src/domain/analysisError*.ts",
"src/domain/evidence*.ts",
"src/domain/inputIssueProjection*.ts",
"src/domain/jsonSerialization*.ts",
"src/domain/jsonValue*.ts",
"src/domain/localPath*.ts",
"src/domain/providerAdapterError*.ts",
"src/domain/providerCleanupError*.ts",
"src/domain/result*.ts",
"src/process/OwnedCommand*.ts",
"src/process/ProviderProcess*.ts",
"src/process/PrivateRuntimeRoot*.ts",
"src/artifacts/ArtifactReader*.ts",
"src/artifacts/ArtifactResourceScope*.ts",
"src/artifacts/ArtifactPaths*.ts",
"src/artifacts/readStableArtifact*.ts",
"src/filesystem/OwnedFileHandle*.ts",
"src/server/EvidenceMcpServer*.ts",
"src/server/analysisAdmission*.ts",
"src/server/contractToolHandler*.ts",
"src/server/toolRegistrationOptions*.ts",
"src/server/createServer*.ts",
"src/cli.ts",
"src/cliCommandNames.ts",
"src/cliLogging.ts",
"src/cli/commandCancellation.ts",
"tests/boundary/sqlite/**",
"tests/fixtures/sqlite/**",
"tests/acceptance/sqliteDatabaseCli.test.ts",
"tests/boundary/mcp/serverLifecycleMcp.test.ts",
".github/workflows/sqlite-runtime.yml",
],
browser: [
".github/workflows/real-browser.yml",
"scripts/verify-real-browser.mjs",
@@ -167,6 +221,7 @@ export const SCOPES = {
"src/artifacts/readStableArtifact*.ts",
"src/artifacts/ArtifactReader.ts",
"scripts/verify-evm-interface.mjs",
"scripts/verify-solidity-metadata.mjs",
"scripts/fixtures/evm-interface.sol",
"third_party/evmole/**",
],
@@ -300,6 +355,7 @@ export const SCOPES = {
nativeaot: [
"bridge/nativeaot/**",
"scripts/generate-nativeaot-fixtures.mjs",
"scripts/lib/nativeaot-build.*",
"tests/fixtures/nativeaot/**",
".github/workflows/nativeaot-fixtures.yml",
],
@@ -312,6 +368,20 @@ export const SCOPES = {
],
};
export const CI_FLAGS = [
...new Set([
"code",
"runtime",
"package",
"apple",
"fixtures",
"readiness",
"docs",
"workflows",
...Object.keys(SCOPES),
]),
];
// Verifier entrypoints that only manual, licensed, or self-hosted workflows
// run. No selected CI lane executes them, so their edits need only the static
// checks and the source-test suite. Shared helpers under scripts/lib remain
+114
View File
@@ -0,0 +1,114 @@
import { execFileSync } from "node:child_process";
import { appendFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const ARTIFACT_NAME = "test-timings";
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
const MAX_CANDIDATES = 5;
const positiveId = (value) =>
(typeof value === "string" || typeof value === "number") &&
/^[1-9]\d*$/.test(String(value)) &&
Number.isSafeInteger(Number(value));
function findTimingRun({ repository, branch, repositoryId, now = Date.now() }) {
if (
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository ?? "") ||
!branch ||
!positiveId(repositoryId)
)
return "";
const deadline = Date.now() + 20_000;
const api = (...arguments_) => {
const remaining = deadline - Date.now();
if (remaining <= 0)
throw new Error("Timing history lookup budget exhausted");
return JSON.parse(
execFileSync("gh", ["api", "--method", "GET", ...arguments_], {
encoding: "utf8",
timeout: Math.min(10_000, remaining),
maxBuffer: 4 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
}),
);
};
const { artifacts } = api(
`repos/${repository}/actions/artifacts`,
"-f",
`name=${ARTIFACT_NAME}`,
"-F",
"per_page=100",
);
if (!Array.isArray(artifacts))
throw new Error("Missing timing artifact inventory");
const candidates = artifacts
.filter((artifact) => {
const lineage = artifact?.workflow_run;
const age = now - Date.parse(artifact?.created_at);
return (
artifact?.name === ARTIFACT_NAME &&
artifact?.expired === false &&
typeof artifact?.created_at === "string" &&
Number.isFinite(age) &&
age >= 0 &&
age < MAX_AGE_MS &&
positiveId(lineage?.id) &&
positiveId(lineage?.repository_id) &&
positiveId(lineage?.head_repository_id) &&
lineage.repository_id === lineage.head_repository_id &&
lineage.repository_id === Number(repositoryId) &&
lineage.head_branch === branch
);
})
.sort(
(left, right) =>
Date.parse(right.created_at) - Date.parse(left.created_at),
)
.slice(0, MAX_CANDIDATES);
for (const artifact of candidates) {
const run = api(
`repos/${repository}/actions/runs/${artifact.workflow_run.id}`,
);
const runAge = now - Date.parse(run?.created_at);
if (
typeof run?.created_at === "string" &&
Number.isFinite(runAge) &&
runAge >= 0 &&
runAge < MAX_AGE_MS &&
run.id === artifact.workflow_run.id &&
run.status === "completed" &&
run.conclusion === "success" &&
run.path === ".github/workflows/ci.yml" &&
["schedule", "workflow_dispatch"].includes(run.event) &&
run.head_branch === branch &&
run.head_repository?.full_name === repository
)
return String(run.id);
}
return "";
}
function write(runId) {
if (process.env.GITHUB_OUTPUT)
appendFileSync(process.env.GITHUB_OUTPUT, `run-id=${runId}\n`);
console.log(JSON.stringify({ run_id: runId }));
}
if (
process.argv[1] &&
fileURLToPath(import.meta.url) === resolve(process.argv[1])
) {
let runId = "";
try {
runId = findTimingRun({
repository: process.env.CI_REPOSITORY,
branch: process.env.CI_DEFAULT_BRANCH,
repositoryId: process.env.CI_REPOSITORY_ID,
});
} catch {
// History improves shard ordering but must never block CI.
}
write(runId);
}
+69 -25
View File
@@ -4,26 +4,33 @@ import { isAbsolute, join, relative, resolve, sep } from "node:path";
import { z } from "zod";
import { compareTestSelection } from "./test-selection.mjs";
const root = process.cwd();
const historyDirectory = join(root, ".cache/ci-test-history");
const historyPath = join(historyDirectory, "test-results.json");
const selectionDirectory = join(root, ".cache/ci-test-selection");
const selectionPath = join(selectionDirectory, "report.json");
// Never let a failed or partial rerun leave a previous successful history file.
await rm(historyPath, { force: true });
await rm(selectionPath, { force: true });
const report = z
.object({
version: z.literal(1),
success: z.boolean(),
numFailedTests: z.number().int().nonnegative(),
testResults: z.array(
reason: z.string().optional(),
unhandledErrors: z.number().int().nonnegative().optional(),
files: z.array(
z.object({
name: z.string().min(1),
startTime: z.number().finite(),
endTime: z.number().finite(),
durationMs: z.number().finite().nonnegative(),
state: z.string().optional(),
}),
),
})
.parse(JSON.parse(await readFile("test-results.json", "utf8")));
.parse(JSON.parse(await readFile("test-timings.json", "utf8")));
const files = report.testResults.map(({ name, startTime, endTime }) => {
const files = report.files.map(({ name, durationMs, state }) => {
const path = relative(root, resolve(root, name));
if (
path === "" ||
@@ -32,27 +39,47 @@ const files = report.testResults.map(({ name, startTime, endTime }) => {
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 };
return { name: path.split(sep).join("/"), durationMs, state };
});
const complete = process.env.SHARD_RESULT === "success";
const passed =
complete &&
process.env.MERGE_RESULT === "success" &&
report.success &&
report.numFailedTests === 0;
complete && process.env.MERGE_RESULT === "success" && report.success;
// Successful shards and merged reports must account for the complete portable
// inventory. Coverage thresholds alone cannot prove that every file ran.
if (passed && process.env.CI_VERIFY_TEST_INVENTORY === "true") {
const expected = execFileSync(
"git",
["ls-files", "-z", "--", "src", "tests"],
{ encoding: "utf8", maxBuffer: 8 * 1024 * 1024 },
)
.split("\0")
.filter((path) => path.endsWith(".test.ts"));
const verifyInventory = process.env.CI_VERIFY_TEST_INVENTORY === "true";
const expected =
verifyInventory || process.env.CI_TEST_SELECTION
? execFileSync("git", ["ls-files", "-z", "--", "src", "tests"], {
encoding: "utf8",
maxBuffer: 8 * 1024 * 1024,
})
.split("\0")
.filter((path) => path.endsWith(".test.ts"))
: [];
let selection;
if (process.env.CI_TEST_SELECTION) {
const comparison = compareTestSelection(
JSON.parse(process.env.CI_TEST_SELECTION),
expected,
files,
);
selection = {
...comparison,
complete:
comparison.complete &&
report.unhandledErrors === 0 &&
["passed", "failed"].includes(report.reason) &&
["success", "failure"].includes(process.env.SHARD_RESULT) &&
["success", "failure"].includes(process.env.MERGE_RESULT),
executionSha: process.env.GITHUB_SHA ?? null,
prHeadSha: process.env.CI_PR_HEAD_SHA ?? null,
runId: process.env.GITHUB_RUN_ID ?? null,
runAttempt: process.env.GITHUB_RUN_ATTEMPT ?? null,
};
await mkdir(selectionDirectory, { recursive: true });
await writeFile(selectionPath, JSON.stringify(selection));
}
if (passed && verifyInventory) {
const actual = files.map(({ name }) => name);
const expectedSet = new Set(expected);
const actualSet = new Set(actual);
@@ -66,18 +93,31 @@ if (passed && process.env.CI_VERIFY_TEST_INVENTORY === "true") {
const summary = [
complete ? "## Merged test diagnostics" : "## Partial test diagnostics",
"",
...(selection
? [
"### Observational PR test selection",
"",
`Plan: ${selection.plan.mode}. Proposed files: ${selection.selected.length}; omitted: ${selection.omitted.length}.`,
`Full-run comparison: ${selection.complete ? "complete" : "incomplete"}; failures outside proposed selection: ${selection.missedFailures.length}.`,
"The complete suite still ran. This report cannot enable omissions or qualify a revision. Skipped, missing, and interrupted observations do not establish parity.",
"",
]
: []),
`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.",
"",
"Durations include tests and hooks, worker preparation, environment, imports, and setup. They are scheduling weights, not summed workflow wall time.",
"",
"| Slowest file | Duration (ms) |",
"| --- | ---: |",
...files
.toSorted((left, right) => right.endTime - left.endTime)
.toSorted((left, right) => right.durationMs - left.durationMs)
.slice(0, 20)
.map(
({ name, endTime }) => `| ${name.replaceAll("|", "\\|")} | ${endTime} |`,
({ name, durationMs }) =>
`| ${name.replaceAll("|", "\\|")} | ${durationMs} |`,
),
"",
].join("\n");
@@ -89,6 +129,10 @@ if (passed) {
await mkdir(historyDirectory, { recursive: true });
await writeFile(
historyPath,
JSON.stringify({ success: true, numFailedTests: 0, testResults: files }),
JSON.stringify({
version: 1,
success: true,
files: files.map(({ name, durationMs }) => ({ name, durationMs })),
}),
);
}
+35
View File
@@ -0,0 +1,35 @@
export interface TestSelectionPlan {
readonly version: 1;
readonly mode: "full" | "candidate";
readonly owners: readonly string[];
readonly changedTests: readonly string[];
readonly changedPaths: readonly string[];
readonly reason: string;
}
export function planTestSelection(
files: readonly string[],
event: string,
tier: string,
): TestSelectionPlan;
export function compareTestSelection(
plan: unknown,
inventory: readonly string[],
results: readonly {
readonly name: string;
readonly durationMs: number;
readonly state?: string;
}[],
): {
readonly version: 1;
readonly observational: true;
readonly plan: TestSelectionPlan;
readonly selected: readonly string[];
readonly omitted: readonly string[];
readonly missing: readonly string[];
readonly unexpected: readonly string[];
readonly duplicateFiles: number;
readonly unobserved: readonly string[];
readonly missedFailures: readonly string[];
readonly complete: boolean;
readonly omittedDurationMs: number;
};
+151
View File
@@ -0,0 +1,151 @@
import { matchesGlob } from "node:path";
// Candidate ownership only: these leaf adapters and their producer tests can be
// compared with full-suite outcomes. This table never changes test execution.
// Shared application/domain code and dynamic CLI/MCP consumers are not leaf owners.
const OWNERS = {
adb: ["src/adb/**", "tests/boundary/providers/adb/**"],
apktool: ["src/apktool/**"],
dotnet: ["src/dotnet/**"],
evm: ["src/evm/**", "tests/boundary/evm/**"],
firmware: ["src/firmware/**", "tests/boundary/firmware/**"],
flutter: ["src/flutter/**"],
ghidra: ["src/ghidra/**", "tests/boundary/providers/ghidra/**"],
hopper: ["src/hopper/**", "tests/boundary/providers/hopper/**"],
ida: ["src/ida/**", "tests/boundary/providers/ida/**"],
jeb: ["src/jeb/**"],
pcap: ["src/pcap/**", "tests/boundary/pcap/**"],
sqlite: ["src/sqlite/**", "tests/boundary/sqlite/**"],
wasm: ["src/wasm/**"],
};
const ownersOf = (path) =>
Object.entries(OWNERS)
.filter(([, patterns]) => patterns.some((glob) => matchesGlob(path, glob)))
.map(([owner]) => owner);
const validPath = (path) =>
typeof path === "string" &&
/^(src|tests)\//.test(path) &&
!/[\\\0\r\n]/.test(path) &&
!path.split("/").some((part) => ["", ".", ".."].includes(part));
const full = (reason, changedPaths = []) => ({
version: 1,
mode: "full",
owners: [],
changedTests: [],
changedPaths,
reason,
});
/** Plan an observational PR subset from the classifier's existing Git diff. */
export function planTestSelection(files, event, tier) {
if (event !== "pull_request" || tier !== "fast")
return full("Only ordinary PRs are candidates", files);
if (!files.length) return full("No changed paths available");
const owners = new Set();
const changedTests = [];
for (const path of files) {
if (!validPath(path))
return full(`Shared or unknown input: ${path}`, files);
const matches = ownersOf(path);
if (!matches.length) return full(`Shared or unknown input: ${path}`, files);
for (const owner of matches) owners.add(owner);
if (path.endsWith(".test.ts")) changedTests.push(path);
}
return {
version: 1,
mode: "candidate",
owners: [...owners].sort(),
changedTests: [...new Set(changedTests)].sort(),
changedPaths: files,
reason:
"Observe affected leaf owners; retain integration and unowned tests",
};
}
/** Compare a proposal with the full run; missing observations never prove parity. */
export function compareTestSelection(plan, inventory, results) {
if (
plan?.version !== 1 ||
!["full", "candidate"].includes(plan.mode) ||
!Array.isArray(plan.owners) ||
plan.owners.some((owner) => !Object.hasOwn(OWNERS, owner)) ||
!Array.isArray(plan.changedTests) ||
!Array.isArray(plan.changedPaths) ||
plan.changedPaths.some((path) => typeof path !== "string") ||
plan.changedTests.some(
(path) => !validPath(path) || !path.endsWith(".test.ts"),
) ||
typeof plan.reason !== "string" ||
(plan.mode === "candidate" && !plan.owners.length) ||
(plan.mode === "full" && (plan.owners.length || plan.changedTests.length))
)
throw new Error("Invalid observational test selection plan");
const expected = new Set(inventory);
if (!expected.size || expected.size !== inventory.length)
throw new Error("Test selection requires a nonempty unique inventory");
const ownersByFile = new Map(
plan.mode === "candidate"
? inventory.map((path) => [path, ownersOf(path)])
: [],
);
// Removed/renamed tests have no current executable identity. Keep all tests.
const removed = plan.changedTests.filter((path) => !expected.has(path));
const missingOwners = plan.owners.filter(
(owner) =>
![...ownersByFile.values()].some((owners) => owners.includes(owner)),
);
const effective = removed.length
? full(
`Changed tests absent from current inventory: ${removed.join(", ")}`,
plan.changedPaths,
)
: missingOwners.length
? full(
`Owners without executable tests: ${missingOwners.join(", ")}`,
plan.changedPaths,
)
: plan;
const selected = [];
const omitted = [];
for (const path of inventory.toSorted()) {
const owners = ownersByFile.get(path) ?? [];
const keep =
effective.mode === "full" ||
!owners.length ||
effective.changedTests.includes(path) ||
owners.some((owner) => effective.owners.includes(owner));
(keep ? selected : omitted).push(path);
}
const actual = new Set(results.map((file) => file.name));
const missing = inventory.filter((path) => !actual.has(path));
const unexpected = [...actual].filter((path) => !expected.has(path));
const duplicateFiles = results.length - actual.size;
const unobserved = results
.filter((file) => !["passed", "failed"].includes(file.state))
.map((file) => file.name);
const omittedSet = new Set(omitted);
const missedFailures = results
.filter((file) => omittedSet.has(file.name) && file.state === "failed")
.map((file) => file.name);
return {
version: 1,
observational: true,
plan: effective,
selected,
omitted,
missing,
unexpected,
duplicateFiles,
unobserved,
missedFailures,
complete:
!missing.length &&
!unexpected.length &&
!duplicateFiles &&
!unobserved.length,
// Phase totals are scheduling weights, not an estimate of wall-time savings.
omittedDurationMs: results
.filter((file) => omittedSet.has(file.name))
.reduce((sum, file) => sum + file.durationMs, 0),
};
}
+43
View File
@@ -0,0 +1,43 @@
import { writeFile } from "node:fs/promises";
/** @implements {import("vitest/node").Reporter} */
export default class TestTimingReporter {
/**
* @param {ReadonlyArray<import("vitest/node").TestModule>} modules
* @param {ReadonlyArray<import("vitest/node").SerializedError>} errors
* @param {import("vitest/node").TestRunEndReason} reason
*/
async onTestRunEnd(modules, errors, reason) {
const files = modules.map((module) => {
const diagnostic = module.diagnostic();
// These disjoint phases match Vitest's per-file wall-time accounting.
// Import timings already include transform waits; do not add them again.
const phases = {
execution: diagnostic.duration,
preparation: diagnostic.prepareDuration,
environment: diagnostic.environmentSetupDuration,
imports: diagnostic.collectDuration,
setup: diagnostic.setupDuration,
};
return {
name: module.moduleId,
state: module.state(),
durationMs: Object.values(phases).reduce(
(total, value) => total + value,
0,
),
phases,
};
});
await writeFile(
"test-timings.json",
JSON.stringify({
version: 1,
success: reason === "passed" && errors.length === 0,
reason,
unhandledErrors: errors.length,
files,
}),
);
}
}
+12 -17
View File
@@ -4,9 +4,14 @@ import { createHash } from "node:crypto";
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
buildNativeAotFixture,
nativeAotCommandError,
supportsNativeAotFixtureHost,
} from "./lib/nativeaot-build.mjs";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
if (process.arch !== "x64" || !["linux", "win32"].includes(process.platform))
if (!supportsNativeAotFixtureHost(process.platform, process.arch))
throw new Error(
"NativeAOT fixture lane requires Linux or Windows x64; it does not cross-compile NativeAOT.",
);
@@ -40,9 +45,7 @@ const run = (command, args) => {
maxBuffer: 8 * 1024 * 1024,
});
if (result.error || result.status !== 0)
throw new Error(
`NativeAOT fixture lane requires/action failed ${command}: ${result.error?.message ?? ""}\n${result.stdout ?? ""}${result.stderr ?? ""}`,
);
throw nativeAotCommandError(command, args[0], result);
return result.stdout;
};
const dotnet = process.env.REA_NATIVEAOT_DOTNET ?? "dotnet";
@@ -57,20 +60,12 @@ if (process.platform === "linux") {
run("clang", ["--version"]);
}
const map = join(output, "NativeAotFixture.map");
run(dotnet, [
"publish",
join(root, "tests/fixtures/nativeaot/NativeAotFixture.csproj"),
"-r",
await buildNativeAotFixture(run, {
dotnet,
project: join(root, "tests/fixtures/nativeaot/NativeAotFixture.csproj"),
runtime,
"-c",
"Release",
"-o",
join(output, "symbols"),
"--maxcpucount:1",
`-p:BaseIntermediateOutputPath=${join(output, "obj")}/`,
`-p:BaseOutputPath=${join(output, "bin")}/`,
`-p:NativeAotMapPath=${map}`,
]);
output,
});
const name =
process.platform === "win32" ? "NativeAotFixture.exe" : "NativeAotFixture";
const binary = join(output, "symbols", name);
+9
View File
@@ -0,0 +1,9 @@
export function assertFeatureTrace(
trace: unknown,
procedure: { readonly address: string },
): void;
export function findMappedNonFunction(
strings: readonly { readonly address: string }[],
resolveAt: (address: string) => Promise<unknown>,
): Promise<string | undefined>;
+52
View File
@@ -0,0 +1,52 @@
import assert from "node:assert/strict";
const NEGATIVE_REASONS = new Set(["not_in_procedure", "outside_segments"]);
/**
* Assert the `trace_feature` result of the real IDA lane for one selected
* procedure. The query is a substring search, so the trace may also match other
* procedures and strings; each reference is checked against its own matched
* target, and a source outside every function (a data reference) is valid.
*/
export function assertFeatureTrace(trace, procedure) {
assert.ok(
trace.matches.some(
({ type, address }) =>
type === "procedure" && address === procedure.address,
),
"The trace omitted the selected procedure's match.",
);
assert.equal(typeof trace.truncated, "boolean");
const matched = new Set(trace.matches.map(({ address }) => address));
for (const reference of trace.references) {
assert.ok(
matched.has(reference.target_address),
`A reference targets ${reference.target_address}, which is not a match.`,
);
const { containing_procedure: containing } = reference;
if (containing.found)
assert.equal(typeof containing.procedure.address, "string");
else assert.ok(NEGATIVE_REASONS.has(containing.reason));
}
}
/**
* Find a string that IDA maps outside every function. A string inside a
* function chunk (inline data in code) is valid and skipped, so the strings are
* tried in order. Any other answer must be "not in a procedure": IDA lists only
* mapped strings, so an unmapped answer is a containment defect and fails the
* lane. Returns the address, or undefined when every string is inside a function.
*/
export async function findMappedNonFunction(strings, resolveAt) {
for (const { address } of strings) {
const resolved = await resolveAt(address);
if (resolved.found) continue;
assert.equal(
resolved.reason,
"not_in_procedure",
`The listed string at ${address} resolved as ${resolved.reason}.`,
);
return address;
}
return undefined;
}
+25
View File
@@ -0,0 +1,25 @@
export function supportsNativeAotFixtureHost(
platform: string,
architecture: string,
): boolean;
export function buildNativeAotFixture(
run: (command: string, args: string[]) => unknown,
options: {
dotnet: string;
project: string;
runtime: string;
output: string;
},
): Promise<void>;
export function nativeAotCommandError(
command: string,
operation: string,
result: {
error?: { message: string };
status: number | null;
stdout?: string | null;
stderr?: string | null;
},
): Error;
+60
View File
@@ -0,0 +1,60 @@
import { join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
/** NativeAOT fixtures use native toolchains, not cross-compilation. */
export function supportsNativeAotFixtureHost(platform, architecture) {
return architecture === "x64" && ["linux", "win32"].includes(platform);
}
/** Keep network recovery separate from compilation and preserve restore failures. */
export async function buildNativeAotFixture(run, options) {
const { dotnet, project, runtime, output } = options;
const common = [
project,
"-r",
runtime,
"--maxcpucount:1",
"-p:Configuration=Release",
`-p:BaseIntermediateOutputPath=${join(output, "obj")}/`,
`-p:BaseOutputPath=${join(output, "bin")}/`,
`-p:NativeAotMapPath=${join(output, "NativeAotFixture.map")}`,
];
for (let attempt = 1; attempt <= 2; attempt++) {
console.error(`NativeAOT dependency restore: attempt ${attempt}/2`);
try {
run(dotnet, ["restore", ...common, "--verbosity", "normal"]);
break;
} catch (cause) {
if (attempt === 2)
throw new Error(
"NativeAOT dependency restore failed after two attempts",
{
cause,
},
);
// Child diagnostics can contain authenticated feed URLs; keep retry logs static.
console.error(
"NativeAOT dependency restore failed; retrying in 1 second",
);
await delay(1_000);
}
}
console.error("NativeAOT compilation (dependencies already restored)");
run(dotnet, [
"publish",
...common,
"--no-restore",
"-o",
join(output, "symbols"),
]);
}
/** Sanitize child diagnostics before any retry, cause chain, or uncaught output. */
export function nativeAotCommandError(command, operation, result) {
const diagnostic = `NativeAOT fixture lane requires/action failed ${command} ${operation}: ${result.error?.message ?? `exit ${result.status}`}\n${result.stdout ?? ""}${result.stderr ?? ""}`;
// Only URL authority userinfo is authentication; preserve host, path, query,
// and unrelated diagnostic values. Never retain the raw result as a cause.
return new Error(
diagnostic.replace(/\b(https?:\/\/)[^\\\s/?#]*@/giu, "$1[redacted]@"),
);
}
+264
View File
@@ -0,0 +1,264 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import {
mkdir,
mkdtemp,
readFile,
readdir,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { parseEvidence } from "../../dist/domain/evidence.js";
import {
requireMcpOperationResult,
requireMcpToolError,
mcpTextValue,
} from "./mcp-verifier-results.mjs";
/** Exercise file coordinates through public CLI/MCP using the source-owned PE fixture. */
export async function verifyGhidraFileOffsets(
targetPath,
entrypoint,
procedure,
) {
const original = await readFile(targetPath);
const workspace = await mkdtemp(join(tmpdir(), "rea-file-offsets-"));
const runtime = join(workspace, "runtime");
await mkdir(runtime);
const env = {
...process.env,
TEMP: runtime,
TMP: runtime,
TMPDIR: runtime,
REA_ANALYSIS_PROVIDER: "ghidra",
REA_LOG_LEVEL: "silent",
};
const client = new Client({ name: "rea-file-offsets", version: "1" });
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env,
stderr: "pipe",
});
let connected = false;
let complete = false;
let primaryFailure;
const call = async (name, arguments_ = {}) => {
const result = await client.callTool(
{ name, arguments: arguments_ },
{ timeout: 240_000 },
);
const projected = requireMcpOperationResult(result, name);
return ["open_binary", "binary_session", "close_binary"].includes(name)
? projected
: (result.structuredContent ?? JSON.parse(mcpTextValue(result)));
};
try {
await client.connect(transport);
connected = true;
await call("open_binary", { path: targetPath, provider_id: "ghidra" });
const session = await call("binary_session");
const mapping = parseEvidence(
await call("address_to_file_offset", { address: procedure }),
);
const offset = mapping.normalized_result.file_offset;
const coordinate = `0x${offset.toString(16)}`;
const expected = parseEvidence(
await call("procedure_pseudo_code", { procedure }),
);
const explicit = parseEvidence(
await call("procedure_pseudo_code", {
procedure: `file-offset:${coordinate}`,
}),
);
const automatic = parseEvidence(
await call("procedure_pseudo_code", { procedure: coordinate }),
);
for (const actual of [explicit, automatic]) {
assert.equal(actual.normalized_result, expected.normalized_result);
assert.equal(actual.raw_result.selection.file_offset, offset);
assert.equal(actual.raw_result.selection.mapped_address, procedure);
assert.ok(
actual.locations.some(
(location) =>
location.kind === "file-offset" && location.offset === offset,
),
);
}
const invalid = await client.callTool({
name: "procedure_pseudo_code",
arguments: { procedure: "file-offset:0x20000000000000" },
});
assert.equal(requireMcpToolError(invalid).code, "invalid_request");
const unmapped = await client.callTool({
name: "procedure_pseudo_code",
arguments: { procedure: "file-offset:0x100000" },
});
assert.ok(
JSON.stringify(requireMcpToolError(unmapped)).includes(
"no loaded source mapping",
),
);
assert.equal(
(await call("binary_session")).analysis_run.run_id,
session.analysis_run.run_id,
);
const image = parseEvidence(await call("inspect_native_load_image"));
const headerMapping = image.normalized_result.observations.mappings.find(
(item) => item.initialized && item.file_offset === 0,
);
assert.ok(
headerMapping,
"Fixture header must have an observed source mapping",
);
const headerOffset = parseEvidence(
await call("address_to_file_offset", { address: headerMapping.start }),
);
assert.equal(headerOffset.normalized_result.file_offset, 0);
for (const selector of ["file-offset:0x0", "0x0"]) {
const missing = requireMcpToolError(
await client.callTool({
name: "procedure_pseudo_code",
arguments: { procedure: selector },
}),
);
assertMissingMappedProcedure(missing, headerMapping.start);
}
assert.equal(
(await call("binary_session")).analysis_run.run_id,
session.analysis_run.run_id,
);
await call("close_binary");
const cli = JSON.parse(
(
await promisify(execFile)(
process.execPath,
[
entrypoint,
"decompile",
targetPath,
`--file-offset=${coordinate}`,
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 240_000, maxBuffer: 16 * 1024 * 1024 },
)
).stdout,
);
assert.equal(cli.normalized_result, explicit.normalized_result);
assert.deepEqual(cli.locations, explicit.locations);
const missingCli = await promisify(execFile)(
process.execPath,
[
entrypoint,
"decompile",
targetPath,
"--file-offset=0x0",
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 240_000, maxBuffer: 16 * 1024 * 1024 },
).then(
() => {
throw new Error(
"Mapped header bytes must not decompile as a procedure",
);
},
(cause) => JSON.parse(cause.stdout),
);
assertMissingMappedProcedure(missingCli, headerMapping.start);
const alias = Buffer.from(original);
const pe = alias.readUInt32LE(0x3c);
if (alias.readUInt16LE(pe + 6) >= 2) {
const secondSection = pe + 24 + alias.readUInt16LE(pe + 20) + 40;
// Both sections now carry the same original bytes at distinct virtual addresses.
alias.writeUInt32LE(0x200, secondSection + 20);
const ambiguousPath = join(workspace, "ambiguous.exe");
await writeFile(ambiguousPath, alias);
await call("open_binary", { path: ambiguousPath, provider_id: "ghidra" });
const ambiguous = await client.callTool(
{
name: "procedure_pseudo_code",
arguments: { procedure: `file-offset:${coordinate}` },
},
{ timeout: 240_000 },
);
assert.ok(
JSON.stringify(requireMcpToolError(ambiguous)).includes(
"multiple loaded mappings",
),
);
await call("close_binary");
}
assert.deepEqual(await readFile(targetPath), original);
await client.close();
connected = false;
assert.deepEqual(await readdir(runtime), []);
complete = true;
return {
cli_mcp_parity: true,
explicit_and_numeric_offsets: true,
invalid_and_unmapped_offsets: true,
mapped_offset_without_procedure: true,
ambiguous_mappings:
alias.readUInt16LE(pe + 6) >= 2
? "verified"
: "unavailable-single-section",
source_unchanged: true,
cleanup: "complete",
};
} catch (cause) {
primaryFailure = cause;
throw cause;
} finally {
const cleanupFailures = [];
if (connected) {
try {
await call("close_binary");
} catch (cause) {
cleanupFailures.push(cause);
}
}
try {
await client.close();
} catch (cause) {
cleanupFailures.push(cause);
}
if (cleanupFailures.length > 0) {
process.stderr.write(
`Retained file-offset verifier workspace: ${workspace}\n`,
);
throw new AggregateError(
primaryFailure === undefined
? cleanupFailures
: [primaryFailure, ...cleanupFailures],
"File-offset verifier cleanup failed",
);
}
if (complete) await rm(workspace, { recursive: true, force: true });
else
process.stderr.write(
`Retained file-offset verifier workspace: ${workspace}\n`,
);
}
}
function assertMissingMappedProcedure(error, mappedAddress) {
assert.equal(error.code, "invalid_request");
const message = error.details?.issues?.[0]?.message;
assert.equal(typeof message, "string");
assert.ok(
message.includes("File offset 0x0 maps to analysis address"),
message,
);
assert.ok(message.includes(mappedAddress), message);
assert.ok(message.includes("no procedure starts at or contains"), message);
assert.ok(!message.includes("--file-offset"), message);
}
@@ -0,0 +1,46 @@
#!/usr/bin/env node
import { setTimeout as delay } from "node:timers/promises";
import { WindowsPrivateRuntime } from "../dist/windows/WindowsPrivateRuntime.js";
if (process.platform !== "win32" || process.argv.length !== 3)
throw new Error(
"Android workspace guardian requires Windows and one parent directory",
);
// This process owns the native directory handle. Detached process creation
// keeps it alive when the REA owner is terminated without running JS cleanup.
const runtime = WindowsPrivateRuntime.create(process.argv[2], "rea-android-");
const ready = await new Promise((resolve) => {
process.stdout.once("error", () => resolve(false));
process.stdout.write(
JSON.stringify({
path: runtime.observation.path,
volumeSerial: runtime.observation.volumeSerial,
fileId: runtime.observation.fileId,
privateDacl: runtime.observation.privateDacl,
}) + "\n",
(error) => resolve(error == null),
);
});
// The owner holds the write end until close/death. A failed readiness pipe
// also grants cleanup authority, including owner death during startup.
if (ready) {
for await (const _ of process.stdin) {
// No commands are accepted; only EOF grants cleanup authority.
}
} else {
process.stdin.destroy();
}
// A failed native walk retains the directory identity lease for a later retry.
// Keep this process alive so a transient sharing violation cannot orphan it.
for (;;) {
try {
await runtime.close();
process.exit(0);
} catch {
await delay(1000);
}
}
+20
View File
@@ -225,6 +225,26 @@ try {
for (const mode of ["cli", "mcp"]) {
const report = await inspect(mode, path, encoding);
assert.equal(report.bytecode.hex, hex);
assert.equal(report.bytecode.metadata.status, "decoded");
assert.deepEqual(report.bytecode.metadata.compiler_version, {
representation: "release-bytes",
value: "0.8.30",
});
assert.equal(
report.bytecode.metadata.bytecode_offset +
report.bytecode.metadata.cbor_bytes +
2,
Buffer.from(hex, "hex").length,
);
assert.ok(
report.bytecode.metadata.entries.some(
({ key, value }) =>
key.kind === "text" &&
key.value === "solc" &&
value.kind === "bytes" &&
value.value === "00081e",
),
);
assert.deepEqual(
report.functions
.filter(({ dispatch }) => dispatch === "abi")
+1
View File
@@ -76,6 +76,7 @@ export async function verifyPackageEnvironment({
HOME: home,
USERPROFILE: home,
APPDATA: join(home, "AppData", "Roaming"),
LOCALAPPDATA: join(home, "AppData", "Local"),
CLAUDE_CONFIG_DIR: home,
CODEX_HOME: codexDir,
COPILOT_HOME: join(home, ".copilot"),
+1
View File
@@ -98,6 +98,7 @@ export async function verifyPackageUpdate({
HOME: home,
USERPROFILE: home,
APPDATA: join(home, "AppData", "Roaming"),
LOCALAPPDATA: join(home, "AppData", "Local"),
CLAUDE_CONFIG_DIR: home,
CODEX_HOME: join(home, ".codex"),
COPILOT_HOME: join(home, ".copilot"),
+1
View File
@@ -118,6 +118,7 @@ async function verifyPublishedUpdate({ version: targetVersion, canaryRoot }) {
HOME: home,
USERPROFILE: home,
APPDATA: join(home, "AppData", "Roaming"),
LOCALAPPDATA: join(home, "AppData", "Local"),
CLAUDE_CONFIG_DIR: home,
CODEX_HOME: join(home, ".codex"),
COPILOT_HOME: join(home, ".copilot"),
+30 -17
View File
@@ -8,7 +8,7 @@ import {
realpath,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { join, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { createHash } from "node:crypto";
import { exec } from "./lib/verify-package-core.mjs";
@@ -18,10 +18,6 @@ import {
toolContract,
} from "../dist/contracts/toolContracts.js";
assert(
process.platform !== "win32",
"The Qwen real-client verifier currently requires a POSIX host; native Windows is unverified.",
);
const repo = fileURLToPath(new URL("..", import.meta.url));
const runtimeRoot = process.env.REA_VERIFY_RUNTIME_ROOT ?? repo;
const lab = await mkdtemp(join(tmpdir(), "rea-qwen-client-"));
@@ -32,6 +28,13 @@ assert(
"Usage: node scripts/verify-qwen-client.mjs [chat|call]",
);
const command = process.env.REA_VERIFY_QWEN_COMMAND ?? "qwen";
// npm exposes only qwen.cmd on Windows. Resolve a selected JavaScript entry
// before the verifier switches to its disposable workspace.
const entryPoint = /\.(c|m)?js$/iu.test(command) ? resolve(command) : undefined;
const launch = (args) =>
entryPoint === undefined
? [command, args]
: [process.execPath, [entryPoint, ...args]];
const verifier = createVerifierRun();
const profile = join(lab, "selected profile");
const account = join(lab, "account");
@@ -54,14 +57,18 @@ const baseEnvironment = {
let clientVersion;
try {
clientVersion = (
await exec(command, ["--version"], {
await exec(...launch(["--version"]), {
env: baseEnvironment,
timeout: 30_000,
})
).stdout.trim();
} catch (cause) {
const windowsHint =
process.platform === "win32"
? " On Windows, select node_modules/@qwen-code/qwen-code/cli-entry.js."
: "";
throw new Error(
`Qwen client preflight failed for ${command}; install Qwen Code or set REA_VERIFY_QWEN_COMMAND. ${String(cause)}`,
`Qwen client preflight failed for ${command}; install Qwen Code or set REA_VERIFY_QWEN_COMMAND.${windowsHint} ${String(cause)}`,
);
}
const original = JSON.stringify(
@@ -93,8 +100,8 @@ for (const step of [
assert(
result.plannedActions.every(
(a) =>
a.target.startsWith(profile + "/") ||
a.target.startsWith(sharedSkills + "/"),
a.target.startsWith(profile + sep) ||
a.target.startsWith(sharedSkills + sep),
),
);
await writeFile(
@@ -163,7 +170,14 @@ const fixture = await createOpenAiModelFixture({
skillSeen ||= JSON.stringify(body.messages ?? []).includes(
"reverse-engineer-anything",
);
selectedSkillLoaded ||= JSON.stringify(body.messages ?? []).includes(
const messageText = (body.messages ?? [])
.map((message) =>
typeof message.content === "string"
? message.content
: (message.content ?? []).map((part) => part.text ?? "").join("\n"),
)
.join("\n");
selectedSkillLoaded ||= messageText.includes(
join(sharedSkills, "reverse-engineer-anything"),
);
const toolText = JSON.stringify(
@@ -243,9 +257,7 @@ const fixture = await createOpenAiModelFixture({
const invoke = tools.find((t) => t.name === "tool_call");
const skill = tools.find((t) => t.name === "skill");
const shell = tools.find((t) => t.name === "run_shell_command");
const persisted = toolText.match(
/Full output saved to: ([^\\"\n]+)(?:\\n|\n)/u,
);
const persisted = messageText.match(/Full output saved to: ([^\r\n]+)/u);
const call = (name, args) => ({
role: "assistant",
content: null,
@@ -268,13 +280,14 @@ const fixture = await createOpenAiModelFixture({
artifactPath = persisted[1];
assert(
(await realpath(artifactPath)).startsWith(
(await realpath(runtime)) + "/",
(await realpath(runtime)) + sep,
),
);
const script = `const fs=require('node:fs');const e=JSON.parse(fs.readFileSync(${JSON.stringify(artifactPath)},'utf8'));console.log(JSON.stringify({REA_CLIENT_FIXTURE:JSON.stringify(e.normalized_result).includes('REA_CLIENT_FIXTURE'),evidence_id:e.evidence_id,graph_nodes:e.normalized_result.graph.nodes.length}));`;
const quote = (value) => "'" + value.replaceAll("'", "'\\''") + "'";
// Keep paths and JSON quotes out of shell syntax on Windows and POSIX.
const encodedScript = Buffer.from(script).toString("base64");
message = call(shell.name, {
command: `node -e ${quote(script)}`,
command: `node -e "eval(Buffer.from('${encodedScript}','base64').toString('utf8'))"`,
description:
"Read the complete saved REA Evidence and verify the fixture facts.",
});
@@ -329,7 +342,7 @@ const args = [
let response;
let failure;
try {
response = await exec(command, args, {
response = await exec(...launch(args), {
env,
cwd: workspace,
timeout: 120_000,
+479
View File
@@ -0,0 +1,479 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import {
mkdir,
mkdtemp,
readFile,
readdir,
rm,
symlink,
unlink,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { parseEvidence } from "../dist/domain/evidence.js";
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
if (process.argv.length !== 2)
throw new Error("Usage: node scripts/verify-real-ghidra-6502.mjs");
if (process.env.GHIDRA_INSTALL_DIR === undefined)
throw new Error(
"Set GHIDRA_INSTALL_DIR to a bring-your-own Ghidra installation.",
);
const exec = promisify(execFile);
const run = createVerifierRun();
const root = fileURLToPath(new URL("..", import.meta.url));
const ca65 = process.env.REA_CA65 ?? "ca65";
const ld65 = process.env.REA_LD65 ?? "ld65";
const toolchain = {};
for (const [name, command] of Object.entries({ ca65, ld65 })) {
const version = await exec(command, ["--version"], { timeout: 10000 });
toolchain[name] = (version.stdout + version.stderr).trim();
}
await mkdir(join(root, "build"), { recursive: true });
const workspace = await mkdtemp(join(root, "build", "rea-6502-proof-"));
const runtime = join(workspace, "runtime");
await mkdir(runtime);
const path = join(workspace, "image.bin");
const object = join(workspace, "image.o");
const entrypoint = join(root, "scripts", "rea.mjs");
const env = {
...process.env,
TMPDIR: runtime,
TMP: runtime,
TEMP: runtime,
REA_LOG_LEVEL: "silent",
REA_ANALYSIS_PROVIDER: "ghidra",
REA_PROCESS_RUN_ID: run.run_id,
};
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env,
stderr: "pipe",
});
const client = new Client({ name: "rea-real-6502-proof", version: "1" });
let stderr = "";
transport.stderr?.on("data", (chunk) => {
stderr = (stderr + chunk.toString()).slice(-65536);
});
let report;
try {
await exec(
ca65,
[join(root, "tests/conformance/ghidra/6502.s"), "-o", object],
{ timeout: 10000 },
);
await exec(
ld65,
["-C", join(root, "tests/conformance/ghidra/6502.cfg"), "-o", path, object],
{ timeout: 10000 },
);
const bytes = await readFile(path);
// Independently known opcodes and link addresses from the source fixture.
assert.equal(bytes.toString("hex"), "a907201080c90ad003a92a60a9006018690360");
const sha256 = createHash("sha256").update(bytes).digest("hex");
await client.connect(transport);
const opened = await call("open_binary", {
path,
format: "raw-binary",
architecture: "6502",
entry_point: "0x008001",
provider_id: "ghidra",
});
assert.equal(opened.architecture, "6502");
assert.equal(opened.entry_point, "0x8001");
const status = await call("binary_session");
const image = evidence(await call("inspect_native_load_image"), sha256);
assert.equal(
image.normalized_result.status,
"verified",
JSON.stringify(image.normalized_result),
);
assert.equal(image.normalized_result.architecture, "6502");
assert.equal(image.normalized_result.base_address, "0x8001");
assert.equal(image.normalized_result.language_id, "6502:LE:16:default");
assert.equal(image.normalized_result.compiler_spec_id, "default");
assert.equal(image.normalized_result.file_bytes, bytes.length);
const memory = evidence(
await call("read_bytes", { address: "0x8001", length: bytes.length }),
sha256,
);
assert.equal(memory.normalized_result.bytes_hex, bytes.toString("hex"));
const procedures = evidence(await call("list_procedures"), sha256);
for (const address of ["0x8001", "0x8010"])
assert.ok(
procedures.normalized_result.some(
(procedure) => procedure.address === address,
),
);
const instruction = evidence(
await call("inspect_native_instruction", { address: "0x8003" }),
sha256,
);
assert.equal(instruction.normalized_result.mnemonic.toUpperCase(), "JSR");
assert.ok(
instruction.normalized_result.references.some(
(reference) => reference.call && reference.target_address === "0x8010",
),
);
const pseudo = evidence(
await call("procedure_pseudo_code", { procedure: "0x8010" }),
sha256,
);
assert.match(pseudo.normalized_result, /\+\s*(?:3|0x3|'\\x03')/u);
const filePseudo = evidence(
await call("procedure_pseudo_code", { procedure: "file-offset:0xf" }),
sha256,
);
assert.equal(filePseudo.normalized_result, pseudo.normalized_result);
assert.ok(
filePseudo.locations.some(
(location) => location.kind === "file-offset" && location.offset === 15,
),
);
const rejected = await client.callTool({
name: "open_binary",
arguments: {
path,
format: "raw-binary",
architecture: "6502",
entry_point: "0xffff",
},
});
assert.equal(rejected.isError, true);
assert.match(JSON.stringify(rejected.content), /6502 address width/u);
const preserved = await call("binary_session");
assert.equal(preserved.analysis_run.run_id, status.analysis_run.run_id);
assert.equal(preserved.entry_point, "0x8001");
await call("close_binary");
await client.ping();
const { stdout } = await exec(
process.execPath,
[
entrypoint,
"inspect-native-load-image",
path,
"--target-format",
"raw-binary",
"--architecture",
"6502",
"--entry-point",
"0x8001",
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
);
const cli = parseEvidence(JSON.parse(stdout));
assert.deepEqual(cli.normalized_result, image.normalized_result);
assert.equal(cli.analysis_profile.digest, image.analysis_profile.digest);
assert.equal(cli.subject.digest.sha256, sha256);
const fileCli = await exec(
process.execPath,
[
entrypoint,
"decompile",
path,
"--file-offset=0xf",
"--target-format",
"raw-binary",
"--architecture",
"6502",
"--entry-point",
"0x8001",
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
);
const fileCliEvidence = parseEvidence(JSON.parse(fileCli.stdout));
assert.equal(fileCliEvidence.normalized_result, filePseudo.normalized_result);
assert.deepEqual(fileCliEvidence.locations, filePseudo.locations);
assert.equal(fileCliEvidence.subject.digest.sha256, sha256);
assert.deepEqual(await readFile(path), bytes);
assert.deepEqual(await readdir(runtime), []);
const lowMemory = [];
// BinaryLoader maps file bytes before trying processor-defined blocks. Prove
// that overlapping zero-page/stack definitions never overlay the selected image.
for (const [entry, fullAddressSpace] of [
[0xff, false],
[0, true],
]) {
const lowPath = join(workspace, `low-${entry}.bin`);
await exec(
ld65,
[
"-C",
join(root, "tests/conformance/ghidra/6502.cfg"),
"--start-addr",
String(entry),
"-o",
lowPath,
object,
],
{ timeout: 10000 },
);
const linked = await readFile(lowPath);
const expectedCode = Buffer.from(
"a907200000c90ad003a92a60a9006018690360",
"hex",
);
expectedCode.writeUInt16LE(entry + 15, 3);
assert.deepEqual(linked, expectedCode);
const lowBytes = fullAddressSpace
? Buffer.concat([linked, Buffer.alloc(65536 - linked.length)])
: linked;
await writeFile(lowPath, lowBytes);
const lowSha256 = createHash("sha256").update(lowBytes).digest("hex");
const lowEntry = `0x${entry.toString(16)}`;
const lowOpened = await call("open_binary", {
path: lowPath,
format: "raw-binary",
architecture: "6502",
entry_point: lowEntry,
provider_id: "ghidra",
});
assert.equal(lowOpened.entry_point, lowEntry);
const lowImage = evidence(
await call("inspect_native_load_image"),
lowSha256,
);
assert.equal(
lowImage.normalized_result.status,
"verified",
JSON.stringify(lowImage.normalized_result),
);
assert.equal(lowImage.normalized_result.base_address, lowEntry);
assert.equal(lowImage.normalized_result.file_bytes, lowBytes.length);
const fileMappings =
lowImage.normalized_result.observations.mappings.filter(
(mapping) => mapping.source_file_index !== null,
);
assert.equal(fileMappings.length, 1);
assert.equal(
fileMappings[0].address_space,
lowImage.normalized_result.observations.default_address_space,
);
assert.equal(fileMappings[0].overlay, false);
assert.equal(fileMappings[0].start, lowEntry);
assert.equal(fileMappings[0].length, lowBytes.length);
const lowRead = evidence(
await call("read_bytes", { address: lowEntry, length: linked.length }),
lowSha256,
);
assert.equal(lowRead.normalized_result.bytes_hex, linked.toString("hex"));
const lowCall = evidence(
await call("inspect_native_instruction", {
address: `0x${(entry + 2).toString(16)}`,
}),
lowSha256,
);
assert.equal(lowCall.normalized_result.mnemonic.toUpperCase(), "JSR");
assert.ok(
lowCall.normalized_result.references.some(
(reference) =>
reference.call &&
reference.target_address === `0x${(entry + 15).toString(16)}`,
),
);
await call("close_binary");
const lowCli = await exec(
process.execPath,
[
entrypoint,
"inspect-native-load-image",
lowPath,
"--target-format",
"raw-binary",
"--architecture",
"6502",
"--entry-point",
lowEntry,
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
);
const lowCliEvidence = evidence(JSON.parse(lowCli.stdout), lowSha256);
assert.deepEqual(
lowCliEvidence.normalized_result,
lowImage.normalized_result,
);
assert.equal(
lowCliEvidence.analysis_profile.digest,
lowImage.analysis_profile.digest,
);
assert.deepEqual(await readFile(lowPath), lowBytes);
assert.deepEqual(await readdir(runtime), []);
lowMemory.push({
entry: lowEntry,
bytes: lowBytes.length,
sha256: lowSha256,
});
}
let windowsReparseAdmission = "not-applicable";
if (process.platform === "win32") {
const junction = join(workspace, "linked");
await symlink(workspace, junction, "junction");
try {
const linkedPath = join(junction, "image.bin");
// Reopening the same bytes through a junction must replace the direct session.
await call("open_binary", {
path,
format: "raw-binary",
architecture: "6502",
entry_point: "0x8001",
provider_id: "ghidra",
});
const directImage = evidence(
await call("inspect_native_load_image"),
sha256,
);
assert.equal(directImage.normalized_result.status, "verified");
// Canonical file identity must not bypass admission of the new path.
await call("open_binary", {
path: linkedPath,
format: "raw-binary",
architecture: "6502",
entry_point: "0x8001",
provider_id: "ghidra",
});
// open_binary selects a lazy session; the first analysis admits the snapshot.
const linkedImage = await client.callTool(
{ name: "inspect_native_load_image", arguments: {} },
{ timeout: 240000 },
);
assert.equal(linkedImage.isError, true);
assert.match(
JSON.stringify(linkedImage),
/Reparse path component is unsupported/u,
);
await call("close_binary");
const snapshotPath = join(workspace, "direct-snapshot.json");
const cliArgs = (selectedPath) => [
entrypoint,
"analyze",
selectedPath,
"--target-format",
"raw-binary",
"--architecture",
"6502",
"--entry-point",
"0x8001",
"--provider",
"ghidra",
"--snapshot",
snapshotPath,
"--json",
];
const saved = await exec(process.execPath, cliArgs(path), {
env,
timeout: 240000,
maxBuffer: 16 * 1024 * 1024,
});
assert.equal(
evidence(JSON.parse(saved.stdout), sha256).operation,
"binary_overview",
);
const savedSnapshot = JSON.parse(await readFile(snapshotPath, "utf8"));
assert.ok(
savedSnapshot.workflow_entries.some(
({ operation }) => operation === "binary_overview",
),
);
assert.deepEqual(await readdir(runtime), []);
// Replaying a matching snapshot must still reach native path admission.
await assert.rejects(
exec(process.execPath, cliArgs(linkedPath), {
env,
timeout: 240000,
maxBuffer: 16 * 1024 * 1024,
}),
(cause) => {
assert.match(
cause.stdout + cause.stderr,
/Reparse path component is unsupported/u,
);
return true;
},
);
assert.deepEqual(await readFile(path), bytes);
assert.deepEqual(await readdir(runtime), []);
windowsReparseAdmission = "cli-snapshot-mcp-rejected";
} finally {
await unlink(junction);
}
}
report = {
ok: true,
mocked: false,
lane: "ghidra-6502",
host: process.platform,
toolchain,
provider: image.provider,
target: {
sha256,
bytes: bytes.length,
architecture: "6502",
entry: "0x8001",
},
cli_mcp_load_image_parity: true,
instructions_and_call_reference: true,
decompilation: true,
file_offset_decompilation_cli_mcp: true,
invalid_mapping_preserves_session: true,
low_memory_cli_mcp_parity: lowMemory,
windows_reparse_admission: windowsReparseAdmission,
source_unchanged: true,
runtime_cleanup: "complete",
};
} catch (cause) {
if (stderr) process.stderr.write(stderr);
throw cause;
} finally {
try {
await client.close();
} finally {
await transport.close();
}
}
const verifierRun = await completeVerifierRun(run);
if (process.platform !== "win32") {
assert.equal(verifierRun.process_lineage.status, "verified");
assert.deepEqual(verifierRun.process_lineage.descendants, []);
}
// Keep the workspace for diagnosis unless verification and cleanup both pass.
await rm(workspace, { recursive: true, force: true });
process.stdout.write(
JSON.stringify({ verifier_run: verifierRun, ...report }) + "\n",
);
async function call(name, args = {}) {
const result = await client.callTool(
{ name, arguments: args },
{ timeout: 240000 },
);
assert.notEqual(result.isError, true, JSON.stringify(result));
if (["open_binary", "binary_session", "close_binary"].includes(name))
return requireMcpOperationResult(result, name);
return result.structuredContent;
}
function evidence(value, sha256) {
const result = parseEvidence(value);
assert.equal(result.subject.digest.sha256, sha256);
assert.equal(result.subject.architecture, "6502");
assert.equal(result.subject.format, "raw-binary");
return result;
}
@@ -0,0 +1,313 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, readFile, readdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { parseEvidence } from "../dist/domain/evidence.js";
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
if (process.argv.length !== 2 || process.platform !== "linux")
throw new Error("Run this ELF verifier on Linux without arguments.");
if (!process.env.GHIDRA_INSTALL_DIR)
throw new Error("Set GHIDRA_INSTALL_DIR to a caller-installed Ghidra.");
const exec = promisify(execFile);
const run = createVerifierRun();
const root = fileURLToPath(new URL("..", import.meta.url));
const workspace = await mkdtemp(join(tmpdir(), "rea-address-context-"));
const runtime = join(workspace, "runtime");
await mkdir(runtime);
const path = join(workspace, "pie.elf");
const entrypoint = join(root, "scripts/rea.mjs");
const env = {
...process.env,
TMPDIR: runtime,
REA_ANALYSIS_PROVIDER: "ghidra",
REA_LOG_LEVEL: "silent",
};
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env,
stderr: "pipe",
});
const client = new Client({ name: "rea-real-address-context", version: "1" });
let stderr = "";
transport.stderr?.on("data", (chunk) => {
stderr = (stderr + chunk.toString()).slice(-65536);
});
let report;
let connected = false;
let primaryFailure;
let verifierRun;
try {
const compiler = process.env.CC ?? "gcc";
const version = await exec(compiler, ["--version"], { timeout: 10000 });
await exec(
compiler,
[
"-O0",
"-g",
"-fPIE",
"-pie",
"-nostdlib",
"-Wl,-e,rea_pie_entry",
"-Wl,--build-id=none",
join(root, "tests/conformance/c/pie-address-context.c"),
"-o",
path,
],
{ timeout: 30000 },
);
const bytes = await readFile(path);
assert.equal(bytes.subarray(0, 6).toString("hex"), "7f454c460201");
assert.equal(bytes.readUInt16LE(16), 3); // ET_DYN: source-owned x86-64 PIE.
assert.equal(bytes.readUInt16LE(18), 62);
const declaredEntry = `0x${bytes.readBigUInt64LE(24).toString(16)}`;
const sha256 = createHash("sha256").update(bytes).digest("hex");
await client.connect(transport);
connected = true;
await call("open_binary", { path, provider_id: "ghidra" });
const overview = parseEvidence(await call("binary_overview"));
assert.equal(overview.subject.digest.sha256, sha256);
const base = overview.normalized_result.image_base;
assert.match(base, /^0x[0-9a-f]+$/u);
assert.notEqual(base, "0x0");
const active = await call("binary_session");
const image = parseEvidence(await call("inspect_native_load_image"));
assert.equal(base, image.normalized_result.observations.image_base);
assert.ok(overview.normalized_result.segments.length > 0);
assert.ok(
overview.normalized_result.segments.every(
(segment) =>
segment.image_base === base &&
typeof segment.address_space === "string",
),
);
const procedures = parseEvidence(await call("list_procedures"));
const entry = procedures.normalized_result.find(
(item) => item.value === "rea_pie_entry",
);
const addition = procedures.normalized_result.find(
(item) => item.value === "rea_pie_add",
);
assert.ok(entry && addition);
assert.notEqual(entry.address, declaredEntry);
assert.ok(
image.normalized_result.observations.entry_points.includes(entry.address),
);
const mapping = image.normalized_result.observations.mappings.find(
(item) =>
item.source_file_index === 0 &&
item.file_offset !== null &&
item.address_space === "ram" &&
BigInt(item.start) <= BigInt(entry.address) &&
BigInt(item.end) >= BigInt(entry.address),
);
assert.ok(mapping);
const offset =
mapping.file_offset + Number(BigInt(entry.address) - BigInt(mapping.start));
const reverseMapping = parseEvidence(
await call("address_to_file_offset", { address: entry.address }),
);
assert.equal(reverseMapping.normalized_result.file_offset, offset);
const memory = parseEvidence(
await call("read_bytes", { address: entry.address, length: 8 }),
);
assert.equal(
memory.normalized_result.bytes_hex,
bytes.subarray(offset, offset + 8).toString("hex"),
);
const fromOffset = parseEvidence(
await call("procedure_pseudo_code", {
procedure: `file-offset:0x${offset.toString(16)}`,
}),
);
assert.ok(
fromOffset.locations.some(
(location) =>
location.kind === "file-offset" && location.offset === offset,
),
);
assert.equal(fromOffset.raw_result.selection.mapped_address, entry.address);
const fromNumericOffset = parseEvidence(
await call("procedure_pseudo_code", {
procedure: `0x${offset.toString(16)}`,
}),
);
assert.equal(
fromNumericOffset.normalized_result,
fromOffset.normalized_result,
);
const missingAddress = "ram:0xffffffff";
const rejected = await client.callTool(
{ name: "procedure_pseudo_code", arguments: { procedure: missingAddress } },
{ timeout: 240000 },
);
assert.equal(rejected.isError, true);
assert.ok(
JSON.stringify(rejected.content).includes(`Ghidra image base: ${base}`),
);
assert.ok(JSON.stringify(rejected.content).includes("list_procedures"));
assert.ok(
JSON.stringify(rejected.content).includes("inspect_native_load_image"),
);
const pseudo = parseEvidence(
await call("procedure_pseudo_code", { procedure: addition.address }),
);
assert.match(pseudo.normalized_result, /\+\s*(?:7|0x7)\b/u);
const retained = await call("binary_session");
assert.equal(retained.analysis_run.run_id, active.analysis_run.run_id);
await call("close_binary");
const cli = async (args) =>
exec(
process.execPath,
[entrypoint, ...args, "--provider", "ghidra", "--json"],
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
);
const cliOverview = parseEvidence(
JSON.parse((await cli(["analyze", path])).stdout),
);
assert.deepEqual(cliOverview.normalized_result, overview.normalized_result);
let cliFailure;
try {
await cli(["decompile", path, missingAddress]);
} catch (cause) {
cliFailure = cause;
}
assert.ok(cliFailure && typeof cliFailure.stdout === "string");
const error = JSON.parse(cliFailure.stdout);
assert.equal(error.code, "invalid_request");
assert.ok(JSON.stringify(error).includes(`Ghidra image base: ${base}`));
for (const command of [
"rea search <path> . --kind procedures --mode regex",
"rea analyze <path>",
"rea inspect-native-load-image <path>",
])
assert.ok(JSON.stringify(error).includes(command));
const cliProcedures = parseEvidence(
JSON.parse(
(
await cli([
"search",
path,
".",
"--kind",
"procedures",
"--mode",
"regex",
])
).stdout,
),
);
const cliEntry = cliProcedures.normalized_result.find(
(item) => item.value === "rea_pie_entry",
);
assert.ok(cliEntry);
assert.equal(cliEntry.address, entry.address);
const cliImage = parseEvidence(
JSON.parse((await cli(["inspect-native-load-image", path])).stdout),
);
assert.equal(cliImage.normalized_result.observations.image_base, base);
const cliPseudo = parseEvidence(
JSON.parse((await cli(["decompile", path, cliEntry.address])).stdout),
);
const cliOffset = parseEvidence(
JSON.parse(
(await cli(["decompile", path, `--file-offset=0x${offset.toString(16)}`]))
.stdout,
),
);
assert.equal(cliOffset.normalized_result, fromOffset.normalized_result);
assert.deepEqual(cliOffset.locations, fromOffset.locations);
assert.match(cliPseudo.normalized_result, /rea_pie_add/u);
assert.deepEqual(await readFile(path), bytes);
assert.deepEqual(await readdir(runtime), []);
report = {
ok: true,
mocked: false,
provider: image.provider,
compiler: version.stdout.split("\n")[0],
sha256,
declared_elf_entry: declaredEntry,
loaded_entry: entry.address,
observed_image_base: base,
cli_mcp_overview_parity: true,
failed_lookup_guidance: true,
cli_recovery_commands: true,
loaded_decompilation: true,
file_offset_decompilation: true,
address_file_offset_round_trip: true,
source_unchanged: true,
runtime_cleanup: "complete",
};
} catch (cause) {
primaryFailure = { cause };
if (stderr) process.stderr.write(stderr);
}
const cleanupFailures = [];
// Await provider-owned cleanup before the SDK can terminate its MCP parent.
if (connected) {
try {
await call("close_binary");
} catch (cause) {
cleanupFailures.push(cause);
}
}
try {
await client.close();
} catch (cause) {
cleanupFailures.push(cause);
}
try {
await transport.close();
} catch (cause) {
cleanupFailures.push(cause);
}
try {
assert.deepEqual(await readdir(runtime), []);
verifierRun = await completeVerifierRun(run);
assert.equal(verifierRun.process_lineage.status, "verified");
assert.deepEqual(verifierRun.process_lineage.descendants, []);
} catch (cause) {
cleanupFailures.push(cause);
}
if (cleanupFailures.length === 0) {
try {
await rm(workspace, { recursive: true, force: true });
} catch (cause) {
cleanupFailures.push(cause);
}
}
if (cleanupFailures.length > 0) {
process.stderr.write(
`Retained verifier workspace after cleanup failure: ${workspace}\n`,
);
throw new AggregateError(
primaryFailure === undefined
? cleanupFailures
: [primaryFailure.cause, ...cleanupFailures],
"Ghidra address-context verifier cleanup failed",
);
}
if (primaryFailure !== undefined) throw primaryFailure.cause;
process.stdout.write(
JSON.stringify({ verifier_run: verifierRun, ...report }) + "\n",
);
async function call(name, args = {}) {
const result = await client.callTool(
{ name, arguments: args },
{ timeout: 240000 },
);
assert.notEqual(result.isError, true, JSON.stringify(result));
return ["open_binary", "binary_session", "close_binary"].includes(name)
? requireMcpOperationResult(result, name)
: result.structuredContent;
}
+152
View File
@@ -12,6 +12,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
import {
inspectMipsReadelf,
inspectLegacyMipsReadelf,
assertMipsLoadedImage,
assertMipsProbe,
assertMipsGlobal,
@@ -122,6 +123,157 @@ async function verifySoftFloat(call, symbols, byteOrder) {
const reports = [];
let primaryFailure;
try {
if (!psp) {
// Recordless legacy admission: ABI field 0, no ABI flags record, pinned
// 32-bit language. Independent GNU readelf checks the header interpretation.
for (const byteOrder of ["little", "big"]) {
const fixture = await buildMipsFixture(workspace, byteOrder, "hard", {
legacy: true,
});
const legacy = await inspectLegacyMipsReadelf(fixture.path, byteOrder);
const resolved = await parseBinaryTarget(fixture.path);
if (!resolved.ok) throw resolved.error;
assert.equal(resolved.value.mips.flags, 0x50000001);
assert.equal(resolved.value.mips.abiFlags, null);
assert.deepEqual(
resolved.value.mips.limitations ?? [],
legacy.limitations,
"REA legacy ABI-record absence interpretation disagrees with GNU readelf",
);
assert.equal(resolved.value.sha256, fixture.sha256);
const snapshotPath = join(workspace, `${byteOrder}-legacy.snapshot.json`);
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env,
stderr: "pipe",
});
const client = new Client({
name: "rea-real-mips-legacy-proof",
version: "1",
});
let stderr = "";
transport.stderr?.on("data", (chunk) => {
stderr = (stderr + chunk.toString()).slice(-65536);
});
const call = async (name, args = {}) => {
const reply = await client.callTool(
{ name, arguments: args },
undefined,
{ timeout: 180000 },
);
return requireMcpOperationResult(reply, name);
};
try {
await client.connect(transport);
const selected = await call("open_binary", {
path: fixture.path,
provider_id: "ghidra",
});
assert.equal(selected.architecture, "mips");
assert.equal(selected.sha256, fixture.sha256);
const image = await call("inspect_native_load_image");
assertMipsLoadedImage(image.observations, byteOrder, fixture.sha256);
// The pinned import language is the checked one, not an auto-selection.
assert.equal(
image.observations.language_id,
`MIPS:${byteOrder === "little" ? "LE" : "BE"}:32:default`,
);
const procedures = await call("list_procedures");
const entry = procedures.find((p) => p.value === "rea_mips_entry");
assert.ok(entry, "Entry function missing from legacy inventory");
assert.equal(
BigInt(entry.address),
BigInt(legacy.symbols.rea_mips_entry),
);
const probe = await call("inspect_native_instruction", {
address: legacy.symbols.rea_mips_probe,
});
const probeBranch = await call("inspect_native_instruction", {
address: `0x${(BigInt(legacy.symbols.rea_mips_probe) + 4n).toString(16)}`,
});
assertMipsProbe(
probe,
probeBranch,
byteOrder,
legacy.symbols.rea_mips_probe,
);
const dossier = functionDossierSchema.parse(
await call("analyze_function", { procedure: entry.address }),
);
assert.ok(dossier.pseudocode.trim().length > 0);
const saved = await call("close_binary", {
snapshot_path: snapshotPath,
});
assert.ok(saved.evidence_records > 0);
const snapshot = parseAnalysisSnapshot(
JSON.parse(await readFile(snapshotPath, "utf8")),
);
assert.equal(snapshot.target.architecture, "mips");
const parameters = snapshot.binding.analysis_profile.parameters;
assert.equal(
parameters.language_id,
`MIPS:${byteOrder === "little" ? "LE" : "BE"}:32:default`,
);
assert.equal(parameters.language_selection, "provider-pinned-v1");
assert.equal(
parameters.mips_support_lane,
"elf32-exec-arch32-legacy-pinned-v1",
);
assert.equal(parameters.mips_abi_basis, "inferred-o32-assumption-v1");
assert.ok(
snapshot.evidence_bundle.records.some((record) =>
parseEvidence(record).limitations.some((item) =>
item.includes("analysis assumption"),
),
),
"Legacy o32 assumption limitation missing from retained Evidence",
);
assert.ok(
snapshot.evidence_bundle.records.some((record) =>
parseEvidence(record).limitations.some((item) =>
item.includes("Floating-point ABI semantics are unverified"),
),
),
"Legacy floating-point uncertainty missing from retained Evidence",
);
await call("open_binary", {
path: fixture.path,
provider_id: "ghidra",
snapshot_path: snapshotPath,
});
assert.deepEqual(await call("list_procedures"), procedures);
await call("close_binary");
} catch (cause) {
throw new Error(
`MIPS legacy ${byteOrder} verification failed; MCP stderr: ${stderr}`,
{ cause },
);
} finally {
try {
await client.close();
} finally {
await transport.close();
}
}
reports.push({
...fixture,
independent_reader: legacy,
variant: "recordless-legacy-pinned",
effective_language: `MIPS:${
byteOrder === "little" ? "LE" : "BE"
}:32:default`,
procedures: "verified-through-snapshot",
checks: [
"recordless header admitted with inferred o32 assumption",
"pinned 32-bit language checked after import",
"legacy limitations retained in Evidence",
"snapshot round-trip and profile parameters",
],
status: "passed",
});
}
}
for (const [byteOrder, floatAbi] of psp
? [["little", "hard"]]
: [
+68
View File
@@ -45,8 +45,13 @@ const transport = new StdioClientTransport({
const source = fileURLToPath(
new URL("../tests/conformance/swift/type-inventory.swift", import.meta.url),
);
const literalSource = fileURLToPath(
new URL("../tests/conformance/swift/string-literals.swift", import.meta.url),
);
const binary = join(root, "swift-inventory");
const literalBinary = join(root, "swift-literals");
const reports = [];
let literalReport;
try {
await execute("swiftc", ["-O", "-g", source, "-o", binary], {
timeout: 60_000,
@@ -54,6 +59,19 @@ try {
await mkdir(join(root, "copied"));
const copied = join(root, "copied", "swift-inventory");
await copyFile(binary, copied);
// Decoding covers AArch64 register pairs, so build that slice on any Mac host.
await execute(
"swiftc",
[
"-O",
"-target",
"arm64-apple-macosx13.0",
literalSource,
"-o",
literalBinary,
],
{ timeout: 60_000 },
);
await client.connect(transport);
for (const [variant, path] of [
["debug", binary],
@@ -132,6 +150,7 @@ try {
await call("close_binary", {});
}
}
literalReport = await verifyStringLiterals();
} finally {
await client.close();
await transport.close();
@@ -143,6 +162,7 @@ console.log(
ok: true,
provider: "ghidra",
fixtures: reports,
swift_string_literals: literalReport,
verifier_run: await completeVerifierRun(run),
},
null,
@@ -159,3 +179,51 @@ async function call(name, arguments_) {
toolContract(name).outputSchema.parse(response.structuredContent);
return requireMcpOperationResult(response, name);
}
// Literal text lives only in register constants, so it must come from the decoded pairs.
async function verifyStringLiterals() {
await call("open_binary", { path: literalBinary, provider_id: "ghidra" });
try {
const procedures = await call("list_procedures", {});
const expected = {
smallLiteral: [["SleepDisabled", "small"]],
largeLiteral: [["The power setting could not be changed.", "large"]],
unicodeLiterals: [
["café", "small"],
["Résumé of the naïve décor, written twice", "large"],
],
emptyAndControlLiterals: [
["", "small"],
["tab\there", "small"],
],
returnedLiteral: [["Holding awake", "small"]],
};
const decoded = {};
for (const [name, literals] of Object.entries(expected)) {
const procedure = procedures.find(({ value }) => value.includes(name));
assert(procedure, `${name} is missing from the procedure inventory`);
const dossier = await call("analyze_function", {
procedure: procedure.address,
});
const observed = dossier.swift_string_literals;
assert(Array.isArray(observed), `${name} recorded no Swift literals`);
assert.deepEqual(dossier.swift_string_literal_omissions, []);
assert.deepEqual(
observed.map(({ value, representation }) => [value, representation]),
literals,
);
for (const literal of observed) {
assert.deepEqual(literal.registers, ["x0", "x1"]);
assert.equal(
literal.address === null,
literal.representation === "small",
`${literal.value} storage does not match its representation`,
);
}
decoded[name] = observed.length;
}
return decoded;
} finally {
await call("close_binary", {});
}
}
+6
View File
@@ -28,6 +28,7 @@ import {
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
import { verifyGhidraCompactReferences } from "./lib/real-ghidra-compact-references.mjs";
import { verifyGhidraFileOffsets } from "./lib/real-ghidra-file-offsets.mjs";
const verifierRun = createVerifierRun();
const SESSION_CAPABILITIES = GHIDRA_SESSION_CAPABILITIES.filter(
@@ -300,6 +301,11 @@ try {
}
if (report === undefined)
throw new Error("Windows Ghidra report was not produced");
report.file_offsets = await verifyGhidraFileOffsets(
targetPath,
resolve(root, "scripts", "rea.mjs"),
architecture === "x86" ? "0x401010" : "0x140001010",
);
process.stdout.write(
`${JSON.stringify({ verifier_run: await completeVerifierRun(verifierRun), ...report })}\n`,
);
+60 -1
View File
@@ -1,4 +1,8 @@
#!/usr/bin/env node
import {
assertFeatureTrace,
findMappedNonFunction,
} from "./lib/ida-feature-trace.mjs";
import { requireMcpToolError } from "./lib/mcp-verifier-results.mjs";
import assert from "node:assert/strict";
@@ -6,7 +10,7 @@ import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { access, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { basename, dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { parseArgs, promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
@@ -261,6 +265,61 @@ const mcp = async (cliDossier) => {
break;
}
}
if (config.mode === "headless") {
const documents = observe(await call("list_documents", {}));
assert.equal(documents.length, 1);
assert.equal(basename(documents[0]), basename(target));
assert.ok(
!workspaces.has(dirname(documents[0])),
"list_documents reported the private workspace copy.",
);
const resolveAt = async (address) =>
observe(await call("resolve_containing_procedure", { address }));
const instructionAddresses = dossier.assembly
.map((line) => /^(0x[0-9a-f]+)/iu.exec(line)?.[1])
.filter((address) => address !== undefined);
const queries = [
dossier.procedure.address,
...instructionAddresses.slice(1, 2),
];
for (const queried of queries) {
const containing = await resolveAt(queried);
assert.equal(containing.found, true);
assert.equal(containing.procedure.address, dossier.procedure.address);
assert.equal(containing.procedure.name, dossier.procedure.name);
}
const unmapped = await resolveAt("0xffffffffffffff00");
assert.equal(unmapped.found, false);
assert.equal(unmapped.reason, "outside_segments");
const strings = observe(await call("list_strings", {}));
const mappedString = await findMappedNonFunction(strings, resolveAt);
if (mappedString === undefined)
report.unverified.push(
"Mapped non-function containment: no string of the target is mapped outside every function.",
);
const context = await client.callTool({
name: "inspect_address_context",
arguments: { address: dossier.procedure.address },
});
assert.notEqual(context.isError, true);
const contextResult = context.structuredContent.result;
assert.equal(contextResult.document, documents[0]);
assert.equal(contextResult.procedure.state, "available");
assert.equal(
contextResult.procedure.value.procedure.address,
dossier.procedure.address,
);
const traced = await client.callTool({
name: "trace_feature",
arguments: { query: dossier.procedure.name },
});
assert.notEqual(traced.isError, true);
const trace = traced.structuredContent.normalized_result;
assertFeatureTrace(trace, dossier.procedure);
report.checks.push(
"headless containing procedures, the admitted program as the document, address context, and the procedure match and references of feature tracing",
);
}
if (values["expected-string"] === undefined)
report.unverified.push(
"String inventory/search correctness: no independent expected literal supplied.",
+105
View File
@@ -0,0 +1,105 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { isAbsolute } from "node:path";
import { pathToFileURL } from "node:url";
import { performance } from "node:perf_hooks";
import { inspectSolidityMetadata } from "../dist/evm/SolidityMetadata.js";
const compilerModule = process.env.REA_VERIFY_SOLC_MODULE;
if (!isAbsolute(compilerModule ?? ""))
throw new Error(
"Select an absolute caller-installed solc 0.8.30 module with REA_VERIFY_SOLC_MODULE.",
);
const solc = (await import(pathToFileURL(compilerModule).href)).default;
assert.equal(solc.version(), "0.8.30+commit.73712a01.Emscripten.clang");
const source = await readFile(
new URL("./fixtures/evm-interface.sol", import.meta.url),
"utf8",
);
const cases = [
{ name: "unoptimized", optimizer: false, viaIR: false, metadata: {} },
{ name: "viaIR", optimizer: true, viaIR: true, metadata: {} },
{
name: "without-content-hash",
optimizer: false,
viaIR: false,
metadata: { bytecodeHash: "none" },
},
{
name: "without-CBOR",
optimizer: false,
viaIR: false,
metadata: { appendCBOR: false },
},
];
const reports = [];
for (const fixture of cases) {
const compiled = JSON.parse(
solc.compile(
JSON.stringify({
language: "Solidity",
sources: { "fixture.sol": { content: source } },
settings: {
optimizer: { enabled: fixture.optimizer },
viaIR: fixture.viaIR,
metadata: fixture.metadata,
outputSelection: { "*": { "*": ["evm.deployedBytecode.object"] } },
},
}),
),
);
assert.ok(
!(compiled.errors ?? []).some((error) => error.severity === "error"),
JSON.stringify(compiled.errors),
);
const bytes = Buffer.from(
compiled.contracts["fixture.sol"].InterfaceFixture.evm.deployedBytecode
.object,
"hex",
);
const cpu = process.cpuUsage();
const started = performance.now();
const parsed = inspectSolidityMetadata(bytes);
const firstMs = performance.now() - started;
const firstCpu = process.cpuUsage(cpu);
if (fixture.metadata.appendCBOR === false)
assert.equal(parsed.status, "unavailable");
else {
assert.equal(parsed.status, "decoded");
assert.deepEqual(parsed.compiler_version, {
representation: "release-bytes",
value: "0.8.30",
});
assert.equal(parsed.bytecode_offset + parsed.cbor_bytes + 2, bytes.length);
if (fixture.metadata.bytecodeHash === "none")
assert.deepEqual(
parsed.entries.map(({ key }) => key),
[{ kind: "text", value: "solc" }],
);
}
const warmResults = new Array(100);
const warmStart = performance.now();
for (let index = 0; index < warmResults.length; index++)
warmResults[index] = inspectSolidityMetadata(bytes);
const warmMs = performance.now() - warmStart;
for (const result of warmResults) assert.deepEqual(result, parsed);
reports.push({
fixture: fixture.name,
bytes: bytes.length,
status: parsed.status,
first_decode_ms: firstMs,
first_decode_cpu_us: firstCpu,
warm_100_decode_ms: warmMs,
});
}
console.log(
JSON.stringify({
ok: true,
compiler_version: solc.version(),
contract_execution: "not-performed",
cases: reports,
process_peak_rss_kib: process.resourceUsage().maxRSS,
measurements:
"In-process decode timings; peak RSS includes the compiler. End-to-end Linux provider/CLI costs and agent tokens were not measured.",
}),
);
+2 -2
View File
@@ -27,9 +27,9 @@ if (
);
const offset = bytes.length >= 64 ? bytes.readUInt32LE(0x3c) : bytes.length;
if (
bytes.toString("ascii", 0, 2) !== "MZ" ||
bytes.toString("latin1", 0, 2) !== "MZ" ||
offset > bytes.length - 26 ||
bytes.toString("ascii", offset, offset + 4) !== "PE\0\0" ||
bytes.toString("latin1", offset, offset + 4) !== "PE\0\0" ||
bytes.readUInt16LE(offset + 4) !== 0x8664 ||
bytes.readUInt16LE(offset + 24) !== 0x20b
)
+3
View File
@@ -52,8 +52,10 @@ try {
"bridge/ghidra/ReaGhidraBridge.java",
"native/windows/build/manifest.json",
"native/windows/build/rea-windows-x64.node",
"dist/android/JadxWorkspaceRoot.js",
"dist/main.js",
"dist/cli.js",
"scripts/rea-android-workspace-guardian.mjs",
"scripts/rea.mjs",
])
if (!packagedPaths.has(required))
@@ -108,6 +110,7 @@ try {
HOME: home,
USERPROFILE: home,
APPDATA: join(home, "AppData", "Roaming"),
LOCALAPPDATA: join(home, "AppData", "Local"),
CLAUDE_CONFIG_DIR: home,
CODEX_HOME: join(home, ".codex"),
XDG_CONFIG_HOME: join(home, ".config"),
+50 -1
View File
@@ -173,6 +173,50 @@ try {
assert.deepEqual(structured.normalized_result, evidence.normalized_result);
console.log(`PASS MCP/CLI parity ${task.name}`);
}
const overloadedInput = {
path,
class_name: "androidx.activity.ComponentActivity",
method_name: "setContentView",
overload_index: 0,
};
const overloadedCli = await execute(
process.execPath,
[
entrypoint,
"trace-android-references",
path,
overloadedInput.class_name,
"--method-name",
overloadedInput.method_name,
"--overload-index",
"0",
"--format",
"json",
],
{
cwd: repository,
env: environment,
timeout: 150_000,
maxBuffer: 8 * 1024 * 1024,
},
);
const overloadedEvidence = parseEvidence(JSON.parse(overloadedCli.stdout));
assert.equal(overloadedEvidence.normalized_result.overload_index, 0);
assert.equal(
overloadedEvidence.normalized_result.reported_signature,
"setContentView(int): void",
);
assert.equal(overloadedEvidence.normalized_result.total, 1);
const overloadedMcp = await client.callTool(
{ name: "trace_android_references", arguments: overloadedInput },
{ timeout: 150_000 },
);
assert.notEqual(overloadedMcp.isError, true, JSON.stringify(overloadedMcp));
assert.deepEqual(
parseEvidence(overloadedMcp.structuredContent).normalized_result,
overloadedEvidence.normalized_result,
);
console.log("PASS real overloaded method references through CLI/MCP");
} catch (cause) {
process.stderr.write(stderr);
throw cause;
@@ -205,7 +249,7 @@ assert.equal(method.body_status, "available");
assert.equal(method.source.status, "complete");
assert.ok(method.source.text.includes("getIntent()"));
assert.ok(method.source.text.includes("setListAdapter"));
assert.equal(method.method.dex_descriptor, null);
assert.equal(method.method.dex_descriptor, "(Landroid/os/Bundle;)V");
const references = cliResults.get("trace_android_references").normalized_result;
assert.ok(references.total > 0);
assert.ok(
@@ -264,3 +308,8 @@ if (process.env.REA_ANDROID_REPORT !== undefined)
JSON.stringify(report, null, 2) + "\n",
);
console.log(JSON.stringify(report, null, 2));
assert.notEqual(
windowsLifecycle?.cli_abrupt_owner_exit_workspace_cleanup,
false,
"Android workspace remained after abrupt owner exit",
);
+112
View File
@@ -0,0 +1,112 @@
import assert from "node:assert/strict";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { parseEvidence } from "../../../dist/domain/evidence.js";
import { hashAndroidFile } from "./digest.mjs";
/** The public result keeps one using node and exposes its two raw DEX invokes. */
export const verifyAndroidDirectInvokes = async ({
apk,
dexEntry,
repository,
entrypoint,
environment,
execute,
}) => {
const digest = await hashAndroidFile(apk);
const check = (value) => {
const evidence = parseEvidence(value);
assert.equal(evidence.subject.digest.sha256, digest);
assert.equal(evidence.operation, "trace_android_references");
const result = evidence.normalized_result;
assert.equal(result.class_name, "fixture.Target");
assert.equal(result.method_name, "mark");
assert.equal(result.dex_descriptor, "()V");
assert.equal(result.reference_unit, "using_node");
assert.equal(result.total, 1);
assert.equal(result.references.length, 1);
const source = result.references[0];
assert.equal(source.reported_full_name, "fixture.Caller.twice");
assert.deepEqual(
source.direct_invokes.map((site) => site.dex_code_unit_offset),
[0, 3],
);
for (const site of source.direct_invokes) {
assert.equal(site.dex_entry, dexEntry);
assert.equal(site.source_raw_method_id, "fixture.Caller.twice()V");
assert.equal(site.opcode, "INVOKE_STATIC");
}
assert.equal(
source.direct_invokes[0].dex_method_index,
source.direct_invokes[1].dex_method_index,
);
return evidence;
};
const cliResponse = await execute(
process.execPath,
[
entrypoint,
"trace-android-references",
apk,
"fixture.Target",
"--method-name",
"mark",
"--format",
"json",
],
{
cwd: repository,
env: environment,
timeout: 150_000,
maxBuffer: 8 * 1024 * 1024,
},
);
const cli = check(JSON.parse(cliResponse.stdout));
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
cwd: repository,
env: environment,
stderr: "pipe",
});
let stderr = "";
transport.stderr?.on("data", (chunk) => {
stderr += chunk.toString();
});
const client = new Client({
name: "rea-real-android-direct-invokes",
version: "1",
});
try {
await client.connect(transport, { timeout: 30_000 });
const response = await client.callTool(
{
name: "trace_android_references",
arguments: {
path: apk,
class_name: "fixture.Target",
method_name: "mark",
},
},
{ timeout: 150_000 },
);
assert.notEqual(response.isError, true, JSON.stringify(response));
const mcp = check(response.structuredContent);
assert.deepEqual(mcp.normalized_result, cli.normalized_result);
assert.deepEqual(mcp.parameters, cli.parameters);
assert.deepEqual(mcp.provider, cli.provider);
} catch (cause) {
process.stderr.write(stderr);
throw cause;
} finally {
await client.close();
}
assert.equal(
await hashAndroidFile(apk),
digest,
"Static inspection changed fixture bytes",
);
console.log(
`PASS real CLI/MCP direct-invoke offsets from ${dexEntry}, parity and unchanged APK`,
);
};
+25
View File
@@ -3,6 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { delimiter, join, resolve } from "node:path";
import { verifyAndroidInitializers } from "./initializers.mjs";
import { verifyAndroidDirectInvokes } from "./direct-invokes.mjs";
/** Prove cold metadata stays unmaterialized and overload identities survive code generation. */
export const verifyAndroidMetadata = async ({
@@ -60,6 +61,7 @@ export const verifyAndroidMetadata = async ({
apk,
resolve(repository, "tests/fixtures/android/static-initializers"),
classes,
resolve(repository, "tests/fixtures/android/direct-invokes"),
],
{ timeout: 120_000, maxBuffer: 8 * 1024 * 1024 },
);
@@ -71,12 +73,19 @@ export const verifyAndroidMetadata = async ({
result.stdout,
/INITIALIZER_REGRESSION_PASS folded=2 bounded=true/u,
);
assert.match(
result.stdout,
/DIRECT_INVOKE_REGRESSION_PASS nodes=1 offsets=0,3 cross_dex=true/u,
);
console.log(
"PASS real cold metadata, full generic types and stable overloaded method selection",
);
console.log(
"PASS real folded and mixed static initializers, absent bodies and bounded smali",
);
console.log(
"PASS real raw DEX direct-invoke offsets and cross-DEX source identity",
);
await verifyAndroidInitializers({
apk: resolve(classes, "initializers.apk"),
repository,
@@ -84,6 +93,22 @@ export const verifyAndroidMetadata = async ({
environment,
execute,
});
await verifyAndroidDirectInvokes({
apk: resolve(classes, "direct-invokes.apk"),
dexEntry: "classes.dex",
repository,
entrypoint,
environment,
execute,
});
await verifyAndroidDirectInvokes({
apk: resolve(classes, "direct-invokes-multidex.apk"),
dexEntry: "classes2.dex",
repository,
entrypoint,
environment,
execute,
});
} finally {
await rm(classes, { recursive: true, force: true });
}

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