Merge remote-tracking branch 'origin/main' into codex/address-context-recovery

# Conflicts:
#	src/application/AnalysisContextQueries.ts
This commit is contained in:
morluto committed 2026-11-02 00:55:18 +08:00
commit 89bf190ea2
38 files changed
+2514 -230

No files matched your search

+59 -21
View File
@@ -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<InventoryItem> 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<String> 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<InventoryItem> 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<Function> 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<Address> requesters, List<Address> holders) {}
private record AddressNameFailure(String reason, AddressNameConflict conflict) {}
private record FunctionEntry(Function function, InventoryItem item) {}
private record ProcedureNameMatch(boolean primary, List<String> aliases) {
boolean matched() {
return primary || !aliases.isEmpty();
}
}
private record FunctionAnalysisObservation(JsonObject dossier, List<String> limitations, JsonObject relationshipCoverage, boolean decompilerFailed) {}
private record CallBoundaryObservation(List<String> limitations, Integer unrepresentedCalleeCalls, JsonArray unresolvedCalls) {}
private record SwiftStringLiteralDecode(JsonArray literals, JsonArray omissions) {}
+2
View File
@@ -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
+16 -1
View File
@@ -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.
+4 -1
View File
@@ -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.
+39
View File
@@ -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",
@@ -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,
+30
View File
@@ -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,
+28 -10
View File
@@ -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" ||
+42 -39
View File
@@ -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<Result<JsonValue, AnalysisError>>;
type RelationshipCall = (
name: AnalysisOperation,
arguments_: Readonly<Record<string, JsonValue>>,
signal?: AbortSignal,
selectorOrigin?: ExecutionOptions["selectorOrigin"],
) => Promise<Result<AnalysisExecution, AnalysisError>>;
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<Result<boolean, AnalysisError>> => {
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,
@@ -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"]);
});
});
+69 -1
View File
@@ -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<Record<string, JsonValue>>,
signal?: AbortSignal,
selectorOrigin?: ExecutionOptions["selectorOrigin"],
) => Promise<Result<AnalysisExecution, AnalysisError>>;
/**
* 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<CallRelationshipRead> => {
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.`;
+17 -1
View File
@@ -10,7 +10,23 @@ import {
type EnhancedToolName,
} from "../contracts/enhancedInputs.js";
export type EnhancedResult = Promise<Result<JsonValue, AnalysisError>>;
/** 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<JsonValue, AnalysisError>
| PartialEnhancedFailure;
export type EnhancedResult = Promise<EnhancedOutcome>;
/** 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;
+33 -34
View File
@@ -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<string>();
const resolved = await this.#observe(
"procedure_address",
@@ -635,40 +636,41 @@ export class EnhancedTools {
];
let queueIndex = 0;
const graph: Record<string, JsonValue[]> = {};
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(
+63 -29
View File
@@ -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<ComposedWorkflowObservation> & {
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<Result<JsonValue, AnalysisError>>,
execute: (observed: AnalysisOperationPort) => EnhancedResult,
budget?: ComposedLimitationBudget,
): Promise<
AnalysisResultWithSources<{
readonly result: JsonValue;
readonly limitations: readonly string[];
readonly cachePolicy?: "live";
readonly evidenceLinks?: readonly string[];
}>
> => {
): Promise<ComposedWorkflowResult> => {
const limitations = new Map<string, SourceLimitation>();
const evidenceLinks = new Set<string>();
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<typeof createWorkflowEvidence>[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 ||
+14 -55
View File
@@ -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<void>>();
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<AnalysisExecution, AnalysisError>,
): 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 {
@@ -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<string> = 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<F extends Failure> {
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<ProviderFailure, "operation" | "reason">,
): 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<string, Outcomes<Failure>>();
readonly #providers = new Map<string, Outcomes<ProviderFailure>>();
#projected: ReadonlyMap<string, { readonly reason: string }> = new Map();
#dispatched = 0;
/** Current runtime unavailability keyed by operation. */
get entries(): ReadonlyMap<string, { readonly reason: string }> {
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<RoutedOperation>,
result: Result<unknown, AnalysisError>,
): 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<RoutedOperation>): boolean {
const projected = new Map<string, { readonly reason: string }>();
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 = <F extends Failure>(
owners: Map<string, Outcomes<F>>,
key: string,
): Outcomes<F> => {
const existing = owners.get(key);
if (existing !== undefined) return existing;
const created: Outcomes<F> = { failure: undefined, succeeded: 0 };
owners.set(key, created);
return created;
};
const newer = <F extends Failure>(current: F | undefined, next: F): F =>
current !== undefined && current.dispatch > next.dispatch ? current : next;
+4 -1
View File
@@ -34,9 +34,12 @@ const generic = <Code extends string>(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"),
+1 -1
View File
@@ -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" },
),
+1 -1
View File
@@ -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[];
+6 -1
View File
@@ -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(),
}),
]);
+21
View File
@@ -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 extends { readonly value: string }>(
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;
};
+7
View File
@@ -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. */
+1
View File
@@ -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",
+5 -1
View File
@@ -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<Exclude<GhidraInventoryOperation, GhidraNamingOperation>, z.ZodType>
+29 -7
View File
@@ -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];
});
};
+5 -4
View File
@@ -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) => {
+33 -13
View File
@@ -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(
-2
View File
@@ -461,5 +461,3 @@ export const createDarwinHelperCache = (options: {
return { lookup, publish, close };
};
export type DarwinHelperCache = ReturnType<typeof createDarwinHelperCache>;
+27 -2
View File
@@ -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<string, JsonValue | undefined>>,
): Record<string, JsonValue> =>
@@ -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" }],
},
});
},
);
});
@@ -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"] },
});
});
@@ -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" },
});
},
);
@@ -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<string, readonly string[] | AnalysisError>
>;
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<typeof createTestBinarySession>,
error: ReturnType<typeof parseMcpToolError>["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] },
);
},
);
@@ -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<string, unknown>) => {
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();
}
});
@@ -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<AnalysisExecution, AnalysisError>;
type Responder = (operation: string) => Promise<Reply>;
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<AnalysisOperation, "health">,
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<ReturnType<typeof createApplicationMcpHarness>>["client"],
) => {
const held = new Map<
string,
{ readonly reply: Promise<Reply>; readonly received: () => void }
>();
const launched: {
readonly release: (reply: Reply) => void;
readonly response: Promise<unknown>;
}[] = [];
return {
/** Serve a held reply when one is registered for this operation. */
respond: (operation: string): Promise<Reply> | 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<string, unknown>) => {
const reply = createDeferred<Reply>();
const received = createDeferred<void>();
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<AnalysisOperation, "health">[];
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<Record<string, string>>;
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<string, unknown>) =>
(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<Reply> => {
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<Reply> => {
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<Reply> =>
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<Reply> =>
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<Reply> => {
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: [] },
],
});
});
+4 -1
View File
@@ -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") {
@@ -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<string, unknown>) => {
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<string, unknown>) =>
parseEvidence(await call(name, arguments_));
await call("open_binary", { path: target });
const search = async (arguments_: Record<string, unknown>) =>
(await observe("search_procedures", arguments_)).normalized_result;
return { client, call, observe, search };
};
const audit = (
version: 1 | 2,
matched_aliases?: readonly string[],
): Record<string, unknown> => ({
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([]);
});
+2 -1
View File
@@ -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