From db675f1d52acda62b6f57b5a8773532f49b259e7 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Thu, 8 Oct 2026 21:45:48 -0700 Subject: [PATCH] feat(mcp): select views of retained analysis results (#1180) Add explicit summary, facet, item and paged views of retained analysis Evidence without repeating analysis. Preserve captured observations, graph validation, artifact identity, coverage and complete default results. Fixes #1050 Verified with focused source, CLI, pinned-SDK schema and MCP tests, serial static checks, and one complete native Windows Notion ASAR workflow. Real Hopper/Ghidra verification was not performed for this provider-independent projection change. Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> --- docs/binary-diagnostics.md | 7 + docs/javascript-application-workflows.md | 13 +- docs/javascript-artifact-reconstruction.md | 13 +- docs/mcp-contracts.md | 17 +- docs/mcp-prompts.md | 4 +- scripts/lib/catalog-builders.mjs | 5 + scripts/lib/catalog-core.mjs | 1 + skill-src/reverse-engineer-anything/SKILL.md | 15 +- .../references/evidence-workflows.md | 5 +- .../references/javascript-applications.md | 14 +- .../references/native-and-artifacts.md | 9 + src/application/CapabilityInventory.test.ts | 4 + src/application/InvestigationProviders.ts | 7 + .../analysisView/AnalysisViewService.test.ts | 176 ++++++ .../analysisView/AnalysisViewService.ts | 132 +++++ src/cli.ts | 2 + src/cli/analysisViewCommands.ts | 53 ++ src/cliCommandNames.ts | 1 + src/contracts/analysisViewToolContracts.ts | 63 +++ src/contracts/contractSnapshot.test.ts | 2 + src/contracts/promptContracts.ts | 4 +- src/contracts/toolContracts.test.ts | 2 + src/contracts/toolContracts.ts | 2 + src/contracts/toolEffects.ts | 1 + src/domain/analysisView/analysisView.test.ts | 526 ++++++++++++++++++ src/domain/analysisView/analysisView.ts | 341 ++++++++++++ .../analysisView/analysisViewCoverage.ts | 29 + src/domain/analysisView/binaryLayoutView.ts | 239 ++++++++ .../analysisView/javascriptApplicationView.ts | 318 +++++++++++ src/generatedPackageMetadata.ts | 2 +- src/server/createServer.ts | 2 + src/server/registerAnalysisViewTool.ts | 32 ++ tests/boundary/cli/analysisViewCli.test.ts | 82 +++ tests/boundary/cli/jsonInputStatus.test.ts | 1 + tests/boundary/cli/productCatalog.test.ts | 6 + tests/boundary/mcp/analysisViewMcp.test.ts | 270 +++++++++ .../boundary/mcp/binaryDiagnosticsMcp.test.ts | 20 + .../retainedApplicationEvidenceMcp.test.ts | 24 + tests/fixtures/analysisView.ts | 195 +++++++ 39 files changed, 2609 insertions(+), 30 deletions(-) create mode 100644 src/application/analysisView/AnalysisViewService.test.ts create mode 100644 src/application/analysisView/AnalysisViewService.ts create mode 100644 src/cli/analysisViewCommands.ts create mode 100644 src/contracts/analysisViewToolContracts.ts create mode 100644 src/domain/analysisView/analysisView.test.ts create mode 100644 src/domain/analysisView/analysisView.ts create mode 100644 src/domain/analysisView/analysisViewCoverage.ts create mode 100644 src/domain/analysisView/binaryLayoutView.ts create mode 100644 src/domain/analysisView/javascriptApplicationView.ts create mode 100644 src/server/registerAnalysisViewTool.ts create mode 100644 tests/boundary/cli/analysisViewCli.test.ts create mode 100644 tests/boundary/mcp/analysisViewMcp.test.ts create mode 100644 tests/fixtures/analysisView.ts diff --git a/docs/binary-diagnostics.md b/docs/binary-diagnostics.md index e337e0fa1d..d1addae134 100644 --- a/docs/binary-diagnostics.md +++ b/docs/binary-diagnostics.md @@ -84,6 +84,13 @@ uses isolated mode and an owned cache. Exact upstream profiles are recorded in stack can accompany an unknown (`null`) NX indicator. ET_DYN can be a shared library, and an absent canary symbol does not prove every function unprotected. +When complete layout Evidence is already retained, `inspect_analysis_view` / +`inspect-analysis-view` projects a summary, the mitigations or linkage facet, +one section or symbol, or a stable page without re-running the decoder. CLI +one-shot invocations pass portable inline Evidence JSON; an MCP session uses +the retained `evidence_id`. Page `limit` is required and bounded by the +measured MCP stdio budget documented on the tool contract. + Complete results have a 32 MiB input, 64 MiB reply, 1 MiB combined diagnostics and 30-second owned command deadline. The reply budget applies to the decoder record; the CLI/MCP Evidence envelope adds copies and encoding overhead. The diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index 3821ccbee7..6b7655e0f8 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -2,12 +2,15 @@ REA derives a complete reachable feature trace from one authenticated JavaScript Application Graph and compares two authenticated graph versions. The MCP tools -are `trace_application_feature`, `trace_javascript_semantics`, -`compare_application_versions`, `compare_source_to_bundle`, and -`compare_javascript_export_shapes`; their CLI equivalents use the same names -with hyphens. +are `inspect_analysis_view`, `trace_application_feature`, +`trace_javascript_semantics`, `compare_application_versions`, +`compare_source_to_bundle`, and `compare_javascript_export_shapes`; their CLI +equivalents use the same names with hyphens. `inspect_analysis_view` projects a +summary, module page, or one module from already completed application +Evidence; it does not walk relationships. -These workflows consume Evidence produced by +`inspect_analysis_view` also projects retained `inspect_binary_layout` +Evidence. The remaining workflows consume Evidence produced by `analyze_javascript_application` or `reconcile_javascript_runtime`. They do not read an artifact, execute application code, attach to a process, or open a native-analysis provider. Static artifact observations, passive runtime diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 51b4f1a985..cd3c824c53 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -108,14 +108,15 @@ memory needed to construct the analysis graph itself. MCP prepares the complete repeated response incrementally against the pinned SDK's 10 MiB stdio receive-buffer budget. Oversized results return an actionable transport constraint and the exact same-session Evidence reference. Use -`trace_application_feature` to inspect a selected module's relationships, or -`export_evidence_bundle` to write the complete canonical bundle without a -document-sized allocation. Same-session analysis reads reuse authenticated -immutable snapshots; foreign inline Evidence is still parsed and authenticated. +`inspect_analysis_view` with the retained Evidence ID for a summary, one +module, or a stable page of module identities. Use `trace_application_feature` +once a module, route, or string seed is known, or `export_evidence_bundle` to +write the complete canonical bundle without a document-sized allocation. +Same-session analysis reads reuse authenticated immutable snapshots; foreign +inline Evidence is still parsed and authenticated. See [MCP tool results](mcp-contracts.md#tool-results) for larger client buffers and `REA_MCP_MAX_RESPONSE_BYTES`. Follow-up results remain complete and can also -exceed the transport budget. Compact result views are tracked separately in -[#1050](https://github.com/morluto/rea/issues/1050). +exceed the transport budget. ## What is reconstructed diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 1eb03986e5..994f301a71 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -194,8 +194,9 @@ room for the JSON-RPC envelope. If a result cannot fit, REA returns `resource_constraint` with `details.resource: "transport"` before constructing a document-sized string. Analysis Evidence remains complete in the current session. Its exact reference is reported in -`details.reported_limits.evidence_reference`; use it with a focused application -workflow, or call `export_evidence_bundle` with a destination path. Complete +`details.reported_limits.evidence_reference`; call `inspect_analysis_view` with +that reference for a summary, one section/module, or a stable page, or call +`export_evidence_bundle` with a destination path. Complete bundle exports stream canonical JSON into an atomically published file. A broad follow-up or `get_evidence_bundle` can also exceed the response budget; exporting preserves the complete session without sending it through a single MCP frame. @@ -212,6 +213,18 @@ Ordinary responses keep their existing complete result contract. ## Retained application Evidence inputs +`inspect_analysis_view` projects a caller-selected view of already completed +`inspect_binary_layout` or `analyze_javascript_application` Evidence. Source is +an exact same-session retained reference or portable inline Evidence. Views are +a summary, a layout mitigations or linkage facet, one section/symbol/module, or +a stable page with a caller-selected positive `limit`. Module pages include +JavaScript assets, bundled modules, and source modules. Select an exact +`node_id` when a path is ambiguous or unavailable. Module items retain their +recorded property values and source locations; summaries include parent +application and semantic coverage. The result carries a digest of the selected +facts. Actual serialized size determines MCP transport admission: reduce the +page size or export retained Evidence if the view is too large. + `trace_application_feature`, `trace_javascript_semantics`, `compare_application_versions`, `compare_source_to_bundle`, and `compare_javascript_export_shapes` accept complete inline application Evidence diff --git a/docs/mcp-prompts.md b/docs/mcp-prompts.md index 160f975229..3c9167265a 100644 --- a/docs/mcp-prompts.md +++ b/docs/mcp-prompts.md @@ -27,7 +27,9 @@ prompt argument or tool explicitly accepts one. For a JavaScript/Electron application directory, `investigate_feature` points directly to `analyze_javascript_application` with `input_path`; it does not -require `open_binary`. The CLI equivalent is `rea analyze `. +require `open_binary`. When that Evidence, or `inspect_binary_layout` Evidence, +is already retained, it suggests `inspect_analysis_view` before tracing a +feature seed. The CLI equivalent is `rea analyze `. `open_binary` and the doctor target check admit files and macOS app bundles. A directory outside that opening route is reported with an available analysis action; this does not establish support for every directory format. diff --git a/scripts/lib/catalog-builders.mjs b/scripts/lib/catalog-builders.mjs index 8049261cd6..f497d43a27 100644 --- a/scripts/lib/catalog-builders.mjs +++ b/scripts/lib/catalog-builders.mjs @@ -75,6 +75,7 @@ export const toolFamilyCatalog = (sources) => { surface: "application-workflow", contracts: [ ...sources.applicationContracts.APPLICATION_TOOL_CONTRACTS, + ...sources.analysisViewContracts.ANALYSIS_VIEW_TOOL_CONTRACTS, ...sources.webScriptContracts.WEB_SCRIPT_TOOL_CONTRACTS, ...sources.javascriptRecoveryContracts .JAVASCRIPT_RECOVERY_TOOL_CONTRACTS, @@ -246,6 +247,10 @@ export const providerCatalog = (sources) => { name !== "project_apple_application_graph", ), }, + { + identity: sources.artifactProviders.ANALYSIS_VIEW_PROVIDER, + contracts: sources.analysisViewContracts.ANALYSIS_VIEW_TOOL_CONTRACTS, + }, { identity: sources.artifactProviders.ANDROID_APPLICATION_PROVIDER, contracts: sources.applicationContracts.APPLICATION_TOOL_CONTRACTS.filter( diff --git a/scripts/lib/catalog-core.mjs b/scripts/lib/catalog-core.mjs index 9dcfdbb187..0bb239e00a 100644 --- a/scripts/lib/catalog-core.mjs +++ b/scripts/lib/catalog-core.mjs @@ -64,6 +64,7 @@ const SOURCE_PATHS = { javascriptRuntimeObservationContracts: "dist/contracts/javascript/javascriptRuntimeObservationToolContracts.js", applicationContracts: "dist/contracts/applicationToolContracts.js", + analysisViewContracts: "dist/contracts/analysisViewToolContracts.js", webScriptContracts: "dist/contracts/webScriptToolContracts.js", javascriptRecoveryContracts: "dist/contracts/javascript/javascriptRecoveryToolContracts.js", diff --git a/skill-src/reverse-engineer-anything/SKILL.md b/skill-src/reverse-engineer-anything/SKILL.md index 404ad45ea8..c2a16cb4fa 100644 --- a/skill-src/reverse-engineer-anything/SKILL.md +++ b/skill-src/reverse-engineer-anything/SKILL.md @@ -2,7 +2,7 @@ name: reverse-engineer-anything description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis. metadata: - version: "33" + version: "34" --- # REA @@ -146,12 +146,13 @@ Start with the default result and use its inline Evidence and graph context. Do not repeat an identical tool call. Make a focused follow-up only when the returned result leaves a specific question unanswered. -If MCP reports `resource_constraint` with `details.resource: "transport"`, use -its reported same-session Evidence reference with a focused application tool, -or call `export_evidence_bundle` to write the complete session to a selected -path. The analysis remains complete in the ledger; avoid repeating it merely -to request the same oversized response. Complete CLI JSON output also streams -without a single MCP frame. +If MCP reports `resource_constraint` with `details.resource: "transport"`, call +`inspect_analysis_view` with its reported same-session Evidence reference for a +summary, one section/module, or a stable page. Use `trace_application_feature` +once a seed is known, or call `export_evidence_bundle` to write the complete +session to a selected path. The analysis remains complete in the ledger; avoid +repeating it merely to request the same oversized response. Complete CLI JSON +output also streams without a single MCP frame. Every conclusion must distinguish observations, inferences, and unknowns. Cite Evidence IDs, preserve limitations and incomplete coverage, and never imply diff --git a/skill-src/reverse-engineer-anything/references/evidence-workflows.md b/skill-src/reverse-engineer-anything/references/evidence-workflows.md index b78dbf40a9..4519b9b39b 100644 --- a/skill-src/reverse-engineer-anything/references/evidence-workflows.md +++ b/skill-src/reverse-engineer-anything/references/evidence-workflows.md @@ -5,7 +5,10 @@ Evidence ID when another tool or your explanation needs a stable reference, and preserve authority, limitations, coverage, and residual unknowns. If a result is incomplete or paginated, continue only when the remaining data matters to the task; do not fetch a bundle or resource merely to read a result already -returned inline. +returned inline. When complete `inspect_binary_layout` or +`analyze_javascript_application` Evidence is already retained, call +`inspect_analysis_view` for a summary, one section/symbol/module, or a stable +page instead of repeating the producer. Use `record_unknown` to track unresolved questions in the user's investigation and name the authority or environment still required. Supply supporting and diff --git a/skill-src/reverse-engineer-anything/references/javascript-applications.md b/skill-src/reverse-engineer-anything/references/javascript-applications.md index 03868c10c2..6d62eaf955 100644 --- a/skill-src/reverse-engineer-anything/references/javascript-applications.md +++ b/skill-src/reverse-engineer-anything/references/javascript-applications.md @@ -8,10 +8,12 @@ only when a specific question remains unanswered. An oversized response reports `resource_constraint` and `details.resource: "transport"`. Reuse -`details.reported_limits.evidence_reference` with `trace_application_feature` -for the module or feature under investigation. `export_evidence_bundle` writes -the complete canonical session to a caller-selected file. Both workflows keep -the original Evidence and coverage; a broad follow-up can also exceed framing. +`details.reported_limits.evidence_reference` with `inspect_analysis_view` for a +summary, one module, or a stable page of module identities. Use +`trace_application_feature` once a module, route, or string seed is known. +`export_evidence_bundle` writes the complete canonical session to a +caller-selected file. These workflows keep the original Evidence and coverage; +a broad follow-up can also exceed framing. BrowserWindow preferences, preload and contextBridge surfaces, IPC registrations, utility processes, and native binding requests are static syntax @@ -50,7 +52,9 @@ through the available browser, Electron, or process workflows. When advertised by the connected server, application trace and compare tools accept complete inline Evidence or `{"kind":"retained-evidence","evidence_id":"ev_<64 lowercase hex characters>"}` -for their application input (`application`, or `left`/`right`). This notation is +for their application input (`application`, or `left`/`right`). `inspect_analysis_view` +uses the same retained-reference form in `source`, or portable inline Evidence, +to project a summary, module page, or one module with its recorded observations. This notation is a template: replace it with the actual returned ID. Versions before 4.1.0 accept only full inline Evidence. Use the exact ID returned by the producer on the same MCP connection. Resolution does not run diff --git a/skill-src/reverse-engineer-anything/references/native-and-artifacts.md b/skill-src/reverse-engineer-anything/references/native-and-artifacts.md index bf42555e86..08e2ac8745 100644 --- a/skill-src/reverse-engineer-anything/references/native-and-artifacts.md +++ b/skill-src/reverse-engineer-anything/references/native-and-artifacts.md @@ -16,6 +16,15 @@ Requested unavailable context fails with setup guidance. See [recorded crashes](https://github.com/morluto/rea/blob/main/docs/recorded-crashes.md) for exact upstream profiles, bounds and verification coverage. +## Offline ELF layout + +Use `inspect_binary_layout` with an explicit local ELF path for file-backed +sections, symbols, relocations, linkage names, and static mitigation inferences +without opening a disassembler target. When that complete record is already +retained, call `inspect_analysis_view` for a summary, mitigations or linkage +facet, one section or symbol, or a stable page. Do not re-run the decoder to +read a single object. + ## Native targets After `open_binary`, use focused search, procedure, or function tools directly. diff --git a/src/application/CapabilityInventory.test.ts b/src/application/CapabilityInventory.test.ts index f235c740ce..e2d6156aa8 100644 --- a/src/application/CapabilityInventory.test.ts +++ b/src/application/CapabilityInventory.test.ts @@ -529,5 +529,9 @@ describe("optional JavaScript recovery availability", () => { expect(entry("analyze_javascript_application", status())).toMatchObject({ available: true, }); + expect(entry("inspect_analysis_view", status())).toMatchObject({ + available: true, + surface: "application", + }); }); }); diff --git a/src/application/InvestigationProviders.ts b/src/application/InvestigationProviders.ts index e99a1773f0..20354c27bd 100644 --- a/src/application/InvestigationProviders.ts +++ b/src/application/InvestigationProviders.ts @@ -77,6 +77,13 @@ export const JAVASCRIPT_RUNTIME_RECONCILIATION_PROVIDER = { version: "1", } as const; +/** Provider identity for selected views of retained analysis Evidence. */ +export const ANALYSIS_VIEW_PROVIDER = { + id: "rea-analysis-view", + name: "REA analysis view projection", + version: "1", +} as const; + /** Provider identity for graph-native feature tracing and version comparison. */ export const JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER = { id: "rea-javascript-application-workflows", diff --git a/src/application/analysisView/AnalysisViewService.test.ts b/src/application/analysisView/AnalysisViewService.test.ts new file mode 100644 index 0000000000..29289efae8 --- /dev/null +++ b/src/application/analysisView/AnalysisViewService.test.ts @@ -0,0 +1,176 @@ +import { expect, it } from "vitest"; + +import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../contracts/javascript/javascriptRuntimeReconciliationExample.js"; +import { createEvidence, parseEvidence } from "../../domain/evidence.js"; +import { + analysisViewJavaScriptAnalysisWithSource, + analysisViewJavaScriptEvidence, + analysisViewLayoutEvidence, +} from "../../../tests/fixtures/analysisView.js"; +import { inspectAnalysisView } from "./AnalysisViewService.js"; + +it("projects inline layout Evidence and retains a derived view record", () => { + const parent = analysisViewLayoutEvidence(); + const result = inspectAnalysisView({ + source: { kind: "inline", evidence: parent }, + view: { + kind: "item", + collection: "sections", + selector: { index: 2 }, + }, + }); + if (!result.ok) throw result.error; + const evidence = parseEvidence(result.value); + expect(evidence.operation).toBe("inspect_analysis_view"); + expect(evidence.predicate_type).toBe("rea.analysis-view"); + expect(evidence.confidence).toBe("derived"); + expect(evidence.evidence_links).toEqual([parent.evidence_id]); + expect(evidence.normalized_result).toMatchObject({ + kind: "item", + parent_evidence_id: parent.evidence_id, + item: { name: { display: ".data" } }, + }); +}); + +it("resolves same-session retained JavaScript Evidence without re-running analysis", () => { + const parent = analysisViewJavaScriptEvidence( + analysisViewJavaScriptAnalysisWithSource(), + ); + const lookup = (evidenceId: string) => + evidenceId === parent.evidence_id ? parent : undefined; + const summary = inspectAnalysisView( + { + source: { + kind: "retained-evidence", + evidence_id: parent.evidence_id, + }, + view: { kind: "summary" }, + }, + lookup, + ); + if (!summary.ok) throw summary.error; + expect(summary.value.normalized_result).toMatchObject({ + kind: "summary", + parent_evidence_id: parent.evidence_id, + parent_operation: "analyze_javascript_application", + }); + expect(summary.value.normalized_result).not.toHaveProperty( + "summary.semantic_graph", + ); + const item = inspectAnalysisView( + { + source: { + kind: "retained-evidence", + evidence_id: parent.evidence_id, + }, + view: { + kind: "item", + collection: "modules", + selector: { path: "renderer.js" }, + }, + }, + lookup, + ); + if (!item.ok) throw item.error; + expect(JSON.stringify(item.value.normalized_result)).toContain( + "export const secret = 1;", + ); +}); + +it("rejects stale retained IDs and unsupported parent operations", () => { + const missing = inspectAnalysisView( + { + source: { + kind: "retained-evidence", + evidence_id: JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE.evidence_id, + }, + view: { kind: "summary" }, + }, + () => undefined, + ); + expect(missing).toMatchObject({ + ok: false, + error: { + _tag: "EvidenceIntegrityError", + evidenceId: JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE.evidence_id, + }, + }); + const unsupported = createEvidence( + undefined, + { id: "fixture", name: "Fixture", version: "1" }, + { + operation: "inspect_recorded_crash", + parameters: {}, + result: { path: "/artifacts/crash.core" }, + }, + ); + const rejected = inspectAnalysisView({ + source: { kind: "inline", evidence: unsupported }, + view: { kind: "summary" }, + }); + expect(rejected).toMatchObject({ + ok: false, + error: { _tag: "AnalysisUnsupportedTargetError" }, + }); + expect( + inspectAnalysisView({ + source: { kind: "inline", evidence: analysisViewLayoutEvidence() }, + view: { kind: "summary", approval: true }, + }), + ).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); +}); + +it("preserves an absent parent subject instead of guessing its format", () => { + const analysis = analysisViewJavaScriptAnalysisWithSource(); + const original = analysisViewJavaScriptEvidence(analysis); + const parent = createEvidence(undefined, original.provider, { + operation: original.operation, + parameters: {}, + result: original.normalized_result, + limitations: original.limitations, + }); + const result = inspectAnalysisView( + { + source: { kind: "retained-evidence", evidence_id: parent.evidence_id }, + view: { kind: "summary" }, + }, + () => parent, + ); + if (!result.ok) throw result.error; + expect(result.value.subject).toBeNull(); + expect(result.value.locations).toEqual([ + { kind: "artifact-path", path: analysis.input_path }, + ]); +}); + +it("rejects contradictory subject and analysis artifact digests", () => { + const analysis = analysisViewJavaScriptAnalysisWithSource(); + const original = analysisViewJavaScriptEvidence(analysis); + const parent = createEvidence( + { + path: analysis.input_path, + format: analysis.format, + sha256: "d".repeat(64), + }, + original.provider, + { + operation: original.operation, + parameters: {}, + result: original.normalized_result, + }, + ); + const result = inspectAnalysisView( + { + source: { kind: "retained-evidence", evidence_id: parent.evidence_id }, + view: { kind: "summary" }, + }, + () => parent, + ); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "EvidenceIntegrityError" }, + }); +}); diff --git a/src/application/analysisView/AnalysisViewService.ts b/src/application/analysisView/AnalysisViewService.ts new file mode 100644 index 0000000000..0d10d5fa8d --- /dev/null +++ b/src/application/analysisView/AnalysisViewService.ts @@ -0,0 +1,132 @@ +import { z } from "zod"; + +import { ANALYSIS_VIEW_PROVIDER } from "../InvestigationProviders.js"; +import type { EvidenceLookup } from "../EvidenceInputResolver.js"; +import { resolveEvidenceInput } from "../EvidenceInputResolver.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; +import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js"; +import { + createEvidence, + parseEvidence, + type Evidence, +} from "../../domain/evidence.js"; +import { + INSPECT_ANALYSIS_VIEW_OPERATION, + inspectAnalysisViewInputSchema, + projectAnalysisView, + type AnalysisViewResult, + type InspectAnalysisViewInput, +} from "../../domain/analysisView/analysisView.js"; +import { jsonValueSchema } from "../../domain/jsonValue.js"; +import { err, ok, type Result } from "../../domain/result.js"; + +/** Authenticate a selected-view request and project already completed Evidence. */ +export const inspectAnalysisView = ( + rawInput: unknown, + lookup?: EvidenceLookup, +): Result => { + const parsed = inspectAnalysisViewInputSchema.safeParse(rawInput); + if (!parsed.success) + return err( + analysisInputErrorFromIssues( + INSPECT_ANALYSIS_VIEW_OPERATION, + parsed.error.issues, + rawInput, + { cause: parsed.error }, + ), + ); + return inspectAnalysisViewValidated(parsed.data, lookup); +}; + +/** Project a view from input already parsed by a trusted adapter. */ +export const inspectAnalysisViewValidated = ( + input: InspectAnalysisViewInput, + lookup?: EvidenceLookup, +): Result => { + const resolved = resolveAnalysisViewSource(input.source, lookup); + if (!resolved.ok) return resolved; + const parent = resolved.value; + const projected = projectAnalysisView( + { + evidenceId: parent.evidence_id, + operation: parent.operation, + normalizedResult: parent.normalized_result, + limitations: parent.limitations, + }, + input.view, + ); + if (!projected.ok) return projected; + if ( + parent.subject !== null && + parent.subject.digest.sha256 !== projected.value.artifact.sha256 + ) + return err( + new EvidenceIntegrityError( + `Parent subject SHA-256 ${parent.subject.digest.sha256} differs from analysis artifact SHA-256 ${projected.value.artifact.sha256}.`, + ), + ); + return ok(createAnalysisViewEvidence(input, parent, projected.value)); +}; + +const resolveAnalysisViewSource = ( + source: InspectAnalysisViewInput["source"], + lookup: EvidenceLookup | undefined, +): Result => { + if (source.kind === "retained-evidence") + return resolveEvidenceInput(source, lookup); + try { + return ok(parseEvidence(source.evidence)); + } catch (cause: unknown) { + if (cause instanceof z.ZodError) + return err( + analysisInputErrorFromIssues( + INSPECT_ANALYSIS_VIEW_OPERATION, + cause.issues, + source.evidence, + { cause }, + ), + ); + if (cause instanceof TypeError) + return err( + new EvidenceIntegrityError( + cause.message, + cause.message.length > 0 ? { userMessage: cause.message } : undefined, + ), + ); + throw cause; + } +}; + +const createAnalysisViewEvidence = ( + input: InspectAnalysisViewInput, + parent: Evidence, + result: AnalysisViewResult, +): Evidence => { + const target = + parent.subject === null + ? undefined + : { + path: parent.subject.local_path, + sha256: parent.subject.digest.sha256, + format: parent.subject.format, + ...(parent.subject.architecture === null + ? {} + : { architecture: parent.subject.architecture }), + }; + return createEvidence(target, ANALYSIS_VIEW_PROVIDER, { + predicateType: "rea.analysis-view", + operation: INSPECT_ANALYSIS_VIEW_OPERATION, + parameters: { + parent_evidence_id: parent.evidence_id, + view: jsonValueSchema.parse(input.view), + }, + result: jsonValueSchema.parse(result), + rawResult: null, + confidence: "derived", + authority: parent.authority, + limitations: result.limitations, + locations: [{ kind: "artifact-path", path: result.artifact.path }], + evidenceLinks: [parent.evidence_id], + }); +}; diff --git a/src/cli.ts b/src/cli.ts index 8b3d300085..eb7d4e83d1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,5 @@ import { registerEvmCommands } from "./cli/evmCommands.js"; +import { registerAnalysisViewCommands } from "./cli/analysisViewCommands.js"; import { registerBinaryDiagnosticsCommands } from "./cli/binaryDiagnosticsCommands.js"; import { Cli } from "incur"; @@ -65,6 +66,7 @@ export const createCli = ( registerAndroidCommands(cli, logger, environment); registerFirmwareCommands(cli, logger, environment); registerBinaryDiagnosticsCommands(cli, logger, environment); + registerAnalysisViewCommands(cli, logger); registerEvidenceCommands(cli, logger); registerProcessCommands(cli, logger, environment); registerBrowserCommands(cli, logger); diff --git a/src/cli/analysisViewCommands.ts b/src/cli/analysisViewCommands.ts new file mode 100644 index 0000000000..480ef67239 --- /dev/null +++ b/src/cli/analysisViewCommands.ts @@ -0,0 +1,53 @@ +import { z } from "incur"; + +import { inspectAnalysisViewValidated } from "../application/analysisView/AnalysisViewService.js"; +import { CLI_COMMANDS } from "../cliCommandNames.js"; +import { parseCliJsonInput } from "../cliJsonInput.js"; +import { logCliCommand } from "../cliLogging.js"; +import { inspectAnalysisViewInputSchema } from "../domain/analysisView/analysisView.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; +import { jsonValueSchema } from "../domain/jsonValue.js"; +import type { Logger } from "../logger.js"; +import type { CliInstance } from "./types.js"; + +/** Project selected views of completed analysis Evidence through the CLI. */ +export const registerAnalysisViewCommands = ( + cli: CliInstance, + logger: Logger, +): void => { + cli.command(CLI_COMMANDS.inspectAnalysisView, { + description: + "Project a selected view of completed layout or JavaScript application Evidence JSON", + args: z.object({ + inputJson: z.string().describe("Inline workflow JSON or JSON file path"), + }), + run: ({ args }) => + logCliCommand(logger, CLI_COMMANDS.inspectAnalysisView, async () => { + const input = await parseCliJsonInput( + args.inputJson, + CLI_COMMANDS.inspectAnalysisView, + ); + if (!input.ok) return input.error; + const parsed = inspectAnalysisViewInputSchema.safeParse(input.value); + if (!parsed.success) + return { + error: "Application workflow failed", + ...projectAnalysisError( + analysisInputErrorFromIssues( + CLI_COMMANDS.inspectAnalysisView, + parsed.error.issues, + input.value, + ), + ), + }; + const result = inspectAnalysisViewValidated(parsed.data); + return result.ok + ? jsonValueSchema.parse(result.value) + : { + error: "Application workflow failed", + ...projectAnalysisError(result.error), + }; + }), + }); +}; diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index 2cf700e580..dd0329eb0b 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -41,6 +41,7 @@ export const CLI_COMMANDS = Object.freeze({ inspectManagedArtifact: "inspect-managed-artifact", inspectRecordedCrash: "inspect-recorded-crash", inspectBinaryLayout: "inspect-binary-layout", + inspectAnalysisView: "inspect-analysis-view", inspectFirmwareRegions: "inspect-firmware-regions", extractFirmware: "extract-firmware", inspectAndroidPackage: "inspect-android-package", diff --git a/src/contracts/analysisViewToolContracts.ts b/src/contracts/analysisViewToolContracts.ts new file mode 100644 index 0000000000..1378722b53 --- /dev/null +++ b/src/contracts/analysisViewToolContracts.ts @@ -0,0 +1,63 @@ +import { + analysisViewResultSchema, + inspectAnalysisViewInputSchema, +} from "../domain/analysisView/analysisView.js"; +import type { ToolContract } from "./toolContractTypes.js"; +import { toolContractMetadata } from "./toolEffects.js"; +import { evidenceResultOf } from "./toolOutputSchemas.js"; + +const RETAINED_EXAMPLE_ID = `ev_${"a".repeat(64)}`; + +/** Selected views of already completed layout and JavaScript analysis Evidence. */ +export const ANALYSIS_VIEW_TOOL_CONTRACTS = [ + { + name: "inspect_analysis_view", + ...toolContractMetadata("inspect_analysis_view"), + kind: "application", + description: + "Inspect a selected view of completed binary-layout or JavaScript application Evidence without repeating analysis. Use an exact same-session retained evidence_id or portable inline Evidence. Select a summary, layout mitigations/linkage facet, one section/symbol/module, or a stable page with a positive limit. Module pages contain JavaScript assets, bundled modules and source modules; use node_id when paths are ambiguous or unavailable. Returns selected facts inline with 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 retained Evidence when needed.", + inputSchema: inspectAnalysisViewInputSchema, + outputSchema: evidenceResultOf(analysisViewResultSchema), + examples: [ + { + title: "Summarize retained JavaScript application Evidence", + input: { + source: { + kind: "retained-evidence", + evidence_id: RETAINED_EXAMPLE_ID, + }, + view: { kind: "summary" }, + }, + }, + { + title: "Read one ELF section from retained layout Evidence", + input: { + source: { + kind: "retained-evidence", + evidence_id: RETAINED_EXAMPLE_ID, + }, + view: { + kind: "item", + collection: "sections", + selector: { name: ".text" }, + }, + }, + }, + { + title: "Page module identities from retained application Evidence", + input: { + source: { + kind: "retained-evidence", + evidence_id: RETAINED_EXAMPLE_ID, + }, + view: { + kind: "page", + collection: "modules", + offset: 0, + limit: 32, + }, + }, + }, + ], + }, +] as const satisfies readonly ToolContract[]; diff --git a/src/contracts/contractSnapshot.test.ts b/src/contracts/contractSnapshot.test.ts index f0e7a6ce94..2861e966e4 100644 --- a/src/contracts/contractSnapshot.test.ts +++ b/src/contracts/contractSnapshot.test.ts @@ -1,4 +1,5 @@ import { EVM_TOOL_CONTRACTS } from "./evm/evmToolContracts.js"; +import { ANALYSIS_VIEW_TOOL_CONTRACTS } from "./analysisViewToolContracts.js"; import { BINARY_DIAGNOSTICS_TOOL_CONTRACTS } from "./native/binaryDiagnosticsToolContracts.js"; import { describe, expect, it } from "vitest"; import { z } from "zod"; @@ -72,6 +73,7 @@ describe("tool contract surface", () => { ...ENHANCED_TOOL_CONTRACTS, ...NATIVE_TOOL_CONTRACTS, ...BINARY_DIAGNOSTICS_TOOL_CONTRACTS, + ...ANALYSIS_VIEW_TOOL_CONTRACTS, ...ARTIFACT_TOOL_CONTRACTS, ...EVM_TOOL_CONTRACTS, ...ANDROID_TOOL_CONTRACTS, diff --git a/src/contracts/promptContracts.ts b/src/contracts/promptContracts.ts index 534515a7db..833ac172ae 100644 --- a/src/contracts/promptContracts.ts +++ b/src/contracts/promptContracts.ts @@ -131,9 +131,9 @@ export const PROMPT_CONTRACTS = [ "Search relevant strings or procedures and analyze the matching procedures; pass names or addresses directly when already known.", }, { - tools: ["trace_application_feature"], + tools: ["inspect_analysis_view", "trace_application_feature"], instruction: - "When application-graph Evidence is relevant, trace a route, API, channel, module, string, or native export across layers.", + "When inspect_binary_layout or analyze_javascript_application Evidence is already retained, call inspect_analysis_view with that evidence_id for a summary, one section/symbol/module, or a stable page. Do not re-run the producer. Use trace_application_feature once a route, API, channel, module, string, or native export seed is known.", }, { tools: [ diff --git a/src/contracts/toolContracts.test.ts b/src/contracts/toolContracts.test.ts index 2c4431eba1..b895cd7820 100644 --- a/src/contracts/toolContracts.test.ts +++ b/src/contracts/toolContracts.test.ts @@ -1,4 +1,5 @@ import { EVM_TOOL_CONTRACTS } from "./evm/evmToolContracts.js"; +import { ANALYSIS_VIEW_TOOL_CONTRACTS } from "./analysisViewToolContracts.js"; import { BINARY_DIAGNOSTICS_TOOL_CONTRACTS } from "./native/binaryDiagnosticsToolContracts.js"; import { describe, expect, it } from "vitest"; @@ -26,6 +27,7 @@ const GROUPS = { enhanced: ENHANCED_TOOL_CONTRACTS, native: NATIVE_TOOL_CONTRACTS, binary_diagnostics: BINARY_DIAGNOSTICS_TOOL_CONTRACTS, + analysis_view: ANALYSIS_VIEW_TOOL_CONTRACTS, artifact: ARTIFACT_TOOL_CONTRACTS, evm: EVM_TOOL_CONTRACTS, managed: MANAGED_TOOL_CONTRACTS, diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts index 304ef41d5f..82a6c513dc 100644 --- a/src/contracts/toolContracts.ts +++ b/src/contracts/toolContracts.ts @@ -1,4 +1,5 @@ import { EVM_TOOL_CONTRACTS } from "./evm/evmToolContracts.js"; +import { ANALYSIS_VIEW_TOOL_CONTRACTS } from "./analysisViewToolContracts.js"; import { BINARY_DIAGNOSTICS_TOOL_CONTRACTS } from "./native/binaryDiagnosticsToolContracts.js"; import { NATIVE_TOOL_CONTRACTS } from "./native/nativeToolContracts.js"; import { ARTIFACT_TOOL_CONTRACTS } from "./artifactToolContracts.js"; @@ -26,6 +27,7 @@ export const TOOL_CONTRACTS = [ ...ENHANCED_TOOL_CONTRACTS, ...NATIVE_TOOL_CONTRACTS, ...BINARY_DIAGNOSTICS_TOOL_CONTRACTS, + ...ANALYSIS_VIEW_TOOL_CONTRACTS, ...ARTIFACT_TOOL_CONTRACTS, ...EVM_TOOL_CONTRACTS, ...MANAGED_TOOL_CONTRACTS, diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index dbe2b3bc9a..35a51010ba 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -80,6 +80,7 @@ export const TOOL_EFFECTS: Readonly> = { writesFilesystem: true, launchesProcess: true, }), + inspect_analysis_view: evidence, inspect_firmware_regions: effects({ mutatesSession: true, launchesProcess: true, diff --git a/src/domain/analysisView/analysisView.test.ts b/src/domain/analysisView/analysisView.test.ts new file mode 100644 index 0000000000..554209a79b --- /dev/null +++ b/src/domain/analysisView/analysisView.test.ts @@ -0,0 +1,526 @@ +import { expect, it } from "vitest"; + +import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../contracts/javascript/javascriptRuntimeReconciliationExample.js"; +import { + analysisViewBindJavaScriptGraphs, + analysisViewJavaScriptAnalysis, + analysisViewJavaScriptAnalysisWithSource, + analysisViewLayoutEvidence, + analysisViewLayoutFixture, +} from "../../../tests/fixtures/analysisView.js"; +import { + createJavaScriptApplicationGraph, + createJavaScriptApplicationNode, + createImmutableJavaScriptApplicationGraphSteps, +} from "../javascript/javascriptApplicationGraph.js"; +import { + createJavaScriptSemanticGraph, + createImmutableJavaScriptSemanticGraphSteps, +} from "../javascript/javascriptSemanticGraph.js"; +import { + completeWithinViewCoverage, + inspectAnalysisViewInputSchema, + pageViewCoverage, + projectAnalysisView, + type AnalysisViewParent, +} from "./analysisView.js"; +import { javascriptModulePath } from "./javascriptApplicationView.js"; + +const layoutParent = (): AnalysisViewParent => { + const evidence = analysisViewLayoutEvidence(); + return { + evidenceId: evidence.evidence_id, + operation: evidence.operation, + normalizedResult: evidence.normalized_result, + limitations: evidence.limitations, + }; +}; + +const javascriptParent = ( + analysis = analysisViewJavaScriptAnalysis(), +): AnalysisViewParent => ({ + evidenceId: JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE.evidence_id, + operation: "analyze_javascript_application", + normalizedResult: analysis, + limitations: JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE.limitations, +}); + +it("accepts caller-selected page sizes and rejects malformed bounds", () => { + const input = { + source: { kind: "retained-evidence", evidence_id: `ev_${"a".repeat(64)}` }, + view: { kind: "page", collection: "sections", offset: 0, limit: 500 }, + }; + expect(inspectAnalysisViewInputSchema.safeParse(input).success).toBe(true); + for (const limit of [0, -1, 0.5, Infinity, Number.MAX_SAFE_INTEGER + 1]) + expect( + inspectAnalysisViewInputSchema.safeParse({ + ...input, + view: { ...input.view, limit }, + }).success, + ).toBe(false); +}); + +it("projects layout summary, facet, item, and stable pages", () => { + const parent = layoutParent(); + const layout = analysisViewLayoutFixture(); + const summary = projectAnalysisView(parent, { kind: "summary" }); + if (!summary.ok) throw summary.error; + expect(summary.value).toMatchObject({ + kind: "summary", + parent_operation: "inspect_binary_layout", + summary: { + format: "elf", + counts: { sections: 3, symbols: 3, segments: 0, relocations: 0 }, + }, + coverage: completeWithinViewCoverage(6, 6), + }); + const mitigations = projectAnalysisView(parent, { + kind: "facet", + facet: "mitigations", + }); + if (!mitigations.ok) throw mitigations.error; + if (mitigations.value.kind !== "facet") + throw new Error("expected facet view"); + expect(mitigations.value.facet).toEqual(layout.mitigations); + const data = projectAnalysisView(parent, { + kind: "item", + collection: "sections", + selector: { name: ".data" }, + }); + if (!data.ok) throw data.error; + if (data.value.kind !== "item") throw new Error("expected item view"); + expect(data.value.item).toMatchObject({ + index: 2, + name: { display: ".data" }, + }); + const indexed = projectAnalysisView(parent, { + kind: "item", + collection: "sections", + selector: { index: 0 }, + }); + if (!indexed.ok) throw indexed.error; + expect(indexed.value.view_digest).not.toBe(data.value.view_digest); + expect(indexed.value.view_digest).not.toBe(parent.evidenceId.slice(3)); + const page = projectAnalysisView(parent, { + kind: "page", + collection: "sections", + offset: 0, + limit: 2, + }); + if (!page.ok) throw page.error; + expect(page.value).toMatchObject({ + kind: "page", + coverage: { + status: "page", + examined: 2, + total: 3, + next_offset: 2, + exhausted: false, + }, + }); + if (page.value.kind !== "page") throw new Error("expected page view"); + expect(page.value.items).toHaveLength(2); + const exhausted = projectAnalysisView(parent, { + kind: "page", + collection: "symbols", + offset: 3, + limit: 8, + }); + if (!exhausted.ok) throw exhausted.error; + expect(exhausted.value).toMatchObject({ + items: [], + coverage: pageViewCoverage(3, 0, 3), + }); +}); + +it("rejects ambiguous layout names with candidate indexes", () => { + const parent = layoutParent(); + const result = projectAnalysisView(parent, { + kind: "item", + collection: "sections", + selector: { name: ".text" }, + }); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisInputError", + issues: [ + { + path: ["view", "selector", "name"], + expected: [{ index: 0 }, { index: 1 }], + }, + ], + }, + }); +}); + +it("projects JavaScript summary and preserves selected module observations", () => { + const analysis = analysisViewJavaScriptAnalysisWithSource(); + const parent = javascriptParent(analysis); + const summary = projectAnalysisView(parent, { kind: "summary" }); + if (!summary.ok) throw summary.error; + if (summary.value.kind !== "summary") throw new Error("expected summary"); + expect(summary.value.summary).toMatchObject({ + format: "directory", + limitation_count: 0, + }); + expect(JSON.stringify(summary.value)).not.toMatch(/"graph"/); + expect(summary.value.summary).not.toHaveProperty("semantic_graph"); + const item = projectAnalysisView(parent, { + kind: "item", + collection: "modules", + selector: { path: "renderer.js" }, + }); + if (!item.ok) throw item.error; + const moduleNode = analysis.graph.nodes[0]; + if (moduleNode === undefined) throw new Error("missing module"); + expect(javascriptModulePath(moduleNode)).toBe("renderer.js"); + if (item.value.kind !== "item") throw new Error("expected item view"); + expect(item.value.item).toMatchObject({ + path: "renderer.js", + kind: "javascript-asset", + }); + expect(JSON.stringify(item.value.item)).toContain("export const secret = 1;"); + expect(JSON.stringify(item.value.item)).toContain("should not leak"); + const page = projectAnalysisView(parent, { + kind: "page", + collection: "modules", + offset: 0, + limit: 8, + }); + if (!page.ok) throw page.error; + if (page.value.kind !== "page") throw new Error("expected page view"); + expect(page.value.items).toEqual([ + { + node_id: analysis.graph.nodes[0]?.node_id, + kind: "javascript-asset", + path: "renderer.js", + }, + ]); + expect(page.value.coverage).toMatchObject({ + status: "page", + examined: 1, + total: 1, + next_offset: null, + exhausted: true, + }); +}); + +it("rejects ambiguous JavaScript module paths with candidate node ids", () => { + const analysis = analysisViewJavaScriptAnalysis(); + const first = analysis.graph.nodes[0]; + if (first === undefined) throw new Error("missing module"); + const duplicate = createJavaScriptApplicationNode({ + kind: first.kind, + identity: { + strategy: "content-digest", + stability: "global-exact", + sha256: "c".repeat(64), + }, + observations: first.observations.map((observation) => ({ + label: observation.label, + properties: observation.properties, + evidence: { + ...observation.evidence, + artifact: { + ...observation.evidence.artifact, + sha256: "c".repeat(64), + artifact_id: `art_${"c".repeat(64)}`, + }, + }, + })), + }); + const graph = createJavaScriptApplicationGraph({ + schema: "JavaScriptApplicationGraph", + root_node_ids: [first.node_id, duplicate.node_id], + nodes: [first, duplicate], + edges: [], + coverage: analysis.graph.coverage, + limitations: analysis.graph.limitations, + }); + const parent = javascriptParent( + analysisViewBindJavaScriptGraphs(analysis, graph), + ); + const result = projectAnalysisView(parent, { + kind: "item", + collection: "modules", + selector: { path: "renderer.js" }, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected ambiguous path"); + expect(result.error).toMatchObject({ + _tag: "AnalysisInputError", + issues: [ + { + path: ["view", "selector", "path"], + expected: expect.arrayContaining([ + { node_id: first.node_id }, + { node_id: duplicate.node_id }, + ]), + }, + ], + }); +}); + +it("pages actual modules, including unknown and empty paths, without unrelated roles", () => { + const analysis = analysisViewJavaScriptAnalysis(); + const original = analysis.graph.nodes[0]; + if (original === undefined) throw new Error("missing module"); + const nodes = [ + original, + ...( + [ + "asar-entry", + "electron-renderer", + "javascript-module", + "source-module", + ] as const + ).map((kind) => + createJavaScriptApplicationNode({ + kind, + identity: + kind === "source-module" + ? { + strategy: "source-map-original", + stability: "source-map-exact", + original_source: "", + source_map_sha256: "c".repeat(64), + source_sha256: null, + } + : kind === "javascript-module" + ? { + strategy: "artifact-local-key", + stability: "artifact-version", + artifact_sha256: "c".repeat(64), + namespace: kind, + key: "module", + } + : original.identity, + observations: original.observations.map((observation) => ({ + label: observation.label, + properties: {}, + evidence: + kind === "javascript-module" || kind === "source-module" + ? { + ...observation.evidence, + artifact: { + ...observation.evidence.artifact, + sha256: "c".repeat(64), + artifact_id: `art_${"c".repeat(64)}`, + }, + ...(kind === "javascript-module" + ? { + state: "unknown", + confidence: "unknown", + limitations: ["Source location unavailable"], + location: { + available: false, + reason: "unknown", + detail: "Source location unavailable", + }, + } + : {}), + } + : observation.evidence, + })), + }), + ), + ]; + const graph = createJavaScriptApplicationGraph({ + schema: "JavaScriptApplicationGraph", + root_node_ids: nodes.map((node) => node.node_id), + nodes, + edges: [], + coverage: analysis.graph.coverage, + limitations: [], + }); + const parent = javascriptParent( + analysisViewBindJavaScriptGraphs(analysis, graph), + ); + const page = projectAnalysisView(parent, { + kind: "page", + collection: "modules", + offset: 0, + limit: 1000, + }); + if (!page.ok) throw page.error; + expect(page.value).toMatchObject({ + coverage: { total: 3, examined: 3, exhausted: true }, + }); + if (page.value.kind !== "page") throw new Error("expected page"); + expect(page.value.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: "javascript-module", path: null }), + expect.objectContaining({ kind: "source-module", path: "" }), + ]), + ); + const emptyPath = projectAnalysisView(parent, { + kind: "item", + collection: "modules", + selector: { path: "" }, + }); + expect(emptyPath.ok).toBe(true); + const role = nodes.find((node) => node.kind === "electron-renderer"); + if (role === undefined) throw new Error("missing role"); + expect( + projectAnalysisView(parent, { + kind: "item", + collection: "modules", + selector: { node_id: role.node_id }, + }).ok, + ).toBe(false); +}); + +const complete = (steps: Generator): Value => { + let step = steps.next(); + while (!step.done) step = steps.next(); + return step.value; +}; + +it("reuses authenticated graphs while rejecting malformed metadata and imported companions", () => { + const analysis = analysisViewJavaScriptAnalysis(); + const { graph_id: _applicationId, ...application } = analysis.graph; + const graph = complete( + createImmutableJavaScriptApplicationGraphSteps(application), + ); + const { graph_id: _semanticId, ...semantic } = analysis.semantic_graph; + const semanticGraph = complete( + createImmutableJavaScriptSemanticGraphSteps({ + ...semantic, + application_graph_id: graph.graph_id, + }), + ); + const owned = { ...analysis, graph, semantic_graph: semanticGraph }; + expect( + projectAnalysisView(javascriptParent(owned), { kind: "summary" }).ok, + ).toBe(true); + for (const malformed of [ + { ...owned, root_artifact_sha256: "d".repeat(64) }, + { + ...owned, + statistics: { ...owned.statistics, parsed_javascript_files: -1 }, + }, + { + ...owned, + semantic_graph: Object.freeze({ + ...semanticGraph, + application_graph_id: "jag_" + "d".repeat(64), + }), + }, + ]) { + const result = projectAnalysisView( + { ...javascriptParent(owned), normalizedResult: malformed }, + { kind: "summary" }, + ); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisInputError", + issues: expect.arrayContaining([ + expect.objectContaining({ + path: expect.arrayContaining(["source", "normalized_result"]), + }), + ]), + }, + }); + } +}); + +it("retains semantic coverage and limitations in a summary", () => { + const analysis = analysisViewJavaScriptAnalysis(); + const { graph_id: _graphId, ...semantic } = analysis.semantic_graph; + const semanticGraph = createJavaScriptSemanticGraph({ + ...semantic, + coverage: { + ...semantic.coverage, + status: "partial", + truncated: true, + omitted_nodes: null, + omitted_relations: null, + limits: [ + { name: "semantic_graph_node_ceiling", value: 100000, unit: "items" }, + ], + }, + limitations: ["Some semantic nodes could not be retained."], + }); + const result = projectAnalysisView( + javascriptParent({ ...analysis, semantic_graph: semanticGraph }), + { kind: "summary" }, + ); + if (!result.ok) throw result.error; + expect(result.value).toMatchObject({ + summary: { coverage: { semantic: { status: "partial", truncated: true } } }, + limitations: ["Some semantic nodes could not be retained."], + unknowns: expect.arrayContaining([ + "semantic_graph.coverage.status is partial", + ]), + }); +}); + +it("does not match unavailable layout names using a display placeholder", () => { + const layout = analysisViewLayoutFixture(); + const section = layout.sections[2]; + if (section === undefined) throw new Error("missing section"); + const result = projectAnalysisView( + { + ...layoutParent(), + normalizedResult: { + ...layout, + sections: [ + ...layout.sections.slice(0, 2), + { + ...section, + name: { + ...section.name, + display: "", + unknown_reason: "Name bytes unavailable", + bytes_base64: null, + location: null, + }, + }, + ], + }, + }, + { + kind: "item", + collection: "sections", + selector: { name: "" }, + }, + ); + expect(result.ok).toBe(false); +}); + +it("rejects unsupported parent operations and incompatible collections", () => { + const unsupported = projectAnalysisView( + { + evidenceId: `ev_${"d".repeat(64)}`, + operation: "inspect_recorded_crash", + normalizedResult: { path: "/artifacts/crash.core" }, + limitations: [], + }, + { kind: "summary" }, + ); + expect(unsupported).toMatchObject({ + ok: false, + error: { _tag: "AnalysisUnsupportedTargetError" }, + }); + const modulesOnLayout = projectAnalysisView(layoutParent(), { + kind: "page", + collection: "modules", + offset: 0, + limit: 1, + }); + expect(modulesOnLayout).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); + const sectionsOnJs = projectAnalysisView(javascriptParent(), { + kind: "item", + collection: "sections", + selector: { name: ".text" }, + }); + expect(sectionsOnJs).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); +}); diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts new file mode 100644 index 0000000000..de778f8949 --- /dev/null +++ b/src/domain/analysisView/analysisView.ts @@ -0,0 +1,341 @@ +import { z } from "zod"; + +import { + AnalysisInputError, + type AnalysisInputIssue, + AnalysisUnsupportedTargetError, +} from "../analysisErrorCore.js"; +import { digestCanonicalValue } from "../canonicalDigest.js"; +import { digestSchema, prefixedDigestSchema } from "../digests.js"; +import { evidenceSchema } from "../evidence.js"; +import { jsonObjectSchema, jsonValueSchema } from "../jsonValue.js"; +import { err, ok, type Result } from "../result.js"; +import type { AnalysisError } from "../analysisErrorBase.js"; +import { binaryLayoutSchema } from "../native/binaryLayout.js"; +import { + parseOwnedJavaScriptApplicationAnalysisSteps, + type JavaScriptApplicationAnalysisResult, +} from "../javascript/javascriptApplicationAnalysis.js"; +import { analysisInputErrorFromIssues } from "../inputIssueProjection.js"; +import { projectBinaryLayoutView } from "./binaryLayoutView.js"; +import { projectJavaScriptApplicationView } from "./javascriptApplicationView.js"; + +/** Public name of the selected-view workflow. */ +export const INSPECT_ANALYSIS_VIEW_OPERATION = "inspect_analysis_view" as const; + +const summaryViewSchema = z.strictObject({ kind: z.literal("summary") }); +const facetViewSchema = z.strictObject({ + kind: z.literal("facet"), + facet: z.enum(["mitigations", "linkage"]), +}); +const itemSelectorSchema = z.union([ + z.strictObject({ index: z.number().int().nonnegative() }), + z.strictObject({ name: z.string() }), + z.strictObject({ + path: z + .string() + .describe( + "Exact observed module path or original source-map reference from a module page; an empty reference is valid.", + ), + }), + z.strictObject({ node_id: prefixedDigestSchema("jag_node") }), +]); +const itemViewSchema = z + .strictObject({ + kind: z.literal("item"), + collection: z.enum(["sections", "symbols", "modules"]), + selector: itemSelectorSchema, + }) + .superRefine((value, context) => { + const selector = value.selector; + if (value.collection === "modules") { + if (!("path" in selector) && !("node_id" in selector)) + context.addIssue({ + code: "custom", + path: ["selector"], + message: + "Module items are selected by exact path or node_id, not index or name.", + }); + return; + } + if (!("index" in selector) && !("name" in selector)) + context.addIssue({ + code: "custom", + path: ["selector"], + message: + "Section and symbol items are selected by index or exact name, not path or node_id.", + }); + }); +const pageViewSchema = z.strictObject({ + kind: z.literal("page"), + collection: z.enum(["sections", "symbols", "modules"]), + offset: z.number().int().nonnegative(), + limit: z.number().int().positive(), +}); + +/** Caller-selected projection of already completed analysis Evidence. */ +export const analysisViewRequestSchema = z.discriminatedUnion("kind", [ + summaryViewSchema, + facetViewSchema, + itemViewSchema, + pageViewSchema, +]); + +/** Inline Evidence or an exact same-session retained reference. */ +export const analysisViewSourceSchema = z.discriminatedUnion("kind", [ + z.strictObject({ + kind: z.literal("retained-evidence"), + evidence_id: prefixedDigestSchema("ev"), + }), + z.strictObject({ + kind: z.literal("inline"), + evidence: evidenceSchema, + }), +]); + +/** Public inspect_analysis_view input. */ +export const inspectAnalysisViewInputSchema = z.strictObject({ + source: analysisViewSourceSchema, + view: analysisViewRequestSchema, +}); + +const coverageSchema = z.strictObject({ + status: z.enum(["complete-within-view", "page", "empty"]), + examined: z.number().int().nonnegative(), + total: z.number().int().nonnegative(), + next_offset: z.number().int().nonnegative().nullable(), + exhausted: z.boolean(), +}); + +const artifactSchema = z.strictObject({ + path: z.string().min(1), + sha256: digestSchema, +}); + +const layoutSummarySchema = z.strictObject({ + bytes: z.number().int().nonnegative(), + format: z.literal("elf"), + architecture: z.strictObject({ + machine: z.literal("EM_X86_64"), + bits: z.literal(64), + byte_order: z.literal("little"), + }), + image_type: z.enum(["ET_EXEC", "ET_DYN", "ET_REL"]), + entry: z.strictObject({ + reported_value: z.string().min(1), + meaning: z.enum(["linked-virtual-address", "not-applicable", "absent"]), + execution_status: z.literal("unknown"), + }), + counts: z.strictObject({ + sections: z.number().int().nonnegative(), + segments: z.number().int().nonnegative(), + symbols: z.number().int().nonnegative(), + relocations: z.number().int().nonnegative(), + }), + limitation_count: z.number().int().nonnegative(), +}); + +const javascriptSummarySchema = z.strictObject({ + input_path: z.string().min(1), + format: z.enum(["asar", "directory"]), + root_artifact_sha256: digestSchema, + statistics: jsonObjectSchema, + electron: jsonObjectSchema, + coverage: jsonObjectSchema, + limitation_count: z.number().int().nonnegative(), +}); + +const viewResultBase = { + parent_evidence_id: prefixedDigestSchema("ev"), + parent_operation: z.enum([ + "inspect_binary_layout", + "analyze_javascript_application", + ]), + parent_digest: digestSchema, + view_digest: digestSchema, + artifact: artifactSchema, + coverage: coverageSchema, + limitations: z.array(z.string()), + unknowns: z.array(z.string()), +} as const; + +/** Projected facts for one selected view of retained analysis Evidence. */ +export const analysisViewResultSchema = z.discriminatedUnion("kind", [ + z.strictObject({ + kind: z.literal("summary"), + view: summaryViewSchema, + summary: z.union([layoutSummarySchema, javascriptSummarySchema]), + ...viewResultBase, + }), + z.strictObject({ + kind: z.literal("facet"), + view: facetViewSchema, + facet: jsonValueSchema, + ...viewResultBase, + }), + z.strictObject({ + kind: z.literal("item"), + view: itemViewSchema, + item: jsonValueSchema, + ...viewResultBase, + }), + z.strictObject({ + kind: z.literal("page"), + view: pageViewSchema, + items: z.array(jsonValueSchema), + ...viewResultBase, + }), +]); + +export type AnalysisViewRequest = z.output; +export type InspectAnalysisViewInput = z.output< + typeof inspectAnalysisViewInputSchema +>; +export type AnalysisViewResult = z.output; +export type AnalysisViewCoverage = z.output; + +/** Parent record already resolved from inline Evidence or a session reference. */ +export interface AnalysisViewParent { + readonly evidenceId: string; + readonly operation: string; + readonly normalizedResult: unknown; + readonly limitations: readonly string[]; +} + +export { + completeWithinViewCoverage, + pageViewCoverage, +} from "./analysisViewCoverage.js"; + +/** Construct a typed input failure for one selected-view constraint. */ +export const analysisViewInputError = ( + issues: readonly AnalysisInputIssue[], +): AnalysisInputError => + new AnalysisInputError(INSPECT_ANALYSIS_VIEW_OPERATION, undefined, issues); + +/** SHA-256 of the canonical projected JSON, excluding this digest field. */ +export const analysisViewDigest = ( + projection: Omit, +): string => digestCanonicalValue(projection, "Analysis view"); + +/** View payload before the content digest is attached. */ +export type UnsignedAnalysisView = + | Omit< + Extract, + "view_digest" + > + | Omit, "view_digest"> + | Omit, "view_digest"> + | Omit, "view_digest">; + +/** Attach the content digest that must change when projected bytes change. */ +export const sealAnalysisView = ( + projection: UnsignedAnalysisView, +): AnalysisViewResult => + analysisViewResultSchema.parse({ + ...projection, + view_digest: analysisViewDigest(projection), + }); + +const parentDigest = (evidenceId: string): string => evidenceId.slice(3); + +const parseJavaScriptParent = ( + input: unknown, +): Result => { + try { + // Only the existing realm-private graph proofs permit reuse. Imported or + // merely frozen graphs still receive full canonical validation. + const steps = parseOwnedJavaScriptApplicationAnalysisSteps(input); + let step = steps.next(); + while (!step.done) step = steps.next(); + return ok(step.value); + } catch (cause: unknown) { + if (!(cause instanceof z.ZodError)) throw cause; + return err( + analysisInputErrorFromIssues( + INSPECT_ANALYSIS_VIEW_OPERATION, + cause.issues.map((issue) => ({ + ...issue, + path: ["source", "normalized_result", ...issue.path], + })), + { source: { normalized_result: input } }, + { cause }, + ), + ); + } +}; + +const unsupportedParent = ( + parent: AnalysisViewParent, +): AnalysisUnsupportedTargetError => + new AnalysisUnsupportedTargetError( + INSPECT_ANALYSIS_VIEW_OPERATION, + parentArtifactPath(parent), + `Parent Evidence operation ${JSON.stringify(parent.operation)} is not inspect_binary_layout or analyze_javascript_application.`, + ); + +const parentArtifactPath = (parent: AnalysisViewParent): string => { + if ( + typeof parent.normalizedResult === "object" && + parent.normalizedResult !== null && + "artifact" in parent.normalizedResult && + typeof parent.normalizedResult.artifact === "object" && + parent.normalizedResult.artifact !== null && + "path" in parent.normalizedResult.artifact && + typeof parent.normalizedResult.artifact.path === "string" + ) + return parent.normalizedResult.artifact.path; + if ( + typeof parent.normalizedResult === "object" && + parent.normalizedResult !== null && + "input_path" in parent.normalizedResult && + typeof parent.normalizedResult.input_path === "string" + ) + return parent.normalizedResult.input_path; + return parent.evidenceId; +}; + +/** Project one selected view from authenticated parent analysis Evidence. */ +export const projectAnalysisView = ( + parent: AnalysisViewParent, + view: AnalysisViewRequest, +): Result => { + const digest = parentDigest(parent.evidenceId); + if (!digestSchema.safeParse(digest).success) + return err( + analysisViewInputError([ + { + path: ["source"], + reason: "invalid_format", + message: "Parent Evidence ID is not a digest-backed identifier.", + }, + ]), + ); + if (parent.operation === "inspect_binary_layout") { + const layout = binaryLayoutSchema.safeParse(parent.normalizedResult); + if (!layout.success) + return err( + analysisViewInputError([ + { + path: ["source"], + reason: "invalid_value", + message: + "Parent Evidence result does not match inspect_binary_layout.", + }, + ]), + ); + const projected = projectBinaryLayoutView(parent, layout.data, view); + return projected.ok ? ok(sealAnalysisView(projected.value)) : projected; + } + if (parent.operation === "analyze_javascript_application") { + const analysis = parseJavaScriptParent(parent.normalizedResult); + if (!analysis.ok) return analysis; + const projected = projectJavaScriptApplicationView( + parent, + analysis.value, + view, + ); + return projected.ok ? ok(sealAnalysisView(projected.value)) : projected; + } + return err(unsupportedParent(parent)); +}; diff --git a/src/domain/analysisView/analysisViewCoverage.ts b/src/domain/analysisView/analysisViewCoverage.ts new file mode 100644 index 0000000000..7f592a8827 --- /dev/null +++ b/src/domain/analysisView/analysisViewCoverage.ts @@ -0,0 +1,29 @@ +import type { AnalysisViewCoverage } from "./analysisView.js"; + +/** Coverage for a summary, facet, or single selected object. */ +export const completeWithinViewCoverage = ( + examined: number, + total: number, +): AnalysisViewCoverage => ({ + status: "complete-within-view", + examined, + total, + next_offset: null, + exhausted: true, +}); + +/** Coverage for a stable page, including an offset past the collection. */ +export const pageViewCoverage = ( + offset: number, + examined: number, + total: number, +): AnalysisViewCoverage => { + const exhausted = offset >= total || offset + examined >= total; + return { + status: examined === 0 ? "empty" : "page", + examined, + total, + next_offset: exhausted ? null : offset + examined, + exhausted, + }; +}; diff --git a/src/domain/analysisView/binaryLayoutView.ts b/src/domain/analysisView/binaryLayoutView.ts new file mode 100644 index 0000000000..5a52904a85 --- /dev/null +++ b/src/domain/analysisView/binaryLayoutView.ts @@ -0,0 +1,239 @@ +import { + AnalysisInputError, + type AnalysisInputIssue, +} from "../analysisErrorCore.js"; +import type { AnalysisError } from "../analysisErrorBase.js"; +import { uniqueSorted } from "../canonicalOrdering.js"; +import type { BinaryLayout } from "../native/binaryLayout.js"; +import { err, ok, type Result } from "../result.js"; +import type { + AnalysisViewParent, + AnalysisViewRequest, + UnsignedAnalysisView, +} from "./analysisView.js"; +import { + completeWithinViewCoverage as completeCoverage, + pageViewCoverage as pageCoverage, +} from "./analysisViewCoverage.js"; + +const OPERATION = "inspect_analysis_view"; + +type LayoutName = BinaryLayout["sections"][number]["name"]; + +const inputError = ( + issues: readonly AnalysisInputIssue[], +): AnalysisInputError => new AnalysisInputError(OPERATION, undefined, issues); + +const layoutUnknowns = (layout: BinaryLayout): readonly string[] => { + const unknowns = [ + "runtime_load_base is unknown", + "entry_point.execution_status is unknown", + "relocation_inventory_completeness is unknown", + "linkage.convenience_maps_completeness is unknown", + "linkage.runtime_library_paths are unknown", + ]; + if (layout.mitigations.nx_indicator === null) + unknowns.push("mitigations.nx_indicator is unknown"); + if (layout.mitigations.relro === null) + unknowns.push("mitigations.relro is unknown"); + return unknowns; +}; + +const displayName = (name: LayoutName): string => name.display; + +const namedMatches = ( + rows: readonly Row[], + name: string, +): readonly { readonly index: number; readonly row: Row }[] => + rows.flatMap((row, index) => + row.name.unknown_reason === null && displayName(row.name) === name + ? [{ index, row }] + : [], + ); + +const selectNamed = ( + collection: "sections" | "symbols", + rows: readonly Row[], + name: string, +): Result => { + const matches = namedMatches(rows, name); + if (matches.length === 1) { + const selected = matches[0]; + if (selected !== undefined) return ok(selected.row); + } + if (matches.length === 0) + return err( + inputError([ + { + path: ["view", "selector", "name"], + reason: "invalid_value", + message: `No ${collection} named ${JSON.stringify(name)}. Collection has ${String(rows.length)} entries; page it to inspect exact indexes and names.`, + }, + ]), + ); + return err( + inputError([ + { + path: ["view", "selector", "name"], + reason: "invalid_value", + message: `Multiple ${collection} named ${JSON.stringify(name)}; select one by index.`, + expected: matches.map(({ index }) => ({ index })), + }, + ]), + ); +}; + +const selectIndexed = ( + collection: "sections" | "symbols", + rows: readonly Row[], + index: number, +): Result => { + const row = rows[index]; + if (row !== undefined) return ok(row); + return err( + inputError([ + { + path: ["view", "selector", "index"], + reason: "out_of_range", + message: `${collection} index ${String(index)} is outside 0..${String(Math.max(0, rows.length - 1))} (${String(rows.length)} entries).`, + minimum: 0, + maximum: Math.max(0, rows.length - 1), + }, + ]), + ); +}; + +const pageRows = ( + rows: readonly Row[], + offset: number, + limit: number, +): readonly Row[] => rows.slice(offset, offset + limit); + +const layoutIncompatible = (detail: string): AnalysisError => + inputError([ + { + path: ["view"], + reason: "invalid_value", + message: detail, + }, + ]); + +const parentFields = (parent: AnalysisViewParent, layout: BinaryLayout) => ({ + parent_evidence_id: parent.evidenceId, + parent_operation: "inspect_binary_layout" as const, + parent_digest: parent.evidenceId.slice(3), + artifact: { + path: layout.artifact.path, + sha256: layout.artifact.sha256, + }, + limitations: uniqueSorted([...parent.limitations, ...layout.limitations]), + unknowns: uniqueSorted([...layoutUnknowns(layout)]), +}); + +const layoutSummary = (layout: BinaryLayout) => ({ + bytes: layout.artifact.bytes, + format: layout.format, + architecture: layout.architecture, + image_type: layout.image_type, + entry: layout.entry_point, + counts: { + sections: layout.sections.length, + segments: layout.segments.length, + symbols: layout.symbols.length, + relocations: layout.relocations.length, + }, + limitation_count: layout.limitations.length, +}); + +/** Project one layout view without re-running the decoder. */ +export const projectBinaryLayoutView = ( + parent: AnalysisViewParent, + layout: BinaryLayout, + view: AnalysisViewRequest, +): Result => { + const shared = parentFields(parent, layout); + if (view.kind === "summary") + return ok({ + kind: "summary", + view, + summary: layoutSummary(layout), + coverage: completeCoverage( + layout.sections.length + + layout.segments.length + + layout.symbols.length + + layout.relocations.length, + layout.sections.length + + layout.segments.length + + layout.symbols.length + + layout.relocations.length, + ), + ...shared, + }); + if (view.kind === "facet") + return ok({ + kind: "facet", + view, + facet: view.facet === "mitigations" ? layout.mitigations : layout.linkage, + coverage: completeCoverage(1, 1), + ...shared, + }); + if (view.kind === "item") { + if (view.collection === "modules") + return err( + layoutIncompatible( + "modules collection applies to analyze_javascript_application Evidence.", + ), + ); + const selected = + view.collection === "sections" + ? selectLayoutItem(view.collection, layout.sections, view.selector) + : selectLayoutItem(view.collection, layout.symbols, view.selector); + if (!selected.ok) return selected; + return ok({ + kind: "item", + view, + item: selected.value, + coverage: completeCoverage( + 1, + view.collection === "sections" + ? layout.sections.length + : layout.symbols.length, + ), + ...shared, + }); + } + if (view.collection === "modules") + return err( + layoutIncompatible( + "modules collection applies to analyze_javascript_application Evidence.", + ), + ); + const rows = + view.collection === "sections" ? layout.sections : layout.symbols; + const items = + view.collection === "sections" + ? pageRows(layout.sections, view.offset, view.limit) + : pageRows(layout.symbols, view.offset, view.limit); + return ok({ + kind: "page", + view, + items: [...items], + coverage: pageCoverage(view.offset, items.length, rows.length), + ...shared, + }); +}; + +const selectLayoutItem = ( + collection: "sections" | "symbols", + rows: readonly Row[], + selector: Extract["selector"], +): Result => { + if ("index" in selector) + return selectIndexed(collection, rows, selector.index); + if ("name" in selector) return selectNamed(collection, rows, selector.name); + return err( + layoutIncompatible( + "Section and symbol items are selected by index or exact name.", + ), + ); +}; diff --git a/src/domain/analysisView/javascriptApplicationView.ts b/src/domain/analysisView/javascriptApplicationView.ts new file mode 100644 index 0000000000..5216c72b44 --- /dev/null +++ b/src/domain/analysisView/javascriptApplicationView.ts @@ -0,0 +1,318 @@ +import { + AnalysisInputError, + type AnalysisInputIssue, +} from "../analysisErrorCore.js"; +import type { AnalysisError } from "../analysisErrorBase.js"; +import { uniqueSorted } from "../canonicalOrdering.js"; +import { jsonObjectSchema, type JsonValue } from "../jsonValue.js"; +import type { JavaScriptApplicationAnalysisResult } from "../javascript/javascriptApplicationAnalysis.js"; +import type { ApplicationNode } from "../javascript/javascriptApplicationGraphSchemas.js"; +import { err, ok, type Result } from "../result.js"; +import { + completeWithinViewCoverage as completeCoverage, + pageViewCoverage as pageCoverage, +} from "./analysisViewCoverage.js"; +import type { + AnalysisViewParent, + AnalysisViewRequest, + UnsignedAnalysisView, +} from "./analysisView.js"; + +const OPERATION = "inspect_analysis_view"; + +const inputError = ( + issues: readonly AnalysisInputIssue[], +): AnalysisInputError => new AnalysisInputError(OPERATION, undefined, issues); + +const jsIncompatible = (detail: string): AnalysisError => + inputError([ + { + path: ["view"], + reason: "invalid_value", + message: detail, + }, + ]); + +/** Exact module path or original source-map reference, when available. */ +export const javascriptModulePath = (node: ApplicationNode): string | null => { + if (node.identity.strategy === "canonical-path") return node.identity.path; + if (node.identity.strategy === "source-map-original") + return node.identity.original_source; + for (const observation of node.observations) { + const path = observation.properties.path; + if (typeof path === "string") return path; + const location = observation.evidence.location; + if (location.available && location.value.kind === "artifact-path") + return location.value.path; + if (location.available && location.value.kind === "source-range") + return location.value.source; + } + return null; +}; + +const moduleEntries = ( + nodes: readonly ApplicationNode[], +): readonly { + readonly node: ApplicationNode; + readonly path: string | null; +}[] => + nodes + .filter( + (node) => + node.kind === "javascript-asset" || + node.kind === "javascript-module" || + node.kind === "source-module", + ) + .map((node) => ({ node, path: javascriptModulePath(node) })); + +const exportNames = (node: ApplicationNode): readonly string[] => { + const names = new Set(); + for (const observation of node.observations) { + const value = + observation.properties.export_names ?? observation.properties.exports; + if (!Array.isArray(value)) continue; + for (const entry of value) if (typeof entry === "string") names.add(entry); + } + return [...names]; +}; + +const nodeHashes = (node: ApplicationNode): readonly string[] => { + const hashes = new Set(); + const { identity } = node; + if (identity.strategy === "content-digest") hashes.add(identity.sha256); + if (identity.strategy === "canonical-path") + hashes.add(identity.artifact_sha256); + if (identity.strategy === "source-map-original") { + hashes.add(identity.source_map_sha256); + if (identity.source_sha256 !== null) hashes.add(identity.source_sha256); + } + for (const observation of node.observations) { + for (const key of [ + "sha256", + "container_sha256", + "entry_sha256", + "source_sha256", + ] as const) { + const value = observation.properties[key]; + if (typeof value === "string" && /^[a-f0-9]{64}$/u.test(value)) + hashes.add(value); + } + } + return [...hashes]; +}; + +const sourceRanges = (node: ApplicationNode): readonly JsonValue[] => + node.observations.flatMap((observation) => { + const location = observation.evidence.location; + return location.available && location.value.kind === "source-range" + ? [location.value] + : []; + }); + +const moduleItem = (node: ApplicationNode): JsonValue => ({ + node_id: node.node_id, + kind: node.kind, + path: javascriptModulePath(node), + identity: node.identity, + hashes: [...nodeHashes(node)], + exports: [...exportNames(node)], + source_ranges: [...sourceRanges(node)], + observations: node.observations.map((observation) => ({ + observation_id: observation.observation_id, + label: observation.label, + properties: observation.properties, + evidence: { + ...observation.evidence, + location: observation.evidence.location, + }, + })), +}); + +const applicationUnknownCount = ( + analysis: JavaScriptApplicationAnalysisResult, +): number => + analysis.graph.nodes.reduce( + (count, node) => count + Number(node.kind === "unknown"), + 0, + ); + +const javascriptUnknowns = ( + analysis: JavaScriptApplicationAnalysisResult, +): readonly string[] => { + const unknowns = [ + ...analysis.limitations, + ...analysis.graph.limitations, + ...analysis.semantic_graph.limitations, + ]; + if (analysis.graph.coverage.status !== "complete") + unknowns.push(`graph.coverage.status is ${analysis.graph.coverage.status}`); + if (analysis.semantic_graph.coverage.status !== "complete") + unknowns.push( + `semantic_graph.coverage.status is ${analysis.semantic_graph.coverage.status}`, + ); + if (applicationUnknownCount(analysis) > 0) + unknowns.push( + `${String(applicationUnknownCount(analysis))} application graph unknowns remain in the parent Evidence.`, + ); + if (analysis.semantic_graph.unknowns.length > 0) + unknowns.push( + `${String(analysis.semantic_graph.unknowns.length)} semantic graph unknowns remain in the parent Evidence.`, + ); + return unknowns; +}; + +const parentFields = ( + parent: AnalysisViewParent, + analysis: JavaScriptApplicationAnalysisResult, +) => ({ + parent_evidence_id: parent.evidenceId, + parent_operation: "analyze_javascript_application" as const, + parent_digest: parent.evidenceId.slice(3), + artifact: { + path: analysis.input_path, + sha256: analysis.root_artifact_sha256, + }, + limitations: uniqueSorted([ + ...parent.limitations, + ...analysis.limitations, + ...analysis.graph.limitations, + ...analysis.semantic_graph.limitations, + ]), + unknowns: uniqueSorted([...javascriptUnknowns(analysis)]), +}); + +const selectModule = ( + modules: ReturnType, + selector: Extract["selector"], +): Result => { + if ("node_id" in selector) { + const matches = modules.filter( + ({ node }) => node.node_id === selector.node_id, + ); + if (matches.length === 1) { + const selected = matches[0]; + if (selected !== undefined) return ok(selected.node); + } + return err( + inputError([ + { + path: ["view", "selector", "node_id"], + reason: "invalid_value", + message: `No module has node_id ${JSON.stringify(selector.node_id)}. Page the modules collection to inspect available identities.`, + }, + ]), + ); + } + if ("path" in selector) { + const matches = modules.filter((entry) => entry.path === selector.path); + if (matches.length === 1) { + const selected = matches[0]; + if (selected !== undefined) return ok(selected.node); + } + if (matches.length === 0) + return err( + inputError([ + { + path: ["view", "selector", "path"], + reason: "invalid_value", + message: `No module has path ${JSON.stringify(selector.path)}. Page the modules collection to inspect exact paths and node_id values.`, + }, + ]), + ); + return err( + inputError([ + { + path: ["view", "selector", "path"], + reason: "invalid_value", + message: `Multiple modules have path ${JSON.stringify(selector.path)}; select one by node_id.`, + expected: matches.map((entry) => ({ node_id: entry.node.node_id })), + }, + ]), + ); + } + return err( + jsIncompatible("Module items are selected by exact path or node_id."), + ); +}; + +/** Project one JavaScript application view without re-running reconstruction. */ +export const projectJavaScriptApplicationView = ( + parent: AnalysisViewParent, + analysis: JavaScriptApplicationAnalysisResult, + view: AnalysisViewRequest, +): Result => { + const shared = parentFields(parent, analysis); + const modules = moduleEntries(analysis.graph.nodes); + if (view.kind === "summary") + return ok({ + kind: "summary", + view, + summary: { + input_path: analysis.input_path, + format: analysis.format, + root_artifact_sha256: analysis.root_artifact_sha256, + statistics: jsonObjectSchema.parse(analysis.statistics), + electron: jsonObjectSchema.parse(analysis.summary), + coverage: jsonObjectSchema.parse({ + application: analysis.graph.coverage, + semantic: { + ...analysis.semantic_graph.coverage, + families: analysis.semantic_graph.coverage.families.map( + ({ unknown_ids, ...family }) => ({ + ...family, + unknown_count: unknown_ids.length, + }), + ), + }, + application_unknowns: applicationUnknownCount(analysis), + semantic_unknowns: analysis.semantic_graph.unknowns.length, + }), + limitation_count: analysis.limitations.length, + }, + coverage: completeCoverage(modules.length, analysis.graph.nodes.length), + ...shared, + }); + if (view.kind === "facet") + return err( + jsIncompatible( + "facet views apply to inspect_binary_layout Evidence (mitigations or linkage).", + ), + ); + if (view.kind === "item") { + if (view.collection !== "modules") + return err( + jsIncompatible( + "Section and symbol collections apply to inspect_binary_layout Evidence.", + ), + ); + const selected = selectModule(modules, view.selector); + if (!selected.ok) return selected; + return ok({ + kind: "item", + view, + item: moduleItem(selected.value), + coverage: completeCoverage(1, modules.length), + ...shared, + }); + } + if (view.collection !== "modules") + return err( + jsIncompatible( + "Section and symbol collections apply to inspect_binary_layout Evidence.", + ), + ); + const items = modules + .slice(view.offset, view.offset + view.limit) + .map(({ node, path }) => ({ + node_id: node.node_id, + kind: node.kind, + path, + })); + return ok({ + kind: "page", + view, + items, + coverage: pageCoverage(view.offset, items.length, modules.length), + ...shared, + }); +}; diff --git a/src/generatedPackageMetadata.ts b/src/generatedPackageMetadata.ts index cbb8670a0f..cad2376472 100644 --- a/src/generatedPackageMetadata.ts +++ b/src/generatedPackageMetadata.ts @@ -5,5 +5,5 @@ export const PACKAGE_METADATA = { serverSdkVersion: "2.3.1", clientSdkVersion: "2.3.1", coreSdkVersion: "2.3.1", - skillVersion: "33", + skillVersion: "34", } as const; diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 8678318553..aafb7b8a50 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -4,6 +4,7 @@ import { registerEvmTools } from "./registerEvmTools.js"; import { registerRecordedCrashTools } from "./registerRecordedCrashTools.js"; import { createRecordedCrashService } from "../composition/binaryDiagnostics.js"; import type { RecordedCrashService } from "../application/binaryDiagnostics/RecordedCrashService.js"; +import { registerAnalysisViewTool } from "./registerAnalysisViewTool.js"; import { registerBinaryDiagnosticsTools } from "./registerBinaryDiagnosticsTools.js"; import { createBinaryLayoutService } from "../composition/binaryDiagnostics.js"; import type { BinaryLayoutService } from "../application/binaryDiagnostics/BinaryLayoutService.js"; @@ -221,6 +222,7 @@ export const createServer = ( toolLogger, recordEvidence, ); + registerAnalysisViewTool(server, toolLogger, evidenceById, recordEvidence); registerEvmTools( server, options.evmInterface ?? createEvmInterfaceService(), diff --git a/src/server/registerAnalysisViewTool.ts b/src/server/registerAnalysisViewTool.ts new file mode 100644 index 0000000000..ccb25aa792 --- /dev/null +++ b/src/server/registerAnalysisViewTool.ts @@ -0,0 +1,32 @@ +import type { McpServer } from "@modelcontextprotocol/server"; + +import { inspectAnalysisView } from "../application/analysisView/AnalysisViewService.js"; +import type { EvidenceLookup } from "../application/EvidenceInputResolver.js"; +import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; +import { toolContract } from "../contracts/toolContracts.js"; +import type { Logger } from "../logger.js"; +import { logToolExecution } from "./toolLogging.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; +import { toCallToolResult, toEvidenceToolResult } from "./toolResult.js"; + +/** Bind selected-view projection to its named contract and session Evidence owner. */ +export const registerAnalysisViewTool = ( + server: McpServer, + logger: Logger, + evidenceById?: EvidenceLookup, + recordEvidence?: EvidenceWriter["recordEvidence"], +): void => { + const contract = toolContract("inspect_analysis_view"); + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + async (input) => { + const result = await logToolExecution(logger, contract.name, () => + Promise.resolve(inspectAnalysisView(input, evidenceById)), + ); + if (!result.ok) return toCallToolResult(result, contract); + const recorded = recordEvidence?.(result.value); + return toEvidenceToolResult(result.value, contract, recorded); + }, + ); +}; diff --git a/tests/boundary/cli/analysisViewCli.test.ts b/tests/boundary/cli/analysisViewCli.test.ts new file mode 100644 index 0000000000..08cea192be --- /dev/null +++ b/tests/boundary/cli/analysisViewCli.test.ts @@ -0,0 +1,82 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; + +import { analysisViewLayoutEvidence } from "../../fixtures/analysisView.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "projects one section from a JSON file of inline layout Evidence", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-analysis-view-cli-"); + const parent = analysisViewLayoutEvidence(); + const request = { + source: { kind: "inline", evidence: parent }, + view: { + kind: "item", + collection: "sections", + selector: { name: ".data" }, + }, + }; + const input = join(root, "view.json"); + await mkdir(root, { recursive: true }); + await writeFile(input, JSON.stringify(request)); + const result = await cli.run({ + arguments: ["inspect-analysis-view", input, "--json"], + environment: { + HOME: root, + XDG_CONFIG_HOME: root, + XDG_CACHE_HOME: root, + }, + }); + expect(result.exitCode).toBe(0); + expect(result.json).toMatchObject({ + operation: "inspect_analysis_view", + normalized_result: { + kind: "item", + parent_evidence_id: parent.evidence_id, + item: { name: { display: ".data" } }, + }, + }); + }, +); + +cliTest("rejects malformed JSON before projection", async ({ cli }) => { + const root = await createTestTempDirectory("rea-analysis-view-cli-bad-"); + const result = await cli.run({ + arguments: ["inspect-analysis-view", "{", "--json"], + environment: { + HOME: root, + XDG_CONFIG_HOME: root, + XDG_CACHE_HOME: root, + }, + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "invalid_request", + category: "invalid_input", + details: { operation: "inspect-analysis-view" }, + }); +}); + +cliTest("rejects a request that omits source", async ({ cli }) => { + const root = await createTestTempDirectory("rea-analysis-view-cli-missing-"); + const input = join(root, "missing-source.json"); + await writeFile(input, JSON.stringify({ view: { kind: "summary" } })); + const result = await cli.run({ + arguments: ["inspect-analysis-view", input, "--json"], + environment: { + HOME: root, + XDG_CONFIG_HOME: root, + XDG_CACHE_HOME: root, + }, + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "invalid_request", + category: "invalid_input", + details: { operation: "inspect-analysis-view" }, + }); +}); diff --git a/tests/boundary/cli/jsonInputStatus.test.ts b/tests/boundary/cli/jsonInputStatus.test.ts index 1acd8c61b0..7e768d7ecd 100644 --- a/tests/boundary/cli/jsonInputStatus.test.ts +++ b/tests/boundary/cli/jsonInputStatus.test.ts @@ -7,6 +7,7 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; const JSON_COMMANDS = [ + ["inspect-analysis-view", "inspect-analysis-view"], ["trace-application-feature", "trace-application-feature"], ["trace-javascript-semantics", "trace-javascript-semantics"], ["compare-application-versions", "compare-application-versions"], diff --git a/tests/boundary/cli/productCatalog.test.ts b/tests/boundary/cli/productCatalog.test.ts index e5b25c1e87..e0e0928376 100644 --- a/tests/boundary/cli/productCatalog.test.ts +++ b/tests/boundary/cli/productCatalog.test.ts @@ -10,6 +10,7 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { SUPPORTED_CLIENT_DEFINITIONS } from "../../../src/application/SupportedClients.js"; import { + ANALYSIS_VIEW_PROVIDER, ANDROID_APPLICATION_PROVIDER, APPLE_APPLICATION_PROVIDER, ARTIFACT_GRAPH_PROVIDER, @@ -132,6 +133,7 @@ describe("canonical product catalog", () => { JAVASCRIPT_APPLICATION_PROVIDER, JAVASCRIPT_RUNTIME_RECONCILIATION_PROVIDER, JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER, + ANALYSIS_VIEW_PROVIDER, WEB_SCRIPT_EXPORT_PROVIDER, ] .map(({ id }) => id) @@ -178,6 +180,10 @@ describe("canonical product catalog", () => { "trace_application_feature", "trace_javascript_semantics", ]); + expect( + catalog.providers.find(({ id }) => id === ANALYSIS_VIEW_PROVIDER.id) + ?.capabilities, + ).toEqual(["inspect_analysis_view"]); expect( catalog.providers.find(({ id }) => id === ANDROID_APPLICATION_PROVIDER.id) ?.capabilities, diff --git a/tests/boundary/mcp/analysisViewMcp.test.ts b/tests/boundary/mcp/analysisViewMcp.test.ts new file mode 100644 index 0000000000..d85fa05ec7 --- /dev/null +++ b/tests/boundary/mcp/analysisViewMcp.test.ts @@ -0,0 +1,270 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { expect, it, onTestFinished } from "vitest"; +import { z } from "zod"; + +import { BinaryLayoutService } from "../../../src/application/binaryDiagnostics/BinaryLayoutService.js"; +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { ok } from "../../../src/domain/result.js"; +import { + createJavaScriptApplicationGraph, + createJavaScriptApplicationNode, +} from "../../../src/domain/javascript/javascriptApplicationGraph.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { + analysisViewJavaScriptEvidence, + analysisViewJavaScriptAnalysisWithSource, + analysisViewBindJavaScriptGraphs, + analysisViewLayoutFixture, +} from "../../fixtures/analysisView.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { BINARY_LAYOUT_TEST_PROVIDER } from "../../fixtures/binaryDiagnostics/layout.js"; + +const connect = async (binaryLayout?: BinaryLayoutService) => { + const session = createTestBinarySession(() => { + throw new Error("selected views must not start a deep provider"); + }); + const server = createServer( + session, + session, + binaryLayout === undefined ? {} : { binaryLayout }, + ); + const client = new Client({ name: "analysis-view-mcp", version: "1" }); + onTestFinished(async () => { + await client.close(); + await server.close(); + await session.close(); + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + return { client, session }; +}; + +it("advertises exact schemas and projects one layout section from retained Evidence", async () => { + const layout = analysisViewLayoutFixture(); + const service = new BinaryLayoutService({ + identity: BINARY_LAYOUT_TEST_PROVIDER, + inspect: () => Promise.resolve(ok(layout)), + }); + const { client, session } = await connect(service); + const advertised = (await client.listTools()).tools.find( + (tool) => tool.name === "inspect_analysis_view", + ); + if (advertised?.outputSchema === undefined) + throw new Error("inspect_analysis_view must publish both schemas"); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + const inputSchema: Record = advertised.inputSchema; + const outputSchema: Record = advertised.outputSchema; + expect(ajv.validateSchema(inputSchema)).toBe(true); + expect(ajv.validateSchema(outputSchema)).toBe(true); + expect( + ajv.validate(inputSchema, { + source: { + kind: "retained-evidence", + evidence_id: `ev_${"a".repeat(64)}`, + }, + view: { kind: "summary", approval: true }, + }), + ).toBe(false); + const inspected = await client.callTool({ + name: "inspect_binary_layout", + arguments: { path: layout.artifact.path }, + }); + expect(inspected.isError).not.toBe(true); + const parent = toolContract("inspect_binary_layout").outputSchema.parse( + inspected.structuredContent, + ); + const response = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source: { + kind: "retained-evidence", + evidence_id: parent.evidence_id, + }, + view: { + kind: "item", + collection: "sections", + selector: { name: ".data" }, + }, + }, + }); + expect(response.isError).not.toBe(true); + expect( + ajv.validate(outputSchema, response.structuredContent), + JSON.stringify(ajv.errors), + ).toBe(true); + const parsed = toolContract("inspect_analysis_view").outputSchema.parse( + response.structuredContent, + ); + expect(parsed.result).toMatchObject({ + kind: "item", + parent_evidence_id: parent.evidence_id, + item: { name: { display: ".data" } }, + }); + expect(parsed.result).toEqual( + parseEvidence(parsed.evidence).normalized_result, + ); + expect(session.evidenceById(parsed.evidence_id)).toEqual( + parseEvidence(parsed.evidence), + ); +}); + +it("retains an oversized selected observation and keeps subsequent views usable", async () => { + const { client, session } = await connect(); + const analysis = analysisViewJavaScriptAnalysisWithSource(); + const original = analysis.graph.nodes[0]; + if (original === undefined) throw new Error("missing module"); + const source = "\0".repeat(500_000); + const node = createJavaScriptApplicationNode({ + kind: original.kind, + identity: original.identity, + observations: original.observations.map((observation) => ({ + label: observation.label, + properties: { ...observation.properties, source }, + evidence: observation.evidence, + })), + }); + const graph = createJavaScriptApplicationGraph({ + schema: "JavaScriptApplicationGraph", + root_node_ids: [node.node_id], + nodes: [node], + edges: [], + coverage: analysis.graph.coverage, + limitations: analysis.graph.limitations, + }); + const parent = analysisViewJavaScriptEvidence( + analysisViewBindJavaScriptGraphs(analysis, graph), + ); + expect(session.recordEvidence(parent).ok).toBe(true); + const response = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source: { kind: "retained-evidence", evidence_id: parent.evidence_id }, + view: { + kind: "item", + collection: "modules", + selector: { node_id: node.node_id }, + }, + }, + }); + expect(response.isError).toBe(true); + const reference = z + .object({ + error: z.object({ + details: z.object({ + reported_limits: z.object({ + evidence_reference: z.object({ + kind: z.literal("retained-evidence"), + evidence_id: z.string(), + }), + }), + }), + }), + }) + .parse(response.structuredContent).error.details + .reported_limits.evidence_reference; + expect( + session.evidenceById(reference.evidence_id)?.normalized_result, + ).toMatchObject({ + item: { + observations: [ + expect.objectContaining({ + properties: expect.objectContaining({ source }), + }), + ], + }, + }); + await client.ping(); + const summary = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source: { kind: "retained-evidence", evidence_id: parent.evidence_id }, + view: { kind: "summary" }, + }, + }); + expect(summary.isError).not.toBe(true); +}); + +it("returns a JavaScript summary without graph payloads from a retained reference", async () => { + const { client, session } = await connect(); + const parent = analysisViewJavaScriptEvidence(); + expect(session.recordEvidence(parent).ok).toBe(true); + const response = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source: { + kind: "retained-evidence", + evidence_id: parent.evidence_id, + }, + view: { kind: "summary" }, + }, + }); + expect(response.isError).not.toBe(true); + const parsed = toolContract("inspect_analysis_view").outputSchema.parse( + response.structuredContent, + ); + expect(parsed.result).toMatchObject({ + kind: "summary", + parent_operation: "analyze_javascript_application", + summary: { format: "directory" }, + }); + expect(parsed.result).not.toHaveProperty("summary.semantic_graph"); + const stale = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source: { + kind: "retained-evidence", + evidence_id: `ev_${"e".repeat(64)}`, + }, + view: { kind: "summary" }, + }, + }); + expect(stale).toMatchObject({ + isError: true, + structuredContent: { + error: { details: { reason: "missing" } }, + }, + }); +}); + +it("accepts a transport-constraint retained reference as the view source", async () => { + const { client, session } = await connect(); + const parent = analysisViewJavaScriptEvidence(); + expect(session.recordEvidence(parent).ok).toBe(true); + const evidenceReference = { + kind: "retained-evidence" as const, + evidence_id: parent.evidence_id, + }; + const response = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source: evidenceReference, + view: { + kind: "page", + collection: "modules", + offset: 0, + limit: 8, + }, + }, + }); + expect(response.isError).not.toBe(true); + const parsed = toolContract("inspect_analysis_view").outputSchema.parse( + response.structuredContent, + ); + expect(parsed.result).toMatchObject({ + kind: "page", + parent_evidence_id: parent.evidence_id, + coverage: { exhausted: true }, + }); + if (parsed.result.kind !== "page") throw new Error("expected page view"); + expect(parsed.result.items).toEqual([ + { + node_id: expect.any(String), + kind: "javascript-asset", + path: "renderer.js", + }, + ]); +}); diff --git a/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts b/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts index 12ed431d96..b6bf0e3cba 100644 --- a/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts +++ b/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts @@ -65,6 +65,26 @@ it("advertises exact valid schemas and records inline layout without a binary ta expect(parsed.result).toEqual(value); expect(parsed.result).toEqual(evidence.normalized_result); expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence); + const viewed = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source: { + kind: "retained-evidence", + evidence_id: evidence.evidence_id, + }, + view: { kind: "summary" }, + }, + }); + expect(viewed.isError).not.toBe(true); + expect( + toolContract("inspect_analysis_view").outputSchema.parse( + viewed.structuredContent, + ).result, + ).toMatchObject({ + kind: "summary", + parent_evidence_id: evidence.evidence_id, + summary: { format: "elf", bytes: value.artifact.bytes }, + }); expect( ( await client.callTool({ diff --git a/tests/boundary/mcp/retainedApplicationEvidenceMcp.test.ts b/tests/boundary/mcp/retainedApplicationEvidenceMcp.test.ts index 304bc9b143..c01fd212fd 100644 --- a/tests/boundary/mcp/retainedApplicationEvidenceMcp.test.ts +++ b/tests/boundary/mcp/retainedApplicationEvidenceMcp.test.ts @@ -215,3 +215,27 @@ it("preserves validation for the referenced record's operation, predicate, and s }); expect(response.isError).toBe(true); }); + +it("projects a retained JavaScript summary without graph or semantic_graph", async () => { + const { client, session } = await connect(); + expect(session.recordEvidence(application).ok).toBe(true); + const inline = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source: { kind: "inline", evidence: application }, + view: { kind: "summary" }, + }, + }); + const referenced = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source: retained(application.evidence_id), + view: { kind: "summary" }, + }, + }); + expect(inline.isError).not.toBe(true); + expect(referenced.structuredContent).toEqual(inline.structuredContent); + expect(inline.structuredContent).not.toHaveProperty( + "result.summary.semantic_graph", + ); +}); diff --git a/tests/fixtures/analysisView.ts b/tests/fixtures/analysisView.ts new file mode 100644 index 0000000000..0f928a22a2 --- /dev/null +++ b/tests/fixtures/analysisView.ts @@ -0,0 +1,195 @@ +import { BINARY_LAYOUT_TEST_PROVIDER } from "./binaryDiagnostics/layout.js"; +import { binaryLayoutFixture } from "./binaryDiagnostics/layout.js"; +import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../src/contracts/javascript/javascriptRuntimeReconciliationExample.js"; +import { createEvidence, type Evidence } from "../../src/domain/evidence.js"; +import type { JavaScriptApplicationAnalysisResult } from "../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { javascriptApplicationAnalysisResultSchema } from "../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { + createJavaScriptApplicationGraph, + createJavaScriptApplicationNode, +} from "../../src/domain/javascript/javascriptApplicationGraph.js"; +import { createJavaScriptSemanticGraph } from "../../src/domain/javascript/javascriptSemanticGraph.js"; +import type { BinaryLayout } from "../../src/domain/native/binaryLayout.js"; +import { binaryLayoutSchema } from "../../src/domain/native/binaryLayout.js"; + +const hex = (value: number): string => + value === 0 ? "0x0" : `0x${value.toString(16)}`; + +const layoutName = (display: string, offset: number) => ({ + display, + bytes_base64: Buffer.from(display, "utf8").toString("base64"), + location: { offset: hex(offset), bytes: hex(display.length + 1) }, + unknown_reason: null, +}); + +const layoutSection = ( + index: number, + display: string, + nameOffset: number, +): BinaryLayout["sections"][number] => ({ + index, + name: layoutName(display, nameOffset), + name_offset: hex(nameOffset), + type: 1, + header_location: { offset: hex(index * 16), bytes: "0x10" }, + address: "0x0", + offset: hex(64 + index * 16), + size: "0x10", + alignment: "0x1", + flags: "0x6", + link: 0, + info: 0, + entry_size: "0x0", + file_backing: "file", +}); + +const layoutSymbol = ( + entryIndex: number, + display: string, + nameOffset: number, +): BinaryLayout["symbols"][number] => ({ + table_index: 0, + entry_index: entryIndex, + name: layoutName(display, nameOffset), + name_offset: hex(nameOffset), + location: { offset: hex(nameOffset), bytes: hex(display.length + 1) }, + value: "0x0", + value_meaning: "no-address", + size: "0x0", + binding: 1, + type: 2, + visibility: 0, + section_index: 1, +}); + +/** Layout fixture with duplicate section/symbol names for selected-view tests. */ +export const analysisViewLayoutFixture = (): BinaryLayout => + binaryLayoutSchema.parse({ + ...binaryLayoutFixture("/artifacts/view-source.elf"), + artifact: { + path: "/artifacts/view-source.elf", + sha256: "b".repeat(64), + bytes: 512, + }, + sections: [ + layoutSection(0, ".text", 256), + layoutSection(1, ".text", 262), + layoutSection(2, ".data", 268), + ], + symbols: [ + layoutSymbol(0, "main", 280), + layoutSymbol(1, "main", 285), + layoutSymbol(2, "start", 290), + ], + }); + +/** Wrap a layout observation as inspect_binary_layout Evidence. */ +export const analysisViewLayoutEvidence = ( + layout: BinaryLayout = analysisViewLayoutFixture(), +): Evidence => + createEvidence( + { + path: layout.artifact.path, + format: "elf", + architecture: "x86_64", + sha256: layout.artifact.sha256, + }, + BINARY_LAYOUT_TEST_PROVIDER, + { + operation: "inspect_binary_layout", + parameters: { path: layout.artifact.path }, + result: layout, + rawResult: layout, + confidence: "observed", + limitations: layout.limitations, + locations: [{ kind: "artifact-path", path: layout.artifact.path }], + }, + ); + +/** Minimal authenticated JavaScript application analysis used by view tests. */ +export const analysisViewJavaScriptAnalysis = + (): JavaScriptApplicationAnalysisResult => + javascriptApplicationAnalysisResultSchema.parse( + JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE.normalized_result, + ); + +/** Application analysis whose module observations include source text. */ +export const analysisViewJavaScriptAnalysisWithSource = + (): JavaScriptApplicationAnalysisResult => { + const analysis = analysisViewJavaScriptAnalysis(); + const original = analysis.graph.nodes[0]; + if (original === undefined) + throw new Error("JavaScript application example is missing its module"); + const withSource = createJavaScriptApplicationNode({ + kind: original.kind, + identity: original.identity, + observations: original.observations.map((observation) => ({ + label: observation.label, + properties: { + ...observation.properties, + source: "export const secret = 1;\n", + text: "should not leak", + }, + evidence: observation.evidence, + })), + }); + const graph = createJavaScriptApplicationGraph({ + schema: "JavaScriptApplicationGraph", + root_node_ids: [withSource.node_id], + nodes: [withSource], + edges: [], + coverage: analysis.graph.coverage, + limitations: analysis.graph.limitations, + }); + const { graph_id: _graphId, ...semantic } = analysis.semantic_graph; + return javascriptApplicationAnalysisResultSchema.parse({ + ...analysis, + graph, + semantic_graph: createJavaScriptSemanticGraph({ + ...semantic, + application_graph_id: graph.graph_id, + }), + }); + }; + +/** Rebind a companion semantic graph after replacing the application graph. */ +export const analysisViewBindJavaScriptGraphs = ( + analysis: JavaScriptApplicationAnalysisResult, + graph: ReturnType, +): JavaScriptApplicationAnalysisResult => { + const { graph_id: _graphId, ...semantic } = analysis.semantic_graph; + return javascriptApplicationAnalysisResultSchema.parse({ + ...analysis, + graph, + semantic_graph: createJavaScriptSemanticGraph({ + ...semantic, + application_graph_id: graph.graph_id, + }), + }); +}; + +/** Wrap JavaScript application analysis as producer Evidence. */ +export const analysisViewJavaScriptEvidence = ( + analysis: JavaScriptApplicationAnalysisResult = analysisViewJavaScriptAnalysis(), +): Evidence => + createEvidence( + { + path: analysis.input_path, + format: analysis.format, + sha256: analysis.root_artifact_sha256, + }, + { + id: "rea-javascript-application", + name: "REA JavaScript application analyzer", + version: "1", + }, + { + predicateType: "rea.javascript-application-analysis", + operation: "analyze_javascript_application", + parameters: { input_path: analysis.input_path, format: analysis.format }, + result: analysis, + confidence: "derived", + authority: "shipped-artifact", + limitations: analysis.limitations, + }, + );