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 4b640cca84..5bbbf5d017 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -430,6 +430,14 @@ loaded bytes, file mappings, relocations and the entry against the immutable snapshot. Keep `mismatch` and `unsupported` explicit. Verified import covers the reported static image, not runtime DOS or PC-98 hardware behavior. +For a large load-image report, select `detail: "summary"` and query its +`parent_evidence_id` with `inspect_analysis_view` kind `native-load-image`. +Choose a collection such as `relocations` or `mappings`, select only needed +fields, and page with offset/limit. Original indices preserve same-address +alternatives. Null collections remain unrecorded or inapplicable; paging does +not upgrade unsupported verification. CLI requires `--evidence_output` for +summary delivery so complete source Evidence remains accessible. + For Ghidra decompilation, `procedure_pseudo_code` also accepts `procedure: "file-offset:0x..."`; CLI `rea decompile --file-offset=0x... --provider ghidra` resolves the source byte offset internally. An unresolved 0x-prefixed numeric procedure selector also falls back to a unique file mapping. Existing symbols and analysis addresses win; multiple mappings fail as ambiguous. Preserve the returned file-offset and address locations and raw selection observations. ELF virtual addresses are distinct from file offsets; never assume an arbitrary offset plus image base is an analysis address. Complete function body ranges use inclusive ends and can contain gaps. Do not diff --git a/docs/ghidra-provider.md b/docs/ghidra-provider.md index fe8a0fd589..7632ca5d78 100644 --- a/docs/ghidra-provider.md +++ b/docs/ghidra-provider.md @@ -160,6 +160,43 @@ have no file backing. ELF mappings are observed without independent format-specific load-image verification, as the result's status and limitations state. +For large relocation inventories, select MCP `inspect_native_load_image` with +`detail: "summary"`. It returns verification state, observed identity and counts, +while retaining complete Evidence. Use `normalized_result.parent_evidence_id` +with `inspect_analysis_view` and a view such as: + +```json +{ + "kind": "native-load-image", + "collection": "relocations", + "offset": 0, + "limit": 32, + "fields": ["address", "status", "type"] +} +``` + +Collections are `source_files`, `mappings`, `relocations`, `entry_points`, +`entry_context`, `checks` and DOL `sections`. Optional +`indices` select original row indices before paging; empty indices select none. +Fields are collection-specific. Relocation defaults omit byte payloads; check defaults omit potentially large `expected`/`observed` inventories. Request those fields explicitly when needed. Other collections default to all fields. Metadata recovery remains counted and retained in complete parent Evidence. +Parent Evidence, collection and `source_index` identify a row, including same-address +relocation alternatives. Null counts or rows mean unrecorded or inapplicable; +they never establish an empty observation. Exhausting a page does not upgrade +unsupported verification or runtime coverage. Pages query immutable retained +facts without reopening the target or repeating Ghidra work. +Each page carries the source's structured `verification_status` independently +of its page coverage. + +CLI summary delivery requires a complete Evidence bundle: + +```sh +rea inspect-native-load-image --provider ghidra --detail summary --evidence_output /new/load-image.json --json +``` + +Read selected pages later through `inspect-analysis-view` using the returned +parent ID and an `evidence-file` source pointing to that bundle. Complete +delivery remains the default in both adapters. + An unsuccessful numeric procedure lookup reports the current Ghidra image base and gives the CLI commands above alongside the corresponding MCP operations. Repeat any target-format, architecture, and entry-point options used to open the diff --git a/experiments/analysis-efficiency/native-load-image-views.md b/experiments/analysis-efficiency/native-load-image-views.md new file mode 100644 index 0000000000..a868b9d6a0 --- /dev/null +++ b/experiments/analysis-efficiency/native-load-image-views.md @@ -0,0 +1,89 @@ +# Native load-image delivery verification + +`native-load-image-views.mjs` runs the compiled production stdio MCP server with +a caller-selected Ghidra and JDK. It compares complete delivery with a summary +plus selected relocation pages, then exports and authenticates the complete +parent. Every call uses its actual advertised SDK contract. Both cold and warm +reads run in one fresh server; OS file caches are not declared cold. + +Build each selected runtime with the pinned toolchain. Use absolute paths or +paths relative to the verifier's launch directory, and new output directories: + +```sh +node experiments/analysis-efficiency/native-load-image-views.mjs \ + --runtime /path/to/baseline --mode complete \ + --target /usr/lib/x86_64-linux-gnu/libcrypto.so.3 \ + --ghidra /path/to/ghidra_12.1.4_PUBLIC --java /path/to/jdk-21 \ + --output /new/baseline-report +node experiments/analysis-efficiency/native-load-image-views.mjs \ + --runtime /path/to/feature --mode summary \ + --target /usr/lib/x86_64-linux-gnu/libcrypto.so.3 \ + --ghidra /path/to/ghidra_12.1.4_PUBLIC --java /path/to/jdk-21 \ + --output /new/summary-report --control /new/baseline-report +``` + +The question is the verification state, all collection counts, and the first +four relocation addresses/statuses. The summary path additionally checks the +last relocation's recorded bytes and unavailable independent verification +checks. It never treats ELF observations as verified. Complete delivery can +exceed the MCP response limit: the baseline then follows the reported retained +reference, exports its source and selects facts locally. That recovery is an +extra workflow, not a successful complete inline MCP reply. Include its calls +and local reads when comparing completed tasks. + +The independent GNU `readelf -Wr` oracle checks that every file-table relocation +coordinate survives, including multiplicity. `readelf -Wl` supplies the original +PT_LOAD base independently of Ghidra's chosen import base. Producer order can +differ. Additional provider observations are retained and reported separately; +this oracle does not independently verify their origin. Full observation +digests and selected facts must agree with the control run. + +The target SHA-256, native oracle output, raw catalog, arguments/replies, +complete Evidence and phase durations stay in the output directory. Measure +whole-process-family elapsed time, CPU and peak memory externally, with bounded +CPU/heap and a memory stop condition. The child requests a 1.5 GiB Ghidra heap +and a 3 GiB Node heap; neither is a whole-family RSS bound. Estimate tokens over +catalog discovery, arguments, all replies and local recovery facts using a named +tokenizer. Raw serialized schema estimates are not actual model usage; protocol +framing, prompts and client rendering remain unmeasured. + +The shared receipt finalizer closes both transports, removes its owned workspace +after successful cleanup, and preserves cleanup/publication failures in the +report. Its phase elapsed field precedes final receipt publication; external +whole-family measurements must include that finalization. + +## Recorded Linux x64 verification + +One fresh cold/warm session per revision used Node 24.22.1, Ghidra 12.1.4, +two CPUs and the system `libcrypto.so.3` with SHA-256 +`76dd3d93e5ee48950a92a58d59b94de8143847f91a80d9682c938767b991577d`. +Baseline `42382651b` has the same tree as main `fc43e285e`; feature `7a53a4a7f`. +Both retained the same parent ID and 21,118 observations. GNU readelf declared +21,117 relocation coordinates, all present; one provider-only coordinate at +relative `0x47dc60` remained visible without an independently established origin. + +| Whole two-capture harness | Complete + local recovery | Summary + selected reads | +| --------------------------------------- | -----------------------------: | -----------------------: | +| Direct capture reply | Refused at the 10 MiB boundary | 9,787 bytes | +| Complete parent Evidence | 9,114,888 bytes | 9,114,888 bytes | +| Raw serialization, cl100k_base estimate | 912,927 tokens | 936,730 tokens | +| Elapsed | 164.93 s | 169.88 s | +| Process-family CPU | 281.75 s | 293.06 s | +| Sampled process-family RSS peak | 2.01 GiB | 2.30 GiB | + +Token estimates include one raw catalog discovery, call arguments/replies and +baseline local recovery facts. Local command text, prompts, protocol framing +and client rendering were not measured; these are not model-usage totals. +The summary harness also checks tail bytes and unavailable checks through +additional RPCs. Neither lower task tokens, faster analysis nor lower memory +is established by this single pair. The benefit is direct bounded MCP access +to facts that previously required an export and local selection. RSS summed +the owned process family every 100 ms; shared pages can be double-counted and +short peaks missed. OS caches were warm. + +The real CLI published the same complete parent with summary delivery, then a +separate CLI process read selected first/last rows from that bundle without +configured Ghidra/JDK. That file-page read took 4.83 s, 5.89 CPU seconds and +0.51 GiB sampled family RSS. This verifies historical projection independently +of reopening a native provider. Other hosts, engines and formats were not +verified in this experiment. diff --git a/experiments/analysis-efficiency/native-load-image-views.mjs b/experiments/analysis-efficiency/native-load-image-views.mjs new file mode 100644 index 0000000000..a8e13ab90d --- /dev/null +++ b/experiments/analysis-efficiency/native-load-image-views.mjs @@ -0,0 +1,357 @@ +/** Actual Ghidra/MCP load observations with retained-page and readelf oracles. */ +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]); +} +if (values.control) values.control = resolve(values.control); +assert.ok(["complete", "summary"].includes(values.mode)); +await mkdir(values.output); +const hash = (value) => createHash("sha256").update(value).digest("hex"); +const { parseEvidence } = await import( + pathToFileURL(join(values.runtime, "dist/domain/evidence.js")).href +); +const { stdout: revision } = await promisify(execFile)("git", [ + "-C", + values.runtime, + "rev-parse", + "HEAD", +]); +const { stdout: workingState } = await promisify(execFile)("git", [ + "-C", + values.runtime, + "status", + "--porcelain", +]); +const targetSha256 = hash(await readFile(values.target)); +const { stdout: oracle } = await promisify(execFile)( + "readelf", + ["-Wr", values.target], + { maxBuffer: 64 * 1024 ** 2 }, +); +const relocationOffsets = [ + ...oracle.matchAll(/^([0-9a-f]+)\s+[0-9a-f]+\s+R_\S+/gm), +].map((row) => row[1]); +await writeFile(join(values.output, "readelf-relocations.txt"), oracle); +const { stdout: programHeaders } = await promisify(execFile)( + "readelf", + ["-Wl", values.target], + { maxBuffer: 1024 ** 2 }, +); +const loadAddresses = [ + ...programHeaders.matchAll(/^\s*LOAD\s+0x[0-9a-f]+\s+0x([0-9a-f]+)/gm), +].map((row) => BigInt(`0x${row[1]}`)); +assert.ok(loadAddresses.length > 0, "Expected an ELF load mapping"); +const lowestLoad = loadAddresses.reduce((left, right) => + left < right ? left : right, +); +await writeFile( + join(values.output, "readelf-program-headers.txt"), + programHeaders, +); +const isolatedHome = await mkdtemp(join(tmpdir(), "rea-load-image-verifier-")); +const transport = new StdioClientTransport({ + command: process.execPath, + args: [join(values.runtime, "scripts/rea.mjs"), "mcp"], + cwd: values.runtime, + env: { + HOME: isolatedHome, + 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", +}); +const serverLog = createWriteStream(join(values.output, "server.log")); +const serverLogFinished = finished(serverLog); +serverLogFinished.catch(() => {}); +transport.stderr?.pipe(serverLog); +const client = new Client({ name: "native-load-image-views", version: "1" }); +const transcript = []; +const report = { + mode: values.mode, + runtime: values.runtime, + revision: revision.trim(), + dirty: workingState.length > 0, + artifact: { path: values.target, sha256: targetSha256 }, + oracle: { + command: ["readelf", "-Wr", values.target], + sha256: hash(oracle), + relocation_count: relocationOffsets.length, + }, + question: + "Report retained load-image verification status, collection counts, and the first four relocation addresses/statuses with source linkage.", + calls: [], + runs: [], +}; +const call = createRetainedQueryCaller(client, (receipt, event) => { + report.calls.push(receipt); + transcript.push(event); +}); +const result = async (name, input, phase = name) => { + const reply = await call(name, input, phase); + assert.notEqual(reply.isError, true, JSON.stringify(reply)); + assert.ok(reply.structuredContent); + return reply.structuredContent; +}; +const exportedParent = async (id, label) => { + const path = join(values.output, `${label}-evidence.json`); + await result("export_evidence_bundle", { path }, `${label}:export`); + const bundle = JSON.parse(await readFile(path, "utf8")); + const parent = bundle.records.find((record) => record.evidence_id === id); + assert.ok(parent, `Missing retained source ${id}`); + assert.equal(parent.operation, "inspect_native_load_image"); + assert.equal(parent.subject.digest.sha256, targetSha256); + assert.equal(parent.provider.id, "ghidra"); + return parseEvidence(parent); +}; +const selectedRecords = (rows) => + rows.slice(0, 4).map(({ address, status }) => ({ address, status })); +let opened = false; +const started = performance.now(); +try { + await client.connect(transport); + const catalogStart = performance.now(); + const catalog = await client.listTools(); + assert.equal(catalog.nextCursor, undefined); + await writeFile(join(values.output, "catalog.json"), JSON.stringify(catalog)); + report.catalog = { + elapsed_ms: performance.now() - catalogStart, + bytes: Buffer.byteLength(JSON.stringify(catalog)), + sha256: hash(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), + }); + await result("open_binary", { path: values.target, provider_id: "ghidra" }); + opened = true; + for (const temperature of ["cold", "warm"]) { + const runStart = performance.now(); + const reply = await call( + "inspect_native_load_image", + values.mode === "summary" ? { detail: "summary" } : {}, + temperature, + ); + let parent; + let delivered; + let id; + if (reply.isError) { + assert.equal(values.mode, "complete"); + const text = reply.content.find((item) => item.type === "text"); + assert.ok(text); + const error = JSON.parse(text.text).error; + assert.equal(error.code, "resource_constraint"); + assert.equal(error.details.reported_limits.boundary, "mcp-response"); + id = error.details.reported_limits.evidence_reference.evidence_id; + parent = await exportedParent(id, temperature); + delivered = parent.normalized_result; + } else { + assert.ok(reply.structuredContent); + delivered = reply.structuredContent.normalized_result; + id = + values.mode === "summary" + ? delivered.parent_evidence_id + : reply.structuredContent.evidence_id; + parent = await exportedParent(id, temperature); + if (values.mode === "complete") + assert.deepEqual(parent, reply.structuredContent); + } + const source = parent.normalized_result; + const observed = source.observations; + // Every file-table coordinate must survive, including repeated coordinates. + // Provider-added observations are retained separately; this oracle does not + // independently establish why the provider created them. + const base = BigInt(observed.image_base.split(":").at(-1)); + const counts = new Map(); + for (const row of observed.relocations) { + const key = (BigInt(row.address.split(":").at(-1)) - base).toString(16); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + for (const offset of relocationOffsets) { + const key = (BigInt(`0x${offset}`) - lowestLoad).toString(16); + const remaining = counts.get(key) ?? 0; + assert.ok(remaining > 0, `Missing ELF relocation coordinate 0x${key}`); + counts.set(key, remaining - 1); + } + const providerOnly = [...counts] + .filter(([, count]) => count > 0) + .map(([relative_address, count]) => ({ + relative_address: `0x${relative_address}`, + count, + })); + const facts = { + observation_sha256: hash(JSON.stringify(observed)), + verification: { + status: source.status, + ...(source.status === "unsupported" + ? { reason: source.reason } + : { format: source.format }), + }, + counts: { + source_files: observed.source_files.length, + mappings: observed.mappings.length, + relocations: observed.relocations.length, + entry_points: observed.entry_points.length, + entry_context: observed.entry_context.length, + metadata_recovery: observed.metadata_recovery?.length ?? null, + checks: source.checks?.length ?? null, + sections: source.sections?.length ?? null, + }, + records: selectedRecords(observed.relocations), + }; + if (values.mode === "summary") { + assert.equal(delivered.parent_operation, parent.operation); + assert.equal(delivered.parent_digest, parent.evidence_id.slice(3)); + assert.equal(delivered.summary.verification.status, source.status); + assert.deepEqual(delivered.summary.counts, facts.counts); + assert.ok(!Object.hasOwn(delivered.summary.observation, "relocations")); + const selected = await result( + "inspect_analysis_view", + { + source: { kind: "retained-evidence", evidence_id: id }, + view: { + kind: "native-load-image", + collection: "relocations", + offset: 0, + limit: 4, + fields: ["address", "status"], + }, + }, + `${temperature}:selected-relocations`, + ); + assert.deepEqual( + selected.normalized_result.items.rows, + facts.records.map((record, source_index) => ({ source_index, record })), + ); + assert.equal( + selected.normalized_result.verification_status, + source.status, + ); + assert.equal( + selected.normalized_result.coverage.total, + observed.relocations.length, + ); + const tail = await result( + "inspect_analysis_view", + { + source: { kind: "retained-evidence", evidence_id: id }, + view: { + kind: "native-load-image", + collection: "relocations", + indices: [observed.relocations.length - 1], + fields: ["address", "original_bytes_hex", "memory_bytes_hex"], + }, + }, + `${temperature}:tail-relocation`, + ); + const last = observed.relocations.at(-1); + assert.deepEqual(tail.normalized_result.items.rows, [ + { + source_index: observed.relocations.length - 1, + record: { + address: last.address, + original_bytes_hex: last.original_bytes_hex, + memory_bytes_hex: last.memory_bytes_hex, + }, + }, + ]); + const checks = await result( + "inspect_analysis_view", + { + source: { kind: "retained-evidence", evidence_id: id }, + view: { kind: "native-load-image", collection: "checks", limit: 1 }, + }, + `${temperature}:verification-checks`, + ); + assert.equal(source.status, "unsupported"); + assert.equal(checks.normalized_result.items.rows, null); + } + assert.deepEqual(parent.parameters, {}); + await writeFile( + join(values.output, `${temperature}-facts.json`), + JSON.stringify(facts), + ); + if (values.control) + assert.deepEqual( + facts, + JSON.parse( + await readFile( + join(values.control, `${temperature}-facts.json`), + "utf8", + ), + ), + ); + report.runs.push({ + temperature, + parent_evidence_id: id, + source_provider: parent.provider, + source_analysis_profile_digest: parent.analysis_profile?.digest ?? null, + reply_refused: reply.isError === true, + complete_evidence_bytes: Buffer.byteLength(JSON.stringify(parent)), + facts_sha256: hash(JSON.stringify(facts)), + provider_only_relocation_coordinates: providerOnly, + elapsed_ms: performance.now() - runStart, + }); + } + assert.equal(hash(await readFile(values.target)), targetSha256); + report.status = "passed"; +} catch (cause) { + report.status = "failed"; + report.failure = String(cause?.stack ?? cause); + process.exitCode = 1; +} finally { + if (opened) { + try { + await result("close_binary", {}); + } catch (cause) { + report.status = "failed"; + report.close_failure = String(cause); + process.exitCode = 1; + } + } + report.elapsed_before_finalization_ms = performance.now() - started; + if ( + !(await finalizeRetainedQueryReceipt({ + report, + client, + transport, + account: isolatedHome, + serverLog, + serverLogFinished, + output: values.output, + transcript, + })) + ) + process.exitCode = 1; +} diff --git a/src/application/analysisView/NativeLoadImageViews.test.ts b/src/application/analysisView/NativeLoadImageViews.test.ts new file mode 100644 index 0000000000..fe99d3f719 --- /dev/null +++ b/src/application/analysisView/NativeLoadImageViews.test.ts @@ -0,0 +1,320 @@ +import { expect, it } from "vitest"; +import { createEvidence } from "../../domain/evidence.js"; +import { + analysisViewResultSchema, + inspectAnalysisViewInputSchema, +} from "../../domain/analysisView/analysisView.js"; +import { projectNativeLoadImageView } from "../../domain/analysisView/nativeLoadImageView.js"; +import type { NativeLoadImage } from "../../domain/native/nativeLoadImage.js"; +import { + nativeDosLoadImageFixture, + nativeLoadImageObservationFixture, +} from "../../../tests/fixtures/nativeLoadImage.js"; +import { inspectAnalysisView } from "./AnalysisViewService.js"; + +const source = (): Extract => ({ + status: "unsupported", + reason: + "ELF load observations have no independent verification in this fixture.", + observations: { + ...nativeLoadImageObservationFixture(), + executable_format: "Executable and Linking Format (ELF)", + relocations: [ + { + address: "ram:0x0010", + segment: null, + offset: null, + status: "APPLIED", + type: 8, + values: [16], + original_bytes_hex: "0000", + memory_bytes_hex: "1000", + }, + { + address: "ram:0x0010", + segment: null, + offset: null, + status: "UNSUPPORTED", + type: 9, + values: [], + original_bytes_hex: null, + memory_bytes_hex: null, + }, + ], + }, + limitations: [ + "Load observations do not establish runtime relocation behavior.", + ], +}); +const evidence = (report: NativeLoadImage) => + createEvidence( + { path: "/selected/fixture", sha256: "a".repeat(64), format: "elf" }, + { id: "ghidra", name: "Ghidra", version: "12.1.4" }, + { + operation: "inspect_native_load_image", + parameters: {}, + result: report, + limitations: ["Provider observation remains historical."], + }, + { immutable: true }, + ); +const view = async (report: NativeLoadImage, request: unknown) => { + const result = await inspectAnalysisView({ + source: { kind: "inline", evidence: evidence(report) }, + view: request, + }); + if (!result.ok) throw result.error; + return analysisViewResultSchema.parse(result.value.normalized_result); +}; + +it("summarizes measured ELF facts without upgrading unsupported verification", async () => { + const report = source(); + const parent = evidence(report); + const summary = await view(report, { kind: "summary" }); + expect(summary).toMatchObject({ + parent_evidence_id: parent.evidence_id, + parent_operation: "inspect_native_load_image", + source_provider: parent.provider, + source_analysis_profile_digest: null, + source_parameters: {}, + artifact: { path: "/selected/fixture", sha256: "a".repeat(64) }, + summary: { + verification: { status: "unsupported", reason: report.reason }, + observation: { + executable_format: "Executable and Linking Format (ELF)", + }, + counts: { + relocations: 2, + checks: null, + sections: null, + metadata_recovery: null, + }, + }, + limitations: expect.arrayContaining([ + ...report.limitations, + "Provider observation remains historical.", + ]), + unknowns: expect.arrayContaining([report.reason]), + }); + expect(JSON.stringify(summary)).not.toContain("memory_bytes_hex"); +}); + +it("preserves same-address alternatives, padding, nulls and byte facts over pages", async () => { + const report = source(); + const records = []; + for (let offset = 0; offset < 2; offset++) { + const page = await view(report, { + kind: "native-load-image", + collection: "relocations", + fields: [ + "address", + "segment", + "offset", + "status", + "type", + "values", + "original_bytes_hex", + "memory_bytes_hex", + ], + offset, + limit: 1, + }); + if (page.kind !== "native-load-image") throw new Error("Expected page"); + expect(page.verification_status).toBe("unsupported"); + expect( + analysisViewResultSchema.safeParse({ + ...page, + items: { + ...page.items, + rows: null, + }, + }).success, + ).toBe(false); + expect(page.items.rows?.[0]?.source_index).toBe(offset); + expect(page.coverage).toMatchObject({ + total: 2, + next_offset: offset === 0 ? 1 : null, + exhausted: offset === 1, + }); + records.push(...(page.items.rows ?? []).map((row) => row.record)); + } + expect(records).toEqual(report.observations.relocations); +}); + +it("filters indices before paging and projects only selected fields", async () => { + const page = await view(source(), { + kind: "native-load-image", + collection: "relocations", + indices: [1, 400, 0, 1], + offset: 1, + fields: ["address", "status", "status"], + limit: 1, + }); + expect(page).toMatchObject({ + items: { + collection: "relocations", + rows: [ + { + source_index: 1, + record: { address: "ram:0x0010", status: "UNSUPPORTED" }, + }, + ], + }, + coverage: { examined: 1, total: 2, next_offset: null, exhausted: true }, + }); + if (page.kind !== "native-load-image") throw new Error("Expected page"); + expect(page.items.rows?.[0]?.record).toEqual({ + address: "ram:0x0010", + status: "UNSUPPORTED", + }); +}); + +it.each(["checks", "sections"])( + "keeps unrecorded or inapplicable %s null rather than inventing empty observations", + async (collection) => { + expect( + await view(source(), { kind: "native-load-image", collection }), + ).toMatchObject({ + items: { collection, rows: null }, + unknowns: expect.arrayContaining([ + "Null collection counts or rows mean not recorded or not applicable, not observed absence.", + ]), + }); + }, +); + +it("distinguishes an explicitly recorded empty metadata collection and exhausted selection", async () => { + const report = source(); + report.observations.metadata_recovery = []; + expect(await view(report, { kind: "summary" })).toMatchObject({ + summary: { counts: { metadata_recovery: 0 } }, + }); + for (const request of [ + { collection: "entry_context" }, + { collection: "relocations", indices: [] }, + { collection: "relocations", offset: 400 }, + ]) + expect( + await view(report, { kind: "native-load-image", ...request }), + ).toMatchObject({ + items: { rows: [] }, + coverage: { status: "empty", exhausted: true, next_offset: null }, + }); +}); + +it("preserves recorded DOS verification details and ordered mismatch checks", async () => { + const report = nativeDosLoadImageFixture(); + const summary = await view(report, { kind: "summary" }); + expect(summary).toMatchObject({ + summary: { + verification: { + status: "verified", + format: "dos-mz", + target_sha256: report.target_sha256, + }, + counts: { checks: report.checks.length }, + }, + }); + const mismatch = { + ...report, + status: "mismatch" as const, + checks: [ + { + ...report.checks[0]!, + matched: false, + expected: { original: 1 }, + observed: null, + }, + ], + }; + expect( + await view(mismatch, { + kind: "native-load-image", + collection: "checks", + fields: [ + "name", + "matched", + "expected", + "observed", + "file_offset", + "address", + ], + }), + ).toMatchObject({ + items: { rows: [{ source_index: 0, record: mismatch.checks[0] }] }, + }); + expect(await view(mismatch, { kind: "summary" })).toMatchObject({ + summary: { verification: { status: "mismatch" } }, + }); +}); + +it("keeps byte and potentially large comparison payloads opt-in while retaining full source facts", async () => { + const relocations = await view(source(), { + kind: "native-load-image", + collection: "relocations", + }); + if (relocations.kind !== "native-load-image") + throw new Error("Expected page"); + expect(relocations.items.rows?.[0]?.record).toEqual({ + address: "ram:0x0010", + segment: null, + offset: null, + status: "APPLIED", + type: 8, + values: [16], + }); + const report = nativeDosLoadImageFixture(); + const body = { complete_comparison: "retained proof body".repeat(128) }; + report.checks[0]!.expected = body; + const checks = await view(report, { + kind: "native-load-image", + collection: "checks", + }); + if (checks.kind !== "native-load-image") throw new Error("Expected page"); + expect(checks.items.rows?.[0]?.record).toEqual({ + name: "original_file_sha256", + matched: true, + file_offset: null, + address: null, + }); + expect( + await view(report, { + kind: "native-load-image", + collection: "checks", + fields: ["expected"], + }), + ).toMatchObject({ + items: { rows: [{ source_index: 0, record: { expected: body } }] }, + }); + expect(report.checks[0]!.expected).toEqual(body); +}); + +it("rejects collection-inapplicable fields and malformed sibling facts even for an empty selection", async () => { + expect( + inspectAnalysisViewInputSchema.safeParse({ + source: { kind: "inline", evidence: evidence(source()) }, + view: { + kind: "native-load-image", + collection: "entry_points", + fields: ["original_bytes_hex"], + }, + }).success, + ).toBe(false); + const report = source(); + const parent = { + evidenceId: "ev_" + "b".repeat(64), + operation: "inspect_native_load_image", + normalizedResult: report, + limitations: [], + }; + const request = { + kind: "native-load-image" as const, + collection: "relocations" as const, + indices: [], + offset: 0, + limit: 1, + }; + expect(projectNativeLoadImageView(parent, request).ok).toBe(true); + Reflect.set(report.observations.relocations[1]!, "status", ""); + expect(projectNativeLoadImageView(parent, request).ok).toBe(false); +}); diff --git a/src/cli/analysisEvidenceDelivery.ts b/src/cli/analysisEvidenceDelivery.ts index f000ac3830..1e46221764 100644 --- a/src/cli/analysisEvidenceDelivery.ts +++ b/src/cli/analysisEvidenceDelivery.ts @@ -1,7 +1,11 @@ import { readArtifactInspectionSources } from "../domain/artifactInspection.js"; import type { AnalysisViewRequest } from "../domain/analysisView/analysisView.js"; import { z } from "incur"; -import { parseEvidence, type Evidence } from "../domain/evidence.js"; +import { + parseEvidence, + withEvidenceCallerInputPath, + type Evidence, +} from "../domain/evidence.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; @@ -180,6 +184,12 @@ export const publishCliAnalysisEvidence = async ( return projectAnalysisError( delivered.error.retainPartialObservation(complete), ); + // This projection answers the same direct invocation as its parent. Later + // inspect_analysis_view calls own their own input and do not inherit it. + const selected = + detail === "summary" && complete.caller_input_path !== undefined + ? withEvidenceCallerInputPath(delivered.value, complete.caller_input_path) + : delivered.value; const inspectionSources = complete.operation === "inspect_artifact" ? readArtifactInspectionSources(complete.normalized_result) @@ -199,7 +209,7 @@ export const publishCliAnalysisEvidence = async ( ).values(), ]), complete, - delivered: delivered.value, + delivered: selected, outputPath: absoluteCallerPath(evidenceOutput), operation, signal, diff --git a/src/cli/coreNativeCommands.ts b/src/cli/coreNativeCommands.ts index 6d033aad27..fdec5c415e 100644 --- a/src/cli/coreNativeCommands.ts +++ b/src/cli/coreNativeCommands.ts @@ -16,6 +16,13 @@ import { snapshotSelectionOption, } from "./options.js"; import type { CliInstance } from "./types.js"; +import { withCommandCancellation } from "./commandCancellation.js"; +import { + analysisEvidenceDeliveryOptions, + cliEvidenceDeliveryInputError, + publishCliAnalysisEvidence, +} from "./analysisEvidenceDelivery.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; /** Register native load-image, memory, and annotation CLI commands. */ export const registerCoreNativeCommands = ( @@ -66,19 +73,32 @@ export const registerCoreNativeCommands = ( }); cli.command(CLI_COMMANDS.inspectNativeLoadImage, { description: - "Verify loaded native bytes, source mappings, relocations and entry", + "Inspect native load observations and supported snapshot verification", args: z.object({ path: z.string().describe("Local executable path") }), options: z.object({ + ...analysisEvidenceDeliveryOptions, ...nativeOpenOptionFields, }), run: ({ args, options }) => - logCliCommand(logger, CLI_COMMANDS.inspectNativeLoadImage, () => - runDirectAnalysis( - args.path, - "inspect_native_load_image", - {}, - directAnalysisOptionsFromCli(logger, options), - ), + withCommandCancellation((signal) => + logCliCommand(logger, CLI_COMMANDS.inspectNativeLoadImage, async () => { + const delivery = { + providerId: "auto", + operation: "inspect_native_load_image", + signal, + detail: options.detail, + evidenceOutput: options.evidence_output, + }; + const invalid = cliEvidenceDeliveryInputError(delivery); + if (invalid !== undefined) return projectAnalysisError(invalid); + const complete = await runDirectAnalysis( + args.path, + "inspect_native_load_image", + {}, + { ...directAnalysisOptionsFromCli(logger, options), signal }, + ); + return publishCliAnalysisEvidence(complete, delivery); + }), ), }); cli.command(CLI_COMMANDS.readBytes, { diff --git a/src/contracts/analysisViewToolContracts.ts b/src/contracts/analysisViewToolContracts.ts index 7de273e6bf..dae34d172c 100644 --- a/src/contracts/analysisViewToolContracts.ts +++ b/src/contracts/analysisViewToolContracts.ts @@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_analysis_view"), kind: "application", description: - 'Use javascript-routes collections to query route patterns, source-linked occurrences, source status, unknowns or summary counts from retained JavaScript application or web-bundle Evidence. Inspect selected views of supported completed Evidence without repeating analysis. Choose a view compatible with the parent operation described below. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Native export tables use kind native-exports, collection exports/issues, exact names or original indices, selected fields and matching-row pagination; parent Evidence plus source_index identifies each row. Source coverage stays separate from page coverage. BEAM views use kind beam, collections atoms/exports/imports/lambdas/chunks, exact names/indices/fields and matching-row pages; import modules intersects function names. source_index identifies original parent-and-collection rows, preserving every arity and raw atom index. beam.counts preserves null missing tables versus decoded empty tables; runtime absence remains unknown. PYC views use kind python-pyc, collection code_objects, names/indices/fields and paging; source_index scopes every same-name alternative to the parent, nested_code keeps code-constant references, and pyc.coverage preserves source coverage. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent also supports summary and browser-scenario steps/events/transactions/text pages, preserving step_indices, event_kinds, transaction_ids and selected artifacts. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized reads that are not entities and have one initial request line (item-shaped paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state (a list also learns the filters, search, sort and paging its recordings verify, and answers new values of them from the state), and every other read tool is a recorded view with a reason. It also reports a write plan per state-changing tool (create, update, delete or document, built from its first successful call) and write_clock; a plan is served only when replaying the capture shows a later read confirming its effect and none contradicting it. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. The environment-coverage view, view {kind: "environment-coverage", template_rules: [...]}, summarizes that state without its entities: reads.state and reads.recorded count read tools answered from the state and replayed (recorded_reasons per view reason), writes.served lists the served write tools, and writes.withheld lists each other write tool with its reason, plan record, contradicting read and a hint about what to record next; it refuses what the environment-state view refuses. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. A completed Evidence JSON file on this host uses {kind: "evidence-file", path: ""}: REA reads and authenticates it, then projects it like retained Evidence, so a large capture is bounded by the file reader rather than by inline-input accounting. The file may also be an Evidence bundle written by CLI --evidence_output or export_evidence_bundle; select evidence_id when it contains more than one record. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions. Occurrences accept exact logical_paths and occurrence_ids; nodes accept artifact_ids. Batch filters intersect (omitted means all available, [] means none); fields selects columns while retaining identity links. source_complete describes retained graph membership; source_coverage describes producer coverage, including unknown encrypted PCK directories. Page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings and carry the source selection and coverage. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Protobuf views use kind protobuf, collection bodies/frames/fields, with record_ordinals, body_pointers and fields; field pages also select frame_indices, field_ids, field_numbers or schema_names. Byte identity, offset basis and body status remain present. Text and schema_value are opt-in page columns. APK signing views use kind apk-signature and collection signers/digests/lineage, with ids, signer_ids, schemes, fields and paging. Identity links remain when fields is empty. Declared identities and digest matches do not establish signature verification or authenticity. Model views use kind model-artifact and collection metadata (keys), tensors (names/types) or types (derived totals), with indices, fields and paging. Header SHA-256 scopes declarations; full-file identity remains unknown unless full hashing was selected. Arrays retain typed eight-element previews with explicit omissions. IL2CPP metadata views use kind il2cpp-metadata and collection sections/images/types/methods/fields/properties/events/parameters/string_literals/code_gen_modules/method_addresses/return_types/field_types/parameter_types/generic_method_instances/generic_method_issues. Select indices, fields and pages; sections/images/types/methods/fields/properties/events/parameters/return_types/field_types/parameter_types/generic_method_instances accept exact names (native type names match retained rendered values, preserving same-name rows; null names never match), types also accept image_indices/namespaces/declaring_types, and methods/members accept declaring_types. Parameters and parameter_types accept method_indices; code_gen_modules accept image_indices; method_addresses and generic_method_instances accept addresses/states (generic_method_instances also method_indices), preserving every folded alternative; generic_method_issues accept rows. Row index is scoped to the metadata digest; native pages add binary_source. Uncollected native tables have null counts and remain unavailable. Desktop directory views use kind desktop-application and collection files/omissions/candidates/components/next_probes, with fields and pagination; files and omissions accept exact paths or their stable IDs, and candidates accept families. Source coverage and stable row IDs remain available. Native inventory Evidence from list_names, list/search_procedures or list/search_strings uses view: {kind: "native-inventory", offset: 0, limit: 64, fields: ["value"]}. Rows keep source_index and address even with selected fields. Summary counts describe retained rows; provider discovery coverage remains unknown. Native function Evidence accepts kind summary for identity, availability and facet counts, or kind native for selected facts: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', + 'Use javascript-routes collections to query route patterns, source-linked occurrences, source status, unknowns or summary counts from retained JavaScript application or web-bundle Evidence. Inspect selected views of supported completed Evidence without repeating analysis. Choose a view compatible with the parent operation described below. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Native export tables use kind native-exports, collection exports/issues, exact names or original indices, selected fields and matching-row pagination; parent Evidence plus source_index identifies each row. Source coverage stays separate from page coverage. BEAM views use kind beam, collections atoms/exports/imports/lambdas/chunks, exact names/indices/fields and matching-row pages; import modules intersects function names. source_index identifies original parent-and-collection rows, preserving every arity and raw atom index. beam.counts preserves null missing tables versus decoded empty tables; runtime absence remains unknown. PYC views use kind python-pyc, collection code_objects, names/indices/fields and paging; source_index scopes every same-name alternative to the parent, nested_code keeps code-constant references, and pyc.coverage preserves source coverage. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent also supports summary and browser-scenario steps/events/transactions/text pages, preserving step_indices, event_kinds, transaction_ids and selected artifacts. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized reads that are not entities and have one initial request line (item-shaped paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state (a list also learns the filters, search, sort and paging its recordings verify, and answers new values of them from the state), and every other read tool is a recorded view with a reason. It also reports a write plan per state-changing tool (create, update, delete or document, built from its first successful call) and write_clock; a plan is served only when replaying the capture shows a later read confirming its effect and none contradicting it. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. The environment-coverage view, view {kind: "environment-coverage", template_rules: [...]}, summarizes that state without its entities: reads.state and reads.recorded count read tools answered from the state and replayed (recorded_reasons per view reason), writes.served lists the served write tools, and writes.withheld lists each other write tool with its reason, plan record, contradicting read and a hint about what to record next; it refuses what the environment-state view refuses. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. A completed Evidence JSON file on this host uses {kind: "evidence-file", path: ""}: REA reads and authenticates it, then projects it like retained Evidence, so a large capture is bounded by the file reader rather than by inline-input accounting. The file may also be an Evidence bundle written by CLI --evidence_output or export_evidence_bundle; select evidence_id when it contains more than one record. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions. Occurrences accept exact logical_paths and occurrence_ids; nodes accept artifact_ids. Batch filters intersect (omitted means all available, [] means none); fields selects columns while retaining identity links. source_complete describes retained graph membership; source_coverage describes producer coverage, including unknown encrypted PCK directories. Page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings and carry the source selection and coverage. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Protobuf views use kind protobuf, collection bodies/frames/fields, with record_ordinals, body_pointers and fields; field pages also select frame_indices, field_ids, field_numbers or schema_names. Byte identity, offset basis and body status remain present. Text and schema_value are opt-in page columns. APK signing views use kind apk-signature and collection signers/digests/lineage, with ids, signer_ids, schemes, fields and paging. Identity links remain when fields is empty. Declared identities and digest matches do not establish signature verification or authenticity. Model views use kind model-artifact and collection metadata (keys), tensors (names/types) or types (derived totals), with indices, fields and paging. Header SHA-256 scopes declarations; full-file identity remains unknown unless full hashing was selected. Arrays retain typed eight-element previews with explicit omissions. IL2CPP metadata views use kind il2cpp-metadata and collection sections/images/types/methods/fields/properties/events/parameters/string_literals/code_gen_modules/method_addresses/return_types/field_types/parameter_types/generic_method_instances/generic_method_issues. Select indices, fields and pages; sections/images/types/methods/fields/properties/events/parameters/return_types/field_types/parameter_types/generic_method_instances accept exact names (native type names match retained rendered values, preserving same-name rows; null names never match), types also accept image_indices/namespaces/declaring_types, and methods/members accept declaring_types. Parameters and parameter_types accept method_indices; code_gen_modules accept image_indices; method_addresses and generic_method_instances accept addresses/states (generic_method_instances also method_indices), preserving every folded alternative; generic_method_issues accept rows. Row index is scoped to the metadata digest; native pages add binary_source. Uncollected native tables have null counts and remain unavailable. Desktop directory views use kind desktop-application and collection files/omissions/candidates/components/next_probes, with fields and pagination; files and omissions accept exact paths or their stable IDs, and candidates accept families. Source coverage and stable row IDs remain available. Native load-image Evidence uses kind native-load-image with collection source_files/mappings/relocations/entry_points/entry_context/checks/sections, original indices, collection-specific fields and offset/limit paging. Null collections remain unrecorded or inapplicable; pages preserve unsupported verification. Native inventory Evidence from list_names, list/search_procedures or list/search_strings uses view: {kind: "native-inventory", offset: 0, limit: 64, fields: ["value"]}. Rows keep source_index and address even with selected fields. Summary counts describe retained rows; provider discovery coverage remains unknown. Native function Evidence accepts kind summary for identity, availability and facet counts, or kind native for selected facts: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', inputSchema: inspectAnalysisViewInputSchema, outputSchema: evidenceResultOf(analysisViewResultSchema), examples: [ diff --git a/src/contracts/officialToolContracts.ts b/src/contracts/officialToolContracts.ts index 3dc834441b..b92973306c 100644 --- a/src/contracts/officialToolContracts.ts +++ b/src/contracts/officialToolContracts.ts @@ -49,8 +49,8 @@ export const OFFICIAL_TOOL_CONTRACTS = [ ), official( "inspect_native_load_image", - "Verify the provider's loaded DOS MZ or explicitly selected COM image against its immutable target snapshot: complete original/modified source-byte digests, header/module file mappings and memory digests, relocations and external entry. Returns measured observations and independent checks with mismatch coordinates. COM verification includes its imposed entry register context and does not claim PSP/runtime coverage. Overlay and uninitialized coverage remain explicit; other formats return unsupported. Does not execute or modify the target.", - z.strictObject({}), + "Inspect the provider's measured source digests, memory mappings, relocations, entry points and entry register context. Independent snapshot verification supports DOS MZ, explicitly selected COM, raw binaries and GameCube DOL; other formats retain their observations with unsupported verification. Overlay and uninitialized coverage remain explicit. Select detail summary for verification state and counts, retaining complete Evidence for inspect_analysis_view kind native-load-image: collection source_files, mappings, relocations, entry_points, entry_context, checks or sections, with indices, fields and offset/limit paging. Relocation bytes and check expected/observed payloads require explicit fields; defaults keep metadata. Null collections are unrecorded or inapplicable. Does not execute or modify the target.", + z.strictObject({ detail: analysisDetailSchema }), ), official( "inspect_native_data_type", diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index d062fa4ffc..c8d0dfb129 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -5,6 +5,7 @@ import { nativeFunctionMatchingSummaryViewResultSchema, nativeFunctionSummaryViewResultSchema, nativeInventorySummaryViewResultSchema, + nativeLoadImageSummaryViewResultSchema, artifactInventorySummaryViewResultSchema, interfaceBuilderSummaryViewResultSchema, machoSummaryViewResultSchema, @@ -130,7 +131,10 @@ const bookmarkFacetSchema = z.discriminatedUnion("state", [ export const officialOutputSchemas: Readonly> = { search_bytes: evidenceResultOf(byteSearchResultSchema), annotate_native_function: evidenceResultOf(nativeFunctionAnnotationsSchema), - inspect_native_load_image: evidenceResultOf(nativeLoadImageSchema), + inspect_native_load_image: analysisDetailOutputSchemaOf( + nativeLoadImageSchema, + nativeLoadImageSummaryViewResultSchema, + ), inspect_native_data_type: evidenceResultOf(nativeDataTypeSchema), inspect_native_instruction: evidenceResultOf(nativeInstructionSchema), resolve_native_call_targets: evidenceResultOf(nativeCallTargetsSchema), diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index d9d2a8b13d..f43bcd0a61 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -111,10 +111,10 @@ import { import { nativeInventoryViewRequestSchema, nativeInventoryViewRowSchema, - nativeInventoryViewContext, nativeInventorySummarySchema, projectNativeInventoryView, } from "./nativeInventoryView.js"; +import { nativeViewSourceContext } from "./nativeViewContext.js"; import { NATIVE_INVENTORY_OPERATIONS, nativeInventoryOperationSchema, @@ -129,6 +129,12 @@ import { browserScenarioViewRequestSchema, } from "./browserScenarioViewSchemas.js"; import { projectBrowserScenarioView } from "./browserScenarioView.js"; +import { + nativeLoadImageViewRequestSchema, + nativeLoadImageViewItemsSchema, + nativeLoadImageSummarySchema, + projectNativeLoadImageView, +} from "./nativeLoadImageView.js"; import { desktopViewRequestSchema, desktopViewItemsSchema, @@ -387,6 +393,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [ javascriptFactsViewRequestSchema, nativeViewSchema, nativeInventoryViewRequestSchema, + nativeLoadImageViewRequestSchema, artifactInventoryViewRequestSchema, wasmViewRequestSchema, machoViewRequestSchema, @@ -839,12 +846,21 @@ export const il2cppMetadataSummaryViewResultSchema = z.strictObject({ /** Exact inventory summary with the original observation's provider and query. */ export const nativeInventorySummaryViewResultSchema = z.strictObject({ ...summaryViewResultBase, - ...nativeInventoryViewContext, + ...nativeViewSourceContext, parent_operation: nativeInventoryOperationSchema, artifact: artifactSchema.nullable(), summary: nativeInventorySummarySchema, }); +/** Compact provider load observations retain the independent verification state. */ +export const nativeLoadImageSummaryViewResultSchema = z.strictObject({ + ...summaryViewResultBase, + ...nativeViewSourceContext, + artifact: artifactSchema.nullable(), + parent_operation: z.literal("inspect_native_load_image"), + summary: nativeLoadImageSummarySchema, +}); + /** Compact native matching discovery retains the complete matching report. */ export const nativeFunctionMatchingSummaryViewResultSchema = z.strictObject({ ...summaryViewResultBase, @@ -1047,6 +1063,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ modelArtifactSummaryViewResultSchema, gpuCodeObjectsSummaryViewResultSchema, nativeInventorySummaryViewResultSchema, + nativeLoadImageSummaryViewResultSchema, nativeFunctionMatchingSummaryViewResultSchema, nativeFunctionSummaryViewResultSchema, sourceToBundleSummaryViewResultSchema, @@ -1071,13 +1088,23 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ }), z.strictObject({ ...viewResultBase, - ...nativeInventoryViewContext, + ...nativeViewSourceContext, kind: z.literal("native-inventory"), parent_operation: nativeInventoryOperationSchema, artifact: artifactSchema.nullable(), view: nativeInventoryViewRequestSchema, rows: z.array(nativeInventoryViewRowSchema), }), + z.strictObject({ + ...viewResultBase, + kind: z.literal("native-load-image"), + ...nativeViewSourceContext, + verification_status: z.enum(["unsupported", "verified", "mismatch"]), + parent_operation: z.literal("inspect_native_load_image"), + artifact: artifactSchema.nullable(), + view: nativeLoadImageViewRequestSchema, + items: nativeLoadImageViewItemsSchema, + }), z.strictObject({ kind: z.literal("protobuf"), view: protobufViewRequestSchema, @@ -1338,6 +1365,7 @@ const retainedViewProjectors = new Map([ ["inspect_erlang_beam", projectErlangBeamView], ["inspect_devicetree", projectDevicetreeView], ["inspect_native_exports", projectNativeExportsView], + ["inspect_native_load_image", projectNativeLoadImageView], ["inspect_python_pyc", projectPythonPycView], ["frida_session_status", projectFridaSessionStatusView], ["gdb_console", projectCommandObservationView], diff --git a/src/domain/analysisView/nativeInventoryView.ts b/src/domain/analysisView/nativeInventoryView.ts index 73ba8bd6e0..979e1856e8 100644 --- a/src/domain/analysisView/nativeInventoryView.ts +++ b/src/domain/analysisView/nativeInventoryView.ts @@ -7,8 +7,6 @@ import { isNativeInventoryOperation, nativeInventoryRowsSchemas, } from "../native/nativeInventory.js"; -import { digestSchema } from "../digests.js"; -import { providerSchema } from "../evidence.js"; import { err, ok, type Result } from "../result.js"; import type { AnalysisError } from "../analysisErrorBase.js"; import type { @@ -53,12 +51,6 @@ export const nativeInventoryViewRowSchema = z.strictObject({ record: inventoryRecordSchema.partial().required({ address: true }), }); -export const nativeInventoryViewContext = { - source_provider: providerSchema.nullable(), - source_analysis_profile_digest: digestSchema.nullable(), - source_parameters: jsonValueSchema, -}; - export const nativeInventorySummarySchema = z.strictObject({ collection: z.enum(["procedures", "strings", "names"]), row_count: z.number().int().nonnegative(), diff --git a/src/domain/analysisView/nativeLoadImageView.ts b/src/domain/analysisView/nativeLoadImageView.ts new file mode 100644 index 0000000000..21744905c7 --- /dev/null +++ b/src/domain/analysisView/nativeLoadImageView.ts @@ -0,0 +1,301 @@ +import { z } from "zod"; +import { + nativeLoadImageCheckSchema, + nativeLoadImageObservationSchema, + nativeLoadImageSchema, + nativeUnsupportedLoadImageSchema, + nativeDosLoadImageSchema, + nativeRawLoadImageSchema, + nativeDolLoadImageSchema, +} from "../native/nativeLoadImage.js"; +import { nativeAddressObservationSchema } from "../native/nativeAddress.js"; +import { jsonValueSchema } from "../jsonValue.js"; +import { err, ok, type Result } from "../result.js"; +import type { AnalysisError } from "../analysisErrorBase.js"; +import type { + AnalysisViewParent, + AnalysisViewRequest, + UnsignedAnalysisView, +} from "./analysisView.js"; +import { analysisViewInputError } from "./analysisViewErrors.js"; +import { createAnalysisViewResultReader } from "./analysisViewParent.js"; +import { + boundedViewMessages, + completeWithinViewCoverage, + pageViewCoverage, +} from "./analysisViewCoverage.js"; + +const observation = nativeLoadImageObservationSchema.shape; +const rows = { + source_files: observation.source_files.element, + mappings: observation.mappings.element, + relocations: observation.relocations.element, + entry_points: z.strictObject({ address: nativeAddressObservationSchema }), + entry_context: observation.entry_context.element, + checks: nativeLoadImageCheckSchema, + sections: nativeDolLoadImageSchema.shape.sections.element, +}; +const defaultFields: Partial> = { + relocations: rows.relocations + .pick({ + address: true, + segment: true, + offset: true, + status: true, + type: true, + values: true, + }) + .keyof().options, + checks: rows.checks + .pick({ name: true, matched: true, file_offset: true, address: true }) + .keyof().options, +}; +const selection = { + kind: z.literal("native-load-image"), + offset: z.int().nonnegative().default(0), + limit: z.int().positive().default(64), + indices: z.array(z.int().nonnegative()).optional(), +}; +const request = (name: Name) => + z.strictObject({ + ...selection, + collection: z.literal(name), + fields: z + .array(rows[name].keyof()) + .optional() + .describe( + name === "relocations" + ? "Defaults to address, segment, offset, status, type and values. Select original_bytes_hex or memory_bytes_hex explicitly." + : name === "checks" + ? "Defaults to name, matched, file_offset and address. Select expected or observed explicitly; these can contain complete comparison inventories." + : "Omit to return every recorded field in each selected row.", + ), + }); + +/** Pages retain producer order and original indices within the parent Evidence. */ +export const nativeLoadImageViewRequestSchema = z.discriminatedUnion( + "collection", + [ + request("source_files"), + request("mappings"), + request("relocations"), + request("entry_points"), + request("entry_context"), + request("checks"), + request("sections"), + ], +); +const items = (name: Name) => { + const records = z.array( + z.strictObject({ + source_index: z.int().nonnegative(), + record: rows[name].partial(), + }), + ); + return z.strictObject({ + collection: z.literal(name), + rows: + name === "checks" || name === "sections" ? records.nullable() : records, + }); +}; +export const nativeLoadImageViewItemsSchema = z.discriminatedUnion( + "collection", + [ + items("source_files"), + items("mappings"), + items("relocations"), + items("entry_points"), + items("entry_context"), + items("checks"), + items("sections"), + ], +); + +// Keep the format owner's verification states and small identity facts. A +// measured unsupported format must never become a verified empty check set. +export const nativeLoadImageSummarySchema = z.strictObject({ + verification: z.union([ + nativeUnsupportedLoadImageSchema.omit({ + observations: true, + limitations: true, + }), + nativeDosLoadImageSchema.omit({ + observations: true, + limitations: true, + checks: true, + }), + nativeRawLoadImageSchema.omit({ + observations: true, + limitations: true, + checks: true, + }), + nativeDolLoadImageSchema.omit({ + observations: true, + limitations: true, + checks: true, + sections: true, + }), + ]), + observation: nativeLoadImageObservationSchema.pick({ + executable_format: true, + language_id: true, + compiler_spec_id: true, + image_base: true, + default_address_space: true, + }), + counts: z.strictObject({ + source_files: z.int().nonnegative(), + mappings: z.int().nonnegative(), + relocations: z.int().nonnegative(), + entry_points: z.int().nonnegative(), + entry_context: z.int().nonnegative(), + metadata_recovery: z.int().nonnegative().nullable(), + checks: z.int().nonnegative().nullable(), + sections: z.int().nonnegative().nullable(), + }), +}); + +const readReport = createAnalysisViewResultReader(nativeLoadImageSchema); + +type Row = z.output<(typeof rows)[keyof typeof rows]>; +type PageRequest = z.output; + +/** Exact source indices need no inventory scan or persistent lookup index. */ +const selectRows = (source: readonly (Row | string)[], view: PageRequest) => { + const selectedFields = view.fields ?? defaultFields[view.collection]; + const fields = + selectedFields === undefined ? undefined : new Set(selectedFields); + const project = (value: Row | string, source_index: number) => { + const record = typeof value === "string" ? { address: value } : value; + return { + source_index, + record: + fields === undefined + ? record + : Object.fromEntries( + Object.entries(record).filter(([key]) => fields.has(key)), + ), + }; + }; + if (view.indices === undefined) + return { + total: source.length, + rows: source + .slice(view.offset, view.offset + view.limit) + .map((value, index) => project(value, view.offset + index)), + }; + const indices = [...new Set(view.indices)] + .filter((index) => index < source.length) + .sort((left, right) => left - right); + return { + total: indices.length, + // The format owner validated a dense collection; these indices are in range. + rows: indices + .slice(view.offset, view.offset + view.limit) + .map((index) => project(source[index]!, index)), + }; +}; + +/** Query the owner's parsed historical observations without reopening the target. */ +export const projectNativeLoadImageView = ( + parent: AnalysisViewParent, + view: AnalysisViewRequest, +): Result => { + if (view.kind !== "summary" && view.kind !== "native-load-image") + return err( + analysisViewInputError([ + { + path: ["view"], + reason: "invalid_value", + message: + "Native load-image Evidence supports summary and native-load-image views.", + }, + ]), + ); + const parsed = readReport(parent); + if (!parsed.ok) return parsed; + const report = parsed.value; + const observed = report.observations; + const { observations: _, limitations, ...verification } = report; + const { + checks: __, + sections: ___, + ...smallVerification + } = { + checks: undefined, + sections: undefined, + ...verification, + }; + const { + source_files, + mappings, + relocations, + entry_points, + entry_context, + metadata_recovery, + ...identity + } = observed; + const shared = { + parent_evidence_id: parent.evidenceId, + parent_operation: "inspect_native_load_image" as const, + parent_digest: parent.evidenceId.slice(3), + artifact: parent.artifact ?? null, + source_provider: parent.provider ?? null, + source_analysis_profile_digest: parent.analysisProfileDigest ?? null, + source_parameters: jsonValueSchema.parse(parent.parameters ?? null), + limitations: boundedViewMessages( + [...parent.limitations, ...limitations], + parent.evidenceId, + "limitations", + ), + unknowns: [ + "Counts and pages describe retained provider observations, not complete runtime mappings or relocation behavior.", + "Null collection counts or rows mean not recorded or not applicable, not observed absence.", + ...(report.status === "unsupported" ? [report.reason] : []), + ], + }; + if (view.kind === "summary") + return ok({ + ...shared, + kind: "summary", + view, + summary: { + verification: smallVerification, + observation: identity, + counts: { + source_files: source_files.length, + mappings: mappings.length, + relocations: relocations.length, + entry_points: entry_points.length, + entry_context: entry_context.length, + metadata_recovery: metadata_recovery?.length ?? null, + checks: "checks" in report ? report.checks.length : null, + sections: "sections" in report ? report.sections.length : null, + }, + }, + coverage: completeWithinViewCoverage(1, 1), + }); + + const selected = + view.collection === "checks" + ? "checks" in report + ? report.checks + : undefined + : view.collection === "sections" + ? "sections" in report + ? report.sections + : undefined + : observed[view.collection]; + const page = selectRows(selected ?? [], view); + return ok({ + ...shared, + kind: "native-load-image", + verification_status: report.status, + view, + items: nativeLoadImageViewItemsSchema.parse({ + collection: view.collection, + rows: selected === undefined ? null : page.rows, + }), + coverage: pageViewCoverage(view.offset, page.rows.length, page.total), + }); +}; diff --git a/src/domain/analysisView/nativeViewContext.ts b/src/domain/analysisView/nativeViewContext.ts new file mode 100644 index 0000000000..a12972a62f --- /dev/null +++ b/src/domain/analysisView/nativeViewContext.ts @@ -0,0 +1,10 @@ +import { digestSchema } from "../digests.js"; +import { providerSchema } from "../evidence.js"; +import { jsonValueSchema } from "../jsonValue.js"; + +/** Native projections reference the original provider/profile without repeating its full commitment. */ +export const nativeViewSourceContext = { + source_provider: providerSchema.nullable(), + source_analysis_profile_digest: digestSchema.nullable(), + source_parameters: jsonValueSchema, +}; diff --git a/src/domain/native/nativeLoadImage.ts b/src/domain/native/nativeLoadImage.ts index e1005b627b..069ddc2588 100644 --- a/src/domain/native/nativeLoadImage.ts +++ b/src/domain/native/nativeLoadImage.ts @@ -84,74 +84,80 @@ export const nativeLoadImageCheckSchema = z.strictObject({ address: nativeAddressObservationSchema.nullable(), }); -/** Read-only native image verification; verified applies only to declared coverage. */ -export const nativeLoadImageSchema = z.union([ - z.strictObject({ - status: z.literal("unsupported"), - reason: z.string().min(1), - observations: nativeLoadImageObservationSchema, - limitations: z.array(z.string().min(1)), +/** Measured observations when independent format verification is unsupported. */ +export const nativeUnsupportedLoadImageSchema = z.strictObject({ + status: z.literal("unsupported"), + reason: z.string().min(1), + observations: nativeLoadImageObservationSchema, + limitations: z.array(z.string().min(1)), +}); +export const nativeDosLoadImageSchema = z.strictObject({ + status: z.enum(["verified", "mismatch"]), + format: z.enum(["dos-mz", "dos-com"]), + target_sha256: digest, + load_segment: integer.max(0xffff), + header_bytes: integer, + module_bytes: integer, + overlay_bytes: integer, + entry: z.strictObject({ + relative_segment: integer.max(0xffff), + offset: integer.max(0xffff), + linear_address: nativeAddressObservationSchema, }), - z.strictObject({ - status: z.enum(["verified", "mismatch"]), - format: z.enum(["dos-mz", "dos-com"]), - target_sha256: digest, - load_segment: integer.max(0xffff), - header_bytes: integer, - module_bytes: integer, - overlay_bytes: integer, - entry: z.strictObject({ - relative_segment: integer.max(0xffff), - offset: integer.max(0xffff), - linear_address: nativeAddressObservationSchema, - }), - observations: nativeLoadImageObservationSchema, - checks: z.array(nativeLoadImageCheckSchema), - limitations: z.array(z.string().min(1)), + observations: nativeLoadImageObservationSchema, + checks: z.array(nativeLoadImageCheckSchema), + limitations: z.array(z.string().min(1)), +}); +export const nativeRawLoadImageSchema = z.strictObject({ + status: z.enum(["verified", "mismatch"]), + format: z.literal("raw-binary"), + target_sha256: digest, + architecture: binaryArchitectureSchema, + language_id: z.string().min(1), + compiler_spec_id: z.string().min(1), + base_address: nativeAddressObservationSchema, + file_bytes: integer.min(1), + entry: z.strictObject({ + address: nativeAddressObservationSchema, + file_offset: z.literal(0), }), - z.strictObject({ - status: z.enum(["verified", "mismatch"]), - format: z.literal("raw-binary"), - target_sha256: digest, - architecture: binaryArchitectureSchema, - language_id: z.string().min(1), - compiler_spec_id: z.string().min(1), - base_address: nativeAddressObservationSchema, - file_bytes: integer.min(1), - entry: z.strictObject({ - address: nativeAddressObservationSchema, - file_offset: z.literal(0), - }), - observations: nativeLoadImageObservationSchema, - checks: z.array(nativeLoadImageCheckSchema), - limitations: z.array(z.string().min(1)), - }), - z.strictObject({ - status: z.enum(["verified", "mismatch"]), - format: z.literal("gamecube-dol"), - target_sha256: digest, - architecture: z.literal("powerpc"), - language_id: z.string().min(1), - compiler_spec_id: z.string().min(1), - file_bytes: integer.min(1), - entry: z.strictObject({ address: nativeAddressObservationSchema }), - sections: z.array( - z.strictObject({ - kind: z.enum(["text", "data"]), - index: integer, - file_offset: nativeAddressObservationSchema, - address: nativeAddressObservationSchema, - size: nativeAddressObservationSchema, - }), - ), - bss: z.strictObject({ + observations: nativeLoadImageObservationSchema, + checks: z.array(nativeLoadImageCheckSchema), + limitations: z.array(z.string().min(1)), +}); +export const nativeDolLoadImageSchema = z.strictObject({ + status: z.enum(["verified", "mismatch"]), + format: z.literal("gamecube-dol"), + target_sha256: digest, + architecture: z.literal("powerpc"), + language_id: z.string().min(1), + compiler_spec_id: z.string().min(1), + file_bytes: integer.min(1), + entry: z.strictObject({ address: nativeAddressObservationSchema }), + sections: z.array( + z.strictObject({ + kind: z.enum(["text", "data"]), + index: integer, + file_offset: nativeAddressObservationSchema, address: nativeAddressObservationSchema, size: nativeAddressObservationSchema, }), - observations: nativeLoadImageObservationSchema, - checks: z.array(nativeLoadImageCheckSchema), - limitations: z.array(z.string().min(1)), + ), + bss: z.strictObject({ + address: nativeAddressObservationSchema, + size: nativeAddressObservationSchema, }), + observations: nativeLoadImageObservationSchema, + checks: z.array(nativeLoadImageCheckSchema), + limitations: z.array(z.string().min(1)), +}); + +/** Read-only native image verification; verified applies only to declared coverage. */ +export const nativeLoadImageSchema = z.union([ + nativeUnsupportedLoadImageSchema, + nativeDosLoadImageSchema, + nativeRawLoadImageSchema, + nativeDolLoadImageSchema, ]); /** Provider-neutral native load-image result. */ diff --git a/src/server/registerOfficialTools.ts b/src/server/registerOfficialTools.ts index 72013fe50a..2cb91517ea 100644 --- a/src/server/registerOfficialTools.ts +++ b/src/server/registerOfficialTools.ts @@ -8,9 +8,10 @@ import { type EvidenceToolFamilyOptions, } from "./retainedSummaryDelivery.js"; -const SUMMARY_OPERATIONS: ReadonlySet = new Set( - NATIVE_INVENTORY_OPERATIONS, -); +const SUMMARY_OPERATIONS: ReadonlySet = new Set([ + ...NATIVE_INVENTORY_OPERATIONS, + "inspect_native_load_image", +]); const withoutDetail = withoutDeliveryDetail(SUMMARY_OPERATIONS); /** Direct tools share Evidence recording/projection with the other provider-backed tools. */ diff --git a/tests/boundary/cli/nativeLoadImageSummaryCli.test.ts b/tests/boundary/cli/nativeLoadImageSummaryCli.test.ts new file mode 100644 index 0000000000..a88c190fbb --- /dev/null +++ b/tests/boundary/cli/nativeLoadImageSummaryCli.test.ts @@ -0,0 +1,79 @@ +import { Cli } from "incur"; +import pino from "pino"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it, vi } from "vitest"; + +import { registerCoreNativeCommands } from "../../../src/cli/coreNativeCommands.js"; +import { + createEvidence, + parseEvidence, + withEvidenceCallerInputPath, +} from "../../../src/domain/evidence.js"; +import { parseEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; +import { inspectAnalysisView } from "../../../src/application/analysisView/AnalysisViewService.js"; +import { nativeDosLoadImageFixture } from "../../fixtures/nativeLoadImage.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it("preserves the relative request path in a load-image CLI summary and its complete bundle", async () => { + const directory = await createTestTempDirectory("rea-load-image-cli-path-"); + const destination = join(directory, "complete.json"); + const selectedPath = "./alias/../selected.exe"; + const parent = withEvidenceCallerInputPath( + createEvidence( + { path: "/selected.exe", sha256: "a".repeat(64), format: "dos-mz" }, + { id: "ghidra", name: "Ghidra", version: "12.1.4" }, + { + operation: "inspect_native_load_image", + parameters: {}, + result: nativeDosLoadImageFixture(), + }, + ), + selectedPath, + ); + const run = vi.fn(async () => parent); + const cli = Cli.create("rea-load-image-cli-test", { sync: false }); + registerCoreNativeCommands(cli, pino({ enabled: false }), run); + let stdout = ""; + await cli.serve( + [ + "inspect-native-load-image", + selectedPath, + "--detail", + "summary", + "--evidence_output", + destination, + "--json", + ], + { + env: {}, + stdout: (text) => { + stdout += text; + }, + exit: () => undefined, + }, + ); + const receipt = JSON.parse(stdout); + const summary = parseEvidence(receipt.summary); + expect(summary.caller_input_path).toBe(selectedPath); + expect(run).toHaveBeenCalledExactlyOnceWith( + selectedPath, + "inspect_native_load_image", + {}, + expect.any(Object), + ); + const bundle = parseEvidenceBundle( + JSON.parse(await readFile(destination, "utf8")), + ); + expect(bundle.records).toEqual([parent]); + expect(summary.normalized_result).toMatchObject({ + parent_evidence_id: parent.evidence_id, + }); + const later = await inspectAnalysisView({ + source: { kind: "inline", evidence: parent }, + view: { kind: "summary" }, + }); + if (!later.ok) throw later.error; + expect(later.value).not.toHaveProperty("caller_input_path"); + expect(later.value.evidence_id).toBe(summary.evidence_id); +}); diff --git a/tests/boundary/mcp/ghidraNativeApiMcp.test.ts b/tests/boundary/mcp/ghidraNativeApiMcp.test.ts index a7811987eb..a4ef3ce5b4 100644 --- a/tests/boundary/mcp/ghidraNativeApiMcp.test.ts +++ b/tests/boundary/mcp/ghidraNativeApiMcp.test.ts @@ -14,6 +14,103 @@ import { functionDossierSchema } from "../../../src/domain/hopperValues.js"; import { ok } from "../../../src/domain/result.js"; import { ghidraFunctionDossier } from "../../../src/domain/ghidraValues.fixture.js"; import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js"; + +it("retains complete load-image facts and pages selected relocations through the advertised SDK contracts", async () => { + const fixture = fixtureDosOverlapLoadImage(); + const directory = await createTestTempDirectory("rea-mcp-load-summary-"); + const targetPath = join(directory, "inert.exe"); + await writeFile(targetPath, fixture.bytes); + const harness = await connectGhidraMcp( + "ghidra-load-summary", + async () => ok(jsonValueSchema.parse(fixture.observation)), + { + targetPath, + sessionTarget: { + name: "inert.exe", + language_id: fixture.observation.language_id, + compiler_spec_id: fixture.observation.compiler_spec_id, + image_base: fixture.observation.image_base, + default_address_space: fixture.observation.default_address_space, + sha256: fixture.sha256, + }, + attestDosTargetSnapshot: async (observations) => + compareDosClientFixture(fixture.bytes, fixture.sha256, observations), + }, + ); + try { + const catalog = await harness.mcp.listTools(); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + const call = async ( + name: "inspect_native_load_image" | "inspect_analysis_view", + input: Record, + ) => { + const definition = catalog.tools.find((tool) => tool.name === name); + if (!definition?.outputSchema) throw new Error("Missing named schema"); + const inputSchema: Record = definition.inputSchema; + const outputSchema: Record = definition.outputSchema; + expect(ajv.validateSchema(inputSchema)).toBe(true); + expect(ajv.validateSchema(outputSchema)).toBe(true); + const reply = await harness.mcp.callTool( + { name, arguments: input }, + { toolDefinition: definition }, + ); + expect(reply.isError).not.toBe(true); + expect( + ajv.validate(outputSchema, reply.structuredContent), + JSON.stringify(ajv.errors), + ).toBe(true); + return sessionEvidence(harness.session, reply.structuredContent); + }; + const complete = await call("inspect_native_load_image", {}); + const summary = await call("inspect_native_load_image", { + detail: "summary", + }); + expect(summary.normalized_result).toMatchObject({ + parent_evidence_id: complete.evidence_id, + source_provider: complete.provider, + source_analysis_profile_digest: complete.analysis_profile?.digest, + source_parameters: complete.parameters, + summary: { + verification: { status: "verified", format: "dos-mz" }, + counts: { relocations: 2 }, + }, + }); + const calls = harness.calls.length; + const page = await call("inspect_analysis_view", { + source: { kind: "retained-evidence", evidence_id: complete.evidence_id }, + view: { + kind: "native-load-image", + collection: "relocations", + offset: 1, + limit: 1, + fields: ["address", "memory_bytes_hex"], + }, + }); + const parsed = analysisViewResultSchema.parse(page.normalized_result); + if (parsed.kind !== "native-load-image") + throw new Error("Expected load-image page"); + expect(parsed.verification_status).toBe("verified"); + expect(parsed.items.rows).toEqual([ + { + source_index: 1, + record: { + address: fixture.observation.relocations[1]!.address, + memory_bytes_hex: + fixture.observation.relocations[1]!.memory_bytes_hex, + }, + }, + ]); + expect(harness.calls).toHaveLength(calls); + expect(harness.session.evidenceById(complete.evidence_id)).toEqual( + complete, + ); + expect(complete.parameters).toEqual({}); + } finally { + await harness.close(); + } +}); it.each([ { state: "final", failed: [] }, diff --git a/tests/boundary/mcp/nativeInventoryViewsMcp.test.ts b/tests/boundary/mcp/nativeInventoryViewsMcp.test.ts index 4a2338fc6d..c76b664936 100644 --- a/tests/boundary/mcp/nativeInventoryViewsMcp.test.ts +++ b/tests/boundary/mcp/nativeInventoryViewsMcp.test.ts @@ -288,12 +288,14 @@ it("refuses summary before provider work when no retaining session is configured InMemoryTransport.createLinkedPair(); await server.connect(serverTransport); await client.connect(clientTransport); - const reply = await client.callTool({ - name: "list_procedures", - arguments: { detail: "summary" }, - }); - expect(reply.isError).toBe(true); - expect(parseMcpToolError(reply).error.code).toBe("capability_unavailable"); + for (const name of ["list_procedures", "inspect_native_load_image"]) { + const reply = await client.callTool({ + name, + arguments: { detail: "summary" }, + }); + expect(reply.isError).toBe(true); + expect(parseMcpToolError(reply).error.code).toBe("capability_unavailable"); + } expect(calls).toBe(0); }); diff --git a/tests/boundary/mcp/nativeLoadImageViewsMcp.test.ts b/tests/boundary/mcp/nativeLoadImageViewsMcp.test.ts new file mode 100644 index 0000000000..998e2cf460 --- /dev/null +++ b/tests/boundary/mcp/nativeLoadImageViewsMcp.test.ts @@ -0,0 +1,95 @@ +import { expect, it } from "vitest"; +import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; +import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js"; +import { + nativeDolLoadImageFixture, + nativeRawLoadImageFixture, +} from "../../fixtures/nativeLoadImage.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; + +it("preserves raw and DOL verification facts and selected DOL sections through the advertised SDK output", async () => { + const { call } = await connectLocalToolsMcp(); + for (const report of [ + nativeRawLoadImageFixture(), + nativeDolLoadImageFixture(), + ]) { + const parent = createEvidence( + { + path: "/selected/fixture", + sha256: report.target_sha256, + format: report.format, + architecture: report.architecture, + }, + { id: "ghidra", name: "Ghidra", version: "12.1.4" }, + { + operation: "inspect_native_load_image", + parameters: {}, + result: report, + }, + ); + const source = { kind: "inline", evidence: parent }; + const summary = await call("inspect_analysis_view", { + source, + view: { kind: "summary" }, + }); + expect(summary.isError, JSON.stringify(summary.content)).not.toBe(true); + const result = analysisViewResultSchema.parse( + parseEvidence(summary.structuredContent).normalized_result, + ); + const { + observations: _observations, + checks, + limitations: _limitations, + ...verification + } = report; + if ("sections" in verification) + Reflect.deleteProperty(verification, "sections"); + expect(result).toMatchObject({ + summary: { + verification, + counts: { + checks: checks.length, + sections: report.format === "gamecube-dol" ? 2 : null, + }, + }, + }); + if (report.format !== "gamecube-dol") continue; + const page = await call("inspect_analysis_view", { + source, + view: { + kind: "native-load-image", + collection: "sections", + indices: [1, 0, 1], + offset: 1, + limit: 1, + fields: ["kind", "address"], + }, + }); + expect(page.isError, JSON.stringify(page.content)).not.toBe(true); + const section = analysisViewResultSchema.parse( + parseEvidence(page.structuredContent).normalized_result, + ); + if (section.kind !== "native-load-image") throw new Error("Expected page"); + expect(section.items.rows).toEqual([ + { + source_index: 1, + record: { kind: "data", address: "ram:0x80004000" }, + }, + ]); + expect(section).toMatchObject({ + verification_status: "mismatch", + parent_evidence_id: parent.evidence_id, + items: { + collection: "sections", + rows: [ + { + source_index: 1, + record: { kind: "data", address: "ram:0x80004000" }, + }, + ], + }, + coverage: { total: 2, next_offset: null, exhausted: true }, + limitations: expect.arrayContaining(report.limitations), + }); + } +}, 30_000); diff --git a/tests/fixtures/nativeLoadImage.ts b/tests/fixtures/nativeLoadImage.ts new file mode 100644 index 0000000000..c60ef2adbb --- /dev/null +++ b/tests/fixtures/nativeLoadImage.ts @@ -0,0 +1,143 @@ +import type { + NativeLoadImage, + NativeLoadImageObservation, +} from "../../src/domain/native/nativeLoadImage.js"; + +/** Authored domain observations; these projection fixtures run no provider. */ +export const nativeLoadImageObservationFixture = + (): NativeLoadImageObservation => ({ + executable_format: "Old-style DOS Executable (MZ)", + language_id: "x86:LE:16:Real Mode", + compiler_spec_id: "default", + image_base: "0x0", + default_address_space: "ram", + source_files: [ + { + name: "fixture.exe", + size: 64, + original_sha256: "a".repeat(64), + modified_sha256: "a".repeat(64), + }, + ], + mappings: [ + { + block: "module", + start: "ram:0x0010", + end: "ram:0x001f", + address_space: "ram", + initialized: true, + loaded: true, + overlay: false, + length: 16, + source_file_index: 0, + file_offset: 32, + sha256: "b".repeat(64), + }, + ], + relocations: [], + entry_points: ["ram:0x0010"], + entry_context: [], + }); + +/** A recorded producer verification claim, not a runtime attestation by this fixture. */ +export const nativeDosLoadImageFixture = (): Extract< + NativeLoadImage, + { format: "dos-mz" | "dos-com" } +> => ({ + status: "verified", + format: "dos-mz", + target_sha256: "a".repeat(64), + load_segment: 0, + header_bytes: 32, + module_bytes: 32, + overlay_bytes: 0, + entry: { relative_segment: 0, offset: 16, linear_address: "ram:0x0010" }, + observations: nativeLoadImageObservationFixture(), + checks: [ + { + name: "original_file_sha256", + matched: true, + expected: "a".repeat(64), + observed: "a".repeat(64), + file_offset: null, + address: null, + }, + ], + limitations: [], +}); + +export const nativeRawLoadImageFixture = (): Extract< + NativeLoadImage, + { format: "raw-binary" } +> => ({ + status: "verified", + format: "raw-binary", + target_sha256: "c".repeat(64), + architecture: "x86_64", + language_id: "x86:LE:64:default", + compiler_spec_id: "gcc", + base_address: "ram:0x00400000", + file_bytes: 32, + entry: { address: "ram:0x00400000", file_offset: 0 }, + observations: { + ...nativeLoadImageObservationFixture(), + executable_format: "Raw Binary", + language_id: "x86:LE:64:default", + compiler_spec_id: "gcc", + image_base: "ram:0x00400000", + entry_points: ["ram:0x00400000"], + }, + checks: [], + limitations: [], +}); + +export const nativeDolLoadImageFixture = (): Extract< + NativeLoadImage, + { format: "gamecube-dol" } +> => ({ + status: "mismatch", + format: "gamecube-dol", + target_sha256: "d".repeat(64), + architecture: "powerpc", + language_id: "PowerPC:BE:32:default", + compiler_spec_id: "default", + file_bytes: 288, + entry: { address: "ram:0x80003100" }, + sections: [ + { + kind: "text", + index: 0, + file_offset: "0x100", + address: "ram:0x80003100", + size: "0x10", + }, + { + kind: "data", + index: 0, + file_offset: "0x110", + address: "ram:0x80004000", + size: "0x10", + }, + ], + bss: { address: "ram:0x80005000", size: "0x20" }, + observations: { + ...nativeLoadImageObservationFixture(), + executable_format: "Nintendo GameCube DOL", + language_id: "PowerPC:BE:32:default", + image_base: "ram:0x80003100", + entry_points: ["ram:0x80003100"], + }, + checks: [ + { + name: "bss", + matched: false, + expected: "0x20", + observed: "0x10", + file_offset: null, + address: "ram:0x80005000", + }, + ], + limitations: [ + "Fixture records a BSS mismatch; this is not runtime coverage.", + ], +});