diff --git a/bridge/native/rea_lldb_tracer.py b/bridge/native/rea_lldb_tracer.py index 306a30e593..cc8bd2b7e5 100644 --- a/bridge/native/rea_lldb_tracer.py +++ b/bridge/native/rea_lldb_tracer.py @@ -3,12 +3,18 @@ REA imports this module into `lldb --batch` and runs `rea_trace `. 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. +continues, and kills the process once it exits, the event or aggregate resource +limit is reached, or the duration elapses. Target output is drained through +bounded FIFOs. 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 errno +import hashlib import json +import os +import select +import threading import time import lldb @@ -22,9 +28,198 @@ ARGUMENT_REGISTERS = { MAX_SELECTOR_BYTES = 1024 MAX_REPORTED_LOCATIONS = 64 MAX_OTHER_STOPS = 256 +MAX_TRACE_BYTES = 8 * 1024 * 1024 +MAX_TRACE_FRAMES = 65_536 +MAX_TARGET_OUTPUT_BYTES = 1024 * 1024 +OUTPUT_DRAIN_BYTES_PER_PASS = 1024 * 1024 +OUTPUT_EOF_WAIT_SECONDS = 1.0 +OUTPUT_READER_JOIN_SECONDS = 0.5 LISTENER_NAME = "rea-native-call-observation" +def _can_retain_trace(current_bytes, current_frames, event_bytes, event_frames): + """Whether a complete event fits both aggregate trace budgets.""" + return ( + current_bytes + event_bytes <= MAX_TRACE_BYTES + and current_frames + event_frames <= MAX_TRACE_FRAMES + ) + + +def _estimated_json_size(value): + """Estimate JSON size without encoding potentially oversized values.""" + if isinstance(value, str): + # json.dumps defaults to ensure_ascii=True; supplementary characters + # can become two six-byte Unicode escapes. + return len(value) * 12 + 2 + if value is None or isinstance(value, (bool, int, float)): + return 32 + if isinstance(value, dict): + return 2 + sum( + len(key) * 6 + 4 + _estimated_json_size(item) + for key, item in value.items() + ) + if isinstance(value, (list, tuple)): + return 2 + sum(_estimated_json_size(item) + 1 for item in value) + return 64 + + +def _fits_trace_budget(value, remaining_bytes): + return _estimated_json_size(value) <= remaining_bytes + + +def _set_launch_flags(launch): + launch.SetLaunchFlags( + launch.GetLaunchFlags() + | lldb.eLaunchFlagDebug + | lldb.eLaunchFlagStopAtEntry + ) + + +def _hash_file(path): + digest = hashlib.sha256() + with open(path, "rb") as target_file: + while True: + chunk = target_file.read(1024 * 1024) + if not chunk: + break + digest.update(chunk) + return digest.hexdigest() + + +def _file_identity(path): + info = os.stat(path) + return str(info.st_dev), str(info.st_ino) + + +def _module_identity(target): + module = target.FindModule(target.GetExecutable()) + if not module.IsValid(): + return None, None, None + spec = module.GetFileSpec() + path = str(spec) if spec.IsValid() else None + identity = None + if path: + try: + identity = _file_identity(path) + except OSError: + pass + return path, identity, module.GetUUIDString() or None + + +def _wait_for_identity_ack(config): + """Hold the target at entry while the host adapter captures its start identity.""" + path = config.get("identity_ack_path") + if not path: + return + while True: + try: + with open(path, "r") as handle: + return handle.read(32).strip() + except FileNotFoundError: + time.sleep(0.025) + + +class _OutputPipe: + """Drain a target FIFO while retaining only its bounded output prefix.""" + + def __init__(self, fifo_path, capture_path, max_bytes): + os.mkfifo(fifo_path, 0o600) + self.path = fifo_path + try: + self.fd = os.open(fifo_path, os.O_RDONLY | os.O_NONBLOCK) + except Exception: + os.unlink(fifo_path) + raise + try: + self.capture = open(capture_path, "wb") + except Exception: + os.close(self.fd) + os.unlink(fifo_path) + raise + self.max_bytes = max_bytes + self.bytes = 0 + self.truncated = False + self.closed = False + self.stop_reader = threading.Event() + self.reader_thread = None + self.reader_error = None + self.launched = False + self.eof = threading.Event() + self.complete = False + + def start(self): + if self.reader_thread is None: + self.reader_thread = threading.Thread(target=self._reader_loop, daemon=True) + self.reader_thread.start() + + def _reader_loop(self): + try: + while not self.stop_reader.is_set(): + readable, _, _ = select.select([self.fd], [], [], 0.05) + if not readable: + continue + if self.drain(OUTPUT_DRAIN_BYTES_PER_PASS) == 0: + # A FIFO read end is readable at EOF before the target opens it. + time.sleep(0.01) + except Exception as error: + self.reader_error = error + self.stop_reader.set() + + def mark_launched(self): + self.launched = True + + def drain(self, max_bytes=OUTPUT_DRAIN_BYTES_PER_PASS): + consumed = 0 + while consumed < max_bytes: + try: + chunk = os.read(self.fd, min(65_536, max_bytes - consumed)) + except OSError as error: + if error.errno in (errno.EAGAIN, errno.EWOULDBLOCK): + return consumed + raise + if not chunk: + if self.launched: + self.eof.set() + return consumed + consumed += len(chunk) + self.bytes += len(chunk) + remaining = max(0, self.max_bytes - self.capture.tell()) + if remaining: + self.capture.write(chunk[:remaining]) + if len(chunk) > remaining: + self.truncated = True + + def close(self, expected_exit=False): + if self.closed: + return + if expected_exit and self.launched: + self.eof.wait(OUTPUT_EOF_WAIT_SECONDS) + self.stop_reader.set() + if self.reader_thread is not None: + self.reader_thread.join(OUTPUT_READER_JOIN_SECONDS) + stopped = self.reader_thread is None or not self.reader_thread.is_alive() + if stopped: + try: + self.drain(OUTPUT_DRAIN_BYTES_PER_PASS) + finally: + try: + self.capture.close() + finally: + os.close(self.fd) + self.closed = True + else: + # A daemon reader may still be unwinding a blocked filesystem write. + # Leave its handles alone rather than racing that thread at shutdown. + self.closed = True + self.complete = bool( + stopped + and (not self.launched or (expected_exit and self.eof.is_set())) + and self.reader_error is None + ) + if not self.complete: + self.truncated = True + + def _hex(value): return "0x%x" % value @@ -34,7 +229,7 @@ def _location(address, target): module = address.GetModule() symbol = address.GetSymbol() file_address = address.GetFileAddress() - return { + location = { "load_address": _hex(address.GetLoadAddress(target)), "file_address": None if file_address == lldb.LLDB_INVALID_ADDRESS @@ -43,6 +238,7 @@ def _location(address, target): "module_path": str(module.GetFileSpec()) if module.IsValid() else None, "symbol": symbol.GetName() if symbol.IsValid() else None, } + return location def _no_files(): @@ -107,10 +303,9 @@ def _wanted(spec, symbol): return symbol.startswith("-[" if method_type == "instance" else "+[") -def _record(context, thread, index, sequence): +def _record(context, thread, index, sequence, location, remaining_bytes): 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 @@ -133,7 +328,14 @@ def _record(context, thread, index, sequence): receiver_class = _dynamic_class( target, frame.FindRegister(names[0]).GetValueAsUnsigned() ) - frames = min(thread.GetNumFrames(), 1 + config["backtrace_frames"]) + estimated_base = ( + _estimated_json_size(location) + + _estimated_json_size(receiver_class) + + _estimated_json_size(selector) + + 2048 + ) + if estimated_base > remaining_bytes: + raise ValueError("trace event exceeds aggregate byte budget") record = { "sequence": sequence, "elapsed_ms": round((time.monotonic() - context["started"]) * 1000, 3), @@ -142,10 +344,7 @@ def _record(context, thread, index, sequence): "receiver_class": receiver_class, "selector": selector, "registers": registers, - "backtrace": [ - _location(thread.GetFrameAtIndex(depth).GetPCAddress(), target) - for depth in range(1, frames) - ], + "backtrace": [], } record.update(location) return record @@ -160,9 +359,62 @@ def _handle_stop(context, events, other_stops): index = context["breakpoint_index"].get(thread.GetStopReasonDataAtIndex(0)) if index is None: continue - record = _record(context, thread, index, len(events)) + frame = thread.GetFrameAtIndex(0) + location = _location(frame.GetPCAddress(), context["target"]) + symbol = location["symbol"] or "" + if not _wanted(context["config"]["breakpoints"][index], symbol): + continue + frame_count = max( + 0, + min(thread.GetNumFrames() - 1, context["config"]["backtrace_frames"]), + ) + event_frame_count = 1 + frame_count + if not _can_retain_trace( + context["retained_trace_bytes"], + context["retained_frames"], + 0, + event_frame_count, + ): + return "resource-limit" + try: + remaining_bytes = MAX_TRACE_BYTES - context["retained_trace_bytes"] + record = _record( + context, thread, index, len(events), location, remaining_bytes + ) + except ValueError: + return "resource-limit" if record is not None: + if not _fits_trace_budget(record, remaining_bytes): + return "resource-limit" + record_bytes = len(json.dumps(record).encode("utf-8")) + if context["retained_trace_bytes"] + record_bytes > MAX_TRACE_BYTES: + return "resource-limit" + for depth in range(1, frame_count + 1): + try: + location = _location( + thread.GetFrameAtIndex(depth).GetPCAddress(), context["target"] + ) + except ValueError: + return "resource-limit" + if ( + _estimated_json_size(location) + record_bytes + 1 + > MAX_TRACE_BYTES - context["retained_trace_bytes"] + ): + return "resource-limit" + location_bytes = len(json.dumps(location).encode("utf-8")) + addition = location_bytes + (1 if record["backtrace"] else 0) + if ( + context["retained_trace_bytes"] + + record_bytes + + addition + > MAX_TRACE_BYTES + ): + return "resource-limit" + record["backtrace"].append(location) + record_bytes += addition events.append(record) + context["retained_frames"] += event_frame_count + context["retained_trace_bytes"] += record_bytes if len(events) >= context["config"]["max_events"]: return "event-limit" elif reason in (lldb.eStopReasonSignal, lldb.eStopReasonException): @@ -172,18 +424,20 @@ def _handle_stop(context, events, other_stops): return None -def _breakpoint_report(target, created): +def _breakpoint_report(target, created, config): report = [] for index, breakpoints in enumerate(created): locations = [] count = 0 for breakpoint in breakpoints: - count += breakpoint.GetNumLocations() for position in range(breakpoint.GetNumLocations()): + address = breakpoint.GetLocationAtIndex(position).GetAddress() + location = _location(address, target) + if not _wanted(config["breakpoints"][index], location["symbol"] or ""): + continue + count += 1 if len(locations) < MAX_REPORTED_LOCATIONS: - locations.append( - _location(breakpoint.GetLocationAtIndex(position).GetAddress(), target) - ) + locations.append(location) report.append({"index": index, "location_count": count, "locations": locations}) return report @@ -191,6 +445,7 @@ def _breakpoint_report(target, created): def _launch_info(target, config, listener): launch = lldb.SBLaunchInfo(config["arguments"]) launch.SetListener(listener) + _set_launch_flags(launch) environment = target.GetEnvironment() for key, value in config["environment"].items(): environment.Set(key, value, True) @@ -217,74 +472,229 @@ def trace(debugger, config): lldb.SBDebugger.SetInternalVariable( "target.skip-prologue", "false", debugger.GetInstanceName() ) + try: + before_digest = _hash_file(config["executable"]) + before_identity = _file_identity(config["executable"]) + except OSError as error: + return {"status": "target-integrity-error", "error": str(error)} + if before_digest != config["expected_sha256"]: + return { + "status": "target-integrity-error", + "error": "selected executable digest changed before LLDB target creation", + } error = lldb.SBError() target = debugger.CreateTarget( config["executable"], config["architecture"], None, False, error ) if not target.IsValid(): return {"status": "target-error", "error": error.GetCString()} + module_path_before, module_file_identity_before, module_uuid_before = ( + _module_identity(target) + ) + if ( + module_file_identity_before is not None + and module_file_identity_before != before_identity + ): + return { + "status": "target-integrity-error", + "error": "LLDB executable module resolved to a different pre-launch file identity", + } 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) + output_budget = config.get("max_output_bytes", MAX_TARGET_OUTPUT_BYTES) + stdout = _OutputPipe( + config["stdout_path"], config["stdout_capture_path"], output_budget + ) + try: + stderr = _OutputPipe( + config["stderr_path"], config["stderr_capture_path"], output_budget + ) + except Exception: + stdout.close() + raise + stdout.start() + stderr.start() 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, - } + process = None + try: + process = target.Launch(_launch_info(target, config, listener), error) + if not error.Success() or not process.IsValid(): + stdout.close() + stderr.close() + return {"status": "launch-error", "error": error.GetCString()} + stdout.mark_launched() + stderr.mark_launched() + # Publish the PID before any post-launch file or module inspection. The + # process is stopped at entry and remains there until identity_ack. + with open(config["pid_path"], "w") as handle: + handle.write(str(process.GetProcessID())) + identity_ack = _wait_for_identity_ack(config) + if identity_ack != "readable": + process.Kill() + _wait_for_exit(listener, process, 5) + expected_exit = process.GetState() == lldb.eStateExited + stdout.close(expected_exit=expected_exit) + stderr.close(expected_exit=expected_exit) + return { + "status": "target-identity-unavailable", + "error": "host could not establish the launched process identity; target was stopped", + } + try: + after_digest = _hash_file(config["executable"]) + after_identity = _file_identity(config["executable"]) + except OSError as identity_error: + process.Kill() + stdout.close(expected_exit=process.GetState() == lldb.eStateExited) + stderr.close(expected_exit=process.GetState() == lldb.eStateExited) + return {"status": "target-integrity-error", "error": str(identity_error)} + if after_digest != config["expected_sha256"] or after_identity != before_identity: + process.Kill() + _wait_for_exit(listener, process, 5) + stdout.close(expected_exit=process.GetState() == lldb.eStateExited) + stderr.close(expected_exit=process.GetState() == lldb.eStateExited) + return { + "status": "target-integrity-error", + "error": "selected executable digest or file identity changed during LLDB launch", + } + module_path_after, module_file_identity_after, module_uuid_after = ( + _module_identity(target) + ) + if ( + module_file_identity_after is not None + and module_file_identity_after != before_identity + ): + process.Kill() + _wait_for_exit(listener, process, 5) + stdout.close(expected_exit=process.GetState() == lldb.eStateExited) + stderr.close(expected_exit=process.GetState() == lldb.eStateExited) + return { + "status": "target-integrity-error", + "error": "LLDB executable module resolved to a different file identity", + } + if ( + module_uuid_before is not None + and module_uuid_after is not None + and module_uuid_before != module_uuid_after + ): + process.Kill() + _wait_for_exit(listener, process, 5) + stdout.close(expected_exit=process.GetState() == lldb.eStateExited) + stderr.close(expected_exit=process.GetState() == lldb.eStateExited) + return { + "status": "target-integrity-error", + "error": "LLDB executable module identity changed during launch", + } + context = { + "target": target, + "process": process, + "config": config, + "started": started, + "breakpoint_index": breakpoint_index, + "retained_frames": 0, + "retained_trace_bytes": 0, + } + events = [] + other_stops = [] + outcome = None + deadline = started + config["duration_ms"] / 1000 + event = lldb.SBEvent() + while outcome is None: + if stdout.reader_error is not None: + raise RuntimeError("stdout capture failed: %s" % stdout.reader_error) + if stderr.reader_error is not None: + raise RuntimeError("stderr capture failed: %s" % stderr.reader_error) + 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) + process_exited = process.GetState() == lldb.eStateExited + stdout.close(expected_exit=process_exited) + stderr.close(expected_exit=process_exited) + launch_identity_stable = ( + module_path_before is not None + and module_path_after == module_path_before + and module_file_identity_before == before_identity + and module_file_identity_after == module_file_identity_before + and module_uuid_before is not None + and module_uuid_after == module_uuid_before + ) + 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, config), + "events": events, + "resource_limit_reached": outcome == "resource-limit", + "target_output": { + "stdout_bytes": stdout.bytes, + "stderr_bytes": stderr.bytes, + "stdout_truncated": stdout.truncated, + "stderr_truncated": stderr.truncated, + "stdout_complete": stdout.complete, + "stderr_complete": stderr.complete, + }, + "target_identity": { + "selected_file_sha256": after_digest, + "loaded_image_sha256": None, + "file_device": after_identity[0], + "file_inode": after_identity[1], + "module_path": module_path_after, + "module_uuid": module_uuid_after, + # This records matching path/module identity observations. LLDB + # does not expose the exact mapped bytes, so it never proves a digest. + "stable": launch_identity_stable, + }, + "other_stops": other_stops, + } + + finally: + if process is not None: + try: + if process.IsValid() and process.GetState() != lldb.eStateExited: + process.Kill() + _wait_for_exit(listener, process, 5) + except Exception: + pass + for output_pipe in (stdout, stderr): + try: + output_pipe.close( + expected_exit=( + process is not None + and process.IsValid() + and process.GetState() == lldb.eStateExited + ) + ) + except Exception: + pass def run(debugger, command, result, internal_dict): diff --git a/bridge/native/test_rea_lldb_tracer.py b/bridge/native/test_rea_lldb_tracer.py new file mode 100644 index 0000000000..eef0c5aa78 --- /dev/null +++ b/bridge/native/test_rea_lldb_tracer.py @@ -0,0 +1,167 @@ +"""Source-owned tests for the LLDB bridge's bounded retention helpers.""" + +import importlib.util +import os +import sys +import tempfile +import types +import unittest + + +fake_lldb = sys.modules.setdefault("lldb", types.ModuleType("lldb")) +fake_lldb.LLDB_INVALID_ADDRESS = -1 +fake_lldb.eLaunchFlagDebug = 2 +fake_lldb.eLaunchFlagStopAtEntry = 4 +spec = importlib.util.spec_from_file_location( + "rea_lldb_tracer", os.path.join(os.path.dirname(__file__), "rea_lldb_tracer.py") +) +tracer = importlib.util.module_from_spec(spec) +spec.loader.exec_module(tracer) + + +class NativeTraceBudgetTests(unittest.TestCase): + def test_aggregate_trace_budget_admits_exact_boundary_and_rejects_next_record(self): + self.assertTrue( + tracer._can_retain_trace( + tracer.MAX_TRACE_BYTES - 10, + tracer.MAX_TRACE_FRAMES - 2, + 10, + 2, + ) + ) + self.assertFalse( + tracer._can_retain_trace( + tracer.MAX_TRACE_BYTES - 10, + tracer.MAX_TRACE_FRAMES - 2, + 11, + 2, + ) + ) + self.assertFalse( + tracer._can_retain_trace( + tracer.MAX_TRACE_BYTES - 10, + tracer.MAX_TRACE_FRAMES - 2, + 10, + 3, + ) + ) + + def test_oversized_producer_string_is_rejected_before_serialization(self): + self.assertTrue(tracer._fits_trace_budget("method", 1024)) + self.assertFalse(tracer._fits_trace_budget("m" * 2048, 1024)) + self.assertGreater( + tracer._estimated_json_size("🙂"), len('"🙂"'.encode("utf-8")) + ) + + def test_launch_flags_preserve_default_debug_and_stop_at_entry(self): + class Launch: + flags = 10 + + def GetLaunchFlags(self): + return self.flags + + def SetLaunchFlags(self, flags): + self.flags = flags + + launch = Launch() + tracer._set_launch_flags(launch) + self.assertEqual(launch.flags, 14) + + def test_output_pipe_counts_all_bytes_and_keeps_only_the_prefix(self): + with tempfile.TemporaryDirectory() as directory: + fifo = os.path.join(directory, "stdout.fifo") + capture = os.path.join(directory, "stdout.capture") + sink = tracer._OutputPipe(fifo, capture, 5) + sink.start() + sink.mark_launched() + writer = os.open(fifo, os.O_WRONLY) + try: + for index in range(100): + chunk = (b"hello world" if index == 0 else b"x" * 4096) + os.write(writer, chunk) + finally: + os.close(writer) + sink.drain() + sink.close(expected_exit=True) + with open(capture, "rb") as retained: + self.assertEqual(retained.read(), b"hello") + self.assertEqual(sink.bytes, 11 + 99 * 4096) + self.assertTrue(sink.truncated) + self.assertTrue(sink.complete) + + def test_output_pipe_reports_incomplete_capture_after_unexpected_close(self): + with tempfile.TemporaryDirectory() as directory: + fifo = os.path.join(directory, "stdout.fifo") + capture = os.path.join(directory, "stdout.capture") + sink = tracer._OutputPipe(fifo, capture, 5) + sink.start() + sink.mark_launched() + sink.close(expected_exit=False) + self.assertFalse(sink.complete) + self.assertTrue(sink.truncated) + + def test_selector_location_report_uses_the_requested_method_type(self): + class Symbol: + def IsValid(self): + return True + + def GetName(self): + return "+[Greeter shared]" + + class FileSpec: + def GetFilename(self): + return "Fixture" + + def __str__(self): + return "/tmp/Fixture" + + class Module: + def IsValid(self): + return True + + def GetFileSpec(self): + return FileSpec() + + class Address: + def GetModule(self): + return Module() + + def GetSymbol(self): + return Symbol() + + def GetFileAddress(self): + return 1 + + def GetLoadAddress(self, target): + return 2 + + class Location: + def GetAddress(self): + return Address() + + class Breakpoint: + def GetNumLocations(self): + return 1 + + def GetLocationAtIndex(self, index): + return Location() + + report = tracer._breakpoint_report( + object(), + [[Breakpoint()]], + { + "breakpoints": [ + { + "kind": "objc-method", + "selector": "shared", + "method_type": "instance", + } + ] + }, + ) + self.assertEqual(report[0]["location_count"], 0) + self.assertEqual(report[0]["locations"], []) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 48860c5311..7bc105ba48 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -188,16 +188,30 @@ 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 run ends when the process exits, `max_events` entries are recorded, +`duration_ms` elapses, or the aggregate trace resource budget is exhausted. +Bounded termination reports partial coverage. REA checks process termination +using a launch-time OS start identity; if ownership or cleanup cannot be +verified, the result or error preserves that uncertainty. The result reports: -- the outcome, exit status and captured stdout/stderr (1 MiB kept per stream); +- the outcome, exit status and captured stdout/stderr (1 MiB kept per stream + while excess bytes are drained and counted without growing capture files). + Each stream reports `complete`; when draining could not finish, its byte + count is an observed lower bound and truncation is explicit; +- caller-selected arguments, working directory, and environment overrides; +- `selected_file_sha256` and LLDB module identity observations. These checks do + not pin the executable against concurrent mutation; `loaded_image_sha256` + remains unknown; - signal or exception stops; - every breakpoint's resolved locations; a breakpoint that matched no loaded code makes coverage partial; - limitations. +The bridge limits retained event JSON to 8 MiB and caller frames to 65,536 +across the run. It checks admission before retaining another complete event; +`resource-limit` reports which observations could not be completed without +silently changing the requested event or frame settings. + 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. diff --git a/docs/testing.md b/docs/testing.md index bbd5df1fa6..961100c980 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -532,17 +532,19 @@ conformance/vtable fixture. 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 +`npm run verify:native-calls` needs macOS with Command Line Tools (`clang`, +`lldb`, `codesign`, `nm`) and Developer Mode enabled for unattended debugger +access. Preflight reports disabled Developer Mode without changing host settings. +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; +- captured stdout and an environment override, plus a 2 MiB flood on each + output stream with bounded retained prefixes and exact drained-byte counts; - 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 diff --git a/scripts/lib/artifact-e2e.mjs b/scripts/lib/artifact-e2e.mjs index f51327fffc..bdbff263f9 100644 --- a/scripts/lib/artifact-e2e.mjs +++ b/scripts/lib/artifact-e2e.mjs @@ -14,13 +14,23 @@ const environment = () => ({ }); /** Execute the compiled CLI with production providers and no test adapters. */ -export async function artifactCli(command, target, arguments_ = []) { - return (await artifactCliEvidence(command, target, arguments_)) +export async function artifactCli( + command, + target, + arguments_ = [], + options = {}, +) { + return (await artifactCliEvidence(command, target, arguments_, options)) .normalized_result; } /** Execute the compiled CLI and return its complete Evidence record. */ -export async function artifactCliEvidence(command, target, arguments_ = []) { +export async function artifactCliEvidence( + command, + target, + arguments_ = [], + { timeoutMs = 60000 } = {}, +) { const { stdout } = await promisify(execFile)( process.execPath, [ @@ -30,7 +40,7 @@ export async function artifactCliEvidence(command, target, arguments_ = []) { ...arguments_, "--json", ], - { env: environment(), timeout: 60000, maxBuffer: 16 * 1024 * 1024 }, + { env: environment(), timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, ); const evidence = JSON.parse(stdout); assert.equal(evidence.error, undefined, stdout); diff --git a/scripts/verify-native-calls.mjs b/scripts/verify-native-calls.mjs index f3b838d4d6..a26b16dd82 100644 --- a/scripts/verify-native-calls.mjs +++ b/scripts/verify-native-calls.mjs @@ -27,6 +27,14 @@ for (const tool of ["clang", "lldb", "codesign", "nm"]) { cause }, ); } +const developerMode = await exec("/usr/sbin/DevToolsSecurity", ["-status"]); +if ( + !/enabled/iu.test(developerMode.stdout) || + /disabled/iu.test(developerMode.stdout) +) + throw new Error( + "Native call observation verification requires macOS Developer Mode; DevToolsSecurity reports it disabled.", + ); const SOURCE = fileURLToPath( new URL("../tests/conformance/native/calls.m", import.meta.url), @@ -46,6 +54,7 @@ const INPUT = { argument_registers: 4, backtrace_frames: 1, }; +const CLI_TIMEOUT_MS = 180_000; /** Pointer-free facts that two runs of the same scenario must share. */ const stable = (result) => @@ -68,17 +77,23 @@ const symbolAddress = async (binary, symbol) => { }; const checkScenario = async (binary) => { - const result = await artifactCli("observe-native-calls", binary, [ - JSON.stringify(INPUT), - ]); + const result = await artifactCli( + "observe-native-calls", + binary, + [JSON.stringify(INPUT)], + { timeoutMs: CLI_TIMEOUT_MS }, + ); 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.equal(result.process.stdout.complete, true); + assert.equal(result.process.stderr.complete, true); assert.deepEqual(result.coverage, { status: "complete", event_limit_reached: false, + resource_limit_reached: false, unresolved_breakpoints: [], }); const greet = result.events.filter(({ symbol }) => symbol === GREET); @@ -115,25 +130,59 @@ const alive = (pid) => { }; 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, - }), - ]); + const flooded = await artifactCli( + "observe-native-calls", + binary, + [ + JSON.stringify({ + breakpoints: [{ kind: "function", name: "rea_never_called" }], + arguments: ["--flood"], + duration_ms: 30_000, + }), + ], + { timeoutMs: CLI_TIMEOUT_MS }, + ); + assert.equal(flooded.process.outcome, "exited"); + assert.equal(flooded.process.stdout.bytes, 2 * 1024 * 1024); + assert.equal(flooded.process.stderr.bytes, 2 * 1024 * 1024); + assert.equal(flooded.process.stdout.text.length, 1024 * 1024); + assert.equal(flooded.process.stderr.text.length, 1024 * 1024); + assert.equal(flooded.process.stdout.text, "O".repeat(1024 * 1024)); + assert.equal(flooded.process.stderr.text, "E".repeat(1024 * 1024)); + assert.equal(flooded.process.stdout.truncated, true); + assert.equal(flooded.process.stderr.truncated, true); + assert.equal(flooded.process.stdout.complete, true); + assert.equal(flooded.process.stderr.complete, true); + + const limited = await artifactCli( + "observe-native-calls", + binary, + [ + JSON.stringify({ + breakpoints: [{ kind: "function", name: "nanosleep" }], + arguments: ["--wait"], + max_events: 2, + duration_ms: 30_000, + }), + ], + { timeoutMs: CLI_TIMEOUT_MS }, + ); 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, - }), - ]); + const elapsed = await artifactCli( + "observe-native-calls", + binary, + [ + JSON.stringify({ + breakpoints: [{ kind: "function", name: "rea_call_add" }], + arguments: ["--wait"], + duration_ms: 1_500, + }), + ], + { timeoutMs: CLI_TIMEOUT_MS }, + ); assert.equal(elapsed.process.outcome, "duration-elapsed"); assert.equal(elapsed.events.length, 0); assert.equal(alive(elapsed.process.pid), false); @@ -150,7 +199,10 @@ const cliFailure = async (binary) => { JSON.stringify(INPUT), "--json", ], - { env: { ...process.env, REA_LOG_LEVEL: "silent" }, timeout: 120_000 }, + { + env: { ...process.env, REA_LOG_LEVEL: "silent" }, + timeout: CLI_TIMEOUT_MS, + }, ); } catch (cause) { return JSON.parse(cause.stdout); diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts index 47fd251edf..0bc40442cc 100644 --- a/src/domain/analysisErrorBase.ts +++ b/src/domain/analysisErrorBase.ts @@ -39,26 +39,37 @@ export interface AnalysisCapturedOutput { readonly truncated: boolean; } +/** Cleanup uncertainty attached to the original typed analysis failure. */ +export interface AnalysisCleanupObservation { + readonly reason: string; + readonly resources: readonly string[]; +} + /** Optional provider-neutral context that must survive typed error projection. */ export interface AnalysisErrorOptions extends ErrorOptions { readonly capturedOutput?: AnalysisCapturedOutput; + readonly cleanup?: AnalysisCleanupObservation; } /** Base class for expected analysis, provider, and session failures. */ export abstract class AnalysisError extends Error { readonly capturedOutput: AnalysisCapturedOutput | undefined; + readonly cleanup: AnalysisCleanupObservation | undefined; + readonly cleanupIncomplete: boolean; + readonly cleanupResources: readonly string[]; constructor(message: string, options?: AnalysisErrorOptions) { super(message, options); this.capturedOutput = options?.capturedOutput === undefined ? undefined : { ...options.capturedOutput }; + this.cleanup = options?.cleanup; + this.cleanupIncomplete = options?.cleanup !== undefined; + this.cleanupResources = options?.cleanup?.resources ?? []; } abstract readonly _tag: AnalysisErrorTag; readonly userMessage: string | undefined = undefined; readonly userCategory: "cancelled" | undefined = undefined; - readonly cleanupIncomplete: boolean = false; - readonly cleanupResources: readonly string[] = []; readonly executionFailure: string | undefined = undefined; readonly partialObservation: PartialProcessCaptureObservation | undefined = undefined; diff --git a/src/domain/analysisErrorProjection.test.ts b/src/domain/analysisErrorProjection.test.ts index 99c397018f..bf5c265f22 100644 --- a/src/domain/analysisErrorProjection.test.ts +++ b/src/domain/analysisErrorProjection.test.ts @@ -11,6 +11,7 @@ import { import { ArtifactOperationError } from "./artifactOperationError.js"; import { BinaryTargetError } from "./configurationErrors.js"; import { BrowserObservationError } from "./browserObservationError.js"; +import { EvidenceIntegrityError } from "./evidenceErrors.js"; import { HopperProcessError, HopperRemoteError, @@ -131,6 +132,53 @@ it.each([ }, ); +it.each([ + new AnalysisCancelledError("observe_native_calls", { + cleanup: { + reason: "target process termination could not be verified", + resources: ["native-target:4242"], + }, + }), + new AnalysisTimeoutError("observe_native_calls", 65_000, { + cleanup: { + reason: "target process identity was unavailable", + resources: ["native-target:4242"], + }, + }), +])("retains incomplete cleanup for lifecycle failures: $._tag", (failure) => { + expect(projectAnalysisError(failure)).toMatchObject({ + code: "cleanup_incomplete", + details: { + cleanup: "incomplete", + cleanup_reason: expect.any(String), + resources: ["native-target:4242"], + }, + }); +}); + +it("retains the original failure tag and code when cleanup is incomplete", () => { + const error = new EvidenceIntegrityError( + "The launched native target did not match its selected digest", + { + cleanup: { + reason: "target survivor identity could not be verified", + resources: ["native-target:4242"], + }, + }, + ); + expect(error._tag).toBe("EvidenceIntegrityError"); + expect(projectAnalysisError(error)).toMatchObject({ + code: "cleanup_incomplete", + category: "integrity_mismatch", + details: { + cleanup: "incomplete", + cleanup_reason: "target survivor identity could not be verified", + resources: ["native-target:4242"], + execution_failure: "evidence_integrity_mismatch", + }, + }); +}); + describe("analysis error projection: provider failures", () => { it("projects the primary browser failure alongside incomplete cleanup", () => { const projected = projectAnalysisError( diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index 4b9ff4f455..6b1ca1710c 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -42,12 +42,26 @@ export const projectAnalysisError = ( error: AnalysisError, ): AnalysisErrorProjection => { assertKnownAnalysisErrorTag(error._tag); - const code = errorCode(error); + const underlyingCode = underlyingErrorCode(error); + const code = error.cleanupIncomplete ? "cleanup_incomplete" : underlyingCode; const primaryDetails = errorDetails(error); - const details = - error.capturedOutput === undefined - ? primaryDetails - : { ...primaryDetails, captured_output: { ...error.capturedOutput } }; + const details = { + ...primaryDetails, + ...(error.cleanupIncomplete + ? { + cleanup: "incomplete", + resources: [...error.cleanupResources], + ...(error.cleanup === undefined + ? {} + : { cleanup_reason: error.cleanup.reason }), + execution_failure: + primaryDetails?.execution_failure ?? underlyingCode, + } + : {}), + ...(error.capturedOutput === undefined + ? {} + : { captured_output: { ...error.capturedOutput } }), + }; return { code, category: analysisErrorCategory(error), @@ -56,12 +70,13 @@ export const projectAnalysisError = ( remediation: { action: analysisErrorRemediationAction(error), }, - ...(details === undefined ? {} : { details }), + ...(Object.keys(details).length === 0 ? {} : { details }), }; }; -const errorCode = (error: AnalysisError): AnalysisErrorProjection["code"] => { - if (error.cleanupIncomplete) return "cleanup_incomplete"; +const underlyingErrorCode = ( + error: AnalysisError, +): AnalysisErrorProjection["code"] => { if (error instanceof ProviderSelectionError) return error.reason === "provider_unavailable" ? "provider_unavailable" @@ -380,11 +395,31 @@ const lifecycleErrorDetails = ( error: AnalysisError, ): Readonly> | undefined => { if (error instanceof AnalysisCancelledError) - return { operation: error.operation, cleanup: "complete" }; + return { + operation: error.operation, + cleanup: error.cleanup === undefined ? "complete" : "incomplete", + ...(error.cleanup === undefined + ? {} + : { + cleanup_reason: error.cleanup.reason, + resources: [...error.cleanup.resources], + execution_failure: "cancelled", + }), + }; if (error instanceof HopperCancelledError) return { operation: "hopper", cleanup: "complete" }; if (error instanceof AnalysisTimeoutError) - return { operation: error.operation, timeout_ms: error.timeoutMs }; + return { + operation: error.operation, + timeout_ms: error.timeoutMs, + ...(error.cleanup === undefined + ? {} + : { + cleanup: "incomplete", + cleanup_reason: error.cleanup.reason, + resources: [...error.cleanup.resources], + }), + }; if (error instanceof HopperTimeoutError) return { stage: error.operation === undefined ? "startup" : error.stage, diff --git a/src/domain/native/nativeCallObservation.ts b/src/domain/native/nativeCallObservation.ts index ecea77d774..020c664db8 100644 --- a/src/domain/native/nativeCallObservation.ts +++ b/src/domain/native/nativeCallObservation.ts @@ -129,8 +129,10 @@ export const nativeCallEventSchema = z.strictObject({ const capturedOutputSchema = z.strictObject({ text: z.string(), + /** Bytes observed by the bridge, not a total when complete is false. */ bytes: z.number().int().nonnegative(), truncated: z.boolean(), + complete: z.boolean(), }); /** Observed calls, the process lifecycle, and breakpoint resolution. */ @@ -140,7 +142,21 @@ export const nativeCallObservationResultSchema = z.strictObject({ sha256: z.string().regex(/^[0-9a-f]{64}$/u), architecture: z.string(), arguments: z.array(z.string()), + environment: z.record(z.string(), z.string()), working_directory: z.string().nullable(), + launch_identity: z.strictObject({ + /** A pathname hash does not verify the bytes mapped into the process. */ + loaded_image_sha256: z.null(), + file_device: z.string().nullable(), + file_inode: z.string().nullable(), + selected_file_sha256: z + .string() + .regex(/^[0-9a-f]{64}$/u) + .nullable(), + module_path: z.string().nullable(), + module_uuid: z.string().nullable(), + stable: z.boolean(), + }), }), debugger: z.strictObject({ path: z.string(), @@ -154,6 +170,7 @@ export const nativeCallObservationResultSchema = z.strictObject({ "duration-elapsed", "event-limit", "stop-limit", + "resource-limit", ]), exit_status: z.number().int().nullable(), exit_description: z.string().nullable(), @@ -187,6 +204,7 @@ export const nativeCallObservationResultSchema = z.strictObject({ coverage: z.strictObject({ status: z.enum(["complete", "partial"]), event_limit_reached: z.boolean(), + resource_limit_reached: z.boolean(), /** Requests that matched no code in any image loaded while observing. */ unresolved_breakpoints: z.array(z.number().int().nonnegative()), }), diff --git a/src/domain/providerAdapterError.ts b/src/domain/providerAdapterError.ts index f999d00ba2..65fe5da806 100644 --- a/src/domain/providerAdapterError.ts +++ b/src/domain/providerAdapterError.ts @@ -1,8 +1,11 @@ import type { JsonValue } from "./jsonValue.js"; -import { AnalysisError } from "./analysisErrorBase.js"; +import { + AnalysisError, + type AnalysisErrorOptions, +} from "./analysisErrorBase.js"; /** A provider adapter failed outside its more precise typed variants. */ -export interface ProviderAdapterErrorOptions extends ErrorOptions { +export interface ProviderAdapterErrorOptions extends AnalysisErrorOptions { readonly diagnostics?: Readonly>; } diff --git a/src/native/LldbCallTracer.ts b/src/native/LldbCallTracer.ts index 31b14731ea..40d2b58260 100644 --- a/src/native/LldbCallTracer.ts +++ b/src/native/LldbCallTracer.ts @@ -1,4 +1,4 @@ -import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { open, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -10,7 +10,11 @@ import { AnalysisCapabilityUnavailableError, AnalysisTimeoutError, } from "../domain/analysisErrorCore.js"; -import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import { EvidenceIntegrityError } from "../domain/evidenceErrors.js"; +import type { + AnalysisCleanupObservation, + AnalysisError, +} from "../domain/analysisErrorBase.js"; import { nativeCallEventSchema, nativeCodeLocationSchema, @@ -19,7 +23,21 @@ import { 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 { + ProviderProcessSupervisor, + spawnOwnedProviderProcess, +} from "../process/ProviderProcess.js"; +import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js"; +import { + observeProcessStartIdentity, + prepareProcessOwnershipInspection, + signalProcessWithStartIdentity, +} from "../process/ProcessOwnershipObservation.js"; +import type { ProcessIdentityObservation } from "../process/ProcessOwnership.js"; +import { + ProviderStartupDeadline, + waitForAbortableDelay, +} from "../process/ProviderDeadline.js"; import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; import { resolveXcrunTool, type ResolvedTool } from "./CommandRunner.js"; @@ -40,11 +58,38 @@ 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"]), + outcome: z.enum([ + "exited", + "duration-elapsed", + "event-limit", + "stop-limit", + "resource-limit", + ]), exit_status: z.number().int().nullable(), exit_description: z.string().nullable(), killed: z.boolean(), terminated: z.boolean(), + target_identity: z.strictObject({ + loaded_image_sha256: z.null(), + selected_file_sha256: z + .string() + .regex(/^[0-9a-f]{64}$/u) + .nullable(), + file_device: z.string().nullable(), + file_inode: z.string().nullable(), + module_path: z.string().nullable(), + module_uuid: z.string().nullable(), + stable: z.boolean(), + }), + resource_limit_reached: z.boolean(), + target_output: z.strictObject({ + stdout_bytes: z.number().int().nonnegative(), + stderr_bytes: z.number().int().nonnegative(), + stdout_truncated: z.boolean(), + stderr_truncated: z.boolean(), + stdout_complete: z.boolean(), + stderr_complete: z.boolean(), + }), elapsed_ms: z.number().nonnegative(), breakpoints: z.array( z.strictObject({ @@ -60,7 +105,13 @@ const tracedSchema = z.strictObject({ const tracerOutputSchema = z.discriminatedUnion("status", [ tracedSchema, z.strictObject({ - status: z.enum(["target-error", "launch-error", "tracer-error"]), + status: z.enum([ + "target-error", + "target-integrity-error", + "target-identity-unavailable", + "launch-error", + "tracer-error", + ]), error: z.string().nullable(), }), ]); @@ -68,8 +119,11 @@ const tracerOutputSchema = z.discriminatedUnion("status", [ /** Bounded text of one target output stream. */ export interface CapturedOutput { readonly text: string; + /** Bytes drained from the target, which may exceed the retained prefix. */ readonly bytes: number; readonly truncated: boolean; + /** Whether the bridge confirms it drained the stream through EOF. */ + readonly complete: boolean; } /** One completed LLDB observation, before projection into the result contract. */ @@ -88,6 +142,7 @@ export interface NativeCallTracer { request: { readonly executable: string; readonly architecture: string; + readonly expectedSha256: string; readonly input: NativeCallObservationInput; }, signal?: AbortSignal, @@ -133,8 +188,11 @@ export class LldbCallTracer implements NativeCallTracer { config: join(directory, "config.json"), result: join(directory, "result.json"), pid: join(directory, "pid"), + identityAck: join(directory, "identity.ack"), stdout: join(directory, "stdout"), stderr: join(directory, "stderr"), + stdoutCapture: join(directory, "stdout.capture"), + stderrCapture: join(directory, "stderr.capture"), }; const { input } = request; await writeFile( @@ -142,6 +200,7 @@ export class LldbCallTracer implements NativeCallTracer { JSON.stringify({ executable: request.executable, architecture: request.architecture, + expected_sha256: request.expectedSha256, arguments: input.arguments, environment: input.environment, working_directory: input.working_directory ?? null, @@ -154,13 +213,18 @@ export class LldbCallTracer implements NativeCallTracer { max_events: input.max_events, argument_registers: input.argument_registers, backtrace_frames: input.backtrace_frames, + max_output_bytes: MAX_OUTPUT_BYTES, stdout_path: paths.stdout, stderr_path: paths.stderr, + stdout_capture_path: paths.stdoutCapture, + stderr_capture_path: paths.stderrCapture, result_path: paths.result, pid_path: paths.pid, + identity_ack_path: paths.identityAck, }), { mode: 0o600 }, ); + await prepareProcessOwnershipInspection(signal); const exited = await runLldb( lldb.path, [ @@ -174,20 +238,77 @@ export class LldbCallTracer implements NativeCallTracer { ], { deadlineMs: input.duration_ms + DEADLINE_GRACE_MS, + pidPath: paths.pid, + identityAckPath: paths.identityAck, ...(signal === undefined ? {} : { signal }), }, ); - const terminated = await ensureTerminated(paths.pid, request.executable); + const output = + exited.kind === "exited" + ? await readTracerOutput(paths.result) + : undefined; + let targetTermination = await ensureTerminated( + paths.pid, + exited.targetIdentity, + true, + ); + // These two bridge outcomes are emitted before a target process exists. + // Other missing-PID cases, including a missing result, remain unknown. + if ( + output !== undefined && + (output.status === "target-error" || output.status === "launch-error") && + (await readTargetPid(paths.pid)) === undefined + ) + targetTermination = "terminated"; + const terminated = targetTermination === "terminated"; + const cleanupDetails = [ + exited.cleanupFailure, + ...(terminated ? [] : [targetTerminationReason(targetTermination)]), + ].filter((detail): detail is string => detail !== undefined); + const lifecycleCleanup = + cleanupDetails.length === 0 + ? undefined + : { + reason: cleanupDetails.join("; "), + resources: [ + `native-target:${String((await readTargetPid(paths.pid)) ?? "unknown")}`, + ], + }; if (exited.kind === "cancelled") - return err(new AnalysisCancelledError(OPERATION)); + return err( + new AnalysisCancelledError( + OPERATION, + lifecycleCleanup === undefined + ? undefined + : { cleanup: lifecycleCleanup }, + ), + ); if (exited.kind === "timeout") return err( new AnalysisTimeoutError( OPERATION, input.duration_ms + DEADLINE_GRACE_MS, + lifecycleCleanup === undefined + ? undefined + : { cleanup: lifecycleCleanup }, ), ); - const output = await readTracerOutput(paths.result); + if (exited.cleanupFailure !== undefined) + return err( + new ProviderAdapterError(PROVIDER, OPERATION, { + cleanup: lifecycleCleanup ?? { + reason: exited.cleanupFailure, + resources: ["lldb-process-group"], + }, + diagnostics: { + reason: "LLDB process-group cleanup could not be verified", + cleanup_failure: exited.cleanupFailure, + ...(lifecycleCleanup === undefined + ? {} + : { target_cleanup: lifecycleCleanup }), + }, + }), + ); if (output === undefined) return err( new ProviderAdapterError(PROVIDER, OPERATION, { @@ -195,15 +316,48 @@ export class LldbCallTracer implements NativeCallTracer { reason: "LLDB exited without a tracer result", exit_code: exited.exitCode, lldb_output: exited.output, + ...(lifecycleCleanup === undefined + ? {} + : { target_cleanup: lifecycleCleanup }), + }, + ...(lifecycleCleanup === undefined + ? {} + : { cleanup: lifecycleCleanup }), + }), + ); + if (output.status !== "traced") { + return err(tracerFailure(output, lifecycleCleanup)); + } + const stdout = await capturedOutput( + paths.stdoutCapture, + output.target_output.stdout_bytes, + output.target_output.stdout_truncated, + output.target_output.stdout_complete, + ); + const stderr = await capturedOutput( + paths.stderrCapture, + output.target_output.stderr_bytes, + output.target_output.stderr_truncated, + output.target_output.stderr_complete, + ); + if (!stdout.ok || !stderr.ok) + return err( + new ProviderAdapterError(PROVIDER, OPERATION, { + diagnostics: { + reason: "LLDB target output capture could not be read", + ...(stdout.ok ? {} : { stdout_capture_error: stdout.error }), + ...(stderr.ok ? {} : { stderr_capture_error: stderr.error }), + ...(lifecycleCleanup === undefined + ? {} + : { target_cleanup: lifecycleCleanup }), }, }), ); - 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), + stdout: stdout.value, + stderr: stderr.value, terminated, }); } @@ -213,13 +367,36 @@ export class LldbCallTracer implements NativeCallTracer { 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 tracerFailure = ( + output: { + readonly status: + | "target-error" + | "target-integrity-error" + | "target-identity-unavailable" + | "launch-error" + | "tracer-error"; + readonly error: string | null; + }, + cleanup?: AnalysisCleanupObservation, +): AnalysisError => { + const options = cleanup === undefined ? undefined : { cleanup }; const message = output.error ?? "no LLDB error text"; + if (output.status === "target-integrity-error") + return new EvidenceIntegrityError( + message || "The launched native target did not match its session digest", + options, + ); + if (output.status === "target-identity-unavailable") + return new ProviderAdapterError(PROVIDER, OPERATION, { + ...options, + diagnostics: { + reason: message, + constraint: "target process start identity could not be verified", + }, + }); if (output.status === "tracer-error") return new ProviderAdapterError(PROVIDER, OPERATION, { + ...options, diagnostics: { reason: `LLDB bridge failed: ${message}` }, }); if (output.status === "target-error") @@ -227,6 +404,7 @@ const tracerFailure = (output: { PROVIDER, OPERATION, `lldb-target-unloadable: ${message}`, + options, ); // debugserver reports denied task access as an attach failure. return /attach|not allowed|permission|denied|debugserver/iu.test(message) @@ -234,12 +412,13 @@ const tracerFailure = (output: { PROVIDER, OPERATION, `debugger-attach-denied: ${message}`, - { userMessage: ATTACH_REMEDIATION }, + { userMessage: ATTACH_REMEDIATION, ...options }, ) : new AnalysisCapabilityUnavailableError( PROVIDER, OPERATION, `launch-failed: ${message}`, + options, ); }; @@ -258,24 +437,40 @@ const readTracerOutput = async ( return output.success ? output.data : undefined; }; -const capturedOutput = async (path: string): Promise => { - let handle; +const capturedOutput = async ( + path: string, + observedBytes: number, + truncated: boolean, + complete: boolean, +): Promise> => { + let handle: Awaited> | undefined; 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 { + const captured = { text: buffer.subarray(0, bytesRead).toString("utf8"), - bytes: size, - truncated: size > MAX_OUTPUT_BYTES, - }; - } finally { + bytes: observedBytes, + truncated: truncated || size > MAX_OUTPUT_BYTES, + complete: + complete && bytesRead >= Math.min(observedBytes, MAX_OUTPUT_BYTES), + } satisfies CapturedOutput; await handle.close(); + handle = undefined; + return ok(captured); + } catch (cause: unknown) { + const failure = cause instanceof Error ? cause.message : String(cause); + if (handle !== undefined) { + try { + await handle.close(); + } catch (closeCause: unknown) { + return err( + `${failure}; closing capture file failed: ${closeCause instanceof Error ? closeCause.message : String(closeCause)}`, + ); + } + } + return err(failure); } }; @@ -284,93 +479,196 @@ type LldbExit = readonly kind: "exited"; readonly exitCode: number | null; readonly output: string; + readonly targetIdentity: string | undefined; + readonly cleanupFailure: string | undefined; } - | { readonly kind: "cancelled" } - | { readonly kind: "timeout" }; + | { + readonly kind: "cancelled"; + readonly targetIdentity: string | undefined; + readonly cleanupFailure: string | undefined; + } + | { + readonly kind: "timeout"; + readonly targetIdentity: string | undefined; + readonly cleanupFailure: string | undefined; + }; -/** Run LLDB in its own process group so cancellation can stop it and debugserver. */ -const runLldb = ( +/** Supervise LLDB as an owned process group while observing its target PID identity. */ +const runLldb = async ( executable: string, arguments_: readonly string[], - options: { readonly signal?: AbortSignal; readonly deadlineMs: number }, -): Promise => - 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); + options: { + readonly signal?: AbortSignal; + readonly deadlineMs: number; + readonly pidPath: string; + readonly identityAckPath: string; + }, +): Promise => { + if (options.signal?.aborted === true) + return { + kind: "cancelled", + targetIdentity: undefined, + cleanupFailure: undefined, }; - child.stdout.on("data", keep); - child.stderr.on("data", keep); - const kill = (reason: "cancelled" | "timeout"): void => { - stopped ??= reason; - if (child.pid !== undefined) + const deadline = new ProviderStartupDeadline( + options.deadlineMs, + options.signal, + ); + let targetIdentity: string | undefined; + let supervisor: ProviderProcessSupervisor | undefined; + try { + const launched = await spawnOwnedProviderProcess({ + command: executable, + arguments: arguments_, + runId: randomUUID(), + expectedCommand: executable, + signal: deadline.signal, + }); + supervisor = new ProviderProcessSupervisor( + { + ...launched, + ownsProcessLifetime: true, + cleanup: () => cleanupOwnedProcessGroup(launched.ownership), + }, + { maxDiagnosticBytes: DIAGNOSTIC_BYTES }, + ); + let identityAckWritten = false; + while (!(await supervisor.waitForOutputClose(20))) { + const pid = await readTargetPid(options.pidPath); + if (pid !== undefined && !identityAckWritten) { + let observation: ProcessIdentityObservation | undefined; try { - process.kill(-child.pid, "SIGKILL"); + observation = await observeProcessStartIdentity(pid); } catch { - // The group already exited. + observation = undefined; } + if (observation?.state === "readable") + targetIdentity = observation.identity; + await writeFile( + options.identityAckPath, + observation?.state === "readable" ? "readable" : "unavailable", + { mode: 0o600 }, + ); + identityAckWritten = true; + } + const interruption = deadline.interruption; + if (interruption !== undefined) { + const stopped = await supervisor.stop(); + const cleanupFailure = + stopped.status === "incomplete" ? stopped.reason : undefined; + return interruption === "cancelled" + ? { kind: "cancelled", targetIdentity, cleanupFailure } + : { kind: "timeout", targetIdentity, cleanupFailure }; + } + await waitForAbortableDelay(20, deadline.signal); + } + const snapshot = supervisor.snapshot(); + const stopped = await supervisor.stop(); + const cleanupFailure = + stopped.status === "incomplete" ? stopped.reason : undefined; + const interruption = deadline.interruption; + if (interruption === "cancelled") + return { kind: "cancelled", targetIdentity, cleanupFailure }; + if (interruption === "timeout") + return { kind: "timeout", targetIdentity, cleanupFailure }; + return { + kind: "exited", + exitCode: snapshot.exitCode ?? null, + output: `${snapshot.stdout.text}${snapshot.stderr.text}`.slice( + -DIAGNOSTIC_BYTES, + ), + targetIdentity, + cleanupFailure, }; - 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); - }); - }); + } catch (cause: unknown) { + const interruption = deadline.interruption; + const stopped = await supervisor?.stop(); + const cleanupFailure = + stopped?.status === "incomplete" ? stopped.reason : undefined; + return interruption === "timeout" + ? { kind: "timeout", targetIdentity, cleanupFailure } + : interruption === "cancelled" + ? { kind: "cancelled", targetIdentity, cleanupFailure } + : { + kind: "exited", + exitCode: null, + output: cause instanceof Error ? cause.message : String(cause), + targetIdentity, + cleanupFailure, + }; + } finally { + supervisor?.dispose(); + deadline.dispose(); + } +}; /** - * debugserver kills the traced process when LLDB exits. Confirm it, and kill a - * survivor only when its executable is still the traced one. + * Confirm target exit, and signal a survivor only when its OS start identity + * still matches the one captured while it was stopped at entry. */ +const readTargetPid = async (pidPath: string): Promise => { + try { + const pid = Number.parseInt(await readFile(pidPath, "utf8"), 10); + return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined; + } catch { + return undefined; + } +}; + +type TargetTermination = + | "terminated" + | "identity-unavailable" + | "identity-changed" + | "signal-did-not-stop"; + +/** Only signal a surviving target when its captured OS start identity still matches. */ const ensureTerminated = async ( pidPath: string, - executable: string, -): Promise => { - 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; + identity: string | undefined, + missingPidIsUnknown: boolean, +): Promise => { + const pid = await readTargetPid(pidPath); + if (pid === undefined) + return missingPidIsUnknown ? "identity-unavailable" : "terminated"; + if (!Number.isSafeInteger(pid) || pid <= 0) return "identity-unavailable"; + let signalDisposition: Exclude | undefined; 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. + if (!alive(pid)) return "terminated"; + if (attempt === 10) { + if (identity === undefined) signalDisposition = "identity-unavailable"; + else { + const result = await signalProcessWithStartIdentity( + pid, + identity, + "SIGKILL", + ); + if (result === "gone") return "terminated"; + if (result === "identity-changed") + signalDisposition = "identity-changed"; + else if (result === "unverified") + signalDisposition = "identity-unavailable"; + else if (result === "signaled") + signalDisposition = "signal-did-not-stop"; } + } await new Promise((resolve) => setTimeout(resolve, 100)); } - return !alive(pid); + return !alive(pid) + ? "terminated" + : (signalDisposition ?? "identity-unavailable"); +}; + +const targetTerminationReason = (termination: TargetTermination): string => { + switch (termination) { + case "terminated": + return "target process terminated"; + case "identity-unavailable": + return "target process start identity was unavailable; survivor was left untouched"; + case "identity-changed": + return "target PID start identity changed; replacement process was left untouched"; + case "signal-did-not-stop": + return "verified target did not exit after SIGKILL"; + } }; const alive = (pid: number): boolean => { @@ -385,16 +683,3 @@ const alive = (pid: number): boolean => { ); } }; - -const commandOf = async (pid: number): Promise => { - try { - const { stdout } = await execFileOutput( - "/bin/ps", - ["-o", "comm=", "-p", String(pid)], - { timeout: 5_000 }, - ); - return stdout.trim(); - } catch { - return undefined; - } -}; diff --git a/src/native/NativeCallObservation.test.ts b/src/native/NativeCallObservation.test.ts new file mode 100644 index 0000000000..fcff10565a --- /dev/null +++ b/src/native/NativeCallObservation.test.ts @@ -0,0 +1,57 @@ +import { createHash } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import type { JsonValue } from "../domain/jsonValue.js"; +import { err, type Result } from "../domain/result.js"; +import type { NativeCallTrace, NativeCallTracer } from "./LldbCallTracer.js"; +import { observeNativeCalls } from "./NativeCallObservation.js"; +import { nativeMachoTarget } from "../../tests/fixtures/nativeCommands.js"; +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; + +class NeverStartedTracer implements NativeCallTracer { + calls = 0; + + trace(): Promise> { + this.calls += 1; + throw new Error("tracer must not run after hash cancellation"); + } +} + +describe("native call target digest cancellation", () => { + it("returns tagged cancellation when hashing aborts after a chunk", async () => { + const directory = await createTestTempDirectory("rea-native-digest-"); + const path = join(directory, "fixture"); + const bytes = "selected executable"; + await writeFile(path, bytes); + const target = { + ...nativeMachoTarget(path), + sha256: createHash("sha256").update(bytes).digest("hex"), + }; + const signal = new AbortController(); + const tracer = new NeverStartedTracer(); + + const observed = await observeNativeCalls( + target, + { breakpoints: [{ kind: "function", name: "main" }] } satisfies Record< + string, + JsonValue + >, + tracer, + signal.signal, + async () => { + signal.abort(); + throw new DOMException("read aborted", "AbortError"); + }, + ); + + expect(!observed.ok && observed.error).toBeInstanceOf( + AnalysisCancelledError, + ); + expect(tracer.calls).toBe(0); + }); +}); diff --git a/src/native/NativeCallObservation.ts b/src/native/NativeCallObservation.ts index 747ee58a66..688c4f507e 100644 --- a/src/native/NativeCallObservation.ts +++ b/src/native/NativeCallObservation.ts @@ -9,6 +9,7 @@ import { import type { AnalysisError } from "../domain/analysisErrorBase.js"; import type { BinaryTarget } from "../domain/binaryTarget.js"; import { EvidenceIntegrityError } from "../domain/evidenceErrors.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { nativeCallObservationInputSchema, @@ -37,6 +38,7 @@ export const observeNativeCalls = async ( parameters: Readonly>, tracer: NativeCallTracer, signal?: AbortSignal, + readTargetDigest: typeof fileSha256 = fileSha256, ): Promise> => { if (target.kind !== "executable" || target.format !== "mach-o") return err( @@ -50,7 +52,22 @@ export const observeNativeCalls = async ( 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) + let digest: string; + try { + digest = await readTargetDigest(target.path, signal); + } catch (cause: unknown) { + if (signal?.aborted === true || isAbortError(cause)) + return err(new AnalysisCancelledError(OPERATION)); + return err( + new ProviderAdapterError("native-macos", OPERATION, { + diagnostics: { + reason: "Unable to verify the selected target digest", + detail: cause instanceof Error ? cause.message : String(cause), + }, + }), + ); + } + if (digest !== target.sha256) return err( new EvidenceIntegrityError( "Native call observation target digest changed after session binding", @@ -62,6 +79,7 @@ export const observeNativeCalls = async ( { executable: target.path, architecture: target.architecture, + expectedSha256: target.sha256, input: parsed.data, }, signal, @@ -70,6 +88,9 @@ export const observeNativeCalls = async ( return ok(projectNativeCalls(target, parsed.data, traced.value)); }; +const isAbortError = (cause: unknown): boolean => + cause instanceof Error && cause.name === "AbortError"; + /** Join the request, the tracer's run and REA's own checks into the result contract. */ export const projectNativeCalls = ( target: Pick & { @@ -83,15 +104,30 @@ export const projectNativeCalls = ( .filter(({ location_count: count }) => count === 0) .map(({ index }) => index); const eventLimit = run.outcome === "event-limit"; + const resourceLimit = run.outcome === "resource-limit"; const partial = - eventLimit || run.outcome === "stop-limit" || unresolved.length > 0; + eventLimit || + resourceLimit || + run.outcome === "duration-elapsed" || + run.outcome === "stop-limit" || + unresolved.length > 0; return nativeCallObservationResultSchema.parse({ target: { path: target.path, sha256: target.sha256, architecture: target.architecture, arguments: input.arguments, + environment: input.environment, working_directory: input.working_directory ?? null, + launch_identity: { + loaded_image_sha256: run.target_identity.loaded_image_sha256, + file_device: run.target_identity.file_device, + file_inode: run.target_identity.file_inode, + selected_file_sha256: run.target_identity.selected_file_sha256, + module_path: run.target_identity.module_path, + module_uuid: run.target_identity.module_uuid, + stable: run.target_identity.stable, + }, }, debugger: trace.debugger, process: { @@ -118,20 +154,32 @@ export const projectNativeCalls = ( coverage: { status: partial ? "partial" : "complete", event_limit_reached: eventLimit, + resource_limit_reached: resourceLimit, unresolved_breakpoints: unresolved, }, limitations: [ ...NATIVE_CALL_OBSERVATION_LIMITATIONS, + "The selected pathname digest and LLDB module identity are checked around launch. No immutable executable snapshot is used; concurrent in-place mutation or replacement and restoration cannot be ruled out, so the loaded-image digest remains unknown.", ...(eventLimit ? [ `Observation stopped at max_events (${input.max_events}); later calls were not recorded and the process was killed.`, ] : []), + ...(resourceLimit + ? [ + "Observation stopped before retaining the next complete event because the estimated 8 MiB trace-payload or 65,536-frame aggregate budget was reached; the process was killed.", + ] + : []), ...(run.outcome === "duration-elapsed" ? [ `The process was still running after duration_ms (${input.duration_ms}) and was killed.`, ] : []), + ...(run.target_identity.stable + ? [] + : [ + "LLDB did not provide enough module identity evidence to confirm pathname and module continuity around launch.", + ]), ...(run.outcome === "stop-limit" ? [ "Observation stopped after repeated signal or exception stops; the process was killed.", @@ -152,6 +200,11 @@ export const projectNativeCalls = ( ...(trace.stdout.truncated || trace.stderr.truncated ? ["Target output beyond 1 MiB per stream is counted but not kept."] : []), + ...(!trace.stdout.complete || !trace.stderr.complete + ? [ + "Target output draining did not complete; reported byte counts are observed lower bounds, not stream totals.", + ] + : []), ...(run.terminated && trace.terminated ? [] : [ diff --git a/src/process/ProcessOwnershipObservation.ts b/src/process/ProcessOwnershipObservation.ts index d47af781c1..723c1b27c8 100644 --- a/src/process/ProcessOwnershipObservation.ts +++ b/src/process/ProcessOwnershipObservation.ts @@ -367,6 +367,49 @@ export const createSystemProcessOwnershipHost = ( export const systemProcessOwnershipHost = createSystemProcessOwnershipHost(); +/** Observe the current OS start identity for one live PID, when supported. */ +export const observeProcessStartIdentity = async ( + pid: number, + host: ProcessOwnershipHost = systemProcessOwnershipHost, +): Promise => { + const process = (await host.listProcesses()).find( + (entry) => entry.pid === pid, + ); + if (process === undefined || liveProcesses([process]).length === 0) + return undefined; + if (host.processIdentities === undefined) + return { state: "unavailable", reason: "process identity is unsupported" }; + return ( + (await host.processIdentities([process])).get(pid) ?? { + state: "unavailable", + reason: "process identity was not returned", + } + ); +}; + +/** Revalidate a launch-time start identity immediately before signaling a PID. */ +export const signalProcessWithStartIdentity = async ( + pid: number, + expectedIdentity: string, + signal: NodeJS.Signals, + options: { + readonly host?: ProcessOwnershipHost; + readonly sendSignal?: (pid: number, signal: NodeJS.Signals) => void; + } = {}, +): Promise<"signaled" | "gone" | "identity-changed" | "unverified"> => { + const host = options.host ?? systemProcessOwnershipHost; + try { + const observed = await observeProcessStartIdentity(pid, host); + if (observed === undefined) return "gone"; + if (observed.state !== "readable") return "unverified"; + if (observed.identity !== expectedIdentity) return "identity-changed"; + (options.sendSignal ?? process.kill)(pid, signal); + return "signaled"; + } catch { + return "unverified"; + } +}; + /** Prepare native token inspection before REA launches a captured child. */ export const prepareProcessOwnershipInspection = async ( signal?: AbortSignal, diff --git a/src/process/ProcessStartIdentity.test.ts b/src/process/ProcessStartIdentity.test.ts new file mode 100644 index 0000000000..be354b24f5 --- /dev/null +++ b/src/process/ProcessStartIdentity.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { ProcessOwnershipHost } from "./ProcessOwnership.js"; +import { + observeProcessStartIdentity, + signalProcessWithStartIdentity, +} from "./ProcessOwnershipObservation.js"; + +const host = (identity: string | undefined): ProcessOwnershipHost => ({ + platform: "linux", + listProcesses: async () => + identity === undefined + ? [] + : [ + { + pid: 42, + parentPid: 1, + processGroupId: 42, + uid: 1000, + state: "S", + command: "/bin/fixture", + }, + ], + environment: async () => ({}), + processIdentities: async () => + new Map([[42, { state: "readable", identity: identity ?? "" }]]), + signalGroup: () => undefined, +}); + +describe("process start identity lease", () => { + it("captures a readable identity for a live PID", async () => { + await expect( + observeProcessStartIdentity(42, host("start-1")), + ).resolves.toEqual({ + state: "readable", + identity: "start-1", + }); + }); + + it("signals the PID when its launch identity still matches", async () => { + const sendSignal = vi.fn(); + await expect( + signalProcessWithStartIdentity(42, "start-1", "SIGKILL", { + host: host("start-1"), + sendSignal, + }), + ).resolves.toBe("signaled"); + expect(sendSignal).toHaveBeenCalledWith(42, "SIGKILL"); + }); + + it("does not signal a reused PID even when its command is unchanged", async () => { + const sendSignal = vi.fn(); + await expect( + signalProcessWithStartIdentity(42, "start-1", "SIGKILL", { + host: host("start-2"), + sendSignal, + }), + ).resolves.toBe("identity-changed"); + expect(sendSignal).not.toHaveBeenCalled(); + }); + + it("does not signal when the PID identity is unavailable", async () => { + const unavailable: ProcessOwnershipHost = { + ...host("start-1"), + processIdentities: async () => + new Map([[42, { state: "unavailable", reason: "reader unavailable" }]]), + }; + const sendSignal = vi.fn(); + await expect( + signalProcessWithStartIdentity(42, "start-1", "SIGKILL", { + host: unavailable, + sendSignal, + }), + ).resolves.toBe("unverified"); + expect(sendSignal).not.toHaveBeenCalled(); + }); + + it("reports an already-gone PID without signaling", async () => { + const sendSignal = vi.fn(); + await expect( + signalProcessWithStartIdentity(42, "start-1", "SIGKILL", { + host: host(undefined), + sendSignal, + }), + ).resolves.toBe("gone"); + expect(sendSignal).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/boundary/mcp/nativeCallObservationMcp.test.ts b/tests/boundary/mcp/nativeCallObservationMcp.test.ts index ba16770dac..8ed46b4cf9 100644 --- a/tests/boundary/mcp/nativeCallObservationMcp.test.ts +++ b/tests/boundary/mcp/nativeCallObservationMcp.test.ts @@ -43,6 +43,15 @@ const tracer: NativeCallTracer & { calls: number } = { exit_description: null, killed: false, terminated: true, + target_identity: { + loaded_image_sha256: null, + selected_file_sha256: request.expectedSha256, + file_device: "1", + file_inode: "2", + module_path: request.executable, + module_uuid: "fixture-uuid", + stable: true, + }, elapsed_ms: 1, breakpoints: request.input.breakpoints.map((_, index) => ({ index, @@ -50,10 +59,19 @@ const tracer: NativeCallTracer & { calls: number } = { locations: [], })), events: [], + resource_limit_reached: false, + target_output: { + stdout_bytes: 0, + stderr_bytes: 0, + stdout_truncated: false, + stderr_truncated: false, + stdout_complete: true, + stderr_complete: true, + }, other_stops: [], }, - stdout: { text: "", bytes: 0, truncated: false }, - stderr: { text: "", bytes: 0, truncated: false }, + stdout: { text: "", bytes: 0, truncated: false, complete: true }, + stderr: { text: "", bytes: 0, truncated: false, complete: true }, terminated: true, }), ); diff --git a/tests/boundary/providers/hopper/hopperClient.cleanup.test.ts b/tests/boundary/providers/hopper/hopperClient.cleanup.test.ts index aaa414e1ec..4ec64f0c2f 100644 --- a/tests/boundary/providers/hopper/hopperClient.cleanup.test.ts +++ b/tests/boundary/providers/hopper/hopperClient.cleanup.test.ts @@ -221,7 +221,8 @@ describe("HopperClient cleanup", () => { const client = new HopperClient({ launcher, runId, - startupTimeoutMs: 1_000, + // Cold Darwin ownership-reader compilation runs inside launcher startup. + startupTimeoutMs: 10_000, onDiagnostic: (event) => diagnostics.push(event), }); clients.push(client); diff --git a/tests/boundary/providers/native/nativeCallObservation.test.ts b/tests/boundary/providers/native/nativeCallObservation.test.ts index 4192a5276b..621d701c31 100644 --- a/tests/boundary/providers/native/nativeCallObservation.test.ts +++ b/tests/boundary/providers/native/nativeCallObservation.test.ts @@ -39,10 +39,28 @@ const run = ( version: "lldb-2100.0.17.203", pid: 4242, outcome: "exited", + resource_limit_reached: false, + target_output: { + stdout_bytes: 0, + stderr_bytes: 0, + stdout_truncated: false, + stderr_truncated: false, + stdout_complete: true, + stderr_complete: true, + }, exit_status: 0, exit_description: null, killed: false, terminated: true, + target_identity: { + loaded_image_sha256: null, + selected_file_sha256: "a".repeat(64), + file_device: "1", + file_inode: "2", + module_path: "/tmp/Fixture", + module_uuid: "fixture-uuid", + stable: true, + }, elapsed_ms: 12.5, breakpoints: [ { index: 0, location_count: 1, locations: [LOCATION] }, @@ -80,8 +98,8 @@ class FixtureTracer implements NativeCallTracer { version: "lldb-2100.0.17.203", }, run: run(), - stdout: { text: "hello\n", bytes: 6, truncated: false }, - stderr: { text: "", bytes: 0, truncated: false }, + stdout: { text: "hello\n", bytes: 6, truncated: false, complete: true }, + stderr: { text: "", bytes: 0, truncated: false, complete: true }, terminated: true, }), ) {} @@ -122,13 +140,17 @@ const observe = async ( signal === undefined ? undefined : { signal }, ); -describe("observe_native_calls projection", () => { +describe("observe_native_calls projection: projects completed observations", () => { 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"] }, + { + breakpoints: BREAKPOINTS, + arguments: ["--flag"], + environment: { REA_CASE: "selected" }, + }, target, ); if (!observed.ok) throw observed.error; @@ -137,6 +159,7 @@ describe("observe_native_calls projection", () => { architecture: "arm64", input: { arguments: ["--flag"], + environment: { REA_CASE: "selected" }, duration_ms: 10_000, max_events: 1_000, argument_registers: 4, @@ -146,6 +169,7 @@ describe("observe_native_calls projection", () => { const result = nativeCallObservationResultSchema.parse( observed.value.result, ); + expect(result.target.environment).toEqual({ REA_CASE: "selected" }); expect(result.breakpoints.map(({ request }) => request)).toEqual([ { kind: "objc-method", @@ -162,6 +186,15 @@ describe("observe_native_calls projection", () => { terminated: true, stdout: { text: "hello\n" }, }); + expect(result.target.launch_identity).toEqual({ + file_device: "1", + file_inode: "2", + loaded_image_sha256: null, + selected_file_sha256: "a".repeat(64), + module_path: "/tmp/Fixture", + module_uuid: "fixture-uuid", + stable: true, + }); expect(result.events[0]).toMatchObject({ receiver_class: "Greeter", selector: "greet:times:", @@ -169,13 +202,16 @@ describe("observe_native_calls projection", () => { expect(result.coverage).toEqual({ status: "complete", event_limit_reached: false, + resource_limit_reached: false, unresolved_breakpoints: [], }); expect(observed.value.locations).toEqual([ { kind: "artifact-path", path: target.path }, ]); }); +}); +describe("observe_native_calls projection: reports partial observation coverage", () => { it("marks event limits, unresolved breakpoints and unconfirmed exits", async () => { const tracer = new FixtureTracer( ok({ @@ -194,8 +230,13 @@ describe("observe_native_calls projection", () => { { index: 1, location_count: 0, locations: [] }, ], }), - stdout: { text: "", bytes: 0, truncated: false }, - stderr: { text: "x".repeat(16), bytes: 2_000_000, truncated: true }, + stdout: { text: "", bytes: 0, truncated: false, complete: false }, + stderr: { + text: "x".repeat(16), + bytes: 2_000_000, + truncated: true, + complete: true, + }, terminated: true, }), ); @@ -210,9 +251,13 @@ describe("observe_native_calls projection", () => { expect(result.coverage).toEqual({ status: "partial", event_limit_reached: true, + resource_limit_reached: false, unresolved_breakpoints: [1], }); expect(result.process.terminated).toBe(false); + expect(result.limitations).toContain( + "Target output draining did not complete; reported byte counts are observed lower bounds, not stream totals.", + ); expect(result.limitations).toEqual( expect.arrayContaining([ "Observation stopped at max_events (1); later calls were not recorded and the process was killed.", @@ -222,6 +267,81 @@ describe("observe_native_calls projection", () => { ]), ); }); + + it("marks duration and aggregate resource limits as partial coverage", async () => { + for (const outcome of ["duration-elapsed", "resource-limit"] as const) { + const tracer = new FixtureTracer( + ok({ + debugger: { + path: "/usr/bin/lldb", + sha256: "a".repeat(64), + version: null, + }, + run: run({ + outcome, + resource_limit_reached: outcome === "resource-limit", + }), + stdout: { text: "", bytes: 0, truncated: false, complete: true }, + stderr: { text: "", bytes: 0, truncated: false, complete: true }, + terminated: true, + }), + ); + const observed = await observe(tracer, { breakpoints: BREAKPOINTS }); + if (!observed.ok) throw observed.error; + const result = nativeCallObservationResultSchema.parse( + observed.value.result, + ); + expect(result.coverage).toMatchObject({ + status: "partial", + resource_limit_reached: outcome === "resource-limit", + }); + } + }); +}); + +describe("observe_native_calls projection: preserves unknown launch identity", () => { + it("preserves unknown launch identity without claiming digest binding", async () => { + const tracer = new FixtureTracer( + ok({ + debugger: { + path: "/usr/bin/lldb", + sha256: "a".repeat(64), + version: null, + }, + run: run({ + target_identity: { + loaded_image_sha256: null, + selected_file_sha256: null, + file_device: null, + file_inode: null, + module_path: null, + module_uuid: null, + stable: false, + }, + }), + stdout: { text: "", bytes: 0, truncated: false, complete: true }, + stderr: { text: "", bytes: 0, truncated: false, complete: true }, + terminated: true, + }), + ); + const observed = await observe(tracer, { breakpoints: BREAKPOINTS }); + if (!observed.ok) throw observed.error; + const result = nativeCallObservationResultSchema.parse( + observed.value.result, + ); + expect(result.target.launch_identity).toEqual({ + file_device: null, + file_inode: null, + loaded_image_sha256: null, + selected_file_sha256: null, + module_path: null, + module_uuid: null, + stable: false, + }); + expect(result.limitations).toContain( + "LLDB did not provide enough module identity evidence to confirm pathname and module continuity around launch.", + ); + }); }); describe("observe_native_calls failures", () => { diff --git a/tests/conformance/native/calls.m b/tests/conformance/native/calls.m index 5f69abeca2..7ad5e37ff6 100644 --- a/tests/conformance/native/calls.m +++ b/tests/conformance/native/calls.m @@ -17,8 +17,26 @@ __attribute__((noinline)) int rea_call_add(int left, int right) { return left + right; } +static void write_fill(int descriptor, char value) { + char chunk[8192]; + memset(chunk, value, sizeof(chunk)); + size_t remaining = 2 * 1024 * 1024; + while (remaining > 0) { + size_t wanted = remaining < sizeof(chunk) ? remaining : sizeof(chunk); + ssize_t written = write(descriptor, chunk, wanted); + if (written <= 0) + return; + remaining -= (size_t)written; + } +} + int main(int argc, char **argv) { @autoreleasepool { + if (argc > 1 && strcmp(argv[1], "--flood") == 0) { + write_fill(STDOUT_FILENO, 'O'); + write_fill(STDERR_FILENO, 'E'); + return 0; + } if (argc > 1 && strcmp(argv[1], "--wait") == 0) for (;;) sleep(1); ReaCallGreeter *greeter = [ReaCallGreeter new];