diff --git a/src/application/InventoryProjectionEvidence.ts b/src/application/InventoryProjectionEvidence.ts index eefc4a9d92..d027febb57 100644 --- a/src/application/InventoryProjectionEvidence.ts +++ b/src/application/InventoryProjectionEvidence.ts @@ -9,6 +9,7 @@ import { 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, workflowInputError } from "./workflowInputError.js"; @@ -23,13 +24,20 @@ interface InventoryProjectionResult { readonly limitations: readonly string[]; } +export interface InventoryProjectionEvidenceResult { + readonly evidence: Evidence; + readonly sources: readonly Evidence[]; +} + interface InventoryProjectionOptions< Input extends InventoryProjectionInput, Output extends InventoryProjectionResult, > { readonly rawInput: unknown; readonly schema: z.ZodType; - readonly project: (input: Input) => Output; + readonly project: ( + input: Input, + ) => MobileApplicationProjectionWithSources; readonly operation: string; readonly predicateType: string; readonly provider: EvidenceProvider; @@ -43,17 +51,18 @@ export const projectInventoryEvidence = < Output extends InventoryProjectionResult, >( options: InventoryProjectionOptions, -): Result => { +): Result => { const parsed = options.schema.safeParse(options.rawInput); if (!parsed.success) return err( requestInputError(options.operation, parsed.error, options.rawInput), ); try { - const result = options.project(parsed.data); - const first = parsed.data.inventory_evidence[0]; - return ok( - createEvidence( + 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 : { @@ -80,7 +89,8 @@ export const projectInventoryEvidence = < evidenceLinks: result.source_evidence_ids, }, ), - ); + sources: projected.sources, + }); } catch (cause: unknown) { return err( cause instanceof TypeError || cause instanceof z.ZodError diff --git a/src/application/android/AndroidApplicationService.ts b/src/application/android/AndroidApplicationService.ts index 0798e055e1..de95c455f3 100644 --- a/src/application/android/AndroidApplicationService.ts +++ b/src/application/android/AndroidApplicationService.ts @@ -1,19 +1,21 @@ import type { AnalysisError } from "../../domain/analysisErrorBase.js"; -import type { Evidence } from "../../domain/evidence.js"; import { androidApplicationProjectionInputSchema, projectAndroidApplication, } from "../../domain/android/androidApplication.js"; import type { Result } from "../../domain/result.js"; import { ANDROID_APPLICATION_PROVIDER } from "../InvestigationProviders.js"; -import { projectInventoryEvidence } from "../InventoryProjectionEvidence.js"; +import { + projectInventoryEvidence, + type InventoryProjectionEvidenceResult, +} from "../InventoryProjectionEvidence.js"; const OPERATION = "project_android_application_graph" as const; /** Project authenticated APK inventory Evidence into Android application Evidence. */ export const projectAndroidApplicationEvidence = ( rawInput: unknown, -): Result => { +): Result => { return projectInventoryEvidence({ rawInput, schema: androidApplicationProjectionInputSchema, diff --git a/src/application/apple/AppleApplicationService.ts b/src/application/apple/AppleApplicationService.ts index a30cb654c0..25686959d3 100644 --- a/src/application/apple/AppleApplicationService.ts +++ b/src/application/apple/AppleApplicationService.ts @@ -1,19 +1,21 @@ import type { AnalysisError } from "../../domain/analysisErrorBase.js"; -import type { Evidence } from "../../domain/evidence.js"; import { appleApplicationProjectionInputSchema, projectAppleApplication, } from "../../domain/apple/appleApplication.js"; import type { Result } from "../../domain/result.js"; import { APPLE_APPLICATION_PROVIDER } from "../InvestigationProviders.js"; -import { projectInventoryEvidence } from "../InventoryProjectionEvidence.js"; +import { + projectInventoryEvidence, + type InventoryProjectionEvidenceResult, +} from "../InventoryProjectionEvidence.js"; const OPERATION = "project_apple_application_graph" as const; /** Project authenticated IPA or macOS app inventory Evidence into Apple application evidence. */ export const projectAppleApplicationEvidence = ( rawInput: unknown, -): Result => { +): Result => { return projectInventoryEvidence({ rawInput, schema: appleApplicationProjectionInputSchema, diff --git a/src/application/harmony/HarmonyApplicationService.ts b/src/application/harmony/HarmonyApplicationService.ts index dcdc8489b9..03d79c98fb 100644 --- a/src/application/harmony/HarmonyApplicationService.ts +++ b/src/application/harmony/HarmonyApplicationService.ts @@ -1,19 +1,21 @@ import type { AnalysisError } from "../../domain/analysisErrorBase.js"; -import type { Evidence } from "../../domain/evidence.js"; import { harmonyApplicationProjectionInputSchema, projectHarmonyApplication, } from "../../domain/harmony/harmonyApplication.js"; import type { Result } from "../../domain/result.js"; import { HARMONY_APPLICATION_PROVIDER } from "../InvestigationProviders.js"; -import { projectInventoryEvidence } from "../InventoryProjectionEvidence.js"; +import { + projectInventoryEvidence, + type InventoryProjectionEvidenceResult, +} from "../InventoryProjectionEvidence.js"; const OPERATION = "project_harmony_application_graph" as const; /** Project authenticated HarmonyOS package inventory Evidence into application evidence. */ export const projectHarmonyApplicationEvidence = ( rawInput: unknown, -): Result => { +): Result => { return projectInventoryEvidence({ rawInput, schema: harmonyApplicationProjectionInputSchema, diff --git a/src/artifacts/inventory/ArtifactOccurrenceIdentity.test.ts b/src/artifacts/inventory/ArtifactOccurrenceIdentity.test.ts index f44d79aeb7..ef4df19b03 100644 --- a/src/artifacts/inventory/ArtifactOccurrenceIdentity.test.ts +++ b/src/artifacts/inventory/ArtifactOccurrenceIdentity.test.ts @@ -133,7 +133,7 @@ describe("content identity and occurrence facts", () => { }, ), ], - }); + }).projection; 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 4561c0f7df..2529efb23b 100644 --- a/src/cli/applicationCommands.ts +++ b/src/cli/applicationCommands.ts @@ -93,7 +93,7 @@ export const registerApplicationCommands = ( await import("../application/android/AndroidApplicationService.js"); const result = projectAndroidApplicationEvidence(input); return result.ok - ? { ok: true, value: jsonValueSchema.parse(result.value) } + ? { ok: true, value: jsonValueSchema.parse(result.value.evidence) } : result; }, }); @@ -109,7 +109,7 @@ export const registerApplicationCommands = ( await import("../application/apple/AppleApplicationService.js"); const result = projectAppleApplicationEvidence(input); return result.ok - ? { ok: true, value: jsonValueSchema.parse(result.value) } + ? { ok: true, value: jsonValueSchema.parse(result.value.evidence) } : result; }, }); @@ -125,7 +125,7 @@ export const registerApplicationCommands = ( await import("../application/harmony/HarmonyApplicationService.js"); const result = projectHarmonyApplicationEvidence(input); return result.ok - ? { ok: true, value: jsonValueSchema.parse(result.value) } + ? { ok: true, value: jsonValueSchema.parse(result.value.evidence) } : result; }, }); diff --git a/src/domain/android/androidApplication.ts b/src/domain/android/androidApplication.ts index 7276e627ef..1a88b0bd2f 100644 --- a/src/domain/android/androidApplication.ts +++ b/src/domain/android/androidApplication.ts @@ -9,6 +9,7 @@ import { prefixedDigestSchema } from "../digests.js"; import { nativeRuntimeConvention } from "../nativeConvention.js"; import { applicationInventoryProjectionInputSchema, + type MobileApplicationProjectionWithSources, bridgeCandidateCoverageSchema, projectCartesianCandidates, projectedBridgeCandidateSchema, @@ -85,7 +86,7 @@ type Component = z.infer; /** Project exact APK paths and hashes without decoding or executing target code. */ export const projectAndroidApplication = ( input: AndroidApplicationProjectionInput, -): AndroidApplicationProjectionResult => { +): MobileApplicationProjectionWithSources => { const parsed = androidApplicationProjectionInputSchema.parse(input); const { evidence, inventory } = parseArtifactInventoryEvidence( parsed.inventory_evidence, @@ -149,10 +150,13 @@ export const projectAndroidApplication = ( ), limitations, }; - return androidApplicationProjectionResultSchema.parse({ - ...withoutId, - projection_id: `adp_${projectionDigest(withoutId, "Android application projection")}`, - }); + return { + projection: androidApplicationProjectionResultSchema.parse({ + ...withoutId, + projection_id: `adp_${projectionDigest(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 27e7775adc..fc973ba732 100644 --- a/src/domain/apple/appleApplication.test.ts +++ b/src/domain/apple/appleApplication.test.ts @@ -161,7 +161,7 @@ const inventoryEvidence = ( }; const project = (evidence: Evidence) => - projectAppleApplication({ inventory_evidence: [evidence] }); + projectAppleApplication({ inventory_evidence: [evidence] }).projection; const macho = (path: string): FixtureEntry => ({ path, format: "mach-o" }); @@ -849,7 +849,7 @@ it.each(["file", "slice"] as const)( }, ]), ], - }); + }).projection; 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 a5b06d0dd3..6c2dfd0496 100644 --- a/src/domain/apple/appleApplication.ts +++ b/src/domain/apple/appleApplication.ts @@ -25,6 +25,7 @@ import { bridgeCandidateCoverageSchema, projectCartesianCandidates, } from "../bridgeCandidateProjection.js"; +import type { MobileApplicationProjectionWithSources } from "../mobileApplicationGraphProjection.js"; import { projectedComponentSchema, projectedInventoryEntries, @@ -113,7 +114,7 @@ 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, -): AppleApplicationProjectionResult => { +): MobileApplicationProjectionWithSources => { const parsed = appleApplicationProjectionInputSchema.parse(input); const { evidence, inventory } = parseArtifactInventoryEvidence( parsed.inventory_evidence, @@ -200,10 +201,13 @@ export const projectAppleApplication = ( }, limitations, }; - return appleApplicationProjectionResultSchema.parse({ - ...withoutId, - projection_id: appleProjectionId(withoutId), - }); + return { + projection: appleApplicationProjectionResultSchema.parse({ + ...withoutId, + projection_id: appleProjectionId(withoutId), + }), + sources: evidence, + }; }; /** Identify the complete projection, including source Evidence provenance. */ diff --git a/src/domain/harmony/harmonyApplication.ts b/src/domain/harmony/harmonyApplication.ts index 2055d8f99c..1e43719896 100644 --- a/src/domain/harmony/harmonyApplication.ts +++ b/src/domain/harmony/harmonyApplication.ts @@ -11,6 +11,7 @@ import { applicationInventoryProjectionInputSchema, bridgeCandidateCoverageSchema, projectCartesianCandidates, + type MobileApplicationProjectionWithSources, projectedBridgeCandidateSchema, projectedComponentSchema, projectedInventoryEntries, @@ -78,7 +79,7 @@ type Component = z.infer; /** Project exact HarmonyOS package paths and hashes without decoding or executing target code. */ export const projectHarmonyApplication = ( input: HarmonyApplicationProjectionInput, -): HarmonyApplicationProjectionResult => { +): MobileApplicationProjectionWithSources => { const parsed = harmonyApplicationProjectionInputSchema.parse(input); const { evidence, inventory } = parseArtifactInventoryEvidence( parsed.inventory_evidence, @@ -145,10 +146,13 @@ export const projectHarmonyApplication = ( ), limitations, }; - return harmonyApplicationProjectionResultSchema.parse({ - ...withoutId, - projection_id: `hmp_${projectionDigest(withoutId, "HarmonyOS application projection")}`, - }); + return { + projection: harmonyApplicationProjectionResultSchema.parse({ + ...withoutId, + projection_id: `hmp_${projectionDigest(withoutId, "HarmonyOS application projection")}`, + }), + sources: evidence, + }; }; const harmonyRootFormat = (format: string): RootFormat => { diff --git a/src/domain/mobileApplicationGraphProjection.ts b/src/domain/mobileApplicationGraphProjection.ts index b2866538af..6d440f95ae 100644 --- a/src/domain/mobileApplicationGraphProjection.ts +++ b/src/domain/mobileApplicationGraphProjection.ts @@ -7,7 +7,7 @@ import { hasCompleteInventoryCoverage, type ParsedInventorySet, } from "./artifactInventoryEvidence.js"; -import { evidenceSchema } from "./evidence.js"; +import { evidenceSchema, type Evidence } from "./evidence.js"; import { digestSchema } from "./digests.js"; import { prefixedDigestSchema } from "./digests.js"; import { @@ -44,6 +44,12 @@ 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[]], diff --git a/src/server/registerApplicationTools/projectMobileApplicationGraph.ts b/src/server/registerApplicationTools/projectMobileApplicationGraph.ts index 195fcdd9f2..c072556edb 100644 --- a/src/server/registerApplicationTools/projectMobileApplicationGraph.ts +++ b/src/server/registerApplicationTools/projectMobileApplicationGraph.ts @@ -10,8 +10,8 @@ import { applicationToolContract, } from "../../contracts/applicationToolContracts.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; -import type { Evidence } from "../../domain/evidence.js"; import type { Result } from "../../domain/result.js"; +import type { InventoryProjectionEvidenceResult } from "../../application/InventoryProjectionEvidence.js"; import { recordSessionEvidenceSources } from "../sessionEvidence.js"; import { logToolExecution } from "../toolLogging.js"; import { toolRegistrationOptions } from "../toolRegistrationOptions.js"; @@ -47,7 +47,9 @@ const registerProjection = (context: { readonly server: EvidenceMcpServer; readonly options: ApplicationToolRegistration; readonly contract: (typeof APPLICATION_TOOL_CONTRACTS)[number]; - readonly project: (input: unknown) => Result; + readonly project: ( + input: unknown, + ) => Result; }): void => { const { server, options, contract, project } = context; server.registerTool( @@ -69,27 +71,16 @@ const registerProjection = (context: { return server.delivery.toCallToolResult(result, contract); const recordedSources = recordSessionEvidenceSources( options.recordEvidence, - inventoryEvidenceSources(input), + result.value.sources, ); if (!recordedSources.ok) return server.delivery.toCallToolResult(recordedSources, contract); return recordResult( { ...options, delivery: server.delivery }, contract, - result.value, + result.value.evidence, ); }, ), ); }; - -const inventoryEvidenceSources = (input: unknown): readonly Evidence[] => { - if ( - typeof input !== "object" || - input === null || - !("inventory_evidence" in input) || - !Array.isArray(input.inventory_evidence) - ) - return []; - return input.inventory_evidence as readonly Evidence[]; -}; diff --git a/tests/boundary/filesystem/android/application.test.ts b/tests/boundary/filesystem/android/application.test.ts index eb5efb950a..db405705f7 100644 --- a/tests/boundary/filesystem/android/application.test.ts +++ b/tests/boundary/filesystem/android/application.test.ts @@ -313,7 +313,7 @@ describe("Android bridge candidate coverage", () => { if (!result.ok) throw result.error; const projection = androidApplicationProjectionResultSchema.parse( - result.value.normalized_result, + result.value.evidence.normalized_result, ); expect(projection.components.dex).toHaveLength(1_001); expect(projection.bridge_candidates).toHaveLength(1_001); @@ -346,7 +346,7 @@ describe("Android bridge candidate coverage", () => { }); if (!result.ok) throw result.error; const projection = androidApplicationProjectionResultSchema.parse( - result.value.normalized_result, + result.value.evidence.normalized_result, ); expect(projection.components.dex).toHaveLength(129); diff --git a/tests/boundary/filesystem/appleApplication.test.ts b/tests/boundary/filesystem/appleApplication.test.ts index 94f4daa85c..418fd1aa3f 100644 --- a/tests/boundary/filesystem/appleApplication.test.ts +++ b/tests/boundary/filesystem/appleApplication.test.ts @@ -66,8 +66,11 @@ async function createCompleteAppleProjection() { }); if (!result.ok) throw new TypeError("Could not project fixture inventory"); + expect(result.value.sources.map(({ evidence_id }) => evidence_id)).toEqual( + fragments.map(({ evidence_id }) => evidence_id), + ); const projection = appleApplicationProjectionResultSchema.parse( - result.value.normalized_result, + result.value.evidence.normalized_result, ); expect([...projection.source_evidence_ids].sort()).toEqual( fragments.map(({ evidence_id }) => evidence_id).sort(), @@ -481,7 +484,7 @@ describe("Apple application projection completeness", () => { if (!result.ok) throw result.error; expect( appleApplicationProjectionResultSchema.parse( - result.value.normalized_result, + result.value.evidence.normalized_result, ), ).toMatchObject({ application_roots: [applicationRoot], diff --git a/tests/boundary/mcp/mobileApplicationGraphMcp.test.ts b/tests/boundary/mcp/mobileApplicationGraphMcp.test.ts index 6dae74c18c..4d66e4cfbb 100644 --- a/tests/boundary/mcp/mobileApplicationGraphMcp.test.ts +++ b/tests/boundary/mcp/mobileApplicationGraphMcp.test.ts @@ -12,6 +12,7 @@ import { afterEach, expect, it } from "vitest"; import { createDirectAnalysis } from "../../../src/composition/directAnalysis.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; +import { fragmentInventoryEvidence } from "../../fixtures/artifactEvidence.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { createServer } from "../../../src/server/createServer.js"; @@ -84,9 +85,10 @@ it("advertises and executes Android and Apple inventory projections", async () = const apkInventory = parseEvidence( await runProviderAnalysis(await writeApk(root), "inventory_artifact", {}), ); + const apkSources = fragmentInventoryEvidence(apkInventory, 2).toReversed(); const android = await client.callTool({ name: "project_android_application_graph", - arguments: { inventory_evidence: [apkInventory] }, + arguments: { inventory_evidence: apkSources }, }); if (android.isError === true) throw new Error(JSON.stringify(android, null, 2)); @@ -94,6 +96,38 @@ it("advertises and executes Android and Apple inventory projections", async () = operation: "project_android_application_graph", provider: { id: "rea-android-application" }, }); + const androidEvidence = parseEvidence(android.structuredContent); + const androidBundle = await client.callTool({ + name: "get_evidence_bundle", + arguments: {}, + }); + expect(androidBundle.structuredContent).toMatchObject({ + result: { + records: expect.arrayContaining([...apkSources, androidEvidence]), + }, + }); + + const tamperedSource = { + ...apkSources[0]!, + evidence_id: apkSources[1]!.evidence_id, + }; + const rejected = await client.callTool({ + name: "project_android_application_graph", + arguments: { inventory_evidence: [tamperedSource] }, + }); + expect(rejected.isError).toBe(true); + const bundleAfterRejectedSource = await client.callTool({ + name: "get_evidence_bundle", + arguments: {}, + }); + expect(bundleAfterRejectedSource.structuredContent).toMatchObject({ + result: { + records: expect.arrayContaining([...apkSources, androidEvidence]), + }, + }); + expect(bundleAfterRejectedSource.structuredContent).toEqual( + androidBundle.structuredContent, + ); const ipaInventory = parseEvidence( await runProviderAnalysis(await writeIpa(root), "inventory_artifact", {}), diff --git a/tests/support/applicationSessionFixture.ts b/tests/support/applicationSessionFixture.ts index 879df0b5dc..4867d410f2 100644 --- a/tests/support/applicationSessionFixture.ts +++ b/tests/support/applicationSessionFixture.ts @@ -13,16 +13,17 @@ import { createDirectAnalysis } from "../../src/composition/directAnalysis.js"; import type { AnalysisError } from "../../src/domain/analysisErrorBase.js"; import { type Evidence, parseEvidence } from "../../src/domain/evidence.js"; import type { Result } from "../../src/domain/result.js"; +import type { InventoryProjectionEvidenceResult } from "../../src/application/InventoryProjectionEvidence.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; const { runProviderAnalysis } = createDirectAnalysis({}); /** Assert a projection succeeded and return its Evidence for schema parsing. */ export const requireSuccessfulProjection = ( - result: Result, + result: Result, ): Evidence => { if (!result.ok) throw result.error; - return result.value; + return result.value.evidence; }; /** Write one synthetic ZIP application package from literal entries. */ @@ -61,7 +62,7 @@ export const inventoryApplicationPackage = async ( export const requireDeterministicProjection = ( project: (input: { inventory_evidence: readonly Evidence[]; - }) => Result, + }) => Result, inventory: Evidence, schema: Schema, ): z.output => {