rea-agents 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -27
- package/bridge/hopper_bridge.py +172 -14
- package/dist/application/AnalysisSnapshotCache.js +131 -0
- package/dist/application/AnalysisSnapshotFiles.js +31 -0
- package/dist/application/ArtifactGraphConstruction.js +1 -1
- package/dist/application/ArtifactInventory.js +100 -24
- package/dist/application/AuthorizedArtifactInventory.js +16 -0
- package/dist/application/BinarySession.js +149 -36
- package/dist/application/BinarySessionPort.js +1 -0
- package/dist/application/BoundedJsonFiles.js +109 -0
- package/dist/application/CapabilityInventory.js +127 -0
- package/dist/application/ClientRegistrationStatus.js +69 -0
- package/dist/application/CrossVersionInventory.js +109 -0
- package/dist/application/CrossVersionInvestigation.js +358 -0
- package/dist/application/DirectAnalysis.js +205 -24
- package/dist/application/Doctor.js +104 -3
- package/dist/application/EvidenceBundleFiles.js +15 -108
- package/dist/application/EvidenceLedger.js +3 -2
- package/dist/application/InvestigationProviders.js +48 -0
- package/dist/application/InvestigationWorkspaceStore.js +214 -0
- package/dist/application/LinuxHopper.js +72 -48
- package/dist/application/PermissionAuthority.js +183 -0
- package/dist/application/PermissionConfiguration.js +26 -0
- package/dist/application/ProcessCaptureAuthority.js +2 -2
- package/dist/application/ProcessCaptureError.js +25 -0
- package/dist/application/ProcessCaptureLifecycle.js +22 -2
- package/dist/application/ProcessCli.js +111 -31
- package/dist/application/ProcessHarness.js +6 -3
- package/dist/application/ProgressReporter.js +30 -0
- package/dist/application/ProjectPermissionStore.js +110 -0
- package/dist/application/ReferenceSourceImportEntries.js +18 -3
- package/dist/application/ReferenceSourceImportTypes.js +32 -0
- package/dist/application/Setup.js +35 -101
- package/dist/application/SetupClients.js +19 -0
- package/dist/application/SetupInstallFailure.js +53 -0
- package/dist/application/SetupPlan.js +31 -0
- package/dist/application/SupportedClients.js +41 -0
- package/dist/application/Uninstall.js +34 -24
- package/dist/application/UnknownEvidence.js +33 -0
- package/dist/application/Upgrade.js +3 -1
- package/dist/application/runtime.js +2 -2
- package/dist/artifacts/ArtifactProvider.js +15 -8
- package/dist/catalogIdentity.js +121 -0
- package/dist/cli.js +76 -13
- package/dist/cliEvidenceCommands.js +52 -12
- package/dist/cliInvestigationCommands.js +137 -0
- package/dist/cliLogging.js +20 -3
- package/dist/cliOutput.js +41 -0
- package/dist/cliPolicyCommands.js +115 -0
- package/dist/config.js +102 -27
- package/dist/contracts/artifactToolContracts.js +14 -1
- package/dist/contracts/errorSchemas.js +98 -0
- package/dist/contracts/promptContracts.js +256 -0
- package/dist/contracts/sessionLifecycleInputs.js +11 -0
- package/dist/contracts/toolContractTypes.js +1 -0
- package/dist/contracts/toolContracts.js +20 -7
- package/dist/contracts/toolOutputSchemas.js +70 -9
- package/dist/domain/analysisSnapshot.js +149 -0
- 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 +35 -10
- package/dist/domain/errors.js +384 -32
- package/dist/domain/evidence.js +4 -2
- package/dist/domain/evidenceBundle.js +28 -0
- package/dist/domain/hopperStartupFailure.js +55 -0
- package/dist/domain/investigationWorkspace.js +341 -0
- package/dist/domain/jsonValue.js +2 -0
- package/dist/domain/nativeInspection.js +3 -2
- package/dist/domain/permissionPolicy.js +157 -0
- package/dist/domain/processCapture.js +5 -4
- package/dist/domain/processComparison.js +6 -4
- 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 +51 -20
- package/dist/hopper/HopperClient.js +45 -3
- package/dist/hopper/HopperProvider.js +1 -0
- package/dist/identity.js +10 -3
- package/dist/logger.js +2 -2
- package/dist/main.js +139 -28
- package/dist/server/createServer.js +54 -5
- package/dist/server/mcpProgress.js +23 -0
- package/dist/server/promptCompletion.js +144 -0
- package/dist/server/registerArtifactComparisonTool.js +6 -2
- package/dist/server/registerBundleComparisonTool.js +6 -2
- package/dist/server/registerEnhancedTools.js +20 -7
- package/dist/server/registerEvidenceResources.js +302 -0
- package/dist/server/registerEvidenceTools.js +59 -8
- package/dist/server/registerFunctionComparisonTool.js +7 -3
- package/dist/server/registerInvestigationTools.js +111 -18
- package/dist/server/registerOfficialTools.js +25 -14
- package/dist/server/registerProcessComparisonTool.js +8 -10
- package/dist/server/registerPrompts.js +67 -0
- package/dist/server/registerSessionStatusTool.js +52 -0
- package/dist/server/registerSessionTools.js +178 -22
- package/dist/server/runDerivedOperation.js +35 -0
- package/dist/server/sessionEvidence.js +2 -8
- package/dist/server/sessionToolPolicies.js +1 -42
- package/dist/server/toolResult.js +34 -6
- package/dist/serverIdentity.js +70 -0
- package/install.sh +11 -11
- package/package.json +10 -6
- package/scripts/hopper-demo-x11.py +349 -0
- package/scripts/rea.mjs +6 -3
- package/skills/rea-analysis/SKILL.md +9 -2
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { EvidenceIntegrityError, NoBinaryOpenError, } from "../domain/errors.js";
|
|
2
|
+
import { err, ok } from "../domain/result.js";
|
|
3
|
+
import { analysisQueryId, createAnalysisSnapshotEntry, snapshotMatchesTarget, snapshotTarget, } from "../domain/analysisSnapshot.js";
|
|
4
|
+
import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/toolContracts.js";
|
|
5
|
+
const STATEFUL_OPERATIONS = new Set([
|
|
6
|
+
"health",
|
|
7
|
+
"current_address",
|
|
8
|
+
"current_procedure",
|
|
9
|
+
"current_document",
|
|
10
|
+
"goto_address",
|
|
11
|
+
"list_documents",
|
|
12
|
+
"set_current_document",
|
|
13
|
+
]);
|
|
14
|
+
const CURSOR_DEFAULT_OPERATIONS = new Set([
|
|
15
|
+
"address_name",
|
|
16
|
+
"comment",
|
|
17
|
+
"inline_comment",
|
|
18
|
+
"next_address",
|
|
19
|
+
"prev_address",
|
|
20
|
+
"xrefs",
|
|
21
|
+
]);
|
|
22
|
+
const DOCUMENT_SCOPED_OPERATIONS = new Set(OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name).filter((name) => name !== "current_document" &&
|
|
23
|
+
name !== "list_documents" &&
|
|
24
|
+
name !== "set_current_document"));
|
|
25
|
+
/** Whether an operation is immutable and independent of provider UI state. */
|
|
26
|
+
export const isSnapshotCacheable = (operation, descriptor, parameters) => !STATEFUL_OPERATIONS.has(operation) &&
|
|
27
|
+
(!DOCUMENT_SCOPED_OPERATIONS.has(operation) ||
|
|
28
|
+
typeof parameters.document === "string") &&
|
|
29
|
+
(!CURSOR_DEFAULT_OPERATIONS.has(operation) ||
|
|
30
|
+
typeof parameters.address === "string") &&
|
|
31
|
+
descriptor?.effects.mutatesArtifact === false &&
|
|
32
|
+
descriptor.effects.mayWriteFilesystem === false &&
|
|
33
|
+
descriptor.effects.changesPermissions === false;
|
|
34
|
+
/** Bounded in-memory cache for one immutable binary identity. */
|
|
35
|
+
export class AnalysisSnapshotCache {
|
|
36
|
+
#entries = new Map();
|
|
37
|
+
#target;
|
|
38
|
+
/** Whether staged entries belong to the supplied binary. */
|
|
39
|
+
matches(target) {
|
|
40
|
+
return (this.#target === undefined || snapshotMatchesTarget(this.#target, target));
|
|
41
|
+
}
|
|
42
|
+
/** Replace cache state when the active binary identity changes. */
|
|
43
|
+
select(target) {
|
|
44
|
+
if (!this.matches(target))
|
|
45
|
+
this.#entries.clear();
|
|
46
|
+
this.#target = snapshotTarget(target);
|
|
47
|
+
}
|
|
48
|
+
/** Merge already-validated entries and return the new-entry count. */
|
|
49
|
+
stage(snapshot) {
|
|
50
|
+
if (this.#target !== undefined &&
|
|
51
|
+
(this.#target.sha256 !== snapshot.target.sha256 ||
|
|
52
|
+
this.#target.format !== snapshot.target.format ||
|
|
53
|
+
this.#target.architecture !== snapshot.target.architecture))
|
|
54
|
+
this.#entries.clear();
|
|
55
|
+
this.#target = snapshot.target;
|
|
56
|
+
let imported = 0;
|
|
57
|
+
for (const entry of snapshot.entries) {
|
|
58
|
+
if (!this.#entries.has(entry.query_id))
|
|
59
|
+
imported += 1;
|
|
60
|
+
this.#entries.set(entry.query_id, entry);
|
|
61
|
+
}
|
|
62
|
+
return imported;
|
|
63
|
+
}
|
|
64
|
+
/** Build a complete snapshot for an active target. */
|
|
65
|
+
export(target, evidenceBundle) {
|
|
66
|
+
if (target === undefined)
|
|
67
|
+
return err(new NoBinaryOpenError());
|
|
68
|
+
return ok({
|
|
69
|
+
snapshot_version: 1,
|
|
70
|
+
target: snapshotTarget(target),
|
|
71
|
+
entries: this.entries(),
|
|
72
|
+
evidence_bundle: evidenceBundle,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/** Validate target identity, merge evidence atomically, then stage entries. */
|
|
76
|
+
import(snapshot, active, mergeEvidence) {
|
|
77
|
+
if (active !== undefined && !snapshotMatchesTarget(snapshot.target, active))
|
|
78
|
+
return err(new EvidenceIntegrityError("Analysis snapshot target does not match the active binary"));
|
|
79
|
+
const importedEvidence = mergeEvidence(snapshot.evidence_bundle);
|
|
80
|
+
return importedEvidence.ok ? ok(this.stage(snapshot)) : importedEvidence;
|
|
81
|
+
}
|
|
82
|
+
/** Return canonical entries for persistence. */
|
|
83
|
+
entries() {
|
|
84
|
+
return [...this.#entries.values()].sort((left, right) => left.query_id.localeCompare(right.query_id));
|
|
85
|
+
}
|
|
86
|
+
/** Replay an exact provider-specific query, marking its cached provenance. */
|
|
87
|
+
lookup(target, operation, parameters, provider) {
|
|
88
|
+
const queryId = analysisQueryId(snapshotTarget(target), operation, parameters, provider);
|
|
89
|
+
const cached = this.#entries.get(queryId);
|
|
90
|
+
if (cached === undefined)
|
|
91
|
+
return undefined;
|
|
92
|
+
const subject = cached.execution.subject;
|
|
93
|
+
return {
|
|
94
|
+
result: cached.execution.result,
|
|
95
|
+
rawResult: cached.execution.raw_result,
|
|
96
|
+
provider: cached.execution.provider,
|
|
97
|
+
limitations: [
|
|
98
|
+
...cached.execution.limitations,
|
|
99
|
+
"Loaded from a local REA analysis snapshot; this call did not re-run the provider.",
|
|
100
|
+
],
|
|
101
|
+
locations: cached.execution.locations,
|
|
102
|
+
subject: subject === null
|
|
103
|
+
? null
|
|
104
|
+
: subject.architecture === null
|
|
105
|
+
? {
|
|
106
|
+
path: subject.path,
|
|
107
|
+
sha256: subject.sha256,
|
|
108
|
+
format: subject.format,
|
|
109
|
+
}
|
|
110
|
+
: {
|
|
111
|
+
path: subject.path,
|
|
112
|
+
sha256: subject.sha256,
|
|
113
|
+
format: subject.format,
|
|
114
|
+
architecture: subject.architecture,
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/** Record one successful immutable call unless the cache is full. */
|
|
119
|
+
record(target, operation, parameters, execution) {
|
|
120
|
+
if (this.#entries.size >= 10_000)
|
|
121
|
+
return;
|
|
122
|
+
this.select(target);
|
|
123
|
+
const entry = createAnalysisSnapshotEntry(snapshotTarget(target), operation, parameters, execution);
|
|
124
|
+
this.#entries.set(entry.query_id, entry);
|
|
125
|
+
}
|
|
126
|
+
/** Forget all target-bound entries. */
|
|
127
|
+
clear() {
|
|
128
|
+
this.#entries.clear();
|
|
129
|
+
this.#target = undefined;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { parseAnalysisSnapshot, serializeAnalysisSnapshot, } from "../domain/analysisSnapshot.js";
|
|
2
|
+
import { EvidenceFileError, EvidenceIntegrityError } from "../domain/errors.js";
|
|
3
|
+
import { err, ok } from "../domain/result.js";
|
|
4
|
+
import { readBoundedJson, writeBoundedText } from "./BoundedJsonFiles.js";
|
|
5
|
+
/** Read and validate a bounded analysis snapshot inside an approved root. */
|
|
6
|
+
export const readAnalysisSnapshot = async (path, policy) => {
|
|
7
|
+
const loaded = await readBoundedJson(path, policy);
|
|
8
|
+
if (!loaded.ok)
|
|
9
|
+
return loaded;
|
|
10
|
+
try {
|
|
11
|
+
return ok(parseAnalysisSnapshot(loaded.value));
|
|
12
|
+
}
|
|
13
|
+
catch (cause) {
|
|
14
|
+
return err(new EvidenceIntegrityError("Analysis snapshot validation failed", {
|
|
15
|
+
cause,
|
|
16
|
+
}));
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
/** Atomically write a deterministic analysis snapshot inside an approved root. */
|
|
20
|
+
export const writeAnalysisSnapshot = async (snapshot, path, overwrite, policy) => {
|
|
21
|
+
let encoded;
|
|
22
|
+
try {
|
|
23
|
+
encoded = serializeAnalysisSnapshot(snapshot);
|
|
24
|
+
}
|
|
25
|
+
catch (cause) {
|
|
26
|
+
return err(new EvidenceIntegrityError("Analysis snapshot validation failed", {
|
|
27
|
+
cause,
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
return writeBoundedText(encoded, path, overwrite, policy);
|
|
31
|
+
};
|
|
@@ -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,15 +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);
|
|
33
|
+
return scanCanonicalArtifactInventory(path, limits, signal, nativeMount, integrity);
|
|
34
|
+
};
|
|
35
|
+
/** Scan one already-canonical artifact path without resolving it again. */
|
|
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");
|
|
27
40
|
const metadata = await lstat(path);
|
|
28
41
|
const rootFormat = await classifyRoot(path, metadata.isDirectory());
|
|
29
42
|
const rootDigest = metadata.isDirectory()
|
|
@@ -31,7 +44,7 @@ export const scanArtifactInventory = async (inputPath, limits, signal, nativeMou
|
|
|
31
44
|
: await hashReadable(createReadStream(path), limits.maxTotalBytes, signal);
|
|
32
45
|
const reader = await createReader(path, rootFormat, nativeMount, signal);
|
|
33
46
|
try {
|
|
34
|
-
const { nodes, occurrences } = await scanReader(reader, limits, signal);
|
|
47
|
+
const { nodes, occurrences, pendingContradictions } = await scanReader(reader, limits, signal, integrity);
|
|
35
48
|
materializeDirectoryNodes(occurrences, nodes);
|
|
36
49
|
const rootNode = createRootNode({
|
|
37
50
|
path,
|
|
@@ -47,6 +60,36 @@ export const scanArtifactInventory = async (inputPath, limits, signal, nativeMou
|
|
|
47
60
|
if (occurrence.parent_occurrence_id === null)
|
|
48
61
|
occurrence.parent_occurrence_id = rootOccurrence.occurrence_id;
|
|
49
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
|
+
});
|
|
50
93
|
const edges = createArtifactEdges(rootNode.artifact_id, occurrences, reader?.provenance()[0]);
|
|
51
94
|
const orderedNodes = [...nodes.values()].sort((left, right) => left.artifact_id.localeCompare(right.artifact_id));
|
|
52
95
|
const orderedOccurrences = occurrences.sort((left, right) => left.logical_path.localeCompare(right.logical_path, "en"));
|
|
@@ -61,6 +104,7 @@ export const scanArtifactInventory = async (inputPath, limits, signal, nativeMou
|
|
|
61
104
|
nodes: orderedNodes,
|
|
62
105
|
occurrences: orderedOccurrences,
|
|
63
106
|
edges: orderedEdges,
|
|
107
|
+
integrity_contradictions: integrityContradictions,
|
|
64
108
|
});
|
|
65
109
|
const manifestId = `agm_${digestCanonical({
|
|
66
110
|
schema_version: 1,
|
|
@@ -84,7 +128,15 @@ export const scanArtifactInventory = async (inputPath, limits, signal, nativeMou
|
|
|
84
128
|
edges: orderedEdges,
|
|
85
129
|
limits: toOutputLimits(limits),
|
|
86
130
|
provenance: reader?.provenance() ?? [],
|
|
87
|
-
|
|
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
|
+
],
|
|
88
140
|
};
|
|
89
141
|
}
|
|
90
142
|
finally {
|
|
@@ -145,11 +197,11 @@ const visitNestedAsar = async (adapterKey, logicalPath, visit) => {
|
|
|
145
197
|
const isExpandableAsar = (entry, logicalPath) => entry.kind === "file" &&
|
|
146
198
|
logicalPath.toLowerCase().endsWith(".asar") &&
|
|
147
199
|
entry.adapterKey.startsWith("/");
|
|
148
|
-
const emptyScan = () => ({ nodes: new Map(), occurrences: [] });
|
|
149
|
-
const scanReader = async (reader, limits, signal) => {
|
|
150
|
-
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();
|
|
151
203
|
if (reader === undefined)
|
|
152
|
-
return { nodes, occurrences };
|
|
204
|
+
return { nodes, occurrences, pendingContradictions };
|
|
153
205
|
const occurrenceByPath = new Map();
|
|
154
206
|
const registry = new ArtifactPathRegistry();
|
|
155
207
|
let totalBytes = 0;
|
|
@@ -163,24 +215,44 @@ const scanReader = async (reader, limits, signal) => {
|
|
|
163
215
|
throw new ArtifactReaderFailure("limit", "Declared artifact bytes exceed remaining cumulative limit");
|
|
164
216
|
const digest = await hashReadable(await currentReader.open(entry, signal), Math.min(limits.maxEntryBytes, remainingBytes), signal);
|
|
165
217
|
totalBytes += digest.bytes;
|
|
166
|
-
|
|
218
|
+
const mismatched = entry.declaredSha256 !== null && entry.declaredSha256 !== digest.sha256;
|
|
219
|
+
if (mismatched && integrity.mode === "fail")
|
|
167
220
|
throw new ArtifactReaderFailure("integrity", `Artifact integrity metadata disagrees with content: ${logicalPath}`, undefined, {
|
|
168
221
|
logicalPath,
|
|
169
222
|
declaredSha256: entry.declaredSha256,
|
|
170
223
|
calculatedSha256: digest.sha256,
|
|
171
224
|
unpacked: entry.unpacked,
|
|
172
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
|
+
}
|
|
173
237
|
const classified = entry.kind === "slice"
|
|
174
238
|
? { kind: "universal-slice", format: "mach-o" }
|
|
175
239
|
: classifyArtifactContent(logicalPath, digest.prefix);
|
|
176
|
-
return
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
+
};
|
|
184
256
|
};
|
|
185
257
|
const visit = async (currentReader, prefix) => {
|
|
186
258
|
for await (const entry of currentReader.entries(signal)) {
|
|
@@ -192,20 +264,24 @@ const scanReader = async (reader, limits, signal) => {
|
|
|
192
264
|
preflightEntry(entry, limits);
|
|
193
265
|
const parent = nearestParent(logicalPath, occurrenceByPath);
|
|
194
266
|
const occurrence = createOccurrence(entry, logicalPath, parent?.occurrence_id ?? null);
|
|
195
|
-
const
|
|
196
|
-
if (
|
|
197
|
-
nodes.set(node.artifact_id, node);
|
|
198
|
-
occurrence.artifact_id = node.artifact_id;
|
|
199
|
-
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.");
|
|
200
276
|
}
|
|
201
277
|
occurrences.push(occurrence);
|
|
202
278
|
occurrenceByPath.set(logicalPath, occurrence);
|
|
203
|
-
if (expandableAsar)
|
|
279
|
+
if (expandableAsar && digested?.mismatched !== true)
|
|
204
280
|
await visitNestedAsar(entry.adapterKey, logicalPath, visit);
|
|
205
281
|
}
|
|
206
282
|
};
|
|
207
283
|
await visit(reader, "");
|
|
208
|
-
return { nodes, occurrences };
|
|
284
|
+
return { nodes, occurrences, pendingContradictions };
|
|
209
285
|
};
|
|
210
286
|
const classifyRoot = async (path, directory) => {
|
|
211
287
|
if (directory)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
2
|
+
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
|
3
|
+
import { withinRoot } from "../reference/ReferenceSourceReaderPaths.js";
|
|
4
|
+
import { scanCanonicalArtifactInventory, } from "./ArtifactInventory.js";
|
|
5
|
+
/** Resolve, authorize, and scan one artifact without a second path resolution. */
|
|
6
|
+
export const scanAuthorizedArtifactInventory = async (inputPath, roots, limits, signal, integrity) => {
|
|
7
|
+
if (roots.length === 0)
|
|
8
|
+
throw new ArtifactReaderFailure("path", "Artifact input roots are disabled");
|
|
9
|
+
const [path, approved] = await Promise.all([
|
|
10
|
+
realpath(inputPath),
|
|
11
|
+
Promise.all(roots.map(async (root) => realpath(root))),
|
|
12
|
+
]);
|
|
13
|
+
if (!approved.some((root) => withinRoot(root, path)))
|
|
14
|
+
throw new ArtifactReaderFailure("path", "Artifact path is outside approved input roots");
|
|
15
|
+
return scanCanonicalArtifactInventory(path, limits, signal, undefined, integrity);
|
|
16
|
+
};
|
|
@@ -2,15 +2,15 @@ import { parseBinaryTarget } from "../domain/binaryTarget.js";
|
|
|
2
2
|
import { AnalysisCapabilityUnavailableError, EvidenceIntegrityError, EvidenceLimitError, AnalysisCancelledError, NoBinaryOpenError, } from "../domain/errors.js";
|
|
3
3
|
import { err, ok } from "../domain/result.js";
|
|
4
4
|
import { createEvidence } from "../domain/evidence.js";
|
|
5
|
+
import { evidenceBundleForTarget } from "../domain/evidenceBundle.js";
|
|
5
6
|
import { EvidenceLedger } from "./EvidenceLedger.js";
|
|
6
7
|
import { UnknownRegistryError } from "../domain/errors.js";
|
|
7
8
|
import { enhancedToolNameSchema } from "../contracts/enhancedInputs.js";
|
|
8
9
|
import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/toolContracts.js";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
};
|
|
10
|
+
import { snapshotMatchesTarget } from "../domain/analysisSnapshot.js";
|
|
11
|
+
import { AnalysisSnapshotCache, isSnapshotCacheable, } from "./AnalysisSnapshotCache.js";
|
|
12
|
+
import { UNKNOWN_REGISTRY_PROVIDER, unknownEvidenceLinks, unknownMutationEvidence, } from "./UnknownEvidence.js";
|
|
13
|
+
import { parseInvestigationWorkspace, } from "../domain/investigationWorkspace.js";
|
|
14
14
|
const OFFICIAL_OPERATIONS = new Set(OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name));
|
|
15
15
|
/**
|
|
16
16
|
* Owns the single active target shared by CLI and MCP adapters.
|
|
@@ -32,8 +32,15 @@ export class BinarySession {
|
|
|
32
32
|
maxRecords: 10_000,
|
|
33
33
|
maxBytes: 64 * 1024 * 1024,
|
|
34
34
|
});
|
|
35
|
-
|
|
35
|
+
#snapshot = new AnalysisSnapshotCache();
|
|
36
|
+
#investigationWorkspaces = new Map();
|
|
37
|
+
#runtimeAvailability = new Map();
|
|
38
|
+
#availabilityListeners = new Set();
|
|
39
|
+
#snapshotsEnabled;
|
|
40
|
+
#snapshotInvalidated = false;
|
|
41
|
+
constructor(provider, options = {}) {
|
|
36
42
|
this.provider = provider;
|
|
43
|
+
this.#snapshotsEnabled = options.snapshotsEnabled ?? true;
|
|
37
44
|
this.#createClient =
|
|
38
45
|
typeof provider === "function"
|
|
39
46
|
? provider
|
|
@@ -70,6 +77,11 @@ export class BinarySession {
|
|
|
70
77
|
}
|
|
71
78
|
return this.#providerIdentity;
|
|
72
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
|
+
}
|
|
73
85
|
/** Add one successful public observation to the session ledger. */
|
|
74
86
|
recordEvidence(evidence) {
|
|
75
87
|
return this.#evidence.record(evidence);
|
|
@@ -90,6 +102,41 @@ export class BinarySession {
|
|
|
90
102
|
importEvidenceBundle(bundle) {
|
|
91
103
|
return this.#evidence.import(bundle);
|
|
92
104
|
}
|
|
105
|
+
/** Export immutable cached calls and session evidence for the active target. */
|
|
106
|
+
exportAnalysisSnapshot() {
|
|
107
|
+
if (!this.#snapshotsEnabled)
|
|
108
|
+
return err(new EvidenceIntegrityError("Analysis snapshots are unavailable with custom Hopper loader arguments"));
|
|
109
|
+
if (this.#snapshotInvalidated)
|
|
110
|
+
return err(new EvidenceIntegrityError("Analysis snapshots are unavailable after analysis metadata mutations"));
|
|
111
|
+
const target = this.#active?.target;
|
|
112
|
+
return this.#snapshot.export(target, target === undefined
|
|
113
|
+
? this.#evidence.export()
|
|
114
|
+
: evidenceBundleForTarget(this.#evidence.export(), target.sha256));
|
|
115
|
+
}
|
|
116
|
+
/** Stage validated cached calls for an identical binary and merge its evidence. */
|
|
117
|
+
importAnalysisSnapshot(snapshot) {
|
|
118
|
+
if (!this.#snapshotsEnabled)
|
|
119
|
+
return err(new EvidenceIntegrityError("Analysis snapshots are unavailable with custom Hopper loader arguments"));
|
|
120
|
+
return this.#snapshot.import(snapshot, this.#active?.target, (bundle) => this.#evidence.import(bundle));
|
|
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
|
+
}
|
|
93
140
|
/** Create an approved residual unknown and immutable mutation evidence. */
|
|
94
141
|
recordUnknown(input) {
|
|
95
142
|
const evidence = unknownMutationEvidence(this.#active?.target, input);
|
|
@@ -101,7 +148,7 @@ export class BinarySession {
|
|
|
101
148
|
}
|
|
102
149
|
/** Update one unknown using compare-and-swap revision semantics. */
|
|
103
150
|
updateUnknown(input) {
|
|
104
|
-
const evidence = createEvidence(this.#active?.target,
|
|
151
|
+
const evidence = createEvidence(this.#active?.target, UNKNOWN_REGISTRY_PROVIDER, {
|
|
105
152
|
predicateType: "rea.residual-unknown-mutation/v1",
|
|
106
153
|
operation: "update_unknown",
|
|
107
154
|
parameters: {
|
|
@@ -137,10 +184,21 @@ export class BinarySession {
|
|
|
137
184
|
const parsed = await parseBinaryTarget(path, process.cwd(), process.arch, options.targetKind);
|
|
138
185
|
if (!parsed.ok)
|
|
139
186
|
return parsed;
|
|
187
|
+
if (options.snapshot !== undefined &&
|
|
188
|
+
!snapshotMatchesTarget(options.snapshot.target, parsed.value))
|
|
189
|
+
return err(new EvidenceIntegrityError("Analysis snapshot target does not match the requested binary"));
|
|
190
|
+
if (this.#active === undefined && !this.#snapshot.matches(parsed.value))
|
|
191
|
+
return err(new EvidenceIntegrityError("Analysis snapshot target does not match the requested binary"));
|
|
140
192
|
if (isAborted(options.signal))
|
|
141
193
|
return err(new AnalysisCancelledError("open_binary"));
|
|
142
|
-
if (this.#active?.target.path === parsed.value.path)
|
|
194
|
+
if (this.#active?.target.path === parsed.value.path) {
|
|
195
|
+
if (options.snapshot !== undefined) {
|
|
196
|
+
const imported = this.importAnalysisSnapshot(options.snapshot);
|
|
197
|
+
if (!imported.ok)
|
|
198
|
+
return imported;
|
|
199
|
+
}
|
|
143
200
|
return ok(parsed.value);
|
|
201
|
+
}
|
|
144
202
|
await this.#drainCalls();
|
|
145
203
|
const previous = this.#active;
|
|
146
204
|
this.#active = undefined;
|
|
@@ -158,6 +216,18 @@ export class BinarySession {
|
|
|
158
216
|
return err(new AnalysisCancelledError("open_binary"));
|
|
159
217
|
}
|
|
160
218
|
this.#active = { target: parsed.value, client };
|
|
219
|
+
this.#clearRuntimeAvailability();
|
|
220
|
+
this.#snapshot.select(parsed.value);
|
|
221
|
+
if (options.snapshot !== undefined) {
|
|
222
|
+
const imported = this.importAnalysisSnapshot(options.snapshot);
|
|
223
|
+
if (!imported.ok) {
|
|
224
|
+
this.#active = undefined;
|
|
225
|
+
await client.close();
|
|
226
|
+
await this.#restore(previous);
|
|
227
|
+
return imported;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
this.#snapshotInvalidated = false;
|
|
161
231
|
return ok(parsed.value);
|
|
162
232
|
});
|
|
163
233
|
}
|
|
@@ -169,6 +239,9 @@ export class BinarySession {
|
|
|
169
239
|
await this.#drainCalls();
|
|
170
240
|
await previous?.client.close();
|
|
171
241
|
this.#evidence.clear();
|
|
242
|
+
this.#snapshot.clear();
|
|
243
|
+
this.#snapshotInvalidated = false;
|
|
244
|
+
this.#clearRuntimeAvailability();
|
|
172
245
|
return ok(null);
|
|
173
246
|
});
|
|
174
247
|
}
|
|
@@ -193,6 +266,10 @@ export class BinarySession {
|
|
|
193
266
|
? []
|
|
194
267
|
: [...this.#capabilities.values()]
|
|
195
268
|
.sort((left, right) => left.operation.localeCompare(right.operation))
|
|
269
|
+
.map((descriptor) => ({
|
|
270
|
+
...descriptor,
|
|
271
|
+
...this.#runtimeAvailability.get(descriptor.operation),
|
|
272
|
+
}))
|
|
196
273
|
.map((descriptor) => ({
|
|
197
274
|
operation: descriptor.operation,
|
|
198
275
|
available: descriptor.available,
|
|
@@ -250,15 +327,78 @@ export class BinarySession {
|
|
|
250
327
|
const active = this.#active;
|
|
251
328
|
if (active === undefined)
|
|
252
329
|
return err(new NoBinaryOpenError());
|
|
330
|
+
const cacheable = isSnapshotCacheable(name, capability, arguments_);
|
|
331
|
+
const cached = cacheable
|
|
332
|
+
? this.#snapshot.lookup(active.target, name, arguments_, capability.provider)
|
|
333
|
+
: undefined;
|
|
334
|
+
if (cached !== undefined)
|
|
335
|
+
return ok(cached);
|
|
253
336
|
const call = active.client.execute(name, arguments_, options);
|
|
254
337
|
this.#calls.add(call);
|
|
255
338
|
try {
|
|
256
|
-
|
|
339
|
+
const result = await call;
|
|
340
|
+
this.#observeRuntimeAvailability(name, result);
|
|
341
|
+
if (result.ok && cacheable)
|
|
342
|
+
this.#snapshot.record(active.target, name, arguments_, result.value);
|
|
343
|
+
else if (result.ok && capability?.effects.mutatesArtifact === true) {
|
|
344
|
+
this.#snapshot.clear();
|
|
345
|
+
this.#snapshotInvalidated = true;
|
|
346
|
+
}
|
|
347
|
+
return result;
|
|
257
348
|
}
|
|
258
349
|
finally {
|
|
259
350
|
this.#calls.delete(call);
|
|
260
351
|
}
|
|
261
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
|
+
}
|
|
262
402
|
#serialize(operation) {
|
|
263
403
|
const result = this.#transition.then(operation, operation);
|
|
264
404
|
this.#transition = result.then(() => undefined, () => undefined);
|
|
@@ -303,31 +443,4 @@ export class BinarySession {
|
|
|
303
443
|
});
|
|
304
444
|
}
|
|
305
445
|
}
|
|
306
|
-
const unknownMutationEvidence = (target, input) => createEvidence(target, REGISTRY_PROVIDER, {
|
|
307
|
-
predicateType: "rea.residual-unknown-mutation/v1",
|
|
308
|
-
operation: "record_unknown",
|
|
309
|
-
parameters: {
|
|
310
|
-
domain: input.domain,
|
|
311
|
-
severity: input.severity,
|
|
312
|
-
},
|
|
313
|
-
result: {
|
|
314
|
-
action: "record",
|
|
315
|
-
question: input.question,
|
|
316
|
-
required_authority: input.required_authority,
|
|
317
|
-
required_confidence: input.required_confidence,
|
|
318
|
-
},
|
|
319
|
-
confidence: "derived",
|
|
320
|
-
authority: "analyst-inference",
|
|
321
|
-
evidenceLinks: unknownEvidenceLinks(input),
|
|
322
|
-
limitations: [
|
|
323
|
-
"Registry mutation evidence records analyst intent, not proof of the answer.",
|
|
324
|
-
],
|
|
325
|
-
});
|
|
326
|
-
const unknownEvidenceLinks = (input) => [
|
|
327
|
-
...input.supporting_evidence_ids,
|
|
328
|
-
...input.contradicting_evidence_ids,
|
|
329
|
-
...("resolution" in input && input.resolution !== null
|
|
330
|
-
? input.resolution.evidence_ids
|
|
331
|
-
: []),
|
|
332
|
-
].filter((id, index, values) => values.indexOf(id) === index);
|
|
333
446
|
const isAborted = (signal) => signal?.aborted === true;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|