Compare commits

...
Author SHA1 Message Date
morluto 015a02d57b feat(ghidra)!: identify containing code and data (#8777)
* feat(ghidra)!: identify containing listed instructions

* fix(ghidra): validate instruction containment status

* fix(ghidra): validate containment coordinates

* feat(ghidra): identify containing defined data

* test(ghidra): correct snapshot and containment controls

* docs(native): distinguish object and dynamic type sizes

* docs(cli): retain incoming reference guidance

* fix(mcp): clarify data object address guidance

* test(ghidra): preserve defined data in reference fixtures
2026-11-07 21:26:52 +00:00
morluto 688dca3e90 fix(mcp): report native value flow support in session status (#8786)
* fix(native)!: preserve value-flow absence semantics

* fix(mcp): report native value flow support in session status

* test(mcp): share schema validation and assert status facts

* chore: preserve unrelated main imports
2026-11-07 21:26:13 +00:00
morlutoandCursor Agent 197a296664 fix(session): guide unsupported provider recovery (#8567)
* fix(session): guide unsupported provider recovery

* fix(session): clarify provider switch state loss

* fix(session): distinguish evidence from provider edits

* fix(session): preserve workflow-specific recovery

* test(session): compare recovery with its owner

* fix(session): guide available entry-comment handoff

* fix(session): condition provider handoff on readiness

* fix(cli): clarify target-free provider discovery

* test(mcp): remove duplicated recovery wording checks

* test(mcp): keep provider-selection binding case within the line limit

The merged unknown-provider checks stay in the same scenario and pass
the workspace path explicitly.

* test(mcp): use one temporary directory owner

* test: avoid freezing retry remediation wording

---------

Co-authored-by: Cursor Agent <[email protected]>
2026-11-07 21:14:10 +00:00
morluto aa2c1c61ce feat(mcp): report provider release verification in session (#8485)
* feat(mcp): report provider release verification in session

* test(kuna): verify release guidance at its owner

* fix(kuna): verify release against reported version

* test(mcp): keep temporary fixture cleanup with its owner
2026-11-07 21:13:49 +00:00
morlutoandCursor Agent 2d25a09c0f fix(native)!: recognize identical function evidence (#8442)
* fix(native)!: recognize identical function evidence

* test(native): verify same-evidence dimension citations

* fix(compare): reject impossible same-evidence changes

A comparison of one Evidence record cannot report a changed dimension,
unequal subject hashes, or unequal digests on an unchanged dimension.

* test(compare): admit the partial same-evidence fixture explicitly

---------

Co-authored-by: Cursor Agent <[email protected]>
2026-11-07 21:06:39 +00:00
morlutoandCursor Agent 9109e6f6a7 fix(ghidra): guide ambiguous selector recovery (#8768)
* fix(ghidra): guide ambiguous procedure selection

* test(ghidra): distinguish malformed selectors

* fix(ghidra): distinguish ambiguous file mappings

* fix(ghidra): name mapping inspection workflows

* docs(mcp): expose strict Ghidra address selection

* docs(cli): explain Ghidra qualified address selection

* test(ghidra): cover published ambiguous selector codes

MCP and projection tests use procedure_ambiguous and file_offset_ambiguous,
and match the recovery main already publishes for those codes.

* fix(ghidra): name the space-qualified address that avoids offset fallback

file_offset_ambiguous now tells the caller to use a form such as
ram:0x1000. A bare 0x selector repeats the file-offset lookup. The MCP
test requires that route and inspect_native_load_image.

---------

Co-authored-by: Cursor Agent <[email protected]>
2026-11-07 20:57:13 +00:00
morluto cc58263ee0 fix(trace): disclose address-only reference scope (#8575)
* fix(trace): disclose address-only reference scope

* test(trace): assert qualified reference facts

* test(contracts): distinguish address and process starts

* fix(trace): clarify procedure interior reference scope
2026-11-07 20:29:36 +00:00
morluto 56fb5219ce fix(cli): retain linked sources in function bundles (#8873)
* fix: preserve source closure during snapshot replay

* fix: revalidate restored snapshot sources lazily

* fix: bound and share source-closure checks

* fix(cli): retain function evidence sources in bundles

* fix(cli): preserve partially captured source closure

* test(ghidra): verify saved project evidence bundles

* fix(cli): retain sources across incomplete finalization

* fix: supervise function bundle verifier processes

* style(test): format CLI help default annotations (#9280)

(cherry picked from commit ac30fbd658)
2026-11-07 20:12:25 +00:00
morlutoandCursor Agent f3c21665d8 docs(mcp): clarify Ghidra edit lifetime on switches (#8484)
* docs(mcp): clarify Ghidra edit lifetime on switches

* test(guidance): expect focused provider switch guidance

---------

Co-authored-by: Cursor Agent <[email protected]>
2026-11-07 19:50:51 +00:00
morlutoandCursor Agent ce45f92219 docs(mcp): clarify retained evidence view support (#8480)
* docs(mcp): clarify retained evidence view support

* docs(mcp): describe pageable pseudocode alternative

* docs(mcp): page retained procedure pseudocode in place

procedure_pseudo_code Evidence already supports summary and native
pseudocode views of the original observation. The guide no longer says
that paging requires a new analyze_function dossier.

---------

Co-authored-by: Cursor Agent <[email protected]>
2026-11-07 19:42:26 +00:00
70 changed files with 2525 additions and 180 deletions

No files matched your search

@@ -685,6 +685,11 @@ numbering gaps leave coverage unknown; a DfuSe suffix CRC mismatch follows
Fresh-import sessions admit annotations; existing-project reuse is read-only.
Use `annotate_native_function` for one function name and/or entry comments, with at least one explicit change. Review its annotation readback and refreshed dossier inline. Changes are atomic; empty comments clear them, omitted fields preserve them. Later MCP calls observe edits. Normal close saves them when owned Program checkpoint mode is enabled; ephemeral mode discards them. CLI `annotate-native-function` follows the same setting when closing after its updated result. Select the [checkpoint workflow](#continue-editable-ghidra-analysis-across-processes) before edits that must survive a later process. Original executable bytes are unchanged; edits invalidate immutable snapshots. Windows P0 does not admit database mutations. For naming only, `set_address_name` / `set_addresses_names` also work with Ghidra: they name function entries or data addresses (primary labels) and return compact per-address success booleans instead of a dossier. Ghidra rejects batch addresses that request the same name or a name another selected address holds; `raw_result.failures` gives each failure's reason.
A provider switch or different target can retire the active Ghidra database and discard
its edits, even if the new provider fails on a later call. Same-target,
same-profile reopen keeps the active database. Retained annotation Evidence
records what happened; it does not restore the edits.
For `compare_functions`, obtain `analyze_function` Evidence before and after the
edit while the MCP session is open; annotation Evidence is not a comparison
input. Export the needed Evidence before close if comparison will happen later.
+23 -2
View File
@@ -1414,8 +1414,29 @@ public final class ReaGhidraBridge extends HeadlessScript {
flow.addProperty("conditional", false);
flow.addProperty("computed", false);
if (instruction == null) {
result.addProperty("status", !currentProgram.getMemory().contains(address) ? "outside-memory" : currentProgram.getListing().getInstructionContaining(address) != null
? "not-instruction-boundary" : currentProgram.getListing().getDefinedDataContaining(address) != null ? "data" : "not-in-listing");
Instruction containing = currentProgram.getListing().getInstructionContaining(address);
boolean inMemory = currentProgram.getMemory().contains(address);
Data containingData = inMemory && containing == null
? currentProgram.getListing().getDefinedDataContaining(address) : null;
result.addProperty("status", !inMemory ? "outside-memory" : containing != null
? "not-instruction-boundary" : containingData != null ? "data" : "not-in-listing");
if (inMemory && containing == null && containingData != null) {
JsonObject parentData = new JsonObject();
parentData.addProperty("address", canonicalAddress(containingData.getAddress()));
parentData.addProperty("length", containingData.getLength());
parentData.addProperty("type_id", containingData.getDataType().getPathName());
parentData.addProperty("provenance", "ghidra-listing");
result.add("containing_data", parentData);
}
if (containing != null && currentProgram.getMemory().contains(address)) {
JsonObject containingInstruction = new JsonObject();
containingInstruction.addProperty("address", canonicalAddress(containing.getAddress()));
containingInstruction.addProperty("length", containing.getLength());
containingInstruction.addProperty("mnemonic", containing.getMnemonicString());
containingInstruction.addProperty("raw_disassembly", containing.toString());
containingInstruction.addProperty("provenance", "ghidra-listing");
result.add("containing_instruction", containingInstruction);
}
for (String key : List.of("bytes", "length", "mnemonic", "raw_disassembly")) result.add(key, JsonNull.INSTANCE);
} else {
result.addProperty("status", "decoded");
@@ -1279,6 +1279,11 @@ and slice commitments establish the same canonical entry. The result reports
remain separate dimensions. Equal numeric addresses without applicable coordinate
commitments do not establish same-entry correspondence. Inspect the retained
source Evidence and coverage when correspondence remains ambiguous.
When both inputs authenticate to the same Evidence ID and an applicable entry
comparison is unavailable, the match reports `method: "same-evidence"`.
This identifies one selected observation, not a correspondence between distinct
records or proof of complete analysis. Unavailable facets and unestablished
call coverage remain unknown.
To aggregate already-retained comparison results, pass their exact IDs to
`find_changed_behavior`, for example:
+17
View File
@@ -140,6 +140,11 @@ also accept source-file byte offsets. Existing symbols and analysis addresses
take precedence; a `0x`-prefixed number that resolves to no procedure falls back
to its observed source mapping. Use `--file-offset=0x...` on the CLI or
`procedure: "file-offset:0x..."` through MCP to select file coordinates explicitly.
To select an analysis address without the bare-hex fallback, qualify it with its
Ghidra address space, for example `ram:0x...`. The `inspect-native-load-image`
CLI command or MCP `inspect_native_load_image` reports Ghidra's observed default
address space; use the space appropriate to the address, and retain any
load-image verification limitations.
Multiple mapping candidates fail as ambiguous rather than selecting a bank or
address space. A successful conversion retains the file offset, mapped address
and procedure entry in raw Evidence and `locations`.
@@ -274,6 +279,18 @@ same function can be retried after raising the limit. See the
[settings design](../design/limits-and-recovery.md) for snapshots, precedence,
and provider integration.
When `inspect_native_instruction` selects a byte inside a listed instruction,
`containing_instruction` reports its separate start, length and Ghidra Listing
text, retaining any non-default address space. The requested address remains
unchanged, and a containing function may be absent. For defined data, the same
tool reports its containing object's start, length and type path; use the start
with `inspect_native_data_type` to inspect its layout. This does not select a
field or union member. These are listing observations, not proof of execution or
an alternate decode. Earlier Evidence can
omit the field. The analysis-profile revision prevents older snapshots from
replaying results without it; open the target without the old snapshot and save
a fresh snapshot when needed.
Function extents and instruction completeness refer to Ghidra's analyzed body.
An inferred no-return call can terminate that body before subsequent bytes.
Pseudocode, assembly, procedure info, instruction windows, incoming and outgoing
+31 -2
View File
@@ -390,7 +390,12 @@ parent operation, use a discovered ID with a compatible `inspect_analysis_view`;
neither discovery nor retained selection starts a provider. For records without
a supported view, use `detail: "records"` to retrieve selected complete records,
or export the bundle to a caller-selected path. Record and bundle delivery are
subject to the MCP response budget.
subject to the MCP response budget. Summary rows are not complete records.
Retained `procedure_pseudo_code` Evidence supports `summary` and
`native`/`pseudocode` views of the original observation, including
`view: {kind: "native", facet: "pseudocode", offset: 0, limit: 4096}`;
pages do not start a provider or create another decompilation. An
`analyze_function` dossier is a different observation.
`retention: "complete-record"` describes stored Evidence, while
`native_dossier.value_flow` independently reports truncation, unavailable or
unknown metadata. Missing omission counts remain null. Summary output carries
@@ -1009,6 +1014,18 @@ finish without reading relationships, even when neither traversal mode is
available. Discovery does not block that direct invocation or replace its typed
partial/error outcomes.
`trace_native_values` includes `value_flow_support` for the provider routed to
`analyze_function`. `supported` declares that the provider may supply value flow;
it does not guarantee a model for the selected function. `unavailable` includes
the provider's reason, and `unknown` means no declaration. The provider ID is
preserved, or null if no analysis provider is routed. This declaration is separate
from operation readiness and the availability of call-target resolution. When
call-target resolution is missing, a direct trace may still return a local graph
with call-resolution unknowns; even `max_depth: 0` attempts to resolve call sites.
When support is unknown but the declared operation prerequisites are ready,
availability can still be true; it does not promise that value flow is present.
The trace's returned observations and unknowns remain authoritative for that call.
`trace_native_ui_action` reports authored-UI and native-seed modes separately.
Apple dispatch metadata requires a Mach-O target; another provider may supply
the symbol-only alternative on other formats. If neither mode is available,
@@ -1062,7 +1079,19 @@ analysis client. `open_binary.provider_id` accepts a concrete provider ID or
concrete version, selection source, and complete analysis profile through
`analysis_provider_binding`. Ambiguity and unknown, unavailable, or unsupported
choices return typed selection details. A selected provider is never replaced
automatically after a runtime failure.
automatically after a runtime failure. When the bound route does not declare an
operation, its error points to `binary_session` with `detail: "complete"` for
candidate capabilities and an explicit `open_binary` provider choice. A
candidate's availability does not guarantee support for the selected target;
switching may discard provider session edits, and retained Evidence does not restore them.
When the selected provider reports `release_verification` in its binding,
the status compares its reported release version at binding with the provider's
verified baseline. It does not recheck the executable, authenticate its bytes,
or guarantee results.
An unverified release may still be usable; the optional remediation preserves
the caller's choice. Omission means verification was not reported. Target-free
discovery does not probe a version solely to populate this fact.
When an admitted `open_binary` attempt fails, `error.details.selection_at_failure`
records the canonical binary path and SHA-256, resource root (including its
@@ -0,0 +1,187 @@
#!/usr/bin/env node
// Exercise the public one-shot CLI on a caller-owned saved project. The report
// is intentionally synthetic; it is not a claim about Ghidra's analysis history.
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import {
mkdtemp,
mkdir,
readFile,
readdir,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { createVerifierCommands } from "./lib/verify-commands.mjs";
import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
const installation = process.env.GHIDRA_INSTALL_DIR;
if (!installation || !process.env.JAVA_HOME)
throw new Error(
"Set GHIDRA_INSTALL_DIR and JAVA_HOME to caller-installed Ghidra and JDK 21.",
);
const commands = await createVerifierCommands();
let root;
let commandsClosed = false;
const targetHash = await withVerifierCleanup(async () => {
root = await mkdtemp(join(tmpdir(), "rea-function-bundle-"));
const target = join(root, "fixture");
const projectDir = join(root, "project");
const scriptDir = join(root, "scripts");
const source = join(root, "fixture.c");
const project = join(projectDir, "SourceControl.gpr");
const repo = resolve(process.cwd());
const hash = (data) => createHash("sha256").update(data).digest("hex");
const tree = async (directory, prefix = "") => {
const result = [];
for (const entry of (
await readdir(directory, { withFileTypes: true })
).sort((a, b) => a.name.localeCompare(b.name))) {
const path = join(directory, entry.name);
const relative = join(prefix, entry.name);
if (entry.isDirectory()) result.push(...(await tree(path, relative)));
else if (entry.isFile())
result.push([relative, hash(await readFile(path))]);
else throw new Error(`Unexpected project entry: ${relative}`);
}
return result;
};
const run = async (command, args, options = {}) =>
commands.exec(command, args, {
timeout: 120_000,
maxBuffer: 16 * 1024 * 1024,
...options,
});
await mkdir(projectDir);
await mkdir(scriptDir);
await writeFile(
source,
"__attribute__((noinline)) int leaf(int x) { return x + 1; }\nint main(void) { return leaf(2); }\n",
);
await writeFile(
join(scriptDir, "SetHistory.java"),
`import ghidra.app.script.GhidraScript;
import ghidra.program.model.listing.Program;
public class SetHistory extends GhidraScript {
public void run() throws Exception {
currentProgram.getOptions(Program.PROGRAM_INFO).setString("REA disabled analyzers", "{\\"format\\":\\"rea-ghidra-disabled-analyzers-v1\\",\\"analyzers\\":[{\\"name\\":\\"Synthetic historical control\\",\\"status\\":\\"disabled\\"}]}");
}
}
`,
);
await run(process.env.REA_CC ?? "cc", ["-O0", "-g", source, "-o", target]);
await run(join(installation, "support", "analyzeHeadless"), [
projectDir,
"SourceControl",
"-import",
target,
"-scriptPath",
scriptDir,
"-postScript",
"SetHistory.java",
]);
const before = await tree(projectDir);
const targetHash = hash(await readFile(target));
const env = {
...process.env,
REA_GHIDRA_PROJECT_PATH: project,
REA_GHIDRA_PROGRAM_NAME: "fixture",
};
const cli = join(repo, "scripts", "rea.mjs");
for (const detail of ["complete", "summary"]) {
const output = join(root, `${detail}.json`);
const { stdout } = await run(
process.execPath,
[
cli,
"function",
target,
"main",
"--provider",
"ghidra",
"--detail",
detail,
"--evidence_output",
output,
"--format",
"json",
"--full-output",
],
{ env },
);
assert.equal(JSON.parse(stdout).ok, true);
const bundle = JSON.parse(await readFile(output, "utf8"));
const parent = bundle.records.find(
(record) => record.operation === "analyze_function",
);
assert.ok(parent, "function observation missing from bundle");
assert.ok(
parent.evidence_links.length,
"fixture did not produce a linked historical source",
);
const records = new Map(
bundle.records.map((record) => [record.evidence_id, record]),
);
for (const record of bundle.records)
for (const link of record.evidence_links)
assert.ok(records.has(link), `bundle omits linked Evidence ${link}`);
assert.ok(
parent.evidence_links.some(
(id) => records.get(id)?.operation === "health",
),
"historical health source missing",
);
}
const existing = join(root, "existing.json");
await writeFile(existing, "caller-owned\n");
let failure;
try {
await run(
process.execPath,
[
cli,
"function",
target,
"main",
"--provider",
"ghidra",
"--detail",
"summary",
"--evidence_output",
existing,
"--format",
"json",
"--full-output",
],
{ env },
);
assert.fail("existing output unexpectedly overwritten");
} catch (error) {
if (error.code !== 1) throw error;
failure = JSON.parse(error.snapshot.stdout.text);
}
assert.equal(await readFile(existing, "utf8"), "caller-owned\n");
assert.equal(failure.ok, false);
assert.equal(failure.error.details.source_closure.complete, true);
assert.ok(
failure.error.details.source_closure.records.some(
(record) => record.operation === "health",
),
);
assert.equal(hash(await readFile(target)), targetHash);
assert.deepEqual(await tree(projectDir), before);
return targetHash;
}, [
async () => {
await commands.close();
commandsClosed = true;
},
async () => {
if (commandsClosed && root !== undefined)
await rm(root, { recursive: true, force: true });
},
]);
process.stdout.write(
`PASS Ghidra function bundles: complete, summary, no-overwrite; target and project unchanged (${targetHash})\n`,
);
+33
View File
@@ -26,6 +26,11 @@ import { parseArgs, promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { inspectKunaInstallation } from "../dist/kuna/KunaInstallation.js";
import {
KUNA_RELEASE_VERIFICATION_EXPLANATION,
KUNA_UNVERIFIED_VERSION_REMEDIATION,
KUNA_VERIFIED_VERSION,
} from "../dist/kuna/KunaRelease.js";
import { ArtifactResourceScope } from "../dist/artifacts/ArtifactResourceScope.js";
import { AnalysisCleanupLease } from "../dist/artifacts/AnalysisCleanupLease.js";
import {
@@ -281,6 +286,34 @@ const openServer = async (provider, kunaSettings = {}) => {
path: provider.path,
provider_id: provider.id,
});
if (provider.id === "kuna") {
const status = (await call("binary_session")).value;
const binding = status.analysis_provider_binding;
const verification = binding.release_verification;
assert.equal(verification.basis, "reported_version");
assert.equal(verification.verified_version, KUNA_VERIFIED_VERSION);
assert.equal(
verification.reported_version,
binding.analysis_profile.parameters.kuna_version,
);
assert.equal(
verification.status,
binding.analysis_profile.parameters.kuna_version ===
KUNA_VERIFIED_VERSION
? "verified"
: "unverified",
);
assert.equal(
verification.explanation,
KUNA_RELEASE_VERIFICATION_EXPLANATION,
);
if (verification.status === "unverified")
assert.equal(
verification.remediation,
KUNA_UNVERIFIED_VERSION_REMEDIATION,
);
else assert.equal(verification.remediation, null);
}
} catch (cause) {
// A rejected connection/target still owns its transport and workspace.
await close(cause);
@@ -276,6 +276,7 @@ const unavailableFacet = (
remediation:
error.retryable ||
error.remediationAction !== undefined ||
error.fallbackRemediationAction !== undefined ||
error.continuingCallerGuidance?.remediationAction !== undefined
? projected.remediation.action
: "Review provider availability in binary_session with detail complete and select another supported provider if appropriate. After switching, if a name is known and search_procedures is available, use it to find candidates and verify their identity and coordinates.",
+8
View File
@@ -31,6 +31,7 @@ import type { ProgressReporter } from "./ProgressReporter.js";
import type { ProviderRejectionCode } from "../domain/providerSelection.js";
import type { ProcessLineageObservation } from "../process/ProcessOwnership.js";
import type { ProviderOperationHealth } from "../domain/providerOperationHealth.js";
import type { ProviderReleaseVerification } from "../domain/providerReleaseVerification.js";
import {
relationshipCoverageSchema,
type RelationshipCoverage,
@@ -291,6 +292,9 @@ export interface ProviderIdentity {
/** Typed analysis profile resolved before provider startup; null means unresolved. */
export interface AnalysisProfileResolution {
readonly profile: AnalysisProfileCommitment | null;
/** Readiness fact for the resolved selection; omitted means not reported. */
readonly releaseVerification?: ProviderReleaseVerification;
/** Closing a client can publish state that invalidates a previewed profile. */
readonly refreshAfterRetirement?: true;
/** Keep provider-private facts bound to this selection without Evidence serialization. */
@@ -360,6 +364,10 @@ export type CapabilityDescriptor = CapabilityAvailability & {
readonly operation: Exclude<AnalysisOperation, "health">;
readonly effects: CapabilityEffects;
readonly limitations: readonly string[];
/** Whether analyze_function can supply value flow for some procedures. */
readonly nativeValueFlow?:
| { readonly status: "supported" }
| { readonly status: "unavailable"; readonly reason: string };
};
/** Factory and capability declaration for an analysis implementation. */
+52 -9
View File
@@ -24,7 +24,6 @@ import {
toolAvailabilitySummaryInventory,
TOOL_ANNOTATION_NAMES,
TOOL_EFFECT_NAMES,
type ProviderCapability,
type ToolAvailabilityInventory,
type ToolAvailabilitySummaryInventory,
type ToolAvailability,
@@ -71,7 +70,7 @@ const statusSchema = z.object({
type ToolAvailabilityReason = "available" | ToolUnavailabilityReason;
type ProviderDescriptor = ProviderCapability;
type ProviderDescriptor = ReturnType<typeof expandProviderCapabilities>[number];
type UnavailableDescriptor = Extract<ProviderDescriptor, { available: false }>;
/** Optional integrations whose verified hosts exclude some platforms. */
@@ -310,6 +309,22 @@ export const buildCapabilityInventory = (
const facts = {
name: contract.name,
surface: contract.kind,
...(contract.name === "trace_native_values"
? {
value_flow_support: (() => {
const source = descriptors.get("analyze_function");
return source?.native_value_flow === undefined
? {
status: "unknown" as const,
provider_id: source?.provider.id ?? null,
}
: {
...source.native_value_flow,
provider_id: source.provider.id,
};
})(),
}
: {}),
...(availability.defaultModeAvailable === undefined
? {}
: { default_mode_available: availability.defaultModeAvailable }),
@@ -377,6 +392,9 @@ export function compactToolAvailability(
available: entry.available,
reason: entry.reason,
remediation: entry.remediation,
...(entry.value_flow_support === undefined
? {}
: { value_flow_support: entry.value_flow_support }),
...(entry.default_mode_available === undefined
? {}
: { default_mode_available: entry.default_mode_available }),
@@ -933,13 +951,38 @@ const composedAvailability = (
reason: "provider_missing",
remediation: "No provider composition is declared for this operation.",
};
return "modes" in requirements
? availabilityForModes(
requirements.modes,
requirements.defaultMode,
descriptors,
)
: composedAvailabilityFor(requirements, descriptors).availability;
const result =
"modes" in requirements
? availabilityForModes(
requirements.modes,
requirements.defaultMode,
descriptors,
)
: composedAvailabilityFor(requirements, descriptors).availability;
if (name !== "trace_native_values") return result;
const source = descriptors.get("analyze_function");
if (source?.available !== true) return result;
if (source.native_value_flow?.status === "unavailable")
return {
reason:
result.reason === "provider_unavailable" ||
result.reason === "unsupported_host"
? result.reason
: "provider_missing",
remediation: `${source.native_value_flow.reason} Select a provider that can report native value-flow observations; cross-function tracing also needs call-target resolution.${(result.reason === "provider_unavailable" || result.reason === "unsupported_host") && result.remediation !== null ? ` ${result.remediation}` : ""}`,
};
if (
result.reason === "provider_missing" &&
descriptors.get("resolve_native_call_targets") === undefined
)
return {
...result,
remediation:
source.native_value_flow?.status === "supported"
? "A direct trace may still return local value-flow observations with call-resolution unknowns. Configure a provider with resolve_native_call_targets for call resolution and cross-function tracing."
: "Value-flow support is undeclared. A direct trace may return partial results or an unknown. Configure a provider with resolve_native_call_targets for call resolution and cross-function tracing.",
};
return result;
};
/** Evaluate source groups once so discovery and missing-operation facts agree. */
+38 -30
View File
@@ -16,7 +16,10 @@ import {
import type { ResolvedSessionOpen } from "./binary/BinarySessionOpen.js";
import { silentLogger } from "../logger.js";
import type { Logger } from "pino";
import { withEvidenceCallerInputPath } from "../domain/evidence.js";
import {
tryParseEvidence,
withEvidenceCallerInputPath,
} from "../domain/evidence.js";
import {
createImmutableEvidenceBundle,
type EvidenceBundle,
@@ -26,8 +29,8 @@ import {
type AnalysisResultWithSources,
} from "./investigation/recordEvidenceSources.js";
import {
resolveInvestigationEvidenceClosure,
createInvestigationEvidenceClosureChecker,
collectInvestigationEvidenceClosure,
} from "./investigation/InvestigationEvidence.js";
import type { Evidence } from "../domain/evidence.js";
import type { NativeToolName } from "../contracts/native/nativeToolContracts.js";
@@ -388,12 +391,12 @@ const runAnalysis = async (
readonly evidenceParameters?: Readonly<Record<string, JsonValue>>;
readonly onEvidenceBundle?: (bundle: EvidenceBundle) => void;
readonly createSession?: () => BinarySession | Promise<BinarySession>;
readonly includeEvidenceSources?: boolean;
readonly providerId?: AnalysisProviderSelector;
readonly formatHint?: ExecutableFormatHint;
readonly architecture?: BinaryArchitecture;
readonly entryPoint?: string;
readonly espFlash?: EspFlashSelection;
readonly includeEvidenceSources?: boolean;
},
): Promise<AnalysisResultWithSources<Evidence, JsonValue>> => {
const { logger, signal, snapshotPath } = options;
@@ -416,23 +419,19 @@ const runAnalysis = async (
evidence: Evidence,
supplied: readonly Evidence[] = [],
): Result<null, AnalysisError> => {
if (options.includeEvidenceSources !== true) return ok(null);
const known = new Map(
if (!options.includeEvidenceSources) return ok(null);
const records = new Map(
supplied.map((record) => [record.evidence_id, record]),
);
const closure = resolveInvestigationEvidenceClosure(
const collected = collectInvestigationEvidenceClosure(
{
evidenceForAnalysis: (id) => {
const record = known.get(id) ?? session.evidenceForAnalysis(id);
if (record !== undefined) known.set(id, record);
return record;
},
evidenceForAnalysis: (id) =>
records.get(id) ?? session.evidenceForAnalysis(id),
},
evidence.evidence_links,
);
if (!closure.ok) return closure;
sourceEvidence = closure.value.map((id) => known.get(id) as Evidence);
return ok(null);
sourceEvidence = collected.records;
return collected.result;
};
const execute = async (): Promise<Result<Evidence, AnalysisError>> => {
let isDirectory = false;
@@ -518,12 +517,12 @@ const runAnalysis = async (
});
if (checkClosure(cached.evidence_links)) {
const evidence = withEvidenceCallerInputPath(cached, path);
const sources = captureSources(
const captured = captureSources(
evidence,
snapshot.evidence_bundle.records,
);
if (!sources.ok)
return err(sources.error.retainPartialObservation(evidence));
if (!captured.ok)
return err(captured.error.retainPartialObservation(evidence));
options.onEvidenceBundle?.(snapshot.evidence_bundle);
return ok(evidence);
}
@@ -554,20 +553,30 @@ const runAnalysis = async (
evidenceProfile,
saveSnapshot: snapshotPath !== undefined,
});
if (!executed.ok) return executed;
if (!executed.ok) {
if (options.includeEvidenceSources) {
const partial = tryParseEvidence(executed.error.partialObservation);
if (partial !== undefined) {
const captured = captureSources(partial);
if (!captured.ok)
executed.error.retainSecondaryFailure(captured.error);
}
}
return executed;
}
const { cachePolicy } = executed.value;
const evidence = withEvidenceCallerInputPath(executed.value.evidence, path);
// Provider execution may have already recorded the same semantic Evidence
// without this invocation's request path. Import replaces that metadata
// while retaining the authenticated result and snapshot binding.
const captured = captureSources(evidence);
if (!captured.ok)
return err(captured.error.retainPartialObservation(evidence));
const recorded = session.importEvidenceBundle(
createImmutableEvidenceBundle([evidence]),
);
if (!recorded.ok)
return err(recorded.error.retainPartialObservation(evidence));
const sources = captureSources(evidence);
if (!sources.ok)
return err(sources.error.retainPartialObservation(evidence));
if (isWorkflowEvidenceTool(tool)) {
const unknowns = recordWorkflowUnknowns({
name: tool,
@@ -649,16 +658,15 @@ const runAnalysis = async (
outcome.cleanupError.retainDiagnostics({
analysis_snapshot: { ...publishedSnapshot },
});
return err(
directAnalysisCleanupFailure(result, outcome.cleanupError, path),
);
return {
...err(directAnalysisCleanupFailure(result, outcome.cleanupError, path)),
...(sourceEvidence === undefined ? {} : { sourceEvidence }),
};
}
return result.ok
? {
...ok(result.value),
...(sourceEvidence === undefined ? {} : { sourceEvidence }),
}
: err(cliError(result.error, path));
return {
...(result.ok ? ok(result.value) : err(cliError(result.error, path))),
...(sourceEvidence === undefined ? {} : { sourceEvidence }),
};
};
const executeAnalysisTool = async (input: {
+1 -1
View File
@@ -21,7 +21,7 @@ export const workflowAnalysisProfile = (
: operation === "batch_decompile"
? { workflow_revision: 4 }
: operation === "trace_feature"
? { workflow_revision: 8 }
? { workflow_revision: 9 }
: operation === "get_call_graph"
? { workflow_revision: 3 }
: operation === "trace_call_path" ||
+7
View File
@@ -204,6 +204,10 @@ export const executeComposedWorkflow = async (
);
};
/** Scope of the exact-address reference lookups composed by trace_feature. */
export const TRACE_FEATURE_REFERENCE_LIMITATION =
"Reference queries cover only each matched start address, not interior bytes of strings and procedures. Empty references and truncated: false do not establish complete reference coverage.";
/** Build the shared Evidence record for a successful composed binary workflow. */
export const createWorkflowEvidence = (input: {
readonly target: BinaryTarget | undefined;
@@ -232,6 +236,9 @@ export const createWorkflowEvidence = (input: {
: { evidenceLinks: input.upstreamEvidenceLinks }),
limitations: [
"Derived by an REA composed workflow.",
...(input.operation === "trace_feature"
? [TRACE_FEATURE_REFERENCE_LIMITATION]
: []),
...input.upstreamLimitations,
],
...(input.operation === "batch_decompile"
@@ -17,6 +17,11 @@ import type {
} from "../AnalysisProvider.js";
import type { AnalysisProviderCandidateStatus } from "./AnalysisProviderRegistry.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import {
providerReleaseVerificationSchema,
type ProviderReleaseVerification,
} from "../../domain/providerReleaseVerification.js";
import { analysisErrorRemediationAction } from "../../domain/analysisErrorPresentation.js";
/** Profile-resolution truth for one discovered provider candidate. */
@@ -26,6 +31,7 @@ export interface AnalysisProviderCandidateEvaluation {
readonly profile?: AnalysisProfileCommitment;
readonly refreshAfterRetirement?: true;
readonly createClient?: AnalysisProviderBoundClientFactory;
readonly releaseVerification?: ProviderReleaseVerification;
readonly profileError?: AnalysisError;
}
@@ -115,10 +121,29 @@ export const evaluateAnalysisProviderCandidate = async (
remediation: PROFILE_REMEDIATION,
}),
);
const verification =
resolved.value.releaseVerification === undefined
? undefined
: providerReleaseVerificationSchema.safeParse(
resolved.value.releaseVerification,
);
if (verification?.success === false)
return ok(
rejectedProfile(candidate, status, {
reason: "Provider returned invalid release verification",
remediation: PROFILE_REMEDIATION,
}),
);
return ok({
candidate,
status,
profile,
...(verification === undefined
? {}
: {
releaseVerification: verification.data,
}),
...(resolved.value.refreshAfterRetirement === undefined
? {}
: { refreshAfterRetirement: resolved.value.refreshAfterRetirement }),
@@ -13,6 +13,7 @@ import {
type AnalysisProviderSelector,
} from "../../domain/providerSelection.js";
import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js";
import type { ProviderReleaseVerification } from "../../domain/providerReleaseVerification.js";
import type { BinaryTarget } from "../../domain/binaryTargetTypes.js";
import {
AnalysisCancelledError,
@@ -80,6 +81,8 @@ export interface AnalysisProviderBinding {
readonly identity: ProviderIdentity & { readonly version: string };
readonly selectionSource: "request" | "environment" | "auto-single-candidate";
readonly profile: AnalysisProfileCommitment;
readonly releaseVerification?: ProviderReleaseVerification;
readonly refreshAfterRetirement?: true;
readonly createClient?: AnalysisProviderBoundClientFactory;
/** The provider's capabilities, attributed to its resolved identity. */
@@ -832,6 +835,12 @@ const selectedResult = (
identity: structuredClone(identity),
selectionSource: source,
profile: structuredClone(evaluated.profile),
...(evaluated.releaseVerification === undefined
? {}
: {
releaseVerification: structuredClone(evaluated.releaseVerification),
}),
...(evaluated.refreshAfterRetirement === undefined
? {}
: { refreshAfterRetirement: evaluated.refreshAfterRetirement }),
@@ -1595,7 +1595,7 @@ it("revises trace workflow profiles so earlier results are not replayed", () =>
expect(
workflowAnalysisProfile(profile, "trace_feature").parameters
.workflow_revision,
).toBe(8);
).toBe(9);
expect(workflowAnalysisProfile(profile, "trace_feature")).not.toEqual(
workflowAnalysisProfile(profile),
@@ -57,6 +57,19 @@ interface PrepareSessionExecutionInput {
) => AnalysisExecution | undefined;
}
/** Recovery for an operation absent from the selected binary route. */
export const undeclaredOperationGuidance = (
operation: string,
entryCommentAvailable = false,
): string => {
const entryComment =
entryCommentAvailable &&
(operation === "set_comment" || operation === "set_inline_comment")
? `If the address is exactly a function entry and an entry comment is intended, use MCP annotate_native_function with procedure set to that entry and ${operation === "set_comment" ? "comment" : "inline_comment"}, or CLI rea annotate-native-function with the target, entry and ${operation === "set_comment" ? "--comment" : "--inline-comment"}. This edits session metadata, not arbitrary-address comments; whether CLI edits persist after the invocation depends on the provider and selected checkpoint mode. `
: "";
return `${entryComment}In MCP, inspect binary_session with detail: "complete" for candidate availability, target support, and capabilities. If a suitable candidate is available, explicitly call open_binary with the same target and its provider_id. In CLI, use rea providers to inspect readiness and capabilities; target support remains unknown until a target is selected. Select an available candidate with --provider and check the result. Switching may discard provider session edits; retained Evidence does not restore them. Candidate availability does not guarantee support for this target.`;
};
/** Resolve capability, profile, and snapshot state before invoking a provider. */
export const prepareSessionExecution = (
input: PrepareSessionExecutionInput,
@@ -74,19 +87,34 @@ export const prepareSessionExecution = (
if (capability?.available !== true) {
const selectionError = unboundOperationError(operation, active.route);
if (selectionError !== undefined) return err(selectionError);
const entryCommentAvailable =
active.route.binding !== null &&
active.route.capabilities.get("annotate_native_function")?.available ===
true &&
active.route.capabilities.get("annotate_native_function")?.provider.id ===
active.route.binding.identity.id;
const undeclaredGuidance =
capability === undefined
? undeclaredOperationGuidance(operation, entryCommentAvailable)
: undefined;
return err(
new AnalysisCapabilityUnavailableError(
active.route.binding?.identity.id ?? active.route.identity.id,
operation,
capability?.reason ?? "operation is not declared by this provider",
{
...(capability?.userMessage === undefined
? {}
: { userMessage: capability.userMessage }),
...(capability?.remediation === undefined
? {}
: { remediationAction: capability.remediation }),
},
undeclaredGuidance === undefined
? {
...(capability?.userMessage === undefined
? {}
: { userMessage: capability.userMessage }),
...(capability?.remediation === undefined
? {}
: { remediationAction: capability.remediation }),
}
: {
userMessage: `The selected provider does not declare ${operation}.`,
fallbackRemediationAction: undeclaredGuidance,
},
),
);
}
@@ -10,6 +10,7 @@ import {
analysisProviderCandidate,
analysisProviderCandidateSummary,
expandProviderCapabilities,
expandToolAvailability,
sessionProviderFacts,
type ProviderCapabilitySet,
} from "../../contracts/toolOutputSchemaPrimitives.js";
@@ -30,6 +31,7 @@ import {
import { NATIVE_MACOS_PROVIDER_IDENTITY } from "../../native/NativeMacosProviderIdentity.js";
import {
buildCapabilityInventory,
compactToolAvailability,
MULTI_MODE_REMEDIATION,
} from "../CapabilityInventory.js";
@@ -74,7 +76,9 @@ type DescriptorOptions = {
| {
readonly available: false;
readonly reason?: string;
readonly availabilityCode?: "architecture_unsupported";
readonly availabilityCode?:
| "architecture_unsupported"
| "unsupported_host";
}
);
@@ -850,3 +854,124 @@ it("groups readiness repairs exactly and drops one replaced by a runtime observa
}),
]);
});
it("keeps independent decompilation and value flow in distinct capability groups", async () => {
const status = await projectStatus(
[
{
...descriptor(providerA, "procedure_pseudo_code", []),
independentDecompile: true,
},
{
...descriptor(providerA, "analyze_function", []),
nativeValueFlow: { status: "supported" as const },
},
descriptor(providerA, "xrefs", []),
],
[],
);
const projected = z
.object({ capabilities: sessionProviderFacts.shape.capabilities })
.parse(status);
const capabilities = expandProviderCapabilities(projected.capabilities);
expect(
capabilities.find((item) => item.operation === "procedure_pseudo_code"),
).toMatchObject({ independent_decompile: true });
expect(
capabilities.find((item) => item.operation === "analyze_function"),
).toMatchObject({ native_value_flow: { status: "supported" } });
expect(
capabilities.find((item) => item.operation === "xrefs"),
).not.toHaveProperty("independent_decompile");
expect(
capabilities.find((item) => item.operation === "xrefs"),
).not.toHaveProperty("native_value_flow");
});
it.each([
{ detail: "summary", supported: true },
{ detail: "complete", supported: true },
{ detail: "summary", supported: false },
{ detail: "complete", supported: false },
] as const)(
"retains routed value-flow ownership and runtime failures in $detail discovery when supported=$supported",
async ({ detail, supported }) => {
const source = {
...descriptor(providerA, "analyze_function", []),
nativeValueFlow: supported
? { status: "supported" as const }
: { status: "unavailable" as const, reason: "No value flow." },
};
const resolver = descriptor(providerB, "resolve_native_call_targets", []);
const providerC = { id: "provider-c", name: "Provider C", version: "3" };
const unselected = {
...descriptor(providerC, "analyze_function", []),
nativeValueFlow: { status: "unavailable" as const, reason: "Unselected" },
};
const status = await projectStatus(
[source, resolver],
[{ ...candidate(providerC, [unselected]), selected: false }],
{
target: dispatchTarget("elf"),
detail,
runtimeUnavailability: new Map([
["resolve_native_call_targets", { reason: "Resolver failed." }],
]),
},
);
const projected = z
.object({ capabilities: sessionProviderFacts.shape.capabilities })
.parse(status);
const route = expandProviderCapabilities(projected.capabilities);
expect(
route.find((item) => item.operation === "analyze_function"),
).toMatchObject({
provider: providerA,
native_value_flow: { status: supported ? "supported" : "unavailable" },
});
const inventory = buildCapabilityInventory(status, {
processCaptureEnabled: false,
});
const item = expandToolAvailability(
compactToolAvailability(inventory, detail),
).find((candidate) => candidate.name === "trace_native_values");
expect(item).toMatchObject({
available: false,
reason: "provider_unavailable",
remediation: expect.stringContaining("Resolver failed."),
value_flow_support: {
status: supported ? "supported" : "unavailable",
provider_id: providerA.id,
},
});
if (!supported) expect(item?.remediation).toContain("No value flow.");
},
);
it("preserves an unsupported resolver host alongside unavailable value flow", async () => {
const source = {
...descriptor(providerA, "analyze_function", []),
nativeValueFlow: {
status: "unavailable" as const,
reason: "No value flow.",
},
};
const resolver = {
...descriptor(providerB, "resolve_native_call_targets", [], {
available: false,
reason: "Resolver host is unsupported.",
availabilityCode: "unsupported_host",
}),
remediation: "Use a supported resolver host.",
};
const status = await projectStatus([source, resolver], [], {
target: dispatchTarget("elf"),
detail: "summary",
});
const item = buildCapabilityInventory(status, {
processCaptureEnabled: false,
}).find((entry) => entry.name === "trace_native_values");
expect(item).toMatchObject({ available: false, reason: "unsupported_host" });
expect(item?.remediation).toContain("No value flow.");
expect(item?.remediation).toContain("Use a supported resolver host.");
});
@@ -108,6 +108,13 @@ export const binarySessionStatus = async ({
provider: providerSummary(route.binding.identity),
selection_source: route.binding.selectionSource,
analysis_profile: structuredClone(route.binding.profile),
...(route.binding.releaseVerification === undefined
? {}
: {
release_verification: structuredClone(
route.binding.releaseVerification,
),
}),
};
const analysisProviderCandidates =
bundleResourceRoot !== undefined
@@ -417,6 +424,7 @@ export const providerCapabilitySets = (
const key = JSON.stringify([
effects,
limitations,
descriptor.nativeValueFlow ?? null,
descriptor.independentDecompile ?? null,
status.available,
status.reason,
@@ -430,6 +438,9 @@ export const providerCapabilitySets = (
operations: [descriptor.operation],
effects,
limitations,
...(descriptor.nativeValueFlow === undefined
? {}
: { native_value_flow: descriptor.nativeValueFlow }),
...(descriptor.independentDecompile === undefined
? {}
: { independent_decompile: descriptor.independentDecompile }),
@@ -7,6 +7,7 @@ import { analysisErrorProjectionSchema } from "../../contracts/errorSchemas.js";
import { InvestigationRecords } from "./InvestigationRecords.js";
import {
createInvestigationEvidenceClosureChecker,
collectInvestigationEvidenceClosure,
resolveInvestigationEvidenceClosure,
} from "./InvestigationEvidence.js";
@@ -158,3 +159,25 @@ it("reuses a complete shared source graph without weakening missing-source check
expect(lookups).toBe(3);
expect(check([source.evidence_id, missing])).toBe(false);
});
it("collects other reachable sources after a missing ancestor", () => {
const missing = `ev_${"0".repeat(64)}`;
const first = observation("/reachable");
const incomplete = observation("/incomplete", [missing]);
const records = new InvestigationRecords();
for (const record of [first, incomplete]) {
const added = records.recordEvidence(record);
if (!added.ok) throw added.error;
}
const result = collectInvestigationEvidenceClosure(records, [
first.evidence_id,
incomplete.evidence_id,
]);
expect(result.result).toMatchObject({
ok: false,
error: { reason: "missing" },
});
expect(result.records.map((record) => record.evidence_id).sort()).toEqual(
[first.evidence_id, incomplete.evidence_id].sort(),
);
});
@@ -3,7 +3,7 @@ import type { EvidenceReader } from "./InvestigationRecordPort.js";
import { uniqueSorted } from "../../domain/canonicalOrdering.js";
import type { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
import type { Evidence } from "../../domain/evidence.js";
import { ok, type Result } from "../../domain/result.js";
import { err, ok, type Result } from "../../domain/result.js";
type Lookup = (id: string) => Evidence | undefined;
@@ -12,8 +12,10 @@ const visitClosure = (
lookup: Lookup,
seedIds: readonly string[],
knownComplete?: ReadonlySet<string>,
collect?: (record: Evidence) => void,
): Result<Set<string>, EvidenceIntegrityError> => {
const visited = new Set<string>();
let failure: EvidenceIntegrityError | undefined;
const pending = [...seedIds];
while (pending.length > 0) {
const evidenceId = pending.pop();
@@ -28,12 +30,36 @@ const visitClosure = (
lookup,
"retained-closure",
);
if (!evidence.ok) return evidence;
if (!evidence.ok) {
if (collect === undefined) return evidence;
failure ??= evidence.error;
visited.add(evidenceId);
continue;
}
visited.add(evidenceId);
collect?.(evidence.value);
for (const linkedId of evidence.value.evidence_links)
pending.push(linkedId);
}
return ok(visited);
return failure === undefined ? ok(visited) : err(failure);
};
/** Gather every reachable authenticated record while retaining the first failure. */
export const collectInvestigationEvidenceClosure = (
reader: Pick<EvidenceReader, "evidenceForAnalysis">,
seedIds: readonly string[],
): {
readonly records: Evidence[];
readonly result: Result<null, EvidenceIntegrityError>;
} => {
const records: Evidence[] = [];
const visited = visitClosure(
(id) => reader.evidenceForAnalysis(id),
seedIds,
undefined,
(record) => records.push(record),
);
return { records, result: visited.ok ? ok(null) : visited };
};
/** Follow exact session references without exporting unrelated records or Unknowns. */
+6 -2
View File
@@ -198,7 +198,7 @@ const registerDecompileCommand = (
runDirectAnalysis: DirectAnalysis["runDirectAnalysis"],
): void => {
cli.command(CLI_COMMANDS.decompile, {
description: `Read one part of an app as code. ${decompileSelectorGuidance} Equal positional and --procedure values are accepted; conflicts fail.`,
description: `Read one part of an app as code. ${decompileSelectorGuidance} Equal positional and --procedure values are accepted; conflicts fail. With Ghidra, a space-qualified address such as ram:0x... avoids file-offset fallback; inspect-native-load-image reports the observed default space.`,
args: z.object({
path: localPathStringSchema.describe("App or program path"),
address: z
@@ -452,7 +452,11 @@ const registerFunctionCommand = (
"analyze_function",
),
},
{ ...directAnalysisOptionsFromCli(logger, options), signal },
{
...directAnalysisOptionsFromCli(logger, options),
signal,
includeEvidenceSources: options.evidence_output !== undefined,
},
);
return publishCliAnalysisEvidence(complete, delivery);
}),
+1 -1
View File
@@ -312,7 +312,7 @@ export const registerCoreNativeCommands = (
cli.command(command, {
description:
operation === "inspect_native_instruction"
? "Inspect an exact code or data address for decoded instructions and typed incoming references where supported"
? "Inspect a code or data address, its containing Ghidra listing object, and typed incoming references where supported"
: "Resolve one static native call site",
args: z.object({
path: localPathStringSchema.describe(
+1 -1
View File
@@ -82,7 +82,7 @@ export const ENHANCED_TOOL_CONTRACTS = [
...FUNCTION_WORKFLOW_TOOL_CONTRACTS,
enhanced(
"trace_feature",
"Trace the complete literal query, preserving spaces, through every matching string and procedure and their xrefs. Preserve an explicit unavailable owner for a non-retryable containment capability refusal. Returns observations and residual unknowns without inferring reference kinds; truncated only reports an observed reference cap. For a procedure match, use procedure_references with direction incoming for provider-reported kinds when supported; it covers the procedure body, so compare target addresses when selecting exact-entry references. Select detail summary to retain complete Evidence and page matches, references and unknowns with inspect_analysis_view kind native-feature-trace.",
"Search provider string and procedure inventories for the complete literal query, preserving spaces, then query xrefs at each matched start address. References to interior bytes of strings and procedures are not searched. An empty result or truncated: false does not prove there are no references. Preserve an explicit unavailable owner for a non-retryable containment capability refusal; reference kinds remain unknown. For a procedure match, use procedure_references with direction incoming for provider-reported kinds when supported; it covers the procedure body, so compare target addresses when selecting exact-entry references. Select detail summary to retain complete Evidence and page matches, references and unknowns with inspect_analysis_view kind native-feature-trace.",
enhancedInputSchemas.trace_feature.extend({ detail: analysisDetailSchema }),
),
enhanced(
+2 -2
View File
@@ -73,7 +73,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [
),
official(
"inspect_native_instruction",
"Inspect one exact code or data address: decoded bytes, mnemonic, ordered operand tokens, flow, outgoing instruction references and typed incoming references where available. Incoming references preserve source, access kind, operand index and analysis provenance even at data addresses; they do not prove runtime access. Page retained references with inspect_analysis_view kind native-instruction-references; null coverage means the selected collection was unavailable or not recorded. Memory addressing decomposition is unavailable when the provider supplies only tokens. Ghidra reports in-memory addresses without a listed instruction or defined data as not-in-listing, without attempting a decode.",
"Inspect one exact code or data address: decoded bytes, mnemonic, ordered operand tokens, flow, outgoing instruction references and typed incoming references where available. For a byte inside a listed instruction or defined data, Ghidra reports the containing instruction or data object separately, preserving the query and address space. Inspect the starting address of a data object with inspect_native_data_type for its layout; a containing object does not select a field or union member. Incoming references preserve source, access kind, operand index and analysis provenance even at data addresses; they do not prove runtime access. Page retained references with inspect_analysis_view kind native-instruction-references; null coverage means the selected collection was unavailable or not recorded. Memory addressing decomposition is unavailable when the provider supplies only tokens. Ghidra reports in-memory addresses without a listed instruction or defined data as not-in-listing, without attempting a decode.",
nativeInstructionInputSchema,
),
official(
@@ -214,7 +214,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [
),
official(
"procedure_pseudo_code",
"Decompile one analyzed procedure by symbol name or provider-normalized address. To request a fresh independent attempt, use analysis: independent_fresh when binary_session reports independent_decompile for this provider operation; otherwise it is refused. The independent result may have different analysis context. 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. When supplied, raw_result.pseudocode_absence distinguishes provider-failure from not-in-export; absence of that fact means unknown. 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.",
"Decompile one analyzed procedure by symbol name or provider-normalized address. To request a fresh independent attempt, use analysis: independent_fresh when binary_session reports independent_decompile for this provider operation; otherwise it is refused. The independent result may have different analysis context. 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. A space-qualified address such as ram:0x... avoids that fallback; inspect_native_load_image reports Ghidra's observed default address space. Returns provider-specific pseudocode, never original source or cross-provider text equivalence, and may return null. When supplied, raw_result.pseudocode_absence distinguishes provider-failure from not-in-export; absence of that fact means unknown. 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,
+3 -3
View File
@@ -68,7 +68,7 @@ export const exportEvidenceBundleInputSchema = z.strictObject({
export const SESSION_TOOL_CONTRACTS = [
session(
"open_binary",
"Open a local executable, application bundle, archive, JavaScript, source map, plist, or analysis database after validation. format=dos-com explicitly interprets 1..65280 headerless bytes as a DOS COM analysis image. format=raw-binary interprets headerless bytes with a required architecture and entry_point; file offset 0 maps at that address. Omission preserves header-based detection. provider_id selects one deep provider or deterministic auto selection; the binding remains stable until close or an explicit switch, with no failure fallback. Reopening the same target and profile retains its active database and metadata edits. For editable Ghidra persistence across processes, set REA_GHIDRA_CHECKPOINT_DIR to a private absolute directory in the startup environment (stock ELF/PE/Mach-O on Linux/macOS; restart MCP). Opens and normal close then restore/save automatically; no separate persistence tool or runtime setting update is needed. An optional analysis snapshot is imported atomically and must match the binary identity, concrete provider, and canonical analysis profile exactly; importing into a session with metadata mutations requires closing and reopening it first. Switching targets preserves investigation records. A failed admitted open reports error.details.selection_at_failure, target_at_open_completion, and provider_id_at_open_completion. The target summary and provider id are the selected binary and bound provider when that attempt settled, or null when absent. Later calls can change the selection, so use binary_session for current state. Call close_binary to release engine resources; reset_investigation explicitly clears investigation records.",
"Open a local executable, application bundle, archive, JavaScript, source map, plist, or analysis database after validation. format=dos-com explicitly interprets 1..65280 headerless bytes as a DOS COM analysis image. format=raw-binary interprets headerless bytes with a required architecture and entry_point; file offset 0 maps at that address. Omission preserves header-based detection. provider_id selects one deep provider or deterministic auto selection; the binding remains stable until close or an explicit switch, with no failure fallback. Reopening the same target and profile retains its active database and metadata edits. Changing target or provider can retire the active Ghidra database and discard its ephemeral edits, including before a later provider call fails; retained investigation records are historical and do not restore those edits. For editable Ghidra persistence across processes, set REA_GHIDRA_CHECKPOINT_DIR to a private absolute directory in the startup environment (stock ELF/PE/Mach-O on Linux/macOS; restart MCP). Opens and normal close then restore/save automatically; no separate persistence tool or runtime setting update is needed. An optional analysis snapshot is imported atomically and must match the binary identity, concrete provider, and canonical analysis profile exactly; importing into a session with metadata mutations requires closing and reopening it first. Switching targets preserves investigation records. A failed admitted open reports error.details.selection_at_failure, target_at_open_completion, and provider_id_at_open_completion. The target summary and provider id are the selected binary and bound provider when that attempt settled, or null when absent. Later calls can change the selection, so use binary_session for current state. Call close_binary to release engine resources; reset_investigation explicitly clears investigation records.",
openBinaryInputSchema,
),
session(
@@ -88,7 +88,7 @@ export const SESSION_TOOL_CONTRACTS = [
),
session(
"binary_session",
'Report the active binary target or resource-only app root, provider and capability availability when applicable, client-feature, analysis, and server-identity status without starting analysis. Start with the default summary: after selecting Ghidra, capabilities[].shared_limitations already reports enabled checkpoint or ephemeral mode. Request detail: "complete" only for provider diagnostics, candidate capabilities or static tool flags; omitted_fields lists what summary leaves out.',
'Report the active binary target or resource-only app root, provider and capability availability when applicable, client-feature, analysis, and server-identity status without starting analysis. A selected provider may report release_verification under its binding; omission means not reported, and reported-version verification does not authenticate executable bytes. Start with the default summary: after selecting Ghidra, capabilities[].shared_limitations already reports enabled checkpoint or ephemeral mode. Request detail: "complete" only for provider diagnostics, candidate capabilities or static tool flags; omitted_fields lists what summary leaves out.',
binarySessionInputSchema,
),
session(
@@ -123,7 +123,7 @@ export const SESSION_TOOL_CONTRACTS = [
),
session(
"compare_functions",
"Compare two explicit analyze_function Evidence records or exact retained session references across identity, exact provider text, calls, references, strings, and address-normalized CFG topology. Authenticated same-artifact entry coordinates establish correspondence independently of renamed labels; metadata and text changes remain separate. Missing or provider-incompatible facets remain truncated or unknown; every conclusion cites both Evidence sets.",
"Compare two explicit analyze_function Evidence records or exact retained session references across identity, exact provider text, calls, references, strings, and address-normalized CFG topology. The same authenticated Evidence record or compatible same-artifact entry coordinates establish correspondence independently of symbol origin; metadata and text changes remain separate. Missing or provider-incompatible facets remain truncated or unknown; every conclusion cites both Evidence sets.",
functionComparisonInputSchema,
),
session(
@@ -1,5 +1,6 @@
import { procedureInfoSchema } from "../domain/native/procedureInfo.js";
import { z } from "zod";
import { providerReleaseVerificationSchema } from "../domain/providerReleaseVerification.js";
import { investigationStatisticsSchema } from "../domain/investigationLifecycle.js";
import { utcDateTimeSchema } from "../domain/offsetDateTime.js";
import { analysisCleanupRetrySchema } from "../domain/analysisErrorRecovery.js";
@@ -53,6 +54,29 @@ export const requireOutputSchema = <
/** The readiness owner's repair instruction; never the failure description. */
const providerRemediation = z.string().min(1);
const nativeValueFlowDeclaration = z.discriminatedUnion("status", [
z.strictObject({ status: z.literal("supported") }),
z.strictObject({
status: z.literal("unavailable"),
reason: z.string().min(1),
}),
]);
const nativeValueFlowSupport = z.discriminatedUnion("status", [
z.strictObject({
status: z.literal("supported"),
provider_id: z.string().min(1),
}),
z.strictObject({
status: z.literal("unavailable"),
provider_id: z.string().min(1),
reason: z.string().min(1),
}),
z.strictObject({
status: z.literal("unknown"),
provider_id: z.string().min(1).nullable(),
}),
]);
const providerCapabilityFacts = {
operation: z.string(),
effects: z.object({
@@ -65,6 +89,7 @@ const providerCapabilityFacts = {
requires_root: z.boolean(),
}),
limitations: z.array(z.string()),
native_value_flow: nativeValueFlowDeclaration.exactOptional(),
independent_decompile: z.literal(true).exactOptional(),
};
@@ -101,6 +126,7 @@ const providerCapabilityGroupFacts = {
operations: z.array(z.string().min(1)).min(1),
effects: z.array(providerCapabilityFacts.effects.keyof()),
limitations: z.array(z.string()),
native_value_flow: nativeValueFlowDeclaration.exactOptional(),
independent_decompile: providerCapabilityFacts.independent_decompile,
};
@@ -140,6 +166,9 @@ export const expandProviderCapabilities = (
group.operations.map((operation) => ({
...providerCapability.parse({
operation,
...(group.native_value_flow === undefined
? {}
: { native_value_flow: group.native_value_flow }),
...(group.independent_decompile === undefined
? {}
: { independent_decompile: group.independent_decompile }),
@@ -380,6 +409,11 @@ const toolAvailabilityFacts = {
surface: z.string(),
default_mode_available: z.boolean().optional(),
modes: z.array(toolAvailabilityMode).optional(),
value_flow_support: nativeValueFlowSupport
.describe(
"The named provider's declared ability to supply value flow for some functions, separate from runtime readiness. Supported does not guarantee a model for a particular function; unknown means no declaration.",
)
.exactOptional(),
client_requirements: z.object({
required: z.array(clientFeatureNameSchema),
optional: z.array(clientFeatureNameSchema),
@@ -445,6 +479,7 @@ const toolAvailabilityStatusGroupFacts = {
names: z.array(z.string().min(1)).min(1),
default_mode_available: toolAvailabilityFacts.default_mode_available,
modes: toolAvailabilityFacts.modes,
value_flow_support: toolAvailabilityFacts.value_flow_support,
client_requirements: toolAvailabilityFacts.client_requirements.optional(),
};
@@ -454,6 +489,7 @@ const toolAvailabilityGroupFacts = {
default_mode_available:
toolAvailabilityStatusGroupFacts.default_mode_available,
modes: toolAvailabilityStatusGroupFacts.modes,
value_flow_support: toolAvailabilityStatusGroupFacts.value_flow_support,
client_requirements: toolAvailabilityStatusGroupFacts.client_requirements,
effects: z.array(toolAvailabilityFacts.effects.keyof()),
annotations: z.array(toolAvailabilityFacts.annotations.keyof()),
@@ -570,6 +606,7 @@ export const sessionProviderFacts = z
"auto-single-candidate",
]),
analysis_profile: analysisProfileSchema,
release_verification: providerReleaseVerificationSchema.optional(),
})
.nullable(),
client_features: z.object({
+4
View File
@@ -220,6 +220,8 @@ export class AnalysisCapabilityUnavailableError extends AnalysisError {
readonly _tag = "AnalysisCapabilityUnavailableError";
override readonly userMessage: string | undefined;
readonly remediationAction: string | undefined;
/** General recovery used only when a consumer has no more specific action. */
readonly fallbackRemediationAction: string | undefined;
readonly siblingOperations: readonly string[] | undefined;
readonly siblingReasons: Readonly<Record<string, string>> | undefined;
/** Incomplete cleanup must settle before the operation can be retried. */
@@ -232,6 +234,7 @@ export class AnalysisCapabilityUnavailableError extends AnalysisError {
options?: AnalysisErrorOptions & {
readonly userMessage?: string;
readonly remediationAction?: string;
readonly fallbackRemediationAction?: string;
readonly siblingOperations?: readonly string[];
readonly siblingReasons?: Readonly<Record<string, string>>;
readonly retryable?: boolean;
@@ -243,6 +246,7 @@ export class AnalysisCapabilityUnavailableError extends AnalysisError {
);
this.userMessage = options?.userMessage;
this.remediationAction = options?.remediationAction;
this.fallbackRemediationAction = options?.fallbackRemediationAction;
this.siblingOperations = options?.siblingOperations;
this.siblingReasons = options?.siblingReasons;
this.retryable = options?.retryable === true && !this.cleanupIncomplete;
+5
View File
@@ -90,6 +90,11 @@ export const analysisErrorRemediationAction = (
return "Set environment.service_workers to allow to permit this capture, or remove the service worker registration through the browser. REA never removes existing registrations.";
if (error instanceof AnalysisCapabilityUnavailableError && error.retryable)
return "Review details.reason, then retry the same analysis after the condition clears.";
if (
error instanceof AnalysisCapabilityUnavailableError &&
error.fallbackRemediationAction !== undefined
)
return error.fallbackRemediationAction;
if (error instanceof EvidenceFileError && error.published !== undefined)
return error.published.path === undefined
? "Resolve the reported staging cleanup failure. Publication completed; its path identity is excluded."
+40 -9
View File
@@ -269,14 +269,13 @@ describe("capability failure retry disposition", () => {
"preserves the producer's $retryable disposition",
({ options, retryable, category }) => {
const reason = "Native self-test failed with a sharing violation";
const projected = projectAnalysisError(
new AnalysisCapabilityUnavailableError(
"fixture-provider",
"inspect",
reason,
options,
),
const error = new AnalysisCapabilityUnavailableError(
"fixture-provider",
"inspect",
reason,
options,
);
const projected = projectAnalysisError(error);
expect(projected).toMatchObject({
code: "capability_unavailable",
category,
@@ -287,8 +286,8 @@ describe("capability failure retry disposition", () => {
expect(projected.message).toBe(
"Required runtime controls are unavailable for this attempt.",
);
expect(projected.remediation.action).toContain(
"retry the same analysis",
expect(projected.remediation.action).toBe(
analysisErrorRemediationAction(error),
);
}
expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(
@@ -313,6 +312,38 @@ describe("capability failure retry disposition", () => {
).toBe("Selected runtime diagnostic");
});
it("uses generic recovery only when no more specific recovery exists", () => {
const project = (options: {
fallbackRemediationAction?: string;
remediationAction?: string;
retryable?: boolean;
}) =>
projectAnalysisError(
new AnalysisCapabilityUnavailableError(
"fixture",
"inspect",
"unavailable",
options,
),
);
expect(
project({ fallbackRemediationAction: "Generic" }).remediation.action,
).toBe("Generic");
expect(
project({
fallbackRemediationAction: "Generic",
remediationAction: "Specific",
}).remediation.action,
).toBe("Specific");
const retry = project({ retryable: true });
const retryWithFallback = project({
fallbackRemediationAction: "Generic",
retryable: true,
});
expect(retryWithFallback.retryable).toBe(true);
expect(retryWithFallback.remediation.action).toBe(retry.remediation.action);
});
it("retains cleanup ownership before allowing a transient operation to retry", () => {
expect(
projectAnalysisError(
+108
View File
@@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import { readComparisonResult } from "./comparisonEvidenceResult.js";
import type { Evidence } from "./evidence.js";
import {
functionComparisonDimensionNameSchema,
functionComparisonResultSchema,
} from "./functionComparisonSchemas.js";
const source = `ev_${"a".repeat(64)}`;
const hash = "b".repeat(64);
const other = "c".repeat(64);
const evidence = (result: unknown): Evidence =>
({
evidence_id: `ev_${"d".repeat(64)}`,
evidence_links: [source],
parameters: { left_evidence_id: source, right_evidence_id: source },
normalized_result: result,
}) as unknown as Evidence;
const baseline = () => ({
status: "unchanged" as const,
function_match: {
status: "matched" as const,
method: "same-evidence" as const,
left_name: "main",
right_name: "main",
},
left_subject_sha256: hash,
right_subject_sha256: hash,
summary: { unchanged: 8, changed: 0, unknown: 0 },
dimensions: functionComparisonDimensionNameSchema.options.map(
(dimension) => ({
dimension,
left_count: 1,
right_count: 1,
evidence_links: [source, source],
limitations: [],
status: "unchanged" as const,
left_digest: hash,
right_digest: hash,
text_delta: null,
conclusion_kind: "derived_relationship" as const,
}),
),
changes: [],
limitations: [],
});
describe("same-evidence comparison admission", () => {
it("accepts a consistent complete comparison", () => {
const result = baseline();
expect(functionComparisonResultSchema.safeParse(result).success).toBe(true);
expect(readComparisonResult(evidence(result), "compare_functions").ok).toBe(
true,
);
});
it("rejects a changed dimension for one source", () => {
const result = baseline();
const changed = {
...result.dimensions[0]!,
status: "changed" as const,
right_digest: other,
};
const candidate = {
...result,
status: "changed" as const,
summary: { unchanged: 7, changed: 1, unknown: 0 },
dimensions: [changed, ...result.dimensions.slice(1)],
changes: [changed],
};
expect(functionComparisonResultSchema.safeParse(candidate).success).toBe(
true,
);
expect(
readComparisonResult(evidence(candidate), "compare_functions").ok,
).toBe(false);
});
it("rejects unequal subject hashes for one source", () => {
const candidate = { ...baseline(), right_subject_sha256: other };
expect(functionComparisonResultSchema.safeParse(candidate).success).toBe(
true,
);
expect(
readComparisonResult(evidence(candidate), "compare_functions").ok,
).toBe(false);
});
it("rejects unequal unchanged digests for one source", () => {
const result = baseline();
const candidate = {
...result,
dimensions: [
{ ...result.dimensions[0]!, right_digest: other },
...result.dimensions.slice(1),
],
};
expect(functionComparisonResultSchema.safeParse(candidate).success).toBe(
true,
);
expect(
readComparisonResult(evidence(candidate), "compare_functions").ok,
).toBe(false);
});
});
+34
View File
@@ -15,6 +15,7 @@ import {
type ProcessCaptureComparison,
} from "./process/processComparison.js";
import { err, ok, type Result } from "./result.js";
import { comparisonSourceEvidenceSides } from "./comparisonSourceEvidence.js";
type ComparisonResults = {
readonly compare_artifacts: ArtifactComparisonResult;
@@ -58,6 +59,39 @@ export function readComparisonResult(
evidence.normalized_result,
);
if (!parsed.ok) return parsed;
if (parsed.value.function_match.method === "same-evidence") {
try {
const { left, right } = comparisonSourceEvidenceSides(evidence);
const sourceId = left[0];
const sameSource =
left.length === 1 &&
right.length === 1 &&
sourceId === right[0] &&
sourceId !== undefined &&
evidence.evidence_links.includes(sourceId) &&
parsed.value.dimensions.every(({ evidence_links }) =>
evidence_links.every((id) => id === sourceId),
);
const impossibleChange =
parsed.value.left_subject_sha256 !==
parsed.value.right_subject_sha256 ||
parsed.value.dimensions.some(
(dimension) =>
dimension.status === "changed" ||
(dimension.status === "unchanged" &&
dimension.left_digest !== dimension.right_digest),
);
if (!sameSource || impossibleChange)
return err(
new EvidenceIntegrityError(
"Same-Evidence function comparison must cite the same source on both sides",
),
);
} catch (cause) {
if (cause instanceof EvidenceIntegrityError) return err(cause);
throw cause;
}
}
const links = validateFindingLinks(
evidence,
parsed.value.dimensions.flatMap(({ evidence_links }) => evidence_links),
@@ -85,6 +85,30 @@ it.each([
expect(JSON.stringify(projected)).not.toContain("explicit-secret");
});
it("preserves sanitized capability recovery specificity", () => {
for (const [options, expected] of [
[{ remediationAction: "Repair explicit-secret" }, "Repair [REDACTED]"],
[
{ fallbackRemediationAction: "Select explicit-secret" },
"Select [REDACTED]",
],
] as const) {
const error = new AnalysisCapabilityUnavailableError(
"provider",
"inspect",
"unsupported",
options,
);
const sanitized = redactExplicitFailure(error, ["explicit-secret"]);
expect(projectAnalysisError(sanitized).remediation.action).toBe(expected);
if (!(sanitized instanceof AnalysisCapabilityUnavailableError))
throw new Error("expected capability failure");
expect(sanitized.remediationAction === undefined).toBe(
"fallbackRemediationAction" in options,
);
}
});
it.each([
["/metadata/private~1~0/numeric", ["private/~"], "/metadata"],
["/metadata/private/child", ["private"], "/metadata"],
+8
View File
@@ -279,6 +279,14 @@ const redactFailure = (
...(error.userMessage === undefined
? {}
: { userMessage: text(error.userMessage) }),
...(error.remediationAction === undefined
? {}
: { remediationAction: text(error.remediationAction) }),
...(error.fallbackRemediationAction === undefined
? {}
: {
fallbackRemediationAction: text(error.fallbackRemediationAction),
}),
...(error.siblingOperations === undefined
? {}
: { siblingOperations: error.siblingOperations }),
+2 -1
View File
@@ -26,7 +26,8 @@ export const compareFunctions = (
const dimensions = compareDimensions(left, right, {
links,
providersDiffer,
sameEntry: match.method === "same-entry",
sameIdentity:
match.method === "same-entry" || match.method === "same-evidence",
coordinateContext,
});
const changes = dimensions.filter(({ status }) => status !== "unchanged");
+2 -2
View File
@@ -34,7 +34,7 @@ type ComparisonContext = Pick<
DimensionResultInput,
"links" | "providersDiffer"
> & {
readonly sameEntry: boolean;
readonly sameIdentity: boolean;
readonly coordinateContext: FunctionSnapshotCoordinateContext;
};
@@ -559,7 +559,7 @@ const compareIdentity = (
context: ComparisonContext,
): FunctionDimension => {
if (
!context.sameEntry &&
!context.sameIdentity &&
(!hasStableNativeSymbol(
left.dossier.procedure.name,
left.dossier.procedure.symbol,
@@ -49,13 +49,21 @@ export const functionMatch = (
left_name: leftName,
right_name: rightName,
};
}
if (left.evidence.evidence_id === right.evidence.evidence_id)
return {
status: "matched",
method: "same-evidence",
left_name: leftName,
right_name: rightName,
};
if (coordinateContext === "common")
return {
status: "ambiguous",
method: "explicit",
left_name: leftName,
right_name: rightName,
};
}
const stableNames =
hasStableNativeSymbol(leftName, left.dossier.procedure.symbol) &&
hasStableNativeSymbol(rightName, right.dossier.procedure.symbol);
+6
View File
@@ -85,6 +85,12 @@ const functionMatchSchema = z.union([
left_name: z.string(),
right_name: z.string(),
}),
z.object({
status: z.literal("matched"),
method: z.literal("same-evidence"),
left_name: z.string(),
right_name: z.string(),
}),
z.object({
status: z.enum(["mismatched", "ambiguous"]),
method: z.literal("explicit"),
+10 -2
View File
@@ -26,8 +26,16 @@ export const literalFeatureTraceSchema = z.object({
query: z.string(),
search_mode: z.literal("literal"),
matches: z.array(literalFeatureMatchSchema),
references: z.array(literalFeatureReferenceSchema),
truncated: z.boolean(),
references: z
.array(literalFeatureReferenceSchema)
.describe(
"References to matched start addresses only; references to interior bytes of strings and procedures are not searched. Empty does not establish absence.",
),
truncated: z
.boolean()
.describe(
"True only when a provider reports a reference cap; false does not establish complete reference coverage.",
),
residual_unknowns: z.array(z.string()),
});
+28
View File
@@ -57,6 +57,34 @@ export const nativeInstructionSchema = z.strictObject({
"decoded is an instruction at the address; not-instruction-boundary is inside a listed instruction; data is defined data; outside-memory is outside the program's memory. Ghidra's not-in-listing means the address is in memory with no listed instruction or defined data; no decode was attempted, and memory may be uninitialized. The legacy undecodable value in older Evidence has the same ambiguous meaning and does not prove decoding failed.",
),
procedure: z.string().nullable(),
containing_instruction: z
.strictObject({
address: z.string().min(1),
length: z.int().positive(),
mnemonic: z.string().min(1),
raw_disassembly: z.string().min(1),
provenance: z.string().min(1),
})
.optional()
.describe(
"Provider-observed listed instruction containing the exact requested interior address. Its address and length describe the containing instruction; the query address remains unchanged. Omission means not recorded or not applicable, including older Evidence. This does not imply a function, alternate decode, reachability, or runtime execution.",
),
containing_data: z
.strictObject({
address: z.string().min(1),
length: z
.int()
.positive()
.describe(
"Observed object length in bytes; a dynamic type may not have a fixed size.",
),
type_id: z.string().min(1),
provenance: z.string().min(1),
})
.optional()
.describe(
"Provider-observed containing defined data object, including its start and type identity; the selected address remains unchanged. Inspect its start with inspect_native_data_type for its layout. This does not choose a field or union member or prove runtime use. Omission means not recorded or not applicable, including older Evidence.",
),
architecture: z.string(),
mode: z
.string()
+19
View File
@@ -0,0 +1,19 @@
import { z } from "zod";
/** Provider-owned verification of a reported release version, not executable authenticity. */
export const providerReleaseVerificationSchema = z
.strictObject({
status: z.enum(["verified", "unverified"]),
basis: z.literal("reported_version"),
reported_version: z.string().min(1),
verified_version: z.string().min(1),
explanation: z.string().min(1),
remediation: z.string().min(1).nullable(),
})
.describe(
"Provider-reported release version comparison at binding, not a fresh executable check; this does not authenticate bytes or prove target correctness. Omission means verification was not reported.",
);
export type ProviderReleaseVerification = z.infer<
typeof providerReleaseVerificationSchema
>;
+1 -1
View File
@@ -69,7 +69,7 @@ const sharedAnalysisSemantics = {
procedure_file_offset_resolution:
"address-first-unique-target-source-mapping-v2",
instruction_flow_evidence: "decoded-return-pcode-v1",
instruction_status: "unlisted-address-without-decode-v1",
instruction_status: "unlisted-and-containing-code-data-v3",
instruction_references: "exact-address-incoming-without-synthetic-entry-v3",
function_boundary_observations: "ghidra-function-boundary-limitations-v4",
} as const;
+7 -1
View File
@@ -522,9 +522,15 @@ describe("Ghidra compact reference provenance", () => {
it("distinguishes incoming references, examined emptiness and unavailable coverage at data addresses", () => {
const instruction = {
...compactInstruction(),
address: "ram:00402000",
address: "ram:0x402000",
status: "data",
procedure: null,
containing_data: {
address: "ram:0x402000",
length: 4,
type_id: "/dword",
provenance: "ghidra-listing",
},
};
const reference = {
...compactReference(),
+47 -1
View File
@@ -1,5 +1,6 @@
import { GHIDRA_FUNCTION_OPERATIONS } from "./GhidraFunctionOperations.js";
import { nativeAddressObservationSchema } from "../domain/native/nativeAddress.js";
import { addressDistance } from "../domain/native/addressCoordinate.js";
import { procedureInfoSchema } from "../domain/native/procedureInfo.js";
import { z } from "zod";
import {
@@ -288,6 +289,51 @@ const ghidraFunctionInstructionWindow = functionInstructionWindowSchema
.extend({ procedure: procedureIdentity })
.strict();
// Current Ghidra profiles commit to emitting containment exactly for an interior
// listing address. The shared Evidence schema remains optional for older records.
const ghidraInstruction = nativeInstructionSchema.superRefine(
(value, context) => {
if (
(value.status === "not-instruction-boundary") !==
(value.containing_instruction !== undefined)
)
context.addIssue({
code: "custom",
path: ["containing_instruction"],
message:
"Ghidra instruction containment must match the reported status",
});
if ((value.status === "data") !== (value.containing_data !== undefined))
context.addIssue({
code: "custom",
path: ["containing_data"],
message: "Ghidra data containment must match the reported status",
});
const data = value.containing_data;
if (data !== undefined) {
const distance = addressDistance(data.address, value.address);
if (distance === null || distance >= data.length)
context.addIssue({
code: "custom",
path: ["containing_data", "address"],
message:
"Ghidra containing data must enclose the queried byte in the same address space",
});
}
const containing = value.containing_instruction;
if (containing !== undefined) {
const distance = addressDistance(containing.address, value.address);
if (distance === null || distance === 0 || distance >= containing.length)
context.addIssue({
code: "custom",
path: ["containing_instruction", "address"],
message:
"Ghidra containing instruction must enclose the queried byte in the same address space",
});
}
},
);
const resultSchemas = {
annotate_native_function: nativeFunctionAnnotationsSchema.safeExtend({
dossier: ghidraFunctionDossier,
@@ -299,7 +345,7 @@ const resultSchemas = {
procedure_info: procedureInfo,
procedure_pseudo_code: z.string().nullable(),
read_function_instructions: ghidraFunctionInstructionWindow,
inspect_native_instruction: nativeInstructionSchema,
inspect_native_instruction: ghidraInstruction,
inspect_native_data_type: nativeDataTypeSchema,
resolve_native_call_targets: nativeCallTargetsSchema,
procedure_references: procedureReferences,
+12 -6
View File
@@ -1315,6 +1315,8 @@ describe("Ghidra result projection", () => {
["invalid_request", "AnalysisInputError"],
["not_found", "AnalysisInputError"],
["ambiguous", "AnalysisInputError"],
["file_offset_ambiguous", "AnalysisInputError"],
["procedure_ambiguous", "AnalysisInputError"],
["method_unavailable", "AnalysisCapabilityUnavailableError"],
] as const)(
"projects remote %s without losing its typed meaning",
@@ -3679,13 +3681,19 @@ describe("Ghidra empty instruction reference facets", () => {
...compactInstruction(outgoing),
incoming_references: incoming,
});
const nonDecoded = (status: JsonValue, incoming: JsonValue[]) => ({
const definedData = (incoming: JsonValue[]) => ({
...compactInstruction(),
status,
status: "data",
bytes: null,
length: null,
mnemonic: null,
raw_disassembly: null,
containing_data: {
address: "0x401000",
length: 1,
type_id: "/undefined1",
provenance: "ghidra-listing",
},
incoming_references: incoming,
});
const executeInspect = async (
@@ -3736,15 +3744,13 @@ describe("Ghidra empty instruction reference facets", () => {
});
it("keeps only the address caveat when a data address resolves no incoming references", async () => {
const limitations = await executeInspect(nonDecoded("data", []));
const limitations = await executeInspect(definedData([]));
expect(limitations).toContain(ghidraEmptyXrefsLimitation);
expect(limitations).not.toContain(ghidraEmptyInstructionOutgoingLimitation);
});
it("adds no caveat for a data address whose incoming references were observed", async () => {
const limitations = await executeInspect(
nonDecoded("data", [compactReference()]),
);
const limitations = await executeInspect(definedData([compactReference()]));
expect(limitations).not.toContain(ghidraEmptyXrefsLimitation);
expect(limitations).not.toContain(ghidraEmptyInstructionOutgoingLimitation);
});
+3
View File
@@ -234,6 +234,9 @@ export const CAPABILITIES: readonly CapabilityDescriptor[] = Object.freeze(
return Object.freeze({
provider: GHIDRA_PROVIDER_IDENTITY,
operation,
...(operation === "analyze_function"
? { nativeValueFlow: { status: "supported" as const } }
: {}),
available: true,
reason: null,
// Project writes are private import machinery. Analysis reads are bound
+1 -1
View File
@@ -1376,7 +1376,7 @@ const projectPrimarySessionError = (
break;
case "file_offset_ambiguous":
recovery =
"Choose an explicit analysis address from the reported mappings. Use inspect_native_load_image to inspect the loaded mappings; REA does not select one automatically.";
"Choose a space-qualified analysis address from the reported mappings, such as ram:0x1000. A bare 0x... selector falls back to file-offset lookup and can report this ambiguity again. Use inspect_native_load_image to inspect the loaded mappings and Ghidra's observed default address space; REA does not select one automatically.";
break;
case "procedure_ambiguous":
recovery =
+5 -3
View File
@@ -11,9 +11,9 @@ import { GhidraProvider } from "./GhidraProvider.js";
// bit-field base-type and inventory-search revisions that prevent reuse of snapshots with earlier
// results.
const RAW_DIGESTS = {
"6502": "a4852d16c3239bce7ac9182977ea56864bfcb4319fa38750947be5e2cb0e9ea4",
"6502": "d36ab0327d6063d0acb78c18b1037a73a13886b0a6a147218e58a7d6b851ccae",
"x86-disabled-analyzer":
"cf72eabf031fcf1f082fb87a5cbcd7285a01f4ca5368952d0bd3f57c4db1f87a",
"39c710586b830f1f417a20b184dd0a3f007445704c5be10d7296ac46934306c0",
} as const;
const configFor = (environment: NodeJS.ProcessEnv) => {
@@ -48,10 +48,12 @@ describe("raw-binary analysis profile digest", () => {
load_image_evidence: "independent-raw-mapping-context-v4",
load_image_observations:
"source-mappings-entry-context-relocation-symbol-v3",
instruction_status: "unlisted-address-without-decode-v1",
instruction_status: "unlisted-and-containing-code-data-v3",
inventory_search_projection: "provider-inventory-facts-v1",
byte_search_boundary: "contiguous-eligible-initialized-blocks-v1",
data_type_observations: "bit-field-base-and-lookup-v1",
instruction_references:
"exact-address-incoming-without-synthetic-entry-v3",
analyzer_preset: "ghidra-default",
});
});
+6 -1
View File
@@ -324,7 +324,12 @@ describe("Ghidra snapshot profile admission", () => {
},
{
parameter: "instruction_status",
current: "unlisted-address-without-decode-v1",
current: "unlisted-and-containing-code-data-v3",
legacy: "unlisted-address-without-decode-v1",
},
{
parameter: "instruction_status",
current: "unlisted-and-containing-code-data-v3",
legacy: undefined,
},
{
+2 -1
View File
@@ -20,6 +20,7 @@ import {
KUNA_COMMAND_REMEDIATION,
KUNA_PROVIDER_ID,
KUNA_UNSUPPORTED_HOST_REMEDIATION,
KUNA_UNVERIFIED_VERSION_REMEDIATION,
KUNA_VERIFIED_VERSION,
KUNA_VERSION_PROBE_REMEDIATION,
} from "./KunaRelease.js";
@@ -140,7 +141,7 @@ export const inspectKunaProvider = async (
const cache = await cacheCheck(environment, platform);
const versionGuidance = value.verified
? null
: `To use the verified release, select Kuna ${KUNA_VERIFIED_VERSION} by setting REA_KUNA_COMMAND to its absolute executable path in the environment that starts REA, then restart REA. You may continue with the selected unverified release.`;
: KUNA_UNVERIFIED_VERSION_REMEDIATION;
return {
id: KUNA_PROVIDER_ID,
configured,
+19 -1
View File
@@ -42,7 +42,13 @@ import {
import { KunaProjectCache } from "./KunaProjectCache.js";
import { kunaCapabilities } from "./KunaProviderCapabilities.js";
import { KUNA_PROVIDER_IDENTITY } from "./KunaProviderMetadata.js";
import { KUNA_COMMAND_REMEDIATION, KUNA_PROVIDER_ID } from "./KunaRelease.js";
import {
KUNA_COMMAND_REMEDIATION,
KUNA_PROVIDER_ID,
KUNA_RELEASE_VERIFICATION_EXPLANATION,
KUNA_UNVERIFIED_VERSION_REMEDIATION,
KUNA_VERIFIED_VERSION,
} from "./KunaRelease.js";
import { KunaRunSlots } from "./KunaRunSlots.js";
import { KunaSessionClient } from "./KunaSessionClient.js";
import { kunaUnavailable } from "./KunaUnavailable.js";
@@ -258,6 +264,18 @@ export class KunaProvider implements AnalysisProviderCandidate {
}
const { value } = installation;
return ok({
releaseVerification: {
status: value.verified
? ("verified" as const)
: ("unverified" as const),
basis: "reported_version" as const,
reported_version: value.version,
verified_version: KUNA_VERIFIED_VERSION,
explanation: KUNA_RELEASE_VERIFICATION_EXPLANATION,
remediation: value.verified
? null
: KUNA_UNVERIFIED_VERSION_REMEDIATION,
},
profile: createAnalysisProfile(
{ ...KUNA_PROVIDER_IDENTITY, version: value.version },
{
+11
View File
@@ -29,6 +29,9 @@ export const KUNA_STRING_LIMITATION =
export const KUNA_XREF_LIMITATION =
"Kuna's static reference walk unifies forwarding aliases and omits references internal to that alias class. REA keeps only exact-address incoming edges; an empty result does not prove no reference exists.";
export const KUNA_VALUE_FLOW_UNAVAILABLE =
"REA's Kuna provider does not report native def-use observations.";
/** Whether an operation is one Kuna answers. */
export const isKunaOperation = (
operation: AnalysisOperation,
@@ -104,6 +107,14 @@ export const kunaCapabilities = (
KUNA_OPERATIONS.map((operation) => ({
provider: KUNA_PROVIDER_IDENTITY,
operation,
...(operation === "analyze_function"
? {
nativeValueFlow: {
status: "unavailable" as const,
reason: KUNA_VALUE_FLOW_UNAVAILABLE,
},
}
: {}),
...(operation === "procedure_pseudo_code"
? { independentDecompile: true as const }
: {}),
+7
View File
@@ -1,6 +1,13 @@
/** Kuna release whose CLI and project layout REA verified; others report as unverified. */
export const KUNA_VERIFIED_VERSION = "1.990";
/** Scope of the version comparison, shared by session output and verification. */
export const KUNA_RELEASE_VERIFICATION_EXPLANATION =
"At selection, REA compared the reported version with its baseline, checked on macOS arm64. This does not authenticate or recheck the executable or guarantee support or results on this host.";
/** Optional selection guidance; the current unverified release remains usable. */
export const KUNA_UNVERIFIED_VERSION_REMEDIATION = `To use the verified release, select Kuna ${KUNA_VERIFIED_VERSION} by setting REA_KUNA_COMMAND to its absolute executable path in the environment that starts REA, then restart REA. You may continue with the selected unverified release.`;
/** Stable provider ID shared by readiness, doctor and Evidence. */
export const KUNA_PROVIDER_ID = "kuna";
+2 -5
View File
@@ -86,6 +86,7 @@ import {
isKunaOperation,
KUNA_RELATIONSHIP_LIMITATION,
KUNA_SINGLE_FUNCTION_LIMITATION,
KUNA_VALUE_FLOW_UNAVAILABLE,
kunaLimitations,
type KunaOperation,
} from "./KunaProviderCapabilities.js";
@@ -346,10 +347,6 @@ const kunaVerificationLimitations = (
/** Kuna names a procedure by its inventory entry or by an exact entry address. */
const procedureSelectorSchema = z.string().min(1);
/** REA's Kuna integration does not supply a def-use model for any procedure. */
export const KUNA_NATIVE_VALUE_FLOW_UNAVAILABLE =
"REA's Kuna provider does not report native def-use observations.";
/**
* One Kuna analysis session for one target.
*
@@ -1733,7 +1730,7 @@ export class KunaSessionClient implements AnalysisClient {
native_api: null,
native_value_flow: {
available: false,
reason: KUNA_NATIVE_VALUE_FLOW_UNAVAILABLE,
reason: KUNA_VALUE_FLOW_UNAVAILABLE,
limitations: [],
},
facet_availability: {
@@ -101,6 +101,7 @@ it("preserves sibling operations, sibling reasons, and remediation action when r
{
userMessage: "Export failed",
remediationAction: "Retry without project requirement",
fallbackRemediationAction: "Review provider choices",
siblingOperations: ["procedure_assembly", "analyze_function"],
siblingReasons: {
analyze_function: "analyze_function export failed",
@@ -126,6 +127,7 @@ it("preserves sibling operations, sibling reasons, and remediation action when r
expect(attributed.remediationAction).toBe(
"Retry without project requirement",
);
expect(attributed.fallbackRemediationAction).toBe("Review provider choices");
expect(attributed.userMessage).toBe("Export failed");
expect(attributed.retryable).toBe(true);
});
+3
View File
@@ -231,6 +231,9 @@ export const analysisClientFailureForOperation = (
...(cause.remediationAction === undefined
? {}
: { remediationAction: cause.remediationAction }),
...(cause.fallbackRemediationAction === undefined
? {}
: { fallbackRemediationAction: cause.fallbackRemediationAction }),
...(cause.siblingOperations === undefined
? {}
: { siblingOperations: cause.siblingOperations }),
@@ -89,6 +89,9 @@ it("summary delivery returns the compact view and writes a bundle inspect-analys
evidence_bundle: { path: string; bytes: number };
};
expect(h.runDirectAnalysis).toHaveBeenCalledOnce();
expect(h.runDirectAnalysis.mock.calls[0]?.[3]).toMatchObject({
includeEvidenceSources: true,
});
expect(out.summary.normalized_result.kind).toBe("summary");
expect(typeof out.summary.normalized_result.parent_evidence_id).toBe(
"string",
@@ -1,8 +1,10 @@
import { EvidenceIntegrityError } from "../../../src/domain/evidenceErrors.js";
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import type { AnalysisError } from "../../../src/domain/analysisErrorBase.js";
import { projectCliAnalysisResult } from "../../../src/cli/projectCliAnalysisResult.js";
import { publishCliAnalysisEvidence } from "../../../src/cli/analysisEvidenceDelivery.js";
import { parseConfig } from "../../../src/config/parseConfig.js";
import { readFile, symlink, writeFile } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
@@ -159,6 +161,7 @@ interface SnapshotProviderOptions {
readonly supportedOperations?: readonly CapabilityDescriptor["operation"][];
readonly limitations?: readonly string[];
readonly sourceEvidence?: boolean;
readonly sourceMissingLink?: boolean;
}
const makeProvider = (
@@ -263,6 +266,9 @@ const makeProvider = (
operation: "health",
parameters: {},
result: "fixture source",
...(options.sourceMissingLink
? { evidenceLinks: [`ev_${"0".repeat(64)}`] }
: {}),
}),
],
}
@@ -2036,7 +2042,10 @@ it("replays source-bearing direct analysis only while snapshot source Evidence i
});
const dependencies = fixture.dependencies(provider);
const parameters = { procedure: "0x1000", document: "fixture" };
const options = { snapshotPath: fixture.snapshotPath };
const options = {
snapshotPath: fixture.snapshotPath,
includeEvidenceSources: true,
};
const run = () =>
runDirectAnalysis(
dependencies,
@@ -2044,8 +2053,12 @@ it("replays source-bearing direct analysis only while snapshot source Evidence i
"procedure_pseudo_code",
parameters,
options,
).then(projectCliAnalysisResult);
const first = parseEvidence(await run());
);
const firstResult = await run();
const first = parseEvidence(projectCliAnalysisResult(firstResult));
expect(
firstResult.sourceEvidence?.map((record) => record.evidence_id),
).toEqual(first.evidence_links);
expect(first.evidence_links).toHaveLength(1);
const saved = await readAnalysisSnapshot(fixture.snapshotPath);
if (!saved.ok) throw saved.error;
@@ -2057,9 +2070,30 @@ it("replays source-bearing direct analysis only while snapshot source Evidence i
),
).toBe(true);
const initialStarts = fixture.starts.length;
expect(parseEvidence(await run()).evidence_links).toEqual(
const replayed = await run();
expect(
parseEvidence(projectCliAnalysisResult(replayed)).evidence_links,
).toEqual(first.evidence_links);
expect(replayed.sourceEvidence?.map((record) => record.evidence_id)).toEqual(
first.evidence_links,
);
const outputPath = join(
dirname(fixture.snapshotPath),
"replayed-evidence.json",
);
await publishCliAnalysisEvidence(replayed, {
providerId: IDENTITY.id,
operation: "procedure_pseudo_code",
signal: new AbortController().signal,
detail: "complete",
evidenceOutput: outputPath,
});
const published = JSON.parse(await readFile(outputPath, "utf8")) as {
records: Evidence[];
};
expect(published.records.map((record) => record.evidence_id).sort()).toEqual(
[first.evidence_id, ...first.evidence_links].sort(),
);
expect(fixture.starts).toHaveLength(initialStarts);
const withoutSource = {
@@ -2074,7 +2108,11 @@ it("replays source-bearing direct analysis only while snapshot source Evidence i
writeAnalysisSnapshot(withoutSource, fixture.snapshotPath, true, resources),
);
if (!written.ok) throw written.error;
const refreshed = parseEvidence(await run());
const refreshedResult = await run();
const refreshed = parseEvidence(projectCliAnalysisResult(refreshedResult));
expect(
refreshedResult.sourceEvidence?.map((record) => record.evidence_id),
).toEqual(first.evidence_links);
expect(fixture.starts).toHaveLength(initialStarts + 1);
expect(refreshed.evidence_links).toEqual(first.evidence_links);
const repaired = await readAnalysisSnapshot(fixture.snapshotPath);
@@ -2086,6 +2124,146 @@ it("replays source-bearing direct analysis only while snapshot source Evidence i
).toBe(true);
});
it("preserves captured sources when a transitive source is missing", async () => {
const fixture = await createSnapshotReplayFixture(
"rea-direct-partial-source-",
);
const provider = fixture.createProvider({
supportedOperations: ["procedure_pseudo_code"],
sourceEvidence: true,
sourceMissingLink: true,
});
const result = await runDirectAnalysis(
fixture.dependencies(provider),
fixture.path,
"procedure_pseudo_code",
{ procedure: "0x1000", document: "fixture" },
{ includeEvidenceSources: true },
);
expect(result.ok).toBe(false);
expect(result.sourceEvidence).toHaveLength(1);
const outputPath = join(dirname(fixture.snapshotPath), "missing-source.json");
const output = await publishCliAnalysisEvidence(result, {
providerId: IDENTITY.id,
operation: "procedure_pseudo_code",
signal: new AbortController().signal,
detail: "complete",
evidenceOutput: outputPath,
});
const failure = output as {
details: {
source_closure: {
records: Evidence[];
omitted: { evidence_id: string }[];
complete: boolean;
};
};
};
expect(failure.details.source_closure.complete).toBe(false);
expect(
failure.details.source_closure.records.map((record) => record.evidence_id),
).toEqual(result.sourceEvidence?.map((record) => record.evidence_id));
expect(failure.details.source_closure.omitted).toEqual([
{ evidence_id: `ev_${"0".repeat(64)}`, reason: "not_captured" },
]);
await expect(readFile(outputPath)).rejects.toMatchObject({ code: "ENOENT" });
});
it("retains completed function sources when cancellation arrives as execution returns", async () => {
const fixture = await createSnapshotReplayFixture(
"rea-direct-cancel-source-",
);
const provider = fixture.createProvider({
supportedOperations: ["analyze_function"],
sourceEvidence: true,
});
const session = createTestBinarySession(provider);
const execute = session.execute.bind(session);
const controller = new AbortController();
session.execute = async (...args) => {
const result = await execute(...args);
if (args[0] === "analyze_function") controller.abort();
return result;
};
const dependencies = {
...fixture.dependencies(provider),
createBinarySession: () => session,
};
const result = await runDirectAnalysis(
dependencies,
fixture.path,
"analyze_function",
{ procedure: "0x1000" },
{ signal: controller.signal, includeEvidenceSources: true },
);
expect(result.ok).toBe(false);
const outputPath = join(dirname(fixture.snapshotPath), "cancelled.json");
const output = await publishCliAnalysisEvidence(result, {
providerId: IDENTITY.id,
operation: "analyze_function",
signal: controller.signal,
detail: "complete",
evidenceOutput: outputPath,
});
const failure = output as {
code: string;
details: {
partial_observation: Evidence;
source_closure: { records: Evidence[]; complete: boolean };
};
};
expect(failure.code).toBe("cancelled");
expect(failure.details.source_closure.complete).toBe(true);
expect(
failure.details.source_closure.records.map((record) => record.evidence_id),
).toEqual(failure.details.partial_observation.evidence_links);
await expect(readFile(outputPath)).rejects.toMatchObject({ code: "ENOENT" });
});
it("retains function sources when completed parent admission fails", async () => {
const fixture = await createSnapshotReplayFixture(
"rea-direct-parent-admission-",
);
const provider = fixture.createProvider({
supportedOperations: ["analyze_function"],
sourceEvidence: true,
});
const session = createTestBinarySession(provider);
session.importEvidenceBundle = () =>
err(new EvidenceIntegrityError("controlled parent admission failure"));
const dependencies = {
...fixture.dependencies(provider),
createBinarySession: () => session,
};
const result = await runDirectAnalysis(
dependencies,
fixture.path,
"analyze_function",
{ procedure: "0x1000" },
{ includeEvidenceSources: true },
);
expect(result.ok).toBe(false);
const outputPath = join(dirname(fixture.snapshotPath), "refused.json");
const output = await publishCliAnalysisEvidence(result, {
providerId: IDENTITY.id,
operation: "analyze_function",
signal: new AbortController().signal,
detail: "complete",
evidenceOutput: outputPath,
});
const failure = output as {
details: {
partial_observation: Evidence;
source_closure: { records: Evidence[]; complete: boolean };
};
};
expect(failure.details.source_closure.complete).toBe(true);
expect(
failure.details.source_closure.records.map((record) => record.evidence_id),
).toEqual(failure.details.partial_observation.evidence_links);
await expect(readFile(outputPath)).rejects.toMatchObject({ code: "ENOENT" });
});
it("runs decompile export against the provider after loading a pseudocode snapshot", async () => {
const fixture = await createSnapshotReplayFixture("rea-directory-snapshot-");
const original = fixture.createProvider({
@@ -592,6 +592,63 @@ it.each(["interrupt", "cleanup-failure", "interrupt-cleanup-failure"] as const)(
},
);
it.each(["interrupt", "cleanup-failure"] as const)(
"retains captured source closure when direct finalization fails: %s",
async (scenario) => {
const workflow = await composedWorkflowFixture();
const controller = new AbortController();
const close = workflow.session.close.bind(workflow.session);
workflow.session.close = async () => {
workflow.session.close = close;
if (scenario === "interrupt") controller.abort();
const closed = await close();
if (scenario === "interrupt" || !closed.ok) return closed;
return err(
new ProviderCleanupError(
"fixture",
["owned-root"],
{ reason: "controlled session close failure" },
{ operation: "close_binary" },
),
);
};
const root = await createTestTempDirectory("rea-closure-direct-captured-");
const complete = await runDirectAnalysis(
workflow.dependencies,
workflow.path,
"binary_overview",
{},
{ signal: controller.signal, includeEvidenceSources: true },
);
const output = await publishCliAnalysisEvidence(complete, {
...delivery(root),
signal: controller.signal,
});
const failure = output as {
code: string;
details: { partial_observation: unknown };
};
expect(failure.code).toBe(
scenario === "interrupt" ? "cancelled" : "cleanup_incomplete",
);
const links = parseEvidence(
failure.details.partial_observation,
).evidence_links;
expect(links.length).toBeGreaterThan(0);
expect(closureOf(output)).toMatchObject({
publication: "not_published",
complete: true,
omitted_count: 0,
});
expect(
closureOf(output)
.records.map((record) => record.evidence_id)
.sort(),
).toEqual([...links].sort());
expect(await readdir(root)).toEqual([]);
},
);
it("reports through the shared one-shot delivery when cancellation arrives during close", async () => {
const root = await createTestTempDirectory("rea-closure-deliver-");
const held = source("held");
+28 -32
View File
@@ -52,7 +52,7 @@ it("finds and retrieves the selected section from the generated package bundle",
}
});
it("separates focused installed guidance from terms spread across a broad parent", async () => {
it("finds focused installed guidance for switching providers with annotations", async () => {
const resources = new ArtifactResourceScope();
try {
const focused = await searchGuidance(
@@ -67,40 +67,36 @@ it("separates focused installed guidance from terms spread across a broad parent
match_scope: "section",
});
const terms = ["switch", "provider", "annotations"];
const scattered = await searchGuidance(
{ query: terms.join(" "), limit: 20 },
const switched = await searchGuidance(
{ query: "switch provider annotations", limit: 20 },
resources,
);
if (!scattered.ok) throw scattered.error;
if (scattered.value.status !== "matches")
if (!switched.ok) throw switched.error;
if (switched.value.status !== "matches")
throw new Error("Expected guidance matches");
let checkedScattered = 0;
for (const match of scattered.value.matches) {
const read = await readGuidanceSection(
{ locator: match.locator },
resources,
);
if (!read.ok || read.value.status !== "section")
throw new Error("Expected a readable section");
expect(read.value.content).toHaveLength(match.content_length);
if (match.match_scope === "section") continue;
checkedScattered += 1;
const pointers = match.subsections ?? [];
expect(pointers.length).toBeGreaterThan(0);
for (const pointer of pointers)
expect(
terms.every((term) => pointer.matched_terms.includes(term)),
).toBe(false);
// Every term is either in the parent's own text or behind a pointer.
const located = new Set([
...(match.own_text_terms ?? []),
...pointers.flatMap((pointer) => pointer.matched_terms),
]);
expect(terms.filter((term) => !located.has(term))).toEqual([]);
}
// These terms sit in different subsections of the installed guides.
expect(checkedScattered).toBeGreaterThan(0);
const match = switched.value.matches.find(
({ heading }) => heading === "Function annotations in Ghidra",
);
expect(match).toMatchObject({
guide: "references/native-and-artifacts.md",
match_scope: "section",
matched_terms: expect.arrayContaining([
"switch",
"provider",
"annotations",
]),
});
if (match === undefined) throw new Error("Expected Ghidra guidance");
const read = await readGuidanceSection(
{ locator: match.locator },
resources,
);
if (!read.ok) throw read.error;
expect(read.value).toMatchObject({
status: "section",
heading: match.heading,
content: expect.stringContaining("discard"),
});
} finally {
await resources.close();
}
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it } from "vitest";
import type { AnalysisOperationPort } from "../../../src/application/AnalysisProvider.js";
import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js";
import { EnhancedTools } from "../../../src/application/EnhancedTools.js";
import { TRACE_FEATURE_REFERENCE_LIMITATION } from "../../../src/application/WorkflowEvidence.js";
import {
AnalysisCapabilityUnavailableError,
AnalysisOutputError,
@@ -137,7 +138,10 @@ const trace = async (analysis: AnalysisOperationPort) => {
arguments: { query: "hello" },
});
expect(result.isError).not.toBe(true);
return result.structuredContent as { normalized_result: unknown };
return result.structuredContent as {
normalized_result: unknown;
limitations: string[];
};
};
it("reports known reference-discovery gaps without claiming truncation or a missing edge", async () => {
@@ -430,6 +434,18 @@ it("retains incomplete cleanup when cancellation arrives during owner lookup", a
});
describe("trace_feature reference coverage", () => {
it("reports the address-only scope when a match has no references", async () => {
const output = await trace(provider({ emptyXrefsOf: "0x10" }));
expect(output.normalized_result).toEqual(expectedTrace([], [], ["0x10"]));
expect(output.limitations).toContain(TRACE_FEATURE_REFERENCE_LIMITATION);
expect(TRACE_FEATURE_REFERENCE_LIMITATION).toMatch(/start address/);
expect(TRACE_FEATURE_REFERENCE_LIMITATION).toMatch(
/interior bytes of strings and procedures/,
);
expect(TRACE_FEATURE_REFERENCE_LIMITATION).toMatch(/truncated: false/);
});
it("marks the trace truncated for the target whose references the provider capped", async () => {
const output = await trace(
provider({ coverage: { "0x10": true, "0x11": false } }),
@@ -0,0 +1,103 @@
import { expect, it } from "vitest";
import { err } from "../../../src/domain/result.js";
import { GhidraSessionError } from "../../../src/ghidra/GhidraSessionError.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { connectGhidraMcp } from "./ghidraMcpHarness.js";
it.each(["compact", "full"] as const)(
"preserves actionable Ghidra procedure ambiguity over MCP (%s)",
async (profile) => {
const message =
"Ghidra procedure name is ambiguous: shared; select an exact entry address from [0x1000, 0x2000]";
const harness = await connectGhidraMcp(
"ambiguous-ghidra-procedure",
() =>
Promise.resolve(
err(
new GhidraSessionError(
"remote",
message,
{},
{ remoteCode: "procedure_ambiguous" },
),
),
),
{ environment: { REA_MCP_OUTPUT_SCHEMA_PROFILE: profile } },
);
try {
const response = await harness.mcp.callTool({
name: "analyze_function",
arguments: { procedure: "shared" },
});
const { error } = parseMcpToolError(response);
expect(error).toMatchObject({
code: "invalid_request",
message,
remediation: {
action: expect.stringContaining("explicit procedure entry address"),
},
});
expect(error.recovery).toBeUndefined();
const malformed = await harness.mcp.callTool({
name: "analyze_function",
arguments: { procedure: 42 },
});
expect(parseMcpToolError(malformed).error).toMatchObject({
code: "invalid_request",
recovery: { kind: "inspect_input_contract" },
details: {
issues: [
expect.objectContaining({
path: ["procedure"],
reason: "invalid_type",
}),
],
},
});
} finally {
await harness.close();
}
},
);
it.each(["compact", "full"] as const)(
"keeps file-offset mapping ambiguity distinct from function identity (%s)",
async (profile) => {
const message =
"File offset 0x400 has multiple loaded mappings: [0x1000, 0x2000]; select an explicit analysis address";
const harness = await connectGhidraMcp(
"ambiguous-ghidra-offset",
() =>
Promise.resolve(
err(
new GhidraSessionError(
"remote",
message,
{},
{ remoteCode: "file_offset_ambiguous" },
),
),
),
{ environment: { REA_MCP_OUTPUT_SCHEMA_PROFILE: profile } },
);
try {
const response = await harness.mcp.callTool({
name: "procedure_pseudo_code",
arguments: { procedure: "file-offset:0x400" },
});
const { error } = parseMcpToolError(response);
expect(error).toMatchObject({
code: "invalid_request",
message,
remediation: {
action: expect.stringContaining("ram:0x1000"),
},
});
expect(error.remediation.action).toContain("inspect_native_load_image");
expect(error.remediation.action).toContain("bare 0x");
expect(error.recovery).toBeUndefined();
} finally {
await harness.close();
}
},
);
@@ -68,6 +68,12 @@ describe("compact Ghidra reference MCP evidence", () => {
inspect_native_instruction: {
...compactInstruction(),
status: "data",
containing_data: {
address: "0x401000",
length: 4,
type_id: "/dword",
provenance: "ghidra-listing",
},
incoming_references: [],
},
resolve_native_call_targets: compactCallTargets(),
@@ -0,0 +1,244 @@
import { expect, it } from "vitest";
import { ok } from "../../../src/domain/result.js";
import { compactInstruction } from "../../../src/domain/native/nativeInstruction.fixture.js";
import { connectGhidraMcp, sessionEvidence } from "./ghidraMcpHarness.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
it.each(["compact", "full"] as const)(
"keeps the selected interior address and containing instruction separate (%s)",
async (profile) => {
const observed = {
...compactInstruction(),
address: "overlay:0x1001",
status: "not-instruction-boundary",
procedure: null,
bytes: null,
length: null,
mnemonic: null,
raw_disassembly: null,
operands: [],
containing_instruction: {
address: "overlay:0x1000",
length: 4,
mnemonic: "sub",
raw_disassembly: "sub sp,sp,#0x10",
provenance: "ghidra-listing",
},
};
const harness = await connectGhidraMcp(
"ghidra-containing-instruction",
() => Promise.resolve(ok(observed)),
{ environment: { REA_MCP_OUTPUT_SCHEMA_PROFILE: profile } },
);
try {
const result = await harness.mcp.callTool({
name: "inspect_native_instruction",
arguments: { address: observed.address },
});
expect(result.isError).not.toBe(true);
expect(
sessionEvidence(harness.session, result.structuredContent)
.normalized_result,
).toMatchObject({
address: observed.address,
status: observed.status,
procedure: null,
bytes: null,
containing_instruction: observed.containing_instruction,
});
} finally {
await harness.close();
}
},
);
it.each(["missing", "unexpected"] as const)(
"rejects a %s containing instruction at the Ghidra boundary",
async (condition) => {
const observed = {
...compactInstruction(),
address: "0x1001",
status: condition === "missing" ? "not-instruction-boundary" : "decoded",
...(condition === "unexpected"
? {
containing_instruction: {
address: "0x1000",
length: 4,
mnemonic: "sub",
raw_disassembly: "sub sp,sp,#0x10",
provenance: "ghidra-listing",
},
}
: {}),
};
const harness = await connectGhidraMcp("ghidra-invalid-containment", () =>
Promise.resolve(ok(observed)),
);
try {
const result = await harness.mcp.callTool({
name: "inspect_native_instruction",
arguments: { address: "0x1001" },
});
expect(parseMcpToolError(result).error).toMatchObject({
code: "unreadable_output",
details: { operation: "inspect_native_instruction" },
});
} finally {
await harness.close();
}
},
);
it.each([
["different space", "other:0x1000"],
["instruction start", "overlay:0x1001"],
["past instruction", "overlay:0xff0"],
["invalid coordinate", "not-an-address"],
] as const)(
"rejects a containing instruction with %s",
async (_description, address) => {
const observed = {
...compactInstruction(),
address: "overlay:0x1001",
status: "not-instruction-boundary",
containing_instruction: {
address,
length: 4,
mnemonic: "sub",
raw_disassembly: "sub sp,sp,#0x10",
provenance: "ghidra-listing",
},
};
const harness = await connectGhidraMcp("ghidra-invalid-coordinate", () =>
Promise.resolve(ok(observed)),
);
try {
const result = await harness.mcp.callTool({
name: "inspect_native_instruction",
arguments: { address: observed.address },
});
expect(parseMcpToolError(result).error).toMatchObject({
code: "unreadable_output",
details: { operation: "inspect_native_instruction" },
});
} finally {
await harness.close();
}
},
);
it.each(["compact", "full"] as const)(
"retains a containing data identity without selecting a field (%s)",
async (profile) => {
const observed = {
...compactInstruction(),
address: "overlay:0x1002",
status: "data",
procedure: null,
bytes: null,
length: null,
mnemonic: null,
raw_disassembly: null,
containing_data: {
address: "overlay:0x1000",
length: 5,
type_id: "/Record",
provenance: "ghidra-listing",
},
};
const harness = await connectGhidraMcp(
"ghidra-containing-data",
() => Promise.resolve(ok(observed)),
{ environment: { REA_MCP_OUTPUT_SCHEMA_PROFILE: profile } },
);
try {
const result = await harness.mcp.callTool({
name: "inspect_native_instruction",
arguments: { address: observed.address },
});
expect(result.isError).not.toBe(true);
expect(
sessionEvidence(harness.session, result.structuredContent)
.normalized_result,
).toMatchObject({
address: observed.address,
status: "data",
containing_data: observed.containing_data,
});
} finally {
await harness.close();
}
},
);
it.each([
["missing", undefined],
[
"wrong space",
{
address: "other:0x1000",
length: 5,
type_id: "/Record",
provenance: "ghidra-listing",
},
],
[
"outside extent",
{
address: "overlay:0x1000",
length: 2,
type_id: "/Record",
provenance: "ghidra-listing",
},
],
] as const)("rejects %s data containment", async (_name, containing_data) => {
const observed = {
...compactInstruction(),
address: "overlay:0x1002",
status: "data",
...(containing_data === undefined ? {} : { containing_data }),
};
const harness = await connectGhidraMcp("ghidra-invalid-data", () =>
Promise.resolve(ok(observed)),
);
try {
const result = await harness.mcp.callTool({
name: "inspect_native_instruction",
arguments: { address: observed.address },
});
expect(parseMcpToolError(result).error).toMatchObject({
code: "unreadable_output",
details: { operation: "inspect_native_instruction" },
});
} finally {
await harness.close();
}
});
it("rejects data containment on a decoded instruction", async () => {
const observed = {
...compactInstruction(),
address: "0x1001",
containing_data: {
address: "0x1000",
length: 4,
type_id: "/Record",
provenance: "ghidra-listing",
},
};
const harness = await connectGhidraMcp("ghidra-unexpected-data", () =>
Promise.resolve(ok(observed)),
);
try {
const result = await harness.mcp.callTool({
name: "inspect_native_instruction",
arguments: { address: observed.address },
});
expect(parseMcpToolError(result).error).toMatchObject({
code: "unreadable_output",
details: { operation: "inspect_native_instruction" },
});
} finally {
await harness.close();
}
});
@@ -0,0 +1,176 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterAll, expect, it } from "vitest";
import type { AnalysisProvider } from "../../../src/application/AnalysisProvider.js";
import {
valueTraceDiamond,
valueTraceProvider,
} from "../../../src/application/native/NativeValueTrace.fixture.js";
import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { functionDossierSchema } from "../../../src/domain/native/functionDossier.js";
import { ok } from "../../../src/domain/result.js";
import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { advertisedMcpToolSchemas } from "../../fixtures/advertisedMcpTool.js";
import { createMcpSchemaValidation } from "../../fixtures/mcpSchemaValidation.js";
const schemaValidation = createMcpSchemaValidation();
afterAll(() => schemaValidation.clear());
it.each([
{ profile: "compact", state: "supported" },
{ profile: "full", state: "supported" },
{ profile: "compact", state: "unknown" },
{ profile: "full", state: "unknown" },
{ profile: "compact", state: "unavailable" },
{ profile: "full", state: "unavailable" },
] as const)(
"reports $state value flow without a resolver in $profile MCP",
async ({ profile, state }) => {
const target = join(
await createTestTempDirectory("flow-capability-"),
"fixture.com",
);
await writeFile(target, Buffer.from([0xb8, 0x00, 0x4c, 0xcd, 0x21]));
const graph = valueTraceDiamond({ sessionSubject: true });
const provider: AnalysisProvider = {
identity: () => valueTraceProvider,
capabilities: () => [
{
provider: valueTraceProvider,
operation: "analyze_function",
available: true,
reason: null,
...(state === "unknown"
? {}
: state === "supported"
? { nativeValueFlow: { status: "supported" as const } }
: {
nativeValueFlow: {
status: "unavailable" as const,
reason: "Fixture has no value flow.",
},
}),
effects: {
mutatesArtifact: false,
launchesProcess: false,
mayShowUi: false,
mayAccessNetwork: false,
mayWriteFilesystem: false,
changesPermissions: false,
requiresRoot: false,
},
limitations: [],
},
],
resolveAnalysisProfile: async () =>
ok({
profile: createAnalysisProfile(valueTraceProvider, { fixture: true }),
}),
createClient: () => ({
execute: async (operation, parameters, options) => {
const execution = await graph.port.execute(
operation,
parameters,
options,
);
if (
!execution.ok ||
state !== "unavailable" ||
operation !== "analyze_function"
)
return execution;
const dossier = functionDossierSchema.parse(execution.value.result);
return ok({
...execution.value,
result: {
...dossier,
native_value_flow: {
available: false,
reason: "Fixture has no value flow.",
limitations: [],
},
},
});
},
close: async () => ok(null),
}),
};
const session = createTestBinarySession(provider);
const { client } = await createApplicationMcpHarness({
session,
schemaValidator: schemaValidation,
serverOptions: {
environment: {
...process.env,
REA_MCP_INPUT_SCHEMA_PROFILE: profile,
REA_MCP_OUTPUT_SCHEMA_PROFILE: profile,
},
},
});
const call = async (name: string, args: Record<string, unknown>) => {
const schema = await advertisedMcpToolSchemas(
client,
name,
schemaValidation.assertions,
);
const reply = await client.callTool(
{ name, arguments: args },
{ toolDefinition: schema.toolDefinition },
);
expect(reply.isError, JSON.stringify(reply)).not.toBe(true);
expect(
schema.ajv.validate(schema.outputSchema, reply.structuredContent),
JSON.stringify(schema.ajv.errors),
).toBe(true);
return reply.structuredContent;
};
await call("open_binary", { path: target, format: "dos-com" });
const status = (await call("binary_session", { detail: "summary" })) as {
result: {
tool_availability: {
groups: Array<{
names: string[];
available: boolean;
value_flow_support?: { status: string; provider_id: string };
remediation: string | null;
}>;
};
};
};
const traceStatus = status.result.tool_availability.groups.find((g) =>
g.names.includes("trace_native_values"),
);
expect(traceStatus).toMatchObject({
available: false,
value_flow_support: { status: state, provider_id: "fixture" },
remediation: expect.any(String),
});
if (state === "unavailable")
expect(traceStatus?.remediation).toContain("Fixture has no value flow.");
else
expect(traceStatus?.remediation).toContain("resolve_native_call_targets");
const trace = parseEvidence(
await call("trace_native_values", { procedure: "0x1000", max_depth: 0 }),
);
const value = trace.normalized_result as {
total_nodes: number;
unknowns: Array<{ reason: string }>;
};
if (state === "unavailable") {
expect(value.total_nodes).toBe(0);
expect(value.unknowns).toContainEqual(
expect.objectContaining({ reason: "Fixture has no value flow." }),
);
} else {
expect(value.total_nodes).toBeGreaterThan(0);
expect(
value.unknowns.some((unknown) =>
unknown.reason.includes("resolve_native_call_targets"),
),
).toBe(true);
}
},
);
+335 -37
View File
@@ -1,6 +1,6 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { createHash } from "node:crypto";
import { rm, writeFile } from "node:fs/promises";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
@@ -24,6 +24,7 @@ import {
} from "../../../src/application/AnalysisProvider.js";
import { AnalysisProviderRegistry } from "../../../src/application/binary/AnalysisProviderRegistry.js";
import { composeBinarySession } from "../../../src/application/binary/BinarySessionComposition.js";
import { undeclaredOperationGuidance } from "../../../src/application/binary/BinarySessionExecution.js";
import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
import { analysisErrorRemediationAction } from "../../../src/domain/analysisErrorPresentation.js";
import { AnalysisSnapshotMismatchError } from "../../../src/domain/evidenceErrors.js";
@@ -48,12 +49,8 @@ import {
} from "../../../src/contracts/toolOutputSchemaPrimitives.js";
const resources: Array<{ close(): Promise<unknown> }> = [];
let directory: string | undefined;
afterEach(async () => {
await Promise.all(resources.splice(0).map((resource) => resource.close()));
if (directory !== undefined)
await rm(directory, { recursive: true, force: true });
directory = undefined;
});
it("delivers provider-owned diagnostic keys without narrowing their JSON facts", async () => {
@@ -107,9 +104,51 @@ it("delivers provider-owned diagnostic keys without narrowing their JSON facts",
expect(JSON.parse(text.text)).toEqual(response.structuredContent);
});
const expectUnknownProviderKeepsBinding = async (
mcp: Client,
session: ReturnType<typeof composeBinarySession>,
workspace: string,
target: string,
opened: Awaited<ReturnType<Client["callTool"]>>,
): Promise<void> => {
const unknown = await mcp.callTool({
name: "open_binary",
arguments: { path: target, provider_id: "missing" },
});
expect(unknown.isError).toBe(true);
const refusal = structured(unknown);
expect(refusal).toMatchObject({
error: {
details: {
selection_reason: "unknown_provider",
target_at_open_completion: structured(opened).result,
provider_id_at_open_completion: "beta",
},
},
});
expect(
structured(
await mcp.callTool({
name: "binary_session",
arguments: {},
}),
).result,
).toMatchObject({
analysis_provider_binding: { provider: { id: "beta" } },
});
await checkQueuedSwitchReceipt(
mcp,
session,
target,
workspace,
opened,
refusal,
);
};
describe("provider selection over MCP", () => {
it("keeps open_binary and binary_session binding semantics exact", async () => {
directory = await createTestTempDirectory("rea-provider-mcp-");
const directory = await createTestTempDirectory("rea-provider-mcp-");
const target = join(directory, "fixture.hop");
await writeFile(target, "fixture");
const starts: string[] = [];
@@ -199,6 +238,30 @@ describe("provider selection over MCP", () => {
});
expect(starts).toEqual(["beta"]);
const unsupported = await mcp.callTool({
name: "read_bytes",
arguments: { address: "0x1000", length: 4 },
});
expect(unsupported.isError).toBe(true);
expect(structured(unsupported)).toMatchObject({
error: {
code: "capability_unavailable",
message: expect.stringContaining("read_bytes"),
details: { provider_id: "beta", operation: "read_bytes" },
remediation: {
action: undeclaredOperationGuidance("read_bytes"),
},
},
});
const remediation = undeclaredOperationGuidance("read_bytes");
expect(parseMcpToolError(unsupported).error.message).not.toContain(
remediation,
);
expect(remediation).toMatch(
/binary_session.*complete.*open_binary.*provider_id/u,
);
expect(starts).toEqual(["beta"]);
const status = structured(
await mcp.callTool({
name: "binary_session",
@@ -223,44 +286,18 @@ describe("provider selection over MCP", () => {
],
});
const unknown = await mcp.callTool({
name: "open_binary",
arguments: { path: target, provider_id: "missing" },
});
expect(unknown.isError).toBe(true);
const refusal = structured(unknown);
expect(refusal).toMatchObject({
error: {
details: {
selection_reason: "unknown_provider",
target_at_open_completion: structured(opened).result,
provider_id_at_open_completion: "beta",
},
},
});
expect(
structured(
await mcp.callTool({
name: "binary_session",
arguments: {},
}),
).result,
).toMatchObject({
analysis_provider_binding: { provider: { id: "beta" } },
});
await checkQueuedSwitchReceipt(
await expectUnknownProviderKeepsBinding(
mcp,
session,
target,
directory,
target,
opened,
refusal,
);
});
});
it("offers admitted providers and retains unconfigured rejections", async () => {
directory = await createTestTempDirectory("rea-provider-mcp-mixed-");
const directory = await createTestTempDirectory("rea-provider-mcp-mixed-");
const target = join(directory, "fixture.hop");
await writeFile(target, "fixture");
const starts: string[] = [];
@@ -462,7 +499,7 @@ const connectDetailFixture = async (starts: string[]) => {
describe("binary_session detail over MCP", () => {
it("defaults to a summary that keeps every decision fact and names what complete detail adds", async () => {
directory = await createTestTempDirectory("rea-session-detail-mcp-");
const directory = await createTestTempDirectory("rea-session-detail-mcp-");
const target = join(directory, "fixture.hop");
await writeFile(target, "fixture");
const starts: string[] = [];
@@ -792,6 +829,171 @@ it("reports confirmed snapshot binding differences and keeps the active session"
});
});
it.each([true, false])(
"offers an entry-comment handoff only when the selected provider admits it (%s)",
async (entryCommentAvailable) => {
const directory = await createTestTempDirectory("rea-comment-recovery-");
const target = join(directory, "fixture.hop");
await writeFile(target, "fixture");
const starts: string[] = [];
const selected = candidate("beta", starts);
const identity = selected.identity();
const annotation = capability(identity);
const session = composeBinarySession(
new AnalysisProviderRegistry([
{
...selected,
capabilities: () => [
capability(identity),
entryCommentAvailable
? { ...annotation, operation: "annotate_native_function" }
: {
...annotation,
operation: "annotate_native_function",
available: false,
reason: "Selected analysis is read-only.",
availabilityCode: "open_options_invalid",
},
],
},
]),
);
const server = createServer(
{ kind: "session", session },
{ logger: silentLogger },
);
const mcp = new Client({ name: "comment-recovery", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
resources.push(mcp, server, { close: () => session.close() });
await server.connect(serverTransport);
await mcp.connect(clientTransport);
expect(
(
await mcp.callTool({
name: "open_binary",
arguments: { path: target, provider_id: "beta" },
})
).isError,
).not.toBe(true);
for (const [operation, field, flag] of [
["set_comment", "comment", "--comment"],
["set_inline_comment", "inline_comment", "--inline-comment"],
] as const) {
const reply = await mcp.callTool({
name: operation,
arguments: { address: "0x1000", comment: "note" },
});
const error = parseMcpToolError(reply).error;
expect(error.code).toBe("capability_unavailable");
expect(error.details).toMatchObject({ provider_id: "beta", operation });
expect(error.remediation.action).toBe(
undeclaredOperationGuidance(operation, entryCommentAvailable),
);
if (entryCommentAvailable) {
expect(error.remediation.action).toContain("annotate_native_function");
expect(error.remediation.action).toContain(field);
expect(error.remediation.action).toContain(flag);
} else
expect(error.remediation.action).not.toContain(
"annotate_native_function",
);
}
expect(starts).toEqual([]);
const after = structured(
await mcp.callTool({ name: "binary_session", arguments: {} }),
);
expect(after.result).toMatchObject({
analysis_provider_binding: { provider: { id: "beta" } },
});
},
);
it("keeps containing-function recovery for undeclared comment facets", async () => {
const directory = await createTestTempDirectory(
"rea-undeclared-comment-facet-",
);
const target = join(directory, "fixture.hop");
await writeFile(target, "fixture");
const starts: string[] = [];
const selected = candidate("beta", starts);
const identity = selected.identity();
const session = composeBinarySession(
new AnalysisProviderRegistry([
{
...selected,
capabilities: () => [
capability(identity),
{
...capability(identity),
operation: "resolve_containing_procedure",
},
],
createClient: (_target, profile) => ({
execute: async (operation) =>
ok(
createAnalysisExecution(
operation === "resolve_containing_procedure"
? {
query_address: "0x1001",
found: true,
procedure: {
address: "0x1000",
name: "entry",
classification: null,
body: { available: false, reason: "not recorded" },
},
}
: "entry",
profile?.provider ?? identity,
),
),
close: async () => ok(null),
}),
},
]),
);
const server = createServer(
{ kind: "session", session },
{ logger: silentLogger },
);
const mcp = new Client({ name: "undeclared-comment-facet", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
resources.push(mcp, server, { close: () => session.close() });
await server.connect(serverTransport);
await mcp.connect(clientTransport);
expect(
(
await mcp.callTool({
name: "open_binary",
arguments: { path: target, provider_id: "beta" },
})
).isError,
).not.toBe(true);
const reply = await mcp.callTool({
name: "inspect_address_context",
arguments: { address: "0x1001", document: "fixture" },
});
expect(reply.isError).not.toBe(true);
expect(structured(reply)).toMatchObject({
result: {
comment: {
state: "unavailable",
remediation: expect.stringContaining("inspect_analysis_view"),
},
inline_comment: {
state: "unavailable",
remediation: expect.stringContaining("inspect_analysis_view"),
},
bookmarks: {
state: "unavailable",
remediation: expect.stringContaining("open_binary"),
},
},
});
});
const candidate = (
id: string,
starts: string[],
@@ -875,6 +1077,100 @@ const structured = (result: CallToolResult): Record<string, unknown> =>
? parseMcpToolError(result)
: z.record(z.string(), z.unknown()).parse(result.structuredContent);
it.each(["full", "compact"] as const)(
"keeps provider-owned release verification bound to the selected session (%s)",
async (profile) => {
const directory = await createTestTempDirectory(
"rea-provider-verification-",
);
const target = join(directory, "fixture.hop");
await writeFile(target, "fixture");
const starts: string[] = [];
const alpha = candidate("alpha", starts);
const verification = {
status: "unverified" as const,
basis: "reported_version" as const,
reported_version: "1",
verified_version: "2",
explanation: "The reported version does not authenticate the executable.",
remediation:
"Continue with the selected version or choose another explicitly.",
};
const session = composeBinarySession(
new AnalysisProviderRegistry([
{
...alpha,
resolveAnalysisProfile: async (...args) => {
const resolved = await alpha.resolveAnalysisProfile(...args);
return resolved.ok
? ok({ ...resolved.value, releaseVerification: verification })
: resolved;
},
},
candidate("beta", starts),
]),
);
const server = createServer(
{ kind: "session", session },
{
logger: silentLogger,
environment: { REA_MCP_OUTPUT_SCHEMA_PROFILE: profile },
},
);
const mcp = new Client(
{ name: "provider-verification", version: "1" },
{
jsonSchemaValidator: new AjvJsonSchemaValidator(),
},
);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
resources.push(mcp, server, { close: () => session.close() });
await server.connect(serverTransport);
await mcp.connect(clientTransport);
const definitions = new Map(
(await mcp.listTools()).tools.map((tool) => [tool.name, tool]),
);
const call = (name: string, args: Record<string, unknown> = {}) => {
const definition = definitions.get(name);
if (definition === undefined) throw new Error(`Missing tool ${name}`);
return mcp.callTool(
{ name, arguments: args },
{ toolDefinition: definition },
);
};
expect(structured(await call("binary_session")).result).toMatchObject({
analysis_provider_binding: null,
});
expect(
(await call("open_binary", { path: target, provider_id: "alpha" }))
.isError,
).toBeFalsy();
for (const detail of ["summary", "complete"]) {
expect(
structured(await call("binary_session", { detail })).result,
).toMatchObject({
analysis_provider_binding: {
provider: { id: "alpha" },
release_verification: verification,
},
});
}
expect(
(await call("open_binary", { path: target, provider_id: "beta" }))
.isError,
).toBeFalsy();
const status = toolContract("binary_session").outputSchema.parse(
(await call("binary_session")).structuredContent,
);
expect(status.result.analysis_provider_binding?.provider.id).toBe("beta");
expect(status.result.analysis_provider_binding).not.toHaveProperty(
"release_verification",
);
expect((await call("close_binary")).isError).toBeFalsy();
},
);
const checkQueuedSwitchReceipt = async (
mcp: Client,
session: ReturnType<typeof composeBinarySession>,
@@ -995,7 +1291,9 @@ it("preserves a selected Ghidra project's read-only guidance when an edit is req
});
it("retains attributed mixed profile cleanup through registered MCP calls and partial retry", async () => {
directory = await createTestTempDirectory("rea-profile-completion-mcp-");
const directory = await createTestTempDirectory(
"rea-profile-completion-mcp-",
);
const path = join(directory, "fixture.hop");
await writeFile(path, "fixture");
const starts: string[] = [];
@@ -0,0 +1,211 @@
import { afterAll, expect, it } from "vitest";
import { Ajv2020 } from "ajv/dist/2020.js";
import type { AnySchema } from "ajv";
import type { Client } from "@modelcontextprotocol/client";
import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { createMcpSchemaValidation } from "../../fixtures/mcpSchemaValidation.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js";
import {
createEvidence,
parseEvidence,
type Evidence,
} from "../../../src/domain/evidence.js";
import { functionDossierSchema } from "../../../src/domain/native/functionDossier.js";
import { functionComparisonResultSchema } from "../../../src/domain/functionComparisonSchemas.js";
const example = FUNCTION_COMPARISON_EXAMPLE.left;
const dossier = functionDossierSchema.parse(example.normalized_result);
const { symbol: _symbol, ...procedure } = dossier.procedure;
const observation = (request: string) =>
createEvidence(
{ path: "/tmp/fixture", sha256: "0".repeat(64), format: "mach-o" },
example.provider,
{
operation: "analyze_function",
parameters: { procedure: request },
result: {
...dossier,
procedure,
comments: [],
facet_availability: {
...dossier.facet_availability,
comments: { available: false, reason: "Fixture has no comments." },
},
},
confidence: "derived",
authority: "shipped-artifact",
relationshipCoverage: { status: "complete" },
},
);
const main = observation("main");
const another = observation("another-selector");
const schemaValidation = createMcpSchemaValidation();
afterAll(() => schemaValidation.clear());
const ref = (evidence_id: string) => ({
kind: "retained-evidence",
evidence_id,
});
const expectRejectedDimensionCitation = async (
client: Client,
comparison: Evidence,
) => {
const original = functionComparisonResultSchema.parse(
comparison.normalized_result,
);
const altered = (dimension: (typeof original.dimensions)[number]) =>
dimension.dimension === "comments"
? {
...dimension,
evidence_links: [main.evidence_id, another.evidence_id],
}
: dimension;
const result = functionComparisonResultSchema.parse({
...original,
dimensions: original.dimensions.map(altered),
changes: original.changes.map(altered),
});
const invalid = createEvidence(undefined, comparison.provider, {
predicateType: comparison.predicate_type,
operation: comparison.operation,
parameters: comparison.parameters,
result,
confidence: comparison.confidence,
authority: comparison.authority,
limitations: comparison.limitations,
evidenceLinks: [main.evidence_id, another.evidence_id],
});
const response = await client.callTool({
name: "find_changed_behavior",
arguments: { comparisons: [invalid] },
});
expect(response.isError).toBe(true);
expect(parseMcpToolError(response).error.code).toBe(
"evidence_integrity_mismatch",
);
};
it.each(["full", "compact"] as const)(
"compares the same authenticated observation while preserving incomplete facets (%s)",
async (profile) => {
const session = createTestBinarySession(() => {
throw new Error("offline comparison must not launch a provider");
});
expect(session.recordEvidence(main).ok).toBe(true);
expect(session.recordEvidence(another).ok).toBe(true);
const { client } = await createApplicationMcpHarness({
session,
schemaValidator: schemaValidation,
serverOptions: {
environment: { REA_MCP_OUTPUT_SCHEMA_PROFILE: profile },
},
});
const schema = (await client.listTools()).tools.find(
({ name }) => name === "compare_functions",
)?.outputSchema;
if (schema === undefined) throw new Error("Missing output schema");
const reply = await client.callTool({
name: "compare_functions",
arguments: { left: ref(main.evidence_id), right: ref(main.evidence_id) },
});
expect(reply.isError, JSON.stringify(reply.content)).not.toBe(true);
expect(
new Ajv2020({ strict: false }).validate(
schema as AnySchema,
reply.structuredContent,
),
).toBe(true);
const result = parseEvidence(reply.structuredContent).normalized_result;
expect(result).toMatchObject({
status: "unknown",
function_match: { status: "matched", method: "same-evidence" },
dimensions: expect.arrayContaining([
expect.objectContaining({ dimension: "identity", status: "unchanged" }),
expect.objectContaining({ dimension: "comments", status: "unknown" }),
]),
});
const comparison = parseEvidence(reply.structuredContent);
const behavior = await client.callTool({
name: "find_changed_behavior",
arguments: { comparisons: [ref(comparison.evidence_id)] },
});
expect(behavior.isError, JSON.stringify(behavior.content)).not.toBe(true);
const behaviorResult = parseEvidence(
behavior.structuredContent,
).normalized_result;
expect(behaviorResult).toMatchObject({
behavior_status: "unknown",
findings: {
items: expect.arrayContaining([
expect.objectContaining({ dimension: "comments", status: "unknown" }),
]),
},
});
expect(behaviorResult).toMatchObject({
findings: {
items: expect.not.arrayContaining([
expect.objectContaining({ dimension: "identity" }),
]),
},
});
const contradictory = createEvidence(undefined, comparison.provider, {
predicateType: comparison.predicate_type,
operation: comparison.operation,
parameters: {
left_evidence_id: main.evidence_id,
right_evidence_id: another.evidence_id,
},
result: comparison.normalized_result,
confidence: comparison.confidence,
authority: comparison.authority,
limitations: comparison.limitations,
evidenceLinks: [main.evidence_id, another.evidence_id],
});
const contradictoryBehavior = await client.callTool({
name: "find_changed_behavior",
arguments: { comparisons: [contradictory] },
});
expect(contradictoryBehavior.isError).toBe(true);
expect(parseMcpToolError(contradictoryBehavior).error.code).toBe(
"evidence_integrity_mismatch",
);
await expectRejectedDimensionCitation(client, comparison);
const distinct = await client.callTool({
name: "compare_functions",
arguments: {
left: ref(main.evidence_id),
right: ref(another.evidence_id),
},
});
expect(
parseEvidence(distinct.structuredContent).normalized_result,
).toMatchObject({
function_match: { status: "ambiguous" },
});
const rebound = await client.callTool({
name: "compare_functions",
arguments: {
left: main,
right: { ...main, caller_input_path: "/tmp/alternate-spelling" },
},
});
expect(
parseEvidence(rebound.structuredContent).normalized_result,
).toMatchObject({
function_match: { status: "matched", method: "same-evidence" },
});
const tampered = await client.callTool({
name: "compare_functions",
arguments: {
left: ref(main.evidence_id),
right: { ...main, normalized_result: { ...dossier, procedure } },
},
});
expect(tampered.isError).toBe(true);
expect(parseMcpToolError(tampered).error.code).toBe(
"evidence_integrity_mismatch",
);
},
);
@@ -20,7 +20,7 @@ import { OwnedFileHandle } from "../../../../src/filesystem/OwnedFileHandle.js";
import { AnalysisProviderRegistry } from "../../../../src/application/binary/AnalysisProviderRegistry.js";
import { composeBinarySession } from "../../../../src/application/binary/BinarySessionComposition.js";
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
import { KUNA_NATIVE_VALUE_FLOW_UNAVAILABLE } from "../../../../src/kuna/KunaSessionClient.js";
import { KUNA_VALUE_FLOW_UNAVAILABLE } from "../../../../src/kuna/KunaProviderCapabilities.js";
import { KunaProvider } from "../../../../src/kuna/KunaProvider.js";
import { KUNA_FUNCTION_TEXT_BYTES } from "../../../../src/kuna/KunaLimits.js";
import { KunaProjectCache } from "../../../../src/kuna/KunaProjectCache.js";
@@ -524,7 +524,7 @@ it("builds a dossier with text and call relationships, leaving unsupported facet
assembly: ["main: ; 0x1000", " 00001000: c3 RET"],
native_value_flow: {
available: false,
reason: KUNA_NATIVE_VALUE_FLOW_UNAVAILABLE,
reason: KUNA_VALUE_FLOW_UNAVAILABLE,
},
facet_availability: {
pseudocode: { available: true },
@@ -6,6 +6,8 @@ import { pathToFileURL } from "node:url";
import { expect, it as test } from "vitest";
import { KUNA_VERIFIED_VERSION } from "../../../../src/kuna/KunaRelease.js";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
import { fakeKunaCalls, writeFakeKuna } from "./fakeKuna.js";
@@ -377,36 +379,42 @@ it.each([
rejectRead: true,
wrongXrefs: false,
wrongStrings: false,
version: KUNA_VERIFIED_VERSION,
},
{
rejectClose: true,
rejectRead: false,
wrongXrefs: false,
wrongStrings: false,
version: "1.833",
},
{
rejectClose: true,
rejectRead: true,
wrongXrefs: false,
wrongStrings: false,
version: "1.833",
},
{
rejectClose: false,
rejectRead: false,
wrongXrefs: true,
wrongStrings: false,
version: "1.833",
},
{
rejectClose: false,
rejectRead: false,
wrongXrefs: false,
wrongStrings: true,
version: "1.833",
},
])(
"reports failure-path lineage (cleanup: $rejectClose, read: $rejectRead, wrong xrefs: $wrongXrefs, wrong strings: $wrongStrings)",
async ({ rejectClose, rejectRead, wrongXrefs, wrongStrings }) => {
async ({ rejectClose, rejectRead, wrongXrefs, wrongStrings, version }) => {
const root = await createTestTempDirectory("rea-kuna-verifier-cleanup-");
const kuna = await writeFakeKuna(root, {
version: { stdout: `kuna ${version}\n` },
extraXrefSources: [4104],
stringRows: [
{ address: 12288, text: "REA fixture prompt" },
+6
View File
@@ -3,6 +3,12 @@
These source-owned fixtures are compiled at verification time; generated
binaries and Ghidra projects are never committed.
For the one-shot function bundle handoff, run `npm run build:cached` and then
`node scripts/verify-real-ghidra-function-bundle.mjs` with `GHIDRA_INSTALL_DIR`
and `JAVA_HOME` set. The verifier compiles its own target, creates a project
with a synthetic history report, and exercises complete, summary, and
no-overwrite CLI delivery. It checks the target and project files for changes.
- `../c/riscv.c` and `riscv-probe.S` produce source-owned RV32/RV64 ELF images
with double-float ABI declarations and an independently known instruction.
`../c/riscv-float-abi.c`, `riscv-probe.S` and `riscv-isa-probe.S` produce