fix(analysis): retain one-shot target selection provenance

Retain the exact selected CLI path in one-shot provider Evidence while preserving canonical subject identity and provider inputs. The shared request-provenance boundary replaces the artifact-only special case across native, managed, and bundle-resource routes. Remove the redundant parameter fallback and make the private managed helper receive explicit Evidence parameters.

Validation: real macOS codesign fixtures through the compiled CLI retained app aliases for signature, plist, and absent asset inspections, with direct-target controls. Archive selection, managed direct analysis, and snapshot replay regressions passed; Windows-specific replay cases were skipped on macOS. Build, static, format, and docs checks passed. Snapshot-backed direct request provenance still follows its existing authenticated query contract.

Refs: #5479
This commit is contained in:
morluto
2026-10-25 01:44:23 +08:00
parent 84c30609e1
commit 535926ca2b
3 changed files with 140 additions and 11 deletions
+5
View File
@@ -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
+20 -11
View File
@@ -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<Record<string, JsonValue>>,
evidenceParameters: Readonly<Record<string, JsonValue>>,
signal?: AbortSignal,
): Promise<Result<Evidence, AnalysisError>> => {
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<Record<string, JsonValue>>;
readonly createSession?: () => BinarySession | Promise<BinarySession>;
readonly providerId?: AnalysisProviderSelector;
readonly formatHint?: ExecutableFormatHint;
@@ -304,6 +315,7 @@ const runAnalysis = async (
},
): Promise<JsonValue> => {
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<Record<string, JsonValue>>;
readonly evidenceParameters: Readonly<Record<string, JsonValue>>;
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
? {}
@@ -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"),
'<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict><key>CFBundleExecutable</key><string>Probe</string><key>CFBundleIdentifier</key><string>org.example.rea.probe</string><key>CFBundlePackageType</key><string>APPL</string></dict></plist>',
);
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);
}
}
},
);
},
);