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.
- package/README.md +49 -18
- package/dist/application/ArtifactInventory.js +80 -44
- package/dist/application/CommandShimReplay.js +136 -0
- package/dist/application/Doctor.js +4 -4
- package/dist/application/EvidenceBundleFiles.js +13 -4
- package/dist/application/MacHopper.js +175 -0
- package/dist/application/ProcessCaptureAuthority.js +25 -0
- package/dist/application/ProcessCaptureCapability.js +23 -0
- package/dist/application/ProcessCaptureError.js +5 -0
- package/dist/application/ProcessCaptureLifecycle.js +269 -0
- package/dist/application/ProcessCheckpoints.js +129 -0
- package/dist/application/ProcessCli.js +61 -0
- package/dist/application/ProcessEvidence.js +38 -0
- package/dist/application/ProcessHarness.js +252 -272
- package/dist/application/ProcessNormalization.js +10 -1
- package/dist/application/ProcessOwnership.js +39 -1
- package/dist/application/ProcessSampling.js +39 -23
- package/dist/application/Setup.js +104 -100
- package/dist/application/SetupSkill.js +44 -0
- package/dist/application/TerminalRenderer.js +92 -0
- package/dist/application/runtime.js +1 -1
- package/dist/artifacts/ArtifactProvider.js +18 -6
- package/dist/artifacts/ArtifactReader.js +3 -1
- package/dist/artifacts/AsarArtifactReader.js +8 -1
- package/dist/artifacts/DirectoryArtifactReader.js +1 -0
- package/dist/artifacts/MachOSliceArtifactReader.js +1 -0
- package/dist/artifacts/NativeDmgArtifactReader.js +151 -0
- package/dist/artifacts/ZipArtifactReader.js +1 -0
- package/dist/cli.js +60 -26
- package/dist/cliProcessCommands.js +19 -0
- package/dist/config.js +2 -0
- package/dist/contracts/artifactToolContracts.js +1 -0
- package/dist/contracts/investigationExamples.js +3 -3
- package/dist/contracts/processCaptureExample.js +50 -7
- package/dist/contracts/toolContracts.js +3 -2
- package/dist/domain/changedBehavior.js +2 -2
- package/dist/domain/errors.js +11 -3
- package/dist/domain/processCapture.js +99 -293
- package/dist/domain/processCaptureValidation.js +124 -0
- package/dist/domain/processComparison.js +176 -32
- package/dist/domain/processScenario.js +370 -0
- package/dist/domain/reconstructionVerification.js +3 -3
- package/dist/domain/runtimeVersion.js +8 -0
- package/dist/domain/staticRuntimeCorrelation.js +3 -2
- package/dist/identity.js +1 -0
- package/dist/server/registerProcessComparisonTool.js +28 -8
- package/dist/server/registerSessionTools.js +3 -25
- package/dist/server/sessionToolPolicies.js +1 -6
- package/install.sh +79 -164
- package/package.json +7 -4
- package/scripts/prepare-node-pty.mjs +35 -0
- package/skills/rea-analysis/SKILL.md +19 -4
package/README.md
CHANGED
|
@@ -11,14 +11,14 @@
|
|
|
11
11
|
[](https://www.npmjs.com/package/rea-agents)
|
|
12
12
|
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
|
13
13
|
[](#68-tools-for-investigation)
|
|
14
|
-
[](https://nodejs.org/)
|
|
15
15
|
[](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>
|
|
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
|
-
###
|
|
84
|
+
### Install the CLI — recommended
|
|
85
85
|
|
|
86
86
|
```bash
|
|
87
|
-
|
|
87
|
+
npm install --global rea-agents
|
|
88
|
+
rea setup
|
|
88
89
|
```
|
|
89
90
|
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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;
|
|
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,
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
134
|
-
if (
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
64
|
-
const
|
|
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:
|
|
92
|
+
return ok({ path: requestedPath, bytes });
|
|
84
93
|
}
|
|
85
94
|
catch (cause) {
|
|
86
95
|
return err(new EvidenceFileError("write", "io", { cause }));
|