diff --git a/src/application/AnalysisSnapshotCache.ts b/src/application/AnalysisSnapshotCache.ts index 544937c48..25c2c2856 100644 --- a/src/application/AnalysisSnapshotCache.ts +++ b/src/application/AnalysisSnapshotCache.ts @@ -95,11 +95,11 @@ export class AnalysisSnapshotCache { this.#target.architecture !== snapshot.target.architecture) ) this.#entries.clear(); - this.#target = snapshot.target; + this.#target = structuredClone(snapshot.target); let imported = 0; for (const entry of snapshot.entries) { if (!this.#entries.has(entry.query_id)) imported += 1; - this.#entries.set(entry.query_id, entry); + this.#entries.set(entry.query_id, structuredClone(entry)); } return imported; } @@ -114,7 +114,7 @@ export class AnalysisSnapshotCache { snapshot_version: 1, target: snapshotTarget(target), entries: this.entries(), - evidence_bundle: evidenceBundle, + evidence_bundle: structuredClone(evidenceBundle), }); } @@ -138,9 +138,9 @@ export class AnalysisSnapshotCache { /** Return canonical entries for persistence. */ entries(): AnalysisSnapshotEntry[] { - return [...this.#entries.values()].sort((left, right) => - left.query_id.localeCompare(right.query_id), - ); + return [...this.#entries.values()] + .sort((left, right) => left.query_id.localeCompare(right.query_id)) + .map((entry) => structuredClone(entry)); } /** Replay an exact provider-specific query, marking its cached provenance. */ @@ -159,7 +159,7 @@ export class AnalysisSnapshotCache { const cached = this.#entries.get(queryId); if (cached === undefined) return undefined; const subject = cached.execution.subject; - return { + return structuredClone({ result: cached.execution.result, rawResult: cached.execution.raw_result, provider: cached.execution.provider, @@ -183,7 +183,7 @@ export class AnalysisSnapshotCache { format: subject.format, architecture: subject.architecture, }, - }; + }); } /** Record one successful immutable call unless the cache is full. */ diff --git a/src/application/ArtifactGraphConstruction.ts b/src/application/ArtifactGraphConstruction.ts index 5c10db6e7..01aa6a80d 100644 --- a/src/application/ArtifactGraphConstruction.ts +++ b/src/application/ArtifactGraphConstruction.ts @@ -30,6 +30,17 @@ export interface MutableOccurrence { limitations: string[]; } +/** Index the first occurrence for each canonical logical path. */ +export const indexOccurrencesByPath = ( + occurrences: readonly MutableOccurrence[], +): ReadonlyMap => { + const byPath = new Map(); + for (const occurrence of occurrences) + if (!byPath.has(occurrence.logical_path)) + byPath.set(occurrence.logical_path, occurrence); + return byPath; +}; + export const createOccurrence = ( entry: ArtifactEntry, path: string, @@ -196,6 +207,7 @@ export const createArtifactEdges = ( producer?: ArtifactCommand, ): ArtifactEdge[] => { const byId = new Map(occurrences.map((item) => [item.occurrence_id, item])); + const byPath = indexOccurrencesByPath(occurrences); const edges: ArtifactEdge[] = []; for (const occurrence of occurrences) { if ( @@ -204,10 +216,7 @@ export const createArtifactEdges = ( ) continue; const mappedSource = occurrence.logical_path.endsWith(".map") - ? occurrences.find( - ({ logical_path: path }) => - path === occurrence.logical_path.slice(0, -".map".length), - ) + ? byPath.get(occurrence.logical_path.slice(0, -".map".length)) : undefined; const parentArtifactId = mappedSource?.artifact_id ?? diff --git a/src/application/ArtifactInventory.ts b/src/application/ArtifactInventory.ts index a3b128c8b..c7f541ca8 100644 --- a/src/application/ArtifactInventory.ts +++ b/src/application/ArtifactInventory.ts @@ -33,6 +33,7 @@ import { createOccurrence, createRootNode, digestCanonical, + indexOccurrencesByPath, materializeDirectoryNodes, nearestParent, pageOf, @@ -180,11 +181,10 @@ export const scanCanonicalArtifactInventory = async ( const occurrenceById = new Map( occurrences.map((occurrence) => [occurrence.occurrence_id, occurrence]), ); + const occurrenceByPath = indexOccurrencesByPath(occurrences); const integrityContradictions = pendingContradictions.map( (contradiction): IntegrityContradiction => { - const occurrence = occurrences.find( - ({ logical_path: path }) => path === contradiction.logicalPath, - ); + const occurrence = occurrenceByPath.get(contradiction.logicalPath); if (occurrence === undefined) throw new ArtifactReaderFailure( "integrity", diff --git a/src/application/BinarySession.ts b/src/application/BinarySession.ts index ee3ecf80d..9844a9ef4 100644 --- a/src/application/BinarySession.ts +++ b/src/application/BinarySession.ts @@ -113,7 +113,7 @@ export class BinarySession implements BinarySessionPort { providerIdentity(operation?: AnalysisOperation): ProviderIdentity { if (operation !== undefined) { const exact = this.#capabilities?.get(operation)?.provider; - if (exact !== undefined) return exact; + if (exact !== undefined) return structuredClone(exact); if (enhancedToolNameSchema.safeParse(operation).success) { const providers = new Map(); for (const descriptor of this.#capabilities?.values() ?? []) @@ -124,11 +124,11 @@ export class BinarySession implements BinarySessionPort { providers.set(descriptor.provider.id, descriptor.provider); if (providers.size === 1) { const provider = providers.values().next().value; - if (provider !== undefined) return provider; + if (provider !== undefined) return structuredClone(provider); } } } - return this.#providerIdentity; + return structuredClone(this.#providerIdentity); } /** Observe runtime provider-health changes that affect discovery metadata. */ @@ -223,18 +223,21 @@ export class BinarySession implements BinarySessionPort { workspaceId: string, revision: number, ): InvestigationWorkspace | undefined { - return this.#investigationWorkspaces.get( + 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, - ); + return [...this.#investigationWorkspaces.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. */ @@ -468,7 +471,9 @@ export class BinarySession implements BinarySessionPort { /** Return the immutable artifact identity captured before Hopper launched. */ activeTarget(): BinaryTarget | undefined { - return this.#active?.target; + return this.#active === undefined + ? undefined + : structuredClone(this.#active.target); } /** diff --git a/src/application/EvidenceLedger.ts b/src/application/EvidenceLedger.ts index d74699301..367b64b77 100644 --- a/src/application/EvidenceLedger.ts +++ b/src/application/EvidenceLedger.ts @@ -122,9 +122,11 @@ export class EvidenceLedger { /** Export records in deterministic, semantically irrelevant ID order. */ export(): EvidenceBundle { - return createEvidenceBundle( - [...this.#records.values()], - [...this.#unknownRevisions.values()], + return structuredClone( + createEvidenceBundle( + [...this.#records.values()], + [...this.#unknownRevisions.values()], + ), ); } @@ -232,7 +234,7 @@ export class EvidenceLedger { const checked = this.#validateCandidate(pendingRecords, pendingUnknowns); if (!checked.ok) return checked; this.#commit(pendingRecords, pendingUnknowns, checked.value); - return ok(unknown); + return ok(structuredClone(unknown)); } /** Apply one approved full-state update using optimistic revision matching. */ @@ -277,7 +279,8 @@ export class EvidenceLedger { unknown.severity === filters.severity) && (filters.domain === undefined || unknown.domain === filters.domain), ) - .sort((left, right) => left.unknown_id.localeCompare(right.unknown_id)); + .sort((left, right) => left.unknown_id.localeCompare(right.unknown_id)) + .map((unknown) => structuredClone(unknown)); } /** Return current resolution validity; imported invalid states are rejected. */ @@ -295,7 +298,7 @@ export class EvidenceLedger { return ok({ valid: unknown.status === "resolved", truthVerified: unknown.resolution?.disposition === "verified", - unknown, + unknown: structuredClone(unknown), }); } @@ -331,7 +334,7 @@ export class EvidenceLedger { const checked = this.#validateCandidate(pendingRecords, pendingUnknowns); if (!checked.ok) return checked; this.#commit(pendingRecords, pendingUnknowns, checked.value); - return ok(unknown); + return ok(structuredClone(unknown)); } #validateCandidate( diff --git a/src/application/ProcessCaptureLifecycle.ts b/src/application/ProcessCaptureLifecycle.ts index 5a66d142e..bf1d89bf6 100644 --- a/src/application/ProcessCaptureLifecycle.ts +++ b/src/application/ProcessCaptureLifecycle.ts @@ -1,6 +1,6 @@ import { createHash, randomUUID } from "node:crypto"; import { createReadStream } from "node:fs"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { IPty } from "node-pty"; @@ -401,6 +401,7 @@ export const prepareProcessCapture = async ( scenario: ProcessScenario, policy: ProcessExecutionPolicy, signal: AbortSignal | undefined, + host: ProcessPreparationHost = systemProcessPreparationHost, ): Promise<{ readonly temporaryRoot: string; readonly runId: string; @@ -416,11 +417,29 @@ export const prepareProcessCapture = async ( await assertRealPathAuthority(scenario, policy); assertNotCancelled(signal); const before = await snapshotRoots(scenario, signal); - const temporaryRoot = await mkdtemp(join(tmpdir(), "rea-process-")); - const runId = randomUUID(); - const home = join(temporaryRoot, "home"); - await import("node:fs/promises").then(({ mkdir }) => mkdir(home)); - return { temporaryRoot, runId, home, before }; + const temporaryRoot = await host.createTemporaryRoot(); + try { + const runId = randomUUID(); + const home = join(temporaryRoot, "home"); + await host.createHome(home); + return { temporaryRoot, runId, home, before }; + } catch (cause: unknown) { + await host.cleanup(temporaryRoot); + throw cause; + } +}; + +/** Filesystem seam for allocating and cleaning a capture's private home. */ +export interface ProcessPreparationHost { + createTemporaryRoot(): Promise; + createHome(path: string): Promise; + cleanup(path: string): Promise; +} + +const systemProcessPreparationHost: ProcessPreparationHost = { + createTemporaryRoot: () => mkdtemp(join(tmpdir(), "rea-process-")), + createHome: (path) => mkdir(path), + cleanup: (path) => rm(path, { recursive: true, force: true }), }; const processPolicyMessage = ( diff --git a/src/application/ProcessCheckpoints.ts b/src/application/ProcessCheckpoints.ts index f439f38e6..37075cd4b 100644 --- a/src/application/ProcessCheckpoints.ts +++ b/src/application/ProcessCheckpoints.ts @@ -126,7 +126,7 @@ export class ProcessCheckpoints { effects: classifyFilesystemEffects(previous, after.files), truncated: after.truncated, }); - return this.#captures; + return structuredClone(this.#captures); } /** Cancel scheduled captures and await any snapshot already in progress. */ diff --git a/src/application/ProcessCli.ts b/src/application/ProcessCli.ts index 8e9d9e5c8..3de0d199a 100644 --- a/src/application/ProcessCli.ts +++ b/src/application/ProcessCli.ts @@ -112,7 +112,12 @@ export const compareProcessEvidenceFiles = async ( }; const parseCaptureEvidence = (input: unknown) => { - const evidence = parseEvidence(input); + let evidence; + try { + evidence = parseEvidence(input); + } catch { + throw invalidCaptureEvidence(); + } if ( evidence.operation !== "capture_process_scenario" || evidence.predicate_type !== "rea.process-capture/v4" || @@ -129,12 +134,22 @@ const parseCaptureEvidence = (input: unknown) => { "Capture evidence is not from the current process-capture workflow. Create new capture evidence, then try again.", ); } - return { - id: evidence.evidence_id, - capture: parseProcessCapture(evidence.normalized_result), - }; + try { + return { + id: evidence.evidence_id, + capture: parseProcessCapture(evidence.normalized_result), + }; + } catch { + throw invalidCaptureEvidence(); + } }; +const invalidCaptureEvidence = (): ProcessCliFailure => + new ProcessCliFailure( + "invalid_input", + "Capture evidence is malformed. Create new capture evidence, then try again.", + ); + const readJson = async (path: string): Promise => { let bytes; try { diff --git a/src/application/ProcessNormalization.ts b/src/application/ProcessNormalization.ts index 1fde7dcf6..712486fde 100644 --- a/src/application/ProcessNormalization.ts +++ b/src/application/ProcessNormalization.ts @@ -31,7 +31,7 @@ export const normalizeProcessText = ( ); } if (scenario.normalization.pids) - normalized = normalized.replaceAll(String(pid), ""); + normalized = normalizePidTokens(normalized, [pid]); if (scenario.normalization.ports) normalized = normalized.replaceAll(/(?<=[:=])\d{2,5}\b/g, ""); for (const pattern of scenario.normalization.patterns) @@ -57,6 +57,17 @@ export const normalizeProcessSamples = ( for (const identifier of identifiers) if (identifier > 0 && !mapping.has(identifier)) mapping.set(identifier, mapping.size + 1); + const normalizeCommand = (command: string): string => { + const normalized = normalizeProcessText( + command, + scenario, + "", + rootPid, + ); + return scenario.normalization.pids + ? normalizePidTokens(normalized, identifiers) + : normalized; + }; return samples.map((sample) => ({ at_ms: Math.floor(sample.at_ms / scenario.normalization.time_bucket_ms) * @@ -69,15 +80,25 @@ export const normalizeProcessSamples = ( : (mapping.get(sample.process_group_id) ?? 0), session_id: sample.session_id === null ? null : (mapping.get(sample.session_id) ?? 0), - command: normalizeProcessText( - sample.command, - scenario, - "", - rootPid, - ), + command: normalizeCommand(sample.command), })); }; +const normalizePidTokens = ( + value: string, + identifiers: readonly number[], +): string => { + const tokens = [...new Set(identifiers)] + .filter((identifier) => Number.isSafeInteger(identifier) && identifier > 0) + .sort((left, right) => right - left) + .map(String); + if (tokens.length === 0) return value; + return value.replace( + new RegExp(`(?", + ); +}; + export const redactProtocolEvents = ( events: readonly ProcessCapture["protocol_events"][number][], scenario: ProcessScenario, diff --git a/src/application/ProcessOwnership.ts b/src/application/ProcessOwnership.ts index 7ce6ac02a..9302d0d2f 100644 --- a/src/application/ProcessOwnership.ts +++ b/src/application/ProcessOwnership.ts @@ -40,11 +40,14 @@ export type ProcessGroupObservation = | { readonly state: "alive" } | { readonly state: "unverifiable"; readonly reason: string }; -const parseEnvironment = (value: string): Readonly> => +/** Parse the NUL-delimited Linux process environment without nameless keys. */ +export const parseProcessEnvironment = ( + value: string, +): Readonly> => Object.fromEntries( value .split("\0") - .filter((entry) => entry.includes("=")) + .filter((entry) => entry.indexOf("=") > 0) .map((entry) => { const separator = entry.indexOf("="); return [entry.slice(0, separator), entry.slice(separator + 1)]; @@ -72,7 +75,9 @@ const systemHost: ProcessOwnershipHost = { }, async environment(pid) { if (process.platform === "linux") { - return parseEnvironment(await readFile(`/proc/${pid}/environ`, "utf8")); + return parseProcessEnvironment( + await readFile(`/proc/${pid}/environ`, "utf8"), + ); } const { stdout } = await execFileAsync("ps", ["eww", "-p", String(pid)]); const environment: Record = {}; diff --git a/src/application/ProcessSampling.ts b/src/application/ProcessSampling.ts index b79f8bf61..29bca043a 100644 --- a/src/application/ProcessSampling.ts +++ b/src/application/ProcessSampling.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { readFile } from "node:fs/promises"; +import { readFile, readdir } from "node:fs/promises"; import { promisify } from "node:util"; import type { ProcessSample } from "../domain/processCapture.js"; @@ -104,25 +104,79 @@ const readProcessCommand = async ( } }; -const readLinuxChildren = async ( +/** Procfs reads used to enumerate children created by every process thread. */ +export interface LinuxChildrenHost { + taskIds( + pid: number, + signal: AbortSignal, + ): Promise; + children( + pid: number, + taskId: number, + signal: AbortSignal, + ): Promise; +} + +const systemLinuxChildrenHost: LinuxChildrenHost = { + async taskIds(pid, signal) { + try { + signal.throwIfAborted(); + const entries = await readdir(`/proc/${String(pid)}/task`); + signal.throwIfAborted(); + return entries + .filter((entry) => /^\d+$/.test(entry)) + .map(Number) + .filter((taskId) => Number.isSafeInteger(taskId) && taskId > 0) + .sort((left, right) => left - right); + } catch (cause: unknown) { + return handleProcReadFailure(cause); + } + }, + async children(pid, taskId, signal) { + try { + return await readFile( + `/proc/${String(pid)}/task/${String(taskId)}/children`, + { encoding: "utf8", signal }, + ); + } catch (cause: unknown) { + return handleProcReadFailure(cause); + } + }, +}; + +/** Read the deduplicated children created by every thread in a Linux process. */ +export const readLinuxChildren = async ( pid: number, signal: AbortSignal, + host: LinuxChildrenHost = systemLinuxChildrenHost, ): Promise => { - let text: string; - try { - text = await readFile(`/proc/${String(pid)}/task/${String(pid)}/children`, { - encoding: "utf8", - signal, - }); - } catch (cause: unknown) { - return handleProcReadFailure(cause); + const taskIds = await host.taskIds(pid, signal); + if (taskIds === undefined) return undefined; + const children = new Set(); + let successfulReads = 0; + for ( + let offset = 0; + offset < taskIds.length; + offset += PROC_READ_CONCURRENCY + ) { + const texts = await Promise.all( + taskIds + .slice(offset, offset + PROC_READ_CONCURRENCY) + .map((taskId) => host.children(pid, taskId, signal)), + ); + for (const text of texts) { + if (text === undefined) continue; + successfulReads += 1; + for (const value of text.trim().split(/\s+/)) { + if (!/^\d+$/.test(value)) continue; + const child = Number(value); + if (Number.isSafeInteger(child) && child > 0) children.add(child); + } + } } - return text - .trim() - .split(/\s+/) - .filter((value) => /^\d+$/.test(value)) - .map(Number) - .filter((child) => Number.isSafeInteger(child) && child > 0); + return successfulReads === 0 + ? undefined + : [...children].sort((left, right) => left - right); }; const inspectProcess = async ( diff --git a/src/application/ProjectPermissionStore.ts b/src/application/ProjectPermissionStore.ts index 91029236a..c9876765f 100644 --- a/src/application/ProjectPermissionStore.ts +++ b/src/application/ProjectPermissionStore.ts @@ -1,6 +1,16 @@ import { createHash } from "node:crypto"; -import { mkdir, readFile, realpath, stat } from "node:fs/promises"; +import { + lstat, + mkdir, + open, + readFile, + realpath, + stat, + unlink, + type FileHandle, +} from "node:fs/promises"; import { dirname } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; import writeFileAtomic from "write-file-atomic"; import { z } from "zod"; @@ -49,7 +59,12 @@ export class ProjectPermissionStoreError extends Error { readonly _tag = "ProjectPermissionStoreError" as const; constructor( - readonly reason: "project_not_found" | "not_owner_only" | "invalid" | "io", + readonly reason: + | "project_not_found" + | "not_owner_only" + | "invalid" + | "locked" + | "io", options?: ErrorOptions, ) { super(`Project permission store failed: ${reason}`, options); @@ -143,5 +158,114 @@ export const writeProjectPermissionStore = async ( } }; -const isNotFound = (cause: unknown): boolean => - cause instanceof Error && "code" in cause && cause.code === "ENOENT"; +/** Revoke one grant while serializing the complete read-modify-write cycle. */ +export const revokeProjectPermissionGrant = async ( + path: string, + projectRoot: string, + grantId: string, +): Promise> => { + let lock: PermissionStoreLock | undefined; + try { + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + const acquired = await acquirePermissionStoreLock(path); + if (!acquired.ok) return acquired; + lock = acquired.value; + const current = await readProjectPermissionStore(path, projectRoot); + if (!current.ok) return current; + const grants = current.value?.grants ?? []; + const retained = grants.filter(({ grant_id }) => grant_id !== grantId); + if (retained.length === grants.length) return ok(false); + const written = await writeProjectPermissionStore( + path, + projectRoot, + retained, + ); + return written.ok ? ok(true) : written; + } catch (cause: unknown) { + return err(new ProjectPermissionStoreError("io", { cause })); + } finally { + if (lock !== undefined) await releasePermissionStoreLock(lock); + } +}; + +interface PermissionStoreLock { + readonly path: string; + readonly handle: FileHandle; +} + +const acquirePermissionStoreLock = async ( + destination: string, +): Promise> => { + const path = `${destination}.lock`; + for (let attempt = 0; attempt < 100; attempt += 1) { + try { + return ok(await createPermissionStoreLock(path)); + } catch (cause: unknown) { + if (!isAlreadyExists(cause)) + return err(new ProjectPermissionStoreError("io", { cause })); + if (await removeStalePermissionStoreLock(path)) continue; + if (attempt === 99) + return err(new ProjectPermissionStoreError("locked", { cause })); + await delay(10); + } + } + return err(new ProjectPermissionStoreError("locked")); +}; + +const createPermissionStoreLock = async ( + path: string, +): Promise => { + const handle = await open(path, "wx", 0o600); + try { + await handle.writeFile(`${String(process.pid)}\n`, "utf8"); + await handle.sync(); + return { path, handle }; + } catch (cause: unknown) { + await handle.close().catch(() => undefined); + await unlink(path).catch(() => undefined); + throw cause; + } +}; + +const removeStalePermissionStoreLock = async ( + path: string, +): Promise => { + try { + const metadata = await lstat(path); + if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > 32) + return false; + const encoded = (await readFile(path, "utf8")).trim(); + if (!/^[1-9][0-9]{0,9}$/u.test(encoded)) return false; + if (processIsAlive(Number(encoded))) return false; + await unlink(path); + return true; + } catch { + return false; + } +}; + +const processIsAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch (cause: unknown) { + return errorCode(cause) !== "ESRCH"; + } +}; + +const releasePermissionStoreLock = async ( + lock: PermissionStoreLock, +): Promise => { + await lock.handle.close().catch(() => undefined); + await unlink(lock.path).catch(() => undefined); +}; + +const isNotFound = (cause: unknown): boolean => errorCode(cause) === "ENOENT"; + +const isAlreadyExists = (cause: unknown): boolean => + errorCode(cause) === "EEXIST"; + +const errorCode = (cause: unknown): unknown => + typeof cause === "object" && cause !== null && "code" in cause + ? cause.code + : undefined; diff --git a/src/application/TerminalRenderer.ts b/src/application/TerminalRenderer.ts index caf14ed3d..562dae3af 100644 --- a/src/application/TerminalRenderer.ts +++ b/src/application/TerminalRenderer.ts @@ -69,7 +69,7 @@ export class TerminalRenderer { /** Await all queued parsing and return immutable rendered observations. */ async frames(): Promise { await this.#pending; - return this.#frames; + return structuredClone(this.#frames); } /** Whether a rendered observation exceeded its independent capture budget. */ diff --git a/src/artifacts/MachOSliceArtifactReader.ts b/src/artifacts/MachOSliceArtifactReader.ts index 6235379e1..0d9d21e52 100644 --- a/src/artifacts/MachOSliceArtifactReader.ts +++ b/src/artifacts/MachOSliceArtifactReader.ts @@ -94,7 +94,7 @@ export class MachOSliceArtifactReader implements ArtifactReader { } provenance(): readonly ArtifactCommand[] { - return this.#command === undefined ? [] : [this.#command]; + return this.#command === undefined ? [] : [structuredClone(this.#command)]; } close(): Promise { diff --git a/src/artifacts/NativeDmgArtifactReader.ts b/src/artifacts/NativeDmgArtifactReader.ts index 03b23c8be..d0b61b2ac 100644 --- a/src/artifacts/NativeDmgArtifactReader.ts +++ b/src/artifacts/NativeDmgArtifactReader.ts @@ -103,7 +103,7 @@ export class NativeDmgArtifactReader implements ArtifactReader { } provenance(): readonly ArtifactCommand[] { - return this.#provenance; + return structuredClone(this.#provenance); } async close(): Promise { diff --git a/src/artifacts/SafeOutputTree.ts b/src/artifacts/SafeOutputTree.ts index a023f1f94..0e272bdf1 100644 --- a/src/artifacts/SafeOutputTree.ts +++ b/src/artifacts/SafeOutputTree.ts @@ -107,7 +107,7 @@ export class SafeOutputTree { } get cleanup(): SafeOutputCleanup { - return this.#cleanup; + return structuredClone(this.#cleanup); } /** Stream one selected regular file with exact byte and digest verification. */ @@ -205,7 +205,7 @@ export class SafeOutputTree { /** Remove only this operation's unsealed tree and verify absence. */ async rollback(): Promise { - if (this.#published) return this.#cleanup; + if (this.#published) return structuredClone(this.#cleanup); await rm(this.#stagingRoot, { recursive: true, force: true }); const absent = await isAbsent(this.#stagingRoot); this.#cleanup = { @@ -218,7 +218,7 @@ export class SafeOutputTree { "integrity", "Extraction output cleanup could not be verified", ); - return this.#cleanup; + return structuredClone(this.#cleanup); } async #prepareParent(relativePath: string): Promise { diff --git a/src/cliPolicyCommands.ts b/src/cliPolicyCommands.ts index 931ff03df..0ed60785b 100644 --- a/src/cliPolicyCommands.ts +++ b/src/cliPolicyCommands.ts @@ -3,7 +3,7 @@ import { Cli, z } from "incur"; import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js"; import { readProjectPermissionStore, - writeProjectPermissionStore, + revokeProjectPermissionGrant, } from "./application/ProjectPermissionStore.js"; import { logCliCommand } from "./cliLogging.js"; import { parseConfig, type AppConfig } from "./config.js"; @@ -68,24 +68,26 @@ export const registerPolicyCommands = ( if (args.action === "revoke") { if (args.value === undefined) return { error: "Grant ID is required for policy revoke" }; - const result = await readProjectGrants(config.value); - if (!result.ok) return result.error; - const retained = result.grants.filter( - ({ grant_id }) => grant_id !== args.value, + const { permissionProjectRoot, permissionProjectStore } = + config.value; + if ( + permissionProjectRoot === undefined || + permissionProjectStore === undefined + ) + return { error: "Project policy is not configured" }; + const revoked = await revokeProjectPermissionGrant( + permissionProjectStore, + permissionProjectRoot, + args.value, ); - if (retained.length === result.grants.length) - return { error: "Grant was not found", grant_id: args.value }; - const written = await writeProjectPermissionStore( - result.path, - result.root, - retained, - ); - return written.ok + return revoked.ok && revoked.value ? { revoked: args.value, reload: "Send SIGHUP to the REA MCP process", } - : { error: written.error.message }; + : revoked.ok + ? { error: "Grant was not found", grant_id: args.value } + : { error: revoked.error.message }; } const capability = capabilitySchema.safeParse(args.value); if (!capability.success) diff --git a/src/domain/changedBehavior.ts b/src/domain/changedBehavior.ts index 4c4e087fc..1d8564123 100644 --- a/src/domain/changedBehavior.ts +++ b/src/domain/changedBehavior.ts @@ -5,6 +5,8 @@ import { evidenceSchema, parseEvidence, type Evidence } from "./evidence.js"; import { functionComparisonResultSchema } from "./functionComparison.js"; import { comparisonStatusSchema, + deriveProcessComparisonStatus, + PROCESS_COMPARISON_DIMENSIONS, processCaptureComparisonSchema, } from "./processCapture.js"; import { @@ -205,20 +207,9 @@ const parseResult = (evidence: Evidence): void => { const result = processCaptureComparisonSchema.parse( evidence.normalized_result, ); - const dimensions = [ - result.terminal, - result.exit, - result.filesystem, - result.protocol, - result.process, - ]; - const expected = dimensions.includes("truncated") - ? "truncated" - : dimensions.some(isChange) - ? "changed" - : dimensions.includes("unknown") - ? "unknown" - : "unchanged"; + const expected = deriveProcessComparisonStatus( + PROCESS_COMPARISON_DIMENSIONS.map((dimension) => result[dimension]), + ); if (result.status !== expected) throw new TypeError( "Process comparison status contradicts its dimensions", @@ -273,10 +264,12 @@ const processFindings = (evidence: Evidence): Finding[] => { ); const dimensions = [ ["terminal", result.terminal, "runtime"], + ["interaction", result.interaction, "runtime"], ["exit", result.exit, "runtime"], ["filesystem", result.filesystem, "resource"], ["protocol", result.protocol, "protocol"], ["process", result.process, "runtime"], + ["shim", result.shim, "protocol"], ] as const; return dimensions.flatMap(([dimension, status, scope]) => status === "unchanged" diff --git a/src/domain/processCapture.ts b/src/domain/processCapture.ts index 2ad10ed0a..7d28cff44 100644 --- a/src/domain/processCapture.ts +++ b/src/domain/processCapture.ts @@ -156,10 +156,12 @@ export interface ProcessCapture { readonly residual_unknowns: readonly { readonly scope: | "terminal" + | "interaction" | "exit" | "process" | "filesystem" | "protocol" + | "shim" | "cleanup" | "network"; readonly reason: string; @@ -309,10 +311,12 @@ export const processCaptureSchema: z.ZodType = z.object({ z.object({ scope: z.enum([ "terminal", + "interaction", "exit", "process", "filesystem", "protocol", + "shim", "cleanup", "network", ]), @@ -349,5 +353,7 @@ export const parseProcessCapture = (input: unknown): ProcessCapture => { export { compareProcessCaptures, comparisonStatusSchema, + deriveProcessComparisonStatus, + PROCESS_COMPARISON_DIMENSIONS, processCaptureComparisonSchema, } from "./processComparison.js"; diff --git a/src/domain/processComparison.ts b/src/domain/processComparison.ts index 6e02bb335..19e371b4a 100644 --- a/src/domain/processComparison.ts +++ b/src/domain/processComparison.ts @@ -16,6 +16,31 @@ export const comparisonStatusSchema = z.enum([ ]); type ComparisonStatus = z.infer; +/** Canonical ordered dimensions that contribute to an overall process status. */ +export const PROCESS_COMPARISON_DIMENSIONS = [ + "terminal", + "interaction", + "exit", + "filesystem", + "protocol", + "process", + "shim", +] as const; + +/** Derive the overall comparison status from every process dimension. */ +export const deriveProcessComparisonStatus = ( + dimensions: readonly ComparisonStatus[], +): ComparisonStatus => { + if (dimensions.includes("truncated")) return "truncated"; + if ( + dimensions.some((status) => + ["changed", "added", "removed"].includes(status), + ) + ) + return "changed"; + return dimensions.includes("unknown") ? "unknown" : "unchanged"; +}; + /** Pure normalized comparison between two captures. */ export const processCaptureComparisonSchema = z .object({ @@ -51,24 +76,9 @@ export const processCaptureComparisonSchema = z limitations: z.array(z.string()), }) .superRefine((comparison, context) => { - const dimensions = [ - comparison.terminal, - comparison.interaction, - comparison.exit, - comparison.filesystem, - comparison.protocol, - comparison.process, - comparison.shim, - ]; - const expected = dimensions.includes("truncated") - ? "truncated" - : dimensions.some((status) => - ["added", "removed", "changed"].includes(status), - ) - ? "changed" - : dimensions.includes("unknown") - ? "unknown" - : "unchanged"; + const expected = deriveProcessComparisonStatus( + PROCESS_COMPARISON_DIMENSIONS.map((dimension) => comparison[dimension]), + ); if (comparison.status !== expected) context.addIssue({ code: "custom", @@ -118,18 +128,6 @@ const hasUnknown = ( scope: ProcessCapture["residual_unknowns"][number]["scope"], ): boolean => capture.residual_unknowns.some((item) => item.scope === scope); -const comparisonStatus = ( - dimensions: readonly ComparisonStatus[], -): ComparisonStatus => { - if ( - dimensions.some((status) => - ["changed", "added", "removed"].includes(status), - ) - ) - return "changed"; - return dimensions.includes("unknown") ? "unknown" : "unchanged"; -}; - type DivergenceDimension = Exclude< ProcessCaptureComparison["first_divergence"], { readonly status: "none" | "unknown" } @@ -277,7 +275,7 @@ const compareDimensions = ( terminalObservations(right), ), interaction: classify( - "terminal", + "interaction", left.interaction_events, right.interaction_events, ), @@ -295,9 +293,7 @@ const compareDimensions = ( ), protocol: classify("protocol", left.protocol_events, right.protocol_events), process: classify("process", left.process_samples, right.process_samples), - shim: sameNormalization - ? classifyCollection(left.shim_events, right.shim_events) - : "unknown", + shim: classify("shim", left.shim_events, right.shim_events), }; }; @@ -367,15 +363,9 @@ export const compareProcessCaptures = ( } as const) : observedFirstDivergence; return { - status: comparisonStatus([ - dimensions.terminal, - dimensions.interaction, - dimensions.exit, - dimensions.filesystem, - dimensions.protocol, - dimensions.process, - dimensions.shim, - ]), + status: deriveProcessComparisonStatus( + PROCESS_COMPARISON_DIMENSIONS.map((dimension) => dimensions[dimension]), + ), ...dimensions, first_divergence: firstDivergence, limitations: [ diff --git a/src/domain/reconstructionVerification.ts b/src/domain/reconstructionVerification.ts index 95edff2a8..187b5dd61 100644 --- a/src/domain/reconstructionVerification.ts +++ b/src/domain/reconstructionVerification.ts @@ -10,6 +10,8 @@ import { parseEvidenceBundle } from "./evidenceBundle.js"; import { functionComparisonResultSchema } from "./functionComparison.js"; import { functionDossierSchema } from "./hopperValues.js"; import { + deriveProcessComparisonStatus, + PROCESS_COMPARISON_DIMENSIONS, processCaptureComparisonSchema, processCaptureSchema, } from "./processCapture.js"; @@ -319,22 +321,9 @@ const observedStatus = (claim: Claim, evidence: Evidence): ObservedStatus => { const validateProcessStatus = ( result: z.infer, ): void => { - const dimensions = [ - result.terminal, - result.exit, - result.filesystem, - result.protocol, - result.process, - ]; - const expected = dimensions.includes("truncated") - ? "truncated" - : dimensions.some((status) => - ["added", "removed", "changed"].includes(status), - ) - ? "changed" - : dimensions.includes("unknown") - ? "unknown" - : "unchanged"; + const expected = deriveProcessComparisonStatus( + PROCESS_COMPARISON_DIMENSIONS.map((dimension) => result[dimension]), + ); if (result.status !== expected) throw new TypeError("Process comparison status contradicts its dimensions"); }; diff --git a/src/domain/reconstructionVerificationSchemas.ts b/src/domain/reconstructionVerificationSchemas.ts index 50f9ca319..cd92975bc 100644 --- a/src/domain/reconstructionVerificationSchemas.ts +++ b/src/domain/reconstructionVerificationSchemas.ts @@ -20,10 +20,12 @@ const behavioralClaimSchema = z.object({ dimension: z.enum([ "overall", "terminal", + "interaction", "exit", "filesystem", "protocol", "process", + "shim", ]), }); const functionClaimSchema = z.object({ diff --git a/src/domain/staticRuntimeCorrelation.ts b/src/domain/staticRuntimeCorrelation.ts index ad7a4fe5b..6db63416f 100644 --- a/src/domain/staticRuntimeCorrelation.ts +++ b/src/domain/staticRuntimeCorrelation.ts @@ -25,10 +25,12 @@ const functionDimensionSchema = z.enum([ ]); const runtimeDimensionSchema = z.enum([ "terminal", + "interaction", "exit", "filesystem", "protocol", "process", + "shim", ]); const expectedPatternSchema = z.enum([ "cochanged", diff --git a/src/domain/symbolAnalysis.ts b/src/domain/symbolAnalysis.ts index 1b4ba0508..8dda33eee 100644 --- a/src/domain/symbolAnalysis.ts +++ b/src/domain/symbolAnalysis.ts @@ -26,11 +26,8 @@ export const discoverObjcClasses = ( ): JsonValue => { const classes = uniqueByName( names - .filter( - ({ name }) => - ["OBJC_CLASS", "OBJC_$_CLASS"].some((marker) => - name.includes(marker), - ) || name.startsWith("_OBJC_"), + .filter(({ name }) => + ["OBJC_CLASS", "OBJC_$_CLASS"].some((marker) => name.includes(marker)), ) .filter(({ name }) => pattern.length === 0 || name.includes(pattern)), ); diff --git a/src/native/CommandRunner.ts b/src/native/CommandRunner.ts index 42e8b5a7d..62b5dabe2 100644 --- a/src/native/CommandRunner.ts +++ b/src/native/CommandRunner.ts @@ -52,6 +52,11 @@ export interface NativeCommandRunner { ): Promise>; } +/** Resolve one allowlisted native tool to an immutable executable identity. */ +export type NativeToolResolver = ( + tool: string, +) => Promise>; + /** Run allowlisted Xcode tools directly with bounded output and no shell. */ export class XcrunCommandRunner implements NativeCommandRunner { readonly #resolved = new Map< @@ -59,6 +64,11 @@ export class XcrunCommandRunner implements NativeCommandRunner { Promise> >(); + constructor( + private readonly resolveTool: NativeToolResolver = (tool) => + resolveXcrunTool(tool), + ) {} + run( tool: string, arguments_: readonly string[], @@ -96,16 +106,22 @@ export class XcrunCommandRunner implements NativeCommandRunner { }); } - #resolve(tool: string): Promise> { + async #resolve( + tool: string, + ): Promise> { const existing = this.#resolved.get(tool); if (existing !== undefined) return existing; - const pending = resolveTool(tool); + const pending = this.resolveTool(tool); this.#resolved.set(tool, pending); - return pending; + const resolved = await pending; + if (!resolved.ok && this.#resolved.get(tool) === pending) + this.#resolved.delete(tool); + return resolved; } } -interface ResolvedTool { +/** Immutable executable identity returned by native tool discovery. */ +export interface ResolvedTool { readonly path: string; readonly sha256: string; } @@ -124,7 +140,7 @@ const ALLOWED_TOOLS = new Set([ "vtool", ]); -const resolveTool = async ( +const resolveXcrunTool = async ( tool: string, ): Promise> => { const found = await captureProcess( diff --git a/src/server/registerProcessComparisonTool.ts b/src/server/registerProcessComparisonTool.ts index b0209ba6f..7425f4aa1 100644 --- a/src/server/registerProcessComparisonTool.ts +++ b/src/server/registerProcessComparisonTool.ts @@ -150,10 +150,12 @@ const comparisonUnknownInput = ( return undefined; const differingScopes = [ ["terminal", comparison.terminal], + ["interaction", comparison.interaction], ["exit", comparison.exit], ["filesystem", comparison.filesystem], ["protocol", comparison.protocol], ["process", comparison.process], + ["shim", comparison.shim], ] .filter(([, status]) => status !== "unchanged") .map(([scope]) => scope) diff --git a/src/server/registerSessionTools.ts b/src/server/registerSessionTools.ts index 3fab9e7fb..557be110c 100644 --- a/src/server/registerSessionTools.ts +++ b/src/server/registerSessionTools.ts @@ -303,6 +303,7 @@ const registerEvidenceTools = ({ 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({ @@ -378,11 +379,13 @@ const registerUnknownTools = ({ outputSchema: recordContract.outputSchema, annotations: recordContract.annotations, }, - (input) => - toCallToolResult( - session.recordUnknown(recordUnknownInputSchema.parse(input)), - recordContract, - ), + (input) => { + const recorded = session.recordUnknown( + recordUnknownInputSchema.parse(input), + ); + if (recorded.ok) server.sendResourceListChanged(); + return toCallToolResult(recorded, recordContract); + }, ); server.registerTool( updateContract.name, @@ -392,11 +395,13 @@ const registerUnknownTools = ({ outputSchema: updateContract.outputSchema, annotations: updateContract.annotations, }, - (input) => - toCallToolResult( - session.updateUnknown(updateUnknownInputSchema.parse(input)), - updateContract, - ), + (input) => { + const updated = session.updateUnknown( + updateUnknownInputSchema.parse(input), + ); + if (updated.ok) server.sendResourceListChanged(); + return toCallToolResult(updated, updateContract); + }, ); server.registerTool( verifyContract.name, diff --git a/src/server/runDerivedOperation.ts b/src/server/runDerivedOperation.ts index eb58d734c..5b816c0c4 100644 --- a/src/server/runDerivedOperation.ts +++ b/src/server/runDerivedOperation.ts @@ -1,6 +1,9 @@ import { setImmediate } from "node:timers/promises"; -import { AnalysisCancelledError } from "../domain/errors.js"; +import { + AnalysisCancelledError, + AnalysisInputError, +} from "../domain/errors.js"; import { err, ok, type Result } from "../domain/result.js"; import { mcpProgressReporter } from "./mcpProgress.js"; @@ -25,7 +28,7 @@ export const runDerivedOperation = async ( context: DerivedOperationContext, operation: string, compute: () => Value, -): Promise> => { +): Promise> => { const progress = mcpProgressReporter(context); await progress.report({ phase: "prepare", @@ -36,7 +39,16 @@ export const runDerivedOperation = async ( await setImmediate(); if (context.mcpReq.signal.aborted) return err(new AnalysisCancelledError(operation)); - const value = compute(); + let value: Value; + try { + value = compute(); + } catch (cause: unknown) { + return err( + cause instanceof AnalysisCancelledError + ? cause + : new AnalysisInputError(operation, { cause }), + ); + } await progress.report({ phase: "compute", completed: 1, diff --git a/tests/analysisSnapshot.test.ts b/tests/analysisSnapshot.test.ts index 50650b538..13a639dd3 100644 --- a/tests/analysisSnapshot.test.ts +++ b/tests/analysisSnapshot.test.ts @@ -8,6 +8,7 @@ import { readAnalysisSnapshot, writeAnalysisSnapshot, } from "../src/application/AnalysisSnapshotFiles.js"; +import { AnalysisSnapshotCache } from "../src/application/AnalysisSnapshotCache.js"; import { createAnalysisSnapshotEntry, parseAnalysisSnapshot, @@ -27,6 +28,49 @@ afterEach(async () => { }); describe("analysis snapshots", () => { + it("returns detached cache entries and evidence bundles", () => { + const cache = new AnalysisSnapshotCache(); + const target = { + path: "/tmp/app", + sha256: "a".repeat(64), + kind: "executable", + format: "mach-o", + architecture: "arm64", + loaderArgs: [], + } as const; + const provider = { id: "fixture", name: "Fixture", version: "1" }; + cache.record( + target, + "address_name", + { address: "0x1000", document: "fixture" }, + createAnalysisExecution("main", provider), + ); + const exported = cache.export(target, createEvidenceBundle([])); + expect(exported.ok).toBe(true); + if (!exported.ok) return; + const entry = exported.value.entries[0]; + if (entry !== undefined) { + Reflect.set(entry.execution, "provider", { + id: "forged", + name: "Forged", + version: "9", + }); + (entry.execution.limitations as string[]).push("forged"); + } + + expect( + cache.lookup( + target, + "address_name", + { address: "0x1000", document: "fixture" }, + provider, + ), + ).toMatchObject({ + provider, + limitations: [expect.stringContaining("local REA analysis snapshot")], + }); + }); + it("finds exact CLI evidence for provider-free replay", () => { const target = { path: "/tmp/app", diff --git a/tests/artifactProvider.test.ts b/tests/artifactProvider.test.ts index 4db2aa009..e05f9ffe0 100644 --- a/tests/artifactProvider.test.ts +++ b/tests/artifactProvider.test.ts @@ -382,6 +382,10 @@ describe("artifact graph provider", () => { expect(reader.provenance()).toEqual([ expect.objectContaining({ tool: "lipo", effects: ["read"] }), ]); + const provenance = reader.provenance(); + if (provenance[0] !== undefined) + Reflect.set(provenance[0], "tool", "forged"); + expect(reader.provenance()[0]?.tool).toBe("lipo"); }); }); diff --git a/tests/binarySession.test.ts b/tests/binarySession.test.ts index f296c6a5a..ab90a8666 100644 --- a/tests/binarySession.test.ts +++ b/tests/binarySession.test.ts @@ -14,6 +14,8 @@ import { ProviderAdapterError, } from "../src/domain/errors.js"; import { err } from "../src/domain/result.js"; +import { createEvidenceBundle } from "../src/domain/evidenceBundle.js"; +import { createInvestigationWorkspace } from "../src/domain/investigationWorkspace.js"; import { observed as ok } from "./fixtures/analysisExecution.js"; let directory: string | undefined; @@ -24,6 +26,35 @@ afterEach(async () => { }); describe("binary session", () => { + it("returns detached provider, target, and workspace metadata", async () => { + const [first] = await targets(); + const session = new BinarySession(cacheProvider([])); + expect((await session.open(first)).ok).toBe(true); + const identity = session.providerIdentity(); + Reflect.set(identity, "id", "forged"); + expect(session.providerIdentity().id).toBe("fixture"); + + const active = session.activeTarget(); + expect(active).toBeDefined(); + if (active !== undefined) Reflect.set(active, "path", "/tmp/forged"); + expect(session.activeTarget()?.path).toBe(first); + + const workspace = createInvestigationWorkspace( + "Detached workspace", + createEvidenceBundle([]), + [], + ); + expect(session.retainInvestigationWorkspace(workspace)).toBe("added"); + const direct = session.investigationWorkspace(workspace.workspace_id, 1); + if (direct !== undefined) Reflect.set(direct, "revision", 999); + const listed = session.investigationWorkspaces()[0]; + if (listed !== undefined) Reflect.set(listed, "workspace_id", "forged"); + expect( + session.investigationWorkspace(workspace.workspace_id, 1), + ).toMatchObject({ revision: 1, workspace_id: workspace.workspace_id }); + await session.close(); + }); + it("runs through a non-Hopper analysis provider", async () => { const [first] = await targets(); const operations: string[] = []; diff --git a/tests/changedBehavior.test.ts b/tests/changedBehavior.test.ts index 9a2c47b01..8a41aa4e4 100644 --- a/tests/changedBehavior.test.ts +++ b/tests/changedBehavior.test.ts @@ -53,17 +53,19 @@ const processResult = ( overrides: Partial<{ status: "unchanged" | "changed" | "unknown" | "truncated"; terminal: "unchanged" | "changed" | "unknown" | "truncated"; + interaction: "unchanged" | "changed" | "unknown" | "truncated"; exit: "unchanged" | "changed" | "unknown" | "truncated"; + shim: "unchanged" | "changed" | "unknown" | "truncated"; }> = {}, ): JsonValue => ({ status: overrides.status ?? "unchanged", terminal: overrides.terminal ?? "unchanged", - interaction: "unchanged", + interaction: overrides.interaction ?? "unchanged", exit: overrides.exit ?? "unchanged", filesystem: "unchanged", protocol: "unchanged", process: "unchanged", - shim: "unchanged", + shim: overrides.shim ?? "unchanged", first_divergence: overrides.status === "changed" ? { @@ -121,6 +123,31 @@ const artifactResult = ( }); describe("changed behavior", () => { + it("reports interaction and shim-only process changes", () => { + const evidence = comparison( + "compare_process_captures", + processResult({ + status: "changed", + interaction: "changed", + shim: "changed", + }), + ); + + expect(findChangedBehavior([evidence], 0, 100)).toMatchObject({ + behavior_status: "observed_changed", + summary: { observed_changes: 2 }, + findings: { + items: expect.arrayContaining([ + expect.objectContaining({ + dimension: "interaction", + scope: "runtime", + }), + expect.objectContaining({ dimension: "shim", scope: "protocol" }), + ]), + }, + }); + }); + it("classifies runtime changes as observed and cites both observations", () => { const evidence = comparison( "compare_process_captures", diff --git a/tests/derivedOperation.test.ts b/tests/derivedOperation.test.ts index 5d1fe9c9f..ece61cab3 100644 --- a/tests/derivedOperation.test.ts +++ b/tests/derivedOperation.test.ts @@ -69,4 +69,22 @@ describe("derived MCP operation boundary", () => { expect(notifications.map(({ params }) => params?.progress)).toEqual([0, 2]); expect(notifications.every(({ params }) => params?.total === 2)).toBe(true); }); + + it("projects synchronous computation failures into a typed result", async () => { + const result = await runDerivedOperation( + context(new AbortController()), + "compare_artifacts", + () => { + throw new TypeError("malformed comparison evidence"); + }, + ); + + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisInputError", + operation: "compare_artifacts", + }, + }); + }); }); diff --git a/tests/enhancedTools.test.ts b/tests/enhancedTools.test.ts index f48f7228e..ca4883fe9 100644 --- a/tests/enhancedTools.test.ts +++ b/tests/enhancedTools.test.ts @@ -215,8 +215,7 @@ describe("enhanced MCP tools", () => { ); } expect(results[0]).toMatchObject({ count: 1 }); - // Legacy behavior treats any `_OBJC_` label, including protocol labels, as a class. - expect(results[1]).toMatchObject({ count: 2 }); + expect(results[1]).toMatchObject({ count: 1 }); expect(results[2]).toMatchObject({ count: 1 }); expect(results[3]).toEqual({ items: [ diff --git a/tests/integration/serverIntegration.test.ts b/tests/integration/serverIntegration.test.ts index e42faba03..ebc3b6809 100644 --- a/tests/integration/serverIntegration.test.ts +++ b/tests/integration/serverIntegration.test.ts @@ -290,7 +290,27 @@ describe("full MCP integration with multi-tool sequences", () => { const left = processCaptureSchema.parse(EMPTY_PROCESS_CAPTURE_EXAMPLE); const right = processCaptureSchema.parse({ ...EMPTY_PROCESS_CAPTURE_EXAMPLE, - exit: { code: 1, signal: null, reason: "exited" }, + interaction_events: [ + { + sequence: 0, + scheduled_at_ms: 0, + dispatched_at_ms: 0, + type: "input", + data: "fixture", + outcome: "dispatched", + }, + ], + shim_events: [ + { + sequence: 0, + at_ms: 0, + command: "fixture", + route_index: null, + arguments: [], + working_directory: "/tmp", + outcome: "unmatched", + }, + ], }); const captureEvidence = (capture: typeof left) => createEvidence(undefined, PROCESS_PROVIDER, { @@ -347,6 +367,7 @@ describe("full MCP integration with multi-tool sequences", () => { { status: "contradicted", domain: "process-comparison", + question: "Process captures disagree across: interaction, shim", contradicting_evidence_ids: [rightEvidence.evidence_id], }, ], diff --git a/tests/nativeDmgArtifactReader.test.ts b/tests/nativeDmgArtifactReader.test.ts index f5cae14b2..8959f4fc3 100644 --- a/tests/nativeDmgArtifactReader.test.ts +++ b/tests/nativeDmgArtifactReader.test.ts @@ -41,6 +41,10 @@ describe("native DMG artifact reader", () => { const entries = []; for await (const entry of reader.entries()) entries.push(entry.path); expect(entries).toContain("image.dmg/Fixture/hello.txt"); + const provenance = reader.provenance(); + if (provenance[0] !== undefined) + Reflect.set(provenance[0], "tool", "forged"); + expect(reader.provenance()[0]?.tool).toBe("hdiutil"); await reader.close(); expect(calls).toContainEqual(["verify", "/tmp/image.dmg"]); expect(calls).toContainEqual(["detach", "/dev/disk-fixture"]); diff --git a/tests/nativeProvider.test.ts b/tests/nativeProvider.test.ts index b15077d28..fe9afa7ef 100644 --- a/tests/nativeProvider.test.ts +++ b/tests/nativeProvider.test.ts @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { NativeMacOSProvider } from "../src/native/NativeMacOSProvider.js"; import { NativeCommandFailure, + XcrunCommandRunner, type NativeCommandCapture, type NativeCommandRunner, } from "../src/native/CommandRunner.js"; @@ -22,6 +23,24 @@ afterEach(async () => { }); describe("native macOS provider", () => { + it("retries a failed native tool resolution and caches only success", async () => { + let resolutions = 0; + const runner = new XcrunCommandRunner((tool) => { + resolutions += 1; + return Promise.resolve( + resolutions === 1 + ? err(new NativeCommandFailure(tool, "unavailable")) + : ok({ path: "/bin/true", sha256: "a".repeat(64) }), + ); + }); + const options = { timeoutMs: 1_000, maxOutputBytes: 1_024 }; + + expect((await runner.run("file", [], options)).ok).toBe(false); + expect((await runner.run("file", [], options)).ok).toBe(true); + expect((await runner.run("file", [], options)).ok).toBe(true); + expect(resolutions).toBe(2); + }); + it("normalizes comprehensive Mach-O inspection with exact bounded provenance", async () => { const runner = new FixtureRunner(); const client = new NativeMacOSProvider(runner, "darwin").createClient( diff --git a/tests/processCapture.test.ts b/tests/processCapture.test.ts index 7c8804810..db914a9ac 100644 --- a/tests/processCapture.test.ts +++ b/tests/processCapture.test.ts @@ -13,6 +13,14 @@ import { ProcessCaptureError, } from "../src/application/ProcessHarness.js"; import { snapshotRoots } from "../src/application/FilesystemSnapshot.js"; +import { + prepareProcessCapture, + type ProcessPreparationHost, +} from "../src/application/ProcessCaptureLifecycle.js"; +import { ProcessCheckpoints } from "../src/application/ProcessCheckpoints.js"; +import { normalizeProcessSamples } from "../src/application/ProcessNormalization.js"; +import { readLinuxChildren } from "../src/application/ProcessSampling.js"; +import { TerminalRenderer } from "../src/application/TerminalRenderer.js"; import { authorizeProcessScenario, compareProcessCaptures, @@ -106,6 +114,132 @@ describe("process capture domain", () => { working_directory: "/tmp", }; + it("returns detached terminal and filesystem checkpoint observations", async () => { + const renderer = new TerminalRenderer({ + columns: 40, + rows: 12, + scrollback: 100, + maxFrames: 10, + maxBytes: 10_000, + normalize: (value) => value, + }); + renderer.write("A", 0); + const frames = await renderer.frames(); + if (frames[0] !== undefined) Reflect.set(frames[0], "cursor_x", 999); + expect((await renderer.frames())[0]?.cursor_x).not.toBe(999); + await renderer.dispose(); + + const scenario = parseProcessScenario(base); + const checkpoints = new ProcessCheckpoints( + scenario, + Date.now(), + { files: [], truncated: false }, + undefined, + ); + const first = await checkpoints.finish({ files: [], truncated: false }); + if (first[0] !== undefined) Reflect.set(first[0], "name", "tampered"); + const second = await checkpoints.finish({ files: [], truncated: false }); + expect(second[0]?.name).toBe("before"); + await checkpoints.dispose(); + }); + + it("cleans the temporary root when capture home creation fails", async () => { + const cleaned: string[] = []; + const host: ProcessPreparationHost = { + createTemporaryRoot: () => Promise.resolve("/tmp/rea-process-fixture"), + createHome: () => Promise.reject(new Error("mkdir failed")), + cleanup: (path) => { + cleaned.push(path); + return Promise.resolve(); + }, + }; + + await expect( + prepareProcessCapture( + parseProcessScenario(base), + { + enabled: true, + executableRoots: ["/bin"], + workingRoots: ["/tmp"], + allowedEnvironment: [], + allowExternalNetwork: true, + }, + undefined, + host, + ), + ).rejects.toThrow("mkdir failed"); + expect(cleaned).toEqual(["/tmp/rea-process-fixture"]); + }); + + it("normalizes every sampled process identifier in command text", () => { + const samples = normalizeProcessSamples( + [ + { + at_ms: 0, + pid: 101, + parent_pid: 0, + process_group_id: 101, + session_id: 101, + command: "root 101", + }, + { + at_ms: 10, + pid: 202, + parent_pid: 101, + process_group_id: 101, + session_id: 101, + command: "child 202 peer=101 unrelated 1202", + }, + ], + parseProcessScenario(base), + 101, + ); + + expect(samples[1]?.command).toBe("child peer= unrelated 1202"); + }); + + it("collects and deduplicates children from every Linux thread", async () => { + const signal = new AbortController().signal; + expect( + await readLinuxChildren(100, signal, { + taskIds: () => Promise.resolve([100, 101, 102]), + children: (_pid, taskId) => + Promise.resolve( + taskId === 100 ? "201 202" : taskId === 101 ? "202 203" : "", + ), + }), + ).toEqual([201, 202, 203]); + }); + + it("keeps interaction and shim residual uncertainty in separate scopes", () => { + const baseCapture = emptyCapture(); + const interaction = parseProcessCapture({ + ...baseCapture, + residual_unknowns: [ + { scope: "interaction", reason: "Interaction capture was partial." }, + ], + }); + const shim = parseProcessCapture({ + ...baseCapture, + residual_unknowns: [ + { scope: "shim", reason: "Shim capture was partial." }, + ], + }); + + expect(compareProcessCaptures(interaction, baseCapture)).toMatchObject({ + status: "unknown", + terminal: "unchanged", + interaction: "unknown", + shim: "unchanged", + }); + expect(compareProcessCaptures(shim, baseCapture)).toMatchObject({ + status: "unknown", + terminal: "unchanged", + interaction: "unchanged", + shim: "unknown", + }); + }); + it("cancels filesystem snapshots before traversing declared roots", async () => { const controller = new AbortController(); controller.abort(); diff --git a/tests/processCli.test.ts b/tests/processCli.test.ts index 3c8372190..1af41cf62 100644 --- a/tests/processCli.test.ts +++ b/tests/processCli.test.ts @@ -175,4 +175,17 @@ describe("process CLI errors", () => { "Capture evidence is not from the current process-capture workflow. Create new capture evidence, then try again.", }); }); + + it("classifies malformed capture evidence as invalid input", async () => { + const root = await fixture(); + const malformed = join(root, "malformed-evidence.json"); + await writeFile(malformed, "{}"); + + expect(await compareProcessEvidenceFiles(malformed, malformed)).toEqual({ + error: "Process command failed", + category: "invalid_input", + message: + "Capture evidence is malformed. Create new capture evidence, then try again.", + }); + }); }); diff --git a/tests/processOwnership.test.ts b/tests/processOwnership.test.ts index d98927a36..09f0bd985 100644 --- a/tests/processOwnership.test.ts +++ b/tests/processOwnership.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { cleanupOwnedProcessGroup, + parseProcessEnvironment, type ProcessOwnershipHost, } from "../src/application/ProcessOwnership.js"; @@ -36,6 +37,12 @@ const host = ( }; describe("owned process-group cleanup", () => { + it("drops nameless Linux environment entries", () => { + expect( + parseProcessEnvironment("=ignored\0REA_PROCESS_RUN_ID=owned\0EMPTY=\0"), + ).toEqual({ REA_PROCESS_RUN_ID: "owned", EMPTY: "" }); + }); + it("signals only a group whose every member carries the run token", async () => { const { adapter, signalGroup } = host({ 100: { REA_PROCESS_RUN_ID: "run-token" }, diff --git a/tests/projectPermissionStore.test.ts b/tests/projectPermissionStore.test.ts index 572d442b5..823731934 100644 --- a/tests/projectPermissionStore.test.ts +++ b/tests/projectPermissionStore.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest"; import { readProjectPermissionStore, + revokeProjectPermissionGrant, writeProjectPermissionStore, } from "../src/application/ProjectPermissionStore.js"; @@ -60,4 +61,43 @@ describe("project permission store", () => { error: { reason: "invalid" }, }); }); + + it("serializes concurrent grant revocations without losing an update", async () => { + const project = await mkdtemp(join(tmpdir(), "rea-policy-revoke-")); + const path = join(project, ".rea", "permissions.json"); + const grant = (grantId: string) => ({ + grant_id: grantId, + capability: "evidence_read" as const, + roots: [project], + executables: [], + environment_names: [], + network: "none" as const, + mount: false, + lifetime: "project" as const, + operation_identity: null, + expires_at: null, + }); + expect( + ( + await writeProjectPermissionStore(path, project, [ + grant("grant-one"), + grant("grant-two"), + ]) + ).ok, + ).toBe(true); + + const revoked = await Promise.all([ + revokeProjectPermissionGrant(path, project, "grant-one"), + revokeProjectPermissionGrant(path, project, "grant-two"), + ]); + + expect(revoked).toEqual([ + { ok: true, value: true }, + { ok: true, value: true }, + ]); + expect(await readProjectPermissionStore(path, project)).toMatchObject({ + ok: true, + value: { grants: [] }, + }); + }); }); diff --git a/tests/reconstructionVerification.test.ts b/tests/reconstructionVerification.test.ts index f362116f6..9d2900272 100644 --- a/tests/reconstructionVerification.test.ts +++ b/tests/reconstructionVerification.test.ts @@ -161,6 +161,62 @@ const artifactComparison = ( ); describe("reconstruction verification", () => { + it("accepts interaction and shim behavioral claim dimensions", () => { + const left = source("1", "controlled-replay"); + const right = source("2", "controlled-replay"); + const result = { + status: "changed", + terminal: "unchanged", + interaction: "changed", + exit: "unchanged", + filesystem: "unchanged", + protocol: "unchanged", + process: "unchanged", + shim: "unchanged", + first_divergence: { + status: "found", + dimension: "interaction", + index: 0, + left_at_ms: 0, + right_at_ms: 0, + left: "left", + right: "right", + }, + limitations: [], + } satisfies JsonValue; + const comparison = processComparison(left, right, result); + + expect( + verifyReconstruction( + { + schema_version: 1, + name: "Interaction reconstruction", + claims: [ + { + kind: "behavioral", + claim_id: "interaction", + title: "Interaction remains equivalent", + comparison_evidence_id: comparison.evidence_id, + dimension: "interaction", + }, + { + kind: "behavioral", + claim_id: "shim", + title: "Shim remains equivalent", + comparison_evidence_id: comparison.evidence_id, + dimension: "shim", + }, + ], + }, + createEvidenceBundle([comparison, right, left]), + 0, + 100, + ), + ).toMatchObject({ + summary: { total: 2, passed: 1, failed: 1, unknown: 0 }, + }); + }); + it("passes a complete authoritative claim with two-sided citations", () => { const left = source("1", "controlled-replay"); const right = source("2", "controlled-replay"); diff --git a/tests/residualUnknown.test.ts b/tests/residualUnknown.test.ts index db3bf9f61..46dc86c47 100644 --- a/tests/residualUnknown.test.ts +++ b/tests/residualUnknown.test.ts @@ -78,6 +78,38 @@ const update = ( }); describe("residual unknown registry", () => { + it("returns detached unknowns and evidence bundles from every read surface", () => { + const store = ledger(); + expect(store.record(evidence("detached-record")).ok).toBe(true); + const created = store.recordUnknown( + input("Does detached state remain intact?"), + mutation("detached-unknown"), + ); + expect(created.ok).toBe(true); + if (!created.ok) return; + + Reflect.set(created.value, "status", "resolved"); + const listed = store.listUnknowns(); + expect(listed[0]?.status).toBe("open"); + if (listed[0] !== undefined) Reflect.set(listed[0], "status", "resolved"); + const verified = store.verifyUnknownResolution(created.value.unknown_id); + expect(verified).toMatchObject({ ok: true, value: { valid: false } }); + if (verified.ok) Reflect.set(verified.value.unknown, "status", "resolved"); + + const exported = store.export(); + const exportedRecord = exported.records.find( + ({ operation }) => operation === "detached-record", + ); + if (exportedRecord !== undefined) + Reflect.set(exportedRecord, "operation", "forged"); + expect(store.export().records.map(({ operation }) => operation)).toContain( + "detached-record", + ); + expect( + store.verifyUnknownResolution(created.value.unknown_id), + ).toMatchObject({ ok: true, value: { valid: false } }); + }); + it("requires approval, derives stable IDs, filters heads, and rejects duplicate creation", () => { expect(() => recordUnknownInputSchema.parse({ diff --git a/tests/safeOutputTree.test.ts b/tests/safeOutputTree.test.ts index ab8ed3665..87bf7abe9 100644 --- a/tests/safeOutputTree.test.ts +++ b/tests/safeOutputTree.test.ts @@ -56,4 +56,14 @@ describe("safe artifact output tree", () => { "visible before seal", ); }); + + it("returns detached cleanup reports", async () => { + const parent = await mkdtemp(join(tmpdir(), "rea-safe-output-")); + const tree = await SafeOutputTree.create(join(parent, "published"), LIMITS); + const cleanup = await tree.rollback(); + (cleanup.residualPaths as string[]).push("/forged/path"); + + expect(tree.cleanup.residualPaths).toEqual([]); + expect((await tree.rollback()).residualPaths).toEqual([]); + }); }); diff --git a/tests/sessionMcp.test.ts b/tests/sessionMcp.test.ts index 6bc756416..711758e82 100644 --- a/tests/sessionMcp.test.ts +++ b/tests/sessionMcp.test.ts @@ -12,7 +12,11 @@ import type { AnalysisClient } from "../src/application/AnalysisProvider.js"; import { probeProcessCaptureCapability } from "../src/application/ProcessHarness.js"; import { observed as ok } from "./fixtures/analysisExecution.js"; import { createServer } from "../src/server/createServer.js"; -import { evidenceBundleSchema } from "../src/domain/evidenceBundle.js"; +import { + createEvidenceBundle, + evidenceBundleSchema, +} from "../src/domain/evidenceBundle.js"; +import { createEvidence } from "../src/domain/evidence.js"; import { silentLogger } from "../src/logger.js"; const resources: Array<{ close(): Promise }> = []; @@ -54,6 +58,10 @@ describe("target-free MCP lifecycle", () => { }, }); const mcp = new Client({ name: "session-test", version: "1.0.0" }); + let resourceListChanges = 0; + mcp.setNotificationHandler("notifications/resources/list_changed", () => { + resourceListChanges += 1; + }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); resources.push(mcp, server); @@ -116,6 +124,31 @@ describe("target-free MCP lifecycle", () => { }), ).result, ).toEqual({ imported: 0, total: 1 }); + const externalEvidencePath = join(directory, "external-evidence.json"); + await writeFile( + externalEvidencePath, + JSON.stringify( + createEvidenceBundle([ + createEvidence( + undefined, + { id: "fixture", name: "Fixture", version: "1" }, + { operation: "external_observation", parameters: {}, result: true }, + ), + ]), + ), + ); + let changesBeforeMutation = resourceListChanges; + expect( + structured( + await mcp.callTool({ + name: "import_evidence_bundle", + arguments: { path: externalEvidencePath }, + }), + ).result, + ).toEqual({ imported: 1, total: 2 }); + await new Promise((resolve) => setImmediate(resolve)); + expect(resourceListChanges).toBe(changesBeforeMutation + 1); + changesBeforeMutation = resourceListChanges; const recordedUnknown = z .object({ result: z.object({ @@ -142,6 +175,8 @@ describe("target-free MCP lifecycle", () => { ), ).result; expect(recordedUnknown.revision).toBe(1); + await new Promise((resolve) => setImmediate(resolve)); + expect(resourceListChanges).toBe(changesBeforeMutation + 1); expect( z .object({ result: z.array(z.unknown()) }) @@ -174,6 +209,8 @@ describe("target-free MCP lifecycle", () => { }, }); expect(resolved.isError).not.toBe(true); + await new Promise((resolve) => setImmediate(resolve)); + expect(resourceListChanges).toBe(changesBeforeMutation + 2); expect( structured( await mcp.callTool({ diff --git a/tests/staticRuntimeCorrelation.test.ts b/tests/staticRuntimeCorrelation.test.ts index 35640663d..a040f291c 100644 --- a/tests/staticRuntimeCorrelation.test.ts +++ b/tests/staticRuntimeCorrelation.test.ts @@ -88,7 +88,7 @@ const processComparison = ( const mapping = ( runtime: Evidence, expected: "cochanged" | "static_only" | "runtime_only" | "both_unchanged", - dimension: "terminal" | "exit" = "terminal", + dimension: "terminal" | "interaction" | "exit" | "shim" = "terminal", ) => ({ static_comparisons: [staticComparison], runtime_comparisons: [runtime], @@ -115,6 +115,16 @@ const mapping = ( }); describe("static/runtime correlation", () => { + it("accepts interaction and shim runtime dimensions", () => { + const runtime = processComparison("unchanged"); + expect( + correlateStaticAndRuntime(mapping(runtime, "static_only", "interaction")), + ).toMatchObject({ status: "correlated" }); + expect( + correlateStaticAndRuntime(mapping(runtime, "static_only", "shim")), + ).toMatchObject({ status: "correlated" }); + }); + it("keeps consistent cochange as a hypothesis with complete citations", () => { const runtime = processComparison("changed"); const result = correlateStaticAndRuntime(mapping(runtime, "cochanged")); diff --git a/tests/symbolAnalysis.test.ts b/tests/symbolAnalysis.test.ts index 7dd271364..4989d97c2 100644 --- a/tests/symbolAnalysis.test.ts +++ b/tests/symbolAnalysis.test.ts @@ -39,6 +39,24 @@ describe("symbol analysis", () => { expect(discoverObjcProtocols(names)).toMatchObject({ count: 1 }); }); + it("excludes Objective-C ivars, metaclasses, properties, and protocols from classes", () => { + const result = discoverObjcClasses( + [ + { address: "0x1", name: "_OBJC_IVAR_$_Fixture.value" }, + { address: "0x2", name: "_OBJC_METACLASS_$_Fixture" }, + { address: "0x3", name: "_OBJC_PROP_$_Fixture.value" }, + { address: "0x4", name: "_OBJC_PROTOCOL_$_FixtureProtocol" }, + { address: "0x5", name: "_OBJC_CLASS_$_Fixture" }, + ], + "", + ); + + expect(result).toMatchObject({ + count: 1, + classes: [{ address: "0x5", name: "_OBJC_CLASS_$_Fixture" }], + }); + }); + it("categorizes every Swift mangling family and deduplicates names", () => { const result = categorizeSwiftTypes([ { address: "1", name: "_TtCClass" },