import { createHash } from "node:crypto"; import { open } from "node:fs/promises"; import { AnalysisCancelledError, AnalysisCapabilityUnavailableError, AnalysisInputError, } from "../domain/analysisErrorCore.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { EvidenceIntegrityError } from "../domain/evidenceErrors.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { nativeCallObservationInputSchema, nativeCallObservationResultSchema, type NativeCallObservationInput, type NativeCallObservationResult, } from "../domain/native/nativeCallObservation.js"; import { err, ok, type Result } from "../domain/result.js"; import type { NativeCallTrace, NativeCallTracer } from "./LldbCallTracer.js"; const OPERATION = "observe_native_calls"; /** What every call observation leaves unobserved or approximated. */ export const NATIVE_CALL_OBSERVATION_LIMITATIONS = [ "Only entries are observed; return values, floating-point and stack-passed arguments are not captured.", "Registers are the raw integer argument registers at entry. For Objective-C methods the first two hold the receiver and the selector.", "Receiver classes come from LLDB's Objective-C runtime reader without running target code; they are null for class methods and for receivers LLDB cannot classify.", "Calls that never reach a symbol's entry, such as inlined code or objc_direct methods called directly, are not observed.", "The target is launched directly under LLDB, not through LaunchServices; LLDB disables ASLR for it, so load addresses can equal file addresses.", "The observation window is checked about once a second, and recording pauses the process at every entry, which slows it.", ] as const; /** Launch the active Mach-O under LLDB, record requested call entries, then stop it. */ export const observeNativeCalls = async ( target: BinaryTarget, parameters: Readonly>, tracer: NativeCallTracer, signal?: AbortSignal, readTargetDigest: typeof fileSha256 = fileSha256, ): Promise> => { if (target.kind !== "executable" || target.format !== "mach-o") return err( new AnalysisCapabilityUnavailableError( "native-macos", OPERATION, "Native call observation launches a Mach-O executable; the active target is not one", ), ); const parsed = nativeCallObservationInputSchema.safeParse(parameters); if (!parsed.success) return err(new AnalysisInputError(OPERATION, { cause: parsed.error })); // Observe the bytes the session's other Evidence describes. 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", ), ); if (signal?.aborted === true) return err(new AnalysisCancelledError(OPERATION)); const traced = await tracer.trace( { executable: target.path, architecture: target.architecture, expectedSha256: target.sha256, input: parsed.data, }, signal, ); if (!traced.ok) return traced; 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 & { readonly architecture: string; }, input: NativeCallObservationInput, trace: NativeCallTrace, ): NativeCallObservationResult => { const { run } = trace; const unresolved = run.breakpoints .filter(({ location_count: count }) => count === 0) .map(({ index }) => index); const eventLimit = run.outcome === "event-limit"; const resourceLimit = run.outcome === "resource-limit"; const breakpointLocationLimit = run.breakpoint_locations_truncated === true; const partial = eventLimit || resourceLimit || breakpointLocationLimit || 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: { pid: run.pid, outcome: run.outcome, exit_status: run.exit_status, exit_description: run.exit_description, terminated: run.terminated && trace.terminated, elapsed_ms: run.elapsed_ms, stdout: trace.stdout, stderr: trace.stderr, other_stops: run.other_stops, }, breakpoints: input.breakpoints.map((request, index) => ({ index, request: request.kind === "function" ? { ...request, module: request.module ?? null } : { ...request, class_name: request.class_name ?? null }, location_count: run.breakpoints[index]?.location_count ?? 0, locations: run.breakpoints[index]?.locations ?? [], })), events: run.events, coverage: { status: partial ? "partial" : "complete", event_limit_reached: eventLimit, resource_limit_reached: resourceLimit, ...(breakpointLocationLimit ? { breakpoint_locations_truncated: true } : {}), 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.", ] : []), ...(unresolved.length === 0 ? [] : [ `Breakpoints ${unresolved.join(", ")} matched no code in any image loaded while observing; their calls could not be observed.`, ]), ...(run.breakpoints.some( ({ location_count: count, locations }) => locations.length < count, ) ? [ "Breakpoints with many matching locations list only the first 64; location_count gives the total.", ] : []), ...(breakpointLocationLimit ? [ "Resolved breakpoint locations exceeded the aggregate 8 MiB metadata budget; omitted locations are counted in location_count. This metadata limit does not change the process outcome.", ] : []), ...(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 ? [] : [ `REA could not confirm that process ${run.pid} exited; check for it before relying on host state.`, ]), ], }); }; const fileSha256 = async ( path: string, signal?: AbortSignal, ): Promise => { const handle = await open(path, "r"); try { const hash = createHash("sha256"); const buffer = new Uint8Array(1024 * 1024); for (;;) { signal?.throwIfAborted(); const { bytesRead } = await handle.read(buffer, 0, buffer.length, null); if (bytesRead === 0) break; hash.update(buffer.subarray(0, bytesRead)); } return hash.digest("hex"); } finally { await handle.close(); } };