rea-agents 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +21 -2
  2. package/dist/application/ArtifactInventory.js +80 -44
  3. package/dist/application/CommandShimReplay.js +136 -0
  4. package/dist/application/EvidenceBundleFiles.js +9 -1
  5. package/dist/application/ProcessCaptureAuthority.js +25 -0
  6. package/dist/application/ProcessCaptureCapability.js +23 -0
  7. package/dist/application/ProcessCaptureError.js +5 -0
  8. package/dist/application/ProcessCaptureLifecycle.js +269 -0
  9. package/dist/application/ProcessCheckpoints.js +129 -0
  10. package/dist/application/ProcessCli.js +61 -0
  11. package/dist/application/ProcessEvidence.js +38 -0
  12. package/dist/application/ProcessHarness.js +252 -272
  13. package/dist/application/ProcessNormalization.js +10 -1
  14. package/dist/application/ProcessOwnership.js +39 -1
  15. package/dist/application/ProcessSampling.js +39 -23
  16. package/dist/application/Setup.js +51 -76
  17. package/dist/application/SetupSkill.js +44 -0
  18. package/dist/application/TerminalRenderer.js +92 -0
  19. package/dist/application/runtime.js +1 -1
  20. package/dist/artifacts/ArtifactProvider.js +18 -6
  21. package/dist/artifacts/ArtifactReader.js +3 -1
  22. package/dist/artifacts/AsarArtifactReader.js +8 -1
  23. package/dist/artifacts/DirectoryArtifactReader.js +1 -0
  24. package/dist/artifacts/MachOSliceArtifactReader.js +1 -0
  25. package/dist/artifacts/NativeDmgArtifactReader.js +151 -0
  26. package/dist/artifacts/ZipArtifactReader.js +1 -0
  27. package/dist/cli.js +29 -24
  28. package/dist/cliProcessCommands.js +19 -0
  29. package/dist/config.js +2 -0
  30. package/dist/contracts/artifactToolContracts.js +1 -0
  31. package/dist/contracts/investigationExamples.js +3 -3
  32. package/dist/contracts/processCaptureExample.js +50 -7
  33. package/dist/contracts/toolContracts.js +3 -2
  34. package/dist/domain/changedBehavior.js +2 -2
  35. package/dist/domain/errors.js +11 -3
  36. package/dist/domain/processCapture.js +99 -293
  37. package/dist/domain/processCaptureValidation.js +124 -0
  38. package/dist/domain/processComparison.js +176 -32
  39. package/dist/domain/processScenario.js +370 -0
  40. package/dist/domain/reconstructionVerification.js +3 -3
  41. package/dist/domain/staticRuntimeCorrelation.js +3 -2
  42. package/dist/identity.js +1 -0
  43. package/dist/server/registerProcessComparisonTool.js +28 -8
  44. package/dist/server/registerSessionTools.js +3 -25
  45. package/dist/server/sessionToolPolicies.js +1 -6
  46. package/package.json +5 -1
  47. package/scripts/prepare-node-pty.mjs +35 -0
  48. package/skills/rea-analysis/SKILL.md +19 -4
package/README.md CHANGED
@@ -199,6 +199,7 @@ Uninstall preserves Hopper, Node.js, evidence, captures, external evidence roots
199
199
  | Inspect or decompile one part of an app from the Terminal | `rea analyze` or `rea decompile` |
200
200
  | Validate, canonicalize, or compare Evidence v2 bundles | `rea evidence-import`, `rea evidence-export`, or `rea compare` |
