rea-agents 1.0.0 → 1.2.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 (52) hide show
  1. package/README.md +49 -18
  2. package/dist/application/ArtifactInventory.js +80 -44
  3. package/dist/application/CommandShimReplay.js +136 -0
  4. package/dist/application/Doctor.js +4 -4
  5. package/dist/application/EvidenceBundleFiles.js +13 -4
  6. package/dist/application/MacHopper.js +175 -0
  7. package/dist/application/ProcessCaptureAuthority.js +25 -0
  8. package/dist/application/ProcessCaptureCapability.js +23 -0
  9. package/dist/application/ProcessCaptureError.js +5 -0
  10. package/dist/application/ProcessCaptureLifecycle.js +269 -0
  11. package/dist/application/ProcessCheckpoints.js +129 -0
  12. package/dist/application/ProcessCli.js +61 -0
  13. package/dist/application/ProcessEvidence.js +38 -0
  14. package/dist/application/ProcessHarness.js +252 -272
  15. package/dist/application/ProcessNormalization.js +10 -1
  16. package/dist/application/ProcessOwnership.js +39 -1
  17. package/dist/application/ProcessSampling.js +39 -23
  18. package/dist/application/Setup.js +104 -100
  19. package/dist/application/SetupSkill.js +44 -0
  20. package/dist/application/TerminalRenderer.js +92 -0
  21. package/dist/application/runtime.js +1 -1
  22. package/dist/artifacts/ArtifactProvider.js +18 -6
  23. package/dist/artifacts/ArtifactReader.js +3 -1
  24. package/dist/artifacts/AsarArtifactReader.js +8 -1
  25. package/dist/artifacts/DirectoryArtifactReader.js +1 -0
  26. package/dist/artifacts/MachOSliceArtifactReader.js +1 -0
  27. package/dist/artifacts/NativeDmgArtifactReader.js +151 -0
  28. package/dist/artifacts/ZipArtifactReader.js +1 -0
  29. package/dist/cli.js +60 -26
  30. package/dist/cliProcessCommands.js +19 -0
  31. package/dist/config.js +2 -0
  32. package/dist/contracts/artifactToolContracts.js +1 -0
  33. package/dist/contracts/investigationExamples.js +3 -3
  34. package/dist/contracts/processCaptureExample.js +50 -7
  35. package/dist/contracts/toolContracts.js +3 -2
  36. package/dist/domain/changedBehavior.js +2 -2
  37. package/dist/domain/errors.js +11 -3
  38. package/dist/domain/processCapture.js +99 -293
  39. package/dist/domain/processCaptureValidation.js +124 -0
  40. package/dist/domain/processComparison.js +176 -32
  41. package/dist/domain/processScenario.js +370 -0
  42. package/dist/domain/reconstructionVerification.js +3 -3
  43. package/dist/domain/runtimeVersion.js +8 -0
  44. package/dist/domain/staticRuntimeCorrelation.js +3 -2
  45. package/dist/identity.js +1 -0
  46. package/dist/server/registerProcessComparisonTool.js +28 -8
  47. package/dist/server/registerSessionTools.js +3 -25
  48. package/dist/server/sessionToolPolicies.js +1 -6
  49. package/install.sh +79 -164
  50. package/package.json +7 -4
  51. package/scripts/prepare-node-pty.mjs +35 -0
  52. package/skills/rea-analysis/SKILL.md +19 -4
