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
package/README.md CHANGED
@@ -420,6 +420,27 @@ rea mcp
420
420
 
421
421
  REA accepts a Mac `.app` folder directly. If an agent cannot find an app by name, tell it where the app is installed.
422
422
 
423
+ ### CLI exit status
424
+
425
+ | Status | Meaning |
426
+ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
427
+ | `0` | The requested operation completed. Truthful unknowns, warnings, and bounded or truncated evidence remain successful results. |
428
+ | `1` | Arguments, policy, permission, provider analysis, integrity checking, cancellation, timeout, setup, diagnostics, update, uninstall, output encoding, or output writing prevented completion. Structured output identifies the failure category when REA could encode it. |
429
+ | `128 + N` | The process ended from signal `N`, where the shell or runtime preserves the conventional signal-derived status. |
430
+
431
+ `setup` returns `1` for `planned`, `needs_confirmation`, or `needs_human`
432
+ because configuration is not ready; rerun it after approval or remediation.
433
+ `doctor` returns `1` when any check is unhealthy. Output format, full envelopes,
434
+ filters, and token controls never change the operation status.
435
+
436
+ When REA feeds a shell pipeline, enable `pipefail` so a downstream formatter
437
+ cannot hide its failure:
438
+
439
+ ```bash
440
+ set -o pipefail
441
+ rea inventory-artifact ./app.asar --json | jq . > inventory.json
442
+ ```
443
+
423
444
  ## Current Hopper provider
424
445
 
425
446
  REA starts Hopper when needed; Hopper does not need to be running first. Hopper's launcher internally activates the application, so opening a target may bring Hopper to the foreground. REA asks macOS to start Hopper hidden and in the background when possible, but cannot guarantee that it will remain behind the current application.
@@ -628,16 +628,23 @@ def _dispatch(method, params):
628
628
  global _selected_document
629
629
  if method == "health":
630
630
  return {"name": "REA Hopper bridge", "version": "1.0.0", "run_id": REA_RUN_ID}
631
- if method == "shutdown":
631
+ if method in ("shutdown", "shutdown_document"):
632
632
  document = _session_document()
633
633
  if document is None:
634
634
  return {"shutdown": True, "analysis_stopped": True, "document_closed": True}
635
635
  if document.backgroundProcessActive():
636
636
  document.requestBackgroundProcessStop()
637
+ if method == "shutdown" and REA_OWNS_PROCESS_LIFETIME:
638
+ return {
639
+ "shutdown": True,
640
+ "analysis_stopped": not document.backgroundProcessActive(),
641
+ "document_closed": False,
642
+ "cleanup_required": True,
643
+ }
637
644
  if document.backgroundProcessActive():
638
645
  document.waitForBackgroundProcessToEnd()
639
- analysis_stopped = not document.backgroundProcessActive()
640
646
  document.closeDocument()
647
+ analysis_stopped = not document.backgroundProcessActive()
641
648
  document_closed = _session_document() is None
