mirror of
https://github.com/morluto/rea.git
synced 2026-11-03 05:16:16 +00:00
Keep a run-scoped evidence owner from acquisition through settlement and cleanup retry. Bind token observations to native generations and retain related generations and unresolved identity lookups across table omissions, reparenting and process-group changes without treating PID hints as authority. Use native effective UID facts for withholding decisions, corroborate Linux procfs absence, and share the Linux stat parser with sampling and debugger identity checks. Signal only after current token and group revalidation. Complete swept cleanup across all groups with one bounded final family check; retain unresolved owners and successful-peer completion across retries. Validation: npm run check, docs:check and test discovery passed. Focused ownership/consumer coverage totals 513 passed and 1 skipped across 35 files, including the corrected Electron cancellation test rerun independently. Darwin helper compilation, real owned Node child cleanup, CLI/MCP capture and retained cleanup retries ran. Real Electron applications, native Linux races/PID reuse and Windows cleanup were not qualified. The original Linux reports remain open for that work. Fixes #5238 Refs #4322, #5206
1540 lines
53 KiB
TypeScript
1540 lines
53 KiB
TypeScript
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<void>;
|
|
/** Release owned native inspection helpers and their temporary files. */
|
|
close?(): Promise<void>;
|
|
/** List current processes; startup inspection may be cancelled by the caller. */
|
|
listProcesses(signal?: AbortSignal): Promise<readonly ProcessTableEntry[]>;
|
|
environment(
|
|
pid: number,
|
|
signal?: AbortSignal,
|
|
): Promise<Readonly<Record<string, string>>>;
|
|
/** Read only run-token values in one host operation when the OS supports it. */
|
|
runTokens?(
|
|
processes: readonly ProcessTableEntry[],
|
|
signal?: AbortSignal,
|
|
): Promise<ReadonlyMap<number, ProcessRunTokenObservation>>;
|
|
/** Read stable identities; omit the signal during cleanup so it can finish. */
|
|
processIdentities?(
|
|
processes: readonly ProcessTableEntry[],
|
|
signal?: AbortSignal,
|
|
): Promise<ReadonlyMap<number, ProcessIdentityObservation>>;
|
|
/** Snapshot identities before launch, honoring startup cancellation. */
|
|
captureBaseline?(signal?: AbortSignal): Promise<ProcessOwnershipBaseline>;
|
|
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<ProcessCleanupResult> => {
|
|
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<string | undefined> =>
|
|
(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<ProcessCleanupResult> => {
|
|
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<void>((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<ProcessCleanupResult> => {
|
|
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<number, string | null>;
|
|
readonly unresolved: readonly ProcessOwnershipValidationFailure[];
|
|
readonly unverified: readonly OwnershipSweepUnverifiedProcess[];
|
|
}
|
|
|
|
interface ProcessRelationSnapshot {
|
|
readonly currentRelations: readonly ProcessTableEntry[];
|
|
readonly identities: ReadonlyMap<number, ProcessIdentityObservation>;
|
|
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<ProcessRelationSnapshot> => {
|
|
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<ProcessCleanupResult, { readonly cleaned: false }>
|
|
> => {
|
|
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<OwnedCleanupPlan | ProcessCleanupResult> => {
|
|
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<number>([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<number>();
|
|
const unresolved = [...tokenOwned.failures, ...relationSnapshot.failures];
|
|
const unverifiableSampledGroups = new Set<number>();
|
|
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<number, string | null>,
|
|
): 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<string, number>();
|
|
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<number, string | null> = new Map(),
|
|
): Promise<readonly ProcessOwnershipValidationFailure[]> => {
|
|
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<readonly ProcessOwnershipValidationFailure[]> => {
|
|
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<readonly ProcessOwnershipValidationFailure[]> => {
|
|
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<void>((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<number, string | null>;
|
|
}
|
|
|
|
/** 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<TokenOwnedProcessScan> => {
|
|
if (host.processIdentities !== undefined)
|
|
return scanWithOwnershipEvidence(
|
|
processes,
|
|
runId,
|
|
host,
|
|
captureBaseline,
|
|
relation,
|
|
);
|
|
|
|
const candidates = processes;
|
|
const failures: ProcessOwnershipValidationFailure[] = [];
|
|
const owned: ProcessTableEntry[] = [];
|
|
const ownedIdentities = new Map<number, string | null>();
|
|
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<number>();
|
|
const relatedGroupIds = new Set<number>();
|
|
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<number> | 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<TokenOwnedProcessScan> => {
|
|
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<void>((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<number>([
|
|
...(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<number, string | null>();
|
|
|
|
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<readonly UnreadableTokenCandidate[]> => {
|
|
let remaining = pending;
|
|
for (
|
|
let attempt = 0;
|
|
attempt < TOKEN_READ_RETRY_ATTEMPTS && remaining.length > 0;
|
|
attempt += 1
|
|
) {
|
|
await new Promise<void>((resolve) =>
|
|
setTimeout(resolve, TOKEN_READ_RETRY_DELAY_MS),
|
|
);
|
|
let live: ReadonlySet<number> | 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;
|
|
};
|