rea-agents 1.3.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 (72) hide show
  1. package/README.md +21 -0
  2. package/bridge/hopper_bridge.py +12 -3
  3. package/dist/application/ArtifactGraphConstruction.js +1 -1
  4. package/dist/application/ArtifactInventory.js +98 -26
  5. package/dist/application/AuthorizedArtifactInventory.js +2 -2
  6. package/dist/application/BinarySession.js +83 -0
  7. package/dist/application/CapabilityInventory.js +127 -0
  8. package/dist/application/ClientRegistrationStatus.js +69 -0
  9. package/dist/application/CrossVersionInventory.js +19 -8
  10. package/dist/application/CrossVersionInvestigation.js +68 -5
  11. package/dist/application/DirectAnalysis.js +97 -9
  12. package/dist/application/Doctor.js +74 -2
  13. package/dist/application/PermissionAuthority.js +183 -0
  14. package/dist/application/PermissionConfiguration.js +26 -0
  15. package/dist/application/ProcessCaptureError.js +13 -0
  16. package/dist/application/ProcessCaptureLifecycle.js +3 -1
  17. package/dist/application/ProcessCli.js +23 -10
  18. package/dist/application/ProcessHarness.js +4 -1
  19. package/dist/application/ProgressReporter.js +30 -0
  20. package/dist/application/ProjectPermissionStore.js +110 -0
  21. package/dist/application/Setup.js +2 -41
  22. package/dist/application/SupportedClients.js +41 -0
  23. package/dist/application/Uninstall.js +1 -1
  24. package/dist/application/Upgrade.js +2 -0
  25. package/dist/application/runtime.js +1 -1
  26. package/dist/artifacts/ArtifactProvider.js +13 -3
  27. package/dist/catalogIdentity.js +121 -0
  28. package/dist/cli.js +46 -3
  29. package/dist/cliEvidenceCommands.js +42 -1
  30. package/dist/cliInvestigationCommands.js +57 -6
  31. package/dist/cliLogging.js +20 -3
  32. package/dist/cliPolicyCommands.js +115 -0
  33. package/dist/cliProcessCommands.js +4 -14
  34. package/dist/config.js +63 -3
  35. package/dist/contracts/artifactToolContracts.js +14 -1
  36. package/dist/contracts/errorSchemas.js +98 -0
  37. package/dist/contracts/toolContracts.js +10 -1
  38. package/dist/contracts/toolOutputSchemas.js +62 -30
  39. package/dist/domain/artifactComparison.js +38 -11
  40. package/dist/domain/artifactGraph.js +25 -1
  41. package/dist/domain/artifactInventoryEvidence.js +18 -0
  42. package/dist/domain/changedBehavior.js +5 -3
  43. package/dist/domain/errors.js +216 -3
  44. package/dist/domain/investigationWorkspace.js +131 -4
  45. package/dist/domain/permissionPolicy.js +157 -0
  46. package/dist/domain/processComparison.js +1 -0
  47. package/dist/domain/reconstructionVerification.js +1 -1
  48. package/dist/domain/reconstructionVerificationSchemas.js +1 -0
  49. package/dist/domain/staticRuntimeCorrelation.js +5 -1
  50. package/dist/generatedPackageMetadata.js +9 -0
  51. package/dist/hopper/BridgeLauncher.js +7 -3
  52. package/dist/hopper/HopperClient.js +32 -5
  53. package/dist/identity.js +10 -3
  54. package/dist/logger.js +2 -2
  55. package/dist/main.js +95 -7
  56. package/dist/server/createServer.js +52 -5
  57. package/dist/server/mcpProgress.js +23 -0
  58. package/dist/server/registerArtifactComparisonTool.js +6 -2
  59. package/dist/server/registerBundleComparisonTool.js +6 -2
  60. package/dist/server/registerEnhancedTools.js +18 -1
  61. package/dist/server/registerEvidenceResources.js +302 -0
  62. package/dist/server/registerEvidenceTools.js +58 -1
  63. package/dist/server/registerFunctionComparisonTool.js +6 -2
  64. package/dist/server/registerInvestigationTools.js +83 -10
  65. package/dist/server/registerOfficialTools.js +19 -1
  66. package/dist/server/registerProcessComparisonTool.js +7 -3
  67. package/dist/server/registerSessionStatusTool.js +44 -3
  68. package/dist/server/registerSessionTools.js +129 -7
  69. package/dist/server/runDerivedOperation.js +35 -0
  70. package/dist/server/toolResult.js +27 -3
  71. package/dist/serverIdentity.js +70 -0
  72. package/package.json +6 -4
