Compare commits

..
75 changed files with 439 additions and 3126 deletions

No files matched your search

+6 -42
View File
@@ -179,7 +179,6 @@ public final class ReaGhidraBridge extends HeadlessScript {
private List<FunctionEntry> procedureInventory;
private List<InventoryItem> stringInventory;
private DecompInterface decompiler;
private boolean existingProject;
// A refused Program may only report its admission failure to the client.
private String identityAdmissionFailure;
/** Per-function native decompiler limit from the session; 0 leaves it unbounded. */
@@ -200,7 +199,6 @@ public final class ReaGhidraBridge extends HeadlessScript {
Path descriptorPath = Path.of(arguments[0]);
checkpointDirectory = descriptorPath.toAbsolutePath().normalize().getParent();
SessionDescriptor descriptor = readDescriptor(descriptorPath);
existingProject = descriptor.existingProject;
decompileTimeoutSeconds = descriptor.decompileTimeoutSeconds();
// Windows retains a native immutable bearer descriptor lease until
// runtime_close; POSIX removes it after this read. Normal cleanup removes
@@ -878,7 +876,6 @@ public final class ReaGhidraBridge extends HeadlessScript {
JsonArray blocks = new JsonArray();
JsonArray skipped = new JsonArray();
JsonArray notLoaded = new JsonArray();
JsonArray overlays = new JsonArray();
List<ByteSearchBlock> eligible = new ArrayList<>();
for (MemoryBlock block : ordered) {
monitor.checkCancelled();
@@ -886,16 +883,10 @@ public final class ReaGhidraBridge extends HeadlessScript {
Address high = block.getEnd();
Address bound = start == null ? end : start;
if (bound != null && !bound.getAddressSpace().equals(low.getAddressSpace())) continue;
// Without bounds, search loaded non-overlay memory. Unloaded file
// parts and loaded overlays stay reachable through explicit bounds.
if (bound == null && !block.isLoaded()) {
if (bound == null && (!block.isLoaded() || block.isOverlay())) {
notLoaded.add(byteSearchCoverage(block, low, high));
continue;
}
if (bound == null && block.isOverlay()) {
overlays.add(byteSearchCoverage(block, low, high));
continue;
}
if (start != null && low.compareTo(start) < 0) low = start;
if (end != null && high.compareTo(end) > 0) high = end;
if (low.compareTo(high) > 0) continue;
@@ -969,7 +960,6 @@ public final class ReaGhidraBridge extends HeadlessScript {
searched.addProperty("bytes", covered);
searched.add("skipped_uninitialized", skipped);
searched.add("skipped_not_loaded", notLoaded);
searched.add("skipped_overlays", overlays);
JsonObject result = new JsonObject();
result.add("matches", matches);
result.add("searched", searched);
@@ -1961,6 +1951,9 @@ public final class ReaGhidraBridge extends HeadlessScript {
limitations.add(
"Def-use links do not cross function boundaries and may omit memory aliasing, call side effects, or unresolved indirect flow."
);
limitations.add(
"Memory read/write effects cover high-p-code LOAD and STORE operations only; direct memory address varnodes in other operations are not included. An empty effect list does not establish that no memory is accessed. Recorded instruction references may provide complementary evidence."
);
limitations.add(
"Memory read/write effects include stack and temporary memory operations; they do not identify persistent state or business meaning."
);
@@ -3747,7 +3740,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
return result;
}
private JsonObject callRelationshipCoverage(
private static JsonObject callRelationshipCoverage(
CallBoundaryObservation calls, Integer missingCallers) {
JsonObject coverage = completeResolvedCallCoverage();
Integer omitted = calls == null ? null : calls.unrepresentedCalleeCalls();
@@ -3764,7 +3757,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
if (calls == null && missingCallers == null) directions.add("callers");
directions.add("callees");
coverage.add("limited_directions", directions);
return qualifyProjectCallCoverage(coverage, calls, missingCallers);
return coverage;
}
JsonArray limitations = new JsonArray();
if (callerLimitation != null) limitations.add(callerLimitation);
@@ -3778,35 +3771,6 @@ public final class ReaGhidraBridge extends HeadlessScript {
coverage.add("limitations", limitations);
coverage.add("limited_directions", directions);
}
return qualifyProjectCallCoverage(coverage, calls, missingCallers);
}
private JsonObject qualifyProjectCallCoverage(
JsonObject coverage, CallBoundaryObservation calls, Integer missingCallers) {
if (!existingProject) return coverage;
String reason = "The selected project's prior analysis completeness is unknown; " +
"the recorded call relationships may omit undiscovered callers or callees.";
String status = coverage.get("status").getAsString();
// A single-direction read with known omissions is still partial; an
// unknown project history adds a limitation rather than erasing them.
if (status.equals("partial") && (calls == null || missingCallers == null)) {
coverage.getAsJsonArray("limitations").add(reason);
return coverage;
}
if (status.equals("unknown")) reason += " " + coverage.get("reason").getAsString();
if (status.equals("partial")) {
for (JsonElement limitation : coverage.getAsJsonArray("limitations"))
reason += " " + limitation.getAsString();
}
JsonArray directions = new JsonArray();
if (missingCallers != null || (calls == null && missingCallers == null))
directions.add("callers");
if (calls != null || (calls == null && missingCallers == null))
directions.add("callees");
coverage.addProperty("status", "unknown");
coverage.addProperty("reason", reason);
coverage.remove("limitations");
coverage.add("limited_directions", directions);
return coverage;
}
-5
View File
@@ -1558,11 +1558,6 @@ For supported header-selected MIPS ELF targets, the profile also binds the
selected target's ELF metadata; it does not claim a fresh-import MIPS lane was
used for the saved project.
A matching recorded import digest does not claim that the database is unedited.
Caller and callee coverage therefore remains unknown. A single-direction query
with known omissions remains partial and also reports the unknown project history.
Observed call edges are still available: a path to an observed caller or callee
can be reached, but a missing path is unknown when the project's prior analysis
cannot establish completeness.
Saved REA seed/analyzer reports remain historical facts in retained source Evidence;
they are not interpreted as overrides applied by this session.
Project content participates in snapshot identity, so different project states
+11 -32
View File
@@ -100,36 +100,6 @@ body are excluded. `procedure_references` returns internal and external edges;
The reference collector's semantics participate in the analysis profile, so
older snapshots cannot replay the former incomplete results under this profile.
For a bounded handoff, request `procedure_references` with `detail: "summary"`.
The summary retains the complete observation and reports the selected procedure,
direction and counts. Page it with `inspect_analysis_view`, using the summary's
`normalized_result.parent_evidence_id` and a view such as
`{"kind":"native-procedure-references","collection":"references","source_address":"0x1234","offset":0,"limit":64}`.
The optional exact source and target filters apply before paging. A row's
`source_index` identifies it in the original retained collection, including
duplicates. Endpoint `kind: "selected-procedure"` refers to the full procedure
in the page summary; `kind: "null"` preserves the original null without asserting
why it was unavailable. `kind: "shared"` carries an index into `items.endpoints`,
where each other procedure object is stored once per page. Use collection
`unresolved_calls` for paged unresolved call sites and their original reasons.
Page coverage describes retained rows, not complete provider discovery or runtime
execution. A 1 MiB projection allowance, also used by other bounded analysis views, can
shorten a requested page; `byte_limited: true` and `coverage.next_offset` identify the
continuation. A single row or summary that cannot fit returns a resource error;
retrieve or export the complete parent Evidence. This allowance has no runtime
override. Omit detail to receive the complete observation inline.
For a one-shot CLI handoff, save the complete Evidence and its linked sources:
```bash
rea procedure-references /absolute/path/to/program my_function --provider ghidra \
--direction incoming --detail summary --evidence_output /absolute/path/to/references.json
```
Use `rea inspect-analysis-view` with that bundle as an `evidence-file` source to
page it without reopening the target. This command requires a provider supporting
`procedure_references`; Kuna currently reports that capability unavailable.
Ghidra regex searches use Java Pattern semantics. If compiling or matching a
pattern exhausts the engine's stack, REA returns a resource constraint and
preserves the active database and annotations. Retry in literal mode or simplify
@@ -574,8 +544,17 @@ The raw Java `is_dead` flag can remain set after Ghidra decodes a live block;
`block_membership` reports actual syntax-tree membership separately.
The provider dossier itself retains up to 3,000 p-code operations, 64 inputs per
operation and 12,000 def-use edges, with explicit omitted counts. A `STORE`
may describe stack memory and does not itself prove persistent state.
operation and 12,000 def-use edges, with explicit omitted counts. Memory effects
cover high-p-code `LOAD` and `STORE` operations; direct memory address varnodes
in other operations are excluded, so an empty effect list does not establish
that the function accesses no memory. `procedure_references` can supply
complementary recorded instruction references, with its own coverage limits. A
`STORE` may describe stack memory and does not itself prove persistent state.
The Ghidra analysis profile commits to this effect scope. Snapshots from before
that commitment are rejected; analyze without the snapshot and save a fresh one.
To keep historical Evidence, copy the old snapshot's `evidence_bundle` object to
a separate JSON file and import that bundle. Import validates the Evidence; it
does not replay the old provider analysis.
Jump-table evidence keeps numeric `mappings`, explicit `default_targets`, and
backing `data_sources` separate. A null case value denotes an unresolved
+4 -11
View File
@@ -359,17 +359,10 @@ must be constrained.
Optional `start` and `end` addresses are inclusive and select one address space.
A match must end at or before `end`, so `end` set inside a match excludes it.
Without bounds, loaded non-overlay memory blocks are eligible, in Ghidra address
order, including across adjacent initialized blocks. Blocks that are not loaded
(file parts such as `.symtab`) are listed in `searched.skipped_not_loaded`;
loaded overlay blocks are listed separately in `searched.skipped_overlays`.
Neither is searched by default; pass `start` or `end` in its address space to
search it. A block that is both unloaded and an overlay is listed once under
`skipped_not_loaded`.
The separate overlay field changes the byte-search result contract and the
Ghidra analysis profile. Refresh cached MCP schemas; snapshots from the older
profile are not replayed. Previously exported Evidence remains importable as
historical data with its original result shape.
Without bounds, loaded memory blocks are eligible, in Ghidra address order.
Blocks that are not loaded (file parts such as `.symtab`) and overlay blocks are
listed in `searched.skipped_not_loaded` and are not searched; pass `start` or
`end` in their address space to search them.
Overlapping matches are included, including across adjacent eligible initialized
blocks in the same address space. Gaps and excluded or uninitialized blocks break
a search run. Each match reports its address, starting segment, unique source file
+2 -14
View File
@@ -1066,20 +1066,8 @@ limitations. An unavailable group carries `remediation` when its readiness
owner supplied a repair instruction; `tool_availability` uses that instruction
rather than the failure description.
`binary_session.analysis_provider_candidates` reports deep-engine discovery.
`analysis_provider_candidates_observed` is `target_selection` when those facts
were retained from the active target's selection, `status_request` when the
status request sought candidate observations, and `not_applicable` for resource-only
sessions. Selection-time readiness may have changed since the target was opened.
Set `refresh_candidates: true` to inspect candidate readiness and target support
again without replacing the active binding or engine. That does not resolve a
new analysis profile or start a provider client. Later transitions and analysis
calls wait for the inspection to finish, including cleanup. Current-route capabilities
remain those of the bound session. Candidate statuses can still be unknown when
an inspection cannot complete.
`open_binary` with an explicit `provider_id` performs a new selection. Reopening
the same target without `provider_id` reuses its current route. A changed route
can retire the previous engine and its ephemeral edits. Each candidate's capability sets, returned with complete detail,
`binary_session.analysis_provider_candidates` is authoritative for deep-engine
discovery. Each candidate's capability sets, returned with complete detail,
describe that possible provider;
they do not select it. The top-level `capabilities` describe the current route,
while `analysis_provider_binding` identifies the concrete selected route. These
+2 -8
View File
@@ -45,7 +45,6 @@ prove behavior on the verified Ghidra 12.1.4 and JDK 21 build.
| Ghidra lane | Supported runner/target | Additional local tools |
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| `npm run verify:ghidra` | Linux x64/arm64 ELF or macOS x64/arm64 Mach-O | Host C compiler, Ghidra 12.1.4, and full JDK 21 |
| `npm run verify:ghidra:project-coverage` | POSIX host; selected-project call coverage, real MCP, retained Evidence and an unexecuted host fixture | Host C compiler, Ghidra 12.1.4, and full JDK 21 |
| `npm run verify:ghidra:seeds` | POSIX; raw x86 fixture; seed ordering, BSS labels, analyzer readback, not-applicable and unknown analyzer names | Ghidra 12.1.4 and full JDK 21 |
| `npm run verify:ghidra:swift` | macOS x64/arm64 Mach-O | Host Swift compiler, Ghidra 12.1.4, and full JDK 21 |
| `npm run verify:ghidra:riscv` | Linux/macOS x64/arm64 host; source-owned RV32/RV64 double-float and RV32 ilp32 ELF through CLI and MCP | RISC-V Clang, LLD, LLVM readelf and objdump, Ghidra 12.1.4, and full JDK 21 |
@@ -541,13 +540,8 @@ the entry changes without `provider_id`, preservation of a session annotation
and the target/provider receipt after a rejected open, unchanged source bytes,
and owned cleanup. Six-byte x86 and x86_64 controls also verify supported compiler
specifications, independent mappings, unchanged bytes, and decompilation of a
known return value. On a disposable Ghidra project, MCP `search_bytes` also
checks a manually created loaded `overlay_test` block: an unbounded search lists
it in `skipped_overlays` and not in `skipped_not_loaded`, and bounds in that
overlay space find the pattern. This lane does not build an overlapping-load ELF,
does not call CLI `search-bytes`, and does not page an overlay search across a
snapshot reopen. AArch64 remains covered by deterministic tests rather than this
real-provider lane. See [raw binary analysis](providers/raw-binary.md).
known return value. AArch64 remains covered by deterministic tests rather than
this real-provider lane. See [raw binary analysis](providers/raw-binary.md).
`npm run verify:ghidra:address-context` runs on Linux with a caller-installed
Ghidra, full JDK, and an x86-64 ELF GCC toolchain (`CC` selects another compatible
-1
View File
@@ -15,7 +15,6 @@
"scripts/verify-real-ghidra-noreturn.mjs",
"scripts/verify-real-ghidra-decompiler-failure.mjs",
"scripts/verify-real-ghidra-decompile-timeout.mjs",
"scripts/verify-real-ghidra-function-bundle.mjs",
"tests/processOwnershipRunner.ts"
],
"project": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.mjs"],
-1
View File
@@ -207,7 +207,6 @@
"verify:flutter": "npm run build:cached && node scripts/verify-flutter.mjs",
"verify:adb": "npm run build:cached && node scripts/verify-adb.mjs",
"verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs",
"verify:ghidra:project-coverage": "npm run build:cached && node scripts/verify-real-ghidra-project-coverage.mjs",
"verify:ghidra:checkpoints": "npm run build:cached && node scripts/verify-real-ghidra-checkpoints.mjs",
"verify:ghidra:seeds": "npm run build:cached && node scripts/verify/ghidra/seeds.mjs",
"verify:ghidra:mips": "npm run build:cached && node scripts/verify-real-ghidra-mips.mjs",
@@ -0,0 +1,123 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { parseBinaryTarget } from "../../dist/application/BinaryTargetResolver.js";
import { createAnalysisProfile } from "../../dist/domain/analysisProfile.js";
import {
createAnalysisSnapshotEntry,
parseAnalysisSnapshot,
serializeAnalysisSnapshot,
snapshotBinding,
snapshotTarget,
} from "../../dist/domain/analysisSnapshot.js";
import { createEvidence } from "../../dist/domain/evidence.js";
import { createEvidenceBundle } from "../../dist/domain/evidenceBundle.js";
import {
requireMcpLifecycleResult,
requireMcpOutput,
requireMcpToolError,
} from "./mcp-verifier-results.mjs";
/** A pre-scope dossier must not be replayed into a newly qualified trace. */
export async function verifyLegacyValueScopeSnapshot(
client,
{ target, procedure, observed, env },
) {
const { value_flow_effect_scope: scope, ...legacyParameters } =
observed.analysis_profile.parameters;
const legacyProfile = createAnalysisProfile(
observed.provider,
legacyParameters,
);
const parsed = await parseBinaryTarget(target.path, {
cwd: process.cwd(),
hostArchitecture: process.arch,
});
if (!parsed.ok) throw parsed.error;
const nativeTarget = parsed.value;
const result = structuredClone(observed.normalized_result);
const flow = result.native_value_flow;
assert.equal(flow.available, true);
const describesScope = (value) =>
value.includes("LOAD and STORE") && value.includes("address varnodes");
assert.ok(flow.limitations.some(describesScope));
flow.limitations = flow.limitations.filter((value) => !describesScope(value));
const evidence = createEvidence(nativeTarget, observed.provider, {
operation: observed.operation,
parameters: observed.parameters,
result,
rawResult: result,
analysisProfile: legacyProfile,
limitations: observed.limitations,
locations: observed.locations,
});
const binding = snapshotBinding(legacyProfile);
const identity = snapshotTarget(nativeTarget);
const snapshot = parseAnalysisSnapshot({
target: identity,
binding,
entries: [
createAnalysisSnapshotEntry({
target: identity,
binding,
operation: observed.operation,
parameters: observed.parameters,
execution: {
result,
rawResult: result,
provider: observed.provider,
analysisProfile: legacyProfile,
limitations: observed.limitations,
locations: observed.locations,
subject: nativeTarget,
},
}),
],
workflow_entries: [],
evidence_bundle: createEvidenceBundle([evidence]),
});
const path = join(env.TMPDIR, `legacy-value-scope-${randomUUID()}.json`);
await writeFile(path, serializeAnalysisSnapshot(snapshot), { flag: "wx" });
try {
const bytes = await readFile(path);
const session = requireMcpLifecycleResult(
await client.callTool({ name: "binary_session", arguments: {} }),
"binary_session",
);
const reply = await client.callTool({
name: "open_binary",
arguments: {
path: target.path,
provider_id: "ghidra",
snapshot_path: path,
},
});
assert.equal(reply.isError, true);
assert.equal(scope, "pcode-load-store-explicit-v1");
const error = requireMcpToolError(reply);
assert.equal(error.code, "evidence_integrity_mismatch");
assert.deepEqual(error.details.binding_mismatch.profile_digest, {
snapshot: legacyProfile.digest,
selected: observed.analysis_profile.digest,
});
assert.deepEqual(
requireMcpLifecycleResult(
await client.callTool({ name: "binary_session", arguments: {} }),
"binary_session",
),
session,
);
const fresh = await client.callTool(
{ name: "trace_native_values", arguments: { procedure, max_depth: 0 } },
{ timeout: 180000 },
);
assert.notEqual(fresh.isError, true, JSON.stringify(fresh));
const trace = requireMcpOutput(fresh, "trace_native_values");
assert.ok(trace.normalized_result.limitations.some(describesScope));
assert.deepEqual(await readFile(path), bytes);
} finally {
await rm(path);
}
}
+35
View File
@@ -17,6 +17,7 @@ import {
requireMcpOutput,
} from "./mcp-verifier-results.mjs";
import { verifyGhidraBoundaries } from "./real-ghidra-boundaries.mjs";
import { verifyLegacyValueScopeSnapshot } from "./ghidra-value-scope-snapshot-e2e.mjs";
import { verifyGhidraProjectIdentity } from "./real-ghidra-project-identity.mjs";
import { verifyGhidraSocketBoundary } from "./real-ghidra-socket-boundary.mjs";
@@ -214,6 +215,14 @@ async function verifyNativeValueAdapters(
procedure,
globalAddress,
);
assert.ok(
mcpGraph.limitations.some(
(value) =>
value.includes("analyze_function") &&
value.includes("LOAD and STORE") &&
value.includes("address varnodes"),
),
);
assert.deepEqual(mcpEvidence.normalized_result, mcpGraph);
assert.deepEqual(mcpGraph, cliGraph);
const warmReply = await client.callTool(
@@ -243,6 +252,26 @@ async function verifyNativeValueAdapters(
);
assert.equal(functionEvidence.operation, "analyze_function");
assert.equal(functionEvidence.provider.id, "ghidra");
const flow = functionEvidence.normalized_result.native_value_flow;
assert.equal(flow.available, true);
const identifiesEffectScope = (value) =>
value.includes("LOAD and STORE") && value.includes("address varnodes");
assert.ok(flow.limitations.some(identifiesEffectScope));
const effectsView = await client.callTool({
name: "inspect_analysis_view",
arguments: {
source: {
kind: "retained-evidence",
evidence_id: functionEvidence.evidence_id,
},
view: { kind: "native", facet: "value_flow_effects" },
},
});
assert.notEqual(effectsView.isError, true, JSON.stringify(effectsView));
const effectsPage = requireMcpOutput(effectsView, "inspect_analysis_view");
assert.ok(
effectsPage.normalized_result.limitations.some(identifiesEffectScope),
);
assert.deepEqual(
functionEvidence.normalized_result.facet_availability,
Object.fromEntries(
@@ -268,6 +297,12 @@ async function verifyNativeValueAdapters(
functionEvidence.analysis_profile,
);
assert.deepEqual(functionProfile, upstreamProfile);
await verifyLegacyValueScopeSnapshot(client, {
target,
procedure,
observed: functionEvidence,
env,
});
const comparisonEvidence = await compareRetainedFunctionEvidence(
client,
functionEvidence,
-21
View File
@@ -1,21 +0,0 @@
import type { Client, Transport } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
/** Share an in-flight SDK close, including one started during failed initialization. */
export class VerifierStdioTransport extends StdioClientTransport {}
type VerifierConnection = {
client: Client;
transport: Transport;
definitions: Map<
string,
Awaited<ReturnType<Client["listTools"]>>["tools"][number]
>;
};
/** Own MCP transports from connection through discovery, use, and cleanup retry. */
export function createVerifierMcpOwner(): {
open(client: Client, transport: Transport): Promise<VerifierConnection>;
closeConnection(connection: VerifierConnection): Promise<void>;
close(): Promise<void>;
};
-54
View File
@@ -1,54 +0,0 @@
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import {
closeVerifierResources,
withVerifierCleanup,
} from "./verifier-cleanup.mjs";
/** Share an in-flight SDK close, including one started during failed initialization. */
export class VerifierStdioTransport extends StdioClientTransport {
#closing;
close() {
if (this.#closing !== undefined) return this.#closing;
this.#closing = super.close().catch((error) => {
this.#closing = undefined;
throw error;
});
return this.#closing;
}
}
/** Own MCP transports from connection through discovery, use, and cleanup retry. */
export function createVerifierMcpOwner() {
const owned = new Set();
const closeConnection = async (connection) => {
await closeVerifierResources([
() => connection.client.close(),
() => connection.transport.close(),
]);
owned.delete(connection);
};
return {
async open(client, transport) {
const connection = { client, transport, definitions: new Map() };
owned.add(connection);
try {
await client.connect(transport);
connection.definitions = new Map(
(await client.listTools()).tools.map((tool) => [tool.name, tool]),
);
return connection;
} catch (cause) {
return withVerifierCleanup(() => {
throw cause;
}, [() => closeConnection(connection)]);
}
},
closeConnection,
close() {
return closeVerifierResources(
[...owned].map((connection) => () => closeConnection(connection)),
);
},
};
}
+2 -4
View File
@@ -75,10 +75,8 @@ if (!internalWorkspace) {
commands === undefined ? workspaceOwner.close() : commands.close(),
]);
} catch (cause) {
const stdout = cause?.snapshot?.stdout?.text;
const stderr = cause?.snapshot?.stderr?.text;
if (typeof stdout === "string") process.stdout.write(stdout);
if (typeof stderr === "string") process.stderr.write(stderr);
if (typeof cause.stdout === "string") process.stdout.write(cause.stdout);
if (typeof cause.stderr === "string") process.stderr.write(cause.stderr);
throw cause;
}
const report = JSON.parse(result.stdout);
@@ -1,421 +0,0 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import {
mkdir,
mkdtemp,
readFile,
readdir,
rm,
writeFile,
} from "node:fs/promises";
import { realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import { isAbsolute, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
OwnedCommandFailure,
runOwnedCommand,
} from "../dist/process/OwnedCommand.js";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
import { Client } from "@modelcontextprotocol/client";
import { createAnalysisProfile } from "../dist/domain/analysisProfile.js";
import { createEvidenceBundle } from "../dist/domain/evidenceBundle.js";
import { inspectVerifierGhidraInstallation } from "./lib/ghidra-installation-inspection.mjs";
import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
import {
createVerifierMcpOwner,
VerifierStdioTransport,
} from "./lib/verifier-mcp-owner.mjs";
import {
requireMcpOutput,
requireMcpToolError,
} from "./lib/mcp-verifier-results.mjs";
export async function verifyProjectCoverage(run) {
const installDir = process.env.GHIDRA_INSTALL_DIR;
if (installDir === undefined || !isAbsolute(installDir))
throw new Error(
"Set GHIDRA_INSTALL_DIR to an absolute Ghidra installation path.",
);
const installation = await inspectVerifierGhidraInstallation({
environment: process.env,
installDir,
...(process.env.JAVA_HOME === undefined
? {}
: { javaHome: process.env.JAVA_HOME }),
});
if (installation.status !== "available")
throw new Error(`Ghidra is unavailable: ${JSON.stringify(installation)}`);
if (process.platform === "win32")
throw new Error(
"REA selected Ghidra projects are supported on POSIX hosts.",
);
const scriptRoot = fileURLToPath(
new URL("../tests/conformance/ghidra/", import.meta.url),
);
const entrypoint = fileURLToPath(new URL("./rea.mjs", import.meta.url));
const workspace = await mkdtemp(
join(tmpdir(), "rea-ghidra-project-coverage-"),
);
const connections = createVerifierMcpOwner();
const target = join(workspace, "fixture");
const projects = join(workspace, "projects");
const env = {
PATH: process.env.PATH ?? "/usr/bin:/bin",
JAVA_HOME: installation.javaHome,
GHIDRA_INSTALL_DIR: installation.installDir,
REA_MCP_OUTPUT_SCHEMA_PROFILE: "compact",
REA_LOG_LEVEL: "silent",
REA_PROCESS_RUN_ID: run.run_id,
};
const hash = (data) => createHash("sha256").update(data).digest("hex");
async function manifest(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const files = await Promise.all(
entries.map(async (entry) => {
const path = join(directory, entry.name);
return entry.isDirectory()
? manifest(path)
: entry.isFile()
? [[path, hash(await readFile(path))]]
: [];
}),
);
return files.flat().sort((a, b) => a[0].localeCompare(b[0]));
}
async function connect(environment) {
const client = new Client({
name: "selected-project-coverage-verifier",
version: "1",
});
const transport = new VerifierStdioTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env: environment,
stderr: "pipe",
});
return connections.open(client, transport);
}
async function call(connection, name, args = {}) {
const reply = await connection.client.callTool(
{ name, arguments: args },
{ timeout: 180000, toolDefinition: connection.definitions.get(name) },
);
return requireMcpOutput(reply, name);
}
let commandCleanupIncomplete = false;
const command = async (executable, args, timeoutMs) => {
try {
return await runOwnedCommand(
{
command: executable,
arguments: args,
env,
runId: run.run_id,
expectedCommand: null,
},
{ timeoutMs, diagnosticBytes: 16 * 1024 * 1024 },
);
} catch (cause) {
commandCleanupIncomplete =
cause instanceof OwnedCommandFailure &&
cause.cleanupOwner !== undefined;
throw cause;
}
};
const result = await withVerifierCleanup(async () => {
await mkdir(projects);
// REA_CC is the invoking developer's explicitly selected compiler, not target input.
await command(
process.env.REA_CC ?? "cc",
[
"-O0",
"-g",
"-fno-inline",
"-fno-optimize-sibling-calls",
join(scriptRoot, "selected-project-coverage.c"),
"-o",
target,
],
120000,
);
for (const name of ["partial", "analyzed"]) {
const args = [projects, name, "-import", target];
if (name === "partial")
args.push(
"-noanalysis",
"-scriptPath",
scriptRoot,
"-postScript",
"ReaSelectedProjectSeeds.java",
);
const result = await command(
installation.analyzeHeadlessPath,
args,
180000,
);
if (name === "partial")
for (const seed of [
"rea_project_leaf",
"rea_project_caller",
"rea_project_other",
])
assert.match(
result.stdout.text,
new RegExp(`REA_PROJECT_SEED ${seed} `),
);
}
const before = {
target: hash(await readFile(target)),
projects: await manifest(projects),
};
const report = [];
for (const mode of ["fresh", "partial", "analyzed"]) {
const selected = mode !== "fresh";
const connection = await connect({
...env,
...(selected
? {
REA_GHIDRA_PROJECT_PATH: join(projects, `${mode}.gpr`),
REA_GHIDRA_PROGRAM_NAME: "fixture",
}
: {}),
});
const bundlePath = join(workspace, `${mode}.json`);
const paths = [];
await withVerifierCleanup(async () => {
await call(connection, "open_binary", {
path: target,
provider_id: "ghidra",
});
const inventory = (
await call(connection, "list_procedures", {
detail: "complete",
})
).normalized_result;
const entry = (name) => {
const matches = inventory.filter(
(item) => item.value === name || item.value === `_${name}`,
);
assert.equal(matches.length, 1, `Expected one entry for ${name}`);
return matches[0].address;
};
const leaf = entry("rea_project_leaf"),
caller = entry("rea_project_caller"),
other = entry("rea_project_other");
const direct = await call(connection, "procedure_callees", {
procedure: caller,
});
assert.equal(
direct.relationship_coverage.status,
selected ? "unknown" : "complete",
);
assert.equal(
direct.normalized_result.includes(leaf),
mode !== "partial",
);
assert.equal(
direct.analysis_profile.parameters
.selected_project_relationship_coverage,
selected ? "prior-analysis-unknown-v1" : undefined,
);
for (const [start, goal, direction] of [
[caller, leaf, "forward"],
[leaf, caller, "backward"],
[other, caller, "backward"],
]) {
const evidence = await call(connection, "trace_call_path", {
start,
goal,
direction,
});
paths.push(evidence);
}
assert.equal(
paths[0].normalized_result.goal_status,
mode === "partial" ? "unknown" : "reached",
);
assert.equal(
paths[1].normalized_result.goal_status,
mode === "partial" ? "unknown" : "reached",
);
assert.equal(paths[2].normalized_result.goal_status, "unknown");
if (!selected) {
assert.equal(
paths[2].normalized_result.search_scope.observed_goal_status,
"not_reached",
);
assert.equal(
paths[2].normalized_result.search_scope.discovery,
"unknown",
);
}
if (selected)
for (const evidence of paths)
assert.ok(
Object.values(
evidence.normalized_result.relationship_coverage,
).some(
(coverage) =>
coverage.status === "unknown" &&
/prior analysis completeness is unknown/u.test(
coverage.reason,
),
),
);
if (mode === "analyzed") {
const dossier = await call(connection, "analyze_function", {
procedure: entry("rea_project_callback"),
detail: "complete",
});
assert.equal(dossier.relationship_coverage.status, "unknown");
assert.match(
dossier.relationship_coverage.reason,
/decoded call\(s\) without destinations/u,
);
assert.match(
dossier.relationship_coverage.reason,
/prior analysis completeness is unknown/u,
);
const callbackPath = await call(connection, "trace_call_path", {
start: entry("rea_project_callback"),
goal: leaf,
direction: "forward",
});
const coverages = Object.values(
callbackPath.normalized_result.relationship_coverage,
);
assert.ok(
coverages.some(
(coverage) =>
coverage.status === "partial" &&
coverage.limitations.some((text) =>
/decoded call\(s\) without destinations/u.test(text),
) &&
coverage.limitations.some((text) =>
/prior analysis completeness is unknown/u.test(text),
),
),
);
}
await call(connection, "export_evidence_bundle", { path: bundlePath });
if (mode === "partial") {
const snapshotPath = join(workspace, "partial-snapshot.json");
await call(connection, "close_binary", {
snapshot_path: snapshotPath,
});
const snapshot = JSON.parse(await readFile(snapshotPath, "utf8"));
assert.equal(
snapshot.binding.analysis_profile.parameters
.selected_project_relationship_coverage,
"prior-analysis-unknown-v1",
);
const profile = snapshot.binding.analysis_profile;
const oldParameters = { ...profile.parameters };
delete oldParameters.selected_project_relationship_coverage;
const stalePath = join(workspace, "partial-old-profile.json");
await writeFile(
stalePath,
JSON.stringify({
...snapshot,
binding: {
...snapshot.binding,
analysis_profile: createAnalysisProfile(
profile.provider,
oldParameters,
),
},
entries: [],
workflow_entries: [],
evidence_bundle: createEvidenceBundle([]),
}),
);
const staleBefore = await readFile(stalePath);
const refused = await connection.client.callTool({
name: "open_binary",
arguments: {
path: target,
provider_id: "ghidra",
snapshot_path: stalePath,
},
});
const error = requireMcpToolError(refused);
assert.equal(error.code, "evidence_integrity_mismatch");
assert.deepEqual(error.details.binding_mismatch.profile_digest, {
snapshot: createAnalysisProfile(profile.provider, oldParameters)
.digest,
selected: profile.digest,
});
assert.deepEqual(await readFile(stalePath), staleBefore);
await call(connection, "open_binary", {
path: target,
provider_id: "ghidra",
snapshot_path: snapshotPath,
});
const replay = await call(connection, "trace_call_path", {
start: caller,
goal: leaf,
direction: "forward",
});
assert.deepEqual(
replay.normalized_result,
paths[0].normalized_result,
);
await call(connection, "close_binary");
} else await call(connection, "close_binary");
}, [() => connections.closeConnection(connection)]);
const offline = await connect(env);
await withVerifierCleanup(async () => {
await call(offline, "import_evidence_bundle", { path: bundlePath });
const records = (
await call(offline, "get_evidence_bundle", { detail: "complete" })
).result.records;
for (const evidence of paths) {
const saved = records.find(
(record) => record.evidence_id === evidence.evidence_id,
);
assert.ok(saved);
assert.deepEqual(saved.normalized_result, evidence.normalized_result);
assert.deepEqual(saved.limitations, evidence.limitations);
}
}, [() => connections.closeConnection(offline)]);
report.push({
mode,
statuses: paths.map(
(evidence) => evidence.normalized_result.goal_status,
),
});
}
assert.deepEqual(
{
target: hash(await readFile(target)),
projects: await manifest(projects),
},
before,
);
return {
ok: true,
provider: installation.providerVersion,
results: report,
};
}, [
async () => {
await connections.close();
if (!commandCleanupIncomplete)
await rm(workspace, { recursive: true, force: true });
},
]);
return result;
}
if (
process.argv[1] !== undefined &&
realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)
) {
const run = createVerifierRun();
const report = await verifyProjectCoverage(run);
await completeVerifierRun(run);
process.stdout.write(`${JSON.stringify(report)}\n`);
}
+1 -6
View File
@@ -760,12 +760,7 @@ async function verifyCrossBlockProject(target, targetSha256) {
!all.matches.some((match) => match.address.startsWith("overlay_test:")),
);
assert.ok(
!all.searched.skipped_not_loaded.some(
(block) => block.segment === "overlay_test",
),
);
assert.ok(
all.searched.skipped_overlays.some(
all.searched.skipped_not_loaded.some(
(block) => block.segment === "overlay_test",
),
);
-7
View File
@@ -1,7 +1,5 @@
#!/usr/bin/env node
import { verifyProjectCoverage } from "./verify-real-ghidra-project-coverage.mjs";
import { execFile } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
@@ -322,10 +320,6 @@ try {
const customPath = process.env.GHIDRA_TARGET_PATH;
const custom =
customPath === undefined ? null : await verifyTarget(customPath, "custom");
const projectCoverage =
!crossFormat && !aarch64JumpTableOnly && process.platform !== "win32"
? await verifyProjectCoverage(verifierRun)
: null;
report = {
ok: true,
provider: { id: "ghidra", version: SUPPORTED_GHIDRA_VERSION },
@@ -346,7 +340,6 @@ try {
? (crossResults[0]?.native_api_cli ?? null)
: jumpTable.native_api_cli,
native_value_e2e: debug.native_values,
selected_project_coverage: projectCoverage,
relocation_symbols: relocationSymbols,
custom_target: custom === null ? null : summary(custom),
cleanup: "complete",
+2 -6
View File
@@ -286,13 +286,9 @@ const openServer = async (provider, kunaSettings = {}) => {
path: provider.path,
provider_id: provider.id,
});
const session = (await call("binary_session")).value;
assert.equal(
session.analysis_provider_candidates_observed,
"target_selection",
);
if (provider.id === "kuna") {
const binding = session.analysis_provider_binding;
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);
+1 -1
View File
@@ -394,7 +394,7 @@ try {
} catch (cause) {
assert.equal(cause.code, 1);
cliFailure = analysisErrorProjectionSchema.parse(
cliOperationFailurePayload(JSON.parse(cause.snapshot.stdout.text)),
cliOperationFailurePayload(JSON.parse(cause.stdout)),
);
}
assert.equal(cliFailure.code, "unsupported_target");
-1
View File
@@ -105,7 +105,6 @@ type DirectAnalysisTool =
| "address_to_file_offset"
| "binary_overview"
| "procedure_pseudo_code"
| "procedure_references"
| "read_function_instructions"
| "inspect_native_instruction"
| "inspect_native_data_type"
+4 -1
View File
@@ -14,7 +14,10 @@ export const workflowAnalysisProfile = (
upstream_analysis_profile: upstream,
source_limitations: "provider-operation-caveats-v1",
...(operation === "trace_native_values"
? { value_flow_absence: "recorded-versus-unavailable-v1" }
? {
value_flow_limitations: "provider-attributed-v1",
value_flow_absence: "recorded-versus-unavailable-v1",
}
: {}),
...(operation === "binary_overview"
? { workflow_revision: 5 }
@@ -230,7 +230,6 @@ const projectResolvedAnalysisView = (
analysisProfile: parent.analysis_profile,
authority: parent.authority,
environment: parent.environment,
rawResult: parent.raw_result,
normalizedResult:
immutableJsonSnapshotMember(parent, "normalized_result") ??
parent.normalized_result,
@@ -1197,60 +1197,87 @@ describe("relationship coverage snapshot replay", () => {
});
});
it("does not replay pre-envelope call graphs under the revised workflow profile", () => {
const cache = new AnalysisSnapshotCache();
const target = ANALYSIS_SNAPSHOT_TARGET;
const profile = ANALYSIS_SNAPSHOT_PROFILE;
const operation = "get_call_graph" as const;
const parameters = { address: "0x1000", direction: "forward" };
const oldProfile = workflowAnalysisProfile(profile);
const revisedProfile = workflowAnalysisProfile(profile, operation);
const oldResult = { "0": [] };
cache.recordWorkflow({
target,
profile,
operation,
parameters,
execution: {
...createAnalysisExecution(oldResult, REA_WORKFLOW_PROVIDER, {
subject: target,
rawResult: oldResult,
}),
analysisProfile: oldProfile,
it.each([
{
operation: "get_call_graph",
parameters: { address: "0x1000", direction: "forward" },
result: { "0": [] },
},
{
operation: "trace_native_values",
parameters: { procedure: "0x1000" },
result: {
seed: "0x1000",
target_sha256: ANALYSIS_SNAPSHOT_TARGET.sha256,
nodes: [],
edges: [],
procedures: [],
unknowns: [],
total_nodes: 0,
total_edges: 0,
decompilations: 1,
next_offset: null,
page_truncated: false,
traversal: { status: "complete" },
truncated: false,
limitations: [],
},
});
const evidence = createEvidence(target, REA_WORKFLOW_PROVIDER, {
operation,
confidence: "derived",
parameters,
result: oldResult,
rawResult: oldResult,
analysisProfile: oldProfile,
});
const exported = cache.export(
target,
profile,
createEvidenceBundle([evidence]),
);
if (!exported.ok) throw exported.error;
const snapshot = parseAnalysisSnapshot(exported.value);
expect(
snapshotEvidenceForQuery(snapshot, {
},
] as const)(
"does not replay old $operation results under the revised workflow profile",
({ operation, parameters, result: oldResult }) => {
const cache = new AnalysisSnapshotCache();
const target = ANALYSIS_SNAPSHOT_TARGET;
const profile = ANALYSIS_SNAPSHOT_PROFILE;
const oldProfile = workflowAnalysisProfile(profile);
const revisedProfile = workflowAnalysisProfile(profile, operation);
cache.recordWorkflow({
target,
bindingProfile: profile,
profile,
operation,
parameters,
provider: REA_WORKFLOW_PROVIDER,
evidenceProfile: revisedProfile,
}),
).toBeUndefined();
expect(
enhancedOutputSchemas.get_call_graph.safeParse({
normalized_result: oldResult,
}).success,
).toBe(false);
});
execution: {
...createAnalysisExecution(oldResult, REA_WORKFLOW_PROVIDER, {
subject: target,
rawResult: oldResult,
}),
analysisProfile: oldProfile,
},
});
const evidence = createEvidence(target, REA_WORKFLOW_PROVIDER, {
operation,
confidence: "derived",
parameters,
result: oldResult,
rawResult: oldResult,
analysisProfile: oldProfile,
});
const exported = cache.export(
target,
profile,
createEvidenceBundle([evidence]),
);
if (!exported.ok) throw exported.error;
const snapshot = parseAnalysisSnapshot(exported.value);
expect(
snapshotEvidenceForQuery(snapshot, {
target,
bindingProfile: profile,
operation,
parameters,
provider: REA_WORKFLOW_PROVIDER,
evidenceProfile: revisedProfile,
}),
).toBeUndefined();
if (operation === "get_call_graph")
expect(
enhancedOutputSchemas.get_call_graph.safeParse({
normalized_result: oldResult,
}).success,
).toBe(false);
},
);
it("keeps older value-trace Evidence as history without replaying its source IDs", () => {
const cache = new AnalysisSnapshotCache();
@@ -1374,6 +1401,41 @@ it("declines source-bearing snapshot entries while replaying omitted and empty s
expect(cache.entries()).toHaveLength(2);
});
it("distinguishes value-flow caveat propagation from source-ownership revisions", () => {
const upstream = ANALYSIS_SNAPSHOT_PROFILE;
const prior = {
...workflowAnalysisProfile(upstream).parameters,
value_flow_absence: "recorded-versus-unavailable-v1",
workflow_revision: 1,
};
const baseline = createAnalysisProfile(REA_WORKFLOW_PROVIDER, prior);
const ownership = createAnalysisProfile(REA_WORKFLOW_PROVIDER, {
...prior,
workflow_revision: 2,
});
const limitations = createAnalysisProfile(REA_WORKFLOW_PROVIDER, {
...prior,
value_flow_limitations: "provider-attributed-v1",
});
const combined = createAnalysisProfile(REA_WORKFLOW_PROVIDER, {
...limitations.parameters,
workflow_revision: 2,
});
const scoped = workflowAnalysisProfile(upstream, "trace_native_values");
expect(scoped.digest).toBe(combined.digest);
expect(scoped.parameters.value_flow_limitations).toBe(
"provider-attributed-v1",
);
expect(
new Set([
baseline.digest,
ownership.digest,
limitations.digest,
combined.digest,
]).size,
).toBe(4);
});
it("replays source-bearing entries only when their complete retained closure exists", () => {
const target = ANALYSIS_SNAPSHOT_TARGET;
const profile = ANALYSIS_SNAPSHOT_PROFILE;
+2 -16
View File
@@ -1304,20 +1304,8 @@ export class BinarySession
options: {
readonly signal?: AbortSignal;
readonly detail?: BinarySessionDetail;
readonly refreshCandidates?: boolean;
} = {},
): Promise<JsonValue> {
return options.refreshCandidates === true
? this.#serialize(() => this.#status(options), { abortPrompts: false })
: this.#status(options);
}
/** Keep an explicit candidate refresh ordered with target transitions. */
#status(options: {
readonly signal?: AbortSignal;
readonly detail?: BinarySessionDetail;
readonly refreshCandidates?: boolean;
}): Promise<JsonValue> {
const resourceRun = this.#activeBundleResources;
const run =
this.#active ??
@@ -1618,11 +1606,9 @@ export class BinarySession
#serialize<T>(
operation: (admittedThrough: number) => Promise<T>,
options: { readonly abortPrompts?: boolean } = {},
): Promise<T> {
if (options.abortPrompts !== false)
for (const controller of this.#promptCompletionControllers)
controller.abort();
for (const controller of this.#promptCompletionControllers)
controller.abort();
this.#transitionPending += 1;
// Later calls wait for this transition and must not be included in its drain.
const admittedThrough = this.#transitionGeneration++;
@@ -166,7 +166,6 @@ it("opens a resource-only bundle without executable identity and keeps snapshot
expect(await session.status()).toMatchObject({
open: true,
session_kind: "bundle_resources",
analysis_provider_candidates_observed: "not_applicable",
resource_root: { canonical_path: opened.value.canonicalPath },
});
const snapshot = session.exportAnalysisSnapshot();
@@ -140,7 +140,6 @@ export interface BinarySessionPort
status(options?: {
readonly signal?: AbortSignal;
readonly detail?: BinarySessionDetail;
readonly refreshCandidates?: boolean;
}): Promise<JsonValue>;
activeTarget(): BinaryTarget | undefined;
/** Read a snapshot with failed descriptors retained for session cleanup retry. */
+1 -15
View File
@@ -47,7 +47,6 @@ interface BinarySessionStatusInput {
readonly cleanupRetry?: AnalysisCleanupRetry;
readonly detail?: BinarySessionDetail;
readonly signal?: AbortSignal;
readonly refreshCandidates?: boolean;
}
/** Project internal provider routing state into the caller-visible session status. */
@@ -67,7 +66,6 @@ export const binarySessionStatus = async ({
cleanupRetry,
detail = "complete",
signal,
refreshCandidates = false,
}: BinarySessionStatusInput): Promise<JsonValue> => {
const routeCapabilities = (
bundleResourceRoot === undefined
@@ -121,13 +119,7 @@ export const binarySessionStatus = async ({
const analysisProviderCandidates =
bundleResourceRoot !== undefined
? []
: (
await router.candidateStatuses(route, {
...(signal === undefined ? {} : { signal }),
...(target === undefined ? {} : { target }),
refresh: refreshCandidates,
})
).map((candidate) =>
: (await router.candidateStatuses(route, signal)).map((candidate) =>
detail === "summary"
? {
provider: providerSummary(candidate.provider),
@@ -161,12 +153,6 @@ export const binarySessionStatus = async ({
capabilities,
analysis_provider_binding: analysisProviderBinding,
analysis_provider_candidates: analysisProviderCandidates,
analysis_provider_candidates_observed:
bundleResourceRoot !== undefined
? "not_applicable"
: route.selection === undefined || refreshCandidates
? "status_request"
: "target_selection",
analysis_run:
runId === undefined
? null
@@ -129,26 +129,11 @@ export class SessionProviderRouter {
);
}
/** Preserve selection observations unless a caller requests a fresh inspection. */
/** Candidate status authoritative for the active or target-free route. */
async candidateStatuses(
route: SessionProviderRoute,
options: {
readonly signal?: AbortSignal;
readonly target?: BinaryTarget;
readonly refresh?: boolean;
} = {},
signal?: AbortSignal,
): Promise<readonly AnalysisProviderCandidateStatus[]> {
const { signal, target, refresh = false } = options;
if (refresh && route.selection !== undefined) {
const current = await this.registry.candidates(
target,
signal === undefined ? {} : { signal },
);
return current.map((candidate) => ({
...candidate,
selected: candidate.provider.id === route.binding?.identity.id,
}));
}
return (
route.selection?.candidates ??
this.registry.candidates(
@@ -564,6 +564,53 @@ describe("native value failed-callee traversal", () => {
});
});
it("preserves and deduplicates source value-flow limitations across analyzed functions", async () => {
const base = functionDossierSchema.parse(ghidraFunctionDossier());
const caller = functionDossierSchema.parse(
nativeFlowDossier(base, {
address: "0x1000",
name: "caller",
operations: [node("call", "0x1000", "CALL")],
parameters: [],
}),
);
const callee = functionDossierSchema.parse(
nativeFlowDossier(base, {
address: "0x2000",
name: "callee",
operations: [],
parameters: [],
}),
);
if (
!caller.native_value_flow?.available ||
!callee.native_value_flow?.available
)
throw new Error("Expected available fixture value flow");
caller.native_value_flow.limitations = ["Shared provider caveat."];
callee.native_value_flow.limitations = [
"Shared provider caveat.",
"Callee-specific caveat.",
];
const result = await traceNativeValues(
callBindingAnalysis({
callerAddress: "0x1000",
calleeAddress: "0x2000",
caller,
callee,
}),
{ procedure: "0x1000", max_functions: 2 },
);
if (!result.ok) throw result.error;
const limitations = nativeValueTraceSchema.parse(result.value).limitations;
expect(limitations).toContain(
"Fixture (fixture 1) analyze_function: Callee-specific caveat.",
);
expect(
limitations.filter((item) => item.includes("Shared provider caveat.")),
).toEqual(["Fixture (fixture 1) analyze_function: Shared provider caveat."]);
});
describe("native value call-binding fan-out", () => {
it("retains limitations embedded in resolved call-target results", async () => {
const callerAddress = "0x1000";
@@ -181,6 +181,14 @@ export const traceNativeValues = async (
analysis_profile_digest: execution.value.analysisProfile?.digest ?? null,
});
const flow = dossier.native_value_flow;
if (flow !== null)
sourceLimitations.push(
...flow.limitations.map((limitation) => ({
provider: execution.value.provider,
operation: "analyze_function" as const,
limitation,
})),
);
if (flow === null || !flow.available) {
unknowns.push({
procedure: address,
+3 -76
View File
@@ -51,26 +51,15 @@ export const registerCoreNativeCommands = (
): void => {
registerAnnotationCommand(cli, logger, runDirectAnalysis);
cli.command(CLI_COMMANDS.searchBytes, {
description:
"Find masked bytes; unbounded searches skip unloaded blocks and overlays",
description: "Find masked byte patterns in initialized provider memory",
args: z.object({
path: localPathStringSchema.describe("Local executable path"),
pattern: bytePatternFields.pattern,
}),
options: z.object({
mask: bytePatternFields.mask,
start: z
.string()
.optional()
.describe(
"Inclusive start address; use an overlay address space to search it",
),
end: z
.string()
.optional()
.describe(
"Inclusive end address; use an overlay address space to search it",
),
start: z.string().optional().describe("Inclusive start address"),
end: z.string().optional().describe("Inclusive end address"),
cursor: z
.string()
.optional()
@@ -151,68 +140,6 @@ export const registerCoreNativeCommands = (
}),
),
});
cli.command(CLI_COMMANDS.procedureReferences, {
description: `Inspect incoming or outgoing references for one native procedure. ${addressOrProcedureGuidance} ${equalSelectorGuidance}`,
args: z.object({
path: localPathStringSchema.describe("Local executable path"),
procedure: z
.string()
.min(1)
.optional()
.describe(
`Procedure name or provider address. ${addressOrProcedureGuidance}`,
),
}),
options: z.object({
procedure: z
.string()
.min(1)
.optional()
.describe(
`Procedure name or provider address; use --procedure=<value> for leading dashes. ${addressOrProcedureGuidance}`,
),
direction: z
.enum(["incoming", "outgoing"])
.default("outgoing")
.describe("Reference direction. Defaults to outgoing."),
snapshot: snapshotSelectionOption,
...analysisEvidenceDeliveryOptions,
...nativeOpenOptionFields,
}),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.procedureReferences, async () => {
const delivery = {
providerId: "auto",
operation: "procedure_references",
signal,
detail: options.detail,
evidenceOutput: options.evidence_output,
};
const invalid = cliEvidenceDeliveryInputError(delivery);
if (invalid !== undefined) return projectAnalysisError(invalid);
const result = await runDirectAnalysis(
args.path,
"procedure_references",
{
procedure: resolveCliAnalysisSelector(
args.procedure,
options.procedure,
"procedure",
"procedure_references",
),
direction: options.direction,
},
{
...directAnalysisOptionsFromCli(logger, options),
signal,
includeEvidenceSources: options.evidence_output !== undefined,
},
);
return publishCliAnalysisEvidence(result, delivery);
}),
),
});
cli.command(CLI_COMMANDS.readBytes, {
description:
"Read provider memory bytes; Ghidra memory may include loader fixups and differ from file bytes",
-1
View File
@@ -35,7 +35,6 @@ export const CLI_COMMANDS = Object.freeze({
annotateNativeFunction: "annotate-native-function",
inspectNativeApi: "inspect-native-api",
instructions: "instructions",
procedureReferences: "procedure-references",
search: "search",
importReferenceSource: "import-reference-source",
inspectArtifact: "inspect-artifact",
+1 -1
View File
@@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [
...toolContractMetadata("inspect_analysis_view"),
kind: "application",
description:
'Use native-procedure-references to page retained procedure reference or unresolved-call rows. Endpoint kind selected-procedure refers to the summary procedure, null preserves the original null, and shared carries an index into items.endpoints, where each other procedure is stored once per page. Exact source_address and target_address filters apply to reference rows. For inspect_api_contract parents, graphql-contract pages retained SDL types/fields, operation definitions, field references, fragment-spreads or diagnostics with exact filters and selected columns. Use javascript-routes collections to query route patterns, source-linked occurrences, source status, unknowns or summary counts from retained JavaScript application or web-bundle Evidence. Use web-bundle collections endpoints or inferences to page endpoint observations and inferences from retained analyze_web_bundle Evidence; summary returns target URL, origin, and counts. For retained trace_feature Evidence, kind native-feature-trace pages matches, references or unknowns; matches filter exact address, references filter exact source_address and target_address, source_index identifies each original row, and list_unknowns pages durable question heads. Inspect selected views of supported completed Evidence without repeating analysis. Use recorded-array with an exact raw_result object-member path to page a provider-recorded array when the producer supplies that path; rows keep their raw meaning and the parent retains coverage. For procedure_pseudo_code Evidence, summary reports text availability and the original selection; kind native with facet pseudocode pages the original UTF-16 text. Other native facets require analyze_function. Choose a view compatible with the parent operation described below. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Native export tables use kind native-exports, collection exports/issues, exact names or original indices, selected fields and matching-row pagination; parent Evidence plus source_index identifies each row. Source coverage stays separate from page coverage. BEAM views use kind beam, collections atoms/exports/imports/lambdas/chunks, exact names/indices/fields and matching-row pages; import modules intersects function names. source_index identifies original parent-and-collection rows, preserving every arity and raw atom index. beam.counts preserves null missing tables versus decoded empty tables; runtime absence remains unknown. PYC views use kind python-pyc, collection code_objects, names/indices/fields and paging; source_index scopes every same-name alternative to the parent, nested_code keeps code-constant references, and pyc.coverage preserves source coverage. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool hLine truncated
'For inspect_api_contract parents, graphql-contract pages retained SDL types/fields, operation definitions, field references, fragment-spreads or diagnostics with exact filters and selected columns. Use javascript-routes collections to query route patterns, source-linked occurrences, source status, unknowns or summary counts from retained JavaScript application or web-bundle Evidence. Use web-bundle collections endpoints or inferences to page endpoint observations and inferences from retained analyze_web_bundle Evidence; summary returns target URL, origin, and counts. For retained trace_feature Evidence, kind native-feature-trace pages matches, references or unknowns; matches filter exact address, references filter exact source_address and target_address, source_index identifies each original row, and list_unknowns pages durable question heads. Inspect selected views of supported completed Evidence without repeating analysis. For procedure_pseudo_code Evidence, summary reports text availability and the original selection; kind native with facet pseudocode pages the original UTF-16 text. Other native facets require analyze_function. Choose a view compatible with the parent operation described below. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Native export tables use kind native-exports, collection exports/issues, exact names or original indices, selected fields and matching-row pagination; parent Evidence plus source_index identifies each row. Source coverage stays separate from page coverage. BEAM views use kind beam, collections atoms/exports/imports/lambdas/chunks, exact names/indices/fields and matching-row pages; import modules intersects function names. source_index identifies original parent-and-collection rows, preserving every arity and raw atom index. beam.counts preserves null missing tables versus decoded empty tables; runtime absence remains unknown. PYC views use kind python-pyc, collection code_objects, names/indices/fields and paging; source_index scopes every same-name alternative to the parent, nested_code keeps code-constant references, and pyc.coverage preserves source coverage. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent also supports summary and bLine truncated
inputSchema: inspectAnalysisViewInputSchema,
outputSchema: evidenceResultOf(analysisViewResultSchema),
examples: [
+1 -1
View File
@@ -11,7 +11,7 @@ export const HOPPER_MEMORY_TOOL_DEFINITIONS = [
{
name: "search_bytes",
description:
"Find masked byte signatures in initialized analyzed memory. Hex byte pairs and ?? wildcards are supported; an optional equal-length hex mask selects bits. Bounds are inclusive. Without bounds, unloaded blocks and loaded overlays are skipped and listed separately; select an overlay address space with start or end to search it. Matches are ordered by provider address and may span adjacent eligible initialized blocks; gaps and excluded blocks end a contiguous range. A match’s segment and file offset describe its starting address. Coverage describes this page; reuse the same query with next_cursor to continue. Uninitialized blocks are listed separately. Ghidra only; Hopper is unavailable.",
"Find masked byte signatures in initialized analyzed memory. Hex byte pairs and ?? wildcards are supported; an optional equal-length hex mask selects bits. Bounds are inclusive. Matches are ordered by provider address and may span adjacent eligible initialized blocks; gaps and excluded blocks end a contiguous range. A match’s segment and file offset describe its starting address. Coverage describes this page; reuse the same query with next_cursor to continue. Uninitialized blocks are listed separately. Ghidra only; Hopper is unavailable.",
inputSchema: z
.strictObject({
...bytePatternFields,
+1 -3
View File
@@ -205,14 +205,12 @@ export const OFFICIAL_TOOL_CONTRACTS = [
),
official(
"procedure_references",
"Return incoming or outgoing reference edges for one procedure. Ghidra preserves detailed reference flags; Hopper preserves native CallReference classifications and exact endpoints. Select detail summary to retain complete Evidence and page references or unresolved calls using inspect_analysis_view kind native-procedure-references. Page coverage describes retained observations, not complete discovery or proof the target ran.",
"Return every raw incoming or outgoing reference edge for one procedure. Endpoint procedures are resolved only from provider containment. Ghidra preserves detailed reference flags. Hopper preserves native CallReference classifications and exact endpoints while leaving unavailable detailed flags explicit.",
z.object({
procedure,
direction: z.enum(["incoming", "outgoing"]).default("outgoing"),
document,
detail: analysisDetailSchema,
}),
{ evidenceParameters: "omit-detail" },
),
official(
"procedure_pseudo_code",
-6
View File
@@ -27,12 +27,6 @@ export const binarySessionInputSchema = z.strictObject({
.describe(
"Start with summary (default): it keeps open state, the binding, current-route capabilities (including selected Ghidra checkpoint mode), each provider candidate's availability and target-support status, code and reason, and every tool's availability, reason and remediation; omitted_fields lists what it leaves out. Request complete only when you need unavailable-candidate remediation, provider diagnostics, candidate capabilities or static tool surface, effect and annotation flags.",
),
refresh_candidates: z
.boolean()
.default(false)
.describe(
"Inspect current provider candidates for the active target without replacing or restarting the selected provider. Later transitions and analysis calls wait for inspection finalization. Omitted or false reports the retained selection observations.",
),
expected_package_version: z.string().min(1).optional(),
expected_catalog_digest: z
.string()
+1 -1
View File
@@ -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. analysis_provider_candidates_observed distinguishes this status request from retained target-selection observations; selection-time readiness may have changed. Set refresh_candidates true to inspect current candidates without changing the active binding or engine. open_binary with an explicit provider_id reselects and may retire the previous engine and its ephemeral edits; reopening the same target without provider_id reuses its current route. 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.',
'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(
+13 -5
View File
@@ -1,4 +1,3 @@
import { procedureReferencesSchema } from "../domain/native/procedureReferences.js";
import {
nativeAddressListSchema,
nativeObservationSourceSchemas,
@@ -12,7 +11,6 @@ import {
nativeFunctionSummaryViewResultSchema,
nativeInventorySummaryViewResultSchema,
nativeFeatureTraceSummaryViewResultSchema,
nativeProcedureReferencesSummaryViewResultSchema,
batchDecompileSummaryViewResultSchema,
nativeLoadImageSummaryViewResultSchema,
artifactInventorySummaryViewResultSchema,
@@ -35,6 +33,11 @@ import {
analysisBookmarkSchema,
nativeInventoryRowsSchemas,
} from "../domain/native/nativeInventory.js";
import {
referenceEdgeSchema,
unresolvedCallSchema,
} from "../domain/native/nativeReference.js";
import { procedureIdentitySchema } from "../domain/native/procedureInfo.js";
import { z } from "zod";
import {
evidenceBundleExportReceiptSchema,
@@ -231,9 +234,14 @@ export const officialOutputSchemas = {
),
}),
),
procedure_references: analysisDetailOutputSchemaOf(
procedureReferencesSchema,
nativeProcedureReferencesSummaryViewResultSchema,
procedure_references: evidenceResultOf(
z.object({
procedure: procedureIdentitySchema,
direction: z.enum(["incoming", "outgoing"]),
reference_kinds_available: z.boolean(),
unresolved_calls: z.array(unresolvedCallSchema),
references: z.array(referenceEdgeSchema),
}),
),
procedure_pseudo_code: analysisDetailOutputSchemaOf(
nullableText,
@@ -597,11 +597,6 @@ export const sessionProviderFacts = z
analysis_run: analysisRun,
analysis_activity: analysisActivity,
provider_operation_health: providerOperationHealth,
analysis_provider_candidates_observed: z
.enum(["status_request", "target_selection", "not_applicable"])
.describe(
"When provider candidate observations were collected: this status request, the retained target selection, or not applicable to a resource-only session.",
),
analysis_provider_binding: z
.object({
provider: providerIdentity,
-9
View File
@@ -1,4 +1,3 @@
import { projectNativeProcedureReferencesView } from "./nativeProcedureReferencesView.js";
import { projectPlaywrightTraceView } from "./playwrightTraceView.js";
import { projectApiContractView } from "./apiContractView.js";
import {
@@ -6,7 +5,6 @@ import {
NATIVE_OBSERVATION_OPERATIONS,
} from "../native/nativeObservation.js";
import { projectNativeObservationView } from "./nativeObservationView.js";
import { projectRecordedArrayView } from "./recordedArrayView.js";
import { projectNativeInstructionReferencesView } from "./nativeInstructionReferencesView.js";
import { projectNativeValueTraceView } from "./nativeValueTraceView.js";
import { projectDevicetreeView } from "./devicetreeView.js";
@@ -155,7 +153,6 @@ import {
il2cppMetadataSummaryViewResultSchema,
nativeInventorySummaryViewResultSchema,
nativeFeatureTraceSummaryViewResultSchema,
nativeProcedureReferencesSummaryViewResultSchema,
nativeLoadImageSummaryViewResultSchema,
nativeFunctionMatchingSummaryViewResultSchema,
nativeFunctionSummaryViewResultSchema,
@@ -232,7 +229,6 @@ export {
modelArtifactPageViewResultSchema,
il2cppMetadataSummaryViewResultSchema,
nativeInventorySummaryViewResultSchema,
nativeProcedureReferencesSummaryViewResultSchema,
nativeLoadImageSummaryViewResultSchema,
nativeFunctionMatchingSummaryViewResultSchema,
nativeFunctionSummaryViewResultSchema,
@@ -339,7 +335,6 @@ const retainedViewProjectors = new Map<string, RetainedViewProjector>([
...NATIVE_OBSERVATION_OPERATIONS.map((operation) =>
retain(operation, projectNativeObservationView),
),
retain("procedure_references", projectNativeProcedureReferencesView),
retain("trace_native_values", projectNativeValueTraceView),
retain("inspect_erlang_beam", projectErlangBeamView),
retain("inspect_devicetree", projectDevicetreeView),
@@ -480,7 +475,6 @@ export const projectAnalysisView = (
parent.operation === "match_native_functions"
? NATIVE_FUNCTION_MATCHING_PREDICATE
: parent.operation === "analyze_function" ||
parent.operation === "procedure_references" ||
parent.operation === "procedure_pseudo_code" ||
parent.operation === "inspect_native_instruction" ||
parent.operation === "trace_feature" ||
@@ -503,9 +497,6 @@ export const projectAnalysisView = (
},
]),
);
if (view.kind === "recorded-array") {
return sealProjected(projectRecordedArrayView(parent, view));
}
if (view.kind === "javascript-routes") {
const projected = projectJavaScriptRoutesView(parent, view);
return sealProjected(projected);
@@ -12,7 +12,6 @@ export interface AnalysisViewParent {
readonly operation: string;
readonly predicateType: string;
readonly normalizedResult: unknown;
readonly rawResult?: unknown;
readonly provider?: Evidence["provider"];
readonly analysisProfile?: Evidence["analysis_profile"];
readonly authority?: Evidence["authority"];
@@ -1,4 +1,3 @@
import { nativeProcedureReferencesViewRequestSchema } from "./nativeProcedureReferencesView.js";
import { graphqlContractViewRequestSchema } from "../apiContract/graphqlContractQuery.js";
import { playwrightTraceViewRequestSchema } from "../playwrightTrace/playwrightTraceQuery.js";
import { apiContractViewRequestSchema } from "../apiContract/apiContractQuery.js";
@@ -64,23 +63,11 @@ import {
pageViewSchema,
summaryViewSchema,
} from "./analysisViewShape.js";
export const recordedArrayViewSchema = z.strictObject({
kind: z.literal("recorded-array"),
path: z
.array(z.string())
.describe(
"Exact object-member path from the parent Evidence raw_result to the recorded array. The provider defines the values and their coverage.",
),
offset: z.int().nonnegative().default(0),
limit: z.int().positive().default(64),
});
/** Caller-selected projection of already completed analysis Evidence. */
export const analysisViewRequestSchema = z.discriminatedUnion("kind", [
nativeInstructionReferencesRequestSchema,
nativeObservationViewRequestSchema,
nativeContextViewRequestSchema,
recordedArrayViewSchema,
devicetreeViewRequestSchema,
erlangBeamViewRequestSchema,
pythonPycViewRequestSchema,
@@ -107,7 +94,6 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [
javascriptFactsViewRequestSchema,
nativeViewSchema,
nativeInventoryViewRequestSchema,
nativeProcedureReferencesViewRequestSchema,
nativeLoadImageViewRequestSchema,
artifactInventoryViewRequestSchema,
wasmViewRequestSchema,
@@ -14,11 +14,6 @@ import {
apiContractViewContextSchema,
apiContractViewItemsSchema,
} from "./apiContractView.js";
import {
procedureReferenceViewRequests,
procedureReferenceViewItems,
procedureReferenceSummarySchema,
} from "./nativeProcedureReferencesView.js";
import {
analysisViewCoverageSchema,
nullableCollectionViewCoverageSchema,
@@ -313,8 +308,6 @@ import {
pageViewSchema,
summaryViewSchema,
} from "./analysisViewShape.js";
import { recordedArrayViewSchema } from "./analysisViewRequest.js";
const artifactSchema = z.strictObject({
path: z
.string()
@@ -394,7 +387,6 @@ const viewResultBase = {
...NATIVE_OBSERVATION_OPERATIONS,
"trace_native_values",
"match_native_functions",
"procedure_references",
]),
parent_digest: digestSchema,
view_digest: digestSchema,
@@ -929,39 +921,6 @@ const nativeFeatureTracePageViewResultSchema = z
nativeFeatureTracePage("unknowns"),
]),
);
export const nativeProcedureReferencesSummaryViewResultSchema = z.strictObject({
...summaryViewResultBase,
...nativeViewSourceContext,
parent_operation: z.literal("procedure_references"),
artifact: artifactSchema.nullable(),
summary: procedureReferenceSummarySchema,
});
const procedureReferencePage = <
Collection extends keyof typeof procedureReferenceViewRequests,
>(
collection: Collection,
) =>
z.strictObject({
...viewResultBase,
...nativeViewSourceContext,
kind: z.literal("native-procedure-references"),
parent_operation: z.literal("procedure_references"),
artifact: artifactSchema.nullable(),
view: procedureReferenceViewRequests[collection],
summary: procedureReferenceSummarySchema,
items: procedureReferenceViewItems[collection],
byte_limited: z.boolean(),
});
const procedureReferencePageViewResultSchema = z
.object({ kind: z.literal("native-procedure-references") })
.passthrough()
.pipe(
z.union([
procedureReferencePage("references"),
procedureReferencePage("unresolved_calls"),
]),
);
/** Compact provider load observations retain the independent verification state. */
export const nativeLoadImageSummaryViewResultSchema = z.strictObject({
@@ -1243,20 +1202,6 @@ const machoPageViewResultSchema = z
/** Projected facts for one selected view of retained analysis Evidence. */
export const analysisViewResultSchema = z.discriminatedUnion("kind", [
z.strictObject({
...viewResultBase,
...nativeViewSourceContext,
source_parameters: jsonObjectSchema
.nullable()
.describe(
"Null means source parameters were omitted to fit the page; retrieve the parent Evidence.",
),
parent_operation: z.string(),
artifact: artifactSchema.nullable(),
kind: z.literal("recorded-array"),
view: recordedArrayViewSchema,
rows: z.array(jsonValueSchema),
}),
playwrightTracePageViewResultSchema,
z.strictObject({
...viewResultBase,
@@ -1369,7 +1314,6 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
gpuCodeObjectsSummaryViewResultSchema,
nativeInventorySummaryViewResultSchema,
nativeFeatureTraceSummaryViewResultSchema,
nativeProcedureReferencesSummaryViewResultSchema,
nativeLoadImageSummaryViewResultSchema,
nativeFunctionMatchingSummaryViewResultSchema,
nativeFunctionSummaryViewResultSchema,
@@ -1409,7 +1353,6 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
rows: z.array(nativeInventoryViewRowSchema),
}),
nativeFeatureTracePageViewResultSchema,
procedureReferencePageViewResultSchema,
z.strictObject({
...viewResultBase,
kind: z.literal("native-load-image"),
@@ -1,141 +0,0 @@
import { expect, it } from "vitest";
import type { AnalysisViewParent } from "./analysisViewParent.js";
import {
procedureReferenceViewRequests,
projectNativeProcedureReferencesView,
} from "./nativeProcedureReferencesView.js";
const PAGE_BYTES = 1024 * 1024;
const selected = {
address: "0x1000",
name: "selected",
classification: null,
body: { available: false as const, reason: "not reported" },
};
const parentFor = (
references: readonly Record<string, unknown>[],
): AnalysisViewParent => ({
evidenceId: `ev_${"a".repeat(64)}`,
operation: "procedure_references",
predicateType: "rea.analysis",
limitations: [],
parameters: {},
normalizedResult: {
procedure: selected,
direction: "outgoing",
reference_kinds_available: false,
unresolved_calls: [],
references,
},
});
const edge = (
sourceAddress: string,
target: Record<string, unknown> | null,
) => ({
source_address: sourceAddress,
target_address: "0x2000",
source_procedure: selected,
target_procedure: target,
kind: { available: false as const, reason: "not reported" },
});
const viewFor = (limit: number) =>
procedureReferenceViewRequests.references.parse({
kind: "native-procedure-references",
collection: "references",
offset: 0,
limit,
});
const pageBytes = (value: unknown) => Buffer.byteLength(JSON.stringify(value));
it("omits an endpoint that no accepted row uses", () => {
const projected = projectNativeProcedureReferencesView(
parentFor([
edge("0x1001", null),
edge("0x1002", {
...selected,
address: "0x3000",
name: "x".repeat(1_048_000),
}),
]),
viewFor(8),
);
if (!projected.ok) throw projected.error;
if (projected.value.kind !== "native-procedure-references")
throw new Error("Expected a procedure-reference page");
if (projected.value.items.collection !== "references")
throw new Error("Expected reference rows");
expect(projected.value.byte_limited).toBe(true);
expect(projected.value.items.rows).toHaveLength(1);
expect(projected.value.items.endpoints).toEqual([]);
expect(pageBytes(projected.value)).toBeLessThanOrEqual(PAGE_BYTES);
});
it("recognizes a multibyte selected procedure after the page is partly full", () => {
const multibyte = {
...selected,
name: "雪".repeat(200_000),
};
const filler = {
...selected,
address: "0x3000",
name: "y".repeat(150_000),
};
const projected = projectNativeProcedureReferencesView(
{
...parentFor([edge("0x1001", filler), edge("0x1002", multibyte)]),
normalizedResult: {
procedure: multibyte,
direction: "outgoing",
reference_kinds_available: false,
unresolved_calls: [],
references: [
{
...edge("0x1001", filler),
source_procedure: null,
},
{
...edge("0x1002", multibyte),
source_procedure: null,
},
],
},
},
viewFor(8),
);
if (!projected.ok) throw projected.error;
if (projected.value.kind !== "native-procedure-references")
throw new Error("Expected a procedure-reference page");
if (projected.value.items.collection !== "references")
throw new Error("Expected reference rows");
expect(projected.value.items.rows).toHaveLength(2);
expect(projected.value.items.rows[1]?.target_procedure).toEqual({
kind: "selected-procedure",
});
expect(projected.value.items.endpoints).toHaveLength(1);
});
it("counts distinct shared endpoints inside the page allowance", () => {
const projected = projectNativeProcedureReferencesView(
parentFor(
Array.from({ length: 40 }, (_, index) =>
edge(`0x${(0x2000 + index).toString(16)}`, {
...selected,
address: `0x${(0x8000 + index).toString(16)}`,
name: "endpoint".repeat(4_000),
}),
),
),
viewFor(40),
);
if (!projected.ok) throw projected.error;
if (projected.value.kind !== "native-procedure-references")
throw new Error("Expected a procedure-reference page");
if (projected.value.items.collection !== "references")
throw new Error("Expected reference rows");
expect(projected.value.byte_limited).toBe(true);
expect(projected.value.items.endpoints).toHaveLength(
projected.value.items.rows.length,
);
expect(pageBytes(projected.value)).toBeLessThanOrEqual(PAGE_BYTES);
});
@@ -1,391 +0,0 @@
import { z } from "zod";
import { canonicalJson } from "../comparisonSemantics.js";
import { jsonValueSchema } from "../jsonValue.js";
import { procedureIdentitySchema } from "../native/procedureInfo.js";
import {
referenceEdgeSchema,
unresolvedCallSchema,
} from "../native/nativeReference.js";
import { procedureReferencesSchema } from "../native/procedureReferences.js";
import { err, ok } from "../result.js";
import {
createAnalysisViewResultReader,
type AnalysisViewParent,
} from "./analysisViewParent.js";
import {
assertOwnedView,
type AdmittedView,
type SummaryAnalysisView,
} from "./analysisViewShape.js";
import { analysisViewInputError } from "./analysisViewErrors.js";
import { AnalysisResourceConstraintError } from "../analysisErrorCore.js";
import type { JsonValue } from "../jsonValue.js";
import { jsonValueByteLength } from "../jsonValueByteLength.js";
import {
boundedViewMessages,
completeWithinViewCoverage,
pageViewCoverage,
} from "./analysisViewCoverage.js";
const page = {
kind: z.literal("native-procedure-references"),
offset: z.int().nonnegative().default(0),
limit: z.int().positive().default(64),
};
export const procedureReferenceViewRequests = {
references: z.strictObject({
...page,
collection: z.literal("references"),
source_address: z
.string()
.min(1)
.optional()
.describe("Match an exact retained source address."),
target_address: z
.string()
.min(1)
.optional()
.describe("Match an exact retained target address."),
}),
unresolved_calls: z.strictObject({
...page,
collection: z.literal("unresolved_calls"),
}),
} as const;
export const nativeProcedureReferencesViewRequestSchema = z.discriminatedUnion(
"collection",
[
procedureReferenceViewRequests.references,
procedureReferenceViewRequests.unresolved_calls,
],
);
const endpointSchema = z.discriminatedUnion("kind", [
z.strictObject({ kind: z.literal("null") }),
z.strictObject({ kind: z.literal("selected-procedure") }),
z.strictObject({
kind: z.literal("shared"),
index: z.int().nonnegative(),
}),
]);
const rowSchema = referenceEdgeSchema.extend({
source_procedure: endpointSchema,
target_procedure: endpointSchema,
source_index: z.int().nonnegative(),
});
export const procedureReferenceViewItems = {
references: z.strictObject({
collection: z.literal("references"),
endpoints: z.array(procedureIdentitySchema),
rows: z.array(rowSchema),
}),
unresolved_calls: z.strictObject({
collection: z.literal("unresolved_calls"),
rows: z.array(
z.strictObject({
source_index: z.int().nonnegative(),
value: unresolvedCallSchema,
}),
),
}),
} as const;
export const procedureReferenceSummarySchema = z.strictObject({
procedure: procedureIdentitySchema,
direction: z.enum(["incoming", "outgoing"]),
reference_kinds_available: z.boolean(),
reference_count: z.int().nonnegative(),
unresolved_call_count: z.int().nonnegative(),
});
const readSource = createAnalysisViewResultReader(procedureReferencesSchema);
// The analysis-view projection allowance leaves room for Evidence and MCP
// encoding expansion. Count JSON without serializing the full page.
const PAGE_BYTES = 1024 * 1024;
const DIGEST_BYTES = jsonValueByteLength({ view_digest: "0".repeat(64) }) - 1;
/** Page the retained result without re-querying the provider or changing source coverage. */
export const projectNativeProcedureReferencesView = (
parent: AnalysisViewParent,
view: AdmittedView,
) => {
if (view.kind !== "summary" && view.kind !== "native-procedure-references")
return err(
analysisViewInputError([
{
path: ["view"],
reason: "invalid_value",
message:
"Procedure-reference Evidence supports summary and native-procedure-references views.",
},
]),
);
assertOwnedView<
| SummaryAnalysisView
| z.output<typeof nativeProcedureReferencesViewRequestSchema>
>(view, "summary", "native-procedure-references");
const parsed = readSource(parent);
if (!parsed.ok) return parsed;
const source = parsed.value;
const summary = {
procedure: source.procedure,
direction: source.direction,
reference_kinds_available: source.reference_kinds_available,
reference_count: source.references.length,
unresolved_call_count: source.unresolved_calls.length,
};
const shared = {
parent_evidence_id: parent.evidenceId,
parent_operation: "procedure_references" as const,
parent_digest: parent.evidenceId.slice(3),
artifact: parent.artifact ?? null,
source_provider: parent.provider ?? null,
source_analysis_profile_digest: parent.analysisProfile?.digest ?? null,
source_parameters: jsonValueSchema.parse(parent.parameters ?? null),
limitations: boundedViewMessages(
parent.limitations,
parent.evidenceId,
"limitations",
),
unknowns: [
"Page coverage describes retained references, not provider discovery completeness or runtime execution.",
],
};
const tooLarge = () =>
err(
new AnalysisResourceConstraintError(
"inspect_analysis_view",
"transport",
"The selected procedure-reference view cannot fit the analysis-view byte budget.",
{ parent_evidence_id: parent.evidenceId, limit_bytes: PAGE_BYTES },
{
retryable: false,
remediationAction:
"An individual row or the page context exceeds the fixed 1,048,576-byte projection allowance. Use the complete input Evidence or its local bundle, or retrieve or export the retained parent by the reported ID. If a CLI failure includes a partial observation, that observation is the completed parent.",
},
),
);
if (view.kind === "summary") {
const result = {
...shared,
kind: "summary" as const,
view,
summary,
coverage: completeWithinViewCoverage(1, 1),
};
return jsonValueByteLength(result as JsonValue, PAGE_BYTES - DIGEST_BYTES) >
PAGE_BYTES - DIGEST_BYTES
? tooLarge()
: ok(result);
}
const common = {
...shared,
kind: "native-procedure-references" as const,
view,
summary,
};
const header = {
...common,
items:
view.collection === "references"
? { collection: view.collection, endpoints: [], rows: [] }
: { collection: view.collection, rows: [] },
byte_limited: false,
coverage: {
status: "complete-within-view",
examined: Number.MAX_SAFE_INTEGER,
total: Number.MAX_SAFE_INTEGER,
next_offset: Number.MAX_SAFE_INTEGER,
exhausted: false,
},
};
let bytes =
DIGEST_BYTES +
jsonValueByteLength(header as JsonValue, PAGE_BYTES - DIGEST_BYTES);
if (bytes > PAGE_BYTES) return tooLarge();
const referenceRows: z.infer<typeof rowSchema>[] = [];
const unresolvedRows: {
source_index: number;
value: z.infer<typeof unresolvedCallSchema>;
}[] = [];
let byteLimited = false;
const offer = (row: JsonValue, accepted: number, extra = 0) => {
const size =
jsonValueByteLength(row, PAGE_BYTES - bytes) +
(accepted > 0 ? 1 : 0) +
extra;
if (bytes + size > PAGE_BYTES) {
byteLimited = true;
return false;
}
bytes += size;
return true;
};
if (view.collection === "unresolved_calls") {
const calls = source.unresolved_calls;
const end = Math.min(calls.length, view.offset + view.limit);
for (let index = view.offset; index < end; index += 1) {
const value = calls[index];
if (value === undefined) break;
const row = { source_index: index, value };
if (!offer(row, unresolvedRows.length)) break;
unresolvedRows.push(row);
}
if (byteLimited && unresolvedRows.length === 0) return tooLarge();
return ok({
...common,
view,
items: { collection: "unresolved_calls" as const, rows: unresolvedRows },
byte_limited: byteLimited,
coverage: pageViewCoverage(
view.offset,
unresolvedRows.length,
calls.length,
),
});
}
const selected = canonicalJson(source.procedure);
const endpoints: z.infer<typeof procedureIdentitySchema>[] = [];
const endpointIndexes = new Map<string, number>();
let longestIdentityBytes = Buffer.byteLength(selected);
const planEndpoints = (
sourceProcedure: z.infer<typeof procedureIdentitySchema> | null,
targetProcedure: z.infer<typeof procedureIdentitySchema> | null,
) => {
const pending: z.infer<typeof procedureIdentitySchema>[] = [];
const pendingKeys: string[] = [];
let extra = 0;
let overflow = false;
const resolve = (value: z.infer<typeof procedureIdentitySchema> | null) => {
if (value === null) return { kind: "null" as const };
const comma = endpoints.length + pending.length > 0 ? 1 : 0;
const measured = jsonValueByteLength(
value as JsonValue,
Math.max(0, PAGE_BYTES - bytes - extra),
);
const size = measured + comma;
// `measured` is a UTF-8 lower bound. It can skip canonicalization only
// when it already exceeds every stored identity's UTF-8 length.
if (
bytes + extra + size > PAGE_BYTES &&
measured > longestIdentityBytes
) {
overflow = true;
return undefined;
}
const key = canonicalJson(value);
if (key === selected) return { kind: "selected-procedure" as const };
const existing = endpointIndexes.get(key);
if (existing !== undefined)
return { kind: "shared" as const, index: existing };
const pendingIndex = pendingKeys.indexOf(key);
if (pendingIndex !== -1)
return {
kind: "shared" as const,
index: endpoints.length + pendingIndex,
};
if (bytes + extra + size > PAGE_BYTES) {
overflow = true;
return undefined;
}
extra += size;
pending.push(value);
pendingKeys.push(key);
const keyBytes = Buffer.byteLength(key);
if (keyBytes > longestIdentityBytes) longestIdentityBytes = keyBytes;
return {
kind: "shared" as const,
index: endpoints.length + pending.length - 1,
};
};
const resolvedSource = resolve(sourceProcedure);
const resolvedTarget = resolve(targetProcedure);
if (
overflow ||
resolvedSource === undefined ||
resolvedTarget === undefined
)
return { overflow: true as const };
return {
overflow: false as const,
resolvedSource,
resolvedTarget,
pending,
pendingKeys,
extra,
};
};
const filtered =
view.source_address !== undefined || view.target_address !== undefined;
const references = source.references;
const visit = function* () {
if (!filtered) {
const end = Math.min(references.length, view.offset + view.limit);
for (let index = view.offset; index < end; index += 1) {
const reference = references[index];
if (reference !== undefined) yield [index, reference] as const;
}
return;
}
let seen = 0;
for (const [index, reference] of references.entries()) {
if (
(view.source_address !== undefined &&
reference.source_address !== view.source_address) ||
(view.target_address !== undefined &&
reference.target_address !== view.target_address)
)
continue;
if (seen >= view.offset && seen < view.offset + view.limit)
yield [index, reference] as const;
seen += 1;
}
};
let filteredTotal = 0;
if (filtered) {
for (const reference of references) {
if (
(view.source_address === undefined ||
reference.source_address === view.source_address) &&
(view.target_address === undefined ||
reference.target_address === view.target_address)
)
filteredTotal += 1;
}
}
for (const [index, reference] of visit()) {
const planned = planEndpoints(
reference.source_procedure,
reference.target_procedure,
);
if (planned.overflow) {
byteLimited = true;
break;
}
const row = {
...reference,
source_index: index,
source_procedure: planned.resolvedSource,
target_procedure: planned.resolvedTarget,
};
if (!offer(row as JsonValue, referenceRows.length, planned.extra)) break;
for (const [offset, value] of planned.pending.entries()) {
endpointIndexes.set(planned.pendingKeys[offset]!, endpoints.length);
endpoints.push(value);
}
referenceRows.push(row);
}
if (byteLimited && referenceRows.length === 0) return tooLarge();
return ok({
...common,
view,
items: {
collection: "references" as const,
endpoints,
rows: referenceRows,
},
byte_limited: byteLimited,
coverage: pageViewCoverage(
view.offset,
referenceRows.length,
filtered ? filteredTotal : references.length,
),
});
};
@@ -1,123 +0,0 @@
import { expect, it } from "vitest";
import { createEvidence } from "../evidence.js";
import { MAX_JSON_DEPTH, type JsonValue } from "../jsonValue.js";
import { projectAnalysisError } from "../analysisErrorProjection.js";
import type { AnalysisViewParent } from "./analysisViewParent.js";
import {
analysisViewRequestSchema,
projectAnalysisView,
} from "./analysisView.js";
const nested = (levels: number): JsonValue => {
let value: JsonValue = "leaf";
for (let index = 0; index < levels; index += 1) value = { child: value };
return value;
};
const parent = (
parameters: JsonValue,
row: JsonValue = "row",
): AnalysisViewParent => ({
predicateType: "rea.analysis",
evidenceId: `ev_${"b".repeat(64)}`,
operation: "xrefs",
normalizedResult: ["0x10"],
rawResult: { values: [row] },
sealedResult: true,
provider: { id: "fixture", name: "Fixture", version: "1" },
artifact: null,
subject: null,
parameters,
limitations: [],
});
const project = (source: AnalysisViewParent) =>
projectAnalysisView(
source,
analysisViewRequestSchema.parse({
kind: "recorded-array",
path: ["values"],
offset: 0,
limit: 1,
}),
);
it("omits parent parameters that would exceed Evidence depth by one level", () => {
// Parameters are one field below the Evidence root. levels === 999 puts the
// leaf at depth 1000 there, and one level deeper under normalized_result.
const parameters = nested(MAX_JSON_DEPTH - 1);
const admitted = createEvidence(
undefined,
{ id: "fixture", name: "Fixture", version: "1" },
{
operation: "xrefs",
parameters,
result: ["0x10"],
rawResult: { values: ["row"] },
},
);
expect(admitted.parameters).toStrictEqual(parameters);
const projected = project(parent(parameters));
if (!projected.ok) throw projected.error;
expect(projected.value).toMatchObject({
kind: "recorded-array",
source_parameters: null,
rows: ["row"],
});
expect(projected.value.limitations.join("\n")).toContain("JSON depth");
expect(() =>
createEvidence(
undefined,
{ id: "view", name: "View", version: "1" },
{
operation: "inspect_analysis_view",
parameters: { parent_evidence_id: admitted.evidence_id },
result: projected.value,
rawResult: null,
},
),
).not.toThrow();
});
it("keeps parameters that still fit one level below the Evidence root", () => {
const parameters = nested(MAX_JSON_DEPTH - 2);
const projected = project(parent(parameters));
if (!projected.ok) throw projected.error;
if (projected.value.kind !== "recorded-array")
throw new Error("expected a recorded-array page");
expect(projected.value.source_parameters).toEqual(parameters);
});
it("returns a typed depth refusal instead of throwing when a row cannot be nested", () => {
const projected = project(parent({}, nested(MAX_JSON_DEPTH - 1)));
expect(projected.ok).toBe(false);
if (projected.ok) return;
expect(projectAnalysisError(projected.error)).toMatchObject({
code: "resource_constraint",
details: { reported_limits: { maximum_json_depth: MAX_JSON_DEPTH } },
});
expect(projectAnalysisError(projected.error).remediation.action).toContain(
"JSON depth",
);
});
it("stops before a later row exceeds the output Evidence depth", () => {
const source = parent({});
const first = nested(MAX_JSON_DEPTH - 3);
const tooDeep = nested(MAX_JSON_DEPTH - 2);
const projected = projectAnalysisView(
{ ...source, rawResult: { values: [first, tooDeep, "later"] } },
analysisViewRequestSchema.parse({
kind: "recorded-array",
path: ["values"],
offset: 0,
limit: 3,
}),
);
if (!projected.ok) throw projected.error;
expect(projected.value).toMatchObject({
kind: "recorded-array",
rows: [first],
coverage: { examined: 1, total: 3, next_offset: 1 },
});
});
@@ -1,187 +0,0 @@
import type { z } from "zod";
import {
jsonValueValidationIssue,
MAX_JSON_DEPTH,
type JsonValue,
} from "../jsonValue.js";
import { jsonValueByteLength } from "../jsonValueByteLength.js";
import { AnalysisResourceConstraintError } from "../analysisErrorCore.js";
import { err, ok } from "../result.js";
import type { AnalysisViewParent } from "./analysisViewParent.js";
import { pageViewCoverage } from "./analysisViewCoverage.js";
import { analysisViewInputError } from "./analysisViewErrors.js";
import type { recordedArrayViewSchema } from "./analysisViewRequest.js";
const PAGE_BYTES = 1024 * 1024;
/**
* Parent parameters sit on the Evidence root. This page stores them under
* `normalized_result.source_parameters`, one level deeper. A parent that used
* the whole depth budget is valid and still cannot be copied unchanged.
*/
const exceedsEvidenceResultDepth = (result: JsonValue): boolean =>
jsonValueValidationIssue({ normalized_result: result }) !== undefined;
/** A row is nested under normalized_result.rows in the output Evidence. */
const exceedsRecordedRowDepth = (row: JsonValue): boolean =>
jsonValueValidationIssue({ normalized_result: { rows: [row] } }) !==
undefined;
const recordedArrayDepthRefusal = () =>
err(
new AnalysisResourceConstraintError(
"inspect_analysis_view",
"memory",
`Recorded array view exceeds the maximum JSON nesting depth of ${MAX_JSON_DEPTH}.`,
{ maximum_json_depth: MAX_JSON_DEPTH },
{
retryable: false,
remediationAction:
"A recorded value cannot be nested inside an Evidence page without exceeding the JSON depth limit. Retrieve or export the complete parent Evidence, or read it from the caller-supplied Evidence file or bundle.",
},
),
);
/** Page an exact array within authenticated provider-recorded raw JSON. */
export const projectRecordedArrayView = (
parent: AnalysisViewParent,
view: z.output<typeof recordedArrayViewSchema>,
) => {
let value: unknown = parent.rawResult;
for (const key of view.path) {
if (
typeof value !== "object" ||
value === null ||
Array.isArray(value) ||
!Object.hasOwn(value, key)
)
return err(
analysisViewInputError([
{
path: ["view", "path"],
reason: "invalid_value",
message:
"The selected path does not identify an object member in recorded raw data.",
},
]),
);
value = (value as Record<string, unknown>)[key];
}
if (!Array.isArray(value))
return err(
analysisViewInputError([
{
path: ["view", "path"],
reason: "invalid_value",
message: "The selected recorded raw value is not an array.",
},
]),
);
const rows: JsonValue[] = [];
let result = {
parent_evidence_id: parent.evidenceId,
parent_operation: parent.operation,
parent_digest: parent.evidenceId.slice(3),
source_provider: parent.provider ?? null,
source_analysis_profile_digest: parent.analysisProfile?.digest ?? null,
source_parameters: (parent.parameters ?? {}) as Record<
string,
JsonValue
> | null,
artifact: parent.artifact ?? null,
kind: "recorded-array" as const,
view,
rows,
coverage: pageViewCoverage(view.offset, 0, value.length),
limitations: [
"These are raw provider-recorded values; interpretation and provider coverage require the parent Evidence.",
"Raw rows may include values excluded from the parent operation's normalized result; use source parameters and parent caveats to interpret their scope.",
],
unknowns: [],
};
// Include the sealed digest and reserve the longest numeric coverage fields
// before adding rows. Output transport may impose a smaller separate limit.
const estimateBase = () =>
jsonValueByteLength(
{
...result,
view_digest: "0".repeat(64),
coverage: {
status: "complete-within-view",
examined: Number.MAX_SAFE_INTEGER,
total: Number.MAX_SAFE_INTEGER,
next_offset: Number.MAX_SAFE_INTEGER,
exhausted: false,
},
},
PAGE_BYTES,
);
const omitParameters = (reason: "bytes" | "depth") => {
result = {
...result,
source_parameters: null,
limitations: [
...result.limitations,
reason === "depth"
? "Source parameters were omitted because nesting them under this Evidence page exceeds the maximum JSON depth; retrieve the parent Evidence for their complete values."
: "Source parameters were omitted to fit the page allowance; retrieve the parent Evidence for their complete values.",
],
};
};
if (result.source_parameters !== null && exceedsEvidenceResultDepth(result))
omitParameters("depth");
let bytes = estimateBase();
if (bytes > PAGE_BYTES) {
omitParameters("bytes");
bytes = estimateBase();
}
const tooLarge = () =>
err(
new AnalysisResourceConstraintError(
"inspect_analysis_view",
"memory",
"Recorded array view exceeds the 1 MiB page allowance.",
{ maximum_page_bytes: PAGE_BYTES },
{
retryable: false,
remediationAction:
"The selected path or one recorded value exceeds the 1,048,576-byte analysis-view page allowance. Retrieve or export the complete parent Evidence, or read it from the caller-supplied Evidence file or bundle.",
},
),
);
if (bytes > PAGE_BYTES) return tooLarge();
const end = Math.min(value.length, view.offset + view.limit);
for (let i = view.offset; i < end; i++) {
const row = value[i] as JsonValue;
const comma = rows.length === 0 ? 0 : 1;
let size = jsonValueByteLength(row, PAGE_BYTES - bytes - comma);
if (
bytes + comma + size > PAGE_BYTES &&
rows.length === 0 &&
result.source_parameters !== null
) {
omitParameters("bytes");
bytes = estimateBase();
if (bytes > PAGE_BYTES) return tooLarge();
size = jsonValueByteLength(row, PAGE_BYTES - bytes);
}
if (bytes + comma + size > PAGE_BYTES) {
if (rows.length === 0) return tooLarge();
break;
}
// The fixed fields are checked separately. Validate only this row at its
// final nesting depth; previously accepted rows cannot change, so walking
// the growing page for every row repeats the same work quadratically.
if (exceedsRecordedRowDepth(row)) {
if (rows.length === 0) return recordedArrayDepthRefusal();
break;
}
bytes += comma + size;
rows.push(row);
}
if (exceedsEvidenceResultDepth(result)) return recordedArrayDepthRefusal();
return ok({
...result,
coverage: pageViewCoverage(view.offset, rows.length, value.length),
});
};
-2
View File
@@ -22,7 +22,6 @@ it.each(["0x20", "overlay:0x10", "invalid"])(
bytes: 16,
skipped_uninitialized: [],
skipped_not_loaded: [],
skipped_overlays: [],
},
complete: true,
next_cursor: null,
@@ -53,7 +52,6 @@ it.each(["", "overlay:"])(
bytes: 16,
skipped_uninitialized: [],
skipped_not_loaded: [],
skipped_overlays: [],
},
complete: true,
next_cursor: null,
+2 -15
View File
@@ -165,22 +165,9 @@ export const byteSearchResultSchema = z
blocks: z.array(coverageBlock),
bytes: z.int().nonnegative().max(Number.MAX_SAFE_INTEGER),
skipped_uninitialized: z.array(coverageBlock),
skipped_not_loaded: z
.array(coverageBlock)
.describe(
"Unloaded blocks skipped by an unbounded search, including unloaded overlays.",
),
skipped_overlays: z
.array(coverageBlock)
.describe(
"Loaded overlay blocks skipped by an unbounded search; select their address space with a bound.",
),
skipped_not_loaded: z.array(coverageBlock),
}),
complete: z
.boolean()
.describe(
"No further matches remain in the eligible selection; skipped blocks were not searched.",
),
complete: z.boolean(),
next_cursor: z.string().min(1).nullable(),
})
.superRefine((value, context) => {
+5 -1
View File
@@ -51,7 +51,11 @@ export const availableNativeValueFlowSchema = z.strictObject({
provenance: z.literal("ghidra-high-pcode"),
operations: z.array(nativePcodeOperationSchema),
def_use: z.array(nativePcodeDefUseSchema),
effects: z.array(nativePcodeEffectSchema),
effects: z
.array(nativePcodeEffectSchema)
.describe(
"Observed p-code effects. Memory reads and writes cover LOAD and STORE operations only; direct memory address varnodes in other operations are excluded. An empty list does not establish no memory access.",
),
parameters: z.array(
z.strictObject({
ordinal: z.int().nonnegative(),
-15
View File
@@ -1,15 +0,0 @@
import { z } from "zod";
import { procedureIdentitySchema } from "./procedureInfo.js";
import {
referenceEdgeSchema,
unresolvedCallSchema,
} from "./nativeReference.js";
/** Complete provider-observed references and unresolved calls for one procedure. */
export const procedureReferencesSchema = z.object({
procedure: procedureIdentitySchema,
direction: z.enum(["incoming", "outgoing"]),
reference_kinds_available: z.boolean(),
unresolved_calls: z.array(unresolvedCallSchema),
references: z.array(referenceEdgeSchema),
});
+1 -2
View File
@@ -61,6 +61,7 @@ const sharedAnalysisSemantics = {
function_references: "complete-body-and-entry-reference-manager-v3",
call_relationship_coverage: "function-body-call-coverage-v5",
function_dossier_coverage: "observed-facets-and-call-relationships-v1",
value_flow_effect_scope: "pcode-load-store-explicit-v1",
string_inventory_evidence: "defined-data-coverage-v1",
inventory_search_projection: "provider-inventory-facts-v1",
data_type_observations: "bit-field-base-and-lookup-v1",
@@ -71,7 +72,6 @@ const sharedAnalysisSemantics = {
instruction_flow_evidence: "decoded-return-pcode-v1",
instruction_status: "unlisted-and-containing-code-data-v3",
instruction_references: "exact-address-incoming-without-synthetic-entry-v3",
byte_search_coverage: "separate-loaded-overlays-v1",
function_boundary_observations: "ghidra-function-boundary-limitations-v4",
} as const;
@@ -487,7 +487,6 @@ export const existingGhidraAnalysisProfile = (
...(mipsElf === undefined ? {} : { mips_elf: mipsElf }),
import_mode: "existing-project-private-copy-v1",
...sharedAnalysisSemantics,
selected_project_relationship_coverage: "prior-analysis-unknown-v1",
annotation_policy: "read-only-existing-project-v1",
existing_project: project,
original_analysis: { completeness: "unknown", settings: "unknown" },
-10
View File
@@ -360,14 +360,6 @@ const byteSearch = (space = "") => ({
bytes: 16,
},
],
skipped_overlays: [
{
segment: "overlay",
start: `${space}0x0040`,
end: `${space}0x004f`,
bytes: 16,
},
],
},
complete: true,
next_cursor: null as string | null,
@@ -393,8 +385,6 @@ it.each([
["uninitialized end", ["searched", "skipped_uninitialized", 0, "end"]],
["unloaded start", ["searched", "skipped_not_loaded", 0, "start"]],
["unloaded end", ["searched", "skipped_not_loaded", 0, "end"]],
["overlay start", ["searched", "skipped_overlays", 0, "start"]],
["overlay end", ["searched", "skipped_overlays", 0, "end"]],
] as const)(
"rejects noncanonical %s at its owning address field",
(_label, path) => {
-1
View File
@@ -445,7 +445,6 @@ const resultSchemas = {
blocks: z.array(canonicalByteCoverage),
skipped_uninitialized: z.array(canonicalByteCoverage),
skipped_not_loaded: z.array(canonicalByteCoverage),
skipped_overlays: z.array(canonicalByteCoverage),
}),
}),
inspect_native_load_image: nativeLoadImageObservationSchema,
+1 -1
View File
@@ -138,7 +138,7 @@ export const limitationsFor = (
case "search_bytes":
return [
...common,
"Searches contiguous eligible initialized loader-adjusted memory in provider address order, including across adjacent blocks. Bounds and page coverage ends are inclusive. Unbounded searches separately list skipped unloaded blocks and loaded overlays; use an overlay address-space bound to search it. Uninitialized blocks are listed separately and break search runs. Overlapping matches are included. A cursor resumes the same target, profile and query. Cancellation discards the active search and returns no successful partial page.",
"Searches contiguous eligible initialized loader-adjusted memory in provider address order, including across adjacent blocks. Bounds and page coverage ends are inclusive. Uninitialized blocks are listed separately and break search runs. Overlapping matches are included. A cursor resumes the same target, profile and query. Cancellation discards the active search and returns no successful partial page.",
];
case "address_to_file_offset":
return [
+4 -4
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": "bac13dfcedcd47a2ad2a69585146cf192cdc210593732b737c7a98d8f88dde9d",
"6502": "04e570d6e4d3486c9432039f2f390fee250e23a542c496c236a0e74584804f2f",
"x86-disabled-analyzer":
"f62ac1a36dca4d5c3399b501f8fdf23a132f471c5172d275fd59d84921d7e90a",
"22207cc0fe7ec188d6c51ea51f3faadd941bd5f92fe8d4b258111e61221712ab",
} as const;
const configFor = (environment: NodeJS.ProcessEnv) => {
@@ -50,8 +50,8 @@ describe("raw-binary analysis profile digest", () => {
"source-mappings-entry-context-relocation-symbol-v3",
instruction_status: "unlisted-and-containing-code-data-v3",
inventory_search_projection: "provider-inventory-facts-v1",
value_flow_effect_scope: "pcode-load-store-explicit-v1",
byte_search_boundary: "contiguous-eligible-initialized-blocks-v1",
byte_search_coverage: "separate-loaded-overlays-v1",
data_type_observations: "bit-field-base-and-lookup-v1",
instruction_references:
"exact-address-incoming-without-synthetic-entry-v3",
@@ -75,7 +75,7 @@ describe("raw-binary analysis profile digest", () => {
);
expect(resolved.value.profile?.parameters).toMatchObject({
inventory_search_projection: "provider-inventory-facts-v1",
byte_search_coverage: "separate-loaded-overlays-v1",
value_flow_effect_scope: "pcode-load-store-explicit-v1",
analysis_options: {
disabled_analyzers: ["Aggressive Instruction Finder"],
},
+2 -2
View File
@@ -308,8 +308,8 @@ describe("Ghidra relationship coverage replay", () => {
describe("Ghidra snapshot profile admission", () => {
it.each([
{
parameter: "byte_search_coverage",
current: "separate-loaded-overlays-v1",
parameter: "value_flow_effect_scope",
current: "pcode-load-store-explicit-v1",
legacy: undefined,
},
{
-6
View File
@@ -1089,12 +1089,6 @@ export class KunaSessionClient implements AnalysisClient {
locations,
limitations: [
...limitations,
...(references.value.pointers !== null &&
references.value.pointers.length > 0
? [
'The recorded pointer rows can be paged from this Evidence with inspect_analysis_view: view {"kind":"recorded-array","path":["references","pointers"]}. These raw Kuna rows may include alias targets; compare each row\'s to_address with the selected address before treating it as an exact-target reference.',
]
: []),
...(references.value.pointerCoverage === null
? []
: [
+3 -6
View File
@@ -1039,12 +1039,9 @@ class ErrorEvidenceTransport implements Transport {
"The MCP tool arguments exceed the configured input element allowance; schema validation and tool execution did not start.",
{ max_input_elements: this.maximumInputElements },
{
remediationAction:
tool === "inspect_analysis_view"
? 'If using inline Evidence, save the Evidence record as a local JSON file and use source {"kind":"evidence-file","path":"..."}; if it is retained, use its retained-evidence ID. Otherwise reduce the combined array elements and object members, or restart REA with a larger REA_MCP_MAX_INPUT_ELEMENTS allowance.'
: this.evidenceReferenceAlternatives.has(tool)
? 'If the source Evidence is retained, use {"kind":"retained-evidence","evidence_id":"ev_..."} for declared Evidence fields; use a summary\'s parent_evidence_id for its complete source. Otherwise import the originating Evidence bundle when available. Reduce the arguments, or restart REA with a larger REA_MCP_MAX_INPUT_ELEMENTS allowance.'
: "Reduce the combined array elements and object members in the arguments, or restart REA with a larger REA_MCP_MAX_INPUT_ELEMENTS allowance for this workload.",
remediationAction: this.evidenceReferenceAlternatives.has(tool)
? 'If the source Evidence is retained, use {"kind":"retained-evidence","evidence_id":"ev_..."} for declared Evidence fields; use a summary\'s parent_evidence_id for its complete source. Otherwise import the originating Evidence bundle when available. Reduce the arguments, or restart REA with a larger REA_MCP_MAX_INPUT_ELEMENTS allowance.'
: "Reduce the combined array elements and object members in the arguments, or restart REA with a larger REA_MCP_MAX_INPUT_ELEMENTS allowance for this workload.",
},
);
const result = this.delivery.toErrorToolResult(error, targetContent);
-1
View File
@@ -103,7 +103,6 @@ export const registerSessionStatusTool = (
status = await session.status({
signal: context.mcpReq.signal,
detail: input.detail,
refreshCandidates: input.refresh_candidates,
});
} catch (cause) {
if (!(cause instanceof AnalysisError)) throw cause;
@@ -1,6 +1,5 @@
import {
captureDecoderHeapRecovery,
CAPTURE_DECODER_OLD_GENERATION_SETTING,
DEFAULT_CAPTURE_DECODER_HEAP,
} from "../../../src/config/captureDecoderHeap.js";
import { execFile } from "node:child_process";
@@ -10,7 +9,7 @@ import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { expect, it } from "vitest";
import { captureDecoderProcessMemoryLimitBytes } from "../../../src/browser/history/CaptureRelease.js";
import { NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES } from "../../fixtures/captureDecoderLimits.js";
import { WEB_NETWORK_CAPTURE_LIMITS } from "../../../src/domain/webNetworkCapture.js";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
@@ -51,10 +50,7 @@ const capture = (count: number) =>
// Node.js enforces the decoder's V8 heap flags. Bun ignores them; on Windows
// the decoder's Job Object commit limit is the enforced bound instead, and
// Bun elsewhere has no bound this capture can reach.
const testHeap = { oldGenerationMiB: 64, source: "setting" } as const;
const jobLimit = captureDecoderProcessMemoryLimitBytes(
testHeap.oldGenerationMiB,
);
const jobLimit = NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES;
it.skipIf(process.versions.bun !== undefined && jobLimit === null)(
"reports real HAR memory exhaustion through CLI and MCP and preserves server usability",
async () => {
@@ -65,11 +61,7 @@ it.skipIf(process.versions.bun !== undefined && jobLimit === null)(
WEB_NETWORK_CAPTURE_LIMITS.inputBytes,
);
await writeFile(path, dense);
const { call, client } = await connectLocalToolsMcp({
[CAPTURE_DECODER_OLD_GENERATION_SETTING]: String(
testHeap.oldGenerationMiB,
),
});
const { call, client } = await connectLocalToolsMcp();
const response = await call("inspect_web_network_capture", {
capture_path: path,
format: "har",
@@ -85,14 +77,14 @@ it.skipIf(process.versions.bun !== undefined && jobLimit === null)(
resource: "memory",
limit: {
setting: "capture.decoder_old_generation_mib",
effective_value: testHeap.oldGenerationMiB,
source: testHeap.source,
effective_value: 192,
source: "default",
rea_restart_required: false,
},
reported_limits:
jobLimit === null
? {
old_generation_heap_mib: testHeap.oldGenerationMiB,
old_generation_heap_mib: 192,
observed_exit_code: process.platform === "win32" ? 134 : null,
observed_signal:
process.platform === "win32" ? null : "SIGABRT",
@@ -111,27 +103,16 @@ it.skipIf(process.versions.bun !== undefined && jobLimit === null)(
expect(JSON.stringify(response)).not.toContain("caller-marked");
expect(JSON.stringify(response)).not.toContain("rea doctor");
const remediation = String(exhausted.error.remediation?.action ?? "");
expect(remediation).toContain(captureDecoderHeapRecovery(testHeap));
const failed: unknown = await execute(
process.execPath,
[
cli,
"inspect-web-network-capture",
path,
"har",
"--json",
"--sensitive-value",
"caller-marked",
],
{
env: {
...process.env,
[CAPTURE_DECODER_OLD_GENERATION_SETTING]: String(
testHeap.oldGenerationMiB,
),
},
},
).then(
expect(remediation).toContain(
captureDecoderHeapRecovery(DEFAULT_CAPTURE_DECODER_HEAP),
);
const failed: unknown = await execute(process.execPath, [
cli,
"inspect-web-network-capture",
path,
"har",
"--json",
]).then(
() => undefined,
(cause: unknown) => cause,
);
@@ -145,13 +126,15 @@ it.skipIf(process.versions.bun !== undefined && jobLimit === null)(
details: {
limit: {
setting: "capture.decoder_old_generation_mib",
effective_value: testHeap.oldGenerationMiB,
source: testHeap.source,
effective_value: 192,
source: "default",
rea_restart_required: false,
},
},
remediation: {
action: expect.stringContaining(captureDecoderHeapRecovery(testHeap)),
action: expect.stringContaining(
captureDecoderHeapRecovery(DEFAULT_CAPTURE_DECODER_HEAP),
),
},
});
const small = join(root, "small.har");
@@ -167,25 +150,14 @@ it.skipIf(process.versions.bun !== undefined && jobLimit === null)(
});
const controlText = JSON.stringify(control.structuredContent);
expect(controlText).toContain(
"The effective REA_CAPTURE_DECODER_OLD_GENERATION_MIB value is 64 MiB (source setting; default 192 MiB, floor 8 MiB, maximum 16384 MiB).",
"The effective REA_CAPTURE_DECODER_OLD_GENERATION_MIB value is 192 MiB (source default; default 192 MiB, floor 8 MiB, maximum 16384 MiB).",
);
expect(controlText).toContain("source setting");
expect(controlText).toContain("source default");
expect(controlText).toContain("floor 8 MiB");
expect(controlText).toContain("maximum 16384 MiB");
expect(controlText).toContain(
"Set REA_CAPTURE_DECODER_OLD_GENERATION_MIB in the environment that starts REA",
);
const defaultServer = await connectLocalToolsMcp();
const defaultControl = await defaultServer.call(
"inspect_web_network_capture",
{ capture_path: small, format: "har" },
);
expect(defaultControl.isError, JSON.stringify(defaultControl)).not.toBe(
true,
);
expect(JSON.stringify(defaultControl.structuredContent)).toContain(
captureDecoderHeapRecovery(DEFAULT_CAPTURE_DECODER_HEAP),
);
await truncate(path, WEB_NETWORK_CAPTURE_LIMITS.inputBytes + 1);
const oversized = await call("inspect_web_network_capture", {
capture_path: path,
@@ -50,6 +50,7 @@ const MCP_TOOLS_WITHOUT_DEDICATED_CLI = Object.freeze([
"procedure_callees",
"procedure_callers",
"procedure_info",
"procedure_references",
"resolve_containing_procedure",
"set_address_name",
"set_addresses_names",
@@ -7,11 +7,7 @@ import { z } from "zod";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js";
import {
cliTest,
runFileCli,
type TestCli,
} from "../../support/cli/cliFixture.js";
import { cliTest, type TestCli } from "../../support/cli/cliFixture.js";
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
import { applicationFeatureTraceResultSchema } from "../../../src/domain/javascript/javascriptFeatureTraceSchemas.js";
import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js";
@@ -223,7 +219,7 @@ cliTest(
direction: "outgoing",
}),
);
const traced = await runFileCli(cli, {
const traced = await cli.run({
arguments: ["trace-application-feature", input, "--json"],
});
expect(traced.exitCode).toBe(0);
@@ -425,7 +421,7 @@ cliTest(
"main.js": `const { ipcMain, clipboard } = require("electron");
ipcMain.handle("nested:write", (event, value) => clipboard.writeText(value));`,
});
const output = await runFileCli(cli, {
const output = await cli.run({
arguments: ["analyze-javascript-application", root, "--json"],
});
expect(output.exitCode, output.stderr).toBe(0);
@@ -467,7 +463,7 @@ cliTest(
direction: "outgoing",
}),
);
const traced = await runFileCli(cli, {
const traced = await cli.run({
arguments: ["trace-application-feature", traceInput, "--json"],
});
expect(traced.exitCode).toBe(0);
@@ -495,7 +491,7 @@ cliTest(
"main.js": `const { ipcMain } = require("electron");
ipcMain.on("write", (event) => event.sender.copy());`,
});
const output = await runFileCli(cli, {
const output = await cli.run({
arguments: ["analyze-javascript-application", root, "--json"],
});
expect(output.exitCode).toBe(0);
@@ -552,7 +548,7 @@ cliTest(
"main.js": `const { ipcMain } = require("electron");
ipcMain.handle("nested:write", (event, value) => value);`,
});
const output = await runFileCli(cli, {
const output = await cli.run({
arguments: ["analyze-javascript-application", root, "--json"],
});
expect(output.exitCode).toBe(0);
@@ -585,7 +581,7 @@ cliTest(
direction: "outgoing",
}),
);
const traced = await runFileCli(cli, {
const traced = await cli.run({
arguments: ["trace-application-feature", traceInput, "--json"],
});
expect(traced.exitCode).toBe(0);
@@ -855,7 +851,7 @@ cliTest(
e.ipcMain.on("shallow:run", event => event.sender.copy());
e.ipcMain.on("deep:run", event => event.sender.copy());`,
});
const output = await runFileCli(cli, {
const output = await cli.run({
arguments: ["analyze-javascript-application", root, "--json"],
});
expect(output.exitCode).toBe(0);
@@ -909,7 +905,7 @@ cliTest(
"main.js": `const e=require("electron");
e.ipcMain.on("shared:run", event => event.sender.copy());`,
});
const output = await runFileCli(cli, {
const output = await cli.run({
arguments: ["analyze-javascript-application", root, "--json"],
});
expect(output.exitCode, output.stderr).toBe(0);
@@ -944,7 +940,7 @@ cliTest(
join(root, "main.js"),
'const e=require("electron"); e.ipcMain.on("shared:run", () => {}); e.ipcMain.on("shared:run", () => {});',
);
const competing = await runFileCli(cli, {
const competing = await cli.run({
arguments: ["analyze-javascript-application", root, "--json"],
});
expect(competing.exitCode, competing.stderr).toBe(0);
@@ -975,7 +971,7 @@ cliTest(
"main.js": `const { ipcMain } = require("electron");
ipcMain.on("empty:key", event => event.sender.copy());`,
});
const output = await runFileCli(cli, {
const output = await cli.run({
arguments: ["analyze-javascript-application", root, "--json"],
});
expect(output.exitCode).toBe(0);
@@ -1,153 +0,0 @@
import { readFile, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Cli } from "incur";
import pino from "pino";
import { expect, it } from "vitest";
import {
createAnalysisExecution,
type AnalysisProvider,
} from "../../../src/application/AnalysisProvider.js";
import { runDirectAnalysis } from "../../../src/application/DirectAnalysis.js";
import type { DirectAnalysisDependencies } from "../../../src/application/DirectAnalysisDependencies.js";
import { registerCoreNativeCommands } from "../../../src/cli/coreNativeCommands.js";
import { parseConfig } from "../../../src/config/parseConfig.js";
import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
import { createEvidence } from "../../../src/domain/evidence.js";
import { parseEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
import { ok } from "../../../src/domain/result.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const identity = { id: "fixture", name: "Fixture", version: "1" } as const;
const effects = {
mutatesArtifact: false,
launchesProcess: false,
mayShowUi: false,
mayAccessNetwork: false,
mayWriteFilesystem: false,
changesPermissions: false,
requiresRoot: false,
};
const procedure = {
address: "0x1000",
name: "selected",
classification: null,
body: { available: false as const, reason: "unavailable" },
};
const result = {
procedure,
direction: "incoming" as const,
reference_kinds_available: false,
references: [],
unresolved_calls: [{ address: "0x1010", reason: "unknown target" }],
};
it.each([false, true])(
"exports linked source Evidence or refuses missing closure (missing=%s)",
async (missing) => {
const root = await createTestTempDirectory("rea-procedure-references-cli-");
const path = join(root, "fixture.hop"),
output = join(root, "evidence.json");
await writeFile(path, "synthetic analysis database");
const history = createEvidence(undefined, identity, {
operation: "health",
parameters: {},
result: "project history",
...(missing ? { evidenceLinks: ["ev_" + "a".repeat(64)] } : {}),
});
const provider: AnalysisProvider = {
identity: () => identity,
capabilities: () => [
{
provider: identity,
operation: "procedure_references",
available: true,
reason: null,
cachePolicy: "live",
limitations: [],
effects,
},
],
resolveAnalysisProfile: async () =>
ok({ profile: createAnalysisProfile(identity, { fixture: true }) }),
createClient: () => ({
execute: async () =>
ok(
createAnalysisExecution(result, identity, {
sourceEvidence: [history],
}),
),
close: async () => ok(null),
}),
};
const createSession = () => createTestBinarySession(provider);
const dependencies: DirectAnalysisDependencies = {
readConfiguration: () => parseConfig({}),
createBinarySession: createSession,
createNativeDispatchBinarySession: createSession,
createArtifactBinarySession: createSession,
createNativeBinarySession: createSession,
createManagedBinarySession: createSession,
};
const cli = Cli.create("procedure-references", { sync: false });
registerCoreNativeCommands(
cli,
pino({ enabled: false }),
(target, tool, args, options) =>
runDirectAnalysis(dependencies, target, tool, args, options),
);
let stdout = "";
await cli.serve(
[
"procedure-references",
path,
"selected",
"--provider",
"fixture",
"--direction",
"incoming",
"--detail",
"summary",
"--evidence_output",
output,
"--json",
],
{
env: {},
stdout: (text) => {
stdout += text;
},
exit: () => undefined,
},
);
const response = JSON.parse(stdout);
if (missing) {
expect(response).toMatchObject({
code: "evidence_integrity_mismatch",
details: { reason: "missing", evidence_id: "ev_" + "a".repeat(64) },
});
await expect(stat(output)).rejects.toMatchObject({ code: "ENOENT" });
} else {
const bundle = parseEvidenceBundle(
JSON.parse(await readFile(output, "utf8")),
);
const parent = bundle.records.find(
(record) => record.operation === "procedure_references",
);
expect(response.summary.normalized_result).toMatchObject({
parent_evidence_id: parent?.evidence_id,
summary: {
reference_count: 0,
unresolved_call_count: 1,
direction: "incoming",
},
});
expect(parent?.evidence_links).toEqual([history.evidence_id]);
expect(
bundle.records.some(
(record) => record.evidence_id === history.evidence_id,
),
).toBe(true);
}
},
);
-142
View File
@@ -2058,145 +2058,3 @@ it("projects an environment manifest identically from retained, portable and fil
expect(JSON.stringify(refused)).not.toContain("/api/folders");
await client.ping();
});
it("pages retained raw array rows without losing the parent and refuses an oversized row", async () => {
const { client, session } = await connect();
const rows = [
{ from_address: "0x10", to_address: "0x20", source: "fixups" },
{ from_address: "0x18", to_address: "0x20", source: "fixups" },
{ from_address: "0x20", name: "x".repeat(1024 * 1024) },
];
const parent = createEvidence(
{ path: "/fixtures/raw-native", format: "mach-o", sha256: "e".repeat(64) },
{ id: "fixture", name: "Fixture", version: "1" },
{
operation: "xrefs",
parameters: { address: "0x20" },
result: ["0x10", "0x18", "0x20"],
rawResult: { references: { pointers: rows } },
},
);
expect(session.recordEvidence(parent).ok).toBe(true);
const call = (offset: number, limit: number) =>
client.callTool({
name: "inspect_analysis_view",
arguments: {
source: { kind: "retained-evidence", evidence_id: parent.evidence_id },
view: {
kind: "recorded-array",
path: ["references", "pointers"],
offset,
limit,
},
},
});
const first = await call(0, 10);
expect(first.isError).not.toBe(true);
const parsed = toolContract("inspect_analysis_view").outputSchema.parse(
first.structuredContent,
);
expect(parsed.normalized_result).toMatchObject({
kind: "recorded-array",
parent_evidence_id: parent.evidence_id,
source_provider: { id: "fixture", name: "Fixture", version: "1" },
source_analysis_profile_digest: null,
source_parameters: { address: "0x20" },
limitations: expect.arrayContaining([
expect.stringContaining(
"excluded from the parent operation's normalized result",
),
]),
rows: rows.slice(0, 2),
coverage: { examined: 2, total: 3, next_offset: 2 },
});
expect(parsed.evidence_links).toEqual([parent.evidence_id]);
const oversized = parseMcpToolError(await call(2, 1)).error;
expect(oversized.code).toBe("resource_constraint");
expect(oversized.remediation.action).toContain("1,048,576-byte");
expect((await call(0, 1)).isError).not.toBe(true);
});
it("directs oversized inline Evidence to a usable file source", async () => {
const { client } = await connect(undefined, undefined, 32);
const parent = analysisViewNativeEvidence();
const view = { kind: "native", facet: "procedure", offset: 0, limit: 1 };
const inline = await client.callTool({
name: "inspect_analysis_view",
arguments: { source: { kind: "inline", evidence: parent }, view },
});
const error = parseMcpToolError(inline).error;
expect(error.code).toBe("resource_constraint");
expect(error.remediation.action).toContain("evidence-file");
const root = await createTestTempDirectory("rea-view-input-capacity-");
const path = join(root, "evidence.json");
await writeFile(path, JSON.stringify(parent));
const fromFile = await client.callTool({
name: "inspect_analysis_view",
arguments: { source: { kind: "evidence-file", path }, view },
});
expect(fromFile.isError).not.toBe(true);
const parsed = toolContract("inspect_analysis_view").outputSchema.parse(
fromFile.structuredContent,
);
expect(parsed.normalized_result).toMatchObject({
parent_evidence_id: parent.evidence_id,
});
});
it.each([
{ queryBytes: 1024 * 1024, rowBytes: 0 },
{ queryBytes: 600_000, rowBytes: 600_000 },
])(
"pages a recorded row when its parent parameters do not fit",
async ({ queryBytes, rowBytes }) => {
const { client, session } = await connect();
const { ajv, outputSchema } = await advertisedMcpToolSchemas(
client,
"inspect_analysis_view",
schemaValidation.assertions,
);
const parent = createEvidence(
{
path: "/fixtures/large-context",
format: "elf",
sha256: "f".repeat(64),
},
{ id: "fixture", name: "Fixture", version: "1" },
{
operation: "xrefs",
parameters: { query: "x".repeat(queryBytes) },
result: ["0x10"],
rawResult: {
values: [{ address: "0x10", data: "y".repeat(rowBytes) }],
},
},
);
expect(session.recordEvidence(parent).ok).toBe(true);
const response = await client.callTool({
name: "inspect_analysis_view",
arguments: {
source: { kind: "retained-evidence", evidence_id: parent.evidence_id },
view: { kind: "recorded-array", path: ["values"], offset: 0, limit: 1 },
},
});
expect(response.isError).not.toBe(true);
expect(
ajv.validate(outputSchema, response.structuredContent),
JSON.stringify(ajv.errors),
).toBe(true);
const parsed = toolContract("inspect_analysis_view").outputSchema.parse(
response.structuredContent,
);
expect(parsed.normalized_result).toMatchObject({
kind: "recorded-array",
parent_evidence_id: parent.evidence_id,
source_provider: { id: "fixture", name: "Fixture", version: "1" },
source_parameters: null,
limitations: expect.arrayContaining([
expect.stringContaining("omitted to fit the page allowance"),
]),
rows: [{ address: "0x10", data: "y".repeat(rowBytes) }],
});
},
);
@@ -75,14 +75,6 @@ it("retains byte-search coverage and rejects malformed pages before creating Evi
skipped_not_loaded: [
{ segment: ".symtab", start: "0x0", end: "0x17f", bytes: 384 },
],
skipped_overlays: [
{
segment: "overlay",
start: "overlay:0x401000",
end: "overlay:0x40100f",
bytes: 16,
},
],
},
complete: false,
next_cursor: "continuation",
@@ -124,27 +116,6 @@ it("retains byte-search coverage and rejects malformed pages before creating Evi
expect(parseMcpToolError(malformed)).toMatchObject({
error: { code: "unreadable_output" },
});
output = jsonValueSchema.parse({
...page,
searched: {
...page.searched,
skipped_overlays: [
{
segment: "overlay",
start: "overlay:0x401000",
end: "overlay:0x40100f",
bytes: 15,
},
],
},
});
const invalidOverlay = await harness.mcp.callTool({
name: "search_bytes",
arguments: { pattern: "48 ??" },
});
expect(parseMcpToolError(invalidOverlay)).toMatchObject({
error: { code: "unreadable_output" },
});
for (const block of [
{ segment: ".text", start: "0x1000", end: "0x1009", bytes: 1 },
{ segment: ".text", start: "0x1009", end: "0x1000", bytes: 1 },
@@ -1,297 +0,0 @@
import { join } from "node:path";
import { expect, it } from "vitest";
import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js";
import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
const selected = {
address: "0x1000",
name: "selected",
classification: null,
body: { available: false as const, reason: "not reported" },
};
const other = { ...selected, address: "0x2000", name: "other" };
const edge = {
source_address: "0x1001",
target_address: "0x2000",
source_procedure: selected,
target_procedure: other,
kind: { available: false as const, reason: "not reported" },
};
const fixture = {
procedure: selected,
direction: "outgoing" as const,
reference_kinds_available: false,
references: [
edge,
{ ...edge, source_address: "0x1002", target_procedure: null },
edge,
],
unresolved_calls: [{ address: "0x1003", reason: "indirect destination" }],
};
const setup = async (profile: "full" | "compact") => {
const session = createTestBinarySession(() => {
throw new Error("View opened provider");
});
return {
...(await createApplicationMcpHarness({
session,
clientIdentity: { name: "procedure-reference-view", version: "1" },
serverOptions: {
environment: {
...process.env,
REA_MCP_INPUT_SCHEMA_PROFILE: profile,
REA_MCP_OUTPUT_SCHEMA_PROFILE: profile,
},
},
})),
session,
};
};
it.each(["full", "compact"] as const)(
"pages procedure reference Evidence across a %s offline handoff",
async (profile) => {
const parent = createEvidence(
undefined,
{ id: "fixture", name: "Fixture", version: "1" },
{
operation: "procedure_references",
parameters: { procedure: "selected", direction: "outgoing" },
result: fixture,
limitations: ["fixture coverage"],
},
);
const first = await setup(profile);
const source = {
kind: "retained-evidence",
evidence_id: parent.evidence_id,
};
const invoke = async (
client: typeof first.client,
name: string,
args: Record<string, unknown>,
) => {
const reply = await client.callTool({ name, arguments: args });
expect(reply.isError, JSON.stringify(reply)).not.toBe(true);
return reply.structuredContent;
};
const view = async (
client: typeof first.client,
request: Record<string, unknown>,
) =>
analysisViewResultSchema.parse(
parseEvidence(
await invoke(client, "inspect_analysis_view", {
source,
view: { kind: "native-procedure-references", ...request },
}),
).normalized_result,
);
const path = join(
await createTestTempDirectory("rea-procedure-reference-view-"),
"bundle.json",
);
try {
expect(first.session.recordEvidence(parent).ok).toBe(true);
const firstPage = await view(first.client, {
collection: "references",
source_address: "0x1001",
target_address: "0x2000",
offset: 0,
limit: 1,
});
const secondPage = await view(first.client, {
collection: "references",
source_address: "0x1001",
target_address: "0x2000",
offset: 1,
limit: 1,
});
expect(firstPage).toMatchObject({
parent_evidence_id: parent.evidence_id,
summary: {
procedure: selected,
direction: "outgoing",
reference_count: 3,
unresolved_call_count: 1,
},
coverage: { total: 2, next_offset: 1 },
items: {
rows: [
{
source_index: 0,
source_procedure: { kind: "selected-procedure" },
target_procedure: { kind: "shared", index: 0 },
kind: edge.kind,
},
],
},
});
expect(secondPage).toMatchObject({
coverage: { total: 2, next_offset: null },
items: { rows: [{ source_index: 2 }] },
});
expect(
analysisViewResultSchema.safeParse({
...firstPage,
items: { collection: "unresolved_calls", rows: [] },
}).success,
).toBe(false);
expect(
await view(first.client, {
collection: "unresolved_calls",
offset: 0,
limit: 1,
}),
).toMatchObject({
items: {
rows: [{ source_index: 0, value: fixture.unresolved_calls[0] }],
},
});
expect(
await view(first.client, {
collection: "references",
source_address: "0xmissing",
}),
).toMatchObject({ coverage: { status: "empty", total: 0 } });
const invalid = await first.client.callTool({
name: "inspect_analysis_view",
arguments: {
source,
view: {
kind: "native-procedure-references",
collection: "unresolved_calls",
source_address: "0x1000",
},
},
});
expect(invalid.isError).toBe(true);
await invoke(first.client, "export_evidence_bundle", { path });
} finally {
await first.close();
}
const second = await setup(profile);
try {
await invoke(second.client, "import_evidence_bundle", { path });
expect(
await view(second.client, {
collection: "references",
offset: 1,
limit: 1,
}),
).toMatchObject({
items: {
rows: [{ source_index: 1, target_procedure: { kind: "null" } }],
},
});
} finally {
await second.close();
}
},
);
it("bounds a requested page by bytes and keeps oversized-row recovery available", async () => {
const largeReason = "observed reference limitation ".repeat(90);
const rows = Array.from({ length: 900 }, (_, index) => ({
...edge,
source_address: `0x${(0x1000 + index).toString(16)}`,
kind: { available: false as const, reason: largeReason },
}));
const provider = { id: "fixture", name: "Fixture", version: "1" };
const parent = createEvidence(undefined, provider, {
operation: "procedure_references",
parameters: {},
result: { ...fixture, references: rows },
});
const oversize = createEvidence(undefined, provider, {
operation: "procedure_references",
parameters: {},
result: {
...fixture,
references: [
{
...edge,
target_procedure: { ...other, name: "x".repeat(1_100_000) },
},
],
},
});
const { session, client, close } = await setup("compact");
const call = (
id: string,
collection: "references" | "unresolved_calls",
offset: number,
) =>
client.callTool({
name: "inspect_analysis_view",
arguments: {
source: { kind: "retained-evidence", evidence_id: id },
view: {
kind: "native-procedure-references",
collection,
offset,
limit: 900,
},
},
});
try {
expect(session.recordEvidence(parent).ok).toBe(true);
expect(session.recordEvidence(oversize).ok).toBe(true);
const first = await call(parent.evidence_id, "references", 0);
expect(first.isError, JSON.stringify(first)).not.toBe(true);
const page = analysisViewResultSchema.parse(
parseEvidence(first.structuredContent).normalized_result,
);
if (
page.kind !== "native-procedure-references" ||
page.items.collection !== "references"
)
throw new Error("Expected references page");
expect(page.byte_limited).toBe(true);
expect(page.coverage.total).toBe(rows.length);
const nextOffset = page.coverage.next_offset;
expect(nextOffset).toBe(page.items.rows.length);
if (nextOffset === null) throw new Error("Expected continuation");
const next = await call(parent.evidence_id, "references", nextOffset);
expect(next.isError, JSON.stringify(next)).not.toBe(true);
const nextPage = analysisViewResultSchema.parse(
parseEvidence(next.structuredContent).normalized_result,
);
if (
nextPage.kind !== "native-procedure-references" ||
nextPage.items.collection !== "references"
)
throw new Error("Expected references continuation");
expect(nextPage.items.rows[0]?.source_index).toBe(nextOffset);
const refused = await call(oversize.evidence_id, "references", 0);
expect(refused.isError).toBe(true);
expect(parseMcpToolError(refused)).toMatchObject({
error: {
code: "resource_constraint",
remediation: {
action: expect.stringContaining("export the retained parent"),
},
details: {
reported_limits: {
parent_evidence_id: oversize.evidence_id,
limit_bytes: 1_048_576,
},
},
},
});
const otherPage = await call(oversize.evidence_id, "unresolved_calls", 0);
expect(otherPage.isError, JSON.stringify(otherPage)).not.toBe(true);
expect(
parseEvidence(otherPage.structuredContent).normalized_result,
).toMatchObject({
items: { rows: [{ value: fixture.unresolved_calls[0] }] },
});
} finally {
await close();
}
});
@@ -101,7 +101,6 @@ describe("official operation arguments", () => {
bytes: 0,
skipped_uninitialized: [],
skipped_not_loaded: [],
skipped_overlays: [],
},
complete: true,
next_cursor: null,
@@ -1290,439 +1290,6 @@ it("preserves a selected Ghidra project's read-only guidance when an edit is req
expect(starts).toEqual([]);
});
it("labels unbound provider observations as selection-time after a repair", async () => {
const directory = await createTestTempDirectory("rea-candidate-observation-");
const target = join(directory, "fixture.hop");
await writeFile(target, "fixture");
let available = false;
const base = candidate("alpha", []);
const session = composeBinarySession(
new AnalysisProviderRegistry([
{
...base,
inspectAvailability: () =>
candidate("alpha", [], available).inspectAvailability(),
},
]),
);
const server = createServer(
{ kind: "session", session },
{ logger: silentLogger },
);
const mcp = new Client({ name: "candidate-scope", 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 } }))
.isError,
).toBeFalsy();
available = true;
const stale = toolContract("binary_session").outputSchema.parse(
(await mcp.callTool({ name: "binary_session", arguments: {} }))
.structuredContent,
).result;
expect(stale.analysis_provider_binding).toBeNull();
expect(stale.analysis_provider_candidates_observed).toBe("target_selection");
expect(stale.analysis_provider_candidates[0]?.availability.status).toBe(
"unavailable",
);
const inspected = toolContract("binary_session").outputSchema.parse(
(
await mcp.callTool({
name: "binary_session",
arguments: { refresh_candidates: true },
})
).structuredContent,
).result;
expect(inspected.analysis_provider_binding).toBeNull();
expect(inspected.analysis_provider_candidates_observed).toBe(
"status_request",
);
expect(inspected.analysis_provider_candidates[0]?.availability.status).toBe(
"available",
);
const retained = toolContract("binary_session").outputSchema.parse(
(await mcp.callTool({ name: "binary_session", arguments: {} }))
.structuredContent,
).result;
expect(retained.analysis_provider_candidates[0]?.availability.status).toBe(
"unavailable",
);
expect(
(await mcp.callTool({ name: "open_binary", arguments: { path: target } }))
.isError,
).toBeFalsy();
const reused = toolContract("binary_session").outputSchema.parse(
(await mcp.callTool({ name: "binary_session", arguments: {} }))
.structuredContent,
).result;
expect(reused.analysis_provider_binding).toBeNull();
expect(reused.analysis_provider_candidates[0]?.availability.status).toBe(
"unavailable",
);
expect(
(
await mcp.callTool({
name: "open_binary",
arguments: { path: target, provider_id: "alpha" },
})
).isError,
).toBeFalsy();
const refreshed = toolContract("binary_session").outputSchema.parse(
(await mcp.callTool({ name: "binary_session", arguments: {} }))
.structuredContent,
).result;
expect(refreshed.analysis_provider_candidates_observed).toBe(
"target_selection",
);
expect(refreshed.analysis_provider_binding?.provider.id).toBe("alpha");
});
it("preserves the bound client when a candidate refresh is cancelled during finalization", async () => {
const directory = await createTestTempDirectory(
"rea-candidate-refresh-cancel-",
);
const target = join(directory, "fixture.hop");
await writeFile(target, "fixture");
const starts: string[] = [];
const base = candidate("alpha", starts);
let hold = false;
let enter!: () => void;
let finish!: () => void;
let finalized!: () => void;
const entered = new Promise<void>((resolve) => {
enter = resolve;
});
const gate = new Promise<void>((resolve) => {
finish = resolve;
});
const done = new Promise<void>((resolve) => {
finalized = resolve;
});
const session = composeBinarySession(
new AnalysisProviderRegistry([
{
...base,
inspectAvailability: async ({
signal,
}: AnalysisProviderInspectionOptions = {}) => {
const result = await base.inspectAvailability();
if (!hold) return result;
enter();
await gate;
finalized();
if (signal?.aborted)
throw new AnalysisCancelledError("binary_session", {
cause: signal.reason,
});
return result;
},
},
]),
);
const server = createServer(
{ kind: "session", session },
{ logger: silentLogger },
);
const mcp = new Client({ name: "candidate-refresh-cancel", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
resources.push(mcp, server, { close: () => session.close() });
await server.connect(serverTransport);
await mcp.connect(clientTransport);
let refreshing: ReturnType<typeof mcp.callTool> | undefined;
try {
expect(
(
await mcp.callTool({
name: "open_binary",
arguments: { path: target, provider_id: "alpha" },
})
).isError,
).toBeFalsy();
expect(
(await mcp.callTool({ name: "address_name", arguments: {} })).isError,
).toBeFalsy();
expect(starts).toEqual(["alpha"]);
hold = true;
const controller = new AbortController();
refreshing = mcp.callTool(
{ name: "binary_session", arguments: { refresh_candidates: true } },
{ signal: controller.signal },
);
await awaitAcknowledgement(entered, refreshing, "candidate refresh");
controller.abort();
await expect(refreshing).rejects.toThrow();
finish();
await done;
hold = false;
expect(
structured(await mcp.callTool({ name: "binary_session", arguments: {} }))
.result,
).toMatchObject({
analysis_provider_binding: { provider: { id: "alpha" } },
analysis_provider_candidates_observed: "target_selection",
});
expect(
(await mcp.callTool({ name: "address_name", arguments: {} })).isError,
).toBeFalsy();
expect(starts).toEqual(["alpha"]);
} finally {
hold = false;
finish();
await Promise.allSettled(refreshing === undefined ? [] : [refreshing]);
}
});
it("orders a provider switch after an in-flight candidate refresh", async () => {
const directory = await createTestTempDirectory(
"rea-candidate-refresh-switch-",
);
const target = join(directory, "fixture.hop");
await writeFile(target, "fixture");
const starts: string[] = [];
const alpha = candidate("alpha", starts);
let hold = false;
let enter!: () => void;
let finish!: () => void;
const entered = new Promise<void>((resolve) => {
enter = resolve;
});
const gate = new Promise<void>((resolve) => {
finish = resolve;
});
const session = composeBinarySession(
new AnalysisProviderRegistry([
{
...alpha,
inspectAvailability: async () => {
if (hold) {
hold = false;
enter();
await gate;
}
return alpha.inspectAvailability();
},
},
candidate("beta", starts),
]),
);
const server = createServer(
{ kind: "session", session },
{ logger: silentLogger },
);
const mcp = new Client({ name: "refresh-switch-race", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
resources.push(mcp, server, { close: () => session.close() });
await server.connect(serverTransport);
await mcp.connect(clientTransport);
let refreshing: ReturnType<typeof mcp.callTool> | undefined;
let switching: ReturnType<typeof mcp.callTool> | undefined;
try {
expect(
(
await mcp.callTool({
name: "open_binary",
arguments: { path: target, provider_id: "alpha" },
})
).isError,
).toBeFalsy();
hold = true;
refreshing = mcp.callTool({
name: "binary_session",
arguments: { refresh_candidates: true },
});
await awaitAcknowledgement(entered, refreshing, "candidate refresh");
let switched = false;
switching = mcp
.callTool({
name: "open_binary",
arguments: { path: target, provider_id: "beta" },
})
.then((result) => {
switched = true;
return result;
});
await new Promise<void>((resolve) => setImmediate(resolve));
expect(switched).toBe(false);
finish();
const result = await refreshing;
expect(result.isError).toBeFalsy();
expect(structured(result).result).toMatchObject({
analysis_provider_binding: { provider: { id: "alpha" } },
});
expect((await switching).isError).toBeFalsy();
expect(
structured(await mcp.callTool({ name: "binary_session", arguments: {} }))
.result,
).toMatchObject({
analysis_provider_binding: { provider: { id: "beta" } },
});
} finally {
finish();
await Promise.allSettled(
[refreshing, switching].filter((operation) => operation !== undefined),
);
}
});
it("does not cancel an already admitted prompt lookup for a candidate refresh", async () => {
const directory = await createTestTempDirectory("rea-refresh-prompt-");
const target = join(directory, "fixture.hop");
await writeFile(target, "fixture");
const base = candidate("alpha", []);
let enter!: () => void;
let finish!: () => void;
const entered = new Promise<void>((resolve) => {
enter = resolve;
});
const gate = new Promise<void>((resolve) => {
finish = resolve;
});
const session = composeBinarySession(
new AnalysisProviderRegistry([
{
...base,
createClient: (selected, profile, context) => {
const client = base.createClient(selected, profile, context);
return {
...client,
executeIfReady: async (operation, args, options) => {
enter();
await gate;
if (options?.signal?.aborted)
throw new Error("Prompt lookup was cancelled");
return client.execute(operation, args, options);
},
};
},
},
]),
);
const server = createServer(
{ kind: "session", session },
{ logger: silentLogger },
);
const mcp = new Client({ name: "refresh-prompt", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
resources.push(mcp, server, { close: () => session.close() });
await server.connect(serverTransport);
await mcp.connect(clientTransport);
let lookup: ReturnType<typeof session.executeIfProviderReady> | undefined;
try {
expect(
(
await mcp.callTool({
name: "open_binary",
arguments: { path: target, provider_id: "alpha" },
})
).isError,
).toBeFalsy();
expect(
(await mcp.callTool({ name: "address_name", arguments: {} })).isError,
).toBeFalsy();
lookup = session.executeIfProviderReady("address_name", {});
await awaitAcknowledgement(entered, lookup, "prompt lookup");
expect(
(
await mcp.callTool({
name: "binary_session",
arguments: { refresh_candidates: true },
})
).isError,
).toBeFalsy();
finish();
expect((await lookup)?.ok).toBe(true);
} finally {
finish();
await Promise.allSettled(lookup === undefined ? [] : [lookup]);
}
});
it("retains failed candidate-refresh cleanup while preserving the selected binding", async () => {
const directory = await createTestTempDirectory(
"rea-candidate-refresh-cleanup-",
);
const target = join(directory, "fixture.hop");
await writeFile(target, "fixture");
const starts: string[] = [];
const beta = candidate("beta", starts);
let fail = false;
let release = false;
const failure = new ProviderCleanupError("beta", ["beta-refresh-owner"], {
reason: "controlled cleanup failure",
});
const session = composeBinarySession(
new AnalysisProviderRegistry([
candidate("alpha", starts),
{
...beta,
inspectAvailability: () => {
if (fail) throw failure;
return beta.inspectAvailability();
},
closeOwnedResources: async () => {
if (!fail) return ok(null);
if (!release) return err(failure);
failure.resolveCleanup();
return ok(null);
},
},
]),
);
const server = createServer(
{ kind: "session", session },
{ logger: silentLogger },
);
const mcp = new Client({ name: "candidate-refresh-cleanup", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
resources.push(mcp, server, { close: () => session.close() });
await server.connect(serverTransport);
await mcp.connect(clientTransport);
try {
expect(
(
await mcp.callTool({
name: "open_binary",
arguments: { path: target, provider_id: "alpha" },
})
).isError,
).toBeFalsy();
fail = true;
const failed = parseMcpToolError(
await mcp.callTool({
name: "binary_session",
arguments: { refresh_candidates: true },
}),
);
expect(failed.error).toMatchObject({
code: "cleanup_incomplete",
details: { resources: ["beta-refresh-owner"] },
});
const status = structured(
await mcp.callTool({ name: "binary_session", arguments: {} }),
);
expect(status.result).toMatchObject({
cleanup_pending: true,
analysis_provider_binding: { provider: { id: "alpha" } },
});
release = true;
expect(
(await mcp.callTool({ name: "close_binary", arguments: {} })).isError,
).toBeFalsy();
expect(failure.cleanupIncomplete).toBe(false);
} finally {
release = true;
await session.close();
}
});
it("retains attributed mixed profile cleanup through registered MCP calls and partial retry", async () => {
const directory = await createTestTempDirectory(
"rea-profile-completion-mcp-",
@@ -1,74 +0,0 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { Client, type Transport } from "@modelcontextprotocol/client";
import { expect, it } from "vitest";
import {
createVerifierMcpOwner,
VerifierStdioTransport,
} from "../../../scripts/lib/verifier-mcp-owner.mjs";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
it.each(["initialize", "discovery"])(
"closes the real MCP child when %s fails before ownership transfers",
async (mode) => {
const workspace = await createTestTempDirectory("rea-verifier-mcp-owner-");
const marker = join(workspace, "exit.txt");
const client = new Client({ name: "verifier-owner-test", version: "1" });
const transport = new VerifierStdioTransport({
command: process.execPath,
args: [
fileURLToPath(
new URL("../../fixtures/verifier-mcp-failure.mjs", import.meta.url),
),
],
env: {
PATH: process.env.PATH ?? "",
REA_TEST_EXIT_MARKER: marker,
REA_TEST_FAILURE_MODE: mode,
},
stderr: "pipe",
});
const owner = createVerifierMcpOwner();
try {
await expect(owner.open(client, transport)).rejects.toThrow();
expect(await readFile(marker, "utf8")).toBe("exited");
} finally {
await owner.close();
}
},
);
it("retains an owner after failed cleanup and retries every close", async () => {
const failure = new Error("discovery failed");
const cleanupFailure = new Error("client close failed");
let clientCloses = 0;
let transportCloses = 0;
const client = {
connect: async () => {},
listTools: async () => {
throw failure;
},
close: async () => {
if (++clientCloses === 1) throw cleanupFailure;
},
} as unknown as Client;
const transport = {
close: async () => {
transportCloses++;
},
} as unknown as Transport;
const owner = createVerifierMcpOwner();
let thrown: unknown;
try {
await owner.open(client, transport);
} catch (cause) {
thrown = cause;
}
expect(thrown).toBeInstanceOf(AggregateError);
expect((thrown as AggregateError).cause).toBe(failure);
expect(transportCloses).toBe(1);
await owner.close();
expect(clientCloses).toBe(2);
expect(transportCloses).toBe(2);
});
+1 -5
View File
@@ -75,8 +75,6 @@ export interface FakeKunaBehavior {
/** Bytes of an extra root member in the graph JSON. */
readonly graphPadding?: number;
readonly xrefs?: "ok" | "hang" | "not-json";
/** Include stored pointers to the selected target and its forwarding alias. */
readonly pointerAliasReferences?: boolean;
/** Additional exact-target incoming call-site addresses. */
readonly extraXrefSources?: readonly number[];
readonly referenceDelayMs?: number;
@@ -165,9 +163,7 @@ if (args[0] === "--version") {
const row = (from, to, kind) => ({ address: from, address_hex: "0x" + from.toString(16), from_address: from, from_address_hex: "0x" + from.toString(16), to_address: to, to_address_hex: "0x" + to.toString(16), kind, from_function: identity("main", 4096), to_function: null, instruction: "fixture reference" + "x".repeat(behavior.referencePadding ?? 0) });
const rows = [4100, ...(behavior.extraXrefSources ?? [])].map((source) => row(source, parseInt(target, 16), "call"));
if (aliases.length) rows.push(row(4164, 12288, "call"));
const pointer = (from, to) => ({ kind: "pointer", from_address: from, from_address_hex: "0x" + from.toString(16), from_name: null, to_address: to, to_address_hex: "0x" + to.toString(16), to_name: null, width: 8, source: "dynamic-relocations" });
const pointers = behavior.pointerAliasReferences ? [pointer(4352, parseInt(target, 16)), pointer(4368, 12288)] : undefined;
setTimeout(() => process.stdout.write(JSON.stringify({ binary: args[1], direction: "to", count: rows.length, target: { ...identity(null, parseInt(target, 16)), aliases }, xrefs: rows, ...(pointers ? { data_refs: pointers, data_refs_count: pointers.length, data_refs_coverage: "dynamic-relocations" } : {}) })), behavior.referenceDelayMs ?? 0);
setTimeout(() => process.stdout.write(JSON.stringify({ binary: args[1], direction: "to", count: rows.length, target: { ...identity(null, parseInt(target, 16)), aliases }, xrefs: rows })), behavior.referenceDelayMs ?? 0);
}
} else if (args[0] === "decompile-project") {
const input = args[1];
@@ -235,30 +235,6 @@ it("filters forwarding aliases and reuses exact-address xrefs without inventory
}
});
it("distinguishes exact xrefs from recorded pointer rows to forwarding aliases", async () => {
const { open } = await session("rea-kuna-pointer-alias-", {
version: { stdout: "kuna 1.990\n" },
pointerAliasReferences: true,
});
const client = open();
try {
const answer = await run(client, "xrefs", { address: "0x2000" });
expect(answer.result).toEqual(["0x1004", "0x1100"]);
expect(answer.rawResult).toMatchObject({
references: {
pointers: [{ to_address: "0x2000" }, { to_address: "0x3000" }],
},
});
expect(answer.limitations).toEqual(
expect.arrayContaining([
expect.stringContaining("compare each row's to_address"),
]),
);
} finally {
await client.close();
}
});
it("resolves an xref procedure name without starting the project export", async () => {
const { open, kuna } = await session("rea-kuna-named-xrefs-");
const client = open();
@@ -1,26 +0,0 @@
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.address.AddressSet;
import ghidra.program.model.listing.Function;
import ghidra.program.model.symbol.SourceType;
import ghidra.program.model.symbol.Symbol;
import java.util.List;
// Create addressable entries without decoding their instructions or analyzing calls.
public class ReaSelectedProjectSeeds extends GhidraScript {
@Override public void run() throws Exception {
for (String name : List.of("rea_project_leaf", "rea_project_caller", "rea_project_other")) {
List<Symbol> symbols = getSymbols(name, null);
if (symbols.isEmpty()) symbols = getSymbols("_" + name, null);
if (symbols.size() != 1) throw new IllegalStateException("Expected one symbol for " + name);
Address entry = symbols.get(0).getAddress();
Function function = getFunctionAt(entry);
if (function == null)
function = currentProgram.getFunctionManager().createFunction(
symbols.get(0).getName(), entry, new AddressSet(entry, entry), SourceType.USER_DEFINED);
if (currentProgram.getListing().getInstructions(function.getBody(), true).hasNext())
throw new IllegalStateException("Expected an unanalyzed function: " + name);
println("REA_PROJECT_SEED " + name + " " + entry);
}
}
}
@@ -1,14 +0,0 @@
volatile int rea_project_global = 7;
__attribute__((noinline, used)) int rea_project_leaf(int x) {
return x + rea_project_global;
}
__attribute__((noinline, used)) int rea_project_caller(int x) {
return rea_project_leaf(x);
}
__attribute__((noinline, used)) int rea_project_other(int x) {
return x - rea_project_global;
}
__attribute__((noinline, used)) int rea_project_callback(int (*fn)(int), int x) {
return fn(x) + rea_project_global;
}
int main(void) { return rea_project_caller(rea_project_global); }
-1
View File
@@ -636,7 +636,6 @@ const inventoryResultFor = (method, params) => {
},
],
skipped_not_loaded: [],
skipped_overlays: [],
},
complete: !paged,
next_cursor: paged ? "fixture-second-page" : null,
-36
View File
@@ -1,36 +0,0 @@
import { writeFileSync } from "node:fs";
import { createInterface } from "node:readline";
const marker = process.env.REA_TEST_EXIT_MARKER;
const mode = process.env.REA_TEST_FAILURE_MODE;
if (!marker || !mode) throw new Error("Missing verifier fixture configuration");
const keepAlive = setInterval(() => {}, 1000);
// The test harness selects its own temporary exit marker.
process.on("exit", () => writeFileSync(marker, "exited"));
process.stdin.on("end", () => clearInterval(keepAlive));
const input = createInterface({ input: process.stdin });
input.on("line", (line) => {
const request = JSON.parse(line);
if (request.id === undefined) return;
if (request.method === "initialize" && mode !== "initialize") {
process.stdout.write(
`${JSON.stringify({
jsonrpc: "2.0",
id: request.id,
result: {
protocolVersion: request.params.protocolVersion,
capabilities: { tools: {} },
serverInfo: { name: "verifier-failure-fixture", version: "1" },
},
})}\n`,
);
return;
}
process.stdout.write(
`${JSON.stringify({
jsonrpc: "2.0",
id: request.id,
error: { code: -32603, message: `intentional ${mode} failure` },
})}\n`,
);
});