rea-agents 1.5.0 → 1.6.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 (88) hide show
  1. package/README.md +17 -15
  2. package/dist/application/BinarySession.js +14 -4
  3. package/dist/application/BrowserEvidence.js +87 -15
  4. package/dist/application/BrowserObservationService.js +65 -1
  5. package/dist/application/CapabilityInventory.js +7 -0
  6. package/dist/application/ClientConfigPath.js +33 -0
  7. package/dist/application/CrossVersionInventory.js +16 -3
  8. package/dist/application/CrossVersionInvestigation.js +17 -62
  9. package/dist/application/CrossVersionInvestigationEvidence.js +64 -0
  10. package/dist/application/CrossVersionInvestigationReplay.js +170 -0
  11. package/dist/application/Doctor.js +16 -11
  12. package/dist/application/ElectronEvidence.js +35 -0
  13. package/dist/application/ElectronObservationPort.js +1 -0
  14. package/dist/application/ElectronObservationService.js +46 -0
  15. package/dist/application/EvidenceLedger.js +5 -2
  16. package/dist/application/PermissionAuthority.js +30 -0
  17. package/dist/application/ProcessCaptureLifecycle.js +1 -5
  18. package/dist/application/ProcessHarness.js +13 -7
  19. package/dist/application/ProcessOwnership.js +40 -20
  20. package/dist/application/ProcessSampling.js +59 -8
  21. package/dist/application/ProjectPermissionStore.js +1 -0
  22. package/dist/application/ReferenceSourceImport.js +13 -16
  23. package/dist/application/Setup.js +89 -30
  24. package/dist/application/SetupClients.js +7 -4
  25. package/dist/application/SetupInstallFailure.js +24 -0
  26. package/dist/application/SetupPlan.js +11 -7
  27. package/dist/application/SetupSkill.js +20 -3
  28. package/dist/application/Uninstall.js +20 -7
  29. package/dist/application/Upgrade.js +14 -3
  30. package/dist/browser/CdpBrowserProvider.js +154 -51
  31. package/dist/browser/CdpCaptureCompleteness.js +90 -0
  32. package/dist/browser/CdpCaptureDocuments.js +180 -21
  33. package/dist/browser/CdpCaptureEvents.js +342 -43
  34. package/dist/browser/CdpCaptureValues.js +5 -7
  35. package/dist/browser/CdpConnection.js +47 -24
  36. package/dist/browser/CdpElectronInspection.js +330 -0
  37. package/dist/browser/CdpElectronProvider.js +134 -0
  38. package/dist/browser/CdpEndpoint.js +64 -5
  39. package/dist/browser/CdpObservationSession.js +301 -0
  40. package/dist/browser/CdpPageCapture.js +159 -56
  41. package/dist/browser/CdpSafeMetadata.js +203 -0
  42. package/dist/browser/CdpScreenshot.js +101 -0
  43. package/dist/browser/CdpTargetSession.js +47 -0
  44. package/dist/browser/CdpWebMcpDiscovery.js +242 -0
  45. package/dist/browser/ElectronFileScope.js +50 -0
  46. package/dist/browser/PngVisualDiff.js +177 -0
  47. package/dist/browser/SensitiveTextCapture.js +25 -0
  48. package/dist/browser/WebSourceMapFetcher.js +357 -0
  49. package/dist/catalogIdentity.js +8 -0
  50. package/dist/cli.js +4 -0
  51. package/dist/cliBrowserAdvancedCommands.js +169 -0
  52. package/dist/cliBrowserCommands.js +177 -1
  53. package/dist/cliElectronCommands.js +114 -0
  54. package/dist/cliInvestigationCommands.js +15 -8
  55. package/dist/cliPolicyCommands.js +47 -2
  56. package/dist/config.js +40 -5
  57. package/dist/contracts/browserToolContracts.js +301 -0
  58. package/dist/contracts/electronToolContracts.js +73 -0
  59. package/dist/contracts/toolContracts.js +2 -0
  60. package/dist/domain/browserCompleteness.js +101 -0
  61. package/dist/domain/browserObservation.js +240 -14
  62. package/dist/domain/browserSession.js +63 -0
  63. package/dist/domain/electronObservation.js +162 -0
  64. package/dist/domain/investigationWorkspace.js +4 -0
  65. package/dist/domain/jsonShape.js +125 -0
  66. package/dist/domain/referenceSourceGraph.js +3 -1
  67. package/dist/domain/webBundleAnalysis.js +195 -0
  68. package/dist/domain/webBundleAnalyzer.js +423 -0
  69. package/dist/domain/webCaptureDiff.js +184 -0
  70. package/dist/domain/webContentArtifact.js +47 -0
  71. package/dist/domain/webInventory.js +61 -0
  72. package/dist/domain/webMcpDiscovery.js +68 -0
  73. package/dist/domain/webScreenshot.js +114 -0
  74. package/dist/generatedPackageMetadata.js +2 -2
  75. package/dist/hopper/BridgeLauncher.js +2 -3
  76. package/dist/hopper/HopperClient.js +51 -11
  77. package/dist/hopper/HopperProvider.js +6 -1
  78. package/dist/main.js +36 -31
  79. package/dist/native/NativeMacOSProvider.js +3 -2
  80. package/dist/native/NativeMachoInspection.js +21 -6
  81. package/dist/server/createServer.js +7 -0
  82. package/dist/server/registerBrowserTools.js +61 -2
  83. package/dist/server/registerElectronTools.js +36 -0
  84. package/dist/server/registerInvestigationTools.js +11 -12
  85. package/dist/server/registerSessionTools.js +1 -0
  86. package/package.json +5 -1
  87. package/scripts/rea.mjs +2 -2
  88. package/skills/rea-analysis/SKILL.md +19 -9
