rea-agents 1.2.0 → 1.4.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 (107) hide show
  1. package/README.md +95 -27
  2. package/bridge/hopper_bridge.py +172 -14
  3. package/dist/application/AnalysisSnapshotCache.js +131 -0
  4. package/dist/application/AnalysisSnapshotFiles.js +31 -0
  5. package/dist/application/ArtifactGraphConstruction.js +1 -1
  6. package/dist/application/ArtifactInventory.js +100 -24
  7. package/dist/application/AuthorizedArtifactInventory.js +16 -0
  8. package/dist/application/BinarySession.js +149 -36
  9. package/dist/application/BinarySessionPort.js +1 -0
  10. package/dist/application/BoundedJsonFiles.js +109 -0
  11. package/dist/application/CapabilityInventory.js +127 -0
  12. package/dist/application/ClientRegistrationStatus.js +69 -0
  13. package/dist/application/CrossVersionInventory.js +109 -0
  14. package/dist/application/CrossVersionInvestigation.js +358 -0
  15. package/dist/application/DirectAnalysis.js +205 -24
  16. package/dist/application/Doctor.js +104 -3
  17. package/dist/application/EvidenceBundleFiles.js +15 -108
  18. package/dist/application/EvidenceLedger.js +3 -2
  19. package/dist/application/InvestigationProviders.js +48 -0
  20. package/dist/application/InvestigationWorkspaceStore.js +214 -0
  21. package/dist/application/LinuxHopper.js +72 -48
  22. package/dist/application/PermissionAuthority.js +183 -0
  23. package/dist/application/PermissionConfiguration.js +26 -0
  24. package/dist/application/ProcessCaptureAuthority.js +2 -2
  25. package/dist/application/ProcessCaptureError.js +25 -0
  26. package/dist/application/ProcessCaptureLifecycle.js +22 -2
  27. package/dist/application/ProcessCli.js +111 -31
  28. package/dist/application/ProcessHarness.js +6 -3
  29. package/dist/application/ProgressReporter.js +30 -0
  30. package/dist/application/ProjectPermissionStore.js +110 -0
  31. package/dist/application/ReferenceSourceImportEntries.js +18 -3
  32. package/dist/application/ReferenceSourceImportTypes.js +32 -0
  33. package/dist/application/Setup.js +35 -101
  34. package/dist/application/SetupClients.js +19 -0
  35. package/dist/application/SetupInstallFailure.js +53 -0
  36. package/dist/application/SetupPlan.js +31 -0
  37. package/dist/application/SupportedClients.js +41 -0
  38. package/dist/application/Uninstall.js +34 -24
  39. package/dist/application/UnknownEvidence.js +33 -0
  40. package/dist/application/Upgrade.js +3 -1
  41. package/dist/application/runtime.js +2 -2
  42. package/dist/artifacts/ArtifactProvider.js +15 -8
  43. package/dist/catalogIdentity.js +121 -0
  44. package/dist/cli.js +76 -13
  45. package/dist/cliEvidenceCommands.js +52 -12
  46. package/dist/cliInvestigationCommands.js +137 -0
  47. package/dist/cliLogging.js +20 -3
  48. package/dist/cliOutput.js +41 -0
  49. package/dist/cliPolicyCommands.js +115 -0
  50. package/dist/config.js +102 -27
  51. package/dist/contracts/artifactToolContracts.js +14 -1
  52. package/dist/contracts/errorSchemas.js +98 -0
  53. package/dist/contracts/promptContracts.js +256 -0
  54. package/dist/contracts/sessionLifecycleInputs.js +11 -0
  55. package/dist/contracts/toolContractTypes.js +1 -0
  56. package/dist/contracts/toolContracts.js +20 -7
  57. package/dist/contracts/toolOutputSchemas.js +70 -9
  58. package/dist/domain/analysisSnapshot.js +149 -0
  59. package/dist/domain/artifactComparison.js +38 -11
  60. package/dist/domain/artifactGraph.js +25 -1
  61. package/dist/domain/artifactInventoryEvidence.js +18 -0
  62. package/dist/domain/changedBehavior.js +35 -10
  63. package/dist/domain/errors.js +384 -32
  64. package/dist/domain/evidence.js +4 -2
  65. package/dist/domain/evidenceBundle.js +28 -0
  66. package/dist/domain/hopperStartupFailure.js +55 -0
  67. package/dist/domain/investigationWorkspace.js +341 -0
  68. package/dist/domain/jsonValue.js +2 -0
  69. package/dist/domain/nativeInspection.js +3 -2
  70. package/dist/domain/permissionPolicy.js +157 -0
  71. package/dist/domain/processCapture.js +5 -4
  72. package/dist/domain/processComparison.js +6 -4
  73. package/dist/domain/reconstructionVerification.js +1 -1
  74. package/dist/domain/reconstructionVerificationSchemas.js +1 -0
  75. package/dist/domain/staticRuntimeCorrelation.js +5 -1
  76. package/dist/generatedPackageMetadata.js +9 -0
  77. package/dist/hopper/BridgeLauncher.js +51 -20
  78. package/dist/hopper/HopperClient.js +45 -3
  79. package/dist/hopper/HopperProvider.js +1 -0
  80. package/dist/identity.js +10 -3
  81. package/dist/logger.js +2 -2
  82. package/dist/main.js +139 -28
  83. package/dist/server/createServer.js +54 -5
  84. package/dist/server/mcpProgress.js +23 -0
  85. package/dist/server/promptCompletion.js +144 -0
  86. package/dist/server/registerArtifactComparisonTool.js +6 -2
  87. package/dist/server/registerBundleComparisonTool.js +6 -2
  88. package/dist/server/registerEnhancedTools.js +20 -7
  89. package/dist/server/registerEvidenceResources.js +302 -0
  90. package/dist/server/registerEvidenceTools.js +59 -8
  91. package/dist/server/registerFunctionComparisonTool.js +7 -3
  92. package/dist/server/registerInvestigationTools.js +111 -18
  93. package/dist/server/registerOfficialTools.js +25 -14
  94. package/dist/server/registerProcessComparisonTool.js +8 -10
  95. package/dist/server/registerPrompts.js +67 -0
  96. package/dist/server/registerSessionStatusTool.js +52 -0
  97. package/dist/server/registerSessionTools.js +178 -22
  98. package/dist/server/runDerivedOperation.js +35 -0
  99. package/dist/server/sessionEvidence.js +2 -8
  100. package/dist/server/sessionToolPolicies.js +1 -42
  101. package/dist/server/toolResult.js +34 -6
  102. package/dist/serverIdentity.js +70 -0
  103. package/install.sh +11 -11
  104. package/package.json +10 -6
  105. package/scripts/hopper-demo-x11.py +349 -0
  106. package/scripts/rea.mjs +6 -3
  107. package/skills/rea-analysis/SKILL.md +9 -2
