rea-agents 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +124 -20
  2. package/bridge/hopper_bridge.py +141 -13
  3. package/dist/application/AnalysisProvider.js +12 -1
  4. package/dist/application/ArtifactExtraction.js +166 -0
  5. package/dist/application/ArtifactGraphConstruction.js +257 -0
  6. package/dist/application/ArtifactInventory.js +253 -0
  7. package/dist/application/BinarySession.js +164 -14
  8. package/dist/application/CompositeProvider.js +73 -0
  9. package/dist/application/DirectAnalysis.js +27 -3
  10. package/dist/application/Doctor.js +35 -6
  11. package/dist/application/EnhancedTools.js +8 -7
  12. package/dist/application/EvidenceBundleCommands.js +29 -0
  13. package/dist/application/EvidenceBundleFiles.js +127 -0
  14. package/dist/application/EvidenceLedger.js +225 -18
  15. package/dist/application/FilesystemSnapshot.js +124 -0
  16. package/dist/application/LinuxHopper.js +186 -0
  17. package/dist/application/LoopbackReplay.js +195 -37
  18. package/dist/application/ProcessHarness.js +200 -191
  19. package/dist/application/ProcessNormalization.js +44 -0
  20. package/dist/application/ProcessOwnership.js +84 -0
  21. package/dist/application/ProcessSampling.js +284 -0
  22. package/dist/application/RealHopperAssertions.js +105 -0
  23. package/dist/application/ReferenceSourceImport.js +182 -0
  24. package/dist/application/ReferenceSourceImportEntries.js +122 -0
  25. package/dist/application/ReferenceSourceImportPolicy.js +73 -0
  26. package/dist/application/ReferenceSourceImportTypes.js +18 -0
  27. package/dist/application/ReferenceSourceVcsAdapter.js +34 -0
  28. package/dist/application/Setup.js +192 -41
  29. package/dist/application/Uninstall.js +130 -0
  30. package/dist/application/runtime.js +8 -1
  31. package/dist/artifacts/ArtifactPaths.js +51 -0
  32. package/dist/artifacts/ArtifactProvider.js +130 -0
  33. package/dist/artifacts/ArtifactReader.js +9 -0
  34. package/dist/artifacts/AsarArtifactReader.js +62 -0
  35. package/dist/artifacts/DirectoryArtifactReader.js +107 -0
  36. package/dist/artifacts/MachOSliceArtifactReader.js +66 -0
  37. package/dist/artifacts/SafeOutputTree.js +199 -0
  38. package/dist/artifacts/StreamBytes.js +10 -0
  39. package/dist/artifacts/ZipArtifactReader.js +109 -0
  40. package/dist/cli.js +153 -6
  41. package/dist/config.js +35 -1
  42. package/dist/contracts/artifactComparisonExample.js +95 -0
  43. package/dist/contracts/artifactToolContracts.js +84 -0
  44. package/dist/contracts/enhancedInputs.js +4 -0
  45. package/dist/contracts/functionComparisonExample.js +57 -0
  46. package/dist/contracts/investigationExamples.js +118 -0
  47. package/dist/contracts/nativeToolContracts.js +52 -0
  48. package/dist/contracts/processCaptureExample.js +25 -0
  49. package/dist/contracts/toolContractExamples.js +69 -0
  50. package/dist/contracts/toolContracts.js +99 -36
  51. package/dist/contracts/toolOutputSchemas.js +154 -82
  52. package/dist/contracts/unknownContractExamples.js +33 -0
  53. package/dist/domain/artifactComparison.js +273 -0
  54. package/dist/domain/artifactGraph.js +194 -0
  55. package/dist/domain/artifactInventoryEvidence.js +150 -0
  56. package/dist/domain/binaryTarget.js +55 -0
  57. package/dist/domain/bundleComparison.js +266 -0
  58. package/dist/domain/callPath.js +346 -0
  59. package/dist/domain/changedBehavior.js +294 -0
  60. package/dist/domain/errors.js +192 -4
  61. package/dist/domain/evidence.js +41 -10
  62. package/dist/domain/evidenceBundle.js +187 -6
  63. package/dist/domain/functionComparison.js +201 -0
  64. package/dist/domain/functionComparisonNormalization.js +112 -0
  65. package/dist/domain/functionComparisonResults.js +54 -0
  66. package/dist/domain/functionComparisonSchemas.js +82 -0
  67. package/dist/domain/functionDossierEvidence.js +171 -0
  68. package/dist/domain/hopperValues.js +14 -7
  69. package/dist/domain/nativeInspection.js +142 -0
  70. package/dist/domain/processCapture.js +152 -54
  71. package/dist/domain/processComparison.js +106 -0
  72. package/dist/domain/reconstructionUnknowns.js +90 -0
  73. package/dist/domain/reconstructionVerification.js +285 -0
  74. package/dist/domain/reconstructionVerificationSchemas.js +126 -0
  75. package/dist/domain/referenceSourceClassification.js +496 -0
  76. package/dist/domain/referenceSourceGraph.js +376 -0
  77. package/dist/domain/referenceSourceImportParsing.js +235 -0
  78. package/dist/domain/residualUnknown.js +239 -0
  79. package/dist/domain/staticRuntimeCorrelation.js +375 -0
  80. package/dist/hopper/BridgeLauncher.js +40 -3
  81. package/dist/hopper/HopperClient.js +14 -5
  82. package/dist/hopper/HopperProvider.js +57 -22
  83. package/dist/identity.js +1 -0
  84. package/dist/main.js +5 -1
  85. package/dist/native/CommandRunner.js +156 -0
  86. package/dist/native/NativeMacOSProvider.js +306 -0
  87. package/dist/native/NativeMachoInspection.js +135 -0
  88. package/dist/native/parsers/codesign.js +55 -0
  89. package/dist/native/parsers/demangle.js +26 -0
  90. package/dist/native/parsers/dyldInfo.js +25 -0
  91. package/dist/native/parsers/lipo.js +67 -0
  92. package/dist/native/parsers/otool.js +193 -0
  93. package/dist/native/parsers/plist.js +23 -0
  94. package/dist/reference/ReferenceSourceReader.js +73 -0
  95. package/dist/reference/ReferenceSourceReaderEntries.js +206 -0
  96. package/dist/reference/ReferenceSourceReaderErrors.js +19 -0
  97. package/dist/reference/ReferenceSourceReaderFile.js +119 -0
  98. package/dist/reference/ReferenceSourceReaderPaths.js +23 -0
  99. package/dist/reference/ReferenceSourceReaderTypes.js +2 -0
  100. package/dist/reference/ReferenceSourceReaderValidate.js +71 -0
  101. package/dist/server/createServer.js +31 -5
  102. package/dist/server/recordDerivedEvidence.js +10 -0
  103. package/dist/server/registerArtifactComparisonTool.js +62 -0
  104. package/dist/server/registerArtifactTools.js +6 -0
  105. package/dist/server/registerBundleComparisonTool.js +47 -0
  106. package/dist/server/registerEnhancedTools.js +64 -12
  107. package/dist/server/registerEvidenceTools.js +36 -0
  108. package/dist/server/registerFunctionComparisonTool.js +68 -0
  109. package/dist/server/registerInvestigationTools.js +224 -0
  110. package/dist/server/registerNativeTools.js +6 -0
  111. package/dist/server/registerOfficialTools.js +47 -14
  112. package/dist/server/registerProcessComparisonTool.js +106 -0
  113. package/dist/server/registerSessionTools.js +179 -70
  114. package/dist/server/sessionEvidence.js +28 -0
  115. package/dist/server/sessionToolPolicies.js +64 -0
  116. package/dist/server/toolRegistrationOptions.js +7 -0
  117. package/dist/server/toolResult.js +8 -5
  118. package/install.sh +198 -0
  119. package/package.json +19 -2
  120. package/scripts/rea.mjs +5 -1
  121. package/skills/rea-analysis/SKILL.md +77 -2
  122. /package/dist/{application/HopperToolPort.js → domain/referenceSourcePolicy.js} +0 -0
