diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index dd4527664..3adf54f0f 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -150,6 +150,9 @@ list stays complete; availability depends on the operation, target, and host. ## Work summary-first Start with the default result and use its inline Evidence and graph context. +For a JavaScript application whose size is unknown or large, call +`analyze_javascript_application` with `"detail": "summary"`, then inspect the +retained `parent_evidence_id` with `inspect_analysis_view`. Do not repeat an identical tool call. Make a focused follow-up only when the returned result leaves a specific question unanswered. diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 68c65ca75..4b988a848 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -129,6 +129,16 @@ documents. Their runtime string-limit failures report a resource constraint with the selected path, observed bytes and UTF-16 limit, rather than malformed JSON. +`analyze_javascript_application` returns the complete analysis Evidence by +default. Select `"detail": "summary"` to retain that complete Evidence in the +current session and receive only its `inspect_analysis_view` summary: artifact +identity, statistics, Electron surface counts, application and semantic +coverage, limitations, and `normalized_result.parent_evidence_id`. Pass that ID +to `inspect_analysis_view` for module pages or items, or to the application +workflows as a retained reference. The summary does not repeat or truncate the +analysis; a server without session retention refuses summary detail with +`capability_unavailable`. CLI output is unchanged and always complete. + 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 diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 60713e9b2..bfd6a717a 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -318,6 +318,13 @@ Ordinary responses keep their existing complete result contract. ## Retained application Evidence inputs +`analyze_javascript_application` accepts `detail`: `complete` (default) returns +the complete analysis Evidence; `summary` records that complete Evidence in the +session and returns the derived `inspect_analysis_view` summary Evidence +instead. Its `normalized_result.parent_evidence_id` and `evidence_links` name +the retained analysis, so later views and application workflows read it without +repeating analysis or moving the complete graph through the response. + `inspect_analysis_view` projects a caller-selected view of already completed `inspect_binary_layout`, `analyze_javascript_application`, or `analyze_function` Evidence. Native views select procedure, pseudocode, assembly, basic blocks, diff --git a/src/application/analysisView/AnalysisViewService.test.ts b/src/application/analysisView/AnalysisViewService.test.ts index b361e162f..0ad7917de 100644 --- a/src/application/analysisView/AnalysisViewService.test.ts +++ b/src/application/analysisView/AnalysisViewService.test.ts @@ -1,4 +1,4 @@ -import { expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../contracts/javascript/javascriptRuntimeReconciliationExample.js"; import { createEvidence, parseEvidence } from "../../domain/evidence.js"; @@ -9,7 +9,13 @@ import { analysisViewJavaScriptEvidence, analysisViewLayoutEvidence, } from "../../../tests/fixtures/analysisView.js"; -import { inspectAnalysisView } from "./AnalysisViewService.js"; +import { + inspectAnalysisView, + summarizeRetainedAnalysis, +} from "./AnalysisViewService.js"; +import type { Evidence } from "../../domain/evidence.js"; +import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js"; +import { err, ok } from "../../domain/result.js"; it.each(["/fixtures/unknown.exe", ""])( "preserves recorded empty native identity fields: path=%j", @@ -266,3 +272,47 @@ it("rejects contradictory subject and analysis artifact digests", () => { error: { _tag: "EvidenceIntegrityError" }, }); }); + +describe("summarizeRetainedAnalysis", () => { + const parent = analysisViewJavaScriptEvidence( + analysisViewJavaScriptAnalysisWithSource(), + ); + + it("retains the complete Evidence before projecting its summary", () => { + const retained = new Map(); + const summary = summarizeRetainedAnalysis(parent, { + recordEvidence: (evidence) => { + retained.set(evidence.evidence_id, evidence); + return ok("added"); + }, + evidenceById: (evidenceId) => retained.get(evidenceId), + }); + if (!summary.ok) throw summary.error; + expect(retained.get(parent.evidence_id)).toBe(parent); + expect(summary.value.evidence_links).toEqual([parent.evidence_id]); + expect(summary.value.normalized_result).toMatchObject({ + kind: "summary", + parent_evidence_id: parent.evidence_id, + parent_operation: "analyze_javascript_application", + }); + }); + + it("refuses a summary whose complete Evidence cannot be retained", () => { + const unavailable = summarizeRetainedAnalysis(parent, { + recordEvidence: undefined, + evidenceById: undefined, + }); + expect(unavailable.ok).toBe(false); + if (unavailable.ok) return; + expect(unavailable.error._tag).toBe("AnalysisCapabilityUnavailableError"); + expect(unavailable.error.userMessage).toContain("detail complete"); + + const conflict = new EvidenceIntegrityError("ledger conflict"); + expect( + summarizeRetainedAnalysis(parent, { + recordEvidence: () => err(conflict), + evidenceById: () => parent, + }), + ).toEqual(err(conflict)); + }); +}); diff --git a/src/application/analysisView/AnalysisViewService.ts b/src/application/analysisView/AnalysisViewService.ts index 7af6fb24a..ea079802e 100644 --- a/src/application/analysisView/AnalysisViewService.ts +++ b/src/application/analysisView/AnalysisViewService.ts @@ -3,7 +3,9 @@ import { z } from "zod"; import { ANALYSIS_VIEW_PROVIDER } from "../InvestigationProviders.js"; import type { EvidenceLookup } from "../EvidenceInputResolver.js"; import { resolveEvidenceInput } from "../EvidenceInputResolver.js"; +import type { EvidenceWriter } from "../investigation/InvestigationRecordPort.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js"; import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js"; import { @@ -76,6 +78,47 @@ export const inspectAnalysisViewValidated = ( return ok(createAnalysisViewEvidence(input, parent, projected.value)); }; +/** Session Evidence ownership required to deliver a view instead of a complete record. */ +export interface AnalysisViewRetention { + readonly evidenceById: EvidenceLookup | undefined; + readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined; +} + +/** + * Retain complete analysis Evidence in the session, then project its summary + * view from the retained record. Callers page or select the rest through + * inspect_analysis_view with the view's parent Evidence ID. + */ +export const summarizeRetainedAnalysis = ( + evidence: Evidence, + retention: AnalysisViewRetention, +): Result => { + if ( + retention.recordEvidence === undefined || + retention.evidenceById === undefined + ) + return err( + new AnalysisCapabilityUnavailableError( + ANALYSIS_VIEW_PROVIDER.id, + evidence.operation, + "summary detail requires a server session that retains Evidence for later views", + { + userMessage: + "This server does not retain Evidence. Select detail complete to receive the complete analysis inline.", + }, + ), + ); + const recorded = retention.recordEvidence(evidence); + if (!recorded.ok) return recorded; + return inspectAnalysisViewValidated( + { + source: { kind: "retained-evidence", evidence_id: evidence.evidence_id }, + view: { kind: "summary" }, + }, + retention.evidenceById, + ); +}; + const resolveAnalysisViewSource = ( source: InspectAnalysisViewInput["source"], lookup: EvidenceLookup | undefined, diff --git a/src/contracts/javascript/electronToolContracts.ts b/src/contracts/javascript/electronToolContracts.ts index 659ea99f0..3141d42e5 100644 --- a/src/contracts/javascript/electronToolContracts.ts +++ b/src/contracts/javascript/electronToolContracts.ts @@ -11,6 +11,9 @@ import { electronActiveObservationInputSchema, electronActiveObservationResultSchema, } from "../../domain/javascript/electronActiveObservation.js"; +import { z } from "zod"; + +import { analysisViewResultSchema } from "../../domain/analysisView/analysisView.js"; import { analyzeJavaScriptApplicationInputSchema, javascriptApplicationAnalysisResultSchema, @@ -23,8 +26,21 @@ import { JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE } from "./javascriptRuntimeRe const listOutputSchema = evidenceResultOf(electronTargetListSchema); const inspectionOutputSchema = evidenceResultOf(electronPageInspectionSchema); +/** Caller-selected delivery of a completed JavaScript application analysis. */ +export const analyzeJavaScriptApplicationRequestSchema = + analyzeJavaScriptApplicationInputSchema.extend({ + detail: z + .enum(["complete", "summary"]) + .default("complete") + .describe( + "complete returns the complete application analysis Evidence inline. summary retains that complete Evidence in this session and returns its inspect_analysis_view summary; page modules or select items from normalized_result.parent_evidence_id.", + ), + }); const applicationOutputSchema = evidenceResultOf( - javascriptApplicationAnalysisResultSchema, + z.union([ + javascriptApplicationAnalysisResultSchema, + analysisViewResultSchema, + ]), ); const reconciliationOutputSchema = evidenceResultOf( javascriptRuntimeReconciliationResultSchema, @@ -79,9 +95,9 @@ export const ELECTRON_TOOL_CONTRACTS = [ name: "analyze_javascript_application", ...toolContractMetadata("analyze_javascript_application"), description: - "Reconstruct one local ASAR or extracted JavaScript application as an inline application graph without executing it. Returns recovered graph nodes, edges, semantic relations, integrity contradictions, limitations, and coverage. Integrity mismatches fail by default; record-and-continue retains observed bytes as untrusted and marks graph coverage partial.", + "Reconstruct one local ASAR or extracted JavaScript application as an inline application graph without executing it. Returns recovered graph nodes, edges, semantic relations, integrity contradictions, limitations, and coverage. Select detail summary to retain the complete analysis Evidence in this session and receive only its summary view, then inspect selected modules or pages with inspect_analysis_view. Integrity mismatches fail by default; record-and-continue retains observed bytes as untrusted and marks graph coverage partial.", kind: "electron-provider", - inputSchema: analyzeJavaScriptApplicationInputSchema, + inputSchema: analyzeJavaScriptApplicationRequestSchema, outputSchema: applicationOutputSchema, examples: [ { @@ -92,6 +108,13 @@ export const ELECTRON_TOOL_CONTRACTS = [ integrity_policy: "fail", }, }, + { + title: "Retain the complete analysis and return its summary view", + input: { + input_path: "/Applications/Example.app/Contents/Resources/app.asar", + detail: "summary", + }, + }, ], }, { diff --git a/src/server/createServer.ts b/src/server/createServer.ts index f5d3884bf..569a8d0b5 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -468,6 +468,7 @@ const registerObservationTools = ({ }); registerElectronTools(server, { ...common, + evidenceById, electron: options.electronObservation, electronActive: options.electronActiveObservation, observationLoadFailure: diff --git a/src/server/registerElectronTools.ts b/src/server/registerElectronTools.ts index 8ea2a4b1e..3cab83ef8 100644 --- a/src/server/registerElectronTools.ts +++ b/src/server/registerElectronTools.ts @@ -1,5 +1,7 @@ import type { ToolResultDelivery } from "./toolResult.js"; import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; +import { summarizeRetainedAnalysis } from "../application/analysisView/AnalysisViewService.js"; +import type { EvidenceLookup } from "../application/EvidenceInputResolver.js"; import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; import { optionalProviderUnavailable, @@ -36,6 +38,7 @@ interface ElectronToolRegistration { readonly electron: ElectronObservationPort | undefined; readonly electronActive: ElectronActiveObservationPort | undefined; readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined; + readonly evidenceById: EvidenceLookup | undefined; } interface ElectronToolContext { @@ -95,8 +98,15 @@ export const registerElectronTools = ( registration, analyzeContract, { input, context }, - (parsed, { signal, progress }) => - analyzeJavaScriptApplicationValidated(parsed, { signal, progress }), + async ({ detail, ...request }, { signal, progress }) => { + const analyzed = await analyzeJavaScriptApplicationValidated( + request, + { signal, progress }, + ); + return detail === "summary" && analyzed.ok + ? summarizeRetainedAnalysis(analyzed.value, options) + : analyzed; + }, ), ); server.registerTool( diff --git a/tests/boundary/mcp/analysisSummaryDetailMcp.test.ts b/tests/boundary/mcp/analysisSummaryDetailMcp.test.ts new file mode 100644 index 000000000..30218c084 --- /dev/null +++ b/tests/boundary/mcp/analysisSummaryDetailMcp.test.ts @@ -0,0 +1,125 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it, onTestFinished } from "vitest"; + +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const contract = toolContract("analyze_javascript_application"); + +const connect = async (retainsEvidence: boolean) => { + const session = createTestBinarySession(() => { + throw new Error("JavaScript analysis must not start a deep provider"); + }); + const server = createServer( + retainsEvidence + ? { kind: "session", session } + : { + kind: "fixed", + analysis: { + execute: () => { + throw new Error("JavaScript analysis must not use a target"); + }, + }, + }, + ); + const client = new Client({ name: "analysis-summary-detail", 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; +}; + +const application = async () => { + const root = await createTestTempDirectory("rea-summary-detail-"); + await writeFile( + join(root, "main.js"), + 'import { helper } from "./util.js";\nexport const greet = (name) => helper(name);\n', + ); + await writeFile( + join(root, "util.js"), + "export const helper = (value) => value.toUpperCase();\n", + ); + return { input_path: root, format: "directory" }; +}; + +const textBytes = (content: unknown): number => { + const [first] = Array.isArray(content) ? content : []; + if (first?.type !== "text") throw new Error("Missing MCP text result"); + return Buffer.byteLength(String(first.text)); +}; + +it("returns the summary of complete Evidence retained for later views", async () => { + const client = await connect(true); + const input = await application(); + const complete = await client.callTool({ + name: contract.name, + arguments: input, + }); + expect(complete.isError).not.toBe(true); + const completeEvidence = contract.outputSchema.parse( + complete.structuredContent, + ); + expect(completeEvidence.operation).toBe("analyze_javascript_application"); + + const summary = await client.callTool({ + name: contract.name, + arguments: { ...input, detail: "summary" }, + }); + expect(summary.isError).not.toBe(true); + const summaryEvidence = contract.outputSchema.parse( + summary.structuredContent, + ); + expect(summaryEvidence).toMatchObject({ + operation: "inspect_analysis_view", + evidence_links: [completeEvidence.evidence_id], + normalized_result: { + kind: "summary", + parent_evidence_id: completeEvidence.evidence_id, + parent_operation: "analyze_javascript_application", + }, + }); + expect(textBytes(summary.content)).toBeLessThan(textBytes(complete.content)); + + const modules = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source: { + kind: "retained-evidence", + evidence_id: completeEvidence.evidence_id, + }, + view: { kind: "page", collection: "modules", offset: 0, limit: 8 }, + }, + }); + expect(modules.isError).not.toBe(true); + expect(modules.structuredContent).toMatchObject({ + normalized_result: { + kind: "page", + parent_evidence_id: completeEvidence.evidence_id, + }, + }); +}); + +it("refuses summary detail when the server cannot retain the complete Evidence", async () => { + const client = await connect(false); + const failure = parseMcpToolError( + await client.callTool({ + name: contract.name, + arguments: { ...(await application()), detail: "summary" }, + }), + ); + expect(failure.error).toMatchObject({ + code: "capability_unavailable", + message: expect.stringContaining("detail complete"), + }); +});