fix(application): add explicit investigation replay
This commit is contained in:
commit
7112e1e6bf
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,16 +27,43 @@ 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"
|
||||
)
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -169,89 +169,73 @@ try {
|
|||
)
|
||||
throw new Error("packaged artifact inventory CLI failed");
|
||||
// prettier-ignore
|
||||
await verifyPackagedInvestigation({ cli, workspace, evidenceRoot, artifactArchive, environment });
|
||||
if (process.platform === "linux") {
|
||||
const unsupportedExecution = await runWithStatus(
|
||||
const investigationReplay = await verifyPackagedInvestigation({ cli, workspace, evidenceRoot, artifactArchive, environment });
|
||||
const overview = json(
|
||||
await run(cli, ["analyze", process.execPath, "--json"], environment),
|
||||
);
|
||||
if (
|
||||
overview.operation !== "binary_overview" ||
|
||||
overview.normalized_result?.procedure_count < 1
|
||||
)
|
||||
throw new Error(
|
||||
`packaged Hopper-backed analyze CLI failed: ${JSON.stringify(overview)}`,
|
||||
);
|
||||
const inspected = json(
|
||||
await run(
|
||||
cli,
|
||||
["analyze", process.execPath, "--json"],
|
||||
[
|
||||
"inspect",
|
||||
process.execPath,
|
||||
"--detail",
|
||||
"detailed",
|
||||
"--limit",
|
||||
"1",
|
||||
"--json",
|
||||
],
|
||||
environment,
|
||||
),
|
||||
);
|
||||
if (
|
||||
inspected.operation !== "binary_overview" ||
|
||||
inspected.normalized_result?.detail !== "detailed"
|
||||
)
|
||||
throw new Error("packaged inspect CLI failed");
|
||||
const functionResult = json(
|
||||
await run(
|
||||
cli,
|
||||
["function", process.execPath, "0x1000", "--json"],
|
||||
environment,
|
||||
),
|
||||
);
|
||||
if (
|
||||
functionResult.operation !== "analyze_function" ||
|
||||
functionResult.provider?.id !== "hopper" ||
|
||||
functionResult.normalized_result?.procedure?.address !== "0x1000"
|
||||
)
|
||||
throw new Error(
|
||||
`packaged function CLI failed: ${JSON.stringify(functionResult)}`,
|
||||
);
|
||||
const unsupported = json(unsupportedExecution.stdout);
|
||||
if (
|
||||
unsupportedExecution.status !== 1 ||
|
||||
unsupported.details?.failure_code !== "unsupported_hopper_build"
|
||||
)
|
||||
throw new Error(
|
||||
`packaged Linux Hopper verification did not fail closed: ${JSON.stringify(unsupported)}`,
|
||||
);
|
||||
} else {
|
||||
const overview = json(
|
||||
await run(cli, ["analyze", process.execPath, "--json"], environment),
|
||||
);
|
||||
if (
|
||||
overview.operation !== "binary_overview" ||
|
||||
overview.normalized_result?.procedure_count < 1
|
||||
)
|
||||
throw new Error(
|
||||
`packaged Hopper-backed analyze CLI failed: ${JSON.stringify(overview)}`,
|
||||
);
|
||||
const inspected = json(
|
||||
await run(
|
||||
cli,
|
||||
[
|
||||
"inspect",
|
||||
process.execPath,
|
||||
"--detail",
|
||||
"detailed",
|
||||
"--limit",
|
||||
"1",
|
||||
"--json",
|
||||
],
|
||||
environment,
|
||||
),
|
||||
);
|
||||
if (
|
||||
inspected.operation !== "binary_overview" ||
|
||||
inspected.normalized_result?.detail !== "detailed"
|
||||
)
|
||||
throw new Error("packaged inspect CLI failed");
|
||||
const functionResult = json(
|
||||
await run(
|
||||
cli,
|
||||
["function", process.execPath, "0x1000", "--json"],
|
||||
environment,
|
||||
),
|
||||
);
|
||||
if (
|
||||
functionResult.operation !== "analyze_function" ||
|
||||
functionResult.provider?.id !== "hopper" ||
|
||||
functionResult.normalized_result?.procedure?.address !== "0x1000"
|
||||
)
|
||||
throw new Error(
|
||||
`packaged function CLI failed: ${JSON.stringify(functionResult)}`,
|
||||
);
|
||||
const xrefs = json(
|
||||
await run(
|
||||
cli,
|
||||
["xrefs", process.execPath, "0x1000", "--json"],
|
||||
environment,
|
||||
),
|
||||
);
|
||||
if (
|
||||
xrefs.operation !== "xrefs" ||
|
||||
JSON.stringify(xrefs.normalized_result) !== JSON.stringify(["0x1000"])
|
||||
)
|
||||
throw new Error(`packaged xrefs CLI failed: ${JSON.stringify(xrefs)}`);
|
||||
const trace = json(
|
||||
await run(
|
||||
cli,
|
||||
["trace", process.execPath, "fixture", "--json"],
|
||||
environment,
|
||||
),
|
||||
);
|
||||
if (trace.operation !== "trace_feature")
|
||||
throw new Error(`packaged trace CLI failed: ${JSON.stringify(trace)}`);
|
||||
}
|
||||
const xrefs = json(
|
||||
await run(
|
||||
cli,
|
||||
["xrefs", process.execPath, "0x1000", "--json"],
|
||||
environment,
|
||||
),
|
||||
);
|
||||
if (
|
||||
xrefs.operation !== "xrefs" ||
|
||||
JSON.stringify(xrefs.normalized_result) !== JSON.stringify(["0x1000"])
|
||||
)
|
||||
throw new Error(`packaged xrefs CLI failed: ${JSON.stringify(xrefs)}`);
|
||||
const trace = json(
|
||||
await run(
|
||||
cli,
|
||||
["trace", process.execPath, "fixture", "--json"],
|
||||
environment,
|
||||
),
|
||||
);
|
||||
if (trace.operation !== "trace_feature")
|
||||
throw new Error(`packaged trace CLI failed: ${JSON.stringify(trace)}`);
|
||||
const capabilities = json(
|
||||
await run(cli, ["capabilities", "--json"], environment),
|
||||
);
|
||||
|
|
@ -263,22 +247,20 @@ try {
|
|||
providers.providers.some(({ id }) => typeof id !== "string")
|
||||
)
|
||||
throw new Error("packaged providers CLI failed");
|
||||
if (process.platform !== "linux") {
|
||||
const searchResult = json(
|
||||
await run(
|
||||
cli,
|
||||
["search", process.execPath, "fixture", "--json"],
|
||||
environment,
|
||||
),
|
||||
const searchResult = json(
|
||||
await run(
|
||||
cli,
|
||||
["search", process.execPath, "fixture", "--json"],
|
||||
environment,
|
||||
),
|
||||
);
|
||||
if (
|
||||
searchResult.operation !== "search_strings" ||
|
||||
searchResult.normalized_result?.items?.length !== 1
|
||||
)
|
||||
throw new Error(
|
||||
`packaged search CLI failed: ${JSON.stringify(searchResult)}`,
|
||||
);
|
||||
if (
|
||||
searchResult.operation !== "search_strings" ||
|
||||
searchResult.normalized_result?.items?.length !== 1
|
||||
)
|
||||
throw new Error(
|
||||
`packaged search CLI failed: ${JSON.stringify(searchResult)}`,
|
||||
);
|
||||
}
|
||||
const referenceImport = json(
|
||||
await run(
|
||||
cli,
|
||||
|
|
@ -466,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 = "";
|
||||
|
|
@ -484,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,
|
||||
|
|
@ -496,41 +500,30 @@ try {
|
|||
);
|
||||
if (opened.isError === true)
|
||||
throw new Error("packaged MCP could not open a binary");
|
||||
await prompts.verifyPromptCompletion(
|
||||
client,
|
||||
mcpOptions,
|
||||
process.platform !== "linux",
|
||||
);
|
||||
await prompts.verifyPromptCompletion(client, mcpOptions, true);
|
||||
const current = await client.callTool(
|
||||
{ name: "current_document", arguments: {} },
|
||||
mcpOptions,
|
||||
);
|
||||
if (process.platform === "linux") {
|
||||
if (
|
||||
current.isError !== true ||
|
||||
!prompts
|
||||
.mcpText(current)
|
||||
.includes("not supported for unattended Linux analysis")
|
||||
)
|
||||
throw new Error("packaged Linux MCP did not reject an unpinned Hopper");
|
||||
} else {
|
||||
if (json(prompts.mcpText(current)).normalized_result !== "fixture")
|
||||
throw new Error("packaged MCP bridge call failed");
|
||||
const batch = await client.callTool(
|
||||
{ name: "batch_decompile", arguments: { addresses: ["0x1000"] } },
|
||||
mcpOptions,
|
||||
);
|
||||
const batchResult = json(prompts.mcpText(batch)).normalized_result;
|
||||
if (
|
||||
batch.isError === true ||
|
||||
batchResult?.total !== 1 ||
|
||||
batchResult?.succeeded !== 1 ||
|
||||
batchResult?.failed !== 0 ||
|
||||
batchResult?.items?.[0]?.status !== "ok" ||
|
||||
batchResult?.items?.[0]?.pseudocode !== "return 0;"
|
||||
)
|
||||
throw new Error("packaged MCP structured batch result failed");
|
||||
}
|
||||
if (
|
||||
current.isError === true ||
|
||||
json(prompts.mcpText(current)).normalized_result !== "fixture"
|
||||
)
|
||||
throw new Error("packaged MCP bridge call failed");
|
||||
const batch = await client.callTool(
|
||||
{ name: "batch_decompile", arguments: { addresses: ["0x1000"] } },
|
||||
mcpOptions,
|
||||
);
|
||||
const batchResult = json(prompts.mcpText(batch)).normalized_result;
|
||||
if (
|
||||
batch.isError === true ||
|
||||
batchResult?.total !== 1 ||
|
||||
batchResult?.succeeded !== 1 ||
|
||||
batchResult?.failed !== 0 ||
|
||||
batchResult?.items?.[0]?.status !== "ok" ||
|
||||
batchResult?.items?.[0]?.pseudocode !== "return 0;"
|
||||
)
|
||||
throw new Error("packaged MCP structured batch result failed");
|
||||
const closed = await client.callTool(
|
||||
{ name: "close_binary", arguments: {} },
|
||||
mcpOptions,
|
||||
|
|
|
|||
|
|
@ -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<Result<null, AnalysisError>>;
|
||||
readonly authorizeWorkspaceWrite?: () => Promise<Result<null, AnalysisError>>;
|
||||
}
|
||||
|
||||
|
|
@ -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<Result<CrossVersionInvestigationOutcome, AnalysisError>> => {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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<typeof parseArtifactInventoryEvidence>;
|
||||
}
|
||||
|
||||
/** 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<string, Evidence>,
|
||||
evidenceIds: readonly string[],
|
||||
): Result<readonly Evidence[], EvidenceIntegrityError> => {
|
||||
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<null, EvidenceIntegrityError> => {
|
||||
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<ValidatedInventory, EvidenceIntegrityError> => {
|
||||
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<typeof parseArtifactInventoryEvidence>["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<never, InvestigationWorkspaceError> =>
|
||||
err(new InvestigationWorkspaceError("read", "revision-conflict"));
|
||||
|
||||
const replayIntegrity = (
|
||||
message: string,
|
||||
cause?: unknown,
|
||||
): Result<never, EvidenceIntegrityError> =>
|
||||
err(
|
||||
new EvidenceIntegrityError(
|
||||
message,
|
||||
cause === undefined ? undefined : { cause },
|
||||
),
|
||||
);
|
||||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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<Result<null, AnalysisError>>)
|
||||
| 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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in New Issue