feat(native): inspect oversized retained function dossiers via bounded views
Add caller-selected native dossier facets with preserved Evidence identity, provenance, unknowns and transport recovery. Refs #1247 and #1065. Co-authored-by: jhuang-tw <77732008+jhuang-tw@users.noreply.github.com> Co-authored-by: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com>
This commit is contained in:
parent
4d95f2b2e7
commit
a9ee2360fd
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<typeof analysisViewRequestSchema>;
|
||||
|
|
@ -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<Extract<AnalysisViewResult, { readonly kind: "facet" }>, "view_digest">
|
||||
| Omit<Extract<AnalysisViewResult, { readonly kind: "item" }>, "view_digest">
|
||||
| Omit<Extract<AnalysisViewResult, { readonly kind: "page" }>, "view_digest">;
|
||||
| Omit<Extract<AnalysisViewResult, { readonly kind: "page" }>, "view_digest">
|
||||
| Omit<
|
||||
Extract<AnalysisViewResult, { readonly kind: "native" }>,
|
||||
"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));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -152,6 +152,10 @@ export const projectBinaryLayoutView = (
|
|||
view: AnalysisViewRequest,
|
||||
): Result<UnsignedAnalysisView, AnalysisError> => {
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -243,6 +243,10 @@ export const projectJavaScriptApplicationView = (
|
|||
): Result<UnsignedAnalysisView, AnalysisError> => {
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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<AnalysisViewRequest, { readonly kind: "native" }>["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<UnsignedAnalysisView, AnalysisError> => {
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> = advertised.inputSchema;
|
||||
const outputSchema: Record<string, unknown> = 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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue