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

This commit is contained in:
ysopwnable authored and GitHub committed 2026-10-07 15:41:50 +08:00
1 parent 4037c6545d
commit b808e2d6bb
17 files changed
+220 -133

No files matched your search

+2 -6
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");
});
});
+4 -4
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({
+2 -2
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"]);
+7 -25
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)),
});
}),
);
+4 -7
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,
);
+10 -57
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 {
+86
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.",
]),
}),
),
);
+18
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 });
});
+7 -17
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"