rea-agents 1.2.0 → 1.3.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 +74 -27
- package/bridge/hopper_bridge.py +161 -12
- package/dist/application/AnalysisSnapshotCache.js +131 -0
- package/dist/application/AnalysisSnapshotFiles.js +31 -0
- package/dist/application/ArtifactInventory.js +4 -0
- package/dist/application/AuthorizedArtifactInventory.js +16 -0
- package/dist/application/BinarySession.js +66 -36
- package/dist/application/BinarySessionPort.js +1 -0
- package/dist/application/BoundedJsonFiles.js +109 -0
- package/dist/application/CrossVersionInventory.js +98 -0
- package/dist/application/CrossVersionInvestigation.js +295 -0
- package/dist/application/DirectAnalysis.js +115 -22
- package/dist/application/Doctor.js +30 -1
- package/dist/application/EvidenceBundleFiles.js +15 -108
- package/dist/application/EvidenceLedger.js +3 -2
- package/dist/application/InvestigationProviders.js +48 -0
- package/dist/application/InvestigationWorkspaceStore.js +214 -0
- package/dist/application/LinuxHopper.js +72 -48
- package/dist/application/ProcessCaptureAuthority.js +2 -2
- package/dist/application/ProcessCaptureError.js +12 -0
- package/dist/application/ProcessCaptureLifecycle.js +19 -1
- package/dist/application/ProcessCli.js +98 -31
- package/dist/application/ProcessHarness.js +2 -2
- package/dist/application/ReferenceSourceImportEntries.js +18 -3
- package/dist/application/ReferenceSourceImportTypes.js +32 -0
- package/dist/application/Setup.js +33 -60
- package/dist/application/SetupClients.js +19 -0
- package/dist/application/SetupInstallFailure.js +53 -0
- package/dist/application/SetupPlan.js +31 -0
- package/dist/application/Uninstall.js +33 -23
- package/dist/application/UnknownEvidence.js +33 -0
- package/dist/application/Upgrade.js +1 -1
- package/dist/application/runtime.js +1 -1
- package/dist/artifacts/ArtifactProvider.js +2 -5
- package/dist/cli.js +31 -11
- package/dist/cliEvidenceCommands.js +11 -12
- package/dist/cliInvestigationCommands.js +86 -0
- package/dist/cliOutput.js +41 -0
- package/dist/cliProcessCommands.js +14 -4
- package/dist/config.js +39 -24
- package/dist/contracts/promptContracts.js +256 -0
- package/dist/contracts/sessionLifecycleInputs.js +11 -0
- package/dist/contracts/toolContractTypes.js +1 -0
- package/dist/contracts/toolContracts.js +10 -6
- package/dist/contracts/toolOutputSchemas.js +33 -4
- package/dist/domain/analysisSnapshot.js +149 -0
- package/dist/domain/changedBehavior.js +30 -7
- package/dist/domain/errors.js +180 -41
- package/dist/domain/evidence.js +4 -2
- package/dist/domain/evidenceBundle.js +28 -0
- package/dist/domain/hopperStartupFailure.js +55 -0
- package/dist/domain/investigationWorkspace.js +214 -0
- package/dist/domain/jsonValue.js +2 -0
- package/dist/domain/nativeInspection.js +3 -2
- package/dist/domain/processCapture.js +5 -4
- package/dist/domain/processComparison.js +5 -4
- package/dist/hopper/BridgeLauncher.js +47 -20
- package/dist/hopper/HopperClient.js +17 -2
- package/dist/hopper/HopperProvider.js +1 -0
- package/dist/main.js +47 -24
- package/dist/server/createServer.js +2 -0
- package/dist/server/promptCompletion.js +144 -0
- package/dist/server/registerEnhancedTools.js +2 -6
- package/dist/server/registerEvidenceTools.js +2 -8
- package/dist/server/registerFunctionComparisonTool.js +1 -1
- package/dist/server/registerInvestigationTools.js +31 -11
- package/dist/server/registerOfficialTools.js +6 -13
- package/dist/server/registerProcessComparisonTool.js +1 -7
- package/dist/server/registerPrompts.js +67 -0
- package/dist/server/registerSessionStatusTool.js +11 -0
- package/dist/server/registerSessionTools.js +52 -18
- package/dist/server/sessionEvidence.js +2 -8
- package/dist/server/sessionToolPolicies.js +1 -42
- package/dist/server/toolResult.js +7 -3
- package/install.sh +11 -11
- package/package.json +5 -3
- package/scripts/hopper-demo-x11.py +349 -0
- package/scripts/rea.mjs +6 -3
- package/skills/rea-analysis/SKILL.md +9 -2
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import canonicalize from "canonicalize";
|
|
1
2
|
import { createEvidenceBundle, parseEvidenceBundle, } from "../domain/evidenceBundle.js";
|
|
2
3
|
import { EvidenceIntegrityError, EvidenceLimitError, UnknownRegistryError, } from "../domain/errors.js";
|
|
3
4
|
import { parseEvidence } from "../domain/evidence.js";
|
|
@@ -258,8 +259,8 @@ export class EvidenceLedger {
|
|
|
258
259
|
}
|
|
259
260
|
const serializedBytes = (value) => Buffer.byteLength(JSON.stringify(value), "utf8");
|
|
260
261
|
const unknownRevisionKey = (unknown) => `${unknown.unknown_id}:${String(unknown.revision)}`;
|
|
261
|
-
const recordsAgree = (left, right) =>
|
|
262
|
-
|
|
262
|
+
const recordsAgree = (left, right) => canonicalize(withoutLocalPath(left)) ===
|
|
263
|
+
canonicalize(withoutLocalPath(right));
|
|
263
264
|
const withoutLocalPath = (evidence) => ({
|
|
264
265
|
...evidence,
|
|
265
266
|
subject: evidence.subject === null
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Provider identity for deterministic artifact inventories. */
|
|
2
|
+
export const ARTIFACT_GRAPH_PROVIDER = {
|
|
3
|
+
id: "rea-artifact-graph",
|
|
4
|
+
name: "REA safe artifact graph provider",
|
|
5
|
+
version: "1",
|
|
6
|
+
};
|
|
7
|
+
/** Provider identity for deterministic cross-artifact comparisons. */
|
|
8
|
+
export const ARTIFACT_COMPARISON_PROVIDER = {
|
|
9
|
+
id: "rea-artifact-comparison",
|
|
10
|
+
name: "REA artifact comparison",
|
|
11
|
+
version: "1",
|
|
12
|
+
};
|
|
13
|
+
/** Provider identity for deterministic cross-function comparisons. */
|
|
14
|
+
export const FUNCTION_COMPARISON_PROVIDER = {
|
|
15
|
+
id: "rea-function-comparison",
|
|
16
|
+
name: "REA function comparison",
|
|
17
|
+
version: "1",
|
|
18
|
+
};
|
|
19
|
+
/** Provider identity for deterministic Evidence bundle comparisons. */
|
|
20
|
+
export const BUNDLE_COMPARISON_PROVIDER = {
|
|
21
|
+
id: "rea-bundle-comparison",
|
|
22
|
+
name: "REA Evidence bundle comparison",
|
|
23
|
+
version: "1",
|
|
24
|
+
};
|
|
25
|
+
/** Provider identity for comparison-Evidence behavior aggregation. */
|
|
26
|
+
export const CHANGED_BEHAVIOR_PROVIDER = {
|
|
27
|
+
id: "rea-changed-behavior",
|
|
28
|
+
name: "REA changed behavior investigation",
|
|
29
|
+
version: "1",
|
|
30
|
+
};
|
|
31
|
+
/** Provider identity for Evidence-backed call-path reconstruction. */
|
|
32
|
+
export const CALL_PATH_PROVIDER = {
|
|
33
|
+
id: "rea-call-path",
|
|
34
|
+
name: "REA call path reconstruction",
|
|
35
|
+
version: "1",
|
|
36
|
+
};
|
|
37
|
+
/** Provider identity for explicit static/runtime hypothesis correlation. */
|
|
38
|
+
export const STATIC_RUNTIME_PROVIDER = {
|
|
39
|
+
id: "rea-static-runtime-correlation",
|
|
40
|
+
name: "REA static/runtime correlation",
|
|
41
|
+
version: "1",
|
|
42
|
+
};
|
|
43
|
+
/** Provider identity for finite reconstruction specification verification. */
|
|
44
|
+
export const RECONSTRUCTION_PROVIDER = {
|
|
45
|
+
id: "rea-reconstruction-verifier",
|
|
46
|
+
name: "REA reconstruction verifier",
|
|
47
|
+
version: "1",
|
|
48
|
+
};
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { lstat, open, readFile, realpath, unlink, } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
|
|
3
|
+
import writeFileAtomic from "write-file-atomic";
|
|
4
|
+
import { InvestigationWorkspaceError } from "../domain/errors.js";
|
|
5
|
+
import { parseInvestigationWorkspace, serializeInvestigationWorkspace, } from "../domain/investigationWorkspace.js";
|
|
6
|
+
import { err, ok } from "../domain/result.js";
|
|
7
|
+
/** Read a validated workspace, returning null only when it does not exist. */
|
|
8
|
+
export const readInvestigationWorkspace = async (path, policy) => {
|
|
9
|
+
if (policy.roots.length === 0)
|
|
10
|
+
return err(new InvestigationWorkspaceError("read", "disabled"));
|
|
11
|
+
try {
|
|
12
|
+
const destination = await resolveDestination(path, policy.roots);
|
|
13
|
+
if (destination === null)
|
|
14
|
+
return err(new InvestigationWorkspaceError("read", "outside-root"));
|
|
15
|
+
return await readWorkspaceFile(destination, policy);
|
|
16
|
+
}
|
|
17
|
+
catch (cause) {
|
|
18
|
+
return err(new InvestigationWorkspaceError("read", "io", { cause }));
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
/** Atomically append one CAS-linked workspace revision under an exclusive lock. */
|
|
22
|
+
export const writeInvestigationWorkspace = async (workspace, path, expectedRevision, policy) => {
|
|
23
|
+
if (policy.roots.length === 0)
|
|
24
|
+
return err(new InvestigationWorkspaceError("update", "disabled"));
|
|
25
|
+
let encoded;
|
|
26
|
+
try {
|
|
27
|
+
encoded = serializeInvestigationWorkspace(workspace);
|
|
28
|
+
}
|
|
29
|
+
catch (cause) {
|
|
30
|
+
return err(new InvestigationWorkspaceError("update", "integrity", { cause }));
|
|
31
|
+
}
|
|
32
|
+
const bytes = Buffer.byteLength(encoded, "utf8");
|
|
33
|
+
if (bytes > policy.maxBytes)
|
|
34
|
+
return err(new InvestigationWorkspaceError("update", "too-large"));
|
|
35
|
+
let lock;
|
|
36
|
+
try {
|
|
37
|
+
const destination = await resolveDestination(path, policy.roots);
|
|
38
|
+
if (destination === null)
|
|
39
|
+
return err(new InvestigationWorkspaceError("update", "outside-root"));
|
|
40
|
+
const acquired = await acquireLock(destination);
|
|
41
|
+
if (!acquired.ok)
|
|
42
|
+
return acquired;
|
|
43
|
+
lock = acquired.value;
|
|
44
|
+
const current = await readWorkspaceFile(destination, policy);
|
|
45
|
+
if (!current.ok)
|
|
46
|
+
return current;
|
|
47
|
+
const checked = validateNextRevision(current.value, workspace, expectedRevision);
|
|
48
|
+
if (!checked.ok)
|
|
49
|
+
return checked;
|
|
50
|
+
await writeFileAtomic(destination, encoded, {
|
|
51
|
+
encoding: "utf8",
|
|
52
|
+
mode: 0o600,
|
|
53
|
+
fsync: true,
|
|
54
|
+
});
|
|
55
|
+
return ok({ path: resolve(path), bytes });
|
|
56
|
+
}
|
|
57
|
+
catch (cause) {
|
|
58
|
+
return err(new InvestigationWorkspaceError("update", "io", { cause }));
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
if (lock !== undefined)
|
|
62
|
+
await releaseLock(lock);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const resolveDestination = async (path, roots) => {
|
|
66
|
+
const requested = resolve(path);
|
|
67
|
+
const canonicalParent = await realpath(dirname(requested));
|
|
68
|
+
const destination = resolve(canonicalParent, basename(requested));
|
|
69
|
+
for (const configuredRoot of roots) {
|
|
70
|
+
const root = await realpath(resolve(configuredRoot));
|
|
71
|
+
const relation = relative(root, destination);
|
|
72
|
+
if (relation === "" ||
|
|
73
|
+
(!relation.startsWith("..") && !isAbsolute(relation)))
|
|
74
|
+
return destination;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
};
|
|
78
|
+
const readWorkspaceFile = async (destination, policy) => {
|
|
79
|
+
const stats = await lstat(destination).catch((cause) => {
|
|
80
|
+
if (isFileNotFound(cause))
|
|
81
|
+
return undefined;
|
|
82
|
+
throw cause;
|
|
83
|
+
});
|
|
84
|
+
if (stats === undefined)
|
|
85
|
+
return ok(null);
|
|
86
|
+
if (!stats.isFile() || stats.isSymbolicLink())
|
|
87
|
+
return err(new InvestigationWorkspaceError("read", "not-file"));
|
|
88
|
+
if (stats.size > policy.maxBytes)
|
|
89
|
+
return err(new InvestigationWorkspaceError("read", "too-large"));
|
|
90
|
+
const encoded = await readFile(destination);
|
|
91
|
+
if (encoded.byteLength > policy.maxBytes)
|
|
92
|
+
return err(new InvestigationWorkspaceError("read", "too-large"));
|
|
93
|
+
let decoded;
|
|
94
|
+
try {
|
|
95
|
+
decoded = JSON.parse(encoded.toString("utf8"));
|
|
96
|
+
}
|
|
97
|
+
catch (cause) {
|
|
98
|
+
return err(new InvestigationWorkspaceError("read", "invalid-json", { cause }));
|
|
99
|
+
}
|
|
100
|
+
if (!withinJsonLimits(decoded, policy))
|
|
101
|
+
return err(new InvestigationWorkspaceError("read", "too-large"));
|
|
102
|
+
try {
|
|
103
|
+
return ok(parseInvestigationWorkspace(decoded));
|
|
104
|
+
}
|
|
105
|
+
catch (cause) {
|
|
106
|
+
return err(new InvestigationWorkspaceError("read", "integrity", { cause }));
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
const acquireLock = async (destination) => {
|
|
110
|
+
const path = `${destination}.lock`;
|
|
111
|
+
try {
|
|
112
|
+
return ok(await createLock(path));
|
|
113
|
+
}
|
|
114
|
+
catch (cause) {
|
|
115
|
+
if (isAlreadyExists(cause) && (await removeStaleLock(path))) {
|
|
116
|
+
try {
|
|
117
|
+
return ok(await createLock(path));
|
|
118
|
+
}
|
|
119
|
+
catch (retryCause) {
|
|
120
|
+
return err(new InvestigationWorkspaceError("update", isAlreadyExists(retryCause) ? "locked" : "io", { cause: retryCause }));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return err(new InvestigationWorkspaceError("update", isAlreadyExists(cause) ? "locked" : "io", { cause }));
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
const createLock = async (path) => {
|
|
127
|
+
const handle = await open(path, "wx", 0o600);
|
|
128
|
+
try {
|
|
129
|
+
await handle.writeFile(`${String(process.pid)}\n`, "utf8");
|
|
130
|
+
await handle.sync();
|
|
131
|
+
return { path, handle };
|
|
132
|
+
}
|
|
133
|
+
catch (cause) {
|
|
134
|
+
await handle.close().catch(() => undefined);
|
|
135
|
+
await unlink(path).catch(() => undefined);
|
|
136
|
+
throw cause;
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
const removeStaleLock = async (path) => {
|
|
140
|
+
try {
|
|
141
|
+
const stats = await lstat(path);
|
|
142
|
+
if (!stats.isFile() || stats.isSymbolicLink() || stats.size > 32)
|
|
143
|
+
return false;
|
|
144
|
+
const encoded = (await readFile(path, "utf8")).trim();
|
|
145
|
+
if (!/^[1-9][0-9]{0,9}$/u.test(encoded))
|
|
146
|
+
return false;
|
|
147
|
+
const pid = Number(encoded);
|
|
148
|
+
if (processIsAlive(pid))
|
|
149
|
+
return false;
|
|
150
|
+
await unlink(path);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
const processIsAlive = (pid) => {
|
|
158
|
+
try {
|
|
159
|
+
process.kill(pid, 0);
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
catch (cause) {
|
|
163
|
+
return errorCode(cause) !== "ESRCH";
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
const releaseLock = async (lock) => {
|
|
167
|
+
await lock.handle.close().catch(() => undefined);
|
|
168
|
+
await unlink(lock.path).catch(() => undefined);
|
|
169
|
+
};
|
|
170
|
+
const validateNextRevision = (current, next, expectedRevision) => {
|
|
171
|
+
if ((current?.revision ?? null) !== expectedRevision)
|
|
172
|
+
return err(new InvestigationWorkspaceError("update", "revision-conflict"));
|
|
173
|
+
if (current === null) {
|
|
174
|
+
return next.revision === 1 && next.previous_revision_digest === null
|
|
175
|
+
? ok(null)
|
|
176
|
+
: err(new InvestigationWorkspaceError("update", "revision-conflict"));
|
|
177
|
+
}
|
|
178
|
+
if (current.name !== next.name || current.workspace_id !== next.workspace_id)
|
|
179
|
+
return err(new InvestigationWorkspaceError("update", "name-conflict"));
|
|
180
|
+
return next.revision === current.revision + 1 &&
|
|
181
|
+
next.previous_revision_digest === current.revision_digest
|
|
182
|
+
? ok(null)
|
|
183
|
+
: err(new InvestigationWorkspaceError("update", "revision-conflict"));
|
|
184
|
+
};
|
|
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
|
+
const isFileNotFound = (cause) => errorCode(cause) === "ENOENT";
|
|
211
|
+
const isAlreadyExists = (cause) => errorCode(cause) === "EEXIST";
|
|
212
|
+
const errorCode = (cause) => typeof cause === "object" && cause !== null && "code" in cause
|
|
213
|
+
? cause.code
|
|
214
|
+
: undefined;
|
|
@@ -1,14 +1,31 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { execFile } from "node:child_process";
|
|
3
|
+
import { createReadStream } from "node:fs";
|
|
3
4
|
import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
5
|
import { tmpdir } from "node:os";
|
|
5
6
|
import { join } from "node:path";
|
|
6
7
|
import { promisify } from "node:util";
|
|
7
|
-
import { z } from "zod";
|
|
8
8
|
const execFileAsync = promisify(execFile);
|
|
9
|
-
const RELEASES_URL = "https://www.hopperapp.com/include/files-api.php?request=releases&public=true";
|
|
10
9
|
const DOWNLOAD_PREFIX = "https://www.hopperapp.com:443/downloader/public/";
|
|
11
10
|
const MAX_PACKAGE_BYTES = 100_000_000;
|
|
11
|
+
const SUPPORTED_HOPPER_SHA256 = "0294ced141cc373468ee22d8343e7dac41980cb05a937994ca81c9f09afe7ded";
|
|
12
|
+
const SUPPORTED_RELEASES = {
|
|
13
|
+
deb: {
|
|
14
|
+
filename: `${DOWNLOAD_PREFIX}Hopper-6.4.2-Linux-demo.deb`,
|
|
15
|
+
file_length: "35755772",
|
|
16
|
+
file_hash: "e4f79dff602648a8ff4a88b773875b6bfac0dc65",
|
|
17
|
+
},
|
|
18
|
+
rpm: {
|
|
19
|
+
filename: `${DOWNLOAD_PREFIX}Hopper-6.4.2-Linux-demo.rpm`,
|
|
20
|
+
file_length: "32264475",
|
|
21
|
+
file_hash: "b9a65f2f583a386d440d4401d9140aef3bf305de",
|
|
22
|
+
},
|
|
23
|
+
arch: {
|
|
24
|
+
filename: `${DOWNLOAD_PREFIX}Hopper-6.4.2-Linux-demo.pkg.tar.xz`,
|
|
25
|
+
file_length: "29405968",
|
|
26
|
+
file_hash: "92d780b61cab38eda95dc4112dee4fbeb0920f46",
|
|
27
|
+
},
|
|
28
|
+
};
|
|
12
29
|
/** Parse an os-release document without executing its shell syntax. */
|
|
13
30
|
export const parseLinuxDistribution = (text) => {
|
|
14
31
|
const fields = new Map();
|
|
@@ -57,15 +74,7 @@ export const installLinuxHopper = async (host = systemLinuxHopperInstallHost(),
|
|
|
57
74
|
return { status: "failed", reason: "unsupported_host" };
|
|
58
75
|
let temporary;
|
|
59
76
|
try {
|
|
60
|
-
const
|
|
61
|
-
if (!metadataDownload.ok)
|
|
62
|
-
return { status: "failed", reason: "release_metadata" };
|
|
63
|
-
const releases = parseReleases(metadataDownload.bytes);
|
|
64
|
-
if (releases === undefined)
|
|
65
|
-
return { status: "failed", reason: "release_metadata" };
|
|
66
|
-
const release = releases[releaseKey(distribution.packageFamily)];
|
|
67
|
-
if (!release.filename.startsWith(DOWNLOAD_PREFIX))
|
|
68
|
-
return { status: "failed", reason: "release_metadata" };
|
|
77
|
+
const release = (options.releases ?? SUPPORTED_RELEASES)[distribution.packageFamily];
|
|
69
78
|
const archiveDownload = await host.download(release.filename, options);
|
|
70
79
|
if (!archiveDownload.ok)
|
|
71
80
|
return { status: "failed", reason: "download" };
|
|
@@ -77,8 +86,12 @@ export const installLinuxHopper = async (host = systemLinuxHopperInstallHost(),
|
|
|
77
86
|
if (!(await host.installPackage(distribution.packageFamily, archive)))
|
|
78
87
|
return { status: "failed", reason: "authorization_or_package_manager" };
|
|
79
88
|
const launcherPath = "/opt/hopper/bin/Hopper";
|
|
80
|
-
|
|
81
|
-
|
|
89
|
+
const launcherStatus = await host.launcherStatus(launcherPath);
|
|
90
|
+
if (launcherStatus !== "ready")
|
|
91
|
+
return {
|
|
92
|
+
status: "failed",
|
|
93
|
+
reason: launcherStatus === "missing" ? "launcher_missing" : launcherStatus,
|
|
94
|
+
};
|
|
82
95
|
return { status: "installed", launcherPath };
|
|
83
96
|
}
|
|
84
97
|
catch (cause) {
|
|
@@ -108,24 +121,40 @@ const systemLinuxHopperInstallHost = () => ({
|
|
|
108
121
|
createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-")),
|
|
109
122
|
writeArchive: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }),
|
|
110
123
|
installPackage: installSystemPackage,
|
|
111
|
-
async
|
|
124
|
+
async launcherStatus(path) {
|
|
112
125
|
try {
|
|
113
126
|
await access(path);
|
|
114
127
|
const linked = await execFileAsync("ldd", [path]);
|
|
115
|
-
|
|
128
|
+
if (!linuxSharedLibrariesAvailable(`${linked.stdout}\n${linked.stderr}`))
|
|
129
|
+
return "runtime_dependencies";
|
|
130
|
+
return (await linuxHopperBinarySupported(path))
|
|
131
|
+
? "ready"
|
|
132
|
+
: "unsupported_hopper_build";
|
|
116
133
|
}
|
|
117
134
|
catch {
|
|
118
|
-
return
|
|
135
|
+
return "missing";
|
|
119
136
|
}
|
|
120
137
|
},
|
|
121
138
|
cleanup: (path) => rm(path, { recursive: true, force: true }),
|
|
122
139
|
});
|
|
140
|
+
/** Match the Linux Hopper build validated by the packaged demo-dialog adapter. */
|
|
141
|
+
export const linuxHopperBinarySupported = async (path) => {
|
|
142
|
+
try {
|
|
143
|
+
const hash = createHash("sha256");
|
|
144
|
+
for await (const chunk of createReadStream(path))
|
|
145
|
+
hash.update(chunk);
|
|
146
|
+
return hash.digest("hex") === SUPPORTED_HOPPER_SHA256;
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
};
|
|
123
152
|
/** Interpret ldd output conservatively so a present but broken launcher is unhealthy. */
|
|
124
153
|
export const linuxSharedLibrariesAvailable = (output) => !/(?:=>\s+not found|error while loading shared libraries)/iu.test(output);
|
|
125
154
|
const installSystemPackage = async (family, archive) => {
|
|
126
155
|
try {
|
|
127
|
-
const command
|
|
128
|
-
|
|
156
|
+
for (const command of linuxPackageManagerCommands(family, archive, process.getuid?.() === 0))
|
|
157
|
+
await execFileAsync(command.executable, command.args);
|
|
129
158
|
return true;
|
|
130
159
|
}
|
|
131
160
|
catch {
|
|
@@ -133,26 +162,32 @@ const installSystemPackage = async (family, archive) => {
|
|
|
133
162
|
}
|
|
134
163
|
};
|
|
135
164
|
/** Select the native package manager and authorization boundary without shell evaluation. */
|
|
136
|
-
export const
|
|
137
|
-
const
|
|
138
|
-
? ["
|
|
165
|
+
export const linuxPackageManagerCommands = (family, archive, isRoot) => {
|
|
166
|
+
const runtimeDependencies = family === "deb"
|
|
167
|
+
? ["xvfb", "xauth", "python3", "libx11-6", "libxtst6"]
|
|
139
168
|
: family === "rpm"
|
|
140
|
-
? [
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
169
|
+
? [
|
|
170
|
+
"xorg-x11-server-Xvfb",
|
|
171
|
+
"xorg-x11-xauth",
|
|
172
|
+
"python3",
|
|
173
|
+
"libX11",
|
|
174
|
+
"libXtst",
|
|
175
|
+
]
|
|
176
|
+
: ["xorg-server-xvfb", "xorg-xauth", "python", "libx11", "libxtst"];
|
|
177
|
+
const commands = family === "deb"
|
|
178
|
+
? [["apt-get", "install", "-y", archive, ...runtimeDependencies]]
|
|
179
|
+
: family === "rpm"
|
|
180
|
+
? [["dnf", "install", "-y", archive, ...runtimeDependencies]]
|
|
181
|
+
: [
|
|
182
|
+
["pacman", "-S", "--needed", "--noconfirm", ...runtimeDependencies],
|
|
183
|
+
["pacman", "-U", "--noconfirm", archive],
|
|
184
|
+
];
|
|
185
|
+
return commands.map((command) => {
|
|
186
|
+
const executable = isRoot ? command[0] : "pkexec";
|
|
187
|
+
if (executable === undefined)
|
|
188
|
+
throw new Error("package command is empty");
|
|
189
|
+
return { executable, args: isRoot ? command.slice(1) : command };
|
|
190
|
+
});
|
|
156
191
|
};
|
|
157
192
|
const packageIntegrityMatches = (bytes, release) => bytes.byteLength === Number(release.file_length) &&
|
|
158
193
|
bytes.byteLength <= MAX_PACKAGE_BYTES &&
|
|
@@ -172,15 +207,4 @@ const packageFamilyFor = (id, idLike) => {
|
|
|
172
207
|
};
|
|
173
208
|
const versionAtLeast = (version, minimum) => version !== undefined &&
|
|
174
209
|
Number.parseInt(version.split(".")[0] ?? "0", 10) >= minimum;
|
|
175
|
-
const releaseKey = (family) => family === "deb" ? "Ubuntu / Mint" : family === "rpm" ? "Fedora" : "Arch";
|
|
176
210
|
const isAbortCause = (cause) => cause instanceof Error && cause.name === "AbortError";
|
|
177
|
-
const releaseSchema = z.object({
|
|
178
|
-
filename: z.string().url(),
|
|
179
|
-
file_length: z.string().regex(/^\d+$/u),
|
|
180
|
-
file_hash: z.string().regex(/^[a-f0-9]{40}$/u),
|
|
181
|
-
});
|
|
182
|
-
const releasesSchema = z.object({
|
|
183
|
-
"Ubuntu / Mint": releaseSchema,
|
|
184
|
-
Fedora: releaseSchema,
|
|
185
|
-
Arch: releaseSchema,
|
|
186
|
-
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { realpath } from "node:fs/promises";
|
|
2
|
-
import { ProcessCaptureError } from "./ProcessCaptureError.js";
|
|
2
|
+
import { ProcessCaptureError, processCaptureCancelled, } from "./ProcessCaptureError.js";
|
|
3
3
|
const isWithin = (candidate, root) => candidate === root ||
|
|
4
4
|
candidate.startsWith(`${root.endsWith("/") ? root.slice(0, -1) : root}/`);
|
|
5
5
|
export const assertRealPathAuthority = async (scenario, policy) => {
|
|
@@ -20,6 +20,6 @@ export const assertRealPathAuthority = async (scenario, policy) => {
|
|
|
20
20
|
/** Expected refusal or runtime failure from the process capture adapter. */
|
|
21
21
|
export const assertNotCancelled = (signal) => {
|
|
22
22
|
if (signal?.aborted === true)
|
|
23
|
-
throw
|
|
23
|
+
throw processCaptureCancelled();
|
|
24
24
|
};
|
|
25
25
|
/** Runtime availability of the native PTY adapter on this host. */
|
|
@@ -2,4 +2,16 @@ import { AnalysisError } from "../domain/errors.js";
|
|
|
2
2
|
/** Typed application failure produced by controlled process capture. */
|
|
3
3
|
export class ProcessCaptureError extends AnalysisError {
|
|
4
4
|
_tag = "ProcessCaptureError";
|
|
5
|
+
userMessage;
|
|
6
|
+
userCategory;
|
|
7
|
+
constructor(message, options) {
|
|
8
|
+
super(message, options);
|
|
9
|
+
this.userMessage = options?.userMessage;
|
|
10
|
+
this.userCategory = options?.userCategory;
|
|
11
|
+
}
|
|
5
12
|
}
|
|
13
|
+
/** Caller-visible cancellation without exposing capture implementation state. */
|
|
14
|
+
export const processCaptureCancelled = () => new ProcessCaptureError("process capture was cancelled", {
|
|
15
|
+
userCategory: "cancelled",
|
|
16
|
+
userMessage: "Process capture was cancelled. Start it again when ready.",
|
|
17
|
+
});
|
|
@@ -257,7 +257,10 @@ export const resolveProcessResult = (capture, executionFailure, cleanupFailure)
|
|
|
257
257
|
export const prepareProcessCapture = async (scenario, policy, signal) => {
|
|
258
258
|
const decision = authorizeProcessScenario(scenario, policy);
|
|
259
259
|
if (!decision.allowed)
|
|
260
|
-
throw new ProcessCaptureError(decision.reason
|
|
260
|
+
throw new ProcessCaptureError(decision.reason, {
|
|
261
|
+
userCategory: "permission_required",
|
|
262
|
+
userMessage: processPolicyMessage(decision.reason),
|
|
263
|
+
});
|
|
261
264
|
await assertRealPathAuthority(scenario, policy);
|
|
262
265
|
assertNotCancelled(signal);
|
|
263
266
|
const before = await snapshotRoots(scenario, signal);
|
|
@@ -267,3 +270,18 @@ export const prepareProcessCapture = async (scenario, policy, signal) => {
|
|
|
267
270
|
await import("node:fs/promises").then(({ mkdir }) => mkdir(home));
|
|
268
271
|
return { temporaryRoot, runId, home, before };
|
|
269
272
|
};
|
|
273
|
+
const processPolicyMessage = (reason) => {
|
|
274
|
+
if (reason === "process capture is disabled")
|
|
275
|
+
return "Process capture is disabled. Set `REA_PROCESS_CAPTURE_ENABLED=true`, configure approved roots, then restart REA.";
|
|
276
|
+
if (reason === "host network access is not approved by operator policy")
|
|
277
|
+
return "This capture requests host network access, but policy does not allow it. Use replayed network access or ask the operator to enable external network capture.";
|
|
278
|
+
if (reason === "executable is outside approved roots")
|
|
279
|
+
return "The executable is outside the approved capture directories. Choose an approved executable or add its directory to `REA_PROCESS_EXECUTABLE_ROOTS_JSON`.";
|
|
280
|
+
if (reason === "working directory is outside approved roots")
|
|
281
|
+
return "The working directory is outside the approved capture directories. Choose an approved directory or add it to `REA_PROCESS_WORKING_ROOTS_JSON`.";
|
|
282
|
+
if (reason === "filesystem root is outside approved roots")
|
|
283
|
+
return "A requested filesystem root is outside the approved capture directories. Remove it or add its directory to `REA_PROCESS_WORKING_ROOTS_JSON`.";
|
|
284
|
+
if (reason === "scenario requests an environment variable not allowed by policy")
|
|
285
|
+
return "The capture requests an environment variable that policy does not allow. Remove it or add its name to `REA_PROCESS_ALLOWED_ENV_JSON`.";
|
|
286
|
+
return reason;
|
|
287
|
+
};
|