refactor(app): split session and CLI workflows
This commit is contained in:
1 parent
3e61a63a10
commit
c6ee0049c4
88 files changed
+7428
-6024
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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],
|
||||
});
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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));
|
||||
@@ -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],
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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 })));
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -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"}`,
|
||||
});
|
||||
@@ -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"}`,
|
||||
});
|
||||
@@ -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
@@ -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 =>
|
||||
|
||||
@@ -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;
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
),
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -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 }),
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { Cli } from "incur";
|
||||
|
||||
export type CliInstance = ReturnType<typeof Cli.create>;
|
||||
@@ -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),
|
||||
};
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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
@@ -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";
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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),
|
||||
);
|
||||
};
|
||||
@@ -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 },
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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.";
|
||||
@@ -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)
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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],
|
||||
},
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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
@@ -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> =>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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}", {
|
||||
|
||||
@@ -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;
|
||||
}[];
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user