rea-agents 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +95 -27
  2. package/bridge/hopper_bridge.py +172 -14
  3. package/dist/application/AnalysisSnapshotCache.js +131 -0
  4. package/dist/application/AnalysisSnapshotFiles.js +31 -0
  5. package/dist/application/ArtifactGraphConstruction.js +1 -1
  6. package/dist/application/ArtifactInventory.js +100 -24
  7. package/dist/application/AuthorizedArtifactInventory.js +16 -0
  8. package/dist/application/BinarySession.js +149 -36
  9. package/dist/application/BinarySessionPort.js +1 -0
  10. package/dist/application/BoundedJsonFiles.js +109 -0
  11. package/dist/application/CapabilityInventory.js +127 -0
  12. package/dist/application/ClientRegistrationStatus.js +69 -0
  13. package/dist/application/CrossVersionInventory.js +109 -0
  14. package/dist/application/CrossVersionInvestigation.js +358 -0
  15. package/dist/application/DirectAnalysis.js +205 -24
  16. package/dist/application/Doctor.js +104 -3
  17. package/dist/application/EvidenceBundleFiles.js +15 -108
  18. package/dist/application/EvidenceLedger.js +3 -2
  19. package/dist/application/InvestigationProviders.js +48 -0
  20. package/dist/application/InvestigationWorkspaceStore.js +214 -0
  21. package/dist/application/LinuxHopper.js +72 -48
  22. package/dist/application/PermissionAuthority.js +183 -0
  23. package/dist/application/PermissionConfiguration.js +26 -0
  24. package/dist/application/ProcessCaptureAuthority.js +2 -2
  25. package/dist/application/ProcessCaptureError.js +25 -0
  26. package/dist/application/ProcessCaptureLifecycle.js +22 -2
  27. package/dist/application/ProcessCli.js +111 -31
  28. package/dist/application/ProcessHarness.js +6 -3
  29. package/dist/application/ProgressReporter.js +30 -0
  30. package/dist/application/ProjectPermissionStore.js +110 -0
  31. package/dist/application/ReferenceSourceImportEntries.js +18 -3
  32. package/dist/application/ReferenceSourceImportTypes.js +32 -0
  33. package/dist/application/Setup.js +35 -101
  34. package/dist/application/SetupClients.js +19 -0
  35. package/dist/application/SetupInstallFailure.js +53 -0
  36. package/dist/application/SetupPlan.js +31 -0
  37. package/dist/application/SupportedClients.js +41 -0
  38. package/dist/application/Uninstall.js +34 -24
  39. package/dist/application/UnknownEvidence.js +33 -0
  40. package/dist/application/Upgrade.js +3 -1
  41. package/dist/application/runtime.js +2 -2
  42. package/dist/artifacts/ArtifactProvider.js +15 -8
  43. package/dist/catalogIdentity.js +121 -0
  44. package/dist/cli.js +76 -13
  45. package/dist/cliEvidenceCommands.js +52 -12
  46. package/dist/cliInvestigationCommands.js +137 -0
  47. package/dist/cliLogging.js +20 -3
  48. package/dist/cliOutput.js +41 -0
  49. package/dist/cliPolicyCommands.js +115 -0
  50. package/dist/config.js +102 -27
  51. package/dist/contracts/artifactToolContracts.js +14 -1
  52. package/dist/contracts/errorSchemas.js +98 -0
  53. package/dist/contracts/promptContracts.js +256 -0
  54. package/dist/contracts/sessionLifecycleInputs.js +11 -0
  55. package/dist/contracts/toolContractTypes.js +1 -0
  56. package/dist/contracts/toolContracts.js +20 -7
  57. package/dist/contracts/toolOutputSchemas.js +70 -9
  58. package/dist/domain/analysisSnapshot.js +149 -0
  59. package/dist/domain/artifactComparison.js +38 -11
  60. package/dist/domain/artifactGraph.js +25 -1
  61. package/dist/domain/artifactInventoryEvidence.js +18 -0
  62. package/dist/domain/changedBehavior.js +35 -10
  63. package/dist/domain/errors.js +384 -32
  64. package/dist/domain/evidence.js +4 -2
  65. package/dist/domain/evidenceBundle.js +28 -0
  66. package/dist/domain/hopperStartupFailure.js +55 -0
  67. package/dist/domain/investigationWorkspace.js +341 -0
  68. package/dist/domain/jsonValue.js +2 -0
  69. package/dist/domain/nativeInspection.js +3 -2
  70. package/dist/domain/permissionPolicy.js +157 -0
  71. package/dist/domain/processCapture.js +5 -4
  72. package/dist/domain/processComparison.js +6 -4
  73. package/dist/domain/reconstructionVerification.js +1 -1
  74. package/dist/domain/reconstructionVerificationSchemas.js +1 -0
  75. package/dist/domain/staticRuntimeCorrelation.js +5 -1
  76. package/dist/generatedPackageMetadata.js +9 -0
  77. package/dist/hopper/BridgeLauncher.js +51 -20
  78. package/dist/hopper/HopperClient.js +45 -3
  79. package/dist/hopper/HopperProvider.js +1 -0
  80. package/dist/identity.js +10 -3
  81. package/dist/logger.js +2 -2
  82. package/dist/main.js +139 -28
  83. package/dist/server/createServer.js +54 -5
  84. package/dist/server/mcpProgress.js +23 -0
  85. package/dist/server/promptCompletion.js +144 -0
  86. package/dist/server/registerArtifactComparisonTool.js +6 -2
  87. package/dist/server/registerBundleComparisonTool.js +6 -2
  88. package/dist/server/registerEnhancedTools.js +20 -7
  89. package/dist/server/registerEvidenceResources.js +302 -0
  90. package/dist/server/registerEvidenceTools.js +59 -8
  91. package/dist/server/registerFunctionComparisonTool.js +7 -3
  92. package/dist/server/registerInvestigationTools.js +111 -18
  93. package/dist/server/registerOfficialTools.js +25 -14
  94. package/dist/server/registerProcessComparisonTool.js +8 -10
  95. package/dist/server/registerPrompts.js +67 -0
  96. package/dist/server/registerSessionStatusTool.js +52 -0
  97. package/dist/server/registerSessionTools.js +178 -22
  98. package/dist/server/runDerivedOperation.js +35 -0
  99. package/dist/server/sessionEvidence.js +2 -8
  100. package/dist/server/sessionToolPolicies.js +1 -42
  101. package/dist/server/toolResult.js +34 -6
  102. package/dist/serverIdentity.js +70 -0
  103. package/install.sh +11 -11
  104. package/package.json +10 -6
  105. package/scripts/hopper-demo-x11.py +349 -0
  106. package/scripts/rea.mjs +6 -3
  107. package/skills/rea-analysis/SKILL.md +9 -2
@@ -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 metadataDownload = await host.download(RELEASES_URL, options);
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
- if (!(await host.launcherReady(launcherPath)))
81
- return { status: "failed", reason: "launcher_missing" };
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 launcherReady(path) {
124
+ async launcherStatus(path) {
112
125
  try {
113
126
  await access(path);
114
127
  const linked = await execFileAsync("ldd", [path]);
115
- return linuxSharedLibrariesAvailable(`${linked.stdout}\n${linked.stderr}`);
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 false;
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 = linuxPackageManagerCommand(family, archive, process.getuid?.() === 0);
128
- await execFileAsync(command.executable, command.args);
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 linuxPackageManagerCommand = (family, archive, isRoot) => {
137
- const command = family === "deb"
138
- ? ["apt-get", "install", "-y", archive]
165
+ export const linuxPackageManagerCommands = (family, archive, isRoot) => {
166
+ const runtimeDependencies = family === "deb"
167
+ ? ["xvfb", "xauth", "python3", "libx11-6", "libxtst6"]
139
168
  : family === "rpm"
140
- ? ["dnf", "install", "-y", archive]
141
- : ["pacman", "-U", "--noconfirm", archive];
142
- const executable = isRoot ? command[0] : "pkexec";
143
- if (executable === undefined)
144
- throw new Error("package command is empty");
145
- return { executable, args: isRoot ? command.slice(1) : command };
146
- };
147
- const parseReleases = (bytes) => {
148
- try {
149
- const parsed = JSON.parse(new TextDecoder().decode(bytes));
150
- const result = releasesSchema.safeParse(parsed);
151
- return result.success ? result.data : undefined;
152
- }
153
- catch {
154
- return undefined;
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
- });
@@ -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,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 new ProcessCaptureError("process capture was cancelled");
23
+ throw processCaptureCancelled();
24
24
  };
25
25
  /** Runtime availability of the native PTY adapter on this host. */
@@ -2,4 +2,29 @@ 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
+ reason;
8
+ cleanupIncomplete;
9
+ cleanupResources;
10
+ constructor(message, options) {
11
+ super(message, options);
12
+ this.userMessage = options?.userMessage;
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 ?? [];
23
+ }
5
24
  }
25
+ /** Caller-visible cancellation without exposing capture implementation state. */
26
+ export const processCaptureCancelled = () => new ProcessCaptureError("process capture was cancelled", {
27
+ userCategory: "cancelled",
28
+ reason: "cancelled",
29
+ userMessage: "Process capture was cancelled. Start it again when ready.",
30
+ });
@@ -249,7 +249,9 @@ export const resolveProcessResult = (capture, executionFailure, cleanupFailure)
249
249
  cause: executionFailure,
250
250
  });
251
251
  if (cleanupFailure !== undefined)
252
- throw new ProcessCaptureError(cleanupFailure);
252
+ throw new ProcessCaptureError(cleanupFailure, {
253
+ reason: "cleanup_incomplete",
254
+ });
253
255
  if (capture === undefined)
254
256
  throw new ProcessCaptureError("process capture produced no result");
255
257
  return capture;
@@ -257,7 +259,10 @@ export const resolveProcessResult = (capture, executionFailure, cleanupFailure)
257
259
  export const prepareProcessCapture = async (scenario, policy, signal) => {
258
260
  const decision = authorizeProcessScenario(scenario, policy);
259
261
  if (!decision.allowed)
260
- throw new ProcessCaptureError(decision.reason);
262
+ throw new ProcessCaptureError(decision.reason, {
263
+ userCategory: "permission_required",
264
+ userMessage: processPolicyMessage(decision.reason),
265
+ });
261
266
  await assertRealPathAuthority(scenario, policy);
262
267
  assertNotCancelled(signal);
263
268
  const before = await snapshotRoots(scenario, signal);
@@ -267,3 +272,18 @@ export const prepareProcessCapture = async (scenario, policy, signal) => {
267
272
  await import("node:fs/promises").then(({ mkdir }) => mkdir(home));
268
273
  return { temporaryRoot, runId, home, before };
269
274
  };
275
+ const processPolicyMessage = (reason) => {
276
+ if (reason === "process capture is disabled")
277
+ return "Process capture is disabled. Set `REA_PROCESS_CAPTURE_ENABLED=true`, configure approved roots, then restart REA.";
278
+ if (reason === "host network access is not approved by operator policy")
279
+ 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.";
280
+ if (reason === "executable is outside approved roots")
281
+ return "The executable is outside the approved capture directories. Choose an approved executable or add its directory to `REA_PROCESS_EXECUTABLE_ROOTS_JSON`.";
282
+ if (reason === "working directory is outside approved roots")
283
+ return "The working directory is outside the approved capture directories. Choose an approved directory or add it to `REA_PROCESS_WORKING_ROOTS_JSON`.";
284
+ if (reason === "filesystem root is outside approved roots")
285
+ return "A requested filesystem root is outside the approved capture directories. Remove it or add its directory to `REA_PROCESS_WORKING_ROOTS_JSON`.";
286
+ if (reason === "scenario requests an environment variable not allowed by policy")
287
+ return "The capture requests an environment variable that policy does not allow. Remove it or add its name to `REA_PROCESS_ALLOWED_ENV_JSON`.";
288
+ return reason;
289
+ };
@@ -1,42 +1,82 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { parseConfig } from "../config.js";
3
+ import { AnalysisError, AnalysisProtocolError, PermissionRequiredError, projectAnalysisError, } from "../domain/errors.js";
3
4
  import { createEvidence, parseEvidence } from "../domain/evidence.js";
4
5
  import { jsonValueSchema } from "../domain/jsonValue.js";
5
6
  import { compareProcessCaptures, LEGACY_PROCESS_CAPTURE_MESSAGE, parseProcessScenario, parseProcessCapture, } from "../domain/processCapture.js";
6
7
  import { captureProcessScenario } from "./ProcessHarness.js";
7
8
  import { PROCESS_PROVIDER, createProcessCaptureEvidence, } from "./ProcessEvidence.js";
9
+ import { loadConfiguredPermissionAuthority } from "./PermissionConfiguration.js";
8
10
  const MAX_INPUT_BYTES = 64 * 1024 * 1024;
9
11
  /** Capture one JSON scenario through the same policy and evidence contract as MCP. */
10
12
  export const captureProcessScenarioFile = async (path) => {
11
- const scenario = parseProcessScenario(await readJson(path));
12
- const config = parseConfig(process.env);
13
- if (!config.ok)
14
- throw config.error;
15
- const captured = await captureProcessScenario(scenario, config.value.processExecutionPolicy);
16
- if (!captured.ok)
17
- throw captured.error;
18
- return createProcessCaptureEvidence(scenario, captured.value);
13
+ try {
14
+ const input = await readJson(path);
15
+ let scenario;
16
+ try {
17
+ scenario = parseProcessScenario(input);
18
+ }
19
+ catch {
20
+ throw new ProcessCliFailure("invalid_input", "Process scenario is invalid. Check its required fields and limits, then try again.");
21
+ }
22
+ const config = parseConfig(process.env);
23
+ if (!config.ok)
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
+ }));
46
+ const captured = await captureProcessScenario(scenario, config.value.processExecutionPolicy);
47
+ if (!captured.ok)
48
+ return cliAnalysisError(captured.error);
49
+ return createProcessCaptureEvidence(scenario, captured.value);
50
+ }
51
+ catch (cause) {
52
+ return projectProcessCliError(cause);
53
+ }
19
54
  };
