From c6ee0049c4bffe2880ddb8cf5a2a6c4195ae18f6 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 18 Jul 2026 04:11:29 +0000 Subject: [PATCH] refactor(app): split session and CLI workflows --- src/application/ArtifactExtraction.ts | 2 +- src/application/ArtifactInventory.ts | 633 +-------- src/application/ArtifactInventory/classify.ts | 48 + src/application/ArtifactInventory/hash.ts | 84 ++ src/application/ArtifactInventory/reader.ts | 57 + .../ArtifactInventory/scanCanonical.ts | 295 ++++ .../ArtifactInventory/scanReader.ts | 241 ++++ src/application/ArtifactInventory/types.ts | 61 + .../AuthorizedArtifactInventory.ts | 13 +- src/application/BinarySession.ts | 524 +------ src/application/BinarySessionExecution.ts | 90 ++ src/application/BinarySessionOpen.ts | 114 ++ src/application/BinarySessionRecords.ts | 307 +++++ src/application/BinarySessionStatus.ts | 114 ++ src/application/CrossVersionInventory.ts | 22 +- src/application/DirectAnalysis.ts | 137 +- src/application/Doctor.ts | 186 ++- src/application/EnhancedToolPagination.ts | 42 + src/application/EnhancedToolTypes.ts | 35 + src/application/EnhancedTools.ts | 90 +- .../InvestigationWorkspaceStore.ts | 21 +- .../JavaScriptArtifactReconstruction.ts | 2 +- src/application/JavaScriptReplayPermission.ts | 26 + src/application/JavaScriptReplayService.ts | 503 ++++--- src/application/MacHopper.ts | 23 +- .../ReconstructionCoverageWorkspaceStore.ts | 7 +- src/application/Setup.ts | 396 +----- src/application/SetupClientConfiguration.ts | 245 ++++ src/application/SetupPlan.ts | 44 +- src/cli.ts | 1220 +---------------- src/cli/artifactCommands.ts | 105 ++ src/cli/coreAnalysisCommands.ts | 300 ++++ src/cli/managedCommands.ts | 500 +++++++ src/cli/managedProjectGraphCommand.ts | 35 + src/cli/options.ts | 19 + src/cli/setupCommands.ts | 106 ++ src/cli/types.ts | 3 + src/cli/utilityCommands.ts | 167 +++ src/cliApplicationCommands.ts | 115 +- src/cliBrowserCommands/shared.ts | 58 + src/cliPolicyCommands.ts | 131 +- src/config.ts | 572 +------- src/config/environment.ts | 130 ++ src/config/parseConfig.ts | 358 +++++ src/config/parsers.ts | 103 ++ src/config/permissions.ts | 49 + src/config/types.ts | 52 + src/main.ts | 263 +--- src/main/messages.ts | 10 + src/main/reload.ts | 100 ++ src/main/shutdown.ts | 49 + src/main/startup.ts | 26 + src/main/state.ts | 46 + src/main/transport.ts | 113 ++ src/main/types.ts | 10 + src/server/createServer.ts | 202 ++- src/server/registerApplicationTools.ts | 452 +----- .../characterization.ts | 100 ++ .../compareVersions.ts | 60 + .../controlledReplay.ts | 66 + .../registerApplicationTools/coverage.ts | 179 +++ .../registerApplicationTools/helpers.ts | 61 + .../registerApplicationTools/traceFeature.ts | 43 + src/server/registerApplicationTools/types.ts | 20 + src/server/registerBrowserTools.ts | 314 ++--- src/server/registerElectronTools.ts | 191 ++- src/server/registerEnhancedTools.ts | 198 ++- src/server/registerEvidenceResources.ts | 63 + src/server/registerInvestigationTools.ts | 385 ++---- .../registerInvestigationTools/helpers.ts | 155 +++ .../registerInvestigationTools/types.ts | 22 + src/server/registerManagedWorkflowTools.ts | 451 +----- .../compareManagedMembers.ts | 95 ++ .../registerManagedWorkflowTools/contract.ts | 12 + .../registerManagedWorkflowTools/evidence.ts | 76 + .../importManagedReconstruction.ts | 66 + .../planManagedRuntimeCorrelation.ts | 68 + .../projectManagedApplicationGraph.ts | 90 ++ .../registerManagedWorkflowTools/types.ts | 14 + .../verifyManagedNativeBoundaries.ts | 118 ++ src/server/registerOfficialTools.ts | 197 +-- src/server/registerSessionRecordTools.ts | 269 ++++ src/server/registerSessionStatusTool.ts | 22 +- src/server/registerSessionTools.ts | 369 ++--- src/server/runDerivedOperation.ts | 2 +- src/server/sessionAvailabilityPolicy.ts | 27 + tests/crossVersionInventory.test.ts | 7 +- tests/mainErrors.test.ts | 56 +- 88 files changed, 7428 insertions(+), 6024 deletions(-) create mode 100644 src/application/ArtifactInventory/classify.ts create mode 100644 src/application/ArtifactInventory/hash.ts create mode 100644 src/application/ArtifactInventory/reader.ts create mode 100644 src/application/ArtifactInventory/scanCanonical.ts create mode 100644 src/application/ArtifactInventory/scanReader.ts create mode 100644 src/application/ArtifactInventory/types.ts create mode 100644 src/application/BinarySessionExecution.ts create mode 100644 src/application/BinarySessionOpen.ts create mode 100644 src/application/BinarySessionRecords.ts create mode 100644 src/application/BinarySessionStatus.ts create mode 100644 src/application/EnhancedToolPagination.ts create mode 100644 src/application/EnhancedToolTypes.ts create mode 100644 src/application/JavaScriptReplayPermission.ts create mode 100644 src/application/SetupClientConfiguration.ts create mode 100644 src/cli/artifactCommands.ts create mode 100644 src/cli/coreAnalysisCommands.ts create mode 100644 src/cli/managedCommands.ts create mode 100644 src/cli/managedProjectGraphCommand.ts create mode 100644 src/cli/options.ts create mode 100644 src/cli/setupCommands.ts create mode 100644 src/cli/types.ts create mode 100644 src/cli/utilityCommands.ts create mode 100644 src/cliBrowserCommands/shared.ts create mode 100644 src/config/environment.ts create mode 100644 src/config/parseConfig.ts create mode 100644 src/config/parsers.ts create mode 100644 src/config/permissions.ts create mode 100644 src/config/types.ts create mode 100644 src/main/messages.ts create mode 100644 src/main/reload.ts create mode 100644 src/main/shutdown.ts create mode 100644 src/main/startup.ts create mode 100644 src/main/state.ts create mode 100644 src/main/transport.ts create mode 100644 src/main/types.ts create mode 100644 src/server/registerApplicationTools/characterization.ts create mode 100644 src/server/registerApplicationTools/compareVersions.ts create mode 100644 src/server/registerApplicationTools/controlledReplay.ts create mode 100644 src/server/registerApplicationTools/coverage.ts create mode 100644 src/server/registerApplicationTools/helpers.ts create mode 100644 src/server/registerApplicationTools/traceFeature.ts create mode 100644 src/server/registerApplicationTools/types.ts create mode 100644 src/server/registerInvestigationTools/helpers.ts create mode 100644 src/server/registerInvestigationTools/types.ts create mode 100644 src/server/registerManagedWorkflowTools/compareManagedMembers.ts create mode 100644 src/server/registerManagedWorkflowTools/contract.ts create mode 100644 src/server/registerManagedWorkflowTools/evidence.ts create mode 100644 src/server/registerManagedWorkflowTools/importManagedReconstruction.ts create mode 100644 src/server/registerManagedWorkflowTools/planManagedRuntimeCorrelation.ts create mode 100644 src/server/registerManagedWorkflowTools/projectManagedApplicationGraph.ts create mode 100644 src/server/registerManagedWorkflowTools/types.ts create mode 100644 src/server/registerManagedWorkflowTools/verifyManagedNativeBoundaries.ts create mode 100644 src/server/registerSessionRecordTools.ts create mode 100644 src/server/sessionAvailabilityPolicy.ts diff --git a/src/application/ArtifactExtraction.ts b/src/application/ArtifactExtraction.ts index e3e85cc49a..ad8170363a 100644 --- a/src/application/ArtifactExtraction.ts +++ b/src/application/ArtifactExtraction.ts @@ -226,7 +226,7 @@ const loadInventory = async ( selectedIds: ReadonlySet, signal?: AbortSignal, ): Promise => { - const snapshot = await scanArtifactInventory(path, limits, signal); + const snapshot = await scanArtifactInventory(path, limits, { signal }); const occurrences = new Map(); const neededNodes = new Set(); collectOccurrences( diff --git a/src/application/ArtifactInventory.ts b/src/application/ArtifactInventory.ts index ecd57dc79e..9af88c7474 100644 --- a/src/application/ArtifactInventory.ts +++ b/src/application/ArtifactInventory.ts @@ -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 => { - 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 => { - 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 => { - 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 => { - 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, -): Promise => { - 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; - 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; - readonly occurrences: MutableOccurrence[]; - readonly pendingContradictions: PendingIntegrityContradiction[]; -}> => { - const { nodes, occurrences, pendingContradictions } = emptyScan(); - if (reader === undefined) - return { nodes, occurrences, pendingContradictions }; - const occurrenceByPath = new Map(); - 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 => { - 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 => { - 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", - ); -}; diff --git a/src/application/ArtifactInventory/classify.ts b/src/application/ArtifactInventory/classify.ts new file mode 100644 index 0000000000..df99f620ef --- /dev/null +++ b/src/application/ArtifactInventory/classify.ts @@ -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 => { + 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; +}; diff --git a/src/application/ArtifactInventory/hash.ts b/src/application/ArtifactInventory/hash.ts new file mode 100644 index 0000000000..02c76a21e2 --- /dev/null +++ b/src/application/ArtifactInventory/hash.ts @@ -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 => { + 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) }; +}; diff --git a/src/application/ArtifactInventory/reader.ts b/src/application/ArtifactInventory/reader.ts new file mode 100644 index 0000000000..09f5986b30 --- /dev/null +++ b/src/application/ArtifactInventory/reader.ts @@ -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 => { + 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."]; +}; diff --git a/src/application/ArtifactInventory/scanCanonical.ts b/src/application/ArtifactInventory/scanCanonical.ts new file mode 100644 index 0000000000..78e47e400b --- /dev/null +++ b/src/application/ArtifactInventory/scanCanonical.ts @@ -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 => { + 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; + readonly occurrences: MutableOccurrence[]; + readonly pendingContradictions: PendingIntegrityContradiction[]; +} + +const buildInventorySnapshot = async ( + input: SnapshotBuildInput, +): Promise => { + 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, + occurrenceById: ReadonlyMap, + 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 => { + 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 = (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.`, + ]), +]; diff --git a/src/application/ArtifactInventory/scanReader.ts b/src/application/ArtifactInventory/scanReader.ts new file mode 100644 index 0000000000..41127af235 --- /dev/null +++ b/src/application/ArtifactInventory/scanReader.ts @@ -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; + 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; + readonly occurrences: MutableOccurrence[]; + readonly pendingContradictions: PendingIntegrityContradiction[]; + readonly occurrenceByPath: Map; + 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; + 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(), + registry: new ArtifactPathRegistry(), + totalBytes: 0, + }; + await visitArtifactEntries(context, reader, ""); + return { nodes, occurrences, pendingContradictions }; +}; + +const visitArtifactEntries = async ( + context: ScanContext, + currentReader: ArtifactReader, + prefix: string, +): Promise => { + 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 => { + 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"; diff --git a/src/application/ArtifactInventory/types.ts b/src/application/ArtifactInventory/types.ts new file mode 100644 index 0000000000..894e54b43f --- /dev/null +++ b/src/application/ArtifactInventory/types.ts @@ -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[]; +} diff --git a/src/application/AuthorizedArtifactInventory.ts b/src/application/AuthorizedArtifactInventory.ts index 7800ed48ce..adbf0662a5 100644 --- a/src/application/AuthorizedArtifactInventory.ts +++ b/src/application/AuthorizedArtifactInventory.ts @@ -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 => { 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); }; diff --git a/src/application/BinarySession.ts b/src/application/BinarySession.ts index 33c79244c2..6ee937007b 100644 --- a/src/application/BinarySession.ts +++ b/src/application/BinarySession.ts @@ -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 = new Set( OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name), @@ -82,7 +47,10 @@ const OFFICIAL_OPERATIONS: ReadonlySet = 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 = Promise.resolve(); readonly #calls = new Set>(); readonly #providerRouter: SessionProviderRouter; - readonly #evidence = new EvidenceLedger({ - maxRecords: 10_000, - maxBytes: 64 * 1024 * 1024, - }); - readonly #snapshot = new AnalysisSnapshotCache(); - readonly #investigationWorkspaces = new Map(); - readonly #reconstructionCoverageWorkspaces = new Map< - string, - ReconstructionCoverageWorkspace - >(); readonly #runtimeAvailability = new Map< string, { readonly available: boolean; readonly reason: string | null } >(); readonly #availabilityListeners = new Set<() => void | Promise>(); - #snapshotInvalidated = false; constructor( readonly provider: @@ -124,6 +81,7 @@ export class BinarySession implements BinarySessionPort { ) => Promise>; } = {}, ) { + 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 { - return this.#evidence.import(bundle); - } - - /** Export immutable cached calls and session evidence for the active target. */ - exportAnalysisSnapshot(): Result { - 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 { - 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 { - 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 { - return this.#evidence.recordWithUnknown( - evidence, - input, - unknownMutationEvidence(undefined, input), - ); - } - - /** Update one unknown using compare-and-swap revision semantics. */ - updateUnknown( - input: UpdateUnknownInput, - ): Result { - 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> { 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> { 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 & { - 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], -}); diff --git a/src/application/BinarySessionExecution.ts b/src/application/BinarySessionExecution.ts new file mode 100644 index 0000000000..d79670f5b6 --- /dev/null +++ b/src/application/BinarySessionExecution.ts @@ -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>; + readonly unboundOperationError: ( + operation: AnalysisOperation, + route: SessionProviderRoute, + ) => AnalysisError | undefined; + readonly lookupSnapshot: ( + target: BinaryTarget, + profile: AnalysisProfileCommitment, + operation: AnalysisOperation, + parameters: Readonly>, + ) => AnalysisExecution | undefined; +} + +/** Resolve capability, profile, and snapshot state before invoking a provider. */ +export const prepareSessionExecution = ( + input: PrepareSessionExecutionInput, +): Result => { + 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 }); +}; diff --git a/src/application/BinarySessionOpen.ts b/src/application/BinarySessionOpen.ts new file mode 100644 index 0000000000..3bb9645adc --- /dev/null +++ b/src/application/BinarySessionOpen.ts @@ -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> => { + 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; +}; diff --git a/src/application/BinarySessionRecords.ts b/src/application/BinarySessionRecords.ts new file mode 100644 index 0000000000..059182edcb --- /dev/null +++ b/src/application/BinarySessionRecords.ts @@ -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(); + 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 { + return this.#evidence.import(bundle); + } + + protected abstract activeAnalysisBinding(): ActiveAnalysisBinding | undefined; + + exportAnalysisSnapshot(): Result { + 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 { + 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 + >, + ): AnalysisExecution | undefined { + return this.#snapshot.lookup(target, profile, operation, parameters); + } + + protected recordSnapshot( + input: Parameters[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 { + const target = this.activeAnalysisBinding()?.target; + return this.#evidence.recordUnknown( + input, + unknownMutationEvidence(target, input), + ); + } + + recordEvidenceWithUnknown( + evidence: Evidence, + input: RecordUnknownInput, + ): Result { + return this.#evidence.recordWithUnknown( + evidence, + input, + unknownMutationEvidence(undefined, input), + ); + } + + updateUnknown( + input: UpdateUnknownInput, + ): Result { + 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, +): readonly T[] => + [...workspaces] + .sort( + (left, right) => + left.workspace_id.localeCompare(right.workspace_id) || + left.revision - right.revision, + ) + .map((workspace) => structuredClone(workspace)); diff --git a/src/application/BinarySessionStatus.ts b/src/application/BinarySessionStatus.ts new file mode 100644 index 0000000000..81d6ea76f7 --- /dev/null +++ b/src/application/BinarySessionStatus.ts @@ -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 & { + 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], +}); diff --git a/src/application/CrossVersionInventory.ts b/src/application/CrossVersionInventory.ts index ca1de2ca3f..7deb79c538 100644 --- a/src/application/CrossVersionInventory.ts +++ b/src/application/CrossVersionInventory.ts @@ -53,20 +53,14 @@ export const scanVersionInventories = async ( scanner: VersionInventoryScanner = scanAuthorizedArtifactInventory, ): Promise => { 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 }; }; diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index 7338faca97..00b8d5abe4 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -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>, - logger: Logger = silentLogger, - signal?: AbortSignal, + ...[path, tool, arguments_, logger = silentLogger, signal]: readonly [ + path: string, + tool: NativeToolName | ArtifactToolName | ManagedToolName, + arguments_: Readonly>, + logger?: Logger, + signal?: AbortSignal, + ] ): Promise => 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; + readonly openedTarget: BinaryTarget; + readonly tool: + | NativeToolName + | ArtifactToolName + | ManagedToolName + | DirectAnalysisTool; + readonly arguments: Readonly>; + 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 ( suppliedSignal: AbortSignal | undefined, operation: (signal: AbortSignal) => Promise, diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index bbb2e2441e..e629ee3bc8 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -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 => { + try { + return (await execFileAsync("sw_vers", ["-productVersion"])).stdout.trim(); + } catch { + return undefined; + } +}; + +const executableAvailable = async (path: string): Promise => { + 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 => { + 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 => { + 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 => + readFile( + join(homedir(), ".agents/skills", PRODUCT_IDENTITY.skillName, "SKILL.md"), + "utf8", + ); + +const installedSkillVersion = async (): Promise => { + 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 diff --git a/src/application/EnhancedToolPagination.ts b/src/application/EnhancedToolPagination.ts new file mode 100644 index 0000000000..417876090c --- /dev/null +++ b/src/application/EnhancedToolPagination.ts @@ -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>, + signal?: AbortSignal, +) => Promise>; + +/** 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; + } +}; diff --git a/src/application/EnhancedToolTypes.ts b/src/application/EnhancedToolTypes.ts new file mode 100644 index 0000000000..32498eca45 --- /dev/null +++ b/src/application/EnhancedToolTypes.ts @@ -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>; +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 }))); diff --git a/src/application/EnhancedTools.ts b/src/application/EnhancedTools.ts index 6c7bbf6312..62973b2f64 100644 --- a/src/application/EnhancedTools.ts +++ b/src/application/EnhancedTools.ts @@ -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>; -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, - }), - ), - ); diff --git a/src/application/InvestigationWorkspaceStore.ts b/src/application/InvestigationWorkspaceStore.ts index 2597a1e0a6..8afe192430 100644 --- a/src/application/InvestigationWorkspaceStore.ts +++ b/src/application/InvestigationWorkspaceStore.ts @@ -65,6 +65,12 @@ export const readRevisionedWorkspace = async ( } }; +export interface RevisionedWorkspaceWriteInput { + readonly document: Document; + readonly expectedRevision: number | null; + readonly codec: RevisionedWorkspaceCodec; +} + /** 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, path: string, - expectedRevision: number | null, policy: EvidenceFilePolicy, - codec: RevisionedWorkspaceCodec, + input: RevisionedWorkspaceWriteInput, ): 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; diff --git a/src/application/JavaScriptArtifactReconstruction.ts b/src/application/JavaScriptArtifactReconstruction.ts index 1b91e9ea49..d4e8bdccdb 100644 --- a/src/application/JavaScriptArtifactReconstruction.ts +++ b/src/application/JavaScriptArtifactReconstruction.ts @@ -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( diff --git a/src/application/JavaScriptReplayPermission.ts b/src/application/JavaScriptReplayPermission.ts new file mode 100644 index 0000000000..321fdb7f9d --- /dev/null +++ b/src/application/JavaScriptReplayPermission.ts @@ -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, + 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"}`, +}); diff --git a/src/application/JavaScriptReplayService.ts b/src/application/JavaScriptReplayService.ts index b283cf5ddc..7ea3ed5af5 100644 --- a/src/application/JavaScriptReplayService.ts +++ b/src/application/JavaScriptReplayService.ts @@ -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, options: ExecutionOptions = {}, ): Promise> => { + 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, + signal: AbortSignal | undefined, +): Promise> => { 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, + signal: AbortSignal | undefined, +): Promise< + Result>, 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, + prepared: Awaited>, +): Promise> => { + 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>, + exportContext: ReproducerExportContext | undefined, + options: ExecutionOptions, +): Promise< + Result< + { + readonly executed: z.infer; + readonly sourceEvidence: ReturnType[]; + }, + 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, + exportContext: ReproducerExportContext | undefined, + prepared: Awaited>, +): Promise> => { + 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>, + executed: z.infer, +): ReturnType[] => { + 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>, + executed: z.infer, + sourceEvidence: ReturnType[], +): 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>, executed: z.infer, @@ -395,24 +509,3 @@ const asJson = (value: unknown): JsonValue => JSON.stringify(controlledReplayOutputSchema.parse(value)), ) as unknown, ); - -const permissionRequest = ( - input: z.infer, - 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"}`, -}); diff --git a/src/application/MacHopper.ts b/src/application/MacHopper.ts index c5e17f20e6..65adfb79bc 100644 --- a/src/application/MacHopper.ts +++ b/src/application/MacHopper.ts @@ -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 = ( diff --git a/src/application/ReconstructionCoverageWorkspaceStore.ts b/src/application/ReconstructionCoverageWorkspaceStore.ts index efb5972bc6..45c9ae701c 100644 --- a/src/application/ReconstructionCoverageWorkspaceStore.ts +++ b/src/application/ReconstructionCoverageWorkspaceStore.ts @@ -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, + }); diff --git a/src/application/Setup.ts b/src/application/Setup.ts index 2fac9799cb..0bbbf3e357 100644 --- a/src/application/Setup.ts +++ b/src/application/Setup.ts @@ -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 = {}; - const fail = async (remediation: string): Promise => ({ - 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>, + ], +): Promise => ({ + 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 => { - const transactionPath = await resolveClientConfigTransactionPath( - client.configPath, - ); - if (transactionPath === undefined) - return { status: "failed", reason: "path" }; - let document: Record = {}; - 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; - 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 => { - const transactionPath = await resolveClientConfigTransactionPath( - client.configPath, - ); - if (transactionPath === undefined) - return { status: "failed", reason: "path" }; - let document: Record = {}; - 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; - 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 => { @@ -756,139 +567,6 @@ const exists = async (path: string): Promise => { return false; } }; -const preserveConfigBackup = async ( - source: string, - destination: string, -): Promise => { - 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 => - value === undefined ? {} : objectSchema.parse(value); -const parseObject = (text: string): Record => - 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 => { - 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 => { - 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 => { - 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 => diff --git a/src/application/SetupClientConfiguration.ts b/src/application/SetupClientConfiguration.ts new file mode 100644 index 0000000000..1646d23cc2 --- /dev/null +++ b/src/application/SetupClientConfiguration.ts @@ -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 => { + const transactionPath = await resolveClientConfigTransactionPath( + client.configPath, + ); + if (transactionPath === undefined) + return { status: "failed", reason: "path" }; + let document: Record = {}; + 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; + 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 => { + const transactionPath = await resolveClientConfigTransactionPath( + client.configPath, + ); + if (transactionPath === undefined) + return { status: "failed", reason: "path" }; + let document: Record = {}; + 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; + 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 => { + 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 => + value === undefined ? {} : objectSchema.parse(value); +const parseObject = (text: string): Record => + 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 => { + 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 => { + 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; diff --git a/src/application/SetupPlan.ts b/src/application/SetupPlan.ts index d934b36105..1dbf9258bf 100644 --- a/src/application/SetupPlan.ts +++ b/src/application/SetupPlan.ts @@ -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", diff --git a/src/cli.ts b/src/cli.ts index 88799e71ee..d75d404a87 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,60 +1,29 @@ -import { Cli, z } from "incur"; +import { Cli } from "incur"; import { fileURLToPath } from "node:url"; -import { runDoctor } from "./application/Doctor.js"; -import { - runDirectAnalysis, - runProviderAnalysis, - runSessionStatus, -} from "./application/DirectAnalysis.js"; -import { compareManagedMemberPaths } from "./application/ManagedMemberComparisonService.js"; -import { projectManagedApplicationGraphEvidence } from "./application/ManagedApplicationGraphService.js"; -import { verifyManagedNativeBoundariesEvidence } from "./application/ManagedNativeVerificationService.js"; -import { importManagedReconstructionEvidence } from "./application/ManagedReconstructionService.js"; -import { planManagedRuntimeCorrelationEvidence } from "./application/ManagedRuntimeCorrelationService.js"; -import { runSetup, systemSetupHost } from "./application/Setup.js"; -import { runUninstall } from "./application/Uninstall.js"; -import { runUpgrade, systemUpgradeHost } from "./application/Upgrade.js"; +import { createLogger, parseLogLevel } from "./logger.js"; import { PRODUCT_IDENTITY } from "./identity.js"; -import { createLogger, parseLogLevel, type Logger } from "./logger.js"; -import { logCliCommand } from "./cliLogging.js"; -import { parseConfig } from "./config.js"; -import { importReferenceSource } from "./application/ReferenceSourceImport.js"; +import { registerSetupCommands } from "./cli/setupCommands.js"; +import { registerCoreAnalysisCommands } from "./cli/coreAnalysisCommands.js"; +import { registerUtilityCommands } from "./cli/utilityCommands.js"; +import { registerArtifactCommands } from "./cli/artifactCommands.js"; +import { registerManagedCommands } from "./cli/managedCommands.js"; +import { registerInvestigationCommands } from "./cliInvestigationCommands.js"; import { registerEvidenceCommands } from "./cliEvidenceCommands.js"; import { registerProcessCommands } from "./cliProcessCommands.js"; -import { registerInvestigationCommands } from "./cliInvestigationCommands.js"; -import { - AnalysisProtocolError, - PermissionRequiredError, - projectAnalysisError, -} from "./domain/errors.js"; -import { projectReferenceSourceImportError } from "./application/ReferenceSourceImportTypes.js"; -import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js"; import { registerPolicyCommands } from "./cliPolicyCommands.js"; import { registerBrowserCommands } from "./cliBrowserCommands.js"; import { registerAdvancedBrowserCommands } from "./cliBrowserAdvancedCommands.js"; import { registerElectronCommands } from "./cliElectronCommands.js"; import { registerApplicationCommands } from "./cliApplicationCommands.js"; -import { CLI_COMMANDS } from "./cliCommandNames.js"; -import { parseCliJsonInput } from "./cliJsonInput.js"; -import { - analysisProviderSelectorSchema, - type AnalysisProviderSelector, -} from "./contracts/providerSelection.js"; -import { createSystemDoctorHost } from "./doctorRuntime.js"; -import { - conciseSetupResult, - confirmInteractiveSetup, - renderInteractiveSetupResult, - renderSetupProgress, -} from "./cliSetup.js"; +import type { CliInstance } from "./cli/types.js"; /** * Build the one-shot Incur CLI without starting Hopper at import time. * Analysis commands acquire and close their own sessions; bare `mcp` and * `--mcp` are intercepted by the executable dispatcher before this module loads. */ -export const createCli = (): ReturnType => { +export const createCli = (): CliInstance => { const logger = createLogger( "cli", process.env.REA_LOG_LEVEL === undefined @@ -81,18 +50,12 @@ export const createCli = (): ReturnType => { }); registerSetupCommands(cli, logger); - registerCoreCommands(cli, logger); - registerFunctionCommand(cli, logger); - registerSearchCommand(cli, logger); - registerXrefsCommand(cli, logger); - registerTraceCommand(cli, logger); - registerCapabilityCommands(cli, logger); - registerNativeCommands(cli, logger); + registerCoreAnalysisCommands(cli, logger); + registerUtilityCommands(cli, logger); registerArtifactCommands(cli, logger); registerManagedCommands(cli, logger); registerInvestigationCommands(cli, logger); registerEvidenceCommands(cli, logger); - registerReferenceSourceCommand(cli, logger); registerProcessCommands(cli, logger); registerPolicyCommands(cli, logger); registerBrowserCommands(cli, logger); @@ -101,1162 +64,3 @@ export const createCli = (): ReturnType => { registerApplicationCommands(cli, logger); return cli; }; - -const registerCoreCommands = ( - cli: ReturnType, - 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 registerSetupCommands = ( - cli: ReturnType, - logger: Logger, -): void => { - cli.command(CLI_COMMANDS.setup, { - description: "Install requirements and configure agents", - outputPolicy: "agent-only", - 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"), - client: z - .array( - z.enum([ - "claude_code", - "claude_desktop", - "codex", - "cursor", - "gemini_cli", - "windsurf", - ]), - ) - .default([]) - .describe("Agent integration to configure; repeat for multiple agents"), - allDetected: z - .boolean() - .default(false) - .describe("Configure every detected supported agent"), - skill: z - .boolean() - .optional() - .describe("Install or skip the bundled REA skill"), - dryRun: z - .boolean() - .default(false) - .describe("Print the resolved plan without applying it"), - accessible: z - .boolean() - .default(false) - .describe("Use sequential accessible setup prompts"), - }), - alias: { - yes: "y", - }, - run: ({ options, formatExplicit, agent }) => - logCliCommand(logger, "setup", () => - runSetupCommand({ options, formatExplicit, agent }), - ), - }); - 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 runSetupCommand = async (input: { - readonly options: { - readonly yes: boolean; - readonly installHopper: boolean; - readonly client: readonly string[]; - readonly allDetected: boolean; - readonly skill?: boolean | undefined; - readonly dryRun: boolean; - readonly accessible: boolean; - }; - readonly formatExplicit: boolean; - readonly agent: boolean; -}) => { - const { options } = input; - const interactive = - !options.yes && - !options.dryRun && - !input.formatExplicit && - process.stdin.isTTY === true && - process.stderr.isTTY === true; - const hasExplicitScope = - options.allDetected || - options.client.length > 0 || - options.skill !== undefined || - options.installHopper; - if (options.yes && !hasExplicitScope) - process.stderr.write( - "! Implicit `rea setup --yes` scope is deprecated; use `--all-detected --skill` to retain it.\n", - ); - const result = await runSetup( - { - approved: options.yes && !options.dryRun, - installHopper: options.installHopper, - structured: input.formatExplicit || options.dryRun, - proposeHopper: !hasExplicitScope || options.installHopper, - ...(!hasExplicitScope || options.allDetected - ? {} - : { clientIds: options.client }), - ...(!hasExplicitScope ? {} : { installSkill: options.skill ?? false }), - ...(interactive ? { onProgress: renderSetupProgress } : {}), - }, - systemSetupHost(createSystemDoctorHost()), - interactive - ? (actions) => confirmInteractiveSetup(actions, options.accessible) - : undefined, - ); - if (interactive) renderInteractiveSetupResult(result); - return input.formatExplicit ? result : conciseSetupResult(result); -}; - -const registerXrefsCommand = ( - cli: ReturnType, - 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: ReturnType, - 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 registerCapabilityCommands = ( - cli: ReturnType, - 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 registerFunctionCommand = ( - cli: ReturnType, - 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: ReturnType, - 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), - ), - ), - }); -}; - -const registerReferenceSourceCommand = ( - cli: ReturnType, - 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), - }; - }), - }); -}; - -const registerArtifactCommands = ( - cli: ReturnType, - 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, - ), - ), - }); -}; - -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"), -}; - -const registerManagedCommands = ( - cli: ReturnType, - 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: z - .number() - .int() - .min(0) - .default(0) - .describe("Zero-based assembly-reference offset"), - referenceLimit: z - .number() - .int() - .min(1) - .max(500) - .default(100) - .describe("Maximum assembly references to return"), - resourceOffset: z - .number() - .int() - .min(0) - .default(0) - .describe("Zero-based manifest-resource offset"), - resourceLimit: z - .number() - .int() - .min(1) - .max(500) - .default(100) - .describe("Maximum manifest resources to return"), - attributeOffset: z - .number() - .int() - .min(0) - .default(0) - .describe("Zero-based assembly-attribute offset"), - attributeLimit: z - .number() - .int() - .min(1) - .max(500) - .default(100) - .describe("Maximum assembly attributes to return"), - ...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, - ), - ), - }); - 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, - ), - ), - }); - 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, - ), - ), - }); - cli.command(CLI_COMMANDS.compareManagedMembers, { - description: - "Compare two managed PE/CLI member inventories without name-based matching", - args: z.object({ - leftPath: z - .string() - .describe("Baseline managed PE executable or assembly"), - rightPath: z - .string() - .describe("Candidate managed PE executable or assembly"), - }), - 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: { - 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, - }, - }); - return result.ok - ? result.value - : { - error: "Managed member comparison failed", - ...projectAnalysisError(result.error), - }; - }), - }); - 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); - }), - }); - 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); - }), - }); - 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); - }), - }); - 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); - }), - }); -}; - -const registerNativeCommands = ( - cli: ReturnType, - 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 providerSelectionOption = analysisProviderSelectorSchema - .optional() - .describe( - "Bind deep analysis to a provider ID or use deterministic auto selection", - ); - -const directAnalysisOptions = ( - logger: Logger, - snapshotPath: string | undefined, - providerId: AnalysisProviderSelector | undefined, -) => ({ - logger, - snapshotPath, - ...(providerId === undefined ? {} : { providerId }), -}); diff --git a/src/cli/artifactCommands.ts b/src/cli/artifactCommands.ts new file mode 100644 index 0000000000..b172e0b94f --- /dev/null +++ b/src/cli/artifactCommands.ts @@ -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, + ), + ), + }); +}; diff --git a/src/cli/coreAnalysisCommands.ts b/src/cli/coreAnalysisCommands.ts new file mode 100644 index 0000000000..7a7ff036a5 --- /dev/null +++ b/src/cli/coreAnalysisCommands.ts @@ -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), + ), + ), + }); +}; diff --git a/src/cli/managedCommands.ts b/src/cli/managedCommands.ts new file mode 100644 index 0000000000..59b6c91965 --- /dev/null +++ b/src/cli/managedCommands.ts @@ -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); + }), + }); +}; diff --git a/src/cli/managedProjectGraphCommand.ts b/src/cli/managedProjectGraphCommand.ts new file mode 100644 index 0000000000..8c89794eb4 --- /dev/null +++ b/src/cli/managedProjectGraphCommand.ts @@ -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); + }), + }); +}; diff --git a/src/cli/options.ts b/src/cli/options.ts new file mode 100644 index 0000000000..a3e1117452 --- /dev/null +++ b/src/cli/options.ts @@ -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 }), +}); diff --git a/src/cli/setupCommands.ts b/src/cli/setupCommands.ts new file mode 100644 index 0000000000..da33c52ab6 --- /dev/null +++ b/src/cli/setupCommands.ts @@ -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 => { + 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(); + } +}; diff --git a/src/cli/types.ts b/src/cli/types.ts new file mode 100644 index 0000000000..ec131f52a9 --- /dev/null +++ b/src/cli/types.ts @@ -0,0 +1,3 @@ +import { Cli } from "incur"; + +export type CliInstance = ReturnType; diff --git a/src/cli/utilityCommands.ts b/src/cli/utilityCommands.ts new file mode 100644 index 0000000000..afbe7edae3 --- /dev/null +++ b/src/cli/utilityCommands.ts @@ -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), + }; + }), + }); +}; diff --git a/src/cliApplicationCommands.ts b/src/cliApplicationCommands.ts index d69427e2fd..7ca4b86a76 100644 --- a/src/cliApplicationCommands.ts +++ b/src/cliApplicationCommands.ts @@ -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; + /** Register CLI equivalents of provider-neutral application graph workflows. */ export const registerApplicationCommands = ( cli: ReturnType, 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, - 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, 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, - 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({ diff --git a/src/cliBrowserCommands/shared.ts b/src/cliBrowserCommands/shared.ts new file mode 100644 index 0000000000..a34ed851c6 --- /dev/null +++ b/src/cliBrowserCommands/shared.ts @@ -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[0], +): JsonValue => ({ + error: "Browser observation failed", + ...projectAnalysisError(error), +}); diff --git a/src/cliPolicyCommands.ts b/src/cliPolicyCommands.ts index c0f14dfabb..2a279d3a45 100644 --- a/src/cliPolicyCommands.ts +++ b/src/cliPolicyCommands.ts @@ -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; +} + +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 => { const prompt = createInterface({ input: process.stdin, diff --git a/src/config.ts b/src/config.ts index bc00daea94..4fe05ddf17 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 => { - 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, - maximum: number, -): Result => { - 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 => { - 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> = {}, -): 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 => { - 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>, -): Result => { - 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"; diff --git a/src/config/environment.ts b/src/config/environment.ts new file mode 100644 index 0000000000..e855cee9c9 --- /dev/null +++ b/src/config/environment.ts @@ -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; + +export const parseEnvironment = ( + environment: Readonly>, +): Result => { + const parsed = environmentSchema.safeParse(environment); + if (!parsed.success) { + return err( + new ConfigurationError("Invalid REA environment configuration", { + cause: parsed.error, + }), + ); + } + return ok(parsed.data); +}; diff --git a/src/config/parseConfig.ts b/src/config/parseConfig.ts new file mode 100644 index 0000000000..b4375ffe3c --- /dev/null +++ b/src/config/parseConfig.ts @@ -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 => { + 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 => { + 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>, +): Result => { + 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), + ); +}; diff --git a/src/config/parsers.ts b/src/config/parsers.ts new file mode 100644 index 0000000000..4976ed5983 --- /dev/null +++ b/src/config/parsers.ts @@ -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 => { + 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, + maximum: number, +): Result => { + 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 => { + 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 => { + 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 }, + ), + ); +}; diff --git a/src/config/permissions.ts b/src/config/permissions.ts new file mode 100644 index 0000000000..2a8b7302db --- /dev/null +++ b/src/config/permissions.ts @@ -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> = {}, +): 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"; diff --git a/src/config/types.ts b/src/config/types.ts new file mode 100644 index 0000000000..9731dd77c8 --- /dev/null +++ b/src/config/types.ts @@ -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; +} diff --git a/src/main.ts b/src/main.ts index 926f8da38c..96a741a1dc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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>(); - 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 | undefined; - let unregisterShutdown = (): void => undefined; - const shutdown = (): Promise => { - 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; }; diff --git a/src/main/messages.ts b/src/main/messages.ts new file mode 100644 index 0000000000..53ed170e2d --- /dev/null +++ b/src/main/messages.ts @@ -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."; diff --git a/src/main/reload.ts b/src/main/reload.ts new file mode 100644 index 0000000000..f6954a82c1 --- /dev/null +++ b/src/main/reload.ts @@ -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; + 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) + ); +}; diff --git a/src/main/shutdown.ts b/src/main/shutdown.ts new file mode 100644 index 0000000000..f0c652c823 --- /dev/null +++ b/src/main/shutdown.ts @@ -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; + readonly request: () => void; +} => { + const { + handle, + session, + permissionAuthority, + unregisterReload, + dependencies, + serverLogger, + } = input; + let shutdownPromise: Promise | undefined; + let unregisterShutdown = (): void => undefined; + const shutdown = async (): Promise => { + 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 }; +}; diff --git a/src/main/startup.ts b/src/main/startup.ts new file mode 100644 index 0000000000..d0ff96a814 --- /dev/null +++ b/src/main/startup.ts @@ -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 }; +}; diff --git a/src/main/state.ts b/src/main/state.ts new file mode 100644 index 0000000000..d4f3bdc175 --- /dev/null +++ b/src/main/state.ts @@ -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], + }, +}); diff --git a/src/main/transport.ts b/src/main/transport.ts new file mode 100644 index 0000000000..147e51cce4 --- /dev/null +++ b/src/main/transport.ts @@ -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; + +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; + } + | { readonly ok: false } +> => { + const liveServers = new Set(); + 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 }; +}; diff --git a/src/main/types.ts b/src/main/types.ts new file mode 100644 index 0000000000..098e1ffc70 --- /dev/null +++ b/src/main/types.ts @@ -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; +} diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 65a69e70f2..563bf39936 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -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[0]) => { + const recorded = session.recordEvidence(evidence); + if (recorded.ok && recorded.value === "added") + server.sendResourceListChanged(); + return recorded; + }, + recordEvidenceWithUnknown: + session === undefined + ? undefined + : ( + evidence: Parameters[0], + input: Parameters[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 { + 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[0]) => { - const recorded = session.recordEvidence(evidence); - if (recorded.ok && recorded.value === "added") - server.sendResourceListChanged(); - return recorded; - }; - const recordEvidenceWithUnknown = - session === undefined - ? undefined - : ( - evidence: Parameters[0], - input: Parameters[1], - ) => { - const recorded = session.recordEvidenceWithUnknown(evidence, input); - if (recorded.ok) server.sendResourceListChanged(); - return recorded; - }; + : (input: Parameters[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 => diff --git a/src/server/registerApplicationTools.ts b/src/server/registerApplicationTools.ts index 947e485801..322a35d0ef 100644 --- a/src/server/registerApplicationTools.ts +++ b/src/server/registerApplicationTools.ts @@ -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, - }); -}; diff --git a/src/server/registerApplicationTools/characterization.ts b/src/server/registerApplicationTools/characterization.ts new file mode 100644 index 0000000000..ea55815353 --- /dev/null +++ b/src/server/registerApplicationTools/characterization.ts @@ -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, + }); + }, + ); +}; diff --git a/src/server/registerApplicationTools/compareVersions.ts b/src/server/registerApplicationTools/compareVersions.ts new file mode 100644 index 0000000000..910a74cba1 --- /dev/null +++ b/src/server/registerApplicationTools/compareVersions.ts @@ -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, + ); + }, + ); +}; diff --git a/src/server/registerApplicationTools/controlledReplay.ts b/src/server/registerApplicationTools/controlledReplay.ts new file mode 100644 index 0000000000..3ef28abfba --- /dev/null +++ b/src/server/registerApplicationTools/controlledReplay.ts @@ -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, + }); + }, + ); +}; diff --git a/src/server/registerApplicationTools/coverage.ts b/src/server/registerApplicationTools/coverage.ts new file mode 100644 index 0000000000..b754dd6b3b --- /dev/null +++ b/src/server/registerApplicationTools/coverage.ts @@ -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); + }, + ); +}; diff --git a/src/server/registerApplicationTools/helpers.ts b/src/server/registerApplicationTools/helpers.ts new file mode 100644 index 0000000000..25694b6eb0 --- /dev/null +++ b/src/server/registerApplicationTools/helpers.ts @@ -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, + }); +}; diff --git a/src/server/registerApplicationTools/traceFeature.ts b/src/server/registerApplicationTools/traceFeature.ts new file mode 100644 index 0000000000..8d1f3c14d4 --- /dev/null +++ b/src/server/registerApplicationTools/traceFeature.ts @@ -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); + }, + ); +}; diff --git a/src/server/registerApplicationTools/types.ts b/src/server/registerApplicationTools/types.ts new file mode 100644 index 0000000000..2c17ec5733 --- /dev/null +++ b/src/server/registerApplicationTools/types.ts @@ -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; +} diff --git a/src/server/registerBrowserTools.ts b/src/server/registerBrowserTools.ts index 3ca4d22695..b24e384d86 100644 --- a/src/server/registerBrowserTools.ts +++ b/src/server/registerBrowserTools.ts @@ -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 { + readonly contract: ToolContract; + readonly schema: Schema; + readonly execute: ( + parsed: z.output, + context: BrowserToolContext, + ) => Promise>; +} + /** 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 = ( + server: McpServer, + options: BrowserToolRegistration, + spec: BrowserToolSpec, +): 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 diff --git a/src/server/registerElectronTools.ts b/src/server/registerElectronTools.ts index 10a778a5b0..a4b1a2cf56 100644 --- a/src/server/registerElectronTools.ts +++ b/src/server/registerElectronTools.ts @@ -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 { + readonly contract: ToolContract; + readonly schema: Schema; + readonly execute: ( + parsed: z.output, + context: ElectronToolContext, + ) => Promise>; +} + /** 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 = ( + server: McpServer, + options: ElectronToolRegistration, + spec: ElectronToolSpec, +): 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 diff --git a/src/server/registerEnhancedTools.ts b/src/server/registerEnhancedTools.ts index fd69285953..14907981f5 100644 --- a/src/server/registerEnhancedTools.ts +++ b/src/server/registerEnhancedTools.ts @@ -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 = z.output< + (typeof enhancedInputSchemas)[Name] +>; + +type EnhancedInputParser = ( + input: unknown, +) => Result, AnalysisInputError>; + +const ENHANCED_INPUT_PARSERS: { + [Name in EnhancedToolName]: EnhancedInputParser; +} = { + 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 => { 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 = ( + parsed: Result, + project: (input: Input) => Call, +): Result => + parsed.ok ? ok(project(parsed.value)) : parsed; + interface WorkflowUnknownInput { readonly name: string; readonly input: Readonly>; diff --git a/src/server/registerEvidenceResources.ts b/src/server/registerEvidenceResources.ts index b42c71bbf7..7f1dbce0a3 100644 --- a/src/server/registerEvidenceResources.ts +++ b/src/server/registerEvidenceResources.ts @@ -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}", { diff --git a/src/server/registerInvestigationTools.ts b/src/server/registerInvestigationTools.ts index 6494fa50a1..e02fd1ded2 100644 --- a/src/server/registerInvestigationTools.ts +++ b/src/server/registerInvestigationTools.ts @@ -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>) - | 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["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>) + | 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 => { - const records = new Map( - session - .exportEvidenceBundle() - .records.map((evidence) => [evidence.evidence_id, evidence]), - ); - const visited = new Set(); - 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 => { - 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: [], - }); -}; diff --git a/src/server/registerInvestigationTools/helpers.ts b/src/server/registerInvestigationTools/helpers.ts new file mode 100644 index 0000000000..c217ae156d --- /dev/null +++ b/src/server/registerInvestigationTools/helpers.ts @@ -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 => { + const records = new Map( + session + .exportEvidenceBundle() + .records.map((evidence) => [evidence.evidence_id, evidence]), + ); + const visited = new Set(); + 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["coverage"], +): Result => { + 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 => { + 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: [], + }); +}; diff --git a/src/server/registerInvestigationTools/types.ts b/src/server/registerInvestigationTools/types.ts new file mode 100644 index 0000000000..8326d0d6d8 --- /dev/null +++ b/src/server/registerInvestigationTools/types.ts @@ -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; + }[]; +} diff --git a/src/server/registerManagedWorkflowTools.ts b/src/server/registerManagedWorkflowTools.ts index d7b446277a..0945fb0ce1 100644 --- a/src/server/registerManagedWorkflowTools.ts +++ b/src/server/registerManagedWorkflowTools.ts @@ -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 => - resolveSessionEvidenceIds(session, evidenceIds, { - operation: "inspect_managed_members", - predicate: "rea.analysis/v2", - }); - -const resolveManagedArtifactEvidence = ( - session: BinarySessionPort, - evidenceId: string, -): Result => - resolveSessionEvidenceIds(session, [evidenceId], { - operation: "inspect_managed_artifact", - predicate: "rea.analysis/v2", - }); - -const resolveManagedBoundaryEvidence = ( - session: BinarySessionPort, - evidenceId: string, -): Result => - resolveSessionEvidenceIds(session, [evidenceId], { - operation: "inspect_managed_native_boundaries", - predicate: "rea.analysis/v2", - }); - -const resolveNativeEvidence = ( - session: BinarySessionPort, - evidenceIds: readonly string[], -): Result => { - 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); diff --git a/src/server/registerManagedWorkflowTools/compareManagedMembers.ts b/src/server/registerManagedWorkflowTools/compareManagedMembers.ts new file mode 100644 index 0000000000..65efe91ba2 --- /dev/null +++ b/src/server/registerManagedWorkflowTools/compareManagedMembers.ts @@ -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, + }, + ); + }, + ); +}; diff --git a/src/server/registerManagedWorkflowTools/contract.ts b/src/server/registerManagedWorkflowTools/contract.ts new file mode 100644 index 0000000000..1269623088 --- /dev/null +++ b/src/server/registerManagedWorkflowTools/contract.ts @@ -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; +}; diff --git a/src/server/registerManagedWorkflowTools/evidence.ts b/src/server/registerManagedWorkflowTools/evidence.ts new file mode 100644 index 0000000000..a67a5a36b7 --- /dev/null +++ b/src/server/registerManagedWorkflowTools/evidence.ts @@ -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 => + resolveSessionEvidenceIds(session, evidenceIds, { + operation: "inspect_managed_members", + predicate: "rea.analysis/v2", + }); + +export const resolveManagedArtifactEvidence = ( + session: BinarySessionPort, + evidenceId: string, +): Result => + resolveSessionEvidenceIds(session, [evidenceId], { + operation: "inspect_managed_artifact", + predicate: "rea.analysis/v2", + }); + +export const resolveManagedBoundaryEvidence = ( + session: BinarySessionPort, + evidenceId: string, +): Result => + resolveSessionEvidenceIds(session, [evidenceId], { + operation: "inspect_managed_native_boundaries", + predicate: "rea.analysis/v2", + }); + +export const resolveNativeEvidence = ( + session: BinarySessionPort, + evidenceIds: readonly string[], +): Result => { + 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); diff --git a/src/server/registerManagedWorkflowTools/importManagedReconstruction.ts b/src/server/registerManagedWorkflowTools/importManagedReconstruction.ts new file mode 100644 index 0000000000..fea8a79f1f --- /dev/null +++ b/src/server/registerManagedWorkflowTools/importManagedReconstruction.ts @@ -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, + }); + }, + ); +}; diff --git a/src/server/registerManagedWorkflowTools/planManagedRuntimeCorrelation.ts b/src/server/registerManagedWorkflowTools/planManagedRuntimeCorrelation.ts new file mode 100644 index 0000000000..8c73f1cb2c --- /dev/null +++ b/src/server/registerManagedWorkflowTools/planManagedRuntimeCorrelation.ts @@ -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, + }); + }, + ); +}; diff --git a/src/server/registerManagedWorkflowTools/projectManagedApplicationGraph.ts b/src/server/registerManagedWorkflowTools/projectManagedApplicationGraph.ts new file mode 100644 index 0000000000..2e33b4aa12 --- /dev/null +++ b/src/server/registerManagedWorkflowTools/projectManagedApplicationGraph.ts @@ -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, + }); + }, + ); +}; diff --git a/src/server/registerManagedWorkflowTools/types.ts b/src/server/registerManagedWorkflowTools/types.ts new file mode 100644 index 0000000000..083d1b6c08 --- /dev/null +++ b/src/server/registerManagedWorkflowTools/types.ts @@ -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; +} diff --git a/src/server/registerManagedWorkflowTools/verifyManagedNativeBoundaries.ts b/src/server/registerManagedWorkflowTools/verifyManagedNativeBoundaries.ts new file mode 100644 index 0000000000..b382cb5bdc --- /dev/null +++ b/src/server/registerManagedWorkflowTools/verifyManagedNativeBoundaries.ts @@ -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, + }); + }, + ); +}; diff --git a/src/server/registerOfficialTools.ts b/src/server/registerOfficialTools.ts index f528ae03c8..01bbbb73e6 100644 --- a/src/server/registerOfficialTools.ts +++ b/src/server/registerOfficialTools.ts @@ -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>, + execution: { + readonly logger: Logger; + readonly signal: AbortSignal; + readonly progress: ProgressReporter; + }, +): Promise> => { + 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 | 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> => { const parsed = jsonObjectSchema.parse(input); diff --git a/src/server/registerSessionRecordTools.ts b/src/server/registerSessionRecordTools.ts new file mode 100644 index 0000000000..1ce705c51e --- /dev/null +++ b/src/server/registerSessionRecordTools.ts @@ -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 | 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>, +): Result => { + 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, + }), + ); +}; diff --git a/src/server/registerSessionStatusTool.ts b/src/server/registerSessionStatusTool.ts index f69a3e0f40..db1ee8ffb7 100644 --- a/src/server/registerSessionStatusTool.ts +++ b/src/server/registerSessionStatusTool.ts @@ -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), diff --git a/src/server/registerSessionTools.ts b/src/server/registerSessionTools.ts index dac101c396..37916246ad 100644 --- a/src/server/registerSessionTools.ts +++ b/src/server/registerSessionTools.ts @@ -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>, @@ -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, }); }; diff --git a/src/server/runDerivedOperation.ts b/src/server/runDerivedOperation.ts index 5b816c0c41..393b6a5305 100644 --- a/src/server/runDerivedOperation.ts +++ b/src/server/runDerivedOperation.ts @@ -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 }; diff --git a/src/server/sessionAvailabilityPolicy.ts b/src/server/sessionAvailabilityPolicy.ts new file mode 100644 index 0000000000..4a3d74f11a --- /dev/null +++ b/src/server/sessionAvailabilityPolicy.ts @@ -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, + })); diff --git a/tests/crossVersionInventory.test.ts b/tests/crossVersionInventory.test.ts index 6f34d675a7..8834795e56 100644 --- a/tests/crossVersionInventory.test.ts +++ b/tests/crossVersionInventory.test.ts @@ -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; }; diff --git a/tests/mainErrors.test.ts b/tests/mainErrors.test.ts index b0ee88447e..c39f55b3c4 100644 --- a/tests/mainErrors.test.ts +++ b/tests/mainErrors.test.ts @@ -8,13 +8,19 @@ type RuntimeDependencies = NonNullable[0]>; const nextTurn = (): Promise => 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();