rea-agents 1.2.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 +74 -27
- 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 +4 -0
- 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/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 +15 -108
- 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 +2 -2
- package/dist/application/ProcessCaptureError.js +12 -0
- package/dist/application/ProcessCaptureLifecycle.js +19 -1
- package/dist/application/ProcessCli.js +98 -31
- package/dist/application/ProcessHarness.js +2 -2
- package/dist/application/ReferenceSourceImportEntries.js +18 -3
- package/dist/application/ReferenceSourceImportTypes.js +32 -0
- package/dist/application/Setup.js +33 -60
- package/dist/application/SetupClients.js +19 -0
- package/dist/application/SetupInstallFailure.js +53 -0
- package/dist/application/SetupPlan.js +31 -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 +1 -1
- package/dist/artifacts/ArtifactProvider.js +2 -5
- package/dist/cli.js +31 -11
- package/dist/cliEvidenceCommands.js +11 -12
- package/dist/cliInvestigationCommands.js +86 -0
- package/dist/cliOutput.js +41 -0
- package/dist/cliProcessCommands.js +14 -4
- package/dist/config.js +39 -24
- 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 +10 -6
- package/dist/contracts/toolOutputSchemas.js +33 -4
- package/dist/domain/analysisSnapshot.js +149 -0
- package/dist/domain/changedBehavior.js +30 -7
- package/dist/domain/errors.js +180 -41
- 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 +5 -4
- package/dist/domain/processComparison.js +5 -4
- package/dist/hopper/BridgeLauncher.js +47 -20
- package/dist/hopper/HopperClient.js +17 -2
- package/dist/hopper/HopperProvider.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 +1 -7
- package/dist/server/registerPrompts.js +67 -0
- package/dist/server/registerSessionStatusTool.js +11 -0
- package/dist/server/registerSessionTools.js +52 -18
- package/dist/server/sessionEvidence.js +2 -8
- package/dist/server/sessionToolPolicies.js +1 -42
- package/dist/server/toolResult.js +7 -3
- package/install.sh +11 -11
- package/package.json +5 -3
- package/scripts/hopper-demo-x11.py +349 -0
- package/scripts/rea.mjs +6 -3
- package/skills/rea-analysis/SKILL.md +9 -2
|
@@ -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);
|
|
@@ -3,6 +3,12 @@ import { EnhancedTools } from "./EnhancedTools.js";
|
|
|
3
3
|
import { createBinarySession } from "./runtime.js";
|
|
4
4
|
import { silentLogger } from "../logger.js";
|
|
5
5
|
import { createEvidence } from "../domain/evidence.js";
|
|
6
|
+
import { EvidenceIntegrityError, projectAnalysisError, } from "../domain/errors.js";
|
|
7
|
+
import { access } from "node:fs/promises";
|
|
8
|
+
import { readAnalysisSnapshot, writeAnalysisSnapshot, } from "./AnalysisSnapshotFiles.js";
|
|
9
|
+
import { parseBinaryTarget } from "../domain/binaryTarget.js";
|
|
10
|
+
import { snapshotEvidenceForQuery, snapshotMatchesTarget, } from "../domain/analysisSnapshot.js";
|
|
11
|
+
import { err, ok } from "../domain/result.js";
|
|
6
12
|
const WORKFLOW_PROVIDER = {
|
|
7
13
|
id: "rea-workflow",
|
|
8
14
|
name: "REA composed investigation workflow",
|
|
@@ -13,14 +19,17 @@ const WORKFLOW_PROVIDER = {
|
|
|
13
19
|
* Unlike MCP mode, every CLI invocation is intentionally isolated and does not
|
|
14
20
|
* retain a target or bridge resources for a subsequent command.
|
|
15
21
|
*/
|
|
16
|
-
export const runDirectAnalysis = async (path, tool, arguments_,
|
|
22
|
+
export const runDirectAnalysis = async (path, tool, arguments_, options = {}) => runAnalysis(path, tool, arguments_, {
|
|
23
|
+
logger: options.logger ?? silentLogger,
|
|
24
|
+
snapshotPath: options.snapshotPath,
|
|
25
|
+
});
|
|
17
26
|
/** Execute one provider-native semantic operation with atomic provenance. */
|
|
18
|
-
export const runProviderAnalysis = async (path, tool, arguments_, logger = silentLogger) => runAnalysis(path, tool, arguments_, logger);
|
|
27
|
+
export const runProviderAnalysis = async (path, tool, arguments_, logger = silentLogger) => runAnalysis(path, tool, arguments_, { logger, snapshotPath: undefined });
|
|
19
28
|
/** Describe configured providers without opening a target or launching Hopper. */
|
|
20
29
|
export const runSessionStatus = async (logger = silentLogger) => {
|
|
21
30
|
const config = parseConfig(process.env);
|
|
22
31
|
if (!config.ok)
|
|
23
|
-
return
|
|
32
|
+
return cliError(config.error);
|
|
24
33
|
const session = createBinarySession(config.value, logger);
|
|
25
34
|
try {
|
|
26
35
|
return session.status();
|
|
@@ -29,21 +38,42 @@ export const runSessionStatus = async (logger = silentLogger) => {
|
|
|
29
38
|
await session.close();
|
|
30
39
|
}
|
|
31
40
|
};
|
|
32
|
-
const runAnalysis = async (path, tool, arguments_,
|
|
41
|
+
const runAnalysis = async (path, tool, arguments_, options) => {
|
|
42
|
+
const { logger, snapshotPath } = options;
|
|
33
43
|
const config = parseConfig(process.env);
|
|
34
44
|
if (!config.ok)
|
|
35
|
-
return
|
|
45
|
+
return cliError(config.error);
|
|
46
|
+
const unavailable = snapshotUnavailable(snapshotPath, config.value.hopperLoaderArgs);
|
|
47
|
+
if (unavailable !== undefined)
|
|
48
|
+
return cliError(unavailable);
|
|
36
49
|
const session = createBinarySession(config.value, logger);
|
|
37
50
|
try {
|
|
38
|
-
const
|
|
51
|
+
const prepared = await prepareSnapshot({
|
|
52
|
+
path,
|
|
53
|
+
tool,
|
|
54
|
+
arguments: arguments_,
|
|
55
|
+
snapshotPath,
|
|
56
|
+
policy: config.value.analysisSnapshotFilePolicy,
|
|
57
|
+
provider: replayProviderFor(session, tool),
|
|
58
|
+
});
|
|
59
|
+
if (!prepared.ok)
|
|
60
|
+
return cliError(prepared.error);
|
|
61
|
+
if (prepared.value.evidence !== undefined)
|
|
62
|
+
return prepared.value.evidence;
|
|
63
|
+
const { snapshot } = prepared.value;
|
|
64
|
+
const opened = await session.open(path, snapshot === undefined ? {} : { snapshot });
|
|
39
65
|
if (!opened.ok)
|
|
40
|
-
return
|
|
66
|
+
return cliError(opened.error);
|
|
67
|
+
let output;
|
|
68
|
+
let evidence;
|
|
41
69
|
if (tool === "binary_overview" ||
|
|
42
70
|
tool === "analyze_function" ||
|
|
43
71
|
tool === "trace_feature") {
|
|
44
72
|
const result = await new EnhancedTools(session).execute(tool, arguments_);
|
|
45
|
-
|
|
46
|
-
|
|
73
|
+
if (!result.ok)
|
|
74
|
+
output = cliError(result.error);
|
|
75
|
+
else {
|
|
76
|
+
evidence = createEvidence(opened.value, tool === "analyze_function"
|
|
47
77
|
? session.providerIdentity(tool)
|
|
48
78
|
: WORKFLOW_PROVIDER, {
|
|
49
79
|
operation: tool,
|
|
@@ -51,22 +81,85 @@ const runAnalysis = async (path, tool, arguments_, logger) => {
|
|
|
51
81
|
result: result.value,
|
|
52
82
|
confidence: "derived",
|
|
53
83
|
limitations: ["Derived by an REA composed workflow."],
|
|
54
|
-
})
|
|
55
|
-
|
|
84
|
+
});
|
|
85
|
+
output = evidence;
|
|
86
|
+
}
|
|
56
87
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
result
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
88
|
+
else {
|
|
89
|
+
const result = await session.execute(tool, arguments_);
|
|
90
|
+
if (!result.ok)
|
|
91
|
+
output = cliError(result.error);
|
|
92
|
+
else {
|
|
93
|
+
evidence = createEvidence(result.value.subject ?? opened.value, result.value.provider, {
|
|
94
|
+
operation: tool,
|
|
95
|
+
parameters: arguments_,
|
|
96
|
+
result: result.value.result,
|
|
97
|
+
rawResult: result.value.rawResult,
|
|
98
|
+
limitations: result.value.limitations,
|
|
99
|
+
locations: result.value.locations,
|
|
100
|
+
});
|
|
101
|
+
output = evidence;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (evidence !== undefined)
|
|
105
|
+
session.recordEvidence(evidence);
|
|
106
|
+
if (snapshotPath !== undefined && evidence !== undefined) {
|
|
107
|
+
const snapshot = session.exportAnalysisSnapshot();
|
|
108
|
+
if (!snapshot.ok)
|
|
109
|
+
return cliError(snapshot.error);
|
|
110
|
+
const written = await writeAnalysisSnapshot(snapshot.value, snapshotPath, true, config.value.analysisSnapshotFilePolicy);
|
|
111
|
+
if (!written.ok)
|
|
112
|
+
return cliError(written.error);
|
|
113
|
+
}
|
|
114
|
+
return output;
|
|
68
115
|
}
|
|
69
116
|
finally {
|
|
70
117
|
await session.close();
|
|
71
118
|
}
|
|
72
119
|
};
|
|
120
|
+
const prepareSnapshot = async (options) => {
|
|
121
|
+
const { path, tool, arguments: arguments_, snapshotPath, policy, provider, } = options;
|
|
122
|
+
if (snapshotPath === undefined || !(await fileExists(snapshotPath)))
|
|
123
|
+
return ok({});
|
|
124
|
+
const loaded = await readAnalysisSnapshot(snapshotPath, policy);
|
|
125
|
+
if (!loaded.ok)
|
|
126
|
+
return loaded;
|
|
127
|
+
const target = await parseBinaryTarget(path);
|
|
128
|
+
if (!target.ok)
|
|
129
|
+
return target;
|
|
130
|
+
if (!snapshotMatchesTarget(loaded.value.target, target.value))
|
|
131
|
+
return err(new EvidenceIntegrityError("Analysis snapshot target does not match the requested binary"));
|
|
132
|
+
const evidence = snapshotEvidenceForQuery(loaded.value, {
|
|
133
|
+
target: target.value,
|
|
134
|
+
operation: tool,
|
|
135
|
+
parameters: arguments_,
|
|
136
|
+
provider,
|
|
137
|
+
});
|
|
138
|
+
return ok(evidence === undefined
|
|
139
|
+
? { snapshot: loaded.value }
|
|
140
|
+
: { snapshot: loaded.value, evidence });
|
|
141
|
+
};
|
|
142
|
+
const snapshotUnavailable = (snapshotPath, loaderArgs) => snapshotPath !== undefined && loaderArgs.length > 0
|
|
143
|
+
? new EvidenceIntegrityError("Analysis snapshots are unavailable with custom Hopper loader arguments")
|
|
144
|
+
: undefined;
|
|
145
|
+
const replayProviderFor = (session, tool) => tool === "binary_overview" || tool === "trace_feature"
|
|
146
|
+
? WORKFLOW_PROVIDER
|
|
147
|
+
: session.providerIdentity(tool);
|
|
148
|
+
const fileExists = async (path) => {
|
|
149
|
+
try {
|
|
150
|
+
await access(path);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
catch (cause) {
|
|
154
|
+
if (typeof cause === "object" &&
|
|
155
|
+
cause !== null &&
|
|
156
|
+
"code" in cause &&
|
|
157
|
+
cause.code === "ENOENT")
|
|
158
|
+
return false;
|
|
159
|
+
throw cause;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
const cliError = (error) => ({
|
|
163
|
+
error: "Analysis failed",
|
|
164
|
+
...projectAnalysisError(error),
|
|
165
|
+
});
|
|
@@ -8,7 +8,7 @@ import { promisify } from "node:util";
|
|
|
8
8
|
import { parseBinaryTarget } from "../domain/binaryTarget.js";
|
|
9
9
|
import { supportsNodeVersion } from "../domain/runtimeVersion.js";
|
|
10
10
|
import { probeHomebrew } from "./homebrew.js";
|
|
11
|
-
import { linuxHopperLauncherPath, linuxSharedLibrariesAvailable, readLinuxDistribution, } from "./LinuxHopper.js";
|
|
11
|
+
import { linuxHopperBinarySupported, linuxHopperLauncherPath, linuxSharedLibrariesAvailable, readLinuxDistribution, } from "./LinuxHopper.js";
|
|
12
12
|
const execFileAsync = promisify(execFile);
|
|
13
13
|
const DEFAULT_HOPPER = "/Applications/Hopper Disassembler.app/Contents/MacOS/hopper";
|
|
14
14
|
const SYSTEM_LINUX_HOPPER = "/opt/hopper/bin/Hopper";
|
|
@@ -53,6 +53,16 @@ export const runDoctor = async (target, host = systemDoctorHost()) => {
|
|
|
53
53
|
remediation: "Run rea setup to install Hopper, or set HOPPER_LAUNCHER_PATH.",
|
|
54
54
|
classification: "missing_analysis_engine",
|
|
55
55
|
}));
|
|
56
|
+
if (host.platform === "linux" && hopperPath !== undefined)
|
|
57
|
+
checks.push(check("hopper-version", await host.supportedLinuxHopper(hopperPath), hopperPath, {
|
|
58
|
+
remediation: unsupportedHopperRemediation(host.configuredHopperPath === hopperPath),
|
|
59
|
+
classification: "config_drift",
|
|
60
|
+
}));
|
|
61
|
+
if (host.platform === "linux" && hopperPath !== undefined)
|
|
62
|
+
checks.push(check("hopper-demo-runtime", await host.linuxDemoRuntimeReady(), undefined, {
|
|
63
|
+
remediation: "Rerun rea setup to install the Xvfb, xauth, Python, X11, and XTEST packages required for Linux demo sessions.",
|
|
64
|
+
classification: "missing_dependency",
|
|
65
|
+
}));
|
|
56
66
|
if (target !== undefined)
|
|
57
67
|
checks.push(check("target", await host.validTarget(target), target, {
|
|
58
68
|
remediation: "Supply a readable local app or program path.",
|
|
@@ -64,6 +74,9 @@ export const runDoctor = async (target, host = systemDoctorHost()) => {
|
|
|
64
74
|
checks,
|
|
65
75
|
};
|
|
66
76
|
};
|
|
77
|
+
const unsupportedHopperRemediation = (configured) => configured
|
|
78
|
+
? "Unset or update HOPPER_LAUNCHER_PATH, install the Hopper build supported by this REA release, or update REA."
|
|
79
|
+
: "Install the Hopper build supported by this REA release, or update REA for a newer Hopper build.";
|
|
67
80
|
/** Create diagnostics backed by the current process and host commands. */
|
|
68
81
|
export const systemDoctorHost = () => ({
|
|
69
82
|
platform: process.platform,
|
|
@@ -97,6 +110,22 @@ export const systemDoctorHost = () => ({
|
|
|
97
110
|
return false;
|
|
98
111
|
}
|
|
99
112
|
},
|
|
113
|
+
async linuxDemoRuntimeReady() {
|
|
114
|
+
try {
|
|
115
|
+
await access("/usr/bin/Xvfb", constants.X_OK);
|
|
116
|
+
await access("/usr/bin/xauth", constants.X_OK);
|
|
117
|
+
await access("/usr/bin/python3", constants.X_OK);
|
|
118
|
+
await execFileAsync("/usr/bin/python3", [
|
|
119
|
+
"-c",
|
|
120
|
+
"import ctypes; ctypes.CDLL('libX11.so.6'); ctypes.CDLL('libXtst.so.6')",
|
|
121
|
+
]);
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
supportedLinuxHopper: linuxHopperBinarySupported,
|
|
100
129
|
async brewHopperPath() {
|
|
101
130
|
return probeHomebrew(async (command) => {
|
|
102
131
|
try {
|
|
@@ -1,54 +1,29 @@
|
|
|
1
|
-
import { lstat, readFile, realpath } from "node:fs/promises";
|
|
2
|
-
import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
|
|
3
|
-
import writeFileAtomic from "write-file-atomic";
|
|
4
1
|
import { parseEvidenceBundle, serializeEvidenceBundle, } from "../domain/evidenceBundle.js";
|
|
5
2
|
import { EvidenceFileError, EvidenceIntegrityError } from "../domain/errors.js";
|
|
6
3
|
import { err, ok } from "../domain/result.js";
|
|
7
4
|
import { LEGACY_PROCESS_CAPTURE_MESSAGE, parseProcessCapture, } from "../domain/processCapture.js";
|
|
5
|
+
import { readBoundedJson, writeBoundedText } from "./BoundedJsonFiles.js";
|
|
8
6
|
/** Read and validate a bounded evidence bundle inside an approved root. */
|
|
9
7
|
export const readEvidenceBundle = async (path, policy) => {
|
|
10
8
|
if (policy.roots.length === 0)
|
|
11
9
|
return err(new EvidenceFileError("read", "disabled"));
|
|
10
|
+
const loaded = await readBoundedJson(path, policy);
|
|
11
|
+
if (!loaded.ok)
|
|
12
|
+
return loaded;
|
|
12
13
|
try {
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if (stats.size > policy.maxBytes)
|
|
20
|
-
return err(new EvidenceFileError("read", "too-large"));
|
|
21
|
-
const encoded = await readFile(canonicalPath);
|
|
22
|
-
if (encoded.byteLength > policy.maxBytes)
|
|
23
|
-
return err(new EvidenceFileError("read", "too-large"));
|
|
24
|
-
let decoded;
|
|
25
|
-
try {
|
|
26
|
-
decoded = JSON.parse(encoded.toString("utf8"));
|
|
27
|
-
}
|
|
28
|
-
catch (cause) {
|
|
29
|
-
return err(new EvidenceFileError("read", "invalid-json", { cause }));
|
|
30
|
-
}
|
|
31
|
-
const limits = validateJsonLimits(decoded, policy);
|
|
32
|
-
if (!limits.ok)
|
|
33
|
-
return limits;
|
|
34
|
-
try {
|
|
35
|
-
const bundle = parseEvidenceBundle(decoded);
|
|
36
|
-
for (const record of bundle.records) {
|
|
37
|
-
if (record.predicate_type === "rea.process-capture/v3")
|
|
38
|
-
throw new TypeError(LEGACY_PROCESS_CAPTURE_MESSAGE);
|
|
39
|
-
if (record.predicate_type === "rea.process-capture/v4")
|
|
40
|
-
parseProcessCapture(record.normalized_result);
|
|
41
|
-
}
|
|
42
|
-
return ok(bundle);
|
|
43
|
-
}
|
|
44
|
-
catch (cause) {
|
|
45
|
-
return err(new EvidenceIntegrityError("Evidence bundle validation failed", {
|
|
46
|
-
cause,
|
|
47
|
-
}));
|
|
14
|
+
const bundle = parseEvidenceBundle(loaded.value);
|
|
15
|
+
for (const record of bundle.records) {
|
|
16
|
+
if (record.predicate_type === "rea.process-capture/v3")
|
|
17
|
+
throw new TypeError(LEGACY_PROCESS_CAPTURE_MESSAGE);
|
|
18
|
+
if (record.predicate_type === "rea.process-capture/v4")
|
|
19
|
+
parseProcessCapture(record.normalized_result);
|
|
48
20
|
}
|
|
21
|
+
return ok(bundle);
|
|
49
22
|
}
|
|
50
23
|
catch (cause) {
|
|
51
|
-
return err(new
|
|
24
|
+
return err(new EvidenceIntegrityError("Evidence bundle validation failed", {
|
|
25
|
+
cause,
|
|
26
|
+
}));
|
|
52
27
|
}
|
|
53
28
|
};
|
|
54
29
|
/** Atomically write deterministic evidence JSON inside an approved root. */
|
|
@@ -64,73 +39,5 @@ export const writeEvidenceBundle = async (bundle, path, overwrite, policy) => {
|
|
|
64
39
|
cause,
|
|
65
40
|
}));
|
|
66
41
|
}
|
|
67
|
-
|
|
68
|
-
if (bytes > policy.maxBytes)
|
|
69
|
-
return err(new EvidenceFileError("write", "too-large"));
|
|
70
|
-
try {
|
|
71
|
-
const requestedPath = resolve(path);
|
|
72
|
-
const canonicalParent = await realpath(dirname(requestedPath));
|
|
73
|
-
const destination = resolve(canonicalParent, basename(requestedPath));
|
|
74
|
-
if (!(await isApproved(destination, policy.roots)))
|
|
75
|
-
return err(new EvidenceFileError("write", "outside-root"));
|
|
76
|
-
const existing = await lstat(destination).catch((cause) => {
|
|
77
|
-
if (isFileNotFound(cause))
|
|
78
|
-
return undefined;
|
|
79
|
-
throw cause;
|
|
80
|
-
});
|
|
81
|
-
if (existing !== undefined) {
|
|
82
|
-
if (!overwrite)
|
|
83
|
-
return err(new EvidenceFileError("write", "exists"));
|
|
84
|
-
if (!existing.isFile() || existing.isSymbolicLink())
|
|
85
|
-
return err(new EvidenceFileError("write", "not-file"));
|
|
86
|
-
}
|
|
87
|
-
await writeFileAtomic(destination, encoded, {
|
|
88
|
-
encoding: "utf8",
|
|
89
|
-
mode: 0o600,
|
|
90
|
-
fsync: true,
|
|
91
|
-
});
|
|
92
|
-
return ok({ path: requestedPath, bytes });
|
|
93
|
-
}
|
|
94
|
-
catch (cause) {
|
|
95
|
-
return err(new EvidenceFileError("write", "io", { cause }));
|
|
96
|
-
}
|
|
97
|
-
};
|
|
98
|
-
const isApproved = async (candidate, roots) => {
|
|
99
|
-
for (const configuredRoot of roots) {
|
|
100
|
-
const root = await realpath(resolve(configuredRoot));
|
|
101
|
-
const relation = relative(root, candidate);
|
|
102
|
-
if (relation === "" ||
|
|
103
|
-
(!relation.startsWith("..") && !isAbsolute(relation)))
|
|
104
|
-
return true;
|
|
105
|
-
}
|
|
106
|
-
return false;
|
|
107
|
-
};
|
|
108
|
-
const validateJsonLimits = (root, policy) => {
|
|
109
|
-
const pending = [
|
|
110
|
-
{ value: root, depth: 0 },
|
|
111
|
-
];
|
|
112
|
-
let nodes = 0;
|
|
113
|
-
while (pending.length > 0) {
|
|
114
|
-
const current = pending.pop();
|
|
115
|
-
if (current === undefined)
|
|
116
|
-
break;
|
|
117
|
-
nodes += 1;
|
|
118
|
-
if (nodes > policy.maxNodes || current.depth > policy.maxDepth)
|
|
119
|
-
return err(new EvidenceFileError("read", "too-large"));
|
|
120
|
-
if (typeof current.value === "string" &&
|
|
121
|
-
current.value.length > policy.maxStringLength)
|
|
122
|
-
return err(new EvidenceFileError("read", "too-large"));
|
|
123
|
-
if (typeof current.value !== "object" || current.value === null)
|
|
124
|
-
continue;
|
|
125
|
-
for (const [key, value] of Object.entries(current.value)) {
|
|
126
|
-
if (key.length > policy.maxStringLength)
|
|
127
|
-
return err(new EvidenceFileError("read", "too-large"));
|
|
128
|
-
pending.push({ value, depth: current.depth + 1 });
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
return ok(null);
|
|
42
|
+
return writeBoundedText(encoded, path, overwrite, policy);
|
|
132
43
|
};
|
|
133
|
-
const isFileNotFound = (cause) => typeof cause === "object" &&
|
|
134
|
-
cause !== null &&
|
|
135
|
-
"code" in cause &&
|
|
136
|
-
cause.code === "ENOENT";
|