rea-agents 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -28
- package/bridge/hopper_bridge.py +161 -12
- package/dist/application/AnalysisSnapshotCache.js +131 -0
- package/dist/application/AnalysisSnapshotFiles.js +31 -0
- package/dist/application/ArtifactInventory.js +84 -44
- package/dist/application/AuthorizedArtifactInventory.js +16 -0
- package/dist/application/BinarySession.js +66 -36
- package/dist/application/BinarySessionPort.js +1 -0
- package/dist/application/BoundedJsonFiles.js +109 -0
- package/dist/application/CommandShimReplay.js +136 -0
- package/dist/application/CrossVersionInventory.js +98 -0
- package/dist/application/CrossVersionInvestigation.js +295 -0
- package/dist/application/DirectAnalysis.js +115 -22
- package/dist/application/Doctor.js +30 -1
- package/dist/application/EvidenceBundleFiles.js +16 -101
- package/dist/application/EvidenceLedger.js +3 -2
- package/dist/application/InvestigationProviders.js +48 -0
- package/dist/application/InvestigationWorkspaceStore.js +214 -0
- package/dist/application/LinuxHopper.js +72 -48
- package/dist/application/ProcessCaptureAuthority.js +25 -0
- package/dist/application/ProcessCaptureCapability.js +23 -0
- package/dist/application/ProcessCaptureError.js +17 -0
- package/dist/application/ProcessCaptureLifecycle.js +287 -0
- package/dist/application/ProcessCheckpoints.js +129 -0
- package/dist/application/ProcessCli.js +128 -0
- package/dist/application/ProcessEvidence.js +38 -0
- package/dist/application/ProcessHarness.js +252 -272
- package/dist/application/ProcessNormalization.js +10 -1
- package/dist/application/ProcessOwnership.js +39 -1
- package/dist/application/ProcessSampling.js +39 -23
- package/dist/application/ReferenceSourceImportEntries.js +18 -3
- package/dist/application/ReferenceSourceImportTypes.js +32 -0
- package/dist/application/Setup.js +44 -96
- package/dist/application/SetupClients.js +19 -0
- package/dist/application/SetupInstallFailure.js +53 -0
- package/dist/application/SetupPlan.js +31 -0
- package/dist/application/SetupSkill.js +44 -0
- package/dist/application/TerminalRenderer.js +92 -0
- package/dist/application/Uninstall.js +33 -23
- package/dist/application/UnknownEvidence.js +33 -0
- package/dist/application/Upgrade.js +1 -1
- package/dist/application/runtime.js +2 -2
- package/dist/artifacts/ArtifactProvider.js +20 -11
- package/dist/artifacts/ArtifactReader.js +3 -1
- package/dist/artifacts/AsarArtifactReader.js +8 -1
- package/dist/artifacts/DirectoryArtifactReader.js +1 -0
- package/dist/artifacts/MachOSliceArtifactReader.js +1 -0
- package/dist/artifacts/NativeDmgArtifactReader.js +151 -0
- package/dist/artifacts/ZipArtifactReader.js +1 -0
- package/dist/cli.js +57 -32
- package/dist/cliEvidenceCommands.js +11 -12
- package/dist/cliInvestigationCommands.js +86 -0
- package/dist/cliOutput.js +41 -0
- package/dist/cliProcessCommands.js +29 -0
- package/dist/config.js +41 -24
- package/dist/contracts/artifactToolContracts.js +1 -0
- package/dist/contracts/investigationExamples.js +3 -3
- package/dist/contracts/processCaptureExample.js +50 -7
- package/dist/contracts/promptContracts.js +256 -0
- package/dist/contracts/sessionLifecycleInputs.js +11 -0
- package/dist/contracts/toolContractTypes.js +1 -0
- package/dist/contracts/toolContracts.js +13 -8
- package/dist/contracts/toolOutputSchemas.js +33 -4
- package/dist/domain/analysisSnapshot.js +149 -0
- package/dist/domain/changedBehavior.js +32 -9
- package/dist/domain/errors.js +186 -39
- package/dist/domain/evidence.js +4 -2
- package/dist/domain/evidenceBundle.js +28 -0
- package/dist/domain/hopperStartupFailure.js +55 -0
- package/dist/domain/investigationWorkspace.js +214 -0
- package/dist/domain/jsonValue.js +2 -0
- package/dist/domain/nativeInspection.js +3 -2
- package/dist/domain/processCapture.js +100 -293
- package/dist/domain/processCaptureValidation.js +124 -0
- package/dist/domain/processComparison.js +177 -32
- package/dist/domain/processScenario.js +370 -0
- package/dist/domain/reconstructionVerification.js +3 -3
- package/dist/domain/staticRuntimeCorrelation.js +3 -2
- package/dist/hopper/BridgeLauncher.js +47 -20
- package/dist/hopper/HopperClient.js +17 -2
- package/dist/hopper/HopperProvider.js +1 -0
- package/dist/identity.js +1 -0
- package/dist/main.js +47 -24
- package/dist/server/createServer.js +2 -0
- package/dist/server/promptCompletion.js +144 -0
- package/dist/server/registerEnhancedTools.js +2 -6
- package/dist/server/registerEvidenceTools.js +2 -8
- package/dist/server/registerFunctionComparisonTool.js +1 -1
- package/dist/server/registerInvestigationTools.js +31 -11
- package/dist/server/registerOfficialTools.js +6 -13
- package/dist/server/registerProcessComparisonTool.js +28 -14
- package/dist/server/registerPrompts.js +67 -0
- package/dist/server/registerSessionStatusTool.js +11 -0
- package/dist/server/registerSessionTools.js +55 -43
- package/dist/server/sessionEvidence.js +2 -8
- package/dist/server/sessionToolPolicies.js +2 -48
- package/dist/server/toolResult.js +7 -3
- package/install.sh +11 -11
- package/package.json +9 -3
- package/scripts/hopper-demo-x11.py +349 -0
- package/scripts/prepare-node-pty.mjs +35 -0
- package/scripts/rea.mjs +6 -3
- package/skills/rea-analysis/SKILL.md +27 -5
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { chmod, mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
const RUNNER = `const command = process.argv[2];
|
|
5
|
+
const response = await fetch(process.env.REA_SHIM_LEDGER_URL, {
|
|
6
|
+
method: "POST",
|
|
7
|
+
headers: { "content-type": "application/json" },
|
|
8
|
+
body: JSON.stringify({ command, arguments: process.argv.slice(3), working_directory: process.cwd() }),
|
|
9
|
+
});
|
|
10
|
+
if (!response.ok) process.exit(127);
|
|
11
|
+
const route = await response.json();
|
|
12
|
+
let elapsed = 0;
|
|
13
|
+
for (const output of route.outputs) {
|
|
14
|
+
const wait = Math.max(0, output.at_ms - elapsed);
|
|
15
|
+
if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
|
|
16
|
+
elapsed = output.at_ms;
|
|
17
|
+
(output.stream === "stdout" ? process.stdout : process.stderr).write(output.data);
|
|
18
|
+
}
|
|
19
|
+
if (route.termination.type === "signal") process.kill(process.pid, route.termination.signal);
|
|
20
|
+
else process.exit(route.termination.code);
|
|
21
|
+
`;
|
|
22
|
+
const shellQuote = (value) => `'${value.replaceAll("'", `'\\''`)}'`;
|
|
23
|
+
/**
|
|
24
|
+
* Start bounded executable replay wrappers and their private invocation ledger.
|
|
25
|
+
*
|
|
26
|
+
* Exact argument matching is deliberate: a fallback to the host executable
|
|
27
|
+
* would make an unmatched dependency call look authoritative. Unmatched and
|
|
28
|
+
* exhausted routes therefore fail visibly and remain part of the capture.
|
|
29
|
+
*/
|
|
30
|
+
export const startCommandShimReplay = async (scenario, temporaryRoot, started) => {
|
|
31
|
+
const binPath = join(temporaryRoot, "shims");
|
|
32
|
+
await mkdir(binPath);
|
|
33
|
+
const runnerPath = join(temporaryRoot, "shim-runner.mjs");
|
|
34
|
+
await writeFile(runnerPath, RUNNER, { mode: 0o600, flag: "wx" });
|
|
35
|
+
for (const shim of scenario.command_shims) {
|
|
36
|
+
const path = join(binPath, shim.name);
|
|
37
|
+
const wrapper = `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(runnerPath)} ${shellQuote(shim.name)} "$@"\n`;
|
|
38
|
+
await writeFile(path, wrapper, { mode: 0o700, flag: "wx" });
|
|
39
|
+
await chmod(path, 0o700);
|
|
40
|
+
}
|
|
41
|
+
const events = [];
|
|
42
|
+
let truncated = false;
|
|
43
|
+
const calls = new Map();
|
|
44
|
+
const server = createServer((request, response) => {
|
|
45
|
+
if (request.method !== "POST" || request.url !== "/invoke") {
|
|
46
|
+
response.writeHead(404).end();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
readInvocation(request)
|
|
50
|
+
.then((invocation) => {
|
|
51
|
+
const shim = scenario.command_shims.find(({ name }) => name === invocation.command);
|
|
52
|
+
const routeIndex = shim?.routes.findIndex(({ arguments: expected }) => JSON.stringify(expected) === JSON.stringify(invocation.arguments));
|
|
53
|
+
const key = `${invocation.command}:${String(routeIndex ?? -1)}`;
|
|
54
|
+
const used = calls.get(key) ?? 0;
|
|
55
|
+
const route = shim !== undefined && routeIndex !== undefined && routeIndex >= 0
|
|
56
|
+
? shim.routes[routeIndex]
|
|
57
|
+
: undefined;
|
|
58
|
+
const outcome = route === undefined
|
|
59
|
+
? "unmatched"
|
|
60
|
+
: used >= route.max_calls
|
|
61
|
+
? "exhausted"
|
|
62
|
+
: "matched";
|
|
63
|
+
if (events.length >= scenario.limits.protocol_events)
|
|
64
|
+
truncated = true;
|
|
65
|
+
else
|
|
66
|
+
events.push({
|
|
67
|
+
sequence: events.length,
|
|
68
|
+
at_ms: Math.max(0, Date.now() - started),
|
|
69
|
+
command: invocation.command,
|
|
70
|
+
route_index: routeIndex !== undefined && routeIndex >= 0 ? routeIndex : null,
|
|
71
|
+
arguments: invocation.arguments,
|
|
72
|
+
working_directory: invocation.working_directory,
|
|
73
|
+
outcome,
|
|
74
|
+
});
|
|
75
|
+
if (outcome !== "matched" || route === undefined) {
|
|
76
|
+
response.writeHead(409).end();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
calls.set(key, used + 1);
|
|
80
|
+
response
|
|
81
|
+
.writeHead(200, { "content-type": "application/json" })
|
|
82
|
+
.end(JSON.stringify(route));
|
|
83
|
+
})
|
|
84
|
+
.catch(() => response.writeHead(400).end());
|
|
85
|
+
});
|
|
86
|
+
await listen(server);
|
|
87
|
+
const address = server.address();
|
|
88
|
+
if (address === null || typeof address === "string") {
|
|
89
|
+
await closeServer(server);
|
|
90
|
+
throw new Error("command shim ledger did not bind an IPv4 port");
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
binPath,
|
|
94
|
+
url: `http://127.0.0.1:${String(address.port)}/invoke`,
|
|
95
|
+
events,
|
|
96
|
+
get truncated() {
|
|
97
|
+
return truncated;
|
|
98
|
+
},
|
|
99
|
+
close: () => closeServer(server),
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
const readInvocation = async (request) => {
|
|
103
|
+
const chunks = [];
|
|
104
|
+
let bytes = 0;
|
|
105
|
+
for await (const chunk of request) {
|
|
106
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
107
|
+
bytes += buffer.length;
|
|
108
|
+
if (bytes > 64 * 1024)
|
|
109
|
+
throw new Error("shim invocation exceeds limit");
|
|
110
|
+
chunks.push(buffer);
|
|
111
|
+
}
|
|
112
|
+
const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
113
|
+
if (typeof value !== "object" ||
|
|
114
|
+
value === null ||
|
|
115
|
+
!("command" in value) ||
|
|
116
|
+
typeof value.command !== "string" ||
|
|
117
|
+
!("arguments" in value) ||
|
|
118
|
+
!Array.isArray(value.arguments) ||
|
|
119
|
+
!value.arguments.every((item) => typeof item === "string") ||
|
|
120
|
+
!("working_directory" in value) ||
|
|
121
|
+
typeof value.working_directory !== "string")
|
|
122
|
+
throw new Error("invalid shim invocation");
|
|
123
|
+
return {
|
|
124
|
+
command: value.command,
|
|
125
|
+
arguments: value.arguments,
|
|
126
|
+
working_directory: value.working_directory,
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
const listen = (server) => new Promise((resolveListen, rejectListen) => {
|
|
130
|
+
server.once("error", rejectListen);
|
|
131
|
+
server.listen(0, "127.0.0.1", () => {
|
|
132
|
+
server.off("error", rejectListen);
|
|
133
|
+
resolveListen();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
const closeServer = (server) => new Promise((resolveClose, rejectClose) => server.close((cause) => cause === undefined ? resolveClose() : rejectClose(cause)));
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
|
2
|
+
import { createEvidence } from "../domain/evidence.js";
|
|
3
|
+
import { AnalysisCancelledError, ArtifactOperationError, } from "../domain/errors.js";
|
|
4
|
+
import { jsonValueSchema } from "../domain/jsonValue.js";
|
|
5
|
+
import { err, ok } from "../domain/result.js";
|
|
6
|
+
import { paginateArtifactInventory, } from "./ArtifactInventory.js";
|
|
7
|
+
import { scanAuthorizedArtifactInventory } from "./AuthorizedArtifactInventory.js";
|
|
8
|
+
import { ARTIFACT_GRAPH_PROVIDER } from "./InvestigationProviders.js";
|
|
9
|
+
export const AUTOMATIC_RUN_LIMITATION = "Automatic cross-version runs compare shipped artifact structure only; they do not execute either version.";
|
|
10
|
+
/** Authorize and scan both inputs once under the same bounded traversal policy. */
|
|
11
|
+
export const scanVersions = async (input, inputRoots, signal) => {
|
|
12
|
+
try {
|
|
13
|
+
if (signal?.aborted === true)
|
|
14
|
+
return err(new AnalysisCancelledError("find_changed_behavior"));
|
|
15
|
+
const limits = artifactLimits(input.options);
|
|
16
|
+
const left = await scanAuthorizedArtifactInventory(input.left_path, inputRoots, limits, signal);
|
|
17
|
+
const right = await scanAuthorizedArtifactInventory(input.right_path, inputRoots, limits, signal);
|
|
18
|
+
return ok({ left, right });
|
|
19
|
+
}
|
|
20
|
+
catch (cause) {
|
|
21
|
+
if (cause instanceof ArtifactReaderFailure)
|
|
22
|
+
return err(new ArtifactOperationError("inventory_artifact", cause.reason, cause.details));
|
|
23
|
+
return err(new ArtifactOperationError("inventory_artifact", "io"));
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
/** Project complete snapshots into bounded, comparable Evidence page sets. */
|
|
27
|
+
export const createInventoryEvidencePages = (input, snapshots) => {
|
|
28
|
+
const left = inventoryPages(input.left_path, snapshots.left, input.options);
|
|
29
|
+
const right = inventoryPages(input.right_path, snapshots.right, input.options);
|
|
30
|
+
if (left.length > 100 || right.length > 100)
|
|
31
|
+
return err(new ArtifactOperationError("inventory_artifact", "limit"));
|
|
32
|
+
return ok({ left, right });
|
|
33
|
+
};
|
|
34
|
+
/** Reduce a snapshot to the content commitments used by run identity. */
|
|
35
|
+
export const targetFor = (snapshot) => ({
|
|
36
|
+
root_sha256: snapshot.manifest.root_sha256,
|
|
37
|
+
graph_sha256: snapshot.manifest.graph_sha256,
|
|
38
|
+
manifest_id: snapshot.manifest.manifest_id,
|
|
39
|
+
format: snapshot.manifest.root_format,
|
|
40
|
+
});
|
|
41
|
+
/** Preserve scan and execution limitations in stable order. */
|
|
42
|
+
export const runLimitations = (snapshots) => [
|
|
43
|
+
...new Set([
|
|
44
|
+
...snapshots.left.limitations.map((item) => `Left: ${item}`),
|
|
45
|
+
...snapshots.right.limitations.map((item) => `Right: ${item}`),
|
|
46
|
+
AUTOMATIC_RUN_LIMITATION,
|
|
47
|
+
]),
|
|
48
|
+
].sort((left, right) => left.localeCompare(right));
|
|
49
|
+
const inventoryPages = (path, snapshot, options) => {
|
|
50
|
+
const count = Math.max(1, Math.ceil(snapshot.nodes.length / options.page_size), Math.ceil(snapshot.occurrences.length / options.page_size), Math.ceil(snapshot.edges.length / options.page_size));
|
|
51
|
+
return Array.from({ length: count }, (_, index) => inventoryPage(path, snapshot, options, index * options.page_size));
|
|
52
|
+
};
|
|
53
|
+
const inventoryPage = (path, snapshot, options, offset) => {
|
|
54
|
+
const result = paginateArtifactInventory(snapshot, {
|
|
55
|
+
nodeOffset: offset,
|
|
56
|
+
nodeLimit: options.page_size,
|
|
57
|
+
occurrenceOffset: offset,
|
|
58
|
+
occurrenceLimit: options.page_size,
|
|
59
|
+
edgeOffset: offset,
|
|
60
|
+
edgeLimit: options.page_size,
|
|
61
|
+
});
|
|
62
|
+
return createEvidence({
|
|
63
|
+
path,
|
|
64
|
+
sha256: snapshot.manifest.root_sha256,
|
|
65
|
+
format: snapshot.manifest.root_format,
|
|
66
|
+
}, ARTIFACT_GRAPH_PROVIDER, {
|
|
67
|
+
operation: "inventory_artifact",
|
|
68
|
+
parameters: {
|
|
69
|
+
node_offset: offset,
|
|
70
|
+
node_limit: options.page_size,
|
|
71
|
+
occurrence_offset: offset,
|
|
72
|
+
occurrence_limit: options.page_size,
|
|
73
|
+
edge_offset: offset,
|
|
74
|
+
edge_limit: options.page_size,
|
|
75
|
+
max_entries: options.max_entries,
|
|
76
|
+
max_total_bytes: options.max_total_bytes,
|
|
77
|
+
max_entry_bytes: options.max_entry_bytes,
|
|
78
|
+
max_compression_ratio: options.max_compression_ratio,
|
|
79
|
+
max_depth: options.max_depth,
|
|
80
|
+
max_path_bytes: options.max_path_bytes,
|
|
81
|
+
},
|
|
82
|
+
result: jsonValueSchema.parse(result),
|
|
83
|
+
rawResult: null,
|
|
84
|
+
limitations: result.limitations,
|
|
85
|
+
locations: result.occurrences.items.map(({ logical_path: logicalPath }) => ({
|
|
86
|
+
kind: "artifact-path",
|
|
87
|
+
path: logicalPath,
|
|
88
|
+
})),
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
const artifactLimits = (options) => ({
|
|
92
|
+
maxEntries: options.max_entries,
|
|
93
|
+
maxTotalBytes: options.max_total_bytes,
|
|
94
|
+
maxEntryBytes: options.max_entry_bytes,
|
|
95
|
+
maxCompressionRatio: options.max_compression_ratio,
|
|
96
|
+
maxDepth: options.max_depth,
|
|
97
|
+
maxPathBytes: options.max_path_bytes,
|
|
98
|
+
});
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { compareArtifacts } from "../domain/artifactComparison.js";
|
|
2
|
+
import { findChangedBehavior } from "../domain/changedBehavior.js";
|
|
3
|
+
import { createEvidence } from "../domain/evidence.js";
|
|
4
|
+
import { createEvidenceBundle, } from "../domain/evidenceBundle.js";
|
|
5
|
+
import { AnalysisInputError, EvidenceIntegrityError, InvestigationWorkspaceError, } from "../domain/errors.js";
|
|
6
|
+
import { createInvestigationRunIdentity, createInvestigationWorkspace, crossVersionInvestigationInputSchema, investigationRunSchema, investigationRunSummarySchema, reviseInvestigationWorkspace, } from "../domain/investigationWorkspace.js";
|
|
7
|
+
import { jsonValueSchema } from "../domain/jsonValue.js";
|
|
8
|
+
import { err, ok } from "../domain/result.js";
|
|
9
|
+
import { AUTOMATIC_RUN_LIMITATION, createInventoryEvidencePages, runLimitations, scanVersions, targetFor, } from "./CrossVersionInventory.js";
|
|
10
|
+
import { readInvestigationWorkspace, writeInvestigationWorkspace, } from "./InvestigationWorkspaceStore.js";
|
|
11
|
+
import { ARTIFACT_COMPARISON_PROVIDER, CHANGED_BEHAVIOR_PROVIDER, } from "./InvestigationProviders.js";
|
|
12
|
+
/** Run or resume a deterministic cross-version artifact investigation. */
|
|
13
|
+
export const runCrossVersionInvestigation = async (input, policy, execution) => {
|
|
14
|
+
const parsed = crossVersionInvestigationInputSchema.safeParse(input);
|
|
15
|
+
if (!parsed.success)
|
|
16
|
+
return err(new AnalysisInputError("find_changed_behavior", {
|
|
17
|
+
cause: parsed.error,
|
|
18
|
+
}));
|
|
19
|
+
const initial = await readInvestigationWorkspace(parsed.data.workspace_path, policy);
|
|
20
|
+
if (!initial.ok)
|
|
21
|
+
return initial;
|
|
22
|
+
const preflight = validateWorkspaceRequest(initial.value, parsed.data);
|
|
23
|
+
if (!preflight.ok)
|
|
24
|
+
return preflight;
|
|
25
|
+
const snapshots = await scanVersions(parsed.data, execution.inputRoots, execution.signal);
|
|
26
|
+
if (!snapshots.ok)
|
|
27
|
+
return snapshots;
|
|
28
|
+
return continueInvestigation({
|
|
29
|
+
input: parsed.data,
|
|
30
|
+
initial: initial.value,
|
|
31
|
+
snapshots: snapshots.value,
|
|
32
|
+
policy,
|
|
33
|
+
session: execution.session,
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
const continueInvestigation = async (context) => {
|
|
37
|
+
const left = targetFor(context.snapshots.left);
|
|
38
|
+
const right = targetFor(context.snapshots.right);
|
|
39
|
+
const identity = createInvestigationRunIdentity({
|
|
40
|
+
left,
|
|
41
|
+
right,
|
|
42
|
+
options: context.input.options,
|
|
43
|
+
});
|
|
44
|
+
const existing = context.initial?.runs.find(({ run_id: id }) => id === identity.runId);
|
|
45
|
+
if (context.initial !== null && existing?.status === "complete")
|
|
46
|
+
return completeOutcome(context.initial, existing, true, context.session);
|
|
47
|
+
const pages = createInventoryEvidencePages(context.input, context.snapshots);
|
|
48
|
+
if (!pages.ok)
|
|
49
|
+
return pages;
|
|
50
|
+
const inventoried = await ensureInventoryCheckpoint({
|
|
51
|
+
input: context.input,
|
|
52
|
+
current: context.initial,
|
|
53
|
+
existing,
|
|
54
|
+
pages: pages.value,
|
|
55
|
+
snapshots: context.snapshots,
|
|
56
|
+
policy: context.policy,
|
|
57
|
+
});
|
|
58
|
+
if (!inventoried.ok)
|
|
59
|
+
return inventoried;
|
|
60
|
+
const compared = await ensureComparisonCheckpoint(context.input, inventoried.value, context.policy);
|
|
61
|
+
if (!compared.ok)
|
|
62
|
+
return compared;
|
|
63
|
+
return finalizeInvestigation(context.input, compared.value, context.policy, context.session);
|
|
64
|
+
};
|
|
65
|
+
const ensureInventoryCheckpoint = async (input) => {
|
|
66
|
+
if (input.existing !== undefined) {
|
|
67
|
+
const consistent = validateInventoryCheckpoint(input.existing, input.pages);
|
|
68
|
+
if (!consistent.ok)
|
|
69
|
+
return consistent;
|
|
70
|
+
return input.current === null
|
|
71
|
+
? err(new EvidenceIntegrityError("Investigation workspace is missing"))
|
|
72
|
+
: ok({ workspace: input.current, run: input.existing });
|
|
73
|
+
}
|
|
74
|
+
const left = targetFor(input.snapshots.left);
|
|
75
|
+
const right = targetFor(input.snapshots.right);
|
|
76
|
+
const identity = createInvestigationRunIdentity({
|
|
77
|
+
left,
|
|
78
|
+
right,
|
|
79
|
+
options: input.input.options,
|
|
80
|
+
});
|
|
81
|
+
const run = investigationRunSchema.parse({
|
|
82
|
+
schema_version: 1,
|
|
83
|
+
run_id: identity.runId,
|
|
84
|
+
request_sha256: identity.requestSha256,
|
|
85
|
+
left,
|
|
86
|
+
right,
|
|
87
|
+
options: input.input.options,
|
|
88
|
+
status: "running",
|
|
89
|
+
completed_stages: ["inventory_left", "inventory_right"],
|
|
90
|
+
left_inventory_evidence_ids: evidenceIds(input.pages.left),
|
|
91
|
+
right_inventory_evidence_ids: evidenceIds(input.pages.right),
|
|
92
|
+
comparison_evidence_id: null,
|
|
93
|
+
result_evidence_id: null,
|
|
94
|
+
limitations: runLimitations(input.snapshots),
|
|
95
|
+
});
|
|
96
|
+
const saved = await checkpoint({
|
|
97
|
+
path: input.input.workspace_path,
|
|
98
|
+
name: input.input.workspace_name,
|
|
99
|
+
current: input.current,
|
|
100
|
+
records: [...input.pages.left, ...input.pages.right],
|
|
101
|
+
run,
|
|
102
|
+
policy: input.policy,
|
|
103
|
+
});
|
|
104
|
+
return saved.ok ? ok({ workspace: saved.value, run }) : saved;
|
|
105
|
+
};
|
|
106
|
+
const ensureComparisonCheckpoint = async (input, state, policy) => {
|
|
107
|
+
if (state.run.comparison_evidence_id !== null)
|
|
108
|
+
return ok(state);
|
|
109
|
+
const inventory = inventoryEvidence(state.workspace, state.run);
|
|
110
|
+
if (!inventory.ok)
|
|
111
|
+
return inventory;
|
|
112
|
+
const evidence = createArtifactComparisonEvidence(inventory.value, input.options.change_limit);
|
|
113
|
+
const run = investigationRunSchema.parse({
|
|
114
|
+
...state.run,
|
|
115
|
+
completed_stages: [
|
|
116
|
+
"inventory_left",
|
|
117
|
+
"inventory_right",
|
|
118
|
+
"compare_artifacts",
|
|
119
|
+
],
|
|
120
|
+
comparison_evidence_id: evidence.evidence_id,
|
|
121
|
+
});
|
|
122
|
+
const saved = await checkpoint({
|
|
123
|
+
path: input.workspace_path,
|
|
124
|
+
name: input.workspace_name,
|
|
125
|
+
current: state.workspace,
|
|
126
|
+
records: [evidence],
|
|
127
|
+
run,
|
|
128
|
+
policy,
|
|
129
|
+
});
|
|
130
|
+
return saved.ok ? ok({ workspace: saved.value, run }) : saved;
|
|
131
|
+
};
|
|
132
|
+
const createArtifactComparisonEvidence = (inventory, limit) => {
|
|
133
|
+
const comparison = compareArtifacts(inventory.left, inventory.right, 0, limit);
|
|
134
|
+
const leftIds = evidenceIds(inventory.left);
|
|
135
|
+
const rightIds = evidenceIds(inventory.right);
|
|
136
|
+
return createEvidence(undefined, ARTIFACT_COMPARISON_PROVIDER, {
|
|
137
|
+
predicateType: "rea.artifact-comparison/v1",
|
|
138
|
+
operation: "compare_artifacts",
|
|
139
|
+
parameters: {
|
|
140
|
+
left_evidence_ids: leftIds,
|
|
141
|
+
right_evidence_ids: rightIds,
|
|
142
|
+
offset: 0,
|
|
143
|
+
limit,
|
|
144
|
+
},
|
|
145
|
+
result: jsonValueSchema.parse(comparison),
|
|
146
|
+
confidence: "derived",
|
|
147
|
+
authority: "analyst-inference",
|
|
148
|
+
limitations: comparison.limitations,
|
|
149
|
+
evidenceLinks: [...leftIds, ...rightIds],
|
|
150
|
+
});
|
|
151
|
+
};
|
|
152
|
+
const finalizeInvestigation = async (input, state, policy, session) => {
|
|
153
|
+
const comparison = comparisonEvidenceFor(state.workspace, state.run);
|
|
154
|
+
if (!comparison.ok)
|
|
155
|
+
return comparison;
|
|
156
|
+
const evidence = createChangedBehaviorEvidence(state, comparison.value);
|
|
157
|
+
const run = investigationRunSchema.parse({
|
|
158
|
+
...state.run,
|
|
159
|
+
status: "complete",
|
|
160
|
+
completed_stages: [
|
|
161
|
+
"inventory_left",
|
|
162
|
+
"inventory_right",
|
|
163
|
+
"compare_artifacts",
|
|
164
|
+
"find_changed_behavior",
|
|
165
|
+
],
|
|
166
|
+
result_evidence_id: evidence.evidence_id,
|
|
167
|
+
});
|
|
168
|
+
const saved = await checkpoint({
|
|
169
|
+
path: input.workspace_path,
|
|
170
|
+
name: input.workspace_name,
|
|
171
|
+
current: state.workspace,
|
|
172
|
+
records: [evidence],
|
|
173
|
+
run,
|
|
174
|
+
policy,
|
|
175
|
+
});
|
|
176
|
+
return saved.ok ? completeOutcome(saved.value, run, false, session) : saved;
|
|
177
|
+
};
|
|
178
|
+
const createChangedBehaviorEvidence = (state, comparison) => {
|
|
179
|
+
const limit = Math.min(state.run.options.change_limit, 100);
|
|
180
|
+
const changed = findChangedBehavior([comparison], 0, limit);
|
|
181
|
+
const summary = investigationRunSummarySchema.parse({
|
|
182
|
+
schema_version: 1,
|
|
183
|
+
workspace_id: state.workspace.workspace_id,
|
|
184
|
+
run_id: state.run.run_id,
|
|
185
|
+
left_manifest_id: state.run.left.manifest_id,
|
|
186
|
+
right_manifest_id: state.run.right.manifest_id,
|
|
187
|
+
inventory_evidence_count: state.run.left_inventory_evidence_ids.length +
|
|
188
|
+
state.run.right_inventory_evidence_ids.length,
|
|
189
|
+
comparison_evidence_id: comparison.evidence_id,
|
|
190
|
+
limitations: state.run.limitations,
|
|
191
|
+
});
|
|
192
|
+
return createEvidence(undefined, CHANGED_BEHAVIOR_PROVIDER, {
|
|
193
|
+
predicateType: "rea.changed-behavior/v1",
|
|
194
|
+
operation: "find_changed_behavior",
|
|
195
|
+
parameters: {
|
|
196
|
+
workspace_id: state.workspace.workspace_id,
|
|
197
|
+
run_id: state.run.run_id,
|
|
198
|
+
comparison_evidence_ids: [comparison.evidence_id],
|
|
199
|
+
offset: 0,
|
|
200
|
+
limit,
|
|
201
|
+
},
|
|
202
|
+
result: jsonValueSchema.parse({
|
|
203
|
+
...changed,
|
|
204
|
+
investigation_run: summary,
|
|
205
|
+
}),
|
|
206
|
+
confidence: "derived",
|
|
207
|
+
authority: "analyst-inference",
|
|
208
|
+
limitations: [...changed.limitations, AUTOMATIC_RUN_LIMITATION],
|
|
209
|
+
evidenceLinks: changed.evidence_links,
|
|
210
|
+
});
|
|
211
|
+
};
|
|
212
|
+
const comparisonEvidenceFor = (workspace, run) => {
|
|
213
|
+
const evidence = workspace.bundle.records.find(({ evidence_id: id }) => id === run.comparison_evidence_id);
|
|
214
|
+
return evidence === undefined
|
|
215
|
+
? err(new EvidenceIntegrityError("Investigation comparison checkpoint is missing Evidence"))
|
|
216
|
+
: ok(evidence);
|
|
217
|
+
};
|
|
218
|
+
const checkpoint = async (input) => {
|
|
219
|
+
const records = mergeEvidence(input.current?.bundle.records ?? [], input.records);
|
|
220
|
+
const bundle = createEvidenceBundle(records, input.current?.bundle.unknowns ?? []);
|
|
221
|
+
const runs = replaceRun(input.current?.runs ?? [], input.run);
|
|
222
|
+
const next = input.current === null
|
|
223
|
+
? createInvestigationWorkspace(input.name, bundle, runs)
|
|
224
|
+
: reviseInvestigationWorkspace(input.current, bundle, runs);
|
|
225
|
+
const written = await writeInvestigationWorkspace(next, input.path, input.current?.revision ?? null, input.policy);
|
|
226
|
+
return written.ok ? ok(next) : written;
|
|
227
|
+
};
|
|
228
|
+
const completeOutcome = (workspace, run, reused, session) => {
|
|
229
|
+
if (run.result_evidence_id === null)
|
|
230
|
+
return err(new EvidenceIntegrityError("Completed investigation has no result"));
|
|
231
|
+
const evidence = workspace.bundle.records.find(({ evidence_id: id }) => id === run.result_evidence_id);
|
|
232
|
+
if (evidence === undefined)
|
|
233
|
+
return err(new EvidenceIntegrityError("Completed investigation result Evidence is missing"));
|
|
234
|
+
if (session !== undefined) {
|
|
235
|
+
const records = recordsForRun(workspace, run);
|
|
236
|
+
const imported = session.importEvidenceBundle(createEvidenceBundle(records));
|
|
237
|
+
if (!imported.ok)
|
|
238
|
+
return imported;
|
|
239
|
+
}
|
|
240
|
+
return ok({ evidence, workspace, reused });
|
|
241
|
+
};
|
|
242
|
+
const validateWorkspaceRequest = (workspace, input) => {
|
|
243
|
+
if (workspace !== null && workspace.name !== input.workspace_name)
|
|
244
|
+
return err(new InvestigationWorkspaceError("update", "name-conflict"));
|
|
245
|
+
if (input.expected_workspace_revision !== undefined &&
|
|
246
|
+
workspace?.revision !== input.expected_workspace_revision)
|
|
247
|
+
return err(new InvestigationWorkspaceError("update", "revision-conflict"));
|
|
248
|
+
return ok(null);
|
|
249
|
+
};
|
|
250
|
+
const validateInventoryCheckpoint = (run, pages) => JSON.stringify(run.left_inventory_evidence_ids) ===
|
|
251
|
+
JSON.stringify(evidenceIds(pages.left)) &&
|
|
252
|
+
JSON.stringify(run.right_inventory_evidence_ids) ===
|
|
253
|
+
JSON.stringify(evidenceIds(pages.right))
|
|
254
|
+
? ok(null)
|
|
255
|
+
: err(new EvidenceIntegrityError("Investigation inventory checkpoint disagrees with current content"));
|
|
256
|
+
const inventoryEvidence = (workspace, run) => {
|
|
257
|
+
if (workspace === null)
|
|
258
|
+
return err(new EvidenceIntegrityError("Investigation workspace is missing"));
|
|
259
|
+
const byId = new Map(workspace.bundle.records.map((record) => [record.evidence_id, record]));
|
|
260
|
+
const left = run.left_inventory_evidence_ids.flatMap((id) => {
|
|
261
|
+
const evidence = byId.get(id);
|
|
262
|
+
return evidence === undefined ? [] : [evidence];
|
|
263
|
+
});
|
|
264
|
+
const right = run.right_inventory_evidence_ids.flatMap((id) => {
|
|
265
|
+
const evidence = byId.get(id);
|
|
266
|
+
return evidence === undefined ? [] : [evidence];
|
|
267
|
+
});
|
|
268
|
+
return left.length === run.left_inventory_evidence_ids.length &&
|
|
269
|
+
right.length === run.right_inventory_evidence_ids.length
|
|
270
|
+
? ok({ left, right })
|
|
271
|
+
: err(new EvidenceIntegrityError("Investigation inventory checkpoint is incomplete"));
|
|
272
|
+
};
|
|
273
|
+
const recordsForRun = (workspace, run) => {
|
|
274
|
+
const ids = new Set([
|
|
275
|
+
...run.left_inventory_evidence_ids,
|
|
276
|
+
...run.right_inventory_evidence_ids,
|
|
277
|
+
...(run.comparison_evidence_id === null
|
|
278
|
+
? []
|
|
279
|
+
: [run.comparison_evidence_id]),
|
|
280
|
+
...(run.result_evidence_id === null ? [] : [run.result_evidence_id]),
|
|
281
|
+
]);
|
|
282
|
+
return workspace.bundle.records.filter(({ evidence_id: id }) => ids.has(id));
|
|
283
|
+
};
|
|
284
|
+
const mergeEvidence = (current, additions) => {
|
|
285
|
+
const records = new Map(current.map((record) => [record.evidence_id, record]));
|
|
286
|
+
for (const record of additions)
|
|
287
|
+
if (!records.has(record.evidence_id))
|
|
288
|
+
records.set(record.evidence_id, record);
|
|
289
|
+
return [...records.values()];
|
|
290
|
+
};
|
|
291
|
+
const replaceRun = (runs, replacement) => [
|
|
292
|
+
...runs.filter(({ run_id: id }) => id !== replacement.run_id),
|
|
293
|
+
replacement,
|
|
294
|
+
];
|
|
295
|
+
const evidenceIds = (records) => records.map(({ evidence_id: id }) => id);
|