* test(mcp): expect trace_feature to report references the provider truncated A provider that reports a capped xrefs read makes the trace truncated with a residual unknown for that target. An explicit complete report and a provider that reports nothing leave the trace as before. * fix(mcp): report references a provider truncated in trace_feature Executions gain an optional flag for a capped xrefs read, set by the IDA adapter from the upstream cap. trace_feature marks the trace truncated with a residual unknown for that target only when the provider reports a truncation; a provider that reports nothing keeps its previous result. The trace_feature workflow profile is revised so earlier results are not replayed. * refactor(ida): report reference truncation whenever the runner observed an xrefs cap * test(mcp): cover session forwarding, per-target state, native reports and failures of the trace coverage * test(mcp): format the session forwarding test * test(ida): expect headless IDA to resolve containing procedures from lookups and segment facts Cover positive and negative containment, segment boundaries including addresses above 2^53, failures of the lookup and of the segment survey, cancellation, input validation and the admitted program as the only document. * test(ida): expect feature tracing and address context to work on headless IDA Run trace_feature, including a capped reference read, and inspect_address_context with and without an explicit document through the IDA session client. * test(mcp): expect trace_feature availability to follow the search operations it calls * fix(mcp): derive trace_feature availability from the search operations it calls * feat(ida): resolve containing procedures and report the admitted program as the document The modern headless profile resolves an address through the function lookup and, when no function contains it, tells a mapped address from an unmapped one using the database-scoped minimal survey. A survey that is missing, truncated or inconsistent fails the read. list_documents reports the single admitted program. The attached profiles do not declare either operation. * docs(ida): describe containing procedures, the admitted document and truncated feature traces * test(ida): verify containing procedures, the admitted document and feature tracing on real IDA * docs(testing): list the headless IDA workflows the real lane verifies * test(ida): make each survey failure case isolate a single defect * test(ida): pin large addresses, segment order, document identity, attached refusals and each trace_feature prerequisite * test(mcp): expect a repeated reference source to be resolved once in trace_feature * fix(mcp): resolve the containing procedure of a repeated reference source once * test(ida): trace the fixture procedure and derive probe addresses from its facts in the real lane * test(process): format processCaptureLifecycle.test.ts after the merge * test(ida): declare the alignment each synthetic universal slice offset satisfies * refactor(ida): move the real lane's feature trace assertions into a helper * test(ida): expect the real lane's feature trace check to accept references to every matched address * fix(ida): check each trace reference against its own matched address in the real lane * test(ida): pin that the new operations reject a document other than the admitted program * refactor(ida): move the real lane's mapped non-function probe into a helper * test(ida): expect the real lane's mapped data probe to skip strings inside functions * fix(ida): try the target's strings in order for the mapped non-function probe * test(ida): declare the real lane helper types for the verifier tests * test(ida): expect the real lane to reject a listed string that resolves as unmapped * fix(ida): fail the real lane when a listed string resolves as unmapped
199 lines
5.6 KiB
TypeScript
199 lines
5.6 KiB
TypeScript
import { z } from "zod";
|
|
import type {
|
|
AnalysisExecution,
|
|
AnalysisOperation,
|
|
} from "./AnalysisProvider.js";
|
|
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
|
|
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
|
import type { JsonValue } from "../domain/jsonValue.js";
|
|
import { err, ok, type Result } from "../domain/result.js";
|
|
import type {
|
|
EnhancedResult,
|
|
TraceMatch,
|
|
TraceReference,
|
|
} from "./EnhancedToolTypes.js";
|
|
|
|
type AnalysisCall = (
|
|
name: AnalysisOperation,
|
|
arguments_: Readonly<Record<string, JsonValue>>,
|
|
signal?: AbortSignal,
|
|
) => Promise<Result<JsonValue, AnalysisError>>;
|
|
|
|
type ObserveCall = (
|
|
name: AnalysisOperation,
|
|
arguments_: Readonly<Record<string, JsonValue>>,
|
|
signal?: AbortSignal,
|
|
) => Promise<Result<AnalysisExecution, AnalysisError>>;
|
|
|
|
type LiteralSearchSource = readonly [
|
|
"search_strings" | "search_procedures",
|
|
"string" | "procedure",
|
|
];
|
|
|
|
interface ReferenceTraceRequest {
|
|
readonly matches: readonly TraceMatch[];
|
|
readonly signal?: AbortSignal;
|
|
}
|
|
|
|
const LITERAL_SEARCH_SOURCES: readonly LiteralSearchSource[] = [
|
|
["search_strings", "string"],
|
|
["search_procedures", "procedure"],
|
|
];
|
|
|
|
export interface LiteralTraceInput {
|
|
readonly query: string;
|
|
readonly case_sensitive: boolean;
|
|
}
|
|
|
|
/** Trace matching strings and procedures through provider xrefs. */
|
|
export const traceLiteralFeature = async (
|
|
call: AnalysisCall,
|
|
observe: ObserveCall,
|
|
input: LiteralTraceInput,
|
|
signal?: AbortSignal,
|
|
): EnhancedResult => {
|
|
const searched = await literalMatches(
|
|
call,
|
|
input,
|
|
LITERAL_SEARCH_SOURCES,
|
|
signal,
|
|
);
|
|
if (!searched.ok) return searched;
|
|
const traced = await traceReferences(call, observe, {
|
|
matches: searched.value.matches,
|
|
...(signal === undefined ? {} : { signal }),
|
|
});
|
|
if (!traced.ok) return traced;
|
|
return ok({
|
|
query: input.query,
|
|
search_mode: "literal",
|
|
matches: searched.value.matches,
|
|
references: traced.value.references,
|
|
truncated: traced.value.residual.length > 0,
|
|
residual_unknowns: traced.value.residual,
|
|
});
|
|
};
|
|
|
|
const literalMatches = async (
|
|
call: AnalysisCall,
|
|
input: LiteralTraceInput,
|
|
sources: readonly LiteralSearchSource[],
|
|
signal?: AbortSignal,
|
|
): Promise<Result<{ matches: TraceMatch[] }, AnalysisError>> => {
|
|
const matches: TraceMatch[] = [];
|
|
const matched = new Set<string>();
|
|
for (const [tool, type] of sources) {
|
|
const result = await call(
|
|
tool,
|
|
{
|
|
pattern: input.query,
|
|
mode: "literal",
|
|
case_sensitive: input.case_sensitive,
|
|
},
|
|
signal,
|
|
);
|
|
if (!result.ok) return result;
|
|
const items = z
|
|
.array(z.object({ address: z.string(), value: z.string() }))
|
|
.safeParse(result.value);
|
|
if (!items.success)
|
|
return err(
|
|
new AnalysisOutputError(
|
|
tool,
|
|
"provider returned an invalid search result",
|
|
),
|
|
);
|
|
addPageMatches({
|
|
items: items.data.map(({ address, value: name }) => ({
|
|
address,
|
|
name,
|
|
})),
|
|
type,
|
|
matches,
|
|
matched,
|
|
});
|
|
}
|
|
return ok({ matches });
|
|
};
|
|
|
|
const addPageMatches = (input: {
|
|
readonly items: readonly {
|
|
readonly address: string;
|
|
readonly name: string;
|
|
}[];
|
|
readonly type: TraceMatch["type"];
|
|
readonly matches: TraceMatch[];
|
|
readonly matched: Set<string>;
|
|
}): void => {
|
|
const { items, type, matches, matched } = input;
|
|
for (const item of items) {
|
|
const key = `${type}\u0000${item.address}`;
|
|
if (matched.has(key)) continue;
|
|
matches.push({ type, address: item.address, value: item.name });
|
|
matched.add(key);
|
|
}
|
|
};
|
|
|
|
const traceReferences = async (
|
|
call: AnalysisCall,
|
|
observe: ObserveCall,
|
|
request: ReferenceTraceRequest,
|
|
): Promise<
|
|
Result<{ references: TraceReference[]; residual: string[] }, AnalysisError>
|
|
> => {
|
|
const references: TraceReference[] = [];
|
|
const residual: string[] = [];
|
|
// A source address is resolved once however many matches reference it.
|
|
const containing = new Map<string, JsonValue>();
|
|
for (const match of request.matches) {
|
|
const observed = await observe(
|
|
"xrefs",
|
|
{ address: match.address },
|
|
request.signal,
|
|
);
|
|
if (!observed.ok) return observed;
|
|
// Only an explicit producer report of truncation is a coverage gap; a
|
|
// provider that reports nothing keeps its previous, unqualified result.
|
|
const unknown = `References to ${match.address} were truncated by the provider; additional references may exist.`;
|
|
if (
|
|
observed.value.referencesTruncated === true &&
|
|
!residual.includes(unknown)
|
|
)
|
|
residual.push(unknown);
|
|
const sources = observed.value.result;
|
|
if (!Array.isArray(sources))
|
|
return err(
|
|
new AnalysisOutputError(
|
|
"xrefs",
|
|
"provider returned a non-array result",
|
|
),
|
|
);
|
|
for (const source of sources) {
|
|
if (typeof source !== "string")
|
|
return err(
|
|
new AnalysisOutputError(
|
|
"xrefs",
|
|
"provider returned a non-address value",
|
|
),
|
|
);
|
|
let containingProcedure = containing.get(source);
|
|
if (containingProcedure === undefined) {
|
|
const resolved = await call(
|
|
"resolve_containing_procedure",
|
|
{ address: source },
|
|
request.signal,
|
|
);
|
|
if (!resolved.ok) return resolved;
|
|
containingProcedure = resolved.value;
|
|
containing.set(source, containingProcedure);
|
|
}
|
|
references.push({
|
|
target_address: match.address,
|
|
source_address: source,
|
|
containing_procedure: containingProcedure,
|
|
});
|
|
}
|
|
}
|
|
return ok({ references, residual });
|
|
};
|