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.
- package/README.md +21 -0
- package/bridge/hopper_bridge.py +12 -3
- package/dist/application/ArtifactGraphConstruction.js +1 -1
- package/dist/application/ArtifactInventory.js +98 -26
- package/dist/application/AuthorizedArtifactInventory.js +2 -2
- package/dist/application/BinarySession.js +83 -0
- package/dist/application/CapabilityInventory.js +127 -0
- package/dist/application/ClientRegistrationStatus.js +69 -0
- package/dist/application/CrossVersionInventory.js +19 -8
- package/dist/application/CrossVersionInvestigation.js +68 -5
- package/dist/application/DirectAnalysis.js +97 -9
- package/dist/application/Doctor.js +74 -2
- package/dist/application/PermissionAuthority.js +183 -0
- package/dist/application/PermissionConfiguration.js +26 -0
- package/dist/application/ProcessCaptureError.js +13 -0
- package/dist/application/ProcessCaptureLifecycle.js +3 -1
- package/dist/application/ProcessCli.js +23 -10
- package/dist/application/ProcessHarness.js +4 -1
- package/dist/application/ProgressReporter.js +30 -0
- package/dist/application/ProjectPermissionStore.js +110 -0
- package/dist/application/Setup.js +2 -41
- package/dist/application/SupportedClients.js +41 -0
- package/dist/application/Uninstall.js +1 -1
- package/dist/application/Upgrade.js +2 -0
- package/dist/application/runtime.js +1 -1
- package/dist/artifacts/ArtifactProvider.js +13 -3
- package/dist/catalogIdentity.js +121 -0
- package/dist/cli.js +46 -3
- package/dist/cliEvidenceCommands.js +42 -1
- package/dist/cliInvestigationCommands.js +57 -6
- package/dist/cliLogging.js +20 -3
- package/dist/cliPolicyCommands.js +115 -0
- package/dist/cliProcessCommands.js +4 -14
- package/dist/config.js +63 -3
- package/dist/contracts/artifactToolContracts.js +14 -1
- package/dist/contracts/errorSchemas.js +98 -0
- package/dist/contracts/toolContracts.js +10 -1
- package/dist/contracts/toolOutputSchemas.js +62 -30
- package/dist/domain/artifactComparison.js +38 -11
- package/dist/domain/artifactGraph.js +25 -1
- package/dist/domain/artifactInventoryEvidence.js +18 -0
- package/dist/domain/changedBehavior.js +5 -3
- package/dist/domain/errors.js +216 -3
- package/dist/domain/investigationWorkspace.js +131 -4
- package/dist/domain/permissionPolicy.js +157 -0
- package/dist/domain/processComparison.js +1 -0
- package/dist/domain/reconstructionVerification.js +1 -1
- package/dist/domain/reconstructionVerificationSchemas.js +1 -0
- package/dist/domain/staticRuntimeCorrelation.js +5 -1
- package/dist/generatedPackageMetadata.js +9 -0
- package/dist/hopper/BridgeLauncher.js +7 -3
- package/dist/hopper/HopperClient.js +32 -5
- package/dist/identity.js +10 -3
- package/dist/logger.js +2 -2
- package/dist/main.js +95 -7
- package/dist/server/createServer.js +52 -5
- package/dist/server/mcpProgress.js +23 -0
- package/dist/server/registerArtifactComparisonTool.js +6 -2
- package/dist/server/registerBundleComparisonTool.js +6 -2
- package/dist/server/registerEnhancedTools.js +18 -1
- package/dist/server/registerEvidenceResources.js +302 -0
- package/dist/server/registerEvidenceTools.js +58 -1
- package/dist/server/registerFunctionComparisonTool.js +6 -2
- package/dist/server/registerInvestigationTools.js +83 -10
- package/dist/server/registerOfficialTools.js +19 -1
- package/dist/server/registerProcessComparisonTool.js +7 -3
- package/dist/server/registerSessionStatusTool.js +44 -3
- package/dist/server/registerSessionTools.js +129 -7
- package/dist/server/runDerivedOperation.js +35 -0
- package/dist/server/toolResult.js +27 -3
- package/dist/serverIdentity.js +70 -0
- 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.
|
package/bridge/hopper_bridge.py
CHANGED
|
@@ -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
|
|
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"] == "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
200
|
-
if (
|
|
201
|
-
nodes.set(node.artifact_id, node);
|
|
202
|
-
occurrence.artifact_id = node.artifact_id;
|
|
203
|
-
occurrence.hash_status =
|
|
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";
|