refactor(app): split session and CLI workflows

This commit is contained in:
morluto committed 2026-07-18 05:49:14 +00:00
1 parent 3e61a63a10
commit c6ee0049c4
88 files changed
+7428 -6024

No files matched your search

+1 -1
View File
@@ -226,7 +226,7 @@ const loadInventory = async (
selectedIds: ReadonlySet<string>,
signal?: AbortSignal,
): Promise<LoadedInventory> => {
const snapshot = await scanArtifactInventory(path, limits, signal);
const snapshot = await scanArtifactInventory(path, limits, { signal });
const occurrences = new Map<string, ArtifactOccurrence>();
const neededNodes = new Set<string>();
collectOccurrences(
+24 -609
View File
@@ -1,96 +1,30 @@
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { lstat, open, realpath } from "node:fs/promises";
import type { Readable } from "node:stream";
import { realpath } from "node:fs/promises";
import { AsarArtifactReader } from "../artifacts/AsarArtifactReader.js";
import {
ArtifactPathRegistry,
normalizeArtifactPath,
} from "../artifacts/ArtifactPaths.js";
import {
ArtifactReaderFailure,
type ArtifactEntry,
type ArtifactLimits,
type ArtifactReader,
} from "../artifacts/ArtifactReader.js";
import { DirectoryArtifactReader } from "../artifacts/DirectoryArtifactReader.js";
import { ZipArtifactReader } from "../artifacts/ZipArtifactReader.js";
import { MachOSliceArtifactReader } from "../artifacts/MachOSliceArtifactReader.js";
import { NativeDmgArtifactReader } from "../artifacts/NativeDmgArtifactReader.js";
import { streamChunkToBuffer } from "../artifacts/StreamBytes.js";
import type { ArtifactLimits } from "../artifacts/ArtifactReader.js";
import {
artifactInventoryResultSchema,
type ArtifactInventoryResult,
type ArtifactNode,
type IntegrityContradiction,
} from "../domain/artifactGraph.js";
import {
classifyArtifactPath,
classifyArtifactContent,
createArtifactEdges,
createArtifactNode,
createOccurrence,
createRootNode,
digestCanonical,
indexOccurrencesByPath,
materializeDirectoryNodes,
nearestParent,
pageOf,
rekeyOccurrences,
rootOccurrenceFor,
toOutputLimits,
type MutableOccurrence,
} from "./ArtifactGraphConstruction.js";
import { pageOf } from "./ArtifactGraphConstruction.js";
import { abortIfNeeded } from "./ArtifactInventory/hash.js";
import { scanCanonicalArtifactInventory } from "./ArtifactInventory/scanCanonical.js";
import type {
ArtifactIntegrityPolicy,
ArtifactInventoryOptions,
ArtifactInventorySnapshot,
ArtifactNativeMountPolicy,
InventoryPageInput,
} from "./ArtifactInventory/types.js";
interface InventoryPageInput {
readonly nodeOffset: number;
readonly nodeLimit: number;
readonly occurrenceOffset: number;
readonly occurrenceLimit: number;
readonly edgeOffset: number;
readonly edgeLimit: number;
}
export { scanCanonicalArtifactInventory } from "./ArtifactInventory/scanCanonical.js";
/** Per-request authority combined with operator-owned native mount policy. */
export interface ArtifactNativeMountPolicy {
readonly nativeMountApproved: boolean;
readonly nativeMountEnabled: boolean;
}
const NATIVE_MOUNT_DISABLED: ArtifactNativeMountPolicy = {
nativeMountApproved: false,
nativeMountEnabled: false,
};
/** Explicit caller approval bounded by operator-owned integrity policy. */
export interface ArtifactIntegrityPolicy {
readonly mode: "fail" | "record-and-continue";
readonly approved: boolean;
readonly enabled: boolean;
readonly maxMismatches: number;
}
const STRICT_INTEGRITY_POLICY: ArtifactIntegrityPolicy = {
mode: "fail",
approved: false,
enabled: false,
maxMismatches: 1,
};
/** Immutable inventory produced by one complete artifact scan. */
export interface ArtifactInventorySnapshot {
readonly manifest: ArtifactInventoryResult["manifest"];
readonly nodes: readonly ArtifactNode[];
readonly occurrences: ArtifactInventoryResult["occurrences"]["items"];
readonly edges: ArtifactInventoryResult["edges"]["items"];
readonly limits: ArtifactInventoryResult["limits"];
readonly provenance: ReadonlyArray<
ArtifactInventoryResult["provenance"][number]
>;
readonly integrity_contradictions: readonly IntegrityContradiction[];
readonly limitations: readonly string[];
}
export type {
ArtifactIntegrityPolicy,
ArtifactInventoryOptions,
ArtifactInventorySnapshot,
ArtifactNativeMountPolicy,
InventoryPageInput,
} from "./ArtifactInventory/types.js";
/** Inventory one local artifact without extracting or mounting it. */
export const inventoryArtifact = async (
@@ -103,13 +37,7 @@ export const inventoryArtifact = async (
readonly integrity?: ArtifactIntegrityPolicy;
} = {},
): Promise<ArtifactInventoryResult> => {
const snapshot = await scanArtifactInventory(
inputPath,
limits,
options.signal,
options.nativeMount ?? NATIVE_MOUNT_DISABLED,
options.integrity ?? STRICT_INTEGRITY_POLICY,
);
const snapshot = await scanArtifactInventory(inputPath, limits, options);
return paginateArtifactInventory(snapshot, page);
};
@@ -117,176 +45,11 @@ export const inventoryArtifact = async (
export const scanArtifactInventory = async (
inputPath: string,
limits: ArtifactLimits,
signal?: AbortSignal,
nativeMount: ArtifactNativeMountPolicy = NATIVE_MOUNT_DISABLED,
integrity: ArtifactIntegrityPolicy = STRICT_INTEGRITY_POLICY,
options: ArtifactInventoryOptions = {},
): Promise<ArtifactInventorySnapshot> => {
abortIfNeeded(signal);
abortIfNeeded(options.signal);
const path = await realpath(inputPath);
return scanCanonicalArtifactInventory(
path,
limits,
signal,
nativeMount,
integrity,
);
};
/** Scan one already-canonical artifact path without resolving it again. */
export const scanCanonicalArtifactInventory = async (
path: string,
limits: ArtifactLimits,
signal?: AbortSignal,
nativeMount: ArtifactNativeMountPolicy = NATIVE_MOUNT_DISABLED,
integrity: ArtifactIntegrityPolicy = STRICT_INTEGRITY_POLICY,
): Promise<ArtifactInventorySnapshot> => {
if (
integrity.mode === "record-and-continue" &&
(!integrity.approved || !integrity.enabled)
)
throw new ArtifactReaderFailure(
"unavailable",
"Integrity continuation requires explicit approval and operator policy",
);
const metadata = await lstat(path);
const rootFormat = await classifyRoot(path, metadata.isDirectory());
const rootDigest = metadata.isDirectory()
? null
: await hashReadable(createReadStream(path), limits.maxTotalBytes, signal);
const reader = await createReader(path, rootFormat, nativeMount, signal);
try {
const { nodes, occurrences, pendingContradictions } = await scanReader(
reader,
limits,
signal,
integrity,
);
materializeDirectoryNodes(occurrences, nodes);
const rootNode = createRootNode({
path,
format: rootFormat,
directory: metadata.isDirectory(),
digest: rootDigest,
occurrences,
});
nodes.set(rootNode.artifact_id, rootNode);
rekeyOccurrences(rootNode.artifact_id, occurrences);
const rootOccurrence = rootOccurrenceFor(rootNode, metadata.size);
for (const occurrence of occurrences)
if (occurrence.parent_occurrence_id === null)
occurrence.parent_occurrence_id = rootOccurrence.occurrence_id;
occurrences.unshift(rootOccurrence);
const occurrenceById = new Map(
occurrences.map((occurrence) => [occurrence.occurrence_id, occurrence]),
);
const occurrenceByPath = indexOccurrencesByPath(occurrences);
const integrityContradictions = pendingContradictions.map(
(contradiction): IntegrityContradiction => {
const occurrence = occurrenceByPath.get(contradiction.logicalPath);
if (occurrence === undefined)
throw new ArtifactReaderFailure(
"integrity",
"Integrity contradiction lost its graph occurrence",
);
const parent =
occurrence.parent_occurrence_id === null
? undefined
: occurrenceById.get(occurrence.parent_occurrence_id);
const parentArtifactId = parent?.artifact_id ?? rootNode.artifact_id;
return {
contradiction_id: `ic_${digestCanonical({
root_artifact_id: rootNode.artifact_id,
logical_path: contradiction.logicalPath,
declared_sha256: contradiction.declaredSha256,
observed_sha256: contradiction.observedSha256,
})}`,
occurrence_id: occurrence.occurrence_id,
parent_artifact_id: parentArtifactId,
logical_path: contradiction.logicalPath,
declared_sha256: contradiction.declaredSha256,
observed_sha256: contradiction.observedSha256,
entry_kind: contradiction.entryKind,
unpacked: contradiction.unpacked,
trust: "observed-untrusted",
provenance: "container-integrity-metadata-versus-observed-bytes",
limitations: [
"Observed bytes contradict declared integrity metadata and cannot support equivalence.",
],
};
},
);
const edges = createArtifactEdges(
rootNode.artifact_id,
occurrences,
reader?.provenance()[0],
);
const orderedNodes = [...nodes.values()].sort((left, right) =>
left.artifact_id.localeCompare(right.artifact_id),
);
const orderedOccurrences = occurrences.sort((left, right) =>
left.logical_path.localeCompare(right.logical_path, "en"),
);
const orderedEdges = edges.sort((left, right) =>
left.edge_id.localeCompare(right.edge_id),
);
if (rootDigest !== null) {
const verified = await hashReadable(
createReadStream(path),
limits.maxTotalBytes,
signal,
);
if (
verified.sha256 !== rootDigest.sha256 ||
verified.bytes !== rootDigest.bytes
)
throw new ArtifactReaderFailure(
"integrity",
"Root artifact changed during inventory",
);
}
const graphSha256 = digestCanonical({
nodes: orderedNodes,
occurrences: orderedOccurrences,
edges: orderedEdges,
integrity_contradictions: integrityContradictions,
});
const manifestId = `agm_${digestCanonical({
schema_version: 1,
root_artifact_id: rootNode.artifact_id,
graph_sha256: graphSha256,
})}`;
return {
manifest: artifactInventoryResultSchema.shape.manifest.parse({
schema_version: 1,
manifest_id: manifestId,
root_artifact_id: rootNode.artifact_id,
root_sha256: rootNode.sha256,
root_format: rootNode.format,
graph_sha256: graphSha256,
node_count: orderedNodes.length,
occurrence_count: orderedOccurrences.length,
edge_count: orderedEdges.length,
}),
nodes: orderedNodes,
occurrences: orderedOccurrences,
edges: orderedEdges,
limits: toOutputLimits(limits),
provenance: reader?.provenance() ?? [],
integrity_contradictions: integrityContradictions,
limitations: [
...inventoryLimitations(rootFormat, reader),
...(integrityContradictions.length === 0
? []
: [
`${String(integrityContradictions.length)} integrity contradiction(s) were recorded; mismatched content is observed-untrusted.`,
]),
],
};
} finally {
await reader?.close();
}
return scanCanonicalArtifactInventory(path, limits, options);
};
/** Project independently paged graph collections from one inventory snapshot. */
@@ -304,351 +67,3 @@ export const paginateArtifactInventory = (
),
edges: pageOf(snapshot.edges, page.edgeOffset, page.edgeLimit),
});
const inventoryLimitations = (
format: ArtifactNode["format"],
reader: ArtifactReader | undefined,
): string[] => {
if (reader !== undefined) return [];
if (format === "dmg" || format === "pkg")
return [
`${format.toUpperCase()} root hash is observed; child inventory requires an approved native macOS adapter.`,
];
if (format === "mach-o-universal" && process.platform !== "darwin")
return ["Universal Mach-O slices require the native macOS lipo adapter."];
return ["Artifact has no child container entries."];
};
const createReader = async (
path: string,
format: ArtifactNode["format"],
nativeMount: ArtifactNativeMountPolicy,
signal?: AbortSignal,
): Promise<ArtifactReader | undefined> => {
switch (format) {
case "directory":
return new DirectoryArtifactReader(path);
case "zip":
case "ipa":
case "apk":
return new ZipArtifactReader(path, format);
case "asar":
return new AsarArtifactReader(path);
case "mach-o-universal":
return process.platform === "darwin"
? new MachOSliceArtifactReader(path)
: undefined;
case "dmg":
if (!nativeMount.nativeMountApproved) return undefined;
if (!nativeMount.nativeMountEnabled)
throw new ArtifactReaderFailure(
"unavailable",
"Native DMG mounting is disabled by operator policy",
);
return NativeDmgArtifactReader.create(path, signal);
default:
return undefined;
}
};
const visitNestedAsar = async (
adapterKey: string,
logicalPath: string,
visit: (reader: ArtifactReader, prefix: string) => Promise<void>,
): Promise<void> => {
const nested = new AsarArtifactReader(adapterKey);
try {
await visit(nested, logicalPath);
} finally {
await nested.close();
}
};
const isExpandableAsar = (entry: ArtifactEntry, logicalPath: string): boolean =>
entry.kind === "file" &&
logicalPath.toLowerCase().endsWith(".asar") &&
entry.adapterKey.startsWith("/");
const emptyScan = (): {
readonly nodes: Map<string, ArtifactNode>;
readonly occurrences: MutableOccurrence[];
readonly pendingContradictions: PendingIntegrityContradiction[];
} => ({ nodes: new Map(), occurrences: [], pendingContradictions: [] });
const UNAVAILABLE_UNPACKED_LIMITATION =
"ASAR unpacked companion bytes were unavailable; no content hash or child artifact was produced.";
interface PendingIntegrityContradiction {
readonly logicalPath: string;
readonly declaredSha256: string;
readonly observedSha256: string;
readonly entryKind: "file" | "slice";
readonly unpacked: boolean;
}
const scanReader = async (
reader: ArtifactReader | undefined,
limits: ArtifactLimits,
signal?: AbortSignal,
integrity: ArtifactIntegrityPolicy = STRICT_INTEGRITY_POLICY,
): Promise<{
readonly nodes: Map<string, ArtifactNode>;
readonly occurrences: MutableOccurrence[];
readonly pendingContradictions: PendingIntegrityContradiction[];
}> => {
const { nodes, occurrences, pendingContradictions } = emptyScan();
if (reader === undefined)
return { nodes, occurrences, pendingContradictions };
const occurrenceByPath = new Map<string, MutableOccurrence>();
const registry = new ArtifactPathRegistry();
let totalBytes = 0;
const digestEntry = async (
currentReader: ArtifactReader,
entry: ArtifactEntry,
logicalPath: string,
): Promise<
{ readonly node: ArtifactNode; readonly mismatched: boolean } | undefined
> => {
if ((entry.kind !== "file" && entry.kind !== "slice") || entry.encrypted)
return undefined;
const remainingBytes = limits.maxTotalBytes - totalBytes;
if (remainingBytes <= 0)
throw new ArtifactReaderFailure(
"limit",
"Artifact total byte limit exceeded",
);
if (entry.declaredSize !== null && entry.declaredSize > remainingBytes)
throw new ArtifactReaderFailure(
"limit",
"Declared artifact bytes exceed remaining cumulative limit",
);
const digest = await hashReadable(
await currentReader.open(entry, signal),
Math.min(limits.maxEntryBytes, remainingBytes),
signal,
);
totalBytes += digest.bytes;
const mismatched =
entry.declaredSha256 !== null && entry.declaredSha256 !== digest.sha256;
if (mismatched && integrity.mode === "fail")
throw new ArtifactReaderFailure(
"integrity",
`Artifact integrity metadata disagrees with content: ${logicalPath}`,
undefined,
{
logicalPath,
declaredSha256: entry.declaredSha256,
calculatedSha256: digest.sha256,
unpacked: entry.unpacked,
},
);
if (mismatched && entry.declaredSha256 !== null) {
if (pendingContradictions.length >= integrity.maxMismatches)
throw new ArtifactReaderFailure(
"limit",
"Artifact integrity mismatch limit exceeded",
);
pendingContradictions.push({
logicalPath,
declaredSha256: entry.declaredSha256,
observedSha256: digest.sha256,
entryKind: entry.kind,
unpacked: entry.unpacked,
});
}
const classified =
entry.kind === "slice"
? ({ kind: "universal-slice", format: "mach-o" } as const)
: classifyArtifactContent(logicalPath, digest.prefix);
return {
node: createArtifactNode({
sha256: digest.sha256,
size: digest.bytes,
kind: classified.kind,
format: classified.format,
executable: entry.executable,
contentState: "embedded",
limitations: mismatched
? [
"Observed content contradicts declared integrity metadata and is untrusted.",
]
: [],
}),
mismatched,
};
};
const visit = async (
currentReader: ArtifactReader,
prefix: string,
): Promise<void> => {
for await (const entry of currentReader.entries(signal)) {
if (occurrences.length >= limits.maxEntries)
throw new ArtifactReaderFailure(
"limit",
"Artifact entry limit exceeded",
);
const logicalPath = normalizeArtifactPath(
prefix.length === 0 ? entry.path : `${prefix}/${entry.path}`,
limits,
);
const expandableAsar = isExpandableAsar(entry, logicalPath);
registry.add(logicalPath, expandableAsar ? "directory" : entry.kind);
preflightEntry(entry, limits);
const parent = nearestParent(logicalPath, occurrenceByPath);
const occurrence = createOccurrence(
entry,
logicalPath,
parent?.occurrence_id ?? null,
);
let digested:
| { readonly node: ArtifactNode; readonly mismatched: boolean }
| undefined;
try {
digested = await digestEntry(currentReader, entry, logicalPath);
} catch (cause: unknown) {
if (!isUnavailableUnpackedEntry(cause, entry)) throw cause;
occurrence.hash_status = "unavailable";
occurrence.limitations.push(UNAVAILABLE_UNPACKED_LIMITATION);
}
if (digested !== undefined) {
nodes.set(digested.node.artifact_id, digested.node);
occurrence.artifact_id = digested.node.artifact_id;
occurrence.hash_status = digested.mismatched
? "mismatched"
: "verified";
if (digested.mismatched)
occurrence.limitations.push(
"Declared integrity metadata contradicts observed bytes.",
);
}
occurrences.push(occurrence);
occurrenceByPath.set(logicalPath, occurrence);
if (expandableAsar && digested?.mismatched !== true)
await visitNestedAsar(entry.adapterKey, logicalPath, visit);
}
};
await visit(reader, "");
return { nodes, occurrences, pendingContradictions };
};
const isUnavailableUnpackedEntry = (
cause: unknown,
entry: ArtifactEntry,
): boolean =>
entry.unpacked &&
cause instanceof ArtifactReaderFailure &&
cause.reason === "unavailable";
const classifyRoot = async (
path: string,
directory: boolean,
): Promise<ArtifactNode["format"]> => {
if (directory) return "directory";
const extensionFormat = classifyContainerExtension(path);
if (extensionFormat !== undefined) return extensionFormat;
const handle = await open(path, "r");
try {
const magic = Buffer.alloc(4);
const observed = await handle.read(magic, 0, magic.length, 0);
if (
observed.bytesRead === 4 &&
magic[0] === 0x50 &&
magic[1] === 0x4b &&
[0x03, 0x05, 0x07].includes(magic[2] ?? -1) &&
[0x04, 0x06, 0x08].includes(magic[3] ?? -1)
)
return "zip";
if (observed.bytesRead === 4) {
const header = magic.readUInt32BE(0);
if ([0xcafebabe, 0xbebafeca, 0xcafebabf, 0xbfbafeca].includes(header))
return "mach-o-universal";
if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(header))
return "mach-o";
if (magic.equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) return "elf";
}
if (observed.bytesRead >= 2 && magic[0] === 0x4d && magic[1] === 0x5a)
return "pe";
} finally {
await handle.close();
}
return classifyArtifactPath(path).format;
};
const classifyContainerExtension = (
path: string,
): ArtifactNode["format"] | undefined => {
const lower = path.toLowerCase();
for (const format of ["asar", "ipa", "apk", "zip", "dmg", "pkg"] as const)
if (lower.endsWith(`.${format}`)) return format;
return undefined;
};
const preflightEntry = (entry: ArtifactEntry, limits: ArtifactLimits): void => {
if (entry.declaredSize !== null && entry.declaredSize > limits.maxEntryBytes)
throw new ArtifactReaderFailure(
"limit",
`Entry exceeds byte limit: ${entry.path}`,
);
if (
entry.declaredSize !== null &&
entry.compressedSize !== null &&
entry.compressedSize === 0 &&
entry.declaredSize > 0
)
throw new ArtifactReaderFailure(
"limit",
`Invalid compression ratio: ${entry.path}`,
);
if (
entry.declaredSize !== null &&
entry.compressedSize !== null &&
entry.compressedSize > 0 &&
entry.declaredSize / entry.compressedSize > limits.maxCompressionRatio
)
throw new ArtifactReaderFailure(
"limit",
`Compression ratio exceeds limit: ${entry.path}`,
);
};
const hashReadable = async (
stream: Readable,
maximum: number,
signal?: AbortSignal,
): Promise<{
readonly sha256: string;
readonly bytes: number;
readonly prefix: Buffer;
}> => {
const hash = createHash("sha256");
const prefixes: Buffer[] = [];
let prefixBytes = 0;
let bytes = 0;
for await (const raw of stream) {
abortIfNeeded(signal);
const chunk = streamChunkToBuffer(raw);
bytes += chunk.length;
if (bytes > maximum) {
stream.destroy();
throw new ArtifactReaderFailure(
"limit",
"Observed entry bytes exceed limit",
);
}
hash.update(chunk);
if (prefixBytes < 16) {
const selected = chunk.subarray(0, 16 - prefixBytes);
prefixes.push(selected);
prefixBytes += selected.length;
}
}
return { sha256: hash.digest("hex"), bytes, prefix: Buffer.concat(prefixes) };
};
const abortIfNeeded = (signal?: AbortSignal): void => {
if (signal?.aborted === true)
throw new ArtifactReaderFailure(
"cancelled",
"Artifact inventory cancelled",
);
};
@@ -0,0 +1,48 @@
import { open } from "node:fs/promises";
import { classifyArtifactPath } from "../ArtifactGraphConstruction.js";
import type { ArtifactNode } from "../../domain/artifactGraph.js";
export const classifyContainerExtension = (
path: string,
): ArtifactNode["format"] | undefined => {
const lower = path.toLowerCase();
for (const format of ["asar", "ipa", "apk", "zip", "dmg", "pkg"] as const)
if (lower.endsWith(`.${format}`)) return format;
return undefined;
};
export const classifyRoot = async (
path: string,
directory: boolean,
): Promise<ArtifactNode["format"]> => {
if (directory) return "directory";
const extensionFormat = classifyContainerExtension(path);
if (extensionFormat !== undefined) return extensionFormat;
const handle = await open(path, "r");
try {
const magic = Buffer.alloc(4);
const observed = await handle.read(magic, 0, magic.length, 0);
if (
observed.bytesRead === 4 &&
magic[0] === 0x50 &&
magic[1] === 0x4b &&
[0x03, 0x05, 0x07].includes(magic[2] ?? -1) &&
[0x04, 0x06, 0x08].includes(magic[3] ?? -1)
)
return "zip";
if (observed.bytesRead === 4) {
const header = magic.readUInt32BE(0);
if ([0xcafebabe, 0xbebafeca, 0xcafebabf, 0xbfbafeca].includes(header))
return "mach-o-universal";
if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(header))
return "mach-o";
if (magic.equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) return "elf";
}
if (observed.bytesRead >= 2 && magic[0] === 0x4d && magic[1] === 0x5a)
return "pe";
} finally {
await handle.close();
}
return classifyArtifactPath(path).format;
};
+84
View File
@@ -0,0 +1,84 @@
import { createHash } from "node:crypto";
import type { Readable } from "node:stream";
import {
ArtifactReaderFailure,
type ArtifactEntry,
type ArtifactLimits,
} from "../../artifacts/ArtifactReader.js";
import { streamChunkToBuffer } from "../../artifacts/StreamBytes.js";
export type HashResult = {
readonly sha256: string;
readonly bytes: number;
readonly prefix: Buffer;
};
export const abortIfNeeded = (signal?: AbortSignal): void => {
if (signal?.aborted === true)
throw new ArtifactReaderFailure(
"cancelled",
"Artifact inventory cancelled",
);
};
export const preflightEntry = (
entry: ArtifactEntry,
limits: ArtifactLimits,
): void => {
if (entry.declaredSize !== null && entry.declaredSize > limits.maxEntryBytes)
throw new ArtifactReaderFailure(
"limit",
`Entry exceeds byte limit: ${entry.path}`,
);
if (
entry.declaredSize !== null &&
entry.compressedSize !== null &&
entry.compressedSize === 0 &&
entry.declaredSize > 0
)
throw new ArtifactReaderFailure(
"limit",
`Invalid compression ratio: ${entry.path}`,
);
if (
entry.declaredSize !== null &&
entry.compressedSize !== null &&
entry.compressedSize > 0 &&
entry.declaredSize / entry.compressedSize > limits.maxCompressionRatio
)
throw new ArtifactReaderFailure(
"limit",
`Compression ratio exceeds limit: ${entry.path}`,
);
};
export const hashReadable = async (
stream: Readable,
maximum: number,
signal?: AbortSignal,
): Promise<HashResult> => {
const hash = createHash("sha256");
const prefixes: Buffer[] = [];
let prefixBytes = 0;
let bytes = 0;
for await (const raw of stream) {
abortIfNeeded(signal);
const chunk = streamChunkToBuffer(raw);
bytes += chunk.length;
if (bytes > maximum) {
stream.destroy();
throw new ArtifactReaderFailure(
"limit",
"Observed entry bytes exceed limit",
);
}
hash.update(chunk);
if (prefixBytes < 16) {
const selected = chunk.subarray(0, 16 - prefixBytes);
prefixes.push(selected);
prefixBytes += selected.length;
}
}
return { sha256: hash.digest("hex"), bytes, prefix: Buffer.concat(prefixes) };
};
@@ -0,0 +1,57 @@
import { AsarArtifactReader } from "../../artifacts/AsarArtifactReader.js";
import {
ArtifactReaderFailure,
type ArtifactReader,
} from "../../artifacts/ArtifactReader.js";
import { DirectoryArtifactReader } from "../../artifacts/DirectoryArtifactReader.js";
import { MachOSliceArtifactReader } from "../../artifacts/MachOSliceArtifactReader.js";
import { NativeDmgArtifactReader } from "../../artifacts/NativeDmgArtifactReader.js";
import { ZipArtifactReader } from "../../artifacts/ZipArtifactReader.js";
import type { ArtifactNode } from "../../domain/artifactGraph.js";
import type { ArtifactNativeMountPolicy } from "./types.js";
export const createReader = async (
path: string,
format: ArtifactNode["format"],
nativeMount: ArtifactNativeMountPolicy,
signal?: AbortSignal,
): Promise<ArtifactReader | undefined> => {
switch (format) {
case "directory":
return new DirectoryArtifactReader(path);
case "zip":
case "ipa":
case "apk":
return new ZipArtifactReader(path, format);
case "asar":
return new AsarArtifactReader(path);
case "mach-o-universal":
return process.platform === "darwin"
? new MachOSliceArtifactReader(path)
: undefined;
case "dmg":
if (!nativeMount.nativeMountApproved) return undefined;
if (!nativeMount.nativeMountEnabled)
throw new ArtifactReaderFailure(
"unavailable",
"Native DMG mounting is disabled by operator policy",
);
return NativeDmgArtifactReader.create(path, signal);
default:
return undefined;
}
};
export const inventoryLimitations = (
format: ArtifactNode["format"],
reader: ArtifactReader | undefined,
): string[] => {
if (reader !== undefined) return [];
if (format === "dmg" || format === "pkg")
return [
`${format.toUpperCase()} root hash is observed; child inventory requires an approved native macOS adapter.`,
];
if (format === "mach-o-universal" && process.platform !== "darwin")
return ["Universal Mach-O slices require the native macOS lipo adapter."];
return ["Artifact has no child container entries."];
};
@@ -0,0 +1,295 @@
import { createReadStream } from "node:fs";
import { lstat } from "node:fs/promises";
import type { Stats } from "node:fs";
import {
ArtifactReaderFailure,
type ArtifactLimits,
type ArtifactReader,
} from "../../artifacts/ArtifactReader.js";
import {
artifactInventoryResultSchema,
type ArtifactInventoryResult,
type ArtifactNode,
type IntegrityContradiction,
} from "../../domain/artifactGraph.js";
import {
createArtifactEdges,
createRootNode,
digestCanonical,
indexOccurrencesByPath,
materializeDirectoryNodes,
rekeyOccurrences,
rootOccurrenceFor,
toOutputLimits,
type MutableOccurrence,
} from "../ArtifactGraphConstruction.js";
import { classifyRoot } from "./classify.js";
import { hashReadable, type HashResult } from "./hash.js";
import { createReader, inventoryLimitations } from "./reader.js";
import {
scanReader,
type PendingIntegrityContradiction,
} from "./scanReader.js";
import {
NATIVE_MOUNT_DISABLED,
STRICT_INTEGRITY_POLICY,
type ArtifactInventoryOptions,
type ArtifactInventorySnapshot,
} from "./types.js";
export const scanCanonicalArtifactInventory = async (
path: string,
limits: ArtifactLimits,
options: ArtifactInventoryOptions = {},
): Promise<ArtifactInventorySnapshot> => {
const integrity = options.integrity ?? STRICT_INTEGRITY_POLICY;
if (
integrity.mode === "record-and-continue" &&
(!integrity.approved || !integrity.enabled)
)
throw new ArtifactReaderFailure(
"unavailable",
"Integrity continuation requires explicit approval and operator policy",
);
const metadata = await lstat(path);
const rootFormat = await classifyRoot(path, metadata.isDirectory());
const rootDigest = metadata.isDirectory()
? null
: await hashReadable(
createReadStream(path),
limits.maxTotalBytes,
options.signal,
);
const reader = await createReader(
path,
rootFormat,
options.nativeMount ?? NATIVE_MOUNT_DISABLED,
options.signal,
);
try {
const { nodes, occurrences, pendingContradictions } = await scanReader(
reader,
limits,
options.signal,
integrity,
);
return await buildInventorySnapshot({
path,
metadata,
rootFormat,
rootDigest,
reader,
limits,
signal: options.signal,
nodes,
occurrences,
pendingContradictions,
});
} finally {
await reader?.close();
}
};
interface SnapshotBuildInput {
readonly path: string;
readonly metadata: Stats;
readonly rootFormat: ArtifactNode["format"];
readonly rootDigest: HashResult | null;
readonly reader: ArtifactReader | undefined;
readonly limits: ArtifactLimits;
readonly signal: AbortSignal | undefined;
readonly nodes: Map<string, ArtifactNode>;
readonly occurrences: MutableOccurrence[];
readonly pendingContradictions: PendingIntegrityContradiction[];
}
const buildInventorySnapshot = async (
input: SnapshotBuildInput,
): Promise<ArtifactInventorySnapshot> => {
const { path, metadata, rootFormat, rootDigest, reader, limits, signal } =
input;
materializeDirectoryNodes(input.occurrences, input.nodes);
const rootNode = createRootNode({
path,
format: rootFormat,
directory: metadata.isDirectory(),
digest: rootDigest,
occurrences: input.occurrences,
});
input.nodes.set(rootNode.artifact_id, rootNode);
rekeyOccurrences(rootNode.artifact_id, input.occurrences);
const rootOccurrence = rootOccurrenceFor(rootNode, metadata.size);
for (const occurrence of input.occurrences)
if (occurrence.parent_occurrence_id === null)
occurrence.parent_occurrence_id = rootOccurrence.occurrence_id;
input.occurrences.unshift(rootOccurrence);
const occurrenceById = new Map(
input.occurrences.map((occurrence) => [
occurrence.occurrence_id,
occurrence,
]),
);
const occurrenceByPath = indexOccurrencesByPath(input.occurrences);
const integrityContradictions = buildIntegrityContradictions(
input.pendingContradictions,
occurrenceByPath,
occurrenceById,
rootNode,
);
const edges = createArtifactEdges(
rootNode.artifact_id,
input.occurrences,
reader?.provenance()[0],
);
const orderedNodes = sortNodes([...input.nodes.values()]);
const orderedOccurrences = sortOccurrences(input.occurrences);
const orderedEdges = sortEdges(edges);
await verifyRootDigest(path, rootDigest, limits.maxTotalBytes, signal);
const graphSha256 = digestCanonical({
nodes: orderedNodes,
occurrences: orderedOccurrences,
edges: orderedEdges,
integrity_contradictions: integrityContradictions,
});
const manifest = buildManifest({
rootNode,
graphSha256,
orderedNodes,
orderedOccurrences,
orderedEdges,
});
return {
manifest,
nodes: orderedNodes,
occurrences: orderedOccurrences,
edges: orderedEdges,
limits: toOutputLimits(limits),
provenance: reader?.provenance() ?? [],
integrity_contradictions: integrityContradictions,
limitations: buildLimitations(rootFormat, reader, integrityContradictions),
};
};
const buildIntegrityContradictions = (
pending: readonly PendingIntegrityContradiction[],
occurrenceByPath: ReadonlyMap<string, MutableOccurrence>,
occurrenceById: ReadonlyMap<string, MutableOccurrence>,
rootNode: ArtifactNode,
): IntegrityContradiction[] =>
pending.map((contradiction): IntegrityContradiction => {
const occurrence = occurrenceByPath.get(contradiction.logicalPath);
if (occurrence === undefined)
throw new ArtifactReaderFailure(
"integrity",
"Integrity contradiction lost its graph occurrence",
);
const parent =
occurrence.parent_occurrence_id === null
? undefined
: occurrenceById.get(occurrence.parent_occurrence_id);
const parentArtifactId = parent?.artifact_id ?? rootNode.artifact_id;
return {
contradiction_id: `ic_${digestCanonical({
root_artifact_id: rootNode.artifact_id,
logical_path: contradiction.logicalPath,
declared_sha256: contradiction.declaredSha256,
observed_sha256: contradiction.observedSha256,
})}`,
occurrence_id: occurrence.occurrence_id,
parent_artifact_id: parentArtifactId,
logical_path: contradiction.logicalPath,
declared_sha256: contradiction.declaredSha256,
observed_sha256: contradiction.observedSha256,
entry_kind: contradiction.entryKind,
unpacked: contradiction.unpacked,
trust: "observed-untrusted",
provenance: "container-integrity-metadata-versus-observed-bytes",
limitations: [
"Observed bytes contradict declared integrity metadata and cannot support equivalence.",
],
};
});
const verifyRootDigest = async (
path: string,
rootDigest: HashResult | null,
maxTotalBytes: number,
signal: AbortSignal | undefined,
): Promise<void> => {
if (rootDigest === null) return;
const verified = await hashReadable(
createReadStream(path),
maxTotalBytes,
signal,
);
if (
verified.sha256 !== rootDigest.sha256 ||
verified.bytes !== rootDigest.bytes
)
throw new ArtifactReaderFailure(
"integrity",
"Root artifact changed during inventory",
);
};
const sortNodes = (nodes: ArtifactNode[]): ArtifactNode[] =>
nodes.sort((left, right) =>
left.artifact_id.localeCompare(right.artifact_id),
);
const sortOccurrences = (
occurrences: MutableOccurrence[],
): MutableOccurrence[] =>
occurrences.sort((left, right) =>
left.logical_path.localeCompare(right.logical_path, "en"),
);
const sortEdges = <T extends { edge_id: string }>(edges: T[]): T[] =>
edges.sort((left, right) => left.edge_id.localeCompare(right.edge_id));
const buildManifest = ({
rootNode,
graphSha256,
orderedNodes,
orderedOccurrences,
orderedEdges,
}: {
readonly rootNode: ArtifactNode;
readonly graphSha256: string;
readonly orderedNodes: readonly ArtifactNode[];
readonly orderedOccurrences: readonly unknown[];
readonly orderedEdges: readonly { edge_id: string }[];
}): ArtifactInventoryResult["manifest"] =>
artifactInventoryResultSchema.shape.manifest.parse({
schema_version: 1,
manifest_id: `agm_${digestCanonical({
schema_version: 1,
root_artifact_id: rootNode.artifact_id,
graph_sha256: graphSha256,
})}`,
root_artifact_id: rootNode.artifact_id,
root_sha256: rootNode.sha256,
root_format: rootNode.format,
graph_sha256: graphSha256,
node_count: orderedNodes.length,
occurrence_count: orderedOccurrences.length,
edge_count: orderedEdges.length,
});
const buildLimitations = (
rootFormat: ArtifactNode["format"],
reader: ArtifactReader | undefined,
integrityContradictions: readonly IntegrityContradiction[],
): string[] => [
...inventoryLimitations(rootFormat, reader),
...(integrityContradictions.length === 0
? []
: [
`${String(integrityContradictions.length)} integrity contradiction(s) were recorded; mismatched content is observed-untrusted.`,
]),
];
@@ -0,0 +1,241 @@
import {
ArtifactPathRegistry,
normalizeArtifactPath,
} from "../../artifacts/ArtifactPaths.js";
import {
ArtifactReaderFailure,
type ArtifactEntry,
type ArtifactLimits,
type ArtifactReader,
} from "../../artifacts/ArtifactReader.js";
import { AsarArtifactReader } from "../../artifacts/AsarArtifactReader.js";
import type { ArtifactNode } from "../../domain/artifactGraph.js";
import {
classifyArtifactContent,
createArtifactNode,
createOccurrence,
nearestParent,
type MutableOccurrence,
} from "../ArtifactGraphConstruction.js";
import {
STRICT_INTEGRITY_POLICY,
type ArtifactIntegrityPolicy,
} from "./types.js";
import { hashReadable, preflightEntry } from "./hash.js";
export interface PendingIntegrityContradiction {
readonly logicalPath: string;
readonly declaredSha256: string;
readonly observedSha256: string;
readonly entryKind: "file" | "slice";
readonly unpacked: boolean;
}
export const UNAVAILABLE_UNPACKED_LIMITATION =
"ASAR unpacked companion bytes were unavailable; no content hash or child artifact was produced.";
export const emptyScan = (): {
readonly nodes: Map<string, ArtifactNode>;
readonly occurrences: MutableOccurrence[];
readonly pendingContradictions: PendingIntegrityContradiction[];
} => ({ nodes: new Map(), occurrences: [], pendingContradictions: [] });
interface ScanContext {
readonly reader: ArtifactReader;
readonly limits: ArtifactLimits;
readonly signal: AbortSignal | undefined;
readonly integrity: ArtifactIntegrityPolicy;
readonly nodes: Map<string, ArtifactNode>;
readonly occurrences: MutableOccurrence[];
readonly pendingContradictions: PendingIntegrityContradiction[];
readonly occurrenceByPath: Map<string, MutableOccurrence>;
readonly registry: ArtifactPathRegistry;
totalBytes: number;
}
export const scanReader = async (
reader: ArtifactReader | undefined,
limits: ArtifactLimits,
signal?: AbortSignal,
integrity: ArtifactIntegrityPolicy = STRICT_INTEGRITY_POLICY,
): Promise<{
readonly nodes: Map<string, ArtifactNode>;
readonly occurrences: MutableOccurrence[];
readonly pendingContradictions: PendingIntegrityContradiction[];
}> => {
const { nodes, occurrences, pendingContradictions } = emptyScan();
if (reader === undefined)
return { nodes, occurrences, pendingContradictions };
const context: ScanContext = {
reader,
limits,
signal,
integrity,
nodes,
occurrences,
pendingContradictions,
occurrenceByPath: new Map<string, MutableOccurrence>(),
registry: new ArtifactPathRegistry(),
totalBytes: 0,
};
await visitArtifactEntries(context, reader, "");
return { nodes, occurrences, pendingContradictions };
};
const visitArtifactEntries = async (
context: ScanContext,
currentReader: ArtifactReader,
prefix: string,
): Promise<void> => {
for await (const entry of currentReader.entries(context.signal)) {
if (context.occurrences.length >= context.limits.maxEntries)
throw new ArtifactReaderFailure("limit", "Artifact entry limit exceeded");
const logicalPath = normalizeArtifactPath(
prefix.length === 0 ? entry.path : `${prefix}/${entry.path}`,
context.limits,
);
const expandableAsar = isExpandableAsar(entry, logicalPath);
context.registry.add(
logicalPath,
expandableAsar ? "directory" : entry.kind,
);
preflightEntry(entry, context.limits);
const parent = nearestParent(logicalPath, context.occurrenceByPath);
const occurrence = createOccurrence(
entry,
logicalPath,
parent?.occurrence_id ?? null,
);
let digested:
| { readonly node: ArtifactNode; readonly mismatched: boolean }
| undefined;
try {
digested = await digestArtifactEntry(
context,
currentReader,
entry,
logicalPath,
);
} catch (cause: unknown) {
if (!isUnavailableUnpackedEntry(cause, entry)) throw cause;
occurrence.hash_status = "unavailable";
occurrence.limitations.push(UNAVAILABLE_UNPACKED_LIMITATION);
}
if (digested !== undefined) {
context.nodes.set(digested.node.artifact_id, digested.node);
occurrence.artifact_id = digested.node.artifact_id;
occurrence.hash_status = digested.mismatched ? "mismatched" : "verified";
if (digested.mismatched)
occurrence.limitations.push(
"Declared integrity metadata contradicts observed bytes.",
);
}
context.occurrences.push(occurrence);
context.occurrenceByPath.set(logicalPath, occurrence);
if (expandableAsar && digested?.mismatched !== true)
await visitNestedAsar(context, entry.adapterKey, logicalPath);
}
};
const digestArtifactEntry = async (
context: ScanContext,
currentReader: ArtifactReader,
entry: ArtifactEntry,
logicalPath: string,
): Promise<
{ readonly node: ArtifactNode; readonly mismatched: boolean } | undefined
> => {
if ((entry.kind !== "file" && entry.kind !== "slice") || entry.encrypted)
return undefined;
const remainingBytes = context.limits.maxTotalBytes - context.totalBytes;
if (remainingBytes <= 0)
throw new ArtifactReaderFailure(
"limit",
"Artifact total byte limit exceeded",
);
if (entry.declaredSize !== null && entry.declaredSize > remainingBytes)
throw new ArtifactReaderFailure(
"limit",
"Declared artifact bytes exceed remaining cumulative limit",
);
const digest = await hashReadable(
await currentReader.open(entry, context.signal),
Math.min(context.limits.maxEntryBytes, remainingBytes),
context.signal,
);
context.totalBytes += digest.bytes;
const mismatched =
entry.declaredSha256 !== null && entry.declaredSha256 !== digest.sha256;
if (mismatched && context.integrity.mode === "fail")
throw new ArtifactReaderFailure(
"integrity",
`Artifact integrity metadata disagrees with content: ${logicalPath}`,
undefined,
{
logicalPath,
declaredSha256: entry.declaredSha256,
calculatedSha256: digest.sha256,
unpacked: entry.unpacked,
},
);
if (mismatched && entry.declaredSha256 !== null) {
if (context.pendingContradictions.length >= context.integrity.maxMismatches)
throw new ArtifactReaderFailure(
"limit",
"Artifact integrity mismatch limit exceeded",
);
context.pendingContradictions.push({
logicalPath,
declaredSha256: entry.declaredSha256,
observedSha256: digest.sha256,
entryKind: entry.kind,
unpacked: entry.unpacked,
});
}
const classified =
entry.kind === "slice"
? ({ kind: "universal-slice", format: "mach-o" } as const)
: classifyArtifactContent(logicalPath, digest.prefix);
return {
node: createArtifactNode({
sha256: digest.sha256,
size: digest.bytes,
kind: classified.kind,
format: classified.format,
executable: entry.executable,
contentState: "embedded",
limitations: mismatched
? [
"Observed content contradicts declared integrity metadata and is untrusted.",
]
: [],
}),
mismatched,
};
};
const visitNestedAsar = async (
context: ScanContext,
adapterKey: string,
logicalPath: string,
): Promise<void> => {
const nested = new AsarArtifactReader(adapterKey);
try {
await visitArtifactEntries(context, nested, logicalPath);
} finally {
await nested.close();
}
};
const isExpandableAsar = (entry: ArtifactEntry, logicalPath: string): boolean =>
entry.kind === "file" &&
logicalPath.toLowerCase().endsWith(".asar") &&
entry.adapterKey.startsWith("/");
const isUnavailableUnpackedEntry = (
cause: unknown,
entry: ArtifactEntry,
): boolean =>
entry.unpacked &&
cause instanceof ArtifactReaderFailure &&
cause.reason === "unavailable";
@@ -0,0 +1,61 @@
import type {
ArtifactInventoryResult,
ArtifactNode,
IntegrityContradiction,
} from "../../domain/artifactGraph.js";
export interface InventoryPageInput {
readonly nodeOffset: number;
readonly nodeLimit: number;
readonly occurrenceOffset: number;
readonly occurrenceLimit: number;
readonly edgeOffset: number;
readonly edgeLimit: number;
}
/** Per-request authority combined with operator-owned native mount policy. */
export interface ArtifactNativeMountPolicy {
readonly nativeMountApproved: boolean;
readonly nativeMountEnabled: boolean;
}
export const NATIVE_MOUNT_DISABLED: ArtifactNativeMountPolicy = {
nativeMountApproved: false,
nativeMountEnabled: false,
};
/** Explicit caller approval bounded by operator-owned integrity policy. */
export interface ArtifactIntegrityPolicy {
readonly mode: "fail" | "record-and-continue";
readonly approved: boolean;
readonly enabled: boolean;
readonly maxMismatches: number;
}
export const STRICT_INTEGRITY_POLICY: ArtifactIntegrityPolicy = {
mode: "fail",
approved: false,
enabled: false,
maxMismatches: 1,
};
/** Options shared by artifact inventory scans. */
export interface ArtifactInventoryOptions {
readonly signal?: AbortSignal | undefined;
readonly nativeMount?: ArtifactNativeMountPolicy | undefined;
readonly integrity?: ArtifactIntegrityPolicy | undefined;
}
/** Immutable inventory produced by one complete artifact scan. */
export interface ArtifactInventorySnapshot {
readonly manifest: ArtifactInventoryResult["manifest"];
readonly nodes: readonly ArtifactNode[];
readonly occurrences: ArtifactInventoryResult["occurrences"]["items"];
readonly edges: ArtifactInventoryResult["edges"]["items"];
readonly limits: ArtifactInventoryResult["limits"];
readonly provenance: ReadonlyArray<
ArtifactInventoryResult["provenance"][number]
>;
readonly integrity_contradictions: readonly IntegrityContradiction[];
readonly limitations: readonly string[];
}
+3 -10
View File
@@ -5,7 +5,7 @@ import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { withinRoot } from "../reference/ReferenceSourceReaderPaths.js";
import {
scanCanonicalArtifactInventory,
type ArtifactIntegrityPolicy,
type ArtifactInventoryOptions,
type ArtifactInventorySnapshot,
} from "./ArtifactInventory.js";
import { canonicalizeConfiguredRoots } from "./ConfiguredRoots.js";
@@ -15,8 +15,7 @@ export const scanAuthorizedArtifactInventory = async (
inputPath: string,
roots: readonly string[],
limits: ArtifactLimits,
signal?: AbortSignal,
integrity?: ArtifactIntegrityPolicy,
options: ArtifactInventoryOptions = {},
): Promise<ArtifactInventorySnapshot> => {
if (roots.length === 0)
throw new ArtifactReaderFailure(
@@ -32,11 +31,5 @@ export const scanAuthorizedArtifactInventory = async (
"path",
"Artifact path is outside approved input roots",
);
return scanCanonicalArtifactInventory(
path,
limits,
signal,
undefined,
integrity,
);
return scanCanonicalArtifactInventory(path, limits, options);
};
+54 -470
View File
@@ -1,25 +1,15 @@
import type { BinaryTarget } from "../domain/binaryTarget.js";
import { parseBinaryTarget } from "../domain/binaryTarget.js";
import {
analysisProfilesEqual,
type AnalysisProfileCommitment,
} from "../domain/analysisProfile.js";
import type { AnalysisProviderSelector } from "../contracts/providerSelection.js";
import {
AnalysisCapabilityUnavailableError,
EvidenceIntegrityError,
EvidenceLimitError,
AnalysisCancelledError,
NoBinaryOpenError,
ProviderAdapterError,
type AnalysisError,
} from "../domain/errors.js";
import { err, ok, type Result } from "../domain/result.js";
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,
@@ -28,48 +18,23 @@ import type {
AnalysisProfileResolutionOptions,
AnalysisOperationPort,
AnalysisProvider,
CapabilityDescriptor,
ProviderIdentity,
} from "./AnalysisProvider.js";
import { EvidenceLedger } from "./EvidenceLedger.js";
import type {
RecordUnknownInput,
ResidualUnknown,
UnknownStatus,
UpdateUnknownInput,
} from "../domain/residualUnknown.js";
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";
import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js";
import {
snapshotMatchesBinding,
snapshotMatchesTarget,
snapshotTarget,
} 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";
import {
parseInvestigationWorkspace,
type InvestigationWorkspace,
} from "../domain/investigationWorkspace.js";
import {
parseReconstructionCoverageWorkspace,
type ReconstructionCoverageWorkspace,
} from "../domain/reconstructionCoverage.js";
import {
SessionProviderRouter,
type SessionProviderRoute,
} from "./SessionProviderRouter.js";
import { BinarySessionRecords } from "./BinarySessionRecords.js";
import { binarySessionStatus } from "./BinarySessionStatus.js";
import {
resolveSessionOpen,
type BinarySessionOpenOptions,
} from "./BinarySessionOpen.js";
import { prepareSessionExecution } from "./BinarySessionExecution.js";
export type { BinarySessionPort } from "./BinarySessionPort.js";
const OFFICIAL_OPERATIONS: ReadonlySet<string> = new Set(
OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name),
@@ -82,7 +47,10 @@ const OFFICIAL_OPERATIONS: ReadonlySet<string> = new Set(
* active provider client. A failed switch recreates the previous target instead
* of retaining a client whose resources were already shut down.
*/
export class BinarySession implements BinarySessionPort {
export class BinarySession
extends BinarySessionRecords
implements BinarySessionPort
{
#active:
| {
readonly target: BinaryTarget;
@@ -95,22 +63,11 @@ export class BinarySession implements BinarySessionPort {
#transition: Promise<void> = Promise.resolve();
readonly #calls = new Set<Promise<unknown>>();
readonly #providerRouter: SessionProviderRouter;
readonly #evidence = new EvidenceLedger({
maxRecords: 10_000,
maxBytes: 64 * 1024 * 1024,
});
readonly #snapshot = new AnalysisSnapshotCache();
readonly #investigationWorkspaces = new Map<string, InvestigationWorkspace>();
readonly #reconstructionCoverageWorkspaces = new Map<
string,
ReconstructionCoverageWorkspace
>();
readonly #runtimeAvailability = new Map<
string,
{ readonly available: boolean; readonly reason: string | null }
>();
readonly #availabilityListeners = new Set<() => void | Promise<void>>();
#snapshotInvalidated = false;
constructor(
readonly provider:
@@ -124,6 +81,7 @@ export class BinarySession implements BinarySessionPort {
) => Promise<Result<AnalysisProfileResolution, AnalysisError>>;
} = {},
) {
super();
this.#providerRouter =
provider instanceof SessionProviderRouter
? provider
@@ -186,279 +144,28 @@ export class BinarySession implements BinarySessionPort {
return () => this.#availabilityListeners.delete(listener);
}
/** Add one successful public observation to the session ledger. */
recordEvidence(
evidence: Evidence,
): Result<
"added" | "duplicate",
EvidenceIntegrityError | EvidenceLimitError
> {
return this.#evidence.record(evidence);
}
/** Check that comparison input Evidence is already session-owned. */
hasEvidence(evidenceId: string): boolean {
return this.#evidence.has(evidenceId);
}
/** Read one detached session-owned Evidence record by semantic ID. */
evidenceById(evidenceId: string): Evidence | undefined {
return this.#evidence.get(evidenceId);
}
/** Return a deterministic snapshot without clearing session evidence. */
exportEvidenceBundle(): EvidenceBundle {
return this.#evidence.export();
}
/** Atomically merge a validated evidence bundle into this session. */
importEvidenceBundle(
bundle: unknown,
): Result<number, EvidenceIntegrityError | EvidenceLimitError> {
return this.#evidence.import(bundle);
}
/** Export immutable cached calls and session evidence for the active target. */
exportAnalysisSnapshot(): Result<AnalysisSnapshot, AnalysisError> {
if (this.#snapshotInvalidated)
return err(
new EvidenceIntegrityError(
"Analysis snapshots are unavailable after analysis metadata mutations",
),
);
const target = this.#active?.target;
const profile = this.#active?.profile ?? undefined;
if (target !== undefined && profile === undefined)
return err(
new EvidenceIntegrityError(
"Analysis snapshots require a concrete provider analysis profile",
),
);
return this.#snapshot.export(
target,
profile,
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> {
const active = this.#active;
if (active?.profile === null)
return err(
new EvidenceIntegrityError(
"Analysis snapshot profile_mismatch: the active target has no concrete analysis profile",
),
);
return this.#snapshot.import(
snapshot,
active === undefined
? undefined
: { target: active.target, profile: active.profile },
(bundle) => this.#evidence.import(bundle),
);
}
/** Retain one validated immutable workspace revision for session resources. */
retainInvestigationWorkspace(
workspace: InvestigationWorkspace,
): "added" | "duplicate" {
const parsed = parseInvestigationWorkspace(workspace);
const key = `${parsed.workspace_id}:${String(parsed.revision)}`;
if (this.#investigationWorkspaces.has(key)) return "duplicate";
this.#investigationWorkspaces.set(key, parsed);
return "added";
}
/** Read one session-retained immutable workspace revision. */
investigationWorkspace(
workspaceId: string,
revision: number,
): InvestigationWorkspace | undefined {
const workspace = this.#investigationWorkspaces.get(
`${workspaceId}:${String(revision)}`,
);
return workspace === undefined ? undefined : structuredClone(workspace);
}
/** List retained workspace revisions in canonical identity order. */
investigationWorkspaces(): readonly InvestigationWorkspace[] {
return [...this.#investigationWorkspaces.values()]
.sort(
(left, right) =>
left.workspace_id.localeCompare(right.workspace_id) ||
left.revision - right.revision,
)
.map((workspace) => structuredClone(workspace));
}
/** Retain one validated immutable reconstruction coverage revision. */
retainReconstructionCoverageWorkspace(
workspace: ReconstructionCoverageWorkspace,
): "added" | "duplicate" {
const parsed = parseReconstructionCoverageWorkspace(workspace);
const key = `${parsed.workspace_id}:${String(parsed.revision)}`;
if (this.#reconstructionCoverageWorkspaces.has(key)) return "duplicate";
this.#reconstructionCoverageWorkspaces.set(key, parsed);
return "added";
}
/** Read one session-retained reconstruction coverage revision. */
reconstructionCoverageWorkspace(
workspaceId: string,
revision: number,
): ReconstructionCoverageWorkspace | undefined {
const workspace = this.#reconstructionCoverageWorkspaces.get(
`${workspaceId}:${String(revision)}`,
);
return workspace === undefined ? undefined : structuredClone(workspace);
}
/** List reconstruction coverage revisions in canonical identity order. */
reconstructionCoverageWorkspaces(): readonly ReconstructionCoverageWorkspace[] {
return [...this.#reconstructionCoverageWorkspaces.values()]
.sort(
(left, right) =>
left.workspace_id.localeCompare(right.workspace_id) ||
left.revision - right.revision,
)
.map((workspace) => structuredClone(workspace));
}
/** Create an approved residual unknown and immutable mutation evidence. */
recordUnknown(
input: RecordUnknownInput,
): Result<ResidualUnknown, AnalysisError> {
const evidence = unknownMutationEvidence(this.#active?.target, input);
return this.#evidence.recordUnknown(input, evidence);
}
/** Atomically record derived Evidence and its approved residual unknown. */
recordEvidenceWithUnknown(
evidence: Evidence,
input: RecordUnknownInput,
): Result<ResidualUnknown | null, AnalysisError> {
return this.#evidence.recordWithUnknown(
evidence,
input,
unknownMutationEvidence(undefined, input),
);
}
/** Update one unknown using compare-and-swap revision semantics. */
updateUnknown(
input: UpdateUnknownInput,
): Result<ResidualUnknown, AnalysisError> {
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.",
],
},
);
return this.#evidence.updateUnknown(input, evidence);
}
/** Query stable current residual-unknown heads. */
listUnknowns(
filters: {
readonly status?: UnknownStatus;
readonly severity?: ResidualUnknown["severity"];
readonly domain?: string;
} = {},
): ResidualUnknown[] {
return this.#evidence.listUnknowns(filters);
}
/** Check whether current head is a bundle-valid resolved state. */
verifyUnknownResolution(unknownId: string): Result<
{
readonly valid: boolean;
readonly truthVerified: boolean;
readonly unknown: ResidualUnknown;
},
UnknownRegistryError
> {
return this.#evidence.verifyUnknownResolution(unknownId);
}
/**
* Open or switch targets after draining calls against the current target.
* Returns the switch failure even if best-effort restoration also fails.
*/
open(
path: string,
options: {
readonly signal?: AbortSignal;
readonly targetKind?: BinaryTarget["kind"];
readonly snapshot?: AnalysisSnapshot;
readonly providerId?: AnalysisProviderSelector;
} = {},
options: BinarySessionOpenOptions = {},
): Promise<Result<BinaryTarget, AnalysisError>> {
return this.#serialize(async () => {
if (isAborted(options.signal))
return err(new AnalysisCancelledError("open_binary"));
const parsed = await parseBinaryTarget(
const resolved = await resolveSessionOpen({
router: this.#providerRouter,
current: this.#active,
path,
process.cwd(),
process.arch,
options.targetKind,
);
if (!parsed.ok) return parsed;
const sameTarget =
this.#active?.target.path === parsed.value.path &&
snapshotMatchesTarget(
snapshotTarget(this.#active.target),
parsed.value,
);
const resolvedRoute =
sameTarget &&
options.providerId === undefined &&
this.#active !== undefined
? ok(this.#active.route)
: await this.#providerRouter.resolve(
parsed.value,
options.providerId,
options.signal,
);
if (!resolvedRoute.ok) return resolvedRoute;
const route = resolvedRoute.value;
options,
stagedSnapshotMatches: (target, profile) =>
this.matchesSnapshot(target, profile),
});
if (!resolved.ok) return resolved;
const { target, route, sameTarget } = resolved.value;
const { profile, compatibility } = route;
if (
options.snapshot !== undefined &&
(profile === null ||
!snapshotMatchesBinding(options.snapshot, parsed.value, profile))
)
return err(
new EvidenceIntegrityError(
"Analysis snapshot profile_mismatch: target, provider, or analysis profile does not match the requested binary",
),
);
if (
this.#active === undefined &&
!this.#snapshot.matches(parsed.value, profile ?? undefined)
)
return err(
new EvidenceIntegrityError(
"Analysis snapshot profile_mismatch: staged target, provider, or analysis profile does not match",
),
);
if (isAborted(options.signal))
return err(new AnalysisCancelledError("open_binary"));
const activeProfile = this.#active?.profile;
@@ -471,13 +178,13 @@ export class BinarySession implements BinarySessionPort {
const imported = this.importAnalysisSnapshot(options.snapshot);
if (!imported.ok) return imported;
}
return ok(parsed.value);
return ok(target);
}
await this.#drainCalls();
const previous = this.#active;
this.#active = undefined;
await previous?.client.close();
const client = route.createClient(parsed.value);
const client = route.createClient(target);
const started = await client.execute("health", {}, options);
if (!started.ok) {
await client.close();
@@ -490,15 +197,15 @@ export class BinarySession implements BinarySessionPort {
return err(new AnalysisCancelledError("open_binary"));
}
this.#active = {
target: parsed.value,
target,
client,
profile,
compatibility: structuredClone(compatibility),
route,
};
this.#clearRuntimeAvailability();
if (profile === null) this.#snapshot.clear();
else this.#snapshot.select(parsed.value, profile);
if (profile === null) this.clearSnapshot();
else this.selectSnapshot(target, profile);
if (options.snapshot !== undefined) {
const imported = this.importAnalysisSnapshot(options.snapshot);
if (!imported.ok) {
@@ -508,8 +215,8 @@ export class BinarySession implements BinarySessionPort {
return imported;
}
}
this.#snapshotInvalidated = false;
return ok(parsed.value);
this.resetSnapshotInvalidation();
return ok(target);
});
}
@@ -520,9 +227,7 @@ export class BinarySession implements BinarySessionPort {
this.#active = undefined;
await this.#drainCalls();
await previous?.client.close();
this.#evidence.clear();
this.#snapshot.clear();
this.#snapshotInvalidated = false;
this.clearSessionRecords();
this.#clearRuntimeAvailability();
return ok(null);
});
@@ -530,85 +235,12 @@ export class BinarySession implements BinarySessionPort {
/** Describe the active binary session. */
status(): JsonValue {
const target = this.#active?.target;
const route = this.#currentRoute();
const configuredProvider = this.#providerRouter.configuredIdentity();
const provider = {
id: configuredProvider.id,
name: configuredProvider.name,
version: configuredProvider.version,
};
const providerList = this.#providerRouter
.providerIdentities(route)
.map(({ id, name, version }) => ({ id, name, version }));
const capabilities =
route.capabilities === undefined
? []
: [...route.capabilities.values()]
.sort((left, right) =>
left.operation.localeCompare(right.operation),
)
.map((descriptor) => ({
...descriptor,
...this.#runtimeAvailability.get(descriptor.operation),
}))
.map((descriptor) => capabilityStatus(descriptor));
const analysisProviderBinding =
route.binding === null
? null
: {
provider: {
id: route.binding.identity.id,
name: route.binding.identity.name,
version: route.binding.identity.version,
},
selection_source: route.binding.selectionSource,
analysis_profile: structuredClone(route.binding.profile),
};
const analysisProviderCandidates = this.#providerRouter
.candidateStatuses(route)
.map((candidate) => ({
provider: {
id: candidate.provider.id,
name: candidate.provider.name,
version: candidate.provider.version,
},
availability: {
status: candidate.availability.status,
code: candidate.availability.code,
reason: candidate.availability.reason,
diagnostics: structuredClone(candidate.availability.diagnostics),
},
target_support: {
status: candidate.targetSupport.status,
code: candidate.targetSupport.code,
reason: candidate.targetSupport.reason,
diagnostics: structuredClone(candidate.targetSupport.diagnostics),
},
selected: candidate.selected,
capabilities: candidate.capabilities
.slice()
.sort((left, right) => left.operation.localeCompare(right.operation))
.map((descriptor) => capabilityStatus(descriptor)),
}));
const common = {
provider,
providers: providerList,
capabilities,
analysis_provider_binding: analysisProviderBinding,
analysis_provider_candidates: analysisProviderCandidates,
};
return target === undefined
? { open: false, ...common }
: {
open: true,
...common,
path: target.path,
sha256: target.sha256,
format: target.format,
kind: target.kind,
architecture: target.architecture ?? null,
};
return binarySessionStatus({
target: this.#active?.target,
route: this.#currentRoute(),
router: this.#providerRouter,
runtimeAvailability: this.#runtimeAvailability,
});
}
/** Return the immutable artifact identity captured before its provider started. */
@@ -618,6 +250,10 @@ export class BinarySession implements BinarySessionPort {
: structuredClone(this.#active.target);
}
protected activeAnalysisBinding() {
return this.#active;
}
/**
* Invoke a provider operation against the active target.
* Calls may overlap, but a pending target transition prevents new calls from
@@ -630,38 +266,17 @@ export class BinarySession implements BinarySessionPort {
): Promise<Result<AnalysisExecution, AnalysisError>> {
const transitioned = await this.#waitForTransition(name, options?.signal);
if (!transitioned.ok) return transitioned;
const active = this.#active;
if (active === undefined) return err(new NoBinaryOpenError());
const capability = active.route.capabilities?.get(name);
if (
active.route.capabilities !== undefined &&
capability?.available !== true
) {
const selectionError = this.#providerRouter.unboundOperationError(
name,
active.route,
);
if (selectionError !== undefined) return err(selectionError);
return err(
new AnalysisCapabilityUnavailableError(
active.route.binding?.identity.id ?? active.route.identity.id,
name,
capability?.reason ?? "operation is not declared by this provider",
),
);
}
const profile =
active.profile !== null &&
(capability === undefined ||
capability.provider.id === active.profile.provider.id)
? active.profile
: undefined;
const cacheable =
profile !== undefined &&
isSnapshotCacheable(name, capability, arguments_);
const cached = cacheable
? this.#snapshot.lookup(active.target, profile, name, arguments_)
: undefined;
const prepared = prepareSessionExecution({
active: this.#active,
operation: name,
parameters: arguments_,
unboundOperationError: (operation, route) =>
this.#providerRouter.unboundOperationError(operation, route),
lookupSnapshot: (target, profile, operation, parameters) =>
this.lookupSnapshot(target, profile, operation, parameters),
});
if (!prepared.ok) return prepared;
const { active, capability, profile, cacheable, cached } = prepared.value;
if (cached !== undefined) return ok(cached);
const call = active.client.execute(name, arguments_, options);
this.#calls.add(call);
@@ -669,8 +284,8 @@ export class BinarySession implements BinarySessionPort {
const result = await call;
const profiled = commitExecutionProfile(name, result, profile);
this.#observeRuntimeAvailability(name, profiled);
if (profiled.ok && cacheable)
this.#snapshot.record({
if (profiled.ok && cacheable && profile !== undefined)
this.recordSnapshot({
target: active.target,
profile,
operation: name,
@@ -678,8 +293,7 @@ export class BinarySession implements BinarySessionPort {
execution: profiled.value,
});
else if (profiled.ok && capability?.effects.mutatesArtifact === true) {
this.#snapshot.clear();
this.#snapshotInvalidated = true;
this.invalidateSnapshot();
}
return profiled;
} finally {
@@ -859,33 +473,3 @@ const commitExecutionProfile = (
analysisProfile: structuredClone(profile),
});
};
const capabilityStatus = (
descriptor: Omit<CapabilityDescriptor, "available" | "reason"> & {
readonly available: boolean;
readonly reason: string | null;
},
) => ({
operation: descriptor.operation,
available: descriptor.available,
reason: descriptor.reason,
input_contract_version: descriptor.inputContractVersion,
output_contract_version: descriptor.outputContractVersion,
pagination: descriptor.pagination,
exhaustive: descriptor.exhaustive,
effects: {
mutates_artifact: descriptor.effects.mutatesArtifact,
launches_process: descriptor.effects.launchesProcess,
may_show_ui: descriptor.effects.mayShowUi,
may_access_network: descriptor.effects.mayAccessNetwork,
may_write_filesystem: descriptor.effects.mayWriteFilesystem,
changes_permissions: descriptor.effects.changesPermissions,
requires_root: descriptor.effects.requiresRoot,
},
limits: {
max_results: descriptor.limits.maxResults,
max_payload_bytes: descriptor.limits.maxPayloadBytes,
timeout_ms: descriptor.limits.timeoutMs,
},
limitations: [...descriptor.limitations],
});
+90
View File
@@ -0,0 +1,90 @@
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
import type { BinaryTarget } from "../domain/binaryTarget.js";
import {
AnalysisCapabilityUnavailableError,
NoBinaryOpenError,
type AnalysisError,
} from "../domain/errors.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { err, ok, type Result } from "../domain/result.js";
import type {
AnalysisClient,
AnalysisExecution,
AnalysisOperation,
CapabilityDescriptor,
} from "./AnalysisProvider.js";
import { isSnapshotCacheable } from "./AnalysisSnapshotCache.js";
import type { SessionProviderRoute } from "./SessionProviderRouter.js";
interface ActiveExecutionBinding {
readonly target: BinaryTarget;
readonly client: AnalysisClient;
readonly profile: AnalysisProfileCommitment | null;
readonly route: SessionProviderRoute;
}
export interface PreparedSessionExecution {
readonly active: ActiveExecutionBinding;
readonly capability: CapabilityDescriptor | undefined;
readonly profile: AnalysisProfileCommitment | undefined;
readonly cacheable: boolean;
readonly cached: AnalysisExecution | undefined;
}
interface PrepareSessionExecutionInput {
readonly active: ActiveExecutionBinding | undefined;
readonly operation: AnalysisOperation;
readonly parameters: Readonly<Record<string, JsonValue>>;
readonly unboundOperationError: (
operation: AnalysisOperation,
route: SessionProviderRoute,
) => AnalysisError | undefined;
readonly lookupSnapshot: (
target: BinaryTarget,
profile: AnalysisProfileCommitment,
operation: AnalysisOperation,
parameters: Readonly<Record<string, JsonValue>>,
) => AnalysisExecution | undefined;
}
/** Resolve capability, profile, and snapshot state before invoking a provider. */
export const prepareSessionExecution = (
input: PrepareSessionExecutionInput,
): Result<PreparedSessionExecution, AnalysisError> => {
const {
active,
operation,
parameters,
unboundOperationError,
lookupSnapshot,
} = input;
if (active === undefined) return err(new NoBinaryOpenError());
const capability = active.route.capabilities?.get(operation);
if (
active.route.capabilities !== undefined &&
capability?.available !== true
) {
const selectionError = unboundOperationError(operation, active.route);
if (selectionError !== undefined) return err(selectionError);
return err(
new AnalysisCapabilityUnavailableError(
active.route.binding?.identity.id ?? active.route.identity.id,
operation,
capability?.reason ?? "operation is not declared by this provider",
),
);
}
const profile =
active.profile !== null &&
(capability === undefined ||
capability.provider.id === active.profile.provider.id)
? active.profile
: undefined;
const cacheable =
profile !== undefined &&
isSnapshotCacheable(operation, capability, parameters);
const cached = cacheable
? lookupSnapshot(active.target, profile, operation, parameters)
: undefined;
return ok({ active, capability, profile, cacheable, cached });
};
+114
View File
@@ -0,0 +1,114 @@
import type { AnalysisProviderSelector } from "../contracts/providerSelection.js";
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
import {
snapshotMatchesBinding,
snapshotMatchesTarget,
snapshotTarget,
} from "../domain/analysisSnapshot.js";
import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js";
import {
parseBinaryTarget,
type BinaryTarget,
} from "../domain/binaryTarget.js";
import {
EvidenceIntegrityError,
type AnalysisError,
} from "../domain/errors.js";
import { err, type Result } from "../domain/result.js";
import type { SessionProviderRoute } from "./SessionProviderRouter.js";
import { SessionProviderRouter } from "./SessionProviderRouter.js";
export interface BinarySessionOpenOptions {
readonly signal?: AbortSignal;
readonly targetKind?: BinaryTarget["kind"];
readonly snapshot?: AnalysisSnapshot;
readonly providerId?: AnalysisProviderSelector;
}
interface CurrentOpenBinding {
readonly target: BinaryTarget;
readonly profile: AnalysisProfileCommitment | null;
readonly route: SessionProviderRoute;
}
export interface ResolvedSessionOpen {
readonly target: BinaryTarget;
readonly route: SessionProviderRoute;
readonly sameTarget: boolean;
}
interface ResolveSessionOpenInput {
readonly router: SessionProviderRouter;
readonly current: CurrentOpenBinding | undefined;
readonly path: string;
readonly options: BinarySessionOpenOptions;
readonly stagedSnapshotMatches: (
target: BinaryTarget,
profile: AnalysisProfileCommitment | null,
) => boolean;
}
/** Parse a target, resolve its provider route, and validate snapshot binding. */
export const resolveSessionOpen = async (
input: ResolveSessionOpenInput,
): Promise<Result<ResolvedSessionOpen, AnalysisError>> => {
const { router, current, path, options, stagedSnapshotMatches } = input;
const parsed = await parseBinaryTarget(
path,
process.cwd(),
process.arch,
options.targetKind,
);
if (!parsed.ok) return parsed;
const target = parsed.value;
const sameTarget =
current?.target.path === target.path &&
snapshotMatchesTarget(snapshotTarget(current.target), target);
const resolvedRoute =
sameTarget && options.providerId === undefined && current !== undefined
? ({ ok: true, value: current.route } as const)
: await router.resolve(target, options.providerId, options.signal);
if (!resolvedRoute.ok) return resolvedRoute;
const route = resolvedRoute.value;
const snapshotError = validateSnapshot({
snapshot: options.snapshot,
target,
profile: route.profile,
current,
stagedSnapshotMatches,
});
if (snapshotError !== undefined) return err(snapshotError);
return { ok: true, value: { target, route, sameTarget } };
};
interface ValidateSnapshotInput {
readonly snapshot: AnalysisSnapshot | undefined;
readonly target: BinaryTarget;
readonly profile: AnalysisProfileCommitment | null;
readonly current: CurrentOpenBinding | undefined;
readonly stagedSnapshotMatches: (
target: BinaryTarget,
profile: AnalysisProfileCommitment | null,
) => boolean;
}
const validateSnapshot = ({
snapshot,
target,
profile,
current,
stagedSnapshotMatches,
}: ValidateSnapshotInput): EvidenceIntegrityError | undefined => {
if (
snapshot !== undefined &&
(profile === null || !snapshotMatchesBinding(snapshot, target, profile))
)
return new EvidenceIntegrityError(
"Analysis snapshot profile_mismatch: target, provider, or analysis profile does not match the requested binary",
);
if (current === undefined && !stagedSnapshotMatches(target, profile))
return new EvidenceIntegrityError(
"Analysis snapshot profile_mismatch: staged target, provider, or analysis profile does not match",
);
return undefined;
};
+307
View File
@@ -0,0 +1,307 @@
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js";
import type { BinaryTarget } from "../domain/binaryTarget.js";
import { createEvidence, type Evidence } from "../domain/evidence.js";
import type { EvidenceBundle } from "../domain/evidenceBundle.js";
import { evidenceBundleForTarget } from "../domain/evidenceBundle.js";
import {
EvidenceIntegrityError,
type AnalysisError,
type EvidenceLimitError,
type UnknownRegistryError,
} from "../domain/errors.js";
import {
parseInvestigationWorkspace,
type InvestigationWorkspace,
} from "../domain/investigationWorkspace.js";
import {
parseReconstructionCoverageWorkspace,
type ReconstructionCoverageWorkspace,
} from "../domain/reconstructionCoverage.js";
import type {
RecordUnknownInput,
ResidualUnknown,
UnknownStatus,
UpdateUnknownInput,
} from "../domain/residualUnknown.js";
import { err, type Result } from "../domain/result.js";
import type {
AnalysisExecution,
AnalysisOperation,
} from "./AnalysisProvider.js";
import { AnalysisSnapshotCache } from "./AnalysisSnapshotCache.js";
import { EvidenceLedger } from "./EvidenceLedger.js";
import {
UNKNOWN_REGISTRY_PROVIDER,
unknownEvidenceLinks,
unknownMutationEvidence,
} from "./UnknownEvidence.js";
export interface ActiveAnalysisBinding {
readonly target: BinaryTarget;
readonly profile: AnalysisProfileCommitment | null;
}
/** Owns session evidence, snapshots, workspaces, and residual unknowns. */
export abstract class BinarySessionRecords {
readonly #evidence = new EvidenceLedger({
maxRecords: 10_000,
maxBytes: 64 * 1024 * 1024,
});
readonly #snapshot = new AnalysisSnapshotCache();
readonly #investigationWorkspaces = new Map<string, InvestigationWorkspace>();
readonly #coverageWorkspaces = new Map<
string,
ReconstructionCoverageWorkspace
>();
#snapshotInvalidated = false;
recordEvidence(
evidence: Evidence,
): Result<
"added" | "duplicate",
EvidenceIntegrityError | EvidenceLimitError
> {
return this.#evidence.record(evidence);
}
hasEvidence(evidenceId: string): boolean {
return this.#evidence.has(evidenceId);
}
evidenceById(evidenceId: string): Evidence | undefined {
return this.#evidence.get(evidenceId);
}
exportEvidenceBundle(): EvidenceBundle {
return this.#evidence.export();
}
importEvidenceBundle(
bundle: unknown,
): Result<number, EvidenceIntegrityError | EvidenceLimitError> {
return this.#evidence.import(bundle);
}
protected abstract activeAnalysisBinding(): ActiveAnalysisBinding | undefined;
exportAnalysisSnapshot(): Result<AnalysisSnapshot, AnalysisError> {
const active = this.activeAnalysisBinding();
if (this.#snapshotInvalidated)
return err(
new EvidenceIntegrityError(
"Analysis snapshots are unavailable after analysis metadata mutations",
),
);
const target = active?.target;
const profile = active?.profile ?? undefined;
if (target !== undefined && profile === undefined)
return err(
new EvidenceIntegrityError(
"Analysis snapshots require a concrete provider analysis profile",
),
);
return this.#snapshot.export(
target,
profile,
target === undefined
? this.#evidence.export()
: evidenceBundleForTarget(this.#evidence.export(), target.sha256),
);
}
importAnalysisSnapshot(
snapshot: AnalysisSnapshot,
): Result<number, AnalysisError> {
const active = this.activeAnalysisBinding();
if (active?.profile === null)
return err(
new EvidenceIntegrityError(
"Analysis snapshot profile_mismatch: the active target has no concrete analysis profile",
),
);
return this.#snapshot.import(
snapshot,
active === undefined
? undefined
: { target: active.target, profile: active.profile },
(bundle) => this.#evidence.import(bundle),
);
}
protected matchesSnapshot(
target: BinaryTarget,
profile: AnalysisProfileCommitment | null,
): boolean {
return this.#snapshot.matches(target, profile ?? undefined);
}
protected selectSnapshot(
target: BinaryTarget,
profile: AnalysisProfileCommitment,
): void {
this.#snapshot.select(target, profile);
}
protected lookupSnapshot(
target: BinaryTarget,
profile: AnalysisProfileCommitment,
operation: AnalysisOperation,
parameters: Readonly<
Record<string, import("../domain/jsonValue.js").JsonValue>
>,
): AnalysisExecution | undefined {
return this.#snapshot.lookup(target, profile, operation, parameters);
}
protected recordSnapshot(
input: Parameters<AnalysisSnapshotCache["record"]>[0],
): void {
this.#snapshot.record(input);
}
protected invalidateSnapshot(): void {
this.#snapshot.clear();
this.#snapshotInvalidated = true;
}
protected resetSnapshotInvalidation(): void {
this.#snapshotInvalidated = false;
}
protected clearSnapshot(): void {
this.#snapshot.clear();
}
protected clearSessionRecords(): void {
this.#evidence.clear();
this.#snapshot.clear();
this.#snapshotInvalidated = false;
}
retainInvestigationWorkspace(
workspace: InvestigationWorkspace,
): "added" | "duplicate" {
const parsed = parseInvestigationWorkspace(workspace);
const key = workspaceKey(parsed.workspace_id, parsed.revision);
if (this.#investigationWorkspaces.has(key)) return "duplicate";
this.#investigationWorkspaces.set(key, parsed);
return "added";
}
investigationWorkspace(
workspaceId: string,
revision: number,
): InvestigationWorkspace | undefined {
const workspace = this.#investigationWorkspaces.get(
workspaceKey(workspaceId, revision),
);
return workspace === undefined ? undefined : structuredClone(workspace);
}
investigationWorkspaces(): readonly InvestigationWorkspace[] {
return sortedWorkspaces(this.#investigationWorkspaces.values());
}
retainReconstructionCoverageWorkspace(
workspace: ReconstructionCoverageWorkspace,
): "added" | "duplicate" {
const parsed = parseReconstructionCoverageWorkspace(workspace);
const key = workspaceKey(parsed.workspace_id, parsed.revision);
if (this.#coverageWorkspaces.has(key)) return "duplicate";
this.#coverageWorkspaces.set(key, parsed);
return "added";
}
reconstructionCoverageWorkspace(
workspaceId: string,
revision: number,
): ReconstructionCoverageWorkspace | undefined {
const workspace = this.#coverageWorkspaces.get(
workspaceKey(workspaceId, revision),
);
return workspace === undefined ? undefined : structuredClone(workspace);
}
reconstructionCoverageWorkspaces(): readonly ReconstructionCoverageWorkspace[] {
return sortedWorkspaces(this.#coverageWorkspaces.values());
}
recordUnknown(
input: RecordUnknownInput,
): Result<ResidualUnknown, AnalysisError> {
const target = this.activeAnalysisBinding()?.target;
return this.#evidence.recordUnknown(
input,
unknownMutationEvidence(target, input),
);
}
recordEvidenceWithUnknown(
evidence: Evidence,
input: RecordUnknownInput,
): Result<ResidualUnknown | null, AnalysisError> {
return this.#evidence.recordWithUnknown(
evidence,
input,
unknownMutationEvidence(undefined, input),
);
}
updateUnknown(
input: UpdateUnknownInput,
): Result<ResidualUnknown, AnalysisError> {
const target = this.activeAnalysisBinding()?.target;
const evidence = createEvidence(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.",
],
});
return this.#evidence.updateUnknown(input, evidence);
}
listUnknowns(filters: {
readonly status?: UnknownStatus;
readonly severity?: ResidualUnknown["severity"];
readonly domain?: string;
}): ResidualUnknown[] {
return this.#evidence.listUnknowns(filters);
}
verifyUnknownResolution(unknownId: string): Result<
{
readonly valid: boolean;
readonly truthVerified: boolean;
readonly unknown: ResidualUnknown;
},
UnknownRegistryError
> {
return this.#evidence.verifyUnknownResolution(unknownId);
}
}
const workspaceKey = (workspaceId: string, revision: number): string =>
`${workspaceId}:${String(revision)}`;
const sortedWorkspaces = <
T extends { readonly workspace_id: string; readonly revision: number },
>(
workspaces: Iterable<T>,
): readonly T[] =>
[...workspaces]
.sort(
(left, right) =>
left.workspace_id.localeCompare(right.workspace_id) ||
left.revision - right.revision,
)
.map((workspace) => structuredClone(workspace));
+114
View File
@@ -0,0 +1,114 @@
import type { BinaryTarget } from "../domain/binaryTarget.js";
import type { JsonValue } from "../domain/jsonValue.js";
import type { CapabilityDescriptor } from "./AnalysisProvider.js";
import type {
SessionProviderRoute,
SessionProviderRouter,
} from "./SessionProviderRouter.js";
interface BinarySessionStatusInput {
readonly target: BinaryTarget | undefined;
readonly route: SessionProviderRoute;
readonly router: SessionProviderRouter;
readonly runtimeAvailability: ReadonlyMap<
string,
{ readonly available: boolean; readonly reason: string | null }
>;
}
/** Project internal provider routing state into the caller-visible session status. */
export const binarySessionStatus = ({
target,
route,
router,
runtimeAvailability,
}: BinarySessionStatusInput): JsonValue => {
const configuredProvider = router.configuredIdentity();
const provider = providerSummary(configuredProvider);
const providers = router.providerIdentities(route).map(providerSummary);
const capabilities = [...(route.capabilities?.values() ?? [])]
.sort((left, right) => left.operation.localeCompare(right.operation))
.map((descriptor) => ({
...descriptor,
...runtimeAvailability.get(descriptor.operation),
}))
.map(capabilityStatus);
const analysisProviderBinding =
route.binding === null
? null
: {
provider: providerSummary(route.binding.identity),
selection_source: route.binding.selectionSource,
analysis_profile: structuredClone(route.binding.profile),
};
const analysisProviderCandidates = router
.candidateStatuses(route)
.map((candidate) => ({
provider: providerSummary(candidate.provider),
availability: structuredClone(candidate.availability),
target_support: structuredClone(candidate.targetSupport),
selected: candidate.selected,
capabilities: candidate.capabilities
.slice()
.sort((left, right) => left.operation.localeCompare(right.operation))
.map(capabilityStatus),
}));
const common = {
provider,
providers,
capabilities,
analysis_provider_binding: analysisProviderBinding,
analysis_provider_candidates: analysisProviderCandidates,
};
return target === undefined
? { open: false, ...common }
: {
open: true,
...common,
path: target.path,
sha256: target.sha256,
format: target.format,
kind: target.kind,
architecture: target.architecture ?? null,
};
};
const providerSummary = ({
id,
name,
version,
}: {
readonly id: string;
readonly name: string;
readonly version: string | null;
}) => ({ id, name, version });
const capabilityStatus = (
descriptor: Omit<CapabilityDescriptor, "available" | "reason"> & {
readonly available: boolean;
readonly reason: string | null;
},
) => ({
operation: descriptor.operation,
available: descriptor.available,
reason: descriptor.reason,
input_contract_version: descriptor.inputContractVersion,
output_contract_version: descriptor.outputContractVersion,
pagination: descriptor.pagination,
exhaustive: descriptor.exhaustive,
effects: {
mutates_artifact: descriptor.effects.mutatesArtifact,
launches_process: descriptor.effects.launchesProcess,
may_show_ui: descriptor.effects.mayShowUi,
may_access_network: descriptor.effects.mayAccessNetwork,
may_write_filesystem: descriptor.effects.mayWriteFilesystem,
changes_permissions: descriptor.effects.changesPermissions,
requires_root: descriptor.effects.requiresRoot,
},
limits: {
max_results: descriptor.limits.maxResults,
max_payload_bytes: descriptor.limits.maxPayloadBytes,
timeout_ms: descriptor.limits.timeoutMs,
},
limitations: [...descriptor.limitations],
});
+8 -14
View File
@@ -53,20 +53,14 @@ export const scanVersionInventories = async (
scanner: VersionInventoryScanner = scanAuthorizedArtifactInventory,
): Promise<VersionSnapshots> => {
const [left, right] = await Promise.all([
scanner(
options.leftPath,
options.inputRoots,
options.limits,
options.signal,
options.integrity,
),
scanner(
options.rightPath,
options.inputRoots,
options.limits,
options.signal,
options.integrity,
),
scanner(options.leftPath, options.inputRoots, options.limits, {
signal: options.signal,
integrity: options.integrity,
}),
scanner(options.rightPath, options.inputRoots, options.limits, {
signal: options.signal,
integrity: options.integrity,
}),
]);
return { left, right };
};
+78 -59
View File
@@ -23,6 +23,7 @@ import {
writeAnalysisSnapshot,
} from "./AnalysisSnapshotFiles.js";
import { parseBinaryTarget } from "../domain/binaryTarget.js";
import type { BinaryTarget } from "../domain/binaryTarget.js";
import {
snapshotEvidenceForQuery,
snapshotMatchesTarget,
@@ -84,11 +85,13 @@ export const runDirectAnalysis = async (
/** Execute one provider-native semantic operation with atomic provenance. */
export const runProviderAnalysis = async (
path: string,
tool: NativeToolName | ArtifactToolName | ManagedToolName,
arguments_: Readonly<Record<string, JsonValue>>,
logger: Logger = silentLogger,
signal?: AbortSignal,
...[path, tool, arguments_, logger = silentLogger, signal]: readonly [
path: string,
tool: NativeToolName | ArtifactToolName | ManagedToolName,
arguments_: Readonly<Record<string, JsonValue>>,
logger?: Logger,
signal?: AbortSignal,
]
): Promise<JsonValue> =>
withProcessCancellation(signal, (operationSignal) =>
runAnalysis(path, tool, arguments_, {
@@ -310,60 +313,14 @@ const runAnalysis = async (
}
const writable = await authorizeDeferredWrite(authorization.value);
if (!writable.ok) return cliError(writable.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_,
signal,
);
if (!result.ok) output = cliError(result.error);
else {
evidence = createEvidence(
opened.value,
tool === "analyze_function"
? session.providerIdentity(tool)
: REA_WORKFLOW_PROVIDER,
{
operation: tool,
parameters: arguments_,
result: result.value,
...(evidenceProfile === undefined
? {}
: { analysisProfile: evidenceProfile }),
confidence: "derived",
limitations: ["Derived by an REA composed workflow."],
},
);
output = evidence;
}
} else {
const result = await session.execute(tool, arguments_, { signal });
if (!result.ok) output = cliError(result.error);
else {
evidence = createEvidence(
result.value.subject ?? opened.value,
result.value.provider,
{
operation: tool,
parameters: arguments_,
result: result.value.result,
...(result.value.analysisProfile === undefined
? {}
: { analysisProfile: result.value.analysisProfile }),
rawResult: result.value.rawResult,
limitations: result.value.limitations,
locations: result.value.locations,
},
);
output = evidence;
}
}
const { output, evidence } = await executeAnalysisTool({
session,
openedTarget: opened.value,
tool,
arguments: arguments_,
signal,
evidenceProfile,
});
if (evidence !== undefined) session.recordEvidence(evidence);
if (snapshotPath !== undefined && evidence !== undefined) {
const snapshot = session.exportAnalysisSnapshot();
@@ -382,6 +339,68 @@ const runAnalysis = async (
}
};
const executeAnalysisTool = async (input: {
readonly session: ReturnType<typeof createBinarySession>;
readonly openedTarget: BinaryTarget;
readonly tool:
| NativeToolName
| ArtifactToolName
| ManagedToolName
| DirectAnalysisTool;
readonly arguments: Readonly<Record<string, JsonValue>>;
readonly signal: AbortSignal;
readonly evidenceProfile: AnalysisProfileCommitment | undefined;
}): Promise<{ readonly output: JsonValue; readonly evidence?: Evidence }> => {
const { session, tool, signal, evidenceProfile } = input;
if (
tool === "binary_overview" ||
tool === "analyze_function" ||
tool === "trace_feature"
) {
const result = await new EnhancedTools(session).execute(
tool,
input.arguments,
signal,
);
if (!result.ok) return { output: cliError(result.error) };
const evidence = createEvidence(
input.openedTarget,
tool === "analyze_function"
? session.providerIdentity(tool)
: REA_WORKFLOW_PROVIDER,
{
operation: tool,
parameters: input.arguments,
result: result.value,
...(evidenceProfile === undefined
? {}
: { analysisProfile: evidenceProfile }),
confidence: "derived",
limitations: ["Derived by an REA composed workflow."],
},
);
return { output: evidence, evidence };
}
const result = await session.execute(tool, input.arguments, { signal });
if (!result.ok) return { output: cliError(result.error) };
const evidence = createEvidence(
result.value.subject ?? input.openedTarget,
result.value.provider,
{
operation: tool,
parameters: input.arguments,
result: result.value.result,
...(result.value.analysisProfile === undefined
? {}
: { analysisProfile: result.value.analysisProfile }),
rawResult: result.value.rawResult,
limitations: result.value.limitations,
locations: result.value.locations,
},
);
return { output: evidence, evidence };
};
const withProcessCancellation = async <Value>(
suppliedSignal: AbortSignal | undefined,
operation: (signal: AbortSignal) => Promise<Value>,
+90 -96
View File
@@ -284,48 +284,13 @@ export const systemDoctorHost = (
...(process.env.REA_ILSPY_CMD_PATH === undefined
? {}
: { configuredIlspyCmdPath: process.env.REA_ILSPY_CMD_PATH }),
async macosVersion() {
try {
return (
await execFileAsync("sw_vers", ["-productVersion"])
).stdout.trim();
} catch {
return undefined;
}
},
macosVersion: readMacosVersion,
linuxDistribution: readLinuxDistribution,
async validTarget(path) {
return (await parseBinaryTarget(path)).ok;
},
async executable(path) {
try {
await access(path, constants.X_OK);
if (process.platform === "linux") {
const linked = await execFileAsync("ldd", [path]);
if (
!linuxSharedLibrariesAvailable(`${linked.stdout}\n${linked.stderr}`)
)
return false;
}
return true;
} catch {
return false;
}
},
async linuxDemoRuntimeReady() {
try {
await access("/usr/bin/Xvfb", constants.X_OK);
await access("/usr/bin/xauth", constants.X_OK);
await access("/usr/bin/python3", constants.X_OK);
await execFileAsync("/usr/bin/python3", [
"-c",
"import ctypes; ctypes.CDLL('libX11.so.6'); ctypes.CDLL('libXtst.so.6')",
]);
return true;
} catch {
return false;
}
},
executable: executableAvailable,
linuxDemoRuntimeReady,
supportedLinuxHopper: linuxHopperBinarySupported,
async brewHopperPath() {
return probeHomebrew(async (command) => {
@@ -343,21 +308,7 @@ export const systemDoctorHost = (
}
});
},
async manualHopperPaths() {
const roots = ["/Applications", join(homedir(), "Applications")];
const paths: string[] = [];
for (const root of roots) {
try {
for (const entry of await readdir(root, { withFileTypes: true })) {
if (entry.isDirectory() && /^Hopper.*\.app$/i.test(entry.name))
paths.push(join(root, entry.name, "Contents/MacOS/hopper"));
}
} catch {
// A missing or unreadable optional application directory is not fatal.
}
}
return paths;
},
manualHopperPaths,
...(options.providerInspections === undefined
? {}
: { providerInspections: options.providerInspections }),
@@ -392,52 +343,95 @@ export const systemDoctorHost = (
return [];
}
},
async installedSkillVersion() {
try {
const content = await readFile(
join(
homedir(),
".agents/skills",
PRODUCT_IDENTITY.skillName,
"SKILL.md",
),
"utf8",
);
return /^\s{2}version:\s*"([^"]+)"\s*$/mu.exec(content)?.[1];
} catch {
return undefined;
}
},
async installedSkillIdentity() {
try {
const content = await readFile(
join(
homedir(),
".agents/skills",
PRODUCT_IDENTITY.skillName,
"SKILL.md",
),
"utf8",
);
const version =
/^\s{2}version:\s*"([^"]+)"\s*$/mu.exec(content)?.[1] ?? null;
const countText = /^\s{2}tool_count:\s*(\d+)\s*$/mu.exec(content)?.[1];
const catalogDigest =
/^\s{2}catalog_digest:\s*"([a-f0-9]{64})"\s*$/mu.exec(content)?.[1] ??
null;
return {
version,
toolCount:
countText === undefined ? null : Number.parseInt(countText, 10),
catalogDigest,
};
} catch {
return undefined;
}
},
installedSkillVersion,
installedSkillIdentity,
clientRegistrations: () => readClientRegistrationStatuses(homedir()),
});
const readMacosVersion = async (): Promise<string | undefined> => {
try {
return (await execFileAsync("sw_vers", ["-productVersion"])).stdout.trim();
} catch {
return undefined;
}
};
const executableAvailable = async (path: string): Promise<boolean> => {
try {
await access(path, constants.X_OK);
if (process.platform !== "linux") return true;
const linked = await execFileAsync("ldd", [path]);
return linuxSharedLibrariesAvailable(`${linked.stdout}\n${linked.stderr}`);
} catch {
return false;
}
};
const linuxDemoRuntimeReady = async (): Promise<boolean> => {
try {
await access("/usr/bin/Xvfb", constants.X_OK);
await access("/usr/bin/xauth", constants.X_OK);
await access("/usr/bin/python3", constants.X_OK);
await execFileAsync("/usr/bin/python3", [
"-c",
"import ctypes; ctypes.CDLL('libX11.so.6'); ctypes.CDLL('libXtst.so.6')",
]);
return true;
} catch {
return false;
}
};
const manualHopperPaths = async (): Promise<readonly string[]> => {
const paths: string[] = [];
for (const root of ["/Applications", join(homedir(), "Applications")]) {
try {
for (const entry of await readdir(root, { withFileTypes: true })) {
if (entry.isDirectory() && /^Hopper.*\.app$/i.test(entry.name))
paths.push(join(root, entry.name, "Contents/MacOS/hopper"));
}
} catch {
// A missing or unreadable optional application directory is not fatal.
}
}
return paths;
};
const readInstalledSkill = (): Promise<string> =>
readFile(
join(homedir(), ".agents/skills", PRODUCT_IDENTITY.skillName, "SKILL.md"),
"utf8",
);
const installedSkillVersion = async (): Promise<string | undefined> => {
try {
return /^\s{2}version:\s*"([^"]+)"\s*$/mu.exec(
await readInstalledSkill(),
)?.[1];
} catch {
return undefined;
}
};
const installedSkillIdentity = async (): Promise<
InstalledSkillIdentity | undefined
> => {
try {
const content = await readInstalledSkill();
const countText = /^\s{2}tool_count:\s*(\d+)\s*$/mu.exec(content)?.[1];
return {
version: /^\s{2}version:\s*"([^"]+)"\s*$/mu.exec(content)?.[1] ?? null,
toolCount:
countText === undefined ? null : Number.parseInt(countText, 10),
catalogDigest:
/^\s{2}catalog_digest:\s*"([a-f0-9]{64})"\s*$/mu.exec(content)?.[1] ??
null,
};
} catch {
return undefined;
}
};
const uniqueLines = (value: string): string[] => [
...new Set(
value
+42
View File
@@ -0,0 +1,42 @@
import type { AnalysisOperation } from "./AnalysisProvider.js";
import { AnalysisOutputError, type AnalysisError } from "../domain/errors.js";
import { parseAddressedPage } from "../domain/hopperValues.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { err, ok, type Result } from "../domain/result.js";
type PageCall = (
name: AnalysisOperation,
arguments_: Readonly<Record<string, JsonValue>>,
signal?: AbortSignal,
) => Promise<Result<JsonValue, AnalysisError>>;
/** Read all addressed entries while enforcing advancing provider pagination. */
export const readAllAddressed = async (input: {
readonly call: PageCall;
readonly tool: "list_names" | "list_procedures";
readonly signal?: AbortSignal;
}) => {
const entries: Array<{ address: string; name: string }> = [];
let offset = 0;
while (true) {
const result = await input.call(
input.tool,
{ offset, limit: 500 },
input.signal,
);
if (!result.ok) return result;
const page = parseAddressedPage(result.value);
if (!page.ok) return page;
entries.push(...page.value.items);
if (!page.value.hasMore || page.value.nextOffset === null)
return ok(entries);
if (page.value.nextOffset <= offset)
return err(
new AnalysisOutputError(
input.tool,
"provider returned a non-advancing pagination offset",
),
);
offset = page.value.nextOffset;
}
};
+35
View File
@@ -0,0 +1,35 @@
import type { z } from "zod";
import { AnalysisInputError, type AnalysisError } from "../domain/errors.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { err, type Result } from "../domain/result.js";
import {
enhancedInputSchemas,
type EnhancedToolName,
} from "../contracts/enhancedInputs.js";
export type EnhancedResult = Promise<Result<JsonValue, AnalysisError>>;
export type TraceMatch = {
readonly type: string;
readonly address: string;
readonly value: string;
};
export type TraceReference = {
readonly target_address: string;
readonly source_address: string;
readonly containing_procedure: JsonValue;
};
/** One enhanced call whose input was parsed at its owning adapter boundary. */
export type ValidatedEnhancedCall = {
[Name in EnhancedToolName]: {
readonly name: Name;
readonly input: z.output<(typeof enhancedInputSchemas)[Name]>;
};
}[EnhancedToolName];
export const invalidEnhancedInput = (
name: EnhancedToolName,
cause: Error,
): EnhancedResult =>
Promise.resolve(err(new AnalysisInputError(name, { cause })));
+27 -63
View File
@@ -2,11 +2,9 @@ import type {
AnalysisOperation,
AnalysisOperationPort,
} from "./AnalysisProvider.js";
import type { z } from "zod";
import type { EnhancedToolName } from "../contracts/enhancedInputs.js";
import { enhancedInputSchemas } from "../contracts/enhancedInputs.js";
import {
AnalysisInputError,
AnalysisCancelledError,
AnalysisOutputError,
projectAnalysisError,
@@ -14,9 +12,9 @@ import {
} from "../domain/errors.js";
import {
addressDistance,
parseAddressedPage,
parseDocuments,
parseFunctionDossier,
parseAddressedPage,
parseListCount,
parseRelatedAddresses,
parseSegments,
@@ -30,21 +28,15 @@ import {
} from "../domain/symbolAnalysis.js";
import type { JsonValue } from "../domain/jsonValue.js";
type EnhancedResult = Promise<Result<JsonValue, AnalysisError>>;
type TraceMatch = { type: string; address: string; value: string };
type TraceReference = {
target_address: string;
source_address: string;
containing_procedure: JsonValue;
};
/** One enhanced call whose input was parsed at its owning adapter boundary. */
export type ValidatedEnhancedCall = {
[Name in EnhancedToolName]: {
readonly name: Name;
readonly input: z.output<(typeof enhancedInputSchemas)[Name]>;
};
}[EnhancedToolName];
import {
invalidEnhancedInput,
type EnhancedResult,
type TraceMatch,
type TraceReference,
type ValidatedEnhancedCall,
} from "./EnhancedToolTypes.js";
import { readAllAddressed } from "./EnhancedToolPagination.js";
export type { ValidatedEnhancedCall } from "./EnhancedToolTypes.js";
/**
* Composes direct provider operations into bounded reverse-engineering tools.
@@ -65,13 +57,13 @@ export class EnhancedTools {
const parsed = enhancedInputSchemas.swift_classes.safeParse(input);
return parsed.success
? this.executeValidated({ name, input: parsed.data }, signal)
: invalidInput(name, parsed.error);
: invalidEnhancedInput(name, parsed.error);
}
case "get_objc_classes": {
const parsed = enhancedInputSchemas.get_objc_classes.safeParse(input);
return parsed.success
? this.executeValidated({ name, input: parsed.data }, signal)
: invalidInput(name, parsed.error);
: invalidEnhancedInput(name, parsed.error);
}
case "get_objc_protocols":
return this.executeValidated({ name, input: {} }, signal);
@@ -79,13 +71,13 @@ export class EnhancedTools {
const parsed = enhancedInputSchemas.batch_decompile.safeParse(input);
return parsed.success
? this.executeValidated({ name, input: parsed.data }, signal)
: invalidInput(name, parsed.error);
: invalidEnhancedInput(name, parsed.error);
}
case "get_call_graph": {
const parsed = enhancedInputSchemas.get_call_graph.safeParse(input);
return parsed.success
? this.executeValidated({ name, input: parsed.data }, signal)
: invalidInput(name, parsed.error);
: invalidEnhancedInput(name, parsed.error);
}
case "analyze_swift_types":
return this.executeValidated({ name, input: {} }, signal);
@@ -93,25 +85,25 @@ export class EnhancedTools {
const parsed = enhancedInputSchemas.find_xrefs_to_name.safeParse(input);
return parsed.success
? this.executeValidated({ name, input: parsed.data }, signal)
: invalidInput(name, parsed.error);
: invalidEnhancedInput(name, parsed.error);
}
case "binary_overview": {
const parsed = enhancedInputSchemas.binary_overview.safeParse(input);
return parsed.success
? this.executeValidated({ name, input: parsed.data }, signal)
: invalidInput(name, parsed.error);
: invalidEnhancedInput(name, parsed.error);
}
case "analyze_function": {
const parsed = enhancedInputSchemas.analyze_function.safeParse(input);
return parsed.success
? this.executeValidated({ name, input: parsed.data }, signal)
: invalidInput(name, parsed.error);
: invalidEnhancedInput(name, parsed.error);
}
case "trace_feature": {
const parsed = enhancedInputSchemas.trace_feature.safeParse(input);
return parsed.success
? this.executeValidated({ name, input: parsed.data }, signal)
: invalidInput(name, parsed.error);
: invalidEnhancedInput(name, parsed.error);
}
}
}
@@ -154,7 +146,7 @@ export class EnhancedTools {
}
async #swiftClasses(pattern: string, signal?: AbortSignal): EnhancedResult {
const procedures = await this.#allProcedures(signal);
const procedures = await this.#allAddressed("list_procedures", signal);
return procedures.ok
? ok(discoverSwiftClasses(procedures.value, pattern))
: procedures;
@@ -276,7 +268,7 @@ export class EnhancedTools {
}
async #analyzeSwiftTypes(signal?: AbortSignal): EnhancedResult {
const procedures = await this.#allProcedures(signal);
const procedures = await this.#allAddressed("list_procedures", signal);
return procedures.ok
? ok(categorizeSwiftTypes(procedures.value))
: procedures;
@@ -321,7 +313,7 @@ export class EnhancedTools {
await Promise.all([
this.#call("list_segments", {}, signal),
this.#call("list_documents", {}, signal),
this.#allProcedures(signal),
this.#allAddressed("list_procedures", signal),
this.#call("list_strings", {}, signal),
]);
if (!segmentsResult.ok) return segmentsResult;
@@ -507,35 +499,16 @@ export class EnhancedTools {
});
}
async #allProcedures(signal?: AbortSignal) {
return this.#allAddressed("list_procedures", signal);
}
async #allAddressed(
tool: "list_names" | "list_procedures",
signal?: AbortSignal,
) {
const entries: Array<{ address: string; name: string }> = [];
let offset = 0;
while (true) {
const result = await this.#call(tool, { offset, limit: 500 }, signal);
if (!result.ok) return result;
const page = parseAddressedPage(result.value);
if (!page.ok) return page;
entries.push(...page.value.items);
if (!page.value.hasMore || page.value.nextOffset === null) {
return ok(entries);
}
if (page.value.nextOffset <= offset) {
return err(
new AnalysisOutputError(
tool,
"provider returned a non-advancing pagination offset",
),
);
}
offset = page.value.nextOffset;
}
return readAllAddressed({
call: (name, arguments_, operationSignal) =>
this.#call(name, arguments_, operationSignal),
tool,
...(signal === undefined ? {} : { signal }),
});
}
async #call(
@@ -552,12 +525,3 @@ export class EnhancedTools {
return execution.ok ? ok(execution.value.result) : execution;
}
}
const invalidInput = (name: EnhancedToolName, cause: Error): EnhancedResult =>
Promise.resolve(
err(
new AnalysisInputError(name, {
cause,
}),
),
);
+12 -9
View File
@@ -65,6 +65,12 @@ export const readRevisionedWorkspace = async <Document>(
}
};
export interface RevisionedWorkspaceWriteInput<Document> {
readonly document: Document;
readonly expectedRevision: number | null;
readonly codec: RevisionedWorkspaceCodec<Document>;
}
/** Atomically append one CAS-linked workspace revision under an exclusive lock. */
export const writeInvestigationWorkspace = async (
workspace: InvestigationWorkspace,
@@ -74,24 +80,21 @@ export const writeInvestigationWorkspace = async (
): Promise<
WorkspaceResult<{ readonly path: string; readonly bytes: number }>
> =>
writeRevisionedWorkspace(
workspace,
path,
writeRevisionedWorkspace(path, policy, {
document: workspace,
expectedRevision,
policy,
investigationWorkspaceCodec,
);
codec: investigationWorkspaceCodec,
});
/** Atomically append one validated CAS-linked revision under an exclusive lock. */
export const writeRevisionedWorkspace = async <Document>(
document: Document,
path: string,
expectedRevision: number | null,
policy: EvidenceFilePolicy,
codec: RevisionedWorkspaceCodec<Document>,
input: RevisionedWorkspaceWriteInput<Document>,
): Promise<
WorkspaceResult<{ readonly path: string; readonly bytes: number }>
> => {
const { document, expectedRevision, codec } = input;
if (policy.roots.length === 0)
return err(new InvestigationWorkspaceError("update", "disabled"));
let encoded: string;
@@ -60,7 +60,7 @@ export const reconstructJavaScriptArtifact = async (
const snapshot = await scanCanonicalArtifactInventory(
path,
artifactLimitsForReconstruction(input),
signal,
{ signal },
);
if (snapshot.manifest.root_format !== format)
throw new ArtifactReaderFailure(
@@ -0,0 +1,26 @@
import type { z } from "zod";
import type { controlledReplayInputSchema } from "../domain/javascriptReplay.js";
import type { JavaScriptReplayPolicy } from "./JavaScriptReplayPlanning.js";
/** Build the exact permission request for one controlled replay operation. */
export const replayPermissionRequest = (
input: z.output<typeof controlledReplayInputSchema>,
policy: JavaScriptReplayPolicy,
) => ({
capability: "javascript_replay" as const,
roots: [...input.left.modules, ...(input.right?.modules ?? [])].map(
({ path }) => path,
),
executables: [
policy.nodePath,
policy.bubblewrapPath,
policy.systemdRunPath,
policy.systemctlPath,
policy.shellPath,
],
environment_names: [],
network: "none" as const,
mount: true,
operation_identity: `run_controlled_replay:${input.plan_digest ?? "plan"}`,
});
+298 -205
View File
@@ -22,6 +22,7 @@ import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import { err, ok, type Result } from "../domain/result.js";
import type { ExecutionOptions } from "./AnalysisProvider.js";
import type { PermissionAuthority } from "./PermissionAuthority.js";
import { replayPermissionRequest } from "./JavaScriptReplayPermission.js";
import {
digestBytes,
prepareReplayPlan,
@@ -65,6 +66,74 @@ export const runControlledReplayValidated = async (
input: z.output<typeof controlledReplayInputSchema>,
options: ExecutionOptions = {},
): Promise<Result<JsonValue, AnalysisError>> => {
const authorized = await authorizeReplay(dependencies, input, options.signal);
if (!authorized.ok) return authorized;
const prepared = await prepareValidatedReplay(
dependencies,
input,
options.signal,
);
if (!prepared.ok) return prepared;
if (input.mode === "plan")
return ok(
asJson({
phase: "plan",
plan: prepared.value.publicPlan,
source_evidence: [],
evidence: null,
}),
);
if (input.plan_digest !== prepared.value.publicPlan.plan_digest)
return err(
new ReplayPlanStaleError(
input.plan_digest ?? "",
prepared.value.publicPlan.plan_digest,
),
);
const exportContext = await authorizeReproducerExport(
dependencies.authority,
input,
prepared.value,
);
if (!exportContext.ok) return exportContext;
const executed = await executeValidatedReplay(
dependencies,
prepared.value,
exportContext.value,
options,
);
if (!executed.ok) return executed;
try {
return ok(
buildReplayOutput(
prepared.value,
executed.value.executed,
executed.value.sourceEvidence,
),
);
} catch (cause: unknown) {
return err(
new AnalysisProtocolError(
cause instanceof z.ZodError
? "Controlled replay produced an invalid bounded result"
: "Controlled replay could not establish or clean up its sandbox",
{ cause },
),
);
}
};
const authorizeReplay = async (
dependencies: JavaScriptReplayDependencies,
input: z.output<typeof controlledReplayInputSchema>,
signal: AbortSignal | undefined,
): Promise<Result<true, AnalysisError>> => {
if (!dependencies.policy.enabled)
return err(
new AnalysisCapabilityUnavailableError(
@@ -81,9 +150,9 @@ export const runControlledReplayValidated = async (
"JavaScript replay permission policy is not configured",
),
);
if (isAborted(options.signal))
return err(new AnalysisCancelledError(OPERATION));
const request = permissionRequest(input, dependencies.policy);
if (isAborted(signal)) return err(new AnalysisCancelledError(OPERATION));
const request = replayPermissionRequest(input, dependencies.policy);
const authorized =
input.mode === "plan"
? await dependencies.authority.explain(request, "read")
@@ -96,12 +165,20 @@ export const runControlledReplayValidated = async (
cause: authorized.error,
}),
);
let prepared;
return ok(true);
};
const prepareValidatedReplay = async (
dependencies: JavaScriptReplayDependencies,
input: z.output<typeof controlledReplayInputSchema>,
signal: AbortSignal | undefined,
): Promise<
Result<Awaited<ReturnType<typeof prepareReplayPlan>>, AnalysisError>
> => {
if (isAborted(signal)) return err(new AnalysisCancelledError(OPERATION));
try {
prepared = await prepareReplayPlan(
input,
dependencies.policy,
dependencies.host,
return ok(
await prepareReplayPlan(input, dependencies.policy, dependencies.host),
);
} catch (cause: unknown) {
return err(
@@ -112,60 +189,78 @@ export const runControlledReplayValidated = async (
),
);
}
if (isAborted(options.signal))
return err(new AnalysisCancelledError(OPERATION));
if (input.mode === "plan")
return ok(
asJson({
phase: "plan",
plan: prepared.publicPlan,
source_evidence: [],
evidence: null,
}),
);
if (input.plan_digest !== prepared.publicPlan.plan_digest)
};
interface ReproducerExportContext {
readonly path: string;
readonly includeSources: boolean;
}
const authorizeReproducerExport = async (
authority: PermissionAuthority | undefined,
input: z.output<typeof controlledReplayInputSchema>,
prepared: Awaited<ReturnType<typeof prepareReplayPlan>>,
): Promise<Result<ReproducerExportContext | undefined, AnalysisError>> => {
if (input.reproducer_export === undefined) return ok(undefined);
if (input.reproducer_export.approved !== true)
return err(new AnalysisInputError(`${OPERATION}:reproducer_export`));
if (authority === undefined)
return err(
new ReplayPlanStaleError(
input.plan_digest ?? "",
prepared.publicPlan.plan_digest,
new AnalysisCapabilityUnavailableError(
"rea-javascript-replay",
OPERATION,
"JavaScript replay permission policy is not configured",
),
);
let canonicalExportPath: string | undefined;
if (input.reproducer_export !== undefined) {
if (input.reproducer_export.approved !== true)
return err(new AnalysisInputError(`${OPERATION}:reproducer_export`));
const exportAuthorized = await dependencies.authority.authorize(
{
capability: "evidence_write",
roots: [input.reproducer_export.path],
executables: [],
environment_names: [],
network: "none",
mount: false,
operation_identity: `${OPERATION}:reproducer:${prepared.publicPlan.plan_digest}`,
},
"write",
const exportAuthorized = await authority.authorize(
{
capability: "evidence_write",
roots: [input.reproducer_export.path],
executables: [],
environment_names: [],
network: "none",
mount: false,
operation_identity: `${OPERATION}:reproducer:${prepared.publicPlan.plan_digest}`,
},
"write",
);
if (!exportAuthorized.ok)
return err(
exportAuthorized.error instanceof PermissionRequiredError
? exportAuthorized.error
: new AnalysisProtocolError(exportAuthorized.error.message, {
cause: exportAuthorized.error,
}),
);
if (!exportAuthorized.ok)
return err(
exportAuthorized.error instanceof PermissionRequiredError
? exportAuthorized.error
: new AnalysisProtocolError(exportAuthorized.error.message, {
cause: exportAuthorized.error,
}),
);
canonicalExportPath = exportAuthorized.value.request.roots[0];
if (canonicalExportPath !== input.reproducer_export.path)
return err(
new AnalysisInputError(`${OPERATION}:reproducer_export:path`, {
cause: new TypeError(
`Use the canonical reproducer path: ${canonicalExportPath ?? "unavailable"}`,
),
}),
);
}
if (isAborted(options.signal))
return err(new AnalysisCancelledError(OPERATION));
const canonicalExportPath = exportAuthorized.value.request.roots[0];
if (canonicalExportPath !== input.reproducer_export.path)
return err(
new AnalysisInputError(`${OPERATION}:reproducer_export:path`, {
cause: new TypeError(
`Use the canonical reproducer path: ${canonicalExportPath ?? "unavailable"}`,
),
}),
);
return ok({
path: canonicalExportPath,
includeSources: input.reproducer_export.include_sources === true,
});
};
const executeValidatedReplay = async (
dependencies: JavaScriptReplayDependencies,
prepared: Awaited<ReturnType<typeof prepareReplayPlan>>,
exportContext: ReproducerExportContext | undefined,
options: ExecutionOptions,
): Promise<
Result<
{
readonly executed: z.infer<typeof replayExecutionResultSchema>;
readonly sourceEvidence: ReturnType<typeof createReplayEvidence>[];
},
AnalysisError
>
> => {
await options.progress?.report({
phase: OPERATION,
completed: 0,
@@ -174,6 +269,7 @@ export const runControlledReplayValidated = async (
});
if (isAborted(options.signal))
return err(new AnalysisCancelledError(OPERATION));
try {
let executed = replayExecutionResultSchema.parse(
await dependencies.runner.execute(
@@ -191,132 +287,9 @@ export const runControlledReplayValidated = async (
});
if (executed.termination === "cancelled")
return err(new AnalysisCancelledError(OPERATION));
if (
input.reproducer_export !== undefined &&
canonicalExportPath !== undefined &&
executed.cleanup.state === "complete"
) {
const manifest = {
schema_version: 1,
plan: prepared.publicPlan,
result: executed,
sources: input.reproducer_export.include_sources
? {
left: prepared.leftSources,
...(prepared.rightSources === undefined
? {}
: { right: prepared.rightSources }),
}
: null,
};
const encoded = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
try {
await writeFileAtomic(canonicalExportPath, encoded, {
mode: 0o600,
});
executed = replayExecutionResultSchema.parse({
...executed,
reproducer: {
state: "written",
path: canonicalExportPath,
sha256: digestBytes(encoded),
},
});
} catch (cause: unknown) {
executed = replayExecutionResultSchema.parse({
...executed,
limitations: [
...executed.limitations,
"Replay completed, but the separately approved reproducer export failed.",
],
reproducer: {
state: "failed",
path: canonicalExportPath,
error:
cause instanceof Error
? cause.message
: "unknown reproducer export failure",
},
});
}
}
const leftCount = prepared.publicPlan.cases.length;
const sourceEvidence = [
createReplayEvidence(
prepared,
executed,
"left",
executed.outcomes.slice(0, leftCount),
),
...(prepared.publicPlan.right === undefined
? []
: [
createReplayEvidence(
prepared,
executed,
"right",
executed.outcomes.slice(leftCount),
),
]),
];
const subject = prepared.publicPlan.left.modules[0];
const evidence = createEvidence(
subject === undefined
? undefined
: {
path: subject.canonical_path,
sha256: subject.sha256,
format: "javascript",
},
{
id: "rea-javascript-replay",
name: "REA isolated JavaScript replay",
version: "1",
},
{
predicateType: "javascript-controlled-replay",
operation: OPERATION,
parameters: {
replay_plan: jsonValueSchema.parse(
JSON.parse(JSON.stringify(prepared.publicPlan)),
),
},
result: jsonValueSchema.parse(JSON.parse(JSON.stringify(executed))),
rawResult: jsonValueSchema.parse(
JSON.parse(
JSON.stringify({
cases: prepared.publicPlan.cases,
outcomes: executed.outcomes,
stderr: executed.stderr,
}),
),
),
confidence: executed.comparison === undefined ? "observed" : "derived",
authority: "controlled-replay",
environment: {
id: prepared.publicPlan.plan_digest,
platform: process.platform,
architecture: process.arch,
isolation: "container",
},
limitations: executed.limitations,
locations: prepared.publicPlan.left.modules.map((module) => ({
kind: "artifact-path" as const,
path: module.canonical_path,
})),
evidenceLinks: sourceEvidence.map(({ evidence_id: id }) => id),
},
);
return ok(
asJson({
phase: "execute",
plan: null,
source_evidence: sourceEvidence.map((item) =>
replayEvidenceSchema.parse(item),
),
evidence: replayEvidenceSchema.parse(evidence),
}),
);
executed = await applyReproducerExport(executed, exportContext, prepared);
const sourceEvidence = buildSourceEvidence(prepared, executed);
return ok({ executed, sourceEvidence });
} catch (cause: unknown) {
return err(
new AnalysisProtocolError(
@@ -329,6 +302,147 @@ export const runControlledReplayValidated = async (
}
};
const applyReproducerExport = async (
executed: z.infer<typeof replayExecutionResultSchema>,
exportContext: ReproducerExportContext | undefined,
prepared: Awaited<ReturnType<typeof prepareReplayPlan>>,
): Promise<z.infer<typeof replayExecutionResultSchema>> => {
if (exportContext === undefined || executed.cleanup.state !== "complete")
return executed;
const manifest = {
schema_version: 1,
plan: prepared.publicPlan,
result: executed,
sources: exportContext.includeSources
? {
left: prepared.leftSources,
...(prepared.rightSources === undefined
? {}
: { right: prepared.rightSources }),
}
: null,
};
const encoded = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
try {
await writeFileAtomic(exportContext.path, encoded, {
mode: 0o600,
});
return replayExecutionResultSchema.parse({
...executed,
reproducer: {
state: "written",
path: exportContext.path,
sha256: digestBytes(encoded),
},
});
} catch (cause: unknown) {
return replayExecutionResultSchema.parse({
...executed,
limitations: [
...executed.limitations,
"Replay completed, but the separately approved reproducer export failed.",
],
reproducer: {
state: "failed",
path: exportContext.path,
error:
cause instanceof Error
? cause.message
: "unknown reproducer export failure",
},
});
}
};
const buildSourceEvidence = (
prepared: Awaited<ReturnType<typeof prepareReplayPlan>>,
executed: z.infer<typeof replayExecutionResultSchema>,
): ReturnType<typeof createReplayEvidence>[] => {
const leftCount = prepared.publicPlan.cases.length;
return [
createReplayEvidence(
prepared,
executed,
"left",
executed.outcomes.slice(0, leftCount),
),
...(prepared.publicPlan.right === undefined
? []
: [
createReplayEvidence(
prepared,
executed,
"right",
executed.outcomes.slice(leftCount),
),
]),
];
};
const buildReplayOutput = (
prepared: Awaited<ReturnType<typeof prepareReplayPlan>>,
executed: z.infer<typeof replayExecutionResultSchema>,
sourceEvidence: ReturnType<typeof createReplayEvidence>[],
): JsonValue => {
const subject = prepared.publicPlan.left.modules[0];
const evidence = createEvidence(
subject === undefined
? undefined
: {
path: subject.canonical_path,
sha256: subject.sha256,
format: "javascript",
},
{
id: "rea-javascript-replay",
name: "REA isolated JavaScript replay",
version: "1",
},
{
predicateType: "javascript-controlled-replay",
operation: OPERATION,
parameters: {
replay_plan: jsonValueSchema.parse(
JSON.parse(JSON.stringify(prepared.publicPlan)),
),
},
result: jsonValueSchema.parse(JSON.parse(JSON.stringify(executed))),
rawResult: jsonValueSchema.parse(
JSON.parse(
JSON.stringify({
cases: prepared.publicPlan.cases,
outcomes: executed.outcomes,
stderr: executed.stderr,
}),
),
),
confidence: executed.comparison === undefined ? "observed" : "derived",
authority: "controlled-replay",
environment: {
id: prepared.publicPlan.plan_digest,
platform: process.platform,
architecture: process.arch,
isolation: "container",
},
limitations: executed.limitations,
locations: prepared.publicPlan.left.modules.map((module) => ({
kind: "artifact-path" as const,
path: module.canonical_path,
})),
evidenceLinks: sourceEvidence.map(({ evidence_id: id }) => id),
},
);
return asJson({
phase: "execute",
plan: null,
source_evidence: sourceEvidence.map((item) =>
replayEvidenceSchema.parse(item),
),
evidence: replayEvidenceSchema.parse(evidence),
});
};
const createReplayEvidence = (
prepared: Awaited<ReturnType<typeof prepareReplayPlan>>,
executed: z.infer<typeof replayExecutionResultSchema>,
@@ -395,24 +509,3 @@ const asJson = (value: unknown): JsonValue =>
JSON.stringify(controlledReplayOutputSchema.parse(value)),
) as unknown,
);
const permissionRequest = (
input: z.infer<typeof controlledReplayInputSchema>,
policy: JavaScriptReplayPolicy,
) => ({
capability: "javascript_replay" as const,
roots: [...input.left.modules, ...(input.right?.modules ?? [])].map(
({ path }) => path,
),
executables: [
policy.nodePath,
policy.bubblewrapPath,
policy.systemdRunPath,
policy.systemctlPath,
policy.shellPath,
],
environment_names: [],
network: "none" as const,
mount: true,
operation_identity: `${OPERATION}:${input.plan_digest ?? "plan"}`,
});
+12 -11
View File
@@ -132,17 +132,7 @@ export const installMacHopper = async (
};
const systemMacHopperInstallHost = (): MacHopperInstallHost => ({
async download(url) {
const response = await fetch(url, {
headers: { accept: "application/json", "user-agent": "rea-installer" },
signal: AbortSignal.timeout(120_000),
});
const length = Number(response.headers.get("content-length") ?? "0");
if (length > MAX_PACKAGE_BYTES)
return { ok: false, bytes: new Uint8Array() };
const bytes = new Uint8Array(await response.arrayBuffer());
return { ok: response.ok && bytes.byteLength <= MAX_PACKAGE_BYTES, bytes };
},
download: downloadPackage,
createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-mac-")),
createMountDirectory: (path) => mkdir(path),
writePackage: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }),
@@ -236,6 +226,17 @@ const systemMacHopperInstallHost = (): MacHopperInstallHost => ({
destination: () => join(homedir(), "Applications/Hopper Disassembler.app"),
});
const downloadPackage = async (url: string) => {
const response = await fetch(url, {
headers: { accept: "application/json", "user-agent": "rea-installer" },
signal: AbortSignal.timeout(120_000),
});
const length = Number(response.headers.get("content-length") ?? "0");
if (length > MAX_PACKAGE_BYTES) return { ok: false, bytes: new Uint8Array() };
const bytes = new Uint8Array(await response.arrayBuffer());
return { ok: response.ok && bytes.byteLength <= MAX_PACKAGE_BYTES, bytes };
};
const parseJson = (bytes: Uint8Array): unknown =>
JSON.parse(new TextDecoder().decode(bytes));
const integrityMatches = (
@@ -55,4 +55,9 @@ export const writeReconstructionCoverageWorkspace = (
{ readonly path: string; readonly bytes: number },
InvestigationWorkspaceError
>
> => writeRevisionedWorkspace(workspace, path, expectedRevision, policy, codec);
> =>
writeRevisionedWorkspace(path, policy, {
document: workspace,
expectedRevision,
codec,
});
+37 -359
View File
@@ -1,21 +1,9 @@
import {
access,
copyFile,
mkdir,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { constants as fsConstants } from "node:fs";
import { access } from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, resolve } from "node:path";
import { z } from "zod";
import writeFileAtomic from "write-file-atomic";
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
import { resolve } from "node:path";
import { PRODUCT_IDENTITY } from "../identity.js";
import { supportsNodeVersion } from "../domain/runtimeVersion.js";
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
import {
runDoctor,
systemDoctorHost,
@@ -36,6 +24,15 @@ import {
installCanonicalSkill,
} from "./SetupSkill.js";
import { setupPlan } from "./SetupPlan.js";
import {
clientConfigurationAligned,
configureJsonClient,
configureTomlClient,
} from "./SetupClientConfiguration.js";
export {
configureJsonClient,
configureTomlClient,
} from "./SetupClientConfiguration.js";
import {
setupInstallFailure,
type SetupFailureCode,
@@ -184,14 +181,8 @@ export const runSetup = async (
const appliedActions: string[] = [];
let plannedActions: readonly SetupAction[] = [];
const clients: Record<string, ClientConfigurationResult> = {};
const fail = async (remediation: string): Promise<SetupResult> => ({
status: "needs_human",
plannedActions,
appliedActions,
clients,
doctor: await host.doctor(),
remediation,
});
const fail = (remediation: string) =>
setupFailure(remediation, [host, plannedActions, appliedActions, clients]);
const unsupported = await hostRemediation(host, options.installHopper);
if (unsupported !== undefined) return fail(unsupported);
let hopperPath = await host.hopperPath();
@@ -345,15 +336,28 @@ export const runSetup = async (
};
};
const discoverSetupActions = async (input: {
readonly host: SetupHost;
readonly providerEnvironment: SetupProviderEnvironment;
readonly forceHopperInstall: boolean;
readonly proposeHopper: boolean;
readonly selectedClientIds: readonly string[] | undefined;
readonly installSkillSelection: boolean | undefined;
}) => {
const { host, providerEnvironment } = input;
const setupFailure = async (
remediation: string,
[host, plannedActions, appliedActions, clients]: readonly [
SetupHost,
readonly SetupAction[],
readonly string[],
Readonly<Record<string, ClientConfigurationResult>>,
],
): Promise<SetupResult> => ({
status: "needs_human",
plannedActions,
appliedActions,
clients,
doctor: await host.doctor(),
remediation,
});
const discoverSetupActions = async (
host: SetupHost,
providerEnvironment: SetupProviderEnvironment,
forceHopperInstall: boolean,
) => {
const initialDoctor = await host.doctor();
const linuxHopperRepairNeeded = initialDoctor.checks.some(
({ name, ok, detail }) =>
@@ -389,49 +393,11 @@ const discoverSetupActions = async (input: {
providerEnvironment,
command,
);
const inspections = await inspectClients(
host,
clients,
providerEnvironment,
command,
);
const invalid = inspections.find(
({ inspection }) => inspection.status === "invalid",
);
const blocker =
invalid?.inspection.status === "invalid"
? `${invalid.client.displayName ?? invalid.client.name}: ${invalid.inspection.remediation}`
: undefined;
const clientPlans = inspections.flatMap(({ client, inspection }) =>
inspection.status === "create" ||
inspection.status === "update" ||
(installHopper && inspection.status === "already_current")
? [
{
client,
operation:
inspection.status === "already_current"
? ("update" as const)
: inspection.status,
...(inspection.status !== "already_current" &&
inspection.backupPath !== undefined
? { backupPath: inspection.backupPath }
: {}),
},
]
: [],
);
const linuxDistribution =
host.platform === "linux" ? await host.linuxDistribution() : undefined;
const plannedActions = setupPlan({
platform: host.platform,
const plannedActions = setupPlan(host.platform, {
installHopper,
installSkill,
clients: clientPlans,
clients,
providerEnvironment,
...(linuxDistribution?.packageFamily === undefined
? {}
: { linuxPackageFamily: linuxDistribution.packageFamily }),
});
return {
installHopper,
@@ -591,161 +557,6 @@ export const detectClients = async (
return detected;
};
/** Back up, atomically update, and semantically read back one JSON MCP configuration. */
export const configureJsonClient = async (
client: SetupClient,
environment: SetupProviderEnvironment | string = {},
command: readonly string[] = [
"npx",
"-y",
PRODUCT_IDENTITY.packageSpecifier,
"mcp",
],
): Promise<ClientConfigurationResult> => {
const transactionPath = await resolveClientConfigTransactionPath(
client.configPath,
);
if (transactionPath === undefined)
return { status: "failed", reason: "path" };
let document: Record<string, unknown> = {};
let original: string | undefined;
try {
original = await readFile(transactionPath, "utf8");
document = parseObject(original);
} catch (cause: unknown) {
if (!isMissing(cause)) return { status: "failed", reason: "readback" };
}
let servers: Record<string, unknown>;
try {
servers = parseOptionalObject(document.mcpServers);
} catch {
return { status: "failed", reason: "readback" };
}
const desired = clientConfigurationDesired(
normalizeProviderEnvironment(environment),
command,
);
if (
JSON.stringify(servers[PRODUCT_IDENTITY.mcpServerKey]) ===
JSON.stringify(desired)
)
return { status: "unchanged" };
let backupPath: string | undefined;
if (original !== undefined) {
backupPath = `${client.configPath}.rea.backup`;
if (!(await preserveConfigBackup(transactionPath, backupPath)))
return { status: "failed", reason: "backup" };
}
document.mcpServers = {
...servers,
[PRODUCT_IDENTITY.mcpServerKey]: desired,
};
const encoded = `${JSON.stringify(document, null, 2)}\n`;
try {
await mkdir(dirname(client.configPath), { recursive: true });
await writeFileAtomic(transactionPath, encoded, {
encoding: "utf8",
mode: 0o600,
});
} catch {
return { status: "failed", reason: "write" };
}
try {
const readback = parseObject(await readFile(transactionPath, "utf8"));
const value = parseOptionalObject(readback.mcpServers)[
PRODUCT_IDENTITY.mcpServerKey
];
if (JSON.stringify(value) !== JSON.stringify(desired)) {
await restoreConfig(transactionPath, original);
return { status: "failed", reason: "readback" };
}
} catch {
await restoreConfig(transactionPath, original);
return { status: "failed", reason: "readback" };
}
return {
status: "configured",
...(backupPath === undefined ? {} : { backupPath }),
};
};
/** Back up, atomically update, and semantically read back Codex TOML configuration. */
export const configureTomlClient = async (
client: SetupClient,
environment: SetupProviderEnvironment | string = {},
command: readonly string[] = [
"npx",
"-y",
PRODUCT_IDENTITY.packageSpecifier,
"mcp",
],
): Promise<ClientConfigurationResult> => {
const transactionPath = await resolveClientConfigTransactionPath(
client.configPath,
);
if (transactionPath === undefined)
return { status: "failed", reason: "path" };
let document: Record<string, unknown> = {};
let original: string | undefined;
try {
original = await readFile(transactionPath, "utf8");
document = objectSchema.parse(parseToml(original));
} catch (cause: unknown) {
if (!isMissing(cause)) return { status: "failed", reason: "readback" };
}
let servers: Record<string, unknown>;
try {
servers = parseOptionalObject(document.mcp_servers);
} catch {
return { status: "failed", reason: "readback" };
}
const desired = clientConfigurationDesired(
normalizeProviderEnvironment(environment),
command,
);
if (
JSON.stringify(servers[PRODUCT_IDENTITY.mcpServerKey]) ===
JSON.stringify(desired)
)
return { status: "unchanged" };
const backupPath =
original === undefined ? undefined : `${client.configPath}.rea.backup`;
if (
backupPath !== undefined &&
!(await preserveConfigBackup(transactionPath, backupPath))
)
return { status: "failed", reason: "backup" };
document.mcp_servers = {
...servers,
[PRODUCT_IDENTITY.mcpServerKey]: desired,
};
try {
await mkdir(dirname(client.configPath), { recursive: true });
await writeFileAtomic(transactionPath, stringifyToml(document), {
encoding: "utf8",
mode: 0o600,
});
const readback = objectSchema.parse(
parseToml(await readFile(transactionPath, "utf8")),
);
if (
JSON.stringify(
parseOptionalObject(readback.mcp_servers)[
PRODUCT_IDENTITY.mcpServerKey
],
) !== JSON.stringify(desired)
)
throw new Error("TOML readback mismatch");
} catch {
await restoreConfig(transactionPath, original);
return { status: "failed", reason: "readback" };
}
return {
status: "configured",
...(backupPath === undefined ? {} : { backupPath }),
};
};
const major = (version: string): number =>
Number.parseInt(version.split(".")[0] ?? "0", 10);
const exists = async (path: string): Promise<boolean> => {
@@ -756,139 +567,6 @@ const exists = async (path: string): Promise<boolean> => {
return false;
}
};
const preserveConfigBackup = async (
source: string,
destination: string,
): Promise<boolean> => {
try {
await copyFile(source, destination, fsConstants.COPYFILE_EXCL);
return true;
} catch (cause: unknown) {
return cause instanceof Error && "code" in cause && cause.code === "EEXIST";
}
};
const objectSchema = z.record(z.string(), z.unknown());
const parseOptionalObject = (value: unknown): Record<string, unknown> =>
value === undefined ? {} : objectSchema.parse(value);
const parseObject = (text: string): Record<string, unknown> =>
objectSchema.parse(JSON.parse(text));
const isMissing = (cause: unknown): boolean =>
cause instanceof Error && "code" in cause && cause.code === "ENOENT";
const restoreConfig = async (
path: string,
original: string | undefined,
): Promise<void> => {
try {
if (original === undefined) await rm(path, { force: true });
else await writeFile(path, original, { encoding: "utf8", mode: 0o600 });
} catch {
// The backup remains available for the remediation reported by setup.
}
};
const clientConfigurationDesired = (
providerEnvironment: SetupProviderEnvironment,
command: readonly string[],
) => {
const environment = Object.fromEntries(
Object.entries(providerEnvironment).sort(([left], [right]) =>
left.localeCompare(right),
),
);
return {
command: command[0] ?? PRODUCT_IDENTITY.cliBinary,
args: command.slice(1),
...(Object.keys(environment).length === 0 ? {} : { env: environment }),
};
};
const clientConfigurationAligned = async (
client: SetupClient,
providerEnvironment: SetupProviderEnvironment,
command: readonly string[],
): Promise<boolean> => {
const desired = clientConfigurationDesired(providerEnvironment, command);
try {
const original = await readFile(client.configPath, "utf8");
if (client.format === "toml") {
const document = objectSchema.parse(parseToml(original));
const servers = parseOptionalObject(document.mcp_servers);
return (
JSON.stringify(servers[PRODUCT_IDENTITY.mcpServerKey]) ===
JSON.stringify(desired)
);
}
const document = parseObject(original);
const servers = parseOptionalObject(document.mcpServers);
return (
JSON.stringify(servers[PRODUCT_IDENTITY.mcpServerKey]) ===
JSON.stringify(desired)
);
} catch {
return false;
}
};
const inspectClientConfiguration = async (
client: SetupClient,
providerEnvironment: SetupProviderEnvironment,
command: readonly string[],
): Promise<ClientConfigurationInspection> => {
if (client.format === "unsupported") return { status: "already_current" };
const transactionPath = await resolveClientConfigTransactionPath(
client.configPath,
);
if (transactionPath === undefined)
return {
status: "invalid",
remediation:
"The configuration path is unsafe or unresolved. Check ownership and symbolic links before rerunning setup.",
};
let original: string;
try {
original = await readFile(transactionPath, "utf8");
} catch (cause: unknown) {
if (isMissing(cause)) return { status: "create" };
return {
status: "invalid",
remediation:
"The configuration file could not be read. Check its permissions before rerunning setup.",
};
}
const desired = clientConfigurationDesired(providerEnvironment, command);
try {
const document =
client.format === "toml"
? objectSchema.parse(parseToml(original))
: parseObject(original);
const servers = parseOptionalObject(
client.format === "toml" ? document.mcp_servers : document.mcpServers,
);
if (
JSON.stringify(servers[PRODUCT_IDENTITY.mcpServerKey]) ===
JSON.stringify(desired)
)
return { status: "already_current" };
} catch {
return {
status: "invalid",
remediation:
"The existing configuration is malformed. Repair it before setup applies any other changes.",
};
}
return {
status: "update",
backupPath: `${client.configPath}.rea.backup`,
};
};
const normalizeProviderEnvironment = (
environment: SetupProviderEnvironment | string,
): SetupProviderEnvironment =>
typeof environment === "string"
? { HOPPER_LAUNCHER_PATH: environment }
: environment;
const providerRegistrationEnvironment = (
inspections: readonly DoctorProviderInspection[],
): SetupProviderEnvironment =>
+245
View File
@@ -0,0 +1,245 @@
import { constants as fsConstants } from "node:fs";
import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import { z } from "zod";
import writeFileAtomic from "write-file-atomic";
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
import { PRODUCT_IDENTITY } from "../identity.js";
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
import type {
ClientConfigurationResult,
SetupProviderEnvironment,
} from "./Setup.js";
import type { SetupClient } from "./SupportedClients.js";
const defaultCommand = (): readonly string[] => [
"npx",
"-y",
PRODUCT_IDENTITY.packageSpecifier,
"mcp",
];
/** Back up, atomically update, and semantically read back one JSON MCP configuration. */
export const configureJsonClient = async (
client: SetupClient,
environment: SetupProviderEnvironment | string = {},
command: readonly string[] = defaultCommand(),
): Promise<ClientConfigurationResult> => {
const transactionPath = await resolveClientConfigTransactionPath(
client.configPath,
);
if (transactionPath === undefined)
return { status: "failed", reason: "path" };
let document: Record<string, unknown> = {};
let original: string | undefined;
try {
original = await readFile(transactionPath, "utf8");
document = parseObject(original);
} catch (cause: unknown) {
if (!isMissing(cause)) return { status: "failed", reason: "readback" };
}
let servers: Record<string, unknown>;
try {
servers = parseOptionalObject(document.mcpServers);
} catch {
return { status: "failed", reason: "readback" };
}
const desired = clientConfigurationDesired(
normalizeProviderEnvironment(environment),
command,
);
if (sameConfiguration(servers[PRODUCT_IDENTITY.mcpServerKey], desired))
return { status: "unchanged" };
let backupPath: string | undefined;
if (original !== undefined) {
backupPath = `${client.configPath}.rea.backup`;
if (!(await preserveConfigBackup(transactionPath, backupPath)))
return { status: "failed", reason: "backup" };
}
document.mcpServers = {
...servers,
[PRODUCT_IDENTITY.mcpServerKey]: desired,
};
try {
await mkdir(dirname(client.configPath), { recursive: true });
await writeFileAtomic(
transactionPath,
`${JSON.stringify(document, null, 2)}\n`,
{
encoding: "utf8",
mode: 0o600,
},
);
} catch {
return { status: "failed", reason: "write" };
}
try {
const readback = parseObject(await readFile(transactionPath, "utf8"));
if (
!sameConfiguration(
parseOptionalObject(readback.mcpServers)[PRODUCT_IDENTITY.mcpServerKey],
desired,
)
) {
await restoreConfig(transactionPath, original);
return { status: "failed", reason: "readback" };
}
} catch {
await restoreConfig(transactionPath, original);
return { status: "failed", reason: "readback" };
}
return {
status: "configured",
...(backupPath === undefined ? {} : { backupPath }),
};
};
/** Back up, atomically update, and semantically read back Codex TOML configuration. */
export const configureTomlClient = async (
client: SetupClient,
environment: SetupProviderEnvironment | string = {},
command: readonly string[] = defaultCommand(),
): Promise<ClientConfigurationResult> => {
const transactionPath = await resolveClientConfigTransactionPath(
client.configPath,
);
if (transactionPath === undefined)
return { status: "failed", reason: "path" };
let document: Record<string, unknown> = {};
let original: string | undefined;
try {
original = await readFile(transactionPath, "utf8");
document = objectSchema.parse(parseToml(original));
} catch (cause: unknown) {
if (!isMissing(cause)) return { status: "failed", reason: "readback" };
}
let servers: Record<string, unknown>;
try {
servers = parseOptionalObject(document.mcp_servers);
} catch {
return { status: "failed", reason: "readback" };
}
const desired = clientConfigurationDesired(
normalizeProviderEnvironment(environment),
command,
);
if (sameConfiguration(servers[PRODUCT_IDENTITY.mcpServerKey], desired))
return { status: "unchanged" };
const backupPath =
original === undefined ? undefined : `${client.configPath}.rea.backup`;
if (
backupPath !== undefined &&
!(await preserveConfigBackup(transactionPath, backupPath))
)
return { status: "failed", reason: "backup" };
document.mcp_servers = {
...servers,
[PRODUCT_IDENTITY.mcpServerKey]: desired,
};
try {
await mkdir(dirname(client.configPath), { recursive: true });
await writeFileAtomic(transactionPath, stringifyToml(document), {
encoding: "utf8",
mode: 0o600,
});
const readback = objectSchema.parse(
parseToml(await readFile(transactionPath, "utf8")),
);
if (
!sameConfiguration(
parseOptionalObject(readback.mcp_servers)[
PRODUCT_IDENTITY.mcpServerKey
],
desired,
)
)
throw new Error("TOML readback mismatch");
} catch {
await restoreConfig(transactionPath, original);
return { status: "failed", reason: "readback" };
}
return {
status: "configured",
...(backupPath === undefined ? {} : { backupPath }),
};
};
/** Determine whether an existing client configuration matches the desired registration. */
export const clientConfigurationAligned = async (
client: SetupClient,
providerEnvironment: SetupProviderEnvironment,
command: readonly string[],
): Promise<boolean> => {
const desired = clientConfigurationDesired(providerEnvironment, command);
try {
const original = await readFile(client.configPath, "utf8");
const document =
client.format === "toml"
? objectSchema.parse(parseToml(original))
: parseObject(original);
const servers = parseOptionalObject(
client.format === "toml" ? document.mcp_servers : document.mcpServers,
);
return sameConfiguration(servers[PRODUCT_IDENTITY.mcpServerKey], desired);
} catch {
return false;
}
};
const objectSchema = z.record(z.string(), z.unknown());
const parseOptionalObject = (value: unknown): Record<string, unknown> =>
value === undefined ? {} : objectSchema.parse(value);
const parseObject = (text: string): Record<string, unknown> =>
objectSchema.parse(JSON.parse(text));
const isMissing = (cause: unknown): boolean =>
cause instanceof Error && "code" in cause && cause.code === "ENOENT";
const sameConfiguration = (left: unknown, right: unknown): boolean =>
JSON.stringify(left) === JSON.stringify(right);
const preserveConfigBackup = async (
source: string,
destination: string,
): Promise<boolean> => {
try {
await copyFile(source, destination, fsConstants.COPYFILE_EXCL);
return true;
} catch (cause: unknown) {
return cause instanceof Error && "code" in cause && cause.code === "EEXIST";
}
};
const restoreConfig = async (
path: string,
original: string | undefined,
): Promise<void> => {
try {
if (original === undefined) await rm(path, { force: true });
else await writeFile(path, original, { encoding: "utf8", mode: 0o600 });
} catch {
// The backup remains available for the remediation reported by setup.
}
};
const clientConfigurationDesired = (
providerEnvironment: SetupProviderEnvironment,
command: readonly string[],
) => {
const environment = Object.fromEntries(
Object.entries(providerEnvironment).sort(([left], [right]) =>
left.localeCompare(right),
),
);
return {
command: command[0] ?? PRODUCT_IDENTITY.cliBinary,
args: command.slice(1),
...(Object.keys(environment).length === 0 ? {} : { env: environment }),
};
};
const normalizeProviderEnvironment = (
environment: SetupProviderEnvironment | string,
): SetupProviderEnvironment =>
typeof environment === "string"
? { HOPPER_LAUNCHER_PATH: environment }
: environment;
+17 -27
View File
@@ -1,34 +1,24 @@
import { PRODUCT_IDENTITY } from "../identity.js";
import type {
SetupAction,
SetupClient,
SetupProviderEnvironment,
} from "./Setup.js";
import { PRODUCT_IDENTITY } from "../identity.js";
import { homedir } from "node:os";
import { join } from "node:path";
import {
linuxHopperInstallDisclosure,
type LinuxPackageFamily,
} from "./LinuxHopper.js";
import { macHopperInstallDisclosure } from "./MacHopper.js";
/** Preflighted client mutation admitted into the setup plan. */
export type SetupClientPlan = Readonly<{
client: SetupClient;
operation: "create" | "update";
backupPath?: string;
}>;
/** Build the complete setup mutation plan before approval. */
export const setupPlan = (input: {
readonly platform: NodeJS.Platform;
/** Inputs used to build the setup mutation plan. */
export interface SetupPlanOptions {
readonly installHopper: boolean;
readonly installSkill: boolean;
readonly clients: readonly SetupClientPlan[];
readonly clients: readonly SetupClient[];
readonly providerEnvironment: SetupProviderEnvironment;
readonly linuxPackageFamily?: LinuxPackageFamily;
}): readonly SetupAction[] => [
...(input.installHopper
}
/** Build the complete setup mutation plan before approval. */
export const setupPlan = (
platform: NodeJS.Platform,
options: SetupPlanOptions,
): readonly SetupAction[] => [
...(options.installHopper
? [
{
id: "install_hopper",
@@ -55,8 +45,8 @@ export const setupPlan = (input: {
},
]
: []),
...input.clients
.filter(({ client }) => client.format !== "unsupported")
...options.clients
.filter(({ format }) => format !== "unsupported")
.map(
({ client, operation, backupPath }): SetupAction => ({
id: `configure_client:${client.name}`,
@@ -64,15 +54,15 @@ export const setupPlan = (input: {
label: client.displayName ?? client.name,
target: client.configPath,
detail: clientConfigurationDetail(
client.displayName ?? client.name,
input.providerEnvironment,
client.name,
options.providerEnvironment,
),
external: false,
operation,
...(backupPath === undefined ? {} : { backupPath }),
}),
),
...(input.installSkill
...(options.installSkill
? [
{
id: "install_skill",
+12 -1208
View File
File diff suppressed because it is too large. Load diff
+105
View File
@@ -0,0 +1,105 @@
import { z } from "incur";
import { runProviderAnalysis } from "../application/DirectAnalysis.js";
import { logCliCommand } from "../cliLogging.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import type { Logger } from "../logger.js";
import type { CliInstance } from "./types.js";
export const registerArtifactCommands = (
cli: CliInstance,
logger: Logger,
): void => {
cli.command(CLI_COMMANDS.inventoryArtifact, {
description: "Build a bounded deterministic artifact graph",
args: z.object({
path: z.string().describe("Application or package path"),
}),
options: z.object({
offset: z
.number()
.int()
.min(0)
.default(0)
.describe("Zero-based offset for graph nodes, occurrences, and edges"),
limit: z
.number()
.int()
.min(1)
.max(500)
.default(100)
.describe("Maximum nodes, occurrences, and edges per result section"),
integrityPolicy: z
.enum(["fail", "record-and-continue"])
.default("fail")
.describe("Behavior when declared artifact integrity does not match"),
integrityContinueApproved: z
.boolean()
.default(false)
.describe("Approve continuing after recorded integrity mismatches"),
maxIntegrityMismatches: z
.number()
.int()
.min(1)
.max(100)
.default(10)
.describe("Maximum integrity mismatches to record before stopping"),
nativeMountApproved: z
.boolean()
.default(false)
.describe("Approve read-only native mounting when required"),
}),
alias: {
integrityPolicy: "integrity-policy",
integrityContinueApproved: "integrity-continue-approved",
maxIntegrityMismatches: "max-integrity-mismatches",
nativeMountApproved: "native-mount-approved",
},
run: ({ args, options }) =>
logCliCommand(logger, "inventory-artifact", () =>
runProviderAnalysis(
args.path,
"inventory_artifact",
{
node_offset: options.offset,
node_limit: options.limit,
occurrence_offset: options.offset,
occurrence_limit: options.limit,
edge_offset: options.offset,
edge_limit: options.limit,
integrity_policy: options.integrityPolicy,
integrity_continue_approved: options.integrityContinueApproved,
max_integrity_mismatches: options.maxIntegrityMismatches,
native_mount_approved: options.nativeMountApproved,
},
logger,
),
),
});
cli.command(CLI_COMMANDS.extractArtifact, {
description: "Extract explicitly selected artifact occurrences safely",
args: z.object({
path: z.string().describe("Application or package path"),
outputRoot: z.string().describe("Absent absolute output root"),
occurrenceIds: z
.array(z.string().regex(/^occ_[a-f0-9]{64}$/u))
.min(1)
.max(500)
.describe("Exact artifact occurrence IDs selected for extraction"),
}),
alias: { outputRoot: "output-root", occurrenceIds: "occurrence-ids" },
run: ({ args }) =>
logCliCommand(logger, "extract-artifact", () =>
runProviderAnalysis(
args.path,
"extract_artifact",
{
approved: true,
output_root: args.outputRoot,
occurrence_ids: args.occurrenceIds,
},
logger,
),
),
});
};
+300
View File
@@ -0,0 +1,300 @@
import { z } from "incur";
import { runDirectAnalysis } from "../application/DirectAnalysis.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
import type { Logger } from "../logger.js";
import { directAnalysisOptions, providerSelectionOption } from "./options.js";
import type { CliInstance } from "./types.js";
export const registerCoreAnalysisCommands = (
cli: CliInstance,
logger: Logger,
): void => {
registerCoreCommands(cli, logger);
registerFunctionCommand(cli, logger);
registerSearchCommand(cli, logger);
registerXrefsCommand(cli, logger);
registerTraceCommand(cli, logger);
};
const registerCoreCommands = (cli: CliInstance, logger: Logger): void => {
const overviewOptions = z.object({
detail: z
.enum(["concise", "detailed"])
.default("concise")
.describe("Overview detail level"),
limit: z
.number()
.int()
.min(1)
.max(50)
.default(10)
.describe("Maximum overview items to return"),
snapshot: z
.string()
.min(1)
.optional()
.describe("Load and update a local analysis snapshot"),
provider: providerSelectionOption,
});
cli.command(CLI_COMMANDS.analyze, {
description: "Get an overview of an app",
args: z.object({
path: z.string().describe("App, program, or analysis database path"),
}),
options: overviewOptions,
run: ({ args, options }) =>
logCliCommand(logger, "analyze", () =>
runDirectAnalysis(
args.path,
"binary_overview",
{ detail: options.detail, limit: options.limit },
directAnalysisOptions(logger, options.snapshot, options.provider),
),
),
});
cli.command(CLI_COMMANDS.inspect, {
description: "Inspect an app overview with evidence",
args: z.object({
path: z.string().describe("App, program, or analysis database path"),
}),
options: overviewOptions,
run: ({ args, options }) =>
logCliCommand(logger, "inspect", () =>
runDirectAnalysis(
args.path,
"binary_overview",
{ detail: options.detail, limit: options.limit },
directAnalysisOptions(logger, options.snapshot, options.provider),
),
),
});
cli.command(CLI_COMMANDS.decompile, {
description: "Read one part of an app as code",
args: z.object({
path: z.string().describe("App or program path"),
address: z.string().describe("Procedure address"),
}),
options: z.object({
snapshot: z
.string()
.min(1)
.optional()
.describe("Load and update a local analysis snapshot"),
provider: providerSelectionOption,
}),
run: ({ args, options }) =>
logCliCommand(logger, "decompile", () =>
runDirectAnalysis(
args.path,
"procedure_pseudo_code",
{ procedure: args.address },
directAnalysisOptions(logger, options.snapshot, options.provider),
),
),
});
};
const registerXrefsCommand = (cli: CliInstance, logger: Logger): void => {
cli.command(CLI_COMMANDS.xrefs, {
description: "List bounded references to an analyzed address",
args: z.object({
path: z.string().describe("App or program path"),
address: z.string().describe("Hexadecimal address"),
}),
options: z.object({
snapshot: z
.string()
.min(1)
.optional()
.describe("Load and update a local analysis snapshot"),
provider: providerSelectionOption,
}),
run: ({ args, options }) =>
logCliCommand(logger, "xrefs", () =>
runDirectAnalysis(
args.path,
"xrefs",
{ address: args.address },
directAnalysisOptions(logger, options.snapshot, options.provider),
),
),
});
};
const registerTraceCommand = (cli: CliInstance, logger: Logger): void => {
cli.command(CLI_COMMANDS.trace, {
description: "Trace a bounded literal feature through analyzed references",
args: z.object({
path: z.string().describe("App or program path"),
query: z.string().min(1).describe("Literal feature query"),
}),
options: z.object({
caseSensitive: z
.boolean()
.default(false)
.describe("Match the query with exact letter case"),
limit: z
.number()
.int()
.min(1)
.max(100)
.default(20)
.describe("Maximum matching roots to examine"),
maxOperations: z
.number()
.int()
.min(1)
.max(100)
.default(20)
.describe("Maximum analysis operations in the trace"),
snapshot: z
.string()
.min(1)
.optional()
.describe("Load and update a local analysis snapshot"),
provider: providerSelectionOption,
}),
alias: {
caseSensitive: "case-sensitive",
maxOperations: "max-operations",
},
run: ({ args, options }) =>
logCliCommand(logger, "trace", () =>
runDirectAnalysis(
args.path,
"trace_feature",
{
query: args.query,
case_sensitive: options.caseSensitive,
limit: options.limit,
max_operations: options.maxOperations,
},
directAnalysisOptions(logger, options.snapshot, options.provider),
),
),
});
};
const registerFunctionCommand = (cli: CliInstance, logger: Logger): void => {
cli.command(CLI_COMMANDS.function, {
description: "Analyze one bounded function with evidence",
args: z.object({
path: z.string().describe("App or program path"),
address: z.string().describe("Procedure name or address"),
}),
options: z.object({
includeAssembly: z
.boolean()
.default(false)
.describe("Include bounded assembly instructions"),
limit: z
.number()
.int()
.min(1)
.max(500)
.default(100)
.describe("Maximum referenced items to return"),
maxPseudocodeChars: z
.number()
.int()
.min(1)
.max(100_000)
.default(20_000)
.describe("Maximum pseudocode characters to return"),
maxInstructions: z
.number()
.int()
.min(1)
.max(5_000)
.default(500)
.describe("Maximum assembly instructions to return"),
snapshot: z
.string()
.min(1)
.optional()
.describe("Load and update a local analysis snapshot"),
provider: providerSelectionOption,
}),
alias: {
includeAssembly: "include-assembly",
maxPseudocodeChars: "max-pseudocode-chars",
maxInstructions: "max-instructions",
},
run: ({ args, options }) =>
logCliCommand(logger, "function", () =>
runDirectAnalysis(
args.path,
"analyze_function",
{
procedure: args.address,
include_assembly: options.includeAssembly,
limit: options.limit,
max_pseudocode_chars: options.maxPseudocodeChars,
max_instructions: options.maxInstructions,
},
directAnalysisOptions(logger, options.snapshot, options.provider),
),
),
});
};
const registerSearchCommand = (cli: CliInstance, logger: Logger): void => {
cli.command(CLI_COMMANDS.search, {
description: "Search bounded analyzed strings or procedure names",
args: z.object({
path: z.string().describe("App or program path"),
pattern: z.string().min(1).describe("Literal text or regex pattern"),
}),
options: z.object({
kind: z
.enum(["strings", "procedures"])
.default("strings")
.describe("Analyzed item kind to search"),
mode: z
.enum(["literal", "regex"])
.default("literal")
.describe("Interpret the pattern as literal text or a regex"),
caseSensitive: z
.boolean()
.default(false)
.describe("Match the pattern with exact letter case"),
offset: z
.number()
.int()
.min(0)
.default(0)
.describe("Zero-based matching-item offset"),
limit: z
.number()
.int()
.min(1)
.max(100)
.default(100)
.describe("Maximum matching items to return"),
snapshot: z
.string()
.min(1)
.optional()
.describe("Load and update a local analysis snapshot"),
provider: providerSelectionOption,
}),
alias: { caseSensitive: "case-sensitive" },
run: ({ args, options }) =>
logCliCommand(logger, "search", () =>
runDirectAnalysis(
args.path,
options.kind === "strings" ? "search_strings" : "search_procedures",
{
pattern: args.pattern,
mode: options.mode,
case_sensitive: options.caseSensitive,
offset: options.offset,
limit: options.limit,
},
directAnalysisOptions(logger, options.snapshot, options.provider),
),
),
});
};
+500
View File
@@ -0,0 +1,500 @@
import { z } from "incur";
import {
compareManagedMemberPaths,
type ManagedMemberPathInspectionLimits,
} from "../application/ManagedMemberComparisonService.js";
import { verifyManagedNativeBoundariesEvidence } from "../application/ManagedNativeVerificationService.js";
import { importManagedReconstructionEvidence } from "../application/ManagedReconstructionService.js";
import { planManagedRuntimeCorrelationEvidence } from "../application/ManagedRuntimeCorrelationService.js";
import { runProviderAnalysis } from "../application/DirectAnalysis.js";
import { parseConfig } from "../config.js";
import { loadConfiguredPermissionAuthority } from "../application/PermissionConfiguration.js";
import { parseCliJsonInput } from "../cliJsonInput.js";
import { projectAnalysisError } from "../domain/errors.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
import type { Logger } from "../logger.js";
import type { CliInstance } from "./types.js";
import { registerProjectManagedApplicationGraph } from "./managedProjectGraphCommand.js";
const managedOffset = (subject: string) =>
z.number().int().min(0).default(0).describe(`Zero-based ${subject} offset`);
const managedLimit = (subject: string, maximum: number, fallback: number) =>
z
.number()
.int()
.min(1)
.max(maximum)
.default(fallback)
.describe(`Maximum ${subject} to return`);
const managedInspectionLimits = {
maxFileBytes: z
.number()
.int()
.min(4_096)
.max(1_073_741_824)
.default(268_435_456)
.describe("Maximum managed artifact file size accepted for inspection"),
maxMetadataBytes: z
.number()
.int()
.min(256)
.max(268_435_456)
.default(67_108_864)
.describe("Maximum CLI metadata region size accepted for inspection"),
maxTableRows: z
.number()
.int()
.min(1)
.max(1_000_000)
.default(100_000)
.describe("Maximum rows accepted in any CLI metadata table"),
maxHeapItemBytes: z
.number()
.int()
.min(1)
.max(16_777_216)
.default(1_048_576)
.describe("Maximum bytes accepted for one CLI metadata heap item"),
};
export const registerManagedCommands = (
cli: CliInstance,
logger: Logger,
): void => {
registerInspectManagedArtifact(cli, logger);
registerInspectManagedMembers(cli, logger);
registerInspectManagedNativeBoundaries(cli, logger);
registerCompareManagedMembers(cli, logger);
registerImportManagedReconstruction(cli, logger);
registerVerifyManagedNativeBoundaries(cli, logger);
registerPlanManagedRuntimeCorrelation(cli, logger);
registerProjectManagedApplicationGraph(cli, logger);
};
const registerInspectManagedArtifact = (
cli: CliInstance,
logger: Logger,
): void => {
cli.command(CLI_COMMANDS.inspectManagedArtifact, {
description: "Inspect PE/CLI identity without loading target code",
args: z.object({
path: z.string().describe("Managed PE executable or assembly path"),
}),
options: z.object({
referenceOffset: managedOffset("assembly-reference"),
referenceLimit: managedLimit("assembly references", 500, 100),
resourceOffset: managedOffset("manifest-resource"),
resourceLimit: managedLimit("manifest resources", 500, 100),
attributeOffset: managedOffset("assembly-attribute"),
attributeLimit: managedLimit("assembly attributes", 500, 100),
...managedInspectionLimits,
}),
alias: {
referenceOffset: "reference-offset",
referenceLimit: "reference-limit",
resourceOffset: "resource-offset",
resourceLimit: "resource-limit",
attributeOffset: "attribute-offset",
attributeLimit: "attribute-limit",
maxFileBytes: "max-file-bytes",
maxMetadataBytes: "max-metadata-bytes",
maxTableRows: "max-table-rows",
maxHeapItemBytes: "max-heap-item-bytes",
},
run: ({ args, options }) =>
logCliCommand(logger, "inspect-managed-artifact", () =>
runProviderAnalysis(
args.path,
"inspect_managed_artifact",
{
reference_offset: options.referenceOffset,
reference_limit: options.referenceLimit,
resource_offset: options.resourceOffset,
resource_limit: options.resourceLimit,
attribute_offset: options.attributeOffset,
attribute_limit: options.attributeLimit,
max_file_bytes: options.maxFileBytes,
max_metadata_bytes: options.maxMetadataBytes,
max_table_rows: options.maxTableRows,
max_heap_item_bytes: options.maxHeapItemBytes,
},
logger,
),
),
});
};
const registerInspectManagedMembers = (
cli: CliInstance,
logger: Logger,
): void => {
cli.command(CLI_COMMANDS.inspectManagedMembers, {
description:
"Inspect PE/CLI metadata members, signatures, and CIL anchors without loading target code",
args: z.object({
path: z.string().describe("Managed PE executable or assembly path"),
}),
options: z.object({
typeOffset: managedOffset("type definition"),
typeLimit: managedLimit("type definitions", 500, 100),
methodOffset: managedOffset("method definition"),
methodLimit: managedLimit("method definitions", 500, 100),
fieldOffset: managedOffset("field definition"),
fieldLimit: managedLimit("field definitions", 500, 100),
memberRefOffset: managedOffset("member reference"),
memberRefLimit: managedLimit("member references", 500, 100),
edgeOffset: managedOffset("member edge"),
edgeLimit: managedLimit("member edges", 1_000, 250),
instructionAnchorLimit: z
.number()
.int()
.min(0)
.max(500)
.default(100)
.describe("Maximum CIL instruction anchors per method"),
...managedInspectionLimits,
maxMethodBodyBytes: z
.number()
.int()
.min(1)
.max(16_777_216)
.default(1_048_576)
.describe("Maximum CIL method body size accepted for inspection"),
maxMethodInstructions: z
.number()
.int()
.min(1)
.max(100_000)
.default(10_000)
.describe("Maximum decoded CIL instructions per method"),
}),
alias: {
typeOffset: "type-offset",
typeLimit: "type-limit",
methodOffset: "method-offset",
methodLimit: "method-limit",
fieldOffset: "field-offset",
fieldLimit: "field-limit",
memberRefOffset: "member-ref-offset",
memberRefLimit: "member-ref-limit",
edgeOffset: "edge-offset",
edgeLimit: "edge-limit",
instructionAnchorLimit: "instruction-anchor-limit",
maxFileBytes: "max-file-bytes",
maxMetadataBytes: "max-metadata-bytes",
maxTableRows: "max-table-rows",
maxHeapItemBytes: "max-heap-item-bytes",
maxMethodBodyBytes: "max-method-body-bytes",
maxMethodInstructions: "max-method-instructions",
},
run: ({ args, options }) =>
logCliCommand(logger, "inspect-managed-members", () =>
runProviderAnalysis(
args.path,
"inspect_managed_members",
{
type_offset: options.typeOffset,
type_limit: options.typeLimit,
method_offset: options.methodOffset,
method_limit: options.methodLimit,
field_offset: options.fieldOffset,
field_limit: options.fieldLimit,
member_ref_offset: options.memberRefOffset,
member_ref_limit: options.memberRefLimit,
edge_offset: options.edgeOffset,
edge_limit: options.edgeLimit,
instruction_anchor_limit: options.instructionAnchorLimit,
max_file_bytes: options.maxFileBytes,
max_metadata_bytes: options.maxMetadataBytes,
max_table_rows: options.maxTableRows,
max_heap_item_bytes: options.maxHeapItemBytes,
max_method_body_bytes: options.maxMethodBodyBytes,
max_method_instructions: options.maxMethodInstructions,
},
logger,
),
),
});
};
const registerInspectManagedNativeBoundaries = (
cli: CliInstance,
logger: Logger,
): void => {
cli.command(CLI_COMMANDS.inspectManagedNativeBoundaries, {
description:
"Inspect PE/CLI PInvoke and native implementation boundary declarations without loading target code",
args: z.object({
path: z.string().describe("Managed PE executable or assembly path"),
}),
options: z.object({
moduleRefOffset: managedOffset("module reference"),
moduleRefLimit: managedLimit("module references", 500, 100),
importOffset: managedOffset("platform import"),
importLimit: managedLimit("platform imports", 500, 100),
implementationOffset: managedOffset("native implementation"),
implementationLimit: managedLimit("native implementations", 500, 100),
...managedInspectionLimits,
}),
alias: {
moduleRefOffset: "module-ref-offset",
moduleRefLimit: "module-ref-limit",
importOffset: "import-offset",
importLimit: "import-limit",
implementationOffset: "implementation-offset",
implementationLimit: "implementation-limit",
maxFileBytes: "max-file-bytes",
maxMetadataBytes: "max-metadata-bytes",
maxTableRows: "max-table-rows",
maxHeapItemBytes: "max-heap-item-bytes",
},
run: ({ args, options }) =>
logCliCommand(logger, "inspect-managed-native-boundaries", () =>
runProviderAnalysis(
args.path,
"inspect_managed_native_boundaries",
{
module_ref_offset: options.moduleRefOffset,
module_ref_limit: options.moduleRefLimit,
import_offset: options.importOffset,
import_limit: options.importLimit,
implementation_offset: options.implementationOffset,
implementation_limit: options.implementationLimit,
max_file_bytes: options.maxFileBytes,
max_metadata_bytes: options.maxMetadataBytes,
max_table_rows: options.maxTableRows,
max_heap_item_bytes: options.maxHeapItemBytes,
},
logger,
),
),
});
};
const compareManagedMemberArgs = z.object({
leftPath: z.string().describe("Baseline managed PE executable or assembly"),
rightPath: z.string().describe("Candidate managed PE executable or assembly"),
});
const registerCompareManagedMembers = (
cli: CliInstance,
logger: Logger,
): void => {
cli.command(CLI_COMMANDS.compareManagedMembers, {
description:
"Compare two managed PE/CLI member inventories without name-based matching",
args: compareManagedMemberArgs,
options: z.object({
maxMethodMatches: z
.number()
.int()
.min(1)
.max(50_000)
.default(10_000)
.describe("Maximum exact method matches to return"),
maxFieldMatches: z
.number()
.int()
.min(0)
.max(50_000)
.default(5_000)
.describe("Maximum exact field matches to return"),
maxCandidates: z
.number()
.int()
.min(1)
.max(500)
.default(50)
.describe("Maximum ambiguous candidates per unmatched member"),
typeLimit: managedLimit("type definitions per artifact", 500, 500),
methodLimit: managedLimit("method definitions per artifact", 500, 500),
fieldLimit: managedLimit("field definitions per artifact", 500, 500),
memberRefLimit: managedLimit("member references per artifact", 500, 500),
edgeLimit: managedLimit("member edges per artifact", 1_000, 1_000),
instructionAnchorLimit: z
.number()
.int()
.min(0)
.max(500)
.default(500)
.describe("Maximum CIL instruction anchors per compared method"),
...managedInspectionLimits,
maxMethodBodyBytes: z
.number()
.int()
.min(1)
.max(16_777_216)
.default(1_048_576)
.describe("Maximum CIL method body size accepted for comparison"),
maxMethodInstructions: z
.number()
.int()
.min(1)
.max(100_000)
.default(100_000)
.describe("Maximum decoded CIL instructions per compared method"),
}),
alias: {
maxMethodMatches: "max-method-matches",
maxFieldMatches: "max-field-matches",
maxCandidates: "max-candidates",
typeLimit: "type-limit",
methodLimit: "method-limit",
fieldLimit: "field-limit",
memberRefLimit: "member-ref-limit",
edgeLimit: "edge-limit",
instructionAnchorLimit: "instruction-anchor-limit",
maxFileBytes: "max-file-bytes",
maxMetadataBytes: "max-metadata-bytes",
maxTableRows: "max-table-rows",
maxHeapItemBytes: "max-heap-item-bytes",
maxMethodBodyBytes: "max-method-body-bytes",
maxMethodInstructions: "max-method-instructions",
},
run: ({ args, options }) =>
logCliCommand(logger, "compare-managed-members", async () => {
const result = await compareManagedMemberPaths({
leftPath: args.leftPath,
rightPath: args.rightPath,
comparisonLimits: {
max_method_matches: options.maxMethodMatches,
max_field_matches: options.maxFieldMatches,
max_candidates: options.maxCandidates,
},
memberLimits: buildMemberLimits(options),
});
return result.ok
? result.value
: {
error: "Managed member comparison failed",
...projectAnalysisError(result.error),
};
}),
});
};
interface ManagedMemberLimitOptions {
readonly maxFileBytes: number;
readonly typeLimit: number;
readonly methodLimit: number;
readonly fieldLimit: number;
readonly memberRefLimit: number;
readonly edgeLimit: number;
readonly instructionAnchorLimit: number;
readonly maxMetadataBytes: number;
readonly maxTableRows: number;
readonly maxHeapItemBytes: number;
readonly maxMethodBodyBytes: number;
readonly maxMethodInstructions: number;
}
const buildMemberLimits = (
options: ManagedMemberLimitOptions,
): ManagedMemberPathInspectionLimits => ({
maxFileBytes: options.maxFileBytes,
typeOffset: 0,
typeLimit: options.typeLimit,
methodOffset: 0,
methodLimit: options.methodLimit,
fieldOffset: 0,
fieldLimit: options.fieldLimit,
memberRefOffset: 0,
memberRefLimit: options.memberRefLimit,
edgeOffset: 0,
edgeLimit: options.edgeLimit,
instructionAnchorLimit: options.instructionAnchorLimit,
maxMetadataBytes: options.maxMetadataBytes,
maxTableRows: options.maxTableRows,
maxHeapItemBytes: options.maxHeapItemBytes,
maxMethodBodyBytes: options.maxMethodBodyBytes,
maxMethodInstructions: options.maxMethodInstructions,
});
const registerImportManagedReconstruction = (
cli: CliInstance,
logger: Logger,
): void => {
cli.command(CLI_COMMANDS.importManagedReconstruction, {
description:
"Import decompiler-produced managed reconstruction against exact static member evidence",
args: z.object({
inputJson: z
.string()
.describe("Inline managed reconstruction JSON or JSON file path"),
}),
run: ({ args }) =>
logCliCommand(logger, "import-managed-reconstruction", async () => {
const input = await parseCliJsonInput(
args.inputJson,
"import-managed-reconstruction",
);
if (!input.ok) return input.error;
const result = importManagedReconstructionEvidence(input.value);
return result.ok ? result.value : projectAnalysisError(result.error);
}),
});
};
const registerVerifyManagedNativeBoundaries = (
cli: CliInstance,
logger: Logger,
): void => {
cli.command(CLI_COMMANDS.verifyManagedNativeBoundaries, {
description:
"Verify managed P/Invoke declarations against authenticated native Evidence",
args: z.object({
inputJson: z
.string()
.describe("Inline managed/native verification JSON or JSON file path"),
}),
run: ({ args }) =>
logCliCommand(logger, "verify-managed-native-boundaries", async () => {
const input = await parseCliJsonInput(
args.inputJson,
"verify-managed-native-boundaries",
);
if (!input.ok) return input.error;
const result = verifyManagedNativeBoundariesEvidence(input.value);
return result.ok ? result.value : projectAnalysisError(result.error);
}),
});
};
const registerPlanManagedRuntimeCorrelation = (
cli: CliInstance,
logger: Logger,
): void => {
cli.command(CLI_COMMANDS.planManagedRuntimeCorrelation, {
description:
"Plan a separately authorized managed runtime-correlation experiment without executing target code",
args: z.object({
inputJson: z
.string()
.describe("Inline managed runtime-correlation JSON or JSON file path"),
}),
run: ({ args }) =>
logCliCommand(logger, "plan-managed-runtime-correlation", async () => {
const input = await parseCliJsonInput(
args.inputJson,
"plan-managed-runtime-correlation",
);
if (!input.ok) return input.error;
const config = parseConfig(process.env);
if (!config.ok) return projectAnalysisError(config.error);
const authority = await loadConfiguredPermissionAuthority(config.value);
if (!authority.ok) return projectAnalysisError(authority.error);
const result = await planManagedRuntimeCorrelationEvidence(
{
policy: config.value.managedRuntimePolicy,
authority: authority.value,
},
input.value,
);
return result.ok ? result.value : projectAnalysisError(result.error);
}),
});
};
+35
View File
@@ -0,0 +1,35 @@
import { z } from "incur";
import { projectManagedApplicationGraphEvidence } from "../application/ManagedApplicationGraphService.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { parseCliJsonInput } from "../cliJsonInput.js";
import { logCliCommand } from "../cliLogging.js";
import { projectAnalysisError } from "../domain/errors.js";
import type { Logger } from "../logger.js";
import type { CliInstance } from "./types.js";
/** Register managed static-Evidence application graph projection. */
export const registerProjectManagedApplicationGraph = (
cli: CliInstance,
logger: Logger,
): void => {
cli.command(CLI_COMMANDS.projectManagedApplicationGraph, {
description:
"Project authenticated managed static Evidence into an application graph",
args: z.object({
inputJson: z
.string()
.describe("Inline managed application graph JSON or JSON file path"),
}),
run: ({ args }) =>
logCliCommand(logger, "project-managed-application-graph", async () => {
const input = await parseCliJsonInput(
args.inputJson,
"project-managed-application-graph",
);
if (!input.ok) return input.error;
const result = projectManagedApplicationGraphEvidence(input.value);
return result.ok ? result.value : projectAnalysisError(result.error);
}),
});
};
+19
View File
@@ -0,0 +1,19 @@
import { analysisProviderSelectorSchema } from "../contracts/providerSelection.js";
import type { AnalysisProviderSelector } from "../contracts/providerSelection.js";
import type { Logger } from "../logger.js";
export const providerSelectionOption = analysisProviderSelectorSchema
.optional()
.describe(
"Bind deep analysis to a provider ID or use deterministic auto selection",
);
export const directAnalysisOptions = (
logger: Logger,
snapshotPath: string | undefined,
providerId: AnalysisProviderSelector | undefined,
) => ({
logger,
snapshotPath,
...(providerId === undefined ? {} : { providerId }),
});
+106
View File
@@ -0,0 +1,106 @@
import { z } from "incur";
import { createInterface } from "node:readline/promises";
import { runDoctor } from "../application/Doctor.js";
import {
runSetup,
systemSetupHost,
type SetupAction,
} from "../application/Setup.js";
import { runUninstall } from "../application/Uninstall.js";
import { runUpgrade, systemUpgradeHost } from "../application/Upgrade.js";
import { PRODUCT_IDENTITY } from "../identity.js";
import { logCliCommand } from "../cliLogging.js";
import { createSystemDoctorHost } from "../doctorRuntime.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import type { Logger } from "../logger.js";
import type { CliInstance } from "./types.js";
export const registerSetupCommands = (
cli: CliInstance,
logger: Logger,
): void => {
cli.command(CLI_COMMANDS.setup, {
description: "Install requirements and configure agents",
options: z.object({
yes: z
.boolean()
.default(false)
.describe("Approve user-owned setup actions without prompting"),
installHopper: z
.boolean()
.default(false)
.describe("Also approve Hopper installation with --yes"),
}),
alias: { yes: "y", installHopper: "install-hopper" },
run: ({ options, formatExplicit }) =>
logCliCommand(logger, "setup", () =>
runSetup(
{
approved: options.yes,
installHopper: options.installHopper,
structured: formatExplicit,
},
systemSetupHost(createSystemDoctorHost()),
options.yes || formatExplicit || process.stdin.isTTY !== true
? undefined
: confirmSetup,
),
),
});
cli.command(CLI_COMMANDS.doctor, {
description: "Check whether REA is ready",
options: z.object({
target: z.string().optional().describe("Optional app path to check"),
}),
run: ({ options }) =>
logCliCommand(logger, "doctor", () =>
runDoctor(options.target, createSystemDoctorHost()),
),
});
cli.command(CLI_COMMANDS.uninstall, {
description: "Remove REA-owned agent configuration and skill files",
options: z.object({
purgeData: z
.boolean()
.default(false)
.describe("Also remove REA caches and state"),
}),
alias: { purgeData: "purge-data" },
run: ({ options }) =>
logCliCommand(logger, "uninstall", () => runUninstall(options.purgeData)),
});
cli.command(CLI_COMMANDS.upgrade, {
description: "Upgrade a global npm installation to the latest REA release",
run: ({ formatExplicit }) =>
logCliCommand(logger, "upgrade", () =>
runUpgrade(
PRODUCT_IDENTITY.packageVersion,
systemUpgradeHost(),
formatExplicit ? "structured" : "human",
),
),
});
};
const confirmSetup = async (
actions: readonly SetupAction[],
): Promise<boolean> => {
process.stdout.write("\nREA setup plan\n");
for (const action of actions)
process.stdout.write(
` - ${action.detail}\n ${action.target}${action.external ? " (external software)" : ""}\n`,
);
const prompt = createInterface({
input: process.stdin,
output: process.stdout,
});
try {
const answer = (await prompt.question("Continue? [Y/n] "))
.trim()
.toLowerCase();
return answer === "" || answer === "y" || answer === "yes";
} finally {
prompt.close();
}
};
+3
View File
@@ -0,0 +1,3 @@
import { Cli } from "incur";
export type CliInstance = ReturnType<typeof Cli.create>;
+167
View File
@@ -0,0 +1,167 @@
import { z } from "incur";
import {
runProviderAnalysis,
runSessionStatus,
} from "../application/DirectAnalysis.js";
import { importReferenceSource } from "../application/ReferenceSourceImport.js";
import { projectReferenceSourceImportError } from "../application/ReferenceSourceImportTypes.js";
import { loadConfiguredPermissionAuthority } from "../application/PermissionConfiguration.js";
import { parseConfig } from "../config.js";
import {
AnalysisProtocolError,
PermissionRequiredError,
projectAnalysisError,
} from "../domain/errors.js";
import { PRODUCT_IDENTITY } from "../identity.js";
import { logCliCommand } from "../cliLogging.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import type { Logger } from "../logger.js";
import type { CliInstance } from "./types.js";
export const registerUtilityCommands = (
cli: CliInstance,
logger: Logger,
): void => {
registerCapabilityCommands(cli, logger);
registerNativeCommands(cli, logger);
registerReferenceSourceCommand(cli, logger);
};
const registerCapabilityCommands = (cli: CliInstance, logger: Logger): void => {
for (const command of [
CLI_COMMANDS.capabilities,
CLI_COMMANDS.providers,
] as const) {
cli.command(command, {
description:
command === "capabilities"
? "List provider capabilities and side effects"
: "List configured analysis providers",
run: () => logCliCommand(logger, command, () => runSessionStatus(logger)),
});
}
};
const registerNativeCommands = (cli: CliInstance, logger: Logger): void => {
for (const [command, tool] of [
[CLI_COMMANDS.inspectMacho, "inspect_macho"],
[CLI_COMMANDS.inspectSignature, "inspect_signature"],
[CLI_COMMANDS.listArchitectures, "list_architectures"],
] as const) {
cli.command(command, {
description: `Run ${tool} without launching Hopper`,
args: z.object({ path: z.string().describe("Mach-O or app path") }),
run: ({ args }) =>
logCliCommand(logger, command, () =>
runProviderAnalysis(args.path, tool, {}, logger),
),
});
}
cli.command(CLI_COMMANDS.inspectPlist, {
description: "Parse app plist metadata without launching Hopper",
args: z.object({ path: z.string().describe("App or Mach-O path") }),
options: z.object({
relativePath: z
.string()
.default("Contents/Info.plist")
.describe("Plist path relative to the app root"),
}),
alias: { relativePath: "relative-path" },
run: ({ args, options }) =>
logCliCommand(logger, "inspect-plist", () =>
runProviderAnalysis(
args.path,
"inspect_plist",
{ relative_path: options.relativePath },
logger,
),
),
});
cli.command(CLI_COMMANDS.demangleSwift, {
description: "Demangle a bounded Swift symbol batch without Hopper",
args: z.object({
path: z.string().describe("Artifact path used for evidence identity"),
symbols: z
.array(z.string().min(1))
.min(1)
.max(500)
.describe("Swift mangled symbols to demangle"),
}),
run: ({ args }) =>
logCliCommand(logger, "demangle-swift", () =>
runProviderAnalysis(
args.path,
"demangle_swift",
{ symbols: args.symbols },
logger,
),
),
});
};
const registerReferenceSourceCommand = (
cli: CliInstance,
logger: Logger,
): void => {
cli.command(CLI_COMMANDS.importReferenceSource, {
description: "Import a bounded source tree as historical reference only",
args: z.object({
root: z
.string()
.describe("Source root allowed by REA_REFERENCE_ROOTS_JSON"),
}),
run: ({ args }) =>
logCliCommand(logger, "import-reference-source", async () => {
const config = parseConfig(process.env);
if (!config.ok)
return {
error: "Import failed",
...projectAnalysisError(config.error),
};
const authority = await loadConfiguredPermissionAuthority(config.value);
if (!authority.ok)
return {
error: "Import failed",
...projectAnalysisError(authority.error),
};
const authorized = await authority.value.authorize(
{
capability: "reference_read",
roots: [args.root],
executables: [],
environment_names: [],
network: "none",
mount: false,
operation_identity: `import_reference_source:${args.root}`,
},
"read",
);
if (!authorized.ok) {
const error =
authorized.error instanceof PermissionRequiredError
? authorized.error
: new AnalysisProtocolError(authorized.error.message, {
cause: authorized.error,
});
return {
error: "Import failed",
...projectAnalysisError(error),
};
}
const imported = await importReferenceSource({
root: args.root,
caller: "rea-cli",
policy: config.value.referenceSourcePolicy,
importer: PRODUCT_IDENTITY.packageName,
importerVersion: null,
});
return imported.ok
? imported.value
: {
error: "Import failed",
...projectReferenceSourceImportError(imported.error),
};
}),
});
};
+69 -46
View File
@@ -36,25 +36,29 @@ import { SystemJavaScriptReplayHost } from "./replay/SystemJavaScriptReplayHost.
import type { AppConfig } from "./config.js";
import type { PermissionAuthority } from "./application/PermissionAuthority.js";
type CliInstance = ReturnType<typeof Cli.create>;
/** Register CLI equivalents of provider-neutral application graph workflows. */
export const registerApplicationCommands = (
cli: ReturnType<typeof Cli.create>,
logger: Logger,
): void => {
registerJsonCommand(
registerJsonCommand({
cli,
logger,
CLI_COMMANDS.traceApplicationFeature,
"Trace a typed seed through authenticated application Evidence JSON",
traceApplicationFeatureEvidence,
);
registerJsonCommand(
name: CLI_COMMANDS.traceApplicationFeature,
description:
"Trace a typed seed through authenticated application Evidence JSON",
workflow: traceApplicationFeatureEvidence,
});
registerJsonCommand({
cli,
logger,
CLI_COMMANDS.compareApplicationVersions,
"Compare two authenticated JavaScript Application Graph versions",
compareApplicationVersionsEvidence,
);
name: CLI_COMMANDS.compareApplicationVersions,
description:
"Compare two authenticated JavaScript Application Graph versions",
workflow: compareApplicationVersionsEvidence,
});
cli.command(CLI_COMMANDS.runControlledReplay, {
description:
"Plan or execute an approved extracted-module replay in the Linux sandbox",
@@ -84,39 +88,49 @@ export const registerApplicationCommands = (
return result.ok ? result.value : projectAnalysisError(result.error);
}),
});
registerAuthorizedJsonCommand(
registerAuthorizedJsonCommand({
cli,
logger,
CLI_COMMANDS.prepareNodeCharacterization,
"Prepare one exact Node/JavaScript characterization without execution",
(config, authority, input) =>
name: CLI_COMMANDS.prepareNodeCharacterization,
description:
"Prepare one exact Node/JavaScript characterization without execution",
workflow: (config, authority, input) =>
prepareNodeCharacterization(replayDependencies(config, authority), input),
);
registerAuthorizedJsonCommand(
});
registerAuthorizedJsonCommand({
cli,
logger,
CLI_COMMANDS.executeNodeCharacterization,
"Execute one separately approved exact Node/JavaScript characterization",
(config, authority, input) =>
name: CLI_COMMANDS.executeNodeCharacterization,
description:
"Execute one separately approved exact Node/JavaScript characterization",
workflow: (config, authority, input) =>
executeNodeCharacterization(replayDependencies(config, authority), input),
);
});
registerCoverageCommands(cli, logger);
};
const registerAuthorizedJsonCommand = (
cli: ReturnType<typeof Cli.create>,
logger: Logger,
name: string,
description: string,
workflow: (
interface AuthorizedJsonCommandOptions {
readonly cli: CliInstance;
readonly logger: Logger;
readonly name: string;
readonly description: string;
readonly workflow: (
config: AppConfig,
authority: PermissionAuthority,
input: unknown,
) => Promise<
| { readonly ok: true; readonly value: JsonValue }
| { readonly ok: false; readonly error: AnalysisError }
>,
): void => {
>;
}
const registerAuthorizedJsonCommand = ({
cli,
logger,
name,
description,
workflow,
}: AuthorizedJsonCommandOptions): void => {
cli.command(name, {
description,
args: z.object({
@@ -142,12 +156,13 @@ const registerCoverageCommands = (
cli: ReturnType<typeof Cli.create>,
logger: Logger,
): void => {
registerAuthorizedJsonCommand(
registerAuthorizedJsonCommand({
cli,
logger,
CLI_COMMANDS.commitReconstructionCoverage,
"Commit one canonical reconstruction coverage workspace revision",
async (config, authority, input) => {
name: CLI_COMMANDS.commitReconstructionCoverage,
description:
"Commit one canonical reconstruction coverage workspace revision",
workflow: async (config, authority, input) => {
const parsed = reconstructionCoverageCommitInputSchema.safeParse(input);
if (!parsed.success)
return {
@@ -174,13 +189,13 @@ const registerCoverageCommands = (
config.evidenceFilePolicy,
);
},
);
registerAuthorizedJsonCommand(
});
registerAuthorizedJsonCommand({
cli,
logger,
CLI_COMMANDS.queryReconstructionCoverage,
"Evaluate one fail-closed reconstruction coverage boundary",
async (config, authority, input) => {
name: CLI_COMMANDS.queryReconstructionCoverage,
description: "Evaluate one fail-closed reconstruction coverage boundary",
workflow: async (config, authority, input) => {
const parsed = reconstructionCoverageQueryInputSchema.safeParse(input);
if (!parsed.success)
return {
@@ -203,7 +218,7 @@ const registerCoverageCommands = (
Date.now(),
);
},
);
});
};
const configuredAuthority = async (): Promise<
@@ -236,17 +251,25 @@ const replayDependencies = (
authority,
});
const registerJsonCommand = (
cli: ReturnType<typeof Cli.create>,
logger: Logger,
name: string,
description: string,
workflow: (
interface JsonCommandOptions {
readonly cli: CliInstance;
readonly logger: Logger;
readonly name: string;
readonly description: string;
readonly workflow: (
input: unknown,
) =>
| { readonly ok: true; readonly value: JsonValue }
| { readonly ok: false; readonly error: Error },
): void => {
| { readonly ok: false; readonly error: Error };
}
const registerJsonCommand = ({
cli,
logger,
name,
description,
workflow,
}: JsonCommandOptions): void => {
cli.command(name, {
description,
args: z.object({
+58
View File
@@ -0,0 +1,58 @@
import { z } from "incur";
import { loadConfiguredPermissionAuthority } from "../application/PermissionConfiguration.js";
import { CdpBrowserProvider } from "../browser/CdpBrowserProvider.js";
import { parseConfig } from "../config.js";
import { projectAnalysisError } from "../domain/errors.js";
import type { JsonValue } from "../domain/jsonValue.js";
export const browserScopeOptions = {
allowedOrigins: z
.array(z.string().min(1))
.optional()
.describe(
"Exact origins to observe; defaults to REA_BROWSER_ALLOWED_ORIGINS_JSON",
),
approved: z.boolean().default(false).describe("Approve passive observation"),
};
export const boundedCount = (
subject: string,
maximum: number,
fallback: number,
minimum = 1,
) =>
z
.number()
.int()
.min(minimum)
.max(maximum)
.default(fallback)
.describe(`Maximum ${subject}`);
export const boundedBytes = (
subject: string,
maximum: number,
fallback: number,
) => boundedCount(`${subject} in bytes`, maximum, fallback);
export const browserContext = async () => {
const config = parseConfig(process.env);
if (!config.ok) return { ok: false as const, error: cliError(config.error) };
const authority = await loadConfiguredPermissionAuthority(config.value);
if (!authority.ok)
return { ok: false as const, error: cliError(authority.error) };
return {
ok: true as const,
authority: authority.value,
provider: new CdpBrowserProvider(),
allowedBrowserOrigins: config.value.browserAllowedOrigins,
};
};
export const cliError = (
error: Parameters<typeof projectAnalysisError>[0],
): JsonValue => ({
error: "Browser observation failed",
...projectAnalysisError(error),
});
+82 -49
View File
@@ -102,69 +102,102 @@ export const registerPolicyCommands = (
logCliCommand(logger, "policy", async () => {
const config = parseConfig(process.env);
if (!config.ok) return failure(config.error);
if (args.action === "status")
return {
reload: "SIGHUP",
restart_required: false,
administrator_ceilings: config.value.permissionCeilings,
project_store: config.value.permissionProjectStore ?? null,
};
if (args.action === "list")
return readProjectGrants(config.value).then((result) =>
result.ok ? { grants: result.grants } : result.error,
);
if (args.action === "revoke") {
if (args.value === undefined)
return { error: "Grant ID is required for policy revoke" };
const { permissionProjectRoot, permissionProjectStore } =
config.value;
if (
permissionProjectRoot === undefined ||
permissionProjectStore === undefined
)
return { error: "Project policy is not configured" };
const approval = await approvePolicyRevocation({
if (args.action === "status") return policyStatus(config.value);
if (args.action === "list") return policyList(config.value);
if (args.action === "revoke")
return policyRevoke({
config: config.value,
grantId: args.value,
approved: options.yes,
interactive:
!agent && !formatExplicit && process.stdin.isTTY === true,
grantId: args.value,
confirm: confirmPolicyRevocation,
});
if (!approval.approved)
return approval.reason === "required"
? {
error:
"Policy revoke requires confirmation. Rerun interactively or with --yes.",
grant_id: args.value,
}
: {
error: "Policy revocation was cancelled",
grant_id: args.value,
};
const revoked = await revokeProjectPermissionGrant(
permissionProjectStore,
permissionProjectRoot,
args.value,
);
return revoked.ok && revoked.value
? {
revoked: args.value,
reload: "Send SIGHUP to the REA MCP process",
}
: revoked.ok
? { error: "Grant was not found", grant_id: args.value }
: { error: revoked.error.message };
}
const capability = capabilitySchema.safeParse(args.value);
if (!capability.success)
return {
error: "A registered capability is required for policy explain",
};
return explain(config.value, capability.data, options);
return policyExplain(config.value, capability.data, options);
}),
});
};
const policyStatus = (config: AppConfig) => ({
reload: "SIGHUP",
restart_required: false,
administrator_ceilings: config.permissionCeilings,
project_store: config.permissionProjectStore ?? null,
});
const policyList = (config: AppConfig) =>
readProjectGrants(config).then((result) =>
result.ok ? { grants: result.grants } : result.error,
);
interface PolicyRevokeContext {
readonly config: AppConfig;
readonly grantId: string | undefined;
readonly approved: boolean;
readonly interactive: boolean;
readonly confirm: (grantId: string) => Promise<boolean>;
}
const policyRevoke = async (context: PolicyRevokeContext) => {
if (context.grantId === undefined)
return { error: "Grant ID is required for policy revoke" };
const { permissionProjectRoot, permissionProjectStore } = context.config;
if (
permissionProjectRoot === undefined ||
permissionProjectStore === undefined
)
return { error: "Project policy is not configured" };
const approval = await approvePolicyRevocation({
approved: context.approved,
interactive: context.interactive,
grantId: context.grantId,
confirm: context.confirm,
});
if (!approval.approved)
return approval.reason === "required"
? {
error:
"Policy revoke requires confirmation. Rerun interactively or with --yes.",
grant_id: context.grantId,
}
: {
error: "Policy revocation was cancelled",
grant_id: context.grantId,
};
const revoked = await revokeProjectPermissionGrant(
permissionProjectStore,
permissionProjectRoot,
context.grantId,
);
return revoked.ok && revoked.value
? {
revoked: context.grantId,
reload: "Send SIGHUP to the REA MCP process",
}
: revoked.ok
? { error: "Grant was not found", grant_id: context.grantId }
: { error: revoked.error.message };
};
const policyExplain = async (
config: AppConfig,
capability: PermissionCapability,
options: {
readonly root?: string | undefined;
readonly executable?: string | undefined;
readonly environmentNames: readonly string[];
readonly origins: readonly string[];
readonly network: "none" | "loopback" | "external";
readonly mount: boolean;
readonly write: boolean;
},
) => explain(config, capability, options);
const confirmPolicyRevocation = async (grantId: string): Promise<boolean> => {
const prompt = createInterface({
input: process.stdin,
+2 -570
View File
@@ -1,570 +1,2 @@
import { z } from "zod";
import { isAbsolute } from "node:path";
import { ConfigurationError } from "./domain/errors.js";
import { err, ok, type Result } from "./domain/result.js";
import type { LogLevel } from "./logger.js";
import type { ProcessExecutionPolicy } from "./domain/processCapture.js";
import type { EvidenceFilePolicy } from "./domain/evidenceBundle.js";
import type { ReferenceSourcePolicy } from "./domain/referenceSourcePolicy.js";
import type {
PermissionCeiling,
PermissionGrant,
} from "./domain/permissionPolicy.js";
import {
browserEndpointSchema,
browserOriginSchema,
isLiteralLoopbackHostname,
} from "./domain/browserObservation.js";
import { electronFileRootsSchema } from "./domain/electronObservation.js";
import {
analysisProviderSelectorSchema,
type AnalysisProviderSelector,
} from "./contracts/providerSelection.js";
const DEFAULT_HOPPER_LAUNCHER_PATH =
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper";
const defaultHopperLauncherPath = (): string =>
process.platform === "linux"
? "/opt/hopper/bin/Hopper"
: DEFAULT_HOPPER_LAUNCHER_PATH;
export interface AppConfig {
readonly analysisProvider: AnalysisProviderSelector;
readonly ghidraInstallDir: string | undefined;
readonly ghidraJavaHome: string | undefined;
readonly ilspyCmdPath: string | undefined;
readonly hopperLauncherPath: string;
readonly hopperTargetPath: string | undefined;
readonly hopperTargetKind: "executable" | "database";
readonly hopperLoaderArgs: readonly string[];
readonly logLevel: LogLevel;
readonly processExecutionPolicy: ProcessExecutionPolicy;
readonly artifactNativeMountEnabled: boolean;
readonly artifactIntegrityContinueEnabled: boolean;
readonly evidenceFilePolicy: EvidenceFilePolicy;
readonly investigationInputRoots: readonly string[];
readonly analysisSnapshotFilePolicy: EvidenceFilePolicy;
readonly referenceSourcePolicy: ReferenceSourcePolicy;
readonly browserObservationEnabled: boolean;
readonly browserCdpEndpoints: readonly string[];
readonly browserAllowedOrigins: readonly string[];
readonly electronObservationEnabled: boolean;
readonly electronCdpEndpoints: readonly string[];
readonly electronFileRoots: readonly string[];
readonly javascriptReplayPolicy: {
readonly enabled: boolean;
readonly roots: readonly string[];
readonly nodePath: string;
readonly bubblewrapPath: string;
readonly systemdRunPath: string;
readonly systemctlPath: string;
readonly shellPath: string;
};
readonly managedRuntimePolicy: {
readonly enabled: boolean;
readonly roots: readonly string[];
readonly executablePath: string;
};
readonly permissionCeilings: readonly PermissionCeiling[];
readonly administratorPermissionGrants: readonly PermissionGrant[];
readonly permissionProjectRoot: string | undefined;
readonly permissionProjectStore: string | undefined;
}
const environmentSchema = z
.object({
REA_ANALYSIS_PROVIDER: analysisProviderSelectorSchema.default("auto"),
GHIDRA_INSTALL_DIR: z
.string()
.min(1)
.refine(isAbsolute, "GHIDRA_INSTALL_DIR must be absolute")
.optional(),
JAVA_HOME: z
.string()
.min(1)
.refine(isAbsolute, "JAVA_HOME must be absolute")
.optional(),
REA_ILSPY_CMD_PATH: z
.string()
.min(1)
.refine(isAbsolute, "REA_ILSPY_CMD_PATH must be absolute")
.optional(),
HOPPER_LAUNCHER_PATH: z.string().min(1).optional(),
HOPPER_TARGET_PATH: z.string().min(1).optional(),
HOPPER_TARGET_KIND: z
.enum(["executable", "database"])
.default("executable"),
HOPPER_LOADER_ARGS_JSON: z.string().optional(),
REA_LOG_LEVEL: z
.enum(["trace", "debug", "info", "warn", "error", "fatal", "silent"])
.default("info"),
REA_PROCESS_CAPTURE_ENABLED: z.enum(["true", "false"]).default("false"),
REA_PROCESS_CAPTURE_AUTO_GRANT: z.enum(["true", "false"]).default("true"),
REA_ARTIFACT_NATIVE_MOUNT_ENABLED: z
.enum(["true", "false"])
.default("false"),
REA_ARTIFACT_INTEGRITY_CONTINUE_ENABLED: z
.enum(["true", "false"])
.default("false"),
REA_PROCESS_ALLOW_EXTERNAL_NETWORK: z
.enum(["true", "false"])
.default("false"),
REA_PROCESS_EXECUTABLE_ROOTS_JSON: z.string().default("[]"),
REA_PROCESS_WORKING_ROOTS_JSON: z.string().default("[]"),
REA_PROCESS_ALLOWED_ENV_JSON: z.string().default("[]"),
REA_EVIDENCE_ROOTS_JSON: z.string().default("[]"),
REA_INVESTIGATION_INPUT_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("[]"),
REA_BROWSER_OBSERVE_ENABLED: z.enum(["true", "false"]).default("false"),
REA_BROWSER_CDP_ENDPOINTS_JSON: z.string().default("[]"),
REA_BROWSER_ALLOWED_ORIGINS_JSON: z.string().default("[]"),
REA_ELECTRON_OBSERVE_ENABLED: z.enum(["true", "false"]).default("false"),
REA_ELECTRON_CDP_ENDPOINTS_JSON: z.string().default("[]"),
REA_ELECTRON_FILE_ROOTS_JSON: z.string().default("[]"),
REA_JAVASCRIPT_REPLAY_ENABLED: z.enum(["true", "false"]).default("false"),
REA_JAVASCRIPT_REPLAY_ROOTS_JSON: z.string().default("[]"),
REA_JAVASCRIPT_REPLAY_NODE_PATH: z
.string()
.min(1)
.refine(isAbsolute, "REA_JAVASCRIPT_REPLAY_NODE_PATH must be absolute")
.default(process.execPath),
REA_JAVASCRIPT_REPLAY_BWRAP_PATH: z
.string()
.min(1)
.refine(isAbsolute, "REA_JAVASCRIPT_REPLAY_BWRAP_PATH must be absolute")
.default("/usr/bin/bwrap"),
REA_JAVASCRIPT_REPLAY_SYSTEMD_RUN_PATH: z
.string()
.min(1)
.refine(
isAbsolute,
"REA_JAVASCRIPT_REPLAY_SYSTEMD_RUN_PATH must be absolute",
)
.default("/usr/bin/systemd-run"),
REA_JAVASCRIPT_REPLAY_SYSTEMCTL_PATH: z
.string()
.min(1)
.refine(
isAbsolute,
"REA_JAVASCRIPT_REPLAY_SYSTEMCTL_PATH must be absolute",
)
.default("/usr/bin/systemctl"),
REA_JAVASCRIPT_REPLAY_SHELL_PATH: z
.string()
.min(1)
.refine(isAbsolute, "REA_JAVASCRIPT_REPLAY_SHELL_PATH must be absolute")
.default("/usr/bin/bash"),
REA_MANAGED_RUNTIME_ENABLED: z.enum(["true", "false"]).default("false"),
REA_MANAGED_RUNTIME_ROOTS_JSON: z.string().default("[]"),
REA_MANAGED_RUNTIME_EXECUTABLE_PATH: z
.string()
.min(1)
.refine(
isAbsolute,
"REA_MANAGED_RUNTIME_EXECUTABLE_PATH must be absolute",
)
.default("/usr/bin/dotnet"),
REA_PERMISSION_PROJECT_ROOT: z.string().min(1).optional(),
REA_PERMISSION_PROJECT_STORE: z.string().min(1).optional(),
})
.refine(
(value) =>
(value.REA_PERMISSION_PROJECT_ROOT === undefined) ===
(value.REA_PERMISSION_PROJECT_STORE === undefined),
{
message:
"REA_PERMISSION_PROJECT_ROOT and REA_PERMISSION_PROJECT_STORE must be configured together",
},
);
const parseStringArray = (
encoded: string,
name: string,
): Result<readonly string[], ConfigurationError> => {
try {
const parsed = z
.array(z.string().min(1))
.max(128)
.safeParse(JSON.parse(encoded));
return parsed.success
? ok(parsed.data)
: err(
new ConfigurationError(
parsed.error.issues.some(({ code }) => code === "too_big")
? `${name} must encode at most 128 strings`
: `${name} must encode an array of strings`,
{ cause: parsed.error },
),
);
} catch (cause: unknown) {
return err(new ConfigurationError(`${name} must be valid JSON`, { cause }));
}
};
const parseBrowserArray = (
encoded: string,
name: string,
itemSchema: z.ZodType<string>,
maximum: number,
): Result<readonly string[], ConfigurationError> => {
try {
const parsed = z
.array(itemSchema)
.max(maximum)
.safeParse(JSON.parse(encoded));
return parsed.success
? ok([...new Set(parsed.data)].sort())
: err(
new ConfigurationError(`${name} must encode valid browser scopes`, {
cause: parsed.error,
}),
);
} catch (cause: unknown) {
return err(new ConfigurationError(`${name} must be valid JSON`, { cause }));
}
};
const parseElectronFileRoots = (
encoded: string,
): Result<readonly string[], ConfigurationError> => {
try {
const decoded: unknown = JSON.parse(encoded);
if (Array.isArray(decoded) && decoded.length === 0) return ok([]);
const parsed = electronFileRootsSchema.safeParse(decoded);
return parsed.success
? ok(parsed.data)
: err(
new ConfigurationError(
"REA_ELECTRON_FILE_ROOTS_JSON must encode absolute roots",
{ cause: parsed.error },
),
);
} catch (cause: unknown) {
return err(
new ConfigurationError(
"REA_ELECTRON_FILE_ROOTS_JSON must be valid JSON",
{ cause },
),
);
}
};
const filePolicy = (roots: readonly string[]): EvidenceFilePolicy => ({
roots,
maxBytes: 64 * 1024 * 1024,
maxDepth: 64,
maxStringLength: 1024 * 1024,
maxNodes: 1_000_000,
});
const permissionScope = (
capability: PermissionCeiling["capability"],
roots: readonly string[],
options: Partial<Omit<PermissionCeiling, "capability" | "roots">> = {},
): PermissionCeiling => ({
capability,
roots,
executables: options.executables ?? [],
environment_names: options.environment_names ?? [],
...(options.origins === undefined ? {} : { origins: options.origins }),
network: options.network ?? "none",
mount: options.mount ?? false,
});
const administratorGrants = (
ceilings: readonly PermissionCeiling[],
): readonly PermissionGrant[] =>
ceilings.map((ceiling) => ({
...ceiling,
grant_id: `administrator:${ceiling.capability}`,
lifetime: "administrator",
operation_identity: null,
expires_at: null,
}));
const parseLoaderArgs = (
encoded: string | undefined,
): Result<readonly string[], ConfigurationError> => {
if (encoded === undefined) return ok([]);
let decoded: unknown;
try {
decoded = JSON.parse(encoded);
} catch (cause: unknown) {
return err(
new ConfigurationError("HOPPER_LOADER_ARGS_JSON must be valid JSON", {
cause,
}),
);
}
const parsed = z.array(z.string()).safeParse(decoded);
return parsed.success
? ok(parsed.data)
: err(
new ConfigurationError(
"HOPPER_LOADER_ARGS_JSON must encode an array of strings",
{ cause: parsed.error },
),
);
};
/**
* Parse provider and authority configuration once at the composition root.
* Explicit loader arguments override REA's header-derived defaults for a
* supported target, allowing callers to refine Hopper's loader behavior without
* bypassing REA's path and executable-header checks.
*/
export const parseConfig = (
environment: Readonly<Record<string, string | undefined>>,
): Result<AppConfig, ConfigurationError> => {
const parsedEnvironment = environmentSchema.safeParse(environment);
if (!parsedEnvironment.success) {
return err(
new ConfigurationError("Invalid REA environment configuration", {
cause: parsedEnvironment.error,
}),
);
}
const loaderArgs = parseLoaderArgs(
parsedEnvironment.data.HOPPER_LOADER_ARGS_JSON,
);
if (!loaderArgs.ok) return loaderArgs;
const executableRoots = parseStringArray(
parsedEnvironment.data.REA_PROCESS_EXECUTABLE_ROOTS_JSON,
"REA_PROCESS_EXECUTABLE_ROOTS_JSON",
);
if (!executableRoots.ok) return executableRoots;
const workingRoots = parseStringArray(
parsedEnvironment.data.REA_PROCESS_WORKING_ROOTS_JSON,
"REA_PROCESS_WORKING_ROOTS_JSON",
);
if (!workingRoots.ok) return workingRoots;
const allowedEnvironment = parseStringArray(
parsedEnvironment.data.REA_PROCESS_ALLOWED_ENV_JSON,
"REA_PROCESS_ALLOWED_ENV_JSON",
);
if (!allowedEnvironment.ok) return allowedEnvironment;
const evidenceRoots = parseStringArray(
parsedEnvironment.data.REA_EVIDENCE_ROOTS_JSON,
"REA_EVIDENCE_ROOTS_JSON",
);
if (!evidenceRoots.ok) return evidenceRoots;
const investigationInputRoots = parseStringArray(
parsedEnvironment.data.REA_INVESTIGATION_INPUT_ROOTS_JSON,
"REA_INVESTIGATION_INPUT_ROOTS_JSON",
);
if (!investigationInputRoots.ok) return investigationInputRoots;
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",
);
if (!referenceRoots.ok) return referenceRoots;
const secretPatterns = parseStringArray(
parsedEnvironment.data.REA_REFERENCE_SECRET_PATTERNS_JSON,
"REA_REFERENCE_SECRET_PATTERNS_JSON",
);
if (!secretPatterns.ok) return secretPatterns;
const browserEndpoints = parseBrowserArray(
parsedEnvironment.data.REA_BROWSER_CDP_ENDPOINTS_JSON,
"REA_BROWSER_CDP_ENDPOINTS_JSON",
browserEndpointSchema,
16,
);
if (!browserEndpoints.ok) return browserEndpoints;
const browserOrigins = parseBrowserArray(
parsedEnvironment.data.REA_BROWSER_ALLOWED_ORIGINS_JSON,
"REA_BROWSER_ALLOWED_ORIGINS_JSON",
browserOriginSchema,
32,
);
if (!browserOrigins.ok) return browserOrigins;
const electronEndpoints = parseBrowserArray(
parsedEnvironment.data.REA_ELECTRON_CDP_ENDPOINTS_JSON,
"REA_ELECTRON_CDP_ENDPOINTS_JSON",
browserEndpointSchema,
16,
);
if (!electronEndpoints.ok) return electronEndpoints;
const electronFileRoots = parseElectronFileRoots(
parsedEnvironment.data.REA_ELECTRON_FILE_ROOTS_JSON,
);
if (!electronFileRoots.ok) return electronFileRoots;
const javascriptReplayRoots = parseStringArray(
parsedEnvironment.data.REA_JAVASCRIPT_REPLAY_ROOTS_JSON,
"REA_JAVASCRIPT_REPLAY_ROOTS_JSON",
);
if (!javascriptReplayRoots.ok) return javascriptReplayRoots;
if (javascriptReplayRoots.value.some((root) => !isAbsolute(root)))
return err(
new ConfigurationError(
"REA_JAVASCRIPT_REPLAY_ROOTS_JSON must encode absolute roots",
),
);
const managedRuntimeRoots = parseStringArray(
parsedEnvironment.data.REA_MANAGED_RUNTIME_ROOTS_JSON,
"REA_MANAGED_RUNTIME_ROOTS_JSON",
);
if (!managedRuntimeRoots.ok) return managedRuntimeRoots;
if (managedRuntimeRoots.value.some((root) => !isAbsolute(root)))
return err(
new ConfigurationError(
"REA_MANAGED_RUNTIME_ROOTS_JSON must encode absolute roots",
),
);
const permissionCeilings = [
...(parsedEnvironment.data.REA_PROCESS_CAPTURE_ENABLED === "true"
? [
permissionScope("process_capture", workingRoots.value, {
executables: executableRoots.value,
environment_names: allowedEnvironment.value,
network:
parsedEnvironment.data.REA_PROCESS_ALLOW_EXTERNAL_NETWORK ===
"true"
? "external"
: "none",
}),
]
: []),
permissionScope("evidence_read", evidenceRoots.value),
permissionScope("evidence_write", evidenceRoots.value),
permissionScope("investigation_input", investigationInputRoots.value),
permissionScope("investigation_workspace_read", evidenceRoots.value),
permissionScope("investigation_workspace_write", evidenceRoots.value),
permissionScope("snapshot_read", analysisSnapshotRoots.value),
permissionScope("snapshot_write", analysisSnapshotRoots.value),
permissionScope("artifact_extract", ["/"]),
permissionScope("reference_read", referenceRoots.value),
...(parsedEnvironment.data.REA_BROWSER_OBSERVE_ENABLED === "true"
? [
permissionScope("browser_observe", [], {
origins: [...browserEndpoints.value, ...browserOrigins.value],
network: browserNetworkScope(browserOrigins.value),
}),
]
: []),
...(parsedEnvironment.data.REA_ELECTRON_OBSERVE_ENABLED === "true"
? [
permissionScope("electron_observe", electronFileRoots.value, {
origins: electronEndpoints.value,
network: "loopback",
}),
]
: []),
...(parsedEnvironment.data.REA_ARTIFACT_NATIVE_MOUNT_ENABLED === "true"
? [permissionScope("native_mount", [], { mount: true })]
: []),
...(parsedEnvironment.data.REA_JAVASCRIPT_REPLAY_ENABLED === "true"
? [
permissionScope("javascript_replay", javascriptReplayRoots.value, {
executables: [
parsedEnvironment.data.REA_JAVASCRIPT_REPLAY_NODE_PATH,
parsedEnvironment.data.REA_JAVASCRIPT_REPLAY_BWRAP_PATH,
parsedEnvironment.data.REA_JAVASCRIPT_REPLAY_SYSTEMD_RUN_PATH,
parsedEnvironment.data.REA_JAVASCRIPT_REPLAY_SYSTEMCTL_PATH,
parsedEnvironment.data.REA_JAVASCRIPT_REPLAY_SHELL_PATH,
],
network: "none",
mount: true,
}),
]
: []),
...(parsedEnvironment.data.REA_MANAGED_RUNTIME_ENABLED === "true"
? [
permissionScope("managed_runtime", managedRuntimeRoots.value, {
executables: [
parsedEnvironment.data.REA_MANAGED_RUNTIME_EXECUTABLE_PATH,
],
network: "none",
}),
]
: []),
] satisfies readonly PermissionCeiling[];
return ok({
analysisProvider: parsedEnvironment.data.REA_ANALYSIS_PROVIDER,
ghidraInstallDir: parsedEnvironment.data.GHIDRA_INSTALL_DIR,
ghidraJavaHome: parsedEnvironment.data.JAVA_HOME,
ilspyCmdPath: parsedEnvironment.data.REA_ILSPY_CMD_PATH,
hopperLauncherPath:
parsedEnvironment.data.HOPPER_LAUNCHER_PATH ??
defaultHopperLauncherPath(),
hopperTargetPath: parsedEnvironment.data.HOPPER_TARGET_PATH,
hopperTargetKind: parsedEnvironment.data.HOPPER_TARGET_KIND,
hopperLoaderArgs: loaderArgs.value,
logLevel: parsedEnvironment.data.REA_LOG_LEVEL,
processExecutionPolicy: {
enabled: parsedEnvironment.data.REA_PROCESS_CAPTURE_ENABLED === "true",
executableRoots: executableRoots.value,
workingRoots: workingRoots.value,
allowedEnvironment: allowedEnvironment.value,
allowExternalNetwork:
parsedEnvironment.data.REA_PROCESS_ALLOW_EXTERNAL_NETWORK === "true",
},
artifactNativeMountEnabled:
parsedEnvironment.data.REA_ARTIFACT_NATIVE_MOUNT_ENABLED === "true",
artifactIntegrityContinueEnabled:
parsedEnvironment.data.REA_ARTIFACT_INTEGRITY_CONTINUE_ENABLED === "true",
evidenceFilePolicy: filePolicy(evidenceRoots.value),
investigationInputRoots: investigationInputRoots.value,
analysisSnapshotFilePolicy: filePolicy(analysisSnapshotRoots.value),
referenceSourcePolicy: {
roots: referenceRoots.value,
secretPatterns: secretPatterns.value,
maxBytes: 16 * 1024 * 1024,
maxEntries: 10_000,
maxDepth: 32,
maxPathBytes: 4_096,
},
browserObservationEnabled:
parsedEnvironment.data.REA_BROWSER_OBSERVE_ENABLED === "true",
browserCdpEndpoints: browserEndpoints.value,
browserAllowedOrigins: browserOrigins.value,
electronObservationEnabled:
parsedEnvironment.data.REA_ELECTRON_OBSERVE_ENABLED === "true",
electronCdpEndpoints: electronEndpoints.value,
electronFileRoots: electronFileRoots.value,
javascriptReplayPolicy: {
enabled: parsedEnvironment.data.REA_JAVASCRIPT_REPLAY_ENABLED === "true",
roots: javascriptReplayRoots.value,
nodePath: parsedEnvironment.data.REA_JAVASCRIPT_REPLAY_NODE_PATH,
bubblewrapPath: parsedEnvironment.data.REA_JAVASCRIPT_REPLAY_BWRAP_PATH,
systemdRunPath:
parsedEnvironment.data.REA_JAVASCRIPT_REPLAY_SYSTEMD_RUN_PATH,
systemctlPath:
parsedEnvironment.data.REA_JAVASCRIPT_REPLAY_SYSTEMCTL_PATH,
shellPath: parsedEnvironment.data.REA_JAVASCRIPT_REPLAY_SHELL_PATH,
},
managedRuntimePolicy: {
enabled: parsedEnvironment.data.REA_MANAGED_RUNTIME_ENABLED === "true",
roots: managedRuntimeRoots.value,
executablePath:
parsedEnvironment.data.REA_MANAGED_RUNTIME_EXECUTABLE_PATH,
},
permissionCeilings,
administratorPermissionGrants: administratorGrants(
parsedEnvironment.data.REA_PROCESS_CAPTURE_AUTO_GRANT === "true"
? permissionCeilings
: permissionCeilings.filter(
({ capability }) => capability !== "process_capture",
),
),
permissionProjectRoot: parsedEnvironment.data.REA_PERMISSION_PROJECT_ROOT,
permissionProjectStore: parsedEnvironment.data.REA_PERMISSION_PROJECT_STORE,
});
};
const browserNetworkScope = (
origins: readonly string[],
): "loopback" | "external" =>
origins.every((origin) => {
const hostname = new URL(origin).hostname;
return isLiteralLoopbackHostname(hostname);
})
? "loopback"
: "external";
export type { AppConfig } from "./config/types.js";
export { parseConfig } from "./config/parseConfig.js";
+130
View File
@@ -0,0 +1,130 @@
import { z } from "zod";
import { isAbsolute } from "node:path";
import { ConfigurationError } from "../domain/errors.js";
import { err, ok, type Result } from "../domain/result.js";
import { analysisProviderSelectorSchema } from "../contracts/providerSelection.js";
export const environmentSchema = z
.object({
REA_ANALYSIS_PROVIDER: analysisProviderSelectorSchema.default("auto"),
GHIDRA_INSTALL_DIR: z
.string()
.min(1)
.refine(isAbsolute, "GHIDRA_INSTALL_DIR must be absolute")
.optional(),
JAVA_HOME: z
.string()
.min(1)
.refine(isAbsolute, "JAVA_HOME must be absolute")
.optional(),
REA_ILSPY_CMD_PATH: z
.string()
.min(1)
.refine(isAbsolute, "REA_ILSPY_CMD_PATH must be absolute")
.optional(),
HOPPER_LAUNCHER_PATH: z.string().min(1).optional(),
HOPPER_TARGET_PATH: z.string().min(1).optional(),
HOPPER_TARGET_KIND: z
.enum(["executable", "database"])
.default("executable"),
HOPPER_LOADER_ARGS_JSON: z.string().optional(),
REA_LOG_LEVEL: z
.enum(["trace", "debug", "info", "warn", "error", "fatal", "silent"])
.default("info"),
REA_PROCESS_CAPTURE_ENABLED: z.enum(["true", "false"]).default("false"),
REA_PROCESS_CAPTURE_AUTO_GRANT: z.enum(["true", "false"]).default("true"),
REA_ARTIFACT_NATIVE_MOUNT_ENABLED: z
.enum(["true", "false"])
.default("false"),
REA_ARTIFACT_INTEGRITY_CONTINUE_ENABLED: z
.enum(["true", "false"])
.default("false"),
REA_PROCESS_ALLOW_EXTERNAL_NETWORK: z
.enum(["true", "false"])
.default("false"),
REA_PROCESS_EXECUTABLE_ROOTS_JSON: z.string().default("[]"),
REA_PROCESS_WORKING_ROOTS_JSON: z.string().default("[]"),
REA_PROCESS_ALLOWED_ENV_JSON: z.string().default("[]"),
REA_EVIDENCE_ROOTS_JSON: z.string().default("[]"),
REA_INVESTIGATION_INPUT_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("[]"),
REA_BROWSER_OBSERVE_ENABLED: z.enum(["true", "false"]).default("false"),
REA_BROWSER_CDP_ENDPOINTS_JSON: z.string().default("[]"),
REA_BROWSER_ALLOWED_ORIGINS_JSON: z.string().default("[]"),
REA_ELECTRON_OBSERVE_ENABLED: z.enum(["true", "false"]).default("false"),
REA_ELECTRON_CDP_ENDPOINTS_JSON: z.string().default("[]"),
REA_ELECTRON_FILE_ROOTS_JSON: z.string().default("[]"),
REA_JAVASCRIPT_REPLAY_ENABLED: z.enum(["true", "false"]).default("false"),
REA_JAVASCRIPT_REPLAY_ROOTS_JSON: z.string().default("[]"),
REA_JAVASCRIPT_REPLAY_NODE_PATH: z
.string()
.min(1)
.refine(isAbsolute, "REA_JAVASCRIPT_REPLAY_NODE_PATH must be absolute")
.default(process.execPath),
REA_JAVASCRIPT_REPLAY_BWRAP_PATH: z
.string()
.min(1)
.refine(isAbsolute, "REA_JAVASCRIPT_REPLAY_BWRAP_PATH must be absolute")
.default("/usr/bin/bwrap"),
REA_JAVASCRIPT_REPLAY_SYSTEMD_RUN_PATH: z
.string()
.min(1)
.refine(
isAbsolute,
"REA_JAVASCRIPT_REPLAY_SYSTEMD_RUN_PATH must be absolute",
)
.default("/usr/bin/systemd-run"),
REA_JAVASCRIPT_REPLAY_SYSTEMCTL_PATH: z
.string()
.min(1)
.refine(
isAbsolute,
"REA_JAVASCRIPT_REPLAY_SYSTEMCTL_PATH must be absolute",
)
.default("/usr/bin/systemctl"),
REA_JAVASCRIPT_REPLAY_SHELL_PATH: z
.string()
.min(1)
.refine(isAbsolute, "REA_JAVASCRIPT_REPLAY_SHELL_PATH must be absolute")
.default("/usr/bin/bash"),
REA_MANAGED_RUNTIME_ENABLED: z.enum(["true", "false"]).default("false"),
REA_MANAGED_RUNTIME_ROOTS_JSON: z.string().default("[]"),
REA_MANAGED_RUNTIME_EXECUTABLE_PATH: z
.string()
.min(1)
.refine(
isAbsolute,
"REA_MANAGED_RUNTIME_EXECUTABLE_PATH must be absolute",
)
.default("/usr/bin/dotnet"),
REA_PERMISSION_PROJECT_ROOT: z.string().min(1).optional(),
REA_PERMISSION_PROJECT_STORE: z.string().min(1).optional(),
})
.refine(
(value) =>
(value.REA_PERMISSION_PROJECT_ROOT === undefined) ===
(value.REA_PERMISSION_PROJECT_STORE === undefined),
{
message:
"REA_PERMISSION_PROJECT_ROOT and REA_PERMISSION_PROJECT_STORE must be configured together",
},
);
export type Environment = z.infer<typeof environmentSchema>;
export const parseEnvironment = (
environment: Readonly<Record<string, string | undefined>>,
): Result<Environment, ConfigurationError> => {
const parsed = environmentSchema.safeParse(environment);
if (!parsed.success) {
return err(
new ConfigurationError("Invalid REA environment configuration", {
cause: parsed.error,
}),
);
}
return ok(parsed.data);
};
+358
View File
@@ -0,0 +1,358 @@
import { isAbsolute } from "node:path";
import { ConfigurationError } from "../domain/errors.js";
import { err, ok, type Result } from "../domain/result.js";
import { parseEnvironment, type Environment } from "./environment.js";
import {
parseStringArray,
parseBrowserArray,
parseElectronFileRoots,
parseLoaderArgs,
} from "./parsers.js";
import {
filePolicy,
permissionScope,
administratorGrants,
browserNetworkScope,
} from "./permissions.js";
import type { AppConfig } from "./types.js";
import type { PermissionCeiling } from "../domain/permissionPolicy.js";
import {
browserEndpointSchema,
browserOriginSchema,
} from "../domain/browserObservation.js";
const DEFAULT_HOPPER_LAUNCHER_PATH =
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper";
const defaultHopperLauncherPath = (): string =>
process.platform === "linux"
? "/opt/hopper/bin/Hopper"
: DEFAULT_HOPPER_LAUNCHER_PATH;
interface ParsedArrays {
readonly loaderArgs: readonly string[];
readonly executableRoots: readonly string[];
readonly workingRoots: readonly string[];
readonly allowedEnvironment: readonly string[];
readonly evidenceRoots: readonly string[];
readonly investigationInputRoots: readonly string[];
readonly analysisSnapshotRoots: readonly string[];
readonly referenceRoots: readonly string[];
readonly secretPatterns: readonly string[];
readonly browserEndpoints: readonly string[];
readonly browserOrigins: readonly string[];
readonly electronEndpoints: readonly string[];
readonly electronFileRoots: readonly string[];
readonly javascriptReplayRoots: readonly string[];
readonly managedRuntimeRoots: readonly string[];
}
const parseAllArrays = (
env: Environment,
): Result<ParsedArrays, ConfigurationError> => {
const loaderArgs = parseLoaderArgs(env.HOPPER_LOADER_ARGS_JSON);
if (!loaderArgs.ok) return loaderArgs;
const executableRoots = parseStringArray(
env.REA_PROCESS_EXECUTABLE_ROOTS_JSON,
"REA_PROCESS_EXECUTABLE_ROOTS_JSON",
);
if (!executableRoots.ok) return executableRoots;
const workingRoots = parseStringArray(
env.REA_PROCESS_WORKING_ROOTS_JSON,
"REA_PROCESS_WORKING_ROOTS_JSON",
);
if (!workingRoots.ok) return workingRoots;
const allowedEnvironment = parseStringArray(
env.REA_PROCESS_ALLOWED_ENV_JSON,
"REA_PROCESS_ALLOWED_ENV_JSON",
);
if (!allowedEnvironment.ok) return allowedEnvironment;
const evidenceRoots = parseStringArray(
env.REA_EVIDENCE_ROOTS_JSON,
"REA_EVIDENCE_ROOTS_JSON",
);
if (!evidenceRoots.ok) return evidenceRoots;
const investigationInputRoots = parseStringArray(
env.REA_INVESTIGATION_INPUT_ROOTS_JSON,
"REA_INVESTIGATION_INPUT_ROOTS_JSON",
);
if (!investigationInputRoots.ok) return investigationInputRoots;
const analysisSnapshotRoots = parseStringArray(
env.REA_ANALYSIS_SNAPSHOT_ROOTS_JSON,
"REA_ANALYSIS_SNAPSHOT_ROOTS_JSON",
);
if (!analysisSnapshotRoots.ok) return analysisSnapshotRoots;
const referenceRoots = parseStringArray(
env.REA_REFERENCE_ROOTS_JSON,
"REA_REFERENCE_ROOTS_JSON",
);
if (!referenceRoots.ok) return referenceRoots;
const secretPatterns = parseStringArray(
env.REA_REFERENCE_SECRET_PATTERNS_JSON,
"REA_REFERENCE_SECRET_PATTERNS_JSON",
);
if (!secretPatterns.ok) return secretPatterns;
const browserEndpoints = parseBrowserArray(
env.REA_BROWSER_CDP_ENDPOINTS_JSON,
"REA_BROWSER_CDP_ENDPOINTS_JSON",
browserEndpointSchema,
16,
);
if (!browserEndpoints.ok) return browserEndpoints;
const browserOrigins = parseBrowserArray(
env.REA_BROWSER_ALLOWED_ORIGINS_JSON,
"REA_BROWSER_ALLOWED_ORIGINS_JSON",
browserOriginSchema,
32,
);
if (!browserOrigins.ok) return browserOrigins;
const electronEndpoints = parseBrowserArray(
env.REA_ELECTRON_CDP_ENDPOINTS_JSON,
"REA_ELECTRON_CDP_ENDPOINTS_JSON",
browserEndpointSchema,
16,
);
if (!electronEndpoints.ok) return electronEndpoints;
const electronFileRoots = parseElectronFileRoots(
env.REA_ELECTRON_FILE_ROOTS_JSON,
);
if (!electronFileRoots.ok) return electronFileRoots;
const javascriptReplayRoots = parseAbsoluteRoots(
env.REA_JAVASCRIPT_REPLAY_ROOTS_JSON,
"REA_JAVASCRIPT_REPLAY_ROOTS_JSON",
);
if (!javascriptReplayRoots.ok) return javascriptReplayRoots;
const managedRuntimeRoots = parseAbsoluteRoots(
env.REA_MANAGED_RUNTIME_ROOTS_JSON,
"REA_MANAGED_RUNTIME_ROOTS_JSON",
);
if (!managedRuntimeRoots.ok) return managedRuntimeRoots;
return ok({
loaderArgs: loaderArgs.value,
executableRoots: executableRoots.value,
workingRoots: workingRoots.value,
allowedEnvironment: allowedEnvironment.value,
evidenceRoots: evidenceRoots.value,
investigationInputRoots: investigationInputRoots.value,
analysisSnapshotRoots: analysisSnapshotRoots.value,
referenceRoots: referenceRoots.value,
secretPatterns: secretPatterns.value,
browserEndpoints: browserEndpoints.value,
browserOrigins: browserOrigins.value,
electronEndpoints: electronEndpoints.value,
electronFileRoots: electronFileRoots.value,
javascriptReplayRoots: javascriptReplayRoots.value,
managedRuntimeRoots: managedRuntimeRoots.value,
});
};
const parseAbsoluteRoots = (
value: string,
name: string,
): Result<readonly string[], ConfigurationError> => {
const parsed = parseStringArray(value, name);
if (!parsed.ok) return parsed;
return parsed.value.some((root) => !isAbsolute(root))
? err(new ConfigurationError(`${name} must encode absolute roots`))
: parsed;
};
const appendProcessCaptureCeiling = (
ceilings: PermissionCeiling[],
env: Environment,
arrays: ParsedArrays,
): void => {
if (env.REA_PROCESS_CAPTURE_ENABLED !== "true") return;
ceilings.push(
permissionScope("process_capture", arrays.workingRoots, {
executables: arrays.executableRoots,
environment_names: arrays.allowedEnvironment,
network:
env.REA_PROCESS_ALLOW_EXTERNAL_NETWORK === "true" ? "external" : "none",
}),
);
};
const appendBrowserObservationCeiling = (
ceilings: PermissionCeiling[],
env: Environment,
arrays: ParsedArrays,
): void => {
if (env.REA_BROWSER_OBSERVE_ENABLED !== "true") return;
ceilings.push(
permissionScope("browser_observe", [], {
origins: [...arrays.browserEndpoints, ...arrays.browserOrigins],
network: browserNetworkScope(arrays.browserOrigins),
}),
);
};
const appendElectronObservationCeiling = (
ceilings: PermissionCeiling[],
env: Environment,
arrays: ParsedArrays,
): void => {
if (env.REA_ELECTRON_OBSERVE_ENABLED !== "true") return;
ceilings.push(
permissionScope("electron_observe", arrays.electronFileRoots, {
origins: arrays.electronEndpoints,
network: "loopback",
}),
);
};
const appendNativeMountCeiling = (
ceilings: PermissionCeiling[],
env: Environment,
): void => {
if (env.REA_ARTIFACT_NATIVE_MOUNT_ENABLED === "true")
ceilings.push(permissionScope("native_mount", [], { mount: true }));
};
const appendJavaScriptReplayCeiling = (
ceilings: PermissionCeiling[],
env: Environment,
arrays: ParsedArrays,
): void => {
if (env.REA_JAVASCRIPT_REPLAY_ENABLED !== "true") return;
ceilings.push(
permissionScope("javascript_replay", arrays.javascriptReplayRoots, {
executables: [
env.REA_JAVASCRIPT_REPLAY_NODE_PATH,
env.REA_JAVASCRIPT_REPLAY_BWRAP_PATH,
env.REA_JAVASCRIPT_REPLAY_SYSTEMD_RUN_PATH,
env.REA_JAVASCRIPT_REPLAY_SYSTEMCTL_PATH,
env.REA_JAVASCRIPT_REPLAY_SHELL_PATH,
],
network: "none",
mount: true,
}),
);
};
const appendManagedRuntimeCeiling = (
ceilings: PermissionCeiling[],
env: Environment,
arrays: ParsedArrays,
): void => {
if (env.REA_MANAGED_RUNTIME_ENABLED !== "true") return;
ceilings.push(
permissionScope("managed_runtime", arrays.managedRuntimeRoots, {
executables: [env.REA_MANAGED_RUNTIME_EXECUTABLE_PATH],
network: "none",
}),
);
};
const buildPermissionCeilings = (
env: Environment,
arrays: ParsedArrays,
): readonly PermissionCeiling[] => {
const ceilings: PermissionCeiling[] = [
permissionScope("evidence_read", arrays.evidenceRoots),
permissionScope("evidence_write", arrays.evidenceRoots),
permissionScope("investigation_input", arrays.investigationInputRoots),
permissionScope("investigation_workspace_read", arrays.evidenceRoots),
permissionScope("investigation_workspace_write", arrays.evidenceRoots),
permissionScope("snapshot_read", arrays.analysisSnapshotRoots),
permissionScope("snapshot_write", arrays.analysisSnapshotRoots),
permissionScope("artifact_extract", ["/"]),
permissionScope("reference_read", arrays.referenceRoots),
];
appendProcessCaptureCeiling(ceilings, env, arrays);
appendBrowserObservationCeiling(ceilings, env, arrays);
appendElectronObservationCeiling(ceilings, env, arrays);
appendNativeMountCeiling(ceilings, env);
appendJavaScriptReplayCeiling(ceilings, env, arrays);
appendManagedRuntimeCeiling(ceilings, env, arrays);
return ceilings;
};
const buildAppConfig = (
env: Environment,
arrays: ParsedArrays,
permissionCeilings: readonly PermissionCeiling[],
): AppConfig => ({
analysisProvider: env.REA_ANALYSIS_PROVIDER,
ghidraInstallDir: env.GHIDRA_INSTALL_DIR,
ghidraJavaHome: env.JAVA_HOME,
ilspyCmdPath: env.REA_ILSPY_CMD_PATH,
hopperLauncherPath: env.HOPPER_LAUNCHER_PATH ?? defaultHopperLauncherPath(),
hopperTargetPath: env.HOPPER_TARGET_PATH,
hopperTargetKind: env.HOPPER_TARGET_KIND,
hopperLoaderArgs: arrays.loaderArgs,
logLevel: env.REA_LOG_LEVEL,
processExecutionPolicy: {
enabled: env.REA_PROCESS_CAPTURE_ENABLED === "true",
executableRoots: arrays.executableRoots,
workingRoots: arrays.workingRoots,
allowedEnvironment: arrays.allowedEnvironment,
allowExternalNetwork: env.REA_PROCESS_ALLOW_EXTERNAL_NETWORK === "true",
},
artifactNativeMountEnabled: env.REA_ARTIFACT_NATIVE_MOUNT_ENABLED === "true",
artifactIntegrityContinueEnabled:
env.REA_ARTIFACT_INTEGRITY_CONTINUE_ENABLED === "true",
evidenceFilePolicy: filePolicy(arrays.evidenceRoots),
investigationInputRoots: arrays.investigationInputRoots,
analysisSnapshotFilePolicy: filePolicy(arrays.analysisSnapshotRoots),
referenceSourcePolicy: {
roots: arrays.referenceRoots,
secretPatterns: arrays.secretPatterns,
maxBytes: 16 * 1024 * 1024,
maxEntries: 10_000,
maxDepth: 32,
maxPathBytes: 4_096,
},
browserObservationEnabled: env.REA_BROWSER_OBSERVE_ENABLED === "true",
browserCdpEndpoints: arrays.browserEndpoints,
browserAllowedOrigins: arrays.browserOrigins,
electronObservationEnabled: env.REA_ELECTRON_OBSERVE_ENABLED === "true",
electronCdpEndpoints: arrays.electronEndpoints,
electronFileRoots: arrays.electronFileRoots,
javascriptReplayPolicy: {
enabled: env.REA_JAVASCRIPT_REPLAY_ENABLED === "true",
roots: arrays.javascriptReplayRoots,
nodePath: env.REA_JAVASCRIPT_REPLAY_NODE_PATH,
bubblewrapPath: env.REA_JAVASCRIPT_REPLAY_BWRAP_PATH,
systemdRunPath: env.REA_JAVASCRIPT_REPLAY_SYSTEMD_RUN_PATH,
systemctlPath: env.REA_JAVASCRIPT_REPLAY_SYSTEMCTL_PATH,
shellPath: env.REA_JAVASCRIPT_REPLAY_SHELL_PATH,
},
managedRuntimePolicy: {
enabled: env.REA_MANAGED_RUNTIME_ENABLED === "true",
roots: arrays.managedRuntimeRoots,
executablePath: env.REA_MANAGED_RUNTIME_EXECUTABLE_PATH,
},
permissionCeilings,
administratorPermissionGrants: administratorGrants(
env.REA_PROCESS_CAPTURE_AUTO_GRANT === "true"
? permissionCeilings
: permissionCeilings.filter(
({ capability }) => capability !== "process_capture",
),
),
permissionProjectRoot: env.REA_PERMISSION_PROJECT_ROOT,
permissionProjectStore: env.REA_PERMISSION_PROJECT_STORE,
});
/**
* Parse provider and authority configuration once at the composition root.
* Explicit loader arguments override REA's header-derived defaults for a
* supported target, allowing callers to refine Hopper's loader behavior without
* bypassing REA's path and executable-header checks.
*/
export const parseConfig = (
environment: Readonly<Record<string, string | undefined>>,
): Result<AppConfig, ConfigurationError> => {
const envResult = parseEnvironment(environment);
if (!envResult.ok) return envResult;
const arraysResult = parseAllArrays(envResult.value);
if (!arraysResult.ok) return arraysResult;
const permissionCeilings = buildPermissionCeilings(
envResult.value,
arraysResult.value,
);
return ok(
buildAppConfig(envResult.value, arraysResult.value, permissionCeilings),
);
};
+103
View File
@@ -0,0 +1,103 @@
import { z } from "zod";
import { ConfigurationError } from "../domain/errors.js";
import { err, ok, type Result } from "../domain/result.js";
import {} from "../domain/browserObservation.js";
import { electronFileRootsSchema } from "../domain/electronObservation.js";
export const parseStringArray = (
encoded: string,
name: string,
): Result<readonly string[], ConfigurationError> => {
try {
const parsed = z
.array(z.string().min(1))
.max(128)
.safeParse(JSON.parse(encoded));
return parsed.success
? ok(parsed.data)
: err(
new ConfigurationError(
parsed.error.issues.some(({ code }) => code === "too_big")
? `${name} must encode at most 128 strings`
: `${name} must encode an array of strings`,
{ cause: parsed.error },
),
);
} catch (cause: unknown) {
return err(new ConfigurationError(`${name} must be valid JSON`, { cause }));
}
};
export const parseBrowserArray = (
encoded: string,
name: string,
itemSchema: z.ZodType<string>,
maximum: number,
): Result<readonly string[], ConfigurationError> => {
try {
const parsed = z
.array(itemSchema)
.max(maximum)
.safeParse(JSON.parse(encoded));
return parsed.success
? ok([...new Set(parsed.data)].sort())
: err(
new ConfigurationError(`${name} must encode valid browser scopes`, {
cause: parsed.error,
}),
);
} catch (cause: unknown) {
return err(new ConfigurationError(`${name} must be valid JSON`, { cause }));
}
};
export const parseElectronFileRoots = (
encoded: string,
): Result<readonly string[], ConfigurationError> => {
try {
const decoded: unknown = JSON.parse(encoded);
if (Array.isArray(decoded) && decoded.length === 0) return ok([]);
const parsed = electronFileRootsSchema.safeParse(decoded);
return parsed.success
? ok(parsed.data)
: err(
new ConfigurationError(
"REA_ELECTRON_FILE_ROOTS_JSON must encode absolute roots",
{ cause: parsed.error },
),
);
} catch (cause: unknown) {
return err(
new ConfigurationError(
"REA_ELECTRON_FILE_ROOTS_JSON must be valid JSON",
{ cause },
),
);
}
};
export const parseLoaderArgs = (
encoded: string | undefined,
): Result<readonly string[], ConfigurationError> => {
if (encoded === undefined) return ok([]);
let decoded: unknown;
try {
decoded = JSON.parse(encoded);
} catch (cause: unknown) {
return err(
new ConfigurationError("HOPPER_LOADER_ARGS_JSON must be valid JSON", {
cause,
}),
);
}
const parsed = z.array(z.string()).safeParse(decoded);
return parsed.success
? ok(parsed.data)
: err(
new ConfigurationError(
"HOPPER_LOADER_ARGS_JSON must encode an array of strings",
{ cause: parsed.error },
),
);
};
+49
View File
@@ -0,0 +1,49 @@
import { isLiteralLoopbackHostname } from "../domain/browserObservation.js";
import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js";
import type {
PermissionCeiling,
PermissionGrant,
} from "../domain/permissionPolicy.js";
export const filePolicy = (roots: readonly string[]): EvidenceFilePolicy => ({
roots,
maxBytes: 64 * 1024 * 1024,
maxDepth: 64,
maxStringLength: 1024 * 1024,
maxNodes: 1_000_000,
});
export const permissionScope = (
capability: PermissionCeiling["capability"],
roots: readonly string[],
options: Partial<Omit<PermissionCeiling, "capability" | "roots">> = {},
): PermissionCeiling => ({
capability,
roots,
executables: options.executables ?? [],
environment_names: options.environment_names ?? [],
...(options.origins === undefined ? {} : { origins: options.origins }),
network: options.network ?? "none",
mount: options.mount ?? false,
});
export const administratorGrants = (
ceilings: readonly PermissionCeiling[],
): readonly PermissionGrant[] =>
ceilings.map((ceiling) => ({
...ceiling,
grant_id: `administrator:${ceiling.capability}`,
lifetime: "administrator",
operation_identity: null,
expires_at: null,
}));
export const browserNetworkScope = (
origins: readonly string[],
): "loopback" | "external" =>
origins.every((origin) => {
const hostname = new URL(origin).hostname;
return isLiteralLoopbackHostname(hostname);
})
? "loopback"
: "external";
+52
View File
@@ -0,0 +1,52 @@
import type { LogLevel } from "../logger.js";
import type { ProcessExecutionPolicy } from "../domain/processCapture.js";
import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js";
import type { ReferenceSourcePolicy } from "../domain/referenceSourcePolicy.js";
import type {
PermissionCeiling,
PermissionGrant,
} from "../domain/permissionPolicy.js";
import type { AnalysisProviderSelector } from "../contracts/providerSelection.js";
export interface AppConfig {
readonly analysisProvider: AnalysisProviderSelector;
readonly ghidraInstallDir: string | undefined;
readonly ghidraJavaHome: string | undefined;
readonly ilspyCmdPath: string | undefined;
readonly hopperLauncherPath: string;
readonly hopperTargetPath: string | undefined;
readonly hopperTargetKind: "executable" | "database";
readonly hopperLoaderArgs: readonly string[];
readonly logLevel: LogLevel;
readonly processExecutionPolicy: ProcessExecutionPolicy;
readonly artifactNativeMountEnabled: boolean;
readonly artifactIntegrityContinueEnabled: boolean;
readonly evidenceFilePolicy: EvidenceFilePolicy;
readonly investigationInputRoots: readonly string[];
readonly analysisSnapshotFilePolicy: EvidenceFilePolicy;
readonly referenceSourcePolicy: ReferenceSourcePolicy;
readonly browserObservationEnabled: boolean;
readonly browserCdpEndpoints: readonly string[];
readonly browserAllowedOrigins: readonly string[];
readonly electronObservationEnabled: boolean;
readonly electronCdpEndpoints: readonly string[];
readonly electronFileRoots: readonly string[];
readonly javascriptReplayPolicy: {
readonly enabled: boolean;
readonly roots: readonly string[];
readonly nodePath: string;
readonly bubblewrapPath: string;
readonly systemdRunPath: string;
readonly systemctlPath: string;
readonly shellPath: string;
};
readonly managedRuntimePolicy: {
readonly enabled: boolean;
readonly roots: readonly string[];
readonly executablePath: string;
};
readonly permissionCeilings: readonly PermissionCeiling[];
readonly administratorPermissionGrants: readonly PermissionGrant[];
readonly permissionProjectRoot: string | undefined;
readonly permissionProjectStore: string | undefined;
}
+40 -223
View File
@@ -3,44 +3,22 @@
import { pathToFileURL } from "node:url";
import { realpathSync } from "node:fs";
import {
serveStdio,
type StdioServerHandle,
} from "@modelcontextprotocol/server/stdio";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { parseConfig } from "./config.js";
import { createBinarySession } from "./application/runtime.js";
import { createServer } from "./server/createServer.js";
import { createLogger } from "./logger.js";
import { projectAnalysisError } from "./domain/errors.js";
import { readProjectPermissionStore } from "./application/ProjectPermissionStore.js";
import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js";
import { CdpBrowserProvider } from "./browser/CdpBrowserProvider.js";
import { CdpElectronProvider } from "./browser/CdpElectronProvider.js";
import type { PermissionGrant } from "./domain/permissionPolicy.js";
const MCP_CONNECTION_LOST =
"REA lost its MCP client connection. Restart REA from your MCP client.";
const MCP_CONNECTION_START_FAILED =
"REA could not start its MCP connection. Restart REA from your MCP client; run `rea doctor` if it fails again.";
const MCP_SHUTDOWN_FAILED =
"REA could not close cleanly. End the REA process before starting it again.";
const MCP_PERMISSION_RELOAD_FAILED =
"Reloaded permission policy failed unexpectedly";
const SERVER_START_FAILED =
"REA could not start. Run `rea doctor`, then restart REA from your MCP client.";
/** Process boundaries owned by the MCP executable adapter. */
interface RuntimeDependencies {
readonly env: NodeJS.ProcessEnv;
readonly serve: typeof serveStdio;
readonly writeStderr: (text: string) => void;
readonly setExitCode: (code: number) => void;
readonly registerShutdown: (handler: () => void) => () => void;
readonly registerReload?: (handler: () => void) => () => void;
readonly createServer?: typeof createServer;
readonly readProjectPermissionStore?: typeof readProjectPermissionStore;
}
import type { RuntimeDependencies } from "./main/types.js";
import { SERVER_START_FAILED } from "./main/messages.js";
import { createRuntimeState } from "./main/state.js";
import { openInitialTarget } from "./main/startup.js";
import { startMcpTransport } from "./main/transport.js";
import { registerConfigReload } from "./main/reload.js";
import { createShutdown } from "./main/shutdown.js";
const runtimeDependencies = (): RuntimeDependencies => ({
env: process.env,
@@ -95,199 +73,38 @@ export const run = async (
const session = createBinarySession(config.value, logger);
const browserObservation = new CdpBrowserProvider();
const electronObservation = new CdpElectronProvider();
if (config.value.hopperTargetPath !== undefined) {
const opened = await session.open(config.value.hopperTargetPath, {
targetKind: config.value.hopperTargetKind,
});
if (!opened.ok) {
await session.close();
serverLogger.error(
{ errorTag: opened.error._tag },
"Initial target failed to open",
);
dependencies.writeStderr(
`${projectAnalysisError(opened.error).message}\n`,
);
return 1;
}
}
let currentConfig = config.value;
const runtimeProcessPolicy = {
...config.value.processExecutionPolicy,
executableRoots: [...config.value.processExecutionPolicy.executableRoots],
workingRoots: [...config.value.processExecutionPolicy.workingRoots],
allowedEnvironment: [
...config.value.processExecutionPolicy.allowedEnvironment,
],
};
const runtimeEvidencePolicy = {
...config.value.evidenceFilePolicy,
roots: [...config.value.evidenceFilePolicy.roots],
};
const runtimeSnapshotPolicy = {
...config.value.analysisSnapshotFilePolicy,
roots: [...config.value.analysisSnapshotFilePolicy.roots],
};
const runtimeInvestigationRoots = [...config.value.investigationInputRoots];
const runtimeJavascriptReplayPolicy = {
...config.value.javascriptReplayPolicy,
roots: [...config.value.javascriptReplayPolicy.roots],
};
const runtimeManagedRuntimePolicy = {
...config.value.managedRuntimePolicy,
roots: [...config.value.managedRuntimePolicy.roots],
};
const liveServers = new Set<ReturnType<typeof createServer>>();
let handle: StdioServerHandle;
try {
handle = dependencies.serve(
() => {
const server = (dependencies.createServer ?? createServer)(
session,
session,
{
logger,
processPolicy: runtimeProcessPolicy,
evidenceFilePolicy: runtimeEvidencePolicy,
investigationInputRoots: runtimeInvestigationRoots,
analysisSnapshotFilePolicy: runtimeSnapshotPolicy,
permissionAuthority: permissionAuthority.value,
browserObservation,
electronObservation,
artifactIntegrityContinueEnabled: () =>
currentConfig.artifactIntegrityContinueEnabled,
javascriptReplayPolicy: runtimeJavascriptReplayPolicy,
managedRuntimePolicy: runtimeManagedRuntimePolicy,
availabilityPolicy: () => ({
processCaptureEnabled:
currentConfig.processExecutionPolicy.enabled,
evidenceFileRoots: currentConfig.evidenceFilePolicy.roots.length,
browserObservationEnabled:
currentConfig.browserObservationEnabled &&
currentConfig.browserCdpEndpoints.length > 0 &&
currentConfig.browserAllowedOrigins.length > 0,
electronObservationEnabled:
currentConfig.electronObservationEnabled &&
currentConfig.electronCdpEndpoints.length > 0 &&
currentConfig.electronFileRoots.length > 0,
javascriptReplayEnabled:
currentConfig.javascriptReplayPolicy.enabled,
managedRuntimeEnabled: currentConfig.managedRuntimePolicy.enabled,
}),
},
);
liveServers.add(server);
return server;
},
{
onerror: () => {
serverLogger.error(MCP_CONNECTION_LOST);
dependencies.writeStderr(`${MCP_CONNECTION_LOST}\n`);
},
},
);
} catch {
await session.close();
serverLogger.error(MCP_CONNECTION_START_FAILED);
dependencies.writeStderr(`${MCP_CONNECTION_START_FAILED}\n`);
return 1;
}
let reloadQueue = Promise.resolve();
const unregisterReload =
dependencies.registerReload?.(() => {
const refreshed = parseConfig(dependencies.env);
if (!refreshed.ok) {
serverLogger.error("Reloaded permission policy is invalid");
return;
}
reloadQueue = reloadQueue
.then(async () => {
let projectGrants: readonly PermissionGrant[] = [];
if (
refreshed.value.permissionProjectRoot !== undefined &&
refreshed.value.permissionProjectStore !== undefined
) {
const project = await (
dependencies.readProjectPermissionStore ??
readProjectPermissionStore
)(
refreshed.value.permissionProjectStore,
refreshed.value.permissionProjectRoot,
);
if (!project.ok) {
serverLogger.error("Reloaded project grants could not be read");
return;
}
projectGrants = project.value?.grants ?? [];
}
const reloaded =
await permissionAuthority.value.replaceConfiguredPolicy({
ceilings: refreshed.value.permissionCeilings,
administratorGrants:
refreshed.value.administratorPermissionGrants,
projectGrants,
});
if (!reloaded.ok) {
serverLogger.error(
"Reloaded permission policy could not be applied",
);
return;
}
currentConfig = refreshed.value;
Object.assign(
runtimeProcessPolicy,
refreshed.value.processExecutionPolicy,
);
Object.assign(
runtimeEvidencePolicy,
refreshed.value.evidenceFilePolicy,
);
Object.assign(
runtimeSnapshotPolicy,
refreshed.value.analysisSnapshotFilePolicy,
);
runtimeInvestigationRoots.splice(
0,
runtimeInvestigationRoots.length,
...refreshed.value.investigationInputRoots,
);
Object.assign(
runtimeJavascriptReplayPolicy,
refreshed.value.javascriptReplayPolicy,
);
Object.assign(
runtimeManagedRuntimePolicy,
refreshed.value.managedRuntimePolicy,
);
for (const server of liveServers) server.sendToolListChanged();
})
.catch(() => {
serverLogger.error(MCP_PERMISSION_RELOAD_FAILED);
});
}) ?? (() => undefined);
let shutdownPromise: Promise<void> | undefined;
let unregisterShutdown = (): void => undefined;
const shutdown = (): Promise<void> => {
shutdownPromise ??= (async () => {
unregisterReload();
unregisterShutdown();
await handle.close();
await session.close();
permissionAuthority.value.clearSessionGrants();
})();
return shutdownPromise;
};
const requestShutdown = (): void => {
shutdown().catch(() => {
dependencies.setExitCode(1);
serverLogger.error(MCP_SHUTDOWN_FAILED);
dependencies.writeStderr(`${MCP_SHUTDOWN_FAILED}\n`);
});
};
unregisterShutdown = dependencies.registerShutdown(requestShutdown);
const opened = await openInitialTarget(
session,
config.value,
serverLogger,
dependencies.writeStderr,
);
if (!opened.ok) return opened.exitCode;
const runtimeState = createRuntimeState(config.value);
const transport = await startMcpTransport(dependencies, session, {
logger,
serverLogger,
browserObservation,
electronObservation,
permissionAuthority: permissionAuthority.value,
runtimeState,
});
if (!transport.ok) return 1;
const unregisterReload = registerConfigReload({
dependencies,
permissionAuthority: permissionAuthority.value,
runtimeState,
liveServers: transport.liveServers,
serverLogger,
});
createShutdown({
handle: transport.handle,
session,
permissionAuthority: permissionAuthority.value,
unregisterReload,
dependencies,
serverLogger,
});
return 0;
};
+10
View File
@@ -0,0 +1,10 @@
export const MCP_CONNECTION_LOST =
"REA lost its MCP client connection. Restart REA from your MCP client.";
export const MCP_CONNECTION_START_FAILED =
"REA could not start its MCP connection. Restart REA from your MCP client; run `rea doctor` if it fails again.";
export const MCP_SHUTDOWN_FAILED =
"REA could not close cleanly. End the REA process before starting it again.";
export const MCP_PERMISSION_RELOAD_FAILED =
"Reloaded permission policy failed unexpectedly";
export const SERVER_START_FAILED =
"REA could not start. Run `rea doctor`, then restart REA from your MCP client.";
+100
View File
@@ -0,0 +1,100 @@
import { parseConfig } from "../config.js";
import { readProjectPermissionStore } from "../application/ProjectPermissionStore.js";
import type { PermissionAuthority } from "../application/PermissionAuthority.js";
import type { PermissionGrant } from "../domain/permissionPolicy.js";
import type { Logger } from "../logger.js";
import type { RuntimeDependencies } from "./types.js";
import type { RuntimeState } from "./state.js";
import { MCP_PERMISSION_RELOAD_FAILED } from "./messages.js";
type McpServerInstance = ReturnType<
typeof import("../server/createServer.js").createServer
>;
export const registerConfigReload = (input: {
readonly dependencies: RuntimeDependencies;
readonly permissionAuthority: PermissionAuthority;
readonly runtimeState: RuntimeState;
readonly liveServers: Set<McpServerInstance>;
readonly serverLogger: Logger;
}): (() => void) => {
const {
dependencies,
permissionAuthority,
runtimeState,
liveServers,
serverLogger,
} = input;
let reloadQueue = Promise.resolve();
return (
dependencies.registerReload?.(() => {
const refreshed = parseConfig(dependencies.env);
if (!refreshed.ok) {
serverLogger.error("Reloaded permission policy is invalid");
return;
}
reloadQueue = reloadQueue
.then(async () => {
let projectGrants: readonly PermissionGrant[] = [];
if (
refreshed.value.permissionProjectRoot !== undefined &&
refreshed.value.permissionProjectStore !== undefined
) {
const project = await (
dependencies.readProjectPermissionStore ??
readProjectPermissionStore
)(
refreshed.value.permissionProjectStore,
refreshed.value.permissionProjectRoot,
);
if (!project.ok) {
serverLogger.error("Reloaded project grants could not be read");
return;
}
projectGrants = project.value?.grants ?? [];
}
const reloaded = await permissionAuthority.replaceConfiguredPolicy({
ceilings: refreshed.value.permissionCeilings,
administratorGrants: refreshed.value.administratorPermissionGrants,
projectGrants,
});
if (!reloaded.ok) {
serverLogger.error(
"Reloaded permission policy could not be applied",
);
return;
}
runtimeState.currentConfig = refreshed.value;
Object.assign(
runtimeState.processPolicy,
refreshed.value.processExecutionPolicy,
);
Object.assign(
runtimeState.evidencePolicy,
refreshed.value.evidenceFilePolicy,
);
Object.assign(
runtimeState.snapshotPolicy,
refreshed.value.analysisSnapshotFilePolicy,
);
runtimeState.investigationRoots.splice(
0,
runtimeState.investigationRoots.length,
...refreshed.value.investigationInputRoots,
);
Object.assign(
runtimeState.javascriptReplayPolicy,
refreshed.value.javascriptReplayPolicy,
);
Object.assign(
runtimeState.managedRuntimePolicy,
refreshed.value.managedRuntimePolicy,
);
for (const server of liveServers) server.sendToolListChanged();
})
.catch(() => {
serverLogger.error(MCP_PERMISSION_RELOAD_FAILED);
});
}) ?? (() => undefined)
);
};
+49
View File
@@ -0,0 +1,49 @@
import type { StdioServerHandle } from "@modelcontextprotocol/server/stdio";
import type { BinarySession } from "../application/BinarySession.js";
import type { PermissionAuthority } from "../application/PermissionAuthority.js";
import type { Logger } from "../logger.js";
import type { RuntimeDependencies } from "./types.js";
import { MCP_SHUTDOWN_FAILED } from "./messages.js";
export const createShutdown = (input: {
readonly handle: StdioServerHandle;
readonly session: BinarySession;
readonly permissionAuthority: PermissionAuthority;
readonly unregisterReload: () => void;
readonly dependencies: RuntimeDependencies;
readonly serverLogger: Logger;
}): {
readonly shutdown: () => Promise<void>;
readonly request: () => void;
} => {
const {
handle,
session,
permissionAuthority,
unregisterReload,
dependencies,
serverLogger,
} = input;
let shutdownPromise: Promise<void> | undefined;
let unregisterShutdown = (): void => undefined;
const shutdown = async (): Promise<void> => {
shutdownPromise ??= (async () => {
unregisterReload();
unregisterShutdown();
await handle.close();
await session.close();
permissionAuthority.clearSessionGrants();
})();
return shutdownPromise;
};
const requestShutdown = (): void => {
shutdown().catch(() => {
dependencies.setExitCode(1);
serverLogger.error(MCP_SHUTDOWN_FAILED);
dependencies.writeStderr(`${MCP_SHUTDOWN_FAILED}\n`);
});
};
unregisterShutdown = dependencies.registerShutdown(requestShutdown);
return { shutdown, request: requestShutdown };
};
+26
View File
@@ -0,0 +1,26 @@
import type { BinarySession } from "../application/BinarySession.js";
import type { AppConfig } from "../config.js";
import type { Logger } from "../logger.js";
import { projectAnalysisError } from "../domain/errors.js";
export const openInitialTarget = async (
session: BinarySession,
config: AppConfig,
serverLogger: Logger,
writeStderr: (text: string) => void,
): Promise<
{ readonly ok: true } | { readonly ok: false; readonly exitCode: 1 }
> => {
if (config.hopperTargetPath === undefined) return { ok: true };
const opened = await session.open(config.hopperTargetPath, {
targetKind: config.hopperTargetKind,
});
if (opened.ok) return { ok: true };
await session.close();
serverLogger.error(
{ errorTag: opened.error._tag },
"Initial target failed to open",
);
writeStderr(`${projectAnalysisError(opened.error).message}\n`);
return { ok: false, exitCode: 1 };
};
+46
View File
@@ -0,0 +1,46 @@
import type { AppConfig } from "../config.js";
import type { ProcessExecutionPolicy } from "../domain/processCapture.js";
import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js";
import type { JavaScriptReplayPolicy } from "../application/JavaScriptReplayPlanning.js";
import type { ManagedRuntimePolicy } from "../application/ManagedRuntimeCorrelationService.js";
export interface RuntimeState {
currentConfig: AppConfig;
processPolicy: ProcessExecutionPolicy & {
executableRoots: string[];
workingRoots: string[];
allowedEnvironment: string[];
};
evidencePolicy: EvidenceFilePolicy & { roots: string[] };
snapshotPolicy: EvidenceFilePolicy & { roots: string[] };
investigationRoots: string[];
javascriptReplayPolicy: JavaScriptReplayPolicy & { roots: string[] };
managedRuntimePolicy: ManagedRuntimePolicy & { roots: string[] };
}
export const createRuntimeState = (config: AppConfig): RuntimeState => ({
currentConfig: config,
processPolicy: {
...config.processExecutionPolicy,
executableRoots: [...config.processExecutionPolicy.executableRoots],
workingRoots: [...config.processExecutionPolicy.workingRoots],
allowedEnvironment: [...config.processExecutionPolicy.allowedEnvironment],
},
evidencePolicy: {
...config.evidenceFilePolicy,
roots: [...config.evidenceFilePolicy.roots],
},
snapshotPolicy: {
...config.analysisSnapshotFilePolicy,
roots: [...config.analysisSnapshotFilePolicy.roots],
},
investigationRoots: [...config.investigationInputRoots],
javascriptReplayPolicy: {
...config.javascriptReplayPolicy,
roots: [...config.javascriptReplayPolicy.roots],
},
managedRuntimePolicy: {
...config.managedRuntimePolicy,
roots: [...config.managedRuntimePolicy.roots],
},
});
+113
View File
@@ -0,0 +1,113 @@
import type { StdioServerHandle } from "@modelcontextprotocol/server/stdio";
import type { BinarySession } from "../application/BinarySession.js";
import type { CdpBrowserProvider } from "../browser/CdpBrowserProvider.js";
import type { CdpElectronProvider } from "../browser/CdpElectronProvider.js";
import type { PermissionAuthority } from "../application/PermissionAuthority.js";
import type { Logger } from "../logger.js";
import { createServer } from "../server/createServer.js";
import type { RuntimeDependencies } from "./types.js";
import type { RuntimeState } from "./state.js";
import {
MCP_CONNECTION_LOST,
MCP_CONNECTION_START_FAILED,
} from "./messages.js";
type McpServerInstance = ReturnType<typeof createServer>;
interface ServerContext {
readonly logger: Logger;
readonly serverLogger: Logger;
readonly browserObservation: CdpBrowserProvider;
readonly electronObservation: CdpElectronProvider;
readonly permissionAuthority: PermissionAuthority;
readonly runtimeState: RuntimeState;
}
export const startMcpTransport = async (
dependencies: RuntimeDependencies,
session: BinarySession,
serverContext: ServerContext,
): Promise<
| {
readonly ok: true;
readonly handle: StdioServerHandle;
readonly liveServers: Set<McpServerInstance>;
}
| { readonly ok: false }
> => {
const liveServers = new Set<McpServerInstance>();
const { serverLogger } = serverContext;
let handle: StdioServerHandle;
try {
handle = dependencies.serve(
() => {
const server = (dependencies.createServer ?? createServer)(
session,
session,
{
logger: serverContext.logger,
processPolicy: serverContext.runtimeState.processPolicy,
evidenceFilePolicy: serverContext.runtimeState.evidencePolicy,
investigationInputRoots:
serverContext.runtimeState.investigationRoots,
analysisSnapshotFilePolicy:
serverContext.runtimeState.snapshotPolicy,
permissionAuthority: serverContext.permissionAuthority,
browserObservation: serverContext.browserObservation,
electronObservation: serverContext.electronObservation,
artifactIntegrityContinueEnabled: () =>
serverContext.runtimeState.currentConfig
.artifactIntegrityContinueEnabled,
javascriptReplayPolicy:
serverContext.runtimeState.javascriptReplayPolicy,
managedRuntimePolicy:
serverContext.runtimeState.managedRuntimePolicy,
availabilityPolicy: () => ({
processCaptureEnabled:
serverContext.runtimeState.currentConfig.processExecutionPolicy
.enabled,
evidenceFileRoots:
serverContext.runtimeState.currentConfig.evidenceFilePolicy
.roots.length,
browserObservationEnabled:
serverContext.runtimeState.currentConfig
.browserObservationEnabled &&
serverContext.runtimeState.currentConfig.browserCdpEndpoints
.length > 0 &&
serverContext.runtimeState.currentConfig.browserAllowedOrigins
.length > 0,
electronObservationEnabled:
serverContext.runtimeState.currentConfig
.electronObservationEnabled &&
serverContext.runtimeState.currentConfig.electronCdpEndpoints
.length > 0 &&
serverContext.runtimeState.currentConfig.electronFileRoots
.length > 0,
javascriptReplayEnabled:
serverContext.runtimeState.currentConfig.javascriptReplayPolicy
.enabled,
managedRuntimeEnabled:
serverContext.runtimeState.currentConfig.managedRuntimePolicy
.enabled,
}),
},
);
liveServers.add(server);
return server;
},
{
onerror: () => {
serverLogger.error(MCP_CONNECTION_LOST);
dependencies.writeStderr(`${MCP_CONNECTION_LOST}\n`);
},
},
);
} catch {
await session.close();
serverLogger.error(MCP_CONNECTION_START_FAILED);
dependencies.writeStderr(`${MCP_CONNECTION_START_FAILED}\n`);
return { ok: false };
}
return { ok: true, handle, liveServers };
};
+10
View File
@@ -0,0 +1,10 @@
export interface RuntimeDependencies {
readonly env: NodeJS.ProcessEnv;
readonly serve: typeof import("@modelcontextprotocol/server/stdio").serveStdio;
readonly writeStderr: (text: string) => void;
readonly setExitCode: (code: number) => void;
readonly registerShutdown: (handler: () => void) => () => void;
readonly registerReload?: (handler: () => void) => () => void;
readonly createServer?: typeof import("../server/createServer.js").createServer;
readonly readProjectPermissionStore?: typeof import("../application/ProjectPermissionStore.js").readProjectPermissionStore;
}
+129 -73
View File
@@ -110,6 +110,86 @@ export const createServer = (
permissionAuthority?.clearSessionGrants();
};
session?.onAvailabilityChanged?.(() => server.sendToolListChanged());
registerServerIdentityResource(server, startedAt);
const toolLogger = logger.child({ layer: "server" });
const { activeTarget, recordEvidence, recordEvidenceWithUnknown } =
createSessionRecorders(server, session);
const toolContext = {
server,
analysis,
session,
options,
logger: toolLogger,
permissionAuthority,
activeTarget,
recordEvidence,
recordEvidenceWithUnknown,
};
registerBinaryAnalysisTools(toolContext);
registerObservationTools(toolContext);
registerGuidedPrompts(server, analysis, session);
if (session !== undefined) {
registerEvidenceResources(server, session);
registerSessionTools(server, session, toolLogger, {
...options,
...(permissionAuthority === undefined ? {} : { permissionAuthority }),
startedAt,
processCaptureElicitation: {
stateCodec: processCaptureStateCodec,
supported: (context) => {
const envelope = context.mcpReq.envelope;
const version = envelope?.[PROTOCOL_VERSION_META_KEY];
const capabilities = envelope?.[CLIENT_CAPABILITIES_META_KEY];
return (
typeof version === "string" &&
PROCESS_CAPTURE_ELICITATION_POLICY.protocolVersions.some(
(supported) => supported === version,
) &&
isRecord(capabilities) &&
isRecord(capabilities.elicitation) &&
capabilities.elicitation.form !== undefined
);
},
now: Date.now,
consumedNonces: new Map(),
},
});
}
return server;
};
const createSessionRecorders = (
server: McpServer,
session: BinarySessionPort | undefined,
) => ({
activeTarget:
session === undefined ? undefined : () => session.activeTarget(),
recordEvidence:
session === undefined
? undefined
: (evidence: Parameters<typeof session.recordEvidence>[0]) => {
const recorded = session.recordEvidence(evidence);
if (recorded.ok && recorded.value === "added")
server.sendResourceListChanged();
return recorded;
},
recordEvidenceWithUnknown:
session === undefined
? undefined
: (
evidence: Parameters<typeof session.recordEvidenceWithUnknown>[0],
input: Parameters<typeof session.recordEvidenceWithUnknown>[1],
) => {
const recorded = session.recordEvidenceWithUnknown(evidence, input);
if (recorded.ok) server.sendResourceListChanged();
return recorded;
},
});
const registerServerIdentityResource = (
server: McpServer,
startedAt: string,
): void => {
server.registerResource(
"server-identity",
"rea://server/identity",
@@ -120,6 +200,7 @@ export const createServer = (
},
(uri) => {
const client = server.server.getClientVersion();
const protocolVersion = server.server.getNegotiatedProtocolVersion();
return {
contents: [
{
@@ -129,12 +210,7 @@ export const createServer = (
createServerIdentity({
startedAt,
...(client === undefined ? {} : { client }),
...(server.server.getNegotiatedProtocolVersion() === undefined
? {}
: {
protocolVersion:
server.server.getNegotiatedProtocolVersion(),
}),
...(protocolVersion === undefined ? {} : { protocolVersion }),
}),
null,
2,
@@ -144,68 +220,66 @@ export const createServer = (
};
},
);
const toolLogger = logger.child({ layer: "server" });
const activeTarget =
session === undefined ? undefined : () => session.activeTarget();
const recordEvidence =
};
interface ServerToolContext extends ReturnType<typeof createSessionRecorders> {
readonly server: McpServer;
readonly analysis: AnalysisOperationPort;
readonly session: BinarySessionPort | undefined;
readonly options: CreateServerOptions;
readonly logger: Logger;
readonly permissionAuthority: PermissionAuthority | undefined;
}
const registerBinaryAnalysisTools = ({
server,
analysis,
session,
options,
logger,
permissionAuthority,
activeTarget,
recordEvidence,
recordEvidenceWithUnknown,
}: ServerToolContext): void => {
const recordUnknown =
session === undefined
? undefined
: (evidence: Parameters<typeof session.recordEvidence>[0]) => {
const recorded = session.recordEvidence(evidence);
if (recorded.ok && recorded.value === "added")
server.sendResourceListChanged();
return recorded;
};
const recordEvidenceWithUnknown =
session === undefined
? undefined
: (
evidence: Parameters<typeof session.recordEvidenceWithUnknown>[0],
input: Parameters<typeof session.recordEvidenceWithUnknown>[1],
) => {
const recorded = session.recordEvidenceWithUnknown(evidence, input);
if (recorded.ok) server.sendResourceListChanged();
return recorded;
};
: (input: Parameters<typeof session.recordUnknown>[0]) =>
session.recordUnknown(input);
registerOfficialTools(server, analysis, {
logger: toolLogger,
logger,
activeTarget,
recordEvidence,
recordUnknown:
session === undefined
? undefined
: (input) => session.recordUnknown(input),
recordUnknown,
});
registerEnhancedTools(server, analysis, {
logger: toolLogger,
logger,
activeTarget,
analysisProfile:
session === undefined ? undefined : () => session.analysisProfile(),
recordEvidence,
recordUnknown:
session === undefined
? undefined
: (input) => session.recordUnknown(input),
recordUnknown,
});
registerNativeTools(server, analysis, {
logger: toolLogger,
logger,
activeTarget,
recordEvidence,
});
registerArtifactTools(server, analysis, {
logger: toolLogger,
logger,
activeTarget,
recordEvidence,
...(permissionAuthority === undefined ? {} : { permissionAuthority }),
});
registerManagedTools(server, analysis, {
logger: toolLogger,
logger,
activeTarget,
recordEvidence,
});
if (session !== undefined)
registerManagedWorkflowTools(server, {
logger: toolLogger,
logger,
recordEvidence,
recordEvidenceWithUnknown,
session,
@@ -218,20 +292,31 @@ export const createServer = (
authority: permissionAuthority,
},
});
};
const registerObservationTools = ({
server,
session,
options,
logger,
permissionAuthority,
recordEvidence,
recordEvidenceWithUnknown,
}: ServerToolContext): void => {
registerBrowserTools(server, {
logger: toolLogger,
logger,
browser: options.browserObservation,
permissionAuthority,
recordEvidence,
});
registerElectronTools(server, {
logger: toolLogger,
logger,
electron: options.electronObservation,
permissionAuthority,
recordEvidence,
});
registerApplicationTools(server, {
logger: toolLogger,
logger,
recordEvidence,
recordEvidenceWithUnknown,
evidenceFilePolicy: options.evidenceFilePolicy ?? {
@@ -267,35 +352,6 @@ export const createServer = (
authority: permissionAuthority,
},
});
registerGuidedPrompts(server, analysis, session);
if (session !== undefined) {
registerEvidenceResources(server, session);
registerSessionTools(server, session, toolLogger, {
...options,
...(permissionAuthority === undefined ? {} : { permissionAuthority }),
startedAt,
processCaptureElicitation: {
stateCodec: processCaptureStateCodec,
supported: (context) => {
const envelope = context.mcpReq.envelope;
const version = envelope?.[PROTOCOL_VERSION_META_KEY];
const capabilities = envelope?.[CLIENT_CAPABILITIES_META_KEY];
return (
typeof version === "string" &&
PROCESS_CAPTURE_ELICITATION_POLICY.protocolVersions.some(
(supported) => supported === version,
) &&
isRecord(capabilities) &&
isRecord(capabilities.elicitation) &&
capabilities.elicitation.form !== undefined
);
},
now: Date.now,
consumedNonces: new Map(),
},
});
}
return server;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
+10 -442
View File
@@ -1,454 +1,22 @@
import type { McpServer } from "@modelcontextprotocol/server";
import type { BinarySessionPort } from "../application/BinarySession.js";
import {
compareApplicationVersionsEvidenceValidated,
traceApplicationFeatureEvidenceValidated,
} from "../application/JavaScriptApplicationWorkflowService.js";
import {
runControlledReplayValidated,
type JavaScriptReplayDependencies,
} from "../application/JavaScriptReplayService.js";
import {
executeNodeCharacterization,
prepareNodeCharacterization,
} from "../application/NodeRuntimeCharacterizationService.js";
import {
commitReconstructionCoverage,
queryReconstructionCoverage,
} from "../application/ReconstructionCoverageService.js";
import { readReconstructionCoverageWorkspace } from "../application/ReconstructionCoverageWorkspaceStore.js";
import {
authorizeFileReadWithDeferredWrite,
authorizeRootPermission,
} from "../application/DeferredFileAuthorization.js";
import type { PermissionAuthority } from "../application/PermissionAuthority.js";
import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js";
import { APPLICATION_TOOL_CONTRACTS } from "../contracts/applicationToolContracts.js";
import { compareApplicationVersionsInputSchema } from "../domain/javascriptApplicationVersionComparisonSchemas.js";
import { applicationVersionComparisonResultSchema } from "../domain/javascriptApplicationVersionComparisonSchemas.js";
import { traceApplicationFeatureInputSchema } from "../domain/javascriptFeatureTraceSchemas.js";
import { parseEvidence, type Evidence } from "../domain/evidence.js";
import type { Logger } from "../logger.js";
import {
controlledReplayInputSchema,
controlledReplayOutputSchema,
} from "../domain/javascriptReplay.js";
import {
nodeCharacterizationExecutionInputSchema,
nodeCharacterizationExecutionOutputSchema,
nodeCharacterizationPreparationInputSchema,
nodeCharacterizationPreparationOutputSchema,
} from "../domain/nodeRuntimeCharacterization.js";
import {
reconstructionCoverageCommitInputSchema,
reconstructionCoverageQueryInputSchema,
} from "../application/ReconstructionCoverageService.js";
import { reconstructionClosureResultSchema } from "../domain/reconstructionCoverage.js";
import {
AnalysisCapabilityUnavailableError,
EvidenceIntegrityError,
} from "../domain/errors.js";
import { err } from "../domain/result.js";
import { logToolExecution } from "./toolLogging.js";
import { toCallToolResult } from "./toolResult.js";
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
import { safeParseToolInput } from "./toolInputValidation.js";
import { mcpProgressReporter } from "./mcpProgress.js";
import { registerTraceFeatureTool } from "./registerApplicationTools/traceFeature.js";
import { registerCompareApplicationVersionsTool } from "./registerApplicationTools/compareVersions.js";
import { registerControlledReplayTool } from "./registerApplicationTools/controlledReplay.js";
import { registerCharacterizationTools } from "./registerApplicationTools/characterization.js";
import { registerCoverageTools } from "./registerApplicationTools/coverage.js";
import type { ApplicationToolRegistration } from "./registerApplicationTools/types.js";
interface ApplicationToolRegistration {
readonly logger: Logger;
readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined;
readonly recordEvidenceWithUnknown:
| BinarySessionPort["recordEvidenceWithUnknown"]
| undefined;
readonly replay: JavaScriptReplayDependencies;
readonly evidenceFilePolicy: EvidenceFilePolicy;
readonly permissionAuthority: PermissionAuthority | undefined;
readonly retainCoverageWorkspace:
| BinarySessionPort["retainReconstructionCoverageWorkspace"]
| undefined;
}
export type { ApplicationToolRegistration };
/** Register provider-neutral JavaScript application graph workflows. */
export const registerApplicationTools = (
server: McpServer,
options: ApplicationToolRegistration,
): void => {
const [traceContract, compareContract, replayContract] =
APPLICATION_TOOL_CONTRACTS;
server.registerTool(
traceContract.name,
toolRegistrationOptions(traceContract),
async (input) => {
const parsedInput = safeParseToolInput(
traceApplicationFeatureInputSchema,
input,
traceContract.name,
);
if (!parsedInput.ok) return toCallToolResult(parsedInput, traceContract);
const parsed = parsedInput.value;
const result = await logToolExecution(
options.logger,
traceContract.name,
() => Promise.resolve(traceApplicationFeatureEvidenceValidated(parsed)),
);
if (!result.ok) return toCallToolResult(result, traceContract);
const sources = [parsed.application, ...parsed.native_observations];
const recorded = recordSources(options.recordEvidence, sources);
if (!recorded.ok) return toCallToolResult(recorded, traceContract);
return recordResult(options, traceContract, result.value);
},
);
server.registerTool(
compareContract.name,
toolRegistrationOptions(compareContract),
async (input) => {
const parsedInput = safeParseToolInput(
compareApplicationVersionsInputSchema,
input,
compareContract.name,
);
if (!parsedInput.ok)
return toCallToolResult(parsedInput, compareContract);
const parsed = parsedInput.value;
const result = await logToolExecution(
options.logger,
compareContract.name,
() =>
Promise.resolve(compareApplicationVersionsEvidenceValidated(parsed)),
);
if (!result.ok) return toCallToolResult(result, compareContract);
const sources = [
parsed.left,
parsed.right,
...parsed.left_native_observations,
...parsed.right_native_observations,
];
const recorded = recordSources(options.recordEvidence, sources);
if (!recorded.ok) return toCallToolResult(recorded, compareContract);
const comparison = applicationVersionComparisonResultSchema.parse(
result.value.normalized_result,
);
const unknown = comparison.summary.unknown > 0;
return recordResult(
options,
compareContract,
result.value,
parsed.unknown_registry_approved === true && unknown,
);
},
);
server.registerTool(
replayContract.name,
toolRegistrationOptions(replayContract),
async (input, context) => {
const parsedInput = safeParseToolInput(
controlledReplayInputSchema,
input,
replayContract.name,
);
if (!parsedInput.ok) return toCallToolResult(parsedInput, replayContract);
const parsed = parsedInput.value;
const result = await logToolExecution(
options.logger,
replayContract.name,
() =>
runControlledReplayValidated(options.replay, parsed, {
signal: context.mcpReq.signal,
progress: mcpProgressReporter(context),
}),
);
if (!result.ok) return toCallToolResult(result, replayContract);
const output = controlledReplayOutputSchema.parse(result.value);
if (output.evidence !== null) {
const sourcesRecorded = recordSources(
options.recordEvidence,
output.source_evidence.map((item) => parseEvidence(item)),
);
if (!sourcesRecorded.ok)
return toCallToolResult(sourcesRecorded, replayContract);
const recorded = options.recordEvidence?.(
parseEvidence(output.evidence),
);
if (recorded !== undefined && !recorded.ok)
return toCallToolResult(recorded, replayContract);
}
return toCallToolResult(result, replayContract, {
evidenceResourcesAvailable:
output.evidence !== null && options.recordEvidence !== undefined,
});
},
);
registerTraceFeatureTool(server, options);
registerCompareApplicationVersionsTool(server, options);
registerControlledReplayTool(server, options);
registerCharacterizationTools(server, options);
registerCoverageTools(server, options);
};
const registerCharacterizationTools = (
server: McpServer,
options: ApplicationToolRegistration,
): void => {
const prepareContract = APPLICATION_TOOL_CONTRACTS[3];
const executeContract = APPLICATION_TOOL_CONTRACTS[4];
server.registerTool(
prepareContract.name,
toolRegistrationOptions(prepareContract),
async (input, context) => {
const parsed = safeParseToolInput(
nodeCharacterizationPreparationInputSchema,
input,
prepareContract.name,
);
if (!parsed.ok) return toCallToolResult(parsed, prepareContract);
const result = await logToolExecution(
options.logger,
prepareContract.name,
() =>
prepareNodeCharacterization(options.replay, parsed.value, {
signal: context.mcpReq.signal,
progress: mcpProgressReporter(context),
}),
);
if (!result.ok) return toCallToolResult(result, prepareContract);
const output = nodeCharacterizationPreparationOutputSchema.parse(
result.value,
);
const recorded = options.recordEvidence?.(
parseEvidence(output.transformation_evidence),
);
if (recorded !== undefined && !recorded.ok)
return toCallToolResult(recorded, prepareContract);
return toCallToolResult(result, prepareContract, {
evidenceResourcesAvailable: options.recordEvidence !== undefined,
});
},
);
server.registerTool(
executeContract.name,
toolRegistrationOptions(executeContract),
async (input, context) => {
const parsed = safeParseToolInput(
nodeCharacterizationExecutionInputSchema,
input,
executeContract.name,
);
if (!parsed.ok) return toCallToolResult(parsed, executeContract);
const result = await logToolExecution(
options.logger,
executeContract.name,
() =>
executeNodeCharacterization(options.replay, parsed.value, {
signal: context.mcpReq.signal,
progress: mcpProgressReporter(context),
}),
);
if (!result.ok) return toCallToolResult(result, executeContract);
const output = nodeCharacterizationExecutionOutputSchema.parse(
result.value,
);
const sources = [
output.transformation_evidence,
...output.replay.source_evidence,
...(output.replay.evidence === null ? [] : [output.replay.evidence]),
output.evidence,
].map((item) => parseEvidence(item));
const recorded = recordSources(options.recordEvidence, sources);
if (!recorded.ok) return toCallToolResult(recorded, executeContract);
return toCallToolResult(result, executeContract, {
evidenceResourcesAvailable: options.recordEvidence !== undefined,
});
},
);
};
const registerCoverageTools = (
server: McpServer,
options: ApplicationToolRegistration,
): void => {
const commitContract = APPLICATION_TOOL_CONTRACTS[5];
const queryContract = APPLICATION_TOOL_CONTRACTS[6];
server.registerTool(
commitContract.name,
toolRegistrationOptions(commitContract),
async (input, context) => {
const parsed = safeParseToolInput(
reconstructionCoverageCommitInputSchema,
input,
commitContract.name,
);
if (!parsed.ok) return toCallToolResult(parsed, commitContract);
if (options.permissionAuthority === undefined)
return toCallToolResult(
err(
new AnalysisCapabilityUnavailableError(
"rea-reconstruction-coverage",
commitContract.name,
"workspace permission policy is not configured",
),
),
commitContract,
);
const authorization = await authorizeFileReadWithDeferredWrite(
options.permissionAuthority,
{
path: parsed.value.workspace_path,
readCapability: "investigation_workspace_read",
writeCapability: "investigation_workspace_write",
operation: commitContract.name,
},
);
if (!authorization.ok)
return toCallToolResult(authorization, commitContract);
const write = await authorization.value.authorizeWrite();
if (!write.ok) return toCallToolResult(write, commitContract);
const result = await logToolExecution(
options.logger,
commitContract.name,
() =>
commitReconstructionCoverage(
parsed.value,
options.evidenceFilePolicy,
{ signal: context.mcpReq.signal },
),
);
if (!result.ok) return toCallToolResult(result, commitContract);
options.retainCoverageWorkspace?.(parsed.value.workspace);
return toCallToolResult(result, commitContract, {
resourceLinks:
options.retainCoverageWorkspace === undefined
? []
: [
{
uri: coverageWorkspaceUri(parsed.value.workspace),
name: parsed.value.workspace.workspace_id,
description:
"Session-retained reconstruction coverage workspace revision",
},
],
});
},
);
server.registerTool(
queryContract.name,
toolRegistrationOptions(queryContract),
async (input, context) => {
const parsed = safeParseToolInput(
reconstructionCoverageQueryInputSchema,
input,
queryContract.name,
);
if (!parsed.ok) return toCallToolResult(parsed, queryContract);
if (options.permissionAuthority === undefined)
return toCallToolResult(
err(
new AnalysisCapabilityUnavailableError(
"rea-reconstruction-coverage",
queryContract.name,
"workspace permission policy is not configured",
),
),
queryContract,
);
const authorized = await authorizeRootPermission(
options.permissionAuthority,
{
capability: "investigation_workspace_read",
roots: [parsed.value.workspace_path],
access: "read",
operation: queryContract.name,
},
);
if (!authorized.ok) return toCallToolResult(authorized, queryContract);
const result = await logToolExecution(
options.logger,
queryContract.name,
() =>
queryReconstructionCoverage(
parsed.value,
options.evidenceFilePolicy,
Date.now(),
{ signal: context.mcpReq.signal },
),
);
if (result.ok && options.retainCoverageWorkspace !== undefined) {
const closure = reconstructionClosureResultSchema.parse(result.value);
const loaded = await readReconstructionCoverageWorkspace(
parsed.value.workspace_path,
options.evidenceFilePolicy,
);
if (!loaded.ok) return toCallToolResult(loaded, queryContract);
if (
loaded.value === null ||
loaded.value.revision_sha256 !== closure.workspace_revision_sha256
)
return toCallToolResult(
err(
new EvidenceIntegrityError(
"workspace changed while its closure was being retained",
),
),
queryContract,
);
options.retainCoverageWorkspace(loaded.value);
}
return toCallToolResult(result, queryContract);
},
);
};
const coverageWorkspaceUri = (workspace: {
readonly workspace_id: string;
readonly revision: number;
}): string =>
`rea://reconstruction-coverage/${workspace.workspace_id}/revision/${String(workspace.revision)}`;
const recordSources = (
recordEvidence: ApplicationToolRegistration["recordEvidence"],
sources: readonly Evidence[],
) => {
for (const source of sources) {
const recorded = recordEvidence?.(source);
if (recorded !== undefined && !recorded.ok) return recorded;
}
return { ok: true as const, value: null };
};
const recordResult = (
options: ApplicationToolRegistration,
contract: (typeof APPLICATION_TOOL_CONTRACTS)[number],
evidence: Evidence,
recordUnknown = false,
) => {
const recorded = recordUnknown
? options.recordEvidenceWithUnknown?.(evidence, {
approved: true,
question:
"Which application entities remain unmatched or ambiguous across these versions?",
severity: "medium",
domain: "application-version-comparison",
supporting_evidence_ids: [evidence.evidence_id],
contradicting_evidence_ids: [],
required_authority: "shipped-artifact",
required_confidence: "observed",
required_environment: null,
recommended_probes: [
{
operation: "analyze_javascript_application",
rationale:
"Repeat static reconstruction with complete artifacts and source maps when available.",
},
{
operation: "reconcile_javascript_runtime",
rationale:
"Add approved passive runtime Evidence without promoting it to static fact.",
},
],
relationships: [],
})
: options.recordEvidence?.(evidence);
if (recorded !== undefined && !recorded.ok)
return toCallToolResult(recorded, contract);
return toCallToolResult({ ok: true, value: evidence }, contract, {
evidenceResourcesAvailable: recorded !== undefined,
});
};
@@ -0,0 +1,100 @@
import type { McpServer } from "@modelcontextprotocol/server";
import {
executeNodeCharacterization,
prepareNodeCharacterization,
} from "../../application/NodeRuntimeCharacterizationService.js";
import { APPLICATION_TOOL_CONTRACTS } from "../../contracts/applicationToolContracts.js";
import {
nodeCharacterizationExecutionInputSchema,
nodeCharacterizationExecutionOutputSchema,
nodeCharacterizationPreparationInputSchema,
nodeCharacterizationPreparationOutputSchema,
} from "../../domain/nodeRuntimeCharacterization.js";
import { parseEvidence } from "../../domain/evidence.js";
import { logToolExecution } from "../toolLogging.js";
import { toCallToolResult } from "../toolResult.js";
import { toolRegistrationOptions } from "../toolRegistrationOptions.js";
import { safeParseToolInput } from "../toolInputValidation.js";
import { mcpProgressReporter } from "../mcpProgress.js";
import { recordSources } from "./helpers.js";
import type { ApplicationToolRegistration } from "./types.js";
const prepareContract = APPLICATION_TOOL_CONTRACTS[3];
const executeContract = APPLICATION_TOOL_CONTRACTS[4];
/** Register node runtime characterization prepare and execute tools. */
export const registerCharacterizationTools = (
server: McpServer,
options: ApplicationToolRegistration,
): void => {
server.registerTool(
prepareContract.name,
toolRegistrationOptions(prepareContract),
async (input, context) => {
const parsed = safeParseToolInput(
nodeCharacterizationPreparationInputSchema,
input,
prepareContract.name,
);
if (!parsed.ok) return toCallToolResult(parsed, prepareContract);
const result = await logToolExecution(
options.logger,
prepareContract.name,
() =>
prepareNodeCharacterization(options.replay, parsed.value, {
signal: context.mcpReq.signal,
progress: mcpProgressReporter(context),
}),
);
if (!result.ok) return toCallToolResult(result, prepareContract);
const output = nodeCharacterizationPreparationOutputSchema.parse(
result.value,
);
const recorded = options.recordEvidence?.(
parseEvidence(output.transformation_evidence),
);
if (recorded !== undefined && !recorded.ok)
return toCallToolResult(recorded, prepareContract);
return toCallToolResult(result, prepareContract, {
evidenceResourcesAvailable: options.recordEvidence !== undefined,
});
},
);
server.registerTool(
executeContract.name,
toolRegistrationOptions(executeContract),
async (input, context) => {
const parsed = safeParseToolInput(
nodeCharacterizationExecutionInputSchema,
input,
executeContract.name,
);
if (!parsed.ok) return toCallToolResult(parsed, executeContract);
const result = await logToolExecution(
options.logger,
executeContract.name,
() =>
executeNodeCharacterization(options.replay, parsed.value, {
signal: context.mcpReq.signal,
progress: mcpProgressReporter(context),
}),
);
if (!result.ok) return toCallToolResult(result, executeContract);
const output = nodeCharacterizationExecutionOutputSchema.parse(
result.value,
);
const sources = [
output.transformation_evidence,
...output.replay.source_evidence,
...(output.replay.evidence === null ? [] : [output.replay.evidence]),
output.evidence,
].map((item) => parseEvidence(item));
const recorded = recordSources(options.recordEvidence, sources);
if (!recorded.ok) return toCallToolResult(recorded, executeContract);
return toCallToolResult(result, executeContract, {
evidenceResourcesAvailable: options.recordEvidence !== undefined,
});
},
);
};
@@ -0,0 +1,60 @@
import type { McpServer } from "@modelcontextprotocol/server";
import { compareApplicationVersionsEvidenceValidated } from "../../application/JavaScriptApplicationWorkflowService.js";
import { APPLICATION_TOOL_CONTRACTS } from "../../contracts/applicationToolContracts.js";
import { compareApplicationVersionsInputSchema } from "../../domain/javascriptApplicationVersionComparisonSchemas.js";
import { applicationVersionComparisonResultSchema } from "../../domain/javascriptApplicationVersionComparisonSchemas.js";
import { logToolExecution } from "../toolLogging.js";
import { toCallToolResult } from "../toolResult.js";
import { toolRegistrationOptions } from "../toolRegistrationOptions.js";
import { safeParseToolInput } from "../toolInputValidation.js";
import { recordResult, recordSources } from "./helpers.js";
import type { ApplicationToolRegistration } from "./types.js";
const compareContract = APPLICATION_TOOL_CONTRACTS[1];
/** Register the provider-neutral JavaScript version comparison tool. */
export const registerCompareApplicationVersionsTool = (
server: McpServer,
options: ApplicationToolRegistration,
): void => {
server.registerTool(
compareContract.name,
toolRegistrationOptions(compareContract),
async (input) => {
const parsedInput = safeParseToolInput(
compareApplicationVersionsInputSchema,
input,
compareContract.name,
);
if (!parsedInput.ok)
return toCallToolResult(parsedInput, compareContract);
const parsed = parsedInput.value;
const result = await logToolExecution(
options.logger,
compareContract.name,
() =>
Promise.resolve(compareApplicationVersionsEvidenceValidated(parsed)),
);
if (!result.ok) return toCallToolResult(result, compareContract);
const sources = [
parsed.left,
parsed.right,
...parsed.left_native_observations,
...parsed.right_native_observations,
];
const recorded = recordSources(options.recordEvidence, sources);
if (!recorded.ok) return toCallToolResult(recorded, compareContract);
const comparison = applicationVersionComparisonResultSchema.parse(
result.value.normalized_result,
);
const unknown = comparison.summary.unknown > 0;
return recordResult(
options,
compareContract,
result.value,
parsed.unknown_registry_approved === true && unknown,
);
},
);
};
@@ -0,0 +1,66 @@
import type { McpServer } from "@modelcontextprotocol/server";
import { runControlledReplayValidated } from "../../application/JavaScriptReplayService.js";
import { APPLICATION_TOOL_CONTRACTS } from "../../contracts/applicationToolContracts.js";
import {
controlledReplayInputSchema,
controlledReplayOutputSchema,
} from "../../domain/javascriptReplay.js";
import { parseEvidence } from "../../domain/evidence.js";
import { logToolExecution } from "../toolLogging.js";
import { toCallToolResult } from "../toolResult.js";
import { toolRegistrationOptions } from "../toolRegistrationOptions.js";
import { safeParseToolInput } from "../toolInputValidation.js";
import { mcpProgressReporter } from "../mcpProgress.js";
import { recordSources } from "./helpers.js";
import type { ApplicationToolRegistration } from "./types.js";
const replayContract = APPLICATION_TOOL_CONTRACTS[2];
/** Register the controlled JavaScript replay tool. */
export const registerControlledReplayTool = (
server: McpServer,
options: ApplicationToolRegistration,
): void => {
server.registerTool(
replayContract.name,
toolRegistrationOptions(replayContract),
async (input, context) => {
const parsedInput = safeParseToolInput(
controlledReplayInputSchema,
input,
replayContract.name,
);
if (!parsedInput.ok) return toCallToolResult(parsedInput, replayContract);
const parsed = parsedInput.value;
const result = await logToolExecution(
options.logger,
replayContract.name,
() =>
runControlledReplayValidated(options.replay, parsed, {
signal: context.mcpReq.signal,
progress: mcpProgressReporter(context),
}),
);
if (!result.ok) return toCallToolResult(result, replayContract);
const output = controlledReplayOutputSchema.parse(result.value);
if (output.evidence !== null) {
const sourcesRecorded = recordSources(
options.recordEvidence,
output.source_evidence.map((item) => parseEvidence(item)),
);
if (!sourcesRecorded.ok)
return toCallToolResult(sourcesRecorded, replayContract);
const recorded = options.recordEvidence?.(
parseEvidence(output.evidence),
);
if (recorded !== undefined && !recorded.ok)
return toCallToolResult(recorded, replayContract);
}
return toCallToolResult(result, replayContract, {
evidenceResourcesAvailable:
output.evidence !== null && options.recordEvidence !== undefined,
});
},
);
};
@@ -0,0 +1,179 @@
import type { McpServer } from "@modelcontextprotocol/server";
import {
commitReconstructionCoverage,
queryReconstructionCoverage,
} from "../../application/ReconstructionCoverageService.js";
import { readReconstructionCoverageWorkspace } from "../../application/ReconstructionCoverageWorkspaceStore.js";
import {
authorizeFileReadWithDeferredWrite,
authorizeRootPermission,
} from "../../application/DeferredFileAuthorization.js";
import { APPLICATION_TOOL_CONTRACTS } from "../../contracts/applicationToolContracts.js";
import {
reconstructionCoverageCommitInputSchema,
reconstructionCoverageQueryInputSchema,
} from "../../application/ReconstructionCoverageService.js";
import { reconstructionClosureResultSchema } from "../../domain/reconstructionCoverage.js";
import {
AnalysisCapabilityUnavailableError,
EvidenceIntegrityError,
} from "../../domain/errors.js";
import { err } from "../../domain/result.js";
import { logToolExecution } from "../toolLogging.js";
import { toCallToolResult } from "../toolResult.js";
import { toolRegistrationOptions } from "../toolRegistrationOptions.js";
import { safeParseToolInput } from "../toolInputValidation.js";
import { coverageWorkspaceUri } from "./helpers.js";
import type { ApplicationToolRegistration } from "./types.js";
const commitContract = APPLICATION_TOOL_CONTRACTS[5];
const queryContract = APPLICATION_TOOL_CONTRACTS[6];
/** Register reconstruction coverage commit and query tools. */
export const registerCoverageTools = (
server: McpServer,
options: ApplicationToolRegistration,
): void => {
registerCommitTool(server, options);
registerQueryTool(server, options);
};
const registerCommitTool = (
server: McpServer,
options: ApplicationToolRegistration,
): void => {
server.registerTool(
commitContract.name,
toolRegistrationOptions(commitContract),
async (input, context) => {
const parsed = safeParseToolInput(
reconstructionCoverageCommitInputSchema,
input,
commitContract.name,
);
if (!parsed.ok) return toCallToolResult(parsed, commitContract);
if (options.permissionAuthority === undefined)
return toCallToolResult(
err(
new AnalysisCapabilityUnavailableError(
"rea-reconstruction-coverage",
commitContract.name,
"workspace permission policy is not configured",
),
),
commitContract,
);
const authorization = await authorizeFileReadWithDeferredWrite(
options.permissionAuthority,
{
path: parsed.value.workspace_path,
readCapability: "investigation_workspace_read",
writeCapability: "investigation_workspace_write",
operation: commitContract.name,
},
);
if (!authorization.ok)
return toCallToolResult(authorization, commitContract);
const write = await authorization.value.authorizeWrite();
if (!write.ok) return toCallToolResult(write, commitContract);
const result = await logToolExecution(
options.logger,
commitContract.name,
() =>
commitReconstructionCoverage(
parsed.value,
options.evidenceFilePolicy,
{ signal: context.mcpReq.signal },
),
);
if (!result.ok) return toCallToolResult(result, commitContract);
options.retainCoverageWorkspace?.(parsed.value.workspace);
return toCallToolResult(result, commitContract, {
resourceLinks:
options.retainCoverageWorkspace === undefined
? []
: [
{
uri: coverageWorkspaceUri(parsed.value.workspace),
name: parsed.value.workspace.workspace_id,
description:
"Session-retained reconstruction coverage workspace revision",
},
],
});
},
);
};
const registerQueryTool = (
server: McpServer,
options: ApplicationToolRegistration,
): void => {
server.registerTool(
queryContract.name,
toolRegistrationOptions(queryContract),
async (input, context) => {
const parsed = safeParseToolInput(
reconstructionCoverageQueryInputSchema,
input,
queryContract.name,
);
if (!parsed.ok) return toCallToolResult(parsed, queryContract);
if (options.permissionAuthority === undefined)
return toCallToolResult(
err(
new AnalysisCapabilityUnavailableError(
"rea-reconstruction-coverage",
queryContract.name,
"workspace permission policy is not configured",
),
),
queryContract,
);
const authorized = await authorizeRootPermission(
options.permissionAuthority,
{
capability: "investigation_workspace_read",
roots: [parsed.value.workspace_path],
access: "read",
operation: queryContract.name,
},
);
if (!authorized.ok) return toCallToolResult(authorized, queryContract);
const result = await logToolExecution(
options.logger,
queryContract.name,
() =>
queryReconstructionCoverage(
parsed.value,
options.evidenceFilePolicy,
Date.now(),
{ signal: context.mcpReq.signal },
),
);
if (result.ok && options.retainCoverageWorkspace !== undefined) {
const closure = reconstructionClosureResultSchema.parse(result.value);
const loaded = await readReconstructionCoverageWorkspace(
parsed.value.workspace_path,
options.evidenceFilePolicy,
);
if (!loaded.ok) return toCallToolResult(loaded, queryContract);
if (
loaded.value === null ||
loaded.value.revision_sha256 !== closure.workspace_revision_sha256
)
return toCallToolResult(
err(
new EvidenceIntegrityError(
"workspace changed while its closure was being retained",
),
),
queryContract,
);
options.retainCoverageWorkspace(loaded.value);
}
return toCallToolResult(result, queryContract);
},
);
};
@@ -0,0 +1,61 @@
import { APPLICATION_TOOL_CONTRACTS } from "../../contracts/applicationToolContracts.js";
import type { Evidence } from "../../domain/evidence.js";
import { toCallToolResult } from "../toolResult.js";
import type { ApplicationToolRegistration } from "./types.js";
export const coverageWorkspaceUri = (workspace: {
readonly workspace_id: string;
readonly revision: number;
}): string =>
`rea://reconstruction-coverage/${workspace.workspace_id}/revision/${String(workspace.revision)}`;
export const recordSources = (
recordEvidence: ApplicationToolRegistration["recordEvidence"],
sources: readonly Evidence[],
) => {
for (const source of sources) {
const recorded = recordEvidence?.(source);
if (recorded !== undefined && !recorded.ok) return recorded;
}
return { ok: true as const, value: null };
};
export const recordResult = (
options: ApplicationToolRegistration,
contract: (typeof APPLICATION_TOOL_CONTRACTS)[number],
evidence: Evidence,
recordUnknown = false,
) => {
const recorded = recordUnknown
? options.recordEvidenceWithUnknown?.(evidence, {
approved: true,
question:
"Which application entities remain unmatched or ambiguous across these versions?",
severity: "medium",
domain: "application-version-comparison",
supporting_evidence_ids: [evidence.evidence_id],
contradicting_evidence_ids: [],
required_authority: "shipped-artifact",
required_confidence: "observed",
required_environment: null,
recommended_probes: [
{
operation: "analyze_javascript_application",
rationale:
"Repeat static reconstruction with complete artifacts and source maps when available.",
},
{
operation: "reconcile_javascript_runtime",
rationale:
"Add approved passive runtime Evidence without promoting it to static fact.",
},
],
relationships: [],
})
: options.recordEvidence?.(evidence);
if (recorded !== undefined && !recorded.ok)
return toCallToolResult(recorded, contract);
return toCallToolResult({ ok: true, value: evidence }, contract, {
evidenceResourcesAvailable: recorded !== undefined,
});
};
@@ -0,0 +1,43 @@
import type { McpServer } from "@modelcontextprotocol/server";
import { traceApplicationFeatureEvidenceValidated } from "../../application/JavaScriptApplicationWorkflowService.js";
import { APPLICATION_TOOL_CONTRACTS } from "../../contracts/applicationToolContracts.js";
import { traceApplicationFeatureInputSchema } from "../../domain/javascriptFeatureTraceSchemas.js";
import { logToolExecution } from "../toolLogging.js";
import { toCallToolResult } from "../toolResult.js";
import { toolRegistrationOptions } from "../toolRegistrationOptions.js";
import { safeParseToolInput } from "../toolInputValidation.js";
import { recordResult, recordSources } from "./helpers.js";
import type { ApplicationToolRegistration } from "./types.js";
const traceContract = APPLICATION_TOOL_CONTRACTS[0];
/** Register the provider-neutral JavaScript feature trace tool. */
export const registerTraceFeatureTool = (
server: McpServer,
options: ApplicationToolRegistration,
): void => {
server.registerTool(
traceContract.name,
toolRegistrationOptions(traceContract),
async (input) => {
const parsedInput = safeParseToolInput(
traceApplicationFeatureInputSchema,
input,
traceContract.name,
);
if (!parsedInput.ok) return toCallToolResult(parsedInput, traceContract);
const parsed = parsedInput.value;
const result = await logToolExecution(
options.logger,
traceContract.name,
() => Promise.resolve(traceApplicationFeatureEvidenceValidated(parsed)),
);
if (!result.ok) return toCallToolResult(result, traceContract);
const sources = [parsed.application, ...parsed.native_observations];
const recorded = recordSources(options.recordEvidence, sources);
if (!recorded.ok) return toCallToolResult(recorded, traceContract);
return recordResult(options, traceContract, result.value);
},
);
};
@@ -0,0 +1,20 @@
import type { BinarySessionPort } from "../../application/BinarySession.js";
import type { JavaScriptReplayDependencies } from "../../application/JavaScriptReplayService.js";
import type { PermissionAuthority } from "../../application/PermissionAuthority.js";
import type { EvidenceFilePolicy } from "../../domain/evidenceBundle.js";
import type { Logger } from "../../logger.js";
/** Shared services for registering JavaScript application graph workflows. */
export interface ApplicationToolRegistration {
readonly logger: Logger;
readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined;
readonly recordEvidenceWithUnknown:
| BinarySessionPort["recordEvidenceWithUnknown"]
| undefined;
readonly replay: JavaScriptReplayDependencies;
readonly evidenceFilePolicy: EvidenceFilePolicy;
readonly permissionAuthority: PermissionAuthority | undefined;
readonly retainCoverageWorkspace:
| BinarySessionPort["retainReconstructionCoverageWorkspace"]
| undefined;
}
+114 -200
View File
@@ -1,4 +1,5 @@
import type { McpServer } from "@modelcontextprotocol/server";
import type { z } from "zod";
import type { BrowserObservationPort } from "../application/BrowserObservationPort.js";
import type { BinarySessionPort } from "../application/BinarySession.js";
@@ -32,6 +33,11 @@ import { toCallToolResult } from "./toolResult.js";
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
import { safeParseToolInput } from "./toolInputValidation.js";
import { mcpProgressReporter } from "./mcpProgress.js";
import type { ProgressReporter } from "../application/ProgressReporter.js";
import type { Evidence } from "../domain/evidence.js";
import type { Result } from "../domain/result.js";
import type { AnalysisError } from "../domain/errors.js";
import type { ToolContract } from "../contracts/toolContracts.js";
interface BrowserToolRegistration {
readonly logger: Logger;
@@ -40,6 +46,20 @@ interface BrowserToolRegistration {
readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined;
}
interface BrowserToolContext {
readonly signal: AbortSignal;
readonly progress: ProgressReporter;
}
interface BrowserToolSpec<Schema extends z.ZodType> {
readonly contract: ToolContract;
readonly schema: Schema;
readonly execute: (
parsed: z.output<Schema>,
context: BrowserToolContext,
) => Promise<Result<Evidence, AnalysisError>>;
}
/** Register browser tools even when policy/provider availability denies execution. */
export const registerBrowserTools = (
server: McpServer,
@@ -55,224 +75,118 @@ export const registerBrowserTools = (
screenshotContract,
screenshotDiffContract,
] = BROWSER_TOOL_CONTRACTS;
registerBrowserTool(server, options, {
contract: listContract,
schema: listBrowserTargetsInputSchema,
execute: (parsed, { signal }) =>
listBrowserTargets(options.browser, options.permissionAuthority, parsed, {
signal,
}),
});
registerBrowserTool(server, options, {
contract: inspectContract,
schema: inspectWebPageInputSchema,
execute: (parsed, { signal, progress }) =>
inspectWebPage(options.browser, options.permissionAuthority, parsed, {
signal,
progress,
}),
});
registerBrowserTool(server, options, {
contract: analyzeContract,
schema: analyzeWebBundleInputSchema,
execute: (parsed, { signal, progress }) =>
analyzeWebBundle(options.browser, options.permissionAuthority, parsed, {
signal,
progress,
}),
});
registerBrowserTool(server, options, {
contract: sessionContract,
schema: observeWebSessionInputSchema,
execute: (parsed, { signal, progress }) =>
observeWebSession(options.browser, options.permissionAuthority, parsed, {
signal,
progress,
}),
});
registerBrowserTool(server, options, {
contract: webMcpContract,
schema: discoverWebMcpToolsInputSchema,
execute: (parsed, { signal, progress }) =>
discoverWebMcpTools(
options.browser,
options.permissionAuthority,
parsed,
{
signal,
progress,
},
),
});
registerBrowserTool(server, options, {
contract: captureDiffContract,
schema: compareWebCapturesInputSchema,
execute: (parsed, _context) =>
compareWebCaptureEvidence(options.browser, parsed),
});
registerBrowserTool(server, options, {
contract: screenshotContract,
schema: captureWebScreenshotInputSchema,
execute: (parsed, { signal, progress }) =>
captureWebScreenshot(
options.browser,
options.permissionAuthority,
parsed,
{
signal,
progress,
},
),
});
registerBrowserTool(server, options, {
contract: screenshotDiffContract,
schema: compareWebScreenshotsInputSchema,
execute: (parsed, _context) =>
compareWebScreenshotEvidence(options.browser, parsed),
});
};
const registerBrowserTool = <Schema extends z.ZodType>(
server: McpServer,
options: BrowserToolRegistration,
spec: BrowserToolSpec<Schema>,
): void => {
server.registerTool(
listContract.name,
toolRegistrationOptions(listContract),
spec.contract.name,
toolRegistrationOptions(spec.contract),
async (input, context) => {
const parsedInput = safeParseToolInput(
listBrowserTargetsInputSchema,
spec.schema,
input,
listContract.name,
spec.contract.name,
);
if (!parsedInput.ok) return toCallToolResult(parsedInput, listContract);
const parsed = parsedInput.value;
if (!parsedInput.ok) return toCallToolResult(parsedInput, spec.contract);
const result = await logToolExecution(
options.logger,
listContract.name,
spec.contract.name,
() =>
listBrowserTargets(
options.browser,
options.permissionAuthority,
parsed,
{ signal: context.mcpReq.signal },
),
);
if (!result.ok) return toCallToolResult(result, listContract);
return evidenceResult(options, listContract, result.value);
},
);
server.registerTool(
inspectContract.name,
toolRegistrationOptions(inspectContract),
async (input, context) => {
const parsedInput = safeParseToolInput(
inspectWebPageInputSchema,
input,
inspectContract.name,
);
if (!parsedInput.ok)
return toCallToolResult(parsedInput, inspectContract);
const parsed = parsedInput.value;
const result = await logToolExecution(
options.logger,
inspectContract.name,
() =>
inspectWebPage(options.browser, options.permissionAuthority, parsed, {
spec.execute(parsedInput.value, {
signal: context.mcpReq.signal,
progress: mcpProgressReporter(context),
}),
);
if (!result.ok) return toCallToolResult(result, inspectContract);
return evidenceResult(options, inspectContract, result.value);
},
);
server.registerTool(
analyzeContract.name,
toolRegistrationOptions(analyzeContract),
async (input, context) => {
const parsedInput = safeParseToolInput(
analyzeWebBundleInputSchema,
input,
analyzeContract.name,
);
if (!parsedInput.ok)
return toCallToolResult(parsedInput, analyzeContract);
const parsed = parsedInput.value;
const result = await logToolExecution(
options.logger,
analyzeContract.name,
() =>
analyzeWebBundle(
options.browser,
options.permissionAuthority,
parsed,
{
signal: context.mcpReq.signal,
progress: mcpProgressReporter(context),
},
),
);
if (!result.ok) return toCallToolResult(result, analyzeContract);
return evidenceResult(options, analyzeContract, result.value);
},
);
server.registerTool(
sessionContract.name,
toolRegistrationOptions(sessionContract),
async (input, context) => {
const parsedInput = safeParseToolInput(
observeWebSessionInputSchema,
input,
sessionContract.name,
);
if (!parsedInput.ok)
return toCallToolResult(parsedInput, sessionContract);
const parsed = parsedInput.value;
const result = await logToolExecution(
options.logger,
sessionContract.name,
() =>
observeWebSession(
options.browser,
options.permissionAuthority,
parsed,
{
signal: context.mcpReq.signal,
progress: mcpProgressReporter(context),
},
),
);
if (!result.ok) return toCallToolResult(result, sessionContract);
return evidenceResult(options, sessionContract, result.value);
},
);
server.registerTool(
webMcpContract.name,
toolRegistrationOptions(webMcpContract),
async (input, context) => {
const parsedInput = safeParseToolInput(
discoverWebMcpToolsInputSchema,
input,
webMcpContract.name,
);
if (!parsedInput.ok) return toCallToolResult(parsedInput, webMcpContract);
const parsed = parsedInput.value;
const result = await logToolExecution(
options.logger,
webMcpContract.name,
() =>
discoverWebMcpTools(
options.browser,
options.permissionAuthority,
parsed,
{
signal: context.mcpReq.signal,
progress: mcpProgressReporter(context),
},
),
);
if (!result.ok) return toCallToolResult(result, webMcpContract);
return evidenceResult(options, webMcpContract, result.value);
},
);
server.registerTool(
captureDiffContract.name,
toolRegistrationOptions(captureDiffContract),
async (input) => {
const parsedInput = safeParseToolInput(
compareWebCapturesInputSchema,
input,
captureDiffContract.name,
);
if (!parsedInput.ok)
return toCallToolResult(parsedInput, captureDiffContract);
const parsed = parsedInput.value;
const result = await logToolExecution(
options.logger,
captureDiffContract.name,
() => compareWebCaptureEvidence(options.browser, parsed),
);
if (!result.ok) return toCallToolResult(result, captureDiffContract);
return evidenceResult(options, captureDiffContract, result.value);
},
);
server.registerTool(
screenshotContract.name,
toolRegistrationOptions(screenshotContract),
async (input, context) => {
const parsedInput = safeParseToolInput(
captureWebScreenshotInputSchema,
input,
screenshotContract.name,
);
if (!parsedInput.ok)
return toCallToolResult(parsedInput, screenshotContract);
const parsed = parsedInput.value;
const result = await logToolExecution(
options.logger,
screenshotContract.name,
() =>
captureWebScreenshot(
options.browser,
options.permissionAuthority,
parsed,
{
signal: context.mcpReq.signal,
progress: mcpProgressReporter(context),
},
),
);
if (!result.ok) return toCallToolResult(result, screenshotContract);
return evidenceResult(options, screenshotContract, result.value);
},
);
server.registerTool(
screenshotDiffContract.name,
toolRegistrationOptions(screenshotDiffContract),
async (input) => {
const parsedInput = safeParseToolInput(
compareWebScreenshotsInputSchema,
input,
screenshotDiffContract.name,
);
if (!parsedInput.ok)
return toCallToolResult(parsedInput, screenshotDiffContract);
const parsed = parsedInput.value;
const result = await logToolExecution(
options.logger,
screenshotDiffContract.name,
() => compareWebScreenshotEvidence(options.browser, parsed),
);
if (!result.ok) return toCallToolResult(result, screenshotDiffContract);
return evidenceResult(options, screenshotDiffContract, result.value);
if (!result.ok) return toCallToolResult(result, spec.contract);
return evidenceResult(options, spec.contract, result.value);
},
);
};
const evidenceResult = (
options: BrowserToolRegistration,
contract: (typeof BROWSER_TOOL_CONTRACTS)[number],
evidence: import("../domain/evidence.js").Evidence,
contract: ToolContract,
evidence: Evidence,
) => {
const recorded = options.recordEvidence?.(evidence);
return recorded !== undefined && !recorded.ok
+85 -106
View File
@@ -1,4 +1,5 @@
import type { McpServer } from "@modelcontextprotocol/server";
import type { z } from "zod";
import type { BinarySessionPort } from "../application/BinarySession.js";
import type { ElectronObservationPort } from "../application/ElectronObservationPort.js";
@@ -22,6 +23,11 @@ import { toCallToolResult } from "./toolResult.js";
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
import { safeParseToolInput } from "./toolInputValidation.js";
import { mcpProgressReporter } from "./mcpProgress.js";
import type { ProgressReporter } from "../application/ProgressReporter.js";
import type { Evidence } from "../domain/evidence.js";
import type { Result } from "../domain/result.js";
import type { AnalysisError } from "../domain/errors.js";
import type { ToolContract } from "../contracts/toolContracts.js";
interface ElectronToolRegistration {
readonly logger: Logger;
@@ -30,6 +36,20 @@ interface ElectronToolRegistration {
readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined;
}
interface ElectronToolContext {
readonly signal: AbortSignal;
readonly progress: ProgressReporter;
}
interface ElectronToolSpec<Schema extends z.ZodType> {
readonly contract: ToolContract;
readonly schema: Schema;
readonly execute: (
parsed: z.output<Schema>,
context: ElectronToolContext,
) => Promise<Result<Evidence, AnalysisError>>;
}
/** Register Electron tools even when provider or permission policy is absent. */
export const registerElectronTools = (
server: McpServer,
@@ -37,127 +57,86 @@ export const registerElectronTools = (
): void => {
const [listContract, inspectContract, analyzeContract, reconcileContract] =
ELECTRON_TOOL_CONTRACTS;
registerElectronTool(server, options, {
contract: listContract,
schema: listElectronTargetsInputSchema,
execute: (parsed, { signal }) =>
listElectronTargets(
options.electron,
options.permissionAuthority,
parsed,
{
signal,
},
),
});
registerElectronTool(server, options, {
contract: inspectContract,
schema: inspectElectronPageInputSchema,
execute: (parsed, { signal, progress }) =>
inspectElectronPage(
options.electron,
options.permissionAuthority,
parsed,
{
signal,
progress,
},
),
});
registerElectronTool(server, options, {
contract: analyzeContract,
schema: analyzeJavaScriptApplicationInputSchema,
execute: (parsed, { signal, progress }) =>
analyzeJavaScriptApplicationValidated(
options.permissionAuthority,
parsed,
{ signal, progress },
),
});
registerElectronTool(server, options, {
contract: reconcileContract,
schema: reconcileJavaScriptRuntimeInputSchema,
execute: (parsed, _context) =>
Promise.resolve(reconcileJavaScriptRuntimeEvidenceValidated(parsed)),
});
};
const registerElectronTool = <Schema extends z.ZodType>(
server: McpServer,
options: ElectronToolRegistration,
spec: ElectronToolSpec<Schema>,
): void => {
server.registerTool(
listContract.name,
toolRegistrationOptions(listContract),
spec.contract.name,
toolRegistrationOptions(spec.contract),
async (input, context) => {
const parsedInput = safeParseToolInput(
listElectronTargetsInputSchema,
spec.schema,
input,
listContract.name,
spec.contract.name,
);
if (!parsedInput.ok) return toCallToolResult(parsedInput, listContract);
const parsed = parsedInput.value;
if (!parsedInput.ok) return toCallToolResult(parsedInput, spec.contract);
const result = await logToolExecution(
options.logger,
listContract.name,
spec.contract.name,
() =>
listElectronTargets(
options.electron,
options.permissionAuthority,
parsed,
{ signal: context.mcpReq.signal },
),
spec.execute(parsedInput.value, {
signal: context.mcpReq.signal,
progress: mcpProgressReporter(context),
}),
);
if (!result.ok) return toCallToolResult(result, listContract);
return evidenceResult(options, listContract, result.value);
},
);
server.registerTool(
inspectContract.name,
toolRegistrationOptions(inspectContract),
async (input, context) => {
const parsedInput = safeParseToolInput(
inspectElectronPageInputSchema,
input,
inspectContract.name,
);
if (!parsedInput.ok)
return toCallToolResult(parsedInput, inspectContract);
const parsed = parsedInput.value;
const result = await logToolExecution(
options.logger,
inspectContract.name,
() =>
inspectElectronPage(
options.electron,
options.permissionAuthority,
parsed,
{
signal: context.mcpReq.signal,
progress: mcpProgressReporter(context),
},
),
);
if (!result.ok) return toCallToolResult(result, inspectContract);
return evidenceResult(options, inspectContract, result.value);
},
);
server.registerTool(
analyzeContract.name,
toolRegistrationOptions(analyzeContract),
async (input, context) => {
const parsedInput = safeParseToolInput(
analyzeJavaScriptApplicationInputSchema,
input,
analyzeContract.name,
);
if (!parsedInput.ok)
return toCallToolResult(parsedInput, analyzeContract);
const parsed = parsedInput.value;
const result = await logToolExecution(
options.logger,
analyzeContract.name,
() =>
analyzeJavaScriptApplicationValidated(
options.permissionAuthority,
parsed,
{
signal: context.mcpReq.signal,
progress: mcpProgressReporter(context),
},
),
);
if (!result.ok) return toCallToolResult(result, analyzeContract);
return evidenceResult(options, analyzeContract, result.value);
},
);
server.registerTool(
reconcileContract.name,
toolRegistrationOptions(reconcileContract),
async (input) => {
const parsedInput = safeParseToolInput(
reconcileJavaScriptRuntimeInputSchema,
input,
reconcileContract.name,
);
if (!parsedInput.ok)
return toCallToolResult(parsedInput, reconcileContract);
const parsed = parsedInput.value;
const result = await logToolExecution(
options.logger,
reconcileContract.name,
() =>
Promise.resolve(reconcileJavaScriptRuntimeEvidenceValidated(parsed)),
);
if (!result.ok) return toCallToolResult(result, reconcileContract);
for (const source of [
...parsed.static_layers.map(({ analysis }) => analysis),
...parsed.runtime_observations,
]) {
const recorded = options.recordEvidence?.(source);
if (recorded !== undefined && !recorded.ok)
return toCallToolResult(recorded, reconcileContract);
}
return evidenceResult(options, reconcileContract, result.value);
if (!result.ok) return toCallToolResult(result, spec.contract);
return evidenceResult(options, spec.contract, result.value);
},
);
};
const evidenceResult = (
options: ElectronToolRegistration,
contract: (typeof ELECTRON_TOOL_CONTRACTS)[number],
evidence: import("../domain/evidence.js").Evidence,
contract: ToolContract,
evidence: Evidence,
) => {
const recorded = options.recordEvidence?.(evidence);
return recorded !== undefined && !recorded.ok
+126 -72
View File
@@ -1,4 +1,5 @@
import type { McpServer } from "@modelcontextprotocol/server";
import type { z } from "zod";
import {
EnhancedTools,
@@ -6,7 +7,10 @@ import {
} from "../application/EnhancedTools.js";
import type { AnalysisOperationPort } from "../application/AnalysisProvider.js";
import type { BinarySessionPort } from "../application/BinarySession.js";
import { enhancedToolNameSchema } from "../contracts/enhancedInputs.js";
import {
enhancedInputSchemas,
enhancedToolNameSchema,
} from "../contracts/enhancedInputs.js";
import {
ENHANCED_TOOL_CONTRACTS,
type ToolContract,
@@ -17,7 +21,6 @@ import { logToolExecution } from "./toolLogging.js";
import type { BinaryTarget } from "../domain/binaryTarget.js";
import { createEvidence } from "../domain/evidence.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { enhancedInputSchemas } from "../contracts/enhancedInputs.js";
import { AnalysisInputError, UnknownRegistryError } from "../domain/errors.js";
import { ok, type Result } from "../domain/result.js";
import { mcpProgressReporter } from "./mcpProgress.js";
@@ -147,94 +150,145 @@ const jsonParameters = (
),
);
type EnhancedToolName = ValidatedEnhancedCall["name"];
type EnhancedInputOf<Name extends EnhancedToolName> = z.output<
(typeof enhancedInputSchemas)[Name]
>;
type EnhancedInputParser<Name extends EnhancedToolName> = (
input: unknown,
) => Result<EnhancedInputOf<Name>, AnalysisInputError>;
const ENHANCED_INPUT_PARSERS: {
[Name in EnhancedToolName]: EnhancedInputParser<Name>;
} = {
swift_classes: (input) =>
safeParseToolInput(
enhancedInputSchemas.swift_classes,
input,
"swift_classes",
),
get_objc_classes: (input) =>
safeParseToolInput(
enhancedInputSchemas.get_objc_classes,
input,
"get_objc_classes",
),
get_objc_protocols: (input) =>
safeParseToolInput(
enhancedInputSchemas.get_objc_protocols,
input,
"get_objc_protocols",
),
batch_decompile: (input) =>
safeParseToolInput(
enhancedInputSchemas.batch_decompile,
input,
"batch_decompile",
),
get_call_graph: (input) =>
safeParseToolInput(
enhancedInputSchemas.get_call_graph,
input,
"get_call_graph",
),
analyze_swift_types: (input) =>
safeParseToolInput(
enhancedInputSchemas.analyze_swift_types,
input,
"analyze_swift_types",
),
find_xrefs_to_name: (input) =>
safeParseToolInput(
enhancedInputSchemas.find_xrefs_to_name,
input,
"find_xrefs_to_name",
),
binary_overview: (input) =>
safeParseToolInput(
enhancedInputSchemas.binary_overview,
input,
"binary_overview",
),
analyze_function: (input) =>
safeParseToolInput(
enhancedInputSchemas.analyze_function,
input,
"analyze_function",
),
trace_feature: (input) =>
safeParseToolInput(
enhancedInputSchemas.trace_feature,
input,
"trace_feature",
),
};
const parseEnhancedCall = (
name: ValidatedEnhancedCall["name"],
input: unknown,
): Result<ValidatedEnhancedCall, AnalysisInputError> => {
switch (name) {
case "swift_classes": {
const parsed = safeParseToolInput(
enhancedInputSchemas.swift_classes,
input,
name,
case "swift_classes":
return mapEnhancedCall(
ENHANCED_INPUT_PARSERS.swift_classes(input),
(value) => ({ name, input: value }),
);
return parsed.ok ? ok({ name, input: parsed.value }) : parsed;
}
case "get_objc_classes": {
const parsed = safeParseToolInput(
enhancedInputSchemas.get_objc_classes,
input,
name,
case "get_objc_classes":
return mapEnhancedCall(
ENHANCED_INPUT_PARSERS.get_objc_classes(input),
(value) => ({ name, input: value }),
);
return parsed.ok ? ok({ name, input: parsed.value }) : parsed;
}
case "get_objc_protocols": {
const parsed = safeParseToolInput(
enhancedInputSchemas.get_objc_protocols,
input,
name,
case "get_objc_protocols":
return mapEnhancedCall(
ENHANCED_INPUT_PARSERS.get_objc_protocols(input),
(value) => ({ name, input: value }),
);
return parsed.ok ? ok({ name, input: parsed.value }) : parsed;
}
case "batch_decompile": {
const parsed = safeParseToolInput(
enhancedInputSchemas.batch_decompile,
input,
name,
case "batch_decompile":
return mapEnhancedCall(
ENHANCED_INPUT_PARSERS.batch_decompile(input),
(value) => ({ name, input: value }),
);
return parsed.ok ? ok({ name, input: parsed.value }) : parsed;
}
case "get_call_graph": {
const parsed = safeParseToolInput(
enhancedInputSchemas.get_call_graph,
input,
name,
case "get_call_graph":
return mapEnhancedCall(
ENHANCED_INPUT_PARSERS.get_call_graph(input),
(value) => ({ name, input: value }),
);
return parsed.ok ? ok({ name, input: parsed.value }) : parsed;
}
case "analyze_swift_types": {
const parsed = safeParseToolInput(
enhancedInputSchemas.analyze_swift_types,
input,
name,
case "analyze_swift_types":
return mapEnhancedCall(
ENHANCED_INPUT_PARSERS.analyze_swift_types(input),
(value) => ({ name, input: value }),
);
return parsed.ok ? ok({ name, input: parsed.value }) : parsed;
}
case "find_xrefs_to_name": {
const parsed = safeParseToolInput(
enhancedInputSchemas.find_xrefs_to_name,
input,
name,
case "find_xrefs_to_name":
return mapEnhancedCall(
ENHANCED_INPUT_PARSERS.find_xrefs_to_name(input),
(value) => ({ name, input: value }),
);
return parsed.ok ? ok({ name, input: parsed.value }) : parsed;
}
case "binary_overview": {
const parsed = safeParseToolInput(
enhancedInputSchemas.binary_overview,
input,
name,
case "binary_overview":
return mapEnhancedCall(
ENHANCED_INPUT_PARSERS.binary_overview(input),
(value) => ({ name, input: value }),
);
return parsed.ok ? ok({ name, input: parsed.value }) : parsed;
}
case "analyze_function": {
const parsed = safeParseToolInput(
enhancedInputSchemas.analyze_function,
input,
name,
case "analyze_function":
return mapEnhancedCall(
ENHANCED_INPUT_PARSERS.analyze_function(input),
(value) => ({ name, input: value }),
);
return parsed.ok ? ok({ name, input: parsed.value }) : parsed;
}
case "trace_feature": {
const parsed = safeParseToolInput(
enhancedInputSchemas.trace_feature,
input,
name,
case "trace_feature":
return mapEnhancedCall(
ENHANCED_INPUT_PARSERS.trace_feature(input),
(value) => ({ name, input: value }),
);
return parsed.ok ? ok({ name, input: parsed.value }) : parsed;
}
}
};
const mapEnhancedCall = <Input, Call extends ValidatedEnhancedCall>(
parsed: Result<Input, AnalysisInputError>,
project: (input: Input) => Call,
): Result<Call, AnalysisInputError> =>
parsed.ok ? ok(project(parsed.value)) : parsed;
interface WorkflowUnknownInput {
readonly name: string;
readonly input: Readonly<Record<string, JsonValue>>;
+63
View File
@@ -17,6 +17,21 @@ const evidenceUri = (evidenceId: string): string =>
export const registerEvidenceResources = (
server: McpServer,
session: BinarySessionPort,
): void => {
registerReconstructionCoverageResource(server, session);
registerInvestigationWorkspaceResource(server, session);
registerSessionEvidenceResource(server, session);
registerActiveResidualUnknownsResource(server, session);
registerAnalysisSnapshotResource(server, session);
registerArtifactPageResource(server, session);
registerFunctionDossierResource(server, session);
registerEvidenceSectionResource(server, session);
registerResidualUnknownResource(server, session);
};
const registerReconstructionCoverageResource = (
server: McpServer,
session: BinarySessionPort,
): void => {
server.registerResource(
"reconstruction-coverage-revision",
@@ -59,6 +74,12 @@ export const registerEvidenceResources = (
return jsonResource(uri.href, workspace);
},
);
};
const registerInvestigationWorkspaceResource = (
server: McpServer,
session: BinarySessionPort,
): void => {
server.registerResource(
"investigation-workspace-revision",
new ResourceTemplate("rea://workspace/{workspaceId}/revision/{revision}", {
@@ -109,6 +130,12 @@ export const registerEvidenceResources = (
return jsonResource(uri.href, workspace);
},
);
};
const registerSessionEvidenceResource = (
server: McpServer,
session: BinarySessionPort,
): void => {
server.registerResource(
"session-evidence",
new ResourceTemplate("rea://evidence/{evidenceId}", {
@@ -151,6 +178,12 @@ export const registerEvidenceResources = (
};
},
);
};
const registerActiveResidualUnknownsResource = (
server: McpServer,
session: BinarySessionPort,
): void => {
server.registerResource(
"active-residual-unknowns",
"rea://unknowns/active",
@@ -165,6 +198,12 @@ export const registerEvidenceResources = (
session.listUnknowns().filter(({ status }) => status !== "resolved"),
),
);
};
const registerAnalysisSnapshotResource = (
server: McpServer,
session: BinarySessionPort,
): void => {
server.registerResource(
"analysis-snapshot",
new ResourceTemplate("rea://snapshot/{snapshotDigest}", {
@@ -199,6 +238,12 @@ export const registerEvidenceResources = (
return jsonResource(uri.href, snapshot.value);
},
);
};
const registerArtifactPageResource = (
server: McpServer,
session: BinarySessionPort,
): void => {
server.registerResource(
"artifact-page",
new ResourceTemplate("rea://artifact/{manifestId}/{collection}", {
@@ -240,6 +285,12 @@ export const registerEvidenceResources = (
});
},
);
};
const registerFunctionDossierResource = (
server: McpServer,
session: BinarySessionPort,
): void => {
server.registerResource(
"function-dossier",
new ResourceTemplate("rea://function/{targetSha256}/{address}", {
@@ -265,6 +316,12 @@ export const registerEvidenceResources = (
return jsonResource(uri.href, evidence);
},
);
};
const registerEvidenceSectionResource = (
server: McpServer,
session: BinarySessionPort,
): void => {
server.registerResource(
"evidence-section",
new ResourceTemplate("rea://evidence/{evidenceId}/section/{section}", {
@@ -300,6 +357,12 @@ export const registerEvidenceResources = (
});
},
);
};
const registerResidualUnknownResource = (
server: McpServer,
session: BinarySessionPort,
): void => {
server.registerResource(
"residual-unknown",
new ResourceTemplate("rea://unknown/{unknownId}", {
+130 -255
View File
@@ -1,4 +1,5 @@
import type { McpServer } from "@modelcontextprotocol/server";
import type { z } from "zod";
import type { BinarySessionPort } from "../application/BinarySession.js";
import { runCrossVersionInvestigationValidated } from "../application/CrossVersionInvestigation.js";
@@ -14,21 +15,15 @@ import {
reconstructionVerificationInputSchema,
verifyReconstruction,
} from "../domain/reconstructionVerification.js";
import { evaluateReconstructionClosure } from "../domain/reconstructionCoverage.js";
import {
changedBehaviorResultSchema,
changedBehaviorInputSchema,
findChangedBehavior,
} from "../domain/changedBehavior.js";
import { createEvidence, type Evidence } from "../domain/evidence.js";
import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js";
import {
AnalysisInputError,
EvidenceIntegrityError,
type AnalysisError,
} from "../domain/errors.js";
import { createEvidence } from "../domain/evidence.js";
import { type AnalysisError } from "../domain/errors.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { err, ok, type Result } from "../domain/result.js";
import type { Result } from "../domain/result.js";
import {
CALL_PATH_PROVIDER,
CHANGED_BEHAVIOR_PROVIDER,
@@ -36,16 +31,26 @@ import {
STATIC_RUNTIME_PROVIDER,
} from "./sessionToolPolicies.js";
import { toCallToolResult } from "./toolResult.js";
import { recordDerivedEvidence } from "./recordDerivedEvidence.js";
import type { PermissionAuthority } from "../application/PermissionAuthority.js";
import type { ProgressReporter } from "../application/ProgressReporter.js";
import { mcpProgressReporter } from "./mcpProgress.js";
import { runDerivedOperation } from "./runDerivedOperation.js";
import {
runDerivedOperation,
type DerivedOperationContext,
} from "./runDerivedOperation.js";
import {
authorizeFileReadWithDeferredWrite,
authorizeRootPermission,
type DeferredFileWriteAuthorization,
} from "../application/DeferredFileAuthorization.js";
import {
comparisonClosure,
evidenceClosure,
functionEvidenceIds,
investigationContext,
isIncomplete,
recordWorkflowEvidence,
verifyCoverageReadiness,
} from "./registerInvestigationTools/helpers.js";
import type { InvestigationToolPolicies } from "./registerInvestigationTools/types.js";
/** Register Evidence-composed differential investigation workflows. */
export const registerInvestigationTools = (
@@ -65,30 +70,6 @@ export const registerInvestigationTools = (
registerReconstruction(server, session, contracts[3]);
};
interface InvestigationToolPolicies {
readonly evidenceFiles: EvidenceFilePolicy;
readonly inputRoots: readonly string[];
readonly permissionAuthority?: PermissionAuthority;
readonly integrityContinueEnabled?: () => boolean;
}
const investigationExecution = (
session: BinarySessionPort,
signal: AbortSignal,
inputRoots: readonly string[],
integrityContinueEnabled: boolean,
progress?: ProgressReporter,
) => ({
session,
signal,
inputRoots,
integrityContinueEnabled,
...(progress === undefined ? {} : { progress }),
});
const isIncomplete = (status: string): boolean =>
status === "unknown" || status === "truncated";
const registerChangedBehavior = (
server: McpServer,
session: BinarySessionPort,
@@ -107,98 +88,16 @@ const registerChangedBehavior = (
if (!parsedInput.ok) return toCallToolResult(parsedInput, contract);
const parsed = parsedInput.value;
const investigationRun = parsed.investigation_run;
if (investigationRun !== undefined) {
const progress = mcpProgressReporter(context);
let workspaceAuthorization: DeferredFileWriteAuthorization | undefined;
let authorizeInputRead:
| (() => Promise<Result<null, AnalysisError>>)
| undefined;
if (policies.permissionAuthority !== undefined) {
const authority = policies.permissionAuthority;
const authorizedWorkspace = await authorizeFileReadWithDeferredWrite(
authority,
{
path: investigationRun.workspace_path,
readCapability: "investigation_workspace_read",
writeCapability: "investigation_workspace_write",
operation: contract.name,
},
);
if (!authorizedWorkspace.ok)
return toCallToolResult(authorizedWorkspace, contract);
workspaceAuthorization = authorizedWorkspace.value;
authorizeInputRead = () =>
authorizeRootPermission(authority, {
capability: "investigation_input",
roots: [investigationRun.left_path, investigationRun.right_path],
access: "read",
operation: contract.name,
});
}
const investigated = await runCrossVersionInvestigationValidated(
investigationRun,
policies.evidenceFiles,
{
...investigationExecution(
session,
context.mcpReq.signal,
policies.inputRoots,
policies.integrityContinueEnabled?.() ?? false,
progress,
),
...(workspaceAuthorization === undefined
? {}
: {
authorizeWorkspaceWrite:
workspaceAuthorization.authorizeWrite,
}),
...(authorizeInputRead === undefined ? {} : { authorizeInputRead }),
},
);
if (!investigated.ok) return toCallToolResult(investigated, contract);
const workspace = investigated.value.workspace;
if (session.retainInvestigationWorkspace(workspace) === "added")
server.sendResourceListChanged();
const result = changedBehaviorResultSchema.parse(
investigated.value.evidence.normalized_result,
);
const toolResult = toCallToolResult(
recordWorkflowEvidence(
session,
investigated.value.evidence,
parsed.unknown_registry_approved,
isIncomplete(result.behavior_status),
{
question: `Automatic changed behavior run ${result.investigation_run?.run_id ?? "unknown"} remains ${result.behavior_status}`,
domain: "changed-behavior",
requiredAuthority: "controlled-replay",
requiredConfidence: "observed",
probes: [
{
operation: "capture_process_scenario",
rationale:
"Capture both versions under the same bounded scenario and environment.",
},
],
},
),
if (investigationRun !== undefined)
return runAutomaticInvestigation({
server,
session,
contract,
);
return {
...toolResult,
content: [
...toolResult.content,
{
type: "resource_link" as const,
uri: `rea://workspace/${workspace.workspace_id}/revision/${String(workspace.revision)}`,
name: `${workspace.workspace_id} revision ${String(workspace.revision)}`,
description:
"Immutable CAS-linked investigation workspace revision",
mimeType: "application/json",
},
],
};
}
policies,
investigationRun,
unknownRegistryApproved: parsed.unknown_registry_approved,
context,
});
const closure = evidenceClosure(
session,
comparisonClosure(parsed.comparisons),
@@ -251,6 +150,107 @@ const registerChangedBehavior = (
);
};
const runAutomaticInvestigation = async (input: {
readonly server: McpServer;
readonly session: BinarySessionPort;
readonly contract: ToolContract<"find_changed_behavior">;
readonly policies: InvestigationToolPolicies;
readonly investigationRun: NonNullable<
z.output<typeof changedBehaviorInputSchema>["investigation_run"]
>;
readonly unknownRegistryApproved: true | undefined;
readonly context: DerivedOperationContext;
}) => {
const { policies, investigationRun, contract, session } = input;
const progress = mcpProgressReporter(input.context);
let workspaceAuthorization: DeferredFileWriteAuthorization | undefined;
let authorizeInputRead:
| (() => Promise<Result<null, AnalysisError>>)
| undefined;
if (policies.permissionAuthority !== undefined) {
const authority = policies.permissionAuthority;
const authorizedWorkspace = await authorizeFileReadWithDeferredWrite(
authority,
{
path: investigationRun.workspace_path,
readCapability: "investigation_workspace_read",
writeCapability: "investigation_workspace_write",
operation: contract.name,
},
);
if (!authorizedWorkspace.ok)
return toCallToolResult(authorizedWorkspace, contract);
workspaceAuthorization = authorizedWorkspace.value;
authorizeInputRead = () =>
authorizeRootPermission(authority, {
capability: "investigation_input",
roots: [investigationRun.left_path, investigationRun.right_path],
access: "read",
operation: contract.name,
});
}
const investigated = await runCrossVersionInvestigationValidated(
investigationRun,
policies.evidenceFiles,
{
...investigationContext({
session,
signal: input.context.mcpReq.signal,
inputRoots: policies.inputRoots,
integrityContinueEnabled:
policies.integrityContinueEnabled?.() ?? false,
progress,
}),
...(workspaceAuthorization === undefined
? {}
: { authorizeWorkspaceWrite: workspaceAuthorization.authorizeWrite }),
...(authorizeInputRead === undefined ? {} : { authorizeInputRead }),
},
);
if (!investigated.ok) return toCallToolResult(investigated, contract);
const workspace = investigated.value.workspace;
if (session.retainInvestigationWorkspace(workspace) === "added")
input.server.sendResourceListChanged();
const result = changedBehaviorResultSchema.parse(
investigated.value.evidence.normalized_result,
);
const toolResult = toCallToolResult(
recordWorkflowEvidence(
session,
investigated.value.evidence,
input.unknownRegistryApproved,
isIncomplete(result.behavior_status),
{
question: `Automatic changed behavior run ${result.investigation_run?.run_id ?? "unknown"} remains ${result.behavior_status}`,
domain: "changed-behavior",
requiredAuthority: "controlled-replay",
requiredConfidence: "observed",
probes: [
{
operation: "capture_process_scenario",
rationale:
"Capture both versions under the same bounded scenario and environment.",
},
],
},
),
contract,
);
return {
...toolResult,
content: [
...toolResult.content,
{
type: "resource_link" as const,
uri: `rea://workspace/${workspace.workspace_id}/revision/${String(workspace.revision)}`,
name: `${workspace.workspace_id} revision ${String(workspace.revision)}`,
description: "Immutable CAS-linked investigation workspace revision",
mimeType: "application/json",
},
],
};
};
const registerCallPath = (
server: McpServer,
session: BinarySessionPort,
@@ -407,47 +407,8 @@ const registerReconstruction = (
);
if (!parsedInput.ok) return toCallToolResult(parsedInput, contract);
const parsed = parsedInput.value;
const coverage = parsed.coverage;
if (coverage !== undefined) {
const workspace = session.reconstructionCoverageWorkspace(
coverage.workspace_id,
coverage.revision,
);
if (
workspace === undefined ||
workspace.revision_sha256 !== coverage.revision_sha256
)
return toCallToolResult(
err(
new EvidenceIntegrityError(
"The requested reconstruction coverage revision is not retained by this session",
),
),
contract,
);
let coverageResult;
try {
coverageResult = evaluateReconstructionClosure(
workspace,
coverage.boundary_id,
Date.now(),
);
} catch (cause: unknown) {
return toCallToolResult(
err(new AnalysisInputError(contract.name, { cause })),
contract,
);
}
if (coverageResult.status !== "ready")
return toCallToolResult(
err(
new EvidenceIntegrityError(
`Reconstruction coverage boundary is ${coverageResult.status}; readiness requires ready`,
),
),
contract,
);
}
const coverage = verifyCoverageReadiness(session, parsed.coverage);
if (!coverage.ok) return toCallToolResult(coverage, contract);
const owned = session.exportEvidenceBundle();
const computed = await runDerivedOperation(context, contract.name, () =>
verifyReconstruction(
@@ -503,89 +464,3 @@ const registerReconstruction = (
};
const contractOptions = toolRegistrationOptions;
const comparisonClosure = (comparisons: readonly Evidence[]): string[] =>
uniqueIds(
comparisons.flatMap((evidence) => [
evidence.evidence_id,
...evidence.evidence_links,
]),
);
const functionEvidenceIds = (
groups: readonly (Evidence | readonly Evidence[])[],
): string[] =>
uniqueIds(
groups.flatMap((group) =>
(Array.isArray(group) ? group : [group]).map(({ evidence_id: id }) => id),
),
);
const uniqueIds = (ids: readonly string[]): string[] =>
[...new Set(ids)].sort((left, right) => left.localeCompare(right));
const evidenceClosure = (
session: BinarySessionPort,
seedIds: readonly string[],
): Result<string[], EvidenceIntegrityError> => {
const records = new Map(
session
.exportEvidenceBundle()
.records.map((evidence) => [evidence.evidence_id, evidence]),
);
const visited = new Set<string>();
const pending = [...seedIds];
while (pending.length > 0) {
const evidenceId = pending.pop();
if (evidenceId === undefined || visited.has(evidenceId)) continue;
const evidence = records.get(evidenceId);
if (evidence === undefined)
return err(
new EvidenceIntegrityError(
"Investigation input has a dangling Evidence link",
),
);
visited.add(evidenceId);
pending.push(...evidence.evidence_links);
}
return ok(uniqueIds([...visited]));
};
const recordWorkflowEvidence = (
...[session, evidence, approved, unresolved, input]: readonly [
BinarySessionPort,
Evidence,
true | undefined,
boolean,
{
readonly question: string;
readonly domain: string;
readonly requiredAuthority:
| "shipped-artifact"
| "controlled-replay"
| null;
readonly requiredConfidence: "observed" | "derived";
readonly probes: readonly {
readonly operation: string;
readonly rationale: string;
}[];
},
]
): Result<Evidence, import("../domain/errors.js").AnalysisError> => {
if (approved !== true || !unresolved) {
return recordDerivedEvidence(session, evidence, undefined);
}
return recordDerivedEvidence(session, evidence, {
approved: true,
question: input.question,
severity: "high",
domain: input.domain,
supporting_evidence_ids: [evidence.evidence_id],
contradicting_evidence_ids: [],
required_authority: input.requiredAuthority,
required_confidence: input.requiredConfidence,
required_environment: null,
recommended_probes: [...input.probes],
relationships: [],
});
};
@@ -0,0 +1,155 @@
import type { BinarySessionPort } from "../../application/BinarySession.js";
import type { ProgressReporter } from "../../application/ProgressReporter.js";
import type { Evidence } from "../../domain/evidence.js";
import {
EvidenceIntegrityError,
type AnalysisError,
} from "../../domain/errors.js";
import { err, ok, type Result } from "../../domain/result.js";
import { recordDerivedEvidence } from "../recordDerivedEvidence.js";
import type { WorkflowUnknownInput } from "./types.js";
import { evaluateReconstructionClosure } from "../../domain/reconstructionCoverage.js";
import type { z } from "zod";
import type { reconstructionVerificationInputSchema } from "../../domain/reconstructionVerification.js";
import { AnalysisInputError } from "../../domain/errors.js";
/** Build the execution context passed to cross-version investigation. */
export const investigationContext = ({
session,
signal,
inputRoots,
integrityContinueEnabled,
progress,
}: {
readonly session: BinarySessionPort;
readonly signal: AbortSignal;
readonly inputRoots: readonly string[];
readonly integrityContinueEnabled: boolean;
readonly progress?: ProgressReporter;
}): {
readonly session: BinarySessionPort;
readonly signal: AbortSignal;
readonly inputRoots: readonly string[];
readonly integrityContinueEnabled: boolean;
readonly progress?: ProgressReporter;
} => ({
session,
signal,
inputRoots,
integrityContinueEnabled,
...(progress === undefined ? {} : { progress }),
});
export const isIncomplete = (status: string): boolean =>
status === "unknown" || status === "truncated";
export const comparisonClosure = (comparisons: readonly Evidence[]): string[] =>
uniqueIds(
comparisons.flatMap((evidence) => [
evidence.evidence_id,
...evidence.evidence_links,
]),
);
export const functionEvidenceIds = (
groups: readonly (Evidence | readonly Evidence[])[],
): string[] =>
uniqueIds(
groups.flatMap((group) =>
(Array.isArray(group) ? group : [group]).map(({ evidence_id: id }) => id),
),
);
export const uniqueIds = (ids: readonly string[]): string[] =>
[...new Set(ids)].sort((left, right) => left.localeCompare(right));
export const evidenceClosure = (
session: BinarySessionPort,
seedIds: readonly string[],
): Result<string[], EvidenceIntegrityError> => {
const records = new Map(
session
.exportEvidenceBundle()
.records.map((evidence) => [evidence.evidence_id, evidence]),
);
const visited = new Set<string>();
const pending = [...seedIds];
while (pending.length > 0) {
const evidenceId = pending.pop();
if (evidenceId === undefined || visited.has(evidenceId)) continue;
const evidence = records.get(evidenceId);
if (evidence === undefined)
return err(
new EvidenceIntegrityError(
"Investigation input has a dangling Evidence link",
),
);
visited.add(evidenceId);
pending.push(...evidence.evidence_links);
}
return ok(uniqueIds([...visited]));
};
/** Verify a retained coverage boundary before reconstruction evidence is derived. */
export const verifyCoverageReadiness = (
session: BinarySessionPort,
coverage: z.output<typeof reconstructionVerificationInputSchema>["coverage"],
): Result<null, AnalysisError> => {
if (coverage === undefined) return ok(null);
const workspace = session.reconstructionCoverageWorkspace(
coverage.workspace_id,
coverage.revision,
);
if (
workspace === undefined ||
workspace.revision_sha256 !== coverage.revision_sha256
)
return err(
new EvidenceIntegrityError(
"The requested reconstruction coverage revision is not retained by this session",
),
);
try {
const result = evaluateReconstructionClosure(
workspace,
coverage.boundary_id,
Date.now(),
);
return result.status === "ready"
? ok(null)
: err(
new EvidenceIntegrityError(
`Reconstruction coverage boundary is ${result.status}; readiness requires ready`,
),
);
} catch (cause: unknown) {
return err(new AnalysisInputError("verify_reconstruction", { cause }));
}
};
export const recordWorkflowEvidence = (
...[session, evidence, approved, unresolved, input]: readonly [
session: BinarySessionPort,
evidence: Evidence,
approved: true | undefined,
unresolved: boolean,
input: WorkflowUnknownInput,
]
): Result<Evidence, AnalysisError> => {
if (approved !== true || !unresolved) {
return recordDerivedEvidence(session, evidence, undefined);
}
return recordDerivedEvidence(session, evidence, {
approved: true,
question: input.question,
severity: "high",
domain: input.domain,
supporting_evidence_ids: [evidence.evidence_id],
contradicting_evidence_ids: [],
required_authority: input.requiredAuthority,
required_confidence: input.requiredConfidence,
required_environment: null,
recommended_probes: [...input.probes],
relationships: [],
});
};
@@ -0,0 +1,22 @@
import type { EvidenceFilePolicy } from "../../domain/evidenceBundle.js";
import type { PermissionAuthority } from "../../application/PermissionAuthority.js";
/** Policy inputs shared by all investigation workflow tools. */
export interface InvestigationToolPolicies {
readonly evidenceFiles: EvidenceFilePolicy;
readonly inputRoots: readonly string[];
readonly permissionAuthority?: PermissionAuthority;
readonly integrityContinueEnabled?: () => boolean;
}
/** Fields used to build an approved residual unknown from a workflow result. */
export interface WorkflowUnknownInput {
readonly question: string;
readonly domain: string;
readonly requiredAuthority: "shipped-artifact" | "controlled-replay" | null;
readonly requiredConfidence: "observed" | "derived";
readonly probes: readonly {
readonly operation: string;
readonly rationale: string;
}[];
}
+12 -439
View File
@@ -1,449 +1,22 @@
import type { McpServer } from "@modelcontextprotocol/server";
import type { BinarySessionPort } from "../application/BinarySession.js";
import { compareManagedMembersEvidenceValidated } from "../application/ManagedMemberComparisonService.js";
import { projectManagedApplicationGraphEvidence } from "../application/ManagedApplicationGraphService.js";
import { verifyManagedNativeBoundariesEvidence } from "../application/ManagedNativeVerificationService.js";
import { importManagedReconstructionEvidenceValidated } from "../application/ManagedReconstructionService.js";
import {
planManagedRuntimeCorrelationEvidenceValidated,
type ManagedRuntimeCorrelationDependencies,
} from "../application/ManagedRuntimeCorrelationService.js";
import {
compareManagedMembersReferenceInputSchema,
managedApplicationGraphReferenceInputSchema,
managedNativeVerificationReferenceInputSchema,
managedReconstructionReferenceInputSchema,
managedRuntimeCorrelationReferenceInputSchema,
MANAGED_WORKFLOW_TOOL_CONTRACTS,
} from "../contracts/managedWorkflowToolContracts.js";
import { managedMemberComparisonResultSchema } from "../domain/managedMemberComparison.js";
import { managedNativeVerificationResultSchema } from "../domain/managedNativeVerification.js";
import type { Evidence } from "../domain/evidence.js";
import type { EvidenceIntegrityError } from "../domain/errors.js";
import type { Result } from "../domain/result.js";
import type { Logger } from "../logger.js";
import { logToolExecution } from "./toolLogging.js";
import { toCallToolResult } from "./toolResult.js";
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
import { safeParseToolInput } from "./toolInputValidation.js";
import { resolveSessionEvidenceIds } from "./sessionEvidence.js";
import { registerCompareManagedMembers } from "./registerManagedWorkflowTools/compareManagedMembers.js";
import { registerVerifyManagedNativeBoundaries } from "./registerManagedWorkflowTools/verifyManagedNativeBoundaries.js";
import { registerImportManagedReconstruction } from "./registerManagedWorkflowTools/importManagedReconstruction.js";
import { registerPlanManagedRuntimeCorrelation } from "./registerManagedWorkflowTools/planManagedRuntimeCorrelation.js";
import { registerProjectManagedApplicationGraph } from "./registerManagedWorkflowTools/projectManagedApplicationGraph.js";
import type { ManagedWorkflowToolRegistration } from "./registerManagedWorkflowTools/types.js";
interface ManagedWorkflowToolRegistration {
readonly logger: Logger;
readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined;
readonly recordEvidenceWithUnknown:
| BinarySessionPort["recordEvidenceWithUnknown"]
| undefined;
readonly runtime: ManagedRuntimeCorrelationDependencies;
readonly session: BinarySessionPort;
}
export type { ManagedWorkflowToolRegistration };
/** Register provider-neutral managed-code workflows. */
export const registerManagedWorkflowTools = (
server: McpServer,
options: ManagedWorkflowToolRegistration,
): void => {
const compareContract = contract("compare_managed_members");
const nativeVerificationContract = contract(
"verify_managed_native_boundaries",
);
const reconstructionContract = contract("import_managed_reconstruction");
const runtimeContract = contract("plan_managed_runtime_correlation");
const graphContract = contract("project_managed_application_graph");
server.registerTool(
compareContract.name,
toolRegistrationOptions(compareContract),
async (input) => {
const parsedInput = safeParseToolInput(
compareManagedMembersReferenceInputSchema,
input,
compareContract.name,
);
if (!parsedInput.ok)
return toCallToolResult(parsedInput, compareContract);
const resolved = resolveManagedEvidence(options.session, [
parsedInput.value.left_evidence_id,
parsedInput.value.right_evidence_id,
]);
if (!resolved.ok) return toCallToolResult(resolved, compareContract);
const [left, right] = resolved.value;
if (left === undefined || right === undefined)
throw new TypeError("Managed comparison Evidence resolution failed");
const {
left_evidence_id: _leftEvidenceId,
right_evidence_id: _rightEvidenceId,
...referencedInput
} = parsedInput.value;
const parsed = { ...referencedInput, left, right };
const result = await logToolExecution(
options.logger,
compareContract.name,
() => Promise.resolve(compareManagedMembersEvidenceValidated(parsed)),
);
if (!result.ok) return toCallToolResult(result, compareContract);
const recorded = recordSources(options.recordEvidence, [
parsed.left,
parsed.right,
]);
if (!recorded.ok) return toCallToolResult(recorded, compareContract);
const comparison = managedMemberComparisonResultSchema.parse(
result.value.normalized_result,
);
const unknown = comparison.summary.unknown > 0;
const output =
parsed.unknown_registry_approved === true && unknown
? options.recordEvidenceWithUnknown?.(result.value, {
approved: true,
question:
"Which managed members remain unmatched or ambiguous across these versions?",
severity: "medium",
domain: "managed-member-comparison",
supporting_evidence_ids: [result.value.evidence_id],
contradicting_evidence_ids: [],
required_authority: "shipped-artifact",
required_confidence: "observed",
required_environment: null,
recommended_probes: [
{
operation: "inspect_managed_members",
rationale:
"Repeat static member inspection with complete pages and method bodies.",
},
],
relationships: [],
})
: options.recordEvidence?.(result.value);
if (output !== undefined && !output.ok)
return toCallToolResult(output, compareContract);
return toCallToolResult(
{ ok: true, value: result.value },
compareContract,
{
evidenceResourcesAvailable: output !== undefined,
},
);
},
);
server.registerTool(
nativeVerificationContract.name,
toolRegistrationOptions(nativeVerificationContract),
async (input) => {
const parsedInput = safeParseToolInput(
managedNativeVerificationReferenceInputSchema,
input,
nativeVerificationContract.name,
);
if (!parsedInput.ok)
return toCallToolResult(parsedInput, nativeVerificationContract);
const managedBoundaries = resolveManagedBoundaryEvidence(
options.session,
parsedInput.value.managed_boundaries_evidence_id,
);
if (!managedBoundaries.ok)
return toCallToolResult(managedBoundaries, nativeVerificationContract);
const nativeObservations = resolveNativeEvidence(
options.session,
parsedInput.value.native_observation_evidence_ids,
);
if (!nativeObservations.ok)
return toCallToolResult(nativeObservations, nativeVerificationContract);
const managedBoundary = managedBoundaries.value[0];
if (managedBoundary === undefined)
throw new TypeError("Managed boundary Evidence resolution failed");
const {
managed_boundaries_evidence_id: _managedBoundariesEvidenceId,
native_observation_evidence_ids: _nativeObservationEvidenceIds,
...referencedInput
} = parsedInput.value;
const parsed = {
...referencedInput,
managed_boundaries: managedBoundary,
native_observations: nativeObservations.value,
};
const result = await logToolExecution(
options.logger,
nativeVerificationContract.name,
() => Promise.resolve(verifyManagedNativeBoundariesEvidence(parsed)),
);
if (!result.ok)
return toCallToolResult(result, nativeVerificationContract);
const recordedSources = recordSources(options.recordEvidence, [
parsed.managed_boundaries,
...parsed.native_observations,
]);
if (!recordedSources.ok)
return toCallToolResult(recordedSources, nativeVerificationContract);
const verification = managedNativeVerificationResultSchema.parse(
result.value.normalized_result,
);
const unknown =
verification.summary.unresolved > 0 ||
verification.summary.contradicted > 0 ||
verification.summary.native_body_unresolved > 0;
const output =
parsed.unknown_registry_approved === true && unknown
? options.recordEvidenceWithUnknown?.(result.value, {
approved: true,
question:
"Which managed/native boundaries remain unresolved or contradicted by the supplied native Evidence?",
severity: "medium",
domain: "managed-native-verification",
supporting_evidence_ids: [result.value.evidence_id],
contradicting_evidence_ids: [],
required_authority: "shipped-artifact",
required_confidence: "observed",
required_environment: null,
recommended_probes: [
{
operation: "inspect_managed_native_boundaries",
rationale:
"Repeat managed boundary inspection with complete ModuleRef, ImplMap, and native implementation pages.",
},
{
operation: "analyze_function",
rationale:
"Analyze the provider-resolved native function candidate for the declared export.",
},
],
relationships: [],
})
: options.recordEvidence?.(result.value);
if (output !== undefined && !output.ok)
return toCallToolResult(output, nativeVerificationContract);
return toCallToolResult(result, nativeVerificationContract, {
evidenceResourcesAvailable: output !== undefined,
});
},
);
server.registerTool(
reconstructionContract.name,
toolRegistrationOptions(reconstructionContract),
async (input) => {
const parsedInput = safeParseToolInput(
managedReconstructionReferenceInputSchema,
input,
reconstructionContract.name,
);
if (!parsedInput.ok)
return toCallToolResult(parsedInput, reconstructionContract);
const resolved = resolveManagedEvidence(options.session, [
parsedInput.value.static_members_evidence_id,
]);
if (!resolved.ok)
return toCallToolResult(resolved, reconstructionContract);
const staticMembers = resolved.value[0];
if (staticMembers === undefined)
throw new TypeError(
"Managed reconstruction Evidence resolution failed",
);
const {
static_members_evidence_id: _staticMembersEvidenceId,
...referencedInput
} = parsedInput.value;
const parsed = { ...referencedInput, static_members: staticMembers };
const result = await logToolExecution(
options.logger,
reconstructionContract.name,
() =>
Promise.resolve(importManagedReconstructionEvidenceValidated(parsed)),
);
if (!result.ok) return toCallToolResult(result, reconstructionContract);
const recordedSource = options.recordEvidence?.(parsed.static_members);
if (recordedSource !== undefined && !recordedSource.ok)
return toCallToolResult(recordedSource, reconstructionContract);
const recorded = options.recordEvidence?.(result.value);
if (recorded !== undefined && !recorded.ok)
return toCallToolResult(recorded, reconstructionContract);
return toCallToolResult(result, reconstructionContract, {
evidenceResourcesAvailable: recorded !== undefined,
});
},
);
server.registerTool(
runtimeContract.name,
toolRegistrationOptions(runtimeContract),
async (input) => {
const parsedInput = safeParseToolInput(
managedRuntimeCorrelationReferenceInputSchema,
input,
runtimeContract.name,
);
if (!parsedInput.ok)
return toCallToolResult(parsedInput, runtimeContract);
const resolved = resolveManagedEvidence(options.session, [
parsedInput.value.static_members_evidence_id,
]);
if (!resolved.ok) return toCallToolResult(resolved, runtimeContract);
const staticMembers = resolved.value[0];
if (staticMembers === undefined)
throw new TypeError("Managed runtime Evidence resolution failed");
const {
static_members_evidence_id: _staticMembersEvidenceId,
...referencedInput
} = parsedInput.value;
const parsed = { ...referencedInput, static_members: staticMembers };
const result = await logToolExecution(
options.logger,
runtimeContract.name,
() =>
planManagedRuntimeCorrelationEvidenceValidated(
options.runtime,
parsed,
),
);
if (!result.ok) return toCallToolResult(result, runtimeContract);
const recordedSource = options.recordEvidence?.(parsed.static_members);
if (recordedSource !== undefined && !recordedSource.ok)
return toCallToolResult(recordedSource, runtimeContract);
const recorded = options.recordEvidence?.(result.value);
if (recorded !== undefined && !recorded.ok)
return toCallToolResult(recorded, runtimeContract);
return toCallToolResult(result, runtimeContract, {
evidenceResourcesAvailable: recorded !== undefined,
});
},
);
server.registerTool(
graphContract.name,
toolRegistrationOptions(graphContract),
async (input) => {
const parsedInput = safeParseToolInput(
managedApplicationGraphReferenceInputSchema,
input,
graphContract.name,
);
if (!parsedInput.ok) return toCallToolResult(parsedInput, graphContract);
const managedArtifact =
parsedInput.value.managed_artifact_evidence_id === undefined
? undefined
: resolveManagedArtifactEvidence(
options.session,
parsedInput.value.managed_artifact_evidence_id,
);
if (managedArtifact !== undefined && !managedArtifact.ok)
return toCallToolResult(managedArtifact, graphContract);
const managedMembers =
parsedInput.value.managed_members_evidence_id === undefined
? undefined
: resolveManagedEvidence(options.session, [
parsedInput.value.managed_members_evidence_id,
]);
if (managedMembers !== undefined && !managedMembers.ok)
return toCallToolResult(managedMembers, graphContract);
const managedBoundaries =
parsedInput.value.managed_native_boundaries_evidence_id === undefined
? undefined
: resolveManagedBoundaryEvidence(
options.session,
parsedInput.value.managed_native_boundaries_evidence_id,
);
if (managedBoundaries !== undefined && !managedBoundaries.ok)
return toCallToolResult(managedBoundaries, graphContract);
const parsed = {
limits: parsedInput.value.limits,
managed_artifact: managedArtifact?.value[0],
managed_members: managedMembers?.value[0],
managed_native_boundaries: managedBoundaries?.value[0],
};
const result = await logToolExecution(
options.logger,
graphContract.name,
() => Promise.resolve(projectManagedApplicationGraphEvidence(parsed)),
);
if (!result.ok) return toCallToolResult(result, graphContract);
const recordedSources = recordSources(
options.recordEvidence,
sourceEvidence(parsed),
);
if (!recordedSources.ok)
return toCallToolResult(recordedSources, graphContract);
const recorded = options.recordEvidence?.(result.value);
if (recorded !== undefined && !recorded.ok)
return toCallToolResult(recorded, graphContract);
return toCallToolResult(result, graphContract, {
evidenceResourcesAvailable: recorded !== undefined,
});
},
);
registerCompareManagedMembers(server, options);
registerVerifyManagedNativeBoundaries(server, options);
registerImportManagedReconstruction(server, options);
registerPlanManagedRuntimeCorrelation(server, options);
registerProjectManagedApplicationGraph(server, options);
};
const contract = (
name: (typeof MANAGED_WORKFLOW_TOOL_CONTRACTS)[number]["name"],
) => {
const found = MANAGED_WORKFLOW_TOOL_CONTRACTS.find(
(candidate) => candidate.name === name,
);
if (found === undefined) throw new Error(`Missing ${name} contract`);
return found;
};
const resolveManagedEvidence = (
session: BinarySessionPort,
evidenceIds: readonly string[],
): Result<Evidence[], EvidenceIntegrityError> =>
resolveSessionEvidenceIds(session, evidenceIds, {
operation: "inspect_managed_members",
predicate: "rea.analysis/v2",
});
const resolveManagedArtifactEvidence = (
session: BinarySessionPort,
evidenceId: string,
): Result<Evidence[], EvidenceIntegrityError> =>
resolveSessionEvidenceIds(session, [evidenceId], {
operation: "inspect_managed_artifact",
predicate: "rea.analysis/v2",
});
const resolveManagedBoundaryEvidence = (
session: BinarySessionPort,
evidenceId: string,
): Result<Evidence[], EvidenceIntegrityError> =>
resolveSessionEvidenceIds(session, [evidenceId], {
operation: "inspect_managed_native_boundaries",
predicate: "rea.analysis/v2",
});
const resolveNativeEvidence = (
session: BinarySessionPort,
evidenceIds: readonly string[],
): Result<Evidence[], EvidenceIntegrityError> => {
const records: Evidence[] = [];
for (const evidenceId of evidenceIds) {
const operation = session.evidenceById(evidenceId)?.operation;
const expectedOperation =
operation === "inspect_macho" || operation === "analyze_function"
? operation
: "inspect_macho or analyze_function";
const resolved = resolveSessionEvidenceIds(session, [evidenceId], {
operation: expectedOperation,
predicate: "rea.analysis/v2",
});
if (!resolved.ok) return resolved;
records.push(...resolved.value);
}
return { ok: true, value: records };
};
const recordSources = (
recordEvidence: ManagedWorkflowToolRegistration["recordEvidence"],
sources: readonly Evidence[],
) => {
for (const source of sources) {
const recorded = recordEvidence?.(source);
if (recorded !== undefined && !recorded.ok) return recorded;
}
return { ok: true as const, value: null };
};
const sourceEvidence = (input: {
readonly managed_artifact?: Evidence | undefined;
readonly managed_members?: Evidence | undefined;
readonly managed_native_boundaries?: Evidence | undefined;
}): Evidence[] =>
[
input.managed_artifact,
input.managed_members,
input.managed_native_boundaries,
].filter((evidence): evidence is Evidence => evidence !== undefined);
@@ -0,0 +1,95 @@
import type { McpServer } from "@modelcontextprotocol/server";
import { compareManagedMembersEvidenceValidated } from "../../application/ManagedMemberComparisonService.js";
import { compareManagedMembersReferenceInputSchema } from "../../contracts/managedWorkflowToolContracts.js";
import { managedMemberComparisonResultSchema } from "../../domain/managedMemberComparison.js";
import { logToolExecution } from "../toolLogging.js";
import { toCallToolResult } from "../toolResult.js";
import { toolRegistrationOptions } from "../toolRegistrationOptions.js";
import { safeParseToolInput } from "../toolInputValidation.js";
import { managedWorkflowContract } from "./contract.js";
import { recordManagedSources, resolveManagedEvidence } from "./evidence.js";
import type { ManagedWorkflowToolRegistration } from "./types.js";
const compareContract = managedWorkflowContract("compare_managed_members");
/** Register the managed member comparison workflow tool. */
export const registerCompareManagedMembers = (
server: McpServer,
options: ManagedWorkflowToolRegistration,
): void => {
server.registerTool(
compareContract.name,
toolRegistrationOptions(compareContract),
async (input) => {
const parsedInput = safeParseToolInput(
compareManagedMembersReferenceInputSchema,
input,
compareContract.name,
);
if (!parsedInput.ok)
return toCallToolResult(parsedInput, compareContract);
const resolved = resolveManagedEvidence(options.session, [
parsedInput.value.left_evidence_id,
parsedInput.value.right_evidence_id,
]);
if (!resolved.ok) return toCallToolResult(resolved, compareContract);
const [left, right] = resolved.value;
if (left === undefined || right === undefined)
throw new TypeError("Managed comparison Evidence resolution failed");
const {
left_evidence_id: _leftEvidenceId,
right_evidence_id: _rightEvidenceId,
...referencedInput
} = parsedInput.value;
const parsed = { ...referencedInput, left, right };
const result = await logToolExecution(
options.logger,
compareContract.name,
() => Promise.resolve(compareManagedMembersEvidenceValidated(parsed)),
);
if (!result.ok) return toCallToolResult(result, compareContract);
const recorded = recordManagedSources(options.recordEvidence, [
parsed.left,
parsed.right,
]);
if (!recorded.ok) return toCallToolResult(recorded, compareContract);
const comparison = managedMemberComparisonResultSchema.parse(
result.value.normalized_result,
);
const unknown = comparison.summary.unknown > 0;
const output =
parsed.unknown_registry_approved === true && unknown
? options.recordEvidenceWithUnknown?.(result.value, {
approved: true,
question:
"Which managed members remain unmatched or ambiguous across these versions?",
severity: "medium",
domain: "managed-member-comparison",
supporting_evidence_ids: [result.value.evidence_id],
contradicting_evidence_ids: [],
required_authority: "shipped-artifact",
required_confidence: "observed",
required_environment: null,
recommended_probes: [
{
operation: "inspect_managed_members",
rationale:
"Repeat static member inspection with complete pages and method bodies.",
},
],
relationships: [],
})
: options.recordEvidence?.(result.value);
if (output !== undefined && !output.ok)
return toCallToolResult(output, compareContract);
return toCallToolResult(
{ ok: true, value: result.value },
compareContract,
{
evidenceResourcesAvailable: output !== undefined,
},
);
},
);
};
@@ -0,0 +1,12 @@
import { MANAGED_WORKFLOW_TOOL_CONTRACTS } from "../../contracts/managedWorkflowToolContracts.js";
/** Locate one managed workflow tool contract by exact name. */
export const managedWorkflowContract = (
name: (typeof MANAGED_WORKFLOW_TOOL_CONTRACTS)[number]["name"],
) => {
const found = MANAGED_WORKFLOW_TOOL_CONTRACTS.find(
(candidate) => candidate.name === name,
);
if (found === undefined) throw new Error(`Missing ${name} contract`);
return found;
};
@@ -0,0 +1,76 @@
import type { BinarySessionPort } from "../../application/BinarySession.js";
import type { Evidence } from "../../domain/evidence.js";
import type { EvidenceIntegrityError } from "../../domain/errors.js";
import type { Result } from "../../domain/result.js";
import { resolveSessionEvidenceIds } from "../sessionEvidence.js";
import type { ManagedWorkflowToolRegistration } from "./types.js";
export const resolveManagedEvidence = (
session: BinarySessionPort,
evidenceIds: readonly string[],
): Result<Evidence[], EvidenceIntegrityError> =>
resolveSessionEvidenceIds(session, evidenceIds, {
operation: "inspect_managed_members",
predicate: "rea.analysis/v2",
});
export const resolveManagedArtifactEvidence = (
session: BinarySessionPort,
evidenceId: string,
): Result<Evidence[], EvidenceIntegrityError> =>
resolveSessionEvidenceIds(session, [evidenceId], {
operation: "inspect_managed_artifact",
predicate: "rea.analysis/v2",
});
export const resolveManagedBoundaryEvidence = (
session: BinarySessionPort,
evidenceId: string,
): Result<Evidence[], EvidenceIntegrityError> =>
resolveSessionEvidenceIds(session, [evidenceId], {
operation: "inspect_managed_native_boundaries",
predicate: "rea.analysis/v2",
});
export const resolveNativeEvidence = (
session: BinarySessionPort,
evidenceIds: readonly string[],
): Result<Evidence[], EvidenceIntegrityError> => {
const records: Evidence[] = [];
for (const evidenceId of evidenceIds) {
const operation = session.evidenceById(evidenceId)?.operation;
const expectedOperation =
operation === "inspect_macho" || operation === "analyze_function"
? operation
: "inspect_macho or analyze_function";
const resolved = resolveSessionEvidenceIds(session, [evidenceId], {
operation: expectedOperation,
predicate: "rea.analysis/v2",
});
if (!resolved.ok) return resolved;
records.push(...resolved.value);
}
return { ok: true, value: records };
};
export const recordManagedSources = (
recordEvidence: ManagedWorkflowToolRegistration["recordEvidence"],
sources: readonly Evidence[],
) => {
for (const source of sources) {
const recorded = recordEvidence?.(source);
if (recorded !== undefined && !recorded.ok) return recorded;
}
return { ok: true as const, value: null };
};
export const sourceEvidence = (input: {
readonly managed_artifact?: Evidence | undefined;
readonly managed_members?: Evidence | undefined;
readonly managed_native_boundaries?: Evidence | undefined;
}): Evidence[] =>
[
input.managed_artifact,
input.managed_members,
input.managed_native_boundaries,
].filter((evidence): evidence is Evidence => evidence !== undefined);
@@ -0,0 +1,66 @@
import type { McpServer } from "@modelcontextprotocol/server";
import { importManagedReconstructionEvidenceValidated } from "../../application/ManagedReconstructionService.js";
import { managedReconstructionReferenceInputSchema } from "../../contracts/managedWorkflowToolContracts.js";
import { logToolExecution } from "../toolLogging.js";
import { toCallToolResult } from "../toolResult.js";
import { toolRegistrationOptions } from "../toolRegistrationOptions.js";
import { safeParseToolInput } from "../toolInputValidation.js";
import { managedWorkflowContract } from "./contract.js";
import { resolveManagedEvidence } from "./evidence.js";
import type { ManagedWorkflowToolRegistration } from "./types.js";
const reconstructionContract = managedWorkflowContract(
"import_managed_reconstruction",
);
/** Register the managed reconstruction import workflow tool. */
export const registerImportManagedReconstruction = (
server: McpServer,
options: ManagedWorkflowToolRegistration,
): void => {
server.registerTool(
reconstructionContract.name,
toolRegistrationOptions(reconstructionContract),
async (input) => {
const parsedInput = safeParseToolInput(
managedReconstructionReferenceInputSchema,
input,
reconstructionContract.name,
);
if (!parsedInput.ok)
return toCallToolResult(parsedInput, reconstructionContract);
const resolved = resolveManagedEvidence(options.session, [
parsedInput.value.static_members_evidence_id,
]);
if (!resolved.ok)
return toCallToolResult(resolved, reconstructionContract);
const staticMembers = resolved.value[0];
if (staticMembers === undefined)
throw new TypeError(
"Managed reconstruction Evidence resolution failed",
);
const {
static_members_evidence_id: _staticMembersEvidenceId,
...referencedInput
} = parsedInput.value;
const parsed = { ...referencedInput, static_members: staticMembers };
const result = await logToolExecution(
options.logger,
reconstructionContract.name,
() =>
Promise.resolve(importManagedReconstructionEvidenceValidated(parsed)),
);
if (!result.ok) return toCallToolResult(result, reconstructionContract);
const recordedSource = options.recordEvidence?.(parsed.static_members);
if (recordedSource !== undefined && !recordedSource.ok)
return toCallToolResult(recordedSource, reconstructionContract);
const recorded = options.recordEvidence?.(result.value);
if (recorded !== undefined && !recorded.ok)
return toCallToolResult(recorded, reconstructionContract);
return toCallToolResult(result, reconstructionContract, {
evidenceResourcesAvailable: recorded !== undefined,
});
},
);
};
@@ -0,0 +1,68 @@
import type { McpServer } from "@modelcontextprotocol/server";
import { planManagedRuntimeCorrelationEvidenceValidated } from "../../application/ManagedRuntimeCorrelationService.js";
import { managedRuntimeCorrelationReferenceInputSchema } from "../../contracts/managedWorkflowToolContracts.js";
import { logToolExecution } from "../toolLogging.js";
import { toCallToolResult } from "../toolResult.js";
import { toolRegistrationOptions } from "../toolRegistrationOptions.js";
import { safeParseToolInput } from "../toolInputValidation.js";
import { managedWorkflowContract } from "./contract.js";
import { resolveManagedEvidence } from "./evidence.js";
import type { ManagedWorkflowToolRegistration } from "./types.js";
const runtimeContract = managedWorkflowContract(
"plan_managed_runtime_correlation",
);
/** Register the managed runtime correlation planning workflow tool. */
export const registerPlanManagedRuntimeCorrelation = (
server: McpServer,
options: ManagedWorkflowToolRegistration,
): void => {
server.registerTool(
runtimeContract.name,
toolRegistrationOptions(runtimeContract),
async (input) => {
const parsedInput = safeParseToolInput(
managedRuntimeCorrelationReferenceInputSchema,
input,
runtimeContract.name,
);
if (!parsedInput.ok)
return toCallToolResult(parsedInput, runtimeContract);
const resolved = resolveManagedEvidence(options.session, [
parsedInput.value.static_members_evidence_id,
]);
if (!resolved.ok) return toCallToolResult(resolved, runtimeContract);
const staticMembers = resolved.value[0];
if (staticMembers === undefined)
throw new TypeError("Managed runtime Evidence resolution failed");
const {
static_members_evidence_id: _staticMembersEvidenceId,
...referencedInput
} = parsedInput.value;
const parsed = { ...referencedInput, static_members: staticMembers };
const result = await logToolExecution(
options.logger,
runtimeContract.name,
() =>
Promise.resolve(
planManagedRuntimeCorrelationEvidenceValidated(
options.runtime,
parsed,
),
),
);
if (!result.ok) return toCallToolResult(result, runtimeContract);
const recordedSource = options.recordEvidence?.(parsed.static_members);
if (recordedSource !== undefined && !recordedSource.ok)
return toCallToolResult(recordedSource, runtimeContract);
const recorded = options.recordEvidence?.(result.value);
if (recorded !== undefined && !recorded.ok)
return toCallToolResult(recorded, runtimeContract);
return toCallToolResult(result, runtimeContract, {
evidenceResourcesAvailable: recorded !== undefined,
});
},
);
};
@@ -0,0 +1,90 @@
import type { McpServer } from "@modelcontextprotocol/server";
import { projectManagedApplicationGraphEvidence } from "../../application/ManagedApplicationGraphService.js";
import { managedApplicationGraphReferenceInputSchema } from "../../contracts/managedWorkflowToolContracts.js";
import { logToolExecution } from "../toolLogging.js";
import { toCallToolResult } from "../toolResult.js";
import { toolRegistrationOptions } from "../toolRegistrationOptions.js";
import { safeParseToolInput } from "../toolInputValidation.js";
import { managedWorkflowContract } from "./contract.js";
import {
recordManagedSources,
resolveManagedArtifactEvidence,
resolveManagedBoundaryEvidence,
resolveManagedEvidence,
sourceEvidence,
} from "./evidence.js";
import type { ManagedWorkflowToolRegistration } from "./types.js";
const graphContract = managedWorkflowContract(
"project_managed_application_graph",
);
/** Register the managed application graph projection workflow tool. */
export const registerProjectManagedApplicationGraph = (
server: McpServer,
options: ManagedWorkflowToolRegistration,
): void => {
server.registerTool(
graphContract.name,
toolRegistrationOptions(graphContract),
async (input) => {
const parsedInput = safeParseToolInput(
managedApplicationGraphReferenceInputSchema,
input,
graphContract.name,
);
if (!parsedInput.ok) return toCallToolResult(parsedInput, graphContract);
const managedArtifact =
parsedInput.value.managed_artifact_evidence_id === undefined
? undefined
: resolveManagedArtifactEvidence(
options.session,
parsedInput.value.managed_artifact_evidence_id,
);
if (managedArtifact !== undefined && !managedArtifact.ok)
return toCallToolResult(managedArtifact, graphContract);
const managedMembers =
parsedInput.value.managed_members_evidence_id === undefined
? undefined
: resolveManagedEvidence(options.session, [
parsedInput.value.managed_members_evidence_id,
]);
if (managedMembers !== undefined && !managedMembers.ok)
return toCallToolResult(managedMembers, graphContract);
const managedBoundaries =
parsedInput.value.managed_native_boundaries_evidence_id === undefined
? undefined
: resolveManagedBoundaryEvidence(
options.session,
parsedInput.value.managed_native_boundaries_evidence_id,
);
if (managedBoundaries !== undefined && !managedBoundaries.ok)
return toCallToolResult(managedBoundaries, graphContract);
const parsed = {
limits: parsedInput.value.limits,
managed_artifact: managedArtifact?.value[0],
managed_members: managedMembers?.value[0],
managed_native_boundaries: managedBoundaries?.value[0],
};
const result = await logToolExecution(
options.logger,
graphContract.name,
() => Promise.resolve(projectManagedApplicationGraphEvidence(parsed)),
);
if (!result.ok) return toCallToolResult(result, graphContract);
const recordedSources = recordManagedSources(
options.recordEvidence,
sourceEvidence(parsed),
);
if (!recordedSources.ok)
return toCallToolResult(recordedSources, graphContract);
const recorded = options.recordEvidence?.(result.value);
if (recorded !== undefined && !recorded.ok)
return toCallToolResult(recorded, graphContract);
return toCallToolResult(result, graphContract, {
evidenceResourcesAvailable: recorded !== undefined,
});
},
);
};
@@ -0,0 +1,14 @@
import type { BinarySessionPort } from "../../application/BinarySession.js";
import type { ManagedRuntimeCorrelationDependencies } from "../../application/ManagedRuntimeCorrelationService.js";
import type { Logger } from "../../logger.js";
/** Shared services for registering managed-code workflow tools. */
export interface ManagedWorkflowToolRegistration {
readonly logger: Logger;
readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined;
readonly recordEvidenceWithUnknown:
| BinarySessionPort["recordEvidenceWithUnknown"]
| undefined;
readonly runtime: ManagedRuntimeCorrelationDependencies;
readonly session: BinarySessionPort;
}
@@ -0,0 +1,118 @@
import type { McpServer } from "@modelcontextprotocol/server";
import { verifyManagedNativeBoundariesEvidence } from "../../application/ManagedNativeVerificationService.js";
import { managedNativeVerificationReferenceInputSchema } from "../../contracts/managedWorkflowToolContracts.js";
import { managedNativeVerificationResultSchema } from "../../domain/managedNativeVerification.js";
import { logToolExecution } from "../toolLogging.js";
import { toCallToolResult } from "../toolResult.js";
import { toolRegistrationOptions } from "../toolRegistrationOptions.js";
import { safeParseToolInput } from "../toolInputValidation.js";
import { managedWorkflowContract } from "./contract.js";
import {
recordManagedSources,
resolveManagedBoundaryEvidence,
resolveNativeEvidence,
} from "./evidence.js";
import type { ManagedWorkflowToolRegistration } from "./types.js";
const nativeVerificationContract = managedWorkflowContract(
"verify_managed_native_boundaries",
);
/** Register the managed/native boundary verification workflow tool. */
export const registerVerifyManagedNativeBoundaries = (
server: McpServer,
options: ManagedWorkflowToolRegistration,
): void => {
server.registerTool(
nativeVerificationContract.name,
toolRegistrationOptions(nativeVerificationContract),
async (input) => {
const parsedInput = safeParseToolInput(
managedNativeVerificationReferenceInputSchema,
input,
nativeVerificationContract.name,
);
if (!parsedInput.ok)
return toCallToolResult(parsedInput, nativeVerificationContract);
const managedBoundaries = resolveManagedBoundaryEvidence(
options.session,
parsedInput.value.managed_boundaries_evidence_id,
);
if (!managedBoundaries.ok)
return toCallToolResult(managedBoundaries, nativeVerificationContract);
const nativeObservations = resolveNativeEvidence(
options.session,
parsedInput.value.native_observation_evidence_ids,
);
if (!nativeObservations.ok)
return toCallToolResult(nativeObservations, nativeVerificationContract);
const managedBoundary = managedBoundaries.value[0];
if (managedBoundary === undefined)
throw new TypeError("Managed boundary Evidence resolution failed");
const {
managed_boundaries_evidence_id: _managedBoundariesEvidenceId,
native_observation_evidence_ids: _nativeObservationEvidenceIds,
...referencedInput
} = parsedInput.value;
const parsed = {
...referencedInput,
managed_boundaries: managedBoundary,
native_observations: nativeObservations.value,
};
const result = await logToolExecution(
options.logger,
nativeVerificationContract.name,
() => Promise.resolve(verifyManagedNativeBoundariesEvidence(parsed)),
);
if (!result.ok)
return toCallToolResult(result, nativeVerificationContract);
const recordedSources = recordManagedSources(options.recordEvidence, [
parsed.managed_boundaries,
...parsed.native_observations,
]);
if (!recordedSources.ok)
return toCallToolResult(recordedSources, nativeVerificationContract);
const verification = managedNativeVerificationResultSchema.parse(
result.value.normalized_result,
);
const unknown =
verification.summary.unresolved > 0 ||
verification.summary.contradicted > 0 ||
verification.summary.native_body_unresolved > 0;
const output =
parsed.unknown_registry_approved === true && unknown
? options.recordEvidenceWithUnknown?.(result.value, {
approved: true,
question:
"Which managed/native boundaries remain unresolved or contradicted by the supplied native Evidence?",
severity: "medium",
domain: "managed-native-verification",
supporting_evidence_ids: [result.value.evidence_id],
contradicting_evidence_ids: [],
required_authority: "shipped-artifact",
required_confidence: "observed",
required_environment: null,
recommended_probes: [
{
operation: "inspect_managed_native_boundaries",
rationale:
"Repeat managed boundary inspection with complete ModuleRef, ImplMap, and native implementation pages.",
},
{
operation: "analyze_function",
rationale:
"Analyze the provider-resolved native function candidate for the declared export.",
},
],
relationships: [],
})
: options.recordEvidence?.(result.value);
if (output !== undefined && !output.ok)
return toCallToolResult(output, nativeVerificationContract);
return toCallToolResult(result, nativeVerificationContract, {
evidenceResourcesAvailable: output !== undefined,
});
},
);
};
+119 -78
View File
@@ -1,6 +1,7 @@
import type { McpServer } from "@modelcontextprotocol/server";
import type { AnalysisOperationPort } from "../application/AnalysisProvider.js";
import type { AnalysisExecution } from "../application/AnalysisProvider.js";
import type { BinarySessionPort } from "../application/BinarySession.js";
import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/toolContracts.js";
import {
@@ -15,11 +16,15 @@ import type { BinaryTarget } from "../domain/binaryTarget.js";
import { createEvidence } from "../domain/evidence.js";
import {
AnalysisCapabilityUnavailableError,
type AnalysisError,
UnknownRegistryError,
} from "../domain/errors.js";
import { mcpProgressReporter } from "./mcpProgress.js";
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
import { safeParseToolInput } from "./toolInputValidation.js";
import type { ToolContract } from "../contracts/toolContracts.js";
import type { Result } from "../domain/result.js";
import type { ProgressReporter } from "../application/ProgressReporter.js";
/** Optional session services used by direct tool registration. */
export interface OfficialToolRegistration {
@@ -68,92 +73,128 @@ const registerOfficialTool = (
if (!parsedInput.ok) return toCallToolResult(parsedInput, contract);
const arguments_ = projectOfficialArguments(contract, parsedInput.value);
const progress = mcpProgressReporter(context);
await progress.report({
phase: contract.name,
completed: 0,
total: 1,
message: "started",
});
const result = await logToolExecution(
registration.logger,
contract.name,
() =>
analysis.execute(contract.name, arguments_, {
signal: context.mcpReq.signal,
progress,
}),
const result = await runOfficialOperation(
analysis,
contract,
arguments_,
{
logger: registration.logger,
signal: context.mcpReq.signal,
progress,
},
);
await progress.report({
phase: contract.name,
completed: 1,
total: 1,
message: result.ok ? "completed" : "failed",
terminal: true,
});
if (result.ok) {
const evidence = createEvidence(
result.value.subject ?? registration.activeTarget?.(),
result.value.provider,
{
operation: contract.name,
parameters: arguments_,
result: result.value.result,
...(result.value.analysisProfile === undefined
? {}
: { analysisProfile: result.value.analysisProfile }),
rawResult: result.value.rawResult,
limitations: result.value.limitations,
locations: result.value.locations,
},
if (!result.ok) {
const unknownError = recordOfficialUnknown(
input,
result.error,
contract,
registration.recordUnknown,
);
const recorded = registration.recordEvidence?.(evidence);
if (recorded !== undefined && !recorded.ok)
return toCallToolResult(recorded, contract);
return toCallToolResult({ ok: true, value: evidence }, contract, {
evidenceResourcesAvailable: recorded !== undefined,
});
if (unknownError !== undefined)
return toCallToolResult(unknownError, contract);
return toCallToolResult(result, contract);
}
if (
result.error instanceof AnalysisCapabilityUnavailableError &&
approvedUnknownTracking(input) &&
registration.recordUnknown !== undefined
) {
const unknown = registration.recordUnknown({
approved: true,
question:
"The requested analysis is unavailable for the current target.",
severity: "medium",
domain: "analysis-capability",
supporting_evidence_ids: [],
contradicting_evidence_ids: [],
required_authority: "shipped-artifact",
required_confidence: "observed",
required_environment: null,
recommended_probes: [
{
operation: contract.name,
rationale:
"Choose another analysis or target that can answer this question.",
},
],
relationships: [],
});
if (
!unknown.ok &&
!(
unknown.error instanceof UnknownRegistryError &&
unknown.error.reason === "already-exists"
)
)
return toCallToolResult(unknown, contract);
}
return toCallToolResult(result, contract);
const evidence = createEvidence(
result.value.subject ?? registration.activeTarget?.(),
result.value.provider,
{
operation: contract.name,
parameters: arguments_,
result: result.value.result,
...(result.value.analysisProfile === undefined
? {}
: { analysisProfile: result.value.analysisProfile }),
rawResult: result.value.rawResult,
limitations: result.value.limitations,
locations: result.value.locations,
},
);
const recorded = registration.recordEvidence?.(evidence);
if (recorded !== undefined && !recorded.ok)
return toCallToolResult(recorded, contract);
return toCallToolResult({ ok: true, value: evidence }, contract, {
evidenceResourcesAvailable: recorded !== undefined,
});
},
);
};
const projectOfficialArguments = (
const runOfficialOperation = async (
analysis: AnalysisOperationPort,
contract: (typeof OFFICIAL_TOOL_CONTRACTS)[number],
arguments_: Readonly<Record<string, JsonValue>>,
execution: {
readonly logger: Logger;
readonly signal: AbortSignal;
readonly progress: ProgressReporter;
},
): Promise<Result<AnalysisExecution, AnalysisError>> => {
await execution.progress.report({
phase: contract.name,
completed: 0,
total: 1,
message: "started",
});
const result = await logToolExecution(execution.logger, contract.name, () =>
analysis.execute(contract.name, arguments_, {
signal: execution.signal,
progress: execution.progress,
}),
);
await execution.progress.report({
phase: contract.name,
completed: 1,
total: 1,
message: result.ok ? "completed" : "failed",
terminal: true,
});
return result;
};
const recordOfficialUnknown = (
input: unknown,
error: AnalysisError,
contract: ToolContract,
recordUnknown: BinarySessionPort["recordUnknown"] | undefined,
): Result<never, AnalysisError> | undefined => {
if (
!(error instanceof AnalysisCapabilityUnavailableError) ||
!approvedUnknownTracking(input) ||
recordUnknown === undefined
)
return undefined;
const unknown = recordUnknown({
approved: true,
question: "The requested analysis is unavailable for the current target.",
severity: "medium",
domain: "analysis-capability",
supporting_evidence_ids: [],
contradicting_evidence_ids: [],
required_authority: "shipped-artifact",
required_confidence: "observed",
required_environment: null,
recommended_probes: [
{
operation: contract.name,
rationale:
"Choose another analysis or target that can answer this question.",
},
],
relationships: [],
});
if (
!unknown.ok &&
!(
unknown.error instanceof UnknownRegistryError &&
unknown.error.reason === "already-exists"
)
)
return unknown;
return undefined;
};
const projectOfficialArguments = (
contract: ToolContract,
input: unknown,
): Readonly<Record<string, JsonValue>> => {
const parsed = jsonObjectSchema.parse(input);
+269
View File
@@ -0,0 +1,269 @@
import type { McpServer } from "@modelcontextprotocol/server";
import type { BinarySessionPort } from "../application/BinarySession.js";
import type { PermissionAuthority } from "../application/PermissionAuthority.js";
import {
readEvidenceBundle,
writeEvidenceBundle,
} from "../application/EvidenceBundleFiles.js";
import {
exportEvidenceBundleInputSchema,
importEvidenceBundleInputSchema,
listUnknownsInputSchema,
SESSION_TOOL_CONTRACTS,
verifyUnknownResolutionInputSchema,
} from "../contracts/toolContracts.js";
import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js";
import {
AnalysisProtocolError,
PermissionRequiredError,
type AnalysisError,
} from "../domain/errors.js";
import {
recordUnknownInputSchema,
updateUnknownInputSchema,
} from "../domain/residualUnknown.js";
import { err, ok, type Result } from "../domain/result.js";
import { safeParseToolInput } from "./toolInputValidation.js";
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
import { toCallToolResult } from "./toolResult.js";
interface EvidenceToolRegistration {
readonly server: McpServer;
readonly session: BinarySessionPort;
readonly exportContract: (typeof SESSION_TOOL_CONTRACTS)[3];
readonly importContract: (typeof SESSION_TOOL_CONTRACTS)[4];
readonly filePolicy: EvidenceFilePolicy;
readonly permissionAuthority?: PermissionAuthority;
}
/** Register evidence bundle import and export tools. */
export const registerEvidenceTools = (
registration: EvidenceToolRegistration,
): void => {
registerExportEvidenceTool(registration);
registerImportEvidenceTool(registration);
};
const registerExportEvidenceTool = ({
server,
session,
exportContract,
filePolicy,
permissionAuthority,
}: EvidenceToolRegistration): void => {
server.registerTool(
exportContract.name,
toolRegistrationOptions(exportContract),
async (input) => {
const parsedInput = safeParseToolInput(
exportEvidenceBundleInputSchema,
input,
exportContract.name,
);
if (!parsedInput.ok) return toCallToolResult(parsedInput, exportContract);
const parsed = parsedInput.value;
const bundle = session.exportEvidenceBundle();
if (parsed.path === undefined)
return toCallToolResult(ok(bundle), exportContract);
const denied = await authorizeEvidencePath({
authority: permissionAuthority,
capability: "evidence_write",
path: parsed.path,
access: "write",
operationIdentity: `export_evidence:${parsed.path}`,
});
if (denied !== undefined) return toCallToolResult(denied, exportContract);
const written = await writeEvidenceBundle(
bundle,
parsed.path,
parsed.overwrite,
filePolicy,
);
return written.ok
? toCallToolResult(
ok({
path: written.value.path,
bytes: written.value.bytes,
records: bundle.records.length,
}),
exportContract,
)
: toCallToolResult(written, exportContract);
},
);
};
const registerImportEvidenceTool = ({
server,
session,
importContract,
filePolicy,
permissionAuthority,
}: EvidenceToolRegistration): void => {
server.registerTool(
importContract.name,
toolRegistrationOptions(importContract),
async (input) => {
const parsedInput = safeParseToolInput(
importEvidenceBundleInputSchema,
input,
importContract.name,
);
if (!parsedInput.ok) return toCallToolResult(parsedInput, importContract);
const path = parsedInput.value.path;
const denied = await authorizeEvidencePath({
authority: permissionAuthority,
capability: "evidence_read",
path,
access: "read",
operationIdentity: `import_evidence:${path}`,
});
if (denied !== undefined) return toCallToolResult(denied, importContract);
const loaded = await readEvidenceBundle(path, filePolicy);
if (!loaded.ok) return toCallToolResult(loaded, importContract);
const imported = session.importEvidenceBundle(loaded.value);
if (imported.ok && imported.value > 0) server.sendResourceListChanged();
return imported.ok
? toCallToolResult(
ok({
imported: imported.value,
total: session.exportEvidenceBundle().records.length,
}),
importContract,
)
: toCallToolResult(imported, importContract);
},
);
};
interface EvidenceAuthorizationInput {
readonly authority: PermissionAuthority | undefined;
readonly capability: "evidence_read" | "evidence_write";
readonly path: string;
readonly access: "read" | "write";
readonly operationIdentity: string;
}
const authorizeEvidencePath = async (
input: EvidenceAuthorizationInput,
): Promise<Result<never, AnalysisError> | undefined> => {
if (input.authority === undefined) return undefined;
const authorized = await input.authority.authorize(
{
capability: input.capability,
roots: [input.path],
executables: [],
environment_names: [],
network: "none",
mount: false,
operation_identity: input.operationIdentity,
},
input.access,
);
return authorized.ok ? undefined : permissionFailure(authorized);
};
interface UnknownToolRegistration {
readonly server: McpServer;
readonly session: BinarySessionPort;
readonly contracts: typeof SESSION_TOOL_CONTRACTS;
}
/** Register residual-unknown query and mutation tools. */
export const registerUnknownTools = ({
server,
session,
contracts,
}: UnknownToolRegistration): void => {
const listContract = contracts[14];
const recordContract = contracts[15];
const updateContract = contracts[16];
const verifyContract = contracts[17];
server.registerTool(
listContract.name,
toolRegistrationOptions(listContract),
(input) => {
const parsed = safeParseToolInput(
listUnknownsInputSchema,
input,
listContract.name,
);
if (!parsed.ok) return toCallToolResult(parsed, listContract);
const filters = parsed.value;
return toCallToolResult(
ok(
session.listUnknowns({
...(filters.status === undefined ? {} : { status: filters.status }),
...(filters.severity === undefined
? {}
: { severity: filters.severity }),
...(filters.domain === undefined ? {} : { domain: filters.domain }),
}),
),
listContract,
);
},
);
server.registerTool(
recordContract.name,
toolRegistrationOptions(recordContract),
(input) => {
const parsed = safeParseToolInput(
recordUnknownInputSchema,
input,
recordContract.name,
);
if (!parsed.ok) return toCallToolResult(parsed, recordContract);
const result = session.recordUnknown(parsed.value);
if (result.ok) server.sendResourceListChanged();
return toCallToolResult(result, recordContract);
},
);
server.registerTool(
updateContract.name,
toolRegistrationOptions(updateContract),
(input) => {
const parsed = safeParseToolInput(
updateUnknownInputSchema,
input,
updateContract.name,
);
if (!parsed.ok) return toCallToolResult(parsed, updateContract);
const result = session.updateUnknown(parsed.value);
if (result.ok) server.sendResourceListChanged();
return toCallToolResult(result, updateContract);
},
);
server.registerTool(
verifyContract.name,
toolRegistrationOptions(verifyContract),
(input) => {
const parsed = safeParseToolInput(
verifyUnknownResolutionInputSchema,
input,
verifyContract.name,
);
return parsed.ok
? toCallToolResult(
session.verifyUnknownResolution(parsed.value.unknown_id),
verifyContract,
)
: toCallToolResult(parsed, verifyContract);
},
);
};
const permissionFailure = (
failure: Awaited<ReturnType<PermissionAuthority["authorize"]>>,
): Result<never, AnalysisError> => {
if (failure.ok)
return err(new AnalysisProtocolError("Expected a denied permission"));
return err(
failure.error instanceof PermissionRequiredError
? failure.error
: new AnalysisProtocolError(failure.error.message, {
cause: failure.error,
}),
);
};
+14 -8
View File
@@ -12,21 +12,27 @@ import { buildCapabilityInventory } from "../application/CapabilityInventory.js"
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
import { safeParseToolInput } from "./toolInputValidation.js";
/** Register the read-only provider and target status operation. */
export const registerSessionStatusTool = (
server: McpServer,
session: BinarySessionPort,
contract: (typeof SESSION_TOOL_CONTRACTS)[2],
startedAt: string,
availabilityPolicy: () => {
/** Inputs required to register the binary session status tool. */
export interface SessionStatusToolOptions {
readonly server: McpServer;
readonly session: BinarySessionPort;
readonly contract: (typeof SESSION_TOOL_CONTRACTS)[2];
readonly startedAt: string;
readonly availabilityPolicy: () => {
readonly processCaptureEnabled: boolean;
readonly evidenceFileRoots: number;
readonly browserObservationEnabled?: boolean;
readonly electronObservationEnabled?: boolean;
readonly javascriptReplayEnabled?: boolean;
readonly managedRuntimeEnabled?: boolean;
},
};
}
/** Register the read-only provider and target status operation. */
export const registerSessionStatusTool = (
options: SessionStatusToolOptions,
): void => {
const { server, session, contract, startedAt, availabilityPolicy } = options;
server.registerTool(
contract.name,
toolRegistrationOptions(contract),
+77 -292
View File
@@ -2,13 +2,7 @@ import type { McpServer } from "@modelcontextprotocol/server";
import { z } from "zod";
import type { BinarySessionPort } from "../application/BinarySession.js";
import {
exportEvidenceBundleInputSchema,
importEvidenceBundleInputSchema,
listUnknownsInputSchema,
SESSION_TOOL_CONTRACTS,
verifyUnknownResolutionInputSchema,
} from "../contracts/toolContracts.js";
import { SESSION_TOOL_CONTRACTS } from "../contracts/toolContracts.js";
import { toCallToolResult } from "./toolResult.js";
import type { Logger } from "../logger.js";
import { logToolExecution } from "./toolLogging.js";
@@ -23,15 +17,7 @@ import { captureProcessScenario } from "../application/ProcessHarness.js";
import { processCapturePermissionRequest } from "../application/ProcessCapturePermission.js";
import type { Evidence } from "../domain/evidence.js";
import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js";
import {
recordUnknownInputSchema,
updateUnknownInputSchema,
} from "../domain/residualUnknown.js";
import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js";
import {
readEvidenceBundle,
writeEvidenceBundle,
} from "../application/EvidenceBundleFiles.js";
import {
readAnalysisSnapshot,
writeAnalysisSnapshot,
@@ -66,6 +52,11 @@ import {
type ProcessCaptureElicitation,
} from "./ProcessCaptureElicitation.js";
import { isInputRequiredResult } from "@modelcontextprotocol/server";
import {
registerEvidenceTools,
registerUnknownTools,
} from "./registerSessionRecordTools.js";
import { sessionAvailabilityPolicy } from "./sessionAvailabilityPolicy.js";
const permissionFailure = (
failure: Awaited<ReturnType<PermissionAuthority["authorize"]>>,
@@ -213,211 +204,6 @@ const registerProcessTools = ({
);
};
interface EvidenceToolRegistration {
readonly server: McpServer;
readonly session: BinarySessionPort;
readonly exportContract: (typeof SESSION_TOOL_CONTRACTS)[3];
readonly importContract: (typeof SESSION_TOOL_CONTRACTS)[4];
readonly filePolicy: EvidenceFilePolicy;
readonly permissionAuthority?: PermissionAuthority;
}
const registerEvidenceTools = ({
server,
session,
exportContract,
importContract,
filePolicy,
permissionAuthority,
}: EvidenceToolRegistration): void => {
server.registerTool(
exportContract.name,
toolRegistrationOptions(exportContract),
async (input) => {
const parsedInput = safeParseToolInput(
exportEvidenceBundleInputSchema,
input,
exportContract.name,
);
if (!parsedInput.ok) return toCallToolResult(parsedInput, exportContract);
const parsed = parsedInput.value;
const bundle = session.exportEvidenceBundle();
if (parsed.path === undefined)
return toCallToolResult(ok(bundle), exportContract);
if (permissionAuthority !== undefined) {
const authorized = await permissionAuthority.authorize(
{
capability: "evidence_write",
roots: [parsed.path],
executables: [],
environment_names: [],
network: "none",
mount: false,
operation_identity: `export_evidence:${parsed.path}`,
},
"write",
);
if (!authorized.ok)
return toCallToolResult(
permissionFailure(authorized),
exportContract,
);
}
const written = await writeEvidenceBundle(
bundle,
parsed.path,
parsed.overwrite,
filePolicy,
);
return written.ok
? toCallToolResult(
ok({
path: written.value.path,
bytes: written.value.bytes,
records: bundle.records.length,
}),
exportContract,
)
: toCallToolResult(written, exportContract);
},
);
server.registerTool(
importContract.name,
toolRegistrationOptions(importContract),
async (input) => {
const parsedInput = safeParseToolInput(
importEvidenceBundleInputSchema,
input,
importContract.name,
);
if (!parsedInput.ok) return toCallToolResult(parsedInput, importContract);
const path = parsedInput.value.path;
if (permissionAuthority !== undefined) {
const authorized = await permissionAuthority.authorize(
{
capability: "evidence_read",
roots: [path],
executables: [],
environment_names: [],
network: "none",
mount: false,
operation_identity: `import_evidence:${path}`,
},
"read",
);
if (!authorized.ok)
return toCallToolResult(
permissionFailure(authorized),
importContract,
);
}
const loaded = await readEvidenceBundle(path, filePolicy);
if (!loaded.ok) return toCallToolResult(loaded, importContract);
const imported = session.importEvidenceBundle(loaded.value);
if (imported.ok && imported.value > 0) server.sendResourceListChanged();
return imported.ok
? toCallToolResult(
ok({
imported: imported.value,
total: session.exportEvidenceBundle().records.length,
}),
importContract,
)
: toCallToolResult(imported, importContract);
},
);
};
interface UnknownToolRegistration {
readonly server: McpServer;
readonly session: BinarySessionPort;
readonly contracts: readonly [
(typeof SESSION_TOOL_CONTRACTS)[14],
(typeof SESSION_TOOL_CONTRACTS)[15],
(typeof SESSION_TOOL_CONTRACTS)[16],
(typeof SESSION_TOOL_CONTRACTS)[17],
];
}
const registerUnknownTools = ({
server,
session,
contracts: [listContract, recordContract, updateContract, verifyContract],
}: UnknownToolRegistration): void => {
server.registerTool(
listContract.name,
toolRegistrationOptions(listContract),
(input) => {
const parsedInput = safeParseToolInput(
listUnknownsInputSchema,
input,
listContract.name,
);
if (!parsedInput.ok) return toCallToolResult(parsedInput, listContract);
const filters = parsedInput.value;
return toCallToolResult(
ok(
session.listUnknowns({
...(filters.status === undefined ? {} : { status: filters.status }),
...(filters.severity === undefined
? {}
: { severity: filters.severity }),
...(filters.domain === undefined ? {} : { domain: filters.domain }),
}),
),
listContract,
);
},
);
server.registerTool(
recordContract.name,
toolRegistrationOptions(recordContract),
(input) => {
const parsedInput = safeParseToolInput(
recordUnknownInputSchema,
input,
recordContract.name,
);
if (!parsedInput.ok) return toCallToolResult(parsedInput, recordContract);
const recorded = session.recordUnknown(parsedInput.value);
if (recorded.ok) server.sendResourceListChanged();
return toCallToolResult(recorded, recordContract);
},
);
server.registerTool(
updateContract.name,
toolRegistrationOptions(updateContract),
(input) => {
const parsedInput = safeParseToolInput(
updateUnknownInputSchema,
input,
updateContract.name,
);
if (!parsedInput.ok) return toCallToolResult(parsedInput, updateContract);
const updated = session.updateUnknown(parsedInput.value);
if (updated.ok) server.sendResourceListChanged();
return toCallToolResult(updated, updateContract);
},
);
server.registerTool(
verifyContract.name,
toolRegistrationOptions(verifyContract),
(input) => {
const parsedInput = safeParseToolInput(
verifyUnknownResolutionInputSchema,
input,
verifyContract.name,
);
if (!parsedInput.ok) return toCallToolResult(parsedInput, verifyContract);
const unknownId = parsedInput.value.unknown_id;
return toCallToolResult(
session.verifyUnknownResolution(unknownId),
verifyContract,
);
},
);
};
interface LifecycleToolRegistration {
readonly server: McpServer;
readonly session: BinarySessionPort;
@@ -440,17 +226,30 @@ interface LifecycleToolRegistration {
readonly permissionAuthority?: PermissionAuthority;
}
const registerLifecycleTools = ({
const registerLifecycleTools = (
registration: LifecycleToolRegistration,
): void => {
const { server, session, contracts, startedAt, availabilityPolicy } =
registration;
registerOpenLifecycleTool(registration);
registerCloseLifecycleTool(registration);
registerSessionStatusTool({
server,
session,
contract: contracts[2],
startedAt,
availabilityPolicy,
});
};
const registerOpenLifecycleTool = ({
server,
session,
logger,
contracts,
contracts: [openContract],
snapshotFilePolicy,
startedAt,
availabilityPolicy,
permissionAuthority,
}: LifecycleToolRegistration): void => {
const [openContract, closeContract, statusContract] = contracts;
server.registerTool(
openContract.name,
toolRegistrationOptions(openContract),
@@ -520,6 +319,16 @@ const registerLifecycleTools = ({
: toCallToolResult(opened, openContract);
},
);
};
const registerCloseLifecycleTool = ({
server,
session,
logger,
contracts: [, closeContract],
snapshotFilePolicy,
permissionAuthority,
}: LifecycleToolRegistration): void => {
server.registerTool(
closeContract.name,
toolRegistrationOptions(closeContract),
@@ -531,64 +340,54 @@ const registerLifecycleTools = ({
);
if (!parsedInput.ok) return toCallToolResult(parsedInput, closeContract);
const parsed = parsedInput.value;
if (parsed.snapshot_path !== undefined) {
if (permissionAuthority !== undefined) {
const authorized = await permissionAuthority.authorize(
{
capability: "snapshot_write",
roots: [parsed.snapshot_path],
executables: [],
environment_names: [],
network: "none",
mount: false,
operation_identity: `close_binary:snapshot:${parsed.snapshot_path}`,
},
"write",
);
if (!authorized.ok)
return toCallToolResult(
permissionFailure(authorized),
closeContract,
);
}
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);
if (parsed.snapshot_path === undefined) {
const closed = await logToolExecution(logger, closeContract.name, () =>
session.close(),
);
if (closed.ok) server.sendToolListChanged();
return closed.ok
? toCallToolResult(
ok({
path: written.value.path,
bytes: written.value.bytes,
entries: snapshot.value.entries.length,
}),
closeContract,
)
: toCallToolResult(closed, closeContract);
return toCallToolResult(closed, closeContract);
}
if (permissionAuthority !== undefined) {
const authorized = await permissionAuthority.authorize(
{
capability: "snapshot_write",
roots: [parsed.snapshot_path],
executables: [],
environment_names: [],
network: "none",
mount: false,
operation_identity: `close_binary:snapshot:${parsed.snapshot_path}`,
},
"write",
);
if (!authorized.ok)
return toCallToolResult(permissionFailure(authorized), closeContract);
}
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(),
);
if (closed.ok) server.sendToolListChanged();
return toCallToolResult(closed, closeContract);
return closed.ok
? toCallToolResult(
ok({
path: written.value.path,
bytes: written.value.bytes,
entries: snapshot.value.entries.length,
}),
closeContract,
)
: toCallToolResult(closed, closeContract);
},
);
registerSessionStatusTool(
server,
session,
statusContract,
startedAt,
availabilityPolicy,
);
};
/** Register MCP-only target lifecycle operations on a long-lived session. */
@@ -637,10 +436,6 @@ export const registerSessionTools = (
callPathContract,
staticRuntimeContract,
reconstructionContract,
listUnknownContract,
recordUnknownContract,
updateUnknownContract,
verifyUnknownContract,
] = SESSION_TOOL_CONTRACTS;
registerLifecycleTools({
server,
@@ -649,16 +444,11 @@ export const registerSessionTools = (
contracts: [openContract, closeContract, statusContract],
snapshotFilePolicy: analysisSnapshotFilePolicy,
startedAt: options.startedAt ?? new Date().toISOString(),
availabilityPolicy:
options.availabilityPolicy ??
(() => ({
processCaptureEnabled: processPolicy.enabled,
evidenceFileRoots: evidenceFilePolicy.roots.length,
browserObservationEnabled: false,
electronObservationEnabled: false,
javascriptReplayEnabled: false,
managedRuntimeEnabled: false,
})),
availabilityPolicy: sessionAvailabilityPolicy(
options.availabilityPolicy,
processPolicy,
evidenceFilePolicy,
),
...(options.permissionAuthority === undefined
? {}
: { permissionAuthority: options.permissionAuthority }),
@@ -716,11 +506,6 @@ export const registerSessionTools = (
registerUnknownTools({
server,
session,
contracts: [
listUnknownContract,
recordUnknownContract,
updateUnknownContract,
verifyUnknownContract,
],
contracts: SESSION_TOOL_CONTRACTS,
});
};
+1 -1
View File
@@ -7,7 +7,7 @@ import {
import { err, ok, type Result } from "../domain/result.js";
import { mcpProgressReporter } from "./mcpProgress.js";
interface DerivedOperationContext {
export interface DerivedOperationContext {
readonly mcpReq: {
readonly signal: AbortSignal;
readonly _meta?: { readonly progressToken?: string | number | undefined };
+27
View File
@@ -0,0 +1,27 @@
import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js";
import type { ProcessExecutionPolicy } from "../domain/processCapture.js";
export interface SessionAvailability {
readonly processCaptureEnabled: boolean;
readonly evidenceFileRoots: number;
readonly browserObservationEnabled?: boolean;
readonly electronObservationEnabled?: boolean;
readonly javascriptReplayEnabled?: boolean;
readonly managedRuntimeEnabled?: boolean;
}
/** Select configured availability reporting or the target-free defaults. */
export const sessionAvailabilityPolicy = (
configured: (() => SessionAvailability) | undefined,
processPolicy: ProcessExecutionPolicy,
evidenceFilePolicy: EvidenceFilePolicy,
): (() => SessionAvailability) =>
configured ??
(() => ({
processCaptureEnabled: processPolicy.enabled,
evidenceFileRoots: evidenceFilePolicy.roots.length,
browserObservationEnabled: false,
electronObservationEnabled: false,
javascriptReplayEnabled: false,
managedRuntimeEnabled: false,
}));
+4 -3
View File
@@ -48,12 +48,13 @@ describe("cross-version inventory", () => {
const calls: string[] = [];
const controller = new AbortController();
const scanner: VersionInventoryScanner = (...arguments_) => {
const [path, roots, limits, signal, integrity] = arguments_;
const [path, roots, limits, options] = arguments_;
if (options === undefined) throw new Error("Expected scanner options");
calls.push(path);
expect(roots).toEqual([root]);
expect(limits).toBe(LIMITS);
expect(signal).toBe(controller.signal);
expect(integrity).toBe(INTEGRITY);
expect(options.signal).toBe(controller.signal);
expect(options.integrity).toBe(INTEGRITY);
return path === "left" ? left.promise : right.promise;
};
+32 -24
View File
@@ -8,13 +8,19 @@ type RuntimeDependencies = NonNullable<Parameters<typeof run>[0]>;
const nextTurn = (): Promise<void> =>
new Promise((resolve) => setImmediate(resolve));
const dependencies = (
serve: RuntimeDependencies["serve"],
output: string[],
shutdown: Array<() => void>,
exitCodes: number[],
shutdownUnregistrations: string[] = [],
): RuntimeDependencies => ({
const dependencies = ({
serve,
output,
shutdown,
exitCodes,
shutdownUnregistrations = [],
}: {
readonly serve: RuntimeDependencies["serve"];
readonly output: string[];
readonly shutdown: Array<() => void>;
readonly exitCodes: number[];
readonly shutdownUnregistrations?: string[];
}): RuntimeDependencies => ({
env: {},
serve,
writeStderr: (text) => output.push(text),
@@ -39,8 +45,8 @@ describe("MCP runtime errors", () => {
const exitCodes: number[] = [];
expect(
await run(
dependencies(
(_factory, received) => {
dependencies({
serve: (_factory, received) => {
options = received;
return { close };
},
@@ -48,7 +54,7 @@ describe("MCP runtime errors", () => {
shutdown,
exitCodes,
shutdownUnregistrations,
),
}),
),
).toBe(0);
options?.onerror?.(new Error("SECRET transport stack"));
@@ -67,17 +73,17 @@ describe("MCP runtime errors", () => {
const unregistrations: string[] = [];
let closeCalls = 0;
const runtime: RuntimeDependencies = {
...dependencies(
() => ({
...dependencies({
serve: () => ({
close: async () => {
closeCalls += 1;
},
}),
[],
output: [],
shutdown,
[],
unregistrations,
),
exitCodes: [],
shutdownUnregistrations: unregistrations,
}),
registerReload: (handler) => {
reload.push(handler);
return () => unregistrations.push("reload");
@@ -100,14 +106,14 @@ describe("MCP runtime errors", () => {
const output: string[] = [];
expect(
await run(
dependencies(
() => {
dependencies({
serve: () => {
throw new Error("SECRET startup stack");
},
output,
[],
[],
),
shutdown: [],
exitCodes: [],
}),
),
).toBe(1);
expect(output).toEqual([
@@ -120,12 +126,14 @@ describe("MCP runtime errors", () => {
const shutdown: Array<() => void> = [];
const exitCodes: number[] = [];
await run(
dependencies(
() => ({ close: () => Promise.reject(new Error("SECRET close")) }),
dependencies({
serve: () => ({
close: () => Promise.reject(new Error("SECRET close")),
}),
output,
shutdown,
exitCodes,
),
}),
);
shutdown[0]?.();
await nextTurn();