feat(native): observe function and Objective-C method calls under LLDB

Add observe_native_calls (CLI: observe-native-calls <path> <input-json>). It
launches the active Mach-O as an owned process under `lldb --batch`, stops at
caller-selected functions or Objective-C methods, records each entry and
auto-continues:

- thread, image, symbol, load and file address;
- raw integer argument registers and optional caller frames;
- for methods, the selector read from _cmd and the receiver's dynamic class,
  from LLDB's runtime reader without running target code.

No expression is evaluated and no existing process is attached. The run ends
on exit, max_events or duration_ms; REA then kills the process and confirms
that it is gone. The result also carries exit status, bounded stdout/stderr,
breakpoint resolution with partial coverage for unresolved requests, and
limitations. A target LLDB may not debug fails with a debugger-attach-denied
reason and remediation.

verify:native-calls checks the tool through the CLI and stdio MCP against a
compiled fixture, compares breakpoint addresses with nm, exercises every
outcome, and checks hardened-runtime targets with and without get-task-allow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 committed 2026-10-07 13:22:55 +00:00
1 parent a6ba772c08
commit 9837203234
31 files changed
+1970 -26

No files matched your search

+1 -1
View File
@@ -405,7 +405,7 @@ See [native investigation](docs/native-investigation.md) for keyed archives, ins
| ------------------------- | ----: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Native inspection | 41 | functions, pseudocode, assembly, strings, symbols, calls, references, annotations, byte reads, and file offsets |
| Investigation workflows | 14 | app overviews, function dossiers, native APIs and dispatch, batch decompilation, feature traces, call paths, call graphs, Swift and Objective-C discovery |
| Native macOS utilities | 7 | Mach-O metadata, code signatures, plists, architectures, and Swift demangling without launching Hopper |
| Native macOS utilities | 8 | Mach-O metadata, code signatures, plists, architectures, Swift demangling, and LLDB call observation without launching Hopper |
| Artifact graph | 5 | directory and package inventories, compiled Interface Builder files, Apple asset catalogs, and extraction |
| Managed PE/CLI | 7 | .NET identity, metadata, CIL instructions, native dependencies, reconstruction imports, and build comparisons |
| Firmware | 2 | Linux firmware region inspection and explicit extraction |
+1 -1
View File
@@ -217,7 +217,7 @@ rea setup
| -------------------- | ----: | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| الفحص الأصلي | 41 | الدوال والشيفرة شبه المصدرية والتعليمات والسلاسل والرموز والاستدعاءات والمراجع والتعليقات وقراءة البايتات ومواضع الملفات |
| سير عمل التحقيق | 14 | نظرات عامة على التطبيقات وملفات الدوال وواجهات API الأصلية والتوزيع وفك الترجمة الدفعي وتتبع الميزات ومسارات الاستدعاء ومخططاته واكتشاف Swift وObjective-C |
| أدوات macOS الأصلية | 7 | بيانات Mach-O والتوقيعات وplist والمعماريات واستعادة أسماء Swift دون تشغيل Hopper |
| أدوات macOS الأصلية | 8 | بيانات Mach-O والتوقيعات وplist والمعماريات واستعادة أسماء Swift ومراقبة الاستدعاءات عبر LLDB دون تشغيل Hopper |
| مخطط المخرجات | 5 | جرد الدلائل والحزم وملفات Interface Builder المترجمة وكتالوجات موارد Apple والاستخراج |
| PE/CLI المُدار | 7 | هوية تجميعات .NET وبياناتها الوصفية وتعليمات CIL والاعتماديات الأصلية واستيراد إعادة البناء ومقارنة الإصدارات |
| البرامج الثابتة | 2 | فحص مناطق البرامج الثابتة على Linux واستخراجها الصريح |
+1 -1
View File
@@ -206,7 +206,7 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ
| -------------------- | --: | -------------------------------------------------------------------------------------------------------------------------------------------- |
| ネイティブ検査 | 41 | 関数、疑似コード、アセンブリ、文字列、シンボル、呼び出し、参照、注釈、バイト読み取り、ファイルオフセット |
| 調査ワークフロー | 14 | アプリ概要、関数ドシエ、ネイティブ API とディスパッチ、一括逆コンパイル、機能トレース、コールパス、コールグラフ、Swift と Objective-C の検出 |
| macOS ネイティブ | 7 | Hopper を起動せずに扱える Mach-O メタデータ、署名、plist、アーキテクチャ、Swift 名の復元 |
| macOS ネイティブ | 8 | Hopper を起動せずに扱える Mach-O メタデータ、署名、plist、アーキテクチャ、Swift 名の復元、LLDB による呼び出し観測 |
| 成果物グラフ | 5 | ディレクトリとパッケージの目録、コンパイル済み Interface Builder ファイル、Apple アセットカタログ、抽出 |
| マネージド PE/CLI | 7 | .NET の識別、メタデータ、CIL 命令、ネイティブ依存関係、再構築インポート、ビルド比較 |
| ファームウェア | 2 | Linux ファームウェア領域の検査と明示的な展開 |
+1 -1
View File
@@ -207,7 +207,7 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이
| ----------------- | --: | ------------------------------------------------------------------------------------------------------------------------- |
| 네이티브 검사 | 41 | 함수, 의사 코드, 어셈블리, 문자열, 심볼, 호출, 참조, 주석, 바이트 읽기, 파일 오프셋 |
| 조사 워크플로 | 14 | 앱 개요, 함수 기록서, 네이티브 API와 디스패치, 일괄 디컴파일, 기능 추적, 호출 경로, 호출 그래프, Swift와 Objective-C 탐색 |
| macOS 네이티브 | 7 | Hopper 실행 없이 처리하는 Mach-O 메타데이터, 서명, plist, 아키텍처, Swift 이름 복원 |
| macOS 네이티브 | 8 | Hopper 실행 없이 처리하는 Mach-O 메타데이터, 서명, plist, 아키텍처, Swift 이름 복원, LLDB 호출 관찰 |
| 아티팩트 그래프 | 5 | 디렉터리와 패키지 목록, 컴파일된 Interface Builder 파일, Apple 애셋 카탈로그, 추출 |
| 관리 PE/CLI | 7 | .NET 식별, 메타데이터, CIL 명령어, 네이티브 종속성, 재구성 가져오기, 빌드 비교 |
| 펌웨어 | 2 | Linux 펌웨어 영역 검사 및 명시적 추출 |
+1 -1
View File
@@ -206,7 +206,7 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
| ----------------- | ---: | ------------------------------------------------------------------------------------------------------ |
| 原生检查 | 41 | 函数、伪代码、汇编、字符串、符号、调用、引用、注释、字节读取与文件偏移 |
| 调查工作流 | 14 | 应用概览、函数档案、原生 API 与分发、批量反编译、功能追踪、调用路径与调用图、Swift 与 Objective-C 发现 |
| macOS 原生工具 | 7 | Mach-O 元数据、代码签名、plist、架构与 Swift 符号还原,无需启动 Hopper |
| macOS 原生工具 | 8 | Mach-O 元数据、代码签名、plist、架构、Swift 符号还原与 LLDB 调用观察,无需启动 Hopper |
| 产物图 | 5 | 目录与软件包清单、编译后的 Interface Builder 文件、Apple 资源目录与提取 |
| 托管 PE/CLI | 7 | .NET 程序集身份、元数据、CIL 指令、原生依赖声明、重建导入与构建比较 |
| 固件 | 2 | Linux 固件区域检查与显式提取 |
+303
View File
@@ -0,0 +1,303 @@
"""Bounded native call observation executed inside LLDB's embedded Python.
REA imports this module into `lldb --batch` and runs `rea_trace <config.json>`.
It launches one owned process, stops at the requested functions and
Objective-C methods, records each entry from registers and memory reads only,
continues, and kills the process once it exits, the event limit is reached or
the duration elapses. No expression is evaluated and no target code runs on
the tracer's behalf. The result is written as JSON to the configured path.
"""
import json
import time
import lldb
ARGUMENT_REGISTERS = {
"arm64": ["x0", "x1", "x2", "x3", "x4", "x5", "x6", "x7"],
"arm64e": ["x0", "x1", "x2", "x3", "x4", "x5", "x6", "x7"],
"x86_64": ["rdi", "rsi", "rdx", "rcx", "r8", "r9"],
"x86_64h": ["rdi", "rsi", "rdx", "rcx", "r8", "r9"],
}
MAX_SELECTOR_BYTES = 1024
MAX_REPORTED_LOCATIONS = 64
MAX_OTHER_STOPS = 256
LISTENER_NAME = "rea-native-call-observation"
def _hex(value):
return "0x%x" % value
def _location(address, target):
"""Load and file address, module and symbol of one code address."""
module = address.GetModule()
symbol = address.GetSymbol()
file_address = address.GetFileAddress()
return {
"load_address": _hex(address.GetLoadAddress(target)),
"file_address": None
if file_address == lldb.LLDB_INVALID_ADDRESS
else _hex(file_address),
"module": module.GetFileSpec().GetFilename() if module.IsValid() else None,
"module_path": str(module.GetFileSpec()) if module.IsValid() else None,
"symbol": symbol.GetName() if symbol.IsValid() else None,
}
def _no_files():
return lldb.SBFileSpecList()
def _create_breakpoints(target, spec):
"""LLDB breakpoints for one request; a class-qualified method may need two."""
if spec["kind"] == "function":
modules = lldb.SBFileSpecList()
if spec.get("module"):
modules.Append(lldb.SBFileSpec(spec["module"], False))
return [
target.BreakpointCreateByName(
spec["name"], lldb.eFunctionNameTypeAuto, modules, _no_files()
)
]
class_name = spec.get("class_name")
method_type = spec.get("method_type", "any")
if class_name is None:
return [
target.BreakpointCreateByName(
spec["selector"], lldb.eFunctionNameTypeSelector, _no_files(), _no_files()
)
]
prefixes = {"instance": ["-"], "class": ["+"], "any": ["-", "+"]}[method_type]
return [
target.BreakpointCreateByName(
"%s[%s %s]" % (prefix, class_name, spec["selector"]),
lldb.eFunctionNameTypeFull,
_no_files(),
_no_files(),
)
for prefix in prefixes
]
def _dynamic_class(target, pointer):
"""Class of an Objective-C receiver from LLDB's runtime reader, without running code."""
if pointer == 0:
return None
data = lldb.SBData.CreateDataFromUInt64Array(
target.GetByteOrder(), target.GetAddressByteSize(), [pointer]
)
value = target.CreateValueFromData(
"receiver", data, target.GetBasicType(lldb.eBasicTypeObjCID)
)
dynamic = value.GetDynamicValue(lldb.eDynamicDontRunTarget)
name = dynamic.GetTypeName() if dynamic.IsValid() else None
if not name or name in ("id", "objc_object *"):
return None
return name[:-2] if name.endswith(" *") else name
def _wanted(spec, symbol):
"""Selector-only requests may admit only instance or only class methods."""
if spec["kind"] != "objc-method" or spec.get("class_name") is not None:
return True
method_type = spec.get("method_type", "any")
if method_type == "any":
return True
return symbol.startswith("-[" if method_type == "instance" else "+[")
def _record(context, thread, index, sequence):
target, process, config = context["target"], context["process"], context["config"]
frame = thread.GetFrameAtIndex(0)
location = _location(frame.GetPCAddress(), target)
symbol = location["symbol"] or ""
if not _wanted(config["breakpoints"][index], symbol):
return None
names = ARGUMENT_REGISTERS.get(config["architecture"], [])
registers = [
{"name": name, "value": _hex(frame.FindRegister(name).GetValueAsUnsigned())}
for name in names[: config["argument_registers"]]
]
receiver_class = None
selector = None
if symbol.startswith("-[") or symbol.startswith("+["):
error = lldb.SBError()
text = process.ReadCStringFromMemory(
frame.FindRegister(names[1]).GetValueAsUnsigned(),
MAX_SELECTOR_BYTES,
error,
)
selector = text if error.Success() and text else None
if symbol.startswith("-["):
receiver_class = _dynamic_class(
target, frame.FindRegister(names[0]).GetValueAsUnsigned()
)
frames = min(thread.GetNumFrames(), 1 + config["backtrace_frames"])
record = {
"sequence": sequence,
"elapsed_ms": round((time.monotonic() - context["started"]) * 1000, 3),
"thread_id": thread.GetThreadID(),
"breakpoint_index": index,
"receiver_class": receiver_class,
"selector": selector,
"registers": registers,
"backtrace": [
_location(thread.GetFrameAtIndex(depth).GetPCAddress(), target)
for depth in range(1, frames)
],
}
record.update(location)
return record
def _handle_stop(context, events, other_stops):
"""Record breakpoint hits of one stop; return an outcome when tracing must end."""
process = context["process"]
for thread in process:
reason = thread.GetStopReason()
if reason == lldb.eStopReasonBreakpoint:
index = context["breakpoint_index"].get(thread.GetStopReasonDataAtIndex(0))
if index is None:
continue
record = _record(context, thread, index, len(events))
if record is not None:
events.append(record)
if len(events) >= context["config"]["max_events"]:
return "event-limit"
elif reason in (lldb.eStopReasonSignal, lldb.eStopReasonException):
other_stops.append(thread.GetStopDescription(256))
if len(other_stops) >= MAX_OTHER_STOPS:
return "stop-limit"
return None
def _breakpoint_report(target, created):
report = []
for index, breakpoints in enumerate(created):
locations = []
count = 0
for breakpoint in breakpoints:
count += breakpoint.GetNumLocations()
for position in range(breakpoint.GetNumLocations()):
if len(locations) < MAX_REPORTED_LOCATIONS:
locations.append(
_location(breakpoint.GetLocationAtIndex(position).GetAddress(), target)
)
report.append({"index": index, "location_count": count, "locations": locations})
return report
def _launch_info(target, config, listener):
launch = lldb.SBLaunchInfo(config["arguments"])
launch.SetListener(listener)
environment = target.GetEnvironment()
for key, value in config["environment"].items():
environment.Set(key, value, True)
launch.SetEnvironment(environment, False)
if config.get("working_directory"):
launch.SetWorkingDirectory(config["working_directory"])
launch.AddOpenFileAction(0, "/dev/null", True, False)
launch.AddOpenFileAction(1, config["stdout_path"], False, True)
launch.AddOpenFileAction(2, config["stderr_path"], False, True)
return launch
def _wait_for_exit(listener, process, seconds):
event = lldb.SBEvent()
deadline = time.monotonic() + seconds
while process.GetState() != lldb.eStateExited and time.monotonic() < deadline:
listener.WaitForEvent(1, event)
def trace(debugger, config):
debugger.SetAsync(True)
# Stop at the symbol itself, where argument registers still hold the ABI
# arguments, even when line tables would let LLDB skip the prologue.
lldb.SBDebugger.SetInternalVariable(
"target.skip-prologue", "false", debugger.GetInstanceName()
)
error = lldb.SBError()
target = debugger.CreateTarget(
config["executable"], config["architecture"], None, False, error
)
if not target.IsValid():
return {"status": "target-error", "error": error.GetCString()}
created = [_create_breakpoints(target, spec) for spec in config["breakpoints"]]
breakpoint_index = {}
for index, breakpoints in enumerate(created):
for breakpoint in breakpoints:
breakpoint_index[breakpoint.GetID()] = index
listener = lldb.SBListener(LISTENER_NAME)
started = time.monotonic()
process = target.Launch(_launch_info(target, config, listener), error)
if not error.Success() or not process.IsValid():
return {"status": "launch-error", "error": error.GetCString()}
with open(config["pid_path"], "w") as handle:
handle.write(str(process.GetProcessID()))
context = {
"target": target,
"process": process,
"config": config,
"started": started,
"breakpoint_index": breakpoint_index,
}
events = []
other_stops = []
outcome = None
deadline = started + config["duration_ms"] / 1000
event = lldb.SBEvent()
while outcome is None:
if time.monotonic() >= deadline:
outcome = "duration-elapsed"
break
if not listener.WaitForEvent(1, event):
continue
if not lldb.SBProcess.EventIsProcessEvent(event):
continue
state = lldb.SBProcess.GetStateFromEvent(event)
if state == lldb.eStateExited:
outcome = "exited"
elif state == lldb.eStateStopped and not lldb.SBProcess.GetRestartedFromEvent(event):
outcome = _handle_stop(context, events, other_stops)
if outcome is None:
process.Continue()
pid = process.GetProcessID()
exit_status = None
exit_description = None
if outcome == "exited":
exit_status = process.GetExitStatus()
exit_description = process.GetExitDescription()
else:
process.Kill()
_wait_for_exit(listener, process, 5)
return {
"status": "traced",
"version": lldb.SBDebugger.GetVersionString().splitlines()[0],
"pid": pid,
"outcome": outcome,
"exit_status": exit_status,
"exit_description": exit_description,
"killed": outcome != "exited",
"terminated": process.GetState() == lldb.eStateExited,
"elapsed_ms": round((time.monotonic() - started) * 1000, 3),
"breakpoints": _breakpoint_report(target, created),
"events": events,
"other_stops": other_stops,
}
def run(debugger, command, result, internal_dict):
"""LLDB command entry point: `rea_trace <config.json>`."""
with open(command.strip()) as handle:
config = json.load(handle)
try:
output = trace(debugger, config)
except Exception as error: # Reported as a tracer failure, never re-raised into LLDB.
output = {"status": "tracer-error", "error": "%s: %s" % (type(error).__name__, error)}
with open(config["result_path"], "w") as handle:
json.dump(output, handle)
def __lldb_init_module(debugger, internal_dict):
debugger.HandleCommand("command script add -f rea_lldb_tracer.run rea_trace")
+41
View File
@@ -159,6 +159,47 @@ owned helper per scenario, removes its temporary compiler cache and stops its
helper on cancellation. It does not launch or own the selected application. UI
actions may change application data or trigger network activity.
## Native call observation
`observe_native_calls` / `rea observe-native-calls <app-or-mach-o> <input-json>`
launches the active Mach-O as a new, owned process under LLDB. It stops at
caller-selected entries, records them and continues:
- functions by exact symbol name, optionally limited to one image;
- Objective-C methods by selector, optionally limited to one class and to
instance or class methods.
```sh
rea observe-native-calls /Applications/Example.app '{"breakpoints":[{"kind":"objc-method","class_name":"NSURLSession","selector":"dataTaskWithRequest:completionHandler:"},{"kind":"function","name":"open","module":"libsystem_kernel.dylib"}],"duration_ms":5000,"backtrace_frames":4}' --json
```
Each event is an observation: thread, image, symbol, load and file address,
the raw integer argument registers, optional caller frames and, for an
Objective-C method, the selector read from `_cmd` and the receiver's dynamic
class. Breakpoints stop at the symbol itself, so the registers hold the ABI
arguments. The receiver class comes from LLDB's Objective-C runtime reader
without running target code; no expression is evaluated, and REA never
attaches to a process it did not launch.
The run ends when the process exits, `max_events` entries are recorded, or
`duration_ms` elapses. In the last two cases the process is killed, and REA
confirms that it is gone. The result reports:
- the outcome, exit status and captured stdout/stderr (1 MiB kept per stream);
- signal or exception stops;
- every breakpoint's resolved locations; a breakpoint that matched no loaded
code makes coverage partial;
- limitations.
Only entries are observed: return values, floating-point and stack arguments,
and inlined or `objc_direct` calls are not. The target runs with the current
user's permissions and may change files, show UI or use the network.
Debugging must be allowed for the user (Developer Tools access). A
hardened-runtime target without `com.apple.security.get-task-allow` cannot be
debugged; `inspect_signature` reports this as the debugger-attach facet, and
the tool fails with a `debugger-attach-denied` reason.
## Provider and verification boundaries
Install a Ghidra 12.1.x release and the 64-bit full JDK it declares, then configure REA to
+12 -9
View File
@@ -7,10 +7,10 @@
"client": "2.3.1",
"core": "2.3.1"
},
"skill_version": "27"
"skill_version": "28"
},
"tools": {
"total": 131,
"total": 132,
"families": [
{
"id": "direct",
@@ -84,7 +84,7 @@
{
"id": "native",
"surface": "native-provider",
"count": 7,
"count": 8,
"tools": [
"capture_native_ui_scenario",
"demangle_swift",
@@ -92,6 +92,7 @@
"inspect_plist",
"inspect_signature",
"list_architectures",
"observe_native_calls",
"observe_native_ui"
]
},
@@ -352,6 +353,7 @@
"inspect_plist",
"inspect_signature",
"list_architectures",
"observe_native_calls",
"observe_native_ui"
]
},
@@ -566,7 +568,7 @@
}
],
"cli": {
"primary_count": 86,
"primary_count": 87,
"commands": [
"address-to-file-offset",
"analyze",
@@ -629,6 +631,7 @@
"list-electron-targets",
"list-javascript-runtime-targets",
"observe-javascript-runtime",
"observe-native-calls",
"observe-native-ui",
"observe-web-session",
"project-android-application-graph",
@@ -664,15 +667,15 @@
},
"runtime_catalog": {
"counts": {
"cli_commands": 86,
"mcp_tools": 131,
"cli_commands": 87,
"mcp_tools": 132,
"mcp_prompts": 6
},
"digests": {
"tools_sha256": "f49e05c52c6fd9955edcede27fbaaa248fdb917caa7b27edb8d97f7a30ad250e",
"tools_sha256": "92ae53640dfc900a7f68d68ebe7c73df3598216bdc5ec9223ddf273646faf428",
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
"combined_sha256": "54ac160db3b96580a9a0203a07fe3db01ffc05224db621f48b4abdf979e4f50b",
"providers_sha256": "a7d885531ee25d42c51385444faec544d34cd7eba6250a7dc75c4faedd4454a3"
"combined_sha256": "620f3dfef357568e29b9a77dd771cf2556a88b54186e1eb871b0f00e33430520",
"providers_sha256": "486202b495c0dedb5ae8e2cf859415b98126d94b7220cb350f854275e80538c3"
}
}
}
+11
View File
@@ -480,6 +480,17 @@ conformance/vtable fixture.
`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)
`npm run verify:native-calls` needs only macOS with Command Line Tools (`clang`,
`lldb`, `codesign`, `nm`). It compiles `tests/conformance/native/calls.m` and
runs `observe-native-calls` through the CLI and stdio MCP. It checks:
- the receiver class, selector and argument registers of every entry, and that
breakpoint addresses equal `nm`'s symbol addresses;
- captured stdout and an environment override;
- the event-limit and duration outcomes, with the process confirmed gone;
- that a hardened-runtime copy is refused with `debugger-attach-denied`, and
that the same copy signed with `get-task-allow` is traced.
for the exact ABI, authority, graph and observation boundaries.
### Firmware adapters
@@ -1,5 +1,5 @@
{
"manifest_id": "ecm_e7e60d5d08bfe335c4c0b38ab4598eb634444b835202e325241749d152808c94",
"manifest_id": "ecm_d9db47786dbb398a90a7a65ba9ad184f528abd18c674732a0ffe8f94b881617e",
"verifier": {
"id": "rea-managed-conformance",
"version": "1"
@@ -13,7 +13,7 @@
"skill_digests": [
{
"skill_id": "reverse-engineer-anything",
"sha256": "265556bd52f52ad829187fe71831cf05329248286c86e3102f0cdbf8841e1d6d"
"sha256": "e7eb838b8fb162d3453b6282b7fbb57c44ab21c77210e7fb8fe266c518e96170"
}
],
"claims": [
+2
View File
@@ -44,6 +44,7 @@
"bridge/hopper_bridge.py",
"bridge/native/NativeUIChildren.swift",
"bridge/native/ReaNativeUI.swift",
"bridge/native/rea_lldb_tracer.py",
"bridge/ghidra/ReaGhidraBridge.java",
"bridge/ghidra/ReaGhidraExtensions.java",
"bridge/ghidra/ReaGhidraPrepareCom.java",
@@ -172,6 +173,7 @@
"prepack": "node scripts/prepack.mjs",
"prepare": "node scripts/prepare.mjs",
"verify:asset-catalog": "npm run build:cached && node scripts/verify-asset-catalog.mjs",
"verify:native-calls": "npm run build:cached && node scripts/verify-native-calls.mjs",
"verify:native-ui:permissions": "npm run build:cached && node scripts/verify-native-ui.mjs --allow-permission-denial",
"test:focused": "node scripts/run-development-tests.mjs focused",
"build:windows-native": "node scripts/build-windows-native.mjs",
+240
View File
@@ -0,0 +1,240 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import {
artifactCli,
artifactMcpResult,
withArtifactMcp,
} from "./lib/artifact-e2e.mjs";
const exec = promisify(execFile);
if (process.platform !== "darwin")
throw new Error(
"Native call observation verification requires macOS with Command Line Tools",
);
for (const tool of ["clang", "lldb", "codesign", "nm"])
try {
await exec("/usr/bin/xcrun", ["--find", tool]);
} catch (cause) {
throw new Error(
`Native call observation verification requires ${tool} from Command Line Tools (xcrun --find ${tool} failed)`,
{ cause },
);
}
const SOURCE = fileURLToPath(
new URL("../tests/conformance/native/calls.m", import.meta.url),
);
const GREET = "-[ReaCallGreeter greet:times:]";
const INPUT = {
breakpoints: [
{
kind: "objc-method",
class_name: "ReaCallGreeter",
selector: "greet:times:",
method_type: "instance",
},
{ kind: "function", name: "rea_call_add" },
],
environment: { REA_CALLS_MARKER: "observed" },
argument_registers: 4,
backtrace_frames: 1,
};
/** Pointer-free facts that two runs of the same scenario must share. */
const stable = (result) =>
result.events.map((event) => ({
symbol: event.symbol,
file_address: event.file_address,
selector: event.selector,
receiver_class: event.receiver_class,
counter:
event.symbol === GREET
? event.registers[3].value
: event.registers[0].value,
}));
const symbolAddress = async (binary, symbol) => {
const { stdout } = await exec("/usr/bin/xcrun", ["nm", "-n", binary]);
const line = stdout.split("\n").find((entry) => entry.endsWith(` ${symbol}`));
assert.ok(line, `nm lists ${symbol}`);
return `0x${BigInt(`0x${line.split(" ")[0]}`).toString(16)}`;
};
const checkScenario = async (binary) => {
const result = await artifactCli("observe-native-calls", binary, [
JSON.stringify(INPUT),
]);
assert.equal(result.process.outcome, "exited");
assert.equal(result.process.exit_status, 0);
assert.equal(result.process.terminated, true);
assert.match(result.process.stdout.text, /^world x0 40\n/u);
assert.match(result.process.stdout.text, /marker observed\n$/u);
assert.deepEqual(result.coverage, {
status: "complete",
event_limit_reached: false,
unresolved_breakpoints: [],
});
const greet = result.events.filter(({ symbol }) => symbol === GREET);
const add = result.events.filter(({ symbol }) => symbol === "rea_call_add");
assert.equal(greet.length, 3);
assert.equal(add.length, 3);
for (const [index, event] of greet.entries()) {
assert.equal(event.receiver_class, "ReaCallGreeter");
assert.equal(event.selector, "greet:times:");
assert.equal(event.registers[3].value, `0x${index}`);
assert.equal(event.backtrace[0].symbol, "main");
assert.equal(event.module, basename(binary));
}
for (const [index, event] of add.entries()) {
assert.equal(event.registers[0].value, `0x${index}`);
assert.equal(event.registers[1].value, "0x28");
}
// Breakpoints stop at the symbol itself, which Apple's nm reports.
assert.equal(
add[0].file_address,
await symbolAddress(binary, "_rea_call_add"),
);
assert.equal(greet[0].file_address, await symbolAddress(binary, GREET));
return result;
};
const alive = (pid) => {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
};
const checkBounds = async (binary) => {
const limited = await artifactCli("observe-native-calls", binary, [
JSON.stringify({
breakpoints: [{ kind: "function", name: "nanosleep" }],
arguments: ["--wait"],
max_events: 2,
duration_ms: 30_000,
}),
]);
assert.equal(limited.process.outcome, "event-limit");
assert.equal(limited.events.length, 2);
assert.equal(limited.process.terminated, true);
assert.equal(alive(limited.process.pid), false);
const elapsed = await artifactCli("observe-native-calls", binary, [
JSON.stringify({
breakpoints: [{ kind: "function", name: "rea_call_add" }],
arguments: ["--wait"],
duration_ms: 1_500,
}),
]);
assert.equal(elapsed.process.outcome, "duration-elapsed");
assert.equal(elapsed.events.length, 0);
assert.equal(alive(elapsed.process.pid), false);
};
const cliFailure = async (binary) => {
try {
await exec(
process.execPath,
[
fileURLToPath(new URL("rea.mjs", import.meta.url)),
"observe-native-calls",
binary,
JSON.stringify(INPUT),
"--json",
],
{ env: { ...process.env, REA_LOG_LEVEL: "silent" }, timeout: 120_000 },
);
} catch (cause) {
return JSON.parse(cause.stdout);
}
throw new Error("observe-native-calls unexpectedly succeeded");
};
/** Hardened runtime blocks the debugger unless get-task-allow is granted. */
const checkHardenedRuntime = async (directory, binary) => {
const blocked = join(directory, "hardened");
await exec("/bin/cp", [binary, blocked]);
await exec("/usr/bin/xcrun", [
"codesign",
"-f",
"-s",
"-",
"-o",
"runtime",
blocked,
]);
const denied = await cliFailure(blocked);
assert.equal(denied.code, "capability_unavailable");
assert.match(denied.details.reason, /^debugger-attach-denied: /u);
const allowed = join(directory, "debuggable");
const entitlements = join(directory, "get-task-allow.plist");
await writeFile(
entitlements,
'<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict><key>com.apple.security.get-task-allow</key><true/></dict></plist>',
);
await exec("/bin/cp", [binary, allowed]);
await exec("/usr/bin/xcrun", [
"codesign",
"-f",
"-s",
"-",
"-o",
"runtime",
"--entitlements",
entitlements,
allowed,
]);
const traced = await checkScenario(allowed);
assert.equal(traced.process.exit_status, 0);
};
const directory = await mkdtemp(join(tmpdir(), "rea-native-calls-"));
try {
const binary = join(directory, "ReaCalls");
await exec("/usr/bin/xcrun", [
"clang",
"-fobjc-arc",
"-framework",
"Foundation",
SOURCE,
"-o",
binary,
]);
const viaCli = await checkScenario(binary);
await withArtifactMcp(binary, async (client) => {
const viaMcp = await artifactMcpResult(
client,
"observe_native_calls",
INPUT,
);
assert.deepEqual(stable(viaMcp), stable(viaCli));
assert.equal(viaMcp.process.stdout.text, viaCli.process.stdout.text);
});
await checkBounds(binary);
await checkHardenedRuntime(directory, binary);
console.log(
JSON.stringify({
ok: true,
mocked: false,
cli: true,
stdio_mcp: true,
debugger: viaCli.debugger.version,
events: viaCli.events.length,
outcomes: ["exited", "event-limit", "duration-elapsed"],
hardened_runtime: {
without_get_task_allow: "denied",
with_get_task_allow: "traced",
},
}),
);
} finally {
await rm(directory, { recursive: true, force: true });
}
+3 -3
View File
@@ -2,9 +2,9 @@
name: reverse-engineer-anything
description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
metadata:
version: "27"
tool_count: 131
catalog_digest: "54ac160db3b96580a9a0203a07fe3db01ffc05224db621f48b4abdf979e4f50b"
version: "28"
tool_count: 132
catalog_digest: "620f3dfef357568e29b9a77dd771cf2556a88b54186e1eb871b0f00e33430520"
---
# REA
@@ -9,6 +9,12 @@ cross-references. Addresses and recovered pseudocode are analysis observations,
not original source. Provider unavailability and unsupported metadata remain
unknown rather than false.
To see which functions or Objective-C methods a Mach-O actually calls in one
run, use `observe_native_calls` with explicit breakpoints and a bounded
`duration_ms`/`max_events`. It launches a new process under LLDB, so the target
runs and may change files or use the network. Hardened-runtime targets need the
`get-task-allow` entitlement.
Use `binary_session` with no arguments to check the open target, selected
provider, and alignment. Its default `result.tool_availability` includes the
complete tool inventory with availability reasons and remediation. When choosing
+57
View File
@@ -0,0 +1,57 @@
import { z } from "incur";
import { runProviderAnalysis } from "../application/DirectAnalysis.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { parseCliJsonInput } from "../cliJsonInput.js";
import { logCliCommand } from "../cliLogging.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { projectInputIssues } from "../domain/inputIssueProjection.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { nativeCallObservationInputSchema } from "../domain/nativeCallObservation.js";
import type { Logger } from "../logger.js";
import type { CliInstance } from "./types.js";
const OPERATION = "observe_native_calls";
/** `observe-native-calls <path> <input-json>`: the MCP input object as JSON. */
export const registerNativeCallCommands = (
cli: CliInstance,
logger: Logger,
): void => {
cli.command(CLI_COMMANDS.observeNativeCalls, {
description:
"Launch a Mach-O under LLDB and record entries into selected functions or Objective-C methods",
args: z.object({
path: z.string().describe("Mach-O or app path"),
inputJson: z
.string()
.describe(
"Inline JSON or a JSON file path: breakpoints, arguments, environment, duration_ms, max_events, argument_registers, backtrace_frames",
),
}),
run: ({ args }) =>
logCliCommand(logger, CLI_COMMANDS.observeNativeCalls, async () => {
const input = await parseCliJsonInput(args.inputJson, OPERATION);
if (!input.ok) return input.error;
const parsed = nativeCallObservationInputSchema.safeParse(input.value);
if (!parsed.success)
return {
error: "Analysis failed",
...projectAnalysisError(
new AnalysisInputError(
OPERATION,
undefined,
projectInputIssues(parsed.error.issues, input.value),
),
),
};
return runProviderAnalysis(
args.path,
OPERATION,
z.record(z.string(), jsonValueSchema).parse(parsed.data),
logger,
);
}),
});
};
+2
View File
@@ -17,6 +17,7 @@ import { swiftSymbolsSchema } from "../contracts/nativeToolContracts.js";
import type { Logger } from "../logger.js";
import { isReferenceSourceImportCliFailure } from "./referenceSourceImportStatus.js";
import type { CliInstance } from "./types.js";
import { registerNativeCallCommands } from "./nativeCallCommands.js";
export const registerUtilityCommands = (
cli: CliInstance,
@@ -25,6 +26,7 @@ export const registerUtilityCommands = (
): void => {
registerCapabilityCommands(cli, logger);
registerNativeCommands(cli, logger);
registerNativeCallCommands(cli, logger);
for (const [command, operation] of [
[CLI_COMMANDS.observeNativeUi, "observe_native_ui"],
[CLI_COMMANDS.captureNativeUiScenario, "capture_native_ui_scenario"],
+1
View File
@@ -48,6 +48,7 @@ export const CLI_COMMANDS = Object.freeze({
importManagedReconstruction: "import-managed-reconstruction",
projectManagedApplicationGraph: "project-managed-application-graph",
observeNativeUi: "observe-native-ui",
observeNativeCalls: "observe-native-calls",
captureNativeUiScenario: "capture-native-ui-scenario",
inspectMacho: "inspect-macho",
inspectSignature: "inspect-signature",
+19
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { nativeCallObservationInputSchema } from "../domain/nativeCallObservation.js";
import {
nativeUiObservationInputSchema,
nativeUiScenarioInputSchema,
@@ -22,6 +23,19 @@ const examples: Readonly<Record<string, Readonly<Record<string, unknown>>>> = {
steps: [{ kind: "wait", milliseconds: 100 }],
},
demangle_swift: { symbols: ["$s4Test3fooyyF"] },
observe_native_calls: {
breakpoints: [
{
kind: "objc-method",
class_name: "NSURLSession",
selector: "dataTaskWithRequest:completionHandler:",
},
{ kind: "function", name: "open", module: "libsystem_kernel.dylib" },
],
arguments: ["--help"],
duration_ms: 5000,
backtrace_frames: 4,
},
};
const native = <Name extends string, Schema extends z.ZodObject>(
@@ -70,6 +84,11 @@ export const NATIVE_TOOL_CONTRACTS = [
"Run 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; application state is left as-is and restoration is not attempted.",
nativeUiScenarioInputSchema,
),
native(
"observe_native_calls",
"Launch the active Mach-O as an owned process under LLDB and record each entry into caller-selected functions or Objective-C methods: thread, module, symbol, load and file address, raw argument registers, selector, receiver class and optional caller frames. Every hit auto-continues. No expression is evaluated and no other process is attached. The process is killed when max_events or duration_ms is reached; exit status, captured stdout/stderr, unresolved breakpoints and limitations are returned inline. The process runs with the current user's permissions and may change files, show UI or use the network. A hardened-runtime target needs the get-task-allow entitlement.",
nativeCallObservationInputSchema,
),
native(
"inspect_macho",
"Inspect Mach-O slices, load commands, imports, exports, dependencies, build metadata, segments, sections, permissions, and exact command provenance without launching Hopper.",
+9
View File
@@ -167,6 +167,15 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
mayDiscardData: true,
idempotent: false,
}),
// The owned process can change files, show UI and use the network.
observe_native_calls: effects({
mutatesSession: true,
writesFilesystem: true,
launchesProcess: true,
accessesNetwork: true,
changesUiState: true,
idempotent: false,
}),
inspect_macho: nativeEvidence,
inspect_signature: nativeEvidence,
inspect_plist: nativeEvidence,
+2
View File
@@ -2,6 +2,7 @@ import { z } from "zod";
import { nativeFunctionAnnotationsSchema } from "../domain/nativeFunctionAnnotations.js";
import { nativeLoadImageSchema } from "../domain/nativeLoadImage.js";
import { nativeUiResultSchema } from "../domain/nativeUiObservation.js";
import { nativeCallObservationResultSchema } from "../domain/nativeCallObservation.js";
import { nativeValueTraceSchema } from "../domain/nativeValueTrace.js";
import { nativeDataTypeSchema } from "../domain/nativeDataType.js";
import {
@@ -322,6 +323,7 @@ export const enhancedOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
export const nativeOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
observe_native_ui: resultOf(nativeUiResultSchema),
capture_native_ui_scenario: resultOf(nativeUiResultSchema),
observe_native_calls: resultOf(nativeCallObservationResultSchema),
inspect_macho: resultOf(inspectMachoSchema),
inspect_signature: resultOf(inspectSignatureSchema),
inspect_plist: resultOf(inspectPlistSchema),
+198
View File
@@ -0,0 +1,198 @@
import { z } from "zod";
const hexSchema = z.string().regex(/^0x[0-9a-f]+$/u);
/** Objective-C class names and selectors never contain spaces or brackets. */
const objcName = z
.string()
.min(1)
.regex(/^[^\s[\]]+$/u, "Use the bare name without spaces or brackets");
const functionBreakpoint = {
kind: z.literal("function"),
name: z
.string()
.min(1)
.describe(
"Exact symbol name, such as `open`, a C++ or mangled Swift symbol, or `-[NSString length]`.",
),
};
const objcMethodBreakpoint = {
kind: z.literal("objc-method"),
selector: objcName.describe(
"Selector such as `dataTaskWithRequest:completionHandler:`.",
),
};
/** One function or Objective-C method whose entries are observed. */
export const nativeCallBreakpointSchema = z.discriminatedUnion("kind", [
z.strictObject({
...functionBreakpoint,
module: z
.string()
.min(1)
.optional()
.describe(
"Image file name that defines the symbol, such as `libsystem_kernel.dylib`; omit to match every image.",
),
}),
z.strictObject({
...objcMethodBreakpoint,
class_name: objcName
.optional()
.describe("Implementing class; omit to match every class."),
method_type: z.enum(["instance", "class", "any"]).default("any"),
}),
]);
/**
* Launch the active Mach-O as an owned process under LLDB and observe calls.
* Duration and event bounds keep runtime observation finite.
*/
export const nativeCallObservationInputSchema = z.strictObject({
breakpoints: z.array(nativeCallBreakpointSchema).min(1),
arguments: z.array(z.string()).default([]),
environment: z
.record(
z
.string()
.min(1)
.regex(/^[^=\0]+$/u, "Environment names cannot contain '=' or NUL"),
z.string().regex(/^[^\0]*$/u, "Environment values cannot contain NUL"),
)
.default({})
.describe("Overrides on top of the environment REA runs with."),
working_directory: z.string().min(1).optional(),
duration_ms: z
.number()
.int()
.min(100)
.max(600_000)
.default(10_000)
.describe(
"Observation window; the process is killed if it is still running.",
),
max_events: z
.number()
.int()
.min(1)
.max(100_000)
.default(1_000)
.describe(
"Stop observing and kill the process after this many recorded entries.",
),
argument_registers: z
.number()
.int()
.min(0)
.max(8)
.default(4)
.describe(
"Integer argument registers to record per entry (arm64 has 8, x86_64 has 6).",
),
backtrace_frames: z
.number()
.int()
.min(0)
.max(64)
.default(0)
.describe("Caller frames to record per entry."),
});
export type NativeCallObservationInput = z.infer<
typeof nativeCallObservationInputSchema
>;
/** A code address with the image and symbol LLDB attributes to it. */
export const nativeCodeLocationSchema = z.strictObject({
load_address: hexSchema,
file_address: hexSchema.nullable(),
module: z.string().nullable(),
module_path: z.string().nullable(),
symbol: z.string().nullable(),
});
/** One observed entry into a requested function or method. */
export const nativeCallEventSchema = z.strictObject({
sequence: z.number().int().nonnegative(),
elapsed_ms: z.number().nonnegative(),
thread_id: z.number().int().nonnegative(),
breakpoint_index: z.number().int().nonnegative(),
...nativeCodeLocationSchema.shape,
/** Dynamic class of an instance-method receiver, read without running code. */
receiver_class: z.string().nullable(),
/** Selector string read from the `_cmd` register of an Objective-C method. */
selector: z.string().nullable(),
registers: z.array(z.strictObject({ name: z.string(), value: hexSchema })),
backtrace: z.array(nativeCodeLocationSchema),
});
const capturedOutputSchema = z.strictObject({
text: z.string(),
bytes: z.number().int().nonnegative(),
truncated: z.boolean(),
});
/** Observed calls, the process lifecycle, and breakpoint resolution. */
export const nativeCallObservationResultSchema = z.strictObject({
target: z.strictObject({
path: z.string(),
sha256: z.string().regex(/^[0-9a-f]{64}$/u),
architecture: z.string(),
arguments: z.array(z.string()),
working_directory: z.string().nullable(),
}),
debugger: z.strictObject({
path: z.string(),
sha256: z.string().regex(/^[0-9a-f]{64}$/u),
version: z.string().nullable(),
}),
process: z.strictObject({
pid: z.number().int().positive(),
outcome: z.enum([
"exited",
"duration-elapsed",
"event-limit",
"stop-limit",
]),
exit_status: z.number().int().nullable(),
exit_description: z.string().nullable(),
/** REA confirmed the process is gone when observation ended. */
terminated: z.boolean(),
elapsed_ms: z.number().nonnegative(),
stdout: capturedOutputSchema,
stderr: capturedOutputSchema,
/** Signal and exception stops LLDB reported; the process continued past each. */
other_stops: z.array(z.string()),
}),
breakpoints: z.array(
z.strictObject({
index: z.number().int().nonnegative(),
request: z.discriminatedUnion("kind", [
z.strictObject({
...functionBreakpoint,
module: z.string().nullable(),
}),
z.strictObject({
...objcMethodBreakpoint,
class_name: z.string().nullable(),
method_type: z.enum(["instance", "class", "any"]),
}),
]),
location_count: z.number().int().nonnegative(),
locations: z.array(nativeCodeLocationSchema),
}),
),
events: z.array(nativeCallEventSchema),
coverage: z.strictObject({
status: z.enum(["complete", "partial"]),
event_limit_reached: z.boolean(),
/** Requests that matched no code in any image loaded while observing. */
unresolved_breakpoints: z.array(z.number().int().nonnegative()),
}),
limitations: z.array(z.string()),
});
export type NativeCallObservationResult = z.infer<
typeof nativeCallObservationResultSchema
>;
+1 -1
View File
@@ -30,7 +30,7 @@ interface GeneratedPayload {
}
const GENERATED_PAYLOAD_JSON =
'{\n "auxiliaryProviders": [\n {\n "capabilities": [\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inventory_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "extract_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "decode_interface_builder",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_keyed_archive",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DLine truncated
'{\n "auxiliaryProviders": [\n {\n "capabilities": [\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inventory_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "extract_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "decode_interface_builder",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_keyed_archive",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DLine truncated
const generatedPayload: unknown = JSON.parse(GENERATED_PAYLOAD_JSON);
if (!isGeneratedPayload(generatedPayload))
throw new TypeError("Generated MCP catalog payload is invalid");
+1 -1
View File
@@ -5,5 +5,5 @@ export const PACKAGE_METADATA = {
serverSdkVersion: "2.3.1",
clientSdkVersion: "2.3.1",
coreSdkVersion: "2.3.1",
skillVersion: "27",
skillVersion: "28",
} as const;
+2 -1
View File
@@ -124,7 +124,8 @@ const ALLOWED_TOOLS = new Set([
"vtool",
]);
const resolveXcrunTool = async (
/** Locate an Xcode tool through `xcrun --find` and pin its executable digest. */
export const resolveXcrunTool = async (
tool: string,
signal?: AbortSignal,
): Promise<Result<ResolvedTool, NativeCommandFailure>> => {
+400
View File
@@ -0,0 +1,400 @@
import { spawn } from "node:child_process";
import { open, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { z } from "zod";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisTimeoutError,
} from "../domain/analysisErrorCore.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import {
nativeCallEventSchema,
nativeCodeLocationSchema,
type NativeCallObservationInput,
} from "../domain/nativeCallObservation.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { err, ok, type Result } from "../domain/result.js";
import { safeParseJson } from "../domain/safeJson.js";
import { execFileOutput } from "../process/ExecFileOutput.js";
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
import { resolveXcrunTool, type ResolvedTool } from "./CommandRunner.js";
const OPERATION = "observe_native_calls";
const PROVIDER = "native-macos";
/** Captured target output kept per stream; the rest is counted, not stored. */
const MAX_OUTPUT_BYTES = 1024 * 1024;
/** LLDB startup, symbol loading and teardown beyond the observation window. */
const DEADLINE_GRACE_MS = 60_000;
const DIAGNOSTIC_BYTES = 16 * 1024;
const BRIDGE = fileURLToPath(
new URL("../../bridge/native/rea_lldb_tracer.py", import.meta.url),
);
/** JSON the LLDB bridge writes after a completed run. */
const tracedSchema = z.strictObject({
status: z.literal("traced"),
version: z.string(),
pid: z.number().int().positive(),
outcome: z.enum(["exited", "duration-elapsed", "event-limit", "stop-limit"]),
exit_status: z.number().int().nullable(),
exit_description: z.string().nullable(),
killed: z.boolean(),
terminated: z.boolean(),
elapsed_ms: z.number().nonnegative(),
breakpoints: z.array(
z.strictObject({
index: z.number().int().nonnegative(),
location_count: z.number().int().nonnegative(),
locations: z.array(nativeCodeLocationSchema),
}),
),
events: z.array(nativeCallEventSchema),
other_stops: z.array(z.string()),
});
const tracerOutputSchema = z.discriminatedUnion("status", [
tracedSchema,
z.strictObject({
status: z.enum(["target-error", "launch-error", "tracer-error"]),
error: z.string().nullable(),
}),
]);
/** Bounded text of one target output stream. */
export interface CapturedOutput {
readonly text: string;
readonly bytes: number;
readonly truncated: boolean;
}
/** One completed LLDB observation, before projection into the result contract. */
export interface NativeCallTrace {
readonly debugger: ResolvedTool & { readonly version: string | null };
readonly run: z.infer<typeof tracedSchema>;
readonly stdout: CapturedOutput;
readonly stderr: CapturedOutput;
/** REA confirmed the traced process no longer exists. */
readonly terminated: boolean;
}
/** Launch one owned process under a debugger and record calls. */
export interface NativeCallTracer {
trace(
request: {
readonly executable: string;
readonly architecture: string;
readonly input: NativeCallObservationInput;
},
signal?: AbortSignal,
): Promise<Result<NativeCallTrace, AnalysisError>>;
}
/** Remediation for a target LLDB may not debug. */
const ATTACH_REMEDIATION =
"Allow debugging for this user (Developer Tools access: `DevToolsSecurity -enable`, or membership in the _developer group). A hardened-runtime target is debuggable only with the com.apple.security.get-task-allow entitlement; see inspect_signature's debugger-attach facet.";
/** Production tracer: `lldb --batch` running the REA LLDB bridge. */
export class LldbCallTracer implements NativeCallTracer {
async trace(
request: Parameters<NativeCallTracer["trace"]>[0],
signal?: AbortSignal,
): Promise<Result<NativeCallTrace, AnalysisError>> {
const lldb = await resolveXcrunTool("lldb", signal);
if (!lldb.ok)
return err(
lldb.error.reason === "cancelled"
? new AnalysisCancelledError(OPERATION)
: new AnalysisCapabilityUnavailableError(
PROVIDER,
OPERATION,
"LLDB from Xcode or the Command Line Tools is not available",
),
);
const root = await PrivateRuntimeRoot.create({ prefix: "rea-lldb-" });
try {
return await this.#run(lldb.value, root.path, request, signal);
} finally {
await root.close();
}
}
async #run(
lldb: ResolvedTool,
directory: string,
request: Parameters<NativeCallTracer["trace"]>[0],
signal?: AbortSignal,
): Promise<Result<NativeCallTrace, AnalysisError>> {
const paths = {
config: join(directory, "config.json"),
result: join(directory, "result.json"),
pid: join(directory, "pid"),
stdout: join(directory, "stdout"),
stderr: join(directory, "stderr"),
};
const { input } = request;
await writeFile(
paths.config,
JSON.stringify({
executable: request.executable,
architecture: request.architecture,
arguments: input.arguments,
environment: input.environment,
working_directory: input.working_directory ?? null,
breakpoints: input.breakpoints.map((breakpoint) =>
breakpoint.kind === "function"
? { ...breakpoint, module: breakpoint.module ?? null }
: { ...breakpoint, class_name: breakpoint.class_name ?? null },
),
duration_ms: input.duration_ms,
max_events: input.max_events,
argument_registers: input.argument_registers,
backtrace_frames: input.backtrace_frames,
stdout_path: paths.stdout,
stderr_path: paths.stderr,
result_path: paths.result,
pid_path: paths.pid,
}),
{ mode: 0o600 },
);
const exited = await runLldb(
lldb.path,
[
"--batch",
"--no-lldbinit",
"--no-use-colors",
"-o",
`command script import ${quote(BRIDGE)}`,
"-o",
`rea_trace ${paths.config}`,
],
{
deadlineMs: input.duration_ms + DEADLINE_GRACE_MS,
...(signal === undefined ? {} : { signal }),
},
);
const terminated = await ensureTerminated(paths.pid, request.executable);
if (exited.kind === "cancelled")
return err(new AnalysisCancelledError(OPERATION));
if (exited.kind === "timeout")
return err(
new AnalysisTimeoutError(
OPERATION,
input.duration_ms + DEADLINE_GRACE_MS,
),
);
const output = await readTracerOutput(paths.result);
if (output === undefined)
return err(
new ProviderAdapterError(PROVIDER, OPERATION, {
diagnostics: {
reason: "LLDB exited without a tracer result",
exit_code: exited.exitCode,
lldb_output: exited.output,
},
}),
);
if (output.status !== "traced") return err(tracerFailure(output));
return ok({
debugger: { ...lldb, version: output.version },
run: output,
stdout: await capturedOutput(paths.stdout),
stderr: await capturedOutput(paths.stderr),
terminated,
});
}
}
/** LLDB command arguments are quoted; the bridge path is REA's own install path. */
const quote = (value: string): string =>
`"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
const tracerFailure = (output: {
readonly status: "target-error" | "launch-error" | "tracer-error";
readonly error: string | null;
}): AnalysisError => {
const message = output.error ?? "no LLDB error text";
if (output.status === "tracer-error")
return new ProviderAdapterError(PROVIDER, OPERATION, {
diagnostics: { reason: `LLDB bridge failed: ${message}` },
});
if (output.status === "target-error")
return new AnalysisCapabilityUnavailableError(
PROVIDER,
OPERATION,
`lldb-target-unloadable: ${message}`,
);
// debugserver reports denied task access as an attach failure.
return /attach|not allowed|permission|denied|debugserver/iu.test(message)
? new AnalysisCapabilityUnavailableError(
PROVIDER,
OPERATION,
`debugger-attach-denied: ${message}`,
{ userMessage: ATTACH_REMEDIATION },
)
: new AnalysisCapabilityUnavailableError(
PROVIDER,
OPERATION,
`launch-failed: ${message}`,
);
};
const readTracerOutput = async (
path: string,
): Promise<z.infer<typeof tracerOutputSchema> | undefined> => {
let text: string;
try {
text = await readFile(path, "utf8");
} catch {
return undefined;
}
const parsed = safeParseJson(text);
if (!parsed.ok) return undefined;
const output = tracerOutputSchema.safeParse(parsed.value);
return output.success ? output.data : undefined;
};
const capturedOutput = async (path: string): Promise<CapturedOutput> => {
let handle;
try {
handle = await open(path, "r");
} catch {
return { text: "", bytes: 0, truncated: false };
}
try {
const { size } = await handle.stat();
const buffer = Buffer.alloc(Math.min(size, MAX_OUTPUT_BYTES));
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
return {
text: buffer.subarray(0, bytesRead).toString("utf8"),
bytes: size,
truncated: size > MAX_OUTPUT_BYTES,
};
} finally {
await handle.close();
}
};
type LldbExit =
| {
readonly kind: "exited";
readonly exitCode: number | null;
readonly output: string;
}
| { readonly kind: "cancelled" }
| { readonly kind: "timeout" };
/** Run LLDB in its own process group so cancellation can stop it and debugserver. */
const runLldb = (
executable: string,
arguments_: readonly string[],
options: { readonly signal?: AbortSignal; readonly deadlineMs: number },
): Promise<LldbExit> =>
new Promise((resolve) => {
if (options.signal?.aborted === true) {
resolve({ kind: "cancelled" });
return;
}
const child = spawn(executable, [...arguments_], {
shell: false,
detached: true,
stdio: ["ignore", "pipe", "pipe"],
});
let output = "";
let stopped: "cancelled" | "timeout" | undefined;
const keep = (chunk: Buffer): void => {
output = (output + chunk.toString("utf8")).slice(-DIAGNOSTIC_BYTES);
};
child.stdout.on("data", keep);
child.stderr.on("data", keep);
const kill = (reason: "cancelled" | "timeout"): void => {
stopped ??= reason;
if (child.pid !== undefined)
try {
process.kill(-child.pid, "SIGKILL");
} catch {
// The group already exited.
}
};
const timer = setTimeout(() => {
kill("timeout");
}, options.deadlineMs);
const onAbort = (): void => {
kill("cancelled");
};
options.signal?.addEventListener("abort", onAbort, { once: true });
const finish = (exitCode: number | null): void => {
clearTimeout(timer);
options.signal?.removeEventListener("abort", onAbort);
resolve(
stopped === undefined
? { kind: "exited", exitCode, output }
: { kind: stopped },
);
};
child.on("error", () => {
finish(null);
});
child.on("close", (code) => {
finish(code);
});
});
/**
* debugserver kills the traced process when LLDB exits. Confirm it, and kill a
* survivor only when its executable is still the traced one.
*/
const ensureTerminated = async (
pidPath: string,
executable: string,
): Promise<boolean> => {
let pid: number;
try {
pid = Number.parseInt(await readFile(pidPath, "utf8"), 10);
} catch {
// LLDB never launched the target.
return true;
}
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
for (let attempt = 0; attempt < 20; attempt++) {
if (!alive(pid)) return true;
if (attempt === 10 && (await commandOf(pid)) === executable)
try {
process.kill(pid, "SIGKILL");
} catch {
// It exited between the checks.
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
return !alive(pid);
};
const alive = (pid: number): boolean => {
try {
process.kill(pid, 0);
return true;
} catch (cause: unknown) {
return !(
cause instanceof Error &&
"code" in cause &&
cause.code === "ESRCH"
);
}
};
const commandOf = async (pid: number): Promise<string | undefined> => {
try {
const { stdout } = await execFileOutput(
"/bin/ps",
["-o", "comm=", "-p", String(pid)],
{ timeout: 5_000 },
);
return stdout.trim();
} catch {
return undefined;
}
};
+182
View File
@@ -0,0 +1,182 @@
import { createHash } from "node:crypto";
import { open } from "node:fs/promises";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisInputError,
} from "../domain/analysisErrorCore.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import type { BinaryTarget } from "../domain/binaryTarget.js";
import { EvidenceIntegrityError } from "../domain/evidenceErrors.js";
import type { JsonValue } from "../domain/jsonValue.js";
import {
nativeCallObservationInputSchema,
nativeCallObservationResultSchema,
type NativeCallObservationInput,
type NativeCallObservationResult,
} from "../domain/nativeCallObservation.js";
import { err, ok, type Result } from "../domain/result.js";
import type { NativeCallTrace, NativeCallTracer } from "./LldbCallTracer.js";
const OPERATION = "observe_native_calls";
/** What every call observation leaves unobserved or approximated. */
export const NATIVE_CALL_OBSERVATION_LIMITATIONS = [
"Only entries are observed; return values, floating-point and stack-passed arguments are not captured.",
"Registers are the raw integer argument registers at entry. For Objective-C methods the first two hold the receiver and the selector.",
"Receiver classes come from LLDB's Objective-C runtime reader without running target code; they are null for class methods and for receivers LLDB cannot classify.",
"Calls that never reach a symbol's entry, such as inlined code or objc_direct methods called directly, are not observed.",
"The target is launched directly under LLDB, not through LaunchServices; LLDB disables ASLR for it, so load addresses can equal file addresses.",
"The observation window is checked about once a second, and recording pauses the process at every entry, which slows it.",
] as const;
/** Launch the active Mach-O under LLDB, record requested call entries, then stop it. */
export const observeNativeCalls = async (
target: BinaryTarget,
parameters: Readonly<Record<string, JsonValue>>,
tracer: NativeCallTracer,
signal?: AbortSignal,
): Promise<Result<NativeCallObservationResult, AnalysisError>> => {
if (target.kind !== "executable" || target.format !== "mach-o")
return err(
new AnalysisCapabilityUnavailableError(
"native-macos",
OPERATION,
"Native call observation launches a Mach-O executable; the active target is not one",
),
);
const parsed = nativeCallObservationInputSchema.safeParse(parameters);
if (!parsed.success)
return err(new AnalysisInputError(OPERATION, { cause: parsed.error }));
// Observe the bytes the session's other Evidence describes.
if ((await fileSha256(target.path, signal)) !== target.sha256)
return err(
new EvidenceIntegrityError(
"Native call observation target digest changed after session binding",
),
);
if (signal?.aborted === true)
return err(new AnalysisCancelledError(OPERATION));
const traced = await tracer.trace(
{
executable: target.path,
architecture: target.architecture,
input: parsed.data,
},
signal,
);
if (!traced.ok) return traced;
return ok(projectNativeCalls(target, parsed.data, traced.value));
};
/** Join the request, the tracer's run and REA's own checks into the result contract. */
export const projectNativeCalls = (
target: Pick<BinaryTarget, "path" | "sha256"> & {
readonly architecture: string;
},
input: NativeCallObservationInput,
trace: NativeCallTrace,
): NativeCallObservationResult => {
const { run } = trace;
const unresolved = run.breakpoints
.filter(({ location_count: count }) => count === 0)
.map(({ index }) => index);
const eventLimit = run.outcome === "event-limit";
const partial =
eventLimit || run.outcome === "stop-limit" || unresolved.length > 0;
return nativeCallObservationResultSchema.parse({
target: {
path: target.path,
sha256: target.sha256,
architecture: target.architecture,
arguments: input.arguments,
working_directory: input.working_directory ?? null,
},
debugger: trace.debugger,
process: {
pid: run.pid,
outcome: run.outcome,
exit_status: run.exit_status,
exit_description: run.exit_description,
terminated: run.terminated && trace.terminated,
elapsed_ms: run.elapsed_ms,
stdout: trace.stdout,
stderr: trace.stderr,
other_stops: run.other_stops,
},
breakpoints: input.breakpoints.map((request, index) => ({
index,
request:
request.kind === "function"
? { ...request, module: request.module ?? null }
: { ...request, class_name: request.class_name ?? null },
location_count: run.breakpoints[index]?.location_count ?? 0,
locations: run.breakpoints[index]?.locations ?? [],
})),
events: run.events,
coverage: {
status: partial ? "partial" : "complete",
event_limit_reached: eventLimit,
unresolved_breakpoints: unresolved,
},
limitations: [
...NATIVE_CALL_OBSERVATION_LIMITATIONS,
...(eventLimit
? [
`Observation stopped at max_events (${input.max_events}); later calls were not recorded and the process was killed.`,
]
: []),
...(run.outcome === "duration-elapsed"
? [
`The process was still running after duration_ms (${input.duration_ms}) and was killed.`,
]
: []),
...(run.outcome === "stop-limit"
? [
"Observation stopped after repeated signal or exception stops; the process was killed.",
]
: []),
...(unresolved.length === 0
? []
: [
`Breakpoints ${unresolved.join(", ")} matched no code in any image loaded while observing; their calls could not be observed.`,
]),
...(run.breakpoints.some(
({ location_count: count, locations }) => locations.length < count,
)
? [
"Breakpoints with many matching locations list only the first 64; location_count gives the total.",
]
: []),
...(trace.stdout.truncated || trace.stderr.truncated
? ["Target output beyond 1 MiB per stream is counted but not kept."]
: []),
...(run.terminated && trace.terminated
? []
: [
`REA could not confirm that process ${run.pid} exited; check for it before relying on host state.`,
]),
],
});
};
const fileSha256 = async (
path: string,
signal?: AbortSignal,
): Promise<string> => {
const handle = await open(path, "r");
try {
const hash = createHash("sha256");
const buffer = new Uint8Array(1024 * 1024);
for (;;) {
signal?.throwIfAborted();
const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
if (bytesRead === 0) break;
hash.update(buffer.subarray(0, bytesRead));
}
return hash.digest("hex");
} finally {
await handle.close();
}
};
+22 -1
View File
@@ -5,6 +5,8 @@ import {
export { NATIVE_MACOS_PROVIDER_IDENTITY } from "./NativeMacOSProviderMetadata.js";
import { inspectAppleDispatchMetadata } from "./AppleDispatchMetadata.js";
import { observeNativeUi } from "./NativeUiObservation.js";
import { LldbCallTracer, type NativeCallTracer } from "./LldbCallTracer.js";
import { observeNativeCalls } from "./NativeCallObservation.js";
import { dirname, isAbsolute, resolve } from "node:path";
import { open, realpath, stat } from "node:fs/promises";
@@ -72,6 +74,7 @@ export class NativeMacOSProvider implements AnalysisProvider {
constructor(
private readonly runner: NativeCommandRunner = new XcrunCommandRunner(),
platform: NodeJS.Platform = process.platform,
private readonly tracer: NativeCallTracer = new LldbCallTracer(),
) {
this.#capabilities = nativeMacOSCapabilities(platform);
}
@@ -85,7 +88,7 @@ export class NativeMacOSProvider implements AnalysisProvider {
}
createClient(target: BinaryTarget): AnalysisClient {
return new NativeMacOSClient(target, this.runner);
return new NativeMacOSClient(target, this.runner, this.tracer);
}
}
@@ -93,6 +96,7 @@ class NativeMacOSClient implements AnalysisClient {
constructor(
private readonly target: BinaryTarget,
private readonly runner: NativeCommandRunner,
private readonly tracer: NativeCallTracer,
) {}
async execute(
@@ -157,6 +161,22 @@ class NativeMacOSClient implements AnalysisClient {
"Operation is not implemented by native macOS tools.",
),
);
if (operation === "observe_native_calls") {
const result = await observeNativeCalls(
this.target,
parameters,
this.tracer,
options?.signal,
);
return result.ok
? ok(
createAnalysisExecution(result.value, IDENTITY, {
limitations: result.value.limitations,
locations: [{ kind: "artifact-path", path: this.target.path }],
}),
)
: result;
}
if (
operation === "observe_native_ui" ||
operation === "capture_native_ui_scenario"
@@ -208,6 +228,7 @@ class NativeMacOSClient implements AnalysisClient {
switch (operation) {
case "observe_native_ui":
case "capture_native_ui_scenario":
case "observe_native_calls":
return Promise.resolve(
err(
new AnalysisCapabilityUnavailableError(
+9 -3
View File
@@ -12,6 +12,12 @@ export const NATIVE_MACOS_PROVIDER_IDENTITY: ProviderIdentity = Object.freeze({
});
const IDENTITY = NATIVE_MACOS_PROVIDER_IDENTITY;
/** Operations whose target process can show UI, use the network and write files. */
const OWNED_PROCESS_OPERATIONS: ReadonlySet<string> = new Set([
"capture_native_ui_scenario",
"observe_native_calls",
]);
/** Declare native host coverage without creating a command runner. */
export const nativeMacOSCapabilities = (
platform: NodeJS.Platform = process.platform,
@@ -36,10 +42,10 @@ export const nativeMacOSCapabilities = (
effects: Object.freeze({
mutatesArtifact: false,
launchesProcess: contract.name !== "inspect_native_dispatch_metadata",
mayShowUi: contract.name === "capture_native_ui_scenario",
mayAccessNetwork: contract.name === "capture_native_ui_scenario",
mayShowUi: OWNED_PROCESS_OPERATIONS.has(contract.name),
mayAccessNetwork: OWNED_PROCESS_OPERATIONS.has(contract.name),
mayWriteFilesystem:
contract.name === "capture_native_ui_scenario" ||
OWNED_PROCESS_OPERATIONS.has(contract.name) ||
contract.name === "observe_native_ui",
changesPermissions: false,
requiresRoot: false,
@@ -0,0 +1,114 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { expect, it } from "vitest";
import { z } from "zod";
import { nativeCallObservationResultSchema } from "../../../src/domain/nativeCallObservation.js";
import { ok } from "../../../src/domain/result.js";
import type { NativeCallTracer } from "../../../src/native/LldbCallTracer.js";
import { NativeMacOSProvider } from "../../../src/native/NativeMacOSProvider.js";
import { createServer } from "../../../src/server/createServer.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { NativeFixtureRunner } from "../../fixtures/nativeCommands.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
/** A thin little-endian arm64 MH_EXECUTE header with no load commands. */
const machoHeader = (): Buffer => {
const header = Buffer.alloc(32);
header.writeUInt32LE(0xfeedfacf, 0);
header.writeUInt32LE(0x0100000c, 4);
header.writeUInt32LE(2, 12);
return header;
};
const tracer: NativeCallTracer & { calls: number } = {
calls: 0,
trace(request) {
this.calls++;
return Promise.resolve(
ok({
debugger: {
path: "/usr/bin/lldb",
sha256: "b".repeat(64),
version: "lldb-fixture",
},
run: {
status: "traced",
version: "lldb-fixture",
pid: 99,
outcome: "exited",
exit_status: 0,
exit_description: null,
killed: false,
terminated: true,
elapsed_ms: 1,
breakpoints: request.input.breakpoints.map((_, index) => ({
index,
location_count: 1,
locations: [],
})),
events: [],
other_stops: [],
},
stdout: { text: "", bytes: 0, truncated: false },
stderr: { text: "", bytes: 0, truncated: false },
terminated: true,
}),
);
},
};
it("routes observe_native_calls through MCP with schema-checked input and output", async () => {
const directory = await createTestTempDirectory("rea-native-calls-mcp-");
const path = join(directory, "Tool");
await writeFile(path, machoHeader());
const session = createTestBinarySession(
new NativeMacOSProvider(new NativeFixtureRunner(), "darwin", tracer),
);
const server = createServer(session, session);
const client = new Client({ name: "native-calls-mcp-test", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const opened = await client.callTool({
name: "open_binary",
arguments: { path },
});
expect(opened.isError, JSON.stringify(opened)).not.toBe(true);
const called = await client.callTool({
name: "observe_native_calls",
arguments: {
breakpoints: [{ kind: "function", name: "open" }],
duration_ms: 1000,
},
});
expect(called.isError, JSON.stringify(called.structuredContent)).not.toBe(
true,
);
const result = nativeCallObservationResultSchema.parse(
z.object({ result: z.unknown() }).parse(called.structuredContent).result,
);
expect(result.process).toMatchObject({ pid: 99, outcome: "exited" });
expect(result.breakpoints[0]?.request).toEqual({
kind: "function",
name: "open",
module: null,
});
const rejected = await client.callTool({
name: "observe_native_calls",
arguments: {
breakpoints: [{ kind: "function", name: "open" }],
duration_ms: 1,
},
});
expect(rejected.isError).toBe(true);
expect(tracer.calls).toBe(1);
} finally {
await client.close();
await server.close();
}
});
@@ -0,0 +1,293 @@
import { createHash } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
AnalysisCapabilityUnavailableError,
AnalysisCancelledError,
AnalysisInputError,
} from "../../../../src/domain/analysisErrorCore.js";
import type { AnalysisError } from "../../../../src/domain/analysisErrorBase.js";
import { EvidenceIntegrityError } from "../../../../src/domain/evidenceErrors.js";
import { nativeCallObservationResultSchema } from "../../../../src/domain/nativeCallObservation.js";
import { err, ok, type Result } from "../../../../src/domain/result.js";
import type {
NativeCallTrace,
NativeCallTracer,
} from "../../../../src/native/LldbCallTracer.js";
import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.js";
import {
NativeFixtureRunner,
nativeMachoTarget,
} from "../../../fixtures/nativeCommands.js";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
const LOCATION = {
load_address: "0x100000928",
file_address: "0x100000928",
module: "Fixture",
module_path: "/tmp/Fixture",
symbol: "-[Greeter greet:times:]",
};
const run = (
overrides: Partial<NativeCallTrace["run"]> = {},
): NativeCallTrace["run"] => ({
status: "traced",
version: "lldb-2100.0.17.203",
pid: 4242,
outcome: "exited",
exit_status: 0,
exit_description: null,
killed: false,
terminated: true,
elapsed_ms: 12.5,
breakpoints: [
{ index: 0, location_count: 1, locations: [LOCATION] },
{ index: 1, location_count: 1, locations: [LOCATION] },
],
events: [
{
sequence: 0,
elapsed_ms: 3,
thread_id: 7,
breakpoint_index: 0,
...LOCATION,
receiver_class: "Greeter",
selector: "greet:times:",
registers: [
{ name: "x0", value: "0x6000" },
{ name: "x1", value: "0x7000" },
],
backtrace: [],
},
],
other_stops: [],
...overrides,
});
/** Records the request and returns a canned run or failure. */
class FixtureTracer implements NativeCallTracer {
readonly requests: Parameters<NativeCallTracer["trace"]>[0][] = [];
constructor(
private readonly outcome: Result<NativeCallTrace, AnalysisError> = ok({
debugger: {
path: "/usr/bin/lldb",
sha256: "a".repeat(64),
version: "lldb-2100.0.17.203",
},
run: run(),
stdout: { text: "hello\n", bytes: 6, truncated: false },
stderr: { text: "", bytes: 0, truncated: false },
terminated: true,
}),
) {}
trace(request: Parameters<NativeCallTracer["trace"]>[0]) {
this.requests.push(request);
return Promise.resolve(this.outcome);
}
}
const BREAKPOINTS = [
{ kind: "objc-method", class_name: "Greeter", selector: "greet:times:" },
{ kind: "function", name: "rea_add" },
];
const fixtureTarget = async () => {
const directory = await createTestTempDirectory("rea-native-calls-");
const path = join(directory, "Fixture");
await writeFile(path, "fixture executable");
return {
...nativeMachoTarget(path),
sha256: createHash("sha256").update("fixture executable").digest("hex"),
};
};
const observe = async (
tracer: NativeCallTracer,
parameters: Record<string, unknown>,
target?: Awaited<ReturnType<typeof fixtureTarget>>,
signal?: AbortSignal,
) =>
new NativeMacOSProvider(new NativeFixtureRunner(), "darwin", tracer)
.createClient(target ?? (await fixtureTarget()))
.execute(
"observe_native_calls",
// The provider parses JSON-like parameters itself.
JSON.parse(JSON.stringify(parameters)),
signal === undefined ? undefined : { signal },
);
describe("observe_native_calls projection", () => {
it("launches the active Mach-O through the tracer and projects its run", async () => {
const tracer = new FixtureTracer();
const target = await fixtureTarget();
const observed = await observe(
tracer,
{ breakpoints: BREAKPOINTS, arguments: ["--flag"] },
target,
);
if (!observed.ok) throw observed.error;
expect(tracer.requests[0]).toMatchObject({
executable: target.path,
architecture: "arm64",
input: {
arguments: ["--flag"],
duration_ms: 10_000,
max_events: 1_000,
argument_registers: 4,
backtrace_frames: 0,
},
});
const result = nativeCallObservationResultSchema.parse(
observed.value.result,
);
expect(result.breakpoints.map(({ request }) => request)).toEqual([
{
kind: "objc-method",
class_name: "Greeter",
selector: "greet:times:",
method_type: "any",
},
{ kind: "function", name: "rea_add", module: null },
]);
expect(result.process).toMatchObject({
pid: 4242,
outcome: "exited",
exit_status: 0,
terminated: true,
stdout: { text: "hello\n" },
});
expect(result.events[0]).toMatchObject({
receiver_class: "Greeter",
selector: "greet:times:",
});
expect(result.coverage).toEqual({
status: "complete",
event_limit_reached: false,
unresolved_breakpoints: [],
});
expect(observed.value.locations).toEqual([
{ kind: "artifact-path", path: target.path },
]);
});
it("marks event limits, unresolved breakpoints and unconfirmed exits", async () => {
const tracer = new FixtureTracer(
ok({
debugger: {
path: "/usr/bin/lldb",
sha256: "a".repeat(64),
version: null,
},
run: run({
outcome: "event-limit",
exit_status: null,
killed: true,
terminated: false,
breakpoints: [
{ index: 0, location_count: 1, locations: [LOCATION] },
{ index: 1, location_count: 0, locations: [] },
],
}),
stdout: { text: "", bytes: 0, truncated: false },
stderr: { text: "x".repeat(16), bytes: 2_000_000, truncated: true },
terminated: true,
}),
);
const observed = await observe(tracer, {
breakpoints: BREAKPOINTS,
max_events: 1,
});
if (!observed.ok) throw observed.error;
const result = nativeCallObservationResultSchema.parse(
observed.value.result,
);
expect(result.coverage).toEqual({
status: "partial",
event_limit_reached: true,
unresolved_breakpoints: [1],
});
expect(result.process.terminated).toBe(false);
expect(result.limitations).toEqual(
expect.arrayContaining([
"Observation stopped at max_events (1); later calls were not recorded and the process was killed.",
"Breakpoints 1 matched no code in any image loaded while observing; their calls could not be observed.",
"Target output beyond 1 MiB per stream is counted but not kept.",
"REA could not confirm that process 4242 exited; check for it before relying on host state.",
]),
);
});
});
describe("observe_native_calls failures", () => {
it("rejects malformed input before launching anything", async () => {
for (const parameters of [
{ breakpoints: [] },
{ breakpoints: [{ kind: "objc-method", selector: "-[A b]" }] },
{ breakpoints: BREAKPOINTS, environment: { "A=B": "x" } },
{ breakpoints: BREAKPOINTS, argument_registers: 9 },
]) {
const tracer = new FixtureTracer();
const observed = await observe(tracer, parameters);
expect(observed.ok).toBe(false);
if (!observed.ok)
expect(observed.error).toBeInstanceOf(AnalysisInputError);
expect(tracer.requests).toEqual([]);
}
});
it("refuses a changed or non-Mach-O target", async () => {
const tracer = new FixtureTracer();
const target = await fixtureTarget();
const changed = await observe(
tracer,
{ breakpoints: BREAKPOINTS },
{ ...target, sha256: "f".repeat(64) },
);
expect(!changed.ok && changed.error).toBeInstanceOf(EvidenceIntegrityError);
const database = await new NativeMacOSProvider(
new NativeFixtureRunner(),
"darwin",
tracer,
)
.createClient({
path: target.path,
sha256: target.sha256,
kind: "database",
format: "analysis-database",
})
.execute("observe_native_calls", { breakpoints: [] });
expect(!database.ok && database.error).toBeInstanceOf(
AnalysisCapabilityUnavailableError,
);
expect(tracer.requests).toEqual([]);
});
it("keeps the tracer's failure reason and caller cancellation", async () => {
const denied = new AnalysisCapabilityUnavailableError(
"native-macos",
"observe_native_calls",
"debugger-attach-denied: attach failed",
);
const failed = await observe(new FixtureTracer(err(denied)), {
breakpoints: BREAKPOINTS,
});
expect(!failed.ok && failed.error).toBe(denied);
const controller = new AbortController();
controller.abort();
const cancelled = await observe(
new FixtureTracer(),
{ breakpoints: BREAKPOINTS },
undefined,
controller.signal,
);
expect(!cancelled.ok && cancelled.error).toBeInstanceOf(
AnalysisCancelledError,
);
});
});
+33
View File
@@ -0,0 +1,33 @@
#import <Foundation/Foundation.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
// Observed by verify:native-calls; `--wait` keeps the process running.
@interface ReaCallGreeter : NSObject
- (NSString *)greet:(NSString *)name times:(long)count;
@end
@implementation ReaCallGreeter
- (NSString *)greet:(NSString *)name times:(long)count {
return [NSString stringWithFormat:@"%@ x%ld", name, count];
}
@end
__attribute__((noinline)) int rea_call_add(int left, int right) {
return left + right;
}
int main(int argc, char **argv) {
@autoreleasepool {
if (argc > 1 && strcmp(argv[1], "--wait") == 0)
for (;;) sleep(1);
ReaCallGreeter *greeter = [ReaCallGreeter new];
for (long index = 0; index < 3; index++)
printf("%s %d\n", [[greeter greet:@"world" times:index] UTF8String],
rea_call_add((int)index, 40));
const char *marker = getenv("REA_CALLS_MARKER");
if (marker != NULL)
printf("marker %s\n", marker);
}
return 0;
}