rea-agents 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +94 -28
  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 +84 -44
  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/CommandShimReplay.js +136 -0
  11. package/dist/application/CrossVersionInventory.js +98 -0
  12. package/dist/application/CrossVersionInvestigation.js +295 -0
  13. package/dist/application/DirectAnalysis.js +115 -22
  14. package/dist/application/Doctor.js +30 -1
  15. package/dist/application/EvidenceBundleFiles.js +16 -101
  16. package/dist/application/EvidenceLedger.js +3 -2
  17. package/dist/application/InvestigationProviders.js +48 -0
  18. package/dist/application/InvestigationWorkspaceStore.js +214 -0
  19. package/dist/application/LinuxHopper.js +72 -48
  20. package/dist/application/ProcessCaptureAuthority.js +25 -0
  21. package/dist/application/ProcessCaptureCapability.js +23 -0
  22. package/dist/application/ProcessCaptureError.js +17 -0
  23. package/dist/application/ProcessCaptureLifecycle.js +287 -0
  24. package/dist/application/ProcessCheckpoints.js +129 -0
  25. package/dist/application/ProcessCli.js +128 -0
  26. package/dist/application/ProcessEvidence.js +38 -0
  27. package/dist/application/ProcessHarness.js +252 -272
  28. package/dist/application/ProcessNormalization.js +10 -1
  29. package/dist/application/ProcessOwnership.js +39 -1
  30. package/dist/application/ProcessSampling.js +39 -23
  31. package/dist/application/ReferenceSourceImportEntries.js +18 -3
  32. package/dist/application/ReferenceSourceImportTypes.js +32 -0
  33. package/dist/application/Setup.js +44 -96
  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/SetupSkill.js +44 -0
  38. package/dist/application/TerminalRenderer.js +92 -0
  39. package/dist/application/Uninstall.js +33 -23
  40. package/dist/application/UnknownEvidence.js +33 -0
  41. package/dist/application/Upgrade.js +1 -1
  42. package/dist/application/runtime.js +2 -2
  43. package/dist/artifacts/ArtifactProvider.js +20 -11
  44. package/dist/artifacts/ArtifactReader.js +3 -1
  45. package/dist/artifacts/AsarArtifactReader.js +8 -1
  46. package/dist/artifacts/DirectoryArtifactReader.js +1 -0
  47. package/dist/artifacts/MachOSliceArtifactReader.js +1 -0
  48. package/dist/artifacts/NativeDmgArtifactReader.js +151 -0
  49. package/dist/artifacts/ZipArtifactReader.js +1 -0
  50. package/dist/cli.js +57 -32
  51. package/dist/cliEvidenceCommands.js +11 -12
  52. package/dist/cliInvestigationCommands.js +86 -0
  53. package/dist/cliOutput.js +41 -0
  54. package/dist/cliProcessCommands.js +29 -0
  55. package/dist/config.js +41 -24
  56. package/dist/contracts/artifactToolContracts.js +1 -0
  57. package/dist/contracts/investigationExamples.js +3 -3
  58. package/dist/contracts/processCaptureExample.js +50 -7
  59. package/dist/contracts/promptContracts.js +256 -0
  60. package/dist/contracts/sessionLifecycleInputs.js +11 -0
  61. package/dist/contracts/toolContractTypes.js +1 -0
  62. package/dist/contracts/toolContracts.js +13 -8
  63. package/dist/contracts/toolOutputSchemas.js +33 -4
  64. package/dist/domain/analysisSnapshot.js +149 -0
  65. package/dist/domain/changedBehavior.js +32 -9
  66. package/dist/domain/errors.js +186 -39
  67. package/dist/domain/evidence.js +4 -2
  68. package/dist/domain/evidenceBundle.js +28 -0
  69. package/dist/domain/hopperStartupFailure.js +55 -0
  70. package/dist/domain/investigationWorkspace.js +214 -0
  71. package/dist/domain/jsonValue.js +2 -0
  72. package/dist/domain/nativeInspection.js +3 -2
  73. package/dist/domain/processCapture.js +100 -293
  74. package/dist/domain/processCaptureValidation.js +124 -0
  75. package/dist/domain/processComparison.js +177 -32
  76. package/dist/domain/processScenario.js +370 -0
  77. package/dist/domain/reconstructionVerification.js +3 -3
  78. package/dist/domain/staticRuntimeCorrelation.js +3 -2
  79. package/dist/hopper/BridgeLauncher.js +47 -20
  80. package/dist/hopper/HopperClient.js +17 -2
  81. package/dist/hopper/HopperProvider.js +1 -0
  82. package/dist/identity.js +1 -0
  83. package/dist/main.js +47 -24
  84. package/dist/server/createServer.js +2 -0
  85. package/dist/server/promptCompletion.js +144 -0
  86. package/dist/server/registerEnhancedTools.js +2 -6
  87. package/dist/server/registerEvidenceTools.js +2 -8
  88. package/dist/server/registerFunctionComparisonTool.js +1 -1
  89. package/dist/server/registerInvestigationTools.js +31 -11
  90. package/dist/server/registerOfficialTools.js +6 -13
  91. package/dist/server/registerProcessComparisonTool.js +28 -14
  92. package/dist/server/registerPrompts.js +67 -0
  93. package/dist/server/registerSessionStatusTool.js +11 -0
  94. package/dist/server/registerSessionTools.js +55 -43
  95. package/dist/server/sessionEvidence.js +2 -8
  96. package/dist/server/sessionToolPolicies.js +2 -48
  97. package/dist/server/toolResult.js +7 -3
  98. package/install.sh +11 -11
  99. package/package.json +9 -3
  100. package/scripts/hopper-demo-x11.py +349 -0
  101. package/scripts/prepare-node-pty.mjs +35 -0
  102. package/scripts/rea.mjs +6 -3
  103. package/skills/rea-analysis/SKILL.md +27 -5
