rea-agents 0.2.1

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 (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +312 -0
  3. package/bridge/hopper_bridge.py +557 -0
  4. package/dist/application/AnalysisProvider.js +1 -0
  5. package/dist/application/BinarySession.js +173 -0
  6. package/dist/application/DirectAnalysis.js +31 -0
  7. package/dist/application/Doctor.js +110 -0
  8. package/dist/application/EnhancedTools.js +357 -0
  9. package/dist/application/EvidenceLedger.js +65 -0
  10. package/dist/application/HopperToolPort.js +1 -0
  11. package/dist/application/LoopbackReplay.js +84 -0
  12. package/dist/application/ProcessHarness.js +347 -0
  13. package/dist/application/Setup.js +240 -0
  14. package/dist/application/homebrew.js +19 -0
  15. package/dist/application/runtime.js +11 -0
  16. package/dist/cli.js +88 -0
  17. package/dist/config.js +86 -0
  18. package/dist/contracts/enhancedInputs.js +73 -0
  19. package/dist/contracts/toolContracts.js +144 -0
  20. package/dist/contracts/toolOutputSchemas.js +267 -0
  21. package/dist/domain/binaryTarget.js +321 -0
  22. package/dist/domain/errors.js +81 -0
  23. package/dist/domain/evidence.js +146 -0
  24. package/dist/domain/evidenceBundle.js +11 -0
  25. package/dist/domain/hopperValues.js +253 -0
  26. package/dist/domain/jsonValue.js +3 -0
  27. package/dist/domain/processCapture.js +252 -0
  28. package/dist/domain/result.js +4 -0
  29. package/dist/domain/symbolAnalysis.js +68 -0
  30. package/dist/hopper/BridgeLauncher.js +130 -0
  31. package/dist/hopper/HopperClient.js +314 -0
  32. package/dist/hopper/HopperProvider.js +66 -0
  33. package/dist/hopper/protocol.js +32 -0
  34. package/dist/identity.js +14 -0
  35. package/dist/logger.js +27 -0
  36. package/dist/main.js +81 -0
  37. package/dist/server/createServer.js +30 -0
  38. package/dist/server/registerEnhancedTools.js +53 -0
  39. package/dist/server/registerOfficialTools.js +59 -0
  40. package/dist/server/registerSessionTools.js +159 -0
  41. package/dist/server/toolLogging.js +11 -0
  42. package/dist/server/toolResult.js +44 -0
  43. package/package.json +109 -0
  44. package/scripts/rea.mjs +15 -0
  45. package/scripts/rebuild-native.mjs +22 -0
  46. package/skills/rea-analysis/SKILL.md +44 -0
@@ -0,0 +1,65 @@
1
+ import { createEvidenceBundle, evidenceBundleSchema, } from "../domain/evidenceBundle.js";
2
+ import { parseEvidence } from "../domain/evidence.js";
3
+ /** Bounded, session-owned set of immutable evidence records. */
4
+ export class EvidenceLedger {
5
+ limits;
6
+ #records = new Map();
7
+ constructor(limits) {
8
+ this.limits = limits;
9
+ if (!Number.isSafeInteger(limits.maxRecords) || limits.maxRecords < 1)
10
+ throw new RangeError("maxRecords must be a positive safe integer");
11
+ }
12
+ /** Record evidence idempotently; conflicting content is rejected. */
13
+ record(input) {
14
+ const evidence = parseEvidence(input);
15
+ const existing = this.#records.get(evidence.evidence_id);
16
+ if (existing !== undefined) {
17
+ if (!recordsAgree(existing, evidence))
18
+ throw new Error(`Conflicting evidence record: ${evidence.evidence_id}`);
19
+ return "duplicate";
20
+ }
21
+ if (this.#records.size >= this.limits.maxRecords)
22
+ throw new RangeError("Evidence ledger record limit exceeded");
23
+ this.#records.set(evidence.evidence_id, evidence);
24
+ return "added";
25
+ }
26
+ /** Validate an entire bundle before atomically merging its records. */
27
+ import(input) {
28
+ const bundle = evidenceBundleSchema.parse(input);
29
+ const pending = new Map(this.#records);
30
+ for (const unparsed of bundle.records) {
31
+ const evidence = parseEvidence(unparsed);
32
+ const existing = pending.get(evidence.evidence_id);
33
+ if (existing !== undefined && !recordsAgree(existing, evidence))
34
+ throw new Error(`Conflicting evidence record: ${evidence.evidence_id}`);
35
+ pending.set(evidence.evidence_id, evidence);
36
+ }
37
+ if (pending.size > this.limits.maxRecords)
38
+ throw new RangeError("Evidence ledger record limit exceeded");
39
+ const added = pending.size - this.#records.size;
40
+ this.#records.clear();
41
+ for (const [id, evidence] of pending)
42
+ this.#records.set(id, evidence);
43
+ return added;
44
+ }
45
+ /** Export records in deterministic, semantically irrelevant ID order. */
46
+ export() {
47
+ return createEvidenceBundle([...this.#records.values()]);
48
+ }
49
+ /** Clear records when the owning session closes. */
50
+ clear() {
51
+ this.#records.clear();
52
+ }
53
+ }
54
+ const recordsAgree = (left, right) => JSON.stringify(withoutLocalPath(left)) ===
55
+ JSON.stringify(withoutLocalPath(right));
56
+ const withoutLocalPath = (evidence) => ({
57
+ ...evidence,
58
+ subject: evidence.subject === null
59
+ ? null
60
+ : {
61
+ ...evidence.subject,
62
+ name: "<non-identity-name>",
63
+ local_path: "<non-identity-local-path>",
64
+ },
65
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,84 @@
1
+ import { createServer } from "node:http";
2
+ import { WebSocketServer } from "ws";
3
+ const closeServer = async (server) => new Promise((resolveClose, rejectClose) => {
4
+ server.close((error) => error === undefined ? resolveClose() : rejectClose(error));
5
+ });
6
+ /** Start bounded HTTP and WebSocket replay endpoints bound only to IPv4 loopback. */
7
+ export const startLoopbackReplay = async (scenario) => {
8
+ const events = [];
9
+ const record = (event) => {
10
+ if (events.length < 10_000)
11
+ events.push({ sequence: events.length, ...event });
12
+ };
13
+ const server = createServer((request, response) => {
14
+ const method = request.method ?? "GET";
15
+ const path = new URL(request.url ?? "/", "http://127.0.0.1").pathname;
16
+ record({ protocol: "http", direction: "request", method, path, data: "" });
17
+ const route = scenario.replay.http.find((candidate) => candidate.method === method && candidate.path === path);
18
+ response.statusCode = route?.status ?? 404;
19
+ const body = route?.body ?? "";
20
+ response.end(body);
21
+ record({
22
+ protocol: "http",
23
+ direction: "response",
24
+ method,
25
+ path,
26
+ data: body,
27
+ });
28
+ });
29
+ const websocket = new WebSocketServer({
30
+ noServer: true,
31
+ maxPayload: 1_000_000,
32
+ clientTracking: true,
33
+ });
34
+ server.on("upgrade", (request, socket, head) => {
35
+ if (new URL(request.url ?? "/", "http://127.0.0.1").pathname !== "/ws") {
36
+ socket.destroy();
37
+ return;
38
+ }
39
+ websocket.handleUpgrade(request, socket, head, (client) => websocket.emit("connection", client, request));
40
+ });
41
+ websocket.on("connection", (client) => {
42
+ client.on("error", () => undefined);
43
+ client.on("message", (value) => record({
44
+ protocol: "websocket",
45
+ direction: "received",
46
+ method: null,
47
+ path: "/ws",
48
+ data: value.toString(),
49
+ }));
50
+ for (const message of scenario.replay.websocket_messages) {
51
+ client.send(message);
52
+ record({
53
+ protocol: "websocket",
54
+ direction: "sent",
55
+ method: null,
56
+ path: "/ws",
57
+ data: message,
58
+ });
59
+ }
60
+ });
61
+ await new Promise((resolveListen, rejectListen) => {
62
+ server.once("error", rejectListen);
63
+ server.listen(0, "127.0.0.1", () => {
64
+ server.off("error", rejectListen);
65
+ resolveListen();
66
+ });
67
+ });
68
+ const address = server.address();
69
+ if (address === null || typeof address === "string") {
70
+ await closeServer(server);
71
+ throw new Error("loopback replay did not acquire a TCP port");
72
+ }
73
+ return {
74
+ httpUrl: `http://127.0.0.1:${String(address.port)}`,
75
+ websocketUrl: `ws://127.0.0.1:${String(address.port)}/ws`,
76
+ events,
77
+ async close() {
78
+ for (const client of websocket.clients)
79
+ client.terminate();
80
+ await new Promise((resolveClose) => websocket.close(() => resolveClose()));
81
+ await closeServer(server);
82
+ },
83
+ };
84
+ };
@@ -0,0 +1,347 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, mkdtemp, readdir, readFile, readlink, realpath, rm, } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join, relative } from "node:path";
5
+ import { execFile } from "node:child_process";
6
+ import { promisify } from "node:util";
7
+ import { spawn } from "node-pty";
8
+ import { authorizeProcessScenario } from "../domain/processCapture.js";
9
+ import { err, ok } from "../domain/result.js";
10
+ import { AnalysisError } from "../domain/errors.js";
11
+ import { startLoopbackReplay } from "./LoopbackReplay.js";
12
+ const execFileAsync = promisify(execFile);
13
+ const isWithin = (candidate, root) => candidate === root ||
14
+ candidate.startsWith(`${root.endsWith("/") ? root.slice(0, -1) : root}/`);
15
+ const assertRealPathAuthority = async (scenario, policy) => {
16
+ const executable = await realpath(scenario.executable);
17
+ const executableRoots = await Promise.all(policy.executableRoots.map((root) => realpath(root)));
18
+ if (!executableRoots.some((root) => isWithin(executable, root)))
19
+ throw new ProcessCaptureError("resolved executable is outside approved roots");
20
+ const workingDirectory = await realpath(scenario.working_directory);
21
+ const workingRoots = await Promise.all(policy.workingRoots.map((root) => realpath(root)));
22
+ if (!workingRoots.some((root) => isWithin(workingDirectory, root)))
23
+ throw new ProcessCaptureError("resolved working directory is outside approved roots");
24
+ for (const root of scenario.filesystem_roots) {
25
+ const resolvedRoot = await realpath(root);
26
+ if (!workingRoots.some((approved) => isWithin(resolvedRoot, approved)))
27
+ throw new ProcessCaptureError("resolved filesystem root is outside approved roots");
28
+ }
29
+ };
30
+ /** Expected refusal or runtime failure from the process capture adapter. */
31
+ export class ProcessCaptureError extends AnalysisError {
32
+ _tag = "ProcessCaptureError";
33
+ }
34
+ /** Probe the actual native PTY seam instead of inferring support from the OS name. */
35
+ export const probeProcessCaptureCapability = async () => {
36
+ try {
37
+ const terminal = spawn(process.platform === "win32" ? "cmd.exe" : "/bin/sh", process.platform === "win32" ? ["/c", "exit", "0"] : ["-c", "exit 0"], {
38
+ cwd: tmpdir(),
39
+ env: { HOME: tmpdir(), TERM: "xterm-256color" },
40
+ cols: 80,
41
+ rows: 24,
42
+ name: "xterm-256color",
43
+ });
44
+ await new Promise((resolveExit) => terminal.onExit(() => resolveExit()));
45
+ return { available: true, backend: "node-pty" };
46
+ }
47
+ catch {
48
+ return {
49
+ available: false,
50
+ backend: "node-pty",
51
+ reason: "the native PTY backend could not start a probe process",
52
+ };
53
+ }
54
+ };
55
+ const hashFile = async (path, maxBytes) => {
56
+ const value = await readFile(path);
57
+ if (value.byteLength > maxBytes)
58
+ return null;
59
+ return createHash("sha256").update(value).digest("hex");
60
+ };
61
+ const snapshotRoots = async (scenario) => {
62
+ const entries = [];
63
+ let remainingBytes = scenario.limits.file_bytes;
64
+ let truncated = false;
65
+ const visit = async (root, path) => {
66
+ if (entries.length >= scenario.limits.files) {
67
+ truncated = true;
68
+ return;
69
+ }
70
+ let stats;
71
+ try {
72
+ stats = await lstat(path);
73
+ }
74
+ catch (error) {
75
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
76
+ return;
77
+ throw error;
78
+ }
79
+ const relativePath = relative(root, path) || ".";
80
+ if (stats.isSymbolicLink()) {
81
+ entries.push({
82
+ path: `${root}:${relativePath}`,
83
+ type: "symlink",
84
+ mode: stats.mode,
85
+ size: stats.size,
86
+ sha256: null,
87
+ symlink_target: await readlink(path),
88
+ });
89
+ return;
90
+ }
91
+ if (stats.isFile()) {
92
+ const allowedBytes = Math.max(0, Math.min(remainingBytes, scenario.limits.file_bytes));
93
+ const sha256 = allowedBytes >= stats.size ? await hashFile(path, allowedBytes) : null;
94
+ remainingBytes -= sha256 === null ? 0 : stats.size;
95
+ if (sha256 === null)
96
+ truncated = true;
97
+ entries.push({
98
+ path: `${root}:${relativePath}`,
99
+ type: "file",
100
+ mode: stats.mode,
101
+ size: stats.size,
102
+ sha256,
103
+ symlink_target: null,
104
+ });
105
+ return;
106
+ }
107
+ const type = stats.isDirectory() ? "directory" : "other";
108
+ entries.push({
109
+ path: `${root}:${relativePath}`,
110
+ type,
111
+ mode: stats.mode,
112
+ size: stats.size,
113
+ sha256: null,
114
+ symlink_target: null,
115
+ });
116
+ if (type !== "directory")
117
+ return;
118
+ for (const child of (await readdir(path)).sort())
119
+ await visit(root, join(path, child));
120
+ };
121
+ for (const root of scenario.filesystem_roots)
122
+ await visit(root, root);
123
+ return { files: entries, truncated };
124
+ };
125
+ const sampleProcesses = async (rootPid, elapsedMs, limit) => {
126
+ if (process.platform === "win32")
127
+ return [];
128
+ const { stdout } = await execFileAsync("ps", ["-axo", "pid=,ppid=,command="]);
129
+ const rows = stdout
130
+ .split("\n")
131
+ .map((line) => /\s*(\d+)\s+(\d+)\s+(.*)/.exec(line))
132
+ .filter((match) => match !== null)
133
+ .map((match) => ({
134
+ pid: Number(match[1]),
135
+ parent_pid: Number(match[2]),
136
+ command: match[3] ?? "",
137
+ }));
138
+ const owned = new Set([rootPid]);
139
+ let changed = true;
140
+ while (changed) {
141
+ changed = false;
142
+ for (const row of rows) {
143
+ if (owned.has(row.parent_pid) && !owned.has(row.pid)) {
144
+ owned.add(row.pid);
145
+ changed = true;
146
+ }
147
+ }
148
+ }
149
+ return rows
150
+ .filter((row) => owned.has(row.pid))
151
+ .slice(0, limit)
152
+ .map((row) => ({ at_ms: elapsedMs, ...row }));
153
+ };
154
+ const makeEnvironment = (scenario, home, replay) => {
155
+ const environment = {
156
+ HOME: home,
157
+ TERM: "xterm-256color",
158
+ ...scenario.environment,
159
+ };
160
+ for (const name of scenario.inherit_environment) {
161
+ const value = process.env[name];
162
+ if (value !== undefined)
163
+ environment[name] = value;
164
+ }
165
+ environment.REA_REPLAY_HTTP_URL = replay.httpUrl;
166
+ environment.REA_REPLAY_WEBSOCKET_URL = replay.websocketUrl;
167
+ return environment;
168
+ };
169
+ const normalizeTerminalData = (value, scenario, temporaryRoot, pid) => {
170
+ let normalized = value;
171
+ for (const alias of scenario.secret_aliases) {
172
+ const secret = scenario.environment[alias];
173
+ if (secret !== undefined && secret.length > 0)
174
+ normalized = normalized.replaceAll(secret, "<redacted>");
175
+ }
176
+ if (scenario.normalization.paths)
177
+ normalized = normalized.replaceAll(temporaryRoot, "<temporary-root>");
178
+ if (scenario.normalization.pids)
179
+ normalized = normalized.replaceAll(String(pid), "<pid>");
180
+ if (scenario.normalization.ports)
181
+ normalized = normalized.replaceAll(/(?<=[:=])\d{2,5}\b/g, "<port>");
182
+ for (const pattern of scenario.normalization.patterns)
183
+ normalized = normalized.replaceAll(pattern.pattern, pattern.replacement);
184
+ return normalized;
185
+ };
186
+ const scheduleScenarioEvents = (scenario, getTerminal, timers) => {
187
+ for (const event of scenario.events) {
188
+ const timer = setTimeout(() => {
189
+ const terminal = getTerminal();
190
+ if (event.type === "input")
191
+ terminal?.write(event.data);
192
+ else if (event.type === "resize")
193
+ terminal?.resize(event.columns, event.rows);
194
+ else
195
+ terminal?.kill(event.signal);
196
+ }, event.at_ms);
197
+ timers.add(timer);
198
+ }
199
+ };
200
+ const normalizeSamples = (samples, scenario, rootPid) => {
201
+ const identifiers = [
202
+ rootPid,
203
+ ...samples.flatMap((sample) => [sample.pid, sample.parent_pid]),
204
+ ];
205
+ const mapping = new Map();
206
+ for (const identifier of identifiers)
207
+ if (identifier > 0 && !mapping.has(identifier))
208
+ mapping.set(identifier, mapping.size + 1);
209
+ return samples.map((sample) => ({
210
+ at_ms: Math.floor(sample.at_ms / scenario.normalization.time_bucket_ms) *
211
+ scenario.normalization.time_bucket_ms,
212
+ pid: mapping.get(sample.pid) ?? 1,
213
+ parent_pid: mapping.get(sample.parent_pid) ?? 0,
214
+ command: normalizeTerminalData(sample.command, scenario, "<no-temporary-root>", rootPid),
215
+ }));
216
+ };
217
+ const redactProtocolEvents = (events, scenario) => events.map((event) => ({
218
+ ...event,
219
+ data: normalizeTerminalData(event.data, scenario, "<no-temporary-root>", -1),
220
+ }));
221
+ const buildCaptureResult = (options) => ({
222
+ schema_version: 1,
223
+ frames: options.frames,
224
+ exit: {
225
+ code: options.exit.exitCode < 0 ? null : options.exit.exitCode,
226
+ signal: options.exit.signal ?? null,
227
+ },
228
+ process_samples: normalizeSamples(options.samples, options.scenario, options.rootPid),
229
+ protocol_events: redactProtocolEvents(options.replay.events, options.scenario),
230
+ files_before: options.before.files,
231
+ files_after: options.after.files,
232
+ truncated: options.truncated,
233
+ limitations: [
234
+ "Process trees are sampled and may omit short-lived descendants.",
235
+ "Filesystem observations are before/after snapshots, not syscall traces.",
236
+ "The harness does not enforce external network isolation.",
237
+ ],
238
+ });
239
+ const awaitTerminalExit = async ({ terminal, scenario, started, lastOutput, signal, timers, }) => new Promise((resolveExit) => {
240
+ terminal.onExit(resolveExit);
241
+ const timeout = setInterval(() => {
242
+ if (Date.now() - started >= scenario.timeout_ms ||
243
+ Date.now() - lastOutput() >= scenario.idle_timeout_ms ||
244
+ signal?.aborted === true) {
245
+ terminal.kill("SIGKILL");
246
+ }
247
+ }, 20);
248
+ timers.add(timeout);
249
+ });
250
+ /** Execute one authorized scenario and return bounded observations. */
251
+ const runProcessScenario = async (scenario, policy, signal) => {
252
+ const decision = authorizeProcessScenario(scenario, policy);
253
+ if (!decision.allowed)
254
+ throw new ProcessCaptureError(decision.reason);
255
+ await assertRealPathAuthority(scenario, policy);
256
+ if (signal?.aborted === true)
257
+ throw new ProcessCaptureError("process capture was cancelled");
258
+ const temporaryRoot = await mkdtemp(join(tmpdir(), "rea-process-"));
259
+ const home = join(temporaryRoot, "home");
260
+ await import("node:fs/promises").then(({ mkdir }) => mkdir(home));
261
+ const before = await snapshotRoots(scenario);
262
+ const frames = [];
263
+ const samples = [];
264
+ let outputBytes = 0;
265
+ let truncated = before.truncated;
266
+ let terminal;
267
+ let replay;
268
+ const started = Date.now();
269
+ let lastOutput = started;
270
+ const timers = new Set();
271
+ try {
272
+ replay = await startLoopbackReplay(scenario);
273
+ terminal = spawn(scenario.executable, [...scenario.arguments], {
274
+ cwd: scenario.working_directory,
275
+ env: makeEnvironment(scenario, home, replay),
276
+ cols: 80,
277
+ rows: 24,
278
+ name: "xterm-256color",
279
+ });
280
+ terminal.onData((data) => {
281
+ lastOutput = Date.now();
282
+ const bytes = Buffer.byteLength(data);
283
+ if (frames.length >= scenario.limits.frames ||
284
+ outputBytes + bytes > scenario.limits.output_bytes) {
285
+ truncated = true;
286
+ return;
287
+ }
288
+ outputBytes += bytes;
289
+ frames.push({
290
+ sequence: frames.length,
291
+ at_ms: Math.floor((Date.now() - started) / scenario.normalization.time_bucket_ms) * scenario.normalization.time_bucket_ms,
292
+ data: normalizeTerminalData(data, scenario, temporaryRoot, terminal?.pid ?? -1),
293
+ });
294
+ });
295
+ scheduleScenarioEvents(scenario, () => terminal, timers);
296
+ const sampler = setInterval(() => {
297
+ void sampleProcesses(terminal?.pid ?? -1, Date.now() - started, scenario.limits.processes - samples.length)
298
+ .then((values) => samples.push(...values))
299
+ .catch(() => undefined);
300
+ }, 50);
301
+ timers.add(sampler);
302
+ const exit = await awaitTerminalExit({
303
+ terminal,
304
+ scenario,
305
+ started,
306
+ lastOutput: () => lastOutput,
307
+ signal,
308
+ timers,
309
+ });
310
+ await new Promise((resolveSettle) => setTimeout(resolveSettle, scenario.settle_ms));
311
+ const after = await snapshotRoots(scenario);
312
+ truncated ||=
313
+ after.truncated || samples.length >= scenario.limits.processes;
314
+ return buildCaptureResult({
315
+ frames,
316
+ exit,
317
+ samples,
318
+ replay,
319
+ before,
320
+ after,
321
+ truncated,
322
+ scenario,
323
+ rootPid: terminal.pid,
324
+ });
325
+ }
326
+ catch (cause) {
327
+ terminal?.kill("SIGKILL");
328
+ throw new ProcessCaptureError("process capture failed", { cause });
329
+ }
330
+ finally {
331
+ for (const timer of timers)
332
+ clearTimeout(timer);
333
+ await replay?.close();
334
+ await rm(temporaryRoot, { recursive: true, force: true });
335
+ }
336
+ };
337
+ /** Execute one scenario through a typed expected-failure channel. */
338
+ export const captureProcessScenario = async (scenario, policy, signal) => {
339
+ try {
340
+ return ok(await runProcessScenario(scenario, policy, signal));
341
+ }
342
+ catch (cause) {
343
+ return err(cause instanceof ProcessCaptureError
344
+ ? cause
345
+ : new ProcessCaptureError("process capture failed", { cause }));
346
+ }
347
+ };