diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index ef06652078..250503e471 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -207,6 +207,12 @@ explicit path or endpoint and do not need it. package order, dependency indices, and the section file range. A missing `.dep-v0` section is null metadata, not proof the binary has no dependencies. A rustc commit hash is an inference from a read-only `/rustc/<40-hex>/` path. + For selected crates, use `view: {collection: "packages", names: [...], + fields: ["name", "version", "source"]}` on the first call; explicitly include + `dependencies` for direct index references. Use `detail: "summary"` for counts + and ranges, or retained `inspect_analysis_view` Rust package/edge pages for + subsequent filters. Keep the complete parent Evidence accessible; CLI compact + delivery requires `--evidence_output`. See the [Rust binary guide](https://github.com/morluto/rea/blob/main/docs/rust-binaries.md). diff --git a/docs/rust-binaries.md b/docs/rust-binaries.md index e0f7a28f1c..a2cc0ff6fe 100644 --- a/docs/rust-binaries.md +++ b/docs/rust-binaries.md @@ -21,6 +21,67 @@ requires an absolute filesystem path and preserves that exact selection: ## Observations +Complete delivery remains the default. For a dependency question, select the +needed records on the first call instead of returning the complete graph: + +```json +{ + "name": "inspect_rust_binary", + "arguments": { + "path": "/artifacts/application", + "view": { + "collection": "packages", + "names": ["serde", "serde_derive"], + "fields": ["name", "version", "source", "dependencies"] + } + } +} +``` + +`detail: "summary"` returns section ranges, root index, package/edge/cycle counts +and compiler-hash count. A `view` selects compact delivery automatically; +combining it with explicit `detail: "complete"` is an input error. The complete +parent Evidence is retained in the MCP session and remains exportable. Subsequent +`inspect_analysis_view` calls accept that `parent_evidence_id`, or its portable +Evidence file, without reopening the binary: + +```json +{ + "source": { + "kind": "retained-evidence", + "evidence_id": "ev_" + }, + "view": { + "kind": "rust", + "collection": "edges", + "from_indices": [12], + "limit": 20 + } +} +``` + +Package filters accept exact `names`, `versions` and original `indices`; edge +filters accept original edge `indices`, `from_indices` and `to_indices`. +Filters intersect, and an empty filter array selects nothing. `offset` counts +matching records; `limit` defaults to 64. Pages retain original indices scoped +to the parent Evidence, selected fields, examined coverage and the next offset. +Default package fields omit dependency vectors; explicitly select +`dependencies` to obtain direct references. No dependency path is traversed. + +The CLI uses the same views and requires an exclusive output bundle for compact +delivery so the complete Evidence survives process exit: + +```bash +rea inspect-rust-binary /artifacts/application --json \ + --view '{"collection":"packages","names":["serde"],"fields":["name","version"]}' \ + --evidence_output /artifacts/rust-evidence.json +``` + +Compact results distinguish complete embedded-record coverage from unknown +runtime dependency coverage. A missing section remains unknown rather than an +empty dependency set. Retained views describe the captured artifact digest; +they do not assert that the current file or running application is unchanged. + The inline Evidence result contains the artifact path, SHA-256, byte length, container format, architecture, word size, and byte order. When a `.dep-v0` section is present, `auditable_metadata` contains: diff --git a/experiments/analysis-efficiency/corpus.json b/experiments/analysis-efficiency/corpus.json index 03d4fa18c5..caba458c83 100644 --- a/experiments/analysis-efficiency/corpus.json +++ b/experiments/analysis-efficiency/corpus.json @@ -433,6 +433,38 @@ "members": 315, "expanded_bytes": 1301927 } + }, + { + "id": "alpine-ripgrep-rust", + "file": "ripgrep", + "bytes": 3416168, + "sha256": "78387bbe12b2110df8d2ce364390a00c190f4543a2f40b8279e49f6c5c85af9c", + "provenance": { + "project": "ripgrep", + "version": "14.1.1-r0", + "upstreamFile": "ripgrep-14.1.1-r0.apk", + "url": "https://dl-cdn.alpinelinux.org/alpine/v3.21/community/x86_64/ripgrep-14.1.1-r0.apk", + "transport_sha256": "213dd0b047163f6627cc2fdd6fbfe205009476eb4fc48c352a3f5b9ab647f6d4", + "transport_bytes": 1422830, + "aports_revision": "4e899d119e4c983a938436d6f397a509521225d1", + "derivation": "selected regular APK tar member usr/bin/rg; read-only static inspection; never executed" + } + }, + { + "id": "alpine-zellij-rust", + "file": "zellij", + "bytes": 17151864, + "sha256": "9a86e9ca47902a869fdf1886f4cf17714f4fa5655427128beadff498cfb392db", + "provenance": { + "project": "zellij", + "version": "0.40.0-r0", + "upstreamFile": "zellij-0.40.0-r0.apk", + "url": "https://dl-cdn.alpinelinux.org/alpine/v3.21/community/x86_64/zellij-0.40.0-r0.apk", + "transport_sha256": "575075945415b221857563e19c5f4c661af9f98bbba54f0994c7385c0e432432", + "transport_bytes": 6722129, + "aports_revision": "3be26a3a26e76e1bf3eff784bffe3956d29d77a0", + "derivation": "selected regular APK tar member usr/bin/zellij; read-only static inspection; never executed" + } } ] } diff --git a/experiments/analysis-efficiency/rust-dependency-costs.py b/experiments/analysis-efficiency/rust-dependency-costs.py new file mode 100644 index 0000000000..f8094a5ad8 --- /dev/null +++ b/experiments/analysis-efficiency/rust-dependency-costs.py @@ -0,0 +1,79 @@ +"""Count completed Rust questions; tokenizer estimates are not 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=(",", ":"), 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", {}) + assert (report["status"] == "passed" 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), path + assert [event["name"] for event in transcript] == [ + "inspect_rust_binary", "export_evidence_bundle"] + count = lambda value: len(encoding.encode(value, disallowed_special=())) + schema_fields = ["name", "description", "inputSchema"] + tool_tokens = lambda tools: count(compact([ + {key: tool[key] for key in schema_fields} for tool in tools])) + inputs = tool_tokens(catalog["tools"]) + selected = tool_tokens([tool for tool in catalog["tools"] if tool["name"] in [ + "inspect_rust_binary", "export_evidence_bundle"]]) + 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") + return {"case": path.name, "lane": report["lane"], + "answer": report["answer"], "parent_evidence_id": report["parent_evidence_id"], + "artifact_sha256": report["artifact_sha256"], + "complete_result_sha256": report["normalized_sha256"], + "catalog_sha256": report["catalog_sha256"], + "catalog_tools": report["catalog_tools"], "calls": report["calls"], + "wall_seconds": scope["elapsed_seconds"], + "cpu_seconds": observed["cpu_stat"]["usage_usec"] / 1e6, + "kernel_family_peak_bytes": observed["memory_peak_bytes"], + "events_at_payload_exit": events, + "final_whole_scope_events": scope.get("resource_events_final"), + "completed_with_verified_cleanup": True, + "token_proxy": {"input_catalog_once": inputs, "arguments": arguments, + "text_replies": replies, "combined": inputs + arguments + replies, + "only_two_selected_tools": selected + 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") + receipt = {"tiktoken": tiktoken.__version__, "encoding": encoding.name, + "actual_model_usage": None, + "projection": "All discovered tool descriptions/input schemas once, plus every call argument and text reply including export. Selected-two-tool and complete transport catalog costs are separate. No model, host injection, history replay or retry observed.", + "recordings": [inspect(path, encoding) for path in args.cases]} + args.output.write_text(json.dumps(receipt, ensure_ascii=False, indent=2) + "\n") + + +if __name__ == "__main__": + main() diff --git a/experiments/analysis-efficiency/rust-dependency-views.json b/experiments/analysis-efficiency/rust-dependency-views.json new file mode 100644 index 0000000000..ed2d0407c8 --- /dev/null +++ b/experiments/analysis-efficiency/rust-dependency-views.json @@ -0,0 +1,578 @@ +{ + "baseline_source": "d672cc23998b6cbb21c485f118595ebfb82fed33", + "candidate_code": "cae2571e7e64a7364b94bb92c2630cba80ec4478", + "runtime_filetables": { + "baseline": { + "sha256": "6497f70d4864489e948b6a6f9611cf1bd9e6d7f60722fcdd41c0984ab7c48d53", + "files": 1917 + }, + "candidate": { + "sha256": "078e6fd0f837ca252265d12d3a95e346f836762d2474117c51f4c9f7ae629b21", + "files": 1920 + } + }, + "lock_sha256": "699a14749e3825c62430faeb18224190fee1c9347760494e38e334e180bea6ec", + "source_sha256": { + ".agents/skills/reverse-engineer-anything/SKILL.md": "4baf850678f0781cbb989a7ac1452c4cbc5d98e4125bc059b5381c336cc8bc07", + "docs/rust-binaries.md": "3317a494376c3dde0c81a848ea678375db4af61fa85ba8d505b7d89b319c8371", + "experiments/analysis-efficiency/corpus.json": "81c223028c7c6308949e508413dea6aef79b691f6dc7fe02eb6ff81e6d52b0cc", + "experiments/analysis-efficiency/rust-dependency-costs.py": "c7eb7a9bc696e06c4cfba3b0aa730bf8cb9f2bcdec3a371eae3849a47c5f3135", + "experiments/analysis-efficiency/rust-dependency-views.mjs": "7c59302fe00c8a00e4cea06bca8f73974e40a618143ea4f1c7c5e275c9c07df5", + "src/application/rust/RustBinaryService.test.ts": "9b424569f52ed6c5e4629b3bda4fb8a8dc805adc0881e82f1bf39f38618c57a3", + "src/application/rust/RustBinaryService.ts": "caa089f62cdf24ee8a4ceb6eb7bdbf9df90fc724f921197f9e69b680366a98a7", + "src/cli/rustCommands.ts": "18c87736f20437f178d2094f0b46392d167ff510fa8a08a6b121610538fc257b", + "src/contracts/analysisViewToolContracts.ts": "149cd98c2ed22444ac9e59fa9144b6840c3fd13bd1613ddf8da23310766829f2", + "src/contracts/rust/rustToolContracts.ts": "5433f15f581618b3184147c04ccd21e04bc4a3a880b7ea0db6dbc38e5825efab", + "src/domain/analysisView/analysisView.ts": "8122d62bf795f3abae4bd903a641e336f0c2650cab98836051839bfaec4e1d65", + "src/domain/analysisView/goBinaryView.ts": "c434f7d7c198481d2fab2c2f7d5672f0db34256e9c8700a52c65c55def05df0b", + "src/domain/analysisView/rustBinaryView.test.ts": "3c53131b5643ed874214c0ba90e66960d2acdf9d22a881d283ef44aba671dfe4", + "src/domain/analysisView/rustBinaryView.ts": "8c330cf31b3ccb37e3b12a40e8157c50a9d43ac42cf6477dcca969398e0f79ad", + "src/domain/analysisView/selectViewRows.ts": "6eb486166e991045bf0a74cf874c80d2542862147b7ba095e45a61ec26afd1e5", + "src/domain/rust/rustBinary.ts": "90fc6fc20faa0f663dc14e08e8735804ae77abaa7fdb2e918162b358efb42462", + "src/domain/rust/rustBinaryQuery.ts": "ecb2ad8a97e3f3d232f94f5969baaef62143392362d18b4d46ab6babcfe96da2", + "src/server/createServer.ts": "b005d3d71edc98b9eb3422d83b8ddeddcc7db0b1982a1e93ce4578d588dbd8e6", + "src/server/registerRustTools.ts": "e57723a66916f3e2d9208e7202ac50b28536234874fbc3752e9259e0145299ae", + "tests/acceptance/rustBinaryCli.test.ts": "85962f740bd1d2f1fc99601337ab7e7ca3f4e659553d41297aa216e8928c0af4", + "tests/acceptance/rustDependencyViews.test.ts": "b1505497e9dbfb1ce855725a8af501f1ebc82bb2c528b8992e9b33cf8628ef7f", + "tests/boundary/mcp/rustBinaryMcp.test.ts": "4d2c5f172ebcd3407265b195adc7c5bc5dd590d97a443560a6d8c6afda60d08a" + }, + "resource_guard_sha256": { + "resource-run.py": "7a9642ab86ba34290564f636eb080cacf38b4185a254028680b238bff172993b", + "resource_payload.py": "6f089e645459eba80c5d73e39c21606c78173a44daf49fabc3b88c4631ce51c6" + }, + "host": { + "platform": "Linux-6.1.0-54-cloud-amd64-x86_64-with-glibc2.36", + "machine": "x86_64", + "affinity": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + "node": "v24.18.0", + "npm": "11.16.0", + "objcopy": "GNU objcopy (GNU Binutils for Debian) 2.40", + "readelf": "GNU readelf (GNU Binutils for Debian) 2.40", + "python": "3.11.2" + }, + "artifacts": [ + { + "package": "ripgrep-14.1.1-r0.apk", + "url": "https://dl-cdn.alpinelinux.org/alpine/v3.21/community/x86_64/ripgrep-14.1.1-r0.apk", + "package_sha256": "213dd0b047163f6627cc2fdd6fbfe205009476eb4fc48c352a3f5b9ab647f6d4", + "package_bytes": 1422830, + "member": "usr/bin/rg", + "bytes": 3416168, + "sha256": "78387bbe12b2110df8d2ce364390a00c190f4543a2f40b8279e49f6c5c85af9c", + "aports_revision": "4e899d119e4c983a938436d6f397a509521225d1" + }, + { + "package": "zellij-0.40.0-r0.apk", + "url": "https://dl-cdn.alpinelinux.org/alpine/v3.21/community/x86_64/zellij-0.40.0-r0.apk", + "package_sha256": "575075945415b221857563e19c5f4c661af9f98bbba54f0994c7385c0e432432", + "package_bytes": 6722129, + "member": "usr/bin/zellij", + "bytes": 17151864, + "sha256": "9a86e9ca47902a869fdf1886f4cf17714f4fa5655427128beadff498cfb392db", + "aports_revision": "3be26a3a26e76e1bf3eff784bffe3956d29d77a0" + } + ], + "oracle": [ + { + "file": "ripgrep", + "packages": 42, + "edges": 88, + "reply_bytes": 10605, + "root": 33, + "upstream_json_sha256": "ea478aa80f91aa8f5159aba648ddfdda642fa2c98759e54e65623a19065634be", + "evidence_id": "ev_be5662081d3c5b609a5e65a78c732674d2fcc160e5b1173ca0d083316673eaa4", + "matched": true + }, + { + "file": "zellij", + "packages": 422, + "edges": 1063, + "reply_bytes": 86805, + "root": 417, + "upstream_json_sha256": "9bcf8d694440a7e064189616aa40b69046e32ee1c70683ef2e0778150b9163ef", + "evidence_id": "ev_8c9124795a5cf45c2bf816bbfa0ced9c3025550e72d4e3facf5fb2e266d3dadb", + "matched": true + } + ], + "root_cycle_oracle": [ + { + "file": "ripgrep", + "root_index_independently_matched": 33, + "independent_topological_visit_count": 42, + "acyclic": true + }, + { + "file": "zellij", + "root_index_independently_matched": 417, + "independent_topological_visit_count": 422, + "acyclic": true + } + ], + "tokenizer": { + "tiktoken": "0.12.0", + "encoding": "cl100k_base", + "actual_model_usage": null, + "projection": "All discovered tool descriptions/input schemas once, plus every call argument and text reply including export. Selected-two-tool and complete transport catalog costs are separate. No model, host injection, history replay or retry observed." + }, + "samples": [ + { + "case": "r1-zellij-before", + "wall_seconds": 5.77, + "cpu_seconds": 5.626476, + "kernel_family_peak_bytes": 644210688, + "completed_with_verified_cleanup": true, + "sha256": { + "report.json": "f2bd86aedd299c4fe16cca470eeaf135b6553c1bfe345018def503424c5661a6", + "catalog.json": "411381fb2417945f8a29eb8db330f82b44d8790d3f1e88c7f766b532c528008f", + "transcript.json": "bca9b5474a2124a99535c4aca0c38d9c77146d508822904aae4adc458b05b10e", + "r1-zellij-before.log": "79acdc6c966345d59ba88aafd22b5fb11cdd759c62611e720a47aef308ef070c" + }, + "calls_ms": [397.233, 103.9], + "reply_bytes": [185833, 310], + "combined_tokens": 223755 + }, + { + "case": "r2-zellij-before", + "wall_seconds": 4.957, + "cpu_seconds": 4.781552, + "kernel_family_peak_bytes": 636317696, + "completed_with_verified_cleanup": true, + "sha256": { + "report.json": "f3f58187dc63116f02586b2b3403848a8a88f6f72c5e322d0be00c9fcb2b7710", + "catalog.json": "411381fb2417945f8a29eb8db330f82b44d8790d3f1e88c7f766b532c528008f", + "transcript.json": "718aa02bf3fd74099b44418ac220e639719495b3e718357fb4e34748e5499e41", + "r2-zellij-before.log": "3e36a14c0b7e8d4d6e6f8b4b024c090133773f2699a8737d17dc5352b5f490ac" + }, + "calls_ms": [380.788, 40.185], + "reply_bytes": [185833, 310], + "combined_tokens": 223755 + }, + { + "case": "r3-zellij-before", + "wall_seconds": 4.854, + "cpu_seconds": 4.693446, + "kernel_family_peak_bytes": 639963136, + "completed_with_verified_cleanup": true, + "sha256": { + "report.json": "75d54eb72935201b8946d83a3bef98d93dc1219022bf735935d42934069b7dea", + "catalog.json": "411381fb2417945f8a29eb8db330f82b44d8790d3f1e88c7f766b532c528008f", + "transcript.json": "940eb7899a07a1115402fbc7b89010b463723b26e129f62a51263b9c9e598b1f", + "r3-zellij-before.log": "0f266f87cf5f2984ac10964f3255ebb76a706695256683c7f80c071b84d1f3a6" + }, + "calls_ms": [340.588, 30.985], + "reply_bytes": [185833, 310], + "combined_tokens": 223755 + }, + { + "case": "r1-zellij-after", + "wall_seconds": 6.277, + "cpu_seconds": 6.056786, + "kernel_family_peak_bytes": 637652992, + "completed_with_verified_cleanup": true, + "sha256": { + "report.json": "8d34bbdba7cfedc58e281efc372cf6969def10bc99b022e4bc786d447daf4709", + "catalog.json": "ba99f10270e8ab53d765f124856921f59156482d603fa883240e1b4068eab921", + "transcript.json": "e09cc44b0ce78a74be6cc9d7c90a78e2173514e5a4cc1dd9f02e5a728c880600", + "r1-zellij-after.log": "a90915676741794e52568dd30604a4e306ebfec77df37982bef6ea3d4411e405" + }, + "calls_ms": [517.341, 43.422], + "reply_bytes": [9261, 308], + "combined_tokens": 199584 + }, + { + "case": "r2-zellij-after", + "wall_seconds": 5.671, + "cpu_seconds": 5.441365, + "kernel_family_peak_bytes": 633688064, + "completed_with_verified_cleanup": true, + "sha256": { + "report.json": "425d70965ad49048d01a88473c1fb4790a7ef6f4ceb1c5374a652fc239fa96e5", + "catalog.json": "ba99f10270e8ab53d765f124856921f59156482d603fa883240e1b4068eab921", + "transcript.json": "96d3bc1798136135beed8e7f2b4eac626124655fb01ca1cdfe1040786ddf9efa", + "r2-zellij-after.log": "74fed3f25bd859ce4f6d4f4e7a6d3aaf31c9f079f730427fffa489a003adcfaa" + }, + "calls_ms": [473.788, 28.945], + "reply_bytes": [9261, 308], + "combined_tokens": 199584 + }, + { + "case": "r3-zellij-after", + "wall_seconds": 5.054, + "cpu_seconds": 4.816254, + "kernel_family_peak_bytes": 622559232, + "completed_with_verified_cleanup": true, + "sha256": { + "report.json": "349360e9b1c19faaa796106a70a26fd5711b804af807fc1b9c98a6e012530f9e", + "catalog.json": "ba99f10270e8ab53d765f124856921f59156482d603fa883240e1b4068eab921", + "transcript.json": "12fc240871df42621b8e80efdb6451136ea317ae364f7c6741aed348a12a73a9", + "r3-zellij-after.log": "18d9edcae6ac46c4034b958ba2e607a524aaf80dfbc671a5fd654f6390ee5c3f" + }, + "calls_ms": [359.933, 34.275], + "reply_bytes": [9261, 308], + "combined_tokens": 199584 + }, + { + "case": "r1-ripgrep-before", + "wall_seconds": 4.859, + "cpu_seconds": 4.643315, + "kernel_family_peak_bytes": 599961600, + "completed_with_verified_cleanup": true, + "sha256": { + "report.json": "2918f88999120cfba046ad5dc615d12bcc98c034c767a00edb3631a2b8bd622a", + "catalog.json": "411381fb2417945f8a29eb8db330f82b44d8790d3f1e88c7f766b532c528008f", + "transcript.json": "aa98de6fb6cbd24672e3a5b8aaf22ff1f977276d00c14ebd32e90501bd9eb4aa", + "r1-ripgrep-before.log": "a3963e991e1286e84a03f69f2d7e8a7e313ea262123269777599d93b7e529153" + }, + "calls_ms": [181.636, 30.005], + "reply_bytes": [22643, 312], + "combined_tokens": 200603 + }, + { + "case": "r2-ripgrep-before", + "wall_seconds": 5.057, + "cpu_seconds": 4.853134, + "kernel_family_peak_bytes": 596680704, + "completed_with_verified_cleanup": true, + "sha256": { + "report.json": "35f8de985a6201efc40f629195a9a801d2f333d605a46220bd4ae313c35f2abd", + "catalog.json": "411381fb2417945f8a29eb8db330f82b44d8790d3f1e88c7f766b532c528008f", + "transcript.json": "bed19b9d031b3416bc79870fb8d94ed0a2376004dfb9b69b78c56bd4a38912ff", + "r2-ripgrep-before.log": "ec0a5b2933307131af7435d737dde4a72ad3c92f3923e46aaaae7b0b7b7b1fd3" + }, + "calls_ms": [189.36, 29.044], + "reply_bytes": [22643, 312], + "combined_tokens": 200603 + }, + { + "case": "r3-ripgrep-before", + "wall_seconds": 4.753, + "cpu_seconds": 4.542499, + "kernel_family_peak_bytes": 589565952, + "completed_with_verified_cleanup": true, + "sha256": { + "report.json": "daaf848260488ef347afc4c600fe89c2902d6a68fa65085c0fcae7a20d7c6d17", + "catalog.json": "411381fb2417945f8a29eb8db330f82b44d8790d3f1e88c7f766b532c528008f", + "transcript.json": "43e570e7421f14aaaf7dc92b8c4a69d12a221851c7e813587eda8ca73e0b1d7f", + "r3-ripgrep-before.log": "6f88e1abbe8ebb8a68cf3a5d678c227516c8506029b0114094b1a42bfd056971" + }, + "calls_ms": [201.092, 30.174], + "reply_bytes": [22643, 312], + "combined_tokens": 200603 + }, + { + "case": "r1-ripgrep-after", + "wall_seconds": 5.159, + "cpu_seconds": 4.945401, + "kernel_family_peak_bytes": 609128448, + "completed_with_verified_cleanup": true, + "sha256": { + "report.json": "14294e20d39e6413e1dac9836a01dd498b7a669c4fa0a9708fd131452a0b6881", + "catalog.json": "ba99f10270e8ab53d765f124856921f59156482d603fa883240e1b4068eab921", + "transcript.json": "dd32475dc2205d3574a0496730a9c60751464c2fcc4a955051e889f1b0547c0d", + "r1-ripgrep-after.log": "dd22e6f0e9eac449e0485a67ae79839958109e5331f4872aa9a527a51aae1766" + }, + "calls_ms": [244.153, 30.492], + "reply_bytes": [9207, 310], + "combined_tokens": 199584 + }, + { + "case": "r2-ripgrep-after", + "wall_seconds": 4.757, + "cpu_seconds": 4.528198, + "kernel_family_peak_bytes": 615854080, + "completed_with_verified_cleanup": true, + "sha256": { + "report.json": "d45dc890ea69f370b47ccefc2b454dc1d0c3baf2beafd0fbe36ffb91c890e8e3", + "catalog.json": "ba99f10270e8ab53d765f124856921f59156482d603fa883240e1b4068eab921", + "transcript.json": "2b060caea34407a0c5c18fac3cad43a4dd048eee54bb760dd56ca95665b6a492", + "r2-ripgrep-after.log": "36cdb1176e1fdeef6e00f360c1a36ddb97506fcd31b426ee9be746da9ece7535" + }, + "calls_ms": [249.866, 28.919], + "reply_bytes": [9207, 310], + "combined_tokens": 199584 + }, + { + "case": "r3-ripgrep-after", + "wall_seconds": 4.758, + "cpu_seconds": 4.512774, + "kernel_family_peak_bytes": 615874560, + "completed_with_verified_cleanup": true, + "sha256": { + "report.json": "61ec33fe49c943a71177562212d255780ee6cf4cce273c1733a9ae6c138c6167", + "catalog.json": "ba99f10270e8ab53d765f124856921f59156482d603fa883240e1b4068eab921", + "transcript.json": "eb48ed154e646e3aa1ea9cb1aae99e363bb68ac122777ba4f3ac63d84547db37", + "r3-ripgrep-after.log": "34fd034def0ab7e0c22e9aa89aaaef5f5cc58a9125606400d8e866023578432b" + }, + "calls_ms": [224.772, 27.335], + "reply_bytes": [9207, 310], + "combined_tokens": 199584 + } + ], + "resource_limits": { + "cpu_quota_percent": 100, + "affinity": [15], + "hard_memory_gib": 4, + "early_stop_gib": 3, + "swap_bytes": 0, + "tasks": 192, + "host_preflight_gib": 8, + "host_stop_gib": 4 + }, + "cache_state": "Fresh process/session every task; unflushed OS caches, controller hashes inputs first; not cold disk.", + "failed_attempts": [ + { + "attempt": "baseline runtime build and cached retry", + "outcome": "Commands exited 0; cleanup counters unqualified due extra observed child; no qualified build resource claim. Actual CLI and SDK tasks separately qualify." + }, + { + "attempt": "prototype source typechecks", + "outcome": "Failed on new optional subject and expanded result-union typings; fixed and passed final check." + }, + { + "attempt": "prototype static gate", + "outcome": "New acceptance function exceeded line limit; refactored and passed." + }, + { + "attempt": "first one-CPU acceptance cohort", + "outcome": "Existing cross-Go/Rust acceptance test timed out at its unchanged 30s limit. All nine CLI acceptance tests passed with a bounded two-CPU/4-GiB cohort; no timeout change." + } + ], + "rejected_prototype": { + "change": "Replace native JSON.stringify report sizing with shared JS byte counter", + "provider_first_repeat_ms": { + "provider-ripgrep-candidate": { + "first_ms": 76.985, + "repeat_median_ms": 20.225 + }, + "provider-zellij-baseline": { + "first_ms": 181.069, + "repeat_median_ms": 92.804 + }, + "provider-ripgrep-baseline": { + "first_ms": 60.221, + "repeat_median_ms": 18.323 + }, + "provider-zellij-candidate": { + "first_ms": 183.659, + "repeat_median_ms": 76.214 + } + }, + "decision": "No consistent CPU or memory advantage established across both real artifacts; reverted. Final provider file is byte-identical to baseline." + }, + "diagnostic_phase_controls": { + "driver_sha256": "eeca9644b4b7199e45e49447bf83f7b33ac60c9ce1c38908b06f59d72e3b7485", + "method": "Same task driver plus performance.now around client.connect and listTools; baseline complete, candidate complete and candidate selected in forward/reverse order. Private instrumented driver, not the primary cohort.", + "samples": [ + { + "case": "control1-baseline", + "connect_ms": 2515.583, + "catalog_ms": 1676.58, + "calls_ms": [431.356, 57.797], + "wall_seconds": 5.46, + "cpu_seconds": 5.243091, + "kernel_family_peak_bytes": 644329472, + "qualified": true, + "report_sha256": "badff5397d47a240272feb4bd2348e3122d5482eea116933b29eddd740768955", + "log_sha256": "9b0eb1bfe51e932a4e8978d165fc26f213b003ee4452f704da1f9ce813830850" + }, + { + "case": "control1-candidate-complete", + "connect_ms": 3086.124, + "catalog_ms": 1726.06, + "calls_ms": [485.865, 30.696], + "wall_seconds": 6.164, + "cpu_seconds": 5.94611, + "kernel_family_peak_bytes": 634658816, + "qualified": true, + "report_sha256": "ac65641dcf7dd556017348860484b2a0485628803ae2f9e619cee40007fe8aff", + "log_sha256": "457a2dee6f38d2f57b4616c5b1138abc26c18235844fa4589e89b4f15ca4abf8" + }, + { + "case": "control1-candidate-selected", + "connect_ms": 3057.187, + "catalog_ms": 1677.082, + "calls_ms": [431.301, 32.986], + "wall_seconds": 6.063, + "cpu_seconds": 5.798686, + "kernel_family_peak_bytes": 639565824, + "qualified": true, + "report_sha256": "ef8e46c78e81f13c84f1bf0a8a8284078252d5af16302a716144149c136f89f5", + "log_sha256": "8d72501364d0a95fb250e2d0486b9b34076688309748b8dc054a75681e8543e0" + }, + { + "case": "control2-baseline", + "connect_ms": 2678.85, + "catalog_ms": 1467.674, + "calls_ms": [413.185, 36.964], + "wall_seconds": 5.364, + "cpu_seconds": 5.183688, + "kernel_family_peak_bytes": 643944448, + "qualified": true, + "report_sha256": "d7e3318331528e1201fd1efc7cdc4edb0624a32e6c6db501504cc77f49374157", + "log_sha256": "d61f437e5c88b2eb994e1f716c05915f0955d5877b1d34207fd39e3d36f85970" + }, + { + "case": "control2-candidate-complete", + "connect_ms": 2527.714, + "catalog_ms": 1545.887, + "calls_ms": [436.068, 30.776], + "wall_seconds": 5.36, + "cpu_seconds": 5.159006, + "kernel_family_peak_bytes": 635162624, + "qualified": true, + "report_sha256": "fb432b0f5eeedc74e66144c136954e580f5efb5b86a921ace3bf29477e90f0aa", + "log_sha256": "4e7485b8b2a5bb0619f0e84df96b68e02827e2afee11533e0f12532c12fd84f7" + }, + { + "case": "control2-candidate-selected", + "connect_ms": 2642.283, + "catalog_ms": 1465.879, + "calls_ms": [424.082, 32.29], + "wall_seconds": 5.362, + "cpu_seconds": 5.110599, + "kernel_family_peak_bytes": 637669376, + "qualified": true, + "report_sha256": "2d4b794902c9fd76b11a2f7ae87bb06be3cc9effadfb161f6f4debb9044ebcfd", + "log_sha256": "e98cc393c717ae19bb0c880d609a05e2f2d4249701f245c19b2f030b8be305fb" + } + ] + }, + "cohort_facts": { + "zellij-complete": { + "answer": { + "packages": [ + { + "index": 294, + "name": "serde", + "version": "1.0.137", + "source": "crates.io", + "dependencies": [296] + }, + { + "index": 296, + "name": "serde_derive", + "version": "1.0.137", + "source": "crates.io", + "dependencies": [256, 264, 331] + } + ], + "direct_edges": [ + { + "from_index": 294, + "to_index": 296 + } + ] + }, + "parent_evidence_id": "ev_8c9124795a5cf45c2bf816bbfa0ced9c3025550e72d4e3facf5fb2e266d3dadb", + "artifact_sha256": "9a86e9ca47902a869fdf1886f4cf17714f4fa5655427128beadff498cfb392db", + "complete_result_sha256": "a9ce92f47aaa92fb0edc1c288d06c298ae623d731499fd4cedb86ab9c2bbb483", + "catalog_sha256": "411381fb2417945f8a29eb8db330f82b44d8790d3f1e88c7f766b532c528008f", + "catalog_tools": 212, + "token_proxy": { + "input_catalog_once": 197359, + "arguments": 58, + "text_replies": 26338, + "combined": 223755, + "only_two_selected_tools": 26850, + "complete_transport_catalog": 886882 + } + }, + "zellij-selected": { + "answer": { + "packages": [ + { + "name": "serde", + "version": "1.0.137", + "source": "crates.io", + "dependencies": [296], + "index": 294 + }, + { + "name": "serde_derive", + "version": "1.0.137", + "source": "crates.io", + "dependencies": [256, 264, 331], + "index": 296 + } + ], + "direct_edges": [ + { + "from_index": 294, + "to_index": 296 + } + ] + }, + "parent_evidence_id": "ev_8c9124795a5cf45c2bf816bbfa0ced9c3025550e72d4e3facf5fb2e266d3dadb", + "artifact_sha256": "9a86e9ca47902a869fdf1886f4cf17714f4fa5655427128beadff498cfb392db", + "complete_result_sha256": "a9ce92f47aaa92fb0edc1c288d06c298ae623d731499fd4cedb86ab9c2bbb483", + "catalog_sha256": "ba99f10270e8ab53d765f124856921f59156482d603fa883240e1b4068eab921", + "catalog_tools": 212, + "token_proxy": { + "input_catalog_once": 198278, + "arguments": 83, + "text_replies": 1223, + "combined": 199584, + "only_two_selected_tools": 2256, + "complete_transport_catalog": 891294 + } + }, + "ripgrep-complete": { + "answer": { + "packages": [ + { + "index": 36, + "name": "serde", + "version": "1.0.210", + "source": "crates.io", + "dependencies": [] + } + ], + "direct_edges": [] + }, + "parent_evidence_id": "ev_be5662081d3c5b609a5e65a78c732674d2fcc160e5b1173ca0d083316673eaa4", + "artifact_sha256": "78387bbe12b2110df8d2ce364390a00c190f4543a2f40b8279e49f6c5c85af9c", + "complete_result_sha256": "9eb75332f423bcb94f627694cde96fb3f39ce7d47313468e027e8ccf03c38f72", + "catalog_sha256": "411381fb2417945f8a29eb8db330f82b44d8790d3f1e88c7f766b532c528008f", + "catalog_tools": 212, + "token_proxy": { + "input_catalog_once": 197359, + "arguments": 62, + "text_replies": 3182, + "combined": 200603, + "only_two_selected_tools": 3698, + "complete_transport_catalog": 886882 + } + }, + "ripgrep-selected": { + "answer": { + "packages": [ + { + "name": "serde", + "version": "1.0.210", + "source": "crates.io", + "dependencies": [], + "index": 36 + } + ], + "direct_edges": [] + }, + "parent_evidence_id": "ev_be5662081d3c5b609a5e65a78c732674d2fcc160e5b1173ca0d083316673eaa4", + "artifact_sha256": "78387bbe12b2110df8d2ce364390a00c190f4543a2f40b8279e49f6c5c85af9c", + "complete_result_sha256": "9eb75332f423bcb94f627694cde96fb3f39ce7d47313468e027e8ccf03c38f72", + "catalog_sha256": "ba99f10270e8ab53d765f124856921f59156482d603fa883240e1b4068eab921", + "catalog_tools": 212, + "token_proxy": { + "input_catalog_once": 198278, + "arguments": 87, + "text_replies": 1219, + "combined": 199584, + "only_two_selected_tools": 2256, + "complete_transport_catalog": 891294 + } + } + }, + "accounting": "Kernel cgroup CPU/peak memory at payload exit while an observer remains alive, with verified exclusive observer and cleanup. Final whole-scope counters unavailable; observer overhead is included. All qualified samples have zero high/max/OOM/pid events.", + "published_equivalent_source": "cc83fd1ee3827861ea94d0e8f325e4521323bc89", + "integration_base": "a5f982de8dbe4b092cc20ab89d492b7e7028b758", + "candidate_reproduction": "Primary paired cohort uses candidate_code on baseline_source. Integration rebase production files are byte-identical to published_equivalent_source; original measured commit was pushed before rebase. Driver/counter/resource hashes and runtime tables are pinned separately." +} diff --git a/experiments/analysis-efficiency/rust-dependency-views.md b/experiments/analysis-efficiency/rust-dependency-views.md new file mode 100644 index 0000000000..f556dd931c --- /dev/null +++ b/experiments/analysis-efficiency/rust-dependency-views.md @@ -0,0 +1,176 @@ +# Rust dependency fact views + +Issue [#5802](https://github.com/morluto/rea/issues/5802) asks for exact embedded +crate versions, sources and selected direct relationships without receiving the +entire dependency report. The existing path-only operation returned every +package, dependency vector and edge. This work adds opt-in first-call selection, +summary delivery and retained package/edge queries to the existing CLI/MCP +contracts. Complete delivery remains the default. + +The producer still reads and validates the complete admitted file and report. +This is a context optimization; it does not establish faster binary analysis or +lower peak memory. It adds no tool, path cache, dependency walker or graph index. +Only successful interpretation of an authenticated immutable Evidence result is +reused, keyed by that exact object in a WeakMap for its lifetime. Mutable imports +are revalidated, and every view checks the artifact subject again. + +## Pinned artifacts and independent checks + +[Curated receipts](rust-dependency-views.json) pin source commits, runtime file +tables, source/driver/resource hashes, package URLs and all measured samples. +Baseline is `d672cc23998b6cbb21c485f118595ebfb82fed33`; measured candidate is +`cae2571e7e64a7364b94bb92c2630cba80ec4478`, pushed before the integration rebase. +The production files are byte-identical to published +`cc83fd1ee` after rebasing onto `a5f982de8`. Later integration measurements do +not relabel the original cohort. + +Official Alpine v3.21/community/x86_64 APKs, not locally rebuilt test applications: + +| Artifact | File bytes | Packages / edges | Complete CLI reply bytes | +| ---------------------------------- | ---------: | ---------------: | -----------------------: | +| ripgrep 14.1.1-r0, `usr/bin/rg` | 3,416,168 | 42 / 88 | 10,605 | +| Zellij 0.40.0-r0, `usr/bin/zellij` | 17,151,864 | 422 / 1,063 | 86,805 | + +The [corpus manifest](corpus.json) records APK and extracted-file SHA-256, +transport size and aports revision. Acquisition selected only the named regular +tar member, with bounded download/extraction. Neither binary was executed. +HTTPS provenance and recorded digests are not an APK-signature verification. + +GNU objcopy/readelf 2.40 independently extracted `.dep-v0` into private output +files without overwriting the input. Python 3.11.2 zlib/JSON parsing, following +the [upstream parsing format](https://github.com/rust-secure-code/cargo-auditable/blob/master/PARSING.md), +matched every exposed package field and dependency edge, omitted revision, +root index, compressed/inflated length, exact section range and full artifact +digest. An independent topological pass visited all 42/422 packages and matched +the empty cyclic sets. This qualifies these two ELF artifacts; PE/Mach-O view +coverage below uses constructed format fixtures, not real shipped applications. + +## Equivalent completed SDK tasks + +Linux x64, Node 24.18.0/npm 11.16.0. Three alternating before/after, +after/before, before/after pairs per artifact run serially. Each fresh production +stdio SDK session discovers all 212 tools and uses their named output schemas. +OS caches are unflushed; the controller hashes inputs first. These are fresh +process/session runs, not cold-disk runs. + +The fixed question is: which versions and sources of `serde` and `serde_derive` +are embedded, and is there a direct edge between those selected packages? +Both lanes make exactly two RPCs: inspect once, then export complete Evidence. +Baseline derives the answer from the complete reply. Candidate selects both +names and `name/version/source/dependencies` in the initial inspection. There +are no extra lookup calls. All runs compare the answer and exported complete +parent against the independently checked report, including identical parent +Evidence IDs and source SHA-256. Zellij has the direct edge 294 → 296; +ripgrep has only `serde`, with no such edge within the embedded records. + +Medians of three whole tasks, including startup, discovery, SDK validation, +both calls, answer/export checks and teardown: + +| Artifact | Wall seconds, before → after | Family CPU seconds, before → after | Peak family MiB, before → after | +| -------- | ---------------------------: | ---------------------------------: | ------------------------------: | +| Zellij | 4.957 → 5.671 | 4.782 → 5.441 | 610.3 → 604.3 | +| ripgrep | 4.859 → 4.758 | 4.643 → 4.528 | 569.0 → 587.3 | + +The main cohort's Zellij elapsed/CPU medians increase about 14%; do not read the +context improvement as a whole-task speedup. Two further forward/reverse +diagnostic controls added clocks around connect and discovery, comparing +baseline complete, candidate complete and candidate selected. In the first +control these took 5.460/6.164/6.063 seconds, with connect times +2.516/3.086/3.057 seconds. In the reverse control all took 5.360–5.364 seconds, +with connect times 2.528–2.679 seconds. Candidate complete also varied, so those +controls do not isolate a persistent regression to compact projection. They +do not erase the original slower cohort. No stable speedup, peak-memory +improvement or exact cause of startup variation is established. + +Initial inspector RPC medians were 380.8 → 473.8 ms for Zellij and +189.4 → 244.2 ms for ripgrep. These include first-use named SDK schema validation, +retention, projection and serialization; they are not isolated parser timings. +Extra retained reads need no source reopen, as the CLI/MCP regressions demonstrate; +their warm latency was not benchmarked. + +## Context accounting and limits + +Offline tiktoken 0.12.0, `cl100k_base`, counts all discovered tool descriptions +and input schemas once, every call argument and every text reply, including +export. It counts the whole scripted task rather than only the selected reply: + +| Artifact | All 212 input definitions + calls, before → after | Only the two used definitions + calls, before → after | +| -------- | ------------------------------------------------: | ----------------------------------------------------: | +| Zellij | 223,755 → 199,584 (−10.8%) | 26,850 → 2,256 (−91.6%) | +| ripgrep | 200,603 → 199,584 (−0.5%) | 3,698 → 2,256 (−39.0%) | + +The Zellij inspect response, including both MCP text and structured content, +shrinks from 185,833 to 9,261 bytes; ripgrep shrinks from 22,643 to 9,207. +Those byte counts alone are not token measurements. Full transport catalogs, +including all output schemas, grow from 886,882 to 891,294 estimated tokens. +If all of that transport metadata is charged once along with calls, totals are +913,278 → 892,600 for Zellij and 890,126 → 892,600 for ripgrep: the small case +then costs more. Schema expansion has a cost even when Rust is not called. + +No AI inference, actual model usage/billing, host schema injection, prompt-history +replays or analyst answer quality was measured. The two-definition column is a +discovery scenario, not an observed routed host. Do not extrapolate these +proxies to a host that repeats schemas or truncates full replies. Opt-in +delivery preserves caller choice and avoids forcing the small-case tradeoff. + +## Correctness, resource bounds and rejected changes + +Original-index facts, all matching versions, exact intersecting batch filters, +empty selections, selected fields and matching-row pagination share one domain +projector through the existing application layer. Package defaults omit +dependency vectors; callers explicitly request them for direct references. +Edges support both endpoint filters. Complete parent Evidence stays available +for export, and portable/retained queries still work after deleting the source. +Missing metadata remains unknown, cycles are counted without walking paths, +compiler hashes remain inferred, and embedded coverage never implies complete +runtime dependencies. Defaults preserve complete Evidence identity. + +Executed tests cover real CLI/stdio SDK schemas, ELF/PE/thin-Mach-O fixtures, +duplicate package names/versions, retained and portable imports, malformed +edge imports, subject mismatch, unknown metadata, contradictory delivery, +retention requirements, bounded warnings and immutable-cache trust. Existing +Go, malformed Rust, cancellation, capacity and cleanup coverage is retained. +Full typecheck/lint/format/knip/module/architecture checks passed before the +integration rebase; final integration checks are recorded in the PR. + +Runtime tasks use one pinned CPU, 4-GiB hard/3-GiB early-stop family limits, +no swap, 192 tasks and host-memory preflight/stop guards. All 12 primary and +six diagnostic samples have verified payload exit/cleanup and zero high/max/ +OOM/pid events. CPU and peak memory use cgroup counters at payload exit while +the observer remains alive; final whole-scope counters are unavailable. +Build/check guards use 6/8-GiB hard bounds respectively. An existing 30-second +mixed Go/Rust test timed out in the initial single-CPU cohort; all nine CLI +acceptance tests passed with a bounded two-CPU/4-GiB rerun, without changing +the test's timeout. Other corrected prototype failures remain in the receipts. + +A proposed JS JSON-size counter replacement had inconsistent provider timing +and no demonstrated memory benefit on these artifacts. It was reverted; the +final Rust provider is unchanged. Full-file input allocation, complete report +construction, ledger retention and schema duplication remain costs. This issue +does not qualify every Rust workflow, larger graphs, real PE/Mach-O applications, +WASM support or current runtime dependency state. + +## Reproduction + +Acquire the exact public APK URLs from the manifest and extract only the named +regular binary. Verify both SHA-256 values before inspection. Use objcopy +`--dump-section .dep-v0=SECTION INPUT PRIVATE_COPY`, then Python zlib/JSON to +compare the normalized package/edge records and section bytes. Save the verified +complete CLI `normalized_result` as `EXPECTED`. + +Build isolated baseline/candidate runtimes with the pinned toolchain. Keep raw +outputs outside Git. For each artifact, alternate three serial pairs: + +```bash +python3 experiments/analysis-efficiency/resource-run.py --observe-payload-exit -- \ + node experiments/analysis-efficiency/rust-dependency-views.mjs \ + --runtime /absolute/runtime --artifact /absolute/binary \ + --expected /absolute/expected-report.json --output /absolute/new-case \ + --lane complete +# Candidate uses --lane selected. Both lanes include complete Evidence export. +``` + +With private `tiktoken==0.12.0`, run +`rust-dependency-costs.py --output FILE CASE...`; each `CASE.log` must contain +qualified guard observations. The counter rejects failed tasks, missing +cleanup and resource events. Guard-stopped attempts are not completed samples. diff --git a/experiments/analysis-efficiency/rust-dependency-views.mjs b/experiments/analysis-efficiency/rust-dependency-views.mjs new file mode 100644 index 0000000000..1306ddb1d8 --- /dev/null +++ b/experiments/analysis-efficiency/rust-dependency-views.mjs @@ -0,0 +1,172 @@ +/** Equivalent selected dependency questions; raw artifacts/transcripts stay outside Git. */ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { createWriteStream } from "node:fs"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { finished } from "node:stream/promises"; +import { parseArgs } from "node:util"; +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { createRetainedQueryCaller } from "./retained-query-calls.mjs"; +import { finalizeRetainedQueryReceipt } from "./retained-query-finalization.mjs"; + +const { values } = parseArgs({ + options: { + ...Object.fromEntries( + ["runtime", "artifact", "expected", "output"].map((name) => [ + name, + { type: "string" }, + ]), + ), + lane: { type: "string" }, + }, +}); +for (const name of ["runtime", "artifact", "expected", "output"]) + assert.ok(values[name] && values[name] === resolve(values[name]), name); +assert.ok(["complete", "selected"].includes(values.lane)); +const hash = (value) => createHash("sha256").update(value).digest("hex"); +const expected = JSON.parse(await readFile(values.expected, "utf8")); +assert.equal(expected.artifact.path, values.artifact); +const names = ["serde", "serde_derive"]; +const fields = ["name", "version", "source", "dependencies"]; +const choose = (report) => + (report.auditable_metadata?.packages ?? []).flatMap((row, index) => + names.includes(row.name) + ? [ + { + index, + ...Object.fromEntries(fields.map((field) => [field, row[field]])), + }, + ] + : [], + ); +const answer = (rows) => { + const targets = new Set( + rows.filter((row) => row.name === "serde_derive").map((row) => row.index), + ); + return { + packages: rows, + direct_edges: rows + .filter((row) => row.name === "serde") + .flatMap((row) => + row.dependencies + .filter((index) => targets.has(index)) + .map((to_index) => ({ from_index: row.index, to_index })), + ), + }; +}; +const expectedRows = choose(expected); +await mkdir(values.output, { mode: 0o700 }); +const account = await mkdtemp(join(tmpdir(), "rea-rust-views-")); +const client = new Client({ name: "rust-dependency-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", + TMPDIR: account, + }, + stderr: "pipe", +}); +const serverLog = createWriteStream(join(values.output, "server.log")); +const serverLogFinished = finished(serverLog); +serverLogFinished.catch(() => {}); +transport.stderr?.pipe(serverLog); +const report = { + lane: values.lane, + artifact_sha256: hash(await readFile(values.artifact)), + normalized_sha256: hash(JSON.stringify(expected)), + calls: [], +}; +const transcript = []; +const call = createRetainedQueryCaller(client, (receipt, event) => { + report.calls.push(receipt); + transcript.push(event); +}); +const observe = async (name, input, phase) => { + const reply = await call(name, input, phase); + assert.notEqual(reply.isError, true, JSON.stringify(reply)); + assert.ok(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; + report.catalog_sha256 = hash(JSON.stringify(catalog)); + 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, + toolDefinition: definitions.get(input.name), + }); + const selected = values.lane === "selected"; + const reply = await observe( + "inspect_rust_binary", + { + path: values.artifact, + ...(selected ? { view: { collection: "packages", names, fields } } : {}), + }, + "question", + ); + const parentId = selected + ? reply.normalized_result.parent_evidence_id + : reply.evidence_id; + const rows = selected + ? reply.normalized_result.page.rows + : choose(reply.normalized_result); + assert.deepEqual(rows, expectedRows); + report.answer = answer(rows); + assert.deepEqual(report.answer, answer(expectedRows)); + report.parent_evidence_id = parentId; + await observe( + "export_evidence_bundle", + { path: join(values.output, "evidence.json") }, + "export", + ); + const bundle = JSON.parse( + await readFile(join(values.output, "evidence.json"), "utf8"), + ); + const parent = bundle.records.find( + (record) => record.evidence_id === parentId, + ); + assert.ok(parent); + assert.deepEqual(parent.normalized_result, expected); + assert.equal(parent.subject.digest.sha256, report.artifact_sha256); + if (selected) + assert.deepEqual(reply.normalized_result.source_coverage, { + auditable_metadata_available: expected.auditable_metadata !== null, + embedded_records: + expected.auditable_metadata === null ? "unknown" : "complete", + runtime_dependencies: "unknown", + }); + assert.equal(hash(await readFile(values.artifact)), report.artifact_sha256); + report.status = "passed"; +} 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; +} diff --git a/src/application/rust/RustBinaryService.test.ts b/src/application/rust/RustBinaryService.test.ts index 69260975ef..0f85cf424b 100644 --- a/src/application/rust/RustBinaryService.test.ts +++ b/src/application/rust/RustBinaryService.test.ts @@ -4,7 +4,7 @@ import { AnalysisInputError, AnalysisOutputError, } from "../../domain/analysisErrorCore.js"; -import { parseEvidence } from "../../domain/evidence.js"; +import { parseEvidence, type Evidence } from "../../domain/evidence.js"; import type { RustBinary } from "../../domain/rust/rustBinary.js"; import { ok } from "../../domain/result.js"; import { RustBinaryService } from "./RustBinaryService.js"; @@ -59,7 +59,7 @@ it("rejects a relative path and extra arguments before reading", async () => { const relative = await service.inspect({ path: "program" }); const extra = await service.inspect({ path: report.artifact.path, - detail: "complete", + unrecognized: true, }); expect(relative.ok).toBe(false); expect(extra.ok).toBe(false); @@ -221,3 +221,70 @@ it.each([ expect(reason.cleanupIncomplete).toBe(true); }, ); + +it("requires retention and rejects contradictory detail before invoking the reader", async () => { + let calls = 0; + const service = new RustBinaryService({ + identity, + inspect: async () => { + calls++; + return ok(report); + }, + }); + const missing = await service.inspect({ + path: report.artifact.path, + detail: "summary", + }); + expect(missing.ok).toBe(false); + const conflict = await service.inspect({ + path: report.artifact.path, + detail: "complete", + view: { collection: "packages" }, + }); + if (conflict.ok) throw new Error("Contradictory delivery was accepted"); + expect(conflict.error).toBeInstanceOf(AnalysisInputError); + expect(calls).toBe(0); +}); + +it("retains the unchanged parent and keeps delivery selectors out of provider inputs", async () => { + const inputs: unknown[] = []; + const service = new RustBinaryService({ + identity, + inspect: async (input) => { + inputs.push(input); + return ok(report); + }, + }); + const first = await service.inspect({ path: report.artifact.path }); + const explicit = await service.inspect({ + path: report.artifact.path, + detail: "complete", + }); + if (!first.ok || !explicit.ok) throw new Error("Complete delivery failed"); + expect(explicit.value).toEqual(first.value); + const records = new Map(); + const selected = await service.inspect( + { + path: report.artifact.path, + view: { collection: "packages", names: ["sample"], fields: ["name"] }, + }, + undefined, + { + recordEvidence: (evidence) => { + records.set(evidence.evidence_id, evidence); + return ok("added" as const); + }, + evidenceById: (id) => records.get(id), + }, + ); + if (!selected.ok) throw selected.error; + expect(selected.value.normalized_result).toMatchObject({ + kind: "rust", + parent_evidence_id: first.value.evidence_id, + page: { collection: "packages", rows: [{ index: 0, name: "sample" }] }, + }); + expect(records.get(first.value.evidence_id)).toEqual(first.value); + expect(inputs).toEqual( + Array.from({ length: 3 }, () => ({ path: report.artifact.path })), + ); +}); diff --git a/src/application/rust/RustBinaryService.ts b/src/application/rust/RustBinaryService.ts index 0c999958c2..61e5c2d4aa 100644 --- a/src/application/rust/RustBinaryService.ts +++ b/src/application/rust/RustBinaryService.ts @@ -8,30 +8,22 @@ import { AnalysisOutputError, } from "../../domain/analysisErrorCore.js"; import { createEvidence, type Evidence } from "../../domain/evidence.js"; -import { BINARY_ARCHITECTURES } from "../../domain/binaryTargetTypes.js"; import { - inspectRustBinaryInputSchema, + rustArchitectureForSubject, rustBinarySchema, } from "../../domain/rust/rustBinary.js"; +import { inspectRustBinaryRequestSchema } from "../../domain/rust/rustBinaryQuery.js"; import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; import { err, ok, type Result } from "../../domain/result.js"; import { completeProviderEvidence } from "../ProviderOperationExecution.js"; +import { + requireAnalysisViewRetention, + projectRetainedAnalysis, + type AnalysisViewRetention, +} from "../analysisView/AnalysisViewService.js"; const OPERATION = "inspect_rust_binary"; -const architectureForSubject = (architecture: string) => { - switch (architecture) { - case "386": - return "x86"; - case "amd64": - return "x86_64"; - default: - return BINARY_ARCHITECTURES.find( - (supported) => supported === architecture, - ); - } -}; - /** Shared exact input validation and artifact-bound Evidence for CLI and MCP. */ export class RustBinaryService { constructor(readonly provider: RustBinaryPort) {} @@ -44,12 +36,16 @@ export class RustBinaryService { async inspect( rawInput: unknown, options?: ExecutionOptions, + retention: AnalysisViewRetention = { + recordEvidence: undefined, + evidenceById: undefined, + }, ): Promise> { if (options?.signal?.aborted) return err( new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }), ); - const input = inspectRustBinaryInputSchema.safeParse(rawInput); + const input = inspectRustBinaryRequestSchema.safeParse(rawInput); if (!input.success) return err( analysisInputErrorFromIssues(OPERATION, input.error.issues, rawInput, { @@ -66,7 +62,13 @@ export class RustBinaryService { }, ]), ); - const inspected = await this.provider.inspect(input.data, options); + const { detail, view, ...parameters } = input.data; + const compact = detail === "summary" || view !== undefined; + if (compact) { + const ready = requireAnalysisViewRetention(OPERATION, retention); + if (!ready.ok) return ready; + } + const inspected = await this.provider.inspect(parameters, options); if (!inspected.ok) return inspected; const report = rustBinarySchema.safeParse(inspected.value); if (!report.success || report.data.artifact.path !== input.data.path) @@ -76,10 +78,17 @@ export class RustBinaryService { "Rust dependency reader returned malformed metadata or changed the selected artifact identity.", ), ); - return completeProviderEvidence( - evidenceFor(this.provider.identity, input.data, report.data), + const completed = completeProviderEvidence( + evidenceFor(this.provider.identity, parameters, report.data), options?.signal, ); + return compact && completed.ok + ? projectRetainedAnalysis( + completed.value, + retention, + view === undefined ? { kind: "summary" } : { kind: "rust", ...view }, + ) + : completed; } } @@ -88,7 +97,7 @@ const evidenceFor = ( parameters: { readonly path: string }, value: ReturnType, ): Evidence => { - const architecture = architectureForSubject(value.architecture); + const architecture = rustArchitectureForSubject(value.architecture); const section = value.auditable_metadata; return createEvidence( { diff --git a/src/cli/rustCommands.ts b/src/cli/rustCommands.ts index 119892afb7..e71546d5cf 100644 --- a/src/cli/rustCommands.ts +++ b/src/cli/rustCommands.ts @@ -2,7 +2,14 @@ import { z } from "incur"; import { createRustBinaryService } from "../composition/rust.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { logCliCommand } from "../cliLogging.js"; -import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; +import { + analysisEvidenceDeliveryOptions, + deliverCliAnalysisEvidence, +} from "./analysisEvidenceDelivery.js"; +import { inspectRustBinaryRequestSchema } from "../domain/rust/rustBinaryQuery.js"; +import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; +import { AnalysisInputError } from "../domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; @@ -19,22 +26,68 @@ export const registerRustCommands = ( args: z.object({ path: z.string().describe("Local ELF, PE or thin Mach-O image"), }), - run: ({ args }) => - withCommandCancellation((signal) => { - const service = createRustBinaryService(); - return logCliCommand(logger, CLI_COMMANDS.inspectRustBinary, () => - finishAnalysisCommand( - () => - service.inspect( - { path: absoluteCallerPath(args.path) }, - { signal }, + options: z.object({ + ...analysisEvidenceDeliveryOptions, + detail: z + .enum(["complete", "summary"]) + .optional() + .describe( + "Default complete; a view selects compact delivery with --evidence_output", + ), + view: z + .string() + .optional() + .describe( + "JSON package or edge selection; retains complete Evidence with --evidence_output", + ), + }), + run: ({ args, options }) => + withCommandCancellation((signal) => + logCliCommand(logger, CLI_COMMANDS.inspectRustBinary, async () => { + let view: unknown; + try { + view = + options.view === undefined ? undefined : JSON.parse(options.view); + } catch (cause) { + return projectAnalysisError( + new AnalysisInputError("inspect_rust_binary", { cause }, [ + { + path: ["view"], + reason: "invalid_format", + message: "view must be a JSON package or edge selection", + }, + ]), + ); + } + const input = inspectRustBinaryRequestSchema.safeParse({ + path: absoluteCallerPath(args.path), + ...(options.detail === undefined ? {} : { detail: options.detail }), + ...(view === undefined ? {} : { view }), + }); + if (!input.success) + return projectAnalysisError( + analysisInputErrorFromIssues( + "inspect_rust_binary", + input.error.issues, + { view }, ), + ); + const service = createRustBinaryService(); + return deliverCliAnalysisEvidence({ + run: () => service.inspect({ path: input.data.path }, { signal }), service, - "rust-auditable", - "inspect_rust_binary", + providerId: "rust-auditable", + operation: "inspect_rust_binary", signal, - ), - ); - }), + detail: + input.data.detail ?? + (input.data.view === undefined ? "complete" : "summary"), + evidenceOutput: options.evidence_output, + ...(input.data.view === undefined + ? {} + : { view: { kind: "rust" as const, ...input.data.view } }), + }); + }), + ), }); }; diff --git a/src/contracts/analysisViewToolContracts.ts b/src/contracts/analysisViewToolContracts.ts index e14192da88..e6a79eb61c 100644 --- a/src/contracts/analysisViewToolContracts.ts +++ b/src/contracts/analysisViewToolContracts.ts @@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_analysis_view"), kind: "application", description: - 'Inspect selected views of completed Frida session status, GDB/objdump command observations, 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, inspect_model_artifact or inspect_web_network_capture Evidence without repeating analysis. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. 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: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. A completed Evidence JSON file on this host uses {kind: "evidence-file", 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 references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions. Occurrences accept exact logical_paths and occurrence_ids; nodes accept artifact_ids. Batch filters intersect (omitted means all available, [] means none); fields selects columns while retaining identity links. source_complete describes retained graph membership; source_coverage describes producer coverage, including unknown encrypted PCK directories. Page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings and carry the source selection and coverage. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Protobuf views use kind protobuf, collection bodies/frames/fields, with record_ordinals, body_pointers and fields; field pages also select frame_indices, field_ids, field_numbers or schema_names. Byte identity, offset basis and body status remain present. Text and schema_value are opt-in page columns. Model views use kind model-artifact and collection metadata (keys), tensors (names/types) or types (derived totals), with indices, fields and paging. Header SHA-256 scopes declarations; full-file identity remains unknown unless full hashing was selected. Arrays retain typed eight-element previews with explicit omissions. IL2CPP metadata views use kind il2cpp-metadata and collection sections/images/types/methods/fields/properties/events/parameters/string_literals/code_gen_modules/method_addresses/return_types/field_types/parameter_types. Select indices, fields and pages; named rows accept names, types also accept image_indices/namespaces/declaring_types, and methods/members accept declaring_types. Parameters and parameter_types accept method_indices; code_gen_modules accept image_indices; method_addresses accept addresses/states, preserving every folded alternative. Row index is scoped to the metadata digest; native pages add binary_source. Uncollected native tables have null counts and remain unavailable. Desktop directory views use kind desktop-application and collection files/omissions/candidates/components/next_probes, with fields and pagination; files and omissions accept exact paths or their stable IDs, and candidates accept families. Source coverage and stable row IDs remain available. Native inventory Evidence from list_names, list/search_procedures or list/search_strings uses view: {kind: "native-inventory", offset: 0, limit: 64, fields: ["value"]}. Rows keep source_index and address even with selected fields. Summary counts describe retained rows; provider discovery coverage remains unknown. Native function Evidence requires kind native: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', + 'Inspect selected views of completed Frida session status, GDB/objdump command observations, Go build metadata, Rust embedded dependencies, binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot, inspect_il2cpp_metadata, inspect_model_artifact or inspect_web_network_capture Evidence without repeating analysis. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized 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: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. A completed Evidence JSON file on this host uses {kind: "evidence-file", 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 references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions. Occurrences accept exact logical_paths and occurrence_ids; nodes accept artifact_ids. Batch filters intersect (omitted means all available, [] means none); fields selects columns while retaining identity links. source_complete describes retained graph membership; source_coverage describes producer coverage, including unknown encrypted PCK directories. Page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings and carry the source selection and coverage. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Protobuf views use kind protobuf, collection bodies/frames/fields, with record_ordinals, body_pointers and fields; field pages also select frame_indices, field_ids, field_numbers or schema_names. Byte identity, offset basis and body status remain present. Text and schema_value are opt-in page columns. Model views use kind model-artifact and collection metadata (keys), tensors (names/types) or types (derived totals), with indices, fields and paging. Header SHA-256 scopes declarations; full-file identity remains unknown unless full hashing was selected. Arrays retain typed eight-element previews with explicit omissions. IL2CPP metadata views use kind il2cpp-metadata and collection sections/images/types/methods/fields/properties/events/parameters/string_literals/code_gen_modules/method_addresses/return_types/field_types/parameter_types. Select indices, fields and pages; named rows accept names, types also accept image_indices/namespaces/declaring_types, and methods/members accept declaring_types. Parameters and parameter_types accept method_indices; code_gen_modules accept image_indices; method_addresses accept addresses/states, preserving every folded alternative. Row index is scoped to the metadata digest; native pages add binary_source. Uncollected native tables have null counts and remain unavailable. Desktop directory views use kind desktop-application and collection files/omissions/candidates/components/next_probes, with fields and pagination; files and omissions accept exact paths or their stable IDs, and candidates accept families. Source coverage and stable row IDs remain available. Native inventory Evidence from list_names, list/search_procedures or list/search_strings uses view: {kind: "native-inventory", offset: 0, limit: 64, fields: ["value"]}. Rows keep source_index and address even with selected fields. Summary counts describe retained rows; provider discovery coverage remains unknown. Native function Evidence requires kind native: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', inputSchema: inspectAnalysisViewInputSchema, outputSchema: evidenceResultOf(analysisViewResultSchema), examples: [ diff --git a/src/contracts/rust/rustToolContracts.ts b/src/contracts/rust/rustToolContracts.ts index 7f827dfbed..966a057dbe 100644 --- a/src/contracts/rust/rustToolContracts.ts +++ b/src/contracts/rust/rustToolContracts.ts @@ -1,7 +1,10 @@ +import { rustBinarySchema } from "../../domain/rust/rustBinary.js"; +import { inspectRustBinaryRequestSchema } from "../../domain/rust/rustBinaryQuery.js"; +import { z } from "zod"; import { - inspectRustBinaryInputSchema, - rustBinarySchema, -} from "../../domain/rust/rustBinary.js"; + rustBinarySummaryViewResultSchema, + rustBinaryPageViewResultSchema, +} from "../../domain/analysisView/analysisView.js"; import type { ToolContract } from "../toolContractTypes.js"; import { toolContractMetadata } from "../toolEffects.js"; import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js"; @@ -13,9 +16,15 @@ export const RUST_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_rust_binary"), kind: "artifact-provider", description: - "Inspect the cargo-auditable crate dependency list embedded in an explicit local ELF, PE or thin Mach-O image. Returns the artifact SHA-256, the .dep-v0 section file offset and size, compressed and uncompressed sizes, format version, packages in their original order, the root package index, and dependency edges by index. Reads zlib-compressed JSON without installing Rust, opening a disassembler, executing the target, writing temporary files or accessing the network. A missing section is null metadata with a limitation and is not proof that the binary has no dependencies. A rustc commit hash is reported only as an inference when a /rustc/<40-hex>/ path is present in read-only data. Resource guards bound files to 256 MiB, dependency sections to 1 MiB, inflated JSON to the smaller of 8 MiB and 64 times the compressed size, and the complete report to 8 MiB. Malformed, unsupported and oversized inputs receive typed failures rather than partial success.", - inputSchema: inspectRustBinaryInputSchema, - outputSchema: evidenceResultOf(rustBinarySchema), + "Inspect the cargo-auditable crate dependency list embedded in an explicit local ELF, PE or thin Mach-O image. Returns the artifact SHA-256, the .dep-v0 section file offset and size, compressed and uncompressed sizes, format version, packages in their original order, the root package index, and dependency edges by index. Omitted or explicit complete detail preserves the full report. Optional detail summary retains that complete Evidence and returns source ranges, counts and parent_evidence_id. Optional view directly selects packages or edges in this call; it cannot be combined with explicit complete detail. Query the same retained facts with inspect_analysis_view kind rust: exact names/versions or package indices, edge from_indices/to_indices, fields and matching-row pagination. Row indices are original and scoped to the parent Evidence; empty filters select none. Default package fields omit dependency vectors; explicitly select dependencies for direct index references. Every matching observed version is retained. No current runtime state is inferred from historical reads. Reads zlib-compressed JSON without installing Rust, opening a disassembler, executing the target, writing temporary files or accessing the network. A missing section is null metadata with a limitation and is not proof that the binary has no dependencies. A rustc commit hash is reported only as an inference when a /rustc/<40-hex>/ path is present in read-only data. Resource guards bound files to 256 MiB, dependency sections to 1 MiB, inflated JSON to the smaller of 8 MiB and 64 times the compressed size, and the complete report to 8 MiB. Malformed, unsupported and oversized inputs receive typed failures rather than partial success.", + inputSchema: inspectRustBinaryRequestSchema, + outputSchema: evidenceResultOf( + z.union([ + rustBinarySchema, + rustBinarySummaryViewResultSchema, + rustBinaryPageViewResultSchema, + ]), + ), examples: [ { title: "Inspect an embedded Rust dependency list", diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index c16b443ad6..0020f4aec4 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -23,6 +23,13 @@ import { goBinaryViewRequestSchema, projectGoBinaryView, } from "./goBinaryView.js"; +import { + rustBinarySummarySchema, + rustBinaryViewContextSchema, + rustBinaryViewPageSchema, + rustBinaryViewRequestSchema, + projectRustBinaryView, +} from "./rustBinaryView.js"; import { projectNativeFunctionMatchingView, nativeFunctionMatchingViewRequestSchema, @@ -277,6 +284,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [ nativeFunctionMatchingViewRequestSchema, summaryViewSchema, goBinaryViewRequestSchema, + rustBinaryViewRequestSchema, desktopViewRequestSchema, facetViewSchema, itemViewSchema, @@ -462,6 +470,24 @@ export const goBinarySummaryViewResultSchema = z.strictObject({ summary: goBinarySummarySchema, }); +/** Compact source identity and counts, retaining the complete embedded graph. */ +export const rustBinarySummaryViewResultSchema = z.strictObject({ + ...summaryViewResultBase, + ...rustBinaryViewContextSchema.shape, + parent_operation: z.literal("inspect_rust_binary"), + summary: rustBinarySummarySchema, +}); + +export const rustBinaryPageViewResultSchema = z.strictObject({ + ...viewResultBase, + ...rustBinaryViewContextSchema.shape, + kind: z.literal("rust"), + parent_operation: z.literal("inspect_rust_binary"), + view: rustBinaryViewRequestSchema, + rust: rustBinarySummarySchema, + page: rustBinaryViewPageSchema, +}); + /** Summary `inspect_binary_layout` detail returns; no other view kind. */ export const binaryLayoutSummaryViewResultSchema = z.strictObject({ ...summaryViewResultBase, @@ -665,6 +691,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ objdumpSummaryViewResultSchema, fridaSessionStatusSummaryViewResultSchema, goBinarySummaryViewResultSchema, + rustBinarySummaryViewResultSchema, binaryLayoutSummaryViewResultSchema, desktopApplicationSummaryViewResultSchema, javascriptApplicationSummaryViewResultSchema, @@ -738,6 +765,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ view: goBinaryViewRequestSchema, page: goBinaryViewPageSchema, }), + rustBinaryPageViewResultSchema, z.strictObject({ kind: z.literal("item"), view: itemViewSchema, @@ -954,6 +982,7 @@ const retainedViewProjectors = new Map([ ["gdb_console", projectCommandObservationView], ["inspect_with_objdump", projectCommandObservationView], ["inspect_go_binary", projectGoBinaryView], + ["inspect_rust_binary", projectRustBinaryView], ...NATIVE_INVENTORY_OPERATIONS.map( (operation) => [operation, projectNativeInventoryView] as const, ), diff --git a/src/domain/analysisView/goBinaryView.ts b/src/domain/analysisView/goBinaryView.ts index bbc1c511a5..7e43774d29 100644 --- a/src/domain/analysisView/goBinaryView.ts +++ b/src/domain/analysisView/goBinaryView.ts @@ -22,6 +22,7 @@ import { } from "./analysisViewCoverage.js"; import { analysisViewInputError } from "./analysisViewErrors.js"; import { parseAnalysisViewResult } from "./analysisViewParent.js"; +import { selectViewRows } from "./selectViewRows.js"; const indexSchema = z.number().int().nonnegative(); const count = z.number().int().nonnegative(); @@ -123,23 +124,6 @@ const parseReport = ( return ok(report); }; -const selectRows = ( - 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, @@ -259,7 +243,7 @@ export const projectGoBinaryView = ( view.fields === undefined ? undefined : new Set(view.fields); if (view.collection === "dependencies") { const paths = view.paths === undefined ? undefined : new Set(view.paths); - const selected = selectRows( + const selected = selectViewRows( info?.module.dependencies ?? [], view, (row, index) => @@ -287,7 +271,7 @@ export const projectGoBinaryView = ( }); } const keys = view.keys === undefined ? undefined : new Set(view.keys); - const selected = selectRows( + const selected = selectViewRows( info?.module.settings ?? [], view, (row, index) => diff --git a/src/domain/analysisView/rustBinaryView.test.ts b/src/domain/analysisView/rustBinaryView.test.ts new file mode 100644 index 0000000000..074ac8dd2f --- /dev/null +++ b/src/domain/analysisView/rustBinaryView.test.ts @@ -0,0 +1,173 @@ +import { expect, it } from "vitest"; +import { createEvidence } from "../evidence.js"; +import type { RustBinary } from "../rust/rustBinary.js"; +import { + projectAnalysisView, + type AnalysisViewParent, +} from "./analysisView.js"; + +const report: RustBinary = { + artifact: { path: "/artifacts/rust", sha256: "a".repeat(64), bytes: 512 }, + format: "elf", + architecture: "amd64", + bits: 64, + byte_order: "little", + auditable_metadata: { + section_name: ".dep-v0", + file_offset: 32, + section_bytes: 64, + compressed_bytes: 64, + uncompressed_bytes: 128, + format_version: null, + packages: [ + { + name: "root", + version: "1", + source: "local", + kind: null, + root: true, + dependencies: [0], + }, + ], + root_package_index: 0, + dependency_edges: [{ from_index: 0, to_index: 0 }], + cyclic_dependency_indices: [0], + }, + rustc_commit: null, + limitations: [], +}; +const parent = (value = structuredClone(report)): AnalysisViewParent => { + const evidence = createEvidence( + { + path: value.artifact.path, + sha256: value.artifact.sha256, + format: "elf", + architecture: "x86_64", + }, + { id: "rust-auditable", name: "Rust reader", version: "1" }, + { + operation: "inspect_rust_binary", + parameters: { path: value.artifact.path }, + result: value, + }, + ); + return { + evidenceId: evidence.evidence_id, + operation: evidence.operation, + subject: evidence.subject, + normalizedResult: value, + limitations: [], + }; +}; + +it("revalidates mutable and merely frozen Rust imports despite a claimed seal", () => { + const mutable = structuredClone(report); + const input = { ...parent(mutable), sealedResult: true }; + expect(projectAnalysisView(input, { kind: "summary" }).ok).toBe(true); + Object.freeze(mutable); + mutable.auditable_metadata!.dependency_edges[0]!.to_index = 99; + expect( + projectAnalysisView(input, { + kind: "rust", + collection: "edges", + offset: 0, + limit: 1, + }).ok, + ).toBe(false); +}); + +it("checks exact artifact identity and subject architecture for every Rust view", () => { + for (const changed of [ + { local_path: "/different" }, + { format: "pe" as const }, + { architecture: "arm64" as const }, + { digest: { sha256: "b".repeat(64) } }, + ]) { + const source = parent(); + if (source.subject == null || source.subject.format !== "elf") + throw new Error("Missing ELF source"); + const input = { ...source, subject: { ...source.subject, ...changed } }; + expect(projectAnalysisView(input, { kind: "summary" }).ok).toBe(false); + expect( + projectAnalysisView(input, { + kind: "rust", + collection: "packages", + offset: 0, + limit: 1, + }).ok, + ).toBe(false); + } +}); + +it("bounds inherited warnings and retains cycle counts without walking a cyclic graph", () => { + const input = { ...parent(), limitations: ["x".repeat(300_000)] }; + const result = projectAnalysisView(input, { kind: "summary" }); + if (!result.ok) throw result.error; + expect(result.value).toMatchObject({ + summary: { + auditable_metadata: { + format_version: null, + counts: { packages: 1, edges: 1, cyclic_packages: 1 }, + }, + }, + source_coverage: { + embedded_records: "complete", + runtime_dependencies: "unknown", + }, + }); + expect(JSON.stringify(result.value).length).toBeLessThan(5000); + expect(result.value.limitations.join(" ")).toContain(input.evidenceId); +}); + +it("pages matching original indices and omits dependency vectors unless selected", () => { + const value = structuredClone(report); + value.auditable_metadata!.packages.push({ + name: "other", + version: "1", + source: "local", + kind: null, + root: false, + dependencies: [], + }); + const source = parent(value); + const first = projectAnalysisView(source, { + kind: "rust", + collection: "packages", + offset: 0, + limit: 1, + }); + if (!first.ok || first.value.kind !== "rust") + throw new Error("Rust page failed"); + expect(first.value.page.rows).toEqual([ + { + index: 0, + name: "root", + version: "1", + source: "local", + kind: null, + root: true, + }, + ]); + expect(first.value.coverage).toMatchObject({ + total: 2, + examined: 1, + exhausted: false, + next_offset: 1, + }); + const next = projectAnalysisView(source, { + kind: "rust", + collection: "packages", + offset: 1, + limit: 1, + fields: [], + }); + if (!next.ok || next.value.kind !== "rust") + throw new Error("Rust continuation failed"); + expect(next.value.page.rows).toEqual([{ index: 1 }]); + expect(next.value.coverage).toMatchObject({ + total: 2, + examined: 1, + exhausted: true, + next_offset: null, + }); +}); diff --git a/src/domain/analysisView/rustBinaryView.ts b/src/domain/analysisView/rustBinaryView.ts new file mode 100644 index 0000000000..e192c245b6 --- /dev/null +++ b/src/domain/analysisView/rustBinaryView.ts @@ -0,0 +1,277 @@ +import { z } from "zod"; +import type { AnalysisError } from "../analysisErrorBase.js"; +import { + rustArchitectureForSubject, + rustBinarySchema, + rustAuditableMetadataSchema, + rustcCommitSchema, + type RustBinary, +} from "../rust/rustBinary.js"; +export { + rustBinaryViewPageSchema, + rustBinaryViewRequestSchema, +} from "../rust/rustBinaryQuery.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"; +import { selectViewRows } from "./selectViewRows.js"; + +const count = z.number().int().nonnegative(); +export const rustBinarySummarySchema = z.strictObject({ + bytes: count, + format: rustBinarySchema.shape.format, + architecture: rustBinarySchema.shape.architecture, + bits: rustBinarySchema.shape.bits, + byte_order: rustBinarySchema.shape.byte_order, + auditable_metadata: rustAuditableMetadataSchema + .omit({ + packages: true, + dependency_edges: true, + cyclic_dependency_indices: true, + }) + .extend({ + counts: z.strictObject({ + packages: count, + edges: count, + cyclic_packages: count, + }), + }) + .nullable(), + rustc_commit: rustcCommitSchema + .omit({ hashes: true }) + .extend({ hashes_count: count }) + .nullable(), +}); +export const rustBinaryViewContextSchema = z.strictObject({ + source_coverage: z.strictObject({ + auditable_metadata_available: z.boolean(), + embedded_records: z.enum(["complete", "unknown"]), + runtime_dependencies: z.literal("unknown"), + }), +}); + +// Exact immutable Evidence identity, not a path or a live-file result cache. +// Mutable/merely frozen imports are validated normally; no failures are cached. +const checkedReports = new WeakMap(); +const parseReport = ( + parent: AnalysisViewParent, +): Result => { + const key = parent.normalizedResult; + const reusable = + parent.sealedResult === true && + typeof key === "object" && + key !== null && + isImmutableJsonSnapshot(key); + const known = reusable ? checkedReports.get(key) : undefined; + if (known !== undefined) return ok(known); + const parsed = parseAnalysisViewResult(rustBinarySchema, parent); + if (!parsed.ok) return parsed; + const report = reusable ? (key as RustBinary) : parsed.value; + if (reusable) checkedReports.set(key, report); + return ok(report); +}; + +const summarize = ( + report: RustBinary, +): z.infer => { + const { + artifact, + auditable_metadata: metadata, + rustc_commit: compiler, + } = report; + const section = + metadata === null + ? null + : { + section_name: metadata.section_name, + file_offset: metadata.file_offset, + section_bytes: metadata.section_bytes, + compressed_bytes: metadata.compressed_bytes, + uncompressed_bytes: metadata.uncompressed_bytes, + format_version: metadata.format_version, + root_package_index: metadata.root_package_index, + counts: { + packages: metadata.packages.length, + edges: metadata.dependency_edges.length, + cyclic_packages: metadata.cyclic_dependency_indices.length, + }, + }; + return { + format: report.format, + architecture: report.architecture, + bits: report.bits, + byte_order: report.byte_order, + bytes: artifact.bytes, + auditable_metadata: section, + rustc_commit: + compiler === null + ? null + : { + evidence_kind: compiler.evidence_kind, + hashes_count: compiler.hashes.length, + }, + }; +}; + +/** Query owner-normalized facts without reopening a source or walking dependency paths. */ +export const projectRustBinaryView = ( + parent: AnalysisViewParent, + view: AnalysisViewRequest, +): Result => { + if (view.kind !== "summary" && view.kind !== "rust") + return err( + analysisViewInputError([ + { + path: ["view"], + reason: "invalid_value", + message: "Rust Evidence supports summary and rust views.", + }, + ]), + ); + const parsed = parseReport(parent); + if (!parsed.ok) return parsed; + const report = parsed.value; + const subject = parent.subject; + if ( + subject == null || + subject.local_path !== report.artifact.path || + subject.digest.sha256 !== report.artifact.sha256 || + subject.format !== (report.format === "macho" ? "mach-o" : report.format) || + subject.architecture !== + (rustArchitectureForSubject(report.architecture) ?? null) + ) + return err( + analysisViewInputError([ + { + path: ["source", "subject"], + reason: "invalid_value", + message: + "Rust parent subject must identify the report's exact artifact.", + }, + ]), + ); + const metadata = report.auditable_metadata; + const unknown = + "Embedded dependency records do not authenticate build provenance or establish complete runtime dependencies or target behavior."; + const shared = { + parent_evidence_id: parent.evidenceId, + parent_operation: "inspect_rust_binary" as const, + parent_digest: parent.evidenceId.slice(3), + artifact: { path: report.artifact.path, sha256: report.artifact.sha256 }, + source_coverage: { + auditable_metadata_available: metadata !== null, + embedded_records: + metadata === null ? ("unknown" as const) : ("complete" as const), + runtime_dependencies: "unknown" as const, + }, + limitations: boundedViewMessages( + [...parent.limitations, ...report.limitations], + parent.evidenceId, + "limitations", + ), + unknowns: boundedViewMessages( + [ + unknown, + ...(metadata === null + ? [ + "No cargo-auditable metadata was observed; missing metadata does not establish an empty dependency set.", + ] + : []), + ], + parent.evidenceId, + "unknowns", + [unknown], + ), + }; + const summary = summarize(report); + if (view.kind === "summary") + return ok({ + kind: "summary", + view, + ...shared, + summary, + coverage: completeWithinViewCoverage(1, 1), + }); + const indices = + view.indices === undefined ? undefined : new Set(view.indices); + const fields = + view.fields === undefined ? undefined : new Set(view.fields); + if (view.collection === "packages") { + const names = view.names === undefined ? undefined : new Set(view.names); + const versions = + view.versions === undefined ? undefined : new Set(view.versions); + const selected = selectViewRows( + metadata?.packages ?? [], + view, + (row, index) => + (indices?.has(index) ?? true) && + (names?.has(row.name) ?? true) && + (versions?.has(row.version) ?? true), + (row, index) => ({ + index, + ...((fields?.has("name") ?? true) && { name: row.name }), + ...((fields?.has("version") ?? true) && { version: row.version }), + ...((fields?.has("source") ?? true) && { source: row.source }), + ...((fields?.has("kind") ?? true) && { kind: row.kind }), + ...((fields?.has("root") ?? true) && { root: row.root }), + ...(fields?.has("dependencies") === true && { + dependencies: row.dependencies, + }), + }), + ); + return ok({ + kind: "rust", + view, + ...shared, + rust: summary, + page: { collection: "packages", rows: selected.rows }, + coverage: pageViewCoverage( + view.offset, + selected.rows.length, + selected.total, + ), + }); + } + const from = + view.from_indices === undefined ? undefined : new Set(view.from_indices); + const to = + view.to_indices === undefined ? undefined : new Set(view.to_indices); + const selected = selectViewRows( + metadata?.dependency_edges ?? [], + view, + (row, index) => + (indices?.has(index) ?? true) && + (from?.has(row.from_index) ?? true) && + (to?.has(row.to_index) ?? true), + (row, index) => ({ + index, + ...((fields?.has("from_index") ?? true) && { + from_index: row.from_index, + }), + ...((fields?.has("to_index") ?? true) && { to_index: row.to_index }), + }), + ); + return ok({ + kind: "rust", + view, + ...shared, + rust: summary, + page: { collection: "edges", rows: selected.rows }, + coverage: pageViewCoverage( + view.offset, + selected.rows.length, + selected.total, + ), + }); +}; diff --git a/src/domain/analysisView/selectViewRows.ts b/src/domain/analysisView/selectViewRows.ts new file mode 100644 index 0000000000..53ec6f34a4 --- /dev/null +++ b/src/domain/analysisView/selectViewRows.ts @@ -0,0 +1,17 @@ +/** Preserve original indices and count every match while projecting only the requested page. */ +export const selectViewRows = ( + 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 }; +}; diff --git a/src/domain/rust/rustBinary.ts b/src/domain/rust/rustBinary.ts index 95cc60a807..2047b627aa 100644 --- a/src/domain/rust/rustBinary.ts +++ b/src/domain/rust/rustBinary.ts @@ -1,6 +1,21 @@ import { z } from "zod"; import { digestSchema } from "../digests.js"; import { localPathStringSchema } from "../localPath.js"; +import { BINARY_ARCHITECTURES } from "../binaryTargetTypes.js"; + +/** Preserve the format owner's architecture spelling separately from Evidence vocabulary. */ +export const rustArchitectureForSubject = (architecture: string) => { + switch (architecture) { + case "386": + return "x86"; + case "amd64": + return "x86_64"; + default: + return BINARY_ARCHITECTURES.find( + (supported) => supported === architecture, + ); + } +}; /** Known revisions used to interpret compatible cargo-auditable documents. */ export const RUST_AUDITABLE_FORMAT_VERSIONS = [0, 1, 8] as const; diff --git a/src/domain/rust/rustBinaryQuery.ts b/src/domain/rust/rustBinaryQuery.ts new file mode 100644 index 0000000000..e4a31376ca --- /dev/null +++ b/src/domain/rust/rustBinaryQuery.ts @@ -0,0 +1,69 @@ +import { z } from "zod"; +import { + inspectRustBinaryInputSchema, + rustPackageSchema, + rustAuditableMetadataSchema, +} from "./rustBinary.js"; + +const edgeSchema = rustAuditableMetadataSchema.shape.dependency_edges.element; +const index = z.number().int().nonnegative(); +const page = { + indices: z.array(index).optional(), + offset: index.default(0), + limit: z.number().int().positive().default(64), +}; +const packages = z.strictObject({ + ...page, + collection: z.literal("packages"), + names: z.array(z.string()).optional(), + versions: z.array(z.string()).optional(), + fields: z + .array(rustPackageSchema.keyof()) + .optional() + .describe( + "Original index always retained. Default attributes omit dependency vectors; select dependencies explicitly for direct index references.", + ), +}); +const edges = z.strictObject({ + ...page, + collection: z.literal("edges"), + from_indices: z.array(index).optional(), + to_indices: z.array(index).optional(), + fields: z.array(edgeSchema.keyof()).optional(), +}); + +/** Filters intersect; empty arrays select none and offsets count matching rows. */ +export const rustBinarySelectionSchema = z.discriminatedUnion("collection", [ + packages, + edges, +]); +export const rustBinaryViewRequestSchema = z.discriminatedUnion("collection", [ + packages.extend({ kind: z.literal("rust") }), + edges.extend({ kind: z.literal("rust") }), +]); +export const rustBinaryViewPageSchema = z.discriminatedUnion("collection", [ + z.strictObject({ + collection: z.literal("packages"), + rows: z.array(rustPackageSchema.partial().extend({ index })), + }), + z.strictObject({ + collection: z.literal("edges"), + rows: z.array(edgeSchema.partial().extend({ index })), + }), +]); + +/** Delivery choices never enter the file-reader port or change original Evidence identity. */ +export const inspectRustBinaryRequestSchema = inspectRustBinaryInputSchema + .extend({ + detail: z.enum(["complete", "summary"]).optional(), + view: rustBinarySelectionSchema.optional(), + }) + .superRefine((value, context) => { + if (value.view !== undefined && value.detail === "complete") + context.addIssue({ + code: "custom", + path: ["view"], + message: + "view selects compact delivery; omit view for complete Evidence", + }); + }); diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 03fef3ef53..4d01bb3b7a 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -777,6 +777,7 @@ const registerConfiguredAnalysisTools = ( toolLogger, recordEvidence, withAdmittedAnalysis, + evidenceById, ); registerWasmTools( server, diff --git a/src/server/registerRustTools.ts b/src/server/registerRustTools.ts index 4f674cd82f..fbf9099caf 100644 --- a/src/server/registerRustTools.ts +++ b/src/server/registerRustTools.ts @@ -1,4 +1,5 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; +import type { EvidenceLookup } from "../application/EvidenceInputResolver.js"; import type { RustBinaryService } from "../application/rust/RustBinaryService.js"; import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; import { toolContract } from "../contracts/toolContracts.js"; @@ -14,11 +15,13 @@ export const registerRustTools = ( logger: Logger, recordEvidence?: EvidenceWriter["recordEvidence"], withAdmittedAnalysis?: WithAdmittedAnalysis, + evidenceById?: EvidenceLookup, ): void => { const contract = toolContract("inspect_rust_binary"); const handler = createEvidenceToolHandler( server, - (input, options) => service.inspect(input, options), + (input, options) => + service.inspect(input, options, { recordEvidence, evidenceById }), logger, recordEvidence, withAdmittedAnalysis, diff --git a/tests/acceptance/rustBinaryCli.test.ts b/tests/acceptance/rustBinaryCli.test.ts index d8d6dc94f5..b568065ac5 100644 --- a/tests/acceptance/rustBinaryCli.test.ts +++ b/tests/acceptance/rustBinaryCli.test.ts @@ -1,3 +1,4 @@ +import { rustBinarySchema } from "../../src/domain/rust/rustBinary.js"; import { createHash } from "node:crypto"; import { deflateSync } from "node:zlib"; import { readFile, writeFile } from "node:fs/promises"; @@ -86,7 +87,9 @@ cliTest( const evidence = toolContract("inspect_rust_binary").outputSchema.parse( result.json, ); - expect(evidence.normalized_result.auditable_metadata).toMatchObject({ + expect( + rustBinarySchema.parse(evidence.normalized_result).auditable_metadata, + ).toMatchObject({ section_name: "__DATA,.dep-v0", format_version: 1, root_package_index: 1, @@ -109,8 +112,9 @@ cliTest("returns null metadata when .dep-v0 is absent", async ({ cli }) => { const evidence = toolContract("inspect_rust_binary").outputSchema.parse( result.json, ); - expect(evidence.normalized_result.auditable_metadata).toBeNull(); - expect(evidence.normalized_result.limitations.join("\n")).toContain( + const report = rustBinarySchema.parse(evidence.normalized_result); + expect(report.auditable_metadata).toBeNull(); + expect(report.limitations.join("\n")).toContain( "does not mean the binary has no dependencies", ); }); @@ -134,13 +138,12 @@ cliTest( const evidence = toolContract("inspect_rust_binary").outputSchema.parse( result.json, ); - expect(evidence.normalized_result.auditable_metadata?.format_version).toBe( - 4, - ); - expect(evidence.normalized_result.limitations.join("\n")).toContain( + const report = rustBinarySchema.parse(evidence.normalized_result); + expect(report.auditable_metadata?.format_version).toBe(4); + expect(report.limitations.join("\n")).toContain( "highest known preceding revision (1)", ); - expect(evidence.normalized_result.limitations.join("\n")).toContain( + expect(report.limitations.join("\n")).toContain( "this specific revision has not been independently verified", ); }, diff --git a/tests/acceptance/rustDependencyViews.test.ts b/tests/acceptance/rustDependencyViews.test.ts new file mode 100644 index 0000000000..6f97f45a24 --- /dev/null +++ b/tests/acceptance/rustDependencyViews.test.ts @@ -0,0 +1,298 @@ +import { readFile, unlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { parseEvidence, createEvidence } from "../../src/domain/evidence.js"; +import { rustBinarySchema } from "../../src/domain/rust/rustBinary.js"; +import { analysisViewResultSchema } from "../../src/domain/analysisView/analysisView.js"; +import { readEvidenceBundle } from "../../src/application/EvidenceBundleFiles.js"; +import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; +import { parseMcpToolError } from "../fixtures/mcpToolError.js"; +import { + createRustImage, + rustDependencyPayload, +} from "../fixtures/rust/image.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +/** Both authenticated retention and portable imports query the same captured facts. */ +const assertRetainedPages = async ( + call: Awaited>["call"], + evidenceId: string, + exported: string, +) => { + for (const [filter, expected] of [ + [ + { names: ["same"], versions: ["2"], fields: ["name", "version"] }, + [{ index: 1, name: "same", version: "2" }], + ], + [{ names: ["same"], indices: [0], fields: [] }, [{ index: 0 }]], + [{ names: ["same"], versions: [], fields: [] }, []], + ] as const) { + const reply = await call("inspect_analysis_view", { + source: { kind: "retained-evidence", evidence_id: evidenceId }, + view: { kind: "rust", collection: "packages", ...filter }, + }); + expect(reply.isError).not.toBe(true); + expect( + parseEvidence(reply.structuredContent).normalized_result, + ).toMatchObject({ page: { rows: expected } }); + } + + for (const source of [ + { kind: "retained-evidence", evidence_id: evidenceId }, + { kind: "evidence-file", path: exported }, + ]) { + const reply = await call("inspect_analysis_view", { + source, + view: { + kind: "rust", + collection: "packages", + names: ["same"], + indices: [0, 1, 99], + offset: 1, + limit: 1, + fields: [], + }, + }); + expect(reply.isError, JSON.stringify(reply)).not.toBe(true); + expect( + parseEvidence(reply.structuredContent).normalized_result, + ).toMatchObject({ + parent_evidence_id: evidenceId, + page: { rows: [{ index: 1 }] }, + coverage: { + total: 2, + examined: 1, + exhausted: true, + next_offset: null, + }, + }); + const edgeReply = await call("inspect_analysis_view", { + source, + view: { + kind: "rust", + collection: "edges", + from_indices: [1, 3], + to_indices: [0], + fields: ["from_index", "to_index"], + }, + }); + expect(edgeReply.isError).not.toBe(true); + expect( + parseEvidence(edgeReply.structuredContent).normalized_result, + ).toMatchObject({ + page: { + collection: "edges", + rows: [ + { index: 1, from_index: 1, to_index: 0 }, + { index: 3, from_index: 3, to_index: 0 }, + ], + }, + }); + const empty = await call("inspect_analysis_view", { + source, + view: { + kind: "rust", + collection: "packages", + names: [], + versions: ["1"], + fields: ["name"], + }, + }); + expect( + parseEvidence(empty.structuredContent).normalized_result, + ).toMatchObject({ + page: { rows: [] }, + coverage: { total: 0, exhausted: true }, + }); + } +}; + +cliTest( + "queries stable Rust packages and edges through real CLI/MCP and portable Evidence", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-rust-views-"); + const { call } = await connectLocalToolsMcp(); + for (const format of ["elf", "pe", "macho"] as const) { + const path = join(root, `source.${format}`); + const output = join(root, `${format}-source.json`); + const payload = rustDependencyPayload({ + packages: [ + { + name: "same", + version: "1", + source: "crates.io", + dependencies: [2], + }, + { + name: "same", + version: "2", + source: "git+https://upstream.example/repo", + dependencies: [0, 2], + }, + { name: "leaf", version: "1", source: "local" }, + { + name: "app", + version: "1", + source: "local", + dependencies: [0, 1], + root: true, + }, + ], + }); + await writeFile(path, createRustImage({ format, payload })); + const fullReply = await call("inspect_rust_binary", { path }); + expect(fullReply.isError).not.toBe(true); + const full = parseEvidence(fullReply.structuredContent); + const report = rustBinarySchema.parse(full.normalized_result); + const explicit = await call("inspect_rust_binary", { + path, + detail: "complete", + }); + expect(parseEvidence(explicit.structuredContent)).toEqual(full); + const selection = { + collection: "packages", + names: ["same"], + fields: ["name", "version", "dependencies"], + }; + const direct = await call("inspect_rust_binary", { + path, + view: selection, + }); + expect(direct.isError, JSON.stringify(direct)).not.toBe(true); + const selected = parseEvidence( + direct.structuredContent, + ).normalized_result; + expect(selected).toMatchObject({ + kind: "rust", + parent_evidence_id: full.evidence_id, + page: { + collection: "packages", + rows: [ + { index: 0, name: "same", version: "1", dependencies: [2] }, + { index: 1, name: "same", version: "2", dependencies: [0, 2] }, + ], + }, + }); + const summary = await call("inspect_rust_binary", { + path, + detail: "summary", + }); + expect(summary.isError).not.toBe(true); + expect( + parseEvidence(summary.structuredContent).normalized_result, + ).toMatchObject({ + kind: "summary", + summary: { + auditable_metadata: { + counts: { packages: 4, edges: 5 }, + format_version: null, + }, + }, + source_coverage: { + embedded_records: "complete", + runtime_dependencies: "unknown", + }, + }); + const delivered = await cli.run({ + arguments: [ + "inspect-rust-binary", + path, + "--view", + JSON.stringify(selection), + "--evidence_output", + output, + "--json", + ], + }); + expect(delivered.exitCode, delivered.stderr).toBe(0); + expect(delivered.json).toMatchObject({ + summary: { normalized_result: selected }, + }); + const bundle = await readEvidenceBundle(output); + if (!bundle.ok) throw bundle.error; + expect( + bundle.value.records.find( + (row) => row.evidence_id === full.evidence_id, + ), + ).toEqual(full); + const exported = join(root, `${format}-record.json`); + await writeFile(exported, JSON.stringify(full)); + await unlink(path); + + await assertRetainedPages(call, full.evidence_id, exported); + const malformed = structuredClone(report); + malformed.auditable_metadata!.dependency_edges[0]!.to_index = 999; + if (full.subject == null) + throw new Error("Missing complete Rust subject"); + const invalid = createEvidence( + { + path, + sha256: report.artifact.sha256, + format: format === "macho" ? "mach-o" : format, + ...(full.subject.architecture === null + ? {} + : { architecture: full.subject.architecture }), + }, + full.provider, + { + operation: full.operation, + parameters: full.parameters, + result: malformed, + }, + ); + expect( + parseMcpToolError( + await call("inspect_analysis_view", { + source: { kind: "inline", evidence: invalid }, + view: { kind: "rust", collection: "edges" }, + }), + ).error, + ).toMatchObject({ code: "invalid_request" }); + } + }, + 60000, +); + +cliTest( + "preserves unknown Rust metadata and refuses invalid compact CLI choices before reading", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-rust-view-missing-"); + const path = join(root, "plain.elf"); + await writeFile( + path, + createRustImage({ format: "elf", omitSection: true }), + ); + const { call } = await connectLocalToolsMcp(); + const summary = await call("inspect_rust_binary", { + path, + detail: "summary", + }); + expect(summary.isError).not.toBe(true); + const view = analysisViewResultSchema.parse( + parseEvidence(summary.structuredContent).normalized_result, + ); + expect(view).toMatchObject({ + summary: { auditable_metadata: null }, + source_coverage: { + embedded_records: "unknown", + runtime_dependencies: "unknown", + }, + }); + const missing = join(root, "never-created"); + for (const args of [ + ["--detail", "summary"], + ["--view", "{"], + ["--detail", "complete", "--view", '{"collection":"packages"}'], + ]) { + const refused = await cli.run({ + arguments: ["inspect-rust-binary", missing, ...args, "--json"], + }); + expect(refused.exitCode).toBe(1); + expect(refused.json).toMatchObject({ code: "invalid_request" }); + expect(JSON.stringify(refused.json)).not.toContain("ENOENT"); + } + expect((await readFile(path)).length).toBeGreaterThan(0); + }, + 30000, +); diff --git a/tests/boundary/mcp/rustBinaryMcp.test.ts b/tests/boundary/mcp/rustBinaryMcp.test.ts index d903affd4d..4f7b3452c0 100644 --- a/tests/boundary/mcp/rustBinaryMcp.test.ts +++ b/tests/boundary/mcp/rustBinaryMcp.test.ts @@ -1,3 +1,4 @@ +import { rustBinarySchema } from "../../../src/domain/rust/rustBinary.js"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect } from "vitest"; @@ -43,7 +44,10 @@ mcpTest( const evidence = toolContract("inspect_rust_binary").outputSchema.parse( response.structuredContent, ); - expect(evidence.normalized_result.auditable_metadata?.packages).toEqual([ + expect( + rustBinarySchema.parse(evidence.normalized_result).auditable_metadata + ?.packages, + ).toEqual([ expect.objectContaining({ name: "itoa", version: "1.0.18" }), expect.objectContaining({ name: "sample", @@ -64,7 +68,10 @@ mcpTest( const absentEvidence = toolContract( "inspect_rust_binary", ).outputSchema.parse(absent.structuredContent); - expect(absentEvidence.normalized_result.auditable_metadata).toBeNull(); + expect( + rustBinarySchema.parse(absentEvidence.normalized_result) + .auditable_metadata, + ).toBeNull(); } finally { await session.close(); }