@@ -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
+ };
@@ -7,24 +7,33 @@ import { ArtifactReaderFailure, } from "../artifacts/ArtifactReader.js";
7
7
  import { DirectoryArtifactReader } from "../artifacts/DirectoryArtifactReader.js";
8
8
  import { ZipArtifactReader } from "../artifacts/ZipArtifactReader.js";
9
9
  import { MachOSliceArtifactReader } from "../artifacts/MachOSliceArtifactReader.js";
10
+ import { NativeDmgArtifactReader } from "../artifacts/NativeDmgArtifactReader.js";
10
11
  import { streamChunkToBuffer } from "../artifacts/StreamBytes.js";
11
12
  import { artifactInventoryResultSchema, } from "../domain/artifactGraph.js";
12
13
  import { classifyArtifactPath, classifyArtifactContent, createArtifactEdges, createArtifactNode, createOccurrence, createRootNode, digestCanonical, materializeDirectoryNodes, nearestParent, pageOf, rekeyOccurrences, rootOccurrenceFor, toOutputLimits, } from "./ArtifactGraphConstruction.js";
14
+ const NATIVE_MOUNT_DISABLED = {
15
+ nativeMountApproved: false,
16
+ nativeMountEnabled: false,
17
+ };
13
18
  /** Inventory one local artifact without extracting or mounting it. */