@@ -8,13 +8,19 @@ import { scanAuthorizedArtifactInventory } from "./AuthorizedArtifactInventory.j
8
8
  import { ARTIFACT_GRAPH_PROVIDER } from "./InvestigationProviders.js";
9
9
  export const AUTOMATIC_RUN_LIMITATION = "Automatic cross-version runs compare shipped artifact structure only; they do not execute either version.";
10
10
  /** Authorize and scan both inputs once under the same bounded traversal policy. */
11
- export const scanVersions = async (input, inputRoots, signal) => {
11
+ export const scanVersions = async (input, inputRoots, signal, integrityContinueEnabled = false) => {
12
12
  try {
13
13
  if (signal?.aborted === true)
14
14
  return err(new AnalysisCancelledError("find_changed_behavior"));
15
15
  const limits = artifactLimits(input.options);
16
- const left = await scanAuthorizedArtifactInventory(input.left_path, inputRoots, limits, signal);
17
- const right = await scanAuthorizedArtifactInventory(input.right_path, inputRoots, limits, signal);
16
+ const integrity = {
17
+ mode: input.integrity_policy,
18
+ approved: input.integrity_continue_approved,
19
+ enabled: integrityContinueEnabled,
20
+ maxMismatches: input.max_integrity_mismatches,
21
+ };
22
+ const left = await scanAuthorizedArtifactInventory(input.left_path, inputRoots, limits, signal, integrity);
23
+ const right = await scanAuthorizedArtifactInventory(input.right_path, inputRoots, limits, signal, integrity);
18
24
  return ok({ left, right });
19
25
  }
20
26
  catch (cause) {
@@ -25,8 +31,8 @@ export const scanVersions = async (input, inputRoots, signal) => {
25
31
  };
26
32
  /** Project complete snapshots into bounded, comparable Evidence page sets. */
27
33
  export const createInventoryEvidencePages = (input, snapshots) => {
28
- const left = inventoryPages(input.left_path, snapshots.left, input.options);
29
- const right = inventoryPages(input.right_path, snapshots.right, input.options);
34
+ const left = inventoryPages(input.left_path, snapshots.left, input);
35
+ const right = inventoryPages(input.right_path, snapshots.right, input);
30
36
  if (left.length > 100 || right.length > 100)
31
37
  return err(new ArtifactOperationError("inventory_artifact", "limit"));
32
38
  return ok({ left, right });
@@ -46,11 +52,13 @@ export const runLimitations = (snapshots) => [
46
52
  AUTOMATIC_RUN_LIMITATION,
47
53
  ]),
48
54
  ].sort((left, right) => left.localeCompare(right));
49
- const inventoryPages = (path, snapshot, options) => {
55
+ const inventoryPages = (path, snapshot, input) => {
56
+ const options = input.options;
50
57
  const count = Math.max(1, Math.ceil(snapshot.nodes.length / options.page_size), Math.ceil(snapshot.occurrences.length / options.page_size), Math.ceil(snapshot.edges.length / options.page_size));
51
- return Array.from({ length: count }, (_, index) => inventoryPage(path, snapshot, options, index * options.page_size));
58
+ return Array.from({ length: count }, (_, index) => inventoryPage(path, snapshot, input, index * options.page_size));
52
59
  };
53
- const inventoryPage = (path, snapshot, options, offset) => {
60
+ const inventoryPage = (path, snapshot, input, offset) => {
61
+ const options = input.options;
54
62
  const result = paginateArtifactInventory(snapshot, {
55
63
  nodeOffset: offset,
56
64
  nodeLimit: options.page_size,
@@ -78,6 +86,9 @@ const inventoryPage = (path, snapshot, options, offset) => {
78
86
  max_compression_ratio: options.max_compression_ratio,
79
87
  max_depth: options.max_depth,
80
88
  max_path_bytes: options.max_path_bytes,
89
+ integrity_policy: input.integrity_policy,
90
+ integrity_continue_approved: input.integrity_continue_approved,
91
+ max_integrity_mismatches: input.max_integrity_mismatches,
81
92
  },
82
93
  result: jsonValueSchema.parse(result),
83
94
  rawResult: null,
@@ -2,8 +2,8 @@ import { compareArtifacts } from "../domain/artifactComparison.js";
2
2
  import { findChangedBehavior } from "../domain/changedBehavior.js";
3
3
  import { createEvidence } from "../domain/evidence.js";
4
4
  import { createEvidenceBundle, } from "../domain/evidenceBundle.js";
5
- import { AnalysisInputError, EvidenceIntegrityError, InvestigationWorkspaceError, } from "../domain/errors.js";
6
- import { createInvestigationRunIdentity, createInvestigationWorkspace, crossVersionInvestigationInputSchema, investigationRunSchema, investigationRunSummarySchema, reviseInvestigationWorkspace, } from "../domain/investigationWorkspace.js";
5
+ import { AnalysisCancelledError, AnalysisInputError, EvidenceIntegrityError, InvestigationWorkspaceError, } from "../domain/errors.js";
6
+ import { createLegacyInvestigationRunIdentity, createInvestigationRunIdentity, createInvestigationWorkspace, crossVersionInvestigationInputSchema, investigationRunSchema, investigationRunSummarySchema, reviseInvestigationWorkspace, } from "../domain/investigationWorkspace.js";
7
7
  import { jsonValueSchema } from "../domain/jsonValue.js";
8
8
  import { err, ok } from "../domain/result.js";
9
9
  import { AUTOMATIC_RUN_LIMITATION, createInventoryEvidencePages, runLimitations, scanVersions, targetFor, } from "./CrossVersionInventory.js";
@@ -22,15 +22,31 @@ export const runCrossVersionInvestigation = async (input, policy, execution) =>
22
22
  const preflight = validateWorkspaceRequest(initial.value, parsed.data);
23
23
  if (!preflight.ok)
24
24
  return preflight;
25
- const snapshots = await scanVersions(parsed.data, execution.inputRoots, execution.signal);
25
+ if (isCancelled(execution.signal))
26
+ return err(new AnalysisCancelledError("find_changed_behavior"));
27
+ await execution.progress?.report({
28
+ phase: "scan_versions",
29
+ completed: 0,
30
+ total: 4,
31
+ message: "Scanning both version inputs",
32
+ });
33
+ const snapshots = await scanVersions(parsed.data, execution.inputRoots, execution.signal, execution.integrityContinueEnabled);
26
34
  if (!snapshots.ok)
27
35
  return snapshots;
36
+ await execution.progress?.report({
37
+ phase: "scan_versions",
38
+ completed: 1,
39
+ total: 4,
40
+ message: "Version inputs scanned",
41
+ });
28
42
  return continueInvestigation({
29
43
  input: parsed.data,
30
44
  initial: initial.value,
31
45
  snapshots: snapshots.value,
32
46
  policy,
33
47
  session: execution.session,
48
+ signal: execution.signal,
49
+ progress: execution.progress,
34
50
  });
35
51
  };
36
52
  const continueInvestigation = async (context) => {
@@ -40,13 +56,29 @@ const continueInvestigation = async (context) => {
40
56
  left,
41
57
  right,
42
58
  options: context.input.options,
59
+ integrity_policy: context.input.integrity_policy,
60
+ integrity_continue_approved: context.input.integrity_continue_approved,
61
+ max_integrity_mismatches: context.input.max_integrity_mismatches,
43
62
  });
44
- const existing = context.initial?.runs.find(({ run_id: id }) => id === identity.runId);
63
+ const legacyIdentity = createLegacyInvestigationRunIdentity({
64
+ left,
65
+ right,
66
+ options: context.input.options,
67
+ });
68
+ const existing = context.initial?.runs.find((run) => run.run_id === identity.runId ||
69
+ (context.input.integrity_policy === "fail" &&
70
+ !context.input.integrity_continue_approved &&
71
+ context.input.max_integrity_mismatches === 10 &&
72
+ "legacy_request_identity" in run &&
73
+ run.max_integrity_mismatches === 10 &&
74
+ run.run_id === legacyIdentity.runId));
45
75
  if (context.initial !== null && existing?.status === "complete")
46
76
  return completeOutcome(context.initial, existing, true, context.session);
47
77
  const pages = createInventoryEvidencePages(context.input, context.snapshots);
48
78
  if (!pages.ok)
49
79
  return pages;
80
+ if (isCancelled(context.signal))
81
+ return err(new AnalysisCancelledError("find_changed_behavior"));
50
82
  const inventoried = await ensureInventoryCheckpoint({
51
83
  input: context.input,
52
84
  current: context.initial,
@@ -57,11 +89,36 @@ const continueInvestigation = async (context) => {
57
89
  });
58
90
  if (!inventoried.ok)
59
91
  return inventoried;
92
+ await context.progress?.report({
93
+ phase: "inventory_versions",
94
+ completed: 2,
95
+ total: 4,
96
+ message: "Both artifact inventories checkpointed",
97
+ });
98
+ if (isCancelled(context.signal))
99
+ return err(new AnalysisCancelledError("find_changed_behavior"));
60
100
  const compared = await ensureComparisonCheckpoint(context.input, inventoried.value, context.policy);
61
101
  if (!compared.ok)
62
102
  return compared;
63
- return finalizeInvestigation(context.input, compared.value, context.policy, context.session);
103
+ await context.progress?.report({
104
+ phase: "compare_artifacts",
105
+ completed: 3,
106
+ total: 4,
107
+ message: "Artifact comparison checkpointed",
108
+ });
109
+ if (isCancelled(context.signal))
110
+ return err(new AnalysisCancelledError("find_changed_behavior"));
111
+ const finalized = await finalizeInvestigation(context.input, compared.value, context.policy, context.session);
112
+ await context.progress?.report({
113
+ phase: "changed_behavior",
114
+ completed: 4,
115
+ total: 4,
116
+ message: finalized.ok ? "Investigation completed" : "Investigation failed",
117
+ terminal: true,
118
+ });
119
+ return finalized;
64
120
  };
121
+ const isCancelled = (signal) => signal?.aborted === true;
65
122
  const ensureInventoryCheckpoint = async (input) => {
66
123
  if (input.existing !== undefined) {
67
124
  const consistent = validateInventoryCheckpoint(input.existing, input.pages);
@@ -77,6 +134,9 @@ const ensureInventoryCheckpoint = async (input) => {
77
134
  left,
78
135
  right,
79
136
  options: input.input.options,
137
+ integrity_policy: input.input.integrity_policy,
138
+ integrity_continue_approved: input.input.integrity_continue_approved,
139
+ max_integrity_mismatches: input.input.max_integrity_mismatches,
80
140
  });
81
141
  const run = investigationRunSchema.parse({
82
142
  schema_version: 1,
@@ -85,6 +145,9 @@ const ensureInventoryCheckpoint = async (input) => {
85
145
  left,
86
146
  right,
87
147
  options: input.input.options,
148
+ integrity_policy: input.input.integrity_policy,
149
+ integrity_continue_approved: input.input.integrity_continue_approved,
150
+ max_integrity_mismatches: input.input.max_integrity_mismatches,
88
151
  status: "running",
89
152
  completed_stages: ["inventory_left", "inventory_right"],
90
153
  left_inventory_evidence_ids: evidenceIds(input.pages.left),
@@ -1,14 +1,17 @@
1
1
  import { parseConfig } from "../config.js";
2
+ import { jsonObjectSchema } from "../domain/jsonValue.js";
3
+ import { createServerIdentity } from "../serverIdentity.js";
2
4
  import { EnhancedTools } from "./EnhancedTools.js";
3
5
  import { createBinarySession } from "./runtime.js";
4
6
  import { silentLogger } from "../logger.js";
5
7
  import { createEvidence } from "../domain/evidence.js";
6
- import { EvidenceIntegrityError, projectAnalysisError, } from "../domain/errors.js";
8
+ import { EvidenceIntegrityError, AnalysisProtocolError, PermissionRequiredError, projectAnalysisError, } from "../domain/errors.js";
7
9
  import { access } from "node:fs/promises";
8
10
  import { readAnalysisSnapshot, writeAnalysisSnapshot, } from "./AnalysisSnapshotFiles.js";
9
11
  import { parseBinaryTarget } from "../domain/binaryTarget.js";
10
12
  import { snapshotEvidenceForQuery, snapshotMatchesTarget, } from "../domain/analysisSnapshot.js";
11
13
  import { err, ok } from "../domain/result.js";
14
+ import { loadConfiguredPermissionAuthority } from "./PermissionConfiguration.js";
12
15
  const WORKFLOW_PROVIDER = {
13
16
  id: "rea-workflow",
14
17
  name: "REA composed investigation workflow",
@@ -19,12 +22,17 @@ const WORKFLOW_PROVIDER = {
19
22
  * Unlike MCP mode, every CLI invocation is intentionally isolated and does not
20
23
  * retain a target or bridge resources for a subsequent command.
21
24
  */
22
- export const runDirectAnalysis = async (path, tool, arguments_, options = {}) => runAnalysis(path, tool, arguments_, {
25
+ export const runDirectAnalysis = async (path, tool, arguments_, options = {}) => withProcessCancellation(options.signal, (signal) => runAnalysis(path, tool, arguments_, {
23
26
  logger: options.logger ?? silentLogger,
24
27
  snapshotPath: options.snapshotPath,
25
- });
28
+ signal,
29
+ }));
26
30
  /** Execute one provider-native semantic operation with atomic provenance. */
27
- export const runProviderAnalysis = async (path, tool, arguments_, logger = silentLogger) => runAnalysis(path, tool, arguments_, { logger, snapshotPath: undefined });
31
+ export const runProviderAnalysis = async (path, tool, arguments_, logger = silentLogger, signal) => withProcessCancellation(signal, (operationSignal) => runAnalysis(path, tool, arguments_, {
32
+ logger,
33
+ snapshotPath: undefined,
34
+ signal: operationSignal,
35
+ }));
28
36
  /** Describe configured providers without opening a target or launching Hopper. */
29
37
  export const runSessionStatus = async (logger = silentLogger) => {
30
38
  const config = parseConfig(process.env);
@@ -32,17 +40,84 @@ export const runSessionStatus = async (logger = silentLogger) => {
32
40
  return cliError(config.error);
33
41
  const session = createBinarySession(config.value, logger);
34
42
  try {
35
- return session.status();
43
+ return {
44
+ ...jsonObjectSchema.parse(session.status()),
45
+ server_identity: createServerIdentity({
46
+ startedAt: new Date().toISOString(),
47
+ }),
48
+ };
36
49
  }
37
50
  finally {
38
51
  await session.close();
39
52
  }
40
53
  };
54
+ const authorizeAnalysis = async (authority, tool, arguments_, snapshotPath) => {
55
+ const requests = [];
56
+ if (snapshotPath !== undefined) {
57
+ requests.push({
58
+ capability: "snapshot_read",
59
+ path: snapshotPath,
60
+ access: "write",
61
+ }, {
62
+ capability: "snapshot_write",
63
+ path: snapshotPath,
64
+ access: "write",
65
+ });
66
+ }
67
+ if (tool === "extract_artifact" && typeof arguments_.output_root === "string")
68
+ requests.push({
69
+ capability: "artifact_extract",
70
+ path: arguments_.output_root,
71
+ access: "write",
72
+ });
73
+ for (const request of requests) {
74
+ const result = await authority.authorize({
75
+ capability: request.capability,
76
+ roots: [request.path],
77
+ executables: [],
78
+ environment_names: [],
79
+ network: "none",
80
+ mount: false,
81
+ operation_identity: `${tool}:${request.capability}:${request.path}`,
82
+ }, request.access);
83
+ if (!result.ok)
84
+ return err(result.error instanceof PermissionRequiredError
85
+ ? result.error
86
+ : new AnalysisProtocolError(result.error.message, {
87
+ cause: result.error,
88
+ }));
89
+ }
90
+ if (tool === "inventory_artifact" &&
91
+ arguments_.native_mount_approved === true) {
92
+ const result = await authority.authorize({
93
+ capability: "native_mount",
94
+ roots: [],
95
+ executables: [],
96
+ environment_names: [],
97
+ network: "none",
98
+ mount: true,
99
+ operation_identity: "inventory_artifact:native_mount",
100
+ }, "read");
101
+ if (!result.ok)
102
+ return err(result.error instanceof PermissionRequiredError
103
+ ? result.error
104
+ : new AnalysisProtocolError(result.error.message, {
105
+ cause: result.error,
106
+ }));
107
+ }
108
+ return ok(null);
109
+ };
41
110
  const runAnalysis = async (path, tool, arguments_, options) => {
42
- const { logger, snapshotPath } = options;
111
+ const { logger, signal, snapshotPath } = options;
43
112
  const config = parseConfig(process.env);
44
113
  if (!config.ok)
45
114
  return cliError(config.error);
115
+ const permissionAuthority = await loadConfiguredPermissionAuthority(config.value);
116
+ if (!permissionAuthority.ok)
117
+ return cliError(permissionAuthority.error);
118
+ const authorized = await authorizeAnalysis(permissionAuthority.value, tool, arguments_, snapshotPath);
119
+ if (!authorized.ok)
120
+ return cliError(authorized.error);
46
121
  const unavailable = snapshotUnavailable(snapshotPath, config.value.hopperLoaderArgs);
47
122
  if (unavailable !== undefined)
48
123
  return cliError(unavailable);
@@ -61,7 +136,7 @@ const runAnalysis = async (path, tool, arguments_, options) => {
61
136
  if (prepared.value.evidence !== undefined)
62
137
  return prepared.value.evidence;
63
138
  const { snapshot } = prepared.value;
64
- const opened = await session.open(path, snapshot === undefined ? {} : { snapshot });
139
+ const opened = await session.open(path, snapshot === undefined ? { signal } : { signal, snapshot });
65
140
  if (!opened.ok)
66
141
  return cliError(opened.error);
67
142
  let output;
@@ -69,7 +144,7 @@ const runAnalysis = async (path, tool, arguments_, options) => {
69
144
  if (tool === "binary_overview" ||
70
145
  tool === "analyze_function" ||
71
146
  tool === "trace_feature") {
72
- const result = await new EnhancedTools(session).execute(tool, arguments_);
147
+ const result = await new EnhancedTools(session).execute(tool, arguments_, signal);
73
148
  if (!result.ok)
74
149
  output = cliError(result.error);
75
150
  else {
@@ -86,7 +161,7 @@ const runAnalysis = async (path, tool, arguments_, options) => {
86
161
  }
87
162
  }
88
163
  else {
89
- const result = await session.execute(tool, arguments_);
164
+ const result = await session.execute(tool, arguments_, { signal });
90
165
  if (!result.ok)
91
166
  output = cliError(result.error);
92
167
  else {
@@ -117,6 +192,19 @@ const runAnalysis = async (path, tool, arguments_, options) => {
117
192
  await session.close();
118
193
  }
119
194
  };
195
+ const withProcessCancellation = async (suppliedSignal, operation) => {
196
+ if (suppliedSignal !== undefined)
197
+ return operation(suppliedSignal);
198
+ const controller = new AbortController();
199
+ const cancel = () => controller.abort();
200
+ process.once("SIGINT", cancel);
201
+ try {
202
+ return await operation(controller.signal);
203
+ }
204
+ finally {
205
+ process.off("SIGINT", cancel);
206
+ }
207
+ };
120
208
  const prepareSnapshot = async (options) => {
121
209
  const { path, tool, arguments: arguments_, snapshotPath, policy, provider, } = options;
122
210
  if (snapshotPath === undefined || !(await fileExists(snapshotPath)))
@@ -1,6 +1,5 @@
1
1
  import { constants } from "node:fs";
2
- import { access } from "node:fs/promises";
3
- import { readdir } from "node:fs/promises";
2
+ import { access, readFile, readdir } from "node:fs/promises";
4
3
  import { homedir } from "node:os";
5
4
  import { join } from "node:path";
6
5
  import { execFile } from "node:child_process";
@@ -9,6 +8,9 @@ import { parseBinaryTarget } from "../domain/binaryTarget.js";
9
8
  import { supportsNodeVersion } from "../domain/runtimeVersion.js";
10
9
  import { probeHomebrew } from "./homebrew.js";
11
10
  import { linuxHopperBinarySupported, linuxHopperLauncherPath, linuxSharedLibrariesAvailable, readLinuxDistribution, } from "./LinuxHopper.js";
11
+ import { CATALOG_IDENTITY } from "../catalogIdentity.js";
12
+ import { PRODUCT_IDENTITY, SDK_IDENTITY } from "../identity.js";
13
+ import { readClientRegistrationStatuses, } from "./ClientRegistrationStatus.js";
12
14
  const execFileAsync = promisify(execFile);
13
15
  const DEFAULT_HOPPER = "/Applications/Hopper Disassembler.app/Contents/MacOS/hopper";
14
16
  const SYSTEM_LINUX_HOPPER = "/opt/hopper/bin/Hopper";
@@ -68,10 +70,54 @@ export const runDoctor = async (target, host = systemDoctorHost()) => {
68
70
  remediation: "Supply a readable local app or program path.",
69
71
  classification: "config_drift",
70
72
  }));
73
+ const installationPaths = (await host.installationPaths?.()) ?? [];
74
+ const installedSkillVersion = await host.installedSkillVersion?.();
75
+ const registrations = (await host.clientRegistrations?.()) ?? [];
76
+ for (const registration of registrations)
77
+ if (registration.state !== "aligned")
78
+ checks.push({
79
+ name: `registration:${registration.client}`,
80
+ ok: false,
81
+ classification: "config_drift",
82
+ detail: registration.config_path,
83
+ remediation: registration.remediation ??
84
+ "Run rea setup, then restart the affected client.",
85
+ });
71
86
  return {
72
87
  healthy: checks.every(({ ok }) => ok),
73
88
  ...(hopperPath === undefined ? {} : { hopperPath }),
74
89
  checks,
90
+ identity: {
91
+ cli_package_version: PRODUCT_IDENTITY.packageVersion,
92
+ expected_skill_version: PRODUCT_IDENTITY.skillVersion,
93
+ sdk: SDK_IDENTITY,
94
+ catalog: CATALOG_IDENTITY,
95
+ live_server: {
96
+ state: "unknown",
97
+ remediation: "Compare rea://server/identity or binary_session from the active client; an on-disk registration cannot prove the running server version.",
98
+ },
99
+ installations: {
100
+ paths: installationPaths,
101
+ state: installationPaths.length === 0
102
+ ? "unknown"
103
+ : installationPaths.length === 1
104
+ ? "single"
105
+ : "multiple",
106
+ },
107
+ skill: {
108
+ installed_version: installedSkillVersion ?? null,
109
+ state: installedSkillVersion === undefined
110
+ ? "unknown"
111
+ : installedSkillVersion === PRODUCT_IDENTITY.skillVersion
112
+ ? "aligned"
113
+ : "stale",
114
+ remediation: installedSkillVersion === undefined ||
115
+ installedSkillVersion === PRODUCT_IDENTITY.skillVersion
116
+ ? null
117
+ : "Run rea setup to update the installed REA skill.",
118
+ },
119
+ registrations,
120
+ },
75
121
  };
76
122
  };
77
123
  const unsupportedHopperRemediation = (configured) => configured
@@ -157,8 +203,34 @@ export const systemDoctorHost = () => ({
157
203
  }
158
204
  return paths;
159
205
  },
206
+ async installationPaths() {
207
+ try {
208
+ const command = process.platform === "win32" ? "where" : "which";
209
+ const arguments_ = process.platform === "win32" ? ["rea"] : ["-a", "rea"];
210
+ return uniqueLines((await execFileAsync(command, arguments_)).stdout);
211
+ }
212
+ catch {
213
+ return [];
214
+ }
215
+ },
216
+ async installedSkillVersion() {
217
+ try {
218
+ const content = await readFile(join(homedir(), ".agents/skills", PRODUCT_IDENTITY.skillName, "SKILL.md"), "utf8");
219
+ return /^\s{2}version:\s*"([^"]+)"\s*$/mu.exec(content)?.[1];
220
+ }
221
+ catch {
222
+ return undefined;
223
+ }
224
+ },
225
+ clientRegistrations: () => readClientRegistrationStatuses(homedir()),
160
226
  });
161
227
  const parseMajor = (version) => Number.parseInt(version.split(".")[0] ?? "0", 10);
228
+ const uniqueLines = (value) => [
229
+ ...new Set(value
230
+ .split(/\r?\n/u)
231
+ .map((line) => line.trim())
232
+ .filter((line) => line.length > 0)),
233
+ ];
162
234
  const check = (name, ok, detail, failure) => ({
163
235
  name,
164
236
  ok,
@@ -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;