Compare commits

..
Author SHA1 Message Date
N0zoM1z0andKaoru0822 3ba1f6ef22 fix(ghidra): restore temporary verifier address aliases
Own temporary address-name edits in the shared DOS/COM boundary verifier. Restore and read back the original public name even after an uncertain write; preserve both verification and restoration failures. Keep strict address overflow, collision and CLI/MCP parity assertions.

Closes #2924

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
2026-10-14 00:35:02 +00:00
33 changed files with 490 additions and 944 deletions
@@ -2,7 +2,7 @@
name: reverse-engineer-anything
description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
metadata:
version: "42"
version: "41"
---
# REA
@@ -104,10 +104,6 @@ explicit path or endpoint and do not need it.
consult the NativeAOT workflow in `references/native-and-artifacts.md` for
optional metadata recovery and its supported host/layout boundary.
- User-owned browser page already open: `list_browser_targets`.
- Browser action the agent must perform (click, type, navigate):
`capture_browser_scenario` with declared actions. See
[runtime observation](references/runtime-observation.md) for connect/launch
and the focused follow-up browser tools.
- Fiddler Everywhere traffic or a native `.saz` capture: read the
[Fiddler workflow](references/runtime-observation.md#fiddler-everywhere-traffic-debugging).
Inspect saved SAZ with `inspect_web_network_capture` and `format: "saz"`;
@@ -327,8 +323,7 @@ as complete while required questions remain open.
[references/javascript-applications.md](references/javascript-applications.md)
- Android APK declarations, classes, methods, and static references:
[references/android-applications.md](references/android-applications.md)
- Browser/Electron observation (passive and scenario) and static/runtime
reconciliation:
- Passive browser/Electron observation and static/runtime reconciliation:
[references/runtime-observation.md](references/runtime-observation.md)
- Evidence paging, comparisons, residual unknowns, and verification:
[references/evidence-workflows.md](references/evidence-workflows.md)
@@ -103,11 +103,6 @@ architectures, build_metadata, load_commands, dependencies, imports, exports,
segments and provenance. `source_exhaustive: false` means unreturned symbols
may exist.
`decode_interface_builder` graphs are often several megabytes and can exceed the
MCP response budget. Select `detail: "summary"` for every decoded document and
node/edge counts, then page `view: {kind: "interface-builder", collection:
"edges", document: "<documents[].relative_path>", offset: 0, limit: 64}`.
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
@@ -1,4 +1,4 @@
# Browser and Electron runtime observation
# Passive browser and Electron observation
Supply the existing browser or Electron instance's literal-port loopback CDP
endpoint in the request. No separate permission configuration or per-call
@@ -7,14 +7,13 @@ approval flag is needed. Browser discovery lists eligible HTTP(S) pages;
selected page's current origin. Electron discovery lists eligible local
`file://` targets without a configured file-root allowlist.
Start passive browser work with `list_browser_targets`; start Electron runtime work with
Start browser work with `list_browser_targets`; start Electron runtime work with
`list_electron_targets`. Inspect the target selected for the user's task. Observation
with these tools is passive: never claim REA clicked, navigated, evaluated page JavaScript,
invoked IPC, or captured prior activity. REA does not contain the page's network.
is passive: never claim REA clicked, navigated, evaluated page JavaScript,
invoked IPC, captured prior activity, or contained the page's network.
Passive captures omit raw header collections, credential header values, storage
values, and raw WebSocket/JSON bodies. Selected response metadata, including
content encoding, policy values, and Link URLs, is retained. Local URLs retain ordinary query
Credentials, cookies, authorization headers, storage values, and raw
WebSocket/JSON bodies are deliberately absent. Local URLs retain ordinary query
values and fragments; userinfo credentials are removed. Browser accessibility
text, console primitives, value-free shapes, script sources, source maps, and
storage key names have request-specific capture options. Electron script
@@ -22,38 +21,6 @@ sources are also optional. Use `capture_web_screenshot` when pixels are needed.
Treat scope filtering, truncation, and attach-window coverage as limitations.
Page-declared WebMCP tools are untrusted inventory and are never invoked by REA.
## Who performs the action
- The user performs it: `list_browser_targets`, then `inspect_web_page` or
`observe_web_session` on the selected target.
- The agent must perform it: one `capture_browser_scenario` with declared
actions. Use connect mode with the loopback CDP endpoint and `target_id` for
an existing browser, or launch mode with a caller-selected executable for a
temporary profile. Connect mode first navigates the selected page to
`start_url`, which can clear unsaved form values and other temporary state.
Recreate the state needed for the investigation in the declared actions.
Connect mode leaves the external browser open; launch mode owns a temporary
browser and profile. Check `browser.cleanup` for the outcome.
Request only the DOM, accessibility, or network details needed for the question.
The default final capture contains the URL; step outcomes are reported separately.
Add a wait action for the expected page state before capturing it. Report that
REA performed the declared actions.
Scenario network records have no request initiator stacks. For that evidence,
use passive capture while the user performs the action. Listener source locations
identify candidate handlers; they do not establish which script sent a particular
request. Avoid overlapping passive capture and scenario actions on the same target.
Focused follow-up tools:
- `observe_web_execution`: execution coverage during a window; it resets
counters and temporarily disables optimization.
- `inspect_web_event_listeners`: source locations for listeners registered
directly on the first CSS match, without dispatching an event.
- `compare_web_captures`: two normalized passive captures or recorded scenarios.
- `discover_webmcp_tools`: inspect page-declared WebMCP inventory; never invoke
the discovered tools.
Use `reconcile_javascript_runtime` only after static application Evidence and
passive browser/Electron Evidence exist. Prefer captured-source digest identity;
report path/digest disagreement as a mismatch and preserve ambiguity. Explicit
+1 -13
View File
@@ -193,8 +193,7 @@ after target content was read.
## Retained application Evidence inputs
`analyze_javascript_application`, `inspect_binary_layout`, `inspect_artifact`,
`inspect_wasm_artifact`, `inspect_macho`, and `decode_interface_builder` accept
`detail`:
`inspect_wasm_artifact`, and `inspect_macho` accept `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
@@ -277,17 +276,6 @@ symbols (Electron Framework: 2.5 MB of imports and exports, 3.3 KB as a
summary), so select `detail: "summary"` on the first call. Delivery detail
never reaches the native tools or changes the source Evidence ID.
Completed `decode_interface_builder` Evidence supports a summary and
`view: {kind: "interface-builder", collection: "nodes", offset: 0, limit: 64}`.
Collections are `documents`, `nodes`, `edges`, and `coverage`; for nodes and
edges an optional `document` keeps rows whose evidence names that exact decoded
`relative_path`, and an undecoded path is refused. The summary keeps every
decoded document, the provider, node-kind, edge-relation and coverage-status
counts, and whether the decoded graph is incomplete. Each page repeats `source_truncated`. A complete
app graph often exceeds the default MCP response budget (XLD.app: 6.7 MB), so
select `detail: "summary"` on the first call. Delivery detail never reaches the
decoder or changes the source Evidence ID.
`trace_application_feature`, `trace_javascript_semantics`,
`compare_application_versions`, `compare_source_to_bundle`, and
`compare_javascript_export_shapes` accept complete inline application Evidence
+1 -10
View File
@@ -319,16 +319,7 @@ complete observation.
digest are preserved; key interpretation follows the pinned plist decoder.
- `decode_interface_builder` / `rea decode-interface-builder <app>` reads
compiled storyboard and nib resources into objects, outlets, actions,
connections and controller/class names. A full app graph is often several
megabytes (XLD.app: 6.7 MB, about 2M tokens, which also exceeds the default
MCP response budget), so MCP callers should pass `detail: "summary"`. The
summary keeps every decoded document and node/edge/coverage counts and
retains the complete Evidence; page rows with `inspect_analysis_view`
`view: {kind: "interface-builder", collection: "nodes", document:
"Contents/Resources/Base.lproj/MainMenu.nib", offset: 0, limit: 64}`.
`document` selects rows whose evidence names that exact
`documents[].relative_path` (nodes and edges only); `source_truncated` keeps
graph incompleteness visible on every page. `verify:interface-builder` compiles
connections and controller/class names. `verify:interface-builder` compiles
a source-owned AppKit XIB with `ibtool`. Storyboards require an installed
iOS platform; unsupported archive forms remain explicit.
XML plist archives accept UTF-8 and BOM-marked UTF-16 in either byte order.
@@ -0,0 +1,8 @@
import type { Client } from "@modelcontextprotocol/client";
/** Verify segmented aliases and restore the entry's observed public name. */
export function verifyGhidraAddressBoundaries(
client: Pick<Client, "callTool">,
entry: string,
cli: (command: string, address: string, flags: string[]) => Promise<unknown>,
): Promise<void>;
+61 -29
View File
@@ -63,35 +63,67 @@ export async function verifyGhidraAddressBoundaries(client, entry, cli) {
await query("read_bytes", { address: entry, length: 1 }),
);
const aliasKeys = [entry, padded];
assert.deepEqual(
await query("set_addresses_names", {
names: {
[entry]: "rea_same_address_alias",
[padded]: "rea_same_address_alias",
},
}),
Object.fromEntries(aliasKeys.map((address) => [address, true])),
"Equivalent address spellings with one requested name share one edit",
);
const nameBeforeAliasedBatch = await query("address_name", {
address: entry,
});
assert.equal(nameBeforeAliasedBatch, "rea_same_address_alias");
assert.deepEqual(
await query("set_addresses_names", {
names: {
[entry]: "rea_first_address_alias",
[padded]: "rea_second_address_alias",
},
}),
Object.fromEntries(aliasKeys.map((address) => [address, false])),
"Conflicting spellings of one Ghidra address must be rejected together",
);
assert.equal(
await query("address_name", { address: entry }),
nameBeforeAliasedBatch,
"A rejected alias collision must leave the preexisting target name unchanged",
);
const originalName = await query("address_name", { address: entry });
assert.equal(typeof originalName, "string", "Entry has no restorable name");
const failures = [];
let restorationFailed = false;
try {
assert.deepEqual(
await query("set_addresses_names", {
names: {
[entry]: "rea_same_address_alias",
[padded]: "rea_same_address_alias",
},
}),
Object.fromEntries(aliasKeys.map((address) => [address, true])),
"Equivalent address spellings with one requested name share one edit",
);
assert.equal(
await query("address_name", { address: entry }),
"rea_same_address_alias",
);
assert.deepEqual(
await query("set_addresses_names", {
names: {
[entry]: "rea_first_address_alias",
[padded]: "rea_second_address_alias",
},
}),
Object.fromEntries(aliasKeys.map((address) => [address, false])),
"Conflicting spellings of one Ghidra address must be rejected together",
);
assert.equal(
await query("address_name", { address: entry }),
"rea_same_address_alias",
"A rejected alias collision must leave the preexisting target name unchanged",
);
} catch (cause) {
failures.push(cause);
} finally {
// Restore even when a failed reply leaves the write outcome uncertain.
// The public API restores the observed name, not private symbol provenance.
try {
assert.equal(
await query("set_address_name", { address: entry, name: originalName }),
true,
"Temporary address alias restoration was rejected",
);
assert.equal(
await query("address_name", { address: entry }),
originalName,
"Temporary address alias restoration did not restore the observed name",
);
} catch (cause) {
restorationFailed = true;
failures.push(cause);
}
}
if (restorationFailed)
throw new AggregateError(
failures,
"Ghidra address boundary verification failed to restore its temporary name",
);
if (failures.length > 0) throw failures[0];
for (const [command, flags, operation] of [
["read-bytes", ["--length", "1"], "read_bytes"],
[
File diff suppressed because one or more lines are too long
+1 -7
View File
@@ -89,17 +89,11 @@ export const ARTIFACT_TOOL_CONTRACTS = [
),
artifact(
"decode_interface_builder",
"Decode compiled storyboard and nib archives inside the active Apple app bundle, including keyed property lists and NIBArchive object tables, into a bounded object, hierarchy, and connection graph. This is static archive parsing; unrecognized archive classes and unresolved code handlers remain explicit unknowns. A full app graph is often several megabytes; detail summary retains it and returns documents with node, edge and coverage counts, then page rows, optionally one document's, with inspect_analysis_view kind interface-builder.",
"Decode compiled storyboard and nib archives inside the active Apple app bundle, including keyed property lists and NIBArchive object tables, into a bounded object, hierarchy, and connection graph. This is static archive parsing; unrecognized archive classes and unresolved code handlers remain explicit unknowns.",
z.strictObject({
max_documents: z.number().int().min(1).max(64).default(64),
max_objects: z.number().int().min(1).max(20_000).default(20_000),
max_connections: z.number().int().min(1).max(40_000).default(40_000),
detail: analysisDetailSchema
.unwrap()
.optional()
.describe(
"Omitted or complete returns the complete decoded graph Evidence. summary retains that complete Evidence in this session and returns its inspect_analysis_view summary; use normalized_result.parent_evidence_id for interface-builder pages.",
),
}),
),
artifact(
+1 -7
View File
@@ -2,7 +2,6 @@ import { z } from "zod";
import { byteSearchResultSchema } from "../domain/native/bytePattern.js";
import {
artifactInventorySummaryViewResultSchema,
interfaceBuilderSummaryViewResultSchema,
machoSummaryViewResultSchema,
managedArtifactSummaryViewResultSchema,
} from "../domain/analysisView/analysisView.js";
@@ -405,12 +404,7 @@ export const artifactOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
]),
),
extract_artifact: evidenceResultOf(artifactExtractionResultSchema),
decode_interface_builder: evidenceResultOf(
z.union([
interfaceBuilderAnalysisSchema,
interfaceBuilderSummaryViewResultSchema,
]),
),
decode_interface_builder: evidenceResultOf(interfaceBuilderAnalysisSchema),
inspect_keyed_archive: evidenceResultOf(keyedArchiveResultSchema),
inspect_asset_catalog: evidenceResultOf(appleAssetCatalogResultSchema),
trace_dylib_resolution: evidenceResultOf(dylibResolutionResultSchema),
+1 -31
View File
@@ -62,13 +62,6 @@ import {
machoViewRequestSchema,
projectMachoView,
} from "./machoView.js";
import {
interfaceBuilderSummarySchema,
interfaceBuilderViewContextSchema,
interfaceBuilderViewItemsSchema,
interfaceBuilderViewRequestSchema,
projectInterfaceBuilderView,
} from "./interfaceBuilderView.js";
import {
projectWebNetworkCaptureView,
captureViewRequestSchema,
@@ -213,7 +206,6 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [
inventoryViewSchema,
wasmViewRequestSchema,
machoViewRequestSchema,
interfaceBuilderViewRequestSchema,
evmViewRequestSchema,
managedViewRequestSchema,
androidViewRequestSchema,
@@ -334,7 +326,6 @@ const viewResultBase = {
"inventory_artifact",
"inspect_wasm_artifact",
"inspect_macho",
"decode_interface_builder",
"inspect_evm_interface",
"inspect_managed_artifact",
"inspect_web_network_capture",
@@ -391,13 +382,6 @@ export const machoSummaryViewResultSchema = z.strictObject({
summary: machoSummarySchema,
});
/** Decoded documents and graph counts for Interface Builder's selected delivery. */
export const interfaceBuilderSummaryViewResultSchema = z.strictObject({
...summaryViewResultBase,
parent_operation: z.literal("decode_interface_builder"),
summary: interfaceBuilderSummarySchema,
});
/** Exact inferred-interface summary for EVM's caller-selected delivery. */
export const evmInterfaceSummaryViewResultSchema = z.strictObject({
...summaryViewResultBase,
@@ -460,7 +444,6 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
inventorySummarySchema,
wasmSummarySchema,
machoSummarySchema,
interfaceBuilderSummarySchema,
evmSummarySchema,
managedSummarySchema,
captureSummarySchema,
@@ -531,14 +514,6 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
...machoViewContextSchema.shape,
parent_operation: z.literal("inspect_macho"),
}),
z.strictObject({
kind: z.literal("interface-builder"),
view: interfaceBuilderViewRequestSchema,
items: interfaceBuilderViewItemsSchema,
...viewResultBase,
...interfaceBuilderViewContextSchema.shape,
parent_operation: z.literal("decode_interface_builder"),
}),
z.strictObject({
kind: z.literal("evm"),
view: evmViewRequestSchema,
@@ -636,10 +611,6 @@ export type UnsignedAnalysisView =
>
| Omit<Extract<AnalysisViewResult, { readonly kind: "wasm" }>, "view_digest">
| Omit<Extract<AnalysisViewResult, { readonly kind: "macho" }>, "view_digest">
| Omit<
Extract<AnalysisViewResult, { readonly kind: "interface-builder" }>,
"view_digest"
>
| Omit<Extract<AnalysisViewResult, { readonly kind: "evm" }>, "view_digest">
| Omit<Extract<AnalysisViewResult, { readonly kind: "qlog" }>, "view_digest">
| Omit<Extract<AnalysisViewResult, { readonly kind: "pcap" }>, "view_digest">
@@ -703,7 +674,7 @@ const unsupportedParent = (
new AnalysisUnsupportedTargetError(
INSPECT_ANALYSIS_VIEW_OPERATION,
parentArtifactPath(parent),
`Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function, artifact inventory/inspection, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package, Dart AOT, or web network capture Evidence.`,
`Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function, artifact inventory/inspection, WASM, Mach-O, EVM, managed artifact, Android package, Dart AOT, or web network capture Evidence.`,
);
const parentArtifactPath = (parent: AnalysisViewParent): string => {
@@ -731,7 +702,6 @@ const parentArtifactPath = (parent: AnalysisViewParent): string => {
const retainedViewProjectors = new Map<string, typeof projectWasmArtifactView>([
["inspect_wasm_artifact", projectWasmArtifactView],
["inspect_macho", projectMachoView],
["decode_interface_builder", projectInterfaceBuilderView],
["inspect_evm_interface", projectEvmInterfaceView],
["inspect_managed_artifact", projectManagedArtifactView],
["inspect_android_package", projectAndroidPackageView],
@@ -1,25 +0,0 @@
import type { z } from "zod";
import type { AnalysisError } from "../analysisErrorBase.js";
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
import { err, ok, type Result } from "../result.js";
import type { AnalysisViewParent } from "./analysisView.js";
/** Parse a parent's result with its owning schema; issues point into the view source. */
export const parseAnalysisViewResult = <Schema extends z.ZodType>(
schema: Schema,
parent: AnalysisViewParent,
): Result<z.output<Schema>, AnalysisError> => {
const parsed = schema.safeParse(parent.normalizedResult);
if (parsed.success) return ok(parsed.data);
return err(
analysisInputErrorFromIssues(
"inspect_analysis_view",
parsed.error.issues.map((issue) => ({
...issue,
path: ["source", "normalized_result", ...issue.path],
})),
{ source: { normalized_result: parent.normalizedResult } },
{ cause: parsed.error },
),
);
};
+15 -4
View File
@@ -1,8 +1,8 @@
import { z } from "zod";
import type { AnalysisError } from "../analysisErrorBase.js";
import { androidResultSchemas } from "../android/androidAnalysis.js";
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
import { err, ok, type Result } from "../result.js";
import { parseAnalysisViewResult } from "./analysisViewParent.js";
import {
analysisViewInputError,
type AnalysisViewParent,
@@ -53,8 +53,19 @@ export const projectAndroidPackageView = (
},
]),
);
const parsed = parseAnalysisViewResult(packageSchema, parent);
if (!parsed.ok) return parsed;
const parsed = packageSchema.safeParse(parent.normalizedResult);
if (!parsed.success)
return err(
analysisInputErrorFromIssues(
"inspect_analysis_view",
parsed.error.issues.map((issue) => ({
...issue,
path: ["source", "normalized_result", ...issue.path],
})),
{ source: { normalized_result: parent.normalizedResult } },
{ cause: parsed.error },
),
);
// Engine artifact_sha256 identifies the JAR, never the APK. A missing
// authenticated APK subject cannot be repaired using the engine identity.
if (parent.subject == null)
@@ -68,7 +79,7 @@ export const projectAndroidPackageView = (
},
]),
);
const report = parsed.value;
const report = parsed.data;
const shared = {
parent_evidence_id: parent.evidenceId,
parent_operation: "inspect_android_package" as const,
+15 -4
View File
@@ -1,8 +1,8 @@
import { z } from "zod";
import type { AnalysisError } from "../analysisErrorBase.js";
import { flutterResultSchemas } from "../flutter/flutterBuildAnalysis.js";
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
import { err, ok, type Result } from "../result.js";
import { parseAnalysisViewResult } from "./analysisViewParent.js";
import {
analysisViewInputError,
type AnalysisViewParent,
@@ -59,9 +59,20 @@ export const projectDartAotView = (
},
]),
);
const parsed = parseAnalysisViewResult(aotSchema, parent);
if (!parsed.ok) return parsed;
const report = parsed.value;
const parsed = aotSchema.safeParse(parent.normalizedResult);
if (!parsed.success)
return err(
analysisInputErrorFromIssues(
"inspect_analysis_view",
parsed.error.issues.map((issue) => ({
...issue,
path: ["source", "normalized_result", ...issue.path],
})),
{ source: { normalized_result: parent.normalizedResult } },
{ cause: parsed.error },
),
);
const report = parsed.data;
const { package_uris, dart_uris, class_like_tokens, ...pool } =
report.string_pool;
const string_pool = {
+15 -4
View File
@@ -2,8 +2,8 @@ import { z } from "zod";
import type { AnalysisError } from "../analysisErrorBase.js";
import { uniqueSorted } from "../canonicalOrdering.js";
import { evmInterfaceSchema } from "../evm/evmInterface.js";
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
import { err, ok, type Result } from "../result.js";
import { parseAnalysisViewResult } from "./analysisViewParent.js";
import {
analysisViewInputError,
type AnalysisViewParent,
@@ -60,9 +60,20 @@ export const projectEvmInterfaceView = (
},
]),
);
const parsed = parseAnalysisViewResult(evmInterfaceSchema, parent);
if (!parsed.ok) return parsed;
const report = parsed.value;
const parsed = evmInterfaceSchema.safeParse(parent.normalizedResult);
if (!parsed.success)
return err(
analysisInputErrorFromIssues(
"inspect_analysis_view",
parsed.error.issues.map((issue) => ({
...issue,
path: ["source", "normalized_result", ...issue.path],
})),
{ source: { normalized_result: parent.normalizedResult } },
{ cause: parsed.error },
),
);
const report = parsed.data;
if (
parent.subject == null ||
parent.subject.format !== "file" ||
@@ -1,168 +0,0 @@
import { describe, expect, it } from "vitest";
import {
projectAnalysisView,
type AnalysisViewParent,
} from "./analysisView.js";
const target = "a".repeat(64);
const evidence = (artifact_path: string) => [
{
kind: "interface_builder_resource" as const,
description: "compiled document",
location: { address: null, file_offset: null },
artifact_path,
artifact_sha256: "b".repeat(64),
},
];
const node = (id: string, kind: string, document: string) => ({
id,
kind,
name: id,
location: null,
attributes: {},
evidence: evidence(document),
});
const main = "Contents/Resources/Main.nib";
const second = "Contents/Resources/Second.nib";
const document = (relative_path: string) => ({
relative_path,
archive_sha256: "b".repeat(64),
document_kind: "nib" as const,
object_count: 2,
connection_count: 1,
hierarchy_complete: true,
});
const report = (truncated = false) => ({
target_sha256: target,
documents: [document(main), document(second)],
graph: {
target_sha256: target,
provider: { id: "rea-ib", version: "1", tool_version: "1" },
nodes: [
node("main:root", "scene", main),
node("main:button", "control", main),
node("second:root", "scene", second),
],
edges: [
{
id: "main:edge",
from: "main:root",
to: "main:button",
relation: "contains",
resolution: "observed",
evidence: evidence(main),
limitations: [],
},
],
coverage: [
{
facet: `objects:${main}`,
status: "complete",
reason: null,
examined: 2,
omitted: 0,
},
],
truncated,
},
limitations: ["Recognized archive fields only."],
});
const parent = (
normalizedResult: unknown = report(),
sha256 = target,
): AnalysisViewParent => ({
evidenceId: `ev_${"c".repeat(64)}`,
operation: "decode_interface_builder",
normalizedResult,
subject: {
name: "Fixture",
digest: { sha256 },
format: "mach-o",
architecture: "arm64",
local_path: "/Applications/Fixture.app/Contents/MacOS/Fixture",
},
limitations: [],
});
describe("Interface Builder analysis views", () => {
it("summarizes documents and graph counts without rows", () => {
const projected = projectAnalysisView(parent(), { kind: "summary" });
if (!projected.ok) throw projected.error;
expect(projected.value).toMatchObject({
kind: "summary",
parent_operation: "decode_interface_builder",
artifact: { sha256: target },
summary: {
documents: report().documents,
available_counts: { nodes: 3, edges: 1, coverage: 1 },
node_kinds: { control: 1, scene: 2 },
edge_relations: { contains: 1 },
coverage_statuses: { complete: 1 },
truncated: false,
},
coverage: { examined: 7, total: 7 },
});
expect(JSON.stringify(projected.value)).not.toContain("main:button");
});
it("pages one document's nodes and keeps decoder truncation visible", () => {
const projected = projectAnalysisView(parent(), {
kind: "interface-builder",
collection: "nodes",
document: main,
offset: 1,
limit: 5,
});
if (!projected.ok) throw projected.error;
expect(projected.value).toMatchObject({
kind: "interface-builder",
items: [node("main:button", "control", main)],
source_truncated: false,
coverage: { status: "page", examined: 1, total: 2, exhausted: true },
});
const truncated = projectAnalysisView(parent(report(true)), {
kind: "interface-builder",
collection: "coverage",
offset: 0,
limit: 64,
});
if (!truncated.ok) throw truncated.error;
expect(truncated.value).toMatchObject({
items: report().graph.coverage,
source_truncated: true,
unknowns: expect.arrayContaining([
expect.stringContaining("The decoded graph is incomplete"),
]),
});
});
it("refuses unknown documents, other views and mismatched targets", () => {
const unknown = projectAnalysisView(parent(), {
kind: "interface-builder",
collection: "edges",
document: "Contents/Resources/Missing.nib",
offset: 0,
limit: 64,
});
if (unknown.ok) throw new Error("expected an unknown-document refusal");
expect(unknown.error).toMatchObject({
issues: [{ path: ["view", "document"] }],
});
const wrongKind = projectAnalysisView(parent(), {
kind: "macho",
facet: "imports",
offset: 0,
limit: 64,
});
if (wrongKind.ok) throw new Error("expected a view-kind refusal");
expect(wrongKind.error).toMatchObject({ issues: [{ path: ["view"] }] });
const mismatched = projectAnalysisView(parent(report(), "d".repeat(64)), {
kind: "summary",
});
if (mismatched.ok) throw new Error("expected a subject refusal");
expect(mismatched.error).toMatchObject({
issues: [{ path: ["source", "subject"] }],
});
});
});
@@ -1,248 +0,0 @@
import { z } from "zod";
import type { AnalysisError } from "../analysisErrorBase.js";
import { uniqueSorted } from "../canonicalOrdering.js";
import {
interfaceBuilderAnalysisSchema,
interfaceBuilderDocumentSchema,
} from "../apple/interfaceBuilderGraph.js";
import {
nativeInvestigationCoverageSchema,
nativeInvestigationEdgeSchema,
nativeInvestigationNodeSchema,
} from "../native/nativeInvestigationGraph.js";
import { err, ok, type Result } from "../result.js";
import { parseAnalysisViewResult } from "./analysisViewParent.js";
import {
analysisViewInputError,
type AnalysisViewParent,
type AnalysisViewRequest,
type UnsignedAnalysisView,
} from "./analysisView.js";
import {
completeWithinViewCoverage,
pageViewCoverage,
} from "./analysisViewCoverage.js";
const page = {
kind: z.literal("interface-builder"),
offset: z.number().int().nonnegative().default(0),
limit: z.number().int().positive().default(64),
} as const;
/** Original decoded rows; graph rows can be narrowed to one decoded document. */
export const interfaceBuilderViewRequestSchema = z.discriminatedUnion(
"collection",
[
z.strictObject({
...page,
collection: z.enum(["nodes", "edges"]),
document: z
.string()
.min(1)
.optional()
.describe(
"Exact documents[].relative_path; selects rows whose evidence names that archive.",
),
}),
z.strictObject({
...page,
collection: z.enum(["documents", "coverage"]),
}),
],
);
export const interfaceBuilderViewItemsSchema = z.union([
z.array(interfaceBuilderDocumentSchema),
z.array(nativeInvestigationNodeSchema),
z.array(nativeInvestigationEdgeSchema),
z.array(nativeInvestigationCoverageSchema),
]);
/** The decoder's own truncation, independent of page exhaustion. */
export const interfaceBuilderViewContextSchema = z.strictObject({
source_truncated: z.boolean(),
});
const countsSchema = z.record(z.string(), z.number().int().positive());
/** Every decoded document with graph counts; rows and evidence stay retained. */
export const interfaceBuilderSummarySchema = z.strictObject({
target_sha256: interfaceBuilderAnalysisSchema.shape.target_sha256,
provider: interfaceBuilderAnalysisSchema.shape.graph.shape.provider,
documents: interfaceBuilderAnalysisSchema.shape.documents,
available_counts: z.strictObject({
nodes: z.number().int().nonnegative(),
edges: z.number().int().nonnegative(),
coverage: z.number().int().nonnegative(),
}),
node_kinds: countsSchema,
edge_relations: countsSchema,
coverage_statuses: countsSchema,
truncated: z.boolean(),
limitation_count: z.number().int().nonnegative(),
});
const tally = (values: readonly string[]): Record<string, number> => {
const counts = new Map<string, number>();
for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
return Object.fromEntries(
[...counts].sort(([left], [right]) => (left < right ? -1 : 1)),
);
};
type InterfaceBuilderReport = z.output<typeof interfaceBuilderAnalysisSchema>;
/** Admit a well-formed report bound to its recorded target, if one exists. */
const parseInterfaceBuilderReport = (
parent: AnalysisViewParent,
): Result<InterfaceBuilderReport, AnalysisError> => {
const parsed = parseAnalysisViewResult(
interfaceBuilderAnalysisSchema,
parent,
);
if (!parsed.ok) return parsed;
const report = parsed.value;
if (
report.graph.target_sha256 !== report.target_sha256 ||
(parent.subject != null &&
parent.subject.digest.sha256 !== report.target_sha256)
)
return err(
analysisViewInputError([
{
path: ["source", "subject"],
reason: "invalid_value",
message:
"Interface Builder parent Evidence subject and graph must identify the report's target SHA-256.",
},
]),
);
return ok(report);
};
/** Every decoded document with graph tallies; rows stay in the retained parent. */
const summarizeInterfaceBuilder = (
report: InterfaceBuilderReport,
limitationCount: number,
) => {
const { graph } = report;
const total =
report.documents.length +
graph.nodes.length +
graph.edges.length +
graph.coverage.length;
return {
summary: {
target_sha256: report.target_sha256,
provider: graph.provider,
documents: report.documents,
available_counts: {
nodes: graph.nodes.length,
edges: graph.edges.length,
coverage: graph.coverage.length,
},
node_kinds: tally(graph.nodes.map((node) => node.kind)),
edge_relations: tally(graph.edges.map((edge) => edge.relation)),
coverage_statuses: tally(graph.coverage.map((row) => row.status)),
truncated: graph.truncated,
limitation_count: limitationCount,
},
coverage: completeWithinViewCoverage(total, total),
};
};
/** Project retained decode_interface_builder Evidence without reopening archives. */
export const projectInterfaceBuilderView = (
parent: AnalysisViewParent,
view: AnalysisViewRequest,
): Result<UnsignedAnalysisView, AnalysisError> => {
if (view.kind !== "summary" && view.kind !== "interface-builder")
return err(
analysisViewInputError([
{
path: ["view"],
reason: "invalid_value",
message:
"Interface Builder decoding supports summary and interface-builder views.",
},
]),
);
const parsed = parseInterfaceBuilderReport(parent);
if (!parsed.ok) return parsed;
const report = parsed.value;
const { graph } = report;
const limitations = uniqueSorted([
...parent.limitations,
...report.limitations,
]);
const shared = {
parent_evidence_id: parent.evidenceId,
parent_operation: "decode_interface_builder" as const,
parent_digest: parent.evidenceId.slice(3),
artifact: {
path: parent.subject?.local_path ?? "",
sha256: report.target_sha256,
},
limitations,
unknowns: [
"Rows describe recognized archive objects and connections only; unrecognized classes, fields and runtime-created UI remain unknown.",
...(graph.truncated
? [
"The decoded graph is incomplete (see coverage and limitations for the cause); page exhaustion does not establish absence of undecoded objects or connections.",
]
: []),
],
};
if (view.kind === "summary")
return ok({
kind: "summary",
view,
...shared,
...summarizeInterfaceBuilder(report, limitations.length),
});
const document =
view.collection === "nodes" || view.collection === "edges"
? view.document
: undefined;
if (
document !== undefined &&
!report.documents.some((row) => row.relative_path === document)
)
return err(
analysisViewInputError([
{
path: ["view", "document"],
reason: "invalid_value",
message:
"Select an exact documents[].relative_path from this decoded report.",
},
]),
);
const inDocument = <
Row extends { readonly evidence: readonly { artifact_path: unknown }[] },
>(
candidates: readonly Row[],
): readonly Row[] =>
document === undefined
? candidates
: candidates.filter((row) =>
row.evidence.some((evidence) => evidence.artifact_path === document),
);
const rows =
view.collection === "documents"
? report.documents
: view.collection === "coverage"
? graph.coverage
: view.collection === "nodes"
? inDocument(graph.nodes)
: inDocument(graph.edges);
const items = rows.slice(view.offset, view.offset + view.limit);
return ok({
kind: "interface-builder",
view,
items,
...shared,
source_truncated: graph.truncated,
coverage: pageViewCoverage(view.offset, items.length, rows.length),
});
};
+15 -4
View File
@@ -2,9 +2,9 @@ import { z } from "zod";
import type { AnalysisError } from "../analysisErrorBase.js";
import { uniqueSorted } from "../canonicalOrdering.js";
import { isMachOFormat } from "../binaryTargetTypes.js";
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
import { inspectMachoSchema } from "../native/nativeInspection.js";
import { err, ok, type Result } from "../result.js";
import { parseAnalysisViewResult } from "./analysisViewParent.js";
import {
analysisViewInputError,
type AnalysisViewParent,
@@ -93,8 +93,19 @@ type MachoReport = z.output<typeof inspectMachoSchema>;
const parseMachoReport = (
parent: AnalysisViewParent,
): Result<MachoReport, AnalysisError> => {
const parsed = parseAnalysisViewResult(inspectMachoSchema, parent);
if (!parsed.ok) return parsed;
const parsed = inspectMachoSchema.safeParse(parent.normalizedResult);
if (!parsed.success)
return err(
analysisInputErrorFromIssues(
"inspect_analysis_view",
parsed.error.issues.map((issue) => ({
...issue,
path: ["source", "normalized_result", ...issue.path],
})),
{ source: { normalized_result: parent.normalizedResult } },
{ cause: parsed.error },
),
);
if (parent.subject != null && !isMachOFormat(parent.subject.format))
return err(
analysisViewInputError([
@@ -106,7 +117,7 @@ const parseMachoReport = (
},
]),
);
return ok(parsed.value);
return ok(parsed.data);
};
/** Counts replace rows; every collection caveat stays in the limitations. */
+16 -6
View File
@@ -1,12 +1,12 @@
import { z } from "zod";
import type { AnalysisError } from "../analysisErrorBase.js";
import { uniqueSorted } from "../canonicalOrdering.js";
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
import {
managedArtifactInspectionSchema,
managedMemberInspectionSchema,
} from "../managed/managedArtifact.js";
import { err, ok, type Result } from "../result.js";
import { parseAnalysisViewResult } from "./analysisViewParent.js";
import {
analysisViewInputError,
type AnalysisViewParent,
@@ -67,12 +67,22 @@ export const projectManagedArtifactView = (
},
]),
);
const parsed = parseAnalysisViewResult(
managedArtifactInspectionSchema,
parent,
const parsed = managedArtifactInspectionSchema.safeParse(
parent.normalizedResult,
);
if (!parsed.ok) return parsed;
const report = parsed.value;
if (!parsed.success)
return err(
analysisInputErrorFromIssues(
"inspect_analysis_view",
parsed.error.issues.map((issue) => ({
...issue,
path: ["source", "normalized_result", ...issue.path],
})),
{ source: { normalized_result: parent.normalizedResult } },
{ cause: parsed.error },
),
);
const report = parsed.data;
const identity_scope = {
token_identity: "build-local" as const,
requires_artifact_sha256: report.artifact.sha256,
+15 -4
View File
@@ -2,11 +2,11 @@ import { z } from "zod";
import type { AnalysisError } from "../analysisErrorBase.js";
import { uniqueSorted } from "../canonicalOrdering.js";
import { err, ok, type Result } from "../result.js";
import { parseAnalysisViewResult } from "./analysisViewParent.js";
import {
wasmArtifactSchema,
wasmArtifactMatchesSubject,
} from "../wasm/wasmArtifact.js";
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
import {
analysisViewInputError,
type AnalysisViewParent,
@@ -81,9 +81,20 @@ export const projectWasmArtifactView = (
},
]),
);
const parsed = parseAnalysisViewResult(wasmArtifactSchema, parent);
if (!parsed.ok) return parsed;
const report = parsed.value;
const parsed = wasmArtifactSchema.safeParse(parent.normalizedResult);
if (!parsed.success)
return err(
analysisInputErrorFromIssues(
"inspect_analysis_view",
parsed.error.issues.map((issue) => ({
...issue,
path: ["source", "normalized_result", ...issue.path],
})),
{ source: { normalized_result: parent.normalizedResult } },
{ cause: parsed.error },
),
);
const report = parsed.data;
if (
!wasmArtifactMatchesSubject(
report.artifact,
@@ -1,8 +1,8 @@
import { z } from "zod";
import type { AnalysisError } from "../analysisErrorBase.js";
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
import type { JsonValue } from "../jsonValue.js";
import { err, ok, type Result } from "../result.js";
import { parseAnalysisViewResult } from "./analysisViewParent.js";
import {
webNetworkCaptureRecordSchema,
webNetworkCaptureBinaryDescriptorSchema,
@@ -118,9 +118,20 @@ const preview = (record: WebNetworkCaptureRecord) => {
const parseCaptureParent = (
parent: AnalysisViewParent,
): Result<WebNetworkCapture, AnalysisError> => {
const parsed = parseAnalysisViewResult(webNetworkCaptureSchema, parent);
if (!parsed.ok) return parsed;
const report = parsed.value;
const parsed = webNetworkCaptureSchema.safeParse(parent.normalizedResult);
if (!parsed.success)
return err(
analysisInputErrorFromIssues(
"inspect_analysis_view",
parsed.error.issues.map((issue) => ({
...issue,
path: ["source", "normalized_result", ...issue.path],
})),
{ source: { normalized_result: parent.normalizedResult } },
{ cause: parsed.error },
),
);
const report = parsed.data;
if (
report.artifact.sha256 !== null &&
(parent.subject == null ||
+1 -1
View File
@@ -5,5 +5,5 @@ export const PACKAGE_METADATA = {
serverSdkVersion: "2.3.1",
clientSdkVersion: "2.3.1",
coreSdkVersion: "2.3.1",
skillVersion: "42",
skillVersion: "41",
} as const;
+29 -19
View File
@@ -1,36 +1,39 @@
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
import {
ARTIFACT_TOOL_CONTRACTS,
artifactInspectionInputSchema,
} from "../contracts/artifactToolContracts.js";
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
import {
requireAnalysisViewRetention,
summarizeRetainedAnalysis,
} from "../application/analysisView/AnalysisViewService.js";
import { createArtifactExtractionDestination } from "../application/artifacts/ArtifactExtractionDestination.js";
import { ok } from "../domain/result.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import type { Logger } from "pino";
import { registerEvidenceTools } from "./registerEvidenceTools.js";
import { artifactInspectionResultSchema } from "../domain/artifactInspection.js";
import {
retainedSummaryDelivery,
withoutDeliveryDetail,
type EvidenceToolFamilyOptions,
} from "./retainedSummaryDelivery.js";
/** Operations whose MCP delivery may be a retained summary view. */
const SUMMARY_DELIVERY: ReadonlySet<string> = new Set([
"inspect_artifact",
"decode_interface_builder",
]);
/** inspect_artifact keeps an explicit detail in its Evidence parameters. */
const decodeWithoutDelivery = withoutDeliveryDetail(
new Set(["decode_interface_builder"]),
);
/** Register deterministic artifact inventory and safe extraction operations. */
export const registerArtifactTools = (
server: EvidenceMcpServer,
options: EvidenceToolFamilyOptions,
options: {
readonly logger: Logger;
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
readonly evidenceById?: EvidenceLookup | undefined;
readonly withAdmittedAnalysis: import("./analysisAdmission.js").WithAdmittedAnalysis;
},
): void => {
const retention = {
recordEvidence: options.recordEvidence,
evidenceById: options.evidenceById,
};
registerEvidenceTools(server, ARTIFACT_TOOL_CONTRACTS, {
...options,
...retainedSummaryDelivery(SUMMARY_DELIVERY, options),
parametersForExecution: (operation, parameters) => {
if (operation === "inspect_artifact") {
const { detail: _detail, ...execution } =
@@ -39,9 +42,16 @@ export const registerArtifactTools = (
}
return operation === "extract_artifact"
? { ...parameters, output_root: createArtifactExtractionDestination() }
: decodeWithoutDelivery(operation, parameters);
: parameters;
},
parametersForEvidence: decodeWithoutDelivery,
preflight: (operation, parameters) =>
operation === "inspect_artifact" && parameters.detail === "summary"
? requireAnalysisViewRetention(operation, retention)
: ok(null),
projectEvidence: (operation, parameters, evidence) =>
operation === "inspect_artifact" && parameters.detail === "summary"
? summarizeRetainedAnalysis(evidence, retention)
: ok(evidence),
sourceEvidence: (operation, result) =>
operation === "inspect_artifact"
? artifactInspectionResultSchema
+11 -2
View File
@@ -1,4 +1,6 @@
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
import {
requireAnalysisViewRetention,
summarizeRetainedAnalysis,
@@ -6,17 +8,24 @@ import {
import type { AnalysisOperationPort } from "../application/AnalysisProvider.js";
import { MANAGED_TOOL_CONTRACTS } from "../contracts/managed/managedToolContracts.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { err, ok } from "../domain/result.js";
import type { Logger } from "pino";
import { registerEvidenceTools } from "./registerEvidenceTools.js";
import type { EvidenceToolFamilyOptions } from "./retainedSummaryDelivery.js";
import { runManagedProviderExecution } from "../composition/directAnalysis.js";
import { isManagedToolName } from "../contracts/managed/managedToolContracts.js";
/** Register execution-free managed PE/CLI inspection. */
export const registerManagedTools = (
server: EvidenceMcpServer,
options: EvidenceToolFamilyOptions,
options: {
readonly logger: Logger;
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
readonly evidenceById?: EvidenceLookup | undefined;
readonly withAdmittedAnalysis: import("./analysisAdmission.js").WithAdmittedAnalysis;
},
): void => {
let selectedManagedPath: string | undefined;
const analysisForAdmission = (
+38 -9
View File
@@ -1,25 +1,54 @@
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
import { NATIVE_TOOL_CONTRACTS } from "../contracts/native/nativeToolContracts.js";
import { registerEvidenceTools } from "./registerEvidenceTools.js";
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
import {
retainedSummaryDelivery,
withoutDeliveryDetail,
type EvidenceToolFamilyOptions,
} from "./retainedSummaryDelivery.js";
requireAnalysisViewRetention,
summarizeRetainedAnalysis,
} from "../application/analysisView/AnalysisViewService.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { ok } from "../domain/result.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import type { Logger } from "pino";
import { registerEvidenceTools } from "./registerEvidenceTools.js";
const SUMMARY_DELIVERY: ReadonlySet<string> = new Set(["inspect_macho"]);
/** Delivery detail selects a response shape; it never reaches the tools or Evidence identity. */
const withoutDelivery = (
operation: string,
parameters: Readonly<Record<string, JsonValue>>,
): Readonly<Record<string, JsonValue>> => {
if (operation !== "inspect_macho") return parameters;
const { detail: _detail, ...selected } = parameters;
return selected;
};
/** Register macOS native contracts; Mach-O inspection can deliver a retained summary. */
export const registerNativeTools = (
server: EvidenceMcpServer,
options: EvidenceToolFamilyOptions,
options: {
readonly logger: Logger;
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
readonly evidenceById?: EvidenceLookup | undefined;
readonly withAdmittedAnalysis: import("./analysisAdmission.js").WithAdmittedAnalysis;
},
): void => {
const withoutDelivery = withoutDeliveryDetail(SUMMARY_DELIVERY);
const retention = {
recordEvidence: options.recordEvidence,
evidenceById: options.evidenceById,
};
registerEvidenceTools(server, NATIVE_TOOL_CONTRACTS, {
...options,
...retainedSummaryDelivery(SUMMARY_DELIVERY, options),
parametersForExecution: withoutDelivery,
parametersForEvidence: withoutDelivery,
preflight: (operation, parameters) =>
operation === "inspect_macho" && parameters.detail === "summary"
? requireAnalysisViewRetention(operation, retention)
: ok(null),
projectEvidence: (operation, parameters, evidence) =>
operation === "inspect_macho" && parameters.detail === "summary"
? summarizeRetainedAnalysis(evidence, retention)
: ok(evidence),
});
};
-81
View File
@@ -1,81 +0,0 @@
import { analysisDetailSchema } from "../contracts/analysisDetail.js";
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
import {
requireAnalysisViewRetention,
summarizeRetainedAnalysis,
} from "../application/analysisView/AnalysisViewService.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import type { Evidence } from "../domain/evidence.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { err, ok } from "../domain/result.js";
import type { Logger } from "pino";
import type { WithAdmittedAnalysis } from "./analysisAdmission.js";
/** Registration inputs shared by provider-backed Evidence tool families. */
export interface EvidenceToolFamilyOptions {
readonly logger: Logger;
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
readonly evidenceById?: EvidenceLookup | undefined;
readonly withAdmittedAnalysis: WithAdmittedAnalysis;
}
type Parameters = Readonly<Record<string, JsonValue>>;
/**
* Summary delivery for the named operations: refuse it before analysis when
* this server retains no Evidence, then return the retained summary view.
*/
export const retainedSummaryDelivery = (
operations: ReadonlySet<string>,
options: Pick<EvidenceToolFamilyOptions, "recordEvidence" | "evidenceById">,
) => {
const retention = {
recordEvidence: options.recordEvidence,
evidenceById: options.evidenceById,
};
const selected = (operation: string, parameters: Parameters): boolean =>
operations.has(operation) && parameters.detail === "summary";
return {
preflight: (operation: string, parameters: Parameters) => {
if (!operations.has(operation)) return ok(null);
const detail = analysisDetailSchema
.unwrap()
.optional()
.safeParse(parameters.detail);
if (!detail.success)
return err(
new AnalysisInputError(operation, { cause: detail.error }, [
{
path: ["detail"],
reason: "invalid_value",
expected: ["complete", "summary"],
},
]),
);
return detail.data === "summary"
? requireAnalysisViewRetention(operation, retention)
: ok(null);
},
projectEvidence: (
operation: string,
parameters: Parameters,
evidence: Evidence,
) =>
selected(operation, parameters)
? summarizeRetainedAnalysis(evidence, retention)
: ok(evidence),
};
};
/** Delivery detail selects a response shape; it never reaches tools or Evidence identity. */
export const withoutDeliveryDetail =
(operations: ReadonlySet<string>) =>
(operation: string, parameters: Parameters): Parameters => {
if (!operations.has(operation)) return parameters;
const { detail: _detail, ...selected } = parameters;
return selected;
};
@@ -1,164 +0,0 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { z } from "zod";
import { interfaceBuilderAnalysisSchema } from "../../../src/domain/apple/interfaceBuilderGraph.js";
import { thinMach } from "../../../src/domain/binaryTarget.fixture.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { mcpTest } from "../../support/mcp/mcpFixture.js";
import { openProductionTarget } from "../../support/mcp/productionSession.js";
const evidenceOf = <Schema extends z.ZodType>(schema: Schema) =>
z.object({
evidence_id: z.string(),
parameters: z.record(z.string(), z.unknown()),
normalized_result: schema,
});
const summarySchema = z.object({
kind: z.literal("summary"),
parent_evidence_id: z.string(),
summary: z.object({
documents: z.array(z.unknown()),
available_counts: z.object({
nodes: z.number(),
edges: z.number(),
coverage: z.number(),
}),
}),
});
const pageSchema = z.object({
kind: z.literal("interface-builder"),
items: z.array(z.unknown()),
coverage: z.object({ next_offset: z.number().nullable() }),
});
/** A two-document app; each nib is a real rooted UIKit outlet archive. */
const fixtureApp = async (): Promise<string> => {
const app = join(
await createTestTempDirectory("rea-ib-summary-mcp-"),
"Fixture.app",
);
const contents = join(app, "Contents");
const resources = join(contents, "Resources");
await mkdir(resources, { recursive: true });
await mkdir(join(contents, "MacOS"));
await writeFile(
join(contents, "MacOS", "Fixture"),
thinMach(0xfeedfacf, 0x0100000c),
);
await writeFile(
join(contents, "Info.plist"),
"<plist><dict><key>CFBundleExecutable</key><string>Fixture</string></dict></plist>",
);
const nib = await readFile(
join(process.cwd(), "tests/fixtures/interface-builder/UIKitOutlet.xml"),
);
await writeFile(join(resources, "Main.nib"), nib);
await writeFile(join(resources, "Second.nib"), nib);
return app;
};
mcpTest(
"retains complete Interface Builder Evidence and pages one document's rows",
async ({ mcp, onTestFinished }) => {
const app = await fixtureApp();
const client = await openProductionTarget(mcp, onTestFinished, app);
const invalidDetail = await client.callTool({
name: "decode_interface_builder",
arguments: { detail: "brief" },
});
expect(invalidDetail.isError).toBe(true);
expect(invalidDetail.content).toEqual([
expect.objectContaining({
type: "text",
text: expect.stringMatching(/Input validation error:.*detail/su),
}),
]);
// Summary first: its own retained parent must serve every later page.
const summarized = await client.callTool({
name: "decode_interface_builder",
arguments: { detail: "summary" },
});
expect(summarized.isError, JSON.stringify(summarized.content)).not.toBe(
true,
);
const summary = evidenceOf(summarySchema).parse(
summarized.structuredContent,
).normalized_result;
const source = {
kind: "retained-evidence",
evidence_id: summary.parent_evidence_id,
};
const readAll = async (view: Record<string, unknown>) => {
const rows: unknown[] = [];
let offset: number | null = 0;
while (offset !== null) {
const paged = await client.callTool({
name: "inspect_analysis_view",
arguments: {
source,
view: { kind: "interface-builder", ...view, offset, limit: 2 },
},
});
expect(paged.isError, JSON.stringify(paged.content)).not.toBe(true);
const page = evidenceOf(pageSchema).parse(
paged.structuredContent,
).normalized_result;
rows.push(...page.items);
offset = page.coverage.next_offset;
}
return rows;
};
const main = "Contents/Resources/Main.nib";
const edges = await readAll({ collection: "edges" });
const mainNodes = await readAll({ collection: "nodes", document: main });
// Only then compare with a complete call made for reference.
const complete = await client.callTool({
name: "decode_interface_builder",
arguments: {},
});
expect(complete.isError, JSON.stringify(complete.content)).not.toBe(true);
const full = evidenceOf(interfaceBuilderAnalysisSchema).parse(
complete.structuredContent,
);
expect(full.parameters).not.toHaveProperty("detail");
const report = full.normalized_result;
expect(report.documents).toHaveLength(2);
// Delivery detail never reaches the decoder or the source Evidence ID.
expect(summary.parent_evidence_id).toBe(full.evidence_id);
expect(summary.summary.documents).toEqual(report.documents);
expect(summary.summary.available_counts).toEqual({
nodes: report.graph.nodes.length,
edges: report.graph.edges.length,
coverage: report.graph.coverage.length,
});
expect(edges).toEqual(report.graph.edges);
const expectedMainNodes = report.graph.nodes.filter(({ evidence }) =>
evidence.some(({ artifact_path }) => artifact_path === main),
);
expect(expectedMainNodes.length).toBeGreaterThan(0);
expect(expectedMainNodes.length).toBeLessThan(report.graph.nodes.length);
expect(mainNodes).toEqual(expectedMainNodes);
const unknownDocument = await client.callTool({
name: "inspect_analysis_view",
arguments: {
source,
view: {
kind: "interface-builder",
collection: "nodes",
document: "Contents/Resources/Missing.nib",
},
},
});
expect(parseMcpToolError(unknownDocument)).toMatchObject({
error: { code: "invalid_request" },
});
},
);
@@ -1,24 +1,40 @@
import { silentLogger } from "../../../src/logger.js";
import { expect } from "vitest";
import { z } from "zod";
import { createBinarySession } from "../../../src/composition/binary.js";
import { parseConfig } from "../../../src/config/parseConfig.js";
import { keyedArchiveResultSchema } from "../../../src/domain/apple/keyedArchive.js";
import { createServer } from "../../../src/server/createServer.js";
import {
archiveNumberMetadataCases,
expectKeyedArchiveNumberMetadata,
keyedArchiveNumberMetadataFixture,
} from "../../fixtures/keyedArchiveNumberMetadata.js";
import { mcpTest } from "../../support/mcp/mcpFixture.js";
import { openProductionTarget } from "../../support/mcp/productionSession.js";
mcpTest.for(archiveNumberMetadataCases)(
"MCP preserves numeric meaning for $name",
async (item, { mcp, onTestFinished }) => {
const fixture = await keyedArchiveNumberMetadataFixture(item);
const client = await openProductionTarget(
mcp,
onTestFinished,
fixture.path,
const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" });
if (!configured.ok) throw configured.error;
const session = createBinarySession(
configured.value,
silentLogger,
process.env,
);
onTestFinished(async () => {
await session.close();
});
const client = await mcp.connect(
createServer({ kind: "session", session }),
);
const opened = await client.callTool({
name: "open_binary",
arguments: { path: fixture.path },
});
expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true);
const result = await client.callTool({
name: "inspect_keyed_archive",
arguments: {},
@@ -1,23 +1,39 @@
import { expect } from "vitest";
import { z } from "zod";
import { createBinarySession } from "../../../src/composition/binary.js";
import { parseConfig } from "../../../src/config/parseConfig.js";
import { keyedArchiveResultSchema } from "../../../src/domain/apple/keyedArchive.js";
import { silentLogger } from "../../../src/logger.js";
import { createServer } from "../../../src/server/createServer.js";
import {
expectKeyedArchivePrototypeKeys,
keyedArchivePrototypeKeyFixture,
} from "../../fixtures/keyedArchivePrototypeKeys.js";
import { mcpTest } from "../../support/mcp/mcpFixture.js";
import { openProductionTarget } from "../../support/mcp/productionSession.js";
mcpTest(
"MCP retains ordinary evidence with formatted XML prototype keys",
async ({ mcp, onTestFinished }) => {
const fixture = await keyedArchivePrototypeKeyFixture();
const client = await openProductionTarget(
mcp,
onTestFinished,
fixture.path,
const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" });
if (!configured.ok) throw configured.error;
const session = createBinarySession(
configured.value,
silentLogger,
process.env,
);
onTestFinished(async () => {
await session.close();
});
const client = await mcp.connect(
createServer({ kind: "session", session }),
);
const opened = await client.callTool({
name: "open_binary",
arguments: { path: fixture.path },
});
expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true);
const result = await client.callTool({
name: "inspect_keyed_archive",
arguments: {},
+22 -2
View File
@@ -1,12 +1,15 @@
import { silentLogger } from "../../../src/logger.js";
import { copyFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect } from "vitest";
import { z } from "zod";
import { createBinarySession } from "../../../src/composition/binary.js";
import { parseConfig } from "../../../src/config/parseConfig.js";
import { createServer } from "../../../src/server/createServer.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { mcpTest } from "../../support/mcp/mcpFixture.js";
import { openProductionTarget } from "../../support/mcp/productionSession.js";
const demangledSchema = z.object({
normalized_result: z.object({
@@ -27,7 +30,24 @@ describe.skipIf(process.platform !== "darwin")(
"tool",
);
await copyFile("/usr/bin/true", target);
const client = await openProductionTarget(mcp, onTestFinished, target);
const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" });
if (!configured.ok) throw configured.error;
const session = createBinarySession(
configured.value,
silentLogger,
process.env,
);
onTestFinished(async () => {
await session.close();
});
const client = await mcp.connect(
createServer({ kind: "session", session }),
);
const opened = await client.callTool({
name: "open_binary",
arguments: { path: target },
});
expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true);
const called = await client.callTool({
name: "demangle_swift",
@@ -1,9 +1,12 @@
import { silentLogger } from "../../../src/logger.js";
import { copyFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect } from "vitest";
import { z } from "zod";
import { createBinarySession } from "../../../src/composition/binary.js";
import { parseConfig } from "../../../src/config/parseConfig.js";
import { inspectMachoSchema } from "../../../src/domain/native/nativeInspection.js";
import { createServer } from "../../../src/server/createServer.js";
import { thinMach } from "../../../src/domain/binaryTarget.fixture.js";
@@ -13,7 +16,6 @@ import { ok } from "../../../src/domain/result.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { mcpTest } from "../../support/mcp/mcpFixture.js";
import { openProductionTarget } from "../../support/mcp/productionSession.js";
const evidenceOf = <Schema extends z.ZodType>(schema: Schema) =>
z.object({
@@ -52,7 +54,24 @@ describe.skipIf(process.platform !== "darwin")(
"ls",
);
await copyFile("/bin/ls", target);
const client = await openProductionTarget(mcp, onTestFinished, target);
const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" });
if (!configured.ok) throw configured.error;
const session = createBinarySession(
configured.value,
silentLogger,
process.env,
);
onTestFinished(async () => {
await session.close();
});
const client = await mcp.connect(
createServer({ kind: "session", session }),
);
const opened = await client.callTool({
name: "open_binary",
arguments: { path: target },
});
expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true);
const complete = await client.callTool({
name: "inspect_macho",
@@ -0,0 +1,136 @@
import type { Client } from "@modelcontextprotocol/client";
import { expect, it } from "vitest";
import { verifyGhidraAddressBoundaries } from "../../../../scripts/lib/real-ghidra-address-boundaries.mjs";
const entry = "0x10000";
const originalName = "fixture::entry";
const fixture = (
options: {
failAfterWrite?: Error;
restoration?: "reject" | "wrong-readback";
initialName?: null;
} = {},
) => {
let name: string | null = options.initialName === null ? null : originalName;
const writes: Record<string, unknown>[] = [];
const success = (value: unknown) => ({
content: [],
structuredContent: { normalized_result: value },
});
const error = (operation: string, address: unknown) => ({
code: "invalid_request",
category: "invalid_input",
message: "Address cannot be represented without truncation",
retryable: false,
remediation: { action: "Select a representable address" },
details: {
operation,
issues: [
{ address, message: "cannot be represented without truncation" },
],
},
});
const client: Pick<Client, "callTool"> = {
callTool: async ({ name: operation, arguments: args = {} }) => {
const address = args.address ?? args.procedure;
if (typeof address === "string" && address.includes("100010000"))
return {
isError: true,
content: [
{
type: "text",
text: JSON.stringify({ error: error(operation, address) }),
},
],
};
switch (operation) {
case "analyze_function":
return success({ procedure: { name } });
case "read_bytes":
return success({ bytes_hex: "90" });
case "address_name":
return success(name);
case "set_addresses_names": {
const names = args.names as Record<string, string>;
writes.push(names);
const requested = Object.values(names);
const accepted = new Set(requested).size === 1;
if (accepted) {
name = requested[0]!;
if (options.failAfterWrite !== undefined)
throw options.failAfterWrite;
}
return success(
Object.fromEntries(
Object.keys(names).map((key) => [key, accepted]),
),
);
}
case "set_address_name":
writes.push(args);
if (options.restoration === "reject") return success(false);
name =
options.restoration === "wrong-readback"
? "unexpected_name"
: String(args.name);
return success(true);
default:
throw new Error(`Unmodeled operation: ${operation}`);
}
},
};
const cli = async (command: string, address: string) => {
const operation =
command === "read-bytes" ? "read_bytes" : "annotate_native_function";
throw Object.assign(new Error("Rejected oversized address"), {
code: 1,
stdout: JSON.stringify(error(operation, address)),
});
};
return {
run: () => verifyGhidraAddressBoundaries(client, entry, cli),
observedName: () => name,
writes,
};
};
it("restores the exact observed name before the next verifier consumer", async () => {
const test = fixture();
await test.run();
expect(test.observedName()).toBe(originalName);
});
it("restores an uncertain write and preserves its original failure", async () => {
const failure = new Error("Reply lost after applying the alias");
const test = fixture({ failAfterWrite: failure });
await expect(test.run()).rejects.toBe(failure);
expect(test.observedName()).toBe(originalName);
});
it.each(["reject", "wrong-readback"] as const)(
"fails closed when restoration has %s",
async (restoration) => {
await expect(fixture({ restoration }).run()).rejects.toMatchObject({
name: "AggregateError",
errors: [expect.any(Error)],
});
},
);
it("retains both verification and restoration failures", async () => {
const failure = new Error("Reply lost after applying the alias");
await expect(
fixture({ failAfterWrite: failure, restoration: "reject" }).run(),
).rejects.toMatchObject({
name: "AggregateError",
errors: [failure, expect.any(Error)],
});
});
it("does not mutate an entry without a restorable public name", async () => {
const test = fixture({ initialName: null });
await expect(test.run()).rejects.toThrow("Entry has no restorable name");
expect(test.writes).toEqual([]);
});
-33
View File
@@ -1,33 +0,0 @@
import type { Client } from "@modelcontextprotocol/client";
import { expect, type OnTestFinishedHandler } from "vitest";
import { createBinarySession } from "../../../src/composition/binary.js";
import { parseConfig } from "../../../src/config/parseConfig.js";
import { silentLogger } from "../../../src/logger.js";
import { createServer } from "../../../src/server/createServer.js";
import type { TestMcp } from "./mcpFixture.js";
/** Open one real target in a production session served over MCP. */
export const openProductionTarget = async (
mcp: TestMcp,
onTestFinished: (handler: OnTestFinishedHandler) => void,
path: string,
): Promise<Client> => {
const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" });
if (!configured.ok) throw configured.error;
const session = createBinarySession(
configured.value,
silentLogger,
process.env,
);
onTestFinished(async () => {
await session.close();
});
const client = await mcp.connect(createServer({ kind: "session", session }));
const opened = await client.callTool({
name: "open_binary",
arguments: { path },
});
expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true);
return client;
};