Merge pull request #109 from morluto/codex/hopper-analysis-snapshots
feat(analysis): persist snapshots and close Hopper reliably
This commit is contained in:
commit
c6407ef3d6
39
README.md
39
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,212 @@
|
|||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { EvidenceBundle } from "../domain/evidenceBundle.js";
|
||||
import {
|
||||
EvidenceIntegrityError,
|
||||
NoBinaryOpenError,
|
||||
type AnalysisError,
|
||||
type EvidenceLimitError,
|
||||
} from "../domain/errors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
analysisQueryId,
|
||||
createAnalysisSnapshotEntry,
|
||||
snapshotMatchesTarget,
|
||||
snapshotTarget,
|
||||
type AnalysisSnapshot,
|
||||
type AnalysisSnapshotEntry,
|
||||
type AnalysisSnapshotTarget,
|
||||
} from "../domain/analysisSnapshot.js";
|
||||
import type {
|
||||
AnalysisExecution,
|
||||
AnalysisOperation,
|
||||
CapabilityDescriptor,
|
||||
ProviderIdentity,
|
||||
} from "./AnalysisProvider.js";
|
||||
import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/toolContracts.js";
|
||||
|
||||
const STATEFUL_OPERATIONS: ReadonlySet<AnalysisOperation> = new Set([
|
||||
"health",
|
||||
"current_address",
|
||||
"current_procedure",
|
||||
"current_document",
|
||||
"goto_address",
|
||||
"list_documents",
|
||||
"set_current_document",
|
||||
]);
|
||||
|
||||
const CURSOR_DEFAULT_OPERATIONS: ReadonlySet<AnalysisOperation> = new Set([
|
||||
"address_name",
|
||||
"comment",
|
||||
"inline_comment",
|
||||
"next_address",
|
||||
"prev_address",
|
||||
"xrefs",
|
||||
]);
|
||||
|
||||
const DOCUMENT_SCOPED_OPERATIONS: ReadonlySet<AnalysisOperation> = new Set(
|
||||
OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name).filter(
|
||||
(name) =>
|
||||
name !== "current_document" &&
|
||||
name !== "list_documents" &&
|
||||
name !== "set_current_document",
|
||||
),
|
||||
);
|
||||
|
||||
/** Whether an operation is immutable and independent of provider UI state. */
|
||||
export const isSnapshotCacheable = (
|
||||
operation: AnalysisOperation,
|
||||
descriptor: CapabilityDescriptor | undefined,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
): descriptor is CapabilityDescriptor =>
|
||||
!STATEFUL_OPERATIONS.has(operation) &&
|
||||
(!DOCUMENT_SCOPED_OPERATIONS.has(operation) ||
|
||||
typeof parameters.document === "string") &&
|
||||
(!CURSOR_DEFAULT_OPERATIONS.has(operation) ||
|
||||
typeof parameters.address === "string") &&
|
||||
descriptor?.effects.mutatesArtifact === false &&
|
||||
descriptor.effects.mayWriteFilesystem === false &&
|
||||
descriptor.effects.changesPermissions === false;
|
||||
|
||||
/** Bounded in-memory cache for one immutable binary identity. */
|
||||
export class AnalysisSnapshotCache {
|
||||
readonly #entries = new Map<string, AnalysisSnapshotEntry>();
|
||||
#target: AnalysisSnapshotTarget | undefined;
|
||||
|
||||
/** Whether staged entries belong to the supplied binary. */
|
||||
matches(target: BinaryTarget): boolean {
|
||||
return (
|
||||
this.#target === undefined || snapshotMatchesTarget(this.#target, target)
|
||||
);
|
||||
}
|
||||
|
||||
/** Replace cache state when the active binary identity changes. */
|
||||
select(target: BinaryTarget): void {
|
||||
if (!this.matches(target)) this.#entries.clear();
|
||||
this.#target = snapshotTarget(target);
|
||||
}
|
||||
|
||||
/** Merge already-validated entries and return the new-entry count. */
|
||||
stage(snapshot: AnalysisSnapshot): number {
|
||||
if (
|
||||
this.#target !== undefined &&
|
||||
(this.#target.sha256 !== snapshot.target.sha256 ||
|
||||
this.#target.format !== snapshot.target.format ||
|
||||
this.#target.architecture !== snapshot.target.architecture)
|
||||
)
|
||||
this.#entries.clear();
|
||||
this.#target = snapshot.target;
|
||||
let imported = 0;
|
||||
for (const entry of snapshot.entries) {
|
||||
if (!this.#entries.has(entry.query_id)) imported += 1;
|
||||
this.#entries.set(entry.query_id, entry);
|
||||
}
|
||||
return imported;
|
||||
}
|
||||
|
||||
/** Build a complete snapshot for an active target. */
|
||||
export(
|
||||
target: BinaryTarget | undefined,
|
||||
evidenceBundle: EvidenceBundle,
|
||||
): Result<AnalysisSnapshot, NoBinaryOpenError> {
|
||||
if (target === undefined) return err(new NoBinaryOpenError());
|
||||
return ok({
|
||||
snapshot_version: 1,
|
||||
target: snapshotTarget(target),
|
||||
entries: this.entries(),
|
||||
evidence_bundle: evidenceBundle,
|
||||
});
|
||||
}
|
||||
|
||||
/** Validate target identity, merge evidence atomically, then stage entries. */
|
||||
import(
|
||||
snapshot: AnalysisSnapshot,
|
||||
active: BinaryTarget | undefined,
|
||||
mergeEvidence: (
|
||||
bundle: EvidenceBundle,
|
||||
) => Result<number, EvidenceIntegrityError | EvidenceLimitError>,
|
||||
): Result<number, AnalysisError> {
|
||||
if (active !== undefined && !snapshotMatchesTarget(snapshot.target, active))
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
"Analysis snapshot target does not match the active binary",
|
||||
),
|
||||
);
|
||||
const importedEvidence = mergeEvidence(snapshot.evidence_bundle);
|
||||
return importedEvidence.ok ? ok(this.stage(snapshot)) : importedEvidence;
|
||||
}
|
||||
|
||||
/** Return canonical entries for persistence. */
|
||||
entries(): AnalysisSnapshotEntry[] {
|
||||
return [...this.#entries.values()].sort((left, right) =>
|
||||
left.query_id.localeCompare(right.query_id),
|
||||
);
|
||||
}
|
||||
|
||||
/** Replay an exact provider-specific query, marking its cached provenance. */
|
||||
lookup(
|
||||
target: BinaryTarget,
|
||||
operation: AnalysisOperation,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
provider: ProviderIdentity,
|
||||
): AnalysisExecution | undefined {
|
||||
const queryId = analysisQueryId(
|
||||
snapshotTarget(target),
|
||||
operation,
|
||||
parameters,
|
||||
provider,
|
||||
);
|
||||
const cached = this.#entries.get(queryId);
|
||||
if (cached === undefined) return undefined;
|
||||
const subject = cached.execution.subject;
|
||||
return {
|
||||
result: cached.execution.result,
|
||||
rawResult: cached.execution.raw_result,
|
||||
provider: cached.execution.provider,
|
||||
limitations: [
|
||||
...cached.execution.limitations,
|
||||
"Loaded from a local REA analysis snapshot; this call did not re-run the provider.",
|
||||
],
|
||||
locations: cached.execution.locations,
|
||||
subject:
|
||||
subject === null
|
||||
? null
|
||||
: subject.architecture === null
|
||||
? {
|
||||
path: subject.path,
|
||||
sha256: subject.sha256,
|
||||
format: subject.format,
|
||||
}
|
||||
: {
|
||||
path: subject.path,
|
||||
sha256: subject.sha256,
|
||||
format: subject.format,
|
||||
architecture: subject.architecture,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Record one successful immutable call unless the cache is full. */
|
||||
record(
|
||||
target: BinaryTarget,
|
||||
operation: AnalysisOperation,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
execution: AnalysisExecution,
|
||||
): void {
|
||||
if (this.#entries.size >= 10_000) return;
|
||||
this.select(target);
|
||||
const entry = createAnalysisSnapshotEntry(
|
||||
snapshotTarget(target),
|
||||
operation,
|
||||
parameters,
|
||||
execution,
|
||||
);
|
||||
this.#entries.set(entry.query_id, entry);
|
||||
}
|
||||
|
||||
/** Forget all target-bound entries. */
|
||||
clear(): void {
|
||||
this.#entries.clear();
|
||||
this.#target = undefined;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import {
|
||||
parseAnalysisSnapshot,
|
||||
serializeAnalysisSnapshot,
|
||||
type AnalysisSnapshot,
|
||||
} from "../domain/analysisSnapshot.js";
|
||||
import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js";
|
||||
import { EvidenceFileError, EvidenceIntegrityError } from "../domain/errors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { readBoundedJson, writeBoundedText } from "./BoundedJsonFiles.js";
|
||||
|
||||
type SnapshotFailure = EvidenceFileError | EvidenceIntegrityError;
|
||||
|
||||
/** Read and validate a bounded analysis snapshot inside an approved root. */
|
||||
export const readAnalysisSnapshot = async (
|
||||
path: string,
|
||||
policy: EvidenceFilePolicy,
|
||||
): Promise<Result<AnalysisSnapshot, SnapshotFailure>> => {
|
||||
const loaded = await readBoundedJson(path, policy);
|
||||
if (!loaded.ok) return loaded;
|
||||
try {
|
||||
return ok(parseAnalysisSnapshot(loaded.value));
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new EvidenceIntegrityError("Analysis snapshot validation failed", {
|
||||
cause,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** Atomically write a deterministic analysis snapshot inside an approved root. */
|
||||
export const writeAnalysisSnapshot = async (
|
||||
snapshot: AnalysisSnapshot,
|
||||
path: string,
|
||||
overwrite: boolean,
|
||||
policy: EvidenceFilePolicy,
|
||||
): Promise<
|
||||
Result<{ readonly path: string; readonly bytes: number }, SnapshotFailure>
|
||||
> => {
|
||||
let encoded: string;
|
||||
try {
|
||||
encoded = serializeAnalysisSnapshot(snapshot);
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new EvidenceIntegrityError("Analysis snapshot validation failed", {
|
||||
cause,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return writeBoundedText(encoded, path, overwrite, policy);
|
||||
};
|
||||
|
|
@ -13,6 +13,7 @@ import type { JsonValue } from "../domain/jsonValue.js";
|
|||
import type { Evidence } from "../domain/evidence.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
import type { EvidenceBundle } from "../domain/evidenceBundle.js";
|
||||
import { evidenceBundleForTarget } from "../domain/evidenceBundle.js";
|
||||
import type {
|
||||
AnalysisClient,
|
||||
AnalysisExecution,
|
||||
|
|
@ -33,63 +34,23 @@ import { UnknownRegistryError } from "../domain/errors.js";
|
|||
import type { AnalysisOperation } from "./AnalysisProvider.js";
|
||||
import { enhancedToolNameSchema } from "../contracts/enhancedInputs.js";
|
||||
import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/toolContracts.js";
|
||||
|
||||
const REGISTRY_PROVIDER: ProviderIdentity = {
|
||||
id: "rea-unknown-registry",
|
||||
name: "REA residual unknown registry",
|
||||
version: "1",
|
||||
};
|
||||
import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js";
|
||||
import { snapshotMatchesTarget } from "../domain/analysisSnapshot.js";
|
||||
import {
|
||||
AnalysisSnapshotCache,
|
||||
isSnapshotCacheable,
|
||||
} from "./AnalysisSnapshotCache.js";
|
||||
import {
|
||||
UNKNOWN_REGISTRY_PROVIDER,
|
||||
unknownEvidenceLinks,
|
||||
unknownMutationEvidence,
|
||||
} from "./UnknownEvidence.js";
|
||||
import type { BinarySessionPort } from "./BinarySessionPort.js";
|
||||
export type { BinarySessionPort } from "./BinarySessionPort.js";
|
||||
const OFFICIAL_OPERATIONS: ReadonlySet<string> = new Set(
|
||||
OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name),
|
||||
);
|
||||
|
||||
/** Target lifecycle used by CLI and MCP without exposing a concrete provider. */
|
||||
export interface BinarySessionPort extends AnalysisOperationPort {
|
||||
open(
|
||||
path: string,
|
||||
options?: {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly targetKind?: BinaryTarget["kind"];
|
||||
},
|
||||
): Promise<Result<BinaryTarget, AnalysisError>>;
|
||||
close(): Promise<Result<null, AnalysisError>>;
|
||||
status(): JsonValue;
|
||||
activeTarget(): BinaryTarget | undefined;
|
||||
recordEvidence(
|
||||
evidence: Evidence,
|
||||
): Result<"added" | "duplicate", EvidenceIntegrityError | EvidenceLimitError>;
|
||||
hasEvidence(evidenceId: string): boolean;
|
||||
evidenceById(evidenceId: string): Evidence | undefined;
|
||||
exportEvidenceBundle(): EvidenceBundle;
|
||||
importEvidenceBundle(
|
||||
bundle: unknown,
|
||||
): Result<number, EvidenceIntegrityError | EvidenceLimitError>;
|
||||
recordUnknown(
|
||||
input: RecordUnknownInput,
|
||||
): Result<ResidualUnknown, AnalysisError>;
|
||||
recordEvidenceWithUnknown(
|
||||
evidence: Evidence,
|
||||
input: RecordUnknownInput,
|
||||
): Result<ResidualUnknown | null, AnalysisError>;
|
||||
updateUnknown(
|
||||
input: UpdateUnknownInput,
|
||||
): Result<ResidualUnknown, AnalysisError>;
|
||||
listUnknowns(filters?: {
|
||||
readonly status?: UnknownStatus;
|
||||
readonly severity?: ResidualUnknown["severity"];
|
||||
readonly domain?: string;
|
||||
}): ResidualUnknown[];
|
||||
verifyUnknownResolution(unknownId: string): Result<
|
||||
{
|
||||
readonly valid: boolean;
|
||||
readonly truthVerified: boolean;
|
||||
readonly unknown: ResidualUnknown;
|
||||
},
|
||||
UnknownRegistryError
|
||||
>;
|
||||
providerIdentity(operation?: AnalysisOperation): ProviderIdentity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the single active target shared by CLI and MCP adapters.
|
||||
*
|
||||
|
|
@ -111,8 +72,15 @@ export class BinarySession implements BinarySessionPort {
|
|||
maxRecords: 10_000,
|
||||
maxBytes: 64 * 1024 * 1024,
|
||||
});
|
||||
readonly #snapshot = new AnalysisSnapshotCache();
|
||||
readonly #snapshotsEnabled: boolean;
|
||||
#snapshotInvalidated = false;
|
||||
|
||||
constructor(readonly provider: AnalysisProvider | AnalysisClientFactory) {
|
||||
constructor(
|
||||
readonly provider: AnalysisProvider | AnalysisClientFactory,
|
||||
options: { readonly snapshotsEnabled?: boolean } = {},
|
||||
) {
|
||||
this.#snapshotsEnabled = options.snapshotsEnabled ?? true;
|
||||
this.#createClient =
|
||||
typeof provider === "function"
|
||||
? provider
|
||||
|
|
@ -185,6 +153,44 @@ export class BinarySession implements BinarySessionPort {
|
|||
return this.#evidence.import(bundle);
|
||||
}
|
||||
|
||||
/** Export immutable cached calls and session evidence for the active target. */
|
||||
exportAnalysisSnapshot(): Result<AnalysisSnapshot, AnalysisError> {
|
||||
if (!this.#snapshotsEnabled)
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
"Analysis snapshots are unavailable with custom Hopper loader arguments",
|
||||
),
|
||||
);
|
||||
if (this.#snapshotInvalidated)
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
"Analysis snapshots are unavailable after analysis metadata mutations",
|
||||
),
|
||||
);
|
||||
const target = this.#active?.target;
|
||||
return this.#snapshot.export(
|
||||
target,
|
||||
target === undefined
|
||||
? this.#evidence.export()
|
||||
: evidenceBundleForTarget(this.#evidence.export(), target.sha256),
|
||||
);
|
||||
}
|
||||
|
||||
/** Stage validated cached calls for an identical binary and merge its evidence. */
|
||||
importAnalysisSnapshot(
|
||||
snapshot: AnalysisSnapshot,
|
||||
): Result<number, AnalysisError> {
|
||||
if (!this.#snapshotsEnabled)
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
"Analysis snapshots are unavailable with custom Hopper loader arguments",
|
||||
),
|
||||
);
|
||||
return this.#snapshot.import(snapshot, this.#active?.target, (bundle) =>
|
||||
this.#evidence.import(bundle),
|
||||
);
|
||||
}
|
||||
|
||||
/** Create an approved residual unknown and immutable mutation evidence. */
|
||||
recordUnknown(
|
||||
input: RecordUnknownInput,
|
||||
|
|
@ -209,21 +215,25 @@ export class BinarySession implements BinarySessionPort {
|
|||
updateUnknown(
|
||||
input: UpdateUnknownInput,
|
||||
): Result<ResidualUnknown, AnalysisError> {
|
||||
const evidence = createEvidence(this.#active?.target, REGISTRY_PROVIDER, {
|
||||
predicateType: "rea.residual-unknown-mutation/v1",
|
||||
operation: "update_unknown",
|
||||
parameters: {
|
||||
unknown_id: input.unknown_id,
|
||||
expected_revision: input.expected_revision,
|
||||
const evidence = createEvidence(
|
||||
this.#active?.target,
|
||||
UNKNOWN_REGISTRY_PROVIDER,
|
||||
{
|
||||
predicateType: "rea.residual-unknown-mutation/v1",
|
||||
operation: "update_unknown",
|
||||
parameters: {
|
||||
unknown_id: input.unknown_id,
|
||||
expected_revision: input.expected_revision,
|
||||
},
|
||||
result: { action: "update", status: input.status },
|
||||
confidence: "derived",
|
||||
authority: "analyst-inference",
|
||||
evidenceLinks: unknownEvidenceLinks(input),
|
||||
limitations: [
|
||||
"Registry mutation evidence records analyst intent, not proof of the answer.",
|
||||
],
|
||||
},
|
||||
result: { action: "update", status: input.status },
|
||||
confidence: "derived",
|
||||
authority: "analyst-inference",
|
||||
evidenceLinks: unknownEvidenceLinks(input),
|
||||
limitations: [
|
||||
"Registry mutation evidence records analyst intent, not proof of the answer.",
|
||||
],
|
||||
});
|
||||
);
|
||||
return this.#evidence.updateUnknown(input, evidence);
|
||||
}
|
||||
|
||||
|
|
@ -259,6 +269,7 @@ export class BinarySession implements BinarySessionPort {
|
|||
options: {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly targetKind?: BinaryTarget["kind"];
|
||||
readonly snapshot?: AnalysisSnapshot;
|
||||
} = {},
|
||||
): Promise<Result<BinaryTarget, AnalysisError>> {
|
||||
return this.#serialize(async () => {
|
||||
|
|
@ -271,10 +282,30 @@ export class BinarySession implements BinarySessionPort {
|
|||
options.targetKind,
|
||||
);
|
||||
if (!parsed.ok) return parsed;
|
||||
if (
|
||||
options.snapshot !== undefined &&
|
||||
!snapshotMatchesTarget(options.snapshot.target, parsed.value)
|
||||
)
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
"Analysis snapshot target does not match the requested binary",
|
||||
),
|
||||
);
|
||||
if (this.#active === undefined && !this.#snapshot.matches(parsed.value))
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
"Analysis snapshot target does not match the requested binary",
|
||||
),
|
||||
);
|
||||
if (isAborted(options.signal))
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
if (this.#active?.target.path === parsed.value.path)
|
||||
if (this.#active?.target.path === parsed.value.path) {
|
||||
if (options.snapshot !== undefined) {
|
||||
const imported = this.importAnalysisSnapshot(options.snapshot);
|
||||
if (!imported.ok) return imported;
|
||||
}
|
||||
return ok(parsed.value);
|
||||
}
|
||||
await this.#drainCalls();
|
||||
const previous = this.#active;
|
||||
this.#active = undefined;
|
||||
|
|
@ -292,6 +323,17 @@ export class BinarySession implements BinarySessionPort {
|
|||
return err(new AnalysisCancelledError("open_binary"));
|
||||
}
|
||||
this.#active = { target: parsed.value, client };
|
||||
this.#snapshot.select(parsed.value);
|
||||
if (options.snapshot !== undefined) {
|
||||
const imported = this.importAnalysisSnapshot(options.snapshot);
|
||||
if (!imported.ok) {
|
||||
this.#active = undefined;
|
||||
await client.close();
|
||||
await this.#restore(previous);
|
||||
return imported;
|
||||
}
|
||||
}
|
||||
this.#snapshotInvalidated = false;
|
||||
return ok(parsed.value);
|
||||
});
|
||||
}
|
||||
|
|
@ -304,6 +346,8 @@ export class BinarySession implements BinarySessionPort {
|
|||
await this.#drainCalls();
|
||||
await previous?.client.close();
|
||||
this.#evidence.clear();
|
||||
this.#snapshot.clear();
|
||||
this.#snapshotInvalidated = false;
|
||||
return ok(null);
|
||||
});
|
||||
}
|
||||
|
|
@ -398,10 +442,27 @@ export class BinarySession implements BinarySessionPort {
|
|||
);
|
||||
const active = this.#active;
|
||||
if (active === undefined) return err(new NoBinaryOpenError());
|
||||
const cacheable = isSnapshotCacheable(name, capability, arguments_);
|
||||
const cached = cacheable
|
||||
? this.#snapshot.lookup(
|
||||
active.target,
|
||||
name,
|
||||
arguments_,
|
||||
capability.provider,
|
||||
)
|
||||
: undefined;
|
||||
if (cached !== undefined) return ok(cached);
|
||||
const call = active.client.execute(name, arguments_, options);
|
||||
this.#calls.add(call);
|
||||
try {
|
||||
return await call;
|
||||
const result = await call;
|
||||
if (result.ok && cacheable)
|
||||
this.#snapshot.record(active.target, name, arguments_, result.value);
|
||||
else if (result.ok && capability?.effects.mutatesArtifact === true) {
|
||||
this.#snapshot.clear();
|
||||
this.#snapshotInvalidated = true;
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
this.#calls.delete(call);
|
||||
}
|
||||
|
|
@ -469,41 +530,5 @@ export class BinarySession implements BinarySessionPort {
|
|||
}
|
||||
}
|
||||
|
||||
const unknownMutationEvidence = (
|
||||
target: BinaryTarget | undefined,
|
||||
input: RecordUnknownInput,
|
||||
): Evidence =>
|
||||
createEvidence(target, REGISTRY_PROVIDER, {
|
||||
predicateType: "rea.residual-unknown-mutation/v1",
|
||||
operation: "record_unknown",
|
||||
parameters: {
|
||||
domain: input.domain,
|
||||
severity: input.severity,
|
||||
},
|
||||
result: {
|
||||
action: "record",
|
||||
question: input.question,
|
||||
required_authority: input.required_authority,
|
||||
required_confidence: input.required_confidence,
|
||||
},
|
||||
confidence: "derived",
|
||||
authority: "analyst-inference",
|
||||
evidenceLinks: unknownEvidenceLinks(input),
|
||||
limitations: [
|
||||
"Registry mutation evidence records analyst intent, not proof of the answer.",
|
||||
],
|
||||
});
|
||||
|
||||
const unknownEvidenceLinks = (
|
||||
input: RecordUnknownInput | UpdateUnknownInput,
|
||||
): string[] =>
|
||||
[
|
||||
...input.supporting_evidence_ids,
|
||||
...input.contradicting_evidence_ids,
|
||||
...("resolution" in input && input.resolution !== null
|
||||
? input.resolution.evidence_ids
|
||||
: []),
|
||||
].filter((id, index, values) => values.indexOf(id) === index);
|
||||
|
||||
const isAborted = (signal: AbortSignal | undefined): boolean =>
|
||||
signal?.aborted === true;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import type { EvidenceBundle } from "../domain/evidenceBundle.js";
|
||||
import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js";
|
||||
import type {
|
||||
AnalysisError,
|
||||
EvidenceIntegrityError,
|
||||
EvidenceLimitError,
|
||||
UnknownRegistryError,
|
||||
} from "../domain/errors.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import type {
|
||||
RecordUnknownInput,
|
||||
ResidualUnknown,
|
||||
UnknownStatus,
|
||||
UpdateUnknownInput,
|
||||
} from "../domain/residualUnknown.js";
|
||||
import type {
|
||||
AnalysisOperation,
|
||||
AnalysisOperationPort,
|
||||
ProviderIdentity,
|
||||
} from "./AnalysisProvider.js";
|
||||
|
||||
/** Target lifecycle used by CLI and MCP without exposing a concrete provider. */
|
||||
export interface BinarySessionPort extends AnalysisOperationPort {
|
||||
open(
|
||||
path: string,
|
||||
options?: {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly targetKind?: BinaryTarget["kind"];
|
||||
readonly snapshot?: AnalysisSnapshot;
|
||||
},
|
||||
): Promise<Result<BinaryTarget, AnalysisError>>;
|
||||
close(): Promise<Result<null, AnalysisError>>;
|
||||
status(): JsonValue;
|
||||
activeTarget(): BinaryTarget | undefined;
|
||||
recordEvidence(
|
||||
evidence: Evidence,
|
||||
): Result<"added" | "duplicate", EvidenceIntegrityError | EvidenceLimitError>;
|
||||
hasEvidence(evidenceId: string): boolean;
|
||||
evidenceById(evidenceId: string): Evidence | undefined;
|
||||
exportEvidenceBundle(): EvidenceBundle;
|
||||
importEvidenceBundle(
|
||||
bundle: unknown,
|
||||
): Result<number, EvidenceIntegrityError | EvidenceLimitError>;
|
||||
exportAnalysisSnapshot(): Result<AnalysisSnapshot, AnalysisError>;
|
||||
importAnalysisSnapshot(
|
||||
snapshot: AnalysisSnapshot,
|
||||
): Result<number, AnalysisError>;
|
||||
recordUnknown(
|
||||
input: RecordUnknownInput,
|
||||
): Result<ResidualUnknown, AnalysisError>;
|
||||
recordEvidenceWithUnknown(
|
||||
evidence: Evidence,
|
||||
input: RecordUnknownInput,
|
||||
): Result<ResidualUnknown | null, AnalysisError>;
|
||||
updateUnknown(
|
||||
input: UpdateUnknownInput,
|
||||
): Result<ResidualUnknown, AnalysisError>;
|
||||
listUnknowns(filters?: {
|
||||
readonly status?: UnknownStatus;
|
||||
readonly severity?: ResidualUnknown["severity"];
|
||||
readonly domain?: string;
|
||||
}): ResidualUnknown[];
|
||||
verifyUnknownResolution(unknownId: string): Result<
|
||||
{
|
||||
readonly valid: boolean;
|
||||
readonly truthVerified: boolean;
|
||||
readonly unknown: ResidualUnknown;
|
||||
},
|
||||
UnknownRegistryError
|
||||
>;
|
||||
providerIdentity(operation?: AnalysisOperation): ProviderIdentity;
|
||||
}
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
import { lstat, readFile, realpath } from "node:fs/promises";
|
||||
import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
|
||||
|
||||
import writeFileAtomic from "write-file-atomic";
|
||||
|
||||
import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js";
|
||||
import { EvidenceFileError } from "../domain/errors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
/** Read bounded JSON data from a regular file beneath an approved root. */
|
||||
export const readBoundedJson = async (
|
||||
path: string,
|
||||
policy: EvidenceFilePolicy,
|
||||
): Promise<Result<unknown, EvidenceFileError>> => {
|
||||
if (policy.roots.length === 0)
|
||||
return err(new EvidenceFileError("read", "disabled"));
|
||||
try {
|
||||
const canonicalPath = await realpath(resolve(path));
|
||||
if (!(await isApproved(canonicalPath, policy.roots)))
|
||||
return err(new EvidenceFileError("read", "outside-root"));
|
||||
const stats = await lstat(canonicalPath);
|
||||
if (!stats.isFile()) return err(new EvidenceFileError("read", "not-file"));
|
||||
if (stats.size > policy.maxBytes)
|
||||
return err(new EvidenceFileError("read", "too-large"));
|
||||
const encoded = await readFile(canonicalPath);
|
||||
if (encoded.byteLength > policy.maxBytes)
|
||||
return err(new EvidenceFileError("read", "too-large"));
|
||||
let decoded: unknown;
|
||||
try {
|
||||
decoded = JSON.parse(encoded.toString("utf8"));
|
||||
} catch (cause: unknown) {
|
||||
return err(new EvidenceFileError("read", "invalid-json", { cause }));
|
||||
}
|
||||
const limits = validateJsonLimits(decoded, policy);
|
||||
return limits.ok ? ok(decoded) : limits;
|
||||
} catch (cause: unknown) {
|
||||
return err(new EvidenceFileError("read", "io", { cause }));
|
||||
}
|
||||
};
|
||||
|
||||
/** Atomically write bounded text to a regular file beneath an approved root. */
|
||||
export const writeBoundedText = async (
|
||||
encoded: string,
|
||||
path: string,
|
||||
overwrite: boolean,
|
||||
policy: EvidenceFilePolicy,
|
||||
): Promise<
|
||||
Result<{ readonly path: string; readonly bytes: number }, EvidenceFileError>
|
||||
> => {
|
||||
if (policy.roots.length === 0)
|
||||
return err(new EvidenceFileError("write", "disabled"));
|
||||
const bytes = Buffer.byteLength(encoded, "utf8");
|
||||
if (bytes > policy.maxBytes)
|
||||
return err(new EvidenceFileError("write", "too-large"));
|
||||
try {
|
||||
const requestedPath = resolve(path);
|
||||
const canonicalParent = await realpath(dirname(requestedPath));
|
||||
const destination = resolve(canonicalParent, basename(requestedPath));
|
||||
if (!(await isApproved(destination, policy.roots)))
|
||||
return err(new EvidenceFileError("write", "outside-root"));
|
||||
const existing = await lstat(destination).catch((cause: unknown) => {
|
||||
if (isFileNotFound(cause)) return undefined;
|
||||
throw cause;
|
||||
});
|
||||
if (existing !== undefined) {
|
||||
if (!overwrite) return err(new EvidenceFileError("write", "exists"));
|
||||
if (!existing.isFile() || existing.isSymbolicLink())
|
||||
return err(new EvidenceFileError("write", "not-file"));
|
||||
}
|
||||
await writeFileAtomic(destination, encoded, {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
fsync: true,
|
||||
});
|
||||
return ok({ path: requestedPath, bytes });
|
||||
} catch (cause: unknown) {
|
||||
return err(new EvidenceFileError("write", "io", { cause }));
|
||||
}
|
||||
};
|
||||
|
||||
const isApproved = async (
|
||||
candidate: string,
|
||||
roots: readonly string[],
|
||||
): Promise<boolean> => {
|
||||
for (const configuredRoot of roots) {
|
||||
const root = await realpath(resolve(configuredRoot));
|
||||
const relation = relative(root, candidate);
|
||||
if (
|
||||
relation === "" ||
|
||||
(!relation.startsWith("..") && !isAbsolute(relation))
|
||||
)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const validateJsonLimits = (
|
||||
root: unknown,
|
||||
policy: EvidenceFilePolicy,
|
||||
): Result<null, EvidenceFileError> => {
|
||||
const pending: Array<{ readonly value: unknown; readonly depth: number }> = [
|
||||
{ value: root, depth: 0 },
|
||||
];
|
||||
let nodes = 0;
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
if (current === undefined) break;
|
||||
nodes += 1;
|
||||
if (nodes > policy.maxNodes || current.depth > policy.maxDepth)
|
||||
return err(new EvidenceFileError("read", "too-large"));
|
||||
if (
|
||||
typeof current.value === "string" &&
|
||||
current.value.length > policy.maxStringLength
|
||||
)
|
||||
return err(new EvidenceFileError("read", "too-large"));
|
||||
if (typeof current.value !== "object" || current.value === null) continue;
|
||||
for (const [key, value] of Object.entries(current.value)) {
|
||||
if (key.length > policy.maxStringLength)
|
||||
return err(new EvidenceFileError("read", "too-large"));
|
||||
pending.push({ value, depth: current.depth + 1 });
|
||||
}
|
||||
}
|
||||
return ok(null);
|
||||
};
|
||||
|
||||
const isFileNotFound = (cause: unknown): boolean =>
|
||||
typeof cause === "object" &&
|
||||
cause !== null &&
|
||||
"code" in cause &&
|
||||
cause.code === "ENOENT";
|
||||
|
|
@ -4,9 +4,26 @@ import { EnhancedTools } from "./EnhancedTools.js";
|
|||
import { createBinarySession } from "./runtime.js";
|
||||
import { silentLogger, type Logger } from "../logger.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import type { NativeToolName } from "../contracts/nativeToolContracts.js";
|
||||
import type { ArtifactToolName } from "../contracts/artifactToolContracts.js";
|
||||
import { projectAnalysisError, type AnalysisError } from "../domain/errors.js";
|
||||
import {
|
||||
EvidenceIntegrityError,
|
||||
projectAnalysisError,
|
||||
type AnalysisError,
|
||||
} from "../domain/errors.js";
|
||||
import { access } from "node:fs/promises";
|
||||
import {
|
||||
readAnalysisSnapshot,
|
||||
writeAnalysisSnapshot,
|
||||
} from "./AnalysisSnapshotFiles.js";
|
||||
import { parseBinaryTarget } from "../domain/binaryTarget.js";
|
||||
import {
|
||||
snapshotEvidenceForQuery,
|
||||
snapshotMatchesTarget,
|
||||
} from "../domain/analysisSnapshot.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js";
|
||||
|
||||
const WORKFLOW_PROVIDER = {
|
||||
id: "rea-workflow",
|
||||
|
|
@ -32,8 +49,15 @@ export const runDirectAnalysis = async (
|
|||
path: string,
|
||||
tool: DirectAnalysisTool,
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
logger: Logger = silentLogger,
|
||||
): Promise<JsonValue> => runAnalysis(path, tool, arguments_, logger);
|
||||
options: {
|
||||
readonly logger?: Logger;
|
||||
readonly snapshotPath?: string | undefined;
|
||||
} = {},
|
||||
): Promise<JsonValue> =>
|
||||
runAnalysis(path, tool, arguments_, {
|
||||
logger: options.logger ?? silentLogger,
|
||||
snapshotPath: options.snapshotPath,
|
||||
});
|
||||
|
||||
/** Execute one provider-native semantic operation with atomic provenance. */
|
||||
export const runProviderAnalysis = async (
|
||||
|
|
@ -41,7 +65,8 @@ export const runProviderAnalysis = async (
|
|||
tool: NativeToolName | ArtifactToolName,
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
logger: Logger = silentLogger,
|
||||
): Promise<JsonValue> => runAnalysis(path, tool, arguments_, logger);
|
||||
): Promise<JsonValue> =>
|
||||
runAnalysis(path, tool, arguments_, { logger, snapshotPath: undefined });
|
||||
|
||||
/** Describe configured providers without opening a target or launching Hopper. */
|
||||
export const runSessionStatus = async (
|
||||
|
|
@ -61,39 +86,67 @@ const runAnalysis = async (
|
|||
path: string,
|
||||
tool: NativeToolName | ArtifactToolName | DirectAnalysisTool,
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
logger: Logger,
|
||||
options: {
|
||||
readonly logger: Logger;
|
||||
readonly snapshotPath: string | undefined;
|
||||
},
|
||||
): Promise<JsonValue> => {
|
||||
const { logger, snapshotPath } = options;
|
||||
const config = parseConfig(process.env);
|
||||
if (!config.ok) return cliError(config.error);
|
||||
const unavailable = snapshotUnavailable(
|
||||
snapshotPath,
|
||||
config.value.hopperLoaderArgs,
|
||||
);
|
||||
if (unavailable !== undefined) return cliError(unavailable);
|
||||
const session = createBinarySession(config.value, logger);
|
||||
try {
|
||||
const opened = await session.open(path);
|
||||
const prepared = await prepareSnapshot({
|
||||
path,
|
||||
tool,
|
||||
arguments: arguments_,
|
||||
snapshotPath,
|
||||
policy: config.value.analysisSnapshotFilePolicy,
|
||||
provider: replayProviderFor(session, tool),
|
||||
});
|
||||
if (!prepared.ok) return cliError(prepared.error);
|
||||
if (prepared.value.evidence !== undefined) return prepared.value.evidence;
|
||||
const { snapshot } = prepared.value;
|
||||
const opened = await session.open(
|
||||
path,
|
||||
snapshot === undefined ? {} : { snapshot },
|
||||
);
|
||||
if (!opened.ok) return cliError(opened.error);
|
||||
let output: JsonValue;
|
||||
let evidence: Evidence | undefined;
|
||||
if (
|
||||
tool === "binary_overview" ||
|
||||
tool === "analyze_function" ||
|
||||
tool === "trace_feature"
|
||||
) {
|
||||
const result = await new EnhancedTools(session).execute(tool, arguments_);
|
||||
return result.ok
|
||||
? createEvidence(
|
||||
opened.value,
|
||||
tool === "analyze_function"
|
||||
? session.providerIdentity(tool)
|
||||
: WORKFLOW_PROVIDER,
|
||||
{
|
||||
operation: tool,
|
||||
parameters: arguments_,
|
||||
result: result.value,
|
||||
confidence: "derived",
|
||||
limitations: ["Derived by an REA composed workflow."],
|
||||
},
|
||||
)
|
||||
: cliError(result.error);
|
||||
}
|
||||
const result = await session.execute(tool, arguments_);
|
||||
return result.ok
|
||||
? createEvidence(
|
||||
if (!result.ok) output = cliError(result.error);
|
||||
else {
|
||||
evidence = createEvidence(
|
||||
opened.value,
|
||||
tool === "analyze_function"
|
||||
? session.providerIdentity(tool)
|
||||
: WORKFLOW_PROVIDER,
|
||||
{
|
||||
operation: tool,
|
||||
parameters: arguments_,
|
||||
result: result.value,
|
||||
confidence: "derived",
|
||||
limitations: ["Derived by an REA composed workflow."],
|
||||
},
|
||||
);
|
||||
output = evidence;
|
||||
}
|
||||
} else {
|
||||
const result = await session.execute(tool, arguments_);
|
||||
if (!result.ok) output = cliError(result.error);
|
||||
else {
|
||||
evidence = createEvidence(
|
||||
result.value.subject ?? opened.value,
|
||||
result.value.provider,
|
||||
{
|
||||
|
|
@ -104,13 +157,108 @@ const runAnalysis = async (
|
|||
limitations: result.value.limitations,
|
||||
locations: result.value.locations,
|
||||
},
|
||||
)
|
||||
: cliError(result.error);
|
||||
);
|
||||
output = evidence;
|
||||
}
|
||||
}
|
||||
if (evidence !== undefined) session.recordEvidence(evidence);
|
||||
if (snapshotPath !== undefined && evidence !== undefined) {
|
||||
const snapshot = session.exportAnalysisSnapshot();
|
||||
if (!snapshot.ok) return cliError(snapshot.error);
|
||||
const written = await writeAnalysisSnapshot(
|
||||
snapshot.value,
|
||||
snapshotPath,
|
||||
true,
|
||||
config.value.analysisSnapshotFilePolicy,
|
||||
);
|
||||
if (!written.ok) return cliError(written.error);
|
||||
}
|
||||
return output;
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
};
|
||||
|
||||
const prepareSnapshot = async (options: {
|
||||
readonly path: string;
|
||||
readonly tool: string;
|
||||
readonly arguments: Readonly<Record<string, JsonValue>>;
|
||||
readonly snapshotPath: string | undefined;
|
||||
readonly policy: Parameters<typeof readAnalysisSnapshot>[1];
|
||||
readonly provider: Parameters<typeof snapshotEvidenceForQuery>[1]["provider"];
|
||||
}): Promise<
|
||||
Result<
|
||||
{ readonly snapshot?: AnalysisSnapshot; readonly evidence?: Evidence },
|
||||
AnalysisError
|
||||
>
|
||||
> => {
|
||||
const {
|
||||
path,
|
||||
tool,
|
||||
arguments: arguments_,
|
||||
snapshotPath,
|
||||
policy,
|
||||
provider,
|
||||
} = options;
|
||||
if (snapshotPath === undefined || !(await fileExists(snapshotPath)))
|
||||
return ok({});
|
||||
const loaded = await readAnalysisSnapshot(snapshotPath, policy);
|
||||
if (!loaded.ok) return loaded;
|
||||
const target = await parseBinaryTarget(path);
|
||||
if (!target.ok) return target;
|
||||
if (!snapshotMatchesTarget(loaded.value.target, target.value))
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
"Analysis snapshot target does not match the requested binary",
|
||||
),
|
||||
);
|
||||
const evidence = snapshotEvidenceForQuery(loaded.value, {
|
||||
target: target.value,
|
||||
operation: tool,
|
||||
parameters: arguments_,
|
||||
provider,
|
||||
});
|
||||
return ok(
|
||||
evidence === undefined
|
||||
? { snapshot: loaded.value }
|
||||
: { snapshot: loaded.value, evidence },
|
||||
);
|
||||
};
|
||||
|
||||
const snapshotUnavailable = (
|
||||
snapshotPath: string | undefined,
|
||||
loaderArgs: readonly string[],
|
||||
): EvidenceIntegrityError | undefined =>
|
||||
snapshotPath !== undefined && loaderArgs.length > 0
|
||||
? new EvidenceIntegrityError(
|
||||
"Analysis snapshots are unavailable with custom Hopper loader arguments",
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const replayProviderFor = (
|
||||
session: ReturnType<typeof createBinarySession>,
|
||||
tool: NativeToolName | ArtifactToolName | DirectAnalysisTool,
|
||||
) =>
|
||||
tool === "binary_overview" || tool === "trace_feature"
|
||||
? WORKFLOW_PROVIDER
|
||||
: session.providerIdentity(tool);
|
||||
|
||||
const fileExists = async (path: string): Promise<boolean> => {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
typeof cause === "object" &&
|
||||
cause !== null &&
|
||||
"code" in cause &&
|
||||
cause.code === "ENOENT"
|
||||
)
|
||||
return false;
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
const cliError = (error: AnalysisError): JsonValue => ({
|
||||
error: "Analysis failed",
|
||||
...projectAnalysisError(error),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,3 @@
|
|||
import { lstat, readFile, realpath } from "node:fs/promises";
|
||||
import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
|
||||
|
||||
import writeFileAtomic from "write-file-atomic";
|
||||
|
||||
import {
|
||||
parseEvidenceBundle,
|
||||
serializeEvidenceBundle,
|
||||
|
|
@ -15,6 +10,7 @@ import {
|
|||
LEGACY_PROCESS_CAPTURE_MESSAGE,
|
||||
parseProcessCapture,
|
||||
} from "../domain/processCapture.js";
|
||||
import { readBoundedJson, writeBoundedText } from "./BoundedJsonFiles.js";
|
||||
|
||||
type EvidenceReadFailure = EvidenceFileError | EvidenceIntegrityError;
|
||||
type EvidenceWriteFailure = EvidenceFileError | EvidenceIntegrityError;
|
||||
|
|
@ -26,43 +22,23 @@ export const readEvidenceBundle = async (
|
|||
): Promise<Result<EvidenceBundle, EvidenceReadFailure>> => {
|
||||
if (policy.roots.length === 0)
|
||||
return err(new EvidenceFileError("read", "disabled"));
|
||||
const loaded = await readBoundedJson(path, policy);
|
||||
if (!loaded.ok) return loaded;
|
||||
try {
|
||||
const canonicalPath = await realpath(resolve(path));
|
||||
if (!(await isApproved(canonicalPath, policy.roots)))
|
||||
return err(new EvidenceFileError("read", "outside-root"));
|
||||
const stats = await lstat(canonicalPath);
|
||||
if (!stats.isFile()) return err(new EvidenceFileError("read", "not-file"));
|
||||
if (stats.size > policy.maxBytes)
|
||||
return err(new EvidenceFileError("read", "too-large"));
|
||||
const encoded = await readFile(canonicalPath);
|
||||
if (encoded.byteLength > policy.maxBytes)
|
||||
return err(new EvidenceFileError("read", "too-large"));
|
||||
let decoded: unknown;
|
||||
try {
|
||||
decoded = JSON.parse(encoded.toString("utf8"));
|
||||
} catch (cause: unknown) {
|
||||
return err(new EvidenceFileError("read", "invalid-json", { cause }));
|
||||
}
|
||||
const limits = validateJsonLimits(decoded, policy);
|
||||
if (!limits.ok) return limits;
|
||||
try {
|
||||
const bundle = parseEvidenceBundle(decoded);
|
||||
for (const record of bundle.records) {
|
||||
if (record.predicate_type === "rea.process-capture/v3")
|
||||
throw new TypeError(LEGACY_PROCESS_CAPTURE_MESSAGE);
|
||||
if (record.predicate_type === "rea.process-capture/v4")
|
||||
parseProcessCapture(record.normalized_result);
|
||||
}
|
||||
return ok(bundle);
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new EvidenceIntegrityError("Evidence bundle validation failed", {
|
||||
cause,
|
||||
}),
|
||||
);
|
||||
const bundle = parseEvidenceBundle(loaded.value);
|
||||
for (const record of bundle.records) {
|
||||
if (record.predicate_type === "rea.process-capture/v3")
|
||||
throw new TypeError(LEGACY_PROCESS_CAPTURE_MESSAGE);
|
||||
if (record.predicate_type === "rea.process-capture/v4")
|
||||
parseProcessCapture(record.normalized_result);
|
||||
}
|
||||
return ok(bundle);
|
||||
} catch (cause: unknown) {
|
||||
return err(new EvidenceFileError("read", "io", { cause }));
|
||||
return err(
|
||||
new EvidenceIntegrityError("Evidence bundle validation failed", {
|
||||
cause,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -90,82 +66,5 @@ export const writeEvidenceBundle = async (
|
|||
}),
|
||||
);
|
||||
}
|
||||
const bytes = Buffer.byteLength(encoded, "utf8");
|
||||
if (bytes > policy.maxBytes)
|
||||
return err(new EvidenceFileError("write", "too-large"));
|
||||
try {
|
||||
const requestedPath = resolve(path);
|
||||
const canonicalParent = await realpath(dirname(requestedPath));
|
||||
const destination = resolve(canonicalParent, basename(requestedPath));
|
||||
if (!(await isApproved(destination, policy.roots)))
|
||||
return err(new EvidenceFileError("write", "outside-root"));
|
||||
const existing = await lstat(destination).catch((cause: unknown) => {
|
||||
if (isFileNotFound(cause)) return undefined;
|
||||
throw cause;
|
||||
});
|
||||
if (existing !== undefined) {
|
||||
if (!overwrite) return err(new EvidenceFileError("write", "exists"));
|
||||
if (!existing.isFile() || existing.isSymbolicLink())
|
||||
return err(new EvidenceFileError("write", "not-file"));
|
||||
}
|
||||
await writeFileAtomic(destination, encoded, {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
fsync: true,
|
||||
});
|
||||
return ok({ path: requestedPath, bytes });
|
||||
} catch (cause: unknown) {
|
||||
return err(new EvidenceFileError("write", "io", { cause }));
|
||||
}
|
||||
return writeBoundedText(encoded, path, overwrite, policy);
|
||||
};
|
||||
|
||||
const isApproved = async (
|
||||
candidate: string,
|
||||
roots: readonly string[],
|
||||
): Promise<boolean> => {
|
||||
for (const configuredRoot of roots) {
|
||||
const root = await realpath(resolve(configuredRoot));
|
||||
const relation = relative(root, candidate);
|
||||
if (
|
||||
relation === "" ||
|
||||
(!relation.startsWith("..") && !isAbsolute(relation))
|
||||
)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const validateJsonLimits = (
|
||||
root: unknown,
|
||||
policy: EvidenceFilePolicy,
|
||||
): Result<null, EvidenceFileError> => {
|
||||
const pending: Array<{ readonly value: unknown; readonly depth: number }> = [
|
||||
{ value: root, depth: 0 },
|
||||
];
|
||||
let nodes = 0;
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
if (current === undefined) break;
|
||||
nodes += 1;
|
||||
if (nodes > policy.maxNodes || current.depth > policy.maxDepth)
|
||||
return err(new EvidenceFileError("read", "too-large"));
|
||||
if (
|
||||
typeof current.value === "string" &&
|
||||
current.value.length > policy.maxStringLength
|
||||
)
|
||||
return err(new EvidenceFileError("read", "too-large"));
|
||||
if (typeof current.value !== "object" || current.value === null) continue;
|
||||
for (const [key, value] of Object.entries(current.value)) {
|
||||
if (key.length > policy.maxStringLength)
|
||||
return err(new EvidenceFileError("read", "too-large"));
|
||||
pending.push({ value, depth: current.depth + 1 });
|
||||
}
|
||||
}
|
||||
return ok(null);
|
||||
};
|
||||
|
||||
const isFileNotFound = (cause: unknown): boolean =>
|
||||
typeof cause === "object" &&
|
||||
cause !== null &&
|
||||
"code" in cause &&
|
||||
cause.code === "ENOENT";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -21,5 +21,6 @@ export const createBinarySession = (
|
|||
new NativeMacOSProvider(),
|
||||
new HopperProvider(config, logger),
|
||||
]),
|
||||
{ snapshotsEnabled: config.hopperLoaderArgs.length === 0 },
|
||||
);
|
||||
};
|
||||
|
|
|
|||
28
src/cli.ts
28
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 },
|
||||
),
|
||||
),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import type { z } from "zod";
|
||||
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
|
||||
/** Caller-visible MCP execution hints. */
|
||||
export interface ToolAnnotations {
|
||||
readonly readOnlyHint: boolean;
|
||||
readonly destructiveHint: boolean;
|
||||
readonly idempotentHint: boolean;
|
||||
readonly openWorldHint: boolean;
|
||||
}
|
||||
|
||||
/** One validated example request for an MCP tool contract. */
|
||||
export interface ToolExample {
|
||||
readonly title: string;
|
||||
readonly input: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
|
||||
/** Adapter family responsible for implementing a public MCP tool. */
|
||||
export type ToolKind =
|
||||
| "official-proxy"
|
||||
| "enhanced"
|
||||
| "native-provider"
|
||||
| "artifact-provider"
|
||||
| "session";
|
||||
|
||||
/** Single source of truth for a public MCP tool. */
|
||||
export interface ToolContract<Name extends string = string> {
|
||||
readonly name: Name;
|
||||
readonly description: string;
|
||||
readonly kind: ToolKind;
|
||||
readonly inputSchema: z.ZodObject;
|
||||
readonly outputSchema: z.ZodObject;
|
||||
readonly annotations: ToolAnnotations;
|
||||
readonly examples: readonly ToolExample[];
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { z } from "zod";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import {
|
||||
processCaptureSchema,
|
||||
processScenarioSchema,
|
||||
|
|
@ -26,6 +26,22 @@ import {
|
|||
import { TOOL_EXAMPLE_OVERRIDES } from "./toolContractExamples.js";
|
||||
import { NATIVE_TOOL_CONTRACTS } from "./nativeToolContracts.js";
|
||||
import { ARTIFACT_TOOL_CONTRACTS } from "./artifactToolContracts.js";
|
||||
import {
|
||||
closeBinaryInputSchema,
|
||||
openBinaryInputSchema,
|
||||
} from "./sessionLifecycleInputs.js";
|
||||
import type {
|
||||
ToolAnnotations,
|
||||
ToolContract,
|
||||
ToolExample,
|
||||
ToolKind,
|
||||
} from "./toolContractTypes.js";
|
||||
|
||||
export type {
|
||||
ToolAnnotations,
|
||||
ToolContract,
|
||||
ToolExample,
|
||||
} from "./toolContractTypes.js";
|
||||
|
||||
const document = z.string().optional().describe("The document name");
|
||||
const address = z.string().describe("A Hopper address");
|
||||
|
|
@ -53,37 +69,6 @@ const searchInput = {
|
|||
document,
|
||||
};
|
||||
|
||||
export interface ToolAnnotations {
|
||||
readonly readOnlyHint: boolean;
|
||||
readonly destructiveHint: boolean;
|
||||
readonly idempotentHint: boolean;
|
||||
readonly openWorldHint: boolean;
|
||||
}
|
||||
|
||||
export interface ToolExample {
|
||||
readonly title: string;
|
||||
readonly input: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
|
||||
/** Adapter family responsible for implementing a public MCP tool. */
|
||||
type ToolKind =
|
||||
| "official-proxy"
|
||||
| "enhanced"
|
||||
| "native-provider"
|
||||
| "artifact-provider"
|
||||
| "session";
|
||||
|
||||
/** Single source of truth for a public tool's name, description, and input. */
|
||||
export interface ToolContract<Name extends string = string> {
|
||||
readonly name: Name;
|
||||
readonly description: string;
|
||||
readonly kind: ToolKind;
|
||||
readonly inputSchema: z.ZodObject;
|
||||
readonly outputSchema: z.ZodObject;
|
||||
readonly annotations: ToolAnnotations;
|
||||
readonly examples: readonly ToolExample[];
|
||||
}
|
||||
|
||||
const exampleInputSchema = z.record(z.string(), jsonValueSchema);
|
||||
const examplesFor = (
|
||||
name: string,
|
||||
|
|
@ -106,12 +91,16 @@ const annotations = (name: string, kind: ToolKind): ToolAnnotations => ({
|
|||
name === "verify_unknown_resolution",
|
||||
destructiveHint:
|
||||
name === "export_evidence_bundle" ||
|
||||
name === "close_binary" ||
|
||||
name === "unset_bookmark" ||
|
||||
name === "set_address_name" ||
|
||||
name === "set_addresses_names" ||
|
||||
name === "set_comment" ||
|
||||
name === "set_inline_comment",
|
||||
idempotentHint: name !== "record_unknown" && name !== "update_unknown",
|
||||
idempotentHint:
|
||||
name !== "close_binary" &&
|
||||
name !== "record_unknown" &&
|
||||
name !== "update_unknown",
|
||||
openWorldHint: name === "capture_process_scenario",
|
||||
});
|
||||
|
||||
|
|
@ -404,13 +393,13 @@ export const ENHANCED_TOOL_CONTRACTS = [
|
|||
export const SESSION_TOOL_CONTRACTS = [
|
||||
session(
|
||||
"open_binary",
|
||||
"Open a local executable, application bundle, archive, JavaScript, source map, plist, or Hopper database after validation. Providers start lazily: inventory_artifact does not launch Hopper; deep native operations may show Hopper UI.",
|
||||
z.object({ path: z.string().min(1) }),
|
||||
"Open a local executable, application bundle, archive, JavaScript, source map, plist, or Hopper database after validation. An optional provider-neutral snapshot is imported atomically and must match the binary digest, format, and architecture exactly; provider resources may still start before an MCP cache replay.",
|
||||
openBinaryInputSchema,
|
||||
),
|
||||
session(
|
||||
"close_binary",
|
||||
"Close the active target and every provider resource started for it. The operation is idempotent; call binary_session to verify the session is closed.",
|
||||
z.object({}),
|
||||
"Optionally write a provider-neutral analysis snapshot atomically, then close the active target and every provider resource started for it. Snapshot files require an operator-approved root and explicit overwrite; a failed save leaves the session open so cached analysis is not lost.",
|
||||
closeBinaryInputSchema,
|
||||
),
|
||||
session(
|
||||
"binary_session",
|
||||
|
|
|
|||
|
|
@ -385,7 +385,16 @@ export const sessionOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
|
|||
architecture: z.enum(["x86", "x86_64", "arm", "arm64"]).nullable(),
|
||||
}),
|
||||
),
|
||||
close_binary: lifecycleResultOf(z.null()),
|
||||
close_binary: lifecycleResultOf(
|
||||
z.union([
|
||||
z.null(),
|
||||
z.object({
|
||||
path: z.string(),
|
||||
bytes: z.number().int().min(0),
|
||||
entries: z.number().int().min(0),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
binary_session: lifecycleResultOf(
|
||||
z.union([
|
||||
sessionProvider.extend({ open: z.literal(false) }),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,226 @@
|
|||
import { createHash } from "node:crypto";
|
||||
|
||||
import canonicalize from "canonicalize";
|
||||
import { z } from "zod";
|
||||
|
||||
import type {
|
||||
AnalysisExecution,
|
||||
AnalysisOperation,
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import type { BinaryTarget } from "./binaryTarget.js";
|
||||
import {
|
||||
evidenceBundleForTarget,
|
||||
evidenceBundleSchema,
|
||||
parseEvidenceBundle,
|
||||
} from "./evidenceBundle.js";
|
||||
import {
|
||||
evidenceLocationSchema,
|
||||
providerSchema,
|
||||
type Evidence,
|
||||
} from "./evidence.js";
|
||||
import {
|
||||
jsonObjectSchema,
|
||||
jsonValueSchema,
|
||||
type JsonValue,
|
||||
} from "./jsonValue.js";
|
||||
|
||||
const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
const architectureSchema = z.enum(["x86", "x86_64", "arm", "arm64"]);
|
||||
const formatSchema = z.enum([
|
||||
"hopper",
|
||||
"mach-o",
|
||||
"elf",
|
||||
"pe",
|
||||
"zip",
|
||||
"ipa",
|
||||
"apk",
|
||||
"asar",
|
||||
"dmg",
|
||||
"pkg",
|
||||
"plist",
|
||||
"javascript",
|
||||
"source-map",
|
||||
]);
|
||||
const subjectSchema = z.object({
|
||||
path: z.string().min(1),
|
||||
sha256: digestSchema,
|
||||
format: z.enum([
|
||||
...formatSchema.options,
|
||||
"directory",
|
||||
"file",
|
||||
"unknown",
|
||||
"mach-o-universal",
|
||||
"javascript-bundle",
|
||||
"entitlements",
|
||||
]),
|
||||
architecture: architectureSchema.nullable(),
|
||||
});
|
||||
const targetSchema = z.object({
|
||||
sha256: digestSchema,
|
||||
format: formatSchema,
|
||||
architecture: architectureSchema.nullable(),
|
||||
loader_args: z.array(z.string()),
|
||||
});
|
||||
const entrySchema = z.object({
|
||||
query_id: z.string().regex(/^query_[a-f0-9]{64}$/u),
|
||||
operation: z.string().min(1),
|
||||
parameters: jsonObjectSchema,
|
||||
execution: z.object({
|
||||
result: jsonValueSchema,
|
||||
raw_result: jsonValueSchema.nullable(),
|
||||
provider: providerSchema,
|
||||
limitations: z.array(z.string()),
|
||||
locations: z.array(evidenceLocationSchema),
|
||||
subject: subjectSchema.nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
/** Provider-neutral, deterministic cache of successful immutable analysis calls. */
|
||||
const analysisSnapshotSchema = z.object({
|
||||
snapshot_version: z.literal(1),
|
||||
target: targetSchema,
|
||||
entries: z.array(entrySchema).max(10_000),
|
||||
evidence_bundle: evidenceBundleSchema,
|
||||
});
|
||||
|
||||
export type AnalysisSnapshot = z.infer<typeof analysisSnapshotSchema>;
|
||||
export type AnalysisSnapshotEntry = z.infer<typeof entrySchema>;
|
||||
export type AnalysisSnapshotTarget = z.infer<typeof targetSchema>;
|
||||
|
||||
const canonicalJson = (value: unknown): string => {
|
||||
const json = jsonValueSchema.parse(value);
|
||||
const encoded = canonicalize(json);
|
||||
if (encoded === undefined)
|
||||
throw new TypeError("RFC 8785 canonicalization rejected snapshot data");
|
||||
return encoded;
|
||||
};
|
||||
|
||||
/** Project a binary into the path-free identity used for cache invalidation. */
|
||||
export const snapshotTarget = (
|
||||
target: BinaryTarget,
|
||||
): AnalysisSnapshotTarget => ({
|
||||
sha256: target.sha256,
|
||||
format: target.format,
|
||||
architecture: target.architecture ?? null,
|
||||
loader_args: [...target.loaderArgs],
|
||||
});
|
||||
|
||||
/** Compare an open binary with the immutable identity in a snapshot. */
|
||||
export const snapshotMatchesTarget = (
|
||||
snapshot: AnalysisSnapshotTarget,
|
||||
target: BinaryTarget,
|
||||
): boolean =>
|
||||
snapshot.sha256 === target.sha256 &&
|
||||
snapshot.format === target.format &&
|
||||
snapshot.architecture === (target.architecture ?? null) &&
|
||||
JSON.stringify(snapshot.loader_args) === JSON.stringify(target.loaderArgs);
|
||||
|
||||
/** Find exact persisted CLI evidence without starting an analysis provider. */
|
||||
export const snapshotEvidenceForQuery = (
|
||||
snapshot: AnalysisSnapshot,
|
||||
query: {
|
||||
readonly target: BinaryTarget;
|
||||
readonly operation: string;
|
||||
readonly parameters: Readonly<Record<string, JsonValue>>;
|
||||
readonly provider: AnalysisExecution["provider"];
|
||||
},
|
||||
): Evidence | undefined => {
|
||||
const { target, operation, parameters, provider } = query;
|
||||
if (!snapshotMatchesTarget(snapshot.target, target)) return undefined;
|
||||
const encodedParameters = canonicalJson(parameters);
|
||||
return snapshot.evidence_bundle.records.find(
|
||||
(record) =>
|
||||
record.subject?.digest.sha256 === target.sha256 &&
|
||||
record.operation === operation &&
|
||||
record.provider.id === provider.id &&
|
||||
record.provider.name === provider.name &&
|
||||
record.provider.version === provider.version &&
|
||||
canonicalJson(record.parameters) === encodedParameters,
|
||||
);
|
||||
};
|
||||
|
||||
/** Compute the stable lookup key for one provider-specific analysis query. */
|
||||
export const analysisQueryId = (
|
||||
target: AnalysisSnapshotTarget,
|
||||
operation: string,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
provider: AnalysisExecution["provider"],
|
||||
): string =>
|
||||
`query_${createHash("sha256")
|
||||
.update(
|
||||
canonicalJson({
|
||||
target,
|
||||
operation,
|
||||
parameters,
|
||||
provider: {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
version: provider.version,
|
||||
},
|
||||
}),
|
||||
)
|
||||
.digest("hex")}`;
|
||||
|
||||
/** Create one serializable snapshot entry from a successful provider call. */
|
||||
export const createAnalysisSnapshotEntry = (
|
||||
target: AnalysisSnapshotTarget,
|
||||
operation: AnalysisOperation,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
execution: AnalysisExecution,
|
||||
): AnalysisSnapshotEntry => ({
|
||||
query_id: analysisQueryId(target, operation, parameters, execution.provider),
|
||||
operation,
|
||||
parameters: jsonObjectSchema.parse(parameters),
|
||||
execution: {
|
||||
result: execution.result,
|
||||
raw_result: execution.rawResult,
|
||||
provider: execution.provider,
|
||||
limitations: [...execution.limitations],
|
||||
locations: [...execution.locations],
|
||||
subject:
|
||||
execution.subject === null
|
||||
? null
|
||||
: {
|
||||
...execution.subject,
|
||||
architecture: execution.subject.architecture ?? null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** Parse a snapshot and reject altered query IDs or non-canonical entry order. */
|
||||
export const parseAnalysisSnapshot = (input: unknown): AnalysisSnapshot => {
|
||||
const parsed = analysisSnapshotSchema.parse(input);
|
||||
parseEvidenceBundle(parsed.evidence_bundle);
|
||||
if (
|
||||
JSON.stringify(
|
||||
evidenceBundleForTarget(parsed.evidence_bundle, parsed.target.sha256),
|
||||
) !== JSON.stringify(parsed.evidence_bundle)
|
||||
)
|
||||
throw new TypeError(
|
||||
"Analysis snapshot evidence contains records for another target",
|
||||
);
|
||||
const ids = new Set<string>();
|
||||
for (const entry of parsed.entries) {
|
||||
const expected = analysisQueryId(
|
||||
parsed.target,
|
||||
entry.operation,
|
||||
entry.parameters,
|
||||
entry.execution.provider,
|
||||
);
|
||||
if (entry.query_id !== expected)
|
||||
throw new TypeError("Analysis snapshot query identifier does not match");
|
||||
if (ids.has(entry.query_id))
|
||||
throw new TypeError("Analysis snapshot contains duplicate queries");
|
||||
ids.add(entry.query_id);
|
||||
}
|
||||
const sorted = [...parsed.entries].sort((left, right) =>
|
||||
left.query_id.localeCompare(right.query_id),
|
||||
);
|
||||
if (JSON.stringify(parsed.entries) !== JSON.stringify(sorted))
|
||||
throw new TypeError("Analysis snapshot entries are not canonical");
|
||||
return parsed;
|
||||
};
|
||||
|
||||
/** Serialize a validated snapshot with byte-stable canonical entry ordering. */
|
||||
export const serializeAnalysisSnapshot = (snapshot: AnalysisSnapshot): string =>
|
||||
`${canonicalJson(parseAnalysisSnapshot(snapshot))}\n`;
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ export const run = async (
|
|||
logger,
|
||||
processPolicy: config.value.processExecutionPolicy,
|
||||
evidenceFilePolicy: config.value.evidenceFilePolicy,
|
||||
analysisSnapshotFilePolicy: config.value.analysisSnapshotFilePolicy,
|
||||
}),
|
||||
{
|
||||
onerror: () => {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export interface CreateServerOptions {
|
|||
readonly logger?: Logger;
|
||||
readonly processPolicy?: ProcessExecutionPolicy;
|
||||
readonly evidenceFilePolicy?: EvidenceFilePolicy;
|
||||
readonly analysisSnapshotFilePolicy?: EvidenceFilePolicy;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue