mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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:
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.
|
||||
@@ -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(
|
||||
|
||||
@@ -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"),
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -934,6 +934,7 @@ const registerConfiguredAnalysisTools = (
|
||||
nativeExports,
|
||||
toolLogger,
|
||||
recordEvidence,
|
||||
evidenceById,
|
||||
withAdmittedAnalysis,
|
||||
);
|
||||
registerElfNotesTool(
|
||||
|
||||
@@ -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)",
|
||||
|
||||
Reference in new issue
Block a user