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.
Files changed (79) hide show
  1. package/README.md +74 -27
  2. package/bridge/hopper_bridge.py +161 -12
  3. package/dist/application/AnalysisSnapshotCache.js +131 -0
  4. package/dist/application/AnalysisSnapshotFiles.js +31 -0
  5. package/dist/application/ArtifactInventory.js +4 -0
  6. package/dist/application/AuthorizedArtifactInventory.js +16 -0
  7. package/dist/application/BinarySession.js +66 -36
  8. package/dist/application/BinarySessionPort.js +1 -0
  9. package/dist/application/BoundedJsonFiles.js +109 -0
  10. package/dist/application/CrossVersionInventory.js +98 -0
  11. package/dist/application/CrossVersionInvestigation.js +295 -0
  12. package/dist/application/DirectAnalysis.js +115 -22
  13. package/dist/application/Doctor.js +30 -1
  14. package/dist/application/EvidenceBundleFiles.js +15 -108
  15. package/dist/application/EvidenceLedger.js +3 -2
  16. package/dist/application/InvestigationProviders.js +48 -0
  17. package/dist/application/InvestigationWorkspaceStore.js +214 -0
  18. package/dist/application/LinuxHopper.js +72 -48
  19. package/dist/application/ProcessCaptureAuthority.js +2 -2
  20. package/dist/application/ProcessCaptureError.js +12 -0
  21. package/dist/application/ProcessCaptureLifecycle.js +19 -1
  22. package/dist/application/ProcessCli.js +98 -31
  23. package/dist/application/ProcessHarness.js +2 -2
  24. package/dist/application/ReferenceSourceImportEntries.js +18 -3
  25. package/dist/application/ReferenceSourceImportTypes.js +32 -0
  26. package/dist/application/Setup.js +33 -60
  27. package/dist/application/SetupClients.js +19 -0
  28. package/dist/application/SetupInstallFailure.js +53 -0
  29. package/dist/application/SetupPlan.js +31 -0
  30. package/dist/application/Uninstall.js +33 -23
  31. package/dist/application/UnknownEvidence.js +33 -0
  32. package/dist/application/Upgrade.js +1 -1
  33. package/dist/application/runtime.js +1 -1
  34. package/dist/artifacts/ArtifactProvider.js +2 -5
  35. package/dist/cli.js +31 -11
  36. package/dist/cliEvidenceCommands.js +11 -12
  37. package/dist/cliInvestigationCommands.js +86 -0
  38. package/dist/cliOutput.js +41 -0
  39. package/dist/cliProcessCommands.js +14 -4
  40. package/dist/config.js +39 -24
  41. package/dist/contracts/promptContracts.js +256 -0
  42. package/dist/contracts/sessionLifecycleInputs.js +11 -0
  43. package/dist/contracts/toolContractTypes.js +1 -0
  44. package/dist/contracts/toolContracts.js +10 -6
  45. package/dist/contracts/toolOutputSchemas.js +33 -4
  46. package/dist/domain/analysisSnapshot.js +149 -0
  47. package/dist/domain/changedBehavior.js +30 -7
  48. package/dist/domain/errors.js +180 -41
  49. package/dist/domain/evidence.js +4 -2
  50. package/dist/domain/evidenceBundle.js +28 -0
  51. package/dist/domain/hopperStartupFailure.js +55 -0
  52. package/dist/domain/investigationWorkspace.js +214 -0
  53. package/dist/domain/jsonValue.js +2 -0
  54. package/dist/domain/nativeInspection.js +3 -2
  55. package/dist/domain/processCapture.js +5 -4
  56. package/dist/domain/processComparison.js +5 -4
  57. package/dist/hopper/BridgeLauncher.js +47 -20
  58. package/dist/hopper/HopperClient.js +17 -2
  59. package/dist/hopper/HopperProvider.js +1 -0
  60. package/dist/main.js +47 -24
  61. package/dist/server/createServer.js +2 -0
  62. package/dist/server/promptCompletion.js +144 -0
  63. package/dist/server/registerEnhancedTools.js +2 -6
  64. package/dist/server/registerEvidenceTools.js +2 -8
  65. package/dist/server/registerFunctionComparisonTool.js +1 -1
  66. package/dist/server/registerInvestigationTools.js +31 -11
  67. package/dist/server/registerOfficialTools.js +6 -13
  68. package/dist/server/registerProcessComparisonTool.js +1 -7
  69. package/dist/server/registerPrompts.js +67 -0
  70. package/dist/server/registerSessionStatusTool.js +11 -0
  71. package/dist/server/registerSessionTools.js +52 -18
  72. package/dist/server/sessionEvidence.js +2 -8
  73. package/dist/server/sessionToolPolicies.js +1 -42
  74. package/dist/server/toolResult.js +7 -3
  75. package/install.sh +11 -11
  76. package/package.json +5 -3
  77. package/scripts/hopper-demo-x11.py +349 -0
  78. package/scripts/rea.mjs +6 -3
  79. 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_, logger = silentLogger) => runAnalysis(path, tool, arguments_, logger);
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 { error: config.error._tag, message: config.error.message };
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_, logger) => {
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 { error: config.error._tag, message: config.error.message };
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 opened = await session.open(path);
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 { error: opened.error._tag, message: opened.error.message };
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
- return result.ok
46
- ? createEvidence(opened.value, tool === "analyze_function"
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
- : { error: result.error._tag, message: result.error.message };
84
+ });
85
+ output = evidence;
86
+ }
56
87
  }
57
- const result = await session.execute(tool, arguments_);
58
- return result.ok
59
- ? createEvidence(result.value.subject ?? opened.value, result.value.provider, {
60
- operation: tool,
61
- parameters: arguments_,
62
- result: result.value.result,
63
- rawResult: result.value.rawResult,
64
- limitations: result.value.limitations,
65
- locations: result.value.locations,
66
- })
67
- : { error: result.error._tag, message: result.error.message };
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 canonicalPath = await realpath(resolve(path));
14
- if (!(await isApproved(canonicalPath, policy.roots)))
15
- return err(new EvidenceFileError("read", "outside-root"));
16
- const stats = await lstat(canonicalPath);
17
- if (!stats.isFile())
18
- return err(new EvidenceFileError("read", "not-file"));
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 EvidenceFileError("read", "io", { cause }));
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
- const bytes = Buffer.byteLength(encoded, "utf8");
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";