diff --git a/src/application/CapabilityInventory.test.ts b/src/application/CapabilityInventory.test.ts index 1af42fe6b5..d70e4e479f 100644 --- a/src/application/CapabilityInventory.test.ts +++ b/src/application/CapabilityInventory.test.ts @@ -1,3 +1,4 @@ +import { sqliteDatabaseCapability } from "./sqlite/SqliteDatabaseCapability.js"; import { persistentDebuggerHostSupported } from "../process/PersistentDebuggerHost.js"; import { pcapCaptureHostSupported } from "../domain/pcap/pcapAnalysis.js"; import { describe, expect, it } from "vitest"; @@ -100,6 +101,77 @@ const entry = ( return found; }; +it("reports the active resource session's dispatch policy without limiting independent tools", () => { + const resourceStatus = { + ...status({ open: true }), + session_kind: "bundle_resources", + capabilities: [ + { + provider: { id: "bundle-resources", name: "Resources", version: "1" }, + shared_limitations: [], + groups: [ + { + operations: [ + "inspect_plist", + "decode_interface_builder", + "inspect_asset_catalog", + "inspect_keyed_archive", + ], + effects: [], + limitations: [], + available: true, + reason: null, + availability_code: null, + }, + ], + }, + ], + }; + const entries = buildCapabilityInventory(resourceStatus, enabledPolicy); + for (const name of [ + "inspect_artifact", + "extract_artifact", + "trace_dylib_resolution", + "get_navigation_context", + ]) + expect(entries.find((item) => item.name === name)).toMatchObject({ + available: false, + reason: "target_unsupported", + remediation: expect.stringContaining("open_binary"), + }); + for (const name of [ + "inspect_plist", + "decode_interface_builder", + "inspect_asset_catalog", + "inspect_keyed_archive", + "inspect_pe_imports", + "inspect_desktop_application", + "close_binary", + ]) + expect(entries.find((item) => item.name === name)).toMatchObject({ + available: true, + }); + expect( + entries.find((item) => item.name === "inspect_apk_signature"), + ).toMatchObject({ + available: true, + reason: "available", + remediation: null, + }); + const sqlite = sqliteDatabaseCapability(process.versions.node); + expect( + entries.find((item) => item.name === "inspect_sqlite_database"), + ).toMatchObject( + sqlite.available + ? { available: true } + : { + available: false, + reason: "provider_unavailable", + remediation: sqlite.reason, + }, + ); +}); + describe("ADB and Apktool host availability", () => { it("uses only their selected owner probes", () => { const ready = { diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index 649361ba62..193459d3ef 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -42,6 +42,7 @@ import type { JebAnalysisAvailability } from "./jeb/JebAnalysisPort.js"; import { FRIDA_TOOL_CONTRACTS } from "../contracts/fridaToolContracts.js"; import { TOOL_CONTRACTS } from "../contracts/toolContracts.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; +import { isBundleResourceOperation } from "./binary/BundleResourceSession.js"; const FRIDA_OPERATION_NAMES: ReadonlySet = new Set( FRIDA_TOOL_CONTRACTS.map(({ name }) => name), @@ -49,6 +50,7 @@ const FRIDA_OPERATION_NAMES: ReadonlySet = new Set( const statusSchema = z.object({ open: z.boolean(), + session_kind: z.literal("bundle_resources").optional(), kind: z.enum(["executable", "database", "archive", "artifact"]).optional(), format: z.string().optional(), capabilities: z @@ -128,6 +130,7 @@ type AvailabilityContext = { readonly name: string; readonly kind: ToolKind; readonly targetOpen: boolean; + readonly resourceSession: boolean; readonly requiresActiveTarget: boolean; readonly targetKind: | "executable" @@ -260,6 +263,7 @@ export const buildCapabilityInventory = ( name: contract.name, kind: contract.kind, targetOpen: status.open, + resourceSession: status.session_kind === "bundle_resources", requiresActiveTarget: "requiresActiveTarget" in contract && contract.requiresActiveTarget === true, @@ -380,6 +384,19 @@ export function compactToolAvailability( } const availabilityFor = (context: AvailabilityContext): Availability => { + const workflowAvailability = workflowAvailabilityFor(context); + if ( + context.targetOpen && + context.resourceSession && + context.requiresActiveTarget && + !isBundleResourceOperation(context.name) && + (workflowAvailability === null || context.kind === "session") + ) + return { + reason: "target_unsupported", + remediation: + "This resource-only session cannot execute this operation. Call open_binary with an executable analysis target.", + }; // These built-in workflows accept a caller-selected endpoint; they do not load the optional passive provider. if ( context.name === "inspect_byte_entropy" || @@ -403,7 +420,6 @@ const availabilityFor = (context: AvailabilityContext): Availability => { reason: "provider_unavailable", remediation: `Adapter ${failure.providerId} could not load: ${failure.reason}. Repair the adapter installation and restart REA; other capabilities remain available.`, }; - const workflowAvailability = workflowAvailabilityFor(context); if (workflowAvailability !== null) return workflowAvailability; const hostDecision = hostAvailability(context); if (hostDecision !== null) return hostDecision; @@ -453,6 +469,7 @@ const workflowAvailabilityFor = ({ "Run persistent debugger sessions on Linux with /proc descriptor readback.", ); if ( + name === "inspect_apk_signature" || name === "inspect_desktop_application" || name === "inspect_go_binary" || name === "inspect_il2cpp_metadata" || diff --git a/src/application/binary/BinarySession.ts b/src/application/binary/BinarySession.ts index f869104b47..9753cd4ac3 100644 --- a/src/application/binary/BinarySession.ts +++ b/src/application/binary/BinarySession.ts @@ -64,7 +64,7 @@ import { analysisErrorWithCancellation } from "../../domain/analysisErrorCancell import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; import { profilelessNativeDispatchProfile } from "./ProfilelessNativeWorkflowSnapshot.js"; import type { AnalysisSnapshot } from "../../domain/analysisSnapshot.js"; -import { AnalysisSnapshotMismatchError } from "../../domain/evidenceErrors.js"; +import { ResourceSnapshotMismatchError } from "../../domain/evidenceErrors.js"; import { parseBinaryTarget, type BinaryTargetResolverOptions, @@ -1149,21 +1149,13 @@ export class BinarySession override exportAnalysisSnapshot() { if (this.#activeBundleResources !== undefined) - return err( - new AnalysisSnapshotMismatchError( - "Analysis snapshots do not apply to a resource-only app session because it has no executable identity or replay binding.", - ), - ); + return err(new ResourceSnapshotMismatchError("export")); return super.exportAnalysisSnapshot(); } override importAnalysisSnapshot(snapshot: AnalysisSnapshot) { if (this.#activeBundleResources !== undefined) - return err( - new AnalysisSnapshotMismatchError( - "Analysis snapshots do not apply to a resource-only app session because it has no executable identity or replay binding.", - ), - ); + return err(new ResourceSnapshotMismatchError("import")); return super.importAnalysisSnapshot(snapshot); } diff --git a/src/application/binary/BinarySessionBundleResources.test.ts b/src/application/binary/BinarySessionBundleResources.test.ts index 506ec45635..8c07ef83ca 100644 --- a/src/application/binary/BinarySessionBundleResources.test.ts +++ b/src/application/binary/BinarySessionBundleResources.test.ts @@ -14,7 +14,10 @@ import { AnalysisProviderRegistry } from "./AnalysisProviderRegistry.js"; import { BinarySession } from "./BinarySession.js"; import { SessionProviderRouter } from "./SessionProviderRouter.js"; import { bundleResourceRootSchema } from "../../domain/apple/bundleResourceRoot.js"; -import { AnalysisSnapshotMismatchError } from "../../domain/evidenceErrors.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { analysisErrorRemediationAction } from "../../domain/analysisErrorPresentation.js"; +import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; +import { ResourceSnapshotMismatchError } from "../../domain/evidenceErrors.js"; import type { AnalysisSnapshot } from "../../domain/analysisSnapshot.js"; import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; @@ -36,6 +39,24 @@ const binaryIdentity: ProviderIdentity = { }; const temporaryDirectories: string[] = []; +const expectResourceSnapshotRefusal = ( + error: AnalysisError, + operation: "export" | "import", +): void => { + expect(error).toBeInstanceOf(ResourceSnapshotMismatchError); + expect(error).toMatchObject({ + _tag: "EvidenceIntegrityError", + operation, + }); + const projected = projectAnalysisError(error); + expect(projected.code).toBe("evidence_integrity_mismatch"); + expect(projected.category).toBe("integrity_mismatch"); + expect(projected.retryable).toBe(false); + expect(projected.remediation).toEqual({ + action: analysisErrorRemediationAction(error), + }); +}; + afterEach(async () => { await Promise.all( temporaryDirectories @@ -137,13 +158,17 @@ it("opens a resource-only bundle without executable identity and keeps snapshot resource_root: { canonical_path: opened.value.canonicalPath }, }); const snapshot = session.exportAnalysisSnapshot(); - expect(snapshot.ok).toBe(false); - if (!snapshot.ok) - expect(snapshot.error).toBeInstanceOf(AnalysisSnapshotMismatchError); + if (snapshot.ok) + throw new Error("resource session exported a binary snapshot"); + expectResourceSnapshotRefusal(snapshot.error, "export"); const imported = session.importAnalysisSnapshot({} as AnalysisSnapshot); - expect(imported.ok).toBe(false); - if (!imported.ok) - expect(imported.error).toBeInstanceOf(AnalysisSnapshotMismatchError); + if (imported.ok) + throw new Error("resource session imported a binary snapshot"); + expectResourceSnapshotRefusal(imported.error, "import"); + expect(await session.status()).toMatchObject({ + open: true, + session_kind: "bundle_resources", + }); const closedResult = await session.close(); expect(closedResult.ok).toBe(true); expect(closed).toBe(true); diff --git a/src/application/binary/BundleResourceSession.ts b/src/application/binary/BundleResourceSession.ts index 2e11804af9..cecca90a94 100644 --- a/src/application/binary/BundleResourceSession.ts +++ b/src/application/binary/BundleResourceSession.ts @@ -24,6 +24,13 @@ export const BUNDLE_RESOURCE_OPERATIONS = new Set([ ...BUNDLE_ARTIFACT_RESOURCE_OPERATIONS, ]); +/** Share the dispatch owner's resource-session operation policy with discovery. */ +export const isBundleResourceOperation = (name: string): boolean => { + for (const operation of BUNDLE_RESOURCE_OPERATIONS) + if (operation === name) return true; + return false; +}; + /** Provider-specific owner of the four read-only app-resource operations. */ export interface BundleResourceClientFactory { createClient( diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index bf20bac1ed..5015503487 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -21,6 +21,7 @@ import { EvidenceIntegrityError, EvidenceReferenceError, AnalysisSnapshotMismatchError, + ResourceSnapshotMismatchError, } from "./evidenceErrors.js"; import { HopperRemoteError, @@ -79,6 +80,10 @@ export const analysisErrorRemediationAction = ( error.remediationAction !== undefined ) return error.remediationAction; + if (error instanceof ResourceSnapshotMismatchError) + return error.operation === "export" + ? "Call close_binary without snapshot_path to close this resource-only session." + : "Open an executable analysis target before importing a binary analysis snapshot."; if (error instanceof AnalysisSnapshotMismatchError) return "Run analysis without this snapshot, then save a fresh snapshot using the intended artifact, provider, and analysis profile."; if (error instanceof AnalysisUnsupportedTargetError) diff --git a/src/domain/evidenceErrors.ts b/src/domain/evidenceErrors.ts index c8db5350a5..56b844b7a2 100644 --- a/src/domain/evidenceErrors.ts +++ b/src/domain/evidenceErrors.ts @@ -33,6 +33,15 @@ export class EvidenceIntegrityError extends AnalysisError { /** A valid snapshot belongs to a different target, provider, or analysis profile. */ export class AnalysisSnapshotMismatchError extends EvidenceIntegrityError {} +/** A resource session cannot import or export a binary analysis snapshot. */ +export class ResourceSnapshotMismatchError extends AnalysisSnapshotMismatchError { + constructor(readonly operation: "import" | "export") { + super( + "Analysis snapshots do not apply to a resource-only app session because it has no executable identity or replay binding.", + ); + } +} + /** A session Evidence reference is missing or has the wrong semantic identity. */ export class EvidenceReferenceError extends EvidenceIntegrityError { constructor( diff --git a/tests/acceptance/applications/bundleResourceRootCliMcp.test.ts b/tests/acceptance/applications/bundleResourceRootCliMcp.test.ts index 00539fd2e6..f9aa6a7532 100644 --- a/tests/acceptance/applications/bundleResourceRootCliMcp.test.ts +++ b/tests/acceptance/applications/bundleResourceRootCliMcp.test.ts @@ -1,10 +1,13 @@ import { build } from "plist"; -import { mkdir, writeFile } from "node:fs/promises"; +import { lstat, mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect } from "vitest"; +import { sqliteDatabaseCapability } from "../../../src/application/sqlite/SqliteDatabaseCapability.js"; import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { analysisErrorRemediationAction } from "../../../src/domain/analysisErrorPresentation.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; +import { ResourceSnapshotMismatchError } from "../../../src/domain/evidenceErrors.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; import { cliTest } from "../../support/cli/cliFixture.js"; @@ -16,6 +19,74 @@ const resourceOperations = [ "inspect_keyed_archive", ] as const; +const expectUnsupportedArtifactOperations = (content: unknown) => { + const parsed = toolContract("binary_session").outputSchema.parse(content); + for (const name of [ + "inspect_artifact", + "extract_artifact", + "trace_dylib_resolution", + ]) + expect( + parsed.result.tool_availability.groups.find((group) => + group.names.includes(name), + ), + ).toMatchObject({ + available: false, + reason: "target_unsupported", + remediation: expect.stringContaining("open_binary"), + }); +}; + +const expectIndependentResourceWorkflows = async ( + call: Awaited>["call"], + root: string, + firstBundle: string, +): Promise => { + for (const detail of ["summary", "complete"] as const) { + const status = await call("binary_session", { detail }); + expectUnsupportedArtifactOperations(status.structuredContent); + const parsed = toolContract("binary_session").outputSchema.parse( + status.structuredContent, + ); + for (const name of ["inspect_desktop_application"]) + expect( + parsed.result.tool_availability.groups.find((group) => + group.names.includes(name), + ), + ).toMatchObject({ available: true }); + } + const capability = sqliteDatabaseCapability(process.versions.node); + const status = await call("binary_session", { detail: "complete" }); + const parsed = toolContract("binary_session").outputSchema.parse( + status.structuredContent, + ); + expect( + parsed.result.tool_availability.groups.find((group) => + group.names.includes("inspect_sqlite_database"), + ), + ).toMatchObject( + capability.available + ? { available: true } + : { + available: false, + reason: "provider_unavailable", + remediation: capability.reason, + }, + ); + const desktop = await call("inspect_desktop_application", { + path: firstBundle, + }); + expect(desktop.isError, JSON.stringify(desktop)).not.toBe(true); + if (!capability.available) return; + const databasePath = join(root, "fixture.sqlite"); + const { DatabaseSync } = await import("node:sqlite"); + const database = new DatabaseSync(databasePath); + database.exec("CREATE TABLE records (id INTEGER PRIMARY KEY)"); + database.close(); + const result = await call("inspect_sqlite_database", { path: databasePath }); + expect(result.isError, JSON.stringify(result)).not.toBe(true); +}; + cliTest.skipIf(process.platform !== "darwin")( "inspects a keyless app resource root through the compiled CLI", async ({ cli }) => { @@ -52,11 +123,11 @@ cliTest.skipIf(process.platform !== "darwin")( cliTest.skipIf(process.platform !== "darwin")( "retains and switches keyless resource sessions through stdio MCP", async () => { - const temporaryRoot = await createTestTempDirectory( + const root = await createTestTempDirectory( "rea-resource-session-acceptance-", ); - const firstBundle = await createKeylessBundle(temporaryRoot, "First"); - const secondBundle = await createKeylessBundle(temporaryRoot, "Second"); + const firstBundle = await createKeylessBundle(root, "First"); + const secondBundle = await createKeylessBundle(root, "Second"); const { call } = await connectLocalToolsMcp(); const opened = await call("open_bundle_resources", { path: firstBundle }); expect(opened.isError).not.toBe(true); @@ -91,6 +162,37 @@ cliTest.skipIf(process.platform !== "darwin")( }, }); + await expectIndependentResourceWorkflows(call, root, firstBundle); + for (const [name, input] of [ + ["inspect_artifact", {}], + ["extract_artifact", { integrity_policy: "fail" }], + ["trace_dylib_resolution", { roots: ["Absent"] }], + ] as const) { + const refused = await call(name, input); + expect(refused.isError).toBe(true); + expect(errorBody(refused)).toMatchObject({ + error: { code: "capability_unavailable" }, + }); + } + const afterRefusals = await call("binary_session", { detail: "complete" }); + expectUnsupportedArtifactOperations(afterRefusals.structuredContent); + const refusedSnapshot = await call("close_binary", { + snapshot_path: join(root, "snapshot.json"), + }); + expect(refusedSnapshot.isError).toBe(true); + expect(errorBody(refusedSnapshot)).toMatchObject({ + error: { + code: "evidence_integrity_mismatch", + category: "integrity_mismatch", + retryable: false, + remediation: { + action: analysisErrorRemediationAction( + new ResourceSnapshotMismatchError("export"), + ), + }, + }, + }); + await expect(lstat(join(root, "snapshot.json"))).rejects.toThrow("ENOENT"); for (const operation of resourceOperations) { const arguments_ = operation === "inspect_keyed_archive" @@ -164,6 +266,14 @@ cliTest.skipIf(process.platform !== "darwin")( }, ); +const errorBody = (response: { + content: ReadonlyArray<{ readonly type: string; readonly text?: string }>; +}): unknown => { + const text = response.content.find((item) => item.type === "text"); + if (typeof text?.text !== "string") throw new Error("Expected error text"); + return JSON.parse(text.text) as unknown; +}; + const createKeylessBundle = async ( root: string, name: string,