diff --git a/README.md b/README.md index c45137c10..a37a85f9f 100644 --- a/README.md +++ b/README.md @@ -207,14 +207,17 @@ Filesystem evidence commands and MCP file tools are disabled until the operator ```bash export REA_EVIDENCE_ROOTS_JSON='["/absolute/path/to/evidence"]' +export REA_INVESTIGATION_INPUT_ROOTS_JSON='["/absolute/path/to/releases"]' rea evidence-import /absolute/path/to/evidence/bundle.json rea evidence-export /absolute/path/to/evidence/bundle.json /absolute/path/to/evidence/canonical.json rea compare /absolute/path/to/evidence/left.json /absolute/path/to/evidence/right.json -rea investigate-versions /path/to/v1 /path/to/v2 /absolute/path/to/evidence/releases.json --yes --workspace-name releases +rea investigate-versions /absolute/path/to/releases/v1 /absolute/path/to/releases/v2 /absolute/path/to/evidence/releases.json --yes --workspace-name releases ``` `investigate-versions` inventories both versions, checkpoints their observed Evidence, derives an artifact comparison, and records a changed-behavior report. +Both input paths must resolve beneath `REA_INVESTIGATION_INPUT_ROOTS_JSON`; +workspace files remain independently restricted by `REA_EVIDENCE_ROOTS_JSON`. The workspace uses deterministic content identities and monotonic CAS-linked revisions, so the same request resumes an interrupted run or reuses a completed run without replacing earlier investigations. It currently compares static diff --git a/docs/investigation-workspaces.md b/docs/investigation-workspaces.md index 0e3e92946..c698337a9 100644 --- a/docs/investigation-workspaces.md +++ b/docs/investigation-workspaces.md @@ -11,6 +11,7 @@ the write: ```bash export REA_EVIDENCE_ROOTS_JSON='["/absolute/path/to/evidence"]' +export REA_INVESTIGATION_INPUT_ROOTS_JSON='["/absolute/path/to/releases"]' rea investigate-versions \ /absolute/path/to/version-1 \ @@ -21,8 +22,10 @@ rea investigate-versions \ ``` The two versions may be regular artifacts or directories supported by the -artifact graph provider. The workspace parent must already exist. REA never -creates an allowlisted root implicitly. +artifact graph provider. Both must resolve beneath an explicitly configured +`REA_INVESTIGATION_INPUT_ROOTS_JSON` root. The workspace parent must already +exist beneath `REA_EVIDENCE_ROOTS_JSON`. REA never creates an allowlisted root +implicitly. Use `--expected-revision N` when an external coordinator needs an explicit compare-and-swap guard. Traversal, byte, page, and comparison limits are also @@ -90,6 +93,7 @@ digest. Parsing verifies: Writes are canonical JSON, atomic, fsynced, and mode `0600`. REA rejects workspace paths outside `REA_EVIDENCE_ROOTS_JSON`, symlink destinations, +artifact inputs outside `REA_INVESTIGATION_INPUT_ROOTS_JSON`, oversized or deeply nested JSON, stale expected revisions, and concurrent lock holders. Lock or CAS conflicts fail without mutating the workspace; callers may retry the same idempotent request. diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index 9f7d55842..648f7e074 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -105,6 +105,7 @@ try { ]), REA_NPX_LOG: npxLog, REA_EVIDENCE_ROOTS_JSON: JSON.stringify([evidenceRoot]), + REA_INVESTIGATION_INPUT_ROOTS_JSON: JSON.stringify([workspace]), REA_REFERENCE_ROOTS_JSON: JSON.stringify([referenceRoot]), REA_REFERENCE_SECRET_PATTERNS_JSON: JSON.stringify([".env"]), }; diff --git a/src/application/ArtifactInventory.ts b/src/application/ArtifactInventory.ts index ed5b60be3..68d7d2fad 100644 --- a/src/application/ArtifactInventory.ts +++ b/src/application/ArtifactInventory.ts @@ -102,6 +102,16 @@ export const scanArtifactInventory = async ( ): Promise => { abortIfNeeded(signal); const path = await realpath(inputPath); + return scanCanonicalArtifactInventory(path, limits, signal, nativeMount); +}; + +/** Scan one already-canonical artifact path without resolving it again. */ +export const scanCanonicalArtifactInventory = async ( + path: string, + limits: ArtifactLimits, + signal?: AbortSignal, + nativeMount: ArtifactNativeMountPolicy = NATIVE_MOUNT_DISABLED, +): Promise => { const metadata = await lstat(path); const rootFormat = await classifyRoot(path, metadata.isDirectory()); const rootDigest = metadata.isDirectory() diff --git a/src/application/AuthorizedArtifactInventory.ts b/src/application/AuthorizedArtifactInventory.ts new file mode 100644 index 000000000..6be9d9b3c --- /dev/null +++ b/src/application/AuthorizedArtifactInventory.ts @@ -0,0 +1,33 @@ +import { realpath } from "node:fs/promises"; + +import type { ArtifactLimits } from "../artifacts/ArtifactReader.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { withinRoot } from "../reference/ReferenceSourceReaderPaths.js"; +import { + scanCanonicalArtifactInventory, + type ArtifactInventorySnapshot, +} from "./ArtifactInventory.js"; + +/** Resolve, authorize, and scan one artifact without a second path resolution. */ +export const scanAuthorizedArtifactInventory = async ( + inputPath: string, + roots: readonly string[], + limits: ArtifactLimits, + signal?: AbortSignal, +): Promise => { + if (roots.length === 0) + throw new ArtifactReaderFailure( + "path", + "Artifact input roots are disabled", + ); + const [path, approved] = await Promise.all([ + realpath(inputPath), + Promise.all(roots.map(async (root) => realpath(root))), + ]); + if (!approved.some((root) => withinRoot(root, path))) + throw new ArtifactReaderFailure( + "path", + "Artifact path is outside approved input roots", + ); + return scanCanonicalArtifactInventory(path, limits, signal); +}; diff --git a/src/application/CrossVersionInventory.ts b/src/application/CrossVersionInventory.ts index 43d3053b9..93c0ef941 100644 --- a/src/application/CrossVersionInventory.ts +++ b/src/application/CrossVersionInventory.ts @@ -15,9 +15,9 @@ import { jsonValueSchema } from "../domain/jsonValue.js"; import { err, ok, type Result } from "../domain/result.js"; import { paginateArtifactInventory, - scanArtifactInventory, type ArtifactInventorySnapshot, } from "./ArtifactInventory.js"; +import { scanAuthorizedArtifactInventory } from "./AuthorizedArtifactInventory.js"; import { ARTIFACT_GRAPH_PROVIDER } from "./InvestigationProviders.js"; export const AUTOMATIC_RUN_LIMITATION = @@ -33,17 +33,28 @@ export interface InventoryEvidencePages { readonly right: readonly Evidence[]; } -/** Scan both inputs once under the same bounded traversal policy. */ +/** Authorize and scan both inputs once under the same bounded traversal policy. */ export const scanVersions = async ( input: CrossVersionInvestigationInput, + inputRoots: readonly string[], signal?: AbortSignal, ): Promise> => { try { if (signal?.aborted === true) return err(new AnalysisCancelledError("find_changed_behavior")); const limits = artifactLimits(input.options); - const left = await scanArtifactInventory(input.left_path, limits, signal); - const right = await scanArtifactInventory(input.right_path, limits, signal); + const left = await scanAuthorizedArtifactInventory( + input.left_path, + inputRoots, + limits, + signal, + ); + const right = await scanAuthorizedArtifactInventory( + input.right_path, + inputRoots, + limits, + signal, + ); return ok({ left, right }); } catch (cause: unknown) { if (cause instanceof ArtifactReaderFailure) diff --git a/src/application/CrossVersionInvestigation.ts b/src/application/CrossVersionInvestigation.ts index 34a8d4374..7ba4408fa 100644 --- a/src/application/CrossVersionInvestigation.ts +++ b/src/application/CrossVersionInvestigation.ts @@ -49,12 +49,18 @@ export interface CrossVersionInvestigationOutcome { readonly reused: boolean; } +/** Runtime authority and cancellation capabilities for one investigation. */ +export interface CrossVersionInvestigationExecution { + readonly inputRoots: readonly string[]; + readonly session?: BinarySessionPort; + readonly signal?: AbortSignal; +} + /** Run or resume a deterministic cross-version artifact investigation. */ export const runCrossVersionInvestigation = async ( input: CrossVersionInvestigationInput, policy: EvidenceFilePolicy, - session?: BinarySessionPort, - signal?: AbortSignal, + execution: CrossVersionInvestigationExecution, ): Promise> => { const parsed = crossVersionInvestigationInputSchema.safeParse(input); if (!parsed.success) @@ -71,14 +77,18 @@ export const runCrossVersionInvestigation = async ( const preflight = validateWorkspaceRequest(initial.value, parsed.data); if (!preflight.ok) return preflight; - const snapshots = await scanVersions(parsed.data, signal); + const snapshots = await scanVersions( + parsed.data, + execution.inputRoots, + execution.signal, + ); if (!snapshots.ok) return snapshots; return continueInvestigation({ input: parsed.data, initial: initial.value, snapshots: snapshots.value, policy, - session, + session: execution.session, }); }; diff --git a/src/cliInvestigationCommands.ts b/src/cliInvestigationCommands.ts index 2af2265d6..7fea6e73f 100644 --- a/src/cliInvestigationCommands.ts +++ b/src/cliInvestigationCommands.ts @@ -98,6 +98,7 @@ const runInvestigation = async ( }, }, config.value.evidenceFilePolicy, + { inputRoots: config.value.investigationInputRoots }, ); return investigated.ok ? investigated.value.evidence diff --git a/src/config.ts b/src/config.ts index 31d6b1090..fbb45557b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -23,6 +23,7 @@ export interface AppConfig { readonly processExecutionPolicy: ProcessExecutionPolicy; readonly artifactNativeMountEnabled: boolean; readonly evidenceFilePolicy: EvidenceFilePolicy; + readonly investigationInputRoots: readonly string[]; readonly analysisSnapshotFilePolicy: EvidenceFilePolicy; readonly referenceSourcePolicy: ReferenceSourcePolicy; } @@ -44,6 +45,7 @@ const environmentSchema = z.object({ REA_PROCESS_WORKING_ROOTS_JSON: z.string().default("[]"), REA_PROCESS_ALLOWED_ENV_JSON: z.string().default("[]"), REA_EVIDENCE_ROOTS_JSON: z.string().default("[]"), + REA_INVESTIGATION_INPUT_ROOTS_JSON: z.string().default("[]"), REA_ANALYSIS_SNAPSHOT_ROOTS_JSON: z.string().default("[]"), REA_REFERENCE_ROOTS_JSON: z.string().default("[]"), REA_REFERENCE_SECRET_PATTERNS_JSON: z.string().default("[]"), @@ -78,6 +80,31 @@ const filePolicy = (roots: readonly string[]): EvidenceFilePolicy => ({ maxNodes: 1_000_000, }); +const parseLoaderArgs = ( + encoded: string | undefined, +): Result => { + if (encoded === undefined) return ok([]); + let decoded: unknown; + try { + decoded = JSON.parse(encoded); + } catch (cause: unknown) { + return err( + new ConfigurationError("HOPPER_LOADER_ARGS_JSON must be valid JSON", { + cause, + }), + ); + } + const parsed = z.array(z.string()).safeParse(decoded); + return parsed.success + ? ok(parsed.data) + : err( + new ConfigurationError( + "HOPPER_LOADER_ARGS_JSON must encode an array of strings", + { cause: parsed.error }, + ), + ); +}; + /** * Parse Hopper launcher configuration once at the composition root. * Explicit loader arguments override REA's header-derived defaults for a @@ -95,28 +122,10 @@ export const parseConfig = ( }), ); } - let loaderArgs: unknown = []; - const encoded = parsedEnvironment.data.HOPPER_LOADER_ARGS_JSON; - if (encoded !== undefined) { - try { - loaderArgs = JSON.parse(encoded); - } catch (cause: unknown) { - return err( - new ConfigurationError("HOPPER_LOADER_ARGS_JSON must be valid JSON", { - cause, - }), - ); - } - } - const parsedArgs = z.array(z.string()).safeParse(loaderArgs); - if (!parsedArgs.success) { - return err( - new ConfigurationError( - "HOPPER_LOADER_ARGS_JSON must encode an array of strings", - { cause: parsedArgs.error }, - ), - ); - } + const loaderArgs = parseLoaderArgs( + parsedEnvironment.data.HOPPER_LOADER_ARGS_JSON, + ); + if (!loaderArgs.ok) return loaderArgs; const executableRoots = parseStringArray( parsedEnvironment.data.REA_PROCESS_EXECUTABLE_ROOTS_JSON, "REA_PROCESS_EXECUTABLE_ROOTS_JSON", @@ -137,6 +146,11 @@ export const parseConfig = ( "REA_EVIDENCE_ROOTS_JSON", ); if (!evidenceRoots.ok) return evidenceRoots; + const investigationInputRoots = parseStringArray( + parsedEnvironment.data.REA_INVESTIGATION_INPUT_ROOTS_JSON, + "REA_INVESTIGATION_INPUT_ROOTS_JSON", + ); + if (!investigationInputRoots.ok) return investigationInputRoots; const analysisSnapshotRoots = parseStringArray( parsedEnvironment.data.REA_ANALYSIS_SNAPSHOT_ROOTS_JSON, "REA_ANALYSIS_SNAPSHOT_ROOTS_JSON", @@ -158,7 +172,7 @@ export const parseConfig = ( defaultHopperLauncherPath(), hopperTargetPath: parsedEnvironment.data.HOPPER_TARGET_PATH, hopperTargetKind: parsedEnvironment.data.HOPPER_TARGET_KIND, - hopperLoaderArgs: parsedArgs.data, + hopperLoaderArgs: loaderArgs.value, logLevel: parsedEnvironment.data.REA_LOG_LEVEL, processExecutionPolicy: { enabled: parsedEnvironment.data.REA_PROCESS_CAPTURE_ENABLED === "true", @@ -171,6 +185,7 @@ export const parseConfig = ( artifactNativeMountEnabled: parsedEnvironment.data.REA_ARTIFACT_NATIVE_MOUNT_ENABLED === "true", evidenceFilePolicy: filePolicy(evidenceRoots.value), + investigationInputRoots: investigationInputRoots.value, analysisSnapshotFilePolicy: filePolicy(analysisSnapshotRoots.value), referenceSourcePolicy: { roots: referenceRoots.value, diff --git a/src/main.ts b/src/main.ts index f253c3a7f..68f0c8085 100644 --- a/src/main.ts +++ b/src/main.ts @@ -88,6 +88,7 @@ export const run = async ( logger, processPolicy: config.value.processExecutionPolicy, evidenceFilePolicy: config.value.evidenceFilePolicy, + investigationInputRoots: config.value.investigationInputRoots, analysisSnapshotFilePolicy: config.value.analysisSnapshotFilePolicy, }), { diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 84e3d6e38..c139620c0 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -16,6 +16,7 @@ export interface CreateServerOptions { readonly logger?: Logger; readonly processPolicy?: ProcessExecutionPolicy; readonly evidenceFilePolicy?: EvidenceFilePolicy; + readonly investigationInputRoots?: readonly string[]; readonly analysisSnapshotFilePolicy?: EvidenceFilePolicy; } diff --git a/src/server/registerInvestigationTools.ts b/src/server/registerInvestigationTools.ts index 6c583057b..95f072d28 100644 --- a/src/server/registerInvestigationTools.ts +++ b/src/server/registerInvestigationTools.ts @@ -41,19 +41,33 @@ export const registerInvestigationTools = ( ToolContract<"correlate_static_and_runtime">, ToolContract<"verify_reconstruction">, ], - evidenceFilePolicy: EvidenceFilePolicy, + policies: InvestigationToolPolicies, ): void => { - registerChangedBehavior(server, session, contracts[0], evidenceFilePolicy); + registerChangedBehavior(server, session, contracts[0], policies); registerCallPath(server, session, contracts[1]); registerStaticRuntime(server, session, contracts[2]); registerReconstruction(server, session, contracts[3]); }; +interface InvestigationToolPolicies { + readonly evidenceFiles: EvidenceFilePolicy; + readonly inputRoots: readonly string[]; +} + +const investigationExecution = ( + session: BinarySessionPort, + signal: AbortSignal, + inputRoots: readonly string[], +) => ({ session, signal, inputRoots }); + +const isIncomplete = (status: string): boolean => + status === "unknown" || status === "truncated"; + const registerChangedBehavior = ( server: McpServer, session: BinarySessionPort, contract: ToolContract<"find_changed_behavior">, - evidenceFilePolicy: EvidenceFilePolicy, + policies: InvestigationToolPolicies, ): void => { server.registerTool( contract.name, @@ -63,9 +77,12 @@ const registerChangedBehavior = ( if (parsed.investigation_run !== undefined) { const investigated = await runCrossVersionInvestigation( parsed.investigation_run, - evidenceFilePolicy, - session, - context.mcpReq.signal, + policies.evidenceFiles, + investigationExecution( + session, + context.mcpReq.signal, + policies.inputRoots, + ), ); if (!investigated.ok) return toCallToolResult(investigated, contract); const result = changedBehaviorResultSchema.parse( @@ -76,8 +93,7 @@ const registerChangedBehavior = ( session, investigated.value.evidence, parsed.unknown_registry_approved, - result.behavior_status === "unknown" || - result.behavior_status === "truncated", + isIncomplete(result.behavior_status), { question: `Automatic changed behavior run ${result.investigation_run?.run_id ?? "unknown"} remains ${result.behavior_status}`, domain: "changed-behavior", @@ -126,8 +142,7 @@ const registerChangedBehavior = ( session, evidence, parsed.unknown_registry_approved, - result.behavior_status === "unknown" || - result.behavior_status === "truncated", + isIncomplete(result.behavior_status), { question: "Did both versions behave the same under a complete controlled replay?", diff --git a/src/server/registerSessionTools.ts b/src/server/registerSessionTools.ts index 3d6d87cae..b643c1b28 100644 --- a/src/server/registerSessionTools.ts +++ b/src/server/registerSessionTools.ts @@ -427,6 +427,7 @@ const registerLifecycleTools = ({ export interface SessionToolOptions { readonly processPolicy?: ProcessExecutionPolicy; readonly evidenceFilePolicy?: EvidenceFilePolicy; + readonly investigationInputRoots?: readonly string[]; readonly analysisSnapshotFilePolicy?: EvidenceFilePolicy; } @@ -486,17 +487,16 @@ export const registerSessionTools = ( registerArtifactComparisonTool(server, session, compareArtifactsContract); registerFunctionComparisonTool(server, session, compareFunctionsContract); registerBundleComparisonTool(server, session, compareBundlesContract); - registerInvestigationTools( - server, - session, - [ - changedBehaviorContract, - callPathContract, - staticRuntimeContract, - reconstructionContract, - ], - evidenceFilePolicy, - ); + const investigationContracts = [ + changedBehaviorContract, + callPathContract, + staticRuntimeContract, + reconstructionContract, + ] as const; + registerInvestigationTools(server, session, investigationContracts, { + evidenceFiles: evidenceFilePolicy, + inputRoots: options.investigationInputRoots ?? [], + }); registerUnknownTools({ server, session, diff --git a/tests/config.test.ts b/tests/config.test.ts index 13a3e05e3..0454a5ff7 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -62,6 +62,7 @@ describe("runtime configuration", () => { maxStringLength: 1024 * 1024, maxNodes: 1_000_000, }, + investigationInputRoots: [], analysisSnapshotFilePolicy: { roots: [], maxBytes: 64 * 1024 * 1024, @@ -117,6 +118,30 @@ describe("runtime configuration", () => { } }); + it("parses investigation input roots independently from evidence roots", () => { + const result = parseConfig({ + REA_EVIDENCE_ROOTS_JSON: '["/evidence"]', + REA_INVESTIGATION_INPUT_ROOTS_JSON: '["/releases", "/builds"]', + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.evidenceFilePolicy.roots).toEqual(["/evidence"]); + expect(result.value.investigationInputRoots).toEqual([ + "/releases", + "/builds", + ]); + } + }); + + it.each(["not-json", "{}", '["/ok", 1]'])( + "rejects invalid investigation input roots: %s", + (encoded) => { + expect( + parseConfig({ REA_INVESTIGATION_INPUT_ROOTS_JSON: encoded }).ok, + ).toBe(false); + }, + ); + it.each(["not-json", "{}", '["/ok", 1]'])( "rejects invalid reference source roots: %s", (encoded) => { diff --git a/tests/integration/configIntegration.test.ts b/tests/integration/configIntegration.test.ts index 45cdc4ba1..6b9dd5f69 100644 --- a/tests/integration/configIntegration.test.ts +++ b/tests/integration/configIntegration.test.ts @@ -23,6 +23,7 @@ describe("configuration parsing integration", () => { HOPPER_TARGET_KIND: "executable", HOPPER_LOADER_ARGS_JSON: '["-l","Mach-O","--aarch64"]', REA_EVIDENCE_ROOTS_JSON: '["/tmp/evidence"]', + REA_INVESTIGATION_INPUT_ROOTS_JSON: '["/tmp/releases"]', REA_ANALYSIS_SNAPSHOT_ROOTS_JSON: '["/tmp/analysis"]', }); expect(result.ok).toBe(true); @@ -35,6 +36,7 @@ describe("configuration parsing integration", () => { "--aarch64", ]); expect(result.value.evidenceFilePolicy.roots).toEqual(["/tmp/evidence"]); + expect(result.value.investigationInputRoots).toEqual(["/tmp/releases"]); expect(result.value.analysisSnapshotFilePolicy.roots).toEqual([ "/tmp/analysis", ]); diff --git a/tests/investigationMcp.test.ts b/tests/investigationMcp.test.ts index 24f9cd9dc..4c9a128e9 100644 --- a/tests/investigationMcp.test.ts +++ b/tests/investigationMcp.test.ts @@ -84,6 +84,48 @@ describe("investigation MCP workflows", () => { } }); + 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"); + const outsideInputs = join(directory, "outside-inputs"); + const evidenceRoot = join(directory, "evidence"); + await Promise.all([ + mkdir(approvedInputs), + mkdir(outsideInputs), + mkdir(evidenceRoot), + ]); + const left = join(outsideInputs, "left"); + const right = join(outsideInputs, "right"); + await Promise.all([mkdir(left), mkdir(right)]); + const { session, server, client } = await connected( + evidencePolicy(evidenceRoot), + [approvedInputs], + ); + try { + const response = await client.callTool({ + name: "find_changed_behavior", + arguments: { + investigation_run: { + approved: true, + workspace_path: join(evidenceRoot, "workspace.json"), + left_path: left, + right_path: right, + }, + }, + }); + expect(response.isError).toBe(true); + expect(response.content).toEqual([ + { + type: "text", + text: "Artifact path is not allowed. Choose a path inside the artifact and try again.", + }, + ]); + } finally { + await close(session, server, client); + await rm(directory, { recursive: true, force: true }); + } + }); + it("aggregates comparison Evidence and records an approved runtime gap", async () => { const { session, server, client } = await connected(); const comparison = @@ -384,7 +426,10 @@ describe("investigation MCP workflows", () => { }); }); -const connected = async (evidenceFilePolicy?: EvidenceFilePolicy) => { +const connected = async ( + evidenceFilePolicy?: EvidenceFilePolicy, + investigationInputRoots: readonly string[] = evidenceFilePolicy?.roots ?? [], +) => { const session = new BinarySession(() => ({ health: () => Promise.resolve(), execute: () => Promise.resolve(observed(null)), @@ -393,7 +438,12 @@ const connected = async (evidenceFilePolicy?: EvidenceFilePolicy) => { const server = createServer( session, session, - evidenceFilePolicy === undefined ? {} : { evidenceFilePolicy }, + evidenceFilePolicy === undefined + ? {} + : { + evidenceFilePolicy, + investigationInputRoots, + }, ); const client = new Client({ name: "investigation-test", version: "1" }); const [clientTransport, serverTransport] = diff --git a/tests/investigationWorkspace.test.ts b/tests/investigationWorkspace.test.ts index baab9f124..1938dec65 100644 --- a/tests/investigationWorkspace.test.ts +++ b/tests/investigationWorkspace.test.ts @@ -70,7 +70,9 @@ describe("persistent cross-version investigation workspace", () => { it("checkpoints, validates, and reuses a completed deterministic run", async () => { const { path, input } = await fixture(); if (directory === undefined) throw new Error("missing fixture root"); - const first = await runCrossVersionInvestigation(input, policy(directory)); + const first = await runCrossVersionInvestigation(input, policy(directory), { + inputRoots: [directory], + }); expect(first).toMatchObject({ ok: true, value: { reused: false, workspace: { revision: 3 } }, @@ -96,7 +98,11 @@ describe("persistent cross-version investigation workspace", () => { serializeInvestigationWorkspace(first.value.workspace), ); - const second = await runCrossVersionInvestigation(input, policy(directory)); + const second = await runCrossVersionInvestigation( + input, + policy(directory), + { inputRoots: [directory] }, + ); expect(second).toMatchObject({ ok: true, value: { @@ -117,6 +123,7 @@ describe("persistent cross-version investigation workspace", () => { const completed = await runCrossVersionInvestigation( input, policy(directory), + { inputRoots: [directory] }, ); if (!completed.ok) throw completed.error; const run = completed.value.workspace.runs[0]; @@ -153,6 +160,7 @@ describe("persistent cross-version investigation workspace", () => { const resumed = await runCrossVersionInvestigation( { ...input, workspace_path: resumePath }, policy(directory), + { inputRoots: [directory] }, ); expect(resumed).toMatchObject({ ok: true, @@ -167,14 +175,20 @@ describe("persistent cross-version investigation workspace", () => { it("appends a changed-content run without replacing earlier Evidence", async () => { const { right, input } = await fixture(); if (directory === undefined) throw new Error("missing fixture root"); - const first = await runCrossVersionInvestigation(input, policy(directory)); + const first = await runCrossVersionInvestigation(input, policy(directory), { + inputRoots: [directory], + }); if (!first.ok) throw first.error; const firstRun = first.value.workspace.runs[0]; if (firstRun === undefined || firstRun.result_evidence_id === null) throw new Error("missing first completed run"); await writeFile(join(right, "app.txt"), "version three\n"); - const second = await runCrossVersionInvestigation(input, policy(directory)); + const second = await runCrossVersionInvestigation( + input, + policy(directory), + { inputRoots: [directory] }, + ); expect(second).toMatchObject({ ok: true, value: { @@ -204,7 +218,9 @@ describe("persistent cross-version investigation workspace", () => { mode: 0o600, }); expect( - await runCrossVersionInvestigation(input, policy(directory)), + await runCrossVersionInvestigation(input, policy(directory), { + inputRoots: [directory], + }), ).toMatchObject({ ok: false, error: { _tag: "InvestigationWorkspaceError", reason: "locked" }, @@ -212,7 +228,9 @@ describe("persistent cross-version investigation workspace", () => { await rm(`${path}.lock`); await writeFile(`${path}.lock`, "occupied\n", { mode: 0o600 }); expect( - await runCrossVersionInvestigation(input, policy(directory)), + await runCrossVersionInvestigation(input, policy(directory), { + inputRoots: [directory], + }), ).toMatchObject({ ok: false, error: { _tag: "InvestigationWorkspaceError", reason: "locked" }, @@ -222,6 +240,7 @@ describe("persistent cross-version investigation workspace", () => { const completed = await runCrossVersionInvestigation( input, policy(directory), + { inputRoots: [directory] }, ); if (!completed.ok) throw completed.error; await expect(stat(`${path}.lock`)).rejects.toMatchObject({ @@ -231,6 +250,7 @@ describe("persistent cross-version investigation workspace", () => { await runCrossVersionInvestigation( { ...input, expected_workspace_revision: 2 }, policy(directory), + { inputRoots: [directory] }, ), ).toMatchObject({ ok: false, @@ -259,8 +279,7 @@ describe("persistent cross-version investigation workspace", () => { await runCrossVersionInvestigation( { ...input, workspace_path: cancelledPath }, policy(directory), - undefined, - controller.signal, + { inputRoots: [directory], signal: controller.signal }, ), ).toMatchObject({ ok: false, @@ -274,7 +293,9 @@ describe("persistent cross-version investigation workspace", () => { const approved = join(directory, "approved"); await mkdir(approved); expect( - await runCrossVersionInvestigation(input, policy(approved)), + await runCrossVersionInvestigation(input, policy(approved), { + inputRoots: [directory], + }), ).toMatchObject({ ok: false, error: { @@ -291,10 +312,27 @@ describe("persistent cross-version investigation workspace", () => { await runCrossVersionInvestigation( { ...input, workspace_path: escaped }, policy(approved), + { inputRoots: [directory] }, ), ).toMatchObject({ ok: false, error: { _tag: "InvestigationWorkspaceError", reason: "not-file" }, }); }); + + it("rejects artifact inputs outside independently approved roots", async () => { + const { input, path } = await fixture(); + if (directory === undefined) throw new Error("missing fixture root"); + const approvedInputs = join(directory, "approved-inputs"); + await mkdir(approvedInputs); + expect( + await runCrossVersionInvestigation(input, policy(directory), { + inputRoots: [approvedInputs], + }), + ).toMatchObject({ + ok: false, + error: { _tag: "ArtifactOperationError", reason: "path" }, + }); + await expect(stat(path)).rejects.toMatchObject({ code: "ENOENT" }); + }); });