rea-agents 1.4.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 +37 -13
- package/dist/application/AnalysisSnapshotCache.js +8 -6
- package/dist/application/ArtifactGraphConstruction.js +11 -2
- package/dist/application/ArtifactInventory.js +3 -2
- package/dist/application/AuthorizedArtifactInventory.js +2 -1
- package/dist/application/BinarySession.js +26 -11
- package/dist/application/BoundedJsonFiles.js +6 -29
- package/dist/application/BrowserEvidence.js +107 -0
- package/dist/application/BrowserObservationPort.js +1 -0
- package/dist/application/BrowserObservationService.js +116 -0
- package/dist/application/CapabilityInventory.js +14 -0
- package/dist/application/ClientConfigPath.js +33 -0
- package/dist/application/ConfiguredRoots.js +19 -0
- package/dist/application/CrossVersionInventory.js +16 -3
- package/dist/application/CrossVersionInvestigation.js +23 -62
- package/dist/application/CrossVersionInvestigationEvidence.js +64 -0
- package/dist/application/CrossVersionInvestigationReplay.js +170 -0
- package/dist/application/DeferredFileAuthorization.js +61 -0
- package/dist/application/DirectAnalysis.js +38 -18
- 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 +11 -7
- package/dist/application/InvestigationWorkspaceStore.js +2 -26
- package/dist/application/PermissionAuthority.js +30 -0
- package/dist/application/ProcessCaptureAuthority.js +3 -2
- package/dist/application/ProcessCaptureLifecycle.js +19 -13
- package/dist/application/ProcessCheckpoints.js +1 -1
- package/dist/application/ProcessCli.js +17 -5
- package/dist/application/ProcessHarness.js +19 -26
- package/dist/application/ProcessNormalization.js +17 -2
- package/dist/application/ProcessOwnership.js +44 -23
- package/dist/application/ProcessSampling.js +111 -25
- package/dist/application/ProjectPermissionStore.js +107 -3
- package/dist/application/ReferenceSourceImport.js +13 -16
- package/dist/application/ReferenceSourceImportPolicy.js +3 -2
- 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/TerminalRenderer.js +1 -1
- package/dist/application/Uninstall.js +20 -7
- package/dist/application/Upgrade.js +14 -3
- package/dist/artifacts/MachOSliceArtifactReader.js +1 -1
- package/dist/artifacts/NativeDmgArtifactReader.js +1 -1
- package/dist/artifacts/SafeOutputTree.js +3 -3
- package/dist/browser/CdpBrowserProvider.js +261 -0
- package/dist/browser/CdpCaptureCompleteness.js +90 -0
- package/dist/browser/CdpCaptureDocuments.js +299 -0
- package/dist/browser/CdpCaptureEvents.js +547 -0
- package/dist/browser/CdpCaptureStorage.js +53 -0
- package/dist/browser/CdpCaptureValues.js +68 -0
- package/dist/browser/CdpConnection.js +241 -0
- package/dist/browser/CdpElectronInspection.js +330 -0
- package/dist/browser/CdpElectronProvider.js +134 -0
- package/dist/browser/CdpEndpoint.js +173 -0
- package/dist/browser/CdpObservationSession.js +301 -0
- package/dist/browser/CdpOptionalCommand.js +15 -0
- package/dist/browser/CdpPageCapture.js +376 -0
- 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 +10 -0
- package/dist/cli.js +6 -0
- package/dist/cliBrowserAdvancedCommands.js +169 -0
- package/dist/cliBrowserCommands.js +312 -0
- package/dist/cliElectronCommands.js +114 -0
- package/dist/cliInvestigationCommands.js +26 -34
- package/dist/cliPolicyCommands.js +60 -12
- package/dist/config.js +80 -1
- package/dist/contracts/browserToolContracts.js +381 -0
- package/dist/contracts/electronToolContracts.js +73 -0
- package/dist/contracts/errorSchemas.js +2 -0
- package/dist/contracts/toolContracts.js +5 -5
- package/dist/domain/browserCompleteness.js +101 -0
- package/dist/domain/browserObservation.js +587 -0
- package/dist/domain/browserSession.js +63 -0
- package/dist/domain/changedBehavior.js +4 -15
- package/dist/domain/electronObservation.js +162 -0
- package/dist/domain/errors.js +41 -0
- package/dist/domain/investigationWorkspace.js +4 -0
- package/dist/domain/jsonLimits.js +26 -0
- package/dist/domain/jsonShape.js +125 -0
- package/dist/domain/permissionPolicy.js +6 -2
- package/dist/domain/processCapture.js +3 -1
- package/dist/domain/processComparison.js +22 -34
- package/dist/domain/reconstructionVerification.js +2 -15
- package/dist/domain/reconstructionVerificationSchemas.js +2 -0
- package/dist/domain/referenceSourceGraph.js +3 -1
- package/dist/domain/staticRuntimeCorrelation.js +2 -0
- package/dist/domain/symbolAnalysis.js +1 -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 +42 -31
- package/dist/native/CommandRunner.js +11 -4
- package/dist/native/NativeMacOSProvider.js +3 -2
- package/dist/native/NativeMachoInspection.js +21 -6
- package/dist/server/createServer.js +14 -0
- package/dist/server/registerBrowserTools.js +95 -0
- package/dist/server/registerElectronTools.js +36 -0
- package/dist/server/registerInvestigationTools.js +31 -37
- package/dist/server/registerProcessComparisonTool.js +2 -0
- package/dist/server/registerSessionTools.js +16 -2
- package/dist/server/runDerivedOperation.js +10 -2
- package/package.json +6 -1
- package/scripts/rea.mjs +2 -2
- package/skills/rea-analysis/SKILL.md +28 -3
|
@@ -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)
|
|
@@ -85,7 +85,7 @@ export class EvidenceLedger {
|
|
|
85
85
|
}
|
|
86
86
|
/** Export records in deterministic, semantically irrelevant ID order. */
|
|
87
87
|
export() {
|
|
88
|
-
return createEvidenceBundle([...this.#records.values()], [...this.#unknownRevisions.values()]);
|
|
88
|
+
return structuredClone(createEvidenceBundle([...this.#records.values()], [...this.#unknownRevisions.values()]));
|
|
89
89
|
}
|
|
90
90
|
/** Check whether one immutable Evidence ID is present in this ledger. */
|
|
91
91
|
has(evidenceId) {
|
|
@@ -162,7 +162,7 @@ export class EvidenceLedger {
|
|
|
162
162
|
if (!checked.ok)
|
|
163
163
|
return checked;
|
|
164
164
|
this.#commit(pendingRecords, pendingUnknowns, checked.value);
|
|
165
|
-
return ok(unknown);
|
|
165
|
+
return ok(structuredClone(unknown));
|
|
166
166
|
}
|
|
167
167
|
/** Apply one approved full-state update using optimistic revision matching. */
|
|
168
168
|
updateUnknown(input, mutationEvidence) {
|
|
@@ -187,7 +187,8 @@ export class EvidenceLedger {
|
|
|
187
187
|
(filters.severity === undefined ||
|
|
188
188
|
unknown.severity === filters.severity) &&
|
|
189
189
|
(filters.domain === undefined || unknown.domain === filters.domain))
|
|
190
|
-
.sort((left, right) => left.unknown_id.localeCompare(right.unknown_id))
|
|
190
|
+
.sort((left, right) => left.unknown_id.localeCompare(right.unknown_id))
|
|
191
|
+
.map((unknown) => structuredClone(unknown));
|
|
191
192
|
}
|
|
192
193
|
/** Return current resolution validity; imported invalid states are rejected. */
|
|
193
194
|
verifyUnknownResolution(unknownId) {
|
|
@@ -197,7 +198,7 @@ export class EvidenceLedger {
|
|
|
197
198
|
return ok({
|
|
198
199
|
valid: unknown.status === "resolved",
|
|
199
200
|
truthVerified: unknown.resolution?.disposition === "verified",
|
|
200
|
-
unknown,
|
|
201
|
+
unknown: structuredClone(unknown),
|
|
201
202
|
});
|
|
202
203
|
}
|
|
203
204
|
/** Clear records when the owning session closes. */
|
|
@@ -225,10 +226,10 @@ export class EvidenceLedger {
|
|
|
225
226
|
if (!checked.ok)
|
|
226
227
|
return checked;
|
|
227
228
|
this.#commit(pendingRecords, pendingUnknowns, checked.value);
|
|
228
|
-
return ok(unknown);
|
|
229
|
+
return ok(structuredClone(unknown));
|
|
229
230
|
}
|
|
230
231
|
#validateCandidate(records, unknowns) {
|
|
231
|
-
if (records.size
|
|
232
|
+
if (this.#exceedsRecordLimit(records.size, unknowns.size))
|
|
232
233
|
return err(new EvidenceLimitError("records", this.limits.maxRecords));
|
|
233
234
|
const bytes = [...records.values()].reduce((total, evidence) => total + serializedBytes(evidence), 0) +
|
|
234
235
|
[...unknowns.values()].reduce((total, unknown) => total + serializedBytes(unknown), 0);
|
|
@@ -242,6 +243,9 @@ export class EvidenceLedger {
|
|
|
242
243
|
}
|
|
243
244
|
return ok(bytes);
|
|
244
245
|
}
|
|
246
|
+
#exceedsRecordLimit(recordCount, unknownCount) {
|
|
247
|
+
return recordCount + unknownCount > this.limits.maxRecords;
|
|
248
|
+
}
|
|
245
249
|
#commit(records, unknowns, bytes) {
|
|
246
250
|
this.#records.clear();
|
|
247
251
|
for (const [id, evidence] of records)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { lstat, open, readFile, realpath, unlink, } from "node:fs/promises";
|
|
2
2
|
import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
|
|
3
3
|
import writeFileAtomic from "write-file-atomic";
|
|
4
|
+
import { isJsonWithinLimits } from "../domain/jsonLimits.js";
|
|
4
5
|
import { InvestigationWorkspaceError } from "../domain/errors.js";
|
|
5
6
|
import { parseInvestigationWorkspace, serializeInvestigationWorkspace, } from "../domain/investigationWorkspace.js";
|
|
6
7
|
import { err, ok } from "../domain/result.js";
|
|
@@ -97,7 +98,7 @@ const readWorkspaceFile = async (destination, policy) => {
|
|
|
97
98
|
catch (cause) {
|
|
98
99
|
return err(new InvestigationWorkspaceError("read", "invalid-json", { cause }));
|
|
99
100
|
}
|
|
100
|
-
if (!
|
|
101
|
+
if (!isJsonWithinLimits(decoded, policy))
|
|
101
102
|
return err(new InvestigationWorkspaceError("read", "too-large"));
|
|
102
103
|
try {
|
|
103
104
|
return ok(parseInvestigationWorkspace(decoded));
|
|
@@ -182,31 +183,6 @@ const validateNextRevision = (current, next, expectedRevision) => {
|
|
|
182
183
|
? ok(null)
|
|
183
184
|
: err(new InvestigationWorkspaceError("update", "revision-conflict"));
|
|
184
185
|
};
|
|
185
|
-
const withinJsonLimits = (root, policy) => {
|
|
186
|
-
const pending = [
|
|
187
|
-
{ value: root, depth: 0 },
|
|
188
|
-
];
|
|
189
|
-
let nodes = 0;
|
|
190
|
-
while (pending.length > 0) {
|
|
191
|
-
const current = pending.pop();
|
|
192
|
-
if (current === undefined)
|
|
193
|
-
break;
|
|
194
|
-
nodes += 1;
|
|
195
|
-
if (nodes > policy.maxNodes || current.depth > policy.maxDepth)
|
|
196
|
-
return false;
|
|
197
|
-
if (typeof current.value === "string" &&
|
|
198
|
-
current.value.length > policy.maxStringLength)
|
|
199
|
-
return false;
|
|
200
|
-
if (typeof current.value !== "object" || current.value === null)
|
|
201
|
-
continue;
|
|
202
|
-
for (const [key, value] of Object.entries(current.value)) {
|
|
203
|
-
if (key.length > policy.maxStringLength)
|
|
204
|
-
return false;
|
|
205
|
-
pending.push({ value, depth: current.depth + 1 });
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
return true;
|
|
209
|
-
};
|
|
210
186
|
const isFileNotFound = (cause) => errorCode(cause) === "ENOENT";
|
|
211
187
|
const isAlreadyExists = (cause) => errorCode(cause) === "EEXIST";
|
|
212
188
|
const errorCode = (cause) => typeof cause === "object" && cause !== null && "code" in cause
|
|
@@ -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);
|
|
@@ -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
|
|
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
|
|
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) {
|
|
@@ -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";
|
|
@@ -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,
|
|
@@ -219,7 +215,6 @@ export const captureTerminalFrames = (options) => {
|
|
|
219
215
|
let outputBytes = 0;
|
|
220
216
|
let truncated = false;
|
|
221
217
|
options.terminal.onData((data) => {
|
|
222
|
-
options.onFirstOutput();
|
|
223
218
|
options.onOutput();
|
|
224
219
|
const bytes = Buffer.byteLength(data);
|
|
225
220
|
if (options.frames.length >= options.scenario.limits.frames ||
|
|
@@ -256,7 +251,7 @@ export const resolveProcessResult = (capture, executionFailure, cleanupFailure)
|
|
|
256
251
|
throw new ProcessCaptureError("process capture produced no result");
|
|
257
252
|
return capture;
|
|
258
253
|
};
|
|
259
|
-
export const prepareProcessCapture = async (scenario, policy, signal) => {
|
|
254
|
+
export const prepareProcessCapture = async (scenario, policy, signal, host = systemProcessPreparationHost) => {
|
|
260
255
|
const decision = authorizeProcessScenario(scenario, policy);
|
|
261
256
|
if (!decision.allowed)
|
|
262
257
|
throw new ProcessCaptureError(decision.reason, {
|
|
@@ -266,11 +261,22 @@ export const prepareProcessCapture = async (scenario, policy, signal) => {
|
|
|
266
261
|
await assertRealPathAuthority(scenario, policy);
|
|
267
262
|
assertNotCancelled(signal);
|
|
268
263
|
const before = await snapshotRoots(scenario, signal);
|
|
269
|
-
const temporaryRoot = await
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
264
|
+
const temporaryRoot = await host.createTemporaryRoot();
|
|
265
|
+
try {
|
|
266
|
+
const runId = randomUUID();
|
|
267
|
+
const home = join(temporaryRoot, "home");
|
|
268
|
+
await host.createHome(home);
|
|
269
|
+
return { temporaryRoot, runId, home, before };
|
|
270
|
+
}
|
|
271
|
+
catch (cause) {
|
|
272
|
+
await host.cleanup(temporaryRoot);
|
|
273
|
+
throw cause;
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
const systemProcessPreparationHost = {
|
|
277
|
+
createTemporaryRoot: () => mkdtemp(join(tmpdir(), "rea-process-")),
|
|
278
|
+
createHome: (path) => mkdir(path),
|
|
279
|
+
cleanup: (path) => rm(path, { recursive: true, force: true }),
|
|
274
280
|
};
|
|
275
281
|
const processPolicyMessage = (reason) => {
|
|
276
282
|
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() {
|
|
@@ -79,7 +79,13 @@ export const compareProcessEvidenceFiles = async (leftPath, rightPath) => {
|
|
|
79
79
|
}
|
|
80
80
|
};
|
|
81
81
|
const parseCaptureEvidence = (input) => {
|
|
82
|
-
|
|
82
|
+
let evidence;
|
|
83
|
+
try {
|
|
84
|
+
evidence = parseEvidence(input);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
throw invalidCaptureEvidence();
|
|
88
|
+
}
|
|
83
89
|
if (evidence.operation !== "capture_process_scenario" ||
|
|
84
90
|
evidence.predicate_type !== "rea.process-capture/v4" ||
|
|
85
91
|
evidence.provider.id !== PROCESS_PROVIDER.id ||
|
|
@@ -88,11 +94,17 @@ const parseCaptureEvidence = (input) => {
|
|
|
88
94
|
throw new ProcessCliFailure("invalid_input", LEGACY_PROCESS_CAPTURE_MESSAGE);
|
|
89
95
|
throw new ProcessCliFailure("invalid_input", "Capture evidence is not from the current process-capture workflow. Create new capture evidence, then try again.");
|
|
90
96
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
97
|
+
try {
|
|
98
|
+
return {
|
|
99
|
+
id: evidence.evidence_id,
|
|
100
|
+
capture: parseProcessCapture(evidence.normalized_result),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
throw invalidCaptureEvidence();
|
|
105
|
+
}
|
|
95
106
|
};
|
|
107
|
+
const invalidCaptureEvidence = () => new ProcessCliFailure("invalid_input", "Capture evidence is malformed. Create new capture evidence, then try again.");
|
|
96
108
|
const readJson = async (path) => {
|
|
97
109
|
let bytes;
|
|
98
110
|
try {
|
|
@@ -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";
|
|
@@ -81,21 +82,11 @@ const scheduleScenarioEvents = (options) => {
|
|
|
81
82
|
timers.add(timer);
|
|
82
83
|
}
|
|
83
84
|
};
|
|
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
85
|
const cleanupFailedStartup = async (options) => {
|
|
95
86
|
options.terminal?.kill("SIGKILL");
|
|
96
87
|
const cleanupFailure = await releaseProcessResources({
|
|
97
88
|
...options,
|
|
98
|
-
|
|
89
|
+
capturedProcessGroupIds: options.terminal === undefined ? [] : [options.terminal.pid],
|
|
99
90
|
});
|
|
100
91
|
if (cleanupFailure !== undefined)
|
|
101
92
|
throw new ProcessCaptureError(cleanupFailure, {
|
|
@@ -134,24 +125,27 @@ const startCaptureRuntime = async (options) => {
|
|
|
134
125
|
rows: scenario.terminal.rows,
|
|
135
126
|
name: "xterm-256color",
|
|
136
127
|
});
|
|
137
|
-
const startEvents = startScenarioEvents({
|
|
138
|
-
...options,
|
|
139
|
-
getTerminal: () => terminal,
|
|
140
|
-
renderer,
|
|
141
|
-
started,
|
|
142
|
-
});
|
|
143
|
-
if (!eventsRequireReadiness(scenario))
|
|
144
|
-
startEvents();
|
|
145
128
|
const framesTruncated = captureTerminalFrames({
|
|
146
129
|
...options,
|
|
147
130
|
terminal,
|
|
148
131
|
started,
|
|
149
132
|
onOutput: () => (lastOutput = Date.now()),
|
|
150
|
-
onFirstOutput: startEvents,
|
|
151
133
|
renderer,
|
|
152
134
|
checkpoints,
|
|
153
135
|
});
|
|
154
|
-
|
|
136
|
+
scheduleScenarioEvents({
|
|
137
|
+
...options,
|
|
138
|
+
getTerminal: () => terminal,
|
|
139
|
+
renderer,
|
|
140
|
+
started,
|
|
141
|
+
});
|
|
142
|
+
const stopSampler = startProcessSampler({
|
|
143
|
+
rootPid: terminal.pid,
|
|
144
|
+
runId: options.runId,
|
|
145
|
+
started,
|
|
146
|
+
limit: scenario.limits.processes,
|
|
147
|
+
samples: options.samples,
|
|
148
|
+
});
|
|
155
149
|
return {
|
|
156
150
|
replay,
|
|
157
151
|
renderer,
|
|
@@ -195,7 +189,9 @@ const finishProcessRun = async (options) => {
|
|
|
195
189
|
checkpoints: options.runtime?.checkpoints,
|
|
196
190
|
runId: options.runId,
|
|
197
191
|
temporaryRoot: options.temporaryRoot,
|
|
198
|
-
|
|
192
|
+
capturedProcessGroupIds: options.runtime === undefined
|
|
193
|
+
? []
|
|
194
|
+
: selectCapturedProcessGroupIds(options.runtime.terminal.pid, options.samples),
|
|
199
195
|
});
|
|
200
196
|
let { capture, executionFailure } = options;
|
|
201
197
|
if (capture !== undefined) {
|
|
@@ -222,10 +218,7 @@ const completeCapture = async (options) => {
|
|
|
222
218
|
const { reason } = options.exit;
|
|
223
219
|
if (reason === "cancelled")
|
|
224
220
|
throw processCaptureCancelled();
|
|
225
|
-
const settlement = await observeSettlement(options.runId,
|
|
226
|
-
runtime.terminal.pid,
|
|
227
|
-
...options.samples.flatMap(({ process_group_id }) => process_group_id === null ? [] : [process_group_id]),
|
|
228
|
-
], scenario.settle_ms);
|
|
221
|
+
const settlement = await observeSettlement(options.runId, selectCapturedProcessGroupIds(runtime.terminal.pid, options.samples), scenario.settle_ms);
|
|
229
222
|
runtime.checkpoints.trigger("settled");
|
|
230
223
|
const samplingPartial = (await runtime.stopSampler()).partial;
|
|
231
224
|
assertNotCancelled(options.signal);
|
|
@@ -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
|
|
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:
|
|
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,23 @@ 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
|
-
|
|
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
|
+
};
|
|
18
|
+
/** Parse the NUL-delimited Linux process environment without nameless keys. */
|
|
19
|
+
export const parseProcessEnvironment = (value) => Object.fromEntries(value
|
|
6
20
|
.split("\0")
|
|
7
|
-
.filter((entry) => entry.
|
|
21
|
+
.filter((entry) => entry.indexOf("=") > 0)
|
|
8
22
|
.map((entry) => {
|
|
9
23
|
const separator = entry.indexOf("=");
|
|
10
24
|
return [entry.slice(0, separator), entry.slice(separator + 1)];
|
|
@@ -15,23 +29,24 @@ const systemHost = {
|
|
|
15
29
|
return [];
|
|
16
30
|
const { stdout } = await execFileAsync("ps", [
|
|
17
31
|
"-axo",
|
|
18
|
-
"pid=,ppid=,pgid=,command=",
|
|
32
|
+
"pid=,ppid=,pgid=,stat=,command=",
|
|
19
33
|
]);
|
|
20
34
|
return stdout
|
|
21
35
|
.split("\n")
|
|
22
|
-
.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))
|
|
23
37
|
.filter((match) => match !== null)
|
|
24
38
|
.map((match) => ({
|
|
25
39
|
pid: Number(match[1]),
|
|
26
40
|
parentPid: Number(match[2]),
|
|
27
41
|
processGroupId: Number(match[3]),
|
|
28
|
-
|
|
42
|
+
state: match[4] ?? "",
|
|
43
|
+
command: match[5] ?? "",
|
|
29
44
|
}))
|
|
30
45
|
.filter((member) => member.processGroupId === processGroupId);
|
|
31
46
|
},
|
|
32
47
|
async environment(pid) {
|
|
33
48
|
if (process.platform === "linux") {
|
|
34
|
-
return
|
|
49
|
+
return parseProcessEnvironment(await readFile(`/proc/${pid}/environ`, "utf8"));
|
|
35
50
|
}
|
|
36
51
|
const { stdout } = await execFileAsync("ps", ["eww", "-p", String(pid)]);
|
|
37
52
|
const environment = {};
|
|
@@ -43,6 +58,8 @@ const systemHost = {
|
|
|
43
58
|
process.kill(-processGroupId, signal);
|
|
44
59
|
},
|
|
45
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;
|
|
46
63
|
/**
|
|
47
64
|
* Verify run-token ownership before signaling a POSIX process group.
|
|
48
65
|
*
|
|
@@ -58,9 +75,10 @@ export const cleanupOwnedProcessGroup = async (ownership, host = systemHost) =>
|
|
|
58
75
|
catch {
|
|
59
76
|
return { cleaned: false, reason: "process group could not be inspected" };
|
|
60
77
|
}
|
|
61
|
-
|
|
78
|
+
const liveMembers = liveProcessGroupMembers(members);
|
|
79
|
+
if (liveMembers.length === 0)
|
|
62
80
|
return { cleaned: true, signaled: false };
|
|
63
|
-
const leader =
|
|
81
|
+
const leader = liveMembers.find((member) => member.pid === ownership.leaderPid);
|
|
64
82
|
if (leader !== undefined) {
|
|
65
83
|
if (ownership.expectedParentPid !== undefined &&
|
|
66
84
|
leader.parentPid !== ownership.expectedParentPid)
|
|
@@ -75,24 +93,25 @@ export const cleanupOwnedProcessGroup = async (ownership, host = systemHost) =>
|
|
|
75
93
|
reason: "owned launcher command identity did not match",
|
|
76
94
|
};
|
|
77
95
|
}
|
|
78
|
-
|
|
79
|
-
|
|
96
|
+
const failures = [];
|
|
97
|
+
for (const member of liveMembers) {
|
|
80
98
|
try {
|
|
81
|
-
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" });
|
|
82
102
|
}
|
|
83
103
|
catch {
|
|
84
|
-
|
|
85
|
-
cleaned: false,
|
|
86
|
-
reason: "process ownership could not be revalidated",
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
if (environment.REA_PROCESS_RUN_ID !== ownership.runId) {
|
|
90
|
-
return {
|
|
91
|
-
cleaned: false,
|
|
92
|
-
reason: "process group contains an unowned or PID-reused process",
|
|
93
|
-
};
|
|
104
|
+
failures.push({ pid: member.pid, reason: "environment-unreadable" });
|
|
94
105
|
}
|
|
95
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
|
+
};
|
|
96
115
|
try {
|
|
97
116
|
host.signalGroup(ownership.processGroupId, "SIGKILL");
|
|
98
117
|
}
|
|
@@ -115,9 +134,10 @@ export const observeOwnedProcessGroup = async (ownership, host = systemHost) =>
|
|
|
115
134
|
reason: "process group could not be inspected",
|
|
116
135
|
};
|
|
117
136
|
}
|
|
118
|
-
|
|
137
|
+
const liveMembers = liveProcessGroupMembers(members);
|
|
138
|
+
if (liveMembers.length === 0)
|
|
119
139
|
return { state: "empty" };
|
|
120
|
-
for (const member of
|
|
140
|
+
for (const member of liveMembers) {
|
|
121
141
|
try {
|
|
122
142
|
if ((await host.environment(member.pid)).REA_PROCESS_RUN_ID !==
|
|
123
143
|
ownership.runId)
|
|
@@ -135,6 +155,7 @@ export const observeOwnedProcessGroup = async (ownership, host = systemHost) =>
|
|
|
135
155
|
}
|
|
136
156
|
return { state: "alive" };
|
|
137
157
|
};
|
|
158
|
+
const liveProcessGroupMembers = (members) => members.filter(({ state }) => !state.startsWith("Z"));
|
|
138
159
|
const commandMatches = (actual, expected) => {
|
|
139
160
|
const normalizedActual = actual.trim();
|
|
140
161
|
const normalizedExpected = expected.trim();
|