diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06d3eec8c..6c863aaeb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -327,6 +327,8 @@ jobs: run: npm run test:focused -- tests/boundary/cli/nativeDemangleCli.test.ts tests/boundary/mcp/nativeDemangleMcp.test.ts - name: Process filesystem snapshot descriptor regressions run: npm run test:focused -- src/process/capture/FilesystemSnapshot.test.ts + - name: Native Darwin ownership and process cleanup regressions + run: npm run test:focused -- tests/boundary/process/darwinProcessRunTokenReader.test.ts tests/boundary/process/processCaptureLifecycle.test.ts tests/boundary/cli/processCli.test.ts tests/boundary/mcp/sessionMcp.test.ts reconstruction-readiness: name: Reconstruction readiness diff --git a/bridge/process/ProcessRunTokenReader.swift b/bridge/process/ProcessRunTokenReader.swift index f5c8da490..1438f8157 100644 --- a/bridge/process/ProcessRunTokenReader.swift +++ b/bridge/process/ProcessRunTokenReader.swift @@ -112,6 +112,31 @@ enum RunTokenReadError: Error { case ambiguousEnvironmentBoundary } +@_silgen_name("csops") +private func csops( + _ pid: Int32, + _ ops: UInt32, + _ addr: UnsafeMutableRawPointer?, + _ size: Int +) -> Int32 + +private let csOpsStatus: UInt32 = 0 +private let csPlatformBinary: UInt32 = 0x0400_0000 + +/** + * Whether the target image is an Apple platform binary. Current macOS omits + * the environment of platform binaries from KERN_PROCARGS2 for every caller, + * so this distinguishes a policy-withheld environment from a process whose + * environment is genuinely absent or transiently unreadable. + */ +func isPlatformBinary(pid: Int32) -> Bool { + var flags: UInt32 = 0 + let result = withUnsafeMutablePointer(to: &flags) { pointer in + csops(pid, csOpsStatus, UnsafeMutableRawPointer(pointer), MemoryLayout.size) + } + return result == 0 && (flags & csPlatformBinary) != 0 +} + private func readRunToken(pid: Int32, pointerSize: Int) -> RunTokenObservation { var mib = [CTL_KERN, KERN_PROCARGS2, Int32(pid)] var size = 0 @@ -133,7 +158,12 @@ private func readRunToken(pid: Int32, pointerSize: Int) -> RunTokenObservation { } catch RunTokenReadError.invalidTokenEncoding { return unavailable(pid: pid, reason: "invalid_run_token_encoding") } catch RunTokenReadError.environmentUnavailable { - return unavailable(pid: pid, reason: "environment_unavailable") + return unavailable( + pid: pid, + reason: isPlatformBinary(pid: pid) + ? "platform_binary_environment_withheld" + : "environment_unavailable" + ) } catch RunTokenReadError.appleVectorUnavailable { return unavailable(pid: pid, reason: "apple_vector_unavailable") } catch RunTokenReadError.ambiguousEnvironmentBoundary { diff --git a/docs/process-capture.md b/docs/process-capture.md index 38048a413..78d84bf91 100644 --- a/docs/process-capture.md +++ b/docs/process-capture.md @@ -102,6 +102,12 @@ run the same owned-process cleanup path. Settlement reports whether the sampled process group quiesced or whether cleanup was needed or unverifiable; sampling cannot prove that every short-lived or detached descendant was seen. +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; +it does not attribute those unrelated processes. Related or otherwise unexplained +unreadable processes still prevent successful cleanup. + ## Compare two captures Compare saved capture Evidence with: diff --git a/docs/product-catalog.json b/docs/product-catalog.json index 50b8db1a1..7a83ccb5f 100644 --- a/docs/product-catalog.json +++ b/docs/product-catalog.json @@ -713,9 +713,9 @@ "mcp_prompts": 6 }, "digests": { - "tools_sha256": "18a0131bdea6a7af17c052bc97d343e1f833e1a35218e0098eba9695e9f821a2", + "tools_sha256": "0ac2316e6bd007d273c60f69e132341fc06a3dcb6879408ecfdd3271f30a8230", "prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d", - "combined_sha256": "b40971d45fb56386599e5f8e904c21ee491b20ef23b089ceb28750641eb58ae7", + "combined_sha256": "20cc1432b29f11c20206cbaeacafab927dcfd68c81a82cf198ad058f54f06eac", "providers_sha256": "954b499c76514890062d2b6c936d73cb3a9c45412d522a081125a325e81cdec5" } } diff --git a/docs/verification/managed-conformance-manifest.json b/docs/verification/managed-conformance-manifest.json index 2636b5aaf..e5c28005c 100644 --- a/docs/verification/managed-conformance-manifest.json +++ b/docs/verification/managed-conformance-manifest.json @@ -1,5 +1,5 @@ { - "manifest_id": "ecm_c39e03fbab9c465463c5c8f729512a712af644971fa1d9cdfd245d583a2aa2e6", + "manifest_id": "ecm_bee7251323bddbe561003a45ddee6c43eb9e37839983dc89e438dca6d9e5fdec", "verifier": { "id": "rea-managed-conformance", "version": "1" @@ -13,7 +13,7 @@ "skill_digests": [ { "skill_id": "reverse-engineer-anything", - "sha256": "7542b5f56f36a31aa3a7e75043933747ecf63b6b51ad10ca375fb03b77aca795" + "sha256": "62f197bbd05ca97ff21cc69f45ccf776c152004f3c7f1bd589dfea0b8202db57" } ], "claims": [ diff --git a/scripts/verify-binary-layout.mjs b/scripts/verify-binary-layout.mjs index 8b83d2c29..280ec58f1 100644 --- a/scripts/verify-binary-layout.mjs +++ b/scripts/verify-binary-layout.mjs @@ -1169,7 +1169,7 @@ try { ); const ownershipInspection = ["/usr/bin/ps", "/bin/ps"].includes(match[1]) && - line.includes('["ps", "-axo", "pid=,ppid=,pgid=,stat=,command="]'); + line.includes('["ps", "-axo", "pid=,ppid=,pgid=,uid=,stat=,command="]'); assert.ok( [process.execPath, python].includes(match[1]) || ownershipInspection, `Unexpected attempted host execution: ${line}`, diff --git a/scripts/verify-evm-interface.mjs b/scripts/verify-evm-interface.mjs index 39cb2b356..88c37b649 100644 --- a/scripts/verify-evm-interface.mjs +++ b/scripts/verify-evm-interface.mjs @@ -474,7 +474,9 @@ try { ); const ownershipInspection = ["/usr/bin/ps", "/bin/ps"].includes(match[1]) && - line.includes('["ps", "-axo", "pid=,ppid=,pgid=,stat=,command="]'); + line.includes( + '["ps", "-axo", "pid=,ppid=,pgid=,uid=,stat=,command="]', + ); assert.ok( [ process.execPath, diff --git a/scripts/verify/recorded-crash/trace.mjs b/scripts/verify/recorded-crash/trace.mjs index abe3cecb7..f5f31624b 100644 --- a/scripts/verify/recorded-crash/trace.mjs +++ b/scripts/verify/recorded-crash/trace.mjs @@ -79,7 +79,7 @@ export async function verifyCoreInspectionTrace({ (line) => /^execve\("\/(?:usr\/)?bin\/ps",/u.test(line) && line.includes( - '["ps", "-axo", "pid=,ppid=,pgid=,stat=,command="]', + '["ps", "-axo", "pid=,ppid=,pgid=,uid=,stat=,command="]', ) && line.endsWith(" = 0"), ); @@ -128,7 +128,9 @@ export async function verifyCoreInspectionTrace({ const executable = await realpath(match[1]).catch(() => match[1]); const ownership = psPaths.has(match[1]) && - line.includes('["ps", "-axo", "pid=,ppid=,pgid=,stat=,command="]'); + line.includes( + '["ps", "-axo", "pid=,ppid=,pgid=,uid=,stat=,command="]', + ); const iconv = iconvPaths.has(match[1]) && line.includes('["iconv", "-l"]'); const version = diff --git a/skills/reverse-engineer-anything/SKILL.md b/skills/reverse-engineer-anything/SKILL.md index 60613a101..f97a188a1 100644 --- a/skills/reverse-engineer-anything/SKILL.md +++ b/skills/reverse-engineer-anything/SKILL.md @@ -4,7 +4,7 @@ description: Reverse engineer native, managed, Electron/JavaScript, packaged, fi metadata: version: "33" tool_count: 139 - catalog_digest: "b40971d45fb56386599e5f8e904c21ee491b20ef23b089ceb28750641eb58ae7" + catalog_digest: "20cc1432b29f11c20206cbaeacafab927dcfd68c81a82cf198ad058f54f06eac" --- # REA diff --git a/src/browser/PlaywrightElectronActiveProvider.ts b/src/browser/PlaywrightElectronActiveProvider.ts index 2ca18b421..5aa0ef88c 100644 --- a/src/browser/PlaywrightElectronActiveProvider.ts +++ b/src/browser/PlaywrightElectronActiveProvider.ts @@ -26,6 +26,7 @@ import { type OwnedProcessGroup, type ProcessCleanupResult, type ProcessLineageObservation, + type OwnershipSweepUnverifiedProcess, } from "../process/ProcessOwnership.js"; import { observeOwnedProcessLineage, @@ -238,6 +239,17 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati ), ); if (closeError !== undefined) return err(providerError(closeError)); + if (outcome.ok && cleanup.unverified !== undefined) + outcome = ok({ + ...outcome.value, + limitations: [ + ...outcome.value.limitations, + ...cleanup.unverified.map( + ({ pid, diagnostic }) => + `Ownership of unrelated process ${String(pid)} could not be verified; it was left untouched: ${diagnostic}`, + ), + ], + }); } return outcome; } @@ -280,6 +292,7 @@ const cleanupElectronProcesses = async ( })), ); let signaled = false; + const unverified: OwnershipSweepUnverifiedProcess[] = []; for (const processGroupId of groupIds) { const cleanupOwnership: OwnedProcessGroup = processGroupId === ownership.processGroupId @@ -292,14 +305,38 @@ const cleanupElectronProcesses = async ( const result = await cleanupOwnedProcessGroup(cleanupOwnership); if (!result.cleaned) return result; signaled ||= result.signaled; + unverified.push(...(result.unverified ?? [])); } const remaining = await verifyNoTokenOwnedProcesses( ownership.runId, undefined, ownership.captureBaseline, + { + leaderPid: ownership.leaderPid, + processGroupId: ownership.processGroupId, + ...(ownership.sampledProcessGroupIds === undefined + ? {} + : { sampledProcessGroupIds: ownership.sampledProcessGroupIds }), + }, ); if (!remaining.cleaned) return remaining; - return { cleaned: true, signaled }; + unverified.push(...(remaining.unverified ?? [])); + return { + cleaned: true, + signaled, + ...(unverified.length === 0 + ? {} + : { + unverified: [ + ...new Map( + unverified.map((item) => [ + `${String(item.pid)}:${item.diagnostic}`, + item, + ]), + ).values(), + ], + }), + }; }; const createResult = ( diff --git a/src/domain/process/processCapture.ts b/src/domain/process/processCapture.ts index 7c41ccb16..283706b12 100644 --- a/src/domain/process/processCapture.ts +++ b/src/domain/process/processCapture.ts @@ -223,6 +223,10 @@ export interface UnverifiedProcessCapture { readonly cleanup: { readonly owned_process_group: "verified"; readonly temporary_root: "removed"; + /** Unrelated processes whose ownership token the host could not expose. */ + readonly unverified_processes?: + | readonly UnverifiedCleanupProcess[] + | undefined; }; } @@ -232,10 +236,19 @@ const fileStateShape = { size: z.number().int().nonnegative(), }; +/** An unrelated process left untouched because its ownership is unknown. */ +export interface UnverifiedCleanupProcess { + readonly pid: number; + readonly reason: string; +} + /** A resource cleanup result retained when observations cannot be verified. */ export interface ProcessCaptureResourceCleanup { readonly state: "cleaned" | "failed" | "unverified" | "not_required"; readonly reason: string | null; + readonly unverified_processes?: + | readonly UnverifiedCleanupProcess[] + | undefined; } export interface ProcessCaptureCleanupReport { @@ -348,6 +361,10 @@ const processSettlementSchema = z.discriminatedUnion("state", [ }), ]); +const unverifiedCleanupProcessesSchema = z.array( + z.strictObject({ pid: z.number().int().positive(), reason: z.string() }), +); + const processCaptureShapeSchema = z.strictObject({ manifest: z.strictObject({ rea_version: z.string().min(1), @@ -478,12 +495,14 @@ const processCaptureShapeSchema = z.strictObject({ cleanup: z.object({ owned_process_group: z.literal("verified"), temporary_root: z.literal("removed"), + unverified_processes: unverifiedCleanupProcessesSchema.optional(), }), }); const processCleanupResourceSchema = z.strictObject({ state: z.enum(["cleaned", "failed", "unverified", "not_required"]), reason: z.string().nullable(), + unverified_processes: unverifiedCleanupProcessesSchema.optional(), }); const partialObservationFieldSchema = ( diff --git a/src/process/ProcessOwnership.identity.test.ts b/src/process/ProcessOwnership.identity.test.ts index 406a35e33..3d79b281d 100644 --- a/src/process/ProcessOwnership.identity.test.ts +++ b/src/process/ProcessOwnership.identity.test.ts @@ -99,92 +99,102 @@ describe("capture-local process ownership", () => { expect(signalGroup).toHaveBeenCalledWith(process.processGroupId, "SIGKILL"); }); - it("cleans verified owners before reporting an unrelated opaque process", 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: "unrelated-process", - }; - const malformed: ProcessTableEntry = { - ...opaque, - pid: 907, - processGroupId: 907, - command: "malformed-procargs-process", - }; - const sysctlDenied: ProcessTableEntry = { - ...opaque, - pid: 908, - processGroupId: 908, - command: "sysctl-denied-process", - }; - const signalGroup = vi.fn(); - const host: ProcessOwnershipHost = { - 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) - : ("environment_unavailable" as const), - }, - ]), + 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, - }; + signalGroup, + }; - await expect( - cleanupOwnedProcessGroup( - { - runId: "run-token", - leaderPid: 100, - processGroupId: 100, - sweepTokenOwnedProcesses: true, - }, - host, - ), - ).resolves.toMatchObject({ - cleaned: false, - reason: - "process ownership token could not be read for 3 live process(es): environment_unavailable=1, malformed_procargs=1, sysctl_errno_1=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", - ); - }); + 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", + ); + }, + ); }); describe("capture-local process identity baselines", () => { diff --git a/src/process/ProcessOwnership.part2.test.ts b/src/process/ProcessOwnership.part2.test.ts index ed6552bda..9fed67e43 100644 --- a/src/process/ProcessOwnership.part2.test.ts +++ b/src/process/ProcessOwnership.part2.test.ts @@ -214,4 +214,38 @@ describe("process environment permission diagnostics", () => { expect(signalGroup).not.toHaveBeenCalled(); }, ); + + it.skipIf(typeof process.getuid !== "function")( + "records a foreign-uid unreadable candidate as unverified without signaling", + async () => { + const foreignUid = process.getuid?.() === 0 ? 1 : 0; + const signalGroup = vi.fn(); + const adapter: ProcessOwnershipHost = { + listProcesses: () => + Promise.resolve([ + { + pid: 900, + parentPid: 1, + processGroupId: 900, + uid: foreignUid, + state: "S", + command: "unverifiable-process", + }, + ]), + environment: () => Promise.reject(new Error("EINVAL: sysctl failed")), + signalGroup, + }; + await expect( + cleanupOwnedProcessGroup( + { ...ownership, sweepTokenOwnedProcesses: true }, + adapter, + ), + ).resolves.toMatchObject({ + cleaned: true, + signaled: false, + unverified: [{ pid: 900, diagnostic: "EINVAL: sysctl failed" }], + }); + expect(signalGroup).not.toHaveBeenCalled(); + }, + ); }); diff --git a/src/process/ProcessOwnership.test.ts b/src/process/ProcessOwnership.test.ts index 815d9188c..a2a39f89a 100644 --- a/src/process/ProcessOwnership.test.ts +++ b/src/process/ProcessOwnership.test.ts @@ -67,7 +67,7 @@ describe("owned process-group cleanup discovery", () => { }); describe("opaque process neighbors during cleanup", () => { - it("cleans verified run-owned groups while reporting an opaque neighbor", async () => { + it("cleans verified run-owned groups and records a withheld unrelated neighbor", async () => { const processes = [ { pid: 100, parentPid: 1, processGroupId: 100, command: "capture" }, { @@ -76,6 +76,71 @@ describe("opaque process neighbors during cleanup", () => { processGroupId: 200, command: "detached-child", }, + { + pid: 900, + parentPid: 1, + processGroupId: 900, + 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 adapter: ProcessOwnershipHost = { + platform: "darwin", + listProcesses: () => Promise.resolve(processes), + environment: (pid) => + Promise.resolve({ + REA_PROCESS_RUN_ID: pid === 900 ? "unowned" : "run-token", + }), + processIdentities: () => Promise.resolve(identities), + runTokens: (members) => + Promise.resolve( + new Map( + members.map(({ pid }) => [ + pid, + pid === 900 + ? { + state: "unavailable" as const, + reason: "platform_binary_environment_withheld", + } + : { state: "readable" as const, runId: "run-token" }, + ]), + ), + ), + signalGroup, + }; + + const result = await cleanupOwnedProcessGroup( + { + ...ownership, + sweepTokenOwnedProcesses: true, + captureBaseline: [], + }, + adapter, + ); + + expect(result).toMatchObject({ + cleaned: true, + signaled: true, + unverified: [ + { pid: 900, diagnostic: "platform_binary_environment_withheld" }, + ], + }); + expect(signalGroup.mock.calls).toEqual([ + [100, "SIGKILL"], + [200, "SIGKILL"], + ]); + expect(signalGroup).not.toHaveBeenCalledWith(900, "SIGKILL"); + }); + + it("fails closed for an unrelated unreadable candidate the OS should expose", async () => { + const processes = [ + { pid: 100, parentPid: 1, processGroupId: 100, command: "capture" }, { pid: 900, parentPid: 1, @@ -91,6 +156,7 @@ describe("opaque process neighbors during cleanup", () => { ]), ); const adapter: ProcessOwnershipHost = { + platform: "darwin", listProcesses: () => Promise.resolve(processes), environment: (pid) => Promise.resolve({ @@ -127,14 +193,83 @@ describe("opaque process neighbors during cleanup", () => { cleaned: false, reason: expect.stringContaining("environment_unavailable=1"), }); - expect(signalGroup.mock.calls).toEqual([ - [100, "SIGKILL"], - [200, "SIGKILL"], - ]); + expect(signalGroup.mock.calls).toEqual([[100, "SIGKILL"]]); expect(signalGroup).not.toHaveBeenCalledWith(900, "SIGKILL"); }); }); +describe("related sweep candidates during cleanup", () => { + it.each(["environment_unavailable", "platform_binary_environment_withheld"])( + "fails closed for a related candidate with %s", + async (diagnostic) => { + const processes = [ + { + pid: 100, + parentPid: 1, + processGroupId: 100, + state: "S", + command: "capture", + }, + { + pid: 101, + parentPid: 100, + processGroupId: 100, + state: "S", + uid: process.getuid?.() === 0 ? 1 : 0, + command: "ready-hang", + }, + ]; + const signalGroup = vi.fn(); + const identities = new Map( + processes.map(({ pid }) => [ + pid, + { state: "readable" as const, identity: `start-${String(pid)}` }, + ]), + ); + const adapter: ProcessOwnershipHost = { + listProcesses: () => Promise.resolve(processes), + environment: (pid) => + pid === 100 + ? Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }) + : Promise.reject(new Error("EACCES: permission denied")), + processIdentities: () => Promise.resolve(identities), + runTokens: (members) => + Promise.resolve( + new Map( + members.map(({ pid }) => [ + pid, + pid === 100 + ? { state: "readable" as const, runId: "run-token" } + : { + state: "unavailable" as const, + reason: diagnostic, + }, + ]), + ), + ), + signalGroup, + }; + + const result = await cleanupOwnedProcessGroup( + { + ...ownership, + sweepTokenOwnedProcesses: true, + captureBaseline: [], + }, + adapter, + ); + + expect(result).toMatchObject({ + cleaned: false, + reason: expect.stringContaining( + "process ownership token could not be read", + ), + }); + expect(signalGroup).not.toHaveBeenCalled(); + }, + ); +}); + describe("sampled detached process groups during cleanup", () => { it("signals only token-authenticated sampled groups and reports an unowned sample", async () => { const processes = [ diff --git a/src/process/ProcessOwnership.ts b/src/process/ProcessOwnership.ts index 814009fe3..eff856e6f 100644 --- a/src/process/ProcessOwnership.ts +++ b/src/process/ProcessOwnership.ts @@ -41,6 +41,8 @@ export interface ProcessTableEntry { readonly pid: number; readonly parentPid: number; readonly processGroupId: number; + /** Effective owner when the process table reports it. */ + readonly uid?: number; readonly state: string; readonly command: string; } @@ -123,9 +125,33 @@ interface ProcessOwnershipValidationFailure { readonly diagnostic?: string; } +/** + * One live process the ownership sweep could not attribute or exonerate. + * Recorded as evidence instead of failing the capture: the process is not + * related to the owned tree, so cleanup cannot act on it and the operating + * system withheld or removed its token (for example, macOS strips the + * environment of Apple platform binaries, and foreign-uid processes return + * EINVAL for KERN_PROCARGS2). + */ +export interface OwnershipSweepUnverifiedProcess { + readonly pid: number; + readonly diagnostic: string; +} + +/** Capture-owned process structures used to classify sweep candidates. */ +export interface OwnershipSweepRelation { + readonly leaderPid: number; + readonly processGroupId: number; + readonly sampledProcessGroupIds?: readonly number[]; +} + /** Cleanup outcome with per-member diagnostics when ownership is uncertain. */ export type ProcessCleanupResult = - | { readonly cleaned: true; readonly signaled: boolean } + | { + readonly cleaned: true; + readonly signaled: boolean; + readonly unverified?: readonly OwnershipSweepUnverifiedProcess[]; + } | { readonly cleaned: false; readonly reason: string; @@ -137,6 +163,7 @@ export type ProcessCleanupResult = | "process-identity-unavailable"; readonly diagnostic?: string; }[]; + readonly unverified?: readonly OwnershipSweepUnverifiedProcess[]; }; /** Token-verified liveness result used by post-root-exit settlement. */ @@ -242,8 +269,12 @@ export const cleanupOwnedProcessGroup = async ( } } if (plan.unresolved.length > 0) - return cleanupValidationFailure(plan.unresolved); - return { cleaned: true, signaled }; + return cleanupValidationFailure(plan.unresolved, plan.unverified); + return { + cleaned: true, + signaled, + ...(plan.unverified.length === 0 ? {} : { unverified: plan.unverified }), + }; }; /** Verify that no live process still carries an owned capture run token. */ @@ -251,6 +282,7 @@ export const verifyNoTokenOwnedProcesses = async ( runId: string, host: ProcessOwnershipHost = systemHost, captureBaseline?: ProcessOwnershipBaseline, + relation?: OwnershipSweepRelation, ): Promise => { const processTable = await readLiveProcessTable(host); if (!processTable.available) @@ -263,11 +295,21 @@ export const verifyNoTokenOwnedProcesses = async ( runId, host, captureBaseline, + relation, ); - if (scan.failures.length > 0) return cleanupValidationFailure(scan.failures); - return scan.owned.length === 0 - ? { cleaned: true, signaled: false } - : { cleaned: false, reason: "token-owned process remained after cleanup" }; + if (scan.failures.length > 0) + return cleanupValidationFailure(scan.failures, scan.unverified); + if (scan.owned.length > 0) + return { + cleaned: false, + reason: "token-owned process remained after cleanup", + ...(scan.unverified.length === 0 ? {} : { unverified: scan.unverified }), + }; + return { + cleaned: true, + signaled: false, + ...(scan.unverified.length === 0 ? {} : { unverified: scan.unverified }), + }; }; const readLiveProcessTable = async ( @@ -293,6 +335,7 @@ interface OwnedCleanupPlan { readonly signalOrder: readonly number[]; readonly tokenOwnedIdentities: ReadonlyMap; readonly unresolved: readonly ProcessOwnershipValidationFailure[]; + readonly unverified: readonly OwnershipSweepUnverifiedProcess[]; } const createOwnedCleanupPlan = async ( @@ -313,8 +356,15 @@ const createOwnedCleanupPlan = async ( ownership.runId, host, ownership.captureBaseline, + { + leaderPid: ownership.leaderPid, + processGroupId: ownership.processGroupId, + ...(sampledProcessGroupIds.size === 0 + ? {} + : { sampledProcessGroupIds: [...sampledProcessGroupIds] }), + }, ) - : { owned: [], failures: [], identities: new Map() }; + : { owned: [], failures: [], unverified: [], identities: new Map() }; const tokenOwnedGroupIds = new Set( tokenOwned.owned.map(({ processGroupId }) => processGroupId), ); @@ -335,8 +385,15 @@ const createOwnedCleanupPlan = async ( signalOrder: [], tokenOwnedIdentities: tokenOwned.identities, unresolved: tokenOwned.failures, + unverified: tokenOwned.unverified, } - : { cleaned: true, signaled: false }; + : { + cleaned: true, + signaled: false, + ...(tokenOwned.unverified.length === 0 + ? {} + : { unverified: tokenOwned.unverified }), + }; let descendants: readonly ProcessTableEntry[] = []; if (launcher !== undefined) { const identityFailure = launcherIdentityFailure(launcher, ownership); @@ -413,7 +470,7 @@ const createOwnedCleanupPlan = async ( signalableGroups.add(processGroupId); } if (ownership.sweepTokenOwnedProcesses !== true && unresolved.length > 0) - return cleanupValidationFailure(unresolved); + return cleanupValidationFailure(unresolved, tokenOwned.unverified); return { signalOrder: [...signalableGroups] .filter((groupId) => processGroupIds.has(groupId)) @@ -426,6 +483,7 @@ const createOwnedCleanupPlan = async ( ), tokenOwnedIdentities: tokenOwned.identities, unresolved, + unverified: tokenOwned.unverified, }; }; @@ -478,7 +536,9 @@ const revalidateOwnedProcessGroup = async ( const cleanupValidationFailure = ( failures: readonly ProcessOwnershipValidationFailure[], + unverified: readonly OwnershipSweepUnverifiedProcess[] = [], ): ProcessCleanupResult => { + const unverifiedEntry = unverified.length === 0 ? {} : { unverified }; const unreadable = failures.filter( ({ reason }) => reason === "environment-unreadable", ); @@ -487,12 +547,14 @@ const cleanupValidationFailure = ( cleaned: false, reason: "process tree contains an unowned or PID-reused process", failures, + ...unverifiedEntry, }; if (failures.some(({ reason }) => reason === "process-identity-unavailable")) return { cleaned: false, reason: "process identity could not be revalidated", failures, + ...unverifiedEntry, }; if (unreadable.length > 0) { const counts = new Map(); @@ -508,17 +570,20 @@ const cleanupValidationFailure = ( cleaned: false, reason: `process ownership token could not be read for ${String(unreadable.length)} live process(es): ${breakdown}`, failures, + ...unverifiedEntry, }; } return { cleaned: false, reason: "process ownership could not be revalidated", failures, + ...unverifiedEntry, }; }; const sanitizedTokenReadFailure = (diagnostic: string | undefined): string => { if (diagnostic === "environment_unavailable") return diagnostic; + if (diagnostic === "platform_binary_environment_withheld") return diagnostic; const environmentErrno = /^(EACCES|EPERM|ENOENT|ESRCH):/u.exec( diagnostic ?? "", ); @@ -630,14 +695,50 @@ const processOwnershipFailures = async ( interface TokenOwnedProcessScan { readonly owned: readonly ProcessTableEntry[]; readonly failures: readonly ProcessOwnershipValidationFailure[]; + readonly unverified: readonly OwnershipSweepUnverifiedProcess[]; readonly identities: ReadonlyMap; } +/** One sweep candidate whose run token could not be read. */ +interface UnreadableTokenCandidate { + readonly process: ProcessTableEntry; + readonly diagnostic: string; +} + +const TOKEN_READ_RETRY_ATTEMPTS = 3; +const TOKEN_READ_RETRY_DELAY_MS = 40; + +/** Darwin reports this when the kernel withholds a platform binary's environment. */ +const PLATFORM_BINARY_ENVIRONMENT_WITHHELD = + "platform_binary_environment_withheld"; + +const currentProcessUid = (): number | undefined => + typeof process.getuid === "function" ? process.getuid() : undefined; + +/** + * Whether the operating system provably withheld this candidate's run token: + * current macOS omits the environment of Apple platform binaries from + * KERN_PROCARGS2 and fails with EINVAL for processes owned by another + * account, and neither class can be token-verified by any caller. Such a + * candidate is recorded as unverified instead of failing an otherwise clean + * capture; every other unreadable candidate stays fail-closed. + */ +const environmentWithheldFromInspection = ( + entry: ProcessTableEntry, + diagnostic: string, +): boolean => { + const uid = currentProcessUid(); + if (uid !== undefined && entry.uid !== undefined && entry.uid !== uid) + return true; + return diagnostic === PLATFORM_BINARY_ENVIRONMENT_WITHHELD; +}; + const scanTokenOwnedProcesses = async ( processes: readonly ProcessTableEntry[], runId: string, host: ProcessOwnershipHost, captureBaseline?: ProcessOwnershipBaseline, + relation?: OwnershipSweepRelation, ): Promise => { let candidates = processes; let candidateIdentities: ReadonlyMap = @@ -678,61 +779,98 @@ const scanTokenOwnedProcesses = async ( } 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, + ); + }; let bulkTokens: ReadonlyMap | undefined; if (host.runTokens !== undefined) bulkTokens = await host.runTokens(candidates); - const owned: ProcessTableEntry[] = []; - const ownedIdentities = new Map(); + const unreadable: UnreadableTokenCandidate[] = []; for (const process of candidates) { const bulkToken = bulkTokens?.get(process.pid); if (bulkToken !== undefined) { if (bulkToken.state === "readable") { - if (bulkToken.runId === runId) { - owned.push(process); - const identity = candidateIdentities.get(process.pid); - ownedIdentities.set( - process.pid, - identity?.state === "readable" ? identity.identity : null, - ); - } + if (bulkToken.runId === runId) recordOwned(process); continue; } - if (!(await processIsGone(host, process.pid))) - failures.push({ - pid: process.pid, - reason: "environment-unreadable", - diagnostic: bulkToken.reason, - }); + unreadable.push({ process, diagnostic: bulkToken.reason }); continue; } try { - if ((await host.environment(process.pid)).REA_PROCESS_RUN_ID === runId) { - owned.push(process); - const identity = candidateIdentities.get(process.pid); - ownedIdentities.set( - process.pid, - identity?.state === "readable" ? identity.identity : null, - ); - } + if ((await host.environment(process.pid)).REA_PROCESS_RUN_ID === runId) + recordOwned(process); } catch (cause: unknown) { - try { - const live = liveProcesses(await host.listProcesses()); - if (!live.some(({ pid }) => pid === process.pid)) continue; - } catch (recheckCause: unknown) { - failures.push({ - pid: process.pid, - reason: "environment-unreadable", - diagnostic: `${errorMessage(cause)}; process liveness recheck failed: ${errorMessage(recheckCause)}`, - }); - continue; - } - failures.push({ - pid: process.pid, - reason: "environment-unreadable", - diagnostic: errorMessage(cause), - }); + unreadable.push({ process, diagnostic: errorMessage(cause) }); } } + const remaining = await settleUnreadableTokenCandidates( + unreadable, + runId, + host, + recordOwned, + ); + const relatedPids = new Set(); + const relatedGroupIds = new Set(); + if (relation !== undefined) { + relatedPids.add(relation.leaderPid); + for (const descendant of descendantsOf(relation.leaderPid, processes)) + relatedPids.add(descendant.pid); + relatedGroupIds.add(relation.processGroupId); + for (const processGroupId of relation.sampledProcessGroupIds ?? []) + if (Number.isSafeInteger(processGroupId) && processGroupId > 0) + relatedGroupIds.add(processGroupId); + for (const process of owned) relatedGroupIds.add(process.processGroupId); + } + const unverified: OwnershipSweepUnverifiedProcess[] = []; + const strict: UnreadableTokenCandidate[] = []; + const withheld: UnreadableTokenCandidate[] = []; + for (const candidate of remaining) { + const isRelated = + relation === undefined || + relatedPids.has(candidate.process.pid) || + relatedGroupIds.has(candidate.process.processGroupId); + if ( + !isRelated && + environmentWithheldFromInspection(candidate.process, candidate.diagnostic) + ) { + withheld.push(candidate); + continue; + } + strict.push(candidate); + } + let finalLive: ReadonlySet | undefined; + try { + finalLive = new Set( + liveProcesses(await host.listProcesses()).map(({ pid }) => pid), + ); + } catch (cause: unknown) { + void cause; + finalLive = undefined; + } + for (const candidate of strict) { + if (finalLive !== undefined && !finalLive.has(candidate.process.pid)) + continue; + failures.push({ + pid: candidate.process.pid, + reason: "environment-unreadable", + diagnostic: candidate.diagnostic, + }); + } + for (const candidate of withheld) { + if (finalLive !== undefined && !finalLive.has(candidate.process.pid)) + continue; + unverified.push({ + pid: candidate.process.pid, + diagnostic: candidate.diagnostic, + }); + } if (candidates.length > 0 && host.processIdentities !== undefined) { const afterRead = await host.processIdentities(candidates); const stillOwned: ProcessTableEntry[] = []; @@ -756,9 +894,91 @@ const scanTokenOwnedProcesses = async ( "process identity changed or became unavailable during token validation", }); } - return { owned: stillOwned, failures, identities: ownedIdentities }; + return { + owned: stillOwned, + failures, + unverified, + identities: ownedIdentities, + }; + } + return { owned, failures, unverified, identities: ownedIdentities }; +}; + +/** + * Re-read run tokens for unresolved sweep candidates so transient exec and + * exit states settle before classification. Each attempt revalidates + * liveness, refreshes the diagnostic, and is bounded by a fixed attempt + * budget; a still-unreadable candidate keeps its latest diagnostic. + */ +const settleUnreadableTokenCandidates = async ( + pending: readonly UnreadableTokenCandidate[], + runId: string, + host: ProcessOwnershipHost, + recordOwned: (process: ProcessTableEntry) => void, +): Promise => { + let remaining = pending; + for ( + let attempt = 0; + attempt < TOKEN_READ_RETRY_ATTEMPTS && remaining.length > 0; + attempt += 1 + ) { + await new Promise((resolve) => + setTimeout(resolve, TOKEN_READ_RETRY_DELAY_MS), + ); + let live: ReadonlySet | undefined; + try { + live = new Set( + liveProcesses(await host.listProcesses()).map(({ pid }) => pid), + ); + } catch (cause: unknown) { + void cause; + live = undefined; + } + let observations: + | ReadonlyMap + | undefined; + if (host.runTokens !== undefined) { + try { + observations = await host.runTokens( + remaining.map(({ process }) => process), + ); + } catch (cause: unknown) { + void cause; + observations = undefined; + } + } + const stillUnreadable: UnreadableTokenCandidate[] = []; + for (const candidate of remaining) { + const pid = candidate.process.pid; + if (live !== undefined && !live.has(pid)) continue; + const observation = + observations?.get(pid) ?? (await readEnvironmentRunToken(host, pid)); + if (observation.state === "readable") { + if (observation.runId === runId) recordOwned(candidate.process); + continue; + } + stillUnreadable.push({ + process: candidate.process, + diagnostic: observation.reason, + }); + } + remaining = stillUnreadable; + } + return remaining; +}; + +const readEnvironmentRunToken = async ( + host: ProcessOwnershipHost, + pid: number, +): Promise => { + try { + return { + state: "readable", + runId: (await host.environment(pid)).REA_PROCESS_RUN_ID, + }; + } catch (cause: unknown) { + return { state: "unavailable", reason: errorMessage(cause) }; } - return { owned, failures, identities: ownedIdentities }; }; const processIsGone = async ( diff --git a/src/process/ProcessOwnershipObservation.ts b/src/process/ProcessOwnershipObservation.ts index c33663f02..d47af781c 100644 --- a/src/process/ProcessOwnershipObservation.ts +++ b/src/process/ProcessOwnershipObservation.ts @@ -192,19 +192,22 @@ export const createSystemProcessOwnershipHost = ( if (platform === "win32") return []; const { stdout } = await execFileOutput( "ps", - ["-axo", "pid=,ppid=,pgid=,stat=,command="], + ["-axo", "pid=,ppid=,pgid=,uid=,stat=,command="], { env: hostEnvironment, ...(signal === undefined ? {} : { signal }) }, ); return stdout .split("\n") - .map((line) => /\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)/u.exec(line)) + .map((line) => + /\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)/u.exec(line), + ) .filter((match): match is RegExpExecArray => match !== null) .map((match) => ({ pid: Number(match[1]), parentPid: Number(match[2]), processGroupId: Number(match[3]), - state: match[4] ?? "", - command: match[5] ?? "", + uid: Number(match[4]), + state: match[5] ?? "", + command: match[6] ?? "", })); }; const processIdentities: NonNullable< diff --git a/src/process/capture/ProcessCaptureLifecycle.ts b/src/process/capture/ProcessCaptureLifecycle.ts index 5cd75bf1a..50f9d8e1d 100644 --- a/src/process/capture/ProcessCaptureLifecycle.ts +++ b/src/process/capture/ProcessCaptureLifecycle.ts @@ -704,10 +704,18 @@ export const releaseProcessResources = async (options: { "owned process cleanup is unverifiable on Windows without process-job authority", }; } else { + const terminalPid = options.terminal.pid; + const relation = { + leaderPid: terminalPid, + processGroupId: terminalPid, + ...(options.sampledProcessGroupIds === undefined + ? {} + : { sampledProcessGroupIds: options.sampledProcessGroupIds }), + }; const cleaned = await host.cleanupProcessGroup({ runId: options.runId, - leaderPid: options.terminal.pid, - processGroupId: options.terminal.pid, + leaderPid: terminalPid, + processGroupId: terminalPid, sweepTokenOwnedProcesses: true, ...(options.sampledProcessGroupIds === undefined ? {} @@ -716,6 +724,7 @@ export const releaseProcessResources = async (options: { ? {} : { captureBaseline: options.captureBaseline }), }); + const unverified = [...(cleaned.unverified ?? [])]; if (!cleaned.cleaned) { ownedProcessGroup = { state: "unverified", reason: cleaned.reason }; } else { @@ -724,11 +733,25 @@ export const releaseProcessResources = async (options: { options.runId, undefined, options.captureBaseline, + relation, ), ); + unverified.push(...(verified.unverified ?? [])); if (!verified.cleaned) ownedProcessGroup = { state: "unverified", reason: verified.reason }; } + if (unverified.length > 0) + ownedProcessGroup = { + ...ownedProcessGroup, + unverified_processes: [ + ...new Map( + unverified.map(({ pid, diagnostic }) => [ + `${String(pid)}:${diagnostic}`, + { pid, reason: diagnostic }, + ]), + ).values(), + ], + }; } } let temporaryRoot: ProcessCaptureCleanupReport["temporary_root"] = { @@ -892,7 +915,8 @@ export const resolveProcessResult = ( } if (capture === undefined) throw new ProcessCaptureError("process capture produced no result"); - if ("cleanup" in capture) return capture; + const unverifiedProcesses = cleanup.owned_process_group.unverified_processes; + if ("cleanup" in capture && unverifiedProcesses === undefined) return capture; const settlement: UnverifiedProcessCapture["settlement"] = capture.settlement.state === "quiesced" @@ -902,9 +926,20 @@ export const resolveProcessResult = ( return parseProcessCapture({ ...capture, settlement, + residual_unknowns: [ + ...capture.residual_unknowns, + ...(unverifiedProcesses ?? []).map(({ pid, reason }) => ({ + scope: "process" as const, + reason: `Ownership of unrelated process ${String(pid)} could not be verified; it was left untouched: ${reason}`, + })), + ], cleanup: { + ...("cleanup" in capture ? capture.cleanup : {}), owned_process_group: "verified", temporary_root: "removed", + ...(unverifiedProcesses === undefined + ? {} + : { unverified_processes: unverifiedProcesses }), }, }); } catch (cause: unknown) { diff --git a/src/process/capture/ProcessHarness.test.ts b/src/process/capture/ProcessHarness.test.ts index 4108f6162..961276809 100644 --- a/src/process/capture/ProcessHarness.test.ts +++ b/src/process/capture/ProcessHarness.test.ts @@ -142,6 +142,64 @@ it("waits for token-owned processes to exit after one cleanup signal", async () } }); +it("retains unrelated withheld processes through cleanup, capture, and JSON", async () => { + const host: ProcessCaptureCleanupHost = { + platform: "darwin", + cleanupProcessGroup: async () => ({ + cleaned: true, + signaled: true, + unverified: [ + { pid: 900, diagnostic: "platform_binary_environment_withheld" }, + ], + }), + verifyTokenOwnedProcesses: async () => ({ + cleaned: true, + signaled: false, + unverified: [{ pid: 901, diagnostic: "foreign uid: EINVAL" }], + }), + removeTemporaryRoot: async () => undefined, + }; + const report = await releaseProcessResources({ + timers: new Set(), + terminal: { pid: 321 }, + renderer: undefined, + runId: "fixture-run", + temporaryRoot: "/fixture/root", + host, + }); + const unverified = [ + { pid: 900, reason: "platform_binary_environment_withheld" }, + { pid: 901, reason: "foreign uid: EINVAL" }, + ]; + expect(report.owned_process_group).toEqual({ + state: "cleaned", + reason: null, + unverified_processes: unverified, + }); + const original = emptyProcessCapture(); + const { cleanup: previousCleanup, ...pending } = original; + for (const input of [pending, original]) { + const resolved = resolveProcessResult(input, undefined, report); + const serialized: unknown = JSON.parse(JSON.stringify(resolved)); + const capture = parseProcessCapture(serialized); + expect(capture.cleanup).toEqual({ + ...previousCleanup, + unverified_processes: unverified, + }); + expect(capture.residual_unknowns).toEqual([ + ...original.residual_unknowns, + { + scope: "process", + reason: expect.stringContaining("900 could not be verified"), + }, + { + scope: "process", + reason: expect.stringContaining("901 could not be verified"), + }, + ]); + } +}); + it("retains the last ownership failure when the verification grace expires", async () => { vi.useFakeTimers(); try { @@ -246,6 +304,9 @@ it("retains observations and both causes when process cleanup is unverifiable", state: "unverified" as const, reason: "process ownership token could not be read for 1 live process(es): environment_unavailable=1", + unverified_processes: [ + { pid: 900, reason: "platform_binary_environment_withheld" }, + ], }, terminal_renderer: { state: "cleaned" as const, reason: null }, temporary_root: { state: "cleaned" as const, reason: null }, diff --git a/tests/boundary/mcp/processEvidenceIdentityMcp.test.ts b/tests/boundary/mcp/processEvidenceIdentityMcp.test.ts index 271ca2a7a..c4b709a32 100644 --- a/tests/boundary/mcp/processEvidenceIdentityMcp.test.ts +++ b/tests/boundary/mcp/processEvidenceIdentityMcp.test.ts @@ -31,12 +31,15 @@ const providerNames = [ "REA deterministic process harness", ]; -const captureEvidence = (side: "left" | "right") => +const captureEvidence = ( + side: "left" | "right", + result: Evidence["normalized_result"] = EMPTY_PROCESS_CAPTURE_EXAMPLE, +) => createEvidence(undefined, PROCESS_PROVIDER, { predicateType: "rea.process-capture", operation: "capture_process_scenario", parameters: { side }, - result: EMPTY_PROCESS_CAPTURE_EXAMPLE, + result, confidence: "observed", authority: "controlled-replay", environment: { @@ -335,3 +338,34 @@ it("separately rejects a tampered digest before admission to any consumer or ses }); } }); + +it("keeps withheld ownership unknown through CLI and MCP comparison", async () => { + const { client, right } = await connectedComparison(); + const left = captureEvidence("left", { + ...EMPTY_PROCESS_CAPTURE_EXAMPLE, + cleanup: { + ...EMPTY_PROCESS_CAPTURE_EXAMPLE.cleanup, + unverified_processes: [ + { pid: 900, reason: "platform_binary_environment_withheld" }, + ], + }, + residual_unknowns: [ + { scope: "process", reason: "Ownership of process 900 was withheld" }, + ], + }); + const response = await client.callTool({ + name: "compare_process_captures", + arguments: { left, right }, + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + const comparison = inlineEvidence(response); + expect(comparison.normalized_result).toMatchObject({ status: "unknown" }); + const root = await createTestTempDirectory("rea-withheld-ownership-"); + const leftPath = join(root, "left.json"); + const rightPath = join(root, "right.json"); + await writeFile(leftPath, JSON.stringify(left)); + await writeFile(rightPath, JSON.stringify(right)); + expect( + parseEvidence(await compareProcessEvidenceFiles(leftPath, rightPath)), + ).toEqual(comparison); +});