diff --git a/src/application/javascript/JavaScriptRuntimeReconciliationService.ts b/src/application/javascript/JavaScriptRuntimeReconciliationService.ts index d392ef1821..ca96d81bc1 100644 --- a/src/application/javascript/JavaScriptRuntimeReconciliationService.ts +++ b/src/application/javascript/JavaScriptRuntimeReconciliationService.ts @@ -1,14 +1,15 @@ import { resolve } from "node:path"; import { z } from "zod"; -import { - AnalysisInputError, - AnalysisProtocolError, -} from "../../domain/analysisErrorCore.js"; +import { AnalysisProtocolError } from "../../domain/analysisErrorCore.js"; import { type AnalysisError } from "../../domain/analysisErrorBase.js"; import type { Evidence } from "../../domain/evidence.js"; import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; -import { reconcileJavaScriptRuntime } from "../../domain/javascript/javascriptRuntimeReconciliation.js"; +import { + parseRuntimeReconciliationInput, + reconcileJavaScriptRuntime, + type ParsedRuntimeReconciliationInput, +} from "../../domain/javascript/javascriptRuntimeReconciliation.js"; import { reconcileJavaScriptRuntimeInputSchema } from "../../domain/javascript/javascriptRuntimeReconciliationSchemas.js"; import { err, ok, type Result } from "../../domain/result.js"; import { createJavaScriptRuntimeReconciliationEvidence } from "./JavaScriptRuntimeReconciliationEvidence.js"; @@ -31,26 +32,30 @@ export const reconcileJavaScriptRuntimeEvidence = ( export const reconcileJavaScriptRuntimeEvidenceValidated = ( parsedInput: z.output, ): Result => { + let parsed: ParsedRuntimeReconciliationInput; try { - const input = reconcileJavaScriptRuntimeInputSchema.parse( + parsed = parseRuntimeReconciliationInput( normalizeRuntimeMappingRoots(parsedInput), ); - const result = reconcileJavaScriptRuntime(input); - return ok(createJavaScriptRuntimeReconciliationEvidence(input, result)); } catch (cause: unknown) { if (cause instanceof z.ZodError) return err( analysisInputErrorFromIssues(OPERATION, cause.issues, parsedInput), ); - if ( - cause instanceof TypeError && - SAFE_RECONCILIATION_INPUT_CONSTRAINTS.has(cause.message) - ) - return err( - new AnalysisInputError(OPERATION, undefined, [ - { path: [], reason: "invalid_value", message: cause.message }, - ]), - ); + return err( + new AnalysisProtocolError( + "JavaScript runtime reconciliation input parsing failed", + { cause }, + ), + ); + } + + try { + const result = reconcileJavaScriptRuntime(parsed); + return ok( + createJavaScriptRuntimeReconciliationEvidence(parsed.input, result), + ); + } catch (cause: unknown) { return err( new AnalysisProtocolError( "JavaScript runtime reconciliation produced an invalid result", @@ -83,23 +88,3 @@ const isPortableAbsolutePath = (path: string): boolean => path.startsWith("/") || /^[a-z]:[\\/]/iu.test(path) || /^(?:\\\\|\/\/)[^\\/]+[\\/][^\\/]+(?:[\\/]|$)/u.test(path); - -const SAFE_RECONCILIATION_INPUT_CONSTRAINTS = new Set([ - "JavaScript application Evidence subject disagrees with its result", - "Runtime reconciliation requires inspect_web_page, inspect_electron_page, observe_javascript_runtime, or capture_electron_scenario Evidence", - "Runtime Evidence target or declared role disagrees with its result", - "Runtime Evidence contains an invalid builtin location", - "Runtime Evidence target disagrees with its captured result", - "Browser Evidence target is outside its recorded origin scope", - "Runtime Evidence contains source without source-capture selection", - "Active Electron Evidence application path disagrees with its configured root", - ...[ - "inspect_web_page", - "inspect_electron_page", - "observe_javascript_runtime", - "capture_electron_scenario", - ].map( - (operation) => - `Evidence does not match the supported ${operation} contract`, - ), -]); diff --git a/src/contracts/javascript/javascriptApplicationWorkflowExamples.ts b/src/contracts/javascript/javascriptApplicationWorkflowExamples.ts index 7e9b6b17e1..29d5b1bf24 100644 --- a/src/contracts/javascript/javascriptApplicationWorkflowExamples.ts +++ b/src/contracts/javascript/javascriptApplicationWorkflowExamples.ts @@ -1,13 +1,21 @@ import { createEvidence } from "../../domain/evidence.js"; -import { reconcileJavaScriptRuntime } from "../../domain/javascript/javascriptRuntimeReconciliation.js"; +import { + parseRuntimeReconciliationInput, + reconcileJavaScriptRuntime, +} from "../../domain/javascript/javascriptRuntimeReconciliation.js"; import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE, JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE, } from "./javascriptRuntimeReconciliationExample.js"; import { createHistoricalSourceGraph } from "../../domain/referenceSourceGraph.js"; +import { reconcileJavaScriptRuntimeInputSchema } from "../../domain/javascript/javascriptRuntimeReconciliationSchemas.js"; const reconciliation = reconcileJavaScriptRuntime( - JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE, + parseRuntimeReconciliationInput( + reconcileJavaScriptRuntimeInputSchema.parse( + JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE, + ), + ), ); const reconciliationEvidence = createEvidence( undefined, diff --git a/src/domain/javascript/javascriptRuntimeReconciliation.ts b/src/domain/javascript/javascriptRuntimeReconciliation.ts index 20bdf50cc7..6ad77385a5 100644 --- a/src/domain/javascript/javascriptRuntimeReconciliation.ts +++ b/src/domain/javascript/javascriptRuntimeReconciliation.ts @@ -2,22 +2,42 @@ import { classifyStaticLoadStates } from "./javascriptRuntimeLoadState.js"; import { buildReconciledApplicationGraph } from "./javascriptRuntimeReconciliationGraph.js"; import { reconcileRuntimeEntities } from "./javascriptRuntimeReconciliationMatching.js"; import { - parseRuntimeCaptures, parseStaticLayers, + type ParsedStaticLayer, } from "./javascriptRuntimeReconciliationParsing.js"; +import { + parseRuntimeCaptures, + type ParsedRuntimeCapture, +} from "./javascriptRuntimeReconciliationCaptureParsing.js"; import { createJavaScriptRuntimeReconciliationResult } from "./javascriptRuntimeReconciliationResult.js"; import type { JavaScriptRuntimeReconciliationResult } from "./javascriptRuntimeReconciliationSchemas.js"; -import { reconcileJavaScriptRuntimeInputSchema } from "./javascriptRuntimeReconciliationSchemas.js"; +import type { ReconcileJavaScriptRuntimeInput } from "./javascriptRuntimeReconciliationSchemas.js"; import { projectRuntimeCaptures } from "./javascriptRuntimeReconciliationRuntime.js"; import { collectStaticRuntimeCandidates } from "./javascriptRuntimeStaticCandidates.js"; +/** Verified caller input and the static and runtime facts parsed from it. */ +export interface ParsedRuntimeReconciliationInput { + readonly input: ReconcileJavaScriptRuntimeInput; + readonly layers: readonly ParsedStaticLayer[]; + readonly captures: readonly ParsedRuntimeCapture[]; +} + +/** Parse caller Evidence once into the owner consumed by reconciliation. */ +export const parseRuntimeReconciliationInput = ( + input: ReconcileJavaScriptRuntimeInput, +): ParsedRuntimeReconciliationInput => ({ + input, + layers: parseStaticLayers(input.static_layers, ["static_layers"]), + captures: parseRuntimeCaptures(input.runtime_observations, [ + "runtime_observations", + ]), +}); + /** Reconcile static JAG layers with authorized passive CDP Evidence. */ export const reconcileJavaScriptRuntime = ( - input: unknown, + parsed: ParsedRuntimeReconciliationInput, ): JavaScriptRuntimeReconciliationResult => { - const parsed = reconcileJavaScriptRuntimeInputSchema.parse(input); - const layers = parseStaticLayers(parsed.static_layers); - const captures = parseRuntimeCaptures(parsed.runtime_observations); + const { layers, captures } = parsed; const runtime = projectRuntimeCaptures(captures); const candidates = collectStaticRuntimeCandidates(layers); const matching = reconcileRuntimeEntities({ diff --git a/src/domain/javascript/javascriptRuntimeReconciliationActive.ts b/src/domain/javascript/javascriptRuntimeReconciliationActive.ts index 32ef65086b..4981888ed6 100644 --- a/src/domain/javascript/javascriptRuntimeReconciliationActive.ts +++ b/src/domain/javascript/javascriptRuntimeReconciliationActive.ts @@ -3,6 +3,10 @@ import { isAbsolute } from "node:path"; import { digestCanonicalValue } from "../canonicalDigest.js"; import { isPathWithinRoot } from "../localPath.js"; import { z } from "zod"; +import { + invalidInput, + parseAtPath, +} from "./javascriptRuntimeReconciliationInputValidation.js"; import { classifyBrowserCompleteness, @@ -13,23 +17,30 @@ import { type ElectronActiveObservationResult, } from "./electronActiveObservation.js"; import type { Evidence } from "../evidence.js"; -import type { ParsedRuntimeCapture } from "./javascriptRuntimeReconciliationParsing.js"; +import type { ParsedRuntimeCapture } from "./javascriptRuntimeReconciliationCaptureParsing.js"; /** Parse active Electron Evidence as a bounded, target-only runtime capture. */ export const parseActiveElectronCapture = ( evidence: Evidence, ): ParsedRuntimeCapture => { assertIdentity(evidence); - const result = electronActiveObservationResultSchema.parse( + const result = parseAtPath( + (value) => electronActiveObservationResultSchema.parse(value), evidence.normalized_result, + ["normalized_result"], + ); + const parameters = parseAtPath( + (value) => + z + .object({ + application_path: absolutePathSchema, + application_root: absolutePathSchema, + }) + .passthrough() + .parse(value), + evidence.parameters, + ["parameters"], ); - const parameters = z - .object({ - application_path: absolutePathSchema, - application_root: absolutePathSchema, - }) - .passthrough() - .parse(evidence.parameters); if ( result.application.application_path !== parameters.application_path || !isPathWithinRoot( @@ -37,7 +48,8 @@ export const parseActiveElectronCapture = ( result.application.application_path, ) ) - throw new TypeError( + throw invalidInput( + ["normalized_result", "application", "application_path"], "Active Electron Evidence application path disagrees with its configured root", ); return { @@ -62,11 +74,11 @@ const assertIdentity = (evidence: Evidence): void => { evidence.authority !== "controlled-replay" || evidence.confidence !== "observed" ) - throw new TypeError( + throw invalidInput( + [], "Evidence does not match the supported capture_electron_scenario contract", ); }; - const normalizeInspection = ( result: ElectronActiveObservationResult, ): { diff --git a/src/domain/javascript/javascriptRuntimeReconciliationCaptureParsing.ts b/src/domain/javascript/javascriptRuntimeReconciliationCaptureParsing.ts new file mode 100644 index 0000000000..7b609b9d98 --- /dev/null +++ b/src/domain/javascript/javascriptRuntimeReconciliationCaptureParsing.ts @@ -0,0 +1,481 @@ +import { z } from "zod"; + +import { + browserAllowedOriginsSchema, + browserEndpointSchema, +} from "../browserObservation.js"; +import { + webPageInspectionSchema, + type WebPageInspection, +} from "../browserObservationSchemas.js"; +import { + classifyBrowserCompleteness, + type BrowserCompleteness, +} from "../browserCompleteness.js"; +import { + electronPageInspectionSchema, + type ElectronPageInspection, +} from "./electronObservation.js"; +import { + javascriptRuntimeObservationSchema, + javascriptRuntimeKindSchema, + type JavaScriptRuntimeObservation, + type JavaScriptRuntimeTargetLocation, +} from "./javascriptRuntimeObservation.js"; +import { digestCanonicalValue } from "../canonicalDigest.js"; +import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; +import { parseEvidence, type Evidence } from "../evidence.js"; +import { parseActiveElectronCapture } from "./javascriptRuntimeReconciliationActive.js"; +import { + assertEvidenceIdentity, + invalidInput, + parseAtPath, +} from "./javascriptRuntimeReconciliationInputValidation.js"; + +interface RuntimeCaptureBase { + readonly evidence: Evidence; + readonly captureSha256: string; + readonly scriptsCompleteWithinScope: boolean; +} + +interface NormalizedV8Inspection { + readonly target: + | { + readonly target_id: string; + readonly type: string; + readonly title: string; + readonly attached: boolean; + readonly file_path: string; + } + | { + readonly target_id: string; + readonly type: string; + readonly title: string; + readonly attached: boolean; + readonly url: string; + } + | { + readonly target_id: string; + readonly type: string; + readonly title: string; + readonly attached: boolean; + readonly unresolved_location: Extract< + JavaScriptRuntimeObservation["target"]["location"], + { kind: "unresolved" } + >; + }; + readonly frames: readonly []; + readonly scripts: { + readonly items: readonly (Readonly<{ + script_key: string; + frame_id: string | null; + cdp_hash: string | null; + length: number | null; + is_module: boolean | null; + language: null; + source: { readonly included: false; readonly reason: string }; + }> & + ( + | { readonly file_path: string } + | { readonly url: string } + | { + readonly unresolved_location: Extract< + JavaScriptRuntimeObservation["scripts"]["items"][number]["location"], + { kind: "unresolved" } + >; + } + ))[]; + }; + readonly workers: readonly []; + readonly completeness: BrowserCompleteness; +} + +export type ParsedRuntimeCapture = + | (RuntimeCaptureBase & { + readonly kind: "browser"; + readonly inspection: WebPageInspection; + }) + | (RuntimeCaptureBase & { + readonly kind: "electron"; + readonly inspection: ElectronPageInspection; + }) + | (RuntimeCaptureBase & { + readonly kind: "v8-inspector"; + readonly inspection: NormalizedV8Inspection; + }) + | (RuntimeCaptureBase & { + readonly kind: "electron-active"; + readonly inspection: { + readonly target: { + readonly target_id: string; + readonly type: string; + readonly title: string; + readonly attached: boolean; + readonly file_path: string; + }; + readonly frames: readonly []; + readonly scripts: { readonly items: readonly [] }; + readonly workers: readonly []; + readonly completeness: BrowserCompleteness; + }; + }); + +/** Parse only supported passive web/Electron inspection Evidence. */ +export const parseRuntimeCaptures = ( + observations: readonly Evidence[], + path: readonly (string | number)[] = ["runtime_observations"], +): ParsedRuntimeCapture[] => + observations + .map((observation, index) => + parseRuntimeCapture(observation, [...path, index]), + ) + .sort((left, right) => + compareUnicodeCodePoints( + left.evidence.evidence_id, + right.evidence.evidence_id, + ), + ); + +const parseRuntimeCapture = ( + input: Evidence, + evidencePath: readonly (string | number)[], +): ParsedRuntimeCapture => { + const evidence = parseAtPath(parseEvidence, input, evidencePath); + if (evidence.operation === "inspect_web_page") + return parseBrowserCapture(evidence, evidencePath); + if (evidence.operation === "inspect_electron_page") + return parseElectronCapture(evidence, evidencePath); + if (evidence.operation === "observe_javascript_runtime") + return parseV8Capture(evidence, evidencePath); + if (evidence.operation === "capture_electron_scenario") { + return parseAtPath( + () => parseActiveElectronCapture(evidence), + evidence, + evidencePath, + ); + } + throw invalidInput( + evidencePath, + "Runtime reconciliation requires inspect_web_page, inspect_electron_page, observe_javascript_runtime, or capture_electron_scenario Evidence", + ); +}; + +const parseBrowserCapture = ( + evidence: Evidence, + path: readonly (string | number)[], +): ParsedRuntimeCapture => { + assertEvidenceIdentity( + evidence, + { + operation: "inspect_web_page", + predicate: "rea.web-page-inspection", + providerId: "rea-cdp-browser", + providerName: "REA Chrome DevTools Protocol observation provider", + providerVersion: "2", + authority: "external-service", + confidence: "observed", + }, + path, + ); + const inspection = parseAtPath( + (value) => webPageInspectionSchema.parse(value), + evidence.normalized_result, + [...path, "normalized_result"], + ); + assertRuntimeParameters( + evidence, + { + kind: "browser", + targetId: inspection.target.target_id, + targetOrigin: inspection.target.origin, + sourceIncluded: inspection.scripts.items.some( + ({ source }) => source.included, + ), + }, + path, + ); + return { + kind: "browser", + evidence, + inspection, + captureSha256: digestCanonicalValue(inspection, "Runtime reconciliation"), + scriptsCompleteWithinScope: scriptsComplete(inspection.completeness), + }; +}; + +const parseElectronCapture = ( + evidence: Evidence, + path: readonly (string | number)[], +): ParsedRuntimeCapture => { + assertEvidenceIdentity( + evidence, + { + operation: "inspect_electron_page", + predicate: "rea.electron-page-inspection", + providerId: "rea-cdp-electron", + providerName: "REA Electron file-page CDP observation provider", + providerVersion: "1", + authority: "external-service", + confidence: "observed", + }, + path, + ); + const inspection = parseAtPath( + (value) => electronPageInspectionSchema.parse(value), + evidence.normalized_result, + [...path, "normalized_result"], + ); + assertRuntimeParameters( + evidence, + { + kind: "electron", + targetId: inspection.target.target_id, + sourceIncluded: inspection.scripts.items.some( + ({ source }) => source.included, + ), + }, + path, + ); + return { + kind: "electron", + evidence, + inspection, + captureSha256: digestCanonicalValue(inspection, "Runtime reconciliation"), + scriptsCompleteWithinScope: scriptsComplete(inspection.completeness), + }; +}; + +const parseV8Capture = ( + evidence: Evidence, + path: readonly (string | number)[], +): ParsedRuntimeCapture => { + assertEvidenceIdentity( + evidence, + { + operation: "observe_javascript_runtime", + predicate: "rea.javascript-runtime-observation", + providerId: "rea-v8-inspector", + providerName: "REA passive Node/Electron V8 Inspector provider", + providerVersion: "1", + authority: "external-service", + confidence: "observed", + }, + path, + ); + const result = parseAtPath( + (value) => javascriptRuntimeObservationSchema.parse(value), + evidence.normalized_result, + [...path, "normalized_result"], + ); + assertV8RuntimeParameters(evidence, result, path); + return { + kind: "v8-inspector", + evidence, + inspection: normalizeV8Inspection(result), + captureSha256: digestCanonicalValue(result, "Runtime reconciliation"), + scriptsCompleteWithinScope: false, + }; +}; + +const assertV8RuntimeParameters = ( + evidence: Evidence, + result: JavaScriptRuntimeObservation, + path: readonly (string | number)[], +): void => { + const parameters = parseAtPath( + (value) => + z + .object({ + inspector_endpoint: browserEndpointSchema, + target_id: z.string().trim().min(1), + runtime_kind: javascriptRuntimeKindSchema.optional(), + }) + .passthrough() + .parse(value), + evidence.parameters, + [...path, "parameters"], + ); + if ( + parameters.target_id !== result.target.target_id || + (parameters.runtime_kind !== undefined && + parameters.runtime_kind !== result.target.runtime_kind) + ) + throw invalidInput( + [...path, "parameters", "target_id"], + "Runtime Evidence target or declared role disagrees with its result", + ); + if ( + result.target.location.kind === "builtin" && + !result.target.location.specifier.startsWith("node:") + ) + throw invalidInput( + [...path, "normalized_result", "target", "location"], + "Runtime Evidence contains an invalid builtin location", + ); + for (const [index, { location }] of result.scripts.items.entries()) + if (location.kind === "builtin" && !location.specifier.startsWith("node:")) + throw invalidInput( + [...path, "normalized_result", "scripts", "items", index, "location"], + "Runtime Evidence contains an invalid builtin location", + ); +}; + +const normalizeV8Inspection = ( + result: JavaScriptRuntimeObservation, +): NormalizedV8Inspection => ({ + target: { + target_id: result.target.target_id, + type: result.target.protocol_type, + title: result.target.runtime_kind, + attached: result.target.attached, + ...(result.target.location.kind === "unresolved" + ? { unresolved_location: result.target.location } + : runtimeLocation(result.target.location)), + }, + frames: [], + scripts: { + items: result.scripts.items.map((script) => ({ + script_key: script.script_key, + frame_id: script.execution_context_key, + ...runtimeLocation(script.location), + cdp_hash: script.cdp_hash, + length: script.length, + is_module: script.is_module, + language: null, + source: { + included: false, + reason: "V8 Inspector source capture is outside passive authority.", + }, + })), + }, + workers: [], + completeness: classifyBrowserCompleteness({ + policyFilteredSections: new Set( + excludedV8Scripts(result) > 0 ? ["scripts"] : [], + ), + attachLimitedSections: new Set([ + "frames", + "scripts", + "script_sources", + "workers", + ]), + truncatedSections: new Set(result.capture.truncated ? ["scripts"] : []), + unavailableSections: new Set(["frames", "script_sources", "workers"]), + excluded: + excludedV8Scripts(result) === 0 + ? [] + : [ + { + section: "scripts", + reason: "out_of_target_scope", + count: excludedV8Scripts(result), + }, + ], + droppedEvents: { + scripts: result.capture.events_dropped, + network_requests: 0, + console_events: 0, + websocket_connections: 0, + websocket_frames: 0, + webmcp_tools: 0, + timeline_events: 0, + }, + }), +}); + +const runtimeLocation = ( + location: JavaScriptRuntimeTargetLocation, +): + | { readonly file_path: string } + | { readonly url: string } + | { + readonly unresolved_location: Extract< + JavaScriptRuntimeTargetLocation, + { kind: "unresolved" } + >; + } => { + if (location.kind === "unresolved") return { unresolved_location: location }; + if (location.kind === "file") return { file_path: location.file_path }; + if (location.kind === "url") return { url: location.sanitized_url }; + return { url: location.specifier }; +}; + +const excludedV8Scripts = (result: JavaScriptRuntimeObservation): number => + Object.values(result.scripts.excluded).reduce( + (total, count) => total + count, + 0, + ); + +const assertRuntimeParameters = ( + evidence: Evidence, + expected: + | { + readonly kind: "browser"; + readonly targetId: string; + readonly targetOrigin: string; + readonly sourceIncluded: boolean; + } + | { + readonly kind: "electron"; + readonly targetId: string; + readonly sourceIncluded: boolean; + }, + path: readonly (string | number)[], +): void => { + const common = { + target_id: z.string().trim().min(1), + cdp_endpoint: browserEndpointSchema, + include_script_sources: z.boolean(), + }; + if (expected.kind === "browser") { + const parameters = parseAtPath( + (value) => + z + .object({ ...common, allowed_origins: browserAllowedOriginsSchema }) + .passthrough() + .parse(value), + evidence.parameters, + [...path, "parameters"], + ); + if (parameters.target_id !== expected.targetId) + throw invalidInput( + [...path, "parameters", "target_id"], + "Runtime Evidence target disagrees with its captured result", + ); + if (!parameters.allowed_origins.includes(expected.targetOrigin)) + throw invalidInput( + [...path, "parameters", "allowed_origins"], + "Browser Evidence target is outside its recorded origin scope", + ); + if (expected.sourceIncluded && !parameters.include_script_sources) + throw invalidInput( + [...path, "parameters", "include_script_sources"], + "Runtime Evidence contains source without source-capture selection", + ); + return; + } + const parameters = parseAtPath( + (value) => z.object(common).passthrough().parse(value), + evidence.parameters, + [...path, "parameters"], + ); + if (parameters.target_id !== expected.targetId) + throw invalidInput( + [...path, "parameters", "target_id"], + "Runtime Evidence target disagrees with its captured result", + ); + if (expected.sourceIncluded && !parameters.include_script_sources) + throw invalidInput( + [...path, "parameters", "include_script_sources"], + "Runtime Evidence contains source without source-capture selection", + ); +}; + +const scriptsComplete = (completeness: { + readonly truncated_sections: readonly string[]; + readonly unavailable_sections: readonly string[]; + readonly dropped_events: { readonly scripts: number }; +}): boolean => + !completeness.truncated_sections.includes("scripts") && + !completeness.unavailable_sections.includes("scripts") && + completeness.dropped_events.scripts === 0; diff --git a/src/domain/javascript/javascriptRuntimeReconciliationGraph.ts b/src/domain/javascript/javascriptRuntimeReconciliationGraph.ts index 7e70678044..d3c8f371fd 100644 --- a/src/domain/javascript/javascriptRuntimeReconciliationGraph.ts +++ b/src/domain/javascript/javascriptRuntimeReconciliationGraph.ts @@ -7,10 +7,8 @@ import type { ApplicationEdge, ApplicationNode, } from "./javascriptApplicationGraphSchemas.js"; -import type { - ParsedRuntimeCapture, - ParsedStaticLayer, -} from "./javascriptRuntimeReconciliationParsing.js"; +import type { ParsedStaticLayer } from "./javascriptRuntimeReconciliationParsing.js"; +import type { ParsedRuntimeCapture } from "./javascriptRuntimeReconciliationCaptureParsing.js"; import { uniqueSorted } from "../canonicalOrdering.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import type { RuntimeProjection } from "./javascriptRuntimeReconciliationRuntime.js"; diff --git a/src/domain/javascript/javascriptRuntimeReconciliationInputValidation.ts b/src/domain/javascript/javascriptRuntimeReconciliationInputValidation.ts new file mode 100644 index 0000000000..7b303b3e44 --- /dev/null +++ b/src/domain/javascript/javascriptRuntimeReconciliationInputValidation.ts @@ -0,0 +1,57 @@ +import { z } from "zod"; +import type { Evidence } from "../evidence.js"; + +/** Prefix caller paths on a nested Zod validation boundary. */ +export const parseAtPath = ( + parse: (input: unknown) => Value, + input: unknown, + path: readonly (string | number)[], +): Value => { + try { + return parse(input); + } catch (cause: unknown) { + if (cause instanceof z.ZodError) + throw new z.ZodError( + cause.issues.map((issue) => ({ + ...issue, + path: [...path, ...issue.path], + })), + ); + throw cause; + } +}; + +/** Represent one semantic caller constraint as a standard validation issue. */ +export const invalidInput = ( + path: readonly (string | number)[], + message: string, +): z.ZodError => new z.ZodError([{ code: "custom", path: [...path], message }]); + +/** Verify an authenticated Evidence record against one runtime workflow. */ +export const assertEvidenceIdentity = ( + evidence: Evidence, + expected: { + readonly operation: string; + readonly predicate: string; + readonly providerId: string; + readonly providerName: string; + readonly providerVersion: string; + readonly authority: Evidence["authority"]; + readonly confidence: Evidence["confidence"]; + }, + path: readonly (string | number)[], +): void => { + if ( + evidence.operation !== expected.operation || + evidence.predicate_type !== expected.predicate || + evidence.provider.id !== expected.providerId || + evidence.provider.name !== expected.providerName || + evidence.provider.version !== expected.providerVersion || + evidence.authority !== expected.authority || + evidence.confidence !== expected.confidence + ) + throw invalidInput( + path, + `Evidence does not match the supported ${expected.operation} contract`, + ); +}; diff --git a/src/domain/javascript/javascriptRuntimeReconciliationParsing.ts b/src/domain/javascript/javascriptRuntimeReconciliationParsing.ts index 4c43103413..afae449989 100644 --- a/src/domain/javascript/javascriptRuntimeReconciliationParsing.ts +++ b/src/domain/javascript/javascriptRuntimeReconciliationParsing.ts @@ -1,27 +1,3 @@ -import { z } from "zod"; - -import { - browserAllowedOriginsSchema, - browserEndpointSchema, -} from "../browserObservation.js"; -import { - webPageInspectionSchema, - type WebPageInspection, -} from "../browserObservationSchemas.js"; -import { - classifyBrowserCompleteness, - type BrowserCompleteness, -} from "../browserCompleteness.js"; -import { - electronPageInspectionSchema, - type ElectronPageInspection, -} from "./electronObservation.js"; -import { - javascriptRuntimeObservationSchema, - javascriptRuntimeKindSchema, - type JavaScriptRuntimeObservation, - type JavaScriptRuntimeTargetLocation, -} from "./javascriptRuntimeObservation.js"; import { digestCanonicalValue } from "../canonicalDigest.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { parseEvidence, type Evidence } from "../evidence.js"; @@ -29,9 +5,13 @@ import { javascriptApplicationAnalysisResultSchema, type JavaScriptApplicationAnalysisResult, } from "./javascriptApplicationAnalysis.js"; -import { type JavaScriptApplicationGraph } from "./javascriptApplicationGraph.js"; -import { parseActiveElectronCapture } from "./javascriptRuntimeReconciliationActive.js"; +import type { JavaScriptApplicationGraph } from "./javascriptApplicationGraph.js"; import type { ReconcileJavaScriptRuntimeInput } from "./javascriptRuntimeReconciliationSchemas.js"; +import { + assertEvidenceIdentity, + invalidInput, + parseAtPath, +} from "./javascriptRuntimeReconciliationInputValidation.js"; type StaticLayerInput = ReconcileJavaScriptRuntimeInput["static_layers"][number]; @@ -46,138 +26,50 @@ export interface ParsedStaticLayer { readonly runtimeMappings: readonly RuntimeMapping[]; } -interface RuntimeCaptureBase { - readonly evidence: Evidence; - readonly captureSha256: string; - readonly scriptsCompleteWithinScope: boolean; -} - -interface NormalizedV8Inspection { - readonly target: - | { - readonly target_id: string; - readonly type: string; - readonly title: string; - readonly attached: boolean; - readonly file_path: string; - } - | { - readonly target_id: string; - readonly type: string; - readonly title: string; - readonly attached: boolean; - readonly url: string; - } - | { - readonly target_id: string; - readonly type: string; - readonly title: string; - readonly attached: boolean; - readonly unresolved_location: Extract< - JavaScriptRuntimeObservation["target"]["location"], - { kind: "unresolved" } - >; - }; - readonly frames: readonly []; - readonly scripts: { - readonly items: readonly (Readonly<{ - script_key: string; - frame_id: string | null; - cdp_hash: string | null; - length: number | null; - is_module: boolean | null; - language: null; - source: { readonly included: false; readonly reason: string }; - }> & - ( - | { readonly file_path: string } - | { readonly url: string } - | { - readonly unresolved_location: Extract< - JavaScriptRuntimeObservation["scripts"]["items"][number]["location"], - { kind: "unresolved" } - >; - } - ))[]; - }; - readonly workers: readonly []; - readonly completeness: BrowserCompleteness; -} - -export type ParsedRuntimeCapture = - | (RuntimeCaptureBase & { - readonly kind: "browser"; - readonly inspection: WebPageInspection; - }) - | (RuntimeCaptureBase & { - readonly kind: "electron"; - readonly inspection: ElectronPageInspection; - }) - | (RuntimeCaptureBase & { - readonly kind: "v8-inspector"; - readonly inspection: NormalizedV8Inspection; - }) - | (RuntimeCaptureBase & { - readonly kind: "electron-active"; - readonly inspection: { - readonly target: { - readonly target_id: string; - readonly type: string; - readonly title: string; - readonly attached: boolean; - readonly file_path: string; - }; - readonly frames: readonly []; - readonly scripts: { readonly items: readonly [] }; - readonly workers: readonly []; - readonly completeness: BrowserCompleteness; - }; - }); - /** Parse and semantically bind every static analysis Evidence layer. */ export const parseStaticLayers = ( layers: readonly StaticLayerInput[], + path: readonly (string | number)[] = ["static_layers"], ): ParsedStaticLayer[] => layers - .map((layer) => parseStaticLayer(layer)) + .map((layer, index) => + parseStaticLayer(layer, [...path, index, "analysis"]), + ) .sort((left, right) => compareUnicodeCodePoints(left.layerId, right.layerId), ); -/** Parse only supported passive web/Electron inspection Evidence. */ -export const parseRuntimeCaptures = ( - observations: readonly Evidence[], -): ParsedRuntimeCapture[] => - observations - .map((observation) => parseRuntimeCapture(observation)) - .sort((left, right) => - compareUnicodeCodePoints( - left.evidence.evidence_id, - right.evidence.evidence_id, - ), - ); - -const parseStaticLayer = (layer: StaticLayerInput): ParsedStaticLayer => { - const evidence = parseEvidence(layer.analysis); - const result = javascriptApplicationAnalysisResultSchema.parse( +const parseStaticLayer = ( + layer: StaticLayerInput, + evidencePath: readonly (string | number)[], +): ParsedStaticLayer => { + const evidence = parseAtPath(parseEvidence, layer.analysis, evidencePath); + const result = parseAtPath( + (value) => javascriptApplicationAnalysisResultSchema.parse(value), evidence.normalized_result, + [...evidencePath, "normalized_result"], + ); + assertEvidenceIdentity( + evidence, + { + operation: "analyze_javascript_application", + predicate: "rea.javascript-application-analysis", + providerId: "rea-javascript-application", + providerName: "REA JavaScript application analyzer", + providerVersion: "1", + authority: "shipped-artifact", + confidence: "derived", + }, + evidencePath, ); - assertEvidenceIdentity(evidence, { - operation: "analyze_javascript_application", - predicate: "rea.javascript-application-analysis", - providerId: "rea-javascript-application", - providerName: "REA JavaScript application analyzer", - providerVersion: "1", - authority: "shipped-artifact", - confidence: "derived", - }); if ( evidence.subject === null || evidence.subject.digest.sha256 !== result.root_artifact_sha256 || evidence.subject.format !== result.format || evidence.subject.local_path !== result.input_path ) - throw new TypeError( + throw invalidInput( + [...evidencePath, "subject"], "JavaScript application Evidence subject disagrees with its result", ); return { @@ -196,294 +88,3 @@ const parseStaticLayer = (layer: StaticLayerInput): ParsedStaticLayer => { runtimeMappings: layer.runtime_mappings, }; }; - -const parseRuntimeCapture = (input: Evidence): ParsedRuntimeCapture => { - const evidence = parseEvidence(input); - if (evidence.operation === "inspect_web_page") { - assertEvidenceIdentity(evidence, { - operation: "inspect_web_page", - predicate: "rea.web-page-inspection", - providerId: "rea-cdp-browser", - providerName: "REA Chrome DevTools Protocol observation provider", - providerVersion: "2", - authority: "external-service", - confidence: "observed", - }); - const inspection = webPageInspectionSchema.parse( - evidence.normalized_result, - ); - assertRuntimeParameters(evidence, { - kind: "browser", - targetId: inspection.target.target_id, - targetOrigin: inspection.target.origin, - sourceIncluded: inspection.scripts.items.some( - ({ source }) => source.included, - ), - }); - return { - kind: "browser", - evidence, - inspection, - captureSha256: digestCanonicalValue(inspection, "Runtime reconciliation"), - scriptsCompleteWithinScope: scriptsComplete(inspection.completeness), - }; - } - if (evidence.operation === "inspect_electron_page") { - assertEvidenceIdentity(evidence, { - operation: "inspect_electron_page", - predicate: "rea.electron-page-inspection", - providerId: "rea-cdp-electron", - providerName: "REA Electron file-page CDP observation provider", - providerVersion: "1", - authority: "external-service", - confidence: "observed", - }); - const inspection = electronPageInspectionSchema.parse( - evidence.normalized_result, - ); - assertRuntimeParameters(evidence, { - kind: "electron", - targetId: inspection.target.target_id, - sourceIncluded: inspection.scripts.items.some( - ({ source }) => source.included, - ), - }); - return { - kind: "electron", - evidence, - inspection, - captureSha256: digestCanonicalValue(inspection, "Runtime reconciliation"), - scriptsCompleteWithinScope: scriptsComplete(inspection.completeness), - }; - } - if (evidence.operation === "observe_javascript_runtime") { - assertEvidenceIdentity(evidence, { - operation: "observe_javascript_runtime", - predicate: "rea.javascript-runtime-observation", - providerId: "rea-v8-inspector", - providerName: "REA passive Node/Electron V8 Inspector provider", - providerVersion: "1", - authority: "external-service", - confidence: "observed", - }); - const result = javascriptRuntimeObservationSchema.parse( - evidence.normalized_result, - ); - assertV8RuntimeParameters(evidence, result); - const inspection = normalizeV8Inspection(result); - return { - kind: "v8-inspector", - evidence, - inspection, - captureSha256: digestCanonicalValue(result, "Runtime reconciliation"), - scriptsCompleteWithinScope: false, - }; - } - if (evidence.operation === "capture_electron_scenario") { - return parseActiveElectronCapture(evidence); - } - throw new TypeError( - "Runtime reconciliation requires inspect_web_page, inspect_electron_page, observe_javascript_runtime, or capture_electron_scenario Evidence", - ); -}; - -const assertV8RuntimeParameters = ( - evidence: Evidence, - result: JavaScriptRuntimeObservation, -): void => { - const parameters = z - .object({ - inspector_endpoint: browserEndpointSchema, - target_id: z.string().trim().min(1), - runtime_kind: javascriptRuntimeKindSchema.optional(), - }) - .passthrough() - .parse(evidence.parameters); - if ( - parameters.target_id !== result.target.target_id || - (parameters.runtime_kind !== undefined && - parameters.runtime_kind !== result.target.runtime_kind) - ) - throw new TypeError( - "Runtime Evidence target or declared role disagrees with its result", - ); - for (const location of [ - result.target.location, - ...result.scripts.items.map(({ location }) => location), - ]) - if (location.kind === "builtin" && !location.specifier.startsWith("node:")) - throw new TypeError( - "Runtime Evidence contains an invalid builtin location", - ); -}; - -const normalizeV8Inspection = ( - result: JavaScriptRuntimeObservation, -): NormalizedV8Inspection => ({ - target: { - target_id: result.target.target_id, - type: result.target.protocol_type, - title: result.target.runtime_kind, - attached: result.target.attached, - ...(result.target.location.kind === "unresolved" - ? { unresolved_location: result.target.location } - : runtimeLocation(result.target.location)), - }, - frames: [], - scripts: { - items: result.scripts.items.map((script) => ({ - script_key: script.script_key, - frame_id: script.execution_context_key, - ...runtimeLocation(script.location), - cdp_hash: script.cdp_hash, - length: script.length, - is_module: script.is_module, - language: null, - source: { - included: false, - reason: "V8 Inspector source capture is outside passive authority.", - }, - })), - }, - workers: [], - completeness: classifyBrowserCompleteness({ - policyFilteredSections: new Set( - excludedV8Scripts(result) > 0 ? ["scripts"] : [], - ), - attachLimitedSections: new Set([ - "frames", - "scripts", - "script_sources", - "workers", - ]), - truncatedSections: new Set(result.capture.truncated ? ["scripts"] : []), - unavailableSections: new Set(["frames", "script_sources", "workers"]), - excluded: - excludedV8Scripts(result) === 0 - ? [] - : [ - { - section: "scripts", - reason: "out_of_target_scope", - count: excludedV8Scripts(result), - }, - ], - droppedEvents: { - scripts: result.capture.events_dropped, - network_requests: 0, - console_events: 0, - websocket_connections: 0, - websocket_frames: 0, - webmcp_tools: 0, - timeline_events: 0, - }, - }), -}); - -const runtimeLocation = ( - location: JavaScriptRuntimeTargetLocation, -): - | { readonly file_path: string } - | { readonly url: string } - | { - readonly unresolved_location: Extract< - JavaScriptRuntimeTargetLocation, - { kind: "unresolved" } - >; - } => { - if (location.kind === "unresolved") return { unresolved_location: location }; - if (location.kind === "file") return { file_path: location.file_path }; - if (location.kind === "url") return { url: location.sanitized_url }; - return { url: location.specifier }; -}; - -const excludedV8Scripts = (result: JavaScriptRuntimeObservation): number => - Object.values(result.scripts.excluded).reduce( - (total, count) => total + count, - 0, - ); - -const assertRuntimeParameters = ( - evidence: Evidence, - expected: - | { - readonly kind: "browser"; - readonly targetId: string; - readonly targetOrigin: string; - readonly sourceIncluded: boolean; - } - | { - readonly kind: "electron"; - readonly targetId: string; - readonly sourceIncluded: boolean; - }, -): void => { - const common = { - target_id: z.string().trim().min(1), - cdp_endpoint: browserEndpointSchema, - include_script_sources: z.boolean(), - }; - if (expected.kind === "browser") { - const parameters = z - .object({ ...common, allowed_origins: browserAllowedOriginsSchema }) - .passthrough() - .parse(evidence.parameters); - if (parameters.target_id !== expected.targetId) - throw new TypeError( - "Runtime Evidence target disagrees with its captured result", - ); - if (!parameters.allowed_origins.includes(expected.targetOrigin)) - throw new TypeError( - "Browser Evidence target is outside its recorded origin scope", - ); - if (expected.sourceIncluded && !parameters.include_script_sources) - throw new TypeError( - "Runtime Evidence contains source without source-capture selection", - ); - return; - } - const parameters = z.object(common).passthrough().parse(evidence.parameters); - if (parameters.target_id !== expected.targetId) - throw new TypeError( - "Runtime Evidence target disagrees with its captured result", - ); - if (expected.sourceIncluded && !parameters.include_script_sources) - throw new TypeError( - "Runtime Evidence contains source without source-capture selection", - ); -}; - -const scriptsComplete = (completeness: { - readonly truncated_sections: readonly string[]; - readonly unavailable_sections: readonly string[]; - readonly dropped_events: { readonly scripts: number }; -}): boolean => - !completeness.truncated_sections.includes("scripts") && - !completeness.unavailable_sections.includes("scripts") && - completeness.dropped_events.scripts === 0; - -const assertEvidenceIdentity = ( - evidence: Evidence, - expected: { - readonly operation: string; - readonly predicate: string; - readonly providerId: string; - readonly providerName: string; - readonly providerVersion: string; - readonly authority: Evidence["authority"]; - readonly confidence: Evidence["confidence"]; - }, -): void => { - if ( - evidence.operation !== expected.operation || - evidence.predicate_type !== expected.predicate || - evidence.provider.id !== expected.providerId || - evidence.provider.name !== expected.providerName || - evidence.provider.version !== expected.providerVersion || - evidence.authority !== expected.authority || - evidence.confidence !== expected.confidence - ) - throw new TypeError( - `Evidence does not match the supported ${expected.operation} contract`, - ); -}; diff --git a/src/domain/javascript/javascriptRuntimeReconciliationResult.ts b/src/domain/javascript/javascriptRuntimeReconciliationResult.ts index a814deae18..19226f27cb 100644 --- a/src/domain/javascript/javascriptRuntimeReconciliationResult.ts +++ b/src/domain/javascript/javascriptRuntimeReconciliationResult.ts @@ -3,10 +3,8 @@ import { digestCanonicalValue } from "../canonicalDigest.js"; import { uniqueSorted } from "../canonicalOrdering.js"; import type { StaticLoadStateProjection } from "./javascriptRuntimeLoadState.js"; import type { RuntimeMatchingProjection } from "./javascriptRuntimeReconciliationMatching.js"; -import type { - ParsedRuntimeCapture, - ParsedStaticLayer, -} from "./javascriptRuntimeReconciliationParsing.js"; +import type { ParsedStaticLayer } from "./javascriptRuntimeReconciliationParsing.js"; +import type { ParsedRuntimeCapture } from "./javascriptRuntimeReconciliationCaptureParsing.js"; import { javascriptRuntimeReconciliationResultSchema, type JavaScriptRuntimeReconciliationResult, diff --git a/src/domain/javascript/javascriptRuntimeReconciliationRuntime.ts b/src/domain/javascript/javascriptRuntimeReconciliationRuntime.ts index 7d9cfd42ce..8c01377013 100644 --- a/src/domain/javascript/javascriptRuntimeReconciliationRuntime.ts +++ b/src/domain/javascript/javascriptRuntimeReconciliationRuntime.ts @@ -8,7 +8,7 @@ import type { ApplicationNode, } from "./javascriptApplicationGraphSchemas.js"; import type { ApplicationGraphEvidence } from "./javascriptApplicationEvidenceSchemas.js"; -import type { ParsedRuntimeCapture } from "./javascriptRuntimeReconciliationParsing.js"; +import type { ParsedRuntimeCapture } from "./javascriptRuntimeReconciliationCaptureParsing.js"; export interface RuntimeReconciliationEntity { readonly kind: "target" | "frame" | "script" | "worker"; diff --git a/tests/boundary/browser/cdpScriptMetadata.test.ts b/tests/boundary/browser/cdpScriptMetadata.test.ts index 5f6f3ce4ef..37b1eb13f0 100644 --- a/tests/boundary/browser/cdpScriptMetadata.test.ts +++ b/tests/boundary/browser/cdpScriptMetadata.test.ts @@ -12,7 +12,7 @@ import { publishWebScripts } from "../../../src/browser/assets/PublishWebScripts import { selectScriptCapture } from "../../../src/browser/assets/ScriptCaptureAdapters.js"; import { inspectWebPageInputSchema } from "../../../src/domain/browserObservation.js"; import { inspectElectronPageInputSchema } from "../../../src/domain/javascript/electronObservation.js"; -import { parseRuntimeCaptures } from "../../../src/domain/javascript/javascriptRuntimeReconciliationParsing.js"; +import { parseRuntimeCaptures } from "../../../src/domain/javascript/javascriptRuntimeReconciliationCaptureParsing.js"; import { projectRuntimeCaptures } from "../../../src/domain/javascript/javascriptRuntimeReconciliationRuntime.js"; import { V8InspectorProvider } from "../../../src/inspector/V8InspectorProvider.js"; import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; diff --git a/tests/boundary/inspector/javascriptRuntimeObservation.test.ts b/tests/boundary/inspector/javascriptRuntimeObservation.test.ts index 149d6e8334..4dd5612408 100644 --- a/tests/boundary/inspector/javascriptRuntimeObservation.test.ts +++ b/tests/boundary/inspector/javascriptRuntimeObservation.test.ts @@ -5,11 +5,13 @@ import { pathToFileURL } from "node:url"; import { describe, expect, test } from "vitest"; import { createJavaScriptRuntimeObservationEvidence } from "../../../src/application/javascript/JavaScriptRuntimeObservationEvidence.js"; +import { createBrowserEvidence } from "../../../src/application/BrowserEvidence.js"; import { listJavaScriptRuntimeTargets, observeJavaScriptRuntime, } from "../../../src/application/javascript/JavaScriptRuntimeObservationService.js"; import { reconcileJavaScriptRuntimeEvidence } from "../../../src/application/javascript/JavaScriptRuntimeReconciliationService.js"; +import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; import { V8_INSPECTOR_PROVIDER_IDENTITY } from "../../../src/inspector/providerIdentity.js"; import { V8InspectorProvider } from "../../../src/inspector/V8InspectorProvider.js"; import { JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE } from "../../../src/contracts/javascript/javascriptRuntimeReconciliationExample.js"; @@ -18,10 +20,102 @@ import type { ObserveJavaScriptRuntimeInput, } from "../../../src/domain/javascript/javascriptRuntimeObservation.js"; import { observeJavaScriptRuntimeInputSchema } from "../../../src/domain/javascript/javascriptRuntimeObservation.js"; +import { inspectWebPageInputSchema } from "../../../src/domain/browserObservation.js"; +import { createEvidence } from "../../../src/domain/evidence.js"; import { javascriptRuntimeReconciliationResultSchema } from "../../../src/domain/javascript/javascriptRuntimeReconciliationSchemas.js"; import { startFakeV8Inspector } from "../../fixtures/inspector/fakeV8Inspector.js"; +import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +test("reports static-layer Evidence digest failure at its caller path", () => { + const layer = JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE.static_layers[0]; + if (layer === undefined) throw new Error("Expected a static layer example"); + const runtime = createJavaScriptRuntimeObservationEvidence( + "observe_javascript_runtime", + { + inspector_endpoint: "http://127.0.0.1:9229", + target_id: "example-v8-target", + runtime_kind: "electron-main", + observation_ms: 100, + }, + runtimeObservation("/tmp/index.html", "/tmp/main.js"), + V8_INSPECTOR_PROVIDER_IDENTITY, + ); + const result = reconcileJavaScriptRuntimeEvidence({ + static_layers: [ + { + ...layer, + analysis: { ...layer.analysis, evidence_id: `ev_${"0".repeat(64)}` }, + }, + ], + runtime_observations: [runtime], + }); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisInputError", + issues: [ + { + path: ["static_layers", 0, "analysis", "evidence_id"], + reason: "invalid_value", + message: "Evidence semantic identifier does not match its record", + }, + ], + }, + }); +}); + +test("reports browser origin scope mismatch at the caller parameter path", async () => { + const browser = await startFakeCdpBrowser(); + try { + const provider = new CdpBrowserProvider(); + const input = inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 0, + }); + const observed = await provider.inspectPage(input); + if (!observed.ok) throw observed.error; + const evidence = createBrowserEvidence( + "inspect_web_page", + input, + observed.value, + provider.identity(), + ); + const invalidEvidence = createEvidence(undefined, evidence.provider, { + predicateType: evidence.predicate_type, + operation: evidence.operation, + parameters: { ...evidence.parameters, allowed_origins: [] }, + result: evidence.normalized_result, + confidence: evidence.confidence, + authority: evidence.authority, + environment: evidence.environment, + limitations: evidence.limitations, + }); + const result = reconcileJavaScriptRuntimeEvidence({ + static_layers: JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE.static_layers, + runtime_observations: [invalidEvidence], + }); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisInputError", + issues: [ + { + path: ["runtime_observations", 0, "parameters", "allowed_origins"], + reason: "invalid_value", + message: + "Browser Evidence target is outside its recorded origin scope", + }, + ], + }, + }); + } finally { + await browser.close(); + } +}); + describe("Inspector execution-context lifecycle metadata", () => { test.each([ [ diff --git a/tests/boundary/inspector/javascriptRuntimeUnresolvedTarget.test.ts b/tests/boundary/inspector/javascriptRuntimeUnresolvedTarget.test.ts index d56518032c..cfc47a7355 100644 --- a/tests/boundary/inspector/javascriptRuntimeUnresolvedTarget.test.ts +++ b/tests/boundary/inspector/javascriptRuntimeUnresolvedTarget.test.ts @@ -7,10 +7,21 @@ import { expect, test } from "vitest"; import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js"; import { createJavaScriptRuntimeObservationEvidence } from "../../../src/application/javascript/JavaScriptRuntimeObservationEvidence.js"; import { V8InspectorProvider } from "../../../src/inspector/V8InspectorProvider.js"; -import { reconcileJavaScriptRuntime } from "../../../src/domain/javascript/javascriptRuntimeReconciliation.js"; +import { + parseRuntimeReconciliationInput, + reconcileJavaScriptRuntime, +} from "../../../src/domain/javascript/javascriptRuntimeReconciliation.js"; +import { reconcileJavaScriptRuntimeInputSchema } from "../../../src/domain/javascript/javascriptRuntimeReconciliationSchemas.js"; import { startFakeV8Inspector } from "../../fixtures/inspector/fakeV8Inspector.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +const reconcileInput = (input: unknown) => + reconcileJavaScriptRuntime( + parseRuntimeReconciliationInput( + reconcileJavaScriptRuntimeInputSchema.parse(input), + ), + ); + test("keeps unresolved target identity unknown while matching verified loaded scripts", async () => { const root = await createTestTempDirectory("rea-unresolved-runtime-"); const entry = join(root, "entry.js"); @@ -37,7 +48,7 @@ test("keeps unresolved target identity unknown while matching verified loaded sc observed.value, provider.identity(), ); - const result = reconcileJavaScriptRuntime({ + const result = reconcileInput({ static_layers: [{ role: "application", analysis: analysis.value }], runtime_observations: [evidence], }); diff --git a/tests/boundary/process/javascriptRuntimeReconciliationAmbiguity.test.ts b/tests/boundary/process/javascriptRuntimeReconciliationAmbiguity.test.ts index 7d61829ae4..3d709e3c70 100644 --- a/tests/boundary/process/javascriptRuntimeReconciliationAmbiguity.test.ts +++ b/tests/boundary/process/javascriptRuntimeReconciliationAmbiguity.test.ts @@ -9,10 +9,21 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { createElectronEvidence } from "../../../src/application/javascript/ElectronEvidence.js"; import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js"; -import { reconcileJavaScriptRuntime } from "../../../src/domain/javascript/javascriptRuntimeReconciliation.js"; +import { + parseRuntimeReconciliationInput, + reconcileJavaScriptRuntime, +} from "../../../src/domain/javascript/javascriptRuntimeReconciliation.js"; +import { reconcileJavaScriptRuntimeInputSchema } from "../../../src/domain/javascript/javascriptRuntimeReconciliationSchemas.js"; import { inspectElectronPageInputSchema } from "../../../src/domain/javascript/electronObservation.js"; import { createWebTextArtifact } from "../../../src/domain/webContentArtifact.js"; +const reconcileInput = (input: unknown) => + reconcileJavaScriptRuntime( + parseRuntimeReconciliationInput( + reconcileJavaScriptRuntimeInputSchema.parse(input), + ), + ); + const SOURCE = `const worker = new Worker("./worker.js");\nexport const observed = worker;\n`; const execute = promisify(execFile); @@ -36,7 +47,7 @@ it("keeps byte-identical cross-layer candidates explicitly ambiguous", async () writeFile(join(runtime, "app.js"), SOURCE), writeFile(join(runtime, "index.html"), ""), ]); - const result = reconcileJavaScriptRuntime({ + const result = reconcileInput({ static_layers: [ { role: "application", @@ -82,7 +93,7 @@ it("retains all runtime entities and static load states without projection caps" }), ]; - const result = reconcileJavaScriptRuntime({ + const result = reconcileInput({ static_layers: [{ role: "application", analysis: staticEvidence }], runtime_observations: runtimeObservations, }); @@ -121,7 +132,7 @@ it("rejects runtime source bytes whose Evidence omits source-capture approval", }); expect(() => - reconcileJavaScriptRuntime({ + reconcileInput({ static_layers: [{ role: "application", analysis: staticEvidence }], runtime_observations: [contradictoryRuntime], }), @@ -131,7 +142,7 @@ it("rejects runtime source bytes whose Evidence omits source-capture approval", it("keeps graph omission counts unknown when a runtime section is unavailable", async () => { const fixture = await applicationFixture(); temporary.push(fixture); - const result = reconcileJavaScriptRuntime({ + const result = reconcileInput({ static_layers: [ { role: "application", analysis: await analyzeFixture(fixture) }, ], diff --git a/tests/composition/runtime-observation/javascriptRuntimeReconciliation.test.ts b/tests/composition/runtime-observation/javascriptRuntimeReconciliation.test.ts index 1fe3f27568..a6809156eb 100644 --- a/tests/composition/runtime-observation/javascriptRuntimeReconciliation.test.ts +++ b/tests/composition/runtime-observation/javascriptRuntimeReconciliation.test.ts @@ -8,20 +8,71 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { createElectronEvidence } from "../../../src/application/javascript/ElectronEvidence.js"; import { createElectronActiveEvidence } from "../../../src/application/javascript/ElectronActiveEvidence.js"; import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js"; -import { reconcileJavaScriptRuntime } from "../../../src/domain/javascript/javascriptRuntimeReconciliation.js"; +import { reconcileJavaScriptRuntimeEvidence } from "../../../src/application/javascript/JavaScriptRuntimeReconciliationService.js"; +import { createEvidence } from "../../../src/domain/evidence.js"; +import { + parseRuntimeReconciliationInput, + reconcileJavaScriptRuntime, +} from "../../../src/domain/javascript/javascriptRuntimeReconciliation.js"; +import { reconcileJavaScriptRuntimeInputSchema } from "../../../src/domain/javascript/javascriptRuntimeReconciliationSchemas.js"; import { javascriptRuntimeReconciliationResultSchema } from "../../../src/domain/javascript/javascriptRuntimeReconciliationSchemas.js"; import { electronActiveObservationInputSchema } from "../../../src/domain/javascript/electronActiveObservation.js"; import { inspectElectronPageInputSchema } from "../../../src/domain/javascript/electronObservation.js"; import { createWebTextArtifact } from "../../../src/domain/webContentArtifact.js"; import { createElectronActiveObservationFixtureResult } from "../../../src/domain/javascript/electronActiveObservation.fixture.js"; +const reconcileInput = (input: unknown) => + reconcileJavaScriptRuntime( + parseRuntimeReconciliationInput( + reconcileJavaScriptRuntimeInputSchema.parse(input), + ), + ); + const SOURCE = `const worker = new Worker("./worker.js");\nexport const observed = worker;\n`; +it("preserves source-capture input paths for runtime Evidence", async () => { + const root = await applicationFixture(); + const application = await analyzeFixture(root); + const runtime = electronRuntimeEvidence(root, SOURCE); + const invalidRuntime = createEvidence(undefined, runtime.provider, { + predicateType: runtime.predicate_type, + operation: runtime.operation, + parameters: { ...runtime.parameters, include_script_sources: false }, + result: runtime.normalized_result, + confidence: runtime.confidence, + authority: runtime.authority, + }); + + const result = reconcileJavaScriptRuntimeEvidence({ + static_layers: [{ role: "application", analysis: application }], + runtime_observations: [invalidRuntime], + }); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisInputError", + issues: [ + { + path: [ + "runtime_observations", + 0, + "parameters", + "include_script_sources", + ], + reason: "invalid_value", + message: + "Runtime Evidence contains source without source-capture selection", + }, + ], + }, + }); +}); + it("reconciles runtime scripts inside dot-prefixed child directories", async () => { const root = await applicationFixture(); await mkdir(join(root, "..cache")); await writeFile(join(root, "..cache", "app.js"), SOURCE); - const result = reconcileJavaScriptRuntime({ + const result = reconcileInput({ static_layers: [ { role: "application", analysis: await analyzeFixture(root) }, ], @@ -48,7 +99,7 @@ it("matches renderer, frame, script bytes, and worker without claiming execution const staticEvidence = await analyzeFixture(fixture); const runtimeEvidence = electronRuntimeEvidence(fixture, SOURCE); - const result = reconcileJavaScriptRuntime({ + const result = reconcileInput({ static_layers: [{ role: "application", analysis: staticEvidence }], runtime_observations: [runtimeEvidence], }); @@ -143,7 +194,7 @@ it("reports a captured digest disagreement instead of accepting a path match", a "export const observed = 'different';\n", ); - const result = reconcileJavaScriptRuntime({ + const result = reconcileInput({ static_layers: [{ role: "application", analysis: staticEvidence }], runtime_observations: [runtimeEvidence], }); @@ -181,7 +232,7 @@ it("reconciles active Electron as a partial target-only runtime capture", async }, ); - const result = reconcileJavaScriptRuntime({ + const result = reconcileInput({ static_layers: [{ role: "application", analysis: staticEvidence }], runtime_observations: [runtimeEvidence], }); @@ -280,7 +331,7 @@ it("imports an operator-provided cache layer through an explicit file mapping", includeWorker: false, }); - const result = reconcileJavaScriptRuntime({ + const result = reconcileInput({ static_layers: [ { role: "application", analysis: applicationEvidence }, {