diff --git a/src/process/ProcessOwnership.part2.test.ts b/src/process/ProcessOwnership.part2.test.ts index 9fed67e43b..af34fde651 100644 --- a/src/process/ProcessOwnership.part2.test.ts +++ b/src/process/ProcessOwnership.part2.test.ts @@ -208,7 +208,7 @@ describe("process environment permission diagnostics", () => { ), ).resolves.toMatchObject({ cleaned: false, - reason: `process ownership token could not be read for 1 live process(es): environment_errno_${code}=1`, + reason: `process ownership token could not be read for 1 live process(es): environment_errno_${code}=1; live candidates 900=environment_errno_${code}`, failures: [{ pid: 900, reason: "environment-unreadable", diagnostic }], }); expect(signalGroup).not.toHaveBeenCalled(); diff --git a/src/process/ProcessOwnership.test.ts b/src/process/ProcessOwnership.test.ts index a2a39f89af..1589cce788 100644 --- a/src/process/ProcessOwnership.test.ts +++ b/src/process/ProcessOwnership.test.ts @@ -193,6 +193,13 @@ describe("opaque process neighbors during cleanup", () => { cleaned: false, reason: expect.stringContaining("environment_unavailable=1"), }); + if (result.cleaned) + throw new Error("Expected unknown ownership to fail closed"); + expect(result.reason).toContain( + "live candidates 900=environment_unavailable", + ); + expect(result.reason).not.toContain("unowned"); + expect(result.reason).not.toContain("run-token"); expect(signalGroup.mock.calls).toEqual([[100, "SIGKILL"]]); expect(signalGroup).not.toHaveBeenCalledWith(900, "SIGKILL"); }); diff --git a/src/process/ProcessOwnership.ts b/src/process/ProcessOwnership.ts index eff856e6f2..ff883c46ac 100644 --- a/src/process/ProcessOwnership.ts +++ b/src/process/ProcessOwnership.ts @@ -566,9 +566,15 @@ const cleanupValidationFailure = ( .sort(([left], [right]) => left.localeCompare(right)) .map(([category, count]) => `${category}=${String(count)}`) .join(", "); + const candidates = unreadable + .map( + ({ pid, diagnostic }) => + `${String(pid)}=${sanitizedTokenReadFailure(diagnostic)}`, + ) + .sort((left, right) => left.localeCompare(right)); return { cleaned: false, - reason: `process ownership token could not be read for ${String(unreadable.length)} live process(es): ${breakdown}`, + reason: `process ownership token could not be read for ${String(unreadable.length)} live process(es): ${breakdown}; live candidates ${candidates.join(", ")}`, failures, ...unverifiedEntry, }; diff --git a/src/process/capture/ProcessHarness.test.ts b/src/process/capture/ProcessHarness.test.ts index 78a3c48ee0..2d4a2832fe 100644 --- a/src/process/capture/ProcessHarness.test.ts +++ b/src/process/capture/ProcessHarness.test.ts @@ -304,7 +304,7 @@ it("retains observations and both causes when process cleanup is unverifiable", owned_process_group: { state: "unverified" as const, reason: - "process ownership token could not be read for 1 live process(es): environment_unavailable=1", + "process ownership token could not be read for 1 live process(es): environment_unavailable=1; live candidates 900=environment_unavailable", unverified_processes: [ { pid: 900, reason: "platform_binary_environment_withheld" }, ], diff --git a/tests/boundary/cli/processCli.test.ts b/tests/boundary/cli/processCli.test.ts index 2b99cd2408..d5c9de51c6 100644 --- a/tests/boundary/cli/processCli.test.ts +++ b/tests/boundary/cli/processCli.test.ts @@ -15,6 +15,7 @@ import { projectProcessCliError, } from "../../../src/application/process/ProcessCli.js"; import { createEvidence } from "../../../src/domain/evidence.js"; +import { ProcessCaptureError } from "../../../src/process/capture/ProcessCaptureError.js"; import { PROCESS_PROVIDER } from "../../../src/application/process/ProcessEvidence.js"; import { INVESTIGATION_EXAMPLES } from "../../../src/contracts/investigationExamples.js"; @@ -229,6 +230,30 @@ describe("process CLI environment key diagnostics", () => { }); describe("process CLI evidence validation", () => { + it("preserves sanitized unknown-process IDs and reasons through CLI projection", () => { + const reason = + "process ownership token could not be read for 1 live process(es): environment_unavailable=1; live candidates 900=environment_unavailable"; + const projected = projectProcessCliError( + new ProcessCaptureError("cleanup incomplete", { + reason: "cleanup_incomplete", + cleanupResources: ["owned_process_group"], + cleanupReport: { + owned_process_group: { state: "unverified", reason }, + terminal_renderer: { state: "cleaned", reason: null }, + temporary_root: { state: "cleaned", reason: null }, + }, + }), + ); + expect(projected).toMatchObject({ + code: "cleanup_incomplete", + details: { + cleanup_report: { + owned_process_group: { reason }, + }, + }, + }); + }); + it("captures the minimal executable-and-arguments scenario", async () => { const root = await fixture(); const scenario = join(root, "scenario.json"); diff --git a/tests/boundary/process/processCaptureLifecycle.test.ts b/tests/boundary/process/processCaptureLifecycle.test.ts index 566c00597e..fdd1bba400 100644 --- a/tests/boundary/process/processCaptureLifecycle.test.ts +++ b/tests/boundary/process/processCaptureLifecycle.test.ts @@ -46,12 +46,14 @@ const captureObservations = ( const partial = result.error.partialObservation; expect(result.error.reason).toBe("cleanup_incomplete"); expect(report?.owned_process_group.state).toBe("unverified"); - const [summary, breakdown] = (report?.owned_process_group.reason ?? "").split( - ": ", - ); + const [summary, diagnostics] = ( + report?.owned_process_group.reason ?? "" + ).split(": "); expect(summary).toMatch( /^process ownership token could not be read for [1-9][0-9]* live process\(es\)$/u, ); + const [breakdown, liveCandidates] = + diagnostics?.split("; live candidates ") ?? []; const categories = breakdown?.split(", ") ?? []; expect(categories.length).toBeGreaterThan(0); const expectedCategory = @@ -59,6 +61,9 @@ const captureObservations = ( ? /^environment_errno_(?:EACCES|EPERM)=[1-9][0-9]*$/u : /^environment_unavailable=[1-9][0-9]*$/u; for (const category of categories) expect(category).toMatch(expectedCategory); + expect(liveCandidates).toMatch( + /^(?:[1-9][0-9]*=(?:environment_unavailable|environment_errno_(?:EACCES|EPERM)))(?:, [1-9][0-9]*=(?:environment_unavailable|environment_errno_(?:EACCES|EPERM)))*$/u, + ); expect(report?.terminal_renderer.state).toBe("cleaned"); expect(report?.temporary_root.state).toBe("cleaned"); if (partial === undefined || !("capture" in partial))