package/README.md CHANGED
@@ -11,14 +11,14 @@
11
11
  [![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
12
12
  [![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
13
13
  [![68 MCP tools](https://img.shields.io/badge/MCP_tools-68-5c4ee5?style=flat-square)](#68-tools-for-investigation)
14
- [![Node.js 24](https://img.shields.io/badge/Node.js-24.18.x-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
14
+ [![Node.js 22+](https://img.shields.io/badge/Node.js-22.19%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
15
15
  [![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE)
16
16
 
17
17
  [Quick start](#quick-start) · [Current status](#current-status) · [Investigation model](#the-investigation-model) · [68 tools](#68-tools-for-investigation) · [Roadmap](#roadmap) · [How it works](#how-it-works)
18
18
 
19
19
  <br />
20
20
 
21
- <code>curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash</code>
21
+ <code>npm install --global rea-agents && rea setup</code>
22
22
 
23
23
  </div>
24
24
 
@@ -81,15 +81,24 @@ REA shows how it reached its conclusions. It does not claim to recover original
81
81
 
82
82
  ## Quick start
83
83
 
84
- ### One-command install — recommended
84
+ ### Install the CLI — recommended
85
85
 
86
86
  ```bash
87
- curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash
87
+ npm install --global rea-agents
88
+ rea setup
88
89
  ```
89
90
 
90
- The installer supports macOS 12+, Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux. It installs compatible Node.js 24/npm 11 prerequisites, the exact latest `rea-agents-*` release, the official Hopper package, detected client registrations, and the bundled skill, then verifies the result. macOS uses Homebrew; Linux uses the native package manager and may show a system authorization prompt. Pin a reproducible release with `REA_VERSION=0.3.0`; `REA_VERSION` is the only supported installer environment override.
91
+ Installing the CLI does not update Homebrew, Node.js, npm, Hopper, or coding-agent configuration. `rea setup` detects what is already present, prints every proposed change, and asks before applying it.
92
+
93
+ REA detects Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf, and Devin. Registrations are additive, backup-first, and read back after writing. You can safely rerun setup.
94
+
95
+ An optional curl wrapper installs the same CLI package and starts setup only when a terminal is available:
96
+
97
+ ```bash
98
+ curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash
99
+ ```
91
100
 
92
- REA detects Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf, and Devin. It configures detected clients with documented local MCP files; Devin is reported as skipped because its public documentation does not define a local configuration file. You can safely rerun the installer or `rea setup --yes`. If REA's local analysis engine needs one-time activation, installation remains intact and setup reports the exact remaining action rather than claiming full readiness.
101
+ Pass installer options after `bash -s --`, for example `--dry-run`, `--no-setup`, or `--version 1.0.0`. The curl wrapper never installs prerequisites or configures integrations itself. See [Installation and setup](docs/installation.md) for its exact mutation boundary.
93
102
 
94
103
  ### With a coding agent — recommended
95
104
 
@@ -99,23 +108,23 @@ npx skills add morluto/rea
99
108
 
100
109
  Ask your agent to set up REA. It will check your Mac, explain anything it needs to install, ask for approval, and guide you through system prompts. After setup, restart the agent if it asks you to load the full REA toolset.
101
110
 
102
- Approve installation if needed, complete the one-time activation when prompted, then describe the app or feature you want to understand. REA handles the binary-analysis tools behind the scenes.
111
+ Review the setup plan, approve it if appropriate, complete Hopper's one-time activation when prompted, then describe the app or feature you want to understand.
103
112
 
104
113
  ### From Terminal — no installation
105
114
 
106
115
  ```bash
107
- npx -y rea-agents setup --yes
116
+ npx -y rea-agents setup
108
117
  npx -y rea-agents doctor
109
118
  npx -y rea-agents analyze /Applications/Notes.app
110
119
  ```
111
120
 
112
- If macOS or an installer asks for confirmation, complete the prompt and run the same command again. Restart a configured coding agent so it loads REA.
121
+ Review the setup plan before confirming it. Restart a configured coding agent so it loads REA.
113
122
 
114
123
  ### From Terminal — install the `rea` command
115
124
 
116
125
  ```bash
117
126
  npm install --global rea-agents
118
- rea setup --yes
127
+ rea setup
119
128
  rea doctor
120
129
  rea analyze /Applications/Notes.app
121
130
  ```
@@ -137,9 +146,10 @@ Choose either the no-install commands or the global installation. You do not nee
137
146
 
138
147
  - macOS 12 or newer
139
148
  - Ubuntu 24.04+, Fedora 41+, or 64-bit Arch Linux
140
- - Node.js 24.18.x with npm 11.16.x
149
+ - Node.js 22.19+ or 24.11+ (including newer releases)
150
+ - npm; REA does not require or install a particular npm version
141
151
 
142
- You do not need to choose or install binary-analysis tools yourself. REA setup handles them when needed. Deep binary analysis currently uses [Hopper](https://www.hopperapp.com/), a separate desktop application with its own license. REA can install the official macOS or Linux package, but you must approve installation and complete its one-time activation.
152
+ Deep binary analysis currently uses [Hopper](https://www.hopperapp.com/), a separate desktop application with its own license. Setup reuses an existing installation. If Hopper is missing, interactive setup proposes the official package and includes it in the confirmation plan. Unattended installation requires `rea setup --yes --install-hopper`.
143
153
 
144
154
  If something is not working, run:
145
155
 
@@ -151,7 +161,9 @@ npx -y rea-agents doctor
151
161
 
152
162
  ### Linux installation and troubleshooting
153
163
 
154
- On Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux, setup downloads the matching official Hopper package, restricts downloads to Hopper's public origin, verifies the published size and checksum, and invokes `apt-get`, `dnf`, or `pacman`. When REA is not already running as root, `pkexec` presents the system authorization prompt. REA never invokes `sudo`.
164
+ On macOS, approved setup downloads Hopper's official DMG, verifies it, and installs the app into `~/Applications` without Homebrew or administrator privileges. It opens Hopper once for activation; no manual drag-and-drop is required.
165
+
166
+ On Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux, approved setup downloads the matching official Hopper package, restricts downloads to Hopper's public origin, verifies the published size and checksum, and invokes `apt-get`, `dnf`, or `pacman`. When REA is not already running as root, `pkexec` presents the system authorization prompt. REA never invokes `sudo`.
155
167
 
156
168
  The normal Linux launcher is `/opt/hopper/bin/Hopper`. If Hopper was installed elsewhere:
157
169
 
@@ -166,7 +178,7 @@ If doctor reports a missing analysis engine even though the file exists, inspect
166
178
  ldd /opt/hopper/bin/Hopper | grep 'not found'
167
179
  ```
168
180
 
169
- Install the missing distribution packages and rerun `rea setup --yes`. Hopper is a desktop application: real analysis requires an active `DISPLAY` or `WAYLAND_DISPLAY`, plus one-time license activation. The curl installer places the `rea` command in `~/.local/bin` on Linux; add that directory to future shell `PATH` values if it is not already present.
181
+ Install the missing distribution packages and rerun `rea setup`. Hopper is a desktop application: real analysis requires an active `DISPLAY` or `WAYLAND_DISPLAY`, plus one-time license activation. The curl installer places the `rea` command in `~/.local/bin` on Linux; add that directory to future shell `PATH` values if it is not already present.
170
182
 
171
183
  REA defaults `HOPPER_LAUNCHER_PATH` to `/Applications/Hopper Disassembler.app/Contents/MacOS/hopper` on macOS and `/opt/hopper/bin/Hopper` on Linux. Explicit configuration always takes precedence.
172
184
 
@@ -177,7 +189,7 @@ rea uninstall
177
189
  rea uninstall --purge-data # also removes only ~/.rea/cache and ~/.rea/state
178
190
  ```
179
191
 
180
- Uninstall preserves Hopper, Homebrew, Node.js, evidence, captures, external evidence roots, unrelated skills, and other MCP servers. It refuses malformed client configuration and never follows purge-data symlinks.
192
+ Uninstall preserves Hopper, Node.js, evidence, captures, external evidence roots, unrelated skills, and other MCP servers. It refuses malformed client configuration and never follows purge-data symlinks.
181
193
 
182
194
  ### CLI or coding agent?
183
195
 
@@ -187,6 +199,7 @@ Uninstall preserves Hopper, Homebrew, Node.js, evidence, captures, external evid
187
199
  | Inspect or decompile one part of an app from the Terminal | `rea analyze` or `rea decompile` |
188
200
  | Validate, canonicalize, or compare Evidence v2 bundles | `rea evidence-import`, `rea evidence-export`, or `rea compare` |
189
201
  | Import source as historical reference | `rea import-reference-source` |
202
+ | Capture or compare controlled process behavior | `rea capture-process` or `rea compare-process-captures` |
190
203
 
191
204
  Filesystem evidence commands and MCP file tools are disabled until the operator approves absolute roots:
192
205
 
@@ -255,12 +268,12 @@ The public interface describes what the agent is trying to learn. Providers deci
255
268
  REA is already useful for native application investigation on macOS:
256
269
 
257
270
  - Open Mach-O, ELF, PE, `.app`, ZIP, APK, IPA, ASAR, plist, JavaScript, source-map, and Hopper database targets.
258
- - Traverse content-addressed artifact graphs without extraction; materialize only approved occurrences into absent output roots.
271
+ - Traverse content-addressed artifact graphs without extraction; on macOS, read-only DMG traversal additionally requires `native_mount_approved: true` and `REA_ARTIFACT_NATIVE_MOUNT_ENABLED=true`. Materialize only approved occurrences into absent output roots.
259
272
  - Build bounded function dossiers with pseudocode, assembly, CFG edges, comments, calls, references, strings, and names.
260
273
  - Search and trace features across symbols, strings, metadata, references, and call paths.
261
274
  - Record every successful result as deterministic Evidence v2 with artifact and provider identity, confidence, authority, limitations, and locations.
262
275
  - Export and import evidence bundles across sessions.
263
- - Capture approved PTY scenarios, child processes, filesystem changes, and loopback HTTP/WebSocket exchanges, then compare normalized captures.
276
+ - Capture approved PTY scenarios as Process Capture v4 Evidence, including committed run manifests, raw and rendered terminal frames, scripted interactions, descendant settlement, named filesystem checkpoints, deterministic command shims, and loopback HTTP/WebSocket exchanges.
264
277
  - Compare complete artifact inventories by stable path, content, metadata, and relations; incomplete evidence never implies equivalence.
265
278
  - Compare explicit function dossiers across text, calls, references, strings, and address-normalized CFG topology with per-facet unknowns.
266
279
  - Compare canonical Evidence bundles by exact membership, explicit observation pairs, and residual-unknown histories without turning omissions into behavioral absence.
@@ -286,7 +299,7 @@ REA is growing into a toolkit for understanding software across static artifacts
286
299
  7. **More targets and platforms** — Windows-native providers and ConPTY verification, Linux parity, websites and APIs, mobile artifacts, firmware, document formats, and other software-defined systems.
287
300
  8. **Differential reconstruction** — compare artifacts, functions, bundles, protocols, UIs, and process captures; track residual unknowns; verify a reconstruction against observed behavior.
288
301
 
289
- Roadmap items describe direction, not shipped support. New providers must produce the same evidence and safety metadata as existing capabilities before they become part of the public workflow.
302
+ Roadmap items describe direction, not shipped support. New providers must produce the same evidence and safety metadata as existing capabilities before they become part of the public workflow. Once REA has multiple optional toolchains, setup can become capability-selective; the consent rules for that future work are recorded in the [installation roadmap](docs/roadmap.md).
290
303
 
291
304
  See the [static-analysis provider evaluation](docs/provider-evaluation.md) for the current research matrix and admission gate.
292
305
 
@@ -377,10 +390,28 @@ environment allowlist in `REA_PROCESS_ALLOWED_ENV_JSON`. Because the current PTY
377
390
  adapter uses host networking, it also requires
378
391
  `REA_PROCESS_ALLOW_EXTERNAL_NETWORK=true`.
379
392
 
393
+ Capture a scenario or compare two saved Process Capture v4 Evidence records:
394
+
395
+ ```bash
396
+ rea capture-process ./scenario.json > authority.json
397
+ rea capture-process ./reconstruction.json > reconstruction.json
398
+ rea compare-process-captures authority.json reconstruction.json
399
+ ```
400
+
401
+ The comparison reports each observed dimension separately and identifies the
402
+ first terminal, interaction, exit, filesystem, protocol, process, or shim
403
+ divergence. See [Process Capture v4](docs/process-capture.md) for scenario
404
+ fields, command-shim replay, checkpoint triggers, limits, and safety behavior.
405
+
380
406
  If the native PTY backend is unavailable, install Xcode command-line tools and
381
407
  run `npm run rebuild:native`. Linux source builds require Python, `make`, and a
382
408
  C++ toolchain. Compatible packaged binaries do not require this rebuild.
383
409
 
410
+ ASAR inventory verifies Electron integrity metadata for both archive entries
411
+ and `.asar.unpacked` companion files. Integrity failures identify the logical
412
+ path, declared and calculated SHA-256 values, and whether the entry was
413
+ unpacked; REA does not silently accept the mismatched artifact.
414
+
384
415
  ## Security model
385
416
 
386
417
  REA does not provide a hosted analysis service. Hopper communication uses an authenticated private local socket. Dynamic capabilities are disabled by default and require both operator policy and explicit per-call approval. REA is not a security sandbox: providers and launched targets run with the current user's permissions, and each capability reports its side effects and limitations. Report vulnerabilities through the private process in [SECURITY.md](SECURITY.md).
@@ -7,16 +7,21 @@ 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);
22
27
  const metadata = await lstat(path);
@@ -24,7 +29,7 @@ export const scanArtifactInventory = async (inputPath, limits, signal) => {
24
29
  const rootDigest = metadata.isDirectory()
25
30
  ? null
26
31
  : await hashReadable(createReadStream(path), limits.maxTotalBytes, signal);
27
- const reader = await createReader(path, rootFormat);
32
+ const reader = await createReader(path, rootFormat, nativeMount, signal);
28
33
  try {
29
34
  const { nodes, occurrences } = await scanReader(reader, limits, signal);
30
35
  materializeDirectoryNodes(occurrences, nodes);
@@ -104,7 +109,7 @@ const inventoryLimitations = (format, reader) => {
104
109
  return ["Universal Mach-O slices require the native macOS lipo adapter."];
105
110
  return ["Artifact has no child container entries."];
106
111
  };
107
- const createReader = async (path, format) => {
112
+ const createReader = async (path, format, nativeMount, signal) => {
108
113
  switch (format) {
109
114
  case "directory":
110
115
  return new DirectoryArtifactReader(path);
@@ -118,57 +123,88 @@ const createReader = async (path, format) => {
118
123
  return process.platform === "darwin"
119
124
  ? new MachOSliceArtifactReader(path)
120
125
  : undefined;
126
+ case "dmg":
127
+ if (!nativeMount.nativeMountApproved)
128
+ return undefined;
129
+ if (!nativeMount.nativeMountEnabled)
130
+ throw new ArtifactReaderFailure("unavailable", "Native DMG mounting is disabled by operator policy");
131
+ return NativeDmgArtifactReader.create(path, signal);
121
132
  default:
122
133
  return undefined;
123
134
  }
124
135
  };
136
+ const visitNestedAsar = async (adapterKey, logicalPath, visit) => {
137
+ const nested = new AsarArtifactReader(adapterKey);
138
+ try {
139
+ await visit(nested, logicalPath);
140
+ }
141
+ finally {
142
+ await nested.close();
143
+ }
144
+ };
145
+ const isExpandableAsar = (entry, logicalPath) => entry.kind === "file" &&
146
+ logicalPath.toLowerCase().endsWith(".asar") &&
147
+ entry.adapterKey.startsWith("/");
148
+ const emptyScan = () => ({ nodes: new Map(), occurrences: [] });
125
149
  const scanReader = async (reader, limits, signal) => {
126
- const nodes = new Map();
127
- const occurrences = [];
150
+ const { nodes, occurrences } = emptyScan();
128
151
  if (reader === undefined)
129
152
  return { nodes, occurrences };
130
153
  const occurrenceByPath = new Map();
131
154
  const registry = new ArtifactPathRegistry();
132
155
  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",
156
+ const digestEntry = async (currentReader, entry, logicalPath) => {
157
+ if ((entry.kind !== "file" && entry.kind !== "slice") || entry.encrypted)
158
+ return undefined;
159
+ const remainingBytes = limits.maxTotalBytes - totalBytes;
160
+ if (remainingBytes <= 0)
161
+ throw new ArtifactReaderFailure("limit", "Artifact total byte limit exceeded");
162
+ if (entry.declaredSize !== null && entry.declaredSize > remainingBytes)
163
+ throw new ArtifactReaderFailure("limit", "Declared artifact bytes exceed remaining cumulative limit");
164
+ const digest = await hashReadable(await currentReader.open(entry, signal), Math.min(limits.maxEntryBytes, remainingBytes), signal);
165
+ totalBytes += digest.bytes;
166
+ if (entry.declaredSha256 !== null && entry.declaredSha256 !== digest.sha256)
167
+ throw new ArtifactReaderFailure("integrity", `Artifact integrity metadata disagrees with content: ${logicalPath}`, undefined, {
168
+ logicalPath,
169
+ declaredSha256: entry.declaredSha256,
170
+ calculatedSha256: digest.sha256,
171
+ unpacked: entry.unpacked,
164
172
  });
165
- nodes.set(node.artifact_id, node);
166
- occurrence.artifact_id = node.artifact_id;
167
- occurrence.hash_status = "verified";
173
+ const classified = entry.kind === "slice"
174
+ ? { kind: "universal-slice", format: "mach-o" }
175
+ : 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
+ });
184
+ };
185
+ const visit = async (currentReader, prefix) => {
186
+ for await (const entry of currentReader.entries(signal)) {
187
+ if (occurrences.length >= limits.maxEntries)
188
+ throw new ArtifactReaderFailure("limit", "Artifact entry limit exceeded");
189
+ const logicalPath = normalizeArtifactPath(prefix.length === 0 ? entry.path : `${prefix}/${entry.path}`, limits);
190
+ const expandableAsar = isExpandableAsar(entry, logicalPath);
191
+ registry.add(logicalPath, expandableAsar ? "directory" : entry.kind);
192
+ preflightEntry(entry, limits);
193
+ const parent = nearestParent(logicalPath, occurrenceByPath);
194
+ 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";
200
+ }
201
+ occurrences.push(occurrence);
202
+ occurrenceByPath.set(logicalPath, occurrence);
203
+ if (expandableAsar)
204
+ await visitNestedAsar(entry.adapterKey, logicalPath, visit);
168
205
  }
169
- occurrences.push(occurrence);
170
- occurrenceByPath.set(logicalPath, occurrence);
171
- }
206
+ };
207
+ await visit(reader, "");
172
208
  return { nodes, occurrences };
173
209
  };
174
210
  const classifyRoot = async (path, directory) => {
@@ -0,0 +1,136 @@
1
+ import { createServer } from "node:http";
2
+ import { chmod, mkdir, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ const RUNNER = `const command = process.argv[2];
5
+ const response = await fetch(process.env.REA_SHIM_LEDGER_URL, {
6
+ method: "POST",
7
+ headers: { "content-type": "application/json" },
8
+ body: JSON.stringify({ command, arguments: process.argv.slice(3), working_directory: process.cwd() }),
9
+ });
10
+ if (!response.ok) process.exit(127);
11
+ const route = await response.json();
12
+ let elapsed = 0;
13
+ for (const output of route.outputs) {
14
+ const wait = Math.max(0, output.at_ms - elapsed);
15
+ if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
16
+ elapsed = output.at_ms;
17
+ (output.stream === "stdout" ? process.stdout : process.stderr).write(output.data);
18
+ }
19
+ if (route.termination.type === "signal") process.kill(process.pid, route.termination.signal);
20
+ else process.exit(route.termination.code);
21
+ `;
22
+ const shellQuote = (value) => `'${value.replaceAll("'", `'\\''`)}'`;
23
+ /**
24
+ * Start bounded executable replay wrappers and their private invocation ledger.
25
+ *
26
+ * Exact argument matching is deliberate: a fallback to the host executable
27
+ * would make an unmatched dependency call look authoritative. Unmatched and
28
+ * exhausted routes therefore fail visibly and remain part of the capture.
29
+ */
30
+ export const startCommandShimReplay = async (scenario, temporaryRoot, started) => {
31
+ const binPath = join(temporaryRoot, "shims");
32
+ await mkdir(binPath);
33
+ const runnerPath = join(temporaryRoot, "shim-runner.mjs");
34
+ await writeFile(runnerPath, RUNNER, { mode: 0o600, flag: "wx" });
35
+ for (const shim of scenario.command_shims) {
36
+ const path = join(binPath, shim.name);
37
+ const wrapper = `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(runnerPath)} ${shellQuote(shim.name)} "$@"\n`;
38
+ await writeFile(path, wrapper, { mode: 0o700, flag: "wx" });
39
+ await chmod(path, 0o700);
40
+ }
41
+ const events = [];
42
+ let truncated = false;
43
+ const calls = new Map();
44
+ const server = createServer((request, response) => {
45
+ if (request.method !== "POST" || request.url !== "/invoke") {
46
+ response.writeHead(404).end();
47
+ return;
48
+ }
49
+ readInvocation(request)
50
+ .then((invocation) => {
51
+ const shim = scenario.command_shims.find(({ name }) => name === invocation.command);
52
+ const routeIndex = shim?.routes.findIndex(({ arguments: expected }) => JSON.stringify(expected) === JSON.stringify(invocation.arguments));
53
+ const key = `${invocation.command}:${String(routeIndex ?? -1)}`;
54
+ const used = calls.get(key) ?? 0;
55
+ const route = shim !== undefined && routeIndex !== undefined && routeIndex >= 0
56
+ ? shim.routes[routeIndex]
57
+ : undefined;
58
+ const outcome = route === undefined
59
+ ? "unmatched"
60
+ : used >= route.max_calls
61
+ ? "exhausted"
62
+ : "matched";
63
+ if (events.length >= scenario.limits.protocol_events)
64
+ truncated = true;
65
+ else
66
+ events.push({
67
+ sequence: events.length,
68
+ at_ms: Math.max(0, Date.now() - started),
69
+ command: invocation.command,
70
+ route_index: routeIndex !== undefined && routeIndex >= 0 ? routeIndex : null,
71
+ arguments: invocation.arguments,
72
+ working_directory: invocation.working_directory,
73
+ outcome,
74
+ });
75
+ if (outcome !== "matched" || route === undefined) {
76
+ response.writeHead(409).end();
77
+ return;
78
+ }
79
+ calls.set(key, used + 1);
80
+ response
81
+ .writeHead(200, { "content-type": "application/json" })
82
+ .end(JSON.stringify(route));
83
+ })
84
+ .catch(() => response.writeHead(400).end());
85
+ });
86
+ await listen(server);
87
+ const address = server.address();
88
+ if (address === null || typeof address === "string") {
89
+ await closeServer(server);
90
+ throw new Error("command shim ledger did not bind an IPv4 port");
91
+ }
92
+ return {
93
+ binPath,
94
+ url: `http://127.0.0.1:${String(address.port)}/invoke`,
95
+ events,
96
+ get truncated() {
97
+ return truncated;
98
+ },
99
+ close: () => closeServer(server),
100
+ };
101
+ };
102
+ const readInvocation = async (request) => {
103
+ const chunks = [];
104
+ let bytes = 0;
105
+ for await (const chunk of request) {
106
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
107
+ bytes += buffer.length;
108
+ if (bytes > 64 * 1024)
109
+ throw new Error("shim invocation exceeds limit");
110
+ chunks.push(buffer);
111
+ }
112
+ const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
113
+ if (typeof value !== "object" ||
114
+ value === null ||
115
+ !("command" in value) ||
116
+ typeof value.command !== "string" ||
117
+ !("arguments" in value) ||
118
+ !Array.isArray(value.arguments) ||
119
+ !value.arguments.every((item) => typeof item === "string") ||
120
+ !("working_directory" in value) ||
121
+ typeof value.working_directory !== "string")
122
+ throw new Error("invalid shim invocation");
123
+ return {
124
+ command: value.command,
125
+ arguments: value.arguments,
126
+ working_directory: value.working_directory,
127
+ };
128
+ };
129
+ const listen = (server) => new Promise((resolveListen, rejectListen) => {
130
+ server.once("error", rejectListen);
131
+ server.listen(0, "127.0.0.1", () => {
132
+ server.off("error", rejectListen);
133
+ resolveListen();
134
+ });
135
+ });
136
+ const closeServer = (server) => new Promise((resolveClose, rejectClose) => server.close((cause) => cause === undefined ? resolveClose() : rejectClose(cause)));
@@ -6,6 +6,7 @@ import { join } from "node:path";
6
6
  import { execFile } from "node:child_process";
7
7
  import { promisify } from "node:util";
8
8
  import { parseBinaryTarget } from "../domain/binaryTarget.js";
9
+ import { supportsNodeVersion } from "../domain/runtimeVersion.js";
9
10
  import { probeHomebrew } from "./homebrew.js";
10
11
  import { linuxHopperLauncherPath, linuxSharedLibrariesAvailable, readLinuxDistribution, } from "./LinuxHopper.js";
11
12
  const execFileAsync = promisify(execFile);
@@ -18,9 +19,8 @@ const SYSTEM_LINUX_HOPPER = "/opt/hopper/bin/Hopper";
18
19
  */
19
20
  export const runDoctor = async (target, host = systemDoctorHost()) => {
20
21
  const checks = [];
21
- const nodeMajor = parseMajor(host.nodeVersion);
22
- checks.push(check("node", nodeMajor >= 24, host.nodeVersion, {
23
- remediation: "Install Node.js 24.18 or newer.",
22
+ checks.push(check("node", supportsNodeVersion(host.nodeVersion), host.nodeVersion, {
23
+ remediation: "Install Node.js 22.19+ or 24.11+.",
24
24
  classification: "missing_dependency",
25
25
  }));
26
26
  const macosVersion = host.platform === "darwin" ? await host.macosVersion() : undefined;
@@ -50,7 +50,7 @@ export const runDoctor = async (target, host = systemDoctorHost()) => {
50
50
  break;
51
51
  }
52
52
  checks.push(check("hopper", hopperPath !== undefined, hopperPath, {
53
- remediation: "Run rea setup --yes to install Hopper, or set HOPPER_LAUNCHER_PATH.",
53
+ remediation: "Run rea setup to install Hopper, or set HOPPER_LAUNCHER_PATH.",
54
54
  classification: "missing_analysis_engine",
55
55
  }));
56
56
  if (target !== undefined)
@@ -4,6 +4,7 @@ import writeFileAtomic from "write-file-atomic";
4
4
  import { parseEvidenceBundle, serializeEvidenceBundle, } from "../domain/evidenceBundle.js";
5
5
  import { EvidenceFileError, EvidenceIntegrityError } from "../domain/errors.js";
6
6
  import { err, ok } from "../domain/result.js";
7
+ import { LEGACY_PROCESS_CAPTURE_MESSAGE, parseProcessCapture, } from "../domain/processCapture.js";
7
8
  /** Read and validate a bounded evidence bundle inside an approved root. */
8
9
  export const readEvidenceBundle = async (path, policy) => {
9
10
  if (policy.roots.length === 0)
@@ -31,7 +32,14 @@ export const readEvidenceBundle = async (path, policy) => {
31
32
  if (!limits.ok)
32
33
  return limits;
33
34
  try {
34
- return ok(parseEvidenceBundle(decoded));
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);
35
43
  }
36
44
  catch (cause) {
37
45
  return err(new EvidenceIntegrityError("Evidence bundle validation failed", {
@@ -60,8 +68,9 @@ export const writeEvidenceBundle = async (bundle, path, overwrite, policy) => {
60
68
  if (bytes > policy.maxBytes)
61
69
  return err(new EvidenceFileError("write", "too-large"));
62
70
  try {
63
- const canonicalParent = await realpath(dirname(resolve(path)));
64
- const destination = resolve(canonicalParent, basename(resolve(path)));
71
+ const requestedPath = resolve(path);
72
+ const canonicalParent = await realpath(dirname(requestedPath));
73
+ const destination = resolve(canonicalParent, basename(requestedPath));
65
74
  if (!(await isApproved(destination, policy.roots)))
66
75
  return err(new EvidenceFileError("write", "outside-root"));
67
76
  const existing = await lstat(destination).catch((cause) => {
@@ -80,7 +89,7 @@ export const writeEvidenceBundle = async (bundle, path, overwrite, policy) => {
80
89
  mode: 0o600,
81
90
  fsync: true,
82
91
  });
83
- return ok({ path: destination, bytes });
92
+ return ok({ path: requestedPath, bytes });
84
93
  }
85
94
  catch (cause) {
86
95
  return err(new EvidenceFileError("write", "io", { cause }));