From a6b5ca4135ac2cf168470b47e97a05d8b67f3147 Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <1705004398@qq.com> Date: Tue, 14 Jul 2026 21:03:23 +0000 Subject: [PATCH 1/2] fix(application): add explicit investigation replay --- docs/investigation-workspaces.md | 11 + scripts/verify-package-investigation.mjs | 19 +- src/application/CrossVersionInvestigation.ts | 109 ++---- .../CrossVersionInvestigationEvidence.ts | 91 +++++ .../CrossVersionInvestigationReplay.ts | 341 ++++++++++++++++++ src/cliInvestigationCommands.ts | 23 +- src/domain/investigationWorkspace.ts | 4 + src/server/registerInvestigationTools.ts | 34 +- tests/investigationMcp.test.ts | 78 ++++ tests/investigationWorkspace.test.ts | 127 ++++++- 10 files changed, 729 insertions(+), 108 deletions(-) create mode 100644 src/application/CrossVersionInvestigationEvidence.ts create mode 100644 src/application/CrossVersionInvestigationReplay.ts diff --git a/docs/investigation-workspaces.md b/docs/investigation-workspaces.md index c698337a9..f4d8b79c7 100644 --- a/docs/investigation-workspaces.md +++ b/docs/investigation-workspaces.md @@ -31,6 +31,11 @@ Use `--expected-revision N` when an external coordinator needs an explicit compare-and-swap guard. Traversal, byte, page, and comparison limits are also available as CLI options. +After a completed run returns its stable run ID, `--replay-run-id RUN_ID` +selects that exact result without reading or rescanning either version input. +The workspace still requires read authority. REA rejects a selector that is +missing, incomplete, or inconsistent with the requested paths and controls. + ## MCP `find_changed_behavior` retains its existing aggregation mode. Its additive @@ -75,6 +80,12 @@ report checkpoints. A process interrupted after a checkpoint can resume from the last complete stage. A completed run is reused when both graph commitments and every bounded option are identical. +By default, REA rescans the version inputs so changed content creates a new +run. For an explicit cache-only replay, add the returned `run_id` as +`investigation_run.replay_run_id`. REA validates the workspace revision and +name, the complete run, its exact paths and controls, and every linked Evidence +record before it skips input-read authorization. + The run ID is a SHA-256 commitment to both root and graph digests plus the normalized options. Local paths and timestamps do not affect that identity. Changing either artifact or a budget creates a new run in the same workspace; diff --git a/scripts/verify-package-investigation.mjs b/scripts/verify-package-investigation.mjs index 1ab794272..13c9e2c0e 100644 --- a/scripts/verify-package-investigation.mjs +++ b/scripts/verify-package-investigation.mjs @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { readFile, writeFile } from "node:fs/promises"; +import { readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -27,11 +27,24 @@ export async function verifyPackagedInvestigation(input) { ]; const investigated = await runJson(input.cli, args, input.environment); const reused = await runJson(input.cli, args, input.environment); - const persisted = JSON.parse(await readFile(workspacePath, "utf8")); + let persisted = JSON.parse(await readFile(workspacePath, "utf8")); + const runId = investigated.normalized_result?.investigation_run?.run_id; + if (runId === undefined) + throw new Error("packaged investigation did not return a run ID"); + await Promise.all([ + rm(input.artifactArchive, { force: true }), + rm(artifactArchiveV2, { force: true }), + ]); + const replayed = await runJson( + input.cli, + [...args.slice(0, -1), "--replay-run-id", runId, "--json"], + input.environment, + ); + persisted = JSON.parse(await readFile(workspacePath, "utf8")); if ( investigated.operation !== "find_changed_behavior" || investigated.normalized_result?.behavior_status !== "unknown" || - investigated.normalized_result?.investigation_run?.run_id === undefined || + replayed.evidence_id !== investigated.evidence_id || reused.evidence_id !== investigated.evidence_id || persisted.revision !== 3 || persisted.runs?.[0]?.status !== "complete" diff --git a/src/application/CrossVersionInvestigation.ts b/src/application/CrossVersionInvestigation.ts index 7c5b2ee7f..19b43056f 100644 --- a/src/application/CrossVersionInvestigation.ts +++ b/src/application/CrossVersionInvestigation.ts @@ -1,6 +1,4 @@ -import { compareArtifacts } from "../domain/artifactComparison.js"; -import { findChangedBehavior } from "../domain/changedBehavior.js"; -import { createEvidence, type Evidence } from "../domain/evidence.js"; +import { type Evidence } from "../domain/evidence.js"; import { createEvidenceBundle, type EvidenceFilePolicy, @@ -18,18 +16,15 @@ import { createInvestigationWorkspace, crossVersionInvestigationInputSchema, investigationRunSchema, - investigationRunSummarySchema, reviseInvestigationWorkspace, type CrossVersionInvestigationInput, type InvestigationRun, type InvestigationWorkspace, } from "../domain/investigationWorkspace.js"; -import { jsonValueSchema } from "../domain/jsonValue.js"; import { err, ok, type Result } from "../domain/result.js"; import type { BinarySessionPort } from "./BinarySession.js"; import type { ProgressReporter } from "./ProgressReporter.js"; import { - AUTOMATIC_RUN_LIMITATION, createInventoryEvidencePages, runLimitations, scanVersions, @@ -42,9 +37,10 @@ import { writeInvestigationWorkspace, } from "./InvestigationWorkspaceStore.js"; import { - ARTIFACT_COMPARISON_PROVIDER, - CHANGED_BEHAVIOR_PROVIDER, -} from "./InvestigationProviders.js"; + createArtifactComparisonEvidence, + createChangedBehaviorEvidence, +} from "./CrossVersionInvestigationEvidence.js"; +import { selectCompletedInvestigationReplay } from "./CrossVersionInvestigationReplay.js"; export interface CrossVersionInvestigationOutcome { readonly evidence: Evidence; @@ -59,6 +55,7 @@ export interface CrossVersionInvestigationExecution { readonly signal?: AbortSignal; readonly progress?: ProgressReporter; readonly integrityContinueEnabled?: boolean; + readonly authorizeInputRead?: () => Promise>; readonly authorizeWorkspaceWrite?: () => Promise>; } @@ -84,6 +81,23 @@ export const runCrossVersionInvestigation = async ( if (!preflight.ok) return preflight; if (isCancelled(execution.signal)) return err(new AnalysisCancelledError("find_changed_behavior")); + if (parsed.data.replay_run_id !== undefined) { + const replay = selectCompletedInvestigationReplay( + initial.value, + parsed.data, + ); + if (!replay.ok) return replay; + return completeOutcome( + replay.value.workspace, + replay.value.run, + true, + execution.session, + ); + } + if (execution.authorizeInputRead !== undefined) { + const authorized = await execution.authorizeInputRead(); + if (!authorized.ok) return authorized; + } await execution.progress?.report({ phase: "scan_versions", completed: 0, @@ -304,38 +318,6 @@ const ensureComparisonCheckpoint = async ( return saved.ok ? ok({ workspace: saved.value, run }) : saved; }; -const createArtifactComparisonEvidence = ( - inventory: { - readonly left: readonly Evidence[]; - readonly right: readonly Evidence[]; - }, - limit: number, -): Evidence => { - const comparison = compareArtifacts( - inventory.left, - inventory.right, - 0, - limit, - ); - const leftIds = evidenceIds(inventory.left); - const rightIds = evidenceIds(inventory.right); - return createEvidence(undefined, ARTIFACT_COMPARISON_PROVIDER, { - predicateType: "rea.artifact-comparison/v1", - operation: "compare_artifacts", - parameters: { - left_evidence_ids: leftIds, - right_evidence_ids: rightIds, - offset: 0, - limit, - }, - result: jsonValueSchema.parse(comparison), - confidence: "derived", - authority: "analyst-inference", - limitations: comparison.limitations, - evidenceLinks: [...leftIds, ...rightIds], - }); -}; - const finalizeInvestigation = async ( input: CrossVersionInvestigationInput, state: RunState, @@ -344,7 +326,11 @@ const finalizeInvestigation = async ( ): Promise> => { const comparison = comparisonEvidenceFor(state.workspace, state.run); if (!comparison.ok) return comparison; - const evidence = createChangedBehaviorEvidence(state, comparison.value); + const evidence = createChangedBehaviorEvidence( + state.workspace, + state.run, + comparison.value, + ); const run = investigationRunSchema.parse({ ...state.run, status: "complete", @@ -367,45 +353,6 @@ const finalizeInvestigation = async ( return saved.ok ? completeOutcome(saved.value, run, false, session) : saved; }; -const createChangedBehaviorEvidence = ( - state: RunState, - comparison: Evidence, -): Evidence => { - const limit = Math.min(state.run.options.change_limit, 100); - const changed = findChangedBehavior([comparison], 0, limit); - const summary = investigationRunSummarySchema.parse({ - schema_version: 1, - workspace_id: state.workspace.workspace_id, - run_id: state.run.run_id, - left_manifest_id: state.run.left.manifest_id, - right_manifest_id: state.run.right.manifest_id, - inventory_evidence_count: - state.run.left_inventory_evidence_ids.length + - state.run.right_inventory_evidence_ids.length, - comparison_evidence_id: comparison.evidence_id, - limitations: state.run.limitations, - }); - return createEvidence(undefined, CHANGED_BEHAVIOR_PROVIDER, { - predicateType: "rea.changed-behavior/v1", - operation: "find_changed_behavior", - parameters: { - workspace_id: state.workspace.workspace_id, - run_id: state.run.run_id, - comparison_evidence_ids: [comparison.evidence_id], - offset: 0, - limit, - }, - result: jsonValueSchema.parse({ - ...changed, - investigation_run: summary, - }), - confidence: "derived", - authority: "analyst-inference", - limitations: [...changed.limitations, AUTOMATIC_RUN_LIMITATION], - evidenceLinks: changed.evidence_links, - }); -}; - const comparisonEvidenceFor = ( workspace: InvestigationWorkspace, run: InvestigationRun, diff --git a/src/application/CrossVersionInvestigationEvidence.ts b/src/application/CrossVersionInvestigationEvidence.ts new file mode 100644 index 000000000..313c5db62 --- /dev/null +++ b/src/application/CrossVersionInvestigationEvidence.ts @@ -0,0 +1,91 @@ +import { compareArtifacts } from "../domain/artifactComparison.js"; +import { findChangedBehavior } from "../domain/changedBehavior.js"; +import { createEvidence, type Evidence } from "../domain/evidence.js"; +import { + investigationRunSummarySchema, + type InvestigationRun, + type InvestigationWorkspace, +} from "../domain/investigationWorkspace.js"; +import { jsonValueSchema } from "../domain/jsonValue.js"; +import { AUTOMATIC_RUN_LIMITATION } from "./CrossVersionInventory.js"; +import { + ARTIFACT_COMPARISON_PROVIDER, + CHANGED_BEHAVIOR_PROVIDER, +} from "./InvestigationProviders.js"; + +/** Derive deterministic artifact-comparison Evidence from inventory pages. */ +export const createArtifactComparisonEvidence = ( + inventory: { + readonly left: readonly Evidence[]; + readonly right: readonly Evidence[]; + }, + limit: number, +): Evidence => { + const comparison = compareArtifacts( + inventory.left, + inventory.right, + 0, + limit, + ); + const leftIds = evidenceIds(inventory.left); + const rightIds = evidenceIds(inventory.right); + return createEvidence(undefined, ARTIFACT_COMPARISON_PROVIDER, { + predicateType: "rea.artifact-comparison/v1", + operation: "compare_artifacts", + parameters: { + left_evidence_ids: leftIds, + right_evidence_ids: rightIds, + offset: 0, + limit, + }, + result: jsonValueSchema.parse(comparison), + confidence: "derived", + authority: "analyst-inference", + limitations: comparison.limitations, + evidenceLinks: [...leftIds, ...rightIds], + }); +}; + +/** Derive the deterministic final report for one comparison checkpoint. */ +export const createChangedBehaviorEvidence = ( + workspace: InvestigationWorkspace, + run: InvestigationRun, + comparison: Evidence, +): Evidence => { + const limit = Math.min(run.options.change_limit, 100); + const changed = findChangedBehavior([comparison], 0, limit); + const summary = investigationRunSummarySchema.parse({ + schema_version: 1, + workspace_id: workspace.workspace_id, + run_id: run.run_id, + left_manifest_id: run.left.manifest_id, + right_manifest_id: run.right.manifest_id, + inventory_evidence_count: + run.left_inventory_evidence_ids.length + + run.right_inventory_evidence_ids.length, + comparison_evidence_id: comparison.evidence_id, + limitations: run.limitations, + }); + return createEvidence(undefined, CHANGED_BEHAVIOR_PROVIDER, { + predicateType: "rea.changed-behavior/v1", + operation: "find_changed_behavior", + parameters: { + workspace_id: workspace.workspace_id, + run_id: run.run_id, + comparison_evidence_ids: [comparison.evidence_id], + offset: 0, + limit, + }, + result: jsonValueSchema.parse({ + ...changed, + investigation_run: summary, + }), + confidence: "derived", + authority: "analyst-inference", + limitations: [...changed.limitations, AUTOMATIC_RUN_LIMITATION], + evidenceLinks: changed.evidence_links, + }); +}; + +const evidenceIds = (records: readonly Evidence[]): string[] => + records.map(({ evidence_id: id }) => id); diff --git a/src/application/CrossVersionInvestigationReplay.ts b/src/application/CrossVersionInvestigationReplay.ts new file mode 100644 index 000000000..f0504be05 --- /dev/null +++ b/src/application/CrossVersionInvestigationReplay.ts @@ -0,0 +1,341 @@ +import { isDeepStrictEqual } from "node:util"; + +import { + artifactInventoryResultSchema, + type ArtifactInventoryResult, +} from "../domain/artifactGraph.js"; +import { parseArtifactInventoryEvidence } from "../domain/artifactInventoryEvidence.js"; +import type { Evidence } from "../domain/evidence.js"; +import { + EvidenceIntegrityError, + InvestigationWorkspaceError, +} from "../domain/errors.js"; +import type { + CrossVersionInvestigationInput, + InvestigationRun, + InvestigationRunTarget, + InvestigationWorkspace, +} from "../domain/investigationWorkspace.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { + createArtifactComparisonEvidence, + createChangedBehaviorEvidence, +} from "./CrossVersionInvestigationEvidence.js"; +import { AUTOMATIC_RUN_LIMITATION } from "./CrossVersionInventory.js"; +import { ARTIFACT_GRAPH_PROVIDER } from "./InvestigationProviders.js"; + +/** Validated workspace state returned by an explicit cache-only selector. */ +export interface CompletedInvestigationReplay { + readonly workspace: InvestigationWorkspace; + readonly run: InvestigationRun; +} + +interface ValidatedInventory { + readonly records: readonly Evidence[]; + readonly result: ReturnType; +} + +/** Select and verify one caller-identified complete run without input access. */ +export const selectCompletedInvestigationReplay = ( + workspace: InvestigationWorkspace | null, + input: CrossVersionInvestigationInput, +): Result< + CompletedInvestigationReplay, + InvestigationWorkspaceError | EvidenceIntegrityError +> => { + const run = workspace?.runs.find( + ({ run_id: runId }) => runId === input.replay_run_id, + ); + if ( + workspace === null || + run === undefined || + run.status !== "complete" || + !controlsMatch(run, input) + ) + return replayConflict(); + const records = new Map( + workspace.bundle.records.map((record) => [record.evidence_id, record]), + ); + const left = inventoryRecords(records, run.left_inventory_evidence_ids); + const right = inventoryRecords(records, run.right_inventory_evidence_ids); + if (!left.ok) return left; + if (!right.ok) return right; + if ( + !pathsMatch(left.value, input.left_path) || + !pathsMatch(right.value, input.right_path) + ) + return replayConflict(); + const validated = validateLinkedEvidence( + workspace, + run, + left.value, + right.value, + ); + return validated.ok ? ok({ workspace, run }) : validated; +}; + +const controlsMatch = ( + run: InvestigationRun, + input: CrossVersionInvestigationInput, +): boolean => + isDeepStrictEqual(run.options, input.options) && + run.integrity_policy === input.integrity_policy && + run.integrity_continue_approved === input.integrity_continue_approved && + run.max_integrity_mismatches === input.max_integrity_mismatches; + +const inventoryRecords = ( + records: ReadonlyMap, + evidenceIds: readonly string[], +): Result => { + if (new Set(evidenceIds).size !== evidenceIds.length) + return replayIntegrity("Investigation replay repeats inventory Evidence"); + const selected = evidenceIds.flatMap((evidenceId) => { + const evidence = records.get(evidenceId); + return evidence === undefined ? [] : [evidence]; + }); + return selected.length === evidenceIds.length + ? ok(selected) + : replayIntegrity("Investigation replay is missing inventory Evidence"); +}; + +const pathsMatch = (records: readonly Evidence[], path: string): boolean => + records.every(({ subject }) => subject?.local_path === path); + +const validateLinkedEvidence = ( + workspace: InvestigationWorkspace, + run: InvestigationRun, + leftRecords: readonly Evidence[], + rightRecords: readonly Evidence[], +): Result => { + try { + const left = validateInventory(leftRecords, run.left, run); + const right = validateInventory(rightRecords, run.right, run); + if (!left.ok) return left; + if (!right.ok) return right; + const expectedLimitations = runLimitations(left.value, right.value); + if (!isDeepStrictEqual(run.limitations, expectedLimitations)) + return replayIntegrity( + "Investigation replay limitations disagree with inventory Evidence", + ); + const comparison = createArtifactComparisonEvidence( + { left: left.value.records, right: right.value.records }, + run.options.change_limit, + ); + const storedComparison = evidenceFor(workspace, run.comparison_evidence_id); + if ( + storedComparison === undefined || + !isDeepStrictEqual(storedComparison, comparison) + ) + return replayIntegrity( + "Investigation replay comparison Evidence is inconsistent", + ); + const result = createChangedBehaviorEvidence(workspace, run, comparison); + const storedResult = evidenceFor(workspace, run.result_evidence_id); + return storedResult !== undefined && isDeepStrictEqual(storedResult, result) + ? ok(null) + : replayIntegrity("Investigation replay result Evidence is inconsistent"); + } catch (cause: unknown) { + return replayIntegrity( + "Investigation replay linked Evidence is invalid", + cause, + ); + } +}; + +const validateInventory = ( + records: readonly Evidence[], + target: InvestigationRunTarget, + run: InvestigationRun, +): Result => { + const pages = records.map((record) => + artifactInventoryResultSchema.parse(record.normalized_result), + ); + const expectedPageCount = Math.max( + 1, + Math.ceil((pages[0]?.manifest.node_count ?? 0) / run.options.page_size), + Math.ceil( + (pages[0]?.manifest.occurrence_count ?? 0) / run.options.page_size, + ), + Math.ceil((pages[0]?.manifest.edge_count ?? 0) / run.options.page_size), + ); + if (records.length !== expectedPageCount) + return replayIntegrity( + "Investigation replay inventory Evidence pagination is inconsistent", + ); + if (!sameSnapshotMetadata(pages)) + return replayIntegrity( + "Investigation replay inventory pages disagree on snapshot metadata", + ); + for (const [index, record] of records.entries()) { + const page = pages[index]; + if (page === undefined || !inventoryRecordMatches(record, page, run, index)) + return replayIntegrity( + "Investigation replay inventory Evidence is inconsistent", + ); + } + const result = parseArtifactInventoryEvidence(records); + if (!result.inventory.complete || !targetMatches(result.inventory, target)) + return replayIntegrity( + "Investigation replay inventory commitment is inconsistent", + ); + return ok({ records, result }); +}; + +const inventoryRecordMatches = ( + record: Evidence, + page: ArtifactInventoryResult, + run: InvestigationRun, + index: number, +): boolean => { + const offset = index * run.options.page_size; + const parameters = { + node_offset: offset, + node_limit: run.options.page_size, + occurrence_offset: offset, + occurrence_limit: run.options.page_size, + edge_offset: offset, + edge_limit: run.options.page_size, + max_entries: run.options.max_entries, + max_total_bytes: run.options.max_total_bytes, + max_entry_bytes: run.options.max_entry_bytes, + max_compression_ratio: run.options.max_compression_ratio, + max_depth: run.options.max_depth, + max_path_bytes: run.options.max_path_bytes, + integrity_policy: run.integrity_policy, + integrity_continue_approved: run.integrity_continue_approved, + max_integrity_mismatches: run.max_integrity_mismatches, + }; + const locations = page.occurrences.items.map(({ logical_path: path }) => ({ + kind: "artifact-path" as const, + path, + })); + return ( + record.subject !== null && + record.subject.name === record.subject.local_path.split("/").at(-1) && + record.subject.digest.sha256 === page.manifest.root_sha256 && + record.subject.format === page.manifest.root_format && + record.subject.architecture === null && + isDeepStrictEqual(record.provider, ARTIFACT_GRAPH_PROVIDER) && + record.predicate_type === "rea.analysis/v2" && + record.operation === "inventory_artifact" && + isDeepStrictEqual(record.parameters, parameters) && + record.raw_result === null && + record.confidence === "observed" && + record.authority === "shipped-artifact" && + record.environment === null && + isDeepStrictEqual(record.limitations, page.limitations) && + isDeepStrictEqual(record.locations, locations) && + record.evidence_links.length === 0 && + isDeepStrictEqual(page.limits, traversalLimits(run)) && + pageMatches( + page.nodes, + offset, + run.options.page_size, + page.manifest.node_count, + ) && + pageMatches( + page.occurrences, + offset, + run.options.page_size, + page.manifest.occurrence_count, + ) && + pageMatches( + page.edges, + offset, + run.options.page_size, + page.manifest.edge_count, + ) + ); +}; + +const sameSnapshotMetadata = ( + pages: readonly ArtifactInventoryResult[], +): boolean => { + const first = pages[0]; + return ( + first !== undefined && + pages.every( + (page) => + isDeepStrictEqual(page.manifest, first.manifest) && + isDeepStrictEqual(page.limits, first.limits) && + isDeepStrictEqual(page.provenance, first.provenance) && + isDeepStrictEqual( + page.integrity_contradictions, + first.integrity_contradictions, + ) && + isDeepStrictEqual(page.limitations, first.limitations), + ) + ); +}; + +const pageMatches = ( + page: { + readonly items: readonly unknown[]; + readonly offset: number; + readonly limit: number; + readonly total: number; + readonly next_offset: number | null; + }, + offset: number, + limit: number, + total: number, +): boolean => + page.offset === offset && + page.limit === limit && + page.total === total && + page.items.length === Math.max(0, Math.min(limit, page.total - offset)) && + page.next_offset === (offset + limit < page.total ? offset + limit : null); + +const traversalLimits = (run: InvestigationRun) => ({ + max_entries: run.options.max_entries, + max_total_bytes: run.options.max_total_bytes, + max_entry_bytes: run.options.max_entry_bytes, + max_compression_ratio: run.options.max_compression_ratio, + max_depth: run.options.max_depth, + max_path_bytes: run.options.max_path_bytes, +}); + +const targetMatches = ( + inventory: ReturnType["inventory"], + target: InvestigationRunTarget, +): boolean => + inventory.manifest.root_sha256 === target.root_sha256 && + inventory.manifest.graph_sha256 === target.graph_sha256 && + inventory.manifest.manifest_id === target.manifest_id && + inventory.manifest.root_format === target.format; + +const runLimitations = ( + left: ValidatedInventory, + right: ValidatedInventory, +): string[] => + [ + ...new Set([ + ...left.result.inventory.limitations.map((item) => `Left: ${item}`), + ...right.result.inventory.limitations.map((item) => `Right: ${item}`), + AUTOMATIC_RUN_LIMITATION, + ]), + ].sort((first, second) => first.localeCompare(second)); + +const evidenceFor = ( + workspace: InvestigationWorkspace, + evidenceId: string | null, +): Evidence | undefined => + evidenceId === null + ? undefined + : workspace.bundle.records.find( + ({ evidence_id: current }) => current === evidenceId, + ); + +const replayConflict = (): Result => + err(new InvestigationWorkspaceError("read", "revision-conflict")); + +const replayIntegrity = ( + message: string, + cause?: unknown, +): Result => + err( + new EvidenceIntegrityError( + message, + cause === undefined ? undefined : { cause }, + ), + ); diff --git a/src/cliInvestigationCommands.ts b/src/cliInvestigationCommands.ts index 89adc3d2d..6cc1df3b7 100644 --- a/src/cliInvestigationCommands.ts +++ b/src/cliInvestigationCommands.ts @@ -18,6 +18,11 @@ const investigationOptionsSchema = z.object({ .describe("Approve persistent workspace writes"), workspaceName: z.string().trim().min(1).max(200).default("default"), expectedRevision: z.number().int().min(1).optional(), + replayRunId: z + .string() + .regex(/^run_[a-f0-9]{64}$/u) + .optional() + .describe("Replay one verified complete workspace run without rescanning"), maxEntries: z.number().int().min(1).max(50_000).default(10_000), maxTotalBytes: z .number() @@ -55,6 +60,7 @@ export const registerInvestigationCommands = ( alias: { workspaceName: "workspace-name", expectedRevision: "expected-revision", + replayRunId: "replay-run-id", maxEntries: "max-entries", maxTotalBytes: "max-total-bytes", maxEntryBytes: "max-entry-bytes", @@ -99,13 +105,6 @@ const runInvestigation = async ( }, ); if (!workspaceAuthorization.ok) return cliError(workspaceAuthorization.error); - const inputAuthorization = await authorizeRootPermission(authority.value, { - capability: "investigation_input", - roots: [args.leftPath, args.rightPath], - access: "read", - operation: "investigate_versions", - }); - if (!inputAuthorization.ok) return cliError(inputAuthorization.error); const investigated = await runCrossVersionInvestigation( { approved: true, @@ -114,6 +113,9 @@ const runInvestigation = async ( ...(options.expectedRevision === undefined ? {} : { expected_workspace_revision: options.expectedRevision }), + ...(options.replayRunId === undefined + ? {} + : { replay_run_id: options.replayRunId }), left_path: args.leftPath, right_path: args.rightPath, integrity_policy: options.integrityPolicy, @@ -134,6 +136,13 @@ const runInvestigation = async ( { inputRoots: config.value.investigationInputRoots, integrityContinueEnabled: config.value.artifactIntegrityContinueEnabled, + authorizeInputRead: () => + authorizeRootPermission(authority.value, { + capability: "investigation_input", + roots: [args.leftPath, args.rightPath], + access: "read", + operation: "investigate_versions", + }), authorizeWorkspaceWrite: workspaceAuthorization.value.authorizeWrite, }, ); diff --git a/src/domain/investigationWorkspace.ts b/src/domain/investigationWorkspace.ts index 4b2460122..a004d8081 100644 --- a/src/domain/investigationWorkspace.ts +++ b/src/domain/investigationWorkspace.ts @@ -59,6 +59,10 @@ export const crossVersionInvestigationInputSchema = z workspace_path: z.string().min(1).max(4_096), workspace_name: z.string().trim().min(1).max(200).default("default"), expected_workspace_revision: z.number().int().min(1).optional(), + replay_run_id: z + .string() + .regex(/^run_[a-f0-9]{64}$/u) + .optional(), left_path: z.string().min(1).max(4_096), right_path: z.string().min(1).max(4_096), integrity_policy: z.enum(["fail", "record-and-continue"]).default("fail"), diff --git a/src/server/registerInvestigationTools.ts b/src/server/registerInvestigationTools.ts index c60bc2c74..92fca322b 100644 --- a/src/server/registerInvestigationTools.ts +++ b/src/server/registerInvestigationTools.ts @@ -19,7 +19,10 @@ import { } from "../domain/changedBehavior.js"; import { createEvidence, type Evidence } from "../domain/evidence.js"; import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js"; -import { EvidenceIntegrityError } from "../domain/errors.js"; +import { + EvidenceIntegrityError, + type AnalysisError, +} from "../domain/errors.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; import { err, ok, type Result } from "../domain/result.js"; import { @@ -93,14 +96,19 @@ const registerChangedBehavior = ( contractOptions(contract), async (input, context) => { const parsed = changedBehaviorInputSchema.parse(input); - if (parsed.investigation_run !== undefined) { + 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( - policies.permissionAuthority, + authority, { - path: parsed.investigation_run.workspace_path, + path: investigationRun.workspace_path, readCapability: "investigation_workspace_read", writeCapability: "investigation_workspace_write", operation: contract.name, @@ -109,23 +117,16 @@ const registerChangedBehavior = ( if (!authorizedWorkspace.ok) return toCallToolResult(authorizedWorkspace, contract); workspaceAuthorization = authorizedWorkspace.value; - const authorizedInput = await authorizeRootPermission( - policies.permissionAuthority, - { + authorizeInputRead = () => + authorizeRootPermission(authority, { capability: "investigation_input", - roots: [ - parsed.investigation_run.left_path, - parsed.investigation_run.right_path, - ], + roots: [investigationRun.left_path, investigationRun.right_path], access: "read", operation: contract.name, - }, - ); - if (!authorizedInput.ok) - return toCallToolResult(authorizedInput, contract); + }); } const investigated = await runCrossVersionInvestigation( - parsed.investigation_run, + investigationRun, policies.evidenceFiles, { ...investigationExecution( @@ -141,6 +142,7 @@ const registerChangedBehavior = ( authorizeWorkspaceWrite: workspaceAuthorization.authorizeWrite, }), + ...(authorizeInputRead === undefined ? {} : { authorizeInputRead }), }, ); if (!investigated.ok) return toCallToolResult(investigated, contract); diff --git a/tests/investigationMcp.test.ts b/tests/investigationMcp.test.ts index 4004afb83..89cc31daf 100644 --- a/tests/investigationMcp.test.ts +++ b/tests/investigationMcp.test.ts @@ -14,6 +14,7 @@ import { PROCESS_CAPTURE_REFERENCE, PROCESS_COMPARISON_EVIDENCE, } from "../src/contracts/investigationExamples.js"; +import { changedBehaviorResultSchema } from "../src/domain/changedBehavior.js"; import { createEvidence, parseEvidence } from "../src/domain/evidence.js"; import { jsonObjectSchema, jsonValueSchema } from "../src/domain/jsonValue.js"; import { createServer } from "../src/server/createServer.js"; @@ -214,6 +215,83 @@ describe("investigation MCP workflows", () => { } }); + it("defers input permission for an explicit replay with deleted inputs", async () => { + const directory = await mkdtemp( + join(tmpdir(), "rea-investigation-replay-"), + ); + const left = join(directory, "left"); + const right = join(directory, "right"); + const workspace = join(directory, "workspace.json"); + await Promise.all([mkdir(left), mkdir(right)]); + await Promise.all([ + writeFile(join(left, "feature.txt"), "old\n"), + writeFile(join(right, "feature.txt"), "new\n"), + ]); + const filePolicy = evidencePolicy(directory); + const investigationRun = { + approved: true, + workspace_path: workspace, + workspace_name: "selected-replay", + left_path: left, + right_path: right, + options: { page_size: 500, change_limit: 100 }, + }; + const writer = await investigationAuthority(directory, true); + const initial = await connected(filePolicy, [directory], writer); + let runId: string | undefined; + try { + const created = await initial.client.callTool({ + name: "find_changed_behavior", + arguments: { investigation_run: investigationRun }, + }); + expect(created.isError, JSON.stringify(created)).not.toBe(true); + const evidence = parseEvidence( + z.object({ result: z.unknown() }).parse(created.structuredContent) + .result, + ); + const result = changedBehaviorResultSchema.parse( + evidence.normalized_result, + ); + runId = result.investigation_run?.run_id; + } finally { + await close(initial.session, initial.server, initial.client); + } + if (runId === undefined) throw new Error("missing completed run ID"); + + await Promise.all([ + rm(left, { recursive: true, force: true }), + rm(right, { recursive: true, force: true }), + ]); + const workspaceOnly = await permissionAuthorityForRoot( + directory, + [ + "investigation_workspace_read", + "investigation_workspace_write", + "investigation_input", + ], + ["investigation_workspace_read"], + ); + const replay = await connected(filePolicy, [], workspaceOnly); + try { + const cached = await replay.client.callTool({ + name: "find_changed_behavior", + arguments: { + investigation_run: { + ...investigationRun, + replay_run_id: runId, + }, + }, + }); + expect(cached.isError, JSON.stringify(cached)).not.toBe(true); + expect( + await readInvestigationWorkspace(workspace, filePolicy), + ).toMatchObject({ ok: true, value: { revision: 3 } }); + } finally { + await close(replay.session, replay.server, replay.client); + await rm(directory, { recursive: true, force: true }); + } + }); + it("refuses automatic artifact reads outside operator-approved roots", async () => { const directory = await mkdtemp(join(tmpdir(), "rea-investigation-mcp-")); const approvedInputs = join(directory, "approved-inputs"); diff --git a/tests/investigationWorkspace.test.ts b/tests/investigationWorkspace.test.ts index e064cd585..76a43f406 100644 --- a/tests/investigationWorkspace.test.ts +++ b/tests/investigationWorkspace.test.ts @@ -12,7 +12,7 @@ import { createHash } from "node:crypto"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { createPackageWithOptions } from "@electron/asar"; import canonicalize from "canonicalize"; @@ -31,6 +31,7 @@ import { parseInvestigationWorkspace, serializeInvestigationWorkspace, } from "../src/domain/investigationWorkspace.js"; +import { ok } from "../src/domain/result.js"; const digestCanonical = (value: unknown): string => { const encoded = canonicalize(value); @@ -239,6 +240,110 @@ describe("persistent cross-version investigation workspace", () => { }); }); + it("replays only an explicitly selected and fully verified complete run", async () => { + const { left, right, input } = await fixture(); + if (directory === undefined) throw new Error("missing fixture root"); + const completed = await runCrossVersionInvestigation( + input, + policy(directory), + { inputRoots: [directory] }, + ); + if (!completed.ok) throw completed.error; + const run = completed.value.workspace.runs[0]; + if (run === undefined || run.comparison_evidence_id === null) + throw new Error("missing completed run"); + + const defaultAuthorization = vi.fn(() => Promise.resolve(ok(null))); + await expect( + runCrossVersionInvestigation(input, policy(directory), { + inputRoots: [directory], + authorizeInputRead: defaultAuthorization, + }), + ).resolves.toMatchObject({ ok: true, value: { reused: true } }); + expect(defaultAuthorization).toHaveBeenCalledOnce(); + + const inconsistentRun = investigationRunSchema.parse({ + ...run, + result_evidence_id: run.comparison_evidence_id, + }); + const inconsistent = createInvestigationWorkspace( + input.workspace_name, + completed.value.workspace.bundle, + [inconsistentRun], + ); + const inconsistentPath = join(directory, "inconsistent.json"); + expect( + await writeInvestigationWorkspace( + inconsistent, + inconsistentPath, + null, + policy(directory), + ), + ).toMatchObject({ ok: true }); + await expect( + runCrossVersionInvestigation( + { + ...input, + workspace_path: inconsistentPath, + replay_run_id: run.run_id, + }, + policy(directory), + { inputRoots: [] }, + ), + ).resolves.toMatchObject({ + ok: false, + error: { _tag: "EvidenceIntegrityError" }, + }); + + await Promise.all([ + rm(left, { recursive: true, force: true }), + rm(right, { recursive: true, force: true }), + ]); + const replayAuthorization = vi.fn(() => Promise.resolve(ok(null))); + await expect( + runCrossVersionInvestigation( + { + ...input, + expected_workspace_revision: completed.value.workspace.revision, + replay_run_id: run.run_id, + }, + policy(directory), + { inputRoots: [], authorizeInputRead: replayAuthorization }, + ), + ).resolves.toMatchObject({ + ok: true, + value: { + reused: true, + evidence: { evidence_id: completed.value.evidence.evidence_id }, + workspace: { revision: completed.value.workspace.revision }, + }, + }); + expect(replayAuthorization).not.toHaveBeenCalled(); + + for (const replayInput of [ + { ...input, replay_run_id: `run_${"f".repeat(64)}` }, + { ...input, right_path: `${right}-other`, replay_run_id: run.run_id }, + { + ...input, + options: { ...input.options, change_limit: 99 }, + replay_run_id: run.run_id, + }, + ]) + await expect( + runCrossVersionInvestigation(replayInput, policy(directory), { + inputRoots: [], + authorizeInputRead: replayAuthorization, + }), + ).resolves.toMatchObject({ + ok: false, + error: { + _tag: "InvestigationWorkspaceError", + reason: "revision-conflict", + }, + }); + expect(replayAuthorization).not.toHaveBeenCalled(); + }); + it("enforces shared JSON depth limits while reading workspaces", async () => { const { path, input } = await fixture(); if (directory === undefined) throw new Error("missing fixture root"); @@ -300,6 +405,26 @@ describe("persistent cross-version investigation workspace", () => { ), ).toMatchObject({ ok: true }); + const replayAuthorization = vi.fn(() => Promise.resolve(ok(null))); + await expect( + runCrossVersionInvestigation( + { + ...input, + workspace_path: resumePath, + replay_run_id: partialRun.run_id, + }, + policy(directory), + { inputRoots: [], authorizeInputRead: replayAuthorization }, + ), + ).resolves.toMatchObject({ + ok: false, + error: { + _tag: "InvestigationWorkspaceError", + reason: "revision-conflict", + }, + }); + expect(replayAuthorization).not.toHaveBeenCalled(); + const resumed = await runCrossVersionInvestigation( { ...input, workspace_path: resumePath }, policy(directory), From 6601e0cb7e57fe94cf0ae65bf0d04de25a3a7b49 Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <1705004398@qq.com> Date: Tue, 14 Jul 2026 21:35:10 +0000 Subject: [PATCH 2/2] test(application): verify packaged MCP replay --- scripts/verify-package-investigation.mjs | 14 +++++++++++++ scripts/verify-package.mjs | 26 ++++++++++++++++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/scripts/verify-package-investigation.mjs b/scripts/verify-package-investigation.mjs index 13c9e2c0e..442ae10fc 100644 --- a/scripts/verify-package-investigation.mjs +++ b/scripts/verify-package-investigation.mjs @@ -50,6 +50,20 @@ export async function verifyPackagedInvestigation(input) { persisted.runs?.[0]?.status !== "complete" ) throw new Error("packaged persistent investigation CLI failed"); + return { + arguments: { + approved: true, + workspace_path: workspacePath, + workspace_name: "package-versions", + expected_workspace_revision: persisted.revision, + replay_run_id: runId, + left_path: input.artifactArchive, + right_path: artifactArchiveV2, + }, + evidenceId: investigated.evidence_id, + workspacePath, + revision: persisted.revision, + }; } async function runJson(command, args, environment) { diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index 4603ea416..c168ef0cf 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -169,7 +169,7 @@ try { ) throw new Error("packaged artifact inventory CLI failed"); // prettier-ignore - await verifyPackagedInvestigation({ cli, workspace, evidenceRoot, artifactArchive, environment }); + const investigationReplay = await verifyPackagedInvestigation({ cli, workspace, evidenceRoot, artifactArchive, environment }); const overview = json( await run(cli, ["analyze", process.execPath, "--json"], environment), ); @@ -448,7 +448,10 @@ try { const transport = new StdioClientTransport({ command: cli, args: ["mcp"], - env: environment, + env: { + ...environment, + REA_INVESTIGATION_INPUT_ROOTS_JSON: JSON.stringify([]), + }, stderr: "pipe", }); let mcpStderr = ""; @@ -466,6 +469,25 @@ try { throw new Error("packaged MCP tool inventory diverged from contracts"); await prompts.verifyPromptCatalog(client, mcpOptions, prompts.names); await prompts.verifyPromptCompletion(client, mcpOptions, false); + const replay = await client.callTool( + { + name: "find_changed_behavior", + arguments: { investigation_run: investigationReplay.arguments }, + }, + mcpOptions, + ); + const replayEvidence = json(prompts.mcpText(replay)); + const replayWorkspace = json( + await readFile(investigationReplay.workspacePath, "utf8"), + ); + if ( + replay.isError === true || + replayEvidence.evidence_id !== investigationReplay.evidenceId || + replayWorkspace.revision !== investigationReplay.revision + ) + throw new Error( + "packaged MCP did not replay with workspace-only authority", + ); const result = await client.callTool( { name: "current_document", arguments: {} }, mcpOptions,