From f76163c2467db623f9de0bda757c9d8610ca9565 Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:09:32 +0800 Subject: [PATCH] feat(evm): add retained interface summary delivery (#2308) Add opt-in EVM MCP summary delivery and retained/portable function and text views. Keep existing complete defaults, provider arguments and original full Evidence identities and records usable. Require retention before selected analysis; preserve inferred candidates, nulls, diagnostics, provenance and explicit unknowns. Verify old schemas, historical Evidence migration, 64 focused feature checks, 30 affected MCP checks after latest-main rebase, 59 real EVM CLI/MCP cases and six shared-domain artifact workflows. Repeated SDK delivery is smaller; all real model trials and mixed token/time outcomes, limitation failures and resource qualification gaps remain documented without a uniform performance claim. Refs #2016 Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../skills/reverse-engineer-anything/SKILL.md | 6 +- docs/evm-bytecode.md | 45 +++- experiments/analysis-efficiency/README.md | 4 + experiments/analysis-efficiency/compare.mjs | 1 + .../evm-interface-summary.md | 208 +++++++++++++++ experiments/analysis-efficiency/measure.mjs | 103 +++++++- .../analysis-efficiency/review-model.py | 5 + .../verify-evm-retention.mjs | 167 ++++++++++++ .../analysisView/AnalysisViewService.ts | 53 ++-- src/contracts/analysisViewToolContracts.ts | 2 +- src/contracts/evm/evmToolContracts.ts | 30 ++- src/domain/analysisView/analysisView.ts | 30 ++- src/domain/analysisView/binaryLayoutView.ts | 4 + src/domain/analysisView/evmInterfaceView.ts | 145 +++++++++++ .../analysisView/javascriptApplicationView.ts | 4 + src/server/createServer.ts | 1 + src/server/registerEvmTools.ts | 31 ++- tests/boundary/mcp/evmInterfaceMcp.test.ts | 244 +++++++++++++++++- 18 files changed, 1049 insertions(+), 34 deletions(-) create mode 100644 experiments/analysis-efficiency/evm-interface-summary.md create mode 100644 experiments/analysis-efficiency/verify-evm-retention.mjs create mode 100644 src/domain/analysisView/evmInterfaceView.ts diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index fc6237dcd..87decc26d 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -75,7 +75,11 @@ explicit path or endpoint and do not need it. - Explicit local EVM bytecode carrier: `inspect_evm_interface` with caller-selected `raw` or `hex` encoding. Preserve carrier/decoded digests and treat selectors, argument strings and mutability as inferred candidates; this performs no chain - lookup or target execution. See the + lookup or target execution. For the complete inferred interface without hex or + duplicate stdout, discover support and select `detail: "summary"`; complete + Evidence stays retained under its original identity. Use the returned + `parent_evidence_id` for exact function or `bytecode_hex`/stdout/stderr pages. + Candidate coverage does not establish interface completeness. See the [EVM bytecode guide](https://github.com/morluto/rea/blob/main/docs/evm-bytecode.md). - Embedded Go compiler, module or build-setting metadata: `inspect_go_binary` with the explicit local file. This built-in target-free inspection requires no diff --git a/docs/evm-bytecode.md b/docs/evm-bytecode.md index 8db25973e..972d8b92c 100644 --- a/docs/evm-bytecode.md +++ b/docs/evm-bytecode.md @@ -35,7 +35,7 @@ license](https://github.com/morluto/rea/blob/main/third_party/evmole/README.md) and whitespace. Decoded-byte SHA-256 identifies actual selected EVM bytes. Neither digest is the Ethereum Keccak code hash. - Full bytecode, upstream function offsets and original producer representation - are returned inline. Offsets are byte positions in decoded bytecode. + are returned inline by default. Offsets are byte positions in decoded bytecode. - Upstream also returns terminal metadata during selector recovery. Its reported values remain in `raw_result`; integer precision and non-text-key coverage are unknown in the JS binding. Full bytecode retains the original encoded metadata. @@ -47,6 +47,49 @@ license](https://github.com/morluto/rea/blob/main/third_party/evmole/README.md) - Analysis is offline: no target runtime execution, implicit chain/RPC/explorer lookup, wallet action, transaction broadcast or network request. +## Selected interface delivery and retained views + +Existing CLI calls and MCP calls with omitted or `complete` detail keep the +complete inline result. On a retaining MCP server, select `detail: "summary"` +when the question needs every inferred function and its identity/uncertainty, +without bytecode text or duplicated raw stdout: + +```json +{ + "name": "inspect_evm_interface", + "arguments": { + "path": "/artifacts/runtime.hex", + "encoding": "hex", + "detail": "summary" + } +} +``` + +The result is distinct derived Evidence linked to its complete parent. Its +`normalized_result.summary` preserves every producer-returned selector, decoded +byte offset, dispatch, argument/mutability string including null, carrier/decoded +identities, original limitations and explicit unknowns. Payload `evidence_kind` +remains `inferred`. It preserves stderr/truncation and records stdout text length. +Complete-within-view coverage covers returned candidates; discovery completeness +remains unknown even for an empty or exhausted collection. Analysis still runs; +delivery selection does not save interpreter work or change source parameters. + +Use `normalized_result.parent_evidence_id` with `inspect_analysis_view` source +kind `retained-evidence`. EVM view kind `evm` pages `functions` by original row +order, or `bytecode_hex`, `stdout`, `stderr` by UTF-16 code units. Hex pages preserve +the original lowercase text without a prefix; two characters represent one decoded +byte. A text-page offset differs from a function's decoded-byte offset. Complete +parent Evidence remains accessible through the normal ledger/export workflow; +reading views does not rerun analysis or change its identity. Existing record +retention and close semantics are unchanged. + +Portable historical complete EVM Evidence can use source kind `inline` without +a provider. A fixed server without Evidence retention rejects selected summary +delivery before starting analysis and continues to support complete delivery. +Discover the actual advertised schema before sending `detail` to an older server; +use its complete operation when an equivalent workflow is required. The one-shot +CLI keeps its existing complete result. + ## Resource and lifecycle limits Inputs are limited to 4 MiB, complete replies to 16 MiB and combined diagnostic diff --git a/experiments/analysis-efficiency/README.md b/experiments/analysis-efficiency/README.md index 25438895d..852e28ccd 100644 --- a/experiments/analysis-efficiency/README.md +++ b/experiments/analysis-efficiency/README.md @@ -15,6 +15,10 @@ Follow-up: [EVM descriptor and real-template controls](evm-descriptor.md) extend the offline corpus with digest-pinned Uniswap runtime templates and an independent published ABI selector oracle, preserving unknown deployment/immutable metadata. +Follow-up: [EVM interface summary delivery](evm-interface-summary.md) records +the opt-in retained view, old-client/Evidence migration checks and repeated SDK +and real-model pairs, including mixed token/time results and strict-answer limits. + The 12 detailed checkpoint receipts were archived byte-for-byte before removal from the final PR tree. The archive `review-receipts-00d51e0fc-20261012` is in the private experiment lab outside the checkout; its `archive-index.json` records diff --git a/experiments/analysis-efficiency/compare.mjs b/experiments/analysis-efficiency/compare.mjs index b6ba1a552..6db538311 100644 --- a/experiments/analysis-efficiency/compare.mjs +++ b/experiments/analysis-efficiency/compare.mjs @@ -24,6 +24,7 @@ const sameSettings = [ "artifact", "artifact_sha256", "artifact_sha256_after", + "retained_verification", "native_question", "javascript_question", "provider_settings_sha256", diff --git a/experiments/analysis-efficiency/evm-interface-summary.md b/experiments/analysis-efficiency/evm-interface-summary.md new file mode 100644 index 000000000..cbc793a5b --- /dev/null +++ b/experiments/analysis-efficiency/evm-interface-summary.md @@ -0,0 +1,208 @@ +# EVM interface delivery and migration checkpoint + +Continues [#2016](https://github.com/morluto/rea/issues/2016) after the static +descriptor merged in [#2261](https://github.com/morluto/rea/pull/2261). Opt-in +`inspect_evm_interface` MCP `detail: "summary"` returns every inferred interface +candidate without bytecode hex or duplicate stdout. Complete Evidence remains +retained under its original identity. Delivery bytes fall; the measured model +cohort does **not** establish a stable token or task-time improvement. + +## Frozen scope + +Control production is `ad08510a113d7a9b8d92fb1e385757880e469869`; candidate +production is `32c7548b9ae5c8cda348653d177b1747127c74ba`. Their compiled file-table +SHA-256 values are respectively +`f68e85030cb05082520ea9f0d7650298ae316ce7bba59a8942e0cc1318ae9850` and +`978b3a014e3b2a1e1c6620364dcd793fa7cb40f19892d44cfb0312739a8c14a3`. +The SDK driver SHA-256 is +`a95d8d8e9f72c0b2b885c61907ed13054ebf7d1642f6f13b0126163840b1221a`. +Driver commit `03bff83e70e09a917341c1fe3ebffbfdc67785c5` fixes only reading the +public `get_evidence_bundle.result.records` envelope; production is unchanged. +Both runtimes use Node 24.18.0, matching locked dependencies, SDK client/server +2.3.1 and unchanged bundled EVMole 0.9.3. + +Inputs are the digest-pinned WETH historical hex carrier and Uniswap v3-core +1.0.1 Factory/Pool compiler runtime templates in [corpus.json](corpus.json). +Original carrier and decoded-byte identities remain distinct. Templates are +not authenticated deployed instances; constructor-dependent values and absent +immutable-reference metadata stay unknown. No chain, target execution or +signature lookup is introduced. + +## Public SDK pairs + +Two pairs per artifact run serially: complete control then selected candidate, +followed by candidate then control. Each starts a fresh MCP process, discovers +the catalog and validates the actual named advertised schema. Host caches are +not independently controlled. All six fact/settings comparisons pass. Both +answer projections omit unrequested hex; original complete replies remain intact. + +| Input | Complete / summary result bytes | Reduction | Control seconds, 1 / 2 | Candidate seconds, 1 / 2 | +| ------- | ------------------------------: | --------: | ---------------------: | -----------------------: | +| Factory | 110,424 / 14,442 | 86.92% | 3.670 / 3.766 | 3.664 / 3.867 | +| Pool | 110,998 / 19,922 | 82.05% | 4.110 / 3.740 | 3.977 / 3.870 | +| WETH | 26,760 / 15,378 | 42.53% | 3.671 / 3.710 | 3.930 / 3.673 | + +These measurements support smaller delivery, not faster interpretation. Analysis +still runs and the full parent still incurs producer/retention costs. Catalog +count and order stay at 184 tools. The complete catalog grows from 2,422,229 to +2,429,938 compact JSON bytes (+7,709); all name/description/input definitions +grow from 512,655 to 514,074 bytes. The EVM input definition grows from 1,443 to +2,029 bytes. Only `inspect_analysis_view` and `inspect_evm_interface` definitions +change. These discovery costs are visible and are not converted into token counts. + +Nine separate measurements verify new-server complete defaults, old-server +summary fallback and retained reads for all three inputs. The four-call retained +proofs retrieve the complete canonical parent plus pages and cost 160,110, +177,106 and 79,160 bytes for Factory, Pool and WETH respectively. They are +verification workflows, not examples of one-call delivery savings. + +## Real Codex pairs and answer qualification + +Twelve serial Codex CLI 0.161.0 trials use the existing identical questions for +each pair, the same artifact path, one enabled EVM tool, temporary MCP profiles +and requested `gpt-5.6-terra` high. The requested alias is not proof of the +resolved model identity. Trials include schema discovery, reasoning, one actual +successful EVM call and final answer. No call fails or retries. Profiles are +removed and input bytes remain unchanged. This fixed one-tool profile does not +test selection from the full catalog. + +| Input / repetition | Uncached input, control → candidate | Seconds, control → candidate | Candidate delivery | +| ------------------ | ----------------------------------: | ---------------------------: | ------------------ | +| Factory / 1 | 22,901 → 25,668 | 52.485 → 48.768 | summary | +| Factory / 2 | 37,181 → 15,270 | 53.404 → 45.345 | summary | +| Pool / 1 | 36,652 → 26,657 | 57.764 → 66.642 | summary | +| Pool / 2 | 28,305 → 36,259 | 62.068 → 71.756 | complete | +| WETH / 1 | 21,128 → 25,187 | 44.869 → 46.809 | summary | +| WETH / 2 | 17,417 → 17,529 | 30.364 → 39.943 | summary | + +Keep all trials, including the candidate that explicitly chose complete delivery. +Five of six candidates select summary, but uncached input improves in only two +pairs and time improves in only two. Aggregate control → candidate observations +are 362,496 → 357,770 total input; 198,912 → 211,200 cached input; +163,584 → 146,570 uncached input; 8,953 → 10,397 output; and +300.954 → 319.263 seconds. Cache state and model/service variation are not +separated; the aggregate does not establish a repeatable saving for an individual +question or a qualified performance recommendation. + +Every answer passes exact core-field checks: both identities/byte counts, every +function selector/offset/dispatch/argument/mutability including null, inference, +unknown completeness/kind/hardfork/authenticity, and absent runtime execution. +All reported IDs occur in actual responses. However, the existing grader requires +the final limitation list to equal the producer list verbatim and in order. +Only three trials pass that additional check; nine fail. The questions request +preserving limitations but do not explicitly request a verbatim ordered list. +Four failures contain all original strings with extra correct scope statements +or different ordering. The other five paraphrase or omit source caveats, including +worker or upstream-metadata limitations. Do not silently waive those omissions +or turn these into twelve fully acceptable answers. The strict reports and +original transcripts remain intact; core-field success is a narrower result. + +Model scope sampled peaks range from 562.5 to 710.5 MiB, with last-observed CPU +5.945–6.878 seconds. CPU includes the local process family, not remote model +computation. All heavy jobs use one CPU, a 4 GiB hard limit, 3 GiB early-stop, +no swap and 192 tasks. No scope abort occurs in this cohort. Final cgroup +counters are unavailable after teardown: final OOM/task/limit-event assertions +remain **unverified**. Last-observed counters are not substituted for final ones. +Consequently neither strict answer acceptance nor resource-qualified performance +success is claimed for the whole cohort. + +## Existing-user compatibility + +Omitted and explicit-complete MCP requests, the existing CLI and provider inputs +preserve their full behavior. `detail` is removed before the unchanged provider +input; original full Evidence parameters, raw result and identity are retained. +Summary receives its own identity linked to that parent. All candidate facts, +original report limitations, stderr/truncation and explicit unknowns stay inline. +Complete-within-view candidate coverage is not global discovery completeness. +Function pages retain original order; text pages use UTF-16 code units, with +hex characters distinguished from decoded-byte function offsets. + +Actual new default replies for all three artifacts validate against the frozen +old advertised input/output schemas, and structured/text payloads agree. Older +servers reject neither request nor unsupported fields because the experimental +client discovers support before selecting summary and otherwise calls complete. +This is an explicit client fallback, not universal server forward compatibility. + +Three authentic old-runtime records and old-created canonical bundles pass new +MCP import, retained summary, CLI inline summary and export. Original records, +IDs and bundle source bytes stay deeply equal. The reproducible verifier below +also checks function/hex pages with the analysis limiter explicitly unavailable, +so retained reads cannot silently start successful producer analysis. This +proves the pinned old/new Linux workflows, not every historical client release. + +The initial retained experiment failed because its driver read the bundle's +wrong envelope. Correcting that consumer produced passing retained proofs; the +failed receipt stays archived and is not recorded as a REA defect. Separately, +one static-check attempt hit the unchanged 3 GiB early-stop. The bounded retry +passes with Node heap 512 MiB/native checker memory controls; its roughly 3 GiB +peak and soft-limit events are excluded from efficiency comparisons. + +## Reproduction and checks + +Use [the shared instructions](README.md), explicit frozen revisions/runtimes and +fresh private output directories. Run `measure.mjs` for `weth.hex`, +`uniswap-v3.factory.hex` and `uniswap-v3.pool.hex`, with `--schemas selected` and +paired `--delivery complete` / `summary`; run `compare.mjs` on equivalent pairs. +Add `--verify-retained` only for the separate four-call proof. Use the unchanged +[WETH](questions/evm.txt) or [template](questions/evm-uniswap.txt) question and the +same one-tool profile for `agent-trial.py`, preserving all usage and grader results. + +Export complete Evidence with the old runtime's public ledger workflow, then run: + +```sh +node experiments/analysis-efficiency/verify-evm-retention.mjs \ + --runtime-root ABS_FROZEN_NEW_RUNTIME --revision NEW_SHA \ + --bundle ABS_OLD_BUNDLE_JSON --output NEW_PRIVATE_ABS_DIR +``` + +The script acquires no dependencies and preserves the old bundle. Revision is +caller-supplied; retain the frozen file-table receipt alongside it. Raw JSON, +transcripts, profiles, bytecode, compiler inputs and generated artifacts stay +outside Git. This checkpoint adds source verification and curated observations. + +The production change passes 64 focused EVM/WASM/generic-view/domain/service +checks and full static/docs gates. Recording provider seams establish exact +schemas, old defaults, inference/null preservation, historical identity failures, +peer availability, cancellation and cleanup retry; they are not real-provider +claims. Real-provider and shared-domain checks are recorded separately. + +The same frozen candidate also passes six actual public MCP artifact workflows: +SQLite WASM with WABT summary, ShareX managed metadata, Obsidian inventory, +NewPipe with JADX, an OpenWrt region inspection, and jq entry analysis with +Ghidra. These are shared-contract regressions; they do not establish AST analysis +of all Obsidian code, full firmware reconstruction, browser coverage, or new token +savings in those domains. Native Windows PE and DOS coverage remains at the +[earlier native checkpoint](native-entry-views.md), not a new Ghidra/Windows claim. +The source retention verifier passes for all three authentic old EVM bundles, +including exact summaries, function/hex pages, CLI inline views and unchanged +exported originals with the limiter explicitly unavailable. + +The frozen candidate passes all 59 existing real EVM CLI/MCP verifier cases with +caller-supplied solc 0.8.30 and strace 6.1, including malformed/unsupported +carriers, unavailable limiter and capacity failures. Its scope takes 96.076 +seconds with 1,345,052,672 sampled peak bytes and 95.130 last-observed CPU seconds. +This is regression evidence, not an interpreter speedup comparison; final +resource counters remain unverified. Fresh fast static and generated-document +checks also pass after adding the migration verifier and this report. + +## Latest-main integration + +The branch rebases without conflict onto main +`0043d9b134c0a353e8bf85c0bde4492275f1f720`. Production integration commit +`3766749316c345ccb594b1b748747b1af9582b34` preserves the same EVM projection, +provider and delivery semantics; intervening main adds unrelated capabilities. +The above performance cohort stays pinned to its original frozen runtimes and +is not relabeled as a measurement of the larger integrated catalog. + +At `cda794fb9d4a5ca2bd661853a899d579fb8a47b2`, all 30 affected EVM/WASM/generic-view +MCP tests pass again, as do full `npm run check` and `npm run docs:check`. A fresh +real Factory SDK selected-summary/retained-parent/page workflow and the original +Pool bundle's SDK/CLI migration verifier also pass against the rebuilt integrated +runtime. The latter still has an explicitly unavailable analysis limiter. + +The initial integrated static-check scope stops at 3,221,446,656 sampled bytes, +just above the unchanged 3 GiB early-stop. Running the same typecheck through +its single Turbo task with less launcher overhead passes at 3,219,353,600 sampled +bytes; the subsequent full gate uses that valid cached typecheck and passes. +No memory threshold or check requirement is relaxed. Preserve both receipts; +these near-limit validation jobs are excluded from efficiency conclusions. diff --git a/experiments/analysis-efficiency/measure.mjs b/experiments/analysis-efficiency/measure.mjs index e763d0717..2a7911b4f 100644 --- a/experiments/analysis-efficiency/measure.mjs +++ b/experiments/analysis-efficiency/measure.mjs @@ -3,7 +3,7 @@ import { createReadStream } from "node:fs"; import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { parseArgs } from "node:util"; +import { isDeepStrictEqual, parseArgs } from "node:util"; import { NATIVE_ARTIFACTS, measureNativeEntry, @@ -23,6 +23,7 @@ const { values } = parseArgs({ providers: { type: "string" }, output: { type: "string" }, delivery: { type: "string", default: "complete" }, + "verify-retained": { type: "boolean", default: false }, "native-question": { type: "string", default: "entry" }, "javascript-question": { type: "string", default: "inventory" }, schemas: { type: "string", default: "default" }, @@ -41,6 +42,10 @@ if ( ) || !["inventory", "selected"].includes(values["javascript-question"]) || !["default", "selected"].includes(values.schemas) || + (values["verify-retained"] && + !["weth.hex", "uniswap-v3.factory.hex", "uniswap-v3.pool.hex"].includes( + values.artifact, + )) || (!NATIVE_ARTIFACTS.includes(values.artifact) && values["native-question"] !== "entry") || (values.artifact !== "jq" && @@ -350,15 +355,44 @@ const inspectWasmTarget = async () => { }; }; -/** Identical complete interface question for pinned offline EVM carriers. */ +/** Identical interface facts for complete delivery, selected summary or old-server fallback. */ const inspectEvmTarget = async () => { + const definition = catalogDefinitions.get("inspect_evm_interface"); + if (!definition) throw new Error("Missing advertised EVM definition"); + const input = { path: values.path, encoding: "hex" }; + const supportsSummary = new Ajv2020({ + strict: false, + validateFormats: false, + }).compile(definition.inputSchema)({ ...input, detail: "summary" }); const info = await call("inspect_evm_interface", { - path: values.path, - encoding: "hex", + ...input, + ...(values.delivery === "summary" && supportsSummary + ? { detail: "summary" } + : {}), }); - const n = normalized(info); + const result = normalized(info); + const projected = result.kind === "summary"; + const n = projected ? result.summary : result; + const references = [info.evidence_id]; + if (projected) { + if ( + result.parent_operation !== "inspect_evm_interface" || + result.parent_evidence_id === info.evidence_id || + !info.evidence_links.includes(result.parent_evidence_id) || + "hex" in n.bytecode || + "stdout" in n.diagnostics + ) + throw new Error( + "EVM summary changed source provenance or precise delivery", + ); + references.push(result.parent_evidence_id); + } if ( n.evidence_kind !== "inferred" || + n.artifact.path !== values.path || + n.artifact.sha256 !== inputSha || + n.artifact.bytes !== artifact.bytes || + n.artifact.encoding !== "hex" || n.discovery_completeness !== "unknown" || n.runtime_execution !== "not-performed" || ["kind", "hardfork", "deployment_authenticity"].some( @@ -370,15 +404,69 @@ const inspectEvmTarget = async () => { n.bytecode.bytes !== artifact.decoded_bytes) ) throw new Error("EVM interface identity or uncertainty changed"); + if (values["verify-retained"]) { + if (!projected) + throw new Error( + "Retained verification requires supported selected summary", + ); + const bundle = (await call("get_evidence_bundle", {})).result; + const parent = bundle.records.find( + (record) => record.evidence_id === result.parent_evidence_id, + ); + if ( + !parent || + parent.operation !== "inspect_evm_interface" || + parent.subject?.digest.sha256 !== inputSha || + parent.parameters.path !== values.path || + parent.parameters.encoding !== "hex" || + "detail" in parent.parameters + ) + throw new Error( + "Complete original EVM source was not retained unchanged", + ); + const original = normalized(parent); + const { hex: _hex, ...identity } = original.bytecode; + const { stdout, ...diagnostics } = original.diagnostics; + const expected = { + ...original, + bytecode: identity, + diagnostics: { ...diagnostics, stdout_code_units: stdout.length }, + }; + if (!isDeepStrictEqual(expected, n)) + throw new Error("EVM summary changed selected original facts"); + for (const [facet, item] of [ + ["functions", original.functions.slice(0, 64)], + [ + "bytecode_hex", + { text: original.bytecode.hex.slice(0, 64), unit: "utf16-code-units" }, + ], + ]) { + const page = await call("inspect_analysis_view", { + source: { kind: "retained-evidence", evidence_id: parent.evidence_id }, + view: { kind: "evm", facet, offset: 0, limit: 64 }, + }); + const v = normalized(page); + if ( + !isDeepStrictEqual(v.item, item) || + v.parent_evidence_id !== parent.evidence_id || + v.artifact.sha256 !== inputSha + ) + throw new Error("EVM retained read changed source facts or identity"); + if (references.includes(page.evidence_id)) + throw new Error("EVM retained read reused an Evidence identity"); + references.push(page.evidence_id); + } + } + const { hex: _hex, ...bytecode } = n.bytecode; return { artifact_sha256: targetSha(info), artifact: n.artifact, - bytecode: n.bytecode, + bytecode, functions: n.functions, discovery_completeness: n.discovery_completeness, runtime_execution: n.runtime_execution, limitations: n.limitations, - evidence_ids: [info.evidence_id], + evidence_ids: references, }; }; @@ -672,6 +760,7 @@ const report = { artifact_sha256_after: finalInputSha, delivery: values.delivery, sdk_schema_mode: values.schemas, + retained_verification: values["verify-retained"], native_question: NATIVE_ARTIFACTS.includes(values.artifact) ? values["native-question"] : null, diff --git a/experiments/analysis-efficiency/review-model.py b/experiments/analysis-efficiency/review-model.py index c31a01290..79e070181 100644 --- a/experiments/analysis-efficiency/review-model.py +++ b/experiments/analysis-efficiency/review-model.py @@ -98,8 +98,13 @@ def main(): check('wabt_' + key, answer.get('wabt_' + key), reference['tool_profile'][key]) elif domain == 'evm': n = producer_results['inspect_evm_interface'][-1]['normalized_result'] + if n.get('kind') == 'summary': + check('evm_summary.parent_operation', n.get('parent_operation'), 'inspect_evm_interface') + checks['evm_summary.parent_reference_observed'] = n.get('parent_evidence_id') in observed_ids + n = n['summary'] for key, expected in {'artifact_bytes': n['artifact']['bytes'], 'bytecode_sha256': n['bytecode']['sha256'], 'bytecode_bytes': n['bytecode']['bytes'], 'functions': n['functions'], 'evidence_kind': n['evidence_kind'], 'discovery_completeness': n['discovery_completeness'], 'runtime_execution': n['runtime_execution'], 'bytecode_kind': n['bytecode']['kind'], 'hardfork': n['bytecode']['hardfork'], 'deployment_authenticity': n['bytecode']['deployment_authenticity']}.items(): check(key, answer.get(key), expected) + check('evm_original_limitations', answer.get('limitations'), n['limitations']) elif domain == 'android': for key in ['package_name', 'version_name', 'version_code', 'matches']: check(key, answer.get(key), reference[key]) diff --git a/experiments/analysis-efficiency/verify-evm-retention.mjs b/experiments/analysis-efficiency/verify-evm-retention.mjs new file mode 100644 index 000000000..e5f7de74c --- /dev/null +++ b/experiments/analysis-efficiency/verify-evm-retention.mjs @@ -0,0 +1,167 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { isAbsolute, resolve } from "node:path"; +import { parseArgs, promisify } from "node:util"; +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; + +const { values } = parseArgs({ + options: { + "runtime-root": { type: "string" }, + revision: { type: "string" }, + bundle: { type: "string" }, + output: { type: "string" }, + }, +}); +for (const key of ["runtime-root", "bundle", "output"]) + assert.ok(values[key] && isAbsolute(values[key]), `Supply --${key} ABS`); +assert.ok(values.revision); +await mkdir(values.output, { mode: 0o700 }); +const sha = (bytes) => createHash("sha256").update(bytes).digest("hex"); +const bytes = await readFile(values.bundle); +const original = JSON.parse(bytes.toString("utf8")); +const parents = original.records.filter( + (record) => record.operation === "inspect_evm_interface", +); +assert.ok(parents.length > 0, "Select a bundle containing old EVM Evidence"); +const entrypoint = resolve(values["runtime-root"], "scripts/rea.mjs"); +const environment = { + ...process.env, + REA_LOG_LEVEL: "silent", + NODE_OPTIONS: "--max-old-space-size=512", + // If a view accidentally starts analysis, its required limiter is unavailable. + REA_EVM_PRLIMIT_COMMAND: resolve(values.output, "absent-prlimit"), +}; +const client = new Client({ name: "rea-old-evm-retention", version: "1" }); +const transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env: environment, + stderr: "pipe", +}); +const definitions = new Map(); +const calls = []; +const call = async (name, args) => { + const definition = definitions.get(name); + assert.ok(definition, `Missing named contract ${name}`); + const reply = await client.callTool( + { name, arguments: args }, + { toolDefinition: definition, timeout: 60_000 }, + ); + await writeFile( + resolve(values.output, `call-${calls.length + 1}.json`), + JSON.stringify(reply), + { flag: "wx", mode: 0o600 }, + ); + assert.notEqual(reply.isError, true, JSON.stringify(reply)); + const text = JSON.parse(reply.content.find((x) => x.type === "text").text); + assert.deepEqual(reply.structuredContent, text); + calls.push({ name, arguments: args, evidence_id: text.evidence_id ?? null }); + return text; +}; +const checked = []; +try { + await client.connect(transport); + transport.stderr?.resume(); + for (const definition of (await client.listTools()).tools) + definitions.set(definition.name, definition); + await call("import_evidence_bundle", { path: values.bundle }); + const imported = (await call("get_evidence_bundle", {})).result.records; + for (const parent of parents) { + assert.deepEqual( + imported.find((record) => record.evidence_id === parent.evidence_id), + parent, + ); + const source = { + kind: "retained-evidence", + evidence_id: parent.evidence_id, + }; + const view = await call("inspect_analysis_view", { + source, + view: { kind: "summary" }, + }); + const { hex, ...bytecode } = parent.normalized_result.bytecode; + const { stdout, ...diagnostics } = parent.normalized_result.diagnostics; + assert.deepEqual(view.normalized_result.summary, { + ...parent.normalized_result, + bytecode, + diagnostics: { ...diagnostics, stdout_code_units: stdout.length }, + }); + assert.equal(view.normalized_result.parent_evidence_id, parent.evidence_id); + assert.notEqual(view.evidence_id, parent.evidence_id); + assert.deepEqual(view.evidence_links, [parent.evidence_id]); + for (const [facet, expected] of [ + ["functions", parent.normalized_result.functions.slice(0, 2)], + ["bytecode_hex", { text: hex.slice(0, 64), unit: "utf16-code-units" }], + ]) { + const page = await call("inspect_analysis_view", { + source, + view: { + kind: "evm", + facet, + offset: 0, + limit: facet === "functions" ? 2 : 64, + }, + }); + assert.deepEqual(page.normalized_result.item, expected); + assert.equal( + page.normalized_result.parent_evidence_id, + parent.evidence_id, + ); + assert.deepEqual(page.evidence_links, [parent.evidence_id]); + } + const inlinePath = resolve(values.output, `inline-${checked.length}.json`); + await writeFile( + inlinePath, + JSON.stringify({ + source: { kind: "inline", evidence: parent }, + view: { kind: "summary" }, + }), + { flag: "wx", mode: 0o600 }, + ); + const result = await promisify(execFile)( + process.execPath, + [entrypoint, "inspect-analysis-view", inlinePath, "--json"], + { env: environment, timeout: 60_000, maxBuffer: 16 * 1024 * 1024 }, + ); + const inline = JSON.parse(result.stdout); + assert.equal( + inline.normalized_result.parent_evidence_id, + parent.evidence_id, + ); + assert.deepEqual( + inline.normalized_result.summary, + view.normalized_result.summary, + ); + checked.push(parent.evidence_id); + } + const path = resolve(values.output, "exported.json"); + await call("export_evidence_bundle", { path }); + const exported = JSON.parse(await readFile(path, "utf8")); + for (const record of original.records) + assert.deepEqual( + exported.records.find((x) => x.evidence_id === record.evidence_id), + record, + ); + assert.deepEqual(await readFile(values.bundle), bytes); +} finally { + await client.close(); +} +const report = { + runtime_revision: values.revision, + original_bundle_sha256: sha(bytes), + checked_original_evidence_ids: checked, + original_records_and_source_bytes_unchanged: true, + exact_summaries_pages_and_cli_inline_pass: true, + calls, + scope: + "Old EVM Evidence through actual public SDK/CLI with an unavailable analysis limiter; original inferred facts, explicit unknowns, parent identities and export records preserved. Caller supplies the frozen runtime revision; this is migration verification, not a performance pair.", +}; +await writeFile( + resolve(values.output, "summary.json"), + JSON.stringify(report, null, 2), + { flag: "wx", mode: 0o600 }, +); +process.stdout.write(JSON.stringify(report) + "\n"); diff --git a/src/application/analysisView/AnalysisViewService.ts b/src/application/analysisView/AnalysisViewService.ts index 4af70eb4e..a5b603a86 100644 --- a/src/application/analysisView/AnalysisViewService.ts +++ b/src/application/analysisView/AnalysisViewService.ts @@ -102,6 +102,38 @@ export interface AnalysisViewRetention { readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined; } +/** Fail before analysis when selected delivery cannot keep its complete source. */ +export const requireAnalysisViewRetention = ( + operation: string, + retention: AnalysisViewRetention, +): Result< + { + readonly evidenceById: EvidenceLookup; + readonly recordEvidence: EvidenceWriter["recordEvidence"]; + }, + AnalysisError +> => { + if ( + retention.recordEvidence === undefined || + retention.evidenceById === undefined + ) + return err( + new AnalysisCapabilityUnavailableError( + ANALYSIS_VIEW_PROVIDER.id, + operation, + "summary detail requires a server session that retains Evidence for later views", + { + userMessage: + "This server does not retain Evidence. Select detail complete to receive the complete analysis inline.", + }, + ), + ); + return ok({ + evidenceById: retention.evidenceById, + recordEvidence: retention.recordEvidence, + }); +}; + /** * Retain complete analysis Evidence in the session, then project its summary * view from the retained record. Callers page or select the rest through @@ -111,29 +143,16 @@ export const summarizeRetainedAnalysis = ( evidence: Evidence, retention: AnalysisViewRetention, ): Result => { - if ( - retention.recordEvidence === undefined || - retention.evidenceById === undefined - ) - return err( - new AnalysisCapabilityUnavailableError( - ANALYSIS_VIEW_PROVIDER.id, - evidence.operation, - "summary detail requires a server session that retains Evidence for later views", - { - userMessage: - "This server does not retain Evidence. Select detail complete to receive the complete analysis inline.", - }, - ), - ); - const recorded = retention.recordEvidence(evidence); + const ready = requireAnalysisViewRetention(evidence.operation, retention); + if (!ready.ok) return ready; + const recorded = ready.value.recordEvidence(evidence); if (!recorded.ok) return recorded; return inspectAnalysisViewValidated( { source: { kind: "retained-evidence", evidence_id: evidence.evidence_id }, view: { kind: "summary" }, }, - retention.evidenceById, + ready.value.evidenceById, ); }; diff --git a/src/contracts/analysisViewToolContracts.ts b/src/contracts/analysisViewToolContracts.ts index f55aafcf7..745009ebc 100644 --- a/src/contracts/analysisViewToolContracts.ts +++ b/src/contracts/analysisViewToolContracts.ts @@ -16,7 +16,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_analysis_view"), kind: "application", description: - 'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact or inspect_web_network_capture Evidence without repeating analysis. 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. 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: }. For layout, JavaScript, native, inventory and WASM 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. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions; occurrence logical_paths matches exact recorded strings (omitted means all available, [] means none). source_complete distinguishes complete inventories from partial source records; 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. Native views 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 binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_evm_interface or inspect_web_network_capture Evidence without repeating analysis. 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. 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: }. For layout, JavaScript, native, inventory, WASM and EVM 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. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions; occurrence logical_paths matches exact recorded strings (omitted means all available, [] means none). source_complete distinguishes complete inventories from partial source records; 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. 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. Native views 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/evm/evmToolContracts.ts b/src/contracts/evm/evmToolContracts.ts index e8df79be3..197fee77b 100644 --- a/src/contracts/evm/evmToolContracts.ts +++ b/src/contracts/evm/evmToolContracts.ts @@ -4,7 +4,18 @@ import { } from "../../domain/evm/evmInterface.js"; import type { ToolContract } from "../toolContractTypes.js"; import { toolContractMetadata } from "../toolEffects.js"; -import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js"; +import { + analysisDetailSchema, + analysisDetailOutputSchemaOf, +} from "../analysisDetail.js"; +import { evmInterfaceSummaryViewResultSchema } from "../../domain/analysisView/analysisView.js"; + +/** Delivery selection leaves the existing provider input and original Evidence unchanged. */ +const evmInterfaceInspectionInputSchema = inspectEvmInterfaceInputSchema.extend( + { + detail: analysisDetailSchema.unwrap().optional(), + }, +); /** Target-free EVM capabilities are independent of archive/Apple artifact providers. */ export const INSPECT_EVM_INTERFACE_CONTRACT = { @@ -12,14 +23,25 @@ export const INSPECT_EVM_INTERFACE_CONTRACT = { ...toolContractMetadata("inspect_evm_interface"), kind: "artifact-provider", description: - "Inspect an explicit local flat EVM bytecode carrier and infer dispatch selectors, argument strings and mutability without executing the target or accessing a chain/RPC endpoint. Choose raw or hex encoding explicitly. Returns original carrier and decoded-byte SHA-256, full bytecode and interface candidates inline as inferred Evidence; bytecode kind, hardfork, deployed authenticity and discovery completeness remain unknown. Initial real-verified Linux x64 profile uses unchanged bundled EVMole 0.9.3/WASM in a bounded owned worker; EOF-style EF00 containers are unsupported. No signature lookup, wallet action or transaction broadcast.", - inputSchema: inspectEvmInterfaceInputSchema, - outputSchema: evidenceResultOf(evmInterfaceSchema), + 'Inspect explicit local flat EVM bytecode without target execution or chain/RPC access. Select raw or hex encoding explicitly. Omitted or complete detail returns original carrier and decoded identities, full bytecode and all inferred interface candidates inline. For all inferred selectors/offsets/arguments/mutability and identities without bytecode hex or duplicated stdout, select detail: "summary" on this first call: it retains unchanged complete Evidence and returns a distinct linked inspect_analysis_view summary. Read original function rows or bytecode_hex/stdout/stderr text pages using view kind evm and normalized_result.parent_evidence_id; original complete Evidence remains accessible. Bytecode kind, hardfork, deployed authenticity and discovery completeness stay unknown; summary coverage covers only producer-returned candidates. Unchanged bundled EVMole 0.9.3/WASM in an owned bounded worker is initially real-verified on Linux x64; EOF-style EF00 containers are unsupported. No signature lookup, wallet action or transaction broadcast.', + inputSchema: evmInterfaceInspectionInputSchema, + outputSchema: analysisDetailOutputSchemaOf( + evmInterfaceSchema, + evmInterfaceSummaryViewResultSchema, + ), examples: [ { title: "Inspect a local runtime bytecode hex carrier", input: { path: "/artifacts/runtime.hex", encoding: "hex" }, }, + { + title: "Read every inferred interface candidate without bytecode text", + input: { + path: "/artifacts/runtime.hex", + encoding: "hex", + detail: "summary", + }, + }, ], } as const satisfies ToolContract; diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index 539557877..2d5751c27 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -35,6 +35,12 @@ import { apiSurfaceViewSchema, } from "./webNetworkApiSurfaceView.js"; import { projectNativeFunctionView } from "./nativeFunctionView.js"; +import { + projectEvmInterfaceView, + evmViewRequestSchema, + evmViewItemSchema, + evmSummarySchema, +} from "./evmInterfaceView.js"; import { projectArtifactInventoryView } from "./artifactInventoryView.js"; import { projectWasmArtifactView, @@ -154,6 +160,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [ nativeViewSchema, inventoryViewSchema, wasmViewRequestSchema, + evmViewRequestSchema, apiSurfaceViewSchema, ]); @@ -266,6 +273,7 @@ const viewResultBase = { "inspect_artifact", "inventory_artifact", "inspect_wasm_artifact", + "inspect_evm_interface", ]), parent_digest: digestSchema, view_digest: digestSchema, @@ -309,6 +317,13 @@ export const wasmArtifactSummaryViewResultSchema = z.strictObject({ summary: wasmSummarySchema, }); +/** Exact inferred-interface summary for EVM's caller-selected delivery. */ +export const evmInterfaceSummaryViewResultSchema = z.strictObject({ + ...summaryViewResultBase, + parent_operation: z.literal("inspect_evm_interface"), + summary: evmSummarySchema, +}); + /** Projected facts for one selected view of retained analysis Evidence. */ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ z.strictObject({ @@ -329,6 +344,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ javascriptSummarySchema, inventorySummarySchema, wasmSummarySchema, + evmSummarySchema, ]), }), z.strictObject({ @@ -362,6 +378,13 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ item: wasmViewItemSchema, ...viewResultBase, }), + z.strictObject({ + kind: z.literal("evm"), + view: evmViewRequestSchema, + item: evmViewItemSchema, + ...viewResultBase, + parent_operation: z.literal("inspect_evm_interface"), + }), z.strictObject({ kind: z.literal("inventory"), view: inventoryViewSchema, @@ -427,7 +450,8 @@ export type UnsignedAnalysisView = Extract, "view_digest" > - | Omit, "view_digest">; + | Omit, "view_digest"> + | Omit, "view_digest">; /** Attach the content digest that must change when projected bytes change. */ export const sealAnalysisView = ( @@ -571,6 +595,10 @@ export const projectAnalysisView = ( const projected = projectWasmArtifactView(parent, view); return projected.ok ? ok(sealAnalysisView(projected.value)) : projected; } + if (parent.operation === "inspect_evm_interface") { + const projected = projectEvmInterfaceView(parent, view); + return projected.ok ? ok(sealAnalysisView(projected.value)) : projected; + } if ( parent.operation === "inspect_artifact" || parent.operation === "inventory_artifact" diff --git a/src/domain/analysisView/binaryLayoutView.ts b/src/domain/analysisView/binaryLayoutView.ts index 2fa2551bb..bb88b421d 100644 --- a/src/domain/analysisView/binaryLayoutView.ts +++ b/src/domain/analysisView/binaryLayoutView.ts @@ -164,6 +164,10 @@ export const projectBinaryLayoutView = ( return err( layoutIncompatible("WASM views require inspect_wasm_artifact Evidence."), ); + if (view.kind === "evm") + return err( + layoutIncompatible("EVM views require inspect_evm_interface Evidence."), + ); if (view.kind === "summary") return ok({ kind: "summary", diff --git a/src/domain/analysisView/evmInterfaceView.ts b/src/domain/analysisView/evmInterfaceView.ts new file mode 100644 index 000000000..a7b343291 --- /dev/null +++ b/src/domain/analysisView/evmInterfaceView.ts @@ -0,0 +1,145 @@ +import { z } from "zod"; +import type { AnalysisError } from "../analysisErrorBase.js"; +import { uniqueSorted } from "../canonicalOrdering.js"; +import { evmInterfaceSchema } from "../evm/evmInterface.js"; +import { analysisInputErrorFromIssues } from "../inputIssueProjection.js"; +import { err, ok, type Result } from "../result.js"; +import { + analysisViewInputError, + type AnalysisViewParent, + type AnalysisViewRequest, + type UnsignedAnalysisView, +} from "./analysisView.js"; +import { + completeWithinViewCoverage, + pageViewCoverage, +} from "./analysisViewCoverage.js"; +import { analysisTextPage } from "./analysisViewText.js"; + +/** Retained inferred candidates or exact text; offsets for text count UTF-16 code units. */ +export const evmViewRequestSchema = z.strictObject({ + kind: z.literal("evm"), + facet: z.enum(["functions", "bytecode_hex", "stdout", "stderr"]), + offset: z.number().int().nonnegative().default(0), + limit: z.number().int().positive().default(64), +}); + +export const evmViewItemSchema = z.union([ + evmInterfaceSchema.shape.functions, + z.strictObject({ text: z.string(), unit: z.literal("utf16-code-units") }), +]); + +/** Complete inferred interface facts without the unrequested hex or duplicated stdout. */ +export const evmSummarySchema = z.strictObject({ + artifact: evmInterfaceSchema.shape.artifact, + bytecode: evmInterfaceSchema.shape.bytecode.omit({ hex: true }), + evidence_kind: evmInterfaceSchema.shape.evidence_kind, + functions: evmInterfaceSchema.shape.functions, + discovery_completeness: evmInterfaceSchema.shape.discovery_completeness, + runtime_execution: evmInterfaceSchema.shape.runtime_execution, + diagnostics: evmInterfaceSchema.shape.diagnostics + .omit({ stdout: true }) + .extend({ + stdout_code_units: z.number().int().nonnegative(), + }), + limitations: evmInterfaceSchema.shape.limitations, +}); + +/** Select already authenticated producer facts; no interpreter or provider is invoked. */ +export const projectEvmInterfaceView = ( + parent: AnalysisViewParent, + view: AnalysisViewRequest, +): Result => { + if (view.kind !== "summary" && view.kind !== "evm") + return err( + analysisViewInputError([ + { + path: ["view"], + reason: "invalid_value", + message: "EVM Evidence supports summary and evm views.", + }, + ]), + ); + const parsed = evmInterfaceSchema.safeParse(parent.normalizedResult); + if (!parsed.success) + return err( + analysisInputErrorFromIssues( + "inspect_analysis_view", + parsed.error.issues.map((issue) => ({ + ...issue, + path: ["source", "normalized_result", ...issue.path], + })), + { source: { normalized_result: parent.normalizedResult } }, + { cause: parsed.error }, + ), + ); + const report = parsed.data; + if ( + parent.subject == null || + parent.subject.local_path !== report.artifact.path || + parent.subject.digest.sha256 !== report.artifact.sha256 + ) + return err( + analysisViewInputError([ + { + path: ["source", "subject"], + reason: "invalid_value", + message: + "EVM parent subject must identify the report's exact carrier path and SHA-256.", + }, + ]), + ); + const shared = { + parent_evidence_id: parent.evidenceId, + parent_operation: "inspect_evm_interface" as const, + parent_digest: parent.evidenceId.slice(3), + artifact: { path: report.artifact.path, sha256: report.artifact.sha256 }, + limitations: uniqueSorted([...parent.limitations, ...report.limitations]), + unknowns: [ + "Candidates remain inferred; discovery completeness, bytecode kind, hardfork and deployment authenticity are unknown. Runtime execution was not performed.", + ], + }; + if (view.kind === "summary") { + const { hex: _hex, ...bytecode } = report.bytecode; + const { stdout, ...diagnostics } = report.diagnostics; + return ok({ + kind: "summary", + view, + ...shared, + summary: { + artifact: report.artifact, + bytecode, + evidence_kind: report.evidence_kind, + functions: report.functions, + discovery_completeness: report.discovery_completeness, + runtime_execution: report.runtime_execution, + diagnostics: { ...diagnostics, stdout_code_units: stdout.length }, + limitations: report.limitations, + }, + coverage: completeWithinViewCoverage( + report.functions.length, + report.functions.length, + ), + }); + } + if (view.facet === "functions") { + const item = report.functions.slice(view.offset, view.offset + view.limit); + return ok({ + kind: "evm", + view, + item, + ...shared, + coverage: pageViewCoverage( + view.offset, + item.length, + report.functions.length, + ), + }); + } + const text = + view.facet === "bytecode_hex" + ? report.bytecode.hex + : report.diagnostics[view.facet]; + const page = analysisTextPage(text, view.offset, view.limit, view.facet); + return page.ok ? ok({ kind: "evm", view, ...page.value, ...shared }) : page; +}; diff --git a/src/domain/analysisView/javascriptApplicationView.ts b/src/domain/analysisView/javascriptApplicationView.ts index 140a30f9e..5e5f9bb88 100644 --- a/src/domain/analysisView/javascriptApplicationView.ts +++ b/src/domain/analysisView/javascriptApplicationView.ts @@ -253,6 +253,10 @@ export const projectJavaScriptApplicationView = ( return err( jsIncompatible("WASM views require inspect_wasm_artifact Evidence."), ); + if (view.kind === "evm") + return err( + jsIncompatible("EVM views require inspect_evm_interface Evidence."), + ); if (view.kind === "summary") return ok({ kind: "summary", diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 19d169bd1..b67a765ca 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -527,6 +527,7 @@ const registerConfiguredAnalysisTools = ( toolLogger, recordEvidence, withAdmittedAnalysis, + evidenceById, ); registerSqliteTools( server, diff --git a/src/server/registerEvmTools.ts b/src/server/registerEvmTools.ts index ff460672e..ce155a9e5 100644 --- a/src/server/registerEvmTools.ts +++ b/src/server/registerEvmTools.ts @@ -2,6 +2,13 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import type { EvmInterfaceService } from "../application/evm/EvmInterfaceService.js"; import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; +import type { EvidenceLookup } from "../application/EvidenceInputResolver.js"; +import { + requireAnalysisViewRetention, + summarizeRetainedAnalysis, +} from "../application/analysisView/AnalysisViewService.js"; +import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; +import { err } from "../domain/result.js"; import { evmInterfaceModule } from "../composition/evm.js"; import type { Logger } from "pino"; import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; @@ -15,11 +22,33 @@ export const registerEvmTools = ( logger: Logger, recordEvidence?: EvidenceWriter["recordEvidence"], withAdmittedAnalysis?: WithAdmittedAnalysis, + evidenceById?: EvidenceLookup, ): void => { const contract = evmInterfaceModule.contract; const handler = createEvidenceToolHandler( server, - (input, options) => service.inspect(input, options), + async (rawInput, options) => { + const parsed = contract.inputSchema.safeParse(rawInput); + if (!parsed.success) + return err( + analysisInputErrorFromIssues( + contract.name, + parsed.error.issues, + rawInput, + { cause: parsed.error }, + ), + ); + const { detail, ...input } = parsed.data; + const retention = { recordEvidence, evidenceById }; + if (detail === "summary") { + const ready = requireAnalysisViewRetention(contract.name, retention); + if (!ready.ok) return ready; + } + const inspected = await service.inspect(input, options); + return detail === "summary" && inspected.ok + ? summarizeRetainedAnalysis(inspected.value, retention) + : inspected; + }, logger, recordEvidence, withAdmittedAnalysis, diff --git a/tests/boundary/mcp/evmInterfaceMcp.test.ts b/tests/boundary/mcp/evmInterfaceMcp.test.ts index a39fd53a8..704af92c5 100644 --- a/tests/boundary/mcp/evmInterfaceMcp.test.ts +++ b/tests/boundary/mcp/evmInterfaceMcp.test.ts @@ -8,7 +8,8 @@ import { AnalysisCancelledError, AnalysisCapabilityUnavailableError, } from "../../../src/domain/analysisErrorCore.js"; -import { parseEvidence } from "../../../src/domain/evidence.js"; +import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; +import { evmInterfaceSummaryViewResultSchema } from "../../../src/domain/analysisView/analysisView.js"; import { err, ok } from "../../../src/domain/result.js"; import { silentLogger } from "../../../src/logger.js"; import { createServer } from "../../../src/server/createServer.js"; @@ -249,3 +250,244 @@ it("settles the admitted EVM operation after SDK cancellation and accepts a subs expect((await harness.client.callTool(input)).isError).not.toBe(true); expect(invocations).toBe(2); }); + +it("returns the exact inferred interface summary while preserving complete defaults and retained source", async () => { + const value = evmInterfaceFixture(); + value.functions = [ + { + selector: "0x11223344", + bytecode_offset: 0, + dispatch: "abi", + inferred_arguments: null, + inferred_state_mutability: null, + }, + { + selector: "0xaabbccdd", + bytecode_offset: 4, + dispatch: "fallback", + inferred_arguments: "uint256", + inferred_state_mutability: "view", + }, + ]; + value.diagnostics = { + stdout: "exact producer JSON\n", + stderr: "source warning\n", + truncated: false, + }; + const inspect = vi.fn(() => + Promise.resolve(ok(evmInterfaceExecution(value))), + ); + const harness = await createApplicationMcpHarness({ + serverOptions: { evmInterface: new EvmInterfaceService({ inspect }) }, + }); + onTestFinished(harness.close); + const definition = (await harness.client.listTools()).tools.find( + (tool) => tool.name === "inspect_evm_interface", + ); + if (definition?.outputSchema === undefined) + throw new Error("Missing EVM output schema"); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + const inputSchema: Record = definition.inputSchema; + const outputSchema: Record = definition.outputSchema; + expect(ajv.validateSchema(inputSchema)).toBe(true); + expect(ajv.validateSchema(outputSchema)).toBe(true); + const input = { path: value.artifact.path, encoding: "hex" }; + let original; + for (const detail of [undefined, "complete"] as const) { + const response = await harness.client.callTool({ + name: "inspect_evm_interface", + arguments: { ...input, ...(detail ? { detail } : {}) }, + }); + expect(response.isError).not.toBe(true); + expect( + ajv.validate(outputSchema, response.structuredContent), + JSON.stringify(ajv.errors), + ).toBe(true); + const evidence = parseEvidence(response.structuredContent); + expect(evidence.normalized_result).toEqual(value); + expect(evidence.parameters).toEqual(input); + original = evidence; + } + if (original === undefined) throw new Error("No complete control"); + const response = await harness.client.callTool({ + name: "inspect_evm_interface", + arguments: { ...input, detail: "summary" }, + }); + expect(response.isError).not.toBe(true); + expect( + ajv.validate(outputSchema, response.structuredContent), + JSON.stringify(ajv.errors), + ).toBe(true); + const derived = parseEvidence(response.structuredContent); + const view = evmInterfaceSummaryViewResultSchema.parse( + derived.normalized_result, + ); + expect(derived.evidence_id).not.toBe(original.evidence_id); + expect(derived.evidence_links).toEqual([original.evidence_id]); + expect(derived.confidence).toBe("derived"); + const { hex: _hex, ...bytecode } = value.bytecode; + expect(view).toMatchObject({ + parent_operation: "inspect_evm_interface", + parent_evidence_id: original.evidence_id, + summary: { + artifact: value.artifact, + bytecode, + functions: value.functions, + evidence_kind: "inferred", + discovery_completeness: "unknown", + runtime_execution: "not-performed", + limitations: value.limitations, + diagnostics: { + stderr: value.diagnostics.stderr, + stdout_code_units: value.diagnostics.stdout.length, + }, + }, + coverage: { examined: 2, total: 2, exhausted: true }, + }); + expect(view.summary.bytecode).not.toHaveProperty("hex"); + expect(view.summary.diagnostics).not.toHaveProperty("stdout"); + expect(harness.session.evidenceById(original.evidence_id)).toEqual(original); + for (const [facet, offset, limit, item] of [ + ["functions", 1, 1, [value.functions[1]]], + [ + "bytecode_hex", + 2, + 4, + { text: value.bytecode.hex.slice(2, 6), unit: "utf16-code-units" }, + ], + [ + "stdout", + 0, + 128, + { text: value.diagnostics.stdout, unit: "utf16-code-units" }, + ], + ] as const) { + const selected = await harness.client.callTool({ + name: "inspect_analysis_view", + arguments: { + source: { + kind: "retained-evidence", + evidence_id: original.evidence_id, + }, + view: { kind: "evm", facet, offset, limit }, + }, + }); + expect(selected.isError).not.toBe(true); + const parsed = toolContract("inspect_analysis_view").outputSchema.parse( + selected.structuredContent, + ); + expect(parsed.normalized_result).toMatchObject({ + parent_evidence_id: original.evidence_id, + item, + }); + } + expect(inspect).toHaveBeenCalledTimes(3); + for (const call of inspect.mock.calls) + expect(call).toEqual([input, expect.any(Object)]); + expect(harness.session.evidenceById(original.evidence_id)).toEqual(original); +}); + +it.each(["valid", "wrong-subject", "no-subject"])( + "preserves historical portable EVM Evidence and rejects incompatible identity: %s", + async (scenario) => { + const value = evmInterfaceFixture(); + const source = createEvidence( + scenario === "no-subject" + ? undefined + : { + path: value.artifact.path, + sha256: + scenario === "wrong-subject" + ? "b".repeat(64) + : value.artifact.sha256, + format: "file", + }, + { id: "historical-evm", name: "Original EVM producer", version: "1" }, + { + operation: "inspect_evm_interface", + parameters: { path: value.artifact.path, encoding: "hex" }, + result: value, + confidence: "inferred", + rawResult: { original: true }, + }, + ); + const before = JSON.stringify(source); + const inspect = vi.fn(() => { + throw new Error("portable read must not rerun provider"); + }); + const harness = await createApplicationMcpHarness({ + serverOptions: { evmInterface: new EvmInterfaceService({ inspect }) }, + }); + onTestFinished(harness.close); + const response = await harness.client.callTool({ + name: "inspect_analysis_view", + arguments: { + source: { kind: "inline", evidence: source }, + view: { kind: "summary" }, + }, + }); + if (scenario === "valid") { + expect(response.isError).not.toBe(true); + const derived = parseEvidence(response.structuredContent); + const view = evmInterfaceSummaryViewResultSchema.parse( + derived.normalized_result, + ); + expect(view.parent_evidence_id).toBe(source.evidence_id); + expect(view.summary.functions).toEqual([]); + expect(view.summary.discovery_completeness).toBe("unknown"); + expect(view.coverage).toMatchObject({ + total: 0, + examined: 0, + exhausted: true, + }); + expect(derived.evidence_links).toEqual([source.evidence_id]); + } else + expect(parseMcpToolError(response).error.code).toBe("invalid_request"); + expect(inspect).not.toHaveBeenCalled(); + expect(JSON.stringify(source)).toBe(before); + }, +); + +it("rejects summary before provider work when retention is unavailable and preserves complete delivery", async () => { + const value = evmInterfaceFixture(); + const inspect = vi.fn(() => + Promise.resolve(ok(evmInterfaceExecution(value))), + ); + const session = createTestBinarySession(() => { + throw new Error("deep provider must not start"); + }); + const server = createServer( + { kind: "fixed", analysis: session }, + { evmInterface: new EvmInterfaceService({ inspect }) }, + ); + const client = new Client({ name: "evm-fixed-delivery", version: "1" }); + const [a, b] = InMemoryTransport.createLinkedPair(); + try { + await server.connect(b); + await client.connect(a); + const summary = await client.callTool({ + name: "inspect_evm_interface", + arguments: { + path: value.artifact.path, + encoding: "hex", + detail: "summary", + }, + }); + expect(parseMcpToolError(summary).error.code).toBe( + "capability_unavailable", + ); + expect(inspect).not.toHaveBeenCalled(); + const complete = await client.callTool({ + name: "inspect_evm_interface", + arguments: { path: value.artifact.path, encoding: "hex" }, + }); + expect(complete.isError).not.toBe(true); + expect(parseEvidence(complete.structuredContent).normalized_result).toEqual( + value, + ); + } finally { + await client.close(); + await server.close(); + await session.close(); + } +});