diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index 0ebe47e0db..eb26f63d84 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -2275,9 +2275,6 @@ public final class ReaGhidraBridge extends HeadlessScript { throw new RequestFailure("invalid_request", "mode must be literal or regex"); } boolean caseSensitive = requireBoolean(params, "case_sensitive"); - List inventory = procedureSearch - ? procedures().stream().map(FunctionEntry::item).toList() - : strings(); ValueMatcher matcher; if (mode.equals("literal") && !caseSensitive) { if (!params.has("literal_ascii_candidate")) @@ -2291,7 +2288,26 @@ public final class ReaGhidraBridge extends HeadlessScript { : regexMatcher(expression, caseSensitive); } JsonArray items = new JsonArray(); - for (InventoryItem item : inventory) { + if (procedureSearch) { + // Search the names exact lookup accepts, keeping one row per + // procedure under its primary name. + for (FunctionEntry entry : procedures()) { + monitor.checkCancelled(); + ProcedureNameMatch match = matchProcedureNames(entry, matcher); + if (!match.matched()) { + continue; + } + JsonObject item = resultItem(entry.item, null); + if (!match.aliases().isEmpty()) { + JsonArray aliases = new JsonArray(); + for (String alias : match.aliases()) aliases.add(alias); + item.add("matched_aliases", aliases); + } + items.add(item); + } + return items; + } + for (InventoryItem item : strings()) { monitor.checkCancelled(); if (!matcher.matches(item.value)) { continue; @@ -2301,6 +2317,36 @@ public final class ReaGhidraBridge extends HeadlessScript { return items; } + /** + * The one definition of the names that select a procedure, shared by exact + * lookup and search: its primary name and every other symbol at its exact + * entry, each in leaf or namespace-qualified spelling. A label inside the + * body never names the procedure. Matching secondary symbols are reported + * by their qualified names, sorted. + */ + private ProcedureNameMatch matchProcedureNames(FunctionEntry entry, ValueMatcher matcher) throws Exception { + Function function = entry.function; + boolean primary = spellingMatches(function.getName(), entry.item.value, matcher); + Set aliases = null; + for (Symbol symbol : currentProgram.getSymbolTable().getSymbols(function.getEntryPoint())) { + monitor.checkCancelled(); + String qualified = symbol.getName(true); + if (!spellingMatches(symbol.getName(), qualified, matcher)) continue; + if (qualified.equals(entry.item.value)) { + primary = true; + continue; + } + if (aliases == null) aliases = new TreeSet<>(); + aliases.add(qualified); + } + return new ProcedureNameMatch(primary, aliases == null ? List.of() : List.copyOf(aliases)); + } + + /** A leaf is the suffix of its qualified spelling, so a literal found in it is found in both. */ + private static boolean spellingMatches(String leaf, String qualified, ValueMatcher matcher) { + return matcher.matches(qualified) || (!leaf.equals(qualified) && matcher.matches(leaf)); + } + private List names() throws Exception { if (nameInventory != null) { return nameInventory; @@ -2403,26 +2449,13 @@ public final class ReaGhidraBridge extends HeadlessScript { // exact symbol name, even when every character is a hexadecimal digit. boolean explicitAddress = isExplicitReaAddress(value); if (!explicitAddress) { + // Imported labels (for example Mach-O _main) can share a function + // entry without becoming its primary symbol; search accepts the same. + ValueMatcher selector = value::equals; List matches = new ArrayList<>(); for (FunctionEntry entry : procedures()) { monitor.checkCancelled(); - Function function = entry.function; - Symbol symbol = function.getSymbol(); - String qualified = symbol == null ? function.getName() : symbol.getName(true); - boolean matchesName = function.getName().equals(value) || qualified.equals(value); - if (!matchesName) { - // Imported labels (for example Mach-O _main) can share a - // function entry without becoming its primary symbol. Only - // exact entries qualify; an interior label is not a selector. - for (Symbol entrySymbol : currentProgram.getSymbolTable().getSymbols(function.getEntryPoint())) { - monitor.checkCancelled(); - if (entrySymbol.getName().equals(value) || entrySymbol.getName(true).equals(value)) { - matchesName = true; - break; - } - } - } - if (matchesName) matches.add(function); + if (matchProcedureNames(entry, selector).matched()) matches.add(entry.function); } if (matches.size() > 1) throw new RequestFailure("ambiguous", "Ghidra procedure name is ambiguous: " + value + @@ -5139,6 +5172,11 @@ public final class ReaGhidraBridge extends HeadlessScript { private record AddressNameConflict(Namespace namespace, String leaf, List
requesters, List
holders) {} private record AddressNameFailure(String reason, AddressNameConflict conflict) {} private record FunctionEntry(Function function, InventoryItem item) {} + private record ProcedureNameMatch(boolean primary, List aliases) { + boolean matched() { + return primary || !aliases.isEmpty(); + } + } private record FunctionAnalysisObservation(JsonObject dossier, List limitations, JsonObject relationshipCoverage, boolean decompilerFailed) {} private record CallBoundaryObservation(List limitations, Integer unrepresentedCalleeCalls, JsonArray unresolvedCalls) {} private record SwiftStringLiteralDecode(JsonArray literals, JsonArray omissions) {} diff --git a/docs/kuna-provider.md b/docs/kuna-provider.md index ced1d2d7f0..c6266ce718 100644 --- a/docs/kuna-provider.md +++ b/docs/kuna-provider.md @@ -47,6 +47,8 @@ Windows is not supported yet. Supported targets are ELF and thin Mach-O executab | `xrefs` | Exact-address incoming reference sites, with forwarding aliases filtered explicitly | | `analyze_function` | Pseudocode and assembly from the export, plus call relationships; typed references, comments, strings and CFG remain unavailable | +Kuna's inventory gives some entries aliases, such as ELF versioned-symbol names and generated `sub_` names. Name selectors accept an alias that names one entry and list every entry a shared alias names. `search_procedures` matches aliases too: each entry appears once under its primary name, with the matching aliases in `matched_aliases`. `list_procedures` shows primary names only. + Typed procedure references, basic blocks, raw-memory tools, annotations and type recovery are not available from this provider. Use another provider for those operations. ## How answers are produced diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 46c47729d3..c643b88cd3 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -742,6 +742,19 @@ requirements. Compare the union of group names with `tools/list` when checking that a client received the full catalog. An operation absent from all groups is missing from the complete inventory; it is not an implicit unavailable entry. +Availability also reflects the active provider client's dispatched requests. A +refusal or provider failure observed for an operation keeps that operation +unavailable until a request for it dispatched later succeeds. A provider adapter +or process failure also marks that provider's other declared-available +operations unavailable until any request to that provider dispatched later +succeeds; their remediation states that the status was inferred rather than +observed for them. Recovery is not proof that each operation works, and failures +observed on other operations or providers remain. Outcomes are ordered by +dispatch to the provider client rather than reply completion, so a late reply +from an older request does not override a newer outcome. Request deadlines and +cancellations neither mark nor clear availability. A new provider client starts +without these outcomes. + `detail` selects the projection and the result echoes it. The default `summary` keeps open state, target identity, the binding, current-route `capabilities`, run, activity and health status, and each provider candidate's @@ -1351,7 +1364,9 @@ cursor outside a procedure is represented as procedure resolution returns typed unreadable output; provider errors never become absence from their message text. `inspect_address_context` requires an explicit address and returns name, procedure, comment, inline-comment, and bookmark facets; -unsupported facets are local `unavailable` outcomes. Use `current_document`, +unsupported facets are local `unavailable` outcomes. A facet whose input is +rejected includes its canonical `error` projection, including any structured +issues, alongside the other facet outcomes. Use `current_document`, `current_address`, and `current_procedure` for direct single-field lookups; use the aggregate tools when you need related context together. diff --git a/docs/native-investigation.md b/docs/native-investigation.md index ac09a4af86..1bf77b8d87 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -68,7 +68,10 @@ function renamed to `dead` remains selectable by name. Use the names returned by the inventory, including their namespaces and any platform symbol prefix. Ghidra also accepts secondary symbols at a function entry, such as an imported `_main` label. A label inside a function is not a procedure-name selector; use -its address when selecting the containing function. +its address when selecting the containing function. `search_procedures` matches +the same names, in leaf or qualified spelling: the function appears once with +its primary name as `value`, and `matched_aliases` lists the qualified names of +the secondary symbols that matched. Overloads can share a fully qualified name; ambiguity errors return every matching entry address so the caller can select the intended function directly. diff --git a/scripts/lib/real-ghidra-entry-aliases.mjs b/scripts/lib/real-ghidra-entry-aliases.mjs index 9fd3cefdfc..7b87ec9bb3 100644 --- a/scripts/lib/real-ghidra-entry-aliases.mjs +++ b/scripts/lib/real-ghidra-entry-aliases.mjs @@ -87,11 +87,50 @@ export async function verifyGhidraEntryAliases({ (await call("procedure_info", { procedure: alias.value })).entrypoint, alias.address, ); + // Search matches the names exact lookup accepts, as one procedure row + // under its primary name. + const escaped = alias.value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + for (const query of [ + { pattern: alias.value }, + { pattern: `^${escaped}$`, mode: "regex", case_sensitive: true }, + ]) { + assert.deepEqual( + (await call("search_procedures", query)) + .filter(({ address }) => address === alias.address) + .map(({ value, matched_aliases }) => ({ value, matched_aliases })), + [{ value: canonical.value, matched_aliases: [alias.value] }], + `Procedure search missed the entry alias for ${JSON.stringify(query)}`, + ); + } + assert.ok( + (await call("list_procedures")).every( + (row) => row.matched_aliases === undefined, + ), + "Listings must not report search matches", + ); + const traced = await call("trace_feature", { query: alias.value }); + assert.ok( + traced.matches.some( + ({ type, address, matched_aliases }) => + type === "procedure" && + address === alias.address && + matched_aliases?.includes(alias.value) === true, + ), + "trace_feature missed the procedure its entry alias names", + ); const interior = names.find( ({ value }) => value === `${prefix}rea_interior`, ); assert.ok(interior); assert.notEqual(interior.address, alias.address); + assert.ok( + (await call("search_procedures", { pattern: interior.value })).every( + ({ address, matched_aliases = [] }) => + address !== interior.address && + !matched_aliases.includes(interior.value), + ), + "An interior label must not match procedure search", + ); assert.equal( interior.symbol.type, "label", diff --git a/scripts/lib/real-ghidra-namespace-annotations.mjs b/scripts/lib/real-ghidra-namespace-annotations.mjs index 35ecfaa3c8..2e4736a9cd 100644 --- a/scripts/lib/real-ghidra-namespace-annotations.mjs +++ b/scripts/lib/real-ghidra-namespace-annotations.mjs @@ -68,6 +68,21 @@ export async function verifyGhidraNamespaceAnnotations({ assert.equal(ambiguous.code, "invalid_request"); for (const { address } of entries) assert.ok(JSON.stringify(ambiguous.details.issues).includes(address)); + // Search accepts the leaf spelling exact lookup accepts, keeping each + // procedure as one row under its qualified primary name. + const leafMatches = await call("search_procedures", { + pattern: "^same$", + mode: "regex", + case_sensitive: true, + }); + for (const { namespace, address } of entries) + assert.deepEqual( + leafMatches + .filter((row) => row.address === address) + .map(({ value }) => value), + [`${namespace}::same`], + `Leaf-name search missed ${namespace}::same`, + ); for (const { namespace, address } of entries) { const leaf = await call("annotate_native_function", { procedure: address, diff --git a/scripts/verify-real-ghidra-com.mjs b/scripts/verify-real-ghidra-com.mjs index 15d030d8d9..f5a38ab81e 100644 --- a/scripts/verify-real-ghidra-com.mjs +++ b/scripts/verify-real-ghidra-com.mjs @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { requireMcpLifecycleResult, requireMcpOutput, + requireMcpToolError, } from "./lib/mcp-verifier-results.mjs"; import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; @@ -203,6 +204,34 @@ try { nativeInstructionSchema.safeParse({ ...unlisted, status: "undecodable" }) .success, ); + const contextDefinition = tools.find( + (tool) => tool.name === "inspect_address_context", + ); + assert.ok(contextDefinition?.outputSchema); + const documents = await call("list_documents"); + assert.equal(documents.length, 1); + const document = documents[0]; + for (const input of [ + { address: fixture.entry, document: `${document}-not-a-program` }, + { address: "not-an-address", document }, + ]) { + const direct = requireMcpToolError( + await client.callTool({ name: "address_name", arguments: input }), + ); + const response = await client.callTool( + { name: "inspect_address_context", arguments: input }, + { timeout: 240000, toolDefinition: contextDefinition }, + ); + const context = requireMcpLifecycleResult( + response, + "inspect_address_context", + ); + assert.equal(context.name.state, "unavailable"); + assert.equal(context.name.error.code, "invalid_request"); + assert.deepEqual(context.name.error.details.issues, direct.details.issues); + assert.equal(context.name.reason, direct.message); + assert.equal(context.name.remediation, direct.remediation.action); + } assert.equal( (await call("resolve_native_call_targets", { address: unlistedAddress })) .status, @@ -427,6 +456,7 @@ try { cli_mcp_parity: true, address_context_comment_recovery: true, }, + context_input_errors_preserved: true, original_unchanged: true, address_truncation_rejected: true, limitations: image.limitations, diff --git a/src/application/AnalysisContextQueries.ts b/src/application/AnalysisContextQueries.ts index 3bf104a0a2..e82e70ee72 100644 --- a/src/application/AnalysisContextQueries.ts +++ b/src/application/AnalysisContextQueries.ts @@ -7,8 +7,13 @@ import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { AnalysisCancelledError, AnalysisCapabilityUnavailableError, + AnalysisInputError, AnalysisOutputError, } from "../domain/analysisErrorCore.js"; +import { + projectAnalysisError, + type AnalysisErrorProjection, +} from "../domain/analysisErrorProjection.js"; import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { analysisErrorWithCancellation } from "../domain/analysisErrorCancellation.js"; import type { JsonValue } from "../domain/jsonValue.js"; @@ -24,6 +29,7 @@ type Facet = readonly state: "unavailable"; readonly reason: string; readonly remediation: string; + readonly error?: AnalysisErrorProjection; }; /** Recovery for a comment inside an observed containing function. */ @@ -190,16 +196,28 @@ const unavailableFacet = ( error: AnalysisError | undefined, operation: AnalysisOperation, capabilityRecovery?: string, -): Facet => ({ - state: "unavailable", - reason: error?.message ?? `${operation} returned no result`, - remediation: - error instanceof AnalysisCapabilityUnavailableError && !error.retryable - ? (error.remediationAction ?? - capabilityRecovery ?? - ADDRESS_CONTEXT_UNAVAILABLE_RECOVERY) - : `Call ${operation} directly to inspect provider availability and diagnostics.`, -}); +): Facet => { + if (error instanceof AnalysisInputError) { + const projected = projectAnalysisError(error); + if (projected.code === "invalid_request") + return { + state: "unavailable", + reason: projected.message, + remediation: projected.remediation.action, + error: projected, + }; + } + return { + state: "unavailable", + reason: error?.message ?? `${operation} returned no result`, + remediation: + error instanceof AnalysisCapabilityUnavailableError && !error.retryable + ? (error.remediationAction ?? + capabilityRecovery ?? + ADDRESS_CONTEXT_UNAVAILABLE_RECOVERY) + : `Call ${operation} directly to inspect provider availability and diagnostics.`, + }; +}; const isRequestLevelFailure = (error: AnalysisError): boolean => error._tag === "AnalysisCancelledError" || diff --git a/src/application/CallPathTracing.ts b/src/application/CallPathTracing.ts index a114b5efb8..58a897b105 100644 --- a/src/application/CallPathTracing.ts +++ b/src/application/CallPathTracing.ts @@ -1,17 +1,22 @@ import type { CallRelationshipDirection } from "../contracts/enhancedInputs.js"; -import { CALL_RELATIONSHIP_TRAVERSALS } from "./CallRelationshipTraversal.js"; +import { + cleanupStoppedTraversal, + readCallRelationships, + type CallRelationshipObserver, +} from "./CallRelationshipTraversal.js"; import type { - AnalysisExecution, AnalysisOperation, ExecutionOptions, } from "./AnalysisProvider.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; -import { parseRelatedAddresses } from "../domain/native/nativeObservation.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { err, ok, type Result } from "../domain/result.js"; -import type { EnhancedResult } from "./EnhancedToolTypes.js"; +import { + failedWithPartialResult, + type EnhancedResult, +} from "./EnhancedToolTypes.js"; import { resolveProcedureAddress } from "./ProcedureAddressResolution.js"; import type { RelationshipCoverage } from "../domain/relationshipCoverage.js"; @@ -22,13 +27,6 @@ type AnalysisCall = ( selectorOrigin?: ExecutionOptions["selectorOrigin"], ) => Promise>; -type RelationshipCall = ( - name: AnalysisOperation, - arguments_: Readonly>, - signal?: AbortSignal, - selectorOrigin?: ExecutionOptions["selectorOrigin"], -) => Promise>; - export interface CallPathTraceInput { readonly start: string; readonly goal?: string | undefined; @@ -80,7 +78,7 @@ interface DiscoveredEdge { /** Trace direct caller/callee relationships until the graph is exhausted or the goal is reached. */ export const traceCallPath = async ( call: AnalysisCall, - callRelationship: RelationshipCall, + callRelationship: CallRelationshipObserver, input: CallPathTraceInput, signal?: AbortSignal, ): EnhancedResult => { @@ -123,7 +121,16 @@ export const traceCallPath = async ( current, ...(signal === undefined ? {} : { signal }), }); - if (!expanded.ok) return expanded; + if (!expanded.ok) { + if (!expanded.error.cleanupIncomplete) return expanded; + state.residual.add( + cleanupStoppedTraversal(address, state.queue.length - state.queueIndex), + ); + return failedWithPartialResult( + expanded.error, + projectTrace(resolvedInput, state), + ); + } if (expanded.value) break; } if (isAborted(signal)) @@ -148,44 +155,40 @@ const createTraceState = (input: CallPathTraceInput): TraceState => ({ }); const expandAddress = async ( - call: RelationshipCall, + call: CallRelationshipObserver, expansion: AddressExpansion, ): Promise> => { const { input, state, address, current, signal } = expansion; - const { relation, operation: tool } = - CALL_RELATIONSHIP_TRAVERSALS[input.direction]; - const result = await call(tool, { procedure: address }, signal, "provider"); - if (!result.ok) { - if ( - result.error._tag === "AnalysisCancelledError" || - signal?.aborted === true - ) - return result; - } + const read = await readCallRelationships( + call, + input.direction, + address, + signal, + ); + // A provider failure delivered with cancellation remains the selected outcome. + if ( + read.status === "stopped" || + (read.status === "unavailable" && isAborted(signal)) + ) + return err(read.error); if (isAborted(signal)) return err( new AnalysisCancelledError("trace_call_path", { cause: signal?.reason }), ); - if (!result.ok) { + if (read.status !== "observed") { state.failures.push({ address, - error: projectAnalysisError(result.error), + error: projectAnalysisError(read.error), }); - state.residual.add(`Call relationships were unavailable for ${address}.`); + state.residual.add( + `Call relationships were ${read.status} for ${address}.`, + ); return ok(false); } - const related = parseRelatedAddresses(result.value.result, relation, tool); - if (!related.ok) { - state.failures.push({ - address, - error: projectAnalysisError(related.error), - }); - state.residual.add(`Call relationships were unreadable for ${address}.`); - return ok(false); - } - for (const limitation of result.value.limitations) + const { execution, related } = read; + for (const limitation of execution.limitations) state.limitations.add(limitation); - const coverage = result.value.relationshipCoverage ?? { + const coverage = execution.relationshipCoverage ?? { status: "unknown", reason: "Provider did not report relationship coverage.", }; @@ -204,7 +207,7 @@ const expandAddress = async ( state.residual.add( `Call relationship coverage is unknown for ${address}: ${coverage.reason}`, ); - for (const relatedAddress of sortedUniqueAddresses(related.value)) { + for (const relatedAddress of sortedUniqueAddresses(related)) { recordEdge(input, state, { address, relatedAddress, diff --git a/src/application/CallRelationshipTraversal.test.ts b/src/application/CallRelationshipTraversal.test.ts new file mode 100644 index 0000000000..c52d6a06d2 --- /dev/null +++ b/src/application/CallRelationshipTraversal.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; + +import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisCapabilityUnavailableError, +} from "../domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { err, ok } from "../domain/result.js"; +import { + createAnalysisExecution, + type AnalysisOperationPort, +} from "./AnalysisProvider.js"; +import { EnhancedTools } from "./EnhancedTools.js"; + +const provider = { id: "fixture", name: "Fixture", version: "1" }; +const tools = ["get_call_graph", "trace_call_path"] as const; +const input = (tool: (typeof tools)[number]) => + tool === "get_call_graph" + ? { address: "0x1", direction: "forward" } + : { start: "0x1", goal: "0x4", direction: "forward" }; + +const cleanupFailure = () => + new AnalysisCapabilityUnavailableError( + provider.id, + "procedure_callees", + "child failed", + { cleanup: { reason: "worker cleanup failed", resources: ["worker"] } }, + ); + +/** 0x1 calls 0x2 and 0x3; 0x2's read is supplied by the test. */ +const port = ( + reads: string[], + second: (signal?: AbortSignal) => AnalysisError, +): AnalysisOperationPort => ({ + execute: async (operation, parameters, options) => { + const procedure = String(parameters.procedure); + if (operation === "procedure_address") + return ok(createAnalysisExecution(procedure, provider)); + reads.push(procedure); + if (procedure === "0x2") return err(second(options?.signal)); + return ok( + createAnalysisExecution( + procedure === "0x1" ? ["0x2", "0x3"] : ["0x4"], + provider, + { relationshipCoverage: { status: "complete" } }, + ), + ); + }, +}); + +describe.each(tools)("%s cleanup ownership", (tool) => { + it("returns the provider's cleanup failure so its owner resolves the outcome", async () => { + const failure = cleanupFailure(); + let released = false; + const lease = new AnalysisCleanupLease(async () => { + if (!released) throw new Error("still owned"); + }, ["worker"]); + lease.bindCleanupError(failure); + failure.bindCleanupRetry({ operation: "close_binary" }); + const reads: string[] = []; + + const result = await new EnhancedTools(port(reads, () => failure)).execute( + tool, + input(tool), + ); + + if (result.ok) throw new Error("expected incomplete cleanup"); + expect(result.error).toBe(failure); + expect("partialResult" in result).toBe(true); + expect(reads).toEqual(["0x1", "0x2"]); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "cleanup_incomplete", + recovery: { kind: "retry_cleanup", operation: "close_binary" }, + }); + await expect(lease.release()).rejects.toThrow("still owned"); + expect(projectAnalysisError(result.error).code).toBe("cleanup_incomplete"); + released = true; + await lease.release(); + expect(projectAnalysisError(result.error).code).toBe( + "capability_unavailable", + ); + }); + + it("keeps a cleanup failure primary when cancellation arrives with it", async () => { + const controller = new AbortController(); + const failure = cleanupFailure(); + const reads: string[] = []; + + const result = await new EnhancedTools( + port(reads, () => { + controller.abort({ requested: "stop" }); + return failure; + }), + ).execute(tool, input(tool), controller.signal); + + if (result.ok) throw new Error("expected incomplete cleanup"); + expect(result.error).toBe(failure); + expect(projectAnalysisError(result.error).code).toBe("cleanup_incomplete"); + expect(reads).toEqual(["0x1", "0x2"]); + }); + + it("stops at a provider cancellation whose own cleanup is unconfirmed", async () => { + const controller = new AbortController(); + const reads: string[] = []; + let cancellation: AnalysisCancelledError | undefined; + + const result = await new EnhancedTools( + port(reads, (signal) => { + controller.abort(); + cancellation = new AnalysisCancelledError("procedure_callees", { + cause: signal?.reason, + cleanup: { reason: "cancel cleanup failed", resources: ["worker"] }, + }); + return cancellation; + }), + ).execute(tool, input(tool), controller.signal); + + if (result.ok) throw new Error("expected cancellation"); + expect(result.error).toBe(cancellation); + expect(projectAnalysisError(result.error).code).toBe("cleanup_incomplete"); + expect(reads).toEqual(["0x1", "0x2"]); + }); +}); diff --git a/src/application/CallRelationshipTraversal.ts b/src/application/CallRelationshipTraversal.ts index 7b6cfea6c0..4b68e5bd3b 100644 --- a/src/application/CallRelationshipTraversal.ts +++ b/src/application/CallRelationshipTraversal.ts @@ -1,5 +1,14 @@ import type { CallRelationshipDirection } from "../contracts/enhancedInputs.js"; -import type { AnalysisOperation } from "./AnalysisProvider.js"; +import type { + AnalysisExecution, + AnalysisOperation, + ExecutionOptions, +} from "./AnalysisProvider.js"; +import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import type { AnalysisOutputError } from "../domain/analysisErrorCore.js"; +import type { JsonValue } from "../domain/jsonValue.js"; +import { parseRelatedAddresses } from "../domain/native/nativeObservation.js"; +import type { Result } from "../domain/result.js"; /** The provider read and response relation for each supported traversal direction. */ export const CALL_RELATIONSHIP_TRAVERSALS = { @@ -14,3 +23,62 @@ export const CALL_RELATIONSHIP_TRAVERSALS = { } > >; + +/** One observed provider operation, as composed workflows dispatch it. */ +export type CallRelationshipObserver = ( + name: AnalysisOperation, + arguments_: Readonly>, + signal?: AbortSignal, + selectorOrigin?: ExecutionOptions["selectorOrigin"], +) => Promise>; + +/** + * One procedure's relationship read. An unavailable or unreadable read leaves + * the rest of the traversal usable; a stopped read must end it. + */ +export type CallRelationshipRead = + | { + readonly status: "observed"; + readonly execution: AnalysisExecution; + readonly related: readonly string[]; + } + | { readonly status: "unavailable"; readonly error: AnalysisError } + | { readonly status: "unreadable"; readonly error: AnalysisOutputError } + | { readonly status: "stopped"; readonly error: AnalysisError }; + +/** Read and parse one procedure's direct relationships for a traversal. */ +export const readCallRelationships = async ( + observe: CallRelationshipObserver, + direction: CallRelationshipDirection, + procedure: string, + signal?: AbortSignal, +): Promise => { + const { operation, relation } = CALL_RELATIONSHIP_TRAVERSALS[direction]; + const execution = await observe(operation, { procedure }, signal, "provider"); + if (!execution.ok) + return { + // A provider that cannot confirm release of resources it owns has not + // finished this read; dispatching more reads would build on that state. + status: + execution.error._tag === "AnalysisCancelledError" || + execution.error.cleanupIncomplete + ? "stopped" + : "unavailable", + error: execution.error, + }; + const related = parseRelatedAddresses( + execution.value.result, + relation, + operation, + ); + return related.ok + ? { status: "observed", execution: execution.value, related: related.value } + : { status: "unreadable", error: related.error }; +}; + +/** The admitted traversal's caveat when unconfirmed cleanup stopped it. */ +export const cleanupStoppedTraversal = ( + procedure: string, + unexpanded: number, +): string => + `Traversal stopped at ${procedure} because the provider did not confirm cleanup of resources it owns; ${unexpanded === 1 ? "1 discovered procedure was" : `${unexpanded} discovered procedures were`} not expanded.`; diff --git a/src/application/EnhancedToolTypes.ts b/src/application/EnhancedToolTypes.ts index 006313b250..686a7bab83 100644 --- a/src/application/EnhancedToolTypes.ts +++ b/src/application/EnhancedToolTypes.ts @@ -10,7 +10,23 @@ import { type EnhancedToolName, } from "../contracts/enhancedInputs.js"; -export type EnhancedResult = Promise>; +/** A failure that stopped a workflow after it admitted part of its result. */ +export interface PartialEnhancedFailure { + readonly ok: false; + readonly error: AnalysisError; + /** The workflow's own result shape, holding only work admitted before the stop. */ + readonly partialResult: JsonValue; +} +export type EnhancedOutcome = + | Result + | PartialEnhancedFailure; +export type EnhancedResult = Promise; + +/** Keep the stopping failure primary while retaining the admitted result. */ +export const failedWithPartialResult = ( + error: AnalysisError, + partialResult: JsonValue, +): PartialEnhancedFailure => ({ ok: false, error, partialResult }); export type TraceReference = { readonly target_address: string; readonly source_address: string; diff --git a/src/application/EnhancedTools.ts b/src/application/EnhancedTools.ts index 4abca5d938..08b001b16f 100644 --- a/src/application/EnhancedTools.ts +++ b/src/application/EnhancedTools.ts @@ -2,7 +2,10 @@ import { permitsCapabilityFallback } from "./CapabilityFallback.js"; import { withOverviewInventoryCoverage } from "../domain/native/inventoryCoverage.js"; import type { NativeCallGraph } from "../domain/native/nativeCallGraph.js"; import { nativeProcedureInputSchema } from "../domain/native/nativeProcedureInput.js"; -import { CALL_RELATIONSHIP_TRAVERSALS } from "./CallRelationshipTraversal.js"; +import { + cleanupStoppedTraversal, + readCallRelationships, +} from "./CallRelationshipTraversal.js"; import { z } from "zod"; import type { @@ -28,7 +31,6 @@ import { parseNativeInventoryCount, addressedValueSchema, } from "../domain/native/nativeInventory.js"; -import { parseRelatedAddresses } from "../domain/native/nativeObservation.js"; import { parseSegments, projectBinaryOverview, @@ -59,6 +61,7 @@ import { } from "../domain/apple/machoTargetSelection.js"; import { + failedWithPartialResult, invalidEnhancedInput, type EnhancedResult, type ValidatedEnhancedCall, @@ -615,8 +618,6 @@ export class EnhancedTools { }, signal?: AbortSignal, ): EnhancedResult { - const { relation, operation: tool } = - CALL_RELATIONSHIP_TRAVERSALS[input.direction]; const limitations = new Set(); const resolved = await this.#observe( "procedure_address", @@ -635,40 +636,41 @@ export class EnhancedTools { ]; let queueIndex = 0; const graph: Record = {}; + const sortedLimitations = () => + [...limitations].sort(compareUnicodeCodePoints); while (queueIndex < queue.length) { const current = queue[queueIndex++]; if (current === undefined) continue; - const level = String(current.depth); - graph[level] ??= []; - - const result = await this.#observe( - tool, - { procedure: current.address }, + const read = await readCallRelationships( + this.#observe.bind(this), + input.direction, + current.address, signal, - "provider", ); - if (!result.ok) { - if (isAnalysisCancellation(result.error)) return result; - graph[level].push({ + if (read.status === "stopped") { + if (!read.error.cleanupIncomplete) return err(read.error); + limitations.add( + cleanupStoppedTraversal(current.address, queue.length - queueIndex), + ); + return failedWithPartialResult(read.error, { + graph, + limitations: sortedLimitations(), + }); + } + const nodes = (graph[String(current.depth)] ??= []); + if (read.status !== "observed") { + nodes.push({ address: current.address, status: "error", - error: projectAnalysisError(result.error), + error: projectAnalysisError(read.error), } satisfies NativeCallGraph["graph"][string][number]); continue; } - const related = parseRelatedAddresses(result.value.result, relation); - if (!related.ok) { - graph[level].push({ - address: current.address, - status: "error", - error: projectAnalysisError(related.error), - } satisfies NativeCallGraph["graph"][string][number]); - continue; - } - for (const limitation of result.value.limitations) + const { execution, related } = read; + for (const limitation of execution.limitations) limitations.add(limitation); - const reportedCoverage = result.value.relationshipCoverage; + const reportedCoverage = execution.relationshipCoverage; const coverage = reportedCoverage ?? { status: "unknown" as const, reason: "Provider did not report relationship coverage.", @@ -676,7 +678,7 @@ export class EnhancedTools { const relationshipLimitations = reportedCoverage === undefined ? [ - ...result.value.limitations, + ...execution.limitations, "Provider did not report relationship coverage.", ] : [ @@ -692,26 +694,23 @@ export class EnhancedTools { const nodeLimitations = [...new Set(relationshipLimitations)].sort( compareUnicodeCodePoints, ); - graph[level].push({ + nodes.push({ address: current.address, status: "ok", - calls: [...related.value], + calls: [...related], relationship_coverage: coverage, ...(nodeLimitations.length === 0 ? {} : { limitations: nodeLimitations }), } satisfies NativeCallGraph["graph"][string][number]); - for (const address of related.value) { + for (const address of related) { if (!discovered.has(address)) { discovered.add(address); queue.push({ address, depth: current.depth + 1 }); } } } - return ok({ - graph, - limitations: [...limitations].sort(compareUnicodeCodePoints), - }); + return ok({ graph, limitations: sortedLimitations() }); } async #analyzeSwiftTypes( diff --git a/src/application/WorkflowEvidence.ts b/src/application/WorkflowEvidence.ts index 1b59ed7fdd..2623fa1f4a 100644 --- a/src/application/WorkflowEvidence.ts +++ b/src/application/WorkflowEvidence.ts @@ -7,8 +7,8 @@ import type { AnalysisOperation, AnalysisOperationPort, } from "./AnalysisProvider.js"; -import type { AnalysisError } from "../domain/analysisErrorBase.js"; -import { ok, type Result } from "../domain/result.js"; +import { ok } from "../domain/result.js"; +import type { EnhancedResult } from "./EnhancedToolTypes.js"; import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js"; import type { WorkflowSnapshotRecordInput } from "./binary/BinarySessionRecords.js"; import { @@ -70,21 +70,26 @@ export const composedLimitationBudget = ( ): ComposedLimitationBudget | undefined => operation === "decompile_all" ? DECOMPILE_ALL_LIMITATIONS : undefined; +/** What one composed call answered, or admitted before a failure stopped it. */ +export interface ComposedWorkflowObservation { + readonly result: JsonValue; + readonly limitations: readonly string[]; + readonly cachePolicy?: "live"; + readonly evidenceLinks?: readonly string[]; +} + +/** A composed outcome; only a stopped failure carries its admitted observation. */ +export type ComposedWorkflowResult = + AnalysisResultWithSources & { + readonly partial?: ComposedWorkflowObservation; + }; + /** Retain the limits of exactly the observations used by one composed call. */ export const executeComposedWorkflow = async ( analysis: AnalysisOperationPort, - execute: ( - observed: AnalysisOperationPort, - ) => Promise>, + execute: (observed: AnalysisOperationPort) => EnhancedResult, budget?: ComposedLimitationBudget, -): Promise< - AnalysisResultWithSources<{ - readonly result: JsonValue; - readonly limitations: readonly string[]; - readonly cachePolicy?: "live"; - readonly evidenceLinks?: readonly string[]; - }> -> => { +): Promise => { const limitations = new Map(); const evidenceLinks = new Set(); const sourceLedger = new EvidenceLedger(); @@ -149,24 +154,32 @@ export const executeComposedWorkflow = async ( .then(() => execute(observed)) .finally(() => workScope.clear()); const sources = sourceLedger.recordsForAnalysis(); - if (!result.ok) return withEvidenceSources(result, sources); + if (!result.ok && !("partialResult" in result)) + return withEvidenceSources(result, sources); for (const source of sources) evidenceLinks.add(source.evidence_id); + const observation = (value: JsonValue): ComposedWorkflowObservation => ({ + result: value, + limitations: [ + ...renderSourceLimitations([...limitations.values()]), + ...(omittedObservations === 0 + ? [] + : [ + `${omittedObservations} per-item provider limitation observation${omittedObservations === 1 ? " was" : "s were"} omitted from this answer; ${budget?.retainedIn}.`, + ]), + ], + ...(cachePolicy === undefined ? {} : { cachePolicy }), + ...(evidenceLinks.size === 0 + ? {} + : { evidenceLinks: [...evidenceLinks].sort(compareUnicodeCodePoints) }), + }); return withEvidenceSources( - ok({ - result: result.value, - limitations: [ - ...renderSourceLimitations([...limitations.values()]), - ...(omittedObservations === 0 - ? [] - : [ - `${omittedObservations} per-item provider limitation observation${omittedObservations === 1 ? " was" : "s were"} omitted from this answer; ${budget?.retainedIn}.`, - ]), - ], - ...(cachePolicy === undefined ? {} : { cachePolicy }), - ...(evidenceLinks.size === 0 - ? {} - : { evidenceLinks: [...evidenceLinks].sort(compareUnicodeCodePoints) }), - }), + result.ok + ? ok(observation(result.value)) + : { + ok: false as const, + error: result.error, + partial: observation(result.partialResult), + }, sources, ); }; @@ -206,6 +219,27 @@ export const createWorkflowEvidence = (input: { : {}), }); +/** Retain a stopped workflow's admitted observation as partial workflow Evidence on its failure. */ +export const retainPartialWorkflowEvidence = ( + outcome: ComposedWorkflowResult, + context: Pick< + Parameters[0], + "target" | "operation" | "parameters" | "upstreamProfile" + >, +): void => { + if (outcome.ok || outcome.partial === undefined) return; + const evidence = createWorkflowEvidence({ + ...context, + result: outcome.partial.result, + upstreamLimitations: outcome.partial.limitations, + ...(outcome.partial.evidenceLinks === undefined + ? {} + : { upstreamEvidenceLinks: outcome.partial.evidenceLinks }), + }); + // A partial observation the failing read already reported stays its own. + outcome.error.retainPartialObservation(evidence); +}; + const batchProcedureLocations = (result: JsonValue): EvidenceLocation[] => { if ( result === null || diff --git a/src/application/binary/BinarySession.ts b/src/application/binary/BinarySession.ts index b73761a502..75fccdc330 100644 --- a/src/application/binary/BinarySession.ts +++ b/src/application/binary/BinarySession.ts @@ -49,6 +49,7 @@ import { InvestigationRecords } from "../investigation/InvestigationRecords.js"; import { recordEvidenceSources } from "../investigation/recordEvidenceSources.js"; import { BinarySessionRecords } from "./BinarySessionRecords.js"; import { binarySessionStatus } from "./BinarySessionStatus.js"; +import { SessionRuntimeAvailability } from "./SessionRuntimeAvailability.js"; import { isSourceEvidenceRetainable } from "./AnalysisSnapshotCache.js"; import type { BinarySessionDetail } from "../../contracts/sessionStatusContract.js"; import { @@ -197,10 +198,7 @@ export class BinarySession readonly #bundleResourceFactory: BundleResourceClientFactory | undefined; #activeBundleResources: ActiveBundleResourceSession | undefined; #pendingBundleResourceCleanup: ActiveBundleResourceSession[] = []; - readonly #runtimeUnavailability = new Map< - string, - { readonly reason: string } - >(); + readonly #runtimeAvailability = new SessionRuntimeAvailability(); readonly #availabilityListeners = new Set<() => void | Promise>(); constructor( @@ -1252,7 +1250,7 @@ export class BinarySession }), route: this.#currentRoute(), router: this.#providerRouter, - runtimeUnavailability: this.#runtimeUnavailability, + runtimeUnavailability: this.#runtimeAvailability.entries, runId: run?.runId, runtimeLineageSnapshots: client?.runtimeLineageSnapshots?.() ?? [], requestActivitySnapshots: client?.requestActivitySnapshots?.() ?? [], @@ -1414,6 +1412,7 @@ export class BinarySession // A failed reply cannot prove that a dispatched metadata write rolled back. // Stop replay before dispatch, including reads overlapping the write. if (capability.effects.mutatesArtifact) this.invalidateSnapshot(); + const dispatch = this.#runtimeAvailability.dispatch(); const call = active.client.execute(name, arguments_, options); const result = unrecordedExecution(await call); const profiled = bindExecutionTarget( @@ -1442,7 +1441,15 @@ export class BinarySession this.#active === active ) this.#active.nativeSourceAdmitted = true; - this.#observeRuntimeAvailability(name, execution); + if ( + this.#runtimeAvailability.observe( + dispatch, + capability, + active.route.capabilities.values(), + execution, + ) + ) + this.#emitAvailabilityChanged(); // Source facts belong to the observation, even when this query cannot be // replayed (for example a Ghidra procedure read without a document selector). if (execution.ok && execution.value.sourceEvidence !== undefined) { @@ -1486,56 +1493,8 @@ export class BinarySession return execution; } - #observeRuntimeAvailability( - operation: AnalysisOperation, - result: Result, - ): void { - if (result.ok) { - if (this.#markRuntimeAvailable(operation)) - this.#emitAvailabilityChanged(); - return; - } - if (result.error._tag === "AnalysisCapabilityUnavailableError") { - if (this.#markRuntimeUnavailable(operation, result.error.message)) - this.#emitAvailabilityChanged(); - return; - } - if ( - [ - "ProviderAdapterError", - "HopperProcessError", - "HopperStartError", - ].includes(result.error._tag) - ) { - const capabilities = this.#active?.route.capabilities; - const providerId = capabilities?.get(operation)?.provider.id; - let changed = false; - for (const descriptor of capabilities?.values() ?? []) - if (providerId !== undefined && descriptor.provider.id === providerId) - changed = - this.#markRuntimeUnavailable( - descriptor.operation, - `${result.error._tag}: ${result.error.message}`, - ) || changed; - if (changed) this.#emitAvailabilityChanged(); - } - } - - #markRuntimeAvailable(operation: string): boolean { - return this.#runtimeUnavailability.delete(operation); - } - - #markRuntimeUnavailable(operation: string, reason: string): boolean { - const current = this.#runtimeUnavailability.get(operation); - if (current?.reason === reason) return false; - this.#runtimeUnavailability.set(operation, { reason }); - return true; - } - #clearRuntimeAvailability(): void { - if (this.#runtimeUnavailability.size === 0) return; - this.#runtimeUnavailability.clear(); - this.#emitAvailabilityChanged(); + if (this.#runtimeAvailability.clear()) this.#emitAvailabilityChanged(); } #emitAvailabilityChanged(): void { diff --git a/src/application/binary/SessionRuntimeAvailability.ts b/src/application/binary/SessionRuntimeAvailability.ts new file mode 100644 index 0000000000..7519865a4b --- /dev/null +++ b/src/application/binary/SessionRuntimeAvailability.ts @@ -0,0 +1,169 @@ +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { Result } from "../../domain/result.js"; +import type { CapabilityDescriptor } from "../AnalysisProvider.js"; + +/** Failures of the provider adapter or process rather than of one request. */ +const PROVIDER_WIDE_FAILURES: ReadonlySet = new Set([ + "ProviderAdapterError", + "HopperProcessError", + "HopperStartError", +]); + +interface Failure { + readonly dispatch: number; + readonly reason: string; +} + +interface ProviderFailure extends Failure { + readonly operation: string; +} + +/** Newest failure and newest success, both by dispatch, for one owner. */ +interface Outcomes { + failure: F | undefined; + succeeded: number; +} + +type RoutedOperation = Pick< + CapabilityDescriptor, + "operation" | "provider" | "available" +>; + +/** Reason recorded for an operation's own refusal or provider-wide failure. */ +export const runtimeFailureReason = ( + failure: AnalysisError, +): string | undefined => { + if (failure._tag === "AnalysisCapabilityUnavailableError") + return failure.message; + return PROVIDER_WIDE_FAILURES.has(failure._tag) + ? `${failure._tag}: ${failure.message}` + : undefined; +}; + +/** Explain unavailability inferred for an operation no request failed for. */ +export const inferredUnavailabilityReason = ( + providerId: string, + failure: Pick, +): string => + `Inferred from the ${failure.operation} failure (${failure.reason}). Retry after repairing the provider; a later successful ${providerId} request clears this.`; + +/** + * Own the runtime unavailability overlaid on the active route's declared + * capabilities. + * + * An operation is unavailable while its newest refusal or provider-wide + * failure was dispatched after its newest success. A declared-available + * operation is also unavailable, as inferred, while its provider's newest + * provider-wide failure was dispatched after any success from that provider. + * One success therefore returns inferred siblings to their declared + * availability without proving each is healthy, and keeps failures observed + * for other operations. + * + * Only the newest dispatch of each outcome matters, so the projection does not + * depend on the order in which overlapping replies complete. Dispatch is the + * latest order this session owns; it does not establish how a provider + * interleaves overlapping requests. + */ +export class SessionRuntimeAvailability { + readonly #operations = new Map>(); + readonly #providers = new Map>(); + #projected: ReadonlyMap = new Map(); + #dispatched = 0; + + /** Current runtime unavailability keyed by operation. */ + get entries(): ReadonlyMap { + return this.#projected; + } + + /** Order one request immediately before it reaches the provider client. */ + dispatch(): number { + this.#dispatched += 1; + return this.#dispatched; + } + + /** + * Record one dispatched request's outcome against the route it ran on and + * report whether visible availability changed. + */ + observe( + dispatch: number, + capability: RoutedOperation, + route: Iterable, + result: Result, + ): boolean { + const operation = outcomes(this.#operations, capability.operation); + const provider = outcomes(this.#providers, capability.provider.id); + if (result.ok) { + operation.succeeded = Math.max(operation.succeeded, dispatch); + provider.succeeded = Math.max(provider.succeeded, dispatch); + // A success can only remove unavailability. + if (this.#projected.size === 0) return false; + } else { + const reason = runtimeFailureReason(result.error); + if (reason === undefined) return false; + operation.failure = newer(operation.failure, { dispatch, reason }); + if (PROVIDER_WIDE_FAILURES.has(result.error._tag)) + provider.failure = newer(provider.failure, { + dispatch, + reason, + operation: capability.operation, + }); + } + return this.#project(route); + } + + /** Forget runtime outcomes when the active provider client is replaced. */ + clear(): boolean { + this.#operations.clear(); + this.#providers.clear(); + if (this.#projected.size === 0) return false; + this.#projected = new Map(); + return true; + } + + #project(route: Iterable): boolean { + const projected = new Map(); + for (const descriptor of route) { + const reason = this.#unavailableReason(descriptor); + if (reason !== undefined) projected.set(descriptor.operation, { reason }); + } + const changed = + projected.size !== this.#projected.size || + [...projected].some( + ([operation, { reason }]) => + this.#projected.get(operation)?.reason !== reason, + ); + this.#projected = projected; + return changed; + } + + #unavailableReason(descriptor: RoutedOperation): string | undefined { + const operation = this.#operations.get(descriptor.operation); + if ( + operation?.failure !== undefined && + operation.failure.dispatch > operation.succeeded + ) + return operation.failure.reason; + // A declared refusal already explains its operation and is never dispatched. + if (!descriptor.available) return undefined; + const provider = this.#providers.get(descriptor.provider.id); + return provider?.failure !== undefined && + provider.failure.dispatch > provider.succeeded + ? inferredUnavailabilityReason(descriptor.provider.id, provider.failure) + : undefined; + } +} + +const outcomes = ( + owners: Map>, + key: string, +): Outcomes => { + const existing = owners.get(key); + if (existing !== undefined) return existing; + const created: Outcomes = { failure: undefined, succeeded: 0 }; + owners.set(key, created); + return created; +}; + +const newer = (current: F | undefined, next: F): F => + current !== undefined && current.dispatch > next.dispatch ? current : next; diff --git a/src/contracts/errorSchemas.ts b/src/contracts/errorSchemas.ts index 4395468939..aa8b8f8aac 100644 --- a/src/contracts/errorSchemas.ts +++ b/src/contracts/errorSchemas.ts @@ -34,9 +34,12 @@ const generic = (code: Code) => .object({ code: z.literal(code), ...common, details: genericDetails }) .strict(); +/** The canonical invalid-input projection, also used for facet-local refusals. */ +export const analysisInputErrorProjectionSchema = generic("invalid_request"); + /** Stable discriminated schema shared by every CLI and MCP error surface. */ export const analysisErrorProjectionSchema = z.discriminatedUnion("code", [ - generic("invalid_request"), + analysisInputErrorProjectionSchema, generic("access_denied"), generic("artifact_changed"), generic("unreadable_output"), diff --git a/src/contracts/officialToolContracts.ts b/src/contracts/officialToolContracts.ts index 47df239305..9d1ab1a899 100644 --- a/src/contracts/officialToolContracts.ts +++ b/src/contracts/officialToolContracts.ts @@ -218,7 +218,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [ ), official( "search_procedures", - `Search every analyzed procedure name using literal matching by default or regex when requested. Results are deterministic and complete. ${hopperRegexDeadline} Select detail summary for counts and retained native-inventory pages; complete remains the default.`, + `Search every analyzed procedure name, including secondary names exact lookup accepts, using literal matching by default or regex when requested. Each procedure appears once under its primary name; matched_aliases lists matching secondary names. Results are deterministic and complete. ${hopperRegexDeadline} Select detail summary for counts and retained native-inventory pages; complete remains the default.`, z.object({ ...nativeSearchInput, detail: analysisDetailSchema }), { evidenceParameters: "omit-detail" }, ), diff --git a/src/contracts/sessionToolContracts.ts b/src/contracts/sessionToolContracts.ts index dc245ecaed..160991addb 100644 --- a/src/contracts/sessionToolContracts.ts +++ b/src/contracts/sessionToolContracts.ts @@ -186,7 +186,7 @@ export const SESSION_TOOL_CONTRACTS = [ }, session( "inspect_address_context", - "Inspect one explicit reproducible address for its analyzed name, containing procedure, regular and inline comments, and matching bookmarks. Each unsupported facet returns a typed unavailable outcome; use xrefs, assembly, or pseudocode for those deeper views.", + "Inspect one explicit reproducible address for its analyzed name, containing procedure, regular and inline comments, and matching bookmarks. Each unsupported facet returns a typed unavailable outcome. A facet rejected for invalid input retains its canonical error and issues alongside any successful facets. Use xrefs, assembly, or pseudocode for those deeper views.", addressContextInputSchema, ), ] as const satisfies readonly ToolContract[]; diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index 4b8cab98b3..b54e5dffdf 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -91,7 +91,10 @@ import { callPathResultSchema } from "../domain/callPathSchemas.js"; import { staticRuntimeCorrelationResultSchema } from "../domain/staticRuntimeCorrelation.js"; import { reconstructionVerificationResultSchema } from "../domain/reconstructionVerificationSchemas.js"; import { decompileDirectoryManifestSchema } from "./decompileDirectory.js"; -import { analysisErrorProjectionSchema } from "./errorSchemas.js"; +import { + analysisErrorProjectionSchema, + analysisInputErrorProjectionSchema, +} from "./errorSchemas.js"; import { relationshipCoverageSchema } from "../domain/relationshipCoverage.js"; import { nativeDispatchMetadataResultSchema } from "../domain/native/objcSwiftMetadata.js"; import { nativeInvestigationTraceSchema } from "../domain/native/nativeInvestigationGraph.js"; @@ -117,6 +120,7 @@ const contextFacetSchema = z.discriminatedUnion("state", [ state: z.literal("unavailable"), reason: z.string(), remediation: z.string(), + error: analysisInputErrorProjectionSchema.optional(), }), ]); const bookmarkFacetSchema = z.discriminatedUnion("state", [ @@ -128,6 +132,7 @@ const bookmarkFacetSchema = z.discriminatedUnion("state", [ state: z.literal("unavailable"), reason: z.string(), remediation: z.string(), + error: analysisInputErrorProjectionSchema.optional(), }), ]); diff --git a/src/domain/analysisSearch.ts b/src/domain/analysisSearch.ts index 3937feaf3d..a41e370501 100644 --- a/src/domain/analysisSearch.ts +++ b/src/domain/analysisSearch.ts @@ -43,3 +43,24 @@ export const analysisSearchPredicate = ( } return literalSubstringPredicate(query.pattern, query.case_sensitive); }; + +/** + * Match one procedure by its primary name and the secondary names its provider + * accepts for exact selection. A procedure remains one row whose value is its + * primary name; matched_aliases keeps each distinct matching secondary + * spelling in provider order, so an alias never becomes a procedure of its own. + */ +export const matchProcedureNames = ( + row: Row, + aliases: readonly string[], + matches: (value: string) => boolean, +): (Row & { readonly matched_aliases?: string[] }) | undefined => { + const matched = + aliases.length === 0 + ? [] + : [...new Set(aliases)].filter( + (alias) => alias !== row.value && matches(alias), + ); + if (matched.length > 0) return { ...row, matched_aliases: matched }; + return matches(row.value) ? row : undefined; +}; diff --git a/src/domain/native/nativeInventory.ts b/src/domain/native/nativeInventory.ts index b8ca5d7eb1..814d1787f6 100644 --- a/src/domain/native/nativeInventory.ts +++ b/src/domain/native/nativeInventory.ts @@ -26,6 +26,13 @@ export const addressedValueSchema = z.object({ byte_length: z.number().int().min(0), }) .exactOptional(), + matched_aliases: z + .array(z.string()) + .min(1) + .exactOptional() + .describe( + "Procedure search only: the procedure's secondary names that matched. value stays its primary name.", + ), }); /** Canonical row arrays shared by complete delivery and retained source validation. */ diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index 23ac5b1834..3e3802f846 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -62,6 +62,7 @@ const sharedAnalysisSemantics = { function_dossier_coverage: "observed-facets-and-call-relationships-v1", string_inventory_evidence: "defined-data-coverage-v1", location_resolution: "explicit-address-exact-entry-symbol-first-v3", + procedure_search: "primary-and-exact-entry-symbol-names-v1", procedure_file_offset_resolution: "address-first-unique-target-source-mapping-v2", instruction_flow_evidence: "decoded-return-pcode-v1", diff --git a/src/ghidra/GhidraInventoryValues.ts b/src/ghidra/GhidraInventoryValues.ts index 56da3cdd2d..be1ee36501 100644 --- a/src/ghidra/GhidraInventoryValues.ts +++ b/src/ghidra/GhidraInventoryValues.ts @@ -200,6 +200,10 @@ const addressedItem = z .object({ ...baseItem, symbol: symbolFacts.exactOptional() }) .strict(); const procedureItem = addressedItem.extend({ procedure: procedureFacts }); +/** Other symbols at the exact entry that matched; value stays the primary name. */ +const procedureMatchItem = addressedItem.extend({ + matched_aliases: z.array(z.string().min(1)).min(1).exactOptional(), +}); const stringItem = z.object({ ...baseItem, string: stringFacts }).strict(); const availablePermissions = z @@ -459,7 +463,7 @@ const resultSchemas = { list_strings: z.array(stringItem), procedure_address: nativeAddressObservationSchema, resolve_containing_procedure: containingProcedure, - search_procedures: z.array(addressedItem), + search_procedures: z.array(procedureMatchItem), search_strings: z.array(addressedItem), } satisfies Readonly< Record, z.ZodType> diff --git a/src/ghidra/GhidraLiteralSearch.ts b/src/ghidra/GhidraLiteralSearch.ts index 5d1218a214..46bac8a09a 100644 --- a/src/ghidra/GhidraLiteralSearch.ts +++ b/src/ghidra/GhidraLiteralSearch.ts @@ -1,5 +1,8 @@ import type { JsonValue } from "../domain/jsonValue.js"; -import { literalSubstringPredicate } from "../domain/analysisSearch.js"; +import { + literalSubstringPredicate, + matchProcedureNames, +} from "../domain/analysisSearch.js"; /** ASCII-only candidates are exact; every non-ASCII source value stays eligible. * Node owns Unicode folding, so the Java runtime's Unicode version cannot omit @@ -32,19 +35,38 @@ export const ghidraAsciiLiteralCandidate = (pattern: string): string | null => { return candidate; }; -/** Leave invalid rows present so the inventory owner retains its output error. */ +/** + * Apply Node's folding to the bridge's candidate rows, including the secondary + * names a procedure row matched. The bridge matches leaf and qualified + * spellings but reports qualified ones; a leaf ends its qualified spelling, so + * a literal is found in one only if it is found in the other. Leave invalid + * rows present so the inventory owner retains its output error. + */ export const filterGhidraLiteralCandidates = ( value: JsonValue, pattern: string, ): JsonValue => { if (!Array.isArray(value)) return value; const matches = literalSubstringPredicate(pattern, false); - return value.filter( - (item) => + return value.flatMap((item) => { + if ( item === null || typeof item !== "object" || Array.isArray(item) || - typeof item.value !== "string" || - matches(item.value), - ); + typeof item.value !== "string" + ) + return [item]; + const { matched_aliases: aliases, ...row } = item; + const candidates = Array.isArray(aliases) ? aliases : []; + if ( + !candidates.every((alias): alias is string => typeof alias === "string") + ) + return [item]; + const matched = matchProcedureNames( + { ...row, value: item.value }, + candidates, + matches, + ); + return matched === undefined ? [] : [matched]; + }); }; diff --git a/src/ghidra/GhidraRawProfileDigest.test.ts b/src/ghidra/GhidraRawProfileDigest.test.ts index 6562f5e346..bf5aa9fd1d 100644 --- a/src/ghidra/GhidraRawProfileDigest.test.ts +++ b/src/ghidra/GhidraRawProfileDigest.test.ts @@ -6,12 +6,13 @@ import { silentLogger } from "../logger.js"; import { ghidraProfileInstallationHost as host } from "./GhidraInstallation.fixture.js"; import { GhidraProvider } from "./GhidraProvider.js"; -// Digests commit to the current shared policy, including the instruction status -// revision that prevents reuse of snapshots with the previous ambiguous result. +// Digests commit to the current shared policy, including the instruction +// status, load-image relocation-symbol and procedure search revisions that +// prevent reuse of snapshots with earlier results. const RAW_DIGESTS = { - "6502": "bf79f425a94feb3d4150b6fe31f446dfb299af974c123cf3d4eaebdfe02319d6", + "6502": "7850ec8d885a9b5e1e6791637b57dfcea2d4359d55754ceeaf858fc43b22293d", "x86-disabled-analyzer": - "a89f116ac233a91a8a7fae5e7a24cc72126ee1c33219c79bb20e95cc440b2fae", + "55fb2b19e10d32bea95a88a097029ab2ad705b4b81dc74844662191799dbb161", } as const; const configFor = (environment: NodeJS.ProcessEnv) => { diff --git a/src/kuna/KunaSessionClient.ts b/src/kuna/KunaSessionClient.ts index fd12ec1860..d7123dd106 100644 --- a/src/kuna/KunaSessionClient.ts +++ b/src/kuna/KunaSessionClient.ts @@ -31,6 +31,7 @@ import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js"; import { analysisSearchPredicate, analysisSearchQuerySchema, + matchProcedureNames, } from "../domain/analysisSearch.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import type { EvidenceLocation } from "../domain/evidence.js"; @@ -844,37 +845,56 @@ export class KunaSessionClient implements AnalysisClient { const index = await this.#ensureIndex(installation, options); if (!index.ok) return index; const { inventory, data, unplaced } = index.value; - let selected = inventory; + let matches: ((name: string) => boolean) | undefined; if (query?.success === true) { if (query.data.mode === "regex") { - const matches = await this.#queryWork.match({ + // Each distinct spelling is matched once, however many entries share it. + let names: readonly string[] = []; + const matched = await this.#queryWork.match({ operation, pattern: query.data.pattern, caseSensitive: query.data.case_sensitive, - values: () => inventory.map(({ name }) => name), + values: () => + (names = [ + ...new Set( + inventory.flatMap(({ name, aliases }) => [name, ...aliases]), + ), + ]), signal: options.signal, }); - if (!matches.ok) return matches; - selected = matches.value.map((position) => inventory[position]!); - } else { - const matches = analysisSearchPredicate(query.data); - selected = inventory.filter(({ name }) => matches(name)); - } + if (!matched.ok) return matched; + const accepted = new Set( + matched.value.map((position) => names[position]!), + ); + matches = (name) => accepted.has(name); + } else matches = analysisSearchPredicate(query.data); } - const procedures: KunaInventoryFunction[] = []; + // Search matches every name exact lookup accepts: the primary name and + // Kuna's aliases. Each entry stays one row under its primary name. + const procedures: { + readonly address: string; + readonly value: string; + readonly matched_aliases?: string[]; + }[] = []; const omitted: string[] = []; let unclassified = 0; - for (const entry of selected) { + for (const entry of inventory) { + const row = { address: entry.address, value: entry.name }; + const selected = + matches === undefined + ? row + : matchProcedureNames(row, entry.aliases, matches); + if (selected === undefined) continue; const region = data.get(entry); if (region !== undefined) { omitted.push(region); continue; } if (unplaced.has(entry)) unclassified += 1; - procedures.push(entry); + procedures.push(selected); } return ok({ - result: procedures.map(({ address, name }) => ({ address, value: name })), + result: procedures, source: null, locations: [], limitations: kunaInventoryLimitations( diff --git a/src/process/DarwinHelperCache.ts b/src/process/DarwinHelperCache.ts index 61ac6e0150..570315b3da 100644 --- a/src/process/DarwinHelperCache.ts +++ b/src/process/DarwinHelperCache.ts @@ -461,5 +461,3 @@ export const createDarwinHelperCache = (options: { return { lookup, publish, close }; }; - -export type DarwinHelperCache = ReturnType; diff --git a/src/server/registerEnhancedTools.ts b/src/server/registerEnhancedTools.ts index 57b23bdfb1..4d24fe87bc 100644 --- a/src/server/registerEnhancedTools.ts +++ b/src/server/registerEnhancedTools.ts @@ -18,11 +18,13 @@ import { composedLimitationBudget, executeComposedWorkflow, createWorkflowEvidence, + retainPartialWorkflowEvidence, workflowSnapshotRecord, recordWorkflowUnknowns, } from "../application/WorkflowEvidence.js"; import { toolContract } from "../contracts/toolContracts.js"; import type { ToolContract } from "../contracts/toolContractTypes.js"; +import type { AnalysisError } from "../domain/analysisErrorBase.js"; import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { ok } from "../domain/result.js"; @@ -278,6 +280,12 @@ const executeEnhancedTool = async ( composedLimitationBudget(name), ), ); + retainPartialWorkflowEvidence(result, { + target: registration.activeTarget?.(), + operation: name, + parameters, + upstreamProfile: registration.analysisProfile?.(name), + }); result = retainAnalysisResultSources( name, registration.recordEvidence, @@ -341,7 +349,7 @@ const executeEnhancedTool = async ( : recorded, ); } - return registration.delivery.toCallToolResult(result, contract); + return deliverEnhancedFailure(registration, contract, result.error); }, ); return admitted.ok @@ -426,7 +434,7 @@ const deliverFunctionAnalysis = async ( ); await reportOperationEnd(progress, "analyze_function", result.ok); if (!result.ok) - return registration.delivery.toCallToolResult(result, contract); + return deliverEnhancedFailure(registration, contract, result.error); if (detail === "summary") { const summary = summarizeRetainedAnalysis(result.value, { recordEvidence: registration.recordEvidence, @@ -447,6 +455,23 @@ const deliverFunctionAnalysis = async ( ); }; +/** + * Deliver an analysis failure with any partial Evidence it carries, whether a + * stopped workflow or its failing read produced that partial. + */ +const deliverEnhancedFailure = ( + registration: EnhancedToolRegistration & { + readonly delivery: ToolResultDelivery; + }, + contract: ToolContract, + error: AnalysisError, +): CallToolResult => + registration.delivery.toPartialErrorToolResult( + error, + contract.returnsTargetContent, + registration.recordEvidence, + ); + const jsonParameters = ( input: Readonly>, ): Record => diff --git a/tests/boundary/cli/procedureAliasSearchCli.test.ts b/tests/boundary/cli/procedureAliasSearchCli.test.ts new file mode 100644 index 0000000000..22a3e29d1a --- /dev/null +++ b/tests/boundary/cli/procedureAliasSearchCli.test.ts @@ -0,0 +1,87 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect } from "vitest"; + +import { elf } from "../../../src/domain/binaryTarget.fixture.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { writeFakeKuna } from "../providers/kuna/fakeKuna.js"; + +describe("procedure alias search CLI", () => { + cliTest( + "finds and traces a procedure by an alias exact lookup accepts", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-alias-search-cli-"); + const kuna = await writeFakeKuna(root, { + functions: [ + { name: "main", addr: "0x1000" }, + { + name: "audit_v1", + addr: "0x1334", + aliases: ["audit_transform", "sub_1334"], + }, + { + name: "audit_v2", + addr: "0x133a", + aliases: ["audit_transform", "sub_133a"], + }, + ], + }); + const target = join(root, "target.elf"); + await writeFile(target, elf(2, 1, 62)); + const run = async (...arguments_: string[]) => { + const result = await cli.run({ + arguments: [...arguments_, "--provider", "kuna", "--format", "json"], + cwd: root, + environment: { + REA_LOG_LEVEL: "silent", + REA_KUNA_COMMAND: kuna, + REA_KUNA_CACHE_DIR: join(root, "cache"), + }, + }); + expect(result.exitCode, JSON.stringify(result.json)).toBe(0); + return result.json; + }; + + expect( + await run( + "search", + target, + "audit_transform", + "--kind", + "procedures", + "--mode", + "regex", + ), + ).toMatchObject({ + operation: "search_procedures", + normalized_result: [ + { + address: "0x1334", + value: "audit_v1", + matched_aliases: ["audit_transform"], + }, + { + address: "0x133a", + value: "audit_v2", + matched_aliases: ["audit_transform"], + }, + ], + }); + expect(await run("trace", target, "sub_1334")).toMatchObject({ + operation: "trace_feature", + normalized_result: { + matches: [ + { + type: "procedure", + address: "0x1334", + value: "audit_v1", + matched_aliases: ["sub_1334"], + }, + ], + references: [{ target_address: "0x1334", source_address: "0x1004" }], + }, + }); + }, + ); +}); diff --git a/tests/boundary/mcp/addressContextInputErrors.test.ts b/tests/boundary/mcp/addressContextInputErrors.test.ts new file mode 100644 index 0000000000..b908f51872 --- /dev/null +++ b/tests/boundary/mcp/addressContextInputErrors.test.ts @@ -0,0 +1,183 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { expect, it } from "vitest"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js"; +import { inspectAddressContext } from "../../../src/application/AnalysisContextQueries.js"; +import { + AnalysisInputError, + AnalysisProtocolError, +} from "../../../src/domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { err, ok } from "../../../src/domain/result.js"; +import { SESSION_TOOL_CONTRACTS } from "../../../src/contracts/sessionToolContracts.js"; +import { createServer } from "../../../src/server/createServer.js"; + +it.each([ + ["compact", false, "comment"], + ["compact", true, "comment"], + ["full", false, "comment"], + ["full", true, "comment"], + ["full", false, "list_bookmarks"], +] as const)( + "preserves a local input refusal alongside successful facets (%s, conforms=%s, operation=%s)", + async (profile, conforms, rejectedOperation) => { + const identity = { id: "fixture", name: "Fixture", version: "1" }; + const directory = await createTestTempDirectory("rea-context-input-"); + const target = join(directory, "fixture.com"); + await writeFile(target, Buffer.from([0xb8, 0x00, 0x4c, 0xcd, 0x21])); + const inputFailure = new AnalysisInputError( + rejectedOperation, + { + userMessage: "Controlled facet refusal", + conformsToInputContract: conforms, + diagnostics: { producer_fact: "retained" }, + }, + [ + { + path: [rejectedOperation === "comment" ? "address" : "document"], + reason: "invalid_value", + expected: "controlled-value", + }, + ], + ); + const session = createTestBinarySession(() => ({ + execute: async (operation) => { + if (operation === rejectedOperation) return err(inputFailure); + if (operation === "inline_comment") + return err(new AnalysisProtocolError("controlled provider failure")); + const result = + operation === "address_name" + ? "entry" + : operation === "resolve_containing_procedure" + ? { + query_address: "0x401001", + found: true, + procedure: { + address: "0x401000", + name: "entry", + classification: null, + body: { available: false, reason: "not recorded" }, + }, + } + : operation === "list_bookmarks" + ? [] + : null; + return ok(createAnalysisExecution(result, identity)); + }, + close: async () => ok(null), + })); + const server = createServer( + { kind: "session", session }, + { + environment: { + ...process.env, + REA_MCP_OUTPUT_SCHEMA_PROFILE: profile, + REA_MCP_TOOL_FAMILIES: "session", + }, + }, + ); + const client = new Client({ name: "context-input-error", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const definitions = new Map( + (await client.listTools()).tools.map((tool) => [tool.name, tool]), + ); + const contextDefinition = definitions.get("inspect_address_context"); + if (contextDefinition === undefined) + throw new Error("inspect_address_context was not advertised"); + expect( + ( + await client.callTool({ + name: "open_binary", + arguments: { path: target, format: "dos-com" }, + }) + ).isError, + ).not.toBe(true); + const response = await client.callTool( + { + name: "inspect_address_context", + arguments: { address: "0x401001", document: "fixture" }, + }, + { toolDefinition: contextDefinition }, + ); + expect(response.isError).not.toBe(true); + const contract = SESSION_TOOL_CONTRACTS.find( + ( + candidate, + ): candidate is Extract< + (typeof SESSION_TOOL_CONTRACTS)[number], + { readonly name: "inspect_address_context" } + > => candidate.name === "inspect_address_context", + ); + if (contract === undefined) + throw new Error("inspect_address_context contract is missing"); + const parsed = contract.outputSchema.parse( + response.structuredContent, + ).result; + const expected = projectAnalysisError(inputFailure); + expect(parsed.name).toEqual({ state: "available", value: "entry" }); + expect(parsed.procedure).toMatchObject({ + state: "available", + value: { found: true, query_address: "0x401001" }, + }); + const rejected = + rejectedOperation === "comment" ? parsed.comment : parsed.bookmarks; + expect(rejected).toEqual({ + state: "unavailable", + reason: expected.message, + remediation: expected.remediation.action, + error: expected, + }); + if (rejectedOperation === "comment") + expect(parsed.bookmarks).toEqual({ state: "available", value: [] }); + else expect(parsed.comment).toEqual({ state: "available", value: null }); + expect(parsed.inline_comment).toMatchObject({ state: "unavailable" }); + expect(parsed.inline_comment).not.toHaveProperty("error"); + expect(expected.details).toMatchObject({ + issues: [ + { + path: [rejectedOperation === "comment" ? "address" : "document"], + expected: "controlled-value", + }, + ], + diagnostics: { producer_fact: "retained" }, + }); + expect(Object.hasOwn(expected, "recovery")).toBe(!conforms); + } finally { + await client.close(); + await server.close(); + } + }, +); + +it("does not demote an input refusal with incomplete cleanup into a local facet", async () => { + const identity = { id: "fixture", name: "Fixture", version: "1" }; + const failure = new AnalysisInputError( + "comment", + { + cleanup: { reason: "controlled failure", resources: ["owned-resource"] }, + }, + [{ path: ["address"], reason: "invalid_value" }], + ); + const result = await inspectAddressContext( + { + execute: async (operation) => + operation === "comment" + ? err(failure) + : ok(createAnalysisExecution(null, identity)), + }, + { address: "0x401001", document: "fixture" }, + ); + if (result.ok) throw new Error("expected incomplete cleanup"); + expect(result.error).toBe(failure); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "cleanup_incomplete", + details: { resources: ["owned-resource"] }, + }); +}); diff --git a/tests/boundary/mcp/analyzeFunctionDetail.test.ts b/tests/boundary/mcp/analyzeFunctionDetail.test.ts index 7a88e84892..89b8757670 100644 --- a/tests/boundary/mcp/analyzeFunctionDetail.test.ts +++ b/tests/boundary/mcp/analyzeFunctionDetail.test.ts @@ -309,3 +309,32 @@ it("rejects a summary request before analysis when the server retains no Evidenc expect(reply.isError).toBe(true); expect(execute).not.toHaveBeenCalled(); }); + +it.each([true, false])( + "delivers an unreadable dossier's partial Evidence through the session writer (retained=%s)", + async (retain) => { + const { client, ledger } = await connect(retain, { + not: "a dossier", + } as never); + + const reply = await client.callTool({ + name: "analyze_function", + arguments: { procedure: "main", detail: "complete" }, + }); + + expect(reply.isError).toBe(true); + const partial = JSON.parse( + reply.content.find((item) => item.type === "text")?.text ?? "{}", + ).error.details.partial_observation; + if (!retain) { + expect(parseEvidence(partial).operation).toBe("analyze_function"); + return; + } + expect(partial).toMatchObject({ kind: "retained-evidence" }); + expect(ledger.forAnalysis(partial.evidence_id)).toMatchObject({ + evidence_id: partial.evidence_id, + operation: "analyze_function", + normalized_result: { not: "a dossier" }, + }); + }, +); diff --git a/tests/boundary/mcp/callGraphCleanupIncomplete.test.ts b/tests/boundary/mcp/callGraphCleanupIncomplete.test.ts new file mode 100644 index 0000000000..e0ec7a107f --- /dev/null +++ b/tests/boundary/mcp/callGraphCleanupIncomplete.test.ts @@ -0,0 +1,428 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { afterEach, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + createAnalysisExecution, + type AnalysisOperationPort, +} from "../../../src/application/AnalysisProvider.js"; +import { AnalysisError } from "../../../src/domain/analysisErrorBase.js"; +import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; +import { AnalysisCapabilityUnavailableError } from "../../../src/domain/analysisErrorCore.js"; +import { createAnalysisExecutionEvidence } from "../../../src/domain/analysisObservation.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js"; +import { err, ok } from "../../../src/domain/result.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { cleanupStoppedTraversal } from "../../../src/application/CallRelationshipTraversal.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { + closeEnhancedToolResources, + connect, + jsonResult, + resources, +} from "./enhancedToolsHarness.js"; + +afterEach(closeEnhancedToolResources); + +const provider = { + id: "fixture", + name: "Fixture analysis provider", + version: "1", +} as const; +const directions = [ + ["forward", "procedure_callees"], + ["backward", "procedure_callers"], +] as const; +const tools = ["get_call_graph", "trace_call_path"] as const; +type Tool = (typeof tools)[number]; +type Relationships = Readonly< + Record +>; + +const childFailure = (operation: string, cleanup: boolean) => + new AnalysisCapabilityUnavailableError( + provider.id, + operation, + "child failed", + cleanup + ? { + cleanup: { + reason: "worker cleanup failed", + resources: ["owned worker"], + }, + } + : undefined, + ); + +/** Answer each selector's relationship read and record every read in order. */ +const relationshipPort = ( + relationships: Relationships, + reads: string[], +): AnalysisOperationPort => ({ + execute: async (operation, parameters) => { + const procedure = String(parameters.procedure); + if (operation === "procedure_address") + return ok(createAnalysisExecution(procedure, provider)); + if (operation !== "procedure_callees" && operation !== "procedure_callers") + return ok(createAnalysisExecution(null, provider)); + reads.push(`${operation}:${procedure}`); + const answer = relationships[procedure] ?? []; + return answer instanceof AnalysisError + ? err(answer) + : ok( + createAnalysisExecution(answer, provider, { + relationshipCoverage: { status: "complete" }, + }), + ); + }, +}); + +const call = ( + tool: Tool, + direction: (typeof directions)[number][0], + goal = "0x4", +) => ({ + name: tool, + arguments: + tool === "get_call_graph" + ? { address: "0x1", direction } + : { start: "0x1", goal, direction }, +}); + +describe.each(directions)("%s relationship reads", (direction, operation) => { + it.each(tools)( + "%s stops at unconfirmed cleanup and reports it with the admitted result", + async (tool) => { + const reads: string[] = []; + const client = await connect( + relationshipPort( + { + "0x1": ["0x2", "0x3"], + "0x2": childFailure(operation, true), + "0x3": ["0x4"], + }, + reads, + ), + ); + + const response = await client.callTool(call(tool, direction)); + + expect(reads).toEqual([`${operation}:0x1`, `${operation}:0x2`]); + const { error } = parseMcpToolError(response); + expect(error).toMatchObject({ + code: "cleanup_incomplete", + details: { + operation, + cleanup: "incomplete", + resources: ["owned worker"], + cleanup_reason: "worker cleanup failed", + execution_failure: "capability_unavailable", + }, + }); + // Without an Evidence writer the admitted result stays inline. + const partial = parseEvidence(error.details?.partial_observation); + expect(partial).toMatchObject({ + operation: tool, + confidence: "derived", + parameters: call(tool, direction).arguments, + }); + // The stop names 0x2 and its one queued sibling; the formatter owns prose. + const stopped = cleanupStoppedTraversal("0x2", 1); + if (tool === "get_call_graph") + expect(partial.normalized_result).toEqual({ + graph: { + "0": [ + { + address: "0x1", + status: "ok", + calls: ["0x2", "0x3"], + relationship_coverage: { status: "complete" }, + }, + ], + }, + limitations: [stopped], + }); + else + expect(partial.normalized_result).toMatchObject({ + goal: "0x4", + goal_status: "unknown", + nodes: [ + { address: "0x1", depth: 0 }, + { address: "0x2", depth: 1 }, + { address: "0x3", depth: 1 }, + ], + traversal_path: [], + failures: [], + relationship_coverage: { "0x1": { status: "complete" } }, + truncated: true, + residual_unknowns: [stopped], + }); + }, + ); + + it.each(tools)( + "%s keeps an ordinary expansion failure as a usable partial result", + async (tool) => { + const reads: string[] = []; + const client = await connect( + relationshipPort( + { + "0x1": ["0x2", "0x3"], + "0x2": childFailure(operation, false), + "0x3": ["0x4"], + }, + reads, + ), + ); + + const response = await client.callTool(call(tool, direction)); + + expect(response.isError).not.toBe(true); + expect(reads).toEqual([ + `${operation}:0x1`, + `${operation}:0x2`, + `${operation}:0x3`, + ...(tool === "get_call_graph" ? [`${operation}:0x4`] : []), + ]); + const failed = { + address: "0x2", + error: { + code: "capability_unavailable", + details: { operation }, + }, + }; + expect(jsonResult(response)).toMatchObject( + tool === "get_call_graph" + ? { + graph: { + "1": [{ ...failed, status: "error" }, { address: "0x3" }], + }, + } + : { + goal_status: "reached", + traversal_path: ["0x1", "0x3", "0x4"], + failures: [failed], + }, + ); + }, + ); +}); + +it.each(tools)( + "%s keeps earlier ordinary failures and dispatches nothing after a later cleanup failure", + async (tool) => { + const reads: string[] = []; + const client = await connect( + relationshipPort( + { + "0x1": ["0x2", "0x3", "0x4"], + "0x2": childFailure("procedure_callees", false), + "0x3": childFailure("procedure_callees", true), + "0x4": ["0x5"], + }, + reads, + ), + ); + + const response = await client.callTool(call(tool, "forward", "0x5")); + + expect(reads).toEqual([ + "procedure_callees:0x1", + "procedure_callees:0x2", + "procedure_callees:0x3", + ]); + const { error } = parseMcpToolError(response); + expect(error).toMatchObject({ + code: "cleanup_incomplete", + details: { resources: ["owned worker"] }, + }); + const partial = parseEvidence(error.details?.partial_observation); + const stopped = cleanupStoppedTraversal("0x3", 1); + const ordinary = { + address: "0x2", + error: { code: "capability_unavailable" }, + }; + expect(partial.normalized_result).toMatchObject( + tool === "get_call_graph" + ? { + graph: { + "0": [{ address: "0x1", status: "ok" }], + "1": [{ ...ordinary, status: "error" }], + }, + limitations: [stopped], + } + : { + goal_status: "unknown", + failures: [ordinary], + residual_unknowns: [ + "Call relationships were unavailable for 0x2.", + stopped, + ], + }, + ); + }, +); + +/** Serve one fixed provider through a production session with a committed profile. */ +const openSession = async (port: AnalysisOperationPort) => { + const directory = await createTestTempDirectory("rea-call-graph-cleanup-"); + const target = join(directory, "fixture.com"); + await writeFile(target, Buffer.from([0xb8, 0x00, 0x4c, 0xcd, 0x21])); + const session = createTestBinarySession( + () => ({ + execute: (operation, parameters, options) => + port.execute(operation, parameters, options), + close: async () => ok(null), + }), + { + // A committed profile lets the session retain each admitted read. + resolveAnalysisProfile: async () => + ok({ profile: createAnalysisProfile(provider, { fixture: true }) }), + }, + ); + const server = createServer({ kind: "session", session }); + const client = new Client({ name: "call-graph-cleanup", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(client, server); + await server.connect(serverTransport); + await client.connect(clientTransport); + const opened = await client.callTool({ + name: "open_binary", + arguments: { path: target, format: "dos-com" }, + }); + expect(opened.isError).not.toBe(true); + return { client, session }; +}; + +/** Resolve a failure's retained partial and prove a complete export keeps it. */ +const retainedPartial = async ( + client: Client, + session: ReturnType, + error: ReturnType["error"], +) => { + const reference = z + .strictObject({ + kind: z.literal("retained-evidence"), + evidence_id: z.string(), + }) + .parse(error.details?.partial_observation); + const exported = await client.callTool({ + name: "get_evidence_bundle", + arguments: {}, + }); + expect(exported.isError).not.toBe(true); + const bundle = evidenceBundleSchema.parse( + z.object({ result: z.unknown() }).parse(exported.structuredContent).result, + ); + expect(bundle.records.map(({ evidence_id }) => evidence_id)).toContain( + reference.evidence_id, + ); + return session.evidenceForAnalysis(reference.evidence_id); +}; + +it.each(tools)( + "%s retains the admitted result as session Evidence and references it", + async (tool) => { + const reads: string[] = []; + const { client, session } = await openSession( + relationshipPort( + { + "0x1": ["0x2", "0x3"], + "0x2": childFailure("procedure_callees", true), + "0x3": ["0x4"], + }, + reads, + ), + ); + + const response = await client.callTool(call(tool, "forward")); + + expect(reads).toEqual(["procedure_callees:0x1", "procedure_callees:0x2"]); + const { error } = parseMcpToolError(response); + expect(error).toMatchObject({ + code: "cleanup_incomplete", + details: { resources: ["owned worker"] }, + }); + const retained = await retainedPartial(client, session, error); + expect(retained).toMatchObject({ + operation: tool, + subject: { format: "dos-com" }, + normalized_result: + tool === "get_call_graph" + ? { graph: { "0": [{ address: "0x1", status: "ok" }] } } + : { goal_status: "unknown", truncated: true }, + }); + // The admitted read stays linked as the partial result's source. + expect(retained?.evidence_links).toHaveLength(1); + expect( + session.evidenceForAnalysis(retained?.evidence_links?.[0] ?? ""), + ).toMatchObject({ + operation: "procedure_callees", + parameters: { procedure: "0x1" }, + }); + }, +); + +it.each(tools)( + "%s retains a failed read's own partial Evidence instead of the workflow's", + async (tool) => { + const failure = childFailure("procedure_callees", true); + const own = createAnalysisExecutionEvidence( + "procedure_callees", + { procedure: "0x2" }, + createAnalysisExecution(["0x9"], provider), + "observed", + ); + failure.retainPartialObservation(own); + const reads: string[] = []; + const { client, session } = await openSession( + relationshipPort({ "0x1": ["0x2", "0x3"], "0x2": failure }, reads), + ); + + const { error } = parseMcpToolError( + await client.callTool(call(tool, "forward")), + ); + + expect(reads).toEqual(["procedure_callees:0x1", "procedure_callees:0x2"]); + expect(error).toMatchObject({ + code: "cleanup_incomplete", + details: { resources: ["owned worker"] }, + }); + expect(await retainedPartial(client, session, error)).toEqual(own); + }, +); + +it.each(tools)( + "%s stops at a root read cleanup failure without dispatching more reads", + async (tool) => { + const reads: string[] = []; + const client = await connect( + relationshipPort( + { "0x1": childFailure("procedure_callees", true), "0x4": [] }, + reads, + ), + ); + + const { error } = parseMcpToolError( + await client.callTool(call(tool, "forward")), + ); + + expect(reads).toEqual(["procedure_callees:0x1"]); + expect(error.code).toBe("cleanup_incomplete"); + const stopped = cleanupStoppedTraversal("0x1", 0); + expect( + parseEvidence(error.details?.partial_observation).normalized_result, + ).toMatchObject( + tool === "get_call_graph" + ? { graph: {}, limitations: [stopped] } + : { goal_status: "unknown", residual_unknowns: [stopped] }, + ); + }, +); diff --git a/tests/boundary/mcp/ghidraInventoryMcp.test.ts b/tests/boundary/mcp/ghidraInventoryMcp.test.ts index b16e757820..02faf8ed06 100644 --- a/tests/boundary/mcp/ghidraInventoryMcp.test.ts +++ b/tests/boundary/mcp/ghidraInventoryMcp.test.ts @@ -566,3 +566,134 @@ it("rejects contradictory provider output before emitting MCP Evidence", async ( await harness.close(); } }); + +it("returns a procedure once with the exact-entry names its search matched", async () => { + const primary = { + primary: true, + dynamic: false, + external: false, + type: "function", + source: "imported", + }; + const row = ( + address: string, + value: string, + matched_aliases?: readonly string[], + ) => ({ + address, + value, + symbol: primary, + ...(matched_aliases === undefined ? {} : { matched_aliases }), + }); + let output = jsonValueSchema.parse([ + row("0x1012a0", "audit_alias", ["audit_primary"]), + ]); + const requests: unknown[] = []; + const harness = await connectGhidraMcp( + "ghidra-procedure-aliases", + (operation, input) => { + if (operation !== "search_procedures") + throw new Error(`Unexpected operation: ${operation}`); + requests.push(input); + return Promise.resolve(ok(output)); + }, + ); + const search = async (arguments_: Record) => { + const reply = await harness.mcp.callTool({ + name: "search_procedures", + arguments: arguments_, + }); + if (reply.isError === true) throw new Error(JSON.stringify(reply)); + return sessionEvidence(harness.session, reply.structuredContent); + }; + try { + // The bridge owns exact and regex matching over its entry symbols. + const exact = await search({ + pattern: "audit_primary", + case_sensitive: true, + }); + expect(exact.normalized_result).toEqual(output); + expect(exact.raw_result).toBeNull(); + expect(exact.analysis_profile?.parameters).toMatchObject({ + procedure_search: "primary-and-exact-entry-symbol-names-v1", + }); + + // Node owns Unicode folding: the bridge's non-ASCII candidates are kept + // only when they match, for secondary names as for primary names. + output = jsonValueSchema.parse([ + row("0x1012a0", "audit_alias", ["audit_primary", "Äudit_label"]), + row("0x101300", "ÄUDIT_only", ["ünrelated"]), + row("0x101340", "FUN_00101340", ["AUDİT_x", "AUDIT_label"]), + row("0x101380", "Audit_plain"), + ]); + const folded = await search({ pattern: "audit" }); + expect(folded.normalized_result).toEqual([ + row("0x1012a0", "audit_alias", ["audit_primary"]), + row("0x101340", "FUN_00101340", ["AUDIT_label"]), + row("0x101380", "Audit_plain"), + ]); + expect(folded.raw_result).toEqual(output); + expect(requests).toEqual([ + expect.objectContaining({ pattern: "audit_primary" }), + expect.objectContaining({ pattern: "audit", case_sensitive: false }), + ]); + + // An empty alias list is not a match report. + output = jsonValueSchema.parse([row("0x1012a0", "audit_alias", [])]); + const malformed = await harness.mcp.callTool({ + name: "search_procedures", + arguments: { pattern: "audit_", mode: "regex" }, + }); + expect(parseMcpToolError(malformed)).toMatchObject({ + error: { code: "unreadable_output" }, + }); + } finally { + await harness.close(); + } +}); + +it("keeps a namespaced entry name that matched by its leaf spelling", async () => { + // procedure_address accepts a symbol's leaf or qualified spelling, so the + // bridge matches both and reports the qualified one. + const output = jsonValueSchema.parse([ + { + address: "0x101400", + value: "outer::impl", + matched_aliases: ["ns::audit_alias"], + }, + { address: "0x101440", value: "alpha::same" }, + ]); + const harness = await connectGhidraMcp("ghidra-namespaced-aliases", () => + Promise.resolve(ok(output)), + ); + try { + const [aliasRow, primaryRow] = output as unknown[]; + for (const [arguments_, expected] of [ + [ + { + pattern: "^(audit_alias|same)$", + mode: "regex", + case_sensitive: true, + }, + output, + ], + // The host folding pass checks reported qualified spellings, which end + // with the leaf the bridge matched. + [{ pattern: "AUDIT_ALIAS" }, [aliasRow]], + [{ pattern: "SAME" }, [primaryRow]], + ] as const) { + const reply = await harness.mcp.callTool({ + name: "search_procedures", + arguments: arguments_, + }); + expect(reply.isError, JSON.stringify(arguments_)).not.toBe(true); + expect( + sessionEvidence(harness.session, reply.structuredContent) + .normalized_result, + JSON.stringify(arguments_), + ).toEqual(expected); + } + } finally { + await harness.close(); + } +}); diff --git a/tests/boundary/mcp/sessionAvailabilityRecovery.test.ts b/tests/boundary/mcp/sessionAvailabilityRecovery.test.ts index 6686c95868..929379e5d4 100644 --- a/tests/boundary/mcp/sessionAvailabilityRecovery.test.ts +++ b/tests/boundary/mcp/sessionAvailabilityRecovery.test.ts @@ -1,14 +1,33 @@ -import { expect, it } from "vitest"; +import { expect, it, onTestFinished } from "vitest"; +import { + createAnalysisExecution, + type AnalysisExecution, + type AnalysisOperation, + type AnalysisProvider, + type CapabilityDescriptor, + type ProviderIdentity, +} from "../../../src/application/AnalysisProvider.js"; import { toolContract } from "../../../src/contracts/toolContracts.js"; import { expandToolAvailability } from "../../../src/contracts/toolOutputSchemaPrimitives.js"; -import { AnalysisTimeoutError } from "../../../src/domain/analysisErrorCore.js"; +import type { AnalysisError } from "../../../src/domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisCapabilityUnavailableError, + AnalysisTimeoutError, +} from "../../../src/domain/analysisErrorCore.js"; +import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; +import { + inferredUnavailabilityReason, + runtimeFailureReason, +} from "../../../src/application/binary/SessionRuntimeAvailability.js"; import { ProviderAdapterError } from "../../../src/domain/providerAdapterError.js"; import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; -import { err, ok } from "../../../src/domain/result.js"; +import { err, ok, type Result } from "../../../src/domain/result.js"; import { silentLogger } from "../../../src/logger.js"; import { observed } from "../../fixtures/analysisExecution.js"; import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; +import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { createBinarySessionTargets, @@ -309,3 +328,529 @@ it("keeps provider tools available after one request reaches its deadline", asyn await close(); } }); + +type Reply = Result; +type Responder = (operation: string) => Promise; + +const FIXTURE = { + id: "fixture", + name: "Fixture analysis provider", + version: "1", +} as const satisfies ProviderIdentity; +const AUXILIARY = { + id: "fixture-auxiliary", + name: "Auxiliary fixture provider", + version: "1", +} as const satisfies ProviderIdentity; + +const liveCapability = ( + provider: ProviderIdentity, + operation: Exclude, + declaredRefusal?: string, +): CapabilityDescriptor => ({ + provider, + operation, + ...(declaredRefusal === undefined + ? { available: true as const, reason: null } + : { available: false as const, reason: declaredRefusal }), + cachePolicy: "live", + effects: { + mutatesArtifact: false, + launchesProcess: false, + mayShowUi: false, + mayAccessNetwork: false, + mayWriteFilesystem: false, + changesPermissions: false, + requiresRoot: false, + }, + limitations: [], +}); + +const adapterFailure = (provider: ProviderIdentity, operation: string) => + Promise.resolve(err(new ProviderAdapterError(provider.id, operation))); +const recordedReason = (failure: AnalysisError): string => { + const reason = runtimeFailureReason(failure); + if (reason === undefined) throw new Error(`${failure._tag} is not recorded`); + return reason; +}; +// Compare remediation with its owning formatters; the facts are their inputs. +const observedFailure = (operation: string, provider: string = FIXTURE.id) => + recordedReason(new ProviderAdapterError(provider, operation)); +const inferredFailure = (operation: string, provider: string = FIXTURE.id) => + inferredUnavailabilityReason(provider, { + operation, + reason: observedFailure(operation, provider), + }); +const refusal = (operation: string, reason: string) => + recordedReason( + new AnalysisCapabilityUnavailableError(FIXTURE.id, operation, reason), + ); + +/** Hold provider replies; release and join every launched call. */ +const heldCalls = ( + client: Awaited>["client"], +) => { + const held = new Map< + string, + { readonly reply: Promise; readonly received: () => void } + >(); + const launched: { + readonly release: (reply: Reply) => void; + readonly response: Promise; + }[] = []; + return { + /** Serve a held reply when one is registered for this operation. */ + respond: (operation: string): Promise | undefined => { + const entry = held.get(operation); + if (entry === undefined) return undefined; + held.delete(operation); + entry.received(); + return entry.reply; + }, + dispatch: async (name: string, arguments_: Record) => { + const reply = createDeferred(); + const received = createDeferred(); + held.set(name, { reply: reply.promise, received: received.resolve }); + const response = client.callTool({ name, arguments: arguments_ }); + launched.push({ release: reply.resolve, response }); + await awaitAcknowledgement(received.promise, response, name); + return { + release: reply.resolve, + isError: async () => (await response).isError === true, + }; + }, + /** Release every held reply and wait for its MCP response. */ + join: async () => { + for (const { release } of launched) release(observed("released")); + await Promise.allSettled(launched.map(({ response }) => response)); + }, + }; +}; + +/** Connect a deep fixture and an auxiliary provider with scripted replies. */ +const connectScripted = async (options: { + readonly operations: readonly Exclude[]; + readonly auxiliaryOperations?: readonly Exclude< + AnalysisOperation, + "health" + >[]; + readonly respond: { current: Responder }; + readonly respondAuxiliary?: { current: Responder }; + /** Operations the fixture declares unavailable, with their declared reason. */ + readonly declaredRefusals?: Readonly>; + readonly families?: string; +}) => { + const [path] = await createBinarySessionTargets(); + const provider: AnalysisProvider = { + identity: () => FIXTURE, + resolveAnalysisProfile: () => + Promise.resolve( + ok({ profile: createAnalysisProfile(FIXTURE, { fixture: true }) }), + ), + capabilities: () => + options.operations.map((operation) => + liveCapability( + FIXTURE, + operation, + options.declaredRefusals?.[operation], + ), + ), + createClient: () => ({ + execute: (operation) => + operation === "health" + ? Promise.resolve(observed(null)) + : options.respond.current(operation), + close: () => Promise.resolve(ok(null)), + }), + }; + const auxiliary: AnalysisProvider = { + identity: () => AUXILIARY, + capabilities: () => + (options.auxiliaryOperations ?? []).map((operation) => + liveCapability(AUXILIARY, operation), + ), + createClient: () => ({ + execute: (operation) => + options.respondAuxiliary?.current(operation) ?? + Promise.resolve(ok(createAnalysisExecution(null, AUXILIARY))), + close: () => Promise.resolve(ok(null)), + }), + }; + const session = createTestBinarySession(provider, { + auxiliaryProviders: [auxiliary], + }); + const { client, close } = await createApplicationMcpHarness({ + session, + clientIdentity: { name: "provider-recovery", version: "1" }, + serverOptions: { + environment: + options.families === undefined + ? {} + : { REA_MCP_TOOL_FAMILIES: options.families }, + logger: silentLogger, + }, + }); + onTestFinished(close); + expect( + (await client.callTool({ name: "open_binary", arguments: { path } })) + .isError, + ).not.toBe(true); + const call = async (name: string, arguments_: Record) => + (await client.callTool({ name, arguments: arguments_ })).isError === true + ? "error" + : "ok"; + const tools = async () => { + const reply = await client.callTool({ + name: "binary_session", + arguments: {}, + }); + return expandToolAvailability( + toolContract("binary_session").outputSchema.parse(reply.structuredContent) + .result.tool_availability, + ); + }; + const availability = async (names: readonly string[]) => { + const advertised = await tools(); + return Object.fromEntries( + names.map((name) => { + const tool = advertised.find((entry) => entry.name === name); + if (tool === undefined) throw new Error(`${name} is not advertised`); + return [name, tool.available ? "available" : tool.remediation]; + }), + ); + }; + return { client, call, availability, tools }; +}; + +const DIRECT_OPERATIONS = [ + "address_name", + "current_address", + "list_procedures", + "procedure_assembly", + "procedure_info", +] as const; +const ADDRESS = { address: "0x1000", document: "fixture" }; +const PROCEDURE = { procedure: "main" }; + +it("returns provider-inferred siblings to declared availability after one success", async () => { + let broken = false; + const respond = { + current: (operation: string): Promise => { + if (operation === "procedure_info") + return Promise.resolve( + err( + new AnalysisCapabilityUnavailableError( + FIXTURE.id, + operation, + "fixture project is read-only", + ), + ), + ); + if (broken) return adapterFailure(FIXTURE, operation); + return Promise.resolve(observed("fixture-text")); + }, + }; + const declared = "Fixture project was opened read-only."; + const { call, availability } = await connectScripted({ + operations: [...DIRECT_OPERATIONS, "set_comment"], + auxiliaryOperations: ["list_documents"], + declaredRefusals: { set_comment: declared }, + respond, + }); + const refused = refusal("procedure_info", "fixture project is read-only"); + expect(await call("procedure_info", PROCEDURE)).toBe("error"); + broken = true; + expect(await call("address_name", ADDRESS)).toBe("error"); + expect(await call("procedure_assembly", PROCEDURE)).toBe("error"); + const names = [...DIRECT_OPERATIONS, "set_comment", "list_documents"]; + expect(await availability(names)).toEqual({ + address_name: observedFailure("address_name"), + current_address: inferredFailure("procedure_assembly"), + list_procedures: inferredFailure("procedure_assembly"), + procedure_assembly: observedFailure("procedure_assembly"), + // Separately observed and declared refusals keep their own reasons. + procedure_info: refused, + set_comment: declared, + list_documents: "available", + }); + + broken = false; + expect(await call("address_name", ADDRESS)).toBe("ok"); + // One success is evidence the provider answers again, not that every + // operation is healthy: failures observed on other operations remain. + expect(await availability(names)).toEqual({ + address_name: "available", + current_address: "available", + list_procedures: "available", + procedure_assembly: observedFailure("procedure_assembly"), + procedure_info: refused, + set_comment: declared, + list_documents: "available", + }); + expect(await call("procedure_assembly", PROCEDURE)).toBe("ok"); + expect(await availability(["procedure_assembly", "procedure_info"])).toEqual({ + procedure_assembly: "available", + procedure_info: refused, + }); +}); + +it("keeps each provider's failures and ignores cancelled requests", async () => { + let fixtureBroken = false; + let auxiliaryBroken = true; + const respond = { + current: (operation: string): Promise => { + if (operation === "list_procedures") + return Promise.resolve(err(new AnalysisCancelledError(operation))); + return fixtureBroken + ? adapterFailure(FIXTURE, operation) + : Promise.resolve(observed("fixture-text")); + }, + }; + const respondAuxiliary = { + current: (operation: string): Promise => + auxiliaryBroken + ? adapterFailure(AUXILIARY, operation) + : Promise.resolve(ok(createAnalysisExecution("doc", AUXILIARY))), + }; + const { call, availability } = await connectScripted({ + operations: ["address_name", "current_address", "list_procedures"], + auxiliaryOperations: ["current_document", "list_documents"], + respond, + respondAuxiliary, + }); + const names = [ + "address_name", + "current_address", + "list_procedures", + "current_document", + "list_documents", + ]; + expect(await call("list_documents", {})).toBe("error"); + expect(await call("address_name", ADDRESS)).toBe("ok"); + const auxiliaryFailure = { + current_document: inferredFailure("list_documents", AUXILIARY.id), + list_documents: observedFailure("list_documents", AUXILIARY.id), + }; + // Another provider's success does not clear the auxiliary provider's state. + expect(await availability(names)).toEqual({ + address_name: "available", + current_address: "available", + list_procedures: "available", + ...auxiliaryFailure, + }); + + fixtureBroken = true; + expect(await call("address_name", ADDRESS)).toBe("error"); + // A cancelled request is not evidence that the provider recovered. + expect(await call("list_procedures", {})).toBe("error"); + const fixtureFailure = { + address_name: observedFailure("address_name"), + current_address: inferredFailure("address_name"), + list_procedures: inferredFailure("address_name"), + }; + expect(await availability(names)).toEqual({ + ...fixtureFailure, + ...auxiliaryFailure, + }); + + auxiliaryBroken = false; + expect(await call("current_document", {})).toBe("ok"); + expect(await availability(names)).toEqual({ + ...fixtureFailure, + current_document: "available", + list_documents: auxiliaryFailure.list_documents, + }); +}); + +it("keeps newer failures when older successes complete after them", async () => { + let reply: Responder = () => Promise.resolve(observed("text")); + const respond = { current: (operation: string) => reply(operation) }; + const { client, call, availability } = await connectScripted({ + operations: [...DIRECT_OPERATIONS, "current_procedure"], + respond, + }); + const calls = heldCalls(client); + respond.current = (operation) => calls.respond(operation) ?? reply(operation); + const document = { document: "doc" }; + const refuse: Responder = (operation) => + Promise.resolve( + err( + new AnalysisCapabilityUnavailableError( + FIXTURE.id, + operation, + "fixture refusal", + ), + ), + ); + try { + // Successes dispatched before an own refusal, before a provider failure, + // or between the two complete after both. + const olderAddress = await calls.dispatch("address_name", ADDRESS); + const olderCurrent = await calls.dispatch("current_address", document); + reply = refuse; + expect(await call("address_name", ADDRESS)).toBe("error"); + expect(await call("current_procedure", document)).toBe("error"); + const between = await calls.dispatch("current_procedure", document); + reply = (operation) => adapterFailure(FIXTURE, operation); + expect(await call("procedure_assembly", PROCEDURE)).toBe("error"); + olderAddress.release(observed("old-name")); + olderCurrent.release(observed("0x1000")); + between.release(observed("main")); + for (const settled of [olderAddress, olderCurrent, between]) + expect(await settled.isError()).toBe(false); + expect( + await availability([ + "address_name", + "current_address", + "current_procedure", + "list_procedures", + ]), + ).toEqual({ + address_name: refusal("address_name", "fixture refusal"), + current_address: inferredFailure("procedure_assembly"), + // Its own later success superseded the refusal, not the provider failure. + current_procedure: inferredFailure("procedure_assembly"), + list_procedures: inferredFailure("procedure_assembly"), + }); + + // A success dispatched behind a failure executed after it, even though + // the failure was not yet observed when the success was dispatched. + reply = () => Promise.resolve(observed("text")); + const failure = await calls.dispatch("procedure_assembly", PROCEDURE); + const queued = await calls.dispatch("current_address", document); + failure.release( + err(new ProviderAdapterError(FIXTURE.id, "procedure_assembly")), + ); + expect(await failure.isError()).toBe(true); + queued.release(observed("0x1000")); + expect(await queued.isError()).toBe(false); + expect( + await availability([ + "address_name", + "current_address", + "current_procedure", + "list_procedures", + "procedure_assembly", + ]), + ).toEqual({ + address_name: refusal("address_name", "fixture refusal"), + current_address: "available", + current_procedure: "available", + list_procedures: "available", + procedure_assembly: observedFailure("procedure_assembly"), + }); + } finally { + await calls.join(); + } +}); + +it("keeps newer successes when an older failure completes last", async () => { + const respond = { + current: (_operation: string): Promise => + Promise.resolve(observed("text")), + }; + const { client, call, availability } = await connectScripted({ + operations: DIRECT_OPERATIONS, + respond, + }); + const calls = heldCalls(client); + respond.current = (operation) => + calls.respond(operation) ?? Promise.resolve(observed("text")); + const names = ["address_name", "current_address", "list_procedures"]; + try { + // A sibling dispatched later already proved the provider answers. + const older = await calls.dispatch("address_name", ADDRESS); + const newer = await calls.dispatch("current_address", { document: "doc" }); + newer.release(observed("0x1000")); + expect(await newer.isError()).toBe(false); + older.release(err(new ProviderAdapterError(FIXTURE.id, "address_name"))); + expect(await older.isError()).toBe(true); + expect(await availability(names)).toEqual({ + address_name: observedFailure("address_name"), + current_address: "available", + list_procedures: "available", + }); + + // A later success of the same operation supersedes its older failure. + const stale = await calls.dispatch("procedure_assembly", PROCEDURE); + expect(await call("procedure_assembly", PROCEDURE)).toBe("ok"); + stale.release( + err(new ProviderAdapterError(FIXTURE.id, "procedure_assembly")), + ); + expect(await stale.isError()).toBe(true); + expect(await availability([...names, "procedure_assembly"])).toEqual({ + address_name: observedFailure("address_name"), + current_address: "available", + list_procedures: "available", + procedure_assembly: "available", + }); + } finally { + await calls.join(); + } +}); + +it("restores composed tools in a scoped catalog after one composed success", async () => { + let broken = true; + const respond = { + current: (operation: string): Promise => { + if (broken) return adapterFailure(FIXTURE, operation); + if (operation === "procedure_address") + return Promise.resolve(observed("0x1")); + if (operation === "address_name") + return Promise.resolve(observed("fixture_name")); + return Promise.resolve(observed("return 0;")); + }, + }; + const { client, availability, tools } = await connectScripted({ + operations: [ + "address_name", + "procedure_address", + "procedure_callees", + "procedure_callers", + "procedure_pseudo_code", + "search_procedures", + "search_strings", + "xrefs", + ], + respond, + families: "session,enhanced", + }); + // Direct prerequisites are outside this scope; only composed tools can retry. + const batchItemStatus = async () => { + const reply = await client.callTool({ + name: "batch_decompile", + arguments: { addresses: ["0x1"] }, + }); + expect(reply.isError).not.toBe(true); + return reply.structuredContent; + }; + expect(await batchItemStatus()).toMatchObject({ + normalized_result: { items: [{ status: "error" }] }, + }); + expect(await availability(["trace_feature", "get_call_graph"])).toEqual({ + // The batch still decompiles the unresolved selector; its failure is newest. + trace_feature: inferredFailure("procedure_pseudo_code"), + get_call_graph: observedFailure("procedure_address"), + }); + + broken = false; + expect(await batchItemStatus()).toMatchObject({ + normalized_result: { items: [{ status: "ok", pseudocode: "return 0;" }] }, + }); + expect( + await availability(["batch_decompile", "trace_feature", "get_call_graph"]), + ).toEqual({ + batch_decompile: "available", + trace_feature: "available", + get_call_graph: "available", + }); + expect( + (await tools()).find(({ name }) => name === "get_call_graph"), + ).toMatchObject({ + default_mode_available: true, + modes: [ + { name: "forward", available: true, missing_operations: [] }, + { name: "backward", available: true, missing_operations: [] }, + ], + }); +}); diff --git a/tests/boundary/providers/kuna/fakeKuna.ts b/tests/boundary/providers/kuna/fakeKuna.ts index 98189c276d..0f24f98296 100644 --- a/tests/boundary/providers/kuna/fakeKuna.ts +++ b/tests/boundary/providers/kuna/fakeKuna.ts @@ -6,6 +6,8 @@ export interface FakeKunaFunction { readonly name: string; readonly addr: string; readonly error?: string; + /** Inventory aliases, such as versioned-symbol names or generated `sub_` names. */ + readonly aliases?: readonly string[]; } /** One inventory-only row; Kuna reports a size of 0 for an unmeasured entry. */ @@ -13,6 +15,7 @@ export interface FakeKunaInventoryEntry { readonly name: string; readonly addr: string; readonly size?: number; + readonly aliases?: readonly string[]; } export interface FakeKunaString { @@ -121,7 +124,7 @@ if (args[0] === "--version") { if (mode === "hang") hang(); else if (mode === "not-json") process.stdout.write("functions: 2\n"); else { - const listed = [...functions, ...(behavior.inventoryEntries ?? []), ...(behavior.inventoryImports === false ? [] : [{ name: "puts", addr: "0x2000" }])].map((fn) => ({ name: fn.name, address: parseInt(fn.addr, 16), address_hex: fn.addr, aliases: [], object_location: null, size: fn.size ?? (fn.name === "puts" ? 0 : 16) })); + const listed = [...functions, ...(behavior.inventoryEntries ?? []), ...(behavior.inventoryImports === false ? [] : [{ name: "puts", addr: "0x2000" }])].map((fn) => ({ name: fn.name, address: parseInt(fn.addr, 16), address_hex: fn.addr, aliases: fn.aliases ?? [], object_location: null, size: fn.size ?? (fn.name === "puts" ? 0 : 16) })); setTimeout(() => process.stdout.write(JSON.stringify({ binary: args[1], count: listed.length, total: listed.length, error: null, functions: listed }, null, 2) + "\n"), behavior.inventoryDelayMs ?? 0); } } else if (args[0] === "strings") { diff --git a/tests/boundary/providers/kuna/kunaAliasSearchMcp.test.ts b/tests/boundary/providers/kuna/kunaAliasSearchMcp.test.ts new file mode 100644 index 0000000000..a633278d30 --- /dev/null +++ b/tests/boundary/providers/kuna/kunaAliasSearchMcp.test.ts @@ -0,0 +1,237 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; + +import { AnalysisProviderRegistry } from "../../../../src/application/binary/AnalysisProviderRegistry.js"; +import { composeBinarySession } from "../../../../src/application/binary/BinarySessionComposition.js"; +import { parseEvidence } from "../../../../src/domain/evidence.js"; +import { KunaProvider } from "../../../../src/kuna/KunaProvider.js"; +import { elfNoteFixture } from "../../../../src/native/elf/ElfNotes.fixture.js"; +import { createServer } from "../../../../src/server/createServer.js"; +import { parseMcpToolError } from "../../../fixtures/mcpToolError.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { mcpTest, type TestMcp } from "../../../support/mcp/mcpFixture.js"; +import { writeFakeKuna, type FakeKunaBehavior } from "./fakeKuna.js"; +import { + MACHO_FUNCTIONS, + MACHO_INVENTORY_ENTRIES, + machoInventoryImage, +} from "./kunaMachoFixture.js"; + +const test = mcpTest.skipIf(process.platform === "win32"); + +/** + * Kuna's inventory for the versioned ELF in #8330: two entries share the + * source-export alias `audit_transform`, each has a generated `sub_` alias, + * and one repeats its primary name among its aliases. + */ +const VERSIONED = [ + { name: "main", addr: "0x1000" }, + { + name: "audit_v1", + addr: "0x1334", + aliases: ["audit_transform", "sub_1334"], + }, + { + name: "audit_v2", + addr: "0x133a", + aliases: ["audit_transform", "sub_133a", "audit_v2", "sub_133a"], + }, +]; + +const aliasSession = async ( + mcp: TestMcp, + prefix: string, + behavior: FakeKunaBehavior, + image: Uint8Array = elfNoteFixture({ bits: 64, type: 2 }), +) => { + const root = await createTestTempDirectory(prefix); + const kuna = await writeFakeKuna(root, behavior); + const target = join(root, "program"); + await writeFile(target, image); + const session = composeBinarySession( + new AnalysisProviderRegistry( + [ + new KunaProvider( + { REA_KUNA_COMMAND: kuna, REA_KUNA_CACHE_DIR: join(root, "cache") }, + { platform: process.platform }, + ), + ], + "kuna", + ), + ); + const client = await mcp.connect(createServer({ kind: "session", session })); + const call = async (name: string, arguments_: Record) => { + const response = await client.callTool({ name, arguments: arguments_ }); + if (response.isError === true) + throw new Error(`${name} failed: ${JSON.stringify(response)}`); + return response.structuredContent; + }; + const observe = async (name: string, arguments_: Record) => + parseEvidence(await call(name, arguments_)); + await call("open_binary", { path: target }); + const search = async (arguments_: Record) => + (await observe("search_procedures", arguments_)).normalized_result; + return { client, call, observe, search }; +}; + +const audit = ( + version: 1 | 2, + matched_aliases?: readonly string[], +): Record => ({ + address: version === 1 ? "0x1334" : "0x133a", + value: `audit_v${String(version)}`, + ...(matched_aliases === undefined ? {} : { matched_aliases }), +}); + +test("searches the aliases exact lookup accepts without listing them as procedures", async ({ + mcp, +}) => { + const { client, call, observe, search } = await aliasSession( + mcp, + "rea-kuna-alias-search-", + { functions: VERSIONED }, + ); + // The listing keeps one row per entry under its primary name. + expect((await observe("list_procedures", {})).normalized_result).toEqual([ + { address: "0x1000", value: "main" }, + audit(1), + audit(2), + { address: "0x2000", value: "puts" }, + ]); + + const shared = [audit(1, ["audit_transform"]), audit(2, ["audit_transform"])]; + expect(await search({ pattern: "audit_transform" })).toEqual(shared); + expect( + await search({ + pattern: "^audit_transform$", + mode: "regex", + case_sensitive: true, + }), + ).toEqual(shared); + expect(await search({ pattern: "SUB_1334" })).toEqual([ + audit(1, ["sub_1334"]), + ]); + expect(await search({ pattern: "SUB_1334", case_sensitive: true })).toEqual( + [], + ); + // A primary-name match carries no alias; one also matching an alias keeps + // the primary value, and an alias repeating the primary name is not listed. + expect(await search({ pattern: "audit_v1" })).toEqual([audit(1)]); + expect(await search({ pattern: "audit" })).toEqual(shared); + expect(await search({ pattern: "^sub_", mode: "regex" })).toEqual([ + audit(1, ["sub_1334"]), + audit(2, ["sub_133a"]), + ]); + expect(await search({ pattern: "absent_alias" })).toEqual([]); + + // Exact lookup and search agree: a unique alias resolves, and a shared one + // stays ambiguous with both entries. + expect( + (await observe("procedure_address", { procedure: "sub_1334" })) + .normalized_result, + ).toBe("0x1334"); + const ambiguous = await client.callTool({ + name: "procedure_address", + arguments: { procedure: "audit_transform" }, + }); + expect(parseMcpToolError(ambiguous).error.message).toContain( + "0x1334, 0x133a", + ); + + // Retained summary pages carry which name matched. + const summary = (await call("search_procedures", { + pattern: "audit_transform", + detail: "summary", + })) as { + normalized_result: { + parent_evidence_id: string; + summary: { row_count: number; selectable_fields: string[] }; + }; + }; + expect(summary.normalized_result.summary.row_count).toBe(2); + expect(summary.normalized_result.summary.selectable_fields).toContain( + "matched_aliases", + ); + const page = await observe("inspect_analysis_view", { + source: { + kind: "retained-evidence", + evidence_id: summary.normalized_result.parent_evidence_id, + }, + view: { kind: "native-inventory", fields: ["matched_aliases"] }, + }); + expect(page.normalized_result).toMatchObject({ + rows: [ + { + source_index: 0, + record: { address: "0x1334", matched_aliases: ["audit_transform"] }, + }, + { + source_index: 1, + record: { address: "0x133a", matched_aliases: ["audit_transform"] }, + }, + ], + }); +}); + +test("traces a procedure found only by its alias through the composed feature trace", async ({ + mcp, +}) => { + const { observe } = await aliasSession(mcp, "rea-kuna-alias-trace-", { + functions: VERSIONED, + }); + const traced = await observe("trace_feature", { query: "sub_1334" }); + expect(traced.normalized_result).toMatchObject({ + matches: [{ type: "procedure", ...audit(1, ["sub_1334"]) }], + references: [ + { + target_address: "0x1334", + source_address: "0x1004", + containing_procedure: { status: "unavailable" }, + }, + ], + }); + const primary = await observe("trace_feature", { query: "audit_v1" }); + expect(primary.normalized_result).toMatchObject({ + matches: [{ type: "procedure", ...audit(1) }], + }); +}); + +test("omits a data entry an alias names while keeping the code entry that shares it", async ({ + mcp, +}) => { + const { search, observe } = await aliasSession( + mcp, + "rea-kuna-alias-macho-", + { + functions: [ + MACHO_FUNCTIONS[0]!, + { ...MACHO_FUNCTIONS[1]!, aliases: ["shared_symbol"] }, + ], + inventoryEntries: MACHO_INVENTORY_ENTRIES.map((entry) => + entry.name === "global_count" + ? { ...entry, aliases: ["shared_symbol"] } + : entry, + ), + inventoryImports: false, + }, + machoInventoryImage(), + ); + const searched = await observe("search_procedures", { + pattern: "shared_symbol", + }); + expect(searched.normalized_result).toEqual([ + { + address: "0x100000440", + value: "helper", + matched_aliases: ["shared_symbol"], + }, + ]); + expect(searched.limitations).toContainEqual( + expect.stringContaining( + "Kuna's inventory also lists 1 entry in Mach-O data regions (__DATA,__data: 1)", + ), + ); + expect(await search({ pattern: "global_count" })).toEqual([]); +}); diff --git a/tests/conformance/ghidra/README.md b/tests/conformance/ghidra/README.md index 34ffb86a8d..580dc41820 100644 --- a/tests/conformance/ghidra/README.md +++ b/tests/conformance/ghidra/README.md @@ -25,7 +25,8 @@ binaries and Ghidra projects are never committed. multi-block branch. - `entry-aliases.c` preserves secondary imported entry labels, a bare-hex alias, and an interior label. The native CLI/MCP lane verifies entry selection, - annotation, interior-label rejection, and unchanged source bytes. + procedure search and feature tracing by the secondary name, annotation, + interior-label rejection, and unchanged source bytes. - `cross-format.c` is freestanding so the verifier can produce AArch64 ELF, x86-64 PE, and x86-64 Mach-O targets from the same semantics. It preserves an exported entry, direct and indirect calls, a volatile string reference, and a