fix(process): centralize capture evidence admission

This commit is contained in:
morluto committed 2026-10-23 10:42:28 +08:00
1 parent 71a024c789
commit 9eb829aca3
11 files changed
+240 -100

No files matched your search

+5 -6
View File
@@ -17,7 +17,7 @@ import {
AnalysisCancelledError,
type AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import { parseProcessCapture } from "../domain/process/processCaptureParsing.js";
import { safeParseProcessCapture } from "../domain/process/processCaptureParsing.js";
import {
bufferedJsonParts,
canonicalJsonParts,
@@ -103,13 +103,12 @@ const readBundle = async (
}
for (const record of bundle.records) {
if (record.predicate_type !== "rea.process-capture") continue;
try {
parseProcessCapture(record.normalized_result);
} catch (cause: unknown) {
const parsedCapture = safeParseProcessCapture(record.normalized_result);
if (!parsedCapture.success) {
return err(
new EvidenceIntegrityError("Evidence bundle validation failed", {
cause,
userMessage: `Process capture record ${record.evidence_id} has an invalid normalized_result (${describeValidationFailure(cause)}). Recreate or re-export the bundle, then try again.`,
cause: parsedCapture.error,
userMessage: `Process capture record ${record.evidence_id} has an invalid normalized_result (${describeValidationFailure(parsedCapture.error)}). Recreate or re-export the bundle, then try again.`,
}),
);
}
@@ -3,16 +3,12 @@ import { digestCanonicalValue } from "../domain/canonicalDigest.js";
import type { Evidence } from "../domain/evidence.js";
import { describeValidationFailure } from "../domain/validationFailure.js";
import { z } from "zod";
import {
parseProcessCapture,
type ProcessCapture,
} from "../domain/process/processCaptureParsing.js";
import type {
ReconstructionObligation,
ReviewedReconstructionObligation,
} from "../domain/reconstructionObligationLedgerSchemas.js";
import { parseApplicationGraphEvidence } from "./javascript/JavaScriptApplicationEvidenceGraph.js";
import { parseProcessCaptureEvidence } from "./process/ProcessEvidence.js";
import {
applicationObligationPolicy,
semanticObligationPolicy,
@@ -181,25 +177,29 @@ const deriveApplicationCandidates = (
};
const deriveProcessCandidate = (
evidence: Evidence,
input: Evidence,
candidates: Map<string, ReconstructionObligationCandidate>,
limitations: Set<string>,
): void => {
if (
evidence.operation !== "capture_process_scenario" ||
evidence.predicate_type !== "rea.process-capture"
)
return;
let capture: ProcessCapture;
try {
capture = parseProcessCapture(evidence.normalized_result);
} catch (cause: unknown) {
if (!(cause instanceof z.ZodError)) throw cause;
limitations.add(
`Process Evidence ${evidence.evidence_id} could not generate candidates: ${describeValidationFailure(cause)}`,
);
const parsed = parseProcessCaptureEvidence(input);
if (!parsed.ok) {
switch (parsed.error.kind) {
case "wrong-workflow":
break;
case "unsupported-provider":
limitations.add(
`Process Evidence ${input.evidence_id} has an unsupported provider; process lifecycle candidates remain unknown.`,
);
break;
case "invalid-evidence":
case "invalid-capture":
limitations.add(
`Process Evidence ${input.evidence_id} could not generate candidates: ${describeValidationFailure(parsed.error.cause)}`,
);
}
return;
}
const { record: evidence, capture } = parsed.value;
const observedCases: ReconstructionObligation["observed_cases"] = [
{
kind: capture.exit.code === 0 ? "positive" : "negative",
+6 -10
View File
@@ -1,5 +1,3 @@
import { z } from "zod";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
import {
@@ -173,13 +171,11 @@ const parseCaptureEvidence = (input: unknown, side: "left" | "right") => {
const failure = parsed.error;
switch (failure.kind) {
case "invalid-evidence":
if (failure.cause instanceof z.ZodError)
throw new AnalysisInputError(
"compare_process_captures",
{ cause: failure.cause },
projectInputIssues(failure.cause.issues, input),
);
throw invalidCaptureEvidence(failure.cause, side);
throw new AnalysisInputError(
"compare_process_captures",
{ cause: failure.cause },
projectInputIssues(failure.cause.issues, input),
);
case "wrong-workflow":
throw processInputFailure(
"compare_process_captures",
@@ -273,7 +269,7 @@ export const PROCESS_UNEXPECTED_FAILURE_REMEDIATION =
"Do not repeat this command. Tell the user the process command could not complete and ask them to check the input files, then run `rea doctor`.";
/** Preserve typed failures and local diagnostics at the process CLI boundary. */
export const projectProcessCliError = (
const projectProcessCliError = (
cause: unknown,
operation: "capture_process_scenario" | "compare_process_captures",
): ProcessCliErrorOutput => {
+11 -12
View File
@@ -1,3 +1,4 @@
import { z } from "zod";
import { posix, win32 } from "node:path";
import type { JsonValue } from "../../domain/jsonValue.js";
@@ -13,7 +14,7 @@ import {
} from "../../domain/process/processEvidenceProvider.js";
import { jsonValueSchema } from "../../domain/jsonValue.js";
import {
parseProcessCapture,
safeParseProcessCapture,
type ProcessCapture,
} from "../../domain/process/processCaptureParsing.js";
import type { ProcessScenario } from "../../domain/process/processScenario.js";
@@ -26,7 +27,7 @@ const PROCESS_CAPTURE_EVIDENCE = {
/** Semantic rejection facts; CLI and MCP retain their own error projections. */
export type ProcessCaptureEvidenceFailure =
| { readonly kind: "invalid-evidence"; readonly cause: unknown }
| { readonly kind: "invalid-evidence"; readonly cause: z.ZodError }
| {
readonly kind: "wrong-workflow";
readonly expected: typeof PROCESS_CAPTURE_EVIDENCE;
@@ -39,7 +40,7 @@ export type ProcessCaptureEvidenceFailure =
readonly kind: "unsupported-provider";
readonly provider: EvidenceProvider;
}
| { readonly kind: "invalid-capture"; readonly cause: unknown };
| { readonly kind: "invalid-capture"; readonly cause: z.ZodError };
/** Authenticate capture Evidence before admitting its workflow, provider and payload. */
export const parseProcessCaptureEvidence = (
@@ -52,7 +53,9 @@ export const parseProcessCaptureEvidence = (
try {
record = parseEvidence(input);
} catch (cause: unknown) {
return err({ kind: "invalid-evidence", cause });
if (cause instanceof z.ZodError)
return err({ kind: "invalid-evidence", cause });
throw cause;
}
if (
record.operation !== PROCESS_CAPTURE_EVIDENCE.operation ||
@@ -65,14 +68,10 @@ export const parseProcessCaptureEvidence = (
});
if (!isProcessEvidenceProvider(record.provider))
return err({ kind: "unsupported-provider", provider: record.provider });
try {
return ok({
record,
capture: parseProcessCapture(record.normalized_result),
});
} catch (cause: unknown) {
return err({ kind: "invalid-capture", cause });
}
const parsedCapture = safeParseProcessCapture(record.normalized_result);
return parsedCapture.success
? ok({ record, capture: parsedCapture.data })
: err({ kind: "invalid-capture", cause: parsedCapture.error });
};
/** Project one process scenario into secret-free Evidence parameters. */
@@ -1,8 +1,12 @@
import { expect, it } from "vitest";
import { expect, expectTypeOf, it } from "vitest";
import { z } from "zod";
import { digestProcessCommitment } from "./processScenario.js";
import { parseProcessCapture } from "./processCaptureParsing.js";
import {
parseProcessCapture,
type ProcessCapture,
} from "./processCaptureParsing.js";
import type { UnverifiedProcessCapture } from "./processCapture.js";
import { partialProcessCaptureObservationSchema } from "./processCapture.js";
import { FINALIZED_PROCESS_CAPTURE_EXAMPLE } from "./processCaptureExample.js";
import { finalizationConsistencyIssue } from "./processCaptureValidation.js";
@@ -13,6 +17,8 @@ import {
processCaptureIssues,
} from "./processCapture.fixture.js";
expectTypeOf<UnverifiedProcessCapture>().not.toExtend<ProcessCapture>();
/** Name every rejected field reported by the production parser. */
const rejectedPaths = (input: unknown): readonly string[] => {
try {
+11 -20
View File
@@ -1,30 +1,21 @@
import {
processCaptureSchema,
type UnverifiedProcessCapture,
} from "./processCapture.js";
import { z } from "zod";
declare class ProcessCaptureProof {
private readonly verified: never;
}
import { processCaptureSchema } from "./processCapture.js";
declare const processCaptureProof: unique symbol;
const verifiedProcessCaptureSchema =
processCaptureSchema.brand<typeof processCaptureProof>();
/** Semantically verified process capture value. */
export type ProcessCapture = UnverifiedProcessCapture & ProcessCaptureProof;
export type ProcessCapture = z.output<typeof verifiedProcessCaptureSchema>;
const verifiedCaptures = new WeakSet<UnverifiedProcessCapture>();
const isVerifiedCapture = (
capture: UnverifiedProcessCapture,
): capture is ProcessCapture => verifiedCaptures.has(capture);
const attachProof = (capture: UnverifiedProcessCapture): ProcessCapture => {
verifiedCaptures.add(capture);
if (isVerifiedCapture(capture)) return capture;
throw new TypeError("Process Capture proof ownership failed");
};
/** Parse a boundary value while retaining Zod's expected validation failure. */
export const safeParseProcessCapture = (input: unknown) =>
verifiedProcessCaptureSchema.safeParse(input);
/**
* Parse unknown input and reject invalid commitments or semantics. Failures
* throw the schema's ZodError so structural and semantic issues stay together.
*/
export const parseProcessCapture = (input: unknown): ProcessCapture =>
attachProof(processCaptureSchema.parse(input));
verifiedProcessCaptureSchema.parse(input);
+1 -2
View File
@@ -216,8 +216,7 @@ const isProcessCaptureEvidence = (evidence: Evidence): boolean => {
return false;
case "invalid-evidence":
case "invalid-capture":
if (parsed.error.cause instanceof z.ZodError) return false;
throw parsed.error.cause;
return false;
}
};
@@ -6,6 +6,7 @@ import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
import { createEvidence } from "../../../src/domain/evidence.js";
import { PROCESS_PROVIDER } from "../../../src/domain/process/processEvidenceProvider.js";
import {
createResidualUnknown,
recordUnknownInputSchema,
@@ -15,6 +16,7 @@ import { createServer } from "../../../src/server/createServer.js";
import {
MIXED_AUTHORITY_SUMMARY,
mixedAuthorityRequest,
processEvidence,
reviewedObligation,
} from "../../../src/application/ReconstructionObligationLedger.fixture.js";
import { observed } from "../../fixtures/analysisExecution.js";
@@ -169,6 +171,66 @@ describe("reconstruction obligation ledger residual graph input", () => {
);
});
describe("reconstruction obligation ledger process provider admission", () => {
it.each([
["current provider", PROCESS_PROVIDER, true],
[
"supported legacy name",
{ ...PROCESS_PROVIDER, name: "REA deterministic process harness" },
true,
],
["foreign id", { ...PROCESS_PROVIDER, id: "foreign-process" }, false],
["unsupported version", { ...PROCESS_PROVIDER, version: "4" }, false],
[
"unsupported name",
{ ...PROCESS_PROVIDER, name: "Other provider" },
false,
],
] as const)(
"preserves process provider admission for %s",
async (_, provider, admitted) => {
const source = processEvidence();
const record = createEvidence(undefined, provider, {
predicateType: source.predicate_type,
operation: source.operation,
parameters: source.parameters,
result: source.normalized_result,
confidence: source.confidence,
authority: source.authority,
});
await withLedgerClient(async (request) => {
const response = await request(createEvidenceBundle([record]));
expect(response.isError).not.toBe(true);
expect(response.structuredContent).toMatchObject({
normalized_result: {
status: admitted ? "open" : "unknown",
summary: { total: admitted ? 1 : 0 },
obligations: admitted
? [
{
family: "packaged-process-lifecycle",
target: { artifact_sha256: "0".repeat(64) },
authority_references: [
{
evidence_id: record.evidence_id,
authority: record.authority,
},
],
},
]
: [],
limitations: admitted
? expect.any(Array)
: expect.arrayContaining([
`Process Evidence ${record.evidence_id} has an unsupported provider; process lifecycle candidates remain unknown.`,
]),
},
});
});
},
);
});
describe("reconstruction obligation ledger MCP parity", () => {
it("rejects noncanonical source bundles before ledger generation", async () => {
const records = ["first", "second"].map((label) =>
-27
View File
@@ -13,10 +13,8 @@ import { cliTest } from "../../support/cli/cliFixture.js";
import {
captureProcessScenarioFile,
compareProcessEvidenceFiles,
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/domain/process/processEvidenceProvider.js";
import { INVESTIGATION_EXAMPLES } from "../../../src/contracts/investigationExamples.js";
import {
@@ -221,31 +219,6 @@ 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",
cleanup: { reason, resources: ["owned_process_group"] },
cleanupReport: {
owned_process_group: { state: "unverified", reason },
terminal_renderer: { state: "cleaned", reason: null },
temporary_root: { state: "cleaned", reason: null },
},
}),
"compare_process_captures",
);
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 createTestTempDirectory("rea-process-cli-");
const scenario = join(root, "scenario.json");
@@ -7,12 +7,44 @@ import { z } from "zod";
import { createCli } from "../../src/cli.js";
import { compareEvidenceBundlesCommand } from "../../src/application/EvidenceBundleCommands.js";
import { bundleComparisonResultSchema } from "../../src/domain/bundleComparison.js";
import { processEvidence } from "../../src/application/ReconstructionObligationLedger.fixture.js";
import { processCaptureSchema } from "../../src/domain/process/processCapture.js";
import {
createEvidenceBundle,
serializeEvidenceBundle,
} from "../../src/domain/evidenceBundle.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
it.each([
new TypeError("capture validation implementation failed"),
new Error("capture validator failed"),
new z.ZodError([
{ code: "custom", path: [], message: "capture refinement failed" },
]),
])(
"preserves unexpected process validation failures while reading a bundle: $name",
async (failure) => {
const directory = await createTestTempDirectory(
"rea-bundle-capture-failure-",
);
const path = join(directory, "bundle.json");
await writeFile(
path,
serializeEvidenceBundle(createEvidenceBundle([processEvidence()])),
);
const validator = vi
.spyOn(processCaptureSchema, "safeParse")
.mockImplementation(() => {
throw failure;
});
onTestFinished(() => validator.mockRestore());
await expect(
compareEvidenceBundlesCommand({ leftPath: path, rightPath: path }),
).rejects.toBe(failure);
},
);
it.each([
new TypeError("comparison output invariant failed"),
new RangeError("comparison computation failed"),
@@ -26,9 +26,11 @@ import { managedMemberComparisonResultSchema } from "../../src/domain/managed/ma
import { managedNativeVerificationResultSchema } from "../../src/domain/managed/managedNativeVerificationSchemas.js";
import { managedReconstructionImportResultSchema } from "../../src/domain/managed/managedReconstruction.js";
import { captureProcessScenarioFile } from "../../src/application/process/ProcessCli.js";
import { compareManagedMemberPaths } from "../../src/application/managed/ManagedMemberComparisonService.js";
import { buildManagedPeFixture } from "../../src/dotnet/ManagedPe.fixture.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { ProcessCaptureError } from "../../src/process/capture/ProcessCaptureError.js";
import { reconstructionObligationLedgerSchema } from "../../src/domain/reconstructionObligationLedgerSchemas.js";
import {
@@ -664,13 +666,13 @@ describe("ledger generation failure ownership through the public SDK", () => {
},
);
it.each(unexpectedResultFailures.filter(([kind]) => kind !== "ZodError"))(
it.each(unexpectedResultFailures)(
"preserves an unexpected %s while deriving process candidates",
async (_label, createFailure) => {
await withSdk(async ({ client, session }) => {
const input = ledgerRequest([processEvidence()]);
const failure = createFailure();
vi.spyOn(processCaptureSchema, "parse").mockImplementation(() => {
vi.spyOn(processCaptureSchema, "safeParse").mockImplementation(() => {
throw failure;
});
const response = await client.callTool({
@@ -684,6 +686,87 @@ describe("ledger generation failure ownership through the public SDK", () => {
);
});
describe("process capture CLI cleanup failure projection", () => {
afterEach(() => vi.restoreAllMocks());
it("preserves sanitized unknown-process IDs and reasons through the capture CLI boundary", async () => {
const root = await createTestTempDirectory("rea-process-cli-cleanup-");
const scenario = join(root, "scenario.json");
await writeFile(
scenario,
JSON.stringify({ executable: process.execPath, arguments: [] }),
);
const reason =
"process ownership token could not be read for 1 live process(es): environment_unavailable=1; live candidates 900=environment_unavailable";
const failure = new ProcessCaptureError("cleanup incomplete", {
reason: "cleanup_incomplete",
cleanup: { reason, resources: ["owned_process_group"] },
cleanupReport: {
owned_process_group: {
state: "unverified",
reason,
unverified_processes: [
{ pid: 900, reason: "environment_unavailable" },
],
},
terminal_renderer: { state: "cleaned", reason: null },
temporary_root: { state: "cleaned", reason: null },
},
});
const processHarness =
await import("../../src/process/capture/ProcessHarness.js");
vi.spyOn(processHarness, "captureProcessScenario").mockResolvedValue({
ok: false,
error: failure,
});
const projected = await captureProcessScenarioFile(scenario);
expect(projected).toMatchObject({
error: "Process command failed",
code: "cleanup_incomplete",
details: {
cleanup_report: {
owned_process_group: {
reason,
unverified_processes: [
{ pid: 900, reason: "environment_unavailable" },
],
},
},
},
});
});
});
describe("process comparison Evidence authentication failures through the public SDK", () => {
afterEach(() => vi.restoreAllMocks());
it.each(unexpectedResultFailures.filter(([kind]) => kind !== "ZodError"))(
"preserves an unexpected %s without retaining Evidence",
async (_label, createFailure) => {
await withSdk(async ({ client, session }) => {
const input = {
left: processEvidence(),
right: processEvidence(),
};
const failure = createFailure();
const evidenceModule = await import("../../src/domain/evidence.js");
vi.spyOn(evidenceModule, "parseEvidence").mockImplementation(() => {
throw failure;
});
const response = await client.callTool({
name: "compare_process_captures",
arguments: input,
});
expectUnexpectedToolFailure(response, failure);
expect(session.exportEvidenceBundle().records).toEqual([]);
expect(session.exportEvidenceBundle().unknowns).toEqual([]);
});
},
);
});
describe("semantic query output failures through the public SDK", () => {
afterEach(() => vi.restoreAllMocks());