fix!: preserve feature traces when owner lookup is unavailable (#8368)

* fix!: preserve feature traces when owner lookup is unavailable

* test: strengthen feature trace cancellation assertions

* fix: preserve feature trace provider readiness errors

* test: verify all feature trace reference sources

* test: verify IDA guidance at its owning boundary

* style: format feature trace regression
This commit is contained in:
morluto authored and GitHub committed 2026-11-01 05:38:40 +08:00
1 parent f72638f6b1
commit 6e9ee97a69
16 files changed
+440 -37

No files matched your search

+7 -2
View File
@@ -210,8 +210,13 @@ unresolved relationship reads. Cancellation remains a tool error.
`trace_feature` validates each reference's containing-procedure resolution using
the same contract as navigation context. Explicit absence remains a successful
negative observation; malformed provider replies return `unreadable_output`
for `resolve_containing_procedure` before successful-result projection.
negative observation. If the provider reports a non-retryable capability refusal
attributed to the owner lookup without incomplete cleanup, the reference remains visible
with `containing_procedure.status: "unavailable"` and a residual unknown. That
unknown does not imply references were truncated.
Malformed provider replies return `unreadable_output` for
`resolve_containing_procedure` before successful-result projection;
cancellation and incomplete cleanup remain errors.
REA prepares complete MCP results within the pinned stdio client's 10 MiB
receive-buffer budget, including both text and structured representations and
+40 -1
View File
@@ -21,6 +21,8 @@ export const verifyKunaReferences = async ({
address,
expectedCaller,
nativeIncoming,
verifyNamedTrace = false,
minimumTraceReferences = 1,
}) => {
const before = await Promise.all([
entries(join(cacheRoot, "projects")),
@@ -80,7 +82,44 @@ export const verifyKunaReferences = async ({
direction: "forward",
});
assert.equal(path.value.goal_status, "reached");
composed = { graph_ms: graph.ms, path_ms: path.ms };
let traceMs;
if (verifyNamedTrace) {
const trace = await call("trace_feature", { query: name });
traceMs = trace.ms;
assert.ok(
trace.value.matches.some(
(match) => match.type === "procedure" && match.address === address,
),
`${name} was not retained in its feature trace`,
);
assert.ok(
incoming.value.length >= minimumTraceReferences,
`${name} fixture did not produce the expected incoming references`,
);
const references = trace.value.references.filter(
(row) => row.target_address === address,
);
assert.deepEqual(
references.map((row) => row.source_address).sort(),
[...incoming.value].sort(),
`${name} lost or duplicated an observed incoming reference`,
);
for (const reference of references) {
assert.equal(reference.containing_procedure.status, "unavailable");
assert.equal(reference.containing_procedure.provider_id, "kuna");
assert.ok(
trace.value.residual_unknowns.some((unknown) =>
unknown.includes(reference.source_address),
),
);
}
assert.equal(trace.value.truncated, false);
}
composed = {
graph_ms: graph.ms,
path_ms: path.ms,
...(traceMs === undefined ? {} : { feature_trace_ms: traceMs }),
};
}
assert.deepEqual(
await Promise.all([
+17 -7
View File
@@ -115,7 +115,9 @@ __attribute__((noinline)) int rea_classify(int c) {
}
int main(int argc, char **argv) {
if (argc < 2) return 2;
printf("%u %d\n", rea_checksum((const unsigned char *)argv[1], strlen(argv[1])),
size_t length = strlen(argv[1]);
printf("%u %u %d\n", rea_checksum((const unsigned char *)argv[1], length),
rea_checksum((const unsigned char *)argv[1], length ? length - 1 : 0),
rea_classify(argv[1][0]));
return 0;
}
@@ -282,8 +284,12 @@ const quality = (text) => ({
const verifyKuna = async (
path,
functions,
expectedCaller,
expectedStrings = [],
{
expectedCaller,
expectedStrings = [],
verifyNamedTrace = false,
minimumTraceReferences = {},
} = {},
) => {
const sha256 = createHash("sha256")
.update(await readFile(path))
@@ -320,6 +326,8 @@ const verifyKuna = async (
name,
address,
expectedCaller,
verifyNamedTrace,
minimumTraceReferences: minimumTraceReferences[name],
nativeIncoming: (targetAddress) =>
nativeKunaIncomingSites({
executable: kuna,
@@ -549,10 +557,12 @@ try {
for (const binary of [built, stripped])
results.push({
binary: binary.slice(workspace.length + 1),
kuna: await verifyKuna(binary, [...fixtureFunctions], fixtureMain, [
"_φ( °-°)/ REA unicode marker",
"REA wide marker",
]),
kuna: await verifyKuna(binary, [...fixtureFunctions], {
expectedCaller: fixtureMain,
expectedStrings: ["_φ( °-°)/ REA unicode marker", "REA wide marker"],
verifyNamedTrace: binary === built,
minimumTraceReferences: { rea_checksum: 2 },
}),
});
// Caller-built matrix.
@@ -776,6 +776,23 @@ describe("trace_feature requirements", () => {
reason: null,
}));
it("remains available when owner lookup is unavailable", () => {
expect(
entry(
"trace_feature",
status({
open: true,
kind: "executable",
capabilities: capabilities([
"search_strings",
"search_procedures",
"xrefs",
]),
}),
),
).toMatchObject({ available: true, reason: "available" });
});
it("needs the search operations the workflow actually calls", () => {
expect(
entry(
+1 -6
View File
@@ -215,12 +215,7 @@ const ENHANCED_REQUIREMENTS: ReadonlyMap<string, ProviderComposition> = new Map(
],
analyze_function: ["analyze_function"],
inspect_native_api: ["analyze_function"],
trace_feature: [
"search_strings",
"search_procedures",
"xrefs",
"resolve_containing_procedure",
],
trace_feature: ["search_strings", "search_procedures", "xrefs"],
} satisfies Readonly<Record<EnhancedContractName, ProviderComposition>>),
);
+45 -11
View File
@@ -4,12 +4,16 @@ import type {
AnalysisExecution,
AnalysisOperation,
} from "./AnalysisProvider.js";
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import type { JsonValue } from "../domain/jsonValue.js";
import {
parseContainingProcedureResolution,
type ContainingProcedureResolution,
type TraceContainingProcedure,
} from "../domain/native/nativeObservation.js";
import { err, ok, type Result } from "../domain/result.js";
import type { EnhancedResult, TraceReference } from "./EnhancedToolTypes.js";
@@ -70,7 +74,7 @@ export const traceLiteralFeature = async (
search_mode: "literal",
matches: searched.value.matches,
references: traced.value.references,
truncated: traced.value.residual.length > 0,
truncated: traced.value.truncated,
residual_unknowns: traced.value.residual,
});
};
@@ -121,14 +125,18 @@ const traceReferences = async (
observe: ObserveCall,
request: ReferenceTraceRequest,
): Promise<
Result<{ references: TraceReference[]; residual: string[] }, AnalysisError>
Result<
{ references: TraceReference[]; residual: string[]; truncated: boolean },
AnalysisError
>
> => {
const references: TraceReference[] = [];
const residual: string[] = [];
let truncated = false;
// References identify targets by address, independently of search alternatives.
const tracedTargets = new Set<string>();
// A source address is resolved once however many matches reference it.
const containing = new Map<string, ContainingProcedureResolution>();
const containing = new Map<string, TraceContainingProcedure>();
for (const match of request.matches) {
if (tracedTargets.has(match.address)) continue;
tracedTargets.add(match.address);
@@ -140,10 +148,12 @@ const traceReferences = async (
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.
if (observed.value.referencesTruncated === true)
if (observed.value.referencesTruncated === true) {
truncated = true;
residual.push(
`References to ${match.address} were truncated by the provider; additional references may exist.`,
);
}
const sources = observed.value.result;
if (!Array.isArray(sources))
return err(
@@ -167,10 +177,34 @@ const traceReferences = async (
{ address: source },
request.signal,
);
if (!resolved.ok) return resolved;
const parsed = parseContainingProcedureResolution(resolved.value);
if (!parsed.ok) return parsed;
containingProcedure = parsed.value;
if (!resolved.ok) {
if (
!(resolved.error instanceof AnalysisCapabilityUnavailableError) ||
resolved.error.operation !== "resolve_containing_procedure" ||
resolved.error.cleanupIncomplete ||
resolved.error.retryable
)
return resolved;
if (request.signal?.aborted === true)
return err(
new AnalysisCancelledError("trace_feature", {
cause: request.signal.reason,
}),
);
containingProcedure = {
query_address: source,
status: "unavailable",
provider_id: resolved.error.providerId,
reason: resolved.error.reason,
};
residual.push(
`Containing procedure for ${source} is unavailable: ${resolved.error.reason}`,
);
} else {
const parsed = parseContainingProcedureResolution(resolved.value);
if (!parsed.ok) return parsed;
containingProcedure = parsed.value;
}
containing.set(source, containingProcedure);
}
references.push({
@@ -180,5 +214,5 @@ const traceReferences = async (
});
}
}
return ok({ references, residual });
return ok({ references, residual, truncated });
};
+2 -2
View File
@@ -3,7 +3,7 @@ import type { z } from "zod";
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import type { JsonValue } from "../domain/jsonValue.js";
import type { ContainingProcedureResolution } from "../domain/native/nativeObservation.js";
import type { TraceContainingProcedure } from "../domain/native/nativeObservation.js";
import { err, type Result } from "../domain/result.js";
import {
enhancedInputSchemas,
@@ -14,7 +14,7 @@ export type EnhancedResult = Promise<Result<JsonValue, AnalysisError>>;
export type TraceReference = {
readonly target_address: string;
readonly source_address: string;
readonly containing_procedure: ContainingProcedureResolution;
readonly containing_procedure: TraceContainingProcedure;
};
/** One enhanced call whose input was parsed at its owning adapter boundary. */
+5 -3
View File
@@ -17,7 +17,9 @@ export const workflowAnalysisProfile = (
? { workflow_revision: 5 }
: operation === "batch_decompile"
? { workflow_revision: 4 }
: operation === "get_call_graph" || operation === "trace_feature"
? { workflow_revision: 3 }
: { workflow_revision: 1 }),
: operation === "trace_feature"
? { workflow_revision: 4 }
: operation === "get_call_graph"
? { workflow_revision: 3 }
: { workflow_revision: 1 }),
});
@@ -1248,6 +1248,11 @@ describe("executions that report truncated references", () => {
it("revises the trace_feature workflow profile so earlier results are not replayed", () => {
const profile = ANALYSIS_SNAPSHOT_PROFILE;
expect(
workflowAnalysisProfile(profile, "trace_feature").parameters
.workflow_revision,
).toBe(4);
expect(workflowAnalysisProfile(profile, "trace_feature")).not.toEqual(
workflowAnalysisProfile(profile),
);
+1 -1
View File
@@ -80,7 +80,7 @@ export const ENHANCED_TOOL_CONTRACTS = [
...FUNCTION_WORKFLOW_TOOL_CONTRACTS,
enhanced(
"trace_feature",
"Trace the complete literal query, preserving spaces, through every matching string and procedure, their xrefs, and truthful containing-procedure resolution. Returns observations and residual unknowns without inferring reference kinds.",
"Trace the complete literal query, preserving spaces, through every matching string and procedure and their xrefs. Preserve an explicit unavailable owner for a non-retryable containment capability refusal. Returns observations and residual unknowns without inferring reference kinds; truncated only reports an observed reference cap.",
enhancedInputSchemas.trace_feature,
),
enhanced(
+2 -2
View File
@@ -1,7 +1,7 @@
import {
nativeAddressListSchema,
nativeObservationSourceSchemas,
containingProcedureResolutionSchema,
traceContainingProcedureSchema,
} from "../domain/native/nativeObservation.js";
import { overviewInventoryCoverageSchema } from "../domain/native/inventoryCoverage.js";
import {
@@ -261,7 +261,7 @@ const literalTraceOutput = evidenceResultOf(
z.object({
target_address: z.string(),
source_address: z.string(),
containing_procedure: containingProcedureResolutionSchema,
containing_procedure: traceContainingProcedureSchema,
}),
),
truncated: z.boolean(),
+14
View File
@@ -27,6 +27,20 @@ export type ContainingProcedureResolution = z.infer<
typeof containingProcedureResolutionSchema
>;
/** A trace can retain a reference when its provider cannot resolve ownership. */
export const traceContainingProcedureSchema = z.union([
containingProcedureResolutionSchema,
z.strictObject({
query_address: z.string(),
status: z.literal("unavailable"),
provider_id: z.string(),
reason: z.string(),
}),
]);
export type TraceContainingProcedure = z.infer<
typeof traceContainingProcedureSchema
>;
/** Validate provider procedure facts before a workflow interprets absence. */
export const parseContainingProcedureResolution = (
value: JsonValue,
@@ -3,7 +3,10 @@ import { afterEach, describe, expect, it } from "vitest";
import type { AnalysisOperationPort } from "../../../src/application/AnalysisProvider.js";
import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js";
import { EnhancedTools } from "../../../src/application/EnhancedTools.js";
import { AnalysisOutputError } from "../../../src/domain/analysisErrorCore.js";
import {
AnalysisCapabilityUnavailableError,
AnalysisOutputError,
} from "../../../src/domain/analysisErrorCore.js";
import { err, ok } from "../../../src/domain/result.js";
import { closeEnhancedToolResources, connect } from "./enhancedToolsHarness.js";
@@ -137,6 +140,195 @@ const trace = async (analysis: AnalysisOperationPort) => {
return result.structuredContent as { normalized_result: unknown };
};
it("preserves references and explicitly unknown owners without claiming truncation", async () => {
const baseline = provider();
const output = await trace({
execute: (operation, input, options) =>
operation === "resolve_containing_procedure" && input.address === "0x20"
? Promise.resolve(
err(
new AnalysisCapabilityUnavailableError(
"fixture",
operation,
"not supported",
),
),
)
: baseline.execute(operation, input, options),
});
expect(output.normalized_result).toMatchObject({
truncated: false,
residual_unknowns: [expect.stringContaining("0x20")],
references: [
{
target_address: "0x10",
source_address: "0x20",
containing_procedure: {
query_address: "0x20",
status: "unavailable",
provider_id: "fixture",
reason: "not supported",
},
},
{
target_address: "0x11",
source_address: "0x21",
containing_procedure: containing("0x21"),
},
],
});
expect(output.normalized_result).toMatchObject({
residual_unknowns: [expect.stringContaining("not supported")],
});
});
it("keeps distinct targets when a capped reference source has an unavailable owner", async () => {
const calls: string[] = [];
const baseline = provider({ duplicateProcedure: true, calls });
const output = await trace({
execute: (operation, input, options) => {
if (operation === "xrefs") {
calls.push(`xrefs:${String(input.address)}`);
return Promise.resolve(
ok(
createAnalysisExecution(["0x20"], PROVIDER, {
referencesTruncated: input.address === "0x10",
}),
),
);
}
if (operation === "resolve_containing_procedure") {
calls.push(`resolve_containing_procedure:${String(input.address)}`);
return Promise.resolve(
err(
new AnalysisCapabilityUnavailableError(
"fixture",
operation,
"not supported",
),
),
);
}
return baseline.execute(operation, input, options);
},
});
expect(calls.filter((call) => call.startsWith("xrefs:"))).toEqual([
"xrefs:0x10",
"xrefs:0x11",
]);
expect(
calls.filter((call) => call.startsWith("resolve_containing_procedure:")),
).toEqual(["resolve_containing_procedure:0x20"]);
expect(output.normalized_result).toMatchObject({
truncated: true,
residual_unknowns: [
expect.stringContaining("0x10"),
expect.stringContaining("0x20"),
],
references: [
{
target_address: "0x10",
source_address: "0x20",
containing_procedure: {
status: "unavailable",
reason: "not supported",
},
},
{
target_address: "0x11",
source_address: "0x20",
containing_procedure: {
status: "unavailable",
reason: "not supported",
},
},
],
});
});
it("does not swallow an unavailable owner with incomplete cleanup", async () => {
const baseline = provider();
const result = await new EnhancedTools({
execute: (operation, input, options) =>
operation === "resolve_containing_procedure"
? Promise.resolve(
err(
new AnalysisCapabilityUnavailableError(
"fixture",
operation,
"cleanup failed",
{
cleanup: {
reason: "cleanup failed",
resources: ["owned worker"],
},
},
),
),
)
: baseline.execute(operation, input, options),
}).execute("trace_feature", { query: "hello" });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error.cleanupIncomplete).toBe(true);
});
it("preserves cancellation that arrives during an unavailable owner lookup", async () => {
const baseline = provider();
const controller = new AbortController();
const reason = { request: "cancel during owner lookup" };
const result = await new EnhancedTools({
execute: (operation, input, options) => {
if (operation !== "resolve_containing_procedure")
return baseline.execute(operation, input, options);
controller.abort(reason);
return Promise.resolve(
err(
new AnalysisCapabilityUnavailableError(
"fixture",
operation,
"not supported",
),
),
);
},
}).execute("trace_feature", { query: "hello" }, controller.signal);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
}
});
it("retains incomplete cleanup when cancellation arrives during owner lookup", async () => {
const baseline = provider();
const controller = new AbortController();
const result = await new EnhancedTools({
execute: (operation, input, options) => {
if (operation !== "resolve_containing_procedure")
return baseline.execute(operation, input, options);
controller.abort();
return Promise.resolve(
err(
new AnalysisCapabilityUnavailableError(
"fixture",
operation,
"cleanup failed",
{
cleanup: {
reason: "cleanup failed",
resources: ["owned worker"],
},
},
),
),
);
},
}).execute("trace_feature", { query: "hello" }, controller.signal);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error.cleanupIncomplete).toBe(true);
});
describe("trace_feature reference coverage", () => {
it("marks the trace truncated for the target whose references the provider capped", async () => {
const output = await trace(
@@ -0,0 +1,87 @@
import { rm } from "node:fs/promises";
import { afterEach, expect, it } from "vitest";
import { AnalysisCapabilityUnavailableError } from "../../../src/domain/analysisErrorCore.js";
import { IdaSessionClient } from "../../../src/ida/IdaSessionClient.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { createIdaTarget, RecordingIdaMcp } from "../../fixtures/idaMcp.js";
import { closeEnhancedToolResources, connect } from "./enhancedToolsHarness.js";
afterEach(closeEnhancedToolResources);
it.each([2, 3])(
"preserves unknown IDA readiness at owner health check %s",
async (failureCheck) => {
const { root, target } = await createIdaTarget("arm64");
const producer = new RecordingIdaMcp(target, "headless");
let xrefsSeen = false;
let healthAfterXrefs = 0;
producer.overrideCall = (name) => {
if (name === "xrefs_to") xrefsSeen = true;
if (
name === "server_health" &&
xrefsSeen &&
++healthAfterXrefs >= failureCheck
)
return {
status: "ok",
input_path: producer.inputPath,
idb_path: `${producer.inputPath}.i64`,
module: "target.elf",
imagebase: producer.imageBase,
auto_analysis_ready: null,
};
return undefined;
};
const ida = new IdaSessionClient(
{
command: "fixture",
args: [],
env: {},
mode: "headless",
timeoutMs: 1000,
workspaceRoot: root,
},
target,
producer,
);
let refusal: AnalysisCapabilityUnavailableError | undefined;
const mcp = await connect({
execute: async (operation, input, options) => {
const result = await ida.execute(operation, input, options);
if (
operation === "resolve_containing_procedure" &&
!result.ok &&
result.error instanceof AnalysisCapabilityUnavailableError
)
refusal = result.error;
return result;
},
});
try {
const result = await mcp.callTool({
name: "trace_feature",
arguments: { query: "a.b" },
});
const error = parseMcpToolError(result).error;
expect(error).toMatchObject({
code: "capability_unavailable",
details: {
operation: "server_health",
diagnostics: { auto_analysis_ready: null },
},
});
expect(refusal?.remediationAction).toBeDefined();
expect(error.remediation?.action).toBe(refusal?.remediationAction);
expect(healthAfterXrefs).toBe(failureCheck);
if (failureCheck === 3) {
expect(error.details?.partial_observation).toMatchObject({
operation: "resolve_containing_procedure",
provider: { id: "ida" },
});
}
} finally {
expect((await ida.close()).ok).toBe(true);
await rm(root, { recursive: true, force: true });
}
},
);
+3 -1
View File
@@ -71,6 +71,8 @@ export interface FakeKunaBehavior {
/** Bytes of an extra root member in the graph JSON. */
readonly graphPadding?: number;
readonly xrefs?: "ok" | "hang" | "not-json";
/** Additional exact-target incoming call-site addresses. */
readonly extraXrefSources?: readonly number[];
readonly referenceDelayMs?: number;
readonly referencePadding?: number;
/** `invalid-then-hang` writes a contradictory first row, then never finishes. */
@@ -155,7 +157,7 @@ if (args[0] === "--version") {
const identity = (name, value) => ({ name, address: value, address_hex: "0x" + value.toString(16) });
const aliases = target === "0x2000" ? [identity("puts.slot", 12288)] : [];
const row = (from, to, kind) => ({ address: from, address_hex: "0x" + from.toString(16), from_address: from, from_address_hex: "0x" + from.toString(16), to_address: to, to_address_hex: "0x" + to.toString(16), kind, from_function: identity("main", 4096), to_function: null, instruction: "fixture reference" + "x".repeat(behavior.referencePadding ?? 0) });
const rows = [row(4100, parseInt(target, 16), "call")];
const rows = [4100, ...(behavior.extraXrefSources ?? [])].map((source) => row(source, parseInt(target, 16), "call"));
if (aliases.length) rows.push(row(4164, 12288, "call"));
setTimeout(() => process.stdout.write(JSON.stringify({ binary: args[1], direction: "to", count: rows.length, target: { ...identity(null, parseInt(target, 16)), aliases }, xrefs: rows })), behavior.referenceDelayMs ?? 0);
}
@@ -146,6 +146,7 @@ it.each([
async ({ rejectClose, rejectRead, wrongXrefs, wrongStrings }) => {
const root = await createTestTempDirectory("rea-kuna-verifier-cleanup-");
const kuna = await writeFakeKuna(root, {
extraXrefSources: [4104],
stringRows: [
{ address: 12288, text: "REA fixture prompt" },
{