Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
beaf4040bc | ||
|
|
90e433df06 | ||
|
|
0ba4d6f43a | ||
|
|
7d97187ec4 | ||
|
|
080c0e2b2b | ||
|
|
9eec57b469 | ||
|
|
8d198d6ec7 | ||
|
|
c2858d2aa0 | ||
|
|
e52a71602d | ||
|
|
8443692e2b | ||
|
|
7b2087e449 |
No files matched your search
@@ -210,7 +210,8 @@ and language. Other resource payload formats remain opaque.
|
||||
`max_file_bytes` defaults to 64 MiB (maximum 512 MiB); `max_entries` defaults to
|
||||
16384, the largest stock Windows resource image measured with headroom
|
||||
(maximum 65536). CLI options are `--max-file-bytes` and `--max-entries`.
|
||||
Metadata is bounded to 8 MiB; exceeding any budget fails without truncation.
|
||||
Metadata is bounded to a fixed 8 MiB, reported as `max_metadata_bytes`;
|
||||
exceeding any budget fails without truncation.
|
||||
Large Evidence envelopes may require a caller-configured MCP transport receive
|
||||
buffer because envelope copies add overhead. Requests honor cancellation during
|
||||
snapshot reading, directory traversal and payload hashing. The operation reads
|
||||
|
||||
@@ -19,6 +19,56 @@ requires an absolute filesystem path and preserves that exact selection:
|
||||
}
|
||||
```
|
||||
|
||||
For build identity and counts, select `detail: "summary"`. MCP retains the same
|
||||
complete Evidence and returns `normalized_result.parent_evidence_id`. Read
|
||||
selected dependency versions without reopening the file:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "inspect_analysis_view",
|
||||
"arguments": {
|
||||
"source": {
|
||||
"kind": "retained-evidence",
|
||||
"evidence_id": "ev_<parent digest>"
|
||||
},
|
||||
"view": {
|
||||
"kind": "go",
|
||||
"collection": "dependencies",
|
||||
"paths": ["golang.org/x/crypto"],
|
||||
"fields": ["path", "version", "replacement"],
|
||||
"offset": 0,
|
||||
"limit": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Go views support `dependencies` (exact `paths`) and `settings` (exact `keys`),
|
||||
optional original `indices`, field selection, and pagination. Filters intersect;
|
||||
empty filter arrays select no rows and `fields: []` returns only row identity.
|
||||
Each row keeps its original `index`, scoped to the parent Evidence and collection;
|
||||
duplicate module paths and setting keys keep distinct indices. Page offsets
|
||||
count matching records. Exhausting retained rows does not establish complete
|
||||
runtime dependencies. Original bytes, unknown lines and all complete metadata
|
||||
remain in the parent and can be exported as an Evidence bundle.
|
||||
|
||||
One-shot CLI summaries save complete Evidence before returning the projection:
|
||||
|
||||
```bash
|
||||
rea inspect-go-binary ./application --detail summary --evidence_output ./go-evidence.json --json
|
||||
```
|
||||
|
||||
`--evidence_output` is required for CLI summary delivery and never overwrites an
|
||||
existing file. The default CLI/MCP reply remains complete.
|
||||
|
||||
[Measured workflows](../experiments/analysis-efficiency/go-build-views.md) include
|
||||
catalog repetition and extra calls. Identity/count questions benefit from a
|
||||
single summary. In the tested Codex full-catalog configuration, a compound
|
||||
question about identity and one dependency uses fewer tokens in captured request JSON with
|
||||
one complete inspection than with a summary followed by a lookup. The complete
|
||||
reply remains useful for that initial question; retained views support later
|
||||
selected reads. Token figures are serialization estimates, not model usage.
|
||||
|
||||
## Observations
|
||||
|
||||
The inline Evidence result contains the artifact path, SHA-256, byte length,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Count verified Go tasks; tokenizer estimates are not model usage or billing."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
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())
|
||||
log = path.with_suffix(".log")
|
||||
scope = next(json.loads(line) for line in reversed(log.read_text().splitlines())
|
||||
if line.startswith('{"resource_scope":'))
|
||||
observed = scope.get("payload_resource_observation") or {}
|
||||
events = observed.get("resource_events", {})
|
||||
memory = events.get("memory.events", {})
|
||||
verified = (report["status"] == "observed"
|
||||
and not report["cleanup_failures"] and not report["receipt_failures"]
|
||||
and scope["abort"] is None
|
||||
and scope.get("payload_resource_observation_verified") is True
|
||||
and observed.get("payload_cleanup_verified") is True
|
||||
and observed.get("payload_exit_code") == 0
|
||||
and all(memory.get(key) == 0 for key in ["high", "max", "oom", "oom_kill"])
|
||||
and events.get("pids.events", {}).get("max") == 0)
|
||||
count = lambda value: len(encoding.encode(value, disallowed_special=()))
|
||||
inputs = count(compact([{key: tool[key] for key in ["name", "description", "inputSchema"]}
|
||||
for tool in catalog["tools"]]))
|
||||
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")
|
||||
cpu = observed.get("cpu_stat", {}).get("usage_usec")
|
||||
return {"case": path.name, "completed_with_verified_cleanup": verified,
|
||||
"mode": report["mode"], "warm_diagnostic": report["warm_diagnostic"],
|
||||
"answer": report["answer"], "parent_evidence_id": report["parent_evidence_id"],
|
||||
"artifact_sha256": report["artifact_sha256"],
|
||||
"complete_result_sha256": report["complete_result_file_sha256"],
|
||||
"calls": report["calls"], "wall_seconds": scope["elapsed_seconds"],
|
||||
"cpu_seconds": None if cpu is None else cpu / 1e6,
|
||||
"kernel_family_peak_bytes": observed.get("memory_peak_bytes"),
|
||||
"events_at_payload_exit": events,
|
||||
"final_whole_scope_events": scope.get("resource_events_final"),
|
||||
"token_proxy": {"input_catalog": inputs, "arguments": arguments,
|
||||
"text_replies": replies, "combined": inputs + arguments + replies,
|
||||
"complete_transport_catalog": count(compact(catalog))},
|
||||
"sha256": {file.name: hashlib.sha256(file.read_bytes()).hexdigest()
|
||||
for file in [path / "report.json", path / "catalog.json",
|
||||
path / "transcript.json", log]}}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("cases", type=Path, nargs="+")
|
||||
args = parser.parse_args()
|
||||
encoding = tiktoken.get_encoding("cl100k_base")
|
||||
records = [inspect(path, encoding) for path in args.cases]
|
||||
receipt = {"tiktoken": tiktoken.__version__, "encoding": encoding.name,
|
||||
"actual_model_usage": None,
|
||||
"projection": "All discovered input schemas once, plus every call's arguments and text replies, including export. Full transport catalog counted separately; no model turn or host injection measured.",
|
||||
"recordings": records}
|
||||
args.output.write_text(json.dumps(receipt, ensure_ascii=False, indent=2) + "\n")
|
||||
assert all(record["completed_with_verified_cleanup"] for record in records)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Pin a completed Go report only after checking native build text and file bytes."""
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--evidence", type=Path, required=True)
|
||||
parser.add_argument("--input", type=Path, required=True)
|
||||
parser.add_argument("--go", type=Path, required=True)
|
||||
parser.add_argument("--dependency", required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
source = args.input.read_bytes()
|
||||
evidence = json.loads(args.evidence.read_text())
|
||||
if "records" in evidence:
|
||||
evidence = next(record for record in evidence["records"]
|
||||
if record["operation"] == "inspect_go_binary"
|
||||
and record["parameters"] == {"path": str(args.input)})
|
||||
report = evidence["normalized_result"]
|
||||
info = report["build_info"]
|
||||
digest = hashlib.sha256(source).hexdigest()
|
||||
assert report["artifact"] == {"path": str(args.input), "sha256": digest,
|
||||
"bytes": len(source)}
|
||||
native = subprocess.check_output(
|
||||
[str(args.go), "version", "-m", str(args.input)], text=True)
|
||||
lines = native.splitlines()
|
||||
assert info["go_version"] == lines[0].rsplit(" ", 1)[1]
|
||||
text = "\n".join(line.removeprefix("\t") for line in lines[1:]) + "\n"
|
||||
assert info["module_text"] == text
|
||||
for name in ["version", "module"]:
|
||||
location = info[name + "_location"]
|
||||
key = ("go_version" if name == "version" else "module") + "_bytes_base64"
|
||||
assert source[location["offset"]:location["offset"] + location["bytes"]] == base64.b64decode(info[key])
|
||||
# Module identity columns are literal tab-separated fields. Preserve empty
|
||||
# sums, development versions, original ordering and local replacements.
|
||||
dependencies = []
|
||||
main_module = None
|
||||
previous = None
|
||||
for line in text.splitlines():
|
||||
columns = line.split("\t")
|
||||
tag = columns[0]
|
||||
if tag not in ["dep", "mod", "=>"]:
|
||||
continue
|
||||
assert len(columns) in [3, 4], columns
|
||||
item = {"path": columns[1], "version": columns[2],
|
||||
"sum": columns[3] if len(columns) == 4 else None}
|
||||
if tag == "=>":
|
||||
assert previous is not None
|
||||
previous["replacement"] = item
|
||||
else:
|
||||
item["replacement"] = None
|
||||
previous = item
|
||||
if tag == "mod":
|
||||
main_module = item
|
||||
else:
|
||||
dependencies.append(item)
|
||||
assert info["module"]["main"] == main_module
|
||||
assert info["module"]["dependencies"] == dependencies
|
||||
answer = {"go_version": info["go_version"], "main": main_module,
|
||||
"runtime_dependencies": "unknown",
|
||||
"dependencies": [{"index": index, **item}
|
||||
for index, item in enumerate(dependencies)
|
||||
if item["path"] == args.dependency]}
|
||||
complete = args.output.with_suffix(".complete-result.json")
|
||||
complete.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
|
||||
receipt = {"artifact_sha256": digest, "dependency_path": args.dependency,
|
||||
"dependency_count": len(dependencies), "answer": answer,
|
||||
"complete_result_path": str(complete.resolve()),
|
||||
"native_text_sha256": hashlib.sha256(native.encode()).hexdigest(),
|
||||
"native_go_path": str(args.go),
|
||||
"native_go_sha256": hashlib.sha256(args.go.read_bytes()).hexdigest()}
|
||||
args.output.write_text(json.dumps(receipt, ensure_ascii=False, indent=2) + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,195 @@
|
||||
# Selected Go build metadata
|
||||
|
||||
Addresses [#5499](https://github.com/morluto/rea/issues/5499). A compiler/main-module
|
||||
identity question previously required the complete Go report, including raw framed
|
||||
bytes, base64, module text and every parsed dependency. Opt-in `detail: summary`
|
||||
retains the same complete parent and returns identity, source ranges, counts and
|
||||
explicit unknowns. Default complete delivery remains compatible.
|
||||
|
||||
`inspect_analysis_view` reuses that parent for `kind: go`, collection
|
||||
`dependencies` or `settings`. Exact paths/keys and original indices intersect;
|
||||
fields select columns, while every row keeps its original index. Pagination
|
||||
counts matching rows in producer order, including duplicate module paths. The
|
||||
view never reopens a target or reparses module text. Unknown lines and original
|
||||
bytes remain in the complete parent. CLI summaries save that parent in a new
|
||||
Evidence bundle before returning their projection.
|
||||
|
||||
Only immutable authenticated Evidence results reuse schema validation, keyed
|
||||
by their object identity for the Evidence lifetime. Mutable inline results are
|
||||
validated on each call. Subject identity is checked separately on every view.
|
||||
There is no live-file cache. Only page rows are projected; inherited messages
|
||||
use the existing bounded, deduplicating view-message owner.
|
||||
|
||||
## Question and independent checks
|
||||
|
||||
The main artifact is the official GitHub CLI v2.102.0 Linux amd64 release:
|
||||
42,086,560 bytes, SHA-256
|
||||
`7469124f706944133d6a169691dd1c6c3511b12e85878d255e044e2948df4c9b`.
|
||||
The archive digest matches its official GitHub asset metadata. Its embedded
|
||||
compiler is `go1.27.1`, main module `github.com/cli/cli/v2@v2.102.0`, and it has
|
||||
165 parsed dependency records. The selected `golang.org/x/crypto@v0.57.0` record
|
||||
keeps original index 152. These are embedded observations, not authentication
|
||||
of the compiler, supply chain or runtime behavior.
|
||||
|
||||
Native `go version -m` independently matches module text, main identity and all
|
||||
165 dependency identities in original order. Original file ranges match the
|
||||
reported compiler and framed module bytes. Before/after exports have identical
|
||||
complete normalized results and parent Evidence identity. The small real case
|
||||
is the installed 12,366,352-byte Go tool: embedded `go1.23.5`, no decoded main
|
||||
module and zero retained dependencies, with dependency coverage still unknown.
|
||||
REA reads both inputs without executing them. The installed Go tool also runs
|
||||
as the trusted native verifier; the downloaded GitHub CLI binary is never
|
||||
executed.
|
||||
|
||||
Timing baseline is main `ee12d517a25d34853d40e31fc039c7d1576c60e1`. Packaged runtimes
|
||||
and source patch are pinned in the [receipt](go-build-views.json); later changes
|
||||
do not relabel these timings. Node 24.18.0/npm 11.16.0, MCP SDK 2.3.1, Linux
|
||||
x86-64. Fresh processes use unflushed OS caches; the SDK controller pre-reads
|
||||
the input for verification. These are not disk-cold measurements.
|
||||
|
||||
## Actual client context, including every request
|
||||
|
||||
The [wire driver](go-build-wire.py) starts actual Codex app-server 0.163.0 with a
|
||||
fresh account/workspace and a loopback deterministic Responses SSE backend.
|
||||
It starts a scripted turn and real MCP calls, with **no AI inference or paid
|
||||
service**. All 210 tools remain discovered/advertised. Full input/output
|
||||
profiles are explicit, and each relevant tool has a 30,000-token output budget
|
||||
so complete JSON is delivered without retries or truncation. This does not
|
||||
qualify another client's defaults or a real model's reasoning/schema acceptance.
|
||||
|
||||
All original facts and parent identities match the native oracle. Export cases
|
||||
also verify the complete parent. The [counter](go-build-wire-costs.py) counts
|
||||
**every captured request**, including repeated schemas and accumulated tool
|
||||
outputs, using `tiktoken 0.12.0` / `cl100k_base` on compact request JSON. These are
|
||||
serialization-token proxies; actual model rendering, usage and cache billing
|
||||
remain unmeasured.
|
||||
|
||||
| Completed question | Requests before → after | Whole request JSON proxy before → after |
|
||||
| ------------------------------------------------------ | ----------------------: | --------------------------------------: |
|
||||
| Compiler, main module and dependency count | 2 → 2 | 158,529 → 123,368 (22.2% less) |
|
||||
| Compiler, main module and one selected dependency | 2 → 3 | 158,538 → 186,812 (17.8% more) |
|
||||
| Same selected dependency question plus Evidence export | 3 → 4 | 256,342 → 250,352 (2.3% less) |
|
||||
|
||||
The actual REA namespace costs 51,821 → 51,983 proxy tokens **per request**.
|
||||
An extra round can outweigh a smaller reply. Summary is useful when identity
|
||||
and counts already answer the question; complete delivery remains useful for
|
||||
an initial compound question. Retained views provide selected follow-up facts,
|
||||
without a claim that every such composition saves total context.
|
||||
|
||||
One recording per cell: identity wall/CPU/family peak is
|
||||
6.970 s / 6.331 s / 861.5 MiB before and 7.069 s / 6.457 s / 857.0 MiB after.
|
||||
Compound answer-only is 6.973 / 6.333 / 865.0 before and 6.969 / 6.348 / 861.6
|
||||
after. Export is 7.175 / 6.537 / 866.7 before and 7.274 / 6.568 / 872.6 after.
|
||||
These scripted-client observations establish no universal latency or memory
|
||||
gain, and do not measure model inference time.
|
||||
|
||||
## Direct SDK task and cold/warm costs
|
||||
|
||||
The [SDK driver](go-build-views.mjs) discovers all tools, answers the compound
|
||||
question, verifies exact facts and exports complete Evidence. Before: one
|
||||
complete inspection and export. After: summary, selected dependency and export.
|
||||
For the zero-dependency small case, the summary avoids an unnecessary empty
|
||||
query. Two repetitions alternate version/target order. All 208 other named
|
||||
catalog definitions agree; only Go inspection and the retained-view tool change.
|
||||
|
||||
| Input | Whole wall s before → after | Family CPU s before → after | Kernel family peak MiB before → after |
|
||||
| ---------- | --------------------------- | --------------------------- | ------------------------------------- |
|
||||
| GitHub CLI | 4.855–4.952 → 6.370–6.564 | 4.682–4.725 → 6.175–6.400 | 671.2–680.1 → 846.4–868.7 |
|
||||
| Go tool | 4.349–4.351 → 4.250–4.450 | 4.093–4.140 → 4.092–4.179 | 548.4–548.6 → 544.3–555.6 |
|
||||
|
||||
Cold selected-query calls take 1.719–1.751 s in the SDK task, including its
|
||||
output-schema validation. A separate warm diagnostic takes 1.712 s for the first
|
||||
query and 32.86 ms for the repeat in one session; both the client validator and
|
||||
retained facts are warm. This is not a paired whole-task speedup. The complete
|
||||
warm diagnostic is separately recorded, not mixed into the eight matched runs.
|
||||
|
||||
The [SDK counter](go-build-view-costs.py) counts the discovered input catalog
|
||||
once plus all arguments and text replies, including export: 229,183 → 195,643
|
||||
for GitHub CLI and 194,854 → 194,724 for the small case. Catalog cost grows
|
||||
193,192 → 193,652. This catalog-once projection is **not** the actual host
|
||||
exchange above; omitting repeated schemas would hide the compound-task penalty.
|
||||
|
||||
All qualified recordings use one CPU, a 4 GiB hard/3 GiB early family memory
|
||||
boundary, no swap and 192 tasks. Verified payload exits are zero, only the
|
||||
observer remains, and observed memory/task resource events are zero. Kernel
|
||||
family peaks include attributed file cache. Final whole-scope counters are
|
||||
unavailable. Release acquisition, oracle preparation and compilation are outside
|
||||
the timed tasks. An initial symlink input was correctly refused; qualification
|
||||
explicitly selects its regular target. One development static check was stopped
|
||||
by the host-available-memory guard, with owned cleanup verified and no observed
|
||||
OOM; the unchanged guard succeeded after headroom recovered.
|
||||
|
||||
## Coverage and reproduction
|
||||
|
||||
Executed: 10 files / 202 tests covering existing Go readers, mappings, provider,
|
||||
CLI/MCP and Rust readers, plus selected rows, duplicate paths, empty filters,
|
||||
identity-only fields, malformed/mutable parents, source conflicts, bounded
|
||||
messages and unavailable metadata. Actual CLI complete output is identical
|
||||
before/after; summary bundles retain that exact parent, and file-based CLI
|
||||
selected queries match the native oracle. Full `npm run check` passed.
|
||||
After rebasing onto main `c374c2fe7`, the 202 focused tests, full
|
||||
`npm run check` and `npm run docs:check` (including five README consistency
|
||||
tests) pass again. A real SDK selected query and complete export also pass
|
||||
against that rebuilt runtime, retaining the original parent identity and all
|
||||
165 dependencies. These checks do not relabel the frozen timing cohort.
|
||||
|
||||
A review found that a transcript-write failure could skip the SDK driver's
|
||||
cleanup. The shared finalizer now closes owned resources before publishing the
|
||||
optional transcript and records publication failures separately. All 11 receipt
|
||||
failure tests pass. A real selected SDK task and full export pass again; the
|
||||
same task with an injected transcript-write failure exits 1, preserves its
|
||||
answer and failure receipt, and verifies that the process family was cleaned.
|
||||
These qualification runs do not replace the frozen performance measurements.
|
||||
|
||||
A second review found that a valid external parent could disagree with its
|
||||
report's architecture. Go domain now owns the existing architecture-to-subject
|
||||
mapping; both Evidence production and every Go view use it. The view checks
|
||||
mapped architecture as well as path, digest and format, including `null` for
|
||||
unrecognized report names. All 213 focused tests pass, including authenticated
|
||||
inline/file conflicts and known-alias/unknown-architecture cases.
|
||||
|
||||
Initial CI also exposed two existing baseline failures. On unmodified `c374c2fe7`,
|
||||
`src/cliJsonPaths.test.ts` has the same three URL-message assertion failures.
|
||||
The duplication gate is already above its 3.5% threshold: 31,737 / 899,602 lines
|
||||
(3.52789%) on baseline versus 31,748 / 900,474 (3.52570%) on the initial PR head.
|
||||
These were reproduced separately with the pinned toolchain, bounded resources
|
||||
and verified cleanup; this PR leaves those unrelated failures unchanged.
|
||||
|
||||
Final qualification rebased onto main `cf5194f68`, preserving its new IL2CPP
|
||||
view contract alongside the Go additions. All 258 tests in 12 files pass,
|
||||
including 32 IL2CPP view tests and the now-fixed CLI path assertions. Full
|
||||
`npm run check`, `npm run docs:check` plus five README tests, and the real SDK
|
||||
selected query/full export pass again. Complete parent identity and all 165
|
||||
dependencies remain exact. Duplication now passes: baseline 31,644 / 904,778
|
||||
lines (3.49743%), PR 31,612 / 905,740 (3.49018%), against 3.5%.
|
||||
One latest-main build was guard-stopped during a host-memory dip; its owned
|
||||
scope was verified inactive with zero OOM events, then the unchanged guard's
|
||||
retry passed. These results qualify the rebased source, not new task timings.
|
||||
|
||||
The broader metadata audit also read three real Rust binaries. Alpine ripgrep
|
||||
14.1.1-r0's original `.dep-v0` matches native `readelf` coordinates and independent
|
||||
zlib/JSON decoding: 42 packages, 88 indexed edges, root index 33. The other two
|
||||
have no dependency section, which remains unknown coverage. Compiler-path
|
||||
inferences in all three match native read-only ranges and original byte offsets;
|
||||
they remain inferences. These fast metadata reads did not justify a Rust cache.
|
||||
The Alpine archive is pinned by observed SHA-256; APK signatures were not checked.
|
||||
Real provider verification here covers Linux ELF; PE/Mach-O use authored fixtures.
|
||||
|
||||
To reproduce, use built baseline/candidate runtimes and the pinned public input.
|
||||
Create a baseline complete CLI Evidence JSON, then pin the independent oracle:
|
||||
|
||||
```sh
|
||||
python3 experiments/analysis-efficiency/go-build-view-oracle.py \
|
||||
--evidence /tmp/baseline-evidence.json --input /absolute/gh \
|
||||
--go /absolute/go --dependency golang.org/x/crypto --output /tmp/go-oracle.json
|
||||
```
|
||||
|
||||
Run `go-build-views.mjs` with absolute `--runtime`, `--input`, `--oracle` and
|
||||
new `--output` directories, and `--mode complete` or `selected`, under
|
||||
`resource-run.py --observe-payload-exit`. `--warm` adds a separate diagnostic.
|
||||
The wire driver additionally requires pinned `--codex` and `--node`, and takes
|
||||
`--question identity --mode complete|summary` or
|
||||
`--question dependency --mode complete|selected`; `--export` adds export.
|
||||
All wire recordings require the same resource guard for owned-scope cleanup.
|
||||
Counter scripts take `--output` and recording directories, with their guard
|
||||
logs at the same path plus `.log`. Both counters require `tiktoken==0.12.0`.
|
||||
@@ -0,0 +1,228 @@
|
||||
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 { createRequire } from "node:module";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { finished } from "node:stream/promises";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { parseArgs } from "node:util";
|
||||
import { createRetainedQueryCaller } from "./retained-query-calls.mjs";
|
||||
import { finalizeRetainedQueryReceipt } from "./retained-query-finalization.mjs";
|
||||
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
runtime: { type: "string" },
|
||||
input: { type: "string" },
|
||||
oracle: { type: "string" },
|
||||
output: { type: "string" },
|
||||
mode: { type: "string" },
|
||||
warm: { type: "boolean", default: false },
|
||||
},
|
||||
});
|
||||
for (const key of ["runtime", "input", "oracle", "output"])
|
||||
assert.equal(values[key], resolve(values[key]));
|
||||
assert.ok(["complete", "selected"].includes(values.mode));
|
||||
const oracle = JSON.parse(await readFile(values.oracle, "utf8"));
|
||||
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||
const bytes = await readFile(values.input);
|
||||
assert.equal(sha256(bytes), oracle.artifact_sha256);
|
||||
const require = createRequire(join(values.runtime, "package.json"));
|
||||
const { Client } = await import(
|
||||
pathToFileURL(require.resolve("@modelcontextprotocol/client"))
|
||||
);
|
||||
const { StdioClientTransport } = await import(
|
||||
pathToFileURL(require.resolve("@modelcontextprotocol/client/stdio"))
|
||||
);
|
||||
await mkdir(values.output, { mode: 0o700 });
|
||||
const account = await mkdtemp(join(tmpdir(), "rea-go-build-views-"));
|
||||
const report = {
|
||||
question:
|
||||
"Report compiler, main module and the selected embedded dependency; preserve complete original Evidence.",
|
||||
mode: values.mode,
|
||||
warm_diagnostic: values.warm,
|
||||
calls: [],
|
||||
artifact_sha256: oracle.artifact_sha256,
|
||||
};
|
||||
const client = new Client({ name: "rea-go-build-views", version: "1" });
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [join(values.runtime, "scripts/rea.mjs"), "mcp"],
|
||||
env: {
|
||||
HOME: account,
|
||||
PATH: process.env.PATH,
|
||||
NODE_OPTIONS: "--max-old-space-size=2048",
|
||||
UV_THREADPOOL_SIZE: "1",
|
||||
REA_LOG_LEVEL: "silent",
|
||||
},
|
||||
stderr: "pipe",
|
||||
});
|
||||
const serverLog = createWriteStream(join(values.output, "server.log"));
|
||||
const serverLogFinished = finished(serverLog);
|
||||
serverLogFinished.catch(() => {});
|
||||
transport.stderr?.pipe(serverLog);
|
||||
const transcript = [];
|
||||
const call = createRetainedQueryCaller(client, (receipt, event) => {
|
||||
report.calls.push(receipt);
|
||||
transcript.push(event);
|
||||
});
|
||||
async function result(name, input, phase = name) {
|
||||
const reply = await call(name, input, phase);
|
||||
assert.ok(!reply.isError, JSON.stringify(reply));
|
||||
assert.ok(reply.structuredContent);
|
||||
assert.equal(reply.content.length, 1);
|
||||
assert.equal(reply.content[0].type, "text");
|
||||
assert.deepEqual(JSON.parse(reply.content[0].text), reply.structuredContent);
|
||||
return reply.structuredContent;
|
||||
}
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const catalog = await client.listTools();
|
||||
assert.equal(catalog.nextCursor, undefined);
|
||||
report.catalog_tools = catalog.tools.length;
|
||||
await writeFile(join(values.output, "catalog.json"), JSON.stringify(catalog));
|
||||
const definitions = new Map(catalog.tools.map((tool) => [tool.name, tool]));
|
||||
const original = client.callTool.bind(client);
|
||||
client.callTool = (input, options) =>
|
||||
original(input, {
|
||||
...options,
|
||||
timeout: 60000,
|
||||
toolDefinition: definitions.get(input.name),
|
||||
});
|
||||
const inspected = await result(
|
||||
"inspect_go_binary",
|
||||
{
|
||||
path: values.input,
|
||||
...(values.mode === "selected" ? { detail: "summary" } : {}),
|
||||
},
|
||||
"inspection:first",
|
||||
);
|
||||
let answer;
|
||||
if (values.mode === "complete") {
|
||||
report.parent_evidence_id = inspected.evidence_id;
|
||||
const info = inspected.normalized_result.build_info;
|
||||
assert.ok(info);
|
||||
answer = {
|
||||
go_version: info.go_version,
|
||||
main: info.module.main,
|
||||
runtime_dependencies: "unknown",
|
||||
dependencies: info.module.dependencies.flatMap((row, index) =>
|
||||
row.path === oracle.dependency_path ? [{ index, ...row }] : [],
|
||||
),
|
||||
};
|
||||
} else {
|
||||
const summary = inspected.normalized_result;
|
||||
assert.equal(summary.kind, "summary");
|
||||
report.parent_evidence_id = summary.parent_evidence_id;
|
||||
assert.equal(summary.source_coverage.runtime_dependencies, "unknown");
|
||||
assert.equal(
|
||||
summary.summary.build_info.module.available_counts.dependencies,
|
||||
oracle.dependency_count,
|
||||
);
|
||||
const input = {
|
||||
source: {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: report.parent_evidence_id,
|
||||
},
|
||||
view: {
|
||||
kind: "go",
|
||||
collection: "dependencies",
|
||||
paths: [oracle.dependency_path],
|
||||
fields: ["path", "version", "sum", "replacement"],
|
||||
offset: 0,
|
||||
limit: 64,
|
||||
},
|
||||
};
|
||||
let dependencies = [];
|
||||
if (summary.summary.build_info.module.available_counts.dependencies > 0) {
|
||||
const selected = await result(
|
||||
"inspect_analysis_view",
|
||||
input,
|
||||
"retained-query:first",
|
||||
);
|
||||
assert.equal(selected.normalized_result.coverage.exhausted, true);
|
||||
assert.equal(
|
||||
selected.normalized_result.source_coverage.runtime_dependencies,
|
||||
"unknown",
|
||||
);
|
||||
dependencies = selected.normalized_result.page.rows;
|
||||
if (values.warm) {
|
||||
const warm = await result(
|
||||
"inspect_analysis_view",
|
||||
input,
|
||||
"retained-query:warm-diagnostic",
|
||||
);
|
||||
assert.deepEqual(warm, selected);
|
||||
}
|
||||
}
|
||||
answer = {
|
||||
go_version: summary.summary.build_info.go_version,
|
||||
main: summary.summary.build_info.module.main,
|
||||
runtime_dependencies: "unknown",
|
||||
dependencies,
|
||||
};
|
||||
}
|
||||
assert.deepEqual(answer, oracle.answer);
|
||||
report.answer = answer;
|
||||
await result("export_evidence_bundle", {
|
||||
path: join(values.output, "evidence.json"),
|
||||
});
|
||||
const bundle = JSON.parse(
|
||||
await readFile(join(values.output, "evidence.json"), "utf8"),
|
||||
);
|
||||
const parent = bundle.records.find(
|
||||
(e) => e.evidence_id === report.parent_evidence_id,
|
||||
);
|
||||
assert.ok(parent);
|
||||
assert.equal(parent.operation, "inspect_go_binary");
|
||||
assert.deepEqual(parent.parameters, { path: values.input });
|
||||
assert.equal(
|
||||
parent.normalized_result.artifact.sha256,
|
||||
oracle.artifact_sha256,
|
||||
);
|
||||
// The pre-change normalized result was independently checked against native
|
||||
// go version -m and its file-backed bytes before pinning this exact JSON.
|
||||
const expected = JSON.parse(
|
||||
await readFile(oracle.complete_result_path, "utf8"),
|
||||
);
|
||||
assert.deepEqual(parent.normalized_result, expected);
|
||||
assert.equal(
|
||||
parent.normalized_result.build_info.module.dependencies.length,
|
||||
oracle.dependency_count,
|
||||
);
|
||||
for (const name of ["version", "module"]) {
|
||||
const info = parent.normalized_result.build_info;
|
||||
const location = info[name + "_location"];
|
||||
const encoded =
|
||||
info[(name === "version" ? "go_version" : "module") + "_bytes_base64"];
|
||||
assert.deepEqual(
|
||||
bytes.subarray(location.offset, location.offset + location.bytes),
|
||||
Buffer.from(encoded, "base64"),
|
||||
);
|
||||
}
|
||||
assert.equal(sha256(await readFile(values.input)), oracle.artifact_sha256);
|
||||
report.oracle_file_sha256 = sha256(await readFile(values.oracle));
|
||||
report.complete_result_file_sha256 = sha256(
|
||||
await readFile(oracle.complete_result_path),
|
||||
);
|
||||
report.status = "observed";
|
||||
} catch (cause) {
|
||||
report.status = "failed";
|
||||
report.failure = String(cause?.stack ?? cause);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (
|
||||
!(await finalizeRetainedQueryReceipt({
|
||||
report,
|
||||
client,
|
||||
transport,
|
||||
account,
|
||||
serverLog,
|
||||
serverLogFinished,
|
||||
output: values.output,
|
||||
transcript,
|
||||
}))
|
||||
)
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Count entire captured Codex request sequences, without claiming model usage."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import tiktoken
|
||||
|
||||
|
||||
def compact(value):
|
||||
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def request_text(body):
|
||||
pieces = []
|
||||
if isinstance(body.get("instructions"), str):
|
||||
pieces.append(body["instructions"])
|
||||
for item in body.get("input", []):
|
||||
kind = item.get("type")
|
||||
if kind == "function_call_output":
|
||||
pieces.append(item["output"])
|
||||
elif kind == "function_call":
|
||||
pieces.append(item["arguments"])
|
||||
elif kind == "message" or "role" in item:
|
||||
for part in item.get("content", []):
|
||||
if isinstance(part, dict) and isinstance(part.get("text"), str):
|
||||
pieces.append(part["text"])
|
||||
return pieces
|
||||
|
||||
|
||||
def inspect(root, encoding):
|
||||
report = json.loads((root / "report.json").read_text())
|
||||
assert (report["status"] == "passed" and not report["cleanup_failures"]
|
||||
and report["appserver_exit_code"] == 0)
|
||||
requests = json.loads((root / "model-requests.json").read_text())
|
||||
whole, namespaces, texts = [], [], []
|
||||
count = lambda value: len(encoding.encode(value, disallowed_special=()))
|
||||
for event in requests:
|
||||
body = event["body"]
|
||||
whole.append(count(compact(body)))
|
||||
namespace = next(tool for tool in body["tools"]
|
||||
if tool.get("type") == "namespace"
|
||||
and tool["name"] == "mcp__rea_audit")
|
||||
namespaces.append(count(compact(namespace)))
|
||||
texts.append(sum(count(piece) for piece in request_text(body)))
|
||||
log = root.with_suffix(".log")
|
||||
scope = next(json.loads(line) for line in reversed(log.read_text().splitlines())
|
||||
if line.startswith('{"resource_scope":'))
|
||||
observed = scope["payload_resource_observation"]
|
||||
assert (scope["abort"] is None and scope["payload_resource_observation_verified"]
|
||||
and observed["payload_cleanup_verified"] and observed["payload_exit_code"] == 0)
|
||||
memory = observed["resource_events"]["memory.events"]
|
||||
assert all(memory[key] == 0 for key in ["high", "max", "oom", "oom_kill"])
|
||||
assert observed["resource_events"]["pids.events"]["max"] == 0
|
||||
return {"case": root.name, "question": report["question_kind"],
|
||||
"request_count": len(requests), "whole_request_json_proxy": whole,
|
||||
"whole_request_json_proxy_sum": sum(whole),
|
||||
"rea_namespace_json_proxy": namespaces,
|
||||
"rea_namespace_json_proxy_sum": sum(namespaces),
|
||||
"unescaped_instruction_and_input_text_proxy": texts,
|
||||
"rea_namespace_plus_text_proxy_sum": sum(namespaces) + sum(texts),
|
||||
"wall_seconds": scope["elapsed_seconds"],
|
||||
"family_cpu_seconds": observed["cpu_stat"]["usage_usec"] / 1e6,
|
||||
"family_peak_mib": observed["memory_peak_bytes"] / 2**20,
|
||||
"completed_with_verified_cleanup": True,
|
||||
"sha256": {file.name: hashlib.sha256(file.read_bytes()).hexdigest()
|
||||
for file in [root / "report.json", root / "model-requests.json",
|
||||
root / "transcript.json", log]}}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("cases", type=Path, nargs="+")
|
||||
args = parser.parse_args()
|
||||
encoding = tiktoken.get_encoding("cl100k_base")
|
||||
receipt = {"actual_model_usage": None,
|
||||
"backend": "deterministic local Responses SSE; no AI inference or paid service; not a model-quality or billing test",
|
||||
"client": "Codex app-server 0.163.0", "tiktoken": tiktoken.__version__,
|
||||
"encoding": encoding.name,
|
||||
"records": [inspect(root, encoding) for root in args.cases]}
|
||||
args.output.write_text(json.dumps(receipt, ensure_ascii=False, indent=2) + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,292 @@
|
||||
"""Actual Codex wire capture with a deterministic local SSE backend; no AI inference."""
|
||||
import argparse
|
||||
import http.server
|
||||
import threading
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import time
|
||||
import traceback
|
||||
import subprocess
|
||||
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--runtime", type=Path, required=True)
|
||||
parser.add_argument("--codex", type=Path, required=True)
|
||||
parser.add_argument("--node", type=Path, required=True)
|
||||
parser.add_argument("--input-profile", choices=["full", "compact"], default="full")
|
||||
parser.add_argument("--output-profile", choices=["full", "off"], default="full")
|
||||
parser.add_argument("--input", type=Path, required=True)
|
||||
parser.add_argument("--oracle", type=Path, required=True)
|
||||
parser.add_argument("--expected-tools", type=int, default=210)
|
||||
parser.add_argument("--mode", choices=["complete", "selected", "summary"], required=True)
|
||||
parser.add_argument("--export", action="store_true")
|
||||
parser.add_argument("--question", choices=["identity", "dependency"], default="dependency")
|
||||
args = parser.parse_args()
|
||||
assert args.question != "identity" or args.mode != "selected"
|
||||
assert args.mode != "summary" or args.question == "identity"
|
||||
oracle=json.loads(args.oracle.read_text())
|
||||
target=str(args.input)
|
||||
assert args.input.is_absolute() and args.oracle.is_absolute() and args.output.is_absolute()
|
||||
assert hashlib.sha256(args.input.read_bytes()).hexdigest()==oracle["artifact_sha256"]
|
||||
def delivered(pieces):
|
||||
assert isinstance(pieces,str) and pieces.startswith("Wall time: "), pieces[:300]
|
||||
_,sep,payload=pieces.partition("Output:\n");assert sep
|
||||
value=json.loads(payload)
|
||||
return value["result"] if "result" in value and "normalized_result" not in value else value
|
||||
def outputs_of(body):
|
||||
return {item["call_id"]:delivered(item["output"]) for item in body.get("input",[]) if item.get("type")=="function_call_output"}
|
||||
steps=1+(args.mode=="selected")+args.export
|
||||
args.output.mkdir(mode=0o700)
|
||||
audit_home = args.output / "account"
|
||||
codex_state = audit_home / ".codex"
|
||||
mcp_home = audit_home / "mcp"
|
||||
workspace = audit_home / "workspace"
|
||||
for directory in [codex_state, mcp_home, workspace]:
|
||||
directory.mkdir(parents=True)
|
||||
captured = []
|
||||
class Capture(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, *_):
|
||||
pass
|
||||
def do_POST(self):
|
||||
size = int(self.headers["Content-Length"])
|
||||
assert 0 < size <= 16 * 1024 * 1024
|
||||
assert self.headers.get("Content-Encoding", "identity") == "identity"
|
||||
body = json.loads(self.rfile.read(size))
|
||||
captured.append({"path": self.path, "body": body})
|
||||
(args.output / "model-requests.json").write_text(json.dumps(captured) + "\n")
|
||||
index = len(captured)
|
||||
names = [t.get("name", "") for ns in body.get("tools", []) if ns.get("type") == "namespace" and ns.get("name") == "mcp__rea_audit" for t in ns.get("tools", [])]
|
||||
assert index <= steps+1
|
||||
response_id=f"resp_local_{index}"
|
||||
if index <= steps:
|
||||
previous=outputs_of(body)
|
||||
if index==1:
|
||||
operation="inspect_go_binary";arguments={"path":target}
|
||||
if args.mode!="complete":arguments["detail"]="summary"
|
||||
elif args.mode=="selected" and index==2:
|
||||
parent=previous["call_local_1"]["normalized_result"]["parent_evidence_id"]
|
||||
operation="inspect_analysis_view";arguments={"source":{"kind":"retained-evidence","evidence_id":parent},"view":{"kind":"go","collection":"dependencies","paths":[oracle["dependency_path"]],"fields":["path","version","sum","replacement"],"offset":0,"limit":64}}
|
||||
else:
|
||||
operation="export_evidence_bundle";arguments={"path":str(args.output/"evidence.json")}
|
||||
selected=[name for name in names if name.endswith(operation)];assert len(selected)==1
|
||||
item={"type":"function_call","id":f"fc_local_{index}","call_id":f"call_local_{index}","name":selected[0],"namespace":"mcp__rea_audit","arguments":json.dumps(arguments),"status":"completed"}
|
||||
else:
|
||||
item={"type":"message","id":"msg_local_done","role":"assistant","status":"completed","content":[{"type":"output_text","text":"Scripted local Go metadata query complete.","annotations":[]}]}
|
||||
base = {"id": response_id, "object": "response", "model": body.get("model"), "status": "completed", "output": [item]}
|
||||
events = [
|
||||
{"type": "response.created", "response": {**base, "status": "in_progress", "output": []}},
|
||||
{"type": "response.output_item.added", "output_index": 0, "item": {**item, "status": "in_progress"}},
|
||||
{"type": "response.output_item.done", "output_index": 0, "item": item},
|
||||
{"type": "response.completed", "response": base},
|
||||
]
|
||||
wire = "".join("event: " + e["type"] + "\ndata: " + json.dumps(e) + "\n\n" for e in events).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Content-Length", str(len(wire)))
|
||||
self.end_headers()
|
||||
self.wfile.write(wire)
|
||||
endpoint = http.server.HTTPServer(("127.0.0.1", 0), Capture)
|
||||
endpoint_thread = threading.Thread(target=endpoint.serve_forever, daemon=True)
|
||||
endpoint_thread.start()
|
||||
task_path = f"{args.node.parent}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
mcp_env = {"HOME": str(mcp_home), "PATH": task_path,
|
||||
"NODE_OPTIONS": "--max-old-space-size=2048", "UV_THREADPOOL_SIZE": "1",
|
||||
"REA_LOG_LEVEL": "silent", "REA_MCP_INPUT_SCHEMA_PROFILE": args.input_profile,
|
||||
"REA_MCP_OUTPUT_SCHEMA_PROFILE": args.output_profile}
|
||||
config = 'model_provider = "rea_capture"\nmodel = "rea-wire-probe"\n[analytics]\nenabled = false\n[mcp_servers.rea_audit]\n'
|
||||
config += f'command = {json.dumps(str(args.node))}\n'
|
||||
config += f'args = {json.dumps([str(args.runtime / "scripts/rea.mjs"), "mcp"])}\n'
|
||||
config += 'startup_timeout_sec = 60\ntool_timeout_sec = 60\n'
|
||||
config += '[mcp_servers.rea_audit.env]\n'
|
||||
config += ''.join(f'{key} = {json.dumps(value)}\n' for key, value in mcp_env.items())
|
||||
for name in ['inspect_go_binary','inspect_analysis_view','export_evidence_bundle']:
|
||||
config += f'[mcp_servers.rea_audit.tools.{name}]\napproval_mode = "approve"\noutput_token_limit = 30000\n'
|
||||
config += f'\n[model_providers.rea_capture]\nname = "Local scripted capture"\nbase_url = "http://127.0.0.1:{endpoint.server_port}/v1"\nwire_api = "responses"\nrequires_openai_auth = false\nsupports_websockets = false\nrequest_max_retries = 0\nstream_max_retries = 0\n'
|
||||
(codex_state / "config.toml").write_text(config)
|
||||
(args.output / "declared-config.toml").write_text(config)
|
||||
environment = {"HOME": str(audit_home), "CODEX_HOME": str(codex_state), "PATH": task_path,
|
||||
"NODE_OPTIONS": "--max-old-space-size=2048", "UV_THREADPOOL_SIZE": "1"}
|
||||
report = {"question": "Read embedded Go identity/counts or a selected dependency. Export is a separate task variant.",
|
||||
"client": "Codex app-server 0.163.0", "model_turns_started": 1, "backend": "local deterministic SSE fixture; no AI model or paid service",
|
||||
"actual_model_usage": None, "input_profile": args.input_profile,
|
||||
"output_profile": args.output_profile, "calls": [], "observations": [],
|
||||
"unexpected_server_requests": [], "cleanup_failures": []}
|
||||
client_version = subprocess.check_output([str(args.codex), "--version"], env=environment, cwd=workspace, text=True, timeout=10).strip()
|
||||
assert client_version == "codex-cli 0.163.0", client_version
|
||||
transcript = []
|
||||
pending = {}
|
||||
completed_turn = asyncio.Event()
|
||||
next_id = 0
|
||||
stderr = (args.output / "server.log").open("wb")
|
||||
process = await asyncio.create_subprocess_exec(str(args.codex), "app-server", "--stdio",
|
||||
stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=stderr,
|
||||
cwd=workspace, env=environment, limit=16 * 1024 * 1024)
|
||||
|
||||
async def send(message):
|
||||
encoded = (json.dumps(message, separators=(",", ":")) + "\n").encode()
|
||||
transcript.append({"direction": "client", "bytes": len(encoded), "message": message})
|
||||
process.stdin.write(encoded)
|
||||
await process.stdin.drain()
|
||||
|
||||
async def read_messages():
|
||||
try:
|
||||
while line := await process.stdout.readline():
|
||||
value = json.loads(line)
|
||||
transcript.append({"direction": "server", "bytes": len(line), "message": value})
|
||||
if value.get("method") == "turn/completed":
|
||||
report["turn"] = value["params"]["turn"]
|
||||
completed_turn.set()
|
||||
if "id" in value and "method" not in value:
|
||||
future = pending.pop(value["id"], None)
|
||||
if future and not future.done():
|
||||
future.set_result(value)
|
||||
elif "id" in value:
|
||||
report["unexpected_server_requests"].append(value["method"])
|
||||
await send({"id": value["id"], "error": {"code": -32601, "message": "Unexpected request in scripted metadata probe"}})
|
||||
raise RuntimeError("app-server stdout closed")
|
||||
except Exception as error:
|
||||
for future in pending.values():
|
||||
if not future.done():
|
||||
future.set_exception(error)
|
||||
|
||||
reader = asyncio.create_task(read_messages())
|
||||
|
||||
async def request(method, params):
|
||||
nonlocal next_id
|
||||
assert method in ["initialize", "thread/start", "mcpServerStatus/list", "mcpServer/tool/call", "turn/start"]
|
||||
next_id += 1
|
||||
message = {"id": next_id, "method": method, "params": params}
|
||||
future = asyncio.get_running_loop().create_future()
|
||||
pending[next_id] = future
|
||||
start = time.monotonic()
|
||||
await send(message)
|
||||
reply = await asyncio.wait_for(future, 90)
|
||||
report["calls"].append({"method": method, "elapsed_ms": (time.monotonic() - start) * 1000,
|
||||
"error": reply.get("error")})
|
||||
if "error" in reply:
|
||||
raise RuntimeError(json.dumps(reply["error"])[:4000])
|
||||
return reply["result"]
|
||||
|
||||
try:
|
||||
await request("initialize", {"clientInfo": {"name": "rea_host_probe", "title": "REA host compatibility probe", "version": "1"},
|
||||
"capabilities": {"experimentalApi": True, "explicitGatewayOauth": True}})
|
||||
await send({"method": "initialized"})
|
||||
created = await request("thread/start", {"cwd": str(workspace), "ephemeral": True,
|
||||
"approvalPolicy": "never", "sandbox": "read-only"})
|
||||
thread_id = created["thread"]["id"]
|
||||
assert not created["thread"].get("turns", [])
|
||||
inventory = await request("mcpServerStatus/list", {"threadId": thread_id,
|
||||
"serverName": "rea_audit", "detail": "toolsAndAuthOnly", "limit": 10})
|
||||
assert inventory.get("nextCursor") is None
|
||||
assert len(inventory["data"]) == 1
|
||||
server = inventory["data"][0]
|
||||
assert server["name"] == "rea_audit" and server.get("toolsError") is None
|
||||
tools = list(server["tools"].values())
|
||||
names = [tool["name"] for tool in tools]
|
||||
assert len(names) == len(set(names)) == args.expected_tools
|
||||
assert "inspect_go_binary" in names
|
||||
output_schemas = sum(tool.get("outputSchema") is not None for tool in tools)
|
||||
assert output_schemas == (args.expected_tools if args.output_profile == "full" else 0)
|
||||
raw = json.dumps(inventory, separators=(",", ":"), ensure_ascii=False).encode()
|
||||
(args.output / "catalog.json").write_bytes(raw)
|
||||
report["catalog"] = {"tools": len(tools), "output_schemas": output_schemas,
|
||||
"bytes": len(raw), "sha256": hashlib.sha256(raw).hexdigest(),
|
||||
"server_info": server.get("serverInfo"), "runtime_status": server.get("runtimeStatus")}
|
||||
await request("turn/start", {"threadId": thread_id, "input": [{"type": "text", "text": ("Read the embedded Go compiler, main module and embedded dependency count; state runtime dependency coverage." if args.question=="identity" else "Read the embedded Go compiler, main module and golang.org/x/crypto dependency and state runtime dependency coverage.")+" The local fixture scripts the required tools; no AI inference is used."}]})
|
||||
await asyncio.wait_for(completed_turn.wait(), 90)
|
||||
assert report["turn"]["status"] == "completed", report["turn"]
|
||||
assert len(captured)==steps+1
|
||||
outputs=outputs_of(captured[-1]["body"])
|
||||
assert len(outputs)==steps
|
||||
initial=outputs["call_local_1"]
|
||||
assert initial["normalized_result"]["artifact"]["sha256"]==oracle["artifact_sha256"]
|
||||
if args.question=="identity":
|
||||
info=initial["normalized_result"]["build_info"] if args.mode=="complete" else initial["normalized_result"]["summary"]["build_info"]
|
||||
count=len(info["module"]["dependencies"]) if args.mode=="complete" else info["module"]["available_counts"]["dependencies"]
|
||||
facts={"go_version":info["go_version"],"main":info["module"]["main"],"runtime_dependencies":"unknown","dependency_count":count}
|
||||
parent=initial["evidence_id"] if args.mode=="complete" else initial["normalized_result"]["parent_evidence_id"]
|
||||
elif args.mode=="complete":
|
||||
info=initial["normalized_result"]["build_info"]
|
||||
facts={"go_version":info["go_version"],"main":info["module"]["main"],"runtime_dependencies":"unknown","dependencies":[{"index":i,**d} for i,d in enumerate(info["module"]["dependencies"]) if d["path"]==oracle["dependency_path"]]}
|
||||
parent=initial["evidence_id"]
|
||||
else:
|
||||
info=initial["normalized_result"]["summary"]["build_info"]
|
||||
selected=outputs["call_local_2"]["normalized_result"]
|
||||
assert selected["coverage"]["exhausted"] and selected["source_coverage"]["runtime_dependencies"]=="unknown"
|
||||
facts={"go_version":info["go_version"],"main":info["module"]["main"],"runtime_dependencies":"unknown","dependencies":selected["page"]["rows"]}
|
||||
parent=initial["normalized_result"]["parent_evidence_id"]
|
||||
expected=({key:oracle["answer"][key] for key in ["go_version","main","runtime_dependencies"]}|{"dependency_count":oracle["dependency_count"]}) if args.question=="identity" else oracle["answer"]
|
||||
assert facts==expected
|
||||
report["question_kind"]=args.question
|
||||
if args.export:
|
||||
bundle=json.loads((args.output/"evidence.json").read_text());evidence=next(e for e in bundle["records"] if e["evidence_id"]==parent)
|
||||
assert evidence["normalized_result"]==json.loads(Path(oracle["complete_result_path"]).read_text())
|
||||
report["observations"]={"answer":facts,"parent_evidence_id":parent,"exported":args.export}
|
||||
report["captured_requests"]=len(captured)
|
||||
report["mode"]=args.mode
|
||||
report["request_tool_counts"] = [len(c["body"].get("tools", [])) for c in captured]
|
||||
assert not report["unexpected_server_requests"]
|
||||
assert hashlib.sha256(args.input.read_bytes()).hexdigest()==oracle["artifact_sha256"]
|
||||
report["status"] = "passed"
|
||||
except Exception:
|
||||
report["status"] = "failed"
|
||||
report["failure"] = traceback.format_exc()[-6000:]
|
||||
finally:
|
||||
process.stdin.close()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), 15)
|
||||
except asyncio.TimeoutError:
|
||||
report["cleanup_failures"].append("app-server did not exit after stdin close; terminated owned process")
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), 5)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
await reader
|
||||
# app-server can acknowledge exit before its MCP child finishes EOF
|
||||
# cleanup. Wait on the allocated resource scope; never target a PID.
|
||||
try:
|
||||
groups = [line[3:] for line in Path("/proc/self/cgroup").read_text().splitlines()
|
||||
if line.startswith("0::")]
|
||||
assert len(groups) == 1
|
||||
group = Path("/sys/fs/cgroup") / groups[0].lstrip("/")
|
||||
assert re.fullmatch(r"rea-efficiency-[0-9a-f]{32}\.scope", group.name)
|
||||
observer = os.getppid()
|
||||
observer_args = Path(f"/proc/{observer}/cmdline").read_bytes().split(b"\0")
|
||||
assert any(Path(os.fsdecode(value)).name == "resource_payload.py" for value in observer_args if value)
|
||||
expected = {os.getpid(), observer}
|
||||
until = time.monotonic() + 10
|
||||
while True:
|
||||
actors = {int(value) for value in (group / "cgroup.procs").read_text().splitlines()}
|
||||
assert expected <= actors
|
||||
remaining = sorted(actors - expected)
|
||||
if not remaining or time.monotonic() >= until:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
report["owned_scope_drain"] = {"remaining_processes": remaining}
|
||||
if remaining:
|
||||
report["cleanup_failures"].append("owned MCP child did not settle after app-server exit")
|
||||
except (OSError, ValueError, AssertionError) as error:
|
||||
report["owned_scope_drain"] = {"status": "unavailable", "reason": type(error).__name__}
|
||||
report["cleanup_failures"].append("allocated resource-scope cleanup could not be verified")
|
||||
endpoint.shutdown()
|
||||
endpoint.server_close()
|
||||
endpoint_thread.join(5)
|
||||
stderr.close()
|
||||
report["appserver_exit_code"] = process.returncode
|
||||
if process.returncode != 0:
|
||||
report["cleanup_failures"].append(f"app-server exit code: {process.returncode}")
|
||||
(args.output / "transcript.json").write_text(json.dumps(transcript) + "\n")
|
||||
(args.output / "report.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||
print(json.dumps({key: report.get(key) for key in ["status", "failure", "catalog", "observations", "cleanup_failures", "appserver_exit_code"]}))
|
||||
return 0 if report["status"] == "passed" and not report["cleanup_failures"] else 1
|
||||
|
||||
|
||||
raise SystemExit(asyncio.run(main()))
|
||||
@@ -40,6 +40,7 @@ export const finalizeRetainedQueryReceipt = async ({
|
||||
serverLog,
|
||||
serverLogFinished,
|
||||
output,
|
||||
transcript,
|
||||
stdout = process.stdout,
|
||||
}) => {
|
||||
report.cleanup_failures = [];
|
||||
@@ -58,6 +59,10 @@ export const finalizeRetainedQueryReceipt = async ({
|
||||
if (report.cleanup_failures.length > 0) report.retained_workspace = account;
|
||||
|
||||
report.receipt_failures = [];
|
||||
if (transcript !== undefined)
|
||||
await attempt(report.receipt_failures, "transcript-file", () =>
|
||||
writeFile(join(output, "transcript.json"), JSON.stringify(transcript)),
|
||||
);
|
||||
let publication = 0;
|
||||
const saveReport = async () => {
|
||||
// Keep failed publications in the owned output directory for inspection.
|
||||
|
||||
@@ -109,6 +109,23 @@ assert.match(report.cleanup_failures[0],/^workspace:/);
|
||||
assert.equal(report.retained_workspace,malformedAccount);
|
||||
assert.deepEqual((await saved()).failure,originalFailure);
|
||||
assert.deepEqual(JSON.parse(chunks.join('')),report);
|
||||
''')
|
||||
|
||||
def test_transcript_failure_cannot_skip_cleanup_or_lose_original_failure(self):
|
||||
self.run_finalization('''
|
||||
await mkdir(join(output,'transcript.json'));
|
||||
const closed=[];
|
||||
assert.equal(await finalizeRetainedQueryReceipt({...input,transcript:[{phase:'page'}],
|
||||
client:{close:async()=>{closed.push('client');}},
|
||||
transport:{close:async()=>{closed.push('transport');}}}),false);
|
||||
assert.deepEqual(closed,['client','transport']);
|
||||
assert.equal(serverLog.writableEnded,true);
|
||||
await assert.rejects(stat(account),error=>error.code==='ENOENT');
|
||||
assert.deepEqual(report.cleanup_failures,[]);
|
||||
assert.match(report.receipt_failures[0],/^transcript-file:/);
|
||||
assert.strictEqual(report.failure,originalFailure);
|
||||
assert.equal(report.calls[0].elapsed_ms,17);
|
||||
assert.deepEqual(await saved(),report);assert.deepEqual(JSON.parse(chunks.join('')),report);
|
||||
''')
|
||||
|
||||
def test_report_file_refusal_preserves_the_stdout_receipt(self):
|
||||
|
||||
@@ -279,6 +279,8 @@ export type CapabilityDescriptor = CapabilityAvailability & {
|
||||
* exclusions apply to every policy.
|
||||
*/
|
||||
readonly cachePolicy?: "snapshot" | "live";
|
||||
/** Exact source profile for an operation implemented by a versioned local reader. */
|
||||
readonly analysisProfile?: AnalysisProfileCommitment;
|
||||
readonly provider: ProviderIdentity;
|
||||
readonly operation: Exclude<AnalysisOperation, "health">;
|
||||
readonly effects: CapabilityEffects;
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
snapshotMatchesTarget,
|
||||
} from "../domain/analysisSnapshot.js";
|
||||
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
|
||||
import { profilelessNativeDispatchProfile } from "./binary/ProfilelessNativeWorkflowSnapshot.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js";
|
||||
import type {
|
||||
@@ -285,7 +286,8 @@ const runAnalysis = async (
|
||||
if (!preview.ok) return cliError(preview.error);
|
||||
resolvedTarget = preview.value;
|
||||
const route = preview.value.route;
|
||||
const bindingProfile = route.profile ?? undefined;
|
||||
const bindingProfile =
|
||||
route.profile ?? nativeDispatchBindingProfile(route, tool);
|
||||
const evidenceProfile = analysisProfileForRoute(route, tool);
|
||||
// A parsed Windows path does not prove Ghidra's native snapshot owner
|
||||
// admitted the selected source; CLI replay must wait for that client.
|
||||
@@ -365,6 +367,7 @@ const runAnalysis = async (
|
||||
const workflowRecord = workflowSnapshotRecord(
|
||||
evidence,
|
||||
tool,
|
||||
evidenceProfile,
|
||||
cachePolicy,
|
||||
);
|
||||
if (workflowRecord !== undefined) {
|
||||
@@ -600,13 +603,30 @@ const analysisProfileForRoute = (
|
||||
| DirectAnalysisTool,
|
||||
): AnalysisProfileCommitment | undefined => {
|
||||
const profile = route.profile;
|
||||
if (profile === null || profile === undefined) return undefined;
|
||||
const nativeDispatchProfile =
|
||||
profile === null || profile === undefined
|
||||
? nativeDispatchBindingProfile(route, tool)
|
||||
: undefined;
|
||||
if (profile === null || profile === undefined) {
|
||||
if (nativeDispatchProfile === undefined) return undefined;
|
||||
return isWorkflowEvidenceTool(tool)
|
||||
? workflowAnalysisProfile(nativeDispatchProfile, tool)
|
||||
: nativeDispatchProfile;
|
||||
}
|
||||
if (isWorkflowEvidenceTool(tool))
|
||||
return workflowAnalysisProfile(profile, tool);
|
||||
const provider = providerIdentityForRoute(route, tool);
|
||||
return provider.id === profile.provider.id ? profile : undefined;
|
||||
};
|
||||
|
||||
const nativeDispatchBindingProfile = (
|
||||
route: SessionProviderRoute,
|
||||
operation: string,
|
||||
): AnalysisProfileCommitment | undefined => {
|
||||
if (operation !== "inspect_native_dispatch_metadata") return undefined;
|
||||
return profilelessNativeDispatchProfile(route);
|
||||
};
|
||||
|
||||
/** Mirror BinarySession.providerIdentity for a route not opened yet. */
|
||||
const providerIdentityForRoute = (
|
||||
route: SessionProviderRoute,
|
||||
@@ -623,7 +643,7 @@ const analysisProfileForEvidence = (
|
||||
| DirectAnalysisTool,
|
||||
): AnalysisProfileCommitment | undefined => {
|
||||
if (!isWorkflowEvidenceTool(tool)) return session.analysisProfile(tool);
|
||||
return session.analysisProfile();
|
||||
return session.analysisProfile() ?? session.analysisProfile(tool);
|
||||
};
|
||||
|
||||
const isWorkflowEvidenceTool = (
|
||||
|
||||
@@ -59,6 +59,7 @@ it.each([false, true])(
|
||||
workflowSnapshotRecord(
|
||||
evidence,
|
||||
"inspect_native_api",
|
||||
ANALYSIS_SNAPSHOT_PROFILE,
|
||||
composed.value.cachePolicy,
|
||||
) === undefined,
|
||||
).toBe(transient);
|
||||
|
||||
@@ -133,12 +133,18 @@ const batchProcedureLocations = (result: JsonValue): EvidenceLocation[] => {
|
||||
export const workflowSnapshotRecord = (
|
||||
evidence: Evidence,
|
||||
operation: AnalysisOperation,
|
||||
bindingProfile: AnalysisProfileCommitment | undefined,
|
||||
cachePolicy?: "live",
|
||||
): WorkflowSnapshotRecordInput | undefined => {
|
||||
if (evidence.analysis_profile === null || cachePolicy === "live")
|
||||
if (
|
||||
evidence.analysis_profile === null ||
|
||||
bindingProfile === undefined ||
|
||||
cachePolicy === "live"
|
||||
)
|
||||
return undefined;
|
||||
return {
|
||||
operation,
|
||||
bindingProfile,
|
||||
parameters: evidence.parameters,
|
||||
execution: {
|
||||
result: evidence.normalized_result,
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
} from "./BinarySessionExecution.js";
|
||||
import { closeAnalysisClient } from "../../providers/AnalysisClientCleanup.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
|
||||
import { profilelessNativeDispatchProfile } from "./ProfilelessNativeWorkflowSnapshot.js";
|
||||
const OFFICIAL_OPERATIONS: ReadonlySet<string> = new Set(
|
||||
OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name),
|
||||
);
|
||||
@@ -64,6 +65,7 @@ interface SessionRun {
|
||||
interface SessionBinding extends SessionRun {
|
||||
readonly target: BinaryTarget;
|
||||
readonly profile: AnalysisProfileCommitment | null;
|
||||
readonly profilelessWorkflowProfile?: AnalysisProfileCommitment;
|
||||
nativeSourceAdmitted: boolean;
|
||||
}
|
||||
|
||||
@@ -173,7 +175,12 @@ export class BinarySession
|
||||
operation?: AnalysisOperation,
|
||||
): AnalysisProfileCommitment | undefined {
|
||||
const profile = this.#active?.profile;
|
||||
if (profile === null || profile === undefined) return undefined;
|
||||
if (profile === null || profile === undefined) {
|
||||
if (operation !== "inspect_native_dispatch_metadata") return undefined;
|
||||
return this.#active?.profilelessWorkflowProfile === undefined
|
||||
? undefined
|
||||
: structuredClone(this.#active.profilelessWorkflowProfile);
|
||||
}
|
||||
if (
|
||||
operation !== undefined &&
|
||||
this.providerIdentity(operation).id !== profile.provider.id
|
||||
@@ -358,9 +365,14 @@ export class BinarySession
|
||||
),
|
||||
);
|
||||
}
|
||||
const profilelessWorkflowProfile =
|
||||
profile === null ? profilelessNativeDispatchProfile(route) : undefined;
|
||||
this.#active = {
|
||||
target,
|
||||
profile,
|
||||
...(profilelessWorkflowProfile === undefined
|
||||
? {}
|
||||
: { profilelessWorkflowProfile }),
|
||||
...run,
|
||||
nativeSourceAdmitted: false,
|
||||
};
|
||||
|
||||
@@ -17,6 +17,10 @@ import {
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { err, type Result } from "../../domain/result.js";
|
||||
import type { SessionProviderRoute } from "./SessionProviderRouter.js";
|
||||
import {
|
||||
isProfilelessNativeDispatchSnapshot,
|
||||
profilelessNativeDispatchProfile,
|
||||
} from "./ProfilelessNativeWorkflowSnapshot.js";
|
||||
import { SessionProviderRouter } from "./SessionProviderRouter.js";
|
||||
|
||||
/** Read a staged snapshot once the open holds its place in the session order. */
|
||||
@@ -126,6 +130,7 @@ export const validateResolvedSessionTarget = (
|
||||
snapshot: options.snapshot,
|
||||
target,
|
||||
profile: route.profile,
|
||||
profilelessWorkflowProfile: profilelessNativeDispatchProfile(route),
|
||||
current,
|
||||
stagedSnapshotMatches,
|
||||
});
|
||||
@@ -147,6 +152,7 @@ interface ValidateSnapshotInput {
|
||||
readonly snapshot: AnalysisSnapshot | undefined;
|
||||
readonly target: BinaryTarget;
|
||||
readonly profile: AnalysisProfileCommitment | null;
|
||||
readonly profilelessWorkflowProfile: AnalysisProfileCommitment | undefined;
|
||||
readonly current: CurrentOpenBinding | undefined;
|
||||
readonly stagedSnapshotMatches: (
|
||||
target: BinaryTarget,
|
||||
@@ -158,17 +164,31 @@ const validateSnapshot = ({
|
||||
snapshot,
|
||||
target,
|
||||
profile,
|
||||
profilelessWorkflowProfile,
|
||||
current,
|
||||
stagedSnapshotMatches,
|
||||
}: ValidateSnapshotInput): EvidenceIntegrityError | undefined => {
|
||||
if (
|
||||
snapshot !== undefined &&
|
||||
(profile === null || !snapshotMatchesBinding(snapshot, target, profile))
|
||||
(profile === null
|
||||
? profilelessWorkflowProfile === undefined ||
|
||||
!isProfilelessNativeDispatchSnapshot(
|
||||
snapshot,
|
||||
target,
|
||||
profilelessWorkflowProfile,
|
||||
)
|
||||
: !snapshotMatchesBinding(snapshot, target, profile))
|
||||
)
|
||||
return new AnalysisSnapshotMismatchError(
|
||||
"Analysis snapshot profile_mismatch: target, provider, or analysis profile does not match the requested binary",
|
||||
);
|
||||
if (current === undefined && !stagedSnapshotMatches(target, profile))
|
||||
if (
|
||||
current === undefined &&
|
||||
!stagedSnapshotMatches(
|
||||
target,
|
||||
profile ?? profilelessWorkflowProfile ?? null,
|
||||
)
|
||||
)
|
||||
return new AnalysisSnapshotMismatchError(
|
||||
"Analysis snapshot profile_mismatch: staged target, provider, or analysis profile does not match",
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js";
|
||||
import { analysisProfilesEqual } from "../../domain/analysisProfile.js";
|
||||
import type { AnalysisSnapshot } from "../../domain/analysisSnapshot.js";
|
||||
import type { BinaryTarget } from "../../domain/binaryTargetTypes.js";
|
||||
import type { Evidence } from "../../domain/evidence.js";
|
||||
@@ -33,6 +34,11 @@ import { InvestigationRecords } from "../investigation/InvestigationRecords.js";
|
||||
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
|
||||
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
||||
import { writeAnalysisSnapshot as writeSnapshotFile } from "./AnalysisSnapshotFiles.js";
|
||||
import { workflowAnalysisProfile } from "../InvestigationProviders.js";
|
||||
import {
|
||||
hasOnlyProfilelessNativeDispatchEntries,
|
||||
isProfilelessNativeDispatchSnapshot,
|
||||
} from "./ProfilelessNativeWorkflowSnapshot.js";
|
||||
|
||||
const SNAPSHOT_MUTATION_RECOVERY =
|
||||
"Export session observations through export_evidence_bundle if needed. Close the active target without saving a snapshot, then reopen it before importing or saving a snapshot.";
|
||||
@@ -40,11 +46,14 @@ const SNAPSHOT_MUTATION_RECOVERY =
|
||||
export interface ActiveAnalysisBinding {
|
||||
readonly target: BinaryTarget;
|
||||
readonly profile: AnalysisProfileCommitment | null;
|
||||
/** Current route profile for the single profileless native dispatch workflow. */
|
||||
readonly profilelessWorkflowProfile?: AnalysisProfileCommitment;
|
||||
}
|
||||
|
||||
/** Validated composed-workflow payload bound to the active target and profile. */
|
||||
export interface WorkflowSnapshotRecordInput {
|
||||
readonly operation: AnalysisOperation;
|
||||
readonly bindingProfile: AnalysisProfileCommitment;
|
||||
readonly parameters: Readonly<Record<string, JsonValue>>;
|
||||
readonly execution: Parameters<
|
||||
AnalysisSnapshotCache["recordWorkflow"]
|
||||
@@ -160,13 +169,30 @@ export abstract class BinarySessionRecords {
|
||||
),
|
||||
);
|
||||
const target = active?.target;
|
||||
const profile = active?.profile ?? undefined;
|
||||
const profile =
|
||||
active?.profile ?? active?.profilelessWorkflowProfile ?? undefined;
|
||||
if (target !== undefined && profile === undefined)
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
"Analysis snapshots require a concrete provider analysis profile",
|
||||
),
|
||||
);
|
||||
if (
|
||||
active?.profile === null &&
|
||||
(target === undefined ||
|
||||
profile === undefined ||
|
||||
!this.#snapshot.matches(target, profile) ||
|
||||
this.#snapshot.entries().length !== 0 ||
|
||||
!hasOnlyProfilelessNativeDispatchEntries(
|
||||
this.#snapshot.workflowEntries(),
|
||||
profile,
|
||||
))
|
||||
)
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
"Profileless native snapshots may contain only current dispatch workflow observations",
|
||||
),
|
||||
);
|
||||
return this.#snapshot.export(
|
||||
target,
|
||||
profile,
|
||||
@@ -192,17 +218,24 @@ export abstract class BinarySessionRecords {
|
||||
},
|
||||
),
|
||||
);
|
||||
if (active?.profile === null)
|
||||
const profile = active?.profile ?? active?.profilelessWorkflowProfile;
|
||||
if (
|
||||
active?.profile === null &&
|
||||
(profile === undefined ||
|
||||
!isProfilelessNativeDispatchSnapshot(snapshot, active.target, profile))
|
||||
)
|
||||
return err(
|
||||
new AnalysisSnapshotMismatchError(
|
||||
"Analysis snapshot profile_mismatch: the active target has no concrete analysis profile",
|
||||
"Analysis snapshot profile_mismatch: the snapshot is not bound to the active native dispatch reader",
|
||||
),
|
||||
);
|
||||
const imported = this.#snapshot.import(
|
||||
snapshot,
|
||||
active === undefined
|
||||
? undefined
|
||||
: { target: active.target, profile: active.profile },
|
||||
: profile === undefined
|
||||
? undefined
|
||||
: { target: active.target, profile },
|
||||
(bundle) => {
|
||||
const imported = this.#records.mergeEvidenceBundle(bundle);
|
||||
return imported.ok ? ok(imported.value.recordsAdded) : imported;
|
||||
@@ -252,17 +285,33 @@ export abstract class BinarySessionRecords {
|
||||
input: WorkflowSnapshotRecordInput,
|
||||
): Result<null, EvidenceIntegrityError> {
|
||||
const active = this.activeAnalysisBinding();
|
||||
if (active === undefined || active.profile === null)
|
||||
if (active === undefined)
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
"Workflow snapshot entries require an active concrete provider profile",
|
||||
),
|
||||
);
|
||||
if (this.#snapshotInvalidated) return ok(null);
|
||||
const profile = active.profile ?? active.profilelessWorkflowProfile;
|
||||
if (
|
||||
profile === undefined ||
|
||||
!analysisProfilesEqual(profile, input.bindingProfile) ||
|
||||
!analysisProfilesEqual(
|
||||
input.execution.analysisProfile,
|
||||
workflowAnalysisProfile(profile, input.operation),
|
||||
) ||
|
||||
(active.profile === null &&
|
||||
input.operation !== "inspect_native_dispatch_metadata")
|
||||
)
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
"Workflow snapshot entry does not match the active reader profile",
|
||||
),
|
||||
);
|
||||
try {
|
||||
this.#snapshot.recordWorkflow({
|
||||
target: active.target,
|
||||
profile: active.profile,
|
||||
profile,
|
||||
...input,
|
||||
});
|
||||
this.#emitSnapshotChanged();
|
||||
|
||||
@@ -16,6 +16,11 @@ import type {
|
||||
SessionProviderRouter,
|
||||
} from "./SessionProviderRouter.js";
|
||||
import { binarySessionStatus } from "./BinarySessionStatus.js";
|
||||
import {
|
||||
APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE,
|
||||
NATIVE_MACOS_PROVIDER_IDENTITY,
|
||||
nativeMacOSCapabilities,
|
||||
} from "../../native/NativeMacOSProviderMetadata.js";
|
||||
|
||||
const projectedStatusSchema = sessionProvider.pick({
|
||||
capabilities: true,
|
||||
@@ -326,6 +331,21 @@ const expectedExpandedCandidateCapabilities = [
|
||||
];
|
||||
|
||||
describe("binary session provider capability projection", () => {
|
||||
it("keeps the private dispatch reader profile out of public capability status", async () => {
|
||||
const capabilities = nativeMacOSCapabilities("darwin");
|
||||
const projected = await project(capabilities, [
|
||||
candidate(NATIVE_MACOS_PROVIDER_IDENTITY, capabilities),
|
||||
]);
|
||||
const serialized = JSON.stringify(projected);
|
||||
expect(serialized).not.toContain(
|
||||
APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE.provider.version,
|
||||
);
|
||||
expect(serialized).not.toContain(
|
||||
APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE.digest,
|
||||
);
|
||||
expect(projectedStatusSchema.parse(projected)).toEqual(projected);
|
||||
});
|
||||
|
||||
it("preserves provider scope and route overlays while factoring exact prefixes", async () => {
|
||||
const selectedBefore = structuredClone(selectedCapabilities);
|
||||
const otherBefore = structuredClone(otherCapabilities);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { analysisProfilesEqual } from "../../domain/analysisProfile.js";
|
||||
import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js";
|
||||
import {
|
||||
snapshotMatchesBinding,
|
||||
type AnalysisSnapshot,
|
||||
} from "../../domain/analysisSnapshot.js";
|
||||
import type { BinaryTarget } from "../../domain/binaryTargetTypes.js";
|
||||
import { workflowAnalysisProfile } from "../InvestigationProviders.js";
|
||||
import type { SessionProviderRoute } from "./SessionProviderRouter.js";
|
||||
|
||||
/** Return the exact currently declared native dispatch reader profile, if any. */
|
||||
export const profilelessNativeDispatchProfile = (
|
||||
route: SessionProviderRoute,
|
||||
): AnalysisProfileCommitment | undefined => {
|
||||
const descriptor = route.capabilities.get("inspect_native_dispatch_metadata");
|
||||
const profile = descriptor?.analysisProfile;
|
||||
if (
|
||||
descriptor === undefined ||
|
||||
descriptor.operation !== "inspect_native_dispatch_metadata" ||
|
||||
!descriptor.available ||
|
||||
descriptor.provider.id !== "native-macos" ||
|
||||
profile === undefined ||
|
||||
profile.provider.id !== descriptor.provider.id ||
|
||||
profile.provider.name !== descriptor.provider.name ||
|
||||
profile.provider.version.length === 0
|
||||
)
|
||||
return undefined;
|
||||
return structuredClone(profile);
|
||||
};
|
||||
|
||||
/** Require only exact native dispatch workflow observations for this target/profile. */
|
||||
export const isProfilelessNativeDispatchSnapshot = (
|
||||
snapshot: AnalysisSnapshot,
|
||||
target: BinaryTarget,
|
||||
profile: AnalysisProfileCommitment,
|
||||
): boolean =>
|
||||
snapshotMatchesBinding(snapshot, target, profile) &&
|
||||
snapshot.entries.length === 0 &&
|
||||
hasOnlyProfilelessNativeDispatchEntries(snapshot.workflow_entries, profile);
|
||||
|
||||
/** Reject mixed operations and entries derived from a different source profile. */
|
||||
export const hasOnlyProfilelessNativeDispatchEntries = (
|
||||
entries: readonly {
|
||||
readonly operation: string;
|
||||
readonly execution: {
|
||||
readonly analysis_profile: AnalysisProfileCommitment;
|
||||
};
|
||||
}[],
|
||||
profile: AnalysisProfileCommitment,
|
||||
): boolean =>
|
||||
entries.length > 0 &&
|
||||
entries.every(
|
||||
(entry) =>
|
||||
entry.operation === "inspect_native_dispatch_metadata" &&
|
||||
analysisProfilesEqual(
|
||||
entry.execution.analysis_profile,
|
||||
workflowAnalysisProfile(profile, entry.operation),
|
||||
),
|
||||
);
|
||||
@@ -14,7 +14,7 @@ export interface GoBinaryPort {
|
||||
readonly identity: ProviderIdentity;
|
||||
close?(): Promise<void>;
|
||||
inspect(
|
||||
input: InspectGoBinaryInput,
|
||||
input: Omit<InspectGoBinaryInput, "detail">,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<GoBinary, AnalysisError>>;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { GoBinaryService } from "./GoBinaryService.js";
|
||||
import {
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
@@ -45,6 +46,25 @@ it("rejects invalid paths and extra caller arguments before invoking the reader"
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
|
||||
it("requires a complete Evidence retention path before starting summary inspection", async () => {
|
||||
let calls = 0;
|
||||
const service = new GoBinaryService({
|
||||
identity,
|
||||
inspect: async () => {
|
||||
calls++;
|
||||
return ok(report);
|
||||
},
|
||||
});
|
||||
const result = await service.inspect({
|
||||
path: report.artifact.path,
|
||||
detail: "summary",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
expect(result.error).toBeInstanceOf(AnalysisCapabilityUnavailableError);
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
|
||||
it("preserves unsupported subject architectures and does not duplicate metadata", async () => {
|
||||
const service = new GoBinaryService({
|
||||
identity,
|
||||
|
||||
@@ -8,27 +8,24 @@ import {
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import { BINARY_ARCHITECTURES } from "../../domain/binaryTargetTypes.js";
|
||||
import {
|
||||
goArchitectureForSubject,
|
||||
goBinarySchema,
|
||||
inspectGoBinaryInputSchema,
|
||||
} from "../../domain/go/goBinary.js";
|
||||
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
|
||||
import {
|
||||
requireAnalysisViewRetention,
|
||||
summarizeRetainedAnalysis,
|
||||
type AnalysisViewRetention,
|
||||
} from "../analysisView/AnalysisViewService.js";
|
||||
|
||||
const OPERATION = "inspect_go_binary";
|
||||
const architectureForSubject = (architecture: string) => {
|
||||
switch (architecture) {
|
||||
case "386":
|
||||
return "x86";
|
||||
case "amd64":
|
||||
return "x86_64";
|
||||
default:
|
||||
return BINARY_ARCHITECTURES.find(
|
||||
(supported) => supported === architecture,
|
||||
);
|
||||
}
|
||||
const NO_RETENTION: AnalysisViewRetention = {
|
||||
recordEvidence: undefined,
|
||||
evidenceById: undefined,
|
||||
};
|
||||
|
||||
/** Shared exact input validation and artifact-bound Evidence for CLI and MCP. */
|
||||
@@ -43,6 +40,7 @@ export class GoBinaryService {
|
||||
async inspect(
|
||||
rawInput: unknown,
|
||||
options?: ExecutionOptions,
|
||||
retention: AnalysisViewRetention = NO_RETENTION,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
if (options?.signal?.aborted)
|
||||
return err(
|
||||
@@ -65,7 +63,12 @@ export class GoBinaryService {
|
||||
},
|
||||
]),
|
||||
);
|
||||
const inspected = await this.provider.inspect(input.data, options);
|
||||
const { detail, ...providerInput } = input.data;
|
||||
if (detail === "summary") {
|
||||
const ready = requireAnalysisViewRetention(OPERATION, retention);
|
||||
if (!ready.ok) return ready;
|
||||
}
|
||||
const inspected = await this.provider.inspect(providerInput, options);
|
||||
if (!inspected.ok) return inspected;
|
||||
const report = goBinarySchema.safeParse(inspected.value);
|
||||
if (!report.success || report.data.artifact.path !== input.data.path)
|
||||
@@ -76,8 +79,8 @@ export class GoBinaryService {
|
||||
),
|
||||
);
|
||||
const value = report.data;
|
||||
const architecture = architectureForSubject(value.architecture);
|
||||
return completeProviderEvidence(
|
||||
const architecture = goArchitectureForSubject(value.architecture);
|
||||
const completed = completeProviderEvidence(
|
||||
createEvidence(
|
||||
{
|
||||
path: value.artifact.path,
|
||||
@@ -88,7 +91,7 @@ export class GoBinaryService {
|
||||
this.provider.identity,
|
||||
{
|
||||
operation: OPERATION,
|
||||
parameters: input.data,
|
||||
parameters: providerInput,
|
||||
result: value,
|
||||
confidence: "observed",
|
||||
limitations: value.limitations,
|
||||
@@ -108,5 +111,9 @@ export class GoBinaryService {
|
||||
),
|
||||
options?.signal,
|
||||
);
|
||||
if (!completed.ok) return completed;
|
||||
return detail === "summary"
|
||||
? summarizeRetainedAnalysis(completed.value, retention)
|
||||
: completed;
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,7 @@ export const registerAnalysisViewCommands = (
|
||||
});
|
||||
cli.command(CLI_COMMANDS.inspectAnalysisView, {
|
||||
description:
|
||||
"Project a selected view of completed 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, 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
-7
@@ -5,7 +5,10 @@ import { logCliCommand } from "../cliLogging.js";
|
||||
import type { Logger } from "pino";
|
||||
import type { CliInstance } from "./types.js";
|
||||
import { withCommandCancellation } from "./commandCancellation.js";
|
||||
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
|
||||
import {
|
||||
analysisEvidenceDeliveryOptions,
|
||||
deliverCliAnalysisEvidence,
|
||||
} from "./analysisEvidenceDelivery.js";
|
||||
import { absoluteCallerPath } from "../filesystem/callerPath.js";
|
||||
|
||||
/** Resolve caller paths and inspect static build metadata through the shared workflow. */
|
||||
@@ -16,24 +19,27 @@ export const registerGoCommands = (cli: CliInstance, logger: Logger): void => {
|
||||
args: z.object({
|
||||
path: z.string().describe("Local ELF, PE or thin Mach-O image"),
|
||||
}),
|
||||
run: ({ args }) =>
|
||||
options: z.object(analysisEvidenceDeliveryOptions),
|
||||
run: ({ args, options }) =>
|
||||
withCommandCancellation(async (signal) => {
|
||||
const service = createGoBinaryService();
|
||||
return await logCliCommand(
|
||||
logger,
|
||||
CLI_COMMANDS.inspectGoBinary,
|
||||
async () =>
|
||||
finishAnalysisCommand(
|
||||
() =>
|
||||
deliverCliAnalysisEvidence({
|
||||
run: () =>
|
||||
service.inspect(
|
||||
{ path: absoluteCallerPath(args.path) },
|
||||
{ signal },
|
||||
),
|
||||
service,
|
||||
"go-build-info",
|
||||
"inspect_go_binary",
|
||||
providerId: "go-build-info",
|
||||
operation: "inspect_go_binary",
|
||||
signal,
|
||||
),
|
||||
detail: options.detail,
|
||||
evidenceOutput: options.evidence_output,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -74,6 +74,8 @@ describe("CLI JSON path normalization", () => {
|
||||
expect(parsed.error.issues).toEqual([
|
||||
expect.objectContaining({
|
||||
path: ["executable_path"],
|
||||
code: "invalid_format",
|
||||
message: expect.stringContaining("absolute local filesystem path"),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [
|
||||
...toolContractMetadata("inspect_analysis_view"),
|
||||
kind: "application",
|
||||
description:
|
||||
'Inspect selected views of completed 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 or inspect_web_network_capture Evidence without repeating analysis. 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. 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 non-entity paths written by POST/PUT/PATCH (item-shaped and delete-only paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state, and every other read tool is a recorded view with a reason. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. For a retained record use source: {kind: "retained-evidence", evidence_id: "<parent_evidence_id>"}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: <complete Evidence>}. A completed Evidence JSON file on this host uses {kind: "evidence-file", path: "<absolute path>"}: REA reads and authenticates it, then projects it like retained Evidence, so a large capture is bounded by the file reader rather than by inline-input accounting. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module refeLine truncated
|
||||
'Inspect selected views of completed Go build metadata, 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 or inspect_web_network_capture Evidence without repeating analysis. 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. 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 non-entity paths written by POST/PUT/PATCH (item-shaped and delete-only paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state, and every other read tool is a recorded view with a reason. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. For a retained record use source: {kind: "retained-evidence", evidence_id: "<parent_evidence_id>"}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: <complete Evidence>}. A completed Evidence JSON file on this host uses {kind: "evidence-file", path: "<absolute path>"}: REA reads and authenticates it, then projects it like retained Evidence, so a large capture is bounded by the file reader rather than by inline-input accounting. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/nLine truncated
|
||||
inputSchema: inspectAnalysisViewInputSchema,
|
||||
outputSchema: evidenceResultOf(analysisViewResultSchema),
|
||||
examples: [
|
||||
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
} from "../../domain/go/goBinary.js";
|
||||
import type { ToolContract } from "../toolContractTypes.js";
|
||||
import { toolContractMetadata } from "../toolEffects.js";
|
||||
import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js";
|
||||
import { analysisDetailOutputSchemaOf } from "../analysisDetail.js";
|
||||
import { goBinarySummaryViewResultSchema } from "../../domain/analysisView/analysisView.js";
|
||||
|
||||
/** Static Go metadata is independent of active native-analysis providers and host toolchains. */
|
||||
export const GO_TOOL_CONTRACTS = [
|
||||
@@ -13,9 +14,12 @@ export const GO_TOOL_CONTRACTS = [
|
||||
...toolContractMetadata("inspect_go_binary"),
|
||||
kind: "artifact-provider",
|
||||
description:
|
||||
"Inspect embedded Go compiler version, main module, dependency/replacement versions and build settings in an explicit local ELF, PE or thin Mach-O image. Returns original artifact SHA-256, exact build-info source ranges, raw framed module bytes and unparsed module-text lines inline as observed Evidence. Supports inline and historical pointer encodings without installing Go, opening a disassembler, executing the target, writing temporary files or accessing the network. Missing metadata is reported as unknown, not proof that an image is not Go. Does not recover functions, source mappings, types or runtime behavior. Resource guards bound files to 256 MiB, aggregate embedded strings to 1 MiB, structural table decoding to 16 MiB and complete inline metadata to 8 MiB; malformed, unsupported and oversized inputs receive typed failures rather than partial success.",
|
||||
"Inspect embedded Go compiler version, main module, dependency/replacement versions and build settings in an explicit local ELF, PE or thin Mach-O image. Default detail complete returns original artifact SHA-256, exact build-info source ranges, raw framed module bytes and unparsed module-text lines inline as observed Evidence. Explicit detail summary retains the same complete parent and returns compiler/main-module identity, counts, source ranges and parent_evidence_id. Query retained rows through inspect_analysis_view kind go, collection dependencies or settings; select exact dependency paths or setting keys, indices, fields and pagination. Rows always retain their original index scoped to the parent Evidence; empty filters select none. Runtime dependency completeness remains unknown. Supports inline and historical pointer encodings without installing Go, opening a disassembler, executing the target, writing temporary files or accessing the network. Missing metadata is reported as unknown, not proof that an image is not Go. Does not recover functions, source mappings, types or runtime behavior. Resource guards bound files to 256 MiB, aggregate embedded strings to 1 MiB, structural table decoding to 16 MiB and complete inline metadata to 8 MiB; malformed, unsupported and oversized inputs receive typed failures rather than partial success.",
|
||||
inputSchema: inspectGoBinaryInputSchema,
|
||||
outputSchema: evidenceResultOf(goBinarySchema),
|
||||
outputSchema: analysisDetailOutputSchemaOf(
|
||||
goBinarySchema,
|
||||
goBinarySummaryViewResultSchema,
|
||||
),
|
||||
examples: [
|
||||
{
|
||||
title: "Inspect embedded build provenance",
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import {
|
||||
goBinarySummarySchema,
|
||||
goBinaryViewContextSchema,
|
||||
goBinaryViewPageSchema,
|
||||
goBinaryViewRequestSchema,
|
||||
projectGoBinaryView,
|
||||
} from "./goBinaryView.js";
|
||||
import {
|
||||
projectNativeFunctionMatchingView,
|
||||
nativeFunctionMatchingViewRequestSchema,
|
||||
@@ -250,6 +257,7 @@ const nativeViewSchema = z.strictObject({
|
||||
export const analysisViewRequestSchema = z.discriminatedUnion("kind", [
|
||||
nativeFunctionMatchingViewRequestSchema,
|
||||
summaryViewSchema,
|
||||
goBinaryViewRequestSchema,
|
||||
desktopViewRequestSchema,
|
||||
facetViewSchema,
|
||||
itemViewSchema,
|
||||
@@ -361,6 +369,7 @@ const viewResultBase = {
|
||||
parent_evidence_id: prefixedDigestSchema("ev"),
|
||||
parent_operation: z.enum([
|
||||
"inspect_binary_layout",
|
||||
"inspect_go_binary",
|
||||
"inspect_desktop_application",
|
||||
"analyze_javascript_application",
|
||||
"analyze_function",
|
||||
@@ -394,6 +403,14 @@ const summaryViewResultBase = {
|
||||
...viewResultBase,
|
||||
} as const;
|
||||
|
||||
/** Opt-in Go delivery retains original bytes and exposes build identity/counts. */
|
||||
export const goBinarySummaryViewResultSchema = z.strictObject({
|
||||
...summaryViewResultBase,
|
||||
...goBinaryViewContextSchema.shape,
|
||||
parent_operation: z.literal("inspect_go_binary"),
|
||||
summary: goBinarySummarySchema,
|
||||
});
|
||||
|
||||
/** Summary `inspect_binary_layout` detail returns; no other view kind. */
|
||||
export const binaryLayoutSummaryViewResultSchema = z.strictObject({
|
||||
...summaryViewResultBase,
|
||||
@@ -577,6 +594,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
|
||||
view: environmentStateViewRequestSchema,
|
||||
}),
|
||||
z.discriminatedUnion("parent_operation", [
|
||||
goBinarySummaryViewResultSchema,
|
||||
binaryLayoutSummaryViewResultSchema,
|
||||
desktopApplicationSummaryViewResultSchema,
|
||||
javascriptApplicationSummaryViewResultSchema,
|
||||
@@ -641,6 +659,14 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
|
||||
facet: jsonValueSchema,
|
||||
...viewResultBase,
|
||||
}),
|
||||
z.strictObject({
|
||||
...viewResultBase,
|
||||
...goBinaryViewContextSchema.shape,
|
||||
kind: z.literal("go"),
|
||||
parent_operation: z.literal("inspect_go_binary"),
|
||||
view: goBinaryViewRequestSchema,
|
||||
page: goBinaryViewPageSchema,
|
||||
}),
|
||||
z.strictObject({
|
||||
kind: z.literal("item"),
|
||||
view: itemViewSchema,
|
||||
@@ -851,6 +877,7 @@ const parentArtifactPath = (parent: AnalysisViewParent): string => {
|
||||
|
||||
/** Named source owners for views that need no extra decoder or accumulator. */
|
||||
const retainedViewProjectors = new Map<string, typeof projectWasmArtifactView>([
|
||||
["inspect_go_binary", projectGoBinaryView],
|
||||
...NATIVE_INVENTORY_OPERATIONS.map(
|
||||
(operation) => [operation, projectNativeInventoryView] as const,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { AnalysisInputError } from "../analysisErrorCore.js";
|
||||
import { createEvidence } from "../evidence.js";
|
||||
import type { GoBinary } from "../go/goBinary.js";
|
||||
import {
|
||||
projectAnalysisView,
|
||||
type AnalysisViewParent,
|
||||
} from "./analysisView.js";
|
||||
|
||||
const report: GoBinary = {
|
||||
artifact: { path: "/artifacts/program", sha256: "a".repeat(64), bytes: 512 },
|
||||
format: "elf",
|
||||
architecture: "amd64",
|
||||
bits: 64,
|
||||
byte_order: "little",
|
||||
build_info: null,
|
||||
limitations: [],
|
||||
};
|
||||
const parent = (): AnalysisViewParent => {
|
||||
const evidence = createEvidence(
|
||||
{
|
||||
path: report.artifact.path,
|
||||
sha256: report.artifact.sha256,
|
||||
format: "elf",
|
||||
architecture: "x86_64",
|
||||
},
|
||||
{
|
||||
id: "go-build-info",
|
||||
name: "REA Go build-information reader",
|
||||
version: "1",
|
||||
},
|
||||
{
|
||||
operation: "inspect_go_binary",
|
||||
parameters: { path: report.artifact.path },
|
||||
result: report,
|
||||
confidence: "observed",
|
||||
},
|
||||
);
|
||||
return {
|
||||
evidenceId: evidence.evidence_id,
|
||||
operation: evidence.operation,
|
||||
subject: evidence.subject,
|
||||
normalizedResult: structuredClone(report),
|
||||
limitations: [],
|
||||
};
|
||||
};
|
||||
|
||||
it("validates mutable inline reports again after an earlier successful view", () => {
|
||||
const mutable = structuredClone(report);
|
||||
const input = { ...parent(), normalizedResult: mutable };
|
||||
expect(projectAnalysisView(input, { kind: "summary" }).ok).toBe(true);
|
||||
mutable.artifact.bytes = -1;
|
||||
expect(
|
||||
projectAnalysisView({ ...input, sealedResult: true }, { kind: "summary" })
|
||||
.ok,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("bounds inherited messages while keeping their complete parent reference", () => {
|
||||
const note = "Large source note: " + "x".repeat(300_000);
|
||||
const input = {
|
||||
...parent(),
|
||||
limitations: [note],
|
||||
};
|
||||
const result = projectAnalysisView(input, { kind: "summary" });
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.limitations.join(" ")).toContain(input.evidenceId);
|
||||
expect(result.value.limitations.join(" ")).toContain("omitted");
|
||||
expect(result.value.unknowns.join(" ")).toContain("runtime dependencies");
|
||||
expect(input.limitations[0]).toBe(note);
|
||||
});
|
||||
|
||||
it.each(["path", "digest", "format"] as const)(
|
||||
"rejects a Go parent whose authenticated %s conflicts with its report",
|
||||
(field) => {
|
||||
const input = parent();
|
||||
if (input.subject == null) throw new Error("Expected artifact subject");
|
||||
const subject = {
|
||||
...input.subject,
|
||||
...(field === "path" ? { local_path: "/artifacts/other" } : {}),
|
||||
...(field === "digest" ? { digest: { sha256: "b".repeat(64) } } : {}),
|
||||
};
|
||||
const result = projectAnalysisView(
|
||||
{
|
||||
...input,
|
||||
subject,
|
||||
...(field === "format"
|
||||
? { normalizedResult: { ...report, format: "pe" } }
|
||||
: {}),
|
||||
},
|
||||
{ kind: "summary" },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["386", "x86", true],
|
||||
["x86", "x86", true],
|
||||
["amd64", "x86_64", true],
|
||||
["x86_64", "x86_64", true],
|
||||
["arm64", "arm64", true],
|
||||
["future-cpu", null, true],
|
||||
["", null, true],
|
||||
["amd64", "arm64", false],
|
||||
["amd64", null, false],
|
||||
["future-cpu", "arm64", false],
|
||||
] as const)(
|
||||
"checks report architecture %j against subject %j for every Go view",
|
||||
(architecture, subjectArchitecture, accepted) => {
|
||||
const input = parent();
|
||||
if (input.subject == null) throw new Error("Expected artifact subject");
|
||||
const subject = { ...input.subject, architecture: subjectArchitecture };
|
||||
const normalizedResult = { ...report, architecture };
|
||||
for (const view of [
|
||||
{ kind: "summary" },
|
||||
{ kind: "go", collection: "dependencies", offset: 0, limit: 64 },
|
||||
] as const) {
|
||||
const result = projectAnalysisView(
|
||||
{ ...input, subject, normalizedResult },
|
||||
view,
|
||||
);
|
||||
expect(result.ok).toBe(accepted);
|
||||
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisInputError);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,312 @@
|
||||
import { z } from "zod";
|
||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import {
|
||||
goArchitectureForSubject,
|
||||
goBinarySchema,
|
||||
goBuildInfoSchema,
|
||||
goModuleMetadataSchema,
|
||||
goModuleSchema,
|
||||
type GoBinary,
|
||||
} from "../go/goBinary.js";
|
||||
import { isImmutableJsonSnapshot } from "../immutableJson.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import type {
|
||||
AnalysisViewParent,
|
||||
AnalysisViewRequest,
|
||||
UnsignedAnalysisView,
|
||||
} from "./analysisView.js";
|
||||
import {
|
||||
boundedViewMessages,
|
||||
completeWithinViewCoverage,
|
||||
pageViewCoverage,
|
||||
} from "./analysisViewCoverage.js";
|
||||
import { analysisViewInputError } from "./analysisViewErrors.js";
|
||||
import { parseAnalysisViewResult } from "./analysisViewParent.js";
|
||||
|
||||
const indexSchema = z.number().int().nonnegative();
|
||||
const count = z.number().int().nonnegative();
|
||||
const settingSchema = goModuleMetadataSchema.shape.settings.element;
|
||||
const page = {
|
||||
kind: z.literal("go"),
|
||||
indices: z.array(indexSchema).optional(),
|
||||
offset: indexSchema.default(0),
|
||||
limit: z.number().int().positive().default(64),
|
||||
};
|
||||
|
||||
/** Exact filters intersect; empty arrays select none. Offsets count matching rows. */
|
||||
export const goBinaryViewRequestSchema = z.discriminatedUnion("collection", [
|
||||
z.strictObject({
|
||||
...page,
|
||||
collection: z.literal("dependencies"),
|
||||
paths: z.array(z.string()).optional(),
|
||||
fields: z.array(goModuleSchema.keyof()).optional(),
|
||||
}),
|
||||
z.strictObject({
|
||||
...page,
|
||||
collection: z.literal("settings"),
|
||||
keys: z.array(z.string()).optional(),
|
||||
fields: z.array(settingSchema.keyof()).optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const goBinaryViewPageSchema = z.discriminatedUnion("collection", [
|
||||
z.strictObject({
|
||||
collection: z.literal("dependencies"),
|
||||
rows: z.array(goModuleSchema.partial().extend({ index: indexSchema })),
|
||||
}),
|
||||
z.strictObject({
|
||||
collection: z.literal("settings"),
|
||||
rows: z.array(settingSchema.partial().extend({ index: indexSchema })),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const goBinaryViewContextSchema = z.strictObject({
|
||||
source_coverage: z.strictObject({
|
||||
build_info_available: z.boolean(),
|
||||
module_parse_complete: z.boolean().nullable(),
|
||||
runtime_dependencies: z.literal("unknown"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const goBinarySummarySchema = z.strictObject({
|
||||
bytes: count,
|
||||
format: goBinarySchema.shape.format,
|
||||
architecture: goBinarySchema.shape.architecture,
|
||||
bits: goBinarySchema.shape.bits,
|
||||
byte_order: goBinarySchema.shape.byte_order,
|
||||
build_info: goBuildInfoSchema
|
||||
.omit({
|
||||
go_version_bytes_base64: true,
|
||||
module_text: true,
|
||||
module_bytes_base64: true,
|
||||
module: true,
|
||||
})
|
||||
.extend({
|
||||
module: goModuleMetadataSchema
|
||||
.omit({
|
||||
dependencies: true,
|
||||
settings: true,
|
||||
unparsed_lines: true,
|
||||
unparsed_line_bytes_base64: true,
|
||||
})
|
||||
.extend({
|
||||
available_counts: z.strictObject({
|
||||
dependencies: count,
|
||||
settings: count,
|
||||
unparsed_lines: count,
|
||||
unparsed_line_bytes_base64: count,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
// Reuse only authenticated immutable report facts, for their Evidence lifetime.
|
||||
// Inline mutable reports are validated each time; this never caches live files.
|
||||
const checkedReports = new WeakMap<object, GoBinary>();
|
||||
const parseReport = (
|
||||
parent: AnalysisViewParent,
|
||||
): Result<GoBinary, AnalysisError> => {
|
||||
const key = parent.normalizedResult;
|
||||
const reusable =
|
||||
parent.sealedResult === true &&
|
||||
typeof key === "object" &&
|
||||
key !== null &&
|
||||
isImmutableJsonSnapshot(key);
|
||||
const existing = reusable ? checkedReports.get(key) : undefined;
|
||||
if (existing !== undefined) return ok(existing);
|
||||
const parsed = parseAnalysisViewResult(goBinarySchema, parent);
|
||||
if (!parsed.ok) return parsed;
|
||||
// No schema transforms/coercions: keep the validated owner's frozen value.
|
||||
const report = reusable ? (key as GoBinary) : parsed.value;
|
||||
if (reusable) checkedReports.set(key, report);
|
||||
return ok(report);
|
||||
};
|
||||
|
||||
const selectRows = <Source, Row>(
|
||||
rows: readonly Source[],
|
||||
view: { readonly offset: number; readonly limit: number },
|
||||
matches: (row: Source, index: number) => boolean,
|
||||
project: (row: Source, index: number) => Row,
|
||||
): { rows: Row[]; total: number } => {
|
||||
const selected: Row[] = [];
|
||||
let total = 0;
|
||||
for (const [index, row] of rows.entries()) {
|
||||
if (!matches(row, index)) continue;
|
||||
if (total >= view.offset && selected.length < view.limit)
|
||||
selected.push(project(row, index));
|
||||
total++;
|
||||
}
|
||||
return { rows: selected, total };
|
||||
};
|
||||
|
||||
/** Query the existing metadata owner's facts; never reopen or reinterpret a file. */
|
||||
export const projectGoBinaryView = (
|
||||
parent: AnalysisViewParent,
|
||||
view: AnalysisViewRequest,
|
||||
): Result<UnsignedAnalysisView, AnalysisError> => {
|
||||
if (view.kind !== "summary" && view.kind !== "go")
|
||||
return err(
|
||||
analysisViewInputError([
|
||||
{
|
||||
path: ["view"],
|
||||
reason: "invalid_value",
|
||||
message: "Go build Evidence supports summary and go views.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const parsed = parseReport(parent);
|
||||
if (!parsed.ok) return parsed;
|
||||
const report = parsed.value;
|
||||
if (
|
||||
parent.subject?.local_path !== report.artifact.path ||
|
||||
parent.subject.digest.sha256 !== report.artifact.sha256 ||
|
||||
parent.subject.format !==
|
||||
(report.format === "macho" ? "mach-o" : report.format) ||
|
||||
parent.subject.architecture !==
|
||||
(goArchitectureForSubject(report.architecture) ?? null)
|
||||
)
|
||||
return err(
|
||||
analysisViewInputError([
|
||||
{
|
||||
path: ["source", "subject"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"Go parent subject must identify the report's exact artifact.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const info = report.build_info;
|
||||
const runtimeUnknown =
|
||||
"Embedded build records do not establish complete runtime dependencies or target behavior.";
|
||||
const unknowns = [
|
||||
runtimeUnknown,
|
||||
...(info === null
|
||||
? [
|
||||
"Build information is unavailable; language and dependencies remain unknown.",
|
||||
]
|
||||
: [
|
||||
...(info.module.complete
|
||||
? []
|
||||
: [
|
||||
"Some module records were not parsed; consult the complete parent Evidence for original lines and bytes.",
|
||||
]),
|
||||
...(info.module.main === null
|
||||
? [
|
||||
"No main module was decoded; embedded dependency coverage remains unknown.",
|
||||
]
|
||||
: []),
|
||||
]),
|
||||
];
|
||||
const shared = {
|
||||
parent_evidence_id: parent.evidenceId,
|
||||
parent_operation: "inspect_go_binary" as const,
|
||||
parent_digest: parent.evidenceId.slice(3),
|
||||
artifact: { path: report.artifact.path, sha256: report.artifact.sha256 },
|
||||
source_coverage: {
|
||||
build_info_available: info !== null,
|
||||
module_parse_complete: info?.module.complete ?? null,
|
||||
runtime_dependencies: "unknown" as const,
|
||||
},
|
||||
limitations: boundedViewMessages(
|
||||
[...parent.limitations, ...report.limitations],
|
||||
parent.evidenceId,
|
||||
"limitations",
|
||||
),
|
||||
unknowns: boundedViewMessages(unknowns, parent.evidenceId, "unknowns", [
|
||||
runtimeUnknown,
|
||||
]),
|
||||
};
|
||||
if (view.kind === "summary")
|
||||
return ok({
|
||||
kind: "summary",
|
||||
view,
|
||||
...shared,
|
||||
summary: {
|
||||
bytes: report.artifact.bytes,
|
||||
format: report.format,
|
||||
architecture: report.architecture,
|
||||
bits: report.bits,
|
||||
byte_order: report.byte_order,
|
||||
build_info:
|
||||
info === null
|
||||
? null
|
||||
: {
|
||||
header_offset: info.header_offset,
|
||||
encoding: info.encoding,
|
||||
go_version: info.go_version,
|
||||
version_location: info.version_location,
|
||||
module_location: info.module_location,
|
||||
module: {
|
||||
path: info.module.path,
|
||||
main: info.module.main,
|
||||
complete: info.module.complete,
|
||||
available_counts: {
|
||||
dependencies: info.module.dependencies.length,
|
||||
settings: info.module.settings.length,
|
||||
unparsed_lines: info.module.unparsed_lines.length,
|
||||
unparsed_line_bytes_base64:
|
||||
info.module.unparsed_line_bytes_base64.length,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
coverage: completeWithinViewCoverage(1, 1),
|
||||
});
|
||||
const indices =
|
||||
view.indices === undefined ? undefined : new Set(view.indices);
|
||||
const fields =
|
||||
view.fields === undefined ? undefined : new Set<string>(view.fields);
|
||||
if (view.collection === "dependencies") {
|
||||
const paths = view.paths === undefined ? undefined : new Set(view.paths);
|
||||
const selected = selectRows(
|
||||
info?.module.dependencies ?? [],
|
||||
view,
|
||||
(row, index) =>
|
||||
(indices?.has(index) ?? true) && (paths?.has(row.path) ?? true),
|
||||
(row, index) => ({
|
||||
index,
|
||||
...((fields?.has("path") ?? true) && { path: row.path }),
|
||||
...((fields?.has("version") ?? true) && { version: row.version }),
|
||||
...((fields?.has("sum") ?? true) && { sum: row.sum }),
|
||||
...((fields?.has("replacement") ?? true) && {
|
||||
replacement: row.replacement,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
return ok({
|
||||
kind: "go",
|
||||
view,
|
||||
...shared,
|
||||
page: { collection: "dependencies", rows: selected.rows },
|
||||
coverage: pageViewCoverage(
|
||||
view.offset,
|
||||
selected.rows.length,
|
||||
selected.total,
|
||||
),
|
||||
});
|
||||
}
|
||||
const keys = view.keys === undefined ? undefined : new Set(view.keys);
|
||||
const selected = selectRows(
|
||||
info?.module.settings ?? [],
|
||||
view,
|
||||
(row, index) =>
|
||||
(indices?.has(index) ?? true) && (keys?.has(row.key) ?? true),
|
||||
(row, index) => ({
|
||||
index,
|
||||
...((fields?.has("key") ?? true) && { key: row.key }),
|
||||
...((fields?.has("value") ?? true) && { value: row.value }),
|
||||
}),
|
||||
);
|
||||
return ok({
|
||||
kind: "go",
|
||||
view,
|
||||
...shared,
|
||||
page: { collection: "settings", rows: selected.rows },
|
||||
coverage: pageViewCoverage(
|
||||
view.offset,
|
||||
selected.rows.length,
|
||||
selected.total,
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -1,8 +1,23 @@
|
||||
import { z } from "zod";
|
||||
import { BINARY_ARCHITECTURES } from "../binaryTargetTypes.js";
|
||||
import { digestSchema } from "../digests.js";
|
||||
import { localPathStringSchema } from "../localPath.js";
|
||||
import { canonicalBase64Schema } from "../stringPatterns.js";
|
||||
|
||||
/** Map reader architecture names into Evidence identity; unknown names stay unknown. */
|
||||
export const goArchitectureForSubject = (architecture: string) => {
|
||||
switch (architecture) {
|
||||
case "386":
|
||||
return "x86";
|
||||
case "amd64":
|
||||
return "x86_64";
|
||||
default:
|
||||
return BINARY_ARCHITECTURES.find(
|
||||
(supported) => supported === architecture,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const sourceRangeSchema = z.strictObject({
|
||||
offset: z.number().int().nonnegative(),
|
||||
bytes: z.number().int().nonnegative(),
|
||||
@@ -34,6 +49,12 @@ export const inspectGoBinaryInputSchema = z.strictObject({
|
||||
path: localPathStringSchema.describe(
|
||||
"Absolute filesystem path to the selected executable or library",
|
||||
),
|
||||
detail: z
|
||||
.enum(["complete", "summary"])
|
||||
.default("complete")
|
||||
.describe(
|
||||
"complete returns all metadata inline; summary retains complete Evidence and returns build identity and counts for subsequent retained Go views.",
|
||||
),
|
||||
});
|
||||
|
||||
/** Compiler-written build information and its original file-backed source ranges. */
|
||||
|
||||
@@ -145,20 +145,28 @@ const matchedItem = (
|
||||
const relationships = compareRelationships(pair, context);
|
||||
const dimensions = changedDimensions(pair, relationships);
|
||||
const complete = graphsComplete(context);
|
||||
const ownEvidenceUncertain =
|
||||
ownPresenceUncertain(pair.left) || ownPresenceUncertain(pair.right);
|
||||
const observedChange =
|
||||
dimensions.includes("content") || dimensions.includes("properties");
|
||||
!ownEvidenceUncertain &&
|
||||
(dimensions.includes("content") || dimensions.includes("properties"));
|
||||
// Coordinates remain comparison evidence; relocation alone is not a
|
||||
// content, property, or relationship change.
|
||||
const completeChange =
|
||||
complete && dimensions.some((dimension) => dimension !== "location");
|
||||
!ownEvidenceUncertain &&
|
||||
complete &&
|
||||
dimensions.some((dimension) => dimension !== "location");
|
||||
const status: ApplicationVersionComparisonItem["status"] =
|
||||
observedChange || completeChange
|
||||
? "changed"
|
||||
: complete && !relationships.unresolved
|
||||
? "unchanged"
|
||||
: "unknown";
|
||||
const finalDimensions =
|
||||
status === "unknown" && !complete
|
||||
ownEvidenceUncertain
|
||||
? "unknown"
|
||||
: observedChange || completeChange
|
||||
? "changed"
|
||||
: complete && !relationships.unresolved
|
||||
? "unchanged"
|
||||
: "unknown";
|
||||
const finalDimensions = ownEvidenceUncertain
|
||||
? []
|
||||
: status === "unknown" && !complete
|
||||
? [...dimensions, "coverage" as const]
|
||||
: dimensions;
|
||||
const semantic: ComparisonItemSemantic = {
|
||||
@@ -170,6 +178,10 @@ const matchedItem = (
|
||||
dimensions: finalDimensions,
|
||||
evidence_links: itemEvidenceLinks(pair.left, pair.right, context),
|
||||
limitations: [
|
||||
...new Set([
|
||||
...ownAvailabilityLimitations(pair.left),
|
||||
...ownAvailabilityLimitations(pair.right),
|
||||
]),
|
||||
...matchLimitations(pair),
|
||||
...(status === "unknown" && !complete
|
||||
? [
|
||||
@@ -218,7 +230,20 @@ const leftOnlyItem = (
|
||||
candidate_right_node_ids: [firstCandidate, ...remainingCandidates],
|
||||
},
|
||||
dimensions: ["availability"],
|
||||
limitations: [AMBIGUOUS_MATCH_LIMITATION],
|
||||
limitations: [
|
||||
...ownAvailabilityLimitations(node),
|
||||
AMBIGUOUS_MATCH_LIMITATION,
|
||||
],
|
||||
});
|
||||
if (ownPresenceUncertain(node))
|
||||
return itemWithId({
|
||||
...common,
|
||||
status: "unknown",
|
||||
left_node_id: node.node_id,
|
||||
right_node_id: null,
|
||||
match: unmatchedMatch(),
|
||||
dimensions: ["availability"],
|
||||
limitations: ownAvailabilityLimitations(node),
|
||||
});
|
||||
const absenceObserved = absenceClaimable(context.rightGraph.coverage);
|
||||
return itemWithId({
|
||||
@@ -230,7 +255,10 @@ const leftOnlyItem = (
|
||||
dimensions: absenceObserved
|
||||
? ["availability"]
|
||||
: ["availability", "coverage"],
|
||||
limitations: absenceObserved ? [] : [INCOMPLETE_GRAPH_LIMITATION],
|
||||
limitations: [
|
||||
...ownAvailabilityLimitations(node),
|
||||
...(absenceObserved ? [] : [INCOMPLETE_GRAPH_LIMITATION]),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
@@ -258,7 +286,20 @@ const rightOnlyItem = (
|
||||
candidate_right_node_ids: [],
|
||||
},
|
||||
dimensions: ["availability"],
|
||||
limitations: [AMBIGUOUS_MATCH_LIMITATION],
|
||||
limitations: [
|
||||
...ownAvailabilityLimitations(node),
|
||||
AMBIGUOUS_MATCH_LIMITATION,
|
||||
],
|
||||
});
|
||||
if (ownPresenceUncertain(node))
|
||||
return itemWithId({
|
||||
...common,
|
||||
status: "unknown",
|
||||
left_node_id: null,
|
||||
right_node_id: node.node_id,
|
||||
match: unmatchedMatch(),
|
||||
dimensions: ["availability"],
|
||||
limitations: ownAvailabilityLimitations(node),
|
||||
});
|
||||
const absenceObserved = absenceClaimable(context.leftGraph.coverage);
|
||||
return itemWithId({
|
||||
@@ -270,7 +311,10 @@ const rightOnlyItem = (
|
||||
dimensions: absenceObserved
|
||||
? ["availability"]
|
||||
: ["availability", "coverage"],
|
||||
limitations: absenceObserved ? [] : [INCOMPLETE_GRAPH_LIMITATION],
|
||||
limitations: [
|
||||
...ownAvailabilityLimitations(node),
|
||||
...(absenceObserved ? [] : [INCOMPLETE_GRAPH_LIMITATION]),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
@@ -331,6 +375,21 @@ const AMBIGUOUS_MATCH_LIMITATION =
|
||||
const INCOMPLETE_GRAPH_LIMITATION =
|
||||
"The opposite graph is incomplete, so an unmatched node cannot be classified as added or removed.";
|
||||
|
||||
const ownPresenceUncertain = (node: ApplicationNode): boolean =>
|
||||
node.observations.every(
|
||||
({ evidence }) =>
|
||||
evidence.state === "unknown" || evidence.state === "unavailable",
|
||||
);
|
||||
|
||||
const ownAvailabilityLimitations = (node: ApplicationNode): string[] => {
|
||||
const limitations = new Set<string>();
|
||||
for (const { evidence } of node.observations)
|
||||
if (evidence.state === "unknown" || evidence.state === "unavailable")
|
||||
for (const limitation of evidence.limitations)
|
||||
limitations.add(limitation);
|
||||
return [...limitations].sort(compareUnicodeCodePoints);
|
||||
};
|
||||
|
||||
const changedDimensions = (
|
||||
pair: ApplicationVersionNodePair,
|
||||
relationships: RelationshipComparison,
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createJavaScriptApplicationGraph,
|
||||
createJavaScriptApplicationNode,
|
||||
} from "./javascriptApplicationGraph.js";
|
||||
import {
|
||||
artifactEvidence,
|
||||
completeCoverage,
|
||||
} from "./javascriptApplicationGraph.fixture.js";
|
||||
import { compareJavaScriptApplicationVersions } from "./javascriptApplicationVersionComparison.js";
|
||||
|
||||
const LEFT_SHA = "a".repeat(64);
|
||||
const RIGHT_SHA = "b".repeat(64);
|
||||
const LIMITATION =
|
||||
"Source is outside the caller-selected source_paths; it was inventoried but not analyzed.";
|
||||
const evidenceId = (character: string) => `ev_${character.repeat(64)}`;
|
||||
const node = (sha256: string, path: string) =>
|
||||
createJavaScriptApplicationNode({
|
||||
kind: "javascript-asset",
|
||||
identity: { strategy: "content-digest", stability: "global-exact", sha256 },
|
||||
observations: [
|
||||
{
|
||||
label: path,
|
||||
properties: { path },
|
||||
evidence: artifactEvidence(sha256, path),
|
||||
},
|
||||
],
|
||||
});
|
||||
const unavailableScope = (
|
||||
artifactSha: string,
|
||||
state: "unknown" | "unavailable" = "unavailable",
|
||||
) =>
|
||||
createJavaScriptApplicationNode({
|
||||
kind: "unknown",
|
||||
identity: {
|
||||
strategy: "artifact-local-key",
|
||||
stability: "artifact-version",
|
||||
artifact_sha256: artifactSha,
|
||||
namespace: "static-parse-scope",
|
||||
key: "select-javascript-sources:lib.js",
|
||||
},
|
||||
observations: [
|
||||
{
|
||||
label: "lib.js parse scope",
|
||||
properties: { path: "lib.js", operation: "select-javascript-sources" },
|
||||
evidence: {
|
||||
...artifactEvidence(artifactSha, "lib.js"),
|
||||
state,
|
||||
confidence: "unknown",
|
||||
limitations: [LIMITATION],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const observedAssetWithUnavailableFact = () => {
|
||||
const digest = "e".repeat(64);
|
||||
return createJavaScriptApplicationNode({
|
||||
kind: "javascript-asset",
|
||||
identity: {
|
||||
strategy: "content-digest",
|
||||
stability: "global-exact",
|
||||
sha256: digest,
|
||||
},
|
||||
observations: [
|
||||
{
|
||||
label: "new.js",
|
||||
properties: { path: "new.js" },
|
||||
evidence: artifactEvidence(digest, "new.js"),
|
||||
},
|
||||
{
|
||||
label: "new.js parse scope",
|
||||
properties: { operation: "parse-javascript" },
|
||||
evidence: {
|
||||
...artifactEvidence(digest, "new.js"),
|
||||
state: "unavailable",
|
||||
confidence: "unknown",
|
||||
limitations: [LIMITATION],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
const graph = (nodes: ReturnType<typeof node>[]) =>
|
||||
createJavaScriptApplicationGraph({
|
||||
schema: "JavaScriptApplicationGraph",
|
||||
root_node_ids: [nodes[0]!.node_id],
|
||||
nodes,
|
||||
edges: [],
|
||||
coverage: completeCoverage,
|
||||
limitations: [],
|
||||
});
|
||||
const compare = (
|
||||
left: ReturnType<typeof node>[],
|
||||
right: ReturnType<typeof node>[],
|
||||
) =>
|
||||
compareJavaScriptApplicationVersions({
|
||||
left: {
|
||||
evidenceId: evidenceId("1"),
|
||||
rootArtifactSha256: LEFT_SHA,
|
||||
graph: graph(left),
|
||||
},
|
||||
right: {
|
||||
evidenceId: evidenceId("2"),
|
||||
rootArtifactSha256: RIGHT_SHA,
|
||||
graph: graph(right),
|
||||
},
|
||||
leftNativeEvidence: [],
|
||||
rightNativeEvidence: [],
|
||||
});
|
||||
|
||||
it("keeps unmatched unavailable scopes unknown in either direction", () => {
|
||||
const leftRoot = node("d".repeat(64), "main.js");
|
||||
const rightRoot = node("d".repeat(64), "main.js");
|
||||
const leftScope = unavailableScope(LEFT_SHA);
|
||||
const rightScope = unavailableScope(RIGHT_SHA);
|
||||
const forward = compare([leftRoot], [rightRoot, rightScope]);
|
||||
const reverse = compare([leftRoot, leftScope], [rightRoot]);
|
||||
|
||||
expect(
|
||||
forward.items.find(
|
||||
({ right_node_id }) => right_node_id === rightScope.node_id,
|
||||
),
|
||||
).toMatchObject({
|
||||
status: "unknown",
|
||||
dimensions: ["availability"],
|
||||
limitations: [LIMITATION],
|
||||
evidence_links: [evidenceId("1"), evidenceId("2")],
|
||||
});
|
||||
expect(
|
||||
reverse.items.find(
|
||||
({ left_node_id }) => left_node_id === leftScope.node_id,
|
||||
),
|
||||
).toMatchObject({
|
||||
status: "unknown",
|
||||
dimensions: ["availability"],
|
||||
limitations: [LIMITATION],
|
||||
});
|
||||
expect(forward.summary).toMatchObject({ added: 0, removed: 0, unknown: 1 });
|
||||
expect(reverse.summary).toMatchObject({ added: 0, removed: 0, unknown: 1 });
|
||||
});
|
||||
|
||||
it("keeps matched unavailable scopes unknown with their source limitation", () => {
|
||||
const leftRoot = node("d".repeat(64), "main.js");
|
||||
const rightRoot = node("d".repeat(64), "main.js");
|
||||
const leftScope = unavailableScope(LEFT_SHA);
|
||||
const rightScope = unavailableScope(LEFT_SHA);
|
||||
const result = compare([leftRoot, leftScope], [rightRoot, rightScope]);
|
||||
|
||||
expect(
|
||||
result.items.find(({ left_node_id }) => left_node_id === leftScope.node_id),
|
||||
).toMatchObject({
|
||||
right_node_id: rightScope.node_id,
|
||||
status: "unknown",
|
||||
dimensions: [],
|
||||
limitations: [LIMITATION],
|
||||
});
|
||||
expect(result.summary).toMatchObject({ unchanged: 1, unknown: 1 });
|
||||
});
|
||||
|
||||
it("retains unknown scope facts and reports genuine entity changes", () => {
|
||||
const leftRoot = node("d".repeat(64), "main.js");
|
||||
const rightRoot = node("d".repeat(64), "main.js");
|
||||
const unknown = unavailableScope(RIGHT_SHA, "unknown");
|
||||
const added = node("e".repeat(64), "new.js");
|
||||
const removed = node("f".repeat(64), "removed.js");
|
||||
const mixedEvidenceAddition = observedAssetWithUnavailableFact();
|
||||
const unknownResult = compare([leftRoot], [rightRoot, unknown]);
|
||||
const addedResult = compare([leftRoot], [rightRoot, added]);
|
||||
const removedResult = compare([leftRoot, removed], [rightRoot]);
|
||||
const mixedResult = compare([leftRoot], [rightRoot, mixedEvidenceAddition]);
|
||||
|
||||
expect(
|
||||
unknownResult.items.find(
|
||||
({ right_node_id }) => right_node_id === unknown.node_id,
|
||||
),
|
||||
).toMatchObject({ status: "unknown", limitations: [LIMITATION] });
|
||||
expect(unknownResult.summary).toMatchObject({ added: 0, unknown: 1 });
|
||||
expect(
|
||||
addedResult.items.find(
|
||||
({ right_node_id }) => right_node_id === added.node_id,
|
||||
),
|
||||
).toMatchObject({ status: "added", dimensions: ["availability"] });
|
||||
expect(addedResult.summary.added).toBe(1);
|
||||
expect(
|
||||
mixedResult.items.find(
|
||||
({ right_node_id }) => right_node_id === mixedEvidenceAddition.node_id,
|
||||
),
|
||||
).toMatchObject({ status: "added", limitations: [LIMITATION] });
|
||||
expect(mixedResult.summary.added).toBe(1);
|
||||
expect(
|
||||
removedResult.items.find(
|
||||
({ left_node_id }) => left_node_id === removed.node_id,
|
||||
),
|
||||
).toMatchObject({ status: "removed", dimensions: ["availability"] });
|
||||
expect(removedResult.summary.removed).toBe(1);
|
||||
expect(
|
||||
compare(
|
||||
[leftRoot, node("c".repeat(64), "same.js")],
|
||||
[rightRoot, node("c".repeat(64), "same.js")],
|
||||
).summary,
|
||||
).toMatchObject({
|
||||
unchanged: 2,
|
||||
added: 0,
|
||||
removed: 0,
|
||||
});
|
||||
});
|
||||
@@ -73,6 +73,23 @@ describe("JavaScript semantic analysis: runtime boundaries 1", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps an overflowing timer delay unknown in a complete semantic result", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`
|
||||
function greet() { return "hello"; }
|
||||
setTimeout(() => greet(), 1e999);
|
||||
`);
|
||||
|
||||
expect(ir.timerOperations).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "schedule",
|
||||
method: "setTimeout",
|
||||
delayMilliseconds: null,
|
||||
resolution: "complete",
|
||||
}),
|
||||
]);
|
||||
expect(ir.callables.some(({ name }) => name === "greet")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat shadowed timer globals as Node timers", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`
|
||||
function run(setTimeout, clearTimeout) {
|
||||
|
||||
@@ -368,7 +368,11 @@ const timerDelay = (candidate: TimerCandidate): number | null => {
|
||||
if (candidate.kind !== "schedule" || candidate.method === "setImmediate")
|
||||
return null;
|
||||
const delay = candidate.node.arguments[1];
|
||||
return t.isNumericLiteral(delay) ? delay.value : null;
|
||||
// An overflowing literal such as 1e999 parses to Infinity, which JSON
|
||||
// Evidence cannot carry; keep that delay unknown like any other.
|
||||
return t.isNumericLiteral(delay) && Number.isFinite(delay.value)
|
||||
? delay.value
|
||||
: null;
|
||||
};
|
||||
|
||||
const timerId = (candidate: TimerCandidate): string =>
|
||||
|
||||
@@ -38,7 +38,13 @@ export const classifySwiftSymbol = (
|
||||
let category: SwiftTypeCategory | null = null;
|
||||
for (;;) {
|
||||
const end = identifierEnd(mangling, offset);
|
||||
if (end === undefined) break;
|
||||
if (end === undefined) {
|
||||
// A zero-prefixed identifier uses word substitutions or Punycode. We
|
||||
// cannot tell whether it continues a nested type or names a member, so
|
||||
// retaining the outer category would be a guess.
|
||||
if (mangling.charAt(offset) === "0") category = null;
|
||||
break;
|
||||
}
|
||||
const kind = mangling.charAt(end);
|
||||
const nominal = NOMINAL_CATEGORIES.get(kind);
|
||||
if (nominal === undefined) {
|
||||
|
||||
@@ -52,6 +52,56 @@ describe("modern Swift category evidence", () => {
|
||||
).toMatchObject({ total: 0, unclassified: [] });
|
||||
});
|
||||
|
||||
it("preserves unsupported zero-prefixed nested Unicode contexts as unresolved", () => {
|
||||
const names = [
|
||||
"_$s6Nested8EnvelopeV007IhuqBAbO8describeSSyF",
|
||||
"_$s6Nested8EnvelopeV007jvrtDBlCAEycfC",
|
||||
"_$s6Nested8EnvelopeV007jvrtDBlCMa",
|
||||
];
|
||||
const procedures = names.map((name, index) => ({
|
||||
address: `0x${String(index + 1)}`,
|
||||
name,
|
||||
}));
|
||||
const unresolved = names.map((name) => ({
|
||||
name,
|
||||
mangled_names: [name],
|
||||
reason: "category_not_decoded",
|
||||
}));
|
||||
expect(categorizeSwiftTypes(procedures)).toMatchObject({
|
||||
total: 3,
|
||||
categories: {
|
||||
other: {
|
||||
items: names.map((name) => ({ name, mangled_names: [name] })),
|
||||
},
|
||||
},
|
||||
unclassified: unresolved,
|
||||
});
|
||||
|
||||
for (const category of ["classes", "enums"] as const)
|
||||
expect(categorizeSwiftTypes(procedures, { category })).toMatchObject({
|
||||
total: 0,
|
||||
categories: { [category]: { count: 0 }, other: { count: 0 } },
|
||||
unclassified: unresolved,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps outer and ASCII nested nominal categories decoded", () => {
|
||||
const names = [
|
||||
["_$s6Nested8EnvelopeVMa", "structs"],
|
||||
["_$s6Nested8EnvelopeV5InnerCMa", "classes"],
|
||||
["_$s6Nested8EnvelopeV5InnerC6methodyyF", "classes"],
|
||||
] as const;
|
||||
for (const [name, category] of names) {
|
||||
expect(
|
||||
categorizeSwiftTypes([{ address: "0x1", name }], { category }),
|
||||
).toMatchObject({
|
||||
total: 1,
|
||||
categories: { [category]: { count: 1 } },
|
||||
unclassified: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("deduplicates display names after selecting a category", () => {
|
||||
expect(
|
||||
categorizeSwiftTypes(
|
||||
|
||||
@@ -42,6 +42,13 @@ const managedSnapshotCapacityError = (
|
||||
"memory",
|
||||
"Managed static inspection requires an eagerly retained PE snapshot within its input byte budget.",
|
||||
{ max_snapshot_bytes: MAX_MANAGED_SNAPSHOT_BYTES },
|
||||
{
|
||||
// The snapshot budget is fixed, so neither waiting nor more worker or
|
||||
// host memory admits the same input.
|
||||
retryable: false,
|
||||
remediationAction:
|
||||
"max_snapshot_bytes is a fixed managed-inspection snapshot budget; worker limits and available host memory do not raise it. Select a complete managed assembly within the budget; retrying the unchanged artifact will fail again.",
|
||||
},
|
||||
);
|
||||
|
||||
export const readManagedSnapshot = async (
|
||||
|
||||
@@ -296,6 +296,13 @@ const collectFlutterLibraries = async (
|
||||
max_flutter_abis: MAX_FLUTTER_ABIS,
|
||||
max_library_path_bytes: MAX_FLUTTER_LIBRARY_PATH_BYTES,
|
||||
},
|
||||
{
|
||||
// Both inventory bounds are fixed; neither waiting nor more worker
|
||||
// or host memory admits the same package.
|
||||
retryable: false,
|
||||
remediationAction:
|
||||
"max_flutter_abis and max_library_path_bytes are fixed Flutter library inventory budgets; worker limits and available host memory do not raise them. Select a complete APK within those budgets, such as a per-ABI split APK; retrying the unchanged package will fail again.",
|
||||
},
|
||||
);
|
||||
libraries.push({
|
||||
entry,
|
||||
|
||||
@@ -62,7 +62,14 @@ const failureForPrimary = (cause: unknown, path: string): AnalysisError => {
|
||||
cause.boundary === "search" ? "cpu" : "memory",
|
||||
`Selected artifact ${path}: ${cause.message}`,
|
||||
{ boundary: cause.boundary, maximum_bytes: cause.maximum_bytes },
|
||||
{ cause },
|
||||
{
|
||||
cause,
|
||||
// Every boundary is a fixed parser budget with no caller override, so
|
||||
// neither waiting nor more worker or host memory admits the same file.
|
||||
retryable: false,
|
||||
remediationAction:
|
||||
"The reported boundary's maximum_bytes is a fixed Go build-info parser budget; worker limits and available host memory do not raise it. Select another complete Go binary whose metadata fits the budget; retrying the unchanged artifact will fail again.",
|
||||
},
|
||||
);
|
||||
if (cause instanceof GoBinaryFormatFailure)
|
||||
return cause.kind === "unsupported"
|
||||
@@ -211,7 +218,7 @@ export class GoBinaryProvider implements GoBinaryPort {
|
||||
|
||||
/** Read complete compiler/module metadata while preserving original byte identity and source ranges. */
|
||||
async inspect(
|
||||
input: InspectGoBinaryInput,
|
||||
input: Omit<InspectGoBinaryInput, "detail">,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<GoBinary, AnalysisError>> {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { truncate, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, onTestFinished } from "vitest";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import {
|
||||
NativeFixtureRunner,
|
||||
nativeMachoTarget,
|
||||
} from "../../tests/fixtures/nativeCommands.js";
|
||||
import { machoWithPropertyAttributes } from "../../tests/fixtures/objcPropertyMacho.js";
|
||||
import { objcRecordBudgetMacho } from "../../tests/fixtures/objcRecordBudgetMacho.js";
|
||||
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
||||
import { decodeAppleDispatchMetadata } from "./AppleDispatchMetadata.js";
|
||||
import { NativeMacOSProvider } from "./NativeMacOSProvider.js";
|
||||
|
||||
const base = 0x100000000n;
|
||||
const fixture = (
|
||||
@@ -119,6 +129,20 @@ const fixture = (
|
||||
};
|
||||
const provenance = { path: "/fixture/app", sha256: "a".repeat(64) };
|
||||
|
||||
const withProtocolListSection = (bytes: Buffer): Buffer => {
|
||||
const pointer = (at: number, offset: number) =>
|
||||
bytes.writeBigUInt64LE(base + BigInt(offset), at);
|
||||
bytes.writeUInt32LE(232, 20);
|
||||
bytes.writeUInt32LE(232, 36);
|
||||
bytes.writeUInt32LE(2, 96);
|
||||
bytes.write("__objc_protolist", 184);
|
||||
bytes.write("__DATA", 200);
|
||||
pointer(216, 0x6c0);
|
||||
bytes.writeBigUInt64LE(8n, 224);
|
||||
pointer(0x6c0, 0x740);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
const partialSiblingsFixture = (
|
||||
options: {
|
||||
readonly emptyMiddleClass?: boolean;
|
||||
@@ -469,6 +493,173 @@ describe("Apple dispatch coverage ownership", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Objective-C declaration-local record-budget coverage", () => {
|
||||
it.each(["protocol", "class"] as const)(
|
||||
"preserves the decoded $0 method at a sufficient budget",
|
||||
(owner) => {
|
||||
const result = decodeAppleDispatchMetadata(
|
||||
objcRecordBudgetMacho(owner),
|
||||
100,
|
||||
provenance,
|
||||
);
|
||||
if (owner === "protocol")
|
||||
expect(result.objc_protocol_records).toMatchObject([
|
||||
{
|
||||
name: "FixtureProtocol",
|
||||
methods: [{ selector: "protocolMethod" }],
|
||||
decode: { status: "decoded", reason: null },
|
||||
},
|
||||
]);
|
||||
else
|
||||
expect(result.objc_classes).toMatchObject([
|
||||
{
|
||||
name: "FixtureClass",
|
||||
methods: [{ selector: "classMethod", method_type: "instance" }],
|
||||
decode: { status: "decoded", reason: null },
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("marks an omitted protocol method list partial while preserving known emptiness", () => {
|
||||
const truncated = decodeAppleDispatchMetadata(
|
||||
withProtocolListSection(fixture()),
|
||||
1,
|
||||
provenance,
|
||||
);
|
||||
expect(truncated.objc_protocol_records).toMatchObject([
|
||||
{
|
||||
name: "FixtureProtocol",
|
||||
methods: [],
|
||||
decode: { status: "partial", reason: "max_records_reached" },
|
||||
},
|
||||
]);
|
||||
|
||||
const emptyBytes = withProtocolListSection(fixture());
|
||||
emptyBytes.writeBigUInt64LE(0n, 0x758);
|
||||
emptyBytes.writeBigUInt64LE(0n, 0x768);
|
||||
const empty = decodeAppleDispatchMetadata(emptyBytes, 1, provenance);
|
||||
expect(empty.objc_protocol_records).toMatchObject([
|
||||
{
|
||||
name: "FixtureProtocol",
|
||||
methods: [],
|
||||
optional_methods: [],
|
||||
decode: { status: "decoded", reason: null },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks omitted class methods, ivars, properties, and adopted protocols partial", () => {
|
||||
const methodBytes = fixture();
|
||||
methodBytes.writeUInt32LE(2, 0x300);
|
||||
methodBytes.writeBigUInt64LE(0n, 0x330);
|
||||
methodBytes.writeBigUInt64LE(0n, 0x340);
|
||||
const methodLimited = decodeAppleDispatchMetadata(
|
||||
methodBytes,
|
||||
1,
|
||||
provenance,
|
||||
);
|
||||
expect(methodLimited.objc_classes[0]).toMatchObject({
|
||||
name: "Fixture",
|
||||
methods: [],
|
||||
decode: { status: "partial", reason: "max_records_reached" },
|
||||
});
|
||||
|
||||
const ivarBytes = fixture();
|
||||
ivarBytes.writeBigUInt64LE(0n, 0x320);
|
||||
ivarBytes.writeBigUInt64LE(0n, 0x328);
|
||||
ivarBytes.writeBigUInt64LE(0n, 0x340);
|
||||
ivarBytes.writeUInt32LE(2, 0x300);
|
||||
const ivarsLimited = decodeAppleDispatchMetadata(ivarBytes, 1, provenance);
|
||||
expect(ivarsLimited.objc_classes[0]).toMatchObject({
|
||||
name: "Fixture",
|
||||
ivar_count: 1,
|
||||
decode: { status: "partial", reason: "max_records_reached" },
|
||||
});
|
||||
expect(ivarsLimited.objc_ivars).toEqual([]);
|
||||
|
||||
const propertyBytes = fixture();
|
||||
propertyBytes.writeBigUInt64LE(0n, 0x320);
|
||||
propertyBytes.writeBigUInt64LE(0n, 0x328);
|
||||
propertyBytes.writeBigUInt64LE(0n, 0x330);
|
||||
propertyBytes.writeUInt32LE(2, 0x300);
|
||||
const propertiesLimited = decodeAppleDispatchMetadata(
|
||||
propertyBytes,
|
||||
1,
|
||||
provenance,
|
||||
);
|
||||
expect(propertiesLimited.objc_classes[0]).toMatchObject({
|
||||
name: "Fixture",
|
||||
properties: [],
|
||||
decode: { status: "partial", reason: "max_records_reached" },
|
||||
});
|
||||
|
||||
const adoptedBytes = fixture();
|
||||
adoptedBytes.writeBigUInt64LE(0n, 0x320);
|
||||
adoptedBytes.writeBigUInt64LE(0n, 0x330);
|
||||
adoptedBytes.writeBigUInt64LE(0n, 0x340);
|
||||
adoptedBytes.writeUInt32LE(2, 0x300);
|
||||
const adoptedLimited = decodeAppleDispatchMetadata(
|
||||
adoptedBytes,
|
||||
1,
|
||||
provenance,
|
||||
);
|
||||
expect(adoptedLimited.objc_classes[0]).toMatchObject({
|
||||
name: "Fixture",
|
||||
protocols: [],
|
||||
decode: { status: "partial", reason: "max_records_reached" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps genuinely empty class method lists decoded at the record limit", () => {
|
||||
const bytes = fixture();
|
||||
bytes.writeBigUInt64LE(0n, 0x320);
|
||||
bytes.writeBigUInt64LE(0n, 0x328);
|
||||
bytes.writeBigUInt64LE(0n, 0x330);
|
||||
bytes.writeBigUInt64LE(0n, 0x340);
|
||||
bytes.writeUInt32LE(2, 0x300);
|
||||
const result = decodeAppleDispatchMetadata(bytes, 1, provenance);
|
||||
expect(result.objc_classes[0]).toMatchObject({
|
||||
name: "Fixture",
|
||||
methods: [],
|
||||
decode: { status: "decoded", reason: null },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Objective-C category-local record-budget coverage", () => {
|
||||
it.each(["instance", "class"] as const)(
|
||||
"marks category %s methods partial only when a declared entry is omitted",
|
||||
(methodType) => {
|
||||
const bytes = fixture(false, "partial-method");
|
||||
const listField = methodType === "instance" ? 0xb50 : 0xb58;
|
||||
const methodList = bytes.readBigUInt64LE(0xb50);
|
||||
bytes.writeBigUInt64LE(0n, 0xb50);
|
||||
bytes.writeBigUInt64LE(methodList, listField);
|
||||
bytes.writeUInt32LE(2, 0x300);
|
||||
bytes.writeBigUInt64LE(0n, 0x320);
|
||||
const truncated = decodeAppleDispatchMetadata(bytes, 11, provenance);
|
||||
expect(truncated.objc_categories).toMatchObject([
|
||||
{
|
||||
name: "FixtureCategory",
|
||||
[`${methodType}_methods`]: [],
|
||||
decode: { status: "partial", reason: "max_records_reached" },
|
||||
},
|
||||
]);
|
||||
|
||||
bytes.writeBigUInt64LE(0n, listField);
|
||||
const empty = decodeAppleDispatchMetadata(bytes, 11, provenance);
|
||||
expect(empty.objc_categories).toMatchObject([
|
||||
{
|
||||
name: "FixtureCategory",
|
||||
[`${methodType}_methods`]: [],
|
||||
decode: { status: "decoded", reason: null },
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("malformed Objective-C property records", () => {
|
||||
it.each([
|
||||
["an unmapped name", { secondNameOffset: 0x2000 }, "first", "0x100000518"],
|
||||
@@ -1193,3 +1384,34 @@ describe("malformed Objective-C protocol method lists", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Apple dispatch snapshot budget", () => {
|
||||
it("refuses a target above the fixed snapshot budget without worker memory advice", async () => {
|
||||
const root = await createTestTempDirectory("rea-dispatch-snapshot-");
|
||||
const path = join(root, "large.macho");
|
||||
await writeFile(path, Buffer.alloc(0));
|
||||
await truncate(path, 64 * 1024 * 1024 + 1);
|
||||
const client = new NativeMacOSProvider(
|
||||
{},
|
||||
new NativeFixtureRunner({}),
|
||||
"darwin",
|
||||
).createClient(nativeMachoTarget(path));
|
||||
onTestFinished(async () => {
|
||||
await client.close();
|
||||
});
|
||||
const result = await client.execute("inspect_native_dispatch_metadata", {});
|
||||
if (result.ok) throw new Error("Expected a snapshot budget refusal");
|
||||
const projected = projectAnalysisError(result.error);
|
||||
expect(projected).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
retryable: false,
|
||||
details: {
|
||||
resource: "memory",
|
||||
reported_limits: { input_bytes: 64 * 1024 * 1024 },
|
||||
},
|
||||
});
|
||||
// The snapshot budget is fixed; worker or host memory cannot admit it.
|
||||
expect(projected.remediation.action).toContain("input_bytes");
|
||||
expect(projected.remediation.action).not.toMatch(/sufficient memory/iu);
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,10 @@ import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { NATIVE_MACOS_PROVIDER_IDENTITY as IDENTITY } from "./NativeMacOSProviderMetadata.js";
|
||||
import {
|
||||
APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE,
|
||||
NATIVE_MACOS_PROVIDER_IDENTITY as IDENTITY,
|
||||
} from "./NativeMacOSProviderMetadata.js";
|
||||
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
|
||||
import { EvidenceIntegrityError } from "../domain/evidenceErrors.js";
|
||||
import { pushDispatchCoverage } from "./AppleDispatchCoverage.js";
|
||||
@@ -214,6 +217,13 @@ export const inspectAppleDispatchMetadata = async (
|
||||
"memory",
|
||||
"Apple static metadata input exceeds its 64 MiB snapshot budget",
|
||||
{ input_bytes: maximumBytes },
|
||||
{
|
||||
// The snapshot budget is fixed, so neither waiting nor more worker or
|
||||
// host memory admits the same target.
|
||||
retryable: false,
|
||||
remediationAction:
|
||||
"input_bytes is the fixed 64 MiB static dispatch-metadata snapshot budget; worker limits and available host memory do not raise it. Select a complete Mach-O within the budget; retrying the unchanged target will fail again.",
|
||||
},
|
||||
);
|
||||
const cancelled = () =>
|
||||
new AnalysisCancelledError("inspect_native_dispatch_metadata", {
|
||||
@@ -249,12 +259,8 @@ export const inspectAppleDispatchMetadata = async (
|
||||
);
|
||||
return nativeDispatchMetadataResultSchema.parse({
|
||||
target_sha256: target.sha256,
|
||||
provider: {
|
||||
id: "native-macos",
|
||||
name: "macOS native inspection utilities",
|
||||
version: "apple-metadata-reader-1",
|
||||
},
|
||||
analysis_profile_digest: null,
|
||||
provider: APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE.provider,
|
||||
analysis_profile_digest: APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE.digest,
|
||||
result: decodeAppleDispatchMetadata(
|
||||
bytes,
|
||||
maxRecords,
|
||||
|
||||
@@ -31,8 +31,9 @@ export const decodeObjcCategories = (input: {
|
||||
className: string,
|
||||
methodType: "instance" | "class",
|
||||
category: string,
|
||||
onTruncated?: () => void,
|
||||
) => string[];
|
||||
readonly protocols: (list: bigint) => string[];
|
||||
readonly protocols: (list: bigint, onTruncated?: () => void) => string[];
|
||||
/** Name of a class defined in this image, from its class_ro_t. */
|
||||
readonly localClassName: (address: bigint) => string;
|
||||
}): number => {
|
||||
@@ -71,6 +72,43 @@ export const decodeObjcCategories = (input: {
|
||||
);
|
||||
}
|
||||
const owner = className ?? `(${name})`;
|
||||
let budgetTruncated = false;
|
||||
const onTruncated = () => {
|
||||
budgetTruncated = true;
|
||||
};
|
||||
const instanceMethods = input.methods(
|
||||
read.pointer(category + 16n),
|
||||
owner,
|
||||
"instance",
|
||||
name,
|
||||
onTruncated,
|
||||
);
|
||||
const classMethods = input.methods(
|
||||
read.pointer(category + 24n),
|
||||
owner,
|
||||
"class",
|
||||
name,
|
||||
onTruncated,
|
||||
);
|
||||
const protocols = input.protocols(
|
||||
read.pointer(category + 32n),
|
||||
onTruncated,
|
||||
);
|
||||
const properties = readObjcPropertiesOf(
|
||||
read,
|
||||
read.pointer(category + 40n),
|
||||
{
|
||||
owner: `category ${name}`,
|
||||
admit: input.admit,
|
||||
issues: input.issues,
|
||||
location: `0x${read.pointer(category + 40n).toString(16)}`,
|
||||
onTruncated,
|
||||
},
|
||||
);
|
||||
const decodeReasons = [
|
||||
...(className === null ? ["category_class_unresolved"] : []),
|
||||
...(budgetTruncated ? ["max_records_reached"] : []),
|
||||
];
|
||||
result.objc_categories.push({
|
||||
name,
|
||||
class_name: className,
|
||||
@@ -80,30 +118,15 @@ export const decodeObjcCategories = (input: {
|
||||
: className === null
|
||||
? "unresolved"
|
||||
: "local",
|
||||
instance_methods: input.methods(
|
||||
read.pointer(category + 16n),
|
||||
owner,
|
||||
"instance",
|
||||
name,
|
||||
),
|
||||
class_methods: input.methods(
|
||||
read.pointer(category + 24n),
|
||||
owner,
|
||||
"class",
|
||||
name,
|
||||
),
|
||||
protocols: input.protocols(read.pointer(category + 32n)),
|
||||
properties: readObjcPropertiesOf(read, read.pointer(category + 40n), {
|
||||
owner: `category ${name}`,
|
||||
admit: input.admit,
|
||||
issues: input.issues,
|
||||
location: `0x${read.pointer(category + 40n).toString(16)}`,
|
||||
}),
|
||||
instance_methods: instanceMethods,
|
||||
class_methods: classMethods,
|
||||
protocols,
|
||||
properties,
|
||||
location: read.location(category),
|
||||
decode:
|
||||
className === null
|
||||
? { status: "partial", reason: "category_class_unresolved" }
|
||||
: { status: "decoded", reason: null },
|
||||
decodeReasons.length === 0
|
||||
? { status: "decoded", reason: null }
|
||||
: { status: "partial", reason: decodeReasons.join("; ") },
|
||||
evidence: read.evidence(category, "Objective-C category_t record"),
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
|
||||
@@ -103,6 +103,7 @@ export const decodeObjcDispatchFacets = (input: {
|
||||
className: string,
|
||||
methodType: "instance" | "class",
|
||||
category?: string,
|
||||
onTruncated?: () => void,
|
||||
): string[] => {
|
||||
const selectors: string[] = [];
|
||||
if (list === 0n) return selectors;
|
||||
@@ -113,7 +114,10 @@ export const decodeObjcDispatchFacets = (input: {
|
||||
if (stride !== (relative ? 12 : 24))
|
||||
throw new TypeError(`Unsupported method stride/flags at ${hex(list)}`);
|
||||
for (let index = 0; index < count; index++) {
|
||||
if (!budget.admit()) break;
|
||||
if (!budget.admit()) {
|
||||
onTruncated?.();
|
||||
break;
|
||||
}
|
||||
const entry = list + 8n + BigInt(index * stride);
|
||||
const rel = (field: bigint) => {
|
||||
const displacement = bytes.readInt32LE(read.offset(field, 4));
|
||||
@@ -279,6 +283,10 @@ export const decodeObjcDispatchFacets = (input: {
|
||||
);
|
||||
const ivarList = read.pointer(ro + 48n);
|
||||
let ivarCount: number | null = meta ? null : 0;
|
||||
let classListTruncated = false;
|
||||
const onTruncated = () => {
|
||||
classListTruncated = true;
|
||||
};
|
||||
if (ivarList !== 0n && !meta) {
|
||||
const stride = read.u32(ivarList),
|
||||
count = read.u32(ivarList + 4n);
|
||||
@@ -286,7 +294,10 @@ export const decodeObjcDispatchFacets = (input: {
|
||||
throw new TypeError("Unsupported Objective-C ivar entry size");
|
||||
ivarCount = count;
|
||||
for (let index = 0; index < count; index++) {
|
||||
if (!budget.admit()) break;
|
||||
if (!budget.admit()) {
|
||||
classListTruncated = true;
|
||||
break;
|
||||
}
|
||||
const entry = ivarList + 8n + BigInt(index * stride);
|
||||
const encodedOffset = read.pointer(entry);
|
||||
let value: number | null = null;
|
||||
@@ -326,7 +337,27 @@ export const decodeObjcDispatchFacets = (input: {
|
||||
});
|
||||
}
|
||||
}
|
||||
methods(read.pointer(ro + 32n), name, meta ? "class" : "instance");
|
||||
methods(
|
||||
read.pointer(ro + 32n),
|
||||
name,
|
||||
meta ? "class" : "instance",
|
||||
undefined,
|
||||
onTruncated,
|
||||
);
|
||||
const properties = readObjcPropertiesOf(read, read.pointer(ro + 64n), {
|
||||
owner: name,
|
||||
admit: () => budget.admit(),
|
||||
issues: categoryIssues,
|
||||
location: hex(read.pointer(ro + 64n)),
|
||||
onTruncated,
|
||||
});
|
||||
const protocols = protocolReader.list(read.pointer(ro + 40n), onTruncated);
|
||||
const classDecodeReasons = [
|
||||
...(superclass.name === null && !root
|
||||
? ["superclass_binding_unresolved"]
|
||||
: []),
|
||||
...(classListTruncated ? ["max_records_reached"] : []),
|
||||
];
|
||||
result.objc_classes.push({
|
||||
name,
|
||||
super_class: superclass.name,
|
||||
@@ -350,22 +381,17 @@ export const decodeObjcDispatchFacets = (input: {
|
||||
is_required: null,
|
||||
is_optional: null,
|
||||
})),
|
||||
properties: readObjcPropertiesOf(read, read.pointer(ro + 64n), {
|
||||
owner: name,
|
||||
admit: () => budget.admit(),
|
||||
issues: categoryIssues,
|
||||
location: hex(read.pointer(ro + 64n)),
|
||||
}),
|
||||
protocols: protocolReader.list(read.pointer(ro + 40n)),
|
||||
properties,
|
||||
protocols,
|
||||
ivar_count: ivarCount,
|
||||
instance_size: read.u32(ro + 8n),
|
||||
location: read.location(address),
|
||||
superclass_address: superclass.address,
|
||||
metaclass_address: meta ? null : hex(read.pointer(address)),
|
||||
decode:
|
||||
superclass.name === null && !root
|
||||
? { status: "partial", reason: "superclass_binding_unresolved" }
|
||||
: decoded,
|
||||
classDecodeReasons.length === 0
|
||||
? decoded
|
||||
: { status: "partial", reason: classDecodeReasons.join("; ") },
|
||||
evidence: read.evidence(
|
||||
address,
|
||||
"Objective-C class and class_ro_t records",
|
||||
@@ -422,7 +448,7 @@ export const decodeObjcDispatchFacets = (input: {
|
||||
issues: categoryIssues,
|
||||
admit: () => budget.admit(),
|
||||
methods,
|
||||
protocols: (list) => protocolReader.list(list),
|
||||
protocols: (list, onTruncated) => protocolReader.list(list, onTruncated),
|
||||
localClassName: (address) => readLocalClass(read, address).name,
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -107,6 +107,7 @@ export const readObjcPropertyList = (
|
||||
read: ObjcPropertyReaders,
|
||||
list: bigint,
|
||||
admit: () => boolean,
|
||||
onTruncated?: () => void,
|
||||
): {
|
||||
readonly properties: ObjcProperty[];
|
||||
readonly issues: PropertyListIssue[];
|
||||
@@ -121,7 +122,10 @@ export const readObjcPropertyList = (
|
||||
`Unsupported Objective-C property entry size ${entrySize}`,
|
||||
);
|
||||
for (let index = 0; index < count; index++) {
|
||||
if (!admit()) break;
|
||||
if (!admit()) {
|
||||
onTruncated?.();
|
||||
break;
|
||||
}
|
||||
const entry = list + 8n + BigInt(index * entrySize);
|
||||
const location = `0x${entry.toString(16)}`;
|
||||
try {
|
||||
@@ -158,6 +162,7 @@ export const readObjcPropertiesOf = (
|
||||
readonly admit: () => boolean;
|
||||
readonly issues: DecodeIssue[];
|
||||
readonly location?: string;
|
||||
readonly onTruncated?: () => void;
|
||||
},
|
||||
): ObjcProperty[] => {
|
||||
try {
|
||||
@@ -165,6 +170,7 @@ export const readObjcPropertiesOf = (
|
||||
read,
|
||||
list,
|
||||
context.admit,
|
||||
context.onTruncated,
|
||||
);
|
||||
for (const item of issues)
|
||||
context.issues.push(
|
||||
|
||||
@@ -18,7 +18,7 @@ export const createObjcProtocolReader = (input: {
|
||||
const { readers: read, result, failures } = input;
|
||||
const visited = new Set<bigint>();
|
||||
const name = (address: bigint) => read.string(read.pointer(address + 8n));
|
||||
const list = (address: bigint): string[] => {
|
||||
const list = (address: bigint, onTruncated?: () => void): string[] => {
|
||||
if (address === 0n) return [];
|
||||
// `count` is plain uintptr_t data, not a pointer fixup.
|
||||
const count = read.u64(address);
|
||||
@@ -26,7 +26,10 @@ export const createObjcProtocolReader = (input: {
|
||||
throw new RangeError("Protocol list exceeds 20000 entries");
|
||||
const names: string[] = [];
|
||||
for (let index = 0n; index < count; index++) {
|
||||
if (!read.admit()) break;
|
||||
if (!read.admit()) {
|
||||
onTruncated?.();
|
||||
break;
|
||||
}
|
||||
const protocol = read.pointer(address + 8n + index * 8n);
|
||||
names.push(name(protocol));
|
||||
record(protocol);
|
||||
@@ -39,6 +42,7 @@ export const createObjcProtocolReader = (input: {
|
||||
instance: boolean,
|
||||
/** Entries skipped for an empty selector, reported by the declaring protocol. */
|
||||
emptySelectors: string[],
|
||||
onTruncated?: () => void,
|
||||
) => {
|
||||
const output: ObjcSwiftMetadata["objc_protocols"][number]["methods"] = [];
|
||||
if (address === 0n) return output;
|
||||
@@ -49,7 +53,10 @@ export const createObjcProtocolReader = (input: {
|
||||
if (stride !== (relative ? 12 : 24))
|
||||
throw new TypeError("Unsupported Objective-C protocol method entry size");
|
||||
for (let index = 0; index < count; index++) {
|
||||
if (!read.admit()) break;
|
||||
if (!read.admit()) {
|
||||
onTruncated?.();
|
||||
break;
|
||||
}
|
||||
const entry = address + 8n + BigInt(index * stride);
|
||||
let selector = relative
|
||||
? entry + BigInt(read.i32(entry))
|
||||
@@ -84,22 +91,54 @@ export const createObjcProtocolReader = (input: {
|
||||
const protocolName = name(address);
|
||||
// Adopted protocols are independent declarations; decode them before
|
||||
// rejecting a parent whose own name is empty.
|
||||
const adopted = list(read.pointer(address + 16n));
|
||||
let budgetTruncated = false;
|
||||
const onTruncated = () => {
|
||||
budgetTruncated = true;
|
||||
};
|
||||
const adopted = list(read.pointer(address + 16n), onTruncated);
|
||||
if (protocolName.length === 0)
|
||||
throw new TypeError("Objective-C protocol has an empty name");
|
||||
const skipped: string[] = [];
|
||||
const required = [
|
||||
...methods(read.pointer(address + 24n), true, true, skipped),
|
||||
...methods(read.pointer(address + 32n), true, false, skipped),
|
||||
...methods(
|
||||
read.pointer(address + 24n),
|
||||
true,
|
||||
true,
|
||||
skipped,
|
||||
onTruncated,
|
||||
),
|
||||
...methods(
|
||||
read.pointer(address + 32n),
|
||||
true,
|
||||
false,
|
||||
skipped,
|
||||
onTruncated,
|
||||
),
|
||||
];
|
||||
const optional = [
|
||||
...methods(read.pointer(address + 40n), false, true, skipped),
|
||||
...methods(read.pointer(address + 48n), false, false, skipped),
|
||||
...methods(
|
||||
read.pointer(address + 40n),
|
||||
false,
|
||||
true,
|
||||
skipped,
|
||||
onTruncated,
|
||||
),
|
||||
...methods(
|
||||
read.pointer(address + 48n),
|
||||
false,
|
||||
false,
|
||||
skipped,
|
||||
onTruncated,
|
||||
),
|
||||
];
|
||||
for (const message of skipped)
|
||||
failures.push(
|
||||
`Protocol 0x${address.toString(16)}: Objective-C protocol ${protocolName} ${message}`,
|
||||
);
|
||||
const decodeReasons = [
|
||||
...(skipped.length === 0 ? [] : ["protocol_method_selector_empty"]),
|
||||
...(budgetTruncated ? ["max_records_reached"] : []),
|
||||
];
|
||||
result.objc_protocols.push({
|
||||
name: protocolName,
|
||||
methods: required,
|
||||
@@ -113,9 +152,9 @@ export const createObjcProtocolReader = (input: {
|
||||
optional_methods: optional,
|
||||
location: read.location(address),
|
||||
decode:
|
||||
skipped.length === 0
|
||||
decodeReasons.length === 0
|
||||
? { status: "decoded", reason: null }
|
||||
: { status: "partial", reason: "protocol_method_selector_empty" },
|
||||
: { status: "partial", reason: decodeReasons.join("; ") },
|
||||
evidence: read.evidence(
|
||||
address,
|
||||
"Objective-C protocol declaration and encoded method lists; IMP fields are not handler targets",
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js";
|
||||
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
|
||||
import {
|
||||
APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE,
|
||||
NATIVE_MACOS_PROVIDER_IDENTITY as IDENTITY,
|
||||
nativeMacOSCapabilities,
|
||||
} from "./NativeMacOSProviderMetadata.js";
|
||||
@@ -237,6 +238,7 @@ class NativeMacOSClient implements AnalysisClient {
|
||||
);
|
||||
return ok(
|
||||
createAnalysisExecution(result, result.provider, {
|
||||
analysisProfile: APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE,
|
||||
locations: [{ kind: "artifact-path", path: this.target.path }],
|
||||
limitations: result.result.coverage.flatMap(({ reason }) =>
|
||||
reason === null ? [] : [reason],
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
CapabilityDescriptor,
|
||||
ProviderIdentity,
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import { createAnalysisProfile } from "../domain/analysisProfile.js";
|
||||
import { NATIVE_TOOL_CONTRACTS } from "../contracts/native/nativeToolContracts.js";
|
||||
|
||||
/** Public identity committed by macOS-native inspection observations. */
|
||||
@@ -10,6 +11,19 @@ export const NATIVE_MACOS_PROVIDER_IDENTITY: ProviderIdentity = Object.freeze({
|
||||
name: "macOS native inspection utilities",
|
||||
version: null,
|
||||
});
|
||||
/** Source-owned commitment for the pure Apple dispatch metadata reader. */
|
||||
export const APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE = createAnalysisProfile(
|
||||
{
|
||||
id: NATIVE_MACOS_PROVIDER_IDENTITY.id,
|
||||
name: NATIVE_MACOS_PROVIDER_IDENTITY.name,
|
||||
version: "apple-metadata-reader-1",
|
||||
},
|
||||
{
|
||||
operation: "inspect_native_dispatch_metadata",
|
||||
reader: "apple-dispatch-metadata",
|
||||
revision: 1,
|
||||
},
|
||||
);
|
||||
/** Operations whose target process can show UI, use the network and write files. */
|
||||
const OWNED_PROCESS_OPERATIONS: ReadonlySet<string> = new Set([
|
||||
"capture_native_ui_scenario",
|
||||
@@ -36,6 +50,9 @@ export const nativeMacOSCapabilities = (
|
||||
return Object.freeze({
|
||||
provider: NATIVE_MACOS_PROVIDER_IDENTITY,
|
||||
operation: contract.name,
|
||||
...(contract.name === "inspect_native_dispatch_metadata"
|
||||
? { analysisProfile: APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE }
|
||||
: {}),
|
||||
...availability,
|
||||
effects: Object.freeze({
|
||||
mutatesArtifact: false,
|
||||
|
||||
@@ -19,7 +19,8 @@ type Range = { offset: number; bytes: number };
|
||||
type OccupiedRange = Range & {
|
||||
kind: "directory" | "name" | "data-entry" | "payload";
|
||||
};
|
||||
const METADATA_BYTES = 8 * 1024 * 1024;
|
||||
/** Fixed complete-result metadata budget; callers cannot raise it. */
|
||||
export const PE_RESOURCE_METADATA_BYTES = 8 * 1024 * 1024;
|
||||
const limit = (message: string, boundary: ArtifactReaderBoundary): never => {
|
||||
throw new ArtifactReaderFailure("limit", message, { boundary });
|
||||
};
|
||||
@@ -51,7 +52,7 @@ export const parsePeResources = async (
|
||||
let examined = 0;
|
||||
let metadataBytes = 0;
|
||||
const ensureCapacity = (projectedBytes: number): void => {
|
||||
if (metadataBytes + projectedBytes > METADATA_BYTES)
|
||||
if (metadataBytes + projectedBytes > PE_RESOURCE_METADATA_BYTES)
|
||||
limit(
|
||||
"PE resource metadata exceeds the 8 MiB complete-result allocation budget.",
|
||||
"memory",
|
||||
@@ -63,7 +64,9 @@ export const parsePeResources = async (
|
||||
};
|
||||
const account = (value: unknown): void => {
|
||||
// Rows are retained in arrays; reserve a separator along with each row.
|
||||
accountBytes(jsonBytes(value, METADATA_BYTES - metadataBytes - 1) + 1);
|
||||
accountBytes(
|
||||
jsonBytes(value, PE_RESOURCE_METADATA_BYTES - metadataBytes - 1) + 1,
|
||||
);
|
||||
};
|
||||
const reserveDecodedName = (utf16Units: number): void => {
|
||||
// A JSON string can need six ASCII bytes per UTF-16 code unit (for example,
|
||||
@@ -254,7 +257,9 @@ export const parsePeResources = async (
|
||||
],
|
||||
};
|
||||
assertArtifactReadActive(signal);
|
||||
if (jsonBytes(report, METADATA_BYTES) > METADATA_BYTES)
|
||||
if (
|
||||
jsonBytes(report, PE_RESOURCE_METADATA_BYTES) > PE_RESOURCE_METADATA_BYTES
|
||||
)
|
||||
limit("PE resource result exceeds the 8 MiB allocation budget.", "memory");
|
||||
return report;
|
||||
};
|
||||
|
||||
@@ -172,3 +172,35 @@ describe("PE resource provider budget reporting", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("PE resource provider fixed metadata budget", () => {
|
||||
it("reports the fixed metadata budget without worker memory advice", async () => {
|
||||
const root = await createTestTempDirectory("rea-pe-metadata-budget-");
|
||||
const path = join(root, "resource.exe");
|
||||
const name = "n".repeat(200);
|
||||
const resources = Array.from({ length: 6000 }, (_, index) => ({
|
||||
type: 10,
|
||||
name: `${name}${String(index).padStart(4, "0")}`,
|
||||
language: 1033,
|
||||
payload: Buffer.alloc(0),
|
||||
}));
|
||||
await writeFile(
|
||||
path,
|
||||
peResourceFixture(false, resources, 10 * 1024 * 1024).bytes,
|
||||
);
|
||||
const result = await inspect(path);
|
||||
if (result.ok) throw new Error("Expected a refusal");
|
||||
const projected = projectAnalysisError(result.error);
|
||||
expect(projected).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
retryable: false,
|
||||
details: {
|
||||
resource: "memory",
|
||||
reported_limits: { max_metadata_bytes: 8 * 1024 * 1024 },
|
||||
},
|
||||
});
|
||||
// Neither caller bounds nor worker or host memory raise the fixed budget.
|
||||
expect(projected.remediation.action).toContain("max_metadata_bytes");
|
||||
expect(projected.remediation.action).not.toMatch(/sufficient memory/iu);
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,10 @@ import {
|
||||
import { err, ok } from "../../domain/result.js";
|
||||
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
|
||||
import { PE_RESOURCES_PROVIDER } from "../../application/InvestigationProviders.js";
|
||||
import { parsePeResources } from "./PeResourceParser.js";
|
||||
import {
|
||||
parsePeResources,
|
||||
PE_RESOURCE_METADATA_BYTES,
|
||||
} from "./PeResourceParser.js";
|
||||
|
||||
/** Portable parser over one bounded, identity-checked regular file. */
|
||||
export class PeResourcesProvider implements PeResourcesPort {
|
||||
@@ -126,19 +129,22 @@ const peResourcesFailurePrimary = (
|
||||
{
|
||||
max_file_bytes: input.max_file_bytes,
|
||||
max_entries: input.max_entries,
|
||||
...(cause.boundary === "memory"
|
||||
? { max_metadata_bytes: PE_RESOURCE_METADATA_BYTES }
|
||||
: {}),
|
||||
},
|
||||
{
|
||||
cause,
|
||||
// Both bounds are caller-selected and deterministic for the same
|
||||
// input, so waiting for an active analysis cannot help.
|
||||
...(cause.boundary === undefined || cause.boundary === "memory"
|
||||
// Every bound is deterministic for the same input, so waiting for an
|
||||
// active analysis or adding worker or host memory cannot help.
|
||||
...(cause.boundary === undefined
|
||||
? {}
|
||||
: {
|
||||
retryable: false,
|
||||
remediationAction: inputBudgetRemediation(
|
||||
cause.boundary,
|
||||
input,
|
||||
),
|
||||
remediationAction:
|
||||
cause.boundary === "memory"
|
||||
? "max_metadata_bytes is a fixed PE resource metadata budget; max_entries, max_file_bytes, worker limits, and available host memory do not raise it. Select a complete PE image whose resource metadata fits the budget; retrying the unchanged image will fail again."
|
||||
: inputBudgetRemediation(cause.boundary, input),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -186,7 +186,18 @@ const failureForPrimary = (cause: unknown, path: string): AnalysisError => {
|
||||
cause.constraint.resource,
|
||||
`Selected artifact ${path}: ${cause.message}`,
|
||||
cause.constraint.reported_limits,
|
||||
{ cause },
|
||||
{
|
||||
cause,
|
||||
// Section and inflated-JSON sizes are fixed reader budgets; worker
|
||||
// limits and host memory cannot admit the same dependency section.
|
||||
...(cause.constraint.resource === "memory"
|
||||
? {
|
||||
retryable: false,
|
||||
remediationAction:
|
||||
"maximum_bytes is a fixed Rust dependency-section budget; worker limits and available host memory do not raise it. Select another complete Rust binary whose dependency section fits the budget; retrying the unchanged artifact will fail again.",
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
if (cause instanceof RustAuditableFormatFailure)
|
||||
return cause.kind === "unsupported"
|
||||
|
||||
@@ -741,6 +741,7 @@ const registerConfiguredAnalysisTools = (
|
||||
toolLogger,
|
||||
recordEvidence,
|
||||
withAdmittedAnalysis,
|
||||
evidenceById,
|
||||
);
|
||||
registerIl2cppTools(
|
||||
server,
|
||||
@@ -904,7 +905,10 @@ const registerBinaryAnalysisTools = ({
|
||||
registerEnhancedTools(server, {
|
||||
...analysisOptions,
|
||||
analysisProfile:
|
||||
session === undefined ? undefined : () => session.analysisProfile(),
|
||||
session === undefined
|
||||
? undefined
|
||||
: (operation) =>
|
||||
session.analysisProfile() ?? session.analysisProfile(operation),
|
||||
allowsSnapshotReplay:
|
||||
session === undefined
|
||||
? undefined
|
||||
|
||||
@@ -37,7 +37,9 @@ export interface EnhancedToolRegistration {
|
||||
readonly logger: Logger;
|
||||
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
|
||||
readonly analysisProfile:
|
||||
| (() => AnalysisProfileCommitment | undefined)
|
||||
| ((
|
||||
operation: ValidatedEnhancedCall["name"],
|
||||
) => AnalysisProfileCommitment | undefined)
|
||||
| undefined;
|
||||
readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
|
||||
readonly recordUnknown: UnknownRegistryPort["recordUnknown"] | undefined;
|
||||
@@ -260,7 +262,7 @@ const executeEnhancedTool = async (
|
||||
operation: name,
|
||||
parameters,
|
||||
result: result.value.result,
|
||||
upstreamProfile: registration.analysisProfile?.(),
|
||||
upstreamProfile: registration.analysisProfile?.(name),
|
||||
upstreamLimitations: result.value.limitations,
|
||||
});
|
||||
const recorded = registration.recordEvidence?.(evidence);
|
||||
@@ -273,6 +275,7 @@ const executeEnhancedTool = async (
|
||||
const workflowRecord = workflowSnapshotRecord(
|
||||
evidence,
|
||||
name,
|
||||
registration.analysisProfile?.(name),
|
||||
result.value.cachePolicy,
|
||||
);
|
||||
if (workflowRecord !== undefined) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
|
||||
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
|
||||
import type { GoBinaryService } from "../application/go/GoBinaryService.js";
|
||||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||
import { toolContract } from "../contracts/toolContracts.js";
|
||||
@@ -14,11 +15,13 @@ export const registerGoTools = (
|
||||
logger: Logger,
|
||||
recordEvidence?: EvidenceWriter["recordEvidence"],
|
||||
withAdmittedAnalysis?: WithAdmittedAnalysis,
|
||||
evidenceById?: EvidenceLookup,
|
||||
): void => {
|
||||
const contract = toolContract("inspect_go_binary");
|
||||
const handler = createEvidenceToolHandler(
|
||||
server,
|
||||
(input, options) => service.inspect(input, options),
|
||||
(input, options) =>
|
||||
service.inspect(input, options, { recordEvidence, evidenceById }),
|
||||
logger,
|
||||
recordEvidence,
|
||||
withAdmittedAnalysis,
|
||||
|
||||
@@ -9,6 +9,113 @@ import { applicationVersionComparisonResultSchema } from "../../../src/domain/ja
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
cliTest(
|
||||
"keeps unavailable source-selection scopes unknown in version comparisons",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-version-scope-");
|
||||
const application = join(root, "application");
|
||||
const copy = join(root, "copy");
|
||||
await Promise.all([mkdir(application), mkdir(copy)]);
|
||||
const files = [
|
||||
["lib.js", "export function shared() { return 1; }\n"],
|
||||
["main.js", 'export const name = "one";\n'],
|
||||
] as const;
|
||||
for (const [name, contents] of files) {
|
||||
await Promise.all([
|
||||
writeFile(join(application, name), contents),
|
||||
writeFile(join(copy, name), contents),
|
||||
]);
|
||||
}
|
||||
|
||||
const analyze = async (input: string, selected: boolean) => {
|
||||
const response = await cli.run({
|
||||
arguments: [
|
||||
"analyze-javascript-application",
|
||||
input,
|
||||
...(selected ? ["--source-path", "main.js"] : []),
|
||||
"--json",
|
||||
],
|
||||
environment: { REA_LOG_LEVEL: "silent" },
|
||||
});
|
||||
expect(response.exitCode, response.stderr).toBe(0);
|
||||
return parseEvidence(response.json);
|
||||
};
|
||||
const full = await analyze(application, false);
|
||||
const selected = await analyze(application, true);
|
||||
const fullCopy = await analyze(copy, false);
|
||||
const selectedAnalysis = javascriptApplicationAnalysisResultSchema.parse(
|
||||
selected.normalized_result,
|
||||
);
|
||||
const parseScope = selectedAnalysis.graph.nodes.find(
|
||||
({ kind, observations }) =>
|
||||
kind === "unknown" &&
|
||||
observations.some(
|
||||
({ properties }) =>
|
||||
properties.path === "lib.js" &&
|
||||
properties.operation === "select-javascript-sources",
|
||||
),
|
||||
);
|
||||
expect(parseScope?.observations[0]?.evidence).toMatchObject({
|
||||
state: "unavailable",
|
||||
limitations: [
|
||||
"Source is outside the caller-selected source_paths; it was inventoried but not analyzed.",
|
||||
],
|
||||
});
|
||||
|
||||
const compare = async (
|
||||
left: ReturnType<typeof parseEvidence>,
|
||||
right: ReturnType<typeof parseEvidence>,
|
||||
name: string,
|
||||
) => {
|
||||
const inputPath = join(root, `${name}.json`);
|
||||
await writeFile(inputPath, JSON.stringify({ left, right }));
|
||||
const response = await cli.run({
|
||||
arguments: ["compare-application-versions", inputPath, "--json"],
|
||||
environment: { REA_LOG_LEVEL: "silent" },
|
||||
});
|
||||
expect(response.exitCode, response.stderr).toBe(0);
|
||||
return applicationVersionComparisonResultSchema.parse(
|
||||
parseEvidence(response.json).normalized_result,
|
||||
);
|
||||
};
|
||||
const forward = await compare(full, selected, "forward");
|
||||
const reverse = await compare(selected, full, "reverse");
|
||||
const unchangedCopy = await compare(full, fullCopy, "full-copy");
|
||||
|
||||
expect(
|
||||
forward.items.find(
|
||||
({ right_node_id }) => right_node_id === parseScope?.node_id,
|
||||
),
|
||||
).toMatchObject({
|
||||
status: "unknown",
|
||||
dimensions: ["availability"],
|
||||
limitations: [
|
||||
"Source is outside the caller-selected source_paths; it was inventoried but not analyzed.",
|
||||
],
|
||||
evidence_links: expect.arrayContaining([
|
||||
full.evidence_id,
|
||||
selected.evidence_id,
|
||||
]),
|
||||
});
|
||||
expect(
|
||||
reverse.items.find(
|
||||
({ left_node_id }) => left_node_id === parseScope?.node_id,
|
||||
),
|
||||
).toMatchObject({
|
||||
status: "unknown",
|
||||
dimensions: ["availability"],
|
||||
limitations: [
|
||||
"Source is outside the caller-selected source_paths; it was inventoried but not analyzed.",
|
||||
],
|
||||
});
|
||||
expect(forward.summary.added).toBe(0);
|
||||
expect(forward.summary.removed).toBe(0);
|
||||
expect(reverse.summary.added).toBe(0);
|
||||
expect(reverse.summary.removed).toBe(0);
|
||||
expect(unchangedCopy.summary).toMatchObject({ added: 0, removed: 0 });
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"uses syntax-aware bundle fingerprints for changed syntax and renamed bindings",
|
||||
async ({ cli }) => {
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
javascriptApplicationAnalysisResultSchema,
|
||||
type JavaScriptApplicationAnalysisResult,
|
||||
} from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { applicationVersionComparisonResultSchema } from "../../../src/domain/javascript/javascriptApplicationVersionComparisonSchemas.js";
|
||||
import { parseJavaScriptApplicationGraph } from "../../../src/domain/javascript/javascriptApplicationGraph.js";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -186,23 +187,31 @@ const expectSelfImportEvidence = (
|
||||
const analyzeThroughStdioMcp = async (
|
||||
inputPath: string,
|
||||
): Promise<JavaScriptApplicationAnalysisResult> => {
|
||||
const evidence = await analyzeEvidenceThroughStdioMcp(inputPath);
|
||||
const result = javascriptApplicationAnalysisResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
expect(result).toMatchObject({ input_path: inputPath, format: "directory" });
|
||||
return result;
|
||||
};
|
||||
|
||||
const analyzeEvidenceThroughStdioMcp = async (
|
||||
inputPath: string,
|
||||
sourcePaths?: readonly string[],
|
||||
) => {
|
||||
const client = await connectedMcpClient();
|
||||
const response = await client.callTool({
|
||||
name: "analyze_javascript_application",
|
||||
arguments: { input_path: inputPath },
|
||||
arguments: {
|
||||
input_path: inputPath,
|
||||
...(sourcePaths === undefined ? {} : { source_paths: [...sourcePaths] }),
|
||||
},
|
||||
});
|
||||
expect(response.isError).not.toBe(true);
|
||||
expect(response.content).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ type: "text" })]),
|
||||
);
|
||||
const result = z
|
||||
.object({ normalized_result: javascriptApplicationAnalysisResultSchema })
|
||||
.parse(response.structuredContent).normalized_result;
|
||||
expect(result).toMatchObject({
|
||||
input_path: inputPath,
|
||||
format: "directory",
|
||||
});
|
||||
return result;
|
||||
return parseEvidence(response.structuredContent);
|
||||
};
|
||||
|
||||
const expectCliAnalysisParity = async (
|
||||
@@ -1220,6 +1229,73 @@ describe("application workflow CLI input", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("application version source-scope MCP comparison", () => {
|
||||
it("keeps unselected parse scopes unknown through stdio MCP", async () => {
|
||||
const root = await createTestTempDirectory("rea-version-scope-mcp-");
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
join(root, "lib.js"),
|
||||
"export function shared() { return 1; }\n",
|
||||
),
|
||||
writeFile(join(root, "main.js"), 'export const name = "one";\n'),
|
||||
]);
|
||||
const full = await analyzeEvidenceThroughStdioMcp(root);
|
||||
const selected = await analyzeEvidenceThroughStdioMcp(root, ["main.js"]);
|
||||
const selectedAnalysis = javascriptApplicationAnalysisResultSchema.parse(
|
||||
selected.normalized_result,
|
||||
);
|
||||
const scope = selectedAnalysis.graph.nodes.find(
|
||||
({ kind, observations }) =>
|
||||
kind === "unknown" &&
|
||||
observations.some(
|
||||
({ properties }) =>
|
||||
properties.path === "lib.js" &&
|
||||
properties.operation === "select-javascript-sources",
|
||||
),
|
||||
);
|
||||
expect(scope?.observations[0]?.evidence).toMatchObject({
|
||||
state: "unavailable",
|
||||
limitations: [
|
||||
"Source is outside the caller-selected source_paths; it was inventoried but not analyzed.",
|
||||
],
|
||||
});
|
||||
const client = await connectedMcpClient();
|
||||
const compare = async (left: unknown, right: unknown) => {
|
||||
const response = await client.callTool({
|
||||
name: "compare_application_versions",
|
||||
arguments: { left, right },
|
||||
});
|
||||
expect(response.isError).not.toBe(true);
|
||||
return applicationVersionComparisonResultSchema.parse(
|
||||
parseEvidence(response.structuredContent).normalized_result,
|
||||
);
|
||||
};
|
||||
const forward = await compare(full, selected);
|
||||
const reverse = await compare(selected, full);
|
||||
|
||||
expect(
|
||||
forward.items.find(
|
||||
({ right_node_id }) => right_node_id === scope?.node_id,
|
||||
),
|
||||
).toMatchObject({
|
||||
status: "unknown",
|
||||
dimensions: ["availability"],
|
||||
limitations: [
|
||||
"Source is outside the caller-selected source_paths; it was inventoried but not analyzed.",
|
||||
],
|
||||
evidence_links: expect.arrayContaining([
|
||||
full.evidence_id,
|
||||
selected.evidence_id,
|
||||
]),
|
||||
});
|
||||
expect(
|
||||
reverse.items.find(({ left_node_id }) => left_node_id === scope?.node_id),
|
||||
).toMatchObject({ status: "unknown", dimensions: ["availability"] });
|
||||
expect(forward.summary).toMatchObject({ added: 0, removed: 0 });
|
||||
expect(reverse.summary).toMatchObject({ added: 0, removed: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("application workflow CLI copy boundaries", () => {
|
||||
it("compares source values excluded from escaped rest and spread copies", async () => {
|
||||
const root = await createTestTempDirectory("rea-copy-boundary-cli-");
|
||||
|
||||
@@ -217,6 +217,8 @@ cliTest(
|
||||
issues: [
|
||||
{
|
||||
path: ["executable_path"],
|
||||
reason: "invalid_format",
|
||||
message: expect.stringContaining("absolute local filesystem path"),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { goBinarySchema } from "../../src/domain/go/goBinary.js";
|
||||
import { evidenceResultOf } from "../../src/contracts/toolOutputSchemaPrimitives.js";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { expect } from "vitest";
|
||||
import { toolContract } from "../../src/contracts/toolContracts.js";
|
||||
import { machoImageWithUnknownCommand } from "../../src/artifacts/apple/MachoImage.fixture.js";
|
||||
import {
|
||||
createGoBinaryByteStringFixture,
|
||||
@@ -16,9 +17,69 @@ import {
|
||||
} from "../fixtures/go/image.js";
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../support/cli/cliFixture.js";
|
||||
import { parseEvidence } from "../../src/domain/evidence.js";
|
||||
import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js";
|
||||
import { parseMcpToolError } from "../fixtures/mcpToolError.js";
|
||||
|
||||
cliTest(
|
||||
"delivers a Go CLI summary with its unchanged complete source bundle",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-go-cli-summary-");
|
||||
const path = join(root, "application.elf");
|
||||
const output = join(root, "source.json");
|
||||
await writeFile(path, createGoBinaryFixture().bytes);
|
||||
const complete = await cli.run({
|
||||
arguments: ["inspect-go-binary", path, "--json"],
|
||||
});
|
||||
expect(complete.exitCode, complete.stderr).toBe(0);
|
||||
const full = evidenceResultOf(goBinarySchema).parse(complete.json);
|
||||
const summary = await cli.run({
|
||||
arguments: [
|
||||
"inspect-go-binary",
|
||||
path,
|
||||
"--detail",
|
||||
"summary",
|
||||
"--evidence_output",
|
||||
output,
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
expect(summary.exitCode, summary.stderr).toBe(0);
|
||||
expect(summary.json).toMatchObject({
|
||||
summary: {
|
||||
normalized_result: {
|
||||
kind: "summary",
|
||||
parent_evidence_id: full.evidence_id,
|
||||
source_coverage: { runtime_dependencies: "unknown" },
|
||||
},
|
||||
},
|
||||
});
|
||||
const bundle = JSON.parse(await readFile(output, "utf8")) as {
|
||||
records: unknown[];
|
||||
};
|
||||
expect(
|
||||
bundle.records
|
||||
.map((record) => parseEvidence(record))
|
||||
.find((record) => record.evidence_id === full.evidence_id),
|
||||
).toEqual(full);
|
||||
const refused = await cli.run({
|
||||
arguments: [
|
||||
"inspect-go-binary",
|
||||
join(root, "missing"),
|
||||
"--detail",
|
||||
"summary",
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
expect(refused.exitCode).toBe(1);
|
||||
expect(refused.json).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: { issues: [{ path: ["evidence_output"] }] },
|
||||
});
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"bounds overlapping ELF scans through CLI and keeps MCP responsive",
|
||||
async ({ cli }) => {
|
||||
@@ -63,9 +124,7 @@ cliTest(
|
||||
arguments: ["inspect-go-binary", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
const evidence = toolContract("inspect_go_binary").outputSchema.parse(
|
||||
result.json,
|
||||
);
|
||||
const evidence = evidenceResultOf(goBinarySchema).parse(result.json);
|
||||
expect(evidence.normalized_result.build_info).not.toBeNull();
|
||||
infos.push(evidence.normalized_result.build_info);
|
||||
const response = await call("inspect_go_binary", { path });
|
||||
@@ -101,9 +160,7 @@ for (const bits of [32, 64] as const) {
|
||||
arguments: ["inspect-go-binary", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
const evidence = toolContract("inspect_go_binary").outputSchema.parse(
|
||||
result.json,
|
||||
);
|
||||
const evidence = evidenceResultOf(goBinarySchema).parse(result.json);
|
||||
expect(evidence.subject?.digest.sha256).toBe(
|
||||
createHash("sha256").update(bytes).digest("hex"),
|
||||
);
|
||||
@@ -168,9 +225,7 @@ cliTest(
|
||||
}
|
||||
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
const evidence = toolContract("inspect_go_binary").outputSchema.parse(
|
||||
result.json,
|
||||
);
|
||||
const evidence = evidenceResultOf(goBinarySchema).parse(result.json);
|
||||
expect(evidence.normalized_result).toMatchObject({
|
||||
format: "macho",
|
||||
bits: fixture.bits,
|
||||
@@ -228,9 +283,7 @@ cliTest(
|
||||
arguments: ["inspect-go-binary", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
const evidence = toolContract("inspect_go_binary").outputSchema.parse(
|
||||
result.json,
|
||||
);
|
||||
const evidence = evidenceResultOf(goBinarySchema).parse(result.json);
|
||||
expect(evidence.normalized_result.build_info).toEqual(
|
||||
fixture.expectedBuildInfo,
|
||||
);
|
||||
@@ -290,9 +343,7 @@ for (const format of ["elf", "pe", "macho"] as const) {
|
||||
cwd: root,
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
const evidence = toolContract("inspect_go_binary").outputSchema.parse(
|
||||
result.json,
|
||||
);
|
||||
const evidence = evidenceResultOf(goBinarySchema).parse(result.json);
|
||||
expect(evidence.raw_result).toBeNull();
|
||||
expect(evidence.subject?.local_path).toBe(path);
|
||||
expect(evidence.subject?.digest.sha256).toBe(
|
||||
@@ -362,9 +413,7 @@ cliTest("preserves MIPS identity in Go CLI Evidence", async ({ cli }) => {
|
||||
arguments: ["inspect-go-binary", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
const evidence = toolContract("inspect_go_binary").outputSchema.parse(
|
||||
result.json,
|
||||
);
|
||||
const evidence = evidenceResultOf(goBinarySchema).parse(result.json);
|
||||
expect(evidence.subject?.architecture).toBe("mips");
|
||||
expect(evidence.normalized_result).toMatchObject({
|
||||
architecture: "mips",
|
||||
@@ -383,7 +432,7 @@ cliTest(
|
||||
arguments: ["inspect-go-binary", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
const report = toolContract("inspect_go_binary").outputSchema.parse(
|
||||
const report = evidenceResultOf(goBinarySchema).parse(
|
||||
result.json,
|
||||
).normalized_result;
|
||||
expect(report.build_info).toBeNull();
|
||||
@@ -407,9 +456,7 @@ cliTest(
|
||||
arguments: ["inspect-go-binary", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
const evidence = toolContract("inspect_go_binary").outputSchema.parse(
|
||||
result.json,
|
||||
);
|
||||
const evidence = evidenceResultOf(goBinarySchema).parse(result.json);
|
||||
expect(evidence.normalized_result.build_info?.module).toMatchObject({
|
||||
path: "example.com/tool/cmd/tool",
|
||||
main: null,
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { toolContract } from "../../src/contracts/toolContracts.js";
|
||||
import { parseEvidence } from "../../src/domain/evidence.js";
|
||||
import { readAnalysisSnapshot } from "../../src/application/binary/AnalysisSnapshotFiles.js";
|
||||
import { APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE } from "../../src/native/NativeMacOSProviderMetadata.js";
|
||||
import {
|
||||
CPU,
|
||||
machoImage,
|
||||
} from "../../src/artifacts/apple/MachoImage.fixture.js";
|
||||
import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js";
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../support/cli/cliFixture.js";
|
||||
|
||||
cliTest.skipIf(process.platform !== "darwin")(
|
||||
"retains and reopens profileless native dispatch workflow snapshots through CLI and stdio MCP",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-native-dispatch-snapshot-");
|
||||
const target = join(root, "minimal.macho");
|
||||
const cliSnapshot = join(root, "cli-snapshot.json");
|
||||
const mcpSnapshot = join(root, "mcp-snapshot.json");
|
||||
const bytes = machoImage({ cpu: CPU.arm64, commands: [] });
|
||||
await writeFile(target, bytes);
|
||||
const isolatedProviderEnvironment = {
|
||||
PATH: process.env.PATH ?? "",
|
||||
REA_ANALYSIS_PROVIDER: "auto",
|
||||
HOPPER_LAUNCHER_PATH: "/rea-unconfigured-deep-provider/hopper",
|
||||
GHIDRA_INSTALL_DIR: "/rea-unconfigured-deep-provider/ghidra",
|
||||
JAVA_HOME: "/rea-unconfigured-deep-provider/java",
|
||||
REA_IDA_MCP_CONFIG: "/rea-unconfigured-deep-provider/ida-mcp.json",
|
||||
REA_KUNA_COMMAND: "/rea-unconfigured-deep-provider/kuna",
|
||||
REA_KUNA_CACHE_DIR: "/rea-unconfigured-deep-provider/kuna-cache",
|
||||
};
|
||||
|
||||
const cliArguments = [
|
||||
"inspect-native-dispatch-metadata",
|
||||
target,
|
||||
"--snapshot",
|
||||
cliSnapshot,
|
||||
"--json",
|
||||
];
|
||||
const firstCli = await cli.run({
|
||||
arguments: cliArguments,
|
||||
environment: isolatedProviderEnvironment,
|
||||
});
|
||||
expect(firstCli.exitCode, firstCli.stderr).toBe(0);
|
||||
const cliEvidence = toolContract(
|
||||
"inspect_native_dispatch_metadata",
|
||||
).outputSchema.parse(firstCli.json);
|
||||
const cliSaved = await readAnalysisSnapshot(cliSnapshot);
|
||||
if (!cliSaved.ok) throw cliSaved.error;
|
||||
expect(cliSaved.value.binding.analysis_profile).toEqual(
|
||||
APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE,
|
||||
);
|
||||
expect(cliSaved.value.entries).toEqual([]);
|
||||
expect(cliSaved.value.workflow_entries).toHaveLength(1);
|
||||
const replayCli = await cli.run({
|
||||
arguments: cliArguments,
|
||||
environment: isolatedProviderEnvironment,
|
||||
});
|
||||
expect(replayCli.exitCode, replayCli.stderr).toBe(0);
|
||||
expect(
|
||||
toolContract("inspect_native_dispatch_metadata").outputSchema.parse(
|
||||
replayCli.json,
|
||||
),
|
||||
).toEqual(cliEvidence);
|
||||
const changedLimitCli = await cli.run({
|
||||
arguments: [...cliArguments, "--max-records", "1"],
|
||||
environment: isolatedProviderEnvironment,
|
||||
});
|
||||
expect(changedLimitCli.exitCode, changedLimitCli.stderr).toBe(0);
|
||||
const changedLimitEvidence = parseEvidence(changedLimitCli.json);
|
||||
expect(changedLimitEvidence.parameters.max_records).toBe(1);
|
||||
expect(changedLimitEvidence.evidence_id).not.toBe(cliEvidence.evidence_id);
|
||||
const changedLimitSnapshot = await readAnalysisSnapshot(cliSnapshot);
|
||||
if (!changedLimitSnapshot.ok) throw changedLimitSnapshot.error;
|
||||
expect(changedLimitSnapshot.value.workflow_entries).toHaveLength(2);
|
||||
|
||||
const { client, call } = await connectLocalToolsMcp(
|
||||
isolatedProviderEnvironment,
|
||||
);
|
||||
const opened = await call("open_binary", { path: target });
|
||||
expect(opened.isError, JSON.stringify(opened)).not.toBe(true);
|
||||
const firstMcp = await call("inspect_native_dispatch_metadata", {
|
||||
max_records: 100,
|
||||
});
|
||||
expect(firstMcp.isError, JSON.stringify(firstMcp)).not.toBe(true);
|
||||
const mcpEvidence = parseEvidence(firstMcp.structuredContent);
|
||||
const closed = await call("close_binary", {
|
||||
snapshot_path: mcpSnapshot,
|
||||
overwrite: true,
|
||||
});
|
||||
expect(closed.isError, JSON.stringify(closed)).not.toBe(true);
|
||||
const mcpSaved = await readAnalysisSnapshot(mcpSnapshot);
|
||||
if (!mcpSaved.ok) throw mcpSaved.error;
|
||||
expect(mcpSaved.value.binding.analysis_profile).toEqual(
|
||||
APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE,
|
||||
);
|
||||
expect(mcpSaved.value.entries).toEqual([]);
|
||||
expect(mcpSaved.value.workflow_entries).toHaveLength(1);
|
||||
|
||||
const reopened = await call("open_binary", {
|
||||
path: target,
|
||||
snapshot_path: mcpSnapshot,
|
||||
});
|
||||
expect(reopened.isError, JSON.stringify(reopened)).not.toBe(true);
|
||||
const reobserved = await call("inspect_native_dispatch_metadata", {
|
||||
max_records: 100,
|
||||
});
|
||||
expect(reobserved.isError, JSON.stringify(reobserved)).not.toBe(true);
|
||||
expect(parseEvidence(reobserved.structuredContent)).toEqual(mcpEvidence);
|
||||
expect(await readFile(target)).toEqual(Buffer.from(bytes));
|
||||
await client.ping();
|
||||
},
|
||||
30000,
|
||||
);
|
||||
@@ -164,6 +164,38 @@ cliTest("reports malformed zlib as invalid input", async ({ cli }) => {
|
||||
});
|
||||
});
|
||||
|
||||
cliTest(
|
||||
"refuses an oversized dependency section with fixed-budget advice",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-rust-cli-section-");
|
||||
const path = join(root, "oversized-section.elf");
|
||||
// Keep the ELF headers and declare a file-backed .dep-v0 one byte above
|
||||
// the fixed 1 MiB compressed-section budget.
|
||||
const limit = 1024 * 1024;
|
||||
const bytes = Buffer.alloc(1024 + limit + 1);
|
||||
createRustImage({ format: "elf" }).copy(bytes);
|
||||
bytes.writeBigUInt64LE(BigInt(limit + 1), 384 + 32);
|
||||
await writeFile(path, bytes);
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-rust-binary", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.json).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
retryable: false,
|
||||
remediation: { action: expect.stringContaining("maximum_bytes") },
|
||||
details: {
|
||||
resource: "memory",
|
||||
reported_limits: { maximum_bytes: 1024 * 1024 },
|
||||
},
|
||||
});
|
||||
// The section budget is fixed; worker or host memory cannot admit it.
|
||||
expect(result.json).not.toMatchObject({
|
||||
remediation: { action: expect.stringMatching(/sufficient memory/iu) },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"releases a failed Rust descriptor close before projecting the CLI failure",
|
||||
async ({ cli }) => {
|
||||
|
||||
@@ -557,70 +557,75 @@ captureTest(
|
||||
},
|
||||
);
|
||||
|
||||
captureTest(
|
||||
"keeps malformed gRPC-web trailers partial through CLI and MCP without changing capture bytes",
|
||||
async ({ cli, mcp }) => {
|
||||
const trailerCases = [
|
||||
["valid", "grpc-status", "0", "complete"],
|
||||
["uppercase", "Grpc-Status", "0", "partial"],
|
||||
["nul", "grpc-status", "0\u0000bad", "partial"],
|
||||
["lf", "grpc-status", "0\nbad", "partial"],
|
||||
["cr", "grpc-status", "0\rbad", "partial"],
|
||||
["vt", "grpc-status", "0\u000bbad", "partial"],
|
||||
["del", "grpc-status", "0\u007fbad", "partial"],
|
||||
] as const;
|
||||
|
||||
captureTest.for(
|
||||
(["grpc-web", "grpc-web-text"] as const).flatMap((framing) =>
|
||||
trailerCases.map(([name, headerName, value, status]) => ({
|
||||
framing,
|
||||
name,
|
||||
headerName,
|
||||
value,
|
||||
status,
|
||||
})),
|
||||
),
|
||||
)(
|
||||
"preserves $framing trailer case $name through CLI and MCP without changing capture bytes",
|
||||
async ({ framing, name, headerName, value, status }, { cli, mcp }) => {
|
||||
const root = await createTestTempDirectory("rea-grpc-web-trailers-");
|
||||
for (const framing of ["grpc-web", "grpc-web-text"] as const)
|
||||
for (const [name, headerName, value, status] of [
|
||||
["valid", "grpc-status", "0", "complete"],
|
||||
["uppercase", "Grpc-Status", "0", "partial"],
|
||||
["nul", "grpc-status", "0\u0000bad", "partial"],
|
||||
["lf", "grpc-status", "0\nbad", "partial"],
|
||||
["cr", "grpc-status", "0\rbad", "partial"],
|
||||
["vt", "grpc-status", "0\u000bbad", "partial"],
|
||||
["del", "grpc-status", "0\u007fbad", "partial"],
|
||||
] as const) {
|
||||
const path = join(root, `${framing}-${name}.har`);
|
||||
const trailer = Buffer.from(`${headerName}: ${value}\r\n`);
|
||||
const body = Buffer.concat([
|
||||
protobufFrame(Buffer.from([8, 1])),
|
||||
protobufFrame(trailer, 128),
|
||||
]);
|
||||
const original = protobufHar(
|
||||
framing === "grpc-web-text"
|
||||
? Buffer.from(body.toString("base64"))
|
||||
: body,
|
||||
`application/${framing}`,
|
||||
);
|
||||
await writeFile(path, original);
|
||||
const response = await cli.run({
|
||||
arguments: [
|
||||
"inspect-web-network-capture",
|
||||
path,
|
||||
"har",
|
||||
"--record",
|
||||
"0",
|
||||
"--protobuf-framing",
|
||||
"auto",
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
expect(response.exitCode, response.stdout + response.stderr).toBe(0);
|
||||
const evidence = parseEvidence(response.json);
|
||||
const report = webNetworkCaptureSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
expect(report.protobuf_coverage?.status, name).toBe(status);
|
||||
expect(report.records[0]?.protobuf?.[0]).toMatchObject({
|
||||
status,
|
||||
framing,
|
||||
examined_bytes: body.length,
|
||||
frames: [
|
||||
{ kind: "data", status: "complete", examined_bytes: 2 },
|
||||
{ kind: "trailer", status, examined_bytes: trailer.length },
|
||||
],
|
||||
fields: [{ number: 1, wire_type: "varint", value: "1" }],
|
||||
});
|
||||
const reply = await mcp.call("inspect_web_network_capture", {
|
||||
capture_path: path,
|
||||
format: "har",
|
||||
record_ordinals: [0],
|
||||
protobuf: { framing: "auto" },
|
||||
});
|
||||
expect(reply.isError, JSON.stringify(reply)).not.toBe(true);
|
||||
expect(reply.structuredContent).toEqual(response.json);
|
||||
expect(await readFile(path, "utf8")).toBe(original);
|
||||
}
|
||||
const path = join(root, `${framing}-${name}.har`);
|
||||
const trailer = Buffer.from(`${headerName}: ${value}\r\n`);
|
||||
const body = Buffer.concat([
|
||||
protobufFrame(Buffer.from([8, 1])),
|
||||
protobufFrame(trailer, 128),
|
||||
]);
|
||||
const original = protobufHar(
|
||||
framing === "grpc-web-text" ? Buffer.from(body.toString("base64")) : body,
|
||||
`application/${framing}`,
|
||||
);
|
||||
await writeFile(path, original);
|
||||
const response = await cli.run({
|
||||
arguments: [
|
||||
"inspect-web-network-capture",
|
||||
path,
|
||||
"har",
|
||||
"--record",
|
||||
"0",
|
||||
"--protobuf-framing",
|
||||
"auto",
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
expect(response.exitCode, response.stdout + response.stderr).toBe(0);
|
||||
const evidence = parseEvidence(response.json);
|
||||
const report = webNetworkCaptureSchema.parse(evidence.normalized_result);
|
||||
expect(report.protobuf_coverage?.status, name).toBe(status);
|
||||
expect(report.records[0]?.protobuf?.[0]).toMatchObject({
|
||||
status,
|
||||
framing,
|
||||
examined_bytes: body.length,
|
||||
frames: [
|
||||
{ kind: "data", status: "complete", examined_bytes: 2 },
|
||||
{ kind: "trailer", status, examined_bytes: trailer.length },
|
||||
],
|
||||
fields: [{ number: 1, wire_type: "varint", value: "1" }],
|
||||
});
|
||||
const reply = await mcp.call("inspect_web_network_capture", {
|
||||
capture_path: path,
|
||||
format: "har",
|
||||
record_ordinals: [0],
|
||||
protobuf: { framing: "auto" },
|
||||
});
|
||||
expect(reply.isError, JSON.stringify(reply)).not.toBe(true);
|
||||
expect(reply.structuredContent).toEqual(response.json);
|
||||
expect(await readFile(path, "utf8")).toBe(original);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,64 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { nativeDispatchMetadataResultSchema } from "../../../src/domain/native/objcSwiftMetadata.js";
|
||||
|
||||
import { objcRecordBudgetMacho } from "../../fixtures/objcRecordBudgetMacho.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
describe.skipIf(process.platform !== "darwin")(
|
||||
"native dispatch declaration-local record budget",
|
||||
() => {
|
||||
cliTest.for([["protocol"], ["class"]] as const)(
|
||||
"marks an omitted $0 method list partial in public CLI output",
|
||||
async ([owner], { cli }) => {
|
||||
const directory = await createTestTempDirectory("rea-objc-budget-");
|
||||
const path = join(directory, "fixture.macho");
|
||||
await writeFile(path, objcRecordBudgetMacho(owner));
|
||||
const output = await cli.run({
|
||||
arguments: [
|
||||
"inspect-native-dispatch-metadata",
|
||||
path,
|
||||
"--max-records",
|
||||
"1",
|
||||
"--json",
|
||||
],
|
||||
environment: { REA_LOG_LEVEL: "silent" },
|
||||
});
|
||||
expect(output.exitCode, `${output.stdout}\n${output.stderr}`).toBe(0);
|
||||
const result = nativeDispatchMetadataResultSchema.parse(
|
||||
parseEvidence(output.json).normalized_result,
|
||||
).result;
|
||||
if (owner === "protocol") {
|
||||
expect(result?.objc_protocol_records).toMatchObject([
|
||||
{
|
||||
name: "FixtureProtocol",
|
||||
methods: [],
|
||||
decode: { status: "partial", reason: "max_records_reached" },
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
expect(result?.objc_classes).toMatchObject([
|
||||
{
|
||||
name: "FixtureClass",
|
||||
methods: [],
|
||||
decode: { status: "partial", reason: "max_records_reached" },
|
||||
},
|
||||
]);
|
||||
}
|
||||
expect(result?.coverage).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
facet: "objc_class_method_ivar_metadata",
|
||||
status: "partial",
|
||||
reason: expect.stringContaining("max_records_reached"),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
workflowAnalysisProfile,
|
||||
} from "../../../src/application/InvestigationProviders.js";
|
||||
import {
|
||||
analysisQueryId,
|
||||
createAnalysisSnapshotWorkflowEntry,
|
||||
type AnalysisSnapshot,
|
||||
} from "../../../src/domain/analysisSnapshot.js";
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
createTestProviderRouter,
|
||||
} from "../../fixtures/binarySession.js";
|
||||
import { BinarySession } from "../../../src/application/binary/BinarySession.js";
|
||||
import { isProfilelessNativeDispatchSnapshot } from "../../../src/application/binary/ProfilelessNativeWorkflowSnapshot.js";
|
||||
import { OwnedStagedOutput } from "../../../src/filesystem/OwnedStagedOutput.js";
|
||||
import { ProviderAdapterError } from "../../../src/domain/providerAdapterError.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
@@ -46,6 +48,7 @@ import {
|
||||
CAPABILITIES as GHIDRA_CAPABILITIES,
|
||||
GHIDRA_PROVIDER_IDENTITY,
|
||||
} from "../../../src/ghidra/GhidraProviderCapabilities.js";
|
||||
import { APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE } from "../../../src/native/NativeMacOSProviderMetadata.js";
|
||||
|
||||
const IDENTITY = {
|
||||
id: "snapshot-fixture",
|
||||
@@ -1039,3 +1042,259 @@ describe("workflow snapshot profile and cancellation binding", () => {
|
||||
expect(fixture.starts).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
const expectProfilelessWorkflowSnapshotRejections = async (
|
||||
path: string,
|
||||
provider: AnalysisProvider,
|
||||
snapshot: AnalysisSnapshot,
|
||||
): Promise<void> => {
|
||||
const session = createTestBinarySession(provider);
|
||||
const opened = await session.open(path);
|
||||
if (!opened.ok) throw opened.error;
|
||||
const readerIdentity = APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE.provider;
|
||||
const wrongReader = structuredClone(snapshot);
|
||||
const otherReaderProfile = createAnalysisProfile(
|
||||
{ ...readerIdentity, version: "apple-metadata-reader-older" },
|
||||
{
|
||||
operation: "inspect_native_dispatch_metadata",
|
||||
reader: "apple-dispatch-metadata",
|
||||
revision: 0,
|
||||
},
|
||||
);
|
||||
wrongReader.binding = {
|
||||
provider: otherReaderProfile.provider,
|
||||
analysis_profile: otherReaderProfile,
|
||||
};
|
||||
expect(
|
||||
isProfilelessNativeDispatchSnapshot(
|
||||
wrongReader,
|
||||
opened.value,
|
||||
APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE,
|
||||
),
|
||||
).toBe(false);
|
||||
const rejectedReader = session.importAnalysisSnapshot(wrongReader);
|
||||
expect(rejectedReader.ok).toBe(false);
|
||||
if (!rejectedReader.ok)
|
||||
expect(rejectedReader.error.message).toContain("profile_mismatch");
|
||||
|
||||
const wrongOperation = structuredClone(snapshot);
|
||||
wrongOperation.workflow_entries[0]!.operation = "inspect_native_api";
|
||||
wrongOperation.workflow_entries[0]!.query_id = analysisQueryId(
|
||||
wrongOperation.target,
|
||||
wrongOperation.binding,
|
||||
"inspect_native_api",
|
||||
wrongOperation.workflow_entries[0]!.parameters,
|
||||
);
|
||||
expect(
|
||||
isProfilelessNativeDispatchSnapshot(
|
||||
wrongOperation,
|
||||
opened.value,
|
||||
APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE,
|
||||
),
|
||||
).toBe(false);
|
||||
const rejectedOperation = session.importAnalysisSnapshot(wrongOperation);
|
||||
expect(rejectedOperation.ok).toBe(false);
|
||||
if (!rejectedOperation.ok)
|
||||
expect(rejectedOperation.error.message).toContain("profile_mismatch");
|
||||
|
||||
const mixedOperation = "inspect_native_api";
|
||||
const mixedProfile = workflowAnalysisProfile(
|
||||
APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE,
|
||||
mixedOperation,
|
||||
);
|
||||
const mixedSnapshot = {
|
||||
...snapshot,
|
||||
workflow_entries: [
|
||||
...snapshot.workflow_entries,
|
||||
createAnalysisSnapshotWorkflowEntry({
|
||||
target: snapshot.target,
|
||||
binding: snapshot.binding,
|
||||
operation: mixedOperation,
|
||||
parameters: {},
|
||||
execution: {
|
||||
result: null,
|
||||
rawResult: null,
|
||||
provider: mixedProfile.provider,
|
||||
analysisProfile: mixedProfile,
|
||||
limitations: [],
|
||||
locations: [],
|
||||
subject: null,
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
const rejectedMixed = session.importAnalysisSnapshot(mixedSnapshot);
|
||||
expect(
|
||||
isProfilelessNativeDispatchSnapshot(
|
||||
mixedSnapshot as AnalysisSnapshot,
|
||||
opened.value,
|
||||
APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(rejectedMixed.ok).toBe(false);
|
||||
if (!rejectedMixed.ok)
|
||||
expect(rejectedMixed.error.message).toContain("profile_mismatch");
|
||||
|
||||
const deepProfile = createAnalysisProfile(
|
||||
{ id: "ghidra", name: "Ghidra", version: "fixture-version" },
|
||||
{ fixture: "deep-provider-only" },
|
||||
);
|
||||
const deepWorkflowProfile = workflowAnalysisProfile(
|
||||
deepProfile,
|
||||
"inspect_native_dispatch_metadata",
|
||||
);
|
||||
const originalWorkflow = snapshot.workflow_entries[0]!;
|
||||
const deepSnapshot = {
|
||||
...snapshot,
|
||||
binding: {
|
||||
provider: deepProfile.provider,
|
||||
analysis_profile: deepProfile,
|
||||
},
|
||||
workflow_entries: [
|
||||
createAnalysisSnapshotWorkflowEntry({
|
||||
target: snapshot.target,
|
||||
binding: {
|
||||
provider: deepProfile.provider,
|
||||
analysis_profile: deepProfile,
|
||||
},
|
||||
operation: "inspect_native_dispatch_metadata",
|
||||
parameters: originalWorkflow.parameters,
|
||||
execution: {
|
||||
result: originalWorkflow.execution.result,
|
||||
rawResult: originalWorkflow.execution.raw_result,
|
||||
provider: deepWorkflowProfile.provider,
|
||||
analysisProfile: deepWorkflowProfile,
|
||||
limitations: originalWorkflow.execution.limitations,
|
||||
...(originalWorkflow.execution.relationship_coverage === null
|
||||
? {}
|
||||
: {
|
||||
relationshipCoverage:
|
||||
originalWorkflow.execution.relationship_coverage,
|
||||
}),
|
||||
locations: originalWorkflow.execution.locations,
|
||||
subject:
|
||||
originalWorkflow.execution.subject === null
|
||||
? null
|
||||
: {
|
||||
path: originalWorkflow.execution.subject.path,
|
||||
sha256: originalWorkflow.execution.subject.sha256,
|
||||
format: originalWorkflow.execution.subject.format,
|
||||
...(originalWorkflow.execution.subject.architecture === null
|
||||
? {}
|
||||
: {
|
||||
architecture:
|
||||
originalWorkflow.execution.subject.architecture,
|
||||
}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
} as AnalysisSnapshot;
|
||||
const rejectedDeepOnly = session.importAnalysisSnapshot(deepSnapshot);
|
||||
expect(rejectedDeepOnly.ok).toBe(false);
|
||||
if (!rejectedDeepOnly.ok)
|
||||
expect(rejectedDeepOnly.error.message).toContain("profile_mismatch");
|
||||
};
|
||||
|
||||
it("retains a native dispatch workflow snapshot without a deep provider profile", async () => {
|
||||
const fixture = await createSnapshotReplayFixture(
|
||||
"rea-native-dispatch-workflow-",
|
||||
);
|
||||
const identity: ProviderIdentity = {
|
||||
id: "native-macos",
|
||||
name: "macOS native inspection utilities",
|
||||
version: null,
|
||||
};
|
||||
const capabilities: CapabilityDescriptor[] = [
|
||||
{
|
||||
provider: identity,
|
||||
operation: "inspect_native_dispatch_metadata",
|
||||
analysisProfile: APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE,
|
||||
available: true,
|
||||
reason: null,
|
||||
cachePolicy: "snapshot",
|
||||
effects: {
|
||||
mutatesArtifact: false,
|
||||
launchesProcess: false,
|
||||
mayShowUi: false,
|
||||
mayAccessNetwork: false,
|
||||
mayWriteFilesystem: false,
|
||||
changesPermissions: false,
|
||||
requiresRoot: false,
|
||||
},
|
||||
limitations: [],
|
||||
},
|
||||
];
|
||||
const readerIdentity = APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE.provider;
|
||||
let dispatchCalls = 0;
|
||||
const provider: AnalysisProvider = {
|
||||
identity: () => identity,
|
||||
capabilities: () => capabilities,
|
||||
createClient: () => ({
|
||||
execute: async (operation) =>
|
||||
operation === "health"
|
||||
? ok(createAnalysisExecution(null, identity))
|
||||
: operation === "inspect_native_dispatch_metadata"
|
||||
? ok(
|
||||
(dispatchCalls++,
|
||||
createAnalysisExecution(
|
||||
{
|
||||
target_sha256: null,
|
||||
provider: {
|
||||
id: identity.id,
|
||||
name: readerIdentity.name,
|
||||
version: readerIdentity.version,
|
||||
},
|
||||
analysis_profile_digest:
|
||||
APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE.digest,
|
||||
result: { db_save_result: null },
|
||||
},
|
||||
readerIdentity,
|
||||
{
|
||||
analysisProfile: APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE,
|
||||
},
|
||||
)),
|
||||
)
|
||||
: err(new ProviderAdapterError(identity.id, operation)),
|
||||
close: async () => ok(null),
|
||||
}),
|
||||
};
|
||||
const dependencies = fixture.dependencies(provider);
|
||||
|
||||
const result = await runDirectAnalysis(
|
||||
dependencies,
|
||||
fixture.path,
|
||||
"inspect_native_dispatch_metadata",
|
||||
{ max_records: 100 },
|
||||
{ snapshotPath: fixture.snapshotPath },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
operation: "inspect_native_dispatch_metadata",
|
||||
});
|
||||
const saved = await readAnalysisSnapshot(fixture.snapshotPath);
|
||||
if (!saved.ok) throw saved.error;
|
||||
expect(saved.value.binding.analysis_profile).toEqual(
|
||||
APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE,
|
||||
);
|
||||
expect(saved.value.entries).toEqual([]);
|
||||
expect(saved.value.workflow_entries).toEqual([
|
||||
expect.objectContaining({ operation: "inspect_native_dispatch_metadata" }),
|
||||
]);
|
||||
|
||||
const replay = await runDirectAnalysis(
|
||||
dependencies,
|
||||
fixture.path,
|
||||
"inspect_native_dispatch_metadata",
|
||||
{ max_records: 100 },
|
||||
{ snapshotPath: fixture.snapshotPath },
|
||||
);
|
||||
expect(replay).toEqual(result);
|
||||
expect(dispatchCalls).toBe(1);
|
||||
|
||||
await expectProfilelessWorkflowSnapshotRejections(
|
||||
fixture.path,
|
||||
provider,
|
||||
saved.value,
|
||||
);
|
||||
});
|
||||
@@ -1245,7 +1245,15 @@ it.each(["identify_flutter_build", "inspect_dart_aot"] as const)(
|
||||
onTestFinished(() => provider.close());
|
||||
const result = await provider.execute({ operation, input: { path } });
|
||||
if (result.ok) throw new Error("ABI expansion must be refused");
|
||||
expect(projectAnalysisError(result.error).code).toBe("resource_constraint");
|
||||
const projected = projectAnalysisError(result.error);
|
||||
expect(projected).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
retryable: false,
|
||||
details: { reported_limits: { max_flutter_abis: 32 } },
|
||||
});
|
||||
// The inventory bound is fixed; worker or host memory cannot admit it.
|
||||
expect(projected.remediation.action).toContain("max_flutter_abis");
|
||||
expect(projected.remediation.action).not.toMatch(/sufficient memory/iu);
|
||||
expect(extract).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
+35
@@ -125,6 +125,41 @@ it("resolves erased wrappers through the composed application graph", async () =
|
||||
expect(parseJavaScriptSource(source, "app.mts")?.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "earlier",
|
||||
source: 'export const target = () => "ready", result = target();',
|
||||
resolved: true,
|
||||
},
|
||||
{
|
||||
name: "later",
|
||||
source: 'export const result = target(), target = () => "ready";',
|
||||
resolved: false,
|
||||
},
|
||||
])(
|
||||
"orders $name exported declarator reads in the composed application graph",
|
||||
async ({ source, resolved }) => {
|
||||
const analysis = await analyzeFiles(new Map([["app.mts", source]]));
|
||||
const calls = analysis.semantic_graph.relations.filter(
|
||||
({ relation }) => relation === "calls",
|
||||
);
|
||||
if (resolved) {
|
||||
expect(calls).toEqual([
|
||||
expect.objectContaining({ resolution: "resolved" }),
|
||||
]);
|
||||
} else {
|
||||
expect(calls.some(({ resolution }) => resolution === "resolved")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(analysis.semantic_graph.unknowns).toContainEqual(
|
||||
expect.objectContaining({
|
||||
relation_kinds: expect.arrayContaining(["calls"]),
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "path-value wrappers",
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
AnalysisResourceConstraintError,
|
||||
AnalysisUnsupportedTargetError,
|
||||
} from "../../../src/domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||
import {
|
||||
createGoBinaryDiagnosticFixtures,
|
||||
createGoBinaryFixture,
|
||||
@@ -67,6 +68,11 @@ it("preserves the actual build-info byte guard as a resource failure", async ()
|
||||
boundary: "build-info",
|
||||
maximum_bytes: 1024 * 1024,
|
||||
});
|
||||
const projected = projectAnalysisError(result.error);
|
||||
expect(projected.retryable).toBe(false);
|
||||
// The parser budget is fixed; worker or host memory cannot admit it.
|
||||
expect(projected.remediation.action).toContain("maximum_bytes");
|
||||
expect(projected.remediation.action).not.toMatch(/sufficient memory/iu);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
|
||||
@@ -1,13 +1,63 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
import { createInterface } from "node:readline";
|
||||
import { PassThrough, type Readable } from "node:stream";
|
||||
import { setImmediate as yieldToIo } from "node:timers/promises";
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisOutputError,
|
||||
} from "../../../src/domain/analysisErrorCore.js";
|
||||
import { JavaScriptAnalysisWorker } from "../../../src/javascript/analysis/JavaScriptAnalysisWorker.js";
|
||||
import {
|
||||
javaScriptWorkerMessageSchema,
|
||||
type JavaScriptWorkerMessage,
|
||||
} from "../../../src/javascript/analysis/JavaScriptAnalysisWorkerProtocol.js";
|
||||
import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js";
|
||||
import type { JavaScriptArtifactFile } from "../../../src/domain/javascript/javascriptArtifactFiles.js";
|
||||
|
||||
const observeWorkerFrames = (
|
||||
stdout: Readable | null | undefined,
|
||||
observe: (frame: JavaScriptWorkerMessage, beforeEof: boolean) => void,
|
||||
): void => {
|
||||
if (stdout === null || stdout === undefined)
|
||||
throw new Error("Expected fixture worker stdout pipe");
|
||||
// The runtime attaches its diagnostic and protocol readers together after
|
||||
// adopting the launch. Wait for that owner before starting a flowing reader.
|
||||
const attach = (event: string | symbol): void => {
|
||||
if (event !== "data") return;
|
||||
stdout.off("newListener", attach);
|
||||
createInterface({ input: stdout }).on("line", (line) =>
|
||||
observe(
|
||||
javaScriptWorkerMessageSchema.parse(JSON.parse(line)),
|
||||
!stdout.readableEnded,
|
||||
),
|
||||
);
|
||||
};
|
||||
stdout.on("newListener", attach);
|
||||
};
|
||||
|
||||
it("preserves buffered startup bytes until the runtime owns stdout", async () => {
|
||||
const stdout = new PassThrough();
|
||||
const frame = { kind: "ready", heap_limit_bytes: 1 } as const;
|
||||
const encoded = JSON.stringify(frame) + "\n";
|
||||
const observed: JavaScriptWorkerMessage[] = [];
|
||||
const owned: Buffer[] = [];
|
||||
observeWorkerFrames(stdout, (message) => observed.push(message));
|
||||
try {
|
||||
stdout.write(encoded);
|
||||
await yieldToIo();
|
||||
expect(observed).toEqual([]);
|
||||
stdout.on("data", (chunk: Buffer) => owned.push(chunk));
|
||||
stdout.end();
|
||||
await once(stdout, "end");
|
||||
expect(observed).toEqual([frame]);
|
||||
expect(Buffer.concat(owned).toString()).toBe(encoded);
|
||||
} finally {
|
||||
stdout.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
const sourceText = "export const observed = 1;";
|
||||
const file: JavaScriptArtifactFile = {
|
||||
path: "app.mjs",
|
||||
@@ -396,10 +446,11 @@ it("keeps a parsed terminal failure primary over startup-close abort", async ()
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"admits a valid complete response with static facts before EOF cancellation (%s)",
|
||||
"admits valid static facts and honors cancellation before terminal admission (%s)",
|
||||
async (abortAfterTerminal) => {
|
||||
const controller = new AbortController();
|
||||
const reason = { requested: "abort after valid terminal frame" };
|
||||
let abortedBeforeEof = false;
|
||||
const encodedStatic = JSON.stringify(staticCheckpointFacts);
|
||||
const worker = new JavaScriptAnalysisWorker(
|
||||
{ heapMb: 128, timeoutMs: 30_000 },
|
||||
@@ -411,10 +462,13 @@ it.each([false, true])(
|
||||
`${ready} const fs=require("node:fs"); const {createHash}=require("node:crypto"); const staticFacts=JSON.parse(${JSON.stringify(encodedStatic)}); let id; require("node:readline").createInterface({input:process.stdin}).on("line", line => { const request=JSON.parse(line); if(request.file){ id=request.id; const text=JSON.stringify(staticFacts); fs.writeFileSync(id+".static.json",text); process.stdout.write(JSON.stringify({kind:"static",id,bytes:Buffer.byteLength(text),sha256:createHash("sha256").update(text).digest("hex")})+"\\n"); } else { process.stdout.write(JSON.stringify({kind:"complete",id,descriptor:null})+"\\n",()=>process.exit(0)); } });`,
|
||||
],
|
||||
});
|
||||
if (abortAfterTerminal)
|
||||
launched.process.once("close", () =>
|
||||
queueMicrotask(() => controller.abort(reason)),
|
||||
);
|
||||
if (abortAfterTerminal) {
|
||||
observeWorkerFrames(launched.process.stdout, (frame, beforeEof) => {
|
||||
if (frame.kind !== "complete") return;
|
||||
abortedBeforeEof = beforeEof;
|
||||
controller.abort(reason);
|
||||
});
|
||||
}
|
||||
return launched;
|
||||
},
|
||||
);
|
||||
@@ -426,6 +480,7 @@ it.each([false, true])(
|
||||
);
|
||||
expect(controller.signal.aborted).toBe(abortAfterTerminal);
|
||||
if (abortAfterTerminal) {
|
||||
expect(abortedBeforeEof).toBe(true);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.error._tag).toBe("AnalysisCancelledError");
|
||||
@@ -1076,15 +1131,13 @@ it.each([
|
||||
...request,
|
||||
arguments: [
|
||||
"-e",
|
||||
`${ready} process.stdin.once("data", chunk => { const id=JSON.parse(String(chunk)).id; process.stdout.write(JSON.stringify({kind:"phase",id,phase:"parse"})+"\\n"); const failure=${JSON.stringify(failure)}; failure.kind="failure"; failure.id=id; setTimeout(() => process.stdout.write(JSON.stringify(failure)+"\\n"),20); setInterval(()=>{},1000); });`,
|
||||
`${ready} process.stdin.once("data", chunk => { const id=JSON.parse(String(chunk)).id; const phase={kind:"phase",id,phase:"parse"}; const failure={...${JSON.stringify(failure)},kind:"failure",id,message:"fixture failure delivered after caller cancellation"}; process.stdout.write(JSON.stringify(phase)+"\\n"+JSON.stringify(failure)+"\\n"); setInterval(()=>{},1000); });`,
|
||||
],
|
||||
});
|
||||
launched.process.stdout?.on("data", (chunk: Buffer | string) => {
|
||||
if (String(chunk).includes('"kind":"phase"')) controller.abort(null);
|
||||
if (
|
||||
controller.signal.aborted &&
|
||||
String(chunk).includes('"kind":"failure"')
|
||||
)
|
||||
observeWorkerFrames(launched.process.stdout, (frame) => {
|
||||
// This launcher observer precedes the worker's protocol reader.
|
||||
if (frame.kind === "phase") controller.abort(null);
|
||||
if (controller.signal.aborted && frame.kind === "failure")
|
||||
failureFrameSeenAfterAbort = true;
|
||||
});
|
||||
return launched;
|
||||
|
||||
@@ -158,3 +158,72 @@ describe("Swift classification through MCP", () => {
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Swift category uncertainty through MCP", () => {
|
||||
it("keeps unsupported nested Unicode names unresolved through MCP filters", async () => {
|
||||
const names = [
|
||||
"_$s6Nested8EnvelopeV007IhuqBAbO8describeSSyF",
|
||||
"_$s6Nested8EnvelopeV007jvrtDBlCAEycfC",
|
||||
"_$s6Nested8EnvelopeV007jvrtDBlCMa",
|
||||
];
|
||||
const procedures = [
|
||||
...names.map((name, index) => ({
|
||||
address: `0x${String(index + 1)}`,
|
||||
value: name,
|
||||
})),
|
||||
{ address: "0x10", value: "_$s6Nested8EnvelopeVMa" },
|
||||
{ address: "0x11", value: "_$s6Nested8EnvelopeV5InnerCMa" },
|
||||
{ address: "0x12", value: "_$s6Nested8EnvelopeV5InnerC6methodyyF" },
|
||||
];
|
||||
const client = await connect({
|
||||
execute: async (operation) =>
|
||||
ok(operation === "list_procedures" ? procedures : []),
|
||||
});
|
||||
|
||||
const all = jsonResult(
|
||||
await client.callTool({
|
||||
name: "analyze_swift_types",
|
||||
arguments: {},
|
||||
}),
|
||||
);
|
||||
expect(all).toMatchObject({
|
||||
total: 6,
|
||||
categories: {
|
||||
structs: {
|
||||
items: [{ name: "_$s6Nested8EnvelopeVMa" }],
|
||||
},
|
||||
classes: {
|
||||
items: [
|
||||
{ name: "_$s6Nested8EnvelopeV5InnerCMa" },
|
||||
{ name: "_$s6Nested8EnvelopeV5InnerC6methodyyF" },
|
||||
],
|
||||
},
|
||||
other: {
|
||||
items: names.map((name) => ({ name, mangled_names: [name] })),
|
||||
},
|
||||
},
|
||||
unclassified: names.map((name) => ({
|
||||
name,
|
||||
mangled_names: [name],
|
||||
reason: "category_not_decoded",
|
||||
})),
|
||||
});
|
||||
|
||||
for (const category of ["classes", "enums"] as const) {
|
||||
const filtered = jsonResult(
|
||||
await client.callTool({
|
||||
name: "analyze_swift_types",
|
||||
arguments: { category },
|
||||
}),
|
||||
);
|
||||
expect(filtered).toMatchObject({
|
||||
categories: { [category]: { count: category === "classes" ? 2 : 0 } },
|
||||
unclassified: names.map((name) => ({
|
||||
name,
|
||||
mangled_names: [name],
|
||||
reason: "category_not_decoded",
|
||||
})),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { goBinarySchema } from "../../../src/domain/go/goBinary.js";
|
||||
import type { Client } from "@modelcontextprotocol/client";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
@@ -6,7 +7,10 @@ import { expect } from "vitest";
|
||||
import { readAnalysisSnapshot } from "../../../src/application/binary/AnalysisSnapshotFiles.js";
|
||||
import { GoBinaryService } from "../../../src/application/go/GoBinaryService.js";
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import { expandToolAvailability } from "../../../src/contracts/toolOutputSchemaPrimitives.js";
|
||||
import {
|
||||
evidenceResultOf,
|
||||
expandToolAvailability,
|
||||
} from "../../../src/contracts/toolOutputSchemaPrimitives.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { GoBinaryProvider } from "../../../src/go/GoBinaryProvider.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
@@ -97,7 +101,7 @@ mcpTest(
|
||||
expect(inspected.isError, JSON.stringify(inspected.content)).not.toBe(
|
||||
true,
|
||||
);
|
||||
const evidence = toolContract("inspect_go_binary").outputSchema.parse(
|
||||
const evidence = evidenceResultOf(goBinarySchema).parse(
|
||||
inspected.structuredContent,
|
||||
);
|
||||
expect(evidence.subject?.architecture).toBe("mips");
|
||||
@@ -165,7 +169,7 @@ goMcpTest(
|
||||
arguments: { path },
|
||||
});
|
||||
expect(response.isError).not.toBe(true);
|
||||
const evidence = toolContract("inspect_go_binary").outputSchema.parse(
|
||||
const evidence = evidenceResultOf(goBinarySchema).parse(
|
||||
response.structuredContent,
|
||||
);
|
||||
expect(evidence.normalized_result.build_info).toEqual(
|
||||
@@ -256,7 +260,7 @@ goMcpTest(
|
||||
ajv.validate(outputSchema, response.structuredContent),
|
||||
JSON.stringify(ajv.errors),
|
||||
).toBe(true);
|
||||
const parsed = toolContract("inspect_go_binary").outputSchema.parse(
|
||||
const parsed = evidenceResultOf(goBinarySchema).parse(
|
||||
response.structuredContent,
|
||||
);
|
||||
const evidence = parseEvidence(parsed);
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
import { readFile, unlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect } from "vitest";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import type { AnySchema } from "ajv";
|
||||
import type { Client } from "@modelcontextprotocol/client";
|
||||
import {
|
||||
createEvidence,
|
||||
parseEvidence,
|
||||
type Evidence,
|
||||
} from "../../../src/domain/evidence.js";
|
||||
import { goBinarySchema } from "../../../src/domain/go/goBinary.js";
|
||||
import {
|
||||
analysisViewResultSchema,
|
||||
goBinarySummaryViewResultSchema,
|
||||
} from "../../../src/domain/analysisView/analysisView.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
import {
|
||||
createGoBinaryFixture,
|
||||
GO_MODULE_TEXT,
|
||||
} from "../../fixtures/go/image.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { mcpTest } from "../../support/mcp/mcpFixture.js";
|
||||
|
||||
type Operation = "inspect_go_binary" | "inspect_analysis_view";
|
||||
const goViewTest = mcpTest.extend<{
|
||||
goViews: {
|
||||
client: Client;
|
||||
root: string;
|
||||
path: string;
|
||||
bytes: Buffer;
|
||||
call: (
|
||||
name: Operation,
|
||||
input: Record<string, unknown>,
|
||||
) => Promise<Evidence>;
|
||||
};
|
||||
}>({
|
||||
goViews: async ({ mcp }, use) => {
|
||||
const root = await createTestTempDirectory("rea-go-views-");
|
||||
const path = join(root, "application");
|
||||
const fixture = createGoBinaryFixture({
|
||||
goVersion: "go1.26.0",
|
||||
moduleText:
|
||||
GO_MODULE_TEXT +
|
||||
"dep\texample.com/dependency\tv2.0.0\th1:second\nfuture\tunknown\n",
|
||||
});
|
||||
await writeFile(path, fixture.bytes);
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error("No disassembler for Go views");
|
||||
});
|
||||
try {
|
||||
const client = await mcp.connect(
|
||||
createServer({ kind: "session", session }),
|
||||
);
|
||||
const catalog = await client.listTools();
|
||||
const ajv = new Ajv2020({ strict: false });
|
||||
const schemas = new Map(
|
||||
catalog.tools
|
||||
.filter(
|
||||
(tool) =>
|
||||
tool.name === "inspect_go_binary" ||
|
||||
tool.name === "inspect_analysis_view",
|
||||
)
|
||||
.map((tool) => {
|
||||
const output = tool.outputSchema as AnySchema;
|
||||
expect(tool.outputSchema?.$schema).toBe(
|
||||
"https://json-schema.org/draft/2020-12/schema",
|
||||
);
|
||||
expect(ajv.validateSchema(output)).toBe(true);
|
||||
return [
|
||||
tool.name,
|
||||
{
|
||||
input: ajv.compile(tool.inputSchema),
|
||||
output: ajv.compile(output),
|
||||
},
|
||||
] as const;
|
||||
}),
|
||||
);
|
||||
const call = async (name: Operation, input: Record<string, unknown>) => {
|
||||
const schema = schemas.get(name)!;
|
||||
expect(schema.input(input), JSON.stringify(schema.input.errors)).toBe(
|
||||
true,
|
||||
);
|
||||
const reply = await client.callTool({ name, arguments: input });
|
||||
expect(reply.isError, JSON.stringify(reply)).not.toBe(true);
|
||||
expect(
|
||||
schema.output(reply.structuredContent),
|
||||
JSON.stringify(schema.output.errors),
|
||||
).toBe(true);
|
||||
return parseEvidence(reply.structuredContent);
|
||||
};
|
||||
await use({ client, root, path, bytes: fixture.bytes, call });
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const goPage = (evidence: Evidence) => {
|
||||
const view = analysisViewResultSchema.parse(evidence.normalized_result);
|
||||
if (view.kind !== "go") throw new Error("Expected a Go row page");
|
||||
return view;
|
||||
};
|
||||
|
||||
goViewTest(
|
||||
"rejects authenticated inline and file parents with conflicting architecture",
|
||||
async ({ goViews: { client, root, path, call } }) => {
|
||||
const complete = await call("inspect_go_binary", { path });
|
||||
const report = goBinarySchema.parse(complete.normalized_result);
|
||||
const conflicting = createEvidence(
|
||||
{
|
||||
path,
|
||||
sha256: report.artifact.sha256,
|
||||
format: "elf",
|
||||
architecture: "arm64",
|
||||
},
|
||||
{ id: "go-build-info", name: "Go metadata test fixture", version: "1" },
|
||||
{
|
||||
operation: "inspect_go_binary",
|
||||
parameters: { path },
|
||||
result: report,
|
||||
confidence: "observed",
|
||||
},
|
||||
);
|
||||
const evidencePath = join(root, "conflicting.json");
|
||||
await writeFile(evidencePath, JSON.stringify(conflicting));
|
||||
for (const source of [
|
||||
{ kind: "inline", evidence: conflicting },
|
||||
{ kind: "evidence-file", path: evidencePath },
|
||||
]) {
|
||||
for (const view of [
|
||||
{ kind: "summary" },
|
||||
{ kind: "go", collection: "dependencies" },
|
||||
]) {
|
||||
const reply = await client.callTool({
|
||||
name: "inspect_analysis_view",
|
||||
arguments: { source, view },
|
||||
});
|
||||
expect(parseMcpToolError(reply).error).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
operation: "inspect_analysis_view",
|
||||
issues: [{ path: ["source", "subject"], reason: "invalid_value" }],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
goViewTest(
|
||||
"retains exact Go rows and complete Evidence after source removal",
|
||||
async ({ goViews: { client, root, path, bytes, call } }) => {
|
||||
const complete = await call("inspect_go_binary", { path });
|
||||
const report = goBinarySchema.parse(complete.normalized_result);
|
||||
const summary = await call("inspect_go_binary", {
|
||||
path,
|
||||
detail: "summary",
|
||||
});
|
||||
expect(summary).toMatchObject({
|
||||
raw_result: null,
|
||||
normalized_result: {
|
||||
kind: "summary",
|
||||
parent_evidence_id: complete.evidence_id,
|
||||
source_coverage: {
|
||||
build_info_available: true,
|
||||
module_parse_complete: false,
|
||||
runtime_dependencies: "unknown",
|
||||
},
|
||||
summary: {
|
||||
build_info: {
|
||||
go_version: "go1.26.0",
|
||||
module: {
|
||||
available_counts: {
|
||||
dependencies: 2,
|
||||
settings: 3,
|
||||
unparsed_lines: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(await readFile(path)).toEqual(bytes);
|
||||
await unlink(path);
|
||||
const source = {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: complete.evidence_id,
|
||||
};
|
||||
const view = {
|
||||
kind: "go",
|
||||
collection: "dependencies",
|
||||
paths: ["example.com/dependency"],
|
||||
fields: ["version", "replacement"],
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
};
|
||||
const first = goPage(await call("inspect_analysis_view", { source, view }));
|
||||
expect(first.page.rows).toEqual([
|
||||
{
|
||||
index: 0,
|
||||
version: "v1.2.3",
|
||||
replacement: report.build_info!.module.dependencies[0]!.replacement,
|
||||
},
|
||||
]);
|
||||
expect(first.coverage).toMatchObject({
|
||||
total: 2,
|
||||
next_offset: 1,
|
||||
exhausted: false,
|
||||
});
|
||||
const second = goPage(
|
||||
await call("inspect_analysis_view", {
|
||||
source,
|
||||
view: { ...view, offset: 1 },
|
||||
}),
|
||||
);
|
||||
expect(second.page.rows).toEqual([
|
||||
{ index: 1, version: "v2.0.0", replacement: null },
|
||||
]);
|
||||
expect(second.coverage).toMatchObject({
|
||||
total: 2,
|
||||
next_offset: null,
|
||||
exhausted: true,
|
||||
});
|
||||
const bundlePath = join(root, "evidence.json");
|
||||
const exported = await client.callTool({
|
||||
name: "export_evidence_bundle",
|
||||
arguments: { path: bundlePath },
|
||||
});
|
||||
expect(exported.isError, JSON.stringify(exported)).not.toBe(true);
|
||||
const bundle = JSON.parse(await readFile(bundlePath, "utf8")) as {
|
||||
records: unknown[];
|
||||
};
|
||||
expect(
|
||||
bundle.records
|
||||
.map((record) => parseEvidence(record))
|
||||
.find((record) => record.evidence_id === complete.evidence_id),
|
||||
).toEqual(complete);
|
||||
await client.ping();
|
||||
},
|
||||
);
|
||||
|
||||
goViewTest(
|
||||
"intersects exact Go selections and keeps row identity with no selected fields",
|
||||
async ({ goViews: { client, path, call } }) => {
|
||||
const summary = goBinarySummaryViewResultSchema.parse(
|
||||
(await call("inspect_go_binary", { path, detail: "summary" }))
|
||||
.normalized_result,
|
||||
);
|
||||
const source = {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: summary.parent_evidence_id,
|
||||
};
|
||||
const identity = goPage(
|
||||
await call("inspect_analysis_view", {
|
||||
source,
|
||||
view: { kind: "go", collection: "dependencies", fields: [] },
|
||||
}),
|
||||
);
|
||||
expect(identity.page.rows).toEqual([{ index: 0 }, { index: 1 }]);
|
||||
for (const selection of [
|
||||
{ paths: [] },
|
||||
{ indices: [] },
|
||||
{ paths: ["other"], indices: [1] },
|
||||
{ offset: 99 },
|
||||
]) {
|
||||
const empty = goPage(
|
||||
await call("inspect_analysis_view", {
|
||||
source,
|
||||
view: { kind: "go", collection: "dependencies", ...selection },
|
||||
}),
|
||||
);
|
||||
expect(empty.page.rows).toEqual([]);
|
||||
expect(empty.coverage).toMatchObject({
|
||||
next_offset: null,
|
||||
exhausted: true,
|
||||
});
|
||||
}
|
||||
const selected = goPage(
|
||||
await call("inspect_analysis_view", {
|
||||
source,
|
||||
view: {
|
||||
kind: "go",
|
||||
collection: "dependencies",
|
||||
paths: ["example.com/dependency"],
|
||||
indices: [1],
|
||||
fields: ["version"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(selected.page.rows).toEqual([{ index: 1, version: "v2.0.0" }]);
|
||||
const setting = goPage(
|
||||
await call("inspect_analysis_view", {
|
||||
source,
|
||||
view: {
|
||||
kind: "go",
|
||||
collection: "settings",
|
||||
keys: ["GOOS"],
|
||||
fields: ["value"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(setting.page.rows).toEqual([{ index: 1, value: "linux" }]);
|
||||
expect(setting.coverage.total).toBe(1);
|
||||
const invalid = await client.callTool({
|
||||
name: "inspect_analysis_view",
|
||||
arguments: {
|
||||
source,
|
||||
view: {
|
||||
kind: "go",
|
||||
collection: "dependencies",
|
||||
fields: ["module_bytes_base64"],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(invalid.isError).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
mcpTest(
|
||||
"keeps unavailable Go metadata unknown in summaries and empty pages",
|
||||
async ({ mcp }) => {
|
||||
const root = await createTestTempDirectory("rea-go-absent-view-");
|
||||
const path = join(root, "application");
|
||||
await writeFile(path, createGoBinaryFixture({ omitBuildInfo: true }).bytes);
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error("No deep provider");
|
||||
});
|
||||
try {
|
||||
const client = await mcp.connect(
|
||||
createServer({ kind: "session", session }),
|
||||
);
|
||||
const reply = await client.callTool({
|
||||
name: "inspect_go_binary",
|
||||
arguments: { path, detail: "summary" },
|
||||
});
|
||||
expect(reply.isError, JSON.stringify(reply)).not.toBe(true);
|
||||
const summary = goBinarySummaryViewResultSchema.parse(
|
||||
parseEvidence(reply.structuredContent).normalized_result,
|
||||
);
|
||||
expect(summary).toMatchObject({
|
||||
summary: { build_info: null },
|
||||
source_coverage: {
|
||||
build_info_available: false,
|
||||
module_parse_complete: null,
|
||||
runtime_dependencies: "unknown",
|
||||
},
|
||||
});
|
||||
const source = {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: summary.parent_evidence_id,
|
||||
};
|
||||
const empty = await client.callTool({
|
||||
name: "inspect_analysis_view",
|
||||
arguments: { source, view: { kind: "go", collection: "dependencies" } },
|
||||
});
|
||||
expect(empty.isError, JSON.stringify(empty)).not.toBe(true);
|
||||
const page = goPage(parseEvidence(empty.structuredContent));
|
||||
expect(page).toMatchObject({
|
||||
page: { rows: [] },
|
||||
coverage: { total: 0 },
|
||||
source_coverage: {
|
||||
build_info_available: false,
|
||||
module_parse_complete: null,
|
||||
runtime_dependencies: "unknown",
|
||||
},
|
||||
});
|
||||
expect(page.unknowns.join(" ")).toContain("remain unknown");
|
||||
const incompatible = await client.callTool({
|
||||
name: "inspect_analysis_view",
|
||||
arguments: { source, view: { kind: "native", facet: "procedure" } },
|
||||
});
|
||||
expect(parseMcpToolError(incompatible).error.code).toBe(
|
||||
"invalid_request",
|
||||
);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
cliTest(
|
||||
"retains unknown timer delays and unrelated facts through public CLI and MCP",
|
||||
{ timeout: 60_000 },
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-timer-delay-");
|
||||
const source = [
|
||||
'export function greet() { return "hello"; }',
|
||||
"setTimeout(() => greet(), 1e999);",
|
||||
"setInterval(() => greet(), 1e309);",
|
||||
"setTimeout(() => greet(), 100);",
|
||||
"setTimeout(() => greet(), 1e308);",
|
||||
"setTimeout(() => greet(), Infinity);",
|
||||
"setImmediate(() => greet());",
|
||||
].join("\n");
|
||||
const path = join(root, "main.js");
|
||||
await writeFile(path, source);
|
||||
const digest = createHash("sha256").update(source).digest("hex");
|
||||
const cliResult = await cli.run({
|
||||
arguments: ["analyze-javascript-application", root, "--json"],
|
||||
});
|
||||
expect(cliResult.exitCode).toBe(0);
|
||||
const contract = toolContract("analyze_javascript_application");
|
||||
const cliEvidence = contract.outputSchema.parse(cliResult.json);
|
||||
const graph = javascriptApplicationAnalysisResultSchema.parse(
|
||||
cliEvidence.normalized_result,
|
||||
).semantic_graph;
|
||||
const timers = graph.nodes.filter(({ kind }) => kind === "timer");
|
||||
const pairs = timers.map(({ properties }) => [
|
||||
properties.method,
|
||||
properties.delay_milliseconds,
|
||||
]);
|
||||
const sorted = (values: unknown[]) =>
|
||||
values.map((value) => JSON.stringify(value)).sort();
|
||||
expect(sorted(pairs)).toEqual(
|
||||
sorted([
|
||||
["setTimeout", null],
|
||||
["setInterval", null],
|
||||
["setTimeout", 100],
|
||||
["setTimeout", 1e308],
|
||||
["setTimeout", null],
|
||||
["setImmediate", null],
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
graph.nodes.find(
|
||||
({ kind, label }) => kind === "function" && label === "greet",
|
||||
)?.identity,
|
||||
).toMatchObject({ module_path: "main.js", artifact_sha256: digest });
|
||||
for (const timer of timers) {
|
||||
expect(Object.hasOwn(timer.properties, "delay_milliseconds")).toBe(true);
|
||||
expect(timer.identity).toMatchObject({
|
||||
module_path: "main.js",
|
||||
artifact_sha256: digest,
|
||||
});
|
||||
}
|
||||
|
||||
const client = new Client({ name: "timer-delay-boundary", version: "1" });
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [resolve("scripts/rea.mjs"), "mcp"],
|
||||
cwd: process.cwd(),
|
||||
env: { PATH: process.env.PATH ?? "" },
|
||||
stderr: "pipe",
|
||||
});
|
||||
transport.stderr?.on("data", () => undefined);
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const reply = await client.callTool({
|
||||
name: contract.name,
|
||||
arguments: { input_path: root },
|
||||
});
|
||||
expect(reply.isError).not.toBe(true);
|
||||
const evidence = contract.outputSchema.parse(reply.structuredContent);
|
||||
expect(evidence.evidence_id).toBe(cliEvidence.evidence_id);
|
||||
const text = reply.content.find((item) => item.type === "text");
|
||||
if (text?.type !== "text") throw new Error("Missing inline Evidence");
|
||||
expect(
|
||||
contract.outputSchema.parse(JSON.parse(text.text)).evidence_id,
|
||||
).toBe(evidence.evidence_id);
|
||||
const retained = javascriptApplicationAnalysisResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
).semantic_graph;
|
||||
expect(retained.nodes.filter(({ kind }) => kind === "timer")).toEqual(
|
||||
timers,
|
||||
);
|
||||
expect(
|
||||
retained.nodes.some(
|
||||
({ kind, label }) => kind === "function" && label === "greet",
|
||||
),
|
||||
).toBe(true);
|
||||
await expect(client.ping()).resolves.toEqual({});
|
||||
} finally {
|
||||
await Promise.all([client.close(), transport.close()]);
|
||||
}
|
||||
expect(await readFile(path, "utf8")).toBe(source);
|
||||
},
|
||||
);
|
||||
@@ -176,7 +176,13 @@ it.each(["caller", "host"] as const)(
|
||||
);
|
||||
let closing: ReturnType<NonNullable<typeof host.close>> | undefined;
|
||||
try {
|
||||
await expect.poll(async () => (await fakeKunaCalls(kuna)).length).toBe(1);
|
||||
// The preceding Ghidra probe may prepare Darwin process ownership before
|
||||
// Kuna starts; keep startup separate from the bounded post-abort join.
|
||||
await expect
|
||||
.poll(async () => (await fakeKunaCalls(kuna)).length, {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toBe(1);
|
||||
const probe = (await fakeKunaCalls(kuna))[0];
|
||||
if (probe === undefined) throw new Error("Missing Kuna version probe");
|
||||
expect(probe.argv).toEqual(["--version"]);
|
||||
|
||||
@@ -664,6 +664,11 @@ describe("managed comparison snapshot admission and cleanup", () => {
|
||||
max_snapshot_bytes: MAX_MANAGED_SNAPSHOT_BYTES,
|
||||
},
|
||||
});
|
||||
const projected = projectAnalysisError(result.error);
|
||||
expect(projected.retryable).toBe(false);
|
||||
// The snapshot budget is fixed; worker or host memory cannot admit it.
|
||||
expect(projected.remediation.action).toContain("max_snapshot_bytes");
|
||||
expect(projected.remediation.action).not.toMatch(/sufficient memory/iu);
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
read.mockRestore();
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
const base = 0x100000000n;
|
||||
|
||||
/** Minimal inert Mach-O with one class instance method and protocol method. */
|
||||
export const objcRecordBudgetMacho = (owner: "class" | "protocol"): Buffer => {
|
||||
const bytes = Buffer.alloc(4096);
|
||||
const u32 = (offset: number, value: number) =>
|
||||
bytes.writeUInt32LE(value, offset);
|
||||
const pointer = (offset: number, target: number) =>
|
||||
bytes.writeBigUInt64LE(base + BigInt(target), offset);
|
||||
const nullPointer = (offset: number) => bytes.writeBigUInt64LE(0n, offset);
|
||||
|
||||
u32(0, 0xfeedfacf);
|
||||
u32(4, 0x0100000c);
|
||||
u32(12, 2);
|
||||
u32(16, 1);
|
||||
u32(20, owner === "protocol" ? 232 : 152);
|
||||
u32(32, 0x19);
|
||||
u32(36, owner === "protocol" ? 232 : 152);
|
||||
bytes.write("__DATA", 40);
|
||||
pointer(56, 0);
|
||||
bytes.writeBigUInt64LE(4096n, 64);
|
||||
bytes.writeBigUInt64LE(4096n, 80);
|
||||
u32(88, 7);
|
||||
u32(92, 5);
|
||||
u32(96, owner === "protocol" ? 2 : 1);
|
||||
bytes.write("__objc_classlist", 104);
|
||||
bytes.write("__DATA", 120);
|
||||
pointer(136, 0x180);
|
||||
bytes.writeBigUInt64LE(8n, 144);
|
||||
u32(152, 0x180);
|
||||
if (owner === "protocol") {
|
||||
bytes.write("__objc_protolist", 184);
|
||||
bytes.write("__DATA", 200);
|
||||
pointer(216, 0x6c0);
|
||||
bytes.writeBigUInt64LE(8n, 224);
|
||||
u32(232, 0x6c0);
|
||||
pointer(0x6c0, 0x740);
|
||||
}
|
||||
|
||||
pointer(0x180, 0x200);
|
||||
// class_t: zero isa/superclass; class_ro_t is root, named and has one method.
|
||||
nullPointer(0x200);
|
||||
nullPointer(0x208);
|
||||
pointer(0x220, 0x300);
|
||||
u32(0x300, 2);
|
||||
u32(0x308, 8);
|
||||
pointer(0x318, 0x900);
|
||||
pointer(0x320, 0x500);
|
||||
nullPointer(0x328);
|
||||
nullPointer(0x330);
|
||||
nullPointer(0x340);
|
||||
bytes.write("FixtureClass\0", 0x900);
|
||||
u32(0x500, 24);
|
||||
u32(0x504, 1);
|
||||
pointer(0x508, 0x920);
|
||||
pointer(0x510, 0x940);
|
||||
pointer(0x518, 0x700);
|
||||
bytes.write("classMethod\0", 0x920);
|
||||
bytes.write("v@:\0", 0x940);
|
||||
|
||||
// Protocol method lists use the same declaration boundary and budget.
|
||||
pointer(0x6c0, 0x740);
|
||||
pointer(0x748, 0x980);
|
||||
pointer(0x758, 0x800);
|
||||
u32(0x780, 72);
|
||||
u32(0x800, 24);
|
||||
u32(0x804, 1);
|
||||
pointer(0x808, 0x9a0);
|
||||
bytes.write("FixtureProtocol\0", 0x980);
|
||||
bytes.write("protocolMethod\0", 0x9a0);
|
||||
return bytes;
|
||||
};
|
||||
Reference in new issue
Block a user