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