rea-agents 1.2.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 +74 -27
- 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 +4 -0
- 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/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 +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/ProcessCaptureAuthority.js +2 -2
- package/dist/application/ProcessCaptureError.js +12 -0
- package/dist/application/ProcessCaptureLifecycle.js +19 -1
- package/dist/application/ProcessCli.js +98 -31
- package/dist/application/ProcessHarness.js +2 -2
- package/dist/application/ReferenceSourceImportEntries.js +18 -3
- package/dist/application/ReferenceSourceImportTypes.js +32 -0
- package/dist/application/Setup.js +33 -60
- package/dist/application/SetupClients.js +19 -0
- package/dist/application/SetupInstallFailure.js +53 -0
- package/dist/application/SetupPlan.js +31 -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 +1 -1
- package/dist/artifacts/ArtifactProvider.js +2 -5
- package/dist/cli.js +31 -11
- package/dist/cliEvidenceCommands.js +11 -12
- package/dist/cliInvestigationCommands.js +86 -0
- package/dist/cliOutput.js +41 -0
- package/dist/cliProcessCommands.js +14 -4
- package/dist/config.js +39 -24
- 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 +10 -6
- package/dist/contracts/toolOutputSchemas.js +33 -4
- package/dist/domain/analysisSnapshot.js +149 -0
- package/dist/domain/changedBehavior.js +30 -7
- package/dist/domain/errors.js +180 -41
- 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 +5 -4
- package/dist/domain/processComparison.js +5 -4
- package/dist/hopper/BridgeLauncher.js +47 -20
- package/dist/hopper/HopperClient.js +17 -2
- package/dist/hopper/HopperProvider.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 +1 -7
- package/dist/server/registerPrompts.js +67 -0
- package/dist/server/registerSessionStatusTool.js +11 -0
- package/dist/server/registerSessionTools.js +52 -18
- package/dist/server/sessionEvidence.js +2 -8
- package/dist/server/sessionToolPolicies.js +1 -42
- package/dist/server/toolResult.js +7 -3
- package/install.sh +11 -11
- package/package.json +5 -3
- 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,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
|
+
};
|
|
@@ -24,6 +24,10 @@ export const inventoryArtifact = async (inputPath, limits, page, options = {}) =
|
|
|
24
24
|
export const scanArtifactInventory = async (inputPath, limits, signal, nativeMount = NATIVE_MOUNT_DISABLED) => {
|
|
25
25
|
abortIfNeeded(signal);
|
|
26
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) => {
|
|
27
31
|
const metadata = await lstat(path);
|
|
28
32
|
const rootFormat = await classifyRoot(path, metadata.isDirectory());
|
|
29
33
|
const rootDigest = metadata.isDirectory()
|
|
@@ -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";
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
|
2
|
+
import { createEvidence } from "../domain/evidence.js";
|
|
3
|
+
import { AnalysisCancelledError, ArtifactOperationError, } from "../domain/errors.js";
|
|
4
|
+
import { jsonValueSchema } from "../domain/jsonValue.js";
|
|
5
|
+
import { err, ok } from "../domain/result.js";
|
|
6
|
+
import { paginateArtifactInventory, } from "./ArtifactInventory.js";
|
|
7
|
+
import { scanAuthorizedArtifactInventory } from "./AuthorizedArtifactInventory.js";
|
|
8
|
+
import { ARTIFACT_GRAPH_PROVIDER } from "./InvestigationProviders.js";
|
|
9
|
+
export const AUTOMATIC_RUN_LIMITATION = "Automatic cross-version runs compare shipped artifact structure only; they do not execute either version.";
|
|
10
|
+
/** Authorize and scan both inputs once under the same bounded traversal policy. */
|
|
11
|
+
export const scanVersions = async (input, inputRoots, signal) => {
|
|
12
|
+
try {
|
|
13
|
+
if (signal?.aborted === true)
|
|
14
|
+
return err(new AnalysisCancelledError("find_changed_behavior"));
|
|
15
|
+
const limits = artifactLimits(input.options);
|
|
16
|
+
const left = await scanAuthorizedArtifactInventory(input.left_path, inputRoots, limits, signal);
|
|
17
|
+
const right = await scanAuthorizedArtifactInventory(input.right_path, inputRoots, limits, signal);
|
|
18
|
+
return ok({ left, right });
|
|
19
|
+
}
|
|
20
|
+
catch (cause) {
|
|
21
|
+
if (cause instanceof ArtifactReaderFailure)
|
|
22
|
+
return err(new ArtifactOperationError("inventory_artifact", cause.reason, cause.details));
|
|
23
|
+
return err(new ArtifactOperationError("inventory_artifact", "io"));
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
/** Project complete snapshots into bounded, comparable Evidence page sets. */
|
|
27
|
+
export const createInventoryEvidencePages = (input, snapshots) => {
|
|
28
|
+
const left = inventoryPages(input.left_path, snapshots.left, input.options);
|
|
29
|
+
const right = inventoryPages(input.right_path, snapshots.right, input.options);
|
|
30
|
+
if (left.length > 100 || right.length > 100)
|
|
31
|
+
return err(new ArtifactOperationError("inventory_artifact", "limit"));
|
|
32
|
+
return ok({ left, right });
|
|
33
|
+
};
|
|
34
|
+
/** Reduce a snapshot to the content commitments used by run identity. */
|
|
35
|
+
export const targetFor = (snapshot) => ({
|
|
36
|
+
root_sha256: snapshot.manifest.root_sha256,
|
|
37
|
+
graph_sha256: snapshot.manifest.graph_sha256,
|
|
38
|
+
manifest_id: snapshot.manifest.manifest_id,
|
|
39
|
+
format: snapshot.manifest.root_format,
|
|
40
|
+
});
|
|
41
|
+
/** Preserve scan and execution limitations in stable order. */
|
|
42
|
+
export const runLimitations = (snapshots) => [
|
|
43
|
+
...new Set([
|
|
44
|
+
...snapshots.left.limitations.map((item) => `Left: ${item}`),
|
|
45
|
+
...snapshots.right.limitations.map((item) => `Right: ${item}`),
|
|
46
|
+
AUTOMATIC_RUN_LIMITATION,
|
|
47
|
+
]),
|
|
48
|
+
].sort((left, right) => left.localeCompare(right));
|
|
49
|
+
const inventoryPages = (path, snapshot, options) => {
|
|
50
|
+
const count = Math.max(1, Math.ceil(snapshot.nodes.length / options.page_size), Math.ceil(snapshot.occurrences.length / options.page_size), Math.ceil(snapshot.edges.length / options.page_size));
|
|
51
|
+
return Array.from({ length: count }, (_, index) => inventoryPage(path, snapshot, options, index * options.page_size));
|
|
52
|
+
};
|
|
53
|
+
const inventoryPage = (path, snapshot, options, offset) => {
|
|
54
|
+
const result = paginateArtifactInventory(snapshot, {
|
|
55
|
+
nodeOffset: offset,
|
|
56
|
+
nodeLimit: options.page_size,
|
|
57
|
+
occurrenceOffset: offset,
|
|
58
|
+
occurrenceLimit: options.page_size,
|
|
59
|
+
edgeOffset: offset,
|
|
60
|
+
edgeLimit: options.page_size,
|
|
61
|
+
});
|
|
62
|
+
return createEvidence({
|
|
63
|
+
path,
|
|
64
|
+
sha256: snapshot.manifest.root_sha256,
|
|
65
|
+
format: snapshot.manifest.root_format,
|
|
66
|
+
}, ARTIFACT_GRAPH_PROVIDER, {
|
|
67
|
+
operation: "inventory_artifact",
|
|
68
|
+
parameters: {
|
|
69
|
+
node_offset: offset,
|
|
70
|
+
node_limit: options.page_size,
|
|
71
|
+
occurrence_offset: offset,
|
|
72
|
+
occurrence_limit: options.page_size,
|
|
73
|
+
edge_offset: offset,
|
|
74
|
+
edge_limit: options.page_size,
|
|
75
|
+
max_entries: options.max_entries,
|
|
76
|
+
max_total_bytes: options.max_total_bytes,
|
|
77
|
+
max_entry_bytes: options.max_entry_bytes,
|
|
78
|
+
max_compression_ratio: options.max_compression_ratio,
|
|
79
|
+
max_depth: options.max_depth,
|
|
80
|
+
max_path_bytes: options.max_path_bytes,
|
|
81
|
+
},
|
|
82
|
+
result: jsonValueSchema.parse(result),
|
|
83
|
+
rawResult: null,
|
|
84
|
+
limitations: result.limitations,
|
|
85
|
+
locations: result.occurrences.items.map(({ logical_path: logicalPath }) => ({
|
|
86
|
+
kind: "artifact-path",
|
|
87
|
+
path: logicalPath,
|
|
88
|
+
})),
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
const artifactLimits = (options) => ({
|
|
92
|
+
maxEntries: options.max_entries,
|
|
93
|
+
maxTotalBytes: options.max_total_bytes,
|
|
94
|
+
maxEntryBytes: options.max_entry_bytes,
|
|
95
|
+
maxCompressionRatio: options.max_compression_ratio,
|
|
96
|
+
maxDepth: options.max_depth,
|
|
97
|
+
maxPathBytes: options.max_path_bytes,
|
|
98
|
+
});
|