rea-agents 1.3.0 → 1.5.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 (110) hide show
  1. package/README.md +55 -12
  2. package/bridge/hopper_bridge.py +12 -3
  3. package/dist/application/AnalysisSnapshotCache.js +8 -6
  4. package/dist/application/ArtifactGraphConstruction.js +12 -3
  5. package/dist/application/ArtifactInventory.js +100 -27
  6. package/dist/application/AuthorizedArtifactInventory.js +4 -3
  7. package/dist/application/BinarySession.js +92 -4
  8. package/dist/application/BoundedJsonFiles.js +6 -29
  9. package/dist/application/BrowserEvidence.js +35 -0
  10. package/dist/application/BrowserObservationPort.js +1 -0
  11. package/dist/application/BrowserObservationService.js +52 -0
  12. package/dist/application/CapabilityInventory.js +134 -0
  13. package/dist/application/ClientRegistrationStatus.js +69 -0
  14. package/dist/application/ConfiguredRoots.js +19 -0
  15. package/dist/application/CrossVersionInventory.js +19 -8
  16. package/dist/application/CrossVersionInvestigation.js +74 -5
  17. package/dist/application/DeferredFileAuthorization.js +61 -0
  18. package/dist/application/DirectAnalysis.js +117 -9
  19. package/dist/application/Doctor.js +74 -2
  20. package/dist/application/EvidenceLedger.js +6 -5
  21. package/dist/application/InvestigationWorkspaceStore.js +2 -26
  22. package/dist/application/PermissionAuthority.js +183 -0
  23. package/dist/application/PermissionConfiguration.js +26 -0
  24. package/dist/application/ProcessCaptureAuthority.js +3 -2
  25. package/dist/application/ProcessCaptureError.js +13 -0
  26. package/dist/application/ProcessCaptureLifecycle.js +21 -9
  27. package/dist/application/ProcessCheckpoints.js +1 -1
  28. package/dist/application/ProcessCli.js +40 -15
  29. package/dist/application/ProcessHarness.js +10 -20
  30. package/dist/application/ProcessNormalization.js +17 -2
  31. package/dist/application/ProcessOwnership.js +4 -3
  32. package/dist/application/ProcessSampling.js +52 -17
  33. package/dist/application/ProgressReporter.js +30 -0
  34. package/dist/application/ProjectPermissionStore.js +213 -0
  35. package/dist/application/ReferenceSourceImportPolicy.js +3 -2
  36. package/dist/application/Setup.js +2 -41
  37. package/dist/application/SupportedClients.js +41 -0
  38. package/dist/application/TerminalRenderer.js +1 -1
  39. package/dist/application/Uninstall.js +1 -1
  40. package/dist/application/Upgrade.js +2 -0
  41. package/dist/application/runtime.js +1 -1
  42. package/dist/artifacts/ArtifactProvider.js +13 -3
  43. package/dist/artifacts/MachOSliceArtifactReader.js +1 -1
  44. package/dist/artifacts/NativeDmgArtifactReader.js +1 -1
  45. package/dist/artifacts/SafeOutputTree.js +3 -3
  46. package/dist/browser/CdpBrowserProvider.js +158 -0
  47. package/dist/browser/CdpCaptureDocuments.js +140 -0
  48. package/dist/browser/CdpCaptureEvents.js +248 -0
  49. package/dist/browser/CdpCaptureStorage.js +53 -0
  50. package/dist/browser/CdpCaptureValues.js +70 -0
  51. package/dist/browser/CdpConnection.js +218 -0
  52. package/dist/browser/CdpEndpoint.js +114 -0
  53. package/dist/browser/CdpOptionalCommand.js +15 -0
  54. package/dist/browser/CdpPageCapture.js +273 -0
  55. package/dist/catalogIdentity.js +123 -0
  56. package/dist/cli.js +48 -3
  57. package/dist/cliBrowserCommands.js +136 -0
  58. package/dist/cliEvidenceCommands.js +42 -1
  59. package/dist/cliInvestigationCommands.js +42 -6
  60. package/dist/cliLogging.js +20 -3
  61. package/dist/cliPolicyCommands.js +118 -0
  62. package/dist/cliProcessCommands.js +4 -14
  63. package/dist/config.js +107 -3
  64. package/dist/contracts/artifactToolContracts.js +14 -1
  65. package/dist/contracts/browserToolContracts.js +80 -0
  66. package/dist/contracts/errorSchemas.js +100 -0
  67. package/dist/contracts/toolContracts.js +13 -6
  68. package/dist/contracts/toolOutputSchemas.js +62 -30
  69. package/dist/domain/artifactComparison.js +38 -11
  70. package/dist/domain/artifactGraph.js +25 -1
  71. package/dist/domain/artifactInventoryEvidence.js +18 -0
  72. package/dist/domain/browserObservation.js +361 -0
  73. package/dist/domain/changedBehavior.js +9 -18
  74. package/dist/domain/errors.js +257 -3
  75. package/dist/domain/investigationWorkspace.js +131 -4
  76. package/dist/domain/jsonLimits.js +26 -0
  77. package/dist/domain/permissionPolicy.js +161 -0
  78. package/dist/domain/processCapture.js +3 -1
  79. package/dist/domain/processComparison.js +23 -34
  80. package/dist/domain/reconstructionVerification.js +3 -16
  81. package/dist/domain/reconstructionVerificationSchemas.js +3 -0
  82. package/dist/domain/staticRuntimeCorrelation.js +7 -1
  83. package/dist/domain/symbolAnalysis.js +1 -1
  84. package/dist/generatedPackageMetadata.js +9 -0
  85. package/dist/hopper/BridgeLauncher.js +7 -3
  86. package/dist/hopper/HopperClient.js +32 -5
  87. package/dist/identity.js +10 -3
  88. package/dist/logger.js +2 -2
  89. package/dist/main.js +101 -7
  90. package/dist/native/CommandRunner.js +11 -4
  91. package/dist/server/createServer.js +59 -5
  92. package/dist/server/mcpProgress.js +23 -0
  93. package/dist/server/registerArtifactComparisonTool.js +6 -2
  94. package/dist/server/registerBrowserTools.js +36 -0
  95. package/dist/server/registerBundleComparisonTool.js +6 -2
  96. package/dist/server/registerEnhancedTools.js +18 -1
  97. package/dist/server/registerEvidenceResources.js +302 -0
  98. package/dist/server/registerEvidenceTools.js +58 -1
  99. package/dist/server/registerFunctionComparisonTool.js +6 -2
  100. package/dist/server/registerInvestigationTools.js +78 -10
  101. package/dist/server/registerOfficialTools.js +19 -1
  102. package/dist/server/registerProcessComparisonTool.js +9 -3
  103. package/dist/server/registerSessionStatusTool.js +44 -3
  104. package/dist/server/registerSessionTools.js +144 -9
  105. package/dist/server/runDerivedOperation.js +43 -0
  106. package/dist/server/toolResult.js +27 -3
  107. package/dist/serverIdentity.js +70 -0
  108. package/package.json +7 -4
  109. package/scripts/rea.mjs +1 -1
  110. package/skills/rea-analysis/SKILL.md +18 -3
@@ -0,0 +1,183 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
+ import { consumePermission, clearSessionPermissions, createPermissionPolicy, evaluatePermission, grantPermission, reloadPermissionCeilings, revokePermission, } from "../domain/permissionPolicy.js";
4
+ import { PermissionRequiredError } from "../domain/errors.js";
5
+ import { err, ok } from "../domain/result.js";
6
+ /** Failure to establish a filesystem object's canonical authorization identity. */
7
+ export class PermissionPathError extends Error {
8
+ path;
9
+ reason;
10
+ _tag = "PermissionPathError";
11
+ constructor(path, reason, options) {
12
+ super(`Cannot canonicalize permission path: ${path}`, options);
13
+ this.path = path;
14
+ this.reason = reason;
15
+ }
16
+ }
17
+ /** Canonicalize administrator ceilings before they become authorization state. */
18
+ export const canonicalizePermissionCeilings = async (ceilings) => {
19
+ const normalized = [];
20
+ for (const ceiling of ceilings) {
21
+ const canonical = await canonicalizeScope(ceiling, "read");
22
+ if (!canonical.ok)
23
+ return canonical;
24
+ normalized.push(canonical.value);
25
+ }
26
+ return ok(normalized);
27
+ };
28
+ /** Canonicalize one exact operation request before subset evaluation. */
29
+ export const canonicalizePermissionRequest = async (request, access) => {
30
+ const canonical = await canonicalizeScope(request, access);
31
+ return canonical.ok
32
+ ? ok({ ...canonical.value, operation_identity: request.operation_identity })
33
+ : canonical;
34
+ };
35
+ const canonicalizeScope = async (scope, access) => {
36
+ const roots = await canonicalizePaths(scope.roots, access);
37
+ if (!roots.ok)
38
+ return roots;
39
+ const executables = await canonicalizePaths(scope.executables, "read");
40
+ if (!executables.ok)
41
+ return executables;
42
+ return ok({ ...scope, roots: roots.value, executables: executables.value });
43
+ };
44
+ const canonicalizePaths = async (paths, access) => {
45
+ const canonical = [];
46
+ for (const path of paths) {
47
+ const result = await canonicalizePath(path, access);
48
+ if (!result.ok)
49
+ return result;
50
+ canonical.push(result.value);
51
+ }
52
+ return ok(canonical);
53
+ };
54
+ const canonicalizePath = async (path, access) => {
55
+ const requested = resolve(path);
56
+ try {
57
+ return ok(await realpath(requested));
58
+ }
59
+ catch (cause) {
60
+ if (access !== "write" || !isNotFound(cause))
61
+ return err(new PermissionPathError(path, errorReason(cause), { cause }));
62
+ }
63
+ try {
64
+ const parent = await realpath(dirname(requested));
65
+ return ok(join(parent, basename(requested)));
66
+ }
67
+ catch (cause) {
68
+ return err(new PermissionPathError(path, errorReason(cause), { cause }));
69
+ }
70
+ };
71
+ const isNotFound = (error) => error instanceof Error && "code" in error && error.code === "ENOENT";
72
+ const errorReason = (error) => isNotFound(error) ? "not_found" : "io";
73
+ /** Stateful owner for permission evaluation, consumption, reload, and revoke. */
74
+ export class PermissionAuthority {
75
+ policy;
76
+ constructor(policy) {
77
+ this.policy = policy;
78
+ }
79
+ /** Canonicalize and authorize one exact operation before its side effect. */
80
+ async authorize(request, access, options = {}) {
81
+ const evaluated = await this.explain(request, access, options);
82
+ if (!evaluated.ok)
83
+ return evaluated;
84
+ const decision = evaluated.value;
85
+ this.policy = consumePermission(this.policy, decision);
86
+ return ok(decision);
87
+ }
88
+ /** Dry-run the exact shared evaluation without consuming a once grant. */
89
+ async explain(request, access, options = {}) {
90
+ const canonical = await canonicalizePermissionRequest(request, access);
91
+ if (!canonical.ok)
92
+ return canonical;
93
+ const decision = evaluatePermission(this.policy, canonical.value);
94
+ if (!decision.allowed)
95
+ return err(new PermissionRequiredError(canonical.value, decision.missing, ceilingFor(this.policy, canonical.value.capability), options.elicitationSupported ?? false, options.restartRequired ?? false));
96
+ return ok(decision);
97
+ }
98
+ /** Add authority beneath the administrator ceiling. */
99
+ grant(grant) {
100
+ const result = grantPermission(this.policy, grant);
101
+ if (result.ok)
102
+ this.policy = result.value;
103
+ return result;
104
+ }
105
+ /** Revoke authority immediately for subsequent operations. */
106
+ revoke(grantId) {
107
+ this.policy = revokePermission(this.policy, grantId);
108
+ }
109
+ /** Drop once/session authority when its owning connection ends. */
110
+ clearSessionGrants() {
111
+ this.policy = clearSessionPermissions(this.policy);
112
+ }
113
+ /** Atomically replace optional persisted project grants. */
114
+ async replaceProjectGrants(grants) {
115
+ let candidate = {
116
+ ...this.policy,
117
+ grants: this.policy.grants.filter(({ lifetime }) => lifetime !== "project"),
118
+ };
119
+ for (const grant of grants) {
120
+ const canonical = await canonicalizeGrant(grant);
121
+ if (!canonical.ok)
122
+ return canonical;
123
+ const added = grantPermission(candidate, canonical.value);
124
+ if (!added.ok)
125
+ return err(new PermissionPathError(grant.grant_id, "io", {
126
+ cause: added.error,
127
+ }));
128
+ candidate = added.value;
129
+ }
130
+ this.policy = candidate;
131
+ return ok(null);
132
+ }
133
+ /** Replace grants derived from the currently loaded administrator ceiling. */
134
+ async replaceAdministratorGrants(grants) {
135
+ let candidate = {
136
+ ...this.policy,
137
+ grants: this.policy.grants.filter(({ lifetime }) => lifetime !== "administrator"),
138
+ };
139
+ for (const grant of grants) {
140
+ const canonical = await canonicalizeGrant(grant);
141
+ if (!canonical.ok)
142
+ return canonical;
143
+ const added = grantPermission(candidate, canonical.value);
144
+ if (!added.ok)
145
+ return err(new PermissionPathError(grant.grant_id, "io", {
146
+ cause: added.error,
147
+ }));
148
+ candidate = added.value;
149
+ }
150
+ this.policy = candidate;
151
+ return ok(null);
152
+ }
153
+ /** Replace ceilings without restarting the MCP process. */
154
+ async reload(ceilings) {
155
+ const canonical = await canonicalizePermissionCeilings(ceilings);
156
+ if (!canonical.ok)
157
+ return canonical;
158
+ this.policy = reloadPermissionCeilings(this.policy, canonical.value);
159
+ return ok(null);
160
+ }
161
+ }
162
+ /** Create one canonical policy and optional grants at the composition root. */
163
+ export const createPermissionAuthority = async (ceilings, grants = []) => {
164
+ const canonical = await canonicalizePermissionCeilings(ceilings);
165
+ if (!canonical.ok)
166
+ return canonical;
167
+ let policy = createPermissionPolicy(canonical.value);
168
+ for (const grant of grants) {
169
+ const canonicalGrant = await canonicalizeGrant(grant);
170
+ if (!canonicalGrant.ok)
171
+ return canonicalGrant;
172
+ const added = grantPermission(policy, canonicalGrant.value);
173
+ if (!added.ok)
174
+ return err(new PermissionPathError(grant.grant_id, "io", { cause: added.error }));
175
+ policy = added.value;
176
+ }
177
+ return ok(new PermissionAuthority(policy));
178
+ };
179
+ const canonicalizeGrant = async (grant) => {
180
+ const scope = await canonicalizeScope(grant, "read");
181
+ return scope.ok ? ok({ ...grant, ...scope.value }) : scope;
182
+ };
183
+ const ceilingFor = (policy, capability) => policy.ceilings.find((ceiling) => ceiling.capability === capability) ?? null;
@@ -0,0 +1,26 @@
1
+ import { ConfigurationError } from "../domain/errors.js";
2
+ import { err, ok } from "../domain/result.js";
3
+ import { createPermissionAuthority, } from "./PermissionAuthority.js";
4
+ import { readProjectPermissionStore } from "./ProjectPermissionStore.js";
5
+ /** Load the same administrator and optional project policy for CLI and MCP. */
6
+ export const loadConfiguredPermissionAuthority = async (config) => {
7
+ const authority = await createPermissionAuthority(config.permissionCeilings, config.administratorPermissionGrants);
8
+ if (!authority.ok)
9
+ return err(new ConfigurationError("Permission policy could not be loaded", {
10
+ cause: authority.error,
11
+ }));
12
+ if (config.permissionProjectRoot === undefined ||
13
+ config.permissionProjectStore === undefined)
14
+ return ok(authority.value);
15
+ const project = await readProjectPermissionStore(config.permissionProjectStore, config.permissionProjectRoot);
16
+ if (!project.ok)
17
+ return err(new ConfigurationError("Project permission policy could not be loaded", {
18
+ cause: project.error,
19
+ }));
20
+ const replaced = await authority.value.replaceProjectGrants(project.value?.grants ?? []);
21
+ return replaced.ok
22
+ ? ok(authority.value)
23
+ : err(new ConfigurationError("Project permission policy could not be applied", {
24
+ cause: replaced.error,
25
+ }));
26
+ };
@@ -1,14 +1,15 @@
1
1
  import { realpath } from "node:fs/promises";
