rea-agents 0.3.0 → 0.5.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 +139 -21
- package/bridge/hopper_bridge.py +156 -14
- package/dist/application/AnalysisProvider.js +12 -1
- package/dist/application/ArtifactExtraction.js +166 -0
- package/dist/application/ArtifactGraphConstruction.js +257 -0
- package/dist/application/ArtifactInventory.js +253 -0
- package/dist/application/BinarySession.js +174 -14
- package/dist/application/CompositeProvider.js +73 -0
- package/dist/application/DirectAnalysis.js +44 -3
- package/dist/application/Doctor.js +35 -6
- package/dist/application/EnhancedTools.js +10 -7
- package/dist/application/EvidenceBundleCommands.js +51 -0
- package/dist/application/EvidenceBundleFiles.js +127 -0
- package/dist/application/EvidenceLedger.js +225 -18
- package/dist/application/FilesystemSnapshot.js +124 -0
- package/dist/application/LinuxHopper.js +186 -0
- package/dist/application/LoopbackReplay.js +195 -37
- package/dist/application/ProcessHarness.js +200 -191
- package/dist/application/ProcessNormalization.js +44 -0
- package/dist/application/ProcessOwnership.js +105 -0
- package/dist/application/ProcessSampling.js +284 -0
- package/dist/application/RealHopperAssertions.js +116 -0
- package/dist/application/ReferenceSourceImport.js +182 -0
- package/dist/application/ReferenceSourceImportEntries.js +122 -0
- package/dist/application/ReferenceSourceImportPolicy.js +73 -0
- package/dist/application/ReferenceSourceImportTypes.js +18 -0
- package/dist/application/ReferenceSourceVcsAdapter.js +34 -0
- package/dist/application/Setup.js +192 -41
- package/dist/application/Uninstall.js +130 -0
- package/dist/application/runtime.js +8 -1
- package/dist/artifacts/ArtifactPaths.js +51 -0
- package/dist/artifacts/ArtifactProvider.js +130 -0
- package/dist/artifacts/ArtifactReader.js +9 -0
- package/dist/artifacts/AsarArtifactReader.js +62 -0
- package/dist/artifacts/DirectoryArtifactReader.js +107 -0
- package/dist/artifacts/MachOSliceArtifactReader.js +66 -0
- package/dist/artifacts/SafeOutputTree.js +199 -0
- package/dist/artifacts/StreamBytes.js +10 -0
- package/dist/artifacts/ZipArtifactReader.js +109 -0
- package/dist/cli.js +229 -21
- package/dist/cliEvidenceCommands.js +68 -0
- package/dist/cliLogging.js +21 -0
- package/dist/config.js +35 -1
- package/dist/contracts/artifactComparisonExample.js +95 -0
- package/dist/contracts/artifactToolContracts.js +84 -0
- package/dist/contracts/enhancedInputs.js +4 -0
- package/dist/contracts/functionComparisonExample.js +57 -0
- package/dist/contracts/investigationExamples.js +118 -0
- package/dist/contracts/nativeToolContracts.js +52 -0
- package/dist/contracts/processCaptureExample.js +25 -0
- package/dist/contracts/toolContractExamples.js +69 -0
- package/dist/contracts/toolContracts.js +99 -36
- package/dist/contracts/toolOutputSchemas.js +156 -82
- package/dist/contracts/unknownContractExamples.js +33 -0
- package/dist/domain/artifactComparison.js +273 -0
- package/dist/domain/artifactGraph.js +194 -0
- package/dist/domain/artifactInventoryEvidence.js +150 -0
- package/dist/domain/binaryTarget.js +55 -0
- package/dist/domain/bundleComparison.js +266 -0
- package/dist/domain/callPath.js +346 -0
- package/dist/domain/changedBehavior.js +294 -0
- package/dist/domain/errors.js +199 -5
- package/dist/domain/evidence.js +41 -10
- package/dist/domain/evidenceBundle.js +187 -6
- package/dist/domain/functionComparison.js +201 -0
- package/dist/domain/functionComparisonNormalization.js +112 -0
- package/dist/domain/functionComparisonResults.js +54 -0
- package/dist/domain/functionComparisonSchemas.js +82 -0
- package/dist/domain/functionDossierEvidence.js +171 -0
- package/dist/domain/hopperValues.js +35 -8
- package/dist/domain/nativeInspection.js +142 -0
- package/dist/domain/processCapture.js +152 -54
- package/dist/domain/processComparison.js +106 -0
- package/dist/domain/reconstructionUnknowns.js +90 -0
- package/dist/domain/reconstructionVerification.js +285 -0
- package/dist/domain/reconstructionVerificationSchemas.js +126 -0
- package/dist/domain/referenceSourceClassification.js +496 -0
- package/dist/domain/referenceSourceGraph.js +376 -0
- package/dist/domain/referenceSourceImportParsing.js +235 -0
- package/dist/domain/referenceSourcePolicy.js +1 -0
- package/dist/domain/residualUnknown.js +239 -0
- package/dist/domain/staticRuntimeCorrelation.js +375 -0
- package/dist/hopper/BridgeLauncher.js +39 -3
- package/dist/hopper/HopperClient.js +14 -5
- package/dist/hopper/HopperProvider.js +57 -22
- package/dist/hopper/protocol.js +13 -2
- package/dist/identity.js +1 -0
- package/dist/main.js +5 -1
- package/dist/native/CommandRunner.js +156 -0
- package/dist/native/NativeMacOSProvider.js +306 -0
- package/dist/native/NativeMachoInspection.js +135 -0
- package/dist/native/parsers/codesign.js +55 -0
- package/dist/native/parsers/demangle.js +26 -0
- package/dist/native/parsers/dyldInfo.js +25 -0
- package/dist/native/parsers/lipo.js +67 -0
- package/dist/native/parsers/otool.js +193 -0
- package/dist/native/parsers/plist.js +23 -0
- package/dist/reference/ReferenceSourceReader.js +73 -0
- package/dist/reference/ReferenceSourceReaderEntries.js +206 -0
- package/dist/reference/ReferenceSourceReaderErrors.js +19 -0
- package/dist/reference/ReferenceSourceReaderFile.js +119 -0
- package/dist/reference/ReferenceSourceReaderPaths.js +23 -0
- package/dist/reference/ReferenceSourceReaderTypes.js +2 -0
- package/dist/reference/ReferenceSourceReaderValidate.js +71 -0
- package/dist/server/createServer.js +31 -5
- package/dist/server/recordDerivedEvidence.js +10 -0
- package/dist/server/registerArtifactComparisonTool.js +62 -0
- package/dist/server/registerArtifactTools.js +6 -0
- package/dist/server/registerBundleComparisonTool.js +47 -0
- package/dist/server/registerEnhancedTools.js +64 -12
- package/dist/server/registerEvidenceTools.js +36 -0
- package/dist/server/registerFunctionComparisonTool.js +68 -0
- package/dist/server/registerInvestigationTools.js +224 -0
- package/dist/server/registerNativeTools.js +6 -0
- package/dist/server/registerOfficialTools.js +47 -14
- package/dist/server/registerProcessComparisonTool.js +106 -0
- package/dist/server/registerSessionTools.js +179 -70
- package/dist/server/sessionEvidence.js +28 -0
- package/dist/server/sessionToolPolicies.js +64 -0
- package/dist/server/toolRegistrationOptions.js +7 -0
- package/dist/server/toolResult.js +8 -5
- package/install.sh +198 -0
- package/package.json +18 -1
- package/scripts/rea.mjs +5 -1
- package/skills/rea-analysis/SKILL.md +77 -2
|
@@ -28,6 +28,7 @@ export const parseBinaryTarget = async (input, cwd = process.cwd(), hostArchitec
|
|
|
28
28
|
(targetKind === undefined && path.toLowerCase().endsWith(".hop")))
|
|
29
29
|
return ok({
|
|
30
30
|
path,
|
|
31
|
+
sourcePath: canonical,
|
|
31
32
|
sha256: await sha256File(path),
|
|
32
33
|
kind: "database",
|
|
33
34
|
format: "hopper",
|
|
@@ -36,6 +37,16 @@ export const parseBinaryTarget = async (input, cwd = process.cwd(), hostArchitec
|
|
|
36
37
|
const handle = await open(path, "r");
|
|
37
38
|
let detected;
|
|
38
39
|
try {
|
|
40
|
+
const artifactFormat = await detectArtifactFormat(path, handle);
|
|
41
|
+
if (artifactFormat !== undefined)
|
|
42
|
+
return ok({
|
|
43
|
+
path,
|
|
44
|
+
sourcePath: canonical,
|
|
45
|
+
sha256: await sha256File(path),
|
|
46
|
+
kind: isArchiveFormat(artifactFormat) ? "archive" : "artifact",
|
|
47
|
+
format: artifactFormat,
|
|
48
|
+
loaderArgs: [],
|
|
49
|
+
});
|
|
39
50
|
detected = await readExecutableMetadata(handle, hostArchitecture);
|
|
40
51
|
}
|
|
41
52
|
finally {
|
|
@@ -45,6 +56,7 @@ export const parseBinaryTarget = async (input, cwd = process.cwd(), hostArchitec
|
|
|
45
56
|
return err(new BinaryTargetError(path, detected.error));
|
|
46
57
|
return ok({
|
|
47
58
|
path,
|
|
59
|
+
sourcePath: canonical,
|
|
48
60
|
sha256: await sha256File(path),
|
|
49
61
|
kind: "executable",
|
|
50
62
|
...detected.value,
|
|
@@ -54,6 +66,49 @@ export const parseBinaryTarget = async (input, cwd = process.cwd(), hostArchitec
|
|
|
54
66
|
return err(new BinaryTargetError(candidate, "path is not readable", { cause }));
|
|
55
67
|
}
|
|
56
68
|
};
|
|
69
|
+
const detectArtifactFormat = async (path, handle) => {
|
|
70
|
+
const lower = path.toLowerCase();
|
|
71
|
+
const magic = Buffer.alloc(8);
|
|
72
|
+
const observed = await handle.read(magic, 0, magic.length, 0);
|
|
73
|
+
if (observed.bytesRead >= 4 &&
|
|
74
|
+
magic[0] === 0x50 &&
|
|
75
|
+
magic[1] === 0x4b &&
|
|
76
|
+
[0x03, 0x05, 0x07].includes(magic[2] ?? -1) &&
|
|
77
|
+
[0x04, 0x06, 0x08].includes(magic[3] ?? -1)) {
|
|
78
|
+
return lower.endsWith(".ipa")
|
|
79
|
+
? "ipa"
|
|
80
|
+
: lower.endsWith(".apk")
|
|
81
|
+
? "apk"
|
|
82
|
+
: "zip";
|
|
83
|
+
}
|
|
84
|
+
const named = namedArtifactFormat(lower);
|
|
85
|
+
if (named !== undefined)
|
|
86
|
+
return named;
|
|
87
|
+
if (lower.endsWith(".pkg") &&
|
|
88
|
+
observed.bytesRead >= 4 &&
|
|
89
|
+
magic.subarray(0, 4).toString("ascii") === "xar!")
|
|
90
|
+
return "pkg";
|
|
91
|
+
if (lower.endsWith(".dmg")) {
|
|
92
|
+
const size = (await handle.stat()).size;
|
|
93
|
+
if (size >= 512) {
|
|
94
|
+
const trailer = Buffer.alloc(4);
|
|
95
|
+
const read = await handle.read(trailer, 0, trailer.length, size - 512);
|
|
96
|
+
if (read.bytesRead === 4 && trailer.toString("ascii") === "koly")
|
|
97
|
+
return "dmg";
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return undefined;
|
|
101
|
+
};
|
|
102
|
+
const namedArtifactFormat = (lowerPath) => {
|
|
103
|
+
if (lowerPath.endsWith(".asar"))
|
|
104
|
+
return "asar";
|
|
105
|
+
if (lowerPath.endsWith(".plist"))
|
|
106
|
+
return "plist";
|
|
107
|
+
if (lowerPath.endsWith(".map"))
|
|
108
|
+
return "source-map";
|
|
109
|
+
return /\.(?:m?js|cjs)$/u.test(lowerPath) ? "javascript" : undefined;
|
|
110
|
+
};
|
|
111
|
+
const isArchiveFormat = (format) => ["zip", "ipa", "apk", "asar", "dmg", "pkg"].includes(format);
|
|
57
112
|
const sha256File = async (path) => {
|
|
58
113
|
const hash = createHash("sha256");
|
|
59
114
|
for await (const chunk of createReadStream(path))
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { evidenceBundleSchema, parseEvidenceBundle, serializeEvidenceBundle, } from "./evidenceBundle.js";
|
|
4
|
+
const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u);
|
|
5
|
+
const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u);
|
|
6
|
+
const classificationSchema = z.enum([
|
|
7
|
+
"added",
|
|
8
|
+
"removed",
|
|
9
|
+
"changed",
|
|
10
|
+
"unknown",
|
|
11
|
+
"history_advanced",
|
|
12
|
+
"history_diverged",
|
|
13
|
+
]);
|
|
14
|
+
/** Strict bounded input for canonical Evidence bundle comparison. */
|
|
15
|
+
export const bundleComparisonInputSchema = z.object({
|
|
16
|
+
left: evidenceBundleSchema,
|
|
17
|
+
right: evidenceBundleSchema,
|
|
18
|
+
record_pairs: z
|
|
19
|
+
.array(z.object({
|
|
20
|
+
left_evidence_id: evidenceIdSchema,
|
|
21
|
+
right_evidence_id: evidenceIdSchema,
|
|
22
|
+
}))
|
|
23
|
+
.max(500)
|
|
24
|
+
.default([]),
|
|
25
|
+
offset: z.number().int().min(0).default(0),
|
|
26
|
+
limit: z.number().int().min(1).max(500).default(100),
|
|
27
|
+
});
|
|
28
|
+
const bundleChangeSchema = z.object({
|
|
29
|
+
entity: z.enum(["evidence", "residual_unknown"]),
|
|
30
|
+
key: z.string().min(1),
|
|
31
|
+
classification: classificationSchema,
|
|
32
|
+
conclusion_kind: z.enum([
|
|
33
|
+
"observed_change",
|
|
34
|
+
"contradiction",
|
|
35
|
+
"unresolved_branch",
|
|
36
|
+
]),
|
|
37
|
+
left_evidence_ids: z.array(evidenceIdSchema),
|
|
38
|
+
right_evidence_ids: z.array(evidenceIdSchema),
|
|
39
|
+
left_digest: digestSchema.nullable(),
|
|
40
|
+
right_digest: digestSchema.nullable(),
|
|
41
|
+
limitations: z.array(z.string()),
|
|
42
|
+
});
|
|
43
|
+
/** Deterministic classified delta between two canonical Evidence bundles. */
|
|
44
|
+
export const bundleComparisonResultSchema = z.object({
|
|
45
|
+
status: z.enum(["unchanged", "changed", "unknown"]),
|
|
46
|
+
left_bundle_sha256: digestSchema,
|
|
47
|
+
right_bundle_sha256: digestSchema,
|
|
48
|
+
summary: z.object({
|
|
49
|
+
records_unchanged: z.number().int().min(0),
|
|
50
|
+
records_added: z.number().int().min(0),
|
|
51
|
+
records_removed: z.number().int().min(0),
|
|
52
|
+
records_changed: z.number().int().min(0),
|
|
53
|
+
unknowns_unchanged: z.number().int().min(0),
|
|
54
|
+
unknowns_added: z.number().int().min(0),
|
|
55
|
+
unknowns_removed: z.number().int().min(0),
|
|
56
|
+
unknowns_advanced: z.number().int().min(0),
|
|
57
|
+
unknowns_diverged: z.number().int().min(0),
|
|
58
|
+
unresolved: z.number().int().min(0),
|
|
59
|
+
}),
|
|
60
|
+
changes: z.object({
|
|
61
|
+
items: z.array(bundleChangeSchema).max(500),
|
|
62
|
+
offset: z.number().int().min(0),
|
|
63
|
+
limit: z.number().int().min(1).max(500),
|
|
64
|
+
total: z.number().int().min(0),
|
|
65
|
+
next_offset: z.number().int().min(0).nullable(),
|
|
66
|
+
}),
|
|
67
|
+
limitations: z.array(z.string()),
|
|
68
|
+
});
|
|
69
|
+
const MAX_ENTITIES = 100_000;
|
|
70
|
+
/** Compare canonical bundle membership and only explicitly paired observations. */
|
|
71
|
+
export const compareBundles = (...[leftInput, rightInput, recordPairs, offset, limit]) => {
|
|
72
|
+
const left = parseEvidenceBundle(leftInput);
|
|
73
|
+
const right = parseEvidenceBundle(rightInput);
|
|
74
|
+
enforceBounds(left, right);
|
|
75
|
+
const leftDigest = bundleDigest(left);
|
|
76
|
+
const rightDigest = bundleDigest(right);
|
|
77
|
+
const recordResult = compareRecords(left, right, recordPairs);
|
|
78
|
+
const unknownResult = compareUnknowns(left, right);
|
|
79
|
+
const changes = [...recordResult.changes, ...unknownResult.changes].sort(compareChanges);
|
|
80
|
+
const page = changes.slice(offset, offset + limit);
|
|
81
|
+
const unresolved = changes.filter(({ conclusion_kind: kind }) => kind !== "observed_change").length;
|
|
82
|
+
return bundleComparisonResultSchema.parse({
|
|
83
|
+
status: leftDigest === rightDigest && changes.length === 0
|
|
84
|
+
? "unchanged"
|
|
85
|
+
: unresolved > 0
|
|
86
|
+
? "unknown"
|
|
87
|
+
: "changed",
|
|
88
|
+
left_bundle_sha256: leftDigest,
|
|
89
|
+
right_bundle_sha256: rightDigest,
|
|
90
|
+
summary: {
|
|
91
|
+
...recordResult.summary,
|
|
92
|
+
...unknownResult.summary,
|
|
93
|
+
unresolved,
|
|
94
|
+
},
|
|
95
|
+
changes: {
|
|
96
|
+
items: page,
|
|
97
|
+
offset,
|
|
98
|
+
limit,
|
|
99
|
+
total: changes.length,
|
|
100
|
+
next_offset: offset + page.length < changes.length ? offset + page.length : null,
|
|
101
|
+
},
|
|
102
|
+
limitations: changes.some(({ limitations }) => limitations.length > 0)
|
|
103
|
+
? [
|
|
104
|
+
"One-sided membership proves only bundle inclusion or omission, not behavioral absence.",
|
|
105
|
+
]
|
|
106
|
+
: [],
|
|
107
|
+
});
|
|
108
|
+
};
|
|
109
|
+
const compareRecords = (left, right, pairs) => {
|
|
110
|
+
const leftIds = new Set(left.records.map(({ evidence_id: id }) => id));
|
|
111
|
+
const rightIds = new Set(right.records.map(({ evidence_id: id }) => id));
|
|
112
|
+
const pairedLeft = new Set();
|
|
113
|
+
const pairedRight = new Set();
|
|
114
|
+
const changes = [];
|
|
115
|
+
let changed = 0;
|
|
116
|
+
for (const pair of pairs) {
|
|
117
|
+
if (!leftIds.has(pair.left_evidence_id))
|
|
118
|
+
throw new TypeError(`Explicit pair references missing left evidence ${pair.left_evidence_id}`);
|
|
119
|
+
if (!rightIds.has(pair.right_evidence_id))
|
|
120
|
+
throw new TypeError(`Explicit pair references missing right evidence ${pair.right_evidence_id}`);
|
|
121
|
+
if (pairedLeft.has(pair.left_evidence_id) ||
|
|
122
|
+
pairedRight.has(pair.right_evidence_id))
|
|
123
|
+
throw new TypeError("Explicit evidence pairs must be one-to-one");
|
|
124
|
+
pairedLeft.add(pair.left_evidence_id);
|
|
125
|
+
pairedRight.add(pair.right_evidence_id);
|
|
126
|
+
if (pair.left_evidence_id === pair.right_evidence_id)
|
|
127
|
+
continue;
|
|
128
|
+
changed += 1;
|
|
129
|
+
changes.push({
|
|
130
|
+
entity: "evidence",
|
|
131
|
+
key: `pair:${pair.left_evidence_id}:${pair.right_evidence_id}`,
|
|
132
|
+
classification: "changed",
|
|
133
|
+
conclusion_kind: "observed_change",
|
|
134
|
+
left_evidence_ids: [pair.left_evidence_id],
|
|
135
|
+
right_evidence_ids: [pair.right_evidence_id],
|
|
136
|
+
left_digest: pair.left_evidence_id.slice(3),
|
|
137
|
+
right_digest: pair.right_evidence_id.slice(3),
|
|
138
|
+
limitations: [],
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
const common = [...leftIds].filter((id) => rightIds.has(id) && !pairedLeft.has(id) && !pairedRight.has(id));
|
|
142
|
+
const removed = [...leftIds].filter((id) => !rightIds.has(id) && !pairedLeft.has(id));
|
|
143
|
+
const added = [...rightIds].filter((id) => !leftIds.has(id) && !pairedRight.has(id));
|
|
144
|
+
for (const [classification, ids, side] of [
|
|
145
|
+
["removed", removed, "left"],
|
|
146
|
+
["added", added, "right"],
|
|
147
|
+
])
|
|
148
|
+
for (const id of ids)
|
|
149
|
+
changes.push({
|
|
150
|
+
entity: "evidence",
|
|
151
|
+
key: id,
|
|
152
|
+
classification,
|
|
153
|
+
conclusion_kind: "observed_change",
|
|
154
|
+
left_evidence_ids: side === "left" ? [id] : [],
|
|
155
|
+
right_evidence_ids: side === "right" ? [id] : [],
|
|
156
|
+
left_digest: side === "left" ? id.slice(3) : null,
|
|
157
|
+
right_digest: side === "right" ? id.slice(3) : null,
|
|
158
|
+
limitations: [
|
|
159
|
+
"One-sided membership does not establish behavioral absence.",
|
|
160
|
+
],
|
|
161
|
+
});
|
|
162
|
+
return {
|
|
163
|
+
changes,
|
|
164
|
+
summary: {
|
|
165
|
+
records_unchanged: common.length + pairs.length - changed,
|
|
166
|
+
records_added: added.length,
|
|
167
|
+
records_removed: removed.length,
|
|
168
|
+
records_changed: changed,
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
};
|
|
172
|
+
const compareUnknowns = (left, right) => {
|
|
173
|
+
const leftHistories = histories(left.unknowns);
|
|
174
|
+
const rightHistories = histories(right.unknowns);
|
|
175
|
+
const ids = [...new Set([...leftHistories.keys(), ...rightHistories.keys()])];
|
|
176
|
+
const changes = [];
|
|
177
|
+
const summary = {
|
|
178
|
+
unknowns_unchanged: 0,
|
|
179
|
+
unknowns_added: 0,
|
|
180
|
+
unknowns_removed: 0,
|
|
181
|
+
unknowns_advanced: 0,
|
|
182
|
+
unknowns_diverged: 0,
|
|
183
|
+
};
|
|
184
|
+
for (const id of ids) {
|
|
185
|
+
const leftHistory = leftHistories.get(id);
|
|
186
|
+
const rightHistory = rightHistories.get(id);
|
|
187
|
+
if (leftHistory === undefined || rightHistory === undefined) {
|
|
188
|
+
const added = leftHistory === undefined;
|
|
189
|
+
summary[added ? "unknowns_added" : "unknowns_removed"] += 1;
|
|
190
|
+
changes.push(unknownChange(id, added ? "added" : "removed", leftHistory, rightHistory));
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const leftDigests = leftHistory.map(({ revision_digest: digest }) => digest);
|
|
194
|
+
const rightDigests = rightHistory.map(({ revision_digest: digest }) => digest);
|
|
195
|
+
if (same(leftDigests, rightDigests)) {
|
|
196
|
+
summary.unknowns_unchanged += 1;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const advanced = isPrefix(leftDigests, rightDigests) ||
|
|
200
|
+
isPrefix(rightDigests, leftDigests);
|
|
201
|
+
summary[advanced ? "unknowns_advanced" : "unknowns_diverged"] += 1;
|
|
202
|
+
changes.push(unknownChange(id, advanced ? "history_advanced" : "history_diverged", leftHistory, rightHistory));
|
|
203
|
+
}
|
|
204
|
+
return { changes, summary };
|
|
205
|
+
};
|
|
206
|
+
const unknownChange = (id, classification, left, right) => ({
|
|
207
|
+
entity: "residual_unknown",
|
|
208
|
+
key: id,
|
|
209
|
+
classification,
|
|
210
|
+
conclusion_kind: classification === "history_diverged"
|
|
211
|
+
? "contradiction"
|
|
212
|
+
: classification === "history_advanced"
|
|
213
|
+
? "observed_change"
|
|
214
|
+
: "unresolved_branch",
|
|
215
|
+
left_evidence_ids: historyEvidenceIds(left ?? []),
|
|
216
|
+
right_evidence_ids: historyEvidenceIds(right ?? []),
|
|
217
|
+
left_digest: left?.at(-1)?.revision_digest ?? null,
|
|
218
|
+
right_digest: right?.at(-1)?.revision_digest ?? null,
|
|
219
|
+
limitations: left === undefined || right === undefined
|
|
220
|
+
? ["A missing unknown history does not establish resolution."]
|
|
221
|
+
: [],
|
|
222
|
+
});
|
|
223
|
+
const histories = (unknowns) => {
|
|
224
|
+
const output = new Map();
|
|
225
|
+
for (const unknown of unknowns) {
|
|
226
|
+
const history = output.get(unknown.unknown_id) ?? [];
|
|
227
|
+
history.push(unknown);
|
|
228
|
+
output.set(unknown.unknown_id, history);
|
|
229
|
+
}
|
|
230
|
+
return output;
|
|
231
|
+
};
|
|
232
|
+
const historyEvidenceIds = (history) => [
|
|
233
|
+
...new Set(history.flatMap((item) => [
|
|
234
|
+
...item.supporting_evidence_ids,
|
|
235
|
+
...item.contradicting_evidence_ids,
|
|
236
|
+
...item.mutation_evidence_ids,
|
|
237
|
+
...(item.resolution?.evidence_ids ?? []),
|
|
238
|
+
])),
|
|
239
|
+
].sort(ascii);
|
|
240
|
+
const enforceBounds = (left, right) => {
|
|
241
|
+
const total = left.records.length +
|
|
242
|
+
right.records.length +
|
|
243
|
+
left.unknowns.length +
|
|
244
|
+
right.unknowns.length;
|
|
245
|
+
if (total > MAX_ENTITIES)
|
|
246
|
+
throw new TypeError(`Bundle comparison exceeds ${MAX_ENTITIES} entities`);
|
|
247
|
+
};
|
|
248
|
+
const bundleDigest = (bundle) => createHash("sha256").update(serializeEvidenceBundle(bundle)).digest("hex");
|
|
249
|
+
const same = (left, right) => left.length === right.length &&
|
|
250
|
+
left.every((value, index) => value === right[index]);
|
|
251
|
+
const isPrefix = (shorter, longer) => shorter.length < longer.length &&
|
|
252
|
+
shorter.every((value, index) => value === longer[index]);
|
|
253
|
+
const ascii = (left, right) => left < right ? -1 : left > right ? 1 : 0;
|
|
254
|
+
const compareChanges = (left, right) => ascii([
|
|
255
|
+
left.entity,
|
|
256
|
+
left.key,
|
|
257
|
+
left.classification,
|
|
258
|
+
left.left_digest ?? "",
|
|
259
|
+
left.right_digest ?? "",
|
|
260
|
+
].join("\u0000"), [
|
|
261
|
+
right.entity,
|
|
262
|
+
right.key,
|
|
263
|
+
right.classification,
|
|
264
|
+
right.left_digest ?? "",
|
|
265
|
+
right.right_digest ?? "",
|
|
266
|
+
].join("\u0000"));
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { DirectedGraph } from "graphology";
|
|
2
|
+
import { bidirectional } from "graphology-shortest-path/unweighted.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { evidenceSchema } from "./evidence.js";
|
|
5
|
+
import { parseFunctionEvidence, } from "./functionDossierEvidence.js";
|
|
6
|
+
const addressSchema = z.string().regex(/^0x(?:0|[1-9a-f][0-9a-f]*)$/u);
|
|
7
|
+
const inputAddressSchema = z
|
|
8
|
+
.string()
|
|
9
|
+
.regex(/^0[xX][0-9a-fA-F]+$/u)
|
|
10
|
+
.transform((address) => `0x${BigInt(address).toString(16)}`);
|
|
11
|
+
const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u);
|
|
12
|
+
const evidenceGroupSchema = z.union([
|
|
13
|
+
evidenceSchema,
|
|
14
|
+
z.array(evidenceSchema).min(1).max(100),
|
|
15
|
+
]);
|
|
16
|
+
const MAX_EVIDENCE_PAGES = 2_000;
|
|
17
|
+
const MAX_GRAPH_EDGES = 10_000;
|
|
18
|
+
const MAX_PATH_EXPANSIONS = 100_000;
|
|
19
|
+
/** Strict, bounded input for explicit call-path reconstruction. */
|
|
20
|
+
export const callPathInputSchema = z
|
|
21
|
+
.object({
|
|
22
|
+
functions: z.array(evidenceGroupSchema).min(1).max(500),
|
|
23
|
+
start: z.object({ address: inputAddressSchema }).strict(),
|
|
24
|
+
goal: z.object({ address: inputAddressSchema }).strict(),
|
|
25
|
+
max_depth: z.number().int().min(0).max(32).default(8),
|
|
26
|
+
max_paths: z.number().int().min(1).max(100).default(10),
|
|
27
|
+
offset: z.number().int().min(0).default(0),
|
|
28
|
+
limit: z.number().int().min(1).max(100).default(100),
|
|
29
|
+
unknown_registry_approved: z.literal(true).optional(),
|
|
30
|
+
})
|
|
31
|
+
.superRefine(({ functions }, context) => {
|
|
32
|
+
const pages = functions.reduce((total, group) => total + (Array.isArray(group) ? group.length : 1), 0);
|
|
33
|
+
if (pages > MAX_EVIDENCE_PAGES)
|
|
34
|
+
context.addIssue({
|
|
35
|
+
code: "custom",
|
|
36
|
+
message: `Call-path Evidence exceeds ${MAX_EVIDENCE_PAGES} pages`,
|
|
37
|
+
path: ["functions"],
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
const citedNodeSchema = z.object({
|
|
41
|
+
address: addressSchema,
|
|
42
|
+
name: z.string().nullable(),
|
|
43
|
+
evidence_links: z.array(evidenceIdSchema).min(1).max(100),
|
|
44
|
+
});
|
|
45
|
+
const citedEdgeSchema = z.object({
|
|
46
|
+
source: addressSchema,
|
|
47
|
+
target: addressSchema,
|
|
48
|
+
evidence_links: z.array(evidenceIdSchema).min(1).max(100),
|
|
49
|
+
});
|
|
50
|
+
const pathSchema = z.object({
|
|
51
|
+
hops: z.number().int().min(0),
|
|
52
|
+
nodes: z.array(citedNodeSchema).min(1).max(33),
|
|
53
|
+
edges: z.array(citedEdgeSchema).max(32),
|
|
54
|
+
evidence_links: z.array(evidenceIdSchema).min(1).max(3_300),
|
|
55
|
+
});
|
|
56
|
+
/** Evidence-cited, bounded directed call-path result. */
|
|
57
|
+
export const callPathResultSchema = z.object({
|
|
58
|
+
status: z.enum(["found", "not_found", "unknown", "truncated"]),
|
|
59
|
+
start: addressSchema,
|
|
60
|
+
goal: addressSchema,
|
|
61
|
+
shortest_hops: z.number().int().min(0).nullable(),
|
|
62
|
+
search_scope: z.object({
|
|
63
|
+
max_depth: z.number().int().min(0).max(32),
|
|
64
|
+
max_paths: z.number().int().min(1).max(100),
|
|
65
|
+
exhaustive: z.boolean(),
|
|
66
|
+
}),
|
|
67
|
+
explored: z.object({
|
|
68
|
+
nodes: z.number().int().min(0),
|
|
69
|
+
edges: z.number().int().min(0),
|
|
70
|
+
depth_reached: z.number().int().min(0),
|
|
71
|
+
}),
|
|
72
|
+
paths: z.object({
|
|
73
|
+
items: z.array(pathSchema).max(100),
|
|
74
|
+
offset: z.number().int().min(0),
|
|
75
|
+
limit: z.number().int().min(1).max(100),
|
|
76
|
+
total: z.number().int().min(0).nullable(),
|
|
77
|
+
returned: z.number().int().min(0).max(100),
|
|
78
|
+
truncated: z.boolean(),
|
|
79
|
+
lower_bound: z.number().int().min(0),
|
|
80
|
+
next_offset: z.number().int().min(0).nullable(),
|
|
81
|
+
}),
|
|
82
|
+
evidence_links: z.array(evidenceIdSchema).min(1).max(50_000),
|
|
83
|
+
limitations: z.array(z.string()),
|
|
84
|
+
});
|
|
85
|
+
/** Reconstruct bounded direct-callee paths from explicit analyze_function Evidence. */
|
|
86
|
+
export const buildCallPath = (input) => {
|
|
87
|
+
const parsed = callPathInputSchema.parse(input);
|
|
88
|
+
const snapshots = parseSnapshots(parsed.functions);
|
|
89
|
+
assertCompatible(snapshots);
|
|
90
|
+
if (!snapshots.has(parsed.start.address))
|
|
91
|
+
throw new TypeError(`No analyze_function Evidence was supplied for start ${parsed.start.address}`);
|
|
92
|
+
const graph = createGraph(snapshots);
|
|
93
|
+
const search = inspectSearch({
|
|
94
|
+
graph,
|
|
95
|
+
snapshots,
|
|
96
|
+
start: parsed.start.address,
|
|
97
|
+
goal: parsed.goal.address,
|
|
98
|
+
maxDepth: parsed.max_depth,
|
|
99
|
+
});
|
|
100
|
+
const shortest = graph.hasNode(parsed.start.address) && graph.hasNode(parsed.goal.address)
|
|
101
|
+
? bidirectional(graph, parsed.start.address, parsed.goal.address)
|
|
102
|
+
: null;
|
|
103
|
+
const enumeration = shortest === null || shortest.length - 1 > parsed.max_depth
|
|
104
|
+
? { paths: [], budgetExhausted: false }
|
|
105
|
+
: enumeratePaths({
|
|
106
|
+
graph,
|
|
107
|
+
start: parsed.start.address,
|
|
108
|
+
goal: parsed.goal.address,
|
|
109
|
+
shortestDepth: shortest.length - 1,
|
|
110
|
+
maxDepth: parsed.max_depth,
|
|
111
|
+
limit: parsed.max_paths + 1,
|
|
112
|
+
});
|
|
113
|
+
const hasKnownExtraPath = enumeration.paths.length > parsed.max_paths;
|
|
114
|
+
const capped = hasKnownExtraPath || enumeration.budgetExhausted;
|
|
115
|
+
const retained = enumeration.paths.slice(0, parsed.max_paths);
|
|
116
|
+
const paths = retained.map((path) => citePath(path, snapshots));
|
|
117
|
+
const total = paths.length;
|
|
118
|
+
const items = paths.slice(parsed.offset, parsed.offset + parsed.limit);
|
|
119
|
+
const found = paths.length > 0;
|
|
120
|
+
const exhaustive = search.exhaustive && !capped;
|
|
121
|
+
const status = callPathStatus({ capped, found, exhaustive });
|
|
122
|
+
const limitations = [...search.limitations];
|
|
123
|
+
if (capped)
|
|
124
|
+
limitations.push(enumeration.budgetExhausted
|
|
125
|
+
? `Path enumeration stopped at ${MAX_PATH_EXPANSIONS} expansions`
|
|
126
|
+
: `Path enumeration stopped at max_paths=${parsed.max_paths}`);
|
|
127
|
+
if (!found && shortest !== null && shortest.length - 1 > parsed.max_depth)
|
|
128
|
+
limitations.push(`The shortest observed path exceeds max_depth=${parsed.max_depth}`);
|
|
129
|
+
return callPathResultSchema.parse({
|
|
130
|
+
status,
|
|
131
|
+
start: parsed.start.address,
|
|
132
|
+
goal: parsed.goal.address,
|
|
133
|
+
shortest_hops: found ? (paths[0]?.hops ?? null) : null,
|
|
134
|
+
search_scope: {
|
|
135
|
+
max_depth: parsed.max_depth,
|
|
136
|
+
max_paths: parsed.max_paths,
|
|
137
|
+
exhaustive,
|
|
138
|
+
},
|
|
139
|
+
explored: {
|
|
140
|
+
nodes: search.reached.size,
|
|
141
|
+
edges: [...search.reached.keys()].reduce((count, node) => count + (graph.hasNode(node) ? graph.outDegree(node) : 0), 0),
|
|
142
|
+
depth_reached: Math.max(0, ...search.reached.values()),
|
|
143
|
+
},
|
|
144
|
+
paths: {
|
|
145
|
+
items,
|
|
146
|
+
offset: parsed.offset,
|
|
147
|
+
limit: parsed.limit,
|
|
148
|
+
total: capped ? null : total,
|
|
149
|
+
returned: items.length,
|
|
150
|
+
truncated: capped,
|
|
151
|
+
lower_bound: total + (hasKnownExtraPath ? 1 : 0),
|
|
152
|
+
next_offset: parsed.offset + items.length < total
|
|
153
|
+
? parsed.offset + items.length
|
|
154
|
+
: null,
|
|
155
|
+
},
|
|
156
|
+
evidence_links: uniqueEvidence(snapshots.values()),
|
|
157
|
+
limitations: [...new Set(limitations)].sort((left, right) => left.localeCompare(right)),
|
|
158
|
+
});
|
|
159
|
+
};
|
|
160
|
+
const callPathStatus = ({ capped, found, exhaustive, }) => {
|
|
161
|
+
if (capped)
|
|
162
|
+
return "truncated";
|
|
163
|
+
if (found)
|
|
164
|
+
return "found";
|
|
165
|
+
return exhaustive ? "not_found" : "unknown";
|
|
166
|
+
};
|
|
167
|
+
const parseSnapshots = (groups) => {
|
|
168
|
+
const snapshots = new Map();
|
|
169
|
+
for (const group of groups) {
|
|
170
|
+
const snapshot = parseFunctionEvidence(group);
|
|
171
|
+
const address = normalizeAddress(snapshot.procedure.address);
|
|
172
|
+
for (const callee of snapshot.collections.callees.items)
|
|
173
|
+
normalizeAddress(callee.address);
|
|
174
|
+
if (snapshots.has(address))
|
|
175
|
+
throw new TypeError(`Duplicate function Evidence for ${address}`);
|
|
176
|
+
snapshots.set(address, snapshot);
|
|
177
|
+
}
|
|
178
|
+
return snapshots;
|
|
179
|
+
};
|
|
180
|
+
const assertCompatible = (snapshots) => {
|
|
181
|
+
const first = snapshots.values().next().value;
|
|
182
|
+
if (first === undefined)
|
|
183
|
+
return;
|
|
184
|
+
for (const snapshot of snapshots.values()) {
|
|
185
|
+
if (snapshot.subject.digest.sha256 !== first.subject.digest.sha256 ||
|
|
186
|
+
snapshot.subject.format !== first.subject.format ||
|
|
187
|
+
snapshot.subject.architecture !== first.subject.architecture)
|
|
188
|
+
throw new TypeError("Call-path Evidence mixes artifact subjects");
|
|
189
|
+
if (snapshot.provider.id !== first.provider.id ||
|
|
190
|
+
snapshot.provider.name !== first.provider.name ||
|
|
191
|
+
snapshot.provider.version !== first.provider.version)
|
|
192
|
+
throw new TypeError("Call-path Evidence mixes providers");
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
const createGraph = (snapshots) => {
|
|
196
|
+
const graph = new DirectedGraph({ allowSelfLoops: true, multi: false });
|
|
197
|
+
for (const [address, snapshot] of snapshots) {
|
|
198
|
+
graph.mergeNode(address);
|
|
199
|
+
for (const callee of snapshot.collections.callees.items) {
|
|
200
|
+
const calleeAddress = normalizeAddress(callee.address);
|
|
201
|
+
graph.mergeNode(calleeAddress);
|
|
202
|
+
graph.mergeDirectedEdge(address, calleeAddress);
|
|
203
|
+
if (graph.size > MAX_GRAPH_EDGES)
|
|
204
|
+
throw new TypeError(`Call graph exceeds ${MAX_GRAPH_EDGES} directed edges`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return graph;
|
|
208
|
+
};
|
|
209
|
+
const inspectSearch = ({ graph, snapshots, start, goal, maxDepth, }) => {
|
|
210
|
+
if (!graph.hasNode(start))
|
|
211
|
+
return {
|
|
212
|
+
graph,
|
|
213
|
+
snapshots,
|
|
214
|
+
reached: new Map(),
|
|
215
|
+
exhaustive: false,
|
|
216
|
+
limitations: [
|
|
217
|
+
`No analyze_function Evidence was supplied for start ${start}`,
|
|
218
|
+
],
|
|
219
|
+
};
|
|
220
|
+
const reached = new Map([[start, 0]]);
|
|
221
|
+
const queue = [start];
|
|
222
|
+
const limitations = [];
|
|
223
|
+
for (let index = 0; index < queue.length; index += 1) {
|
|
224
|
+
const node = queue[index];
|
|
225
|
+
if (node === undefined)
|
|
226
|
+
continue;
|
|
227
|
+
const depth = reached.get(node) ?? 0;
|
|
228
|
+
if (node === goal)
|
|
229
|
+
continue;
|
|
230
|
+
const snapshot = snapshots.get(node);
|
|
231
|
+
if (snapshot === undefined) {
|
|
232
|
+
limitations.push(`No analyze_function Evidence covers reachable function ${node}`);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (!snapshot.collections.callees.complete)
|
|
236
|
+
limitations.push(`Callee coverage is incomplete for reachable function ${node}`);
|
|
237
|
+
const neighbors = graph
|
|
238
|
+
.outNeighbors(node)
|
|
239
|
+
.sort((left, right) => left.localeCompare(right));
|
|
240
|
+
if (depth === maxDepth) {
|
|
241
|
+
if (neighbors.length > 0)
|
|
242
|
+
limitations.push(`Search reached max_depth=${maxDepth} at ${node}`);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
for (const neighbor of neighbors)
|
|
246
|
+
if (!reached.has(neighbor)) {
|
|
247
|
+
reached.set(neighbor, depth + 1);
|
|
248
|
+
queue.push(neighbor);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
graph,
|
|
253
|
+
snapshots,
|
|
254
|
+
reached,
|
|
255
|
+
exhaustive: limitations.length === 0,
|
|
256
|
+
limitations,
|
|
257
|
+
};
|
|
258
|
+
};
|
|
259
|
+
const enumeratePaths = ({ graph, start, goal, shortestDepth, maxDepth, limit, }) => {
|
|
260
|
+
const output = [];
|
|
261
|
+
const state = {
|
|
262
|
+
graph,
|
|
263
|
+
goal,
|
|
264
|
+
path: [start],
|
|
265
|
+
visited: new Set([start]),
|
|
266
|
+
output,
|
|
267
|
+
limit,
|
|
268
|
+
expansions: 0,
|
|
269
|
+
budgetExhausted: false,
|
|
270
|
+
};
|
|
271
|
+
for (let depth = shortestDepth; depth <= maxDepth && output.length < limit; depth += 1)
|
|
272
|
+
enumerateAtDepth(state, depth);
|
|
273
|
+
return { paths: output, budgetExhausted: state.budgetExhausted };
|
|
274
|
+
};
|
|
275
|
+
const enumerateAtDepth = (state, remaining) => {
|
|
276
|
+
const { graph, goal, path, visited, output, limit } = state;
|
|
277
|
+
state.expansions += 1;
|
|
278
|
+
if (state.expansions > MAX_PATH_EXPANSIONS) {
|
|
279
|
+
state.budgetExhausted = true;
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const current = path.at(-1);
|
|
283
|
+
if (current === undefined || output.length >= limit)
|
|
284
|
+
return;
|
|
285
|
+
if (remaining === 0) {
|
|
286
|
+
if (current === goal)
|
|
287
|
+
output.push([...path]);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (current === goal)
|
|
291
|
+
return;
|
|
292
|
+
for (const neighbor of graph
|
|
293
|
+
.outNeighbors(current)
|
|
294
|
+
.sort((left, right) => left.localeCompare(right))) {
|
|
295
|
+
if (visited.has(neighbor))
|
|
296
|
+
continue;
|
|
297
|
+
visited.add(neighbor);
|
|
298
|
+
path.push(neighbor);
|
|
299
|
+
enumerateAtDepth(state, remaining - 1);
|
|
300
|
+
path.pop();
|
|
301
|
+
visited.delete(neighbor);
|
|
302
|
+
if (output.length >= limit || state.budgetExhausted)
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
const citePath = (addresses, snapshots) => {
|
|
307
|
+
const edges = addresses.slice(0, -1).map((source, index) => {
|
|
308
|
+
const target = addresses[index + 1];
|
|
309
|
+
if (target === undefined)
|
|
310
|
+
throw new TypeError("Call path has an invalid edge");
|
|
311
|
+
return {
|
|
312
|
+
source,
|
|
313
|
+
target,
|
|
314
|
+
evidence_links: snapshotLinks(snapshots.get(source)),
|
|
315
|
+
};
|
|
316
|
+
});
|
|
317
|
+
const nodes = addresses.map((address, index) => {
|
|
318
|
+
const snapshot = snapshots.get(address);
|
|
319
|
+
const supporting = snapshot ??
|
|
320
|
+
(index > 0 ? snapshots.get(addresses[index - 1] ?? "") : undefined);
|
|
321
|
+
return {
|
|
322
|
+
address,
|
|
323
|
+
name: snapshot?.procedure.name ?? null,
|
|
324
|
+
evidence_links: snapshotLinks(supporting),
|
|
325
|
+
};
|
|
326
|
+
});
|
|
327
|
+
return {
|
|
328
|
+
hops: edges.length,
|
|
329
|
+
nodes,
|
|
330
|
+
edges,
|
|
331
|
+
evidence_links: [
|
|
332
|
+
...new Set(edges
|
|
333
|
+
.flatMap(({ evidence_links: links }) => links)
|
|
334
|
+
.concat(nodes.flatMap(({ evidence_links: links }) => links))),
|
|
335
|
+
],
|
|
336
|
+
};
|
|
337
|
+
};
|
|
338
|
+
const snapshotLinks = (snapshot) => {
|
|
339
|
+
if (snapshot === undefined)
|
|
340
|
+
throw new TypeError("Every call-path claim requires supporting Evidence");
|
|
341
|
+
return snapshot.evidence.map(({ evidence_id }) => evidence_id);
|
|
342
|
+
};
|
|
343
|
+
const uniqueEvidence = (snapshots) => [
|
|
344
|
+
...new Set([...snapshots].flatMap((snapshot) => snapshotLinks(snapshot))),
|
|
345
|
+
].sort((left, right) => left.localeCompare(right));
|
|
346
|
+
const normalizeAddress = (input) => inputAddressSchema.parse(input);
|