rea-agents 1.1.0 → 1.3.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 +94 -28
- package/bridge/hopper_bridge.py +161 -12
- package/dist/application/AnalysisSnapshotCache.js +131 -0
- package/dist/application/AnalysisSnapshotFiles.js +31 -0
- package/dist/application/ArtifactInventory.js +84 -44
- package/dist/application/AuthorizedArtifactInventory.js +16 -0
- package/dist/application/BinarySession.js +66 -36
- package/dist/application/BinarySessionPort.js +1 -0
- package/dist/application/BoundedJsonFiles.js +109 -0
- package/dist/application/CommandShimReplay.js +136 -0
- package/dist/application/CrossVersionInventory.js +98 -0
- package/dist/application/CrossVersionInvestigation.js +295 -0
- package/dist/application/DirectAnalysis.js +115 -22
- package/dist/application/Doctor.js +30 -1
- package/dist/application/EvidenceBundleFiles.js +16 -101
- 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/ProcessCaptureAuthority.js +25 -0
- package/dist/application/ProcessCaptureCapability.js +23 -0
- package/dist/application/ProcessCaptureError.js +17 -0
- package/dist/application/ProcessCaptureLifecycle.js +287 -0
- package/dist/application/ProcessCheckpoints.js +129 -0
- package/dist/application/ProcessCli.js +128 -0
- package/dist/application/ProcessEvidence.js +38 -0
- package/dist/application/ProcessHarness.js +252 -272
- package/dist/application/ProcessNormalization.js +10 -1
- package/dist/application/ProcessOwnership.js +39 -1
- package/dist/application/ProcessSampling.js +39 -23
- package/dist/application/ReferenceSourceImportEntries.js +18 -3
- package/dist/application/ReferenceSourceImportTypes.js +32 -0
- package/dist/application/Setup.js +44 -96
- package/dist/application/SetupClients.js +19 -0
- package/dist/application/SetupInstallFailure.js +53 -0
- package/dist/application/SetupPlan.js +31 -0
- package/dist/application/SetupSkill.js +44 -0
- package/dist/application/TerminalRenderer.js +92 -0
- package/dist/application/Uninstall.js +33 -23
- package/dist/application/UnknownEvidence.js +33 -0
- package/dist/application/Upgrade.js +1 -1
- package/dist/application/runtime.js +2 -2
- package/dist/artifacts/ArtifactProvider.js +20 -11
- package/dist/artifacts/ArtifactReader.js +3 -1
- package/dist/artifacts/AsarArtifactReader.js +8 -1
- package/dist/artifacts/DirectoryArtifactReader.js +1 -0
- package/dist/artifacts/MachOSliceArtifactReader.js +1 -0
- package/dist/artifacts/NativeDmgArtifactReader.js +151 -0
- package/dist/artifacts/ZipArtifactReader.js +1 -0
- package/dist/cli.js +57 -32
- package/dist/cliEvidenceCommands.js +11 -12
- package/dist/cliInvestigationCommands.js +86 -0
- package/dist/cliOutput.js +41 -0
- package/dist/cliProcessCommands.js +29 -0
- package/dist/config.js +41 -24
- package/dist/contracts/artifactToolContracts.js +1 -0
- package/dist/contracts/investigationExamples.js +3 -3
- package/dist/contracts/processCaptureExample.js +50 -7
- 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 +13 -8
- package/dist/contracts/toolOutputSchemas.js +33 -4
- package/dist/domain/analysisSnapshot.js +149 -0
- package/dist/domain/changedBehavior.js +32 -9
- package/dist/domain/errors.js +186 -39
- 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 +214 -0
- package/dist/domain/jsonValue.js +2 -0
- package/dist/domain/nativeInspection.js +3 -2
- package/dist/domain/processCapture.js +100 -293
- package/dist/domain/processCaptureValidation.js +124 -0
- package/dist/domain/processComparison.js +177 -32
- package/dist/domain/processScenario.js +370 -0
- package/dist/domain/reconstructionVerification.js +3 -3
- package/dist/domain/staticRuntimeCorrelation.js +3 -2
- package/dist/hopper/BridgeLauncher.js +47 -20
- package/dist/hopper/HopperClient.js +17 -2
- package/dist/hopper/HopperProvider.js +1 -0
- package/dist/identity.js +1 -0
- package/dist/main.js +47 -24
- package/dist/server/createServer.js +2 -0
- package/dist/server/promptCompletion.js +144 -0
- package/dist/server/registerEnhancedTools.js +2 -6
- package/dist/server/registerEvidenceTools.js +2 -8
- package/dist/server/registerFunctionComparisonTool.js +1 -1
- package/dist/server/registerInvestigationTools.js +31 -11
- package/dist/server/registerOfficialTools.js +6 -13
- package/dist/server/registerProcessComparisonTool.js +28 -14
- package/dist/server/registerPrompts.js +67 -0
- package/dist/server/registerSessionStatusTool.js +11 -0
- package/dist/server/registerSessionTools.js +55 -43
- package/dist/server/sessionEvidence.js +2 -8
- package/dist/server/sessionToolPolicies.js +2 -48
- package/dist/server/toolResult.js +7 -3
- package/install.sh +11 -11
- package/package.json +9 -3
- package/scripts/hopper-demo-x11.py +349 -0
- package/scripts/prepare-node-pty.mjs +35 -0
- package/scripts/rea.mjs +6 -3
- package/skills/rea-analysis/SKILL.md +27 -5
|
@@ -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
|
+
};
|
|
@@ -7,24 +7,33 @@ import { ArtifactReaderFailure, } from "../artifacts/ArtifactReader.js";
|
|
|
7
7
|
import { DirectoryArtifactReader } from "../artifacts/DirectoryArtifactReader.js";
|
|
8
8
|
import { ZipArtifactReader } from "../artifacts/ZipArtifactReader.js";
|
|
9
9
|
import { MachOSliceArtifactReader } from "../artifacts/MachOSliceArtifactReader.js";
|
|
10
|
+
import { NativeDmgArtifactReader } from "../artifacts/NativeDmgArtifactReader.js";
|
|
10
11
|
import { streamChunkToBuffer } from "../artifacts/StreamBytes.js";
|
|
11
12
|
import { artifactInventoryResultSchema, } from "../domain/artifactGraph.js";
|
|
12
13
|
import { classifyArtifactPath, classifyArtifactContent, createArtifactEdges, createArtifactNode, createOccurrence, createRootNode, digestCanonical, materializeDirectoryNodes, nearestParent, pageOf, rekeyOccurrences, rootOccurrenceFor, toOutputLimits, } from "./ArtifactGraphConstruction.js";
|
|
14
|
+
const NATIVE_MOUNT_DISABLED = {
|
|
15
|
+
nativeMountApproved: false,
|
|
16
|
+
nativeMountEnabled: false,
|
|
17
|
+
};
|
|
13
18
|
/** Inventory one local artifact without extracting or mounting it. */
|
|
14
|
-
export const inventoryArtifact = async (inputPath, limits, page,
|
|
15
|
-
const snapshot = await scanArtifactInventory(inputPath, limits, signal);
|
|
19
|
+
export const inventoryArtifact = async (inputPath, limits, page, options = {}) => {
|
|
20
|
+
const snapshot = await scanArtifactInventory(inputPath, limits, options.signal, options.nativeMount ?? NATIVE_MOUNT_DISABLED);
|
|
16
21
|
return paginateArtifactInventory(snapshot, page);
|
|
17
22
|
};
|
|
18
23
|
/** Scan an artifact once and retain the complete immutable graph for projection. */
|
|
19
|
-
export const scanArtifactInventory = async (inputPath, limits, signal) => {
|
|
24
|
+
export const scanArtifactInventory = async (inputPath, limits, signal, nativeMount = NATIVE_MOUNT_DISABLED) => {
|
|
20
25
|
abortIfNeeded(signal);
|
|
21
26
|
const path = await realpath(inputPath);
|
|
27
|
+
return scanCanonicalArtifactInventory(path, limits, signal, nativeMount);
|
|
28
|
+
};
|
|
29
|
+
/** Scan one already-canonical artifact path without resolving it again. */
|
|
30
|
+
export const scanCanonicalArtifactInventory = async (path, limits, signal, nativeMount = NATIVE_MOUNT_DISABLED) => {
|
|
22
31
|
const metadata = await lstat(path);
|
|
23
32
|
const rootFormat = await classifyRoot(path, metadata.isDirectory());
|
|
24
33
|
const rootDigest = metadata.isDirectory()
|
|
25
34
|
? null
|
|
26
35
|
: await hashReadable(createReadStream(path), limits.maxTotalBytes, signal);
|
|
27
|
-
const reader = await createReader(path, rootFormat);
|
|
36
|
+
const reader = await createReader(path, rootFormat, nativeMount, signal);
|
|
28
37
|
try {
|
|
29
38
|
const { nodes, occurrences } = await scanReader(reader, limits, signal);
|
|
30
39
|
materializeDirectoryNodes(occurrences, nodes);
|
|
@@ -104,7 +113,7 @@ const inventoryLimitations = (format, reader) => {
|
|
|
104
113
|
return ["Universal Mach-O slices require the native macOS lipo adapter."];
|
|
105
114
|
return ["Artifact has no child container entries."];
|
|
106
115
|
};
|
|
107
|
-
const createReader = async (path, format) => {
|
|
116
|
+
const createReader = async (path, format, nativeMount, signal) => {
|
|
108
117
|
switch (format) {
|
|
109
118
|
case "directory":
|
|
110
119
|
return new DirectoryArtifactReader(path);
|
|
@@ -118,57 +127,88 @@ const createReader = async (path, format) => {
|
|
|
118
127
|
return process.platform === "darwin"
|
|
119
128
|
? new MachOSliceArtifactReader(path)
|
|
120
129
|
: undefined;
|
|
130
|
+
case "dmg":
|
|
131
|
+
if (!nativeMount.nativeMountApproved)
|
|
132
|
+
return undefined;
|
|
133
|
+
if (!nativeMount.nativeMountEnabled)
|
|
134
|
+
throw new ArtifactReaderFailure("unavailable", "Native DMG mounting is disabled by operator policy");
|
|
135
|
+
return NativeDmgArtifactReader.create(path, signal);
|
|
121
136
|
default:
|
|
122
137
|
return undefined;
|
|
123
138
|
}
|
|
124
139
|
};
|
|
140
|
+
const visitNestedAsar = async (adapterKey, logicalPath, visit) => {
|
|
141
|
+
const nested = new AsarArtifactReader(adapterKey);
|
|
142
|
+
try {
|
|
143
|
+
await visit(nested, logicalPath);
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
await nested.close();
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
const isExpandableAsar = (entry, logicalPath) => entry.kind === "file" &&
|
|
150
|
+
logicalPath.toLowerCase().endsWith(".asar") &&
|
|
151
|
+
entry.adapterKey.startsWith("/");
|
|
152
|
+
const emptyScan = () => ({ nodes: new Map(), occurrences: [] });
|
|
125
153
|
const scanReader = async (reader, limits, signal) => {
|
|
126
|
-
const nodes =
|
|
127
|
-
const occurrences = [];
|
|
154
|
+
const { nodes, occurrences } = emptyScan();
|
|
128
155
|
if (reader === undefined)
|
|
129
156
|
return { nodes, occurrences };
|
|
130
157
|
const occurrenceByPath = new Map();
|
|
131
158
|
const registry = new ArtifactPathRegistry();
|
|
132
159
|
let totalBytes = 0;
|
|
133
|
-
|
|
134
|
-
if (
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (entry.declaredSha256 !== null &&
|
|
150
|
-
entry.declaredSha256 !== digest.sha256)
|
|
151
|
-
throw new ArtifactReaderFailure("integrity", `Artifact integrity metadata disagrees with content: ${logicalPath}`);
|
|
152
|
-
if (totalBytes > limits.maxTotalBytes)
|
|
153
|
-
throw new ArtifactReaderFailure("limit", "Artifact total byte limit exceeded");
|
|
154
|
-
const classified = entry.kind === "slice"
|
|
155
|
-
? { kind: "universal-slice", format: "mach-o" }
|
|
156
|
-
: classifyArtifactContent(logicalPath, digest.prefix);
|
|
157
|
-
const node = createArtifactNode({
|
|
158
|
-
sha256: digest.sha256,
|
|
159
|
-
size: digest.bytes,
|
|
160
|
-
kind: classified.kind,
|
|
161
|
-
format: classified.format,
|
|
162
|
-
executable: entry.executable,
|
|
163
|
-
contentState: "embedded",
|
|
160
|
+
const digestEntry = async (currentReader, entry, logicalPath) => {
|
|
161
|
+
if ((entry.kind !== "file" && entry.kind !== "slice") || entry.encrypted)
|
|
162
|
+
return undefined;
|
|
163
|
+
const remainingBytes = limits.maxTotalBytes - totalBytes;
|
|
164
|
+
if (remainingBytes <= 0)
|
|
165
|
+
throw new ArtifactReaderFailure("limit", "Artifact total byte limit exceeded");
|
|
166
|
+
if (entry.declaredSize !== null && entry.declaredSize > remainingBytes)
|
|
167
|
+
throw new ArtifactReaderFailure("limit", "Declared artifact bytes exceed remaining cumulative limit");
|
|
168
|
+
const digest = await hashReadable(await currentReader.open(entry, signal), Math.min(limits.maxEntryBytes, remainingBytes), signal);
|
|
169
|
+
totalBytes += digest.bytes;
|
|
170
|
+
if (entry.declaredSha256 !== null && entry.declaredSha256 !== digest.sha256)
|
|
171
|
+
throw new ArtifactReaderFailure("integrity", `Artifact integrity metadata disagrees with content: ${logicalPath}`, undefined, {
|
|
172
|
+
logicalPath,
|
|
173
|
+
declaredSha256: entry.declaredSha256,
|
|
174
|
+
calculatedSha256: digest.sha256,
|
|
175
|
+
unpacked: entry.unpacked,
|
|
164
176
|
});
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
177
|
+
const classified = entry.kind === "slice"
|
|
178
|
+
? { kind: "universal-slice", format: "mach-o" }
|
|
179
|
+
: 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
|
+
});
|
|
188
|
+
};
|
|
189
|
+
const visit = async (currentReader, prefix) => {
|
|
190
|
+
for await (const entry of currentReader.entries(signal)) {
|
|
191
|
+
if (occurrences.length >= limits.maxEntries)
|
|
192
|
+
throw new ArtifactReaderFailure("limit", "Artifact entry limit exceeded");
|
|
193
|
+
const logicalPath = normalizeArtifactPath(prefix.length === 0 ? entry.path : `${prefix}/${entry.path}`, limits);
|
|
194
|
+
const expandableAsar = isExpandableAsar(entry, logicalPath);
|
|
195
|
+
registry.add(logicalPath, expandableAsar ? "directory" : entry.kind);
|
|
196
|
+
preflightEntry(entry, limits);
|
|
197
|
+
const parent = nearestParent(logicalPath, occurrenceByPath);
|
|
198
|
+
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";
|
|
204
|
+
}
|
|
205
|
+
occurrences.push(occurrence);
|
|
206
|
+
occurrenceByPath.set(logicalPath, occurrence);
|
|
207
|
+
if (expandableAsar)
|
|
208
|
+
await visitNestedAsar(entry.adapterKey, logicalPath, visit);
|
|
168
209
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
210
|
+
};
|
|
211
|
+
await visit(reader, "");
|
|
172
212
|
return { nodes, occurrences };
|
|
173
213
|
};
|
|
174
214
|
const classifyRoot = async (path, 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) => {
|
|
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);
|
|
16
|
+
};
|
|
@@ -2,15 +2,14 @@ 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
|
-
version: "1",
|
|
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";
|
|
14
13
|
const OFFICIAL_OPERATIONS = new Set(OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name));
|
|
15
14
|
/**
|
|
16
15
|
* Owns the single active target shared by CLI and MCP adapters.
|
|
@@ -32,8 +31,12 @@ export class BinarySession {
|
|
|
32
31
|
maxRecords: 10_000,
|
|
33
32
|
maxBytes: 64 * 1024 * 1024,
|
|
34
33
|
});
|
|
35
|
-
|
|
34
|
+
#snapshot = new AnalysisSnapshotCache();
|
|
35
|
+
#snapshotsEnabled;
|
|
36
|
+
#snapshotInvalidated = false;
|
|
37
|
+
constructor(provider, options = {}) {
|
|
36
38
|
this.provider = provider;
|
|
39
|
+
this.#snapshotsEnabled = options.snapshotsEnabled ?? true;
|
|
37
40
|
this.#createClient =
|
|
38
41
|
typeof provider === "function"
|
|
39
42
|
? provider
|
|
@@ -90,6 +93,23 @@ export class BinarySession {
|
|
|
90
93
|
importEvidenceBundle(bundle) {
|
|
91
94
|
return this.#evidence.import(bundle);
|
|
92
95
|
}
|
|
96
|
+
/** Export immutable cached calls and session evidence for the active target. */
|
|
97
|
+
exportAnalysisSnapshot() {
|
|
98
|
+
if (!this.#snapshotsEnabled)
|
|
99
|
+
return err(new EvidenceIntegrityError("Analysis snapshots are unavailable with custom Hopper loader arguments"));
|
|
100
|
+
if (this.#snapshotInvalidated)
|
|
101
|
+
return err(new EvidenceIntegrityError("Analysis snapshots are unavailable after analysis metadata mutations"));
|
|
102
|
+
const target = this.#active?.target;
|
|
103
|
+
return this.#snapshot.export(target, target === undefined
|
|
104
|
+
? this.#evidence.export()
|
|
105
|
+
: evidenceBundleForTarget(this.#evidence.export(), target.sha256));
|
|
106
|
+
}
|
|
107
|
+
/** Stage validated cached calls for an identical binary and merge its evidence. */
|
|
108
|
+
importAnalysisSnapshot(snapshot) {
|
|
109
|
+
if (!this.#snapshotsEnabled)
|
|
110
|
+
return err(new EvidenceIntegrityError("Analysis snapshots are unavailable with custom Hopper loader arguments"));
|
|
111
|
+
return this.#snapshot.import(snapshot, this.#active?.target, (bundle) => this.#evidence.import(bundle));
|
|
112
|
+
}
|
|
93
113
|
/** Create an approved residual unknown and immutable mutation evidence. */
|
|
94
114
|
recordUnknown(input) {
|
|
95
115
|
const evidence = unknownMutationEvidence(this.#active?.target, input);
|
|
@@ -101,7 +121,7 @@ export class BinarySession {
|
|
|
101
121
|
}
|
|
102
122
|
/** Update one unknown using compare-and-swap revision semantics. */
|
|
103
123
|
updateUnknown(input) {
|
|
104
|
-
const evidence = createEvidence(this.#active?.target,
|
|
124
|
+
const evidence = createEvidence(this.#active?.target, UNKNOWN_REGISTRY_PROVIDER, {
|
|
105
125
|
predicateType: "rea.residual-unknown-mutation/v1",
|
|
106
126
|
operation: "update_unknown",
|
|
107
127
|
parameters: {
|
|
@@ -137,10 +157,21 @@ export class BinarySession {
|
|
|
137
157
|
const parsed = await parseBinaryTarget(path, process.cwd(), process.arch, options.targetKind);
|
|
138
158
|
if (!parsed.ok)
|
|
139
159
|
return parsed;
|
|
160
|
+
if (options.snapshot !== undefined &&
|
|
161
|
+
!snapshotMatchesTarget(options.snapshot.target, parsed.value))
|
|
162
|
+
return err(new EvidenceIntegrityError("Analysis snapshot target does not match the requested binary"));
|
|
163
|
+
if (this.#active === undefined && !this.#snapshot.matches(parsed.value))
|
|
164
|
+
return err(new EvidenceIntegrityError("Analysis snapshot target does not match the requested binary"));
|
|
140
165
|
if (isAborted(options.signal))
|
|
141
166
|
return err(new AnalysisCancelledError("open_binary"));
|
|
142
|
-
if (this.#active?.target.path === parsed.value.path)
|
|
167
|
+
if (this.#active?.target.path === parsed.value.path) {
|
|
168
|
+
if (options.snapshot !== undefined) {
|
|
169
|
+
const imported = this.importAnalysisSnapshot(options.snapshot);
|
|
170
|
+
if (!imported.ok)
|
|
171
|
+
return imported;
|
|
172
|
+
}
|
|
143
173
|
return ok(parsed.value);
|
|
174
|
+
}
|
|
144
175
|
await this.#drainCalls();
|
|
145
176
|
const previous = this.#active;
|
|
146
177
|
this.#active = undefined;
|
|
@@ -158,6 +189,17 @@ export class BinarySession {
|
|
|
158
189
|
return err(new AnalysisCancelledError("open_binary"));
|
|
159
190
|
}
|
|
160
191
|
this.#active = { target: parsed.value, client };
|
|
192
|
+
this.#snapshot.select(parsed.value);
|
|
193
|
+
if (options.snapshot !== undefined) {
|
|
194
|
+
const imported = this.importAnalysisSnapshot(options.snapshot);
|
|
195
|
+
if (!imported.ok) {
|
|
196
|
+
this.#active = undefined;
|
|
197
|
+
await client.close();
|
|
198
|
+
await this.#restore(previous);
|
|
199
|
+
return imported;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
this.#snapshotInvalidated = false;
|
|
161
203
|
return ok(parsed.value);
|
|
162
204
|
});
|
|
163
205
|
}
|
|
@@ -169,6 +211,8 @@ export class BinarySession {
|
|
|
169
211
|
await this.#drainCalls();
|
|
170
212
|
await previous?.client.close();
|
|
171
213
|
this.#evidence.clear();
|
|
214
|
+
this.#snapshot.clear();
|
|
215
|
+
this.#snapshotInvalidated = false;
|
|
172
216
|
return ok(null);
|
|
173
217
|
});
|
|
174
218
|
}
|
|
@@ -250,10 +294,23 @@ export class BinarySession {
|
|
|
250
294
|
const active = this.#active;
|
|
251
295
|
if (active === undefined)
|
|
252
296
|
return err(new NoBinaryOpenError());
|
|
297
|
+
const cacheable = isSnapshotCacheable(name, capability, arguments_);
|
|
298
|
+
const cached = cacheable
|
|
299
|
+
? this.#snapshot.lookup(active.target, name, arguments_, capability.provider)
|
|
300
|
+
: undefined;
|
|
301
|
+
if (cached !== undefined)
|
|
302
|
+
return ok(cached);
|
|
253
303
|
const call = active.client.execute(name, arguments_, options);
|
|
254
304
|
this.#calls.add(call);
|
|
255
305
|
try {
|
|
256
|
-
|
|
306
|
+
const result = await call;
|
|
307
|
+
if (result.ok && cacheable)
|
|
308
|
+
this.#snapshot.record(active.target, name, arguments_, result.value);
|
|
309
|
+
else if (result.ok && capability?.effects.mutatesArtifact === true) {
|
|
310
|
+
this.#snapshot.clear();
|
|
311
|
+
this.#snapshotInvalidated = true;
|
|
312
|
+
}
|
|
313
|
+
return result;
|
|
257
314
|
}
|
|
258
315
|
finally {
|
|
259
316
|
this.#calls.delete(call);
|
|
@@ -303,31 +360,4 @@ export class BinarySession {
|
|
|
303
360
|
});
|
|
304
361
|
}
|
|
305
362
|
}
|
|
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
363
|
const isAborted = (signal) => signal?.aborted === true;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { lstat, readFile, realpath } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
|
|
3
|
+
import writeFileAtomic from "write-file-atomic";
|
|
4
|
+
import { EvidenceFileError } from "../domain/errors.js";
|
|
5
|
+
import { err, ok } from "../domain/result.js";
|
|
6
|
+
/** Read bounded JSON data from a regular file beneath an approved root. */
|
|
7
|
+
export const readBoundedJson = async (path, policy) => {
|
|
8
|
+
if (policy.roots.length === 0)
|
|
9
|
+
return err(new EvidenceFileError("read", "disabled"));
|
|
10
|
+
try {
|
|
11
|
+
const canonicalPath = await realpath(resolve(path));
|
|
12
|
+
if (!(await isApproved(canonicalPath, policy.roots)))
|
|
13
|
+
return err(new EvidenceFileError("read", "outside-root"));
|
|
14
|
+
const stats = await lstat(canonicalPath);
|
|
15
|
+
if (!stats.isFile())
|
|
16
|
+
return err(new EvidenceFileError("read", "not-file"));
|
|
17
|
+
if (stats.size > policy.maxBytes)
|
|
18
|
+
return err(new EvidenceFileError("read", "too-large"));
|
|
19
|
+
const encoded = await readFile(canonicalPath);
|
|
20
|
+
if (encoded.byteLength > policy.maxBytes)
|
|
21
|
+
return err(new EvidenceFileError("read", "too-large"));
|
|
22
|
+
let decoded;
|
|
23
|
+
try {
|
|
24
|
+
decoded = JSON.parse(encoded.toString("utf8"));
|
|
25
|
+
}
|
|
26
|
+
catch (cause) {
|
|
27
|
+
return err(new EvidenceFileError("read", "invalid-json", { cause }));
|
|
28
|
+
}
|
|
29
|
+
const limits = validateJsonLimits(decoded, policy);
|
|
30
|
+
return limits.ok ? ok(decoded) : limits;
|
|
31
|
+
}
|
|
32
|
+
catch (cause) {
|
|
33
|
+
return err(new EvidenceFileError("read", "io", { cause }));
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
/** Atomically write bounded text to a regular file beneath an approved root. */
|
|
37
|
+
export const writeBoundedText = async (encoded, path, overwrite, policy) => {
|
|
38
|
+
if (policy.roots.length === 0)
|
|
39
|
+
return err(new EvidenceFileError("write", "disabled"));
|
|
40
|
+
const bytes = Buffer.byteLength(encoded, "utf8");
|
|
41
|
+
if (bytes > policy.maxBytes)
|
|
42
|
+
return err(new EvidenceFileError("write", "too-large"));
|
|
43
|
+
try {
|
|
44
|
+
const requestedPath = resolve(path);
|
|
45
|
+
const canonicalParent = await realpath(dirname(requestedPath));
|
|
46
|
+
const destination = resolve(canonicalParent, basename(requestedPath));
|
|
47
|
+
if (!(await isApproved(destination, policy.roots)))
|
|
48
|
+
return err(new EvidenceFileError("write", "outside-root"));
|
|
49
|
+
const existing = await lstat(destination).catch((cause) => {
|
|
50
|
+
if (isFileNotFound(cause))
|
|
51
|
+
return undefined;
|
|
52
|
+
throw cause;
|
|
53
|
+
});
|
|
54
|
+
if (existing !== undefined) {
|
|
55
|
+
if (!overwrite)
|
|
56
|
+
return err(new EvidenceFileError("write", "exists"));
|
|
57
|
+
if (!existing.isFile() || existing.isSymbolicLink())
|
|
58
|
+
return err(new EvidenceFileError("write", "not-file"));
|
|
59
|
+
}
|
|
60
|
+
await writeFileAtomic(destination, encoded, {
|
|
61
|
+
encoding: "utf8",
|
|
62
|
+
mode: 0o600,
|
|
63
|
+
fsync: true,
|
|
64
|
+
});
|
|
65
|
+
return ok({ path: requestedPath, bytes });
|
|
66
|
+
}
|
|
67
|
+
catch (cause) {
|
|
68
|
+
return err(new EvidenceFileError("write", "io", { cause }));
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const isApproved = async (candidate, roots) => {
|
|
72
|
+
for (const configuredRoot of roots) {
|
|
73
|
+
const root = await realpath(resolve(configuredRoot));
|
|
74
|
+
const relation = relative(root, candidate);
|
|
75
|
+
if (relation === "" ||
|
|
76
|
+
(!relation.startsWith("..") && !isAbsolute(relation)))
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
};
|
|
81
|
+
const validateJsonLimits = (root, policy) => {
|
|
82
|
+
const pending = [
|
|
83
|
+
{ value: root, depth: 0 },
|
|
84
|
+
];
|
|
85
|
+
let nodes = 0;
|
|
86
|
+
while (pending.length > 0) {
|
|
87
|
+
const current = pending.pop();
|
|
88
|
+
if (current === undefined)
|
|
89
|
+
break;
|
|
90
|
+
nodes += 1;
|
|
91
|
+
if (nodes > policy.maxNodes || current.depth > policy.maxDepth)
|
|
92
|
+
return err(new EvidenceFileError("read", "too-large"));
|
|
93
|
+
if (typeof current.value === "string" &&
|
|
94
|
+
current.value.length > policy.maxStringLength)
|
|
95
|
+
return err(new EvidenceFileError("read", "too-large"));
|
|
96
|
+
if (typeof current.value !== "object" || current.value === null)
|
|
97
|
+
continue;
|
|
98
|
+
for (const [key, value] of Object.entries(current.value)) {
|
|
99
|
+
if (key.length > policy.maxStringLength)
|
|
100
|
+
return err(new EvidenceFileError("read", "too-large"));
|
|
101
|
+
pending.push({ value, depth: current.depth + 1 });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return ok(null);
|
|
105
|
+
};
|
|
106
|
+
const isFileNotFound = (cause) => typeof cause === "object" &&
|
|
107
|
+
cause !== null &&
|
|
108
|
+
"code" in cause &&
|
|
109
|
+
cause.code === "ENOENT";
|