merge: integrate current main into call discovery scope

This commit is contained in:
morluto committed 2026-11-04 08:31:28 +08:00
commit 670d025e33
322 files changed
+14044 -1046

No files matched your search

@@ -73,6 +73,14 @@ Parent Evidence ID plus source index preserves same-address
alternatives. Follow `coverage.next_offset` only as far as the question needs;
exhausting retained rows does not establish complete program discovery.
`batch_decompile` returns every body unless you select `detail: "summary"` when
the connected tool advertises it. Use its parent Evidence ID with
`view: {kind: "batch-decompile", collection: "items"}` for body-free rows and
exact `where.indices`, `addresses`, `procedure_addresses` or `statuses`. Add
`include_pseudocode: true` for several chosen bodies, or use
`collection: "pseudocode"` with one row's `index` to page a large body. Batch
limitations aggregate every item, so do not attribute them to one successful row.
Use `record_unknown` to track unresolved questions in the user's investigation
and name the authority or environment still required. Supply supporting and
contradicting evidence IDs. It updates the session registry directly.
+2 -1
View File
@@ -106,7 +106,8 @@ limits, security/privacy/containment implications, and meaningful proof gaps. --
## Author readiness
<!-- The human owner completes these declarations before requesting review.
<!-- The human owner completes these declarations before requesting review,
unless the PR author is exempt as listed in CONTRIBUTING.md.
The PR author is the owner by default. A different owner must explicitly accept
ownership on this PR.
AI agents must leave these declarations to a human to dictate to them or there could be catastrophic consequences for many users of REA.
+2
View File
@@ -807,6 +807,8 @@ jobs:
tests/boundary/mcp/nativeDemangleMcp.test.ts
tests/boundary/mcp/nativeMachoSummaryMcp.test.ts
tests/boundary/providers/native/nativeSignatureTargetBinding.test.ts
tests/boundary/providers/native/nativeUiOffscreenWindow.test.ts
tests/boundary/process/nativeUiVerifierLane.test.ts
tests/boundary/cli/nativeSignatureCli.test.ts
tests/boundary/cli/nativeMachoArm64_32Cli.test.ts
tests/boundary/cli/nativeMachoEncryptionCli.test.ts
+3 -3
View File
@@ -55,12 +55,12 @@ jobs:
- uses: ./.github/actions/portable-build-cache
with:
build_cache_artifact: ${{ inputs.build_cache_artifact }}
- name: Install ELF32 cross toolchains for source-owned fixtures
- name: Install AArch64 and ELF32 cross toolchains for source-owned fixtures
run: |
sudo apt-get update
sudo apt-get install --yes --no-install-recommends \
gcc-i686-linux-gnu gcc-arm-linux-gnueabi gcc-mips-linux-gnu gcc-mipsel-linux-gnu \
libc6-dev-i386-cross libc6-dev-armel-cross libc6-dev-mips-cross libc6-dev-mipsel-cross
gcc-aarch64-linux-gnu gcc-i686-linux-gnu gcc-arm-linux-gnueabi gcc-mips-linux-gnu gcc-mipsel-linux-gnu \
libc6-dev-arm64-cross libc6-dev-i386-cross libc6-dev-armel-cross libc6-dev-mips-cross libc6-dev-mipsel-cross
- name: Acquire exact upstream libraries in an isolated runner environment
shell: bash
run: |
+4
View File
@@ -21,6 +21,10 @@ jobs:
python-version: "3.12"
cache: pip
cache-dependency-path: website/requirements.txt
- name: Set up Node.js for the website Worker checks
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
- name: Install sharing-image dependencies
run: |
sudo apt-get update
+50 -8
View File
@@ -54,6 +54,10 @@ jobs:
python-version: "3.12"
cache: pip
cache-dependency-path: website/requirements.txt
- name: Set up Node.js for the website Worker checks
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
- name: Install sharing-image dependencies
run: |
sudo apt-get update
@@ -75,10 +79,21 @@ jobs:
PY
- name: Configure Pages
uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0
# upload-pages-artifact excludes dot directories, including .well-known.
- name: Package the website including agent discovery
run: |
tar --dereference --hard-dereference --directory website/_site \
--exclude=.git --exclude=.github -cf "$RUNNER_TEMP/artifact.tar" .
tar -tf "$RUNNER_TEMP/artifact.tar" \
./.well-known/ard.json ./.well-known/ai-catalog.json \
./.well-known/agent-skills/index.json > /dev/null
- name: Upload the website
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
path: website/_site
name: github-pages
path: ${{ runner.temp }}/artifact.tar
retention-days: 1
if-no-files-found: error
deploy:
needs: prepare
@@ -130,14 +145,24 @@ jobs:
PUBLISH_TARGET: ${{ inputs.target }}
run: |
python3 -u - <<'PY'
import hashlib
import json
import os
import sys
import time
from urllib.error import URLError
from urllib.parse import urljoin
from urllib.parse import urljoin, urlsplit
from urllib.request import Request, urlopen
def open_public(url, accept=None):
headers = {
"Cache-Control": "no-cache",
"User-Agent": "REA-Website-Verifier/1.0 (+https://github.com/morluto/rea)",
}
if accept:
headers["Accept"] = accept
return urlopen(Request(url, headers=headers), timeout=10)
expected = os.environ["GITHUB_SHA"]
pages_url = os.environ["PAGES_URL"]
if not pages_url:
@@ -153,11 +178,7 @@ jobs:
for name, base in list(pending.items()):
url = urljoin(base, "deployment.json") + "?commit=" + expected
try:
request = Request(url, headers={
"Cache-Control": "no-cache",
"User-Agent": "REA-Website-Verifier/1.0 (+https://github.com/morluto/rea)",
})
with urlopen(request, timeout=10) as response:
with open_public(url) as response:
actual = json.load(response).get("commit")
if actual == expected:
print(f"{name} serves {expected}: {base}")
@@ -172,6 +193,27 @@ jobs:
time.sleep(5)
if pending:
sys.exit("Could not verify published revision on: " + ", ".join(pending))
for name, base in hosts.items():
with open_public(urljoin(base, ".well-known/agent-skills/index.json")) as response:
skill, = json.load(response)["skills"]
# Fetch from each selected host, including the /rea/ mirror.
artifact_url = urljoin(base, urlsplit(skill["url"]).path.lstrip("/"))
with open_public(artifact_url) as response:
digest = "sha256:" + hashlib.sha256(response.read()).hexdigest()
if digest != skill["digest"]:
sys.exit(name + " serves a skill archive with the wrong digest")
for manifest in ("ard.json", "ai-catalog.json"):
with open_public(urljoin(base, ".well-known/" + manifest)) as response:
if not json.load(response).get("entries"):
sys.exit(name + " serves an empty discovery manifest")
if name == "Cloudflare":
with open_public(base, accept="text/markdown") as response:
vary = {value.strip().lower() for value in response.headers.get("Vary", "").split(",")}
if response.headers.get_content_type() != "text/markdown" or "accept" not in vary:
sys.exit("Cloudflare did not negotiate Markdown with Vary: Accept")
if 'rel="ard"' not in response.headers.get("Link", ""):
sys.exit("Cloudflare is missing its discovery Link header")
print(name + " serves the discovery manifests and verified skill archive")
with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as summary:
links = " and ".join(f"[{name}]({url})" for name, url in hosts.items())
summary.write(f"Published commit `{expected}` to {links}.\n")
+2 -2
View File
@@ -128,8 +128,8 @@ owner to complete. Never attest to human review on their behalf. Draft status
is optional; preserve the PR's current draft status unless the user asks to change
it. Later pushes do not invalidate the readiness declaration or require another
human sign-off. Keep the PR body and affected validation current as work changes.
The readiness check validates
the filled declaration; CI and automated review results do not establish that
The readiness check validates the filled declaration or the author exemption
listed in CONTRIBUTING.md; CI and automated review results do not establish that
human review occurred, maintainer scope approval, or merge permission.
<!-- BEGIN:turborepo-agent-rules -->
+8 -4
View File
@@ -93,7 +93,8 @@ Before requesting maintainer review, the human owner must:
passing. Follow the [verification requirements](#development-feedback-and-pr-verification)
for required checks and real-provider coverage.
- Complete the **Author readiness** declaration in the PR template, explicitly
stating that the PR is ready for initial review.
stating that the PR is ready for initial review, unless the PR author is
exempt from the declaration as listed below.
Keep the PR description concise and current. Explain the problem, observable
outcome, approach, and useful evidence; link long logs or put them in optional
@@ -114,9 +115,12 @@ validation current as work changes. AI agents must leave human declarations to
the human owner and must not attest to a human review on their behalf.
The `PR readiness` check reports pending for drafts and requires a named human
owner and all three template declarations for non-drafts. It checks that the
declaration is filled, not whether the code or evidence is correct. Existing PRs
can copy the **Author readiness** section from the current template.
owner and all three template declarations for non-drafts. PRs authored by
@morluto, @kaoru0822-kitauji, @N0zoM1z0, or @gensokyo-dev are exempt from
the declaration; their human ownership, review, and evidence responsibilities
above still apply. The check identifies authors by GitHub account ID. It checks
that the declaration is filled, not whether the code or evidence is correct.
Existing PRs can copy the **Author readiness** section from the current template.
Open PRs must have distinct head commits because GitHub shares commit statuses.
Maintainers enable merge enforcement after the workflow is merged: run
+2
View File
@@ -20,6 +20,8 @@
**[Website](https://rea.tools/) · [Guides](https://rea.tools/guides/) · [Showcases](https://rea.tools/showcase/)**
<a href="https://discord.com/invite/GkcryMnJDM"><img alt="Join the REA community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
[Quick start](#quick-start) · [How REA works](#how-rea-works) · [What you can analyze](#what-you-can-analyze) · [Showcases](#showcases) · [Build RL environments](#build-rl-environments) · [FAQ](#faq) · [Documentation](#documentation)
<code>npx rea-agents setup</code>
+1 -1
View File
@@ -60,7 +60,7 @@ pin inspection sources; they are not included in the npm package.
| ghidra-nativeaot | [MIT](https://github.com/Washi1337/ghidra-nativeaot/blob/effeb734fc570c32650f88b159608979dc7b423e/LICENSE.md) | Pinned inspection source; derived parser attribution above. |
| JADX | [Apache-2.0](https://github.com/skylot/jadx/blob/v1.5.6/LICENSE) | The audited jadx-headless-mcp source uses JADX 1.5.6 for Android decompilation; the engine JAR is separately supplied. |
| jadx-headless-mcp | [Apache-2.0](https://github.com/1013503897/jadx-headless-mcp/blob/5844800a486d2248fa949b74c9896285f5b54de2/LICENSE) | v0.7.1; pinned submodule and user-supplied JAR. |
| Kuna | [Apache-2.0](https://github.com/Noelo-Lab/kuna/blob/v1.988/LICENSE), with its [NOTICE](https://github.com/Noelo-Lab/kuna/blob/v1.988/NOTICE) | Bring your own `kuna`; REA verified 1.988 and never downloads, installs or bundles it. REA invokes it as a separate process. |
| Kuna | [Apache-2.0](https://github.com/Noelo-Lab/kuna/blob/v1.989/LICENSE), with its [NOTICE](https://github.com/Noelo-Lab/kuna/blob/v1.989/NOTICE) | Bring your own `kuna`; REA verified 1.989 and never downloads, installs or bundles it. REA invokes it as a separate process. |
| Apktool | [Apache-2.0](https://github.com/iBotPeaches/Apktool/blob/master/LICENSE.md) | User-supplied Android resource decoder. |
| Binwalk | [MIT](https://github.com/ReFirmLabs/binwalk/blob/4fdab3d464d97b68e0af9088df3f9e2e1545b21c/LICENSE) | v3.1.0; pinned submodule and user-supplied firmware engine. |
| Unblob | [MIT](https://github.com/onekey-sec/unblob/blob/1fcc7a0a584a70a96c31f5a276c20944d199a089/LICENSE) | 26.6.4; pinned submodule and user-supplied firmware engine. |
+4 -1
View File
@@ -65,7 +65,10 @@ func observe(_ request: Request) async throws -> [String: Any] {
guard SHA256.hash(data: executableBytes).map({ String(format: "%02x", $0) }).joined() == request.sha256 else {
try fail("target-mismatch", "Running executable bytes differ from the active target digest")
}
guard let windows = CGWindowListCopyWindowInfo([.optionIncludingWindow], request.window_id) as? [[String: Any]],
// .optionIncludingWindow omits a selected window while it is off screen, which
// is not absence: select from every window, and let capture report what it can.
guard let windows = (CGWindowListCopyWindowInfo([.optionAll], kCGNullWindowID) as? [[String: Any]])?
.filter({ ($0[kCGWindowNumber as String] as? NSNumber)?.uint32Value == request.window_id }),
windows.count == 1, let window = windows.first,
(window[kCGWindowOwnerPID as String] as? NSNumber)?.int32Value == request.pid,
(window[kCGWindowLayer as String] as? NSNumber)?.intValue == 0,
+38 -23
View File
@@ -15,6 +15,8 @@ struct ProcessIdentityObservation: Encodable {
let effective_uid: UInt32?
let lifecycle: String?
let relation: ProcessRelationObservation?
/** Display-only kernel accounting name (p_comm, at most MAXCOMLEN bytes). */
let name: String?
let reason: String?
}
@@ -31,6 +33,7 @@ struct ProcessMetadata {
let parentPid: Int32
let processGroupId: Int32
let pointerSize: Int
let name: String
}
struct ProcessSnapshot {
@@ -92,7 +95,8 @@ func readProcessSnapshot(
lifecycle: process.kp_proc.p_stat == SZOMB ? "zombie" : "non-zombie",
parentPid: process.kp_eproc.e_ppid,
processGroupId: process.kp_eproc.e_pgid,
pointerSize: process.kp_proc.p_flag & P_LP64 != 0 ? 8 : 4
pointerSize: process.kp_proc.p_flag & P_LP64 != 0 ? 8 : 4,
name: processName(process)
)
)
},
@@ -103,6 +107,13 @@ func readProcessSnapshot(
return ProcessSnapshot(metadata: [:], failure: "process_table_failed_\(ENOMEM)")
}
/** Decode the NUL-terminated accounting name; invalid UTF-8 is replaced for display. */
private func processName(_ process: kinfo_proc) -> String {
withUnsafeBytes(of: process.kp_proc.p_comm) { bytes in
String(decoding: bytes.prefix { $0 != 0 }, as: UTF8.self)
}
}
func readProcessIdentities(pids: [Int32]) -> [ProcessIdentityObservation] {
readProcessIdentities(pids: pids, snapshot: readProcessSnapshot())
}
@@ -130,6 +141,7 @@ func readProcessIdentities(
effective_uid: nil,
lifecycle: nil,
relation: nil,
name: nil,
reason: "not-in-snapshot"
)
}
@@ -144,6 +156,7 @@ func readProcessIdentities(
parent_pid: metadata.parentPid,
process_group_id: metadata.processGroupId
),
name: metadata.name,
reason: nil
)
}
@@ -157,6 +170,7 @@ private func unavailableIdentity(pid: Int32, reason: String) -> ProcessIdentityO
effective_uid: nil,
lifecycle: nil,
relation: nil,
name: nil,
reason: reason
)
}
@@ -214,6 +228,22 @@ func processRunTokenObservation(
parse: Result<ParsedRunToken, RunTokenReadError>,
codeSigningFlags: () -> UInt32?
) -> RunTokenObservation {
// Native inspection restrictions apply even when argv is incomplete or
// overwritten, before parsing reaches the environment. Keep this fact at
// the native boundary; cleanup still decides whether the process is related.
func withheldEnvironment() -> RunTokenObservation? {
guard let flags = codeSigningFlags() else { return nil }
if (flags & csPlatformBinary) != 0 {
return unavailable(pid: pid, reason: "platform_binary_environment_withheld")
}
if (flags & csRestrict) != 0 {
return unavailable(pid: pid, reason: "restricted_process_environment_withheld")
}
return nil
}
func unavailableEnvironment(_ reason: String) -> RunTokenObservation {
withheldEnvironment() ?? unavailable(pid: pid, reason: reason)
}
switch parse {
case .success(let parsed):
if let runId = parsed.runId {
@@ -224,40 +254,25 @@ func processRunTokenObservation(
if parsed.environmentEntryCount > 0 {
return RunTokenObservation(pid: pid, state: "readable", run_id: nil, reason: nil)
}
let flags = codeSigningFlags()
if let flags, (flags & csPlatformBinary) != 0 {
return unavailable(pid: pid, reason: "platform_binary_environment_withheld")
}
if let flags, (flags & csRestrict) != 0 {
return unavailable(pid: pid, reason: "restricted_process_environment_withheld")
}
if let withheld = withheldEnvironment() { return withheld }
return RunTokenObservation(pid: pid, state: "readable", run_id: nil, reason: nil)
case .failure(let failure):
switch failure {
case .environmentUnavailable:
let flags = codeSigningFlags()
if let flags, (flags & csPlatformBinary) != 0 {
return unavailable(pid: pid, reason: "platform_binary_environment_withheld")
}
if let flags, (flags & csRestrict) != 0 {
return unavailable(pid: pid, reason: "restricted_process_environment_withheld")
}
return unavailable(pid: pid, reason: "environment_unavailable")
return unavailableEnvironment("environment_unavailable")
case .ambiguousEnvironmentBoundary:
if let flags = codeSigningFlags(), (flags & csRestrict) != 0 {
return unavailable(pid: pid, reason: "restricted_process_environment_withheld")
}
return unavailable(pid: pid, reason: "ambiguous_environment_boundary")
return unavailableEnvironment("ambiguous_environment_boundary")
// These failures already establish that token bytes were available.
case .duplicateToken:
return unavailable(pid: pid, reason: "duplicate_run_token")
case .invalidTokenEncoding:
return unavailable(pid: pid, reason: "invalid_run_token_encoding")
case .appleVectorUnavailable:
return unavailable(pid: pid, reason: "apple_vector_unavailable")
return unavailableEnvironment("apple_vector_unavailable")
case .invalidBuffer:
return unavailable(pid: pid, reason: "malformed_procargs")
return unavailableEnvironment("malformed_procargs")
case .invalidBufferAt(let stage):
return unavailable(pid: pid, reason: "malformed_procargs_\(stage.rawValue)")
return unavailableEnvironment("malformed_procargs_\(stage.rawValue)")
}
}
}
+4 -4
View File
@@ -16,10 +16,10 @@ PROFILE = "pwntools@4.15.0;pyelftools@0.33;unicorn@2.1.2"
PACKAGES = {"pwntools": "4.15.0", "pyelftools": "0.33", "unicorn": "2.1.2"}
OUTPUT_BYTES = 64 * 1024 * 1024
# (e_machine, ELF class bits, byte order) -> architecture pwntools must also report.
PROFILES = {(62, 64, "little"): "amd64", (3, 32, "little"): "i386", (40, 32, "little"): "arm",
(8, 32, "little"): "mips", (8, 32, "big"): "mips"}
PROFILE_SCOPE = ("Layout profile requires ELF64 x86-64, ELF32 i386, ELF32 ARM little-endian "
"or ELF32 MIPS little- or big-endian objects.")
PROFILES = {(62, 64, "little"): "amd64", (183, 64, "little"): "aarch64", (3, 32, "little"): "i386",
(40, 32, "little"): "arm", (8, 32, "little"): "mips", (8, 32, "big"): "mips"}
PROFILE_SCOPE = ("Layout profile requires ELF64 x86-64, ELF64 AArch64 little-endian, ELF32 i386, "
"ELF32 ARM little-endian or ELF32 MIPS little- or big-endian objects.")
class LayoutFailure(Exception):
+6 -1
View File
@@ -1149,7 +1149,12 @@ global conflict involving an equal pair is not used to normalize either pair; th
A step agrees when the statuses and the normalised bodies are equal. The report gives agreement overall, per probe and
per tool, `agreement_ignoring_order` for steps that differ only in item order, and each step's differences, at most 20,
with a JSON pointer and both values cut short:
with a JSON pointer and both values cut short. Each step reports `differences_total` before that cap,
`differences_omitted`, and `differences_truncated` (true when entries were omitted). Agreement and categories use the
complete comparison. Omitted entries are not retained, so `--full-output` cannot recover them; inspect the original
capture and probe Evidence to investigate beyond the returned pointers.
The difference categories are:
| Category | Meaning |
| --------------- | ------------------------------------------------------------------------------------------------------------ |
+9 -5
View File
@@ -9,9 +9,11 @@ ordinary binary inspection use the same modular capability.
## Bring your own engine
Real verification covers **Linux x64** hosts and **EXEC/DYN/REL** objects in
these profiles: ELF64 x86-64, ELF32 i386, ELF32 little-endian ARM and ELF32
little- or big-endian MIPS. Other machines, classes and byte orders, including
AArch64 and big-endian ARM, return `unsupported_target`. Provide an absolute
these profiles: ELF64 x86-64, ELF64 little-endian AArch64, ELF32 i386, ELF32
little-endian ARM and ELF32 little- or big-endian MIPS. Other machines, classes
and byte orders, including big-endian ARM, return `unsupported_target`. Other
hosts, including Linux arm64, return `capability_unavailable` naming the
detected host before any target is read. Provide an absolute
Python executable whose environment already contains unchanged pwntools 4.15.0,
pyelftools 0.33 and Unicorn 2.1.2:
@@ -424,8 +426,10 @@ rea inspect-native-exports ./libexample.so --detail summary --evidence_output ./
without an export directory is a complete empty inventory.
- ELF rows list defined `.dynsym` symbols with global, weak or GNU-unique
binding and default or protected visibility. Each row keeps `value`
(`st_value` as stored; bit 0 marks Thumb code on 32-bit ARM), `size`, `type`
and `binding`. ELF string tables do not declare a text encoding. Names that
(`st_value` as stored; bit 0 marks Thumb code on 32-bit ARM), `size`, `type`,
`binding` and `visibility` (`"default"` or `"protected"`; `"unknown"` in older
historical Evidence where visibility was not recorded). ELF string tables do
not declare a text encoding. Names that
contain non-ASCII bytes are decoded as UTF-8 when those bytes form valid
UTF-8; `name_utf8_decodable` records whether the non-ASCII name bytes are
valid UTF-8 and `name_bytes_base64` retains those exact bytes (`null` for
+1 -1
View File
@@ -500,7 +500,7 @@ REA retains local observation data and removes only URL userinfo credentials, ex
connection/frame inventories; matching direction/opcode sequences alone do
not prove alignment after drops. Redirect resource types use only shared known metadata:
an omitted type leaves equality unknown rather than proving a modification.
- Console observations with a stack source retain call type, argument types, the CDP `Runtime.Timestamp` in milliseconds since Unix epoch, and source location. An absent or invalid timestamp is `null` and makes `console_events` incomplete; an observed zero remains zero. Argument types are listed by CDP argument position. An absent or non-array argument inventory is `null`, and a malformed argument keeps its position as `unknown`; either makes `console_events` incomplete, while an observed empty inventory remains `[]`. A malformed stack or top stack frame is not replaced by its caller: the console event is excluded, and a request initiator location or WebMCP `registration_source` is `null`; each is reported as `invalid_protocol_value`. This epoch-millisecond clock is separate from the monotonic-second timestamps in `observe-web-session`. When console text capture is selected, already-delivered primitive values are retained verbatim with their original `argument_index`; objects, getters, and remote properties are never expanded. Text capture is `unavailable` when the argument inventory is unknown.
- Console observations with a stack source retain call type, argument types, the CDP `Runtime.Timestamp` in milliseconds since Unix epoch, and source location. An absent or invalid timestamp is `null` and makes `console_events` incomplete; an observed zero remains zero. Argument types are listed by CDP argument position. An absent or non-array argument inventory is `null`, and a malformed argument keeps its position as `unknown`; either makes `console_events` incomplete, while an observed empty inventory remains `[]`. A malformed stack or top stack frame is not replaced by its caller: the console event is excluded, and a request initiator location or WebMCP `registration_source` is `null`; each is reported as `invalid_protocol_value`. Source locations are CDP's zero-based lines and UTF-16 columns. A negative column, which Chrome reports on the first line of some inline scripts, is kept as reported and does not locate a column in the source URL; an invalid line or a non-integer column is `null` (WebMCP `registration_source` is `null`) and is reported as `invalid_protocol_value`. This epoch-millisecond clock is separate from the monotonic-second timestamps in `observe-web-session`. When console text capture is selected, already-delivered primitive values are retained verbatim with their original `argument_index`; objects, getters, and remote properties are never expanded. Text capture is `unavailable` when the argument inventory is unknown.
- WebSocket observations retain direction, opcode, and payload byte length. A missing or malformed CDP opcode is returned as `null`, with opcode-dependent payload byte length and shape also `null`, and marks `websocket_frames` incomplete. Missing or wrong-typed payload data is not treated as empty; its byte length is `null`, and a text payload shape is unknown. Numeric opcode `0` and an observed empty payload remain distinct from missing data. Opcode is nullable in the public inspection schema, so clients must handle unknown protocol values. When shape capture is selected, text messages are classified as text or complete value-free JSON shape; binary bytes, hashes, prefixes, and raw payloads are never retained.
- Storage observations always redact values. Key names, IndexedDB names, and cache names require `include_storage_keys`; stable content fingerprints require the additional `include_storage_fingerprints` selection. Cookie fingerprints cover cookies applicable to the current main-frame URL, including its path, rather than every cookie path on that origin. Other storage remains scoped to the origin. Cache fingerprints hash all decoded response bytes, including bodies above 64 KiB, subject to the [64 MiB per-message CDP limit](#request-boundary). Missing or undecodable cache bodies and partial IndexedDB remote objects make fingerprint coverage incomplete, so identical observations remain `unknown`.
- Unavailable or malformed storage inventories mark `storage_keys` unavailable, even without fingerprints. Comparisons suppress inferred key additions and removals while preserving modifications proven by complete fingerprints on both sides. Previously exported captures are not retroactively corrected; recapture them to obtain these coverage markers.
+23
View File
@@ -569,6 +569,17 @@ prints a failure, so it omits the same-session reuse and
`parameter`) for a new invocation and rerun. For example, a Ghidra decompile
deadline names `REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS`.
`rea debug-with-gdb` and `rea debug-with-rizin` hold their debugger only for the
command's own process. If cleanup cannot confirm the debugger stopped, they print
`cleanup_incomplete` and exit nonzero without stopping it. `remediation.action`
names the process, as does `retained_debugger_pids` in `details.diagnostics`, or
in `details.diagnostics.cleanup_error.details.diagnostics` when the command also
failed or was interrupted. Exiting closes the debugger's command and output pipes.
REA does not establish what the debugger or its target does next, so check that
process. An MCP server that shuts down likewise exits with status 1 instead of
waiting on a debugger it could not stop. It names the process on stderr and as
`retained_debugger_pids` in its error log.
If one-shot analysis fails and closing its session also fails, the original
error details remain inline and `details.cleanup_error` describes the later
close failure. Each diagnostic retains its own reported resources; the later
@@ -1294,6 +1305,18 @@ new file. A publication that has committed can win a later cancellation; the
after commit, the `cleanup_incomplete` error reports the same file as
`details.published_path` and `details.bytes`.
When a failure retains a completed observation and no bundle committed,
`details.source_closure` states what that observation cites. `publication` is
`not_published`. `records` holds authentic source Evidence this invocation
captured, within 64 KiB. `omitted` lists up to 64 other identities and
`omitted_count` the total: `not_captured` means the invocation holds no such
record, so nothing is shown for it or its ancestors; `not_inlined` means it was
captured but is not in this response. `complete` is true only when every cited
record is in `records` or embedded in the observation. If examining the closure
itself fails, `collection_failure` names the error type and the closure state is
unknown; the original failure is still returned. Nothing is retained after exit,
and running the command again is a new observation.
When the reader of piped output closes early, as `rea ... | head` does, the
CLI stops writing, prints no error and exits `141` (`128 + 13`, the status of a
SIGPIPE-terminated writer). The operation itself may have completed; only its
+4
View File
@@ -45,6 +45,10 @@ the client's configured command or reproduce the client's environment. If doctor
passes but the client disconnects, inspect the client's startup error and registered
launcher; see [verification scope](client-verification.md#launch-environment-verification).
`rea doctor` has the same limit for provider checks, and reports a provider setting
the registration's file does not carry as a
[non-blocking unknown](installation.md#check-readiness-for-your-task).
## Runtime settings and client deadlines
`update_analysis_settings` changes curated defaults inside the running REA
+9
View File
@@ -28,6 +28,15 @@ The host must already provide util-linux `prlimit`, normally `/usr/bin/prlimit`;
no system tools and changes no user configuration. [Upstream provenance and
license](https://github.com/morluto/rea/blob/main/third_party/evmole/README.md) are packaged.
Limiter discovery validates the selected path without launching it. A relative
path or directory is reported as `not_configured`, a missing file as
`executable_missing`, and denied access or another filesystem-probe failure as
`runtime_unavailable`. Recovery names the selected path and
`REA_EVM_PRLIMIT_COMMAND`; denied access asks for execute permission and
filesystem access. The original admission or system code stays in diagnostics.
An available filesystem probe does not verify the selected tool's version or
util-linux compatibility, and REA does not substitute another command.
## Evidence semantics
- `encoding` is required: raw bytes or UTF-8 hex, with optional `0x` prefix and
+3
View File
@@ -26,6 +26,9 @@ REA does not install these tools, their dependencies or system packages. It
checks the tool's `--version` banner when executing a request. Binwalk must
print `binwalk <version>` and Unblob must print the version alone. Inspection and
extraction have independent prerequisites; one does not require the other.
Capability discovery recommends `REA_BINWALK_COMMAND` for missing inspection
and `REA_UNBLOB_COMMAND` for missing extraction. Configuring the other engine
does not enable the unavailable operation.
Both require util-linux `prlimit`, normally `/usr/bin/prlimit`; override its
absolute path with `REA_FIRMWARE_PRLIMIT_COMMAND` if needed. `rea doctor` does
not currently inspect firmware readiness. Engine selection/version checks run
+14 -7
View File
@@ -109,9 +109,13 @@ server; older servers keep the complete request after schema discovery.
header check does not establish valid table contents or exported symbols.
- **`libflutter` facts** are per-ABI observations: byte size, SHA-256
digest, GNU build IDs from ELF-declared `PT_NOTE` / `SHT_NOTE` ranges,
and any _labeled_ strings the build
carries. Release engine builds often strip the `Dart VM version` string —
absence is reported as `null`, not guessed. Android clang/LLVM toolchain
and Dart/toolchain strings the build carries. `dart_version` accepts the
labeled `Dart VM version:` form and the unlabeled Dart
`Version::String()` form (`<version> (<channel>) (<commit time>)` with an
optional ` on "<os>_<arch>"` suffix) that release engines embed. An
unlabeled match is a candidate based on its shape; it is not bound to a
Dart symbol and another string can match. `null` means no supported pattern
was found, not proof that no version exists. Android clang/LLVM toolchain
lines are reported verbatim as build provenance.
`build_id_candidates` preserves distinct IDs. `build_id` is populated only
when all declared note ranges were examined and one ID was found;
@@ -122,10 +126,13 @@ server; older servers keep the complete request after schema discovery.
Compressed `SHT_NOTE` sections are skipped with an unsupported-compression
limitation and partial coverage; IDs from other note ranges remain candidates.
Encoded note storage is never interpreted as note headers or inflated.
- Labeled strings must fit their complete grammar. `dart_version_capped`
and `toolchain_capped` independently report labels omitted by grammar or
projection bounds; either makes identification coverage partial. Values
split across read chunks are retained without publishing truncated prefixes.
- Version and toolchain strings must fit their complete grammar.
`dart_version_capped` and `toolchain_capped` independently report labels
omitted by grammar or projection bounds; either makes identification
coverage partial. Values split across read chunks are retained without
publishing truncated prefixes. Unlabeled runs that miss the
`Version::String()` grammar are ignored and do not set
`dart_version_capped`.
- **`coverage: "partial"`** also appears when an ABI carries `libapp.so`
without `libflutter.so` (or vice versa).
- The APK itself is digested before parsing, so `target.sha256` binds the
-1
View File
@@ -51,7 +51,6 @@ For runtime instrumentation, see the [Frida guide](./frida-instrumentation.md).
| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Identify a desktop installation | [Runtime candidates, evidence and follow-up files](desktop-applications.md) |
| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [CPython pyc files](python-pyc.md), [PE certificates](pe-certificates.md), [PE imports](pe-imports.md), [PE TLS/load config](pe-load-config.md), [ELF notes](elf-notes.md), [Linux kernel modules](kernel-modules.md), [Apple IPS](apple-ips.md), [Erlang BEAM](erlang-beam.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) |
| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [CPython pyc files](python-pyc.md), [PE certificates](pe-certificates.md), [PE imports](pe-imports.md), [PE TLS/load config](pe-load-config.md), [ELF notes](elf-notes.md), [Apple IPS](apple-ips.md), [Erlang BEAM](erlang-beam.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) |
| Select a native provider or import format | [Ghidra semantics](ghidra-provider.md), [Windows](windows-ghidra-p0.md), [DOS](ghidra-dos.md), [raw bytes](raw-binary.md), [GameCube DOL](ghidra-dol.md), [MIPS](ghidra-mips.md), [PSP](ghidra-psp.md), [NativeAOT](ghidra-nativeaot.md), [IDA](ida-provider.md), [Kuna](kuna-provider.md), [JEB](jeb-analysis.md) |
| Analyze JavaScript or Electron artifacts | [Static analysis](javascript-artifact-reconstruction.md), [tracing and comparisons](javascript-application-workflows.md), [application graph](javascript-application-graph.md), [source recovery](javascript-recovery.md) |
| Compare static JavaScript with runtime observations | [Runtime attribution](javascript-runtime-observation.md), [reconciliation](javascript-runtime-reconciliation.md), [Electron observation](electron-observation.md), [Electron fuses](electron-fuses.md) |
+25 -1
View File
@@ -390,6 +390,20 @@ A scoped report has `scope.mode: "explicit"`. Its `scope_checks` determine
`environment_healthy` summarizes the full audit. `--target` adds a target check,
but the report remains audit-wide unless a scope option is also supplied.
Provider checks read the terminal running doctor. A client-started server may
receive a different environment, and doctor cannot observe it. When an available
provider relies on a setting that a registration's file does not record, or
records with other text, doctor adds a non-blocking
`registration_environment:<client>` check with `classification: "unknown"`. The
setting names appear in that registration's `provider_settings`. Selecting the
client or an affected provider places the check in `scope_checks`; it never
changes `healthy`, because the client may still pass the setting on. Preview
`rea setup --client <client-id> --skill=false --dry-run` from that terminal and
approve it to record the settings, then restart the client. To see what the
running server can use, call `binary_session` in the client and read
`result.tool_availability`. For Hopper, only an explicit `HOPPER_LAUNCHER_PATH`
is compared.
An invalid `REA_LOG_LEVEL` fails every scope, including one client or the
installed skill, because the executable rejects it before any command. A
selected client also fails for every other setting that MCP startup rejects,
@@ -666,7 +680,8 @@ Hopper is separate commercial software with its own license. Its free demo has
vendor-defined limits, and a paid license is optional. REA reuses any detected
installation and preserves Hopper during uninstall.
The automated Hopper installer supports macOS 12+ and x86_64 hosts running
The automated Hopper installer supports macOS 12+, subject to the minimum
macOS version of the current Hopper release, and x86_64 hosts running
Ubuntu 24.04+, Fedora 41+, Nobara 44+, Arch Linux, CachyOS, or Omarchy. The
official Linux packages are x86_64-only, so setup does not offer Hopper on other
Linux architectures; configure [Ghidra](#ghidra) there instead. REA itself runs on any
@@ -709,6 +724,15 @@ and digest, validates the application bundle, and atomically installs it to
operator can choose its demo mode or activate an existing license. Homebrew and
administrator access are not used.
Each Hopper release declares its own minimum macOS version, so the setup plan
cannot confirm support before approval. Setup compares this Mac's version with
the release metadata's `deployment_target` before downloading the DMG, and with
the signed application's `LSMinimumSystemVersion` before copying it. If this Mac
is older, setup fails with `incompatible_host_version`, names both versions, and
installs nothing. Update macOS, or install a Hopper release that supports this
macOS version yourself and set `HOPPER_LAUNCHER_PATH`. An existing Hopper
installation is not checked against the current release's minimum.
The Hopper launcher action used by REA creates a document from an executable;
its supported command-line interface does not attach to an already-open
document. While another live REA session owns the same target and loader
+22 -3
View File
@@ -876,8 +876,22 @@ and Evidence. If that expansion does not fit, the file node retains
`static_analysis` and any admitted `semantic_module_analysis` observations,
including their exact values and source locations. Use the public modules item
view to read them. The graph marks those unexpanded relationships as unknown.
If cumulative result capacity is exhausted, analysis stops; inventoried but
unexamined files remain present with explicit unknown scopes.
The parent charges everything it retains against the heap that was free when
analysis started, less a transfer reserve. Semantic facts and expanded
relationships stop at half of that capacity, and a source refused there keeps
its static facts with the refusal reported. Static facts can continue to three
quarters of it while the live heap also has room for another copy of what it
holds, so results charged for earlier sources do not cost later sources their
static facts when the heap can still hold them. When that share runs short,
relationships that were reserved but not yet expanded are given up before any
static facts, and each affected file reports the omission.
If static facts themselves exhaust that share, or the parent's live heap cannot
hold the next checkpoint, analysis stops; inventoried but unexamined files remain
present with explicit unknown scopes. The failure reports the limit that applied
in bytes, what was still available, the live heap in use and what the checkpoint
needed.
### Large local JSON inputs
@@ -1002,7 +1016,12 @@ reason in the renderer observations.
HTML script source ranges follow the HTML parser across LF, CRLF, and bare CR
line endings, preserving UTF-16 columns.
Inline JavaScript in local HTML, meaning classic or module `<script>` bodies
without a `src` attribute, is not analyzed. Each HTML file that has one gets an
without a `src` attribute, is not analyzed. Embedded SVG scripts use `href`
(or `xlink:href` when `href` is absent) instead: an existing reference, even
an empty one, suppresses the inline body. SVG's `src` and HTML-only `language`
attributes do not suppress its JavaScript body. SVG references and inline
bodies share the same inert parsing and source coordinates as HTML scripts;
template content stays inert. Each HTML file that has an inline body gets an
unknown `parse-html-inline-scripts` scope with the exact body count and the
script type and source range of the first 100 bodies; beyond that, coverage is
truncated under the `html_inline_script_locations_per_file` limit, and the
@@ -93,6 +93,10 @@ coverage. It does not print captured source text.
Runtime targets, frames, scripts, and workers become capture-scoped graph
nodes. REA evaluates compatible static candidates conservatively:
V8 Inspector targets whose protocol type is `node` use JavaScript asset/module
candidates for their verified entry location. A target-only correspondence does
not mark that script loaded; load-state evidence requires a script observation.
1. A captured-source SHA-256 plus one matching mapped location yields
a content-and-location match.
2. When no static candidate exists at a mapped location, one unique exact
+23 -1
View File
@@ -1,9 +1,27 @@
# Kuna provider
[Kuna](https://github.com/Noelo-Lab/kuna) is an Apache-2.0 decompiler distributed as one `kuna` executable. For a new installation, use Kuna 1.988, REA's recommended verified release. REA uses a `kuna` you supply; it does not download, install, update or bundle Kuna. REA accepts other releases, which it reports as unverified in `rea doctor` and in each analysis profile. The verified-release flag compares the reported version; it does not authenticate the executable. The 1.988 real-provider checks ran on macOS arm64; Linux and other hosts have not been verified with that release.
[Kuna](https://github.com/Noelo-Lab/kuna) is an Apache-2.0 decompiler distributed as one `kuna` executable. For a new installation, use Kuna 1.989, REA's recommended verified release. REA uses a `kuna` you supply; it does not download, install, update or bundle Kuna. REA accepts other releases; `rea doctor` and analysis Evidence report them as unverified. Verification compares the reported version; it does not authenticate the executable. Analysis profiles retain the observed version and executable digest, independently of REA's current verified-release policy. The 1.989 real-provider checks ran on macOS arm64; Linux and other hosts have not been verified with that release.
Kuna is static: it reads the selected image and never executes it.
This profile change makes earlier Kuna analysis snapshots and resumable directory
exports incompatible, even with the same Kuna executable. Keep the old files:
their recorded Evidence remains historical. To continue analysis, open the
binary without the old snapshot and save a new one, or choose a new export
directory. Existing snapshot Evidence can be extracted from its
`evidence_bundle` JSON member and imported as a separate Evidence bundle; that
does not restore cached workflow entries or resume the old directory export.
For example, using `jq` and a new output path:
```bash
jq -e '.evidence_bundle | select(type == "object")' old-snapshot.json > history.json
rea evidence-import history.json --json
```
The CLI command validates the bundle in a one-shot session. To use the records
in an MCP session, call `import_evidence_bundle` with the extracted file path.
This validates the embedded bundle, not the rest of the snapshot container.
## Configure
```bash
@@ -30,6 +48,10 @@ completes. Cleanup retries keep both the observations and the exact file owner.
| `REA_KUNA_TIMEOUT_MS` | 120 s | Deadline for one single-function decompile, and for the inventory run REA makes when the export ends without one. |
| `REA_KUNA_JOBS` | 1 | `decompile-project --jobs`. 1 is Kuna's reference answer; more jobs finish sooner but lose callee-first prototypes, and change about 40–60% of function bodies on the measured binaries. |
Pseudocode read from a parallel project export includes a prototype-context
limitation in its Evidence, including in function dossiers and composed
workflows. Early single-function results have their own source limitation.
A single-function decompile, inventory, string, graph or xref run that exceeds its deadline fails as `provider_timeout`, naming the setting that owns the deadline and that raising it takes a restart. A project export that exceeds `REA_KUNA_PROJECT_TIMEOUT_MS` is an export failure instead: requests that need the export report `capability_unavailable` with the export's own retry guidance.
Windows is not supported yet. Supported targets are ELF and thin Mach-O executables for x86-64, AArch64 and ARM; PE and universal Mach-O are rejected with a typed reason.
+71 -1
View File
@@ -303,6 +303,12 @@ limitation and reason IDs index that page's `items.messages` dictionary. Expand
those references to recover exact text, repeated messages, and absent versus
empty optional fields. Failed nodes retain their original error.
Retained `xrefs` Evidence also supports `native-observation` address pages and
`native-context` source caveats. `xrefs` still returns complete Evidence by
default and does not accept `detail`; when its MCP response exceeds the budget,
follow the returned continuation to page the retained result. These addresses
remain untyped; paging does not add reference kinds or establish completeness.
These views reference complete source context through `source_context.digest`
and caveat counts. Read it using the same parent with
`view: {"kind":"native-context","facet":"overview"}`. The other facets are
@@ -331,6 +337,8 @@ Oversized native inventories likewise include a ready-to-call first
`native-inventory` page. Reduce its limit or select fields when a row is large.
An oversized `trace_application_feature` result includes a first `feature-trace`
page of its retained terminal `paths`; page `nodes` or `edges` from there.
An oversized `batch_decompile` result includes a first body-free
`batch-decompile` page of its retained `items`; read bodies from there.
A broad follow-up or `get_evidence_bundle` can also exceed the response budget; exporting
preserves the complete session without sending it through a single MCP frame.
@@ -399,9 +407,26 @@ that revision. If a list response overflows, retry on the same connection with
the same filters and offset and a smaller limit; export if one head still cannot
fit.
To find missing sources before a downstream analysis refuses them, read
`missing_direct_links` from the summary, or call `get_evidence_bundle` with
`detail: "links"`. The result (`kind: "evidence-link-inspection"`) transfers no
record payload. `filters` select the starting records, while membership is
checked against every record in the session or file, so a filtered-out source is
`retained`, not missing. Rows (`evidence_id`, `linked_evidence_id`, `status`) are
in canonical order, default to `link_status: "missing"` and page with `offset` and
`limit` (default 64); `links` counts every inspected link regardless of that
selection. `traversal: "direct"` (the default) reads the selected records' own
links and sets `retained_closure` to null, because it makes no closure claim.
`traversal: "retained-closure"` also follows retained links, as composed workflows
such as `build_call_path` do, and reports `complete` or `incomplete`; anything
beyond a missing link remains unknown. Missing means absent from this session or
file, not nonexistent, and neither status describes analysis coverage. Importing
a missing source changes this membership without changing any Evidence ID.
For a portable exported bundle, the CLI shares this projection:
`rea inspect-evidence-bundle '{"path":"/tmp/evidence.json","detail":"summary","filters":{"operation":"analyze_function"}}' --format json`.
`"detail":"records"` with an `evidence_id` filter returns that complete record.
`"detail":"records"` with an `evidence_id` filter returns that complete record,
and `"detail":"links"` reads link membership.
The CLI validates and materializes that local bundle under its existing memory
budget; summary delivery does not make arbitrary large files cheap to import.
@@ -631,6 +656,43 @@ never upgrades `trace_coverage`, and graph connectivity does not prove runtime
reachability. The CLI keeps complete trace JSON; save it and use
`inspect-analysis-view` with an `evidence-file` source.
### Selected batch decompile results
`batch_decompile` returns every pseudocode body by default. `detail: "summary"`
retains the complete batch in the session first and returns its producer counts,
`source_provider` (the composing workflow), `source_analysis_profile_digest`,
`decompiling_provider`, procedure identity counts, total and largest
`pseudocode` size, and `limitation_count`. A server that retains no Evidence
refuses the summary before analysis. Further views use the returned
`parent_evidence_id` through `inspect_analysis_view`, or a saved Evidence file or
portable bundle, and never repeat decompilation.
A `batch-decompile` view selects one `collection` and repeats the batch counts
and provider identities:
- `items`: rows in retained order with their original `index`, so duplicate
selectors stay distinct. A row keeps the selector in `address`, its `procedure`
identity and `status`, plus `pseudocode_code_units` or its exact `error` and
`limitations`. `where.indices`, `where.addresses`, `where.procedure_addresses`
and `where.statuses` filter exactly and combine; an item with unknown identity
never matches `procedure_addresses`. Omitted filters select all, and empty
arrays select none. `offset` and `limit` default to 0 and 64.
`include_pseudocode: true` adds each selected body.
- `pseudocode`: one `index`'s text in UTF-16 code-unit pages. `offset` and `limit`
default to 0 and 16,384, and `coverage` measures that body. An offset inside a
surrogate pair is refused, and a limit ending inside one stops before it. An
error item has no text and is refused with a pointer to `items`.
Page `coverage` counts the selection only: exhausting it establishes nothing
about functions outside the batch. Evidence `limitations` aggregate every item's
provider observations and are not attributed to one successful item; only error
items keep their own. A page over the response budget offers a smaller selection
from the same `source`: fewer rows, or one body's `pseudocode` pages when a single
row does not fit. An inline source offers none, because the record cannot be
repeated in that reply; resend it with a smaller selection.
`export_evidence_bundle` remains the complete alternative. The CLI has no batch command; save the Evidence and use
`inspect-analysis-view` with an `evidence-file` source or `--evidence-bundle`.
Completed `inspect_artifact` and atomic `inventory_artifact` Evidence also
support summary and inventory views. A summary reports the manifest's expected
counts separately from available record counts, observed entry kinds, inventory
@@ -951,6 +1013,14 @@ is `unavailable` with a reason when ownership could not be revalidated, or
at that bounded check. These are historical snapshots, not live process
inventories, and do not claim that no short-lived descendant existed.
After a failed close retires the target, `binary_session.cleanup_retry` names
`close_binary` only while that session still retains the cleanup bound to the
close error. Target-dependent availability then gives the same conditional
cleanup guidance before suggesting `open_binary`. The hint disappears after
cleanup succeeds. A pending flag alone does not establish this retry: active
targets, an in-flight initial close, and external-mutation blockers keep their
own recovery and do not receive this hint.
`binary_session.cleanup_pending` reports whether the session retains a provider
client, session file resource, or provider-candidate resource whose cleanup
has not succeeded. It can be true with
+22 -4
View File
@@ -201,6 +201,11 @@ remains valid. This tool
has no dedicated CLI command; the existing `decompile` command remains the
single-procedure CLI operation.
`detail: "summary"` retains the complete batch and returns only its counts,
identities and sizes. `inspect_analysis_view` then reads rows or one body from
the retained record, an Evidence file or a portable bundle without repeating
decompilation: see [selected batch results](mcp-contracts.md#selected-batch-decompile-results).
Each batch schedules one provider read at a time, but has no shared input-count
or aggregate retained-output allowance. It retains every requested row and full
pseudocode body through Evidence construction. The MCP response budget checks
@@ -383,6 +388,11 @@ sites remain distinct. A static route does not establish runtime reachability.
Swift/closure dispatch requires resolved static references; unavailable ABI
forms remain unknown.
Encoded Objective-C method records retain their own locations in dispatch
Evidence. A handler node uses the implementation address with an unknown file
offset unless that function already has an independently located graph node;
the method-record offset does not locate the handler's code.
Incomplete reference kinds do not discard known typed calls or partial
provider call references. A `provider_call` with `observed` resolution retains
the provider-reported call site and endpoint without claiming generic direct,
@@ -428,7 +438,12 @@ Within one value trace, each exact procedure selector is attempted at most once,
including failed analyses. A failed seed returns the provider's original typed
failure and corrective guidance; it cannot establish a trace or target identity.
Failures while expanding a validated seed remain unknown observations and do not
establish canonical function identity. A successful seed without a native def-use
establish canonical function identity. A cancelled expansion read returns the
cancellation. An expansion read whose provider cannot confirm cleanup of
resources it owns stops the trace: the result is that provider's own
`cleanup_incomplete` failure with its resources and any bound cleanup retry, and the work
admitted before the stop is retained as partial Evidence with a located unknown
at the stopping read. A successful seed without a native def-use
model still returns its procedure and an explicit coverage unknown. A new trace
can retry failed selectors.
@@ -437,8 +452,9 @@ page omits retained nodes, and the edges they source, before `offset` or after
`next_offset`. Every page repeats `traversal`: `complete`, or `truncated` with
`reasons` naming each observed cause: the `max_depth`, `max_functions`,
`max_call_sites`, `max_nodes` or `max_edges` budget, the fixed
`node_payload_bytes` allowance, `provider_pcode_truncated`, or
`ambiguous_call_candidates`. Each cause also has a located unknown. A budget is
`node_payload_bytes` allowance, `provider_pcode_truncated`,
`ambiguous_call_candidates`, or, only in a stopped trace's partial Evidence,
`cleanup_incomplete`. Each cause also has a located unknown. A budget is
reported only after it rejects work, so an exactly filled budget is not
truncation; a depth or candidate limit is reported only when no other path
analyzes or queues that destination. `total_nodes` and `total_edges` count
@@ -993,7 +1009,9 @@ with a path-specific gap and incomplete capture coverage. Null alone does not
establish that an accessibility value was successfully read or absent.
An initial PID or window mismatch is `invalid_request` on `pid` or
`window_id` and leaves provider availability intact. An unavailable executable
`window_id` and leaves provider availability intact. A selected window that
exists off screen is not a mismatch: selection succeeds and the permission and
capture checks report their own outcome. An unavailable executable
URL or unresolved executable path leaves identity unknown and reports
`capability_unavailable`. Identity or window loss
after the initial capture remains an observation failure with collected facts.
+2
View File
@@ -20,6 +20,8 @@
**[الموقع](https://rea.tools/ar/) · [الأدلة](https://rea.tools/ar/guides/) · [دراسات الحالة](https://rea.tools/ar/showcase/)**
<a href="https://discord.com/invite/GkcryMnJDM"><img alt="انضم إلى مجتمع REA على Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
[البدء السريع](#البدء-السريع) · [كيف يعمل REA](#كيف-يعمل-rea) · [ما الذي يمكنك تحليله](#ما-الذي-يمكنك-تحليله) · [دراسات الحالة](#دراسات-الحالة) · [إنشاء بيئات التعلم المعزز](#build-rl-environments) · [الأسئلة الشائعة](#الأسئلة-الشائعة) · [أدوات GNU objdump وRizin](../reverse-engineering-tools.md) · [التوثيق](#التوثيق)
<code>npx rea-agents setup</code>
+2
View File
@@ -20,6 +20,8 @@
**[Website](https://rea.tools/de/) · [Anleitungen](https://rea.tools/de/guides/) · [Fallstudien](https://rea.tools/de/showcase/)**
<a href="https://discord.com/invite/GkcryMnJDM"><img alt="Der REA-Community auf Discord beitreten" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
[Schnellstart](#schnellstart) · [So funktioniert REA](#so-funktioniert-rea) · [Was du analysieren kannst](#was-du-analysieren-kannst) · [Fallstudien](#fallstudien) · [RL-Umgebungen erstellen](#build-rl-environments) · [Häufige Fragen](#häufige-fragen) · [Dokumentation](#dokumentation)
<code>npx rea-agents setup</code>
+2
View File
@@ -19,6 +19,8 @@
**[Ιστότοπος](https://rea.tools/) · [Οδηγοί](https://rea.tools/guides/) · [Μελέτες περίπτωσης](https://rea.tools/showcase/)**
<a href="https://discord.com/invite/GkcryMnJDM"><img alt="Έλα στην κοινότητα REA στο Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
[Γρήγορη εκκίνηση](#γρήγορη-εκκίνηση) · [Πώς λειτουργεί το REA](#πώς-λειτουργεί-το-rea) · [Τι μπορείς να αναλύσεις](#τι-μπορείς-να-αναλύσεις) · [Μελέτες περίπτωσης](#μελέτες-περίπτωσης) · [Δημιουργία περιβαλλόντων RL](#build-rl-environments) · [Συχνές ερωτήσεις](#συχνές-ερωτήσεις) · [Τεκμηρίωση](#τεκμηρίωση)
<code>npx rea-agents setup</code>
+2
View File
@@ -20,6 +20,8 @@
**[Sitio web](https://rea.tools/es/) · [Guías](https://rea.tools/es/guides/) · [Casos prácticos](https://rea.tools/es/showcase/)**
<a href="https://discord.com/invite/GkcryMnJDM"><img alt="Únete a la comunidad de REA en Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
[Inicio rápido](#inicio-rápido) · [Cómo funciona REA](#cómo-funciona-rea) · [Qué puedes analizar](#qué-puedes-analizar) · [Casos prácticos](#casos-prácticos) · [Crear entornos de RL](#build-rl-environments) · [Preguntas frecuentes](#preguntas-frecuentes) · [Documentación](#documentación)
<code>npx rea-agents setup</code>
+2
View File
@@ -20,6 +20,8 @@
**[وب‌سایت](https://rea.tools/fa/) · [راهنماها](https://rea.tools/fa/guides/) · [نمونه‌های عملی](https://rea.tools/fa/showcase/)**
<a href="https://discord.com/invite/GkcryMnJDM"><img alt="به انجمن REA در دیسکورد بپیوندید" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
[شروع سریع](#شروع-سریع) · [REA چگونه کار می‌کند؟](#rea-چگونه-کار-می‌کند) · [چه چیزهایی را میتوان تحلیل کرد؟](#چه-چیزهایی-را-می‌توان-تحلیل-کرد) · [نمونه‌های عملی](#نمونه‌های-عملی) · [ساخت محیط‌های RL](#build-rl-environments) · [پرسش‌های متداول](#پرسش‌های-متداول) · [مستندات](#مستندات)
<code>npx rea-agents setup</code>
+2
View File
@@ -20,6 +20,8 @@
**[Site web](https://rea.tools/fr/) · [Guides](https://rea.tools/fr/guides/) · [Études de cas](https://rea.tools/fr/showcase/)**
<a href="https://discord.com/invite/GkcryMnJDM"><img alt="Rejoignez la communauté REA sur Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
[Démarrage rapide](#démarrage-rapide) · [Comment fonctionne REA](#comment-fonctionne-rea) · [Ce que vous pouvez analyser](#ce-que-vous-pouvez-analyser) · [Études de cas](#études-de-cas) · [Créer des environnements RL](#build-rl-environments) · [Questions fréquentes](#questions-fréquentes) · [Documentation](#documentation)
<code>npx rea-agents setup</code>
+2
View File
@@ -20,6 +20,8 @@
**[Situs Web](https://rea.tools/id/) · [Panduan](https://rea.tools/id/guides/) · [Contoh Penggunaan](https://rea.tools/id/showcase/)**
<a href="https://discord.com/invite/GkcryMnJDM"><img alt="Bergabunglah dengan komunitas REA di Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
[Mulai Cepat](#mulai-cepat) · [Cara Kerja REA](#cara-kerja-rea) · [Hal yang Dapat Anda Analisis](#hal-yang-dapat-anda-analisis) · [Contoh Penggunaan](#contoh-penggunaan) · [Membangun lingkungan RL](#build-rl-environments) · [FAQ](#faq) · [Dokumentasi](#dokumentasi)
<code>npx rea-agents setup</code>
+2
View File
@@ -20,6 +20,8 @@
**[Sito web](https://rea.tools/it/) · [Guide](https://rea.tools/it/guides/) · [Showcase](https://rea.tools/it/showcase/)**
<a href="https://discord.com/invite/GkcryMnJDM"><img alt="Unisciti alla community REA su Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
[Avvio rapido](#avvio-rapido) · [Come funziona REA](#come-funziona-rea) · [Cosa puoi analizzare](#cosa-puoi-analizzare) · [Showcase](#showcase) · [Creare ambienti RL](#build-rl-environments) · [FAQ](#faq) · [Documentazione](#documentazione)
<code>npx rea-agents setup</code>
+2
View File
@@ -20,6 +20,8 @@
**[ウェブサイト](https://rea.tools/ja/) · [ガイド](https://rea.tools/ja/guides/) · [事例](https://rea.tools/ja/showcase/)**
<a href="https://discord.com/invite/GkcryMnJDM"><img alt="DiscordのREAコミュニティに参加" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
[クイックスタート](#クイックスタート) · [REA の仕組み](#rea-の仕組み) · [分析できる対象](#分析できる対象) · [事例](#事例) · [RL 環境を構築する](#build-rl-environments) · [よくある質問](#よくある質問) · [ドキュメント](#ドキュメント)
<code>npx rea-agents setup</code>
+2
View File
@@ -20,6 +20,8 @@
**[웹사이트](https://rea.tools/ko/) · [가이드](https://rea.tools/ko/guides/) · [사례](https://rea.tools/ko/showcase/)**
<a href="https://discord.com/invite/GkcryMnJDM"><img alt="Discord에서 REA 커뮤니티에 참여하세요" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
[빠른 시작](#빠른-시작) · [REA의 작동 방식](#rea의-작동-방식) · [분석할 수 있는 대상](#분석할-수-있는-대상) · [사례](#사례) · [RL 환경 만들기](#build-rl-environments) · [자주 묻는 질문](#자주-묻는-질문) · [문서](#문서)
<code>npx rea-agents setup</code>
+2
View File
@@ -20,6 +20,8 @@
**[വെബ്സൈറ്റ്](https://rea.tools/) · [ഗൈഡുകൾ](https://rea.tools/guides/) · [ഉദാഹരണങ്ങൾ](https://rea.tools/showcase/)**
<a href="https://discord.com/invite/GkcryMnJDM"><img alt="Discord-ൽ REA കമ്മ്യൂണിറ്റിയിൽ ചേരുക" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
[ദ്രുതാരംഭം](#ദ്രുതാരംഭം) · [REA എങ്ങനെ പ്രവർത്തിക്കുന്നു](#rea-എങ്ങനെ-പ്രവർത്തിക്കുന്നു) · [എന്തൊക്കെ വിശകലനം ചെയ്യാം](#എന്തൊക്കെ-വിശകലനം-ചെയ്യാം) · [ഉദാഹരണങ്ങൾ](#ഉദാഹരണങ്ങൾ) · [RL പരിതസ്ഥിതികൾ നിർമ്മിക്കുക](#build-rl-environments) · [പതിവുചോദ്യങ്ങൾ](#പതിവുചോദ്യങ്ങൾ) · [ഡോക്യുമെന്റേഷൻ](#ഡോക്യുമെന്റേഷൻ)
<code>npx rea-agents setup</code>
+2
View File
@@ -20,6 +20,8 @@
**[Strona internetowa](https://rea.tools/pl/) · [Przewodniki](https://rea.tools/pl/guides/) · [Przykłady zastosowań](https://rea.tools/pl/showcase/)**
<a href="https://discord.com/invite/GkcryMnJDM"><img alt="Dołącz do społeczności REA na Discordzie" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
[Szybki start](#szybki-start) · [Jak działa REA](#jak-działa-rea) · [Co możesz analizować](#co-możesz-analizować) · [Przykłady zastosowań](#przykłady-zastosowań) · [Tworzenie środowisk RL](#build-rl-environments) · [FAQ](#faq) · [Dokumentacja](#dokumentacja)
<code>npx rea-agents setup</code>
+2
View File
@@ -20,6 +20,8 @@
**[Site](https://rea.tools/pt-BR/) · [Guias](https://rea.tools/pt-BR/guides/) · [Casos de uso](https://rea.tools/pt-BR/showcase/)**
<a href="https://discord.com/invite/GkcryMnJDM"><img alt="Participe da comunidade REA no Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
[Início rápido](#início-rápido) · [Como o REA funciona](#como-o-rea-funciona) · [O que você pode analisar](#o-que-você-pode-analisar) · [Casos de uso](#casos-de-uso) · [Criar ambientes de RL](#build-rl-environments) · [Perguntas frequentes](#perguntas-frequentes) · [Documentação](#documentação)
<code>npx rea-agents setup</code>
+2
View File
@@ -20,6 +20,8 @@
**[Site web](https://rea.tools/) · [Ghiduri](https://rea.tools/guides/) · [Studii de caz](https://rea.tools/showcase/)**
<a href="https://discord.com/invite/GkcryMnJDM"><img alt="Alătură-te comunității REA pe Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
[Pornire rapidă](#pornire-rapidă) · [Cum funcționează REA](#cum-funcționează-rea) · [Ce poți analiza](#ce-poți-analiza) · [Studii de caz](#studii-de-caz) · [Construiește medii RL](#build-rl-environments) · [Întrebări frecvente](#întrebări-frecvente) · [Documentație](#documentație)
<code>npx rea-agents setup</code>
+2
View File
@@ -20,6 +20,8 @@
**[Сайт](https://rea.tools/ru/) · [Руководства](https://rea.tools/ru/guides/) · [Примеры](https://rea.tools/ru/showcase/)**
<a href="https://discord.com/invite/GkcryMnJDM"><img alt="Присоединяйтесь к сообществу REA в Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
[Быстрый старт](#быстрый-старт) · [Как работает REA](#как-работает-rea) · [Что можно анализировать](#что-можно-анализировать) · [Примеры](#примеры) · [Создание RL-окружений](#build-rl-environments) · [Частые вопросы](#частые-вопросы) · [Документация](#документация)
<code>npx rea-agents setup</code>
+2
View File
@@ -20,6 +20,8 @@
**[เว็บไซต์](https://rea.tools/th/) · [คู่มือ](https://rea.tools/th/guides/) · [ตัวอย่างการใช้งาน](https://rea.tools/th/showcase/)**
<a href="https://discord.com/invite/GkcryMnJDM"><img alt="เข้าร่วมชุมชน REA บน Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
[เริ่มต้นอย่างรวดเร็ว](#เริ่มต้นอย่างรวดเร็ว) · [REA ทำงานอย่างไร](#rea-ทำงานอย่างไร) · [สิ่งที่วิเคราะห์ได้](#สิ่งที่วิเคราะห์ได้) · [ตัวอย่างการใช้งาน](#ตัวอย่างการใช้งาน) · [สร้างสภาพแวดล้อม RL](#build-rl-environments) · [คำถามที่พบบ่อย](#คำถามที่พบบ่อย) · [เอกสาร](#เอกสาร)
<code>npx rea-agents setup</code>
+2
View File
@@ -20,6 +20,8 @@
**[Web sitesi](https://rea.tools/tr/) · [Rehberler](https://rea.tools/tr/guides/) · [Örnek çalışmalar](https://rea.tools/tr/showcase/)**
<a href="https://discord.com/invite/GkcryMnJDM"><img alt="Discord'da REA topluluğuna katılın" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
[Hızlı başlangıç](#hızlı-başlangıç) · [REA nasıl çalışır](#rea-nasıl-çalışır) · [Neleri analiz edebilirsiniz](#neleri-analiz-edebilirsiniz) · [Örnek çalışmalar](#örnek-çalışmalar) · [RL ortamları oluşturun](#build-rl-environments) · [Sık sorulan sorular](#sık-sorulan-sorular) · [GNU objdump ve Rizin araçları](../reverse-engineering-tools.md) · [Belgeler](#belgeler)
<code>npx rea-agents setup</code>
+2
View File
@@ -20,6 +20,8 @@
**[Сайт](https://rea.tools/uk/) · [Посібники](https://rea.tools/uk/guides/) · [Приклади](https://rea.tools/uk/showcase/)**
<a href="https://discord.com/invite/GkcryMnJDM"><img alt="Приєднуйтеся до спільноти REA в Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
[Швидкий старт](#швидкий-старт) · [Як працює REA](#як-працює-rea) · [Що можна аналізувати](#що-можна-аналізувати) · [Приклади](#приклади) · [Створення RL-середовищ](#build-rl-environments) · [Поширені запитання](#поширені-запитання) · [Документація](#документація)
<code>npx rea-agents setup</code>
+2
View File
@@ -20,6 +20,8 @@
**[Website](https://rea.tools/) · [Guides](https://rea.tools/guides/) · [Showcases](https://rea.tools/showcase/)**
<a href="https://discord.com/invite/GkcryMnJDM"><img alt="Discord par REA community mein shamil hon" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
[Jaldi shuru karein](#jaldi-shuru-karein) · [REA kaise kaam karta hai](#rea-kaise-kaam-karta-hai) · [Kya kuch analyze kar sakte hain](#kya-kuch-analyze-kar-sakte-hain) · [Amli misaalein](#amli-misaalein) · [RL environments banayein](#build-rl-environments) · [FAQ](#faq) · [Documentation](#documentation)
<code>npx rea-agents setup</code>
+2
View File
@@ -20,6 +20,8 @@
**[Trang web](https://rea.tools/vi/) · [Hướng dẫn](https://rea.tools/vi/guides/) · [Các nghiên cứu điển hình](https://rea.tools/vi/showcase/)**
<a href="https://discord.com/invite/GkcryMnJDM"><img alt="Tham gia cộng đồng REA trên Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
[Bắt đầu nhanh](#bắt-đầu-nhanh) · [Cách REA hoạt động](#cách-rea-hoạt-động) · [Những gì bạn có thể phân tích](#những-gì-bạn-có-thể-phân-tích) · [Các nghiên cứu điển hình](#các-nghiên-cứu-điển-hình) · [Xây dựng môi trường RL](#build-rl-environments) · [Câu hỏi thường gặp](#câu-hỏi-thường-gặp) · [Tài liệu](#tài-liệu)
<code>npx rea-agents setup</code>
+2
View File
@@ -20,6 +20,8 @@
**[網站](https://rea.tools/zh-TW/) · [指南](https://rea.tools/zh-TW/guides/) · [案例](https://rea.tools/zh-TW/showcase/)**
<a href="https://discord.com/invite/GkcryMnJDM"><img alt="加入 REA 的 Discord 社群" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
[快速開始](#快速開始) · [REA 的工作原理](#rea-的工作原理) · [可以分析什麼](#可以分析什麼) · [案例](#案例) · [建立 RL 環境](#build-rl-environments) · [常見問題](#常見問題) · [文件](#文件)
<code>npx rea-agents setup</code>
+2
View File
@@ -20,6 +20,8 @@
**[网站](https://rea.tools/zh-CN/) · [指南](https://rea.tools/zh-CN/guides/) · [案例](https://rea.tools/zh-CN/showcase/)**
<a href="https://discord.com/invite/GkcryMnJDM"><img alt="加入 REA 的 Discord 社区" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
[快速开始](#快速开始) · [REA 的工作原理](#rea-的工作原理) · [可以分析什么](#可以分析什么) · [案例](#案例) · [构建 RL 环境](#build-rl-environments) · [常见问题](#常见问题) · [GNU objdump 和 Rizin 工具](../reverse-engineering-tools.md) · [文档](#文档)
<code>npx rea-agents setup</code>
+8 -2
View File
@@ -14,7 +14,13 @@ can run.
## GNU objdump
`inspect_with_objdump` and `rea inspect-with-objdump` capture stdout and stderr as inline Evidence. Artifact inspection Evidence records SHA-256 digests before and after the provider call and whether those observed digests differ; this cannot prove the exact bytes consumed if the artifact changes during execution. Choose one artifact operation: `file_headers`, `section_headers`, `symbols`, `relocations`, `dwarf`, `disassemble`, or `disassemble_all`. `disassemble` maps to `-d`; `disassemble_all` maps to `-D` and has different symbol-boundary semantics. The pathless `inspect_with_objdump {"operation":"architectures"}` MCP call or `rea list-objdump-architectures` CLI command maps to `-i` and produces subjectless provider-capability Evidence describing BFD targets compiled into the installed binary, not a guarantee that a target is supported.
REA sends GNU-style arguments to the selected command but does not verify its
implementation or version. Evidence identifies the command profile as
`objdump.command` and retains the selected executable; it does not claim that
the executable is GNU objdump. Other implementations may accept some commands
while differing in supported options and output.
`inspect_with_objdump` and `rea inspect-with-objdump` capture stdout and stderr as inline Evidence. Artifact inspection Evidence records SHA-256 digests before and after the provider call and whether those observed digests differ; this cannot prove the exact bytes consumed if the artifact changes during execution. Choose one artifact operation: `file_headers`, `section_headers`, `symbols`, `relocations`, `dwarf`, `disassemble`, or `disassemble_all`. `disassemble` maps to `-d`; `disassemble_all` maps to `-D` and has different symbol-boundary semantics. The pathless `inspect_with_objdump {"operation":"architectures"}` MCP call or `rea list-objdump-architectures` CLI command maps to `-i` and produces subjectless Evidence containing the selected command’s output, not a guarantee that any target is supported. GNU objdump uses this option to list its compiled-in BFD targets.
Failure to launch the selected executable reports provider unavailability, the
configured command and the host error under `boundary: "provider-launch"`.
@@ -25,7 +31,7 @@ their rejected fields in input issues before artifact reads or provider launch.
For DWARF, REA does not follow local debug links by default. Set `follow_debug_links` to `true` to pass `-WK`; external debug-file identities are not bound to Evidence when following links is enabled. The CLI flag is `--follow-debug-links`; `--no-follow-debug-links` or `--follow-debug-links=false` keeps the default. A separate `true` or `false` word after the flag is not its value. REA clears `DEBUGINFOD_URLS` in the objdump child environment to disable debuginfod network access on GNU binutils versions that do not support `-WE`. The complete raw output is authoritative; REA does not normalize it into a version-independent disassembly schema.
For a known native symbol, use `operation: "disassemble", symbol: "my_function"` in MCP or `--symbol my_function` in CLI. REA passes `--disassemble=my_function` to objdump before collecting output, rather than producing the whole disassembly and filtering it afterward. Omit `symbol` to keep full code-section disassembly. Other operations reject this selector. Use a nonempty, NUL-free name with well-formed Unicode; unsupported values are rejected before reading the artifact or starting objdump. Symbol names are passed unchanged; matching, versioned symbols, aliases and the end of the disassembly follow the installed GNU objdump's semantics. Evidence records the selector and actual arguments. An empty match does not establish artifact-wide absence, and the selected observation does not contain the omitted disassembly; request the complete operation when needed.
For a known native symbol, use `operation: "disassemble", symbol: "my_function"` in MCP or `--symbol my_function` in CLI. REA passes `--disassemble=my_function` to objdump before collecting output, rather than producing the whole disassembly and filtering it afterward. Omit `symbol` to keep full code-section disassembly. Other operations reject this selector. Use a nonempty, NUL-free name with well-formed Unicode; unsupported values are rejected before reading the artifact or starting objdump. Symbol names are passed unchanged; matching, versioned symbols, aliases and the end of the disassembly follow the selected executable's semantics. Evidence records the selector and actual arguments. An empty match does not establish artifact-wide absence, and the selected observation does not contain the omitted disassembly; request the complete operation when needed.
```sh
rea inspect-with-objdump ./program disassemble
@@ -86,7 +86,10 @@ credentials and resets it between sequences.
environment refused the call), `not-served`, `no-tool` and `unmapped-id`, each with a JSON pointer and short values.
- The report gives agreement overall, per probe and per tool, agreement ignoring item order beside it, the counts per
category, the ids mapped and the values normalised, the records left uncompared, and each step's differences (at most
20 per step).
20 per step). Each step gives `differences_total` before that cap, `differences_omitted`, and
`differences_truncated`, true when entries were omitted. Equality and categories use all differences.
Omitted entries are not retained and `--full-output` cannot recover them; further investigation uses the original
capture and probe Evidence.
## Non-goals
+15 -7
View File
@@ -269,8 +269,8 @@ APK for the negative check. No engine or external tool is involved. Record:
Flutter Gallery 2.9.2 (112 MB, arm64-v8a / armeabi-v7a / x86_64) — every
ABI reports the same agreeing snapshot hash
(`65817c30a78bb44c3dc3771876b6010a`, two sections each) read 20 bytes after
the Dart snapshot magic, per-ABI engine GNU build-ids, no labeled Dart
version string (expected for a release engine build), and the Android
the Dart snapshot magic, per-ABI engine GNU build-ids, an unlabeled
Dart-shaped version candidate, and the Android
clang/LLVM toolchain line; the AOT inspection locates all four snapshot
symbols through the dynamic symbol table on both ELF64 and ELF32 images,
validates the data-section headers, agrees with the identification hash,
@@ -284,7 +284,7 @@ prefixes, with no private local-file dependency. The verifier now compares
compiled CLI and stdio MCP Evidence on every ABI, including data-header kind
and declared lengths. Add
`--expectations scripts/fixtures/flutter-gallery-expectations.json` for the
documented Gallery APK to assert its pinned digest, hash, exact engine labels
documented Gallery APK to assert its pinned digest, hash, exact engine strings
and per-ABI URI counts. The verifier requires complete structural snapshot
coverage, reports bounded pool coverage as partial/unverified, and reports
exact labels/counts on other APKs unverified without an expectations file.
@@ -421,7 +421,10 @@ the first cold pseudocode answer comes from a single-function run or an export
that already finished, assembly waits for and serves from that export, and
pseudocode and both dossier facets match their recorded file spans and digests,
a fresh server reuses the export without rebuilding it, staging is empty after
a completed export, and no owned process survives. Pass
a completed export, and no owned process survives. A separate forced 1 ms
project deadline checks that a timed-out inventory recommends raising the
project deadline without changing jobs; a fresh server retries with 60 seconds
and the same two-worker setting. Pass
`--targets DIR --functions a,b` to add caller-built binaries laid out as
`DIR/<flavor>/<name>` beside `<name>.stripped`; addresses come from the
symbol-bearing build. `--compare-ghidra` with `GHIDRA_INSTALL_DIR` runs those
@@ -451,6 +454,11 @@ Mach-O fixture and caller-built symbol-bearing and stripped x86-64 ELF fixtures.
Linux and other hosts have not been verified with 1.988; this is not a repeat
of the larger 1.833 matrix.
Kuna 1.989 passed the real-provider lane on macOS arm64 with the generated
symbol-bearing and stripped Mach-O fixtures, including cold and warm reads,
references, and cleanup. Linux, other hosts, and the larger 1.833 target matrix
were not verified with 1.989.
## DOS Ghidra analysis
`npm run verify:ghidra:dos` requires the supported Ghidra and JDK installation
@@ -1103,13 +1111,13 @@ coverage limits.
### Offline binary layout
`npm run verify:binary:layout` requires Linux x64, GCC/binutils, the
`i686-linux-gnu`, `arm-linux-gnueabi`, `mips-linux-gnu` and `mipsel-linux-gnu`
cross `gcc`/`readelf` toolchains, absolute
`aarch64-linux-gnu`, `i686-linux-gnu`, `arm-linux-gnueabi`, `mips-linux-gnu` and
`mipsel-linux-gnu` cross `gcc`/`readelf` toolchains, absolute
`REA_PWNTOOLS_PYTHON` with pwntools 4.15.0/pyelftools 0.33/Unicorn 2.1.2 and
absolute `REA_VERIFY_STRACE_COMMAND`. It compiles ephemeral source-owned ELF
fixtures and checks public CLI/MCP, lossless addresses/names, file ranges,
mitigation inferences, malformed/unsupported input, original file hashes and
released process ownership. Cross-compiled ELF32 executables and relocatable
released process ownership. Cross-compiled ELF64 AArch64 and ELF32 executables and relocatable
objects check the reported class, machine and byte order, and every section
name, address, offset and size against GNU `readelf`. Exec syscall tracing must identify only the declared
Node/Python launchers; no target binary is executed. Core/debugger claims need
+8 -3
View File
@@ -28,7 +28,7 @@ behavior, and complete CI evidence before merging.
| `npm run test:python` | LLDB and mitmproxy producer contracts plus analysis-efficiency checks; no real debugger or proxy required |
| `npm run test:watch` | Dirty source tests in watch mode, without build |
| `npm run test:watch:all` | Changed tests from every project; builds at startup, so rebuild after production edits before relying on compiled tests |
| `npm run check:fast` | Cached typecheck and lint |
| `npm run check:fast` | Cached typecheck and lint |
| `npm run check:changed` | Cached typecheck/lint and branch-related source feedback |
| `npm run check:pr` | Opt-in complete local deterministic gate and generated-file checks |
| `npm run docs:check` | Generated-document validation and fresh onboarding example consistency checks |
@@ -754,9 +754,14 @@ membership changes, a cached lineage token is read again on the next refresh, an
a group that reappears after it was processed is revalidated once, a further
reappearance stays unresolved, lineage publishes one row per PID, and lineage does not publish a
verified empty descendant list while that row remains unexamined. A readable token with unavailable
identity stays unresolved. Native UID facts, rather than an earlier process-table
identity stays unresolved. Both the sweep and the pre-signal group check re-read
an unreadable live token a bounded number of times. The sweep classifies the
PID's current process; the pre-signal re-read stays bound to the first observed
generation, so a reused PID stays unresolved there. A token that stays
unreadable fails closed with the native name, parent, group and identity that
bracketed it. Native UID facts, rather than an earlier process-table
row, determine whether a token denial concerns another user. Darwin tests compile
the helper and exercise snapshot growth, absence, lifecycle, and UID observations;
the helper and exercise snapshot growth, absence, lifecycle, name and UID observations;
controlled Linux procfs tests do not establish real Linux exit or PID-reuse behavior.
For atomic writes, exercise cancellation on both sides of the documented commit
+6 -2
View File
@@ -97,9 +97,13 @@ it does not invent an empty string or body bytes.
The observed Playwright 1.58.2 profile can report a bracketed IPv6
`serverIPAddress`, forwarded from Chromium's `remoteIPAddress` through
[Playwright's HAR tracer](https://github.com/microsoft/playwright/blob/v1.58.2/packages/playwright-core/src/server/har/harTracer.ts).
Chromium DevTools exports (declared as `WebInspector` `537.36`, the frozen
AppleWebKit version in Chromium's user agent) report the same bracketed form:
[its HAR writer](https://github.com/ChromeDevTools/devtools-frontend/blob/63739e8ec45ca01b9e4be9b8b05946acdadd8f1f/front_end/models/har/Log.ts)
strips the port but not the brackets.
REA preserves the original spelling in reported Evidence and removes brackets
only from its validation copy, only for this declared producer and a valid IPv6
literal. Invalid addresses, bracketed IPv4, ports and zone identifiers remain
only from its validation copy, only for these declared producers and a valid
IPv6 literal. Invalid addresses, bracketed IPv4, ports and zone identifiers remain
validation failures. Producer declarations do not authenticate origin.
Native mitmproxy decoding requires a caller-supplied **mitmdump 12.2.3 on
+1 -1
View File
@@ -22,7 +22,7 @@ Results retain:
- Main-document script inventory, exact session script IDs, resource start coordinates, sourceURL/source-map declarations, producer hash and independently computed UTF-8 text digest.
- Armed-window requests from the selected main frame and selected origins, complete reported initiator objects (including stack hierarchy/descriptions), derived callsites, exact script-ID associations and unresolved async parent IDs. Source association does not establish UI causality. Callsite and listener `line_number`/`column_number` values are CDP's zero-based lines and UTF-16 columns in the resource named by `url`. With a `script_id`, that is the script's enclosing resource: the script file for an external script, or the HTML document for an inline script. `resource_start` is where the script text starts in that resource. A callsite with an empty `script_id` comes from the request's reported initiator rather than a script call frame, such as markup that the parser read in a document or a module import in a script file. It still locates a position in `url`, but has no session-script association, so it cannot be joined to captured source or `resource_start`. `trace_web_source_location` takes one-based, script-relative lines and zero-based UTF-16 columns.
For an external script, where `url` is the script file, use `line = line_number + 1` and keep `column = column_number`; this also applies to an empty-`script_id` initiator position in a script file. For an inline script, which requires a `script_id` and its `resource_start`, use `line = line_number - resource_start.line_number + 1`. Subtract `resource_start.column_number` from `column_number` only when `line_number === resource_start.line_number`; keep the reported column on later lines. Columns remain zero-based. This conversion requires known source-start and column coordinates; preserve unavailable coordinates as unknown.
For an external script, where `url` is the script file, use `line = line_number + 1` and keep `column = column_number`; this also applies to an empty-`script_id` initiator position in a script file. For an inline script, which requires a `script_id` and its `resource_start`, use `line = line_number - resource_start.line_number + 1`. Subtract `resource_start.column_number` from `column_number` only when `line_number === resource_start.line_number`; keep the reported column on later lines. Columns remain zero-based. This conversion requires known source-start and column coordinates; preserve unavailable coordinates as unknown. Chrome reports a negative start column for some inline scripts, such as one in a helper frame, and positions on that script's first line include it. REA keeps those columns as reported rather than failing the observation; a negative column does not locate a column in `url`, but the subtraction above still gives the script-relative column.
The coverage counter interval starts at backend acceptance before the armed receipt and ends at the resetting sample after the local timer. Counts may include execution during these command intervals; request events are restricted to the locally armed window. The result retains both backend timestamps and the distinct local clock.
+45
View File
@@ -81,6 +81,51 @@
// In this case, there is no squashed merge commit. This could be a simple
// merge commit, a rebase merge commit, or a direct commit to the branch.
// Fallback to fetching the list of commits from the REST API. In the future
@@ -1154,15 +1154,40 @@
* @returns {string} HTML URL of the new file
*/
async createFileOnNewBranch(filename, contents, newBranchName, baseBranchName) {
- // create or update new branch to match base branch
- await this.forkBranch(newBranchName, baseBranchName);
- // use the single file upload API
+ // Preserve existing notes until the replacement file is committed.
+ // A failed write must not leave an open proposal pointing at a missing file.
+ if (!(await this.getBranchSha(newBranchName))) {
+ const baseSha = await this.getBranchSha(baseBranchName);
+ if (!baseSha) {
+ throw new Error(`Unable to find base branch: ${baseBranchName}`);
+ }
+ await this.createNewBranch(newBranchName, baseSha);
+ }
+ let existingSha;
+ try {
+ const { data } = await this.octokit.repos.getContent({
+ owner: this.repository.owner,
+ repo: this.repository.repo,
+ path: filename,
+ ref: newBranchName,
+ });
+ if (Array.isArray(data) || data.type !== 'file' || !data.sha) {
+ throw new Error(`Expected a file at ${filename} on branch: ${newBranchName}`);
+ }
+ existingSha = data.sha;
+ }
+ catch (error) {
+ if (!(error && error.status === 404)) {
+ throw error;
+ }
+ }
const { data: { content }, } = await this.octokit.repos.createOrUpdateFileContents({
owner: this.repository.owner,
repo: this.repository.repo,
path: filename,
// contents need to be base64 encoded
- content: Buffer.from(contents, 'binary').toString('base64'),
+ content: Buffer.from(contents, 'utf8').toString('base64'),
+ ...(existingSha ? { sha: existingSha } : {}),
message: 'Saving release notes',
branch: newBranchName,
});
--- a/build/src/github.d.ts
+++ b/build/src/github.d.ts
@@ -47,6 +47,8 @@
+2 -1
View File
@@ -3,6 +3,7 @@ export interface ReadinessPullRequest {
readonly state: "open" | "closed";
readonly draft: boolean;
readonly body: string | null;
readonly user: { readonly id: number } | null;
readonly head: { readonly sha: string };
readonly base: { readonly ref: string };
}
@@ -53,7 +54,7 @@ export interface ReadinessGitHub {
>;
}
export function evaluateReadiness(
pull: Pick<ReadinessPullRequest, "body" | "draft">,
pull: Pick<ReadinessPullRequest, "body" | "draft" | "user">,
template: string,
): ReadinessResult;
export function runReadinessCheck(input: {
+14
View File
@@ -2,6 +2,14 @@ import { readFile } from "node:fs/promises";
const result = (state, description) => ({ state, description });
// GitHub account IDs remain stable when a login changes.
const EXEMPT_AUTHOR_IDS = new Set([
76467478, // morluto
208281322, // kaoru0822-kitauji
161784452, // N0zoM1z0
342580529, // gensokyo-dev
]);
const OPEN_HEADS_QUERY = `query ReadinessOpenHeads($owner: String!, $name: String!, $after: String) {
repository(owner: $owner, name: $name) {
pullRequests(first: 100, after: $after, states: OPEN, orderBy: {field: CREATED_AT, direction: ASC}) {
@@ -105,6 +113,11 @@ export function evaluateReadiness(pull, template) {
"pending",
"Draft PR: complete Author readiness before requesting review.",
);
if (EXEMPT_AUTHOR_IDS.has(pull.user?.id))
return result(
"success",
"PR author is exempt from the readiness declaration.",
);
const expected = readinessLines(template).flatMap((line) => {
const match = /^- \[ \] (.+)$/u.exec(line);
return match ? [match[1]] : [];
@@ -210,6 +223,7 @@ export async function runReadinessCheck({ github, context, core }) {
latest.head.sha !== pull.head.sha ||
latest.body !== pull.body ||
latest.draft !== pull.draft ||
latest.user?.id !== pull.user?.id ||
latest.base.ref !== pull.base.ref
)
verdict = result(
+8 -1
View File
@@ -76,6 +76,7 @@ const PATH_SCOPES = {
"src/frida/frida-bindings.d.ts",
"src/objdump/**",
"src/cli/reverseEngineeringCommands.ts",
"src/process/ownedDebuggerCleanupError.ts",
"src/process/ownedDebuggerSession.ts",
"src/rizin/**",
"src/contracts/*ToolContracts.ts",
@@ -103,6 +104,7 @@ const PATH_SCOPES = {
"tests/boundary/providers/RizinInferiorTty.test.ts",
"tests/boundary/providers/RizinTestInferiorTty.ts",
"tests/boundary/process/ownedDebuggerSession.test.ts",
"tests/boundary/cli/debuggerOneShotExit.test.ts",
"tests/boundary/cli/gdbStartupCleanupCli.test.ts",
"tests/boundary/cli/reverseEngineeringCommands.test.ts",
"tests/boundary/mcp/cutter*.test.ts",
@@ -163,6 +165,9 @@ const PATH_SCOPES = {
"tests/boundary/mcp/nativeDemangleMcp.test.ts",
"tests/boundary/mcp/nativeMachoSummaryMcp.test.ts",
"tests/boundary/providers/native/nativeSignatureTargetBinding.test.ts",
"tests/boundary/providers/native/nativeUiOffscreenWindow.test.ts",
"tests/boundary/process/nativeUiVerifierLane.test.ts",
"tests/conformance/native/ui-offscreen.swift",
"tests/boundary/cli/nativeSignatureCli.test.ts",
"tests/boundary/cli/nativeMachoArm64_32Cli.test.ts",
"tests/boundary/cli/nativeMachoEncryptionCli.test.ts",
@@ -699,7 +704,9 @@ export const CI_LANES = {
agentenv: sameJobs(PATH_SCOPES.agentenv, ["agentenv"]),
nativeaot: sameJobs(PATH_SCOPES.nativeaot, ["nativeaot"]),
ghidra_bridge: sameJobs(PATH_SCOPES.ghidra_bridge, ["ghidra_bridge"]),
website: sameJobs(PATH_SCOPES.website, ["website"]),
website: sameJobs(PATH_SCOPES.website, ["website"], {
earlyInputs: [".agents/skills/reverse-engineer-anything/**"],
}),
};
// An input owner omitted from the registry must never silently lose a lane.
for (const [name, paths] of Object.entries(PATH_SCOPES))
@@ -3,19 +3,19 @@
"snapshot_hash": "65817c30a78bb44c3dc3771876b6010a",
"engines": {
"x86_64": {
"dart_version": null,
"dart_version": "2.14.0-151.0.dev (dev) (Tue May 25 10:58:39 2021 -0700)",
"toolchain_lines": [
"Android (5900059 based on r365631c) clang version 9.0.8 (https://android.googlesource.com/toolchain/llvm-project 207d7abc1a2abf3ef8d4301736d6a7ebc224a290) (based on LLVM 9.0.8svn)"
]
},
"armeabi-v7a": {
"dart_version": null,
"dart_version": "2.14.0-151.0.dev (dev) (Tue May 25 10:58:39 2021 -0700)",
"toolchain_lines": [
"Android (5900059 based on r365631c) clang version 9.0.8 (https://android.googlesource.com/toolchain/llvm-project 207d7abc1a2abf3ef8d4301736d6a7ebc224a290) (based on LLVM 9.0.8svn)"
]
},
"arm64-v8a": {
"dart_version": null,
"dart_version": "2.14.0-151.0.dev (dev) (Tue May 25 10:58:39 2021 -0700)",
"toolchain_lines": [
"Android (5900059 based on r365631c) clang version 9.0.8 (https://android.googlesource.com/toolchain/llvm-project 207d7abc1a2abf3ef8d4301736d6a7ebc224a290) (based on LLVM 9.0.8svn)"
]
+16 -21
View File
@@ -253,20 +253,9 @@ async function verifyNativeValueAdapters(
functionEvidence.analysis_profile,
);
assert.deepEqual(functionProfile, upstreamProfile);
const comparison = await client.callTool(
{
name: "compare_functions",
arguments: {
left: analyzed.structuredContent,
right: analyzed.structuredContent,
},
},
{ timeout: 180000 },
);
assert.notEqual(comparison.isError, true, JSON.stringify(comparison));
assert.notEqual(comparison.structuredContent, undefined);
const comparisonEvidence = parseEvidence(
requireMcpOutput(comparison, "compare_functions"),
const comparisonEvidence = await compareRetainedFunctionEvidence(
client,
functionEvidence,
);
assert.equal(
comparisonEvidence.normalized_result.status,
@@ -395,21 +384,27 @@ async function verifyUnresolvedCallEvidence(client) {
assert.equal(trace.goal_status, "unknown");
assert.equal(trace.truncated, true);
assert.ok(trace.residual_unknowns.length > 0);
const compared = await compareRetainedFunctionEvidence(client, evidence);
assert.equal(compared.normalized_result.status, "unknown");
}
/** Compare an already admitted function record without retransmitting its payload. */
export async function compareRetainedFunctionEvidence(client, evidence) {
const reference = {
kind: "retained-evidence",
evidence_id: evidence.evidence_id,
};
const compared = await client.callTool(
{
name: "compare_functions",
arguments: {
left: analyzed.structuredContent,
right: analyzed.structuredContent,
left: reference,
right: reference,
},
},
{ timeout: 180000 },
);
assert.notEqual(compared.isError, true, JSON.stringify(compared));
assert.notEqual(compared.structuredContent, undefined);
assert.equal(
parseEvidence(requireMcpOutput(compared, "compare_functions"))
.normalized_result.status,
"unknown",
);
return parseEvidence(requireMcpOutput(compared, "compare_functions"));
}
+2
View File
@@ -1,5 +1,7 @@
export function applyReleasePleasePatch(directory?: string): Promise<void>;
export function describeReleaseProposalError(error: unknown): string;
export function releaseProposalSearchDepth(
run: (command: string, args: string[]) => Promise<string>,
sourceSha: string,
+9
View File
@@ -13,6 +13,15 @@ const patch = fileURLToPath(
new URL("../../patches/release-please+17.3.0.patch", import.meta.url),
);
export function describeReleaseProposalError(error) {
if (!(error instanceof Error)) return String(error);
const description = error.message || error.name || "Unknown error";
const status = error.status;
return Number.isInteger(status) && status >= 400 && status <= 599
? `${description} (HTTP ${status})`
: description;
}
export async function applyReleasePleasePatch(
directory = dirname(require.resolve("release-please/package.json")),
) {
+3
View File
@@ -7,6 +7,7 @@ import xml.etree.ElementTree as ET
from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
from website import PageMetadata, page_url
from website_agents import prepare_discovery, prepare_markdown
EXAMPLE_FILES = (
@@ -81,6 +82,8 @@ def prepare_assets(site):
prepare_sharing_image(site)
prepare_sitemap(site)
prepare_notes_example(site)
prepare_discovery(Path(__file__).resolve().parent.parent, site)
prepare_markdown(site)
if __name__ == "__main__":
+2 -1
View File
@@ -4,6 +4,7 @@ import { appendFile } from "node:fs/promises";
import { parseArgs, promisify } from "node:util";
import {
applyReleasePleasePatch,
describeReleaseProposalError,
releaseProposalSearchDepth,
} from "./lib/release-proposal.mjs";
@@ -63,7 +64,7 @@ async function main() {
await main().catch((error) => {
process.stderr.write(
`Release proposal failed: ${error instanceof Error ? error.message : String(error)}\n`,
`Release proposal failed: ${describeReleaseProposalError(error)}\n`,
);
process.exitCode = 1;
});
+109
View File
@@ -0,0 +1,109 @@
"""Check the public build artifacts and Markdown content contract."""
import hashlib
import json
from pathlib import Path
import tempfile
import unittest
from zipfile import ZipFile
from bs4 import BeautifulSoup
import yaml
from website_agents import prepare_discovery, prepare_markdown
ROOT = Path(__file__).resolve().parent.parent
class AgentPublishingChecks(unittest.TestCase):
def test_published_skill_digest_covers_every_reference_and_catalogs_resolve(self):
site = ROOT / "website/_site"
index = json.loads((site / ".well-known/agent-skills/index.json").read_text())
self.assertEqual(index["$schema"], "https://schemas.agentskills.io/discovery/0.2.0/schema.json")
skill, = index["skills"]
self.assertEqual(skill["name"], "reverse-engineer-anything")
self.assertEqual(skill["type"], "archive")
artifact = site / skill["url"].removeprefix("https://rea.tools/")
self.assertEqual(skill["digest"], "sha256:" + hashlib.sha256(artifact.read_bytes()).hexdigest())
source = ROOT / ".agents/skills/reverse-engineer-anything"
with ZipFile(artifact) as archive:
expected = {f"{source.name}/{p.relative_to(source).as_posix()}": p.read_bytes()
for p in source.rglob("*.md")}
self.assertEqual(set(archive.namelist()), set(expected))
for name, data in expected.items():
self.assertEqual(archive.read(name), data)
manifest = archive.read(f"{source.name}/SKILL.md").decode()
metadata = yaml.safe_load(manifest[4:].split("\n---\n", 1)[0])
self.assertEqual(skill["description"], metadata["description"])
catalog = json.loads((site / ".well-known/ai-catalog.json").read_text())
self.assertEqual(catalog["specVersion"], "1.0")
entry, = catalog["entries"]
self.assertEqual(entry["identifier"], "urn:air:rea.tools:skill:reverse-engineer-anything")
self.assertEqual(entry["url"], skill["url"])
self.assertNotIn("data", entry)
ard = json.loads((site / ".well-known/ard.json").read_text())
resource, = ard["entries"]
self.assertEqual(resource["url"], skill["url"])
self.assertTrue(2 <= len(resource["representativeQueries"]) <= 5)
def test_reference_changes_invalidate_digest_and_yaml_scalars_are_parsed(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = root / ".agents/skills/reverse-engineer-anything"
source.mkdir(parents=True)
(source / "SKILL.md").write_text(
'---\nname: reverse-engineer-anything\ndescription: >-\n Inspect programs:\n preserve evidence.\n---\n# REA\n'
)
(source / "references").mkdir()
reference = source / "references/evidence.md"
reference.write_text("First revision")
site = root / "site"
def build():
prepare_discovery(root, site)
return json.loads((site / ".well-known/agent-skills/index.json").read_text())["skills"][0]
before = build()
self.assertEqual(before["description"], "Inspect programs: preserve evidence.")
self.assertEqual(build(), before)
reference.write_text("Second revision")
self.assertNotEqual(build()["digest"], before["digest"])
def test_markdown_preserves_content_and_resources_without_publishing_excluded_pages(self):
with tempfile.TemporaryDirectory() as directory:
site = Path(directory)
page = site / "zh-CN/guides/test/index.html"
page.parent.mkdir(parents=True)
page.write_text('''<html><head><title>REA: test</title></head><body>
<nav>Outside navigation</nav><main><aside>Shortened display paths.</aside>
<h1>Inspect a program</h1><nav>Contents navigation</nav>
<ol><li><span class="flow-label">Read</span><span class="flow-node"><code>input.exe</code></span><span class="flow-note">Keep evidence.</span></li></ol>
<p><a href="../other/?q=a%2Fb#code">Another guide</a></p>
<pre><code>const value = a &lt; b ? "x_y" : "&amp;";\n keepIndent();</code></pre>
<details><summary>Evidence</summary><p>Hidden evidence remains available.</p></details>
<img src="../../../assets/diagram.svg" alt="Investigation diagram">
<button>Copy code</button><script>doNotPublish()</script></main></body></html>''')
fixture = site / "examples/notes-web/index.html"
fixture.parent.mkdir(parents=True)
fixture.write_text("<main>Target application</main>")
fallback = site / "zh-CN/fallback/index.html"
fallback.parent.mkdir(parents=True)
fallback.write_text('<head><meta name="robots" content="noindex, follow"></head><main>English fallback notice.</main>')
prepare_markdown(site)
markdown = page.with_suffix(".md").read_text()
self.assertIn('# Inspect a program', markdown)
self.assertIn('const value = a < b ? "x_y" : "&";\n keepIndent();', markdown)
self.assertIn("Hidden evidence remains available.", markdown)
self.assertIn("Shortened display paths.", markdown)
self.assertNotIn("Read`input.exe`", markdown)
self.assertIn("https://rea.tools/zh-CN/guides/other/?q=a%2Fb#code", markdown)
self.assertIn("https://rea.tools/assets/diagram.svg", markdown)
for text in ("Outside navigation", "Contents navigation", "Copy code", "doNotPublish"):
self.assertNotIn(text, markdown)
self.assertFalse(fixture.with_suffix(".md").exists())
self.assertFalse(fallback.with_suffix(".md").exists())
html = BeautifulSoup(page.read_text(), "html.parser")
self.assertEqual(html.find("link", rel="alternate")["href"], "https://rea.tools/zh-CN/guides/test/index.md")
if __name__ == "__main__":
unittest.main()
-1
View File
@@ -358,7 +358,6 @@ try {
`versionCode=${String(details.version_code)}`,
);
}
} finally {
await rm(transferRoot, { recursive: true, force: true });
}
+21 -16
View File
@@ -38,15 +38,16 @@ if (
!isAbsolute(strace ?? "")
)
throw new Error(
"verify:binary:layout requires Linux x64, absolute REA_PWNTOOLS_PYTHON (pwntools 4.15.0/pyelftools 0.33/Unicorn 2.1.2) and REA_VERIFY_STRACE_COMMAND. gcc, ld, strip and the i686/ARM/MIPS/MIPSel linux-gnu cross gcc and readelf must be available. No target is executed.",
"verify:binary:layout requires Linux x64, absolute REA_PWNTOOLS_PYTHON (pwntools 4.15.0/pyelftools 0.33/Unicorn 2.1.2) and REA_VERIFY_STRACE_COMMAND. gcc, ld, strip and the AArch64/i686/ARM/MIPS/MIPSel linux-gnu cross gcc and readelf must be available. No target is executed.",
);
const run = createVerifierRun();
const execute = promisify(execFile);
const ELF32_TOOLCHAINS = [
["i686-linux-gnu", "EM_386", "little"],
["arm-linux-gnueabi", "EM_ARM", "little"],
["mips-linux-gnu", "EM_MIPS", "big"],
["mipsel-linux-gnu", "EM_MIPS", "little"],
const CROSS_TOOLCHAINS = [
["aarch64-linux-gnu", "EM_AARCH64", 64, "little"],
["i686-linux-gnu", "EM_386", 32, "little"],
["arm-linux-gnueabi", "EM_ARM", 32, "little"],
["mips-linux-gnu", "EM_MIPS", 32, "big"],
["mipsel-linux-gnu", "EM_MIPS", 32, "little"],
];
for (const command of [
"gcc",
@@ -54,7 +55,7 @@ for (const command of [
"strip",
"/usr/bin/prlimit",
strace,
...ELF32_TOOLCHAINS.flatMap(([prefix]) => [
...CROSS_TOOLCHAINS.flatMap(([prefix]) => [
`${prefix}-gcc`,
`${prefix}-readelf`,
]),
@@ -347,14 +348,15 @@ try {
cases++;
}
}
// ELF32 profiles: cross-compiled, never executed, checked against GNU readelf.
// Cross profiles: cross-compiled, never executed, checked against GNU readelf.
for (const [
prefix,
machine,
bits,
byteOrder,
kind,
flags,
] of ELF32_TOOLCHAINS.flatMap((toolchain) => [
] of CROSS_TOOLCHAINS.flatMap((toolchain) => [
[...toolchain, "executable", ["-fPIE", "-pie"]],
[...toolchain, "relocatable", ["-c", "-fPIC"]],
])) {
@@ -423,16 +425,18 @@ try {
`readelf relocation count: ${name}`,
);
for (const [entryIndex, [, offset, info]] of rows.entries()) {
// ELF32 r_info uses the low byte for type and the upper 24 bits for
// the symbol index. readelf has already decoded the file's byte order.
// ELF32 r_info packs an 8-bit type under a 24-bit symbol index;
// ELF64 packs a 32-bit type under a 32-bit index. readelf has already
// decoded the file's byte order.
const decodedInfo = BigInt(`0x${info}`);
const typeBits = bits === 32 ? 8n : 32n;
expectedRelocations.push({
section_index: section.index,
entry_index: entryIndex,
reported_offset: BigInt(`0x${offset}`),
type: Number(decodedInfo & 0xffn),
type: Number(decodedInfo & ((1n << typeBits) - 1n)),
symbol_table_index: section.link,
symbol_index: Number(decodedInfo >> 8n),
symbol_index: Number(decodedInfo >> typeBits),
});
}
}
@@ -445,7 +449,7 @@ try {
const value = await inspect(mode, path);
assert.deepEqual(value.architecture, {
machine,
bits: 32,
bits,
byte_order: byteOrder,
});
assert.equal(
@@ -1312,7 +1316,8 @@ try {
}
}
}
unsupported.writeUInt16LE(183, 18);
// EM_RISCV: AArch64 (183) is now an admitted profile.
unsupported.writeUInt16LE(243, 18);
const core = Buffer.from(object);
core.writeUInt16LE(4, 16);
for (const [name, bytes, category] of [
@@ -1337,7 +1342,7 @@ try {
"invalid_input",
],
["undersized-program-header", undersizedProgramHeader, "invalid_input"],
["arm64", unsupported, "unsupported_target"],
["riscv64", unsupported, "unsupported_target"],
["core", core, "unsupported_target"],
]) {
const path = join(root.path, name);
+3 -1
View File
@@ -390,7 +390,9 @@ const report = {
"Linux reverse HTTP/3 capture verification for mitmproxy 12.2.3, aioquic 1.2.0 and guest cURL",
limitations: [
...(inspectExisting
? ["Existing capture producer provenance was not authenticated by this attempt."]
? [
"Existing capture producer provenance was not authenticated by this attempt.",
]
: []),
"No local/WireGuard, browser, QUIC v2, migration, 0-RTT, mTLS or replay claim.",
"No HAR, interruption, certificate-failure or body-completeness coverage in this profile.",
+7 -1
View File
@@ -245,7 +245,13 @@ try {
scope,
{ environment: process.env },
);
if (mismatch.ok || !mismatch.error.message.includes("target-mismatch"))
// A selection failure carries its helper code in the issue, not in its message.
if (
mismatch.ok ||
!mismatch.error.issues?.some(({ message }) =>
message.startsWith("target-mismatch:"),
)
)
throw new Error("Real helper did not reject changed target bytes");
report = {
ok: true,
+75 -3
View File
@@ -28,7 +28,10 @@ import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { inspectKunaInstallation } from "../dist/kuna/KunaInstallation.js";
import { ArtifactResourceScope } from "../dist/artifacts/ArtifactResourceScope.js";
import { AnalysisCleanupLease } from "../dist/artifacts/AnalysisCleanupLease.js";
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
import {
requireMcpOperationResult,
requireMcpToolError,
} from "./lib/mcp-verifier-results.mjs";
import { verifyKunaReferences } from "./lib/kuna-reference-verifier.mjs";
import {
nativeKunaStrings,
@@ -172,7 +175,7 @@ const sampleTree = async (root) => {
};
/** One MCP server with a selected provider, sampled while it runs. */
const openServer = async (provider) => {
const openServer = async (provider, kunaSettings = {}) => {
const owner = Symbol(provider.id);
pendingServers.add(owner);
const transport = new StdioClientTransport({
@@ -185,6 +188,7 @@ const openServer = async (provider) => {
REA_PROCESS_RUN_ID: run.run_id,
REA_KUNA_COMMAND: kuna,
REA_KUNA_CACHE_DIR: cacheRoot,
...kunaSettings,
...(process.env.GHIDRA_INSTALL_DIR === undefined
? {}
: { GHIDRA_INSTALL_DIR: process.env.GHIDRA_INSTALL_DIR }),
@@ -229,6 +233,13 @@ const openServer = async (provider) => {
structured: result.structuredContent,
};
};
const callError = async (name, arguments_ = {}) => {
const result = await client.callTool(
{ name, arguments: arguments_ },
{ timeout: 3_600_000 },
);
return requireMcpToolError(result);
};
const close = async (priorFailure) => {
clearInterval(timer);
await sample();
@@ -270,7 +281,59 @@ const openServer = async (provider) => {
// A rejected connection/target still owns its transport and workspace.
await close(cause);
}
return { call, close };
return { call, callError, close };
};
/** Verify a forced inventory timeout can be retried without changing jobs. */
const verifyProjectTimeoutRecovery = async (path, address) => {
const before = createHash("sha256")
.update(await readFile(path))
.digest("hex");
const settings = {
REA_KUNA_JOBS: "2",
REA_KUNA_TIMEOUT_MS: "60000",
REA_KUNA_PROJECT_TIMEOUT_MS: "1",
};
const timed = await openServer({ path, id: "kuna" }, settings);
let timedFailure;
try {
const failure = await timed.callError("procedure_assembly", {
procedure: address,
});
assert.equal(failure.code, "capability_unavailable");
assert.match(failure.message, /kuna functions/u);
assert.match(failure.remediation.action, /REA_KUNA_PROJECT_TIMEOUT_MS/u);
assert.doesNotMatch(failure.remediation.action, /REA_KUNA_JOBS/u);
} catch (cause) {
timedFailure = cause;
} finally {
await timed.close(timedFailure);
}
const retry = await openServer(
{ path, id: "kuna" },
{ ...settings, REA_KUNA_PROJECT_TIMEOUT_MS: "60000" },
);
let retryFailure;
try {
const assembly = await retry.call("procedure_assembly", {
procedure: address,
});
assert.equal(typeof assembly.value, "string");
assert.ok(assembly.value.length > 0);
assert.equal(assembly.structured.analysis_profile.parameters.jobs, 2);
} catch (cause) {
retryFailure = cause;
} finally {
await retry.close(retryFailure);
}
assert.equal(
createHash("sha256")
.update(await readFile(path))
.digest("hex"),
before,
"Kuna timeout recovery changed the target",
);
return { forced_deadline_ms: 1, retry_deadline_ms: 60000, jobs: 2 };
};
/** Count structuring and diagnostic markers in provider pseudocode. */
@@ -535,6 +598,7 @@ const verifyVariant = async ({ flavor, directory, symbols }, variant) => {
};
const results = [];
let timeoutRecovery;
const failures = [];
let inspectionResourcesClean = true;
try {
@@ -596,6 +660,11 @@ try {
}),
});
timeoutRecovery = await verifyProjectTimeoutRecovery(
built,
fixtureFunctions.get("rea_checksum"),
);
// Caller-built matrix.
for (const pair of callerPairs)
for (const variant of [pair.file, `${pair.file}.stripped`])
@@ -650,6 +719,9 @@ const report = {
platform: `${process.platform}/${process.arch}`,
verifier_run: lifecycle,
results,
...(timeoutRecovery === undefined
? {}
: { timeout_recovery: timeoutRecovery }),
limitations: [
"Targets are analyzed statically; they are never executed.",
"Memory is the sampled peak resident set of the MCP server process tree; CPU is its sampled cumulative time. A short-lived peak between 200 ms samples is not observed.",
+2 -1
View File
@@ -327,8 +327,9 @@ def main():
refresh_contributors(root)
elif args.command == "check":
sync_pages(root, check=True)
for name in ("prepare-website.py", "verify-website.py", "test-website.py"):
for name in ("prepare-website.py", "verify-website.py", "test-website.py", "test-website-agents.py"):
run_script(root, name)
subprocess.run(["node", "--test", "website/worker.test.mjs"], cwd=root, check=True)
elif args.command == "links":
run_script(root, "audit-website-links.py", ["--report", args.report] if args.report else [])
else:
+116
View File
@@ -0,0 +1,116 @@
"""Build public agent discovery and Markdown from their maintained sources."""
import hashlib
import json
from urllib.parse import urljoin
from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
from bs4 import BeautifulSoup
from markdownify import MarkdownConverter
import yaml
from website import NOTES_FIXTURES, PageMetadata, SITE_ORIGIN, page_url
def write_json(path, value):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def prepare_discovery(root, site):
"""Publish the complete skill, including its relative reference documents."""
source = root / ".agents/skills/reverse-engineer-anything"
skill = (source / "SKILL.md").read_text(encoding="utf-8")
if not skill.startswith("---\n") or "\n---\n" not in skill[4:]:
raise ValueError("Public REA skill needs YAML frontmatter")
metadata = yaml.safe_load(skill[4:].split("\n---\n", 1)[0])
if not isinstance(metadata, dict):
raise ValueError("Public REA skill needs YAML metadata")
name, description = metadata["name"], metadata["description"]
if name != source.name or not isinstance(description, str) or not description.strip():
raise ValueError("Invalid public REA skill metadata")
discovery = site / ".well-known/agent-skills"
discovery.mkdir(parents=True, exist_ok=True)
artifact = discovery / f"{name}.zip"
with ZipFile(artifact, "w") as archive:
for path in [source / "SKILL.md", *sorted(source.glob("references/**/*.md"))]:
relative = path.relative_to(source)
entry = ZipInfo(f"{name}/{relative.as_posix()}", (1980, 1, 1, 0, 0, 0))
entry.create_system = 3
entry.external_attr = 0o100644 << 16
archive.writestr(entry, path.read_bytes(), compress_type=ZIP_DEFLATED)
artifact_url = f"{SITE_ORIGIN}/.well-known/agent-skills/{artifact.name}"
write_json(discovery / "index.json", {
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
"skills": [{
"name": name, "type": "archive", "description": description,
"url": artifact_url,
"digest": "sha256:" + hashlib.sha256(artifact.read_bytes()).hexdigest(),
}],
})
entry = {
"identifier": f"urn:air:rea.tools:skill:{name}",
"displayName": "REA reverse engineering skill",
"description": description,
"type": "application/zip",
"url": artifact_url,
}
write_json(site / ".well-known/ai-catalog.json", {
"specVersion": "1.0",
"host": {"displayName": "REA", "documentationUrl": f"{SITE_ORIGIN}/get-started/"},
"entries": [entry],
})
write_json(site / ".well-known/ard.json", {"entries": [{
**entry,
"representativeQueries": [
"Explain how a feature works in a shipped executable using REA.",
"Trace an Electron application's behavior back to its packaged JavaScript.",
"Build an AgentEnv bundle from a web application capture.",
],
}]})
print("Prepared agent skill archive, discovery index, AI Catalog and ARD manifest.")
def prepare_markdown(site):
"""Convert rendered reading pages, keeping code and resolving resource URLs."""
converter = MarkdownConverter(heading_style="ATX", bullets="-", escape_underscores=False)
count = 0
for path in sorted(site.rglob("*.html")):
if path.relative_to(site).as_posix() in NOTES_FIXTURES:
continue
source = path.read_text(encoding="utf-8")
metadata = PageMetadata()
metadata.feed(source)
# Drafts and English fallbacks keep their HTML indexing policy intact.
if metadata.noindex:
continue
soup = BeautifulSoup(source, "html.parser")
main = soup.find("main")
if main is None:
continue
# Preserve fallback notices and closed disclosures; remove interface chrome.
for node in main.select('script, style, nav, button, svg, .home-page-nav, [aria-hidden="true"]'):
if node.parent is not None:
node.decompose()
# These spans are separate lines in the shared flow component's CSS.
for node in main.select("span.flow-label, span.flow-node, span.flow-note"):
node.name = "div"
canonical = page_url(site, path)
for node in main.select("[href], [src]"):
for attribute in ("href", "src"):
if node.has_attr(attribute):
node[attribute] = urljoin(canonical, node[attribute])
title = metadata.titles[0] if metadata.titles else "REA"
header = yaml.safe_dump({"title": title, "url": canonical}, allow_unicode=True, sort_keys=False)
markdown = converter.convert_soup(main).strip()
path.with_suffix(".md").write_text(f"---\n{header}---\n\n{markdown}\n", encoding="utf-8")
alternate = page_url(site, path.with_suffix(".md"))
links = (
f'<link rel="alternate" type="text/markdown" href="{alternate}" />\n'
f'<link rel="ard" type="application/json" href="{SITE_ORIGIN}/.well-known/ard.json" />\n'
f'<link rel="ai-catalog" type="application/ai-catalog+json" href="{SITE_ORIGIN}/.well-known/ai-catalog.json" />\n'
f'<link rel="related" type="application/json" title="Agent skills index" href="{SITE_ORIGIN}/.well-known/agent-skills/index.json" />\n'
)
path.write_text(source.replace("</head>", links + "</head>", 1), encoding="utf-8")
count += 1
print(f"Prepared Markdown for {count} reading pages.")
+127 -1
View File
@@ -1,6 +1,7 @@
import { readArtifactInspectionSources } from "../domain/artifactInspection.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import type { Evidence } from "../domain/evidence.js";
import { tryParseEvidence, type Evidence } from "../domain/evidence.js";
import { jsonValueByteLength } from "../domain/jsonValueByteLength.js";
import { err, ok, type Result } from "../domain/result.js";
/** Resolve the embedded publication closure without reinterpreting it in delivery adapters. */
@@ -15,3 +16,128 @@ export const analysisEvidenceSources = (
return err(embedded.error.retainPartialObservation(complete));
return ok([...embedded.value, complete]);
};
/** Matches the diagnostics allowance retained for other finalized one-shot work. */
const SOURCE_CLOSURE_INLINE_BYTES = 64 * 1024;
/** Identities beyond this stay counted; direct links remain on the observation. */
const SOURCE_CLOSURE_LISTED_OMISSIONS = 64;
const SOURCE_CLOSURE_RECOVERY =
"No bundle was written and this invocation retains nothing after exit. Keep any records shown here, or run the operation again after following remediation; that is a new observation and may differ.";
/** What a failed one-shot delivery holds for one record its observation cites. */
export type SourceClosureOmission = {
readonly evidence_id: string;
/**
* `not_captured`: this invocation holds no such record, so none can be shown.
* `not_inlined`: captured, but not part of this bounded failure response.
*/
readonly reason: "not_captured" | "not_inlined";
};
/** Bounded failure handoff for a completed observation whose bundle never committed. */
export type UndeliveredSourceClosure = {
readonly publication: "not_published";
/** Every cited record is in `records` or embedded in the observation itself. */
readonly complete: boolean;
readonly records: Evidence[];
readonly omitted_count: number;
readonly omitted: SourceClosureOmission[];
readonly recovery: string;
};
/** The closure state is unknown because examining it failed. */
export type UnavailableSourceClosure = {
readonly publication: "not_published";
readonly complete: false;
readonly collection_failure: string;
readonly recovery: string;
};
const collectSourceClosure = (
observation: Evidence,
captured: readonly Evidence[],
options: { readonly inlineRecords: boolean },
): UndeliveredSourceClosure | undefined => {
const inspection = analysisEvidenceSources(observation);
const embedded = new Map(
(inspection.ok ? inspection.value : []).map((record) => [
record.evidence_id,
record,
]),
);
const held = new Map(captured.map((record) => [record.evidence_id, record]));
// Set iteration reaches entries added while it runs, which makes this a
// deduplicating breadth-first queue: directly cited records spend the
// allowance first, and each link costs one lookup however often it repeats.
const closure = new Set([observation.evidence_id]);
for (const evidenceId of observation.evidence_links) closure.add(evidenceId);
for (const evidenceId of held.keys()) closure.add(evidenceId);
const records: Evidence[] = [];
const omitted: SourceClosureOmission[] = [];
let omittedCount = 0;
let inlineBytes = options.inlineRecords ? SOURCE_CLOSURE_INLINE_BYTES : 0;
const omit = (
evidenceId: string,
reason: SourceClosureOmission["reason"],
) => {
omittedCount += 1;
if (omitted.length < SOURCE_CLOSURE_LISTED_OMISSIONS)
omitted.push({ evidence_id: evidenceId, reason });
};
for (const evidenceId of closure) {
if (evidenceId === observation.evidence_id) continue;
const inObservation = embedded.get(evidenceId);
const record = inObservation ?? held.get(evidenceId);
if (record === undefined) {
omit(evidenceId, "not_captured");
continue;
}
for (const link of record.evidence_links) closure.add(link);
if (inObservation !== undefined) continue;
const bytes = jsonValueByteLength(record, inlineBytes);
if (bytes > inlineBytes) {
omit(evidenceId, "not_inlined");
continue;
}
records.push(record);
inlineBytes -= bytes;
}
if (closure.size === 1) return undefined;
return {
publication: "not_published",
complete: omittedCount === 0,
records,
omitted_count: omittedCount,
omitted,
recovery: SOURCE_CLOSURE_RECOVERY,
};
};
/**
* Describe the source closure of a completed observation after delivery failed
* before commit. Only already captured records are reported, so an unresolved
* link stays an explicit omission and its own ancestors remain unknown.
*
* This annotates a failure that must reach the caller, so it never throws. If
* examining the closure fails, the state is reported as unknown, naming only
* the bounded error type rather than a message that could quote analyzed data.
*/
export const undeliveredSourceClosure = (
partialObservation: unknown,
captured: readonly Evidence[],
options: { readonly inlineRecords: boolean },
): UndeliveredSourceClosure | UnavailableSourceClosure | undefined => {
try {
const observation = tryParseEvidence(partialObservation);
return observation === undefined
? undefined
: collectSourceClosure(observation, captured, options);
} catch (cause: unknown) {
return {
publication: "not_published",
complete: false,
collection_failure: cause instanceof Error ? cause.name : "unknown",
recovery: SOURCE_CLOSURE_RECOVERY,
};
}
};
+17 -16
View File
@@ -461,10 +461,11 @@ const resolveLayoutExecutable = async (
let name: string;
try {
name = hasBinaryPlistSignature(plist)
? await (fileSystem.decodeBinaryPlist?.(plistPath) ??
parseBinaryPlistExecutable(plist))
: parseXmlPlistExecutable(decodeXmlPlistText(plist));
name =
hasBinaryPlistSignature(plist) &&
fileSystem.decodeBinaryPlist !== undefined
? await fileSystem.decodeBinaryPlist(plistPath)
: executableName(decodeInfoPlistValue(plist));
} catch (cause: unknown) {
return err(
new BinaryTargetError(
@@ -546,12 +547,18 @@ const resolveLayoutExecutable = async (
}
};
/** Decode the top-level executable name with an XML parser, not a pattern. */
const parseXmlPlistExecutable = (plist: string): string => {
// An unrelated `__proto__` entry must not make the bundle unreadable.
const { value } = parseXmlPropertyList(plist);
return executableName(value);
};
/**
* Decode one Info.plist's top-level value in-process, binary or XML, so no
* host plist tool is required. Values are read with format-aware parsers, not
* patterns; an unrelated `__proto__` entry must not make the bundle unreadable.
*/
export const decodeInfoPlistValue = (plist: Buffer): unknown =>
hasBinaryPlistSignature(plist)
? decodeBinaryPlistValue(
plist,
preflightPropertyListDecode(plist, BINARY_PLIST_DECODE_BYTES),
)
: parseXmlPropertyList(decodeXmlPlistText(plist)).value;
/** The Info.plist decoded but declares no string CFBundleExecutable. */
class UndeclaredBundleExecutable extends Error {}
@@ -565,12 +572,6 @@ const executableName = (value: unknown): string => {
const executableEntrySchema = z.looseObject({ CFBundleExecutable: z.string() });
/** Decode a binary plist in-process, so no host plist tool is required. */
const parseBinaryPlistExecutable = (plist: Buffer): string => {
const plan = preflightPropertyListDecode(plist, BINARY_PLIST_DECODE_BYTES);
return executableName(decodeBinaryPlistValue(plist, plan));
};
/** Names are one local path component; backslash is literal on POSIX hosts. */
const isSafeExecutableName = (name: string): boolean =>
name.length > 0 &&
+10 -7
View File
@@ -46,6 +46,15 @@ export type CallRelationshipRead =
| { readonly status: "unreadable"; readonly error: AnalysisOutputError }
| { readonly status: "stopped"; readonly error: AnalysisError };
/**
* Whether a failed child read must end a traversal rather than become one of
* its unknowns. A cancelled read ends it; so does a read whose provider cannot
* confirm release of resources it owns, because that read has not finished and
* dispatching more reads would build on that state.
*/
export const stopsTraversal = (error: AnalysisError): boolean =>
error._tag === "AnalysisCancelledError" || error.cleanupIncomplete;
/** Read and parse one procedure's direct relationships for a traversal. */
export const readCallRelationships = async (
observe: CallRelationshipObserver,
@@ -57,13 +66,7 @@ export const readCallRelationships = async (
const execution = await observe(operation, { procedure }, signal, "provider");
if (!execution.ok)
return {
// A provider that cannot confirm release of resources it owns has not
// finished this read; dispatching more reads would build on that state.
status:
execution.error._tag === "AnalysisCancelledError" ||
execution.error.cleanupIncomplete
? "stopped"
: "unavailable",
status: stopsTraversal(execution.error) ? "stopped" : "unavailable",
error: execution.error,
};
const related = parseRelatedAddresses(
@@ -8,6 +8,7 @@ import {
compactToolAvailability,
} from "./CapabilityInventory.js";
import { TOOL_CONTRACTS } from "../contracts/toolContracts.js";
import { cleanupRetryRemediation } from "../domain/analysisErrorRecovery.js";
import {
expandToolAvailability,
toolAvailabilityInventory,
@@ -52,6 +53,29 @@ const enabledPolicy: Parameters<typeof buildCapabilityInventory>[1] = {
v8InspectorObservationEnabled: true,
};
it("adds the retaining close owner's prerequisite without guessing from a pending flag", () => {
const retry = { operation: "close_binary" } as const;
const input = { open: false, capabilities: [], cleanup_pending: true };
const unavailable = buildCapabilityInventory(
{ ...input, cleanup_retry: retry },
enabledPolicy,
).find(({ name }) => name === "procedure_assembly");
expect(unavailable).toMatchObject({
available: false,
reason: "target_required",
remediation: expect.stringContaining(cleanupRetryRemediation(retry)),
});
const unknownOwner = buildCapabilityInventory(input, enabledPolicy).find(
({ name }) => name === "procedure_assembly",
);
const original = buildCapabilityInventory(
{ open: false, capabilities: [] },
enabledPolicy,
).find(({ name }) => name === "procedure_assembly");
expect(unknownOwner).toEqual(original);
expect(unknownOwner?.reason).toBe("target_required");
});
const status = (
options: {
readonly open?: boolean;
@@ -856,6 +880,62 @@ describe("trace_feature requirements", () => {
});
});
describe("firmware operation recovery", () => {
it.each([
[false, false],
[true, false],
[false, true],
[true, true],
])(
"keeps inspection=%s and extraction=%s independent",
(inspection, extraction) => {
const inventory = buildCapabilityInventory(status(), {
processCaptureEnabled: false,
firmwareInspectionEnabled: inspection,
firmwareExtractionEnabled: extraction,
});
for (const [name, enabled, command, otherCommand] of [
[
"inspect_firmware_regions",
inspection,
"REA_BINWALK_COMMAND",
"REA_UNBLOB_COMMAND",
],
[
"extract_firmware",
extraction,
"REA_UNBLOB_COMMAND",
"REA_BINWALK_COMMAND",
],
] as const) {
const tool = inventory.find((candidate) => candidate.name === name);
expect(tool).toMatchObject({
available: enabled,
reason: enabled ? "available" : "provider_missing",
});
if (enabled) {
expect(tool?.remediation).toBeNull();
} else {
expect(tool?.remediation).toContain(command);
expect(tool?.remediation).not.toContain(otherCommand);
expect(tool?.remediation).toContain("prlimit");
expect(tool?.remediation).toContain("procps ps");
expect(tool?.remediation?.includes("external extractor")).toBe(
name === "extract_firmware",
);
}
}
const byName = (left: ToolAvailability, right: ToolAvailability) =>
left.name.localeCompare(right.name);
expect(
expandToolAvailability(compactToolAvailability(inventory)).toSorted(
byName,
),
).toEqual(inventory.toSorted(byName));
},
);
});
const HOST_RESTRICTED_TOOLS = [
["inspect_packet_capture", "packetCapture", "packetCaptureEnabled"],
["inspect_packet_fields", "packetCapture", "packetCaptureEnabled"],
+23 -4
View File
@@ -1,6 +1,9 @@
import { PCAP_CAPTURE_UNSUPPORTED_HOST_REASON } from "../domain/pcap/pcapAnalysis.js";
import { DEFAULT_CALL_RELATIONSHIP_DIRECTION } from "../contracts/enhancedInputs.js";
import { FIRMWARE_TOOL_REQUIREMENTS } from "../contracts/firmware/firmwareToolRequirements.js";
import {
FIRMWARE_OPERATION_ENGINES,
FIRMWARE_TOOL_REQUIREMENTS,
} from "../contracts/firmware/firmwareToolRequirements.js";
import { CALL_RELATIONSHIP_TRAVERSALS } from "./CallRelationshipTraversal.js";
import { NATIVE_DISPATCH_METADATA_SOURCES } from "./native/NativeDispatchMetadataInspection.js";
import { NATIVE_UI_ACTION_OPERATIONS } from "./native/NativeUiActionTrace.js";
@@ -42,6 +45,11 @@ import type { JebAnalysisAvailability } from "./jeb/JebAnalysisPort.js";
import { FRIDA_TOOL_CONTRACTS } from "../contracts/fridaToolContracts.js";
import { TOOL_CONTRACTS } from "../contracts/toolContracts.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import {
analysisCleanupRetrySchema,
cleanupRetryRemediation,
type AnalysisCleanupRetry,
} from "../domain/analysisErrorRecovery.js";
import { isBundleResourceOperation } from "./binary/BundleResourceSession.js";
const FRIDA_OPERATION_NAMES: ReadonlySet<string> = new Set(
@@ -50,6 +58,7 @@ const FRIDA_OPERATION_NAMES: ReadonlySet<string> = new Set(
const statusSchema = z.object({
open: z.boolean(),
cleanup_retry: analysisCleanupRetrySchema.optional(),
session_kind: z.literal("bundle_resources").optional(),
kind: z.enum(["executable", "database", "archive", "artifact"]).optional(),
format: z.string().optional(),
@@ -135,6 +144,7 @@ type AvailabilityContext = {
readonly name: string;
readonly kind: ToolKind;
readonly targetOpen: boolean;
readonly cleanupRetry: AnalysisCleanupRetry | undefined;
readonly resourceSession: boolean;
readonly requiresActiveTarget: boolean;
readonly targetKind:
@@ -263,6 +273,7 @@ export const buildCapabilityInventory = (
name: contract.name,
kind: contract.kind,
targetOpen: status.open,
cleanupRetry: status.cleanup_retry,
resourceSession: status.session_kind === "bundle_resources",
requiresActiveTarget:
"requiresActiveTarget" in contract &&
@@ -587,15 +598,19 @@ const workflowAvailabilityFor = ({
"On Linux x64, set absolute REA_PWNTOOLS_PYTHON to caller-supplied Python with pwntools 4.15.0. No active binary target is required.",
);
if (kind === "firmware-provider") {
const { binwalk, unblob } = FIRMWARE_TOOL_REQUIREMENTS;
const inspection = name === "inspect_firmware_regions";
const operation = inspection
? "inspect_firmware_regions"
: "extract_firmware";
const requirement =
FIRMWARE_TOOL_REQUIREMENTS[FIRMWARE_OPERATION_ENGINES[operation]];
return hostRestrictedAvailability(
policy,
inspection ? "firmwareInspection" : "firmwareExtraction",
inspection
? policy.firmwareInspectionEnabled
: policy.firmwareExtractionEnabled,
`On Linux, provide an absolute ${binwalk.commandEnvironment} (${binwalk.name} ${binwalk.line}, verified with ${binwalk.version}) or ${unblob.commandEnvironment} (${unblob.name} ${unblob.line}, verified with ${unblob.version}) path and util-linux prlimit. Extraction also requires the selected format’s external extractor.`,
`On Linux, provide an absolute ${requirement.commandEnvironment} (${requirement.requirement}) path, util-linux prlimit and procps ps.${inspection ? "" : " Extraction also requires the selected format’s external extractor."}`,
);
}
if (kind === "android-provider")
@@ -779,13 +794,17 @@ const targetAvailability = ({
targetKind,
targetFormat,
targetOpen,
cleanupRetry,
requiresActiveTarget,
descriptors,
}: AvailabilityContext): Availability | null => {
if (!targetOpen && requiresActiveTarget)
return {
reason: "target_required",
remediation: "Call open_binary with a supported local target.",
remediation:
cleanupRetry === undefined
? "Call open_binary with a supported local target."
: `${cleanupRetryRemediation(cleanupRetry)} After cleanup succeeds, call open_binary with a supported local target.`,
};
const descriptor = descriptors.get(name);
if (
@@ -127,6 +127,62 @@ export const clientRegistrationEnvironmentDetails = (
return validatedDetails(environment, validation, platform);
};
/** Setting names a registration does not carry as the value selected here. */
export interface RecordedProviderSettings {
/** Selected here and absent from the registration's recorded environment. */
readonly unrecorded: readonly string[];
/** Recorded with text other than the value selected here. */
readonly differing: readonly string[];
/** Recorded as an expression, or in a map REA could not read. */
readonly unresolved: readonly string[];
}
/**
* Compare settings selected in this process with a registration's own file.
* The server's environment also depends on what its client passes on, which is
* not observable here, so a gap is never a claim about the effective value.
* Returns only names: recorded values stay in the file that owns them.
*/
export const recordedProviderSettings = (
parsed: ClientConfigurationDocument,
selected: Readonly<Record<string, string>>,
platform: NodeJS.Platform = process.platform,
): RecordedProviderSettings | undefined => {
const names = Object.keys(selected).sort(compareUnicodeCodePoints);
if (names.length === 0) return undefined;
const details = clientRegistrationEnvironmentDetails(
parsed,
{},
"fragment",
platform,
);
if (!details.ok) return { unrecorded: [], differing: [], unresolved: names };
const recorded = snapshotEnvironment(details.value.values, platform);
// Expression names fold like every other environment name on this platform.
const expressions = snapshotEnvironment(
Object.fromEntries(
[...details.value.unresolvedSettings].map((name) => [name, ""]),
),
platform,
);
const facts = {
unrecorded: names.filter(
(name) => recorded[name] === undefined && expressions[name] === undefined,
),
differing: names.filter(
(name) =>
recorded[name] !== undefined && recorded[name] !== selected[name],
),
unresolved: names.filter((name) => expressions[name] !== undefined),
};
return facts.unrecorded.length +
facts.differing.length +
facts.unresolved.length ===
0
? undefined
: facts;
};
/** Resolve the async provider-specific budget through its retained reader owner. */
export const clientRegistrationEnvironmentDetailsWithBudget = async (
parsed: ClientConfigurationDocument | undefined,
+26 -2
View File
@@ -34,8 +34,14 @@ import {
import type { SetupClient } from "./SupportedClients.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import { dshDocument } from "./DshCordisPatch.js";
import { clientRegistrationEnvironmentDetailsWithBudget } from "./ClientRegistrationEnvironment.js";
import type { ProviderRequestBudgetResolver } from "./ClientRegistrationEnvironment.js";
import {
clientRegistrationEnvironmentDetailsWithBudget,
recordedProviderSettings,
} from "./ClientRegistrationEnvironment.js";
import type {
ProviderRequestBudgetResolver,
RecordedProviderSettings,
} from "./ClientRegistrationEnvironment.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import {
@@ -52,6 +58,8 @@ interface ClientRegistrationStatusBase {
readonly package_reference?: string;
/** Non-blocking timeout guidance; caller-selected values remain aligned. */
readonly advisory?: string;
/** Provider settings this process selected that the registration's file does not carry. */
readonly provider_settings?: RecordedProviderSettings;
}
type RegistrationCommand = readonly [string, ...string[]];
@@ -99,6 +107,8 @@ interface ClientRegistrationReadOptions {
*/
readonly evidence?: "readiness" | "ownership";
readonly resolveProviderRequestBudget?: ProviderRequestBudgetResolver;
/** Settings to compare with each registration's file; omitted skips that read. */
readonly providerSettings?: Readonly<Record<string, string>>;
}
/** Registration facts shared by diagnostics and update maintenance. */
@@ -362,6 +372,13 @@ export const readClientRegistrationInspections = async (
registrationEnvironment.value.timeoutInputs,
)
: undefined,
options.providerSettings === undefined
? undefined
: recordedProviderSettings(
parsed,
options.providerSettings,
options.platform,
),
),
activation,
commandFacts.identity,
@@ -494,6 +511,7 @@ const configuredStatus = (
policyRemediation?: string,
packageReference?: string,
advisory?: string,
providerSettings?: RecordedProviderSettings,
): ClientRegistrationStatus =>
state === "aligned"
? {
@@ -506,6 +524,9 @@ const configuredStatus = (
state,
remediation: null,
...(advisory === undefined ? {} : { advisory }),
...(providerSettings === undefined
? {}
: { provider_settings: providerSettings }),
}
: {
client: client.name,
@@ -516,6 +537,9 @@ const configuredStatus = (
: { package_reference: packageReference }),
state,
remediation: policyRemediation ?? remediation(client),
...(providerSettings === undefined
? {}
: { provider_settings: providerSettings }),
};
const timeoutAdvisory = (
+7
View File
@@ -59,6 +59,7 @@ import {
composedLimitationBudget,
executeComposedWorkflow,
createWorkflowEvidence,
retainPartialWorkflowEvidence,
workflowSnapshotRecord,
recordWorkflowUnknowns,
} from "./WorkflowEvidence.js";
@@ -627,6 +628,12 @@ const executeAnalysisTool = async (input: {
new EnhancedTools(observed).execute(tool, input.arguments, signal),
composedLimitationBudget(tool),
);
retainPartialWorkflowEvidence(result, {
target: input.openedTarget,
operation: tool,
parameters: input.evidenceParameters,
upstreamProfile: evidenceProfile,
});
result = retainAnalysisResultSources(
tool,
(source) => session.recordEvidence(source),
+104 -8
View File
@@ -57,6 +57,7 @@ import {
type UnhealthyClientRegistrationStatus,
} from "./ClientRegistrationStatus.js";
import type { ProviderRequestBudgetResolver } from "./ClientRegistrationEnvironment.js";
import { registrationSpelledSettings } from "./SetupRegistrationEnvironment.js";
import {
collectDoctorDiagnostics,
doctorHealthy,
@@ -146,6 +147,7 @@ export interface DoctorHost {
close?(): Promise<string | undefined>;
clientRegistrations?(
selectedClientIds?: readonly string[],
providerSettings?: Readonly<Record<string, string>>,
): Promise<readonly ClientRegistrationStatus[]>;
ilspyCmdVersion?(path: string): Promise<string | undefined>;
}
@@ -276,18 +278,26 @@ const inspectDoctor = async (
hopperPath,
providerInspections,
} = await collectDoctorDiagnostics(target, host);
const identity = await collectDoctorIdentity(host, scope);
const hopperAvailable =
hopperPath !== undefined &&
diagnosticChecks
.filter(({ name }) => name.startsWith("hopper"))
.every(({ ok }) => ok);
const selections = providerSettingSelections(
providerInspections,
hopperAvailable && hopperPath === host.configuredHopperPath
? hopperPath
: undefined,
);
const identity = await collectDoctorIdentity(host, scope, selections);
const checks = [
...diagnosticChecks,
...identity.checks,
...registrationAdvisoryChecks(identity.value.registrations),
...registrationEnvironmentChecks(identity.value.registrations, selections),
];
const providers = {
hopperAvailable:
hopperPath !== undefined &&
checks
.filter(({ name }) => name.startsWith("hopper"))
.every(({ ok }) => ok),
hopperAvailable,
hopperConfigured: host.configuredHopperPath !== undefined,
providerInspections,
};
@@ -329,6 +339,77 @@ const registrationAdvisoryChecks = (
],
);
/** Settings an available provider's checks in this report relied on. */
interface ProviderSettingSelection {
readonly provider: string;
readonly settings: Readonly<Record<string, string>>;
}
const providerSettingSelections = (
inspections: readonly DoctorProviderInspection[] | undefined,
configuredHopperPath: string | undefined,
): readonly ProviderSettingSelection[] => [
...(inspections ?? [])
.filter(({ available }) => available)
.map(({ id, registrationEnvironment }) => ({
provider: id,
settings: registrationEnvironment,
})),
// Startup probes the fixed and renamed locations on its own, so only an
// explicit path is something this terminal selected for the server.
...(configuredHopperPath === undefined
? []
: [
{
provider: "hopper",
settings: { HOPPER_LAUNCHER_PATH: configuredHopperPath },
},
]),
];
/**
* A healthy provider check describes this terminal, not the server a client
* starts. Report what the registration's file leaves out without gating health:
* the client may still pass the setting on, which doctor cannot observe.
*/
const registrationEnvironmentChecks = (
registrations: readonly ClientRegistrationStatus[],
selections: readonly ProviderSettingSelection[],
): readonly DoctorCheck[] =>
registrations.flatMap(({ client, provider_settings: settings }) => {
if (settings === undefined) return [];
const { unrecorded, differing, unresolved } = settings;
const names = [...unrecorded, ...differing, ...unresolved];
return [
{
name: `registration_environment:${client}`,
ok: true,
classification: "unknown" as const,
detail: [
`Provider checks used this terminal's environment; what the server ${client} starts receives is unknown.`,
...(unrecorded.length === 0
? []
: [`Its registration does not record ${unrecorded.join(", ")}.`]),
...(differing.length === 0
? []
: [`It records other values for ${differing.join(", ")}.`]),
...(unresolved.length === 0
? []
: [`REA could not resolve its ${unresolved.join(", ")}.`]),
].join(" "),
details: {
effective_environment: "unknown",
providers: selections
.filter(({ settings: selected }) =>
names.some((name) => selected[name] !== undefined),
)
.map(({ provider }) => provider),
},
remediation: `Preview rea setup --client ${client} --skill=false --dry-run from this terminal, apply it to record these settings and restart the client, or read binary_session result.tool_availability in the client.`,
},
];
});
const closeDoctorHost = async (
host: DoctorHost,
): Promise<string | AnalysisError | undefined> => {
@@ -369,12 +450,19 @@ const doctorReportWithCleanup = (
const collectDoctorIdentity = async (
host: DoctorHost,
scope: DoctorScopeReport,
selections: readonly ProviderSettingSelection[],
): Promise<{
readonly checks: readonly DoctorCheck[];
readonly value: DoctorIdentity;
}> => {
const installationPaths = (await host.installationPaths?.()) ?? [];
const registrations = (await host.clientRegistrations?.(scope.clients)) ?? [];
const registrations =
(await host.clientRegistrations?.(
scope.clients,
Object.fromEntries(
selections.flatMap(({ settings }) => Object.entries(settings)),
),
)) ?? [];
const skillClientIds =
scope.skill_clients ??
(scope.clients.length > 0 ? scope.clients : undefined);
@@ -786,12 +874,20 @@ export const systemDoctorHost = (
throw failure;
}
},
clientRegistrations: (selectedClientIds) =>
clientRegistrations: (selectedClientIds, providerSettings) =>
readClientRegistrationStatuses(homeDirectory, undefined, {
platform,
environment,
resources,
...(selectedClientIds === undefined ? {} : { selectedClientIds }),
...(providerSettings === undefined
? {}
: {
providerSettings: registrationSpelledSettings(
providerSettings,
environment,
),
}),
...(resolveProviderRequestBudget === undefined
? {}
: {
@@ -0,0 +1,184 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { createDoctorHostFixture as host } from "./Doctor.fixture.js";
import { runDoctor, systemDoctorHost, type DoctorHost } from "./Doctor.js";
const inspection = (
id: string,
registrationEnvironment: Readonly<Record<string, string>>,
available = true,
) => ({
id,
configured: true,
available,
providerVersion: null,
registrationEnvironment,
checks: [],
});
const recordingHost = (overrides: Partial<DoctorHost>) => {
const requested: Readonly<Record<string, string>>[] = [];
return {
requested,
host: host({
clientRegistrations: (_clients, providerSettings) => {
requested.push(providerSettings ?? {});
return Promise.resolve([
{
client: "cursor",
config_path: "/home/user/.cursor/mcp.json",
command: ["/current/rea", "mcp"],
state: "aligned",
remediation: null,
provider_settings: {
unrecorded: ["GHIDRA_INSTALL_DIR"],
differing: [],
unresolved: [],
},
},
]);
},
...overrides,
}),
};
};
const selectedProviders = {
providerInspections: () =>
Promise.resolve([
inspection("ghidra", { GHIDRA_INSTALL_DIR: "/opt/ghidra" }),
inspection("jadx", { REA_JADX_MCP_JAR: "/opt/jadx.jar" }),
inspection("kuna", { REA_KUNA_COMMAND: "/opt/kuna" }, false),
]),
};
const CHECK = "registration_environment:cursor";
it("compares only settings an available provider relied on", async () => {
const { host: selectedHost, requested } = recordingHost(selectedProviders);
await runDoctor(undefined, selectedHost);
expect(requested).toEqual([
{
GHIDRA_INSTALL_DIR: "/opt/ghidra",
REA_JADX_MCP_JAR: "/opt/jadx.jar",
},
]);
});
it.each([
[{ providers: ["ghidra"] }, true],
[{ clients: ["cursor"] }, true],
[{ clients: ["codex"] }, false],
[{ providers: ["jadx"] }, false],
[{ skill: true }, false],
])("follows the selected scope %j", async (scope, selected) => {
const report = await runDoctor(
undefined,
recordingHost(selectedProviders).host,
scope,
);
const inScope = report.scope_checks.some(({ name }) => name === CHECK);
expect(inScope).toBe(selected);
expect(report.informational_checks.some(({ name }) => name === CHECK)).toBe(
!selected,
);
});
it("does not gate health or name unaffected providers", async () => {
const report = await runDoctor(
undefined,
recordingHost(selectedProviders).host,
{ providers: ["ghidra"] },
);
expect(report.healthy).toBe(true);
expect(report.checks).toContainEqual(
expect.objectContaining({
name: CHECK,
ok: true,
classification: "unknown",
details: { effective_environment: "unknown", providers: ["ghidra"] },
}),
);
});
it.each([
["/custom/Hopper", { HOPPER_LAUNCHER_PATH: "/custom/Hopper" }],
// A default location is found again without this terminal's environment.
[undefined, {}],
])(
"includes Hopper only for an explicit available path (%s)",
async (configuredHopperPath, expected) => {
const { host: selectedHost, requested } = recordingHost(
configuredHopperPath === undefined ? {} : { configuredHopperPath },
);
await runDoctor(undefined, selectedHost);
expect(requested).toEqual([expected]);
},
);
it("does not include an explicit Hopper path that fails its checks", async () => {
const { host: selectedHost, requested } = recordingHost({
configuredHopperPath: "/custom/missing",
executable: () => Promise.resolve(false),
});
await runDoctor(undefined, selectedHost);
expect(requested).toEqual([{}]);
});
it("compares a caller selection as setup spells it", async () => {
const home = await createTestTempDirectory("rea-doctor-spelling-");
await writeFile(
join(home, ".claude.json"),
JSON.stringify({
mcpServers: {
rea: {
command: "/current/rea",
args: ["mcp"],
env: {
GHIDRA_INSTALL_DIR: "/opt/ghidra",
JAVA_HOME: "/selected/alias",
},
},
},
}),
);
const system = systemDoctorHost({
environment: { HOME: home, JAVA_HOME: "/selected/alias" },
});
const statuses = await system.clientRegistrations?.(["claude_code"], {
GHIDRA_INSTALL_DIR: "/opt/ghidra",
// The provider's observed home; setup records the caller's spelling.
JAVA_HOME: "/observed/jdk",
});
await system.close?.();
expect(statuses?.[0]?.client).toBe("claude_code");
expect(statuses?.[0]).not.toHaveProperty("provider_settings");
});
it("states each comparison result without a claim it did not establish", async () => {
const report = await runDoctor(
undefined,
host({
clientRegistrations: () =>
Promise.resolve([
{
client: "cursor",
config_path: "/cursor.json",
command: ["/current/rea", "mcp"],
state: "aligned",
remediation: null,
provider_settings: {
unrecorded: [],
differing: ["JAVA_HOME"],
unresolved: ["GHIDRA_INSTALL_DIR"],
},
},
]),
}),
);
const detail = report.checks.find(({ name }) => name === CHECK)?.detail;
expect(detail).toContain("what the server cursor starts receives is unknown");
expect(detail).toContain("records other values for JAVA_HOME");
expect(detail).toContain("could not resolve its GHIDRA_INSTALL_DIR");
expect(detail).not.toContain("does not record");
});
+20
View File
@@ -72,6 +72,9 @@ export const scopeDoctorChecks = (
input.registrations.find((candidate) => candidate.client === client),
),
);
const environmentChecks = input.checks.filter((check) =>
registrationEnvironmentInScope(check, input.scope),
);
const skillChecks = input.scope.skill
? [selectedSkillCheck(input.skillState, input.checks)]
: [];
@@ -111,6 +114,7 @@ export const scopeDoctorChecks = (
...hopperHostChecks,
...selectedProviderChecks,
...clientChecks,
...environmentChecks,
...skillChecks,
];
const selectedNames = new Set(scopeChecks.map(({ name }) => name));
@@ -129,6 +133,22 @@ export const scopeDoctorChecks = (
};
};
/** Follow the caller's question: a named client, or a provider it relies on. */
const registrationEnvironmentInScope = (
{ name, details }: DoctorCheck,
scope: DoctorScopeReport,
): boolean => {
const [family, client] = name.split(":");
if (family !== "registration_environment" || client === undefined)
return false;
const providers = details?.providers;
return (
scope.clients.includes(client) ||
(Array.isArray(providers) &&
scope.providers.some((provider) => providers.includes(provider)))
);
};
const isProviderCheck = (
name: string,
inspections: readonly DoctorProviderInspection[] | undefined,
+4 -22
View File
@@ -1,6 +1,10 @@
import { permitsCapabilityFallback } from "./CapabilityFallback.js";
import { withOverviewInventoryCoverage } from "../domain/native/inventoryCoverage.js";
import type { NativeCallGraph } from "../domain/native/nativeCallGraph.js";
import type {
BatchDecompileItem,
BatchProcedureIdentity,
} from "../domain/native/batchDecompile.js";
import { nativeProcedureInputSchema } from "../domain/native/nativeProcedureInput.js";
import {
cleanupStoppedTraversal,
@@ -76,18 +80,6 @@ import {
import { traceLiteralFeature } from "./EnhancedLiteralTracing.js";
import { projectNativeApiInspection } from "./native/NativeApiInspection.js";
type BatchProcedureIdentity =
| {
readonly status: "resolved";
readonly address: string;
readonly name: string | null;
readonly name_error?: ReturnType<typeof projectAnalysisError>;
}
| {
readonly status: "unknown";
readonly error: ReturnType<typeof projectAnalysisError>;
};
type BatchResolution = {
readonly address: string;
readonly result: Result<string, AnalysisError>;
@@ -99,16 +91,6 @@ type BatchReadyItem = {
readonly providerProcedure: string;
};
type BatchDecompileItem = {
readonly address: string;
readonly procedure: BatchProcedureIdentity;
readonly status: "ok" | "error";
readonly pseudocode?: string;
readonly error?: ReturnType<typeof projectAnalysisError>;
/** Caveats of this item's own observation when it supplied no pseudocode. */
readonly limitations?: string[];
};
/**
* Guidance for a batch item whose provider returned null pseudocode, by
* whether that observation reported caveats for the item to carry.
+8 -1
View File
@@ -212,6 +212,11 @@ export const publishEvidenceDelivery = async (input: {
return ok({ delivered: input.delivered, evidenceBundle: written.value });
};
/** A delivery failure can follow commit; its receipt then owns the source closure. */
export const evidenceDeliveryCommitted = (error: AnalysisError): boolean =>
error.diagnostics?.evidence_bundle !== undefined ||
(error instanceof EvidenceFileError && error.published !== undefined);
/** Publish a completed bundle and retry its exact staged-output cleanup owner. */
const persistEvidenceBundleCommand = async (
bundle: EvidenceBundle,
@@ -292,10 +297,12 @@ const projectWrite = (
export const inspectEvidenceBundleCommand = async (
input: {
readonly path: string;
readonly detail: "complete" | "summary" | "records";
readonly detail: "complete" | "summary" | "records" | "links";
readonly filters?: EvidenceRecordFilters | undefined;
readonly offset?: number | undefined;
readonly limit?: number | undefined;
readonly traversal?: "direct" | "retained-closure" | undefined;
readonly link_status?: "missing" | "retained" | "all" | undefined;
},
signal?: AbortSignal,
) => {
+12
View File
@@ -90,6 +90,18 @@ export const parseMacHopperReleases = (
return ok(retained);
};
/**
* The minimum macOS version the selected release declares (`deployment_target`).
* Undefined when the row declares none as text; the signed application's own
* requirement is still checked before it is copied.
*/
export const macHopperReleaseMinimumSystemVersion = (
release: MacHopperRelease,
): string | undefined =>
typeof release.deployment_target === "string"
? release.deployment_target
: undefined;
/** Stable publisher identity; signing subkeys are admitted through this primary. */
export const HOPPER_LINUX_PRIMARY_FINGERPRINT =
"7C2D73BAFAF5B9A710A69DA1A26DF815099B1BCD";
+189
View File
@@ -9,6 +9,8 @@ import type {
} from "./HopperDownload.js";
import {
installMacHopper,
macHopperHostVersionDetail,
macHopperIncomparableMinimumDetail,
type MacHopperBundleVerification,
type MacHopperBundleInstallResult,
type MacHopperInstallHost,
@@ -77,6 +79,17 @@ class FakeMacHopperHost implements MacHopperInstallHost {
this.verificationFailure === undefined
? Promise.resolve(this.verification)
: Promise.reject(this.verificationFailure);
hostVersion: string | undefined = "14.5";
appMinimum: string | undefined = "12.4";
appMinimumFailure: Error | undefined;
appMinimumReads = 0;
macosVersion = () => Promise.resolve(this.hostVersion);
minimumSystemVersion = () => {
this.appMinimumReads += 1;
return this.appMinimumFailure === undefined
? Promise.resolve(this.appMinimum)
: Promise.reject(this.appMinimumFailure);
};
destinationExists = () => Promise.resolve(this.existing);
installBundle = (
_source: string,
@@ -237,6 +250,182 @@ describe("macOS Hopper installation", () => {
});
});
const PACKAGE_URL =
"https://www.hopperapp.com:443/downloader/public/Hopper-6.6.1-demo.dmg";
/** The refusal detail carries the observed facts, independent of its wording. */
const expectNamed = (
result: Awaited<ReturnType<typeof installMacHopper>>,
facts: readonly string[],
): void => {
const diagnostic = result.status === "failed" ? result.diagnostic : undefined;
for (const fact of facts) expect(diagnostic).toContain(fact);
};
/** The fixture's macOS row with vendor fields such as `deployment_target`. */
const macFeed = (
host: FakeMacHopperHost,
fields: Readonly<Record<string, unknown>>,
): Uint8Array =>
new TextEncoder().encode(
JSON.stringify({
"OS X": {
filename: PACKAGE_URL,
file_length: String(host.archive.byteLength),
file_hash: createHash("sha1").update(host.archive).digest("hex"),
version: "6.6.1",
...fields,
},
}),
);
describe("macOS Hopper release host requirements", () => {
it.each(["12", "12.0", "12.3", "12.3.9"])(
"refuses macOS %s below the release deployment target before downloading the package",
async (hostVersion) => {
const host = new FakeMacHopperHost();
host.hostVersion = hostVersion;
host.feedBytes = macFeed(host, { deployment_target: "12.4" });
const result = await installMacHopper({}, host);
expect(result).toEqual({
status: "failed",
reason: "incompatible_host_version",
diagnostic: macHopperHostVersionDetail({
source: "release_feed",
packageUrl: PACKAGE_URL,
minimumVersion: "12.4",
hostVersion,
}),
});
expectNamed(result, [PACKAGE_URL, "12.4", hostVersion]);
expect(host.packageDownloads).toBe(0);
expect(host.mounts).toBe(0);
expect(host.installs).toBe(0);
},
);
it.each(["12.4", "12.4.0", "12.4.1", "12.10", "13", "26.1"])(
"installs on macOS %s at or above both declared minimums",
async (hostVersion) => {
const host = new FakeMacHopperHost();
host.hostVersion = hostVersion;
host.feedBytes = macFeed(host, { deployment_target: "12.4" });
expect(await installMacHopper({}, host)).toMatchObject({
status: "installed",
});
expect(host.installs).toBe(1);
},
);
it.each([
{ label: "an undeclared", fields: {} },
{ label: "an unrecognized", fields: { deployment_target: "twelve" } },
{ label: "a non-text", fields: { deployment_target: 12.4 } },
{ label: "a lower", fields: { deployment_target: "11.0" } },
])(
"lets the signed app refuse the host after $label feed deployment target",
async ({ fields }) => {
const host = new FakeMacHopperHost();
host.hostVersion = "12.0";
host.feedBytes = macFeed(host, fields);
const result = await installMacHopper({}, host);
expect(result).toEqual({
status: "failed",
reason: "incompatible_host_version",
diagnostic: macHopperHostVersionDetail({
source: "signed_app",
minimumVersion: "12.4",
hostVersion: "12.0",
}),
});
expectNamed(result, ["LSMinimumSystemVersion", "12.4", "12.0"]);
expect(host.packageDownloads).toBe(1);
expect(host.installs).toBe(0);
expect(host.opens).toBe(0);
expect(host.unmounts).toBe(1);
expect(host.cleanups).toBe(1);
},
);
it.each(["invalid_signature", "unverified_publisher"] as const)(
"reads the app's requirement only after its signature checks pass: %s",
async (verification) => {
const host = new FakeMacHopperHost();
host.verification = verification;
expect(await installMacHopper({}, host)).toMatchObject({
status: "failed",
});
expect(host.appMinimumReads).toBe(0);
expect(host.installs).toBe(0);
},
);
it("installs when neither the feed nor the signed app declares a minimum", async () => {
const host = new FakeMacHopperHost();
host.hostVersion = "12.0";
host.appMinimum = undefined;
expect(await installMacHopper({}, host)).toMatchObject({
status: "installed",
});
});
it("reports an incomparable signed-app minimum as a bundle failure without copying", async () => {
const host = new FakeMacHopperHost();
host.appMinimum = "12.x";
const result = await installMacHopper({}, host);
expect(result).toEqual({
status: "failed",
reason: "bundle",
diagnostic: macHopperIncomparableMinimumDetail("12.x", "14.5"),
});
expectNamed(result, ['"12.x"', "14.5"]);
expect(host.installs).toBe(0);
expect(host.unmounts).toBe(1);
expect(host.cleanups).toBe(1);
});
it("preserves an unreadable signed-app requirement as a bundle diagnostic", async () => {
const host = new FakeMacHopperHost();
host.appMinimumFailure = new Error("Info.plist: permission denied");
expect(await installMacHopper({}, host)).toMatchObject({
status: "failed",
reason: "bundle",
diagnostic: "Info.plist: permission denied",
});
expect(host.installs).toBe(0);
expect(host.unmounts).toBe(1);
expect(host.cleanups).toBe(1);
});
it.each([undefined, "", "14beta", "14.0\n"])(
"refuses an incomparable host version %j before any vendor request",
async (hostVersion) => {
const host = new FakeMacHopperHost();
host.hostVersion = hostVersion;
const download = host.download;
let requests = 0;
host.download = (url) => {
requests += 1;
return download(url);
};
expect(await installMacHopper({}, host)).toEqual({
status: "failed",
reason: "unsupported_host",
});
expect(requests).toBe(0);
expect(host.mounts).toBe(0);
},
);
});
it("reports an unresolved destination probe without claiming an existing installation", async () => {
const host = new FakeMacHopperHost();
host.destinationExists = async () => {
+105 -8
View File
@@ -4,6 +4,7 @@ import {
mkdir,
mkdtemp,
readdir,
readFile,
rename,
rm,
writeFile,
@@ -12,8 +13,9 @@ import { tmpdir } from "node:os";
import { join, posix } from "node:path";
import { promisify } from "node:util";
import { z } from "zod";
import { appendCallerPath } from "../filesystem/callerPath.js";
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
import type { AnalysisCleanupObservation } from "../domain/analysisErrorBase.js";
import { HOPPER_PUBLISHER } from "../domain/hopperPublisher.js";
import { macDeveloperIdRequirement } from "../process/MacCodeSignature.js";
@@ -29,8 +31,14 @@ import {
parseHopperReleaseJson,
parseMacHopperReleases,
hopperArchiveIntegrityMatches,
macHopperReleaseMinimumSystemVersion,
} from "./HopperReleaseFeed.js";
import type { HopperInstallFailureReason } from "./SetupInstallFailure.js";
import { decodeInfoPlistValue } from "./AppBundleExecutable.js";
import {
macosMajorVersion,
macosVersionMeetsMinimum,
} from "../domain/macosVersion.js";
const execFileAsync = promisify(execFile);
const HOPPER_PUBLISHER_REQUIREMENT = macDeveloperIdRequirement(
@@ -46,6 +54,8 @@ export type MacHopperBundleVerification =
type MacHopperInstallFailureReason = Extract<
HopperInstallFailureReason,
| "unsupported_host"
| "incompatible_host_version"
| "release_metadata"
| "download"
| "integrity"
@@ -77,8 +87,10 @@ export interface MacHopperBundleInstallOperations {
removeStagingRoot(path: string): Promise<void>;
}
/** Stable network and command effects disclosed before macOS Hopper setup. */
/** Stable effects and checks disclosed before macOS Hopper setup. */
export const macHopperInstallDisclosure = {
detail:
"Download the official Hopper package, verify it, and install it. Before the download, and again from the signed app before copying, setup compares the release's minimum macOS version with this Mac's and stops if this Mac is older. Hopper may show its demo or license prompt when first opened.",
networkOrigins: [HOPPER_RELEASES_URL, HOPPER_DOWNLOAD_PREFIX] as const,
commands: [
"hdiutil attach -readonly -nobrowse -mountpoint <private-mount> <verified-hopper.dmg>",
@@ -116,6 +128,10 @@ export interface MacHopperInstallHost {
unmount(mountPath: string): Promise<void>;
appBundle(mountPath: string): Promise<string | undefined>;
validBundle(appPath: string): Promise<MacHopperBundleVerification>;
/** This Mac's version, read through the probe setup planning used. */
macosVersion(): Promise<string | undefined>;
/** The mounted app's declared LSMinimumSystemVersion; undefined when it declares none. */
minimumSystemVersion(appPath: string): Promise<string | undefined>;
destinationExists(path: string): Promise<boolean>;
installBundle(
source: string,
@@ -132,14 +148,20 @@ export interface MacHopperInstallHost {
destination(): string;
}
/** Download, verify, and install Hopper into the current user's Applications directory. */
/**
* Download, verify, and install Hopper into the current user's Applications
* directory. The selected release's own macOS minimum gates installation: the
* feed's `deployment_target` before the package is downloaded, and the signed
* app's `LSMinimumSystemVersion` before it is copied.
*/
export const installMacHopper = async (
options: { readonly replaceExisting?: boolean } = {},
host: MacHopperInstallHost = systemMacHopperInstallHost(
homeDirectoryFromEnvironment(process.env, process.platform),
process.env,
),
options: { readonly replaceExisting?: boolean },
host: MacHopperInstallHost,
): Promise<MacHopperInstallResult> => {
const hostVersion = await host.macosVersion();
// Without a comparable host version no declared minimum can be checked.
if (hostVersion === undefined || macosMajorVersion(hostVersion) === undefined)
return { status: "failed", reason: "unsupported_host" };
let temporary: string | undefined;
let mounted = false;
let bundleCleanupFailure: string | undefined;
@@ -159,6 +181,22 @@ export const installMacHopper = async (
const parsed = parseMacHopperReleases(decoded.value);
if (!parsed.ok) return { status: "failed", reason: "release_metadata" };
const release = parsed.value;
const releaseMinimum = macHopperReleaseMinimumSystemVersion(release);
// An unrecognized feed value defers to the signed app checked below.
if (
releaseMinimum !== undefined &&
macosVersionMeetsMinimum(hostVersion, releaseMinimum) === false
)
return {
status: "failed",
reason: "incompatible_host_version",
diagnostic: macHopperHostVersionDetail({
source: "release_feed",
packageUrl: release.filename,
minimumVersion: releaseMinimum,
hostVersion,
}),
};
installStage = "download";
const archive = await host.download(release.filename);
if (!archive.ok)
@@ -193,6 +231,30 @@ export const installMacHopper = async (
return { status: "failed", reason: "bundle" };
if (verification === "unverified_publisher")
return { status: "failed", reason: "publisher_verification_failed" };
// An app that declares no minimum is accepted, as macOS does.
const appMinimum = await host.minimumSystemVersion(source);
if (appMinimum !== undefined) {
const compatible = macosVersionMeetsMinimum(hostVersion, appMinimum);
if (compatible === false)
return {
status: "failed",
reason: "incompatible_host_version",
diagnostic: macHopperHostVersionDetail({
source: "signed_app",
minimumVersion: appMinimum,
hostVersion,
}),
};
if (compatible === undefined)
return {
status: "failed",
reason: "bundle",
diagnostic: macHopperIncomparableMinimumDetail(
appMinimum,
hostVersion,
),
};
}
installStage = "copy";
const installation = await host.installBundle(
source,
@@ -374,6 +436,7 @@ export const installMacHopperBundle = async (
export const systemMacHopperInstallHost = (
homeDirectory: string,
environment: Readonly<NodeJS.ProcessEnv>,
macosVersion: () => Promise<string | undefined>,
): MacHopperInstallHost & {
close(): Promise<AnalysisCleanupObservation | undefined>;
} => {
@@ -434,6 +497,18 @@ export const systemMacHopperInstallHost = (
return "unverified_publisher";
return "verified";
},
macosVersion,
async minimumSystemVersion(appPath) {
const plist = posix.join(appPath, "Contents", "Info.plist");
const declared = minimumSystemVersionSchema.safeParse(
decodeInfoPlistValue(await readFile(plist)),
);
if (!declared.success)
throw new TypeError(
`${plist} does not declare LSMinimumSystemVersion as text`,
);
return declared.data.LSMinimumSystemVersion;
},
async destinationExists(path) {
try {
await access(path);
@@ -494,6 +569,28 @@ export const systemMacHopperInstallHost = (
};
};
/** Installer detail naming the declared minimum this Mac's version does not meet. */
export const macHopperHostVersionDetail = (
refusal: (
| { readonly source: "release_feed"; readonly packageUrl: string }
| { readonly source: "signed_app" }
) & { readonly minimumVersion: string; readonly hostVersion: string },
): string =>
refusal.source === "release_feed"
? `The current Hopper release (${refusal.packageUrl}) declares deployment_target ${refusal.minimumVersion}; this Mac runs macOS ${refusal.hostVersion}. The package was not downloaded.`
: `The signed Hopper application declares LSMinimumSystemVersion ${refusal.minimumVersion}; this Mac runs macOS ${refusal.hostVersion}. Nothing was copied.`;
/** Installer detail for a signed-app minimum REA cannot compare with this Mac's version. */
export const macHopperIncomparableMinimumDetail = (
declared: string,
hostVersion: string,
): string =>
`The signed Hopper application declares LSMinimumSystemVersion ${JSON.stringify(declared)}, which REA cannot compare with macOS ${hostVersion}. Nothing was copied.`;
const minimumSystemVersionSchema = z.looseObject({
LSMinimumSystemVersion: z.string().optional(),
});
const commandSucceeds = async (
command: string,
args: readonly string[],
@@ -125,6 +125,8 @@ export interface ReferenceSourceImportOptions {
export const DEFAULT_REFERENCE_SOURCE_IGNORE_PATTERNS = [
".git/",
".git/hooks/",
// Match pointer files after directory rules to preserve their provenance.
".git",
"node_modules/",
"dist/",
"build/",
+30 -2
View File
@@ -301,10 +301,13 @@ describe("setup client selection", () => {
configPath: "/codex.toml",
format: "toml" as const,
};
host.clients = [codex];
host.clients = [
codex,
{ name: "grok_bot", configPath: "/grokbot", format: "unsupported" },
];
host.availableClients = [
{ name: "cursor", configPath: "/cursor.json" },
codex,
...host.clients,
];
const result = await runSetup(
@@ -331,6 +334,31 @@ describe("setup client selection", () => {
expect(host.skillInstalls).toBe(0);
});
it.each([undefined, false])(
"omits manual clients from automatic readiness while preserving detection (skill: %s)",
async (installSkill) => {
const host = new FakeSetupHost();
host.clients = [
{ name: "grok_bot", configPath: "/grokbot", format: "unsupported" },
];
const result = await runSetup(
{
...options(true),
allDetectedClients: true,
...(installSkill === undefined ? {} : { installSkill }),
},
host,
);
expect(result.status).toBe("ready");
expect(result.doctor.scope.clients).toEqual([]);
expect(result.clientStates).toMatchObject([
{ client: { name: "grok_bot" }, detected: true },
]);
expect(result.clients).toEqual({});
expect(host.configurations).toBe(0);
},
);
it.each([true, false])(
"handles repeated clients once in first-selected order (dry run: %s)",
async (dryRun) => {
+33
View File
@@ -6,6 +6,8 @@ import { ConfigurationError } from "../domain/configurationErrors.js";
import { SUPPORTED_NODE_VERSION_PROSE } from "../domain/runtimeVersion.js";
import { FakeSetupHost, options } from "./Setup.fixture.js";
import { runSetup } from "./Setup.js";
import { discoverSetupState } from "./SetupPlan.js";
import { macHopperInstallDisclosure } from "./MacHopper.js";
import {
parseLinuxDistribution,
type LinuxDistribution,
@@ -690,6 +692,8 @@ describe("setup Hopper install eligibility", () => {
{ version: "14.", installs: false },
{ version: "14.0\n", installs: false },
{ version: "99999999999999999", installs: false },
// REA's own floor admits these; the selected release's minimum is
// compared by the installer, which this recording host replaces.
{ version: "12", installs: true },
{ version: "12.0", installs: true },
])(
@@ -709,6 +713,35 @@ describe("setup Hopper install eligibility", () => {
});
},
);
it.each(["12.0", "12.3", "12.4", "14.5"])(
"leaves Hopper support on macOS %s unknown until the release minimum is read",
async (version) => {
const host = new FakeSetupHost("darwin");
host.version = version;
const discovery = await discoverSetupState({
host,
providerConfigured: false,
forceHopperInstall: false,
proposeHopper: true,
initialDoctor: await host.doctor(),
});
expect(discovery).toMatchObject({
hopperInstall: { supported: "unknown" },
installHopper: true,
});
const plan = await runSetup(
{ ...options(false, true), dryRun: true },
host,
);
// The installer owns the disclosed check; its refusal is tested there.
expect(
plan.plannedActions.find(({ kind }) => kind === "install_hopper")
?.detail,
).toBe(macHopperInstallDisclosure.detail);
},
);
});
it("leaves ownerless caller-host failures unconfirmed after an unobserved direct retry", async () => {
+2 -1
View File
@@ -395,7 +395,8 @@ const runSetupWorkflow = async (
appliedActions.includes("installed_hopper"),
doctor.healthy,
// Offer installation only where the explicit gate accepts it.
hopperPath === undefined && planDiscovery.hopperInstall.supported,
hopperPath === undefined &&
planDiscovery.hopperInstall.supported !== false,
),
hopperWarning,
manualRemediation,
+3 -1
View File
@@ -130,7 +130,9 @@ export const systemSetupHost = (
const { homeDirectory } = doctorHost;
const macHopperHost =
platform === "darwin"
? systemMacHopperInstallHost(homeDirectory, environment)
? systemMacHopperInstallHost(homeDirectory, environment, () =>
doctorHost.macosVersion(),
)
: undefined;
const resources = new ArtifactResourceScope();
const linuxDistribution = () => doctorHost.linuxDistribution();
@@ -20,6 +20,18 @@ describe("macOS Hopper setup failure projection", () => {
"The mounted package did not contain the expected Hopper application bundle. Retry setup; if it repeats, update REA before installing.",
});
});
it("keeps a host below the release minimum distinct from an unsupported host", () => {
const incompatible = setupInstallFailure("incompatible_host_version");
expect(incompatible).toMatchObject({
status: "failed",
code: "incompatible_host_version",
});
expect(incompatible.remediation).toContain("HOPPER_LAUNCHER_PATH");
const unsupported = setupInstallFailure("unsupported_host");
expect(unsupported.code).toBe("unsupported_host");
expect(incompatible.remediation).not.toBe(unsupported.remediation);
});
});
describe("Linux package-manager setup failure projection", () => {
+9
View File
@@ -9,6 +9,7 @@ import type { LinuxPackageInstallFailure } from "./LinuxHopper.js";
export type SetupFailureCode =
| "unsupported_host"
| "incompatible_host_version"
| "download_failed"
| "integrity_mismatch"
| "authorization_or_package_manager_failed"
@@ -49,6 +50,7 @@ export type SetupHopperInstallFailure = Extract<
export type HopperInstallFailureReason =
| "unsupported_host"
| "incompatible_host_version"
| "release_metadata"
| "download"
| "integrity"
@@ -150,6 +152,13 @@ const stageFailure = (
code: "unsupported_host",
remediation: "Use a supported host, then rerun rea setup.",
};
case "incompatible_host_version":
return {
status: "failed",
code: "incompatible_host_version",
remediation:
"The current Hopper release requires a newer macOS version than this Mac runs, so REA did not install it. Update macOS to at least the version the installer detail names, or install a Hopper release that supports this macOS version yourself and set HOPPER_LAUNCHER_PATH to its launcher, then rerun rea setup.",
};
case "integrity":
return {
status: "failed",
+16 -7
View File
@@ -26,9 +26,15 @@ import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
import { macosMajorVersion } from "../domain/macosVersion.js";
/** Whether automated Hopper installation can run on this host, and why not. */
/**
* Whether automated Hopper installation can run on this host, and why not.
* `unknown`: the host meets REA's own prerequisites, but the selected release
* declares its minimum macOS version only in metadata the installer reads
* after approval, so planning cannot claim the release supports this host.
*/
export type HopperInstallEligibility =
| { readonly supported: true }
| { readonly supported: "unknown" }
| { readonly supported: false; readonly remediation: string };
/** Read-only setup facts shared by client selection and preflight. */
@@ -74,7 +80,7 @@ export const discoverSetupState = async (input: {
);
const installHopper =
input.forceHopperInstall ||
(hopperInstall.supported &&
(hopperInstall.supported !== false &&
input.proposeHopper &&
(!input.providerConfigured || linuxHopperRepairNeeded));
const [clients, detectedClients] = await Promise.all([
@@ -133,7 +139,9 @@ export const discoverSetupState = async (input: {
installHopper,
hopperInstall,
clients,
detectedClientIds: [...detectedIds],
detectedClientIds: detectedClients
.filter(({ format }) => format !== "unsupported")
.map(({ name }) => name),
defaultClientIds,
clientStates,
...(linuxDistribution?.packageFamily === undefined
@@ -327,7 +335,7 @@ const setupPlan = (input: {
detail:
input.platform === "linux"
? `Download the current official Hopper demo, verify its PGP publisher and launcher bytes, and retain archive, public-key.asc and release.json under ${input.hopperCacheDirectory ?? `${input.homeDirectory}/.cache/rea/hopper-releases`}/<launcher-sha256> for offline verification. Preserve replaced damaged evidence beside it as <launcher-sha256>.backup-<id>. Install Hopper plus its Xvfb demo-session dependencies${input.linuxPackageFamily === "deb" ? ", including the shared library matching the distribution Python interpreter" : ""}. REA uses a private display and selects Hopper's offered demo mode for each analysis session.`
: "Download the official Hopper package, verify it, and install it. Hopper may show its demo or license prompt when first opened.",
: macHopperInstallDisclosure.detail,
external: true,
operation: "install" as const,
...(input.platform === "linux"
@@ -393,7 +401,7 @@ const linuxDisclosure = (family: LinuxPackageFamily) => {
export const hopperInstallRefusal = (
discovery: Pick<SetupDiscovery, "installHopper" | "hopperInstall">,
): string | undefined =>
discovery.installHopper && !discovery.hopperInstall.supported
discovery.installHopper && discovery.hopperInstall.supported === false
? discovery.hopperInstall.remediation
: undefined;
@@ -401,7 +409,8 @@ export const hopperInstallRefusal = (
* Decide automated Hopper installation once from the host facts read during
* discovery, so proposals, explicit requests, and recovery advice agree. An
* unknown or unparseable macOS version and an unidentified Linux distribution
* are not eligible.
* are not eligible. macOS 12 is REA's own floor; the selected Hopper release's
* minimum is unknown until the installer reads it.
*/
const hopperInstallEligibility = (
platform: NodeJS.Platform,
@@ -411,7 +420,7 @@ const hopperInstallEligibility = (
if (platform === "darwin") {
const major = macosMajorVersion(macosVersion);
return major !== undefined && major >= 12
? { supported: true }
? { supported: "unknown" }
: { supported: false, remediation: "Upgrade to macOS 12 or newer." };
}
if (platform === "linux")
@@ -107,3 +107,17 @@ export const providerRegistrationEnvironment = (
)
.sort(([left], [right]) => compareUnicodeCodePoints(left, right)),
);
/** Spell selected settings as setup records them: an explicit selection wins. */
export const registrationSpelledSettings = (
settings: Readonly<Record<string, string>>,
environment: Readonly<Record<string, string | undefined>>,
): Readonly<Record<string, string>> => {
const explicit = explicitSetupRegistrationEnvironment(environment);
return Object.fromEntries(
Object.entries(settings).map(([key, value]) => [
key,
explicit[key] ?? value,
]),
);
};
@@ -121,53 +121,56 @@ const graph = (): NativeCallGraph =>
limitations: ["Graph caveat", "Graph caveat"],
});
it("returns useful caller/callee facts with exact source context identity and partial coverage", async () => {
const source = parent("procedure_callees", [
"ram:0x0010",
"ram:0x0010",
"ram:0x0020",
]);
const before = JSON.stringify(source);
const summary = await project(source, { kind: "summary" });
expect(summary.value).toMatchObject({
kind: "summary",
parent_evidence_id: source.evidence_id,
source_provider: source.provider,
source_parameters: source.parameters,
source_analysis_profile_digest: source.analysis_profile?.digest,
source_context: {
evidence_limitation_count: 1,
result_limitation_count: null,
},
summary: { observations: 3 },
items: {
type: "addresses",
rows: [
{ source_index: 0, address: "ram:0x0010" },
{ source_index: 1, address: "ram:0x0010" },
{ source_index: 2, address: "ram:0x0020" },
],
},
});
expect(summary.evidence.relationship_coverage).toEqual(
source.relationship_coverage,
);
const page = await project(source, {
kind: "native-observation",
offset: 1,
limit: 1,
});
expect(page.value).toMatchObject({
items: { rows: [{ source_index: 1, address: "ram:0x0010" }] },
coverage: { total: 3, next_offset: 2, exhausted: false },
});
expect(page.value).toHaveProperty(
"source_context.digest",
contextDigest(summary.value),
);
expect(JSON.stringify(summary.value)).not.toContain('"analysis_profile":');
expect(JSON.stringify(source)).toBe(before);
});
it.each(["procedure_callees", "xrefs"])(
"pages %s addresses with exact source context and duplicate identity",
async (operation) => {
const source = parent(operation, [
"ram:0x0010",
"ram:0x0010",
"ram:0x0020",
]);
const before = JSON.stringify(source);
const summary = await project(source, { kind: "summary" });
expect(summary.value).toMatchObject({
kind: "summary",
parent_evidence_id: source.evidence_id,
source_provider: source.provider,
source_parameters: source.parameters,
source_analysis_profile_digest: source.analysis_profile?.digest,
source_context: {
evidence_limitation_count: 1,
result_limitation_count: null,
},
summary: { observations: 3 },
items: {
type: "addresses",
rows: [
{ source_index: 0, address: "ram:0x0010" },
{ source_index: 1, address: "ram:0x0010" },
{ source_index: 2, address: "ram:0x0020" },
],
},
});
expect(summary.evidence.relationship_coverage).toEqual(
source.relationship_coverage,
);
const page = await project(source, {
kind: "native-observation",
offset: 1,
limit: 1,
});
expect(page.value).toMatchObject({
items: { rows: [{ source_index: 1, address: "ram:0x0010" }] },
coverage: { total: 3, next_offset: 2, exhausted: false },
});
expect(page.value).toHaveProperty(
"source_context.digest",
contextDigest(summary.value),
);
expect(JSON.stringify(summary.value)).not.toContain('"analysis_profile":');
expect(JSON.stringify(source)).toBe(before);
},
);
it("pages complete context fields and changes its digest when source profile or caveats change", async () => {
const source = parent("procedure_callers", []);
Loaded 100 of 322 files, more files were not shown because too many files have changed in this diff. Show more