perf(native): query retained export table facts (#6907)

* perf(native): query retained export table facts

* docs(perf): record native export task comparisons

* fix(native): preserve omitted export projection fields

* docs(perf): refresh final export view qualification

* test(native): verify sparse export view digests
This commit is contained in:
gensokyo-dev authored and GitHub committed 2026-10-24 05:00:05 +08:00
1 parent d29ae43aeb
commit 57b1f82367
20 files changed
+3871 -51

No files matched your search

@@ -104,3 +104,14 @@ each plan states why in its `reason`. It replays the capture instead with
`--replay-only` or when the state is unavailable (not derivable, or a catalog
over the discovery budget), and its stopped document's `state` says which
applied.
For a PE/ELF export question, `inspect_native_exports` accepts `detail: "summary"`
and retains the complete table. Query `inspect_analysis_view` with
`view: {kind: "native-exports", collection: "exports", names: ["selected_name"],
fields: ["name"], offset: 0, limit: 32}` and the reported parent Evidence ID.
All same-name versions and aliases survive; use `source_index` with the parent ID
for stable identity. `collection: "issues"` pages interpretation failures. Page
coverage describes selected retained rows; `summary.coverage` or `exports.coverage`
still describes the source table, so an empty partial page does not prove absence.
CLI `inspect-native-exports --detail summary --evidence_output PATH` saves the
complete bundle for later `inspect-analysis-view --evidence-bundle PATH` requests.
+44 -3
View File
@@ -326,7 +326,48 @@ rea inspect-native-exports ./runtimes/linux-x64/native/libexample.so --json
```
The complete Evidence identifies the stable artifact snapshot by path, byte
count and SHA-256.
count and SHA-256. Complete delivery remains the default. For a small initial
MCP reply, select `detail: "summary"`; this retains the complete Evidence and
returns artifact identity, table coverage and row counts. Use its
`normalized_result.parent_evidence_id` with `inspect_analysis_view`:
```json
{
"source": {
"kind": "retained-evidence",
"evidence_id": "ev_<parent digest>"
},
"view": {
"kind": "native-exports",
"collection": "exports",
"names": ["selected_name"],
"fields": ["name"],
"offset": 0,
"limit": 32
}
}
```
Names match exactly and preserve every same-name version or alias. Optional
`indices` selects original row positions, intersecting a name filter when both
are supplied; empty filters select nothing. `source_index` always remains and,
with the parent ID, identifies the original export or issue row. Fields default
to the complete row; an empty field list returns only its identity. ELF `name`
selection also returns raw-byte and encoding qualifications. Fields from the
other format are rejected. `collection: "issues"` pages the complete diagnostic
records, with optional original indices. Offset and limit default to 0 and 64
and apply to matching rows in original order.
View `coverage` describes the selected page; `summary.coverage` or
`exports.coverage` preserves source-table coverage. An exhausted or empty page
does not make a partial source complete. Retained views never reopen the file.
The CLI uses the same projector and requires a durable bundle for a summary:
```sh
rea inspect-native-exports ./libexample.so --detail summary --evidence_output ./exports.bundle.json --json
# Pass the summary's parent ID and the same view as JSON to inspect-analysis-view,
# together with --evidence-bundle ./exports.bundle.json.
```
- PE rows reproduce the export directory: each used export-address-table slot
with its biased `ordinal` (`OrdinalBase` plus the slot index), one row per
@@ -368,8 +409,8 @@ Unresolved symbol versions keep their rows: the version name is null when no
definition or requirement names the index, and `version` is
`"unknown"` when the version table cannot be read; coverage is likewise
`partial`. A dynamic symbol table without its null entry is malformed. An ELF
without section headers is `unsupported`:
enumerating dynamic symbols from the dynamic segment is not
without section headers, or with a dynamic segment but no dynamic-symbol
section, is `unsupported`: enumerating dynamic symbols from the dynamic segment is not
implemented. Mach-O exports are reported by `inspect_macho`.
`max_file_bytes` defaults to 256 MiB (maximum 512 MiB; CLI `--max-file-bytes`).
@@ -0,0 +1,115 @@
"""Check equivalent export questions and count explicitly projected tokenizer costs."""
import argparse
import hashlib
import json
from pathlib import Path
import statistics
import tiktoken
def compact(value):
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
def inspect(path, encoding):
report = json.loads((path / "report.json").read_text())
catalog = json.loads((path / "catalog.json").read_text())
transcript = json.loads((path / "transcript.json").read_text())
scope = next(json.loads(line) for line in reversed(path.with_suffix(".log").read_text().splitlines())
if line.startswith('{"resource_scope":'))
observed = scope.get("payload_resource_observation") or {}
events = observed.get("resource_events", {})
assert report["status"] == "passed" and report.get("runtime_identity_sha256")
assert not report["cleanup_failures"] and not report["receipt_failures"]
assert scope["abort"] is None and scope.get("payload_resource_observation_verified") is True
assert observed.get("payload_exit_code") == 0 and observed.get("payload_cleanup_verified") is True
assert all(events.get("memory.events", {}).get(key) == 0 for key in ["high", "max", "oom", "oom_kill"])
assert events.get("pids.events", {}).get("max") == 0
assert len(catalog["tools"]) == 220
count = lambda text: len(encoding.encode(text, disallowed_special=()))
directory = [{key: tool[key] for key in ["name", "description", "inputSchema"]} for tool in catalog["tools"]]
question = (f"For {report['artifact']['path']}, report the export count and every version/alias of "
f"{', '.join(dict.fromkeys(row['name'] for row in report['answer']['rows']))}, including addresses, "
"sizes, types, binding and symbol-version facts. Check that __rea_export_oracle_missing__ "
"has no recorded match, preserve source coverage and export the complete Evidence. "
"Report static declarations without claiming runtime execution.")
arguments = sum(count(compact({"name": event["name"], "arguments": event["input"]})) for event in transcript)
replies = sum(count(block["text"]) for event in transcript for block in event["reply"]["content"] if block["type"] == "text")
inputs = count(compact(directory))
raw_catalog = count(compact(catalog))
task_directory = [tool for tool in directory if tool["name"] in ["inspect_native_exports", "inspect_analysis_view", "export_evidence_bundle"]]
return {"case": path.name, "lane": report["lane"], "artifact": report["artifact"],
"answer": report["answer"], "parent_evidence_id": report["parent_evidence_id"],
"source_report_sha256": report["source_report_sha256"],
"runtime_identity_sha256": report["runtime_identity_sha256"],
"catalog_sha256": report["catalog_sha256"], "catalog_tools": len(catalog["tools"]),
"buffer_bytes": report["buffer_bytes"], "calls": report["calls"], "phases": report["phases"],
"wall_seconds": scope["elapsed_seconds"], "cpu_seconds": observed["cpu_stat"]["usage_usec"] / 1e6,
"kernel_family_peak_bytes": observed["memory_peak_bytes"],
"events_at_payload_exit": events, "payload_cleanup_verified": True,
"final_whole_scope_events": scope.get("resource_events_final"),
"token_proxy": {"question": count(question), "input_catalog_once": inputs, "arguments": arguments,
"text_replies": replies, "combined": count(question) + inputs + arguments + replies,
"only_three_task_tools": count(question) + count(compact(task_directory)) + arguments + replies,
"complete_transport_catalog": raw_catalog},
"receipt_sha256": {file.name: hashlib.sha256(file.read_bytes()).hexdigest() for file in
[path / "report.json", path / "catalog.json", path / "transcript.json", path / "evidence.json", path.with_suffix(".log")]}}
def summarize(rows):
result = {}
for artifact in sorted({row["artifact"]["sha256"] for row in rows}):
selected = [row for row in rows if row["artifact"]["sha256"] == artifact]
assert len({row["parent_evidence_id"] for row in selected}) == 1
assert len({row["source_report_sha256"] for row in selected}) == 1
assert all(row["answer"] == selected[0]["answer"] for row in selected)
result[artifact] = {}
for lane in ["complete", "selected"]:
samples = [row for row in selected if row["lane"] == lane]
assert len(samples) == 3
metrics = {key: [row[key] for row in samples] for key in ["wall_seconds", "cpu_seconds", "kernel_family_peak_bytes"]}
metrics["projected_tokens"] = [row["token_proxy"]["combined"] for row in samples]
metrics["mcp_call_count"] = [len(row["calls"]) for row in samples]
metrics["mcp_reply_bytes"] = [sum(call["reply_bytes"] for call in row["calls"]) for row in samples]
result[artifact][lane] = {metric: {"median": statistics.median(values), "minimum": min(values), "maximum": max(values)} for metric, values in metrics.items()}
return result
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--cohort", type=Path, required=True)
parser.add_argument("cases", type=Path, nargs="+")
args = parser.parse_args()
manifest = json.loads((args.cohort / "manifest.json").read_text())
index = json.loads((args.cohort / "index.json").read_text())
assert len(index) == 12 and all(case["exit"] == 0 and case["status"] == "passed" for case in index)
assert {path.resolve() for path in args.cases} == {(args.cohort / case["name"]).resolve() for case in index}
assert len(args.cases) == len(index)
for file, digest in manifest["driver_files"].items():
assert hashlib.sha256(Path(file).read_bytes()).hexdigest() == digest, file
runtimes = {}
for case in index:
path = Path(case["runtime"]) / "runtime-identity.json"
data = path.read_bytes()
identity = json.loads(data)
assert identity["source_clean"] is True
runtimes[hashlib.sha256(data).hexdigest()] = {
"source_revision": identity["source_revision"],
"compiled_files": len(identity["compiled_files"]),
"entrypoints": len(identity["entrypoints"]),
}
encoding = tiktoken.get_encoding("cl100k_base")
rows = [inspect(path, encoding) for path in args.cases]
assert all(row["runtime_identity_sha256"] in runtimes for row in rows)
receipt = {"tiktoken": tiktoken.__version__, "encoding": encoding.name, "actual_model_usage": None,
"cohort": manifest, "commands": index, "runtime_identities": runtimes,
"projection": "Scripted SDK tasks, not an actual model run. The estimate includes a fixed task description, every discovered tool name/description/input schema once, every tool argument and text reply, including Evidence export. Complete raw SDK directory token costs and a three-tool-only alternative are reported separately; output schemas are not included in the primary model-context projection. Host injection, history replay, inference quality and billable usage are unmeasured.",
"resources": "CPU quota 100%; launcher binds clients/servers to CPUs 0-1 (the outer observer starts on CPU15). The guard receipt affinity is initial placement, not child affinity; a live cohort observation records the override. 3 GiB early stop/4 GiB hard/no swap. Fresh client/server/HOME per task, Node compile cache disabled, OS caches unflushed. Later retained pages are warm queries; no separate warm-server cohort. Whole-family costs include client/oracle comparisons, source hashing, runtime binding, serialization and receipts. Tokenizer processing itself is outside these measured tasks. Verified counters are at payload exit while the observer remains alive; unavailable final whole-scope counters remain null.",
"summary": summarize(rows), "recordings": rows}
args.output.write_text(json.dumps(receipt, ensure_ascii=False, indent=2) + "\n")
if __name__ == "__main__":
main()
@@ -0,0 +1,48 @@
"""Independent readelf symbol facts and directly mapped ELF64 version indices."""
import hashlib
import json
from pathlib import Path
import re
import struct
import subprocess
import sys
path = Path(sys.argv[1]).resolve()
selected_names = sys.argv[2:]
source = path.read_bytes()
assert source[:6] == b'\x7fELF\x02\x01', 'This experiment oracle only covers ELF64 little-endian'
section_offset = struct.unpack_from('<Q', source, 40)[0]
section_width, section_count = struct.unpack_from('<HH', source, 58)
assert section_width == 64 and section_count > 0
sections = [struct.unpack_from('<IIQQQQIIQQ', source, section_offset + i * section_width) for i in range(section_count)]
dynsym_index = next(i for i, row in enumerate(sections) if row[1] == 11)
version_section = next(row for row in sections if row[1] == 0x6fffffff and row[6] == dynsym_index)
text = subprocess.check_output(['readelf', '--dyn-syms', '--wide', str(path)], text=True)
pattern = re.compile(r'^\s*(\d+):\s+([0-9a-f]+)\s+(\d+)\s+(\w+)\s+(\w+)\s+(\w+)\s+(\S+)\s+(\S+)')
rows = []
for line in text.splitlines():
match = pattern.match(line)
if not match:
continue
ordinal, value, size, kind, binding, visibility, section, display = match.groups()
ordinal = int(ordinal)
if binding not in ['GLOBAL', 'WEAK', 'UNIQUE'] or section == 'UND' or visibility not in ['DEFAULT', 'PROTECTED']:
continue
version_word = struct.unpack_from('<H', source, version_section[4] + ordinal * 2)[0]
if (version_word & 0x7fff) == 0:
continue
name, _, version_name = display.partition('@@' if '@@' in display else '@')
version = None if (version_word & 0x7fff) < 2 else {
'index': version_word & 0x7fff, 'name': version_name or None,
'name_bytes_base64': None, 'name_utf8_decodable': True if version_name else None,
'hidden': bool(version_word & 0x8000),
}
rows.append({'source_index': len(rows), 'name': name, 'name_bytes_base64': None, 'name_utf8_decodable': True,
'value': hex(int(value, 16)), 'size': size, 'binding': binding.lower(),
'type': {'FUNC': 'function', 'IFUNC': 'ifunc', 'OBJECT': 'object', 'TLS': 'tls'}.get(kind, 'other'),
'version': version})
selected = [row for row in rows if row['name'] in selected_names]
assert set(row['name'] for row in selected) == set(selected_names)
print(json.dumps({'artifact': {'path': str(path), 'sha256': hashlib.sha256(source).hexdigest(), 'bytes': len(source)},
'oracle': subprocess.check_output(['readelf', '--version'], text=True).splitlines()[0],
'names': selected_names, 'export_count': len(rows), 'rows': selected}, indent=2))
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,167 @@
# Native export questions over retained Evidence
Issue #6900: retrieve the export count and every declared version/alias of two
named exports, verify a missing-name query within the examined table, and export
the complete Evidence. The previous workflow returns the complete export table
to the client. The selected workflow returns a summary, queries matching rows,
and keeps the same complete parent accessible through Evidence export.
## Pinned inputs and correctness
- Baseline: `f713087132021d235682f908a1c0f3bc6ab8762d` (main, including the ELF
unknown-coverage fix #6827). Candidate and experiment driver:
`c7a90b742a29328e5b0069c6f8a34381e0c11e57`.
- Linux x86-64, Node 24.22.1, npm 11.16.0; package and kernel versions are in
[the receipt](native-export-views.json). Each runtime's compiled files and
entrypoints were SHA-256 checked before and after every task. Source checkouts
were clean when frozen. Later reporting changes do not change that measured
implementation revision.
- `/usr/lib/x86_64-linux-gnu/libLLVM-14.so.1`: 109,967,296 bytes, 44,459 exports,
SHA-256 `436887791de0478d72c8323be99df69d6d0cf82745e5abec79d5e0374f4df560`.
Query `LLVMContextCreate` and `LLVMContextDispose`: two rows.
- `/usr/lib/x86_64-linux-gnu/libc.so.6`: 1,926,232 bytes, 3,025 exports,
SHA-256 `6b4a45352fd0c540a9c7c718f35ce8c8e46a4e482f9d3885a910c32d1a0e1421`.
Query `malloc` and `memcpy`: three rows, including both `memcpy` versions and
the IFUNC declaration. No runtime address or execution claim is made.
The independent oracle combines GNU `readelf` 2.40 dynamic symbol facts with
direct ELF64 little-endian mapping of version indices and hidden flags in
[native-export-oracle.py](native-export-oracle.py). It checks the full export
count and each selected row, including its index in the original report. All six
tasks for each artifact produced identical answers, complete parent Evidence IDs
and source report digests. Target bytes were unchanged. This oracle is qualified
for these ELF64 little-endian artifacts and selected ASCII names; it does not
establish real PE performance or arbitrary ELF encoding coverage.
## Measured completed tasks
Three alternating pairs per artifact, serial execution, fresh stdio client,
server and HOME per task; Node compile cache disabled. Retained page queries
reuse their parent within the task. OS file caches were not flushed, and there
was no separate warm-server cohort. All twelve tasks passed without resource
stops, OOM events, process-limit events or owned payload processes remaining.
| Artifact / workflow | Calls | Reply bytes | Projected tokens | Elapsed seconds, median [range] | Family CPU seconds, median [range] | Kernel family peak MiB, median [range] |
| ------------------- | ----: | ----------: | ---------------: | ------------------------------- | ---------------------------------- | -------------------------------------- |
| libc / complete | 2 | 1,653,415 | 289,912 | 18.848 [18.657–30.361] | 17.260 [15.342–29.125] | 993.1 [962.6–1,048.7] |
| libc / selected | 5 | 25,952 | 58,253 | 18.858 [17.044–32.088] | 17.476 [15.765–31.440] | 928.6 [922.2–939.7] |
| LLVM / complete | 2 | 28,986,227 | 4,355,448 | 97.516 [94.112–119.443] | 88.030 [84.867–111.504] | 1,833.8 [1,806.6–1,861.8] |
| LLVM / selected | 4 | 19,695 | 57,346 | 37.094 [29.132–39.676] | 32.645 [19.386–38.746] | 1,142.6 [1,123.0–1,233.6] |
These are scripted SDK tasks, with the full 220-tool directory discovered once.
The `cl100k_base` estimate (tiktoken 0.12.0) counts a fixed task description,
every tool name/description/input schema once, all call arguments and text
replies, including Evidence export. It excludes host injection and history
replay. No actual model ran; these are neither billable usage nor investigation
quality measurements. The SDK also receives output schemas: the entire raw
catalog costs 812,145 tokens before and 815,046 after if a host injects all of it.
That cost is reported separately. With only the three task tools injected,
estimated task totals are 238,857 → 7,198 for libc and 4,304,393 → 6,291 for LLVM.
Tokenizer work ran separately from the twelve task measurements.
Both libc lanes used a 10 MiB transport budget. Both LLVM timing lanes used
64 MiB to permit a complete-response comparison. Separate final probes at
10 MiB observed the baseline complete call return `resource_constraint`, while
the selected task completed. The failed complete task is preserved and excluded
from successful-task timing statistics. These boundary probes establish behavior,
not an additional performance pair.
The selected libc task has extra calls and shows no established latency gain;
its medians are essentially equal within the observed ranges.
LLVM has substantial observed task savings, but the result does not identify a
faster native parser: both workflows still analyze and retain the full table.
The `inspect` RPC includes analysis, Evidence, validation and delivery; no
internal phase attribution was measured. Per-call timings are retained for each pair; first selected-page calls validate
the retained parent, while later queries reuse that checked representation.
The weak cache holds validation of an exact immutable retained report, not live
filesystem state. Unfiltered pages slice the original order; filtered pages scan
the retained rows and preserve every matching alternative.
Resources: one CPU's aggregate quota (100%), clients/servers bound to CPUs 0–1,
observer initially on CPU 15; 3 GiB early stop, 4 GiB hard limit, no swap, 192
tasks. The guard's affinity field records initial placement; it does not describe
the wrapper's child affinity override. A live cgroup/process observation confirmed
the quota and override. Family CPU and peak memory come from kernel cgroup
counters at payload exit while an observer remains alive. Cleanup was verified
at that point. Final whole-scope counters became unavailable after scope removal
and remain null; no final whole-scope counter verification is claimed. Timing
includes discovery, runtime hashing, client checks, Evidence export and receipts.
Variation in the slower complete samples is retained in the ranges.
## Reproduction and validation
Run from the candidate checkout with its locked dependencies and pinned Node.
Freeze a clean built runtime with `scripts/rea.mjs`, `package.json`, `dist/` and
dependencies; record source revision plus SHA-256 for compiled files and
entrypoints as demonstrated by the committed receipt's runtime identities.
```sh
python3 experiments/analysis-efficiency/native-export-oracle.py \
/usr/lib/x86_64-linux-gnu/libLLVM-14.so.1 LLVMContextCreate LLVMContextDispose \
> /absolute/path/oracle.json
python3 experiments/analysis-efficiency/resource-run.py --observe-payload-exit -- \
node experiments/analysis-efficiency/native-export-views.mjs \
--runtime /absolute/path/runtime --identity /absolute/path/runtime/runtime-identity.json \
--oracle /absolute/path/oracle.json --output /absolute/path/unused-receipt-directory \
--lane selected --buffer 67108864
```
Use `--lane complete` against the baseline. Repeat three pairs in alternating
order. The JSON receipt records every executed command, runtime binding,
per-call timings/bytes, results and SHA-256 of raw reports/catalogs/transcripts/
Evidence/resource logs. Its `cohort` manifest records driver and guard hashes.
Generate token estimates with `native-export-costs.py --cohort COHORT --output
OUTPUT CASE...`, using tiktoken 0.12.0. Raw local receipts are under
`/tmp/rea-perf-native-exports-20261023/`; they are not committed binaries or
portable artifact downloads. `selector-cohort/index.json` and `manifest.json` bind
the twelve cases. Preserve these files together when rerunning the cost script.
Executed functional validation of the measured implementation:
- 48 selected tests passed: parser, export views, MCP schemas/retention preflight,
and actual public CLI/stdio workflows for PE and ELF fixtures. Source deletion
after retention still permits equivalent offline CLI and retained MCP views.
Sparse field checks also recompute the returned view digest in the native
regression and actual MCP acceptance workflow.
- `npm run check`, documentation generation/check and test-discovery verification
passed; 2,180 tests were discovered exactly once.
- Real LLVM public CLI summary → full Evidence bundle → offline selected view
was also checked; the complete bundle retains 44,459 exports.
- Earlier assertion spelling mistakes, an architecture-boundary test placement
violation and a concurrent-worker timeout are retained in the local logs.
The test was moved to the native layer. A later field-omission regression
reproduced source defaults leaking into projected columns; the projection
schema now removes those defaults, while complete source normalization retains
them. Final bounded tests and static gates passed. No real Hopper/Ghidra/IDA behavior changed or was qualified here.
Partial/unsupported source coverage, duplicate names/versions, PE ordinal-only
exports, forwarded exports, field projection, malformed retained input and older
encoding metadata are covered by deterministic boundary regressions. Those tests
supplement the real ELF tasks; they are not real PE provider measurements.
## Initial CI attribution
[CI run 40015745440](https://github.com/morluto/rea/actions/runs/40015745440)
tested head `5e9a17bea7a6bc5931df60256eef61010a9c67e1`, merge
`e6f10a331aed5228504ed45ca6244b9498548cb9`, against main
`f713087132021d235682f908a1c0f3bc6ab8762d`. Static checks, build, docs and selected
real-format qualification jobs passed. Source tests and built tests failed.
Every cancelled shard's log was inspected: 11 source and 46 built failures were
actually observed, including the ADB floating-point regex rejected in `v` mode.
Bounded serial controls compared that exact main base with implementation
`c7a90b742a29328e5b0069c6f8a34381e0c11e57`. The source selection reproduced all
11 failures on both. A broader built selection executed 52 cases on both, with
48 failures and four passes; it includes every observed CI built failure. The
two extra failures and four passes in this selection are not presented as CI
observations. All selected case identities, statuses and full failure messages
were identical after replacing only the two checkout roots, including the
physical path of their shared dependency symlink. No assertion text was removed.
All 57 actual CI failures were therefore reproduced on the base and candidate.
Local `ci-attribution.json`, `ci-observed-case-identities.json`,
`ci-control-index.json`, the four control reports and their resource logs under
the receipt directory retain commands, pinned revisions, complete differences
and SHA-256 bindings. Controls had no resource stop or OOM and verified payload
cleanup. This attribution applies to that initial CI run and those revisions;
later CI results must be inspected separately.
@@ -0,0 +1,224 @@
/** Equivalent export-table questions over public production stdio MCP workflows. */
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { createWriteStream } from "node:fs";
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { finished } from "node:stream/promises";
import { parseArgs } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { createRetainedQueryCaller } from "./retained-query-calls.mjs";
import { finalizeRetainedQueryReceipt } from "./retained-query-finalization.mjs";
const { values } = parseArgs({
options: {
runtime: { type: "string" },
oracle: { type: "string" },
output: { type: "string" },
lane: { type: "string" },
buffer: { type: "string", default: "67108864" },
identity: { type: "string" },
},
});
for (const key of ["runtime", "oracle", "output"])
assert.ok(values[key] && values[key] === resolve(values[key]), key);
assert.ok(["complete", "selected"].includes(values.lane));
const buffer = Number(values.buffer);
assert.ok(Number.isSafeInteger(buffer) && buffer >= 10485760);
const expected = JSON.parse(await readFile(values.oracle, "utf8"));
const hash = (value) => createHash("sha256").update(value).digest("hex");
const artifact = expected.artifact.path;
assert.equal(hash(await readFile(artifact)), expected.artifact.sha256);
await mkdir(values.output, { mode: 0o700 });
const account = await mkdtemp(join(tmpdir(), "rea-export-views-"));
const client = new Client({ name: "native-export-views", version: "1" });
const transport = new StdioClientTransport({
command: process.execPath,
args: [join(values.runtime, "scripts/rea.mjs"), "mcp"],
maxBufferSize: buffer,
env: {
HOME: account,
TMPDIR: account,
PATH: process.env.PATH,
REA_LOG_LEVEL: "silent",
REA_MCP_MAX_RESPONSE_BYTES: String(buffer),
NODE_OPTIONS: "--max-old-space-size=2048",
NODE_DISABLE_COMPILE_CACHE: "1",
UV_THREADPOOL_SIZE: "1",
},
stderr: "pipe",
});
const serverLog = createWriteStream(join(values.output, "server.log"));
const serverLogFinished = finished(serverLog);
serverLogFinished.catch(() => {});
transport.stderr?.pipe(serverLog);
const report = {
lane: values.lane,
buffer_bytes: buffer,
artifact: expected.artifact,
calls: [],
phases: {},
status: "running",
};
const verifyRuntime = async (phase) => {
if (values.identity === undefined) return;
const started = performance.now();
const identityBytes = await readFile(values.identity);
const identity = JSON.parse(identityBytes);
for (const entry of [...identity.compiled_files, ...identity.entrypoints])
assert.equal(
hash(await readFile(join(values.runtime, entry.path))),
entry.sha256,
entry.path,
);
report.runtime_identity_sha256 ??= hash(identityBytes);
assert.equal(hash(identityBytes), report.runtime_identity_sha256);
report.phases[`runtime_validation_${phase}_ms`] = performance.now() - started;
};
const transcript = [];
const call = createRetainedQueryCaller(client, (receipt, event) => {
report.calls.push(receipt);
transcript.push(event);
});
const observe = async (name, input, phase) => {
const reply = await call(name, input, phase);
if (reply.isError === true) assert.fail(JSON.stringify(reply));
assert.ok(reply.structuredContent);
return reply.structuredContent;
};
const choose = (source) =>
source.exports.flatMap((row, source_index) =>
expected.names.includes(row.name) ? [{ ...row, source_index }] : [],
);
try {
await verifyRuntime("before");
let start = performance.now();
await client.connect(transport);
const catalog = await client.listTools();
report.phases.discovery_ms = performance.now() - start;
report.catalog_bytes = Buffer.byteLength(JSON.stringify(catalog));
report.catalog_sha256 = hash(JSON.stringify(catalog));
await writeFile(join(values.output, "catalog.json"), JSON.stringify(catalog));
const selected = values.lane === "selected";
const reply = await observe(
"inspect_native_exports",
{ path: artifact, ...(selected ? { detail: "summary" } : {}) },
"inspect",
);
const parentId = selected
? reply.normalized_result.parent_evidence_id
: reply.evidence_id;
report.parent_evidence_id = parentId;
let rows;
if (selected) {
rows = [];
let offset = 0;
do {
const page = (
await observe(
"inspect_analysis_view",
{
source: { kind: "retained-evidence", evidence_id: parentId },
view: {
kind: "native-exports",
collection: "exports",
names: expected.names,
offset,
limit: 2,
},
},
"page",
)
).normalized_result;
assert.equal(page.exports.coverage.status, "complete");
assert.equal(page.coverage.total, expected.rows.length);
rows.push(...page.items);
offset = page.coverage.next_offset;
} while (offset !== null);
const empty = (
await observe(
"inspect_analysis_view",
{
source: { kind: "retained-evidence", evidence_id: parentId },
view: {
kind: "native-exports",
collection: "exports",
names: ["__rea_export_oracle_missing__"],
limit: 1,
},
},
"negative",
)
).normalized_result;
assert.deepEqual(empty.items, []);
assert.equal(empty.exports.coverage.status, "complete");
assert.equal(empty.coverage.total, 0);
} else {
rows = choose(reply.normalized_result);
assert.equal(
reply.normalized_result.exports.some(
(row) => row.name === "__rea_export_oracle_missing__",
),
false,
);
}
assert.deepEqual(rows, expected.rows);
await observe(
"export_evidence_bundle",
{ path: join(values.output, "evidence.json") },
"export",
);
start = performance.now();
const bundle = JSON.parse(
await readFile(join(values.output, "evidence.json"), "utf8"),
);
const parent = bundle.records.find(
(record) => record.evidence_id === parentId,
);
assert.ok(parent);
assert.deepEqual(choose(parent.normalized_result), expected.rows);
assert.equal(
parent.normalized_result.coverage.exports,
expected.export_count,
);
assert.equal(parent.normalized_result.exports.length, expected.export_count);
assert.equal(parent.normalized_result.coverage.status, "complete");
assert.equal(parent.subject.digest.sha256, expected.artifact.sha256);
report.source_report_sha256 = hash(JSON.stringify(parent.normalized_result));
report.phases.bundle_verification_ms = performance.now() - start;
assert.equal(hash(await readFile(artifact)), expected.artifact.sha256);
report.answer = {
rows,
export_count: expected.export_count,
negative: [],
coverage: parent.normalized_result.coverage,
};
report.status = "passed";
} catch (cause) {
report.status = "failed";
report.failure = String(cause?.stack ?? cause);
process.exitCode = 1;
} finally {
try {
await verifyRuntime("after");
} catch (cause) {
report.status = "failed";
report.runtime_identity_failure = String(cause?.stack ?? cause);
process.exitCode = 1;
}
if (
!(await finalizeRetainedQueryReceipt({
report,
client,
transport,
account,
serverLog,
serverLogFinished,
output: values.output,
transcript,
}))
)
process.exitCode = 1;
}
@@ -46,8 +46,7 @@ export class NativeExportsService {
if (
!report.success ||
report.data.artifact.path !== input.data.path ||
report.data.artifact.bytes > input.data.max_file_bytes ||
report.data.coverage.exports !== report.data.exports.length
report.data.artifact.bytes > input.data.max_file_bytes
)
return err(
new AnalysisOutputError(
+1 -1
View File
@@ -79,7 +79,7 @@ export const registerAnalysisViewCommands = (
});
cli.command(CLI_COMMANDS.inspectAnalysisView, {
description:
"Project a selected view of completed Go build metadata, binary-layout, JavaScript application, native function/inventory, artifact-inventory, desktop-directory, WASM, Mach-O, Interface Builder, EVM, managed, Android package, Dart AOT, web network capture or pcap Evidence JSON",
"Project a selected view of completed Go build metadata, binary-layout, JavaScript application, native function/inventory/exports, artifact-inventory, desktop-directory, WASM, Mach-O, Interface Builder, EVM, managed, Android package, Dart AOT, web network capture or pcap Evidence JSON",
args: z.object({
inputJson: z.string().describe("Inline workflow JSON or JSON file path"),
}),
+13 -6
View File
@@ -23,6 +23,10 @@ import { inspectElfNotesInputSchema } from "../domain/native/elfNotes.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import {
analysisEvidenceDeliveryOptions,
deliverCliAnalysisEvidence,
} from "./analysisEvidenceDelivery.js";
/** Inspect operator-selected files through the same shared workflow as MCP. */
export const registerBinaryDiagnosticsCommands = (
@@ -173,6 +177,7 @@ export const registerBinaryDiagnosticsCommands = (
args: z.object({ path: z.string().describe("Local PE or ELF image") }),
options: z.object({
maxFileBytes: inspectNativeExportsInputSchema.shape.max_file_bytes,
...analysisEvidenceDeliveryOptions,
}),
run: ({ args, options }) =>
withCommandCancellation(async (signal) => {
@@ -181,8 +186,8 @@ export const registerBinaryDiagnosticsCommands = (
logger,
CLI_COMMANDS.inspectNativeExports,
async () =>
finishAnalysisCommand(
() =>
deliverCliAnalysisEvidence({
run: () =>
exports.inspect(
{
path: absoluteCallerPath(args.path),
@@ -190,11 +195,13 @@ export const registerBinaryDiagnosticsCommands = (
},
{ signal },
),
exports,
"rea-native-exports",
"inspect_native_exports",
service: exports,
providerId: "rea-native-exports",
operation: "inspect_native_exports",
signal,
),
detail: options.detail,
evidenceOutput: options.evidence_output,
}),
);
}),
});
+1 -1
View File
@@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [
...toolContractMetadata("inspect_analysis_view"),
kind: "application",
description:
'Use javascript-routes collections to query route patterns, source-linked occurrences, source status, unknowns or summary counts from retained JavaScript application or web-bundle Evidence. Inspect selected views of completed Frida session status, GDB/objdump command observations, Go build metadata, Rust embedded dependencies, binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot, inspect_il2cpp_metadata, inspect_model_artifact, inspect_gpu_code_objects or inspect_web_network_capture Evidence without repeating analysis. 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. 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 that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized reads that are not entities and have one initial request line (item-shaped paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state (a list also learns the filters, search, sort and paging its recordings verify, and answers new values of them from the state), and every other read tool is a recorded view with a reason. It also reports a write plan per state-changing tool (create, update, delete or document, built from its first successful call) and write_clock; a plan is served only when replaying the capture shows a later read confirming iLine truncated
'Use javascript-routes collections to query route patterns, source-linked occurrences, source status, unknowns or summary counts from retained JavaScript application or web-bundle Evidence. Inspect selected views of completed Frida session status, GDB/objdump command observations, Go build metadata, Rust embedded dependencies, binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot, inspect_il2cpp_metadata, inspect_model_artifact, inspect_gpu_code_objects or inspect_web_network_capture Evidence without repeating analysis. 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. 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 that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized reads that are not entities and have one initial request line (item-shaped paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state (a list also learns the filters, search, sort and paging its recordings verify, and answers new values of them from the state), and every other read tool isLine truncated
inputSchema: inspectAnalysisViewInputSchema,
outputSchema: evidenceResultOf(analysisViewResultSchema),
examples: [
@@ -9,7 +9,10 @@ import {
inspectBinaryLayoutInputSchema,
binaryLayoutSchema,
} from "../../domain/native/binaryLayout.js";
import { binaryLayoutSummaryViewResultSchema } from "../../domain/analysisView/analysisView.js";
import {
binaryLayoutSummaryViewResultSchema,
nativeExportsSummaryViewResultSchema,
} from "../../domain/analysisView/analysisView.js";
import {
analysisDetailOutputSchemaOf,
analysisDetailSchema,
@@ -38,6 +41,9 @@ import {
export const inspectBinaryLayoutRequestSchema =
inspectBinaryLayoutInputSchema.extend({ detail: analysisDetailSchema });
export const inspectNativeExportsRequestSchema =
inspectNativeExportsInputSchema.extend({ detail: analysisDetailSchema });
/**
* Public `inspect_byte_entropy` input. The shared constructor runs before
* `detail` is added so the advertised input stays closed.
@@ -123,9 +129,12 @@ export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [
...toolContractMetadata("inspect_native_exports"),
kind: "native-provider",
description:
"Inspect one explicit local PE or ELF image's export table without executing it or opening an engine: PE export-directory names, ordinals, addresses and forwarders, or ELF defined default/protected global, weak and unique .dynsym symbols with type, binding and symbol version. Ordinal-only, forwarded and hidden-version exports stay distinct. Portable on any supported host. Coverage is partial when table entries are unreadable and unsupported for an ELF without section headers; an empty complete inventory means the image declares no exports. Mach-O exports use inspect_macho.",
inputSchema: inspectNativeExportsInputSchema,
outputSchema: evidenceResultOf(nativeExportsSchema),
"Inspect one explicit local PE or ELF image's export table without executing it or opening an engine: PE export-directory names, ordinals, addresses and forwarders, or ELF defined default/protected global, weak and unique .dynsym symbols with type, binding and symbol version. Ordinal-only, forwarded and hidden-version exports stay distinct. Portable on any supported host. Coverage is partial when table entries are unreadable and unsupported when ELF dynamic symbols cannot be enumerated from the available section metadata; an empty complete inventory means the image declares no exports. Mach-O exports use inspect_macho. Omitted/complete detail preserves inline delivery. Select detail summary to retain complete Evidence and return identity, counts and source coverage. Query inspect_analysis_view kind native-exports, collection exports or issues, with exact names/original indices, fields and matching-row pagination. Names return every version or alias; source_index is scoped to the parent Evidence. ELF name projections keep raw-byte and encoding qualifications.",
inputSchema: inspectNativeExportsRequestSchema,
outputSchema: analysisDetailOutputSchemaOf(
nativeExportsSchema,
nativeExportsSummaryViewResultSchema,
),
examples: [
{
title: "List a native library's exports",
+27
View File
@@ -8,6 +8,12 @@ import {
apkSignatureSourceCoverageSchema,
} from "./apkSignatureView.js";
import { byteEntropySchema } from "../native/byteEntropy.js";
import {
nativeExportsViewRequestSchema,
nativeExportsViewItemsSchema,
nativeExportsSummarySchema,
projectNativeExportsView,
} from "./nativeExportsView.js";
import {
byteEntropySummarySchema,
projectByteEntropyView,
@@ -325,6 +331,7 @@ const nativeViewSchema = z.strictObject({
/** Caller-selected projection of already completed analysis Evidence. */
export const analysisViewRequestSchema = z.discriminatedUnion("kind", [
nativeExportsViewRequestSchema,
javaScriptRoutesViewRequestSchema,
nativeFunctionMatchingViewRequestSchema,
sourceToBundleViewRequestSchema,
@@ -443,6 +450,7 @@ const viewResultBase = {
parent_evidence_id: prefixedDigestSchema("ev"),
parent_operation: z.enum([
"inspect_byte_entropy",
"inspect_native_exports",
"inspect_binary_layout",
"inspect_go_binary",
"gdb_console",
@@ -490,6 +498,12 @@ export const byteEntropySummaryViewResultSchema = z.strictObject({
parent_operation: z.literal("inspect_byte_entropy"),
});
export const nativeExportsSummaryViewResultSchema = z.strictObject({
...summaryViewResultBase,
parent_operation: z.literal("inspect_native_exports"),
summary: nativeExportsSummarySchema,
});
/** Opt-in command delivery keeps output once and retains the complete parent. */
export const gdbCommandSummaryViewResultSchema = z.strictObject({
...summaryViewResultBase,
@@ -807,6 +821,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
view: environmentStateViewRequestSchema,
}),
z.discriminatedUnion("parent_operation", [
nativeExportsSummaryViewResultSchema,
byteEntropySummaryViewResultSchema,
gdbCommandSummaryViewResultSchema,
objdumpSummaryViewResultSchema,
@@ -835,6 +850,14 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
nativeFunctionMatchingSummaryViewResultSchema,
sourceToBundleSummaryViewResultSchema,
]),
z.strictObject({
...viewResultBase,
kind: z.literal("native-exports"),
parent_operation: z.literal("inspect_native_exports"),
view: nativeExportsViewRequestSchema,
exports: nativeExportsSummarySchema,
items: nativeExportsViewItemsSchema,
}),
z.strictObject({
...viewResultBase,
...nativeInventoryViewContext,
@@ -1190,6 +1213,10 @@ export const projectAnalysisView = (
const projected = projectPcapCaptureView(parent, view);
return projected.ok ? ok(sealAnalysisView(projected.value)) : projected;
}
if (parent.operation === "inspect_native_exports") {
const projected = projectNativeExportsView(parent, view);
return projected.ok ? ok(sealAnalysisView(projected.value)) : projected;
}
if (parent.operation === "inspect_byte_entropy") {
const report = byteEntropySchema.safeParse(parent.normalizedResult);
if (!report.success)
@@ -0,0 +1,251 @@
import { z } from "zod";
import {
nativeExportsSchema,
peNativeExportsSchema as pe,
elfNativeExportsSchema as elf,
type NativeExports,
} from "../native/nativeExports.js";
import {
canBorrowParsedJsonSnapshot,
freezeOwnedJsonTreeSnapshot,
isImmutableJsonSnapshot,
} from "../immutableJson.js";
import type { AnalysisError } from "../analysisErrorBase.js";
import { err, ok, type Result } from "../result.js";
import type {
AnalysisViewParent,
AnalysisViewRequest,
UnsignedAnalysisView,
} from "./analysisView.js";
import { analysisViewInputError } from "./analysisViewErrors.js";
import { parseAnalysisViewResult } from "./analysisViewParent.js";
import {
boundedViewMessages,
completeWithinViewCoverage,
pageViewCoverage,
} from "./analysisViewCoverage.js";
import { selectViewRows } from "./selectViewRows.js";
const peRow = pe.shape.exports.element;
const elfRow = elf.shape.exports.element;
const issue = pe.shape.issues.element;
const sourceIndex = { source_index: z.int().nonnegative() };
const page = {
kind: z.literal("native-exports"),
offset: z.int().nonnegative().default(0),
limit: z.int().positive().default(64),
};
/** Exact names preserve all versions/aliases; indices are scoped to the complete parent. */
export const nativeExportsViewRequestSchema = z.discriminatedUnion(
"collection",
[
z.strictObject({
...page,
collection: z.literal("exports"),
names: z.array(z.string()).optional(),
indices: z.array(z.int().nonnegative()).optional(),
fields: z.array(z.union([peRow.keyof(), elfRow.keyof()])).optional(),
}),
z.strictObject({
...page,
collection: z.literal("issues"),
indices: z.array(z.int().nonnegative()).optional(),
}),
],
);
export const nativeExportsViewItemsSchema = z.union([
z.array(peRow.partial().extend(sourceIndex)),
z.array(
elfRow.partial().extend({
...sourceIndex,
// Historical defaults normalize complete source rows, never omitted columns.
name_bytes_base64: elfRow.shape.name_bytes_base64
.removeDefault()
.optional(),
name_utf8_decodable: elfRow.shape.name_utf8_decodable
.removeDefault()
.optional(),
}),
),
z.array(issue.extend(sourceIndex)),
]);
const counts = {
export_count: z.int().nonnegative(),
issue_count: z.int().nonnegative(),
limitation_count: z.int().nonnegative(),
};
/** Source coverage remains distinct from page coverage, including an empty partial page. */
export const nativeExportsSummarySchema = z.discriminatedUnion("format", [
pe.omit({ exports: true, issues: true, limitations: true }).extend(counts),
elf.omit({ exports: true, issues: true, limitations: true }).extend(counts),
]);
// This validates exact immutable Evidence, never a path or current filesystem state.
// Weak keys release their parsed representation with the owning retained record.
const checked = new WeakMap<object, NativeExports>();
const readReport = (
parent: AnalysisViewParent,
): Result<NativeExports, AnalysisError> => {
const source = parent.normalizedResult;
const reusable =
parent.sealedResult === true &&
typeof source === "object" &&
source !== null &&
isImmutableJsonSnapshot(source);
const known = reusable ? checked.get(source) : undefined;
if (known !== undefined) return ok(known);
const parsed = parseAnalysisViewResult(nativeExportsSchema, parent);
if (!parsed.ok) return parsed;
if (reusable) {
const report = canBorrowParsedJsonSnapshot(source, parsed.value)
? source
: freezeOwnedJsonTreeSnapshot(parsed.value);
checked.set(source, report);
return ok(report);
}
return parsed;
};
/** Read selected export facts without reopening the artifact or reinterpreting its tables. */
export const projectNativeExportsView = (
parent: AnalysisViewParent,
view: AnalysisViewRequest,
): Result<UnsignedAnalysisView, AnalysisError> => {
const invalid = (message: string) =>
err(
analysisViewInputError([
{ path: ["view"], reason: "invalid_value", message },
]),
);
if (view.kind !== "summary" && view.kind !== "native-exports")
return invalid(
"Native export Evidence supports summary and native-exports views.",
);
const parsed = readReport(parent);
if (!parsed.ok) return parsed;
const report = parsed.value;
const { exports, issues, limitations, ...identity } = report;
const summary = {
...identity,
export_count: exports.length,
issue_count: issues.length,
limitation_count: limitations.length,
};
const shared = {
parent_evidence_id: parent.evidenceId,
parent_operation: "inspect_native_exports" as const,
parent_digest: parent.evidenceId.slice(3),
artifact: { path: report.artifact.path, sha256: report.artifact.sha256 },
limitations: boundedViewMessages(
[...parent.limitations, ...limitations],
parent.evidenceId,
"limitations",
),
unknowns:
report.coverage.status === "complete"
? []
: [
report.coverage.reason ??
"Export-table coverage is incomplete; no-match pages do not prove absence.",
],
};
if (view.kind === "summary")
return ok({
...shared,
kind: "summary",
view,
summary,
coverage: completeWithinViewCoverage(exports.length, exports.length),
});
const indices =
view.indices === undefined ? undefined : new Set(view.indices);
if (view.collection === "issues") {
const selected =
indices === undefined
? {
rows: issues
.slice(view.offset, view.offset + view.limit)
.map((row, index) => ({
...row,
source_index: view.offset + index,
})),
total: issues.length,
}
: selectViewRows(
issues,
view,
(_, index) => indices.has(index),
(row, source_index) => ({ ...row, source_index }),
);
return ok({
...shared,
kind: "native-exports",
view,
exports: summary,
items: selected.rows,
coverage: pageViewCoverage(
view.offset,
selected.rows.length,
selected.total,
),
});
}
const allowed =
report.format === "pe" ? peRow.keyof().options : elfRow.keyof().options;
const fields =
view.fields === undefined ? allowed : [...new Set(view.fields)];
const allowedFields: readonly string[] = allowed;
if (fields.some((field) => !allowedFields.includes(field)))
return invalid(
`Selected export fields do not belong to ${report.format}; available fields: ${allowed.join(", ")}.`,
);
const names = view.names === undefined ? undefined : new Set(view.names);
// A projected decoded name still carries its exact bytes and encoding qualification.
const columns = new Set<string>(fields);
if (report.format === "elf" && columns.has("name")) {
columns.add("name_bytes_base64");
columns.add("name_utf8_decodable");
}
const rows: readonly NativeExports["exports"][number][] = exports;
const projectRow = (
row: NativeExports["exports"][number],
source_index: number,
) => ({
...Object.fromEntries(
Object.entries(row).filter(([key]) => columns.has(key)),
),
source_index,
});
// Unfiltered pages use the owner's original order directly; no whole-table scan.
const selected =
names === undefined && indices === undefined
? {
rows: rows
.slice(view.offset, view.offset + view.limit)
.map((row, index) => projectRow(row, view.offset + index)),
total: rows.length,
}
: selectViewRows(
rows,
view,
(row, index) =>
(indices === undefined || indices.has(index)) &&
(names === undefined || (row.name !== null && names.has(row.name))),
projectRow,
);
return ok({
...shared,
kind: "native-exports",
view,
exports: summary,
items: selected.rows,
coverage: pageViewCoverage(
view.offset,
selected.rows.length,
selected.total,
),
});
};
+44 -33
View File
@@ -100,39 +100,50 @@ const coverageSchema = z.strictObject({
});
/** Static export inventory of one PE or ELF image, read without execution. */
export const nativeExportsSchema = z.discriminatedUnion("format", [
z.strictObject({
artifact: artifactSchema,
format: z.literal("pe"),
architecture: z.string().min(1),
bits: z.union([z.literal(32), z.literal(64)]),
/** Null when the image declares no export directory. */
export_directory: z
.strictObject({
rva: uint,
size: uint,
dll_name: z.string().nullable(),
ordinal_base: uint,
timestamp: uint,
})
.nullable(),
exports: z.array(peExportSchema).max(MAX_NATIVE_EXPORT_ROWS),
coverage: coverageSchema,
issues: z.array(nativeExportIssueSchema),
limitations: z.array(z.string().min(1)),
}),
z.strictObject({
artifact: artifactSchema,
format: z.literal("elf"),
architecture: z.string().min(1),
bits: z.union([z.literal(32), z.literal(64)]),
byte_order: z.enum(["little", "big"]),
exports: z.array(elfExportSchema).max(MAX_NATIVE_EXPORT_ROWS),
coverage: coverageSchema,
issues: z.array(nativeExportIssueSchema),
limitations: z.array(z.string().min(1)),
}),
]);
export const peNativeExportsSchema = z.strictObject({
artifact: artifactSchema,
format: z.literal("pe"),
architecture: z.string().min(1),
bits: z.union([z.literal(32), z.literal(64)]),
/** Null when the image declares no export directory. */
export_directory: z
.strictObject({
rva: uint,
size: uint,
dll_name: z.string().nullable(),
ordinal_base: uint,
timestamp: uint,
})
.nullable(),
exports: z.array(peExportSchema).max(MAX_NATIVE_EXPORT_ROWS),
coverage: coverageSchema,
issues: z.array(nativeExportIssueSchema),
limitations: z.array(z.string().min(1)),
});
export const elfNativeExportsSchema = z.strictObject({
artifact: artifactSchema,
format: z.literal("elf"),
architecture: z.string().min(1),
bits: z.union([z.literal(32), z.literal(64)]),
byte_order: z.enum(["little", "big"]),
exports: z.array(elfExportSchema).max(MAX_NATIVE_EXPORT_ROWS),
coverage: coverageSchema,
issues: z.array(nativeExportIssueSchema),
limitations: z.array(z.string().min(1)),
});
export const nativeExportsSchema = z
.discriminatedUnion("format", [peNativeExportsSchema, elfNativeExportsSchema])
.superRefine((value, context) => {
if (value.coverage.exports !== value.exports.length)
context.addIssue({
code: "custom",
path: ["coverage", "exports"],
message:
"Export coverage count must equal the retained export row count.",
});
});
export type NativeExports = z.infer<typeof nativeExportsSchema>;
export type PeExport = z.infer<typeof peExportSchema>;
export type ElfExport = z.infer<typeof elfExportSchema>;
@@ -0,0 +1,263 @@
import { expect, it } from "vitest";
import { parseNativeExports } from "./NativeExportParser.js";
import {
elfExportImage,
peExportImage,
} from "../../../tests/fixtures/binaryDiagnostics/nativeExports.js";
import { freezeJsonSnapshot } from "../../domain/immutableJson.js";
import { digestCanonicalValue } from "../../domain/canonicalDigest.js";
import {
analysisViewRequestSchema,
projectAnalysisView,
type AnalysisViewParent,
} from "../../domain/analysisView/analysisView.js";
const artifact = {
path: "/fixtures/exports",
sha256: "a".repeat(64),
bytes: 2048,
};
const parent = (report: unknown): AnalysisViewParent => ({
evidenceId: `ev_${"b".repeat(64)}`,
operation: "inspect_native_exports",
normalizedResult: report,
limitations: [],
});
const project = (report: unknown, input: unknown) => {
const result = projectAnalysisView(
parent(report),
analysisViewRequestSchema.parse(input),
);
if (!result.ok) throw result.error;
return result.value;
};
const elf = () => {
const report = parseNativeExports(
elfExportImage({
symbols: [
{ name: "same", version: 2, value: 0x1234n },
{ name: "other" },
{ name: "same", version: 0x8003, value: 0x1234n },
{ name: "café" },
],
versionDefinitions: ["libfixture.so", "V1", "V2"],
}),
artifact,
);
if (report.format !== "elf") throw new Error("Expected ELF fixture");
return report;
};
it("keeps every ELF same-name/version alternative, source order and matching-page coordinates", () => {
const report = elf();
const input = {
kind: "native-exports",
collection: "exports",
names: ["same", "same"],
fields: ["name", "value", "version"],
limit: 1,
};
expect(project(report, input)).toMatchObject({
items: [
{
source_index: 0,
name: "same",
value: "0x1234",
version: { index: 2, name: "V1", hidden: false },
},
],
exports: { export_count: 4, coverage: { status: "complete" } },
coverage: { examined: 1, total: 2, next_offset: 1, exhausted: false },
});
expect(project(report, { ...input, offset: 1 })).toMatchObject({
items: [
{ source_index: 2, version: { index: 3, name: "V2", hidden: true } },
],
coverage: { total: 2, next_offset: null, exhausted: true },
});
expect(
project(report, { ...input, indices: [2, 2, 99], fields: [] }),
).toMatchObject({ items: [{ source_index: 2 }], coverage: { total: 1 } });
expect(project(report, { ...input, names: [] })).toMatchObject({
items: [],
coverage: { status: "empty", total: 0 },
});
});
it("qualifies projected names with exact encoding facts and normalizes historical omissions", () => {
const report = elf();
expect(
project(report, {
kind: "native-exports",
collection: "exports",
names: ["café"],
fields: ["name"],
}),
).toMatchObject({
items: [
{
source_index: 3,
name: "café",
name_bytes_base64: "Y2Fmw6k=",
name_utf8_decodable: true,
},
],
});
const historical = {
...report,
exports: report.exports.map(
({ name_bytes_base64: _bytes, name_utf8_decodable: _utf8, ...row }) =>
row,
),
};
freezeJsonSnapshot(historical);
const input = { ...parent(historical), sealedResult: true };
for (let attempt = 0; attempt < 2; attempt++) {
const result = projectAnalysisView(
input,
analysisViewRequestSchema.parse({
kind: "native-exports",
collection: "exports",
names: ["café"],
fields: ["name"],
}),
);
if (!result.ok) throw result.error;
expect(result.value).toMatchObject({
items: [
{ name: "café", name_bytes_base64: null, name_utf8_decodable: null },
],
});
}
});
it("does not fill historical name defaults into a projection that omits names", () => {
const result = project(elf(), {
kind: "native-exports",
collection: "exports",
names: ["same"],
fields: ["value"],
});
expect(result).toHaveProperty("items", [
{ source_index: 0, value: "0x1234" },
{ source_index: 2, value: "0x1234" },
]);
const { view_digest, ...content } = result;
expect(view_digest).toBe(digestCanonicalValue(content, "Analysis view"));
});
it("preserves PE ordinal-only, forwarder, alias and same-address distinctions", () => {
const report = parseNativeExports(
peExportImage({
ordinalBase: 65536,
functions: [
{ rva: 0x2000 },
{ forwarder: "other.Target" },
{ rva: 0x2000 },
],
names: [
{ name: "AliasA", index: 0 },
{ name: "AliasB", index: 0 },
{ name: "Forwarded", index: 1 },
],
}),
artifact,
);
const result = project(report, {
kind: "native-exports",
collection: "exports",
offset: 1,
limit: 9,
});
expect(result).toMatchObject({
items: [
{
source_index: 1,
name: "AliasB",
ordinal: 65536,
address: "0x180002000",
forwarder: null,
},
{
source_index: 2,
name: "Forwarded",
ordinal: 65537,
address: null,
forwarder: "other.Target",
},
{
source_index: 3,
name: null,
ordinal: 65538,
address: "0x180002000",
forwarder: null,
},
],
});
});
it.each(["partial", "unsupported"] as const)(
"keeps %s source coverage in empty pages and exposes complete issues",
(status) => {
const report = elf();
report.coverage = {
...report.coverage,
status,
reason: "Selected tables remain incomplete",
};
report.issues = [
{
code: "table_unavailable",
location: null,
message: "Unobserved table",
},
];
expect(
project(report, {
kind: "native-exports",
collection: "exports",
names: ["missing"],
}),
).toMatchObject({
items: [],
coverage: { status: "empty", total: 0 },
exports: {
coverage: { status, reason: "Selected tables remain incomplete" },
issue_count: 1,
},
unknowns: ["Selected tables remain incomplete"],
});
expect(
project(report, { kind: "native-exports", collection: "issues" }),
).toMatchObject({
items: [
{
source_index: 0,
code: "table_unavailable",
location: null,
message: "Unobserved table",
},
],
});
},
);
it("revalidates mutable imports and refuses inconsistent coverage and format-incompatible fields", () => {
const report = elf();
const input = { ...parent(report), sealedResult: true };
expect(projectAnalysisView(input, { kind: "summary" }).ok).toBe(true);
Object.freeze(report);
report.coverage.exports = 99;
expect(projectAnalysisView(input, { kind: "summary" }).ok).toBe(false);
expect(
projectAnalysisView(
parent(elf()),
analysisViewRequestSchema.parse({
kind: "native-exports",
collection: "exports",
fields: ["ordinal"],
}),
).ok,
).toBe(false);
expect(projectAnalysisView(parent({}), { kind: "summary" }).ok).toBe(false);
});
+1
View File
@@ -934,6 +934,7 @@ const registerConfiguredAnalysisTools = (
nativeExports,
toolLogger,
recordEvidence,
evidenceById,
withAdmittedAnalysis,
);
registerElfNotesTool(
+30 -1
View File
@@ -6,6 +6,13 @@ import type { Logger } from "pino";
import { createEvidenceToolHandler } from "./contractToolHandler.js";
import type { WithAdmittedAnalysis } from "./analysisAdmission.js";
import { applicationToolRegistrationOptions } from "./toolRegistrationOptions.js";
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
import {
requireAnalysisViewRetention,
summarizeRetainedAnalysis,
} from "../application/analysisView/AnalysisViewService.js";
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
import { err } from "../domain/result.js";
/** Register the explicit-file export-table workflow and retain its complete Evidence. */
export const registerNativeExportsTool = (
@@ -13,12 +20,34 @@ export const registerNativeExportsTool = (
service: NativeExportsService,
logger: Logger,
recordEvidence?: EvidenceWriter["recordEvidence"],
evidenceById?: EvidenceLookup,
withAdmittedAnalysis?: WithAdmittedAnalysis,
): void => {
const contract = toolContract("inspect_native_exports");
const handler = createEvidenceToolHandler(
server,
(input, options) => service.inspect(input, options),
async (rawInput, options) => {
const parsed = contract.inputSchema.safeParse(rawInput);
if (!parsed.success)
return err(
analysisInputErrorFromIssues(
contract.name,
parsed.error.issues,
rawInput,
{ cause: parsed.error },
),
);
const { detail, ...input } = parsed.data;
const retention = { recordEvidence, evidenceById };
if (detail === "summary") {
const ready = requireAnalysisViewRetention(contract.name, retention);
if (!ready.ok) return ready;
}
const inspected = await service.inspect(input, options);
return detail === "summary" && inspected.ok
? summarizeRetainedAnalysis(inspected.value, retention)
: inspected;
},
logger,
recordEvidence,
withAdmittedAnalysis,
@@ -0,0 +1,168 @@
import { rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { z } from "zod";
import { readEvidenceBundle } from "../../../src/application/EvidenceBundleFiles.js";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { evidenceSchema } from "../../../src/domain/evidence.js";
import { digestCanonicalValue } from "../../../src/domain/canonicalDigest.js";
import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import {
elfExportImage,
peExportImage,
} from "../../fixtures/binaryDiagnostics/nativeExports.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
const exportsContract = toolContract("inspect_native_exports");
const viewContract = toolContract("inspect_analysis_view");
cliTest.for(["pe", "elf"] as const)(
"queries retained %s exports through CLI and actual stdio MCP after the source is deleted",
{ timeout: 60_000 },
async (format, { cli }) => {
const root = await createTestTempDirectory("rea-export-views-");
const path = join(root, `library.${format}`);
const bytes =
format === "pe"
? peExportImage({
functions: [{ rva: 0x2000 }, { forwarder: "other.Target" }],
names: [
{ name: "selected", index: 0 },
{ name: "selected", index: 1 },
],
})
: elfExportImage({
symbols: [
{ name: "selected", version: 2 },
{ name: "selected", version: 0x8002 },
],
versionDefinitions: ["fixture", "V1"],
});
await writeFile(path, bytes);
const run = cli.sharedCliRun?.bind(cli) ?? cli.run.bind(cli);
const complete = await run({
arguments: ["inspect-native-exports", path, "--json"],
});
expect(complete.exitCode, complete.stderr).toBe(0);
const parent = exportsContract.outputSchema.parse(complete.json);
const bundle = join(root, "complete.bundle.json");
const summarized = await run({
arguments: [
"inspect-native-exports",
path,
"--detail",
"summary",
"--evidence_output",
bundle,
"--json",
],
});
expect(summarized.exitCode, summarized.stderr).toBe(0);
const delivered = z
.object({ summary: evidenceSchema })
.parse(summarized.json).summary;
expect(delivered.operation).toBe("inspect_analysis_view");
expect(delivered.normalized_result).toMatchObject({
parent_evidence_id: parent.evidence_id,
summary: { format, export_count: 2, coverage: { status: "complete" } },
});
const loaded = await readEvidenceBundle(bundle);
if (!loaded.ok) throw loaded.error;
expect(loaded.value.records).toEqual([parent]);
const call =
cli.sharedMcpCall === undefined
? (await connectLocalToolsMcp()).call
: await cli.sharedMcpCall();
const response = await call("inspect_native_exports", {
path,
detail: "summary",
});
expect(response.isError).not.toBe(true);
expect(
exportsContract.outputSchema.parse(response.structuredContent)
.normalized_result,
).toEqual(delivered.normalized_result);
await rm(path);
const source = {
kind: "retained-evidence",
evidence_id: parent.evidence_id,
};
const request = {
source,
view: {
kind: "native-exports",
collection: "exports",
names: ["selected"],
fields: ["name"],
offset: 1,
limit: 1,
},
};
const viewed = await call("inspect_analysis_view", request);
expect(viewed.isError).not.toBe(true);
const page = viewContract.outputSchema.parse(viewed.structuredContent);
expect(page.evidence_links).toContain(parent.evidence_id);
expect(page.normalized_result).toMatchObject({
kind: "native-exports",
items: [{ source_index: 1, name: "selected" }],
exports: { coverage: { status: "complete" } },
coverage: { examined: 1, total: 2, next_offset: null, exhausted: true },
});
const offline = await run({
arguments: [
"inspect-analysis-view",
JSON.stringify(request),
"--evidence-bundle",
bundle,
"--json",
],
});
expect(offline.exitCode, offline.stderr).toBe(0);
expect(viewContract.outputSchema.parse(offline.json)).toEqual(page);
const projected = await call("inspect_analysis_view", {
...request,
view: {
...request.view,
fields: [format === "elf" ? "value" : "ordinal"],
},
});
expect(projected.isError).not.toBe(true);
const projectedView = analysisViewResultSchema.parse(
viewContract.outputSchema.parse(projected.structuredContent)
.normalized_result,
);
expect(projectedView).toHaveProperty("items", [
{
source_index: 1,
...(format === "elf"
? { value: expect.any(String) }
: { ordinal: expect.any(Number) }),
},
]);
const { view_digest, ...content } = projectedView;
expect(view_digest).toBe(digestCanonicalValue(content, "Analysis view"));
},
);
cliTest(
"refuses CLI summary without a durable Evidence destination before reading",
async ({ cli }) => {
const result = await cli.run({
arguments: [
"inspect-native-exports",
"/rea-nonexistent/export.so",
"--detail",
"summary",
"--json",
],
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
code: "invalid_request",
details: { issues: [{ path: ["evidence_output"] }] },
});
},
);
@@ -30,6 +30,51 @@ import {
} from "../../fixtures/binaryDiagnostics/layout.js";
import { elfExportImage } from "../../fixtures/binaryDiagnostics/nativeExports.js";
import { parseNativeExports } from "../../../src/native/exports/NativeExportParser.js";
import { createNativeExportsService } from "../../../src/composition/binaryDiagnostics.js";
import { registerNativeExportsTool } from "../../../src/server/registerNativeExportsTool.js";
it("refuses a native exports summary before file admission when retention is unavailable", async () => {
const service = createNativeExportsService();
const server = new EvidenceMcpServer(
{ name: "exports-preflight", version: "1" },
{},
undefined,
createToolResultDelivery(undefined),
);
registerNativeExportsTool(server, service, silentLogger);
const client = new Client({ name: "exports-preflight-client", version: "1" });
onTestFinished(async () => {
await client.close();
await server.close();
await service.close();
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
const schemas = await advertisedMcpToolSchemas(
client,
"inspect_native_exports",
);
expect(
schemas.ajv.validate(schemas.inputSchema, {
path: "/absent/native-library.so",
detail: "summary",
}),
).toBe(true);
const refused = await client.callTool({
name: "inspect_native_exports",
arguments: { path: "/absent/native-library.so", detail: "summary" },
});
expect(parseMcpToolError(refused).error).toMatchObject({
code: "capability_unavailable",
});
const complete = await client.callTool({
name: "inspect_native_exports",
arguments: { path: "/absent/native-library.so" },
});
expect(parseMcpToolError(complete).error.code).toBe("invalid_request");
});
it.each([false, true])(
"preserves a completed byte report through MCP cleanup failure and retry (retained=%s)",