A trace keeps linked sources on the result. Use those records for the undelivered source closure as well as the bundle, so a refused output does not report them as not captured.
336 lines
12 KiB
TypeScript
336 lines
12 KiB
TypeScript
import { recordCliEvidencePublication } from "./cliEvidencePublication.js";
|
|
import {
|
|
analysisEvidenceSources,
|
|
undeliveredSourceClosure,
|
|
} from "../application/AnalysisEvidenceSources.js";
|
|
import type { AnalysisViewRequest } from "../domain/analysisView/analysisView.js";
|
|
import { z } from "incur";
|
|
import {
|
|
isImmutableEvidence,
|
|
parseEvidence,
|
|
withEvidenceCallerInputPath,
|
|
type Evidence,
|
|
} from "../domain/evidence.js";
|
|
import type { JsonValue } from "../domain/jsonValue.js";
|
|
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
|
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
|
import { EvidenceReferenceError } from "../domain/evidenceErrors.js";
|
|
import { redactExplicitFailure } from "../domain/explicitSensitiveFailure.js";
|
|
import { err, ok, type Result } from "../domain/result.js";
|
|
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
|
import {
|
|
createEvidenceBundle,
|
|
createImmutableEvidenceBundle,
|
|
} from "../domain/evidenceBundle.js";
|
|
import {
|
|
evidenceDeliveryCommitted,
|
|
publishEvidenceDelivery,
|
|
} from "../application/EvidenceBundleCommands.js";
|
|
import { inspectAnalysisViewValidated } from "../application/analysisView/AnalysisViewService.js";
|
|
import { localPathStringSchema } from "../domain/localPath.js";
|
|
import { absoluteCallerPath } from "../filesystem/callerPath.js";
|
|
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
|
|
import {
|
|
isObject,
|
|
projectCliAnalysisResult,
|
|
} from "./projectCliAnalysisResult.js";
|
|
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
|
|
import type { ZodSafeParseResult } from "zod";
|
|
import { bindCliEvidenceCallerPath } from "./cliEvidenceCallerPath.js";
|
|
import type { AnalysisResultWithSources } from "../application/investigation/recordEvidenceSources.js";
|
|
import { resolveInvestigationEvidenceClosure } from "../application/investigation/InvestigationEvidence.js";
|
|
|
|
export const analysisEvidenceDeliveryOptions = {
|
|
detail: z
|
|
.enum(["complete", "summary"])
|
|
.default("complete")
|
|
.describe("Deliver complete Evidence, or a summary with --evidence_output"),
|
|
evidence_output: localPathStringSchema
|
|
.optional()
|
|
.describe(
|
|
"Save complete Evidence as a bundle; required for summary delivery. Never overwrites.",
|
|
),
|
|
};
|
|
|
|
interface CliEvidenceDelivery {
|
|
readonly view?: AnalysisViewRequest;
|
|
readonly providerId: string;
|
|
readonly operation: string;
|
|
readonly signal: AbortSignal;
|
|
readonly detail: "complete" | "summary";
|
|
readonly evidenceOutput: string | undefined;
|
|
/** Exact one-shot CLI target spelling, when this invocation selects a file. */
|
|
readonly callerInputPath?: string;
|
|
/** Derived workflows publish their selected source closure with the result. */
|
|
readonly sourceEvidence?: readonly Evidence[];
|
|
/** Invocation exclusions cover final cleanup and publication paths/diagnostics. */
|
|
readonly sensitiveValues?: readonly string[];
|
|
}
|
|
|
|
export const cliEvidenceDeliveryInputError = (
|
|
input: Pick<CliEvidenceDelivery, "detail" | "evidenceOutput" | "operation">,
|
|
): AnalysisInputError | undefined =>
|
|
input.detail === "summary" && input.evidenceOutput === undefined
|
|
? new AnalysisInputError(input.operation, undefined, [
|
|
{
|
|
path: ["evidence_output"],
|
|
reason: "invalid_value",
|
|
message:
|
|
"Summary delivery requires --evidence_output so complete source Evidence remains available.",
|
|
},
|
|
])
|
|
: undefined;
|
|
|
|
/** Resolve only the authenticated Evidence records linked by one CLI result. */
|
|
export const cliEvidenceSourceClosure = (
|
|
root: Evidence,
|
|
available: readonly Evidence[],
|
|
): Result<Evidence[], AnalysisError> => {
|
|
const recordsById = new Map(
|
|
available.map((record) => [record.evidence_id, record]),
|
|
);
|
|
const closure = resolveInvestigationEvidenceClosure(
|
|
{ evidenceForAnalysis: (evidenceId) => recordsById.get(evidenceId) },
|
|
root.evidence_links,
|
|
);
|
|
if (!closure.ok) {
|
|
if (closure.error instanceof EvidenceReferenceError)
|
|
return err(
|
|
new EvidenceReferenceError(
|
|
closure.error.evidenceId,
|
|
closure.error.reason,
|
|
{
|
|
cause: closure.error,
|
|
expected: closure.error.expected,
|
|
actual: closure.error.actual,
|
|
referenceScope: closure.error.referenceScope,
|
|
diagnostics: { operation: root.operation },
|
|
},
|
|
),
|
|
);
|
|
return err(closure.error);
|
|
}
|
|
return ok(
|
|
closure.value
|
|
.filter((evidenceId) => evidenceId !== root.evidence_id)
|
|
.map((evidenceId) => recordsById.get(evidenceId)!),
|
|
);
|
|
};
|
|
|
|
/** Parse delivery JSON once and enforce its owning schema before opening a target. */
|
|
export const parseCliAnalysisEvidenceDelivery = <
|
|
Delivery extends {
|
|
readonly detail: "complete" | "summary";
|
|
readonly view?: AnalysisViewRequest | undefined;
|
|
},
|
|
>(
|
|
operation: string,
|
|
options: {
|
|
readonly detail: "complete" | "summary";
|
|
readonly evidence_output?: string | undefined;
|
|
readonly view?: string | undefined;
|
|
},
|
|
schema: {
|
|
readonly safeParse: (input: unknown) => ZodSafeParseResult<Delivery>;
|
|
},
|
|
): Result<
|
|
Delivery & { readonly evidenceOutput: string | undefined },
|
|
AnalysisError
|
|
> => {
|
|
let view: unknown;
|
|
try {
|
|
view = options.view === undefined ? undefined : JSON.parse(options.view);
|
|
} catch (cause) {
|
|
return err(
|
|
new AnalysisInputError(operation, { cause }, [
|
|
{
|
|
path: ["view"],
|
|
reason: "invalid_format",
|
|
message: "view must be valid JSON",
|
|
},
|
|
]),
|
|
);
|
|
}
|
|
const raw = {
|
|
detail: options.detail,
|
|
...(view === undefined ? {} : { view }),
|
|
};
|
|
const parsed = schema.safeParse(raw);
|
|
if (!parsed.success)
|
|
return err(
|
|
analysisInputErrorFromIssues(operation, parsed.error.issues, raw),
|
|
);
|
|
const value = { ...parsed.data, evidenceOutput: options.evidence_output };
|
|
const invalid = cliEvidenceDeliveryInputError({ ...value, operation });
|
|
return invalid === undefined ? ok(value) : err(invalid);
|
|
};
|
|
|
|
/** One-shot delivery owns report publication; analysis keeps its own cleanup owner. */
|
|
export const deliverCliAnalysisEvidence = async (
|
|
input: CliEvidenceDelivery & {
|
|
readonly run: () => Promise<Result<Evidence, AnalysisError>>;
|
|
readonly service: { close(): Promise<void> };
|
|
},
|
|
) => {
|
|
const {
|
|
run,
|
|
service,
|
|
providerId,
|
|
operation,
|
|
signal,
|
|
detail,
|
|
evidenceOutput,
|
|
} = input;
|
|
const redactFailure = (error: AnalysisError) =>
|
|
redactExplicitFailure(error, input.sensitiveValues ?? []);
|
|
const inputError = cliEvidenceDeliveryInputError(input);
|
|
if (inputError !== undefined)
|
|
return finishAnalysisCommand(
|
|
async () => ({ ok: false, error: inputError }),
|
|
service,
|
|
providerId,
|
|
operation,
|
|
signal,
|
|
undefined,
|
|
{ redactFailure },
|
|
).then(projectCliAnalysisResult);
|
|
const complete = await finishAnalysisCommand(
|
|
input.callerInputPath === undefined
|
|
? run
|
|
: bindCliEvidenceCallerPath(input.callerInputPath, run),
|
|
service,
|
|
providerId,
|
|
operation,
|
|
signal,
|
|
undefined,
|
|
{ redactFailure },
|
|
);
|
|
return publishCliAnalysisEvidence(complete, {
|
|
providerId,
|
|
operation,
|
|
signal,
|
|
detail,
|
|
evidenceOutput,
|
|
...(input.sensitiveValues === undefined
|
|
? {}
|
|
: { sensitiveValues: input.sensitiveValues }),
|
|
...(input.view === undefined ? {} : { view: input.view }),
|
|
...(input.sourceEvidence === undefined
|
|
? {}
|
|
: { sourceEvidence: input.sourceEvidence }),
|
|
});
|
|
};
|
|
|
|
/**
|
|
* A requested bundle that never committed cannot silently drop what a completed
|
|
* observation cites. Absent delivery or observation leaves the failure untouched.
|
|
*/
|
|
const withUndeliveredSourceClosure = (
|
|
failure: JsonValue,
|
|
input: CliEvidenceDelivery,
|
|
): JsonValue => {
|
|
if (input.evidenceOutput === undefined || !isObject(failure)) return failure;
|
|
const { details } = failure;
|
|
if (!isObject(details)) return failure;
|
|
const closure = undeliveredSourceClosure(
|
|
details.partial_observation,
|
|
input.sourceEvidence ?? [],
|
|
// Error output is not the caller's selected Evidence sink. Keep records
|
|
// out of it when this invocation carries explicit exclusions.
|
|
{ inlineRecords: (input.sensitiveValues?.length ?? 0) === 0 },
|
|
);
|
|
return closure === undefined
|
|
? failure
|
|
: { ...failure, details: { ...details, source_closure: closure } };
|
|
};
|
|
|
|
/** Publish already finalized CLI analysis without opening a second provider/session. */
|
|
export const publishCliAnalysisEvidence = async (
|
|
result: AnalysisResultWithSources<Evidence, JsonValue>,
|
|
input: CliEvidenceDelivery,
|
|
): Promise<JsonValue> => {
|
|
const { operation, signal, detail, evidenceOutput } = input;
|
|
// Failure reporting uses the same captured sources as publication. A trace
|
|
// carries them on the result, while other commands pass them on the input.
|
|
const capturedSources = [
|
|
...(input.sourceEvidence ?? []),
|
|
...(result.sourceEvidence ?? []),
|
|
];
|
|
const closureInput =
|
|
capturedSources.length === 0
|
|
? input
|
|
: { ...input, sourceEvidence: capturedSources };
|
|
const projectFailure = (error: AnalysisError) =>
|
|
projectAnalysisError(
|
|
redactExplicitFailure(error, input.sensitiveValues ?? []),
|
|
);
|
|
const undelivered = (error: AnalysisError) =>
|
|
withUndeliveredSourceClosure(projectFailure(error), closureInput);
|
|
if (!result.ok)
|
|
return withUndeliveredSourceClosure(result.error, closureInput);
|
|
const complete = parseEvidence(result.value);
|
|
const inputError = cliEvidenceDeliveryInputError(input);
|
|
if (inputError !== undefined) {
|
|
inputError.retainPartialObservation(complete);
|
|
return projectFailure(inputError);
|
|
}
|
|
if (evidenceOutput === undefined) return complete;
|
|
// Project before publication so a view failure cannot commit an
|
|
// unreported bundle; publication then owns every post-commit outcome.
|
|
const delivered =
|
|
detail === "summary"
|
|
? inspectAnalysisViewValidated({
|
|
source: { kind: "inline", evidence: complete },
|
|
view: input.view ?? { kind: "summary" },
|
|
})
|
|
: ok(complete);
|
|
if (!delivered.ok)
|
|
return undelivered(delivered.error.retainPartialObservation(complete));
|
|
// This projection answers the same direct invocation as its parent. Later
|
|
// inspect_analysis_view calls own their own input and do not inherit it.
|
|
const selected =
|
|
detail === "summary" && complete.caller_input_path !== undefined
|
|
? withEvidenceCallerInputPath(delivered.value, complete.caller_input_path)
|
|
: delivered.value;
|
|
const inspectionSources = analysisEvidenceSources(complete);
|
|
if (!inspectionSources.ok) return undelivered(inspectionSources.error);
|
|
const records = [
|
|
...new Map(
|
|
[...capturedSources, ...inspectionSources.value].map((record) => [
|
|
record.evidence_id,
|
|
record,
|
|
]),
|
|
).values(),
|
|
];
|
|
// Only skip the writer's heap check when every record is already sealed.
|
|
// A mutable producer record must retain the existing allocation check.
|
|
const bundle = records.every(isImmutableEvidence)
|
|
? createImmutableEvidenceBundle(records)
|
|
: createEvidenceBundle(records);
|
|
const published = await publishEvidenceDelivery({
|
|
bundle,
|
|
complete,
|
|
delivered: selected,
|
|
outputPath: absoluteCallerPath(evidenceOutput),
|
|
operation,
|
|
signal,
|
|
});
|
|
if (!published.ok)
|
|
return evidenceDeliveryCommitted(published.error)
|
|
? projectFailure(published.error)
|
|
: undelivered(published.error);
|
|
const receipt = {
|
|
...(input.sensitiveValues?.some(
|
|
(literal) =>
|
|
literal !== "" && published.value.evidenceBundle.path.includes(literal),
|
|
)
|
|
? {}
|
|
: { path: published.value.evidenceBundle.path }),
|
|
bytes: published.value.evidenceBundle.bytes,
|
|
};
|
|
recordCliEvidencePublication(receipt);
|
|
return detail === "summary"
|
|
? { summary: published.value.delivered, evidence_bundle: receipt }
|
|
: published.value.delivered;
|
|
};
|