20
55
  /** Compare two capture Evidence files and emit derived comparison Evidence. */
21
56
  export const compareProcessEvidenceFiles = async (leftPath, rightPath) => {
22
- const left = parseCaptureEvidence(await readJson(leftPath));
23
- const right = parseCaptureEvidence(await readJson(rightPath));
24
- const comparison = compareProcessCaptures(left.capture, right.capture);
25
- return createEvidence(undefined, PROCESS_PROVIDER, {
26
- predicateType: "rea.process-comparison/v3",
27
- operation: "compare_process_captures",
28
- parameters: {
29
- left_evidence_id: left.id,
30
- right_evidence_id: right.id,
31
- left_normalization: left.capture.normalization,
32
- right_normalization: right.capture.normalization,
33
- },
34
- result: jsonValueSchema.parse(comparison),
35
- confidence: "derived",
36
- authority: "analyst-inference",
37
- limitations: comparison.limitations,
38
- evidenceLinks: [left.id, right.id],
39
- });
57
+ try {
58
+ const left = parseCaptureEvidence(await readJson(leftPath));
59
+ const right = parseCaptureEvidence(await readJson(rightPath));
60
+ const comparison = compareProcessCaptures(left.capture, right.capture);
61
+ return createEvidence(undefined, PROCESS_PROVIDER, {
62
+ predicateType: "rea.process-comparison/v3",
63
+ operation: "compare_process_captures",
64
+ parameters: {
65
+ left_evidence_id: left.id,
66
+ right_evidence_id: right.id,
67
+ left_normalization: left.capture.normalization,
68
+ right_normalization: right.capture.normalization,
69
+ },
70
+ result: jsonValueSchema.parse(comparison),
71
+ confidence: "derived",
72
+ authority: "analyst-inference",
73
+ limitations: comparison.limitations,
74
+ evidenceLinks: [left.id, right.id],
75
+ });
76
+ }
77
+ catch (cause) {
78
+ return projectProcessCliError(cause);
79
+ }
40
80
  };
