fix(security): restrict investigation artifact inputs
This commit is contained in:
parent
ee785ca9a9
commit
a2076b47a6
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ const runInvestigation = async (
|
|||
},
|
||||
},
|
||||
config.value.evidenceFilePolicy,
|
||||
{ inputRoots: config.value.investigationInputRoots },
|
||||
);
|
||||
return investigated.ok
|
||||
? investigated.value.evidence
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
{
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export interface CreateServerOptions {
|
|||
readonly logger?: Logger;
|
||||
readonly processPolicy?: ProcessExecutionPolicy;
|
||||
readonly evidenceFilePolicy?: EvidenceFilePolicy;
|
||||
readonly investigationInputRoots?: readonly string[];
|
||||
readonly analysisSnapshotFilePolicy?: EvidenceFilePolicy;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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] =
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue