diff --git a/docs/tool-design.md b/docs/tool-design.md index d3c0f9fe1f..f6dbad333f 100644 --- a/docs/tool-design.md +++ b/docs/tool-design.md @@ -53,6 +53,11 @@ satisfy. A plan without execution does not establish runtime behavior. - Use strict inputs with explicit target identity and required fields. Preserve meaningful target, action, capture, and output choices; remove ignored options and repeated permission declarations. + One-shot CLI provider inspections retain the caller's exact target spelling + in Evidence `parameters.input_path`; the subject keeps the canonical inspected + file identity. Keep request provenance separate from provider arguments. + Snapshot entries bind Evidence parameters to provider queries, so changes to + snapshot-backed request provenance must preserve that cache contract. - Return the requested facts inline with artifact/provider identity, source locations, Evidence references, coverage, and relevant confidence and limitations. Use supported selection, summaries or views when they answer diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index e3a2045aae..4a46c45dc9 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -195,12 +195,16 @@ export const runProviderAnalysis = async ( ]), ); return withProcessCancellation(signal, async (operationSignal) => { + // The selected CLI path is a request fact. Keep it out of provider inputs + // and target identity, which may use the resolved executable instead. + const evidenceParameters = { ...arguments_, input_path: path }; if (isManagedToolName(tool)) { const original = await runManagedProviderAnalysis( dependencies, path, tool, arguments_, + evidenceParameters, operationSignal, ); await dispatchQueuedInterrupts(); @@ -227,6 +231,7 @@ export const runProviderAnalysis = async ( logger, snapshotPath: undefined, signal: operationSignal, + evidenceParameters, ...(ARTIFACT_ANALYSIS_OPERATION_SET.has(tool) ? { createSession: dependencies.createArtifactBinarySession } : NATIVE_INSPECTION_OPERATIONS.has(tool) @@ -265,6 +270,7 @@ const runManagedProviderAnalysis = async ( path: string, tool: ManagedToolName, arguments_: Readonly>, + evidenceParameters: Readonly>, signal?: AbortSignal, ): Promise> => { const execution = await runManagedProviderExecution( @@ -277,9 +283,13 @@ const runManagedProviderAnalysis = async ( if (!execution.ok) return execution; const value = execution.value; return ok( - createAnalysisExecutionEvidence(tool, arguments_, value, "observed", { - immutable: true, - }), + createAnalysisExecutionEvidence( + tool, + evidenceParameters, + value, + "observed", + { immutable: true }, + ), ); }; @@ -296,6 +306,7 @@ const runAnalysis = async ( readonly logger: Logger; readonly snapshotPath: string | undefined; readonly signal: AbortSignal; + readonly evidenceParameters?: Readonly>; readonly createSession?: () => BinarySession | Promise; readonly providerId?: AnalysisProviderSelector; readonly formatHint?: ExecutableFormatHint; @@ -304,6 +315,7 @@ const runAnalysis = async ( }, ): Promise => { const { logger, signal, snapshotPath } = options; + const evidenceParameters = options.evidenceParameters ?? arguments_; let session: BinarySession; if (options.createSession !== undefined) { session = await options.createSession(); @@ -332,7 +344,7 @@ const runAnalysis = async ( return ok( createAnalysisExecutionEvidence( tool, - arguments_, + evidenceParameters, execution.value, "observed", ), @@ -427,9 +439,9 @@ const runAnalysis = async ( const executed = await executeAnalysisTool({ session, openedTarget: opened.value, - selectedPath: path, tool, arguments: arguments_, + evidenceParameters, signal, evidenceProfile, }); @@ -523,13 +535,13 @@ const runAnalysis = async ( const executeAnalysisTool = async (input: { readonly session: BinarySession; readonly openedTarget: BinaryTarget; - readonly selectedPath: string; readonly tool: | NativeToolName | ArtifactAnalysisOperation | ManagedToolName | DirectAnalysisTool; readonly arguments: Readonly>; + readonly evidenceParameters: Readonly>; readonly signal: AbortSignal; readonly evidenceProfile: AnalysisProfileCommitment | undefined; }): Promise< @@ -573,7 +585,7 @@ const executeAnalysisTool = async (input: { const evidence = createWorkflowEvidence({ target: input.openedTarget, operation: tool, - parameters: input.arguments, + parameters: input.evidenceParameters, result: result.value.result, upstreamProfile: evidenceProfile, upstreamLimitations: result.value.limitations, @@ -621,10 +633,7 @@ const executeAnalysisTool = async (input: { result.value.provider, { operation: tool, - parameters: - tool === "inspect_artifact" - ? { ...input.arguments, input_path: input.selectedPath } - : input.arguments, + parameters: input.evidenceParameters, result: result.value.result, ...(result.value.analysisProfile === undefined ? {} diff --git a/tests/boundary/cli/nativeSignatureCallerSelection.test.ts b/tests/boundary/cli/nativeSignatureCallerSelection.test.ts new file mode 100644 index 0000000000..326a5dbac7 --- /dev/null +++ b/tests/boundary/cli/nativeSignatureCallerSelection.test.ts @@ -0,0 +1,115 @@ +import { execFile } from "node:child_process"; +import { copyFile, mkdir, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +import { describe, expect } from "vitest"; + +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { inspectPlistSchema } from "../../../src/domain/native/nativeInspection.js"; +import { appleAssetCatalogResultSchema } from "../../../src/domain/apple/appleAssetCatalog.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const run = promisify(execFile); +const environment = { + REA_LOG_LEVEL: "silent", + REA_ANALYSIS_PROVIDER: "auto", +} as const; + +const signedApp = async () => { + const root = await createTestTempDirectory("rea-selected-app-"); + const app = join(root, "Probe.app"); + const executable = join(app, "Contents/MacOS/Probe"); + await mkdir(join(app, "Contents/MacOS"), { recursive: true }); + await copyFile("/usr/bin/true", executable); + await writeFile( + join(app, "Contents/Info.plist"), + 'CFBundleExecutableProbeCFBundleIdentifierorg.example.rea.probeCFBundlePackageTypeAPPL', + ); + await run("/usr/bin/codesign", ["-s", "-", "-f", app]); + await symlink("Probe.app", join(root, "Selected.app")); + return { root, app, executable }; +}; + +describe.skipIf(process.platform !== "darwin")( + "native app CLI caller selection", + () => { + cliTest( + "retains the selected app alias across signature, plist, and absent asset inspections", + async ({ cli }) => { + const { root, app, executable } = await signedApp(); + for (const command of [ + "inspect-signature", + "inspect-plist", + "inspect-asset-catalog", + ]) { + const selectedRun = await cli.run({ + arguments: [command, "Selected.app", "--json"], + cwd: root, + environment, + }); + const directRun = await cli.run({ + arguments: [command, "Probe.app", "--json"], + cwd: root, + environment, + }); + expect(selectedRun.exitCode, selectedRun.stderr).toBe(0); + expect(directRun.exitCode, directRun.stderr).toBe(0); + const selected = parseEvidence(selectedRun.json); + const direct = parseEvidence(directRun.json); + expect(selected.parameters).toMatchObject({ + input_path: "Selected.app", + }); + expect(direct.parameters).toMatchObject({ input_path: "Probe.app" }); + expect(selected.subject).toEqual(direct.subject); + if (command === "inspect-signature") { + expect(selected.subject?.local_path).toBe(executable); + expect(selected.normalized_result).toEqual( + direct.normalized_result, + ); + } else if (command === "inspect-plist") { + const aliasResult = inspectPlistSchema.parse( + selected.normalized_result, + ); + const directResult = inspectPlistSchema.parse( + direct.normalized_result, + ); + expect(aliasResult.resource_root).toMatchObject({ + caller_path: join(root, "Selected.app"), + canonical_path: app, + }); + expect(directResult.resource_root).toMatchObject({ + caller_path: app, + canonical_path: app, + }); + expect({ + ...aliasResult, + resource_root: directResult.resource_root, + }).toEqual(directResult); + } else { + const aliasResult = appleAssetCatalogResultSchema.parse( + selected.normalized_result, + ); + const directResult = appleAssetCatalogResultSchema.parse( + direct.normalized_result, + ); + expect(aliasResult.catalogs).toEqual([]); + expect(aliasResult.resource_root).toMatchObject({ + caller_path: join(root, "Selected.app"), + canonical_path: app, + }); + expect(directResult.resource_root).toMatchObject({ + caller_path: app, + canonical_path: app, + }); + expect({ + ...aliasResult, + resource_root: directResult.resource_root, + }).toEqual(directResult); + } + } + }, + ); + }, +);