201
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` |
202
203
 
203
204
  Filesystem evidence commands and MCP file tools are disabled until the operator approves absolute roots:
204
205
 
@@ -267,12 +268,12 @@ The public interface describes what the agent is trying to learn. Providers deci
267
268
  REA is already useful for native application investigation on macOS:
268
269
 
269
270
  - Open Mach-O, ELF, PE, `.app`, ZIP, APK, IPA, ASAR, plist, JavaScript, source-map, and Hopper database targets.
270
- - Traverse content-addressed artifact graphs without extraction; materialize only approved occurrences into absent output roots.
271
+ - Traverse content-addressed artifact graphs without extraction; on macOS, read-only DMG traversal additionally requires `native_mount_approved: true` and `REA_ARTIFACT_NATIVE_MOUNT_ENABLED=true`. Materialize only approved occurrences into absent output roots.
271
272
  - Build bounded function dossiers with pseudocode, assembly, CFG edges, comments, calls, references, strings, and names.
272
273
  - Search and trace features across symbols, strings, metadata, references, and call paths.
273
274
  - Record every successful result as deterministic Evidence v2 with artifact and provider identity, confidence, authority, limitations, and locations.
274
275
  - Export and import evidence bundles across sessions.
275
- - Capture approved PTY scenarios, child processes, filesystem changes, and loopback HTTP/WebSocket exchanges, then compare normalized captures.
276
+ - Capture approved PTY scenarios as Process Capture v4 Evidence, including committed run manifests, raw and rendered terminal frames, scripted interactions, descendant settlement, named filesystem checkpoints, deterministic command shims, and loopback HTTP/WebSocket exchanges.
276
277
  - Compare complete artifact inventories by stable path, content, metadata, and relations; incomplete evidence never implies equivalence.
277
278
  - Compare explicit function dossiers across text, calls, references, strings, and address-normalized CFG topology with per-facet unknowns.
278
279
  - Compare canonical Evidence bundles by exact membership, explicit observation pairs, and residual-unknown histories without turning omissions into behavioral absence.
@@ -389,10 +390,28 @@ environment allowlist in `REA_PROCESS_ALLOWED_ENV_JSON`. Because the current PTY
389
390
  adapter uses host networking, it also requires
390
391
  `REA_PROCESS_ALLOW_EXTERNAL_NETWORK=true`.
391
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
+
392
406
  If the native PTY backend is unavailable, install Xcode command-line tools and
393
407
  run `npm run rebuild:native`. Linux source builds require Python, `make`, and a
394
408
  C++ toolchain. Compatible packaged binaries do not require this rebuild.
395
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
+
396
415
  ## Security model
397
416
 
398
417
  REA does not provide a hosted analysis service. Hopper communication uses an authenticated private local socket. Dynamic capabilities are disabled by default and require both operator policy and explicit per-call approval. REA is not a security sandbox: providers and launched targets run with the current user's permissions, and each capability reports its side effects and limitations. Report vulnerabilities through the private process in [SECURITY.md](SECURITY.md).
@@ -7,16 +7,21 @@ import { ArtifactReaderFailure, } from "../artifacts/ArtifactReader.js";
7
7
  import { DirectoryArtifactReader } from "../artifacts/DirectoryArtifactReader.js";
8
8
  import { ZipArtifactReader } from "../artifacts/ZipArtifactReader.js";
9
9
  import { MachOSliceArtifactReader } from "../artifacts/MachOSliceArtifactReader.js";
10
+ import { NativeDmgArtifactReader } from "../artifacts/NativeDmgArtifactReader.js";
10
11
  import { streamChunkToBuffer } from "../artifacts/StreamBytes.js";
11
12
  import { artifactInventoryResultSchema, } from "../domain/artifactGraph.js";
12
13
  import { classifyArtifactPath, classifyArtifactContent, createArtifactEdges, createArtifactNode, createOccurrence, createRootNode, digestCanonical, materializeDirectoryNodes, nearestParent, pageOf, rekeyOccurrences, rootOccurrenceFor, toOutputLimits, } from "./ArtifactGraphConstruction.js";
14
+ const NATIVE_MOUNT_DISABLED = {
15
+ nativeMountApproved: false,
16
+ nativeMountEnabled: false,
17
+ };
13
18
  /** Inventory one local artifact without extracting or mounting it. */
14
- export const inventoryArtifact = async (inputPath, limits, page, signal) => {
15
- const snapshot = await scanArtifactInventory(inputPath, limits, signal);
19
+ export const inventoryArtifact = async (inputPath, limits, page, options = {}) => {
20
+ const snapshot = await scanArtifactInventory(inputPath, limits, options.signal, options.nativeMount ?? NATIVE_MOUNT_DISABLED);
16
21
  return paginateArtifactInventory(snapshot, page);
17
22
  };
18
23
  /** Scan an artifact once and retain the complete immutable graph for projection. */
19
- export const scanArtifactInventory = async (inputPath, limits, signal) => {
24
+ export const scanArtifactInventory = async (inputPath, limits, signal, nativeMount = NATIVE_MOUNT_DISABLED) => {
20
25
  abortIfNeeded(signal);
21
26
  const path = await realpath(inputPath);
22
27
  const metadata = await lstat(path);
@@ -24,7 +29,7 @@ export const scanArtifactInventory = async (inputPath, limits, signal) => {
24
29
  const rootDigest = metadata.isDirectory()
25
30
  ? null
26
31
  : await hashReadable(createReadStream(path), limits.maxTotalBytes, signal);
27
- const reader = await createReader(path, rootFormat);
32
+ const reader = await createReader(path, rootFormat, nativeMount, signal);
28
33
  try {
29
34
  const { nodes, occurrences } = await scanReader(reader, limits, signal);
30
35
  materializeDirectoryNodes(occurrences, nodes);
@@ -104,7 +109,7 @@ const inventoryLimitations = (format, reader) => {
104
109
  return ["Universal Mach-O slices require the native macOS lipo adapter."];
105
110
  return ["Artifact has no child container entries."];
106
111
  };
107
- const createReader = async (path, format) => {
112
+ const createReader = async (path, format, nativeMount, signal) => {
108
113
  switch (format) {
109
114
  case "directory":
110
115
  return new DirectoryArtifactReader(path);
@@ -118,57 +123,88 @@ const createReader = async (path, format) => {
118
123
  return process.platform === "darwin"
119
124
  ? new MachOSliceArtifactReader(path)
120
125
  : undefined;
126
+ case "dmg":
127
+ if (!nativeMount.nativeMountApproved)
128
+ return undefined;
129
+ if (!nativeMount.nativeMountEnabled)
130
+ throw new ArtifactReaderFailure("unavailable", "Native DMG mounting is disabled by operator policy");
131
+ return NativeDmgArtifactReader.create(path, signal);
121
132
  default:
122
133
  return undefined;
123
134
  }
124
135
  };
136
+ const visitNestedAsar = async (adapterKey, logicalPath, visit) => {
137
+ const nested = new AsarArtifactReader(adapterKey);
138
+ try {
139
+ await visit(nested, logicalPath);
140
+ }
141
+ finally {
142
+ await nested.close();
143
+ }
144
+ };
145
+ const isExpandableAsar = (entry, logicalPath) => entry.kind === "file" &&
146
+ logicalPath.toLowerCase().endsWith(".asar") &&
147
+ entry.adapterKey.startsWith("/");
148
+ const emptyScan = () => ({ nodes: new Map(), occurrences: [] });
125
149
  const scanReader = async (reader, limits, signal) => {
126
- const nodes = new Map();
127
- const occurrences = [];
150
+ const { nodes, occurrences } = emptyScan();
128
151
  if (reader === undefined)
129
152
  return { nodes, occurrences };
130
153
  const occurrenceByPath = new Map();
131
154
  const registry = new ArtifactPathRegistry();
132
155
  let totalBytes = 0;
133
- for await (const entry of reader.entries(signal)) {
134
- if (occurrences.length >= limits.maxEntries)
135
- throw new ArtifactReaderFailure("limit", "Artifact entry limit exceeded");
136
- const logicalPath = normalizeArtifactPath(entry.path, limits);
137
- registry.add(logicalPath, entry.kind);
138
- preflightEntry(entry, limits);
139
- const parent = nearestParent(logicalPath, occurrenceByPath);
140
- const occurrence = createOccurrence(entry, logicalPath, parent?.occurrence_id ?? null);
141
- if ((entry.kind === "file" || entry.kind === "slice") && !entry.encrypted) {
142
- const remainingBytes = limits.maxTotalBytes - totalBytes;
143
- if (remainingBytes <= 0)
144
- throw new ArtifactReaderFailure("limit", "Artifact total byte limit exceeded");
145
- if (entry.declaredSize !== null && entry.declaredSize > remainingBytes)
146
- throw new ArtifactReaderFailure("limit", "Declared artifact bytes exceed remaining cumulative limit");
147
- const digest = await hashReadable(await reader.open(entry, signal), Math.min(limits.maxEntryBytes, remainingBytes), signal);
148
- totalBytes += digest.bytes;
149
- if (entry.declaredSha256 !== null &&
150
- entry.declaredSha256 !== digest.sha256)
151
- throw new ArtifactReaderFailure("integrity", `Artifact integrity metadata disagrees with content: ${logicalPath}`);
152
- if (totalBytes > limits.maxTotalBytes)
153
- throw new ArtifactReaderFailure("limit", "Artifact total byte limit exceeded");
154
- const classified = entry.kind === "slice"
155
- ? { kind: "universal-slice", format: "mach-o" }
156
- : classifyArtifactContent(logicalPath, digest.prefix);
157
- const node = createArtifactNode({
158
- sha256: digest.sha256,
159
- size: digest.bytes,
160
- kind: classified.kind,
161
- format: classified.format,
162
- executable: entry.executable,
163
- contentState: "embedded",
156
+ const digestEntry = async (currentReader, entry, logicalPath) => {
157
+ if ((entry.kind !== "file" && entry.kind !== "slice") || entry.encrypted)
158
+ return undefined;
159
+ const remainingBytes = limits.maxTotalBytes - totalBytes;
160
+ if (remainingBytes <= 0)
161
+ throw new ArtifactReaderFailure("limit", "Artifact total byte limit exceeded");
162
+ if (entry.declaredSize !== null && entry.declaredSize > remainingBytes)
163
+ throw new ArtifactReaderFailure("limit", "Declared artifact bytes exceed remaining cumulative limit");
164
+ const digest = await hashReadable(await currentReader.open(entry, signal), Math.min(limits.maxEntryBytes, remainingBytes), signal);
165
+ totalBytes += digest.bytes;
166
+ if (entry.declaredSha256 !== null && entry.declaredSha256 !== digest.sha256)
167
+ throw new ArtifactReaderFailure("integrity", `Artifact integrity metadata disagrees with content: ${logicalPath}`, undefined, {
168
+ logicalPath,
169
+ declaredSha256: entry.declaredSha256,
170
+ calculatedSha256: digest.sha256,
171
+ unpacked: entry.unpacked,
164
172
  });
165
- nodes.set(node.artifact_id, node);
166
- occurrence.artifact_id = node.artifact_id;
167
- occurrence.hash_status = "verified";
173
+ const classified = entry.kind === "slice"
174
+ ? { kind: "universal-slice", format: "mach-o" }
175
+ : classifyArtifactContent(logicalPath, digest.prefix);
176
+ return createArtifactNode({
177
+ sha256: digest.sha256,
178
+ size: digest.bytes,
179
+ kind: classified.kind,
180
+ format: classified.format,
181
+ executable: entry.executable,
182
+ contentState: "embedded",
183
+ });
184
+ };
185
+ const visit = async (currentReader, prefix) => {
186
+ for await (const entry of currentReader.entries(signal)) {
187
+ if (occurrences.length >= limits.maxEntries)
188
+ throw new ArtifactReaderFailure("limit", "Artifact entry limit exceeded");
189
+ const logicalPath = normalizeArtifactPath(prefix.length === 0 ? entry.path : `${prefix}/${entry.path}`, limits);
190
+ const expandableAsar = isExpandableAsar(entry, logicalPath);
191
+ registry.add(logicalPath, expandableAsar ? "directory" : entry.kind);
192
+ preflightEntry(entry, limits);
193
+ const parent = nearestParent(logicalPath, occurrenceByPath);
194
+ const occurrence = createOccurrence(entry, logicalPath, parent?.occurrence_id ?? null);
195
+ const node = await digestEntry(currentReader, entry, logicalPath);
196
+ if (node !== undefined) {
197
+ nodes.set(node.artifact_id, node);
198
+ occurrence.artifact_id = node.artifact_id;
199
+ occurrence.hash_status = "verified";
200
+ }
201
+ occurrences.push(occurrence);
202
+ occurrenceByPath.set(logicalPath, occurrence);
203
+ if (expandableAsar)
204
+ await visitNestedAsar(entry.adapterKey, logicalPath, visit);
168
205
  }
169
- occurrences.push(occurrence);
170
- occurrenceByPath.set(logicalPath, occurrence);
171
- }
206
+ };
207
+ await visit(reader, "");
172
208
  return { nodes, occurrences };
173
209
  };
174
210
  const classifyRoot = async (path, directory) => {
@@ -0,0 +1,136 @@
1
+ import { createServer } from "node:http";
2
+ import { chmod, mkdir, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ const RUNNER = `const command = process.argv[2];
5
+ const response = await fetch(process.env.REA_SHIM_LEDGER_URL, {
6
+ method: "POST",
7
+ headers: { "content-type": "application/json" },
8
+ body: JSON.stringify({ command, arguments: process.argv.slice(3), working_directory: process.cwd() }),
9
+ });
10
+ if (!response.ok) process.exit(127);
11
+ const route = await response.json();
12
+ let elapsed = 0;
13
+ for (const output of route.outputs) {
14
+ const wait = Math.max(0, output.at_ms - elapsed);
15
+ if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
16
+ elapsed = output.at_ms;
17
+ (output.stream === "stdout" ? process.stdout : process.stderr).write(output.data);
18
+ }
19
+ if (route.termination.type === "signal") process.kill(process.pid, route.termination.signal);
20
+ else process.exit(route.termination.code);
21
+ `;
22
+ const shellQuote = (value) => `'${value.replaceAll("'", `'\\''`)}'`;
23
+ /**
24
+ * Start bounded executable replay wrappers and their private invocation ledger.
25
+ *
26
+ * Exact argument matching is deliberate: a fallback to the host executable
27
+ * would make an unmatched dependency call look authoritative. Unmatched and
28
+ * exhausted routes therefore fail visibly and remain part of the capture.
29
+ */
30
+ export const startCommandShimReplay = async (scenario, temporaryRoot, started) => {
31
+ const binPath = join(temporaryRoot, "shims");
32
+ await mkdir(binPath);
33
+ const runnerPath = join(temporaryRoot, "shim-runner.mjs");
34
+ await writeFile(runnerPath, RUNNER, { mode: 0o600, flag: "wx" });
35
+ for (const shim of scenario.command_shims) {
36
+ const path = join(binPath, shim.name);
37
+ const wrapper = `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(runnerPath)} ${shellQuote(shim.name)} "$@"\n`;
38
+ await writeFile(path, wrapper, { mode: 0o700, flag: "wx" });
39
+ await chmod(path, 0o700);
40
+ }
41
+ const events = [];
42
+ let truncated = false;
43
+ const calls = new Map();
44
+ const server = createServer((request, response) => {
45
+ if (request.method !== "POST" || request.url !== "/invoke") {
46
+ response.writeHead(404).end();
47
+ return;
48
+ }
49
+ readInvocation(request)
50
+ .then((invocation) => {
51
+ const shim = scenario.command_shims.find(({ name }) => name === invocation.command);
52
+ const routeIndex = shim?.routes.findIndex(({ arguments: expected }) => JSON.stringify(expected) === JSON.stringify(invocation.arguments));
53
+ const key = `${invocation.command}:${String(routeIndex ?? -1)}`;
54
+ const used = calls.get(key) ?? 0;
55
+ const route = shim !== undefined && routeIndex !== undefined && routeIndex >= 0
56
+ ? shim.routes[routeIndex]
57
+ : undefined;
58
+ const outcome = route === undefined
59
+ ? "unmatched"
60
+ : used >= route.max_calls
61
+ ? "exhausted"
62
+ : "matched";
63
+ if (events.length >= scenario.limits.protocol_events)
64
+ truncated = true;
65
+ else
66
+ events.push({
67
+ sequence: events.length,
68
+ at_ms: Math.max(0, Date.now() - started),
69
+ command: invocation.command,
70
+ route_index: routeIndex !== undefined && routeIndex >= 0 ? routeIndex : null,
71
+ arguments: invocation.arguments,
72
+ working_directory: invocation.working_directory,
73
+ outcome,
74
+ });
75
+ if (outcome !== "matched" || route === undefined) {
76
+ response.writeHead(409).end();
77
+ return;
78
+ }
79
+ calls.set(key, used + 1);
80
+ response
81
+ .writeHead(200, { "content-type": "application/json" })
82
+ .end(JSON.stringify(route));
83
+ })
84
+ .catch(() => response.writeHead(400).end());
85
+ });
86
+ await listen(server);
87
+ const address = server.address();
88
+ if (address === null || typeof address === "string") {
89
+ await closeServer(server);
90
+ throw new Error("command shim ledger did not bind an IPv4 port");
91
+ }
92
+ return {
93
+ binPath,
94
+ url: `http://127.0.0.1:${String(address.port)}/invoke`,
95
+ events,
96
+ get truncated() {
97
+ return truncated;
98
+ },
99
+ close: () => closeServer(server),
100
+ };
101
+ };
102
+ const readInvocation = async (request) => {
103
+ const chunks = [];
104
+ let bytes = 0;
105
+ for await (const chunk of request) {
106
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
107
+ bytes += buffer.length;
108
+ if (bytes > 64 * 1024)
109
+ throw new Error("shim invocation exceeds limit");
110
+ chunks.push(buffer);
111
+ }
112
+ const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
113
+ if (typeof value !== "object" ||
114
+ value === null ||
115
+ !("command" in value) ||
116
+ typeof value.command !== "string" ||
117
+ !("arguments" in value) ||
118
+ !Array.isArray(value.arguments) ||
119
+ !value.arguments.every((item) => typeof item === "string") ||
120
+ !("working_directory" in value) ||
121
+ typeof value.working_directory !== "string")
122
+ throw new Error("invalid shim invocation");
123
+ return {
124
+ command: value.command,
125
+ arguments: value.arguments,
126
+ working_directory: value.working_directory,
127
+ };
128
+ };
129
+ const listen = (server) => new Promise((resolveListen, rejectListen) => {
130
+ server.once("error", rejectListen);
131
+ server.listen(0, "127.0.0.1", () => {
132
+ server.off("error", rejectListen);
133
+ resolveListen();
134
+ });
135
+ });
136
+ const closeServer = (server) => new Promise((resolveClose, rejectClose) => server.close((cause) => cause === undefined ? resolveClose() : rejectClose(cause)));
@@ -4,6 +4,7 @@ import writeFileAtomic from "write-file-atomic";
4
4
  import { parseEvidenceBundle, serializeEvidenceBundle, } from "../domain/evidenceBundle.js";
