diff --git a/README.md b/README.md index f4610345e..344531b6e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/bridge/hopper_bridge.py b/bridge/hopper_bridge.py index a22f20637..18548fac1 100644 --- a/bridge/hopper_bridge.py +++ b/bridge/hopper_bridge.py @@ -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) diff --git a/src/application/AnalysisSnapshotCache.ts b/src/application/AnalysisSnapshotCache.ts new file mode 100644 index 000000000..544937c48 --- /dev/null +++ b/src/application/AnalysisSnapshotCache.ts @@ -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 = new Set([ + "health", + "current_address", + "current_procedure", + "current_document", + "goto_address", + "list_documents", + "set_current_document", +]); + +const CURSOR_DEFAULT_OPERATIONS: ReadonlySet = new Set([ + "address_name", + "comment", + "inline_comment", + "next_address", + "prev_address", + "xrefs", +]); + +const DOCUMENT_SCOPED_OPERATIONS: ReadonlySet = 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>, +): 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(); + #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 { + 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, + ): Result { + 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>, + 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>, + 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; + } +} diff --git a/src/application/AnalysisSnapshotFiles.ts b/src/application/AnalysisSnapshotFiles.ts new file mode 100644 index 000000000..94cb6464c --- /dev/null +++ b/src/application/AnalysisSnapshotFiles.ts @@ -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> => { + 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); +}; diff --git a/src/application/BinarySession.ts b/src/application/BinarySession.ts index ea7fb4a42..af842a7d5 100644 --- a/src/application/BinarySession.ts +++ b/src/application/BinarySession.ts @@ -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 = 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>; - close(): Promise>; - 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; - recordUnknown( - input: RecordUnknownInput, - ): Result; - recordEvidenceWithUnknown( - evidence: Evidence, - input: RecordUnknownInput, - ): Result; - updateUnknown( - input: UpdateUnknownInput, - ): Result; - 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 { + 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 { + 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 { - 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> { 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; diff --git a/src/application/BinarySessionPort.ts b/src/application/BinarySessionPort.ts new file mode 100644 index 000000000..659eabb85 --- /dev/null +++ b/src/application/BinarySessionPort.ts @@ -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>; + close(): Promise>; + 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; + exportAnalysisSnapshot(): Result; + importAnalysisSnapshot( + snapshot: AnalysisSnapshot, + ): Result; + recordUnknown( + input: RecordUnknownInput, + ): Result; + recordEvidenceWithUnknown( + evidence: Evidence, + input: RecordUnknownInput, + ): Result; + updateUnknown( + input: UpdateUnknownInput, + ): Result; + 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; +} diff --git a/src/application/BoundedJsonFiles.ts b/src/application/BoundedJsonFiles.ts new file mode 100644 index 000000000..c62c708d9 --- /dev/null +++ b/src/application/BoundedJsonFiles.ts @@ -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> => { + 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 => { + 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 => { + 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"; diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index 6c92b39dd..b40843f95 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -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>, - logger: Logger = silentLogger, -): Promise => runAnalysis(path, tool, arguments_, logger); + options: { + readonly logger?: Logger; + readonly snapshotPath?: string | undefined; + } = {}, +): Promise => + 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>, logger: Logger = silentLogger, -): Promise => runAnalysis(path, tool, arguments_, logger); +): Promise => + 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>, - logger: Logger, + options: { + readonly logger: Logger; + readonly snapshotPath: string | undefined; + }, ): Promise => { + 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>; + readonly snapshotPath: string | undefined; + readonly policy: Parameters[1]; + readonly provider: Parameters[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, + tool: NativeToolName | ArtifactToolName | DirectAnalysisTool, +) => + tool === "binary_overview" || tool === "trace_feature" + ? WORKFLOW_PROVIDER + : session.providerIdentity(tool); + +const fileExists = async (path: string): Promise => { + 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), diff --git a/src/application/EvidenceBundleFiles.ts b/src/application/EvidenceBundleFiles.ts index d3b4a3886..7febb5fce 100644 --- a/src/application/EvidenceBundleFiles.ts +++ b/src/application/EvidenceBundleFiles.ts @@ -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> => { 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 => { - 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 => { - 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"; diff --git a/src/application/UnknownEvidence.ts b/src/application/UnknownEvidence.ts new file mode 100644 index 000000000..cbc515e18 --- /dev/null +++ b/src/application/UnknownEvidence.ts @@ -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); diff --git a/src/application/runtime.ts b/src/application/runtime.ts index 9dafb2330..ed7f647a9 100644 --- a/src/application/runtime.ts +++ b/src/application/runtime.ts @@ -21,5 +21,6 @@ export const createBinarySession = ( new NativeMacOSProvider(), new HopperProvider(config, logger), ]), + { snapshotsEnabled: config.hopperLoaderArgs.length === 0 }, ); }; diff --git a/src/cli.ts b/src/cli.ts index ac257e5c2..99d1d7f13 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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 }, ), ), }); diff --git a/src/config.ts b/src/config.ts index 6554bbcf0..31d6b1090 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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, diff --git a/src/contracts/sessionLifecycleInputs.ts b/src/contracts/sessionLifecycleInputs.ts new file mode 100644 index 000000000..4cdfeaada --- /dev/null +++ b/src/contracts/sessionLifecycleInputs.ts @@ -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), +}); diff --git a/src/contracts/toolContractTypes.ts b/src/contracts/toolContractTypes.ts new file mode 100644 index 000000000..762947798 --- /dev/null +++ b/src/contracts/toolContractTypes.ts @@ -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>; +} + +/** 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 { + readonly name: Name; + readonly description: string; + readonly kind: ToolKind; + readonly inputSchema: z.ZodObject; + readonly outputSchema: z.ZodObject; + readonly annotations: ToolAnnotations; + readonly examples: readonly ToolExample[]; +} diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts index b92c97fc9..c2351e5e1 100644 --- a/src/contracts/toolContracts.ts +++ b/src/contracts/toolContracts.ts @@ -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>; -} - -/** 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 { - 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", diff --git a/src/contracts/toolOutputSchemas.ts b/src/contracts/toolOutputSchemas.ts index 4f65576ce..c03dbbba2 100644 --- a/src/contracts/toolOutputSchemas.ts +++ b/src/contracts/toolOutputSchemas.ts @@ -385,7 +385,16 @@ export const sessionOutputSchemas: Readonly> = { 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) }), diff --git a/src/domain/analysisSnapshot.ts b/src/domain/analysisSnapshot.ts new file mode 100644 index 000000000..2c3d83bd9 --- /dev/null +++ b/src/domain/analysisSnapshot.ts @@ -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; +export type AnalysisSnapshotEntry = z.infer; +export type AnalysisSnapshotTarget = z.infer; + +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>; + 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>, + 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>, + 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(); + 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`; diff --git a/src/domain/evidence.ts b/src/domain/evidence.ts index 8e487cfbe..982f51494 100644 --- a/src/domain/evidence.ts +++ b/src/domain/evidence.ts @@ -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"), diff --git a/src/domain/evidenceBundle.ts b/src/domain/evidenceBundle.ts index b278fe5ec..0f0a29c38 100644 --- a/src/domain/evidenceBundle.ts +++ b/src/domain/evidenceBundle.ts @@ -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); diff --git a/src/hopper/BridgeLauncher.ts b/src/hopper/BridgeLauncher.ts index 7ad63b796..1bc927e71 100644 --- a/src/hopper/BridgeLauncher.ts +++ b/src/hopper/BridgeLauncher.ts @@ -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"); diff --git a/src/hopper/HopperClient.ts b/src/hopper/HopperClient.ts index 04aa73ec7..5f81b457f 100644 --- a/src/hopper/HopperClient.ts +++ b/src/hopper/HopperClient.ts @@ -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, diff --git a/src/main.ts b/src/main.ts index 8794250b6..f253c3a7f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -88,6 +88,7 @@ export const run = async ( logger, processPolicy: config.value.processExecutionPolicy, evidenceFilePolicy: config.value.evidenceFilePolicy, + analysisSnapshotFilePolicy: config.value.analysisSnapshotFilePolicy, }), { onerror: () => { diff --git a/src/server/createServer.ts b/src/server/createServer.ts index b1020dbd2..84e3d6e38 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -16,6 +16,7 @@ export interface CreateServerOptions { readonly logger?: Logger; readonly processPolicy?: ProcessExecutionPolicy; readonly evidenceFilePolicy?: EvidenceFilePolicy; + readonly analysisSnapshotFilePolicy?: EvidenceFilePolicy; } /** diff --git a/src/server/registerSessionStatusTool.ts b/src/server/registerSessionStatusTool.ts new file mode 100644 index 000000000..fc4c4f763 --- /dev/null +++ b/src/server/registerSessionStatusTool.ts @@ -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), + ); +}; diff --git a/src/server/registerSessionTools.ts b/src/server/registerSessionTools.ts index e11d2df02..edfcbb568 100644 --- a/src/server/registerSessionTools.ts +++ b/src/server/registerSessionTools.ts @@ -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, diff --git a/tests/analysisSnapshot.test.ts b/tests/analysisSnapshot.test.ts new file mode 100644 index 000000000..50650b538 --- /dev/null +++ b/tests/analysisSnapshot.test.ts @@ -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); + }); +}); diff --git a/tests/binarySession.test.ts b/tests/binarySession.test.ts index b5407eae9..9c2075abf 100644 --- a/tests/binarySession.test.ts +++ b/tests/binarySession.test.ts @@ -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( diff --git a/tests/bridgeTruthfulness.test.ts b/tests/bridgeTruthfulness.test.ts index 29df93b85..3b16d0c27 100644 --- a/tests/bridgeTruthfulness.test.ts +++ b/tests/bridgeTruthfulness.test.ts @@ -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()", + ); + }); }); diff --git a/tests/config.test.ts b/tests/config.test.ts index 4c2322e7a..13a3e05e3 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -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: [], diff --git a/tests/fixtures/fakeHopper.mjs b/tests/fixtures/fakeHopper.mjs index e5262d665..a756fa29a 100644 --- a/tests/fixtures/fakeHopper.mjs +++ b/tests/fixtures/fakeHopper.mjs @@ -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. diff --git a/tests/integration/configIntegration.test.ts b/tests/integration/configIntegration.test.ts index 948d034fa..45cdc4ba1 100644 --- a/tests/integration/configIntegration.test.ts +++ b/tests/integration/configIntegration.test.ts @@ -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", () => { diff --git a/tests/sessionMcp.test.ts b/tests/sessionMcp.test.ts index 5780a0dc0..ece3a8239 100644 --- a/tests/sessionMcp.test.ts +++ b/tests/sessionMcp.test.ts @@ -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 () => {