diff --git a/bridge/process/ProcessRunTokenReader.swift b/bridge/process/ProcessRunTokenReader.swift index d4b21fe513..9f2456fe49 100644 --- a/bridge/process/ProcessRunTokenReader.swift +++ b/bridge/process/ProcessRunTokenReader.swift @@ -12,16 +12,29 @@ struct ProcessIdentityObservation: Encodable { let pid: Int32 let state: String let identity: String? + let effective_uid: UInt32? + let lifecycle: String? + let relation: ProcessRelationObservation? let reason: String? } -private struct ProcessMetadata { +struct ProcessRelationObservation: Encodable { + let state: String + let parent_pid: Int32 + let process_group_id: Int32 +} + +struct ProcessMetadata { let identity: String + let effectiveUid: UInt32 + let lifecycle: String + let parentPid: Int32 + let processGroupId: Int32 let pointerSize: Int } struct ProcessSnapshot { - fileprivate let metadata: [Int32: ProcessMetadata] + let metadata: [Int32: ProcessMetadata] let failure: String? } @@ -75,6 +88,10 @@ func readProcessSnapshot( process.kp_proc.p_pid, ProcessMetadata( identity: "\(start.tv_sec):\(start.tv_usec)", + effectiveUid: process.kp_eproc.e_ucred.cr_uid, + lifecycle: process.kp_proc.p_stat == SZOMB ? "zombie" : "non-zombie", + parentPid: process.kp_eproc.e_ppid, + processGroupId: process.kp_eproc.e_pgid, pointerSize: process.kp_proc.p_flag & P_LP64 != 0 ? 8 : 4 ) ) @@ -87,20 +104,61 @@ func readProcessSnapshot( } func readProcessIdentities(pids: [Int32]) -> [ProcessIdentityObservation] { - let snapshot = readProcessSnapshot() + readProcessIdentities(pids: pids, snapshot: readProcessSnapshot()) +} + +func readProcessIdentities( + pids: [Int32], + snapshot: ProcessSnapshot +) -> [ProcessIdentityObservation] { return pids.map { pid in + guard pid > 0 else { + return unavailableIdentity(pid: pid, reason: "invalid_process_id") + } guard let metadata = snapshot.metadata[pid] else { - return unavailableIdentity( + if let failure = snapshot.failure { + return unavailableIdentity(pid: pid, reason: failure) + } + // This records absence from this successful KERN_PROC_ALL snapshot only. + // Unlike the Linux procfs path, it has no PID-specific kill(0) + // corroboration; callers must not treat it as universal or future exit + // proof. + return ProcessIdentityObservation( pid: pid, - reason: snapshot.failure ?? "process_not_in_snapshot" + state: "absent", + identity: nil, + effective_uid: nil, + lifecycle: nil, + relation: nil, + reason: "not-in-snapshot" ) } - return ProcessIdentityObservation(pid: pid, state: "readable", identity: metadata.identity, reason: nil) + return ProcessIdentityObservation( + pid: pid, + state: "readable", + identity: metadata.identity, + effective_uid: metadata.effectiveUid, + lifecycle: metadata.lifecycle, + relation: ProcessRelationObservation( + state: "observed", + parent_pid: metadata.parentPid, + process_group_id: metadata.processGroupId + ), + reason: nil + ) } } private func unavailableIdentity(pid: Int32, reason: String) -> ProcessIdentityObservation { - ProcessIdentityObservation(pid: pid, state: "unavailable", identity: nil, reason: reason) + ProcessIdentityObservation( + pid: pid, + state: "unavailable", + identity: nil, + effective_uid: nil, + lifecycle: nil, + relation: nil, + reason: reason + ) } enum RunTokenReadError: Error { diff --git a/docs/process-capture.md b/docs/process-capture.md index 3280c80c3e..9096b494fa 100644 --- a/docs/process-capture.md +++ b/docs/process-capture.md @@ -459,6 +459,14 @@ Each filesystem checkpoint starts with a fresh `file_bytes` budget across all selected roots; it is not a per-file allowance. File contents are read in 64 KiB chunks for hashing and are not embedded in capture Evidence. +Ownership inspections bind run-token reads to native start identities and retain +related generations through reparenting, group changes, omitted rows, and cleanup +retries. The captured run keeps one evidence owner through settlement and cleanup. +An absent or changed token leaves a previously related generation unresolved; +current token proof is still required before signaling. Swept cleanup verifies the +remaining family after its group signals. Coverage is bounded by the native +snapshots and generations inspected; sampled group IDs select candidates. + When the host withholds an unrelated process’s ownership token, REA leaves that process untouched and records its PID and reason in `cleanup.unverified_processes` and process residual unknowns. Successful cleanup verifies the owned group; diff --git a/docs/testing.md b/docs/testing.md index 334024cc1a..4da56f539d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -251,6 +251,15 @@ lineage followed by a confirmed passive family census. Windows controls require a fresh lineage observation and do not signal cached descendant PIDs after root termination. Simulated Windows controls do not establish native Windows behavior. +Native process-ownership controls distinguish start identity, zombie lifecycle, +current parent/group relation, and token-read evidence. Retain generation-bound +candidates through smaller lineage observations, table omissions, reparenting, +group changes, and failed cleanup retries. A readable token with unavailable +identity stays unresolved. Native UID facts, rather than an earlier process-table +row, determine whether a token denial concerns another user. Darwin tests compile +the helper and exercise snapshot growth, absence, lifecycle, and UID observations; +controlled Linux procfs tests do not establish real Linux exit or PID-reuse behavior. + For atomic writes, exercise cancellation on both sides of the documented commit point and preserve any publication receipt. A late interruption does not prove that no output was published. diff --git a/src/browser/ElectronCaptureCleanup.boundary.test.ts b/src/browser/ElectronCaptureCleanup.boundary.test.ts index 1fdf98b12d..e9b0a57cb2 100644 --- a/src/browser/ElectronCaptureCleanup.boundary.test.ts +++ b/src/browser/ElectronCaptureCleanup.boundary.test.ts @@ -37,9 +37,12 @@ it.skipIf(process.platform === "win32")( await once(spawned.process.stdout!, "data"); const lineage = await observeOwnedProcessLineage(spawned.ownership); expect(lineage.status).toBe("verified"); + const evidenceOwner = spawned.ownership.evidenceOwner; + if (evidenceOwner === undefined) + throw new Error("owned spawn omitted persistent ownership evidence"); const owner = new ElectronCaptureCleanup( { close }, - spawned.ownership, + { ...spawned.ownership, evidenceOwner }, lineage, ); expect(await owner.release(scope)).toMatchObject({ diff --git a/src/browser/ElectronCaptureCleanup.test.ts b/src/browser/ElectronCaptureCleanup.test.ts index 7e128152ff..a95f90af59 100644 --- a/src/browser/ElectronCaptureCleanup.test.ts +++ b/src/browser/ElectronCaptureCleanup.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { createDeferred } from "../../tests/fixtures/binarySession.js"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js"; @@ -7,33 +7,38 @@ import * as ownershipObservation from "../process/ProcessOwnershipObservation.js import * as processCleanup from "./ElectronProcessCleanup.js"; import * as processOwnership from "../process/ProcessOwnership.js"; import type { - OwnedProcessGroup, + PersistentOwnedProcessGroup, ProcessLineageObservation, } from "../process/ProcessOwnership.js"; +import { ProcessOwnershipEvidenceOwner } from "../process/ProcessOwnershipEvidence.js"; import { ElectronCaptureCleanup } from "./ElectronCaptureCleanup.js"; -const ownership: OwnedProcessGroup = { - runId: "authored-run-token", - leaderPid: 424242, - processGroupId: 424242, -}; +let ownership: PersistentOwnedProcessGroup; +beforeEach(() => { + ownership = { + runId: "authored-run-token", + leaderPid: 424242, + processGroupId: 424242, + evidenceOwner: new ProcessOwnershipEvidenceOwner(), + }; +}); const verified: ProcessLineageObservation = { status: "verified", observedAt: "2026-10-17T00:00:00Z", lineage: { - runId: ownership.runId, - launcherPid: ownership.leaderPid, + runId: "authored-run-token", + launcherPid: 424242, launcherParentPid: 1, - processGroupId: ownership.processGroupId, + processGroupId: 424242, descendants: [], }, }; const unavailable: ProcessLineageObservation = { status: "unavailable", observedAt: "2026-10-17T00:00:01Z", - runId: ownership.runId, - launcherPid: ownership.leaderPid, - processGroupId: ownership.processGroupId, + runId: "authored-run-token", + launcherPid: 424242, + processGroupId: 424242, reason: "authored identity unavailable", }; afterEach(() => { @@ -311,7 +316,7 @@ it("retains a Windows family left after root termination without signaling cache { pid: 424243, parentPid: ownership.leaderPid, - processGroupId: ownership.processGroupId, + processGroupId: 424242, }, ], }, diff --git a/src/browser/ElectronCaptureCleanup.ts b/src/browser/ElectronCaptureCleanup.ts index d7e0e567ad..791169a263 100644 --- a/src/browser/ElectronCaptureCleanup.ts +++ b/src/browser/ElectronCaptureCleanup.ts @@ -8,7 +8,7 @@ import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.j import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { observeOwnedProcessLineage } from "../process/ProcessOwnershipObservation.js"; import type { - OwnedProcessGroup, + PersistentOwnedProcessGroup, ProcessCleanupResult, ProcessLineageObservation, } from "../process/ProcessOwnership.js"; @@ -21,15 +21,17 @@ type ElectronApplication = Awaited>; export class ElectronCaptureCleanup { readonly #transport: AnalysisCleanupLease; readonly #processes: AnalysisCleanupLease; + readonly #ownership: PersistentOwnedProcessGroup | undefined; #closeOperation: Promise | undefined; #lineage: ProcessLineageObservation | undefined; #processResult: ProcessCleanupResult | undefined; constructor( application: Pick, - ownership: OwnedProcessGroup | undefined, + ownership: PersistentOwnedProcessGroup | undefined, lineage: ProcessLineageObservation | undefined, ) { + this.#ownership = ownership; this.#lineage = lineage; this.#transport = new AnalysisCleanupLease(async () => { // A deadline stops this wait, not the underlying Playwright close. Reuse @@ -49,21 +51,21 @@ export class ElectronCaptureCleanup { ); }, ["browser_transport"]); const resources = - ownership === undefined + this.#ownership === undefined ? ["owned_process_group"] : [ "owned_process_group", - `run:${ownership.runId}`, - `process-group:${String(ownership.processGroupId)}`, + `run:${this.#ownership.runId}`, + `process-group:${String(this.#ownership.processGroupId)}`, ]; this.#processes = new AnalysisCleanupLease(async () => { const cleanup = - ownership === undefined + this.#ownership === undefined ? { cleaned: false as const, reason: "owned Electron process identity was unavailable", } - : await this.#cleanupProcesses(ownership); + : await this.#cleanupProcesses(this.#ownership); this.#processResult = cleanup; if (!cleanup.cleaned) throw new ArtifactReaderFailure("unavailable", cleanup.reason, { @@ -103,7 +105,7 @@ export class ElectronCaptureCleanup { } async #cleanupProcesses( - ownership: OwnedProcessGroup, + ownership: PersistentOwnedProcessGroup, ): Promise { const current = await observeOwnedProcessLineage(ownership); if (current.status === "verified") this.#lineage = current; diff --git a/src/browser/ElectronProcessCleanup.test.ts b/src/browser/ElectronProcessCleanup.test.ts new file mode 100644 index 0000000000..dca446022d --- /dev/null +++ b/src/browser/ElectronProcessCleanup.test.ts @@ -0,0 +1,378 @@ +import { describe, expect, it, vi } from "vitest"; +import type { + OwnedProcessGroup, + ProcessLineageObservation, + ProcessOwnershipHost, + ProcessTableEntry, +} from "../process/ProcessOwnership.js"; +import { ProcessOwnershipEvidenceOwner } from "../process/ProcessOwnershipEvidence.js"; +import { cleanupElectronProcesses } from "./ElectronProcessCleanup.js"; +import { observeOwnedProcessLineage } from "../process/ProcessOwnershipObservation.js"; + +describe.skipIf(process.platform === "win32")( + "Electron cleanup ownership evidence", + () => { + it("keeps a previously token-owned helper unresolved after it reparents and changes groups", async () => { + let rows: readonly ProcessTableEntry[] = [ + { + pid: 100, + parentPid: 1, + processGroupId: 100, + state: "S", + command: "electron", + }, + { + pid: 101, + parentPid: 100, + processGroupId: 100, + state: "S", + command: "electron-helper", + }, + ]; + let withholdToken = false; + const evidenceOwner = new ProcessOwnershipEvidenceOwner(); + const ownership: OwnedProcessGroup = { + runId: "electron-run", + leaderPid: 100, + processGroupId: 100, + expectedParentPid: 1, + expectedCommand: "electron", + sweepTokenOwnedProcesses: true, + evidenceOwner, + }; + const host: ProcessOwnershipHost = { + platform: process.platform, + listProcesses: () => Promise.resolve(rows), + environment: () => Promise.resolve({}), + runTokens: (members) => + Promise.resolve( + new Map( + members.map(({ pid }) => [ + pid, + withholdToken + ? { + state: "unavailable" as const, + reason: "platform_binary_environment_withheld", + } + : { state: "readable" as const, runId: "electron-run" }, + ]), + ), + ), + processIdentities: (members) => + Promise.resolve( + new Map( + members.map((member) => [ + member.pid, + withholdToken && member.pid === 100 + ? { + state: "absent" as const, + reason: + process.platform === "darwin" + ? ("not-in-snapshot" as const) + : ("not-found-in-caller-pid-namespace" as const), + } + : { + state: "readable" as const, + identity: `start-${String(member.pid)}`, + lifecycle: { + state: "observed" as const, + lifecycle: "non-zombie" as const, + }, + relation: { + state: "observed" as const, + parentPid: member.parentPid, + processGroupId: member.processGroupId, + }, + }, + ]), + ), + ), + signalGroup: vi.fn(() => { + rows = [ + { + pid: 101, + parentPid: 1, + processGroupId: 200, + state: "S", + command: "electron-helper", + }, + ]; + withholdToken = true; + }), + }; + const lineage: ProcessLineageObservation = { + status: "verified", + observedAt: "2026-10-19T00:00:00.000Z", + lineage: { + runId: ownership.runId, + launcherPid: ownership.leaderPid, + launcherParentPid: 1, + processGroupId: ownership.processGroupId, + descendants: [{ pid: 101, parentPid: 100, processGroupId: 100 }], + }, + }; + + const result = await cleanupElectronProcesses(ownership, lineage, { + platform: process.platform, + terminateTree: vi.fn(), + processOwnershipHost: host, + }); + + expect(result.cleaned).toBe(false); + if (result.cleaned) + throw new Error("expected cleanup to remain unresolved"); + expect(result.reason).toContain( + "process ownership token could not be read", + ); + expect(evidenceOwner.candidates).toMatchObject([ + { + pid: 101, + identity: "start-101", + applicability: "token-verified", + scopeEvidence: "run-token", + relation: { parentPid: 1, processGroupId: 200 }, + }, + ]); + }); + }, +); + +it.each([ + { peerFails: false, withholdToken: true }, + { peerFails: true, withholdToken: true }, + { peerFails: false, withholdToken: false }, +])( + "retains detached lineage evidence (initial peer failure: $peerFails, withheld token: $withholdToken)", + async ({ peerFails, withholdToken }) => { + let detached = false; + const launcher: ProcessTableEntry = { + pid: 100, + parentPid: 1, + processGroupId: 100, + state: "S", + command: "electron", + }; + const child: ProcessTableEntry = { + pid: 101, + parentPid: 100, + processGroupId: 100, + state: "S", + command: "helper", + }; + const evidenceOwner = new ProcessOwnershipEvidenceOwner(); + const ownership = { + runId: "lineage-run", + leaderPid: 100, + processGroupId: 100, + sweepTokenOwnedProcesses: true as const, + evidenceOwner, + }; + let launcherExited = false; + let childExited = false; + const signalGroup = vi.fn((groupId: number) => { + if (groupId === 100) launcherExited = true; + if (groupId === 200) childExited = true; + }); + + const host: ProcessOwnershipHost = { + listProcesses: async () => [ + ...(launcherExited ? [] : [launcher]), + ...(childExited + ? [] + : [ + detached + ? { ...child, parentPid: 1, processGroupId: 200 } + : child, + ]), + ...(!detached && peerFails ? [{ ...child, pid: 102 }] : []), + ], + environment: async () => ({}), + runTokens: async (members) => + new Map( + members.map(({ pid }) => [ + pid, + (detached && withholdToken && pid === 101) || + (peerFails && pid === 102) + ? { + state: "unavailable" as const, + reason: "platform_binary_environment_withheld", + } + : { state: "readable" as const, runId: ownership.runId }, + ]), + ), + processIdentities: async (members) => + new Map( + members.map(({ pid }) => [ + pid, + (launcherExited && pid === 100) || + (childExited && pid === 101) || + (detached && pid === 102) + ? { state: "absent" as const, reason: "not-in-snapshot" as const } + : { + state: "readable" as const, + identity: `generation-${pid}`, + lifecycle: { + state: "observed" as const, + lifecycle: "non-zombie" as const, + }, + relation: { + state: "observed" as const, + parentPid: pid === 100 || detached ? 1 : 100, + processGroupId: detached && pid === 101 ? 200 : 100, + }, + }, + ]), + ), + signalGroup, + }; + const first = await observeOwnedProcessLineage(ownership, host); + expect(first.status).toBe(peerFails ? "unavailable" : "verified"); + expect(evidenceOwner.candidates).toContainEqual( + expect.objectContaining({ + pid: 101, + identity: "generation-101", + applicability: "token-verified", + }), + ); + detached = true; + const current = await observeOwnedProcessLineage(ownership, host); + expect(current).toMatchObject({ + status: "verified", + lineage: { descendants: [] }, + }); + const result = await cleanupElectronProcesses(ownership, current, { + platform: "darwin", + terminateTree: vi.fn(), + processOwnershipHost: host, + }); + if (withholdToken) { + expect(result).toMatchObject({ cleaned: false }); + expect(signalGroup).not.toHaveBeenCalledWith(200, expect.anything()); + expect(evidenceOwner.candidates).toContainEqual( + expect.objectContaining({ + pid: 101, + identity: "generation-101", + applicability: "token-verified", + }), + ); + } else { + expect(result).toMatchObject({ cleaned: true, signaled: true }); + expect(signalGroup.mock.calls).toEqual([ + [100, "SIGKILL"], + [200, "SIGKILL"], + ]); + expect(evidenceOwner.candidates).toEqual([]); + } + }, +); + +it.skipIf(process.platform === "win32")( + "signals every verified captured group before the shared final census", + async () => { + let rows: readonly ProcessTableEntry[] = [ + { + pid: 100, + parentPid: 1, + processGroupId: 100, + state: "S", + command: "electron", + }, + { + pid: 101, + parentPid: 100, + processGroupId: 100, + state: "S", + command: "renderer", + }, + { + pid: 200, + parentPid: 101, + processGroupId: 200, + state: "S", + command: "helper-group-leader", + }, + { + pid: 201, + parentPid: 200, + processGroupId: 200, + state: "S", + command: "helper", + }, + ]; + const signalGroups: number[] = []; + const ownership = { + runId: "multi-group-run", + leaderPid: 100, + processGroupId: 100, + expectedParentPid: 1, + sweepTokenOwnedProcesses: true as const, + evidenceOwner: new ProcessOwnershipEvidenceOwner(), + }; + const host: ProcessOwnershipHost = { + platform: process.platform, + listProcesses: async () => rows, + environment: async () => ({}), + runTokens: async (members) => + new Map( + members.map(({ pid }) => [ + pid, + { state: "readable" as const, runId: ownership.runId }, + ]), + ), + processIdentities: async (members) => { + const liveByPid = new Map(rows.map((row) => [row.pid, row])); + return new Map( + members.map((member) => { + const current = liveByPid.get(member.pid); + return [ + member.pid, + current === undefined + ? { + state: "absent" as const, + reason: "not-in-snapshot" as const, + } + : { + state: "readable" as const, + identity: `generation-${String(member.pid)}`, + relation: { + state: "observed" as const, + parentPid: current.parentPid, + processGroupId: current.processGroupId, + }, + }, + ] as const; + }), + ); + }, + signalGroup: (groupId) => { + signalGroups.push(groupId); + rows = rows.filter(({ processGroupId }) => processGroupId !== groupId); + }, + }; + const lineage: ProcessLineageObservation = { + status: "verified", + observedAt: "2026-10-19T00:00:00.000Z", + lineage: { + runId: ownership.runId, + launcherPid: ownership.leaderPid, + launcherParentPid: 1, + processGroupId: ownership.processGroupId, + descendants: [ + { pid: 101, parentPid: 100, processGroupId: 100 }, + { pid: 200, parentPid: 101, processGroupId: 200 }, + { pid: 201, parentPid: 200, processGroupId: 200 }, + ], + }, + }; + + const result = await cleanupElectronProcesses(ownership, lineage, { + platform: process.platform, + terminateTree: vi.fn(), + processOwnershipHost: host, + }); + + expect(result).toMatchObject({ cleaned: true, signaled: true }); + expect(signalGroups).toEqual([100, 200]); + expect(rows).toEqual([]); + }, +); diff --git a/src/browser/ElectronProcessCleanup.ts b/src/browser/ElectronProcessCleanup.ts index df13ed20e3..45238703ac 100644 --- a/src/browser/ElectronProcessCleanup.ts +++ b/src/browser/ElectronProcessCleanup.ts @@ -3,22 +3,27 @@ import { cleanupWindowsProcessTree, verifyNoTokenOwnedProcesses, type OwnedProcessGroup, + type PersistentOwnedProcessGroup, type ProcessCleanupResult, type ProcessLineageObservation, type OwnershipSweepUnverifiedProcess, + type ProcessOwnershipHost, } from "../process/ProcessOwnership.js"; +import { systemProcessOwnershipHost } from "../process/ProcessOwnershipObservation.js"; import { selectCapturedProcessGroupIds } from "../process/ProcessOwnershipProcessTree.js"; /** Clean up an Electron launch. Windows signals a process only after lineage is verified. */ export const cleanupElectronProcesses = async ( - ownership: OwnedProcessGroup, + ownership: PersistentOwnedProcessGroup, lineage: ProcessLineageObservation | undefined, host: { readonly platform: NodeJS.Platform; readonly terminateTree: typeof cleanupWindowsProcessTree; + readonly processOwnershipHost?: ProcessOwnershipHost; } = { platform: process.platform, terminateTree: cleanupWindowsProcessTree, + processOwnershipHost: systemProcessOwnershipHost, }, ): Promise => { if ( @@ -36,9 +41,18 @@ export const cleanupElectronProcesses = async ( const root = host.platform === "win32" ? { cleaned: true as const, signaled: false } - : await cleanupOwnedProcessGroup(ownership); + : await cleanupOwnedProcessGroup(ownership, host.processOwnershipHost); if (!root.cleaned) return root; - const released = await verifyFamilyRelease(ownership, root); + if ( + host.platform !== "win32" && + ownership.sweepTokenOwnedProcesses === true + ) + return root; + const released = await verifyFamilyRelease( + ownership, + root, + host.processOwnershipHost, + ); return released.cleaned ? released : { @@ -50,7 +64,9 @@ export const cleanupElectronProcesses = async ( // /t already includes descendants. Do not signal cached child PIDs again // after awaiting root termination: they may have exited or been reused. const root = await host.terminateTree(ownership.leaderPid); - return root.cleaned ? verifyFamilyRelease(ownership, root) : root; + return root.cleaned + ? verifyFamilyRelease(ownership, root, host.processOwnershipHost) + : root; } const groupIds = selectCapturedProcessGroupIds( ownership.leaderPid, @@ -59,36 +75,27 @@ export const cleanupElectronProcesses = async ( process_group_id: processGroupId, })), ); - let signaled = false; - const unverified: OwnershipSweepUnverifiedProcess[] = []; - for (const processGroupId of groupIds) { - const cleanupOwnership: OwnedProcessGroup = - processGroupId === ownership.processGroupId - ? { ...ownership, leaderPid: processGroupId, processGroupId } - : { - runId: ownership.runId, - leaderPid: processGroupId, - processGroupId, - }; - const result = await cleanupOwnedProcessGroup(cleanupOwnership); - if (!result.cleaned) return result; - signaled ||= result.signaled; - unverified.push(...(result.unverified ?? [])); - } - return verifyFamilyRelease(ownership, { - cleaned: true, - signaled, - ...(unverified.length === 0 ? {} : { unverified }), - }); + const sampledProcessGroupIds = [ + ...new Set([...(ownership.sampledProcessGroupIds ?? []), ...groupIds]), + ]; + const root = await cleanupOwnedProcessGroup( + { ...ownership, sampledProcessGroupIds }, + host.processOwnershipHost, + ); + if (!root.cleaned) return root; + return ownership.sweepTokenOwnedProcesses === true + ? root + : verifyFamilyRelease(ownership, root, host.processOwnershipHost); }; const verifyFamilyRelease = async ( - ownership: OwnedProcessGroup, + ownership: PersistentOwnedProcessGroup, root: Extract, + processHost: ProcessOwnershipHost | undefined, ): Promise => { const remaining = await verifyNoTokenOwnedProcesses( ownership.runId, - undefined, + processHost, ownership.captureBaseline, { leaderPid: ownership.leaderPid, @@ -96,6 +103,7 @@ const verifyFamilyRelease = async ( ...(ownership.sampledProcessGroupIds === undefined ? {} : { sampledProcessGroupIds: ownership.sampledProcessGroupIds }), + evidenceOwner: ownership.evidenceOwner, }, ); const unverified = [ diff --git a/src/browser/PlaywrightElectronActiveProvider.test.ts b/src/browser/PlaywrightElectronActiveProvider.test.ts index 17c34e985b..146c4a2528 100644 --- a/src/browser/PlaywrightElectronActiveProvider.test.ts +++ b/src/browser/PlaywrightElectronActiveProvider.test.ts @@ -1,6 +1,6 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { cleanupElectronProcesses } from "./ElectronProcessCleanup.js"; -import { afterEach, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; @@ -11,10 +11,11 @@ import { } from "../../tests/fixtures/electron/fuseImage.js"; import * as processOwnership from "../process/ProcessOwnership.js"; +import { ProcessOwnershipEvidenceOwner } from "../process/ProcessOwnershipEvidence.js"; import * as ownershipObservation from "../process/ProcessOwnershipObservation.js"; import * as electronActions from "./PlaywrightElectronActiveActions.js"; import { electronActiveObservationInputSchema } from "../domain/javascript/electronActiveObservation.js"; -import type { OwnedProcessGroup } from "../process/ProcessOwnership.js"; +import type { PersistentOwnedProcessGroup } from "../process/ProcessOwnership.js"; import { PlaywrightElectronActiveProvider, safeElectronEnvironment, @@ -75,12 +76,17 @@ it("passes required Windows runtime variables without inheriting unrelated value }); }); -const ownership = { - runId: "run", - leaderPid: 4242, - processGroupId: 4242, - expectedParentPid: 1, -} satisfies OwnedProcessGroup; +let ownership: PersistentOwnedProcessGroup; + +beforeEach(() => { + ownership = { + runId: "run", + leaderPid: 4242, + processGroupId: 4242, + expectedParentPid: 1, + evidenceOwner: new ProcessOwnershipEvidenceOwner(), + }; +}); const emptyHookSnapshot = () => ({ events: [], @@ -694,8 +700,8 @@ it("cancels a completed capture when cancellation arrives during process cleanup stubCompletedCapture(); const controller = new AbortController(); const reason = new Error("SIGTERM during process cleanup"); - // The POSIX path ends with the token sweep; Windows ends with tree termination. - vi.spyOn(processOwnership, "verifyNoTokenOwnedProcesses").mockImplementation( + // POSIX cleanup owns the final sweep; Windows uses tree termination. + vi.spyOn(processOwnership, "cleanupOwnedProcessGroup").mockImplementation( async () => { controller.abort(reason); return { diff --git a/src/browser/PlaywrightElectronActiveProvider.ts b/src/browser/PlaywrightElectronActiveProvider.ts index 819501130d..70c8b6f63f 100644 --- a/src/browser/PlaywrightElectronActiveProvider.ts +++ b/src/browser/PlaywrightElectronActiveProvider.ts @@ -25,9 +25,10 @@ import { BrowserObservationError } from "../domain/browserObservationError.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { err, ok, type Result } from "../domain/result.js"; import type { - OwnedProcessGroup, + PersistentOwnedProcessGroup, ProcessLineageObservation, } from "../process/ProcessOwnership.js"; +import { ProcessOwnershipEvidenceOwner } from "../process/ProcessOwnershipEvidence.js"; import { observeOwnedProcessLineage, prepareProcessOwnershipInspection, @@ -108,7 +109,7 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati options: ExecutionOptions, ): Promise> { let application: ElectronApplication | undefined; - let ownership: OwnedProcessGroup | undefined; + let ownership: PersistentOwnedProcessGroup | undefined; let lineage: ProcessLineageObservation | undefined; let capture: ElectronActiveObservationResult | undefined; let paths: ElectronPaths | undefined; @@ -182,6 +183,7 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati expectedParentPid: process.pid, expectedCommand: paths.executable, sweepTokenOwnedProcesses: true, + evidenceOwner: new ProcessOwnershipEvidenceOwner(), ...(captureBaseline === undefined ? {} : { captureBaseline }), }; lineage = await observeOwnedProcessLineage(ownership); diff --git a/src/gdb/GdbSessionManager.ts b/src/gdb/GdbSessionManager.ts index 25d21a392a..dc517aadc2 100644 --- a/src/gdb/GdbSessionManager.ts +++ b/src/gdb/GdbSessionManager.ts @@ -30,7 +30,7 @@ import { type ProcessStartTimeReader, verifyOwnedInferiorsEnded, } from "../process/ownedDebuggerSession.js"; -import { parseLinuxProcessStartTime } from "../process/ProcessOwnershipObservation.js"; +import { parseLinuxProcessStat } from "../process/LinuxProcessStat.js"; const STARTUP_TIMEOUT_MS = 10_000; const COMMAND_TIMEOUT_MS = 120_000; @@ -387,7 +387,10 @@ const verifyGdbInferiorOutputSink = async ( "GDB cannot confirm live inferior output descriptors outside Linux /proc.", ); const statPath = `/proc/${pid}/stat`; - const before = parseLinuxProcessStartTime(await readFile(statPath, "utf8")); + const expectedPid = Number(pid); + const beforeStat = parseLinuxProcessStat(await readFile(statPath, "utf8")); + const before = + beforeStat?.pid === expectedPid ? beforeStat.startTime : undefined; if (before === undefined) throw new Error("GDB could not establish the live inferior identity."); const ttyPath = await realpath(selectedTty); @@ -408,7 +411,9 @@ const verifyGdbInferiorOutputSink = async ( `GDB inferior descriptor ${descriptor} no longer points to the selected TTY.`, ); } - const after = parseLinuxProcessStartTime(await readFile(statPath, "utf8")); + const afterStat = parseLinuxProcessStat(await readFile(statPath, "utf8")); + const after = + afterStat?.pid === expectedPid ? afterStat.startTime : undefined; if (after !== before) throw new Error( "The GDB inferior identity changed during descriptor readback.", diff --git a/src/process/DarwinProcessRunTokenReader.finalization.test.ts b/src/process/DarwinProcessRunTokenReader.finalization.test.ts index 0b67eb19a1..d85fb9f628 100644 --- a/src/process/DarwinProcessRunTokenReader.finalization.test.ts +++ b/src/process/DarwinProcessRunTokenReader.finalization.test.ts @@ -8,6 +8,7 @@ import { ProviderCleanupError } from "../domain/providerCleanupError.js"; const command = vi.hoisted(() => { let helperMode: "read" | "identities" = "read"; + let identityAbsent = false; let helperSessions: Array<{ readonly pid: number; readonly failed: boolean; @@ -30,8 +31,9 @@ const command = vi.hoisted(() => { }; return { - configure(mode: "read" | "identities") { + configure(mode: "read" | "identities", absent = false) { helperMode = mode; + identityAbsent = absent; events.length = 0; expectedHelpers = 1; startedHelpers = 0; @@ -100,11 +102,24 @@ const command = vi.hoisted(() => { results: [ helperMode === "read" ? { pid: session.pid, state: "readable", run_id: "run" } - : { - pid: session.pid, - state: "readable", - identity: "start:456", - }, + : identityAbsent + ? { + pid: session.pid, + state: "absent", + identity: null, + reason: "not-in-snapshot", + } + : { + pid: session.pid, + state: "readable", + identity: "start:456", + lifecycle: "non-zombie", + relation: { + state: "observed", + parent_pid: 1, + process_group_id: 123, + }, + }, ], }); return { @@ -146,6 +161,37 @@ vi.mock("./ExecFileOutput.js", async (importOriginal) => { import { createDarwinProcessRunTokenReader } from "./DarwinProcessRunTokenReader.js"; +it("decodes a successful native snapshot absence as a distinct identity fact", async () => { + command.configure("identities", true); + const reader = createDarwinProcessRunTokenReader({ + compiler: { + kind: "swiftc", + executable: join(tmpdir(), "fixture-swiftc"), + sdkRoot: tmpdir(), + }, + environment: {}, + }); + try { + const operation = reader.identities([ + { + pid: 123, + parentPid: 1, + processGroupId: 123, + state: "S", + command: "fixture", + }, + ]); + await command.waitUntilStarted(); + command.release(); + await expect(operation).resolves.toEqual( + new Map([[123, { state: "absent", reason: "not-in-snapshot" }]]), + ); + } finally { + command.release(); + await reader.close(); + } +}); + it.each(["read", "identities"] as const)( "waits for an active helper %s before removing its executable root", async (mode) => { @@ -213,7 +259,16 @@ it.each(["read", "identities"] as const)( 123, mode === "read" ? { state: "readable", runId: "run" } - : { state: "readable", identity: "start:456" }, + : { + state: "readable", + identity: "start:456", + lifecycle: { state: "observed", lifecycle: "non-zombie" }, + relation: { + state: "observed", + parentPid: 1, + processGroupId: 123, + }, + }, ], ]), ); diff --git a/src/process/DarwinProcessRunTokenReader.ts b/src/process/DarwinProcessRunTokenReader.ts index 8475813750..dfdfd2811e 100644 --- a/src/process/DarwinProcessRunTokenReader.ts +++ b/src/process/DarwinProcessRunTokenReader.ts @@ -46,10 +46,25 @@ const responseSchema = z.strictObject({ const identityResponseSchema = z.strictObject({ results: z.array( z.discriminatedUnion("state", [ + z.strictObject({ + pid: z.number().int().positive(), + state: z.literal("absent"), + identity: z.null().optional(), + reason: z.literal("not-in-snapshot"), + }), z.strictObject({ pid: z.number().int().positive(), state: z.literal("readable"), identity: z.string().min(1), + effective_uid: z.number().int().nonnegative().optional(), + lifecycle: z.enum(["non-zombie", "zombie"]).optional(), + relation: z + .strictObject({ + state: z.literal("observed"), + parent_pid: z.number().int().nonnegative(), + process_group_id: z.number().int().nonnegative(), + }) + .optional(), reason: z.null().optional(), }), z.strictObject({ @@ -701,8 +716,33 @@ export const createDarwinProcessRunTokenReader = ( observations.set( result.pid, result.state === "readable" - ? { state: "readable", identity: result.identity } - : { state: "unavailable", reason: result.reason }, + ? { + state: "readable", + identity: result.identity, + ...(result.effective_uid === undefined + ? {} + : { effectiveUid: result.effective_uid }), + ...(result.lifecycle === undefined + ? {} + : { + lifecycle: { + state: "observed" as const, + lifecycle: result.lifecycle, + }, + }), + ...(result.relation === undefined + ? {} + : { + relation: { + state: "observed" as const, + parentPid: result.relation.parent_pid, + processGroupId: result.relation.process_group_id, + }, + }), + } + : result.state === "absent" + ? { state: "absent", reason: result.reason } + : { state: "unavailable", reason: result.reason }, ); } if (observations.size !== requested.size) diff --git a/src/process/LinuxProcessStat.test.ts b/src/process/LinuxProcessStat.test.ts new file mode 100644 index 0000000000..d8544528bb --- /dev/null +++ b/src/process/LinuxProcessStat.test.ts @@ -0,0 +1,58 @@ +import { expect, it } from "vitest"; + +import { parseLinuxProcessStat } from "./LinuxProcessStat.js"; + +const statRecord = ( + options: { + readonly pid?: string; + readonly command?: string; + readonly state?: string; + readonly parentPid?: string; + readonly processGroupId?: string; + readonly sessionId?: string; + readonly startTime?: string; + } = {}, +): string => { + const fields = [ + options.state ?? "S", + options.parentPid ?? "17", + options.processGroupId ?? "18", + options.sessionId ?? "19", + ]; + while (fields.length < 19) fields.push("0"); + fields.push(options.startTime ?? "987654"); + return `${options.pid ?? "42"} (${options.command ?? "worker"}) ${fields.join(" ")}`; +}; + +it("parses command names containing parentheses and retains native relation facts", () => { + expect( + parseLinuxProcessStat(statRecord({ command: "worker ) old ) name" })), + ).toEqual({ + pid: 42, + startTime: "987654", + lifecycle: "non-zombie", + parentPid: 17, + processGroupId: 18, + sessionId: 19, + }); +}); + +it("accepts zero parent, process-group, and session IDs as native facts", () => { + expect( + parseLinuxProcessStat( + statRecord({ parentPid: "0", processGroupId: "0", sessionId: "0" }), + ), + ).toMatchObject({ parentPid: 0, processGroupId: 0, sessionId: 0 }); +}); + +it("distinguishes zombie lifecycle and rejects malformed or missing identity fields", () => { + expect(parseLinuxProcessStat(statRecord({ state: "Z" }))?.lifecycle).toBe( + "zombie", + ); + expect(parseLinuxProcessStat("(worker) S 1 2 3")).toBeUndefined(); + expect(parseLinuxProcessStat(statRecord({ pid: "0" }))).toBeUndefined(); + expect(parseLinuxProcessStat(statRecord({ state: "??" }))).toBeUndefined(); + expect(parseLinuxProcessStat(statRecord({ startTime: "not-a-number" }))).toBe( + undefined, + ); +}); diff --git a/src/process/LinuxProcessStat.ts b/src/process/LinuxProcessStat.ts new file mode 100644 index 0000000000..93e56647c1 --- /dev/null +++ b/src/process/LinuxProcessStat.ts @@ -0,0 +1,69 @@ +/** Native process facts parsed from one Linux `/proc//stat` record. */ +export interface LinuxProcessStat { + readonly pid: number; + readonly startTime: string; + readonly lifecycle: "non-zombie" | "zombie"; + readonly parentPid: number; + readonly processGroupId: number; + readonly sessionId: number; +} + +/** + * Parse the changing command field by its final `)`, then validate the native + * identity and relation fields. Zero is valid for the native group/session + * facts; callers may impose narrower admission rules for their own purpose. + */ +export const parseLinuxProcessStat = ( + value: string, +): LinuxProcessStat | undefined => { + const commandStart = value.indexOf("("); + const commandEnd = value.lastIndexOf(")"); + if (commandStart <= 0 || commandEnd <= commandStart) return undefined; + + const pidText = value.slice(0, commandStart).trim(); + if (!/^\d+$/u.test(pidText)) return undefined; + const pid = Number(pidText); + if (!Number.isSafeInteger(pid) || pid <= 0) return undefined; + + const fields = value + .slice(commandEnd + 1) + .trim() + .split(/\s+/u); + const state = fields[0]; + const parentPidText = fields[1]; + const processGroupIdText = fields[2]; + const sessionIdText = fields[3]; + const startTime = fields[19]; + if ( + state === undefined || + !/^[A-Za-z]$/u.test(state) || + parentPidText === undefined || + !/^\d+$/u.test(parentPidText) || + processGroupIdText === undefined || + !/^\d+$/u.test(processGroupIdText) || + sessionIdText === undefined || + !/^\d+$/u.test(sessionIdText) || + startTime === undefined || + !/^\d+$/u.test(startTime) + ) + return undefined; + + const parentPid = Number(parentPidText); + const processGroupId = Number(processGroupIdText); + const sessionId = Number(sessionIdText); + if ( + !Number.isSafeInteger(parentPid) || + !Number.isSafeInteger(processGroupId) || + !Number.isSafeInteger(sessionId) + ) + return undefined; + + return { + pid, + startTime, + lifecycle: state === "Z" ? "zombie" : "non-zombie", + parentPid, + processGroupId, + sessionId, + }; +}; diff --git a/src/process/ProcessOwnership.finalSweep.test.ts b/src/process/ProcessOwnership.finalSweep.test.ts new file mode 100644 index 0000000000..b9c2cd1d5a --- /dev/null +++ b/src/process/ProcessOwnership.finalSweep.test.ts @@ -0,0 +1,252 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { + cleanupOwnedProcessGroup, + type ProcessOwnershipHost, + type ProcessTableEntry, +} from "./ProcessOwnership.js"; +import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js"; + +const initialRows: readonly ProcessTableEntry[] = [ + { + pid: 100, + parentPid: 1, + processGroupId: 100, + state: "S", + command: "capture-root", + }, + { + pid: 101, + parentPid: 100, + processGroupId: 100, + state: "S", + command: "capture-child", + }, +]; + +const makeHost = (onSignal: (groupId: number) => void) => { + let liveRows = initialRows; + const signalGroups: number[] = []; + const host: ProcessOwnershipHost = { + platform: "linux", + listProcesses: async () => liveRows, + environment: async () => ({}), + runTokens: async (entries) => + new Map( + entries.map(({ pid }) => [ + pid, + { + state: "readable" as const, + runId: "sweep-run", + }, + ]), + ), + processIdentities: async (entries) => { + const liveByPid = new Map(liveRows.map((row) => [row.pid, row])); + return new Map( + entries.map((entry) => { + const current = liveByPid.get(entry.pid); + return [ + entry.pid, + current === undefined + ? { + state: "absent" as const, + reason: "not-found-in-caller-pid-namespace" as const, + } + : { + state: "readable" as const, + identity: `start-${String(entry.pid)}`, + lifecycle: { + state: "observed" as const, + lifecycle: "non-zombie" as const, + }, + relation: { + state: "observed" as const, + parentPid: current.parentPid, + processGroupId: current.processGroupId, + }, + }, + ] as const; + }), + ); + }, + signalGroup: (groupId) => { + signalGroups.push(groupId); + onSignal(groupId); + }, + }; + return { + host, + signalGroups, + replaceRows: (rows: readonly ProcessTableEntry[]) => { + liveRows = rows; + }, + rows: () => liveRows, + }; +}; + +const ownership = () => ({ + runId: "sweep-run", + leaderPid: 100, + processGroupId: 100, + sweepTokenOwnedProcesses: true as const, + evidenceOwner: new ProcessOwnershipEvidenceOwner(), +}); + +afterEach(() => vi.useRealTimers()); + +it("waits for native absence after one signal and does not signal again", async () => { + vi.useFakeTimers(); + const fixture = makeHost(() => { + setTimeout(() => fixture.replaceRows([]), 50); + }); + + const resultPromise = cleanupOwnedProcessGroup(ownership(), fixture.host); + await vi.runAllTimersAsync(); + const result = await resultPromise; + + expect(result).toEqual({ cleaned: true, signaled: true }); + expect(fixture.signalGroups).toEqual([100]); +}); + +it("preserves a moved same-generation helper as unresolved through the final deadline", async () => { + const child = initialRows[1]; + if (child === undefined) throw new Error("fixture child is missing"); + let moved = false; + const fixture = makeHost(() => { + moved = true; + fixture.replaceRows([ + { + ...child, + parentPid: 1, + processGroupId: 777, + }, + ]); + }); + const host: ProcessOwnershipHost = { + ...fixture.host, + runTokens: async (entries) => + new Map( + entries.map(({ pid }) => [ + pid, + { + state: "readable" as const, + runId: pid === 101 && moved ? "foreign-token" : "sweep-run", + }, + ]), + ), + }; + + const resultPromise = cleanupOwnedProcessGroup(ownership(), host); + const result = await resultPromise; + + expect(result).toMatchObject({ + cleaned: false, + reason: "process ownership token mismatch for PID(s): 101", + }); + expect(fixture.rows()).toMatchObject([ + { pid: 101, parentPid: 1, processGroupId: 777 }, + ]); + expect(fixture.signalGroups).toEqual([100]); +}); + +it("retries a moved token-owned helper group without signaling a released peer again", async () => { + let rows: readonly ProcessTableEntry[] = [ + { + pid: 100, + parentPid: 1, + processGroupId: 100, + state: "S", + command: "capture-root", + }, + { + pid: 200, + parentPid: 100, + processGroupId: 200, + state: "S", + command: "helper-group-leader", + }, + { + pid: 201, + parentPid: 200, + processGroupId: 200, + state: "S", + command: "moving-helper", + }, + { + pid: 202, + parentPid: 200, + processGroupId: 200, + state: "S", + command: "released-peer", + }, + ]; + const signalGroups: number[] = []; + const owner = ownership(); + const host: ProcessOwnershipHost = { + platform: "linux", + listProcesses: async () => rows, + environment: async () => ({}), + runTokens: async (entries) => + new Map( + entries.map(({ pid }) => [ + pid, + { state: "readable" as const, runId: owner.runId }, + ]), + ), + processIdentities: async (entries) => { + const liveByPid = new Map(rows.map((row) => [row.pid, row])); + return new Map( + entries.map((entry) => { + const current = liveByPid.get(entry.pid); + return [ + entry.pid, + current === undefined + ? { + state: "absent" as const, + reason: "not-found-in-caller-pid-namespace" as const, + } + : { + state: "readable" as const, + identity: `start-${String(entry.pid)}`, + lifecycle: { + state: "observed" as const, + lifecycle: "non-zombie" as const, + }, + relation: { + state: "observed" as const, + parentPid: current.parentPid, + processGroupId: current.processGroupId, + }, + }, + ] as const; + }), + ); + }, + signalGroup: (groupId) => { + signalGroups.push(groupId); + if (groupId === 100) { + rows = rows + .filter(({ pid }) => pid !== 100) + .map((row) => + row.pid === 201 + ? { ...row, parentPid: 1, processGroupId: 300 } + : row, + ); + } else { + rows = rows.filter(({ processGroupId }) => processGroupId !== groupId); + } + }, + }; + + const first = await cleanupOwnedProcessGroup(owner, host); + expect(first).toMatchObject({ cleaned: false }); + expect(signalGroups).toEqual([100, 200]); + expect(rows).toContainEqual( + expect.objectContaining({ pid: 201, parentPid: 1, processGroupId: 300 }), + ); + + const retry = await cleanupOwnedProcessGroup(owner, host); + expect(retry).toEqual({ cleaned: true, signaled: true }); + expect(signalGroups).toEqual([100, 200, 300]); + expect(owner.evidenceOwner.candidates).toEqual([]); +}); diff --git a/src/process/ProcessOwnership.fixture.ts b/src/process/ProcessOwnership.fixture.ts index 3d984d97ec..fead795dad 100644 --- a/src/process/ProcessOwnership.fixture.ts +++ b/src/process/ProcessOwnership.fixture.ts @@ -1,25 +1,31 @@ import { vi } from "vitest"; import type { ProcessOwnershipHost } from "./ProcessOwnership.js"; +import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js"; -export const ownership = { +export const createOwnership = () => ({ runId: "run-token", leaderPid: 100, processGroupId: 100, -}; + evidenceOwner: new ProcessOwnershipEvidenceOwner(), +}); + export const host = ( environments: Readonly>>>, ): { readonly adapter: ProcessOwnershipHost; readonly signalGroup: ReturnType; } => { - const signalGroup = vi.fn(); + const livePids = new Set(Object.keys(environments).map(Number)); + const signalGroup = vi.fn((processGroupId: number) => { + if (processGroupId === 100) livePids.clear(); + }); return { adapter: { listProcesses: () => Promise.resolve( - Object.keys(environments).map((pid) => ({ - pid: Number(pid), - parentPid: Number(pid) === 100 ? 1 : 100, + [...livePids].map((pid) => ({ + pid, + parentPid: pid === 100 ? 1 : 100, processGroupId: 100, state: "S", command: "fixture", diff --git a/src/process/ProcessOwnership.identity.test.ts b/src/process/ProcessOwnership.identity.test.ts index 8f6ea72239..1d518fda2c 100644 --- a/src/process/ProcessOwnership.identity.test.ts +++ b/src/process/ProcessOwnership.identity.test.ts @@ -5,13 +5,21 @@ import { type ProcessOwnershipHost, type ProcessTableEntry, } from "./ProcessOwnership.js"; -import { - createSystemProcessOwnershipHost, - parseLinuxProcessStartTime, -} from "./ProcessOwnershipObservation.js"; +import { createSystemProcessOwnershipHost } from "./ProcessOwnershipObservation.js"; +import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js"; const itOnLinux = process.platform === "linux" ? it : it.skip; +const observedIdentity = (entry: ProcessTableEntry, identity: string) => ({ + state: "readable" as const, + identity, + relation: { + state: "observed" as const, + parentPid: entry.parentPid, + processGroupId: entry.processGroupId, + }, +}); + describe("Linux process start identities", () => { itOnLinux("reads Linux process start identities from proc stat", async () => { const host = createSystemProcessOwnershipHost("linux", {}); @@ -28,57 +36,578 @@ describe("Linux process start identities", () => { expect(identities?.get(observedProcess.pid)).toMatchObject({ state: "readable", identity: expect.stringMatching(/^linux-starttime:\d+$/u), + lifecycle: { state: "observed", lifecycle: "non-zombie" }, + relation: { + state: "observed", + parentPid: process.ppid, + processGroupId: expect.any(Number), + }, }); + expect(identities?.get(observedProcess.pid)).not.toHaveProperty( + "effectiveUid", + ); const baseline = await host.captureBaseline?.(); expect(baseline).toContainEqual({ pid: observedProcess.pid, identity: expect.stringMatching(/^linux-starttime:\d+$/u), }); }); +}); - it("parses stat start time after a command name containing parentheses", () => { - const fields = Array.from({ length: 20 }, (_, index) => - index === 0 ? "S" : index === 19 ? "987654" : "0", - ); - expect( - parseLinuxProcessStartTime( - `42 (worker ) old ) name) ${fields.join(" ")}`, +it("validates and cleans an exact-token owner without a capture baseline", async () => { + const process: ProcessTableEntry = { + pid: 904, + parentPid: 1, + processGroupId: 904, + state: "S", + command: "generic-darwin-owner", + }; + let live = true; + const signalGroup = vi.fn(() => { + live = false; + }); + const host: ProcessOwnershipHost = { + listProcesses: () => Promise.resolve(live ? [process] : []), + environment: () => Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }), + runTokens: (entries) => + Promise.resolve( + new Map( + entries.map(({ pid }) => [ + pid, + { state: "readable", runId: "run-token" } as const, + ]), + ), ), - ).toBe("987654"); - expect( - parseLinuxProcessStartTime("42 (worker) S too-short"), - ).toBeUndefined(); + processIdentities: (entries) => + Promise.resolve( + new Map( + entries.map((entry) => [ + entry.pid, + live + ? observedIdentity(entry, "owner-start") + : { + state: "absent" as const, + reason: "not-in-snapshot" as const, + }, + ]), + ), + ), + signalGroup, + }; + + await expect( + cleanupOwnedProcessGroup( + { + runId: "run-token", + leaderPid: process.pid, + processGroupId: process.processGroupId, + sweepTokenOwnedProcesses: true, + evidenceOwner: new ProcessOwnershipEvidenceOwner(), + }, + host, + ), + ).resolves.toEqual({ cleaned: true, signaled: true }); + expect(signalGroup).toHaveBeenCalledWith(process.processGroupId, "SIGKILL"); +}); + +it.skipIf(typeof process.getuid !== "function")( + "uses a same-generation native UID instead of the process-table UID for unreadable tokens", + async () => { + const callerUid = process.getuid!(); + const foreignUid = callerUid === 0 ? 1 : 0; + const verify = async (rowUid: number, effectiveUid: number) => { + const row: ProcessTableEntry = { + pid: 906, + parentPid: 1, + processGroupId: 906, + uid: rowUid, + state: "S", + command: "unrelated-process", + }; + const host: ProcessOwnershipHost = { + platform: "darwin", + signalGroup: vi.fn(), + listProcesses: () => Promise.resolve([row]), + environment: () => Promise.resolve({}), + runTokens: () => + Promise.resolve( + new Map([ + [906, { state: "unavailable", reason: "EINVAL" } as const], + ]), + ), + processIdentities: (entries) => + Promise.resolve( + new Map( + entries.map((entry) => [ + entry.pid, + { + ...observedIdentity(entry, "reused-current-generation"), + effectiveUid, + }, + ]), + ), + ), + }; + return verifyNoTokenOwnedProcesses("run-token", host, undefined, { + leaderPid: 100, + processGroupId: 100, + evidenceOwner: new ProcessOwnershipEvidenceOwner(), + }); + }; + + await expect(verify(foreignUid, callerUid)).resolves.toMatchObject({ + cleaned: false, + failures: [{ pid: 906, reason: "environment-unreadable" }], + }); + await expect(verify(callerUid, foreignUid)).resolves.toMatchObject({ + cleaned: true, + unverified: [{ pid: 906, diagnostic: "EINVAL" }], + }); + }, +); + +it.each(["omitted", "throws"] as const)( + "keeps a matching token unresolved when the first native identity is %s", + async (firstRead) => { + const row: ProcessTableEntry = { + pid: 907, + parentPid: 1, + processGroupId: 907, + state: "S", + command: "identity-read-failure-candidate", + }; + let identityReads = 0; + const host: ProcessOwnershipHost = { + platform: "linux", + signalGroup: vi.fn(), + listProcesses: () => Promise.resolve([row]), + environment: () => Promise.resolve({}), + runTokens: () => + Promise.resolve( + new Map([[row.pid, { state: "readable", runId: "run-token" }]]), + ), + processIdentities: (entries) => { + identityReads += 1; + if (identityReads === 1) { + if (firstRead === "throws") + return Promise.reject(new Error("native identity read denied")); + return Promise.resolve(new Map()); + } + return Promise.resolve( + new Map( + entries.map((entry) => [ + entry.pid, + observedIdentity(entry, "current-generation"), + ]), + ), + ); + }, + }; + + await expect( + verifyNoTokenOwnedProcesses("run-token", host, undefined, { + leaderPid: 100, + processGroupId: 100, + evidenceOwner: new ProcessOwnershipEvidenceOwner(), + }), + ).resolves.toMatchObject({ + cleaned: false, + reason: "process identity could not be revalidated", + failures: [{ pid: row.pid, reason: "process-identity-unavailable" }], + }); + expect(identityReads).toBe(2); + }, +); + +it("retains a scoped unknown generation when a later table omits its PID", async () => { + const row: ProcessTableEntry = { + pid: 101, + parentPid: 100, + processGroupId: 100, + state: "S", + command: "identity-unavailable-related-process", + }; + const tables = [[row], []] as const; + let tableRead = 0; + let identityReads = 0; + let nativeAbsence = false; + const evidenceOwner = new ProcessOwnershipEvidenceOwner(); + const host: ProcessOwnershipHost = { + platform: "linux", + signalGroup: vi.fn(), + listProcesses: () => Promise.resolve(tables[tableRead++] ?? []), + environment: () => Promise.resolve({}), + runTokens: (entries) => + Promise.resolve( + new Map( + entries.map(({ pid }) => [ + pid, + { state: "unavailable" as const, reason: "EPERM: denied" }, + ]), + ), + ), + processIdentities: (entries) => { + identityReads += 1; + return Promise.resolve( + new Map( + entries.map(({ pid }) => [ + pid, + nativeAbsence + ? { + state: "absent" as const, + reason: "not-found-in-caller-pid-namespace" as const, + } + : { + state: "unavailable" as const, + reason: "native identity read denied", + }, + ]), + ), + ); + }, + }; + const relation = { + leaderPid: 100, + processGroupId: 100, + evidenceOwner, + }; + + const first = await verifyNoTokenOwnedProcesses( + "run-token", + host, + undefined, + relation, + ); + expect(first).toMatchObject({ + cleaned: false, + failures: [{ pid: row.pid, reason: "process-identity-unavailable" }], + }); + expect(evidenceOwner.candidates).toMatchObject([ + { + pid: row.pid, + identity: undefined, + applicability: "identity-unresolved", + scopeEvidence: "native-identity-unresolved", + }, + ]); + + const readsAfterFirstSnapshot = identityReads; + const second = await verifyNoTokenOwnedProcesses( + "run-token", + host, + undefined, + relation, + ); + + expect(second).toMatchObject({ + cleaned: false, + failures: [{ pid: row.pid, reason: "process-identity-unavailable" }], + }); + expect(identityReads).toBeGreaterThan(readsAfterFirstSnapshot); + expect(evidenceOwner.candidates).toMatchObject([ + { + pid: row.pid, + identity: undefined, + applicability: "identity-unresolved", + }, + ]); + expect(evidenceOwner.coverage).toMatchObject({ + listedRows: 0, + retainedOnlyPids: 1, + retainedUnresolved: 1, + }); + + nativeAbsence = true; + await expect( + verifyNoTokenOwnedProcesses("run-token", host, undefined, relation), + ).resolves.toMatchObject({ cleaned: true }); + expect(evidenceOwner.candidates).toEqual([]); +}); + +it("requires a scoped current relation before exempting a withheld token", async () => { + const row: ProcessTableEntry = { + pid: 908, + parentPid: 1, + processGroupId: 908, + state: "S", + command: "opaque-unrelated-process", + }; + const verify = async (scoped: boolean, nativeRelation: boolean) => { + const host: ProcessOwnershipHost = { + platform: "darwin", + signalGroup: vi.fn(), + listProcesses: () => Promise.resolve([row]), + environment: () => Promise.resolve({}), + runTokens: () => + Promise.resolve( + new Map([ + [ + row.pid, + { + state: "unavailable", + reason: "platform_binary_environment_withheld", + } as const, + ], + ]), + ), + processIdentities: (entries) => + Promise.resolve( + new Map( + entries.map((entry) => [ + entry.pid, + nativeRelation + ? observedIdentity(entry, "opaque-generation") + : { + state: "readable" as const, + identity: "opaque-generation", + }, + ]), + ), + ), + }; + return verifyNoTokenOwnedProcesses( + "run-token", + host, + undefined, + scoped + ? { + leaderPid: 100, + processGroupId: 100, + evidenceOwner: new ProcessOwnershipEvidenceOwner(), + } + : undefined, + ); + }; + + await expect(verify(true, false)).resolves.toMatchObject({ + cleaned: false, + failures: [{ pid: row.pid, reason: "environment-unreadable" }], + }); + await expect(verify(false, false)).resolves.toMatchObject({ + cleaned: false, + failures: [{ pid: row.pid, reason: "environment-unreadable" }], + }); + await expect(verify(false, true)).resolves.toMatchObject({ + cleaned: false, + failures: [{ pid: row.pid, reason: "environment-unreadable" }], + }); + await expect(verify(true, true)).resolves.toMatchObject({ + cleaned: true, + unverified: [ + { pid: row.pid, diagnostic: "platform_binary_environment_withheld" }, + ], }); }); -describe("capture-local process ownership", () => { - it("validates and cleans an exact-token owner without a capture baseline", async () => { - const process: ProcessTableEntry = { - pid: 904, - parentPid: 1, - processGroupId: 904, +it("retains related scope when a same-generation process reparents during its first token read", async () => { + const row: ProcessTableEntry = { + pid: 909, + parentPid: 100, + processGroupId: 100, + state: "S", + command: "reparented-withheld-process", + }; + let identityReads = 0; + const evidenceOwner = new ProcessOwnershipEvidenceOwner(); + const host: ProcessOwnershipHost = { + platform: "darwin", + signalGroup: vi.fn(), + listProcesses: () => Promise.resolve([row]), + environment: () => Promise.resolve({}), + runTokens: (entries) => + Promise.resolve( + new Map( + entries.map(({ pid }) => [ + pid, + { + state: "unavailable" as const, + reason: "platform_binary_environment_withheld", + }, + ]), + ), + ), + processIdentities: (entries) => { + identityReads += 1; + const reparented = identityReads > 1; + return Promise.resolve( + new Map( + entries.map(({ pid }) => [ + pid, + { + state: "readable" as const, + identity: "generation-909", + relation: { + state: "observed" as const, + parentPid: reparented ? 1 : 100, + processGroupId: reparented ? 777 : 100, + }, + }, + ]), + ), + ); + }, + }; + + await expect( + verifyNoTokenOwnedProcesses("run-token", host, undefined, { + leaderPid: 100, + processGroupId: 100, + evidenceOwner, + }), + ).resolves.toMatchObject({ + cleaned: false, + failures: [ + { + pid: row.pid, + reason: "environment-unreadable", + diagnostic: "platform_binary_environment_withheld", + }, + ], + }); + expect(evidenceOwner.candidates).toMatchObject([ + { + pid: row.pid, + identity: "generation-909", + applicability: "related-ownership-unresolved", + scopeEvidence: "previously-related-generation", + relation: { parentPid: 1, processGroupId: 777 }, + }, + ]); +}); + +it.each([undefined, "foreign-token"] as const)( + "does not treat a readable %s token as exonerating a related generation that moved during its first read", + async (observedRunId) => { + const row: ProcessTableEntry = { + pid: 909, + parentPid: 100, + processGroupId: 100, state: "S", - command: "generic-darwin-owner", + command: "reparented-cleared-token-process", }; + let identityReads = 0; + const evidenceOwner = new ProcessOwnershipEvidenceOwner(); const signalGroup = vi.fn(); const host: ProcessOwnershipHost = { - listProcesses: () => Promise.resolve([process]), - environment: () => Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }), + platform: "darwin", + signalGroup, + listProcesses: () => Promise.resolve([row]), + environment: () => Promise.resolve({}), runTokens: (entries) => Promise.resolve( new Map( entries.map(({ pid }) => [ pid, - { state: "readable", runId: "run-token" } as const, + { state: "readable" as const, runId: observedRunId }, ]), ), ), - processIdentities: (entries) => + processIdentities: (entries) => { + identityReads += 1; + const reparented = identityReads > 1; + return Promise.resolve( + new Map( + entries.map(({ pid }) => [ + pid, + { + state: "readable" as const, + identity: "generation-909", + relation: { + state: "observed" as const, + parentPid: reparented ? 1 : 100, + processGroupId: reparented ? 777 : 100, + }, + }, + ]), + ), + ); + }, + }; + + await expect( + verifyNoTokenOwnedProcesses("run-token", host, undefined, { + leaderPid: 100, + processGroupId: 100, + evidenceOwner, + }), + ).resolves.toMatchObject({ + cleaned: false, + failures: [{ pid: row.pid, reason: "run-token-mismatch" }], + }); + expect(identityReads).toBe(2); + expect(signalGroup).not.toHaveBeenCalled(); + expect(evidenceOwner.candidates).toMatchObject([ + { + pid: row.pid, + identity: "generation-909", + applicability: "related-ownership-unresolved", + scopeEvidence: "captured-process-group-id", + relation: { parentPid: 1, processGroupId: 777 }, + }, + ]); + }, +); + +it.skipIf(typeof process.getuid !== "function")( + "cleans verified owners and records unrelated opaque candidates as unverified", + async () => { + const owned: ProcessTableEntry = { + pid: 905, + parentPid: 1, + processGroupId: 905, + state: "S", + command: "owned-detached-child", + }; + const opaque: ProcessTableEntry = { + pid: 906, + parentPid: 1, + processGroupId: 906, + state: "S", + command: "/usr/bin/unrelated-process", + }; + const malformed: ProcessTableEntry = { + ...opaque, + pid: 907, + processGroupId: 907, + uid: process.getuid?.() === 0 ? 1 : 0, + command: "/usr/bin/malformed-procargs-process", + }; + const sysctlDenied: ProcessTableEntry = { + ...opaque, + pid: 908, + processGroupId: 908, + uid: process.getuid?.() === 0 ? 1 : 0, + command: "/usr/bin/sysctl-denied-process", + }; + const processes = [owned, opaque, malformed, sysctlDenied]; + const signalGroup = vi.fn((processGroupId: number) => { + for (let index = processes.length - 1; index >= 0; index -= 1) + if (processes[index]?.processGroupId === processGroupId) + processes.splice(index, 1); + }); + const host: ProcessOwnershipHost = { + platform: "darwin", + listProcesses: () => Promise.resolve(processes), + environment: (pid) => + pid === owned.pid + ? Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }) + : Promise.reject(new Error("permission denied")), + runTokens: (entries) => Promise.resolve( new Map( entries.map(({ pid }) => [ pid, - { state: "readable", identity: "owner-start" } as const, + pid === owned.pid + ? { state: "readable", runId: "run-token" as const } + : { + state: "unavailable", + reason: + pid === malformed.pid + ? ("malformed_procargs" as const) + : pid === sysctlDenied.pid + ? ("sysctl_failed_1" as const) + : ("platform_binary_environment_withheld" as const), + }, ]), ), ), @@ -89,113 +618,37 @@ describe("capture-local process ownership", () => { cleanupOwnedProcessGroup( { runId: "run-token", - leaderPid: process.pid, - processGroupId: process.processGroupId, + leaderPid: 100, + processGroupId: 100, sweepTokenOwnedProcesses: true, + evidenceOwner: new ProcessOwnershipEvidenceOwner(), }, host, ), - ).resolves.toEqual({ cleaned: true, signaled: true }); - expect(signalGroup).toHaveBeenCalledWith(process.processGroupId, "SIGKILL"); - }); - - it.skipIf(typeof process.getuid !== "function")( - "cleans verified owners and records unrelated opaque candidates as unverified", - async () => { - const owned: ProcessTableEntry = { - pid: 905, - parentPid: 1, - processGroupId: 905, - state: "S", - command: "owned-detached-child", - }; - const opaque: ProcessTableEntry = { - pid: 906, - parentPid: 1, - processGroupId: 906, - state: "S", - command: "/usr/bin/unrelated-process", - }; - const malformed: ProcessTableEntry = { - ...opaque, - pid: 907, - processGroupId: 907, - uid: process.getuid?.() === 0 ? 1 : 0, - command: "/usr/bin/malformed-procargs-process", - }; - const sysctlDenied: ProcessTableEntry = { - ...opaque, - pid: 908, - processGroupId: 908, - uid: process.getuid?.() === 0 ? 1 : 0, - command: "/usr/bin/sysctl-denied-process", - }; - const signalGroup = vi.fn(); - const host: ProcessOwnershipHost = { - platform: "darwin", - listProcesses: () => - Promise.resolve([owned, opaque, malformed, sysctlDenied]), - environment: (pid) => - pid === owned.pid - ? Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }) - : Promise.reject(new Error("permission denied")), - runTokens: (entries) => - Promise.resolve( - new Map( - entries.map(({ pid }) => [ - pid, - pid === owned.pid - ? { state: "readable", runId: "run-token" as const } - : { - state: "unavailable", - reason: - pid === malformed.pid - ? ("malformed_procargs" as const) - : pid === sysctlDenied.pid - ? ("sysctl_failed_1" as const) - : ("platform_binary_environment_withheld" as const), - }, - ]), - ), - ), - signalGroup, - }; - - await expect( - cleanupOwnedProcessGroup( - { - runId: "run-token", - leaderPid: 100, - processGroupId: 100, - sweepTokenOwnedProcesses: true, - }, - host, - ), - ).resolves.toMatchObject({ - cleaned: true, - signaled: true, - unverified: [ - { pid: 906, diagnostic: "platform_binary_environment_withheld" }, - { pid: 907, diagnostic: "malformed_procargs" }, - { pid: 908, diagnostic: "sysctl_failed_1" }, - ], - }); - expect(signalGroup).toHaveBeenCalledWith(owned.processGroupId, "SIGKILL"); - expect(signalGroup).not.toHaveBeenCalledWith( - opaque.processGroupId, - "SIGKILL", - ); - expect(signalGroup).not.toHaveBeenCalledWith( - malformed.processGroupId, - "SIGKILL", - ); - expect(signalGroup).not.toHaveBeenCalledWith( - sysctlDenied.processGroupId, - "SIGKILL", - ); - }, - ); -}); + ).resolves.toMatchObject({ + cleaned: true, + signaled: true, + unverified: [ + { pid: 906, diagnostic: "platform_binary_environment_withheld" }, + { pid: 907, diagnostic: "malformed_procargs" }, + { pid: 908, diagnostic: "sysctl_failed_1" }, + ], + }); + expect(signalGroup).toHaveBeenCalledWith(owned.processGroupId, "SIGKILL"); + expect(signalGroup).not.toHaveBeenCalledWith( + opaque.processGroupId, + "SIGKILL", + ); + expect(signalGroup).not.toHaveBeenCalledWith( + malformed.processGroupId, + "SIGKILL", + ); + expect(signalGroup).not.toHaveBeenCalledWith( + sysctlDenied.processGroupId, + "SIGKILL", + ); + }, +); describe("capture-local process identity baselines", () => { it("checks reused and unknown PIDs while skipping unchanged identities", async () => { @@ -229,14 +682,14 @@ describe("capture-local process identity baselines", () => { processIdentities: (entries) => Promise.resolve( new Map( - entries.map(({ pid }) => [ - pid, - pid === 902 + entries.map((entry) => [ + entry.pid, + entry.pid === 902 ? { state: "unavailable", reason: "fixture" } - : { - state: "readable", - identity: pid === 899 ? "stable-start" : "reused-start", - }, + : observedIdentity( + entry, + entry.pid === 899 ? "stable-start" : "reused-start", + ), ]), ), ), @@ -270,11 +723,12 @@ describe("capture-local process identity baselines", () => { { pid: 902, reason: "process-identity-unavailable", - diagnostic: "process identity was unavailable during token scan", + diagnostic: + "process identity was unavailable during token validation", }, ], }); - expect(tokenReads).toEqual([[900]]); + expect(tokenReads).toEqual([[900, 902]]); }); it("revalidates fallback environment owners before signaling after PID reuse", async () => { @@ -295,9 +749,9 @@ describe("capture-local process identity baselines", () => { const identity = identityReads < 3 ? "owned-start" : "reused-start"; return Promise.resolve( new Map( - entries.map(({ pid }) => [ - pid, - { state: "readable", identity } as const, + entries.map((entry) => [ + entry.pid, + observedIdentity(entry, identity), ]), ), ); @@ -312,6 +766,7 @@ describe("capture-local process identity baselines", () => { leaderPid: 100, processGroupId: 100, sweepTokenOwnedProcesses: true, + evidenceOwner: new ProcessOwnershipEvidenceOwner(), captureBaseline: [{ pid: process.pid, identity: "old-start" }], }, host, @@ -325,172 +780,450 @@ describe("capture-local process identity baselines", () => { }); }); -describe("process identity during token validation", () => { - it("revalidates identity after the final environment token read", async () => { - let environmentReads = 0; - let reusedDuringFinalRead = false; - const process: ProcessTableEntry = { - pid: 912, - parentPid: 1, - processGroupId: 912, - state: "S", - command: "reused-during-final-token-read", - }; - const signalGroup = vi.fn(); - const host: ProcessOwnershipHost = { - listProcesses: () => Promise.resolve([process]), - environment: () => { - environmentReads += 1; - if (environmentReads === 3) reusedDuringFinalRead = true; - return Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }); - }, - processIdentities: (entries) => { - const identity = reusedDuringFinalRead ? "reused-start" : "owned-start"; - return Promise.resolve( - new Map( - entries.map(({ pid }) => [ - pid, - { state: "readable", identity } as const, - ]), - ), - ); - }, - signalGroup, - }; - - await expect( - cleanupOwnedProcessGroup( - { - runId: "run-token", - leaderPid: 100, - processGroupId: 100, - sweepTokenOwnedProcesses: true, - captureBaseline: [{ pid: process.pid, identity: "old-start" }], - }, - host, - ), - ).resolves.toMatchObject({ - cleaned: false, - reason: "process identity could not be revalidated", - }); - expect(signalGroup).not.toHaveBeenCalled(); - expect(reusedDuringFinalRead).toBe(true); - }); - - it("fails closed when candidate PIDs change identity during token reading", async () => { - let identityReads = 0; - const process: ProcessTableEntry = { - pid: 911, - parentPid: 1, - processGroupId: 911, - state: "S", - command: "reused-during-token-read", - }; - const processes = [process, { ...process, pid: 912, processGroupId: 912 }]; - const listProcesses = vi.fn(async () => processes); - const host: ProcessOwnershipHost = { - listProcesses, - environment: () => Promise.resolve({}), - processIdentities: (entries) => { - identityReads += 1; - const identity = identityReads === 1 ? "new-start" : "reused-start"; - return Promise.resolve( - new Map( - entries.map(({ pid }) => [ - pid, - { state: "readable", identity } as const, - ]), - ), - ); - }, - runTokens: (entries) => - Promise.resolve( - new Map( - entries.map(({ pid }) => [ - pid, - { state: "readable", runId: undefined } as const, - ]), - ), +it("retains initially related scope when bounded token retries later move out of scope", async () => { + const process: ProcessTableEntry = { + pid: 921, + parentPid: 919, + processGroupId: 919, + state: "S", + command: "retry-reparented-candidate", + }; + const tokenReads: number[][] = []; + const evidenceOwner = new ProcessOwnershipEvidenceOwner(); + let tokenAttempt = 0; + let identityRead = 0; + const host: ProcessOwnershipHost = { + listProcesses: () => Promise.resolve([process]), + environment: () => Promise.resolve({}), + processIdentities: (entries) => { + identityRead += 1; + const moved = identityRead > 2; + return Promise.resolve( + new Map( + entries.map((entry) => { + return [ + entry.pid, + { + state: "readable" as const, + identity: "generation-921", + relation: { + state: "observed" as const, + parentPid: moved ? 1 : 919, + processGroupId: moved ? 930 : 919, + }, + }, + ]; + }), ), - signalGroup: vi.fn(), - }; + ); + }, + runTokens: (entries) => { + tokenAttempt += 1; + tokenReads.push(entries.map(({ pid }) => pid)); + return Promise.resolve( + new Map( + entries.map(({ pid }) => [ + pid, + { + state: "unavailable" as const, + reason: + tokenAttempt === 1 + ? "permission denied" + : "platform_binary_environment_withheld", + }, + ]), + ), + ); + }, + signalGroup: vi.fn(), + }; - await expect( - verifyNoTokenOwnedProcesses( - "run-token", - host, - processes.map(({ pid }) => ({ pid, identity: "old-start" })), - ), - ).resolves.toEqual({ - cleaned: false, - reason: "process identity could not be revalidated", - failures: processes.map(({ pid }) => ({ - pid, - reason: "process-identity-unavailable", - diagnostic: - "process identity changed or became unavailable during token validation", - })), - }); - // Initial observation, final token classification, then one fresh recheck - // shared by both changed identities. - expect(listProcesses).toHaveBeenCalledTimes(3); + const result = await verifyNoTokenOwnedProcesses( + "run-token", + host, + undefined, + { + leaderPid: 919, + processGroupId: 919, + sampledProcessGroupIds: [919], + evidenceOwner, + }, + ); + + expect(result).toMatchObject({ + cleaned: false, + failures: [{ pid: 921, reason: "environment-unreadable" }], + }); + expect(tokenReads).toEqual([[921], [921], [921], [921]]); + expect(evidenceOwner.candidates).toMatchObject([ + { + pid: 921, + applicability: "related-ownership-unresolved", + scopeEvidence: "previously-related-generation", + relation: { state: "observed", parentPid: 1, processGroupId: 930 }, + }, + ]); +}); + +it("rechecks retained related generations when a later table omits them", async () => { + const process: ProcessTableEntry = { + pid: 920, + parentPid: 919, + processGroupId: 919, + state: "S", + command: "retained-related-candidate", + }; + const tables = [[process], []] as const; + let tableRead = 0; + const tokenReads: number[][] = []; + const evidenceOwner = new ProcessOwnershipEvidenceOwner(); + const host: ProcessOwnershipHost = { + listProcesses: () => Promise.resolve(tables[tableRead++] ?? []), + environment: () => Promise.resolve({}), + processIdentities: (entries) => { + const processGroupId = tableRead < 2 ? 919 : 930; + return Promise.resolve( + new Map( + entries.map((entry) => [ + entry.pid, + { + state: "readable" as const, + identity: "generation-920", + relation: { + state: "observed" as const, + parentPid: tableRead < 2 ? 919 : 1, + processGroupId, + }, + }, + ]), + ), + ); + }, + runTokens: (entries) => { + tokenReads.push(entries.map(({ pid }) => pid)); + return Promise.resolve( + new Map( + entries.map(({ pid }) => [ + pid, + { state: "unavailable" as const, reason: "permission denied" }, + ]), + ), + ); + }, + signalGroup: vi.fn(), + }; + const relation = { + leaderPid: 919, + processGroupId: 919, + sampledProcessGroupIds: [919], + evidenceOwner, + }; + + const first = await verifyNoTokenOwnedProcesses( + "run-token", + host, + undefined, + relation, + ); + expect(tokenReads).toEqual([[920], [920], [920], [920]]); + expect(evidenceOwner.candidates).toMatchObject([ + { pid: 920, applicability: "related-ownership-unresolved" }, + ]); + const second = await verifyNoTokenOwnedProcesses( + "run-token", + host, + undefined, + relation, + ); + + expect(first).toMatchObject({ cleaned: false, failures: [{ pid: 920 }] }); + expect(second).toMatchObject({ cleaned: false, failures: [{ pid: 920 }] }); + expect(tokenReads).toEqual([ + [920], + [920], + [920], + [920], + [920], + [920], + [920], + [920], + ]); + expect(evidenceOwner.candidates).toMatchObject([ + { + pid: 920, + identity: "generation-920", + applicability: "related-ownership-unresolved", + relation: { parentPid: 1, processGroupId: 930 }, + }, + ]); + expect(evidenceOwner.coverage).toMatchObject({ + snapshotCount: 2, + listedRows: 0, + retainedOnlyPids: 1, + processTableCoverage: "unreported-by-host", }); }); -describe("process identity before signaling", () => { - it("refuses to signal when PID identity changes after token validation", async () => { - const signalGroup = vi.fn(); - let identityReads = 0; - const process: ProcessTableEntry = { - pid: 903, - parentPid: 1, - processGroupId: 903, - state: "S", - command: "reused-before-signal", - }; - const host: ProcessOwnershipHost = { - listProcesses: () => Promise.resolve([process]), - environment: () => Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }), - processIdentities: (entries) => { - identityReads += 1; - const identity = identityReads < 3 ? "owned-start" : "reused-start"; - return Promise.resolve( - new Map( - entries.map(({ pid }) => [ - pid, - { state: "readable", identity } as const, - ]), - ), - ); - }, - runTokens: (entries) => - Promise.resolve( - new Map( - entries.map(({ pid }) => [ - pid, - { state: "readable", runId: "run-token" } as const, - ]), - ), +it("revalidates identity after the final environment token read", async () => { + let environmentReads = 0; + let reusedDuringFinalRead = false; + const process: ProcessTableEntry = { + pid: 912, + parentPid: 1, + processGroupId: 912, + state: "S", + command: "reused-during-final-token-read", + }; + const signalGroup = vi.fn(); + const host: ProcessOwnershipHost = { + listProcesses: () => Promise.resolve([process]), + environment: () => { + environmentReads += 1; + if (environmentReads === 3) reusedDuringFinalRead = true; + return Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }); + }, + processIdentities: (entries) => { + const identity = reusedDuringFinalRead ? "reused-start" : "owned-start"; + return Promise.resolve( + new Map( + entries.map((entry) => [ + entry.pid, + observedIdentity(entry, identity), + ]), ), - signalGroup, - }; + ); + }, + signalGroup, + }; - await expect( - cleanupOwnedProcessGroup( - { - runId: "run-token", - leaderPid: 100, - processGroupId: 100, - sweepTokenOwnedProcesses: true, - captureBaseline: [{ pid: process.pid, identity: "old-start" }], - }, - host, - ), - ).resolves.toMatchObject({ - cleaned: false, - reason: "process identity could not be revalidated", - }); - expect(signalGroup).not.toHaveBeenCalled(); - expect(identityReads).toBeGreaterThanOrEqual(3); + await expect( + cleanupOwnedProcessGroup( + { + runId: "run-token", + leaderPid: 100, + processGroupId: 100, + sweepTokenOwnedProcesses: true, + evidenceOwner: new ProcessOwnershipEvidenceOwner(), + captureBaseline: [{ pid: process.pid, identity: "old-start" }], + }, + host, + ), + ).resolves.toMatchObject({ + cleaned: false, + reason: "process identity could not be revalidated", }); + expect(signalGroup).not.toHaveBeenCalled(); + expect(reusedDuringFinalRead).toBe(true); +}); + +it("fails closed when candidate PIDs change identity during token reading", async () => { + let identityReads = 0; + const process: ProcessTableEntry = { + pid: 911, + parentPid: 1, + processGroupId: 911, + state: "S", + command: "reused-during-token-read", + }; + const processes = [process, { ...process, pid: 912, processGroupId: 912 }]; + const listProcesses = vi.fn(async () => processes); + const host: ProcessOwnershipHost = { + listProcesses, + environment: () => Promise.resolve({}), + processIdentities: (entries) => { + identityReads += 1; + const identity = identityReads === 1 ? "new-start" : "reused-start"; + return Promise.resolve( + new Map( + entries.map((entry) => [ + entry.pid, + observedIdentity(entry, identity), + ]), + ), + ); + }, + runTokens: (entries) => + Promise.resolve( + new Map( + entries.map(({ pid }) => [ + pid, + { state: "readable", runId: undefined } as const, + ]), + ), + ), + signalGroup: vi.fn(), + }; + + await expect( + verifyNoTokenOwnedProcesses( + "run-token", + host, + processes.map(({ pid }) => ({ pid, identity: "old-start" })), + ), + ).resolves.toEqual({ + cleaned: false, + reason: "process identity could not be revalidated", + failures: processes.map(({ pid }) => ({ + pid, + reason: "process-identity-unavailable", + diagnostic: "process identity changed during token validation", + })), + }); + // Retry the exact candidate generations with native identity reads; do not + // rescan the full process table, which would add unrelated transient rows. + expect(listProcesses).toHaveBeenCalledTimes(1); +}); + +it("uses the launcher's native relation instead of stale process-table fields", async () => { + const row = { + pid: 100, + parentPid: 999, + processGroupId: 999, + state: "S", + command: "/owned/hopper", + } satisfies ProcessTableEntry; + const signalGroup = vi.fn(); + const host: ProcessOwnershipHost = { + listProcesses: () => Promise.resolve([row]), + environment: () => Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }), + runTokens: (members) => + Promise.resolve( + new Map( + members.map(({ pid }) => [ + pid, + { state: "readable" as const, runId: "run-token" }, + ]), + ), + ), + processIdentities: (members) => + Promise.resolve( + new Map( + members.map((member) => [ + member.pid, + observedIdentity( + { ...member, parentPid: 1, processGroupId: 100 }, + "start-100", + ), + ]), + ), + ), + signalGroup, + }; + const result = await cleanupOwnedProcessGroup( + { + runId: "run-token", + leaderPid: 100, + processGroupId: 100, + expectedParentPid: 1, + expectedCommand: "/owned/hopper", + }, + host, + ); + expect(result).toMatchObject({ cleaned: true, signaled: true }); + expect(signalGroup).toHaveBeenCalledWith(100, "SIGKILL"); +}); + +it("does not settle a live launcher from stale table ancestry when native relation mismatches", async () => { + const row = { + pid: 100, + parentPid: 1, + processGroupId: 100, + state: "S", + command: "/owned/hopper", + } satisfies ProcessTableEntry; + const signalGroup = vi.fn(); + const host: ProcessOwnershipHost = { + listProcesses: () => Promise.resolve([row]), + environment: () => Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }), + processIdentities: () => + Promise.resolve( + new Map([ + [ + 100, + { + ...observedIdentity(row, "start-100"), + relation: { + state: "observed" as const, + parentPid: 2, + processGroupId: 200, + }, + }, + ], + ]), + ), + signalGroup, + }; + await expect( + cleanupOwnedProcessGroup( + { + runId: "run-token", + leaderPid: 100, + processGroupId: 100, + expectedParentPid: 1, + expectedCommand: "/owned/hopper", + }, + host, + ), + ).resolves.toMatchObject({ + cleaned: false, + reason: "owned launcher process-group identity did not match", + }); + expect(signalGroup).not.toHaveBeenCalled(); +}); + +it("refuses to signal when PID identity changes after token validation", async () => { + const signalGroup = vi.fn(); + let identityReads = 0; + const process: ProcessTableEntry = { + pid: 903, + parentPid: 1, + processGroupId: 903, + state: "S", + command: "reused-before-signal", + }; + const host: ProcessOwnershipHost = { + listProcesses: () => Promise.resolve([process]), + environment: () => Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }), + processIdentities: (entries) => { + identityReads += 1; + const identity = identityReads < 3 ? "owned-start" : "reused-start"; + return Promise.resolve( + new Map( + entries.map((entry) => [ + entry.pid, + observedIdentity(entry, identity), + ]), + ), + ); + }, + runTokens: (entries) => + Promise.resolve( + new Map( + entries.map(({ pid }) => [ + pid, + { state: "readable", runId: "run-token" } as const, + ]), + ), + ), + signalGroup, + }; + + await expect( + cleanupOwnedProcessGroup( + { + runId: "run-token", + leaderPid: 100, + processGroupId: 100, + sweepTokenOwnedProcesses: true, + evidenceOwner: new ProcessOwnershipEvidenceOwner(), + captureBaseline: [{ pid: process.pid, identity: "old-start" }], + }, + host, + ), + ).resolves.toMatchObject({ + cleaned: false, + reason: "process identity could not be revalidated", + }); + expect(signalGroup).not.toHaveBeenCalled(); + expect(identityReads).toBeGreaterThanOrEqual(3); }); diff --git a/src/process/ProcessOwnership.nativeExit.test.ts b/src/process/ProcessOwnership.nativeExit.test.ts new file mode 100644 index 0000000000..4768ad5110 --- /dev/null +++ b/src/process/ProcessOwnership.nativeExit.test.ts @@ -0,0 +1,79 @@ +import { expect, it, vi } from "vitest"; +import { + cleanupOwnedProcessGroup, + type ProcessIdentityObservation, + type ProcessOwnershipHost, + type ProcessTableEntry, +} from "./ProcessOwnership.js"; + +it.each(["zombie", "absent", "live", "unavailable"] as const)( + "rechecks native lifetime when a token disappears during cleanup: %s", + async (lifetimeAfterToken) => { + const row: ProcessTableEntry = { + pid: 100, + parentPid: 1, + processGroupId: 100, + state: "S", + command: "fixture", + }; + let tokenRead = false; + const identity = (): ProcessIdentityObservation => { + if (tokenRead && lifetimeAfterToken === "absent") + return { state: "absent", reason: "not-found-in-caller-pid-namespace" }; + if (tokenRead && lifetimeAfterToken === "unavailable") + return { state: "unavailable", reason: "identity read denied" }; + return { + state: "readable", + identity: "same-generation", + lifecycle: { + state: "observed", + lifecycle: + tokenRead && lifetimeAfterToken === "zombie" + ? "zombie" + : "non-zombie", + }, + relation: { state: "observed", parentPid: 1, processGroupId: 100 }, + }; + }; + const signalGroup = vi.fn(); + const host: ProcessOwnershipHost = { + platform: "linux", + listProcesses: async () => [row], + environment: async () => ({}), + runTokens: async (members) => { + tokenRead = true; + return new Map( + members.map(({ pid }) => [ + pid, + { state: "readable" as const, runId: undefined }, + ]), + ); + }, + processIdentities: async (members) => + new Map(members.map(({ pid }) => [pid, identity()])), + signalGroup, + }; + const result = await cleanupOwnedProcessGroup( + { runId: "run", leaderPid: 100, processGroupId: 100 }, + host, + ); + expect(tokenRead).toBe(true); + if (lifetimeAfterToken === "zombie" || lifetimeAfterToken === "absent") { + expect(result).toEqual({ cleaned: true, signaled: false }); + } else { + expect(result).toMatchObject({ + cleaned: false, + failures: [ + { + pid: 100, + reason: + lifetimeAfterToken === "live" + ? "run-token-mismatch" + : "process-identity-unavailable", + }, + ], + }); + } + expect(signalGroup).not.toHaveBeenCalled(); + }, +); diff --git a/src/process/ProcessOwnership.observation.test.ts b/src/process/ProcessOwnership.observation.test.ts index c105e7cd7c..f8b1330a87 100644 --- a/src/process/ProcessOwnership.observation.test.ts +++ b/src/process/ProcessOwnership.observation.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { cleanupOwnedProcessGroup, type ProcessOwnershipHost, @@ -7,7 +7,13 @@ import { observeOwnedProcessGroups, observeOwnedProcessLineage, } from "./ProcessOwnershipObservation.js"; -import { host, ownership } from "./ProcessOwnership.fixture.js"; +import { createOwnership, host } from "./ProcessOwnership.fixture.js"; +import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js"; + +let ownership = createOwnership(); +beforeEach(() => { + ownership = createOwnership(); +}); const processes = [100, 101].map((pid) => ({ pid, @@ -17,123 +23,211 @@ const processes = [100, 101].map((pid) => ({ command: "fixture", })); -describe("multi-group ownership observation", () => { - it("observes multiple groups from one process snapshot and token batch", async () => { - const rows = [ - ...processes, - { - pid: 200, - parentPid: 1, - processGroupId: 200, - state: "S", - command: "other-owned-group", - }, - { - pid: 300, - parentPid: 1, - processGroupId: 300, - state: "Z", - command: "[exited]", - }, - ]; - const listProcesses = vi.fn(() => Promise.resolve(rows)); - const environment = vi.fn(() => Promise.resolve({})); - const runTokens = vi.fn>( - (members) => - Promise.resolve( - new Map( - members.map(({ pid }) => [ - pid, - { - state: "readable" as const, - runId: pid === 200 ? "other-run" : ownership.runId, +it("uses native same-generation group relations instead of stale table groups", async () => { + const rows = [ + { ...processes[0]!, processGroupId: 200 }, + { ...processes[1]!, pid: 200, parentPid: 1, processGroupId: 100 }, + ]; + const host: ProcessOwnershipHost = { + listProcesses: () => Promise.resolve(rows), + environment: () => Promise.resolve({}), + runTokens: (members) => + Promise.resolve( + new Map( + members.map(({ pid }) => [ + pid, + { state: "readable" as const, runId: ownership.runId }, + ]), + ), + ), + processIdentities: (members) => + Promise.resolve( + new Map( + members.map((member) => [ + member.pid, + { + state: "readable" as const, + identity: `start-${String(member.pid)}`, + relation: { + state: "observed" as const, + parentPid: member.pid === 100 ? 1 : 100, + processGroupId: member.pid === 100 ? 100 : 200, }, - ]), - ), + }, + ]), ), - ); + ), + signalGroup: vi.fn(), + }; - await expect( - observeOwnedProcessGroups(ownership.runId, [100, 200, 300], { - listProcesses, - environment, - runTokens, - signalGroup: vi.fn(), - }), - ).resolves.toEqual( - new Map([ - [100, { state: "alive" }], - [ - 200, - { state: "unverifiable", reason: "process ownership did not match" }, - ], - [300, { state: "empty" }], - ]), - ); - expect(listProcesses).toHaveBeenCalledTimes(1); - expect(runTokens).toHaveBeenCalledTimes(1); - expect(runTokens).toHaveBeenCalledWith( - rows.filter(({ state }) => state !== "Z"), - ); - expect(environment).not.toHaveBeenCalled(); - }); + await expect( + observeOwnedProcessGroups(ownership.runId, [100, 200], host), + ).resolves.toEqual( + new Map([ + [100, { state: "alive" }], + [200, { state: "alive" }], + ]), + ); +}); - it("rechecks unavailable members individually before classifying their groups", async () => { - const rows = [ - ...processes, - { - pid: 200, - parentPid: 1, - processGroupId: 200, - state: "S", - command: "unreadable-group", - }, - ]; - const listProcesses = vi - .fn() - .mockResolvedValueOnce(rows) - .mockResolvedValueOnce(rows.filter(({ pid }) => pid !== 101)) - .mockResolvedValueOnce(rows); - const runTokens = vi.fn>( - (members) => - Promise.resolve( - new Map( - members.map(({ pid }) => [ - pid, - pid === 101 || pid === 200 - ? { - state: "unavailable" as const, - reason: `unreadable-${String(pid)}`, - } - : { state: "readable" as const, runId: ownership.runId }, - ]), - ), +it("does not report a native zombie as a live owned group member", async () => { + const row = processes[0]!; + const host: ProcessOwnershipHost = { + listProcesses: () => Promise.resolve([row]), + environment: () => Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }), + runTokens: (members) => + Promise.resolve( + new Map( + members.map(({ pid }) => [ + pid, + { state: "readable" as const, runId: ownership.runId }, + ]), ), - ); + ), + processIdentities: (members) => + Promise.resolve( + new Map( + members.map(({ pid }) => [ + pid, + { + state: "readable" as const, + identity: `start-${String(pid)}`, + lifecycle: { + state: "observed" as const, + lifecycle: "zombie" as const, + }, + relation: { + state: "observed" as const, + parentPid: 1, + processGroupId: 100, + }, + }, + ]), + ), + ), + signalGroup: vi.fn(), + }; - await expect( - observeOwnedProcessGroups(ownership.runId, [100, 200], { - listProcesses, - environment: vi.fn(() => Promise.resolve({})), - runTokens, - signalGroup: vi.fn(), - }), - ).resolves.toEqual( - new Map([ - [100, { state: "alive" }], - [ - 200, - { - state: "unverifiable", - reason: - "process ownership could not be revalidated for PID 200: unreadable-200", - }, - ], - ]), - ); - expect(listProcesses).toHaveBeenCalledTimes(3); - expect(runTokens).toHaveBeenCalledTimes(1); - }); + await expect( + observeOwnedProcessGroups(ownership.runId, [100], host), + ).resolves.toEqual(new Map([[100, { state: "empty" }]])); +}); + +it("observes multiple groups from one process snapshot and token batch", async () => { + const rows = [ + ...processes, + { + pid: 200, + parentPid: 1, + processGroupId: 200, + state: "S", + command: "other-owned-group", + }, + { + pid: 300, + parentPid: 1, + processGroupId: 300, + state: "Z", + command: "[exited]", + }, + ]; + const listProcesses = vi.fn(() => Promise.resolve(rows)); + const environment = vi.fn(() => Promise.resolve({})); + const runTokens = vi.fn>( + (members) => + Promise.resolve( + new Map( + members.map(({ pid }) => [ + pid, + { + state: "readable" as const, + runId: pid === 200 ? "other-run" : ownership.runId, + }, + ]), + ), + ), + ); + + await expect( + observeOwnedProcessGroups(ownership.runId, [100, 200, 300], { + listProcesses, + environment, + runTokens, + signalGroup: vi.fn(), + }), + ).resolves.toEqual( + new Map([ + [100, { state: "alive" }], + [ + 200, + { state: "unverifiable", reason: "process ownership did not match" }, + ], + [300, { state: "empty" }], + ]), + ); + expect(listProcesses).toHaveBeenCalledTimes(1); + expect(runTokens).toHaveBeenCalledTimes(1); + expect(runTokens).toHaveBeenCalledWith( + rows.filter(({ state }) => state !== "Z"), + ); + expect(environment).not.toHaveBeenCalled(); +}); + +it("rechecks unavailable members individually before classifying their groups", async () => { + const rows = [ + ...processes, + { + pid: 200, + parentPid: 1, + processGroupId: 200, + state: "S", + command: "unreadable-group", + }, + ]; + const listProcesses = vi + .fn() + .mockResolvedValueOnce(rows) + .mockResolvedValueOnce(rows.filter(({ pid }) => pid !== 101)) + .mockResolvedValueOnce(rows); + const runTokens = vi.fn>( + (members) => + Promise.resolve( + new Map( + members.map(({ pid }) => [ + pid, + pid === 101 || pid === 200 + ? { + state: "unavailable" as const, + reason: `unreadable-${String(pid)}`, + } + : { state: "readable" as const, runId: ownership.runId }, + ]), + ), + ), + ); + + await expect( + observeOwnedProcessGroups(ownership.runId, [100, 200], { + listProcesses, + environment: vi.fn(() => Promise.resolve({})), + runTokens, + signalGroup: vi.fn(), + }), + ).resolves.toEqual( + new Map([ + [100, { state: "alive" }], + [ + 200, + { + state: "unverifiable", + reason: + "process ownership could not be revalidated for PID 200: unreadable-200", + }, + ], + ]), + ); + expect(listProcesses).toHaveBeenCalledTimes(3); + expect(runTokens).toHaveBeenCalledTimes(1); }); describe("multi-group ownership observation cancellation", () => { @@ -279,115 +373,159 @@ describe("multi-group observation short-circuit", () => { }); }); -describe("batched ownership observation", () => { - it("observes group and lineage with one batch each and falls back only for missing rows", async () => { - const { adapter } = host({ - 100: { REA_PROCESS_RUN_ID: ownership.runId }, - 101: { REA_PROCESS_RUN_ID: ownership.runId }, - }); - const environment = vi.fn(adapter.environment); - const runTokens = vi.fn>( - () => - Promise.resolve( - new Map([[100, { state: "readable", runId: ownership.runId }]]), +it("builds lineage from same-generation native parent/group facts", async () => { + const rows = [ + { ...processes[0]!, pid: 100, parentPid: 1, processGroupId: 100 }, + { ...processes[1]!, pid: 101, parentPid: 1, processGroupId: 200 }, + ]; + const host: ProcessOwnershipHost = { + listProcesses: () => Promise.resolve(rows), + environment: () => Promise.resolve({}), + runTokens: (members) => + Promise.resolve( + new Map( + members.map(({ pid }) => [ + pid, + { state: "readable" as const, runId: ownership.runId }, + ]), ), + ), + processIdentities: (members) => + Promise.resolve( + new Map( + members.map((member) => [ + member.pid, + { + state: "readable" as const, + identity: `start-${String(member.pid)}`, + relation: { + state: "observed" as const, + parentPid: member.pid === 100 ? 1 : 100, + processGroupId: member.pid === 100 ? 100 : 200, + }, + }, + ]), + ), + ), + signalGroup: vi.fn(), + }; + + await expect( + observeOwnedProcessLineage(ownership, host), + ).resolves.toMatchObject({ + status: "verified", + lineage: { + launcherParentPid: 1, + processGroupId: 100, + descendants: [{ pid: 101, parentPid: 100, processGroupId: 200 }], + }, + }); +}); + +it("observes group and lineage with one batch each and falls back only for missing rows", async () => { + const { adapter } = host({ + 100: { REA_PROCESS_RUN_ID: ownership.runId }, + 101: { REA_PROCESS_RUN_ID: ownership.runId }, + }); + const environment = vi.fn(adapter.environment); + const runTokens = vi.fn>(() => + Promise.resolve( + new Map([[100, { state: "readable", runId: ownership.runId }]]), + ), + ); + const observedHost = { ...adapter, environment, runTokens }; + await expect( + observeOwnedProcessGroups( + ownership.runId, + [ownership.processGroupId], + observedHost, + ), + ).resolves.toEqual(new Map([[ownership.processGroupId, { state: "alive" }]])); + await expect( + observeOwnedProcessLineage(ownership, observedHost), + ).resolves.toMatchObject({ + status: "verified", + lineage: { + descendants: [{ pid: 101, parentPid: 100, processGroupId: 100 }], + }, + }); + expect(runTokens.mock.calls).toEqual([[processes], [processes]]); + expect(environment.mock.calls).toEqual([[101], [101]]); +}); + +it.each(["batch", "environment"] as const)( + "preserves cancellation during %s reads, including successful completion races", + async (boundary) => { + const controller = new AbortController(); + const reason = new Error("cancelled ownership read"); + const listProcesses = vi.fn(() => Promise.resolve(processes)); + const environment = vi.fn( + async (_pid, signal) => { + expect(signal).toBe(controller.signal); + controller.abort(reason); + return { REA_PROCESS_RUN_ID: ownership.runId }; + }, + ); + const runTokens = vi.fn>( + async (_processes, signal) => { + expect(signal).toBe(controller.signal); + if (boundary === "batch") controller.abort(reason); + return new Map(); + }, ); - const observedHost = { ...adapter, environment, runTokens }; await expect( observeOwnedProcessGroups( ownership.runId, [ownership.processGroupId], - observedHost, + { listProcesses, environment, runTokens, signalGroup: vi.fn() }, + controller.signal, ), - ).resolves.toEqual( - new Map([[ownership.processGroupId, { state: "alive" }]]), + ).rejects.toBe(reason); + expect(listProcesses).toHaveBeenCalledTimes(1); + expect(environment).toHaveBeenCalledTimes(boundary === "batch" ? 0 : 1); + }, +); + +it.each([100, 101])( + "rechecks unavailable batch rows for exited PID %i without overriding them through fallback", + async (exitedPid) => { + const listProcesses = vi + .fn() + .mockResolvedValueOnce(processes) + .mockResolvedValue(processes.filter(({ pid }) => pid !== exitedPid)); + const environment = vi.fn(() => + Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }), ); - await expect( - observeOwnedProcessLineage(ownership, observedHost), - ).resolves.toMatchObject({ - status: "verified", - lineage: { - descendants: [{ pid: 101, parentPid: 100, processGroupId: 100 }], - }, - }); - expect(runTokens.mock.calls).toEqual([[processes], [processes]]); - expect(environment.mock.calls).toEqual([[101], [101]]); - }); - - it.each(["batch", "environment"] as const)( - "preserves cancellation during %s reads, including successful completion races", - async (boundary) => { - const controller = new AbortController(); - const reason = new Error("cancelled ownership read"); - const listProcesses = vi.fn(() => Promise.resolve(processes)); - const environment = vi.fn( - async (_pid, signal) => { - expect(signal).toBe(controller.signal); - controller.abort(reason); - return { REA_PROCESS_RUN_ID: ownership.runId }; - }, - ); - const runTokens = vi.fn>( - async (_processes, signal) => { - expect(signal).toBe(controller.signal); - if (boundary === "batch") controller.abort(reason); - return new Map(); - }, - ); - await expect( - observeOwnedProcessGroups( - ownership.runId, - [ownership.processGroupId], - { listProcesses, environment, runTokens, signalGroup: vi.fn() }, - controller.signal, - ), - ).rejects.toBe(reason); - expect(listProcesses).toHaveBeenCalledTimes(1); - expect(environment).toHaveBeenCalledTimes(boundary === "batch" ? 0 : 1); - }, - ); - - it.each([100, 101])( - "rechecks unavailable batch rows for exited PID %i without overriding them through fallback", - async (exitedPid) => { - const listProcesses = vi - .fn() - .mockResolvedValueOnce(processes) - .mockResolvedValue(processes.filter(({ pid }) => pid !== exitedPid)); - const environment = vi.fn(() => - Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }), - ); - const observedHost: ProcessOwnershipHost = { - listProcesses, - environment, - signalGroup: vi.fn(), - runTokens: () => - Promise.resolve( - new Map( - processes.map(({ pid }) => [ - pid, - pid === exitedPid - ? { state: "unavailable" as const, reason: "process exited" } - : { state: "readable" as const, runId: ownership.runId }, - ]), - ), + const observedHost: ProcessOwnershipHost = { + listProcesses, + environment, + signalGroup: vi.fn(), + runTokens: () => + Promise.resolve( + new Map( + processes.map(({ pid }) => [ + pid, + pid === exitedPid + ? { state: "unavailable" as const, reason: "process exited" } + : { state: "readable" as const, runId: ownership.runId }, + ]), ), - }; - const result = await observeOwnedProcessLineage(ownership, observedHost); - if (exitedPid === 100) - expect(result).toMatchObject({ - status: "unavailable", - reason: "owned launcher exited during lineage validation", - }); - else - expect(result).toMatchObject({ - status: "verified", - lineage: { descendants: [] }, - }); - expect(environment).not.toHaveBeenCalled(); - }, - ); -}); + ), + }; + const result = await observeOwnedProcessLineage(ownership, observedHost); + if (exitedPid === 100) + expect(result).toMatchObject({ + status: "unavailable", + reason: "owned launcher exited during lineage validation", + }); + else + expect(result).toMatchObject({ + status: "verified", + lineage: { descendants: [] }, + }); + expect(environment).not.toHaveBeenCalled(); + }, +); describe("token sweep batch failures", () => { it("cleans token-owned groups when a failed batch has readable fallback observations", async () => { @@ -436,3 +574,67 @@ describe("token sweep batch failures", () => { expect(signalGroup).not.toHaveBeenCalled(); }); }); + +it.each(["parent", "group"] as const)( + "keeps a changed native launcher %s relation unavailable instead of publishing the earlier relation", + async (changedRelation) => { + const evidenceOwner = new ProcessOwnershipEvidenceOwner(); + const launcher = { + pid: 100, + parentPid: 20, + processGroupId: 100, + state: "S", + command: "fixture", + }; + let tokenRead = false; + const currentRelation = () => ({ + state: "observed" as const, + parentPid: tokenRead && changedRelation === "parent" ? 1 : 20, + processGroupId: tokenRead && changedRelation === "group" ? 200 : 100, + }); + const host: ProcessOwnershipHost = { + listProcesses: async () => [launcher], + environment: async () => ({}), + runTokens: async (members) => { + tokenRead = true; + return new Map( + members.map(({ pid }) => [ + pid, + { state: "readable" as const, runId: "run" }, + ]), + ); + }, + processIdentities: async (members) => + new Map( + members.map(({ pid }) => [ + pid, + { + state: "readable" as const, + identity: "same-launcher-generation", + relation: currentRelation(), + }, + ]), + ), + signalGroup: vi.fn(), + }; + const result = await observeOwnedProcessLineage( + { + runId: "run", + leaderPid: 100, + processGroupId: 100, + expectedParentPid: 20, + evidenceOwner, + }, + host, + ); + expect(result).toMatchObject({ status: "unavailable" }); + expect(evidenceOwner.candidates).toMatchObject([ + { + pid: 100, + applicability: "token-verified", + relation: currentRelation(), + }, + ]); + expect(host.signalGroup).not.toHaveBeenCalled(); + }, +); diff --git a/src/process/ProcessOwnership.part2.test.ts b/src/process/ProcessOwnership.part2.test.ts index 219b8095da..9984ccfbe8 100644 --- a/src/process/ProcessOwnership.part2.test.ts +++ b/src/process/ProcessOwnership.part2.test.ts @@ -1,39 +1,48 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { cleanupOwnedProcessGroup, verifyNoTokenOwnedProcesses, type ProcessOwnershipHost, } from "./ProcessOwnership.js"; import { observeOwnedProcessLineage } from "./ProcessOwnershipObservation.js"; -import { host, ownership } from "./ProcessOwnership.fixture.js"; +import { createOwnership, host } from "./ProcessOwnership.fixture.js"; + +let ownership = createOwnership(); +beforeEach(() => { + ownership = createOwnership(); +}); describe("owned process-group cleanup discovery", () => { it("signals token-owned groups that were reparented outside the launcher tree", async () => { - const signalGroup = vi.fn(); + const processes = [ + { + pid: 100, + parentPid: 1, + processGroupId: 100, + state: "S", + command: "fixture", + }, + { + pid: 900, + parentPid: 1, + processGroupId: 900, + state: "S", + command: "detached-helper", + }, + { + pid: 901, + parentPid: 900, + processGroupId: 900, + state: "S", + command: "detached-worker", + }, + ]; + const signalGroup = vi.fn((processGroupId: number) => { + for (let index = processes.length - 1; index >= 0; index -= 1) + if (processes[index]?.processGroupId === processGroupId) + processes.splice(index, 1); + }); const adapter: ProcessOwnershipHost = { - listProcesses: () => - Promise.resolve([ - { - pid: 100, - parentPid: 1, - processGroupId: 100, - state: "S", - command: "fixture", - }, - { - pid: 900, - parentPid: 1, - processGroupId: 900, - state: "S", - command: "detached-helper", - }, - { - pid: 901, - parentPid: 900, - processGroupId: 900, - state: "S", - command: "detached-worker", - }, - ]), + listProcesses: () => Promise.resolve(processes), environment: () => Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }), signalGroup, }; diff --git a/src/process/ProcessOwnership.procIdentity.test.ts b/src/process/ProcessOwnership.procIdentity.test.ts new file mode 100644 index 0000000000..6b2eaa3eb6 --- /dev/null +++ b/src/process/ProcessOwnership.procIdentity.test.ts @@ -0,0 +1,148 @@ +import { expect, it, vi } from "vitest"; + +const filesystem = vi.hoisted(() => ({ + readFile: vi.fn(), +})); + +vi.mock("node:fs/promises", async (importOriginal) => { + const original = await importOriginal(); + return { ...original, readFile: filesystem.readFile }; +}); + +import { createSystemProcessOwnershipHost } from "./ProcessOwnershipObservation.js"; +import type { ProcessTableEntry } from "./ProcessOwnership.js"; + +const entry: ProcessTableEntry = { + pid: 42, + parentPid: 1, + processGroupId: 42, + state: "S", + command: "fixture", +}; + +const filesystemError = (code: string): NodeJS.ErrnoException => + Object.assign(new Error(code), { code }); + +it("reports absence only when /proc lookup and signal-zero both report ESRCH", async () => { + const host = createSystemProcessOwnershipHost("linux", {}); + const signalProbe = vi.spyOn(process, "kill").mockImplementation(() => { + throw filesystemError("ESRCH"); + }); + try { + for (const code of ["ENOENT", "ESRCH"]) { + filesystem.readFile.mockRejectedValueOnce(filesystemError(code)); + await expect(host.processIdentities?.([entry])).resolves.toEqual( + new Map([ + [ + 42, + { + state: "absent", + reason: "not-found-in-caller-pid-namespace", + }, + ], + ]), + ); + } + expect(signalProbe).toHaveBeenCalledTimes(2); + expect(signalProbe).toHaveBeenNthCalledWith(1, 42, 0); + } finally { + signalProbe.mockRestore(); + } +}); + +it("keeps hidden or permission-denied live PIDs unavailable", async () => { + const host = createSystemProcessOwnershipHost("linux", {}); + const signalProbe = vi.spyOn(process, "kill"); + try { + filesystem.readFile.mockRejectedValueOnce(filesystemError("ENOENT")); + signalProbe.mockReturnValueOnce(true); + await expect(host.processIdentities?.([entry])).resolves.toEqual( + new Map([ + [ + 42, + { + state: "unavailable", + reason: "proc_stat_missing_but_process_signal_probe_succeeded", + }, + ], + ]), + ); + + filesystem.readFile.mockRejectedValueOnce(filesystemError("ENOENT")); + signalProbe.mockImplementationOnce(() => { + throw filesystemError("EPERM"); + }); + const denied = await host.processIdentities?.([entry]); + const deniedIdentity = denied?.get(42); + expect(deniedIdentity).toMatchObject({ state: "unavailable" }); + expect( + deniedIdentity?.state === "unavailable" ? deniedIdentity.reason : "", + ).toContain("EPERM"); + } finally { + signalProbe.mockRestore(); + } +}); + +it("keeps permission, parser, and unexpected failures unavailable", async () => { + const host = createSystemProcessOwnershipHost("linux", {}); + const signalProbe = vi.spyOn(process, "kill"); + try { + for (const code of ["EACCES", "EPERM", "EIO"]) { + filesystem.readFile.mockRejectedValueOnce(filesystemError(code)); + const result = await host.processIdentities?.([entry]); + expect(result?.get(42)?.state).toBe("unavailable"); + } + + filesystem.readFile.mockResolvedValueOnce("42 (fixture) S malformed"); + await expect(host.processIdentities?.([entry])).resolves.toEqual( + new Map([[42, { state: "unavailable", reason: "malformed_proc_stat" }]]), + ); + expect(signalProbe).not.toHaveBeenCalled(); + } finally { + signalProbe.mockRestore(); + } +}); + +it("rejects invalid PIDs before procfs lookup or signal probing", async () => { + const host = createSystemProcessOwnershipHost("linux", {}); + const signalProbe = vi.spyOn(process, "kill"); + filesystem.readFile.mockClear(); + try { + await expect( + host.processIdentities?.([{ ...entry, pid: 0 }]), + ).resolves.toEqual( + new Map([[0, { state: "unavailable", reason: "invalid_process_id" }]]), + ); + expect(filesystem.readFile).not.toHaveBeenCalled(); + expect(signalProbe).not.toHaveBeenCalled(); + } finally { + signalProbe.mockRestore(); + } +}); + +it("preserves cancellation before and after the signal-zero probe", async () => { + const host = createSystemProcessOwnershipHost("linux", {}); + const controller = new AbortController(); + const reason = new Error("cancelled"); + controller.abort(reason); + filesystem.readFile.mockRejectedValueOnce( + Object.assign(new Error("aborted"), { name: "AbortError" }), + ); + await expect( + host.processIdentities?.([entry], controller.signal), + ).rejects.toMatchObject({ name: "AbortError" }); + + const afterProbe = new AbortController(); + const probeAbort = vi.spyOn(process, "kill").mockImplementation(() => { + afterProbe.abort(reason); + throw filesystemError("ESRCH"); + }); + try { + filesystem.readFile.mockRejectedValueOnce(filesystemError("ENOENT")); + await expect( + host.processIdentities?.([entry], afterProbe.signal), + ).rejects.toBe(reason); + } finally { + probeAbort.mockRestore(); + } +}); diff --git a/src/process/ProcessOwnership.test.ts b/src/process/ProcessOwnership.test.ts index fafa399508..424211e09c 100644 --- a/src/process/ProcessOwnership.test.ts +++ b/src/process/ProcessOwnership.test.ts @@ -1,12 +1,37 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { cleanupOwnedProcessGroup, + type ProcessIdentityObservation, type ProcessOwnershipHost, + type ProcessTableEntry, } from "./ProcessOwnership.js"; import { parseProcessEnvironment } from "./ProcessOwnershipObservation.js"; import { selectCapturedProcessGroupIds } from "./ProcessOwnershipProcessTree.js"; import { matchesOwnedProcessCommand } from "./ProcessCommandIdentity.js"; -import { host, ownership } from "./ProcessOwnership.fixture.js"; +import { createOwnership, host } from "./ProcessOwnership.fixture.js"; + +let ownership = createOwnership(); +beforeEach(() => { + ownership = createOwnership(); +}); + +const identitiesWithRelations = ( + processes: readonly ProcessTableEntry[], +): ReadonlyMap => + new Map( + processes.map((entry) => [ + entry.pid, + { + state: "readable" as const, + identity: `start-${String(entry.pid)}`, + relation: { + state: "observed" as const, + parentPid: entry.parentPid, + processGroupId: entry.processGroupId, + }, + }, + ]), + ); describe("owned process-group cleanup discovery", () => { it("excludes sampled groups whose leader was outside the captured tree", () => { expect( @@ -88,13 +113,11 @@ describe("opaque process neighbors during cleanup", () => { command: "/usr/bin/opaque-neighbor", }, ].map((process) => ({ ...process, state: "S" })); - const signalGroup = vi.fn(); - const identities = new Map( - processes.map(({ pid }) => [ - pid, - { state: "readable" as const, identity: `start-${String(pid)}` }, - ]), - ); + const signalGroup = vi.fn((processGroupId: number) => { + for (let index = processes.length - 1; index >= 0; index -= 1) + if (processes[index]?.processGroupId === processGroupId) + processes.splice(index, 1); + }); const adapter: ProcessOwnershipHost = { platform: "darwin", listProcesses: () => Promise.resolve(processes), @@ -102,7 +125,23 @@ describe("opaque process neighbors during cleanup", () => { Promise.resolve({ REA_PROCESS_RUN_ID: pid === 900 ? "unowned" : "run-token", }), - processIdentities: () => Promise.resolve(identities), + processIdentities: (entries) => + Promise.resolve( + new Map( + entries.map((entry) => { + const current = processes.find(({ pid }) => pid === entry.pid); + return [ + entry.pid, + current === undefined + ? { + state: "absent" as const, + reason: "not-in-snapshot" as const, + } + : identitiesWithRelations([current]).get(entry.pid)!, + ]; + }), + ), + ), runTokens: (members) => Promise.resolve( new Map( @@ -154,12 +193,7 @@ describe("opaque process neighbors during cleanup", () => { }, ].map((process) => ({ ...process, state: "S" })); const signalGroup = vi.fn(); - const identities = new Map( - processes.map(({ pid }) => [ - pid, - { state: "readable" as const, identity: `start-${String(pid)}` }, - ]), - ); + const identities = identitiesWithRelations(processes); const adapter: ProcessOwnershipHost = { platform: "darwin", listProcesses: () => Promise.resolve(processes), @@ -282,13 +316,7 @@ describe("related sweep candidates during cleanup", () => { let childReads = 0; const adapter: ProcessOwnershipHost = { listProcesses: async () => processes, - processIdentities: async (members) => - new Map( - members.map(({ pid }) => [ - pid, - { state: "readable" as const, identity: `start-${String(pid)}` }, - ]), - ), + processIdentities: async (members) => identitiesWithRelations(members), environment: async () => ({}), runTokens: async (members) => new Map( @@ -416,12 +444,7 @@ describe("withheld process evidence during cleanup", () => { }, ]; const signalGroup = vi.fn(); - const identities = new Map( - processes.map(({ pid }) => [ - pid, - { state: "readable" as const, identity: `start-${String(pid)}` }, - ]), - ); + const identities = identitiesWithRelations(processes); const adapter: ProcessOwnershipHost = { listProcesses: () => Promise.resolve(processes), environment: (pid) => @@ -484,10 +507,7 @@ describe("sampled detached process groups during cleanup", () => { }, ]; const signalGroup = vi.fn(); - const identities = new Map([ - [100, { state: "readable" as const, identity: "start-100" }], - [200, { state: "readable" as const, identity: "start-200" }], - ]); + const identities = identitiesWithRelations(processes); const adapter: ProcessOwnershipHost = { listProcesses: () => Promise.resolve(processes), environment: (pid) => diff --git a/src/process/ProcessOwnership.ts b/src/process/ProcessOwnership.ts index 91d681f79b..3cc3d6ee22 100644 --- a/src/process/ProcessOwnership.ts +++ b/src/process/ProcessOwnership.ts @@ -1,19 +1,26 @@ 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); -/** Identity proof required before REA may signal an owned process group. */ -export interface OwnedProcessGroup { +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; @@ -21,8 +28,6 @@ export interface OwnedProcessGroup { readonly expectedCommand?: string; /** Expected launcher parent, checked only while the leader exists. */ readonly expectedParentPid?: number; - /** Scan all live processes for this run token during cleanup. */ - readonly sweepTokenOwnedProcesses?: boolean; /** Prelaunch process identities for this capture; absent on generic callers. */ readonly captureBaseline?: ProcessOwnershipBaseline; /** @@ -32,6 +37,27 @@ export interface OwnedProcessGroup { 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; @@ -51,9 +77,57 @@ export interface ProcessTableEntry { /** Native process start identity; unavailable identities must never be skipped. */ export type ProcessIdentityObservation = - | { readonly state: "readable"; readonly identity: string } + | { + 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; @@ -149,6 +223,7 @@ 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. */ @@ -227,14 +302,22 @@ export const readProcessRunId = async ( ): Promise => (await host.environment(pid, signal)).REA_PROCESS_RUN_ID; -/** Keep unrelated-process uncertainty attached to every outcome of this cleanup attempt. */ -const withUnverifiedObservations = ( - result: ProcessCleanupFailure, +/** Merge duplicate uncertainty rows from discovery and final verification. */ +const mergeUnverifiedObservations = ( + result: ProcessCleanupResult, unverified: readonly OwnershipSweepUnverifiedProcess[], -): ProcessCleanupFailure => - unverified.length === 0 || (result.unverified?.length ?? 0) > 0 - ? result - : { ...result, unverified }; +): 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 ( @@ -261,16 +344,8 @@ export const cleanupOwnedProcessGroup = async ( host, plan.tokenOwnedIdentities, ); - if ("cleaned" in revalidation) { - if (!revalidation.cleaned) - return withUnverifiedObservations(revalidation, plan.unverified); - return { - ...revalidation, - ...(revalidation.unverified?.length || plan.unverified.length === 0 - ? {} - : { unverified: plan.unverified }), - }; - } + if ("cleaned" in revalidation) + return mergeUnverifiedObservations(revalidation, plan.unverified); if (revalidation.empty) continue; try { host.signalGroup(processGroupId, "SIGKILL"); @@ -279,7 +354,7 @@ export const cleanupOwnedProcessGroup = async ( const code = cause instanceof Error && "code" in cause ? cause.code : undefined; if (code !== "ESRCH") - return withUnverifiedObservations( + return mergeUnverifiedObservations( { cleaned: false, reason: `owned process group signal failed: ${errorMessage(cause)}`, @@ -290,6 +365,57 @@ export const cleanupOwnedProcessGroup = async ( } 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, @@ -358,6 +484,75 @@ interface OwnedCleanupPlan { 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. @@ -371,8 +566,38 @@ const settleLauncherExit = async ( > => { const launcher = processes.find(({ pid }) => pid === ownership.leaderPid); if (launcher === undefined) return processes; - const reason = launcherIdentityFailure(launcher, ownership); + 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 && @@ -413,6 +638,11 @@ const createOwnedCleanupPlan = async ( ): 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) => @@ -429,6 +659,7 @@ const createOwnedCleanupPlan = async ( { leaderPid: ownership.leaderPid, processGroupId: ownership.processGroupId, + evidenceOwner: ownership.evidenceOwner, ...(sampledProcessGroupIds.size === 0 ? {} : { sampledProcessGroupIds: [...sampledProcessGroupIds] }), @@ -438,23 +669,26 @@ const createOwnedCleanupPlan = async ( const tokenOwnedGroupIds = new Set( tokenOwned.owned.map(({ processGroupId }) => processGroupId), ); - const launcher = processes.find(({ pid }) => pid === ownership.leaderPid); - const rootMembers = processes.filter( + 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 && - !processes.some(({ processGroupId }) => + !relationSnapshot.currentRelations.some(({ processGroupId }) => sampledProcessGroupIds.has(processGroupId), ) ) - return tokenOwned.failures.length > 0 + return tokenOwned.failures.length > 0 || + relationSnapshot.failures.length > 0 ? { signalOrder: [], tokenOwnedIdentities: tokenOwned.identities, - unresolved: tokenOwned.failures, + unresolved: [...tokenOwned.failures, ...relationSnapshot.failures], unverified: tokenOwned.unverified, } : { @@ -466,7 +700,10 @@ const createOwnedCleanupPlan = async ( }; let descendants: readonly ProcessTableEntry[] = []; if (launcher !== undefined) { - descendants = descendantsOf([launcher.pid], processes); + descendants = descendantsOf( + [launcher.pid], + relationSnapshot.currentRelations, + ); } const descendantPids = new Set(descendants.map(({ pid }) => pid)); const processGroupIds = new Set([ownership.processGroupId]); @@ -477,11 +714,11 @@ const createOwnedCleanupPlan = async ( for (const processGroupId of sampledProcessGroupIds) processGroupIds.add(processGroupId); const signalableGroups = new Set(); - const unresolved = [...tokenOwned.failures]; + const unresolved = [...tokenOwned.failures, ...relationSnapshot.failures]; const unverifiableSampledGroups = new Set(); for (const processGroupId of processGroupIds) { if (processGroupId === ownership.processGroupId) continue; - const groupLeader = processes.find( + const groupLeader = relationSnapshot.currentRelations.find( ({ pid, processGroupId: observedGroupId }) => pid === processGroupId && observedGroupId === processGroupId, ); @@ -501,8 +738,8 @@ const createOwnedCleanupPlan = async ( ) unverifiableSampledGroups.add(processGroupId); } - const liveMembers = processes.filter(({ processGroupId }) => - processGroupIds.has(processGroupId), + const liveMembers = relationSnapshot.currentRelations.filter( + ({ processGroupId }) => processGroupIds.has(processGroupId), ); for (const processGroupId of processGroupIds) { const members = liveMembers.filter( @@ -599,7 +836,14 @@ const revalidateOwnedProcessGroup = async ( host, ); if ("cleaned" in processes) return processes; - members = processes.filter( + const relationSnapshot = await readProcessRelationSnapshot( + ownership, + processes, + host, + ); + if (relationSnapshot.failures.length > 0) + return cleanupValidationFailure(relationSnapshot.failures); + members = relationSnapshot.currentRelations.filter( ({ processGroupId: observedGroupId }) => observedGroupId === processGroupId, ); @@ -727,61 +971,67 @@ const processOwnershipFailures = async ( host: ProcessOwnershipHost, expectedIdentities: ReadonlyMap = new Map(), ): Promise => { - const failures: ProcessOwnershipValidationFailure[] = []; - if (expectedIdentities.size > 0 && host.processIdentities !== undefined) { - const expectedMembers = members.filter(({ pid }) => - expectedIdentities.has(pid), - ); - const observed = await host.processIdentities(expectedMembers); - const identityFailures: ProcessOwnershipValidationFailure[] = []; - for (const member of expectedMembers) { - const expected = expectedIdentities.get(member.pid); - const current = observed.get(member.pid); + 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 ( - expected === null || - current?.state !== "readable" || - current.identity !== expected - ) { - identityFailures.push({ - pid: member.pid, + 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" }); } } - failures.push(...(await recheckOwnershipFailures(identityFailures, host))); + return failures; } - failures.push(...(await processRunTokenFailures(members, runId, host))); - if ( - failures.length === 0 && - expectedIdentities.size > 0 && - host.processIdentities !== undefined - ) { - const expectedMembers = members.filter(({ pid }) => - expectedIdentities.has(pid), - ); - const observed = await host.processIdentities(expectedMembers); - const identityFailures: ProcessOwnershipValidationFailure[] = []; - for (const member of expectedMembers) { - const expected = expectedIdentities.get(member.pid); - const current = observed.get(member.pid); - if ( - expected !== null && - current?.state === "readable" && - current.identity === expected - ) - continue; - identityFailures.push({ - pid: member.pid, - reason: "process-identity-unavailable", - diagnostic: - "process identity changed or became unavailable during token validation", - }); - } - failures.push(...(await recheckOwnershipFailures(identityFailures, host))); - } - return failures; + + return processRunTokenFailures(members, runId, host); }; const processRunTokenFailures = async ( @@ -892,11 +1142,11 @@ const currentProcessUid = (): number | undefined => * unreadable candidate stays fail-closed. */ const environmentWithheldFromInspection = ( - entry: ProcessTableEntry, diagnostic: string, + observedUid: number | undefined, ): boolean => { const uid = currentProcessUid(); - if (uid !== undefined && entry.uid !== undefined && entry.uid !== uid) + if (uid !== undefined && observedUid !== undefined && observedUid !== uid) return true; return ( diagnostic === PLATFORM_BINARY_ENVIRONMENT_WITHHELD || @@ -911,55 +1161,22 @@ const scanTokenOwnedProcesses = async ( captureBaseline?: ProcessOwnershipBaseline, relation?: OwnershipSweepRelation, ): Promise => { - let candidates = processes; - let candidateIdentities: ReadonlyMap = - new Map(); - if (host.processIdentities !== undefined) { - const current = await host.processIdentities(processes); - candidateIdentities = current; - if (captureBaseline !== undefined) { - const baseline = new Map( - captureBaseline.map(({ pid, identity }) => [pid, identity]), - ); - candidates = processes.filter((process) => { - const before = baseline.get(process.pid); - const now = current.get(process.pid); - return !( - before !== undefined && - before !== null && - now?.state === "readable" && - now.identity === before - ); - }); - } - } + if (host.processIdentities !== undefined) + return scanWithOwnershipEvidence( + processes, + runId, + host, + captureBaseline, + relation, + ); + + const candidates = processes; const failures: ProcessOwnershipValidationFailure[] = []; - if (captureBaseline !== undefined && host.processIdentities !== undefined) { - const stableCandidates: ProcessTableEntry[] = []; - const identityFailures: ProcessOwnershipValidationFailure[] = []; - for (const process of candidates) { - if (candidateIdentities.get(process.pid)?.state === "readable") { - stableCandidates.push(process); - continue; - } - identityFailures.push({ - pid: process.pid, - reason: "process-identity-unavailable", - diagnostic: "process identity was unavailable during token scan", - }); - } - failures.push(...(await recheckOwnershipFailures(identityFailures, host))); - candidates = stableCandidates; - } const owned: ProcessTableEntry[] = []; const ownedIdentities = new Map(); const recordOwned = (process: ProcessTableEntry): void => { owned.push(process); - const identity = candidateIdentities.get(process.pid); - ownedIdentities.set( - process.pid, - identity?.state === "readable" ? identity.identity : null, - ); + ownedIdentities.set(process.pid, null); }; const unreadable: UnreadableTokenCandidate[] = []; for await (const { process, observation } of readProcessRunTokens( @@ -998,7 +1215,10 @@ const scanTokenOwnedProcesses = async ( relatedGroupIds.has(candidate.process.processGroupId); if ( !isRelated && - environmentWithheldFromInspection(candidate.process, candidate.diagnostic) + environmentWithheldFromInspection( + candidate.diagnostic, + candidate.process.uid, + ) ) { withheld.push(candidate); continue; @@ -1031,38 +1251,239 @@ const scanTokenOwnedProcesses = async ( diagnostic: candidate.diagnostic, }); } - if (candidates.length > 0 && host.processIdentities !== undefined) { - const afterRead = await host.processIdentities(candidates); - const stillOwned: ProcessTableEntry[] = []; - const identityFailures: ProcessOwnershipValidationFailure[] = []; - const ownedPids = new Set(owned.map(({ pid }) => pid)); - for (const process of candidates) { - const before = candidateIdentities.get(process.pid); - const current = afterRead.get(process.pid); - if ( - before?.state === "readable" && - current?.state === "readable" && - current.identity === before.identity - ) { - if (ownedPids.has(process.pid)) stillOwned.push(process); - continue; - } - identityFailures.push({ + 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 changed or became unavailable during token validation", + 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; } - failures.push(...(await recheckOwnershipFailures(identityFailures, host))); - return { - owned: stillOwned, - failures, - unverified, - identities: ownedIdentities, - }; + 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); } - return { owned, failures, unverified, identities: ownedIdentities }; + + 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 }; }; /** diff --git a/src/process/ProcessOwnership.validation.test.ts b/src/process/ProcessOwnership.validation.test.ts index ed6179a77e..a2ee26b029 100644 --- a/src/process/ProcessOwnership.validation.test.ts +++ b/src/process/ProcessOwnership.validation.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { cleanupOwnedProcessGroup, cleanupWindowsProcessTree, @@ -10,7 +10,12 @@ import { observeOwnedProcessGroups, observeOwnedProcessLineage, } from "./ProcessOwnershipObservation.js"; -import { host, ownership } from "./ProcessOwnership.fixture.js"; +import { createOwnership, host } from "./ProcessOwnership.fixture.js"; + +let ownership = createOwnership(); +beforeEach(() => { + ownership = createOwnership(); +}); const launcher: ProcessTableEntry = { pid: 100, diff --git a/src/process/ProcessOwnershipEvidence.ts b/src/process/ProcessOwnershipEvidence.ts new file mode 100644 index 0000000000..7f35d93a02 --- /dev/null +++ b/src/process/ProcessOwnershipEvidence.ts @@ -0,0 +1,359 @@ +import type { + ProcessIdentityObservation, + ProcessNativeRelationObservation, + ProcessOwnershipFact, + ProcessRunTokenObservation, + ProcessTableEntry, +} from "./ProcessOwnership.js"; + +export interface RetainedProcessOwnershipCandidate { + readonly pid: number; + /** Undefined means the generation has never been established, not PID ownership. */ + readonly identity: string | undefined; + readonly process: ProcessTableEntry; + readonly applicability: + | "token-verified" + | "related-ownership-unresolved" + | "identity-unresolved"; + /** Why this generation remains in the ledger; capture anchors are only scope uncertainty. */ + readonly scopeEvidence: + | "run-token" + | "current-token-owned-parent" + | "capture-leader-pid" + | "captured-process-group-id" + | "native-identity-unresolved" + | "previously-related-generation"; + readonly token: ProcessRunTokenObservation; + readonly relation: + | Extract + | undefined; + readonly diagnostic?: string; +} + +export interface ProcessOwnershipEvidenceCoverage { + readonly snapshotCount: number; + /** Rows supplied by the caller for the latest snapshot, not a completeness claim. */ + readonly listedRows: number; + /** Distinct PIDs given before/token/after reads, including retained-only PIDs. */ + readonly examinedPids: number; + readonly retainedOnlyPids: number; + readonly retainedOwned: number; + readonly retainedUnresolved: number; + readonly uncertainPids: readonly number[]; + readonly processTableCoverage: "unreported-by-host"; +} + +/** Per-run facts retained across failed cleanup attempts; identity is required for authority. */ +export class ProcessOwnershipEvidenceOwner { + readonly #candidates = new Map(); + #snapshotCount = 0; + #coverage: ProcessOwnershipEvidenceCoverage = { + snapshotCount: 0, + listedRows: 0, + examinedPids: 0, + retainedOnlyPids: 0, + retainedOwned: 0, + retainedUnresolved: 0, + uncertainPids: [], + processTableCoverage: "unreported-by-host", + }; + + get candidates(): readonly RetainedProcessOwnershipCandidate[] { + return [...this.#candidates.values()]; + } + + get coverage(): ProcessOwnershipEvidenceCoverage { + return this.#coverage; + } + + /** + * Reconcile facts from one explicit snapshot. Unrelated transient rows are + * never retained; unknown related candidates remain evidence, not authority. + */ + reconcile( + facts: readonly ProcessOwnershipFact[], + options: { + readonly runId: string; + readonly listedRows: number; + readonly listedPids: ReadonlySet; + readonly leaderPid: number; + readonly processGroupIds: ReadonlySet; + /** First identity/token/identity result before bounded token-only retries. */ + readonly scopeFacts?: readonly ProcessOwnershipFact[]; + }, + ): readonly RetainedProcessOwnershipCandidate[] { + this.#snapshotCount += 1; + const factsByPid = new Map(facts.map((fact) => [fact.process.pid, fact])); + const sameGeneration = new Map< + number, + Extract< + ProcessOwnershipFact["identityAfter"], + { readonly state: "readable" } + > + >(); + const sameGenerationBefore = new Map< + number, + Extract + >(); + const tokenMatchedPids = new Set(); + const uncertainPids = new Set(); + const retiredPids = new Set(); + + for (const fact of facts) { + if (fact.state !== "same-generation") continue; + if (fact.identityAfter.state !== "readable") continue; + sameGeneration.set(fact.process.pid, fact.identityAfter); + if ( + fact.identityBefore.state === "readable" && + fact.identityBefore.identity === fact.identityAfter.identity + ) + sameGenerationBefore.set(fact.process.pid, fact.identityBefore); + if (fact.token.state === "readable" && fact.token.runId === options.runId) + tokenMatchedPids.add(fact.process.pid); + } + + const findScopeEvidence = ( + pid: number, + generations: ReadonlyMap< + number, + Extract + >, + tokenOwners: ReadonlySet, + ): + | Exclude< + RetainedProcessOwnershipCandidate["scopeEvidence"], + "run-token" | "previously-related-generation" + > + | undefined => { + const identity = generations.get(pid); + if (identity === undefined) return undefined; + const relation = + identity.relation?.state === "observed" ? identity.relation : undefined; + if (relation === undefined) return undefined; + if (options.processGroupIds.has(relation.processGroupId)) + return "captured-process-group-id"; + const visited = new Set([pid]); + let parentPid = relation.parentPid; + while (!visited.has(parentPid)) { + const parent = generations.get(parentPid); + if (parent === undefined) { + return parentPid === options.leaderPid + ? "capture-leader-pid" + : undefined; + } + const previousParent = this.#candidates.get(parentPid); + if ( + tokenOwners.has(parentPid) || + (previousParent?.applicability === "token-verified" && + parent.identity === previousParent.identity) + ) + return "current-token-owned-parent"; + if (parentPid === options.leaderPid) return "capture-leader-pid"; + visited.add(parentPid); + if (parent.relation?.state !== "observed") return undefined; + parentPid = parent.relation.parentPid; + } + return undefined; + }; + + const scopeGenerations = new Map< + number, + Extract + >(); + const scopeBeforeGenerations = new Map< + number, + Extract + >(); + const scopeTokenMatchedPids = new Set(); + for (const fact of options.scopeFacts ?? []) { + const current = sameGeneration.get(fact.process.pid); + if ( + fact.state !== "same-generation" || + fact.identityAfter.state !== "readable" || + current?.identity !== fact.identityAfter.identity + ) + continue; + scopeGenerations.set(fact.process.pid, fact.identityAfter); + if ( + fact.identityBefore.state === "readable" && + current?.identity === fact.identityBefore.identity + ) + scopeBeforeGenerations.set(fact.process.pid, fact.identityBefore); + if (fact.token.state === "readable" && fact.token.runId === options.runId) + scopeTokenMatchedPids.add(fact.process.pid); + } + const related = (pid: number) => { + const current = + findScopeEvidence(pid, sameGeneration, tokenMatchedPids) ?? + findScopeEvidence(pid, sameGenerationBefore, tokenMatchedPids); + if (current !== undefined) return current; + const initial = + findScopeEvidence(pid, scopeGenerations, scopeTokenMatchedPids) ?? + findScopeEvidence(pid, scopeBeforeGenerations, scopeTokenMatchedPids); + return initial === undefined + ? undefined + : "previously-related-generation"; + }; + + for (const [pid, previous] of this.#candidates) { + const fact = factsByPid.get(pid); + if (fact === undefined) continue; + // Retire only this generation's current ledger entry. Darwin's + // `not-in-snapshot` and Linux's corroborated ENOENT/ESRCH facts are + // bounded observations, not claims about later PID reuse or global + // liveness. + if ( + fact.state === "absent" || + (previous.identity !== undefined && + fact.identityAfter.state === "readable" && + fact.identityAfter.identity !== previous.identity) || + (fact.state === "same-generation" && + fact.identityAfter.state === "readable" && + fact.identityAfter.lifecycle?.state === "observed" && + fact.identityAfter.lifecycle.lifecycle === "zombie") + ) { + this.#candidates.delete(pid); + retiredPids.add(pid); + continue; + } + if (fact.state !== "same-generation") { + uncertainPids.add(pid); + continue; + } + const identity = fact.identityAfter; + if (identity.state !== "readable") { + uncertainPids.add(pid); + continue; + } + const matched = + fact.token.state === "readable" && fact.token.runId === options.runId; + if (previous.identity === undefined && !matched) { + uncertainPids.add(pid); + this.#candidates.set(pid, { + ...previous, + process: fact.process, + token: fact.token, + ...(fact.token.state === "unavailable" + ? { diagnostic: fact.token.reason } + : {}), + }); + continue; + } + const relation = + identity.relation?.state === "observed" + ? { + state: "observed" as const, + parentPid: identity.relation.parentPid, + processGroupId: identity.relation.processGroupId, + } + : undefined; + this.#candidates.set(pid, { + pid, + identity: identity.identity, + process: fact.process, + applicability: + matched || previous.applicability === "token-verified" + ? "token-verified" + : "related-ownership-unresolved", + scopeEvidence: + matched || previous.applicability === "token-verified" + ? "run-token" + : (related(pid) ?? "previously-related-generation"), + token: fact.token, + relation, + ...(fact.token.state === "unavailable" + ? { diagnostic: fact.token.reason } + : {}), + }); + } + + for (const fact of facts) { + const pid = fact.process.pid; + if ( + this.#candidates.has(pid) || + (retiredPids.has(pid) && fact.state !== "same-generation") || + fact.state === "absent" || + (fact.state === "same-generation" && + fact.identityAfter.state === "readable" && + fact.identityAfter.lifecycle?.state === "observed" && + fact.identityAfter.lifecycle.lifecycle === "zombie") + ) + continue; + const identity = fact.identityAfter; + if (fact.state !== "same-generation" || identity.state !== "readable") { + this.#candidates.set(pid, { + pid, + identity: undefined, + process: fact.process, + applicability: "identity-unresolved", + scopeEvidence: "native-identity-unresolved", + token: fact.token, + relation: undefined, + ...(fact.token.state === "unavailable" + ? { diagnostic: fact.token.reason } + : {}), + }); + uncertainPids.add(pid); + continue; + } + const matched = + fact.token.state === "readable" && fact.token.runId === options.runId; + const unresolvedScope = matched ? undefined : related(pid); + if (!matched && unresolvedScope === undefined) continue; + const relation = + identity.relation?.state === "observed" + ? { + state: "observed" as const, + parentPid: identity.relation.parentPid, + processGroupId: identity.relation.processGroupId, + } + : undefined; + this.#candidates.set(pid, { + pid, + identity: identity.identity, + process: fact.process, + applicability: matched + ? "token-verified" + : "related-ownership-unresolved", + scopeEvidence: matched + ? "run-token" + : (unresolvedScope ?? "previously-related-generation"), + token: fact.token, + relation, + ...(fact.token.state === "unavailable" + ? { diagnostic: fact.token.reason } + : {}), + }); + } + + const retainedOnlyPids = [...this.#candidates.keys()].filter( + (pid) => !options.listedPids.has(pid), + ); + this.#coverage = { + snapshotCount: this.#snapshotCount, + listedRows: options.listedRows, + examinedPids: facts.length, + retainedOnlyPids: retainedOnlyPids.length, + retainedOwned: [...this.#candidates.values()].filter( + ({ applicability }) => applicability === "token-verified", + ).length, + retainedUnresolved: [...this.#candidates.values()].filter( + ({ applicability }) => applicability !== "token-verified", + ).length, + uncertainPids: [...uncertainPids].sort((left, right) => left - right), + processTableCoverage: "unreported-by-host", + }; + return this.candidates; + } + + /** Merge current discovery rows with retained candidates without PID-only proof. */ + rowsForObservation( + processes: readonly ProcessTableEntry[], + ): readonly ProcessTableEntry[] { + const byPid = new Map(processes.map((process) => [process.pid, process])); + for (const candidate of this.#candidates.values()) + if (!byPid.has(candidate.pid)) + byPid.set(candidate.pid, candidate.process); + return [...byPid.values()]; + } +} diff --git a/src/process/ProcessOwnershipFacts.test.ts b/src/process/ProcessOwnershipFacts.test.ts new file mode 100644 index 0000000000..90616f9788 --- /dev/null +++ b/src/process/ProcessOwnershipFacts.test.ts @@ -0,0 +1,600 @@ +import { expect, it, vi } from "vitest"; + +import type { + ProcessIdentityObservation, + ProcessOwnershipFact, + ProcessOwnershipHost, + ProcessTableEntry, + ProcessRunTokenObservation, + ProcessNativeRelationObservation, +} from "./ProcessOwnership.js"; +import { readProcessOwnershipFacts } from "./ProcessOwnershipObservation.js"; +import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js"; + +const row: ProcessTableEntry = { + pid: 42, + parentPid: 1, + processGroupId: 42, + state: "S", + command: "fixture", +}; + +const identity = ( + value: string, + lifecycle: "non-zombie" | "zombie" = "non-zombie", + relation: ProcessNativeRelationObservation = { + state: "observed", + parentPid: row.parentPid, + processGroupId: row.processGroupId, + }, +): ProcessIdentityObservation => ({ + state: "readable", + identity: value, + lifecycle: { state: "observed", lifecycle }, + relation, +}); + +const hostFor = ( + identities: readonly ReadonlyMap[], + token: ProcessRunTokenObservation = { state: "readable", runId: "run" }, +): ProcessOwnershipHost => { + let identityRead = 0; + return { + platform: "linux", + listProcesses: vi.fn(async () => { + throw new Error( + "ownership fact owner must not enumerate the process table", + ); + }), + environment: async () => ({}), + processIdentities: async () => { + const result = identities[identityRead]; + identityRead += 1; + return result ?? new Map(); + }, + runTokens: async () => new Map([[row.pid, token]]), + signalGroup: () => undefined, + }; +}; + +it("binds owned, missing, and foreign tokens only to the same identity generation", async () => { + for (const token of [ + { state: "readable", runId: "run" }, + { state: "readable", runId: undefined }, + { state: "readable", runId: "foreign" }, + ] as const) { + const host = hostFor( + [ + new Map([[row.pid, identity("start:42")]]), + new Map([[row.pid, identity("start:42")]]), + ], + token, + ); + const [fact] = await readProcessOwnershipFacts([row], host); + expect(fact).toMatchObject({ + state: "same-generation", + token, + identityBefore: { state: "readable", identity: "start:42" }, + identityAfter: { state: "readable", identity: "start:42" }, + }); + } +}); + +it("retains a matching token as partial evidence when identity changes", async () => { + const host = hostFor([ + new Map([[row.pid, identity("start:old")]]), + new Map([[row.pid, identity("start:new")]]), + ]); + const [fact] = await readProcessOwnershipFacts([row], host); + expect(fact).toMatchObject({ + state: "replaced", + token: { state: "readable", runId: "run" }, + }); +}); + +it("keeps omitted identities uncertain and native absence distinct", async () => { + const omitted = hostFor([new Map(), new Map()]); + const [uncertain] = await readProcessOwnershipFacts([row], omitted); + expect(uncertain).toMatchObject({ + state: "unavailable", + identityBefore: { + state: "unavailable", + reason: "process identity was not returned during before observation", + }, + }); + + const absent: ProcessIdentityObservation = { + state: "absent", + reason: "not-in-snapshot", + }; + const absentHost = hostFor([ + new Map([[row.pid, absent]]), + new Map([[row.pid, absent]]), + ]); + const [gone] = await readProcessOwnershipFacts([row], absentHost); + expect(gone).toMatchObject({ + state: "absent", + token: { state: "readable", runId: "run" }, + }); + expect(absentHost.listProcesses).not.toHaveBeenCalled(); + + const appeared = hostFor([ + new Map([[row.pid, absent]]), + new Map([[row.pid, identity("start:appeared")]]), + ]); + const [replacement] = await readProcessOwnershipFacts([row], appeared); + expect(replacement?.state).toBe("replaced"); +}); + +it("keeps zombie lifecycle separate from identity and token diagnostics intact", async () => { + const zombie = hostFor( + [ + new Map([[row.pid, identity("start:zombie", "zombie")]]), + new Map([[row.pid, identity("start:zombie", "zombie")]]), + ], + { state: "unavailable", reason: "environment denied" }, + ); + const [fact] = await readProcessOwnershipFacts([row], zombie); + expect(fact).toMatchObject({ + state: "same-generation", + token: { state: "unavailable", reason: "environment denied" }, + }); + expect(fact?.identityBefore).toEqual( + expect.objectContaining({ + identity: "start:zombie", + lifecycle: { state: "observed", lifecycle: "zombie" }, + }), + ); +}); + +it("keeps stale table ancestry separate from current native membership", async () => { + const staleRow: ProcessTableEntry = { + ...row, + pid: 101, + parentPid: 100, + processGroupId: 100, + }; + const newGenerationRelation = { + state: "observed", + parentPid: 1, + processGroupId: 102, + } as const; + const host = hostFor([ + new Map([ + [ + staleRow.pid, + identity("start:new", "non-zombie", newGenerationRelation), + ], + ]), + new Map([ + [ + staleRow.pid, + identity("start:new", "non-zombie", newGenerationRelation), + ], + ]), + ]); + + const [fact] = await readProcessOwnershipFacts([staleRow], host); + + expect(fact).toMatchObject({ + state: "same-generation", + process: { pid: 101, parentPid: 100, processGroupId: 100 }, + identityBefore: { relation: newGenerationRelation }, + identityAfter: { relation: newGenerationRelation }, + }); + expect(fact?.identityAfter).toMatchObject({ + relation: { parentPid: 1, processGroupId: 102 }, + }); +}); + +it("retains both native relations when group membership changes during the read", async () => { + const staleRow: ProcessTableEntry = { + ...row, + pid: 101, + parentPid: 100, + processGroupId: 100, + }; + const beforeRelation = { + state: "observed", + parentPid: 100, + processGroupId: 100, + } as const; + const afterRelation = { + state: "observed", + parentPid: 1, + processGroupId: 102, + } as const; + const host = hostFor([ + new Map([ + [staleRow.pid, identity("start:same", "non-zombie", beforeRelation)], + ]), + new Map([ + [staleRow.pid, identity("start:same", "non-zombie", afterRelation)], + ]), + ]); + + const [fact] = await readProcessOwnershipFacts([staleRow], host); + + expect(fact?.state).toBe("same-generation"); + expect(fact?.process).toEqual(staleRow); + expect(fact?.identityBefore).toMatchObject({ relation: beforeRelation }); + expect(fact?.identityAfter).toMatchObject({ relation: afterRelation }); +}); + +it("retains owned generations across retries after their token becomes unreadable", () => { + const owner = new ProcessOwnershipEvidenceOwner(); + const owned: ProcessOwnershipFact = { + process: row, + state: "same-generation", + identityBefore: identity("start:owned"), + token: { state: "readable", runId: "run" }, + identityAfter: identity("start:owned"), + }; + owner.reconcile([owned], { + runId: "run", + listedRows: 1, + listedPids: new Set([row.pid]), + leaderPid: 1, + processGroupIds: new Set([row.processGroupId]), + }); + + const later = { + ...owned, + token: { state: "unavailable", reason: "environment denied" }, + identityBefore: identity("start:owned"), + identityAfter: identity("start:owned", "non-zombie", { + state: "observed", + parentPid: 1, + processGroupId: 55, + }), + } satisfies ProcessOwnershipFact; + const retained = owner.reconcile([later], { + runId: "run", + listedRows: 0, + listedPids: new Set(), + leaderPid: 1, + processGroupIds: new Set([row.processGroupId]), + }); + + expect(retained).toMatchObject([ + { + pid: row.pid, + identity: "start:owned", + applicability: "token-verified", + relation: { parentPid: 1, processGroupId: 55 }, + }, + ]); + expect(owner.coverage).toMatchObject({ + listedRows: 0, + retainedOnlyPids: 1, + retainedOwned: 1, + processTableCoverage: "unreported-by-host", + }); +}); + +it("upgrades an identity-unresolved candidate only after a same-generation token match", () => { + const owner = new ProcessOwnershipEvidenceOwner(); + const options = { + runId: "run", + listedRows: 1, + listedPids: new Set([row.pid]), + leaderPid: 42, + processGroupIds: new Set([row.processGroupId]), + }; + const unavailable: ProcessIdentityObservation = { + state: "unavailable", + reason: "native identity denied", + }; + const first: ProcessOwnershipFact = { + process: row, + state: "unavailable", + identityBefore: unavailable, + token: { state: "unavailable", reason: "EPERM" }, + identityAfter: unavailable, + }; + expect(owner.reconcile([first], options)).toMatchObject([ + { + pid: row.pid, + identity: undefined, + applicability: "identity-unresolved", + }, + ]); + + const current: ProcessOwnershipFact = { + process: row, + state: "same-generation", + identityBefore: identity("start:current"), + token: { state: "readable", runId: "run" }, + identityAfter: identity("start:current"), + }; + expect(owner.reconcile([current], options)).toMatchObject([ + { + pid: row.pid, + identity: "start:current", + applicability: "token-verified", + scopeEvidence: "run-token", + }, + ]); +}); + +it("lets a successful native absence snapshot retire a token-matching old generation", () => { + const owner = new ProcessOwnershipEvidenceOwner(); + const owned: ProcessOwnershipFact = { + process: row, + state: "same-generation", + identityBefore: identity("start:owned"), + token: { state: "readable", runId: "run" }, + identityAfter: identity("start:owned"), + }; + const options = { + runId: "run", + listedRows: 1, + listedPids: new Set([row.pid]), + leaderPid: 1, + processGroupIds: new Set([row.processGroupId]), + }; + owner.reconcile([owned], options); + + const snapshotAbsence: ProcessIdentityObservation = { + state: "absent", + reason: "not-in-snapshot", + }; + const contradictory = { + ...owned, + state: "absent" as const, + identityBefore: snapshotAbsence, + identityAfter: snapshotAbsence, + } satisfies ProcessOwnershipFact; + + expect(owner.reconcile([contradictory], options)).toEqual([]); + expect(owner.coverage.retainedOwned).toBe(0); +}); + +it("retains a newly verified replacement generation after retiring the old one", () => { + const owner = new ProcessOwnershipEvidenceOwner(); + const options = { + runId: "run", + listedRows: 1, + listedPids: new Set([row.pid]), + leaderPid: 1, + processGroupIds: new Set([row.processGroupId]), + }; + owner.reconcile( + [ + { + process: row, + state: "same-generation", + identityBefore: identity("start:old"), + token: { state: "readable", runId: "run" }, + identityAfter: identity("start:old"), + }, + ], + options, + ); + + const replacement: ProcessOwnershipFact = { + process: row, + state: "same-generation", + identityBefore: identity("start:new"), + token: { state: "readable", runId: "run" }, + identityAfter: identity("start:new"), + }; + expect(owner.reconcile([replacement], options)).toMatchObject([ + { + pid: row.pid, + identity: "start:new", + applicability: "token-verified", + scopeEvidence: "run-token", + }, + ]); +}); + +it("retains only related unresolved rows and retires them on replacement", () => { + const owner = new ProcessOwnershipEvidenceOwner(); + const candidate: ProcessTableEntry = { + ...row, + pid: 77, + parentPid: 42, + processGroupId: 55, + }; + const unreadable: ProcessOwnershipFact = { + process: candidate, + state: "same-generation", + identityBefore: identity("start:maybe-owned", "non-zombie", { + state: "observed", + parentPid: 42, + processGroupId: 55, + }), + token: { state: "unavailable", reason: "permission denied" }, + identityAfter: identity("start:maybe-owned", "non-zombie", { + state: "observed", + parentPid: 42, + processGroupId: 55, + }), + }; + const options = { + runId: "run", + listedRows: 1, + listedPids: new Set([77]), + leaderPid: 42, + processGroupIds: new Set([42]), + }; + expect(owner.reconcile([unreadable], options)).toMatchObject([ + { + pid: 77, + applicability: "related-ownership-unresolved", + scopeEvidence: "capture-leader-pid", + }, + ]); + + const reparented = { + ...unreadable, + process: { ...candidate, parentPid: 1, processGroupId: 100 }, + identityBefore: identity("start:maybe-owned", "non-zombie", { + state: "observed", + parentPid: 1, + processGroupId: 100, + }), + identityAfter: identity("start:maybe-owned", "non-zombie", { + state: "observed", + parentPid: 1, + processGroupId: 100, + }), + } satisfies ProcessOwnershipFact; + expect( + owner.reconcile([reparented], { + ...options, + listedRows: 0, + listedPids: new Set(), + }), + ).toMatchObject([ + { + pid: 77, + applicability: "related-ownership-unresolved", + scopeEvidence: "previously-related-generation", + relation: { processGroupId: 100 }, + }, + ]); + + const replacement = { + ...reparented, + state: "replaced" as const, + identityAfter: identity("start:replacement"), + }; + expect(owner.reconcile([replacement], options)).toEqual([]); +}); + +it("requires a retained owned parent to be present as the same native generation", () => { + const owner = new ProcessOwnershipEvidenceOwner(); + const parent: ProcessTableEntry = { + ...row, + pid: 70, + parentPid: 1, + processGroupId: 70, + }; + const child: ProcessTableEntry = { + ...row, + pid: 71, + parentPid: 70, + processGroupId: 71, + }; + const parentFact: ProcessOwnershipFact = { + process: parent, + state: "same-generation", + identityBefore: identity("start:parent", "non-zombie", { + state: "observed", + parentPid: 1, + processGroupId: 70, + }), + token: { state: "readable", runId: "run" }, + identityAfter: identity("start:parent", "non-zombie", { + state: "observed", + parentPid: 1, + processGroupId: 70, + }), + }; + const options = { + runId: "run", + listedRows: 1, + listedPids: new Set([parent.pid]), + leaderPid: 42, + processGroupIds: new Set([42]), + }; + owner.reconcile([parentFact], options); + + const unreadableChild: ProcessOwnershipFact = { + process: child, + state: "same-generation", + identityBefore: identity("start:child", "non-zombie", { + state: "observed", + parentPid: 70, + processGroupId: 71, + }), + token: { state: "unavailable", reason: "permission denied" }, + identityAfter: identity("start:child", "non-zombie", { + state: "observed", + parentPid: parent.pid, + processGroupId: child.processGroupId, + }), + }; + expect( + owner.reconcile([unreadableChild], { + ...options, + listedRows: 1, + listedPids: new Set([child.pid]), + }), + ).toMatchObject([{ pid: parent.pid, applicability: "token-verified" }]); + + const currentParent = { + ...parentFact, + token: { state: "unavailable", reason: "permission denied" }, + } satisfies ProcessOwnershipFact; + expect( + owner.reconcile([currentParent, unreadableChild], { + ...options, + listedRows: 2, + listedPids: new Set([parent.pid, child.pid]), + }), + ).toMatchObject([ + { pid: parent.pid, applicability: "token-verified" }, + { + pid: child.pid, + applicability: "related-ownership-unresolved", + scopeEvidence: "current-token-owned-parent", + }, + ]); +}); + +it("does not retain unrelated transient rows and reports table coverage as unknown", () => { + const owner = new ProcessOwnershipEvidenceOwner(); + const unrelated: ProcessOwnershipFact = { + process: { ...row, pid: 88, parentPid: 1, processGroupId: 88 }, + state: "same-generation", + identityBefore: identity("start:unrelated", "non-zombie", { + state: "observed", + parentPid: 1, + processGroupId: 88, + }), + token: { state: "unavailable", reason: "temporary permission error" }, + identityAfter: identity("start:unrelated", "non-zombie", { + state: "observed", + parentPid: 1, + processGroupId: 88, + }), + }; + expect( + owner.reconcile([unrelated], { + runId: "run", + listedRows: 1, + listedPids: new Set([88]), + leaderPid: 42, + processGroupIds: new Set([42]), + }), + ).toEqual([]); + expect(owner.coverage).toMatchObject({ + listedRows: 1, + examinedPids: 1, + retainedUnresolved: 0, + processTableCoverage: "unreported-by-host", + }); +}); + +it("preserves the caller's cancellation reason after the final identity read", async () => { + const controller = new AbortController(); + const reason = new Error("caller stopped final identity read"); + let identityRead = 0; + const host: ProcessOwnershipHost = { + ...hostFor([ + new Map([[row.pid, identity("start:42")]]), + new Map([[row.pid, identity("start:42")]]), + ]), + processIdentities: async () => { + identityRead += 1; + if (identityRead === 2) controller.abort(reason); + return new Map([[row.pid, identity("start:42")]]); + }, + }; + await expect( + readProcessOwnershipFacts([row], host, controller.signal), + ).rejects.toBe(reason); +}); diff --git a/src/process/ProcessOwnershipObservation.ts b/src/process/ProcessOwnershipObservation.ts index b2db045e2a..592833855d 100644 --- a/src/process/ProcessOwnershipObservation.ts +++ b/src/process/ProcessOwnershipObservation.ts @@ -1,4 +1,5 @@ import { readFile } from "node:fs/promises"; +import { parseLinuxProcessStat } from "./LinuxProcessStat.js"; import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js"; import { launcherIdentityFailure } from "./ProcessOwnershipIdentity.js"; @@ -15,9 +16,12 @@ import type { ProcessLineageObservation, ProcessOwnershipHost, ProcessIdentityObservation, + ProcessOwnershipFact, ProcessOwnershipBaseline, + ProcessRunTokenObservation, ProcessTableEntry, } from "./ProcessOwnership.js"; +import type { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js"; /** Observe multiple groups from one process snapshot and one token batch. */ export const observeOwnedProcessGroups = async ( @@ -25,6 +29,7 @@ export const observeOwnedProcessGroups = async ( processGroupIds: readonly number[], host: ProcessOwnershipHost = systemProcessOwnershipHost, signal?: AbortSignal, + evidenceOwner?: ProcessOwnershipEvidenceOwner, ): Promise> => { signal?.throwIfAborted(); const groupIds = [...new Set(processGroupIds)]; @@ -41,6 +46,116 @@ export const observeOwnedProcessGroups = async ( return new Map(groupIds.map((groupId) => [groupId, failure])); } signal?.throwIfAborted(); + if (host.processIdentities !== undefined) { + const observationRows = + evidenceOwner?.rowsForObservation(processes) ?? processes; + const retainedPids = new Set( + evidenceOwner?.candidates.map(({ pid }) => pid) ?? [], + ); + const firstIdentities = await readNativeProcessIdentityMap( + observationRows, + host, + signal, + ); + const uncertainGroups = new Set(); + const candidateProcesses = observationRows.filter((process) => { + if (retainedPids.has(process.pid)) return true; + const identity = firstIdentities.get(process.pid); + if (identity?.state === "readable") { + if ( + identity.lifecycle?.state === "observed" && + identity.lifecycle.lifecycle === "zombie" + ) + return false; + if ( + identity.relation?.state === "observed" && + groupIds.includes(identity.relation.processGroupId) + ) + return true; + if ( + groupIds.includes(process.processGroupId) && + identity.relation?.state !== "observed" + ) + uncertainGroups.add(process.processGroupId); + return groupIds.includes(process.processGroupId); + } + if (groupIds.includes(process.processGroupId)) + uncertainGroups.add(process.processGroupId); + return groupIds.includes(process.processGroupId); + }); + const facts = await readProcessOwnershipFacts( + candidateProcesses, + host, + signal, + { identityBefore: firstIdentities }, + ); + evidenceOwner?.reconcile(facts, { + runId, + listedRows: processes.length, + listedPids: new Set(processes.map(({ pid }) => pid)), + leaderPid: -1, + processGroupIds: new Set(groupIds), + scopeFacts: facts, + }); + const observations = new Map( + groupIds.map((groupId) => [ + groupId, + uncertainGroups.has(groupId) + ? { + state: "unverifiable", + reason: "native process-group membership could not be verified", + } + : { state: "empty" }, + ]), + ); + for (const fact of facts) { + if (fact.identityAfter.state === "absent") continue; + const nativeGroupId = + fact.identityAfter.state === "readable" && + fact.identityAfter.relation?.state === "observed" + ? fact.identityAfter.relation.processGroupId + : undefined; + if ( + fact.identityAfter.state === "readable" && + fact.identityAfter.lifecycle?.state === "observed" && + fact.identityAfter.lifecycle.lifecycle === "zombie" + ) + continue; + const candidateGroups = groupIds.filter( + (groupId) => + nativeGroupId === groupId || + (nativeGroupId === undefined && + fact.process.processGroupId === groupId), + ); + for (const groupId of candidateGroups) { + if (observations.get(groupId)?.state === "unverifiable") continue; + if ( + fact.state !== "same-generation" || + fact.identityAfter.state !== "readable" || + nativeGroupId !== groupId + ) { + observations.set(groupId, { + state: "unverifiable", + reason: `native process-group membership could not be revalidated for PID ${String(fact.process.pid)}`, + }); + continue; + } + if (fact.token.state !== "readable" || fact.token.runId !== runId) { + observations.set(groupId, { + state: "unverifiable", + reason: + fact.token.state === "unavailable" + ? `process ownership could not be revalidated for PID ${String(fact.process.pid)}: ${fact.token.reason}` + : "process ownership did not match", + }); + continue; + } + observations.set(groupId, { state: "alive" }); + } + } + signal?.throwIfAborted(); + return observations; + } const membersByGroup = new Map( groupIds.map((groupId) => [groupId, [] as ProcessTableEntry[]]), ); @@ -113,6 +228,8 @@ export const observeOwnedProcessLineage = async ( `process table could not be inspected: ${errorMessage(cause)}`, ); } + if (host.processIdentities !== undefined) + return observeNativeOwnedProcessLineage(ownership, processes, host); const launcher = processes.find(({ pid }) => pid === ownership.leaderPid); if (launcher === undefined) return unavailableLineage(ownership, "owned launcher is not live"); @@ -166,6 +283,177 @@ export const observeOwnedProcessLineage = async ( }; }; +const observeNativeOwnedProcessLineage = async ( + ownership: OwnedProcessGroup, + processes: readonly ProcessTableEntry[], + host: ProcessOwnershipHost, +): Promise => { + const initialIdentities = await readNativeProcessIdentityMap(processes, host); + const launcher = processes.find(({ pid }) => pid === ownership.leaderPid); + if (launcher === undefined) + return unavailableLineage(ownership, "owned launcher is not live"); + const launcherIdentity = initialIdentities.get(launcher.pid); + if (launcherIdentity?.state === "absent") + return unavailableLineage(ownership, "owned launcher is not live"); + if (launcherIdentity?.state !== "readable") + return unavailableLineage( + ownership, + `owned launcher identity could not be read: ${launcherIdentity?.reason ?? "identity observation was omitted"}`, + ); + if ( + launcherIdentity.lifecycle?.state === "observed" && + launcherIdentity.lifecycle.lifecycle === "zombie" + ) + return unavailableLineage(ownership, "owned launcher is not live"); + const nativeLauncher = processWithNativeRelation(launcher, launcherIdentity); + if (nativeLauncher === undefined) + return unavailableLineage( + ownership, + "owned launcher native parent/group relation was unavailable", + ); + const identityFailure = launcherIdentityFailure(nativeLauncher, ownership); + if (identityFailure !== null) + return unavailableLineage(ownership, identityFailure); + + const nativeProcesses = processes.flatMap((process) => { + const identity = initialIdentities.get(process.pid); + if ( + identity?.state === "readable" && + identity.lifecycle?.state === "observed" && + identity.lifecycle.lifecycle === "zombie" + ) + return []; + const nativeProcess = + identity?.state === "readable" + ? processWithNativeRelation(process, identity) + : undefined; + return nativeProcess === undefined ? [] : [nativeProcess]; + }); + const descendants = descendantsOf([ownership.leaderPid], nativeProcesses); + const candidates = [nativeLauncher, ...descendants]; + const facts = await readProcessOwnershipFacts(candidates, host, undefined, { + identityBefore: initialIdentities, + }); + // Preserve each generation's ownership evidence before projecting a lineage. + // A later candidate failure or a smaller future lineage cannot erase a + // token-verified helper that has since detached from this launcher. + ownership.evidenceOwner?.reconcile(facts, { + runId: ownership.runId, + listedRows: processes.length, + listedPids: new Set(processes.map(({ pid }) => pid)), + leaderPid: ownership.leaderPid, + processGroupIds: new Set([ + ownership.processGroupId, + ...(ownership.sampledProcessGroupIds ?? []), + ]), + }); + const factsByPid = new Map(facts.map((fact) => [fact.process.pid, fact])); + const verifiedDescendants: ProcessTableEntry[] = []; + let currentLauncher = nativeLauncher; + for (const member of candidates) { + const fact = factsByPid.get(member.pid); + if (fact === undefined) + return unavailableLineage( + ownership, + `native process facts were unavailable for PID ${String(member.pid)}`, + ); + if (fact.state === "absent") { + if (member.pid === ownership.leaderPid) + return unavailableLineage( + ownership, + "owned launcher exited during lineage validation", + ); + continue; + } + if (fact.state !== "same-generation") + return unavailableLineage( + ownership, + `process identity changed or became unavailable during lineage validation for PID ${String(member.pid)}`, + ); + if ( + fact.identityAfter.state === "readable" && + fact.identityAfter.lifecycle?.state === "observed" && + fact.identityAfter.lifecycle.lifecycle === "zombie" + ) { + if (member.pid === ownership.leaderPid) + return unavailableLineage( + ownership, + "owned launcher exited during lineage validation", + ); + continue; + } + if (fact.token.state !== "readable") + return unavailableLineage( + ownership, + `process ownership could not be revalidated for PID ${String(member.pid)}: ${fact.token.reason}`, + ); + if (fact.token.runId !== ownership.runId) + return unavailableLineage( + ownership, + "process lineage contains an unowned or PID-reused process", + ); + const nativeMember = processWithNativeRelation( + fact.process, + fact.identityAfter, + ); + if (nativeMember === undefined) + return unavailableLineage( + ownership, + `native process relation was unavailable for PID ${String(member.pid)}`, + ); + if (member.pid === ownership.leaderPid) { + const failure = launcherIdentityFailure(nativeMember, ownership); + if (failure !== null) return unavailableLineage(ownership, failure); + currentLauncher = nativeMember; + continue; + } + verifiedDescendants.push(nativeMember); + } + const currentDescendantPids = new Set( + descendantsOf( + [ownership.leaderPid], + [currentLauncher, ...verifiedDescendants], + ).map(({ pid }) => pid), + ); + const moved = verifiedDescendants.find( + ({ pid }) => !currentDescendantPids.has(pid), + ); + if (moved !== undefined) + return unavailableLineage( + ownership, + `process lineage relation changed during validation for PID ${String(moved.pid)}`, + ); + return { + status: "verified", + observedAt: new Date().toISOString(), + lineage: { + runId: ownership.runId, + launcherPid: ownership.leaderPid, + launcherParentPid: currentLauncher.parentPid, + processGroupId: currentLauncher.processGroupId, + descendants: verifiedDescendants + .sort((left, right) => left.pid - right.pid) + .map(({ pid, parentPid, processGroupId }) => ({ + pid, + parentPid, + processGroupId, + })), + }, + }; +}; + +const processWithNativeRelation = ( + process: ProcessTableEntry, + identity: ProcessIdentityObservation, +): ProcessTableEntry | undefined => + identity.state === "readable" && identity.relation?.state === "observed" + ? { + ...process, + parentPid: identity.relation.parentPid, + processGroupId: identity.relation.processGroupId, + } + : undefined; + const unavailableLineage = ( ownership: OwnedProcessGroup, reason: string, @@ -192,20 +480,149 @@ export const parseProcessEnvironment = ( }), ); -/** Read Linux stat field 22, tolerating spaces and closing parentheses in comm. */ -export const parseLinuxProcessStartTime = ( - value: string, -): string | undefined => { - const commandEnd = value.lastIndexOf(")"); - if (commandEnd < 0) return undefined; - const fields = value - .slice(commandEnd + 1) - .trim() - .split(/\s+/u); - const startTime = fields[19]; - return startTime !== undefined && /^\d+$/u.test(startTime) - ? startTime - : undefined; +const identityFact = ( + observations: ReadonlyMap | undefined, + pid: number, + phase: "before" | "after", + failure?: string, +): ProcessIdentityObservation => + observations?.get(pid) ?? { + state: "unavailable", + reason: + failure ?? + `process identity was not returned during ${phase} observation`, + }; + +const readIdentityFacts = async ( + processes: readonly ProcessTableEntry[], + host: ProcessOwnershipHost, + phase: "before" | "after", + signal?: AbortSignal, +): Promise> => { + signal?.throwIfAborted(); + if (host.processIdentities === undefined) + return new Map( + processes.map(({ pid }) => [ + pid, + { + state: "unavailable", + reason: "process identity inspection is unsupported", + } as const, + ]), + ); + try { + const observations = + signal === undefined + ? await host.processIdentities(processes) + : await host.processIdentities(processes, signal); + signal?.throwIfAborted(); + return new Map( + processes.map(({ pid }) => [pid, identityFact(observations, pid, phase)]), + ); + } catch (cause: unknown) { + signal?.throwIfAborted(); + const reason = errorMessage(cause); + return new Map( + processes.map(({ pid }) => [ + pid, + identityFact(undefined, pid, phase, reason), + ]), + ); + } +}; + +/** Read native identities/relations for caller-supplied rows without listing processes. */ +export const readNativeProcessIdentityMap = async ( + processes: readonly ProcessTableEntry[], + host: ProcessOwnershipHost, + signal?: AbortSignal, +): Promise> => + readIdentityFacts(processes, host, "before", signal); + +/** + * Read identity, token, then identity for caller-provided rows. This owner + * never enumerates the process table; a token is tied to a generation only + * when both native identity observations match. + */ +export const readProcessOwnershipFacts = async ( + processes: readonly ProcessTableEntry[], + host: ProcessOwnershipHost, + signal?: AbortSignal, + options: { + readonly identityBefore?: ReadonlyMap; + } = {}, +): Promise => { + signal?.throwIfAborted(); + if (processes.length === 0) return []; + const identityBefore = + options.identityBefore === undefined + ? await readIdentityFacts(processes, host, "before", signal) + : new Map( + processes.map(({ pid }) => [ + pid, + identityFact(options.identityBefore, pid, "before"), + ]), + ); + signal?.throwIfAborted(); + + const tokens = new Map(); + try { + for await (const { process, observation } of readProcessRunTokens( + host, + processes, + signal, + )) { + signal?.throwIfAborted(); + tokens.set(process.pid, observation); + } + } catch (cause: unknown) { + signal?.throwIfAborted(); + const reason = errorMessage(cause); + for (const { pid } of processes) + if (!tokens.has(pid)) tokens.set(pid, { state: "unavailable", reason }); + } + signal?.throwIfAborted(); + + const identityAfter = await readIdentityFacts( + processes, + host, + "after", + signal, + ); + signal?.throwIfAborted(); + const facts = processes.map((process): ProcessOwnershipFact => { + const before = identityBefore.get(process.pid) ?? { + state: "unavailable" as const, + reason: "process identity was not returned during before observation", + }; + const after = identityAfter.get(process.pid) ?? { + state: "unavailable" as const, + reason: "process identity was not returned during after observation", + }; + const token = tokens.get(process.pid) ?? { + state: "unavailable" as const, + reason: "process token was not returned", + }; + const state = + after.state === "absent" + ? "absent" + : before.state === "readable" && after.state === "readable" + ? before.identity === after.identity + ? "same-generation" + : "replaced" + : before.state === "absent" && after.state === "readable" + ? "replaced" + : "unavailable"; + return { + process, + state, + identityBefore: before, + token, + identityAfter: after, + }; + }); + signal?.throwIfAborted(); + return facts; }; const isExpectedAbort = ( @@ -288,26 +705,80 @@ export const createSystemProcessOwnershipHost = ( return new Map( await Promise.all( processes.map(async ({ pid }) => { + if (!Number.isSafeInteger(pid) || pid <= 0) + return [ + pid, + { state: "unavailable", reason: "invalid_process_id" } as const, + ] as const; try { const stat = await readFile(`/proc/${String(pid)}/stat`, { encoding: "utf8", ...(signal === undefined ? {} : { signal }), }); - const identity = parseLinuxProcessStartTime(stat); + const parsed = parseLinuxProcessStat(stat); return [ pid, - identity === undefined + parsed === undefined || parsed.pid !== pid ? ({ state: "unavailable", reason: "malformed_proc_stat", } as const) : ({ state: "readable", - identity: `linux-starttime:${identity}`, + identity: `linux-starttime:${parsed.startTime}`, + lifecycle: { + state: "observed", + lifecycle: parsed.lifecycle, + }, + relation: { + state: "observed", + parentPid: parsed.parentPid, + processGroupId: parsed.processGroupId, + }, } as const), ] as const; } catch (cause: unknown) { if (isExpectedAbort(cause, signal)) throw cause; + if ( + cause instanceof Error && + "code" in cause && + (cause.code === "ENOENT" || cause.code === "ESRCH") + ) { + signal?.throwIfAborted(); + let presenceProbeFailure: unknown; + let presenceProbeSucceeded = false; + try { + presenceProbeSucceeded = process.kill(pid, 0); + } catch (probeCause: unknown) { + presenceProbeFailure = probeCause; + } + signal?.throwIfAborted(); + if ( + presenceProbeFailure instanceof Error && + "code" in presenceProbeFailure && + presenceProbeFailure.code === "ESRCH" + ) + return [ + pid, + { + state: "absent", + reason: "not-found-in-caller-pid-namespace", + } as const, + ] as const; + return [ + pid, + { + state: "unavailable", + reason: + presenceProbeFailure === undefined && + presenceProbeSucceeded + ? "proc_stat_missing_but_process_signal_probe_succeeded" + : presenceProbeFailure === undefined + ? "proc_stat_missing_and_process_signal_probe_returned_false" + : `proc_stat_missing_and_process_signal_probe_failed: ${errorMessage(presenceProbeFailure)}`, + } as const, + ] as const; + } return [ pid, { state: "unavailable", reason: errorMessage(cause) } as const, diff --git a/src/process/ProviderProcess.ts b/src/process/ProviderProcess.ts index 5d8c2623a3..b1a8183f20 100644 --- a/src/process/ProviderProcess.ts +++ b/src/process/ProviderProcess.ts @@ -19,6 +19,7 @@ import { prepareProcessOwnershipInspection, systemProcessOwnershipHost, } from "./ProcessOwnershipObservation.js"; +import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js"; const DEFAULT_TERMINATION_GRACE_MS = 250; const DEFAULT_KILL_GRACE_MS = 1_000; @@ -295,6 +296,7 @@ export const spawnOwnedProviderProcess = async ( leaderPid: pid, processGroupId: pid, expectedParentPid: process.pid, + evidenceOwner: new ProcessOwnershipEvidenceOwner(), ...(sweepTokenOwnedProcesses ? { sweepTokenOwnedProcesses: true, diff --git a/src/process/capture/LinuxProcessSampling.ts b/src/process/capture/LinuxProcessSampling.ts index 67ba0fcad9..1e3800455e 100644 --- a/src/process/capture/LinuxProcessSampling.ts +++ b/src/process/capture/LinuxProcessSampling.ts @@ -1,4 +1,5 @@ import { readFile, readdir } from "node:fs/promises"; +import { parseLinuxProcessStat } from "../LinuxProcessStat.js"; import { mergeSamplingDiagnostics, readProcSamplingSource, @@ -27,43 +28,21 @@ const parseProcStat = ( pid: number, stat: string, ): SampledProcessRow | undefined => { - const commandStart = stat.indexOf("("); - const commandEnd = stat.lastIndexOf(")"); + const parsed = parseLinuxProcessStat(stat); if ( - commandStart < 0 || - commandEnd <= commandStart || - !/^\d+$/u.test(stat.slice(0, commandStart).trim()) || - Number(stat.slice(0, commandStart).trim()) !== pid - ) - return undefined; - const fields = stat - .slice(commandEnd + 2) - .trim() - .split(/\s+/u); - const parentPid = Number(fields[1]); - const processGroupId = Number(fields[2]); - const sessionId = Number(fields[3]); - const startTime = fields[19]; - if ( - !Number.isSafeInteger(pid) || - pid <= 0 || - !Number.isSafeInteger(parentPid) || - parentPid < 0 || - !Number.isSafeInteger(processGroupId) || - processGroupId <= 0 || - !Number.isSafeInteger(sessionId) || - sessionId <= 0 || - startTime === undefined || - !/^\d+$/u.test(startTime) + parsed === undefined || + parsed.pid !== pid || + parsed.processGroupId <= 0 || + parsed.sessionId <= 0 ) return undefined; return { - pid, - parent_pid: parentPid, - process_group_id: processGroupId, - session_id: sessionId, + pid: parsed.pid, + parent_pid: parsed.parentPid, + process_group_id: parsed.processGroupId, + session_id: parsed.sessionId, command: "", - startTime, + startTime: parsed.startTime, }; }; diff --git a/src/process/capture/ProcessCaptureLifecycle.ts b/src/process/capture/ProcessCaptureLifecycle.ts index dd5c393b30..d20e693b28 100644 --- a/src/process/capture/ProcessCaptureLifecycle.ts +++ b/src/process/capture/ProcessCaptureLifecycle.ts @@ -32,16 +32,16 @@ import { import { classifyFilesystemEffects } from "./ProcessFilesystemEffects.js"; import { cleanupOwnedProcessGroup, - verifyNoTokenOwnedProcesses, type OwnedProcessGroup, - type ProcessCleanupResult, type ProcessOwnershipBaseline, + type ProcessOwnershipHost, } from "../ProcessOwnership.js"; import { observeOwnedProcessGroups, prepareProcessOwnershipInspection, systemProcessOwnershipHost, } from "../ProcessOwnershipObservation.js"; +import { ProcessOwnershipEvidenceOwner } from "../ProcessOwnershipEvidence.js"; import { describeProcessCaptureExecutionFailure, normalizeCaptureFailure, @@ -697,15 +697,27 @@ export const createRunManifest = async ( }; }; -export const observeSettlement = async ( - runId: string, - processGroupIds: readonly number[], - settleMs: number, - recordEvent: RecordProcessCaptureEvent = () => undefined, - platform: NodeJS.Platform = process.platform, - signal?: AbortSignal, - onLiveProgress?: () => void, -): Promise => { +export const observeSettlement = async ({ + runId, + processGroupIds, + ownershipEvidence, + settleMs, + recordEvent = () => undefined, + platform = process.platform, + signal, + ownershipHost, + onLiveProgress, +}: { + readonly runId: string; + readonly processGroupIds: readonly number[]; + readonly ownershipEvidence: ProcessOwnershipEvidenceOwner; + readonly settleMs: number; + readonly recordEvent?: RecordProcessCaptureEvent; + readonly platform?: NodeJS.Platform; + readonly signal?: AbortSignal | undefined; + readonly ownershipHost?: ProcessOwnershipHost; + readonly onLiveProgress?: (() => void) | undefined; +}): Promise => { assertNotCancelled(signal); onLiveProgress?.(); if (platform === "win32") { @@ -722,8 +734,9 @@ export const observeSettlement = async ( const observations = await observeOwnedProcessGroups( runId, processGroupIds, - undefined, + ownershipHost, signal, + ownershipEvidence, ); assertNotCancelled(signal); if ( @@ -866,10 +879,10 @@ export const awaitTerminalExit = async ({ Promise.race([ signalTarget(finalizationSignal), delay( - PROCESS_CLEANUP_VERIFICATION_GRACE_MS, + PROCESS_FINALIZATION_DELIVERY_GRACE_MS, { delivery: "unverified" as const, - reason: `delivery did not settle within ${String(PROCESS_CLEANUP_VERIFICATION_GRACE_MS)} ms`, + reason: `delivery did not settle within ${String(PROCESS_FINALIZATION_DELIVERY_GRACE_MS)} ms`, }, { ref: false, @@ -988,7 +1001,7 @@ export const awaitTerminalExit = async ({ const grace = new AbortController(); void Promise.race([ Promise.allSettled(pendingDeliveries), - delay(PROCESS_CLEANUP_VERIFICATION_GRACE_MS, undefined, { + delay(PROCESS_FINALIZATION_DELIVERY_GRACE_MS, undefined, { ref: false, signal: grace.signal, }).catch(() => undefined), @@ -1030,42 +1043,22 @@ export const awaitTerminalExit = async ({ export interface ProcessCaptureCleanupHost { readonly platform: NodeJS.Platform; readonly cleanupProcessGroup: typeof cleanupOwnedProcessGroup; - readonly verifyTokenOwnedProcesses: typeof verifyNoTokenOwnedProcesses; readonly removeTemporaryRoot: (path: string) => Promise; } const processCaptureCleanupHost: ProcessCaptureCleanupHost = { platform: process.platform, cleanupProcessGroup: cleanupOwnedProcessGroup, - verifyTokenOwnedProcesses: verifyNoTokenOwnedProcesses, removeTemporaryRoot: (path) => rm(path, { recursive: true, force: true }), }; -const PROCESS_CLEANUP_VERIFICATION_GRACE_MS = 1_000; -const PROCESS_CLEANUP_VERIFICATION_INTERVAL_MS = 25; - -const verifyTokenOwnedProcessesUntilSettled = async ( - verify: () => Promise, -): Promise => { - const deadline = - monotonicProcessCaptureClock() + PROCESS_CLEANUP_VERIFICATION_GRACE_MS; - let result = await verify(); - while (!result.cleaned) { - const remaining = deadline - monotonicProcessCaptureClock(); - if (remaining <= 0) return result; - await new Promise((resolve) => - setTimeout( - resolve, - Math.min(PROCESS_CLEANUP_VERIFICATION_INTERVAL_MS, remaining), - ), - ); - result = await verify(); - } - return result; -}; +const PROCESS_FINALIZATION_DELIVERY_GRACE_MS = 1_000; const releaseCapturedProcessGroup = async ( - ownership: OwnedProcessGroup, + ownership: Extract< + OwnedProcessGroup, + { readonly sweepTokenOwnedProcesses: true } + >, host: ProcessCaptureCleanupHost, ): Promise => { if (host.platform === "win32") @@ -1085,21 +1078,8 @@ const releaseCapturedProcessGroup = async ( try { const cleaned = await host.cleanupProcessGroup(ownership); unverified.push(...(cleaned.unverified ?? [])); - if (!cleaned.cleaned) { + if (!cleaned.cleaned) outcome = { state: "unverified", reason: cleaned.reason }; - } else { - const verified = await verifyTokenOwnedProcessesUntilSettled(() => - host.verifyTokenOwnedProcesses( - ownership.runId, - undefined, - ownership.captureBaseline, - ownership, - ), - ); - unverified.push(...(verified.unverified ?? [])); - if (!verified.cleaned) - outcome = { state: "unverified", reason: verified.reason }; - } } catch (cause: unknown) { outcome = { state: "failed", @@ -1132,6 +1112,8 @@ export interface ProcessCaptureResourceCleanupOptions { readonly temporaryRoot: string; readonly captureBaseline?: ProcessOwnershipBaseline; readonly sampledProcessGroupIds?: readonly number[]; + /** Shared with settlement observations for this exact captured run. */ + readonly ownershipEvidence?: ProcessOwnershipEvidenceOwner; readonly host?: ProcessCaptureCleanupHost; } @@ -1146,6 +1128,7 @@ export interface ProcessCaptureCleanupOutcome { class ProcessCaptureResourceOwner { readonly cleanupLease: AnalysisCleanupLease; readonly #host: ProcessCaptureCleanupHost; + readonly #ownershipEvidence: ProcessOwnershipEvidenceOwner; #terminalRenderer: | ProcessCaptureCleanupReport["terminal_renderer"] | undefined; @@ -1156,6 +1139,8 @@ class ProcessCaptureResourceOwner { constructor(private readonly options: ProcessCaptureResourceCleanupOptions) { this.#host = options.host ?? processCaptureCleanupHost; + this.#ownershipEvidence = + options.ownershipEvidence ?? new ProcessOwnershipEvidenceOwner(); if (options.renderer === undefined) this.#terminalRenderer = { state: "not_required", reason: null }; if (options.terminal === undefined) @@ -1249,6 +1234,7 @@ class ProcessCaptureResourceOwner { leaderPid: this.options.terminal.pid, processGroupId: this.options.terminal.pid, sweepTokenOwnedProcesses: true, + evidenceOwner: this.#ownershipEvidence, ...(this.options.sampledProcessGroupIds === undefined ? {} : { sampledProcessGroupIds: this.options.sampledProcessGroupIds }), diff --git a/src/process/capture/ProcessCaptureOwnershipEvidence.test.ts b/src/process/capture/ProcessCaptureOwnershipEvidence.test.ts new file mode 100644 index 0000000000..27ee931952 --- /dev/null +++ b/src/process/capture/ProcessCaptureOwnershipEvidence.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it, vi } from "vitest"; +import type { + ProcessIdentityObservation, + ProcessOwnershipHost, + ProcessTableEntry, + ProcessRunTokenObservation, +} from "../ProcessOwnership.js"; +import { observeOwnedProcessGroups } from "../ProcessOwnershipObservation.js"; +import { ProcessOwnershipEvidenceOwner } from "../ProcessOwnershipEvidence.js"; +import { + observeSettlement, + ProcessCaptureResourceScope, +} from "./ProcessCaptureLifecycle.js"; +import { cleanupOwnedProcessGroup } from "../ProcessOwnership.js"; + +const runId = "capture-run"; +const root: ProcessTableEntry = { + pid: 100, + parentPid: 1, + processGroupId: 100, + state: "S", + command: "capture-root", +}; +const helper: ProcessTableEntry = { + pid: 101, + parentPid: 100, + processGroupId: 100, + state: "S", + command: "capture-helper", +}; + +describe("capture ownership evidence lifetime", () => { + it("retains a same-generation helper that moves groups before its token is withheld", async () => { + const owner = new ProcessOwnershipEvidenceOwner(); + let helperMoved = false; + let rootAlive = true; + let helperIdentity = "start-101"; + const processRows = (): readonly ProcessTableEntry[] => [ + ...(rootAlive ? [root] : []), + helperMoved ? { ...helper, parentPid: 1, processGroupId: 200 } : helper, + ]; + const processIdentities = ( + members: readonly ProcessTableEntry[], + ): Promise> => + Promise.resolve( + new Map( + members.map((member) => [ + member.pid, + !rootAlive && member.pid === 100 + ? { + state: "absent" as const, + reason: "not-found-in-caller-pid-namespace" as const, + } + : { + state: "readable" as const, + identity: member.pid === 101 ? helperIdentity : "start-100", + relation: { + state: "observed" as const, + parentPid: member.pid === 100 ? 1 : helperMoved ? 1 : 100, + processGroupId: + member.pid === 100 ? 100 : helperMoved ? 200 : 100, + }, + }, + ]), + ), + ); + const host: ProcessOwnershipHost = { + listProcesses: () => Promise.resolve(processRows()), + environment: () => Promise.resolve({}), + processIdentities, + runTokens: (members) => + Promise.resolve( + new Map( + members.map((member) => [ + member.pid, + member.pid === 101 && helperMoved + ? { + state: "unavailable" as const, + reason: "platform_binary_environment_withheld", + } + : { state: "readable" as const, runId }, + ]), + ), + ), + signalGroup: () => undefined, + }; + + await expect( + observeSettlement({ + runId: runId, + processGroupIds: [100], + ownershipEvidence: owner, + settleMs: 0, + platform: "linux", + ownershipHost: host, + }), + ).resolves.toMatchObject({ state: "alive_at_deadline" }); + expect(owner.candidates.map(({ pid }) => pid)).toEqual([100, 101]); + + helperMoved = true; + await observeOwnedProcessGroups(runId, [100], host, undefined, owner); + + expect(owner.candidates).toContainEqual( + expect.objectContaining({ + pid: 101, + identity: "start-101", + applicability: "token-verified", + relation: { + state: "observed", + parentPid: 1, + processGroupId: 200, + }, + token: { + state: "unavailable", + reason: "platform_binary_environment_withheld", + }, + }), + ); + + rootAlive = false; + const resources = new ProcessCaptureResourceScope(); + const removeTemporaryRoot = vi.fn(async () => undefined); + const cleanup = await resources.release({ + timers: new Set(), + terminal: { pid: 100 }, + renderer: undefined, + runId, + temporaryRoot: "authored-root", + ownershipEvidence: owner, + host: { + platform: "linux", + cleanupProcessGroup: (ownership) => + cleanupOwnedProcessGroup(ownership, host), + removeTemporaryRoot, + }, + }); + expect(cleanup.report.owned_process_group).toMatchObject({ + state: "unverified", + }); + expect(cleanup.report.temporary_root).toMatchObject({ + state: "unverified", + }); + expect(removeTemporaryRoot).not.toHaveBeenCalled(); + + helperIdentity = "replacement-101"; + await observeOwnedProcessGroups(runId, [100], host, undefined, owner); + expect(owner.candidates).toEqual([]); + await resources.close(); + expect(removeTemporaryRoot).toHaveBeenCalledExactlyOnceWith( + "authored-root", + ); + }); +}); diff --git a/src/process/capture/ProcessHarness.test.ts b/src/process/capture/ProcessHarness.test.ts index 6c7ed7cb67..bd7a3c0c77 100644 --- a/src/process/capture/ProcessHarness.test.ts +++ b/src/process/capture/ProcessHarness.test.ts @@ -316,10 +316,6 @@ it("keeps empty cleanup exception messages actionable in the report", async () = cleaned: false, reason: "unused process cleanup", }), - verifyTokenOwnedProcesses: async () => ({ - cleaned: false, - reason: "unused process cleanup", - }), removeTemporaryRoot: async () => { throw new Error(""); }, @@ -339,132 +335,69 @@ it("keeps empty cleanup exception messages actionable in the report", async () = }); }); -it.each(["termination", "verification"] as const)( - "retains observations and retries the temporary root when process %s throws", - async (stage) => { - const cleanupFailure = new Error(`${stage} inspection failed`); - const removeTemporaryRoot = vi.fn(async () => undefined); - let failing = true; - const scope = new ProcessCaptureResourceScope(); - const host: ProcessCaptureCleanupHost = { - platform: "linux", - cleanupProcessGroup: async () => { - if (failing && stage === "termination") throw cleanupFailure; - return { - cleaned: true, - signaled: true, - unverified: failing - ? [{ pid: 900, diagnostic: "environment unavailable" }] - : [], - }; - }, - verifyTokenOwnedProcesses: async () => { - if (failing) throw cleanupFailure; - return { cleaned: true, signaled: false }; - }, - removeTemporaryRoot, - }; - const cleanup = await scope.release({ - timers: new Set(), - terminal: { pid: 321 }, - renderer: undefined, - runId: "fixture-run", - temporaryRoot: "/fixture/root", - host, - }); +it("retains observations and retries the temporary root when process cleanup throws", async () => { + const cleanupFailure = new Error("termination inspection failed"); + const removeTemporaryRoot = vi.fn(async () => undefined); + let failing = true; + const scope = new ProcessCaptureResourceScope(); + const host: ProcessCaptureCleanupHost = { + platform: "linux", + cleanupProcessGroup: async () => { + if (failing) throw cleanupFailure; + return { cleaned: true, signaled: true }; + }, + removeTemporaryRoot, + }; + const cleanup = await scope.release({ + timers: new Set(), + terminal: { pid: 321 }, + renderer: undefined, + runId: "fixture-run", + temporaryRoot: "/fixture/root", + host, + }); - expect(removeTemporaryRoot).not.toHaveBeenCalled(); - expect(cleanup.report.temporary_root).toEqual({ - state: "unverified", - reason: `temporary root retained because owned process cleanup is failed: ${cleanupFailure.message}`, - }); - expect(cleanup.report.owned_process_group).toMatchObject({ - state: "failed", - reason: cleanupFailure.message, - }); - if (stage === "verification") - expect(cleanup.report.owned_process_group.unverified_processes).toEqual([ - { pid: 900, reason: "environment unavailable" }, - ]); - - const executionFailure = new Error("capture snapshot failed"); - const capture = parseProcessCapture({ - ...accountFullyObservedProcessCapture({ - ...emptyProcessCapture(), - frames: [{ sequence: 0, at_ms: 0, data: "collected before failure" }], - }), - event_journal: [], - }); - try { - resolveProcessResult(capture, executionFailure, cleanup); - throw new Error("expected cleanup failure"); - } catch (cause: unknown) { - if (!(cause instanceof ProcessCaptureError)) throw cause; - expect(cause.cause).toBe(executionFailure); - expect( - analysisErrorProjectionSchema.parse(projectAnalysisError(cause)), - ).toMatchObject({ - code: "cleanup_incomplete", - details: { - cleanup_report: cleanup.report, - execution_failure: "capture snapshot failed", - partial_observation: { - capture: { frames: [{ data: "collected before failure" }] }, - }, - }, - }); - } - failing = false; - await scope.retryCleanup(); - expect(removeTemporaryRoot).toHaveBeenCalledExactlyOnceWith( - "/fixture/root", - ); - await scope.close(); - expect(removeTemporaryRoot).toHaveBeenCalledTimes(1); - }, -); - -it("waits for token-owned processes to exit after one cleanup signal", async () => { - vi.useFakeTimers(); + expect(removeTemporaryRoot).not.toHaveBeenCalled(); + expect(cleanup.report.temporary_root).toEqual({ + state: "unverified", + reason: `temporary root retained because owned process cleanup is failed: ${cleanupFailure.message}`, + }); + expect(cleanup.report.owned_process_group).toMatchObject({ + state: "failed", + reason: cleanupFailure.message, + }); + const executionFailure = new Error("capture snapshot failed"); + const capture = parseProcessCapture({ + ...accountFullyObservedProcessCapture({ + ...emptyProcessCapture(), + frames: [{ sequence: 0, at_ms: 0, data: "collected before failure" }], + }), + event_journal: [], + }); try { - let verificationCalls = 0; - const exitsAt = Date.now() + 50; - let cleanupCalls = 0; - const host: ProcessCaptureCleanupHost = { - platform: "linux", - cleanupProcessGroup: async () => { - cleanupCalls += 1; - return { cleaned: true, signaled: true }; + resolveProcessResult(capture, executionFailure, cleanup); + throw new Error("expected cleanup failure"); + } catch (cause: unknown) { + if (!(cause instanceof ProcessCaptureError)) throw cause; + expect(cause.cause).toBe(executionFailure); + expect( + analysisErrorProjectionSchema.parse(projectAnalysisError(cause)), + ).toMatchObject({ + code: "cleanup_incomplete", + details: { + cleanup_report: cleanup.report, + execution_failure: "capture snapshot failed", + partial_observation: { + capture: { frames: [{ data: "collected before failure" }] }, + }, }, - verifyTokenOwnedProcesses: async () => { - verificationCalls += 1; - return Date.now() < exitsAt - ? { cleaned: false, reason: "owned process is exiting" } - : { cleaned: true, signaled: false }; - }, - removeTemporaryRoot: async () => undefined, - }; - const result = releaseProcessResources({ - timers: new Set(), - terminal: { pid: 321 }, - renderer: undefined, - runId: "fixture-run", - temporaryRoot: "/fixture/root", - host, }); - - await vi.runAllTimersAsync(); - const outcome = await result; - - expect(outcome.report.owned_process_group).toEqual({ - state: "cleaned", - reason: null, - }); - expect(cleanupCalls).toBe(1); - expect(verificationCalls).toBeGreaterThan(1); - } finally { - vi.useRealTimers(); } + failing = false; + await scope.retryCleanup(); + expect(removeTemporaryRoot).toHaveBeenCalledExactlyOnceWith("/fixture/root"); + await scope.close(); + expect(removeTemporaryRoot).toHaveBeenCalledTimes(1); }); it("retains unrelated withheld processes through cleanup, capture, and JSON", async () => { @@ -475,13 +408,9 @@ it("retains unrelated withheld processes through cleanup, capture, and JSON", as signaled: true, unverified: [ { pid: 900, diagnostic: "platform_binary_environment_withheld" }, + { pid: 901, diagnostic: "foreign uid: EINVAL" }, ], }), - verifyTokenOwnedProcesses: async () => ({ - cleaned: true, - signaled: false, - unverified: [{ pid: 901, diagnostic: "foreign uid: EINVAL" }], - }), removeTemporaryRoot: async () => undefined, }; const outcome = await releaseProcessResources({ @@ -525,52 +454,6 @@ it("retains unrelated withheld processes through cleanup, capture, and JSON", as } }); -it("retains the last ownership failure when the verification grace expires", async () => { - vi.useFakeTimers(); - try { - let verificationCalls = 0; - let cleanupCalls = 0; - const host: ProcessCaptureCleanupHost = { - platform: "linux", - cleanupProcessGroup: async () => { - cleanupCalls += 1; - return { cleaned: true, signaled: true }; - }, - verifyTokenOwnedProcesses: async () => { - verificationCalls += 1; - return { - cleaned: false, - reason: - verificationCalls === 1 - ? "owned process has not exited yet" - : "owned process remained after identity recheck", - }; - }, - removeTemporaryRoot: async () => undefined, - }; - const result = releaseProcessResources({ - timers: new Set(), - terminal: { pid: 322 }, - renderer: undefined, - runId: "fixture-run", - temporaryRoot: "/fixture/root", - host, - }); - - await vi.runAllTimersAsync(); - const outcome = await result; - - expect(outcome.report.owned_process_group).toEqual({ - state: "unverified", - reason: "owned process remained after identity recheck", - }); - expect(cleanupCalls).toBe(1); - expect(verificationCalls).toBeGreaterThan(1); - } finally { - vi.useRealTimers(); - } -}); - it("passes sampled detached groups into cleanup even when their token is unknown", async () => { let sampledGroups: readonly number[] | undefined; const host: ProcessCaptureCleanupHost = { @@ -582,10 +465,6 @@ it("passes sampled detached groups into cleanup even when their token is unknown reason: "sampled group has no verifiable run token", }; }, - verifyTokenOwnedProcesses: async () => ({ - cleaned: true, - signaled: false, - }), removeTemporaryRoot: async () => undefined, }; diff --git a/src/process/capture/ProcessHarness.ts b/src/process/capture/ProcessHarness.ts index ff80cea6c9..1580a1cb77 100644 --- a/src/process/capture/ProcessHarness.ts +++ b/src/process/capture/ProcessHarness.ts @@ -73,6 +73,7 @@ import { type ProcessCaptureProgressTracker, } from "./ProcessCaptureProgress.js"; import type { ProcessOwnershipBaseline } from "../ProcessOwnership.js"; +import { ProcessOwnershipEvidenceOwner } from "../ProcessOwnershipEvidence.js"; import { observeProcessStartIdentity, signalProcessWithStartIdentity, @@ -88,6 +89,8 @@ interface StartedCaptureRuntime { readonly lastOutput: () => number; readonly terminalRetention: () => TerminalCaptureRetention; readonly stopSampler: ReturnType; + /** One ownership ledger shared by observations and final cleanup for this run. */ + readonly ownershipEvidence: ProcessOwnershipEvidenceOwner; /** Signals the captured root only while its launch-time start identity still matches. */ readonly signalRoot: (signal: "SIGTERM" | "SIGKILL") => Promise<{ readonly delivery: "signaled" | "gone" | "identity-changed" | "unverified"; @@ -168,6 +171,7 @@ const cleanupFailedStartup = async (options: { readonly renderer: TerminalRenderer | undefined; readonly runId: string; readonly captureBaseline: ProcessOwnershipBaseline; + readonly ownershipEvidence: ProcessOwnershipEvidenceOwner; readonly scenario: ProcessScenario; readonly cleanupHost?: ProcessCaptureCleanupHost; readonly observations?: ProcessCaptureObservationBuffer; @@ -201,6 +205,7 @@ const cleanupFailedStartup = async (options: { renderer: options.renderer, runId: options.runId, captureBaseline: options.captureBaseline, + ownershipEvidence: options.ownershipEvidence, ...(sampledProcessGroupIds === undefined ? {} : { sampledProcessGroupIds }), temporaryRoot: options.temporaryRoot, ...(options.cleanupHost === undefined ? {} : { host: options.cleanupHost }), @@ -250,6 +255,7 @@ interface StartCaptureRuntimeOptions { readonly temporaryRoot: string; readonly runId: string; readonly ownershipBaseline: ProcessOwnershipBaseline; + readonly ownershipEvidence: ProcessOwnershipEvidenceOwner; readonly cleanupHost?: ProcessCaptureCleanupHost; readonly resourceScope?: ProcessCaptureResourceScope; readonly observationBuffer: ProcessCaptureObservationBuffer; @@ -268,6 +274,7 @@ const startCaptureRuntime = async ( options: StartCaptureRuntimeOptions, ): Promise => { const { scenario } = options; + const { ownershipEvidence } = options; let renderer: TerminalRenderer | undefined; let terminal: IPty | undefined; try { @@ -335,6 +342,7 @@ const startCaptureRuntime = async ( return { renderer, terminal, + ownershipEvidence, started, startedAt, executableIdentity, @@ -356,6 +364,7 @@ const startCaptureRuntime = async ( renderer, runId: options.runId, captureBaseline: options.ownershipBaseline, + ownershipEvidence, scenario, ...(options.cleanupHost === undefined ? {} @@ -374,6 +383,7 @@ const finishProcessRun = async (options: { readonly timers: Set; readonly runId: string; readonly captureBaseline: ProcessOwnershipBaseline; + readonly ownershipEvidence: ProcessOwnershipEvidenceOwner; readonly cleanupHost?: ProcessCaptureCleanupHost; readonly temporaryRoot: string; readonly scenario: ProcessScenario; @@ -403,6 +413,7 @@ const finishProcessRun = async (options: { renderer: options.runtime?.renderer, runId: options.runId, captureBaseline: options.captureBaseline, + ownershipEvidence: options.ownershipEvidence, ...(sampledProcessGroupIds === undefined ? {} : { sampledProcessGroupIds }), temporaryRoot: options.temporaryRoot, ...(options.cleanupHost === undefined ? {} : { host: options.cleanupHost }), @@ -498,15 +509,19 @@ const completeCapture = async (options: { throw processCaptureCancelled(options.signal?.reason); let settlement: Awaited>; try { - settlement = await observeSettlement( - options.runId, - selectCapturedProcessGroupIds(runtime.terminal.pid, options.samples), - scenario.settle_ms, - options.recordEvent, - options.hostPlatform, - options.signal, - options.onLiveProgress, - ); + settlement = await observeSettlement({ + runId: options.runId, + processGroupIds: selectCapturedProcessGroupIds( + runtime.terminal.pid, + options.samples, + ), + ownershipEvidence: runtime.ownershipEvidence, + settleMs: scenario.settle_ms, + recordEvent: options.recordEvent, + platform: options.hostPlatform, + signal: options.signal, + onLiveProgress: options.onLiveProgress, + }); } catch (cause: unknown) { options.observationBuffer.settlement = { state: "unavailable", @@ -704,6 +719,7 @@ const runProcessScenario = async ( throw cause; } const { temporaryRoot, runId, ownershipBaseline, before } = prepared; + const ownershipEvidence = new ProcessOwnershipEvidenceOwner(); const journal = createProcessCaptureJournal(); const { entries: eventJournal, recordEvent } = journal; recordEvent("filesystem_checkpoints", 0); @@ -730,6 +746,7 @@ const runProcessScenario = async ( temporaryRoot, runId, ownershipBaseline, + ownershipEvidence, ...(cleanupHost === undefined ? {} : { cleanupHost }), ...(resourceScope === undefined ? {} : { resourceScope }), observationBuffer: observations, @@ -844,6 +861,7 @@ const runProcessScenario = async ( timers, runId, captureBaseline: ownershipBaseline, + ownershipEvidence, ...(cleanupHost === undefined ? {} : { cleanupHost }), temporaryRoot, scenario, diff --git a/src/process/capture/ProcessSampling.procReadCoverage.test.ts b/src/process/capture/ProcessSampling.procReadCoverage.test.ts index 9d6ed6849a..e3e425c38b 100644 --- a/src/process/capture/ProcessSampling.procReadCoverage.test.ts +++ b/src/process/capture/ProcessSampling.procReadCoverage.test.ts @@ -29,6 +29,8 @@ const fixture = vi.hoisted(() => ({ readonly read: number; readonly pid?: number; readonly parent?: number; + readonly processGroupId?: number; + readonly sessionId?: number; readonly start?: number; } >(), @@ -165,6 +167,7 @@ vi.mock("../ExecFileOutput.js", () => ({ })); import { startProcessSampler } from "./ProcessSampling.js"; +import { readLinuxProcessStat } from "./LinuxProcessSampling.js"; import type { ProcessSample } from "../../domain/process/processCapture.js"; const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); @@ -195,8 +198,8 @@ const statLine = (pid: number, read: number): string => { String( mutation?.read === read ? (mutation.parent ?? row.parent) : row.parent, ), - "100", - "100", + String(mutation?.read === read ? (mutation.processGroupId ?? 100) : 100), + String(mutation?.read === read ? (mutation.sessionId ?? 100) : 100), ]; while (fields.length < 19) fields.push("0"); fields.push( @@ -269,6 +272,28 @@ const waitFor = async (condition: () => boolean): Promise => { } }; +it("rejects a mismatched stat PID and zero process-group/session IDs at sampling admission", async () => { + setUpFixture(); + fixture.statMutations.set(100, { read: 1, pid: 200 }); + const wrongPid = await readLinuxProcessStat( + 100, + new AbortController().signal, + ); + expect(wrongPid.value).toBeUndefined(); + + setUpFixture(); + fixture.statMutations.set(100, { + read: 1, + processGroupId: 0, + sessionId: 0, + }); + const zeroGroups = await readLinuxProcessStat( + 100, + new AbortController().signal, + ); + expect(zeroGroups.value).toBeUndefined(); +}); + const flushImmediateProcPass = async (): Promise => { await new Promise((resolve) => setTimeout(resolve, 0)); await Promise.resolve(); diff --git a/src/process/ownedDebuggerSession.ts b/src/process/ownedDebuggerSession.ts index 7fc2c0b4f1..8f01dc4ef4 100644 --- a/src/process/ownedDebuggerSession.ts +++ b/src/process/ownedDebuggerSession.ts @@ -1,6 +1,6 @@ import { readFile } from "node:fs/promises"; -import { parseLinuxProcessStartTime } from "./ProcessOwnershipObservation.js"; +import { parseLinuxProcessStat } from "./LinuxProcessStat.js"; /** Result of terminating one debugger process owned by this application. */ export type OwnedDebuggerStopResult = @@ -37,10 +37,10 @@ const readLinuxProcessStartTime: ProcessStartTimeReader = async (pid) => { return null; throw cause; } - const startTime = parseLinuxProcessStartTime(stat); - if (startTime === undefined) + const parsed = parseLinuxProcessStat(stat); + if (parsed === undefined || parsed.pid !== Number(pid)) throw new Error(`Inferior PID ${pid} has an unreadable process identity.`); - return startTime; + return parsed.startTime; }; /** Confirm only that every previously identified inferior process has ended. */ diff --git a/tests/boundary/mcp/processCleanupRetryMcp.test.ts b/tests/boundary/mcp/processCleanupRetryMcp.test.ts index d808e764f9..0dc9ddf476 100644 --- a/tests/boundary/mcp/processCleanupRetryMcp.test.ts +++ b/tests/boundary/mcp/processCleanupRetryMcp.test.ts @@ -30,10 +30,6 @@ const cleanupOptions = ( const host: ProcessCaptureCleanupHost = { platform: process.platform, cleanupProcessGroup: async () => ({ cleaned: true, signaled: false }), - verifyTokenOwnedProcesses: async () => ({ - cleaned: true, - signaled: false, - }), removeTemporaryRoot, }; return { diff --git a/tests/boundary/process/ProcessEvidence.host.test.ts b/tests/boundary/process/ProcessEvidence.host.test.ts index d8a3354df5..8052ad7383 100644 --- a/tests/boundary/process/ProcessEvidence.host.test.ts +++ b/tests/boundary/process/ProcessEvidence.host.test.ts @@ -1,3 +1,4 @@ +import { ProcessOwnershipEvidenceOwner } from "../../../src/process/ProcessOwnershipEvidence.js"; import { expect, it } from "vitest"; import { emptyUnverifiedProcessCapture } from "../../../src/domain/process/processCapture.fixture.js"; @@ -57,13 +58,14 @@ it("stamps process Evidence from the capture manifest host", () => { it("uses the selected host platform for process settlement behavior", async () => { const events: string[] = []; - const result = await observeSettlement( - "run", - [], - 0, - (event) => events.push(event), - "win32", - ); + const result = await observeSettlement({ + runId: "run", + processGroupIds: [], + settleMs: 0, + recordEvent: (event) => events.push(event), + platform: "win32", + ownershipEvidence: new ProcessOwnershipEvidenceOwner(), + }); expect(result).toEqual({ state: "unverifiable", elapsed_ms: 0 }); expect(events).toEqual(["lifecycle"]); diff --git a/tests/boundary/process/darwinProcessRunTokenReader.test.ts b/tests/boundary/process/darwinProcessRunTokenReader.test.ts index ebae535d21..dd7ae558ad 100644 --- a/tests/boundary/process/darwinProcessRunTokenReader.test.ts +++ b/tests/boundary/process/darwinProcessRunTokenReader.test.ts @@ -1230,6 +1230,12 @@ it.skipIf(!onDarwin)( expect(JSON.parse(stdout)).toEqual({ preservedOtherErrno: true, retriedAfterFreshSize: true, + successfulSnapshotMissingPidIsAbsent: true, + failedSnapshotMissingPidIsUnavailable: true, + invalidPidIsUnavailable: true, + zombieLifecycleIsSeparateFromIdentity: true, + effectiveUidComesFromNativeIdentity: true, + nativeRelationIsSeparateFromIdentity: true, }); } finally { await rm(root, { recursive: true, force: true }); diff --git a/tests/boundary/process/processCaptureLifecycle.test.ts b/tests/boundary/process/processCaptureLifecycle.test.ts index b865b7225c..8775431b00 100644 --- a/tests/boundary/process/processCaptureLifecycle.test.ts +++ b/tests/boundary/process/processCaptureLifecycle.test.ts @@ -371,10 +371,6 @@ itWithCaptureCapability( cleaned: false, reason: "fixture cleanup could not be verified", }), - verifyTokenOwnedProcesses: async () => ({ - cleaned: false, - reason: "fixture cleanup could not be verified", - }), removeTemporaryRoot: async (path) => rm(path, { recursive: true, force: true }), }; diff --git a/tests/boundary/process/processCleanupRetryOwnership.test.ts b/tests/boundary/process/processCleanupRetryOwnership.test.ts index 0c8a4877b6..2369c29eed 100644 --- a/tests/boundary/process/processCleanupRetryOwnership.test.ts +++ b/tests/boundary/process/processCleanupRetryOwnership.test.ts @@ -21,10 +21,6 @@ const cleanupOptions = ( const host: ProcessCaptureCleanupHost = { platform: process.platform, cleanupProcessGroup: async () => ({ cleaned: true, signaled: false }), - verifyTokenOwnedProcesses: async () => ({ - cleaned: true, - signaled: false, - }), removeTemporaryRoot, }; return { diff --git a/tests/boundary/process/processHostIdentity.test.ts b/tests/boundary/process/processHostIdentity.test.ts index 995648ab5c..24fa5886f0 100644 --- a/tests/boundary/process/processHostIdentity.test.ts +++ b/tests/boundary/process/processHostIdentity.test.ts @@ -1,3 +1,4 @@ +import { ProcessOwnershipEvidenceOwner } from "../../../src/process/ProcessOwnershipEvidence.js"; import { expect, it } from "vitest"; import { parseProcessScenario } from "../../../src/domain/process/processScenario.js"; @@ -28,13 +29,14 @@ it("uses one selected host identity for manifest and settlement", async () => { observeLaunchedExecutable(scenario.executable, selectedExecutable), selectedHost, ); - const settlement = await observeSettlement( - "run", - [], - 0, - () => undefined, - selectedHost.platform, - ); + const settlement = await observeSettlement({ + runId: "run", + processGroupIds: [], + settleMs: 0, + recordEvent: () => undefined, + platform: selectedHost.platform, + ownershipEvidence: new ProcessOwnershipEvidenceOwner(), + }); expect(manifest).toMatchObject(selectedHost); expect(settlement).toEqual({ state: "unverifiable", elapsed_ms: 0 }); diff --git a/tests/boundary/process/processSessionMcp.test.ts b/tests/boundary/process/processSessionMcp.test.ts index a26f422e6d..06035e9214 100644 --- a/tests/boundary/process/processSessionMcp.test.ts +++ b/tests/boundary/process/processSessionMcp.test.ts @@ -145,7 +145,12 @@ itWithCaptureCapability( }); const equalSize = await capture("b"); - expect(equalSize.truncated).toBe(false); + // This short-lived target can exit before process sampling finishes. + // Assert the terminal coverage that this normalization control exercises. + expect(equalSize.truncation_details.raw_terminal).toMatchObject({ + observed_bytes: Buffer.byteLength(`${"a".repeat(100)}\r\n`), + retained_bytes: Buffer.byteLength(`${"a".repeat(100)}\r\n`), + }); expect(equalSize.frames.map(({ data }) => data).join("")).toBe( `${"b".repeat(100)}\r\n`, ); diff --git a/tests/boundary/process/processSettlementDeadline.test.ts b/tests/boundary/process/processSettlementDeadline.test.ts index eed7975c36..61cadf7141 100644 --- a/tests/boundary/process/processSettlementDeadline.test.ts +++ b/tests/boundary/process/processSettlementDeadline.test.ts @@ -1,3 +1,4 @@ +import { ProcessOwnershipEvidenceOwner } from "../../../src/process/ProcessOwnershipEvidence.js"; import { describe, expect, it } from "vitest"; import { @@ -65,13 +66,14 @@ describe.skipIf(process.platform === "win32")( (await observeGroup(ABSENT_GROUP_ID)).get(ABSENT_GROUP_ID)?.state, ).toBe("empty"); - const settlement = await observeSettlement( - "settlement-single-empty", - [ABSENT_GROUP_ID], - 0, - () => undefined, - "linux", - ); + const settlement = await observeSettlement({ + runId: "settlement-single-empty", + processGroupIds: [ABSENT_GROUP_ID], + settleMs: 0, + recordEvent: () => undefined, + platform: "linux", + ownershipEvidence: new ProcessOwnershipEvidenceOwner(), + }); expect(settlement.state).toBe("unverifiable"); }); @@ -107,13 +109,17 @@ describe.skipIf(process.platform === "win32")( : undefined, ).toBe("alive"); - const settlement = await observeSettlement( - runId, - [launched.ownership.processGroupId], - 0, - () => undefined, - process.platform, - ); + const ownershipEvidence = launched.ownership.evidenceOwner; + if (ownershipEvidence === undefined) + throw new Error("owned spawn omitted ownership evidence"); + const settlement = await observeSettlement({ + runId: runId, + processGroupIds: [launched.ownership.processGroupId], + settleMs: 0, + recordEvent: () => undefined, + platform: process.platform, + ownershipEvidence: ownershipEvidence, + }); expect(settlement.state).toBe("alive_at_deadline"); } finally { await stopOwnedFixture(launched.process, launched.ownership); @@ -121,13 +127,14 @@ describe.skipIf(process.platform === "win32")( }); it("still reports two consecutive empty observations as quiesced", async () => { - const settlement = await observeSettlement( - "settlement-two-empty", - [ABSENT_GROUP_ID], - 1_000, - () => undefined, - "linux", - ); + const settlement = await observeSettlement({ + runId: "settlement-two-empty", + processGroupIds: [ABSENT_GROUP_ID], + settleMs: 1_000, + recordEvent: () => undefined, + platform: "linux", + ownershipEvidence: new ProcessOwnershipEvidenceOwner(), + }); expect(settlement.state).toBe("quiesced"); }); }, diff --git a/tests/boundary/providers/GdbSessionManagerCoverage.test.ts b/tests/boundary/providers/GdbSessionManagerCoverage.test.ts index 04bd741842..9b768dc01b 100644 --- a/tests/boundary/providers/GdbSessionManagerCoverage.test.ts +++ b/tests/boundary/providers/GdbSessionManagerCoverage.test.ts @@ -22,7 +22,7 @@ import type { SpawnedOwnedProviderProcess, } from "../../../src/process/ProviderProcess.js"; import { stopOwnedDebuggerProcess } from "../../../src/process/ownedDebuggerSession.js"; -import { parseLinuxProcessStartTime } from "../../../src/process/ProcessOwnershipObservation.js"; +import { parseLinuxProcessStat } from "../../../src/process/LinuxProcessStat.js"; import { expectGdbSessionStarted } from "./gdbTestHelpers.js"; let directory: string | undefined; @@ -1941,7 +1941,9 @@ it.each([ expect(observedPid).toBe(pid); if (process.platform === "linux") { const stat = await readFile(`/proc/${observedPid}/stat`, "utf8"); - capturedStartTime = parseLinuxProcessStartTime(stat); + const parsed = parseLinuxProcessStat(stat); + capturedStartTime = + parsed?.pid === Number(observedPid) ? parsed.startTime : undefined; } else capturedStartTime = `test-inferior-${observedPid}`; if (capturedStartTime === undefined) throw new Error("Controlled inferior identity was unreadable"); @@ -1951,7 +1953,8 @@ it.each([ if (observedPid !== pid || !inferiorAlive) return null; if (process.platform === "linux") { const stat = await readFile(`/proc/${observedPid}/stat`, "utf8"); - return parseLinuxProcessStartTime(stat) ?? null; + const parsed = parseLinuxProcessStat(stat); + return parsed?.pid === Number(observedPid) ? parsed.startTime : null; } return capturedStartTime ?? null; }, diff --git a/tests/fixtures/processRunTokenSnapshotProbe.swift b/tests/fixtures/processRunTokenSnapshotProbe.swift index bfb2aae7c7..77a758d705 100644 --- a/tests/fixtures/processRunTokenSnapshotProbe.swift +++ b/tests/fixtures/processRunTokenSnapshotProbe.swift @@ -30,9 +30,35 @@ struct ProcessRunTokenSnapshotProbe { return -1 } + let absentIdentities = readProcessIdentities( + pids: [42], + snapshot: ProcessSnapshot(metadata: [:], failure: nil) + ) + let failedIdentities = readProcessIdentities( + pids: [42], + snapshot: ProcessSnapshot(metadata: [:], failure: "process_table_failed_13") + ) + let invalidIdentity = readProcessIdentities( + pids: [0], + snapshot: ProcessSnapshot(metadata: [:], failure: nil) + ) + let zombieIdentity = readProcessIdentities( + pids: [42], + snapshot: ProcessSnapshot( + metadata: [42: ProcessMetadata(identity: "start:42", effectiveUid: 501, lifecycle: "zombie", parentPid: 7, processGroupId: 9, pointerSize: 8)], + failure: nil + ) + ) + let output: [String: Any] = [ "retriedAfterFreshSize": retried.failure == nil && sizeQueries == 2 && readAttempts == 2, - "preservedOtherErrno": otherError.failure == "process_table_failed_\(EACCES)" + "preservedOtherErrno": otherError.failure == "process_table_failed_\(EACCES)", + "successfulSnapshotMissingPidIsAbsent": absentIdentities.first?.state == "absent" && absentIdentities.first?.reason == "not-in-snapshot", + "failedSnapshotMissingPidIsUnavailable": failedIdentities.first?.state == "unavailable" && failedIdentities.first?.reason == "process_table_failed_13", + "invalidPidIsUnavailable": invalidIdentity.first?.state == "unavailable" && invalidIdentity.first?.reason == "invalid_process_id", + "zombieLifecycleIsSeparateFromIdentity": zombieIdentity.first?.state == "readable" && zombieIdentity.first?.identity == "start:42" && zombieIdentity.first?.lifecycle == "zombie", + "effectiveUidComesFromNativeIdentity": zombieIdentity.first?.effective_uid == 501, + "nativeRelationIsSeparateFromIdentity": zombieIdentity.first?.relation?.parent_pid == 7 && zombieIdentity.first?.relation?.process_group_id == 9 ] let data = try JSONSerialization.data(withJSONObject: output, options: [.sortedKeys]) FileHandle.standardOutput.write(data) diff --git a/tests/process-global/processPrelaunchCancellation.test.ts b/tests/process-global/processPrelaunchCancellation.test.ts index ede237e948..a8e4bab411 100644 --- a/tests/process-global/processPrelaunchCancellation.test.ts +++ b/tests/process-global/processPrelaunchCancellation.test.ts @@ -120,9 +120,6 @@ it.each(["none", "before", "open", "final-stat", "close"] as const)( cleanupProcessGroup: async () => { throw new Error("No process should have been acquired"); }, - verifyTokenOwnedProcesses: async () => { - throw new Error("No process should have been acquired"); - }, removeTemporaryRoot: async (path) => { await rm(path, { recursive: true }); removed.push(path);