diff --git a/bridge/native/rea_lldb_tracer.py b/bridge/native/rea_lldb_tracer.py index b99123bf4b..fcec3ea92b 100644 --- a/bridge/native/rea_lldb_tracer.py +++ b/bridge/native/rea_lldb_tracer.py @@ -567,18 +567,27 @@ def trace(debugger, config): before_digest = _hash_file(config["executable"]) before_identity = _file_identity(config["executable"]) except OSError as error: - return {"status": "target-integrity-error", "error": str(error)} + return { + "status": "target-integrity-error", + "error": str(error), + "target_process_created": False, + } if before_digest != config["expected_sha256"]: return { "status": "target-integrity-error", "error": "selected executable digest changed before LLDB target creation", + "target_process_created": False, } error = lldb.SBError() target = debugger.CreateTarget( config["executable"], config["architecture"], None, False, error ) if not target.IsValid(): - return {"status": "target-error", "error": error.GetCString()} + return { + "status": "target-error", + "error": error.GetCString(), + "target_process_created": False, + } module_path_before, module_file_identity_before, module_uuid_before = ( _module_identity(target) ) @@ -589,6 +598,7 @@ def trace(debugger, config): return { "status": "target-integrity-error", "error": "LLDB executable module resolved to a different pre-launch file identity", + "target_process_created": False, } created = [_create_breakpoints(target, spec) for spec in config["breakpoints"]] breakpoint_index = {} @@ -622,12 +632,24 @@ def trace(debugger, config): if not error.Success() or not process.IsValid(): stdout.close() stderr.close() - return {"status": "launch-error", "error": error.GetCString()} + return { + "status": "launch-error", + "error": error.GetCString(), + **( + {"target_process_created": True} + if process.IsValid() + else {} + ), + } launch_failure = _failed_launch_description(process) if launch_failure is not None: stdout.close(expected_exit=True) stderr.close(expected_exit=True) - return {"status": "launch-error", "error": launch_failure} + return { + "status": "launch-error", + "error": launch_failure, + "target_process_created": True, + } stdout.mark_launched() stderr.mark_launched() # Publish the PID before any post-launch file or module inspection. The @@ -644,6 +666,7 @@ def trace(debugger, config): return { "status": "target-identity-unavailable", "error": "host could not establish the launched process identity; target was stopped", + "target_process_created": True, } try: after_digest = _hash_file(config["executable"]) @@ -652,7 +675,11 @@ def trace(debugger, config): 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)} + return { + "status": "target-integrity-error", + "error": str(identity_error), + "target_process_created": True, + } if after_digest != config["expected_sha256"] or after_identity != before_identity: process.Kill() _wait_for_exit(listener, process, 5) @@ -661,6 +688,7 @@ def trace(debugger, config): return { "status": "target-integrity-error", "error": "selected executable digest or file identity changed during LLDB launch", + "target_process_created": True, } module_path_after, module_file_identity_after, module_uuid_after = ( _module_identity(target) @@ -676,6 +704,7 @@ def trace(debugger, config): return { "status": "target-integrity-error", "error": "LLDB executable module resolved to a different file identity", + "target_process_created": True, } if ( module_uuid_before is not None @@ -689,6 +718,7 @@ def trace(debugger, config): return { "status": "target-integrity-error", "error": "LLDB executable module identity changed during launch", + "target_process_created": True, } context = { "target": target, @@ -748,7 +778,11 @@ def trace(debugger, config): # exit code; preserve the debugger's concrete launch denial. launch_failure = _failed_launch_description(process) if launch_failure is not None: - return {"status": "launch-error", "error": launch_failure} + return { + "status": "launch-error", + "error": launch_failure, + "target_process_created": True, + } launch_identity_stable = ( module_path_before is not None and module_path_after == module_path_before diff --git a/bridge/native/test_rea_lldb_tracer.py b/bridge/native/test_rea_lldb_tracer.py index 7cd95d5038..308ed00336 100644 --- a/bridge/native/test_rea_lldb_tracer.py +++ b/bridge/native/test_rea_lldb_tracer.py @@ -8,6 +8,7 @@ import sys import tempfile import types import unittest +from unittest.mock import patch fake_lldb = sys.modules.setdefault("lldb", types.ModuleType("lldb")) @@ -22,6 +23,42 @@ spec.loader.exec_module(tracer) class NativeTraceBudgetTests(unittest.TestCase): + def test_pre_target_integrity_failure_reports_no_process_created(self): + original_debugger = getattr(tracer.lldb, "SBDebugger", None) + + class DebuggerApi: + @staticmethod + def SetInternalVariable(*_arguments): + return None + + class Debugger: + def SetAsync(self, _enabled): + return None + + def GetInstanceName(self): + return "fixture" + + def CreateTarget(self, *_arguments): + raise AssertionError("integrity failure precedes target creation") + + tracer.lldb.SBDebugger = DebuggerApi + try: + with patch.object(tracer, "_hash_file", return_value="changed"), patch.object( + tracer, "_file_identity", return_value=(1, 2) + ): + result = tracer.trace( + Debugger(), + {"executable": "fixture", "expected_sha256": "expected"}, + ) + finally: + if original_debugger is None: + del tracer.lldb.SBDebugger + else: + tracer.lldb.SBDebugger = original_debugger + + self.assertEqual(result["status"], "target-integrity-error") + self.assertIs(result["target_process_created"], False) + def test_journal_checks_exact_byte_budget_before_writing(self): record = {"kind": "other-stop", "reason": "signal"} encoded_size = len((tracer.json.dumps(record) + "\n").encode("utf-8")) diff --git a/src/native/LldbCallTracer.ts b/src/native/LldbCallTracer.ts index 9fa5813b86..a7d1e0a3f0 100644 --- a/src/native/LldbCallTracer.ts +++ b/src/native/LldbCallTracer.ts @@ -123,6 +123,7 @@ const tracerOutputSchema = z.discriminatedUnion("status", [ "tracer-error", ]), error: z.string().nullable(), + target_process_created: z.boolean().optional(), }), ]); @@ -423,11 +424,12 @@ export class LldbCallTracer implements NativeCallTracer { let targetTermination: TargetTermination = exited.targetLaunched ? await ensureTerminated(paths.pid, exited.targetIdentity, true) : "terminated"; - // These two bridge outcomes are emitted before a target process exists. - // Other missing-PID cases, including a missing result, remain unknown. + // Only an explicit bridge phase fact may prove that no target process was + // created. A missing PID or an integrity-error status alone is ambiguous. if ( output !== undefined && - (output.status === "target-error" || output.status === "launch-error") && + output.status !== "traced" && + output.target_process_created === false && (await readTargetPid(paths.pid)) === undefined ) targetTermination = "terminated"; diff --git a/tests/boundary/providers/native/lldbCallTracerFailures.test.ts b/tests/boundary/providers/native/lldbCallTracerFailures.test.ts index a8d65dedbd..a365f67733 100644 --- a/tests/boundary/providers/native/lldbCallTracerFailures.test.ts +++ b/tests/boundary/providers/native/lldbCallTracerFailures.test.ts @@ -1,4 +1,4 @@ -import { access, readFile, writeFile } from "node:fs/promises"; +import { access, readFile, rm, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import { describe, expect, it } from "vitest"; @@ -215,3 +215,165 @@ describe("LLDB failure retention through the production tracer", () => { }); }); }); + +describe("LLDB pre-target phase proof", () => { + it("releases a proven pre-target integrity rejection and allows tracer reuse", async () => { + let launches = 0; + let firstRuntime: string | undefined; + const tracer = new LldbCallTracer( + {}, + async (_executable, arguments_) => { + const { config } = await configFromArguments(arguments_); + launches += 1; + if (firstRuntime === undefined) + firstRuntime = dirname(config.result_path); + await writeFile(config.observation_path, ""); + await writeFile(config.stdout_capture_path, ""); + await writeFile( + config.result_path, + JSON.stringify( + launches === 1 + ? { + status: "target-integrity-error", + error: + "selected executable digest changed before LLDB target creation", + target_process_created: false, + } + : { + status: "target-error", + error: "second launch reached the bridge", + }, + ), + ); + return { + kind: "exited", + targetLaunched: launches === 1, + exitCode: 1, + output: "", + targetIdentity: undefined, + cleanupFailure: undefined, + }; + }, + async () => ok(fakeTool), + ); + + const rejected = await tracer.trace(request); + if (rejected.ok) throw new Error("expected pre-target integrity failure"); + expect(rejected.error).toBeInstanceOf(EvidenceIntegrityError); + expect(projectAnalysisError(rejected.error).code).toBe( + "evidence_integrity_mismatch", + ); + if (firstRuntime === undefined) throw new Error("LLDB was not launched"); + await expect(access(firstRuntime)).rejects.toThrow(); + + const retried = await tracer.trace(request); + expect(launches).toBe(2); + if (retried.ok) throw new Error("expected the second bridge result"); + expect(retried.error).toMatchObject({ + _tag: "AnalysisCapabilityUnavailableError", + reason: expect.stringContaining("second launch reached the bridge"), + }); + }); +}); + +describe("LLDB retains ambiguous target ownership", () => { + it("keeps a post-target integrity rejection unresolved when its PID is missing", async () => { + let runtime: string | undefined; + const tracer = new LldbCallTracer( + {}, + async (_executable, arguments_) => { + const { config } = await configFromArguments(arguments_); + runtime = dirname(config.result_path); + await writeFile(config.observation_path, ""); + await writeFile(config.stdout_capture_path, ""); + await writeFile( + config.result_path, + JSON.stringify({ + status: "target-integrity-error", + error: "selected executable changed during LLDB launch", + target_process_created: true, + }), + ); + return { + kind: "exited", + targetLaunched: true, + exitCode: 1, + output: "", + targetIdentity: undefined, + cleanupFailure: undefined, + }; + }, + async () => ok(fakeTool), + ); + + try { + const result = await tracer.trace(request); + if (result.ok) throw new Error("expected unresolved target identity"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "cleanup_incomplete", + details: { + resources: expect.arrayContaining(["native-target:unknown"]), + }, + }); + expect(runtime).toBeDefined(); + if (runtime !== undefined) await access(runtime); + const retry = await tracer.trace(request); + if (retry.ok) throw new Error("expected retained target cleanup"); + expect(projectAnalysisError(retry.error).code).toBe("cleanup_incomplete"); + } finally { + if (runtime !== undefined) + await rm(runtime, { recursive: true, force: true }); + } + }); + + it("keeps an invalid process after Launch unresolved without producer proof", async () => { + let launches = 0; + let runtime: string | undefined; + const tracer = new LldbCallTracer( + {}, + async (_executable, arguments_) => { + launches += 1; + const { config } = await configFromArguments(arguments_); + runtime = dirname(config.result_path); + await writeFile(config.observation_path, ""); + await writeFile(config.stdout_capture_path, ""); + await writeFile( + config.result_path, + JSON.stringify({ + status: "launch-error", + error: "LLDB returned an invalid process after Launch", + }), + ); + return { + kind: "exited", + targetLaunched: true, + exitCode: 1, + output: "", + targetIdentity: undefined, + cleanupFailure: undefined, + }; + }, + async () => ok(fakeTool), + ); + + try { + const result = await tracer.trace(request); + if (result.ok) throw new Error("expected unresolved launch cleanup"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "cleanup_incomplete", + details: { + resources: expect.arrayContaining(["native-target:unknown"]), + }, + }); + expect(runtime).toBeDefined(); + if (runtime !== undefined) await access(runtime); + const retry = await tracer.trace(request); + if (retry.ok) throw new Error("expected retained target cleanup"); + expect(projectAnalysisError(retry.error).code).toBe("cleanup_incomplete"); + expect(launches).toBe(1); + } finally { + if (runtime !== undefined) + await rm(runtime, { recursive: true, force: true }); + } + }); +});