import { execFile } from "node:child_process"; import { performance } from "node:perf_hooks"; import { promisify } from "node:util"; import { errorMessage, readNativeProcessIdentityMap, readProcessOwnershipFacts, systemProcessOwnershipHost as systemHost, } from "./ProcessOwnershipObservation.js"; import { launcherIdentityFailure } from "./ProcessOwnershipIdentity.js"; import { descendantsOf, liveProcesses } from "./ProcessOwnershipProcessTree.js"; import { readProcessRunTokens } from "./ProcessRunTokenObservations.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js"; const execFileAsync = promisify(execFile); const PROCESS_CLEANUP_VERIFICATION_GRACE_MS = 1_000; const PROCESS_CLEANUP_VERIFICATION_INTERVAL_MS = 25; /** Shared fields for a process group acquired by a resource owner. */ interface OwnedProcessGroupFacts { readonly runId: string; readonly leaderPid: number; readonly processGroupId: number; /** Expected launcher identity, checked only while the leader exists. */ readonly expectedCommand?: string; /** Expected launcher parent, checked only while the leader exists. */ readonly expectedParentPid?: number; /** Prelaunch process identities for this capture; absent on generic callers. */ readonly captureBaseline?: ProcessOwnershipBaseline; /** * Groups observed in capture samples. Observation adds cleanup candidates but * does not authorize signaling; every live member still needs token proof. */ readonly sampledProcessGroupIds?: readonly number[]; } /** Identity proof required before REA may signal an owned process group. * Token-swept resources must carry one ledger for the whole resource lifetime. */ export type OwnedProcessGroup = OwnedProcessGroupFacts & ( | { /** Scan all live processes for this run token during cleanup. */ readonly sweepTokenOwnedProcesses: true; readonly evidenceOwner: ProcessOwnershipEvidenceOwner; } | { readonly sweepTokenOwnedProcesses?: false; readonly evidenceOwner?: ProcessOwnershipEvidenceOwner; } ); /** Process-group authority retained across retries and final verification. */ export type PersistentOwnedProcessGroup = OwnedProcessGroup & { readonly evidenceOwner: ProcessOwnershipEvidenceOwner; }; /** Capture-local baseline; null means the PID identity could not be established. */ export type ProcessOwnershipBaseline = readonly { readonly pid: number; readonly identity: string | null; }[]; /** One entry from an operating-system process-table snapshot. */ export interface ProcessTableEntry { readonly pid: number; readonly parentPid: number; readonly processGroupId: number; /** Effective owner when the process table reports it. */ readonly uid?: number; readonly state: string; readonly command: string; } /** Native process start identity; unavailable identities must never be skipped. */ export type ProcessIdentityObservation = | { readonly state: "readable"; readonly identity: string; /** Effective UID from the same native observation, when that boundary reports it. */ readonly effectiveUid?: number; /** Kept separate from the start identity; optional for older/custom hosts. */ readonly lifecycle?: ProcessLifecycleObservation; /** Native relation facts read with this identity, never copied from a process-table row. */ readonly relation?: ProcessNativeRelationObservation; } | { readonly state: "absent"; /** * Bounded native evidence, not global liveness: Linux corroborates * procfs absence with kill(pid, 0) in this PID namespace; Darwin reports * absence from one successful KERN_PROC_ALL snapshot only. */ readonly reason: "not-found-in-caller-pid-namespace" | "not-in-snapshot"; } | { readonly state: "unavailable"; readonly reason: string }; /** Native lifecycle fact observed alongside (but independent of) start identity. */ export type ProcessLifecycleObservation = | { readonly state: "observed"; readonly lifecycle: "non-zombie" | "zombie"; } | { readonly state: "unavailable"; readonly reason: string }; /** Parent and process-group membership from the same native identity read. */ export type ProcessNativeRelationObservation = | { readonly state: "observed"; readonly parentPid: number; readonly processGroupId: number; } | { readonly state: "unavailable"; readonly reason: string }; /** One caller-supplied process row validated across identity/token/identity reads. */ export interface ProcessOwnershipFact { /** Caller-supplied candidate row; its parent/group may predate this read. */ readonly process: ProcessTableEntry; /** Identity relation only; token ownership still requires matching readable runId. */ readonly state: "same-generation" | "replaced" | "absent" | "unavailable"; readonly identityBefore: ProcessIdentityObservation; /** Preserve the raw token result; it is not attributable across a replaced generation. */ readonly token: ProcessRunTokenObservation; /** Current membership is only this read's native relation, when available. */ readonly identityAfter: ProcessIdentityObservation; } /** Token-verified process lineage retained for one owned provider run. */ export interface OwnedProcessLineage { readonly runId: string; readonly launcherPid: number; readonly launcherParentPid: number; readonly processGroupId: number; readonly descendants: readonly { readonly pid: number; readonly parentPid: number; readonly processGroupId: number; }[]; } /** Result of observing owned lineage without signaling any process. */ export type ProcessLineageObservation = | { readonly status: "verified"; readonly observedAt: string; readonly lineage: OwnedProcessLineage; } | { readonly status: "unavailable"; readonly observedAt: string; readonly runId: string; readonly launcherPid: number; readonly processGroupId: number; readonly reason: string; }; /** Narrow operating-system seam used to inspect processes and signal groups. */ export interface ProcessOwnershipHost { readonly platform?: NodeJS.Platform; /** Prepare required native identity inspection before launching a child. */ prepare?(signal?: AbortSignal): Promise; /** Release owned native inspection helpers and their temporary files. */ close?(): Promise; /** List current processes; startup inspection may be cancelled by the caller. */ listProcesses(signal?: AbortSignal): Promise; environment( pid: number, signal?: AbortSignal, ): Promise>>; /** Read only run-token values in one host operation when the OS supports it. */ runTokens?( processes: readonly ProcessTableEntry[], signal?: AbortSignal, ): Promise>; /** Read stable identities; omit the signal during cleanup so it can finish. */ processIdentities?( processes: readonly ProcessTableEntry[], signal?: AbortSignal, ): Promise>; /** Snapshot identities before launch, honoring startup cancellation. */ captureBaseline?(signal?: AbortSignal): Promise; signalGroup(processGroupId: number, signal: NodeJS.Signals): void; } /** Whether one live process exposed its ownership token to the host. */ export type ProcessRunTokenObservation = | { readonly state: "readable"; readonly runId: string | undefined } | { readonly state: "unavailable"; readonly reason: string }; /** Narrow Windows P0 seam for bounded process-tree termination. */ export interface WindowsProcessTreeHost { terminateTree(rootPid: number): Promise<"terminated" | "missing">; } /** Per-member reason that token-verified cleanup failed closed. */ interface ProcessOwnershipValidationFailure { readonly pid: number; readonly reason: | "environment-unreadable" | "run-token-mismatch" | "process-identity-unavailable"; readonly diagnostic?: string; } /** * One live process the ownership sweep could not attribute or exonerate. * Recorded as evidence instead of failing the capture: the process is not * related to the owned tree, so cleanup cannot act on it and the operating * system withheld or removed its token (for example, macOS strips the * environment of Apple platform binaries, and foreign-uid processes return * EINVAL for KERN_PROCARGS2). */ export interface OwnershipSweepUnverifiedProcess { readonly pid: number; readonly diagnostic: string; } /** Capture-owned process structures used to classify sweep candidates. */ export interface OwnershipSweepRelation { readonly leaderPid: number; readonly processGroupId: number; readonly sampledProcessGroupIds?: readonly number[]; readonly evidenceOwner: ProcessOwnershipEvidenceOwner; } /** Cleanup outcome with per-member diagnostics when ownership is uncertain. */ export type ProcessCleanupResult = | { readonly cleaned: true; readonly signaled: boolean; readonly unverified?: readonly OwnershipSweepUnverifiedProcess[]; } | ProcessCleanupFailure; /** Retained verification failures for one cleanup attempt, without additional signaling authority. */ export interface ProcessCleanupFailure { readonly cleaned: false; readonly reason: string; readonly failures?: readonly ProcessOwnershipValidationFailure[]; readonly unverified?: readonly OwnershipSweepUnverifiedProcess[]; } /** Token-verified liveness result used by post-root-exit settlement. */ export type ProcessGroupObservation = | { readonly state: "empty" } | { readonly state: "alive" } | { readonly state: "unverifiable"; readonly reason: string }; /** * Terminate one Windows process tree through the platform utility. * * This is a bounded P0 cleanup mechanism, not Job Object ownership proof. The * caller-visible Windows capability report remains unavailable until a native * authority verifies Job Object creation, membership, and cleanup semantics. */ export const cleanupWindowsProcessTree = async ( rootPid: number, host: WindowsProcessTreeHost = systemWindowsProcessTreeHost, ): Promise => { if (!Number.isSafeInteger(rootPid) || rootPid <= 0) return { cleaned: false, reason: "Windows process-tree PID is invalid" }; try { const result = await host.terminateTree(rootPid); return { cleaned: true, signaled: result === "terminated" }; } catch (cause: unknown) { return { cleaned: false, reason: `Windows process-tree termination failed for PID ${String(rootPid)}: ${errorMessage(cause)}`, }; } }; const systemWindowsProcessTreeHost: WindowsProcessTreeHost = { async terminateTree(rootPid) { try { await execFileAsync( "taskkill.exe", ["/pid", String(rootPid), "/t", "/f"], { windowsHide: true, timeout: 5_000 }, ); return "terminated"; } catch (cause: unknown) { if ( cause instanceof Error && "code" in cause && (cause.code === 128 || cause.code === "ESRCH") ) return "missing"; throw cause; } }, }; /** Read the capture run token currently exposed by one live process. */ export const readProcessRunId = async ( pid: number, host: ProcessOwnershipHost = systemHost, signal?: AbortSignal, ): Promise => (await host.environment(pid, signal)).REA_PROCESS_RUN_ID; /** Merge duplicate uncertainty rows from discovery and final verification. */ const mergeUnverifiedObservations = ( result: ProcessCleanupResult, unverified: readonly OwnershipSweepUnverifiedProcess[], ): ProcessCleanupResult => { if (unverified.length === 0) return result; const merged = [ ...new Map( [...(result.unverified ?? []), ...unverified].map((item) => [ JSON.stringify([item.pid, item.diagnostic]), item, ]), ).values(), ]; return { ...result, unverified: merged }; }; /** Token-validate every rooted POSIX process group before signaling any. */ export const cleanupOwnedProcessGroup = async ( ownership: OwnedProcessGroup, host: ProcessOwnershipHost = systemHost, ): Promise => { const processTable = await readLiveProcessTable(host); if (!processTable.available) return { cleaned: false, reason: `process table could not be inspected: ${processTable.reason}`, }; const plan = await createOwnedCleanupPlan( ownership, processTable.processes, host, ); if ("cleaned" in plan) return plan; let signaled = false; for (const processGroupId of plan.signalOrder) { const revalidation = await revalidateOwnedProcessGroup( processGroupId, ownership, host, plan.tokenOwnedIdentities, ); if ("cleaned" in revalidation) return mergeUnverifiedObservations(revalidation, plan.unverified); if (revalidation.empty) continue; try { host.signalGroup(processGroupId, "SIGKILL"); signaled = true; } catch (cause: unknown) { const code = cause instanceof Error && "code" in cause ? cause.code : undefined; if (code !== "ESRCH") return mergeUnverifiedObservations( { cleaned: false, reason: `owned process group signal failed: ${errorMessage(cause)}`, }, plan.unverified, ); } } if (plan.unresolved.length > 0) return cleanupValidationFailure(plan.unresolved, plan.unverified); if (ownership.sweepTokenOwnedProcesses === true) { let verification: ProcessCleanupResult; try { const relation = { leaderPid: ownership.leaderPid, processGroupId: ownership.processGroupId, evidenceOwner: ownership.evidenceOwner, ...(ownership.sampledProcessGroupIds === undefined ? {} : { sampledProcessGroupIds: ownership.sampledProcessGroupIds }), }; const deadline = performance.now() + PROCESS_CLEANUP_VERIFICATION_GRACE_MS; verification = await verifyNoTokenOwnedProcesses( ownership.runId, host, ownership.captureBaseline, relation, ); while (!verification.cleaned) { const remaining = deadline - performance.now(); if (remaining <= 0) break; await new Promise((resolve) => setTimeout( resolve, Math.min(PROCESS_CLEANUP_VERIFICATION_INTERVAL_MS, remaining), ), ); verification = await verifyNoTokenOwnedProcesses( ownership.runId, host, ownership.captureBaseline, relation, ); } } catch (cause: unknown) { return mergeUnverifiedObservations( { cleaned: false, reason: `final process ownership verification failed: ${errorMessage(cause)}`, }, plan.unverified, ); } if (!verification.cleaned) return mergeUnverifiedObservations(verification, plan.unverified); return mergeUnverifiedObservations({ cleaned: true, signaled }, [ ...plan.unverified, ...(verification.unverified ?? []), ]); } return { cleaned: true, signaled, ...(plan.unverified.length === 0 ? {} : { unverified: plan.unverified }), }; }; /** Verify that no live process still carries an owned capture run token. */ export const verifyNoTokenOwnedProcesses = async ( runId: string, host: ProcessOwnershipHost = systemHost, captureBaseline?: ProcessOwnershipBaseline, relation?: OwnershipSweepRelation, ): Promise => { const processTable = await readLiveProcessTable(host); if (!processTable.available) return { cleaned: false, reason: `process table could not be inspected: ${processTable.reason}`, }; const scan = await scanTokenOwnedProcesses( processTable.processes, runId, host, captureBaseline, relation, ); if (scan.failures.length > 0) return cleanupValidationFailure(scan.failures, scan.unverified); if (scan.owned.length > 0) return { cleaned: false, reason: "token-owned process remained after cleanup", ...(scan.unverified.length === 0 ? {} : { unverified: scan.unverified }), }; return { cleaned: true, signaled: false, ...(scan.unverified.length === 0 ? {} : { unverified: scan.unverified }), }; }; const readLiveProcessTable = async ( host: ProcessOwnershipHost, ): Promise< | { readonly available: true; readonly processes: readonly ProcessTableEntry[]; } | { readonly available: false; readonly reason: string } > => { try { return { available: true, processes: liveProcesses(await host.listProcesses()), }; } catch (cause: unknown) { return { available: false, reason: errorMessage(cause) }; } }; interface OwnedCleanupPlan { readonly signalOrder: readonly number[]; readonly tokenOwnedIdentities: ReadonlyMap; readonly unresolved: readonly ProcessOwnershipValidationFailure[]; readonly unverified: readonly OwnershipSweepUnverifiedProcess[]; } interface ProcessRelationSnapshot { readonly currentRelations: readonly ProcessTableEntry[]; readonly identities: ReadonlyMap; readonly failures: readonly ProcessOwnershipValidationFailure[]; } /** * Enrich cleanup candidates with same-snapshot native membership. Process-table * ancestry/group fields select only rows whose missing native facts need to * fail closed; they never authorize a group or descendant relation. */ const readProcessRelationSnapshot = async ( ownership: OwnedProcessGroup, processes: readonly ProcessTableEntry[], host: ProcessOwnershipHost, ): Promise => { if (host.processIdentities === undefined) return { currentRelations: processes, identities: new Map(), failures: [], }; const identities = await readNativeProcessIdentityMap(processes, host); const possibleDescendants = new Set( descendantsOf([ownership.leaderPid], processes).map(({ pid }) => pid), ); const relevantPids = new Set([ ownership.leaderPid, ...possibleDescendants, ...processes .filter( ({ processGroupId }) => processGroupId === ownership.processGroupId || ownership.sampledProcessGroupIds?.includes(processGroupId), ) .map(({ pid }) => pid), ]); const currentRelations: ProcessTableEntry[] = []; const failures: ProcessOwnershipValidationFailure[] = []; for (const process of processes) { const identity = identities.get(process.pid); if ( identity?.state === "readable" && identity.lifecycle?.state === "observed" && identity.lifecycle.lifecycle === "zombie" ) continue; if ( identity?.state === "readable" && identity.relation?.state === "observed" ) { currentRelations.push({ ...process, parentPid: identity.relation.parentPid, processGroupId: identity.relation.processGroupId, }); continue; } if (relevantPids.has(process.pid) && identity?.state !== "absent") failures.push({ pid: process.pid, reason: "process-identity-unavailable", diagnostic: `native process membership could not be read${identity?.state === "unavailable" ? `: ${identity.reason}` : ""}`, }); } return { currentRelations, identities, failures }; }; // A process-table read can race exit and retain the old state with an altered // command. Only a fresh table proving that PID is no longer live can settle // that mismatch; surviving group members still require normal token checks. const settleLauncherExit = async ( ownership: OwnedProcessGroup, processes: readonly ProcessTableEntry[], host: ProcessOwnershipHost, ): Promise< | readonly ProcessTableEntry[] | Extract > => { const launcher = processes.find(({ pid }) => pid === ownership.leaderPid); if (launcher === undefined) return processes; let identityCheckedLauncher = launcher; if (host.processIdentities !== undefined) { const identities = await readNativeProcessIdentityMap([launcher], host); const identity = identities.get(launcher.pid); if (identity?.state === "absent") return processes.filter(({ pid }) => pid !== launcher.pid); if ( identity?.state === "readable" && identity.lifecycle?.state === "observed" && identity.lifecycle.lifecycle === "zombie" ) return processes.filter(({ pid }) => pid !== launcher.pid); if ( identity?.state !== "readable" || identity.relation?.state !== "observed" ) return { cleaned: false, reason: `owned launcher native identity could not be verified${identity?.state === "unavailable" ? `: ${identity.reason}` : ""}`, }; identityCheckedLauncher = { ...launcher, parentPid: identity.relation.parentPid, processGroupId: identity.relation.processGroupId, }; } const reason = launcherIdentityFailure(identityCheckedLauncher, ownership); if (reason === null) return processes; // A native identity read already says that the launcher still exists and // supplies its current parent/group relation. Refreshing the process table // cannot repair a command or launch-relation mismatch for that generation. if (host.processIdentities !== undefined) return { cleaned: false, reason }; const refreshed = await readLiveProcessTable(host); if ( refreshed.available && !refreshed.processes.some(({ pid }) => pid === launcher.pid) ) { // Refreshing must not discard a surviving detached descendant's lineage, // including a child that changed groups while the launcher exited. const descendants = descendantsOf([launcher.pid], processes); const descendantPids = new Set(descendants.map(({ pid }) => pid)); const descendantGroupIds = new Set( descendants.map(({ processGroupId }) => processGroupId), ); const refreshedDescendantPids = new Set( descendantsOf( descendants.map(({ pid }) => pid), refreshed.processes, ).map(({ pid }) => pid), ); if ( refreshed.processes.some( (process) => process.processGroupId !== ownership.processGroupId && (refreshedDescendantPids.has(process.pid) || descendantPids.has(process.pid) || descendantGroupIds.has(process.processGroupId)), ) ) return { cleaned: false, reason }; return refreshed.processes; } return { cleaned: false, reason }; }; const createOwnedCleanupPlan = async ( ownership: OwnedProcessGroup, initialProcesses: readonly ProcessTableEntry[], host: ProcessOwnershipHost, ): Promise => { const processes = await settleLauncherExit(ownership, initialProcesses, host); if ("cleaned" in processes) return processes; const relationSnapshot = await readProcessRelationSnapshot( ownership, processes, host, ); const sampledProcessGroupIds = new Set( (ownership.sampledProcessGroupIds ?? []).filter( (processGroupId) => Number.isSafeInteger(processGroupId) && processGroupId > 0, ), ); const tokenOwned = ownership.sweepTokenOwnedProcesses === true ? await scanTokenOwnedProcesses( processes, ownership.runId, host, ownership.captureBaseline, { leaderPid: ownership.leaderPid, processGroupId: ownership.processGroupId, evidenceOwner: ownership.evidenceOwner, ...(sampledProcessGroupIds.size === 0 ? {} : { sampledProcessGroupIds: [...sampledProcessGroupIds] }), }, ) : { owned: [], failures: [], unverified: [], identities: new Map() }; const tokenOwnedGroupIds = new Set( tokenOwned.owned.map(({ processGroupId }) => processGroupId), ); const launcher = relationSnapshot.currentRelations.find( ({ pid }) => pid === ownership.leaderPid, ); const rootMembers = relationSnapshot.currentRelations.filter( ({ processGroupId }) => processGroupId === ownership.processGroupId, ); if ( launcher === undefined && rootMembers.length === 0 && tokenOwned.owned.length === 0 && !relationSnapshot.currentRelations.some(({ processGroupId }) => sampledProcessGroupIds.has(processGroupId), ) ) return tokenOwned.failures.length > 0 || relationSnapshot.failures.length > 0 ? { signalOrder: [], tokenOwnedIdentities: tokenOwned.identities, unresolved: [...tokenOwned.failures, ...relationSnapshot.failures], unverified: tokenOwned.unverified, } : { cleaned: true, signaled: false, ...(tokenOwned.unverified.length === 0 ? {} : { unverified: tokenOwned.unverified }), }; let descendants: readonly ProcessTableEntry[] = []; if (launcher !== undefined) { descendants = descendantsOf( [launcher.pid], relationSnapshot.currentRelations, ); } const descendantPids = new Set(descendants.map(({ pid }) => pid)); const processGroupIds = new Set([ownership.processGroupId]); for (const descendant of descendants) processGroupIds.add(descendant.processGroupId); for (const process of tokenOwned.owned) processGroupIds.add(process.processGroupId); for (const processGroupId of sampledProcessGroupIds) processGroupIds.add(processGroupId); const signalableGroups = new Set(); const unresolved = [...tokenOwned.failures, ...relationSnapshot.failures]; const unverifiableSampledGroups = new Set(); for (const processGroupId of processGroupIds) { if (processGroupId === ownership.processGroupId) continue; const groupLeader = relationSnapshot.currentRelations.find( ({ pid, processGroupId: observedGroupId }) => pid === processGroupId && observedGroupId === processGroupId, ); if ( (groupLeader === undefined || !descendantPids.has(groupLeader.pid)) && !tokenOwnedGroupIds.has(processGroupId) && !sampledProcessGroupIds.has(processGroupId) ) return { cleaned: false, reason: "descendant process-group leader identity could not be verified", }; if ( (groupLeader === undefined || !descendantPids.has(groupLeader.pid)) && !tokenOwnedGroupIds.has(processGroupId) ) unverifiableSampledGroups.add(processGroupId); } const liveMembers = relationSnapshot.currentRelations.filter( ({ processGroupId }) => processGroupIds.has(processGroupId), ); for (const processGroupId of processGroupIds) { const members = liveMembers.filter( ({ processGroupId: observedGroupId }) => observedGroupId === processGroupId, ); if (members.length === 0) continue; const failures = await processOwnershipFailures( members, ownership.runId, host, tokenOwned.identities, ); if (failures.length === 0 && unverifiableSampledGroups.has(processGroupId)) unresolved.push( ...members.map(({ pid }) => ({ pid, reason: "process-identity-unavailable" as const, diagnostic: "sampled process-group leader identity could not be verified", })), ); const scanFailures = tokenOwned.failures.filter(({ pid }) => members.some((member) => member.pid === pid), ); for (const failure of failures) mergeProcessOwnershipFailure(unresolved, failure); if ( failures.length === 0 && scanFailures.length === 0 && !unverifiableSampledGroups.has(processGroupId) ) signalableGroups.add(processGroupId); } if (ownership.sweepTokenOwnedProcesses !== true && unresolved.length > 0) return cleanupValidationFailure(unresolved, tokenOwned.unverified); return { signalOrder: [...signalableGroups] .filter((groupId) => processGroupIds.has(groupId)) .sort((left, right) => left === ownership.processGroupId ? -1 : right === ownership.processGroupId ? 1 : left - right, ), tokenOwnedIdentities: tokenOwned.identities, unresolved, unverified: tokenOwned.unverified, }; }; /** Keep one current, most useful ownership diagnosis for each live PID. */ const mergeProcessOwnershipFailure = ( failures: ProcessOwnershipValidationFailure[], next: ProcessOwnershipValidationFailure, ): void => { const index = failures.findIndex(({ pid }) => pid === next.pid); if (index < 0) { failures.push(next); return; } const previous = failures[index]; if ( previous !== undefined && ownershipFailurePriority(next.reason) >= ownershipFailurePriority(previous.reason) ) failures[index] = next; }; const ownershipFailurePriority = ( reason: ProcessOwnershipValidationFailure["reason"], ): number => reason === "run-token-mismatch" ? 3 : reason === "process-identity-unavailable" ? 2 : reason === "environment-unreadable" ? 1 : 0; const revalidateOwnedProcessGroup = async ( processGroupId: number, ownership: OwnedProcessGroup, host: ProcessOwnershipHost, expectedIdentities: ReadonlyMap, ): Promise<{ readonly empty: boolean } | ProcessCleanupResult> => { let members: readonly ProcessTableEntry[]; try { const processes = await settleLauncherExit( ownership, liveProcesses(await host.listProcesses()), host, ); if ("cleaned" in processes) return processes; const relationSnapshot = await readProcessRelationSnapshot( ownership, processes, host, ); if (relationSnapshot.failures.length > 0) return cleanupValidationFailure(relationSnapshot.failures); members = relationSnapshot.currentRelations.filter( ({ processGroupId: observedGroupId }) => observedGroupId === processGroupId, ); } catch (cause: unknown) { return { cleaned: false, reason: `process ownership could not be revalidated: ${errorMessage(cause)}`, }; } const failures = await processOwnershipFailures( members, ownership.runId, host, expectedIdentities, ); if (failures.length > 0) return cleanupValidationFailure(failures); if ( processGroupId !== ownership.processGroupId && members.length > 0 && !members.some(({ pid }) => pid === processGroupId) && ownership.sweepTokenOwnedProcesses !== true ) return { cleaned: false, reason: "descendant process-group leader identity could not be revalidated", }; return { empty: members.length === 0 }; }; const cleanupValidationFailure = ( failures: readonly ProcessOwnershipValidationFailure[], unverified: readonly OwnershipSweepUnverifiedProcess[] = [], ): ProcessCleanupResult => { const unverifiedEntry = unverified.length === 0 ? {} : { unverified }; const unreadable = failures.filter( ({ reason }) => reason === "environment-unreadable", ); if (failures.some(({ reason }) => reason === "run-token-mismatch")) return { cleaned: false, reason: `process ownership token mismatch for PID(s): ${[ ...new Set( failures .filter(({ reason }) => reason === "run-token-mismatch") .map(({ pid }) => pid), ), ] .sort((left, right) => left - right) .join(", ")}`, failures, ...unverifiedEntry, }; if (failures.some(({ reason }) => reason === "process-identity-unavailable")) return { cleaned: false, reason: "process identity could not be revalidated", failures, ...unverifiedEntry, }; if (unreadable.length > 0) { const counts = new Map(); for (const { diagnostic } of unreadable) { const category = sanitizedTokenReadFailure(diagnostic); counts.set(category, (counts.get(category) ?? 0) + 1); } const breakdown = [...counts] .sort(([left], [right]) => compareUnicodeCodePoints(left, right)) .map(([category, count]) => `${category}=${String(count)}`) .join(", "); const candidates = unreadable .map( ({ pid, diagnostic }) => `${String(pid)}=${sanitizedTokenReadFailure(diagnostic)}`, ) .sort((left, right) => compareUnicodeCodePoints(left, right)); return { cleaned: false, reason: `process ownership token could not be read for ${String(unreadable.length)} live process(es): ${breakdown}; live candidates ${candidates.join(", ")}`, failures, ...unverifiedEntry, }; } return { cleaned: false, reason: "process ownership could not be revalidated", failures, ...unverifiedEntry, }; }; const sanitizedTokenReadFailure = (diagnostic: string | undefined): string => { if (diagnostic === "environment_unavailable") return diagnostic; if (diagnostic === "platform_binary_environment_withheld") return diagnostic; if (diagnostic === "restricted_process_environment_withheld") return diagnostic; const environmentErrno = /^(EACCES|EPERM|ENOENT|ESRCH):/u.exec( diagnostic ?? "", ); if (environmentErrno?.[1] !== undefined) return `environment_errno_${environmentErrno[1]}`; if (diagnostic === "apple_vector_unavailable") return diagnostic; if (diagnostic === "ambiguous_environment_boundary") return diagnostic; if (diagnostic === "malformed_procargs") return diagnostic; if (diagnostic === "process_identity_changed_during_token_read") return diagnostic; if (diagnostic === "process_not_in_snapshot") return diagnostic; const processTableFailure = /^process_table_failed_(\d+)$/u.exec( diagnostic ?? "", ); if (processTableFailure?.[1] !== undefined) return `process_table_errno_${processTableFailure[1]}`; const sysctlFailure = /^sysctl_failed_(\d+)$/u.exec(diagnostic ?? ""); if (sysctlFailure?.[1] !== undefined) return `sysctl_errno_${sysctlFailure[1]}`; if (diagnostic === "reader_failure") return diagnostic; if (diagnostic === "duplicate_run_token") return diagnostic; if (diagnostic === "invalid_run_token_encoding") return diagnostic; return "other_unavailable"; }; const processOwnershipFailures = async ( members: readonly ProcessTableEntry[], runId: string, host: ProcessOwnershipHost, expectedIdentities: ReadonlyMap = new Map(), ): Promise => { if (host.processIdentities !== undefined) { const facts = await readProcessOwnershipFacts(members, host); const failures: ProcessOwnershipValidationFailure[] = []; for (const fact of facts) { if (fact.identityAfter.state === "absent") continue; if ( fact.identityAfter.state === "readable" && fact.identityAfter.lifecycle?.state === "observed" && fact.identityAfter.lifecycle.lifecycle === "zombie" ) continue; const relation = fact.identityAfter.state === "readable" && fact.identityAfter.relation?.state === "observed" ? fact.identityAfter.relation : undefined; if ( relation !== undefined && relation.processGroupId !== fact.process.processGroupId ) continue; if (fact.state !== "same-generation") { failures.push({ pid: fact.process.pid, reason: "process-identity-unavailable", diagnostic: "process identity changed or became unavailable before signal", }); continue; } const expected = expectedIdentities.get(fact.process.pid); if ( (expected !== undefined && expected !== null && fact.identityAfter.state === "readable" && fact.identityAfter.identity !== expected) || expected === null || relation === undefined ) { failures.push({ pid: fact.process.pid, reason: "process-identity-unavailable", diagnostic: "process identity or group membership could not be revalidated before signal", }); continue; } if (fact.token.state === "unavailable") { failures.push({ pid: fact.process.pid, reason: "environment-unreadable", diagnostic: fact.token.reason, }); } else if (fact.token.runId !== runId) { failures.push({ pid: fact.process.pid, reason: "run-token-mismatch" }); } } return failures; } return processRunTokenFailures(members, runId, host); }; const processRunTokenFailures = async ( members: readonly ProcessTableEntry[], runId: string, host: ProcessOwnershipHost, ): Promise => { const failures: ProcessOwnershipValidationFailure[] = []; for await (const { process: member, observation } of readProcessRunTokens( host, members, )) { if (observation.state === "readable") { if (observation.runId !== runId) failures.push({ pid: member.pid, reason: "run-token-mismatch" }); continue; } failures.push({ pid: member.pid, reason: "environment-unreadable", diagnostic: observation.reason, }); } return recheckOwnershipFailures(failures, host); }; /** * Take a fresh liveness snapshot after each ownership-read phase. Only an * absent PID resolves an unreadable observation; a readable token mismatch * remains a failure. Never reuse this snapshot across identity/token reads. */ const recheckOwnershipFailures = async ( failures: readonly ProcessOwnershipValidationFailure[], host: ProcessOwnershipHost, ): Promise => { if (!failures.some(({ reason }) => reason !== "run-token-mismatch")) return failures; try { let processes = await host.listProcesses(); const isDarwin = (host.platform ?? process.platform) === "darwin"; for ( let attempt = 0; isDarwin && attempt < TOKEN_READ_RETRY_ATTEMPTS && processes.some( ({ pid, state }) => failures.some( (failure) => failure.pid === pid && failure.reason !== "run-token-mismatch", ) && state.slice(1).includes("E"), ); attempt += 1 ) { await new Promise((resolve) => setTimeout(resolve, TOKEN_READ_RETRY_DELAY_MS), ); processes = await host.listProcesses(); } const live = new Set(liveProcesses(processes).map(({ pid }) => pid)); return failures.filter( ({ pid, reason }) => reason === "run-token-mismatch" || live.has(pid), ); } catch (cause: unknown) { return failures.map((failure) => failure.reason === "run-token-mismatch" ? failure : { ...failure, diagnostic: `${failure.diagnostic}; process liveness recheck failed: ${errorMessage(cause)}`, }, ); } }; interface TokenOwnedProcessScan { readonly owned: readonly ProcessTableEntry[]; readonly failures: readonly ProcessOwnershipValidationFailure[]; readonly unverified: readonly OwnershipSweepUnverifiedProcess[]; readonly identities: ReadonlyMap; } /** One sweep candidate whose run token could not be read. */ interface UnreadableTokenCandidate { readonly process: ProcessTableEntry; readonly diagnostic: string; } const TOKEN_READ_RETRY_ATTEMPTS = 3; const TOKEN_READ_RETRY_DELAY_MS = 40; /** Darwin reports this when the kernel withholds a platform binary's environment. */ const PLATFORM_BINARY_ENVIRONMENT_WITHHELD = "platform_binary_environment_withheld"; /** Darwin reports this when the kernel withholds a restricted process environment. */ const RESTRICTED_PROCESS_ENVIRONMENT_WITHHELD = "restricted_process_environment_withheld"; const currentProcessUid = (): number | undefined => typeof process.getuid === "function" ? process.getuid() : undefined; /** * Whether the operating system provably withheld this candidate's run token: * current macOS omits the environment of Apple platform binaries and * CS_RESTRICT processes from KERN_PROCARGS2, and fails with EINVAL for * processes owned by another account. These classes cannot be token-verified * by this caller. An unrelated candidate is recorded as unverified instead * of failing an otherwise clean capture; every related or otherwise * unreadable candidate stays fail-closed. */ const environmentWithheldFromInspection = ( diagnostic: string, observedUid: number | undefined, ): boolean => { const uid = currentProcessUid(); if (uid !== undefined && observedUid !== undefined && observedUid !== uid) return true; return ( diagnostic === PLATFORM_BINARY_ENVIRONMENT_WITHHELD || diagnostic === RESTRICTED_PROCESS_ENVIRONMENT_WITHHELD ); }; const scanTokenOwnedProcesses = async ( processes: readonly ProcessTableEntry[], runId: string, host: ProcessOwnershipHost, captureBaseline?: ProcessOwnershipBaseline, relation?: OwnershipSweepRelation, ): Promise => { if (host.processIdentities !== undefined) return scanWithOwnershipEvidence( processes, runId, host, captureBaseline, relation, ); const candidates = processes; const failures: ProcessOwnershipValidationFailure[] = []; const owned: ProcessTableEntry[] = []; const ownedIdentities = new Map(); const recordOwned = (process: ProcessTableEntry): void => { owned.push(process); ownedIdentities.set(process.pid, null); }; const unreadable: UnreadableTokenCandidate[] = []; for await (const { process, observation } of readProcessRunTokens( host, candidates, )) { if (observation.state === "readable") { if (observation.runId === runId) recordOwned(process); } else unreadable.push({ process, diagnostic: observation.reason }); } const remaining = await settleUnreadableTokenCandidates( unreadable, runId, host, recordOwned, ); const relatedPids = new Set(); const relatedGroupIds = new Set(); if (relation !== undefined) { relatedPids.add(relation.leaderPid); for (const descendant of descendantsOf([relation.leaderPid], processes)) relatedPids.add(descendant.pid); relatedGroupIds.add(relation.processGroupId); for (const processGroupId of relation.sampledProcessGroupIds ?? []) if (Number.isSafeInteger(processGroupId) && processGroupId > 0) relatedGroupIds.add(processGroupId); for (const process of owned) relatedGroupIds.add(process.processGroupId); } const unverified: OwnershipSweepUnverifiedProcess[] = []; const strict: UnreadableTokenCandidate[] = []; const withheld: UnreadableTokenCandidate[] = []; for (const candidate of remaining) { const isRelated = relation === undefined || relatedPids.has(candidate.process.pid) || relatedGroupIds.has(candidate.process.processGroupId); if ( !isRelated && environmentWithheldFromInspection( candidate.diagnostic, candidate.process.uid, ) ) { withheld.push(candidate); continue; } strict.push(candidate); } let finalLive: ReadonlySet | undefined; try { finalLive = new Set( liveProcesses(await host.listProcesses()).map(({ pid }) => pid), ); } catch (cause: unknown) { void cause; finalLive = undefined; } for (const candidate of strict) { if (finalLive !== undefined && !finalLive.has(candidate.process.pid)) continue; failures.push({ pid: candidate.process.pid, reason: "environment-unreadable", diagnostic: candidate.diagnostic, }); } for (const candidate of withheld) { if (finalLive !== undefined && !finalLive.has(candidate.process.pid)) continue; unverified.push({ pid: candidate.process.pid, diagnostic: candidate.diagnostic, }); } return { owned, failures, unverified, identities: ownedIdentities }; }; const scanWithOwnershipEvidence = async ( listedProcesses: readonly ProcessTableEntry[], runId: string, host: ProcessOwnershipHost, captureBaseline: ProcessOwnershipBaseline | undefined, relation: OwnershipSweepRelation | undefined, ): Promise => { const evidenceOwner = relation === undefined ? new ProcessOwnershipEvidenceOwner() : relation.evidenceOwner; if (evidenceOwner === undefined) return { owned: [], failures: [ { pid: relation?.leaderPid ?? -1, reason: "process-identity-unavailable", diagnostic: "persistent process ownership evidence owner is required for scoped token scan", }, ], unverified: [], identities: new Map(), }; const observationRows = evidenceOwner.rowsForObservation(listedProcesses); const listedPids = new Set(listedProcesses.map(({ pid }) => pid)); const baseline = new Map( (captureBaseline ?? []).map(({ pid, identity }) => [pid, identity]), ); const retainedPids = new Set(evidenceOwner.candidates.map(({ pid }) => pid)); const firstIdentities = await readNativeProcessIdentityMap( observationRows, host, ); const baselineIdentityFailures: ProcessOwnershipValidationFailure[] = []; const candidates = observationRows.filter((process) => { if (retainedPids.has(process.pid)) return true; if (!baseline.has(process.pid)) return true; const before = baseline.get(process.pid); const now = firstIdentities.get(process.pid); if (before === null || now?.state !== "readable") { baselineIdentityFailures.push({ pid: process.pid, reason: "process-identity-unavailable", diagnostic: "process identity was unavailable during token validation", }); // Keep the row in the owning fact pass. The failure is not evidence that // the process is absent, and a related unknown generation must survive // a later process-table omission. return true; } return now.identity !== before; }); // Keep the first relation/token observation: bounded retries can see a // reparented process and a different withheld-token diagnostic. const initialFacts = await readProcessOwnershipFacts( candidates, host, undefined, { identityBefore: firstIdentities, }, ); let facts = initialFacts; for ( let attempt = 0; attempt < TOKEN_READ_RETRY_ATTEMPTS && facts.some(({ token }) => token.state === "unavailable"); attempt += 1 ) { await new Promise((resolve) => setTimeout(resolve, TOKEN_READ_RETRY_DELAY_MS), ); const pendingPids = new Set( facts .filter(({ token }) => token.state === "unavailable") .map(({ process }) => process.pid), ); const retryRows = candidates.filter(({ pid }) => pendingPids.has(pid)); const retries = await readProcessOwnershipFacts(retryRows, host); const retryByPid = new Map(retries.map((fact) => [fact.process.pid, fact])); facts = facts.map((fact) => retryByPid.get(fact.process.pid) ?? fact); } const processGroupIds = new Set([ ...(relation === undefined ? [] : [relation.processGroupId]), ...(relation?.sampledProcessGroupIds ?? []), ]); const retained = evidenceOwner.reconcile(facts, { runId, listedRows: listedProcesses.length, listedPids, leaderPid: relation?.leaderPid ?? -1, processGroupIds, scopeFacts: initialFacts, }); const factsByPid = new Map(facts.map((fact) => [fact.process.pid, fact])); const retainedByPid = new Map( retained.map((candidate) => [candidate.pid, candidate]), ); const failures: ProcessOwnershipValidationFailure[] = [ ...baselineIdentityFailures, ]; const unverified: OwnershipSweepUnverifiedProcess[] = []; const owned: ProcessTableEntry[] = []; const identities = new Map(); for (const fact of facts) { if (fact.state === "replaced") failures.push({ pid: fact.process.pid, reason: "process-identity-unavailable", diagnostic: "process identity changed during token validation", }); else if ( fact.state === "unavailable" && fact.identityAfter.state !== "absent" ) failures.push({ pid: fact.process.pid, reason: "process-identity-unavailable", diagnostic: "process identity was unavailable during token validation", }); const retainedCandidate = retainedByPid.get(fact.process.pid); if ( fact.state === "same-generation" && fact.token.state === "readable" && fact.token.runId !== runId && retainedCandidate !== undefined ) failures.push({ pid: fact.process.pid, reason: "run-token-mismatch", }); if (fact.token.state !== "unavailable") continue; if (fact.identityAfter.state === "absent") continue; const relationBefore = fact.identityBefore.state === "readable" ? fact.identityBefore.relation : undefined; const relationAfter = fact.identityAfter.state === "readable" ? fact.identityAfter.relation : undefined; const knownUnrelated = relation !== undefined && retainedCandidate === undefined && fact.state === "same-generation" && relationBefore?.state === "observed" && relationAfter?.state === "observed" && relationBefore.parentPid === relationAfter.parentPid && relationBefore.processGroupId === relationAfter.processGroupId; const observedUid = fact.state === "same-generation" && fact.identityAfter.state === "readable" ? fact.identityAfter.effectiveUid : undefined; if ( knownUnrelated && environmentWithheldFromInspection(fact.token.reason, observedUid) ) { unverified.push({ pid: fact.process.pid, diagnostic: fact.token.reason, }); continue; } if ( fact.state === "same-generation" || (retainedCandidate?.applicability === "related-ownership-unresolved" && fact.identityAfter.state === "readable" && fact.identityAfter.identity === retainedCandidate.identity) ) failures.push({ pid: fact.process.pid, reason: "environment-unreadable", diagnostic: fact.token.reason, }); } for (const candidate of retained) { const fact = factsByPid.get(candidate.pid); if (fact?.state !== "same-generation") { if ( fact?.identityAfter.state !== "absent" && !failures.some(({ pid }) => pid === candidate.pid) ) mergeProcessOwnershipFailure(failures, { pid: candidate.pid, reason: "process-identity-unavailable", diagnostic: "retained candidate identity is not currently verified", }); continue; } const identity = fact.identityAfter; if ( identity.state !== "readable" || candidate.identity === undefined || identity.identity !== candidate.identity ) continue; if ( candidate.applicability !== "token-verified" || fact.token.state !== "readable" || fact.token.runId !== runId ) continue; if (identity.relation?.state !== "observed") { failures.push({ pid: candidate.pid, reason: "process-identity-unavailable", diagnostic: "native process membership was unavailable", }); continue; } owned.push({ ...fact.process, parentPid: identity.relation.parentPid, processGroupId: identity.relation.processGroupId, }); identities.set(candidate.pid, candidate.identity); } const mergedFailures: ProcessOwnershipValidationFailure[] = []; for (const failure of failures) mergeProcessOwnershipFailure(mergedFailures, failure); return { owned, failures: mergedFailures, unverified, identities }; }; /** * Re-read run tokens for unresolved sweep candidates so transient exec and * exit states settle before classification. Each attempt revalidates * liveness, refreshes the diagnostic, and is bounded by a fixed attempt * budget; a still-unreadable candidate keeps its latest diagnostic. */ const settleUnreadableTokenCandidates = async ( pending: readonly UnreadableTokenCandidate[], runId: string, host: ProcessOwnershipHost, recordOwned: (process: ProcessTableEntry) => void, ): Promise => { let remaining = pending; for ( let attempt = 0; attempt < TOKEN_READ_RETRY_ATTEMPTS && remaining.length > 0; attempt += 1 ) { await new Promise((resolve) => setTimeout(resolve, TOKEN_READ_RETRY_DELAY_MS), ); let live: ReadonlySet | undefined; try { live = new Set( liveProcesses(await host.listProcesses()).map(({ pid }) => pid), ); } catch (cause: unknown) { void cause; live = undefined; } const stillUnreadable: UnreadableTokenCandidate[] = []; const candidates = remaining .map(({ process }) => process) .filter(({ pid }) => live === undefined || live.has(pid)); for await (const { process, observation } of readProcessRunTokens( host, candidates, )) { if (observation.state === "readable") { if (observation.runId === runId) recordOwned(process); continue; } stillUnreadable.push({ process, diagnostic: observation.reason, }); } remaining = stillUnreadable; } return remaining; };