14
- export const inventoryArtifact = async (inputPath, limits, page, signal) => {
15
- const snapshot = await scanArtifactInventory(inputPath, limits, signal);
19
+ export const inventoryArtifact = async (inputPath, limits, page, options = {}) => {
20
+ const snapshot = await scanArtifactInventory(inputPath, limits, options.signal, options.nativeMount ?? NATIVE_MOUNT_DISABLED);
16
21
  return paginateArtifactInventory(snapshot, page);
17
22
  };
18
23
  /** Scan an artifact once and retain the complete immutable graph for projection. */
19
- export const scanArtifactInventory = async (inputPath, limits, signal) => {
24
+ export const scanArtifactInventory = async (inputPath, limits, signal, nativeMount = NATIVE_MOUNT_DISABLED) => {
20
25
  abortIfNeeded(signal);
21
26
  const path = await realpath(inputPath);
27
+ return scanCanonicalArtifactInventory(path, limits, signal, nativeMount);
28
+ };
29
+ /** Scan one already-canonical artifact path without resolving it again. */
30
+ export const scanCanonicalArtifactInventory = async (path, limits, signal, nativeMount = NATIVE_MOUNT_DISABLED) => {
22
31
  const metadata = await lstat(path);
23
32
  const rootFormat = await classifyRoot(path, metadata.isDirectory());
24
33
  const rootDigest = metadata.isDirectory()
25
34
  ? null
26
35
  : await hashReadable(createReadStream(path), limits.maxTotalBytes, signal);
27
- const reader = await createReader(path, rootFormat);
36
+ const reader = await createReader(path, rootFormat, nativeMount, signal);
28
37
  try {
29
38
  const { nodes, occurrences } = await scanReader(reader, limits, signal);
30
39
  materializeDirectoryNodes(occurrences, nodes);
@@ -104,7 +113,7 @@ const inventoryLimitations = (format, reader) => {
104
113
  return ["Universal Mach-O slices require the native macOS lipo adapter."];
105
114
  return ["Artifact has no child container entries."];
106
115
  };
107
- const createReader = async (path, format) => {
116
+ const createReader = async (path, format, nativeMount, signal) => {
108
117
  switch (format) {
109
118
  case "directory":
110
119
  return new DirectoryArtifactReader(path);
@@ -118,57 +127,88 @@ const createReader = async (path, format) => {
118
127
  return process.platform === "darwin"
119
128
  ? new MachOSliceArtifactReader(path)
120
129
  : undefined;
130
+ case "dmg":
131
+ if (!nativeMount.nativeMountApproved)
132
+ return undefined;
133
+ if (!nativeMount.nativeMountEnabled)
134
+ throw new ArtifactReaderFailure("unavailable", "Native DMG mounting is disabled by operator policy");
135
+ return NativeDmgArtifactReader.create(path, signal);
121
136
  default:
122
137
  return undefined;
123
138
  }
124
139
  };
140
+ const visitNestedAsar = async (adapterKey, logicalPath, visit) => {
141
+ const nested = new AsarArtifactReader(adapterKey);
142
+ try {
143
+ await visit(nested, logicalPath);
144
+ }
145
+ finally {
146
+ await nested.close();
147
+ }
148
+ };
149
+ const isExpandableAsar = (entry, logicalPath) => entry.kind === "file" &&
150
+ logicalPath.toLowerCase().endsWith(".asar") &&
151
+ entry.adapterKey.startsWith("/");
152
+ const emptyScan = () => ({ nodes: new Map(), occurrences: [] });
125
153
  const scanReader = async (reader, limits, signal) => {
126
- const nodes = new Map();
127
- const occurrences = [];
154
+ const { nodes, occurrences } = emptyScan();
128
155
  if (reader === undefined)
129
156
  return { nodes, occurrences };
130
157
  const occurrenceByPath = new Map();
131
158
  const registry = new ArtifactPathRegistry();
132
159
  let totalBytes = 0;
133
- for await (const entry of reader.entries(signal)) {
134
- if (occurrences.length >= limits.maxEntries)
135
- throw new ArtifactReaderFailure("limit", "Artifact entry limit exceeded");
136
- const logicalPath = normalizeArtifactPath(entry.path, limits);
137
- registry.add(logicalPath, entry.kind);
138
- preflightEntry(entry, limits);
139
- const parent = nearestParent(logicalPath, occurrenceByPath);
140
- const occurrence = createOccurrence(entry, logicalPath, parent?.occurrence_id ?? null);
141
- if ((entry.kind === "file" || entry.kind === "slice") && !entry.encrypted) {
142
- const remainingBytes = limits.maxTotalBytes - totalBytes;
143
- if (remainingBytes <= 0)
144
- throw new ArtifactReaderFailure("limit", "Artifact total byte limit exceeded");
145
- if (entry.declaredSize !== null && entry.declaredSize > remainingBytes)
146
- throw new ArtifactReaderFailure("limit", "Declared artifact bytes exceed remaining cumulative limit");
147
- const digest = await hashReadable(await reader.open(entry, signal), Math.min(limits.maxEntryBytes, remainingBytes), signal);
148
- totalBytes += digest.bytes;
149
- if (entry.declaredSha256 !== null &&
150
- entry.declaredSha256 !== digest.sha256)
151
- throw new ArtifactReaderFailure("integrity", `Artifact integrity metadata disagrees with content: ${logicalPath}`);
152
- if (totalBytes > limits.maxTotalBytes)
153
- throw new ArtifactReaderFailure("limit", "Artifact total byte limit exceeded");
154
- const classified = entry.kind === "slice"
155
- ? { kind: "universal-slice", format: "mach-o" }
156
- : classifyArtifactContent(logicalPath, digest.prefix);
157
- const node = createArtifactNode({
158
- sha256: digest.sha256,
159
- size: digest.bytes,
160
- kind: classified.kind,
161
- format: classified.format,
162
- executable: entry.executable,
163
- contentState: "embedded",
160
+ const digestEntry = async (currentReader, entry, logicalPath) => {
161
+ if ((entry.kind !== "file" && entry.kind !== "slice") || entry.encrypted)
162
+ return undefined;
163
+ const remainingBytes = limits.maxTotalBytes - totalBytes;
164
+ if (remainingBytes <= 0)
165
+ throw new ArtifactReaderFailure("limit", "Artifact total byte limit exceeded");
166
+ if (entry.declaredSize !== null && entry.declaredSize > remainingBytes)
167
+ throw new ArtifactReaderFailure("limit", "Declared artifact bytes exceed remaining cumulative limit");
168
+ const digest = await hashReadable(await currentReader.open(entry, signal), Math.min(limits.maxEntryBytes, remainingBytes), signal);
169
+ totalBytes += digest.bytes;
170
+ if (entry.declaredSha256 !== null && entry.declaredSha256 !== digest.sha256)
171
+ throw new ArtifactReaderFailure("integrity", `Artifact integrity metadata disagrees with content: ${logicalPath}`, undefined, {
172
+ logicalPath,
173
+ declaredSha256: entry.declaredSha256,
174
+ calculatedSha256: digest.sha256,
175
+ unpacked: entry.unpacked,
164
176
  });
165
- nodes.set(node.artifact_id, node);
166
- occurrence.artifact_id = node.artifact_id;
167
- occurrence.hash_status = "verified";
177
+ const classified = entry.kind === "slice"
178
+ ? { kind: "universal-slice", format: "mach-o" }
179
+ : classifyArtifactContent(logicalPath, digest.prefix);
180
+ return createArtifactNode({
181
+ sha256: digest.sha256,
182
+ size: digest.bytes,
183
+ kind: classified.kind,
184
+ format: classified.format,
185
+ executable: entry.executable,
186
+ contentState: "embedded",
187
+ });
188
+ };
189
+ const visit = async (currentReader, prefix) => {
190
+ for await (const entry of currentReader.entries(signal)) {
191
+ if (occurrences.length >= limits.maxEntries)
192
+ throw new ArtifactReaderFailure("limit", "Artifact entry limit exceeded");
193
+ const logicalPath = normalizeArtifactPath(prefix.length === 0 ? entry.path : `${prefix}/${entry.path}`, limits);
194
+ const expandableAsar = isExpandableAsar(entry, logicalPath);
195
+ registry.add(logicalPath, expandableAsar ? "directory" : entry.kind);
196
+ preflightEntry(entry, limits);
197
+ const parent = nearestParent(logicalPath, occurrenceByPath);
198
+ const occurrence = createOccurrence(entry, logicalPath, parent?.occurrence_id ?? null);
199
+ const node = await digestEntry(currentReader, entry, logicalPath);
200
+ if (node !== undefined) {
201
+ nodes.set(node.artifact_id, node);
202
+ occurrence.artifact_id = node.artifact_id;
203
+ occurrence.hash_status = "verified";
204
+ }
205
+ occurrences.push(occurrence);
206
+ occurrenceByPath.set(logicalPath, occurrence);
207
+ if (expandableAsar)
208
+ await visitNestedAsar(entry.adapterKey, logicalPath, visit);
168
209
  }
169
- occurrences.push(occurrence);
170
- occurrenceByPath.set(logicalPath, occurrence);
171
- }
210
+ };
211
+ await visit(reader, "");
172
212
  return { nodes, occurrences };
173
213
  };
174
214
  const classifyRoot = async (path, 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) => {
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);
16
+ };
@@ -2,15 +2,14 @@ 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";
14
13
  const OFFICIAL_OPERATIONS = new Set(OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name));
15
14
  /**
16
15
  * Owns the single active target shared by CLI and MCP adapters.
@@ -32,8 +31,12 @@ export class BinarySession {
32
31
  maxRecords: 10_000,
33
32
  maxBytes: 64 * 1024 * 1024,
34
33
  });
35
- constructor(provider) {
34
+ #snapshot = new AnalysisSnapshotCache();
35
+ #snapshotsEnabled;
36
+ #snapshotInvalidated = false;
37
+ constructor(provider, options = {}) {
36
38
  this.provider = provider;
39
+ this.#snapshotsEnabled = options.snapshotsEnabled ?? true;
37
40
  this.#createClient =
38
41
  typeof provider === "function"
39
42
  ? provider
@@ -90,6 +93,23 @@ export class BinarySession {
90
93
  importEvidenceBundle(bundle) {
91
94
  return this.#evidence.import(bundle);
92
95
  }
96
+ /** Export immutable cached calls and session evidence for the active target. */
97
+ exportAnalysisSnapshot() {
98
+ if (!this.#snapshotsEnabled)
99
+ return err(new EvidenceIntegrityError("Analysis snapshots are unavailable with custom Hopper loader arguments"));
100
+ if (this.#snapshotInvalidated)
101
+ return err(new EvidenceIntegrityError("Analysis snapshots are unavailable after analysis metadata mutations"));
102
+ const target = this.#active?.target;
103
+ return this.#snapshot.export(target, target === undefined
104
+ ? this.#evidence.export()
105
+ : evidenceBundleForTarget(this.#evidence.export(), target.sha256));
106
+ }
107
+ /** Stage validated cached calls for an identical binary and merge its evidence. */
108
+ importAnalysisSnapshot(snapshot) {
109
+ if (!this.#snapshotsEnabled)
110
+ return err(new EvidenceIntegrityError("Analysis snapshots are unavailable with custom Hopper loader arguments"));
111
+ return this.#snapshot.import(snapshot, this.#active?.target, (bundle) => this.#evidence.import(bundle));
112
+ }
93
113
  /** Create an approved residual unknown and immutable mutation evidence. */
94
114
  recordUnknown(input) {
95
115
  const evidence = unknownMutationEvidence(this.#active?.target, input);
@@ -101,7 +121,7 @@ export class BinarySession {
101
121
  }
102
122
  /** Update one unknown using compare-and-swap revision semantics. */
103
123
  updateUnknown(input) {
104
- const evidence = createEvidence(this.#active?.target, REGISTRY_PROVIDER, {
124
+ const evidence = createEvidence(this.#active?.target, UNKNOWN_REGISTRY_PROVIDER, {
105
125
  predicateType: "rea.residual-unknown-mutation/v1",
106
126
  operation: "update_unknown",
107
127
  parameters: {
@@ -137,10 +157,21 @@ export class BinarySession {
137
157
  const parsed = await parseBinaryTarget(path, process.cwd(), process.arch, options.targetKind);
138
158
  if (!parsed.ok)
139
159
  return parsed;
160
+ if (options.snapshot !== undefined &&
161
+ !snapshotMatchesTarget(options.snapshot.target, parsed.value))
162
+ return err(new EvidenceIntegrityError("Analysis snapshot target does not match the requested binary"));
163
+ if (this.#active === undefined && !this.#snapshot.matches(parsed.value))
164
+ return err(new EvidenceIntegrityError("Analysis snapshot target does not match the requested binary"));
140
165
  if (isAborted(options.signal))
141
166
  return err(new AnalysisCancelledError("open_binary"));
142
- if (this.#active?.target.path === parsed.value.path)
167
+ if (this.#active?.target.path === parsed.value.path) {
168
+ if (options.snapshot !== undefined) {
169
+ const imported = this.importAnalysisSnapshot(options.snapshot);
170
+ if (!imported.ok)
171
+ return imported;
172
+ }
143
173
  return ok(parsed.value);
174
+ }
144
175
  await this.#drainCalls();
145
176
  const previous = this.#active;
146
177
  this.#active = undefined;
@@ -158,6 +189,17 @@ export class BinarySession {
158
189
  return err(new AnalysisCancelledError("open_binary"));
159
190
  }
160
191
  this.#active = { target: parsed.value, client };
192
+ this.#snapshot.select(parsed.value);
193
+ if (options.snapshot !== undefined) {
194
+ const imported = this.importAnalysisSnapshot(options.snapshot);
195
+ if (!imported.ok) {
196
+ this.#active = undefined;
197
+ await client.close();
198
+ await this.#restore(previous);
199
+ return imported;
200
+ }
201
+ }
202
+ this.#snapshotInvalidated = false;
161
203
  return ok(parsed.value);
162
204
  });
163
205
  }
@@ -169,6 +211,8 @@ export class BinarySession {
169
211
  await this.#drainCalls();
170
212
  await previous?.client.close();
171
213
  this.#evidence.clear();
214
+ this.#snapshot.clear();
215
+ this.#snapshotInvalidated = false;
172
216
  return ok(null);
173
217
  });
174
218
  }
@@ -250,10 +294,23 @@ export class BinarySession {
250
294
  const active = this.#active;
251
295
  if (active === undefined)
252
296
  return err(new NoBinaryOpenError());
297
+ const cacheable = isSnapshotCacheable(name, capability, arguments_);
298
+ const cached = cacheable
299
+ ? this.#snapshot.lookup(active.target, name, arguments_, capability.provider)
300
+ : undefined;
301
+ if (cached !== undefined)
302
+ return ok(cached);
253
303
  const call = active.client.execute(name, arguments_, options);
254
304
  this.#calls.add(call);
255
305
  try {
256
- return await call;
306
+ const result = await call;
307
+ if (result.ok && cacheable)
308
+ this.#snapshot.record(active.target, name, arguments_, result.value);
309
+ else if (result.ok && capability?.effects.mutatesArtifact === true) {
310
+ this.#snapshot.clear();
311
+ this.#snapshotInvalidated = true;
312
+ }
313
+ return result;
257
314
  }
258
315
  finally {
259
316
  this.#calls.delete(call);
@@ -303,31 +360,4 @@ export class BinarySession {
303
360
  });
304
361
  }
305
362
  }
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
363
  const isAborted = (signal) => signal?.aborted === true;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,109 @@
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
+ import { EvidenceFileError } from "../domain/errors.js";
5
+ import { err, ok } from "../domain/result.js";
6
+ /** Read bounded JSON data from a regular file beneath an approved root. */
7
+ export const readBoundedJson = async (path, policy) => {
8
+ if (policy.roots.length === 0)
9
+ return err(new EvidenceFileError("read", "disabled"));
10
+ try {
11
+ const canonicalPath = await realpath(resolve(path));
12
+ if (!(await isApproved(canonicalPath, policy.roots)))
13
+ return err(new EvidenceFileError("read", "outside-root"));
14
+ const stats = await lstat(canonicalPath);
15
+ if (!stats.isFile())
16
+ return err(new EvidenceFileError("read", "not-file"));
17
+ if (stats.size > policy.maxBytes)
18
+ return err(new EvidenceFileError("read", "too-large"));
19
+ const encoded = await readFile(canonicalPath);
20
+ if (encoded.byteLength > policy.maxBytes)
21
+ return err(new EvidenceFileError("read", "too-large"));
22
+ let decoded;
23
+ try {
24
+ decoded = JSON.parse(encoded.toString("utf8"));
25
+ }
26
+ catch (cause) {
27
+ return err(new EvidenceFileError("read", "invalid-json", { cause }));
28
+ }
29
+ const limits = validateJsonLimits(decoded, policy);
30
+ return limits.ok ? ok(decoded) : limits;
31
+ }
32
+ catch (cause) {
33
+ return err(new EvidenceFileError("read", "io", { cause }));
34
+ }
35
+ };
36
+ /** Atomically write bounded text to a regular file beneath an approved root. */
37
+ export const writeBoundedText = async (encoded, path, overwrite, policy) => {
38
+ if (policy.roots.length === 0)
39
+ return err(new EvidenceFileError("write", "disabled"));
40
+ const bytes = Buffer.byteLength(encoded, "utf8");
41
+ if (bytes > policy.maxBytes)
42
+ return err(new EvidenceFileError("write", "too-large"));
43
+ try {
44
+ const requestedPath = resolve(path);
45
+ const canonicalParent = await realpath(dirname(requestedPath));
46
+ const destination = resolve(canonicalParent, basename(requestedPath));
47
+ if (!(await isApproved(destination, policy.roots)))
48
+ return err(new EvidenceFileError("write", "outside-root"));
49
+ const existing = await lstat(destination).catch((cause) => {
50
+ if (isFileNotFound(cause))
51
+ return undefined;
52
+ throw cause;
53
+ });
54
+ if (existing !== undefined) {
55
+ if (!overwrite)
56
+ return err(new EvidenceFileError("write", "exists"));
57
+ if (!existing.isFile() || existing.isSymbolicLink())
58
+ return err(new EvidenceFileError("write", "not-file"));
59
+ }
60
+ await writeFileAtomic(destination, encoded, {
61
+ encoding: "utf8",
62
+ mode: 0o600,
63
+ fsync: true,
64
+ });
65
+ return ok({ path: requestedPath, bytes });
66
+ }
67
+ catch (cause) {
68
+ return err(new EvidenceFileError("write", "io", { cause }));
69
+ }
70
+ };
71
+ const isApproved = async (candidate, roots) => {
72
+ for (const configuredRoot of roots) {
73
+ const root = await realpath(resolve(configuredRoot));
74
+ const relation = relative(root, candidate);
75
+ if (relation === "" ||
76
+ (!relation.startsWith("..") && !isAbsolute(relation)))
77
+ return true;
78
+ }
79
+ return false;
80
+ };
81
+ const validateJsonLimits = (root, policy) => {
82
+ const pending = [
83
+ { value: root, depth: 0 },
84
+ ];
85
+ let nodes = 0;
86
+ while (pending.length > 0) {
87
+ const current = pending.pop();
88
+ if (current === undefined)
89
+ break;
90
+ nodes += 1;
91
+ if (nodes > policy.maxNodes || current.depth > policy.maxDepth)
92
+ return err(new EvidenceFileError("read", "too-large"));
93
+ if (typeof current.value === "string" &&
94
+ current.value.length > policy.maxStringLength)
95
+ return err(new EvidenceFileError("read", "too-large"));
96
+ if (typeof current.value !== "object" || current.value === null)
97
+ continue;
98
+ for (const [key, value] of Object.entries(current.value)) {
99
+ if (key.length > policy.maxStringLength)
100
+ return err(new EvidenceFileError("read", "too-large"));
101
+ pending.push({ value, depth: current.depth + 1 });
102
+ }
103
+ }
104
+ return ok(null);
105
+ };
106
+ const isFileNotFound = (cause) => typeof cause === "object" &&
107
+ cause !== null &&
108
+ "code" in cause &&
109
+ cause.code === "ENOENT";