diff --git a/docs/android-analysis.md b/docs/android-analysis.md index 63b935da5..efcb05c64 100644 --- a/docs/android-analysis.md +++ b/docs/android-analysis.md @@ -118,6 +118,12 @@ selected overload. Native/abstract methods with no decompiled body report responses remain in Evidence. Source revision is populated only for bytes matching the audited release JAR. +If a later step fails, the error's `details.partial_observation` retains the +selected and resolved APK paths, digest, observed provider identity and versions, +and each upstream request and reply collected before the failure. Failed replies +remain available even when their payload cannot be interpreted. Cancellation +also stops target hashing before provider admission. + These operations do not execute the APK or provide split APK/AAB handling, signature validation, full resource-table semantics, native-library analysis or Android runtime capture. Existing artifact inventory/extraction tools can supply diff --git a/src/android/JadxProvider.ts b/src/android/JadxProvider.ts index e1dc7d4ab..98e798cf7 100644 --- a/src/android/JadxProvider.ts +++ b/src/android/JadxProvider.ts @@ -123,24 +123,25 @@ const cleanup = async ( resources.session = undefined; } catch (cause) { // Retain an uncertain workspace until the caller can resolve process ownership. - return err( - new ProviderCleanupError( - "jadx", - [ - session?.transport.runId ?? "unknown", - ...(root === undefined ? [] : [root.path]), - ], - { - ...diagnostics, - reason: cause instanceof Error ? cause.message : String(cause), - provider_cleanup: - cause instanceof ProviderCleanupError - ? (cause.diagnostics ?? null) - : null, - }, - { cause: previousError ?? cause }, - ), + const error = new ProviderCleanupError( + "jadx", + [ + session?.transport.runId ?? "unknown", + ...(root === undefined ? [] : [root.path]), + ], + { + ...diagnostics, + reason: cause instanceof Error ? cause.message : String(cause), + provider_cleanup: + cause instanceof ProviderCleanupError + ? (cause.diagnostics ?? null) + : null, + }, + { cause: previousError ?? cause }, ); + if (previousError?.partialObservation !== undefined) + error.retainPartialObservation(previousError.partialObservation); + return err(error); } try { await root?.close(); @@ -396,6 +397,9 @@ export class JadxProvider implements AndroidAnalysisPort { signal: options?.signal, session, }); + const partialObservation = session?.partialObservation(); + if (partialObservation !== undefined) + previousError.retainPartialObservation(partialObservation); // Admission can fail before selecting an existing session for execution. root ??= this.#retained?.root; session ??= this.#retained?.session; diff --git a/src/android/JadxSession.ts b/src/android/JadxSession.ts index b8e11f285..13f154b2e 100644 --- a/src/android/JadxSession.ts +++ b/src/android/JadxSession.ts @@ -4,6 +4,7 @@ import { type AnalysisExecution, } from "../application/AnalysisProvider.js"; import type { AndroidRequest } from "../domain/android/androidAnalysis.js"; +import type { AndroidPartialObservation } from "../domain/android/androidPartialObservation.js"; import { AnalysisCapabilityUnavailableError, AnalysisInputError, @@ -35,6 +36,23 @@ export class JadxSession { readonly #client = new Client({ name: "rea-android-adapter", version: "1" }); #loaded: ReturnType | undefined; #connected = false; + #partialContext: + | { + readonly operation: AndroidRequest["operation"]; + readonly selectedPath: string; + readonly target: BinaryTarget; + readonly jarHash: string; + readonly bridgeHash: string; + } + | undefined; + #observedCalls: { + readonly operation: string; + readonly input: Readonly>; + readonly response: JsonValue; + }[] = []; + #serverName: string | null = null; + #serverVersion: string | null = null; + #engineVersion: string | null = null; constructor( options: Omit, @@ -53,6 +71,17 @@ export class JadxSession { readonly signal: AbortSignal; }): Promise { const { request, target, snapshot, jarHash, signal } = context; + this.#partialContext = { + operation: request.operation, + selectedPath: request.input.path, + target, + jarHash, + bridgeHash: context.bridgeHash, + }; + this.#observedCalls = []; + this.#serverName = null; + this.#serverVersion = null; + this.#engineVersion = null; const abort = () => { void this.transport.close().catch(() => undefined); }; @@ -60,12 +89,13 @@ export class JadxSession { try { signal.throwIfAborted(); this.transport.beginOperation(); - const raw: JsonValue[] = []; if (!this.#connected) { await this.#client.connect(this.transport, { timeout: 30_000 }); this.#connected = true; } const server = this.#client.getServerVersion(); + this.#serverName = server?.name ?? null; + this.#serverVersion = server?.version ?? null; if ( server?.name !== JADX_BRIDGE_IDENTITY.name || server.version !== JADX_BRIDGE_IDENTITY.version @@ -75,10 +105,11 @@ export class JadxSession { request.operation, `Expected ${JADX_BRIDGE_IDENTITY.name} ${JADX_BRIDGE_IDENTITY.version}; server reported ${server?.name ?? "unknown"} ${server?.version ?? "unknown"}.`, ); - const tools = this.#tools(request, target, signal, raw); + const tools = this.#tools(request, target, signal); const runtime = jadxRuntimeSchema.parse( await tools.json("rea_jvm_status", {}), ); + this.#engineVersion = runtime.engine_reported_version; if (runtime.engine_reported_version !== JADX_RELEASE.version) throw new AnalysisCapabilityUnavailableError( "jadx", @@ -117,7 +148,7 @@ export class JadxSession { bridge_sha256: context.bridgeHash, jar_sha256: jarHash, loaded, - calls: raw, + calls: this.#observedCalls, }, subject: target, limitations: JADX_LIMITATIONS, @@ -131,6 +162,32 @@ export class JadxSession { } } + /** Facts and upstream replies retained if a later request step fails. */ + partialObservation(): AndroidPartialObservation | undefined { + const context = this.#partialContext; + if (context === undefined) return undefined; + return { + provider_id: "jadx", + operation: context.operation, + target: { + selected_path: context.selectedPath, + path: context.target.path, + sha256: context.target.sha256, + format: "apk", + }, + provider_facts: { + jar_sha256: context.jarHash, + bridge_sha256: context.bridgeHash, + bridge_name: JADX_BRIDGE_IDENTITY.name, + bridge_version: JADX_BRIDGE_IDENTITY.version, + server_name: this.#serverName, + server_version: this.#serverVersion, + engine_version: this.#engineVersion, + }, + calls: this.#observedCalls, + }; + } + /** Whether the selected Java runtime established the REA bridge protocol. */ initialized(): boolean { return this.#connected; @@ -149,7 +206,6 @@ export class JadxSession { request: AndroidRequest, target: BinaryTarget, signal: AbortSignal, - raw: JsonValue[], ): JadxToolPort { const call = async ( name: string, @@ -160,12 +216,10 @@ export class JadxSession { { name, arguments: input }, { timeout: 120_000, signal }, ); + const observedResponse = jsonValueSchema.parse(response); + const call = { operation: name, input, response: observedResponse }; + this.#observedCalls.push(call); const envelope = parseJadxEnvelope(response, request.operation); - raw.push({ - operation: name, - input, - response: jsonValueSchema.parse(response), - }); if (envelope.failed) { if (envelope.text.startsWith("{")) { const failure = jadxInputFailureSchema.safeParse( diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index b51c88656..b24e9cfb2 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -269,7 +269,8 @@ const runAnalysis = async ( }); if (evidence !== undefined) { const recorded = session.recordEvidence(evidence); - if (!recorded.ok) return cliError(recorded.error); + if (!recorded.ok) + return cliError(recorded.error.retainPartialObservation(evidence)); if (isWorkflowEvidenceTool(tool)) { const unknowns = recordWorkflowUnknowns({ name: tool, @@ -277,7 +278,8 @@ const runAnalysis = async ( evidenceId: evidence.evidence_id, recordUnknown: (unknown) => session.recordUnknown(unknown), }); - if (!unknowns.ok) return cliError(unknowns.error); + if (!unknowns.ok) + return cliError(unknowns.error.retainPartialObservation(evidence)); } } if ( @@ -290,7 +292,8 @@ const runAnalysis = async ( const workflowRecord = workflowSnapshotRecord(evidence, tool); if (workflowRecord !== undefined) { const recorded = session.recordWorkflowSnapshot(workflowRecord); - if (!recorded.ok) return cliError(recorded.error); + if (!recorded.ok) + return cliError(recorded.error.retainPartialObservation(evidence)); } } if ( @@ -299,13 +302,15 @@ const runAnalysis = async ( evidence !== undefined ) { const snapshot = session.exportAnalysisSnapshot(); - if (!snapshot.ok) return cliError(snapshot.error); + if (!snapshot.ok) + return cliError(snapshot.error.retainPartialObservation(evidence)); const written = await writeAnalysisSnapshot( snapshot.value, snapshotPath, true, ); - if (!written.ok) return cliError(written.error); + if (!written.ok) + return cliError(written.error.retainPartialObservation(evidence)); } return output; }, diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts index 97638ecd8..a8449146e 100644 --- a/src/domain/analysisErrorBase.ts +++ b/src/domain/analysisErrorBase.ts @@ -77,8 +77,14 @@ export abstract class AnalysisError extends Error { readonly userMessage: string | undefined = undefined; readonly userCategory: "cancelled" | undefined = undefined; readonly executionFailure: string | undefined = undefined; - readonly partialObservation: AnalysisPartialObservation | undefined; + partialObservation: AnalysisPartialObservation | undefined; readonly cleanupReport: ProcessCaptureCleanupReport | undefined = undefined; + + /** Retain a completed observation when a later step fails. */ + retainPartialObservation(observation: AnalysisPartialObservation): this { + this.partialObservation ??= observation; + return this; + } } import type { PartialProcessCaptureObservation, @@ -90,6 +96,8 @@ import type { ElectronActivePartialObservation } from "./javascript/electronActi import type { JavaScriptRuntimeObservation } from "./javascript/javascriptRuntimeObservation.js"; import type { ArtifactInventoryPartialObservation } from "./artifactPartialObservation.js"; import type { BrowserScenarioPartialObservation } from "./browserScenarioCapture.js"; +import type { Evidence } from "./evidence.js"; +import type { AndroidPartialObservation } from "./android/androidPartialObservation.js"; /** Provider-neutral evidence collected before a typed analysis failure. */ export type AnalysisPartialObservation = @@ -99,4 +107,6 @@ export type AnalysisPartialObservation = | ElectronActivePartialObservation | BrowserScenarioPartialObservation | JavaScriptRuntimeObservation - | ArtifactInventoryPartialObservation; + | ArtifactInventoryPartialObservation + | Evidence + | AndroidPartialObservation; diff --git a/src/domain/android/androidPartialObservation.ts b/src/domain/android/androidPartialObservation.ts new file mode 100644 index 000000000..6a0cbf947 --- /dev/null +++ b/src/domain/android/androidPartialObservation.ts @@ -0,0 +1,46 @@ +import { z } from "zod"; + +import { digestSchema } from "../digests.js"; +import { jsonObjectSchema, jsonValueSchema } from "../jsonValue.js"; +import { localPathStringSchema } from "../localPath.js"; + +const operationSchema = z.enum([ + "inspect_android_package", + "search_android_classes", + "inspect_android_class", + "inspect_android_method", + "trace_android_references", +]); + +/** JSON-safe Android provider facts collected before an analysis failure. */ +export const androidPartialObservationSchema = z.strictObject({ + provider_id: z.literal("jadx"), + operation: operationSchema, + target: z.strictObject({ + selected_path: localPathStringSchema, + path: localPathStringSchema, + sha256: digestSchema, + format: z.literal("apk"), + }), + provider_facts: z.strictObject({ + jar_sha256: digestSchema.nullable(), + bridge_sha256: digestSchema.nullable(), + bridge_name: z.string().min(1), + bridge_version: z.string().min(1), + server_name: z.string().nullable(), + server_version: z.string().nullable(), + engine_version: z.string().min(1).nullable(), + }), + calls: z.array( + z.strictObject({ + operation: z.string().min(1), + input: jsonObjectSchema, + response: jsonValueSchema, + }), + ), +}); + +/** JSON-safe Android provider facts retained when analysis fails. */ +export type AndroidPartialObservation = z.infer< + typeof androidPartialObservationSchema +>; diff --git a/tests/boundary/process/androidAnalysisMcp.test.ts b/tests/boundary/process/androidAnalysisMcp.test.ts index de2827fb8..d0179f3c5 100644 --- a/tests/boundary/process/androidAnalysisMcp.test.ts +++ b/tests/boundary/process/androidAnalysisMcp.test.ts @@ -5,7 +5,9 @@ import { expect, it as test, onTestFinished } from "vitest"; import { z } from "zod"; import { access } from "node:fs/promises"; import { ANDROID_TOOL_CONTRACTS } from "../../../src/contracts/android/androidToolContracts.js"; +import { androidPartialObservationSchema } from "../../../src/domain/android/androidPartialObservation.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { createServer } from "../../../src/server/createServer.js"; import { ToolResultDelivery } from "../../../src/server/toolResult.js"; import { startMcpTransport } from "../../../src/main/transport.js"; @@ -14,6 +16,7 @@ import type { AndroidAnalysisPort } from "../../../src/application/android/Andro import { silentLogger } from "../../../src/logger.js"; import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { createJadxProtocolFixture, verifyJadxFixtureCleanup, @@ -148,6 +151,137 @@ it("publishes and executes all APK contracts with inline Evidence and no active await verifyJadxFixtureCleanup(launches); }); +test.each([ + { + scenario: "failed upstream reply", + mode: "tool-error", + errorCode: "execution_failure", + serverName: "rea-jadx-bridge", + serverVersion: "1", + engineVersion: "0.7.1", + calls: [ + { operation: "rea_jvm_status" }, + { operation: "load_apk" }, + { operation: "get_app_info" }, + { + operation: "get_android_manifest", + response: { isError: true }, + }, + ], + containsAmbientEnvironment: false, + }, + { + scenario: "transport error after successful replies", + mode: "rpc-disconnect", + errorCode: "execution_failure", + serverName: "rea-jadx-bridge", + serverVersion: "1", + engineVersion: "0.7.1", + calls: [{ operation: "rea_jvm_status" }, { operation: "load_apk" }], + containsAmbientEnvironment: true, + }, + { + scenario: "empty handshake identity mismatch", + mode: "empty-server-identity", + errorCode: "capability_unavailable", + serverName: "", + serverVersion: "", + engineVersion: null, + calls: [], + containsAmbientEnvironment: true, + }, +])( + "retains Android partial observations for $scenario through MCP", + async ({ + mode, + errorCode, + serverName, + serverVersion, + engineVersion, + calls, + containsAmbientEnvironment, + }) => { + const { provider, apk, launches } = await createJadxProtocolFixture(mode); + const session = createTestBinarySession(() => { + throw new Error("Unexpected native provider acquisition"); + }); + const server = createServer( + { kind: "session", session }, + { androidAnalysis: provider }, + ); + const client = new Client({ + name: "android-partial-observation-regression", + version: "1", + }); + onTestFinished(async () => { + await client.close(); + await server.close(); + await session.close(); + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const response = await client.callTool({ + name: "inspect_android_package", + arguments: { path: apk }, + }); + const projected = parseMcpToolError(response).error; + expect(projected.code).toBe(errorCode); + const details = z.record(z.string(), z.unknown()).parse(projected.details); + const partial = androidPartialObservationSchema.parse( + details.partial_observation, + ); + expect(partial).toMatchObject({ + provider_id: "jadx", + operation: "inspect_android_package", + target: { selected_path: apk, path: apk, format: "apk" }, + provider_facts: { + bridge_name: "rea-jadx-bridge", + bridge_version: "1", + server_name: serverName, + server_version: serverVersion, + engine_version: engineVersion, + }, + calls, + }); + if (!containsAmbientEnvironment) + expect(JSON.stringify(partial)).not.toContain("JAVA_HOME"); + await client.close(); + await server.close(); + await verifyJadxFixtureCleanup(launches); + }, +); + +it("retains partial JADX replies when owned cleanup also fails", async () => { + const { service, apk } = await createJadxProtocolFixture("cleanup-failure"); + const result = await service.execute("inspect_android_package", { + path: apk, + }); + if (result.ok) throw new Error("Expected the fixture tool to fail"); + + const projected = projectAnalysisError(result.error); + expect(projected.code).toBe("cleanup_incomplete"); + expect(projected.details).toMatchObject({ + cleanup: "incomplete", + execution_failure: "execution_failure", + partial_observation: { + provider_id: "jadx", + operation: "inspect_android_package", + calls: [ + { operation: "rea_jvm_status" }, + { operation: "load_apk" }, + { operation: "get_app_info" }, + { + operation: "get_android_manifest", + response: { isError: true }, + }, + ], + }, + }); +}); + it("cleans an active engine when the MCP client disconnects", async () => { const { provider, apk, launches } = await createJadxProtocolFixture("stall"); const session = createTestBinarySession(() => { diff --git a/tests/composition/directAnalysisSnapshotReplay.test.ts b/tests/composition/directAnalysisSnapshotReplay.test.ts index 0a2332e0d..3fbf5d268 100644 --- a/tests/composition/directAnalysisSnapshotReplay.test.ts +++ b/tests/composition/directAnalysisSnapshotReplay.test.ts @@ -3,6 +3,7 @@ import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { z } from "zod"; import { runDirectAnalysis } from "../../src/application/DirectAnalysis.js"; import type { @@ -18,7 +19,11 @@ import { readAnalysisSnapshot, writeAnalysisSnapshot, } from "../../src/application/binary/AnalysisSnapshotFiles.js"; -import { createEvidence, type Evidence } from "../../src/domain/evidence.js"; +import { + createEvidence, + parseEvidence, + type Evidence, +} from "../../src/domain/evidence.js"; import { createEvidenceBundle } from "../../src/domain/evidenceBundle.js"; import { REA_WORKFLOW_PROVIDER, @@ -646,7 +651,51 @@ describe("snapshot replay provider identity", () => { }); }); +const assertEvidenceSurvivesSnapshotSaveFailure = async (): Promise => { + const directory = await createTestTempDirectory("rea-workflow-save-error-"); + const path = join(directory, "fixture.hop"); + const snapshotPath = join(directory, "missing", "snapshot.json"); + await writeFile(path, "fixture"); + const starts: string[] = []; + const calls: string[] = []; + const provider = makeProvider(starts, calls); + const dependencies: DirectAnalysisDependencies = { + readConfiguration: () => parseConfig({}), + createBinarySession: () => createTestBinarySession(provider), + createManagedBinarySession: () => createTestBinarySession(provider), + }; + + const result = await runDirectAnalysis( + dependencies, + path, + "binary_overview", + {}, + { snapshotPath }, + ); + + expect(result).toMatchObject({ + code: "invalid_request", + details: { + partial_observation: { + operation: "binary_overview", + subject: { local_path: path }, + }, + }, + }); + const partial = z + .object({ + details: z.object({ partial_observation: z.unknown() }), + }) + .parse(result).details.partial_observation; + expect(parseEvidence(partial).operation).toBe("binary_overview"); + expect(calls.toSorted()).toEqual(operations.toSorted()); +}; + describe("workflow snapshot profile and cancellation binding", () => { + it( + "preserves completed Evidence when CLI snapshot persistence fails", + assertEvidenceSurvivesSnapshotSaveFailure, + ); it("does not replay a valid entry from a different workflow profile", async () => { const directory = await createTestTempDirectory("rea-workflow-profile-"); const path = join(directory, "fixture.hop"); diff --git a/tests/fixtures/android/jadx-mcp.mjs b/tests/fixtures/android/jadx-mcp.mjs index 1ab39e866..e95029d53 100644 --- a/tests/fixtures/android/jadx-mcp.mjs +++ b/tests/fixtures/android/jadx-mcp.mjs @@ -6,8 +6,9 @@ import { z } from "zod"; // It is a protocol fixture, not evidence of JADX decompiler support. const mode = process.env.REA_FAKE_JADX_MODE ?? "normal"; const server = new McpServer({ - name: "rea-jadx-bridge", - version: mode === "version" ? "99.0.0" : "1", + name: mode === "empty-server-identity" ? "" : "rea-jadx-bridge", + version: + mode === "empty-server-identity" ? "" : mode === "version" ? "99.0.0" : "1", }); const classes = [ "fixture.Target", @@ -60,16 +61,17 @@ register("load_apk", async ({ path }) => { resources: "full", }); }); -register("get_app_info", () => - json({ +register("get_app_info", () => { + if (mode === "rpc-disconnect") process.exit(17); + return json({ package: "fixture", version_name: "1", version_code: "1", min_sdk: "26", target_sdk: "33", permissions: ["android.permission.INTERNET"], - }), -); + }); +}); register("get_android_manifest", () => { if (mode === "tool-error" || mode === "cleanup-failure") return {