@@ -0,0 +1,170 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { artifactInventoryResultSchema, } from "../domain/artifactGraph.js";
3
+ import { parseArtifactInventoryEvidence } from "../domain/artifactInventoryEvidence.js";
4
+ import { EvidenceIntegrityError, InvestigationWorkspaceError, } from "../domain/errors.js";
5
+ import { err, ok } from "../domain/result.js";
6
+ import { createArtifactComparisonEvidence, createChangedBehaviorEvidence, } from "./CrossVersionInvestigationEvidence.js";
7
+ import { AUTOMATIC_RUN_LIMITATION } from "./CrossVersionInventory.js";
8
+ import { ARTIFACT_GRAPH_PROVIDER } from "./InvestigationProviders.js";
9
+ /** Select and verify one caller-identified complete run without input access. */
10
+ export const selectCompletedInvestigationReplay = (workspace, input) => {
11
+ const run = workspace?.runs.find(({ run_id: runId }) => runId === input.replay_run_id);
12
+ if (workspace === null ||
13
+ run === undefined ||
14
+ run.status !== "complete" ||
15
+ !controlsMatch(run, input))
16
+ return replayConflict();
17
+ const records = new Map(workspace.bundle.records.map((record) => [record.evidence_id, record]));
18
+ const left = inventoryRecords(records, run.left_inventory_evidence_ids);
19
+ const right = inventoryRecords(records, run.right_inventory_evidence_ids);
20
+ if (!left.ok)
21
+ return left;
22
+ if (!right.ok)
23
+ return right;
24
+ if (!pathsMatch(left.value, input.left_path) ||
25
+ !pathsMatch(right.value, input.right_path))
26
+ return replayConflict();
27
+ const validated = validateLinkedEvidence(workspace, run, left.value, right.value);
28
+ return validated.ok ? ok({ workspace, run }) : validated;
29
+ };
30
+ const controlsMatch = (run, input) => isDeepStrictEqual(run.options, input.options) &&
31
+ run.integrity_policy === input.integrity_policy &&
32
+ run.integrity_continue_approved === input.integrity_continue_approved &&
33
+ run.max_integrity_mismatches === input.max_integrity_mismatches;
34
+ const inventoryRecords = (records, evidenceIds) => {
35
+ if (new Set(evidenceIds).size !== evidenceIds.length)
36
+ return replayIntegrity("Investigation replay repeats inventory Evidence");
37
+ const selected = evidenceIds.flatMap((evidenceId) => {
38
+ const evidence = records.get(evidenceId);
39
+ return evidence === undefined ? [] : [evidence];
40
+ });
41
+ return selected.length === evidenceIds.length
42
+ ? ok(selected)
43
+ : replayIntegrity("Investigation replay is missing inventory Evidence");
44
+ };
45
+ const pathsMatch = (records, path) => records.every(({ subject }) => subject?.local_path === path);
46
+ const validateLinkedEvidence = (workspace, run, leftRecords, rightRecords) => {
47
+ try {
48
+ const left = validateInventory(leftRecords, run.left, run);
49
+ const right = validateInventory(rightRecords, run.right, run);
50
+ if (!left.ok)
51
+ return left;
52
+ if (!right.ok)
53
+ return right;
54
+ const expectedLimitations = runLimitations(left.value, right.value);
55
+ if (!isDeepStrictEqual(run.limitations, expectedLimitations))
56
+ return replayIntegrity("Investigation replay limitations disagree with inventory Evidence");
57
+ const comparison = createArtifactComparisonEvidence({ left: left.value.records, right: right.value.records }, run.options.change_limit);
58
+ const storedComparison = evidenceFor(workspace, run.comparison_evidence_id);
59
+ if (storedComparison === undefined ||
60
+ !isDeepStrictEqual(storedComparison, comparison))
61
+ return replayIntegrity("Investigation replay comparison Evidence is inconsistent");
62
+ const result = createChangedBehaviorEvidence(workspace, run, comparison);
63
+ const storedResult = evidenceFor(workspace, run.result_evidence_id);
64
+ return storedResult !== undefined && isDeepStrictEqual(storedResult, result)
65
+ ? ok(null)
66
+ : replayIntegrity("Investigation replay result Evidence is inconsistent");
67
+ }
68
+ catch (cause) {
69
+ return replayIntegrity("Investigation replay linked Evidence is invalid", cause);
70
+ }
71
+ };
72
+ const validateInventory = (records, target, run) => {
73
+ const pages = records.map((record) => artifactInventoryResultSchema.parse(record.normalized_result));
74
+ const expectedPageCount = Math.max(1, Math.ceil((pages[0]?.manifest.node_count ?? 0) / run.options.page_size), Math.ceil((pages[0]?.manifest.occurrence_count ?? 0) / run.options.page_size), Math.ceil((pages[0]?.manifest.edge_count ?? 0) / run.options.page_size));
75
+ if (records.length !== expectedPageCount)
76
+ return replayIntegrity("Investigation replay inventory Evidence pagination is inconsistent");
77
+ if (!sameSnapshotMetadata(pages))
78
+ return replayIntegrity("Investigation replay inventory pages disagree on snapshot metadata");
79
+ for (const [index, record] of records.entries()) {
80
+ const page = pages[index];
81
+ if (page === undefined || !inventoryRecordMatches(record, page, run, index))
82
+ return replayIntegrity("Investigation replay inventory Evidence is inconsistent");
83
+ }
84
+ const result = parseArtifactInventoryEvidence(records);
85
+ if (!result.inventory.complete || !targetMatches(result.inventory, target))
86
+ return replayIntegrity("Investigation replay inventory commitment is inconsistent");
87
+ return ok({ records, result });
88
+ };
89
+ const inventoryRecordMatches = (record, page, run, index) => {
90
+ const offset = index * run.options.page_size;
91
+ const parameters = {
92
+ node_offset: offset,
93
+ node_limit: run.options.page_size,
94
+ occurrence_offset: offset,
95
+ occurrence_limit: run.options.page_size,
96
+ edge_offset: offset,
97
+ edge_limit: run.options.page_size,
98
+ max_entries: run.options.max_entries,
99
+ max_total_bytes: run.options.max_total_bytes,
100
+ max_entry_bytes: run.options.max_entry_bytes,
101
+ max_compression_ratio: run.options.max_compression_ratio,
102
+ max_depth: run.options.max_depth,
103
+ max_path_bytes: run.options.max_path_bytes,
104
+ integrity_policy: run.integrity_policy,
105
+ integrity_continue_approved: run.integrity_continue_approved,
106
+ max_integrity_mismatches: run.max_integrity_mismatches,
107
+ };
108
+ const locations = page.occurrences.items.map(({ logical_path: path }) => ({
109
+ kind: "artifact-path",
110
+ path,
111
+ }));
112
+ return (record.subject !== null &&
113
+ record.subject.name === record.subject.local_path.split("/").at(-1) &&
114
+ record.subject.digest.sha256 === page.manifest.root_sha256 &&
115
+ record.subject.format === page.manifest.root_format &&
116
+ record.subject.architecture === null &&
117
+ isDeepStrictEqual(record.provider, ARTIFACT_GRAPH_PROVIDER) &&
118
+ record.predicate_type === "rea.analysis/v2" &&
119
+ record.operation === "inventory_artifact" &&
120
+ isDeepStrictEqual(record.parameters, parameters) &&
121
+ record.raw_result === null &&
122
+ record.confidence === "observed" &&
123
+ record.authority === "shipped-artifact" &&
124
+ record.environment === null &&
125
+ isDeepStrictEqual(record.limitations, page.limitations) &&
126
+ isDeepStrictEqual(record.locations, locations) &&
127
+ record.evidence_links.length === 0 &&
128
+ isDeepStrictEqual(page.limits, traversalLimits(run)) &&
129
+ pageMatches(page.nodes, offset, run.options.page_size, page.manifest.node_count) &&
130
+ pageMatches(page.occurrences, offset, run.options.page_size, page.manifest.occurrence_count) &&
131
+ pageMatches(page.edges, offset, run.options.page_size, page.manifest.edge_count));
132
+ };
133
+ const sameSnapshotMetadata = (pages) => {
134
+ const first = pages[0];
135
+ return (first !== undefined &&
136
+ pages.every((page) => isDeepStrictEqual(page.manifest, first.manifest) &&
137
+ isDeepStrictEqual(page.limits, first.limits) &&
138
+ isDeepStrictEqual(page.provenance, first.provenance) &&
139
+ isDeepStrictEqual(page.integrity_contradictions, first.integrity_contradictions) &&
140
+ isDeepStrictEqual(page.limitations, first.limitations)));
141
+ };
142
+ const pageMatches = (page, offset, limit, total) => page.offset === offset &&
143
+ page.limit === limit &&
144
+ page.total === total &&
145
+ page.items.length === Math.max(0, Math.min(limit, page.total - offset)) &&
146
+ page.next_offset === (offset + limit < page.total ? offset + limit : null);
147
+ const traversalLimits = (run) => ({
148
+ max_entries: run.options.max_entries,
149
+ max_total_bytes: run.options.max_total_bytes,
150
+ max_entry_bytes: run.options.max_entry_bytes,
151
+ max_compression_ratio: run.options.max_compression_ratio,
152
+ max_depth: run.options.max_depth,
153
+ max_path_bytes: run.options.max_path_bytes,
154
+ });
155
+ const targetMatches = (inventory, target) => inventory.manifest.root_sha256 === target.root_sha256 &&
156
+ inventory.manifest.graph_sha256 === target.graph_sha256 &&
157
+ inventory.manifest.manifest_id === target.manifest_id &&
158
+ inventory.manifest.root_format === target.format;
159
+ const runLimitations = (left, right) => [
160
+ ...new Set([
161
+ ...left.result.inventory.limitations.map((item) => `Left: ${item}`),
162
+ ...right.result.inventory.limitations.map((item) => `Right: ${item}`),
163
+ AUTOMATIC_RUN_LIMITATION,
164
+ ]),
165
+ ].sort((first, second) => first.localeCompare(second));
166
+ const evidenceFor = (workspace, evidenceId) => evidenceId === null
167
+ ? undefined
168
+ : workspace.bundle.records.find(({ evidence_id: current }) => current === evidenceId);
169
+ const replayConflict = () => err(new InvestigationWorkspaceError("read", "revision-conflict"));
170
+ const replayIntegrity = (message, cause) => err(new EvidenceIntegrityError(message, cause === undefined ? undefined : { cause }));
@@ -37,23 +37,28 @@ export const runDoctor = async (target, host = systemDoctorHost()) => {
37
37
  remediation: "REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux.",
38
38
  classification: "unsupported_host",
39
39
  }));