@@ -0,0 +1,131 @@
1
+ import { EvidenceIntegrityError, NoBinaryOpenError, } from "../domain/errors.js";
2
+ import { err, ok } from "../domain/result.js";
3
+ import { analysisQueryId, createAnalysisSnapshotEntry, snapshotMatchesTarget, snapshotTarget, } from "../domain/analysisSnapshot.js";
4
+ import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/toolContracts.js";
5
+ const STATEFUL_OPERATIONS = new Set([
6
+ "health",
7
+ "current_address",
8
+ "current_procedure",
9
+ "current_document",
10
+ "goto_address",
11
+ "list_documents",
12
+ "set_current_document",
13
+ ]);
14
+ const CURSOR_DEFAULT_OPERATIONS = new Set([
15
+ "address_name",
16
+ "comment",
17
+ "inline_comment",
18
+ "next_address",
19
+ "prev_address",
20
+ "xrefs",
21
+ ]);
22
+ const DOCUMENT_SCOPED_OPERATIONS = new Set(OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name).filter((name) => name !== "current_document" &&
23
+ name !== "list_documents" &&
24
+ name !== "set_current_document"));
25
+ /** Whether an operation is immutable and independent of provider UI state. */
26
+ export const isSnapshotCacheable = (operation, descriptor, parameters) => !STATEFUL_OPERATIONS.has(operation) &&
27
+ (!DOCUMENT_SCOPED_OPERATIONS.has(operation) ||
28
+ typeof parameters.document === "string") &&
29
+ (!CURSOR_DEFAULT_OPERATIONS.has(operation) ||
30
+ typeof parameters.address === "string") &&
31
+ descriptor?.effects.mutatesArtifact === false &&
32
+ descriptor.effects.mayWriteFilesystem === false &&
33
+ descriptor.effects.changesPermissions === false;
34
+ /** Bounded in-memory cache for one immutable binary identity. */
35
+ export class AnalysisSnapshotCache {
36
+ #entries = new Map();
37
+ #target;
38
+ /** Whether staged entries belong to the supplied binary. */
39
+ matches(target) {
40
+ return (this.#target === undefined || snapshotMatchesTarget(this.#target, target));
41
+ }
42
+ /** Replace cache state when the active binary identity changes. */
43
+ select(target) {
44
+ if (!this.matches(target))
45
+ this.#entries.clear();
46
+ this.#target = snapshotTarget(target);
47
+ }
48
+ /** Merge already-validated entries and return the new-entry count. */
49
+ stage(snapshot) {
50
+ if (this.#target !== undefined &&
51
+ (this.#target.sha256 !== snapshot.target.sha256 ||
52
+ this.#target.format !== snapshot.target.format ||
53
+ this.#target.architecture !== snapshot.target.architecture))
54
+ this.#entries.clear();
55
+ this.#target = snapshot.target;
56
+ let imported = 0;
57
+ for (const entry of snapshot.entries) {
58
+ if (!this.#entries.has(entry.query_id))
59
+ imported += 1;
60
+ this.#entries.set(entry.query_id, entry);
61
+ }
62
+ return imported;
63
+ }
64
+ /** Build a complete snapshot for an active target. */
65
+ export(target, evidenceBundle) {
66
+ if (target === undefined)
67
+ return err(new NoBinaryOpenError());
68
+ return ok({
69
+ snapshot_version: 1,
70
+ target: snapshotTarget(target),
71
+ entries: this.entries(),
72
+ evidence_bundle: evidenceBundle,
73
+ });
74
+ }
75
+ /** Validate target identity, merge evidence atomically, then stage entries. */
76
+ import(snapshot, active, mergeEvidence) {
77
+ if (active !== undefined && !snapshotMatchesTarget(snapshot.target, active))
78
+ return err(new EvidenceIntegrityError("Analysis snapshot target does not match the active binary"));
79
+ const importedEvidence = mergeEvidence(snapshot.evidence_bundle);
80
+ return importedEvidence.ok ? ok(this.stage(snapshot)) : importedEvidence;
81
+ }
82
+ /** Return canonical entries for persistence. */
83
+ entries() {
84
+ return [...this.#entries.values()].sort((left, right) => left.query_id.localeCompare(right.query_id));
85
+ }
86
+ /** Replay an exact provider-specific query, marking its cached provenance. */
87
+ lookup(target, operation, parameters, provider) {
88
+ const queryId = analysisQueryId(snapshotTarget(target), operation, parameters, provider);
89
+ const cached = this.#entries.get(queryId);
90
+ if (cached === undefined)
91
+ return undefined;
92
+ const subject = cached.execution.subject;
93
+ return {
94
+ result: cached.execution.result,
95
+ rawResult: cached.execution.raw_result,
96
+ provider: cached.execution.provider,
97
+ limitations: [
98
+ ...cached.execution.limitations,
99
+ "Loaded from a local REA analysis snapshot; this call did not re-run the provider.",
100
+ ],
101
+ locations: cached.execution.locations,
102
+ subject: subject === null
103
+ ? null
104
+ : subject.architecture === null
105
+ ? {
106
+ path: subject.path,
107
+ sha256: subject.sha256,
108
+ format: subject.format,
109
+ }
110
+ : {
111
+ path: subject.path,
112
+ sha256: subject.sha256,
113
+ format: subject.format,
114
+ architecture: subject.architecture,
115
+ },
116
+ };
117
+ }
118
+ /** Record one successful immutable call unless the cache is full. */
119
+ record(target, operation, parameters, execution) {
120
+ if (this.#entries.size >= 10_000)
121
+ return;
122
+ this.select(target);
123
+ const entry = createAnalysisSnapshotEntry(snapshotTarget(target), operation, parameters, execution);
124
+ this.#entries.set(entry.query_id, entry);
125
+ }
126
+ /** Forget all target-bound entries. */
127
+ clear() {
128
+ this.#entries.clear();
129
+ this.#target = undefined;
130
+ }
131
+ }
@@ -0,0 +1,31 @@
1
+ import { parseAnalysisSnapshot, serializeAnalysisSnapshot, } from "../domain/analysisSnapshot.js";
2
+ import { EvidenceFileError, EvidenceIntegrityError } from "../domain/errors.js";
3
+ import { err, ok } from "../domain/result.js";
4
+ import { readBoundedJson, writeBoundedText } from "./BoundedJsonFiles.js";
5
+ /** Read and validate a bounded analysis snapshot inside an approved root. */
6
+ export const readAnalysisSnapshot = async (path, policy) => {
7
+ const loaded = await readBoundedJson(path, policy);
8
+ if (!loaded.ok)
9
+ return loaded;
10
+ try {
11
+ return ok(parseAnalysisSnapshot(loaded.value));
12
+ }
13
+ catch (cause) {
14
+ return err(new EvidenceIntegrityError("Analysis snapshot validation failed", {
15
+ cause,
16
+ }));
17
+ }
18
+ };
19
+ /** Atomically write a deterministic analysis snapshot inside an approved root. */
20
+ export const writeAnalysisSnapshot = async (snapshot, path, overwrite, policy) => {
21
+ let encoded;
22
+ try {
23
+ encoded = serializeAnalysisSnapshot(snapshot);
24
+ }
25
+ catch (cause) {
26
+ return err(new EvidenceIntegrityError("Analysis snapshot validation failed", {
27
+ cause,
28
+ }));
29
+ }
30
+ return writeBoundedText(encoded, path, overwrite, policy);
31
+ };
@@ -83,7 +83,7 @@ export const createArtifactNode = (input) => ({
83
83
  architecture: null,
84
84
  executable: input.executable,
85
85
  content_state: input.contentState,
86
- limitations: [],
86
+ limitations: [...(input.limitations ?? [])],
87
87
  });
88
88
  export const rootOccurrenceFor = (node, declaredSize) => ({
89
89
  occurrence_id: `occ_${digestCanonical({ root: node.artifact_id })}`,
@@ -15,15 +15,28 @@ const NATIVE_MOUNT_DISABLED = {
15
15
  nativeMountApproved: false,
16
16
  nativeMountEnabled: false,
17
17
  };
18
+ const STRICT_INTEGRITY_POLICY = {
19
+ mode: "fail",
20
+ approved: false,
21
+ enabled: false,
22
+ maxMismatches: 1,
23
+ };
18
24
  /** Inventory one local artifact without extracting or mounting it. */
19
25
  export const inventoryArtifact = async (inputPath, limits, page, options = {}) => {
20
- const snapshot = await scanArtifactInventory(inputPath, limits, options.signal, options.nativeMount ?? NATIVE_MOUNT_DISABLED);
26
+ const snapshot = await scanArtifactInventory(inputPath, limits, options.signal, options.nativeMount ?? NATIVE_MOUNT_DISABLED, options.integrity ?? STRICT_INTEGRITY_POLICY);
21
27
  return paginateArtifactInventory(snapshot, page);
22
28
  };
23
29
  /** Scan an artifact once and retain the complete immutable graph for projection. */
24
- export const scanArtifactInventory = async (inputPath, limits, signal, nativeMount = NATIVE_MOUNT_DISABLED) => {
30
+ export const scanArtifactInventory = async (inputPath, limits, signal, nativeMount = NATIVE_MOUNT_DISABLED, integrity = STRICT_INTEGRITY_POLICY) => {
25
31
  abortIfNeeded(signal);
26
32
  const path = await realpath(inputPath);
33
+ return scanCanonicalArtifactInventory(path, limits, signal, nativeMount, integrity);
34
+ };
35
+ /** Scan one already-canonical artifact path without resolving it again. */
36
+ export const scanCanonicalArtifactInventory = async (path, limits, signal, nativeMount = NATIVE_MOUNT_DISABLED, integrity = STRICT_INTEGRITY_POLICY) => {
37
+ if (integrity.mode === "record-and-continue" &&
38
+ (!integrity.approved || !integrity.enabled))
39
+ throw new ArtifactReaderFailure("unavailable", "Integrity continuation requires explicit approval and operator policy");
27
40
  const metadata = await lstat(path);
28
41
  const rootFormat = await classifyRoot(path, metadata.isDirectory());
29
42
  const rootDigest = metadata.isDirectory()
@@ -31,7 +44,7 @@ export const scanArtifactInventory = async (inputPath, limits, signal, nativeMou
31
44
  : await hashReadable(createReadStream(path), limits.maxTotalBytes, signal);
32
45
  const reader = await createReader(path, rootFormat, nativeMount, signal);
33
46
  try {
34
- const { nodes, occurrences } = await scanReader(reader, limits, signal);
47
+ const { nodes, occurrences, pendingContradictions } = await scanReader(reader, limits, signal, integrity);
35
48
  materializeDirectoryNodes(occurrences, nodes);
36
49
  const rootNode = createRootNode({
37
50
  path,
@@ -47,6 +60,36 @@ export const scanArtifactInventory = async (inputPath, limits, signal, nativeMou
47
60
  if (occurrence.parent_occurrence_id === null)
48
61
  occurrence.parent_occurrence_id = rootOccurrence.occurrence_id;
49
62
  occurrences.unshift(rootOccurrence);
63
+ const occurrenceById = new Map(occurrences.map((occurrence) => [occurrence.occurrence_id, occurrence]));
64
+ const integrityContradictions = pendingContradictions.map((contradiction) => {
65
+ const occurrence = occurrences.find(({ logical_path: path }) => path === contradiction.logicalPath);
66
+ if (occurrence === undefined)
67
+ throw new ArtifactReaderFailure("integrity", "Integrity contradiction lost its graph occurrence");
68
+ const parent = occurrence.parent_occurrence_id === null
69
+ ? undefined
70
+ : occurrenceById.get(occurrence.parent_occurrence_id);
71
+ const parentArtifactId = parent?.artifact_id ?? rootNode.artifact_id;
72
+ return {
73
+ contradiction_id: `ic_${digestCanonical({
74
+ root_artifact_id: rootNode.artifact_id,
75
+ logical_path: contradiction.logicalPath,
76
+ declared_sha256: contradiction.declaredSha256,
77
+ observed_sha256: contradiction.observedSha256,
78
+ })}`,
79
+ occurrence_id: occurrence.occurrence_id,
80
+ parent_artifact_id: parentArtifactId,
81
+ logical_path: contradiction.logicalPath,
82
+ declared_sha256: contradiction.declaredSha256,
83
+ observed_sha256: contradiction.observedSha256,
84
+ entry_kind: contradiction.entryKind,
85
+ unpacked: contradiction.unpacked,
86
+ trust: "observed-untrusted",
87
+ provenance: "container-integrity-metadata-versus-observed-bytes",
88
+ limitations: [
89
+ "Observed bytes contradict declared integrity metadata and cannot support equivalence.",
90
+ ],
91
+ };
92
+ });
50
93
  const edges = createArtifactEdges(rootNode.artifact_id, occurrences, reader?.provenance()[0]);
51
94
  const orderedNodes = [...nodes.values()].sort((left, right) => left.artifact_id.localeCompare(right.artifact_id));
52
95
  const orderedOccurrences = occurrences.sort((left, right) => left.logical_path.localeCompare(right.logical_path, "en"));
@@ -61,6 +104,7 @@ export const scanArtifactInventory = async (inputPath, limits, signal, nativeMou
61
104
  nodes: orderedNodes,
62
105
  occurrences: orderedOccurrences,
63
106
  edges: orderedEdges,
107
+ integrity_contradictions: integrityContradictions,
64
108
  });
65
109
  const manifestId = `agm_${digestCanonical({
66
110
  schema_version: 1,
@@ -84,7 +128,15 @@ export const scanArtifactInventory = async (inputPath, limits, signal, nativeMou
84
128
  edges: orderedEdges,
85
129
  limits: toOutputLimits(limits),
86
130
  provenance: reader?.provenance() ?? [],
87
- limitations: inventoryLimitations(rootFormat, reader),
131
+ integrity_contradictions: integrityContradictions,
132
+ limitations: [
133
+ ...inventoryLimitations(rootFormat, reader),
134
+ ...(integrityContradictions.length === 0
135
+ ? []
136
+ : [
137
+ `${String(integrityContradictions.length)} integrity contradiction(s) were recorded; mismatched content is observed-untrusted.`,
138
+ ]),
139
+ ],
88
140
  };
89
141
  }
90
142
  finally {
@@ -145,11 +197,11 @@ const visitNestedAsar = async (adapterKey, logicalPath, visit) => {
145
197
  const isExpandableAsar = (entry, logicalPath) => entry.kind === "file" &&
146
198
  logicalPath.toLowerCase().endsWith(".asar") &&
147
199
  entry.adapterKey.startsWith("/");
148
- const emptyScan = () => ({ nodes: new Map(), occurrences: [] });
149
- const scanReader = async (reader, limits, signal) => {
150
- const { nodes, occurrences } = emptyScan();
200
+ const emptyScan = () => ({ nodes: new Map(), occurrences: [], pendingContradictions: [] });
201
+ const scanReader = async (reader, limits, signal, integrity = STRICT_INTEGRITY_POLICY) => {
202
+ const { nodes, occurrences, pendingContradictions } = emptyScan();
151
203
  if (reader === undefined)
152
- return { nodes, occurrences };
204
+ return { nodes, occurrences, pendingContradictions };
153
205
  const occurrenceByPath = new Map();
154
206
  const registry = new ArtifactPathRegistry();
155
207
  let totalBytes = 0;
@@ -163,24 +215,44 @@ const scanReader = async (reader, limits, signal) => {
163
215
  throw new ArtifactReaderFailure("limit", "Declared artifact bytes exceed remaining cumulative limit");
164
216
  const digest = await hashReadable(await currentReader.open(entry, signal), Math.min(limits.maxEntryBytes, remainingBytes), signal);
165
217
  totalBytes += digest.bytes;
166
- if (entry.declaredSha256 !== null && entry.declaredSha256 !== digest.sha256)
218
+ const mismatched = entry.declaredSha256 !== null && entry.declaredSha256 !== digest.sha256;
219
+ if (mismatched && integrity.mode === "fail")
167
220
  throw new ArtifactReaderFailure("integrity", `Artifact integrity metadata disagrees with content: ${logicalPath}`, undefined, {
168
221
  logicalPath,
169
222
  declaredSha256: entry.declaredSha256,
170
223
  calculatedSha256: digest.sha256,
171
224
  unpacked: entry.unpacked,
172
225
  });
226
+ if (mismatched && entry.declaredSha256 !== null) {
227
+ if (pendingContradictions.length >= integrity.maxMismatches)
228
+ throw new ArtifactReaderFailure("limit", "Artifact integrity mismatch limit exceeded");
229
+ pendingContradictions.push({
230
+ logicalPath,
231
+ declaredSha256: entry.declaredSha256,
232
+ observedSha256: digest.sha256,
233
+ entryKind: entry.kind,
234
+ unpacked: entry.unpacked,
235
+ });
236
+ }
173
237
  const classified = entry.kind === "slice"
174
238
  ? { kind: "universal-slice", format: "mach-o" }
175
239
  : classifyArtifactContent(logicalPath, digest.prefix);
176
- return createArtifactNode({
177
- sha256: digest.sha256,
178
- size: digest.bytes,
179
- kind: classified.kind,
180
- format: classified.format,
181
- executable: entry.executable,
182
- contentState: "embedded",
183
- });
240
+ return {
241
+ node: createArtifactNode({
242
+ sha256: digest.sha256,
243
+ size: digest.bytes,
244
+ kind: classified.kind,
245
+ format: classified.format,
246
+ executable: entry.executable,
247
+ contentState: "embedded",
248
+ limitations: mismatched
249
+ ? [
250
+ "Observed content contradicts declared integrity metadata and is untrusted.",
251
+ ]
252
+ : [],
253
+ }),
254
+ mismatched,
255
+ };
184
256
  };
185
257
  const visit = async (currentReader, prefix) => {
186
258
  for await (const entry of currentReader.entries(signal)) {
@@ -192,20 +264,24 @@ const scanReader = async (reader, limits, signal) => {
192
264
  preflightEntry(entry, limits);
193
265
  const parent = nearestParent(logicalPath, occurrenceByPath);
194
266
  const occurrence = createOccurrence(entry, logicalPath, parent?.occurrence_id ?? null);
195
- const node = await digestEntry(currentReader, entry, logicalPath);
196
- if (node !== undefined) {
197
- nodes.set(node.artifact_id, node);
198
- occurrence.artifact_id = node.artifact_id;
199
- occurrence.hash_status = "verified";
267
+ const digested = await digestEntry(currentReader, entry, logicalPath);
268
+ if (digested !== undefined) {
269
+ nodes.set(digested.node.artifact_id, digested.node);
270
+ occurrence.artifact_id = digested.node.artifact_id;
271
+ occurrence.hash_status = digested.mismatched
272
+ ? "mismatched"
273
+ : "verified";
274
+ if (digested.mismatched)
275
+ occurrence.limitations.push("Declared integrity metadata contradicts observed bytes.");
200
276
  }
201
277
  occurrences.push(occurrence);
202
278
  occurrenceByPath.set(logicalPath, occurrence);
203
- if (expandableAsar)
279
+ if (expandableAsar && digested?.mismatched !== true)
204
280
  await visitNestedAsar(entry.adapterKey, logicalPath, visit);
205
281
  }
206
282
  };
207
283
  await visit(reader, "");
208
- return { nodes, occurrences };
284
+ return { nodes, occurrences, pendingContradictions };
209
285
  };
210
286
  const classifyRoot = async (path, directory) => {
211
287
  if (directory)
@@ -0,0 +1,16 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
3
+ import { withinRoot } from "../reference/ReferenceSourceReaderPaths.js";
4
+ import { scanCanonicalArtifactInventory, } from "./ArtifactInventory.js";
5
+ /** Resolve, authorize, and scan one artifact without a second path resolution. */
6
+ export const scanAuthorizedArtifactInventory = async (inputPath, roots, limits, signal, integrity) => {
7
+ if (roots.length === 0)
8
+ throw new ArtifactReaderFailure("path", "Artifact input roots are disabled");
9
+ const [path, approved] = await Promise.all([
10
+ realpath(inputPath),
11
+ Promise.all(roots.map(async (root) => realpath(root))),
12
+ ]);
13
+ if (!approved.some((root) => withinRoot(root, path)))
14
+ throw new ArtifactReaderFailure("path", "Artifact path is outside approved input roots");
15
+ return scanCanonicalArtifactInventory(path, limits, signal, undefined, integrity);
16
+ };
@@ -2,15 +2,15 @@ import { parseBinaryTarget } from "../domain/binaryTarget.js";
2
2
  import { AnalysisCapabilityUnavailableError, EvidenceIntegrityError, EvidenceLimitError, AnalysisCancelledError, NoBinaryOpenError, } from "../domain/errors.js";
3
3
  import { err, ok } from "../domain/result.js";
4
4
  import { createEvidence } from "../domain/evidence.js";
5
+ import { evidenceBundleForTarget } from "../domain/evidenceBundle.js";
5
6
  import { EvidenceLedger } from "./EvidenceLedger.js";
6
7
  import { UnknownRegistryError } from "../domain/errors.js";
7
8
  import { enhancedToolNameSchema } from "../contracts/enhancedInputs.js";
8
9
  import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/toolContracts.js";
9
- const REGISTRY_PROVIDER = {
10
- id: "rea-unknown-registry",
11
- name: "REA residual unknown registry",
12
- version: "1",
13
- };
10
+ import { snapshotMatchesTarget } from "../domain/analysisSnapshot.js";
11
+ import { AnalysisSnapshotCache, isSnapshotCacheable, } from "./AnalysisSnapshotCache.js";
12
+ import { UNKNOWN_REGISTRY_PROVIDER, unknownEvidenceLinks, unknownMutationEvidence, } from "./UnknownEvidence.js";
13
+ import { parseInvestigationWorkspace, } from "../domain/investigationWorkspace.js";
14
14
  const OFFICIAL_OPERATIONS = new Set(OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name));
15
15
  /**
16
16
  * Owns the single active target shared by CLI and MCP adapters.
@@ -32,8 +32,15 @@ export class BinarySession {
32
32
  maxRecords: 10_000,
33
33
  maxBytes: 64 * 1024 * 1024,
34
34
  });
35
- constructor(provider) {
35
+ #snapshot = new AnalysisSnapshotCache();
36
+ #investigationWorkspaces = new Map();
37
+ #runtimeAvailability = new Map();
38
+ #availabilityListeners = new Set();
39
+ #snapshotsEnabled;
40
+ #snapshotInvalidated = false;
41
+ constructor(provider, options = {}) {
36
42
  this.provider = provider;
43
+ this.#snapshotsEnabled = options.snapshotsEnabled ?? true;
37
44
  this.#createClient =
38
45
  typeof provider === "function"
39
46
  ? provider
@@ -70,6 +77,11 @@ export class BinarySession {
70
77
  }
71
78
  return this.#providerIdentity;
72
79
  }
80
+ /** Observe runtime provider-health changes that affect discovery metadata. */
81
+ onAvailabilityChanged(listener) {
82
+ this.#availabilityListeners.add(listener);
83
+ return () => this.#availabilityListeners.delete(listener);
84
+ }
73
85
  /** Add one successful public observation to the session ledger. */
74
86
  recordEvidence(evidence) {
75
87
  return this.#evidence.record(evidence);
@@ -90,6 +102,41 @@ export class BinarySession {
90
102
  importEvidenceBundle(bundle) {
91
103
  return this.#evidence.import(bundle);
92
104
  }
105
+ /** Export immutable cached calls and session evidence for the active target. */
106
+ exportAnalysisSnapshot() {
107
+ if (!this.#snapshotsEnabled)
108
+ return err(new EvidenceIntegrityError("Analysis snapshots are unavailable with custom Hopper loader arguments"));
109
+ if (this.#snapshotInvalidated)
110
+ return err(new EvidenceIntegrityError("Analysis snapshots are unavailable after analysis metadata mutations"));
111
+ const target = this.#active?.target;
112
+ return this.#snapshot.export(target, target === undefined
113
+ ? this.#evidence.export()
114
+ : evidenceBundleForTarget(this.#evidence.export(), target.sha256));
115
+ }
116
+ /** Stage validated cached calls for an identical binary and merge its evidence. */
117
+ importAnalysisSnapshot(snapshot) {
118
+ if (!this.#snapshotsEnabled)
119
+ return err(new EvidenceIntegrityError("Analysis snapshots are unavailable with custom Hopper loader arguments"));
120
+ return this.#snapshot.import(snapshot, this.#active?.target, (bundle) => this.#evidence.import(bundle));
121
+ }
122
+ /** Retain one validated immutable workspace revision for session resources. */
123
+ retainInvestigationWorkspace(workspace) {
124
+ const parsed = parseInvestigationWorkspace(workspace);
125
+ const key = `${parsed.workspace_id}:${String(parsed.revision)}`;
126
+ if (this.#investigationWorkspaces.has(key))
127
+ return "duplicate";
128
+ this.#investigationWorkspaces.set(key, parsed);
129
+ return "added";
130
+ }
131
+ /** Read one session-retained immutable workspace revision. */
132
+ investigationWorkspace(workspaceId, revision) {
133
+ return this.#investigationWorkspaces.get(`${workspaceId}:${String(revision)}`);
134
+ }
135
+ /** List retained workspace revisions in canonical identity order. */
136
+ investigationWorkspaces() {
137
+ return [...this.#investigationWorkspaces.values()].sort((left, right) => left.workspace_id.localeCompare(right.workspace_id) ||
138
+ left.revision - right.revision);
139
+ }
93
140
  /** Create an approved residual unknown and immutable mutation evidence. */
94
141
  recordUnknown(input) {
95
142
  const evidence = unknownMutationEvidence(this.#active?.target, input);
@@ -101,7 +148,7 @@ export class BinarySession {
101
148
  }
102
149
  /** Update one unknown using compare-and-swap revision semantics. */
103
150
  updateUnknown(input) {
104
- const evidence = createEvidence(this.#active?.target, REGISTRY_PROVIDER, {
151
+ const evidence = createEvidence(this.#active?.target, UNKNOWN_REGISTRY_PROVIDER, {
105
152
  predicateType: "rea.residual-unknown-mutation/v1",
106
153
  operation: "update_unknown",
107
154
  parameters: {
@@ -137,10 +184,21 @@ export class BinarySession {
137
184
  const parsed = await parseBinaryTarget(path, process.cwd(), process.arch, options.targetKind);
138
185
  if (!parsed.ok)
139
186
  return parsed;
187
+ if (options.snapshot !== undefined &&
188
+ !snapshotMatchesTarget(options.snapshot.target, parsed.value))
189
+ return err(new EvidenceIntegrityError("Analysis snapshot target does not match the requested binary"));
190
+ if (this.#active === undefined && !this.#snapshot.matches(parsed.value))
191
+ return err(new EvidenceIntegrityError("Analysis snapshot target does not match the requested binary"));
140
192
  if (isAborted(options.signal))
141
193
  return err(new AnalysisCancelledError("open_binary"));
142
- if (this.#active?.target.path === parsed.value.path)
194
+ if (this.#active?.target.path === parsed.value.path) {
195
+ if (options.snapshot !== undefined) {
196
+ const imported = this.importAnalysisSnapshot(options.snapshot);
197
+ if (!imported.ok)
198
+ return imported;
199
+ }
143
200
  return ok(parsed.value);
201
+ }
144
202
  await this.#drainCalls();
145
203
  const previous = this.#active;
146
204
  this.#active = undefined;
@@ -158,6 +216,18 @@ export class BinarySession {
158
216
  return err(new AnalysisCancelledError("open_binary"));
159
217
  }
160
218
  this.#active = { target: parsed.value, client };
219
+ this.#clearRuntimeAvailability();
220
+ this.#snapshot.select(parsed.value);
221
+ if (options.snapshot !== undefined) {
222
+ const imported = this.importAnalysisSnapshot(options.snapshot);
223
+ if (!imported.ok) {
224
+ this.#active = undefined;
225
+ await client.close();
226
+ await this.#restore(previous);
227
+ return imported;
228
+ }
229
+ }
230
+ this.#snapshotInvalidated = false;
161
231
  return ok(parsed.value);
162
232
  });
163
233
  }
@@ -169,6 +239,9 @@ export class BinarySession {
169
239
  await this.#drainCalls();
170
240
  await previous?.client.close();
171
241
  this.#evidence.clear();
242
+ this.#snapshot.clear();
243
+ this.#snapshotInvalidated = false;
244
+ this.#clearRuntimeAvailability();
172
245
  return ok(null);
173
246
  });
174
247
  }
@@ -193,6 +266,10 @@ export class BinarySession {
193
266
  ? []
194
267
  : [...this.#capabilities.values()]
195
268
  .sort((left, right) => left.operation.localeCompare(right.operation))
269
+ .map((descriptor) => ({
270
+ ...descriptor,
271
+ ...this.#runtimeAvailability.get(descriptor.operation),
272
+ }))
196
273
  .map((descriptor) => ({
197
274
  operation: descriptor.operation,
198
275
  available: descriptor.available,
@@ -250,15 +327,78 @@ export class BinarySession {
250
327
  const active = this.#active;
251
328
  if (active === undefined)
252
329
  return err(new NoBinaryOpenError());
330
+ const cacheable = isSnapshotCacheable(name, capability, arguments_);
331
+ const cached = cacheable
332
+ ? this.#snapshot.lookup(active.target, name, arguments_, capability.provider)
333
+ : undefined;
334
+ if (cached !== undefined)
335
+ return ok(cached);
253
336
  const call = active.client.execute(name, arguments_, options);
254
337
  this.#calls.add(call);
255
338
  try {
256
- return await call;
339
+ const result = await call;
340
+ this.#observeRuntimeAvailability(name, result);
341
+ if (result.ok && cacheable)
342
+ this.#snapshot.record(active.target, name, arguments_, result.value);
343
+ else if (result.ok && capability?.effects.mutatesArtifact === true) {
344
+ this.#snapshot.clear();
345
+ this.#snapshotInvalidated = true;
346
+ }
347
+ return result;
257
348
  }
258
349
  finally {
259
350
  this.#calls.delete(call);
260
351
  }
261
352
  }
353
+ #observeRuntimeAvailability(operation, result) {
354
+ if (result.ok) {
355
+ if (this.#setRuntimeAvailability(operation, true, null))
356
+ this.#emitAvailabilityChanged();
357
+ return;
358
+ }
359
+ if (result.error._tag === "AnalysisCapabilityUnavailableError") {
360
+ if (this.#setRuntimeAvailability(operation, false, result.error.message))
361
+ this.#emitAvailabilityChanged();
362
+ return;
363
+ }
364
+ if ([
365
+ "ProviderAdapterError",
366
+ "HopperProcessError",
367
+ "HopperStartError",
368
+ "HopperRemoteError",
369
+ ].includes(result.error._tag)) {
370
+ const providerId = this.#capabilities?.get(operation)?.provider.id;
371
+ let changed = false;
372
+ for (const descriptor of this.#capabilities?.values() ?? [])
373
+ if (providerId !== undefined && descriptor.provider.id === providerId)
374
+ changed =
375
+ this.#setRuntimeAvailability(descriptor.operation, false, "Provider became unavailable during this session.") || changed;
376
+ if (changed)
377
+ this.#emitAvailabilityChanged();
378
+ }
379
+ }
380
+ #setRuntimeAvailability(operation, available, reason) {
381
+ const current = this.#runtimeAvailability.get(operation);
382
+ if (current?.available === available && current.reason === reason)
383
+ return false;
384
+ if (available && current === undefined)
385
+ return false;
386
+ if (available)
387
+ this.#runtimeAvailability.delete(operation);
388
+ else
389
+ this.#runtimeAvailability.set(operation, { available, reason });
390
+ return true;
391
+ }
392
+ #clearRuntimeAvailability() {
393
+ if (this.#runtimeAvailability.size === 0)
394
+ return;
395
+ this.#runtimeAvailability.clear();
396
+ this.#emitAvailabilityChanged();
397
+ }
398
+ #emitAvailabilityChanged() {
399
+ for (const listener of this.#availabilityListeners)
400
+ listener();
401
+ }
262
402
  #serialize(operation) {
263
403
  const result = this.#transition.then(operation, operation);
264
404
  this.#transition = result.then(() => undefined, () => undefined);
@@ -303,31 +443,4 @@ export class BinarySession {
303
443
  });
304
444
  }
305
445
  }
306
- const unknownMutationEvidence = (target, input) => createEvidence(target, REGISTRY_PROVIDER, {
307
- predicateType: "rea.residual-unknown-mutation/v1",
308
- operation: "record_unknown",
309
- parameters: {
310
- domain: input.domain,
311
- severity: input.severity,
312
- },
313
- result: {
314
- action: "record",
315
- question: input.question,
316
- required_authority: input.required_authority,
317
- required_confidence: input.required_confidence,
318
- },
319
- confidence: "derived",
320
- authority: "analyst-inference",
321
- evidenceLinks: unknownEvidenceLinks(input),
322
- limitations: [
323
- "Registry mutation evidence records analyst intent, not proof of the answer.",
324
- ],
325
- });
326
- const unknownEvidenceLinks = (input) => [
327
- ...input.supporting_evidence_ids,
328
- ...input.contradicting_evidence_ids,
329
- ...("resolution" in input && input.resolution !== null
330
- ? input.resolution.evidence_ids
331
- : []),
332
- ].filter((id, index, values) => values.indexOf(id) === index);
333
446
  const isAborted = (signal) => signal?.aborted === true;
@@ -0,0 +1 @@
1
+ export {};