642
649
  return {
643
650
  "shutdown": True,
@@ -806,7 +813,9 @@ def _serve_connection(connection):
806
813
  if not isinstance(request["token"], str) or not hmac.compare_digest(request["token"], REA_TOKEN):
807
814
  raise PermissionError("Invalid bridge capability")
808
815
  result = _dispatch(request["method"], request["params"])
809
- should_stop = request["method"] == "shutdown"
816
+ should_stop = request["method"] == "shutdown_document" or (
817
+ request["method"] == "shutdown" and not result.get("cleanup_required", False)
818
+ )
810
819
  response = {"id": request_id, "result": _json_safe(result)}
811
820
  except Exception as error:
812
821
  response = {"id": request_id if isinstance(request_id, int) else 0, "error": {"code": -32000, "message": str(error)[:512], "type": _diagnostic_type(error)}}
@@ -83,7 +83,7 @@ export const createArtifactNode = (input) => ({
83
83
  architecture: null,
84
84
  executable: input.executable,
85
85
  content_state: input.contentState,
86
- limitations: [],
86
+ limitations: [...(input.limitations ?? [])],
87
87
  });
88
88
  export const rootOccurrenceFor = (node, declaredSize) => ({
89
89
  occurrence_id: `occ_${digestCanonical({ root: node.artifact_id })}`,
@@ -15,19 +15,28 @@ const NATIVE_MOUNT_DISABLED = {
15
15
  nativeMountApproved: false,
16
16
  nativeMountEnabled: false,
17
17
  };
18
+ const STRICT_INTEGRITY_POLICY = {
19
+ mode: "fail",
20
+ approved: false,
21
+ enabled: false,
22
+ maxMismatches: 1,
23
+ };
18
24
  /** Inventory one local artifact without extracting or mounting it. */
19
25
  export const inventoryArtifact = async (inputPath, limits, page, options = {}) => {
20
- const snapshot = await scanArtifactInventory(inputPath, limits, options.signal, options.nativeMount ?? NATIVE_MOUNT_DISABLED);
26
+ const snapshot = await scanArtifactInventory(inputPath, limits, options.signal, options.nativeMount ?? NATIVE_MOUNT_DISABLED, options.integrity ?? STRICT_INTEGRITY_POLICY);
21
27
  return paginateArtifactInventory(snapshot, page);
22
28
  };
23
29
  /** Scan an artifact once and retain the complete immutable graph for projection. */
24
- export const scanArtifactInventory = async (inputPath, limits, signal, nativeMount = NATIVE_MOUNT_DISABLED) => {
30
+ export const scanArtifactInventory = async (inputPath, limits, signal, nativeMount = NATIVE_MOUNT_DISABLED, integrity = STRICT_INTEGRITY_POLICY) => {
25
31
  abortIfNeeded(signal);
26
32
  const path = await realpath(inputPath);
27
- return scanCanonicalArtifactInventory(path, limits, signal, nativeMount);
33
+ return scanCanonicalArtifactInventory(path, limits, signal, nativeMount, integrity);
28
34
  };
29
35
  /** Scan one already-canonical artifact path without resolving it again. */
30
- export const scanCanonicalArtifactInventory = async (path, limits, signal, nativeMount = NATIVE_MOUNT_DISABLED) => {
36
+ export const scanCanonicalArtifactInventory = async (path, limits, signal, nativeMount = NATIVE_MOUNT_DISABLED, integrity = STRICT_INTEGRITY_POLICY) => {
37
+ if (integrity.mode === "record-and-continue" &&
38
+ (!integrity.approved || !integrity.enabled))
39
+ throw new ArtifactReaderFailure("unavailable", "Integrity continuation requires explicit approval and operator policy");
31
40
  const metadata = await lstat(path);
32
41
  const rootFormat = await classifyRoot(path, metadata.isDirectory());
33
42
  const rootDigest = metadata.isDirectory()
@@ -35,7 +44,7 @@ export const scanCanonicalArtifactInventory = async (path, limits, signal, nativ
35
44
  : await hashReadable(createReadStream(path), limits.maxTotalBytes, signal);
36
45
  const reader = await createReader(path, rootFormat, nativeMount, signal);
37
46
  try {
38
- const { nodes, occurrences } = await scanReader(reader, limits, signal);
47
+ const { nodes, occurrences, pendingContradictions } = await scanReader(reader, limits, signal, integrity);
39
48
  materializeDirectoryNodes(occurrences, nodes);
40
49
  const rootNode = createRootNode({
41
50
  path,
@@ -51,6 +60,36 @@ export const scanCanonicalArtifactInventory = async (path, limits, signal, nativ
51
60
  if (occurrence.parent_occurrence_id === null)
52
61
  occurrence.parent_occurrence_id = rootOccurrence.occurrence_id;
53
62
  occurrences.unshift(rootOccurrence);
63
+ const occurrenceById = new Map(occurrences.map((occurrence) => [occurrence.occurrence_id, occurrence]));
64
+ const integrityContradictions = pendingContradictions.map((contradiction) => {
65
+ const occurrence = occurrences.find(({ logical_path: path }) => path === contradiction.logicalPath);
66
+ if (occurrence === undefined)
67
+ throw new ArtifactReaderFailure("integrity", "Integrity contradiction lost its graph occurrence");
68
+ const parent = occurrence.parent_occurrence_id === null
69
+ ? undefined
70
+ : occurrenceById.get(occurrence.parent_occurrence_id);
71
+ const parentArtifactId = parent?.artifact_id ?? rootNode.artifact_id;
72
+ return {
73
+ contradiction_id: `ic_${digestCanonical({
74
+ root_artifact_id: rootNode.artifact_id,
75
+ logical_path: contradiction.logicalPath,
76
+ declared_sha256: contradiction.declaredSha256,
77
+ observed_sha256: contradiction.observedSha256,
78
+ })}`,
79
+ occurrence_id: occurrence.occurrence_id,
80
+ parent_artifact_id: parentArtifactId,
81
+ logical_path: contradiction.logicalPath,
82
+ declared_sha256: contradiction.declaredSha256,
83
+ observed_sha256: contradiction.observedSha256,
84
+ entry_kind: contradiction.entryKind,
85
+ unpacked: contradiction.unpacked,
86
+ trust: "observed-untrusted",
87
+ provenance: "container-integrity-metadata-versus-observed-bytes",
88
+ limitations: [
89
+ "Observed bytes contradict declared integrity metadata and cannot support equivalence.",
90
+ ],
91
+ };
92
+ });
54
93
  const edges = createArtifactEdges(rootNode.artifact_id, occurrences, reader?.provenance()[0]);
55
94
  const orderedNodes = [...nodes.values()].sort((left, right) => left.artifact_id.localeCompare(right.artifact_id));
56
95
  const orderedOccurrences = occurrences.sort((left, right) => left.logical_path.localeCompare(right.logical_path, "en"));
@@ -65,6 +104,7 @@ export const scanCanonicalArtifactInventory = async (path, limits, signal, nativ
65
104
  nodes: orderedNodes,
66
105
  occurrences: orderedOccurrences,
67
106
  edges: orderedEdges,
107
+ integrity_contradictions: integrityContradictions,
68
108
  });
69
109
  const manifestId = `agm_${digestCanonical({
70
110
  schema_version: 1,
@@ -88,7 +128,15 @@ export const scanCanonicalArtifactInventory = async (path, limits, signal, nativ
88
128
  edges: orderedEdges,
89
129
  limits: toOutputLimits(limits),
90
130
  provenance: reader?.provenance() ?? [],
91
- limitations: inventoryLimitations(rootFormat, reader),
131
+ integrity_contradictions: integrityContradictions,
132
+ limitations: [
133
+ ...inventoryLimitations(rootFormat, reader),
134
+ ...(integrityContradictions.length === 0
135
+ ? []
136
+ : [
137
+ `${String(integrityContradictions.length)} integrity contradiction(s) were recorded; mismatched content is observed-untrusted.`,
138
+ ]),
139
+ ],
92
140
  };
93
141
  }
94
142
  finally {
@@ -149,11 +197,11 @@ const visitNestedAsar = async (adapterKey, logicalPath, visit) => {
149
197
  const isExpandableAsar = (entry, logicalPath) => entry.kind === "file" &&
150
198
  logicalPath.toLowerCase().endsWith(".asar") &&
151
199
  entry.adapterKey.startsWith("/");
152
- const emptyScan = () => ({ nodes: new Map(), occurrences: [] });
153
- const scanReader = async (reader, limits, signal) => {
154
- const { nodes, occurrences } = emptyScan();
200
+ const emptyScan = () => ({ nodes: new Map(), occurrences: [], pendingContradictions: [] });
201
+ const scanReader = async (reader, limits, signal, integrity = STRICT_INTEGRITY_POLICY) => {
202
+ const { nodes, occurrences, pendingContradictions } = emptyScan();
155
203
  if (reader === undefined)
156
- return { nodes, occurrences };
204
+ return { nodes, occurrences, pendingContradictions };
157
205
  const occurrenceByPath = new Map();
158
206
  const registry = new ArtifactPathRegistry();
159
207
  let totalBytes = 0;
@@ -167,24 +215,44 @@ const scanReader = async (reader, limits, signal) => {
167
215
  throw new ArtifactReaderFailure("limit", "Declared artifact bytes exceed remaining cumulative limit");
168
216
  const digest = await hashReadable(await currentReader.open(entry, signal), Math.min(limits.maxEntryBytes, remainingBytes), signal);
169
217
  totalBytes += digest.bytes;
170
- if (entry.declaredSha256 !== null && entry.declaredSha256 !== digest.sha256)
218
+ const mismatched = entry.declaredSha256 !== null && entry.declaredSha256 !== digest.sha256;
219
+ if (mismatched && integrity.mode === "fail")
171
220
  throw new ArtifactReaderFailure("integrity", `Artifact integrity metadata disagrees with content: ${logicalPath}`, undefined, {
172
221
  logicalPath,
173
222
  declaredSha256: entry.declaredSha256,
174
223
  calculatedSha256: digest.sha256,
175
224
  unpacked: entry.unpacked,
176
225
  });
226
+ if (mismatched && entry.declaredSha256 !== null) {
227
+ if (pendingContradictions.length >= integrity.maxMismatches)
228
+ throw new ArtifactReaderFailure("limit", "Artifact integrity mismatch limit exceeded");
229
+ pendingContradictions.push({
230
+ logicalPath,
231
+ declaredSha256: entry.declaredSha256,
232
+ observedSha256: digest.sha256,
233
+ entryKind: entry.kind,
234
+ unpacked: entry.unpacked,
235
+ });
236
+ }
177
237
  const classified = entry.kind === "slice"
178
238
  ? { kind: "universal-slice", format: "mach-o" }
179
239
  : classifyArtifactContent(logicalPath, digest.prefix);
180
- return createArtifactNode({
181
- sha256: digest.sha256,
182
- size: digest.bytes,
183
- kind: classified.kind,
184
- format: classified.format,
185
- executable: entry.executable,
186
- contentState: "embedded",
187
- });
240
+ return {
241
+ node: createArtifactNode({
242
+ sha256: digest.sha256,
243
+ size: digest.bytes,
244
+ kind: classified.kind,
245
+ format: classified.format,
246
+ executable: entry.executable,
247
+ contentState: "embedded",
248
+ limitations: mismatched
249
+ ? [
250
+ "Observed content contradicts declared integrity metadata and is untrusted.",
251
+ ]
252
+ : [],
253
+ }),
254
+ mismatched,
255
+ };
188
256
  };
189
257
  const visit = async (currentReader, prefix) => {
190
258
  for await (const entry of currentReader.entries(signal)) {
@@ -196,20 +264,24 @@ const scanReader = async (reader, limits, signal) => {
196
264
  preflightEntry(entry, limits);
197
265
  const parent = nearestParent(logicalPath, occurrenceByPath);
198
266
  const occurrence = createOccurrence(entry, logicalPath, parent?.occurrence_id ?? null);
199
- const node = await digestEntry(currentReader, entry, logicalPath);
200
- if (node !== undefined) {
201
- nodes.set(node.artifact_id, node);
202
- occurrence.artifact_id = node.artifact_id;
203
- occurrence.hash_status = "verified";
267
+ const digested = await digestEntry(currentReader, entry, logicalPath);
268
+ if (digested !== undefined) {
269
+ nodes.set(digested.node.artifact_id, digested.node);
270
+ occurrence.artifact_id = digested.node.artifact_id;
271
+ occurrence.hash_status = digested.mismatched
272
+ ? "mismatched"
273
+ : "verified";
274
+ if (digested.mismatched)
275
+ occurrence.limitations.push("Declared integrity metadata contradicts observed bytes.");
204
276
  }
205
277
  occurrences.push(occurrence);
206
278
  occurrenceByPath.set(logicalPath, occurrence);
207
- if (expandableAsar)
279
+ if (expandableAsar && digested?.mismatched !== true)
208
280
  await visitNestedAsar(entry.adapterKey, logicalPath, visit);
209
281
  }
210
282
  };
211
283
  await visit(reader, "");
212
- return { nodes, occurrences };
284
+ return { nodes, occurrences, pendingContradictions };
213
285
  };
214
286
  const classifyRoot = async (path, directory) => {
215
287
  if (directory)
@@ -3,7 +3,7 @@ import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
3
3
  import { withinRoot } from "../reference/ReferenceSourceReaderPaths.js";
4
4
  import { scanCanonicalArtifactInventory, } from "./ArtifactInventory.js";
5
5
  /** Resolve, authorize, and scan one artifact without a second path resolution. */
6
- export const scanAuthorizedArtifactInventory = async (inputPath, roots, limits, signal) => {
6
+ export const scanAuthorizedArtifactInventory = async (inputPath, roots, limits, signal, integrity) => {
7
7
  if (roots.length === 0)
8
8
  throw new ArtifactReaderFailure("path", "Artifact input roots are disabled");
9
9
  const [path, approved] = await Promise.all([
@@ -12,5 +12,5 @@ export const scanAuthorizedArtifactInventory = async (inputPath, roots, limits,
12
12
  ]);
13
13
  if (!approved.some((root) => withinRoot(root, path)))
14
14
  throw new ArtifactReaderFailure("path", "Artifact path is outside approved input roots");
15
- return scanCanonicalArtifactInventory(path, limits, signal);
15
+ return scanCanonicalArtifactInventory(path, limits, signal, undefined, integrity);
16
16
  };
@@ -10,6 +10,7 @@ import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/toolContracts.js";
10
10
  import { snapshotMatchesTarget } from "../domain/analysisSnapshot.js";
11
11
  import { AnalysisSnapshotCache, isSnapshotCacheable, } from "./AnalysisSnapshotCache.js";
12
12
  import { UNKNOWN_REGISTRY_PROVIDER, unknownEvidenceLinks, unknownMutationEvidence, } from "./UnknownEvidence.js";
13
+ import { parseInvestigationWorkspace, } from "../domain/investigationWorkspace.js";
13
14
  const OFFICIAL_OPERATIONS = new Set(OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name));
14
15
  /**
15
16
  * Owns the single active target shared by CLI and MCP adapters.
@@ -32,6 +33,9 @@ export class BinarySession {
32
33
  maxBytes: 64 * 1024 * 1024,
33
34
  });
34
35
  #snapshot = new AnalysisSnapshotCache();
36
+ #investigationWorkspaces = new Map();
37
+ #runtimeAvailability = new Map();
38
+ #availabilityListeners = new Set();
35
39
  #snapshotsEnabled;
36
40
  #snapshotInvalidated = false;
37
41
  constructor(provider, options = {}) {
@@ -73,6 +77,11 @@ export class BinarySession {
73
77
  }
74
78
  return this.#providerIdentity;
75
79
  }
80
+ /** Observe runtime provider-health changes that affect discovery metadata. */
81
+ onAvailabilityChanged(listener) {
82
+ this.#availabilityListeners.add(listener);
83
+ return () => this.#availabilityListeners.delete(listener);
84
+ }
76
85
  /** Add one successful public observation to the session ledger. */
77
86
  recordEvidence(evidence) {
78
87
  return this.#evidence.record(evidence);
@@ -110,6 +119,24 @@ export class BinarySession {
110
119
  return err(new EvidenceIntegrityError("Analysis snapshots are unavailable with custom Hopper loader arguments"));
111
120
  return this.#snapshot.import(snapshot, this.#active?.target, (bundle) => this.#evidence.import(bundle));
112
121
  }
122
+ /** Retain one validated immutable workspace revision for session resources. */
123
+ retainInvestigationWorkspace(workspace) {
124
+ const parsed = parseInvestigationWorkspace(workspace);
125
+ const key = `${parsed.workspace_id}:${String(parsed.revision)}`;
126
+ if (this.#investigationWorkspaces.has(key))
127
+ return "duplicate";
128
+ this.#investigationWorkspaces.set(key, parsed);
129
+ return "added";
130
+ }
131
+ /** Read one session-retained immutable workspace revision. */
132
+ investigationWorkspace(workspaceId, revision) {
133
+ return this.#investigationWorkspaces.get(`${workspaceId}:${String(revision)}`);
134
+ }
135
+ /** List retained workspace revisions in canonical identity order. */
136
+ investigationWorkspaces() {
137
+ return [...this.#investigationWorkspaces.values()].sort((left, right) => left.workspace_id.localeCompare(right.workspace_id) ||
138
+ left.revision - right.revision);
139
+ }
113
140
  /** Create an approved residual unknown and immutable mutation evidence. */
114
141
  recordUnknown(input) {
115
142
  const evidence = unknownMutationEvidence(this.#active?.target, input);
@@ -189,6 +216,7 @@ export class BinarySession {
189
216
  return err(new AnalysisCancelledError("open_binary"));
190
217
  }
191
218
  this.#active = { target: parsed.value, client };
219
+ this.#clearRuntimeAvailability();
192
220
  this.#snapshot.select(parsed.value);
193
221
  if (options.snapshot !== undefined) {
194
222
  const imported = this.importAnalysisSnapshot(options.snapshot);
@@ -213,6 +241,7 @@ export class BinarySession {
213
241
  this.#evidence.clear();
214
242
  this.#snapshot.clear();
215
243
  this.#snapshotInvalidated = false;
244
+ this.#clearRuntimeAvailability();
216
245
  return ok(null);
217
246
  });
218
247
  }
@@ -237,6 +266,10 @@ export class BinarySession {
237
266
  ? []
238
267
  : [...this.#capabilities.values()]
239
268
  .sort((left, right) => left.operation.localeCompare(right.operation))
269
+ .map((descriptor) => ({
270
+ ...descriptor,
271
+ ...this.#runtimeAvailability.get(descriptor.operation),
272
+ }))
240
273
  .map((descriptor) => ({
241
274
  operation: descriptor.operation,
242
275
  available: descriptor.available,
@@ -304,6 +337,7 @@ export class BinarySession {
304
337
  this.#calls.add(call);
305
338
  try {
306
339
  const result = await call;
340
+ this.#observeRuntimeAvailability(name, result);
307
341
  if (result.ok && cacheable)
308
342
  this.#snapshot.record(active.target, name, arguments_, result.value);
309
343
  else if (result.ok && capability?.effects.mutatesArtifact === true) {
@@ -316,6 +350,55 @@ export class BinarySession {
316
350
  this.#calls.delete(call);
317
351
  }
318
352
  }
353
+ #observeRuntimeAvailability(operation, result) {
354
+ if (result.ok) {
355
+ if (this.#setRuntimeAvailability(operation, true, null))
356
+ this.#emitAvailabilityChanged();
357
+ return;
358
+ }
359
+ if (result.error._tag === "AnalysisCapabilityUnavailableError") {
360
+ if (this.#setRuntimeAvailability(operation, false, result.error.message))
361
+ this.#emitAvailabilityChanged();
362
+ return;
363
+ }
364
+ if ([
365
+ "ProviderAdapterError",
366
+ "HopperProcessError",
367
+ "HopperStartError",
368
+ "HopperRemoteError",
369
+ ].includes(result.error._tag)) {
370
+ const providerId = this.#capabilities?.get(operation)?.provider.id;
371
+ let changed = false;
372
+ for (const descriptor of this.#capabilities?.values() ?? [])
373
+ if (providerId !== undefined && descriptor.provider.id === providerId)
374
+ changed =
375
+ this.#setRuntimeAvailability(descriptor.operation, false, "Provider became unavailable during this session.") || changed;
376
+ if (changed)
377
+ this.#emitAvailabilityChanged();
378
+ }
379
+ }
380
+ #setRuntimeAvailability(operation, available, reason) {
381
+ const current = this.#runtimeAvailability.get(operation);
382
+ if (current?.available === available && current.reason === reason)
383
+ return false;
384
+ if (available && current === undefined)
385
+ return false;
386
+ if (available)
387
+ this.#runtimeAvailability.delete(operation);
388
+ else
389
+ this.#runtimeAvailability.set(operation, { available, reason });
390
+ return true;
391
+ }
392
+ #clearRuntimeAvailability() {
393
+ if (this.#runtimeAvailability.size === 0)
394
+ return;
395
+ this.#runtimeAvailability.clear();
396
+ this.#emitAvailabilityChanged();
397
+ }
398
+ #emitAvailabilityChanged() {
399
+ for (const listener of this.#availabilityListeners)
400
+ listener();
401
+ }
319
402
  #serialize(operation) {
320
403
  const result = this.#transition.then(operation, operation);
321
404
  this.#transition = result.then(() => undefined, () => undefined);
@@ -0,0 +1,127 @@
1
+ import { z } from "zod";
2
+ import { TOOL_CONTRACTS } from "../contracts/toolContracts.js";
3
+ const statusSchema = z.object({
4
+ open: z.boolean(),
5
+ kind: z.enum(["executable", "database", "archive", "artifact"]).optional(),
6
+ format: z.string().optional(),
7
+ capabilities: z.array(z.object({
8
+ operation: z.string(),
9
+ available: z.boolean(),
10
+ reason: z.string().nullable(),
11
+ })),
12
+ });
13
+ const ENHANCED_REQUIREMENTS = {
14
+ swift_classes: ["list_procedures"],
15
+ get_objc_classes: ["list_names"],
16
+ get_objc_protocols: ["list_names"],
17
+ batch_decompile: ["procedure_pseudo_code"],
18
+ get_call_graph: ["procedure_callees", "procedure_callers"],
19
+ analyze_swift_types: ["list_procedures"],
20
+ find_xrefs_to_name: ["list_names", "xrefs"],
21
+ binary_overview: [
22
+ "list_segments",
23
+ "list_documents",
24
+ "list_procedures",
25
+ "list_strings",
26
+ ],
27
+ analyze_function: ["analyze_function"],
28
+ trace_feature: [
29
+ "list_strings",
30
+ "list_procedures",
31
+ "xrefs",
32
+ "resolve_containing_procedure",
33
+ ],
34
+ };
35
+ /** Build stable per-operation availability without hiding familiar tools. */
36
+ export const buildCapabilityInventory = (sessionStatus, policy) => {
37
+ const status = statusSchema.parse(sessionStatus);
38
+ const descriptors = new Map(status.capabilities.map((descriptor) => [descriptor.operation, descriptor]));
39
+ return TOOL_CONTRACTS.map((contract) => {
40
+ const availability = availabilityFor(contract.name, contract.kind, status.open, status.kind, descriptors, policy);
41
+ return {
42
+ name: contract.name,
43
+ surface: contract.kind,
44
+ available: availability.reason === "available",
45
+ reason: availability.reason,
46
+ remediation: availability.remediation,
47
+ annotations: {
48
+ read_only: contract.annotations.readOnlyHint ?? false,
49
+ destructive: contract.annotations.destructiveHint ?? false,
50
+ idempotent: contract.annotations.idempotentHint ?? false,
51
+ open_world: contract.annotations.openWorldHint ?? true,
52
+ },
53
+ };
54
+ }).sort((left, right) => left.name.localeCompare(right.name));
55
+ };
56
+ const availabilityFor = (name, kind, targetOpen, targetKind, descriptors, policy) => {
57
+ if (name === "capture_process_scenario" && !policy.processCaptureEnabled)
58
+ return {
59
+ reason: "policy_disabled",
60
+ remediation: "Enable or grant process_capture within the administrator ceiling.",
61
+ };
62
+ if (name === "import_evidence_bundle" && policy.evidenceFileRoots === 0)
63
+ return {
64
+ reason: "policy_disabled",
65
+ remediation: "Configure or grant an evidence_read root.",
66
+ };
67
+ if (kind === "session")
68
+ return { reason: "available", remediation: null };
69
+ if (!targetOpen)
70
+ return {
71
+ reason: "target_required",
72
+ remediation: "Call open_binary with a supported local target.",
73
+ };
74
+ if (targetKind !== undefined &&
75
+ targetKind !== "executable" &&
76
+ targetKind !== "database" &&
77
+ (kind === "official-proxy" || kind === "enhanced"))
78
+ return {
79
+ reason: "target_unsupported",
80
+ remediation: "Inventory or extract a native executable, then call open_binary on that executable.",
81
+ };
82
+ if (kind === "enhanced")
83
+ return composedAvailability(name, descriptors);
84
+ const descriptor = descriptors.get(name);
85
+ if (descriptor === undefined)
86
+ return {
87
+ reason: "provider_missing",
88
+ remediation: "Install or configure a provider that declares this operation.",
89
+ };
90
+ if (!descriptor.available &&
91
+ descriptor.reason?.toLowerCase().includes("require macos") === true)
92
+ return {
93
+ reason: "unsupported_host",
94
+ remediation: descriptor.reason,
95
+ };
96
+ return descriptor.available
97
+ ? { reason: "available", remediation: null }
98
+ : {
99
+ reason: "provider_unavailable",
100
+ remediation: descriptor.reason ?? "Choose another target or configured provider.",
101
+ };
102
+ };
103
+ const composedAvailability = (name, descriptors) => {
104
+ const requirements = ENHANCED_REQUIREMENTS[name];
105
+ if (requirements === undefined)
106
+ return {
107
+ reason: "provider_missing",
108
+ remediation: "No provider composition is declared for this operation.",
109
+ };
110
+ const missing = requirements.find((operation) => !descriptors.has(operation));
111
+ if (missing !== undefined)
112
+ return {
113
+ reason: "provider_missing",
114
+ remediation: `Configure a provider for required operation ${missing}.`,
115
+ };
116
+ const unavailable = requirements
117
+ .map((operation) => descriptors.get(operation))
118
+ .find((descriptor) => descriptor?.available === false);
119
+ if (unavailable !== undefined)
120
+ return {
121
+ reason: unavailable.reason?.toLowerCase().includes("require macos") === true
122
+ ? "unsupported_host"
123
+ : "provider_unavailable",
124
+ remediation: unavailable.reason ?? "Choose another target or configured provider.",
125
+ };
126
+ return { reason: "available", remediation: null };
127
+ };
@@ -0,0 +1,69 @@
1
+ import { access, readFile } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import { parse as parseToml } from "smol-toml";
4
+ import { z } from "zod";
5
+ import { PRODUCT_IDENTITY } from "../identity.js";
6
+ import { supportedClients } from "./SupportedClients.js";
7
+ const objectSchema = z.record(z.string(), z.unknown());
8
+ const registrationSchema = z
9
+ .object({ command: z.string().min(1), args: z.array(z.string()).default([]) })
10
+ .passthrough();
11
+ /** Inspect supported client registrations without reading their environment. */
12
+ export const readClientRegistrationStatuses = async (home, currentCommandPath = resolve(process.argv[1] ?? "unknown")) => {
13
+ const statuses = [];
14
+ for (const client of supportedClients(home)) {
15
+ if (client.format === "unsupported" || !(await exists(client.markerPath)))
16
+ continue;
17
+ try {
18
+ const content = await readFile(client.configPath, "utf8");
19
+ const document = objectSchema.parse(client.format === "toml" ? parseToml(content) : JSON.parse(content));
20
+ const servers = objectSchema.parse(document[client.format === "toml" ? "mcp_servers" : "mcpServers"] ?? {});
21
+ const raw = servers[PRODUCT_IDENTITY.mcpServerKey];
22
+ if (raw === undefined) {
23
+ statuses.push(status(client.name, client.configPath, [], "missing"));
24
+ continue;
25
+ }
26
+ const registration = registrationSchema.parse(raw);
27
+ const command = [registration.command, ...registration.args];
28
+ statuses.push(status(client.name, client.configPath, command, registrationAligned(command, currentCommandPath)
29
+ ? "aligned"
30
+ : "stale"));
31
+ }
32
+ catch (cause) {
33
+ statuses.push(status(client.name, client.configPath, [], isMissing(cause) ? "missing" : "invalid"));
34
+ }
35
+ }
36
+ return statuses.sort((left, right) => left.client.localeCompare(right.client));
37
+ };
38
+ const registrationAligned = (command, currentCommandPath) => {
39
+ if (command.length === 4 &&
40
+ command[0] === "npx" &&
41
+ command[1] === "-y" &&
42
+ command[2] === PRODUCT_IDENTITY.packageName &&
43
+ command[3] === "mcp")
44
+ return true;
45
+ return (command.length === 2 &&
46
+ command[1] === "mcp" &&
47
+ resolve(command[0] ?? "") === currentCommandPath);
48
+ };
49
+ const status = (client, configPath, command, state) => ({
50
+ client,
51
+ config_path: configPath,
52
+ command,
53
+ state,
54
+ remediation: state === "aligned"
55
+ ? null
56
+ : "Run rea setup to refresh this registration, then restart the client.",
57
+ });
58
+ const exists = async (path) => {
59
+ if (path === undefined)
60
+ return false;
61
+ try {
62
+ await access(path);
63
+ return true;
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ };
69
+ const isMissing = (cause) => cause instanceof Error && "code" in cause && cause.code === "ENOENT";