From 4c8647a706694665c881e5d8e3d50bfe70239db7 Mon Sep 17 00:00:00 2001 From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:32:01 +0800 Subject: [PATCH] feat(native): page retained observations and source context (#7146) * feat(native): page retained observations and source context * docs(native): record complete observation workflow measurements * fix(native): preserve context coverage and overflow recovery * fix(benchmarks): finalize partially initialized observation probes * docs(native): publish reviewed workflow verification --- .../references/native-and-artifacts.md | 15 + docs/mcp-contracts.md | 30 + .../native-observation-views.md | 113 ++++ .../native-observation-views.mjs | 417 ++++++++++++++ .../retained-query-finalization.mjs | 26 +- .../test_retained_queries.py | 20 + src/application/EnhancedTools.ts | 7 +- .../analysisView/AnalysisViewService.ts | 12 +- .../NativeObservationViews.test.ts | 543 ++++++++++++++++++ src/contracts/enhancedToolContracts.ts | 7 +- src/contracts/officialToolContracts.ts | 16 +- src/contracts/toolOutputSchemaGroups.ts | 39 +- src/contracts/toolOutputSchemaPrimitives.ts | 17 - src/domain/analysisView/analysisView.ts | 99 +++- .../analysisView/commandObservationView.ts | 2 +- src/domain/analysisView/nativeGraphView.ts | 98 ++++ .../analysisView/nativeInventoryView.ts | 2 +- .../analysisView/nativeLoadImageView.ts | 2 +- .../analysisView/nativeObservationSchemas.ts | 199 +++++++ .../analysisView/nativeObservationView.ts | 326 +++++++++++ src/domain/evidence.ts | 19 +- src/domain/native/nativeCallGraph.ts | 26 + src/domain/native/nativeObservation.ts | 36 ++ src/domain/relationshipCoverage.ts | 12 +- src/server/registerEnhancedTools.ts | 28 +- src/server/registerOfficialTools.ts | 2 + src/server/toolResult.ts | 5 +- .../mcp/nativeInventoryViewsMcp.test.ts | 12 +- .../mcp/nativeObservationViewsMcp.test.ts | 214 +++++++ 29 files changed, 2266 insertions(+), 78 deletions(-) create mode 100644 experiments/analysis-efficiency/native-observation-views.md create mode 100644 experiments/analysis-efficiency/native-observation-views.mjs create mode 100644 src/application/analysisView/NativeObservationViews.test.ts create mode 100644 src/domain/analysisView/nativeGraphView.ts create mode 100644 src/domain/analysisView/nativeObservationSchemas.ts create mode 100644 src/domain/analysisView/nativeObservationView.ts create mode 100644 src/domain/native/nativeCallGraph.ts create mode 100644 src/domain/native/nativeObservation.ts create mode 100644 tests/boundary/mcp/nativeObservationViewsMcp.test.ts diff --git a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md index 5bbbf5d017..d2f9a57b5e 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -403,6 +403,21 @@ encoded in bounded batches. The 25000 ms Cutter request deadline and the 256-candidate discovery budget still have no runtime override. +## Selected native facts and call graphs + +For a large `get_call_graph`, or repeated `procedure_address`, +`procedure_callees`, `procedure_callers` and `resolve_containing_procedure` +queries, select `detail: "summary"` when the first 64 recorded observations +suffice. Read further `native-observation` pages from the returned parent Evidence +ID. Graph message IDs index that page's exact-text dictionary; keep level, +source index, failed nodes, and partial/unknown relationship coverage. +Read referenced source context through `native-context` facets `overview`, +`analysis-profile`, `limitations` and `result-limitations`. Reuse inspected +context/profile values only for the same digest; until caveats are read, +interpretation beyond the returned facts remains unknown. Complete Evidence +remains retained and exportable. Small complete replies can cost fewer tokens +than additional context/page calls; count the completed task and discovery. + ## Static memory and original file bytes For Ghidra, `read_bytes` reads initialized analysis memory, including loader diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 174d827130..6f69bb80a0 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -242,6 +242,36 @@ to `/absolute/view-request.json`, then run `rea inspect-analysis-view /absolute/view-request.json --json`. This also reads pre-summary source records without changing their identity; no live provider is started. +`procedure_address`, `procedure_callees`, `procedure_callers`, +`resolve_containing_procedure` and `get_call_graph` also accept `detail: "summary"` +(complete remains the default). The summary includes the first 64 recorded +observations, exact source parameters and provider/profile identity, relationship +coverage where recorded, and the retained complete parent's ID. Continue with +`view: {"kind":"native-observation","offset":64,"limit":64}`. Array positions +retain `source_index`; graph rows additionally retain their original `level`. +Graph pages order levels by Unicode code point and keep original row order within +each level. A recorded empty level is a `status: "empty-level"` row with +`source_index: null`; observation counts include these markers. Graph diagnostic, +limitation and reason IDs index that page's `items.messages` dictionary. Expand +those references to recover exact text, repeated messages, and absent versus +empty optional fields. Failed nodes retain their original error. + +These views reference complete source context through `source_context.digest` +and caveat counts. Read it using the same parent with +`view: {"kind":"native-context","facet":"overview"}`. The other facets are +`analysis-profile`, `limitations` and `result-limitations`; all accept `offset` +and `limit`. Overview includes source subject, provider, authority, execution +environment and profile digest. Full profiles and caveat text remain accessible +without repeating them in every fact response. Context digests follow Evidence's +semantic subject identity, so caller-selected path aliases preserve context +identity while responses retain the selected display path. A null profile means +unavailable; null result-limitations means not applicable to a primitive and +is distinct from an observed empty graph limitation list. Until the source +caveats have been inspected, interpretations beyond the returned facts remain +unknown. Exhausted pages describe the recorded observations and do not establish +complete native discovery. The same views work through the CLI with historical +Evidence files or bundles and require no live provider. + For oversized native function Evidence, `details.reported_limits.continuation` contains a ready-to-call `inspect_analysis_view` request for the retained pseudocode's first page. Use its `name` and `arguments` directly, then follow diff --git a/experiments/analysis-efficiency/native-observation-views.md b/experiments/analysis-efficiency/native-observation-views.md new file mode 100644 index 0000000000..b97542286a --- /dev/null +++ b/experiments/analysis-efficiency/native-observation-views.md @@ -0,0 +1,113 @@ +# Retained native observations and context + +This experiment answers one fixed question over the unmodified stripped Linux +`/usr/bin/ls`: resolve 21 recorded functions, inspect every recorded caller/callee +relation and containing-function outcome, two complete graphs, and one missing +symbol. Repeat in the same session. Both lanes read all facts, source profiles, +and source caveats; no completeness advantage is inferred from smaller replies. + +The final complete control uses `5d428390996498bc4dcca4da934641557f9109bc`, +after the unrelated input-schema fitting revert. The first control used +`2b0aa212156fe125c60aea8fd73092329e25dd09` (merged as `6381aa0b1`). +This already includes request-scoped Ghidra input-error diagnostics, so those +savings are not attributed to observation views. The candidate uses the +implementation in this PR. Both discover the entire actual compact SDK catalog, +use the same full 652-row inventory to choose seeds, and use named SDK tool +definitions for response validation. The summary client keeps exact-digest +context/profile values within this one investigation. The server adds no cache. + +The first 64 observations arrive inline. Because this question explicitly needs +every remaining observation and every caveat, the client requests subsequent +pages of up to 512 entries. It decodes each graph page's message dictionary, +preserves original source indices, levels, duplicate text, optional fields, +failed nodes and coverage, and checks every reconstructed result against the +authenticated exported complete parent. All 65 original Evidence IDs and all +facts match the control in cold and warm phases. This establishes preservation +of the real Ghidra output, not independent completeness of Ghidra discovery. +Separate source fixtures establish graph ordering, duplicate/Unicode caveats, +empty levels, partial/unknown coverage, negative resolution, malformed omitted +rows, and authenticated path-alias identity. + +## Environment and reproduction + +- Target SHA-256: `cb30d69b24245bf2ecdc9e7f53bbad19159999970b6d82c0c00c7d32d9e37aa4`. +- Linux x86-64; Node 24.22.1; Ghidra 12.1.4; JDK 21.0.12.1. +- Fresh MCP process and Ghidra session for each lane; warm OS file caches. + The warm phase reuses the same provider session and the client's validated + context values. This is not a cold-disk comparison. +- Two CPU affinity slots; Node old-space limit 3 GiB and Ghidra heap 1536 MiB. + An external supervisor sampled the whole process family's summed RSS every + 100 ms, stopped at 8 GiB or below 4 GiB available host memory, and enforced a + 300-second deadline. No resource stop occurred. RSS can double-count shared + pages and miss short peaks. CPU includes driver, SDK, REA and Java descendants. + +Build both worktrees with the pinned toolchain. Run this PR's driver against +each built runtime, with separate new output directories: + +```sh +node experiments/analysis-efficiency/native-observation-views.mjs \ + --runtime /absolute/control --target /usr/bin/ls --output /tmp/native-control \ + --ghidra /absolute/ghidra --java /absolute/jdk --mode complete +node experiments/analysis-efficiency/native-observation-views.mjs \ + --runtime /absolute/candidate --target /usr/bin/ls --output /tmp/native-summary \ + --ghidra /absolute/ghidra --java /absolute/jdk --mode summary \ + --control /tmp/native-control +``` + +Apply a process-family resource supervisor appropriate to the host. The driver +itself does not impose a family-memory limit. Each output keeps the actual +catalog, every RPC argument/reply, complete Evidence export, per-phase facts, +server log and completion/cleanup receipts. Failed runs retain their receipt. + +## Measurements + +| Completed task | Complete control | Summary + complete context | +| ------------------------------------------------ | ---------------: | -------------------------: | +| RPCs | 137 | 179 | +| Catalog token estimate | 919,115 | 932,545 | +| Catalog + arguments + text token estimate | 1,243,161 | 1,234,768 | +| Catalog + arguments + raw replies token estimate | 1,587,863 | 1,554,038 | +| All reply text bytes | 1,331,358 | 1,006,503 | +| Whole-family elapsed seconds | 39.38 | 41.06 | +| Whole-family CPU seconds | 69.26 | 70.4 | +| Sampled family peak RSS GiB | 1.45 | 1.69 | + +The completed-task text estimate decreases 0.68%; raw-reply accounting decreases +2.13%, and text bytes decrease 24.4%. The large forward graph's first reply is +63,888 → 5,150 estimated text tokens (283,990 → 15,995 bytes); remaining facts +and all caveats are included in the completed-task table. A one-address callee +reply is only 754 → 744 tokens despite 3,104 → 2,326 bytes: bytes alone would +overstate its token improvement. There is no demonstrated time or memory +improvement. The complete-read workflow adds 42 RPCs; its warm phase is +0.762 → 1.005 seconds. Cold phases are 33.151 → 34.804 seconds. This one fresh +session per final lane cannot separate provider-startup variance from overhead. +The final candidate implementation and experiment driver are `4ac1971864`; +the final control is `5d428390996`. CPU and RSS remain within the stated guard +but are higher. An earlier pre-review pair measured 34.55 → 50.29 seconds, +so elapsed-time variance must not be mistaken for a speed improvement. + +Token estimates use `cl100k_base` over one pass of raw catalog JSON plus every +RPC's arguments and text content. A second accounting counts the full raw RPC +reply, including duplicate structured/text representations. Both include page, +context, error, export and close calls. They exclude prompts, MCP initialization, +JSON-RPC envelopes, host rendering and replay, and are not actual model usage. +Catalog discovery dominates this example. No claim of better model reasoning +or complete discovery follows from these counts. + +A first control attempt completed both investigations but its experiment driver +incorrectly parsed the export acknowledgement as Evidence; it failed during +verification, cleaned up, and was rerun after correcting the driver. A first +candidate with 64-entry continuation pages and duplicated coverage/verbose +caveat instructions increased the completed-task text token estimate by 1.2%. +That result was retained; the final implementation removes repeated coverage +from the normalized payload (the Evidence envelope owns it), shortens the +instructions, and the complete-read client requests larger continuation pages. +Neither failed/development attempt is included in the final paired task table. + +Review regressions also cover context-facet partial/unknown relationship +coverage and actionable overflow continuations through all five native tools +without another provider read. The latter injects a 32 KiB delivery budget to +exercise capacity cheaply; it does not establish a real 10 MiB graph failure. +Actual missing-target and failing-Git commands each publish a failed receipt +without leaking a temporary HOME. Thirteen deterministic receipt tests also +cover partially acquired resources and failed cleanup. diff --git a/experiments/analysis-efficiency/native-observation-views.mjs b/experiments/analysis-efficiency/native-observation-views.mjs new file mode 100644 index 0000000000..695cb33d35 --- /dev/null +++ b/experiments/analysis-efficiency/native-observation-views.mjs @@ -0,0 +1,417 @@ +/** Equivalent real Ghidra investigations, including every page and complete source context. */ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { execFile } from "node:child_process"; +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 { pathToFileURL } from "node:url"; +import { finished } from "node:stream/promises"; +import { parseArgs, promisify } 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", "target", "output", "ghidra", "java", "mode", "control"].map( + (name) => [name, { type: "string" }], + ), + ), +}); +for (const key of ["runtime", "target", "output", "ghidra", "java"]) { + assert.ok(values[key], `Missing --${key}`); + values[key] = resolve(values[key]); +} +assert.ok(["complete", "summary"].includes(values.mode)); +await mkdir(values.output); +const load = (path) => import(pathToFileURL(join(values.runtime, path)).href); +let parseEvidence, evidenceSubjectIdentity, digestCanonicalValue; +const hash = (value) => createHash("sha256").update(value).digest("hex"); +let account, transport, serverLog, serverLogFinished, client, call; +const git = async (args) => + (await promisify(execFile)("git", ["-C", values.runtime, ...args])).stdout; +const report = { + mode: values.mode, + runtime: values.runtime, + revision: null, + dirty: null, + target: values.target, + sha256: null, + question: + "Resolve 21 recorded functions, retain all caller/callee facts and two graphs, materialize every source profile and caveat, and inspect one unknown-symbol error, twice in one session.", + calls: [], + runs: [], +}; +const transcript = []; +const good = async (name, input, phase) => { + const reply = await call(name, input, phase); + assert.notEqual(reply.isError, true, JSON.stringify(reply)); + return parseEvidence(reply.structuredContent); +}; +const page = async (id, view, phase) => + ( + await good( + "inspect_analysis_view", + { source: { kind: "retained-evidence", evidence_id: id }, view }, + phase, + ) + ).normalized_result; +const contexts = new Map(), + profiles = new Map(), + expectedParents = new Map(); +const readContext = async (summary, phase) => { + const ref = summary.source_context; + if (contexts.has(ref.digest)) return contexts.get(ref.digest); + const id = summary.parent_evidence_id; + const facet = async (name) => { + let offset = 0, + rows = []; + while (true) { + const result = await page( + id, + { kind: "native-context", facet: name, offset, limit: 512 }, + `${phase}:context:${name}`, + ); + assert.equal(result.source_context.digest, ref.digest); + if (result.items.rows === null) return null; + for (const row of result.items.rows) { + assert.equal(row.source_index, rows.length); + rows.push( + name === "limitations" || name === "result-limitations" + ? row.text + : row.record, + ); + } + if (result.coverage.exhausted) return rows; + assert.ok(result.coverage.next_offset > offset); + offset = result.coverage.next_offset; + } + }; + const overview = (await facet("overview"))[0]; + const profileId = overview.analysis_profile_digest; + if (!profiles.has(profileId)) + profiles.set(profileId, (await facet("analysis-profile"))[0]); + const { analysis_profile_digest: _digest, ...metadata } = overview; + const context = { + ...metadata, + analysis_profile: profiles.get(profileId), + limitations: await facet("limitations"), + result_limitations: await facet("result-limitations"), + }; + assert.equal(context.limitations.length, ref.evidence_limitation_count); + assert.equal( + context.result_limitations?.length ?? null, + ref.result_limitation_count, + ); + assert.equal( + digestCanonicalValue( + { ...context, subject: evidenceSubjectIdentity(context.subject) }, + "Native source context", + ), + ref.digest, + ); + contexts.set(ref.digest, context); + return context; +}; +const decodeGraphRow = (row, messages) => { + if (row.status === "empty-level") return null; + const { level: _level, source_index: _index, ...node } = row; + if (row.status === "error") return node; + const { limitation_ids, relationship_coverage: coverage, ...rest } = node; + const { + diagnostic_ids, + limitation_ids: coverageIds, + reason_id, + ...coverageRest + } = coverage; + return { + ...rest, + ...(limitation_ids === undefined + ? {} + : { limitations: limitation_ids.map((id) => messages[id]) }), + relationship_coverage: { + ...coverageRest, + ...(diagnostic_ids === undefined + ? {} + : { diagnostics: diagnostic_ids.map((id) => messages[id]) }), + ...(coverageIds === undefined + ? {} + : { limitations: coverageIds.map((id) => messages[id]) }), + ...(reason_id === undefined ? {} : { reason: messages[reason_id] }), + }, + }; +}; +const observation = async (name, args, phase) => { + const evidence = await good( + name, + { ...args, ...(values.mode === "summary" ? { detail: "summary" } : {}) }, + phase, + ); + if (values.mode === "complete") { + expectedParents.set(evidence.evidence_id, { + result: evidence.normalized_result, + }); + return evidence; + } + const summary = evidence.normalized_result, + id = summary.parent_evidence_id; + const context = await readContext(summary, phase); + let selected = summary, + received = 0; + const rows = [], + graph = {}; + while (true) { + for (const row of selected.items.rows) { + if (name === "get_call_graph") { + const nodes = (graph[row.level] ??= []); + const node = decodeGraphRow(row, selected.items.messages); + if (node !== null) { + assert.equal(row.source_index, nodes.length); + nodes.push(node); + } + } else { + assert.equal(row.source_index, rows.length); + rows.push( + selected.items.type === "resolutions" ? row.record : row.address, + ); + } + received++; + } + if (selected.coverage.exhausted) break; + assert.equal(selected.coverage.next_offset, received); + selected = await page( + id, + { kind: "native-observation", offset: received, limit: 512 }, + `${phase}:facts-page`, + ); + assert.equal(selected.source_context.digest, summary.source_context.digest); + } + assert.equal(received, summary.summary.observations); + const result = + name === "get_call_graph" + ? { graph, limitations: context.result_limitations } + : name === "resolve_containing_procedure" || name === "procedure_address" + ? rows[0] + : rows; + expectedParents.set(id, { result, context }); + return { evidence_id: id, normalized_result: result }; +}; +let opened = false; +const started = performance.now(); +try { + ({ parseEvidence, evidenceSubjectIdentity } = await load( + "dist/domain/evidence.js", + )); + ({ digestCanonicalValue } = await load("dist/domain/canonicalDigest.js")); + report.revision = (await git(["rev-parse", "HEAD"])).trim(); + report.dirty = (await git(["status", "--porcelain"])).length > 0; + report.sha256 = hash(await readFile(values.target)); + account = await mkdtemp(join(tmpdir(), "rea-native-observations-")); + transport = new StdioClientTransport({ + command: process.execPath, + args: [join(values.runtime, "scripts/rea.mjs"), "mcp"], + cwd: values.runtime, + env: { + HOME: account, + PATH: process.env.PATH, + NODE_OPTIONS: "--max-old-space-size=3072", + GHIDRA_INSTALL_DIR: values.ghidra, + JAVA_HOME: values.java, + GHIDRA_HEADLESS_MAXMEM: "1536M", + REA_GHIDRA_STARTUP_TIMEOUT_MS: "600000", + REA_MCP_INPUT_SCHEMA_PROFILE: "compact", + REA_LOG_LEVEL: "silent", + }, + stderr: "pipe", + }); + serverLog = createWriteStream(join(values.output, "server.log")); + serverLogFinished = finished(serverLog); + serverLogFinished.catch(() => {}); + transport.stderr?.pipe(serverLog); + client = new Client({ name: "native-observation-views", version: "1" }); + call = createRetainedQueryCaller(client, (receipt, event) => { + report.calls.push(receipt); + transcript.push(event); + }); + await client.connect(transport); + const catalog = await client.listTools(); + assert.equal(catalog.nextCursor, undefined); + await writeFile(join(values.output, "catalog.json"), JSON.stringify(catalog)); + const definitions = new Map(catalog.tools.map((tool) => [tool.name, tool])); + const original = client.callTool.bind(client); + client.callTool = (input, options) => + original(input, { + ...options, + timeout: 600000, + toolDefinition: definitions.get(input.name), + }); + const open = await call( + "open_binary", + { path: values.target, provider_id: "ghidra" }, + "open", + ); + assert.notEqual(open.isError, true, JSON.stringify(open)); + opened = true; + let seeds; + for (const phase of ["cold", "warm"]) { + const start = performance.now(); + const inventory = await good("list_procedures", {}, `${phase}:inventory`); + const rows = inventory.normalized_result; + assert.ok(Array.isArray(rows)); + seeds ??= rows + .filter((row) => !row.procedure?.external && !row.procedure?.thunk) + .slice(0, 21) + .map((row) => row.address); + assert.equal(seeds.length, 21); + const ids = [], + facts = []; + let mostCallers = { address: seeds[0], count: -1 }, + mostCallees = { address: seeds[0], count: -1 }; + for (const address of seeds) { + for (const [name, args] of [ + ["procedure_callees", { procedure: address }], + ["procedure_callers", { procedure: address }], + ["resolve_containing_procedure", { address }], + ]) { + const evidence = await observation(name, args, `${phase}:${name}`); + ids.push(evidence.evidence_id); + facts.push(evidence.normalized_result); + if ( + name === "procedure_callees" && + evidence.normalized_result.length > mostCallees.count + ) + mostCallees = { address, count: evidence.normalized_result.length }; + if ( + name === "procedure_callers" && + evidence.normalized_result.length > mostCallers.count + ) + mostCallers = { address, count: evidence.normalized_result.length }; + } + } + for (const direction of ["forward", "backward"]) { + const evidence = await observation( + "get_call_graph", + { + address: + direction === "forward" ? mostCallees.address : mostCallers.address, + direction, + }, + `${phase}:graph:${direction}`, + ); + ids.push(evidence.evidence_id); + facts.push(evidence.normalized_result); + } + const missing = await call( + "procedure_callers", + { + procedure: "rea_unknown_symbol_6426", + ...(values.mode === "summary" ? { detail: "summary" } : {}), + }, + `${phase}:missing`, + ); + assert.equal(missing.isError, true); + const error = JSON.parse( + missing.content.find((item) => item.type === "text").text, + ).error; + assert.equal(error.code, "invalid_request"); + assert.equal(error.retryable, false); + assert.equal(error.details.diagnostics.diagnostic_scope, "request"); + assert.equal(error.details.diagnostics.target_sha256, report.sha256); + const run = { + phase, + inventory_count: rows.length, + seeds, + mostCallers, + mostCallees, + success_evidence_ids: ids, + facts_sha256: digestCanonicalValue(facts, "Investigation facts"), + elapsed_ms: performance.now() - start, + }; + await writeFile( + join(values.output, `${phase}-facts.json`), + JSON.stringify(facts), + ); + if (values.control) { + assert.deepEqual( + facts, + JSON.parse( + await readFile(join(values.control, `${phase}-facts.json`), "utf8"), + ), + ); + const control = JSON.parse( + await readFile(join(values.control, "report.json"), "utf8"), + ); + assert.deepEqual( + ids, + control.runs.find((item) => item.phase === phase).success_evidence_ids, + ); + } + report.runs.push(run); + } + const exported = await call( + "export_evidence_bundle", + { path: join(values.output, "complete-evidence.json") }, + "export", + ); + assert.notEqual(exported.isError, true, JSON.stringify(exported)); + const bundle = JSON.parse( + await readFile(join(values.output, "complete-evidence.json"), "utf8"), + ); + for (const [id, expected] of expectedParents) { + const evidence = parseEvidence( + bundle.records.find((record) => record.evidence_id === id), + ); + assert.equal(evidence.subject.digest.sha256, report.sha256); + assert.deepEqual(evidence.normalized_result, expected.result); + if (expected.context) + assert.deepEqual(expected.context, { + subject: evidence.subject, + provider: evidence.provider, + analysis_profile: evidence.analysis_profile, + authority: evidence.authority, + environment: evidence.environment, + limitations: evidence.limitations, + result_limitations: + evidence.operation === "get_call_graph" + ? evidence.normalized_result.limitations + : null, + }); + } + report.verified_parent_count = expectedParents.size; + report.materialized_contexts = contexts.size; + report.materialized_profiles = profiles.size; + const close = await call("close_binary", {}, "close"); + assert.notEqual(close.isError, true, JSON.stringify(close)); + opened = false; + assert.equal(hash(await readFile(values.target)), report.sha256); + report.status = "passed"; +} catch (cause) { + report.status = "failed"; + report.failure = String(cause?.stack ?? cause); + process.exitCode = 1; +} finally { + if (opened) + try { + const close = await call("close_binary", {}, "cleanup"); + assert.notEqual(close.isError, true); + } catch (cause) { + report.close_failure = String(cause); + process.exitCode = 1; + } + report.elapsed_ms = performance.now() - started; + if ( + !(await finalizeRetainedQueryReceipt({ + report, + client, + transport, + account, + serverLog, + serverLogFinished, + output: values.output, + transcript, + })) + ) + process.exitCode = 1; +} diff --git a/experiments/analysis-efficiency/retained-query-finalization.mjs b/experiments/analysis-efficiency/retained-query-finalization.mjs index 205ef30223..aa9b724415 100644 --- a/experiments/analysis-efficiency/retained-query-finalization.mjs +++ b/experiments/analysis-efficiency/retained-query-finalization.mjs @@ -45,18 +45,28 @@ export const finalizeRetainedQueryReceipt = async ({ }) => { report.cleanup_failures = []; await Promise.all([ - attempt(report.cleanup_failures, "client", () => client.close()), - attempt(report.cleanup_failures, "transport", () => transport.close()), + ...(client === undefined + ? [] + : [attempt(report.cleanup_failures, "client", () => client.close())]), + ...(transport === undefined + ? [] + : [ + attempt(report.cleanup_failures, "transport", () => + transport.close(), + ), + ]), ]); - await attempt(report.cleanup_failures, "server-log", async () => { - if (!serverLog.writableEnded) serverLog.end(); - await serverLogFinished; - }); - if (report.cleanup_failures.length === 0) + if (serverLog !== undefined) + await attempt(report.cleanup_failures, "server-log", async () => { + if (!serverLog.writableEnded) serverLog.end(); + await serverLogFinished; + }); + if (account !== undefined && report.cleanup_failures.length === 0) await attempt(report.cleanup_failures, "workspace", () => rm(account, { recursive: true }), ); - if (report.cleanup_failures.length > 0) report.retained_workspace = account; + if (account !== undefined && report.cleanup_failures.length > 0) + report.retained_workspace = account; return publishRetainedQueryReceipt({ report, output, transcript, stdout }); }; diff --git a/experiments/analysis-efficiency/test_retained_queries.py b/experiments/analysis-efficiency/test_retained_queries.py index f125b436dd..7280b7c8d2 100644 --- a/experiments/analysis-efficiency/test_retained_queries.py +++ b/experiments/analysis-efficiency/test_retained_queries.py @@ -180,4 +180,24 @@ assert.match(report.cleanup_failures[0],/client:.*close failed/); assert.equal(report.retained_workspace,account);assert.equal(serverLog.writableEnded,true); assert.equal((await stat(account)).isDirectory(),true); assert.deepEqual(await saved(),report);assert.deepEqual(JSON.parse(chunks.join('')),report); +''') + + def test_setup_failure_without_acquired_resources_still_publishes_receipts(self): + self.run_finalization(''' +assert.equal(await finalizeRetainedQueryReceipt({report,output,stdout,transcript:[]}),true); +assert.deepEqual(report.cleanup_failures,[]); +assert.deepEqual((await saved()).failure,originalFailure); +assert.deepEqual(JSON.parse(await readFile(join(output,'transcript.json'),'utf8')),[]); +assert.ok((await stat(account)).isDirectory()); +''') + + def test_partial_setup_closes_acquired_transport_and_releases_its_workspace(self): + self.run_finalization(''' +let closed=0; +assert.equal(await finalizeRetainedQueryReceipt({report,output,stdout,account, + transport:{close:async()=>{closed++;}}}),true); +assert.equal(closed,1); +await assert.rejects(stat(account),{code:'ENOENT'}); +assert.deepEqual((await saved()).failure,originalFailure); +assert.deepEqual(report.cleanup_failures,[]); ''') diff --git a/src/application/EnhancedTools.ts b/src/application/EnhancedTools.ts index 9956403f1c..1c56bb7ff1 100644 --- a/src/application/EnhancedTools.ts +++ b/src/application/EnhancedTools.ts @@ -1,3 +1,4 @@ +import type { NativeCallGraph } from "../domain/native/nativeCallGraph.js"; import { nativeProcedureInputSchema } from "../domain/native/nativeProcedureInput.js"; import { CALL_RELATIONSHIP_TRAVERSALS } from "./CallRelationshipTraversal.js"; import { z } from "zod"; @@ -608,7 +609,7 @@ export class EnhancedTools { address: current.address, status: "error", error: projectAnalysisError(result.error), - }); + } satisfies NativeCallGraph["graph"][string][number]); continue; } const related = parseRelatedAddresses(result.value.result, relation); @@ -617,7 +618,7 @@ export class EnhancedTools { address: current.address, status: "error", error: projectAnalysisError(related.error), - }); + } satisfies NativeCallGraph["graph"][string][number]); continue; } for (const limitation of result.value.limitations) @@ -654,7 +655,7 @@ export class EnhancedTools { ...(nodeLimitations.length === 0 ? {} : { limitations: nodeLimitations }), - }); + } satisfies NativeCallGraph["graph"][string][number]); for (const address of related.value) { if (!discovered.has(address)) { discovered.add(address); diff --git a/src/application/analysisView/AnalysisViewService.ts b/src/application/analysisView/AnalysisViewService.ts index 0867b8051a..531e1be5af 100644 --- a/src/application/analysisView/AnalysisViewService.ts +++ b/src/application/analysisView/AnalysisViewService.ts @@ -1,3 +1,4 @@ +import { isNativeObservationOperation } from "../../domain/native/nativeObservation.js"; import { setImmediate } from "node:timers/promises"; import { dispatchQueuedInterrupts } from "../../process/queuedInterrupts.js"; import { readImmutableEvidenceBundle } from "../EvidenceBundleFiles.js"; @@ -202,7 +203,9 @@ const projectResolvedAnalysisView = ( evidenceId: parent.evidence_id, operation: parent.operation, provider: parent.provider, - analysisProfileDigest: parent.analysis_profile?.digest ?? null, + analysisProfile: parent.analysis_profile, + authority: parent.authority, + environment: parent.environment, normalizedResult: immutableJsonSnapshotMember(parent, "normalized_result") ?? parent.normalized_result, @@ -372,6 +375,11 @@ const createAnalysisViewEvidence = ( result.parent_operation === "analyze_function") ? result : undefined; + const nativeObservationView = + result.kind === "native-observation" || + result.kind === "native-context" || + (result.kind === "summary" && + isNativeObservationOperation(result.parent_operation)); return createEvidence( target, ANALYSIS_VIEW_PROVIDER, @@ -396,7 +404,7 @@ const createAnalysisViewEvidence = ( : "derived", authority: parent.authority, limitations: result.limitations, - ...(nativeFunctionView !== undefined && + ...((nativeFunctionView !== undefined || nativeObservationView) && parent.relationship_coverage !== null ? { relationshipCoverage: parent.relationship_coverage } : {}), diff --git a/src/application/analysisView/NativeObservationViews.test.ts b/src/application/analysisView/NativeObservationViews.test.ts new file mode 100644 index 0000000000..e656b7ddd3 --- /dev/null +++ b/src/application/analysisView/NativeObservationViews.test.ts @@ -0,0 +1,543 @@ +import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; +import { nativeGraphViewItemsSchema } from "../../domain/analysisView/nativeObservationSchemas.js"; +import { expect, it } from "vitest"; +import { + createEvidence, + rebindEvidenceToTargetPath, + type Evidence, +} from "../../domain/evidence.js"; +import { createAnalysisProfile } from "../../domain/analysisProfile.js"; +import { ghidraFunctionIdentity } from "../../domain/ghidraValues.fixture.js"; +import { + nativeCallGraphSchema, + type NativeCallGraph, +} from "../../domain/native/nativeCallGraph.js"; +import { + analysisViewResultSchema, + type AnalysisViewRequest, + type AnalysisViewResult, +} from "../../domain/analysisView/analysisView.js"; +import { inspectAnalysisView } from "./AnalysisViewService.js"; + +const provider = { id: "fixture", name: "Fixture provider", version: "1" }; +const parent = ( + operation: string, + result: unknown, + limitations = ["Source caveat"], + profileValue = 1, +) => + createEvidence( + { + path: "/selected/fixture.elf", + sha256: "a".repeat(64), + format: "elf", + architecture: "x86_64", + }, + provider, + { + operation, + parameters: { procedure: "selected_symbol" }, + result, + limitations, + analysisProfile: createAnalysisProfile(provider, { + profile: profileValue, + }), + ...(operation === "procedure_callees" + ? { + relationshipCoverage: { + status: "partial" as const, + limitations: ["Indirect calls remain unknown"], + limited_directions: ["callees"] as ["callees"], + }, + } + : {}), + }, + { immutable: true }, + ); +const project = async (source: Evidence, view: AnalysisViewRequest) => { + const result = await inspectAnalysisView({ + source: { kind: "inline", evidence: source }, + view, + }); + if (!result.ok) throw result.error; + return { + evidence: result.value, + value: analysisViewResultSchema.parse(result.value.normalized_result), + }; +}; +const contextDigest = (value: AnalysisViewResult): string => { + if (!("source_context" in value)) + throw new Error("Expected native source context"); + return value.source_context.digest; +}; +const graph = (): NativeCallGraph => + nativeCallGraphSchema.parse({ + graph: { + "2": [ + { + address: "ram:0x0010", + status: "ok", + calls: ["ram:0x0020"], + relationship_coverage: { + status: "complete", + diagnostics: ["Shared diagnostic 雪🧭"], + }, + }, + ], + "10": [ + { + address: "ram:0x0010", + status: "ok", + calls: [], + limitations: [], + relationship_coverage: { + status: "unknown", + reason: "Shared caveat", + limited_directions: ["callers"], + diagnostics: ["Shared diagnostic 雪🧭"], + }, + }, + ], + "01": [ + { + address: "ram:0x0010", + status: "ok", + calls: ["ram:0x0020", "ram:0x0020"], + limitations: ["Shared caveat", "Shared caveat"], + relationship_coverage: { + status: "partial", + limitations: ["Shared caveat", "Shared caveat"], + limited_directions: ["callees"], + diagnostics: ["Shared diagnostic 雪🧭"], + }, + }, + ], + }, + limitations: ["Graph caveat", "Graph caveat"], + }); + +it("returns useful caller/callee facts with exact source context identity and partial coverage", async () => { + const source = parent("procedure_callees", [ + "ram:0x0010", + "ram:0x0010", + "ram:0x0020", + ]); + const before = JSON.stringify(source); + const summary = await project(source, { kind: "summary" }); + expect(summary.value).toMatchObject({ + kind: "summary", + parent_evidence_id: source.evidence_id, + source_provider: source.provider, + source_parameters: source.parameters, + source_analysis_profile_digest: source.analysis_profile?.digest, + source_context: { + evidence_limitation_count: 1, + result_limitation_count: null, + }, + summary: { observations: 3 }, + items: { + type: "addresses", + rows: [ + { source_index: 0, address: "ram:0x0010" }, + { source_index: 1, address: "ram:0x0010" }, + { source_index: 2, address: "ram:0x0020" }, + ], + }, + }); + expect(summary.evidence.relationship_coverage).toEqual( + source.relationship_coverage, + ); + const page = await project(source, { + kind: "native-observation", + offset: 1, + limit: 1, + }); + expect(page.value).toMatchObject({ + items: { rows: [{ source_index: 1, address: "ram:0x0010" }] }, + coverage: { total: 3, next_offset: 2, exhausted: false }, + }); + expect(page.value).toHaveProperty( + "source_context.digest", + contextDigest(summary.value), + ); + expect(JSON.stringify(summary.value)).not.toContain('"analysis_profile":'); + expect(JSON.stringify(source)).toBe(before); +}); + +it("pages complete context fields and changes its digest when source profile or caveats change", async () => { + const source = parent("procedure_callers", []); + const overview = await project(source, { + kind: "native-context", + facet: "overview", + offset: 0, + limit: 1, + }); + expect(overview.value).toMatchObject({ + items: { + type: "overview", + rows: [ + { + record: { + subject: source.subject, + provider, + analysis_profile_digest: source.analysis_profile?.digest, + }, + }, + ], + }, + }); + const profile = await project(source, { + kind: "native-context", + facet: "analysis-profile", + offset: 0, + limit: 1, + }); + expect(profile.value).toMatchObject({ + items: { rows: [{ source_index: 0, record: source.analysis_profile }] }, + }); + const caveat = await project(source, { + kind: "native-context", + facet: "limitations", + offset: 0, + limit: 1, + }); + expect(caveat.value).toMatchObject({ + items: { rows: [{ source_index: 0, text: "Source caveat" }] }, + }); + for (const changed of [ + parent("procedure_callers", [], ["Changed caveat"]), + parent("procedure_callers", [], ["Source caveat"], 2), + ]) { + const changedView = await project(changed, { kind: "summary" }); + expect(changedView.value).not.toHaveProperty( + "source_context.digest", + contextDigest(overview.value), + ); + } +}); + +it("keeps absent profile and inapplicable result caveats distinct from observed empty data", async () => { + const source = createEvidence(undefined, provider, { + operation: "procedure_address", + parameters: {}, + result: "0x0000", + }); + expect( + ( + await project(source, { + kind: "native-context", + facet: "analysis-profile", + offset: 0, + limit: 64, + }) + ).value, + ).toMatchObject({ items: { rows: [{ source_index: 0, record: null }] } }); + expect( + ( + await project(source, { + kind: "native-context", + facet: "result-limitations", + offset: 0, + limit: 64, + }) + ).value, + ).toMatchObject({ + source_context: { result_limitation_count: null }, + items: { rows: null }, + unknowns: expect.arrayContaining([ + expect.stringContaining("not applicable"), + ]), + }); + expect( + ( + await project(parent("procedure_callers", []), { + kind: "native-observation", + offset: 0, + limit: 64, + }) + ).value, + ).toMatchObject({ + summary: { observations: 0 }, + items: { rows: [] }, + coverage: { status: "empty", exhausted: true }, + }); +}); + +it.each([true, false])( + "preserves the complete containing-procedure outcome (found=%s)", + async (found) => { + const report = found + ? { + query_address: "0x401001", + found: true, + procedure: ghidraFunctionIdentity(), + } + : { + query_address: "0x9999", + found: false, + procedure: null, + reason: "not_in_procedure", + }; + const result = await project( + parent("resolve_containing_procedure", report), + { kind: "summary" }, + ); + expect(result.value).toMatchObject({ + summary: { observations: 1 }, + items: { rows: [{ source_index: 0, record: report }] }, + }); + }, +); + +it("reconstructs every graph field from dictionary pages without changing nulls, duplicate messages or level identities", async () => { + const report = graph(); + const source = parent("get_call_graph", report); + const decoded: NativeCallGraph["graph"] = {}; + for (let offset = 0; offset < 3; offset++) { + const { value } = await project(source, { + kind: "native-observation", + offset, + limit: 1, + }); + if ( + value.kind !== "native-observation" || + value.parent_operation !== "get_call_graph" + ) + throw new Error("Expected graph page"); + const { rows, messages } = value.items; + expect(rows).toHaveLength(1); + const row = rows[0]!; + expect(row.source_index).toBe(0); + expect(row.level).toBe(["01", "10", "2"][offset]); + expect(value.coverage).toMatchObject({ total: 3, exhausted: offset === 2 }); + if (row.status !== "ok") throw new Error("Expected recorded node"); + const coverage = row.relationship_coverage; + const diagnostics = + coverage.diagnostic_ids === undefined + ? {} + : { diagnostics: coverage.diagnostic_ids.map((i) => messages[i]!) }; + const directions = + "limited_directions" in coverage && + coverage.limited_directions !== undefined + ? { limited_directions: coverage.limited_directions } + : {}; + const originalCoverage = + coverage.status === "complete" + ? { status: "complete" as const, ...diagnostics } + : coverage.status === "partial" + ? { + status: "partial" as const, + limitations: coverage.limitation_ids.map((i) => messages[i]!), + ...directions, + ...diagnostics, + } + : { + status: "unknown" as const, + reason: messages[coverage.reason_id]!, + ...directions, + ...diagnostics, + }; + decoded[row.level] = [ + { + address: row.address, + status: row.status, + calls: row.calls, + relationship_coverage: originalCoverage, + ...(row.limitation_ids === undefined + ? {} + : { limitations: row.limitation_ids.map((i) => messages[i]!) }), + }, + ]; + expect(messages).toHaveLength(coverage.status === "complete" ? 1 : 2); + } + expect(decoded).toEqual(report.graph); + const caveats = await project(source, { + kind: "native-context", + facet: "result-limitations", + offset: 1, + limit: 1, + }); + expect(caveats.value).toMatchObject({ + source_context: { result_limitation_count: 2 }, + items: { rows: [{ source_index: 1, text: "Graph caveat" }] }, + }); +}); + +it("rejects malformed unselected source facts and preserves exact source paths", async () => { + const malformed = { + ...graph(), + graph: { + "0": [ + { + address: "0x10", + status: "ok", + calls: [], + relationship_coverage: { status: "complete" }, + }, + { + address: "0x20", + status: "ok", + calls: [42], + relationship_coverage: { status: "complete" }, + }, + ], + }, + }; + const result = await inspectAnalysisView({ + source: { kind: "inline", evidence: parent("get_call_graph", malformed) }, + view: { kind: "native-observation", offset: 0, limit: 1 }, + }); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisInputError", + issues: expect.arrayContaining([ + expect.objectContaining({ + path: ["source", "normalized_result", "graph", "0", 1, "calls", 0], + }), + ]), + }, + }); +}); + +it("retains failed graph nodes and rejects broken message references", async () => { + const source = graph(); + const failure = { + address: "ram:0x0010", + status: "error" as const, + error: projectAnalysisError( + new AnalysisCapabilityUnavailableError( + "fixture", + "procedure_callees", + "No call references", + ), + ), + }; + source.graph.failure = [failure]; + const result = await project(parent("get_call_graph", source), { + kind: "native-observation", + offset: 3, + limit: 1, + }); + expect(result.value).toMatchObject({ + items: { + type: "graph-nodes", + messages: [], + rows: [{ level: "failure", source_index: 0, ...failure }], + }, + coverage: { total: 4, exhausted: true }, + }); + const normal = await project(parent("get_call_graph", graph()), { + kind: "native-observation", + offset: 0, + limit: 1, + }); + if ( + normal.value.kind !== "native-observation" || + normal.value.parent_operation !== "get_call_graph" + ) + throw new Error("Expected graph page"); + expect( + nativeGraphViewItemsSchema.safeParse({ + ...normal.value.items, + messages: [], + }).success, + ).toBe(false); +}); + +it("keeps graph row identity stable when source object key insertion order changes", async () => { + const report = graph(); + report.graph.beta = [report.graph["2"]![0]!]; + const first = parent("get_call_graph", report); + const reversed = parent("get_call_graph", { + ...report, + graph: Object.fromEntries(Object.entries(report.graph).reverse()), + }); + expect(Object.keys(report.graph)).not.toEqual( + Object.keys(Object.fromEntries(Object.entries(report.graph).reverse())), + ); + expect(first.evidence_id).toBe(reversed.evidence_id); + expect( + await project(first, { kind: "native-observation", offset: 0, limit: 64 }), + ).toEqual( + await project(reversed, { + kind: "native-observation", + offset: 0, + limit: 64, + }), + ); +}); + +it("keeps context identity across authenticated target path aliases while preserving selected paths", async () => { + const original = parent("procedure_callers", []); + const rebound = rebindEvidenceToTargetPath(original, { + path: "/other/alias.elf", + sha256: "a".repeat(64), + format: "elf", + architecture: "x86_64", + }); + expect(rebound.evidence_id).toBe(original.evidence_id); + const view = { + kind: "native-context" as const, + facet: "overview" as const, + offset: 0, + limit: 1, + }; + const first = await project(original, view); + const second = await project(rebound, view); + expect(contextDigest(second.value)).toBe(contextDigest(first.value)); + expect(second.value).toMatchObject({ + items: { + rows: [{ record: { subject: { local_path: "/other/alias.elf" } } }], + }, + }); + expect(second.evidence.subject?.local_path).toBe("/other/alias.elf"); + expect(second.evidence.evidence_id).not.toBe(first.evidence.evidence_id); +}); + +it("pages recorded empty graph levels without inventing nodes or dropping their identity", async () => { + const report = { graph: { absent: [], empty: [] }, limitations: [] }; + const result = await project(parent("get_call_graph", report), { + kind: "native-observation", + offset: 1, + limit: 1, + }); + expect(result.value).toMatchObject({ + items: { + messages: [], + rows: [{ level: "empty", source_index: null, status: "empty-level" }], + }, + summary: { observations: 2 }, + coverage: { total: 2, exhausted: true }, + }); +}); + +it.each([ + { status: "partial" as const, limitations: ["Unresolved indirect edges"] }, + { status: "unknown" as const, reason: "Provider did not establish coverage" }, +])( + "keeps $status relationship coverage on every context facet", + async (coverage) => { + const source = createEvidence(undefined, provider, { + operation: "procedure_callees", + parameters: {}, + result: [], + relationshipCoverage: coverage, + }); + for (const facet of [ + "overview", + "analysis-profile", + "limitations", + "result-limitations", + ] as const) { + const result = await project(source, { + kind: "native-context", + facet, + offset: 0, + limit: 64, + }); + expect(result.evidence.relationship_coverage).toEqual(coverage); + } + }, +); diff --git a/src/contracts/enhancedToolContracts.ts b/src/contracts/enhancedToolContracts.ts index e1b19e7ff8..1bc48dab46 100644 --- a/src/contracts/enhancedToolContracts.ts +++ b/src/contracts/enhancedToolContracts.ts @@ -1,3 +1,4 @@ +import { analysisDetailSchema } from "./analysisDetail.js"; import { z } from "zod"; import { enhancedInputSchemas } from "./enhancedInputs.js"; @@ -53,8 +54,10 @@ export const ENHANCED_TOOL_CONTRACTS = [ ), enhanced( "get_call_graph", - "Resolve one symbol or address to its canonical procedure entry, then traverse the bound provider's caller or callee relationships until the reachable graph is exhausted. Every node uses a canonical address and has an ok/error status; failures use safe typed projections. Unresolved indirect calls may be missing and results are not a whole-program CFG.", - enhancedInputSchemas.get_call_graph, + "Resolve one symbol or address to its canonical procedure entry, then traverse the bound provider's caller or callee relationships until the reachable graph is exhausted. Every node uses a canonical address and has an ok/error status; failures use safe typed projections. Unresolved indirect calls may be missing and results are not a whole-program CFG. Select detail summary for a bounded first node page with shared message references; native-observation pages and native-context fields retain complete source access.", + enhancedInputSchemas.get_call_graph.extend({ + detail: analysisDetailSchema, + }), ), enhanced( "analyze_swift_types", diff --git a/src/contracts/officialToolContracts.ts b/src/contracts/officialToolContracts.ts index b92973306c..ba9b8ab30f 100644 --- a/src/contracts/officialToolContracts.ts +++ b/src/contracts/officialToolContracts.ts @@ -152,8 +152,8 @@ export const OFFICIAL_TOOL_CONTRACTS = [ ), official( "procedure_address", - "Resolve an unambiguous procedure symbol name or provider-normalized address to its canonical entry address. External address spaces remain explicit.", - z.object({ procedure, document }), + "Resolve an unambiguous procedure symbol name or provider-normalized address to its canonical entry address. External address spaces remain explicit. Select detail summary for selected facts and a context digest; native-observation and native-context views retain complete source access.", + z.object({ procedure, document, detail: analysisDetailSchema }), ), official( "procedure_assembly", @@ -162,13 +162,13 @@ export const OFFICIAL_TOOL_CONTRACTS = [ ), official( "procedure_callees", - "Return the provider's resolved direct callees for one procedure identified by symbol or address. Unresolved indirect calls may be absent; use analyze_function and typed references to preserve available edge uncertainty. Ghidra terminal-call assumptions that may cut the analyzed body are reported in Evidence limitations.", - z.object({ procedure, document }), + "Return the provider's resolved direct callees for one procedure identified by symbol or address. Unresolved indirect calls may be absent; use analyze_function and typed references to preserve available edge uncertainty. Ghidra terminal-call assumptions that may cut the analyzed body are reported in Evidence limitations. Select detail summary for selected facts and a context digest; native-observation and native-context views retain complete source access.", + z.object({ procedure, document, detail: analysisDetailSchema }), ), official( "procedure_callers", - "Return the provider's resolved direct callers for one procedure identified by symbol or address. Results reflect completed static analysis and may omit unresolved indirect references. Ghidra terminal-call assumptions that may cut the analyzed body are reported in Evidence limitations.", - z.object({ procedure, document }), + "Return the provider's resolved direct callers for one procedure identified by symbol or address. Results reflect completed static analysis and may omit unresolved indirect references. Ghidra terminal-call assumptions that may cut the analyzed body are reported in Evidence limitations. Select detail summary for selected facts and a context digest; native-observation and native-context views retain complete source access.", + z.object({ procedure, document, detail: analysisDetailSchema }), ), official( "procedure_info", @@ -199,8 +199,8 @@ export const OFFICIAL_TOOL_CONTRACTS = [ ), official( "resolve_containing_procedure", - "Resolve an arbitrary address, including an interior instruction or exact external entry, to its provider-analyzed containing procedure. A negative result distinguishes outside segments from not in a procedure and is never guessed from nearby symbols.", - z.object({ address, document }), + "Resolve an arbitrary address, including an interior instruction or exact external entry, to its provider-analyzed containing procedure. A negative result distinguishes outside segments from not in a procedure and is never guessed from nearby symbols. Select detail summary for selected facts and a context digest; native-observation and native-context views retain complete source access.", + z.object({ address, document, detail: analysisDetailSchema }), ), official( "search_procedures", diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index c8d0dfb129..30149c2174 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -1,3 +1,8 @@ +import { + nativeAddressListSchema, + nativeObservationSourceSchemas, +} from "../domain/native/nativeObservation.js"; +import { nativeObservationSummaryViewResultSchemas } from "../domain/analysisView/analysisView.js"; import { literalFeatureMatchSchema } from "../domain/native/literalFeatureMatch.js"; import { byteEntropySchema } from "../domain/native/byteEntropy.js"; import { @@ -88,10 +93,8 @@ import { relationshipCoverageSchema } from "../domain/relationshipCoverage.js"; import { nativeDispatchMetadataResultSchema } from "../domain/native/objcSwiftMetadata.js"; import { nativeInvestigationTraceSchema } from "../domain/native/nativeInvestigationGraph.js"; import { - addressList, addressedEntry, functionDossierOutput, - graphNode, lifecycleResultOf, nullableText, procedureInfoOutput, @@ -168,10 +171,19 @@ export const officialOutputSchemas: Readonly> = { ), next_address: evidenceResultOf(z.string()), prev_address: evidenceResultOf(z.string()), - procedure_address: evidenceResultOf(z.string()), + procedure_address: analysisDetailOutputSchemaOf( + nativeObservationSourceSchemas.procedure_address, + nativeObservationSummaryViewResultSchemas.procedure_address, + ), procedure_assembly: evidenceResultOf(z.string()), - procedure_callees: evidenceResultOf(addressList), - procedure_callers: evidenceResultOf(addressList), + procedure_callees: analysisDetailOutputSchemaOf( + nativeObservationSourceSchemas.procedure_callees, + nativeObservationSummaryViewResultSchemas.procedure_callees, + ), + procedure_callers: analysisDetailOutputSchemaOf( + nativeObservationSourceSchemas.procedure_callers, + nativeObservationSummaryViewResultSchemas.procedure_callers, + ), procedure_info: procedureInfoOutput, read_function_instructions: evidenceResultOf(functionInstructionWindowSchema), read_bytes: evidenceResultOf(nativeByteReadSchema), @@ -213,8 +225,9 @@ export const officialOutputSchemas: Readonly> = { }), ), procedure_pseudo_code: evidenceResultOf(nullableText), - resolve_containing_procedure: evidenceResultOf( - containingProcedureResolutionSchema, + resolve_containing_procedure: analysisDetailOutputSchemaOf( + nativeObservationSourceSchemas.resolve_containing_procedure, + nativeObservationSummaryViewResultSchemas.resolve_containing_procedure, ), search_procedures: analysisDetailOutputSchemaOf( nativeInventoryRowsSchemas.names, @@ -235,7 +248,7 @@ export const officialOutputSchemas: Readonly> = { set_comment: evidenceResultOf(z.boolean()), set_inline_comment: evidenceResultOf(z.boolean()), unset_bookmark: evidenceResultOf(z.boolean()), - xrefs: evidenceResultOf(addressList), + xrefs: evidenceResultOf(nativeAddressListSchema), }; const literalTraceOutput = evidenceResultOf( @@ -345,11 +358,9 @@ export const enhancedOutputSchemas: Readonly> = { failed: z.int().min(0), }), ), - get_call_graph: evidenceResultOf( - z.strictObject({ - graph: z.record(z.string(), z.array(graphNode)), - limitations: z.array(z.string()), - }), + get_call_graph: analysisDetailOutputSchemaOf( + nativeObservationSourceSchemas.get_call_graph, + nativeObservationSummaryViewResultSchemas.get_call_graph, ), analyze_swift_types: evidenceResultOf( z.object({ @@ -381,7 +392,7 @@ export const enhancedOutputSchemas: Readonly> = { status: z.literal("resolved"), name: z.string(), address: z.string(), - xrefs: addressList, + xrefs: nativeAddressListSchema, }), z.object({ status: z.literal("unresolved"), diff --git a/src/contracts/toolOutputSchemaPrimitives.ts b/src/contracts/toolOutputSchemaPrimitives.ts index 1c4caf5670..a4374b60cf 100644 --- a/src/contracts/toolOutputSchemaPrimitives.ts +++ b/src/contracts/toolOutputSchemaPrimitives.ts @@ -18,7 +18,6 @@ import { PROVIDER_RETRY_ACTIONS, } from "../domain/providerOperationHealth.js"; import { analysisErrorProjectionSchema } from "./errorSchemas.js"; -import { relationshipCoverageSchema } from "../domain/relationshipCoverage.js"; import { ZIP_PACKAGE_FORMATS } from "../domain/zipPackageFormat.js"; import { objcClassImportIdentitySchema } from "../domain/native/objcSwiftMetadata.js"; import { BINARY_SESSION_SUMMARY_OMITTED_FIELDS } from "./sessionStatusContract.js"; @@ -659,7 +658,6 @@ export const sessionProvider = z.discriminatedUnion("detail", [ ]); export const nullableText = z.string().nullable(); -export const addressList = z.array(z.string()); export const addressedEntry = z.object({ address: z.string(), name: z.string(), @@ -729,19 +727,4 @@ export const objcClassDiscoveryOutput = evidenceResultOf( }), ); -export const graphNode = z.discriminatedUnion("status", [ - z.object({ - address: z.string(), - status: z.literal("ok"), - calls: z.array(z.string()), - relationship_coverage: relationshipCoverageSchema, - limitations: z.array(z.string()).optional(), - }), - z.object({ - address: z.string(), - status: z.literal("error"), - error: analysisErrorProjectionSchema, - }), -]); - export const functionDossierOutput = evidenceResultOf(functionDossierSchema); diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index f43bcd0a61..84e9ef3c85 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -1,3 +1,16 @@ +import { + nativeObservationViewRequestSchema, + nativeContextViewRequestSchema, + nativeObservationItemsSchemas, + nativeContextItemsSchemas, + nativeSourceContextReferenceSchema, + nativeObservationOperationSchema, +} from "./nativeObservationSchemas.js"; +import { + NATIVE_OBSERVATION_OPERATIONS, + type NativeObservationOperation, +} from "../native/nativeObservation.js"; +import { projectNativeObservationView } from "./nativeObservationView.js"; import { devicetreeViewRequestSchema, devicetreeViewItemsSchema, @@ -375,6 +388,8 @@ const nativeViewSchema = z.strictObject({ /** Caller-selected projection of already completed analysis Evidence. */ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [ + nativeObservationViewRequestSchema, + nativeContextViewRequestSchema, devicetreeViewRequestSchema, erlangBeamViewRequestSchema, pythonPycViewRequestSchema, @@ -534,6 +549,7 @@ const viewResultBase = { "inspect_model_artifact", "inspect_gpu_code_objects", ...NATIVE_INVENTORY_OPERATIONS, + ...NATIVE_OBSERVATION_OPERATIONS, "match_native_functions", ]), parent_digest: digestSchema, @@ -550,6 +566,79 @@ const summaryViewResultBase = { ...viewResultBase, } as const; +const nativeObservationSummaryFor = < + Operation extends NativeObservationOperation, +>( + operation: Operation, +) => + z.strictObject({ + ...summaryViewResultBase, + ...nativeViewSourceContext, + artifact: artifactSchema.nullable(), + parent_operation: z.literal(operation), + source_context: nativeSourceContextReferenceSchema, + summary: z.strictObject({ observations: z.int().nonnegative() }), + items: nativeObservationItemsSchemas[operation], + }); +export const nativeObservationSummaryViewResultSchemas = { + procedure_address: nativeObservationSummaryFor("procedure_address"), + procedure_callees: nativeObservationSummaryFor("procedure_callees"), + procedure_callers: nativeObservationSummaryFor("procedure_callers"), + resolve_containing_procedure: nativeObservationSummaryFor( + "resolve_containing_procedure", + ), + get_call_graph: nativeObservationSummaryFor("get_call_graph"), +}; +const nativeObservationSummaryViewResultSchema = z.discriminatedUnion( + "parent_operation", + [ + nativeObservationSummaryViewResultSchemas.procedure_address, + nativeObservationSummaryViewResultSchemas.procedure_callees, + nativeObservationSummaryViewResultSchemas.procedure_callers, + nativeObservationSummaryViewResultSchemas.resolve_containing_procedure, + nativeObservationSummaryViewResultSchemas.get_call_graph, + ], +); +const nativeObservationPageFor = ( + operation: Operation, +) => + nativeObservationSummaryFor(operation) + .omit({ kind: true, view: true }) + .extend({ + kind: z.literal("native-observation"), + view: nativeObservationViewRequestSchema, + }); +const nativeObservationPageViewResultSchema = z.discriminatedUnion( + "parent_operation", + [ + nativeObservationPageFor("procedure_address"), + nativeObservationPageFor("procedure_callees"), + nativeObservationPageFor("procedure_callers"), + nativeObservationPageFor("resolve_containing_procedure"), + nativeObservationPageFor("get_call_graph"), + ], +); +const nativeContextFor = ( + facet: Facet, +) => + z.strictObject({ + ...viewResultBase, + ...nativeViewSourceContext, + kind: z.literal("native-context"), + artifact: artifactSchema.nullable(), + parent_operation: nativeObservationOperationSchema, + source_context: nativeSourceContextReferenceSchema, + facet: z.literal(facet), + view: nativeContextViewRequestSchema.extend({ facet: z.literal(facet) }), + items: nativeContextItemsSchemas[facet], + }); +const nativeContextViewResultSchema = z.discriminatedUnion("facet", [ + nativeContextFor("overview"), + nativeContextFor("analysis-profile"), + nativeContextFor("limitations"), + nativeContextFor("result-limitations"), +]); + export const byteEntropySummaryViewResultSchema = z.strictObject({ ...summaryViewResultBase, summary: byteEntropySummarySchema, @@ -980,6 +1069,8 @@ export const sourceToBundlePageViewResultSchema = z.strictObject({ /** Projected facts for one selected view of retained analysis Evidence. */ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ + nativeObservationPageViewResultSchema, + nativeContextViewResultSchema, javaScriptRoutesViewResultSchema, sourceToBundlePageViewResultSchema, z.strictObject({ @@ -1033,6 +1124,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ view: environmentCoverageViewRequestSchema, }), z.discriminatedUnion("parent_operation", [ + nativeObservationSummaryViewResultSchema, erlangBeamSummaryViewResultSchema, devicetreeSummaryViewResultSchema, pythonPycSummaryViewResultSchema, @@ -1286,7 +1378,9 @@ export interface AnalysisViewParent { readonly operation: string; readonly normalizedResult: unknown; readonly provider?: Evidence["provider"]; - readonly analysisProfileDigest?: string | null; + readonly analysisProfile?: Evidence["analysis_profile"]; + readonly authority?: Evidence["authority"]; + readonly environment?: Evidence["environment"]; /** The owner proved normalizedResult belongs to a sealed, deep-frozen Evidence snapshot. */ readonly sealedResult?: boolean; /** Preserve the authenticated source identity, including explicit absence. */ @@ -1362,6 +1456,9 @@ const parentArtifactPath = (parent: AnalysisViewParent): string => { /** Named source owners for views that need no extra decoder or accumulator. */ const retainedViewProjectors = new Map([ + ...NATIVE_OBSERVATION_OPERATIONS.map( + (operation) => [operation, projectNativeObservationView] as const, + ), ["inspect_erlang_beam", projectErlangBeamView], ["inspect_devicetree", projectDevicetreeView], ["inspect_native_exports", projectNativeExportsView], diff --git a/src/domain/analysisView/commandObservationView.ts b/src/domain/analysisView/commandObservationView.ts index 530331138c..6779a7255e 100644 --- a/src/domain/analysisView/commandObservationView.ts +++ b/src/domain/analysisView/commandObservationView.ts @@ -96,7 +96,7 @@ export const projectCommandObservationView = ( parent_digest: parent.evidenceId.slice(3), artifact, source_provider: parent.provider ?? null, - source_analysis_profile_digest: parent.analysisProfileDigest ?? null, + source_analysis_profile_digest: parent.analysisProfile?.digest ?? null, source_parameters: parameters.data, coverage: completeWithinViewCoverage(1, 1), limitations: boundedViewMessages( diff --git a/src/domain/analysisView/nativeGraphView.ts b/src/domain/analysisView/nativeGraphView.ts new file mode 100644 index 0000000000..fcafdbe3c6 --- /dev/null +++ b/src/domain/analysisView/nativeGraphView.ts @@ -0,0 +1,98 @@ +import { z } from "zod"; +import type { NativeCallGraph } from "../native/nativeCallGraph.js"; +import type { RelationshipCoverage } from "../relationshipCoverage.js"; +import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; +import { + nativeGraphViewItemsSchema, + referencedRelationshipCoverageSchema, +} from "./nativeObservationSchemas.js"; + +type GraphItems = z.output; +type Coverage = z.output; + +const referenceCoverage = ( + source: RelationshipCoverage, + intern: (message: string) => number, +): Coverage => { + const diagnostic = + source.diagnostics === undefined + ? {} + : { diagnostic_ids: source.diagnostics.map(intern) }; + if (source.status === "complete") + return { status: "complete", ...diagnostic }; + const directions = + source.limited_directions === undefined + ? {} + : { limited_directions: source.limited_directions }; + return source.status === "partial" + ? { + status: "partial", + limitation_ids: source.limitations.map(intern), + ...directions, + ...diagnostic, + } + : { + status: "unknown", + reason_id: intern(source.reason), + ...directions, + ...diagnostic, + }; +}; + +/** Object levels have canonical key order; row identity is (parent, level, source_index). */ +export const projectNativeGraphRows = ( + source: NativeCallGraph, + view: { readonly offset: number; readonly limit: number }, +): { items: GraphItems; total: number } => { + const messages: string[] = []; + const known = new Map(); + const intern = (message: string): number => { + const prior = known.get(message); + if (prior !== undefined) return prior; + const id = messages.length; + messages.push(message); + known.set(message, id); + return id; + }; + const rows: GraphItems["rows"] = []; + let total = 0; + for (const level of Object.keys(source.graph).sort( + compareUnicodeCodePoints, + )) { + const nodes = source.graph[level]!; + if (nodes.length === 0) { + if (total >= view.offset && rows.length < view.limit) + rows.push({ level, source_index: null, status: "empty-level" }); + total++; + continue; + } + const begin = Math.max(0, view.offset - total); + const end = Math.min( + nodes.length, + begin + Math.max(0, view.limit - rows.length), + ); + for (let index = begin; index < end; index++) { + const node = nodes[index]!; + rows.push( + node.status === "error" + ? { level, source_index: index, ...node } + : { + level, + source_index: index, + address: node.address, + status: node.status, + calls: node.calls, + relationship_coverage: referenceCoverage( + node.relationship_coverage, + intern, + ), + ...(node.limitations === undefined + ? {} + : { limitation_ids: node.limitations.map(intern) }), + }, + ); + } + total += nodes.length; + } + return { items: { type: "graph-nodes", messages, rows }, total }; +}; diff --git a/src/domain/analysisView/nativeInventoryView.ts b/src/domain/analysisView/nativeInventoryView.ts index 979e1856e8..c10c86e300 100644 --- a/src/domain/analysisView/nativeInventoryView.ts +++ b/src/domain/analysisView/nativeInventoryView.ts @@ -116,7 +116,7 @@ export const projectNativeInventoryView = ( parent_digest: parent.evidenceId.slice(3), artifact: parent.artifact ?? null, source_provider: parent.provider ?? null, - source_analysis_profile_digest: parent.analysisProfileDigest ?? null, + source_analysis_profile_digest: parent.analysisProfile?.digest ?? null, source_parameters: jsonValueSchema.parse(parent.parameters ?? null), limitations: boundedViewMessages( parent.limitations, diff --git a/src/domain/analysisView/nativeLoadImageView.ts b/src/domain/analysisView/nativeLoadImageView.ts index 21744905c7..c8908989de 100644 --- a/src/domain/analysisView/nativeLoadImageView.ts +++ b/src/domain/analysisView/nativeLoadImageView.ts @@ -241,7 +241,7 @@ export const projectNativeLoadImageView = ( parent_digest: parent.evidenceId.slice(3), artifact: parent.artifact ?? null, source_provider: parent.provider ?? null, - source_analysis_profile_digest: parent.analysisProfileDigest ?? null, + source_analysis_profile_digest: parent.analysisProfile?.digest ?? null, source_parameters: jsonValueSchema.parse(parent.parameters ?? null), limitations: boundedViewMessages( [...parent.limitations, ...limitations], diff --git a/src/domain/analysisView/nativeObservationSchemas.ts b/src/domain/analysisView/nativeObservationSchemas.ts new file mode 100644 index 0000000000..91401875db --- /dev/null +++ b/src/domain/analysisView/nativeObservationSchemas.ts @@ -0,0 +1,199 @@ +import { NATIVE_OBSERVATION_OPERATIONS } from "../native/nativeObservation.js"; +import { z } from "zod"; +import { digestSchema } from "../digests.js"; +import { evidenceSchema } from "../evidence.js"; +import { containingProcedureResolutionSchema } from "../hopperValues.js"; +import { + completeRelationshipCoverageSchema, + partialRelationshipCoverageSchema, + unknownRelationshipCoverageSchema, +} from "../relationshipCoverage.js"; +import { analysisErrorProjectionSchema } from "../../contracts/errorSchemas.js"; + +export const nativeObservationOperationSchema = z.enum( + NATIVE_OBSERVATION_OPERATIONS, +); +const paging = { + offset: z.int().nonnegative().default(0), + limit: z.int().positive().default(64), +}; +export const nativeObservationViewRequestSchema = z.strictObject({ + kind: z.literal("native-observation"), + ...paging, +}); +export const nativeContextViewRequestSchema = z.strictObject({ + kind: z.literal("native-context"), + facet: z + .enum(["overview", "analysis-profile", "limitations", "result-limitations"]) + .default("overview"), + ...paging, +}); +export const nativeSourceContextSchema = z.strictObject({ + subject: evidenceSchema.shape.subject, + provider: evidenceSchema.shape.provider, + analysis_profile: evidenceSchema.shape.analysis_profile, + authority: evidenceSchema.shape.authority, + environment: evidenceSchema.shape.environment, + limitations: evidenceSchema.shape.limitations, + result_limitations: z.array(z.string()).nullable(), +}); +export type NativeSourceContext = z.infer; +export const nativeSourceContextReferenceSchema = z.strictObject({ + materialization: z.literal("reference"), + digest: digestSchema, + evidence_limitation_count: z.int().nonnegative(), + result_limitation_count: z.int().nonnegative().nullable(), +}); + +const sourceIndex = z.int().nonnegative(); +const ids = z.array(sourceIndex); +const diagnostics = ids.min(1).exactOptional(); +export const referencedRelationshipCoverageSchema = z.discriminatedUnion( + "status", + [ + completeRelationshipCoverageSchema + .omit({ diagnostics: true }) + .extend({ diagnostic_ids: diagnostics }), + partialRelationshipCoverageSchema + .omit({ diagnostics: true, limitations: true }) + .extend({ limitation_ids: ids.min(1), diagnostic_ids: diagnostics }), + unknownRelationshipCoverageSchema + .omit({ diagnostics: true, reason: true }) + .extend({ reason_id: sourceIndex, diagnostic_ids: diagnostics }), + ], +); +const graphRowBase = { + level: z.string(), + source_index: sourceIndex, + address: z.string(), +}; +const graphRowsSchema = z.array( + z.discriminatedUnion("status", [ + z.strictObject({ + level: z.string(), + source_index: z.null(), + status: z.literal("empty-level"), + }), + z.strictObject({ + ...graphRowBase, + status: z.literal("ok"), + calls: z.array(z.string()), + relationship_coverage: referencedRelationshipCoverageSchema, + limitation_ids: ids.exactOptional(), + }), + z.strictObject({ + ...graphRowBase, + status: z.literal("error"), + error: analysisErrorProjectionSchema, + }), + ]), +); +export const nativeGraphViewItemsSchema = z + .strictObject({ + type: z.literal("graph-nodes"), + messages: z.array(z.string()), + rows: graphRowsSchema, + }) + .superRefine((items, context) => { + const valid = (value: number, path: PropertyKey[]) => { + if (value >= items.messages.length) + context.addIssue({ + code: "custom", + path, + message: "Message reference is outside this page's dictionary", + }); + }; + items.rows.forEach((row, index) => { + if (row.status !== "ok") return; + row.limitation_ids?.forEach((value, i) => + valid(value, ["rows", index, "limitation_ids", i]), + ); + const coverage = row.relationship_coverage; + coverage.diagnostic_ids?.forEach((value, i) => + valid(value, [ + "rows", + index, + "relationship_coverage", + "diagnostic_ids", + i, + ]), + ); + if (coverage.status === "partial") + coverage.limitation_ids.forEach((value, i) => + valid(value, [ + "rows", + index, + "relationship_coverage", + "limitation_ids", + i, + ]), + ); + if (coverage.status === "unknown") + valid(coverage.reason_id, [ + "rows", + index, + "relationship_coverage", + "reason_id", + ]); + }); + }); +const addresses = z.strictObject({ + type: z.literal("addresses"), + rows: z.array( + z.strictObject({ source_index: sourceIndex, address: z.string() }), + ), +}); +const resolutions = z.strictObject({ + type: z.literal("resolutions"), + rows: z.array( + z.strictObject({ + source_index: sourceIndex, + record: containingProcedureResolutionSchema, + }), + ), +}); +export const nativeObservationItemsSchemas = { + procedure_address: addresses, + procedure_callees: addresses, + procedure_callers: addresses, + resolve_containing_procedure: resolutions, + get_call_graph: nativeGraphViewItemsSchema, +}; +export const nativeContextItemsSchemas = { + overview: z.strictObject({ + type: z.literal("overview"), + rows: z.array( + z.strictObject({ + source_index: sourceIndex, + record: nativeSourceContextSchema + .omit({ + analysis_profile: true, + limitations: true, + result_limitations: true, + }) + .extend({ analysis_profile_digest: digestSchema.nullable() }), + }), + ), + }), + "analysis-profile": z.strictObject({ + type: z.literal("analysis-profile"), + rows: z.array( + z.strictObject({ + source_index: sourceIndex, + record: evidenceSchema.shape.analysis_profile, + }), + ), + }), + limitations: z.strictObject({ + type: z.literal("limitations"), + rows: z.array( + z.strictObject({ source_index: sourceIndex, text: z.string() }), + ), + }), + "result-limitations": z.strictObject({ + type: z.literal("result-limitations"), + rows: z + .array(z.strictObject({ source_index: sourceIndex, text: z.string() })) + .nullable(), + }), +}; diff --git a/src/domain/analysisView/nativeObservationView.ts b/src/domain/analysisView/nativeObservationView.ts new file mode 100644 index 0000000000..a8c9d85ceb --- /dev/null +++ b/src/domain/analysisView/nativeObservationView.ts @@ -0,0 +1,326 @@ +import type { AnalysisError } from "../analysisErrorBase.js"; +import { evidenceSubjectIdentity } from "../evidence.js"; +import { digestCanonicalValue } from "../canonicalDigest.js"; +import { jsonValueSchema } from "../jsonValue.js"; +import { analysisInputErrorFromIssues } from "../inputIssueProjection.js"; +import { err, ok, type Result } from "../result.js"; +import { + nativeAddressListSchema, + nativeObservationSourceSchemas, + type NativeObservation, +} from "../native/nativeObservation.js"; +import type { + AnalysisViewParent, + AnalysisViewRequest, + UnsignedAnalysisView, +} from "./analysisView.js"; +import { createAnalysisViewResultReader } from "./analysisViewParent.js"; +import { analysisViewInputError } from "./analysisViewErrors.js"; +import { + pageViewCoverage, + completeWithinViewCoverage, +} from "./analysisViewCoverage.js"; +import { + nativeSourceContextSchema, + type NativeSourceContext, +} from "./nativeObservationSchemas.js"; +import { projectNativeGraphRows } from "./nativeGraphView.js"; + +const readAddresses = createAnalysisViewResultReader(nativeAddressListSchema); +const readAddress = createAnalysisViewResultReader( + nativeObservationSourceSchemas.procedure_address, +); +const readResolution = createAnalysisViewResultReader( + nativeObservationSourceSchemas.resolve_containing_procedure, +); +const readGraph = createAnalysisViewResultReader( + nativeObservationSourceSchemas.get_call_graph, +); +const readSource = ( + parent: AnalysisViewParent, +): Result => { + switch (parent.operation) { + case "procedure_callees": + case "procedure_callers": { + const read = readAddresses(parent); + return read.ok + ? ok({ operation: parent.operation, result: read.value }) + : read; + } + case "procedure_address": { + const read = readAddress(parent); + return read.ok + ? ok({ operation: parent.operation, result: read.value }) + : read; + } + case "resolve_containing_procedure": { + const read = readResolution(parent); + return read.ok + ? ok({ operation: parent.operation, result: read.value }) + : read; + } + case "get_call_graph": { + const read = readGraph(parent); + return read.ok + ? ok({ operation: parent.operation, result: read.value }) + : read; + } + default: + return err( + analysisViewInputError([ + { + path: ["source"], + reason: "invalid_value", + message: + "This parent does not record a supported native observation.", + }, + ]), + ); + } +}; +const readContext = ( + parent: AnalysisViewParent, + source: NativeObservation, +): Result => { + const values = { + subject: parent.subject, + provider: parent.provider, + analysis_profile: parent.analysisProfile, + authority: parent.authority, + environment: parent.environment, + limitations: parent.limitations, + result_limitations: + source.operation === "get_call_graph" ? source.result.limitations : null, + }; + const parsed = nativeSourceContextSchema.safeParse(values); + return parsed.success + ? ok(parsed.data) + : err( + analysisInputErrorFromIssues( + "inspect_analysis_view", + parsed.error.issues.map((issue) => ({ + ...issue, + path: ["source", ...issue.path], + })), + { source: values }, + { cause: parsed.error }, + ), + ); +}; +const selectedRows = ( + values: readonly Value[], + selection: { readonly offset: number; readonly limit: number }, +) => + values + .slice(selection.offset, selection.offset + selection.limit) + .map((value, index) => ({ source_index: selection.offset + index, value })); + +const observationRows = ( + source: NativeObservation, + view: { readonly offset: number; readonly limit: number }, +) => { + switch (source.operation) { + case "procedure_address": + return { + parent_operation: source.operation, + total: 1, + items: { + type: "addresses" as const, + rows: selectedRows([source.result], view).map((row) => ({ + source_index: row.source_index, + address: row.value, + })), + }, + }; + case "procedure_callees": + case "procedure_callers": + return { + parent_operation: source.operation, + total: source.result.length, + items: { + type: "addresses" as const, + rows: selectedRows(source.result, view).map((row) => ({ + source_index: row.source_index, + address: row.value, + })), + }, + }; + case "resolve_containing_procedure": + return { + parent_operation: source.operation, + total: 1, + items: { + type: "resolutions" as const, + rows: selectedRows([source.result], view).map((row) => ({ + source_index: row.source_index, + record: row.value, + })), + }, + }; + case "get_call_graph": + return { + parent_operation: source.operation, + ...projectNativeGraphRows(source.result, view), + }; + } +}; +const contextRows = ( + source: NativeSourceContext, + view: Extract, +) => { + switch (view.facet) { + case "overview": { + const { + analysis_profile, + limitations: _limitations, + result_limitations: _result, + ...overview + } = source; + return { + facet: view.facet, + view: { ...view, facet: view.facet }, + total: 1, + items: { + type: "overview" as const, + rows: selectedRows( + [ + { + ...overview, + analysis_profile_digest: analysis_profile?.digest ?? null, + }, + ], + view, + ).map((row) => ({ + source_index: row.source_index, + record: row.value, + })), + }, + }; + } + case "analysis-profile": + return { + facet: view.facet, + view: { ...view, facet: view.facet }, + total: 1, + items: { + type: "analysis-profile" as const, + rows: selectedRows([source.analysis_profile], view).map((row) => ({ + source_index: row.source_index, + record: row.value, + })), + }, + }; + case "limitations": + return { + facet: view.facet, + view: { ...view, facet: view.facet }, + total: source.limitations.length, + items: { + type: "limitations" as const, + rows: selectedRows(source.limitations, view).map((row) => ({ + source_index: row.source_index, + text: row.value, + })), + }, + }; + case "result-limitations": + return { + facet: view.facet, + view: { ...view, facet: view.facet }, + total: source.result_limitations?.length ?? null, + items: { + type: "result-limitations" as const, + rows: + source.result_limitations === null + ? null + : selectedRows(source.result_limitations, view).map((row) => ({ + source_index: row.source_index, + text: row.value, + })), + }, + }; + } +}; + +/** Select recorded native facts without repeating provider profiles or complete caveat inventories. */ +export const projectNativeObservationView = ( + parent: AnalysisViewParent, + view: AnalysisViewRequest, +): Result => { + if ( + view.kind !== "summary" && + view.kind !== "native-observation" && + view.kind !== "native-context" + ) + return err( + analysisViewInputError([ + { + path: ["view"], + reason: "invalid_value", + message: + "Use summary, native-observation or native-context with this source.", + }, + ]), + ); + const source = readSource(parent); + if (!source.ok) return source; + const context = readContext(parent, source.value); + if (!context.ok) return context; + const sourceContext = { + materialization: "reference" as const, + digest: digestCanonicalValue( + { + ...context.value, + subject: evidenceSubjectIdentity(context.value.subject), + }, + "Native source context", + ), + evidence_limitation_count: context.value.limitations.length, + result_limitation_count: context.value.result_limitations?.length ?? null, + }; + const common = { + parent_evidence_id: parent.evidenceId, + parent_digest: parent.evidenceId.slice(3), + artifact: parent.artifact ?? null, + source_provider: context.value.provider, + source_analysis_profile_digest: + context.value.analysis_profile?.digest ?? null, + source_parameters: jsonValueSchema.parse(parent.parameters ?? {}), + source_context: sourceContext, + limitations: ["Source caveats require native-context."], + unknowns: [ + "Interpretation beyond recorded rows is unknown until source caveats are read.", + ], + }; + if (view.kind === "native-context") { + const { total, ...page } = contextRows(context.value, view); + return ok({ + ...common, + kind: "native-context", + ...page, + parent_operation: source.value.operation, + coverage: + total === null + ? completeWithinViewCoverage(0, 0) + : pageViewCoverage(view.offset, page.items.rows?.length ?? 0, total), + unknowns: + total === null + ? [ + "Result limitations are not applicable to this primitive observation; null does not report an examined empty list.", + ] + : common.unknowns, + }); + } + const selection = view.kind === "summary" ? { offset: 0, limit: 64 } : view; + const page = observationRows(source.value, selection); + const { total, ...payload } = page; + const observation = { + ...common, + ...payload, + summary: { observations: total }, + coverage: pageViewCoverage(selection.offset, page.items.rows.length, total), + }; + return view.kind === "summary" + ? ok({ ...observation, kind: "summary", view }) + : ok({ ...observation, kind: "native-observation", view }); +}; diff --git a/src/domain/evidence.ts b/src/domain/evidence.ts index 344ccb9bb1..07cf9d47f3 100644 --- a/src/domain/evidence.ts +++ b/src/domain/evidence.ts @@ -269,18 +269,21 @@ export interface EvidenceObservation extends EvidenceObservationInput { type EvidenceWithoutId = Omit; +/** Target identity shared by semantic commitments, excluding caller-selected display paths. */ +export const evidenceSubjectIdentity = (subject: Evidence["subject"]) => + subject === null + ? null + : { + digest: subject.digest, + format: subject.format, + architecture: subject.architecture, + }; + /** Content committed by an Evidence ID, excluding local request/display metadata. */ export const evidenceSemanticContent = ( evidence: EvidenceWithoutId, ): Record => ({ - subject: - evidence.subject === null - ? null - : { - digest: evidence.subject.digest, - format: evidence.subject.format, - architecture: evidence.subject.architecture, - }, + subject: evidenceSubjectIdentity(evidence.subject), provider: evidence.provider, analysis_profile: evidence.analysis_profile, predicate_type: evidence.predicate_type, diff --git a/src/domain/native/nativeCallGraph.ts b/src/domain/native/nativeCallGraph.ts new file mode 100644 index 0000000000..f2d1c82b28 --- /dev/null +++ b/src/domain/native/nativeCallGraph.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; +import { analysisErrorProjectionSchema } from "../../contracts/errorSchemas.js"; +import { relationshipCoverageSchema } from "../relationshipCoverage.js"; + +/** One recorded traversal outcome; source levels and array positions retain identity. */ +export const nativeCallGraphNodeSchema = z.discriminatedUnion("status", [ + z.object({ + address: z.string(), + status: z.literal("ok"), + calls: z.array(z.string()), + relationship_coverage: relationshipCoverageSchema, + limitations: z.array(z.string()).optional(), + }), + z.object({ + address: z.string(), + status: z.literal("error"), + error: analysisErrorProjectionSchema, + }), +]); + +/** Provider-independent result owned by the shared call-graph workflow. */ +export const nativeCallGraphSchema = z.strictObject({ + graph: z.record(z.string(), z.array(nativeCallGraphNodeSchema)), + limitations: z.array(z.string()), +}); +export type NativeCallGraph = z.infer; diff --git a/src/domain/native/nativeObservation.ts b/src/domain/native/nativeObservation.ts new file mode 100644 index 0000000000..4ad3723217 --- /dev/null +++ b/src/domain/native/nativeObservation.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; +import { containingProcedureResolutionSchema } from "../hopperValues.js"; +import { nativeCallGraphSchema } from "./nativeCallGraph.js"; + +export const NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS = [ + "procedure_address", + "procedure_callees", + "procedure_callers", + "resolve_containing_procedure", +] as const; +export const NATIVE_OBSERVATION_OPERATIONS = [ + ...NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS, + "get_call_graph", +] as const; +export type NativeObservationOperation = + (typeof NATIVE_OBSERVATION_OPERATIONS)[number]; +export const nativeAddressListSchema = z.array(z.string()); +export const nativeObservationSourceSchemas = { + procedure_address: z.string(), + procedure_callees: nativeAddressListSchema, + procedure_callers: nativeAddressListSchema, + resolve_containing_procedure: containingProcedureResolutionSchema, + get_call_graph: nativeCallGraphSchema, +}; +export type NativeObservation = { + [Operation in NativeObservationOperation]: { + operation: Operation; + result: z.output<(typeof nativeObservationSourceSchemas)[Operation]>; + }; +}[NativeObservationOperation]; + +/** One operation family shared by view projection and response-budget recovery. */ +export const isNativeObservationOperation = ( + operation: string, +): operation is NativeObservationOperation => + NATIVE_OBSERVATION_OPERATIONS.some((supported) => supported === operation); diff --git a/src/domain/relationshipCoverage.ts b/src/domain/relationshipCoverage.ts index ec69dc5cc8..d3c048f49e 100644 --- a/src/domain/relationshipCoverage.ts +++ b/src/domain/relationshipCoverage.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { fixedTupleSchema } from "./fixedTupleSchema.js"; const diagnostics = z.array(z.string().min(1)).min(1).exactOptional(); -const completeCoverage = z.strictObject({ +export const completeRelationshipCoverageSchema = z.strictObject({ status: z.literal("complete"), diagnostics, }); @@ -12,13 +12,13 @@ const limitedDirections = z.union([ fixedTupleSchema([z.literal("callees")]), fixedTupleSchema([z.literal("callers"), z.literal("callees")]), ]); -const partialCoverage = z.strictObject({ +export const partialRelationshipCoverageSchema = z.strictObject({ status: z.literal("partial"), limitations: z.array(z.string().min(1)).min(1), limited_directions: limitedDirections.exactOptional(), diagnostics, }); -const unknownCoverage = z.strictObject({ +export const unknownRelationshipCoverageSchema = z.strictObject({ status: z.literal("unknown"), reason: z.string().min(1), limited_directions: limitedDirections.exactOptional(), @@ -27,9 +27,9 @@ const unknownCoverage = z.strictObject({ /** Explicit coverage state for a provider's observed caller/callee set. */ export const relationshipCoverageSchema = z.discriminatedUnion("status", [ - completeCoverage, - partialCoverage, - unknownCoverage, + completeRelationshipCoverageSchema, + partialRelationshipCoverageSchema, + unknownRelationshipCoverageSchema, ]); export type RelationshipCoverage = z.infer; diff --git a/src/server/registerEnhancedTools.ts b/src/server/registerEnhancedTools.ts index b0022719dd..60e54f610e 100644 --- a/src/server/registerEnhancedTools.ts +++ b/src/server/registerEnhancedTools.ts @@ -25,6 +25,7 @@ import { toolContract } from "../contracts/toolContracts.js"; import type { ToolContract } from "../contracts/toolContractTypes.js"; import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; +import { ok } from "../domain/result.js"; import type { JsonValue } from "../domain/jsonValue.js"; import type { Logger } from "pino"; import { mcpProgressReporter } from "./mcpProgress.js"; @@ -146,10 +147,11 @@ export const registerEnhancedTools = ( server.registerTool( callGraph.name, toolRegistrationOptions(callGraph), - (input, context) => + ({ detail, ...input }, context) => executeEnhancedTool(registration, callGraph, { validatedCall: { name: "get_call_graph", input }, context, + detail, }), ); server.registerTool( @@ -233,10 +235,19 @@ const executeEnhancedTool = async ( }, contract: ToolContract, request: { + readonly detail?: "complete" | "summary"; readonly validatedCall: ValidatedEnhancedCall; readonly context: ServerContext; }, ): Promise => { + if (request.detail === "summary") { + const ready = requireAnalysisViewRetention(request.validatedCall.name, { + recordEvidence: registration.recordEvidence, + evidenceById: registration.evidenceById, + }); + if (!ready.ok) + return registration.delivery.toCallToolResult(ready, contract); + } const admitted = await registration.withAdmittedAnalysis( request.validatedCall.name, request.context.mcpReq.signal, @@ -307,10 +318,21 @@ const executeEnhancedTool = async ( }); if (!unknowns.ok) return registration.delivery.toCallToolResult(unknowns, contract); + const delivered = + request.detail === "summary" + ? summarizeRetainedAnalysis(evidence, { + recordEvidence: registration.recordEvidence, + evidenceById: registration.evidenceById, + }) + : ok(evidence); + if (!delivered.ok) + return registration.delivery.toCallToolResult(delivered, contract); return registration.delivery.toEvidenceToolResult( - evidence, + delivered.value, contract, - recorded, + request.detail === "summary" + ? registration.recordEvidence?.(delivered.value) + : recorded, ); } return registration.delivery.toCallToolResult(result, contract); diff --git a/src/server/registerOfficialTools.ts b/src/server/registerOfficialTools.ts index 2cb91517ea..1f70a1237d 100644 --- a/src/server/registerOfficialTools.ts +++ b/src/server/registerOfficialTools.ts @@ -1,3 +1,4 @@ +import { NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS } from "../domain/native/nativeObservation.js"; import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/officialToolContracts.js"; import { NATIVE_INVENTORY_OPERATIONS } from "../domain/native/nativeInventory.js"; @@ -10,6 +11,7 @@ import { const SUMMARY_OPERATIONS: ReadonlySet = new Set([ ...NATIVE_INVENTORY_OPERATIONS, + ...NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS, "inspect_native_load_image", ]); const withoutDetail = withoutDeliveryDetail(SUMMARY_OPERATIONS); diff --git a/src/server/toolResult.ts b/src/server/toolResult.ts index 88ac43e9e3..a8d904e24c 100644 --- a/src/server/toolResult.ts +++ b/src/server/toolResult.ts @@ -1,3 +1,4 @@ +import { isNativeObservationOperation } from "../domain/native/nativeObservation.js"; import { isNativeInventoryOperation } from "../domain/native/nativeInventory.js"; import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from "@modelcontextprotocol/server"; import { ToolResultDelivery } from "./ToolResultDelivery.js"; @@ -24,7 +25,9 @@ const retainedEvidenceContinuation = ( ? { kind: "native", facet: "pseudocode", offset: 0 } : isNativeInventoryOperation(evidence.operation) ? { kind: "native-inventory", offset: 0, limit: 64 } - : undefined; + : isNativeObservationOperation(evidence.operation) + ? { kind: "native-observation", offset: 0, limit: 64 } + : undefined; if (view === undefined) return {}; const input = inspectAnalysisViewInputSchema.safeParse({ source: { kind: "retained-evidence", evidence_id: evidence.evidence_id }, diff --git a/tests/boundary/mcp/nativeInventoryViewsMcp.test.ts b/tests/boundary/mcp/nativeInventoryViewsMcp.test.ts index c76b664936..5ec387ea9d 100644 --- a/tests/boundary/mcp/nativeInventoryViewsMcp.test.ts +++ b/tests/boundary/mcp/nativeInventoryViewsMcp.test.ts @@ -288,10 +288,18 @@ it("refuses summary before provider work when no retaining session is configured InMemoryTransport.createLinkedPair(); await server.connect(serverTransport); await client.connect(clientTransport); - for (const name of ["list_procedures", "inspect_native_load_image"]) { + for (const [name, parameters] of [ + ["list_procedures", {}], + ["inspect_native_load_image", {}], + ["procedure_address", { procedure: "fixture_main" }], + ["procedure_callees", { procedure: "fixture_main" }], + ["procedure_callers", { procedure: "fixture_main" }], + ["resolve_containing_procedure", { address: "0x401001" }], + ["get_call_graph", { address: "fixture_main" }], + ] as const) { const reply = await client.callTool({ name, - arguments: { detail: "summary" }, + arguments: { ...parameters, detail: "summary" }, }); expect(reply.isError).toBe(true); expect(parseMcpToolError(reply).error.code).toBe("capability_unavailable"); diff --git a/tests/boundary/mcp/nativeObservationViewsMcp.test.ts b/tests/boundary/mcp/nativeObservationViewsMcp.test.ts new file mode 100644 index 0000000000..c5a1266865 --- /dev/null +++ b/tests/boundary/mcp/nativeObservationViewsMcp.test.ts @@ -0,0 +1,214 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createToolResultDelivery } from "../../../src/server/toolResult.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { ok } from "../../../src/domain/result.js"; +import { z } from "zod"; +import { afterAll, expect, it } from "vitest"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { + analysisViewResultSchema, + inspectAnalysisViewInputSchema, +} from "../../../src/domain/analysisView/analysisView.js"; +import { NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS } from "../../../src/domain/native/nativeObservation.js"; +import { connectGhidraMcp } from "./ghidraMcpHarness.js"; +import { createMcpSchemaValidation } from "../../fixtures/mcpSchemaValidation.js"; +import { advertisedMcpToolSchemas } from "../../fixtures/advertisedMcpTool.js"; + +const validation = createMcpSchemaValidation(); +afterAll(() => validation.clear()); + +it("delivers native primitive and graph summaries with exact retained parents and SDK-validated context pages", async () => { + const harness = await connectGhidraMcp("native-observation-views"); + try { + const definitions = new Map( + (await harness.mcp.listTools()).tools.map((tool) => [tool.name, tool]), + ); + const call = async (name: string, input: Record) => { + const tool = definitions.get(name); + if (!tool?.outputSchema) throw new Error("Missing named contract"); + const result = await harness.mcp.callTool( + { name, arguments: input }, + { toolDefinition: tool }, + ); + expect(result.isError, JSON.stringify(result.content)).not.toBe(true); + expect( + validation.assertions.validate( + tool.outputSchema, + result.structuredContent, + ), + JSON.stringify(validation.assertions.errors), + ).toBe(true); + return parseEvidence(result.structuredContent); + }; + const viewSchemas = await advertisedMcpToolSchemas( + harness.mcp, + "inspect_analysis_view", + validation.assertions, + ); + for (const operation of [ + ...NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS, + "get_call_graph", + ] as const) { + const input = + operation === "get_call_graph" + ? { address: "fixture_main" } + : operation === "resolve_containing_procedure" + ? { address: "0x401001" } + : { procedure: "fixture_main" }; + const complete = await call(operation, input); + const summary = await call(operation, { ...input, detail: "summary" }); + expect(complete.operation).toBe(operation); + expect(complete.parameters).not.toHaveProperty("detail"); + expect(summary.normalized_result).toMatchObject({ + kind: "summary", + parent_operation: operation, + parent_evidence_id: complete.evidence_id, + source_parameters: complete.parameters, + source_provider: complete.provider, + }); + expect(harness.session.evidenceById(complete.evidence_id)).toEqual( + complete, + ); + const calls = harness.calls.length; + const source = { + kind: "retained-evidence", + evidence_id: complete.evidence_id, + }; + const page = await call("inspect_analysis_view", { + source, + view: { kind: "native-observation", offset: 0, limit: 1 }, + }); + const parsed = analysisViewResultSchema.parse(page.normalized_result); + expect(parsed).toMatchObject({ + kind: "native-observation", + parent_evidence_id: complete.evidence_id, + }); + for (const facet of [ + "overview", + "analysis-profile", + "limitations", + "result-limitations", + ]) { + const context = await call("inspect_analysis_view", { + source, + view: { kind: "native-context", facet, offset: 0, limit: 1 }, + }); + expect(context.relationship_coverage).toEqual( + complete.relationship_coverage, + ); + expect(context.normalized_result).toMatchObject({ + kind: "native-context", + facet, + parent_evidence_id: complete.evidence_id, + }); + } + expect(harness.calls).toHaveLength(calls); + const invalid = { + source, + view: { kind: "native-context", facet: "unknown" }, + }; + expect(inspectAnalysisViewInputSchema.safeParse(invalid).success).toBe( + false, + ); + const rejected = await harness.mcp.callTool( + { name: "inspect_analysis_view", arguments: invalid }, + { toolDefinition: viewSchemas.toolDefinition }, + ); + expect(rejected.isError).toBe(true); + } + } finally { + await harness.close(); + } +}, 90_000); + +it("follows native overflow continuations through MCP without repeating provider work", async () => { + let reads = 0; + const provider = { id: "fixture", name: "Fixture", version: "1" }; + const session = createTestBinarySession(() => ({ + execute: async (operation) => { + reads++; + return ok( + createAnalysisExecution( + operation === "procedure_address" + ? "0x1000" + : operation === "resolve_containing_procedure" + ? { + query_address: "0x9999", + found: false, + procedure: null, + reason: "not_in_procedure", + } + : [], + provider, + { + limitations: ["Capacity fixture ".repeat(1500)], + relationshipCoverage: { + status: "partial", + limitations: ["Indirect edges unknown"], + }, + }, + ), + ); + }, + close: async () => ok(null), + })); + // Exercise the real delivery/SDK path with a small injected response budget. + const server = createServer( + { kind: "session", session }, + { delivery: createToolResultDelivery(32 * 1024) }, + ); + const client = new Client({ + name: "native-observation-capacity", + version: "1", + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const opened = await client.callTool({ + name: "open_binary", + arguments: { path: process.execPath }, + }); + expect(opened.isError).not.toBe(true); + for (const name of [ + ...NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS, + "get_call_graph", + ] as const) { + const input = + name === "get_call_graph" || name === "resolve_containing_procedure" + ? { address: "0x1000" } + : { procedure: "fixture_main" }; + const complete = await client.callTool({ name, arguments: input }); + const error = parseMcpToolError(complete).error; + expect(error.code).toBe("resource_constraint"); + const continuation = z + .object({ + reported_limits: z.object({ + continuation: z.object({ + name: z.literal("inspect_analysis_view"), + arguments: inspectAnalysisViewInputSchema, + }), + }), + }) + .parse(error.details).reported_limits.continuation; + expect(continuation.arguments.view.kind).toBe("native-observation"); + const before = reads; + const page = await client.callTool(continuation); + expect(page.isError, JSON.stringify(page.content)).not.toBe(true); + const evidence = parseEvidence(page.structuredContent); + expect(evidence.normalized_result).toMatchObject({ + kind: "native-observation", + parent_operation: name, + }); + expect(reads).toBe(before); + } + } finally { + await Promise.all([client.close(), server.close()]); + const closed = await session.close(); + if (!closed.ok) throw closed.error; + } +}, 90_000);