Merge remote-tracking branch 'origin/main' into codex/address-context-recovery
# Conflicts: # src/application/AnalysisContextQueries.ts
This commit is contained in:
commit
89bf190ea2
38 files changed
+2514
-230
No files matched your search
@@ -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) {}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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.`;
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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;
|
||||
@@ -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"),
|
||||
|
||||
@@ -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" },
|
||||
),
|
||||
|
||||
@@ -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[];
|
||||
@@ -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(),
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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. */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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];
|
||||
});
|
||||
};
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -461,5 +461,3 @@ export const createDarwinHelperCache = (options: {
|
||||
|
||||
return { lookup, publish, close };
|
||||
};
|
||||
|
||||
export type DarwinHelperCache = ReturnType<typeof createDarwinHelperCache>;
|
||||
@@ -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: [] },
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user