Files
rea/src/application/WebNetworkCaptureService.ts
T
morluto 3b698645a0 fix(evidence): retain completed observations on late cancellation
Validate completed reports before cancellation projection, retain valid
Evidence and captured command diagnostics, and convert raw cleanup partials
at their application owner. Record partial Evidence consistently across MCP
handlers. Keep page inspection separate from unfinished bundle analysis.

Refs #4261
2026-10-16 06:46:25 +08:00

213 lines
7.4 KiB
TypeScript

import { redactExplicitFailure } from "../domain/explicitSensitiveFailure.js";
import { excludeCaptureCoordinates } from "../domain/webNetworkCaptureCoordinates.js";
import { redactExplicitText } from "../domain/explicitSensitiveValues.js";
import { isAbsolute } from "node:path";
import type { ExecutionOptions } from "./AnalysisProvider.js";
import type { WebNetworkCapturePort } from "./WebNetworkCapturePort.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisInputError,
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import { createAnalysisProfile } from "../domain/analysisProfile.js";
import { createEvidence, type Evidence } from "../domain/evidence.js";
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
import { jsonObjectSchema } from "../domain/jsonValue.js";
import { err, ok, type Result } from "../domain/result.js";
import {
inspectWebNetworkCaptureInputSchema,
captureSensitiveValues,
webNetworkCaptureSchema,
} from "../domain/webNetworkCapture.js";
import { completeProviderEvidence } from "./ProviderOperationExecution.js";
const OPERATION = "inspect_web_network_capture";
/** Shared CLI/MCP historical inspection preserves caller selection and producer record identity. */
export class WebNetworkCaptureService {
constructor(readonly port: WebNetworkCapturePort) {}
/** Release resources retained by the selected capture adapter. */
close(): Promise<void> {
return this.port.close?.() ?? Promise.resolve();
}
/** Return selected complete records inline, without inventing live browser attribution. */
async inspect(
rawInput: unknown,
options?: ExecutionOptions,
): Promise<Result<Evidence, AnalysisError>> {
if (options?.signal?.aborted)
return err(new AnalysisCancelledError(OPERATION));
const failure = (error: AnalysisError): Result<never, AnalysisError> =>
err(redactExplicitFailure(error, captureSensitiveValues(rawInput)));
const parsed = inspectWebNetworkCaptureInputSchema.safeParse(rawInput);
if (!parsed.success)
return failure(
analysisInputErrorFromIssues(OPERATION, parsed.error.issues, rawInput, {
cause: parsed.error,
}),
);
const input = parsed.data;
if (!isAbsolute(input.capture_path))
return failure(
new AnalysisInputError(OPERATION, undefined, [
{
path: ["capture_path"],
reason: "invalid_format",
message: "Expected an absolute filesystem path on this host.",
},
]),
);
if (
input.record_ordinals !== undefined &&
new Set(input.record_ordinals).size !== input.record_ordinals.length
)
return failure(
new AnalysisInputError(OPERATION, undefined, [
{
path: ["record_ordinals"],
reason: "invalid_format",
message: "Each requested producer ordinal must occur once.",
},
]),
);
const loaded = await this.port.inspect(input, options);
if (!loaded.ok) return failure(loaded.error);
const report = webNetworkCaptureSchema.safeParse(loaded.value);
if (!report.success)
return failure(
new AnalysisOutputError(
OPERATION,
"Capture adapter returned malformed historical evidence.",
),
);
const value = report.data;
const artifactSha256 = value.artifact.sha256;
if (artifactSha256 === null)
return failure(
new AnalysisOutputError(
OPERATION,
"Capture adapter omitted its observed artifact digest before explicit projection.",
),
);
if (
value.artifact.path !== input.capture_path ||
value.format !== input.format ||
value.total_records !== value.records.length ||
value.records.some((record, index) => record.ordinal !== index)
)
return failure(
new AnalysisOutputError(
OPERATION,
"Capture adapter changed the selected artifact, format or original record sequence.",
),
);
const safeValue = excludeCaptureCoordinates(value, input.sensitive_values);
const redactText = (text: string): string =>
redactExplicitText(text, input.sensitive_values);
const selected =
input.record_ordinals ?? value.records.map(({ ordinal }) => ordinal);
const records = [];
for (const ordinal of selected) {
const record = safeValue.records[ordinal];
if (record === undefined)
return failure(
new AnalysisInputError(OPERATION, undefined, [
{
path: ["record_ordinals"],
reason: "invalid_format",
message: `Producer ordinal ${ordinal} is outside the retained capture (${value.total_records} records).`,
},
]),
);
records.push({
...record,
limitations: record.limitations.map(redactText),
});
}
const safePath = input.sensitive_values.some((literal) =>
input.capture_path.includes(literal),
)
? ""
: input.capture_path;
const safeSha256 = input.sensitive_values.some((literal) =>
artifactSha256.includes(literal),
)
? null
: artifactSha256;
const subjectUnavailableReason = redactText(
"The explicitly sensitive artifact digest is excluded; its cryptographic identity is unknown in this Evidence.",
);
const limitations = [
...safeValue.limitations,
...(safePath === ""
? [
"The explicitly sensitive artifact path is excluded; its size remains available.",
]
: []),
].map(redactText);
const parameters = jsonObjectSchema.parse({
capture_path: safePath,
...(input.sensitive_values.some((literal) =>
input.format.includes(literal),
)
? {}
: { format: input.format }),
record_ordinals: selected,
sensitive_value_count: input.sensitive_values.length,
});
return completeProviderEvidence(
ok(
createEvidence(
safeSha256 === null
? undefined
: {
path: safePath,
sha256: safeSha256,
format: "file",
},
value.decoder,
{
operation: OPERATION,
parameters,
result: {
...safeValue,
artifact: {
...value.artifact,
path: safePath,
sha256: safeSha256,
},
records,
limitations:
safeSha256 === null
? [...limitations, subjectUnavailableReason]
: limitations,
},
...(safeValue.format !== "qlog"
? {}
: {
analysisProfile: createAnalysisProfile(value.decoder, {
qlog_profile: safeValue.qlog.profile,
clock: safeValue.qlog.clock,
scope: safeValue.qlog.scope,
}),
}),
confidence: "observed",
authority: "historical-reference",
limitations,
...(safeSha256 === null ? { subjectUnavailableReason } : {}),
locations:
safePath === ""
? []
: [{ kind: "artifact-path", path: safePath }],
},
),
),
OPERATION,
options,
);
}
}