41
81
  const parseCaptureEvidence = (input) => {
42
82
  const evidence = parseEvidence(input);
@@ -45,8 +85,8 @@ const parseCaptureEvidence = (input) => {
45
85
  evidence.provider.id !== PROCESS_PROVIDER.id ||
46
86
  evidence.provider.version !== PROCESS_PROVIDER.version) {
47
87
  if (evidence.predicate_type === "rea.process-capture/v3")
48
- throw new TypeError(LEGACY_PROCESS_CAPTURE_MESSAGE);
49
- throw new TypeError("Expected REA Process Capture v4 Evidence");
88
+ throw new ProcessCliFailure("invalid_input", LEGACY_PROCESS_CAPTURE_MESSAGE);
89
+ throw new ProcessCliFailure("invalid_input", "Capture evidence is not from the current process-capture workflow. Create new capture evidence, then try again.");
50
90
  }
51
91
  return {
52
92
  id: evidence.evidence_id,
@@ -54,8 +94,48 @@ const parseCaptureEvidence = (input) => {
54
94
  };
55
95
  };
56
96
  const readJson = async (path) => {
57
- const bytes = await readFile(path);
97
+ let bytes;
98
+ try {
99
+ bytes = await readFile(path);
100
+ }
101
+ catch {
102
+ throw new ProcessCliFailure("invalid_input", "Process input file could not be read. Check that the path exists and is readable.");
103
+ }
58
104
  if (bytes.length > MAX_INPUT_BYTES)
59
- throw new TypeError("Process JSON input exceeds byte limit");
60
- return JSON.parse(bytes.toString("utf8"));
105
+ throw new ProcessCliFailure("truncated", "Process input file is too large. Reduce it below 64 MiB, then try again.");
106
+ try {
107
+ return JSON.parse(bytes.toString("utf8"));
108
+ }
109
+ catch {
110
+ throw new ProcessCliFailure("invalid_input", "Process input file is not valid JSON. Repair the file, then try again.");
111
+ }
112
+ };
113
+ class ProcessCliFailure extends Error {
114
+ category;
115
+ userMessage;
116
+ constructor(category, userMessage) {
117
+ super(userMessage);
118
+ this.category = category;
119
+ this.userMessage = userMessage;
120
+ }
121
+ }
122
+ const cliAnalysisError = (error) => ({
123
+ error: "Process command failed",
124
+ ...projectAnalysisError(error),
125
+ });
126
+ /** Project any process CLI failure without exposing its cause. */
127
+ export const projectProcessCliError = (cause) => {
128
+ if (cause instanceof ProcessCliFailure)
129
+ return {
130
+ error: "Process command failed",
131
+ category: cause.category,
132
+ message: cause.userMessage,
133
+ };
134
+ if (cause instanceof AnalysisError)
135
+ return cliAnalysisError(cause);
136
+ return {
137
+ error: "Process command failed",
138
+ category: "execution_failure",
139
+ message: "Process command could not complete. Check the input files and run `rea doctor`, then try again.",
140
+ };
61
141
  };