2
2
  import { ProcessCaptureError, processCaptureCancelled, } from "./ProcessCaptureError.js";
3
+ import { canonicalizeConfiguredRoots } from "./ConfiguredRoots.js";
3
4
  const isWithin = (candidate, root) => candidate === root ||
4
5
  candidate.startsWith(`${root.endsWith("/") ? root.slice(0, -1) : root}/`);
5
6
  export const assertRealPathAuthority = async (scenario, policy) => {
6
7
  const executable = await realpath(scenario.executable);
7
- const executableRoots = await Promise.all(policy.executableRoots.map((root) => realpath(root)));
8
+ const executableRoots = await canonicalizeConfiguredRoots(policy.executableRoots);
8
9
  if (!executableRoots.some((root) => isWithin(executable, root)))
9
10
  throw new ProcessCaptureError("resolved executable is outside approved roots");
10
11
  const workingDirectory = await realpath(scenario.working_directory);
11
- const workingRoots = await Promise.all(policy.workingRoots.map((root) => realpath(root)));
12
+ const workingRoots = await canonicalizeConfiguredRoots(policy.workingRoots);
12
13
  if (!workingRoots.some((root) => isWithin(workingDirectory, root)))
13
14
  throw new ProcessCaptureError("resolved working directory is outside approved roots");
14
15
  for (const root of scenario.filesystem_roots) {
@@ -4,14 +4,27 @@ export class ProcessCaptureError extends AnalysisError {
4
4
  _tag = "ProcessCaptureError";
5
5
  userMessage;
6
6
  userCategory;
7
+ reason;
8
+ cleanupIncomplete;
9
+ cleanupResources;
7
10
  constructor(message, options) {
8
11
  super(message, options);
9
12
  this.userMessage = options?.userMessage;
10
13
  this.userCategory = options?.userCategory;
14
+ this.reason =
15
+ options?.reason ??
16
+ (options?.userCategory === "permission_required"
17
+ ? "permission_required"
18
+ : options?.userCategory === "cancelled"
19
+ ? "cancelled"
20
+ : "capture_failed");
21
+ this.cleanupIncomplete = this.reason === "cleanup_incomplete";
22
+ this.cleanupResources = options?.cleanupResources ?? [];
11
23
  }
12
24
  }
13
25
  /** Caller-visible cancellation without exposing capture implementation state. */
14
26
  export const processCaptureCancelled = () => new ProcessCaptureError("process capture was cancelled", {
15
27
  userCategory: "cancelled",
28
+ reason: "cancelled",
16
29
  userMessage: "Process capture was cancelled. Start it again when ready.",
17
30
  });
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { createReadStream } from "node:fs";
3
- import { mkdtemp, rm } from "node:fs/promises";
3
+ import { mkdir, mkdtemp, rm } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { authorizeProcessScenario, digestProcessCommitment, processComparisonContract, processScenarioCommitment, } from "../domain/processCapture.js";
@@ -219,7 +219,6 @@ export const captureTerminalFrames = (options) => {
219
219
  let outputBytes = 0;
220
220
  let truncated = false;
221
221
  options.terminal.onData((data) => {
222
- options.onFirstOutput();
223
222
  options.onOutput();
224
223
  const bytes = Buffer.byteLength(data);
225
224
  if (options.frames.length >= options.scenario.limits.frames ||
@@ -249,12 +248,14 @@ export const resolveProcessResult = (capture, executionFailure, cleanupFailure)
249
248
  cause: executionFailure,
250
249
  });
251
250
  if (cleanupFailure !== undefined)
252
- throw new ProcessCaptureError(cleanupFailure);
251
+ throw new ProcessCaptureError(cleanupFailure, {
252
+ reason: "cleanup_incomplete",
253
+ });
253
254
  if (capture === undefined)
254
255
  throw new ProcessCaptureError("process capture produced no result");
255
256
  return capture;
256
257
  };
257
- export const prepareProcessCapture = async (scenario, policy, signal) => {
258
+ export const prepareProcessCapture = async (scenario, policy, signal, host = systemProcessPreparationHost) => {
258
259
  const decision = authorizeProcessScenario(scenario, policy);
259
260
  if (!decision.allowed)
260
261
  throw new ProcessCaptureError(decision.reason, {
@@ -264,11 +265,22 @@ export const prepareProcessCapture = async (scenario, policy, signal) => {
264
265
  await assertRealPathAuthority(scenario, policy);
265
266
  assertNotCancelled(signal);
266
267
  const before = await snapshotRoots(scenario, signal);
267
- const temporaryRoot = await mkdtemp(join(tmpdir(), "rea-process-"));
268
- const runId = randomUUID();
269
- const home = join(temporaryRoot, "home");
270
- await import("node:fs/promises").then(({ mkdir }) => mkdir(home));
271
- return { temporaryRoot, runId, home, before };
268
+ const temporaryRoot = await host.createTemporaryRoot();
269
+ try {
270
+ const runId = randomUUID();
271
+ const home = join(temporaryRoot, "home");
272
+ await host.createHome(home);
273
+ return { temporaryRoot, runId, home, before };
274
+ }
275
+ catch (cause) {
276
+ await host.cleanup(temporaryRoot);
277
+ throw cause;
278
+ }
279
+ };
280
+ const systemProcessPreparationHost = {
281
+ createTemporaryRoot: () => mkdtemp(join(tmpdir(), "rea-process-")),
282
+ createHome: (path) => mkdir(path),
283
+ cleanup: (path) => rm(path, { recursive: true, force: true }),
272
284
  };
273
285
  const processPolicyMessage = (reason) => {
274
286
  if (reason === "process capture is disabled")
@@ -105,7 +105,7 @@ export class ProcessCheckpoints {
105
105
  effects: classifyFilesystemEffects(previous, after.files),
106
106
  truncated: after.truncated,
107
107
  });
108
- return this.#captures;
108
+ return structuredClone(this.#captures);
109
109
  }
110
110
  /** Cancel scheduled captures and await any snapshot already in progress. */
111
111
  async dispose() {
@@ -1,21 +1,13 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { parseConfig } from "../config.js";
3
- import { AnalysisError, projectAnalysisError } from "../domain/errors.js";
3
+ import { AnalysisError, AnalysisProtocolError, PermissionRequiredError, projectAnalysisError, } from "../domain/errors.js";
4
4
  import { createEvidence, parseEvidence } from "../domain/evidence.js";
5
5
  import { jsonValueSchema } from "../domain/jsonValue.js";
6
6
  import { compareProcessCaptures, LEGACY_PROCESS_CAPTURE_MESSAGE, parseProcessScenario, parseProcessCapture, } from "../domain/processCapture.js";
7
7
  import { captureProcessScenario } from "./ProcessHarness.js";
8
8
  import { PROCESS_PROVIDER, createProcessCaptureEvidence, } from "./ProcessEvidence.js";
9
+ import { loadConfiguredPermissionAuthority } from "./PermissionConfiguration.js";
9
10
  const MAX_INPUT_BYTES = 64 * 1024 * 1024;
10
- /** Distinguish a safe process-command failure from successful Evidence. */
11
- export const isProcessCliErrorOutput = (value) => typeof value === "object" &&
12
- value !== null &&
13
- "error" in value &&
14
- value.error === "Process command failed" &&
15
- "category" in value &&
16
- typeof value.category === "string" &&
17
- "message" in value &&
18
- typeof value.message === "string";
19
11
  /** Capture one JSON scenario through the same policy and evidence contract as MCP. */
20
12
  export const captureProcessScenarioFile = async (path) => {
21
13
  try {
@@ -30,6 +22,27 @@ export const captureProcessScenarioFile = async (path) => {
30
22
  const config = parseConfig(process.env);
31
23
  if (!config.ok)
32
24
  return cliAnalysisError(config.error);
25
+ const authority = await loadConfiguredPermissionAuthority(config.value);
26
+ if (!authority.ok)
27
+ return cliAnalysisError(authority.error);
28
+ const authorized = await authority.value.authorize({
29
+ capability: "process_capture",
30
+ roots: [scenario.working_directory, ...scenario.filesystem_roots],
31
+ executables: [scenario.executable],
32
+ environment_names: [
33
+ ...Object.keys(scenario.environment),
34
+ ...scenario.inherit_environment,
35
+ ],
36
+ network: scenario.network_access === "host" ? "external" : "none",
37
+ mount: false,
38
+ operation_identity: `capture_process_scenario:${scenario.executable}`,
39
+ }, "read");
40
+ if (!authorized.ok)
41
+ return cliAnalysisError(authorized.error instanceof PermissionRequiredError
42
+ ? authorized.error
43
+ : new AnalysisProtocolError(authorized.error.message, {
44
+ cause: authorized.error,
45
+ }));
33
46
  const captured = await captureProcessScenario(scenario, config.value.processExecutionPolicy);
34
47
  if (!captured.ok)
35
48
  return cliAnalysisError(captured.error);
@@ -66,7 +79,13 @@ export const compareProcessEvidenceFiles = async (leftPath, rightPath) => {
66
79
  }
67
80
  };
68
81
  const parseCaptureEvidence = (input) => {
69
- const evidence = parseEvidence(input);
82
+ let evidence;
83
+ try {
84
+ evidence = parseEvidence(input);
85
+ }
86
+ catch {
87
+ throw invalidCaptureEvidence();
88
+ }
70
89
  if (evidence.operation !== "capture_process_scenario" ||
71
90
  evidence.predicate_type !== "rea.process-capture/v4" ||
72
91
  evidence.provider.id !== PROCESS_PROVIDER.id ||
@@ -75,11 +94,17 @@ const parseCaptureEvidence = (input) => {
75
94
  throw new ProcessCliFailure("invalid_input", LEGACY_PROCESS_CAPTURE_MESSAGE);
76
95
  throw new ProcessCliFailure("invalid_input", "Capture evidence is not from the current process-capture workflow. Create new capture evidence, then try again.");
77
96
  }
78
- return {
79
- id: evidence.evidence_id,
80
- capture: parseProcessCapture(evidence.normalized_result),
81
- };
97
+ try {
98
+ return {
99
+ id: evidence.evidence_id,
100
+ capture: parseProcessCapture(evidence.normalized_result),
101
+ };
102
+ }
103
+ catch {
104
+ throw invalidCaptureEvidence();
105
+ }
82
106
  };
107
+ const invalidCaptureEvidence = () => new ProcessCliFailure("invalid_input", "Capture evidence is malformed. Create new capture evidence, then try again.");
83
108
  const readJson = async (path) => {
84
109
  let bytes;
85
110
  try {
@@ -81,16 +81,6 @@ const scheduleScenarioEvents = (options) => {
81
81
  timers.add(timer);
82
82
  }
83
83
  };
84
- const startScenarioEvents = (options) => {
85
- let eventsStarted = false;
86
- return () => {
87
- if (eventsStarted)
88
- return;
89
- eventsStarted = true;
90
- scheduleScenarioEvents(options);
91
- };
92
- };
93
- const eventsRequireReadiness = (scenario) => scenario.events.some((event) => event.type === "input" || event.type === "resize");
94
84
  const cleanupFailedStartup = async (options) => {
95
85
  options.terminal?.kill("SIGKILL");
96
86
  const cleanupFailure = await releaseProcessResources({
@@ -98,7 +88,10 @@ const cleanupFailedStartup = async (options) => {
98
88
  sampledProcessGroupIds: [],
99
89
  });
100
90
  if (cleanupFailure !== undefined)
101
- throw new ProcessCaptureError(cleanupFailure, { cause: options.cause });
91
+ throw new ProcessCaptureError(cleanupFailure, {
92
+ cause: options.cause,
93
+ reason: "cleanup_incomplete",
94
+ });
102
95
  throw options.cause;
103
96
  };
104
97
  const createTerminalRenderer = (scenario, temporaryRoot, terminalPid) => new TerminalRenderer({
@@ -131,23 +124,20 @@ const startCaptureRuntime = async (options) => {
131
124
  rows: scenario.terminal.rows,
132
125
  name: "xterm-256color",
133
126
  });
134
- const startEvents = startScenarioEvents({
135
- ...options,
136
- getTerminal: () => terminal,
137
- renderer,
138
- started,
139
- });
140
- if (!eventsRequireReadiness(scenario))
141
- startEvents();
142
127
  const framesTruncated = captureTerminalFrames({
143
128
  ...options,
144
129
  terminal,
145
130
  started,
146
131
  onOutput: () => (lastOutput = Date.now()),
147
- onFirstOutput: startEvents,
148
132
  renderer,
149
133
  checkpoints,
150
134
  });
135
+ scheduleScenarioEvents({
136
+ ...options,
137
+ getTerminal: () => terminal,
138
+ renderer,
139
+ started,
140
+ });
151
141
  const stopSampler = startProcessSampler(terminal.pid, started, scenario.limits.processes, options.samples);
152
142
  return {
153
143
  replay,
@@ -14,7 +14,7 @@ export const normalizeProcessText = (value, scenario, temporaryRoot, pid) => {
14
14
  normalized = normalized.replaceAll(root, `<filesystem-root-${String(index)}>`);
15
15
  }
16
16
  if (scenario.normalization.pids)
17
- normalized = normalized.replaceAll(String(pid), "<pid>");
17
+ normalized = normalizePidTokens(normalized, [pid]);
18
18
  if (scenario.normalization.ports)
19
19
  normalized = normalized.replaceAll(/(?<=[:=])\d{2,5}\b/g, "<port>");
20
20
  for (const pattern of scenario.normalization.patterns)
@@ -35,6 +35,12 @@ export const normalizeProcessSamples = (samples, scenario, rootPid) => {
35
35
  for (const identifier of identifiers)
36
36
  if (identifier > 0 && !mapping.has(identifier))
37
37
  mapping.set(identifier, mapping.size + 1);
38
+ const normalizeCommand = (command) => {
39
+ const normalized = normalizeProcessText(command, scenario, "<no-temporary-root>", rootPid);
40
+ return scenario.normalization.pids
41
+ ? normalizePidTokens(normalized, identifiers)
42
+ : normalized;
43
+ };
38
44
  return samples.map((sample) => ({
39
45
  at_ms: Math.floor(sample.at_ms / scenario.normalization.time_bucket_ms) *
40
46
  scenario.normalization.time_bucket_ms,
@@ -44,9 +50,18 @@ export const normalizeProcessSamples = (samples, scenario, rootPid) => {
44
50
  ? null
45
51
  : (mapping.get(sample.process_group_id) ?? 0),
46
52
  session_id: sample.session_id === null ? null : (mapping.get(sample.session_id) ?? 0),
47
- command: normalizeProcessText(sample.command, scenario, "<no-temporary-root>", rootPid),
53
+ command: normalizeCommand(sample.command),
48
54
  }));
49
55
  };
56
+ const normalizePidTokens = (value, identifiers) => {
57
+ const tokens = [...new Set(identifiers)]
58
+ .filter((identifier) => Number.isSafeInteger(identifier) && identifier > 0)
59
+ .sort((left, right) => right - left)
60
+ .map(String);
61
+ if (tokens.length === 0)
62
+ return value;
63
+ return value.replace(new RegExp(`(?<!\\d)(?:${tokens.join("|")})(?!\\d)`, "gu"), "<pid>");
64
+ };
50
65
  export const redactProtocolEvents = (events, scenario) => events.map((event) => ({
51
66
  ...event,
52
67
  data: normalizeProcessText(event.data, scenario, "<no-temporary-root>", -1),
@@ -2,9 +2,10 @@ import { execFile } from "node:child_process";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { promisify } from "node:util";
4
4
  const execFileAsync = promisify(execFile);
5
- const parseEnvironment = (value) => Object.fromEntries(value
5
+ /** Parse the NUL-delimited Linux process environment without nameless keys. */
6
+ export const parseProcessEnvironment = (value) => Object.fromEntries(value
6
7
  .split("\0")
7
- .filter((entry) => entry.includes("="))
8
+ .filter((entry) => entry.indexOf("=") > 0)
8
9
  .map((entry) => {
9
10
  const separator = entry.indexOf("=");
10
11
  return [entry.slice(0, separator), entry.slice(separator + 1)];
@@ -31,7 +32,7 @@ const systemHost = {
31
32
  },
32
33
  async environment(pid) {
33
34
  if (process.platform === "linux") {
34
- return parseEnvironment(await readFile(`/proc/${pid}/environ`, "utf8"));
35
+ return parseProcessEnvironment(await readFile(`/proc/${pid}/environ`, "utf8"));
35
36
  }
36
37
  const { stdout } = await execFileAsync("ps", ["eww", "-p", String(pid)]);
37
38
  const environment = {};
@@ -1,5 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
- import { readFile } from "node:fs/promises";
2
+ import { readFile, readdir } from "node:fs/promises";
3
3
  import { promisify } from "node:util";
4
4
  const execFileAsync = promisify(execFile);
5
5
  const PROC_READ_CONCURRENCY = 64;
@@ -67,23 +67,58 @@ const readProcessCommand = async (pid, signal) => {
67
67
  return handleProcReadFailure(cause);
68
68
  }
69
69
  };
70
- const readLinuxChildren = async (pid, signal) => {
71
- let text;
72
- try {
73
- text = await readFile(`/proc/${String(pid)}/task/${String(pid)}/children`, {
74
- encoding: "utf8",
75
- signal,
76
- });
77
- }
78
- catch (cause) {
79
- return handleProcReadFailure(cause);
70
+ const systemLinuxChildrenHost = {
71
+ async taskIds(pid, signal) {
72
+ try {
73
+ signal.throwIfAborted();
74
+ const entries = await readdir(`/proc/${String(pid)}/task`);
75
+ signal.throwIfAborted();
76
+ return entries
77
+ .filter((entry) => /^\d+$/.test(entry))
78
+ .map(Number)
79
+ .filter((taskId) => Number.isSafeInteger(taskId) && taskId > 0)
80
+ .sort((left, right) => left - right);
81
+ }
82
+ catch (cause) {
83
+ return handleProcReadFailure(cause);
84
+ }
85
+ },
86
+ async children(pid, taskId, signal) {
87
+ try {
88
+ return await readFile(`/proc/${String(pid)}/task/${String(taskId)}/children`, { encoding: "utf8", signal });
89
+ }
90
+ catch (cause) {
91
+ return handleProcReadFailure(cause);
92
+ }
93
+ },
94
+ };
95
+ /** Read the deduplicated children created by every thread in a Linux process. */
96
+ export const readLinuxChildren = async (pid, signal, host = systemLinuxChildrenHost) => {
97
+ const taskIds = await host.taskIds(pid, signal);
98
+ if (taskIds === undefined)
99
+ return undefined;
100
+ const children = new Set();
101
+ let successfulReads = 0;
102
+ for (let offset = 0; offset < taskIds.length; offset += PROC_READ_CONCURRENCY) {
103
+ const texts = await Promise.all(taskIds
104
+ .slice(offset, offset + PROC_READ_CONCURRENCY)
105
+ .map((taskId) => host.children(pid, taskId, signal)));
106
+ for (const text of texts) {
107
+ if (text === undefined)
108
+ continue;
109
+ successfulReads += 1;
110
+ for (const value of text.trim().split(/\s+/)) {
111
+ if (!/^\d+$/.test(value))
112
+ continue;
113
+ const child = Number(value);
114
+ if (Number.isSafeInteger(child) && child > 0)
115
+ children.add(child);
116
+ }
117
+ }
80
118
  }
81
- return text
82
- .trim()
83
- .split(/\s+/)
84
- .filter((value) => /^\d+$/.test(value))
85
- .map(Number)
86
- .filter((child) => Number.isSafeInteger(child) && child > 0);
119
+ return successfulReads === 0
120
+ ? undefined
121
+ : [...children].sort((left, right) => left - right);
87
122
  };
88
123
  const inspectProcess = async (pid, expectedParent, signal, identities) => {
89
124
  const before = await readProcessStat(pid, signal);
@@ -0,0 +1,30 @@
1
+ /** Create a monotonic, rate-bounded reporter around a surface-specific sink. */
2
+ export const createProgressReporter = (sink, options = {}) => {
3
+ const minimumIntervalMs = options.minimumIntervalMs ?? 100;
4
+ const now = options.now ?? Date.now;
5
+ let lastCompleted = -1;
6
+ let lastSentAt = Number.NEGATIVE_INFINITY;
7
+ let sequence = 0;
8
+ return {
9
+ async report(update) {
10
+ if (!Number.isFinite(update.completed) || update.completed < 0)
11
+ throw new TypeError("Progress completed must be a nonnegative number");
12
+ if (update.total !== null && update.total < update.completed)
13
+ throw new TypeError("Progress total cannot be less than completed");
14
+ if (update.completed < lastCompleted)
15
+ throw new TypeError("Progress cannot move backwards");
16
+ lastCompleted = update.completed;
17
+ const observedAt = now();
18
+ if (update.terminal !== true &&
19
+ observedAt - lastSentAt < minimumIntervalMs)
20
+ return;
21
+ lastSentAt = observedAt;
22
+ sequence += 1;
23
+ await sink({ ...update, sequence });
24
+ },
25
+ };
26
+ };
27
+ /** Surface that deliberately ignores progress while preserving one workflow API. */
28
+ export const silentProgressReporter = {
29
+ report: () => Promise.resolve(),
30
+ };