diff --git a/src/application/InventoryProjectionEvidence.ts b/src/application/InventoryProjectionEvidence.ts index 2d69bd9463..48285acd5a 100644 --- a/src/application/InventoryProjectionEvidence.ts +++ b/src/application/InventoryProjectionEvidence.ts @@ -7,9 +7,9 @@ import type { AnalysisInputError } from "../domain/analysisErrorCore.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { createEvidence, + evidenceSubjectTarget, type Evidence, type EvidenceProvider, - type EvidenceSubjectTarget, } from "../domain/evidence.js"; import type { JsonValue } from "../domain/jsonValue.js"; import type { ParsedInventorySet } from "../domain/artifactInventoryEvidence.js"; @@ -42,7 +42,6 @@ interface InventoryProjectionOptions< readonly operation: string; readonly predicateType: string; readonly provider: EvidenceProvider; - readonly subjectFormat: (first: Evidence) => EvidenceSubjectTarget["format"]; } /** Resolve source inventory once, then wrap its family projection in Evidence. */ @@ -70,13 +69,7 @@ export const projectInventoryEvidence = < const first = source.value.evidence[0]; return ok({ evidence: createEvidence( - first?.subject === null || first?.subject === undefined - ? undefined - : { - path: first.subject.local_path, - sha256: result.root_sha256, - format: options.subjectFormat(first), - }, + evidenceSubjectTarget(first?.subject ?? null), options.provider, { predicateType: options.predicateType, diff --git a/src/application/ReconstructionObligationCandidates.test.ts b/src/application/ReconstructionObligationCandidates.test.ts index 041e884784..5e223e9c51 100644 --- a/src/application/ReconstructionObligationCandidates.test.ts +++ b/src/application/ReconstructionObligationCandidates.test.ts @@ -79,10 +79,32 @@ const applicationWithUnknowns = ( ], }), ); + const root = createJavaScriptApplicationNode({ + kind: "artifact", + identity: { + strategy: "content-digest", + stability: "global-exact", + sha256: APPLICATION_GRAPH_DIGESTS.asar, + }, + observations: [ + { + label: "managed root", + properties: {}, + evidence: artifactEvidence( + APPLICATION_GRAPH_DIGESTS.asar, + "artifact-root", + ), + }, + ], + }); const graph = createJavaScriptApplicationGraph({ schema: "JavaScriptApplicationGraph", - root_node_ids: base.root_node_ids, - nodes: [...base.nodes, ...unknownNodes], + root_node_ids: [root.node_id], + nodes: [ + ...base.nodes.filter(({ node_id }) => node_id !== root.node_id), + ...unknownNodes, + root, + ], edges: base.edges, coverage: completeCoverage, limitations: base.limitations, diff --git a/src/application/analysisView/AnalysisViewService.ts b/src/application/analysisView/AnalysisViewService.ts index 0b964a0baf..936a17e8d6 100644 --- a/src/application/analysisView/AnalysisViewService.ts +++ b/src/application/analysisView/AnalysisViewService.ts @@ -21,6 +21,7 @@ import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js"; import { safeParseEvidence } from "../../domain/evidenceValidation.js"; import { createEvidence, + evidenceSubjectTarget, rebindEvidenceToTargetPath, type Evidence, type EvidenceSubjectTarget, @@ -327,21 +328,11 @@ export const projectRetainedAnalysis = ( if (!retained.ok) return retained; // Duplicate semantic records retain their original ledger metadata. Detail // selection changes presentation, not the historical lookup or payload owner. - const subject = evidence.subject; return projectResolvedAnalysisView( { source, view }, prepareAnalysisViewProjection(view), retained.value, - subject === null - ? undefined - : { - path: subject.local_path, - sha256: subject.digest.sha256, - format: subject.format, - ...(subject.architecture === null - ? {} - : { architecture: subject.architecture }), - }, + evidenceSubjectTarget(evidence.subject), ); }; @@ -383,17 +374,7 @@ const createAnalysisViewEvidence = ( parent: Evidence, result: AnalysisViewResult, ): Evidence => { - const target = - parent.subject === null - ? undefined - : { - path: parent.subject.local_path, - sha256: parent.subject.digest.sha256, - format: parent.subject.format, - ...(parent.subject.architecture === null - ? {} - : { architecture: parent.subject.architecture }), - }; + const target = evidenceSubjectTarget(parent.subject); const nativeFunctionView = result.kind === "native" || (result.kind === "summary" && diff --git a/src/application/android/AndroidAnalysisService.test.ts b/src/application/android/AndroidAnalysisService.test.ts index 5ac8d36c9d..5ec0f7641d 100644 --- a/src/application/android/AndroidAnalysisService.test.ts +++ b/src/application/android/AndroidAnalysisService.test.ts @@ -2,6 +2,33 @@ import { expect, it, vi } from "vitest"; import { AndroidAnalysisService } from "./AndroidAnalysisService.js"; import type { AndroidAnalysisPort } from "./AndroidAnalysisPort.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; +import { createEvidence } from "../../domain/evidence.js"; +import { ok } from "../../domain/result.js"; +import { summarizeRetainedAndroidSearch } from "./AndroidSearchEvidenceSummary.js"; + +it("retains the selected subject in bounded search Evidence", () => { + const source = createEvidence( + { + path: "/fixture/app.apk", + sha256: "a".repeat(64), + format: "apk", + name: "Selected app", + }, + { id: "fixture", name: "Fixture", version: "1" }, + { + operation: "search_android_classes", + parameters: {}, + result: { matches: [] }, + rawResult: { complete: true }, + }, + ); + const result = summarizeRetainedAndroidSearch(source, { pages: 1 }, () => + ok("added"), + ); + if (!result.ok) throw result.error; + expect(result.value.subject).toEqual(source.subject); + expect(result.value.evidence_links).toContain(source.evidence_id); +}); it("retains selector correction issues before target acquisition", async () => { const execute = vi.fn(); diff --git a/src/application/android/AndroidApplicationService.ts b/src/application/android/AndroidApplicationService.ts index 05a7744828..907e2851ca 100644 --- a/src/application/android/AndroidApplicationService.ts +++ b/src/application/android/AndroidApplicationService.ts @@ -25,6 +25,5 @@ export const projectAndroidApplicationEvidence = ( operation: OPERATION, predicateType: "rea.android-application-graph", provider: ANDROID_APPLICATION_PROVIDER, - subjectFormat: () => "apk", }); }; diff --git a/src/application/android/AndroidSearchEvidenceSummary.ts b/src/application/android/AndroidSearchEvidenceSummary.ts index 9b0f3809f3..a779c50b81 100644 --- a/src/application/android/AndroidSearchEvidenceSummary.ts +++ b/src/application/android/AndroidSearchEvidenceSummary.ts @@ -1,5 +1,9 @@ import type { EvidenceWriter } from "../investigation/InvestigationRecordPort.js"; -import { createEvidence, type Evidence } from "../../domain/evidence.js"; +import { + createEvidence, + evidenceSubjectTarget, + type Evidence, +} from "../../domain/evidence.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import type { JsonValue } from "../../domain/jsonValue.js"; import { ok, type Result } from "../../domain/result.js"; @@ -18,30 +22,16 @@ export const summarizeRetainedAndroidSearch = ( if (recordEvidence === undefined) return ok(evidence); const recorded = recordEvidence(evidence); if (!recorded.ok) return recorded; - const subject = evidence.subject; return ok( - createEvidence( - subject === null - ? undefined - : { - path: subject.local_path, - sha256: subject.digest.sha256, - format: subject.format, - ...(subject.architecture === null - ? {} - : { architecture: subject.architecture }), - }, - evidence.provider, - { - operation: evidence.operation, - parameters: evidence.parameters, - result: evidence.normalized_result, - rawResult: { ...summary, complete_evidence_id: evidence.evidence_id }, - confidence: evidence.confidence, - limitations: evidence.limitations, - locations: evidence.locations, - evidenceLinks: [evidence.evidence_id], - }, - ), + createEvidence(evidenceSubjectTarget(evidence.subject), evidence.provider, { + operation: evidence.operation, + parameters: evidence.parameters, + result: evidence.normalized_result, + rawResult: { ...summary, complete_evidence_id: evidence.evidence_id }, + confidence: evidence.confidence, + limitations: evidence.limitations, + locations: evidence.locations, + evidenceLinks: [evidence.evidence_id], + }), ); }; diff --git a/src/application/apple/AppleApplicationService.ts b/src/application/apple/AppleApplicationService.ts index bee7e256cb..d0a2c040b4 100644 --- a/src/application/apple/AppleApplicationService.ts +++ b/src/application/apple/AppleApplicationService.ts @@ -25,6 +25,5 @@ export const projectAppleApplicationEvidence = ( operation: OPERATION, predicateType: "rea.apple-application-graph", provider: APPLE_APPLICATION_PROVIDER, - subjectFormat: (first) => first.subject?.format ?? "unknown", }); }; diff --git a/src/application/harmony/HarmonyApplicationService.ts b/src/application/harmony/HarmonyApplicationService.ts index 7ee6c946b5..8248b21ff8 100644 --- a/src/application/harmony/HarmonyApplicationService.ts +++ b/src/application/harmony/HarmonyApplicationService.ts @@ -25,6 +25,5 @@ export const projectHarmonyApplicationEvidence = ( operation: OPERATION, predicateType: "rea.harmony-application-graph", provider: HARMONY_APPLICATION_PROVIDER, - subjectFormat: (first) => first.subject?.format ?? "unknown", }); }; diff --git a/src/application/javascript/JavaScriptApplicationEvidenceGraph.ts b/src/application/javascript/JavaScriptApplicationEvidenceGraph.ts index ae982cff7f..8d23cd4e24 100644 --- a/src/application/javascript/JavaScriptApplicationEvidenceGraph.ts +++ b/src/application/javascript/JavaScriptApplicationEvidenceGraph.ts @@ -3,7 +3,12 @@ import { JAVASCRIPT_RUNTIME_RECONCILIATION_PROVIDER, MANAGED_WORKFLOW_PROVIDER, } from "../../domain/investigationProviders.js"; -import { isImmutableEvidence, type Evidence } from "../../domain/evidence.js"; +import { + isImmutableEvidence, + evidenceSubjectTarget, + type Evidence, + type EvidenceSubjectTarget, +} from "../../domain/evidence.js"; import { readEvidence } from "../../domain/evidenceValidation.js"; import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js"; import { @@ -35,6 +40,32 @@ export interface ApplicationGraphEvidenceSource { readonly semanticGraph: JavaScriptSemanticGraph | null; } +/** Carry a subject only when it identifies the graph's root artifact. */ +export const applicationGraphSubjectTarget = ( + source: ApplicationGraphEvidenceSource, +): EvidenceSubjectTarget | undefined => { + const subject = source.evidence.subject; + const staticRootObserved = + source.kind === "static-application" && + source.graph.nodes.some( + (node) => + node.kind === "artifact" && + node.identity.strategy === "content-digest" && + node.identity.sha256 === source.rootArtifactSha256 && + node.observations.some( + ({ evidence }) => evidence.extractor.operation === "inventory-root", + ), + ); + return evidenceSubjectTarget( + staticRootObserved || + (source.kind === "managed-application" && + subject?.format === "pe" && + subject.digest.sha256 === source.rootArtifactSha256) + ? subject + : null, + ); +}; + /** Caller-owned Evidence failed validation at the application graph boundary. */ export interface ApplicationGraphEvidenceInputFailure { readonly issues: readonly AnalysisInputIssue[]; diff --git a/src/application/javascript/JavaScriptApplicationWorkflowEvidence.ts b/src/application/javascript/JavaScriptApplicationWorkflowEvidence.ts index 24e84a9f96..3f0d1f51d4 100644 --- a/src/application/javascript/JavaScriptApplicationWorkflowEvidence.ts +++ b/src/application/javascript/JavaScriptApplicationWorkflowEvidence.ts @@ -2,6 +2,7 @@ import { createEvidence, type Evidence, type EvidenceObservation, + type EvidenceSubjectTarget, } from "../../domain/evidence.js"; import type { ApplicationVersionComparisonResult } from "../../domain/javascript/javascriptApplicationVersionComparisonSchemas.js"; import type { JavaScriptExportShapeComparisonResult } from "../../domain/javascript/javascriptExportShapeComparisonSchemas.js"; @@ -12,10 +13,11 @@ import { JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER } from "../../domain/investiga /** Create derived Evidence for one bounded application-graph feature trace. */ export const createApplicationFeatureTraceEvidence = ( + subject: EvidenceSubjectTarget | undefined, parameters: EvidenceObservation["parameters"], result: ApplicationFeatureTraceResult, ): Evidence => - createEvidence(undefined, JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER, { + createEvidence(subject, JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER, { predicateType: "rea.application-feature-trace", operation: "trace_application_feature", parameters, @@ -30,10 +32,11 @@ export const createApplicationFeatureTraceEvidence = ( /** Create derived Evidence for one bounded JavaScript semantic trace. */ export const createJavaScriptSemanticTraceEvidence = ( + subject: EvidenceSubjectTarget | undefined, parameters: EvidenceObservation["parameters"], result: JavaScriptSemanticTraceResult, ): Evidence => - createEvidence(undefined, JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER, { + createEvidence(subject, JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER, { predicateType: "rea.javascript-semantic-trace", operation: "trace_javascript_semantics", parameters, diff --git a/src/application/javascript/JavaScriptApplicationWorkflowService.ts b/src/application/javascript/JavaScriptApplicationWorkflowService.ts index a0c4d96a98..76e6813890 100644 --- a/src/application/javascript/JavaScriptApplicationWorkflowService.ts +++ b/src/application/javascript/JavaScriptApplicationWorkflowService.ts @@ -36,6 +36,7 @@ import { compareSourceToBundle } from "../../domain/javascript/sourceToBundleCom import type { SourceToBundleComparisonResult } from "../../domain/javascript/sourceToBundleComparisonSchemas.js"; import { applicationGraphEvidenceInputError, + applicationGraphSubjectTarget, parseApplicationGraphEvidence, parseNativeApplicationEvidence, type ApplicationGraphEvidenceSource, @@ -132,6 +133,7 @@ export const traceApplicationFeatureEvidence = ( direction: input.direction, }); const evidence = createApplicationFeatureTraceEvidence( + applicationGraphSubjectTarget(source), { application_evidence_id: source.evidence.evidence_id, native_evidence_ids: nativeEvidence.map(({ evidence_id: id }) => id), diff --git a/src/application/javascript/JavaScriptEvidenceInputs.test.ts b/src/application/javascript/JavaScriptEvidenceInputs.test.ts index 2bd6d77197..2235fc310e 100644 --- a/src/application/javascript/JavaScriptEvidenceInputs.test.ts +++ b/src/application/javascript/JavaScriptEvidenceInputs.test.ts @@ -1,6 +1,15 @@ import { expect, it, vi } from "vitest"; -import { createEvidence, type Evidence } from "../../domain/evidence.js"; +import { + createEvidence, + parseEvidence, + rebindEvidenceToTargetPath, + type Evidence, +} from "../../domain/evidence.js"; +import { ok } from "../../domain/result.js"; +import { createJavaScriptApplicationGraph } from "../../domain/javascript/javascriptApplicationGraph.js"; +import { createJavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticGraph.js"; +import { javascriptApplicationAnalysisResultSchema } from "../../domain/javascript/javascriptApplicationAnalysis.js"; import { JAVASCRIPT_APPLICATION_VERSION_COMPARISON_EXAMPLE, JAVASCRIPT_FEATURE_TRACE_EXAMPLE, @@ -14,18 +23,165 @@ import { } from "./JavaScriptApplicationWorkflowService.js"; import { reconcileJavaScriptRuntimeEvidence } from "./JavaScriptRuntimeReconciliationService.js"; import { traceJavaScriptSemanticsEvidence } from "./JavaScriptSemanticTraceService.js"; +import { + inspectAnalysisView, + projectRetainedAnalysis, +} from "../analysisView/AnalysisViewService.js"; const reference = ({ evidence_id }: Evidence) => ({ kind: "retained-evidence" as const, evidence_id, }); +it("keeps a static trace subject unknown when legacy graph root evidence is omitted", () => { + const original = JAVASCRIPT_FEATURE_TRACE_EXAMPLE.application; + const result = javascriptApplicationAnalysisResultSchema.parse( + original.normalized_result, + ); + const { graph_id: _graphId, ...graphInput } = result.graph; + const graph = createJavaScriptApplicationGraph({ + ...graphInput, + nodes: graphInput.nodes.filter(({ kind }) => kind !== "artifact"), + root_node_ids: graphInput.nodes + .filter(({ kind }) => kind === "javascript-asset") + .map(({ node_id }) => node_id), + edges: [], + }); + const { graph_id: _semanticId, ...semanticInput } = result.semantic_graph; + const semanticGraph = createJavaScriptSemanticGraph({ + ...semanticInput, + application_graph_id: graph.graph_id, + }); + if (original.subject === null) + throw new Error("Expected application subject"); + const legacy = createEvidence( + { + path: original.subject.local_path, + sha256: original.subject.digest.sha256, + format: original.subject.format, + }, + original.provider, + { + operation: original.operation, + predicateType: original.predicate_type, + parameters: original.parameters, + result: { ...result, graph, semantic_graph: semanticGraph }, + confidence: "derived", + authority: "shipped-artifact", + }, + ); + const trace = traceApplicationFeatureEvidence({ + ...JAVASCRIPT_FEATURE_TRACE_EXAMPLE, + application: legacy, + }); + if (!trace.ok) throw trace.error; + expect(trace.value.evidence.subject).toBeNull(); +}); + const native = createEvidence( { path: "/example/addon.node", sha256: "e".repeat(64), format: "elf" }, { id: "fixture", name: "Native fixture", version: "1" }, { operation: "procedure_info", parameters: {}, result: {} }, ); +it("preserves the verified application subject in both trace workflows", async () => { + const original = JAVASCRIPT_FEATURE_TRACE_EXAMPLE.application; + if (original.subject === null) + throw new Error("Expected application subject"); + const application = createEvidence( + { + path: original.subject.local_path, + sha256: original.subject.digest.sha256, + format: original.subject.format, + architecture: "x86_64", + name: "caller-selected-name", + }, + original.provider, + { + operation: original.operation, + predicateType: original.predicate_type, + parameters: original.parameters, + result: original.normalized_result, + confidence: "derived", + authority: "shipped-artifact", + }, + ); + const feature = traceApplicationFeatureEvidence({ + ...JAVASCRIPT_FEATURE_TRACE_EXAMPLE, + application, + }); + const semantic = traceJavaScriptSemanticsEvidence({ + application, + query: { + seed: { kind: "property", name: "unobserved" }, + direction: "ownership", + }, + }); + for (const result of [feature, semantic]) { + if (!result.ok) throw result.error; + expect(result.value.evidence.subject).toEqual(application.subject); + expect(result.value.evidence.limitations).not.toContain( + "Artifact identity is unavailable for this observation.", + ); + } + if (!feature.ok) throw feature.error; + const view = await inspectAnalysisView({ + source: { kind: "inline", evidence: feature.value.evidence }, + view: { kind: "summary" }, + }); + if (!view.ok) throw view.error; + expect(view.value.subject).toEqual(application.subject); + const selected = parseEvidence({ + ...feature.value.evidence, + subject: { ...feature.value.evidence.subject, name: "selected display" }, + }); + const retained = projectRetainedAnalysis( + selected, + { + recordEvidence: () => ok("added"), + evidenceById: () => feature.value.evidence, + }, + { kind: "summary" }, + ); + if (!retained.ok) throw retained.error; + expect(retained.value.subject?.name).toBe("selected display"); + expect( + rebindEvidenceToTargetPath(feature.value.evidence, { + path: application.subject!.local_path, + sha256: "f".repeat(64), + format: application.subject!.format, + architecture: "x86_64", + name: "wrong artifact", + }), + ).toBe(feature.value.evidence); +}); + +it("does not attribute a multi-source reconciliation graph to its envelope subject", () => { + const original = JAVASCRIPT_APPLICATION_VERSION_COMPARISON_EXAMPLE.right; + const reconciliation = createEvidence( + { path: "/other/artifact", sha256: "f".repeat(64), format: "file" }, + original.provider, + { + operation: original.operation, + predicateType: original.predicate_type, + parameters: original.parameters, + result: original.normalized_result, + confidence: "inferred", + authority: "analyst-inference", + evidenceLinks: original.evidence_links, + }, + ); + const result = traceApplicationFeatureEvidence({ + ...JAVASCRIPT_FEATURE_TRACE_EXAMPLE, + application: reconciliation, + }); + if (!result.ok) throw result.error; + expect(result.value.evidence.subject).toBeNull(); + expect(result.value.evidence.limitations).toContain( + "Artifact identity is unavailable for this observation.", + ); +}); + it("resolves feature native arrays without changing source identity or unknowns", () => { const input = { ...JAVASCRIPT_FEATURE_TRACE_EXAMPLE, diff --git a/src/application/javascript/JavaScriptSemanticTraceService.ts b/src/application/javascript/JavaScriptSemanticTraceService.ts index 0f0be04ca6..d656abd861 100644 --- a/src/application/javascript/JavaScriptSemanticTraceService.ts +++ b/src/application/javascript/JavaScriptSemanticTraceService.ts @@ -8,6 +8,7 @@ import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { applicationGraphEvidenceInputError, + applicationGraphSubjectTarget, parseApplicationGraphEvidence, } from "./JavaScriptApplicationEvidenceGraph.js"; import { queryJavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticQuery.js"; @@ -63,6 +64,7 @@ export const traceJavaScriptSemanticsEvidence = ( }; const { allowed_relations, ...queryParameters } = input.query; const evidence = createJavaScriptSemanticTraceEvidence( + applicationGraphSubjectTarget(source), { application_evidence_id: source.evidence.evidence_id, query: diff --git a/src/application/managed/ManagedApplicationGraphService.ts b/src/application/managed/ManagedApplicationGraphService.ts index 5f401a45b0..1a2b43cdca 100644 --- a/src/application/managed/ManagedApplicationGraphService.ts +++ b/src/application/managed/ManagedApplicationGraphService.ts @@ -6,6 +6,7 @@ import type { ExecutionOptions } from "../AnalysisProvider.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { createEvidence, + evidenceSubjectTarget, type Evidence, type EvidenceSubjectTarget, } from "../../domain/evidence.js"; @@ -90,12 +91,5 @@ const subjectTarget = ( input.boundaries?.evidence.subject; if (subject === undefined || subject === null || subject.format !== "pe") return undefined; - return { - path: subject.local_path, - sha256: subject.digest.sha256, - format: "pe", - ...(subject.architecture === null - ? {} - : { architecture: subject.architecture }), - }; + return evidenceSubjectTarget(subject); }; diff --git a/src/contracts/javascript/javascriptRuntimeReconciliationExample.ts b/src/contracts/javascript/javascriptRuntimeReconciliationExample.ts index a13aea0422..9af0980a76 100644 --- a/src/contracts/javascript/javascriptRuntimeReconciliationExample.ts +++ b/src/contracts/javascript/javascriptRuntimeReconciliationExample.ts @@ -2,6 +2,7 @@ import { createEvidence } from "../../domain/evidence.js"; import { createJavaScriptApplicationGraph, createJavaScriptApplicationNode, + createJavaScriptApplicationEdge, } from "../../domain/javascript/javascriptApplicationGraph.js"; import { createWebTextArtifact } from "../../domain/webContentArtifact.js"; import { @@ -63,11 +64,46 @@ const asset = createJavaScriptApplicationNode({ }, ], }); +const root = createJavaScriptApplicationNode({ + kind: "artifact", + identity: { + strategy: "content-digest", + stability: "global-exact", + sha256: applicationSha256, + }, + observations: [ + { + label: "artifact root", + properties: { format: "directory" }, + evidence: { + ...graphEvidence, + artifact: { + available: true, + artifact_id: `art_${applicationSha256}`, + sha256: applicationSha256, + }, + location: { + available: true, + value: { kind: "artifact-path", path: "artifact-root" }, + }, + extractor: { ...graphEvidence.extractor, operation: "inventory-root" }, + }, + }, + ], +}); const graph = createJavaScriptApplicationGraph({ schema: "JavaScriptApplicationGraph", - root_node_ids: [asset.node_id], - nodes: [asset], - edges: [], + root_node_ids: [root.node_id], + nodes: [asset, root], + edges: [ + createJavaScriptApplicationEdge({ + source_node_id: root.node_id, + target_node_id: asset.node_id, + relation: "contains", + properties: { path: "renderer.js" }, + evidence: graphEvidence, + }), + ], coverage: completeCoverage, limitations: [], }); diff --git a/src/domain/analysisView/analysisView.test.ts b/src/domain/analysisView/analysisView.test.ts index ce28066fd0..cd3401f417 100644 --- a/src/domain/analysisView/analysisView.test.ts +++ b/src/domain/analysisView/analysisView.test.ts @@ -883,7 +883,9 @@ it("projects typed module facts without source bodies and keeps source coverage it("counts retained range observations including repeats and skips unavailable locations", () => { const analysis = analysisViewJavaScriptAnalysis(); - const original = analysis.graph.nodes[0]; + const original = analysis.graph.nodes.find( + ({ kind }) => kind === "javascript-asset", + ); const observed = original?.observations[0]; if (!original || !observed) throw new Error("missing module observation"); const range = { @@ -929,6 +931,7 @@ it("counts retained range observations including repeats and skips unavailable l ...application, nodes: [node], root_node_ids: [node.node_id], + edges: [], }); const parent = javascriptParent( analysisViewBindJavaScriptGraphs(analysis, graph), @@ -997,7 +1000,9 @@ it("validates typed module selectors without coercing null or empty paths", () = it("keeps mixed observation support distinct instead of promoting a module to observed", () => { const analysis = analysisViewJavaScriptAnalysis(); - const original = analysis.graph.nodes[0]; + const original = analysis.graph.nodes.find( + ({ kind }) => kind === "javascript-asset", + ); const observed = original?.observations[0]; if (!original || !observed) throw new Error("missing module observation"); const inferred = { @@ -1025,6 +1030,7 @@ it("keeps mixed observation support distinct instead of promoting a module to ob ...application, nodes: [node], root_node_ids: [node.node_id], + edges: [], }); const result = projectAnalysisView( javascriptParent(analysisViewBindJavaScriptGraphs(analysis, graph)), diff --git a/src/domain/analysisView/analysisViewResult.ts b/src/domain/analysisView/analysisViewResult.ts index e8f2b28a5e..dce7b88e67 100644 --- a/src/domain/analysisView/analysisViewResult.ts +++ b/src/domain/analysisView/analysisViewResult.ts @@ -1144,7 +1144,7 @@ export const sourceToBundlePageViewResultSchema = z.strictObject({ /** Compact retained feature-trace summary; pages carry the same trace context. */ export const featureTraceSummaryViewResultSchema = z.strictObject({ ...summaryViewResultBase, - artifact: z.null(), + artifact: artifactSchema.nullable(), parent_operation: z.literal("trace_application_feature"), summary: featureTraceSummarySchema, }); @@ -1157,7 +1157,7 @@ const featureTracePage = < ...viewResultBase, ...featureTraceViewContextSchema.shape, kind: z.literal("feature-trace"), - artifact: z.null(), + artifact: artifactSchema.nullable(), parent_operation: z.literal("trace_application_feature"), view: featureTraceViewCollections[collection].request, items: featureTraceViewCollections[collection].rows, diff --git a/src/domain/analysisView/featureTraceView.ts b/src/domain/analysisView/featureTraceView.ts index b9a848401d..49b3d6a582 100644 --- a/src/domain/analysisView/featureTraceView.ts +++ b/src/domain/analysisView/featureTraceView.ts @@ -317,7 +317,7 @@ export const projectFeatureTraceView = ( parent_evidence_id: parent.evidenceId, parent_operation: OPERATION, parent_digest: parent.evidenceId.slice(3), - artifact: null, + artifact: parent.artifact ?? null, limitations: boundedViewMessages( limitations, parent.evidenceId, diff --git a/src/domain/evidence.ts b/src/domain/evidence.ts index 6e15957fdc..b0b1cc0428 100644 --- a/src/domain/evidence.ts +++ b/src/domain/evidence.ts @@ -184,16 +184,40 @@ export interface EvidenceSubjectTarget { readonly sha256: string; readonly format: z.infer["format"]; readonly architecture?: BinaryArchitecture; + /** Preserve an existing Evidence subject's display name when deriving observations. */ + readonly name?: string; } +/** Preserve the complete subject when deriving a new Evidence observation. */ +export const evidenceSubjectTarget = ( + subject: Evidence["subject"], +): EvidenceSubjectTarget | undefined => + subject === null + ? undefined + : { + path: subject.local_path, + sha256: subject.digest.sha256, + format: subject.format, + name: subject.name, + ...(subject.architecture === null + ? {} + : { architecture: subject.architecture }), + }; + +const sameEvidenceSubjectTargetIdentity = ( + subject: EvidenceSubjectTarget, + target: EvidenceSubjectTarget, +): boolean => + subject.sha256 === target.sha256 && + subject.format === target.format && + (subject.architecture ?? null) === (target.architecture ?? null); + /** Rebind a matching immutable artifact subject to the caller's selected path. */ export const rebindEvidenceSubjectTargetPath = ( subject: EvidenceSubjectTarget, target: EvidenceSubjectTarget, ): EvidenceSubjectTarget => - subject.sha256 === target.sha256 && - subject.format === target.format && - (subject.architecture ?? null) === (target.architecture ?? null) + sameEvidenceSubjectTargetIdentity(subject, target) ? { ...subject, path: target.path } : subject; @@ -324,21 +348,18 @@ export const rebindEvidenceToTargetPath = ( ): Evidence => { const subject = evidence.subject; if (subject === null) return evidence; - const rebound = rebindEvidenceSubjectTargetPath( - { - path: subject.local_path, - sha256: subject.digest.sha256, - format: subject.format, - ...(subject.architecture === null - ? {} - : { architecture: subject.architecture }), - }, - target, - ); - if (rebound.path === subject.local_path) return evidence; + if ( + !sameEvidenceSubjectTargetIdentity(evidenceSubjectTarget(subject)!, target) + ) + return evidence; + if ( + target.path === subject.local_path && + (target.name === undefined || target.name === subject.name) + ) + return evidence; const reboundSubject = subjectSchema.parse({ ...subject, - name: basename(target.path) || "artifact", + name: target.name ?? (basename(target.path) || "artifact"), local_path: target.path, }); const reboundEvidence = { ...evidence, subject: reboundSubject }; @@ -466,7 +487,9 @@ export const evidenceEnvelopeForAdmission = ( target === undefined ? null : { - name: basename(target.path) || "artifact", + name: + ("name" in target ? target.name : undefined) ?? + (basename(target.path) || "artifact"), digest: { sha256: target.sha256 }, format: target.format, architecture: target.architecture ?? null, diff --git a/src/domain/javascript/javascriptApplicationAnalysis.ts b/src/domain/javascript/javascriptApplicationAnalysis.ts index 294d1d31e6..c54ce3d66d 100644 --- a/src/domain/javascript/javascriptApplicationAnalysis.ts +++ b/src/domain/javascript/javascriptApplicationAnalysis.ts @@ -132,6 +132,27 @@ function* checkApplicationBindingsSteps( try { for (const node of result.graph.nodes) { applicationNodeIds.add(node.node_id); + if ( + node.kind === "artifact" && + (node.identity.strategy !== "content-digest" || + node.identity.sha256 !== result.root_artifact_sha256) + ) { + for (const observation of node.observations) { + if (observation.evidence.extractor.operation === "inventory-root") { + context.addIssue({ + code: "custom", + path: ["root_artifact_sha256"], + message: + "Application artifact root must match the declared root SHA-256", + }); + break; + } + if (++examined === 4096) { + examined = 0; + yield; + } + } + } if (++examined === 4096) { examined = 0; yield; diff --git a/src/domain/managed/managedApplicationGraph.ts b/src/domain/managed/managedApplicationGraph.ts index 2441e738e4..dc54b7e056 100644 --- a/src/domain/managed/managedApplicationGraph.ts +++ b/src/domain/managed/managedApplicationGraph.ts @@ -94,48 +94,65 @@ const managedGraphRequestSchema = getEvidenceInputContract( ).frameSchema; /** Managed-code projection result containing a validated application graph. */ -export const managedApplicationGraphResultSchema = z.strictObject({ - projection_id: prefixedDigestSchema("magp"), - root_artifact_sha256: digestSchema, - source_evidence: z.strictObject({ - managed_artifact_evidence_id: evidenceIdSchema.nullable(), - managed_members_evidence_id: evidenceIdSchema.nullable(), - managed_native_boundaries_evidence_id: evidenceIdSchema.nullable(), - }), - summary: z.strictObject({ - graph_nodes: z.int().min(0), - graph_edges: z.int().min(0), - assemblies: z.int().min(0), - modules: z.int().min(0), - types: z.int().min(0), - methods: z.int().min(0), - fields: z.int().min(0), - pinvoke_imports: z.int().min(0), - native_implementations: z.int().min(0), - // Graphs projected before call and field-access edges existed omit these - // counters; null means those relations were not projected, not zero. - calls: z - .int() - .min(0) - .nullable() - .default(null) - .describe("calls edges; null for graphs projected before calls edges"), - field_accesses: z - .int() - .min(0) - .nullable() - .default(null) - .describe( - "accesses edges; null for graphs projected before accesses edges", - ), - }), - graph: javascriptApplicationGraphSchema, - coverage: z.strictObject({ - status: z.enum(["complete-within-inputs", "partial"]), - }), - evidence_links: z.array(evidenceIdSchema).min(1).max(3), - limitations: z.array(textSchema), -}); +export const managedApplicationGraphResultSchema = z + .strictObject({ + projection_id: prefixedDigestSchema("magp"), + root_artifact_sha256: digestSchema, + source_evidence: z.strictObject({ + managed_artifact_evidence_id: evidenceIdSchema.nullable(), + managed_members_evidence_id: evidenceIdSchema.nullable(), + managed_native_boundaries_evidence_id: evidenceIdSchema.nullable(), + }), + summary: z.strictObject({ + graph_nodes: z.int().min(0), + graph_edges: z.int().min(0), + assemblies: z.int().min(0), + modules: z.int().min(0), + types: z.int().min(0), + methods: z.int().min(0), + fields: z.int().min(0), + pinvoke_imports: z.int().min(0), + native_implementations: z.int().min(0), + // Graphs projected before call and field-access edges existed omit these + // counters; null means those relations were not projected, not zero. + calls: z + .int() + .min(0) + .nullable() + .default(null) + .describe("calls edges; null for graphs projected before calls edges"), + field_accesses: z + .int() + .min(0) + .nullable() + .default(null) + .describe( + "accesses edges; null for graphs projected before accesses edges", + ), + }), + graph: javascriptApplicationGraphSchema, + coverage: z.strictObject({ + status: z.enum(["complete-within-inputs", "partial"]), + }), + evidence_links: z.array(evidenceIdSchema).min(1).max(3), + limitations: z.array(textSchema), + }) + .superRefine((result, context) => { + const [rootId] = result.graph.root_node_ids; + const root = result.graph.nodes.find(({ node_id }) => node_id === rootId); + if ( + result.graph.root_node_ids.length !== 1 || + root?.kind !== "artifact" || + root.identity.strategy !== "content-digest" || + root.identity.sha256 !== result.root_artifact_sha256 + ) + context.addIssue({ + code: "custom", + path: ["root_artifact_sha256"], + message: + "Managed graph root must identify the declared root artifact SHA-256.", + }); + }); export type ManagedApplicationGraphResult = z.infer< typeof managedApplicationGraphResultSchema diff --git a/tests/acceptance/applications/applicationWorkflowCli.test.ts b/tests/acceptance/applications/applicationWorkflowCli.test.ts index 66e6afd271..3379d5ee62 100644 --- a/tests/acceptance/applications/applicationWorkflowCli.test.ts +++ b/tests/acceptance/applications/applicationWorkflowCli.test.ts @@ -771,15 +771,16 @@ describe("application workflow CLI parity", () => { expect(traced).toMatchObject({ operation: "trace_application_feature", predicate_type: "rea.application-feature-trace", + subject: JAVASCRIPT_FEATURE_TRACE_EXAMPLE.application.subject, normalized_result: { seed: { kind: "module", value: "renderer.js", match: "exact" }, summary: { matched_seeds: 1, - traced_nodes: 1, - traced_edges: 0, + traced_nodes: 2, + traced_edges: 1, terminal_paths: 0, native_handoffs: 0, - observed_facts: 1, + observed_facts: 3, inferred_facts: 0, unknown_facts: 0, unavailable_facts: 0, @@ -809,7 +810,7 @@ describe("application workflow CLI parity", () => { predicate_type: "rea.application-version-comparison", normalized_result: { summary: { - unchanged: 0, + unchanged: 1, added: 2, removed: 0, changed: 1, @@ -852,7 +853,7 @@ describe("application workflow CLI parity", () => { reference_inventory_state: "complete", application_graph_status: "complete", retained_source_files: 1, - retained_application_nodes: 1, + retained_application_nodes: 2, }, }, }); @@ -890,6 +891,7 @@ describe("application workflow CLI parity", () => { expect(traced).toMatchObject({ operation: "trace_javascript_semantics", predicate_type: "rea.javascript-semantic-trace", + subject: analyzed.value.subject, normalized_result: { source_evidence_id: analyzed.value.evidence_id, source_graph_id: result.semantic_graph.graph_id, @@ -936,6 +938,7 @@ it("traces all endpoint carriers and their response consumers through CLI and MC const cliEvidence = parseEvidence( await runCliWithRequestFile(root, "trace-javascript-semantics", input), ); + expect(cliEvidence.subject).toEqual(application.subject); const mcpResponse = await client.callTool({ name: "trace_javascript_semantics", arguments: input, @@ -1920,6 +1923,11 @@ const runCliWithRequestFile = async ( /** The CLI keeps complete trace JSON; the shared view owner selects from the saved file. */ const assertTraceFileView = async (root: string, traced: unknown) => { const parent = parseEvidence(traced); + if (parent.subject === null) throw new Error("Expected trace subject"); + const artifact = { + path: parent.subject.local_path, + sha256: parent.subject.digest.sha256, + }; const tracePath = join(root, "trace.json"); await writeFile(tracePath, JSON.stringify(parent)); const source = { kind: "evidence-file", path: tracePath }; @@ -1934,7 +1942,8 @@ const assertTraceFileView = async (root: string, traced: unknown) => { kind: "summary", parent_evidence_id: parent.evidence_id, parent_operation: "trace_application_feature", - summary: { summary: { matched_seeds: 1, traced_nodes: 1 } }, + artifact, + summary: { summary: { matched_seeds: 1, traced_nodes: 2 } }, }, }); expect( @@ -1949,8 +1958,9 @@ const assertTraceFileView = async (root: string, traced: unknown) => { ).toMatchObject({ normalized_result: { kind: "feature-trace", + artifact, items: [{ seed: true, observation_count: 1 }], - coverage: { total: 1, exhausted: true }, + coverage: { total: 2, exhausted: false }, }, }); // Without a session ledger the CLI cannot retain a parent for a selected view. diff --git a/tests/boundary/filesystem/android/application.test.ts b/tests/boundary/filesystem/android/application.test.ts index 7608105a4b..132111b484 100644 --- a/tests/boundary/filesystem/android/application.test.ts +++ b/tests/boundary/filesystem/android/application.test.ts @@ -20,6 +20,7 @@ import { import { projectAndroidApplicationEvidence } from "../../../../src/application/android/AndroidApplicationService.js"; import { androidApplicationProjectionResultSchema } from "../../../../src/domain/android/androidApplication.js"; import { artifactInventoryResultSchema } from "../../../../src/domain/artifactGraph.js"; +import { parseEvidence } from "../../../../src/domain/evidence.js"; describe("Android application projection", () => { it("projects deterministic APK components and explicit bridge hypotheses", async () => { @@ -172,6 +173,23 @@ describe("Android application projection", () => { }); }); +it("preserves the selected inventory subject through application projection", async () => { + const root = await createTestTempDirectory("rea-android-subject-"); + const path = join(root, "Fixture.apk"); + const writer = new ZipWriter(new Uint8ArrayWriter()); + await writer.add("AndroidManifest.xml", new TextReader("manifest")); + await writeFile(path, await writer.close()); + const inventory = await inventoryApplicationPackage(path); + const selected = parseEvidence({ + ...inventory, + subject: { ...inventory.subject, name: "Selected application" }, + }); + const result = projectAndroidApplicationEvidence({ + inventory_evidence: [selected], + }); + expect(requireSuccessfulProjection(result).subject).toEqual(selected.subject); +}); + it("projects APK v1 metadata candidates at any META-INF depth", async () => { const root = await createTestTempDirectory("rea-android-v1-metadata-"); const path = join(root, "NestedSigning.apk"); diff --git a/tests/boundary/javascript/javascriptSelectedPathEvidence.test.ts b/tests/boundary/javascript/javascriptSelectedPathEvidence.test.ts index 7a23bee1f4..bc5adad3fd 100644 --- a/tests/boundary/javascript/javascriptSelectedPathEvidence.test.ts +++ b/tests/boundary/javascript/javascriptSelectedPathEvidence.test.ts @@ -9,7 +9,9 @@ import { import { join } from "node:path"; import { expect } from "vitest"; -import { parseEvidence } from "../../../src/domain/evidence.js"; +import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { createJavaScriptSemanticGraph } from "../../../src/domain/javascript/javascriptSemanticGraph.js"; import { parseEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; import { parseApplicationGraphEvidence } from "../../../src/application/javascript/JavaScriptApplicationEvidenceGraph.js"; import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../../src/contracts/javascript/javascriptRuntimeReconciliationExample.js"; @@ -45,6 +47,40 @@ cliTest( }); expect(cliResponse.exitCode, cliResponse.stderr).toBe(0); const cliEvidence = parseEvidence(cliResponse.json); + const parsed = javascriptApplicationAnalysisResultSchema.parse( + cliEvidence.normalized_result, + ); + const { graph_id: _id, ...semantic } = parsed.semantic_graph; + const foreignSha = "f".repeat(64); + const forged = createEvidence( + { path: parsed.input_path, sha256: foreignSha, format: parsed.format }, + cliEvidence.provider, + { + operation: cliEvidence.operation, + predicateType: cliEvidence.predicate_type, + parameters: cliEvidence.parameters, + confidence: "derived", + authority: "shipped-artifact", + result: { + ...parsed, + root_artifact_sha256: foreignSha, + semantic_graph: createJavaScriptSemanticGraph({ + ...semantic, + root_artifact_sha256: foreignSha, + }), + }, + }, + ); + expect(parseApplicationGraphEvidence(forged)).toMatchObject({ + ok: false, + error: { + issues: expect.arrayContaining([ + expect.objectContaining({ + path: ["normalized_result", "root_artifact_sha256"], + }), + ]), + }, + }); for (const command of ["analyze", "analyze-javascript-application"]) { const relativeResponse = await cli.run({ diff --git a/tests/boundary/mcp/applicationEvidenceMcp.test.ts b/tests/boundary/mcp/applicationEvidenceMcp.test.ts index 3c0ca94c71..242aaacfee 100644 --- a/tests/boundary/mcp/applicationEvidenceMcp.test.ts +++ b/tests/boundary/mcp/applicationEvidenceMcp.test.ts @@ -163,7 +163,21 @@ async function runInlineWorkflowScenarios( expect(tracedInline.structuredContent).toMatchObject({ normalized_result: { seed: { kind: "module", value: "renderer.js", match: "exact" }, - summary: { matched_seeds: 1, traced_nodes: 1, unknown_facts: 0 }, + summary: { + matched_seeds: 1, + traced_nodes: 2, + traced_edges: 1, + unknown_facts: 0, + }, + graph: { + nodes: expect.arrayContaining([ + expect.objectContaining({ kind: "artifact" }), + expect.objectContaining({ kind: "javascript-asset" }), + ]), + edges: expect.arrayContaining([ + expect.objectContaining({ relation: "contains" }), + ]), + }, coverage: { status: "complete-within-source", source_graph_status: "complete", diff --git a/tests/boundary/mcp/featureTraceViewsMcp.test.ts b/tests/boundary/mcp/featureTraceViewsMcp.test.ts index 7d3656115c..70f12810ea 100644 --- a/tests/boundary/mcp/featureTraceViewsMcp.test.ts +++ b/tests/boundary/mcp/featureTraceViewsMcp.test.ts @@ -143,7 +143,13 @@ it.each(["full", "compact"] as const)( expect(summary.evidence.normalized_result).toMatchObject({ kind: "summary", parent_operation: "trace_application_feature", - artifact: null, + artifact: + parent.subject === null + ? null + : { + path: parent.subject.local_path, + sha256: parent.subject.digest.sha256, + }, summary: { trace_id: full.trace_id, source_evidence_id: full.source_evidence_id, diff --git a/tests/boundary/mcp/fixedEvidenceConsumersMcp.test.ts b/tests/boundary/mcp/fixedEvidenceConsumersMcp.test.ts index 358a5d9193..9996228ac5 100644 --- a/tests/boundary/mcp/fixedEvidenceConsumersMcp.test.ts +++ b/tests/boundary/mcp/fixedEvidenceConsumersMcp.test.ts @@ -35,7 +35,7 @@ const scenarios = [ name: "trace_application_feature", input: JAVASCRIPT_FEATURE_TRACE_EXAMPLE, sourceIds: [application.evidence_id], - facts: { summary: { matched_seeds: 1, traced_nodes: 1 } }, + facts: { summary: { matched_seeds: 1, traced_nodes: 2, traced_edges: 1 } }, }, { name: "trace_javascript_semantics", diff --git a/tests/composition/analysis-sessions/managedApplicationGraph.test.ts b/tests/composition/analysis-sessions/managedApplicationGraph.test.ts index 0268d69a9c..e475b4a4f8 100644 --- a/tests/composition/analysis-sessions/managedApplicationGraph.test.ts +++ b/tests/composition/analysis-sessions/managedApplicationGraph.test.ts @@ -273,6 +273,85 @@ describe("managed application graph projection", () => { }); }); +it("preserves only a managed graph's matching subject in feature traces", () => { + const { artifactEvidence } = createManagedInteropEvidence(); + const selectedArtifact = parseEvidence({ + ...artifactEvidence, + subject: { ...artifactEvidence.subject, name: "selected managed image" }, + }); + const projected = projectManagedApplicationGraphEvidence({ + managed_artifact: selectedArtifact, + }); + if (!projected.ok) throw projected.error; + const parsed = parseEvidence(projected.value); + expect(parsed.subject).toEqual(selectedArtifact.subject); + const query = { + seed: { + kind: "string" as const, + value: "MessageBoxW", + match: "exact" as const, + case_sensitive: true, + }, + direction: "incoming" as const, + }; + const trace = traceApplicationFeatureEvidence({ + application: parsed, + ...query, + }); + if (!trace.ok) throw trace.error; + expect(trace.value.evidence.subject).toEqual(selectedArtifact.subject); + const foreign = createEvidence( + { path: "/other.exe", sha256: "f".repeat(64), format: "pe" }, + parsed.provider, + { + operation: parsed.operation, + predicateType: parsed.predicate_type, + parameters: parsed.parameters, + result: parsed.normalized_result, + confidence: "inferred", + authority: "analyst-inference", + evidenceLinks: parsed.evidence_links, + }, + ); + const foreignTrace = traceApplicationFeatureEvidence({ + application: foreign, + ...query, + }); + if (!foreignTrace.ok) throw foreignTrace.error; + expect(foreignTrace.value.evidence.subject).toBeNull(); + const contradictory = createEvidence( + { path: "/other.exe", sha256: "f".repeat(64), format: "pe" }, + parsed.provider, + { + operation: parsed.operation, + predicateType: parsed.predicate_type, + parameters: parsed.parameters, + result: { + ...(parsed.normalized_result as Record), + root_artifact_sha256: "f".repeat(64), + }, + confidence: "inferred", + authority: "analyst-inference", + evidenceLinks: parsed.evidence_links, + }, + ); + const contradictoryTrace = traceApplicationFeatureEvidence({ + application: contradictory, + ...query, + }); + expect(contradictoryTrace).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisInputError", + issues: expect.arrayContaining([ + expect.objectContaining({ + path: ["application", "normalized_result", "root_artifact_sha256"], + }), + ]), + }, + }); +}); + describe("managed application graph request", () => { it("accepts any nonempty set of inline sources and rejects duplicates", () => { const evidence = MANAGED_APPLICATION_GRAPH_EXAMPLE.managed_members;