fix(discovery): advertise attached IDA navigation effects (#7017)

* fix(discovery): advertise attached IDA navigation effects

The canonical tool audit now includes the UI and network effects of the attached IDA legacy profile for the four navigation operations. Session inventory still starts from the provider-neutral evidence base and composes the selected provider descriptors, so headless and native sessions do not inherit attached UI state.

Adds SDK/session boundary coverage for all four operations and preserves attached, headless, and native distinctions.

* fix(discovery): cover IDA provider effects across tools and workflows

* test(discovery): isolate IDA metadata from host tool probes

* test(discovery): compare live identity with session effect bases

* fix(discovery): audit complete IDA provider effect union

* fix(discovery): exclude unreachable provider workflow effects

* fix(discovery): propagate IDA effects through composed tools

* fix(artifacts): deduplicate keyed archive path diagnostics

* test(artifacts): use real bundle in provider binding fixture

* test(mcp): align overview inventory fixtures with row contracts

* fix(javascript): align Electron path and owner identities

Refresh archive close and function CLI fixtures for current admission and Result contracts.

* test: align boundary fixtures with runtime prerequisites

* test(javascript): preserve unknown Electron IPC ownership in fixture

* fix: audit IDA network effects during lifecycle cleanup

* test: align close lifecycle hint with network audit

* style(website): format merged Wrangler watch paths

* fix(discovery): retain retry and cleanup effects

* style(website): drop unrelated Wrangler formatting

The watch_dir reflow is whitespace only. The IDA navigation-effect fix stays.

---------

Co-authored-by: dhruvac29 <dhruvac29@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-11-06 01:47:56 +08:00
1 parent f76be69428
commit dd31958743
11 files changed
+711 -60

No files matched your search

+10 -7
View File
@@ -32,6 +32,15 @@ type Facet =
readonly error?: AnalysisErrorProjection;
};
/** Facets inspected after an explicit or provider-resolved document is known. */
export const ADDRESS_CONTEXT_FACET_OPERATIONS = [
"address_name",
"resolve_containing_procedure",
"comment",
"inline_comment",
"list_bookmarks",
] as const satisfies readonly AnalysisOperation[];
const parameters = (
document: string | undefined,
address?: string,
@@ -103,13 +112,7 @@ export const inspectAddressContext = async (
);
if (!document.ok) return document;
const args = parameters(document.value, input.address);
const operations = [
"address_name",
"resolve_containing_procedure",
"comment",
"inline_comment",
"list_bookmarks",
] as const satisfies readonly AnalysisOperation[];
const operations = ADDRESS_CONTEXT_FACET_OPERATIONS;
const completed: Result<AnalysisExecution, AnalysisError>[] = [];
if (signal?.aborted === true)
return err(
+53 -11
View File
@@ -5,6 +5,7 @@ import {
FIRMWARE_TOOL_REQUIREMENTS,
} from "../contracts/firmware/firmwareToolRequirements.js";
import { CALL_RELATIONSHIP_TRAVERSALS } from "./CallRelationshipTraversal.js";
import { ADDRESS_CONTEXT_FACET_OPERATIONS } from "./AnalysisContextQueries.js";
import { NATIVE_DISPATCH_METADATA_SOURCES } from "./native/NativeDispatchMetadataInspection.js";
import { NATIVE_UI_ACTION_OPERATIONS } from "./native/NativeUiActionTrace.js";
import { NATIVE_CALL_ROUTE_SOURCES } from "./native/NativeCallRoutes.js";
@@ -32,6 +33,7 @@ import {
} from "../contracts/toolOutputSchemaPrimitives.js";
import {
annotationsFromEffects,
toolSessionEffects,
type ToolEffects,
} from "../contracts/toolEffects.js";
import type { ToolKind } from "../contracts/toolContractTypes.js";
@@ -259,6 +261,22 @@ const NAVIGATION_CONTEXT_MODES = [
},
] as const;
const ADDRESS_CONTEXT_EFFECT_COMPOSITION: ModeComposition = {
modes: [
{
name: "explicit_document",
requirements: [ADDRESS_CONTEXT_FACET_OPERATIONS],
},
{
name: "provider_document",
requirements: [
["current_document", "list_documents"],
ADDRESS_CONTEXT_FACET_OPERATIONS,
],
},
],
};
/** Build stable per-operation availability for discovery and tool visibility. */
export const buildCapabilityInventory = (
sessionStatus: JsonValue,
@@ -285,7 +303,7 @@ export const buildCapabilityInventory = (
});
const effects = composedEffects(
contract.name,
contract.effects,
toolSessionEffects(contract.name),
status.capabilities,
);
const annotations = annotationsFromEffects(effects);
@@ -1082,6 +1100,28 @@ const availabilityFailurePriority = (
}
};
/** Include operations the selected route can still dispatch, including runtime retries. */
const reachableRequirements = (
requirements: CompositionRequirements,
descriptors: ReadonlyMap<string, ProviderDescriptor>,
): readonly string[] => {
const reached: string[] = [];
for (const group of requirements) {
const operations = typeof group === "string" ? [group] : group;
const available = operations.filter((operation) => {
const descriptor = descriptors.get(operation);
return (
descriptor?.available === true ||
(descriptor?.available === false &&
descriptor.runtime_unavailable === true)
);
});
reached.push(...available);
if (available.length === 0) break;
}
return reached;
};
/** Use the same composition requirements as availability when exposing provider effects. */
const composedEffects = (
name: string,
@@ -1091,19 +1131,21 @@ const composedEffects = (
const composition: ProviderComposition | undefined =
name === "get_navigation_context"
? { modes: NAVIGATION_CONTEXT_MODES }
: ENHANCED_REQUIREMENTS.get(name);
const requirements: CompositionRequirements =
: name === "inspect_address_context"
? ADDRESS_CONTEXT_EFFECT_COMPOSITION
: ENHANCED_REQUIREMENTS.get(name);
const byOperation = new Map(
descriptors.map((descriptor) => [descriptor.operation, descriptor]),
);
const reachable =
composition === undefined
? []
: "modes" in composition
? composition.modes.flatMap(({ requirements }) => requirements)
: composition;
const operations = new Set<string>([
name,
...requirements.flatMap((group) =>
typeof group === "string" ? [group] : group,
),
]);
? composition.modes.flatMap(({ requirements }) =>
reachableRequirements(requirements, byOperation),
)
: reachableRequirements(composition, byOperation);
const operations = new Set<string>([name, ...reachable]);
const effects = { ...base };
for (const descriptor of descriptors) {
if (!operations.has(descriptor.operation)) continue;
@@ -226,6 +226,7 @@ const expectedRouteCapabilities: ProviderCapabilitySet[] = [
available: false,
reason: "Runtime operation failure",
availability_code: null,
runtime_unavailable: true,
},
{
operations: ["read_bytes"],
@@ -253,6 +254,7 @@ const expectedExpandedRouteCapabilities = [
available: false,
reason: "Runtime operation failure",
availability_code: null,
runtime_unavailable: true,
effects: MUTATING_EFFECTS,
limitations: ["Shared limitation"],
},
@@ -184,10 +184,10 @@ export const binarySessionStatus = async ({
const runtimeUnavailable = (
descriptor: CapabilityDescriptor,
reason: string,
): CapabilityDescriptor => {
): CapabilityDescriptor & { readonly runtimeUnavailable?: true } => {
if (descriptor.available) {
const { availabilityCode: _available, ...declared } = descriptor;
return { ...declared, available: false, reason };
return { ...declared, available: false, reason, runtimeUnavailable: true };
}
const { remediation: _superseded, ...declared } = descriptor;
return { ...declared, reason };
@@ -351,7 +351,7 @@ type CapabilityAvailabilityCode = Exclude<
type CapabilityProjection = Omit<
CapabilityDescriptor,
"operation" | "available" | "reason"
> & { readonly operation: string } & (
> & { readonly operation: string; readonly runtimeUnavailable?: true } & (
| { readonly available: true; readonly reason: null }
| {
readonly available: false;
@@ -407,6 +407,9 @@ export const providerCapabilitySets = (
available: false as const,
reason: descriptor.reason,
availability_code: availabilityCode,
...(descriptor.runtimeUnavailable === true
? { runtime_unavailable: true as const }
: {}),
...(descriptor.remediation === undefined
? {}
: { remediation: descriptor.remediation }),
@@ -417,6 +420,7 @@ export const providerCapabilitySets = (
status.available,
status.reason,
status.availability_code,
status.available ? null : (status.runtime_unavailable ?? null),
status.available ? null : (status.remediation ?? null),
]);
const group = groups.get(key);
+64
View File
@@ -4,6 +4,7 @@ import { TOOL_CONTRACTS } from "./toolContracts.js";
import {
TOOL_EFFECTS,
annotationsFromEffects,
toolSessionEffects,
type ToolEffects,
} from "./toolEffects.js";
@@ -257,6 +258,69 @@ describe("tool effects", () => {
});
});
it("advertises IDA provider effects while retaining session bases", () => {
for (const name of [
"procedure_pseudo_code",
"procedure_assembly",
"read_function_instructions",
"analyze_function",
]) {
expect(TOOL_EFFECTS[name], name).toMatchObject({
mutatesSession: true,
changesUiState: true,
accessesNetwork: true,
});
expect(toolSessionEffects(name), name).toMatchObject({
mutatesSession: true,
changesUiState: false,
accessesNetwork: false,
});
const contract = TOOL_CONTRACTS.find(
(candidate) => candidate.name === name,
);
expect(contract?.annotations, name).toMatchObject({
readOnlyHint: false,
destructiveHint: false,
openWorldHint: true,
});
}
for (const name of [
"batch_decompile",
"decompile_all",
"trace_native_values",
"inspect_native_api",
]) {
expect(TOOL_EFFECTS[name], name).toMatchObject({
changesUiState: true,
accessesNetwork: true,
});
expect(toolSessionEffects(name), name).toMatchObject({
changesUiState: false,
accessesNetwork: false,
});
}
});
it("audits network access during lifecycle cleanup across provider changes", () => {
for (const name of [
"open_binary",
"open_bundle_resources",
"close_binary",
"reset_investigation",
"start_rizin_debug_session",
"close_rizin_debug_session",
"close_gdb_session",
]) {
expect(toolSessionEffects(name).accessesNetwork, name).toBe(true);
expect(TOOL_EFFECTS[name]?.accessesNetwork, name).toBe(true);
expect(
TOOL_CONTRACTS.find((contract) => contract.name === name)?.annotations
.openWorldHint,
name,
).toBe(true);
}
});
it("advertises signature inspection's temporary snapshot filesystem writes", () => {
expect(TOOL_EFFECTS.inspect_signature).toMatchObject({
writesFilesystem: true,
+108 -32
View File
@@ -50,8 +50,33 @@ const browserEvidence = effects({
accessesNetwork: true,
});
type EffectAudit =
| ToolEffects
| { readonly session: ToolEffects; readonly global: Partial<ToolEffects> };
/** Keep provider-neutral effects with the additions possible across profiles. */
const providerEffects = (
session: ToolEffects,
global: Partial<ToolEffects>,
): EffectAudit => ({ session, global });
/** Global possibility across supported provider paths; sessions compose their selected descriptor. */
const providerRemoteEffects = {
launchesProcess: true,
writesFilesystem: true,
accessesNetwork: true,
};
const providerNetworkEvidence = providerEffects(
evidence,
providerRemoteEffects,
);
const providerUiNetworkEvidence = providerEffects(evidence, {
...providerRemoteEffects,
changesUiState: true,
});
/** Explicit effect audit for every public tool. */
export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
const TOOL_EFFECT_AUDIT: Readonly<Record<string, EffectAudit>> = {
inspect_analysis_settings: effects(),
update_analysis_settings: effects({ mutatesSession: true }),
list_cutter_sessions: effects({ accessesNetwork: true }),
@@ -88,6 +113,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
mutatesTarget: true,
mutatesSession: true,
launchesProcess: true,
accessesNetwork: true,
executesForeignCode: true,
idempotent: false,
}),
@@ -106,12 +132,14 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
close_rizin_debug_session: effects({
mutatesSession: true,
launchesProcess: true,
accessesNetwork: true,
mutatesTarget: true,
executesForeignCode: true,
}),
close_gdb_session: effects({
launchesProcess: true,
mutatesSession: true,
accessesNetwork: true,
mutatesTarget: true,
executesForeignCode: true,
idempotent: false,
@@ -508,8 +536,8 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
}),
inspect_native_load_image: evidence,
inspect_native_dispatch_metadata: evidence,
trace_native_ui_action: evidence,
trace_native_values: evidence,
trace_native_ui_action: providerNetworkEvidence,
trace_native_values: providerUiNetworkEvidence,
address_name: evidence,
comment: evidence,
current_address: evidence,
@@ -518,19 +546,19 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
goto_address: effects({ mutatesSession: true, changesUiState: true }),
inline_comment: evidence,
list_bookmarks: evidence,
list_documents: evidence,
list_documents: providerNetworkEvidence,
list_names: evidence,
list_procedures: evidence,
list_procedures: providerNetworkEvidence,
list_segments: evidence,
list_strings: evidence,
list_strings: providerNetworkEvidence,
next_address: evidence,
prev_address: evidence,
procedure_address: evidence,
procedure_assembly: evidence,
procedure_callees: evidence,
procedure_callers: evidence,
procedure_address: providerNetworkEvidence,
procedure_assembly: providerUiNetworkEvidence,
procedure_callees: providerNetworkEvidence,
procedure_callers: providerNetworkEvidence,
procedure_info: evidence,
read_function_instructions: evidence,
read_function_instructions: providerUiNetworkEvidence,
inspect_native_instruction: evidence,
inspect_native_data_type: evidence,
resolve_native_call_targets: evidence,
@@ -538,10 +566,10 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
search_bytes: evidence,
address_to_file_offset: evidence,
procedure_references: evidence,
procedure_pseudo_code: evidence,
resolve_containing_procedure: evidence,
search_procedures: evidence,
search_strings: evidence,
procedure_pseudo_code: providerUiNetworkEvidence,
resolve_containing_procedure: providerNetworkEvidence,
search_procedures: providerNetworkEvidence,
search_strings: providerNetworkEvidence,
set_address_name: effects({
mutatesTarget: true,
mutatesSession: true,
@@ -572,23 +600,35 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
mutatesSession: true,
mayDiscardData: true,
}),
xrefs: evidence,
xrefs: providerNetworkEvidence,
get_objc_classes: evidence,
get_objc_protocols: evidence,
batch_decompile: evidence,
decompile_all: effects({
mutatesSession: true,
writesFilesystem: true,
idempotent: false,
}),
get_call_graph: evidence,
analyze_swift_types: evidence,
batch_decompile: providerUiNetworkEvidence,
decompile_all: providerEffects(
effects({
mutatesSession: true,
writesFilesystem: true,
idempotent: false,
}),
{ ...providerRemoteEffects, changesUiState: true },
),
get_call_graph: providerEffects(evidence, providerRemoteEffects),
analyze_swift_types: providerEffects(evidence, providerRemoteEffects),
find_xrefs_to_name: evidence,
binary_overview: evidence,
analyze_function: evidence,
inspect_native_api: effects({ mutatesSession: true, idempotent: false }),
trace_feature: effects({ mutatesSession: true, idempotent: false }),
trace_call_path: effects({ mutatesSession: true, idempotent: false }),
binary_overview: providerEffects(evidence, providerRemoteEffects),
analyze_function: providerUiNetworkEvidence,
inspect_native_api: providerEffects(
effects({ mutatesSession: true, idempotent: false }),
{ ...providerRemoteEffects, changesUiState: true },
),
trace_feature: providerEffects(
effects({ mutatesSession: true, idempotent: false }),
providerRemoteEffects,
),
trace_call_path: providerEffects(
effects({ mutatesSession: true, idempotent: false }),
providerRemoteEffects,
),
observe_native_ui: effects({
mutatesSession: true,
launchesProcess: true,
@@ -715,17 +755,28 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
project_android_application_graph: evidence,
project_apple_application_graph: evidence,
project_harmony_application_graph: evidence,
open_binary: effects({ mutatesSession: true, launchesProcess: true }),
open_bundle_resources: effects({ mutatesSession: true }),
// Lifecycle transitions can retire an IDA client, including a pending
// cleanup from an earlier provider, over its configured MCP transport.
open_binary: effects({
mutatesSession: true,
launchesProcess: true,
accessesNetwork: true,
}),
open_bundle_resources: effects({
mutatesSession: true,
accessesNetwork: true,
}),
close_binary: effects({
mutatesSession: true,
writesFilesystem: true,
accessesNetwork: true,
mayDiscardData: true,
idempotent: false,
}),
reset_investigation: effects({
mutatesSession: true,
writesFilesystem: true,
accessesNetwork: true,
mayDiscardData: true,
idempotent: false,
}),
@@ -736,7 +787,10 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
}),
get_evidence_bundle: effects(),
get_navigation_context: effects({ mutatesSession: true }),
inspect_address_context: effects({ mutatesSession: true }),
inspect_address_context: providerEffects(
effects({ mutatesSession: true }),
providerRemoteEffects,
),
import_evidence_bundle: effects({ mutatesSession: true }),
capture_process_scenario: effects({
mutatesSession: true,
@@ -762,6 +816,28 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
verify_unknown_resolution: effects(),
};
const sessionEffects = (audit: EffectAudit): ToolEffects =>
"session" in audit ? audit.session : audit;
/** Possible effects across every supported provider profile. */
export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> =
Object.fromEntries(
Object.entries(TOOL_EFFECT_AUDIT).map(([name, audit]) => [
name,
"session" in audit
? effects({ ...audit.session, ...audit.global })
: audit,
]),
);
/** Effects owned by the tool before composing the selected provider. */
export const toolSessionEffects = (name: string): ToolEffects => {
const audited = TOOL_EFFECT_AUDIT[name];
if (audited === undefined)
throw new Error(`Missing effect audit for ${name}`);
return sessionEffects(audited);
};
/** Derive MCP annotations without tool-name or tool-family heuristics. */
export const annotationsFromEffects = (
value: ToolEffects,
@@ -80,6 +80,7 @@ export const providerCapability = z.discriminatedUnion("available", [
reason: z.string().nullable(),
availability_code: z.enum(PROVIDER_REJECTION_CODES).nullable(),
remediation: providerRemediation.exactOptional(),
runtime_unavailable: z.literal(true).exactOptional(),
}),
]);
export type ProviderCapability = z.output<typeof providerCapability>;
@@ -114,6 +115,7 @@ const providerCapabilityGroup = z.discriminatedUnion("available", [
reason: z.string().nullable(),
availability_code: z.enum(PROVIDER_REJECTION_CODES).nullable(),
remediation: providerRemediation.exactOptional(),
runtime_unavailable: z.literal(true).exactOptional(),
}),
]);
@@ -142,6 +144,10 @@ export const expandProviderCapabilities = (
...("remediation" in group && group.remediation !== undefined
? { remediation: group.remediation }
: {}),
...("runtime_unavailable" in group &&
group.runtime_unavailable === true
? { runtime_unavailable: true }
: {}),
effects: Object.fromEntries(
PROVIDER_EFFECT_NAMES.map((name) => [
name,
+165
View File
@@ -0,0 +1,165 @@
import { rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { parseConfig } from "../../../src/config/parseConfig.js";
import { IdaProvider } from "../../../src/ida/IdaProvider.js";
import { createServer } from "../../../src/server/createServer.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { createIdaTarget, RecordingIdaMcp } from "../../fixtures/idaMcp.js";
import { createTestMcp } from "../../support/mcp/mcpFixture.js";
const ATTACHED_IDA_NAVIGATION = [
"procedure_pseudo_code",
"procedure_assembly",
"read_function_instructions",
"analyze_function",
] as const;
const IDA_NETWORK_DISCOVERY = [
...ATTACHED_IDA_NAVIGATION,
"list_procedures",
"search_strings",
"batch_decompile",
"decompile_all",
"trace_native_values",
"inspect_native_api",
] as const;
type DiscoveryStatus = {
readonly capabilities: readonly {
readonly provider: { readonly id: string };
readonly groups: readonly {
readonly operations: readonly string[];
readonly effects: readonly string[];
}[];
}[];
readonly tool_availability: {
readonly groups: readonly {
readonly names: readonly string[];
readonly effects: readonly string[];
readonly annotations: readonly string[];
}[];
};
};
const unconfiguredHostTool = {
inspectAvailability: async () => ({
status: "unavailable" as const,
code: "not_configured" as const,
reason: "Host tools are outside the IDA discovery fixture.",
remediation: "Configure the host tool for a separate test.",
diagnostics: { source: "test_fixture" },
}),
close: async () => {},
execute: async (): Promise<never> => {
throw new Error("Host-tool execution is outside the IDA discovery fixture");
},
};
it("advertises attached IDA UI and network effects in SDK and session discovery", async () => {
const { root, target } = await createIdaTarget();
const mcp = createTestMcp();
let session: ReturnType<typeof createTestBinarySession> | undefined;
try {
const registration = join(root, "registration.json");
await writeFile(
registration,
JSON.stringify({ command: "fixture", mode: "attached" }),
);
const config = parseConfig({ REA_IDA_MCP_CONFIG: registration });
if (!config.ok) throw config.error;
const producer = new RecordingIdaMcp(target);
session = createTestBinarySession(
new IdaProvider(config.value, {}, () => producer),
);
expect((await session.open(target.path)).ok).toBe(true);
const server = createServer(
{ kind: "session", session },
{
environment: { REA_LOG_LEVEL: "silent" },
androidAnalysis: unconfiguredHostTool,
adbDeviceAnalysis: unconfiguredHostTool,
apktoolAnalysis: unconfiguredHostTool,
},
);
const client = await mcp.connect(server);
const tools = (await client.listTools()).tools;
for (const name of ATTACHED_IDA_NAVIGATION) {
const response = await client.callTool({
name,
arguments: { procedure: "main" },
});
expect(response.isError, name).not.toBe(true);
}
const response = await client.callTool({
name: "binary_session",
arguments: { detail: "complete" },
});
expect(response.isError).not.toBe(true);
const status = (
response.structuredContent as unknown as { result: DiscoveryStatus }
).result;
const ida = status.capabilities.find(
({ provider }) => provider.id === "ida",
);
expect(ida).toBeDefined();
for (const name of ATTACHED_IDA_NAVIGATION) {
const capability = ida?.groups.find(({ operations }) =>
operations.includes(name),
);
expect(capability?.effects, name).toEqual(
expect.arrayContaining(["may_show_ui", "may_access_network"]),
);
const availability = status.tool_availability.groups.find(({ names }) =>
names.includes(name),
);
expect(availability?.effects, name).toEqual(
expect.arrayContaining(["changesUiState", "accessesNetwork"]),
);
expect(availability?.annotations, name).toContain("open_world");
const advertised = tools.find((tool) => tool.name === name);
expect(advertised?.annotations?.openWorldHint, name).toBe(true);
}
for (const name of IDA_NETWORK_DISCOVERY) {
const advertised = tools.find((tool) => tool.name === name);
expect(advertised?.annotations?.openWorldHint, name).toBe(true);
const availability = status.tool_availability.groups.find(({ names }) =>
names.includes(name),
);
expect(availability?.effects, name).toContain("accessesNetwork");
}
for (const name of ["trace_native_ui_action", "inspect_address_context"]) {
const advertised = tools.find((tool) => tool.name === name);
expect(advertised?.annotations?.openWorldHint, name).toBe(true);
}
const addressContext = status.tool_availability.groups.find(({ names }) =>
names.includes("inspect_address_context"),
);
expect(addressContext?.effects).toContain("accessesNetwork");
expect(addressContext?.annotations).toContain("open_world");
for (const name of [
"open_binary",
"open_bundle_resources",
"close_binary",
"reset_investigation",
"start_rizin_debug_session",
"close_rizin_debug_session",
"close_gdb_session",
]) {
const advertised = tools.find((tool) => tool.name === name);
expect(advertised?.annotations?.openWorldHint, name).toBe(true);
const availability = status.tool_availability.groups.find(({ names }) =>
names.includes(name),
);
expect(availability?.effects, name).toContain("accessesNetwork");
expect(availability?.annotations, name).toContain("open_world");
}
} finally {
await mcp.close();
await session?.close();
await rm(root, { recursive: true, force: true });
}
});
+11 -5
View File
@@ -22,6 +22,10 @@ import { CLI_COMMAND_NAMES } from "../../../src/cliCommandNames.js";
import { PACKAGE_METADATA } from "../../../src/generatedPackageMetadata.js";
import { PRODUCT_IDENTITY, SDK_IDENTITY } from "../../../src/identity.js";
import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js";
import {
annotationsFromEffects,
toolSessionEffects,
} from "../../../src/contracts/toolEffects.js";
import { expandToolAvailability } from "../../../src/contracts/toolOutputSchemaPrimitives.js";
import { sessionOutputSchemas } from "../../../src/contracts/toolOutputSchemaGroups.js";
import { PROMPT_CONTRACTS } from "../../../src/contracts/promptContracts.js";
@@ -530,16 +534,18 @@ const assertLiveIdentity = async (client: Client): Promise<void> => {
TOOL_CONTRACTS.map(({ name }) => name).sort(),
);
for (const contract of TOOL_CONTRACTS) {
const effects = toolSessionEffects(contract.name);
const annotations = annotationsFromEffects(effects);
expect(availability).toContainEqual(
expect.objectContaining({
name: contract.name,
surface: contract.kind,
effects: contract.effects,
effects,
annotations: {
read_only: contract.annotations.readOnlyHint ?? false,
destructive: contract.annotations.destructiveHint ?? false,
idempotent: contract.annotations.idempotentHint ?? false,
open_world: contract.annotations.openWorldHint ?? true,
read_only: annotations.readOnlyHint,
destructive: annotations.destructiveHint,
idempotent: annotations.idempotentHint,
open_world: annotations.openWorldHint,
},
}),
);
@@ -95,7 +95,7 @@ function expectKnownAuthorityHints(tools: readonly ToolSchemas[]): void {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
openWorldHint: false,
openWorldHint: true,
},
start_gdb_session: {
readOnlyHint: false,
@@ -1,8 +1,14 @@
import { IDA_COMPATIBILITY_PROFILES } from "../../../../src/ida/IdaConfiguration.js";
import { expect, it } from "vitest";
import type { CapabilityDescriptor } from "../../../../src/application/AnalysisProvider.js";
import { buildCapabilityInventory } from "../../../../src/application/CapabilityInventory.js";
import { providerCapabilitySets } from "../../../../src/application/binary/BinarySessionStatus.js";
import { idaCapabilities } from "../../../../src/ida/IdaProviderCapabilities.js";
import { IDA_OPERATIONS } from "../../../../src/ida/IdaProviderMetadata.js";
import {
TOOL_EFFECTS,
toolSessionEffects,
} from "../../../../src/contracts/toolEffects.js";
const enabledPolicy: Parameters<typeof buildCapabilityInventory>[1] = {
processCaptureEnabled: true,
@@ -12,6 +18,109 @@ const enabledPolicy: Parameters<typeof buildCapabilityInventory>[1] = {
v8InspectorObservationEnabled: true,
};
it("audits every IDA operation's network transport without adding it to the session base", () => {
for (const name of IDA_OPERATIONS) {
expect(TOOL_EFFECTS[name]?.accessesNetwork, name).toBe(true);
expect(toolSessionEffects(name).accessesNetwork, name).toBe(false);
}
});
it("retains network cleanup effects in session discovery for lifecycle tools", () => {
const entries = buildCapabilityInventory(
{
open: true,
kind: "executable",
capabilities: providerCapabilitySets(
idaCapabilities(IDA_COMPATIBILITY_PROFILES.modern, false),
),
},
enabledPolicy,
);
for (const name of [
"open_binary",
"open_bundle_resources",
"close_binary",
"reset_investigation",
"start_rizin_debug_session",
"close_rizin_debug_session",
"close_gdb_session",
]) {
expect(
entries.find((entry) => entry.name === name),
name,
).toMatchObject({
effects: { accessesNetwork: true },
annotations: { open_world: true },
});
}
});
it("keeps retryable IDA effects in composed discovery while preserving unavailability", () => {
const ida = idaCapabilities(IDA_COMPATIBILITY_PROFILES.legacy, false);
const statusFor = (runtimeUnavailable: boolean) => ({
open: true,
kind: "executable",
capabilities: providerCapabilitySets(
ida.map((descriptor) =>
descriptor.operation === "analyze_function"
? {
...descriptor,
available: false as const,
reason: "Transient IDA failure",
...(runtimeUnavailable
? { runtimeUnavailable: true as const }
: {}),
}
: descriptor,
),
),
});
const retryable = buildCapabilityInventory(
statusFor(true),
enabledPolicy,
).find(({ name }) => name === "inspect_native_api");
expect(retryable).toMatchObject({
available: false,
reason: "provider_unavailable",
effects: { accessesNetwork: true, changesUiState: true },
annotations: { open_world: true },
});
const blocked = buildCapabilityInventory(
statusFor(false),
enabledPolicy,
).find(({ name }) => name === "inspect_native_api");
expect(blocked).toMatchObject({
available: false,
effects: { accessesNetwork: false, changesUiState: false },
annotations: { open_world: false },
});
});
it.each([
["legacy", false],
["native", true],
["modern", true],
] as const)(
"covers IDA provider effects in the global audit (%s, stdio=%s)",
(profileName, stdio) => {
for (const descriptor of idaCapabilities(
IDA_COMPATIBILITY_PROFILES[profileName],
stdio,
)) {
const audited = TOOL_EFFECTS[descriptor.operation];
expect(audited, descriptor.operation).toBeDefined();
if (descriptor.effects.mayAccessNetwork)
expect(audited?.accessesNetwork, descriptor.operation).toBe(true);
if (descriptor.effects.mayShowUi)
expect(audited?.changesUiState, descriptor.operation).toBe(true);
if (descriptor.effects.launchesProcess)
expect(audited?.launchesProcess, descriptor.operation).toBe(true);
if (descriptor.effects.mayWriteFilesystem)
expect(audited?.writesFilesystem, descriptor.operation).toBe(true);
}
},
);
it.each([false, true])(
"retains selected provider UI/process/network effects in direct and composed discovery (UI=%s)",
(showUi) => {
@@ -28,17 +137,191 @@ it.each([false, true])(
const entries = buildCapabilityInventory(input, enabledPolicy);
for (const name of [
"procedure_pseudo_code",
"procedure_assembly",
"read_function_instructions",
"analyze_function",
"batch_decompile",
"decompile_all",
"trace_native_values",
"inspect_native_api",
]) {
expect(entries.find((entry) => entry.name === name)).toMatchObject({
effects: {
changesUiState: showUi,
accessesNetwork: true,
launchesProcess: !showUi,
},
annotations: { open_world: true },
});
}
for (const name of ["list_procedures", "search_strings", "trace_feature"]) {
expect(entries.find((entry) => entry.name === name)).toMatchObject({
effects: { changesUiState: false, accessesNetwork: true },
annotations: { open_world: true },
});
}
},
);
it("omits effects of IDA calls hidden behind unavailable workflow prerequisites", () => {
const entries = buildCapabilityInventory(
{
open: true,
kind: "executable",
capabilities: providerCapabilitySets(
idaCapabilities(IDA_COMPATIBILITY_PROFILES.legacy, false),
),
},
enabledPolicy,
);
for (const name of ["find_xrefs_to_name", "get_navigation_context"]) {
expect(TOOL_EFFECTS[name], name).toMatchObject({
accessesNetwork: false,
launchesProcess: false,
writesFilesystem: false,
});
expect(
entries.find((entry) => entry.name === name),
name,
).toMatchObject({
available: false,
effects: {
accessesNetwork: false,
launchesProcess: false,
writesFilesystem: false,
},
annotations: { open_world: false },
});
}
});
it("advertises reachable IDA calls through native UI tracing", () => {
const ida = idaCapabilities(IDA_COMPATIBILITY_PROFILES.legacy, false);
const source = ida[0];
if (source === undefined) throw new Error("IDA capability fixture is empty");
const metadata = {
...source,
provider: { id: "native-fixture", name: "Native fixture", version: "1" },
operation: "inspect_native_dispatch_metadata" as const,
effects: {
...source.effects,
launchesProcess: false,
mayAccessNetwork: false,
mayWriteFilesystem: false,
mayShowUi: false,
},
};
expect(TOOL_EFFECTS.trace_native_ui_action).toMatchObject({
accessesNetwork: true,
launchesProcess: true,
writesFilesystem: true,
});
expect(toolSessionEffects("trace_native_ui_action")).toMatchObject({
accessesNetwork: false,
launchesProcess: false,
writesFilesystem: false,
});
const status = (capabilities: readonly CapabilityDescriptor[]) => ({
open: true,
kind: "executable",
capabilities: providerCapabilitySets(capabilities),
});
const withoutMetadata = buildCapabilityInventory(status(ida), enabledPolicy);
expect(
withoutMetadata.find((entry) => entry.name === "trace_native_ui_action"),
).toMatchObject({
effects: { accessesNetwork: false },
annotations: { open_world: false },
});
const withMetadata = buildCapabilityInventory(
status([...ida, metadata]),
enabledPolicy,
);
expect(
withMetadata.find((entry) => entry.name === "trace_native_ui_action"),
).toMatchObject({
effects: { accessesNetwork: true },
annotations: { open_world: true },
});
});
it("includes reachable IDA document and procedure effects in address-context discovery", () => {
const ida = idaCapabilities(IDA_COMPATIBILITY_PROFILES.modern, false);
expect(TOOL_EFFECTS.inspect_address_context).toMatchObject({
accessesNetwork: true,
launchesProcess: true,
writesFilesystem: true,
});
expect(toolSessionEffects("inspect_address_context")).toMatchObject({
accessesNetwork: false,
launchesProcess: false,
writesFilesystem: false,
});
const entryFor = (capabilities: readonly CapabilityDescriptor[]) =>
buildCapabilityInventory(
{
open: true,
kind: "executable",
capabilities: providerCapabilitySets(capabilities),
},
enabledPolicy,
).find((entry) => entry.name === "inspect_address_context");
expect(entryFor(ida)).toMatchObject({
effects: {
accessesNetwork: true,
launchesProcess: true,
writesFilesystem: true,
},
annotations: { open_world: true },
});
for (const operation of ["list_documents", "resolve_containing_procedure"]) {
expect(
entryFor(ida.filter((descriptor) => descriptor.operation === operation)),
operation,
).toMatchObject({
effects: { accessesNetwork: true },
annotations: { open_world: true },
});
}
const local = ida.map((descriptor) => ({
...descriptor,
effects: {
...descriptor.effects,
mayAccessNetwork: false,
launchesProcess: false,
mayWriteFilesystem: false,
},
}));
expect(entryFor(local)).toMatchObject({
effects: {
accessesNetwork: false,
launchesProcess: false,
writesFilesystem: false,
},
annotations: { open_world: false },
});
});
it("keeps native attached UI effects limited to the operations that show them", () => {
const input = {
open: true,
kind: "executable",
capabilities: providerCapabilitySets(
idaCapabilities(IDA_COMPATIBILITY_PROFILES.native, true),
),
};
const entries = buildCapabilityInventory(input, enabledPolicy);
for (const [name, showUi] of [
["procedure_pseudo_code", true],
["analyze_function", true],
["procedure_assembly", false],
["read_function_instructions", false],
] as const) {
expect(
entries.find((entry) => entry.name === name),
name,
).toMatchObject({
effects: { changesUiState: showUi, accessesNetwork: true },
annotations: { open_world: true },
});
}
});