diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 5eedd9c14..7bf2fb76c 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -237,7 +237,12 @@ 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 +`inspect_binary_layout`, `analyze_javascript_application`, or `analyze_function` +Evidence. Native views select procedure, pseudocode, assembly, callers/callees, +references, and high-pcode facets without starting a provider. Native offset +and limit default to 0 and 64; pseudocode uses UTF-16 code units and never +splits surrogate pairs, while other pages count rows. Unavailable facts and +provider limitations remain explicit. 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 diff --git a/skill-src/reverse-engineer-anything/references/native-and-artifacts.md b/skill-src/reverse-engineer-anything/references/native-and-artifacts.md index f47017171..65b68c6b2 100644 --- a/skill-src/reverse-engineer-anything/references/native-and-artifacts.md +++ b/skill-src/reverse-engineer-anything/references/native-and-artifacts.md @@ -34,6 +34,14 @@ cross-references. Addresses and recovered pseudocode are analysis observations, not original source. Provider unavailability and unsupported metadata remain unknown rather than false. +For a completed `analyze_function` dossier, use `inspect_analysis_view` with +`view: {kind: "native", facet: "value_flow_operations", offset: 0, limit: 32}` +and the exact retained Evidence ID to read selected facts without reanalysis. +Native facets also include procedure, pseudocode, assembly, callers/callees, +references, and value-flow summaries. Pseudocode offsets and limits count UTF-16 +code units; use the returned `next_offset` for continuation. Provider limitations +and unavailable facts remain explicit; the complete parent Evidence is retained. + To see which functions or Objective-C methods a Mach-O actually calls in one run, use `observe_native_calls` with explicit breakpoints and a bounded `duration_ms`/`max_events`. It launches a new process under LLDB, so the target diff --git a/src/application/analysisView/AnalysisViewService.test.ts b/src/application/analysisView/AnalysisViewService.test.ts index 29289efae..b361e162f 100644 --- a/src/application/analysisView/AnalysisViewService.test.ts +++ b/src/application/analysisView/AnalysisViewService.test.ts @@ -2,6 +2,8 @@ import { expect, it } from "vitest"; import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../contracts/javascript/javascriptRuntimeReconciliationExample.js"; import { createEvidence, parseEvidence } from "../../domain/evidence.js"; +import { functionDossierSchema } from "../../domain/hopperValues.js"; +import { ghidraFunctionDossier } from "../../domain/ghidraValues.fixture.js"; import { analysisViewJavaScriptAnalysisWithSource, analysisViewJavaScriptEvidence, @@ -9,6 +11,96 @@ import { } from "../../../tests/fixtures/analysisView.js"; import { inspectAnalysisView } from "./AnalysisViewService.js"; +it.each(["/fixtures/unknown.exe", ""])( + "preserves recorded empty native identity fields: path=%j", + (path) => { + const original = functionDossierSchema.parse(ghidraFunctionDossier()); + const parent = createEvidence( + { path, format: "pe", sha256: "c".repeat(64) }, + { id: "ghidra", name: "Ghidra", version: "12.1.4" }, + { + operation: "analyze_function", + parameters: {}, + result: { + ...original, + procedure: { + ...original.procedure, + address: "", + body: { available: false, reason: "Not recorded" }, + }, + }, + }, + ); + const result = inspectAnalysisView({ + source: { kind: "inline", evidence: parent }, + view: { + kind: "native", + facet: "procedure", + offset: 0, + limit: 64, + }, + }); + if (!result.ok) throw result.error; + expect(result.value.normalized_result).toMatchObject({ + procedure_address: "", + artifact: { path }, + item: { address: "" }, + }); + expect(result.value.locations).toEqual( + path.length > 0 ? [{ kind: "artifact-path", path }] : [], + ); + }, +); + +it("projects a small native view from an authenticated >10 MiB retained dossier", () => { + const original = functionDossierSchema.parse(ghidraFunctionDossier()); + const parent = createEvidence( + { path: "/fixtures/large.exe", format: "pe", sha256: "c".repeat(64) }, + { id: "ghidra", name: "Ghidra", version: "12.1.4" }, + { + operation: "analyze_function", + parameters: { address: "0x401000" }, + result: { ...original, pseudocode: "X".repeat(11 * 1024 * 1024) }, + limitations: ["Synthetic fixture"], + }, + ); + const input = { + source: { + kind: "retained-evidence" as const, + evidence_id: parent.evidence_id, + }, + view: { + kind: "native" as const, + facet: "pseudocode" as const, + offset: 0, + limit: 128, + }, + }; + const view = inspectAnalysisView(input, (id) => + id === parent.evidence_id ? parent : undefined, + ); + if (!view.ok) throw view.error; + expect(view.value.evidence_links).toEqual([parent.evidence_id]); + expect(view.value.normalized_result).toMatchObject({ + kind: "native", + parent_operation: "analyze_function", + parent_evidence_id: parent.evidence_id, + procedure_address: "0x401000", + artifact: { path: "/fixtures/large.exe", sha256: "c".repeat(64) }, + coverage: { total: 11 * 1024 * 1024, examined: 128, next_offset: 128 }, + }); + expect(view.value.locations).toEqual([ + { kind: "artifact-path", path: "/fixtures/large.exe" }, + { kind: "address", address: "0x401000" }, + ]); + expect(JSON.stringify(view.value).length).toBeLessThan(12000); + expect(JSON.stringify(parent).length).toBeGreaterThan(10 * 1024 * 1024); + expect(inspectAnalysisView(input, () => undefined)).toMatchObject({ + ok: false, + error: { _tag: "EvidenceIntegrityError" }, + }); +}); + it("projects inline layout Evidence and retains a derived view record", () => { const parent = analysisViewLayoutEvidence(); const result = inspectAnalysisView({ diff --git a/src/application/analysisView/AnalysisViewService.ts b/src/application/analysisView/AnalysisViewService.ts index 0d10d5fa8..7af6fb24a 100644 --- a/src/application/analysisView/AnalysisViewService.ts +++ b/src/application/analysisView/AnalysisViewService.ts @@ -53,6 +53,13 @@ export const inspectAnalysisViewValidated = ( operation: parent.operation, normalizedResult: parent.normalized_result, limitations: parent.limitations, + artifact: + parent.subject === null + ? null + : { + path: parent.subject.local_path, + sha256: parent.subject.digest.sha256, + }, }, input.view, ); @@ -126,7 +133,16 @@ const createAnalysisViewEvidence = ( confidence: "derived", authority: parent.authority, limitations: result.limitations, - locations: [{ kind: "artifact-path", path: result.artifact.path }], + locations: [ + ...(result.artifact.path.length > 0 + ? [{ kind: "artifact-path" as const, path: result.artifact.path }] + : []), + ...(result.kind === "native" && + result.procedure_address !== null && + result.procedure_address.length > 0 + ? [{ kind: "address" as const, address: result.procedure_address }] + : []), + ], evidenceLinks: [parent.evidence_id], }); }; diff --git a/src/cli/analysisViewCommands.ts b/src/cli/analysisViewCommands.ts index a0deb0229..a193d2740 100644 --- a/src/cli/analysisViewCommands.ts +++ b/src/cli/analysisViewCommands.ts @@ -18,7 +18,7 @@ export const registerAnalysisViewCommands = ( ): void => { cli.command(CLI_COMMANDS.inspectAnalysisView, { description: - "Project a selected view of completed layout or JavaScript application Evidence JSON", + "Project a selected view of completed layout, JavaScript application or native function Evidence JSON", args: z.object({ inputJson: z.string().describe("Inline workflow JSON or JSON file path"), }), diff --git a/src/contracts/analysisViewToolContracts.ts b/src/contracts/analysisViewToolContracts.ts index b8f455767..be65b229f 100644 --- a/src/contracts/analysisViewToolContracts.ts +++ b/src/contracts/analysisViewToolContracts.ts @@ -8,14 +8,14 @@ import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js"; const RETAINED_EXAMPLE_ID = `ev_${"a".repeat(64)}`; -/** Selected views of already completed layout and JavaScript analysis Evidence. */ +/** Selected views of already completed layout, JavaScript and native function 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.", + "Inspect selected views of completed binary-layout, JavaScript application or native analyze_function 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. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Native views select procedure, pseudocode, assembly, callers/callees, references and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.", inputSchema: inspectAnalysisViewInputSchema, outputSchema: evidenceResultOf(analysisViewResultSchema), examples: [ @@ -43,6 +43,21 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [ }, }, }, + { + title: "Page native high-pcode operations from retained Evidence", + input: { + source: { + kind: "retained-evidence", + evidence_id: RETAINED_EXAMPLE_ID, + }, + view: { + kind: "native", + facet: "value_flow_operations", + offset: 0, + limit: 32, + }, + }, + }, { title: "Page module identities from retained application Evidence", input: { diff --git a/src/domain/analysisView/analysisView.test.ts b/src/domain/analysisView/analysisView.test.ts index e3aabcf14..1fd6fb7f1 100644 --- a/src/domain/analysisView/analysisView.test.ts +++ b/src/domain/analysisView/analysisView.test.ts @@ -1,6 +1,8 @@ import { expect, it } from "vitest"; import { resolveJavaScriptSourceMapReference } from "../javascript/javascriptSourceMapPaths.js"; +import { functionDossierSchema } from "../hopperValues.js"; +import { ghidraFunctionDossier } from "../ghidraValues.fixture.js"; import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../contracts/javascript/javascriptRuntimeReconciliationExample.js"; import { analysisViewBindJavaScriptGraphs, @@ -48,21 +50,190 @@ const javascriptParent = ( 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); +const nativeParent = ( + result: unknown = ghidraFunctionDossier(), +): AnalysisViewParent => ({ + evidenceId: "ev_" + "b".repeat(64), + operation: "analyze_function", + normalizedResult: result, + limitations: ["Retained provider limitation"], + artifact: { path: "/fixtures/example.exe", sha256: "a".repeat(64) }, }); +it("projects native procedure and bounded assembly and high-pcode with exact parent identity", () => { + const parent = nativeParent(); + const procedure = projectAnalysisView(parent, { + kind: "native", + facet: "procedure", + offset: 0, + limit: 64, + }); + if (!procedure.ok) throw procedure.error; + expect(procedure.value).toMatchObject({ + kind: "native", + parent_operation: "analyze_function", + artifact: parent.artifact, + item: { address: "0x401000" }, + }); + expect(procedure.value.view_digest).not.toBe(parent.evidenceId.slice(3)); + const assembly = projectAnalysisView(parent, { + kind: "native", + facet: "assembly", + offset: 0, + limit: 1, + }); + if (!assembly.ok) throw assembly.error; + expect(assembly.value).toMatchObject({ + coverage: { examined: 1, total: 2, next_offset: 1 }, + item: ["0x401000: CALL 0x401020"], + }); + const flow = projectAnalysisView(parent, { + kind: "native", + facet: "value_flow_operations", + offset: 0, + limit: 1, + }); + if (!flow.ok) throw flow.error; + expect(flow.value).toMatchObject({ item: [{ opcode: "COPY" }] }); +}); + +it("rejects invalid native parents, unsupported source operations and singleton offsets", () => { + const view = { + kind: "native", + facet: "assembly", + offset: 0, + limit: 4, + } as const; + expect(projectAnalysisView(nativeParent({}), view)).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); + expect( + projectAnalysisView({ ...nativeParent(), artifact: null }, view), + ).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); + expect(projectAnalysisView(layoutParent(), view)).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); + expect(projectAnalysisView(javascriptParent(), view)).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); + expect( + projectAnalysisView(nativeParent(), { + kind: "native", + facet: "procedure", + offset: 1, + limit: 2, + }), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } }); +}); + +it("preserves native API limitations and unavailable residual unknowns", () => { + const original = functionDossierSchema.parse(ghidraFunctionDossier()); + const view = { + kind: "native", + facet: "procedure", + offset: 0, + limit: 64, + } as const; + const unavailable = projectAnalysisView( + nativeParent({ + ...original, + native_api: { + available: false, + reason: "No decompiler", + residual_unknowns: ["Calling convention unresolved"], + }, + }), + view, + ); + if (!unavailable.ok) throw unavailable.error; + expect(unavailable.value.unknowns).toContain("Calling convention unresolved"); + const available = projectAnalysisView(nativeParent(), view); + if (!available.ok) throw available.error; + if (!original.native_api?.available) throw new Error("expected API fixture"); + expect(available.value.limitations).toEqual( + expect.arrayContaining(original.native_api.limitations), + ); +}); + +it("reports the malformed native dossier field", () => { + const result = projectAnalysisView( + nativeParent({ + ...functionDossierSchema.parse(ghidraFunctionDossier()), + pseudocode: 12, + }), + { + kind: "native", + facet: "pseudocode", + offset: 0, + limit: 64, + }, + ); + expect(result).toMatchObject({ + ok: false, + error: { + issues: expect.arrayContaining([ + expect.objectContaining({ + path: ["source", "normalized_result", "pseudocode"], + }), + ]), + }, + }); +}); + +it("native pseudocode pages do not split surrogate pairs", () => { + const original = functionDossierSchema.parse(ghidraFunctionDossier()); + const parent = nativeParent({ ...original, pseudocode: "ab😀cd" }); + expect( + projectAnalysisView(parent, { + kind: "native", + facet: "pseudocode", + offset: 3, + limit: 2, + }), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } }); + const safe = projectAnalysisView(parent, { + kind: "native", + facet: "pseudocode", + offset: 2, + limit: 3, + }); + if (!safe.ok) throw safe.error; + expect(safe.value).toMatchObject({ + item: { text: "😀c", unit: "utf16-code-units" }, + coverage: { examined: 3, next_offset: 5 }, + }); +}); + +it.each([ + { kind: "page", collection: "sections", offset: 0, limit: 500 }, + { kind: "native", facet: "assembly", offset: 0, limit: 500 }, +])( + "accepts caller-selected page sizes and rejects malformed bounds: $kind", + (view) => { + const input = { + source: { + kind: "retained-evidence", + evidence_id: `ev_${"a".repeat(64)}`, + }, + view, + }; + 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(); diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index efb9f5d44..db2d2dbac 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -12,6 +12,7 @@ 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 { functionDossierSchema } from "../hopperValues.js"; import { parseOwnedJavaScriptApplicationAnalysisSteps, type JavaScriptApplicationAnalysisResult, @@ -19,6 +20,7 @@ import { import { analysisInputErrorFromIssues } from "../inputIssueProjection.js"; import { projectBinaryLayoutView } from "./binaryLayoutView.js"; import { projectJavaScriptApplicationView } from "./javascriptApplicationView.js"; +import { projectNativeFunctionView } from "./nativeFunctionView.js"; /** Public name of the selected-view workflow. */ export const INSPECT_ANALYSIS_VIEW_OPERATION = "inspect_analysis_view" as const; @@ -73,12 +75,35 @@ const pageViewSchema = z.strictObject({ limit: z.number().int().positive(), }); +/** Bounded native function facets of already authenticated analyze_function Evidence. */ +const nativeViewSchema = z.strictObject({ + kind: z.literal("native"), + facet: z.enum([ + "procedure", + "pseudocode", + "assembly", + "callers", + "callees", + "incoming_references", + "outgoing_references", + "value_flow_summary", + "value_flow_operations", + "value_flow_def_use", + "value_flow_effects", + "value_flow_parameters", + "value_flow_parameter_uses", + ]), + offset: z.number().int().nonnegative().default(0), + limit: z.number().int().positive().default(64), +}); + /** Caller-selected projection of already completed analysis Evidence. */ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [ summaryViewSchema, facetViewSchema, itemViewSchema, pageViewSchema, + nativeViewSchema, ]); /** Inline Evidence or an exact same-session retained reference. */ @@ -108,7 +133,9 @@ const coverageSchema = z.strictObject({ }); const artifactSchema = z.strictObject({ - path: z.string().min(1), + path: z + .string() + .describe("Exact recorded artifact path, including an empty string."), sha256: digestSchema, }); @@ -150,6 +177,7 @@ const viewResultBase = { parent_operation: z.enum([ "inspect_binary_layout", "analyze_javascript_application", + "analyze_function", ]), parent_digest: digestSchema, view_digest: digestSchema, @@ -185,6 +213,13 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ items: z.array(jsonValueSchema), ...viewResultBase, }), + z.strictObject({ + kind: z.literal("native"), + view: nativeViewSchema, + procedure_address: z.string().nullable(), + item: jsonValueSchema, + ...viewResultBase, + }), ]); export type AnalysisViewRequest = z.output; @@ -200,6 +235,7 @@ export interface AnalysisViewParent { readonly operation: string; readonly normalizedResult: unknown; readonly limitations: readonly string[]; + readonly artifact?: { readonly path: string; readonly sha256: string } | null; } /** Construct a typed input failure for one selected-view constraint. */ @@ -216,7 +252,11 @@ export type UnsignedAnalysisView = > | Omit, "view_digest"> | Omit, "view_digest"> - | Omit, "view_digest">; + | Omit, "view_digest"> + | Omit< + Extract, + "view_digest" + >; /** Attach the content digest that must change when projected bytes change. */ export const sealAnalysisView = ( @@ -261,7 +301,7 @@ const unsupportedParent = ( new AnalysisUnsupportedTargetError( INSPECT_ANALYSIS_VIEW_OPERATION, parentArtifactPath(parent), - `Parent Evidence operation ${JSON.stringify(parent.operation)} is not inspect_binary_layout or analyze_javascript_application.`, + `Parent Evidence operation ${JSON.stringify(parent.operation)} is not inspect_binary_layout, analyze_javascript_application or analyze_function.`, ); const parentArtifactPath = (parent: AnalysisViewParent): string => { @@ -327,5 +367,22 @@ export const projectAnalysisView = ( ); return projected.ok ? ok(sealAnalysisView(projected.value)) : projected; } + if (parent.operation === "analyze_function") { + const native = functionDossierSchema.safeParse(parent.normalizedResult); + if (!native.success) + return err( + analysisInputErrorFromIssues( + INSPECT_ANALYSIS_VIEW_OPERATION, + native.error.issues.map((issue) => ({ + ...issue, + path: ["source", "normalized_result", ...issue.path], + })), + { source: { normalized_result: parent.normalizedResult } }, + { cause: native.error }, + ), + ); + const projected = projectNativeFunctionView(parent, native.data, view); + return projected.ok ? ok(sealAnalysisView(projected.value)) : projected; + } return err(unsupportedParent(parent)); }; diff --git a/src/domain/analysisView/binaryLayoutView.ts b/src/domain/analysisView/binaryLayoutView.ts index 5a52904a8..13a9b766f 100644 --- a/src/domain/analysisView/binaryLayoutView.ts +++ b/src/domain/analysisView/binaryLayoutView.ts @@ -152,6 +152,10 @@ export const projectBinaryLayoutView = ( view: AnalysisViewRequest, ): Result => { const shared = parentFields(parent, layout); + if (view.kind === "native") + return err( + layoutIncompatible("Native views require analyze_function Evidence."), + ); if (view.kind === "summary") return ok({ kind: "summary", diff --git a/src/domain/analysisView/javascriptApplicationView.ts b/src/domain/analysisView/javascriptApplicationView.ts index 5216c72b4..d6e994fc9 100644 --- a/src/domain/analysisView/javascriptApplicationView.ts +++ b/src/domain/analysisView/javascriptApplicationView.ts @@ -243,6 +243,10 @@ export const projectJavaScriptApplicationView = ( ): Result => { const shared = parentFields(parent, analysis); const modules = moduleEntries(analysis.graph.nodes); + if (view.kind === "native") + return err( + jsIncompatible("Native views require analyze_function Evidence."), + ); if (view.kind === "summary") return ok({ kind: "summary", diff --git a/src/domain/analysisView/nativeFunctionView.ts b/src/domain/analysisView/nativeFunctionView.ts new file mode 100644 index 000000000..d17f69b03 --- /dev/null +++ b/src/domain/analysisView/nativeFunctionView.ts @@ -0,0 +1,202 @@ +import type { AnalysisError } from "../analysisErrorBase.js"; +import { uniqueSorted } from "../canonicalOrdering.js"; +import type { FunctionDossier } from "../hopperValues.js"; +import { jsonValueSchema } from "../jsonValue.js"; +import { err, ok, type Result } from "../result.js"; +import { + analysisViewInputError, + type AnalysisViewCoverage, + type AnalysisViewParent, + type AnalysisViewRequest, + type UnsignedAnalysisView, +} from "./analysisView.js"; +import { + completeWithinViewCoverage, + pageViewCoverage, +} from "./analysisViewCoverage.js"; + +type Artifact = { readonly path: string; readonly sha256: string }; + +const viewError = (message: string): AnalysisError => + analysisViewInputError([ + { path: ["view"], reason: "invalid_value", message }, + ]); + +const parentFields = ( + parent: AnalysisViewParent, + dossier: FunctionDossier, + artifact: Artifact, +) => { + const flow = dossier.native_value_flow; + const api = dossier.native_api; + const unknowns: string[] = []; + if (flow === null) unknowns.push("native_value_flow was not recorded"); + else if (!flow.available) + unknowns.push("native_value_flow unavailable: " + flow.reason); + else if (flow.truncated) + unknowns.push( + "native_value_flow is truncated; omitted observations remain unknown", + ); + if (api === null) unknowns.push("native_api was not recorded"); + else if (!api.available) + unknowns.push( + "native_api unavailable: " + api.reason, + ...api.residual_unknowns, + ); + return { + parent_evidence_id: parent.evidenceId, + parent_operation: "analyze_function" as const, + parent_digest: parent.evidenceId.slice(3), + procedure_address: dossier.procedure.address, + artifact, + limitations: uniqueSorted([ + ...parent.limitations, + ...dossier.limitations, + ...(flow?.limitations ?? []), + ...(api?.available ? api.limitations : []), + ]), + unknowns: uniqueSorted(unknowns), + }; +}; + +const pseudocodePage = ( + body: string, + offset: number, + limit: number, +): Result< + { + readonly item: { readonly text: string; readonly unit: "utf16-code-units" }; + readonly coverage: AnalysisViewCoverage; + }, + AnalysisError +> => { + const start = Math.min(offset, body.length); + const high = (n: number) => n >= 0xd800 && n <= 0xdbff; + const low = (n: number) => n >= 0xdc00 && n <= 0xdfff; + if ( + start > 0 && + start < body.length && + high(body.charCodeAt(start - 1)) && + low(body.charCodeAt(start)) + ) + return err(viewError("Pseudocode offset splits a UTF-16 surrogate pair.")); + let end = Math.min(start + limit, body.length); + if ( + end > start && + end < body.length && + high(body.charCodeAt(end - 1)) && + low(body.charCodeAt(end)) + ) + end--; + if (end === start && start < body.length) + return err( + viewError( + "Increase pseudocode limit to include the next complete Unicode character.", + ), + ); + return ok({ + item: { text: body.slice(start, end), unit: "utf16-code-units" }, + coverage: pageViewCoverage(start, end - start, body.length), + }); +}; + +const nativeRows = ( + dossier: FunctionDossier, + facet: Exclude< + Extract["facet"], + "procedure" | "pseudocode" | "value_flow_summary" + >, +) => { + const sourceFlow = dossier.native_value_flow; + const flow = sourceFlow?.available ? sourceFlow : undefined; + return { + assembly: dossier.assembly, + callers: dossier.callers, + callees: dossier.callees, + incoming_references: dossier.incoming_references, + outgoing_references: dossier.outgoing_references, + value_flow_operations: flow?.operations, + value_flow_def_use: flow?.def_use, + value_flow_effects: flow?.effects, + value_flow_parameters: flow?.parameters, + value_flow_parameter_uses: flow?.parameter_uses, + }[facet]; +}; + +/** Project bounded native function facets from complete retained Evidence without reanalysis. */ +export const projectNativeFunctionView = ( + parent: AnalysisViewParent, + dossier: FunctionDossier, + view: AnalysisViewRequest, +): Result => { + if (view.kind !== "native") + return err(viewError("analyze_function Evidence requires a native view.")); + const artifact = parent.artifact; + if (!artifact) + return err( + analysisViewInputError([ + { + path: ["source", "subject"], + reason: "invalid_value", + message: + "Native function Evidence requires an artifact-bound subject.", + }, + ]), + ); + const common = parentFields(parent, dossier, artifact); + const singleton = (item: unknown): UnsignedAnalysisView => ({ + kind: "native", + view, + item: jsonValueSchema.parse(item), + coverage: completeWithinViewCoverage(1, 1), + ...common, + }); + const flow = dossier.native_value_flow; + if (view.facet === "procedure" || view.facet === "value_flow_summary") { + if (view.offset !== 0) + return err(viewError("Singleton native facets require offset 0.")); + if (view.facet === "procedure") return ok(singleton(dossier.procedure)); + const status = + flow === null + ? { available: false, reason: "not recorded" } + : !flow.available + ? { available: false, reason: flow.reason } + : { + available: true, + provenance: flow.provenance, + truncated: flow.truncated, + omitted_operations_lower_bound: + flow.omitted_operations_lower_bound, + known_omitted_inputs: flow.known_omitted_inputs, + known_omitted_edges: flow.known_omitted_edges, + operations: flow.operations.length, + def_use: flow.def_use.length, + effects: flow.effects.length, + parameters: flow.parameters.length, + parameter_uses: flow.parameter_uses.length, + }; + return ok(singleton(status)); + } + if (view.facet === "pseudocode") { + const page = pseudocodePage(dossier.pseudocode, view.offset, view.limit); + return page.ok + ? ok({ kind: "native", view, ...page.value, ...common }) + : page; + } + const rows = nativeRows(dossier, view.facet); + if (rows === undefined) + return ok( + singleton({ + available: false, + reason: flow?.available === false ? flow.reason : "not recorded", + }), + ); + const items = rows.slice(view.offset, view.offset + view.limit); + return ok({ + kind: "native", + view, + item: jsonValueSchema.parse(items), + coverage: pageViewCoverage(view.offset, items.length, rows.length), + ...common, + }); +}; diff --git a/tests/boundary/cli/analysisViewCli.test.ts b/tests/boundary/cli/analysisViewCli.test.ts index 86e0d9b27..2abaf700a 100644 --- a/tests/boundary/cli/analysisViewCli.test.ts +++ b/tests/boundary/cli/analysisViewCli.test.ts @@ -7,6 +7,8 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; import { analysisViewLayoutEvidence } from "../../fixtures/analysisView.js"; +import { createEvidence } from "../../../src/domain/evidence.js"; +import { ghidraFunctionDossier } from "../../../src/domain/ghidraValues.fixture.js"; cliTest( "projects one section from a JSON file of inline layout Evidence", async ({ cli }) => { @@ -43,6 +45,48 @@ cliTest( }, ); +cliTest( + "projects native function pseudocode without requiring Ghidra", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-native-view-cli-"); + const parent = createEvidence( + { path: "/fixtures/native.exe", format: "pe", sha256: "a".repeat(64) }, + { id: "ghidra", name: "Ghidra", version: "12.1.4" }, + { + operation: "analyze_function", + parameters: { address: "0x401000" }, + result: ghidraFunctionDossier(), + }, + ); + const input = join(root, "native-view.json"); + await writeFile( + input, + JSON.stringify({ + source: { kind: "inline", evidence: parent }, + view: { kind: "native", facet: "pseudocode", offset: 0, limit: 12 }, + }), + ); + 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: "native", + parent_evidence_id: parent.evidence_id, + procedure_address: "0x401000", + item: { text: "int fixture_", unit: "utf16-code-units" }, + }, + locations: [ + { kind: "artifact-path", path: "/fixtures/native.exe" }, + { kind: "address", address: "0x401000" }, + ], + }); + }, +); + cliTest("rejects malformed JSON before projection", async ({ cli }) => { const root = await createTestTempDirectory("rea-analysis-view-cli-bad-"); const result = await cli.run({ diff --git a/tests/boundary/mcp/analysisViewMcp.test.ts b/tests/boundary/mcp/analysisViewMcp.test.ts index 3edeffba7..3c15312aa 100644 --- a/tests/boundary/mcp/analysisViewMcp.test.ts +++ b/tests/boundary/mcp/analysisViewMcp.test.ts @@ -6,7 +6,9 @@ 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 { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; +import { functionDossierSchema } from "../../../src/domain/hopperValues.js"; +import { ghidraFunctionDossier } from "../../../src/domain/ghidraValues.fixture.js"; import { ok } from "../../../src/domain/result.js"; import { createJavaScriptApplicationGraph, @@ -109,6 +111,68 @@ it("advertises exact schemas and projects one layout section from retained Evide ); }); +it("delivers a bounded native dossier view over MCP without a Ghidra provider or lost connection", async () => { + const { client, session } = await connect(); + const original = functionDossierSchema.parse(ghidraFunctionDossier()); + const parent = createEvidence( + { + path: "/fixtures/oversized-native.exe", + format: "pe", + sha256: "b".repeat(64), + }, + { id: "ghidra", name: "Ghidra", version: "12.1.4" }, + { + operation: "analyze_function", + parameters: { address: "0x401000" }, + result: { ...original, pseudocode: "X".repeat(11 * 1024 * 1024) }, + }, + ); + expect(session.recordEvidence(parent).ok).toBe(true); + const advertised = (await client.listTools()).tools.find( + (tool) => tool.name === "inspect_analysis_view", + ); + if (advertised?.outputSchema === undefined) + throw new Error("missing view schemas"); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + const inputSchema: Record = advertised.inputSchema; + const outputSchema: Record = advertised.outputSchema; + const arguments_ = { + source: { kind: "retained-evidence", evidence_id: parent.evidence_id }, + view: { kind: "native", facet: "pseudocode", offset: 0, limit: 500 }, + }; + expect(ajv.validateSchema(inputSchema)).toBe(true); + expect(ajv.validateSchema(outputSchema)).toBe(true); + expect( + ajv.validate(inputSchema, arguments_), + JSON.stringify(ajv.errors), + ).toBe(true); + const response = await client.callTool({ + name: "inspect_analysis_view", + arguments: arguments_, + }); + 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.normalized_result).toMatchObject({ + kind: "native", + parent_evidence_id: parent.evidence_id, + procedure_address: "0x401000", + coverage: { examined: 500, next_offset: 500, total: 11 * 1024 * 1024 }, + }); + expect(parsed.evidence_links).toEqual([parent.evidence_id]); + expect(parsed.locations).toEqual([ + { kind: "artifact-path", path: "/fixtures/oversized-native.exe" }, + { kind: "address", address: "0x401000" }, + ]); + expect(JSON.stringify(response.structuredContent).length).toBeLessThan(12000); + await client.ping(); +}); + it("retains an oversized selected observation and keeps subsequent views usable", async () => { const { client, session } = await connect(); const analysis = analysisViewJavaScriptAnalysisWithSource(); @@ -185,6 +249,71 @@ it("retains an oversized selected observation and keeps subsequent views usable" expect(summary.isError).not.toBe(true); }); +it("retains an oversized native assembly row and permits a subsequent smaller view", async () => { + const { client, session } = await connect(); + const row = "\0".repeat(1_000_000); + const parent = createEvidence( + { path: "/fixtures/large-row.exe", format: "pe", sha256: "c".repeat(64) }, + { id: "ghidra", name: "Ghidra", version: "12.1.4" }, + { + operation: "analyze_function", + parameters: {}, + result: { + ...functionDossierSchema.parse(ghidraFunctionDossier()), + assembly: [row], + }, + }, + ); + expect(session.recordEvidence(parent).ok).toBe(true); + const source = { kind: "retained-evidence", evidence_id: parent.evidence_id }; + const response = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source, + view: { kind: "native", facet: "assembly", offset: 0, limit: 1 }, + }, + }); + expect(response.isError).toBe(true); + const error = 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(parseMcpToolError(response)); + const retained = session.evidenceById( + error.error.details.reported_limits.evidence_reference.evidence_id, + ); + expect(retained?.normalized_result).toMatchObject({ + item: [row], + parent_evidence_id: parent.evidence_id, + }); + const next = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source, + view: { kind: "native", facet: "pseudocode", offset: 0, limit: 12 }, + }, + }); + expect(next.isError).not.toBe(true); + expect( + toolContract("inspect_analysis_view").outputSchema.parse( + next.structuredContent, + ).normalized_result, + ).toMatchObject({ + item: { text: "int fixture_" }, + parent_evidence_id: parent.evidence_id, + }); + await client.ping(); +}); + it("returns a JavaScript summary without graph payloads from a retained reference", async () => { const { client, session } = await connect(); const parent = analysisViewJavaScriptEvidence();