diff --git a/src/application/InventoryProjectionEvidence.ts b/src/application/InventoryProjectionEvidence.ts index b89059c15e..9d25988723 100644 --- a/src/application/InventoryProjectionEvidence.ts +++ b/src/application/InventoryProjectionEvidence.ts @@ -1,23 +1,15 @@ -import { z } from "zod"; - -import { AnalysisInputError } from "../domain/analysisErrorCore.js"; -import { AnalysisError } from "../domain/analysisErrorBase.js"; +import type { AnalysisInputError } from "../domain/analysisErrorCore.js"; +import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { createEvidence, type Evidence, type EvidenceProvider, type EvidenceSubjectTarget, } from "../domain/evidence.js"; -import { jsonValueSchema } from "../domain/jsonValue.js"; -import type { MobileApplicationProjectionWithSources } from "../domain/mobileApplicationGraphProjection.js"; -import { err, ok, type Result } from "../domain/result.js"; -import { requestInputError } from "./workflowInputError.js"; -import { ArtifactInventoryCarrierError } from "../domain/artifactInventoryEvidence.js"; - -interface InventoryProjectionInput { - readonly inventory_evidence: readonly Evidence[]; - readonly limits?: unknown; -} +import type { JsonValue } from "../domain/jsonValue.js"; +import type { ParsedInventorySet } from "../domain/artifactInventoryEvidence.js"; +import { readApplicationInventoryProjection } from "../domain/mobileApplicationGraphProjection.js"; +import { ok, type Result } from "../domain/result.js"; interface InventoryProjectionResult { readonly root_sha256: string; @@ -31,86 +23,56 @@ export interface InventoryProjectionEvidenceResult { } interface InventoryProjectionOptions< - Input extends InventoryProjectionInput, - Output extends InventoryProjectionResult, + Output extends InventoryProjectionResult & JsonValue, > { readonly rawInput: unknown; - readonly schema: z.ZodType; readonly project: ( - input: Input, - ) => MobileApplicationProjectionWithSources; + input: ParsedInventorySet, + ) => Result; readonly operation: string; readonly predicateType: string; readonly provider: EvidenceProvider; readonly subjectFormat: (first: Evidence) => EvidenceSubjectTarget["format"]; } -/** Parse one inventory projection and wrap its deterministic result in Evidence. */ +/** Resolve source inventory once, then wrap its family projection in Evidence. */ export const projectInventoryEvidence = < - Input extends InventoryProjectionInput, - Output extends InventoryProjectionResult, + Output extends InventoryProjectionResult & JsonValue, >( - options: InventoryProjectionOptions, + options: InventoryProjectionOptions, ): Result => { - const parsed = options.schema.safeParse(options.rawInput); - if (!parsed.success) - return err( - requestInputError(options.operation, parsed.error, options.rawInput), - ); - try { - const projected = options.project(parsed.data); - const result = projected.projection; - const first = projected.sources[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), - }, - options.provider, - { - predicateType: options.predicateType, - operation: options.operation, - parameters: { - inventory_evidence_ids: [...result.source_evidence_ids], - ...("limits" in parsed.data && parsed.data.limits !== undefined - ? { limits: jsonValueSchema.parse(parsed.data.limits) } - : {}), + const source = readApplicationInventoryProjection( + options.rawInput, + options.operation, + ); + if (!source.ok) return source; + const projected = options.project(source.value); + if (!projected.ok) return projected; + const result = projected.value; + 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), }, - result: jsonValueSchema.parse(result), - rawResult: null, - confidence: "inferred", - authority: "analyst-inference", - environment: null, - limitations: result.limitations, - evidenceLinks: result.source_evidence_ids, - }, - ), - sources: projected.sources, - }); - } catch (cause: unknown) { - if (cause instanceof AnalysisError) return err(cause); - if (cause instanceof ArtifactInventoryCarrierError) - return err( - new AnalysisInputError( - options.operation, - { cause, userMessage: cause.message }, - [ - { - path: [ - "inventory_evidence", - ...(cause.index === undefined ? [] : [cause.index]), - ...(cause.index === undefined ? [] : ["operation"]), - ], - reason: "invalid_value", - message: cause.message, - }, - ], - ), - ); - throw cause; - } + options.provider, + { + predicateType: options.predicateType, + operation: options.operation, + parameters: { inventory_evidence_ids: [...result.source_evidence_ids] }, + result, + rawResult: null, + confidence: "inferred", + authority: "analyst-inference", + environment: null, + limitations: result.limitations, + evidenceLinks: result.source_evidence_ids, + }, + ), + sources: source.value.evidence, + }); }; diff --git a/src/application/android/AndroidApplicationService.ts b/src/application/android/AndroidApplicationService.ts index 58f202d9b0..0fe88d60df 100644 --- a/src/application/android/AndroidApplicationService.ts +++ b/src/application/android/AndroidApplicationService.ts @@ -1,8 +1,5 @@ import type { AnalysisError } from "../../domain/analysisErrorBase.js"; -import { - androidApplicationProjectionInputSchema, - projectAndroidApplication, -} from "../../domain/android/androidApplication.js"; +import { projectAndroidInventory } from "../../domain/android/androidApplication.js"; import type { Result } from "../../domain/result.js"; import { ANDROID_APPLICATION_PROVIDER } from "../InvestigationProviders.js"; import { @@ -18,8 +15,7 @@ export const projectAndroidApplicationEvidence = ( ): Result => { return projectInventoryEvidence({ rawInput, - schema: androidApplicationProjectionInputSchema, - project: projectAndroidApplication, + project: projectAndroidInventory, operation: OPERATION, predicateType: "rea.android-application-graph", provider: ANDROID_APPLICATION_PROVIDER, diff --git a/src/application/apple/AppleApplicationService.ts b/src/application/apple/AppleApplicationService.ts index 34495afab6..60fa72f450 100644 --- a/src/application/apple/AppleApplicationService.ts +++ b/src/application/apple/AppleApplicationService.ts @@ -1,8 +1,5 @@ import type { AnalysisError } from "../../domain/analysisErrorBase.js"; -import { - appleApplicationProjectionInputSchema, - projectAppleApplication, -} from "../../domain/apple/appleApplication.js"; +import { projectAppleInventory } from "../../domain/apple/appleApplication.js"; import type { Result } from "../../domain/result.js"; import { APPLE_APPLICATION_PROVIDER } from "../InvestigationProviders.js"; import { @@ -18,8 +15,7 @@ export const projectAppleApplicationEvidence = ( ): Result => { return projectInventoryEvidence({ rawInput, - schema: appleApplicationProjectionInputSchema, - project: projectAppleApplication, + project: projectAppleInventory, operation: OPERATION, predicateType: "rea.apple-application-graph", provider: APPLE_APPLICATION_PROVIDER, diff --git a/src/application/harmony/HarmonyApplicationService.ts b/src/application/harmony/HarmonyApplicationService.ts index 7e277ba533..eeda5692ef 100644 --- a/src/application/harmony/HarmonyApplicationService.ts +++ b/src/application/harmony/HarmonyApplicationService.ts @@ -1,8 +1,5 @@ import type { AnalysisError } from "../../domain/analysisErrorBase.js"; -import { - harmonyApplicationProjectionInputSchema, - projectHarmonyApplication, -} from "../../domain/harmony/harmonyApplication.js"; +import { projectHarmonyInventory } from "../../domain/harmony/harmonyApplication.js"; import type { Result } from "../../domain/result.js"; import { HARMONY_APPLICATION_PROVIDER } from "../InvestigationProviders.js"; import { @@ -18,8 +15,7 @@ export const projectHarmonyApplicationEvidence = ( ): Result => { return projectInventoryEvidence({ rawInput, - schema: harmonyApplicationProjectionInputSchema, - project: projectHarmonyApplication, + project: projectHarmonyInventory, operation: OPERATION, predicateType: "rea.harmony-application-graph", provider: HARMONY_APPLICATION_PROVIDER, diff --git a/src/artifacts/inventory/ArtifactOccurrenceIdentity.test.ts b/src/artifacts/inventory/ArtifactOccurrenceIdentity.test.ts index 1878eabc88..1d2ea42ae0 100644 --- a/src/artifacts/inventory/ArtifactOccurrenceIdentity.test.ts +++ b/src/artifacts/inventory/ArtifactOccurrenceIdentity.test.ts @@ -6,7 +6,8 @@ import { describe, expect, it } from "vitest"; import { inventoryArtifact } from "../../../tests/fixtures/artifactInventory.js"; import { compareArtifacts } from "../../domain/artifactComparison.js"; -import { projectAppleApplication } from "../../domain/apple/appleApplication.js"; +import { parseArtifactInventoryEvidence } from "../../domain/artifactInventoryEvidence.js"; +import { projectAppleInventory } from "../../domain/apple/appleApplication.js"; import { createEvidence } from "../../domain/evidence.js"; import { jsonValueSchema } from "../../domain/jsonValue.js"; import { thinMach } from "../../domain/binaryTarget.fixture.js"; @@ -115,8 +116,8 @@ describe("content identity and occurrence facts", () => { ); await writeFile(path, await writer.close()); const inventory = await inventoryArtifact(path); - const projection = projectAppleApplication({ - inventory_evidence: [ + const projectionResult = projectAppleInventory( + parseArtifactInventoryEvidence([ createEvidence( { path, sha256: inventory.manifest.root_sha256, format: "zip" }, { @@ -132,8 +133,10 @@ describe("content identity and occurrence facts", () => { authority: "shipped-artifact", }, ), - ], - }).projection; + ]), + ); + if (!projectionResult.ok) throw projectionResult.error; + const projection = projectionResult.value; expect( projection.components.javascript.map((component) => component.path), ).toEqual(["Demo.app/Contents/Resources/a.js"]); diff --git a/src/cli/applicationCommands.ts b/src/cli/applicationCommands.ts index 4b9f3f620b..ec6fdb0f70 100644 --- a/src/cli/applicationCommands.ts +++ b/src/cli/applicationCommands.ts @@ -6,10 +6,7 @@ import { parseCliJsonInput } from "../cliJsonInput.js"; import { logCliCommand } from "../cliLogging.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; -import { androidApplicationProjectionInputSchema } from "../domain/android/androidApplication.js"; -import { appleApplicationProjectionInputSchema } from "../domain/apple/appleApplication.js"; -import { harmonyApplicationProjectionInputSchema } from "../domain/harmony/harmonyApplication.js"; -import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; +import type { JsonValue } from "../domain/jsonValue.js"; import type { Logger } from "pino"; import { traceApplicationFeatureInputSchema } from "../domain/javascript/javascriptFeatureTraceSchemas.js"; import { traceJavaScriptSemanticsInputSchema } from "../domain/javascript/javascriptSemanticTraceSchemas.js"; @@ -86,14 +83,12 @@ export const registerApplicationCommands = ( name: CLI_COMMANDS.projectAndroidApplicationGraph, description: "Project authenticated APK inventory Evidence into an Android application graph", - inputSchema: androidApplicationProjectionInputSchema, + inputSchema: z.unknown(), workflow: async (input) => { const { projectAndroidApplicationEvidence } = await import("../application/android/AndroidApplicationService.js"); const result = projectAndroidApplicationEvidence(input); - return result.ok - ? { ok: true, value: jsonValueSchema.parse(result.value.evidence) } - : result; + return result.ok ? { ok: true, value: result.value.evidence } : result; }, }); registerJsonCommand({ @@ -102,14 +97,12 @@ export const registerApplicationCommands = ( name: CLI_COMMANDS.projectAppleApplicationGraph, description: "Project authenticated IPA or macOS app inventory Evidence into an Apple application graph", - inputSchema: appleApplicationProjectionInputSchema, + inputSchema: z.unknown(), workflow: async (input) => { const { projectAppleApplicationEvidence } = await import("../application/apple/AppleApplicationService.js"); const result = projectAppleApplicationEvidence(input); - return result.ok - ? { ok: true, value: jsonValueSchema.parse(result.value.evidence) } - : result; + return result.ok ? { ok: true, value: result.value.evidence } : result; }, }); registerJsonCommand({ @@ -118,14 +111,12 @@ export const registerApplicationCommands = ( name: CLI_COMMANDS.projectHarmonyApplicationGraph, description: "Project authenticated HarmonyOS HAP, HSP, or App Pack inventory Evidence into an application graph", - inputSchema: harmonyApplicationProjectionInputSchema, + inputSchema: z.unknown(), workflow: async (input) => { const { projectHarmonyApplicationEvidence } = await import("../application/harmony/HarmonyApplicationService.js"); const result = projectHarmonyApplicationEvidence(input); - return result.ok - ? { ok: true, value: jsonValueSchema.parse(result.value.evidence) } - : result; + return result.ok ? { ok: true, value: result.value.evidence } : result; }, }); }; diff --git a/src/contracts/applicationToolContracts.ts b/src/contracts/applicationToolContracts.ts index d4f9555367..d7c08c5d32 100644 --- a/src/contracts/applicationToolContracts.ts +++ b/src/contracts/applicationToolContracts.ts @@ -1,3 +1,4 @@ +import { applicationInventoryProjectionInputSchema } from "../domain/mobileApplicationGraphProjection.js"; import { z } from "zod"; import { sourceToBundleSummaryViewResultSchema, @@ -21,18 +22,9 @@ import { reconstructionObligationLedgerInputSchema, reconstructionObligationLedgerSchema, } from "../domain/reconstructionObligationLedgerSchemas.js"; -import { - androidApplicationProjectionInputSchema, - androidApplicationProjectionResultSchema, -} from "../domain/android/androidApplication.js"; -import { - appleApplicationProjectionInputSchema, - appleApplicationProjectionResultSchema, -} from "../domain/apple/appleApplication.js"; -import { - harmonyApplicationProjectionInputSchema, - harmonyApplicationProjectionResultSchema, -} from "../domain/harmony/harmonyApplication.js"; +import { androidApplicationProjectionResultSchema } from "../domain/android/androidApplication.js"; +import { appleApplicationProjectionResultSchema } from "../domain/apple/appleApplication.js"; +import { harmonyApplicationProjectionResultSchema } from "../domain/harmony/harmonyApplication.js"; import type { ToolContract } from "./toolContractTypes.js"; import { toolContractMetadata } from "./toolEffects.js"; import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js"; @@ -280,7 +272,7 @@ export const APPLICATION_TOOL_CONTRACTS = [ description: "Project authenticated APK inventory_artifact Evidence into an execution-free Android application inventory. Reports exact component paths and hashes, runtime-family hints, and path-based bridge hypotheses without decoding DEX, executing the APK, or claiming observed runtime calls.", kind: "application", - inputSchema: androidApplicationProjectionInputSchema, + inputSchema: applicationInventoryProjectionInputSchema, outputSchema: evidenceResultOf(androidApplicationProjectionResultSchema), examples: [ { @@ -296,7 +288,7 @@ export const APPLICATION_TOOL_CONTRACTS = [ description: "Project authenticated IPA, macOS .app directory, ZIP, or DMG inventory Evidence into an execution-free Apple application inventory. Reports application roots, nested bundles with path-convention roles (app extensions, XPC services, login items, privileged helpers, system extensions, frameworks), launchd plists, exact component paths and hashes, runtime-family hints, and path-based bridge hypotheses without parsing plist/CMS semantics or claiming observed runtime calls.", kind: "application", - inputSchema: appleApplicationProjectionInputSchema, + inputSchema: applicationInventoryProjectionInputSchema, outputSchema: evidenceResultOf(appleApplicationProjectionResultSchema), examples: [ { @@ -316,7 +308,7 @@ export const APPLICATION_TOOL_CONTRACTS = [ description: "Project authenticated HarmonyOS HAP, HSP, or App Pack inventory Evidence into an execution-free application inventory. Reports packaging-model hints (Stage module.json versus FA config.json), exact component paths and hashes (manifests, Ark bytecode, resources, native libraries, JavaScript, signing), App Pack child packages, runtime-family hints, and path-based N-API bridge hypotheses without decoding Ark bytecode, parsing manifest semantics, or claiming observed runtime calls.", kind: "application", - inputSchema: harmonyApplicationProjectionInputSchema, + inputSchema: applicationInventoryProjectionInputSchema, outputSchema: evidenceResultOf(harmonyApplicationProjectionResultSchema), examples: [ { diff --git a/src/domain/android/androidApplication.ts b/src/domain/android/androidApplication.ts index 0aa32e3b58..4dd94f0fb7 100644 --- a/src/domain/android/androidApplication.ts +++ b/src/domain/android/androidApplication.ts @@ -7,15 +7,13 @@ import { AnalysisInputError } from "../analysisErrorCore.js"; import { hasCompleteInventoryCoverage, - parseArtifactInventoryEvidence, + type ParsedInventorySet, } from "../artifactInventoryEvidence.js"; import { digestSchema } from "../digests.js"; import { prefixedDigestSchema } from "../digests.js"; import { digestCanonicalValue } from "../canonicalDigest.js"; import { nativeRuntimeConvention } from "../nativeConvention.js"; import { - applicationInventoryProjectionInputSchema, - type MobileApplicationProjectionWithSources, projectedBridgeCandidateSchema, projectedComponentSchema, projectedInventoryEntries, @@ -25,28 +23,22 @@ import { unavailableComponentSchema, } from "../mobileApplicationGraphProjection.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; - -const evidenceIdSchema = projectionEvidenceIdSchema; -const componentSchema = projectedComponentSchema; - -/** Authenticated APK inventory pages projected as one Android application. */ -export const androidApplicationProjectionInputSchema = - applicationInventoryProjectionInputSchema; +import { err, ok, type Result } from "../result.js"; /** Deterministic, execution-free Android application inventory projection. */ export const androidApplicationProjectionResultSchema = z.strictObject({ projection_id: prefixedDigestSchema("adp"), root_sha256: digestSchema, root_format: z.literal("apk"), - source_evidence_ids: z.array(evidenceIdSchema).min(1), + source_evidence_ids: z.array(projectionEvidenceIdSchema).min(1), components: z.strictObject({ - manifests: z.array(componentSchema), - resources: z.array(componentSchema), - dex: z.array(componentSchema), - jvm_classes: z.array(componentSchema), - native_libraries: z.array(componentSchema), - javascript: z.array(componentSchema), - signing: z.array(componentSchema), + manifests: z.array(projectedComponentSchema), + resources: z.array(projectedComponentSchema), + dex: z.array(projectedComponentSchema), + jvm_classes: z.array(projectedComponentSchema), + native_libraries: z.array(projectedComponentSchema), + javascript: z.array(projectedComponentSchema), + signing: z.array(projectedComponentSchema), }), unavailable_components: z.array(unavailableComponentSchema), runtime_families: z.array( @@ -78,37 +70,34 @@ export const androidApplicationProjectionResultSchema = z.strictObject({ limitations: z.array(z.string()), }); -export type AndroidApplicationProjectionInput = z.infer< - typeof androidApplicationProjectionInputSchema ->; export type AndroidApplicationProjectionResult = z.infer< typeof androidApplicationProjectionResultSchema >; -type Component = z.infer; +type Component = z.infer; /** Project exact APK paths and hashes without decoding or executing target code. */ -export const projectAndroidApplication = ( - input: AndroidApplicationProjectionInput, -): MobileApplicationProjectionWithSources => { - const parsed = androidApplicationProjectionInputSchema.parse(input); - const { evidence, inventory } = parseArtifactInventoryEvidence( - parsed.inventory_evidence, - ); +export const projectAndroidInventory = ( + source: ParsedInventorySet, +): Result => { + const { evidence, inventory } = source; if (inventory.manifest.root_format !== "apk") - throw new AnalysisInputError("project_android_application_graph", {}, [ - { - path: [ - "inventory_evidence", - 0, - "normalized_result", - "manifest", - "root_format", - ], - reason: "invalid_value", - message: `Android application projection requires APK inventory Evidence; the inventory root format is ${inventory.manifest.root_format}. APK is chosen for a ZIP archive whose name ends in .apk.`, - }, - ]); - const entries = projectedInventoryEntries(inventory); + return err( + new AnalysisInputError("project_android_application_graph", {}, [ + { + path: [ + "inventory_evidence", + 0, + "normalized_result", + "manifest", + "root_format", + ], + reason: "invalid_value", + message: `Android application projection requires APK inventory Evidence; the inventory root format is ${inventory.manifest.root_format}. APK is chosen for a ZIP archive whose name ends in .apk.`, + }, + ]), + ); + const { entries, limitations: unresolved } = + projectedInventoryEntries(inventory); const all = projectedFileComponents(entries); const unavailable = entries.flatMap(({ unavailable }) => unavailable === undefined ? [] : [unavailable], @@ -120,6 +109,7 @@ export const projectAndroidApplication = ( ); const components = classified; const limitations = [ + ...unresolved, ...inventory.limitations, ...(!hasCompleteInventoryCoverage(inventory, "container-entries") ? ["Source inventory coverage is partial; absence is unknown."] @@ -163,13 +153,12 @@ export const projectAndroidApplication = ( ), limitations, }; - return { - projection: androidApplicationProjectionResultSchema.parse({ + return ok( + androidApplicationProjectionResultSchema.parse({ ...withoutId, projection_id: `adp_${digestCanonicalValue(withoutId, "Android application projection")}`, }), - sources: evidence, - }; + ); }; const classify = (all: readonly Component[]) => ({ diff --git a/src/domain/apple/appleApplication.test.ts b/src/domain/apple/appleApplication.test.ts index 85868bcdf9..b37f9dc524 100644 --- a/src/domain/apple/appleApplication.test.ts +++ b/src/domain/apple/appleApplication.test.ts @@ -4,7 +4,8 @@ import { describe, expect, it } from "vitest"; import type { ArtifactOccurrence } from "../artifactGraph.js"; -import { projectAppleApplication } from "./appleApplication.js"; +import { parseArtifactInventoryEvidence } from "../artifactInventoryEvidence.js"; +import { projectAppleInventory } from "./appleApplication.js"; import { AnalysisInputError } from "../analysisErrorCore.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { digestCanonicalValue } from "../canonicalDigest.js"; @@ -161,8 +162,13 @@ const inventoryEvidence = ( ); }; -const project = (evidence: Evidence) => - projectAppleApplication({ inventory_evidence: [evidence] }).projection; +const project = (evidence: Evidence) => { + const result = projectAppleInventory( + parseArtifactInventoryEvidence([evidence]), + ); + if (!result.ok) throw result.error; + return result.value; +}; const macho = (path: string): FixtureEntry => ({ path, format: "mach-o" }); @@ -857,19 +863,17 @@ describe("Apple path conventions", () => { it.each(["file", "slice"] as const)( "retains non-encrypted unavailable %s facts without classifying content", (kind) => { - const projection = projectAppleApplication({ - inventory_evidence: [ - inventoryEvidence("ipa", "Fixture.ipa", [ - { path: "Payload/Fixture.app/Info.plist", format: "plist" }, - { - path: "Payload/Fixture.app/libentry.dylib", - kind, - format: "file", - unavailable: true, - }, - ]), - ], - }).projection; + const projection = project( + inventoryEvidence("ipa", "Fixture.ipa", [ + { path: "Payload/Fixture.app/Info.plist", format: "plist" }, + { + path: "Payload/Fixture.app/libentry.dylib", + kind, + format: "file", + unavailable: true, + }, + ]), + ); expect(projection.unavailable_components).toEqual([ { path: "Payload/Fixture.app/libentry.dylib", diff --git a/src/domain/apple/appleApplication.ts b/src/domain/apple/appleApplication.ts index 6d6a930c7e..bab7817236 100644 --- a/src/domain/apple/appleApplication.ts +++ b/src/domain/apple/appleApplication.ts @@ -17,9 +17,8 @@ import { } from "../nativeConvention.js"; import { hasCompleteInventoryCoverage, - parseArtifactInventoryEvidence, + type ParsedInventorySet, } from "../artifactInventoryEvidence.js"; -import { evidenceSchema } from "../evidence.js"; import { digestSchema } from "../digests.js"; import { prefixedDigestSchema } from "../digests.js"; import { digestCanonicalValue } from "../canonicalDigest.js"; @@ -27,9 +26,10 @@ import { bridgeCandidateCoverageSchema, projectCartesianCandidates, } from "../bridgeCandidateProjection.js"; -import type { MobileApplicationProjectionWithSources } from "../mobileApplicationGraphProjection.js"; import { projectedComponentSchema, + projectionEvidenceIdSchema, + projectionPathSchema, projectedInventoryEntries, projectedFileComponents, unavailableComponentSchema, @@ -37,25 +37,18 @@ import { } from "../mobileApplicationGraphProjection.js"; import { uniqueSortedBy } from "../canonicalOrdering.js"; import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js"; - -const evidenceIdSchema = prefixedDigestSchema("ev"); -const pathSchema = z.string().min(1); +import { err, ok, type Result } from "../result.js"; const APPLE_ROOT_FORMATS = ["ipa", "directory", "zip", "dmg"] as const; -/** Authenticated artifact inventory pages projected as one Apple application. */ -export const appleApplicationProjectionInputSchema = z.strictObject({ - inventory_evidence: z.array(evidenceSchema).min(1), -}); - /** Deterministic, execution-free Apple application inventory projection. */ export const appleApplicationProjectionResultSchema = z.strictObject({ projection_id: prefixedDigestSchema("aap"), root_sha256: digestSchema, root_format: z.enum(APPLE_ROOT_FORMATS), platforms: z.array(z.enum(["ios", "macos"])), - source_evidence_ids: z.array(evidenceIdSchema).min(1), - application_roots: z.array(pathSchema), + source_evidence_ids: z.array(projectionEvidenceIdSchema).min(1), + application_roots: z.array(projectionPathSchema), bundles: z.array(appleBundleSchema), components: z.strictObject({ bundle_metadata: z.array(projectedComponentSchema), @@ -71,7 +64,7 @@ export const appleApplicationProjectionResultSchema = z.strictObject({ helpers: z.array(projectedComponentSchema), }), unavailable_components: z.array(unavailableComponentSchema), - symlinks: z.array(pathSchema), + symlinks: z.array(projectionPathSchema), runtime_families: z.array( z.enum([ "native", @@ -84,8 +77,8 @@ export const appleApplicationProjectionResultSchema = z.strictObject({ ), bridge_candidates: z.array( z.strictObject({ - source_path: pathSchema, - native_path: pathSchema, + source_path: projectionPathSchema, + native_path: projectionPathSchema, basis: z.enum([ "javascript-and-native-content", "react-native-convention", @@ -103,9 +96,6 @@ export const appleApplicationProjectionResultSchema = z.strictObject({ limitations: z.array(z.string()), }); -export type AppleApplicationProjectionInput = z.infer< - typeof appleApplicationProjectionInputSchema ->; export type AppleApplicationProjectionResult = z.infer< typeof appleApplicationProjectionResultSchema >; @@ -113,16 +103,16 @@ export type AppleApplicationProjectionResult = z.infer< type Component = z.infer; type RootFormat = (typeof APPLE_ROOT_FORMATS)[number]; -/** Project exact Apple application paths and hashes without parsing or executing target code. */ -export const projectAppleApplication = ( - input: AppleApplicationProjectionInput, -): MobileApplicationProjectionWithSources => { - const parsed = appleApplicationProjectionInputSchema.parse(input); - const { evidence, inventory } = parseArtifactInventoryEvidence( - parsed.inventory_evidence, - ); - const rootFormat = appleRootFormat(inventory.manifest.root_format); - const entries = projectedInventoryEntries(inventory); +/** Project an already authenticated inventory without reparsing its source. */ +export const projectAppleInventory = ( + source: ParsedInventorySet, +): Result => { + const { evidence, inventory } = source; + const rootFormatResult = appleRootFormat(inventory.manifest.root_format); + if (!rootFormatResult.ok) return rootFormatResult; + const rootFormat = rootFormatResult.value; + const { entries, limitations: unresolved } = + projectedInventoryEntries(inventory); const inventoried = projectedFileComponents(entries); const { roots, bundles } = projectAppleBundleAnatomy(entries, { name: evidence[0]?.subject?.name ?? "", @@ -161,6 +151,7 @@ export const projectAppleApplication = ( ); const complete = inventoryComplete && !uninventoriedImage; const limitations = [ + ...unresolved, ...inventory.limitations, ...projectionLimitations({ complete: inventoryComplete, @@ -203,13 +194,12 @@ export const projectAppleApplication = ( }, limitations, }; - return { - projection: appleApplicationProjectionResultSchema.parse({ + return ok( + appleApplicationProjectionResultSchema.parse({ ...withoutId, projection_id: appleProjectionId(withoutId), }), - sources: evidence, - }; + ); }; /** Identify the complete projection, including source Evidence provenance. */ @@ -235,23 +225,27 @@ const attributeComponents = ( return { all, unattributed: visible.length - all.length }; }; -const appleRootFormat = (format: string): RootFormat => { +const appleRootFormat = ( + format: string, +): Result => { const root = APPLE_ROOT_FORMATS.find((candidate) => candidate === format); if (root === undefined) - throw new AnalysisInputError("project_apple_application_graph", {}, [ - { - path: [ - "inventory_evidence", - 0, - "normalized_result", - "manifest", - "root_format", - ], - reason: "invalid_value", - message: `Apple application projection requires IPA, directory, ZIP, or DMG inventory Evidence (got ${format})`, - }, - ]); - return root; + return err( + new AnalysisInputError("project_apple_application_graph", {}, [ + { + path: [ + "inventory_evidence", + 0, + "normalized_result", + "manifest", + "root_format", + ], + reason: "invalid_value", + message: `Apple application projection requires IPA, directory, ZIP, or DMG inventory Evidence (got ${format})`, + }, + ]), + ); + return ok(root); }; const isNativeLibrary = (path: string): boolean => diff --git a/src/domain/harmony/harmonyApplication.ts b/src/domain/harmony/harmonyApplication.ts index be3040fd45..88e3c695e3 100644 --- a/src/domain/harmony/harmonyApplication.ts +++ b/src/domain/harmony/harmonyApplication.ts @@ -7,15 +7,13 @@ import { AnalysisInputError } from "../analysisErrorCore.js"; import { hasCompleteInventoryCoverage, - parseArtifactInventoryEvidence, + type ParsedInventorySet, } from "../artifactInventoryEvidence.js"; import { digestSchema } from "../digests.js"; import { prefixedDigestSchema } from "../digests.js"; import { digestCanonicalValue } from "../canonicalDigest.js"; import { nativeRuntimeConvention } from "../nativeConvention.js"; import { - applicationInventoryProjectionInputSchema, - type MobileApplicationProjectionWithSources, projectedBridgeCandidateSchema, projectedComponentSchema, projectedInventoryEntries, @@ -25,34 +23,28 @@ import { unavailableComponentSchema, } from "../mobileApplicationGraphProjection.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; - -const evidenceIdSchema = projectionEvidenceIdSchema; -const componentSchema = projectedComponentSchema; +import { err, ok, type Result } from "../result.js"; const HARMONY_ROOT_FORMATS = ["hap", "hsp", "app-pack"] as const; type RootFormat = (typeof HARMONY_ROOT_FORMATS)[number]; -/** Authenticated HarmonyOS inventory pages projected as one application. */ -export const harmonyApplicationProjectionInputSchema = - applicationInventoryProjectionInputSchema; - /** Deterministic, execution-free HarmonyOS application inventory projection. */ export const harmonyApplicationProjectionResultSchema = z.strictObject({ projection_id: prefixedDigestSchema("hmp"), root_sha256: digestSchema, root_format: z.enum(HARMONY_ROOT_FORMATS), - source_evidence_ids: z.array(evidenceIdSchema).min(1), + source_evidence_ids: z.array(projectionEvidenceIdSchema).min(1), packaging_model: z.enum(["stage", "fa", "unknown"]), components: z.strictObject({ - manifests: z.array(componentSchema), - bytecode: z.array(componentSchema), - resources: z.array(componentSchema), - native_libraries: z.array(componentSchema), - javascript: z.array(componentSchema), - signing: z.array(componentSchema), + manifests: z.array(projectedComponentSchema), + bytecode: z.array(projectedComponentSchema), + resources: z.array(projectedComponentSchema), + native_libraries: z.array(projectedComponentSchema), + javascript: z.array(projectedComponentSchema), + signing: z.array(projectedComponentSchema), }), unavailable_components: z.array(unavailableComponentSchema), - app_pack_children: z.array(componentSchema), + app_pack_children: z.array(projectedComponentSchema), runtime_families: z.array( z.enum(["ark", "native", "javascript", "react-native", "flutter", "unity"]), ), @@ -71,24 +63,21 @@ export const harmonyApplicationProjectionResultSchema = z.strictObject({ limitations: z.array(z.string()), }); -export type HarmonyApplicationProjectionInput = z.infer< - typeof harmonyApplicationProjectionInputSchema ->; export type HarmonyApplicationProjectionResult = z.infer< typeof harmonyApplicationProjectionResultSchema >; -type Component = z.infer; +type Component = z.infer; /** Project exact HarmonyOS package paths and hashes without decoding or executing target code. */ -export const projectHarmonyApplication = ( - input: HarmonyApplicationProjectionInput, -): MobileApplicationProjectionWithSources => { - const parsed = harmonyApplicationProjectionInputSchema.parse(input); - const { evidence, inventory } = parseArtifactInventoryEvidence( - parsed.inventory_evidence, - ); - const rootFormat = harmonyRootFormat(inventory.manifest.root_format); - const entries = projectedInventoryEntries(inventory); +export const projectHarmonyInventory = ( + source: ParsedInventorySet, +): Result => { + const { evidence, inventory } = source; + const rootFormatResult = harmonyRootFormat(inventory.manifest.root_format); + if (!rootFormatResult.ok) return rootFormatResult; + const rootFormat = rootFormatResult.value; + const { entries, limitations: unresolved } = + projectedInventoryEntries(inventory); const all = projectedFileComponents(entries); const unavailable = entries.flatMap(({ unavailable }) => unavailable === undefined ? [] : [unavailable], @@ -99,6 +88,7 @@ export const projectHarmonyApplication = ( classified.native_libraries, ); const limitations = [ + ...unresolved, ...inventory.limitations, ...(!hasCompleteInventoryCoverage(inventory, "container-entries") ? ["Source inventory coverage is partial; absence is unknown."] @@ -149,32 +139,35 @@ export const projectHarmonyApplication = ( ), limitations, }; - return { - projection: harmonyApplicationProjectionResultSchema.parse({ + return ok( + harmonyApplicationProjectionResultSchema.parse({ ...withoutId, projection_id: `hmp_${digestCanonicalValue(withoutId, "HarmonyOS application projection")}`, }), - sources: evidence, - }; + ); }; -const harmonyRootFormat = (format: string): RootFormat => { +const harmonyRootFormat = ( + format: string, +): Result => { const root = HARMONY_ROOT_FORMATS.find((candidate) => candidate === format); if (root === undefined) - throw new AnalysisInputError("project_harmony_application_graph", {}, [ - { - path: [ - "inventory_evidence", - 0, - "normalized_result", - "manifest", - "root_format", - ], - reason: "invalid_value", - message: `HarmonyOS application projection requires HAP, HSP, or App Pack inventory Evidence (got ${format})`, - }, - ]); - return root; + return err( + new AnalysisInputError("project_harmony_application_graph", {}, [ + { + path: [ + "inventory_evidence", + 0, + "normalized_result", + "manifest", + "root_format", + ], + reason: "invalid_value", + message: `HarmonyOS application projection requires HAP, HSP, or App Pack inventory Evidence (got ${format})`, + }, + ]), + ); + return ok(root); }; const packagingModel = ( diff --git a/src/domain/mobileApplicationGraphProjection.ts b/src/domain/mobileApplicationGraphProjection.ts index ea3ca45486..3b25f0f4d4 100644 --- a/src/domain/mobileApplicationGraphProjection.ts +++ b/src/domain/mobileApplicationGraphProjection.ts @@ -2,9 +2,15 @@ import { z } from "zod"; import { hasCompleteInventoryCoverage, + parseArtifactInventoryEvidence, + ArtifactInventoryCarrierError, type ParsedInventorySet, } from "./artifactInventoryEvidence.js"; -import { evidenceSchema, type Evidence } from "./evidence.js"; +import { evidenceSchema } from "./evidence.js"; +import { AnalysisInputError } from "./analysisErrorCore.js"; +import { EvidenceIntegrityError } from "./evidenceErrors.js"; +import { analysisInputErrorFromIssues } from "./inputIssueProjection.js"; +import { err, ok, type Result } from "./result.js"; import { digestSchema } from "./digests.js"; import { prefixedDigestSchema } from "./digests.js"; @@ -17,6 +23,43 @@ export const applicationInventoryProjectionInputSchema = z.strictObject({ inventory_evidence: z.array(evidenceSchema).min(1), }); +const applicationInventoryRequestSchema = + applicationInventoryProjectionInputSchema.extend({ + inventory_evidence: z.array(z.unknown()).min(1), + }); + +/** Resolve the request and authenticate inventory pages once for all projections. */ +export const readApplicationInventoryProjection = ( + input: unknown, + operation: string, +): Result => { + const frame = applicationInventoryRequestSchema.safeParse(input); + if (!frame.success) + return err( + analysisInputErrorFromIssues(operation, frame.error.issues, input, { + cause: frame.error, + }), + ); + try { + return ok(parseArtifactInventoryEvidence(frame.data.inventory_evidence)); + } catch (cause: unknown) { + if (cause instanceof EvidenceIntegrityError) return err(cause); + if (!(cause instanceof ArtifactInventoryCarrierError)) throw cause; + return err( + new AnalysisInputError(operation, { cause, userMessage: cause.message }, [ + { + path: [ + "inventory_evidence", + ...(cause.index === undefined ? [] : [cause.index, "operation"]), + ], + reason: "invalid_value", + message: cause.message, + }, + ]), + ); + } +}; + /** One authenticated inventory entry retained by application graph projections. */ export const projectedComponentSchema = z.strictObject({ path: projectionPathSchema, @@ -37,12 +80,6 @@ export const unavailableComponentSchema = z.strictObject({ export type ProjectedComponent = z.infer; -/** A projection and the authenticated source Evidence used to derive it. */ -export interface MobileApplicationProjectionWithSources { - readonly projection: Projection; - readonly sources: readonly Evidence[]; -} - /** Path-pair hypotheses connecting managed content to a native library. */ export const projectedBridgeCandidateSchema = < Basis extends readonly [string, ...string[]], @@ -72,7 +109,7 @@ export const projectionCoverage = ( inventory_complete: inventory.complete, }); -/** One occurrence, preserving entry kind and either authenticated or unavailable content. */ +/** One occurrence with resolved content, unavailable identity, or an unresolved node reference. */ export type ProjectedInventoryEntry = { readonly path: string; readonly kind: ParsedInventorySet["inventory"]["occurrences"][number]["entry_kind"]; @@ -82,16 +119,21 @@ export type ProjectedInventoryEntry = { readonly component: undefined; readonly unavailable: z.infer; } + | { readonly component: undefined; readonly unavailable: undefined } ); -/** Interpret identity once, before application-specific attribution and classification. */ +/** Resolve available identities and report references outside the supplied pages. */ export const projectedInventoryEntries = ( inventory: ParsedInventorySet["inventory"], -): ProjectedInventoryEntry[] => { +): { + readonly entries: ProjectedInventoryEntry[]; + readonly limitations: string[]; +} => { const nodes = new Map( inventory.nodes.map((node) => [node.artifact_id, node]), ); - return inventory.occurrences + const limitations: string[] = []; + const entries = inventory.occurrences .filter(({ logical_path }) => logical_path !== ".") .map((occurrence): ProjectedInventoryEntry => { const path = occurrence.logical_path; @@ -111,10 +153,12 @@ export const projectedInventoryEntries = ( }, }; const node = nodes.get(occurrence.artifact_id); - if (node === undefined) - throw new TypeError( - "Application projection occurrence has no artifact node", + if (node === undefined) { + limitations.push( + `Inventory occurrence ${JSON.stringify(path)} references ${occurrence.artifact_id} (${occurrence.artifact_format}), whose node is outside the supplied pages. Its content SHA-256 and component classification remain unknown; the source Evidence retains the occurrence.`, ); + return { path, kind, component: undefined, unavailable: undefined }; + } return { path, kind, @@ -127,6 +171,7 @@ export const projectedInventoryEntries = ( unavailable: undefined, }; }); + return { entries, limitations }; }; /** Directory trees and links retain inventory identity but are not file components. */ diff --git a/tests/acceptance/tools/mobileGraphExamples.test.ts b/tests/acceptance/tools/mobileGraphExamples.test.ts index 09cc9532d6..2ab3ffc0d3 100644 --- a/tests/acceptance/tools/mobileGraphExamples.test.ts +++ b/tests/acceptance/tools/mobileGraphExamples.test.ts @@ -14,7 +14,7 @@ import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; -cliTest.for([ +const graphExamples = [ { name: "Android", command: "project-android-application-graph", @@ -39,7 +39,9 @@ cliTest.for([ operation: "project_harmony_application_graph", example: HARMONY_APPLICATION_GRAPH_EXAMPLE, }, -] as const)( +] as const; + +cliTest.for(graphExamples)( "CLI executes the canonical $name graph example without target files", async ({ command, operation, example }, { cli }) => { const root = await createTestTempDirectory("rea-mobile-graph-example-"); @@ -61,6 +63,34 @@ cliTest.for([ }, ); +cliTest.for(graphExamples)( + "CLI classifies malformed $name source Evidence separately from request errors", + async ({ command, example }, { cli }) => { + const source = example.inventory_evidence[0]!; + for (const { input, code } of [ + { + input: { + inventory_evidence: [{ ...source, evidence_id: "not-a-digest" }], + }, + code: "evidence_integrity_mismatch", + }, + { + input: { inventory_evidence: [source], unexpected: true }, + code: "invalid_request", + }, + { input: { inventory_evidence: [] }, code: "invalid_request" }, + ]) { + const result = await cli.run({ + arguments: [command, JSON.stringify(input), "--json"], + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code, + }); + } + }, +); + cliTest( "CLI still rejects an unsupported inventory coverage status", async ({ cli }) => { diff --git a/tests/boundary/mcp/mobileApplicationGraphMcp.test.ts b/tests/boundary/mcp/mobileApplicationGraphMcp.test.ts index 4d66e4cfbb..a8132588a9 100644 --- a/tests/boundary/mcp/mobileApplicationGraphMcp.test.ts +++ b/tests/boundary/mcp/mobileApplicationGraphMcp.test.ts @@ -11,8 +11,11 @@ import { import { afterEach, expect, it } from "vitest"; import { createDirectAnalysis } from "../../../src/composition/directAnalysis.js"; -import { parseEvidence } from "../../../src/domain/evidence.js"; +import { androidApplicationProjectionResultSchema } from "../../../src/domain/android/androidApplication.js"; +import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js"; +import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; import { fragmentInventoryEvidence } from "../../fixtures/artifactEvidence.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { createServer } from "../../../src/server/createServer.js"; @@ -115,7 +118,9 @@ it("advertises and executes Android and Apple inventory projections", async () = name: "project_android_application_graph", arguments: { inventory_evidence: [tamperedSource] }, }); - expect(rejected.isError).toBe(true); + expect(parseMcpToolError(rejected)).toMatchObject({ + error: { code: "evidence_integrity_mismatch" }, + }); const bundleAfterRejectedSource = await client.callTool({ name: "get_evidence_bundle", arguments: {}, @@ -143,6 +148,160 @@ it("advertises and executes Android and Apple inventory projections", async () = }); }); +it("projects a single inventory page as partial and links only that source", async () => { + const client = await connect(); + const root = await createTestTempDirectory("rea-mobile-partial-page-mcp-"); + const inventory = parseEvidence( + await runProviderAnalysis(await writeApk(root), "inventory_artifact", {}), + ); + const facts = artifactInventoryResultSchema.parse( + inventory.normalized_result, + ); + const subject = inventory.subject; + if (subject === null) throw new Error("Expected an inventory subject"); + const page = createEvidence( + { + path: subject.local_path, + sha256: subject.digest.sha256, + format: subject.format, + }, + inventory.provider, + { + operation: "inventory_artifact", + parameters: inventory.parameters, + result: { ...facts, nodes: [] }, + confidence: "derived", + authority: inventory.authority, + }, + ); + + const reply = await client.callTool({ + name: "project_android_application_graph", + arguments: { inventory_evidence: [page] }, + }); + if (reply.isError === true) throw new Error(JSON.stringify(reply, null, 2)); + const projection = parseEvidence(reply.structuredContent); + const result = androidApplicationProjectionResultSchema.parse( + projection.normalized_result, + ); + expect(result).toMatchObject({ + source_evidence_ids: [page.evidence_id], + coverage: { status: "partial", inventory_complete: false }, + }); + expect(result.limitations).toContain( + "Source inventory coverage is partial; absence is unknown.", + ); + expect(result.components.dex).toEqual([]); + expect(result.unavailable_components).toEqual([]); + const dex = facts.occurrences.find( + ({ logical_path }) => logical_path === "classes.dex", + ); + if (dex?.artifact_id === null || dex === undefined) + throw new Error("Expected a known DEX artifact reference"); + const dexArtifactId = dex.artifact_id; + const unresolved = result.limitations.find((limitation) => + limitation.includes(dexArtifactId), + ); + expect(unresolved).toContain('"classes.dex"'); + expect(unresolved).toContain(dex.artifact_format); + expect(unresolved).toContain("unknown"); + expect(projection.evidence_links).toEqual([page.evidence_id]); + const bundle = await client.callTool({ + name: "get_evidence_bundle", + arguments: {}, + }); + expect(bundle.structuredContent).toMatchObject({ + result: { records: expect.arrayContaining([page, projection]) }, + }); + const completed = await client.callTool({ + name: "project_android_application_graph", + arguments: { inventory_evidence: [page, inventory] }, + }); + if (completed.isError === true) throw new Error(JSON.stringify(completed)); + const resolved = androidApplicationProjectionResultSchema.parse( + parseEvidence(completed.structuredContent).normalized_result, + ); + expect(resolved.coverage.inventory_complete).toBe(true); + expect(resolved.components.dex).toContainEqual( + expect.objectContaining({ + path: "classes.dex", + artifact_id: dex.artifact_id, + }), + ); + expect( + resolved.limitations.some((limitation) => + limitation.includes(dexArtifactId), + ), + ).toBe(false); +}); + +it("rejects a valid non-inventory carrier as caller-selected input", async () => { + const client = await connect(); + const wrongCarrier = createEvidence( + undefined, + { id: "fixture", name: "Fixture", version: "1" }, + { operation: "analyze_binary", parameters: {}, result: {} }, + ); + + const rejected = await client.callTool({ + name: "project_android_application_graph", + arguments: { inventory_evidence: [wrongCarrier] }, + }); + expect(parseMcpToolError(rejected)).toMatchObject({ + error: { + code: "invalid_request", + category: "invalid_input", + details: { + issues: [ + { + path: ["inventory_evidence", 0, "operation"], + }, + ], + }, + }, + }); + const bundle = await client.callTool({ + name: "get_evidence_bundle", + arguments: {}, + }); + expect(bundle.structuredContent).toMatchObject({ result: { records: [] } }); +}); + +it("rejects authentic pages from different roots without retaining either", async () => { + const client = await connect(); + const root = await createTestTempDirectory("rea-mobile-mixed-pages-mcp-"); + const apk = parseEvidence( + await runProviderAnalysis(await writeApk(root), "inventory_artifact", {}), + ); + const ipa = parseEvidence( + await runProviderAnalysis(await writeIpa(root), "inventory_artifact", {}), + ); + + const rejected = await client.callTool({ + name: "project_android_application_graph", + arguments: { + inventory_evidence: [ + fragmentInventoryEvidence(apk, 2)[0], + fragmentInventoryEvidence(ipa, 2)[0], + ], + }, + }); + expect(parseMcpToolError(rejected)).toMatchObject({ + error: { + code: "invalid_request", + category: "invalid_input", + details: { + issues: [{ path: ["inventory_evidence"] }], + }, + }, + }); + const bundle = await client.callTool({ + name: "get_evidence_bundle", + arguments: {}, + }); + expect(bundle.structuredContent).toMatchObject({ result: { records: [] } }); +}); + it.each([ { family: "Harmony",