diff --git a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md index fa969b5222..658222b9aa 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -320,6 +320,17 @@ cross-references. Addresses and recovered pseudocode are analysis observations, not original source. Provider unavailability and unsupported metadata remain unknown rather than false. +For large pseudocode, request `procedure_pseudo_code` with `detail: "summary"` +on the first MCP call. It retains the complete observation and returns the +original selection, text availability and length without the body. Use +`normalized_result.parent_evidence_id` with `inspect_analysis_view` and +`view: {kind: "native", facet: "pseudocode", offset: 0, limit: 20000}`; +follow `coverage.next_offset` for later pages. Offsets and limits count UTF-16 +code units, not lines or tokens. Pages reuse the same observation without +repeating decompilation. Null and empty text remain distinct. Omitted or +`complete` detail preserves full inline delivery. Summary delivery requires a +retaining session and does not reduce the work or memory of decompilation. + For a completed `analyze_function` dossier, use `inspect_analysis_view` with `view: {kind: "native", facet: "value_flow_operations", offset: 0, limit: 32}` and the exact retained Evidence ID to read selected facts without reanalysis. diff --git a/docs/cli.md b/docs/cli.md index aebeca4b8f..ba49b0e3a9 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -494,6 +494,15 @@ The workflow reports IFF chunk names, atom tables, exports, imports, lambdas, and the Code chunk header. Missing optional tables stay null. See [Erlang BEAM files](erlang-beam.md). +Complete `rea decompile --json` Evidence can be saved and inspected through +`inspect-analysis-view` with an inline source and +`view: {kind: "native", facet: "pseudocode", offset: 0, limit: 20000}`. +The projection reads that saved observation without starting a provider; +offsets/limits count UTF-16 code units. A summary view preserves the original +selector and distinguishes null from empty text. The MCP tool additionally +supports `detail: "summary"` on its first call to retain the complete source +without returning the body inline; see [native investigation](native-investigation.md). + Configure [Hopper or Ghidra](installation.md#hopper), the [IDA adapter](ida-provider.md), or [Kuna](kuna-provider.md) before analyzing a native target. Substitute diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 8c710d51e5..8afa1d44e4 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -43,6 +43,17 @@ focused tools such as `list_procedures` and `analyze_function` on the same sessi | What did the declared runtime scenario observe? | [Desktop](#native-desktop-observation) or [call observation](#native-call-observation) | | What can this provider and host establish? | [Provider boundaries](#provider-and-verification-boundaries) | +For large pseudocode, request `procedure_pseudo_code` with `detail: "summary"` +on the first MCP call. It retains the complete observation and returns the +original selection, text availability and length without the body. Use +`normalized_result.parent_evidence_id` with `inspect_analysis_view` and +`view: {kind: "native", facet: "pseudocode", offset: 0, limit: 20000}`; +follow `coverage.next_offset` for later pages. Offsets and limits count UTF-16 +code units, not lines or tokens. Pages reuse the same observation without +repeating decompilation. Null and empty text remain distinct. Omitted or +`complete` detail preserves full inline delivery. Summary delivery requires a +retaining session and does not reduce the work or memory of decompilation. + ## Exact CLI selectors `function`, `instructions` and `decompile` accept `--procedure=`; diff --git a/src/application/analysisView/AnalysisViewService.ts b/src/application/analysisView/AnalysisViewService.ts index aef660fd4b..2798efcd26 100644 --- a/src/application/analysisView/AnalysisViewService.ts +++ b/src/application/analysisView/AnalysisViewService.ts @@ -440,6 +440,15 @@ const createAnalysisViewEvidence = ( ...(result.artifact != null && result.artifact.path.length > 0 ? [{ kind: "artifact-path" as const, path: result.artifact.path }] : []), + // Scalar text has no structured function entry; retain the producer's + // authenticated positions without interpreting the caller's selector. + ...(parent.operation === "procedure_pseudo_code" + ? parent.locations.filter( + (location) => + location.kind !== "artifact-path" || + location.path !== result.artifact?.path, + ) + : []), ...(nativeFunctionView !== undefined && nativeFunctionView.procedure_address !== null && nativeFunctionView.procedure_address.length > 0 diff --git a/src/contracts/analysisViewToolContracts.ts b/src/contracts/analysisViewToolContracts.ts index c7047e9123..18a5edb9d9 100644 --- a/src/contracts/analysisViewToolContracts.ts +++ b/src/contracts/analysisViewToolContracts.ts @@ -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. For retained trace_feature Evidence, kind native-feature-trace pages matches, references or unknowns; matches filter exact address, references filter exact source_address and target_address, source_index identifies each original row, and list_unknowns pages durable question heads. 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 first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized reads that are not entities and have one initial request line (item-shaped paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state (a list also learns the filters, search, sort and paging its recordings verify, and answers new values of them from the state), and every other read tool is a recorded view with a reason. It also reports a write plan per state-changing tool (create, update, delete or document, built from its first successful call) and write_clock; a plan is served only when replaying the capture shows a later read confirming its effect and none contradicting it. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. The environment-coverage view, view {kind: "environment-coverage", template_rules: [...]}, summarizes that state without its entities: reads.state and reads.recorded count read tools answered from the state and replayed (recorded_reasons per view reason), writes.served lists the served write tools, and writes.withheld lists each other write tool with its reason, plan record, contradicting read and a hint about what to record next; it refuses what the environment-state view refuses. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. A completed Evidence JSON file on this host uses {kind: "evidence-file", path: ""}: REA reads and authenticates it, then projects it like retained Evidence, so a large capture is bounded by the file reader rather than by inline-input accounting. The file may also be an Evidence bundle written by CLI --evidence_output or export_evidence_bundle; select evidence_id when it contains more than one record. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions. Occurrences accept exact logical_paths and occurrence_ids; nodes accept artifact_ids. Batch filters intersect (omitted means all available, [] means none); fields selects columns while retaining identity links. source_complete describes retained graph membership; source_coverage describes producer coverage, including unknown encrypted PCK directories. Page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings. Exact names and declaring_types filters intersect before paging where supported; fields selects columns (distinct from collection: "fields"). Identity/source_index, module MVID, source coverage and unresolved filter counts remain; omit body to avoid IL anchors. Omitted fields preserves full rows. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Protobuf views use kind protobuf, collection bodies/frames/fields, with record_ordinals, body_pointers and fields; field pages also select frame_indices, field_ids, field_numbers or schema_names. Byte identity, offset basis and body status remain present. Text and schema_value are opt-in page columns. APK signing views use kind apk-signature and collection signers/digests/lineage, with ids, signer_ids, schemes, fields and paging. Identity links remain when fields is empty. Declared identities and digest matches do not establish signature verification or authenticity. Model views use kind model-artifact and collection metadata (keys), tensors (names/types) or types (derived totals), with indices, fields and paging. Header SHA-256 scopes declarations; full-file identity remains unknown unless full hashing was selected. Arrays retain typed eight-element previews with explicit omissions. IL2CPP metadata views use kind il2cpp-metadata and collection sections/images/types/methods/fields/properties/events/parameters/string_literals/code_gen_modules/method_addresses/return_types/field_types/parameter_types/generic_method_instances/generic_method_issues. Select indices, fields and pages; sections/images/types/methods/fields/properties/events/parameters/return_types/field_types/parameter_types/generic_method_instances accept exact names (native type names match retained rendered values, preserving same-name rows; null names never match), types also accept image_indices/namespaces/declaring_types, and methods/members accept declaring_types. Parameters and parameter_types accept method_indices; code_gen_modules accept image_indices; method_addresses and generic_method_instances accept addresses/states (generic_method_instances also method_indices), preserving every folded alternative; generic_method_issues accept rows. Row index is scoped to the metadata digest; native pages add binary_source. Uncollected native tables have null counts and remain unavailable. Desktop directory views use kind desktop-application and collection files/omissions/candidates/components/next_probes, with fields and pagination; files and omissions accept exact paths or their stable IDs, and candidates accept families. Source coverage and stable row IDs remain available. Native load-image Evidence uses kind native-load-image with collection source_files/mappings/relocations/entry_points/entry_context/checks/sections, original indices, collection-specific fields and offset/limit paging. Null collections remain unrecorded or inapplicable; pages preserve unsupported verification. Native inventory Evidence from list_names, list/search_procedures or list/search_strings uses view: {kind: "native-inventory", offset: 0, limit: 64, fields: ["value"]}. Rows keep source_index and address even with selected fields. Summary counts describe retained rows; provider discovery coverage remains unknown. Native function Evidence accepts kind summary for identity, availability and facet counts, or kind native for selected facts: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native_api and value-flow observations. For parameter type paths when value flow is recorded, use facet: "value_flow_parameters"; its decompiler-observed path may be passed to inspect_native_data_type, but exact database lookup can be unavailable and does not by itself establish type identity. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Retained batch_decompile Evidence accepts kind summary for counts and sizes, or view: {kind: "batch-decompile", collection: "items", where: {statuses: ["unavailable", "error"]}} for body-free rows with exact indices, addresses, procedure_addresses or statuses (include_pseudocode adds bodies), or collection "pseudocode" with an index to page one body. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', + '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. For retained trace_feature Evidence, kind native-feature-trace pages matches, references or unknowns; matches filter exact address, references filter exact source_address and target_address, source_index identifies each original row, and list_unknowns pages durable question heads. Inspect selected views of supported completed Evidence without repeating analysis. For procedure_pseudo_code Evidence, summary reports text availability and the original selection; kind native with facet pseudocode pages the original UTF-16 text. Other native facets require analyze_function. 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 first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized reads that are not entities and have one initial request line (item-shaped paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state (a list also learns the filters, search, sort and paging its recordings verify, and answers new values of them from the state), and every other read tool is a recorded view with a reason. It also reports a write plan per state-changing tool (create, update, delete or document, built from its first successful call) and write_clock; a plan is served only when replaying the capture shows a later read confirming its effect and none contradicting it. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. The environment-coverage view, view {kind: "environment-coverage", template_rules: [...]}, summarizes that state without its entities: reads.state and reads.recorded count read tools answered from the state and replayed (recorded_reasons per view reason), writes.served lists the served write tools, and writes.withheld lists each other write tool with its reason, plan record, contradicting read and a hint about what to record next; it refuses what the environment-state view refuses. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. A completed Evidence JSON file on this host uses {kind: "evidence-file", path: ""}: REA reads and authenticates it, then projects it like retained Evidence, so a large capture is bounded by the file reader rather than by inline-input accounting. The file may also be an Evidence bundle written by CLI --evidence_output or export_evidence_bundle; select evidence_id when it contains more than one record. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions. Occurrences accept exact logical_paths and occurrence_ids; nodes accept artifact_ids. Batch filters intersect (omitted means all available, [] means none); fields selects columns while retaining identity links. source_complete describes retained graph membership; source_coverage describes producer coverage, including unknown encrypted PCK directories. Page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings. Exact names and declaring_types filters intersect before paging where supported; fields selects columns (distinct from collection: "fields"). Identity/source_index, module MVID, source coverage and unresolved filter counts remain; omit body to avoid IL anchors. Omitted fields preserves full rows. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Protobuf views use kind protobuf, collection bodies/frames/fields, with record_ordinals, body_pointers and fields; field pages also select frame_indices, field_ids, field_numbers or schema_names. Byte identity, offset basis and body status remain present. Text and schema_value are opt-in page columns. APK signing views use kind apk-signature and collection signers/digests/lineage, with ids, signer_ids, schemes, fields and paging. Identity links remain when fields is empty. Declared identities and digest matches do not establish signature verification or authenticity. Model views use kind model-artifact and collection metadata (keys), tensors (names/types) or types (derived totals), with indices, fields and paging. Header SHA-256 scopes declarations; full-file identity remains unknown unless full hashing was selected. Arrays retain typed eight-element previews with explicit omissions. IL2CPP metadata views use kind il2cpp-metadata and collection sections/images/types/methods/fields/properties/events/parameters/string_literals/code_gen_modules/method_addresses/return_types/field_types/parameter_types/generic_method_instances/generic_method_issues. Select indices, fields and pages; sections/images/types/methods/fields/properties/events/parameters/return_types/field_types/parameter_types/generic_method_instances accept exact names (native type names match retained rendered values, preserving same-name rows; null names never match), types also accept image_indices/namespaces/declaring_types, and methods/members accept declaring_types. Parameters and parameter_types accept method_indices; code_gen_modules accept image_indices; method_addresses and generic_method_instances accept addresses/states (generic_method_instances also method_indices), preserving every folded alternative; generic_method_issues accept rows. Row index is scoped to the metadata digest; native pages add binary_source. Uncollected native tables have null counts and remain unavailable. Desktop directory views use kind desktop-application and collection files/omissions/candidates/components/next_probes, with fields and pagination; files and omissions accept exact paths or their stable IDs, and candidates accept families. Source coverage and stable row IDs remain available. Native load-image Evidence uses kind native-load-image with collection source_files/mappings/relocations/entry_points/entry_context/checks/sections, original indices, collection-specific fields and offset/limit paging. Null collections remain unrecorded or inapplicable; pages preserve unsupported verification. Native inventory Evidence from list_names, list/search_procedures or list/search_strings uses view: {kind: "native-inventory", offset: 0, limit: 64, fields: ["value"]}. Rows keep source_index and address even with selected fields. Summary counts describe retained rows; provider discovery coverage remains unknown. Native function Evidence accepts kind summary for identity, availability and facet counts, or kind native for selected facts: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native_api and value-flow observations. For parameter type paths when value flow is recorded, use facet: "value_flow_parameters"; its decompiler-observed path may be passed to inspect_native_data_type, but exact database lookup can be unavailable and does not by itself establish type identity. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Retained batch_decompile Evidence accepts kind summary for counts and sizes, or view: {kind: "batch-decompile", collection: "items", where: {statuses: ["unavailable", "error"]}} for body-free rows with exact indices, addresses, procedure_addresses or statuses (include_pseudocode adds bodies), or collection "pseudocode" with an index to page one body. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', inputSchema: inspectAnalysisViewInputSchema, outputSchema: evidenceResultOf(analysisViewResultSchema), examples: [ diff --git a/src/contracts/officialToolContracts.ts b/src/contracts/officialToolContracts.ts index d46da784d4..700f0bc8bd 100644 --- a/src/contracts/officialToolContracts.ts +++ b/src/contracts/officialToolContracts.ts @@ -207,8 +207,9 @@ export const OFFICIAL_TOOL_CONTRACTS = [ ), official( "procedure_pseudo_code", - "Decompile one analyzed procedure by symbol name or provider-normalized address. Ghidra also accepts file-offset:0x... selectors and falls back to a unique source-file mapping when a 0x-prefixed number resolves to no procedure; existing analysis addresses take precedence and ambiguous mappings fail explicitly. Returns provider-specific pseudocode, never original source or cross-provider text equivalence, and may return null; request procedure_assembly when instruction precision matters. Ghidra no-return assumptions may cut the analyzed body; Evidence limitations report observed terminal calls and available callee identities.", - z.object({ procedure, document }), + "Decompile one analyzed procedure by symbol name or provider-normalized address. Ghidra also accepts file-offset:0x... selectors and falls back to a unique source-file mapping when a 0x-prefixed number resolves to no procedure; existing analysis addresses take precedence and ambiguous mappings fail explicitly. Returns provider-specific pseudocode, never original source or cross-provider text equivalence, and may return null; request procedure_assembly when instruction precision matters. Ghidra no-return assumptions may cut the analyzed body; Evidence limitations report observed terminal calls and available callee identities. Select detail summary to retain complete Evidence and receive text availability and counts; inspect_analysis_view summary or native/pseudocode pages read the original observation without decompiling again. Complete remains the default.", + z.object({ procedure, document, detail: analysisDetailSchema }), + { evidenceParameters: "omit-detail" }, ), official( "resolve_containing_procedure", diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index 8d40f15230..119b67e225 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -4,6 +4,7 @@ import { } from "../domain/native/nativeObservation.js"; import { overviewInventoryCoverageSchema } from "../domain/native/inventoryCoverage.js"; import { + procedurePseudocodeSummaryViewResultSchema, nativeObservationSummaryViewResultSchemas, byteEntropySummaryViewResultSchema, nativeFunctionMatchingSummaryViewResultSchema, @@ -236,7 +237,10 @@ export const officialOutputSchemas = { references: z.array(referenceEdgeSchema), }), ), - procedure_pseudo_code: evidenceResultOf(nullableText), + procedure_pseudo_code: analysisDetailOutputSchemaOf( + nullableText, + procedurePseudocodeSummaryViewResultSchema, + ), resolve_containing_procedure: analysisDetailOutputSchemaOf( nativeObservationSourceSchemas.resolve_containing_procedure, nativeObservationSummaryViewResultSchemas.resolve_containing_procedure, diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index 3f44eaf9f1..94026bfcae 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -62,6 +62,7 @@ import { createApiSurfaceAccumulator, projectWebNetworkApiSurfaceView, } from "./webNetworkApiSurfaceView.js"; +import { projectProcedurePseudocodeView } from "./procedurePseudocodeView.js"; import { projectNativeFunctionView } from "./nativeFunctionView.js"; import { projectEvmInterfaceView } from "./evmInterfaceView.js"; import { projectArtifactInventoryView } from "./artifactInventoryView.js"; @@ -100,6 +101,7 @@ import { type InspectAnalysisViewInput, } from "./analysisViewRequest.js"; import { + procedurePseudocodeSummaryViewResultSchema, nativeObservationSummaryViewResultSchemas, byteEntropySummaryViewResultSchema, nativeExportsSummaryViewResultSchema, @@ -168,6 +170,7 @@ export { type AnalysisViewParent, }; export { + procedurePseudocodeSummaryViewResultSchema, nativeObservationSummaryViewResultSchemas, nativeFeatureTraceSummaryViewResultSchema, byteEntropySummaryViewResultSchema, @@ -309,6 +312,7 @@ const retain = ( ): readonly [string, RetainedViewProjector] => [operation, project]; const retainedViewProjectors = new Map([ + retain("procedure_pseudo_code", projectProcedurePseudocodeView), retain("inspect_native_instruction", projectNativeInstructionReferencesView), retain("trace_feature", projectNativeFeatureTraceView), ...NATIVE_OBSERVATION_OPERATIONS.map((operation) => @@ -448,6 +452,7 @@ export const projectAnalysisView = ( parent.operation === "match_native_functions" ? NATIVE_FUNCTION_MATCHING_PREDICATE : parent.operation === "analyze_function" || + parent.operation === "procedure_pseudo_code" || parent.operation === "inspect_native_instruction" || parent.operation === "trace_feature" || parent.operation === "batch_decompile" || diff --git a/src/domain/analysisView/analysisViewResult.ts b/src/domain/analysisView/analysisViewResult.ts index 4710f90cdc..edea69c003 100644 --- a/src/domain/analysisView/analysisViewResult.ts +++ b/src/domain/analysisView/analysisViewResult.ts @@ -54,6 +54,7 @@ import { nativeExportsViewItemsSchema, nativeExportsSummarySchema, } from "./nativeExportsView.js"; +import { procedurePseudocodeSummarySchema } from "./procedurePseudocodeSchemas.js"; import { byteEntropySummarySchema } from "./byteEntropyView.js"; import { javaScriptRoutesViewRequestSchema, @@ -342,6 +343,7 @@ const viewResultBase = { "analyze_javascript_application", "analyze_web_bundle", "analyze_function", + "procedure_pseudo_code", "inspect_artifact", "inventory_artifact", "inspect_wasm_artifact", @@ -379,6 +381,13 @@ const summaryViewResultBase = { ...viewResultBase, } as const; +/** Original selector and text availability without the complete decompiled text. */ +export const procedurePseudocodeSummaryViewResultSchema = z.strictObject({ + ...summaryViewResultBase, + parent_operation: z.literal("procedure_pseudo_code"), + summary: procedurePseudocodeSummarySchema, +}); + const nativeObservationSummaryFor = < Operation extends NativeObservationOperation, >( @@ -1191,6 +1200,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ view: environmentCoverageViewRequestSchema, }), z.discriminatedUnion("parent_operation", [ + procedurePseudocodeSummaryViewResultSchema, nativeObservationSummaryViewResultSchema, nativeValueTraceSummaryViewResultSchema, erlangBeamSummaryViewResultSchema, diff --git a/src/domain/analysisView/procedurePseudocodeSchemas.ts b/src/domain/analysisView/procedurePseudocodeSchemas.ts new file mode 100644 index 0000000000..2a9ddf141e --- /dev/null +++ b/src/domain/analysisView/procedurePseudocodeSchemas.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +export const procedurePseudocodeSelectionSchema = z.strictObject({ + procedure: z.string(), + document: z.string().optional(), +}); + +/** A returned null remains distinct from a returned, empty pseudocode string. */ +export const procedurePseudocodeSummarySchema = z.strictObject({ + selection: procedurePseudocodeSelectionSchema, + pseudocode: z.discriminatedUnion("available", [ + z.strictObject({ + available: z.literal(true), + code_units: z.number().int().nonnegative(), + unit: z.literal("utf16-code-units"), + }), + z.strictObject({ + available: z.literal(false), + reason: z.literal("Provider returned null"), + }), + ]), +}); diff --git a/src/domain/analysisView/procedurePseudocodeView.test.ts b/src/domain/analysisView/procedurePseudocodeView.test.ts new file mode 100644 index 0000000000..e5a5fe1100 --- /dev/null +++ b/src/domain/analysisView/procedurePseudocodeView.test.ts @@ -0,0 +1,113 @@ +import { expect, it } from "vitest"; + +import { ANALYSIS_EVIDENCE_PREDICATE } from "../evidence.js"; + +import { + projectAnalysisView, + type AnalysisViewParent, +} from "./analysisView.js"; + +const parent = (normalizedResult: unknown): AnalysisViewParent => ({ + evidenceId: `ev_${"a".repeat(64)}`, + operation: "procedure_pseudo_code", + predicateType: ANALYSIS_EVIDENCE_PREDICATE, + normalizedResult, + parameters: { procedure: "file-offset:0x1000", document: "" }, + artifact: { path: "", sha256: "b".repeat(64) }, + limitations: ["Recorded provider limitation"], +}); + +it("preserves the selected document and file-offset without inventing a canonical function address", () => { + const source = parent("A๐Ÿ˜€\r\nๅฐพ\n"); + const summary = projectAnalysisView(source, { kind: "summary" }); + if (!summary.ok) throw summary.error; + expect(summary.value).toMatchObject({ + summary: { + selection: source.parameters, + pseudocode: { available: true, code_units: 7 }, + }, + artifact: source.artifact, + limitations: source.limitations, + }); + const first = projectAnalysisView(source, { + kind: "native", + facet: "pseudocode", + offset: 0, + limit: 2, + }); + if (!first.ok) throw first.error; + expect(first.value).toMatchObject({ + procedure_address: null, + item: { text: "A" }, + coverage: { next_offset: 1 }, + }); + const second = projectAnalysisView(source, { + kind: "native", + facet: "pseudocode", + offset: 1, + limit: 2, + }); + if (!second.ok) throw second.error; + expect(second.value).toMatchObject({ + item: { text: "๐Ÿ˜€" }, + coverage: { next_offset: 3 }, + }); + expect( + projectAnalysisView(source, { + kind: "native", + facet: "pseudocode", + offset: 2, + limit: 2, + }), + ).toMatchObject({ ok: false, error: { issues: [{ path: ["view"] }] } }); + const beyond = projectAnalysisView(source, { + kind: "native", + facet: "pseudocode", + offset: 100, + limit: 2, + }); + if (!beyond.ok) throw beyond.error; + expect(beyond.value).toMatchObject({ + item: { text: "" }, + coverage: { total: 7, exhausted: true, next_offset: null }, + }); + expect(source.normalizedResult).toBe("A๐Ÿ˜€\r\nๅฐพ\n"); +}); + +it("rejects malformed parent facts at their owning source paths", () => { + const invalidResult = projectAnalysisView(parent({ text: "not a scalar" }), { + kind: "summary", + }); + expect(invalidResult).toMatchObject({ + ok: false, + error: { issues: [{ path: ["source", "normalized_result"] }] }, + }); + const invalidSelection = projectAnalysisView( + { ...parent("text"), parameters: { procedure: 1 } }, + { kind: "summary" }, + ); + expect(invalidSelection).toMatchObject({ + ok: false, + error: { issues: [{ path: ["source", "parameters", "procedure"] }] }, + }); + expect( + projectAnalysisView( + { ...parent("text"), artifact: null }, + { kind: "summary" }, + ), + ).toMatchObject({ + ok: false, + error: { issues: [{ path: ["source", "subject"] }] }, + }); +}); + +it("refuses unrelated dossier facets for a scalar pseudocode observation", () => { + expect( + projectAnalysisView(parent("text"), { + kind: "native", + facet: "assembly", + offset: 0, + limit: 1, + }), + ).toMatchObject({ ok: false, error: { issues: [{ path: ["view"] }] } }); +}); diff --git a/src/domain/analysisView/procedurePseudocodeView.ts b/src/domain/analysisView/procedurePseudocodeView.ts new file mode 100644 index 0000000000..e1f0f6b84d --- /dev/null +++ b/src/domain/analysisView/procedurePseudocodeView.ts @@ -0,0 +1,127 @@ +import { z } from "zod"; + +import { analysisInputErrorFromIssues } from "../inputIssueProjection.js"; +import { err, ok } from "../result.js"; +import { + parseAnalysisViewResult, + type AnalysisViewParent, +} from "./analysisViewParent.js"; +import { + ownedView, + type AdmittedView, + type NativeFacetAnalysisView, + type SummaryAnalysisView, +} from "./analysisViewShape.js"; +import { analysisViewInputError } from "./analysisViewErrors.js"; +import { completeWithinViewCoverage } from "./analysisViewCoverage.js"; +import { analysisTextPage } from "./analysisViewText.js"; +import { procedurePseudocodeSelectionSchema } from "./procedurePseudocodeSchemas.js"; + +const unavailable = { + available: false as const, + reason: "Provider returned null" as const, +}; + +/** Project the original scalar observation, never another decompilation or a synthetic dossier. */ +export const projectProcedurePseudocodeView = ( + parent: AnalysisViewParent, + request: AdmittedView, +) => { + const view = ownedView( + request, + "summary", + "native", + ); + if ( + view === undefined || + (view.kind === "native" && view.facet !== "pseudocode") + ) + return err( + analysisViewInputError([ + { + path: ["view"], + reason: "invalid_value", + message: + "procedure_pseudo_code Evidence supports summary or native pseudocode views.", + }, + ]), + ); + if (parent.artifact == null) + return err( + analysisViewInputError([ + { + path: ["source", "subject"], + reason: "invalid_value", + message: "Pseudocode Evidence requires an artifact-bound subject.", + }, + ]), + ); + const parsed = parseAnalysisViewResult(z.string().nullable(), parent); + if (!parsed.ok) return parsed; + const selection = procedurePseudocodeSelectionSchema.safeParse( + parent.parameters, + ); + if (!selection.success) + return err( + analysisInputErrorFromIssues( + "inspect_analysis_view", + selection.error.issues.map((issue) => ({ + ...issue, + path: ["source", "parameters", ...issue.path], + })), + { source: { parameters: parent.parameters } }, + { cause: selection.error }, + ), + ); + const text = parsed.value; + const common = { + parent_evidence_id: parent.evidenceId, + parent_operation: "procedure_pseudo_code" as const, + parent_digest: parent.evidenceId.slice(3), + artifact: parent.artifact, + limitations: [...parent.limitations], + unknowns: + text === null + ? [ + "Provider returned null; this does not establish absence of a function.", + ] + : [], + }; + if (view.kind === "summary") + return ok({ + kind: "summary" as const, + view, + ...common, + summary: { + selection: selection.data, + pseudocode: + text === null + ? unavailable + : { + available: true as const, + code_units: text.length, + unit: "utf16-code-units" as const, + }, + }, + coverage: completeWithinViewCoverage(1, 1), + }); + if (text === null) + return ok({ + kind: "native" as const, + view, + ...common, + procedure_address: null, + item: unavailable, + coverage: completeWithinViewCoverage(1, 1), + }); + const page = analysisTextPage(text, view.offset, view.limit); + return page.ok + ? ok({ + kind: "native" as const, + view, + ...common, + procedure_address: null, + ...page.value, + }) + : page; +}; diff --git a/src/server/toolResult.ts b/src/server/toolResult.ts index d74555b451..421f66392e 100644 --- a/src/server/toolResult.ts +++ b/src/server/toolResult.ts @@ -78,7 +78,8 @@ const retainedEvidenceContinuation = ( /** The first page an owner offers when its complete retained result does not fit. */ const ownerFirstPage = (evidence: Evidence): JsonValue | undefined => - evidence.operation === "analyze_function" + evidence.operation === "analyze_function" || + evidence.operation === "procedure_pseudo_code" ? { kind: "native", facet: "pseudocode", offset: 0 } : evidence.operation === "inspect_native_instruction" ? { diff --git a/tests/boundary/cli/analysisViewCli.test.ts b/tests/boundary/cli/analysisViewCli.test.ts index d4b0aac213..df73edb935 100644 --- a/tests/boundary/cli/analysisViewCli.test.ts +++ b/tests/boundary/cli/analysisViewCli.test.ts @@ -46,6 +46,50 @@ import { } from "../../../src/domain/evidenceBundle.js"; import { inspectEvidenceBundle } from "../../../src/application/investigation/InspectEvidenceBundle.js"; +cliTest( + "reads a saved scalar pseudocode observation without starting an analysis engine", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-pseudocode-view-cli-"); + const parent = createEvidence( + { path: "/fixtures/program", format: "elf", sha256: "a".repeat(64) }, + { id: "fixture", name: "Fixture", version: "1" }, + { + operation: "procedure_pseudo_code", + parameters: { procedure: "file-offset:0x10" }, + result: "A๐Ÿ˜€\r\nๅฐพ\n", + locations: [ + { kind: "artifact-path", path: "/fixtures/program" }, + { kind: "file-offset", offset: 0x10 }, + { kind: "address", address: "0x401010" }, + { kind: "address", address: "0x401000" }, + ], + }, + ); + const request = join(root, "request.json"); + await writeFile( + request, + JSON.stringify({ + source: { kind: "inline", evidence: parent }, + view: { kind: "native", facet: "pseudocode", offset: 1, limit: 2 }, + }), + ); + const response = await cli.run({ + arguments: ["inspect-analysis-view", request, "--json"], + }); + expect(response.exitCode).toBe(0); + const result = parseEvidence(response.json); + expect(result.subject).toEqual(parent.subject); + expect(result.evidence_links).toEqual([parent.evidence_id]); + expect(result.locations).toEqual(parent.locations); + expect(result.normalized_result).toMatchObject({ + parent_operation: "procedure_pseudo_code", + procedure_address: null, + item: { text: "๐Ÿ˜€" }, + coverage: { next_offset: 3 }, + }); + }, +); + cliTest( "reads selected batch rows and one body from a saved bundle without a provider", async ({ cli }) => { diff --git a/tests/boundary/mcp/pseudocodeDeliveryMcp.test.ts b/tests/boundary/mcp/pseudocodeDeliveryMcp.test.ts new file mode 100644 index 0000000000..ef8d43e77b --- /dev/null +++ b/tests/boundary/mcp/pseudocodeDeliveryMcp.test.ts @@ -0,0 +1,326 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { expect, it, onTestFinished } from "vitest"; + +import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js"; +import { OFFICIAL_TOOL_CONTRACTS } from "../../../src/contracts/officialToolContracts.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { jsonObjectSchema } from "../../../src/domain/jsonValue.js"; +import { ok } from "../../../src/domain/result.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { advertisedMcpToolSchemas } from "../../fixtures/advertisedMcpTool.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { connectGhidraMcp } from "./ghidraMcpHarness.js"; + +const connectPseudocode = async (text: string | null) => { + const harness = await connectGhidraMcp( + "pseudocode-delivery", + async (name, input) => { + if (name !== "procedure_pseudo_code") + throw new Error(`Unexpected provider operation ${name}`); + expect(input).toEqual({ procedure: "fixture_main", document: null }); + return ok({ + value: text, + limitations: ["Fixture decompiler limitation"], + }); + }, + ); + onTestFinished(harness.close); + return harness; +}; + +it("retains a complete pseudocode observation once and pages its exact text through advertised MCP contracts", async () => { + const contract = OFFICIAL_TOOL_CONTRACTS.find( + ({ name }) => name === "procedure_pseudo_code", + ); + if (contract === undefined) + throw new Error("Missing procedure_pseudo_code contract"); + const text = + "int search(void) { return 42; }\n".repeat(3200) + "// ๆœ็ดข๐Ÿ˜€\r\n"; + const { mcp, session, calls } = await connectPseudocode(text); + const schema = await advertisedMcpToolSchemas(mcp, "procedure_pseudo_code"); + const input = { procedure: "fixture_main", detail: "summary" }; + expect(schema.ajv.validate(schema.inputSchema, input)).toBe(true); + const reply = await mcp.callTool({ + name: "procedure_pseudo_code", + arguments: input, + }); + expect(reply.isError, JSON.stringify(reply)).not.toBe(true); + expect( + schema.ajv.validate(schema.outputSchema, reply.structuredContent), + JSON.stringify(schema.ajv.errors), + ).toBe(true); + const summary = parseEvidence(reply.structuredContent); + const result = jsonObjectSchema.parse(summary.normalized_result); + expect(result).toMatchObject({ + kind: "summary", + parent_operation: "procedure_pseudo_code", + summary: { + selection: { procedure: "fixture_main" }, + pseudocode: { + available: true, + code_units: text.length, + unit: "utf16-code-units", + }, + }, + }); + expect(Buffer.byteLength(JSON.stringify(reply))).toBeLessThan(16000); + expect(summary.raw_result).toBeNull(); + expect(reply._meta).toMatchObject({ + provenance: { untrusted_content: true }, + }); + if (typeof result.parent_evidence_id !== "string") + throw new Error("Missing parent reference"); + const parent = session.evidenceById(result.parent_evidence_id); + if (parent === undefined) + throw new Error("Complete observation was not retained"); + expect(parent.normalized_result).toBe(text); + // The normalized result is the provider observation; no raw copy repeats it. + expect(parent.raw_result).toBeNull(); + expect(parent.parameters).toEqual({ procedure: "fixture_main" }); + expect(parent.limitations).toContain("Fixture decompiler limitation"); + const executions = [...calls]; + expect(executions).toEqual(["procedure_pseudo_code"]); + let offset = 0; + let restored = ""; + for (;;) { + const page = await mcp.callTool({ + name: "inspect_analysis_view", + arguments: { + source: { kind: "retained-evidence", evidence_id: parent.evidence_id }, + view: { kind: "native", facet: "pseudocode", offset, limit: 20000 }, + }, + }); + expect(page.isError).not.toBe(true); + const evidence = parseEvidence(page.structuredContent); + const view = jsonObjectSchema.parse(evidence.normalized_result); + expect(evidence.subject).toEqual(parent.subject); + expect(evidence.evidence_links).toContain(parent.evidence_id); + expect(view).toMatchObject({ + parent_operation: "procedure_pseudo_code", + parent_evidence_id: parent.evidence_id, + procedure_address: null, + }); + const item = jsonObjectSchema.parse(view.item); + if (typeof item.text !== "string") throw new Error("Missing page text"); + restored += item.text; + const coverage = jsonObjectSchema.parse(view.coverage); + if (coverage.next_offset === null) break; + if ( + typeof coverage.next_offset !== "number" || + coverage.next_offset <= offset + ) + throw new Error("Page did not advance"); + offset = coverage.next_offset; + } + expect(restored).toBe(text); + expect(calls).toEqual(executions); + for (const detail of [undefined, "complete"] as const) { + const complete = await mcp.callTool({ + name: "procedure_pseudo_code", + arguments: { + procedure: "fixture_main", + ...(detail === undefined ? {} : { detail }), + }, + }); + expect(complete.isError).not.toBe(true); + expect( + contract.outputSchema.safeParse(complete.structuredContent).success, + ).toBe(true); + expect(parseEvidence(complete.structuredContent)).toEqual(parent); + } +}); + +it.each([ + { procedure: "file-offset:0x10", value: "int resolved(void) { return 42; }" }, + { procedure: "0x10", value: null }, +])( + "keeps resolved parent locations in summary and portable/retained pages for $procedure", + async ({ procedure, value }) => { + const harness = await connectGhidraMcp( + "pseudocode-resolved-identity", + async (operation, input) => { + expect(operation).toBe("procedure_pseudo_code"); + expect(input).toEqual({ procedure, document: null }); + return ok({ + value, + limitations: [], + selection: { + file_offset: 0x10, + mapped_address: "0x401010", + procedure_address: "0x401000", + }, + }); + }, + ); + onTestFinished(harness.close); + const response = await harness.mcp.callTool({ + name: "procedure_pseudo_code", + arguments: { procedure, detail: "summary" }, + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + const summary = parseEvidence(response.structuredContent); + const result = jsonObjectSchema.parse(summary.normalized_result); + if (typeof result.parent_evidence_id !== "string") + throw new Error("Missing parent reference"); + const parent = harness.session.evidenceById(result.parent_evidence_id); + if (parent === undefined || parent.subject === null) + throw new Error("Missing retained artifact observation"); + const locations = [ + { kind: "artifact-path", path: parent.subject.local_path }, + ...parent.locations, + ]; + expect(parent.parameters).toEqual({ procedure }); + expect(parent.locations).toEqual( + expect.arrayContaining([ + { kind: "file-offset", offset: 0x10 }, + { kind: "address", address: "0x401010" }, + { kind: "address", address: "0x401000" }, + ]), + ); + expect(summary.locations).toEqual(locations); + for (const source of [ + { kind: "retained-evidence", evidence_id: parent.evidence_id }, + { kind: "inline", evidence: parent }, + ]) { + const page = await harness.mcp.callTool({ + name: "inspect_analysis_view", + arguments: { + source, + view: { kind: "native", facet: "pseudocode", offset: 0, limit: 12 }, + }, + }); + expect(page.isError, JSON.stringify(page)).not.toBe(true); + const evidence = parseEvidence(page.structuredContent); + expect(evidence.locations).toEqual(locations); + expect(evidence.evidence_links).toEqual([parent.evidence_id]); + expect(evidence.normalized_result).toMatchObject({ + item: + value === null + ? { available: false, reason: "Provider returned null" } + : { text: value.slice(0, 12), unit: "utf16-code-units" }, + }); + } + expect(harness.calls).toEqual(["procedure_pseudo_code"]); + }, +); + +it.each(["", null])( + "preserves the distinct scalar observation %j in a summary and page", + async (text) => { + const { mcp, session } = await connectPseudocode(text); + const response = await mcp.callTool({ + name: "procedure_pseudo_code", + arguments: { procedure: "fixture_main", detail: "summary" }, + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + const result = jsonObjectSchema.parse( + parseEvidence(response.structuredContent).normalized_result, + ); + const summary = jsonObjectSchema.parse(result.summary); + expect(summary.pseudocode).toEqual( + text === null + ? { available: false, reason: "Provider returned null" } + : { available: true, code_units: 0, unit: "utf16-code-units" }, + ); + if (typeof result.parent_evidence_id !== "string") + throw new Error("Missing parent"); + expect( + session.evidenceById(result.parent_evidence_id)?.normalized_result, + ).toBe(text); + const page = await mcp.callTool({ + name: "inspect_analysis_view", + arguments: { + source: { + kind: "retained-evidence", + evidence_id: result.parent_evidence_id, + }, + view: { kind: "native", facet: "pseudocode", offset: 0, limit: 10 }, + }, + }); + expect(page.isError).not.toBe(true); + expect( + jsonObjectSchema.parse( + parseEvidence(page.structuredContent).normalized_result, + ).item, + ).toEqual( + text === null + ? { available: false, reason: "Provider returned null" } + : { text: "", unit: "utf16-code-units" }, + ); + }, +); + +it("refuses summary delivery before calling a fixed provider without retention", async () => { + let calls = 0; + const server = createServer( + { + kind: "fixed", + analysis: { + execute: async () => { + calls++; + return ok( + createAnalysisExecution("text", { + id: "fixture", + name: "Fixture", + version: "1", + }), + ); + }, + }, + }, + { environment: {} }, + ); + const client = new Client({ name: "pseudocode-no-retention", version: "1" }); + onTestFinished(async () => { + await client.close(); + await server.close(); + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + const result = await client.callTool({ + name: "procedure_pseudo_code", + arguments: { procedure: "main", detail: "summary" }, + }); + expect(parseMcpToolError(result).error.message).toContain( + "does not retain Evidence", + ); + expect(calls).toBe(0); +}); + +it("keeps transport-overflow recovery usable for the original pseudocode observation", async () => { + // Exceeds the default 10 MiB stdio response budget on its own. + const text = "x".repeat(11 * 1024 * 1024); + const { mcp, session, calls } = await connectPseudocode(text); + const response = await mcp.callTool({ + name: "procedure_pseudo_code", + arguments: { procedure: "fixture_main" }, + }); + const failure = parseMcpToolError(response).error; + expect(failure.code).toBe("resource_constraint"); + const limits = jsonObjectSchema.parse( + jsonObjectSchema.parse(failure.details).reported_limits, + ); + const continuation = jsonObjectSchema.parse(limits.continuation); + expect(continuation.name).toBe("inspect_analysis_view"); + const page = await mcp.callTool({ + name: "inspect_analysis_view", + arguments: jsonObjectSchema.parse(continuation.arguments), + }); + expect(page.isError).not.toBe(true); + const result = jsonObjectSchema.parse( + parseEvidence(page.structuredContent).normalized_result, + ); + expect(result).toMatchObject({ + item: { text: "x".repeat(64) }, + coverage: { total: text.length, next_offset: 64 }, + }); + if (typeof result.parent_evidence_id !== "string") + throw new Error("Missing parent"); + expect( + session.evidenceById(result.parent_evidence_id)?.normalized_result, + ).toBe(text); + expect(calls).toEqual(["procedure_pseudo_code"]); + await mcp.ping(); +});