refactor(provider): declare deep capabilities from source (#892)

This commit is contained in:
ysopwnable 2026-10-07 15:41:50 +08:00 committed by GitHub
parent 4037c6545d
commit b808e2d6bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 220 additions and 133 deletions

View File

@ -225,6 +225,27 @@ explicit downloader and fixed manifest belong to `scripts/fixtures/android/`.
JADX CLI/MCP parity requires the fixed APK and existing audited JAR; synthetic
protocol/cancellation success does not establish that engine or an unverified host.
## Deep-provider source declarations
Hopper, Ghidra and IDA declare their implemented analyst operations in their
own `*ProviderCapabilities.ts` modules. Hopper's inventory is typed against the
existing direct-operation contracts; Ghidra reuses its inventory/function adapter
operations; IDA retains its read-only operation family. Frozen operation arrays
feed the existing capability builders without importing generated catalog data.
The catalog generator reads these source declarations and joins operation names
to canonical `TOOL_CONTRACTS`. A missing contract identifies the declaring provider
and operation. Public schemas and descriptions remain owned by contracts; the
declarations describe implementation coverage. Provider adapters must not consume
the generated MCP catalog that is derived from them. The resolved module guard
checks this edge while admitting canonical source contracts, generated package
metadata and the existing application snapshot-cache consumer.
Exact provider effects and lifecycle differences remain in their adapters:
Hopper's GUI and mutation effects, Ghidra's experimental Windows authority and
mutation restrictions, and IDA's attached/headless effects and live observations.
This source increment introduces no common provider lifecycle or new interface.
## Binary application ownership
`src/application/binary/` owns the active session, deep-provider registry and

View File

@ -96,6 +96,21 @@ export const toolFamilyCatalog = (sources) => {
/** Build provider identities with their sorted capability names. */
export const providerCatalog = (sources) => {
const contractsByName = new Map(
sources.toolContracts.TOOL_CONTRACTS.map((contract) => [
contract.name,
contract,
]),
);
const declaredContracts = (providerId, operations) =>
operations.map((operation) => {
const contract = contractsByName.get(operation);
if (contract === undefined)
throw new TypeError(
`Provider ${providerId} declares an unknown analyst operation: ${operation}`,
);
return contract;
});
const {
applicationContracts,
reconciliationContracts,
@ -105,15 +120,21 @@ export const providerCatalog = (sources) => {
return [
{
identity: sources.hopperProvider.HOPPER_PROVIDER_IDENTITY,
contracts: sources.hopperProvider.HOPPER_PROVIDER_TOOL_CONTRACTS,
contracts: declaredContracts(
"hopper",
sources.hopperProvider.HOPPER_OPERATIONS,
),
},
{
identity: sources.ghidraProvider.GHIDRA_PROVIDER_IDENTITY,
contracts: sources.ghidraProvider.GHIDRA_PROVIDER_TOOL_CONTRACTS,
contracts: declaredContracts(
"ghidra",
sources.ghidraProvider.GHIDRA_OPERATIONS,
),
},
{
identity: sources.idaProvider.IDA_PROVIDER_IDENTITY,
contracts: sources.idaProvider.IDA_PROVIDER_TOOL_CONTRACTS,
contracts: declaredContracts("ida", sources.idaProvider.IDA_OPERATIONS),
},
{
identity: sources.nativeProvider.NATIVE_MACOS_PROVIDER_IDENTITY,

View File

@ -59,8 +59,8 @@ const SOURCE_PATHS = {
"dist/contracts/javascriptRecoveryToolContracts.js",
javascriptRecoveryProvider: "dist/javascript/recovery/WakaruRelease.js",
supportedClients: "dist/application/SupportedClients.js",
hopperProvider: "dist/hopper/HopperProvider.js",
ghidraProvider: "dist/ghidra/GhidraProvider.js",
hopperProvider: "dist/hopper/HopperProviderCapabilities.js",
ghidraProvider: "dist/ghidra/GhidraProviderCapabilities.js",
idaProvider: "dist/ida/IdaProviderCapabilities.js",
nativeProvider: "dist/native/NativeMacOSProviderMetadata.js",
artifactProviders: "dist/application/InvestigationProviders.js",

View File

@ -7,7 +7,8 @@ export interface ModuleBoundaryViolation {
readonly boundary:
| "pure-layer"
| "application-composition"
| "provider-construction";
| "provider-construction"
| "provider-generated-catalog";
}
/** Check resolved source ownership, including type imports and reexports. */

View File

@ -13,6 +13,13 @@ const MIGRATED_PROVIDER_ROOTS = new Set([
"inspector",
"javascript",
]);
const PROVIDER_ROOTS = new Set([
...MIGRATED_PROVIDER_ROOTS,
"artifacts",
"browser",
"dotnet",
"native",
]);
const repositoryPath = (root, path) =>
relative(root, path).replaceAll("\\", "/");
@ -53,6 +60,11 @@ const failedBoundary = (file, target) => {
if (!file.startsWith("src/") || !target.startsWith("src/")) return undefined;
const layer = file.split("/")[1];
const targetLayer = target.split("/")[1];
if (
PROVIDER_ROOTS.has(layer) &&
/^src\/generatedMcpToolCatalog\.(?:js|ts)$/u.test(target)
)
return "provider-generated-catalog";
if (PURE_LAYERS.has(layer)) {
if (
(/^src\/[^/]+\//u.test(target) && !PURE_LAYERS.has(targetLayer)) ||

View File

@ -10,7 +10,7 @@ import {
CAPABILITIES,
windowsP0Capabilities,
} from "./GhidraProviderCapabilities.js";
import { HOPPER_PROVIDER_TOOL_CONTRACTS } from "../hopper/HopperProvider.js";
import { HOPPER_OPERATIONS } from "../hopper/HopperProvider.js";
const result = () => {
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
@ -105,10 +105,6 @@ describe("Ghidra function annotations", () => {
(c) => c.operation === "annotate_native_function",
),
).toMatchObject({ available: false });
expect(
HOPPER_PROVIDER_TOOL_CONTRACTS.some(
(c) => c.name === "annotate_native_function",
),
).toBe(false);
expect(HOPPER_OPERATIONS).not.toContain("annotate_native_function");
});
});

View File

@ -13,7 +13,7 @@ import type { BinaryTarget } from "../domain/binaryTarget.js";
import { createAnalysisProfile } from "../domain/analysisProfile.js";
import {
GHIDRA_PROVIDER_IDENTITY,
GHIDRA_PROVIDER_TOOL_CONTRACTS,
GHIDRA_OPERATIONS,
GhidraProvider,
type GhidraProviderClientFactory,
} from "./GhidraProvider.js";
@ -140,9 +140,9 @@ describe("Ghidra provider", () => {
const ghidra = provider(host);
expect(ghidra.identity()).toEqual(GHIDRA_PROVIDER_IDENTITY);
expect(ghidra.capabilities().map(({ operation }) => operation)).toEqual(
GHIDRA_PROVIDER_TOOL_CONTRACTS.map(({ name }) => name),
);
expect(ghidra.capabilities().map(({ operation }) => operation)).toEqual([
...GHIDRA_OPERATIONS,
]);
expect(ghidra.capabilities()).toEqual(
expect.arrayContaining([
expect.objectContaining({

View File

@ -34,7 +34,7 @@ import {
CAPABILITIES,
windowsP0Capabilities,
GHIDRA_PROVIDER_IDENTITY,
GHIDRA_PROVIDER_TOOL_CONTRACTS,
GHIDRA_OPERATIONS,
} from "./GhidraProviderCapabilities.js";
import {
createGhidraProviderClient,
@ -46,7 +46,7 @@ import {
windowsNativeCapabilities,
} from "../process/WindowsAuthority.js";
export { GHIDRA_PROVIDER_IDENTITY, GHIDRA_PROVIDER_TOOL_CONTRACTS };
export { GHIDRA_PROVIDER_IDENTITY, GHIDRA_OPERATIONS };
export type { GhidraProviderClientFactory };
const SUPPORTED_ARCHITECTURES = new Set(["x86", "x86_64", "arm", "arm64"]);

View File

@ -6,7 +6,6 @@ import {
hasWindowsNativeAuthority,
windowsNativeAuthorityUnavailableReason,
} from "../process/WindowsAuthority.js";
import { GENERATED_MCP_TOOL_CATALOG } from "../generatedMcpToolCatalog.js";
import { GHIDRA_FUNCTION_OPERATIONS } from "./GhidraFunctionValues.js";
import { GHIDRA_INVENTORY_OPERATIONS } from "./GhidraInventoryValues.js";
@ -17,25 +16,11 @@ export const GHIDRA_PROVIDER_IDENTITY: ProviderIdentity = Object.freeze({
version: null,
});
const providerContractByName = new Map(
GENERATED_MCP_TOOL_CATALOG.filter(
({ kind }) => kind === "official-proxy" || kind === "enhanced",
).map((contract) => [contract.name, contract]),
);
/** Provider-neutral contracts implemented by the Ghidra adapter. */
export const GHIDRA_PROVIDER_TOOL_CONTRACTS = Object.freeze(
[...GHIDRA_INVENTORY_OPERATIONS, ...GHIDRA_FUNCTION_OPERATIONS].map(
(operation) => {
const contract = providerContractByName.get(operation);
if (contract === undefined)
throw new TypeError(
`Missing provider-neutral contract for ${operation}`,
);
return contract;
},
),
);
/** Analyst operations implemented by the Ghidra inventory and function adapters. */
export const GHIDRA_OPERATIONS = Object.freeze([
...GHIDRA_INVENTORY_OPERATIONS,
...GHIDRA_FUNCTION_OPERATIONS,
]);
/** Health limitations shared by every Ghidra-backed capability. */
export const healthLimitations = Object.freeze([
@ -157,10 +142,7 @@ export const limitationsFor = (operation: string): readonly string[] => {
/** Provider-neutral capabilities advertised by every non-Windows Ghidra session. */
export const CAPABILITIES: readonly CapabilityDescriptor[] = Object.freeze(
GHIDRA_PROVIDER_TOOL_CONTRACTS.map((contract) => {
const operation = contract.analysisOperation;
if (operation === null)
throw new TypeError(`Missing analysis operation for ${contract.name}`);
GHIDRA_OPERATIONS.map((operation) => {
return Object.freeze({
provider: GHIDRA_PROVIDER_IDENTITY,
operation,
@ -175,7 +157,7 @@ export const CAPABILITIES: readonly CapabilityDescriptor[] = Object.freeze(
changesPermissions: false,
requiresRoot: false,
}),
limitations: Object.freeze(limitationsFor(contract.name)),
limitations: Object.freeze(limitationsFor(operation)),
});
}),
);

View File

@ -2,10 +2,7 @@ import { describe, expect, it } from "vitest";
import { parseConfig } from "../config.js";
import { silentLogger } from "../logger.js";
import {
HopperProvider,
HOPPER_PROVIDER_TOOL_CONTRACTS,
} from "./HopperProvider.js";
import { HopperProvider, HOPPER_OPERATIONS } from "./HopperProvider.js";
describe("Hopper provider capabilities", () => {
it("declines DOS MZ targets with a provider-specific support reason", () => {
@ -35,9 +32,9 @@ describe("Hopper provider capabilities", () => {
const provider = new HopperProvider(config.value, silentLogger);
const capabilities = provider.capabilities();
const published = structuredClone(capabilities);
expect(capabilities.map(({ operation }) => operation)).toEqual(
HOPPER_PROVIDER_TOOL_CONTRACTS.map(({ name }) => name),
);
expect(capabilities.map(({ operation }) => operation)).toEqual([
...HOPPER_OPERATIONS,
]);
expect(new Set(capabilities.map(({ operation }) => operation)).size).toBe(
capabilities.length,
);

View File

@ -18,7 +18,6 @@ import type { BinaryTarget } from "../domain/binaryTarget.js";
import type { Logger } from "../logger.js";
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
import { err } from "../domain/result.js";
import { GENERATED_MCP_TOOL_CATALOG } from "../generatedMcpToolCatalog.js";
import { HopperApplicationLauncher } from "./BridgeLauncher.js";
import {
hopperLoaderArgsForTarget,
@ -26,63 +25,17 @@ import {
} from "./HopperAnalysisProfile.js";
import { HopperClient } from "./HopperClient.js";
/** Public identity committed by every Hopper-backed observation. */
export const HOPPER_PROVIDER_IDENTITY: ProviderIdentity = Object.freeze({
id: "hopper",
name: "Hopper Disassembler",
version: null,
});
import {
CAPABILITIES,
HOPPER_PROVIDER_IDENTITY,
} from "./HopperProviderCapabilities.js";
export {
HOPPER_PROVIDER_IDENTITY,
HOPPER_OPERATIONS,
} from "./HopperProviderCapabilities.js";
const IDENTITY = HOPPER_PROVIDER_IDENTITY;
const MUTATING_OPERATIONS = new Set([
"set_address_name",
"set_addresses_names",
"set_bookmark",
"set_comment",
"set_inline_comment",
"unset_bookmark",
]);
/** Tool contracts implemented directly by the Hopper adapter. */
export const HOPPER_PROVIDER_TOOL_CONTRACTS = Object.freeze([
...GENERATED_MCP_TOOL_CATALOG.filter(
({ kind, name }) =>
kind === "official-proxy" &&
name !== "annotate_native_function" &&
name !== "inspect_native_load_image" &&
name !== "inspect_native_data_type" &&
name !== "inspect_native_instruction" &&
name !== "resolve_native_call_targets",
),
...GENERATED_MCP_TOOL_CATALOG.filter(
({ name }) => name === "analyze_function",
),
]);
const CAPABILITIES: readonly CapabilityDescriptor[] = Object.freeze(
HOPPER_PROVIDER_TOOL_CONTRACTS.map((contract) => {
const operation = contract.analysisOperation;
if (operation === null)
throw new TypeError(`Missing analysis operation for ${contract.name}`);
return Object.freeze({
provider: IDENTITY,
operation,
available: true,
reason: null,
effects: Object.freeze({
mutatesArtifact: MUTATING_OPERATIONS.has(contract.name),
launchesProcess: true,
mayShowUi: true,
mayAccessNetwork: false,
mayWriteFilesystem: MUTATING_OPERATIONS.has(contract.name),
changesPermissions: false,
requiresRoot: false,
}),
limitations: Object.freeze([
"Results depend on Hopper's completed static analysis.",
]),
});
}),
);
/** Concrete analysis provider backed by REA's private Hopper bridge. */
export class HopperProvider implements AnalysisProviderCandidate {

View File

@ -0,0 +1,86 @@
import type {
CapabilityDescriptor,
ProviderIdentity,
} from "../application/AnalysisProvider.js";
import type { OfficialToolName } from "../contracts/officialToolContracts.js";
/** Public identity committed by every Hopper-backed observation. */
export const HOPPER_PROVIDER_IDENTITY: ProviderIdentity = Object.freeze({
id: "hopper",
name: "Hopper Disassembler",
version: null,
});
/** Analyst operations implemented by REA's private Hopper bridge. */
export const HOPPER_OPERATIONS = Object.freeze([
"address_name",
"comment",
"current_address",
"current_procedure",
"current_document",
"goto_address",
"inline_comment",
"list_bookmarks",
"list_documents",
"list_names",
"list_procedures",
"list_segments",
"list_strings",
"next_address",
"prev_address",
"procedure_address",
"procedure_assembly",
"procedure_callees",
"procedure_callers",
"procedure_info",
"read_function_instructions",
"read_bytes",
"address_to_file_offset",
"procedure_references",
"procedure_pseudo_code",
"resolve_containing_procedure",
"search_procedures",
"search_strings",
"set_address_name",
"set_addresses_names",
"set_bookmark",
"set_comment",
"set_current_document",
"set_inline_comment",
"unset_bookmark",
"xrefs",
"analyze_function",
] as const satisfies readonly (OfficialToolName | "analyze_function")[]);
const MUTATING_OPERATIONS = new Set<string>([
"set_address_name",
"set_addresses_names",
"set_bookmark",
"set_comment",
"set_inline_comment",
"unset_bookmark",
]);
/** Source-owned capabilities without acquiring or probing a Hopper session. */
export const CAPABILITIES: readonly CapabilityDescriptor[] = Object.freeze(
HOPPER_OPERATIONS.map((operation) =>
Object.freeze({
provider: HOPPER_PROVIDER_IDENTITY,
operation,
available: true,
reason: null,
effects: Object.freeze({
mutatesArtifact: MUTATING_OPERATIONS.has(operation),
launchesProcess: true,
mayShowUi: true,
mayAccessNetwork: false,
mayWriteFilesystem: MUTATING_OPERATIONS.has(operation),
changesPermissions: false,
requiresRoot: false,
}),
limitations: Object.freeze([
"Results depend on Hopper's completed static analysis.",
]),
}),
),
);

View File

@ -0,0 +1,18 @@
import { expect, it } from "vitest";
import { IDA_OPERATIONS, idaCapabilities } from "./IdaProviderCapabilities.js";
it("preserves live read-only capabilities when callers attempt to alter declarations", () => {
const original = idaCapabilities("headless", true);
Reflect.set(IDA_OPERATIONS, 0, "set_comment");
Reflect.set(IDA_OPERATIONS, IDA_OPERATIONS.length, "set_address_name");
expect(idaCapabilities("headless", true)).toEqual(original);
expect(original.every(({ cachePolicy }) => cachePolicy === "live")).toBe(
true,
);
expect(original.every(({ effects }) => !effects.mutatesArtifact)).toBe(true);
expect(
original.find(({ operation }) => operation === "procedure_callers"),
).toMatchObject({ available: false });
});

View File

@ -1,5 +1,7 @@
import type { CapabilityDescriptor } from "../application/AnalysisProvider.js";
import { GENERATED_MCP_TOOL_CATALOG } from "../generatedMcpToolCatalog.js";
import type {
AnalysisOperation,
CapabilityDescriptor,
} from "../application/AnalysisProvider.js";
/** This version identifies REA's adapter, not IDA or the upstream Python package. */
export const IDA_PROVIDER_IDENTITY = Object.freeze({
@ -9,7 +11,7 @@ export const IDA_PROVIDER_IDENTITY = Object.freeze({
});
/** Read-only analyst outcomes supported by both upstream compatibility profiles. */
export const IDA_OPERATIONS = [
export const IDA_OPERATIONS = Object.freeze([
"list_procedures",
"search_procedures",
"list_strings",
@ -22,7 +24,7 @@ export const IDA_OPERATIONS = [
"read_function_instructions",
"xrefs",
"analyze_function",
] as const;
] as const satisfies readonly Exclude<AnalysisOperation, "health">[]);
export type IdaOperation = (typeof IDA_OPERATIONS)[number];
/** Shared limitations of observations from an externally mutable analysis database. */
@ -35,24 +37,12 @@ export const IDA_LIMITATIONS = Object.freeze([
"Complete function body ranges, typed references, unresolved indirect calls, referenced data, and basic blocks are unavailable in this adapter. Empty unsupported dossier facets are unknown, not observed absence.",
]);
/** Existing REA contracts implemented by the adapter; no upstream tools are exposed directly. */
export const IDA_PROVIDER_TOOL_CONTRACTS = Object.freeze(
IDA_OPERATIONS.map((operation) => {
const contract = GENERATED_MCP_TOOL_CATALOG.find(
({ name }) => name === operation,
);
if (contract === undefined)
throw new TypeError(`Missing contract for ${operation}`);
return { ...contract, name: operation };
}),
);
/** Advertise live observations and configured lifecycle effects truthfully. */
export const idaCapabilities = (
mode: "attached" | "headless",
stdio: boolean,
): readonly CapabilityDescriptor[] =>
IDA_PROVIDER_TOOL_CONTRACTS.map(({ name: operation }) => ({
IDA_OPERATIONS.map((operation) => ({
provider: IDA_PROVIDER_IDENTITY,
operation,
...(mode === "headless" && operation === "procedure_callers"

View File

@ -34,11 +34,11 @@ import { JADX_PROVIDER_IDENTITY } from "../../../src/android/JadxRelease.js";
import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js";
import {
HOPPER_PROVIDER_IDENTITY,
HOPPER_PROVIDER_TOOL_CONTRACTS,
HOPPER_OPERATIONS,
} from "../../../src/hopper/HopperProvider.js";
import {
GHIDRA_PROVIDER_IDENTITY,
GHIDRA_PROVIDER_TOOL_CONTRACTS,
GHIDRA_OPERATIONS,
} from "../../../src/ghidra/GhidraProvider.js";
import { NATIVE_MACOS_PROVIDER_IDENTITY } from "../../../src/native/NativeMacOSProvider.js";
import { IDA_PROVIDER_IDENTITY } from "../../../src/ida/IdaProvider.js";
@ -130,11 +130,11 @@ describe("canonical product catalog", () => {
expect(
catalog.providers.find(({ id }) => id === HOPPER_PROVIDER_IDENTITY.id)
?.capabilities,
).toEqual(HOPPER_PROVIDER_TOOL_CONTRACTS.map(({ name }) => name).sort());
).toEqual([...HOPPER_OPERATIONS].sort());
expect(
catalog.providers.find(({ id }) => id === GHIDRA_PROVIDER_IDENTITY.id)
?.capabilities,
).toEqual(GHIDRA_PROVIDER_TOOL_CONTRACTS.map(({ name }) => name).sort());
).toEqual([...GHIDRA_OPERATIONS].sort());
expect(
catalog.providers.find(
({ id }) => id === CDP_ELECTRON_PROVIDER_IDENTITY.id,

View File

@ -1,6 +1,6 @@
import { describe, expect, it, onTestFinished, vi } from "vitest";
import { HOPPER_PROVIDER_TOOL_CONTRACTS } from "../../../../src/hopper/HopperProvider.js";
import { HOPPER_OPERATIONS } from "../../../../src/hopper/HopperProvider.js";
import { HopperClient } from "../../../../src/hopper/HopperClient.js";
import type { HopperDiagnostic } from "../../../../src/hopper/HopperDiagnostics.js";
import { providerCleanupFailure } from "../../../../src/hopper/HopperDiagnostics.js";
@ -74,11 +74,9 @@ describe("HopperClient protocol", () => {
it("routes every established operation through the authenticated bridge", async () => {
const client = await startClient();
const results = await Promise.all(
HOPPER_PROVIDER_TOOL_CONTRACTS.map(({ name }) =>
client.callTool(name, {}),
),
HOPPER_OPERATIONS.map((name) => client.callTool(name, {})),
);
expect(results).toHaveLength(HOPPER_PROVIDER_TOOL_CONTRACTS.length);
expect(results).toHaveLength(HOPPER_OPERATIONS.length);
expect(results.every((result) => result.ok)).toBe(true);
});

View File

@ -65,6 +65,18 @@ describe("incremental module import boundaries", () => {
["src/domain/probe.test.ts", "../dotnet/ManagedMemberInspector.js", false],
["src/server/probe.ts", "../composition/android.js", true],
["src/application/probe.ts", "../browser/CdpCaptureValues.js", true],
["src/hopper/probe.ts", "../generatedMcpToolCatalog.js", false],
["src/ghidra/probe.ts", "../generatedMcpToolCatalog.ts", false],
["src/ida/probe.ts", "../generatedMcpToolCatalog.js", false],
["src/artifacts/probe.ts", "../generatedMcpToolCatalog.js", false],
["src/browser/probe.ts", "../generatedMcpToolCatalog.js", false],
["src/ghidra/probe.ts", "../generatedPackageMetadata.js", true],
["src/hopper/probe.ts", "../contracts/officialToolContracts.js", true],
[
"src/application/binary/probe.ts",
"../../generatedMcpToolCatalog.js",
true,
],
])("checks %s importing %s", async (file, dependency, allowed) => {
const temporary = await createTestTempDirectory("rea-module-boundary-");
const fixture = join(temporary, file);