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.
- package/README.md +17 -15
- package/dist/application/BinarySession.js +14 -4
- package/dist/application/BrowserEvidence.js +87 -15
- package/dist/application/BrowserObservationService.js +65 -1
- package/dist/application/CapabilityInventory.js +7 -0
- package/dist/application/ClientConfigPath.js +33 -0
- package/dist/application/CrossVersionInventory.js +16 -3
- package/dist/application/CrossVersionInvestigation.js +17 -62
- package/dist/application/CrossVersionInvestigationEvidence.js +64 -0
- package/dist/application/CrossVersionInvestigationReplay.js +170 -0
- package/dist/application/Doctor.js +16 -11
- package/dist/application/ElectronEvidence.js +35 -0
- package/dist/application/ElectronObservationPort.js +1 -0
- package/dist/application/ElectronObservationService.js +46 -0
- package/dist/application/EvidenceLedger.js +5 -2
- package/dist/application/PermissionAuthority.js +30 -0
- package/dist/application/ProcessCaptureLifecycle.js +1 -5
- package/dist/application/ProcessHarness.js +13 -7
- package/dist/application/ProcessOwnership.js +40 -20
- package/dist/application/ProcessSampling.js +59 -8
- package/dist/application/ProjectPermissionStore.js +1 -0
- package/dist/application/ReferenceSourceImport.js +13 -16
- package/dist/application/Setup.js +89 -30
- package/dist/application/SetupClients.js +7 -4
- package/dist/application/SetupInstallFailure.js +24 -0
- package/dist/application/SetupPlan.js +11 -7
- package/dist/application/SetupSkill.js +20 -3
- package/dist/application/Uninstall.js +20 -7
- package/dist/application/Upgrade.js +14 -3
- package/dist/browser/CdpBrowserProvider.js +154 -51
- package/dist/browser/CdpCaptureCompleteness.js +90 -0
- package/dist/browser/CdpCaptureDocuments.js +180 -21
- package/dist/browser/CdpCaptureEvents.js +342 -43
- package/dist/browser/CdpCaptureValues.js +5 -7
- package/dist/browser/CdpConnection.js +47 -24
- package/dist/browser/CdpElectronInspection.js +330 -0
- package/dist/browser/CdpElectronProvider.js +134 -0
- package/dist/browser/CdpEndpoint.js +64 -5
- package/dist/browser/CdpObservationSession.js +301 -0
- package/dist/browser/CdpPageCapture.js +159 -56
- package/dist/browser/CdpSafeMetadata.js +203 -0
- package/dist/browser/CdpScreenshot.js +101 -0
- package/dist/browser/CdpTargetSession.js +47 -0
- package/dist/browser/CdpWebMcpDiscovery.js +242 -0
- package/dist/browser/ElectronFileScope.js +50 -0
- package/dist/browser/PngVisualDiff.js +177 -0
- package/dist/browser/SensitiveTextCapture.js +25 -0
- package/dist/browser/WebSourceMapFetcher.js +357 -0
- package/dist/catalogIdentity.js +8 -0
- package/dist/cli.js +4 -0
- package/dist/cliBrowserAdvancedCommands.js +169 -0
- package/dist/cliBrowserCommands.js +177 -1
- package/dist/cliElectronCommands.js +114 -0
- package/dist/cliInvestigationCommands.js +15 -8
- package/dist/cliPolicyCommands.js +47 -2
- package/dist/config.js +40 -5
- package/dist/contracts/browserToolContracts.js +301 -0
- package/dist/contracts/electronToolContracts.js +73 -0
- package/dist/contracts/toolContracts.js +2 -0
- package/dist/domain/browserCompleteness.js +101 -0
- package/dist/domain/browserObservation.js +240 -14
- package/dist/domain/browserSession.js +63 -0
- package/dist/domain/electronObservation.js +162 -0
- package/dist/domain/investigationWorkspace.js +4 -0
- package/dist/domain/jsonShape.js +125 -0
- package/dist/domain/referenceSourceGraph.js +3 -1
- package/dist/domain/webBundleAnalysis.js +195 -0
- package/dist/domain/webBundleAnalyzer.js +423 -0
- package/dist/domain/webCaptureDiff.js +184 -0
- package/dist/domain/webContentArtifact.js +47 -0
- package/dist/domain/webInventory.js +61 -0
- package/dist/domain/webMcpDiscovery.js +68 -0
- package/dist/domain/webScreenshot.js +114 -0
- package/dist/generatedPackageMetadata.js +2 -2
- package/dist/hopper/BridgeLauncher.js +2 -3
- package/dist/hopper/HopperClient.js +51 -11
- package/dist/hopper/HopperProvider.js +6 -1
- package/dist/main.js +36 -31
- package/dist/native/NativeMacOSProvider.js +3 -2
- package/dist/native/NativeMachoInspection.js +21 -6
- package/dist/server/createServer.js +7 -0
- package/dist/server/registerBrowserTools.js +61 -2
- package/dist/server/registerElectronTools.js +36 -0
- package/dist/server/registerInvestigationTools.js +11 -12
- package/dist/server/registerSessionTools.js +1 -0
- package/package.json +5 -1
- package/scripts/rea.mjs +2 -2
- 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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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:
|
|
56
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
+
const liveMembers = liveProcessGroupMembers(members);
|
|
79
|
+
if (liveMembers.length === 0)
|
|
63
80
|
return { cleaned: true, signaled: false };
|
|
64
|
-
const leader =
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
+
const liveMembers = liveProcessGroupMembers(members);
|
|
138
|
+
if (liveMembers.length === 0)
|
|
120
139
|
return { state: "empty" };
|
|
121
|
-
for (const member of
|
|
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 = (
|
|
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 =
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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({
|