40
- const candidates = [
41
- host.configuredHopperPath,
42
- DEFAULT_HOPPER,
43
- SYSTEM_LINUX_HOPPER,
44
- linuxHopperLauncherPath(homedir()),
45
- ...(await host.manualHopperPaths()),
46
- await host.brewHopperPath(),
47
- ].filter((value) => value !== undefined);
40
+ const candidates = host.configuredHopperPath === undefined
41
+ ? [
42
+ DEFAULT_HOPPER,
43
+ SYSTEM_LINUX_HOPPER,
44
+ linuxHopperLauncherPath(homedir()),
45
+ ...(await host.manualHopperPaths()),
46
+ await host.brewHopperPath(),
47
+ ].filter((value) => value !== undefined)
48
+ : [host.configuredHopperPath];
48
49
  let hopperPath;
49
50
  for (const candidate of new Set(candidates))
50
51
  if (await host.executable(candidate)) {
51
52
  hopperPath = candidate;
52
53
  break;
53
54
  }
54
- checks.push(check("hopper", hopperPath !== undefined, hopperPath, {
55
- remediation: "Run rea setup to install Hopper, or set HOPPER_LAUNCHER_PATH.",
56
- classification: "missing_analysis_engine",
55
+ checks.push(check("hopper", hopperPath !== undefined, hopperPath ?? host.configuredHopperPath, {
56
+ remediation: host.configuredHopperPath === undefined
57
+ ? "Run rea setup to install Hopper, or set HOPPER_LAUNCHER_PATH."
58
+ : "Unset or update HOPPER_LAUNCHER_PATH to an executable Hopper launcher.",
59
+ classification: host.configuredHopperPath === undefined
60
+ ? "missing_analysis_engine"
61
+ : "config_drift",
57
62
  }));
58
63
  if (host.platform === "linux" && hopperPath !== undefined)
59
64
  checks.push(check("hopper-version", await host.supportedLinuxHopper(hopperPath), hopperPath, {
@@ -0,0 +1,35 @@
1
+ import { createEvidence, } from "../domain/evidence.js";
2
+ import { jsonValueSchema } from "../domain/jsonValue.js";
3
+ /** Create Evidence v2 for one root-confined Electron observation. */
4
+ export const createElectronEvidence = (operation, input, result, provider) => createEvidence(undefined, provider, {
5
+ predicateType: operation === "list_electron_targets"
6
+ ? "rea.electron-target-list/v1"
7
+ : "rea.electron-page-inspection/v1",
8
+ operation,
9
+ parameters: parameters(input),
10
+ result: jsonValueSchema.parse(result),
11
+ confidence: "observed",
12
+ authority: "external-service",
13
+ environment: "target" in result
14
+ ? {
15
+ id: `${result.browser.product}@${result.browser.revision}`,
16
+ platform: process.platform,
17
+ architecture: process.arch,
18
+ isolation: "none",
19
+ }
20
+ : null,
21
+ limitations: result.limitations,
22
+ });
23
+ const parameters = (input) => ({
24
+ cdp_endpoint: input.cdp_endpoint,
25
+ allowed_file_roots: input.allowed_file_roots,
26
+ ...("target_id" in input
27
+ ? {
28
+ target_id: input.target_id,
29
+ observation_ms: input.observation_ms,
30
+ include_script_sources: input.include_script_sources,
31
+ source_capture_approved: input.source_capture_approved,
32
+ limits: input.limits,
33
+ }
34
+ : { offset: input.offset, limit: input.limit }),
35
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,46 @@
1
+ import { AnalysisCapabilityUnavailableError, AnalysisProtocolError, PermissionRequiredError, } from "../domain/errors.js";
2
+ import { err, ok } from "../domain/result.js";
3
+ import { createElectronEvidence } from "./ElectronEvidence.js";
4
+ /** Authorize and list root-confined Electron page targets. */
5
+ export const listElectronTargets = async (provider, authority, input, options = {}) => {
6
+ const ready = await prepare(provider, authority, input, "list_electron_targets");
7
+ if (!ready.ok)
8
+ return ready;
9
+ const result = await ready.value.listTargets(input, options);
10
+ return result.ok
11
+ ? ok(createElectronEvidence("list_electron_targets", input, result.value, ready.value.identity()))
12
+ : result;
13
+ };
14
+ /** Authorize and inspect one root-confined Electron page. */
15
+ export const inspectElectronPage = async (provider, authority, input, options = {}) => {
16
+ const ready = await prepare(provider, authority, input, "inspect_electron_page");
17
+ if (!ready.ok)
18
+ return ready;
19
+ const result = await ready.value.inspectPage(input, options);
20
+ return result.ok
21
+ ? ok(createElectronEvidence("inspect_electron_page", input, result.value, ready.value.identity()))
22
+ : result;
23
+ };
24
+ const prepare = async (provider, authority, input, operation) => {
25
+ if (authority === undefined)
26
+ return err(new AnalysisCapabilityUnavailableError("rea-cdp-electron", operation, "Electron observation permission policy is not configured"));
27
+ const authorized = await authority.authorize({
28
+ capability: "electron_observe",
29
+ roots: input.allowed_file_roots,
30
+ executables: [],
31
+ environment_names: [],
32
+ origins: [input.cdp_endpoint],
33
+ network: "loopback",
34
+ mount: false,
35
+ operation_identity: `${operation}:${"target_id" in input ? input.target_id : input.cdp_endpoint}`,
36
+ }, "read");
37
+ if (!authorized.ok)
38
+ return err(authorized.error instanceof PermissionRequiredError
39
+ ? authorized.error
40
+ : new AnalysisProtocolError(authorized.error.message, {
41
+ cause: authorized.error,
42
+ }));
43
+ return provider === undefined
44
+ ? err(new AnalysisCapabilityUnavailableError("rea-cdp-electron", operation, "Electron observation provider is not configured"))
45
+ : ok(provider);
46
+ };
@@ -35,7 +35,7 @@ export class EvidenceLedger {
35
35
  ? ok("duplicate")
36
36
  : err(new EvidenceIntegrityError("Conflicting evidence record"));
37
37
  }
38
- if (this.#records.size >= this.limits.maxRecords)
38
+ if (this.#exceedsRecordLimit(this.#records.size + 1, this.#unknownRevisions.size))
39
39
  return err(new EvidenceLimitError("records", this.limits.maxRecords));
40
40
  const bytes = serializedBytes(evidence);
41
41
  if (this.#bytes + bytes > this.limits.maxBytes)
@@ -229,7 +229,7 @@ export class EvidenceLedger {
229
229
  return ok(structuredClone(unknown));
230
230
  }
231
231
  #validateCandidate(records, unknowns) {
232
- if (records.size + unknowns.size > this.limits.maxRecords)
232
+ if (this.#exceedsRecordLimit(records.size, unknowns.size))
233
233
  return err(new EvidenceLimitError("records", this.limits.maxRecords));
234
234
  const bytes = [...records.values()].reduce((total, evidence) => total + serializedBytes(evidence), 0) +
235
235
  [...unknowns.values()].reduce((total, unknown) => total + serializedBytes(unknown), 0);
@@ -243,6 +243,9 @@ export class EvidenceLedger {
243
243
  }
244
244
  return ok(bytes);
245
245
  }
246
+ #exceedsRecordLimit(recordCount, unknownCount) {
247
+ return recordCount + unknownCount > this.limits.maxRecords;
248
+ }
246
249
  #commit(records, unknowns, bytes) {
247
250
  this.#records.clear();
248
251
  for (const [id, evidence] of records)
@@ -150,6 +150,36 @@ export class PermissionAuthority {
150
150
  this.policy = candidate;
151
151
  return ok(null);
152
152
  }
153
+ /** Atomically replace reloadable ceilings and persisted grants. */
154
+ async replaceConfiguredPolicy(configuration) {
155
+ const ceilings = await canonicalizePermissionCeilings(configuration.ceilings);
156
+ if (!ceilings.ok)
157
+ return ceilings;
158
+ const grants = [];
159
+ for (const grant of [
160
+ ...configuration.administratorGrants,
161
+ ...configuration.projectGrants,
162
+ ]) {
163
+ const canonical = await canonicalizeGrant(grant);
164
+ if (!canonical.ok)
165
+ return canonical;
166
+ grants.push(canonical.value);
167
+ }
168
+ let candidate = {
169
+ ...reloadPermissionCeilings(this.policy, ceilings.value),
170
+ grants: this.policy.grants.filter(({ lifetime }) => lifetime !== "administrator" && lifetime !== "project"),
171
+ };
172
+ for (const grant of grants) {
173
+ const added = grantPermission(candidate, grant);
174
+ if (!added.ok)
175
+ return err(new PermissionPathError(grant.grant_id, "io", {
176
+ cause: added.error,
177
+ }));
178
+ candidate = added.value;
179
+ }
180
+ this.policy = candidate;
181
+ return ok(null);
182
+ }
153
183
  /** Replace ceilings without restarting the MCP process. */
154
184
  async reload(ceilings) {
155
185
  const canonical = await canonicalizePermissionCeilings(ceilings);
@@ -193,11 +193,7 @@ export const releaseProcessResources = async (options) => {
193
193
  failure ??= "filesystem checkpoint cleanup failed";
194
194
  }
195
195
  if (options.terminal !== undefined && process.platform !== "win32") {
196
- const processGroupIds = new Set([
197
- options.terminal.pid,
198
- ...options.sampledProcessGroupIds,
199
- ]);
200
- for (const processGroupId of processGroupIds) {
196
+ for (const processGroupId of new Set(options.capturedProcessGroupIds)) {
201
197
  const cleaned = await cleanupOwnedProcessGroup({
202
198
  runId: options.runId,
203
199
  leaderPid: processGroupId,
@@ -10,6 +10,7 @@ import { ProcessCheckpoints } from "./ProcessCheckpoints.js";
10
10
  import { TerminalRenderer } from "./TerminalRenderer.js";
11
11
  import { startCommandShimReplay, } from "./CommandShimReplay.js";
12
12
  import { normalizeProcessText } from "./ProcessNormalization.js";
13
+ import { selectCapturedProcessGroupIds } from "./ProcessOwnership.js";
13
14
  import { awaitTerminalExit, buildCaptureResult, captureTerminalFrames, createRunManifest, observeSettlement, prepareProcessCapture, releaseProcessResources, resolveProcessResult, } from "./ProcessCaptureLifecycle.js";
14
15
  import { assertNotCancelled } from "./ProcessCaptureAuthority.js";
15
16
  export { probeProcessCaptureCapability } from "./ProcessCaptureCapability.js";
@@ -85,7 +86,7 @@ const cleanupFailedStartup = async (options) => {
85
86
  options.terminal?.kill("SIGKILL");
86
87
  const cleanupFailure = await releaseProcessResources({
87
88
  ...options,
88
- sampledProcessGroupIds: [],
89
+ capturedProcessGroupIds: options.terminal === undefined ? [] : [options.terminal.pid],
89
90
  });
90
91
  if (cleanupFailure !== undefined)
91
92
  throw new ProcessCaptureError(cleanupFailure, {
@@ -138,7 +139,13 @@ const startCaptureRuntime = async (options) => {
138
139
  renderer,
139
140
  started,
140
141
  });
141
- const stopSampler = startProcessSampler(terminal.pid, started, scenario.limits.processes, options.samples);
142
+ const stopSampler = startProcessSampler({
143
+ rootPid: terminal.pid,
144
+ runId: options.runId,
145
+ started,
146
+ limit: scenario.limits.processes,
147
+ samples: options.samples,
148
+ });
142
149
  return {
143
150
  replay,
144
151
  renderer,
@@ -182,7 +189,9 @@ const finishProcessRun = async (options) => {
182
189
  checkpoints: options.runtime?.checkpoints,
183
190
  runId: options.runId,
184
191
  temporaryRoot: options.temporaryRoot,
185
- sampledProcessGroupIds: options.samples.flatMap(({ process_group_id }) => process_group_id === null ? [] : [process_group_id]),
192
+ capturedProcessGroupIds: options.runtime === undefined
193
+ ? []
194
+ : selectCapturedProcessGroupIds(options.runtime.terminal.pid, options.samples),
186
195
  });
187
196
  let { capture, executionFailure } = options;
188
197
  if (capture !== undefined) {
@@ -209,10 +218,7 @@ const completeCapture = async (options) => {
209
218
  const { reason } = options.exit;
210
219
  if (reason === "cancelled")
211
220
  throw processCaptureCancelled();
212
- const settlement = await observeSettlement(options.runId, [
213
- runtime.terminal.pid,
214
- ...options.samples.flatMap(({ process_group_id }) => process_group_id === null ? [] : [process_group_id]),
215
- ], scenario.settle_ms);
221
+ const settlement = await observeSettlement(options.runId, selectCapturedProcessGroupIds(runtime.terminal.pid, options.samples), scenario.settle_ms);
216
222
  runtime.checkpoints.trigger("settled");
217
223
  const samplingPartial = (await runtime.stopSampler()).partial;
218
224
  assertNotCancelled(options.signal);
@@ -2,6 +2,19 @@ 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
+ /** Select the PTY root group and groups led by an observed captured process. */
6
+ export const selectCapturedProcessGroupIds = (rootPid, samples) => {
7
+ const processGroupIds = new Set([rootPid]);
8
+ for (const sample of samples) {
9
+ // A sampled member may transiently inherit an unrelated group. POSIX group
10
+ // leaders have pid === pgid, so only that observation establishes that the
11
+ // group leader itself belonged to the captured tree. Token checks below
12
+ // remain the final authority immediately before observation or signaling.
13
+ if (sample.pid === sample.process_group_id)
14
+ processGroupIds.add(sample.pid);
15
+ }
16
+ return [...processGroupIds];
17
+ };
5
18
  /** Parse the NUL-delimited Linux process environment without nameless keys. */
6
19
  export const parseProcessEnvironment = (value) => Object.fromEntries(value
7
20
  .split("\0")
@@ -16,17 +29,18 @@ const systemHost = {
16
29
  return [];
17
30
  const { stdout } = await execFileAsync("ps", [
18
31
  "-axo",
19
- "pid=,ppid=,pgid=,command=",
32
+ "pid=,ppid=,pgid=,stat=,command=",
20
33
  ]);
21
34
  return stdout
22
35
  .split("\n")
23
- .map((line) => /\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)/u.exec(line))
36
+ .map((line) => /\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)/u.exec(line))
24
37
  .filter((match) => match !== null)
25
38
  .map((match) => ({
26
39
  pid: Number(match[1]),
27
40
  parentPid: Number(match[2]),
28
41
  processGroupId: Number(match[3]),
29
- command: match[4] ?? "",
42
+ state: match[4] ?? "",
43
+ command: match[5] ?? "",
30
44
  }))
31
45
  .filter((member) => member.processGroupId === processGroupId);
32
46
  },
@@ -44,6 +58,8 @@ const systemHost = {
44
58
  process.kill(-processGroupId, signal);
45
59
  },
46
60
  };
61
+ /** Read the capture run token currently exposed by one live process. */
62
+ export const readProcessRunId = async (pid, host = systemHost) => (await host.environment(pid)).REA_PROCESS_RUN_ID;
47
63
  /**
48
64
  * Verify run-token ownership before signaling a POSIX process group.
49
65
  *
@@ -59,9 +75,10 @@ export const cleanupOwnedProcessGroup = async (ownership, host = systemHost) =>
59
75
  catch {
60
76
  return { cleaned: false, reason: "process group could not be inspected" };
61
77
  }
62
- if (members.length === 0)
78
+ const liveMembers = liveProcessGroupMembers(members);
79
+ if (liveMembers.length === 0)
63
80
  return { cleaned: true, signaled: false };
64
- const leader = members.find((member) => member.pid === ownership.leaderPid);
81
+ const leader = liveMembers.find((member) => member.pid === ownership.leaderPid);
65
82
  if (leader !== undefined) {
66
83
  if (ownership.expectedParentPid !== undefined &&
67
84
  leader.parentPid !== ownership.expectedParentPid)
@@ -76,24 +93,25 @@ export const cleanupOwnedProcessGroup = async (ownership, host = systemHost) =>
76
93
  reason: "owned launcher command identity did not match",
77
94
  };
78
95
  }
79
- for (const member of members) {
80
- let environment;
96
+ const failures = [];
97
+ for (const member of liveMembers) {
81
98
  try {
82
- environment = await host.environment(member.pid);
99
+ const environment = await host.environment(member.pid);
100
+ if (environment.REA_PROCESS_RUN_ID !== ownership.runId)
101
+ failures.push({ pid: member.pid, reason: "run-token-mismatch" });
83
102
  }
84
103
  catch {
85
- return {
86
- cleaned: false,
87
- reason: "process ownership could not be revalidated",
88
- };
89
- }
90
- if (environment.REA_PROCESS_RUN_ID !== ownership.runId) {
91
- return {
92
- cleaned: false,
93
- reason: "process group contains an unowned or PID-reused process",
94
- };
104
+ failures.push({ pid: member.pid, reason: "environment-unreadable" });
95
105
  }
96
106
  }
107
+ if (failures.length > 0)
108
+ return {
109
+ cleaned: false,
110
+ reason: failures.some(({ reason }) => reason === "run-token-mismatch")
111
+ ? "process group contains an unowned or PID-reused process"
112
+ : "process ownership could not be revalidated",
113
+ failures,
114
+ };
97
115
  try {
98
116
  host.signalGroup(ownership.processGroupId, "SIGKILL");
99
117
  }
@@ -116,9 +134,10 @@ export const observeOwnedProcessGroup = async (ownership, host = systemHost) =>
116
134
  reason: "process group could not be inspected",
117
135
  };
118
136
  }
119
- if (members.length === 0)
137
+ const liveMembers = liveProcessGroupMembers(members);
138
+ if (liveMembers.length === 0)
120
139
  return { state: "empty" };
121
- for (const member of members) {
140
+ for (const member of liveMembers) {
122
141
  try {
123
142
  if ((await host.environment(member.pid)).REA_PROCESS_RUN_ID !==
124
143
  ownership.runId)
@@ -136,6 +155,7 @@ export const observeOwnedProcessGroup = async (ownership, host = systemHost) =>
136
155
  }
137
156
  return { state: "alive" };
138
157
  };
158
+ const liveProcessGroupMembers = (members) => members.filter(({ state }) => !state.startsWith("Z"));
139
159
  const commandMatches = (actual, expected) => {
140
160
  const normalizedActual = actual.trim();
141
161
  const normalizedExpected = expected.trim();
@@ -1,8 +1,19 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { readFile, readdir } from "node:fs/promises";
3
3
  import { promisify } from "node:util";
4
+ import { readProcessRunId } from "./ProcessOwnership.js";
4
5
  const execFileAsync = promisify(execFile);
5
6
  const PROC_READ_CONCURRENCY = 64;
7
+ /** Prove that forkpty completed session setup and installed the run token. */
8
+ export const isInitializedPtyRoot = (options) => options.before.pid === options.rootPid &&
9
+ options.before.process_group_id === options.rootPid &&
10
+ options.before.session_id === options.rootPid &&
11
+ options.after.pid === options.rootPid &&
12
+ options.after.parent_pid === options.before.parent_pid &&
13
+ options.after.process_group_id === options.rootPid &&
14
+ options.after.session_id === options.rootPid &&
15
+ options.after.startTime === options.before.startTime &&
16
+ options.observedRunId === options.expectedRunId;
6
17
  const isExpectedProcReadFailure = (cause) => cause instanceof Error &&
7
18
  "code" in cause &&
8
19
  (cause.code === "ENOENT" ||
@@ -214,6 +225,34 @@ const readPsRows = async (signal) => {
214
225
  row.parent_pid >= 0)
215
226
  .sort((left, right) => left.pid - right.pid);
216
227
  };
228
+ const readRootIdentity = async (rootPid, signal) => process.platform === "linux"
229
+ ? readProcessStat(rootPid, signal)
230
+ : (await readPsRows(signal)).find(({ pid }) => pid === rootPid);
231
+ const inspectInitializedPtyRoot = async (rootPid, runId, signal) => {
232
+ const before = await readRootIdentity(rootPid, signal);
233
+ if (before === undefined ||
234
+ before.process_group_id !== rootPid ||
235
+ before.session_id !== rootPid)
236
+ return undefined;
237
+ let observedRunId;
238
+ try {
239
+ observedRunId = await readProcessRunId(rootPid);
240
+ }
241
+ catch {
242
+ return undefined;
243
+ }
244
+ const after = await readRootIdentity(rootPid, signal);
245
+ if (after === undefined ||
246
+ !isInitializedPtyRoot({
247
+ rootPid,
248
+ expectedRunId: runId,
249
+ before,
250
+ observedRunId,
251
+ after,
252
+ }))
253
+ return undefined;
254
+ return { startTime: after.startTime };
255
+ };
217
256
  const samplePs = async (context) => {
218
257
  const { rootPid, limit, signal } = context;
219
258
  if (limit <= 0)
@@ -277,25 +316,37 @@ const sampleProcesses = async (context) => {
277
316
  return samples;
278
317
  };
279
318
  /** Start bounded process-tree sampling and expose an awaited stop. */
280
- export const startProcessSampler = (rootPid, started, limit, samples) => {
319
+ export const startProcessSampler = (options) => {
320
+ const { rootPid, runId, started, limit, samples } = options;
281
321
  const identities = new Map();
282
322
  const lastObservations = new Map();
283
323
  let pending;
284
324
  let stopped = false;
285
325
  let abortCurrent;
286
326
  let partial = false;
327
+ let rootInitialized = false;
287
328
  const sample = () => {
288
329
  if (stopped || pending !== undefined)
289
330
  return;
290
331
  const controller = new AbortController();
291
332
  abortCurrent = () => controller.abort();
292
- pending = sampleProcesses({
293
- rootPid,
294
- elapsedMs: Date.now() - started,
295
- limit: limit + 1,
296
- identities,
297
- signal: controller.signal,
298
- })
333
+ pending = (async () => {
334
+ if (!rootInitialized) {
335
+ const identity = await inspectInitializedPtyRoot(rootPid, runId, controller.signal);
336
+ if (identity === undefined)
337
+ return [];
338
+ if (identity.startTime !== undefined)
339
+ identities.set(rootPid, identity.startTime);
340
+ rootInitialized = true;
341
+ }
342
+ return sampleProcesses({
343
+ rootPid,
344
+ elapsedMs: Date.now() - started,
345
+ limit: limit + 1,
346
+ identities,
347
+ signal: controller.signal,
348
+ });
349
+ })()
299
350
  .then((values) => {
300
351
  for (const value of values) {
301
352
  const observation = JSON.stringify({
@@ -10,6 +10,7 @@ const grantSchema = z.object({
10
10
  capability: z.enum([
11
11
  "process_capture",
12
12
  "browser_observe",
13
+ "electron_observe",
13
14
  "evidence_read",
14
15
  "evidence_write",
15
16
  "investigation_input",