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.
- package/README.md +124 -20
- package/bridge/hopper_bridge.py +141 -13
- package/dist/application/AnalysisProvider.js +12 -1
- package/dist/application/ArtifactExtraction.js +166 -0
- package/dist/application/ArtifactGraphConstruction.js +257 -0
- package/dist/application/ArtifactInventory.js +253 -0
- package/dist/application/BinarySession.js +164 -14
- package/dist/application/CompositeProvider.js +73 -0
- package/dist/application/DirectAnalysis.js +27 -3
- package/dist/application/Doctor.js +35 -6
- package/dist/application/EnhancedTools.js +8 -7
- package/dist/application/EvidenceBundleCommands.js +29 -0
- package/dist/application/EvidenceBundleFiles.js +127 -0
- package/dist/application/EvidenceLedger.js +225 -18
- package/dist/application/FilesystemSnapshot.js +124 -0
- package/dist/application/LinuxHopper.js +186 -0
- package/dist/application/LoopbackReplay.js +195 -37
- package/dist/application/ProcessHarness.js +200 -191
- package/dist/application/ProcessNormalization.js +44 -0
- package/dist/application/ProcessOwnership.js +84 -0
- package/dist/application/ProcessSampling.js +284 -0
- package/dist/application/RealHopperAssertions.js +105 -0
- package/dist/application/ReferenceSourceImport.js +182 -0
- package/dist/application/ReferenceSourceImportEntries.js +122 -0
- package/dist/application/ReferenceSourceImportPolicy.js +73 -0
- package/dist/application/ReferenceSourceImportTypes.js +18 -0
- package/dist/application/ReferenceSourceVcsAdapter.js +34 -0
- package/dist/application/Setup.js +192 -41
- package/dist/application/Uninstall.js +130 -0
- package/dist/application/runtime.js +8 -1
- package/dist/artifacts/ArtifactPaths.js +51 -0
- package/dist/artifacts/ArtifactProvider.js +130 -0
- package/dist/artifacts/ArtifactReader.js +9 -0
- package/dist/artifacts/AsarArtifactReader.js +62 -0
- package/dist/artifacts/DirectoryArtifactReader.js +107 -0
- package/dist/artifacts/MachOSliceArtifactReader.js +66 -0
- package/dist/artifacts/SafeOutputTree.js +199 -0
- package/dist/artifacts/StreamBytes.js +10 -0
- package/dist/artifacts/ZipArtifactReader.js +109 -0
- package/dist/cli.js +153 -6
- package/dist/config.js +35 -1
- package/dist/contracts/artifactComparisonExample.js +95 -0
- package/dist/contracts/artifactToolContracts.js +84 -0
- package/dist/contracts/enhancedInputs.js +4 -0
- package/dist/contracts/functionComparisonExample.js +57 -0
- package/dist/contracts/investigationExamples.js +118 -0
- package/dist/contracts/nativeToolContracts.js +52 -0
- package/dist/contracts/processCaptureExample.js +25 -0
- package/dist/contracts/toolContractExamples.js +69 -0
- package/dist/contracts/toolContracts.js +99 -36
- package/dist/contracts/toolOutputSchemas.js +154 -82
- package/dist/contracts/unknownContractExamples.js +33 -0
- package/dist/domain/artifactComparison.js +273 -0
- package/dist/domain/artifactGraph.js +194 -0
- package/dist/domain/artifactInventoryEvidence.js +150 -0
- package/dist/domain/binaryTarget.js +55 -0
- package/dist/domain/bundleComparison.js +266 -0
- package/dist/domain/callPath.js +346 -0
- package/dist/domain/changedBehavior.js +294 -0
- package/dist/domain/errors.js +192 -4
- package/dist/domain/evidence.js +41 -10
- package/dist/domain/evidenceBundle.js +187 -6
- package/dist/domain/functionComparison.js +201 -0
- package/dist/domain/functionComparisonNormalization.js +112 -0
- package/dist/domain/functionComparisonResults.js +54 -0
- package/dist/domain/functionComparisonSchemas.js +82 -0
- package/dist/domain/functionDossierEvidence.js +171 -0
- package/dist/domain/hopperValues.js +14 -7
- package/dist/domain/nativeInspection.js +142 -0
- package/dist/domain/processCapture.js +152 -54
- package/dist/domain/processComparison.js +106 -0
- package/dist/domain/reconstructionUnknowns.js +90 -0
- package/dist/domain/reconstructionVerification.js +285 -0
- package/dist/domain/reconstructionVerificationSchemas.js +126 -0
- package/dist/domain/referenceSourceClassification.js +496 -0
- package/dist/domain/referenceSourceGraph.js +376 -0
- package/dist/domain/referenceSourceImportParsing.js +235 -0
- package/dist/domain/residualUnknown.js +239 -0
- package/dist/domain/staticRuntimeCorrelation.js +375 -0
- package/dist/hopper/BridgeLauncher.js +40 -3
- package/dist/hopper/HopperClient.js +14 -5
- package/dist/hopper/HopperProvider.js +57 -22
- package/dist/identity.js +1 -0
- package/dist/main.js +5 -1
- package/dist/native/CommandRunner.js +156 -0
- package/dist/native/NativeMacOSProvider.js +306 -0
- package/dist/native/NativeMachoInspection.js +135 -0
- package/dist/native/parsers/codesign.js +55 -0
- package/dist/native/parsers/demangle.js +26 -0
- package/dist/native/parsers/dyldInfo.js +25 -0
- package/dist/native/parsers/lipo.js +67 -0
- package/dist/native/parsers/otool.js +193 -0
- package/dist/native/parsers/plist.js +23 -0
- package/dist/reference/ReferenceSourceReader.js +73 -0
- package/dist/reference/ReferenceSourceReaderEntries.js +206 -0
- package/dist/reference/ReferenceSourceReaderErrors.js +19 -0
- package/dist/reference/ReferenceSourceReaderFile.js +119 -0
- package/dist/reference/ReferenceSourceReaderPaths.js +23 -0
- package/dist/reference/ReferenceSourceReaderTypes.js +2 -0
- package/dist/reference/ReferenceSourceReaderValidate.js +71 -0
- package/dist/server/createServer.js +31 -5
- package/dist/server/recordDerivedEvidence.js +10 -0
- package/dist/server/registerArtifactComparisonTool.js +62 -0
- package/dist/server/registerArtifactTools.js +6 -0
- package/dist/server/registerBundleComparisonTool.js +47 -0
- package/dist/server/registerEnhancedTools.js +64 -12
- package/dist/server/registerEvidenceTools.js +36 -0
- package/dist/server/registerFunctionComparisonTool.js +68 -0
- package/dist/server/registerInvestigationTools.js +224 -0
- package/dist/server/registerNativeTools.js +6 -0
- package/dist/server/registerOfficialTools.js +47 -14
- package/dist/server/registerProcessComparisonTool.js +106 -0
- package/dist/server/registerSessionTools.js +179 -70
- package/dist/server/sessionEvidence.js +28 -0
- package/dist/server/sessionToolPolicies.js +64 -0
- package/dist/server/toolRegistrationOptions.js +7 -0
- package/dist/server/toolResult.js +8 -5
- package/install.sh +198 -0
- package/package.json +19 -2
- package/scripts/rea.mjs +5 -1
- package/skills/rea-analysis/SKILL.md +77 -2
- /package/dist/{application/HopperToolPort.js → domain/referenceSourcePolicy.js} +0 -0
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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 (
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
251
|
-
const
|
|
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
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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
|
-
|
|
269
|
-
let lastOutput =
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
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
|
-
|
|
311
|
+
assertNotCancelled(signal);
|
|
312
|
+
const after = await snapshotRoots(scenario, signal);
|
|
312
313
|
truncated ||=
|
|
313
|
-
after.truncated ||
|
|
314
|
-
|
|
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
|
-
|
|
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
|
+
};
|