From 419a2113044a1579564b9c8a560f8f8ee99eac02 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:26:22 +0800 Subject: [PATCH] fix(cli): preserve caller paths in format inspections (#7855) * fix(cli): preserve caller paths in format inspections * fix(cli): retain caller path on partial evidence * test(cli): preserve caller path in adapter parity checks * test(rust): account for retained caller path * fix(wasm): compare shared cli and mcp evidence --- scripts/verify-apk-signature.mjs | 7 +- scripts/verify-packet-capture.mjs | 6 +- scripts/verify-wasm-artifact.mjs | 12 ++- src/cli/analysisEvidenceDelivery.ts | 7 +- src/cli/androidCommands.ts | 36 ++++++--- src/cli/apkSignatureCommands.ts | 1 + src/cli/apktoolCommands.ts | 45 +++++++---- src/cli/binaryDiagnosticsCommands.ts | 37 ++++++--- src/cli/captureCommands.ts | 4 +- src/cli/cliEvidenceCallerPath.ts | 53 +++++++++++++ src/cli/erlangCommands.ts | 1 + src/cli/evmCommands.ts | 5 +- src/cli/firmwareCommands.ts | 40 ++++++---- src/cli/flutterCommands.ts | 16 +++- src/cli/goCommands.ts | 1 + src/cli/gpuCommands.ts | 1 + src/cli/il2cppCommands.ts | 1 + src/cli/jebCommands.ts | 16 +++- src/cli/modelCommands.ts | 1 + src/cli/pcapCommands.ts | 25 ++++-- src/cli/pythonPycCommands.ts | 1 + src/cli/rustCommands.ts | 1 + src/cli/signalCommands.ts | 4 +- src/cli/sqliteCommands.ts | 5 +- src/cli/wasmCommands.ts | 5 +- tests/acceptance/flutterAnalysis.test.ts | 3 +- .../formatSpecificCallerPathCli.test.ts | 69 ++++++++++++++++ tests/acceptance/goBinaryCli.test.ts | 15 ++-- .../goBinaryPointerMappingsCli.test.ts | 3 +- tests/acceptance/rustBinaryCli.test.ts | 7 +- tests/acceptance/rustDependencyViews.test.ts | 2 +- tests/acceptance/rustUnnamedSections.test.ts | 3 +- tests/acceptance/sqliteDatabaseCli.test.ts | 15 ++-- .../cli/cliEvidenceCallerPath.test.ts | 78 +++++++++++++++++++ tests/boundary/cli/flutterCli.test.ts | 9 ++- tests/fixtures/cliMcpEvidenceParity.ts | 14 ++++ 36 files changed, 453 insertions(+), 96 deletions(-) create mode 100644 src/cli/cliEvidenceCallerPath.ts create mode 100644 tests/acceptance/formatSpecificCallerPathCli.test.ts create mode 100644 tests/boundary/cli/cliEvidenceCallerPath.test.ts create mode 100644 tests/fixtures/cliMcpEvidenceParity.ts diff --git a/scripts/verify-apk-signature.mjs b/scripts/verify-apk-signature.mjs index b5b27e2d74..fc3951e45d 100644 --- a/scripts/verify-apk-signature.mjs +++ b/scripts/verify-apk-signature.mjs @@ -240,7 +240,7 @@ try { "output schema invalid", ); - /** CLI and MCP must return identical, schema-valid Evidence. */ + /** CLI and MCP share schema-valid Evidence, with CLI-only caller spelling. */ const inspectBoth = async (path) => { const cliValue = await inspectCli(path); assert.equal( @@ -262,7 +262,10 @@ try { JSON.stringify(ajv.errors), ); const mcp = parseEvidence(requireMcpOutput(reply, "inspect_apk_signature")); - assert.deepEqual(cli, mcp, "CLI and MCP evidence differ"); + const { caller_input_path: callerInputPath, ...cliShared } = cli; + assert.equal(callerInputPath, path); + assert.equal(Object.hasOwn(mcp, "caller_input_path"), false); + assert.deepEqual(cliShared, mcp, "CLI and MCP evidence differ"); cases += 2; return mcp.normalized_result; }; diff --git a/scripts/verify-packet-capture.mjs b/scripts/verify-packet-capture.mjs index 9e80b4ea8b..a57ef7a688 100644 --- a/scripts/verify-packet-capture.mjs +++ b/scripts/verify-packet-capture.mjs @@ -183,7 +183,11 @@ await withVerifierCleanup(async () => { ) ).stdout, ); - assert.deepEqual(parseEvidence(cli), evidence); + const { caller_input_path: callerInputPath, ...cliShared } = + parseEvidence(cli); + assert.equal(callerInputPath, input.path); + assert.equal(Object.hasOwn(evidence, "caller_input_path"), false); + assert.deepEqual(cliShared, evidence); const bundle = requireMcpEvidenceBundle( await client.callTool({ name: "get_evidence_bundle", diff --git a/scripts/verify-wasm-artifact.mjs b/scripts/verify-wasm-artifact.mjs index 3c64f9b259..53d0a4e879 100644 --- a/scripts/verify-wasm-artifact.mjs +++ b/scripts/verify-wasm-artifact.mjs @@ -62,6 +62,12 @@ const tool = (name, args, cwd) => maxBuffer: 32 * 1024 * 1024, }); const sha256 = (value) => createHash("sha256").update(value).digest("hex"); +const assertEvidenceParity = (cli, mcp, path) => { + const { caller_input_path: callerInputPath, ...cliShared } = cli; + assert.equal(callerInputPath, path); + assert.equal(Object.hasOwn(mcp, "caller_input_path"), false); + assert.deepEqual(cliShared, mcp); +}; const run = createVerifierRun(); const root = await PrivateRuntimeRoot.create({ prefix: "rea-wabt-verifier-" }); // Keep provider scratch outside the leased private input root. On Windows the @@ -281,7 +287,7 @@ try { ); const mcp = parseEvidence(requireMcpOutput(reply, "inspect_wasm_artifact")); assert.equal(cli.evidence_id, mcp.evidence_id); - assert.deepEqual(cli, mcp); + assertEvidenceParity(cli, mcp, path); const report = mcp.normalized_result; assert.equal( report.tool_profile.timeout_ms, @@ -521,7 +527,7 @@ try { JSON.stringify(ajv.errors), ); const mcp = parseEvidence(requireMcpOutput(reply, "inspect_wasm_artifact")); - assert.deepEqual(cli, mcp); + assertEvidenceParity(cli, mcp, path); const report = mcp.normalized_result; assert.equal(report.validation, "valid"); assert.equal(report.artifact.sha256, sha256(original)); @@ -587,7 +593,7 @@ try { JSON.stringify(ajv.errors), ); const mcp = parseEvidence(requireMcpOutput(reply, "inspect_wasm_artifact")); - assert.deepEqual(cli, mcp); + assertEvidenceParity(cli, mcp, path); const report = mcp.normalized_result; assert.equal(report.artifact.sha256, sha256(original)); assert.equal(report.debug_name_metadata, "unknown"); diff --git a/src/cli/analysisEvidenceDelivery.ts b/src/cli/analysisEvidenceDelivery.ts index 3ec09dbd64..0550210b15 100644 --- a/src/cli/analysisEvidenceDelivery.ts +++ b/src/cli/analysisEvidenceDelivery.ts @@ -22,6 +22,7 @@ import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; import type { ZodSafeParseResult } from "zod"; +import { bindCliEvidenceCallerPath } from "./cliEvidenceCallerPath.js"; export const analysisEvidenceDeliveryOptions = { detail: z @@ -42,6 +43,8 @@ interface CliEvidenceDelivery { readonly signal: AbortSignal; readonly detail: "complete" | "summary"; readonly evidenceOutput: string | undefined; + /** Exact one-shot CLI target spelling, when this invocation selects a file. */ + readonly callerInputPath?: string; /** Derived workflows publish their selected source closure with the result. */ readonly sourceEvidence?: readonly Evidence[]; /** Invocation exclusions cover final cleanup and publication paths/diagnostics. */ @@ -140,7 +143,9 @@ export const deliverCliAnalysisEvidence = async ( { redactFailure }, ).then(projectCliAnalysisResult); const complete = await finishAnalysisCommand( - run, + input.callerInputPath === undefined + ? run + : bindCliEvidenceCallerPath(input.callerInputPath, run), service, providerId, operation, diff --git a/src/cli/androidCommands.ts b/src/cli/androidCommands.ts index 3b2393c25a..36e4140810 100644 --- a/src/cli/androidCommands.ts +++ b/src/cli/androidCommands.ts @@ -9,6 +9,7 @@ import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; +import { bindCliEvidenceCallerPath } from "./cliEvidenceCallerPath.js"; import { analysisEvidenceDeliveryOptions, deliverCliAnalysisEvidence, @@ -34,6 +35,7 @@ export const registerAndroidCommands = ( name: string, operation: AndroidOperation, input: unknown, + callerPath: string, delivery?: { readonly detail: "complete" | "summary"; readonly evidence_output?: string | undefined; @@ -41,7 +43,9 @@ export const registerAndroidCommands = ( ) => withCommandCancellation((signal) => logCliCommand(logger, name, () => { - const run = () => service.execute(operation, input, { signal }); + const run = bindCliEvidenceCallerPath(callerPath, () => + service.execute(operation, input, { signal }), + ); const cleanup = { close: () => provider.close() }; if (delivery !== undefined) return deliverCliAnalysisEvidence({ @@ -127,6 +131,7 @@ export const registerAndroidCommands = ( ); if (!selected.ok) return projectAnalysisError(selected.error); return deliverCliAnalysisEvidence({ + callerInputPath: args.path, run: () => service.execute( operation, @@ -170,6 +175,7 @@ export const registerAndroidCommands = ( ); if (!delivery.ok) return projectAnalysisError(delivery.error); return deliverCliAnalysisEvidence({ + callerInputPath: args.path, run: () => service.execute("inspect_android_package", args, { signal }), service: { close: () => provider.close() }, @@ -200,6 +206,7 @@ export const registerAndroidCommands = ( CLI_COMMANDS.searchAndroidClasses, "search_android_classes", args, + args.path, ), }); cli.command(CLI_COMMANDS.inspectAndroidClass, { @@ -215,6 +222,7 @@ export const registerAndroidCommands = ( class_name: args.className, ...mappingInput(options.mappingPath), }, + args.path, options, ), }); @@ -247,6 +255,7 @@ export const registerAndroidCommands = ( ? {} : { overload_index: options.overloadIndex }), }, + args.path, options, ), }); @@ -264,15 +273,20 @@ export const registerAndroidCommands = ( .describe("Index among same-name methods from inspect-android-class"), }), run: ({ args, options }) => - execute(CLI_COMMANDS.traceAndroidReferences, "trace_android_references", { - path: args.path, - class_name: args.className, - ...(options.methodName === undefined - ? {} - : { method_name: options.methodName }), - ...(options.overloadIndex === undefined - ? {} - : { overload_index: options.overloadIndex }), - }), + execute( + CLI_COMMANDS.traceAndroidReferences, + "trace_android_references", + { + path: args.path, + class_name: args.className, + ...(options.methodName === undefined + ? {} + : { method_name: options.methodName }), + ...(options.overloadIndex === undefined + ? {} + : { overload_index: options.overloadIndex }), + }, + args.path, + ), }); }; diff --git a/src/cli/apkSignatureCommands.ts b/src/cli/apkSignatureCommands.ts index d1894e42ee..395b3964b0 100644 --- a/src/cli/apkSignatureCommands.ts +++ b/src/cli/apkSignatureCommands.ts @@ -74,6 +74,7 @@ export const registerApkSignatureCommands = ( ); const service = apkSignatureModule.create(environment); return deliverCliAnalysisEvidence({ + callerInputPath: args.path, run: () => service.inspect( { path: input.data.path, detail: "complete" }, diff --git a/src/cli/apktoolCommands.ts b/src/cli/apktoolCommands.ts index f795b24640..817496eeb7 100644 --- a/src/cli/apktoolCommands.ts +++ b/src/cli/apktoolCommands.ts @@ -13,6 +13,7 @@ import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; +import { bindCliEvidenceCallerPath } from "./cliEvidenceCallerPath.js"; /** Expose the same explicit Apktool requests through one-shot CLI commands. */ export const registerApktoolCommands = ( @@ -24,11 +25,20 @@ export const registerApktoolCommands = ( ), ): void => { const service = new ApktoolResourceAnalysisService(provider); - const execute = (name: string, operation: ApktoolOperation, input: unknown) => + const execute = ( + name: string, + operation: ApktoolOperation, + input: unknown, + callerPath?: string, + ) => withCommandCancellation((signal) => logCliCommand(logger, name, () => finishAnalysisCommand( - () => service.execute(operation, input, { signal }), + callerPath === undefined + ? () => service.execute(operation, input, { signal }) + : bindCliEvidenceCallerPath(callerPath, () => + service.execute(operation, input, { signal }), + ), { close: () => provider.close() }, "apktool", operation, @@ -76,17 +86,24 @@ export const registerApktoolCommands = ( ), }), run: ({ args, options }) => - execute(CLI_COMMANDS.decodeAndroidResources, "decode_android_resources", { - path: args.path, - include_strings: options.strings, - ...(options["string-tables-only"] === undefined - ? {} - : { string_tables_only: options["string-tables-only"] }), - ...(options.locale === undefined ? {} : { locale: options.locale }), - ...(options.locales === undefined ? {} : { locales: options.locales }), - ...(options.decodeTimeoutMs === undefined - ? {} - : { decode_timeout_ms: options.decodeTimeoutMs }), - }), + execute( + CLI_COMMANDS.decodeAndroidResources, + "decode_android_resources", + { + path: args.path, + include_strings: options.strings, + ...(options["string-tables-only"] === undefined + ? {} + : { string_tables_only: options["string-tables-only"] }), + ...(options.locale === undefined ? {} : { locale: options.locale }), + ...(options.locales === undefined + ? {} + : { locales: options.locales }), + ...(options.decodeTimeoutMs === undefined + ? {} + : { decode_timeout_ms: options.decodeTimeoutMs }), + }, + args.path, + ), }); }; diff --git a/src/cli/binaryDiagnosticsCommands.ts b/src/cli/binaryDiagnosticsCommands.ts index 5ec804f21b..e5c36969ba 100644 --- a/src/cli/binaryDiagnosticsCommands.ts +++ b/src/cli/binaryDiagnosticsCommands.ts @@ -32,7 +32,8 @@ import { inspectElfNotesInputSchema } from "../domain/native/elfNotes.js"; import { inspectAppleIpsInputSchema } from "../domain/native/appleIps.js"; import { inspectKernelModuleInputSchema } from "../domain/native/kernelModule.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; -import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; +import { finishCliEvidenceCommand } from "./cliEvidenceCallerPath.js"; +import { withEvidenceCallerInputPath } from "../domain/evidence.js"; import { localPathStringSchema } from "../domain/localPath.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; import { @@ -63,7 +64,8 @@ export const registerBinaryDiagnosticsCommands = ( logger, CLI_COMMANDS.inspectElfNotes, async () => - finishAnalysisCommand( + finishCliEvidenceCommand( + args.path, () => elf.inspect( { @@ -103,7 +105,8 @@ export const registerBinaryDiagnosticsCommands = ( logger, CLI_COMMANDS.inspectAppleIps, async () => - finishAnalysisCommand( + finishCliEvidenceCommand( + args.path, () => ips.inspect( { @@ -144,7 +147,8 @@ export const registerBinaryDiagnosticsCommands = ( logger, CLI_COMMANDS.inspectKernelModule, async () => - finishAnalysisCommand( + finishCliEvidenceCommand( + args.path, () => kernelModule.inspect( { @@ -179,7 +183,8 @@ export const registerBinaryDiagnosticsCommands = ( logger, CLI_COMMANDS.inspectPeLoadConfig, async () => - finishAnalysisCommand( + finishCliEvidenceCommand( + args.path, () => elf.inspect( { @@ -216,7 +221,8 @@ export const registerBinaryDiagnosticsCommands = ( run: ({ args, options }) => withCommandCancellation((signal) => logCliCommand(logger, CLI_COMMANDS.inspectByteEntropy, async () => { - return await finishAnalysisCommand( + return await finishCliEvidenceCommand( + args.path, () => entropy.inspect( { @@ -252,7 +258,8 @@ export const registerBinaryDiagnosticsCommands = ( logger, CLI_COMMANDS.inspectPeCertificates, async () => - finishAnalysisCommand( + finishCliEvidenceCommand( + args.path, () => pe.inspect( { @@ -286,7 +293,8 @@ export const registerBinaryDiagnosticsCommands = ( logger, CLI_COMMANDS.inspectPeImports, async () => - finishAnalysisCommand( + finishCliEvidenceCommand( + args.path, () => pe.inspect( { @@ -322,7 +330,8 @@ export const registerBinaryDiagnosticsCommands = ( logger, CLI_COMMANDS.inspectPeResources, async () => - finishAnalysisCommand( + finishCliEvidenceCommand( + args.path, () => pe.inspect( { @@ -368,6 +377,7 @@ export const registerBinaryDiagnosticsCommands = ( CLI_COMMANDS.inspectDevicetree, async () => deliverCliAnalysisEvidence({ + callerInputPath: args.path, run: () => devicetree.inspect( { @@ -407,6 +417,7 @@ export const registerBinaryDiagnosticsCommands = ( CLI_COMMANDS.inspectNativeExports, async () => deliverCliAnalysisEvidence({ + callerInputPath: args.path, run: () => exports.inspect( { @@ -450,7 +461,9 @@ export const registerBinaryDiagnosticsCommands = ( }, { signal }, ); - return result.ok ? result.value : projectAnalysisError(result.error); + return result.ok + ? withEvidenceCallerInputPath(result.value, args.path) + : projectAnalysisError(result.error); }), ), }); @@ -484,7 +497,9 @@ export const registerBinaryDiagnosticsCommands = ( }, { signal }, ); - return result.ok ? result.value : projectAnalysisError(result.error); + return result.ok + ? withEvidenceCallerInputPath(result.value, args.path) + : projectAnalysisError(result.error); }), ), }); diff --git a/src/cli/captureCommands.ts b/src/cli/captureCommands.ts index bfd0324786..d5854bb499 100644 --- a/src/cli/captureCommands.ts +++ b/src/cli/captureCommands.ts @@ -14,6 +14,7 @@ import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; +import { finishCliEvidenceCommand } from "./cliEvidenceCallerPath.js"; import { localPathStringSchema } from "../domain/localPath.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; export const registerCaptureCommands = ( @@ -104,7 +105,8 @@ export const registerCaptureCommands = ( run: ({ args, options: { all_matches, ...options } }) => withCommandCancellation((signal) => logCliCommand(logger, CLI_COMMANDS.inspectPacketCapture, async () => { - return finishAnalysisCommand( + return finishCliEvidenceCommand( + args.path, () => service.inspect( { diff --git a/src/cli/cliEvidenceCallerPath.ts b/src/cli/cliEvidenceCallerPath.ts new file mode 100644 index 0000000000..800459d9fc --- /dev/null +++ b/src/cli/cliEvidenceCallerPath.ts @@ -0,0 +1,53 @@ +import { + parseEvidence, + withEvidenceCallerInputPath, + type Evidence, +} from "../domain/evidence.js"; +import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import type { Result } from "../domain/result.js"; +import { ZodError } from "zod"; +import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; + +/** Bind the CLI selection before final cleanup or publication can retain it. */ +export const bindCliEvidenceCallerPath = + ( + callerPath: string, + run: () => Promise>, + ): (() => Promise>) => + async () => { + const result = await run(); + if (result.ok) + return { + ok: true, + value: withEvidenceCallerInputPath(result.value, callerPath), + }; + if (result.error.partialObservation !== undefined) { + try { + result.error.partialObservation = withEvidenceCallerInputPath( + parseEvidence(result.error.partialObservation), + callerPath, + ); + } catch (cause) { + // Raw or invalid partial observations must retain their original form. + if (!(cause instanceof ZodError)) throw cause; + } + } + return result; + }; + +/** Finalize a one-shot file inspection with its original CLI selection. */ +export const finishCliEvidenceCommand = ( + callerPath: string, + run: () => Promise>, + service: { close(): Promise }, + providerId: string, + operation: string, + signal: AbortSignal, +) => + finishAnalysisCommand( + bindCliEvidenceCallerPath(callerPath, run), + service, + providerId, + operation, + signal, + ); diff --git a/src/cli/erlangCommands.ts b/src/cli/erlangCommands.ts index cea8894f1a..9ec3113c8b 100644 --- a/src/cli/erlangCommands.ts +++ b/src/cli/erlangCommands.ts @@ -50,6 +50,7 @@ export const registerErlangCommands = ( if (!delivery.ok) return projectAnalysisError(delivery.error); const service = createBeamService(); return deliverCliAnalysisEvidence({ + callerInputPath: args.path, run: () => service.inspect( { diff --git a/src/cli/evmCommands.ts b/src/cli/evmCommands.ts index 8cf53caa06..d7def8345e 100644 --- a/src/cli/evmCommands.ts +++ b/src/cli/evmCommands.ts @@ -7,7 +7,7 @@ import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { localPathStringSchema } from "../domain/localPath.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; -import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; +import { finishCliEvidenceCommand } from "./cliEvidenceCallerPath.js"; import { inspectEvmInterfaceInputSchema } from "../domain/evm/evmInterface.js"; /** Resolve operator paths and preserve explicit encoding through the shared workflow. */ @@ -31,7 +31,8 @@ export const registerEvmCommands = ( run: ({ args, options }) => withCommandCancellation((signal) => logCliCommand(logger, evmInterfaceModule.cliCommand, () => - finishAnalysisCommand( + finishCliEvidenceCommand( + args.path, () => service.inspect( { diff --git a/src/cli/firmwareCommands.ts b/src/cli/firmwareCommands.ts index 0c0a828ee6..43223a58cd 100644 --- a/src/cli/firmwareCommands.ts +++ b/src/cli/firmwareCommands.ts @@ -11,7 +11,7 @@ import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; -import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; +import { finishCliEvidenceCommand } from "./cliEvidenceCallerPath.js"; /** One-shot firmware CLI commands backed by the same workflows as MCP. */ export const registerFirmwareCommands = ( @@ -28,10 +28,12 @@ export const registerFirmwareCommands = ( name: string, operation: FirmwareOperation, input: unknown, + callerPath: string, ) => withCommandCancellation((signal) => logCliCommand(logger, name, () => - finishAnalysisCommand( + finishCliEvidenceCommand( + callerPath, () => service.execute(operation, input, { signal }), service, operation === "inspect_firmware_regions" ? "binwalk" : "unblob", @@ -59,6 +61,7 @@ export const registerFirmwareCommands = ( ? {} : { analysis_timeout_ms: options.analysisTimeoutMs }), }, + args.path, ), }); cli.command(CLI_COMMANDS.extractFirmware, { @@ -84,19 +87,24 @@ export const registerFirmwareCommands = ( analysisTimeoutMs: extractInput.analysis_timeout_ms, }), run: ({ args, options }) => - execute(CLI_COMMANDS.extractFirmware, "extract_firmware", { - path: absoluteCallerPath(args.path), - output_directory: absoluteCallerPath(args.outputDirectory), - ...(options.offset === undefined && options.length === undefined - ? {} - : { range: { offset: options.offset, length: options.length } }), - max_depth: options.maxDepth, - max_output_bytes: options.maxOutputBytes, - max_output_files: options.maxOutputFiles, - publication_timeout_ms: options.publicationTimeoutMs, - ...(options.analysisTimeoutMs === undefined - ? {} - : { analysis_timeout_ms: options.analysisTimeoutMs }), - }), + execute( + CLI_COMMANDS.extractFirmware, + "extract_firmware", + { + path: absoluteCallerPath(args.path), + output_directory: absoluteCallerPath(args.outputDirectory), + ...(options.offset === undefined && options.length === undefined + ? {} + : { range: { offset: options.offset, length: options.length } }), + max_depth: options.maxDepth, + max_output_bytes: options.maxOutputBytes, + max_output_files: options.maxOutputFiles, + publication_timeout_ms: options.publicationTimeoutMs, + ...(options.analysisTimeoutMs === undefined + ? {} + : { analysis_timeout_ms: options.analysisTimeoutMs }), + }, + args.path, + ), }); }; diff --git a/src/cli/flutterCommands.ts b/src/cli/flutterCommands.ts index beb7c46447..a5a6b51107 100644 --- a/src/cli/flutterCommands.ts +++ b/src/cli/flutterCommands.ts @@ -1,5 +1,8 @@ import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; -import { evidenceSchema } from "../domain/evidence.js"; +import { + evidenceSchema, + withEvidenceCallerInputPath, +} from "../domain/evidence.js"; import { z } from "incur"; import { FlutterBuildAnalysisService } from "../application/flutter/FlutterBuildAnalysisService.js"; import type { FlutterBuildAnalysisPort } from "../application/flutter/FlutterBuildAnalysisPort.js"; @@ -14,6 +17,7 @@ import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; +import { bindCliEvidenceCallerPath } from "./cliEvidenceCallerPath.js"; /** One flutter command: CLI shape and the request it produces. */ interface FlutterCommandSpec { @@ -72,12 +76,13 @@ export const registerFlutterCommands = ( withCommandCancellation((signal) => logCliCommand(logger, spec.command, () => finishAnalysisCommand( - () => + bindCliEvidenceCallerPath(args.path as string, () => new FlutterBuildAnalysisService(provider).execute( spec.operation, spec.toInput(args, options ?? {}), { signal }, ), + ), { close: () => provider.close() }, "flutter", spec.operation, @@ -86,7 +91,12 @@ export const registerFlutterCommands = ( { recoverCompletedObservation: (observation) => { const parsed = evidenceSchema.safeParse(observation); - return parsed.success ? parsed.data : undefined; + return parsed.success + ? withEvidenceCallerInputPath( + parsed.data, + args.path as string, + ) + : undefined; }, }, ).then(projectCliAnalysisResult), diff --git a/src/cli/goCommands.ts b/src/cli/goCommands.ts index 5536464afe..17ad941f3c 100644 --- a/src/cli/goCommands.ts +++ b/src/cli/goCommands.ts @@ -39,6 +39,7 @@ export const registerGoCommands = (cli: CliInstance, logger: Logger): void => { CLI_COMMANDS.inspectGoBinary, async () => deliverCliAnalysisEvidence({ + callerInputPath: args.path, run: () => service.inspect( { diff --git a/src/cli/gpuCommands.ts b/src/cli/gpuCommands.ts index c5cb889dcf..186c35cf4c 100644 --- a/src/cli/gpuCommands.ts +++ b/src/cli/gpuCommands.ts @@ -99,6 +99,7 @@ export const registerGpuCommands = (cli: CliInstance, logger: Logger): void => { ); const service = createGpuCodeObjectsService(); return deliverCliAnalysisEvidence({ + callerInputPath: args.path, run: () => service.inspect( { diff --git a/src/cli/il2cppCommands.ts b/src/cli/il2cppCommands.ts index 0ba11f2fa1..4dc4a6ca2c 100644 --- a/src/cli/il2cppCommands.ts +++ b/src/cli/il2cppCommands.ts @@ -62,6 +62,7 @@ export const registerIl2cppCommands = ( logCliCommand(logger, CLI_COMMANDS.inspectIl2cppMetadata, async () => { const service = createIl2cppMetadataService(); return deliverCliAnalysisEvidence({ + callerInputPath: args.path, run: () => { const input = { path: absoluteCallerPath(args.path), diff --git a/src/cli/jebCommands.ts b/src/cli/jebCommands.ts index 888e0493b2..e3e65bb69e 100644 --- a/src/cli/jebCommands.ts +++ b/src/cli/jebCommands.ts @@ -10,6 +10,7 @@ import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; +import { bindCliEvidenceCallerPath } from "./cliEvidenceCallerPath.js"; import { jebInputSchemas } from "../domain/jeb/jebAnalysis.js"; /** Expose the same explicit JEB requests through one-shot CLI commands. */ @@ -20,11 +21,20 @@ export const registerJebCommands = ( provider: JebAnalysisPort = createJebAnalysisProvider(environment), ): void => { const service = new JebAnalysisService(provider); - const execute = (name: string, operation: JebOperation, input: unknown) => + const execute = ( + name: string, + operation: JebOperation, + input: unknown, + callerPath?: string, + ) => withCommandCancellation((signal) => logCliCommand(logger, name, () => finishAnalysisCommand( - () => service.execute(operation, input, { signal }), + callerPath === undefined + ? () => service.execute(operation, input, { signal }) + : bindCliEvidenceCallerPath(callerPath, () => + service.execute(operation, input, { signal }), + ), { close: () => provider.close() }, "jeb", operation, @@ -45,7 +55,7 @@ export const registerJebCommands = ( path: jebInputSchemas.open_jeb_project.shape.path, }), run: ({ args }) => - execute(CLI_COMMANDS.openJebProject, "open_jeb_project", args), + execute(CLI_COMMANDS.openJebProject, "open_jeb_project", args, args.path), }); cli.command(CLI_COMMANDS.listJebUnits, { description: "List JEB project units by path and type with optional filter", diff --git a/src/cli/modelCommands.ts b/src/cli/modelCommands.ts index c120e88ea9..2016ca8a46 100644 --- a/src/cli/modelCommands.ts +++ b/src/cli/modelCommands.ts @@ -84,6 +84,7 @@ export const registerModelCommands = ( ); const service = createModelArtifactService(); return deliverCliAnalysisEvidence({ + callerInputPath: args.path, run: () => service.inspect( { diff --git a/src/cli/pcapCommands.ts b/src/cli/pcapCommands.ts index 954943bebe..49721123b9 100644 --- a/src/cli/pcapCommands.ts +++ b/src/cli/pcapCommands.ts @@ -10,6 +10,7 @@ import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; +import { bindCliEvidenceCallerPath } from "./cliEvidenceCallerPath.js"; /** Expose the same explicit pcap requests through one-shot CLI commands. */ export const registerPcapCommands = ( @@ -19,11 +20,20 @@ export const registerPcapCommands = ( provider: PcapAnalysisPort = createPcapAnalysisProvider(environment), ): void => { const service = new PcapAnalysisService(provider); - const execute = (name: string, operation: PcapOperation, input: unknown) => + const execute = ( + name: string, + operation: PcapOperation, + input: unknown, + callerPath?: string, + ) => withCommandCancellation((signal) => logCliCommand(logger, name, () => finishAnalysisCommand( - () => service.execute(operation, input, { signal }), + callerPath === undefined + ? () => service.execute(operation, input, { signal }) + : bindCliEvidenceCallerPath(callerPath, () => + service.execute(operation, input, { signal }), + ), { close: () => provider.close() }, "pcap", operation, @@ -49,8 +59,13 @@ export const registerPcapCommands = ( .describe("Local pcap or pcapng capture file to inspect"), }), run: ({ args }) => - execute(CLI_COMMANDS.inspectPcapCapture, "inspect_pcap_capture", { - path: args.path, - }), + execute( + CLI_COMMANDS.inspectPcapCapture, + "inspect_pcap_capture", + { + path: args.path, + }, + args.path, + ), }); }; diff --git a/src/cli/pythonPycCommands.ts b/src/cli/pythonPycCommands.ts index 07fc2d11fd..4caaabd6b8 100644 --- a/src/cli/pythonPycCommands.ts +++ b/src/cli/pythonPycCommands.ts @@ -51,6 +51,7 @@ export const registerPythonPycCommands = ( if (!delivery.ok) return projectAnalysisError(delivery.error); const service = createPythonPycService(); return deliverCliAnalysisEvidence({ + callerInputPath: args.path, run: () => service.inspect( { diff --git a/src/cli/rustCommands.ts b/src/cli/rustCommands.ts index 6840bb62b9..c2baa01d4c 100644 --- a/src/cli/rustCommands.ts +++ b/src/cli/rustCommands.ts @@ -92,6 +92,7 @@ export const registerRustCommands = ( ); const service = createRustBinaryService(); return deliverCliAnalysisEvidence({ + callerInputPath: args.path, run: () => service.inspect( { diff --git a/src/cli/signalCommands.ts b/src/cli/signalCommands.ts index 2ee5c42307..471e1ab681 100644 --- a/src/cli/signalCommands.ts +++ b/src/cli/signalCommands.ts @@ -14,6 +14,7 @@ import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; +import { finishCliEvidenceCommand } from "./cliEvidenceCallerPath.js"; import { localPathStringSchema } from "../domain/localPath.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; export const registerSignalCommands = ( @@ -106,7 +107,8 @@ export const registerSignalCommands = ( run: ({ args, options: { all_lines, ...options } }) => withCommandCancellation((signal) => logCliCommand(logger, CLI_COMMANDS.decodeSignalRecording, async () => { - return finishAnalysisCommand( + return finishCliEvidenceCommand( + args.path, () => service.decode( { diff --git a/src/cli/sqliteCommands.ts b/src/cli/sqliteCommands.ts index 72cce0429d..4288a162f3 100644 --- a/src/cli/sqliteCommands.ts +++ b/src/cli/sqliteCommands.ts @@ -8,7 +8,7 @@ import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { localPathStringSchema } from "../domain/localPath.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; -import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; +import { finishCliEvidenceCommand } from "./cliEvidenceCallerPath.js"; import { inspectSqliteDatabaseInputSchema } from "../domain/sqlite/sqliteDatabase.js"; /** Resolve an operator-selected SQLite snapshot through the shared inspection workflow. */ @@ -54,7 +54,8 @@ export const registerSqliteCommands = ( run: ({ args, options }) => withCommandCancellation((signal) => logCliCommand(logger, CLI_COMMANDS.inspectSqliteDatabase, () => - finishAnalysisCommand( + finishCliEvidenceCommand( + args.path, () => service.inspect( { diff --git a/src/cli/wasmCommands.ts b/src/cli/wasmCommands.ts index a2651fb73b..278671da07 100644 --- a/src/cli/wasmCommands.ts +++ b/src/cli/wasmCommands.ts @@ -11,7 +11,7 @@ import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; -import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; +import { finishCliEvidenceCommand } from "./cliEvidenceCallerPath.js"; import { inspectWasmArtifactInputSchema, wasmArtifactPathsSchema, @@ -65,7 +65,8 @@ export const registerWasmCommands = ( run: ({ args, options }) => withCommandCancellation((signal) => logCliCommand(logger, wasmArtifactModule.cliCommand, () => - finishAnalysisCommand( + finishCliEvidenceCommand( + args.path, () => service.inspect( { diff --git a/tests/acceptance/flutterAnalysis.test.ts b/tests/acceptance/flutterAnalysis.test.ts index 7cb29276c4..f1a8177b5d 100644 --- a/tests/acceptance/flutterAnalysis.test.ts +++ b/tests/acceptance/flutterAnalysis.test.ts @@ -19,6 +19,7 @@ import { createDuplicateFlutterApk, } from "../fixtures/flutterApk.js"; import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; +import { expectCliMcpEvidenceParity } from "../fixtures/cliMcpEvidenceParity.js"; import { parseMcpToolError } from "../fixtures/mcpToolError.js"; import { cliTest } from "../support/cli/cliFixture.js"; @@ -46,7 +47,7 @@ cliTest( ); const response = await call("inspect_dart_aot", { path, abi: "x86_64" }); expect(response.isError, JSON.stringify(response)).not.toBe(true); - expect(parseEvidence(response.structuredContent)).toEqual(evidence); + expectCliMcpEvidenceParity(evidence, response.structuredContent, path); const digest = createHash("sha256").update(image).digest("hex"); expect(report).toMatchObject({ coverage: "complete", diff --git a/tests/acceptance/formatSpecificCallerPathCli.test.ts b/tests/acceptance/formatSpecificCallerPathCli.test.ts new file mode 100644 index 0000000000..595bc359c8 --- /dev/null +++ b/tests/acceptance/formatSpecificCallerPathCli.test.ts @@ -0,0 +1,69 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { readEvidenceBundle } from "../../src/application/EvidenceBundleFiles.js"; +import { parseEvidence } from "../../src/domain/evidence.js"; +import { elfNoteFixture } from "../../src/native/elf/ElfNotes.fixture.js"; +import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; +import { safetensorsFixture } from "../fixtures/model/model.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +cliTest( + "retains format-specific CLI selections in complete and published Evidence", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-format-caller-path-"); + const model = join(root, "model.safetensors"); + const elf = join(root, "image.elf"); + await writeFile(model, safetensorsFixture().bytes); + await writeFile(elf, elfNoteFixture({ ownership: "program" })); + for (const [command, selection] of [ + ["inspect-model-artifact", "./model.safetensors"], + ["inspect-elf-notes", "./image.elf"], + ["inspect-byte-entropy", "./image.elf"], + ] as const) { + const result = await cli.run({ + arguments: [command, selection, "--json"], + cwd: root, + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = parseEvidence(result.json); + expect(evidence.caller_input_path).toBe(selection); + expect(evidence.parameters).toMatchObject({ + path: join(root, selection.slice(2)), + }); + } + const summary = await cli.run({ + arguments: [ + "inspect-model-artifact", + "./model.safetensors", + "--detail", + "summary", + "--evidence_output", + "./model-evidence.json", + "--json", + ], + cwd: root, + }); + expect(summary.exitCode, summary.stderr).toBe(0); + expect( + parseEvidence((summary.json as { summary: unknown }).summary) + .caller_input_path, + ).toBe("./model.safetensors"); + const bundle = await readEvidenceBundle(join(root, "model-evidence.json")); + if (!bundle.ok) throw bundle.error; + expect( + bundle.value.records.find( + (record) => record.operation === "inspect_model_artifact", + )?.caller_input_path, + ).toBe("./model.safetensors"); + + const { call } = await connectLocalToolsMcp(); + const mcp = await call("inspect_model_artifact", { path: model }); + expect(mcp.isError).not.toBe(true); + expect(parseEvidence(mcp.structuredContent)).not.toHaveProperty( + "caller_input_path", + ); + }, +); diff --git a/tests/acceptance/goBinaryCli.test.ts b/tests/acceptance/goBinaryCli.test.ts index 134b8f01b7..fdd18c41c2 100644 --- a/tests/acceptance/goBinaryCli.test.ts +++ b/tests/acceptance/goBinaryCli.test.ts @@ -23,6 +23,7 @@ import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; import { cliTest, runFileCli } from "../support/cli/cliFixture.js"; import { parseEvidence } from "../../src/domain/evidence.js"; import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; +import { expectCliMcpEvidenceParity } from "../fixtures/cliMcpEvidenceParity.js"; import { parseMcpToolError } from "../fixtures/mcpToolError.js"; cliTest( @@ -61,7 +62,7 @@ cliTest( }, }); expect(response.isError, JSON.stringify(response)).not.toBe(true); - expect(response.structuredContent).toEqual(evidence); + expectCliMcpEvidenceParity(evidence, response.structuredContent, path); } else { const expected = { code: "invalid_request", @@ -131,7 +132,7 @@ cliTest( item.fixture.headerOffset, ).toBeGreaterThan(maximum + 32); expect(response.isError, JSON.stringify(response)).not.toBe(true); - expect(response.structuredContent).toEqual(evidence); + expectCliMcpEvidenceParity(evidence, response.structuredContent, path); } else { const code = item.outcome === "resource" @@ -283,7 +284,7 @@ cliTest( infos.push(evidence.normalized_result.build_info); const response = await call("inspect_go_binary", { path }); expect(response.isError, JSON.stringify(response)).not.toBe(true); - expect(response.structuredContent).toEqual(evidence); + expectCliMcpEvidenceParity(evidence, response.structuredContent, path); } expect(infos[1]).toEqual(infos[0]); await client.ping(); @@ -328,7 +329,11 @@ for (const bits of [32, 64] as const) { const response = await call("inspect_go_binary", { path }); expect(response.isError, JSON.stringify(response)).not.toBe(true); - expect(response.structuredContent).toEqual(evidence); + expectCliMcpEvidenceParity( + evidence, + response.structuredContent, + path, + ); } expect(buildInfos[1]).toEqual(buildInfos[0]); @@ -386,7 +391,7 @@ cliTest( build_info: null, }); expect(response.isError, JSON.stringify(response)).not.toBe(true); - expect(response.structuredContent).toEqual(evidence); + expectCliMcpEvidenceParity(evidence, response.structuredContent, path); } await client.ping(); }, diff --git a/tests/acceptance/goBinaryPointerMappingsCli.test.ts b/tests/acceptance/goBinaryPointerMappingsCli.test.ts index 6fc14c7a57..cc858ccbe8 100644 --- a/tests/acceptance/goBinaryPointerMappingsCli.test.ts +++ b/tests/acceptance/goBinaryPointerMappingsCli.test.ts @@ -9,6 +9,7 @@ import { createGoPeHeaderPointerFixture, } from "../fixtures/go/image.js"; import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; +import { expectCliMcpEvidenceParity } from "../fixtures/cliMcpEvidenceParity.js"; import { parseMcpToolError } from "../fixtures/mcpToolError.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; import { cliTest, runFileCli } from "../support/cli/cliFixture.js"; @@ -55,7 +56,7 @@ cliTest( ); const response = await call("inspect_go_binary", { path }); expect(response.isError, JSON.stringify(response)).not.toBe(true); - expect(response.structuredContent).toEqual(evidence); + expectCliMcpEvidenceParity(evidence, response.structuredContent, path); expect(Buffer.compare(await readFile(path), fixture.bytes)).toBe(0); } diff --git a/tests/acceptance/rustBinaryCli.test.ts b/tests/acceptance/rustBinaryCli.test.ts index ae409a734d..0658501bb0 100644 --- a/tests/acceptance/rustBinaryCli.test.ts +++ b/tests/acceptance/rustBinaryCli.test.ts @@ -17,6 +17,7 @@ import { import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; import { cliTest } from "../support/cli/cliFixture.js"; import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; +import { expectCliMcpEvidenceParity } from "../fixtures/cliMcpEvidenceParity.js"; import { parseMcpToolError } from "../fixtures/mcpToolError.js"; cliTest( @@ -68,7 +69,7 @@ cliTest( auditable_metadata: null, }); expect(response.isError, JSON.stringify(response)).not.toBe(true); - expect(response.structuredContent).toEqual(evidence); + expectCliMcpEvidenceParity(evidence, response.structuredContent, path); } await client.ping(); }, @@ -471,7 +472,7 @@ cliTest( }); const response = await call("inspect_rust_binary", { path }); expect(response.isError, JSON.stringify(response)).not.toBe(true); - expect(response.structuredContent).toEqual(evidence); + expectCliMcpEvidenceParity(evidence, response.structuredContent, path); await client.ping(); }, 30000, @@ -649,7 +650,7 @@ cliTest( }); const response = await call("inspect_rust_binary", { path }); expect(response.isError, JSON.stringify(response)).not.toBe(true); - expect(response.structuredContent).toEqual(evidence); + expectCliMcpEvidenceParity(evidence, response.structuredContent, path); const go = await cli.run({ arguments: ["inspect-go-binary", path, "--json"], }); diff --git a/tests/acceptance/rustDependencyViews.test.ts b/tests/acceptance/rustDependencyViews.test.ts index 61ebff36fe..0d8c8915d4 100644 --- a/tests/acceptance/rustDependencyViews.test.ts +++ b/tests/acceptance/rustDependencyViews.test.ts @@ -223,7 +223,7 @@ cliTest( bundle.value.records.find( (row) => row.evidence_id === full.evidence_id, ), - ).toEqual(full); + ).toEqual({ ...full, caller_input_path: path }); const exported = join(root, `${format}-record.json`); await writeFile(exported, JSON.stringify(full)); await unlink(path); diff --git a/tests/acceptance/rustUnnamedSections.test.ts b/tests/acceptance/rustUnnamedSections.test.ts index 5e50bb2c56..b101d221f9 100644 --- a/tests/acceptance/rustUnnamedSections.test.ts +++ b/tests/acceptance/rustUnnamedSections.test.ts @@ -6,6 +6,7 @@ import { expect } from "vitest"; import { toolContract } from "../../src/contracts/toolContracts.js"; import { rustBinarySchema } from "../../src/domain/rust/rustBinary.js"; import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; +import { expectCliMcpEvidenceParity } from "../fixtures/cliMcpEvidenceParity.js"; import { RUSTC_HASH, rustElfSectionNameFixture, @@ -51,7 +52,7 @@ cliTest( ); const response = await call("inspect_rust_binary", { path }); expect(response.isError, JSON.stringify(response)).not.toBe(true); - expect(response.structuredContent).toEqual(evidence); + expectCliMcpEvidenceParity(evidence, response.structuredContent, path); expect(Buffer.compare(await readFile(path), bytes)).toBe(0); } await client.ping(); diff --git a/tests/acceptance/sqliteDatabaseCli.test.ts b/tests/acceptance/sqliteDatabaseCli.test.ts index 5c7a5ce226..d8fa1496ce 100644 --- a/tests/acceptance/sqliteDatabaseCli.test.ts +++ b/tests/acceptance/sqliteDatabaseCli.test.ts @@ -27,6 +27,7 @@ import { import { parseEvidence } from "../../src/domain/evidence.js"; import { sqliteDatabaseSchema } from "../../src/domain/sqlite/sqliteDatabase.js"; import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; +import { expectCliMcpEvidenceParity } from "../fixtures/cliMcpEvidenceParity.js"; import { parseMcpToolError } from "../fixtures/mcpToolError.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; import { cliTest, runFileCli } from "../support/cli/cliFixture.js"; @@ -556,7 +557,7 @@ cliTest.runIf(SQLITE_NATIVE_LIMITS_AVAILABLE)( row_limit: limit, }); expect(response.isError, JSON.stringify(response)).not.toBe(true); - expect(response.structuredContent).toEqual(result.json); + expectCliMcpEvidenceParity(result.json, response.structuredContent, path); } }, ); @@ -726,7 +727,11 @@ cliTest row_limit: 1, }); expect(response.isError, JSON.stringify(response)).not.toBe(true); - expect(response.structuredContent).toEqual(result.json); + expectCliMcpEvidenceParity( + result.json, + response.structuredContent, + "selected.db", + ); expect(Buffer.compare(await readFile(path), original)).toBe(0); }, ); @@ -786,7 +791,7 @@ cliTest.runIf(SQLITE_NATIVE_LIMITS_AVAILABLE)( table: "committed", }); expect(response.isError, JSON.stringify(response)).not.toBe(true); - expect(response.structuredContent).toEqual(result.json); + expectCliMcpEvidenceParity(result.json, response.structuredContent, path); const after = await Promise.all(files.map(sourceState)); expect(after).toEqual(before); }, @@ -1229,7 +1234,7 @@ cliTest.runIf(SQLITE_NATIVE_LIMITS_AVAILABLE)( schema_scope, }); expect(response.isError, JSON.stringify(response)).not.toBe(true); - expect(response.structuredContent).toEqual(result.json); + expectCliMcpEvidenceParity(result.json, response.structuredContent, path); } expect(Buffer.compare(await readFile(path), original)).toBe(0); }, @@ -1432,7 +1437,7 @@ cliTest.runIf(SQLITE_NATIVE_LIMITS_AVAILABLE)( }); const response = await call("inspect_sqlite_database", { path }); expect(response.isError, JSON.stringify(response)).not.toBe(true); - expect(response.structuredContent).toEqual(result.json); + expectCliMcpEvidenceParity(result.json, response.structuredContent, path); expect(Buffer.compare(await readFile(path), original)).toBe(0); } }, diff --git a/tests/boundary/cli/cliEvidenceCallerPath.test.ts b/tests/boundary/cli/cliEvidenceCallerPath.test.ts new file mode 100644 index 0000000000..5c2754d63c --- /dev/null +++ b/tests/boundary/cli/cliEvidenceCallerPath.test.ts @@ -0,0 +1,78 @@ +import { expect, it } from "vitest"; + +import { createEvidence } from "../../../src/domain/evidence.js"; +import { err, ok } from "../../../src/domain/result.js"; +import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js"; +import { finishCliEvidenceCommand } from "../../../src/cli/cliEvidenceCallerPath.js"; + +it("retains the original file selection when final cleanup fails", async () => { + const evidence = createEvidence( + { path: "/tmp/selected", sha256: "a".repeat(64), format: "file" }, + { id: "test", name: "Test", version: "1" }, + { operation: "inspect", parameters: {}, result: null }, + ); + const result = await finishCliEvidenceCommand( + "./selected", + async () => ok(evidence), + { + close: async () => { + throw new Error("cleanup failed"); + }, + }, + "test", + "inspect", + new AbortController().signal, + ); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected cleanup failure"); + expect(result.error).toMatchObject({ + code: "cleanup_incomplete", + details: { + partial_observation: { + evidence_id: evidence.evidence_id, + caller_input_path: "./selected", + }, + }, + }); + expect(evidence).not.toHaveProperty("caller_input_path"); +}); + +it("binds authenticated partial Evidence but preserves raw partial observations", async () => { + const evidence = createEvidence( + { path: "/tmp/selected", sha256: "a".repeat(64), format: "file" }, + { id: "test", name: "Test", version: "1" }, + { operation: "inspect", parameters: {}, result: null }, + ); + for (const [partial, expected] of [ + [ + evidence, + { evidence_id: evidence.evidence_id, caller_input_path: "./selected" }, + ], + [ + { + kind: "file-snapshot-hash", + path: "/tmp/selected", + sha256: "a".repeat(64), + bytes: 1, + }, + { kind: "file-snapshot-hash", path: "/tmp/selected", bytes: 1 }, + ], + ] as const) { + const result = await finishCliEvidenceCommand( + "./selected", + async () => + err( + new AnalysisCancelledError("inspect", { + partialObservation: partial, + }), + ), + { close: async () => {} }, + "test", + "inspect", + new AbortController().signal, + ); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected cancellation"); + expect(result.error.details?.partial_observation).toMatchObject(expected); + } +}); diff --git a/tests/boundary/cli/flutterCli.test.ts b/tests/boundary/cli/flutterCli.test.ts index 8d09fc90ae..33593345ac 100644 --- a/tests/boundary/cli/flutterCli.test.ts +++ b/tests/boundary/cli/flutterCli.test.ts @@ -110,6 +110,7 @@ it("inspects the caller-selected nonstandard ABI through the public CLI", async ]); expect(selected.exitCode).toBe(0); expect(JSON.parse(selected.stdout)).toMatchObject({ + caller_input_path: path, parameters: { path, abi: "ARM64-V8A" }, normalized_result: { abi: "ARM64-V8A", @@ -170,7 +171,11 @@ it.each([ process.exitCode = exitCode; }); const control = await serveWithCleanupRetry(command, apk, false); - expect(control).toMatchObject({ operation, confidence: "observed" }); + expect(control).toMatchObject({ + operation, + confidence: "observed", + caller_input_path: apk, + }); expect(process.exitCode).toBe(exitCode); const interrupted = await serveWithCleanupRetry(command, apk, true); expect(process.exitCode).toBe(130); @@ -180,6 +185,7 @@ it.each([ operation, partial_observation: { operation, + caller_input_path: apk, subject: { local_path: apk }, confidence: "observed", normalized_result: control.normalized_result, @@ -193,6 +199,7 @@ it.each([ resources: [expect.stringContaining("rea-flutter-")], partial_observation: { operation, + caller_input_path: apk, normalized_result: control.normalized_result, }, }, diff --git a/tests/fixtures/cliMcpEvidenceParity.ts b/tests/fixtures/cliMcpEvidenceParity.ts new file mode 100644 index 0000000000..9efe03c832 --- /dev/null +++ b/tests/fixtures/cliMcpEvidenceParity.ts @@ -0,0 +1,14 @@ +import { expect } from "vitest"; +import { parseEvidence } from "../../src/domain/evidence.js"; + +/** Compare complete Evidence while asserting the CLI-only caller spelling. */ +export const expectCliMcpEvidenceParity = ( + cli: unknown, + mcp: unknown, + callerPath: string, +): void => { + const { caller_input_path: callerInputPath, ...shared } = parseEvidence(cli); + expect(callerInputPath).toBe(callerPath); + expect(mcp).not.toHaveProperty("caller_input_path"); + expect(mcp).toEqual(shared); +};