diff --git a/scripts/lib/catalog-builders.mjs b/scripts/lib/catalog-builders.mjs index 2ac0bb9894..feb1b4e361 100644 --- a/scripts/lib/catalog-builders.mjs +++ b/scripts/lib/catalog-builders.mjs @@ -257,6 +257,13 @@ export const providerCatalog = (sources) => { ({ name }) => name === "inspect_byte_entropy", ), }, + { + identity: sources.artifactProviders.NATIVE_EXPORTS_PROVIDER, + contracts: + sources.binaryDiagnosticsContracts.BINARY_DIAGNOSTICS_TOOL_CONTRACTS.filter( + ({ name }) => name === "inspect_native_exports", + ), + }, { identity: sources.captureProvider.TSHARK_PROVIDER_IDENTITY, contracts: sources.captureContracts.CAPTURE_TOOL_CONTRACTS, diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index cd3394ddd8..87e5ad4144 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -498,7 +498,7 @@ const workflowAvailabilityFor = ({ remediation: "Supply WABT 1.0.42 via absolute REA_WABT_BIN_DIRECTORY. No active binary target is required; tools are checked at execution.", }; - if (name === "inspect_pe_resources") + if (name === "inspect_pe_resources" || name === "inspect_native_exports") return { reason: "available", remediation: null }; if (name === "inspect_evm_interface") { if (policy.evmInterfaceEnabled !== true) diff --git a/src/application/InvestigationProviders.ts b/src/application/InvestigationProviders.ts index 00fcd5642d..06f235418d 100644 --- a/src/application/InvestigationProviders.ts +++ b/src/application/InvestigationProviders.ts @@ -55,6 +55,13 @@ export const BYTE_ENTROPY_PROVIDER = Object.freeze({ version: "1", } as const); +/** Provider identity for portable, execution-free PE and ELF export tables. */ +export const NATIVE_EXPORTS_PROVIDER = Object.freeze({ + id: "rea-native-exports", + name: "REA PE/ELF export table reader", + version: "1", +} as const); + /** Provider identity for deterministic managed-code comparison workflows. */ export const MANAGED_WORKFLOW_PROVIDER = { id: "rea-dotnet-workflows", diff --git a/src/application/binaryDiagnostics/NativeExportsPort.ts b/src/application/binaryDiagnostics/NativeExportsPort.ts new file mode 100644 index 0000000000..640d8e186e --- /dev/null +++ b/src/application/binaryDiagnostics/NativeExportsPort.ts @@ -0,0 +1,20 @@ +import type { + ExecutionOptions, + ProviderIdentity, +} from "../AnalysisProvider.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { + InspectNativeExportsInput, + NativeExports, +} from "../../domain/native/nativeExports.js"; +import type { Result } from "../../domain/result.js"; + +/** Stable read and export-table decoding, independent of an active engine session. */ +export interface NativeExportsPort { + readonly identity: ProviderIdentity; + close?(): Promise; + inspect( + input: InspectNativeExportsInput, + options?: ExecutionOptions, + ): Promise>; +} diff --git a/src/application/binaryDiagnostics/NativeExportsService.ts b/src/application/binaryDiagnostics/NativeExportsService.ts new file mode 100644 index 0000000000..d8d5bceb37 --- /dev/null +++ b/src/application/binaryDiagnostics/NativeExportsService.ts @@ -0,0 +1,79 @@ +import type { ExecutionOptions } from "../AnalysisProvider.js"; +import type { NativeExportsPort } from "./NativeExportsPort.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisOutputError, +} from "../../domain/analysisErrorCore.js"; +import { createEvidence, type Evidence } from "../../domain/evidence.js"; +import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; +import { + inspectNativeExportsInputSchema, + nativeExportsSchema, +} from "../../domain/native/nativeExports.js"; +import { err, type Result } from "../../domain/result.js"; +import { completeProviderEvidence } from "../ProviderOperationExecution.js"; + +const OPERATION = "inspect_native_exports"; + +/** Shared caller validation, output parsing and Evidence provenance for CLI/MCP. */ +export class NativeExportsService { + constructor(readonly provider: NativeExportsPort) {} + + close(): Promise { + return this.provider.close?.() ?? Promise.resolve(); + } + + /** Read one explicitly selected PE or ELF image's export table. */ + async inspect( + rawInput: unknown, + options?: ExecutionOptions, + ): Promise> { + if (options?.signal?.aborted) + return err( + new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }), + ); + const input = inspectNativeExportsInputSchema.safeParse(rawInput); + if (!input.success) + return err( + analysisInputErrorFromIssues(OPERATION, input.error.issues, rawInput, { + cause: input.error, + }), + ); + const result = await this.provider.inspect(input.data, options); + if (!result.ok) return result; + const report = nativeExportsSchema.safeParse(result.value); + if ( + !report.success || + report.data.artifact.path !== input.data.path || + report.data.artifact.bytes > input.data.max_file_bytes || + report.data.coverage.exports !== report.data.exports.length + ) + return err( + new AnalysisOutputError( + OPERATION, + "Native export reader returned malformed output or changed artifact identity.", + ), + ); + const value = report.data; + return completeProviderEvidence( + createEvidence( + { + path: value.artifact.path, + format: value.format, + sha256: value.artifact.sha256, + }, + this.provider.identity, + { + operation: OPERATION, + parameters: input.data, + result: value, + confidence: "observed", + limitations: value.limitations, + locations: [{ kind: "artifact-path", path: value.artifact.path }], + }, + ), + options?.signal, + ); + } +} diff --git a/src/cli/binaryDiagnosticsCommands.ts b/src/cli/binaryDiagnosticsCommands.ts index 7b1eb9173f..f9a6f21108 100644 --- a/src/cli/binaryDiagnosticsCommands.ts +++ b/src/cli/binaryDiagnosticsCommands.ts @@ -5,6 +5,7 @@ import { createRecordedCrashService, createBinaryLayoutService, createPeResourcesService, + createNativeExportsService, } from "../composition/binaryDiagnostics.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { logCliCommand } from "../cliLogging.js"; @@ -14,6 +15,7 @@ import { withCommandCancellation } from "./commandCancellation.js"; import { inspectBinaryLayoutInputSchema } from "../domain/native/binaryLayout.js"; import { inspectRecordedCrashInputSchema } from "../domain/native/recordedCrash.js"; import { inspectPeResourcesInputSchema } from "../domain/native/peResources.js"; +import { inspectNativeExportsInputSchema } from "../domain/native/nativeExports.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; @@ -95,6 +97,37 @@ export const registerBinaryDiagnosticsCommands = ( ); }), }); + cli.command(CLI_COMMANDS.inspectNativeExports, { + description: + "Inspect a PE or ELF image's export table without executing it", + args: z.object({ path: z.string().describe("Local PE or ELF image") }), + options: z.object({ + maxFileBytes: inspectNativeExportsInputSchema.shape.max_file_bytes, + }), + run: ({ args, options }) => + withCommandCancellation(async (signal) => { + const exports = createNativeExportsService(); + return await logCliCommand( + logger, + CLI_COMMANDS.inspectNativeExports, + async () => + finishAnalysisCommand( + () => + exports.inspect( + { + path: absoluteCallerPath(args.path), + max_file_bytes: options.maxFileBytes, + }, + { signal }, + ), + exports, + "rea-native-exports", + "inspect_native_exports", + signal, + ), + ); + }), + }); const service = createBinaryLayoutService(environment); cli.command(CLI_COMMANDS.inspectBinaryLayout, { description: diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index 27eeca5bdd..e5e31a0933 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -66,6 +66,7 @@ export const CLI_COMMANDS = Object.freeze({ inspectGpuCodeObjects: "inspect-gpu-code-objects", inspectRustBinary: "inspect-rust-binary", inspectPeResources: "inspect-pe-resources", + inspectNativeExports: "inspect-native-exports", inspectAnalysisView: "inspect-analysis-view", listFridaDevices: "list-frida-devices", listFridaProcesses: "list-frida-processes", diff --git a/src/composition/binaryDiagnostics.ts b/src/composition/binaryDiagnostics.ts index 4d9cfc69cb..d2dd5698f1 100644 --- a/src/composition/binaryDiagnostics.ts +++ b/src/composition/binaryDiagnostics.ts @@ -6,11 +6,17 @@ import { PwntoolsLayoutProvider } from "../native/pwntools/PwntoolsLayoutProvide import { PeResourcesService } from "../application/binaryDiagnostics/PeResourcesService.js"; import { PeResourcesProvider } from "../native/pe/PeResourcesProvider.js"; import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { NativeExportsService } from "../application/binaryDiagnostics/NativeExportsService.js"; +import { NativeExportsProvider } from "../native/exports/NativeExportsProvider.js"; /** Portable PE inspection with no engine, installation or target acquisition. */ export const createPeResourcesService = (): PeResourcesService => new PeResourcesService(new PeResourcesProvider()); +/** Portable PE/ELF export tables with no engine, toolchain or target acquisition. */ +export const createNativeExportsService = (): NativeExportsService => + new NativeExportsService(new NativeExportsProvider()); + /** Compose the offline adapter without import-time I/O or dependency acquisition. */ export const createBinaryLayoutService = ( environment: Readonly = process.env, diff --git a/src/contracts/native/binaryDiagnosticsToolContracts.ts b/src/contracts/native/binaryDiagnosticsToolContracts.ts index 0e31d8ef35..603d559ac3 100644 --- a/src/contracts/native/binaryDiagnosticsToolContracts.ts +++ b/src/contracts/native/binaryDiagnosticsToolContracts.ts @@ -14,6 +14,10 @@ import { analysisDetailOutputSchemaOf, analysisDetailSchema, } from "../analysisDetail.js"; +import { + inspectNativeExportsInputSchema, + nativeExportsSchema, +} from "../../domain/native/nativeExports.js"; import type { ToolContract } from "../toolContractTypes.js"; import { toolContractMetadata } from "../toolEffects.js"; import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js"; @@ -76,6 +80,21 @@ export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [ }, ], }, + { + name: "inspect_native_exports", + ...toolContractMetadata("inspect_native_exports"), + kind: "native-provider", + description: + "Inspect one explicit local PE or ELF image's export table without executing it or opening an engine: PE export-directory names, ordinals, addresses and forwarders, or ELF defined default/protected global, weak and unique .dynsym symbols with type, binding and symbol version. Ordinal-only, forwarded and hidden-version exports stay distinct. Portable on any supported host. Coverage is partial when table entries are unreadable and unsupported for an ELF without section headers; an empty complete inventory means the image declares no exports. Mach-O exports use inspect_macho.", + inputSchema: inspectNativeExportsInputSchema, + outputSchema: evidenceResultOf(nativeExportsSchema), + examples: [ + { + title: "List a native library's exports", + input: { path: "/artifacts/native/libexample.so" }, + }, + ], + }, { name: "inspect_binary_layout", ...toolContractMetadata("inspect_binary_layout"), diff --git a/src/contracts/toolEffects.test.ts b/src/contracts/toolEffects.test.ts index 0403a6d963..024d85501d 100644 --- a/src/contracts/toolEffects.test.ts +++ b/src/contracts/toolEffects.test.ts @@ -27,6 +27,7 @@ const EXECUTION_WORDING_EXCEPTIONS: Readonly> = { resolve_native_call_targets: "states that it does not execute code", inspect_byte_entropy: "states that it profiles the file without execution", inspect_pe_resources: "states that it does not execute the image", + inspect_native_exports: "reads the export table without executing it", inspect_desktop_application: "states that directory triage performs no target execution", inspect_go_binary: "reads embedded metadata without executing the target", diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 24d8e2989a..7e438f1809 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -253,6 +253,7 @@ export const TOOL_EFFECTS: Readonly> = { }), inspect_byte_entropy: evidence, inspect_pe_resources: evidence, + inspect_native_exports: evidence, inspect_analysis_view: evidence, inspect_firmware_regions: effects({ mutatesSession: true, diff --git a/src/native/exports/NativeExportsProvider.ts b/src/native/exports/NativeExportsProvider.ts new file mode 100644 index 0000000000..49291eee81 --- /dev/null +++ b/src/native/exports/NativeExportsProvider.ts @@ -0,0 +1,212 @@ +import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + ArtifactFileCleanupFailure, + assertArtifactReadActive, + bindArtifactFileCleanup, + artifactFileCancellationCause, +} from "../../artifacts/ArtifactFileOperation.js"; +import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; +import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; +import type { NativeExportsPort } from "../../application/binaryDiagnostics/NativeExportsPort.js"; +import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; +import { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { + AnalysisAccessDeniedError, + AnalysisArtifactChangedError, + AnalysisCancelledError, + AnalysisInputError, + AnalysisResourceConstraintError, + AnalysisUnsupportedTargetError, +} from "../../domain/analysisErrorCore.js"; +import { fixedAllowanceOptions } from "../../domain/fixedAllowanceRemediation.js"; +import { + MAX_INSPECT_NATIVE_EXPORTS_FILE_BYTES, + type InspectNativeExportsInput, +} from "../../domain/native/nativeExports.js"; +import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; +import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; +import { err, ok } from "../../domain/result.js"; +import { NATIVE_EXPORTS_PROVIDER } from "../../application/InvestigationProviders.js"; +import { + BinaryFormatFailure, + BinaryResourceFailure, +} from "../metadata/BinaryContainer.js"; +import { parseNativeExports } from "./NativeExportParser.js"; + +const OPERATION = "inspect_native_exports"; + +/** Portable export-table reader over one bounded, identity-checked regular file. */ +export class NativeExportsProvider implements NativeExportsPort { + readonly identity = NATIVE_EXPORTS_PROVIDER; + readonly #resources = new ArtifactResourceScope(); + + async close(): Promise { + try { + await this.#resources.close(); + } catch (cause: unknown) { + if ( + !(cause instanceof ArtifactReaderFailure) || + cause.cleanup === undefined + ) + throw cause; + const error = new ProviderCleanupError( + NATIVE_EXPORTS_PROVIDER.id, + cause.cleanup.resources, + { reason: cause.cleanup.reason }, + { cause, operation: OPERATION }, + ); + bindArtifactFileCleanup(cause, error); + throw error; + } + } + + async inspect(input: InspectNativeExportsInput, options?: ExecutionOptions) { + try { + return await this.#resources.run(async () => { + const snapshot = await readStableArtifact( + input.path, + input.max_file_bytes, + options?.signal, + undefined, + this.#resources, + ); + assertArtifactReadActive(options?.signal); + const report = parseNativeExports(snapshot.bytes, { + path: input.path, + sha256: snapshot.sha256, + bytes: snapshot.bytes.length, + }); + assertArtifactReadActive(options?.signal); + return ok(report); + }); + } catch (cause) { + return err(nativeExportsFailure(cause, input, options?.signal)); + } + } +} + +const fileSizeRemediation = (input: InspectNativeExportsInput): string => + input.max_file_bytes >= MAX_INSPECT_NATIVE_EXPORTS_FILE_BYTES + ? `The image exceeds the ${String(MAX_INSPECT_NATIVE_EXPORTS_FILE_BYTES)}-byte input ceiling this operation accepts. This release has no runtime override. Select a smaller artifact.` + : `Raise max_file_bytes above ${String(input.max_file_bytes)} bytes or select a smaller artifact; the limit is the caller-selected input read budget.`; + +const nativeExportsFailurePrimary = ( + cause: unknown, + input: InspectNativeExportsInput, +): AnalysisError => { + if (cause instanceof AnalysisError) return cause; + if (cause instanceof ArtifactReaderFailure && cause.reason === "integrity") + return new AnalysisArtifactChangedError( + OPERATION, + input.path, + cause.message, + { cause }, + ); + if (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled") + return new AnalysisCancelledError(OPERATION, { + cause: artifactFileCancellationCause(cause), + }); + if (cause instanceof BinaryResourceFailure) + return new AnalysisResourceConstraintError( + OPERATION, + "capacity", + `Selected artifact ${input.path}: ${cause.message}`, + { boundary: cause.boundary, maximum: cause.maximum_bytes }, + { + cause, + ...fixedAllowanceOptions( + "The native export inventory allowance", + "count", + ), + }, + ); + if (cause instanceof BinaryFormatFailure) + return cause.kind === "unsupported" + ? new AnalysisUnsupportedTargetError( + OPERATION, + input.path, + cause.message, + { + cause, + }, + ) + : new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_format", + message: `Selected artifact ${input.path}: ${cause.message}`, + }, + ]); + if (cause instanceof ArtifactReaderFailure) { + if (cause.reason === "limit") + return new AnalysisResourceConstraintError( + OPERATION, + "file-size", + cause.message, + { max_file_bytes: input.max_file_bytes }, + { + cause, + retryable: false, + remediationAction: fileSizeRemediation(input), + }, + ); + if (cause.reason === "path" || cause.reason === "format") + return new AnalysisInputError(OPERATION, { cause }, [ + { path: ["path"], reason: "invalid_format", message: cause.message }, + ]); + } + if (cause instanceof Error && "code" in cause) { + if (cause.code === "EACCES" || cause.code === "EPERM") + return new AnalysisAccessDeniedError(OPERATION, input.path, cause.code, { + cause, + }); + if ( + ["ENOENT", "ENOTDIR", "ELOOP", "ENAMETOOLONG"].includes( + String(cause.code), + ) + ) + return new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_value", + message: `Selected image could not be read (${String(cause.code)}): ${input.path}.`, + }, + ]); + } + return new ProviderAdapterError(NATIVE_EXPORTS_PROVIDER.id, OPERATION, { + cause, + diagnostics: { + path: input.path, + reason: cause instanceof Error ? cause.message : String(cause), + }, + }); +}; + +const nativeExportsFailure = ( + cause: unknown, + input: InspectNativeExportsInput, + signal: AbortSignal | undefined, +): AnalysisError => { + const primary = + cause instanceof ArtifactFileCleanupFailure && + cause.fileCleanupFailure.outcome.kind === "failed" + ? nativeExportsFailurePrimary( + cause.fileCleanupFailure.outcome.cause, + input, + ) + : cause instanceof ArtifactFileCleanupFailure && signal?.aborted + ? new AnalysisCancelledError(OPERATION, { cause: signal.reason }) + : nativeExportsFailurePrimary(cause, input); + if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined) + return primary; + const cleanup = new ProviderCleanupError( + NATIVE_EXPORTS_PROVIDER.id, + cause.cleanup.resources, + { reason: cause.cleanup.reason }, + { operation: OPERATION, cause }, + ); + const error = analysisErrorWithCleanupFailure(primary, cleanup, OPERATION); + bindArtifactFileCleanup(cause, error); + return error; +}; diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 67f7a4e0d9..f3aa6ea8cd 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -12,9 +12,14 @@ import { wasmArtifactModule } from "../composition/wasm.js"; import { registerWasmTools } from "./registerWasmTools.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { captureProviderEnvironment } from "./providerEnvironment.js"; -import { createPeResourcesService } from "../composition/binaryDiagnostics.js"; +import { + createNativeExportsService, + createPeResourcesService, +} from "../composition/binaryDiagnostics.js"; import type { PeResourcesService } from "../application/binaryDiagnostics/PeResourcesService.js"; +import type { NativeExportsService } from "../application/binaryDiagnostics/NativeExportsService.js"; import { registerPeResourcesTool } from "./registerPeResourcesTool.js"; +import { registerNativeExportsTool } from "./registerNativeExportsTool.js"; import type { EvmInterfaceService } from "../application/evm/EvmInterfaceService.js"; import { evmInterfaceModule, @@ -350,6 +355,7 @@ export const createServer = ( const il2cppMetadata = options.il2cppMetadata ?? createIl2cppMetadataService(); const peResources = createPeResourcesService(); + const nativeExports = createNativeExportsService(); const electronFuses = options.electronFuses ?? createElectronFuseService(); const webNetworkCapture = options.webNetworkCapture ?? createWebNetworkCaptureService(environment); @@ -451,6 +457,7 @@ export const createServer = ( modelArtifact.close(), gpuCodeObjects.close(), peResources.close(), + nativeExports.close(), electronFuses.close(), webNetworkCapture.close(), javascriptRecovery.close?.() ?? Promise.resolve(), @@ -514,6 +521,7 @@ export const createServer = ( rustBinary, il2cppMetadata, peResources, + nativeExports, electronFuses, webNetworkCapture, }; @@ -745,6 +753,7 @@ const registerConfiguredAnalysisTools = ( rustBinary, il2cppMetadata, peResources, + nativeExports, webNetworkCapture, artifactResources, }: ServerToolContext, @@ -794,6 +803,13 @@ const registerConfiguredAnalysisTools = ( recordEvidence, withAdmittedAnalysis, ); + registerNativeExportsTool( + server, + nativeExports, + toolLogger, + recordEvidence, + withAdmittedAnalysis, + ); registerGoTools( server, goBinary, @@ -927,6 +943,7 @@ interface ServerToolContext extends ReturnType { readonly rustBinary: RustBinaryService; readonly il2cppMetadata: Il2cppMetadataService; readonly peResources: PeResourcesService; + readonly nativeExports: NativeExportsService; readonly electronFuses: ElectronFuseService; readonly webNetworkCapture: WebNetworkCaptureService; readonly artifactResources: ArtifactResourceScope; diff --git a/src/server/registerNativeExportsTool.ts b/src/server/registerNativeExportsTool.ts new file mode 100644 index 0000000000..91e02b4b2a --- /dev/null +++ b/src/server/registerNativeExportsTool.ts @@ -0,0 +1,31 @@ +import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; +import type { NativeExportsService } from "../application/binaryDiagnostics/NativeExportsService.js"; +import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; +import { toolContract } from "../contracts/toolContracts.js"; +import type { Logger } from "pino"; +import { createEvidenceToolHandler } from "./contractToolHandler.js"; +import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; +import { applicationToolRegistrationOptions } from "./toolRegistrationOptions.js"; + +/** Register the explicit-file export-table workflow and retain its complete Evidence. */ +export const registerNativeExportsTool = ( + server: EvidenceMcpServer, + service: NativeExportsService, + logger: Logger, + recordEvidence?: EvidenceWriter["recordEvidence"], + withAdmittedAnalysis?: WithAdmittedAnalysis, +): void => { + const contract = toolContract("inspect_native_exports"); + const handler = createEvidenceToolHandler( + server, + (input, options) => service.inspect(input, options), + logger, + recordEvidence, + withAdmittedAnalysis, + ); + server.registerTool( + contract.name, + applicationToolRegistrationOptions(contract), + handler(contract), + ); +}; diff --git a/tests/acceptance/tools/localToolContracts.test.ts b/tests/acceptance/tools/localToolContracts.test.ts index 980638aa5b..669be90046 100644 --- a/tests/acceptance/tools/localToolContracts.test.ts +++ b/tests/acceptance/tools/localToolContracts.test.ts @@ -218,6 +218,7 @@ const pathRequests: readonly [string, Record][] = [ { capture_path: nul, output_directory: "/tmp/exported-scripts" }, ], ["inspect_pe_resources", { path: nul }], + ["inspect_native_exports", { path: nul }], ["inspect_binary_layout", { path: nul }], ["inspect_recorded_crash", { path: nul }], ["inspect_wasm_artifact", { path: nul }], diff --git a/tests/boundary/filesystem/nativeExports.test.ts b/tests/boundary/filesystem/nativeExports.test.ts new file mode 100644 index 0000000000..e79f80ac4d --- /dev/null +++ b/tests/boundary/filesystem/nativeExports.test.ts @@ -0,0 +1,104 @@ +import { createHash } from "node:crypto"; +import { symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createNativeExportsService } from "../../../src/composition/binaryDiagnostics.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { nativeExportsSchema } from "../../../src/domain/native/nativeExports.js"; +import { + elfExportImage, + peExportImage, +} from "../../fixtures/binaryDiagnostics/nativeExports.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +describe("native export stable filesystem boundary", () => { + it("binds PE and ELF export inventories to the selected file", async () => { + const root = await createTestTempDirectory("rea-native-exports-"); + const service = createNativeExportsService(); + for (const [name, bytes, format] of [ + [ + "fixture.dll", + peExportImage({ + functions: [{ rva: 0x2000 }], + names: [{ name: "Exported", index: 0 }], + }), + "pe", + ], + [ + "libfixture.so", + elfExportImage({ symbols: [{ name: "exported" }] }), + "elf", + ], + ] as const) { + const path = join(root, name); + await writeFile(path, bytes); + const result = await service.inspect({ path }); + if (!result.ok) throw new Error(JSON.stringify(result.error)); + const evidence = parseEvidence(result.value); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + + expect(evidence).toMatchObject({ + operation: "inspect_native_exports", + provider: { id: "rea-native-exports" }, + confidence: "observed", + subject: { local_path: path, format, digest: { sha256 } }, + }); + expect( + nativeExportsSchema.parse(evidence.normalized_result), + ).toMatchObject({ + artifact: { path, sha256, bytes: bytes.length }, + format, + coverage: { status: "complete", exports: 1 }, + }); + } + await service.close(); + }); + + it("returns typed input, unsupported, budget and cancellation failures", async () => { + const root = await createTestTempDirectory("rea-native-export-failures-"); + const path = join(root, "fixture.dll"); + const alias = join(root, "alias.dll"); + const macho = join(root, "fixture.dylib"); + const malformed = join(root, "malformed.dll"); + await writeFile(path, peExportImage({ functions: [] })); + const machoBytes = Buffer.alloc(64); + machoBytes.writeUInt32LE(0xfeedfacf, 0); + await writeFile(macho, machoBytes); + await writeFile(malformed, Buffer.from("MZ not a PE image")); + await symlink(path, alias); + const service = createNativeExportsService(); + const failure = async ( + input: Record, + signal?: AbortSignal, + ) => { + const result = await service.inspect( + input, + signal === undefined ? undefined : { signal }, + ); + if (result.ok) throw new Error("Expected a failure"); + return result.error; + }; + + for (const [candidate, reason] of [ + [alias, "invalid_format"], + [malformed, "invalid_format"], + [join(root, "missing.dll"), "invalid_value"], + ] as const) + expect(await failure({ path: candidate })).toMatchObject({ + _tag: "AnalysisInputError", + operation: "inspect_native_exports", + issues: [{ path: ["path"], reason }], + }); + expect(await failure({ path: macho })).toMatchObject({ + _tag: "AnalysisUnsupportedTargetError", + reason: expect.stringContaining("inspect_macho"), + }); + expect(await failure({ path, max_file_bytes: 64 })).toMatchObject({ + _tag: "AnalysisResourceConstraintError", + }); + expect( + await failure({ path }, AbortSignal.abort("caller stop")), + ).toMatchObject({ _tag: "AnalysisCancelledError" }); + await service.close(); + }); +}); diff --git a/tests/boundary/mcp/absoluteLocalPathSchema.test.ts b/tests/boundary/mcp/absoluteLocalPathSchema.test.ts index 536f2c3f2e..d0a1489886 100644 --- a/tests/boundary/mcp/absoluteLocalPathSchema.test.ts +++ b/tests/boundary/mcp/absoluteLocalPathSchema.test.ts @@ -78,6 +78,7 @@ const standaloneAnalysisCases = ( { name: "inspect_evm_interface", input: { encoding: "hex" } }, { name: "inspect_sqlite_database", input: {} }, { name: "inspect_pe_resources", input: {} }, + { name: "inspect_native_exports", input: {} }, { name: "inspect_electron_fuses", input: {} }, { name: "inspect_packet_capture", input: { fields: ["ip.addr"] } }, { diff --git a/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts b/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts index 1131b45b05..9c83d87d0d 100644 --- a/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts +++ b/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts @@ -1,4 +1,4 @@ -import { randomBytes } from "node:crypto"; +import { createHash, randomBytes } from "node:crypto"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -20,6 +20,8 @@ import { binaryLayoutFixture, BINARY_LAYOUT_TEST_PROVIDER, } from "../../fixtures/binaryDiagnostics/layout.js"; +import { elfExportImage } from "../../fixtures/binaryDiagnostics/nativeExports.js"; +import { parseNativeExports } from "../../../src/native/exports/NativeExportParser.js"; it("advertises exact valid schemas and records inline layout without a binary target", async () => { const value = binaryLayoutFixture(); @@ -181,3 +183,50 @@ it("profiles real bytes with exact offsets, valid advertised schemas and read-on ).isError, ).toBe(true); }); + +it("records a portable export inventory through MCP without a binary target", async () => { + const root = await createTestTempDirectory("rea-native-exports-mcp-"); + const path = join(root, "libfixture.so"); + const bytes = elfExportImage({ + symbols: [{ name: "exported_fn" }, { name: "hidden_fn", visibility: 2 }], + }); + await writeFile(path, bytes); + const session = createTestBinarySession(() => { + throw new Error("deep provider must not start"); + }); + const server = createServer({ kind: "session", session }); + const client = new Client({ name: "native-exports-contract", 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 schemas = await advertisedMcpToolSchemas( + client, + "inspect_native_exports", + ); + expect(schemas.ajv.validate(schemas.inputSchema, { path })).toBe(true); + expect( + schemas.ajv.validate(schemas.inputSchema, { path: "relative.so" }), + ).toBe(false); + const response = await client.callTool({ + name: "inspect_native_exports", + arguments: { path }, + }); + assertRecordedMcpEvidence( + response, + "inspect_native_exports", + schemas, + session, + parseNativeExports(bytes, { + path, + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.length, + }), + "observed", + ); +}); diff --git a/tests/fixtures/advertisedMcpTool.ts b/tests/fixtures/advertisedMcpTool.ts index 55521941de..e10e603e40 100644 --- a/tests/fixtures/advertisedMcpTool.ts +++ b/tests/fixtures/advertisedMcpTool.ts @@ -35,7 +35,8 @@ export const assertRecordedMcpEvidence = ( name: | "inspect_wasm_artifact" | "inspect_binary_layout" - | "inspect_evm_interface", + | "inspect_evm_interface" + | "inspect_native_exports", schemas: Awaited>, session: BinarySession, expected: { readonly artifact: { readonly sha256: string } },