fix(cli): prefer native byte inspection for automatic dispatch metadata
Allow the composed one-shot dispatch workflow to open a covered Mach-O without selecting a deep provider. The registry still owns selector precedence; explicit request/environment choices remain binding, while non-Mach-O and unsupported hosts retain deep symbol fallback. Requested snapshots keep ordinary binding and profile semantics. Share runtime composition and target admission with the native byte reader. Keep target parsing, resource ownership and Evidence on the original session. Validation: 13 focused files, 192 passed and 2 skipped; npm run check passed. The six previously blocked native property CLI controls now pass. Ten measured CLI workflows preserved exact metadata facts; warm raw reply token medians 1344/927, elapsed .475/.547s, CPU .691/.745s, sampled family RSS351.2/359.3MiB baseline/candidate. Baseline used explicit Ghidra workaround; true cold and full-agent costs unmeasured. No speed/memory improvement or Windows claim. Refs #4555
This commit is contained in:
1 parent
a70c42e03d
commit
060587b456
16 files changed
+234
-6
No files matched your search
@@ -248,7 +248,11 @@ const runAnalysis = async (
|
||||
} else {
|
||||
const config = dependencies.readConfiguration();
|
||||
if (!config.ok) return cliError(config.error);
|
||||
session = await dependencies.createBinarySession(config.value, logger);
|
||||
session = await (
|
||||
tool === "inspect_native_dispatch_metadata" && snapshotPath === undefined
|
||||
? dependencies.createNativeDispatchBinarySession
|
||||
: dependencies.createBinarySession
|
||||
)(config.value, logger);
|
||||
}
|
||||
return withSessionCleanup(
|
||||
session,
|
||||
|
||||
@@ -11,6 +11,11 @@ export interface DirectAnalysisDependencies {
|
||||
config: AppConfig,
|
||||
logger: Logger,
|
||||
) => BinarySession | Promise<BinarySession>;
|
||||
/** Prefer byte-backed dispatch inspection while preserving explicit selection. */
|
||||
readonly createNativeDispatchBinarySession: (
|
||||
config: AppConfig,
|
||||
logger: Logger,
|
||||
) => BinarySession | Promise<BinarySession>;
|
||||
/** Create an artifact-only session without selecting native analysis engines. */
|
||||
readonly createArtifactBinarySession: () =>
|
||||
| BinarySession
|
||||
|
||||
@@ -145,6 +145,88 @@ it("selects the sole available provider and honors explicit selection precedence
|
||||
});
|
||||
});
|
||||
|
||||
describe("inspection without deep binding", () => {
|
||||
it("retains discovery facts without resolving a deep profile for covered automatic inspection", async () => {
|
||||
const alpha = candidate("alpha", { profile: "error" });
|
||||
const beta = candidate("beta", { profile: "error" });
|
||||
const registry = new AnalysisProviderRegistry(
|
||||
[alpha.provider, beta.provider],
|
||||
"auto",
|
||||
(target) => target === DATABASE_TARGET,
|
||||
);
|
||||
expect(await registry.select(DATABASE_TARGET)).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
requestedProviderId: "auto",
|
||||
binding: null,
|
||||
candidates: [
|
||||
{
|
||||
provider: { id: "alpha" },
|
||||
selected: false,
|
||||
availability: { status: "available" },
|
||||
},
|
||||
{
|
||||
provider: { id: "beta" },
|
||||
selected: false,
|
||||
availability: { status: "available" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(alpha.created).toEqual([]);
|
||||
expect(beta.created).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps explicit request and environment choices even when auxiliary inspection covers the target", async () => {
|
||||
const alpha = candidate("alpha");
|
||||
const beta = candidate("beta");
|
||||
const registry = new AnalysisProviderRegistry(
|
||||
[alpha.provider, beta.provider],
|
||||
"beta",
|
||||
() => true,
|
||||
);
|
||||
expect(await registry.select(DATABASE_TARGET)).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
binding: { identity: { id: "beta" }, selectionSource: "environment" },
|
||||
},
|
||||
});
|
||||
expect(await registry.select(DATABASE_TARGET, "alpha")).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
binding: { identity: { id: "alpha" }, selectionSource: "request" },
|
||||
},
|
||||
});
|
||||
expect(await registry.select(DATABASE_TARGET, "auto")).toMatchObject({
|
||||
ok: true,
|
||||
value: { binding: null },
|
||||
});
|
||||
expect(await registry.select(DATABASE_TARGET, "missing")).toMatchObject({
|
||||
ok: false,
|
||||
error: { reason: "unknown_provider" },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves ordinary ambiguity when auxiliary inspection does not cover the target", async () => {
|
||||
const registry = new AnalysisProviderRegistry(
|
||||
[candidate("alpha").provider, candidate("beta").provider],
|
||||
"auto",
|
||||
() => false,
|
||||
);
|
||||
expect(await registry.select(DATABASE_TARGET)).toMatchObject({
|
||||
ok: false,
|
||||
error: { reason: "ambiguous" },
|
||||
});
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
expect(
|
||||
await registry.select(DATABASE_TARGET, undefined, {
|
||||
signal: controller.signal,
|
||||
}),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("analysis provider registry: selection failures", () => {
|
||||
it("returns actionable unknown, unavailable, and unsupported failures", async () => {
|
||||
const unavailable = candidate("alpha", { available: false });
|
||||
|
||||
@@ -83,6 +83,9 @@ export class AnalysisProviderRegistry {
|
||||
constructor(
|
||||
providers: readonly AnalysisProviderCandidate[],
|
||||
defaultSelector: AnalysisProviderSelector = "auto",
|
||||
private readonly canInspectWithoutDeepProvider?: (
|
||||
target: BinaryTarget,
|
||||
) => boolean,
|
||||
) {
|
||||
this.#defaultSelector =
|
||||
analysisProviderSelectorSchema.parse(defaultSelector);
|
||||
@@ -209,6 +212,12 @@ export class AnalysisProviderRegistry {
|
||||
const baseline = this.candidates(target);
|
||||
if (signalIsAborted(options.signal))
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
if (selector === "auto" && this.canInspectWithoutDeepProvider?.(target))
|
||||
return ok({
|
||||
requestedProviderId: selector,
|
||||
binding: null,
|
||||
candidates: baseline,
|
||||
});
|
||||
if (
|
||||
request === undefined &&
|
||||
this.#byId.has(selector) &&
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isAppleDispatchMetadataTarget } from "../domain/native/objcSwiftMetadata.js";
|
||||
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
|
||||
import type { AppConfig } from "../config/types.js";
|
||||
import type { BinarySession } from "../application/binary/BinarySession.js";
|
||||
@@ -21,18 +22,49 @@ export const createBinarySession = (
|
||||
config: AppConfig,
|
||||
logger: Logger,
|
||||
selectedEnvironment: Readonly<NodeJS.ProcessEnv>,
|
||||
): BinarySession =>
|
||||
composeDeepBinarySession(config, logger, selectedEnvironment);
|
||||
|
||||
/** Prefer byte-backed dispatch inspection while retaining deep symbol fallback. */
|
||||
export const createNativeDispatchBinarySession = (
|
||||
config: AppConfig,
|
||||
logger: Logger,
|
||||
selectedEnvironment: Readonly<NodeJS.ProcessEnv>,
|
||||
): BinarySession =>
|
||||
composeDeepBinarySession(
|
||||
config,
|
||||
logger,
|
||||
selectedEnvironment,
|
||||
"inspect_native_dispatch_metadata",
|
||||
);
|
||||
|
||||
const composeDeepBinarySession = (
|
||||
config: AppConfig,
|
||||
logger: Logger,
|
||||
selectedEnvironment: Readonly<NodeJS.ProcessEnv>,
|
||||
inspection?: "inspect_native_dispatch_metadata",
|
||||
): BinarySession => {
|
||||
const environment = snapshotEnvironment(selectedEnvironment);
|
||||
const hopper = new HopperProvider(config, logger, environment);
|
||||
const ghidra = new GhidraProvider(config, logger, environment);
|
||||
const ida = new IdaProvider(config, environment);
|
||||
const kuna = new KunaProvider(environment);
|
||||
const auxiliaryDeclarations =
|
||||
auxiliaryAnalysisProviderDeclarations(environment);
|
||||
const nativeInspectionAvailable =
|
||||
inspection !== undefined &&
|
||||
auxiliaryDeclarations.some(({ capabilities }) =>
|
||||
capabilities.some(
|
||||
({ operation, available }) => operation === inspection && available,
|
||||
),
|
||||
);
|
||||
return composeBinarySession(
|
||||
new AnalysisProviderRegistry(
|
||||
[hopper, ghidra, ida, kuna],
|
||||
config.analysisProvider,
|
||||
nativeInspectionAvailable ? isAppleDispatchMetadataTarget : undefined,
|
||||
),
|
||||
auxiliaryAnalysisProviderDeclarations(environment).map(
|
||||
auxiliaryDeclarations.map(
|
||||
(declaration) => new LazyAnalysisProvider(declaration),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -28,6 +28,10 @@ export const createDirectAnalysis = (
|
||||
const { createManagedBinarySession } = await import("./binary.js");
|
||||
return createManagedBinarySession();
|
||||
},
|
||||
createNativeDispatchBinarySession: async (config, logger) => {
|
||||
const { createNativeDispatchBinarySession } = await import("./binary.js");
|
||||
return createNativeDispatchBinarySession(config, logger, environment);
|
||||
},
|
||||
createNativeBinarySession: async () => {
|
||||
const { createNativeBinarySession } = await import("./binary.js");
|
||||
return createNativeBinarySession(environment);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { isMachOFormat, type BinaryTarget } from "../binaryTargetTypes.js";
|
||||
import { providerSchema } from "../evidence.js";
|
||||
import { isSwiftMangledSymbolName } from "../swiftSymbolClassification.js";
|
||||
|
||||
@@ -7,6 +8,10 @@ import {
|
||||
compositeKey,
|
||||
} from "../unicodeCodePointOrder.js";
|
||||
|
||||
/** Targets admitted to the Apple byte reader; the reader validates slice support. */
|
||||
export const isAppleDispatchMetadataTarget = (target: BinaryTarget): boolean =>
|
||||
target.kind === "executable" && isMachOFormat(target.format);
|
||||
|
||||
/** Objective-C method type. */
|
||||
export const objcMethodTypeSchema = z.enum([
|
||||
"instance",
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
isMachOFormat,
|
||||
type BinaryTarget,
|
||||
} from "../domain/binaryTargetTypes.js";
|
||||
import { isAppleDispatchMetadataTarget } from "../domain/native/objcSwiftMetadata.js";
|
||||
import { toolContract } from "../contracts/toolContracts.js";
|
||||
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
|
||||
import type { EvidenceLocation } from "../domain/evidence.js";
|
||||
@@ -186,10 +187,7 @@ class NativeMacOSClient implements AnalysisClient {
|
||||
},
|
||||
),
|
||||
);
|
||||
if (
|
||||
this.target.kind !== "executable" ||
|
||||
!isMachOFormat(this.target.format)
|
||||
)
|
||||
if (!isAppleDispatchMetadataTarget(this.target))
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
IDENTITY.id,
|
||||
|
||||
@@ -112,3 +112,53 @@ describe.skipIf(process.platform !== "darwin")(
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
describe.skipIf(process.platform !== "darwin")(
|
||||
"native dispatch provider choice",
|
||||
() => {
|
||||
cliTest.for([
|
||||
[[], "missing", 1, "unknown_provider"],
|
||||
[["--provider", "missing"], "auto", 1, "unknown_provider"],
|
||||
[["--provider", "auto"], "missing", 0, null],
|
||||
] as const)(
|
||||
"preserves explicit selection %j",
|
||||
async ([options, configured, exitCode, reason], { cli }) => {
|
||||
const directory = await createTestTempDirectory(
|
||||
"rea-dispatch-provider-choice-",
|
||||
);
|
||||
const path = join(directory, "fixture.bin");
|
||||
await writeFile(path, machoWithPropertyAttributes("Tq,N"));
|
||||
const output = await cli.run({
|
||||
arguments: [
|
||||
"inspect-native-dispatch-metadata",
|
||||
path,
|
||||
...options,
|
||||
"--json",
|
||||
],
|
||||
environment: {
|
||||
REA_LOG_LEVEL: "silent",
|
||||
REA_ANALYSIS_PROVIDER: configured,
|
||||
},
|
||||
});
|
||||
expect(output.exitCode, `${output.stdout}\n${output.stderr}`).toBe(
|
||||
exitCode,
|
||||
);
|
||||
if (reason !== null)
|
||||
expect(output.json).toMatchObject({
|
||||
code: "capability_unavailable",
|
||||
details: {
|
||||
selection_reason: reason,
|
||||
requested_provider_id: "missing",
|
||||
},
|
||||
});
|
||||
else
|
||||
expect(output.json).toMatchObject({
|
||||
normalized_result: {
|
||||
provider: { id: "native-macos" },
|
||||
result: { objc_classes: [{ name: "Fixture" }] },
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -246,6 +246,8 @@ const createSnapshotReplayFixture = async (
|
||||
readConfiguration: () => parseConfig({}),
|
||||
createBinarySession: () => createTestBinarySession(provider),
|
||||
createArtifactBinarySession: () => createTestBinarySession(provider),
|
||||
createNativeDispatchBinarySession: () =>
|
||||
createTestBinarySession(provider),
|
||||
createNativeBinarySession: () => createTestBinarySession(provider),
|
||||
createManagedBinarySession: () => createTestBinarySession(provider),
|
||||
};
|
||||
@@ -1003,6 +1005,7 @@ describe("workflow snapshot profile and cancellation binding", () => {
|
||||
readConfiguration: () => parseConfig({}),
|
||||
createBinarySession: () => session,
|
||||
createArtifactBinarySession: () => session,
|
||||
createNativeDispatchBinarySession: () => session,
|
||||
createNativeBinarySession: () => session,
|
||||
createManagedBinarySession: () => session,
|
||||
};
|
||||
|
||||
@@ -71,6 +71,7 @@ describe("official operation arguments", () => {
|
||||
readConfiguration: () => parseConfig({}),
|
||||
createBinarySession: createSession,
|
||||
createArtifactBinarySession: createSession,
|
||||
createNativeDispatchBinarySession: createSession,
|
||||
createNativeBinarySession: createSession,
|
||||
createManagedBinarySession: createSession,
|
||||
};
|
||||
|
||||
@@ -331,6 +331,7 @@ describe("MCP composed workflow snapshot replay", () => {
|
||||
readConfiguration: () => parseConfig({}),
|
||||
createBinarySession: () => createWorkflowSession(provider),
|
||||
createArtifactBinarySession: () => createWorkflowSession(provider),
|
||||
createNativeDispatchBinarySession: () => createWorkflowSession(provider),
|
||||
createNativeBinarySession: () => createWorkflowSession(provider),
|
||||
createManagedBinarySession: () => createWorkflowSession(provider),
|
||||
};
|
||||
|
||||
@@ -62,6 +62,7 @@ const factories = (
|
||||
readConfiguration: () => parseConfig({}),
|
||||
createBinarySession: () => session,
|
||||
createArtifactBinarySession: () => session,
|
||||
createNativeDispatchBinarySession: () => session,
|
||||
createNativeBinarySession: () => session,
|
||||
createManagedBinarySession: () => session,
|
||||
};
|
||||
|
||||
@@ -37,6 +37,7 @@ const recordingFactories = (failure?: Error) => {
|
||||
readConfiguration: () => parseConfig({}),
|
||||
createBinarySession: createSession,
|
||||
createArtifactBinarySession: createSession,
|
||||
createNativeDispatchBinarySession: createSession,
|
||||
createNativeBinarySession: createSession,
|
||||
createManagedBinarySession: createSession,
|
||||
};
|
||||
@@ -104,6 +105,36 @@ describe("one-shot analysis factory boundary", () => {
|
||||
expect(factories.sessions[0]?.activeTarget()).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([undefined, "/fixture/committed.snapshot"])(
|
||||
"selects the dispatch inspection factory only without a requested snapshot (%s)",
|
||||
async (snapshotPath) => {
|
||||
const factories = recordingFactories();
|
||||
const expected = new Error("selected factory");
|
||||
const unexpected = (): never => {
|
||||
throw new Error("wrong session policy");
|
||||
};
|
||||
const selected = (): never => {
|
||||
throw expected;
|
||||
};
|
||||
await expect(
|
||||
runDirectAnalysis(
|
||||
{
|
||||
...factories.dependencies,
|
||||
createBinarySession:
|
||||
snapshotPath === undefined ? unexpected : selected,
|
||||
createNativeDispatchBinarySession:
|
||||
snapshotPath === undefined ? selected : unexpected,
|
||||
},
|
||||
"/fixture/target",
|
||||
"inspect_native_dispatch_metadata",
|
||||
{},
|
||||
{ snapshotPath },
|
||||
),
|
||||
).rejects.toBe(expected);
|
||||
expect(factories.sessions).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"inspect_macho",
|
||||
"inspect_signature",
|
||||
|
||||
@@ -36,6 +36,7 @@ it("retains provider coverage caveats in a composed CLI result and closes the se
|
||||
createBinarySession: createSession,
|
||||
createManagedBinarySession: createSession,
|
||||
createArtifactBinarySession: createSession,
|
||||
createNativeDispatchBinarySession: createSession,
|
||||
createNativeBinarySession: createSession,
|
||||
},
|
||||
path,
|
||||
|
||||
@@ -83,6 +83,7 @@ const fixture = (
|
||||
readConfiguration: () => parseConfig({}),
|
||||
createBinarySession: createSession,
|
||||
createArtifactBinarySession: createSession,
|
||||
createNativeDispatchBinarySession: createSession,
|
||||
createNativeBinarySession: createSession,
|
||||
createManagedBinarySession: createSession,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user