Merge pull request #109 from morluto/codex/hopper-analysis-snapshots

feat(analysis): persist snapshots and close Hopper reliably
This commit is contained in:
morluto 2026-07-14 04:23:27 +08:00 committed by GitHub
commit c6407ef3d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 1794 additions and 350 deletions

View File

@ -193,13 +193,14 @@ Uninstall preserves Hopper, Node.js, evidence, captures, external evidence roots
### CLI or agent?
| If you want to… | Use |
| --------------------------------------------------------- | -------------------------------------------------------------- |
| Ask an agent to investigate an app and build a feature | Install the skill, then talk to your agent |
| Inspect or decompile one part of an app from the Terminal | `rea analyze` or `rea decompile` |
| Validate, canonicalize, or compare Evidence v2 bundles | `rea evidence-import`, `rea evidence-export`, or `rea compare` |
| Import source as historical reference | `rea import-reference-source` |
| Capture or compare controlled process behavior | `rea capture-process` or `rea compare-process-captures` |
| If you want to… | Use |
| --------------------------------------------------------------- | ------------------------------------------------------------------------- |
| Ask an agent to investigate an app and build a feature | Install the skill, then talk to your agent |
| Inspect or decompile one part of an app from the Terminal | `rea analyze` or `rea decompile` |
| Validate, canonicalize, or compare Evidence v2 bundles | `rea evidence-import`, `rea evidence-export`, or `rea compare` |
| Reuse immutable analysis results without relaunching a provider | Pass `--snapshot /approved/path/analysis.json` to a deep-analysis command |
| Import source as historical reference | `rea import-reference-source` |
| Capture or compare controlled process behavior | `rea capture-process` or `rea compare-process-captures` |
Filesystem evidence commands and MCP file tools are disabled until the operator approves absolute roots:
@ -219,6 +220,30 @@ rea import-reference-source /absolute/path/to/source
Exports never replace an existing file unless `--overwrite` is explicit. Imports are size/depth bounded, validate every Evidence v2 ID and manifest, and never execute bundle content.
Provider-neutral analysis snapshots persist successful, immutable REA calls and
their Evidence v2 records. They are exact caches rather than Hopper databases:
REA reuses an entry only when the binary digest, format, architecture, operation
parameters, loader arguments, and provider identity match. Custom Hopper loader
overrides disable snapshots because their provider configuration cannot be
replayed safely. Cursor-dependent and mutating calls are never cached. Snapshot
files can contain proprietary analysis results and local
paths, so REA keeps them local, writes them with owner-only permissions, and
requires a separate approved root:
```bash
export REA_ANALYSIS_SNAPSHOT_ROOTS_JSON='["/absolute/path/to/analysis"]'
rea analyze /absolute/path/to/app --snapshot /absolute/path/to/analysis/app.json
# The same exact query can now be answered from the snapshot.
rea analyze /absolute/path/to/app --snapshot /absolute/path/to/analysis/app.json
```
Exact CLI evidence replays happen before any provider starts. In MCP sessions,
pass `snapshot_path` to `open_binary` to import a snapshot atomically while
opening its matching target; MCP providers may still start before a cached call
is replayed. Pass `snapshot_path` and, when required, `overwrite: true` to
`close_binary` to save atomically before Hopper resources are released. If the
save fails, REA deliberately leaves the session open.
## One prompt, a full investigation
```text

View File

@ -18,6 +18,19 @@ _selected_document = None
_search_inventory_cache = {}
MAX_SEARCH_PATTERN_LENGTH = 256
MAX_SEARCH_VALUE_LENGTH = 4096
def _session_document():
"""Find only the document opened for this authenticated REA session."""
target = os.path.realpath(REA_TARGET_PATH)
for document in Document.getAllDocuments():
paths = (document.getExecutableFilePath(), document.getDatabaseFilePath())
for path in paths:
if path and os.path.realpath(path) == target:
return document
return None
MAX_REGEX_BACKTRACKING_PATHS = 10000
MAX_REGEX_CANDIDATE_LENGTH = 4096
MAX_REGEX_SEARCH_WORK_UNITS = 1000000
@ -616,7 +629,21 @@ def _dispatch(method, params):
if method == "health":
return {"name": "REA Hopper bridge", "version": "1.0.0", "run_id": REA_RUN_ID}
if method == "shutdown":
return {"shutdown": True}
document = _session_document()
if document is None:
return {"shutdown": True, "analysis_stopped": True, "document_closed": True}
if document.backgroundProcessActive():
document.requestBackgroundProcessStop()
if document.backgroundProcessActive():
document.waitForBackgroundProcessToEnd()
analysis_stopped = not document.backgroundProcessActive()
document.closeDocument()
document_closed = _session_document() is None
return {
"shutdown": True,
"analysis_stopped": analysis_stopped,
"document_closed": document_closed,
}
if method == "list_documents":
return [document.getDocumentName() for document in Document.getAllDocuments()]
if method == "current_document":
@ -726,9 +753,9 @@ def _dispatch(method, params):
if method == "procedure_pseudo_code":
return procedure.decompile()
if method == "procedure_callers":
return [_procedure_name(item) for item in procedure.getAllCallerProcedures()]
return sorted((_hex(item.getEntryPoint()) for item in procedure.getAllCallerProcedures()), key=lambda value: int(value, 16))
if method == "procedure_callees":
return [_procedure_name(item) for item in procedure.getAllCalleeProcedures()]
return sorted((_hex(item.getEntryPoint()) for item in procedure.getAllCalleeProcedures()), key=lambda value: int(value, 16))
if method == "procedure_info":
blocks = list(procedure.basicBlockIterator())
length = sum(max(0, block.getEndingAddress() - block.getStartingAddress()) for block in blocks)

View File

@ -0,0 +1,212 @@
import type { BinaryTarget } from "../domain/binaryTarget.js";
import type { JsonValue } from "../domain/jsonValue.js";
import type { EvidenceBundle } from "../domain/evidenceBundle.js";
import {
EvidenceIntegrityError,
NoBinaryOpenError,
type AnalysisError,
type EvidenceLimitError,
} from "../domain/errors.js";
import { err, ok, type Result } from "../domain/result.js";
import {
analysisQueryId,
createAnalysisSnapshotEntry,
snapshotMatchesTarget,
snapshotTarget,
type AnalysisSnapshot,
type AnalysisSnapshotEntry,
type AnalysisSnapshotTarget,
} from "../domain/analysisSnapshot.js";
import type {
AnalysisExecution,
AnalysisOperation,
CapabilityDescriptor,
ProviderIdentity,
} from "./AnalysisProvider.js";
import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/toolContracts.js";
const STATEFUL_OPERATIONS: ReadonlySet<AnalysisOperation> = new Set([
"health",
"current_address",
"current_procedure",
"current_document",
"goto_address",
"list_documents",
"set_current_document",
]);
const CURSOR_DEFAULT_OPERATIONS: ReadonlySet<AnalysisOperation> = new Set([
"address_name",
"comment",
"inline_comment",
"next_address",
"prev_address",
"xrefs",
]);
const DOCUMENT_SCOPED_OPERATIONS: ReadonlySet<AnalysisOperation> = new Set(
OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name).filter(
(name) =>
name !== "current_document" &&
name !== "list_documents" &&
name !== "set_current_document",
),
);
/** Whether an operation is immutable and independent of provider UI state. */
export const isSnapshotCacheable = (
operation: AnalysisOperation,
descriptor: CapabilityDescriptor | undefined,
parameters: Readonly<Record<string, JsonValue>>,
): descriptor is CapabilityDescriptor =>
!STATEFUL_OPERATIONS.has(operation) &&
(!DOCUMENT_SCOPED_OPERATIONS.has(operation) ||
typeof parameters.document === "string") &&
(!CURSOR_DEFAULT_OPERATIONS.has(operation) ||
typeof parameters.address === "string") &&
descriptor?.effects.mutatesArtifact === false &&
descriptor.effects.mayWriteFilesystem === false &&
descriptor.effects.changesPermissions === false;
/** Bounded in-memory cache for one immutable binary identity. */
export class AnalysisSnapshotCache {
readonly #entries = new Map<string, AnalysisSnapshotEntry>();
#target: AnalysisSnapshotTarget | undefined;
/** Whether staged entries belong to the supplied binary. */
matches(target: BinaryTarget): boolean {
return (
this.#target === undefined || snapshotMatchesTarget(this.#target, target)
);
}
/** Replace cache state when the active binary identity changes. */
select(target: BinaryTarget): void {
if (!this.matches(target)) this.#entries.clear();
this.#target = snapshotTarget(target);
}
/** Merge already-validated entries and return the new-entry count. */
stage(snapshot: AnalysisSnapshot): number {
if (
this.#target !== undefined &&
(this.#target.sha256 !== snapshot.target.sha256 ||
this.#target.format !== snapshot.target.format ||
this.#target.architecture !== snapshot.target.architecture)
)
this.#entries.clear();
this.#target = snapshot.target;
let imported = 0;
for (const entry of snapshot.entries) {
if (!this.#entries.has(entry.query_id)) imported += 1;
this.#entries.set(entry.query_id, entry);
}
return imported;
}
/** Build a complete snapshot for an active target. */
export(
target: BinaryTarget | undefined,
evidenceBundle: EvidenceBundle,
): Result<AnalysisSnapshot, NoBinaryOpenError> {
if (target === undefined) return err(new NoBinaryOpenError());
return ok({
snapshot_version: 1,
target: snapshotTarget(target),
entries: this.entries(),
evidence_bundle: evidenceBundle,
});
}
/** Validate target identity, merge evidence atomically, then stage entries. */
import(
snapshot: AnalysisSnapshot,
active: BinaryTarget | undefined,
mergeEvidence: (
bundle: EvidenceBundle,
) => Result<number, EvidenceIntegrityError | EvidenceLimitError>,
): Result<number, AnalysisError> {
if (active !== undefined && !snapshotMatchesTarget(snapshot.target, active))
return err(
new EvidenceIntegrityError(
"Analysis snapshot target does not match the active binary",
),
);
const importedEvidence = mergeEvidence(snapshot.evidence_bundle);
return importedEvidence.ok ? ok(this.stage(snapshot)) : importedEvidence;
}
/** Return canonical entries for persistence. */
entries(): AnalysisSnapshotEntry[] {
return [...this.#entries.values()].sort((left, right) =>
left.query_id.localeCompare(right.query_id),
);
}
/** Replay an exact provider-specific query, marking its cached provenance. */
lookup(
target: BinaryTarget,
operation: AnalysisOperation,
parameters: Readonly<Record<string, JsonValue>>,
provider: ProviderIdentity,
): AnalysisExecution | undefined {
const queryId = analysisQueryId(
snapshotTarget(target),
operation,
parameters,
provider,
);
const cached = this.#entries.get(queryId);
if (cached === undefined) return undefined;
const subject = cached.execution.subject;
return {
result: cached.execution.result,
rawResult: cached.execution.raw_result,
provider: cached.execution.provider,
limitations: [
...cached.execution.limitations,
"Loaded from a local REA analysis snapshot; this call did not re-run the provider.",
],
locations: cached.execution.locations,
subject:
subject === null
? null
: subject.architecture === null
? {
path: subject.path,
sha256: subject.sha256,
format: subject.format,
}
: {
path: subject.path,
sha256: subject.sha256,
format: subject.format,
architecture: subject.architecture,
},
};
}
/** Record one successful immutable call unless the cache is full. */
record(
target: BinaryTarget,
operation: AnalysisOperation,
parameters: Readonly<Record<string, JsonValue>>,
execution: AnalysisExecution,
): void {
if (this.#entries.size >= 10_000) return;
this.select(target);
const entry = createAnalysisSnapshotEntry(
snapshotTarget(target),
operation,
parameters,
execution,
);
this.#entries.set(entry.query_id, entry);
}
/** Forget all target-bound entries. */
clear(): void {
this.#entries.clear();
this.#target = undefined;
}
}

View File

@ -0,0 +1,51 @@
import {
parseAnalysisSnapshot,
serializeAnalysisSnapshot,
type AnalysisSnapshot,
} from "../domain/analysisSnapshot.js";
import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js";
import { EvidenceFileError, EvidenceIntegrityError } from "../domain/errors.js";
import { err, ok, type Result } from "../domain/result.js";
import { readBoundedJson, writeBoundedText } from "./BoundedJsonFiles.js";
type SnapshotFailure = EvidenceFileError | EvidenceIntegrityError;
/** Read and validate a bounded analysis snapshot inside an approved root. */
export const readAnalysisSnapshot = async (
path: string,
policy: EvidenceFilePolicy,
): Promise<Result<AnalysisSnapshot, SnapshotFailure>> => {
const loaded = await readBoundedJson(path, policy);
if (!loaded.ok) return loaded;
try {
return ok(parseAnalysisSnapshot(loaded.value));
} catch (cause: unknown) {
return err(
new EvidenceIntegrityError("Analysis snapshot validation failed", {
cause,
}),
);
}
};
/** Atomically write a deterministic analysis snapshot inside an approved root. */
export const writeAnalysisSnapshot = async (
snapshot: AnalysisSnapshot,
path: string,
overwrite: boolean,
policy: EvidenceFilePolicy,
): Promise<
Result<{ readonly path: string; readonly bytes: number }, SnapshotFailure>
> => {
let encoded: string;
try {
encoded = serializeAnalysisSnapshot(snapshot);
} catch (cause: unknown) {
return err(
new EvidenceIntegrityError("Analysis snapshot validation failed", {
cause,
}),
);
}
return writeBoundedText(encoded, path, overwrite, policy);
};

View File

@ -13,6 +13,7 @@ import type { JsonValue } from "../domain/jsonValue.js";
import type { Evidence } from "../domain/evidence.js";
import { createEvidence } from "../domain/evidence.js";
import type { EvidenceBundle } from "../domain/evidenceBundle.js";
import { evidenceBundleForTarget } from "../domain/evidenceBundle.js";
import type {
AnalysisClient,
AnalysisExecution,
@ -33,63 +34,23 @@ import { UnknownRegistryError } from "../domain/errors.js";
import type { AnalysisOperation } from "./AnalysisProvider.js";
import { enhancedToolNameSchema } from "../contracts/enhancedInputs.js";
import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/toolContracts.js";
const REGISTRY_PROVIDER: ProviderIdentity = {
id: "rea-unknown-registry",
name: "REA residual unknown registry",
version: "1",
};
import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js";
import { snapshotMatchesTarget } from "../domain/analysisSnapshot.js";
import {
AnalysisSnapshotCache,
isSnapshotCacheable,
} from "./AnalysisSnapshotCache.js";
import {
UNKNOWN_REGISTRY_PROVIDER,
unknownEvidenceLinks,
unknownMutationEvidence,
} from "./UnknownEvidence.js";
import type { BinarySessionPort } from "./BinarySessionPort.js";
export type { BinarySessionPort } from "./BinarySessionPort.js";
const OFFICIAL_OPERATIONS: ReadonlySet<string> = new Set(
OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name),
);
/** Target lifecycle used by CLI and MCP without exposing a concrete provider. */
export interface BinarySessionPort extends AnalysisOperationPort {
open(
path: string,
options?: {
readonly signal?: AbortSignal;
readonly targetKind?: BinaryTarget["kind"];
},
): Promise<Result<BinaryTarget, AnalysisError>>;
close(): Promise<Result<null, AnalysisError>>;
status(): JsonValue;
activeTarget(): BinaryTarget | undefined;
recordEvidence(
evidence: Evidence,
): Result<"added" | "duplicate", EvidenceIntegrityError | EvidenceLimitError>;
hasEvidence(evidenceId: string): boolean;
evidenceById(evidenceId: string): Evidence | undefined;
exportEvidenceBundle(): EvidenceBundle;
importEvidenceBundle(
bundle: unknown,
): Result<number, EvidenceIntegrityError | EvidenceLimitError>;
recordUnknown(
input: RecordUnknownInput,
): Result<ResidualUnknown, AnalysisError>;
recordEvidenceWithUnknown(
evidence: Evidence,
input: RecordUnknownInput,
): Result<ResidualUnknown | null, AnalysisError>;
updateUnknown(
input: UpdateUnknownInput,
): Result<ResidualUnknown, AnalysisError>;
listUnknowns(filters?: {
readonly status?: UnknownStatus;
readonly severity?: ResidualUnknown["severity"];
readonly domain?: string;
}): ResidualUnknown[];
verifyUnknownResolution(unknownId: string): Result<
{
readonly valid: boolean;
readonly truthVerified: boolean;
readonly unknown: ResidualUnknown;
},
UnknownRegistryError
>;
providerIdentity(operation?: AnalysisOperation): ProviderIdentity;
}
/**
* Owns the single active target shared by CLI and MCP adapters.
*
@ -111,8 +72,15 @@ export class BinarySession implements BinarySessionPort {
maxRecords: 10_000,
maxBytes: 64 * 1024 * 1024,
});
readonly #snapshot = new AnalysisSnapshotCache();
readonly #snapshotsEnabled: boolean;
#snapshotInvalidated = false;
constructor(readonly provider: AnalysisProvider | AnalysisClientFactory) {
constructor(
readonly provider: AnalysisProvider | AnalysisClientFactory,
options: { readonly snapshotsEnabled?: boolean } = {},
) {
this.#snapshotsEnabled = options.snapshotsEnabled ?? true;
this.#createClient =
typeof provider === "function"
? provider
@ -185,6 +153,44 @@ export class BinarySession implements BinarySessionPort {
return this.#evidence.import(bundle);
}
/** Export immutable cached calls and session evidence for the active target. */
exportAnalysisSnapshot(): Result<AnalysisSnapshot, AnalysisError> {
if (!this.#snapshotsEnabled)
return err(
new EvidenceIntegrityError(
"Analysis snapshots are unavailable with custom Hopper loader arguments",
),
);
if (this.#snapshotInvalidated)
return err(
new EvidenceIntegrityError(
"Analysis snapshots are unavailable after analysis metadata mutations",
),
);
const target = this.#active?.target;
return this.#snapshot.export(
target,
target === undefined
? this.#evidence.export()
: evidenceBundleForTarget(this.#evidence.export(), target.sha256),
);
}
/** Stage validated cached calls for an identical binary and merge its evidence. */
importAnalysisSnapshot(
snapshot: AnalysisSnapshot,
): Result<number, AnalysisError> {
if (!this.#snapshotsEnabled)
return err(
new EvidenceIntegrityError(
"Analysis snapshots are unavailable with custom Hopper loader arguments",
),
);
return this.#snapshot.import(snapshot, this.#active?.target, (bundle) =>
this.#evidence.import(bundle),
);
}
/** Create an approved residual unknown and immutable mutation evidence. */
recordUnknown(
input: RecordUnknownInput,
@ -209,21 +215,25 @@ export class BinarySession implements BinarySessionPort {
updateUnknown(
input: UpdateUnknownInput,
): Result<ResidualUnknown, AnalysisError> {
const evidence = createEvidence(this.#active?.target, REGISTRY_PROVIDER, {
predicateType: "rea.residual-unknown-mutation/v1",
operation: "update_unknown",
parameters: {
unknown_id: input.unknown_id,
expected_revision: input.expected_revision,
const evidence = createEvidence(
this.#active?.target,
UNKNOWN_REGISTRY_PROVIDER,
{
predicateType: "rea.residual-unknown-mutation/v1",
operation: "update_unknown",
parameters: {
unknown_id: input.unknown_id,
expected_revision: input.expected_revision,
},
result: { action: "update", status: input.status },
confidence: "derived",
authority: "analyst-inference",
evidenceLinks: unknownEvidenceLinks(input),
limitations: [
"Registry mutation evidence records analyst intent, not proof of the answer.",
],
},
result: { action: "update", status: input.status },
confidence: "derived",
authority: "analyst-inference",
evidenceLinks: unknownEvidenceLinks(input),
limitations: [
"Registry mutation evidence records analyst intent, not proof of the answer.",
],
});
);
return this.#evidence.updateUnknown(input, evidence);
}
@ -259,6 +269,7 @@ export class BinarySession implements BinarySessionPort {
options: {
readonly signal?: AbortSignal;
readonly targetKind?: BinaryTarget["kind"];
readonly snapshot?: AnalysisSnapshot;
} = {},
): Promise<Result<BinaryTarget, AnalysisError>> {
return this.#serialize(async () => {
@ -271,10 +282,30 @@ export class BinarySession implements BinarySessionPort {
options.targetKind,
);
if (!parsed.ok) return parsed;
if (
options.snapshot !== undefined &&
!snapshotMatchesTarget(options.snapshot.target, parsed.value)
)
return err(
new EvidenceIntegrityError(
"Analysis snapshot target does not match the requested binary",
),
);
if (this.#active === undefined && !this.#snapshot.matches(parsed.value))
return err(
new EvidenceIntegrityError(
"Analysis snapshot target does not match the requested binary",
),
);
if (isAborted(options.signal))
return err(new AnalysisCancelledError("open_binary"));
if (this.#active?.target.path === parsed.value.path)
if (this.#active?.target.path === parsed.value.path) {
if (options.snapshot !== undefined) {
const imported = this.importAnalysisSnapshot(options.snapshot);
if (!imported.ok) return imported;
}
return ok(parsed.value);
}
await this.#drainCalls();
const previous = this.#active;
this.#active = undefined;
@ -292,6 +323,17 @@ export class BinarySession implements BinarySessionPort {
return err(new AnalysisCancelledError("open_binary"));
}
this.#active = { target: parsed.value, client };
this.#snapshot.select(parsed.value);
if (options.snapshot !== undefined) {
const imported = this.importAnalysisSnapshot(options.snapshot);
if (!imported.ok) {
this.#active = undefined;
await client.close();
await this.#restore(previous);
return imported;
}
}
this.#snapshotInvalidated = false;
return ok(parsed.value);
});
}
@ -304,6 +346,8 @@ export class BinarySession implements BinarySessionPort {
await this.#drainCalls();
await previous?.client.close();
this.#evidence.clear();
this.#snapshot.clear();
this.#snapshotInvalidated = false;
return ok(null);
});
}
@ -398,10 +442,27 @@ export class BinarySession implements BinarySessionPort {
);
const active = this.#active;
if (active === undefined) return err(new NoBinaryOpenError());
const cacheable = isSnapshotCacheable(name, capability, arguments_);
const cached = cacheable
? this.#snapshot.lookup(
active.target,
name,
arguments_,
capability.provider,
)
: undefined;
if (cached !== undefined) return ok(cached);
const call = active.client.execute(name, arguments_, options);
this.#calls.add(call);
try {
return await call;
const result = await call;
if (result.ok && cacheable)
this.#snapshot.record(active.target, name, arguments_, result.value);
else if (result.ok && capability?.effects.mutatesArtifact === true) {
this.#snapshot.clear();
this.#snapshotInvalidated = true;
}
return result;
} finally {
this.#calls.delete(call);
}
@ -469,41 +530,5 @@ export class BinarySession implements BinarySessionPort {
}
}
const unknownMutationEvidence = (
target: BinaryTarget | undefined,
input: RecordUnknownInput,
): Evidence =>
createEvidence(target, REGISTRY_PROVIDER, {
predicateType: "rea.residual-unknown-mutation/v1",
operation: "record_unknown",
parameters: {
domain: input.domain,
severity: input.severity,
},
result: {
action: "record",
question: input.question,
required_authority: input.required_authority,
required_confidence: input.required_confidence,
},
confidence: "derived",
authority: "analyst-inference",
evidenceLinks: unknownEvidenceLinks(input),
limitations: [
"Registry mutation evidence records analyst intent, not proof of the answer.",
],
});
const unknownEvidenceLinks = (
input: RecordUnknownInput | UpdateUnknownInput,
): string[] =>
[
...input.supporting_evidence_ids,
...input.contradicting_evidence_ids,
...("resolution" in input && input.resolution !== null
? input.resolution.evidence_ids
: []),
].filter((id, index, values) => values.indexOf(id) === index);
const isAborted = (signal: AbortSignal | undefined): boolean =>
signal?.aborted === true;

View File

@ -0,0 +1,75 @@
import type { BinaryTarget } from "../domain/binaryTarget.js";
import type { JsonValue } from "../domain/jsonValue.js";
import type { Evidence } from "../domain/evidence.js";
import type { EvidenceBundle } from "../domain/evidenceBundle.js";
import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js";
import type {
AnalysisError,
EvidenceIntegrityError,
EvidenceLimitError,
UnknownRegistryError,
} from "../domain/errors.js";
import type { Result } from "../domain/result.js";
import type {
RecordUnknownInput,
ResidualUnknown,
UnknownStatus,
UpdateUnknownInput,
} from "../domain/residualUnknown.js";
import type {
AnalysisOperation,
AnalysisOperationPort,
ProviderIdentity,
} from "./AnalysisProvider.js";
/** Target lifecycle used by CLI and MCP without exposing a concrete provider. */
export interface BinarySessionPort extends AnalysisOperationPort {
open(
path: string,
options?: {
readonly signal?: AbortSignal;
readonly targetKind?: BinaryTarget["kind"];
readonly snapshot?: AnalysisSnapshot;
},
): Promise<Result<BinaryTarget, AnalysisError>>;
close(): Promise<Result<null, AnalysisError>>;
status(): JsonValue;
activeTarget(): BinaryTarget | undefined;
recordEvidence(
evidence: Evidence,
): Result<"added" | "duplicate", EvidenceIntegrityError | EvidenceLimitError>;
hasEvidence(evidenceId: string): boolean;
evidenceById(evidenceId: string): Evidence | undefined;
exportEvidenceBundle(): EvidenceBundle;
importEvidenceBundle(
bundle: unknown,
): Result<number, EvidenceIntegrityError | EvidenceLimitError>;
exportAnalysisSnapshot(): Result<AnalysisSnapshot, AnalysisError>;
importAnalysisSnapshot(
snapshot: AnalysisSnapshot,
): Result<number, AnalysisError>;
recordUnknown(
input: RecordUnknownInput,
): Result<ResidualUnknown, AnalysisError>;
recordEvidenceWithUnknown(
evidence: Evidence,
input: RecordUnknownInput,
): Result<ResidualUnknown | null, AnalysisError>;
updateUnknown(
input: UpdateUnknownInput,
): Result<ResidualUnknown, AnalysisError>;
listUnknowns(filters?: {
readonly status?: UnknownStatus;
readonly severity?: ResidualUnknown["severity"];
readonly domain?: string;
}): ResidualUnknown[];
verifyUnknownResolution(unknownId: string): Result<
{
readonly valid: boolean;
readonly truthVerified: boolean;
readonly unknown: ResidualUnknown;
},
UnknownRegistryError
>;
providerIdentity(operation?: AnalysisOperation): ProviderIdentity;
}

View File

@ -0,0 +1,130 @@
import { lstat, readFile, realpath } from "node:fs/promises";
import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
import writeFileAtomic from "write-file-atomic";
import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js";
import { EvidenceFileError } from "../domain/errors.js";
import { err, ok, type Result } from "../domain/result.js";
/** Read bounded JSON data from a regular file beneath an approved root. */
export const readBoundedJson = async (
path: string,
policy: EvidenceFilePolicy,
): Promise<Result<unknown, EvidenceFileError>> => {
if (policy.roots.length === 0)
return err(new EvidenceFileError("read", "disabled"));
try {
const canonicalPath = await realpath(resolve(path));
if (!(await isApproved(canonicalPath, policy.roots)))
return err(new EvidenceFileError("read", "outside-root"));
const stats = await lstat(canonicalPath);
if (!stats.isFile()) return err(new EvidenceFileError("read", "not-file"));
if (stats.size > policy.maxBytes)
return err(new EvidenceFileError("read", "too-large"));
const encoded = await readFile(canonicalPath);
if (encoded.byteLength > policy.maxBytes)
return err(new EvidenceFileError("read", "too-large"));
let decoded: unknown;
try {
decoded = JSON.parse(encoded.toString("utf8"));
} catch (cause: unknown) {
return err(new EvidenceFileError("read", "invalid-json", { cause }));
}
const limits = validateJsonLimits(decoded, policy);
return limits.ok ? ok(decoded) : limits;
} catch (cause: unknown) {
return err(new EvidenceFileError("read", "io", { cause }));
}
};
/** Atomically write bounded text to a regular file beneath an approved root. */
export const writeBoundedText = async (
encoded: string,
path: string,
overwrite: boolean,
policy: EvidenceFilePolicy,
): Promise<
Result<{ readonly path: string; readonly bytes: number }, EvidenceFileError>
> => {
if (policy.roots.length === 0)
return err(new EvidenceFileError("write", "disabled"));
const bytes = Buffer.byteLength(encoded, "utf8");
if (bytes > policy.maxBytes)
return err(new EvidenceFileError("write", "too-large"));
try {
const requestedPath = resolve(path);
const canonicalParent = await realpath(dirname(requestedPath));
const destination = resolve(canonicalParent, basename(requestedPath));
if (!(await isApproved(destination, policy.roots)))
return err(new EvidenceFileError("write", "outside-root"));
const existing = await lstat(destination).catch((cause: unknown) => {
if (isFileNotFound(cause)) return undefined;
throw cause;
});
if (existing !== undefined) {
if (!overwrite) return err(new EvidenceFileError("write", "exists"));
if (!existing.isFile() || existing.isSymbolicLink())
return err(new EvidenceFileError("write", "not-file"));
}
await writeFileAtomic(destination, encoded, {
encoding: "utf8",
mode: 0o600,
fsync: true,
});
return ok({ path: requestedPath, bytes });
} catch (cause: unknown) {
return err(new EvidenceFileError("write", "io", { cause }));
}
};
const isApproved = async (
candidate: string,
roots: readonly string[],
): Promise<boolean> => {
for (const configuredRoot of roots) {
const root = await realpath(resolve(configuredRoot));
const relation = relative(root, candidate);
if (
relation === "" ||
(!relation.startsWith("..") && !isAbsolute(relation))
)
return true;
}
return false;
};
const validateJsonLimits = (
root: unknown,
policy: EvidenceFilePolicy,
): Result<null, EvidenceFileError> => {
const pending: Array<{ readonly value: unknown; readonly depth: number }> = [
{ value: root, depth: 0 },
];
let nodes = 0;
while (pending.length > 0) {
const current = pending.pop();
if (current === undefined) break;
nodes += 1;
if (nodes > policy.maxNodes || current.depth > policy.maxDepth)
return err(new EvidenceFileError("read", "too-large"));
if (
typeof current.value === "string" &&
current.value.length > policy.maxStringLength
)
return err(new EvidenceFileError("read", "too-large"));
if (typeof current.value !== "object" || current.value === null) continue;
for (const [key, value] of Object.entries(current.value)) {
if (key.length > policy.maxStringLength)
return err(new EvidenceFileError("read", "too-large"));
pending.push({ value, depth: current.depth + 1 });
}
}
return ok(null);
};
const isFileNotFound = (cause: unknown): boolean =>
typeof cause === "object" &&
cause !== null &&
"code" in cause &&
cause.code === "ENOENT";

View File

@ -4,9 +4,26 @@ import { EnhancedTools } from "./EnhancedTools.js";
import { createBinarySession } from "./runtime.js";
import { silentLogger, type Logger } from "../logger.js";
import { createEvidence } from "../domain/evidence.js";
import type { Evidence } from "../domain/evidence.js";
import type { NativeToolName } from "../contracts/nativeToolContracts.js";
import type { ArtifactToolName } from "../contracts/artifactToolContracts.js";
import { projectAnalysisError, type AnalysisError } from "../domain/errors.js";
import {
EvidenceIntegrityError,
projectAnalysisError,
type AnalysisError,
} from "../domain/errors.js";
import { access } from "node:fs/promises";
import {
readAnalysisSnapshot,
writeAnalysisSnapshot,
} from "./AnalysisSnapshotFiles.js";
import { parseBinaryTarget } from "../domain/binaryTarget.js";
import {
snapshotEvidenceForQuery,
snapshotMatchesTarget,
} from "../domain/analysisSnapshot.js";
import { err, ok, type Result } from "../domain/result.js";
import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js";
const WORKFLOW_PROVIDER = {
id: "rea-workflow",
@ -32,8 +49,15 @@ export const runDirectAnalysis = async (
path: string,
tool: DirectAnalysisTool,
arguments_: Readonly<Record<string, JsonValue>>,
logger: Logger = silentLogger,
): Promise<JsonValue> => runAnalysis(path, tool, arguments_, logger);
options: {
readonly logger?: Logger;
readonly snapshotPath?: string | undefined;
} = {},
): Promise<JsonValue> =>
runAnalysis(path, tool, arguments_, {
logger: options.logger ?? silentLogger,
snapshotPath: options.snapshotPath,
});
/** Execute one provider-native semantic operation with atomic provenance. */
export const runProviderAnalysis = async (
@ -41,7 +65,8 @@ export const runProviderAnalysis = async (
tool: NativeToolName | ArtifactToolName,
arguments_: Readonly<Record<string, JsonValue>>,
logger: Logger = silentLogger,
): Promise<JsonValue> => runAnalysis(path, tool, arguments_, logger);
): Promise<JsonValue> =>
runAnalysis(path, tool, arguments_, { logger, snapshotPath: undefined });
/** Describe configured providers without opening a target or launching Hopper. */
export const runSessionStatus = async (
@ -61,39 +86,67 @@ const runAnalysis = async (
path: string,
tool: NativeToolName | ArtifactToolName | DirectAnalysisTool,
arguments_: Readonly<Record<string, JsonValue>>,
logger: Logger,
options: {
readonly logger: Logger;
readonly snapshotPath: string | undefined;
},
): Promise<JsonValue> => {
const { logger, snapshotPath } = options;
const config = parseConfig(process.env);
if (!config.ok) return cliError(config.error);
const unavailable = snapshotUnavailable(
snapshotPath,
config.value.hopperLoaderArgs,
);
if (unavailable !== undefined) return cliError(unavailable);
const session = createBinarySession(config.value, logger);
try {
const opened = await session.open(path);
const prepared = await prepareSnapshot({
path,
tool,
arguments: arguments_,
snapshotPath,
policy: config.value.analysisSnapshotFilePolicy,
provider: replayProviderFor(session, tool),
});
if (!prepared.ok) return cliError(prepared.error);
if (prepared.value.evidence !== undefined) return prepared.value.evidence;
const { snapshot } = prepared.value;
const opened = await session.open(
path,
snapshot === undefined ? {} : { snapshot },
);
if (!opened.ok) return cliError(opened.error);
let output: JsonValue;
let evidence: Evidence | undefined;
if (
tool === "binary_overview" ||
tool === "analyze_function" ||
tool === "trace_feature"
) {
const result = await new EnhancedTools(session).execute(tool, arguments_);
return result.ok
? createEvidence(
opened.value,
tool === "analyze_function"
? session.providerIdentity(tool)
: WORKFLOW_PROVIDER,
{
operation: tool,
parameters: arguments_,
result: result.value,
confidence: "derived",
limitations: ["Derived by an REA composed workflow."],
},
)
: cliError(result.error);
}
const result = await session.execute(tool, arguments_);
return result.ok
? createEvidence(
if (!result.ok) output = cliError(result.error);
else {
evidence = createEvidence(
opened.value,
tool === "analyze_function"
? session.providerIdentity(tool)
: WORKFLOW_PROVIDER,
{
operation: tool,
parameters: arguments_,
result: result.value,
confidence: "derived",
limitations: ["Derived by an REA composed workflow."],
},
);
output = evidence;
}
} else {
const result = await session.execute(tool, arguments_);
if (!result.ok) output = cliError(result.error);
else {
evidence = createEvidence(
result.value.subject ?? opened.value,
result.value.provider,
{
@ -104,13 +157,108 @@ const runAnalysis = async (
limitations: result.value.limitations,
locations: result.value.locations,
},
)
: cliError(result.error);
);
output = evidence;
}
}
if (evidence !== undefined) session.recordEvidence(evidence);
if (snapshotPath !== undefined && evidence !== undefined) {
const snapshot = session.exportAnalysisSnapshot();
if (!snapshot.ok) return cliError(snapshot.error);
const written = await writeAnalysisSnapshot(
snapshot.value,
snapshotPath,
true,
config.value.analysisSnapshotFilePolicy,
);
if (!written.ok) return cliError(written.error);
}
return output;
} finally {
await session.close();
}
};
const prepareSnapshot = async (options: {
readonly path: string;
readonly tool: string;
readonly arguments: Readonly<Record<string, JsonValue>>;
readonly snapshotPath: string | undefined;
readonly policy: Parameters<typeof readAnalysisSnapshot>[1];
readonly provider: Parameters<typeof snapshotEvidenceForQuery>[1]["provider"];
}): Promise<
Result<
{ readonly snapshot?: AnalysisSnapshot; readonly evidence?: Evidence },
AnalysisError
>
> => {
const {
path,
tool,
arguments: arguments_,
snapshotPath,
policy,
provider,
} = options;
if (snapshotPath === undefined || !(await fileExists(snapshotPath)))
return ok({});
const loaded = await readAnalysisSnapshot(snapshotPath, policy);
if (!loaded.ok) return loaded;
const target = await parseBinaryTarget(path);
if (!target.ok) return target;
if (!snapshotMatchesTarget(loaded.value.target, target.value))
return err(
new EvidenceIntegrityError(
"Analysis snapshot target does not match the requested binary",
),
);
const evidence = snapshotEvidenceForQuery(loaded.value, {
target: target.value,
operation: tool,
parameters: arguments_,
provider,
});
return ok(
evidence === undefined
? { snapshot: loaded.value }
: { snapshot: loaded.value, evidence },
);
};
const snapshotUnavailable = (
snapshotPath: string | undefined,
loaderArgs: readonly string[],
): EvidenceIntegrityError | undefined =>
snapshotPath !== undefined && loaderArgs.length > 0
? new EvidenceIntegrityError(
"Analysis snapshots are unavailable with custom Hopper loader arguments",
)
: undefined;
const replayProviderFor = (
session: ReturnType<typeof createBinarySession>,
tool: NativeToolName | ArtifactToolName | DirectAnalysisTool,
) =>
tool === "binary_overview" || tool === "trace_feature"
? WORKFLOW_PROVIDER
: session.providerIdentity(tool);
const fileExists = async (path: string): Promise<boolean> => {
try {
await access(path);
return true;
} catch (cause: unknown) {
if (
typeof cause === "object" &&
cause !== null &&
"code" in cause &&
cause.code === "ENOENT"
)
return false;
throw cause;
}
};
const cliError = (error: AnalysisError): JsonValue => ({
error: "Analysis failed",
...projectAnalysisError(error),

View File

@ -1,8 +1,3 @@
import { lstat, readFile, realpath } from "node:fs/promises";
import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
import writeFileAtomic from "write-file-atomic";
import {
parseEvidenceBundle,
serializeEvidenceBundle,
@ -15,6 +10,7 @@ import {
LEGACY_PROCESS_CAPTURE_MESSAGE,
parseProcessCapture,
} from "../domain/processCapture.js";
import { readBoundedJson, writeBoundedText } from "./BoundedJsonFiles.js";
type EvidenceReadFailure = EvidenceFileError | EvidenceIntegrityError;
type EvidenceWriteFailure = EvidenceFileError | EvidenceIntegrityError;
@ -26,43 +22,23 @@ export const readEvidenceBundle = async (
): Promise<Result<EvidenceBundle, EvidenceReadFailure>> => {
if (policy.roots.length === 0)
return err(new EvidenceFileError("read", "disabled"));
const loaded = await readBoundedJson(path, policy);
if (!loaded.ok) return loaded;
try {
const canonicalPath = await realpath(resolve(path));
if (!(await isApproved(canonicalPath, policy.roots)))
return err(new EvidenceFileError("read", "outside-root"));
const stats = await lstat(canonicalPath);
if (!stats.isFile()) return err(new EvidenceFileError("read", "not-file"));
if (stats.size > policy.maxBytes)
return err(new EvidenceFileError("read", "too-large"));
const encoded = await readFile(canonicalPath);
if (encoded.byteLength > policy.maxBytes)
return err(new EvidenceFileError("read", "too-large"));
let decoded: unknown;
try {
decoded = JSON.parse(encoded.toString("utf8"));
} catch (cause: unknown) {
return err(new EvidenceFileError("read", "invalid-json", { cause }));
}
const limits = validateJsonLimits(decoded, policy);
if (!limits.ok) return limits;
try {
const bundle = parseEvidenceBundle(decoded);
for (const record of bundle.records) {
if (record.predicate_type === "rea.process-capture/v3")
throw new TypeError(LEGACY_PROCESS_CAPTURE_MESSAGE);
if (record.predicate_type === "rea.process-capture/v4")
parseProcessCapture(record.normalized_result);
}
return ok(bundle);
} catch (cause: unknown) {
return err(
new EvidenceIntegrityError("Evidence bundle validation failed", {
cause,
}),
);
const bundle = parseEvidenceBundle(loaded.value);
for (const record of bundle.records) {
if (record.predicate_type === "rea.process-capture/v3")
throw new TypeError(LEGACY_PROCESS_CAPTURE_MESSAGE);
if (record.predicate_type === "rea.process-capture/v4")
parseProcessCapture(record.normalized_result);
}
return ok(bundle);
} catch (cause: unknown) {
return err(new EvidenceFileError("read", "io", { cause }));
return err(
new EvidenceIntegrityError("Evidence bundle validation failed", {
cause,
}),
);
}
};
@ -90,82 +66,5 @@ export const writeEvidenceBundle = async (
}),
);
}
const bytes = Buffer.byteLength(encoded, "utf8");
if (bytes > policy.maxBytes)
return err(new EvidenceFileError("write", "too-large"));
try {
const requestedPath = resolve(path);
const canonicalParent = await realpath(dirname(requestedPath));
const destination = resolve(canonicalParent, basename(requestedPath));
if (!(await isApproved(destination, policy.roots)))
return err(new EvidenceFileError("write", "outside-root"));
const existing = await lstat(destination).catch((cause: unknown) => {
if (isFileNotFound(cause)) return undefined;
throw cause;
});
if (existing !== undefined) {
if (!overwrite) return err(new EvidenceFileError("write", "exists"));
if (!existing.isFile() || existing.isSymbolicLink())
return err(new EvidenceFileError("write", "not-file"));
}
await writeFileAtomic(destination, encoded, {
encoding: "utf8",
mode: 0o600,
fsync: true,
});
return ok({ path: requestedPath, bytes });
} catch (cause: unknown) {
return err(new EvidenceFileError("write", "io", { cause }));
}
return writeBoundedText(encoded, path, overwrite, policy);
};
const isApproved = async (
candidate: string,
roots: readonly string[],
): Promise<boolean> => {
for (const configuredRoot of roots) {
const root = await realpath(resolve(configuredRoot));
const relation = relative(root, candidate);
if (
relation === "" ||
(!relation.startsWith("..") && !isAbsolute(relation))
)
return true;
}
return false;
};
const validateJsonLimits = (
root: unknown,
policy: EvidenceFilePolicy,
): Result<null, EvidenceFileError> => {
const pending: Array<{ readonly value: unknown; readonly depth: number }> = [
{ value: root, depth: 0 },
];
let nodes = 0;
while (pending.length > 0) {
const current = pending.pop();
if (current === undefined) break;
nodes += 1;
if (nodes > policy.maxNodes || current.depth > policy.maxDepth)
return err(new EvidenceFileError("read", "too-large"));
if (
typeof current.value === "string" &&
current.value.length > policy.maxStringLength
)
return err(new EvidenceFileError("read", "too-large"));
if (typeof current.value !== "object" || current.value === null) continue;
for (const [key, value] of Object.entries(current.value)) {
if (key.length > policy.maxStringLength)
return err(new EvidenceFileError("read", "too-large"));
pending.push({ value, depth: current.depth + 1 });
}
}
return ok(null);
};
const isFileNotFound = (cause: unknown): boolean =>
typeof cause === "object" &&
cause !== null &&
"code" in cause &&
cause.code === "ENOENT";

View File

@ -0,0 +1,49 @@
import type { BinaryTarget } from "../domain/binaryTarget.js";
import { createEvidence, type Evidence } from "../domain/evidence.js";
import type {
RecordUnknownInput,
UpdateUnknownInput,
} from "../domain/residualUnknown.js";
import type { ProviderIdentity } from "./AnalysisProvider.js";
/** Provider identity for analyst-authored residual-unknown mutations. */
export const UNKNOWN_REGISTRY_PROVIDER: ProviderIdentity = {
id: "rea-unknown-registry",
name: "REA residual unknown registry",
version: "1",
};
/** Create immutable evidence for one approved residual-unknown mutation. */
export const unknownMutationEvidence = (
target: BinaryTarget | undefined,
input: RecordUnknownInput,
): Evidence =>
createEvidence(target, UNKNOWN_REGISTRY_PROVIDER, {
predicateType: "rea.residual-unknown-mutation/v1",
operation: "record_unknown",
parameters: { domain: input.domain, severity: input.severity },
result: {
action: "record",
question: input.question,
required_authority: input.required_authority,
required_confidence: input.required_confidence,
},
confidence: "derived",
authority: "analyst-inference",
evidenceLinks: unknownEvidenceLinks(input),
limitations: [
"Registry mutation evidence records analyst intent, not proof of the answer.",
],
});
/** Deduplicate evidence links carried by an unknown mutation. */
export const unknownEvidenceLinks = (
input: RecordUnknownInput | UpdateUnknownInput,
): string[] =>
[
...input.supporting_evidence_ids,
...input.contradicting_evidence_ids,
...("resolution" in input && input.resolution !== null
? input.resolution.evidence_ids
: []),
].filter((id, index, values) => values.indexOf(id) === index);

View File

@ -21,5 +21,6 @@ export const createBinarySession = (
new NativeMacOSProvider(),
new HopperProvider(config, logger),
]),
{ snapshotsEnabled: config.hopperLoaderArgs.length === 0 },
);
};

View File

@ -74,6 +74,11 @@ const registerCoreCommands = (
const overviewOptions = z.object({
detail: z.enum(["concise", "detailed"]).default("concise"),
limit: z.number().int().min(1).max(50).default(10),
snapshot: z
.string()
.min(1)
.optional()
.describe("Load and update a local analysis snapshot"),
});
cli.command("analyze", {
description: "Get an overview of an app",
@ -87,7 +92,7 @@ const registerCoreCommands = (
args.path,
"binary_overview",
{ detail: options.detail, limit: options.limit },
logger,
{ logger, snapshotPath: options.snapshot },
),
),
});
@ -103,7 +108,7 @@ const registerCoreCommands = (
args.path,
"binary_overview",
{ detail: options.detail, limit: options.limit },
logger,
{ logger, snapshotPath: options.snapshot },
),
),
});
@ -113,13 +118,14 @@ const registerCoreCommands = (
path: z.string().describe("App or program path"),
address: z.string().describe("Procedure address"),
}),
run: ({ args }) =>
options: z.object({ snapshot: z.string().min(1).optional() }),
run: ({ args, options }) =>
logCliCommand(logger, "decompile", () =>
runDirectAnalysis(
args.path,
"procedure_pseudo_code",
{ procedure: args.address },
logger,
{ logger, snapshotPath: options.snapshot },
),
),
});
@ -222,13 +228,14 @@ const registerXrefsCommand = (
path: z.string().describe("App or program path"),
address: z.string().describe("Hexadecimal address"),
}),
run: ({ args }) =>
options: z.object({ snapshot: z.string().min(1).optional() }),
run: ({ args, options }) =>
logCliCommand(logger, "xrefs", () =>
runDirectAnalysis(
args.path,
"xrefs",
{ address: args.address },
logger,
{ logger, snapshotPath: options.snapshot },
),
),
});
@ -248,6 +255,7 @@ const registerTraceCommand = (
caseSensitive: z.boolean().default(false),
limit: z.number().int().min(1).max(100).default(20),
maxOperations: z.number().int().min(1).max(100).default(20),
snapshot: z.string().min(1).optional(),
}),
alias: {
caseSensitive: "case-sensitive",
@ -264,7 +272,7 @@ const registerTraceCommand = (
limit: options.limit,
max_operations: options.maxOperations,
},
logger,
{ logger, snapshotPath: options.snapshot },
),
),
});
@ -300,6 +308,7 @@ const registerFunctionCommand = (
limit: z.number().int().min(1).max(500).default(100),
maxPseudocodeChars: z.number().int().min(1).max(100_000).default(20_000),
maxInstructions: z.number().int().min(1).max(5_000).default(500),
snapshot: z.string().min(1).optional(),
}),
alias: {
includeAssembly: "include-assembly",
@ -318,7 +327,7 @@ const registerFunctionCommand = (
max_pseudocode_chars: options.maxPseudocodeChars,
max_instructions: options.maxInstructions,
},
logger,
{ logger, snapshotPath: options.snapshot },
),
),
});
@ -340,6 +349,7 @@ const registerSearchCommand = (
caseSensitive: z.boolean().default(false),
offset: z.number().int().min(0).default(0),
limit: z.number().int().min(1).max(100).default(100),
snapshot: z.string().min(1).optional(),
}),
alias: { caseSensitive: "case-sensitive" },
run: ({ args, options }) =>
@ -354,7 +364,7 @@ const registerSearchCommand = (
offset: options.offset,
limit: options.limit,
},
logger,
{ logger, snapshotPath: options.snapshot },
),
),
});

View File

@ -23,6 +23,7 @@ export interface AppConfig {
readonly processExecutionPolicy: ProcessExecutionPolicy;
readonly artifactNativeMountEnabled: boolean;
readonly evidenceFilePolicy: EvidenceFilePolicy;
readonly analysisSnapshotFilePolicy: EvidenceFilePolicy;
readonly referenceSourcePolicy: ReferenceSourcePolicy;
}
@ -43,6 +44,7 @@ const environmentSchema = z.object({
REA_PROCESS_WORKING_ROOTS_JSON: z.string().default("[]"),
REA_PROCESS_ALLOWED_ENV_JSON: z.string().default("[]"),
REA_EVIDENCE_ROOTS_JSON: z.string().default("[]"),
REA_ANALYSIS_SNAPSHOT_ROOTS_JSON: z.string().default("[]"),
REA_REFERENCE_ROOTS_JSON: z.string().default("[]"),
REA_REFERENCE_SECRET_PATTERNS_JSON: z.string().default("[]"),
});
@ -68,6 +70,14 @@ const parseStringArray = (
}
};
const filePolicy = (roots: readonly string[]): EvidenceFilePolicy => ({
roots,
maxBytes: 64 * 1024 * 1024,
maxDepth: 64,
maxStringLength: 1024 * 1024,
maxNodes: 1_000_000,
});
/**
* Parse Hopper launcher configuration once at the composition root.
* Explicit loader arguments override REA's header-derived defaults for a
@ -127,6 +137,11 @@ export const parseConfig = (
"REA_EVIDENCE_ROOTS_JSON",
);
if (!evidenceRoots.ok) return evidenceRoots;
const analysisSnapshotRoots = parseStringArray(
parsedEnvironment.data.REA_ANALYSIS_SNAPSHOT_ROOTS_JSON,
"REA_ANALYSIS_SNAPSHOT_ROOTS_JSON",
);
if (!analysisSnapshotRoots.ok) return analysisSnapshotRoots;
const referenceRoots = parseStringArray(
parsedEnvironment.data.REA_REFERENCE_ROOTS_JSON,
"REA_REFERENCE_ROOTS_JSON",
@ -155,13 +170,8 @@ export const parseConfig = (
},
artifactNativeMountEnabled:
parsedEnvironment.data.REA_ARTIFACT_NATIVE_MOUNT_ENABLED === "true",
evidenceFilePolicy: {
roots: evidenceRoots.value,
maxBytes: 64 * 1024 * 1024,
maxDepth: 64,
maxStringLength: 1024 * 1024,
maxNodes: 1_000_000,
},
evidenceFilePolicy: filePolicy(evidenceRoots.value),
analysisSnapshotFilePolicy: filePolicy(analysisSnapshotRoots.value),
referenceSourcePolicy: {
roots: referenceRoots.value,
secretPatterns: secretPatterns.value,

View File

@ -0,0 +1,13 @@
import { z } from "zod";
/** Input contract for opening a target with an optional staged snapshot. */
export const openBinaryInputSchema = z.object({
path: z.string().min(1),
snapshot_path: z.string().min(1).optional(),
});
/** Input contract for closing a target after an optional atomic snapshot. */
export const closeBinaryInputSchema = z.object({
snapshot_path: z.string().min(1).optional(),
overwrite: z.boolean().default(false),
});

View File

@ -0,0 +1,36 @@
import type { z } from "zod";
import type { JsonValue } from "../domain/jsonValue.js";
/** Caller-visible MCP execution hints. */
export interface ToolAnnotations {
readonly readOnlyHint: boolean;
readonly destructiveHint: boolean;
readonly idempotentHint: boolean;
readonly openWorldHint: boolean;
}
/** One validated example request for an MCP tool contract. */
export interface ToolExample {
readonly title: string;
readonly input: Readonly<Record<string, JsonValue>>;
}
/** Adapter family responsible for implementing a public MCP tool. */
export type ToolKind =
| "official-proxy"
| "enhanced"
| "native-provider"
| "artifact-provider"
| "session";
/** Single source of truth for a public MCP tool. */
export interface ToolContract<Name extends string = string> {
readonly name: Name;
readonly description: string;
readonly kind: ToolKind;
readonly inputSchema: z.ZodObject;
readonly outputSchema: z.ZodObject;
readonly annotations: ToolAnnotations;
readonly examples: readonly ToolExample[];
}

View File

@ -1,5 +1,5 @@
import { z } from "zod";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import {
processCaptureSchema,
processScenarioSchema,
@ -26,6 +26,22 @@ import {
import { TOOL_EXAMPLE_OVERRIDES } from "./toolContractExamples.js";
import { NATIVE_TOOL_CONTRACTS } from "./nativeToolContracts.js";
import { ARTIFACT_TOOL_CONTRACTS } from "./artifactToolContracts.js";
import {
closeBinaryInputSchema,
openBinaryInputSchema,
} from "./sessionLifecycleInputs.js";
import type {
ToolAnnotations,
ToolContract,
ToolExample,
ToolKind,
} from "./toolContractTypes.js";
export type {
ToolAnnotations,
ToolContract,
ToolExample,
} from "./toolContractTypes.js";
const document = z.string().optional().describe("The document name");
const address = z.string().describe("A Hopper address");
@ -53,37 +69,6 @@ const searchInput = {
document,
};
export interface ToolAnnotations {
readonly readOnlyHint: boolean;
readonly destructiveHint: boolean;
readonly idempotentHint: boolean;
readonly openWorldHint: boolean;
}
export interface ToolExample {
readonly title: string;
readonly input: Readonly<Record<string, JsonValue>>;
}
/** Adapter family responsible for implementing a public MCP tool. */
type ToolKind =
| "official-proxy"
| "enhanced"
| "native-provider"
| "artifact-provider"
| "session";
/** Single source of truth for a public tool's name, description, and input. */
export interface ToolContract<Name extends string = string> {
readonly name: Name;
readonly description: string;
readonly kind: ToolKind;
readonly inputSchema: z.ZodObject;
readonly outputSchema: z.ZodObject;
readonly annotations: ToolAnnotations;
readonly examples: readonly ToolExample[];
}
const exampleInputSchema = z.record(z.string(), jsonValueSchema);
const examplesFor = (
name: string,
@ -106,12 +91,16 @@ const annotations = (name: string, kind: ToolKind): ToolAnnotations => ({
name === "verify_unknown_resolution",
destructiveHint:
name === "export_evidence_bundle" ||
name === "close_binary" ||
name === "unset_bookmark" ||
name === "set_address_name" ||
name === "set_addresses_names" ||
name === "set_comment" ||
name === "set_inline_comment",
idempotentHint: name !== "record_unknown" && name !== "update_unknown",
idempotentHint:
name !== "close_binary" &&
name !== "record_unknown" &&
name !== "update_unknown",
openWorldHint: name === "capture_process_scenario",
});
@ -404,13 +393,13 @@ export const ENHANCED_TOOL_CONTRACTS = [
export const SESSION_TOOL_CONTRACTS = [
session(
"open_binary",
"Open a local executable, application bundle, archive, JavaScript, source map, plist, or Hopper database after validation. Providers start lazily: inventory_artifact does not launch Hopper; deep native operations may show Hopper UI.",
z.object({ path: z.string().min(1) }),
"Open a local executable, application bundle, archive, JavaScript, source map, plist, or Hopper database after validation. An optional provider-neutral snapshot is imported atomically and must match the binary digest, format, and architecture exactly; provider resources may still start before an MCP cache replay.",
openBinaryInputSchema,
),
session(
"close_binary",
"Close the active target and every provider resource started for it. The operation is idempotent; call binary_session to verify the session is closed.",
z.object({}),
"Optionally write a provider-neutral analysis snapshot atomically, then close the active target and every provider resource started for it. Snapshot files require an operator-approved root and explicit overwrite; a failed save leaves the session open so cached analysis is not lost.",
closeBinaryInputSchema,
),
session(
"binary_session",

View File

@ -385,7 +385,16 @@ export const sessionOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
architecture: z.enum(["x86", "x86_64", "arm", "arm64"]).nullable(),
}),
),
close_binary: lifecycleResultOf(z.null()),
close_binary: lifecycleResultOf(
z.union([
z.null(),
z.object({
path: z.string(),
bytes: z.number().int().min(0),
entries: z.number().int().min(0),
}),
]),
),
binary_session: lifecycleResultOf(
z.union([
sessionProvider.extend({ open: z.literal(false) }),

View File

@ -0,0 +1,226 @@
import { createHash } from "node:crypto";
import canonicalize from "canonicalize";
import { z } from "zod";
import type {
AnalysisExecution,
AnalysisOperation,
} from "../application/AnalysisProvider.js";
import type { BinaryTarget } from "./binaryTarget.js";
import {
evidenceBundleForTarget,
evidenceBundleSchema,
parseEvidenceBundle,
} from "./evidenceBundle.js";
import {
evidenceLocationSchema,
providerSchema,
type Evidence,
} from "./evidence.js";
import {
jsonObjectSchema,
jsonValueSchema,
type JsonValue,
} from "./jsonValue.js";
const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u);
const architectureSchema = z.enum(["x86", "x86_64", "arm", "arm64"]);
const formatSchema = z.enum([
"hopper",
"mach-o",
"elf",
"pe",
"zip",
"ipa",
"apk",
"asar",
"dmg",
"pkg",
"plist",
"javascript",
"source-map",
]);
const subjectSchema = z.object({
path: z.string().min(1),
sha256: digestSchema,
format: z.enum([
...formatSchema.options,
"directory",
"file",
"unknown",
"mach-o-universal",
"javascript-bundle",
"entitlements",
]),
architecture: architectureSchema.nullable(),
});
const targetSchema = z.object({
sha256: digestSchema,
format: formatSchema,
architecture: architectureSchema.nullable(),
loader_args: z.array(z.string()),
});
const entrySchema = z.object({
query_id: z.string().regex(/^query_[a-f0-9]{64}$/u),
operation: z.string().min(1),
parameters: jsonObjectSchema,
execution: z.object({
result: jsonValueSchema,
raw_result: jsonValueSchema.nullable(),
provider: providerSchema,
limitations: z.array(z.string()),
locations: z.array(evidenceLocationSchema),
subject: subjectSchema.nullable(),
}),
});
/** Provider-neutral, deterministic cache of successful immutable analysis calls. */
const analysisSnapshotSchema = z.object({
snapshot_version: z.literal(1),
target: targetSchema,
entries: z.array(entrySchema).max(10_000),
evidence_bundle: evidenceBundleSchema,
});
export type AnalysisSnapshot = z.infer<typeof analysisSnapshotSchema>;
export type AnalysisSnapshotEntry = z.infer<typeof entrySchema>;
export type AnalysisSnapshotTarget = z.infer<typeof targetSchema>;
const canonicalJson = (value: unknown): string => {
const json = jsonValueSchema.parse(value);
const encoded = canonicalize(json);
if (encoded === undefined)
throw new TypeError("RFC 8785 canonicalization rejected snapshot data");
return encoded;
};
/** Project a binary into the path-free identity used for cache invalidation. */
export const snapshotTarget = (
target: BinaryTarget,
): AnalysisSnapshotTarget => ({
sha256: target.sha256,
format: target.format,
architecture: target.architecture ?? null,
loader_args: [...target.loaderArgs],
});
/** Compare an open binary with the immutable identity in a snapshot. */
export const snapshotMatchesTarget = (
snapshot: AnalysisSnapshotTarget,
target: BinaryTarget,
): boolean =>
snapshot.sha256 === target.sha256 &&
snapshot.format === target.format &&
snapshot.architecture === (target.architecture ?? null) &&
JSON.stringify(snapshot.loader_args) === JSON.stringify(target.loaderArgs);
/** Find exact persisted CLI evidence without starting an analysis provider. */
export const snapshotEvidenceForQuery = (
snapshot: AnalysisSnapshot,
query: {
readonly target: BinaryTarget;
readonly operation: string;
readonly parameters: Readonly<Record<string, JsonValue>>;
readonly provider: AnalysisExecution["provider"];
},
): Evidence | undefined => {
const { target, operation, parameters, provider } = query;
if (!snapshotMatchesTarget(snapshot.target, target)) return undefined;
const encodedParameters = canonicalJson(parameters);
return snapshot.evidence_bundle.records.find(
(record) =>
record.subject?.digest.sha256 === target.sha256 &&
record.operation === operation &&
record.provider.id === provider.id &&
record.provider.name === provider.name &&
record.provider.version === provider.version &&
canonicalJson(record.parameters) === encodedParameters,
);
};
/** Compute the stable lookup key for one provider-specific analysis query. */
export const analysisQueryId = (
target: AnalysisSnapshotTarget,
operation: string,
parameters: Readonly<Record<string, JsonValue>>,
provider: AnalysisExecution["provider"],
): string =>
`query_${createHash("sha256")
.update(
canonicalJson({
target,
operation,
parameters,
provider: {
id: provider.id,
name: provider.name,
version: provider.version,
},
}),
)
.digest("hex")}`;
/** Create one serializable snapshot entry from a successful provider call. */
export const createAnalysisSnapshotEntry = (
target: AnalysisSnapshotTarget,
operation: AnalysisOperation,
parameters: Readonly<Record<string, JsonValue>>,
execution: AnalysisExecution,
): AnalysisSnapshotEntry => ({
query_id: analysisQueryId(target, operation, parameters, execution.provider),
operation,
parameters: jsonObjectSchema.parse(parameters),
execution: {
result: execution.result,
raw_result: execution.rawResult,
provider: execution.provider,
limitations: [...execution.limitations],
locations: [...execution.locations],
subject:
execution.subject === null
? null
: {
...execution.subject,
architecture: execution.subject.architecture ?? null,
},
},
});
/** Parse a snapshot and reject altered query IDs or non-canonical entry order. */
export const parseAnalysisSnapshot = (input: unknown): AnalysisSnapshot => {
const parsed = analysisSnapshotSchema.parse(input);
parseEvidenceBundle(parsed.evidence_bundle);
if (
JSON.stringify(
evidenceBundleForTarget(parsed.evidence_bundle, parsed.target.sha256),
) !== JSON.stringify(parsed.evidence_bundle)
)
throw new TypeError(
"Analysis snapshot evidence contains records for another target",
);
const ids = new Set<string>();
for (const entry of parsed.entries) {
const expected = analysisQueryId(
parsed.target,
entry.operation,
entry.parameters,
entry.execution.provider,
);
if (entry.query_id !== expected)
throw new TypeError("Analysis snapshot query identifier does not match");
if (ids.has(entry.query_id))
throw new TypeError("Analysis snapshot contains duplicate queries");
ids.add(entry.query_id);
}
const sorted = [...parsed.entries].sort((left, right) =>
left.query_id.localeCompare(right.query_id),
);
if (JSON.stringify(parsed.entries) !== JSON.stringify(sorted))
throw new TypeError("Analysis snapshot entries are not canonical");
return parsed;
};
/** Serialize a validated snapshot with byte-stable canonical entry ordering. */
export const serializeAnalysisSnapshot = (snapshot: AnalysisSnapshot): string =>
`${canonicalJson(parseAnalysisSnapshot(snapshot))}\n`;

View File

@ -7,7 +7,8 @@ import type { BinaryTarget } from "./binaryTarget.js";
import { jsonValueSchema, type JsonValue } from "./jsonValue.js";
const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u);
const providerSchema = z.object({
/** Provider identity schema shared by evidence-bearing persistence formats. */
export const providerSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
version: z.string().nullable(),
@ -40,7 +41,8 @@ const subjectSchema = z.object({
local_path: z.string(),
});
/** Source location attached to an evidence observation. */
const evidenceLocationSchema = z.discriminatedUnion("kind", [
/** Source-location schema shared by evidence-bearing persistence formats. */
export const evidenceLocationSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("address"), address: z.string().min(1) }),
z.object({
kind: z.literal("address-range"),

View File

@ -104,6 +104,56 @@ export const createEvidenceBundle = (
};
};
/** Restrict a bundle to records and complete unknown histories for one artifact. */
export const evidenceBundleForTarget = (
bundle: EvidenceBundle,
sha256: string,
): EvidenceBundle => {
const recordsById = new Map(
bundle.records.map((record) => [record.evidence_id, record]),
);
let unknowns = bundle.unknowns.filter(
({ scope_digest: scopeDigest }) => scopeDigest === sha256,
);
let changed = true;
while (changed) {
const ids = new Set(unknowns.map(({ unknown_id: id }) => id));
const retained = unknowns.filter(
(unknown) =>
unknown.relationships.every(({ unknown_id: id }) => ids.has(id)) &&
referencedEvidenceIds(unknown).every((id) => {
const record = recordsById.get(id);
return (
record?.subject?.digest.sha256 === sha256 ||
(unknown.mutation_evidence_ids.includes(id) &&
record?.subject === null &&
record.predicate_type === "rea.residual-unknown-mutation/v1")
);
}),
);
changed = retained.length !== unknowns.length;
unknowns = retained;
}
const mutationIds = new Set(
unknowns.flatMap(({ mutation_evidence_ids: ids }) => ids),
);
return createEvidenceBundle(
bundle.records.filter(
(record) =>
record.subject?.digest.sha256 === sha256 ||
mutationIds.has(record.evidence_id),
),
unknowns,
);
};
const referencedEvidenceIds = (unknown: ResidualUnknown): readonly string[] => [
...unknown.supporting_evidence_ids,
...unknown.contradicting_evidence_ids,
...unknown.mutation_evidence_ids,
...(unknown.resolution?.evidence_ids ?? []),
];
/** Parse records, verify semantic IDs, and reject inconsistent manifests. */
export const parseEvidenceBundle = (input: unknown): EvidenceBundle => {
const parsed = evidenceBundleSchema.parse(input);

View File

@ -72,6 +72,7 @@ export class HopperApplicationLauncher implements BridgeLauncher {
`REA_SOCKET = ${JSON.stringify(session.socketPath)}`,
`REA_TOKEN = ${JSON.stringify(session.token)}`,
`REA_RUN_ID = ${JSON.stringify(session.runId)}`,
`REA_TARGET_PATH = ${JSON.stringify(this.options.targetPath)}`,
`exec(compile(open(${JSON.stringify(this.options.bridgeScriptPath)}, 'rb').read(), ${JSON.stringify(this.options.bridgeScriptPath)}, 'exec'))`,
"",
].join("\n");

View File

@ -19,6 +19,7 @@ import { silentLogger, type Logger } from "../logger.js";
import { parseResponseLine, responseResult } from "./protocol.js";
const MAX_LINE_BYTES = 10 * 1024 * 1024;
const SHUTDOWN_TIMEOUT_MS = 30_000;
const SESSION_ROOT = process.platform === "darwin" ? "/tmp" : tmpdir();
/** Dependencies, deadlines, and redacted diagnostics for one bridge client. */
@ -164,9 +165,16 @@ export class HopperClient {
try {
const socket = this.#socket;
if (socket !== undefined && !socket.destroyed) {
await this.#request("shutdown", {}, { timeoutMs: 500 }).catch(() =>
err(new HopperProcessError(null)),
);
const shutdown = await this.#request(
"shutdown",
{},
{ timeoutMs: SHUTDOWN_TIMEOUT_MS },
).catch(() => err(new HopperProcessError(null)));
if (!shutdown.ok || !isShutdownAcknowledgement(shutdown.value))
this.#logger.warn(
{ status: shutdown.ok ? "invalid-acknowledgement" : "failed" },
"Hopper document shutdown was not confirmed",
);
}
this.#failAll(new HopperProcessError(null));
socket?.destroy();
@ -395,6 +403,14 @@ const connectOnce = async (
});
});
const isShutdownAcknowledgement = (value: JsonValue): boolean =>
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
value.shutdown === true &&
value.analysis_stopped === true &&
value.document_closed === true;
const parseServerInfo = (
value: JsonValue,
expectedRunId: string,

View File

@ -88,6 +88,7 @@ export const run = async (
logger,
processPolicy: config.value.processExecutionPolicy,
evidenceFilePolicy: config.value.evidenceFilePolicy,
analysisSnapshotFilePolicy: config.value.analysisSnapshotFilePolicy,
}),
{
onerror: () => {

View File

@ -16,6 +16,7 @@ export interface CreateServerOptions {
readonly logger?: Logger;
readonly processPolicy?: ProcessExecutionPolicy;
readonly evidenceFilePolicy?: EvidenceFilePolicy;
readonly analysisSnapshotFilePolicy?: EvidenceFilePolicy;
}
/**

View File

@ -0,0 +1,23 @@
import type { McpServer } from "@modelcontextprotocol/server";
import type { BinarySessionPort } from "../application/BinarySession.js";
import { SESSION_TOOL_CONTRACTS } from "../contracts/toolContracts.js";
import { toCallToolResult } from "./toolResult.js";
/** Register the read-only provider and target status operation. */
export const registerSessionStatusTool = (
server: McpServer,
session: BinarySessionPort,
contract: (typeof SESSION_TOOL_CONTRACTS)[2],
): void => {
server.registerTool(
contract.name,
{
description: contract.description,
inputSchema: contract.inputSchema,
outputSchema: contract.outputSchema,
annotations: contract.annotations,
},
() => toCallToolResult({ ok: true, value: session.status() }, contract),
);
};

View File

@ -15,6 +15,7 @@ import type {
import { processScenarioSchema } from "../domain/processCapture.js";
import { captureProcessScenario } from "../application/ProcessHarness.js";
import type { Evidence } from "../domain/evidence.js";
import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js";
import {
recordUnknownInputSchema,
updateUnknownInputSchema,
@ -24,6 +25,10 @@ import {
readEvidenceBundle,
writeEvidenceBundle,
} from "../application/EvidenceBundleFiles.js";
import {
readAnalysisSnapshot,
writeAnalysisSnapshot,
} from "../application/AnalysisSnapshotFiles.js";
import { UnknownRegistryError, type AnalysisError } from "../domain/errors.js";
import {
DENY_EVIDENCE_FILE_POLICY,
@ -35,6 +40,11 @@ import { registerArtifactComparisonTool } from "./registerArtifactComparisonTool
import { registerFunctionComparisonTool } from "./registerFunctionComparisonTool.js";
import { registerBundleComparisonTool } from "./registerBundleComparisonTool.js";
import { registerInvestigationTools } from "./registerInvestigationTools.js";
import {
closeBinaryInputSchema,
openBinaryInputSchema,
} from "../contracts/sessionLifecycleInputs.js";
import { registerSessionStatusTool } from "./registerSessionStatusTool.js";
const recordProcessResidualUnknowns = (
session: BinarySessionPort,
@ -305,16 +315,25 @@ const registerUnknownTools = ({
);
};
const registerLifecycleTools = (
server: McpServer,
session: BinarySessionPort,
logger: Logger,
contracts: readonly [
interface LifecycleToolRegistration {
readonly server: McpServer;
readonly session: BinarySessionPort;
readonly logger: Logger;
readonly contracts: readonly [
(typeof SESSION_TOOL_CONTRACTS)[0],
(typeof SESSION_TOOL_CONTRACTS)[1],
(typeof SESSION_TOOL_CONTRACTS)[2],
],
): void => {
];
readonly snapshotFilePolicy: EvidenceFilePolicy;
}
const registerLifecycleTools = ({
server,
session,
logger,
contracts,
snapshotFilePolicy,
}: LifecycleToolRegistration): void => {
const [openContract, closeContract, statusContract] = contracts;
server.registerTool(
openContract.name,
@ -325,9 +344,21 @@ const registerLifecycleTools = (
annotations: openContract.annotations,
},
async (input, context) => {
const parsed = z.object({ path: z.string().min(1) }).parse(input);
const parsed = openBinaryInputSchema.parse(input);
let snapshot: AnalysisSnapshot | undefined;
if (parsed.snapshot_path !== undefined) {
const loaded = await readAnalysisSnapshot(
parsed.snapshot_path,
snapshotFilePolicy,
);
if (!loaded.ok) return toCallToolResult(loaded, openContract);
snapshot = loaded.value;
}
const opened = await logToolExecution(logger, openContract.name, () =>
session.open(parsed.path, { signal: context.mcpReq.signal }),
session.open(parsed.path, {
signal: context.mcpReq.signal,
...(snapshot === undefined ? {} : { snapshot }),
}),
);
return opened.ok
? toCallToolResult(
@ -355,31 +386,48 @@ const registerLifecycleTools = (
outputSchema: closeContract.outputSchema,
annotations: closeContract.annotations,
},
async () =>
toCallToolResult(
async (input) => {
const parsed = closeBinaryInputSchema.parse(input);
if (parsed.snapshot_path !== undefined) {
const snapshot = session.exportAnalysisSnapshot();
if (!snapshot.ok) return toCallToolResult(snapshot, closeContract);
const written = await writeAnalysisSnapshot(
snapshot.value,
parsed.snapshot_path,
parsed.overwrite,
snapshotFilePolicy,
);
if (!written.ok) return toCallToolResult(written, closeContract);
const closed = await logToolExecution(logger, closeContract.name, () =>
session.close(),
);
return closed.ok
? toCallToolResult(
ok({
path: written.value.path,
bytes: written.value.bytes,
entries: snapshot.value.entries.length,
}),
closeContract,
)
: toCallToolResult(closed, closeContract);
}
return toCallToolResult(
await logToolExecution(logger, closeContract.name, () =>
session.close(),
),
closeContract,
),
);
server.registerTool(
statusContract.name,
{
description: statusContract.description,
inputSchema: statusContract.inputSchema,
outputSchema: statusContract.outputSchema,
annotations: statusContract.annotations,
);
},
() =>
toCallToolResult({ ok: true, value: session.status() }, statusContract),
);
registerSessionStatusTool(server, session, statusContract);
};
/** Register MCP-only target lifecycle operations on a long-lived session. */
export interface SessionToolOptions {
readonly processPolicy?: ProcessExecutionPolicy;
readonly evidenceFilePolicy?: EvidenceFilePolicy;
readonly analysisSnapshotFilePolicy?: EvidenceFilePolicy;
}
export const registerSessionTools = (
@ -391,6 +439,8 @@ export const registerSessionTools = (
const processPolicy = options.processPolicy ?? DENY_PROCESS_POLICY;
const evidenceFilePolicy =
options.evidenceFilePolicy ?? DENY_EVIDENCE_FILE_POLICY;
const analysisSnapshotFilePolicy =
options.analysisSnapshotFilePolicy ?? DENY_EVIDENCE_FILE_POLICY;
const [
openContract,
closeContract,
@ -411,11 +461,13 @@ export const registerSessionTools = (
updateUnknownContract,
verifyUnknownContract,
] = SESSION_TOOL_CONTRACTS;
registerLifecycleTools(server, session, logger, [
openContract,
closeContract,
statusContract,
]);
registerLifecycleTools({
server,
session,
logger,
contracts: [openContract, closeContract, statusContract],
snapshotFilePolicy: analysisSnapshotFilePolicy,
});
registerEvidenceTools({
server,
session,

View File

@ -0,0 +1,127 @@
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
readAnalysisSnapshot,
writeAnalysisSnapshot,
} from "../src/application/AnalysisSnapshotFiles.js";
import {
createAnalysisSnapshotEntry,
parseAnalysisSnapshot,
snapshotEvidenceForQuery,
type AnalysisSnapshot,
} from "../src/domain/analysisSnapshot.js";
import { createEvidenceBundle } from "../src/domain/evidenceBundle.js";
import { createEvidence } from "../src/domain/evidence.js";
import { createAnalysisExecution } from "../src/application/AnalysisProvider.js";
let directory: string | undefined;
afterEach(async () => {
if (directory !== undefined)
await rm(directory, { recursive: true, force: true });
directory = undefined;
});
describe("analysis snapshots", () => {
it("finds exact CLI evidence for provider-free replay", () => {
const target = {
path: "/tmp/app",
sha256: "b".repeat(64),
kind: "executable",
format: "mach-o",
architecture: "arm64",
loaderArgs: [],
} as const;
const evidence = createEvidence(
target,
{ id: "fixture", name: "Fixture", version: "1" },
{
operation: "analyze_function",
parameters: { procedure: "main" },
result: { summary: "cached" },
},
);
const snapshot: AnalysisSnapshot = {
snapshot_version: 1,
target: {
sha256: target.sha256,
format: target.format,
architecture: target.architecture,
loader_args: [],
},
entries: [],
evidence_bundle: createEvidenceBundle([evidence]),
};
expect(
snapshotEvidenceForQuery(snapshot, {
target,
operation: "analyze_function",
parameters: {
procedure: "main",
},
provider: { id: "fixture", name: "Fixture", version: "1" },
}),
).toEqual(evidence);
expect(
snapshotEvidenceForQuery(snapshot, {
target,
operation: "analyze_function",
parameters: {
procedure: "other",
},
provider: { id: "fixture", name: "Fixture", version: "1" },
}),
).toBeUndefined();
});
it("writes canonical private JSON and rejects a changed query identity", async () => {
directory = await mkdtemp(join(tmpdir(), "rea-analysis-snapshot-"));
const path = join(directory, "analysis.json");
const target: AnalysisSnapshot["target"] = {
sha256: "a".repeat(64),
format: "mach-o",
architecture: "arm64",
loader_args: [],
};
const entry = createAnalysisSnapshotEntry(
target,
"address_name",
{ address: "0x1000" },
createAnalysisExecution("main", {
id: "fixture",
name: "Fixture analysis provider",
version: "1",
}),
);
const snapshot: AnalysisSnapshot = {
snapshot_version: 1,
target,
entries: [entry],
evidence_bundle: createEvidenceBundle([]),
};
const policy = {
roots: [directory],
maxBytes: 1024 * 1024,
maxDepth: 64,
maxStringLength: 1024,
maxNodes: 10_000,
};
const written = await writeAnalysisSnapshot(snapshot, path, false, policy);
expect(written.ok).toBe(true);
expect((await readFile(path, "utf8")).endsWith("\n")).toBe(true);
expect((await stat(path)).mode & 0o777).toBe(0o600);
expect((await readAnalysisSnapshot(path, policy)).ok).toBe(true);
const altered = JSON.parse(await readFile(path, "utf8"));
altered.entries[0].parameters.address = "0x2000";
await writeFile(path, JSON.stringify(altered));
const loaded = await readAnalysisSnapshot(path, policy);
expect(loaded.ok).toBe(false);
if (!loaded.ok) expect(loaded.error._tag).toBe("EvidenceIntegrityError");
expect(() => parseAnalysisSnapshot(altered)).toThrow(/identifier/u);
});
});

View File

@ -5,7 +5,10 @@ import { afterEach, describe, expect, it } from "vitest";
import { BinarySession } from "../src/application/BinarySession.js";
import type { AnalysisClient } from "../src/application/AnalysisProvider.js";
import type { AnalysisProvider } from "../src/application/AnalysisProvider.js";
import type {
AnalysisProvider,
CapabilityDescriptor,
} from "../src/application/AnalysisProvider.js";
import { HopperStartError } from "../src/domain/errors.js";
import { err } from "../src/domain/result.js";
import { observed as ok } from "./fixtures/analysisExecution.js";
@ -96,6 +99,97 @@ describe("binary session", () => {
await session.close();
});
it("replays exact immutable calls from a matching provider-neutral snapshot", async () => {
const [first, second] = await targets();
const initialCalls: string[] = [];
const initial = new BinarySession(cacheProvider(initialCalls));
expect((await initial.open(first)).ok).toBe(true);
expect(
(
await initial.execute("address_name", {
address: "0x1000",
document: "first",
})
).ok,
).toBe(true);
const snapshot = initial.exportAnalysisSnapshot();
expect(snapshot.ok).toBe(true);
if (!snapshot.ok) return;
expect(snapshot.value.entries).toHaveLength(1);
await initial.close();
const replayCalls: string[] = [];
const replay = new BinarySession(cacheProvider(replayCalls));
expect(replay.importAnalysisSnapshot(snapshot.value)).toEqual({
ok: true,
value: 1,
});
expect((await replay.open(first)).ok).toBe(true);
const cached = await replay.execute("address_name", {
address: "0x1000",
document: "first",
});
expect(cached.ok).toBe(true);
if (cached.ok)
expect(cached.value.limitations).toContainEqual(
expect.stringContaining("local REA analysis snapshot"),
);
expect(
(
await replay.execute("set_address_name", {
address: "0x1000",
name: "renamed",
document: "first",
})
).ok,
).toBe(true);
expect(
(
await replay.execute("address_name", {
address: "0x1000",
document: "first",
})
).ok,
).toBe(true);
expect(replayCalls).toEqual(["health", "set_address_name", "address_name"]);
expect(replay.exportAnalysisSnapshot().ok).toBe(false);
await replay.close();
const mismatch = new BinarySession(cacheProvider([]));
expect(mismatch.importAnalysisSnapshot(snapshot.value).ok).toBe(true);
const opened = await mismatch.open(second);
expect(opened.ok).toBe(false);
if (!opened.ok) expect(opened.error._tag).toBe("EvidenceIntegrityError");
await mismatch.close();
});
it("does not snapshot reads that depend on the provider cursor", async () => {
const [first] = await targets();
const calls: string[] = [];
const session = new BinarySession(cacheProvider(calls));
expect((await session.open(first)).ok).toBe(true);
expect((await session.execute("address_name", {})).ok).toBe(true);
expect((await session.execute("address_name", {})).ok).toBe(true);
expect(calls).toEqual(["health", "address_name", "address_name"]);
expect(session.exportAnalysisSnapshot()).toMatchObject({
ok: true,
value: { entries: [] },
});
await session.close();
});
it("does not replay operations with filesystem side effects", async () => {
const [first] = await targets();
const calls: string[] = [];
const session = new BinarySession(cacheProvider(calls, true));
expect((await session.open(first)).ok).toBe(true);
const input = { address: "0x1000", document: "first" };
expect((await session.execute("address_name", input)).ok).toBe(true);
expect((await session.execute("address_name", input)).ok).toBe(true);
expect(calls).toEqual(["health", "address_name", "address_name"]);
await session.close();
});
it("returns typed unavailability without dispatching a partial provider", async () => {
const [first] = await targets();
const operations: string[] = [];
@ -306,6 +400,62 @@ const client = (fail = false): AnalysisClient => ({
close: () => Promise.resolve(),
});
const cacheProvider = (
calls: string[],
mayWriteFilesystem = false,
): AnalysisProvider => {
const identity = {
id: "fixture",
name: "Fixture analysis provider",
version: "1",
} as const;
return {
identity: () => identity,
capabilities: () => [
cacheCapability(identity, "address_name", false, mayWriteFilesystem),
cacheCapability(identity, "set_address_name", true),
],
createClient: () => ({
execute: (operation) => {
calls.push(operation);
return Promise.resolve(ok(operation));
},
close: () => Promise.resolve(),
}),
};
};
const cacheCapability = (
provider: CapabilityDescriptor["provider"],
operation: "address_name" | "set_address_name",
mutatesArtifact: boolean,
mayWriteFilesystem = false,
): CapabilityDescriptor => ({
provider,
operation,
inputContractVersion: 1,
outputContractVersion: 1,
available: true,
reason: null,
pagination: "none",
exhaustive: true,
effects: {
mutatesArtifact,
launchesProcess: false,
mayShowUi: false,
mayAccessNetwork: false,
mayWriteFilesystem,
changesPermissions: false,
requiresRoot: false,
},
limits: {
maxResults: null,
maxPayloadBytes: null,
timeoutMs: null,
},
limitations: [],
});
class TestClient implements AnalysisClient {
closed = 0;
constructor(

View File

@ -96,4 +96,33 @@ describe("Hopper bridge truthfulness", () => {
expect(bridgeSource).toContain('"type": _diagnostic_type(error)');
expect(bridgeSource).not.toContain("traceback.format_exc");
});
it("stops background analysis and closes the session-owned document", () => {
expect(bridgeSource).toContain("def _session_document():");
expect(bridgeSource).toContain("os.path.realpath(REA_TARGET_PATH)");
expect(bridgeSource).toContain("document.getExecutableFilePath()");
expect(bridgeSource).toContain("document.getDatabaseFilePath()");
expect(bridgeSource).toContain("document.backgroundProcessActive()");
expect(bridgeSource).toContain("document.requestBackgroundProcessStop()");
expect(bridgeSource).toContain("document.waitForBackgroundProcessToEnd()");
expect(bridgeSource).toContain("document.closeDocument()");
expect(bridgeSource).toContain(
"document_closed = _session_document() is None",
);
});
it("returns deterministic addresses for direct procedure relationships", () => {
expect(bridgeSource).toContain(
"_hex(item.getEntryPoint()) for item in procedure.getAllCallerProcedures()",
);
expect(bridgeSource).toContain(
"_hex(item.getEntryPoint()) for item in procedure.getAllCalleeProcedures()",
);
expect(bridgeSource).not.toContain(
"_procedure_name(item) for item in procedure.getAllCallerProcedures()",
);
expect(bridgeSource).not.toContain(
"_procedure_name(item) for item in procedure.getAllCalleeProcedures()",
);
});
});

View File

@ -62,6 +62,13 @@ describe("runtime configuration", () => {
maxStringLength: 1024 * 1024,
maxNodes: 1_000_000,
},
analysisSnapshotFilePolicy: {
roots: [],
maxBytes: 64 * 1024 * 1024,
maxDepth: 64,
maxStringLength: 1024 * 1024,
maxNodes: 1_000_000,
},
referenceSourcePolicy: {
roots: [],
secretPatterns: [],

View File

@ -126,7 +126,14 @@ const server = createServer((socket) => {
true,
);
} else if (request.method === "shutdown") {
send({ id: request.id, result: { shutdown: true } });
send({
id: request.id,
result: {
shutdown: true,
analysis_stopped: true,
document_closed: true,
},
});
setTimeout(() => server.close(), 2);
} else if (request.method === "hang") {
// Deliberately leave the request pending.

View File

@ -23,6 +23,7 @@ describe("configuration parsing integration", () => {
HOPPER_TARGET_KIND: "executable",
HOPPER_LOADER_ARGS_JSON: '["-l","Mach-O","--aarch64"]',
REA_EVIDENCE_ROOTS_JSON: '["/tmp/evidence"]',
REA_ANALYSIS_SNAPSHOT_ROOTS_JSON: '["/tmp/analysis"]',
});
expect(result.ok).toBe(true);
if (!result.ok) throw new Error("unreachable");
@ -34,6 +35,9 @@ describe("configuration parsing integration", () => {
"--aarch64",
]);
expect(result.value.evidenceFilePolicy.roots).toEqual(["/tmp/evidence"]);
expect(result.value.analysisSnapshotFilePolicy.roots).toEqual([
"/tmp/analysis",
]);
});
it("rejects target kind values outside the enum", () => {

View File

@ -1,4 +1,4 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
@ -45,6 +45,13 @@ describe("target-free MCP lifecycle", () => {
maxStringLength: 1024,
maxNodes: 10_000,
},
analysisSnapshotFilePolicy: {
roots: [directory],
maxBytes: 1024 * 1024,
maxDepth: 68,
maxStringLength: 1024,
maxNodes: 10_000,
},
});
const mcp = new Client({ name: "session-test", version: "1.0.0" });
const [clientTransport, serverTransport] =
@ -180,10 +187,42 @@ describe("target-free MCP lifecycle", () => {
expect(
text(await mcp.callTool({ name: "binary_session", arguments: {} })),
).toContain("second.hop");
await mcp.callTool({ name: "close_binary", arguments: {} });
const snapshotPath = join(directory, "analysis.json");
expect(
structured(
await mcp.callTool({
name: "close_binary",
arguments: { snapshot_path: snapshotPath },
}),
).result,
).toMatchObject({ path: snapshotPath, entries: 0 });
expect(JSON.parse(await readFile(snapshotPath, "utf8"))).toMatchObject({
evidence_bundle: { records: [], unknowns: [] },
});
expect(
text(await mcp.callTool({ name: "binary_session", arguments: {} })),
).toContain('"open": false');
expect(
(
await mcp.callTool({
name: "open_binary",
arguments: { path: first, snapshot_path: snapshotPath },
})
).isError,
).toBe(true);
expect(
structured(await mcp.callTool({ name: "list_unknowns", arguments: {} }))
.result,
).toEqual([]);
expect(
(
await mcp.callTool({
name: "open_binary",
arguments: { path: second, snapshot_path: snapshotPath },
})
).isError,
).not.toBe(true);
await mcp.callTool({ name: "close_binary", arguments: {} });
}, 10_000);
it("records approved process residuals in the unknown registry", async () => {