fix(security): restrict investigation artifact inputs

This commit is contained in:
morluto 2026-07-14 04:48:46 +08:00
parent ee785ca9a9
commit a2076b47a6
17 changed files with 286 additions and 66 deletions

View File

@ -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

View File

@ -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.

View File

@ -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"]),
};

View File

@ -102,6 +102,16 @@ export const scanArtifactInventory = async (
): Promise<ArtifactInventorySnapshot> => {
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<ArtifactInventorySnapshot> => {
const metadata = await lstat(path);
const rootFormat = await classifyRoot(path, metadata.isDirectory());
const rootDigest = metadata.isDirectory()

View File

@ -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<ArtifactInventorySnapshot> => {
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);
};

View File

@ -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<Result<VersionSnapshots, AnalysisError>> => {
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)

View File

@ -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<Result<CrossVersionInvestigationOutcome, AnalysisError>> => {
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,
});
};

View File

@ -98,6 +98,7 @@ const runInvestigation = async (
},
},
config.value.evidenceFilePolicy,
{ inputRoots: config.value.investigationInputRoots },
);
return investigated.ok
? investigated.value.evidence

View File

@ -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<readonly string[], ConfigurationError> => {
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,

View File

@ -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,
}),
{

View File

@ -16,6 +16,7 @@ export interface CreateServerOptions {
readonly logger?: Logger;
readonly processPolicy?: ProcessExecutionPolicy;
readonly evidenceFilePolicy?: EvidenceFilePolicy;
readonly investigationInputRoots?: readonly string[];
readonly analysisSnapshotFilePolicy?: EvidenceFilePolicy;
}

View File

@ -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?",

View File

@ -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,

View File

@ -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) => {

View File

@ -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",
]);

View File

@ -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] =

View File

@ -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" });
});
});