feat(native): add inspection primitives, dispatch traces and approved UI observation (#500)

* fix(hopper): normalize string-backed addresses and update verification

* feat(native): add inspection primitives, dispatch traces and approved UI observation

* test: prioritize real artifact E2E and captured golden regressions

* fix(process): queue observed output before reactive rendering actions

* test: prune redundant cases and strengthen boundary assertions

* fix(cli): route native value tracing through shared workflows

* test: replace synthetic success with real workflows and captured data

* fix(catalog): separate portable metadata from native host availability
This commit is contained in:
morluto
2026-10-04 16:02:36 +08:00
committed by GitHub
parent 0356f7677f
commit 4fbc501121
205 changed files with 8136 additions and 1816 deletions
+19 -1
View File
@@ -216,7 +216,7 @@ jobs:
run: npm run verify:fixtures
package-e2e:
name: Package E2E (${{ matrix.os }})
name: Package/install integration (${{ matrix.os }})
needs: changes
if: github.event_name == 'push' || needs.changes.outputs.package == 'true'
strategy:
@@ -233,6 +233,24 @@ jobs:
- run: npm ci
- run: npm run verify:package
apple-artifacts:
name: Real Apple artifact CLI/MCP E2E
needs: changes
if: github.event_name == 'push' || needs.changes.outputs.code == 'true'
runs-on: macos-14
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- run: npm run verify:keyed-archive
- run: npm run verify:asset-catalog
- name: Real binary metadata and Interface Builder integration
run: npm run verify:apple-dispatch && npm run verify:interface-builder
reconstruction-readiness:
name: Reconstruction readiness
needs: changes
+1 -1
View File
@@ -2,7 +2,7 @@
## Product Direction
REA exposes reverse-engineering tools through a CLI and MCP server. Hopper and the bring-your-own Ghidra adapter are operation-capable deep binary-analysis providers. Ghidra is supported on Linux x64 and has an experimental Windows x64 P0 boundary for approved native x86-64 PE applications; it supplies admitted read-only inventory and function-analysis operations but no GUI or mutation authority. Keep provider-specific code out of the domain and application layers.
REA exposes reverse-engineering tools through a CLI and MCP server. Hopper and the bring-your-own Ghidra adapter are operation-capable deep binary-analysis providers. Ghidra is supported on Linux x64 and macOS x64/arm64 with matching native decompiler tools, and has an experimental Windows x64 P0 boundary for approved native x86-64 PE applications; it supplies admitted read-only inventory and function-analysis operations but no GUI or mutation authority. Keep provider-specific code out of the domain and application layers.
Prioritize:
+13 -11
View File
@@ -30,7 +30,7 @@
See a feature in an app that you want in your own product? Give the app to your agent—even without its source code. With REA, the agent can investigate the feature, explain how it works, show its evidence, and build a version adapted to your stack and requirements.
REA gives agents one consistent way to investigate software. Today that includes deep native analysis and function dossiers through Hopper or bring-your-own Ghidra on Linux, plus an experimental Windows x64 Ghidra P0 for approved native PE applications; execution-free managed PE/CLI triage; reproducible Evidence records; controlled process capture; passive website, Electron page, and Node/Electron V8 Inspector observation; JavaScript/source-map reconstruction; and provider-neutral graphs for connecting application layers without confusing static inference with runtime observation. The longer-term toolkit extends the same agent workflow to APIs, protocols, mobile artifacts, firmware, richer runtime behavior, and differences between versions.
REA gives agents one consistent way to investigate software. Today that includes deep native analysis and function dossiers through Hopper or bring-your-own Ghidra on Linux and macOS, plus an experimental Windows x64 Ghidra P0 for approved native PE applications; execution-free managed PE/CLI triage; reproducible Evidence records; controlled process capture; passive website, Electron page, and Node/Electron V8 Inspector observation; JavaScript/source-map reconstruction; and provider-neutral graphs for connecting application layers without confusing static inference with runtime observation. The longer-term toolkit extends the same agent workflow to APIs, protocols, mobile artifacts, firmware, richer runtime behavior, and differences between versions.
Reverse engineering normally makes the operator choose a tool, learn its API, move evidence between programs, and decide what to inspect next. REA gives that work to the agent through commands, skills, structured results, and repeatable investigation workflows.
@@ -226,7 +226,7 @@ REA defaults `HOPPER_LAUNCHER_PATH` to `/Applications/Hopper Disassembler.app/Co
### Ghidra read-only analysis provider
The Ghidra adapter supports the exact official Ghidra 12.1.4 release with a 64-bit full JDK 21 on Linux x64. macOS is not an admitted Ghidra host. The adapter also provides an experimental Windows x64 P0 limited to approved native x86-64 PE applications. Download and extract those projects yourself, then configure absolute paths:
The Ghidra adapter supports the exact official Ghidra 12.1.4 release with a 64-bit full JDK 21 on Linux x64 and macOS x64/arm64. macOS additionally requires the matching native Ghidra decompiler; the adapter does not build or install it. The adapter also provides an experimental Windows x64 P0 limited to approved native x86-64 PE applications. Download and extract those projects yourself, then configure absolute paths:
```bash
export GHIDRA_INSTALL_DIR=/absolute/path/to/ghidra_12.1.4_PUBLIC
@@ -240,17 +240,17 @@ Doctor distinguishes missing configuration, a bad installation root, the wrong G
On Windows, set the same variables in PowerShell and run `rea doctor --json`; automated `rea setup` and Hopper installation remain unavailable. The P0 target boundary rejects DLLs, managed PE files, non-x86-64 images, mutable/hostile inputs, and non-PE formats. See the [Windows Ghidra P0 operations guide](docs/windows-ghidra-p0.md) for registration, exact limitations, CI evidence, and acceptance gates.
REA loads its packaged Java `HeadlessScript` with `-scriptPath`, copies and digest-verifies the target in an ephemeral runtime, enables `-readOnly` and `-deleteProject`, and authenticates every request. Auto-analysis completes before operations are served; startup has a deadline, while tool requests run until a result, caller cancellation, or provider shutdown. Linux uses a mode-0600 Unix socket. Windows P0 uses token-authenticated IPv4 loopback and a token-free endpoint record because Node path-based IPC does not connect to Java AF_UNIX sockets on Windows. The bridge verifies Ghidra's imported-byte SHA-256 before serving any operation.
REA loads its packaged Java `HeadlessScript` with `-scriptPath`, copies and digest-verifies the target in an ephemeral runtime, enables `-readOnly` and `-deleteProject`, and authenticates every request. Auto-analysis completes before operations are served; startup has a deadline, while tool requests run until a result, caller cancellation, or provider shutdown. Linux and macOS use a mode-0600 Unix socket. Windows P0 uses token-authenticated IPv4 loopback and a token-free endpoint record because Node path-based IPC does not connect to Java AF_UNIX sockets on Windows. The bridge verifies Ghidra's imported-byte SHA-256 before serving any operation.
The Ghidra adapter declares 19 direct and enhanced operations. Its ten inventory operations are `list_documents`, `list_procedures`, `list_strings`, `list_names`, `list_segments`, `address_name`, `procedure_address`, `resolve_containing_procedure`, `search_procedures`, and `search_strings`. It also admits `procedure_info`, `procedure_pseudo_code`, `procedure_assembly`, `read_function_instructions`, `procedure_callers`, `procedure_callees`, `procedure_references`, `xrefs`, and `analyze_function`. `read_function_instructions` is the offset-paginated fast path for raw instruction windows: it does not invoke the decompiler or whole-program name/string inventories, and is also exposed as `rea instructions`. These capabilities enable the shared Swift/Objective-C inventory workflows, `binary_overview`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, `trace_feature`, and complete function dossiers. Default-space addresses are lowercase `0x` hexadecimal. Other spaces, including `EXTERNAL`, use `<percent-encoded-space>:0x<hex>`. Symbol results identify primary, dynamic, external, type, and source facts; procedures distinguish external functions and thunks; strings identify charset, missing-terminator state, byte length, and value truncation; memory-block ends are exclusive and permissions come directly from Ghidra.
The Ghidra adapter declares 22 direct and enhanced operations. Its ten inventory operations are `list_documents`, `list_procedures`, `list_strings`, `list_names`, `list_segments`, `address_name`, `procedure_address`, `resolve_containing_procedure`, `search_procedures`, and `search_strings`. It also admits `procedure_info`, `procedure_pseudo_code`, `procedure_assembly`, `read_function_instructions`, `procedure_callers`, `procedure_callees`, `procedure_references`, `xrefs`, `analyze_function`, `inspect_native_instruction`, `resolve_native_call_targets`, and `inspect_native_data_type`. The exact-object primitives report instruction tokens, static call targets, and recovered database layouts; unsupported facets remain explicit. `trace_native_values` composes bounded decompiler-derived argument/parameter and return/output dependencies across resolved calls. `read_function_instructions` is the offset-paginated fast path for raw instruction windows: it does not invoke the decompiler or whole-program name/string inventories, and is also exposed as `rea instructions`. These capabilities enable the shared Swift/Objective-C inventory workflows, `binary_overview`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, `trace_feature`, and complete function dossiers. Default-space addresses are lowercase `0x` hexadecimal. Other spaces, including `EXTERNAL`, use `<percent-encoded-space>:0x<hex>`. Symbol results identify primary, dynamic, external, type, and source facts; procedures distinguish external functions and thunks; strings identify charset, missing-terminator state, byte length, and value truncation; memory-block ends are exclusive and permissions come directly from Ghidra.
The bridge serves operations only after auto-analysis completes. Each Program owns one persistent `DecompInterface`, and a serial FIFO keeps Ghidra API access on the owning program's thread; it has no fixed queue length or per-operation deadline. Reference results preserve Ghidra's call/jump/data/read/write/indirect/computed/external facts, while unresolved targetless flows remain explicitly unknown. Synthetic entry-point references without actionable memory sources are omitted. Pseudocode and assembly are provider-specific observations, not original source or Hopper-equivalent text. Caller cancellation and provider shutdown remain available; provider results are returned without a fixed response-size ceiling.
`npm run verify:ghidra` builds debug and stripped fixtures for the supported Linux x64 ELF host. Against real Ghidra 12.1.4 it validates every admitted operation, direct and indirect calls, imports/exports/thunks, typed references, strings/xrefs, multi-block CFG, cancellation, deadlines, concurrency, malformed inputs, and complete process/project cleanup. It needs a host C compiler and the Ghidra/JDK prerequisites above.
`npm run verify:ghidra` builds debug and stripped host-native fixtures (Linux x64 ELF or macOS Mach-O) and a native DWARF 4 type-layout object. Against real Ghidra 12.1.4 it validates every admitted operation, direct and indirect calls, imports/exports/thunks, typed references, strings/xrefs, multi-block CFG, cancellation, deadlines, concurrency, malformed inputs, and complete process/project cleanup. It needs a host C compiler and the Ghidra/JDK prerequisites above.
`npm run verify:ghidra:cross-format` adds AArch64 ELF, x86-64 PE, and x86-64 Mach-O fixtures. This separate lane requires `clang`, LLD, and `lld-link`; use `REA_CLANG`, `REA_LLD`, or `REA_LLD_LINK` to select alternate command paths. It preflights the required toolchain before compiling fixtures.
`npm run verify:ghidra:windows` uses a deterministic source-owned native x86-64 PE application and requires all 19 operations, target/snapshot/import digest linkage, authenticated loopback transport, and cleanup on a controlled Windows x64 Ghidra 12.1.4 runner. This proof does not establish Job Object ownership, private DACLs, or reparse-point-safe authority.
`npm run verify:ghidra:windows` uses a deterministic source-owned native x86-64 PE application and requires the Windows P0 operations, target/snapshot/import digest linkage, authenticated loopback transport, and cleanup on a controlled Windows x64 Ghidra 12.1.4 runner. This proof does not establish Job Object ownership, private DACLs, or reparse-point-safe authority.
To remove only REA-owned MCP registrations and the managed skill:
@@ -356,14 +356,16 @@ REA handles the app analysis in steps 1–5. The agent performs step 6 with its
- Analyze Swift and Objective-C metadata without manually untangling every mangled symbol.
- Leave names, comments, and bookmarks in Hopper so human and agent analysis reinforce each other.
See [native investigation](docs/native-investigation.md) for keyed archives, instruction/call/type primitives, typed dispatch metadata, value traces and approved native desktop observation.
## Tool catalog for investigation
| Tool family | Count | Examples |
| ------------------------- | ----: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Native inspection | 36 | procedures, pseudocode, assembly, strings, names, segments, callers, callees, xrefs, annotations, bounded byte reads, file-offset translation |
| Investigation workflows | 14 | `binary_overview`, `analyze_function`, `inspect_native_api`, `inspect_native_dispatch_metadata`, `batch_decompile`, `trace_feature`, `trace_native_investigation`, exact string-to-code lookup, bounded call paths, call graphs, Swift and Objective-C discovery |
| Native macOS utilities | 5 | Mach-O metadata, code signatures, plists, architectures, Swift demangling; Hopper-free and provenance-bearing |
| Artifact graph | 3 | complete inline inspection of directories and supported packages, compiled Interface Builder UI graph decoding, plus explicitly selected extraction into an absent owned tree |
| Native inspection | 39 | procedures, pseudocode, assembly, strings, names, segments, callers, callees, xrefs, annotations, bounded byte reads, file-offset translation |
| Investigation workflows | 15 | `binary_overview`, `analyze_function`, `inspect_native_api`, `inspect_native_dispatch_metadata`, `batch_decompile`, `trace_feature`, `trace_native_investigation`, exact string-to-code lookup, bounded call paths, call graphs, Swift and Objective-C discovery |
| Native macOS utilities | 7 | Mach-O metadata, code signatures, plists, architectures, Swift demangling; Hopper-free and provenance-bearing |
| Artifact graph | 5 | complete inline inspection of directories and supported packages, compiled Interface Builder UI graph decoding, paginated Apple asset catalog metadata, plus explicitly selected extraction into an absent owned tree |
| Managed PE/CLI | 8 | PE/CLI identity, metadata members, CIL hashes, P/Invoke/native-boundary declarations and verification, application-graph projection, decompiler reconstruction import, token remapping, runtime-correlation plans, and version comparison |
| Browser observation | 9 | exact-origin passive CDP capture, bundle and source-map analysis, WebMCP discovery, session timelines, capture diff, visual evidence, and bounded Playwright scenarios |
| Electron analysis | 5 | passive root-confined observation, static application mapping, evidence-backed static/runtime reconciliation, and provider-owned click/wait scenarios |
@@ -714,7 +716,7 @@ verified or absent.
## Security model
REA does not provide a hosted analysis service. Hopper and Linux Ghidra bridge communication uses authenticated private local sockets. Windows Ghidra P0 uses authenticated IPv4 loopback but does not claim named-pipe DACL or hostile-local-user isolation. Dynamic capabilities are disabled by default and require both operator policy and explicit per-call approval. Shipped providers, passive observers, and Process Capture are not security sandboxes: providers and launched targets run with the current user's permissions. Extracted JavaScript replay is a distinct Linux-only capability that fails closed unless Bubblewrap namespaces, architecture-checked seccomp, private runtime mounts, and delegated cgroup limits are available; it never inherits browser, Electron, or Process Capture authority. See [ADR-0002](docs/adr/0002-controlled-replay-authority-and-sandbox.md). Report vulnerabilities through the private process in [SECURITY.md](SECURITY.md).
REA does not provide a hosted analysis service. Hopper and POSIX Ghidra bridge communication uses authenticated private local sockets. Windows Ghidra P0 uses authenticated IPv4 loopback but does not claim named-pipe DACL or hostile-local-user isolation. Dynamic capabilities are disabled by default and require both operator policy and explicit per-call approval. Shipped providers, passive observers, and Process Capture are not security sandboxes: providers and launched targets run with the current user's permissions. Extracted JavaScript replay is a distinct Linux-only capability that fails closed unless Bubblewrap namespaces, architecture-checked seccomp, private runtime mounts, and delegated cgroup limits are available; it never inherits browser, Electron, or Process Capture authority. See [ADR-0002](docs/adr/0002-controlled-replay-authority-and-sandbox.md). Report vulnerabilities through the private process in [SECURITY.md](SECURITY.md).
## FAQ
+4 -4
View File
@@ -161,10 +161,10 @@ rea uninstall --purge-data
| عائلة الأدوات | العدد | أمثلة |
| --------------------- | ----: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| فحص الملفات التنفيذية | 36 | الدوال، والشيفرة شبه المصدرية، والتعليمات، والسلاسل، والأسماء، والمراجع، والتعليقات |
| التحليل المركب | 14 | النظرة العامة، و`inspect_native_api`، وفك الترجمة الدفعي، ومخططات الاستدعاء، والمراجع، واكتشاف الأنواع |
| أدوات macOS الأصلية | 5 | بيانات Mach-O والتوقيعات وملفات plist والمعماريات وفك رموز Swift دون تشغيل Hopper |
| رسم القطع الأثرية | 3 | فحص كامل مباشر للدلائل والحزم المدعومة، واستخراج معاملاتي محدد صراحة |
| فحص الملفات التنفيذية | 39 | الدوال، والشيفرة شبه المصدرية، والتعليمات، والسلاسل، والأسماء، والمراجع، والتعليقات |
| التحليل المركب | 15 | النظرة العامة، و`inspect_native_api`، وفك الترجمة الدفعي، ومخططات الاستدعاء، والمراجع، واكتشاف الأنواع |
| أدوات macOS الأصلية | 7 | بيانات Mach-O والتوقيعات وملفات plist والمعماريات وفك رموز Swift دون تشغيل Hopper |
| رسم القطع الأثرية | 5 | فحص الدلائل والحزم، ورسم واجهة Interface Builder، وبيانات كتالوج أصول Apple، والاستخراج المحدد صراحة |
| Managed PE/CLI | 8 | هوية PE/CLI، أعضاء البيانات الوصفية، بصمات CIL، تصريحات P/Invoke/الحدود الأصلية والتحقق منها، إسقاط رسم التطبيق، استيراد إعادة البناء من فك الترجمة، إعادة ربط الرموز، خطط ترابط وقت التشغيل ومقارنة الإصدارات |
| مراقبة المتصفح | 9 | التقاط CDP محدود الأصل وتحليل الحزم وخرائط المصدر واكتشاف WebMCP والفروق والأدلة المرئية |
| تحليل Electron | 5 | مراقبة سلبية ضمن جذور canonical، ورسم ثابت ومحدود للتطبيق، ومطابقة ثابتة/وقت تشغيل قائمة على Evidence |
+4 -4
View File
@@ -151,10 +151,10 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ
| ツール群 | 数 | 例 |
| ---------------------- | --: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| バイナリ調査 | 36 | プロシージャ、疑似コード、アセンブリ、文字列、名前、セグメント、callers、callees、xrefs、注釈 |
| 合成解析 | 14 | `binary_overview`, `analyze_function`, `inspect_native_api`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift / ObjC 検出 |
| macOS ネイティブ | 5 | Mach-O メタデータ、署名、plist、アーキテクチャ、Swift デマングル。Hopper 起動不要 |
| アーティファクトグラフ | 3 | ディレクトリと対応パッケージの完全なインライン調査、および明示選択されたトランザクション抽出 |
| バイナリ調査 | 39 | プロシージャ、疑似コード、アセンブリ、文字列、名前、セグメント、callers、callees、xrefs、注釈 |
| 合成解析 | 15 | `binary_overview`, `analyze_function`, `inspect_native_api`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift / ObjC 検出 |
| macOS ネイティブ | 7 | Mach-O メタデータ、署名、plist、アーキテクチャ、Swift デマングル。Hopper 起動不要 |
| アーティファクトグラフ | 5 | ディレクトリと対応パッケージの調査、Interface Builder UI グラフ、Apple アセットカタログのメタデータ、明示選択された抽出 |
| Managed PE/CLI | 8 | PE/CLI 識別、メタデータメンバー、CIL ハッシュ、P/Invoke/ネイティブ境界宣言と検証、アプリケーショングラフ投影、逆コンパイル再構成インポート、構造的 token 再マッピング、ランタイム相関計画、バージョン比較 |
| ブラウザ観察 | 9 | origin 限定 CDP 取得、bundle/source map 解析、WebMCP 検出、セッション、capture diff、視覚証拠 |
| Electron 解析 | 5 | canonical ファイルルート内の受動観察、有界な静的アプリマッピング、Evidence に基づく静的/実行時の照合 |
+4 -4
View File
@@ -151,10 +151,10 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이
| 도구 그룹 | 수 | 예시 |
| ------------------- | --: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 바이너리 검사 | 36 | 프로시저, 의사 코드, 어셈블리, 문자열, 이름, 세그먼트, callers, callees, xrefs, 주석 |
| 합성 분석 | 14 | `binary_overview`, `analyze_function`, `inspect_native_api`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift 및 ObjC 탐색 |
| macOS 네이티브 도구 | 5 | Mach-O 메타데이터, 코드 서명, plist, 아키텍처, Swift 디맹글링. Hopper 실행 불필요 |
| 아티팩트 그래프 | 3 | 디렉터리와 지원 패키지의 전체 인라인 검사 및 명시적으로 선택한 트랜잭션 추출 |
| 바이너리 검사 | 39 | 프로시저, 의사 코드, 어셈블리, 문자열, 이름, 세그먼트, callers, callees, xrefs, 주석 |
| 합성 분석 | 15 | `binary_overview`, `analyze_function`, `inspect_native_api`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift 및 ObjC 탐색 |
| macOS 네이티브 도구 | 7 | Mach-O 메타데이터, 코드 서명, plist, 아키텍처, Swift 디맹글링. Hopper 실행 불필요 |
| 아티팩트 그래프 | 5 | 디렉터리와 지원 패키지 검사, Interface Builder UI 그래프, Apple 에셋 카탈로그 메타데이터, 명시적으로 선택한 추출 |
| Managed PE/CLI | 8 | PE/CLI ID, 메타데이터 멤버, CIL 해시, P/Invoke/네이티브 경계 선언 및 검증, 애플리케이션 그래프 투영, 디컴파일 재구성 가져오기, 구조적 token 재매핑, 런타임 상관 계획, 버전 비교 |
| 브라우저 관찰 | 9 | origin 제한 CDP 캡처, bundle/source map 분석, WebMCP 검색, 세션 타임라인, capture diff 및 시각 증거 |
| Electron 분석 | 5 | canonical 파일 루트 내 수동 관찰, 제한된 정적 앱 매핑, Evidence 기반 정적/런타임 조정 |
+4 -4
View File
@@ -151,10 +151,10 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
| 工具类别 | 数量 | 示例 |
| ----------------- | ---: | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| 二进制检查 | 36 | 过程、伪代码、汇编、字符串、名称、段、调用者、被调用者、交叉引用、注释 |
| 组合分析 | 14 | `binary_overview`, `analyze_function`, `inspect_native_api`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`、Swift 与 ObjC 发现 |
| macOS 原生工具 | 5 | Mach-O 元数据、代码签名、plist、架构与 Swift 符号还原,无需启动 Hopper |
| 制品图谱 | 3 | 目录及支持的软件包的完整内联检查,以及显式选择的事务式提取 |
| 二进制检查 | 39 | 过程、伪代码、汇编、字符串、名称、段、调用者、被调用者、交叉引用、注释 |
| 组合分析 | 15 | `binary_overview`, `analyze_function`, `inspect_native_api`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`、Swift 与 ObjC 发现 |
| macOS 原生工具 | 7 | Mach-O 元数据、代码签名、plist、架构与 Swift 符号还原,无需启动 Hopper |
| 制品图谱 | 5 | 目录及支持的软件包内联检查、Interface Builder UI 图解、Apple 资源目录元数据分页检查,以及显式选择的事务式提取 |
| Managed PE/CLI | 8 | PE/CLI 身份、元数据成员、CIL 哈希、P/Invoke/原生边界声明与验证、应用图谱投影、反编译重建导入、结构化 token 重映射、运行时关联计划与跨版本比较 |
| 浏览器观察 | 9 | 精确来源 CDP 抓取、bundle/source map 分析、WebMCP 发现、会话时间线、capture diff 与视觉证据 |
| Electron 分析 | 5 | 限定 canonical 文件根的被动观察、有界静态应用映射,以及基于 Evidence 的静态/运行时对账 |
+310 -42
View File
@@ -58,6 +58,8 @@ import ghidra.program.model.block.CodeBlockReference;
import ghidra.program.model.block.CodeBlockReferenceIterator;
import ghidra.program.model.data.StringDataInstance;
import ghidra.program.model.data.DataType;
import ghidra.program.model.lang.Register;
import ghidra.program.model.scalar.Scalar;
import ghidra.program.model.listing.CommentType;
import ghidra.program.model.listing.Data;
import ghidra.program.model.listing.DataIterator;
@@ -75,6 +77,7 @@ import ghidra.program.model.symbol.Symbol;
import ghidra.program.model.symbol.SymbolIterator;
import ghidra.program.model.pcode.FunctionPrototype;
import ghidra.program.model.pcode.HighFunction;
import ghidra.program.model.pcode.HighParam;
import ghidra.program.model.pcode.HighSymbol;
import ghidra.program.model.pcode.JumpTable;
import ghidra.program.model.pcode.PcodeOp;
@@ -122,6 +125,9 @@ public final class ReaGhidraBridge extends HeadlessScript {
"procedure_info",
"procedure_pseudo_code",
"read_function_instructions",
"inspect_native_instruction",
"inspect_native_data_type",
"resolve_native_call_targets",
"procedure_references",
"xrefs"
};
@@ -314,6 +320,9 @@ public final class ReaGhidraBridge extends HeadlessScript {
case "resolve_containing_procedure" -> containingProcedure(request.params);
case "search_procedures" -> search(request.params, true);
case "search_strings" -> search(request.params, false);
case "inspect_native_data_type" -> inspectNativeDataType(request.params);
case "inspect_native_instruction" -> inspectNativeInstruction(request.params);
case "resolve_native_call_targets" -> resolveNativeCallTargets(request.params);
case "xrefs" -> xrefs(request.params);
case "analyze_function" -> analyzeFunction(request.params);
default -> throw new RequestFailure(
@@ -463,6 +472,224 @@ public final class ReaGhidraBridge extends HeadlessScript {
result.add("procedure", procedureIdentity(function));
result.addProperty("direction", direction);
result.add("references", edges);
result.addProperty("reference_kinds_available", true);
JsonArray unresolvedCalls = new JsonArray();
if (direction.equals("outgoing")) for (Instruction instruction : scan.instructions) {
if (!instruction.getFlowType().isCall()) continue;
boolean resolved = false;
for (Reference reference : instruction.getReferencesFrom()) if (reference.getReferenceType().isCall()) resolved = true;
if (!resolved) {
JsonObject site = new JsonObject();
site.addProperty("address", canonicalAddress(instruction.getAddress()));
site.addProperty("reason", "Instruction decoder identifies a call but ReferenceManager supplies no call destination");
unresolvedCalls.add(site);
}
}
result.add("unresolved_calls", unresolvedCalls);
return result;
}
private JsonObject inspectNativeDataType(JsonObject params) throws Exception {
requireKeys(params, Set.of("document", "type", "address"));
requireDocument(params);
boolean byType = params.has("type") && !params.get("type").isJsonNull();
boolean byAddress = params.has("address") && !params.get("address").isJsonNull();
if (byType == byAddress) throw new RequestFailure("invalid_request", "Supply exactly one of type or address");
DataType type;
Address address = null;
if (byType) type = currentProgram.getDataTypeManager().getDataType(requireString(params, "type"));
else {
address = requireAddress(params, "address");
Data data = currentProgram.getListing().getDefinedDataAt(address);
type = data == null ? null : data.getDataType();
}
JsonObject result = new JsonObject();
result.addProperty("status", type == null ? "unavailable" : "available");
result.add("reason", type == null ? GSON.toJsonTree("No exact database type or defined typed data exists at the selected identity") : JsonNull.INSTANCE);
for (String key : List.of("id", "name", "source_archive", "size_bytes", "alignment_bytes", "packing_enabled", "referenced_type", "array_count", "array_stride_bytes")) result.add(key, JsonNull.INSTANCE);
result.add("address", address == null ? JsonNull.INSTANCE : GSON.toJsonTree(canonicalAddress(address)));
result.addProperty("source", "analysis-database");
result.addProperty("kind", "unavailable");
JsonArray fields = new JsonArray();
JsonArray members = new JsonArray();
int totalFields = 0;
if (type != null) {
result.addProperty("id", type.getPathName());
result.addProperty("name", type.getName());
if (type.getSourceArchive() != null) result.addProperty("source_archive", type.getSourceArchive().getName());
if (type.getLength() >= 0) result.addProperty("size_bytes", type.getLength());
if (type.getAlignment() > 0) result.addProperty("alignment_bytes", type.getAlignment());
String kind = type instanceof ghidra.program.model.data.Structure ? "struct" : type instanceof ghidra.program.model.data.Union ? "union" : type instanceof ghidra.program.model.data.Enum ? "enum" : type instanceof ghidra.program.model.data.Pointer ? "pointer" : type instanceof ghidra.program.model.data.Array ? "array" : type instanceof ghidra.program.model.data.TypeDef ? "typedef" : type instanceof ghidra.program.model.data.BuiltInDataType ? "scalar" : "unsupported";
result.addProperty("kind", kind);
if (type instanceof ghidra.program.model.data.Composite composite) {
result.addProperty("packing_enabled", composite.isPackingEnabled());
ghidra.program.model.data.DataTypeComponent[] components = composite.getComponents();
totalFields = components.length;
for (int index = 0; index < Math.min(components.length, 20_000); index++) {
monitor.checkCancelled();
ghidra.program.model.data.DataTypeComponent component = components[index];
JsonObject field = new JsonObject();
field.addProperty("ordinal", component.getOrdinal());
field.add("name", component.getFieldName() == null ? JsonNull.INSTANCE : GSON.toJsonTree(component.getFieldName()));
field.addProperty("offset_bytes", component.getOffset());
field.add("size_bytes", component.getLength() < 0 ? JsonNull.INSTANCE : GSON.toJsonTree(component.getLength()));
field.addProperty("type_id", component.getDataType().getPathName());
field.add("bit_size", JsonNull.INSTANCE);
field.add("bit_offset", JsonNull.INSTANCE);
if (component.getDataType() instanceof ghidra.program.model.data.BitFieldDataType bitfield) {
field.addProperty("bit_size", bitfield.getBitSize());
field.addProperty("bit_offset", bitfield.getBitOffset());
}
field.addProperty("status", "observed");
field.addProperty("flexible_tail", "unknown");
fields.add(field);
}
} else if (type instanceof ghidra.program.model.data.Enum enumeration) {
String[] names = enumeration.getNames();
totalFields = names.length;
java.util.Arrays.sort(names);
for (int index = 0; index < Math.min(names.length, 20_000); index++) {
monitor.checkCancelled();
JsonObject member = new JsonObject();
member.addProperty("name", names[index]);
member.addProperty("value", Long.toString(enumeration.getValue(names[index])));
members.add(member);
}
} else if (type instanceof ghidra.program.model.data.Pointer pointer) {
if (pointer.getDataType() != null) result.addProperty("referenced_type", pointer.getDataType().getPathName());
} else if (type instanceof ghidra.program.model.data.Array array) {
result.addProperty("referenced_type", array.getDataType().getPathName());
result.addProperty("array_count", array.getNumElements());
result.addProperty("array_stride_bytes", array.getElementLength());
} else if (type instanceof ghidra.program.model.data.TypeDef alias) {
result.addProperty("referenced_type", alias.getDataType().getPathName());
}
}
result.add("fields", fields);
result.add("members", members);
result.addProperty("total_fields", totalFields);
result.addProperty("truncated", totalFields > 20_000);
result.add("limitations", GSON.toJsonTree(List.of("Layouts are Ghidra data-type database observations; original debug/source authority and flexible-tail semantics are not inferred. Nested and recursive types retain category-path identities for separate inspection.", "At most 20000 fields or enum members are serialized per type to bound provider output. total_fields and truncated report omissions. Enum values preserve Ghidra's signed long representation.")));
return result;
}
private JsonObject instructionReference(Reference reference) {
JsonObject item = new JsonObject();
RefType type = reference.getReferenceType();
item.addProperty("target_address", canonicalAddress(reference.getToAddress()));
item.addProperty("type", type.getName());
item.addProperty("call", type.isCall());
item.addProperty("jump", type.isJump());
item.addProperty("indirect", type.isIndirect());
item.addProperty("computed", type.isComputed());
item.addProperty("operand_index", reference.getOperandIndex());
return item;
}
private JsonObject inspectNativeInstruction(JsonObject params) throws Exception {
requireKeys(params, Set.of("document", "address"));
requireDocument(params);
Address address = requireAddress(params, "address");
Instruction instruction = currentProgram.getListing().getInstructionAt(address);
Function function = currentProgram.getFunctionManager().getFunctionContaining(address);
JsonObject result = new JsonObject();
result.addProperty("address", canonicalAddress(address));
result.add("procedure", function == null ? JsonNull.INSTANCE : GSON.toJsonTree(canonicalAddress(function.getEntryPoint())));
result.addProperty("architecture", currentProgram.getLanguageID().getIdAsString());
result.addProperty("mode", currentProgram.getLanguage().getLanguageDescription().getVariant());
JsonArray operands = new JsonArray();
JsonArray references = new JsonArray();
JsonArray destinations = new JsonArray();
JsonObject flow = new JsonObject();
flow.addProperty("kind", "unavailable");
flow.addProperty("conditional", false);
flow.addProperty("computed", false);
if (instruction == null) {
result.addProperty("status", !currentProgram.getMemory().contains(address) ? "outside-memory" : currentProgram.getListing().getInstructionContaining(address) != null
? "not-instruction-boundary" : currentProgram.getListing().getDefinedDataContaining(address) != null ? "data" : "undecodable");
for (String key : List.of("bytes", "length", "mnemonic", "raw_disassembly")) result.add(key, JsonNull.INSTANCE);
} else {
result.addProperty("status", "decoded");
result.addProperty("bytes", java.util.HexFormat.of().formatHex(instruction.getBytes()));
result.addProperty("length", instruction.getLength());
result.addProperty("mnemonic", instruction.getMnemonicString());
result.addProperty("raw_disassembly", instruction.toString());
RefType type = instruction.getFlowType();
flow.addProperty("kind", type.isCall() ? "call" : type.isJump() ? "jump" : type.isTerminal() ? "terminal" : "fallthrough");
flow.addProperty("conditional", type.isConditional());
flow.addProperty("computed", type.isComputed());
if (!type.isComputed()) for (Address target : instruction.getFlows()) destinations.add(canonicalAddress(target));
for (Reference reference : instruction.getReferencesFrom()) references.add(instructionReference(reference));
for (int index = 0; index < instruction.getNumOperands(); index++) {
JsonObject operand = new JsonObject();
operand.addProperty("index", index);
operand.addProperty("raw", instruction.getDefaultOperandRepresentation(index));
operand.addProperty("provider_type", instruction.getOperandType(index));
operand.addProperty("memory_addressing", "unavailable");
JsonArray components = new JsonArray();
for (Object object : instruction.getOpObjects(index)) {
JsonObject component = new JsonObject();
component.addProperty("text", object.toString());
component.add("value", JsonNull.INSTANCE);
component.add("bit_width", JsonNull.INSTANCE);
if (object instanceof Register register) {
component.addProperty("kind", "register");
component.addProperty("value", register.getName());
component.addProperty("bit_width", register.getBitLength());
} else if (object instanceof Scalar scalar) {
component.addProperty("kind", "immediate");
component.addProperty("value", "0x" + Long.toUnsignedString(scalar.getUnsignedValue(), 16));
component.addProperty("bit_width", scalar.bitLength());
} else if (object instanceof Address target) {
component.addProperty("kind", "address");
component.addProperty("value", canonicalAddress(target));
} else component.addProperty("kind", "unknown");
components.add(component);
}
operand.add("components", components);
operands.add(operand);
}
}
flow.add("direct_destinations", destinations);
result.add("operands", operands);
result.add("flow", flow);
result.add("references", references);
result.add("limitations", GSON.toJsonTree(List.of("Operand components are Ghidra Listing decoder tokens; effective memory base/index/displacement roles and per-instruction context mode are unavailable. The mode field is the program language variant.", "Terminal flow is not classified as a return without provider evidence.")));
return result;
}
private JsonObject resolveNativeCallTargets(JsonObject params) throws Exception {
JsonObject instruction = inspectNativeInstruction(params);
JsonObject result = new JsonObject();
result.add("call_site", instruction.get("address"));
result.add("procedure", instruction.get("procedure"));
JsonArray targets = new JsonArray();
JsonObject flow = instruction.getAsJsonObject("flow");
boolean decoded = instruction.get("status").getAsString().equals("decoded");
boolean call = flow.get("kind").getAsString().equals("call");
boolean computed = flow.get("computed").getAsBoolean();
Map<String, JsonArray> byTarget = new TreeMap<>();
if (call) for (JsonElement element : instruction.getAsJsonArray("references")) {
JsonObject reference = element.getAsJsonObject();
if (reference.get("call").getAsBoolean())
byTarget.computeIfAbsent(reference.get("target_address").getAsString(), ignored -> new JsonArray()).add(reference);
}
boolean ambiguous = byTarget.size() > 1;
for (Map.Entry<String, JsonArray> entry : byTarget.entrySet()) {
JsonObject target = new JsonObject();
target.addProperty("address", entry.getKey());
Address address = tryParseAddress(entry.getKey());
Function function = address == null ? null : currentProgram.getFunctionManager().getFunctionAt(address);
target.add("procedure", function == null ? JsonNull.INSTANCE : GSON.toJsonTree(canonicalAddress(function.getEntryPoint())));
target.addProperty("status", ambiguous ? "candidate" : computed ? "resolved-indirect" : "direct");
target.addProperty("basis", "provider-reference");
target.add("references", entry.getValue());
targets.add(target);
}
result.addProperty("status", !decoded ? "unavailable" : !call ? "not-call" : ambiguous ? "ambiguous" : targets.size() == 0 ? "unresolved" : computed ? "resolved-indirect" : "direct");
result.addProperty("mechanism", !call ? "unavailable" : computed ? "computed" : "direct");
result.add("targets", targets);
result.add("limitations", GSON.toJsonTree(List.of("Targets are static Ghidra ReferenceManager call-reference observations. Computed calls without call references remain unresolved; a resolved reference is not proof of runtime execution.", "Objective-C, Swift, vtable and closure mechanisms are not identified by this reference-only boundary.")));
return result;
}
@@ -586,6 +813,18 @@ public final class ReaGhidraBridge extends HeadlessScript {
for (PcodeOpAST operation : selected) {
operationIds.put(operation, pcodeOperationId(operation));
}
JsonArray parameterUses = new JsonArray();
JsonArray parameterValues = new JsonArray();
FunctionPrototype prototype = highFunction.getFunctionPrototype();
for (int ordinal = 0; ordinal < prototype.getNumParams(); ordinal++) {
HighSymbol parameter = prototype.getParam(ordinal);
if (parameter == null) continue;
JsonObject value = new JsonObject();
value.addProperty("ordinal", ordinal);
value.addProperty("name", parameter.getName());
value.addProperty("data_type", parameter.getDataType().getPathName());
parameterValues.add(value);
}
JsonArray operationValues = new JsonArray();
JsonArray defUse = new JsonArray();
JsonArray effects = new JsonArray();
@@ -603,6 +842,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
value.addProperty("sequence", operation.getSeqnum().getTime());
value.addProperty("opcode", operation.getMnemonic());
value.addProperty("is_dead", operation.isDead());
value.addProperty("block_membership", operation.getParent() == null ? "detached" : "member");
JsonArray inputs = new JsonArray();
int inputCount = operation.getNumInputs();
int capturedInputs = Math.min(inputCount, MAX_HIGH_PCODE_INPUTS_PER_OP);
@@ -610,6 +850,16 @@ public final class ReaGhidraBridge extends HeadlessScript {
Varnode input = operation.getInput(inputIndex);
inputs.add(varnodeValue(input));
if (input instanceof VarnodeAST astInput) {
if (astInput.getHigh() instanceof HighParam parameter) {
if (parameterUses.size() >= MAX_HIGH_PCODE_DEF_USE_EDGES) omittedEdges++;
else {
JsonObject use = new JsonObject();
use.addProperty("ordinal", parameter.getSlot());
use.addProperty("use", operationId);
use.addProperty("input_index", inputIndex);
parameterUses.add(use);
}
}
PcodeOp definition = astInput.getDef();
if (definition instanceof PcodeOpAST astDefinition) {
String definitionId = operationIds.get(astDefinition);
@@ -638,6 +888,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
result.addProperty("provenance", "ghidra-high-pcode");
result.add("operations", operationValues);
result.add("def_use", defUse);
result.add("parameters", parameterValues);
result.add("parameter_uses", parameterUses);
result.add("effects", effects);
boolean truncated = omittedOperationsLowerBound > 0 ||
omittedInputs > 0 || omittedEdges > 0;
@@ -650,7 +902,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
result.addProperty("known_omitted_edges", omittedEdges);
JsonArray limitations = new JsonArray();
limitations.add(
"High p-code is a decompiler-derived intra-function representation, not original source or runtime behavior; dead operations are retained and marked."
"High p-code is decompiler-derived, not original source or runtime behavior. is_dead preserves the Java AST flag; decoded block members can retain that flag, so block_membership separately reports actual syntax-tree membership."
);
limitations.add(
"Def-use links do not cross function boundaries and may omit memory aliasing, call side effects, or unresolved indirect flow."
@@ -1198,9 +1450,9 @@ public final class ReaGhidraBridge extends HeadlessScript {
canonicalAddress(jumpTable.getSwitchAddress())
);
JumpTable.LoadTable[] loadTables = jumpTable.getLoadTables();
RecoveredAArch64ByteTable recoveredAArch64 =
RecoveredAArch64RelativeTable recoveredAArch64 =
loadTables.length == 0
? recoverAArch64ByteTable(jumpTable)
? recoverAArch64RelativeTable(jumpTable)
: null;
List<Address> referencedDataAddresses = loadTables.length == 0
? dispatchPathDataReferences(jumpTable)
@@ -1241,8 +1493,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
"address",
canonicalAddress(recoveredAArch64.tableAddress)
);
source.addProperty("provenance", "ghidra-aarch64-byte-table");
source.addProperty("entry_size_bytes", 1);
source.addProperty("provenance", recoveredAArch64.entrySize == 1 ? "ghidra-aarch64-byte-table" : "ghidra-aarch64-halfword-table");
source.addProperty("entry_size_bytes", recoveredAArch64.entrySize);
source.addProperty("entry_count", recoveredAArch64.mappings.size());
source.addProperty("confidence", "high");
JsonArray evidence = new JsonArray();
@@ -1251,8 +1503,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
"jump-table",
"ghidra-instruction-and-memory",
"Decoded " + recoveredAArch64.mappings.size() +
" byte entries from " + canonicalAddress(recoveredAArch64.tableAddress) +
"; the AArch64 LDRB/ADR/ADD/BR chain uses a selector guarded by an unsigned upper-bound branch."
" entries of size " + recoveredAArch64.entrySize + " bytes from " + canonicalAddress(recoveredAArch64.tableAddress) +
"; the AArch64 LDRB-or-LDRH/ADR/ADD/BR chain uses a selector guarded by an unsigned upper-bound branch."
)
);
source.add("evidence", evidence);
@@ -1307,9 +1559,9 @@ public final class ReaGhidraBridge extends HeadlessScript {
inferenceEvidence(
"jump-table",
"ghidra-instruction-and-memory",
"Case value " + recovered.caseValue + " indexes byte " +
recovered.caseValue + " at " + canonicalAddress(recoveredAArch64.tableAddress) +
"; the loaded byte selects a four-byte instruction offset from " +
"Case value " + recovered.caseValue + (recoveredAArch64.entrySize == 1 ? " indexes byte " : " indexes halfword at byte offset ") +
(recovered.caseValue * recoveredAArch64.entrySize) + " at " + canonicalAddress(recoveredAArch64.tableAddress) +
"; the loaded unsigned entry selects a four-byte instruction offset from " +
canonicalAddress(recoveredAArch64.branchBase) + ", reaching " + targetAddress + "."
)
);
@@ -1384,6 +1636,9 @@ public final class ReaGhidraBridge extends HeadlessScript {
"Case-label and target counts differ or labels are unavailable; all case values are left unknown because index alignment cannot be established."
);
}
if (recoveredAArch64 == null && loadTables.length == 0 && currentProgram.getLanguageID().getIdAsString().startsWith("AARCH64:")) {
limitations.add("REA did not verify a supported byte/halfword relative dispatch chain with exact unsigned bounds and register definitions; unsupported encodings and ambiguous bounds remain unknown.");
}
if (targets.length == 0) {
limitations.add(
"Ghidra exposed a jump-table dispatch without recovered case targets."
@@ -1403,27 +1658,30 @@ public final class ReaGhidraBridge extends HeadlessScript {
}
}
private static final class RecoveredAArch64ByteTable {
private static final class RecoveredAArch64RelativeTable {
private final Address tableAddress;
private final Address branchBase;
private final int entrySize;
private final List<RecoveredAArch64Mapping> mappings;
private RecoveredAArch64ByteTable(
private RecoveredAArch64RelativeTable(
Address tableAddress,
Address branchBase,
int entrySize,
List<RecoveredAArch64Mapping> mappings
) {
this.tableAddress = tableAddress;
this.branchBase = branchBase;
this.entrySize = entrySize;
this.mappings = mappings;
}
}
/**
* Recover only the directly verified AArch64 byte-indexed relative branch
* Recover only the directly verified AArch64 byte/halfword-indexed relative branch
* table form. Other instruction sequences keep their case labels unknown.
*/
private RecoveredAArch64ByteTable recoverAArch64ByteTable(
private RecoveredAArch64RelativeTable recoverAArch64RelativeTable(
JumpTable jumpTable
) throws Exception {
if (!currentProgram.getLanguageID().getIdAsString().startsWith("AARCH64:")) {
@@ -1452,31 +1710,25 @@ public final class ReaGhidraBridge extends HeadlessScript {
Set<String> recoveredTargetSet = new HashSet<>();
for (Address target : recoveredTargets) recoveredTargetSet.add(canonicalAddress(target));
List<Address> dataAddresses = dispatchPathDataReferences(jumpTable);
if (dataAddresses.isEmpty()) return null;
for (int loadIndex = 0; loadIndex < branchIndex; loadIndex += 1) {
Instruction load = instructions.get(loadIndex);
if (!load.getMnemonicString().equalsIgnoreCase("LDRB")) continue;
int entrySize = load.getMnemonicString().equalsIgnoreCase("LDRB") ? 1 : load.getMnemonicString().equalsIgnoreCase("LDRH") ? 2 : 0;
if (entrySize == 0 || branchIndex != loadIndex + 2 || loadIndex < 3 ||
!instructions.get(loadIndex - 1).getMnemonicString().equalsIgnoreCase("ADR") ||
!instructions.get(loadIndex - 2).getMnemonicString().equalsIgnoreCase("ADD") ||
!instructions.get(loadIndex - 3).getMnemonicString().equalsIgnoreCase("ADRP")) continue;
String loadOperands = operands(load);
java.util.regex.Matcher loadMatcher = java.util.regex.Pattern
.compile("(?i)^[wx]\\d+,\\s*\\[([wx]\\d+),\\s*([wx]\\d+)\\]$")
.compile(entrySize == 1 ? "(?i)^[wx]\\d+,\\s*\\[([wx]\\d+),\\s*([wx]\\d+)\\]$" : "(?i)^[wx]\\d+,\\s*\\[([wx]\\d+),\\s*([wx]\\d+),\\s*LSL\\s*#(?:0x)?1\\]$")
.matcher(loadOperands);
if (!loadMatcher.matches()) continue;
String loadedRegister = normalizedRegister(loadOperands.substring(0, loadOperands.indexOf(',')));
String tableBaseRegister = normalizedRegister(loadMatcher.group(1));
String indexRegister = normalizedRegister(loadMatcher.group(2));
Address tableAddress = null;
for (Address candidate : dataAddresses) {
if (currentProgram.getMemory().contains(candidate)) {
tableAddress = candidate;
break;
}
}
if (tableAddress == null) continue;
Address resolvedTableAddress = null;
for (int index = 0; index < loadIndex; index += 1) {
for (int index = loadIndex - 3; index < loadIndex - 1; index += 1) {
Instruction instruction = instructions.get(index);
String mnemonic = instruction.getMnemonicString().toUpperCase(Locale.ROOT);
String text = operands(instruction);
@@ -1496,18 +1748,20 @@ public final class ReaGhidraBridge extends HeadlessScript {
}
}
}
if (!tableAddress.equals(resolvedTableAddress)) continue;
if (resolvedTableAddress == null || !currentProgram.getMemory().contains(resolvedTableAddress) ||
(!dataAddresses.isEmpty() && !dataAddresses.contains(resolvedTableAddress))) continue;
Address tableAddress = resolvedTableAddress;
Address branchBase = null;
boolean addressChain = false;
for (int index = 0; index < branchIndex; index += 1) {
for (int index = loadIndex - 1; index < branchIndex; index += 1) {
Instruction instruction = instructions.get(index);
String mnemonic = instruction.getMnemonicString().toUpperCase(Locale.ROOT);
String text = operands(instruction);
if (index < loadIndex && mnemonic.equals("ADR") && text.matches("(?i)^[wx]\\d+,\\s*0x[0-9a-f]+$")) {
int comma = text.indexOf(',');
String destination = normalizedRegister(text.substring(0, comma));
if (destination.equals(tableBaseRegister)) continue;
if (!destination.equals(normalizedRegister(operands(instructions.get(branchIndex))))) continue;
branchBase = addressFromOperand(text.substring(comma + 1));
}
if (index > loadIndex && mnemonic.equals("ADD") && branchBase != null &&
@@ -1543,7 +1797,6 @@ public final class ReaGhidraBridge extends HeadlessScript {
if (!mnemonic.equals("B.HI")) continue;
Address[] flows = guard.getFlows();
if (flows.length != 1 || !recoveredTargetSet.contains(canonicalAddress(flows[0]))) continue;
boolean selectorFeedsTable = indexRegister.equals(comparedRegister);
boolean supportedSequence = true;
for (int sequenceIndex = compareIndex + 1; sequenceIndex < loadIndex; sequenceIndex += 1) {
String sequenceMnemonic = instructions.get(sequenceIndex)
@@ -1553,17 +1806,31 @@ public final class ReaGhidraBridge extends HeadlessScript {
break;
}
}
for (int moveIndex = compareIndex + 1; moveIndex < loadIndex && !selectorFeedsTable; moveIndex += 1) {
Instruction move = instructions.get(moveIndex);
if (!move.getMnemonicString().equalsIgnoreCase("MOV")) continue;
String[] moveParts = operands(move).split(",");
if (moveParts.length == 2 &&
normalizedRegister(moveParts[0]).equals(indexRegister) &&
normalizedRegister(moveParts[1]).equals(comparedRegister)) {
selectorFeedsTable = true;
Set<String> selectorAliases = new HashSet<>();
selectorAliases.add(comparedRegister);
Set<String> boundedIndexes = new HashSet<>();
if (compareMatcher.group(1).toLowerCase(Locale.ROOT).startsWith("x")) boundedIndexes.add(comparedRegister);
for (int sequenceIndex = compareIndex + 1; sequenceIndex < loadIndex; sequenceIndex++) {
Instruction instruction = instructions.get(sequenceIndex);
String[] parts = operands(instruction).split(",");
if (parts.length == 0) continue;
if (instruction.getMnemonicString().equalsIgnoreCase("MOV")) {
String destination = normalizedRegister(parts[0]);
String source = parts.length == 2 ? normalizedRegister(parts[1]) : "";
boolean aliasesSelector = selectorAliases.contains(source);
boolean fullWidthBounded = boundedIndexes.contains(source) ||
(parts[0].trim().toLowerCase(Locale.ROOT).startsWith("w") && aliasesSelector);
if (aliasesSelector) selectorAliases.add(destination);
else selectorAliases.remove(destination);
if (fullWidthBounded) boundedIndexes.add(destination);
else boundedIndexes.remove(destination);
} else if (Set.of("ADRP", "ADD", "ADR").contains(instruction.getMnemonicString().toUpperCase(Locale.ROOT))) {
selectorAliases.remove(normalizedRegister(parts[0]));
boundedIndexes.remove(normalizedRegister(parts[0]));
}
}
if (selectorFeedsTable && supportedSequence) {
boolean selectorFeedsTable = boundedIndexes.contains(indexRegister);
if (compareIndex < loadIndex - 3 && selectorFeedsTable && supportedSequence) {
entryCount = (int) upperBound + 1;
defaultTarget = flows[0];
break;
@@ -1577,7 +1844,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
Set<String> mappedTargets = new HashSet<>();
boolean valid = true;
for (int caseValue = 0; caseValue < entryCount; caseValue += 1) {
int encodedOffset = currentProgram.getMemory().getByte(tableAddress.add(caseValue)) & 0xff;
Address entry = tableAddress.add((long) caseValue * entrySize);
int encodedOffset = entrySize == 1 ? currentProgram.getMemory().getByte(entry) & 0xff : currentProgram.getMemory().getShort(entry) & 0xffff;
Address target = branchBase.add((long) encodedOffset * 4L);
String canonicalTarget = canonicalAddress(target);
if (!recoveredTargetSet.contains(canonicalTarget)) {
@@ -1591,7 +1859,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
Set<String> accountedTargets = new HashSet<>(mappedTargets);
accountedTargets.add(canonicalAddress(defaultTarget));
if (!accountedTargets.equals(recoveredTargetSet)) continue;
return new RecoveredAArch64ByteTable(tableAddress, branchBase, List.copyOf(mappings));
return new RecoveredAArch64RelativeTable(tableAddress, branchBase, entrySize, List.copyOf(mappings));
}
return null;
}
+5
View File
@@ -150,6 +150,11 @@ EXHAUSTIVE_ANALYSIS_METHODS = frozenset(
def _hex(value):
if not isinstance(value, int) or isinstance(value, bool):
try:
value = int(str(value), 16)
except ValueError as error:
raise ValueError("Hopper returned a non-hexadecimal address") from error
return "0x%x" % value
+156
View File
@@ -0,0 +1,156 @@
import AppKit
import ApplicationServices
import CryptoKit
import ScreenCaptureKit
import Darwin
struct Request: Decodable {
var pid: Int32
var window_id: UInt32
var executable: String
var sha256: String
var launch_time: Double?
var screenshot: Bool
var accessibility: Bool
var max_nodes: Int
var action: Action?
}
struct Action: Decodable { var kind: String; var path: [Int]?; var direction: String?; var text: String? }
struct BoundaryFailure: Error { var code: String; var message: String }
func fail(_ code: String, _ message: String) throws -> Never { throw BoundaryFailure(code: code, message: message) }
func attribute(_ element: AXUIElement, _ key: String) -> CFTypeRef? {
var value: CFTypeRef?
return AXUIElementCopyAttributeValue(element, key as CFString, &value) == .success ? value : nil
}
func childCount(_ element: AXUIElement) -> Int {
var count = 0
return AXUIElementGetAttributeValueCount(element, kAXChildrenAttribute as CFString, &count) == .success ? count : 0
}
func children(_ element: AXUIElement, limit: Int = 2000) -> [AXUIElement] {
var value: CFArray?
let count = min(limit, childCount(element))
if count == 0 { return [] }
return AXUIElementCopyAttributeValues(element, kAXChildrenAttribute as CFString, 0, count, &value) == .success ? value as? [AXUIElement] ?? [] : []
}
func text(_ element: AXUIElement, _ key: String) -> Any { (attribute(element, key) as? String).map { String($0.prefix(4096)) } ?? NSNull() as Any }
func windowBounds(_ element: AXUIElement) -> CGRect? {
guard let p = attribute(element, kAXPositionAttribute), let s = attribute(element, kAXSizeAttribute),
CFGetTypeID(p) == AXValueGetTypeID(), CFGetTypeID(s) == AXValueGetTypeID() else { return nil }
var point = CGPoint.zero; var size = CGSize.zero
guard AXValueGetValue(p as! AXValue, .cgPoint, &point), AXValueGetValue(s as! AXValue, .cgSize, &size) else { return nil }
return CGRect(origin: point, size: size)
}
func observe(_ request: Request) async throws -> [String: Any] {
guard let app = NSRunningApplication(processIdentifier: request.pid), let url = app.executableURL else {
try fail("target-mismatch", "Selected PID does not expose an application executable URL")
}
guard let canonicalPointer = realpath(url.path, nil) else { try fail("target-mismatch", "Running executable path cannot be resolved") }
defer { free(canonicalPointer) }
let canonicalExecutable = String(cString: canonicalPointer)
guard canonicalExecutable == request.executable else {
try fail("target-mismatch", "Selected PID executable \(canonicalExecutable) differs from active target \(request.executable)")
}
guard let launch = app.launchDate?.timeIntervalSince1970 else {
try fail("process-identity-unavailable", "Selected application does not expose a launch time; stable PID reuse checks are unavailable")
}
if let expected = request.launch_time, expected != launch { try fail("process-replaced", "Selected process was replaced") }
let executableSize = (try FileManager.default.attributesOfItem(atPath: url.path)[.size] as? NSNumber)?.int64Value ?? -1
guard executableSize >= 0 && executableSize <= 256 * 1024 * 1024 else { try fail("target-limit", "Running executable exceeds the 256 MiB identity-check budget") }
let executableBytes = try Data(contentsOf: url, options: .mappedIfSafe)
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]],
windows.count == 1, let window = windows.first,
(window[kCGWindowOwnerPID as String] as? NSNumber)?.int32Value == request.pid,
(window[kCGWindowLayer as String] as? NSNumber)?.intValue == 0,
let boundsDictionary = window[kCGWindowBounds as String] as? [String: Any],
let bounds = CGRect(dictionaryRepresentation: boundsDictionary as CFDictionary) else {
try fail("window-mismatch", "Selected window is absent or belongs to another process")
}
if request.screenshot && !CGPreflightScreenCaptureAccess() {
try fail("screen-recording-denied", "Grant Screen Recording to the REA host in System Settings > Privacy & Security, then retry; no broad capture fallback is used")
}
var selected: AXUIElement?
if request.accessibility || request.action != nil {
guard AXIsProcessTrusted() else { try fail("accessibility-denied", "Grant Accessibility to the REA host in System Settings > Privacy & Security, then retry") }
let application = AXUIElementCreateApplication(request.pid)
AXUIElementSetMessagingTimeout(application, 2)
guard let axWindows = attribute(application, kAXWindowsAttribute) as? [AXUIElement] else {
try fail("accessibility-unavailable", "Selected application does not expose accessibility windows")
}
let matches = axWindows.filter { windowBounds($0) == bounds }
guard matches.count == 1 else { try fail("ambiguous-window", "Accessibility window geometry does not identify exactly one selected window; selected CG bounds \(bounds), AX candidates \(axWindows.map { String(describing: windowBounds($0)) })") }
selected = matches[0]
}
if let action = request.action, let root = selected {
var element = root
for index in action.path ?? [] {
var selectedChild: CFArray?
guard index >= 0 && index < childCount(element),
AXUIElementCopyAttributeValues(element, kAXChildrenAttribute as CFString, index, 1, &selectedChild) == .success,
let item = (selectedChild as? [AXUIElement])?.first else { try fail("element-missing", "Selected accessibility path no longer exists") }
element = item
}
var owner: pid_t = 0
guard AXUIElementGetPid(element, &owner) == .success && owner == request.pid else { try fail("element-owner-mismatch", "Selected element belongs to another process") }
let outcome: AXError
switch action.kind {
case "click": outcome = AXUIElementPerformAction(element, kAXPressAction as CFString)
case "scroll": outcome = AXUIElementPerformAction(element, (action.direction == "increment" ? kAXIncrementAction : kAXDecrementAction) as CFString)
case "key-entry": outcome = AXUIElementSetAttributeValue(element, kAXValueAttribute as CFString, (action.text ?? "") as CFString)
default: try fail("unsupported-action", "Only selected-element press, increment/decrement and text-value entry are admitted")
}
guard outcome == .success else { try fail("action-failed", "Accessibility action failed with AXError \(outcome.rawValue)") }
}
var nodes: [[String: Any]] = []; var truncated = false
if request.accessibility, let root = selected {
var pending: [(AXUIElement, [Int])] = [(root, [])]
while let (element, path) = pending.popLast() {
if nodes.count >= request.max_nodes { truncated = true; break }
let items = children(element)
var actions: CFArray?
AXUIElementCopyActionNames(element, &actions)
nodes.append(["path": path, "role": text(element, kAXRoleAttribute), "title": text(element, kAXTitleAttribute), "value": text(element, kAXValueAttribute), "actions": actions as? [String] ?? [], "children_count": childCount(element)])
if path.count >= 32 { if !items.isEmpty { truncated = true }; continue }
let available = max(0, request.max_nodes - nodes.count - pending.count)
let count = min(available, items.count)
if childCount(element) > count { truncated = true }
for index in (0..<count).reversed() { pending.append((items[index], path + [index])) }
}
}
var screenshot: Any = NSNull()
if request.screenshot {
guard #available(macOS 14, *) else { try fail("unsupported-host", "Selected-window screenshots require macOS 14 or later") }
let content = try await SCShareableContent.excludingDesktopWindows(true, onScreenWindowsOnly: false)
guard let scWindow = content.windows.first(where: { $0.windowID == request.window_id && $0.owningApplication?.processID == request.pid }) else {
try fail("capture-window-missing", "Selected window disappeared before capture")
}
guard bounds.width > 0 && bounds.height > 0 && bounds.width <= 100000 && bounds.height <= 100000 else { try fail("window-bounds", "Selected window has invalid or oversized geometry") }
let filter = SCContentFilter(desktopIndependentWindow: scWindow)
let configuration = SCStreamConfiguration()
let scale = min(1, 2048 / max(bounds.width, bounds.height))
configuration.width = max(1, Int(bounds.width * scale)); configuration.height = max(1, Int(bounds.height * scale))
configuration.showsCursor = false
let image = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: configuration)
guard let png = NSBitmapImageRep(cgImage: image).representation(using: .png, properties: [:]), png.count <= 8 * 1024 * 1024 else {
try fail("image-limit", "Selected-window PNG exceeded 8 MiB")
}
screenshot = ["mime_type": "image/png", "base64": png.base64EncodedString(), "sha256": SHA256.hash(data: png).map({ String(format: "%02x", $0) }).joined(), "width": image.width, "height": image.height]
}
return ["window": ["pid": request.pid, "window_id": request.window_id, "executable": request.executable, "launch_time": launch, "title": window[kCGWindowName as String] as? String ?? ""], "nodes": nodes, "truncated": truncated, "screenshot": screenshot, "gaps": request.accessibility ? [] : ["Accessibility capture was disabled"]]
}
Task { @MainActor in
do {
let request = try JSONDecoder().decode(Request.self, from: Data(CommandLine.arguments[1].utf8))
let result = try await observe(request)
let output = try JSONSerialization.data(withJSONObject: ["ok": true, "result": result], options: [.sortedKeys])
FileHandle.standardOutput.write(output)
} catch {
let failure = error as? BoundaryFailure
let output: [String: Any] = ["ok": false, "code": failure?.code ?? "capture-failed", "message": failure?.message ?? error.localizedDescription]
if let data = try? JSONSerialization.data(withJSONObject: output, options: [.sortedKeys]) { FileHandle.standardOutput.write(data) }
}
exit(0)
}
RunLoop.main.run()
+135 -67
View File
@@ -1,78 +1,146 @@
# Native UI and dispatch investigation
REA combines decoded application resources, provider metadata, and native code
analysis into evidence-linked investigation graphs. Each layer has a different
coverage boundary; a graph edge is not automatically proof that a runtime path
executes.
REA joins static resources, native metadata and code facts while keeping runtime
observations separate. Every result reports its evidence, target identity,
coverage and unknowns. The CLI and MCP use the same application workflows.
## Current workflow
## Static inspection
1. `decode_interface_builder` reads compiled Apple storyboard and nib resources
from an artifact graph and returns scenes, objects, outlets, actions,
connections, and available controller/class names. A source-owned AppKit
XIB is compiled by `ibtool` in `verify:interface-builder` and checked for
its hierarchy and target/action edge. Storyboard compilation still depends
on an installed iOS platform; unsupported archive objects remain explicit.
2. `inspect_native_dispatch_metadata` normalizes supported provider name
inventory into typed Objective-C and Swift records. Current extraction is
symbol-name based: symbolized Objective-C method implementations and
mangled Swift names can be reported as partial evidence. It does not yet
decode Objective-C runtime sections, ivar layouts, protocol conformance
records, Swift witness tables, vtables, relative pointers, or closure
thunks from binary metadata.
3. `trace_native_ui_action` accepts one selector or Interface Builder object
ID. REA decodes the active app and reads names from the bound native provider,
verifies that both observations refer to the same target digest, joins an
authored action to a symbolized Objective-C implementation, and follows
bounded direct callees. Ambiguous selectors and unresolved calls remain
explicit. This is a static candidate route, not proof of runtime reachability;
it does not resolve Swift witnesses, vtables, closures, or cross-function
value flow yet. The CLI uses the same query: `rea trace-native-ui-action
<app-or-binary> <selector-or-object-id>`.
`inspect_native_dispatch_metadata` and `rea inspect-native-dispatch-metadata`
expose the provider's typed symbol-derived inventory separately.
4. Ghidra `analyze_function` returns a bounded high-p-code def-use view for the
selected function. This can support manual state/value analysis, but REA
does not yet compose those views into an automatic cross-function slice.
- `inspect_asset_catalog` / `rea inspect-asset-catalog <app>` reads compiled
`Assets.car` metadata through macOS `assetutil --info`. Catalog digests, raw
rendition fields, pagination and exact UI resource-name matches are returned.
Image extraction and undocumented rendition interpretation are unsupported.
- `inspect_keyed_archive` / `rea inspect-keyed-archive <plist>` reads XML or
binary Foundation keyed archives without instantiating classes. For an active
app, provide its relative archive path as the second CLI argument or MCP
`path`. Results retain original `$objects` indices, named `$top` roots,
shared/cyclic references, class descriptors, raw serialized fields and
malformed/unresolved/nil references. Missing fields stay absent; unknown
classes are preserved. Select `root`, `offset` and `limit` explicitly.
UID 0 cannot distinguish conditional nil from ordinary nil. Limits are
64 MiB of input, 200,000 objects/references and 128 nesting levels.
- `decode_interface_builder` / `rea decode-interface-builder <app>` reads
compiled storyboard and nib resources into objects, outlets, actions,
connections and controller/class names. `verify:interface-builder` compiles
a source-owned AppKit XIB with `ibtool`. Storyboards require an installed
iOS platform; unsupported archive forms remain explicit.
- `inspect_native_dispatch_metadata` / `rea inspect-native-dispatch-metadata
<app-or-binary>` prefers a validated macOS Mach-O byte reader. It decodes
64-bit little-endian Objective-C class/metaclass records, superclass pointers,
absolute/relative method entries, ivar offsets/sizes/alignment and protocol
declarations. It also decodes simple Swift conformances, static synchronous
witness slots, signed-relative pointers and non-generic, non-resilient class
vtable descriptors. Records include exact virtual addresses/file offsets and
artifact evidence. Vtable indexes are metadata word offsets; witness indexes
start after the conformance header. Names unavailable in metadata remain null.
Chained fixups, external bindings, authenticated pointers, categories,
properties, generic/resilient/async/coroutine tables and inherited overrides
have explicit unsupported or partial coverage. Other providers retain the
existing symbol-based inventory with its narrower coverage.
## Ghidra p-code result boundary
## Native instruction, call and type primitives
The dossier retains up to 3,000 p-code operations, up to 64 inputs per
operation, and 12,000 def-use edges. Each operation carries its address,
sequence, opcode, input/output varnodes, and whether Ghidra marks it dead.
Memory reads/writes, conditional and indirect branches, and direct/indirect
calls are reported by opcode with operand indexes. Results expose truncation,
an omitted-operation lower bound, and known omitted-input and omitted-edge
counts.
Ghidra supplies three exact-object operations:
These are decompiler-derived static observations. Def-use edges stop at a
function boundary; memory aliasing, call side effects, unresolved dispatch,
runtime behavior, and application-level meaning require additional evidence.
A p-code `STORE` can describe stack memory and does not by itself identify
persistent application state.
```bash
rea inspect-native-instruction <binary> <address> --provider ghidra
rea resolve-native-call-targets <binary> <call-site> --provider ghidra
rea inspect-native-data-type <binary> --type /MyStruct --provider ghidra
rea inspect-native-data-type <binary> --address <typed-data-address> --provider ghidra
```
Recovered jump-table load sources and case-to-target mappings are reported as
separate facts. On AArch64, REA additionally decodes the verified byte-indexed
relative branch-table form when the selector's unsigned upper bound, `LDRB`
table load, `ADR` branch base, scaled `ADD`, indirect `BR`, and recovered
target set agree. It reads only the proven entry count and reports one direct
mapping per selector value. Other layouts retain unknown case values and
unknown table bounds when Ghidra does not provide aligned labels or load-table
metadata; REA does not extrapolate from neighboring targets.
Instruction facts include bytes, length, decoder text, ordered register/scalar/
address tokens, typed references and flow destinations. Mid-instruction, data,
outside-memory and undecodable addresses have separate outcomes. Effective
memory base/index/displacement roles and per-instruction context mode remain
unavailable; `mode` is the program language variant.
## Provider admission and remaining work
Call resolution reports direct, resolved indirect, ambiguous, unresolved and
non-call outcomes from static call references. It does not establish runtime
execution or classify Objective-C/Swift/vtable/closure mechanisms from names.
Ghidra is admitted on Linux x64; the experimental Windows x64 P0 is limited to
approved native PE applications. macOS is not an admitted Ghidra host. The
optimized AArch64 ELF byte-table fixture has a separate lane so its acceptance
does not depend on PE or Mach-O cross-linkers.
Type inspection selects one exact database pathname or typed data address.
Struct/union fields, enums, pointers, arrays, typedefs, size, alignment, packing
and bitfields retain database authority. Child types use exact IDs for further
inspection, including recursive layouts. Source/debug authority and flexible
array semantics are not inferred. The real lane uses a native DWARF 4 object
with a struct containing a union, enum and recursive pointer; a stripped linked
binary may have no corresponding recovered layout.
The native feature set is still partial. Objective-C/Swift metadata currently
uses symbol names rather than runtime sections, witness tables, vtables, or
relative pointers. UI action traces do not yet resolve Swift dispatch or
closure/thunk routes. Value dependencies remain intra-function, and the
AArch64 switch lane currently proves one optimized byte-indexed ELF layout;
sparse, stripped, Mach-O, and other table patterns remain unknown. The local
Xcode installation has no iOS simulator platform, so compiled storyboard
acceptance could not run here. These boundaries should stay visible until
matching provider-backed readers and fixtures exist.
## Bounded static traces
`trace_native_ui_action` / `rea trace-native-ui-action <target> <seed>` accepts
one authored selector/object ID, native symbol or exact function address. It
joins UI wiring to uniquely matched class/selector implementations and follows
bounded typed call references. Direct references, resolved indirect references,
ambiguous candidates, inferred untyped provider callees and targetless call
sites remain distinct. A static route does not establish runtime reachability.
Swift/closure dispatch requires resolved static references; unavailable ABI
forms remain unknown.
`trace_native_values` / `rea trace-native-values <binary> <procedure> --provider
ghidra` composes high-p-code def-use graphs across statically resolved calls.
Logical CALL arguments bind to recovered parameter ordinals; recovered RETURN
values bind to CALL outputs. These edges are decompiler-derived. Constants,
operators, LOAD/STORE and branch operands are included inline. Alias effects,
persistent state, RNG roles, missing/variadic bindings and ambiguous destinations
remain unknown. Budgets control depth, decompilations, call-site resolutions,
nodes and edges. Node payloads are bounded to 8 MiB and serialized results to
32 MiB. Pagination returns edges whose source is on the node page and preserves
stable IDs for endpoints outside that page.
The raw Java `is_dead` flag can remain set after Ghidra decodes a live block;
`block_membership` reports actual syntax-tree membership separately.
The provider dossier itself retains up to 3,000 p-code operations, 64 inputs per
operation and 12,000 def-use edges, with explicit omitted counts. A `STORE`
may describe stack memory and does not itself prove persistent state.
AArch64 jump-table recovery additionally verifies byte and halfword relative
forms from unsigned bounds, register definitions, table loads, branch bases,
scaled ADD/BR instructions and the recovered target set. It reads exactly the
proven count and preserves unknowns for other forms. Real ELF and host ARM64
Mach-O fixtures check each case against source-owned return values.
## Approved native desktop observation
`observe_native_ui` captures one explicit existing PID/window ID after
`observation_approved: true`. Screenshots use a selected-window ScreenCaptureKit
filter on macOS 14+; accessibility reads stay within that window. Missing Screen
Recording/Accessibility permissions produce actionable errors without broad
capture or automatic permission prompts. Executable bytes and process launch
time guard against a different target or PID reuse. AX selection requires one
unique geometry match; ambiguity fails closed.
`capture_native_ui_scenario` additionally requires `actions_approved: true` and
`restore: "leave-as-is"`. Steps select AX child-index paths for press, increment/
decrement scrolling and text-value entry, or bounded waits. No global event
injection is used. Unsupported AX actions fail explicitly. The result preserves
ordered before/after captures and gaps; an action may have occurred before a
post-action capture fails. The caller must choose whether to recover app state.
Scenarios allow 16 steps, 30 seconds of total waits, 2,000 AX nodes per capture,
8 MiB PNGs and 64 MiB output within a 180-second deadline. REA compiles one owned
helper per scenario, removes its temporary compiler cache and stops its helper
on cancellation. It does not launch or own the selected application. Approved
UI actions may change application data or trigger network activity.
## Provider and verification boundaries
Ghidra 12.1.4 with a full 64-bit JDK 21 is bring-your-own. Linux x64 and macOS
x64/arm64 are admitted; macOS requires the matching native decompiler. Windows
x64 P0 remains limited to approved native PE applications. There is no GUI or
mutation authority and no automatic fallback to Hopper.
- `npm run verify:ghidra`: host-native debug/stripped targets, native type layout,
instruction/call facts, value dependencies and process/project cleanup.
- `npm run verify:ghidra:aarch64-jump-table`: optimized ELF and byte/halfword
relative tables, plus ARM64 Mach-O on an ARM64 macOS host.
- `npm run verify:apple-dispatch`: source-built Objective-C protocols/classes and
Swift conformances/vtables, repeated after stripping local symbols.
- `npm run verify:native-ui`: one source-owned fixture window, selected capture
or a reported OS permission denial, changed-target rejection and cleanup.
macOS ARM64 is the real host verified during this implementation. Admission of
macOS Intel does not claim an Intel verification run. Unsupported metadata and
unresolved runtime/value semantics remain visible in results.
+39 -16
View File
@@ -10,12 +10,12 @@
"skill_version": "23"
},
"tools": {
"total": 114,
"total": 122,
"families": [
{
"id": "direct",
"surface": "official-proxy",
"count": 36,
"count": 39,
"tools": [
"address_name",
"address_to_file_offset",
@@ -25,6 +25,8 @@
"current_procedure",
"goto_address",
"inline_comment",
"inspect_native_data_type",
"inspect_native_instruction",
"list_bookmarks",
"list_documents",
"list_names",
@@ -43,6 +45,7 @@
"read_bytes",
"read_function_instructions",
"resolve_containing_procedure",
"resolve_native_call_targets",
"search_procedures",
"search_strings",
"set_address_name",
@@ -58,7 +61,7 @@
{
"id": "enhanced",
"surface": "enhanced",
"count": 14,
"count": 15,
"tools": [
"analyze_function",
"analyze_swift_types",
@@ -73,29 +76,34 @@
"inspect_native_dispatch_metadata",
"trace_call_path",
"trace_feature",
"trace_native_ui_action"
"trace_native_ui_action",
"trace_native_values"
]
},
{
"id": "native",
"surface": "native-provider",
"count": 5,
"count": 7,
"tools": [
"capture_native_ui_scenario",
"demangle_swift",
"inspect_macho",
"inspect_plist",
"inspect_signature",
"list_architectures"
"list_architectures",
"observe_native_ui"
]
},
{
"id": "artifact",
"surface": "artifact-provider",
"count": 3,
"count": 5,
"tools": [
"decode_interface_builder",
"extract_artifact",
"inspect_artifact"
"inspect_artifact",
"inspect_asset_catalog",
"inspect_keyed_archive"
]
},
{
@@ -206,6 +214,8 @@
"capabilities": [
"address_name",
"analyze_function",
"inspect_native_data_type",
"inspect_native_instruction",
"list_documents",
"list_names",
"list_procedures",
@@ -220,6 +230,7 @@
"procedure_references",
"read_function_instructions",
"resolve_containing_procedure",
"resolve_native_call_targets",
"search_procedures",
"search_strings",
"xrefs"
@@ -274,11 +285,13 @@
"name": "macOS native inspection utilities",
"version": null,
"capabilities": [
"capture_native_ui_scenario",
"demangle_swift",
"inspect_macho",
"inspect_plist",
"inspect_signature",
"list_architectures"
"list_architectures",
"observe_native_ui"
]
},
{
@@ -288,7 +301,9 @@
"capabilities": [
"decode_interface_builder",
"extract_artifact",
"inspect_artifact"
"inspect_artifact",
"inspect_asset_catalog",
"inspect_keyed_archive"
]
},
{
@@ -430,7 +445,7 @@
}
],
"cli": {
"primary_count": 67,
"primary_count": 75,
"commands": [
"analyze",
"analyze-javascript-application",
@@ -439,6 +454,7 @@
"capabilities",
"capture-browser-scenario",
"capture-electron-scenario",
"capture-native-ui-scenario",
"capture-process",
"capture-web-screenshot",
"compare",
@@ -464,13 +480,17 @@
"import-reference-source",
"inspect",
"inspect-artifact",
"inspect-asset-catalog",
"inspect-electron-page",
"inspect-keyed-archive",
"inspect-macho",
"inspect-managed-artifact",
"inspect-managed-members",
"inspect-managed-native-boundaries",
"inspect-native-api",
"inspect-native-data-type",
"inspect-native-dispatch-metadata",
"inspect-native-instruction",
"inspect-plist",
"inspect-signature",
"inspect-web-page",
@@ -480,6 +500,7 @@
"list-electron-targets",
"list-javascript-runtime-targets",
"observe-javascript-runtime",
"observe-native-ui",
"observe-web-session",
"plan-managed-runtime-correlation",
"policy",
@@ -487,6 +508,7 @@
"project-managed-application-graph",
"providers",
"reconcile-javascript-runtime",
"resolve-native-call-targets",
"run-controlled-replay",
"run-replay-machine",
"search",
@@ -495,6 +517,7 @@
"trace-application-feature",
"trace-javascript-semantics",
"trace-native-ui-action",
"trace-native-values",
"uninstall",
"upgrade",
"verify-managed-native-boundaries",
@@ -509,15 +532,15 @@
},
"runtime_catalog": {
"counts": {
"cli_commands": 67,
"mcp_tools": 114,
"cli_commands": 75,
"mcp_tools": 122,
"mcp_prompts": 6
},
"digests": {
"tools_sha256": "eb79567cc08f4afec223254c3977dc167d498f766b920077db0e1eead8be67b9",
"tools_sha256": "d3d6a83063a35a555ecf939e16383822c948081435ffc020bab23786515cf78d",
"prompts_sha256": "fa889fc671185ac9c968c6dc8309cf609e709cdc837dbbf39b7cdfadb84ac2d7",
"combined_sha256": "3e9ab4e05563ea2609b7cf4db67638a79cd8a74188c292718d99a63451d5b9db",
"providers_sha256": "03498d8adb7d941f8293eea8d71375871fb30a278e310fdc19b98df6781fc1a7"
"combined_sha256": "fd9ccf3ce95e870cf24abb98e8aca444ec3035de1f358765c5a4e2d1ea280c48",
"providers_sha256": "c5a88a49990fafc8244972ee8ff543fc19995eb79db960da55f6a311e15340d1"
}
}
}
+17 -18
View File
@@ -1,11 +1,10 @@
# Static-analysis provider evaluation
Status: the Ghidra read-only analysis provider is shipped on Linux x64 and has
an experimental Windows x64 P0 for approved native PE applications. macOS is
not an admitted Ghidra host. The provider
Status: the Ghidra read-only analysis provider is shipped on Linux x64 and macOS x64/arm64 with matching native tools, and has
an experimental Windows x64 P0 for approved native PE applications. The provider
validates an exact bring-your-own Ghidra 12.1.4/JDK 21 environment, resolves a
provider/version/profile commitment, runs one isolated read-only headless
import, and publishes 19 operation-level capabilities after the authenticated
import, and publishes 22 operation-level capabilities after the authenticated
post-analysis handshake.
The provider-neutral target, provider registry, deterministic target binding,
@@ -98,21 +97,21 @@ capability from a successful import.
## Admitted function-analysis semantics
| Concern | Ghidra contract |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Decompiler lifetime | One persistent `DecompInterface` is opened for the imported Program and disposed during bridge shutdown. Native decompilation has no fixed per-function deadline. External functions or functions without bodies return `null`; cancellation and native failure remain distinct. |
| Serialization | A FIFO sends one Program request at a time. Caller cancellation removes queued work promptly and is passed to active socket waits. This is an adapter safety commitment, not a claim that every Ghidra API is thread-safe. |
| Function identity | Every function result carries the entry address and Ghidra FunctionManager classification for external, thunk, and resolved thunk target. These are observations, not proof that unresolved targetless calls have been recovered. |
| Assembly and pseudocode | Assembly is complete Ghidra Listing text; pseudocode is Ghidra decompiler output. Neither is original source, and cross-provider comparison never treats Hopper and Ghidra text as equal or unequal semantic facts. |
| Instruction fast path | `read_function_instructions` returns every raw Listing instruction for the requested function without invoking the decompiler or whole-program name/string inventories. |
| Calls and references | Callers/callees contain only resolved functions. Reference edges preserve exact ReferenceManager type and call/jump/data/read/write/indirect/computed/conditional/terminal/external facts. Targetless computed flow remains unknown. Synthetic entry-point references without actionable memory sources are omitted explicitly. |
| CFG | Dossiers use `BasicBlockModel` and retain only non-call successors inside the function body. CFG topology is address-normalized for comparison; provider-specific block construction remains a declared difference. |
| P-code value flow | Function dossiers include up to 3,000 high-p-code operations, at most 64 inputs per operation, and 12,000 def-use edges. Results identify memory reads/writes, branches, and calls by p-code opcode, and mark decompiler-dead operations. Large results report truncation, an omitted-operation lower bound, and known omitted-input/edge counts. This is an intra-function decompiler graph: it does not resolve cross-function data flow, memory aliasing, call side effects, runtime behavior, persistent state, or business meaning. |
| Result extent | Function instruction scans and native API boundary observations remain complete. P-code flow is the explicit bounded exception; its count fields distinguish exact retained counts from lower-bound or known-omitted counts. Caller cancellation and provider failures remain distinct. |
| Concern | Ghidra contract |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Decompiler lifetime | One persistent `DecompInterface` is opened for the imported Program and disposed during bridge shutdown. Native decompilation has no fixed per-function deadline. External functions or functions without bodies return `null`; cancellation and native failure remain distinct. |
| Serialization | A FIFO sends one Program request at a time. Caller cancellation removes queued work promptly and is passed to active socket waits. This is an adapter safety commitment, not a claim that every Ghidra API is thread-safe. |
| Function identity | Every function result carries the entry address and Ghidra FunctionManager classification for external, thunk, and resolved thunk target. These are observations, not proof that unresolved targetless calls have been recovered. |
| Assembly and pseudocode | Assembly is complete Ghidra Listing text; pseudocode is Ghidra decompiler output. Neither is original source, and cross-provider comparison never treats Hopper and Ghidra text as equal or unequal semantic facts. |
| Instruction fast path | `read_function_instructions` returns every raw Listing instruction for the requested function without invoking the decompiler or whole-program name/string inventories. |
| Calls and references | Callers/callees contain only resolved functions. Reference edges preserve exact ReferenceManager type and call/jump/data/read/write/indirect/computed/conditional/terminal/external facts. Targetless computed flow remains unknown. Synthetic entry-point references without actionable memory sources are omitted explicitly. |
| CFG | Dossiers use `BasicBlockModel` and retain only non-call successors inside the function body. CFG topology is address-normalized for comparison; provider-specific block construction remains a declared difference. |
| P-code value flow | Function dossiers include up to 3,000 high-p-code operations, at most 64 inputs per operation, and 12,000 def-use edges. Results identify memory reads/writes, branches, and calls by p-code opcode, and mark decompiler-dead operations. Large results report truncation, an omitted-operation lower bound, and known omitted-input/edge counts. The dossier is an intra-function graph; `trace_native_values` composes derived parameter/argument and return/output dependencies across resolved calls. It does not resolve memory aliasing, call side effects, runtime behavior, persistent state, or business meaning. |
| Result extent | Function instruction scans and native API boundary observations remain complete. P-code flow is the explicit bounded exception; its count fields distinguish exact retained counts from lower-bound or known-omitted counts. Caller cancellation and provider failures remain distinct. |
`npm run verify:ghidra` compiles the versioned C oracle into debug and stripped
host-native targets (x86-64 ELF on Linux x64). It proves all
19 operations, external functions, resolved thunks, exports, stripped-name
host-native targets (x86-64 ELF on Linux x64 or Mach-O on macOS), plus a native
DWARF 4 type-layout object. It proves the admitted operations, external functions, resolved thunks, exports, stripped-name
behavior, direct and targetless indirect calls, typed references, strings/xrefs,
multi-block CFG, bounded p-code def-use/effect links, semantic enhanced workflows, cancellation, startup deadlines,
serialized concurrency, malformed-target rejection, profile identity, and
@@ -127,7 +126,7 @@ these tools before compilation. Keeping this matrix separate lets Linux
host/provider acceptance run with only the host compiler.
`npm run verify:ghidra:windows` generates a deterministic source-owned native
x86-64 PE application, proves all 19 operations through the production Windows
x86-64 PE application, proves the Windows P0 operations through the production Windows
launcher and loopback transport, checks target/snapshot/import SHA-256 linkage,
and requires project, endpoint, process, and runtime cleanup. Passing that lane
proves only the documented P0 boundary, not the remaining Windows security
+169
View File
@@ -0,0 +1,169 @@
# Test suite audit
## Scope and priority
The expanded audit on 2026-10-04 reviewed every one of the 355 Vitest files
present at its start: 82 domain/contracts, 56 application/composition, 147
adapter/configuration/process/filesystem/CLI files, and 70 MCP/acceptance/
conformance/evaluation files. Three review passes checked the proposed removals
against existing stronger coverage and checked the final changes for lost
assertions. The remaining suite has 351 files.
Prefer full real-provider E2E, then integration across schemas and transports,
then real-data goldens. Remove lower-level duplication once the replacement
asserts the same behavior. Keep distinct failure regressions that a successful
E2E cannot exercise. Do not lower coverage thresholds to accommodate pruning.
## Changes made
| Previous test or assertion | Decision | Replacement or retained claim |
| ----------------------------------------------------------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| Asset application tests using fabricated `.car` bytes and injected utility JSON | Remove two plumbing cases | `verify:asset-catalog`: real actool → assetutil → CLI and stdio MCP, including digest, full metadata, pagination and invalid input |
| Keyed archive positive binary/XML objects assembled with the same plist library as the reader | Remove two synthetic success cases | Captured Foundation XML plus reviewed complete object/reference graph golden; real Foundation binary/XML → CLI and stdio MCP |
| Generic contract schema/annotation/example loops and output-map inventory comparisons | Consolidate into one SDK wire test | Assert exported schemas, guidance, effect annotations and valid examples on the actual advertised MCP catalog |
| Ghidra function operation enum membership | Remove | Canonical catalog, provider handshake and actual operation roundtrips establish admission; malformed provider results remain covered |
| Asset page assertion pinning `10_001` | Remove | Invalid-page rejection through real MCP; malformed utility data still covered |
| Suggested prompt tool positions | Remove incidental ordering assertions | Keep authority, evidence, untrusted input and preparation constraints |
| Package “acceptance” assertions pinning CI matrix/shard values and fake launcher command arrays | Remove configuration-shaped cases | Actual package/install verifier remains; retain a compact release verification-before-publish and registry-canary static guard |
| Package failure classifier under acceptance | Move to package evaluation | Preserve all malformed/unrelated-failure rejection cases; do not call them E2E |
| Native value graph checks conditional on success | Strengthen | Require success before pagination/ambiguity assertions; unexpected errors can no longer pass |
| Native UI verifier treating permission denial as a positive pass | Separate outcomes | Default lane requires real capture/action success; opt-in permissions lane explicitly reports no positive E2E proof |
Ten previous test cases were removed or consolidated; three replacement
Vitest cases cover the captured goldens and release guard. The two real artifact
E2E scripts add product-level evidence outside the deterministic Vitest count.
## Exhaustive follow-up
The follow-up removes or consolidates another 31 test declarations, including
three redundant test files. Parameterized declarations and executed cases have
different counts; the figures above count source declarations. It also repairs
assertions that could pass without exercising their stated behavior.
| Area | Cleanup | Stronger retained evidence |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| Configuration | Merge duplicate target/loader tests and preserve custom launcher, explicit provider and actionable error diagnostics | Independent malformed inputs; each capability flag is rejected separately |
| Browser scenarios | Remove fake launch success and expression-selected artifact success | Real Chromium scenarios, actual artifacts, CLI capture and owned/external cleanup in `verify:browser` |
| CLI policy | Remove duplicate confirmation helper cases | Compiled CLI proves project files stay unchanged until `--yes`; distinct interactive choices remain |
| Provider capabilities | Remove numerical catalog freezes; consolidate Hopper byte-read discovery | Exact canonical capability identities, admission, mutation effects and detached descriptors |
| Evidence ledger | Merge reordered-key checks into serialized bundle import; remove one-record quota claims | Atomic import, conflicts, residual revisions, actual large collections and byte regressions |
| MCP batching and concurrency | Replace maximum concurrency and empty indistinguishable responses | Every distinct ordered batch result; concurrently gated requests complete in reverse order with matching payloads |
| MCP inventories and graphs | Remove traversal/inventory call order and redundant catalog/prose checks | Exact complete cyclic graph, full inline inventories, canonical SDK catalog and all-handler roundtrips |
| Authority and lifecycle | Attempt the refused Ghidra mutation; require successful setup, call and switch; close twice | Typed refusal with zero mutation dispatch, restored target calls and exact owned-resource cleanup |
| Process and provider output | Remove incidental environment read and command option order/defaults | PID/ownership validation, exact disabled-policy failure and complete decoded response above 10 MiB |
| Conformance and CI guards | Merge identical replay runs; remove duplicate missing-row fault, trivial hash/self equality and incidental worker/step counts | Distinct faulty evidence, digest order, actual test discovery, file existence and release/runner authority |
| Pure schemas | Remove fabricated comparison-dimension injection and impossible long boundary success | Real artifact comparison and SDK coverage evaluation with returned boundary identity |
The removed long boundary selector test only parsed a 1,000-character request;
workspace boundary identities admit at most 200 characters, so that positive
could never represent a successful evaluation. The actual MCP evaluation stays
covered. One unused ledger serialization helper was also retired after removing
its only test consumer; no CLI or MCP contract changed.
No further immediately safe pruning was identified in the final review. Retained
replacement candidates below are coverage gaps, rather than permission to delete
unique tests. Test-only domain scaffolding also stays pending a separate decision
about the corresponding source behavior; deleting those tests alone would leave
unverified code and fail unused-export checks.
## Stronger replacements from the closing pass
Three more synthetic success cases were removed. Native value composition now
runs through the production CLI and a separate stdio MCP subprocess with real
Ghidra, using a source-built native fixture. It checks argument/parameter/return
relationships, the fixture's global operand, authenticated Evidence, upstream
and workflow analysis profiles, identical CLI/MCP graphs, discovery availability
and successful MCP session closure. Ambiguity, cancellation, target changes,
missing flow and bounded-work regressions remain in the focused suite.
That replacement found missing CLI workflow routing for `trace_native_values`
and missing workflow profile classification. The routing and discovery now use
the composed operation's actual provider prerequisites; workflow Evidence commits
to the upstream Ghidra profile.
The lone hand-built client-document equality test is replaced by a real TOML
filesystem transaction: reversed environment keys leave the original bytes
unchanged and create no backup. A real ZIP entry with an application root longer
than 5,000 characters replaces injected schema-only positive data. Inventory
completeness tests partition actual scans into authenticated partial observations
and require exact assembled results and every source citation, including more
than 100 citations. These are consumer merge tests, not provider pagination
claims. Replay aggregation now covers all four statuses and retains actual runner
diagnostics; it does not claim to execute a real sandbox. Retained pixel-metric
fixtures now write correct PNG chunk checksums.
The all-zero screenshot buffer is replaced by a real Chromium PNG of 9,467,940
bytes, transported intact through CLI and stdio MCP and decoded by the production
pixel comparator. The verifier configures the SDK client's receive buffer because
its 10 MiB default is smaller than the inline JSON response. This does not claim
large comparison-request transport coverage. A real SDK initialize request,
captured during a successful production stdio handshake, replaces the JSON-RPC
decoder's field-count-only oracle with exact field/value expectations.
The closing pass also removed assertions tied to internal call order, defaults,
private cache sizes, duplicated polling and fabricated schema extensions. Unique
malformed-input, security-capacity and cleanup regressions remain.
## Real process regression found
The full suite exposed a renderer ordering race. Publishing a terminal output
journal entry could synchronously trigger a reactive resize before the preceding
output was queued for rendering. Queue that output first. The existing real
source-owned interactive PTY test now also requires the first resized frame to
contain the output that triggered the resize, preserving the original event
order rather than sorting or changing timestamps to conceal it.
## Coverage kept
Malformed archives, broken and cyclic references, unsupported metadata pointers,
relative Objective-C method layouts, ambiguous call candidates, digest mismatch,
cancellation, permission approval, bounded output and owned-resource cleanup
remain covered. These cases provide evidence beyond a successful real-tool run.
The hand-assembled relative method fixture stays until compiler-produced
relative-list fixtures establish equivalent assertions.
## Remaining priorities
1. Extend the real Ghidra CLI/stdio MCP journey from value tracing to dispatch
and UI handlers before pruning their hand-authored composition success cases.
Native value tracing now establishes full transport parity.
2. Real binary dispatch and nib → full product transports, with normalized
semantic goldens. The compiler-backed readers already run in the new Apple
CI lane; synthetic pointer/layout rejection tests remain useful.
3. An approved interactive macOS runner for native UI capture/actions, including
stdio MCP scenarios. Hosted runners without permissions cannot supply this
proof. No arbitrary application's windows should be opened for this lane.
4. Real macOS/Linux Ghidra CI runners with bring-your-own installations. This
change adds Apple artifact CI without installing Java or Ghidra.
5. Classify fake-runner replay/application acceptance accurately and replace
repeated happy paths only after real sandbox journeys assert the same claims.
The replay workflow's allow-unavailable mode must not imply executed replay.
6. Replace repeated-array schema ceiling tests with real producer/transport
regressions before deleting them. Existing integration tests do not yet
establish their large-input guarantees.
Some static guards protect irreversible release authority rather than product
behavior. Keep these few guards explicit and separate from E2E evidence.
## Verification after the expanded audit
- `npm run check:pr`: 351 files passed; 1,794 tests passed and two skipped.
Typecheck, lint, formatting, unused-code and generated-document gates passed.
- `npm run check:fast`: passed; 70 lint warnings and zero errors.
- `npm run verify:browser`: actual Chromium observation and scenario CLI routes,
retained artifacts, owned/external lifecycle checks and large CLI/stdio MCP PNG
capture passed.
- `npm run verify:ghidra`: macOS ARM64 with Ghidra 12.1.4/JDK 21 passed, including
real native value tracing through CLI and stdio MCP: 171 nodes, 298 edges and
six decompilations.
- `npm run verify:asset-catalog`: real actool/assetutil → CLI and stdio MCP,
complete records, pagination and malformed input checks passed.
- `npm run verify:keyed-archive`: real Foundation binary/XML → CLI and stdio MCP,
full graph golden, shared/cyclic identity and malformed scope checks passed.
- `npm run verify:fixtures`: seven source-built host fixtures, source/artifact
digests and exhaustive inventory checks passed.
No Hopper windows were opened for this audit. The closing pass runs real Ghidra
on macOS ARM64; earlier Hopper evidence remains separate. No new hosted CI success
is claimed.
+69 -9
View File
@@ -1,8 +1,15 @@
# Testing REA
REA's test suite follows one rule: use the lowest behavioral depth that can
prove a claim. Tests are grouped into native Vitest projects so ownership,
allowed dependencies, and runtime cost are visible from their paths.
Prefer evidence in this order: full end-to-end workflows with real production
providers, integration tests across data/API boundaries, then golden regressions
from real captured inputs. Keep focused module tests for distinct failure or
semantic cases that these workflows cannot reliably reproduce. A test's path
or suite name does not establish its behavioral depth.
Tests are grouped into Vitest projects so ownership, allowed dependencies and
runtime cost are visible from their paths. Before pruning a test, identify the
replacement scenario and its assertions; passing success journeys do not
replace malformed input, cancellation, permission or cleanup coverage.
## Behavioral depths
@@ -12,7 +19,7 @@ allowed dependencies, and runtime cost are visible from their paths.
| Composition | `tests/composition/**` | Provider-neutral session and registry wiring; filesystem access is limited to fixture materialization |
| Boundary | `tests/boundary/**` | Exactly one production filesystem, process, network, browser, CLI, or provider boundary |
| MCP boundary | `tests/boundary/mcp/**` | MCP transport and tool-contract boundaries with isolated sessions and explicit cleanup |
| Acceptance | `tests/acceptance/**` | A complete compiled CLI or MCP workflow |
| Acceptance | `tests/acceptance/**` | A compiled CLI or MCP journey; injected providers still make it integration |
| Conformance | `tests/conformance/**` | Shared provider contracts, parameterized by declared capabilities and explicit opt-outs |
| Evaluation | `tests/evaluation/**` | Deterministic evaluator parsing, scoring, and report generation |
@@ -43,6 +50,39 @@ claims belong to their explicit `npm run verify:*` lanes. They are not inferred
from mocks or folded into the deterministic local gate. Real model trials are
manual; Vitest covers only deterministic evaluator logic.
## End-to-end, integration and golden evidence
Full E2E tests invoke the production command dispatcher and real providers,
without fake launchers, runners or responses. `verify:keyed-archive` writes an
actual Foundation binary archive, runs the CLI and a separate stdio MCP
subprocess, checks parity and pagination, rejects an escaping path and checks
an XML graph golden. `verify:asset-catalog` compiles source-owned colors with
`actool`, invokes real `assetutil`, then checks CLI/MCP results, exact catalog
digest, every raw metadata field, pagination and malformed input rejection.
Neither artifact workflow requires Hopper or launches it. Both run in macOS CI.
MCP SDK transport tests with recording providers remain integration tests.
They are useful for schema drift and failure projection but do not prove that
Hopper, Ghidra or another substituted engine works. `verify:package` proves
packaging/install behavior and fake-provider integration; use the corresponding
real-provider lanes for engine claims. Real Apple dispatch and Interface
Builder verifiers currently prove format integration through production readers.
Golden tests use immutable captured text inputs with producer/source provenance
under `tests/fixtures/golden/`. Expected results are reviewed for the semantic
claim; capture commands do not automatically approve new expected outputs.
Do not call handcrafted utility output or synthetic binary builders real-data
goldens. Keep unsupported binary layouts and malformed boundaries as targeted
regressions until a real fixture establishes equivalent coverage.
`verify:browser` also captures a source-owned noise canvas as a real PNG above
8 MiB through the CLI and stdio MCP, with complete byte/digest parity and real PNG
decoding. Its SDK client explicitly permits the larger inline JSON response;
this lane does not establish large image-comparison request transport coverage.
The [test suite audit](test-suite-audit.md) records the pruning decisions,
replacement evidence and remaining priorities.
## Real-toolchain verification lanes
Each real-toolchain command must require only the host tools needed to prove
@@ -54,11 +94,16 @@ that a host or target is covered when it was skipped.
| Ghidra lane | Supported runner/target | Additional local tools |
| ------------------------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| `npm run verify:ghidra` | Linux x64 with x86-64 ELF | Host C compiler, Ghidra 12.1.4, and full JDK 21 |
| `npm run verify:ghidra:aarch64-jump-table` | Any supported Ghidra host; AArch64 Linux ELF relocatable fixture | Clang with AArch64 target support, Ghidra 12.1.4, and full JDK 21 |
| `npm run verify:ghidra` | Linux x64 ELF or macOS x64/arm64 Mach-O | Host C compiler, Ghidra 12.1.4, and full JDK 21 |
| `npm run verify:ghidra:aarch64-jump-table` | Any supported Ghidra host; AArch64 ELF; byte/halfword tables; host ARM64 Mach-O | Clang with AArch64 target support, Ghidra 12.1.4, and full JDK 21 |
| `npm run verify:ghidra:cross-format` | Any supported Ghidra host; also analyzes AArch64 ELF, x86-64 PE, and x86-64 Mach-O | `clang`, LLD, and `lld-link` in addition to host-lane prerequisites |
| `npm run verify:ghidra:windows` | Controlled Windows x64 with native x86-64 PE | Ghidra 12.1.4, full JDK 21, and the Windows P0 fixture toolchain |
The host-native Ghidra lane also verifies native value tracing through the
production CLI and a separate stdio MCP process. It compares complete dependency
graphs, validates Evidence and upstream/workflow profiles, checks capability
discovery, and closes the MCP session. No provider or transport is mocked.
The cross-format Ghidra lane also analyzes an optimized AArch64 ELF switch
fixture. It checks the recovered case values against the source cases and
requires unresolved table bounds or case mappings to remain visible as
@@ -98,9 +143,10 @@ miss behavior connected through runtime registration, generated data, shell
entrypoints, or other relationships that are absent from the import graph.
Use `npm run check:pr` before handing off a contribution.
Local full-suite Vitest runs are intentionally capped at one worker and one
project at a time. Boundary fixtures own real subprocesses, and this local cap
keeps aggregate memory predictable; CI retains the existing two-worker budget.
Local full-suite Vitest runs use up to two workers and schedule projects one
at a time. Process, acceptance and process-global projects serialize their
files to prevent competing lifecycle observations. CI retains its two-worker
budget.
The pure domain/contracts and recording-port service projects share one worker
module context because their tests own no mutable runtime resources. MCP
boundary files also share the immutable server module graph while creating and
@@ -146,3 +192,17 @@ minutes across three warm-build runs on the benchmark host. Keep Vitest caches
cold unless separately identified. A PR that touches packaging or real-system
behavior also requires `npm run verify:package` and the applicable
`verify:*` lanes.
## Apple native metadata and UI
`npm run verify:apple-dispatch` compiles Objective-C class/protocol and Swift
conformance/vtable fixtures, inspects their bytes and repeats after stripping
local symbols. It requires macOS and the host Xcode toolchain; targets are not
executed. `npm run verify:native-ui` launches exactly one source-owned fixture
window and requires successful selected-window capture and approved actions.
Permission denial fails the positive lane. `npm run verify:native-ui:permissions`
allows a permission-boundary-only result and explicitly reports
`positive_e2e: false`; it must not be reported as capture/action proof.
Both commands reject a changed executable digest and clean up the fixture
process and helper. These lanes require an interactive macOS desktop. See [native investigation](native-investigation.md)
for the exact ABI, authority, graph and observation boundaries.
@@ -1,5 +1,5 @@
{
"manifest_id": "ecm_26db97ee1f42255040febd543bb2f3feac773f48d5ef38bd6651cd8770b36013",
"manifest_id": "ecm_618953f9b8c54b9ee0bee16be32808f286d5ad978769b6ce3975b863ed312078",
"verifier": {
"id": "rea-managed-conformance",
"version": "1"
@@ -13,7 +13,7 @@
"skill_digests": [
{
"skill_id": "reverse-engineer-anything",
"sha256": "28122d8fd448db430249431aedfa9d3d865da8a323b703631fec5c44041d9d77"
"sha256": "1a60aa6554a323eaf600370916889c05b5daa8a7b5e4195e9055118bb5985212"
}
],
"claims": [
+7 -1
View File
@@ -42,6 +42,7 @@
"dist",
"install.sh",
"bridge/hopper_bridge.py",
"bridge/native/ReaNativeUI.swift",
"bridge/ghidra/ReaGhidraBridge.java",
"scripts/rea.mjs",
"scripts/electron-active-hook.cjs",
@@ -116,6 +117,9 @@
"typecheck": "tsc --noEmit",
"verify:test-temp-hygiene": "npm run build:cached && node scripts/verify-test-temp-hygiene.mjs",
"verify:hopper": "npm run verify:fixtures && npm run build:cached && node scripts/verify-real-hopper.mjs",
"verify:apple-dispatch": "npm run build:cached && node scripts/verify-apple-dispatch.mjs",
"verify:native-ui": "npm run build:cached && node scripts/verify-native-ui.mjs",
"verify:keyed-archive": "npm run build:cached && node scripts/verify-keyed-archive.mjs",
"verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs",
"verify:ghidra:aarch64-jump-table": "npm run build:cached && node scripts/verify-real-ghidra.mjs --aarch64-jump-table",
"verify:interface-builder": "npm run build:cached && node scripts/verify-interface-builder.mjs",
@@ -150,7 +154,9 @@
"docs:check:built": "node scripts/generate-package-metadata.mjs --check && node scripts/generate-mcp-tool-catalog.mjs --check && node scripts/generate-skill-metadata.mjs --check && node scripts/generate-product-catalog.mjs --check && node scripts/generate-completion-ledger.mjs --check && node scripts/check-doc-facts.mjs && node scripts/generate-error-schema.mjs --check",
"docs:error-schema": "npm run build:cached && node scripts/generate-error-schema.mjs",
"prepack": "node scripts/prepack.mjs",
"prepare": "node scripts/prepare.mjs"
"prepare": "node scripts/prepare.mjs",
"verify:asset-catalog": "npm run build:cached && node scripts/verify-asset-catalog.mjs",
"verify:native-ui:permissions": "npm run build:cached && node scripts/verify-native-ui.mjs --allow-permission-denial"
},
"lint-staged": {
"*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}": [
@@ -7,6 +7,21 @@ export async function startBrowserVerifierSite() {
let port = 0;
let sessionGeneration = 0;
const server = createServer((request, response) => {
if (request.url === "/screenshot-noise") {
response.setHeader("content-type", "text/html");
response.end(`<!doctype html><html><body style="margin:0"><canvas width="2048" height="1536"></canvas><script>
const context = document.querySelector('canvas').getContext('2d');
const image = context.createImageData(2048, 1536);
let state = 0x6d2b79f5;
for (let index = 0; index < image.data.length; index++) {
state ^= state << 13; state ^= state >>> 17; state ^= state << 5;
image.data[index] = index % 4 === 3 ? 255 : state & 255;
}
context.putImageData(image, 0, 0);
document.body.dataset.ready = 'true';
</script></body></html>`);
return;
}
if (request.url === "/app.js.map") {
response.setHeader("content-type", "application/source-map+json");
response.end(sourceMap());
+1 -1
View File
@@ -78,7 +78,7 @@ async function sdkToolCatalog() {
}
}
const auxiliaryProviders = [
new ArtifactProvider(false, false),
new ArtifactProvider(false, false, CATALOG_PLATFORM),
new NativeMacOSProvider(undefined, CATALOG_PLATFORM),
new ManagedStaticProvider(),
].map((provider) => ({
+64
View File
@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
const environment = () => ({
PATH: process.env.PATH ?? "/usr/bin:/bin",
REA_LOG_LEVEL: "silent",
REA_ANALYSIS_PROVIDER: "auto",
// Artifact workflows have no deep-provider prerequisite or GUI authority.
HOPPER_LAUNCHER_PATH: "/rea-unconfigured-deep-provider/hopper",
});
/** Execute the compiled CLI with production providers and no test adapters. */
export async function artifactCli(command, target, arguments_ = []) {
const { stdout } = await promisify(execFile)(
process.execPath,
[
fileURLToPath(new URL("../rea.mjs", import.meta.url)),
command,
target,
...arguments_,
"--json",
],
{ env: environment(), timeout: 60000, maxBuffer: 16 * 1024 * 1024 },
);
const evidence = JSON.parse(stdout);
assert.equal(evidence.error, undefined, stdout);
assert.ok(evidence.normalized_result, "CLI omitted normalized result");
return evidence.normalized_result;
}
/** Run a real stdio MCP subprocess, owning and closing its transport. */
export async function withArtifactMcp(target, verify) {
const transport = new StdioClientTransport({
command: process.execPath,
args: [fileURLToPath(new URL("../rea.mjs", import.meta.url)), "mcp"],
env: environment(),
stderr: "pipe",
});
const client = new Client({ name: "artifact-real-e2e", version: "1" });
try {
await client.connect(transport);
const opened = await client.callTool({
name: "open_binary",
arguments: { path: target },
});
assert.notEqual(opened.isError, true, JSON.stringify(opened));
await verify(client);
} finally {
await client.close();
await transport.close();
}
}
/** Require a successful structured MCP tool result rather than its text rendering. */
export async function artifactMcpResult(client, name, arguments_ = {}) {
const result = await client.callTool({ name, arguments: arguments_ });
assert.notEqual(result.isError, true, JSON.stringify(result));
assert.ok(result.structuredContent?.result, "MCP omitted structured result");
return result.structuredContent.result;
}
+115
View File
@@ -0,0 +1,115 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright-core";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { parseEvidence } from "../../dist/domain/evidence.js";
import { webScreenshotSchema } from "../../dist/domain/webScreenshot.js";
import { comparePngScreenshots } from "../../dist/browser/PngVisualDiff.js";
/** Verify a real, large PNG through production CLI and stdio MCP boundaries. */
export async function verifyLargeScreenshotE2e(endpoint, origin) {
const browser = await chromium.connectOverCDP(endpoint);
const context = await browser.newContext({
viewport: { width: 2048, height: 1536 },
deviceScaleFactor: 1,
});
const entrypoint = fileURLToPath(new URL("../rea.mjs", import.meta.url));
const env = {
PATH: process.env.PATH ?? "/usr/bin:/bin",
REA_LOG_LEVEL: "silent",
HOPPER_LAUNCHER_PATH: "/rea-unconfigured-deep-provider/hopper",
REA_BROWSER_OBSERVE_ENABLED: "true",
REA_BROWSER_CDP_ENDPOINTS_JSON: JSON.stringify([endpoint]),
REA_BROWSER_ALLOWED_ORIGINS_JSON: JSON.stringify([origin]),
};
let client;
let transport;
try {
const page = await context.newPage();
await page.goto(`${origin}/screenshot-noise`);
await page.waitForFunction(() => document.body.dataset.ready === "true");
const targets = await (await fetch(`${endpoint}/json/list`)).json();
const target = targets.find((value) => value.url === page.url());
assert.ok(
target,
"Owned screenshot page is absent from actual CDP discovery",
);
const { stdout } = await promisify(execFile)(
process.execPath,
[entrypoint, "capture-web-screenshot", endpoint, target.id, "--json"],
{ env, timeout: 60000, maxBuffer: 72 * 1024 * 1024 },
);
const cliEvidence = parseEvidence(JSON.parse(stdout));
assert.equal(cliEvidence.operation, "capture_web_screenshot");
const screenshot = webScreenshotSchema.parse(cliEvidence.normalized_result);
assert.ok(
screenshot.artifact.bytes > 8 * 1024 * 1024,
"Real PNG did not cross the former screenshot byte ceiling",
);
const bytes = Buffer.from(screenshot.artifact.data_base64, "base64");
assert.equal(bytes.byteLength, screenshot.artifact.bytes);
assert.deepEqual(
bytes.subarray(0, 8),
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
);
assert.equal(bytes.readUInt32BE(16), 2048);
assert.equal(bytes.readUInt32BE(20), 1536);
transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env,
stderr: "pipe",
// The SDK defaults to a 10 MiB receive buffer; inline PNG output is larger.
maxBufferSize: 64 * 1024 * 1024,
});
client = new Client({ name: "browser-screenshot-real-e2e", version: "1" });
await client.connect(transport);
const captured = await client.callTool(
{
name: "capture_web_screenshot",
arguments: {
cdp_endpoint: endpoint,
allowed_origins: [origin],
target_id: target.id,
},
},
{ timeout: 60000 },
);
assert.notEqual(captured.isError, true, JSON.stringify(captured));
const mcpEvidence = parseEvidence({
...captured.structuredContent?.evidence,
normalized_result: captured.structuredContent?.result,
});
const mcpScreenshot = webScreenshotSchema.parse(
mcpEvidence.normalized_result,
);
assert.deepEqual(mcpScreenshot.artifact, screenshot.artifact);
const compared = comparePngScreenshots({
before: screenshot.artifact,
after: mcpScreenshot.artifact,
channel_threshold: 0,
});
assert.equal(compared.status, "identical");
assert.equal(compared.changed_pixels, 0);
return {
mocked: false,
cli: true,
stdio_mcp: true,
png_bytes: bytes.byteLength,
};
} finally {
try {
if (client !== undefined) await client.close();
} finally {
try {
if (transport !== undefined) await transport.close();
} finally {
await context.close();
await browser.close();
}
}
}
}
+183
View File
@@ -0,0 +1,183 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { parseEvidence } from "../../dist/domain/evidence.js";
import { analysisProfileSchema } from "../../dist/domain/analysisProfile.js";
import { nativeValueTraceSchema } from "../../dist/domain/nativeValueTrace.js";
const entrypoint = fileURLToPath(new URL("../rea.mjs", import.meta.url));
const environment = () => ({
PATH: process.env.PATH ?? "/usr/bin:/bin",
REA_LOG_LEVEL: "silent",
REA_ANALYSIS_PROVIDER: "ghidra",
HOPPER_LAUNCHER_PATH: "/rea-unconfigured-deep-provider/hopper",
...(process.env.GHIDRA_INSTALL_DIR === undefined
? {}
: { GHIDRA_INSTALL_DIR: process.env.GHIDRA_INSTALL_DIR }),
...(process.env.JAVA_HOME === undefined
? {}
: { JAVA_HOME: process.env.JAVA_HOME }),
});
const verifyGraph = (input, target, procedure, globalAddress) => {
const graph = nativeValueTraceSchema.parse(input);
assert.equal(graph.target_sha256, target.sha256);
assert.equal(graph.seed, procedure);
const nodes = new Map(graph.nodes.map((node) => [node.id, node]));
for (const kind of ["argument-binding", "parameter-use", "return-binding"]) {
const edges = graph.edges.filter((edge) => edge.kind === kind);
assert.ok(edges.length > 0, `Real native dependency graph lacks ${kind}`);
for (const edge of edges) {
const source = nodes.get(edge.source);
const destination = nodes.get(edge.target);
assert.ok(source, `Missing source node ${edge.source}`);
assert.ok(destination, `Missing target node ${edge.target}`);
assert.equal(edge.status, "derived");
if (kind === "argument-binding") {
assert.match(source.operation?.opcode ?? "", /^CALL(?:IND)?$/u);
assert.equal(destination.kind, "parameter");
assert.equal(edge.input_index, destination.parameter.ordinal + 1);
} else if (kind === "parameter-use") {
assert.equal(source.kind, "parameter");
assert.ok(destination.operation.inputs[edge.input_index]);
} else {
assert.equal(source.operation?.opcode, "RETURN");
assert.match(destination.operation?.opcode ?? "", /^CALL(?:IND)?$/u);
assert.equal(edge.input_index, 1);
}
}
}
assert.ok(
graph.nodes.some((node) =>
node.operation?.inputs.some((value) => value.location === globalAddress),
),
"Source-owned global operand is absent from the real dependency graph",
);
for (const node of graph.nodes)
assert.match(node.evidence_id, /^ev_[a-f0-9]{64}$/u);
for (const procedure of graph.procedures) {
assert.equal(procedure.provider, "ghidra");
assert.match(procedure.analysis_profile_digest, /^[a-f0-9]{64}$/u);
}
return graph;
};
/** Verify real Ghidra dependency composition through production CLI and stdio MCP. */
export async function verifyNativeValueE2e(target, procedure, globalAddress) {
const { stdout } = await promisify(execFile)(
process.execPath,
[
entrypoint,
"trace-native-values",
target.path,
procedure,
"--provider",
"ghidra",
"--max-depth",
"2",
"--max-functions",
"8",
"--limit",
"5000",
"--json",
],
{ env: environment(), timeout: 240000, maxBuffer: 72 * 1024 * 1024 },
);
const evidence = parseEvidence(JSON.parse(stdout));
assert.equal(evidence.operation, "trace_native_values");
assert.equal(evidence.provider.id, "rea-workflow");
const workflowProfile = analysisProfileSchema.parse(
evidence.analysis_profile,
);
assert.equal(workflowProfile.provider.id, "rea-workflow");
const upstreamProfile = analysisProfileSchema.parse(
workflowProfile.parameters.upstream_analysis_profile,
);
assert.equal(upstreamProfile.provider.id, "ghidra");
const cliGraph = verifyGraph(
evidence.normalized_result,
target,
procedure,
globalAddress,
);
for (const procedure of cliGraph.procedures)
assert.equal(procedure.analysis_profile_digest, upstreamProfile.digest);
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env: environment(),
stderr: "pipe",
});
const client = new Client({ name: "native-values-real-e2e", version: "1" });
try {
await client.connect(transport);
const opened = await client.callTool(
{
name: "open_binary",
arguments: { path: target.path, provider: "ghidra" },
},
{ timeout: 180000 },
);
assert.notEqual(opened.isError, true, JSON.stringify(opened));
const inventory = await client.callTool({
name: "binary_session",
arguments: {},
});
assert.notEqual(inventory.isError, true, JSON.stringify(inventory));
assert.ok(
inventory.structuredContent?.result?.tool_availability.some(
(tool) => tool.name === "trace_native_values" && tool.available,
),
"Native value tracing is unavailable in real provider discovery",
);
const result = await client.callTool(
{
name: "trace_native_values",
arguments: { procedure, max_depth: 2, max_functions: 8, limit: 5000 },
},
{ timeout: 180000 },
);
assert.notEqual(result.isError, true, JSON.stringify(result));
const mcpEvidence = parseEvidence({
...result.structuredContent?.evidence,
normalized_result: result.structuredContent?.result,
});
assert.equal(mcpEvidence.operation, "trace_native_values");
assert.equal(mcpEvidence.provider.id, "rea-workflow");
assert.deepEqual(mcpEvidence.analysis_profile, workflowProfile);
const mcpGraph = verifyGraph(
result.structuredContent?.result,
target,
procedure,
globalAddress,
);
assert.deepEqual(mcpEvidence.normalized_result, mcpGraph);
assert.deepEqual(mcpGraph, cliGraph);
const closed = await client.callTool({
name: "close_binary",
arguments: {},
});
assert.notEqual(closed.isError, true, JSON.stringify(closed));
const status = await client.callTool({
name: "binary_session",
arguments: {},
});
assert.equal(status.structuredContent?.result?.open, false);
} finally {
await client.close();
await transport.close();
}
return {
mocked: false,
cli: true,
stdio_mcp: true,
nodes: cliGraph.total_nodes,
edges: cliGraph.total_edges,
decompilations: cliGraph.decompilations,
unknowns: cliGraph.unknowns.length,
};
}
+8 -13
View File
@@ -31,23 +31,21 @@ export const verifyHopperFunctionBasics = async (
arguments: {
procedure,
direction: "outgoing",
limit: 10,
max_instructions: 100,
},
},
options,
),
"procedure_references",
);
if (!Array.isArray(references?.references?.items)) {
throw new Error("procedure_references returned an invalid bounded result");
if (!Array.isArray(references?.references)) {
throw new Error("procedure_references returned an invalid result");
}
const instructions = requireMcpResult(
await client.callTool(
{
name: "read_function_instructions",
arguments: { procedure, limit: 2 },
arguments: { procedure },
},
options,
),
@@ -55,17 +53,14 @@ export const verifyHopperFunctionBasics = async (
);
if (
instructions?.procedure?.address !== procedure ||
!Array.isArray(instructions.instructions?.items) ||
instructions.instructions.items.length === 0 ||
instructions.instructions.items.length > 2
!Array.isArray(instructions.instructions) ||
instructions.instructions.length === 0
) {
throw new Error(
"read_function_instructions returned an invalid bounded window",
);
throw new Error("read_function_instructions returned an invalid result");
}
return {
outgoingReferenceCount: references.references.items.length,
instructionWindowCount: instructions.instructions.items.length,
outgoingReferenceCount: references.references.length,
instructionWindowCount: instructions.instructions.length,
};
};
+133
View File
@@ -0,0 +1,133 @@
import { execFile } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
import { inspectAppleDispatchMetadata } from "../dist/native/AppleDispatchMetadata.js";
if (process.platform !== "darwin")
throw new Error(
"Apple dispatch verification requires macOS with a host Objective-C compiler and Foundation SDK",
);
const exec = promisify(execFile);
const root = await mkdtemp(join(tmpdir(), "rea-dispatch-"));
try {
const targetPath = join(root, "fixture");
await exec("/usr/bin/xcrun", [
"clang",
"-O2",
"-framework",
"Foundation",
"-Wl,-no_fixup_chains",
fileURLToPath(
new URL("../tests/conformance/native/dispatch.m", import.meta.url),
),
"-o",
targetPath,
]);
const results = [];
for (const variant of ["symbols", "stripped"]) {
if (variant === "stripped")
await exec("/usr/bin/strip", ["-x", targetPath]);
const target = await parseBinaryTarget(targetPath);
if (!target.ok) throw target.error;
const observation = await inspectAppleDispatchMetadata(target.value, 5000);
const metadata = observation.result;
if (
!metadata.objc_dispatch_implementations.some(
(item) =>
item.class_name === "ReaDispatchFixture" &&
item.selector === "performAction:" &&
item.implementation_address !== null,
) ||
!metadata.objc_dispatch_implementations.some(
(item) =>
item.selector === "fixtureVersion" && item.method_type === "class",
) ||
!metadata.objc_ivars.some(
(item) => item.name === "state" && item.offset !== null,
) ||
!metadata.objc_protocol_records.some(
(protocol) =>
protocol.name === "ReaDispatchProtocol" &&
protocol.methods.some(
(method) => method.selector === "performAction:",
) &&
protocol.optional_methods.some(
(method) => method.selector === "optionalFixtureValue",
),
)
)
throw new Error(
`Apple metadata fixture drifted: ${JSON.stringify(observation)}`,
);
results.push({
variant,
target_sha256: target.value.sha256,
implementations: metadata.objc_dispatch_implementations.length,
ivars: metadata.objc_ivars.length,
coverage: metadata.coverage,
});
}
const swiftPath = join(root, "libReaWitnessFixture.dylib");
await exec("/usr/bin/xcrun", [
"swiftc",
"-emit-library",
"-O",
"-Xlinker",
"-no_fixup_chains",
fileURLToPath(
new URL("../tests/conformance/native/dispatch.swift", import.meta.url),
),
"-o",
swiftPath,
]);
for (const variant of ["swift-symbols", "swift-stripped"]) {
if (variant === "swift-stripped")
await exec("/usr/bin/strip", ["-x", swiftPath]);
const target = await parseBinaryTarget(swiftPath);
if (!target.ok) throw target.error;
const observation = await inspectAppleDispatchMetadata(target.value, 5000);
const metadata = observation.result;
if (
!metadata.swift_dispatch_slots.some(
(slot) =>
slot.table_kind === "class_vtable" &&
slot.owner === "ReaVtableFixture" &&
slot.decode.status === "decoded",
)
)
throw new Error(
`Swift class vtable fixture unavailable: ${JSON.stringify(metadata.coverage)}`,
);
if (
!metadata.swift_conformances.some(
(item) =>
item.type_name === "ReaWitnessFixture" &&
item.protocol_name === "ReaWitnessFixtureProtocol",
) ||
!metadata.swift_dispatch_slots.some(
(item) =>
item.owner === "ReaWitnessFixture: ReaWitnessFixtureProtocol" &&
item.implementation_address !== null,
)
)
throw new Error(
`Swift metadata fixture drifted: ${JSON.stringify(observation)}`,
);
results.push({
variant,
target_sha256: target.value.sha256,
conformances: metadata.swift_conformances.length,
slots: metadata.swift_dispatch_slots.length,
coverage: metadata.coverage,
});
}
process.stdout.write(
`${JSON.stringify({ ok: true, target_executed: false, fixtures: results })}\n`,
);
} finally {
await rm(root, { recursive: true, force: true });
}
+103
View File
@@ -0,0 +1,103 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import {
artifactCli,
artifactMcpResult,
withArtifactMcp,
} from "./lib/artifact-e2e.mjs";
if (process.platform !== "darwin")
throw new Error(
"Asset catalog E2E requires macOS, Xcode actool and assetutil",
);
const exec = promisify(execFile);
await exec("/usr/bin/xcrun", ["--find", "actool"]);
await exec("/usr/bin/xcrun", ["--find", "clang"]);
await exec("/usr/bin/assetutil", ["--version"]);
const root = await mkdtemp(join(tmpdir(), "rea-asset-e2e-"));
try {
const app = join(root, "Fixture.app");
const resources = join(app, "Contents", "Resources");
await mkdir(resources, { recursive: true });
await mkdir(join(app, "Contents", "MacOS"), { recursive: true });
await exec("/usr/bin/xcrun", [
"clang",
fileURLToPath(
new URL(
"../tests/conformance/interface-builder/fixture.c",
import.meta.url,
),
),
"-o",
join(app, "Contents", "MacOS", "Fixture"),
]);
await writeFile(
join(app, "Contents", "Info.plist"),
'<?xml version="1.0"?><plist version="1.0"><dict><key>CFBundleExecutable</key><string>Fixture</string><key>CFBundleIdentifier</key><string>dev.rea.asset-fixture</string></dict></plist>',
);
await exec("/usr/bin/xcrun", [
"actool",
fileURLToPath(
new URL("../tests/conformance/native/Assets.xcassets", import.meta.url),
),
"--compile",
resources,
"--platform",
"macosx",
"--minimum-deployment-target",
"12.0",
]);
const raw = JSON.parse(
(
await exec("/usr/bin/assetutil", [
"--info",
join(resources, "Assets.car"),
])
).stdout,
);
const sha256 = createHash("sha256")
.update(await readFile(join(resources, "Assets.car")))
.digest("hex");
const cli = await artifactCli("inspect-asset-catalog", app);
assert.deepEqual(
cli.records.map(({ metadata }) => metadata),
raw,
);
assert.equal(cli.catalogs[0].sha256, sha256);
assert.ok(
cli.records.some(
({ asset_name, metadata }) =>
asset_name === "FixtureColor" && metadata.AssetType === "Color",
),
);
await withArtifactMcp(app, async (client) => {
assert.deepEqual(
await artifactMcpResult(client, "inspect_asset_catalog"),
cli,
);
const first = await artifactMcpResult(client, "inspect_asset_catalog", {
limit: 1,
});
assert.equal(first.next_offset, 1);
const next = await artifactMcpResult(client, "inspect_asset_catalog", {
offset: first.next_offset,
});
assert.deepEqual([...first.records, ...next.records], cli.records);
const invalid = await client.callTool({
name: "inspect_asset_catalog",
arguments: { offset: -1 },
});
assert.equal(invalid.isError, true);
});
process.stdout.write(
`${JSON.stringify({ ok: true, mocked: false, cli: true, stdio_mcp: true, records: raw.length, pagination: true, malformed_input_rejected: true })}\n`,
);
} finally {
await rm(root, { recursive: true, force: true });
}
+125
View File
@@ -0,0 +1,125 @@
import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import {
artifactCli,
artifactMcpResult,
withArtifactMcp,
} from "./lib/artifact-e2e.mjs";
if (process.platform !== "darwin")
throw new Error(
"Real Foundation keyed archive verification requires macOS and Xcode Swift tools",
);
const root = await mkdtemp(join(tmpdir(), "rea-keyed-fixture-"));
try {
const archive = join(root, "model.plist");
await promisify(execFile)("/usr/bin/xcrun", [
"swift",
"-module-cache-path",
join(root, "modules"),
fileURLToPath(
new URL(
"../tests/conformance/native/keyed-archive.swift",
import.meta.url,
),
),
archive,
]);
const graph = await artifactCli("inspect-keyed-archive", archive);
const digest = createHash("sha256")
.update(await readFile(archive))
.digest("hex");
assert.equal(graph.archive_sha256, digest);
assert.equal(graph.target_sha256, digest);
await withArtifactMcp(archive, async (client) => {
assert.deepEqual(
await artifactMcpResult(client, "inspect_keyed_archive"),
graph,
);
const page = await artifactMcpResult(client, "inspect_keyed_archive", {
offset: 1,
limit: 1,
});
assert.deepEqual(page.objects, [graph.objects[1]]);
assert.equal(page.next_offset, 2);
const invalid = await client.callTool({
name: "inspect_keyed_archive",
arguments: { path: "../other.plist" },
});
assert.equal(invalid.isError, true);
});
const xml = join(root, "model.xml.plist");
await promisify(execFile)("/usr/bin/plutil", [
"-convert",
"xml1",
"-o",
xml,
archive,
]);
const xmlGraph = await artifactCli("inspect-keyed-archive", xml);
const expected = JSON.parse(
await readFile(
new URL(
"../tests/fixtures/golden/keyed-archive/graph.json",
import.meta.url,
),
"utf8",
),
);
assert.deepEqual(
Object.fromEntries(
Object.entries(xmlGraph).filter(
([key]) =>
!["archive_path", "archive_sha256", "target_sha256"].includes(key),
),
),
expected,
);
assert.deepEqual(
graph.references.map(({ source, path, target, status }) => ({
source,
path,
target,
status,
})),
xmlGraph.references.map(({ source, path, target, status }) => ({
source,
path,
target,
status,
})),
);
if (
graph?.archive_format !== "binary-plist" ||
!graph.references.some(
(link) =>
link.source !== null &&
link.source === link.target &&
link.status === "resolved",
) ||
!graph.objects.some((item) => item.class_name?.includes("ReaArchiveRecord"))
)
throw new Error(
`Real Foundation archive graph drifted: ${JSON.stringify(graph)}`,
);
const record = graph.objects.find((item) =>
item.class_name?.includes("ReaArchiveRecord"),
);
if (
graph.references.filter(
(link) => link.target === record.id && link.source !== record.id,
).length < 2
)
throw new Error("Real shared object identity was not preserved");
process.stdout.write(
`${JSON.stringify({ ok: true, mocked: false, cli: true, stdio_mcp: true, xml_golden: true, format: graph.archive_format, objects: graph.total_objects, references: graph.total_references, shared_identity: true, cyclic_identity: true, target_classes_instantiated_by_reader: false })}\n`,
);
} finally {
await rm(root, { recursive: true, force: true });
}
+166
View File
@@ -0,0 +1,166 @@
import { execFile, spawn } from "node:child_process";
import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
import { observeNativeUi } from "../dist/native/NativeUiObservation.js";
if (process.platform !== "darwin")
throw new Error(
"Native UI verification requires macOS, an interactive desktop and Xcode command-line tools",
);
const root = await mkdtemp(join(tmpdir(), "rea-ui-fixture-"));
let child;
let fixturePid;
try {
const contents = join(root, "Fixture.app", "Contents");
await mkdir(join(contents, "MacOS"), { recursive: true });
await writeFile(
join(contents, "Info.plist"),
`<?xml version="1.0"?><plist version="1.0"><dict><key>CFBundleExecutable</key><string>fixture</string><key>CFBundleIdentifier</key><string>io.rea.source-owned-ui-fixture</string><key>CFBundleName</key><string>REA UI Fixture</string><key>CFBundlePackageType</key><string>APPL</string></dict></plist>`,
);
const executable = join(contents, "MacOS", "fixture");
await promisify(execFile)("/usr/bin/xcrun", [
"swiftc",
"-module-cache-path",
join(root, "modules"),
fileURLToPath(
new URL("../tests/conformance/native/ui.swift", import.meta.url),
),
"-o",
executable,
]);
const coordinates = join(root, "scope.json");
await writeFile(coordinates, "");
child = spawn(
"/usr/bin/open",
["-W", "-n", "-g", "--stdout", coordinates, join(root, "Fixture.app")],
{ stdio: "ignore" },
);
let scope;
const deadline = Date.now() + 20000;
while (Date.now() < deadline) {
const output = await readFile(coordinates, "utf8");
if (output.includes("\n")) {
scope = JSON.parse(output.split("\n")[0]);
break;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
if (scope === undefined)
throw new Error(
"Source-owned UI fixture failed to expose one window within 20 seconds",
);
fixturePid = scope.pid;
const target = await parseBinaryTarget(executable);
if (!target.ok) throw target.error;
const observation = await observeNativeUi(target.value, "observe_native_ui", {
...scope,
observation_approved: true,
});
if (
!observation.ok &&
!/accessibility-denied|screen-recording-denied/u.test(
observation.error.message,
)
)
throw observation.error;
if (!observation.ok && !process.argv.includes("--allow-permission-denial"))
throw new Error(
`Native UI E2E requires successful capture; permissions prevented coverage: ${observation.error.message}. Use --allow-permission-denial only for the separate permission diagnostic lane.`,
);
let scenarioStatus = "not-run-permission-denied";
if (observation.ok) {
const button = observation.value.initial.nodes.find(
(node) => node.title === "Increment REA fixture",
);
if (button === undefined)
throw new Error("Source-owned fixture button is absent from AX capture");
const scenario = await observeNativeUi(
target.value,
"capture_native_ui_scenario",
{
...scope,
observation_approved: true,
actions_approved: true,
restore: "leave-as-is",
screenshot: false,
steps: [
{ kind: "click", path: button.path },
{ kind: "wait", milliseconds: 200 },
],
},
);
if (!scenario.ok) throw scenario.error;
if (
scenario.value.steps.some((step) => step.outcome !== "completed") ||
!scenario.value.steps
.at(-1)
?.after?.nodes.some((node) => node.title === "REA fixture incremented")
)
throw new Error(
`Source-owned AX scenario failed: ${JSON.stringify(scenario.value.steps.map(({ outcome, reason }) => ({ outcome, reason })))}`,
);
scenarioStatus = "selected-AX-button-action-observed";
const cli = await promisify(execFile)(
process.execPath,
[
fileURLToPath(new URL("./rea.mjs", import.meta.url)),
"capture-native-ui-scenario",
executable,
"--pid",
String(scope.pid),
"--window-id",
String(scope.window_id),
"--observation-approved",
"--actions-approved",
"--restore",
"leave-as-is",
"--steps",
JSON.stringify([
{ kind: "click", path: button.path },
{ kind: "wait", milliseconds: 200 },
]),
"--json",
],
{ maxBuffer: 32 * 1024 * 1024, timeout: 180000 },
);
const evidence = JSON.parse(cli.stdout);
if (
evidence.normalized_result?.steps?.some(
(step) => step.outcome !== "completed",
) ||
evidence.normalized_result?.steps?.length !== 2
)
throw new Error(
`Native UI CLI did not preserve the approved scenario: ${JSON.stringify(evidence.error ?? evidence.normalized_result?.steps?.map(({ outcome, reason }) => ({ outcome, reason })))}`,
);
scenarioStatus = "selected-AX-button-action-and-CLI-parity-observed";
}
const mismatch = await observeNativeUi(
{ ...target.value, sha256: "0".repeat(64) },
"observe_native_ui",
{ ...scope, observation_approved: true },
);
if (mismatch.ok || !mismatch.error.message.includes("target-mismatch"))
throw new Error("Real helper did not reject changed target bytes");
process.stdout.write(
`${JSON.stringify({ ok: true, verification_status: observation.ok ? "passed" : "permission-boundary-only", positive_e2e: observation.ok, observation: observation.ok ? "captured-selected-fixture-window" : "os-permission-denial-verified", scenario: scenarioStatus, permission_failure: observation.ok ? null : observation.error.message, mismatch_rejected: true, target_owned: true })}\n`,
);
} finally {
if (fixturePid !== undefined) {
try {
process.kill(fixturePid, "SIGTERM");
} catch (error) {
if (error.code !== "ESRCH") throw error;
}
}
if (child !== undefined && child.exitCode === null) {
const exited = new Promise((resolve) => child.once("exit", resolve));
child.kill("SIGTERM");
await exited;
}
await rm(root, { recursive: true, force: true });
}
+3
View File
@@ -33,6 +33,7 @@ import {
scenarioProfiles,
} from "./lib/browser-scenario-verifier.mjs";
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
import { verifyLargeScreenshotE2e } from "./lib/browser-screenshot-e2e.mjs";
const SECRET_VALUES = [
"network-secret-value",
@@ -297,6 +298,7 @@ try {
)
throw new Error(`Browser scenario retained sensitive value: ${secret}`);
const largeScreenshot = await verifyLargeScreenshotE2e(endpoint, site.origin);
process.stdout.write(
`${JSON.stringify({
verifier_run: await completeVerifierRun(verifierRun),
@@ -314,6 +316,7 @@ try {
sessionEvents: session.value.timeline.length,
pageScopedTransport: true,
screenshotBytes: screenshot.value.artifact.bytes,
largeScreenshot,
browserScenarioCli: true,
browserScenarioAttachCleanup: "disconnected-external",
browserScenarioLaunchCleanup: "terminated-owned-process",
+4 -1
View File
@@ -249,7 +249,10 @@ export async function assertSession(
session.read_only !== true ||
session.analysis_complete !== true ||
session.analysis_timed_out !== false ||
session.capabilities.join(",") !== GHIDRA_SESSION_CAPABILITIES.join(",") ||
session.capabilities.length !== GHIDRA_SESSION_CAPABILITIES.length ||
GHIDRA_SESSION_CAPABILITIES.some(
(name) => !session.capabilities.includes(name),
) ||
!/^0x[0-9a-f]+$/u.test(session.target.image_base) ||
session.target.default_address_space.length === 0
)
+138
View File
@@ -6,6 +6,7 @@ import {
parseGhidraInventoryInput,
parseGhidraInventoryResult,
} from "../dist/ghidra/GhidraInventoryValues.js";
import { verifyNativeValueE2e } from "./lib/native-value-e2e.mjs";
import {
assertDenseSwitchDossier,
@@ -84,6 +85,39 @@ export async function verifyDebugFunctionOperations(
entryXrefs,
] = await collectEntryFunctionData(client, entry);
assertLocalProcedureInfo(info, entry.address);
const instruction = await functionCall(client, "inspect_native_instruction", {
document: null,
address: entry.address,
});
if (
instruction.status !== "decoded" ||
instruction.length < 1 ||
instruction.bytes.length !== instruction.length * 2 ||
instruction.operands.length === 0
)
throw new Error("Structured native instruction facts drifted");
const directSite = outgoing.references.find(
(reference) => reference.kind.call && !reference.kind.computed,
);
if (directSite === undefined)
throw new Error("Direct call fixture site unavailable");
const direct = await functionCall(client, "resolve_native_call_targets", {
document: null,
address: directSite.source_address,
});
if (
direct.status !== "direct" ||
!direct.targets.some(
(target) => target.address === directSite.target_address,
)
)
throw new Error("Direct call target resolution drifted");
const missingType = await functionCall(client, "inspect_native_data_type", {
document: null,
type: "/REA_MISSING_TYPE",
});
if (missingType.status !== "unavailable")
throw new Error("Missing native type was not explicit");
assertProviderText(assembly, pseudocode, entry.address);
if (
instructionWindow.procedure?.address !== entry.address ||
@@ -475,3 +509,107 @@ async function findCrossBranchAddress(client, variant, callees) {
}
throw new Error(`${variant} did not recover the multi-block callee`);
}
export async function verifyNativeTypeLayout(client, typeNames) {
const layoutSymbol = typeNames.find((item) =>
matchesSymbol(item.value, "rea_ghidra_inventory_layout"),
);
if (layoutSymbol === undefined)
throw new Error("Native layout fixture symbol unavailable");
const layout = await functionCall(client, "inspect_native_data_type", {
document: null,
address: layoutSymbol.address,
});
if (
layout.kind !== "struct" ||
layout.fields.length !== 3 ||
layout.size_bytes < 12
)
throw new Error(
`Native struct layout unavailable: ${JSON.stringify(layout)}`,
);
const payloadField = layout.fields.find((field) => field.name === "payload");
const stateField = layout.fields.find((field) => field.name === "state");
if (payloadField === undefined || stateField === undefined)
throw new Error(
`Nested layout field identities unavailable: ${JSON.stringify(layout)}`,
);
const payload = await functionCall(client, "inspect_native_data_type", {
document: null,
type: payloadField.type_id,
});
const state = await functionCall(client, "inspect_native_data_type", {
document: null,
type: stateField.type_id,
});
if (
payload.kind !== "union" ||
payload.fields.length !== 2 ||
state.kind !== "enum" ||
!state.members.some(
(member) => member.name === "REA_FIXTURE_READY" && member.value === "3",
)
)
throw new Error("Nested union or enum layout drifted");
return { layout, payload, state };
}
export async function verifyRelativeSwitch(client, procedures, entrySize) {
// Relocatable loaders may choose an assembler-generated alias as the function name.
const names = await inventoryCall(client, "list_names", {
document: null,
address: null,
});
const procedure = requireProcedure(names, "rea_relative_switch");
const dossier = await functionCall(client, "analyze_function", {
procedure: procedure.address,
});
const table = dossier.native_api?.jump_tables.find((item) =>
item.data_sources.some(
(source) =>
source.entry_size_bytes === entrySize && source.entry_count === 4,
),
);
if (table === undefined)
throw new Error(
`Exact ${entrySize}-byte relative table unavailable: ${JSON.stringify(dossier.native_api)}`,
);
for (let value = 0; value < 4; value++) {
const mapping = table.mappings.find((item) => item.case_value === value);
if (mapping === undefined || mapping.confidence !== "high")
throw new Error("Relative switch case mapping was not verified");
const instruction = await functionCall(
client,
"inspect_native_instruction",
{ address: mapping.target_address },
);
if (
instruction.status !== "decoded" ||
!instruction.operands.some((operand) =>
operand.components.some(
(component) =>
component.kind === "immediate" &&
component.value === `0x${(100 + value).toString(16)}`,
),
)
)
throw new Error(
`Relative switch target does not implement source case ${value}: ${JSON.stringify(instruction)}`,
);
}
return {
entry_size: entrySize,
dispatch_address: table.dispatch_address,
mappings: table.mappings.length,
};
}
export async function verifyNativeValueTrace(client, procedures, target) {
const entry = requireProcedure(procedures, "rea_ghidra_inventory_entry");
const names = await inventoryCall(client, "list_names", {
document: null,
address: null,
});
const global = requireProcedure(names, "rea_ghidra_inventory_global");
return verifyNativeValueE2e(target, entry.address, global.address);
}
+74 -12
View File
@@ -34,6 +34,11 @@ import {
} from "./verify-real-ghidra-inventory.mjs";
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
import {
verifyNativeTypeLayout,
verifyRelativeSwitch,
verifyNativeValueTrace,
} from "./verify-real-ghidra-function.mjs";
const exec = promisify(execFile);
const verifierRun = createVerifierRun();
const installDir = process.env.GHIDRA_INSTALL_DIR;
@@ -71,6 +76,7 @@ const crossFormatSourcePath = fileURLToPath(
new URL("../tests/conformance/ghidra/cross-format.c", import.meta.url),
);
const debugPath = join(fixtureRoot, "rea-ghidra-inventory-debug");
const layoutPath = join(fixtureRoot, "rea-ghidra-layout.o");
const strippedPath = join(fixtureRoot, "rea-ghidra-inventory-stripped");
const arm64ElfPath = join(fixtureRoot, "rea-ghidra-cross-arm64");
const peObjectPath = join(fixtureRoot, "rea-ghidra-cross-x86_64.obj");
@@ -113,12 +119,49 @@ try {
const common = ["-O0", "-g", "-fno-inline"];
if (expectedNativeTarget.format === "elf") common.push("-fno-pie", "-no-pie");
await exec(compiler, [...common, sourcePath, "-o", debugPath]);
await exec(compiler, [
"-O0",
"-g",
"-gdwarf-4",
"-c",
sourcePath,
"-o",
layoutPath,
]);
const strippedFlags = [...common, "-s"];
if (expectedNativeTarget.format === "mach-o")
strippedFlags.push("-fvisibility=hidden");
await exec(compiler, [...strippedFlags, sourcePath, "-o", strippedPath]);
const crossTargets = [];
if (aarch64JumpTableOnly) {
const relativeSource = fileURLToPath(
new URL("../tests/conformance/ghidra/relative-switch.S", import.meta.url),
);
for (const entrySize of [1, 2]) {
for (const format of [
"elf",
...(expectedNativeTarget.architecture === "arm64" ? ["mach-o"] : []),
]) {
const targetPath = join(
fixtureRoot,
`relative-${entrySize}-${format}.o`,
);
await exec(clang, [
...(format === "elf" ? ["--target=aarch64-linux-gnu"] : []),
`-DREA_ENTRY_BYTES=${entrySize}`,
"-c",
relativeSource,
"-o",
targetPath,
]);
crossTargets.push([
targetPath,
`relative-${entrySize}`,
{ format, architecture: "arm64" },
]);
}
}
await exec(clang, [
"--target=aarch64-linux-gnu",
"-O2",
@@ -197,6 +240,11 @@ try {
const debug = await verifyTarget(debugPath, "debug", expectedNativeTarget);
assertDebugFixture(debug);
const layout = await verifyTarget(
layoutPath,
"type-layout",
expectedNativeTarget,
);
const stripped = await verifyTarget(
strippedPath,
"stripped",
@@ -206,7 +254,8 @@ try {
const crossResults = [];
for (const [targetPath, variant, expectedTarget] of crossTargets) {
const result = await verifyTarget(targetPath, variant, expectedTarget);
if (variant !== "aarch64-jump-table") assertCrossFixture(result);
if (variant !== "aarch64-jump-table" && !variant.startsWith("relative-"))
assertCrossFixture(result);
crossResults.push(result);
}
await assertMalformedFixture(malformedPath);
@@ -228,11 +277,12 @@ try {
crossFormat || aarch64JumpTableOnly
? [sourcePath, crossFormatSourcePath]
: [sourcePath],
fixtures: [debug, stripped, ...crossResults].map(summary),
fixtures: [debug, stripped, layout, ...crossResults].map(summary),
malformed_target: "rejected-before-provider-start",
native_api_cli: aarch64JumpTableOnly
? (crossResults[0]?.native_api_cli ?? null)
: debug.native_api_cli,
native_value_e2e: debug.native_values,
custom_target: custom === null ? null : summary(custom),
cleanup: "complete",
})}\n`,
@@ -417,16 +467,27 @@ async function verifyTarget(targetPath, variant, expectedTarget = null) {
: "rea_ghidra_inventory_dense_switch",
)
: null;
const probes =
variant === "custom" || variant === "aarch64-jump-table"
? null
: await verifyInventoryOperations({
client,
variant,
procedures,
names,
strings,
});
const probes = variant.startsWith("relative-")
? await verifyRelativeSwitch(
client,
procedures,
Number(variant.slice(-1)),
)
: variant === "type-layout"
? await verifyNativeTypeLayout(client, names)
: variant === "custom" || variant === "aarch64-jump-table"
? null
: await verifyInventoryOperations({
client,
variant,
procedures,
names,
strings,
});
const nativeValues =
variant === "debug"
? await verifyNativeValueTrace(client, procedures, parsedTarget.value)
: null;
return {
variant,
target: parsedTarget.value,
@@ -439,6 +500,7 @@ async function verifyTarget(targetPath, variant, expectedTarget = null) {
strings,
probes,
native_api_cli: nativeApiCli,
native_values: nativeValues,
};
} finally {
await client.close();
+1
View File
@@ -309,6 +309,7 @@ try {
{ name: "binary_overview", arguments: {} },
options,
);
requireSuccessfulTool(overview, "binary_overview");
requireEvidenceProvider(
documents,
"list_documents",
+2 -2
View File
@@ -3,8 +3,8 @@ name: reverse-engineer-anything
description: Reverse engineer native, managed, Electron/JavaScript, packaged, and browser applications with REA. Use shipped-artifact or approved runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
metadata:
version: "23"
tool_count: 114
catalog_digest: "3e9ab4e05563ea2609b7cf4db67638a79cd8a74188c292718d99a63451d5b9db"
tool_count: 122
catalog_digest: "fd9ccf3ce95e870cf24abb98e8aca444ec3035de1f358765c5a4e2d1ea280c48"
---
# REA
@@ -87,7 +87,8 @@ describe("analysis context queries: navigation", () => {
await expect(
getNavigationContext(analysis, { document: "fixture" }),
).resolves.toEqual(err(failure));
expect(calls).toEqual(["current_address", "resolve_containing_procedure"]);
expect(calls).not.toContain("current_document");
expect(calls).toContain("resolve_containing_procedure");
});
});
@@ -103,7 +103,9 @@ describe("analysis provider registry: discovery and selection", () => {
const selected = await registry.select(DATABASE_TARGET);
expect(selected.ok).toBe(true);
if (!selected.ok || selected.value.binding === null) return;
if (!selected.ok) throw new Error("provider selection failed");
if (selected.value.binding === null)
throw new Error("expected a bound provider");
expect(bindingProjection(selected.value.binding)).toEqual({
id: "alpha",
source: "auto-single-candidate",
@@ -198,6 +200,9 @@ describe("analysis provider registry: binding failures and cancellation", () =>
candidate("alpha", { available: false }).provider,
]).select(ARTIFACT_TARGET, "alpha");
expect(unsupported.ok).toBe(false);
if (unsupported.ok)
throw new Error("expected unsupported target rejection");
expect(unsupported.error).toBeInstanceOf(ProviderSelectionError);
if (
!unsupported.ok &&
unsupported.error instanceof ProviderSelectionError
@@ -237,7 +242,9 @@ describe("analysis provider registry: binding failures and cancellation", () =>
}
const explicit = await automatic.select(ARTIFACT_TARGET, "alpha");
expect(explicit.ok).toBe(false);
if (!explicit.ok && explicit.error instanceof ProviderSelectionError)
if (explicit.ok) throw new Error("expected provider selection rejection");
expect(explicit.error).toBeInstanceOf(ProviderSelectionError);
if (explicit.error instanceof ProviderSelectionError)
expect(explicit.error.reason).toBe("target_unsupported");
});
@@ -267,7 +274,9 @@ describe("analysis provider registry: binding failures and cancellation", () =>
failed.provider,
]).select(DATABASE_TARGET, "failed");
expect(explicit.ok).toBe(false);
if (!explicit.ok && explicit.error instanceof ProviderSelectionError)
if (explicit.ok) throw new Error("expected provider selection rejection");
expect(explicit.error).toBeInstanceOf(ProviderSelectionError);
if (explicit.error instanceof ProviderSelectionError)
expect(explicit.error.rejections).toMatchObject([
{
providerId: "failed",
@@ -31,16 +31,15 @@ describe("analysis snapshot cache partitioning", () => {
createEvidenceBundle([]),
);
expect(exported.ok).toBe(true);
if (!exported.ok) return;
if (!exported.ok) throw new Error("snapshot export failed");
const entry = exported.value.entries[0];
if (entry !== undefined) {
Reflect.set(entry.execution, "provider", {
id: "forged",
name: "Forged",
version: "9",
});
entry.execution.limitations.push("forged");
}
if (entry === undefined) throw new Error("exported snapshot entry missing");
Reflect.set(entry.execution, "provider", {
id: "forged",
name: "Forged",
version: "9",
});
entry.execution.limitations.push("forged");
expect(
cache.lookup(
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { collectInterfaceBuilderResourceKeys } from "./AppleAssetCatalogAnalysis.js";
describe("Apple asset catalog application workflow", () => {
it("joins only explicit resource-key fields with archive provenance", () => {
expect(
collectInterfaceBuilderResourceKeys([
{
id: "ib:Main.nib:object:2",
kind: "resource",
name: "Decorative image",
attributes: {
imageName: "Toolbar",
title: "not-an-asset-key",
image: 42,
},
evidence: [
{
artifact_path: "Contents/Resources/Main.nib",
artifact_sha256: "d".repeat(64),
},
],
},
{
id: "ib:Main.nib:object:3",
kind: "control",
name: "Toolbar",
attributes: { title: "not-an-asset-key" },
evidence: [],
},
]),
).toEqual([
{
sourceNodeId: "ib:Main.nib:object:2",
sourcePath: "Contents/Resources/Main.nib",
sourceArchiveSha256: "d".repeat(64),
field: "imageName",
key: "Toolbar",
},
]);
});
});
@@ -0,0 +1,267 @@
import { createHash } from "node:crypto";
import { lstat } from "node:fs/promises";
import { DirectoryArtifactReader } from "../artifacts/DirectoryArtifactReader.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { analyzeInterfaceBuilderBundle } from "./InterfaceBuilderAnalysis.js";
import {
appleAssetCatalogInputSchema,
parseAppleAssetCatalogRecords,
projectAppleAssetCatalogPage,
} from "../domain/appleAssetCatalog.js";
import { execFileOutput } from "../process/ExecFileOutput.js";
const ASSETUTIL = "/usr/bin/assetutil";
const MAX_CATALOG_BYTES = 64 * 1024 * 1024;
const MAX_TOTAL_METADATA_BYTES = 64 * 1024 * 1024;
const RESOURCE_KEY_FIELDS = new Set([
"image",
"imageName",
"image_name",
"resourceName",
"resource_name",
"assetName",
"asset_name",
"UIImageName",
"NSImageName",
]);
/** Inspect compiled asset catalog metadata without extracting rendition bytes. */
export const analyzeAppleAssetCatalogs = async (input: {
readonly bundlePath: string;
readonly targetSha256: string;
readonly page?: unknown;
readonly signal?: AbortSignal;
readonly runAssetUtil?: (
path: string,
signal?: AbortSignal,
) => Promise<string>;
}) => {
if (process.platform !== "darwin" && input.runAssetUtil === undefined)
throw new ArtifactReaderFailure(
"unavailable",
"Apple asset catalog inspection requires macOS assetutil",
);
const page = appleAssetCatalogInputSchema.parse(input.page ?? {});
const reader = new DirectoryArtifactReader(input.bundlePath);
const catalogs: {
path: string;
sha256: string;
records: ReturnType<typeof parseAppleAssetCatalogRecords>;
}[] = [];
let totalMetadataBytes = 0;
try {
for await (const entry of reader.entries(input.signal)) {
if (entry.kind !== "file" || !entry.path.endsWith("Assets.car")) continue;
const inspected = await inspectAssetCatalogEntry({
reader,
entry,
runAssetUtil: input.runAssetUtil ?? runAssetUtil,
...(input.signal === undefined ? {} : { signal: input.signal }),
});
totalMetadataBytes += inspected.metadataBytes;
if (totalMetadataBytes > MAX_TOTAL_METADATA_BYTES)
throw new ArtifactReaderFailure(
"limit",
`Combined asset catalog metadata exceeds ${MAX_TOTAL_METADATA_BYTES} bytes`,
);
catalogs.push(inspected.catalog);
}
} finally {
await reader.close();
}
if (catalogs.length === 0)
throw new ArtifactReaderFailure(
"unavailable",
"No compiled Assets.car catalogs were found in the app bundle",
);
const interfaceBuilder = await analyzeInterfaceBuilderBundle({
bundlePath: input.bundlePath,
targetSha256: input.targetSha256,
...(input.signal === undefined ? {} : { signal: input.signal }),
});
const resourceReferences = collectInterfaceBuilderResourceKeys(
interfaceBuilder.graph.nodes,
);
const result = projectAppleAssetCatalogPage({
targetSha256: input.targetSha256,
catalogs,
offset: page.offset,
limit: page.limit,
resourceReferences,
});
return {
...result,
limitations: [
...result.limitations,
"Exact resource-key joins use only recognized image or asset key fields on decoded Interface Builder resource objects; other archive resource references are not inferred.",
...(interfaceBuilder.graph.truncated
? [
"Interface Builder resource-key joins may be incomplete because the decoded UI graph was truncated.",
]
: []),
],
};
};
const inspectAssetCatalogEntry = async (input: {
readonly reader: DirectoryArtifactReader;
readonly entry: import("../artifacts/ArtifactReader.js").ArtifactEntry;
readonly runAssetUtil: (
path: string,
signal?: AbortSignal,
) => Promise<string>;
readonly signal?: AbortSignal;
}) => {
const { entry, reader, signal } = input;
const sha256 = await digestBounded(reader, entry, signal);
const before = await lstat(entry.adapterKey);
if (
entry.sourceIdentity === undefined ||
before.dev !== entry.sourceIdentity.device ||
before.ino !== entry.sourceIdentity.inode
)
throw new ArtifactReaderFailure(
"integrity",
`Asset catalog changed before inspection: ${entry.path}`,
);
const output = await input.runAssetUtil(entry.adapterKey, signal);
const after = await lstat(entry.adapterKey);
if (
before.dev !== after.dev ||
before.ino !== after.ino ||
before.size !== after.size ||
(await digestBounded(reader, entry, signal)) !== sha256
)
throw new ArtifactReaderFailure(
"integrity",
`Asset catalog changed during inspection: ${entry.path}`,
);
let parsed: unknown;
try {
parsed = JSON.parse(output);
} catch (cause: unknown) {
throw new ArtifactReaderFailure(
"format",
`assetutil returned invalid JSON for ${entry.path}`,
{ cause },
);
}
try {
return {
catalog: {
path: entry.path,
sha256,
records: parseAppleAssetCatalogRecords(parsed),
},
metadataBytes: Buffer.byteLength(output, "utf8"),
};
} catch (cause: unknown) {
throw new ArtifactReaderFailure(
"format",
`assetutil returned an unsupported record for ${entry.path}`,
{ cause },
);
}
};
/** Return only explicit image or asset fields from decoded resource objects. */
export const collectInterfaceBuilderResourceKeys = (
nodes: readonly {
readonly id: string;
readonly kind: string;
readonly name?: string;
readonly attributes: Readonly<Record<string, unknown>>;
readonly evidence: readonly {
readonly artifact_path: string | null;
readonly artifact_sha256: string | null;
}[];
}[],
) =>
nodes.flatMap((node) => {
const source = node.evidence.find(
(item) => item.artifact_path !== null && item.artifact_sha256 !== null,
);
const sourcePath = source?.artifact_path;
const sourceArchiveSha256 = source?.artifact_sha256;
if (sourcePath == null || sourceArchiveSha256 == null) return [];
return Object.entries(node.attributes).flatMap(([field, value]) =>
RESOURCE_KEY_FIELDS.has(field) &&
typeof value === "string" &&
value.length > 0
? [
{
sourceNodeId: node.id,
sourcePath,
sourceArchiveSha256,
field,
key: value,
},
]
: [],
);
});
const runAssetUtil = async (
path: string,
signal?: AbortSignal,
): Promise<string> => {
try {
return (
await execFileOutput(ASSETUTIL, ["--info", path], {
timeout: 60_000,
maxBuffer: MAX_CATALOG_BYTES,
...(signal === undefined ? {} : { signal }),
})
).stdout;
} catch (cause: unknown) {
if (signal?.aborted === true)
throw new ArtifactReaderFailure(
"cancelled",
"Asset catalog inspection cancelled",
);
const code =
typeof cause === "object" && cause !== null && "code" in cause
? cause.code
: undefined;
throw new ArtifactReaderFailure(
code === "ENOENT"
? "unavailable"
: code === "ETIMEDOUT" || code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"
? "limit"
: "io",
`assetutil failed${code === undefined ? "" : ` (${String(code)})`}`,
{ cause },
);
}
};
const digestBounded = async (
reader: DirectoryArtifactReader,
entry: import("../artifacts/ArtifactReader.js").ArtifactEntry,
signal?: AbortSignal,
): Promise<string> => {
if (entry.declaredSize === null || entry.declaredSize > MAX_CATALOG_BYTES)
throw new ArtifactReaderFailure(
"limit",
`Asset catalog exceeds ${MAX_CATALOG_BYTES} bytes: ${entry.path}`,
);
const stream = await reader.open(entry, signal);
const hash = createHash("sha256");
let length = 0;
for await (const chunk of stream) {
if (signal?.aborted === true)
throw new ArtifactReaderFailure(
"cancelled",
"Asset catalog inspection cancelled",
);
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
length += bytes.length;
if (length > MAX_CATALOG_BYTES)
throw new ArtifactReaderFailure(
"limit",
`Asset catalog exceeds byte limit: ${entry.path}`,
);
hash.update(bytes);
}
return hash.digest("hex");
};
@@ -18,12 +18,6 @@ describe("artifact inventory policy resolution", () => {
});
});
it("projects strict integrity without dormant continuation state", () => {
expect(resolveArtifactIntegrityPolicy({ mode: "fail" }, true)).toEqual({
mode: "fail",
});
});
it("admits parsed continuation intent only under operator policy", () => {
expect(() =>
resolveArtifactIntegrityPolicy({ mode: "record-and-continue" }, false),
+6 -2
View File
@@ -303,7 +303,9 @@ export class BinarySession
unboundOperationError: (operation, route) =>
this.#providerRouter.unboundOperationError(operation, route),
lookupSnapshot: (target, profile, operation, parameters) =>
operation === "decode_interface_builder"
operation === "decode_interface_builder" ||
operation === "inspect_asset_catalog" ||
operation === "inspect_keyed_archive"
? undefined
: this.lookupSnapshot(target, profile, operation, parameters),
});
@@ -324,7 +326,9 @@ export class BinarySession
profiled.ok &&
cacheable &&
profile !== undefined &&
name !== "decode_interface_builder"
name !== "decode_interface_builder" &&
name !== "inspect_asset_catalog" &&
name !== "inspect_keyed_archive"
)
this.recordSnapshot({
target: active.target,
@@ -1,35 +0,0 @@
import { describe, expect, it } from "vitest";
import type {
AnalysisClient,
AnalysisProvider,
ProviderIdentity,
} from "./AnalysisProvider.js";
import { AnalysisProviderRegistry } from "./AnalysisProviderRegistry.js";
import { composeBinarySession } from "./BinarySessionComposition.js";
const auxiliaryProvider = (identity: ProviderIdentity): AnalysisProvider => ({
identity: () => identity,
capabilities: () => [],
createClient: (): AnalysisClient => {
throw new Error("The composition check must not create provider clients");
},
});
describe("composeBinarySession", () => {
it("exposes the complete configured provider set without starting clients", () => {
const session = composeBinarySession(new AnalysisProviderRegistry([]), [
auxiliaryProvider({
id: "test-auxiliary",
name: "Test auxiliary provider",
version: "1.0.0",
}),
]);
expect(session.providerIdentity()).toEqual({
id: "composite:test-auxiliary",
name: "REA composite analysis provider",
version: null,
});
});
});
+1
View File
@@ -71,6 +71,7 @@ type AvailabilityContext = {
};
const ENHANCED_REQUIREMENTS: Readonly<Record<string, readonly string[]>> = {
trace_native_values: ["analyze_function", "resolve_native_call_targets"],
trace_native_ui_action: [
"decode_interface_builder",
"list_names",
@@ -1,37 +0,0 @@
import { describe, expect, it } from "vitest";
import { clientConfigurationValuesEqual } from "./ClientConfigurationDocument.js";
describe("client configuration comparison", () => {
it("ignores TOML parser object prototypes and key order", () => {
const parsedEnvironment = {
HOPPER_LAUNCHER_PATH: "/opt/rea/node",
};
const parsedRegistration = {
env: parsedEnvironment,
startup_timeout_sec: 30,
args: ["mcp"],
command: "/opt/rea/bin/rea",
};
Object.setPrototypeOf(parsedEnvironment, null);
Object.setPrototypeOf(parsedRegistration, null);
expect(
clientConfigurationValuesEqual(parsedRegistration, {
command: "/opt/rea/bin/rea",
args: ["mcp"],
startup_timeout_sec: 30,
env: { HOPPER_LAUNCHER_PATH: "/opt/rea/node" },
}),
).toBe(true);
});
it("still detects configuration value changes", () => {
expect(
clientConfigurationValuesEqual(
{ startup_timeout_sec: 30 },
{ startup_timeout_sec: 15 },
),
).toBe(false);
});
});
+8 -1
View File
@@ -47,6 +47,9 @@ type DirectAnalysisTool =
| "binary_overview"
| "procedure_pseudo_code"
| "read_function_instructions"
| "inspect_native_instruction"
| "inspect_native_data_type"
| "resolve_native_call_targets"
| "analyze_function"
| "inspect_native_api"
| "inspect_native_dispatch_metadata"
@@ -54,7 +57,8 @@ type DirectAnalysisTool =
| "search_procedures"
| "xrefs"
| "trace_feature"
| "trace_native_ui_action";
| "trace_native_ui_action"
| "trace_native_values";
/**
* Open one binary, execute one tool, and always release provider resources.
@@ -285,6 +289,7 @@ const executeAnalysisTool = async (input: {
tool === "inspect_native_api" ||
tool === "inspect_native_dispatch_metadata" ||
tool === "trace_feature" ||
tool === "trace_native_values" ||
tool === "trace_native_ui_action"
) {
const result = await new EnhancedTools(session).execute(
@@ -407,7 +412,9 @@ const isWorkflowEvidenceTool = (
): boolean =>
tool === "binary_overview" ||
tool === "inspect_native_api" ||
tool === "inspect_native_dispatch_metadata" ||
tool === "trace_feature" ||
tool === "trace_native_values" ||
tool === "trace_native_ui_action";
const fileExists = async (path: string): Promise<boolean> => {
+11 -16
View File
@@ -4,11 +4,9 @@ import { ok } from "../domain/result.js";
import { traceLiteralFeature } from "./EnhancedLiteralTracing.js";
describe("literal feature tracing", () => {
it("searches procedure and string names before following references", async () => {
const calls: string[] = [];
it("returns matched procedure and string facts with references", async () => {
const result = await traceLiteralFeature(
async (name) => {
calls.push(name);
if (name === "search_strings")
return ok([{ address: "0x1000", value: "launch feature" }]);
if (name === "search_procedures")
@@ -31,21 +29,11 @@ describe("literal feature tracing", () => {
references: [],
},
});
expect(calls).toEqual([
"search_strings",
"search_procedures",
"search_strings",
"search_procedures",
"xrefs",
"xrefs",
]);
});
it("keeps string-code tracing scoped to string labels", async () => {
const calls: string[] = [];
await traceLiteralFeature(
const result = await traceLiteralFeature(
async (name) => {
calls.push(name);
if (name === "search_strings")
return ok([{ address: "0x1000", value: "launch feature" }]);
if (name === "xrefs") return ok([]);
@@ -54,7 +42,14 @@ describe("literal feature tracing", () => {
{ query: "launch", case_sensitive: false },
"string",
);
expect(calls).toEqual(["search_strings", "xrefs"]);
expect(result).toMatchObject({
ok: true,
value: {
matches: [
{ type: "string", address: "0x1000", value: "launch feature" },
],
references: [],
},
});
});
});
@@ -14,7 +14,10 @@ import {
} from "../domain/nativeInvestigationGraph.js";
import { err, ok } from "../domain/result.js";
import type { Result } from "../domain/result.js";
import { AnalysisCancelledError } from "../domain/errors.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
} from "../domain/errors.js";
const target = "a".repeat(64);
const provider = { id: "fixture", name: "Fixture", version: "1" };
@@ -138,6 +141,30 @@ const testAnalysis = (
const override = overrides[operation];
if (override !== undefined) return override(parameters);
switch (operation) {
case "procedure_address":
return err(
new AnalysisCapabilityUnavailableError(
"fixture",
operation,
"Seed not available",
),
);
case "procedure_references":
return err(
new AnalysisCapabilityUnavailableError(
"fixture",
operation,
"No typed reference reader in fixture",
),
);
case "inspect_native_dispatch_metadata":
return err(
new AnalysisCapabilityUnavailableError(
"fixture",
operation,
"No binary metadata reader in fixture",
),
);
case "decode_interface_builder":
return execution(operation, {
target_sha256: target,
@@ -170,6 +197,22 @@ const testAnalysis = (
});
describe("native UI action trace", () => {
it("accepts an explicit native seed without an authored UI connection", async () => {
const result = await new EnhancedTools(
testAnalysis({
procedure_address: () => execution("procedure_address", "0x1000"),
}),
).execute("trace_native_ui_action", { action: "0x1000" });
expect(result).toMatchObject({
ok: true,
value: {
start: "native:function:0x1000",
nodes: expect.arrayContaining([
expect.objectContaining({ id: "native:function:0x2000" }),
]),
},
});
});
it("builds the evidence path from the active app and native provider", async () => {
const tools = new EnhancedTools(testAnalysis());
const result = await tools.execute("trace_native_ui_action", {
@@ -189,7 +232,7 @@ describe("native UI action trace", () => {
);
expect(trace.edges).toContainEqual(
expect.objectContaining({
relation: "direct_call",
relation: "provider_call",
to: "native:function:0x2000",
}),
);
@@ -416,7 +459,7 @@ describe("native UI query outcomes", () => {
expect(result.value).toMatchObject({
coverage: expect.arrayContaining([
expect.objectContaining({
facet: "direct_native_calls",
facet: "static_native_calls",
status: "partial",
omitted: 1,
}),
+12 -41
View File
@@ -27,10 +27,8 @@ import {
discoverObjcClasses,
discoverObjcProtocols,
} from "../domain/symbolAnalysis.js";
import {
inspectNativeDispatchMetadata,
nativeDispatchMetadataResultSchema,
} from "../domain/objcSwiftMetadata.js";
import { inspectNativeDispatch } from "./NativeDispatchMetadataInspection.js";
import { traceNativeValues } from "./NativeValueTrace.js";
import { traceNativeUiAction } from "./NativeUiActionTrace.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
@@ -64,6 +62,12 @@ export class EnhancedTools {
name === "trace_call_path"
)
return this.#executeTracing(name, input, signal);
if (name === "trace_native_values") {
const parsed = enhancedInputSchemas.trace_native_values.safeParse(input);
return parsed.success
? this.executeValidated({ name, input: parsed.data }, signal)
: invalidEnhancedInput(name, parsed.error);
}
if (name === "trace_native_ui_action") {
const parsed =
enhancedInputSchemas.trace_native_ui_action.safeParse(input);
@@ -177,6 +181,8 @@ export class EnhancedTools {
call.input,
signal,
);
case "trace_native_values":
return traceNativeValues(this.analysis, call.input, signal);
case "trace_native_ui_action": {
return traceNativeUiAction(this.analysis, call.input, signal);
}
@@ -246,46 +252,11 @@ export class EnhancedTools {
return names.ok ? ok(discoverObjcClasses(names.value, pattern)) : names;
}
async #inspectNativeDispatchMetadata(
#inspectNativeDispatchMetadata(
maxRecords: number,
signal?: AbortSignal,
): EnhancedResult {
const execution = await this.analysis.execute(
"list_names",
{},
signal === undefined ? {} : { signal },
);
if (!execution.ok) return err(execution.error);
const names = z
.array(
z.union([
z.strictObject({ address: z.string(), value: z.string() }),
z.strictObject({ address: z.string(), name: z.string() }),
]),
)
.safeParse(execution.value.result);
if (!names.success)
return err(
new AnalysisOutputError(
"list_names",
"provider returned an invalid inventory",
),
);
return ok(
nativeDispatchMetadataResultSchema.parse({
target_sha256: execution.value.subject?.sha256 ?? null,
provider: execution.value.provider,
analysis_profile_digest:
execution.value.analysisProfile?.digest ?? null,
result: inspectNativeDispatchMetadata(
names.data.map((entry) => ({
address: entry.address,
name: "value" in entry ? entry.value : entry.name,
})),
maxRecords,
),
}),
);
return inspectNativeDispatch(this.analysis, maxRecords, signal);
}
async #objcProtocols(signal?: AbortSignal): EnhancedResult {
@@ -110,9 +110,6 @@ describe("residual unknown registry reads and identity", () => {
});
it("derives stable IDs, filters heads, and rejects duplicate creation", () => {
expect(recordUnknownInputSchema.parse(input("Is branch live?"))).toEqual(
input("Is branch live?"),
);
const store = ledger();
const first = store.recordUnknown(
input(" Is branch live? "),
@@ -306,11 +303,5 @@ describe("residual unknown registry resolution and history", () => {
const before = serializeEvidenceBundle(base.export());
expect(base.import(branch.export()).ok).toBe(false);
expect(serializeEvidenceBundle(base.export())).toBe(before);
const unbounded = new EvidenceLedger();
expect(
unbounded.recordUnknown(input("No quota"), mutation("unbounded")).ok,
).toBe(true);
expect(unbounded.export().unknowns).toHaveLength(1);
});
});
+18 -51
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import type { BinaryTarget } from "../domain/binaryTarget.js";
import { createEvidence, evidenceSchema } from "../domain/evidence.js";
import { createEvidence } from "../domain/evidence.js";
import { createEvidenceBundle } from "../domain/evidenceBundle.js";
import { recordUnknownInputSchema } from "../domain/residualUnknown.js";
import { EvidenceLedger } from "./EvidenceLedger.js";
@@ -41,27 +41,29 @@ describe("evidence ledger recording", () => {
value: { recordsAdded: 0, unknownsAdded: 0, changed: false },
});
expect(ledger.export().records).toEqual([evidence]);
ledger.clear();
expect(ledger.export().records).toEqual([]);
});
it("treats canonical JSON key order as semantically irrelevant", () => {
const evidence = createEvidence(TARGET, PROVIDER, {
const ordered = createEvidence(TARGET, PROVIDER, {
operation: "health",
parameters: { alpha: 1, beta: 2 },
result: { alpha: 1, beta: 2 },
});
const reordered = evidenceSchema.parse({
...evidence,
parameters: { beta: 2, alpha: 1 },
normalized_result: { beta: 2, alpha: 1 },
});
const ledger = new EvidenceLedger();
expect(ledger.record(evidence)).toEqual({ ok: true, value: "added" });
expect(ledger.record(reordered)).toEqual({
expect(ledger.record(ordered)).toMatchObject({ ok: true, value: "added" });
const reorderedBundle: unknown = JSON.parse(
JSON.stringify(
createEvidenceBundle([
{
...ordered,
parameters: { beta: 2, alpha: 1 },
normalized_result: { beta: 2, alpha: 1 },
},
]),
),
);
expect(ledger.import(reorderedBundle)).toEqual({
ok: true,
value: "duplicate",
value: { recordsAdded: 0, unknownsAdded: 0, changed: false },
});
ledger.clear();
expect(ledger.export().records).toEqual([]);
});
it("retains more than ten thousand inline evidence records", () => {
@@ -87,41 +89,6 @@ describe("evidence ledger recording", () => {
expect(ledger.record(record)).toEqual({ ok: true, value: "added" });
expect(ledger.get(record.evidence_id)).toEqual(record);
});
it("retains Evidence alongside unknown revisions without a record quota", () => {
const ledger = new EvidenceLedger();
const mutation = createEvidence(undefined, PROVIDER, {
predicateType: "rea.residual-unknown-mutation",
operation: "record_unknown",
parameters: {},
result: { action: "record" },
});
const unknown = recordUnknownInputSchema.parse({
approved: true,
question: "What remains unresolved?",
severity: "high",
domain: "record-limit-test",
supporting_evidence_ids: [],
contradicting_evidence_ids: [],
required_authority: "shipped-artifact",
required_confidence: "observed",
required_environment: null,
recommended_probes: [],
relationships: [],
});
expect(ledger.recordUnknown(unknown, mutation).ok).toBe(true);
const direct = createEvidence(TARGET, PROVIDER, {
operation: "health",
parameters: {},
result: true,
});
expect(ledger.record(direct)).toEqual({ ok: true, value: "added" });
expect(ledger.export()).toMatchObject({
records: expect.arrayContaining([mutation, direct]),
unknowns: [expect.objectContaining({ domain: "record-limit-test" })],
});
});
});
describe("evidence bundle imports", () => {
@@ -18,7 +18,7 @@ afterEach(async () => {
});
describe("compiled Interface Builder bundle reader", () => {
it("reads nib plist archives without following symlinks and reports provenance", async () => {
it("reads nib plist archives and reports provenance", async () => {
const root = await mkdtemp(join(tmpdir(), "rea-ib-test-"));
roots.push(root);
const bundle = join(root, "Example.app");
@@ -27,7 +27,6 @@ it("projects closure and direct interprocedural flow without execution", () => {
"returns-to-call",
] as const)
expect(relations.has(expected)).toBe(true);
expect(graph.coverage.families).toHaveLength(12);
expect(graph.limitations).toContain(
"The semantic graph contains static syntax observations and conservative relationship candidates; it does not claim runtime execution.",
);
@@ -244,7 +244,6 @@ it("uses unavailable evidence when no source produced semantic IR", () => {
applicationGraph: { graph_id: GRAPH_ID, nodes: [] },
analysis: emptyAnalysis(),
});
expect(graph.nodes).toHaveLength(1);
expect(graph.nodes[0]?.evidence).toMatchObject({
authority: "unknown",
state: "unavailable",
+1 -6
View File
@@ -6,7 +6,7 @@ import { nativeApiBoundarySchema } from "../domain/nativeApiBoundary.js";
import { projectNativeApiInspection } from "./NativeApiInspection.js";
describe("native API inspection", () => {
it("projects structured boundary evidence through inspectable substeps", () => {
it("preserves structured type and jump-table boundary evidence", () => {
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
expect(projectNativeApiInspection(dossier)).toMatchObject({
@@ -26,11 +26,6 @@ describe("native API inspection", () => {
},
],
},
substeps: [
{ operation: "analyze_function", status: "completed" },
{ operation: "project_native_api_boundary", status: "completed" },
{ operation: "preserve_residual_unknowns", status: "completed" },
],
unsupported_branches: [],
residual_unknowns: [],
});
+104
View File
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import { readNativeCallRoutes } from "./NativeCallRoutes.js";
import { createAnalysisExecution } from "./AnalysisProvider.js";
import { ghidraReferenceEdge } from "../domain/hopperValues.fixture.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { err, ok } from "../domain/result.js";
import { AnalysisCancelledError } from "../domain/errors.js";
const provider = { id: "fixture", name: "Fixture", version: "1" };
describe("typed native call routes", () => {
it("preserves computed targets, ambiguity and targetless call sites", async () => {
const reference = jsonValueSchema.parse(ghidraReferenceEdge());
if (
reference === null ||
typeof reference !== "object" ||
Array.isArray(reference)
)
throw new TypeError("Invalid reference fixture");
const result = await readNativeCallRoutes(
{
execute: async () =>
ok(
createAnalysisExecution(
{
reference_kinds_available: true,
references: ["0x2000", "0x3000"].map((address) => ({
...reference,
source_address: "0x1004",
target_address: address,
target_procedure: { address },
kind: {
available: true,
provenance: "fixture-reference",
type: "COMPUTED_CALL",
flow: true,
call: true,
jump: false,
data: false,
read: false,
write: false,
indirect: true,
computed: true,
external: false,
conditional: false,
terminal: false,
primary: true,
operand_index: 0,
},
})),
unresolved_calls: [
{ address: "0x1008", reason: "No static target" },
],
},
provider,
),
),
},
"0x1000",
);
expect(result).toMatchObject({
ok: true,
value: {
routes: [
{
address: "0x2000",
relation: "indirect_call",
resolution: "ambiguous",
},
{
address: "0x3000",
relation: "indirect_call",
resolution: "ambiguous",
},
],
unknowns: [{ address: "0x1008" }],
},
});
});
it("does not fall back on cancellation or invalid typed output", async () => {
let calls = 0;
const cancelled = await readNativeCallRoutes(
{
execute: async () => {
calls++;
return err(new AnalysisCancelledError("procedure_references"));
},
},
"0x1000",
);
expect(cancelled).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
expect(calls).toBe(1);
const malformed = await readNativeCallRoutes(
{ execute: async () => ok(createAnalysisExecution({}, provider)) },
"0x1000",
);
expect(malformed).toMatchObject({
ok: false,
error: { _tag: "AnalysisOutputError" },
});
});
});
+101
View File
@@ -0,0 +1,101 @@
import { z } from "zod";
import type { AnalysisOperationPort } from "./AnalysisProvider.js";
import {
parseRelatedAddresses,
referenceKindSchema,
} from "../domain/hopperValues.js";
import { err, ok } from "../domain/result.js";
import { AnalysisOutputError } from "../domain/errors.js";
const referencesSchema = z.object({
reference_kinds_available: z.boolean().optional(),
references: z.array(
z.object({
source_address: z.string(),
target_address: z.string(),
target_procedure: z.object({ address: z.string() }).nullable(),
kind: referenceKindSchema,
}),
),
unresolved_calls: z
.array(z.object({ address: z.string(), reason: z.string() }))
.default([]),
});
/** Prefer typed static call references, preserving computed targets and targetless sites. */
export const readNativeCallRoutes = async (
analysis: AnalysisOperationPort,
procedure: string,
signal?: AbortSignal,
) => {
const options = signal === undefined ? {} : { signal };
const execution = await analysis.execute(
"procedure_references",
{ procedure, direction: "outgoing" },
options,
);
if (execution.ok) {
const parsed = referencesSchema.safeParse(execution.value.result);
if (!parsed.success)
return err(
new AnalysisOutputError(
"procedure_references",
"Invalid typed call references",
),
);
const references = parsed.data.references;
if (
parsed.data.reference_kinds_available === true ||
(references.length > 0 && references.every(({ kind }) => kind.available))
) {
const calls = references.filter(
({ kind }) => kind.available && kind.call,
);
const countBySite = new Map<string, Set<string>>();
for (const call of calls) {
const targets =
countBySite.get(call.source_address) ?? new Set<string>();
targets.add(call.target_address);
countBySite.set(call.source_address, targets);
}
return ok({
provider: execution.value.provider,
routes: calls.map((call) => ({
address: call.target_procedure?.address ?? call.target_address,
site: call.source_address,
relation:
call.kind.available && (call.kind.computed || call.kind.indirect)
? ("indirect_call" as const)
: ("direct_call" as const),
resolution:
(countBySite.get(call.source_address)?.size ?? 0) > 1
? ("ambiguous" as const)
: call.kind.available &&
(call.kind.computed || call.kind.indirect)
? ("resolved" as const)
: ("observed" as const),
})),
unknowns: parsed.data.unresolved_calls,
});
}
} else if (execution.error._tag !== "AnalysisCapabilityUnavailableError")
return err(execution.error);
const callees = await analysis.execute(
"procedure_callees",
{ procedure },
options,
);
if (!callees.ok) return callees;
const parsed = parseRelatedAddresses(callees.value.result, "callees");
return parsed.ok
? ok({
provider: callees.value.provider,
routes: parsed.value.map((address) => ({
address,
site: procedure,
relation: "provider_call" as const,
resolution: "inferred" as const,
})),
unknowns: [],
})
: parsed;
};
@@ -0,0 +1,68 @@
import { z } from "zod";
import {
nativeDispatchMetadataResultSchema,
inspectNativeDispatchMetadata,
} from "../domain/objcSwiftMetadata.js";
import { AnalysisOutputError } from "../domain/errors.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { err, ok } from "../domain/result.js";
import type { AnalysisOperationPort } from "./AnalysisProvider.js";
import type { EnhancedResult } from "./EnhancedToolTypes.js";
/** Prefer validated binary metadata, otherwise preserve the bound provider's symbol-only coverage. */
export const inspectNativeDispatch = async (
analysis: AnalysisOperationPort,
maxRecords: number,
signal?: AbortSignal,
): EnhancedResult => {
const binaryMetadata = await analysis.execute(
"inspect_native_dispatch_metadata",
{ max_records: maxRecords },
signal === undefined ? {} : { signal },
);
if (binaryMetadata.ok)
return ok(
jsonValueSchema.parse(
nativeDispatchMetadataResultSchema.parse(binaryMetadata.value.result),
),
);
if (binaryMetadata.error._tag !== "AnalysisCapabilityUnavailableError")
return err(binaryMetadata.error);
const execution = await analysis.execute(
"list_names",
{},
signal === undefined ? {} : { signal },
);
if (!execution.ok) return err(execution.error);
const names = z
.array(
z.union([
z.strictObject({ address: z.string(), value: z.string() }),
z.strictObject({ address: z.string(), name: z.string() }),
]),
)
.safeParse(execution.value.result);
if (!names.success)
return err(
new AnalysisOutputError(
"list_names",
"provider returned an invalid inventory",
),
);
return ok(
jsonValueSchema.parse(
nativeDispatchMetadataResultSchema.parse({
target_sha256: execution.value.subject?.sha256 ?? null,
provider: execution.value.provider,
analysis_profile_digest:
execution.value.analysisProfile?.digest ?? null,
result: inspectNativeDispatchMetadata(
names.data.map((entry) => ({
address: entry.address,
name: "value" in entry ? entry.value : entry.name,
})),
maxRecords,
),
}),
),
);
};
+88 -73
View File
@@ -10,34 +10,21 @@ import {
traceNativeInvestigationGraph,
joinInterfaceBuilderDispatch,
} from "../domain/nativeInvestigationGraph.js";
import {
inspectNativeDispatchMetadata,
nativeDispatchMetadataResultSchema,
} from "../domain/objcSwiftMetadata.js";
import { nativeDispatchMetadataResultSchema } from "../domain/objcSwiftMetadata.js";
import { inspectNativeDispatch } from "./NativeDispatchMetadataInspection.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { parseRelatedAddresses } from "../domain/hopperValues.js";
import { readNativeCallRoutes } from "./NativeCallRoutes.js";
import { err, ok } from "../domain/result.js";
import {
AnalysisCancelledError,
AnalysisOutputError,
projectAnalysisError,
} from "../domain/errors.js";
import { AnalysisOutputError, projectAnalysisError } from "../domain/errors.js";
type Input = z.output<typeof enhancedInputSchemas.trace_native_ui_action>;
const nativeNameSchema = z.array(
z.union([
z.strictObject({ address: z.string(), name: z.string() }),
z.strictObject({ address: z.string(), value: z.string() }),
]),
);
/** Build a UI-to-code trace from the active app and selected native provider. */
export const traceNativeUiAction = async (
analysis: AnalysisOperationPort,
input: Input,
signal?: AbortSignal,
): EnhancedResult => {
const [uiExecution, namesExecution] = await Promise.all([
const [uiExecution, metadataExecution] = await Promise.all([
analysis.execute(
"decode_interface_builder",
{
@@ -47,44 +34,24 @@ export const traceNativeUiAction = async (
},
signal === undefined ? {} : { signal },
),
analysis.execute("list_names", {}, signal === undefined ? {} : { signal }),
inspectNativeDispatch(analysis, 20_000, signal),
]);
if (!uiExecution.ok && uiExecution.error._tag === "AnalysisCancelledError")
return err(new AnalysisCancelledError("decode_interface_builder"));
if (!namesExecution.ok) return err(namesExecution.error);
const parsedNames = nativeNameSchema.safeParse(namesExecution.value.result);
if (!parsedNames.success)
return err(uiExecution.error);
if (!metadataExecution.ok) return err(metadataExecution.error);
const metadata = nativeDispatchMetadataResultSchema.parse(
metadataExecution.value,
);
const targetSha256 = metadata.target_sha256;
if (targetSha256 === null)
return err(
new AnalysisOutputError(
"list_names",
"provider returned an invalid name inventory",
),
);
const targetSha256 = namesExecution.value.subject?.sha256;
if (targetSha256 === undefined)
return err(
new AnalysisOutputError(
"list_names",
"provider did not bind the name inventory to a target SHA-256",
"inspect_native_dispatch_metadata",
"Dispatch metadata lacks target identity",
),
);
const metadata = nativeDispatchMetadataResultSchema.parse({
target_sha256: targetSha256,
provider: namesExecution.value.provider,
analysis_profile_digest:
namesExecution.value.analysisProfile?.digest ?? null,
result: inspectNativeDispatchMetadata(
parsedNames.data.map((entry) => ({
address: entry.address,
name: "name" in entry ? entry.name : entry.value,
})),
parsedNames.data.length,
),
});
let graph = emptyGraph(targetSha256, namesExecution.value.provider);
let graph = emptyGraph(targetSha256, metadata.provider);
const coverage = [];
if (uiExecution.ok) {
const decoded = interfaceBuilderAnalysisSchema.safeParse(
@@ -136,7 +103,44 @@ export const traceNativeUiAction = async (
});
}
const selected = resolveUiAction(graph, input.action);
let selected = resolveUiAction(graph, input.action);
if (selected.nodeId === null && selected.candidates.length === 0) {
const native = await analysis.execute(
"procedure_address",
{ procedure: input.action },
signal === undefined ? {} : { signal },
);
if (!native.ok && native.error._tag === "AnalysisCancelledError")
return err(native.error);
if (native.ok) {
const address = z.string().safeParse(native.value.result);
if (!address.success || native.value.subject?.sha256 !== targetSha256)
return err(
new AnalysisOutputError(
"procedure_address",
"Native seed lacks a validated address or matching target identity",
),
);
const id = `native:function:${address.data}`;
graph.nodes.push({
id,
kind: "function",
name: input.action,
location: { address: address.data, file_offset: null },
attributes: { resolved_by: native.value.provider.id },
evidence: [
{
kind: "reference",
description: `Provider resolved the explicit native seed ${input.action}`,
location: { address: address.data, file_offset: null },
artifact_path: null,
artifact_sha256: targetSha256,
},
],
});
selected = { nodeId: id, candidates: [], reason: null };
}
}
if (selected.nodeId === null) {
coverage.push(...graph.coverage);
const trace = {
@@ -152,7 +156,7 @@ export const traceNativeUiAction = async (
reason: selected.reason,
coverage,
limitations: [
"The query did not identify one unique authored UI action; no handler path was inferred.",
"The query did not identify one unique authored UI action or native function; no handler path was inferred.",
],
};
return ok(
@@ -179,9 +183,10 @@ export const traceNativeUiAction = async (
});
if (expanded.error !== undefined) return err(expanded.error);
coverage.push({
facet: "direct_native_calls",
facet: "static_native_calls",
status: "partial",
reason: "resolved_direct_calls_do_not_cover_all_indirect_dispatch",
reason:
"typed_direct_and_indirect_references_do_not_cover_all_runtime_dispatch; untyped_provider_callees_remain_inferred",
examined: expanded.examined,
omitted: expanded.omitted,
});
@@ -390,10 +395,10 @@ const addDirectCallees = async (input: {
omitted += queue.length - queueIndex + 1;
break;
}
const execution = await input.analysis.execute(
"procedure_callees",
{ procedure: current.address },
input.signal === undefined ? {} : { signal: input.signal },
const execution = await readNativeCallRoutes(
input.analysis,
current.address,
input.signal,
);
examined += 1;
if (!execution.ok) {
@@ -414,35 +419,45 @@ const addDirectCallees = async (input: {
});
continue;
}
const related = parseRelatedAddresses(execution.value.result, "callees");
if (!related.ok) {
const id = `unknown-callees:${current.address}`;
for (const unknown of execution.value.unknowns) {
if (edges.size >= input.input.max_edges) {
truncated = true;
omitted++;
break;
}
const id = `unknown-dispatch:${current.address}:${unknown.address}`;
edges.set(id, {
id,
from: current.node.id,
to: null,
relation: "direct_call",
relation: "indirect_call",
resolution: "unresolved",
reason: "provider_returned_unreadable_callee_relationships",
evidence: [],
limitations: [
"The provider result could not be normalized into callee addresses.",
reason: unknown.reason,
evidence: [
{
kind: "reference",
description: unknown.reason,
location: { address: unknown.address, file_offset: null },
artifact_path: null,
artifact_sha256: input.targetSha256,
},
],
limitations: [],
});
continue;
}
for (const [index, address] of related.value.entries()) {
for (const [index, route] of execution.value.routes.entries()) {
const address = route.address;
if (edges.size >= input.input.max_edges) {
truncated = true;
omitted += related.value.length - index;
omitted += execution.value.routes.length - index;
break;
}
const targetId = `native:function:${address}`;
const evidence = [
{
kind: "reference" as const,
description: `${execution.value.provider.name} resolved a direct callee from ${current.address} to ${address}`,
location: { address: current.address, file_offset: null },
description: `${execution.value.provider.name} reported ${route.relation} from ${current.address} to ${address}`,
location: { address: route.site, file_offset: null },
artifact_path: null,
artifact_sha256: input.targetSha256,
},
@@ -476,13 +491,13 @@ const addDirectCallees = async (input: {
nodes.set(targetId, node);
queue.push({ node, address, depth: current.depth + 1 });
}
const edgeId = `call:${current.address}:${address}`;
const edgeId = `call:${current.address}:${route.site}:${address}`;
edges.set(edgeId, {
id: edgeId,
from: current.node.id,
to: targetId,
relation: "direct_call",
resolution: "observed",
relation: route.relation,
resolution: route.resolution,
evidence,
limitations: [
"A resolved static call edge does not establish runtime reachability.",
+194
View File
@@ -0,0 +1,194 @@
import { describe, expect, it } from "vitest";
import { traceNativeValues } from "./NativeValueTrace.js";
import {
createAnalysisExecution,
type AnalysisOperationPort,
} from "./AnalysisProvider.js";
import { ghidraFunctionDossier } from "../domain/hopperValues.fixture.js";
import { functionDossierSchema } from "../domain/hopperValues.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { AnalysisCancelledError } from "../domain/errors.js";
import { ok, err } from "../domain/result.js";
const subject = {
path: "/fixture",
sha256: "a".repeat(64),
format: "mach-o" as const,
architecture: "arm64" as const,
};
const provider = { id: "fixture", name: "Fixture", version: "1" };
const scalar = {
kind: "constant",
size_bytes: 4,
location: null,
constant_hex: "3",
};
const node = (id: string, address: string, opcode: string) => ({
id,
address,
sequence: 0,
opcode,
is_dead: true,
block_membership: "member",
inputs: [scalar, scalar],
output: scalar,
});
const analysis: AnalysisOperationPort = {
execute: async (operation, parameters) => {
if (operation === "resolve_native_call_targets")
return ok(
createAnalysisExecution(
{
call_site: "0x1000",
procedure: "0x1000",
status: "direct",
mechanism: "direct",
targets: [
{
address: "0x2000",
procedure: "0x2000",
status: "direct",
basis: "provider-reference",
references: [],
},
],
limitations: [],
},
provider,
{ subject },
),
);
const callee = parameters.procedure === "0x2000";
return ok(
createAnalysisExecution(
jsonValueSchema.parse({
...functionDossierSchema.parse(ghidraFunctionDossier()),
procedure: {
address: callee ? "0x2000" : "0x1000",
name: callee ? "callee" : "caller",
classification: null,
signature: null,
locals: [],
},
native_value_flow: {
available: true,
provenance: "ghidra-high-pcode",
operations: callee
? [node("return", "0x2000", "RETURN")]
: [node("call", "0x1000", "CALL")],
def_use: [],
effects: [],
parameters: callee
? [{ ordinal: 0, name: "value", data_type: "/int" }]
: [],
parameter_uses: callee
? [{ ordinal: 0, use: "return", input_index: 1 }]
: [],
truncated: false,
omitted_operations_lower_bound: 0,
known_omitted_inputs: 0,
known_omitted_edges: 0,
limitations: [],
},
}),
provider,
{ subject },
),
);
},
};
describe("bounded native value dependency composition", () => {
it("reports work truncation and stable pagination without traversing ambiguous calls", async () => {
const bounded = await traceNativeValues(analysis, {
procedure: "0x1000",
max_functions: 1,
});
expect(bounded.ok).toBe(true);
if (bounded.ok)
expect(bounded.value).toMatchObject({
truncated: true,
decompilations: 1,
});
const page = await traceNativeValues(analysis, {
procedure: "0x1000",
limit: 1,
});
expect(page.ok).toBe(true);
if (page.ok)
expect(page.value).toMatchObject({ total_nodes: 3, next_offset: 1 });
const ambiguous = await traceNativeValues(
{
execute: async (operation, parameters, options) => {
if (operation !== "resolve_native_call_targets")
return analysis.execute(operation, parameters, options);
return ok(
createAnalysisExecution(
{
call_site: "0x1000",
procedure: "0x1000",
status: "ambiguous",
mechanism: "computed",
targets: [
{
address: "0x2000",
procedure: "0x2000",
status: "candidate",
basis: "provider-reference",
references: [],
},
],
limitations: [],
},
provider,
{ subject },
),
);
},
},
{ procedure: "0x1000" },
);
expect(ambiguous.ok).toBe(true);
if (ambiguous.ok)
expect(ambiguous.value).toMatchObject({
decompilations: 1,
edges: [expect.objectContaining({ status: "candidate" })],
});
});
it("propagates cancellation and rejects cross-target dependencies", async () => {
const controller = new AbortController();
controller.abort();
expect(
await traceNativeValues(
analysis,
{ procedure: "0x1000" },
controller.signal,
),
).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } });
const cancelled = await traceNativeValues(
{
execute: async () =>
err(new AnalysisCancelledError("analyze_function")),
},
{ procedure: "0x1000" },
);
expect(cancelled.ok).toBe(false);
const mismatch = await traceNativeValues(
{
execute: async (operation, parameters, options) => {
const value = await analysis.execute(operation, parameters, options);
return value.ok && parameters.procedure === "0x2000"
? ok({
...value.value,
subject: { ...subject, sha256: "b".repeat(64) },
})
: value;
},
},
{ procedure: "0x1000" },
);
expect(mismatch).toMatchObject({
ok: false,
error: { _tag: "AnalysisOutputError" },
});
});
});
+389
View File
@@ -0,0 +1,389 @@
import { functionDossierSchema } from "../domain/hopperValues.js";
import { nativeCallTargetsSchema } from "../domain/nativeInstruction.js";
import {
nativeValueTraceInputSchema,
nativeValueTraceSchema,
} from "../domain/nativeValueTrace.js";
import { createEvidence } from "../domain/evidence.js";
import {
AnalysisCancelledError,
AnalysisOutputError,
} from "../domain/errors.js";
import { err, ok } from "../domain/result.js";
import type { AnalysisOperationPort } from "./AnalysisProvider.js";
import type { EnhancedResult } from "./EnhancedToolTypes.js";
/** Compose bounded high-p-code def-use graphs across exactly referenced call destinations. */
export const traceNativeValues = async (
analysis: AnalysisOperationPort,
parameters: unknown,
signal?: AbortSignal,
): EnhancedResult => {
const input = nativeValueTraceInputSchema.parse(parameters);
const nodes: typeof nativeValueTraceSchema._output.nodes = [];
const edges: typeof nativeValueTraceSchema._output.edges = [];
const procedures: typeof nativeValueTraceSchema._output.procedures = [];
const unknowns: typeof nativeValueTraceSchema._output.unknowns = [];
const callBindings: {
caller: string;
callee: string;
call: string;
argument_count: number;
output: boolean;
evidence_id: string;
}[] = [];
const pending = [{ procedure: input.procedure, depth: 0 }];
const visited = new Set<string>();
let targetSha256: string | undefined;
let truncated = false;
let decompilations = 0;
let callSites = 0;
let bytes = 0;
while (pending.length > 0) {
if (signal?.aborted)
return err(new AnalysisCancelledError("trace_native_values"));
const next = pending.shift();
if (next === undefined || visited.has(next.procedure)) continue;
if (decompilations >= input.max_functions) {
truncated = true;
unknowns.push({
procedure: next.procedure,
address: null,
reason: "decompilation budget reached",
});
break;
}
const execution = await analysis.execute(
"analyze_function",
{ procedure: next.procedure },
signal === undefined ? {} : { signal },
);
decompilations++;
if (!execution.ok) {
if (execution.error._tag === "AnalysisCancelledError")
return err(execution.error);
unknowns.push({
procedure: next.procedure,
address: null,
reason: execution.error.message,
});
continue;
}
const parsed = functionDossierSchema.safeParse(execution.value.result);
if (!parsed.success || execution.value.subject === null)
return err(
new AnalysisOutputError(
"trace_native_values",
"Function dossier lacks validated facts or artifact identity",
),
);
const dossier = parsed.data;
const address = dossier.procedure.address;
if (visited.has(address)) continue;
visited.add(next.procedure);
visited.add(address);
targetSha256 ??= execution.value.subject.sha256;
if (execution.value.subject.sha256 !== targetSha256)
return err(
new AnalysisOutputError(
"trace_native_values",
"Cross-function target digest mismatch",
),
);
const evidence = createEvidence(
execution.value.subject,
execution.value.provider,
{
operation: "analyze_function",
parameters: { procedure: next.procedure },
result: execution.value.result,
rawResult: execution.value.rawResult,
limitations: execution.value.limitations,
locations: execution.value.locations,
...(execution.value.analysisProfile === undefined
? {}
: { analysisProfile: execution.value.analysisProfile }),
},
);
const evidenceId = evidence.evidence_id;
procedures.push({
address,
depth: next.depth,
evidence_id: evidenceId,
provider: execution.value.provider.id,
analysis_profile_digest: execution.value.analysisProfile?.digest ?? null,
});
const flow = dossier.native_value_flow;
if (flow === null || !flow.available) {
unknowns.push({
procedure: address,
address: null,
reason: flow?.reason ?? "Provider has no native def-use model",
});
continue;
}
if (flow.truncated) {
truncated = true;
unknowns.push({
procedure: address,
address: null,
reason: "Provider p-code was truncated",
});
}
const ids = new Set<string>();
const qualified = (id: string) => `${address}/${id}`;
for (const operation of flow.operations) {
if (
operation.block_membership === "detached" ||
(operation.block_membership === "unavailable" && operation.is_dead)
)
continue;
const node = {
id: qualified(operation.id),
procedure: address,
evidence_id: evidenceId,
operation,
kind: "operation" as const,
parameter: null,
};
const size = Buffer.byteLength(JSON.stringify(node));
if (nodes.length >= input.max_nodes || bytes + size > 8 * 1024 * 1024) {
truncated = true;
break;
}
bytes += size;
ids.add(operation.id);
nodes.push(node);
}
for (const parameter of flow.parameters) {
if (nodes.length >= input.max_nodes) {
truncated = true;
break;
}
nodes.push({
id: `${address}/parameter:${parameter.ordinal}`,
procedure: address,
evidence_id: evidenceId,
kind: "parameter",
operation: null,
parameter,
});
}
for (const use of flow.parameter_uses) {
if (
!ids.has(use.use) ||
!nodes.some((node) => node.id === `${address}/parameter:${use.ordinal}`)
)
continue;
if (edges.length >= input.max_edges) {
truncated = true;
break;
}
edges.push({
source: `${address}/parameter:${use.ordinal}`,
target: qualified(use.use),
input_index: use.input_index,
kind: "parameter-use",
status: "derived",
evidence_id: evidenceId,
});
}
for (const edge of flow.def_use) {
if (!ids.has(edge.definition) || !ids.has(edge.use)) continue;
if (edges.length >= input.max_edges) {
truncated = true;
break;
}
edges.push({
source: qualified(edge.definition),
target: qualified(edge.use),
input_index: edge.input_index,
kind: "def-use",
status: "derived",
evidence_id: evidenceId,
});
}
for (const operation of flow.operations) {
if (
!ids.has(operation.id) ||
!["CALL", "CALLIND"].includes(operation.opcode)
)
continue;
if (callSites >= input.max_call_sites) {
truncated = true;
unknowns.push({
procedure: address,
address: operation.address,
reason: "Call-site resolution budget reached",
});
break;
}
callSites++;
const resolved = await analysis.execute(
"resolve_native_call_targets",
{ address: operation.address },
signal === undefined ? {} : { signal },
);
if (!resolved.ok) {
if (resolved.error._tag === "AnalysisCancelledError")
return err(resolved.error);
unknowns.push({
procedure: address,
address: operation.address,
reason: resolved.error.message,
});
continue;
}
const targets = nativeCallTargetsSchema.safeParse(resolved.value.result);
if (!targets.success)
return err(
new AnalysisOutputError(
"resolve_native_call_targets",
"Invalid static call resolution",
),
);
if (targets.data.targets.length === 0)
unknowns.push({
procedure: address,
address: operation.address,
reason: `Call target ${targets.data.status}`,
});
for (const target of targets.data.targets) {
if (edges.length >= input.max_edges) {
truncated = true;
break;
}
const destination = target.procedure ?? target.address;
edges.push({
source: qualified(operation.id),
target: destination,
input_index: null,
kind: "call",
status: target.status,
evidence_id: evidenceId,
});
if (target.status !== "candidate")
callBindings.push({
caller: address,
callee: destination,
call: qualified(operation.id),
argument_count: Math.max(0, operation.inputs.length - 1),
output: operation.output !== null,
evidence_id: evidenceId,
});
if (next.depth < input.max_depth && target.status !== "candidate")
pending.push({ procedure: destination, depth: next.depth + 1 });
else if (!visited.has(destination)) {
truncated = true;
unknowns.push({
procedure: address,
address: operation.address,
reason:
target.status === "candidate"
? "Ambiguous call candidates were not traversed"
: "Call depth budget reached",
});
}
}
}
if (
truncated &&
(nodes.length >= input.max_nodes ||
edges.length >= input.max_edges ||
bytes >= 8 * 1024 * 1024)
)
break;
}
if (targetSha256 === undefined)
return err(
new AnalysisOutputError(
"trace_native_values",
"No seed function could be analyzed",
),
);
for (const binding of callBindings) {
const parameters = nodes.filter(
(node) => node.procedure === binding.callee && node.kind === "parameter",
);
if (parameters.length === 0)
unknowns.push({
procedure: binding.caller,
address: null,
reason: `Parameter bindings for ${binding.callee} are unavailable or omitted`,
});
for (const node of parameters) {
const ordinal = node.parameter?.ordinal;
if (ordinal === undefined || ordinal >= binding.argument_count) {
unknowns.push({
procedure: binding.caller,
address: null,
reason: `Argument ${ordinal ?? "unknown"} for ${binding.callee} was not recovered`,
});
continue;
}
if (edges.length >= input.max_edges) {
truncated = true;
break;
}
edges.push({
source: binding.call,
target: node.id,
input_index: ordinal + 1,
kind: "argument-binding",
status: "derived",
evidence_id: binding.evidence_id,
});
}
if (binding.output)
for (const node of nodes.filter(
(node) =>
node.procedure === binding.callee &&
node.operation?.opcode === "RETURN" &&
node.operation.inputs.length > 1,
)) {
if (edges.length >= input.max_edges) {
truncated = true;
break;
}
edges.push({
source: node.id,
target: binding.call,
input_index: 1,
kind: "return-binding",
status: "derived",
evidence_id: node.evidence_id,
});
}
}
const page = nodes.slice(input.offset, input.offset + input.limit);
const pageIds = new Set(page.map(({ id }) => id));
const result = nativeValueTraceSchema.parse({
seed: input.procedure,
target_sha256: targetSha256,
nodes: page,
edges: edges.filter(({ source }) => pageIds.has(source)),
procedures,
unknowns,
total_nodes: nodes.length,
total_edges: edges.length,
decompilations,
next_offset:
input.offset + page.length < nodes.length
? input.offset + page.length
: null,
truncated: truncated || page.length !== nodes.length,
limitations: [
"Static decompiler def-use graphs cover recovered block members; when membership is unavailable they exclude operations marked dead. Logical CALL argument positions bind to recovered HighParam ordinals and RETURN values bind to recovered CALL outputs; these are decompiler-derived dependencies, not original source or runtime observations. Missing parameters, variadic arguments and alias dependencies remain unknown.",
"LOAD and STORE are memory operations; object fields, persistent state and RNG semantics are not inferred from names or pseudocode.",
"Traversal caps depth, decompilations, call-site resolutions, nodes, edges and node payloads (8 MiB). Complete serialized output is bounded to 32 MiB. Pagination reruns analysis and returns edges whose source is on the node page; endpoints outside the page retain stable identities.",
],
});
if (Buffer.byteLength(JSON.stringify(result)) > 32 * 1024 * 1024)
return err(
new AnalysisOutputError(
"trace_native_values",
"Graph output exceeds 32 MiB; lower max_nodes, max_edges or page limit",
),
);
return ok(result);
};
+3 -1
View File
@@ -394,8 +394,10 @@ export const captureTerminalFrames = (options: {
at_ms: atMs,
data: normalized,
});
options.recordEvent("frames", sequence);
// Queue the observed output before publishing it: reactive subscribers may
// synchronously resize the terminal in response to this journal entry.
options.renderer.write(data, atMs);
options.recordEvent("frames", sequence);
options.checkpoints.observeTerminal(normalized);
});
return () => truncated;
@@ -43,8 +43,13 @@ it("returns detached terminal and filesystem checkpoint observations", async ()
expect(frames).toHaveLength(22);
expect(frames.slice(0, 2).map(({ at_ms }) => at_ms)).toEqual([20, 10]);
expect(observed).toHaveLength(22);
if (frames[0] !== undefined) Reflect.set(frames[0], "cursor_x", 999);
expect((await renderer.frames())[0]?.cursor_x).not.toBe(999);
const frame = frames[0];
if (frame === undefined) throw new Error("expected observed frame");
const originalCursor = frame.cursor_x;
Reflect.set(frame, "cursor_x", 999);
const reread = await renderer.frames();
expect(reread).toHaveLength(frames.length);
expect(reread[0]?.cursor_x).toBe(originalCursor);
await renderer.dispose();
const scenario = parseProcessScenario(base);
@@ -55,7 +60,9 @@ it("returns detached terminal and filesystem checkpoint observations", async ()
{ signal: undefined },
);
const first = await checkpoints.finish({ files: [], truncated: false });
if (first[0] !== undefined) Reflect.set(first[0], "name", "tampered");
const checkpoint = first[0];
if (checkpoint === undefined) throw new Error("expected checkpoint");
Reflect.set(checkpoint, "name", "tampered");
const second = await checkpoints.finish({ files: [], truncated: false });
expect(second[0]?.name).toBe("before");
await checkpoints.dispose();
@@ -276,7 +283,7 @@ it("cancels filesystem snapshots before traversing declared roots", async () =>
});
it("parses bounded scenarios and rejects unordered events", () => {
expect(parseProcessScenario(base).timeout_ms).toBe(30_000);
const timeoutMs = parseProcessScenario(base).timeout_ms;
expect(() =>
parseProcessScenario({
...base,
@@ -295,7 +302,7 @@ it("parses bounded scenarios and rejects unordered events", () => {
expect(() =>
parseProcessScenario({
...base,
events: [{ type: "input", at_ms: 31_000, data: "late" }],
events: [{ type: "input", at_ms: timeoutMs + 1, data: "late" }],
}),
).toThrow(/after the scenario timeout/);
});
@@ -1,16 +0,0 @@
import { describe, expect, it } from "vitest";
import { completeReconstructionCoverageData } from "../domain/reconstructionCoverage.fixture.js";
import { reconstructionCoverageEvaluationInputSchema } from "./ReconstructionCoverageService.js";
describe("reconstruction coverage service input", () => {
it("accepts caller boundary identifiers longer than the former schema ceiling", () => {
const boundaryId = "b".repeat(1_000);
const parsed = reconstructionCoverageEvaluationInputSchema.parse({
coverage: completeReconstructionCoverageData(),
boundary_id: boundaryId,
});
expect(parsed.boundary_id).toBe(boundaryId);
});
});
@@ -12,7 +12,6 @@ import { jsonValueSchema } from "../domain/jsonValue.js";
import { processCaptureSchema } from "../domain/processCapture.js";
import {
reconstructionObligationLedgerSchema,
serializeReconstructionObligationLedger,
type ReconstructionObligationLedgerInput,
type ReconstructionObligationLedger,
type ReviewedReconstructionObligation,
@@ -153,13 +152,8 @@ describe("reconstruction obligation ledger", () => {
);
expect(first.obligations).toHaveLength(2);
expect(first).toEqual(second);
expect(second.ledger_id).toBe(first.ledger_id);
expect(second.closure_digest).toBe(first.closure_digest);
expect(first.obligations[0]?.source_state).toBe("reviewed");
expect(first.status).toBe("open");
expect(serializeReconstructionObligationLedger(first)).toBe(
serializeReconstructionObligationLedger(second),
);
});
it("does not let green unit evidence close a packaged-process obligation", () => {
-1
View File
@@ -92,7 +92,6 @@ describe("setup workflow", () => {
expect(result.status).toBe("needs_human");
expect(host.hopperInstalls).toBe(0);
expect(result.appliedActions).toEqual(["installed_skill"]);
expect(result.remediation).toContain("--install-hopper");
expect(result.remediation).toBe(
"Hopper is optional for non-Hopper providers. Rerun with --yes --install-hopper for deep native analysis.",
);
+1 -2
View File
@@ -26,7 +26,6 @@ describe("setup installer failure projection", () => {
] as const)("maps %s to %s", (reason, code) => {
const result = setupInstallFailure(reason as HopperInstallFailureReason);
expect(result).toMatchObject({ status: "failed", code });
if (result.status === "failed")
expect(result.remediation.length).toBeGreaterThan(20);
expect(result).toMatchObject({ remediation: expect.stringMatching(/\S/u) });
});
});
+6 -1
View File
@@ -6,6 +6,7 @@ import { silentLogger, type Logger } from "../logger.js";
import { GENERATED_AUXILIARY_PROVIDERS } from "../generatedMcpToolCatalog.js";
import { AnalysisProviderRegistry } from "./AnalysisProviderRegistry.js";
import { composeBinarySession } from "./BinarySessionComposition.js";
import { nativeHostCapabilities } from "../native/NativeHostCapabilities.js";
import { LazyAnalysisProvider } from "./LazyAnalysisProvider.js";
/**
@@ -32,7 +33,11 @@ export const createBinarySession = (
const generated = auxiliary.get(id);
if (generated === undefined)
throw new TypeError(`Missing generated provider metadata for ${id}`);
return new LazyAnalysisProvider({ ...generated, load });
return new LazyAnalysisProvider({
...generated,
capabilities: nativeHostCapabilities(generated.capabilities),
load,
});
};
return composeBinarySession(
new AnalysisProviderRegistry([hopper, ghidra], config.analysisProvider),
+118 -24
View File
@@ -7,9 +7,12 @@ import {
type ProviderIdentity,
type ExecutionOptions,
} from "../application/AnalysisProvider.js";
import { inspectBundleKeyedArchive } from "./KeyedArchiveReader.js";
import { basename, dirname } from "node:path";
import { inventoryArtifact } from "../application/ArtifactInventory.js";
import { extractArtifact } from "../application/ArtifactExtraction.js";
import { analyzeInterfaceBuilderBundle } from "../application/InterfaceBuilderAnalysis.js";
import { analyzeAppleAssetCatalogs } from "../application/AppleAssetCatalogAnalysis.js";
import {
ARTIFACT_ANALYSIS_OPERATIONS,
artifactInventoryInputSchema,
@@ -38,34 +41,47 @@ const IDENTITY: ProviderIdentity = Object.freeze(ARTIFACT_GRAPH_PROVIDER);
/** Read-only inventory and exclusively owned extraction provider. */
export class ArtifactProvider implements AnalysisProvider {
readonly #capabilities: readonly CapabilityDescriptor[];
constructor(
private readonly nativeMountEnabled = false,
private readonly integrityContinueEnabled = false,
) {}
readonly #capabilities: readonly CapabilityDescriptor[] = Object.freeze(
ARTIFACT_ANALYSIS_OPERATIONS.map((operation) =>
Object.freeze({
provider: IDENTITY,
operation,
available: true as const,
reason: null,
effects: Object.freeze({
mutatesArtifact: false,
launchesProcess: operation !== "decode_interface_builder",
mayShowUi: false,
mayAccessNetwork: false,
mayWriteFilesystem: operation === "extract_artifact",
changesPermissions: false,
requiresRoot: false,
}),
limitations: Object.freeze([
"DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.",
"ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only.",
]),
platform: NodeJS.Platform = process.platform,
) {
this.#capabilities = Object.freeze(
ARTIFACT_ANALYSIS_OPERATIONS.map((operation) => {
const common = {
provider: IDENTITY,
operation,
effects: Object.freeze({
mutatesArtifact: false,
launchesProcess:
operation !== "decode_interface_builder" &&
operation !== "inspect_keyed_archive",
mayShowUi: false,
mayAccessNetwork: false,
mayWriteFilesystem: operation === "extract_artifact",
changesPermissions: false,
requiresRoot: false,
}),
limitations: Object.freeze([
"DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.",
"ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only.",
]),
};
return Object.freeze(
operation === "inspect_asset_catalog" && platform !== "darwin"
? {
...common,
available: false as const,
availabilityCode: "unsupported_host" as const,
reason: "Apple asset catalogs require macOS assetutil.",
}
: { ...common, available: true as const, reason: null },
);
}),
),
);
);
}
identity(): ProviderIdentity {
return IDENTITY;
@@ -138,6 +154,53 @@ class ArtifactClient implements AnalysisClient {
}),
);
}
if (operation === "inspect_keyed_archive") {
const standalone =
this.target.kind === "artifact" && this.target.format === "plist";
if (
standalone &&
parameters.path !== undefined &&
parameters.path !== "." &&
parameters.path !== basename(this.target.path)
)
throw new ArtifactReaderFailure(
"path",
"For an active plist, path must select that archive (omit path or use its basename)",
);
const bundlePath = standalone
? dirname(this.target.path)
: this.target.sourcePath;
if (
bundlePath === undefined ||
(!standalone && !bundlePath.toLowerCase().endsWith(".app"))
)
throw new ArtifactReaderFailure(
"unavailable",
"inspect_keyed_archive requires an active plist or .app bundle",
);
const result = await inspectBundleKeyedArchive({
bundlePath,
targetSha256: this.target.sha256,
parameters: standalone
? { ...parameters, path: basename(this.target.path) }
: parameters,
...(options?.signal === undefined ? {} : { signal: options.signal }),
});
if (standalone && result.archive_sha256 !== this.target.sha256)
throw new ArtifactReaderFailure(
"integrity",
`Active archive digest changed: expected ${this.target.sha256}, observed ${result.archive_sha256}`,
);
return ok(
createAnalysisExecution(result, IDENTITY, {
limitations: result.limitations,
locations: [{ kind: "artifact-path", path: result.archive_path }],
}),
);
}
if (operation === "inspect_asset_catalog") {
return await this.inspectAssetCatalog(parameters, options);
}
if (operation === "extract_artifact") {
const parsed = artifactExtractionExecutionSchema.parse(parameters);
const result = await extractArtifact(
@@ -188,6 +251,37 @@ class ArtifactClient implements AnalysisClient {
return Promise.resolve();
}
private async inspectAssetCatalog(
parameters: Readonly<Record<string, JsonValue>>,
options?: ExecutionOptions,
) {
const bundlePath = this.target.sourcePath;
if (
this.target.kind !== "executable" ||
bundlePath === undefined ||
!bundlePath.toLowerCase().endsWith(".app")
)
throw new ArtifactReaderFailure(
"unavailable",
"inspect_asset_catalog requires an active .app bundle target",
);
const result = await analyzeAppleAssetCatalogs({
bundlePath,
targetSha256: this.target.sha256,
page: parameters,
...(options?.signal === undefined ? {} : { signal: options.signal }),
});
return ok(
createAnalysisExecution(result, IDENTITY, {
limitations: result.limitations,
locations: result.catalogs.map(({ path }) => ({
kind: "artifact-path" as const,
path,
})),
}),
);
}
private async inspectArtifact(
parameters: Readonly<Record<string, JsonValue>>,
options?: ExecutionOptions,
+2
View File
@@ -38,6 +38,8 @@ export class ArtifactReaderFailure extends Error {
| "cancelled"
| "format"
| "integrity"
| "io"
| "limit"
| "path"
| "policy"
| "unavailable",
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import { build, buildBinary } from "plist";
import { decodeKeyedArchiveBytes } from "./KeyedArchiveReader.js";
const archive = {
$archiver: "NSKeyedArchiver",
$version: 100000,
$objects: [
"$null",
{
$class: { UID: 4 },
child: { UID: 2 },
shared: { UID: 2 },
self: { UID: 1 },
conditional: { UID: 0 },
broken: { UID: 90 },
},
{ "NS.objects": [{ UID: 1 }, { UID: 3 }] },
"value",
{ $classname: "UnknownModel", $classes: ["UnknownModel", "NSObject"] },
],
$top: { root: { UID: 1 }, other: { UID: 2 } },
};
describe("inert keyed archive decoding", () => {
it("preserves CF$UID, malformed references and original pagination identities", () => {
const bytes = Buffer.from(
build({
...archive,
$objects: [
"$null",
{ bad: { CF$UID: -1 }, valid: { CF$UID: 2 }, broken: { CF$UID: 90 } },
"value",
],
}),
);
const graph = decodeKeyedArchiveBytes(bytes, {
root: "root",
offset: 1,
limit: 1,
});
expect(graph.roots).toEqual({ root: { UID: 1 } });
expect(graph.objects.map(({ id }) => id)).toEqual([1]);
expect(graph.next_offset).toBe(2);
expect(graph.references).toContainEqual(
expect.objectContaining({ source: 1, target: 90, status: "unresolved" }),
);
expect(graph.references).toContainEqual(
expect.objectContaining({ source: 1, target: null, status: "malformed" }),
);
expect(graph.references).toContainEqual(
expect.objectContaining({ source: 1, target: 2, status: "resolved" }),
);
});
it("rejects missing roots, malformed plist, and non-keyed archives", () => {
expect(() =>
decodeKeyedArchiveBytes(Buffer.from("bplist00bad"), {
offset: 0,
limit: 1,
}),
).toThrow();
expect(() =>
decodeKeyedArchiveBytes(Buffer.from(build({ ...archive, $top: {} })), {
offset: 0,
limit: 1,
}),
).toThrow("roots");
expect(() =>
decodeKeyedArchiveBytes(Buffer.from(build({ plain: true })), {
offset: 0,
limit: 1,
}),
).toThrow("NSKeyedArchiver");
expect(() =>
decodeKeyedArchiveBytes(Buffer.from(buildBinary(archive)), {
root: "missing",
offset: 0,
limit: 1,
}),
).toThrow("does not exist");
});
});
+132
View File
@@ -0,0 +1,132 @@
import { createHash } from "node:crypto";
import { parse, parseBinary } from "plist";
import { z } from "zod";
import type { JsonValue } from "../domain/jsonValue.js";
import {
keyedArchiveInputSchema,
keyedArchiveResultSchema,
projectKeyedArchive,
} from "../domain/keyedArchive.js";
import { DirectoryArtifactReader } from "./DirectoryArtifactReader.js";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
const MAX_BYTES = 64 * 1024 * 1024;
/** Parse inert plist bytes, preserving data and dates with explicit typed values. */
export const decodeKeyedArchiveBytes = (
bytes: Buffer,
selection: { root?: string | undefined; offset: number; limit: number },
) => {
if (bytes.length > MAX_BYTES)
throw new RangeError("Keyed archive exceeds 64 MiB");
const binary = bytes.subarray(0, 8).toString("ascii") === "bplist00";
const parsed: unknown = binary
? parseBinary(bytes)
: parse(bytes.toString("utf8"));
return {
archive_format: binary ? ("binary-plist" as const) : ("xml-plist" as const),
...projectKeyedArchive(normalizePlist(parsed), selection),
};
};
const normalizePlist = (value: unknown, depth = 0): JsonValue => {
if (depth > 128) throw new RangeError("Plist exceeds 128 nesting levels");
if (value instanceof Uint8Array)
return {
$plist_type: "data",
base64: Buffer.from(value).toString("base64"),
};
if (value instanceof Date)
return { $plist_type: "date", iso8601: value.toISOString() };
if (Array.isArray(value))
return value.map((item: unknown) => normalizePlist(item, depth + 1));
if (value !== null && typeof value === "object")
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
key,
normalizePlist(item, depth + 1),
]),
);
return z
.union([z.string(), z.number().finite(), z.boolean(), z.null()])
.parse(value);
};
/** Read exactly one regular, contained bundle entry without following symlinks. */
export const inspectBundleKeyedArchive = async (input: {
bundlePath: string;
targetSha256: string;
parameters: unknown;
signal?: AbortSignal;
}) => {
const selected = keyedArchiveInputSchema.parse(input.parameters);
if (
selected.path.startsWith("/") ||
selected.path
.split("/")
.some((part) => part === ".." || part === "." || part.length === 0)
)
throw new ArtifactReaderFailure(
"path",
"Archive path must be a canonical relative bundle path",
);
const reader = new DirectoryArtifactReader(input.bundlePath);
try {
for await (const entry of reader.entries(input.signal)) {
if (entry.path !== selected.path) continue;
if (entry.kind !== "file")
throw new ArtifactReaderFailure(
"path",
"Archive must be a regular file, not a symlink or directory",
);
if ((entry.declaredSize ?? 0) > MAX_BYTES)
throw new ArtifactReaderFailure(
"limit",
"Keyed archive exceeds 64 MiB",
);
const stream = await reader.open(entry, input.signal);
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of stream) {
if (input.signal?.aborted) {
stream.destroy();
throw new ArtifactReaderFailure(
"cancelled",
"Archive inspection cancelled",
);
}
const bytes = z.instanceof(Buffer).parse(chunk);
size += bytes.length;
if (size > MAX_BYTES) {
stream.destroy();
throw new ArtifactReaderFailure(
"limit",
"Keyed archive exceeds 64 MiB",
);
}
chunks.push(bytes);
}
const bytes = Buffer.concat(chunks, size);
try {
return keyedArchiveResultSchema.parse({
target_sha256: input.targetSha256,
archive_path: entry.path,
archive_sha256: createHash("sha256").update(bytes).digest("hex"),
...decodeKeyedArchiveBytes(bytes, selected),
});
} catch (cause) {
throw new ArtifactReaderFailure(
cause instanceof RangeError ? "limit" : "format",
"Cannot decode selected Foundation keyed archive",
{ cause },
);
}
}
throw new ArtifactReaderFailure(
"path",
`Archive not found in bundle: ${selected.path}`,
);
} finally {
await reader.close();
}
};
@@ -189,28 +189,6 @@ class FakeSession implements BrowserScenarioSessionPort {
}
describe("PlaywrightBrowserScenarioProvider", () => {
it("captures initial and action steps and closes its launched session", async () => {
const session = new FakeSession("launch");
const provider = new PlaywrightBrowserScenarioProvider({
open: () => Promise.resolve(session),
});
const result = await provider.captureScenario(scenario());
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.steps.map(({ step_index }) => step_index)).toEqual([
0, 1,
]);
expect(result.value.browser).toMatchObject({
process_ownership: "provider-owned",
cleanup: "terminated-owned-process",
});
expect(result.value.completeness).toMatchObject({
status: "complete",
equality_eligible: true,
});
expect(session.closeCalls).toBe(1);
});
it("returns an initial state and every step beyond the former action ceiling", async () => {
const session = new FakeSession("launch");
const provider = new PlaywrightBrowserScenarioProvider({
@@ -39,31 +39,3 @@ it("returns a complete requested text artifact beyond the former inline ceiling"
value: { bytes: 16 * 1_024 * 1_024 + 1 },
});
});
it("wraps URL, history, and storage snapshots in the capture state contract", async () => {
const page = {
url: () => "https://app.example.test/path?token=secret",
evaluate: async (expression: string) =>
expression.includes("window.history.length")
? {
length: 2,
navigation_entries: [
{ type: "navigate", name: "https://app.example.test/path" },
],
}
: { local_storage: [], session_storage: [] },
} as unknown as Page;
const context = { cookies: async () => [] } as unknown as BrowserContext;
const result = await capturePlaywrightStepArtifacts({
context,
page,
scenario,
secrets,
requested: new Set(["url", "history", "storage"]),
});
expect(result.url.state).toBe("captured");
expect(result.history.state).toBe("captured");
expect(result.storage.state).toBe("captured");
});
+5 -1
View File
@@ -1,4 +1,4 @@
import { deflateSync } from "node:zlib";
import { crc32, deflateSync } from "node:zlib";
import { describe, expect, it } from "vitest";
@@ -111,5 +111,9 @@ const chunk = (type: string, data: Buffer): Buffer => {
result.writeUInt32BE(data.byteLength, 0);
result.write(type, 4, 4, "ascii");
data.copy(result, 8);
result.writeUInt32BE(
crc32(result.subarray(4, 8 + data.byteLength)),
8 + data.byteLength,
);
return result;
};
+77
View File
@@ -14,6 +14,83 @@ export const registerArtifactCommands = (
registerInspectionCommand(cli, logger);
registerExtractionCommand(cli, logger);
registerInterfaceBuilderCommand(cli, logger);
registerAssetCatalogCommand(cli, logger);
cli.command(CLI_COMMANDS.inspectKeyedArchive, {
description:
"Inspect a Foundation keyed archive as an object-reference graph",
args: z.object({
path: z.string().describe("Foundation plist archive or Apple app bundle"),
archive: z
.string()
.default(".")
.describe("Relative archive path when the target is a bundle"),
}),
options: z.object({
root: z.string().optional().describe("Named $top archive root"),
offset: z
.number()
.int()
.min(0)
.default(0)
.describe("First original object-table index"),
limit: z
.number()
.int()
.min(1)
.max(20000)
.default(20000)
.describe("Maximum object-table entries to return"),
}),
run: ({ args, options }) =>
logCliCommand(logger, CLI_COMMANDS.inspectKeyedArchive, () =>
runProviderAnalysis(
args.path,
"inspect_keyed_archive",
{
path: args.archive,
offset: options.offset,
limit: options.limit,
...(options.root === undefined ? {} : { root: options.root }),
},
logger,
),
),
});
};
const registerAssetCatalogCommand = (
cli: CliInstance,
logger: Logger,
): void => {
cli.command(CLI_COMMANDS.inspectAssetCatalog, {
description: "Inspect compiled Apple asset catalog metadata",
args: z.object({ path: z.string().describe("Apple .app bundle path") }),
options: z.object({
offset: z
.number()
.int()
.min(0)
.default(0)
.describe("First metadata record"),
limit: z
.number()
.int()
.min(1)
.max(10_000)
.default(1_000)
.describe("Maximum records to return"),
}),
alias: {},
run: ({ args, options }) =>
logCliCommand(logger, CLI_COMMANDS.inspectAssetCatalog, () =>
runProviderAnalysis(
args.path,
"inspect_asset_catalog",
{ offset: options.offset, limit: options.limit },
logger,
),
),
});
};
const registerInterfaceBuilderCommand = (
+135
View File
@@ -15,6 +15,141 @@ export const registerCoreAnalysisCommands = (
logger: Logger,
): void => {
registerCoreCommands(cli, logger);
cli.command(CLI_COMMANDS.traceNativeValues, {
description: "Trace bounded native def-use and call dependencies",
args: z.object({
path: z.string().describe("Local native executable or app path"),
procedure: z
.string()
.describe("Explicit native procedure symbol or address"),
}),
options: z.object({
maxDepth: z
.number()
.int()
.min(0)
.max(16)
.default(3)
.describe("Maximum call traversal depth"),
maxFunctions: z
.number()
.int()
.min(1)
.max(64)
.default(16)
.describe("Maximum function decompilations"),
maxCallSites: z
.number()
.int()
.min(1)
.max(4096)
.default(256)
.describe("Maximum static call-site resolutions"),
maxNodes: z
.number()
.int()
.min(1)
.max(20000)
.default(5000)
.describe("Maximum retained graph nodes"),
maxEdges: z
.number()
.int()
.min(1)
.max(40000)
.default(10000)
.describe("Maximum retained graph edges"),
offset: z
.number()
.int()
.min(0)
.default(0)
.describe("First graph node index"),
limit: z
.number()
.int()
.min(1)
.max(5000)
.default(1000)
.describe("Maximum nodes on this page"),
provider: providerSelectionOption,
}),
alias: {
maxDepth: "max-depth",
maxFunctions: "max-functions",
maxCallSites: "max-call-sites",
maxNodes: "max-nodes",
maxEdges: "max-edges",
},
run: ({ args, options }) =>
logCliCommand(logger, CLI_COMMANDS.traceNativeValues, () =>
runDirectAnalysis(
args.path,
"trace_native_values",
{
procedure: args.procedure,
max_depth: options.maxDepth,
max_functions: options.maxFunctions,
max_call_sites: options.maxCallSites,
max_nodes: options.maxNodes,
max_edges: options.maxEdges,
offset: options.offset,
limit: options.limit,
},
directAnalysisOptions(logger, undefined, options.provider),
),
),
});
cli.command(CLI_COMMANDS.inspectNativeDataType, {
description: "Inspect one recovered database type or typed data object",
args: z.object({
path: z.string().describe("Local native executable or app path"),
}),
options: z.object({
type: z.string().optional().describe("Exact database type category path"),
address: z.string().describe("Exact native address").optional(),
provider: providerSelectionOption,
}),
run: ({ args, options }) =>
logCliCommand(logger, CLI_COMMANDS.inspectNativeDataType, () =>
runDirectAnalysis(
args.path,
"inspect_native_data_type",
{
...(options.type === undefined ? {} : { type: options.type }),
...(options.address === undefined
? {}
: { address: options.address }),
},
directAnalysisOptions(logger, undefined, options.provider),
),
),
});
for (const [command, operation] of [
[CLI_COMMANDS.inspectNativeInstruction, "inspect_native_instruction"],
[CLI_COMMANDS.resolveNativeCallTargets, "resolve_native_call_targets"],
] as const) {
cli.command(command, {
description:
operation === "inspect_native_instruction"
? "Inspect one native instruction with decoded operand facts"
: "Resolve one static native call site",
args: z.object({
path: z.string().describe("Local native executable or app path"),
address: z.string().describe("Exact native address"),
}),
options: z.object({ provider: providerSelectionOption }),
run: ({ args, options }) =>
logCliCommand(logger, command, () =>
runDirectAnalysis(
args.path,
operation,
{ address: args.address },
directAnalysisOptions(logger, undefined, options.provider),
),
),
});
}
registerFunctionCommand(cli, logger);
registerNativeApiCommand(cli, logger);
registerInstructionsCommand(cli, logger);
+90
View File
@@ -1,4 +1,5 @@
import { z } from "incur";
import { jsonValueSchema } from "../domain/jsonValue.js";
import {
runCapabilityStatus,
@@ -21,6 +22,95 @@ export const registerUtilityCommands = (
): void => {
registerCapabilityCommands(cli, logger);
registerNativeCommands(cli, logger);
for (const [command, operation] of [
[CLI_COMMANDS.observeNativeUi, "observe_native_ui"],
[CLI_COMMANDS.captureNativeUiScenario, "capture_native_ui_scenario"],
] as const) {
cli.command(command, {
description:
"Observe or run an approved scenario in one exact native application window",
args: z.object({
path: z.string().describe("Local native executable or app path"),
}),
options: z.object({
pid: z
.number()
.int()
.positive()
.describe("Approved existing application PID"),
windowId: z
.number()
.int()
.positive()
.describe("Exact selected application window ID"),
observationApproved: z
.boolean()
.default(false)
.describe("Approve passive observation of this PID/window"),
actionsApproved: z
.boolean()
.default(false)
.describe("Independently approve scenario actions"),
restore: z
.enum(["leave-as-is"])
.optional()
.describe("Explicit choice to leave application state as-is"),
steps: z
.string()
.optional()
.describe("JSON array of approved declarative AX actions or waits"),
screenshot: z
.boolean()
.default(true)
.describe("Capture only the selected window"),
accessibility: z
.boolean()
.default(true)
.describe("Read the selected window accessibility tree"),
maxNodes: z
.number()
.int()
.min(1)
.max(2000)
.default(500)
.describe("Maximum accessibility nodes per capture"),
}),
alias: {
windowId: "window-id",
observationApproved: "observation-approved",
actionsApproved: "actions-approved",
maxNodes: "max-nodes",
},
run: ({ args, options }) =>
logCliCommand(logger, command, () =>
runProviderAnalysis(
args.path,
operation,
{
pid: options.pid,
window_id: options.windowId,
observation_approved: options.observationApproved,
screenshot: options.screenshot,
accessibility: options.accessibility,
max_nodes: options.maxNodes,
...(operation === "capture_native_ui_scenario"
? {
actions_approved: options.actionsApproved,
...(options.restore === undefined
? {}
: { restore: options.restore }),
steps:
options.steps === undefined
? []
: jsonValueSchema.parse(JSON.parse(options.steps)),
}
: {}),
},
logger,
),
),
});
}
registerReferenceSourceCommand(cli, logger);
};
+8
View File
@@ -21,6 +21,12 @@ export const CLI_COMMANDS = Object.freeze({
inspectArtifact: "inspect-artifact",
extractArtifact: "extract-artifact",
decodeInterfaceBuilder: "decode-interface-builder",
inspectKeyedArchive: "inspect-keyed-archive",
inspectNativeDataType: "inspect-native-data-type",
traceNativeValues: "trace-native-values",
inspectNativeInstruction: "inspect-native-instruction",
resolveNativeCallTargets: "resolve-native-call-targets",
inspectAssetCatalog: "inspect-asset-catalog",
inspectManagedArtifact: "inspect-managed-artifact",
inspectManagedMembers: "inspect-managed-members",
inspectManagedNativeBoundaries: "inspect-managed-native-boundaries",
@@ -29,6 +35,8 @@ export const CLI_COMMANDS = Object.freeze({
importManagedReconstruction: "import-managed-reconstruction",
planManagedRuntimeCorrelation: "plan-managed-runtime-correlation",
projectManagedApplicationGraph: "project-managed-application-graph",
observeNativeUi: "observe-native-ui",
captureNativeUiScenario: "capture-native-ui-scenario",
inspectMacho: "inspect-macho",
inspectSignature: "inspect-signature",
listArchitectures: "list-architectures",
+13 -12
View File
@@ -4,17 +4,18 @@ import { parseConfig } from "./config.js";
describe("capability flags", () => {
it("rejects values outside the exact true/false boundary", () => {
const result = parseConfig({
REA_PROCESS_CAPTURE_ENABLED: "typo",
REA_BROWSER_OBSERVE_ENABLED: "typo",
REA_BROWSER_SCENARIO_ENABLED: "typo",
REA_ELECTRON_OBSERVE_ENABLED: "typo",
REA_ELECTRON_AUTOMATE_ENABLED: "typo",
REA_V8_INSPECTOR_OBSERVE_ENABLED: "typo",
REA_JAVASCRIPT_REPLAY_ENABLED: "typo",
REA_MANAGED_RUNTIME_ENABLED: "typo",
});
expect(result.ok).toBe(false);
for (const key of [
"REA_PROCESS_CAPTURE_ENABLED",
"REA_BROWSER_OBSERVE_ENABLED",
"REA_BROWSER_SCENARIO_ENABLED",
"REA_ELECTRON_OBSERVE_ENABLED",
"REA_ELECTRON_AUTOMATE_ENABLED",
"REA_V8_INSPECTOR_OBSERVE_ENABLED",
"REA_JAVASCRIPT_REPLAY_ENABLED",
"REA_MANAGED_RUNTIME_ENABLED",
]) {
const result = parseConfig({ [key]: "typo" });
expect(result.ok, key).toBe(false);
}
});
});
-86
View File
@@ -1,86 +0,0 @@
import { describe, expect, it } from "vitest";
import { parseConfig } from "./config.js";
describe("configuration parsing integration", () => {
it("returns defaults when no environment variables are set", () => {
const result = parseConfig({});
expect(result.ok).toBe(true);
if (!result.ok) throw new Error("unreachable");
expect(result.value.hopperLauncherPath).toBe(
process.platform === "linux"
? "/opt/hopper/bin/Hopper"
: "/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
);
expect(result.value.hopperTargetPath).toBeUndefined();
expect(result.value.hopperTargetKind).toBe("executable");
expect(result.value.hopperLoaderArgs).toEqual([]);
expect(result.value.analysisProvider).toBe("auto");
});
it("parses a complete target-bound configuration", () => {
const result = parseConfig({
HOPPER_TARGET_PATH: "/usr/bin/true",
HOPPER_TARGET_KIND: "executable",
HOPPER_LOADER_ARGS_JSON: '["-l","Mach-O","--aarch64"]',
REA_ANALYSIS_PROVIDER: "hopper",
});
expect(result.ok).toBe(true);
if (!result.ok) throw new Error("unreachable");
expect(result.value.hopperTargetPath).toBe("/usr/bin/true");
expect(result.value.hopperTargetKind).toBe("executable");
expect(result.value.analysisProvider).toBe("hopper");
expect(result.value.hopperLoaderArgs).toEqual([
"-l",
"Mach-O",
"--aarch64",
]);
});
it("rejects target kind values outside the enum", () => {
const result = parseConfig({
HOPPER_TARGET_KIND: "archive",
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("unreachable");
expect(result.error.message).toContain("Invalid REA environment");
});
it("rejects invalid JSON in loader arguments", () => {
const result = parseConfig({
HOPPER_LOADER_ARGS_JSON: "not json",
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("unreachable");
expect(result.error.message).toContain("valid JSON");
});
it("rejects non-array types in loader arguments JSON", () => {
const result = parseConfig({
HOPPER_LOADER_ARGS_JSON: '"just a string"',
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("unreachable");
expect(result.error.message).toContain("array of strings");
});
it("allows overriding the launcher path while keeping other defaults", () => {
const result = parseConfig({
HOPPER_LAUNCHER_PATH: "/custom/hopper",
});
expect(result.ok).toBe(true);
if (!result.ok) throw new Error("unreachable");
expect(result.value.hopperLauncherPath).toBe("/custom/hopper");
expect(result.value.hopperTargetPath).toBeUndefined();
});
it("parses database target kind correctly", () => {
const result = parseConfig({
HOPPER_TARGET_PATH: "/path/to/file.hop",
HOPPER_TARGET_KIND: "database",
});
expect(result.ok).toBe(true);
if (!result.ok) throw new Error("unreachable");
expect(result.value.hopperTargetKind).toBe("database");
});
});
+22 -11
View File
@@ -302,6 +302,13 @@ describe("runtime permission configuration", () => {
});
describe("runtime target configuration", () => {
it("rejects invalid target kinds with actionable environment diagnostics", () => {
const result = parseConfig({ HOPPER_TARGET_KIND: "archive" });
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected invalid target kind");
expect(result.error.message).toContain("Invalid REA environment");
});
it("builds a no-network managed runtime ceiling only when enabled", () => {
const result = parseConfig({
REA_MANAGED_RUNTIME_ENABLED: "true",
@@ -361,6 +368,8 @@ describe("runtime target configuration", () => {
it("parses database kind and loader arguments", () => {
expect(
parseConfig({
HOPPER_LAUNCHER_PATH: "/custom/hopper",
REA_ANALYSIS_PROVIDER: "hopper",
HOPPER_TARGET_PATH: "/fixture/sample.hop",
HOPPER_TARGET_KIND: "database",
HOPPER_LOADER_ARGS_JSON: '["-l","FAT","--aarch64","-l","Mach-O"]',
@@ -368,10 +377,8 @@ describe("runtime target configuration", () => {
).toMatchObject({
ok: true,
value: {
hopperLauncherPath:
process.platform === "linux"
? "/opt/hopper/bin/Hopper"
: "/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
hopperLauncherPath: "/custom/hopper",
analysisProvider: "hopper",
hopperTargetPath: "/fixture/sample.hop",
hopperTargetKind: "database",
hopperLoaderArgs: ["-l", "FAT", "--aarch64", "-l", "Mach-O"],
@@ -389,15 +396,19 @@ describe("runtime target configuration", () => {
});
describe("runtime collection configuration", () => {
it.each(["not-json", "{}", '["ok",1]'])(
it.each(["not-json", "{}", '"just a string"', '["ok",1]'])(
"rejects invalid loader args: %s",
(encoded) => {
expect(
parseConfig({
HOPPER_TARGET_PATH: "/tmp/a",
HOPPER_LOADER_ARGS_JSON: encoded,
}).ok,
).toBe(false);
const result = parseConfig({
HOPPER_TARGET_PATH: "/tmp/a",
HOPPER_LOADER_ARGS_JSON: encoded,
});
expect(result.ok).toBe(false);
if (result.ok)
throw new Error("expected malformed loader arguments to fail");
expect(result.error.message).toContain(
encoded === "not-json" ? "valid JSON" : "array of strings",
);
},
);
+14
View File
@@ -5,6 +5,8 @@ import { artifactOutputSchemas } from "./toolOutputSchemas.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { toolContractMetadata } from "./toolEffects.js";
import { requireOutputSchema } from "./toolOutputSchemaPrimitives.js";
import { appleAssetCatalogInputSchema } from "../domain/appleAssetCatalog.js";
import { keyedArchiveInputSchema } from "../domain/keyedArchive.js";
const integrityInput = {
fail: {
integrity_policy: z.literal("fail").default("fail"),
@@ -39,6 +41,8 @@ const examples: Readonly<Record<string, Readonly<Record<string, unknown>>>> = {
inspect_artifact: {},
extract_artifact: {},
decode_interface_builder: {},
inspect_asset_catalog: { offset: 0, limit: 1000 },
inspect_keyed_archive: { path: "Contents/Resources/Model.plist" },
};
const artifact = <
@@ -87,6 +91,16 @@ export const ARTIFACT_TOOL_CONTRACTS = [
max_connections: z.number().int().min(1).max(40_000).default(40_000),
}),
),
artifact(
"inspect_keyed_archive",
"Inspect one Foundation NSKeyedArchiver plist in the active app bundle as original object-table nodes, class descriptors, named roots, and UID edges. Preserves shared and cyclic references without instantiating classes. The path is relative to the bundle; root selection and object pagination preserve original identities.",
keyedArchiveInputSchema,
),
artifact(
"inspect_asset_catalog",
"Inspect compiled Assets.car metadata in an active Apple app bundle. Returns stable paginated catalog and rendition records with raw assetutil metadata and catalog digests; rendition bytes are not extracted.",
appleAssetCatalogInputSchema,
),
] as const satisfies readonly ToolContract[];
/** Provider operations; inventory remains an internal primitive of inspection. */
+3 -97
View File
@@ -5,7 +5,6 @@ import {
ENHANCED_TOOL_CONTRACTS,
OFFICIAL_TOOL_CONTRACTS,
SESSION_TOOL_CONTRACTS,
TOOL_CONTRACTS,
} from "./toolContracts.js";
import { NATIVE_TOOL_CONTRACTS } from "./nativeToolContracts.js";
import { ARTIFACT_TOOL_CONTRACTS } from "./artifactToolContracts.js";
@@ -16,47 +15,10 @@ import { BROWSER_SCENARIO_TOOL_CONTRACTS } from "./browserScenarioToolContracts.
import { ELECTRON_TOOL_CONTRACTS } from "./electronToolContracts.js";
import { JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS } from "./javascriptRuntimeObservationToolContracts.js";
import { APPLICATION_TOOL_CONTRACTS } from "./applicationToolContracts.js";
import {
enhancedOutputSchemas,
managedOutputSchemas,
managedWorkflowOutputSchemas,
officialOutputSchemas,
sessionOutputSchemas,
} from "./toolOutputSchemas.js";
import { annotationsFromEffects, TOOL_EFFECTS } from "./toolEffects.js";
import { TOOL_EFFECTS } from "./toolEffects.js";
const convertContractJsonSchema = (schema: z.ZodType) =>
z.toJSONSchema(schema, {
target: "draft-07",
unrepresentable: "any",
});
const jsonSchemaCache = new WeakMap<
z.ZodType,
ReturnType<typeof convertContractJsonSchema>
>();
const contractJsonSchema = (schema: z.ZodType) => {
const cached = jsonSchemaCache.get(schema);
if (cached !== undefined) return cached;
const converted = convertContractJsonSchema(schema);
jsonSchemaCache.set(schema, converted);
return converted;
};
const describesObject = (schema: ReturnType<typeof contractJsonSchema>) =>
schema.type === "object" ||
[schema.oneOf, schema.anyOf].some(
(variants) =>
Array.isArray(variants) &&
variants.every(
(variant) =>
typeof variant === "object" &&
variant !== null &&
"type" in variant &&
variant.type === "object",
),
);
const contractJsonSchema = (schema: z.ZodType) =>
z.toJSONSchema(schema, { target: "draft-07", unrepresentable: "any" });
describe("tool contract surface", () => {
it("advertises capture comparison alternatives on an object root", () => {
@@ -98,32 +60,6 @@ describe("tool contract surface", () => {
expect(contract.inputSchema.safeParse(incomplete).success).toBe(false);
});
it("advertises complete typed schemas and annotations for every public tool", () => {
const contracts = TOOL_CONTRACTS;
for (const contract of contracts) {
const inputSchema = contractJsonSchema(contract.inputSchema);
const outputSchema = contractJsonSchema(contract.outputSchema);
expect(describesObject(inputSchema), contract.name).toBe(true);
expect(describesObject(outputSchema)).toBe(true);
expect(contract.title.length).toBeGreaterThan(2);
expect(contract.annotations).toEqual(
annotationsFromEffects(contract.effects),
);
expect(typeof contract.annotations.idempotentHint).toBe("boolean");
expect(typeof contract.annotations.openWorldHint).toBe("boolean");
expect(typeof contract.annotations.readOnlyHint).toBe("boolean");
expect(typeof contract.annotations.destructiveHint).toBe("boolean");
expect(contract.examples.length).toBeGreaterThan(0);
for (const example of contract.examples) {
expect(example.title.trim().length).toBeGreaterThan(0);
expect(
contract.inputSchema.safeParse(example.input).success,
`${contract.name}: ${example.title}`,
).toBe(true);
}
}
});
it("audits every tool effect explicitly with no heuristic fallback", () => {
const names = [
...OFFICIAL_TOOL_CONTRACTS,
@@ -166,34 +102,4 @@ describe("tool contract surface", () => {
destructiveHint: false,
});
});
it("publishes a dedicated output schema and agent guidance for every tool", () => {
expect(Object.keys(officialOutputSchemas).sort()).toEqual(
OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name).sort(),
);
expect(Object.keys(enhancedOutputSchemas).sort()).toEqual(
ENHANCED_TOOL_CONTRACTS.map(({ name }) => name).sort(),
);
expect(Object.keys(sessionOutputSchemas).sort()).toEqual(
SESSION_TOOL_CONTRACTS.map(({ name }) => name).sort(),
);
expect(Object.keys(managedOutputSchemas).sort()).toEqual(
MANAGED_TOOL_CONTRACTS.map(({ name }) => name).sort(),
);
expect(Object.keys(managedWorkflowOutputSchemas).sort()).toEqual(
MANAGED_WORKFLOW_TOOL_CONTRACTS.map(({ name }) => name).sort(),
);
for (const contract of [
...OFFICIAL_TOOL_CONTRACTS,
...ENHANCED_TOOL_CONTRACTS,
...MANAGED_TOOL_CONTRACTS,
...MANAGED_WORKFLOW_TOOL_CONTRACTS,
...SESSION_TOOL_CONTRACTS,
]) {
const schema = contractJsonSchema(contract.outputSchema);
expect(JSON.stringify(schema)).not.toContain('"result":{}');
expect(contract.description.trim().length).toBeGreaterThan(0);
}
});
});
+5 -1
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { nativeValueTraceInputSchema } from "../domain/nativeValueTrace.js";
const traceLiteralInputSchema = z.strictObject({
query: z.string().min(1),
case_sensitive: z.boolean().default(false),
@@ -6,6 +7,7 @@ const traceLiteralInputSchema = z.strictObject({
/** Input schemas shared by MCP registration and enhanced application dispatch. */
export const enhancedInputSchemas = {
trace_native_values: nativeValueTraceInputSchema,
inspect_native_dispatch_metadata: z.strictObject({
max_records: z.number().int().min(1).max(20_000).default(5_000),
}),
@@ -54,7 +56,9 @@ export const enhancedInputSchemas = {
action: z
.string()
.min(1)
.describe("A unique compiled UI action selector or interface object ID"),
.describe(
"A unique compiled UI action selector, interface object ID, native symbol or exact function address",
),
max_depth: z.number().int().min(0).max(32).default(8),
max_nodes: z.number().int().min(1).max(2_000).default(250),
max_edges: z.number().int().min(1).max(5_000).default(500),
+23
View File
@@ -1,4 +1,8 @@
import { z } from "zod";
import {
nativeUiObservationInputSchema,
nativeUiScenarioInputSchema,
} from "../domain/nativeUiObservation.js";
import type { ToolContract } from "./toolContracts.js";
import { nativeOutputSchemas } from "./toolOutputSchemas.js";
@@ -11,6 +15,15 @@ const examples: Readonly<Record<string, Readonly<Record<string, unknown>>>> = {
inspect_signature: {},
inspect_plist: { path: "/Applications/Example.app/Contents/Info.plist" },
list_architectures: {},
observe_native_ui: { pid: 123, window_id: 456, observation_approved: true },
capture_native_ui_scenario: {
pid: 123,
window_id: 456,
observation_approved: true,
actions_approved: true,
restore: "leave-as-is",
steps: [{ kind: "wait", milliseconds: 100 }],
},
demangle_swift: { symbols: ["$s4Test3fooyyF"] },
};
@@ -40,6 +53,16 @@ const native = <Name extends string, Schema extends z.ZodObject>(
/** Provider-neutral semantic operations backed initially by macOS utilities. */
export const NATIVE_TOOL_CONTRACTS = [
native(
"observe_native_ui",
"Passively observe one explicitly approved already-running native app PID and window ID bound to the active Mach-O target. Captures a bounded accessibility tree and selected-window screenshot; OS permission denial fails without broad capture or target launch.",
nativeUiObservationInputSchema,
),
native(
"capture_native_ui_scenario",
"Run separately approved selected-element press, increment/decrement scroll, AXValue text entry and bounded wait steps in one exact native app window. Returns ordered before/after captures and action/capture failures. Actions can change app data, network activity and persistent state. The caller must choose leave-as-is; automatic restoration is unsupported.",
nativeUiScenarioInputSchema,
),
native(
"inspect_macho",
"Inspect Mach-O slices, load commands, imports, exports, dependencies, build metadata, segments, sections, permissions, and exact command provenance without launching Hopper.",
+13 -7
View File
@@ -53,7 +53,7 @@ describe("guided prompt contracts", () => {
});
// Assert the properties an analyst depends on rather than thirteen
// incidental fragments, so a copy edit is a reviewable diff instead of a
// wall of failures. The wording is still pinned by the last assertion.
// wall of failures.
// Untrusted caller input must be framed as data, never as instruction.
expect(rendered).toMatch(/not instructions/i);
expect(rendered).toContain(
@@ -67,12 +67,6 @@ describe("guided prompt contracts", () => {
expect(rendered).toContain(section);
// Tools are never a grant of authority.
expect(rendered).toMatch(/never as authorization/i);
expect(rendered.indexOf("`list_documents`")).toBeLessThan(
rendered.indexOf("`search_strings`"),
);
expect(rendered.indexOf("`search_strings`")).toBeLessThan(
rendered.indexOf("`analyze_function`"),
);
});
it("orders mutation and execution after inspection and preparation", () => {
@@ -82,6 +76,12 @@ describe("guided prompt contracts", () => {
renderGuidedPrompt(prompt, {}),
]),
);
expect(rendered.get("audit_residual_unknowns")).toContain(
"`list_unknowns`",
);
expect(rendered.get("audit_residual_unknowns")).toContain(
"`update_unknown`",
);
expect(
rendered.get("audit_residual_unknowns")?.indexOf("`list_unknowns`"),
).toBeLessThan(
@@ -95,6 +95,12 @@ describe("guided prompt contracts", () => {
.get("prepare_bounded_process_capture")
?.indexOf("`capture_process_scenario`"),
).toBeGreaterThanOrEqual(0);
expect(rendered.get("compare_application_versions")).toContain(
"`inspect_artifact`",
);
expect(rendered.get("compare_application_versions")).toContain(
"`compare_artifacts`",
);
expect(
rendered
.get("compare_application_versions")
+4
View File
@@ -21,6 +21,10 @@ export const TOOL_EXAMPLE_OVERRIDES: Readonly<
procedure_callees: { procedure: "main" },
procedure_callers: { procedure: "main" },
procedure_info: { procedure: "main" },
trace_native_values: { procedure: "0x1000" },
inspect_native_data_type: { type: "/MyStruct" },
inspect_native_instruction: { address: "0x1000" },
resolve_native_call_targets: { address: "0x1000" },
read_function_instructions: { procedure: "main" },
read_bytes: { address: "0x1000", length: 16 },
address_to_file_offset: { address: "0x1000" },
+1
View File
@@ -91,6 +91,7 @@ const PUBLISHED_TOOL_NAME_FLOOR = [
"inline_comment",
"inspect_address_context",
"inspect_artifact",
"inspect_asset_catalog",
"inspect_electron_page",
"inspect_macho",
"inspect_managed_artifact",
+23 -1
View File
@@ -46,6 +46,8 @@ import {
} from "./toolContractHelpers.js";
import type { ToolContract } from "./toolContractTypes.js";
import { toolContractMetadata } from "./toolEffects.js";
import { nativeDataTypeInputSchema } from "../domain/nativeDataType.js";
import { nativeInstructionInputSchema } from "../domain/nativeInstruction.js";
import { functionInstructionInputSchema } from "./functionInstructionContract.js";
import { HOPPER_MEMORY_TOOL_DEFINITIONS } from "./hopperMemoryContracts.js";
import { analysisSearchInput } from "./analysisSearchContract.js";
@@ -111,6 +113,21 @@ const session = <Name extends string, Schema extends z.ZodObject>(
/** Bridge operations exposed without additional application composition. */
export const OFFICIAL_TOOL_CONTRACTS = [
official(
"inspect_native_data_type",
"Inspect one recovered type by exact database category path or defined typed data address. Returns observed size, alignment, packing, fields, bitfields, enum values, and child type identities; source-level authority and flexible-tail semantics remain unknown unless substantiated.",
nativeDataTypeInputSchema,
),
official(
"inspect_native_instruction",
"Inspect one exact instruction address: decoded bytes, mnemonic, ordered operand tokens, flow and typed references. Memory addressing decomposition is unavailable when the provider supplies only tokens. Data, interior instruction addresses and undecodable bytes are distinct outcomes.",
nativeInstructionInputSchema,
),
official(
"resolve_native_call_targets",
"Resolve one explicit static call site using typed provider call references. Preserves ambiguous targets as candidates and unresolved computed calls as unknown; does not execute code or traverse a call graph.",
nativeInstructionInputSchema,
),
official(
"address_name",
"Resolve the primary analyzed name at a code or data address. Headless providers require an explicit address; GUI providers may default to their current cursor. Null means the provider has no primary name at that address.",
@@ -353,9 +370,14 @@ export const ENHANCED_TOOL_CONTRACTS = [
),
enhanced(
"trace_native_ui_action",
"Trace one unique compiled Interface Builder action selector or object ID through its authored UI connections to symbolized Objective-C handlers and bounded direct callees. REA decodes the active app bundle and reads the selected provider's symbol inventory itself. Authored connections, statically matched handlers, resolved direct calls, and unresolved dynamic dispatch remain distinct; runtime reachability and cross-function value flow are not claimed.",
"Trace one unique compiled UI action, object ID, native symbol or exact function address through authored connections, encoded or symbolized Objective-C handlers, and bounded static call references. Typed direct and resolved indirect calls, ambiguous candidates, inferred untyped provider callees, and targetless sites remain distinct. Runtime reachability is unknown; use trace_native_values for recovered value dependencies.",
enhancedInputSchemas.trace_native_ui_action,
),
enhanced(
"trace_native_values",
"Trace a bounded static dependency graph from one explicit native procedure. Includes high-p-code def-use, constants, operators, memory and branch operations, resolved call edges, and derived argument/parameter and return/output bindings. Missing bindings, alias semantics, persistent state and RNG roles remain unknown. Budgets bound depth, decompilations, graph size and payloads; nodes are paginated.",
enhancedInputSchemas.trace_native_values,
),
] as const satisfies readonly ToolContract[];
/** Session-owned Evidence bundle export options. */
+21
View File
@@ -42,6 +42,7 @@ const sessionEvidence = effects({ mutatesSession: true });
export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
inspect_native_dispatch_metadata: evidence,
trace_native_ui_action: evidence,
trace_native_values: evidence,
address_name: evidence,
comment: evidence,
current_address: evidence,
@@ -63,6 +64,9 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
procedure_callers: evidence,
procedure_info: evidence,
read_function_instructions: evidence,
inspect_native_instruction: evidence,
inspect_native_data_type: evidence,
resolve_native_call_targets: evidence,
read_bytes: evidence,
address_to_file_offset: evidence,
procedure_references: evidence,
@@ -94,6 +98,21 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
trace_feature: effects({ mutatesSession: true, idempotent: false }),
find_code_for_string: effects({ mutatesSession: true, idempotent: false }),
trace_call_path: effects({ mutatesSession: true, idempotent: false }),
observe_native_ui: effects({
mutatesSession: true,
launchesProcess: true,
writesFilesystem: true,
}),
capture_native_ui_scenario: effects({
writesFilesystem: true,
mutatesTarget: true,
mutatesSession: true,
launchesProcess: true,
accessesNetwork: true,
changesUiState: true,
mayDiscardData: true,
idempotent: false,
}),
inspect_macho: nativeEvidence,
inspect_signature: nativeEvidence,
inspect_plist: nativeEvidence,
@@ -102,6 +121,8 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
inspect_artifact: evidence,
extract_artifact: effects({ mutatesSession: true, writesFilesystem: true }),
decode_interface_builder: evidence,
inspect_asset_catalog: nativeEvidence,
inspect_keyed_archive: evidence,
inspect_managed_artifact: evidence,
inspect_managed_members: evidence,
inspect_managed_native_boundaries: evidence,
+21
View File
@@ -1,4 +1,11 @@
import { z } from "zod";
import { nativeUiResultSchema } from "../domain/nativeUiObservation.js";
import { nativeValueTraceSchema } from "../domain/nativeValueTrace.js";
import { nativeDataTypeSchema } from "../domain/nativeDataType.js";
import {
nativeInstructionSchema,
nativeCallTargetsSchema,
} from "../domain/nativeInstruction.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { residualUnknownSchema } from "../domain/residualUnknown.js";
import { evidenceBundleSchema } from "../domain/evidenceBundle.js";
@@ -22,6 +29,8 @@ import {
import { artifactExtractionResultSchema } from "../domain/artifactGraph.js";
import { artifactInspectionResultSchema } from "../domain/artifactInspection.js";
import { interfaceBuilderAnalysisSchema } from "../domain/interfaceBuilderGraph.js";
import { keyedArchiveResultSchema } from "../domain/keyedArchive.js";
import { appleAssetCatalogResultSchema } from "../domain/appleAssetCatalog.js";
import {
managedArtifactInspectionSchema,
managedMemberInspectionSchema,
@@ -93,6 +102,9 @@ const addressedString = z.object({
/** Exact structured-content schemas shared by direct analysis providers. */
export const officialOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
inspect_native_data_type: resultOf(nativeDataTypeSchema),
inspect_native_instruction: resultOf(nativeInstructionSchema),
resolve_native_call_targets: resultOf(nativeCallTargetsSchema),
address_name: resultOf(nullableText),
comment: resultOf(nullableText),
current_address: resultOf(z.string()),
@@ -133,6 +145,10 @@ export const officialOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
z.object({
procedure: procedureIdentity,
direction: z.enum(["incoming", "outgoing"]),
reference_kinds_available: z.boolean().optional(),
unresolved_calls: z
.array(z.object({ address: z.string(), reason: z.string() }))
.default([]),
references: z.array(
z.object({
source_address: z.string(),
@@ -225,6 +241,7 @@ export const enhancedOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
inspect_native_dispatch_metadata: resultOf(
nativeDispatchMetadataResultSchema,
),
trace_native_values: resultOf(nativeValueTraceSchema),
trace_native_ui_action: resultOf(nativeInvestigationTraceSchema),
get_objc_classes: symbolDiscoveryOutput("classes"),
get_objc_protocols: symbolDiscoveryOutput("protocols"),
@@ -302,6 +319,8 @@ export const enhancedOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
/** Exact Evidence schemas for provider-neutral native inspection. */
export const nativeOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
observe_native_ui: resultOf(nativeUiResultSchema),
capture_native_ui_scenario: resultOf(nativeUiResultSchema),
inspect_macho: resultOf(inspectMachoSchema),
inspect_signature: resultOf(inspectSignatureSchema),
inspect_plist: resultOf(inspectPlistSchema),
@@ -314,6 +333,8 @@ export const artifactOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
inspect_artifact: resultOf(artifactInspectionResultSchema),
extract_artifact: resultOf(artifactExtractionResultSchema),
decode_interface_builder: resultOf(interfaceBuilderAnalysisSchema),
inspect_keyed_archive: resultOf(keyedArchiveResultSchema),
inspect_asset_catalog: resultOf(appleAssetCatalogResultSchema),
};
/** Exact Evidence schema for execution-free managed static analysis. */
+1 -1
View File
@@ -124,7 +124,7 @@ describe("analysis error projection: provider failures", () => {
code: "provider_unavailable",
details: { failure_code: code, exit_code: exitCode },
});
expect(projected.message.length).toBeGreaterThan(20);
expect(projected.message).toMatch(/\S/u);
expect(JSON.stringify(projected)).not.toContain("/proc/");
}
});
+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import {
appleAssetCatalogInputSchema,
parseAppleAssetCatalogRecords,
projectAppleAssetCatalogPage,
} from "./appleAssetCatalog.js";
describe("compiled Apple asset catalog projection", () => {
it("preserves metadata and paginates across catalog boundaries", () => {
const catalogs = [
{
path: "Resources/A/Assets.car",
sha256: "a".repeat(64),
records: parseAppleAssetCatalogRecords([
{ AssetStorageVersion: 1, Platform: "ios" },
{
Name: "Toolbar",
RenditionName: "[email protected]",
Scale: 2,
Idiom: "iphone",
Locale: "en",
Appearance: "dark",
},
]),
},
{
path: "Resources/B/Assets.car",
sha256: "b".repeat(64),
records: parseAppleAssetCatalogRecords([
{
Name: "Toolbar",
RenditionName: "toolbar~ipad.png",
PixelWidth: 64,
},
]),
},
];
const first = projectAppleAssetCatalogPage({
targetSha256: "c".repeat(64),
catalogs,
offset: 0,
limit: 2,
resourceReferences: [
{
sourceNodeId: "ib:Main.nib:object:1",
sourcePath: "Main.nib",
sourceArchiveSha256: "d".repeat(64),
field: "imageName",
key: "Toolbar",
},
{
sourceNodeId: "ib:Main.nib:object:2",
sourcePath: "Main.nib",
sourceArchiveSha256: "d".repeat(64),
field: "imageName",
key: "Missing",
},
],
});
expect(first.records.map(({ kind }) => kind)).toEqual([
"catalog",
"rendition",
]);
expect(first.records[1]?.metadata.Scale).toBe(2);
expect(first.records[1]?.metadata.Idiom).toBe("iphone");
expect(first.records[1]?.metadata.Locale).toBe("en");
expect(first.records[1]?.metadata.Appearance).toBe("dark");
expect(first.resource_key_matches).toEqual([
expect.objectContaining({ status: "ambiguous", key: "Toolbar" }),
expect.objectContaining({ status: "unmatched", key: "Missing" }),
]);
expect(first.next_offset).toBe(2);
expect(first.truncated).toBe(true);
const second = projectAppleAssetCatalogPage({
targetSha256: "c".repeat(64),
catalogs,
offset: 2,
limit: 2,
});
expect(second.records[0]?.catalog_path).toBe("Resources/B/Assets.car");
expect(second.next_offset).toBeNull();
expect(second.total_records).toBe(3);
});
it("rejects malformed pages and invalid utility data", () => {
expect(appleAssetCatalogInputSchema.safeParse({ offset: -1 }).success).toBe(
false,
);
expect(() =>
parseAppleAssetCatalogRecords({ Name: "not an array" }),
).toThrow(/JSON array/u);
expect(() => parseAppleAssetCatalogRecords([null])).toThrow(/record 0/u);
});
});
+168
View File
@@ -0,0 +1,168 @@
import { z } from "zod";
import { jsonValueSchema } from "./jsonValue.js";
/** Caller-controlled page bounds for compiled Apple asset metadata. */
export const appleAssetCatalogInputSchema = z.strictObject({
offset: z.number().int().min(0).default(0),
limit: z.number().int().min(1).max(10_000).default(1_000),
});
const catalogSchema = z.strictObject({
path: z.string().min(1),
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
record_count: z.number().int().nonnegative(),
});
const recordSchema = z.strictObject({
catalog_path: z.string().min(1),
catalog_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
index: z.number().int().nonnegative(),
kind: z.enum(["catalog", "rendition"]),
asset_name: z.string().nullable(),
rendition_name: z.string().nullable(),
metadata: z.record(z.string(), jsonValueSchema),
});
const resourceKeyMatchSchema = z.strictObject({
source_node_id: z.string().min(1),
source_path: z.string().min(1),
source_archive_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
field: z.string().min(1),
key: z.string().min(1),
status: z.enum(["matched", "ambiguous", "unmatched"]),
renditions: z.array(
z.strictObject({
catalog_path: z.string().min(1),
catalog_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
record_index: z.number().int().nonnegative(),
rendition_name: z.string().nullable(),
}),
),
});
/** Normalized result returned by asset catalog inspection. */
export const appleAssetCatalogResultSchema = z.strictObject({
target_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
catalogs: z.array(catalogSchema),
total_records: z.number().int().nonnegative(),
offset: z.number().int().nonnegative(),
limit: z.number().int().positive(),
records: z.array(recordSchema),
resource_key_matches: z.array(resourceKeyMatchSchema),
next_offset: z.number().int().nonnegative().nullable(),
truncated: z.boolean(),
limitations: z.array(z.string()),
});
export type AppleAssetCatalogRecord = z.infer<typeof recordSchema>;
/** Parse assetutil JSON records without discarding format-specific metadata. */
export const parseAppleAssetCatalogRecords = (
value: unknown,
): readonly Readonly<
Record<string, ReturnType<typeof jsonValueSchema.parse>>
>[] => {
if (!Array.isArray(value))
throw new Error("assetutil output must be a JSON array");
return value.map((record, index) => {
const parsed = z.record(z.string(), jsonValueSchema).safeParse(record);
if (!parsed.success)
throw new Error(`assetutil record ${index} is not a JSON object`);
return parsed.data;
});
};
/** Project catalog records and apply stable global pagination. */
export const projectAppleAssetCatalogPage = (input: {
readonly targetSha256: string;
readonly catalogs: readonly {
readonly path: string;
readonly sha256: string;
readonly records: readonly Readonly<
Record<string, ReturnType<typeof jsonValueSchema.parse>>
>[];
}[];
readonly offset: number;
readonly limit: number;
readonly resourceReferences?: readonly {
readonly sourceNodeId: string;
readonly sourcePath: string;
readonly sourceArchiveSha256: string;
readonly field: string;
readonly key: string;
}[];
}) => {
const all = input.catalogs.flatMap((catalog) =>
catalog.records.map(
(metadata, index): AppleAssetCatalogRecord => ({
catalog_path: catalog.path,
catalog_sha256: catalog.sha256,
index,
kind:
typeof metadata.RenditionName === "string" ||
typeof metadata.Name === "string"
? "rendition"
: "catalog",
asset_name: typeof metadata.Name === "string" ? metadata.Name : null,
rendition_name:
typeof metadata.RenditionName === "string"
? metadata.RenditionName
: null,
metadata,
}),
),
);
const records = all.slice(input.offset, input.offset + input.limit);
const next = input.offset + records.length;
const recordsByAssetName = new Map<string, typeof all>();
for (const record of all) {
if (record.kind !== "rendition" || record.asset_name === null) continue;
const matches = recordsByAssetName.get(record.asset_name) ?? [];
matches.push(record);
recordsByAssetName.set(record.asset_name, matches);
}
const resourceKeyMatches = (input.resourceReferences ?? []).map((source) => {
const matches = recordsByAssetName.get(source.key) ?? [];
return {
source_node_id: source.sourceNodeId,
source_path: source.sourcePath,
source_archive_sha256: source.sourceArchiveSha256,
field: source.field,
key: source.key,
status:
matches.length === 0
? ("unmatched" as const)
: matches.length === 1
? ("matched" as const)
: ("ambiguous" as const),
renditions: matches.map((record) => ({
catalog_path: record.catalog_path,
catalog_sha256: record.catalog_sha256,
record_index: record.index,
rendition_name: record.rendition_name,
})),
};
});
return appleAssetCatalogResultSchema.parse({
target_sha256: input.targetSha256,
catalogs: input.catalogs.map(
({ path, sha256, records: catalogRecords }) => ({
path,
sha256,
record_count: catalogRecords.length,
}),
),
total_records: all.length,
offset: input.offset,
limit: input.limit,
records,
resource_key_matches: resourceKeyMatches,
next_offset: next < all.length ? next : null,
truncated: input.offset > 0 || next < all.length,
limitations: [
"Rendition metadata is reported as emitted by the installed assetutil version; private fields and platform-specific interpretations are not normalized.",
"Asset catalog inspection does not decode or extract rendition image bytes.",
],
});
};
+1 -1
View File
@@ -244,7 +244,7 @@ describe("browser scenario comparison validation", () => {
});
});
it("rejects ambiguous input pairs and duplicate normalization rule IDs", () => {
it("rejects duplicate normalization rule IDs", () => {
const capture = scenarioCapture({});
expect(
compareBrowserScenariosInputSchema.safeParse({
+2
View File
@@ -276,6 +276,8 @@ export class ArtifactOperationError extends AnalysisError {
| "inspect_artifact"
| "extract_artifact"
| "decode_interface_builder"
| "inspect_asset_catalog"
| "inspect_keyed_archive"
| "analyze_javascript_application",
readonly reason:
| "cancelled"
@@ -132,7 +132,7 @@ describe("JavaScript semantic analysis: calls 1", () => {
({ method }) => method === "all",
);
expect(aggregate?.sourcePromiseIds).toHaveLength(2);
expect(aggregate?.returnSiteId).not.toBeNull();
expect(aggregate?.returnSiteId).toBeTypeOf("string");
});
});
+200
View File
@@ -0,0 +1,200 @@
import { z } from "zod";
import { jsonValueSchema, type JsonValue } from "./jsonValue.js";
/** Select one archive within the active bundle and optionally one named root. */
export const keyedArchiveInputSchema = z.strictObject({
path: z.string().min(1).default("."),
root: z.string().optional(),
offset: z.number().int().min(0).default(0),
limit: z.number().int().min(1).max(20_000).default(20_000),
});
const linkSchema = z.strictObject({
source: z.number().int().nonnegative().nullable(),
path: z.array(z.union([z.string(), z.number().int().nonnegative()])),
target: z.number().int().nonnegative().nullable(),
status: z.enum(["resolved", "nil", "unresolved", "malformed"]),
raw: jsonValueSchema,
});
/** Archive object identity is its original $objects index, never a recursive copy. */
export const keyedArchiveResultSchema = z.strictObject({
target_sha256: z.string(),
archive_path: z.string(),
archive_sha256: z.string(),
archive_format: z.enum(["binary-plist", "xml-plist"]),
roots: z.record(z.string(), jsonValueSchema),
objects: z.array(
z.strictObject({
id: z.number().int().nonnegative(),
kind: z.enum([
"nil",
"scalar",
"array",
"dictionary",
"class-descriptor",
"archived-object",
]),
class_id: z.number().int().nonnegative().nullable(),
class_name: z.string().nullable(),
status: z.enum(["observed", "unknown-class", "unresolved-class", "nil"]),
value: jsonValueSchema,
}),
),
references: z.array(linkSchema),
total_objects: z.number().int().nonnegative(),
total_references: z.number().int().nonnegative(),
offset: z.number().int().nonnegative(),
next_offset: z.number().int().nonnegative().nullable(),
truncated: z.boolean(),
limitations: z.array(z.string()),
});
const record = (
value: JsonValue | undefined,
): Record<string, JsonValue> | undefined =>
value !== null && typeof value === "object" && !Array.isArray(value)
? value
: undefined;
const uid = (value: JsonValue | undefined): JsonValue | undefined => {
const object = record(value);
if (object === undefined || Object.keys(object).length !== 1)
return undefined;
return Object.hasOwn(object, "UID") ? object.UID : object["CF$UID"];
};
/** Interpret serialized Foundation references without instantiating archive classes. */
export const projectKeyedArchive = (
value: unknown,
selection: { root?: string | undefined; offset: number; limit: number },
) => {
const archive = record(jsonValueSchema.parse(value));
if (
archive?.$archiver !== "NSKeyedArchiver" ||
!Array.isArray(archive.$objects)
)
throw new TypeError("Expected NSKeyedArchiver with an $objects array");
const table = archive.$objects;
if (table.length > 200_000)
throw new RangeError("Archive exceeds 200000 object-table entries");
const top = record(archive.$top);
if (top === undefined || Object.keys(top).length === 0)
throw new TypeError("Keyed archive has no $top roots");
if (selection.root !== undefined && !Object.hasOwn(top, selection.root))
throw new TypeError(`Archive root does not exist: ${selection.root}`);
const roots =
selection.root === undefined
? top
: { [selection.root]: top[selection.root] ?? null };
const references: z.infer<typeof linkSchema>[] = [];
const scan = (source: number | null, initial: JsonValue) => {
const pending: { value: JsonValue; path: (string | number)[] }[] = [
{ value: initial, path: [] },
];
let visited = 0;
while (pending.length > 0) {
const item = pending.pop();
if (item === undefined) break;
if (++visited > 200_000 || item.path.length > 128)
throw new RangeError(
"Archive exceeds 200000 values per object or 128 nesting levels",
);
const target = uid(item.value);
if (target !== undefined) {
const valid =
typeof target === "number" &&
Number.isSafeInteger(target) &&
target >= 0;
const index = valid ? target : null;
references.push({
source,
path: item.path,
target: index,
raw: item.value,
status:
index === null
? "malformed"
: index >= table.length
? "unresolved"
: index === 0 && table[0] === "$null"
? "nil"
: "resolved",
});
} else if (Array.isArray(item.value)) {
for (let index = item.value.length - 1; index >= 0; index--) {
const child = item.value[index];
if (child !== undefined)
pending.push({ value: child, path: [...item.path, index] });
}
} else {
const fields = record(item.value);
if (fields !== undefined)
for (const key of Object.keys(fields).sort().reverse()) {
const child = fields[key];
if (child !== undefined)
pending.push({ value: child, path: [...item.path, key] });
}
}
if (references.length > 200_000)
throw new RangeError("Archive exceeds 200000 reference edges");
}
};
scan(null, roots);
const objects = table.map((value, id) => {
scan(id, value);
const fields = record(value);
const classUid = uid(fields?.$class);
const classId =
typeof classUid === "number" &&
Number.isSafeInteger(classUid) &&
classUid >= 0
? classUid
: null;
const descriptor = classId === null ? undefined : record(table[classId]);
const className =
typeof descriptor?.$classname === "string" ? descriptor.$classname : null;
const isNil = id === 0 && value === "$null";
return {
id,
kind: isNil
? ("nil" as const)
: Array.isArray(value)
? ("array" as const)
: fields === undefined
? ("scalar" as const)
: typeof fields.$classname === "string"
? ("class-descriptor" as const)
: Object.hasOwn(fields, "$class")
? ("archived-object" as const)
: ("dictionary" as const),
class_id: classId,
class_name: className,
status: isNil
? ("nil" as const)
: fields !== undefined && Object.hasOwn(fields, "$class")
? className === null
? ("unresolved-class" as const)
: ("unknown-class" as const)
: ("observed" as const),
value,
};
});
const end = Math.min(objects.length, selection.offset + selection.limit);
const page = objects.slice(selection.offset, end);
const ids = new Set(page.map(({ id }) => id));
return {
roots,
objects: page,
references: references.filter(
({ source }) => source === null || ids.has(source),
),
total_objects: objects.length,
total_references: references.length,
offset: selection.offset,
next_offset: end < objects.length ? end : null,
truncated: page.length !== objects.length,
limitations: [
"Archive classes are never instantiated. Class names and serialized fields are observed; class-specific semantics are unknown.",
"Conditional references serialized as UID 0 cannot be distinguished from ordinary nil references. Missing fields remain absent in the raw serialized value.",
"Root selection selects the named $top entry; object pagination retains original indices and does not recursively expand references.",
],
};
};
+1
View File
@@ -53,6 +53,7 @@ const jumpTableDataSourceSchema = z
"ghidra-decompiler-load-table",
"ghidra-dispatch-block-data-reference",
"ghidra-aarch64-byte-table",
"ghidra-aarch64-halfword-table",
]),
entry_size_bytes: z.number().int().min(1).nullable(),
entry_count: z.number().int().min(0).nullable(),
+57
View File
@@ -0,0 +1,57 @@
import { z } from "zod";
/** Select a database type by exact category path or a defined typed data address. */
export const nativeDataTypeInputSchema = z
.strictObject({
type: z.string().min(1).optional(),
address: z.string().min(1).optional(),
document: z.string().optional(),
})
.refine(
(value) => (value.type === undefined) !== (value.address === undefined),
"Supply exactly one of type or address",
);
/** Types remain analysis database observations; child identities can be inspected separately. */
export const nativeDataTypeSchema = z.strictObject({
status: z.enum(["available", "unavailable"]),
reason: z.string().nullable(),
id: z.string().nullable(),
name: z.string().nullable(),
kind: z.enum([
"struct",
"union",
"enum",
"pointer",
"array",
"typedef",
"scalar",
"unsupported",
"unavailable",
]),
source: z.literal("analysis-database"),
source_archive: z.string().nullable(),
address: z.string().nullable(),
size_bytes: z.number().int().nonnegative().nullable(),
alignment_bytes: z.number().int().positive().nullable(),
packing_enabled: z.boolean().nullable(),
referenced_type: z.string().nullable(),
array_count: z.number().int().nonnegative().nullable(),
array_stride_bytes: z.number().int().nonnegative().nullable(),
fields: z.array(
z.strictObject({
ordinal: z.number().int().nonnegative(),
name: z.string().nullable(),
offset_bytes: z.number().int().nonnegative(),
size_bytes: z.number().int().nonnegative().nullable(),
type_id: z.string(),
bit_size: z.number().int().nonnegative().nullable(),
bit_offset: z.number().int().nonnegative().nullable(),
status: z.enum(["observed", "unsupported"]),
flexible_tail: z.literal("unknown"),
}),
),
members: z.array(z.strictObject({ name: z.string(), value: z.string() })),
total_fields: z.number().int().nonnegative(),
truncated: z.boolean(),
limitations: z.array(z.string()),
});
+90
View File
@@ -0,0 +1,90 @@
import { z } from "zod";
/** Exact instruction location within the active analysis program. */
export const nativeInstructionInputSchema = z.strictObject({
address: z.string().min(1),
document: z.string().optional(),
});
const reference = z.strictObject({
target_address: z.string(),
type: z.string(),
call: z.boolean(),
jump: z.boolean(),
indirect: z.boolean(),
computed: z.boolean(),
operand_index: z.number().int(),
});
/** Provider-decoded facts for one instruction; operand tokens preserve their native ordering. */
export const nativeInstructionSchema = z.strictObject({
address: z.string(),
status: z.enum([
"decoded",
"not-instruction-boundary",
"data",
"undecodable",
"outside-memory",
]),
procedure: z.string().nullable(),
architecture: z.string(),
mode: z.string(),
bytes: z.string().nullable(),
length: z.number().int().positive().nullable(),
mnemonic: z.string().nullable(),
raw_disassembly: z.string().nullable(),
operands: z.array(
z.strictObject({
index: z.number().int().nonnegative(),
raw: z.string(),
provider_type: z.number().int(),
components: z.array(
z.strictObject({
kind: z.enum(["register", "immediate", "address", "unknown"]),
text: z.string(),
value: z.string().nullable(),
bit_width: z.number().int().nonnegative().nullable(),
}),
),
memory_addressing: z.literal("unavailable"),
}),
),
flow: z.strictObject({
kind: z.enum([
"call",
"jump",
"return",
"fallthrough",
"terminal",
"unavailable",
]),
conditional: z.boolean(),
computed: z.boolean(),
direct_destinations: z.array(z.string()),
}),
references: z.array(reference),
limitations: z.array(z.string()),
});
/** Static target resolution preserves provider references as evidence, never runtime certainty. */
export const nativeCallTargetsSchema = z.strictObject({
call_site: z.string(),
procedure: z.string().nullable(),
status: z.enum([
"direct",
"resolved-indirect",
"ambiguous",
"unresolved",
"not-call",
"unavailable",
]),
mechanism: z.enum(["direct", "computed", "indirect", "unavailable"]),
targets: z.array(
z.strictObject({
address: z.string(),
procedure: z.string().nullable(),
status: z.enum(["direct", "resolved-indirect", "candidate"]),
basis: z.literal("provider-reference"),
references: z.array(reference),
}),
),
limitations: z.array(z.string()),
});

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