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>
This commit is contained in:
Matt Van HornandMatt Van Horn authored and GitHub committed 2026-10-09 12:45:48 +08:00
1 parent 911152a04d
commit db675f1d52
39 files changed
+2609 -30

No files matched your search

+7
View File
@@ -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
+8 -5
View File
@@ -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
+7 -6
View File
@@ -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
+15 -2
View File
@@ -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
+3 -1
View File
@@ -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 <directory>`.
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 <directory>`.
`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.
+5
View File
@@ -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(
+1
View File
@@ -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",
+8 -7
View File
@@ -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
@@ -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
@@ -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
@@ -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.
@@ -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",
});
});
});
@@ -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",
@@ -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" },
});
});
@@ -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<Evidence, AnalysisError> => {
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<Evidence, AnalysisError> => {
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<Evidence, AnalysisError> => {
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],
});
};
+2
View File
@@ -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);
+53
View File
@@ -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),
};
}),
});
};
+1
View File
@@ -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",
@@ -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[];
+2
View File
@@ -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,
+2 -2
View File
@@ -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: [
+2
View File
@@ -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,
+2
View File
@@ -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,
+1
View File
@@ -80,6 +80,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
writesFilesystem: true,
launchesProcess: true,
}),
inspect_analysis_view: evidence,
inspect_firmware_regions: effects({
mutatesSession: true,
launchesProcess: true,
@@ -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 = <Value>(steps: Generator<void, Value>): 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: "<unavailable>",
unknown_reason: "Name bytes unavailable",
bytes_base64: null,
location: null,
},
},
],
},
},
{
kind: "item",
collection: "sections",
selector: { name: "<unavailable>" },
},
);
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" },
});
});
+341
View File
@@ -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<typeof analysisViewRequestSchema>;
export type InspectAnalysisViewInput = z.output<
typeof inspectAnalysisViewInputSchema
>;
export type AnalysisViewResult = z.output<typeof analysisViewResultSchema>;
export type AnalysisViewCoverage = z.output<typeof coverageSchema>;
/** 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<AnalysisViewResult, "view_digest">,
): string => digestCanonicalValue(projection, "Analysis view");
/** View payload before the content digest is attached. */
export type UnsignedAnalysisView =
| Omit<
Extract<AnalysisViewResult, { readonly kind: "summary" }>,
"view_digest"
>
| Omit<Extract<AnalysisViewResult, { readonly kind: "facet" }>, "view_digest">
| Omit<Extract<AnalysisViewResult, { readonly kind: "item" }>, "view_digest">
| Omit<Extract<AnalysisViewResult, { readonly kind: "page" }>, "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<JavaScriptApplicationAnalysisResult, AnalysisError> => {
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<AnalysisViewResult, AnalysisError> => {
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));
};
@@ -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,
};
};
+239
View File
@@ -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 = <Row extends { readonly name: LayoutName }>(
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 = <Row extends { readonly name: LayoutName }>(
collection: "sections" | "symbols",
rows: readonly Row[],
name: string,
): Result<Row, AnalysisError> => {
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 = <Row>(
collection: "sections" | "symbols",
rows: readonly Row[],
index: number,
): Result<Row, AnalysisError> => {
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 = <Row>(
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<UnsignedAnalysisView, AnalysisError> => {
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 = <Row extends { readonly name: LayoutName }>(
collection: "sections" | "symbols",
rows: readonly Row[],
selector: Extract<AnalysisViewRequest, { readonly kind: "item" }>["selector"],
): Result<Row, AnalysisError> => {
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.",
),
);
};
@@ -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<string>();
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<string>();
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<typeof moduleEntries>,
selector: Extract<AnalysisViewRequest, { readonly kind: "item" }>["selector"],
): Result<ApplicationNode, AnalysisError> => {
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<UnsignedAnalysisView, AnalysisError> => {
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,
});
};
+1 -1
View File
@@ -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;
+2
View File
@@ -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(),
+32
View File
@@ -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);
},
);
};
@@ -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" },
});
});
@@ -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"],
@@ -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,
+270
View File
@@ -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<string, unknown> = advertised.inputSchema;
const outputSchema: Record<string, unknown> = 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",
},
]);
});
@@ -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({
@@ -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",
);
});
+195
View File
@@ -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<typeof createJavaScriptApplicationGraph>,
): 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,
},
);