mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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:
1 parent
a6ba772c08
commit
9837203234
31 files changed
+1970
-26
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 固件区域检查与显式提取 |
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,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),
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
@@ -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>> => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user