5
5
  import { EvidenceFileError, EvidenceIntegrityError } from "../domain/errors.js";
6
6
  import { err, ok } from "../domain/result.js";
7
+ import { LEGACY_PROCESS_CAPTURE_MESSAGE, parseProcessCapture, } from "../domain/processCapture.js";
7
8
  /** Read and validate a bounded evidence bundle inside an approved root. */
8
9
  export const readEvidenceBundle = async (path, policy) => {
9
10
  if (policy.roots.length === 0)
@@ -31,7 +32,14 @@ export const readEvidenceBundle = async (path, policy) => {
31
32
  if (!limits.ok)
32
33
  return limits;
33
34
  try {
34
- return ok(parseEvidenceBundle(decoded));
35
+ const bundle = parseEvidenceBundle(decoded);
36
+ for (const record of bundle.records) {
37
+ if (record.predicate_type === "rea.process-capture/v3")
38
+ throw new TypeError(LEGACY_PROCESS_CAPTURE_MESSAGE);
39
+ if (record.predicate_type === "rea.process-capture/v4")
40
+ parseProcessCapture(record.normalized_result);
41
+ }
42
+ return ok(bundle);
35
43
  }
36
44
  catch (cause) {
37
45
  return err(new EvidenceIntegrityError("Evidence bundle validation failed", {
@@ -0,0 +1,25 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import { ProcessCaptureError } from "./ProcessCaptureError.js";
3
+ const isWithin = (candidate, root) => candidate === root ||
4
+ candidate.startsWith(`${root.endsWith("/") ? root.slice(0, -1) : root}/`);
5
+ export const assertRealPathAuthority = async (scenario, policy) => {
6
+ const executable = await realpath(scenario.executable);
7
+ const executableRoots = await Promise.all(policy.executableRoots.map((root) => realpath(root)));
8
+ if (!executableRoots.some((root) => isWithin(executable, root)))
9
+ throw new ProcessCaptureError("resolved executable is outside approved roots");
10
+ const workingDirectory = await realpath(scenario.working_directory);
11
+ const workingRoots = await Promise.all(policy.workingRoots.map((root) => realpath(root)));
12
+ if (!workingRoots.some((root) => isWithin(workingDirectory, root)))
13
+ throw new ProcessCaptureError("resolved working directory is outside approved roots");
14
+ for (const root of scenario.filesystem_roots) {
15
+ const resolvedRoot = await realpath(root);
16
+ if (!workingRoots.some((approved) => isWithin(resolvedRoot, approved)))
17
+ throw new ProcessCaptureError("resolved filesystem root is outside approved roots");
18
+ }
19
+ };
20
+ /** Expected refusal or runtime failure from the process capture adapter. */
21
+ export const assertNotCancelled = (signal) => {
22
+ if (signal?.aborted === true)
23
+ throw new ProcessCaptureError("process capture was cancelled");
24
+ };
25
+ /** Runtime availability of the native PTY adapter on this host. */
@@ -0,0 +1,23 @@
1
+ import { tmpdir } from "node:os";
2
+ import { spawn } from "node-pty";
3
+ /** Probe the actual native PTY seam instead of inferring support from the OS name. */
4
+ export const probeProcessCaptureCapability = async () => {
5
+ try {
6
+ const terminal = spawn(process.platform === "win32" ? "cmd.exe" : "/bin/sh", process.platform === "win32" ? ["/c", "exit", "0"] : ["-c", "exit 0"], {
7
+ cwd: tmpdir(),
8
+ env: { HOME: tmpdir(), TERM: "xterm-256color" },
9
+ cols: 80,
10
+ rows: 24,
11
+ name: "xterm-256color",
12
+ });
13
+ await new Promise((resolveExit) => terminal.onExit(() => resolveExit()));
14
+ return { available: true, backend: "node-pty" };
15
+ }
16
+ catch {
17
+ return {
18
+ available: false,
19
+ backend: "node-pty",
20
+ reason: "the native PTY backend could not start a probe process",
21
+ };
22
+ }
23
+ };
@@ -0,0 +1,5 @@
1
+ import { AnalysisError } from "../domain/errors.js";
2
+ /** Typed application failure produced by controlled process capture. */
3
+ export class ProcessCaptureError extends AnalysisError {
4
+ _tag = "ProcessCaptureError";
5
+ }