Files
rea/src/application/managed/ManagedMemberComparisonService.ts
T
289aef6838 fix(workflows): keep caller-input details in workflow input errors (#1089)
* fix(managed): keep caller-input details in managed workflow errors

The managed workflows wrapped every rejected input as
`new AnalysisInputError(op, { cause })` with no issues. The projected
result said "Correct the listed arguments and retry" and listed nothing:

- `rea import-managed-reconstruction`,
  `rea project-managed-application-graph` and
  `rea verify-managed-native-boundaries` lost every schema issue for
  malformed JSON;
- domain checks such as a reconstruction text hash mismatch, or Evidence
  from the wrong operation, lost their message on CLI and MCP;
- `rea compare-managed-members` with a missing file did not say which
  path failed.

Add managedInputError, which projects Zod issues with their paths and
keeps a domain TypeError's message, as the JavaScript workflows already
do. Return the BinaryTargetError from an unopenable comparison path
unchanged, with details.path, as every other path-based command does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(managed): keep nested Evidence schema issues off request paths

A ZodError raised after the request parsed comes from a nested
Evidence result, so its issue paths are relative to that result.
Projecting it against the whole request reported, for example,
artifact.sha256 as a missing request field. Keep request-relative
projection for the raw request's schema failures. Report nested schema
failures as root issues whose message names the nested location.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(managed): type the malformed nested Evidence fixture

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(workflows): keep input details in the remaining workflow services

Inventory projection (project_android_application_graph and
project_apple_application_graph), build_reconstruction_obligation_ledger
and evaluate_reconstruction_coverage had the same issue-less
`AnalysisInputError({ cause })` as the managed services. For example,
evaluating coverage for an unknown boundary returned "Correct the listed
arguments" with no issues, dropping "Unknown reconstruction boundary: X"
on CLI and MCP.

Move the helpers to src/application/workflowInputError.ts and use them
for these services: requestInputError for raw request schema failures,
workflowInputError for nested schema and domain constraint failures.
Classification is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:56:19 +08:00

178 lines
6.0 KiB
TypeScript

import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { z } from "zod";
import {
compareManagedMembers,
managedMemberComparisonResultSchema,
parseManagedMemberEvidence,
type CompareManagedMembersInput,
} from "../../domain/managed/managedMemberComparison.js";
import { AnalysisProtocolError } from "../../domain/analysisErrorCore.js";
import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
import { type AnalysisError } from "../../domain/analysisErrorBase.js";
import { createEvidence, type Evidence } from "../../domain/evidence.js";
import { jsonValueSchema } from "../../domain/jsonValue.js";
import { parseBinaryTarget } from "../BinaryTargetResolver.js";
import { err, ok, type Result } from "../../domain/result.js";
import { inspectManagedMembersBytes } from "../../dotnet/ManagedMemberInspector.js";
import {
MANAGED_STATIC_PROVIDER,
MANAGED_WORKFLOW_PROVIDER,
} from "../InvestigationProviders.js";
import { workflowInputError } from "../workflowInputError.js";
/** Compare managed members from input parsed by a trusted adapter. */
export const compareManagedMembersEvidenceValidated = (
input: CompareManagedMembersInput,
): Result<Evidence, AnalysisError> => {
const operation = "compare_managed_members";
try {
const left = parseManagedMemberEvidence(input.left);
const right = parseManagedMemberEvidence(input.right);
const result = compareManagedMembers(
{ evidenceId: left.evidenceId, result: left.result },
{ evidenceId: right.evidenceId, result: right.result },
);
return ok(createManagedMemberComparisonEvidence(input, result));
} catch (cause: unknown) {
return workflowFailure(operation, cause);
}
};
interface ManagedMemberComparisonPathDependencies {
readonly resolveTarget: typeof parseBinaryTarget;
readonly readBytes: (path: string) => Promise<Buffer>;
}
const DEFAULT_PATH_DEPENDENCIES: ManagedMemberComparisonPathDependencies = {
resolveTarget: parseBinaryTarget,
readBytes: readFile,
};
/** Inspect two local PE artifacts and return one derived comparison Evidence. */
export const compareManagedMemberPaths = async (
input: {
readonly leftPath: string;
readonly rightPath: string;
},
dependencies: ManagedMemberComparisonPathDependencies = DEFAULT_PATH_DEPENDENCIES,
): Promise<Result<Evidence, AnalysisError>> => {
const operation = "compare_managed_members";
try {
const [leftTarget, rightTarget] = await Promise.all([
dependencies.resolveTarget(input.leftPath),
dependencies.resolveTarget(input.rightPath),
]);
// Report an unopenable path like every other path-based command does,
// with the failed path and constraint, rather than a bare input error.
if (!leftTarget.ok) return err(leftTarget.error);
if (!rightTarget.ok) return err(rightTarget.error);
const [leftBytes, rightBytes] = await Promise.all([
dependencies.readBytes(leftTarget.value.path),
dependencies.readBytes(rightTarget.value.path),
]);
for (const [target, bytes] of [
[leftTarget.value, leftBytes],
[rightTarget.value, rightBytes],
] as const) {
const observedSha256 = createHash("sha256").update(bytes).digest("hex");
if (observedSha256 !== target.sha256)
return err(
new EvidenceIntegrityError(
`Managed artifact digest changed after open: expected ${target.sha256}, observed ${observedSha256} at ${target.path}`,
),
);
}
const leftInspection = inspectManagedMembersBytes(
leftBytes,
leftTarget.value,
);
const rightInspection = inspectManagedMembersBytes(
rightBytes,
rightTarget.value,
);
const leftEvidence = createEvidence(
leftTarget.value,
MANAGED_STATIC_PROVIDER,
{
operation: "inspect_managed_members",
parameters: {},
result: jsonValueSchema.parse(leftInspection),
rawResult: null,
limitations: leftInspection.limitations,
locations: [{ kind: "artifact-path", path: leftTarget.value.path }],
},
);
const rightEvidence = createEvidence(
rightTarget.value,
MANAGED_STATIC_PROVIDER,
{
operation: "inspect_managed_members",
parameters: {},
result: jsonValueSchema.parse(rightInspection),
rawResult: null,
limitations: rightInspection.limitations,
locations: [{ kind: "artifact-path", path: rightTarget.value.path }],
},
);
const result = compareManagedMembers(
{
evidenceId: leftEvidence.evidence_id,
result: leftInspection,
},
{
evidenceId: rightEvidence.evidence_id,
result: rightInspection,
},
);
return ok(
createManagedMemberComparisonEvidence(
{
left: leftEvidence,
right: rightEvidence,
},
result,
),
);
} catch (cause: unknown) {
return workflowFailure(operation, cause);
}
};
/** ECMA-335 representable limits for path-based member comparison. */
const createManagedMemberComparisonEvidence = (
parameters: Pick<CompareManagedMembersInput, "left" | "right">,
result: z.infer<typeof managedMemberComparisonResultSchema>,
): Evidence =>
createEvidence(undefined, MANAGED_WORKFLOW_PROVIDER, {
predicateType: "rea.managed-member-comparison",
operation: "compare_managed_members",
parameters: {
left_evidence_id: parameters.left.evidence_id,
right_evidence_id: parameters.right.evidence_id,
},
result: jsonValueSchema.parse(result),
rawResult: null,
confidence: "inferred",
authority: "analyst-inference",
environment: null,
limitations: result.limitations,
evidenceLinks: result.evidence_links,
});
const workflowFailure = (
operation: string,
cause: unknown,
): Result<never, AnalysisError> =>
err(
cause instanceof z.ZodError || cause instanceof TypeError
? workflowInputError(operation, cause)
: new AnalysisProtocolError(
"Managed member comparison produced an invalid result",
{ cause },
),
);