@@ -1,15 +1,16 @@
1
- import { createHash } from "node:crypto";
2
- import { lstat, mkdtemp, readdir, readFile, readlink, realpath, rm, } from "node:fs/promises";
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdtemp, realpath, rm } from "node:fs/promises";
3
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";
4
+ import { join } from "node:path";
7
5
  import { spawn } from "node-pty";
8
6
  import { authorizeProcessScenario } from "../domain/processCapture.js";
9
7
  import { err, ok } from "../domain/result.js";
10
8
  import { AnalysisError } from "../domain/errors.js";
11
9
  import { startLoopbackReplay } from "./LoopbackReplay.js";
12
- const execFileAsync = promisify(execFile);
10
+ import { cleanupOwnedProcessGroup } from "./ProcessOwnership.js";
11
+ import { startProcessSampler } from "./ProcessSampling.js";
12
+ import { snapshotRoots } from "./FilesystemSnapshot.js";
13
+ import { normalizeProcessSamples, normalizeProcessText, redactProtocolEvents, } from "./ProcessNormalization.js";
13
14
  const isWithin = (candidate, root) => candidate === root ||
14
15
  candidate.startsWith(`${root.endsWith("/") ? root.slice(0, -1) : root}/`);
15
16
  const assertRealPathAuthority = async (scenario, policy) => {
@@ -31,6 +32,10 @@ const assertRealPathAuthority = async (scenario, policy) => {
31
32
  export class ProcessCaptureError extends AnalysisError {
32
33
  _tag = "ProcessCaptureError";
33
34
  }
35
+ const assertNotCancelled = (signal) => {
36
+ if (signal?.aborted === true)
37
+ throw new ProcessCaptureError("process capture was cancelled");
38
+ };
34
39
  /** Probe the actual native PTY seam instead of inferring support from the OS name. */
35
40
  export const probeProcessCaptureCapability = async () => {
36
41
  try {
@@ -52,110 +57,12 @@ export const probeProcessCaptureCapability = async () => {
52
57
  };
53
58
  }
54
59
  };
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) => {
60
+ const makeEnvironment = (scenario, home, replay, runId) => {
155
61
  const environment = {
62
+ ...scenario.environment,
156
63
  HOME: home,
157
64
  TERM: "xterm-256color",
158
- ...scenario.environment,
65
+ REA_PROCESS_RUN_ID: runId,
159
66
  };
160
67
  for (const name of scenario.inherit_environment) {
161
68
  const value = process.env[name];
@@ -166,23 +73,6 @@ const makeEnvironment = (scenario, home, replay) => {
166
73
  environment.REA_REPLAY_WEBSOCKET_URL = replay.websocketUrl;
167
74
  return environment;
168
75
  };
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
76
  const scheduleScenarioEvents = (scenario, getTerminal, timers) => {
187
77
  for (const event of scenario.events) {
188
78
  const timer = setTimeout(() => {
@@ -197,108 +87,214 @@ const scheduleScenarioEvents = (scenario, getTerminal, timers) => {
197
87
  timers.add(timer);
198
88
  }
199
89
  };
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
- }));
90
+ const startScenarioEvents = (scenario, getTerminal, timers) => {
91
+ let started = false;
92
+ return () => {
93
+ if (started)
94
+ return;
95
+ started = true;
96
+ scheduleScenarioEvents(scenario, getTerminal, timers);
97
+ };
98
+ };
99
+ const eventsRequireReadiness = (scenario) => scenario.events.some((event) => event.type === "input" || event.type === "resize");
100
+ const classifyFilesystemEffects = (before, after) => {
101
+ const beforeByPath = new Map(before.map((file) => [file.path, file]));
102
+ const afterByPath = new Map(after.map((file) => [file.path, file]));
103
+ const paths = [
104
+ ...new Set([...beforeByPath.keys(), ...afterByPath.keys()]),
105
+ ].sort();
106
+ return paths.map((path) => {
107
+ const beforeFile = beforeByPath.get(path) ?? null;
108
+ const afterFile = afterByPath.get(path) ?? null;
109
+ const status = beforeFile === null
110
+ ? "created"
111
+ : afterFile === null
112
+ ? "deleted"
113
+ : JSON.stringify(beforeFile) === JSON.stringify(afterFile)
114
+ ? "unchanged"
115
+ : "modified";
116
+ return { path, status, before: beforeFile, after: afterFile };
117
+ });
216
118
  };
217
- const redactProtocolEvents = (events, scenario) => events.map((event) => ({
218
- ...event,
219
- data: normalizeTerminalData(event.data, scenario, "<no-temporary-root>", -1),
220
- }));
221
119
  const buildCaptureResult = (options) => ({
222
- schema_version: 1,
120
+ schema_version: 2,
121
+ normalization: options.scenario.normalization,
223
122
  frames: options.frames,
224
123
  exit: {
225
124
  code: options.exit.exitCode < 0 ? null : options.exit.exitCode,
226
125
  signal: options.exit.signal ?? null,
126
+ reason: options.exit.reason,
227
127
  },
228
- process_samples: normalizeSamples(options.samples, options.scenario, options.rootPid),
128
+ process_samples: normalizeProcessSamples(options.samples, options.scenario, options.rootPid),
229
129
  protocol_events: redactProtocolEvents(options.replay.events, options.scenario),
230
130
  files_before: options.before.files,
231
131
  files_after: options.after.files,
132
+ filesystem_effects: classifyFilesystemEffects(options.before.files, options.after.files),
232
133
  truncated: options.truncated,
233
134
  limitations: [
234
135
  "Process trees are sampled and may omit short-lived descendants.",
136
+ ...(options.samplingPartial
137
+ ? ["Process-tree sampling ended with an incomplete observation."]
138
+ : []),
235
139
  "Filesystem observations are before/after snapshots, not syscall traces.",
236
140
  "The harness does not enforce external network isolation.",
237
141
  ],
142
+ residual_unknowns: [
143
+ {
144
+ scope: "process",
145
+ reason: "Process trees are sampled and may omit short-lived descendants.",
146
+ },
147
+ {
148
+ scope: "network",
149
+ reason: "External network isolation is not enforced by this adapter.",
150
+ },
151
+ ],
152
+ cleanup: {
153
+ owned_process_group: "verified",
154
+ temporary_root: "removed",
155
+ },
238
156
  });
239
157
  const awaitTerminalExit = async ({ terminal, scenario, started, lastOutput, signal, timers, }) => new Promise((resolveExit) => {
240
- terminal.onExit(resolveExit);
158
+ let reason = "exited";
159
+ terminal.onExit((exit) => {
160
+ for (const timer of timers) {
161
+ clearTimeout(timer);
162
+ clearInterval(timer);
163
+ }
164
+ timers.clear();
165
+ resolveExit({ ...exit, reason });
166
+ });
241
167
  const timeout = setInterval(() => {
242
- if (Date.now() - started >= scenario.timeout_ms ||
243
- Date.now() - lastOutput() >= scenario.idle_timeout_ms ||
244
- signal?.aborted === true) {
168
+ if (signal?.aborted === true) {
169
+ reason = "cancelled";
170
+ terminal.kill("SIGKILL");
171
+ }
172
+ else if (Date.now() - started >= scenario.timeout_ms) {
173
+ reason = "timeout";
174
+ terminal.kill("SIGKILL");
175
+ }
176
+ else if (Date.now() - lastOutput() >= scenario.idle_timeout_ms) {
177
+ reason = "idle_timeout";
245
178
  terminal.kill("SIGKILL");
246
179
  }
247
180
  }, 20);
248
181
  timers.add(timeout);
249
182
  });
250
- /** Execute one authorized scenario and return bounded observations. */
251
- const runProcessScenario = async (scenario, policy, signal) => {
183
+ const releaseProcessResources = async (options) => {
184
+ for (const timer of options.timers)
185
+ clearTimeout(timer);
186
+ let failure;
187
+ try {
188
+ await options.replay?.close();
189
+ }
190
+ catch {
191
+ failure = "loopback replay cleanup failed";
192
+ }
193
+ if (options.terminal !== undefined && process.platform !== "win32") {
194
+ const cleaned = await cleanupOwnedProcessGroup({
195
+ runId: options.runId,
196
+ leaderPid: options.terminal.pid,
197
+ processGroupId: options.terminal.pid,
198
+ });
199
+ if (!cleaned.cleaned)
200
+ failure ??= cleaned.reason;
201
+ }
202
+ try {
203
+ await rm(options.temporaryRoot, { recursive: true, force: true });
204
+ }
205
+ catch {
206
+ failure ??= "temporary process root cleanup failed";
207
+ }
208
+ return failure;
209
+ };
210
+ const captureTerminalFrames = (options) => {
211
+ let outputBytes = 0;
212
+ let truncated = false;
213
+ options.terminal.onData((data) => {
214
+ options.onFirstOutput();
215
+ options.onOutput();
216
+ const bytes = Buffer.byteLength(data);
217
+ if (options.frames.length >= options.scenario.limits.frames ||
218
+ outputBytes + bytes > options.scenario.limits.output_bytes) {
219
+ truncated = true;
220
+ return;
221
+ }
222
+ outputBytes += bytes;
223
+ options.frames.push({
224
+ sequence: options.frames.length,
225
+ at_ms: Math.floor((Date.now() - options.started) /
226
+ options.scenario.normalization.time_bucket_ms) * options.scenario.normalization.time_bucket_ms,
227
+ data: normalizeProcessText(data, options.scenario, options.temporaryRoot, options.terminal.pid),
228
+ });
229
+ });
230
+ return () => truncated;
231
+ };
232
+ const resolveProcessResult = (capture, executionFailure, cleanupFailure) => {
233
+ if (executionFailure instanceof ProcessCaptureError)
234
+ throw executionFailure;
235
+ if (executionFailure !== undefined)
236
+ throw new ProcessCaptureError("process capture failed", {
237
+ cause: executionFailure,
238
+ });
239
+ if (cleanupFailure !== undefined)
240
+ throw new ProcessCaptureError(cleanupFailure);
241
+ if (capture === undefined)
242
+ throw new ProcessCaptureError("process capture produced no result");
243
+ return capture;
244
+ };
245
+ const prepareProcessCapture = async (scenario, policy, signal) => {
252
246
  const decision = authorizeProcessScenario(scenario, policy);
253
247
  if (!decision.allowed)
254
248
  throw new ProcessCaptureError(decision.reason);
255
249
  await assertRealPathAuthority(scenario, policy);
256
- if (signal?.aborted === true)
257
- throw new ProcessCaptureError("process capture was cancelled");
250
+ assertNotCancelled(signal);
251
+ const before = await snapshotRoots(scenario, signal);
258
252
  const temporaryRoot = await mkdtemp(join(tmpdir(), "rea-process-"));
253
+ const runId = randomUUID();
259
254
  const home = join(temporaryRoot, "home");
260
255
  await import("node:fs/promises").then(({ mkdir }) => mkdir(home));
261
- const before = await snapshotRoots(scenario);
256
+ return { temporaryRoot, runId, home, before };
257
+ };
258
+ /** Execute one authorized scenario and return bounded observations. */
259
+ const runProcessScenario = async (scenario, policy, signal) => {
260
+ const { temporaryRoot, runId, home, before } = await prepareProcessCapture(scenario, policy, signal);
262
261
  const frames = [];
263
262
  const samples = [];
264
- let outputBytes = 0;
265
263
  let truncated = before.truncated;
266
264
  let terminal;
267
265
  let replay;
268
- const started = Date.now();
269
- let lastOutput = started;
266
+ let started = 0;
267
+ let lastOutput = 0;
270
268
  const timers = new Set();
269
+ let capture;
270
+ let executionFailure;
271
+ let framesTruncated = () => false;
272
+ let stopSampler = async () => ({ partial: false });
273
+ let samplingPartial = false;
271
274
  try {
272
275
  replay = await startLoopbackReplay(scenario);
276
+ started = Date.now();
277
+ lastOutput = started;
273
278
  terminal = spawn(scenario.executable, [...scenario.arguments], {
274
279
  cwd: scenario.working_directory,
275
- env: makeEnvironment(scenario, home, replay),
280
+ env: makeEnvironment(scenario, home, replay, runId),
276
281
  cols: 80,
277
282
  rows: 24,
278
283
  name: "xterm-256color",
279
284
  });
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
- });
285
+ const startEvents = startScenarioEvents(scenario, () => terminal, timers);
286
+ if (!eventsRequireReadiness(scenario))
287
+ startEvents();
288
+ framesTruncated = captureTerminalFrames({
289
+ terminal,
290
+ scenario,
291
+ frames,
292
+ started,
293
+ temporaryRoot,
294
+ onOutput: () => (lastOutput = Date.now()),
295
+ onFirstOutput: startEvents,
294
296
  });
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);
297
+ stopSampler = startProcessSampler(terminal.pid, started, scenario.limits.processes, samples);
302
298
  const exit = await awaitTerminalExit({
303
299
  terminal,
304
300
  scenario,
@@ -307,13 +303,22 @@ const runProcessScenario = async (scenario, policy, signal) => {
307
303
  signal,
308
304
  timers,
309
305
  });
306
+ const exitReason = exit.reason;
307
+ samplingPartial = (await stopSampler()).partial;
308
+ if (exitReason === "cancelled")
309
+ throw new ProcessCaptureError("process capture was cancelled");
310
310
  await new Promise((resolveSettle) => setTimeout(resolveSettle, scenario.settle_ms));
311
- const after = await snapshotRoots(scenario);
311
+ assertNotCancelled(signal);
312
+ const after = await snapshotRoots(scenario, signal);
312
313
  truncated ||=
313
- after.truncated || samples.length >= scenario.limits.processes;
314
- return buildCaptureResult({
314
+ after.truncated ||
315
+ replay.truncated ||
316
+ framesTruncated() ||
317
+ samples.length >= scenario.limits.processes;
318
+ truncated ||= samplingPartial;
319
+ capture = buildCaptureResult({
315
320
  frames,
316
- exit,
321
+ exit: { ...exit, reason: exitReason },
317
322
  samples,
318
323
  replay,
319
324
  before,
@@ -321,18 +326,22 @@ const runProcessScenario = async (scenario, policy, signal) => {
321
326
  truncated,
322
327
  scenario,
323
328
  rootPid: terminal.pid,
329
+ samplingPartial,
324
330
  });
325
331
  }
326
332
  catch (cause) {
327
333
  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 });
334
+ executionFailure = cause;
335
335
  }
336
+ samplingPartial ||= (await stopSampler()).partial;
337
+ const cleanupFailure = await releaseProcessResources({
338
+ timers,
339
+ replay,
340
+ terminal,
341
+ runId,
342
+ temporaryRoot,
343
+ });
344
+ return resolveProcessResult(capture, executionFailure, cleanupFailure);
336
345
  };
337
346
  /** Execute one scenario through a typed expected-failure channel. */
338
347
  export const captureProcessScenario = async (scenario, policy, signal) => {
@@ -0,0 +1,44 @@
1
+ /** Normalize and redact one terminal/protocol payload under scenario rules. */
2
+ export const normalizeProcessText = (value, scenario, temporaryRoot, pid) => {
3
+ let normalized = value;
4
+ for (const alias of scenario.secret_aliases) {
5
+ const secret = scenario.environment[alias];
6
+ if (secret !== undefined && secret.length > 0)
7
+ normalized = normalized.replaceAll(secret, "<redacted>");
8
+ }
9
+ if (scenario.normalization.paths) {
10
+ normalized = normalized.replaceAll(temporaryRoot, "<temporary-root>");
11
+ normalized = normalized.replaceAll(scenario.working_directory, "<working-directory>");
12
+ normalized = normalized.replaceAll(scenario.executable, "<executable>");
13
+ for (const [index, root] of scenario.filesystem_roots.entries())
14
+ normalized = normalized.replaceAll(root, `<filesystem-root-${String(index)}>`);
15
+ }
16
+ if (scenario.normalization.pids)
17
+ normalized = normalized.replaceAll(String(pid), "<pid>");
18
+ if (scenario.normalization.ports)
19
+ normalized = normalized.replaceAll(/(?<=[:=])\d{2,5}\b/g, "<port>");
20
+ for (const pattern of scenario.normalization.patterns)
21
+ normalized = normalized.replaceAll(pattern.pattern, pattern.replacement);
22
+ return normalized;
23
+ };
24
+ export const normalizeProcessSamples = (samples, scenario, rootPid) => {
25
+ const identifiers = [
26
+ rootPid,
27
+ ...samples.flatMap((sample) => [sample.pid, sample.parent_pid]),
28
+ ];
29
+ const mapping = new Map();
30
+ for (const identifier of identifiers)
31
+ if (identifier > 0 && !mapping.has(identifier))
32
+ mapping.set(identifier, mapping.size + 1);
33
+ return samples.map((sample) => ({
34
+ at_ms: Math.floor(sample.at_ms / scenario.normalization.time_bucket_ms) *
35
+ scenario.normalization.time_bucket_ms,
36
+ pid: mapping.get(sample.pid) ?? 1,
37
+ parent_pid: mapping.get(sample.parent_pid) ?? 0,
38
+ command: normalizeProcessText(sample.command, scenario, "<no-temporary-root>", rootPid),
39
+ }));
40
+ };
41
+ export const redactProtocolEvents = (events, scenario) => events.map((event) => ({
42
+ ...event,
43
+ data: normalizeProcessText(event.data, scenario, "<no-temporary-root>", -1),
44
+ }));
@@ -0,0 +1,84 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import { promisify } from "node:util";
4
+ const execFileAsync = promisify(execFile);
5
+ const parseEnvironment = (value) => Object.fromEntries(value
6
+ .split("\0")
7
+ .filter((entry) => entry.includes("="))
8
+ .map((entry) => {
9
+ const separator = entry.indexOf("=");
10
+ return [entry.slice(0, separator), entry.slice(separator + 1)];
11
+ }));
12
+ const systemHost = {
13
+ async listMembers(processGroupId) {
14
+ if (process.platform === "win32")
15
+ return [];
16
+ const { stdout } = await execFileAsync("ps", [
17
+ "-axo",
18
+ "pid=,ppid=,pgid=,command=",
19
+ ]);
20
+ return stdout
21
+ .split("\n")
22
+ .map((line) => /\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)/u.exec(line))
23
+ .filter((match) => match !== null)
24
+ .map((match) => ({
25
+ pid: Number(match[1]),
26
+ parentPid: Number(match[2]),
27
+ processGroupId: Number(match[3]),
28
+ command: match[4] ?? "",
29
+ }))
30
+ .filter((member) => member.processGroupId === processGroupId);
31
+ },
32
+ async environment(pid) {
33
+ if (process.platform === "linux") {
34
+ return parseEnvironment(await readFile(`/proc/${pid}/environ`, "utf8"));
35
+ }
36
+ const { stdout } = await execFileAsync("ps", ["eww", "-p", String(pid)]);
37
+ const environment = {};
38
+ for (const match of stdout.matchAll(/(?:^|\s)([A-Za-z_][A-Za-z0-9_]*)=([^\s]*)/gu))
39
+ environment[match[1]] = match[2] ?? "";
40
+ return environment;
41
+ },
42
+ signalGroup(processGroupId, signal) {
43
+ process.kill(-processGroupId, signal);
44
+ },
45
+ };
46
+ /** Verify run-token ownership before signaling a POSIX process group. */
47
+ export const cleanupOwnedProcessGroup = async (ownership, host = systemHost) => {
48
+ let members;
49
+ try {
50
+ members = await host.listMembers(ownership.processGroupId);
51
+ }
52
+ catch {
53
+ return { cleaned: false, reason: "process group could not be inspected" };
54
+ }
55
+ if (members.length === 0)
56
+ return { cleaned: true, signaled: false };
57
+ for (const member of members) {
58
+ let environment;
59
+ try {
60
+ environment = await host.environment(member.pid);
61
+ }
62
+ catch {
63
+ return {
64
+ cleaned: false,
65
+ reason: "process ownership could not be revalidated",
66
+ };
67
+ }
68
+ if (environment.REA_PROCESS_RUN_ID !== ownership.runId) {
69
+ return {
70
+ cleaned: false,
71
+ reason: "process group contains an unowned or PID-reused process",
72
+ };
73
+ }
74
+ }
75
+ try {
76
+ host.signalGroup(ownership.processGroupId, "SIGKILL");
77
+ }
78
+ catch (cause) {
79
+ const code = cause instanceof Error && "code" in cause ? cause.code : undefined;
80
+ if (code !== "ESRCH")
81
+ return { cleaned: false, reason: "owned process group signal failed" };
82
+ }
83
+ return { cleaned: true, signaled: true };
84
+ };