fix(mcp)!: distinguish unavailable batch pseudocode (#8737)

* fix(mcp)!: distinguish unavailable batch pseudocode

* test(mcp): preserve historical batch error details

* test(mcp): retain unavailable items in batch recovery

* test: update Ghidra batch unavailable expectation
This commit is contained in:
morluto authored and GitHub committed 2026-11-05 01:51:38 +08:00
1 parent 30228c9a5f
commit d896ab7bd7
10 files changed
+130 -96

No files matched your search

+10 -3
View File
@@ -682,12 +682,19 @@ refuses the summary before analysis. Further views use the returned
`parent_evidence_id` through `inspect_analysis_view`, or a saved Evidence file or
portable bundle, and never repeat decompilation.
A null provider observation produces `status: "unavailable"` and
`reason: "no_pseudocode"`; item limitations retain any provider explanation.
This does not establish why pseudocode is absent or whether a retry would help.
Operation failures and malformed non-null output remain `status: "error"`.
The `failed` count includes both unavailable and error items. Existing retained
records keep their original item statuses and error details.
A `batch-decompile` view selects one `collection` and repeats the batch counts
and provider identities:
- `items`: rows in retained order with their original `index`, so duplicate
selectors stay distinct. A row keeps the selector in `address`, its `procedure`
identity and `status`, plus `pseudocode_code_units` or its exact `error` and
identity and `status`, plus `pseudocode_code_units`, an unavailable `reason`, or its exact `error` and
`limitations`. `where.indices`, `where.addresses`, `where.procedure_addresses`
and `where.statuses` filter exactly and combine; an item with unknown identity
never matches `procedure_addresses`. Omitted filters select all, and empty
@@ -696,11 +703,11 @@ and provider identities:
- `pseudocode`: one `index`'s text in UTF-16 code-unit pages. `offset` and `limit`
default to 0 and 16,384, and `coverage` measures that body. An offset inside a
surrogate pair is refused, and a limit ending inside one stops before it. An
error item has no text and is refused with a pointer to `items`.
unavailable or error item has no text and is refused with a pointer to `items`.
Page `coverage` counts the selection only: exhausting it establishes nothing
about functions outside the batch. Evidence `limitations` aggregate every item's
provider observations and are not attributed to one successful item; only error
provider observations and are not attributed to one successful item; unavailable and error
items keep their own. A page over the response budget offers a smaller selection
from the same `source`: fewer rows, or one body's `pseudocode` pages when a single
row does not fit. An inline source offers none, because the record cannot be
+20 -51
View File
@@ -25,7 +25,6 @@ import {
} from "../contracts/enhancedInputs.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
@@ -91,48 +90,14 @@ type BatchReadyItem = {
readonly providerProcedure: string;
};
/**
* Guidance for a batch item whose provider returned null pseudocode, by
* whether that observation reported caveats for the item to carry.
*/
export const NULL_PSEUDOCODE_GUIDANCE = {
reported: {
userMessage:
"The provider returned no pseudocode for this function; this item's limitations hold that observation's caveats, including any reason the provider reported.",
remediationAction:
"Read this item's limitations for the provider's reason before retrying; another provider may decompile this entry.",
},
none: {
userMessage:
"The provider returned no pseudocode for this function and reported no caveat or reason for it.",
remediationAction:
"The provider gave no further explanation; another provider may decompile this entry.",
},
} as const;
/**
* The item error for a provider answer without usable text. Null is the
* provider's observation that it has no pseudocode for this function; empty
* or non-text pseudocode is unreadable output.
*/
const missingPseudocodeError = (
providerId: string,
pseudocode: JsonValue,
caveats: keyof typeof NULL_PSEUDOCODE_GUIDANCE,
): AnalysisError =>
pseudocode === null
? new AnalysisCapabilityUnavailableError(
providerId,
"procedure_pseudo_code",
"provider returned null pseudocode",
NULL_PSEUDOCODE_GUIDANCE[caveats],
)
: new AnalysisOutputError(
"procedure_pseudo_code",
typeof pseudocode === "string"
? "provider returned empty pseudocode"
: "provider returned non-text pseudocode",
);
/** Empty and non-text answers are malformed; null is a separate observation. */
const missingPseudocodeError = (pseudocode: JsonValue): AnalysisError =>
new AnalysisOutputError(
"procedure_pseudo_code",
typeof pseudocode === "string"
? "provider returned empty pseudocode"
: "provider returned non-text pseudocode",
);
const isAnalysisCancellation = (
error: AnalysisError,
@@ -539,7 +504,7 @@ export class EnhancedTools {
});
continue;
}
const { result: pseudocode, provider } = observed.value;
const { result: pseudocode } = observed.value;
if (typeof pseudocode === "string" && pseudocode.length > 0) {
items.push({ address, procedure, status: "ok", pseudocode });
continue;
@@ -547,17 +512,21 @@ export class EnhancedTools {
// Only this observation can explain its missing text, so its caveats
// stay with the item; the batch Evidence still aggregates them.
const limitations = [...new Set(observed.value.limitations)];
if (pseudocode === null) {
items.push({
address,
procedure,
status: "unavailable",
reason: "no_pseudocode",
...(limitations.length === 0 ? {} : { limitations }),
});
continue;
}
items.push({
address,
procedure,
status: "error",
error: projectAnalysisError(
missingPseudocodeError(
provider.id,
pseudocode,
limitations.length === 0 ? "none" : "reported",
),
),
error: projectAnalysisError(missingPseudocodeError(pseudocode)),
...(limitations.length === 0 ? {} : { limitations }),
});
}
+1 -1
View File
@@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [
...toolContractMetadata("inspect_analysis_view"),
kind: "application",
description:
'Use javascript-routes collections to query route patterns, source-linked occurrences, source status, unknowns or summary counts from retained JavaScript application or web-bundle Evidence. Use web-bundle collections endpoints or inferences to page endpoint observations and inferences from retained analyze_web_bundle Evidence; summary returns target URL, origin, and counts. Inspect selected views of supported completed Evidence without repeating analysis. Choose a view compatible with the parent operation described below. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Native export tables use kind native-exports, collection exports/issues, exact names or original indices, selected fields and matching-row pagination; parent Evidence plus source_index identifies each row. Source coverage stays separate from page coverage. BEAM views use kind beam, collections atoms/exports/imports/lambdas/chunks, exact names/indices/fields and matching-row pages; import modules intersects function names. source_index identifies original parent-and-collection rows, preserving every arity and raw atom index. beam.counts preserves null missing tables versus decoded empty tables; runtime absence remains unknown. PYC views use kind python-pyc, collection code_objects, names/indices/fields and paging; source_index scopes every same-name alternative to the parent, nested_code keeps code-constant references, and pyc.coverage preserves source coverage. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent also supports summary and browser-scenario steps/events/transactions/text pages, preserving step_indices, event_kinds, transaction_ids and selected artifacts. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the fLine truncated
'Use javascript-routes collections to query route patterns, source-linked occurrences, source status, unknowns or summary counts from retained JavaScript application or web-bundle Evidence. Use web-bundle collections endpoints or inferences to page endpoint observations and inferences from retained analyze_web_bundle Evidence; summary returns target URL, origin, and counts. Inspect selected views of supported completed Evidence without repeating analysis. Choose a view compatible with the parent operation described below. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Native export tables use kind native-exports, collection exports/issues, exact names or original indices, selected fields and matching-row pagination; parent Evidence plus source_index identifies each row. Source coverage stays separate from page coverage. BEAM views use kind beam, collections atoms/exports/imports/lambdas/chunks, exact names/indices/fields and matching-row pages; import modules intersects function names. source_index identifies original parent-and-collection rows, preserving every arity and raw atom index. beam.counts preserves null missing tables versus decoded empty tables; runtime absence remains unknown. PYC views use kind python-pyc, collection code_objects, names/indices/fields and paging; source_index scopes every same-name alternative to the parent, nested_code keeps code-constant references, and pyc.coverage preserves source coverage. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent also supports summary and browser-scenario steps/events/transactions/text pages, preserving step_indices, event_kinds, transaction_ids and selected artifacts. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the fLine truncated
inputSchema: inspectAnalysisViewInputSchema,
outputSchema: evidenceResultOf(analysisViewResultSchema),
examples: [
+1 -1
View File
@@ -47,7 +47,7 @@ export const ENHANCED_TOOL_CONTRACTS = [
),
enhanced(
"batch_decompile",
"Decompile each explicit procedure symbol or address, with one provider read active per batch. Preserves each input selector in address and reports the observed canonical entry and entry label in procedure; identity or label failures remain explicit without discarding usable pseudocode. Returns ordered per-item success or error results and aggregate counts. An item whose provider answer has no pseudocode keeps that observation's limitations, including any reason the provider reported. The complete reply carries every pseudocode body. Select detail summary to retain the batch and receive only counts, then read rows or one body by index through inspect_analysis_view. A per-function limit is not a batch deadline; allow for every item or split calls.",
"Decompile each explicit procedure symbol or address, with one provider read active per batch. Preserves each input selector in address and reports the observed canonical entry and entry label in procedure; identity or label failures remain explicit without discarding usable pseudocode. Returns ordered per-item outcomes and aggregate counts. An unavailable item means the provider returned null pseudocode; its limitations retain any reported reason without asserting provider capability or retryability. Errors report failed operations or malformed output. Failed counts include unavailable items. The complete reply carries every pseudocode body. Select detail summary to retain the batch and receive only counts, then read rows or one body by index through inspect_analysis_view. A per-function limit is not a batch deadline; allow for every item or split calls.",
enhancedInputSchemas.batch_decompile.extend({
detail: analysisDetailSchema,
}),
+17 -2
View File
@@ -65,7 +65,7 @@ const itemsRequestSchema = z.strictObject({
"Exact observed canonical entries. An item with unknown procedure identity never matches. Omitted selects all; [] selects none.",
),
statuses: z
.array(z.enum(["ok", "error"]))
.array(z.enum(["ok", "unavailable", "error"]))
.optional()
.describe("Item outcomes. Omitted selects all; [] selects none."),
})
@@ -115,6 +115,12 @@ const batchDecompileRowSchema = z.discriminatedUnion("status", [
pseudocode_code_units: z.int().positive(),
pseudocode: z.string().min(1).exactOptional(),
}),
z.strictObject({
...itemIdentity,
status: z.literal("unavailable"),
reason: z.literal("no_pseudocode"),
limitations: batchItemLimitationsSchema,
}),
z.strictObject({
...itemIdentity,
status: z.literal("error"),
@@ -271,6 +277,15 @@ const itemRow = (
pseudocode_code_units: item.pseudocode.length,
...(includePseudocode ? { pseudocode: item.pseudocode } : {}),
};
if (item.status === "unavailable")
return {
...identity,
status: "unavailable",
reason: item.reason,
...(item.limitations === undefined
? {}
: { limitations: item.limitations }),
};
return {
...identity,
status: "error",
@@ -375,7 +390,7 @@ export const projectBatchDecompileView = (
);
if (item.status !== "ok")
return pseudocodeRefusal(
`Item ${String(view.index)} has status error and no pseudocode; read its error and limitations through the items collection.`,
`Item ${String(view.index)} has status ${item.status} and no pseudocode; read its reason or error and limitations through the items collection.`,
);
const page = analysisTextPage(item.pseudocode, view.offset, view.limit);
if (!page.ok) return page;
+7
View File
@@ -38,6 +38,13 @@ export const batchDecompileItemSchema = z.discriminatedUnion("status", [
status: z.literal("ok"),
pseudocode: z.string().min(1),
}),
z.object({
address: batchSelectorSchema,
procedure: batchProcedureIdentitySchema,
status: z.literal("unavailable"),
reason: z.literal("no_pseudocode"),
limitations: batchItemLimitationsSchema,
}),
z.object({
address: batchSelectorSchema,
procedure: batchProcedureIdentitySchema,
@@ -303,8 +303,8 @@ it.each(["full", "compact"] as const)(
{ index: 3, address: "emoji" },
{
index: 4,
status: "error",
error: { code: "capability_unavailable" },
status: "unavailable",
reason: "no_pseudocode",
limitations: expect.arrayContaining([NO_BODY_REASON]),
},
{ index: 5, procedure: { status: "unknown" } },
@@ -316,7 +316,8 @@ it.each(["full", "compact"] as const)(
[{ procedure_addresses: ["0x401000"] }, [0, 1, 2]],
// A selector never matches as an observed entry, including an unknown one.
[{ procedure_addresses: ["missing"] }, []],
[{ statuses: ["error"] }, [4, 5]],
[{ statuses: ["unavailable"] }, [4, 5]],
[{ statuses: ["error"] }, []],
[{ indices: [5, 3, 3] }, [3, 5]],
[{ statuses: ["ok"], procedure_addresses: ["0x402000"] }, [3]],
[{ indices: [] }, []],
@@ -355,7 +356,7 @@ it.each(["full", "compact"] as const)(
});
expect(selected.items).toMatchObject([
{ index: 3, pseudocode: EMOJI_BODY },
{ index: 4, status: "error" },
{ index: 4, status: "unavailable" },
]);
expect(JSON.stringify(selected.items)).not.toContain('"pseudocode":null');
@@ -435,7 +436,7 @@ it("pages one body exactly without splitting a Unicode character", async () => {
}),
).error;
expect(await refused({ index: 4 })).toMatchObject(
invalidIssue("Item 4 has status error"),
invalidIssue("Item 4 has status unavailable"),
);
expect(await refused({ index: 6 })).toMatchObject(
invalidIssue("outside this batch of 6"),
@@ -484,6 +485,15 @@ it("recovers oversized batches and pages through smaller selections of the paren
});
const rows = await harness.call(first.name, first.arguments);
expect(rowIndices(viewOf(rows.evidence))).toEqual([0, 1, 2]);
expect(viewOf(rows.evidence).items).toMatchObject([
{ status: "ok" },
{ status: "ok" },
{
status: "unavailable",
reason: "no_pseudocode",
limitations: expect.arrayContaining([NO_BODY_REASON]),
},
]);
expect(Buffer.byteLength(JSON.stringify(rows.result))).toBeLessThan(budget);
// Several bodies that cannot fit continue with fewer rows from the same parent.
@@ -573,6 +583,56 @@ it("reads the same rows from a bundle and an Evidence file without a provider",
}
});
it("reads historical batch Evidence with the prior null-pseudocode error", async () => {
const harness = await connect("full");
const savedItem = {
address: "bodyless",
procedure: { status: "resolved", address: "0x403000", name: "bodyless" },
status: "error",
error: {
code: "capability_unavailable",
category: "unsupported_provider",
message:
"The provider returned no pseudocode for this function; this item's limitations hold that observation's caveats, including any reason the provider reported.",
retryable: false,
remediation: {
action:
"Read this item's limitations for the provider's reason before retrying; another provider may decompile this entry.",
},
details: {
provider_id: "fixture",
operation: "procedure_pseudo_code",
reason: "provider returned null pseudocode",
},
},
limitations: [NO_BODY_REASON],
} as const;
const historical = createEvidence(
undefined,
{ id: "fixture", name: "Fixture", version: "1" },
{
operation: "batch_decompile",
parameters: { addresses: ["bodyless"] },
result: {
items: [savedItem],
total: 1,
succeeded: 0,
failed: 1,
},
confidence: "derived",
},
);
const result = await harness.call("inspect_analysis_view", {
source: { kind: "inline", evidence: historical },
view: {
kind: "batch-decompile",
collection: "items",
where: { statuses: ["error"] },
},
});
expect(viewOf(result.evidence).items).toEqual([{ index: 0, ...savedItem }]);
});
it("keeps the caller's own source when a page cannot fit, without retaining its parent", async () => {
const budget = 64 * 1024;
const body = `int f(void) { /*${"x".repeat(budget * 2)}*/ }`;
@@ -10,7 +10,6 @@ import {
import { parseEvidence } from "../../../src/domain/evidence.js";
import { batchDecompileResultSchema } from "../../../src/domain/native/batchDecompile.js";
import { ENHANCED_TOOL_CONTRACTS } from "../../../src/contracts/enhancedToolContracts.js";
import { NULL_PSEUDOCODE_GUIDANCE } from "../../../src/application/EnhancedTools.js";
import type { RelationshipCoverage } from "../../../src/domain/relationshipCoverage.js";
import { err, ok as resultOk } from "../../../src/domain/result.js";
@@ -664,33 +663,20 @@ describe("enhanced MCP batch answers without pseudocode", () => {
if (contract === undefined)
throw new Error("Missing batch_decompile contract");
const evidence = contract.outputSchema.parse(result.structuredContent);
const noPseudocode = (
guidance: (typeof NULL_PSEUDOCODE_GUIDANCE)[keyof typeof NULL_PSEUDOCODE_GUIDANCE],
) => ({
code: "capability_unavailable",
retryable: false,
message: guidance.userMessage,
remediation: { action: guidance.remediationAction },
details: {
provider_id: "fixture",
operation: "procedure_pseudo_code",
reason: "provider returned null pseudocode",
},
});
expect(evidence.normalized_result).toMatchObject({
items: [
{ address: "0x1", status: "ok", pseudocode: answers["0x1"]?.result },
{
address: "0x2",
status: "error",
error: noPseudocode(NULL_PSEUDOCODE_GUIDANCE.reported),
status: "unavailable",
reason: "no_pseudocode",
limitations: answers["0x2"]?.limitations,
},
// Without caveats, the guidance does not point at an absent field.
// An unclassified absence has no invented reason or retry policy.
{
address: "0x3",
status: "error",
error: noPseudocode(NULL_PSEUDOCODE_GUIDANCE.none),
status: "unavailable",
reason: "no_pseudocode",
},
{
address: "0x4",
+2 -5
View File
@@ -124,11 +124,8 @@ describe("Ghidra MCP capability routing", () => {
expect.objectContaining({ address: "0x401000", status: "ok" }),
expect.objectContaining({
address: bodyless,
status: "error",
error: expect.objectContaining({
code: "capability_unavailable",
details: expect.objectContaining({ provider_id: "ghidra" }),
}),
status: "unavailable",
reason: "no_pseudocode",
limitations: [...new Set(observed.limitations)],
}),
],
@@ -131,15 +131,8 @@ test("keeps each Kuna no-pseudocode observation's limitations on its batch item"
expect(main).toMatchObject({ address: "main", status: "ok" });
expect(main).not.toHaveProperty("limitations");
const noPseudocode = {
status: "error",
error: {
code: "capability_unavailable",
retryable: false,
details: {
provider_id: "kuna",
operation: "procedure_pseudo_code",
},
},
status: "unavailable",
reason: "no_pseudocode",
};
// The item keeps exactly the facts the provider reported for its own
// observation, as the single-function Evidence does.