rea-agents 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +124 -20
- package/bridge/hopper_bridge.py +141 -13
- 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 +164 -14
- package/dist/application/CompositeProvider.js +73 -0
- package/dist/application/DirectAnalysis.js +27 -3
- package/dist/application/Doctor.js +35 -6
- package/dist/application/EnhancedTools.js +8 -7
- package/dist/application/EvidenceBundleCommands.js +29 -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 +84 -0
- package/dist/application/ProcessSampling.js +284 -0
- package/dist/application/RealHopperAssertions.js +105 -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 +153 -6
- 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 +154 -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 +192 -4
- 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 +14 -7
- 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/residualUnknown.js +239 -0
- package/dist/domain/staticRuntimeCorrelation.js +375 -0
- package/dist/hopper/BridgeLauncher.js +40 -3
- package/dist/hopper/HopperClient.js +14 -5
- package/dist/hopper/HopperProvider.js +57 -22
- 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 +19 -2
- package/scripts/rea.mjs +5 -1
- package/skills/rea-analysis/SKILL.md +77 -2
- /package/dist/{application/HopperToolPort.js → domain/referenceSourcePolicy.js} +0 -0
|
@@ -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);
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { artifactComparisonResultSchema } from "./artifactComparison.js";
|
|
3
|
+
import { evidenceSchema, parseEvidence } from "./evidence.js";
|
|
4
|
+
import { functionComparisonResultSchema } from "./functionComparison.js";
|
|
5
|
+
import { comparisonStatusSchema, processCaptureComparisonSchema, } from "./processCapture.js";
|
|
6
|
+
const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u);
|
|
7
|
+
/** Strict bounded input for aggregating existing comparison Evidence. */
|
|
8
|
+
export const changedBehaviorInputSchema = z.object({
|
|
9
|
+
comparisons: z.array(evidenceSchema).min(1).max(100),
|
|
10
|
+
offset: z.number().int().min(0).default(0),
|
|
11
|
+
limit: z.number().int().min(1).max(100).default(100),
|
|
12
|
+
unknown_registry_approved: z.literal(true).optional(),
|
|
13
|
+
});
|
|
14
|
+
const findingSchema = z.object({
|
|
15
|
+
scope: z.enum(["runtime", "protocol", "resource", "static_candidate"]),
|
|
16
|
+
dimension: z.string().min(1),
|
|
17
|
+
classification: z.enum([
|
|
18
|
+
"observed_change",
|
|
19
|
+
"derived_relationship",
|
|
20
|
+
"contradiction",
|
|
21
|
+
"unresolved_branch",
|
|
22
|
+
]),
|
|
23
|
+
status: comparisonStatusSchema,
|
|
24
|
+
source_comparison_id: evidenceIdSchema,
|
|
25
|
+
evidence_links: z.array(evidenceIdSchema).min(3).max(201),
|
|
26
|
+
limitations: z.array(z.string()),
|
|
27
|
+
});
|
|
28
|
+
/** Truthful aggregate: runtime observations remain separate from static candidates. */
|
|
29
|
+
export const changedBehaviorResultSchema = z.object({
|
|
30
|
+
behavior_status: z.enum([
|
|
31
|
+
"observed_changed",
|
|
32
|
+
"observed_unchanged",
|
|
33
|
+
"unknown",
|
|
34
|
+
"truncated",
|
|
35
|
+
]),
|
|
36
|
+
summary: z.object({
|
|
37
|
+
observed_changes: z.number().int().min(0),
|
|
38
|
+
static_candidates: z.number().int().min(0),
|
|
39
|
+
contradictions: z.number().int().min(0),
|
|
40
|
+
unresolved: z.number().int().min(0),
|
|
41
|
+
}),
|
|
42
|
+
findings: z.object({
|
|
43
|
+
items: z.array(findingSchema).max(100),
|
|
44
|
+
offset: z.number().int().min(0),
|
|
45
|
+
limit: z.number().int().min(1).max(100),
|
|
46
|
+
total: z.number().int().min(0),
|
|
47
|
+
next_offset: z.number().int().min(0).nullable(),
|
|
48
|
+
}),
|
|
49
|
+
evidence_links: z.array(evidenceIdSchema).min(3).max(20_100),
|
|
50
|
+
limitations: z.array(z.string()),
|
|
51
|
+
});
|
|
52
|
+
const EXPECTED_PROVIDERS = {
|
|
53
|
+
compare_process_captures: {
|
|
54
|
+
predicate: "rea.process-comparison/v1",
|
|
55
|
+
id: "rea-process",
|
|
56
|
+
name: "REA deterministic process harness",
|
|
57
|
+
version: "1",
|
|
58
|
+
},
|
|
59
|
+
compare_artifacts: {
|
|
60
|
+
predicate: "rea.artifact-comparison/v1",
|
|
61
|
+
id: "rea-artifact-comparison",
|
|
62
|
+
name: "REA artifact comparison",
|
|
63
|
+
version: "1",
|
|
64
|
+
},
|
|
65
|
+
compare_functions: {
|
|
66
|
+
predicate: "rea.function-comparison/v1",
|
|
67
|
+
id: "rea-function-comparison",
|
|
68
|
+
name: "REA function comparison",
|
|
69
|
+
version: "1",
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
/** Find observed runtime changes and separately report static change candidates. */
|
|
73
|
+
export const findChangedBehavior = (comparisonsInput, offset, limit) => {
|
|
74
|
+
const parsedInput = changedBehaviorInputSchema.parse({
|
|
75
|
+
comparisons: comparisonsInput,
|
|
76
|
+
offset,
|
|
77
|
+
limit,
|
|
78
|
+
});
|
|
79
|
+
const comparisons = parsedInput.comparisons;
|
|
80
|
+
const evidence = comparisons.map(parseComparisonEvidence);
|
|
81
|
+
assertUnique(evidence.map(({ evidence_id: id }) => id), "comparison Evidence");
|
|
82
|
+
const findings = evidence.flatMap(findingsFor).sort(compareFindings);
|
|
83
|
+
const runtimeStatuses = evidence.flatMap(runtimeStatus);
|
|
84
|
+
const links = uniqueSorted(evidence.flatMap((item) => [item.evidence_id, ...item.evidence_links]));
|
|
85
|
+
if (links.length > 20_100)
|
|
86
|
+
throw new TypeError("Changed-behavior Evidence closure exceeds limit");
|
|
87
|
+
const page = findings.slice(parsedInput.offset, parsedInput.offset + parsedInput.limit);
|
|
88
|
+
return changedBehaviorResultSchema.parse({
|
|
89
|
+
behavior_status: behaviorStatus(runtimeStatuses),
|
|
90
|
+
summary: {
|
|
91
|
+
observed_changes: count(findings, "observed_change"),
|
|
92
|
+
static_candidates: count(findings, "derived_relationship"),
|
|
93
|
+
contradictions: count(findings, "contradiction"),
|
|
94
|
+
unresolved: count(findings, "unresolved_branch"),
|
|
95
|
+
},
|
|
96
|
+
findings: {
|
|
97
|
+
items: page,
|
|
98
|
+
offset: parsedInput.offset,
|
|
99
|
+
limit: parsedInput.limit,
|
|
100
|
+
total: findings.length,
|
|
101
|
+
next_offset: parsedInput.offset + page.length < findings.length
|
|
102
|
+
? parsedInput.offset + page.length
|
|
103
|
+
: null,
|
|
104
|
+
},
|
|
105
|
+
evidence_links: links,
|
|
106
|
+
limitations: uniqueSorted([
|
|
107
|
+
...evidence.flatMap(({ limitations }) => limitations),
|
|
108
|
+
...(evidence.some(({ operation }) => operation !== "compare_process_captures")
|
|
109
|
+
? [
|
|
110
|
+
"Static differences are behavior candidates, not runtime observations.",
|
|
111
|
+
]
|
|
112
|
+
: []),
|
|
113
|
+
...(runtimeStatuses.length === 0
|
|
114
|
+
? ["No process comparison Evidence was supplied."]
|
|
115
|
+
: []),
|
|
116
|
+
]),
|
|
117
|
+
});
|
|
118
|
+
};
|
|
119
|
+
const parseComparisonEvidence = (input) => {
|
|
120
|
+
const evidence = parseEvidence(input);
|
|
121
|
+
if (!(evidence.operation in EXPECTED_PROVIDERS))
|
|
122
|
+
throw new TypeError("Changed-behavior analysis requires process, artifact, or function comparison Evidence");
|
|
123
|
+
const operation = z
|
|
124
|
+
.enum([
|
|
125
|
+
"compare_process_captures",
|
|
126
|
+
"compare_artifacts",
|
|
127
|
+
"compare_functions",
|
|
128
|
+
])
|
|
129
|
+
.parse(evidence.operation);
|
|
130
|
+
const expected = EXPECTED_PROVIDERS[operation];
|
|
131
|
+
if (evidence.predicate_type !== expected.predicate ||
|
|
132
|
+
evidence.provider.id !== expected.id ||
|
|
133
|
+
evidence.provider.name !== expected.name ||
|
|
134
|
+
evidence.provider.version !== expected.version ||
|
|
135
|
+
evidence.confidence !== "derived" ||
|
|
136
|
+
evidence.authority !== "analyst-inference" ||
|
|
137
|
+
evidence.subject !== null)
|
|
138
|
+
throw new TypeError("Comparison Evidence operation, predicate, and provider disagree");
|
|
139
|
+
if (evidence.evidence_links.length < 2)
|
|
140
|
+
throw new TypeError("Comparison Evidence must cite both source observations");
|
|
141
|
+
assertUnique(evidence.evidence_links, "source Evidence links");
|
|
142
|
+
parseResult(evidence);
|
|
143
|
+
return evidence;
|
|
144
|
+
};
|
|
145
|
+
const parseResult = (evidence) => {
|
|
146
|
+
if (evidence.operation === "compare_process_captures") {
|
|
147
|
+
const result = processCaptureComparisonSchema.parse(evidence.normalized_result);
|
|
148
|
+
const dimensions = [
|
|
149
|
+
result.terminal,
|
|
150
|
+
result.exit,
|
|
151
|
+
result.filesystem,
|
|
152
|
+
result.protocol,
|
|
153
|
+
result.process,
|
|
154
|
+
];
|
|
155
|
+
const expected = dimensions.includes("truncated")
|
|
156
|
+
? "truncated"
|
|
157
|
+
: dimensions.some(isChange)
|
|
158
|
+
? "changed"
|
|
159
|
+
: dimensions.includes("unknown")
|
|
160
|
+
? "unknown"
|
|
161
|
+
: "unchanged";
|
|
162
|
+
if (result.status !== expected)
|
|
163
|
+
throw new TypeError("Process comparison status contradicts its dimensions");
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (evidence.operation === "compare_functions") {
|
|
167
|
+
const result = functionComparisonResultSchema.parse(evidence.normalized_result);
|
|
168
|
+
assertNestedLinks(evidence, result.dimensions.flatMap(({ evidence_links: links }) => links));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const result = artifactComparisonResultSchema.parse(evidence.normalized_result);
|
|
172
|
+
if (result.changes.offset !== 0 ||
|
|
173
|
+
result.changes.next_offset !== null ||
|
|
174
|
+
result.changes.items.length !== result.changes.total)
|
|
175
|
+
throw new TypeError("Changed-behavior analysis requires complete artifact comparison pagination");
|
|
176
|
+
assertNestedLinks(evidence, result.changes.items.flatMap(({ evidence_links: links }) => links));
|
|
177
|
+
};
|
|
178
|
+
const assertNestedLinks = (evidence, nestedLinks) => {
|
|
179
|
+
const topLevel = new Set(evidence.evidence_links);
|
|
180
|
+
if (nestedLinks.some((evidenceId) => !topLevel.has(evidenceId)))
|
|
181
|
+
throw new TypeError("Comparison finding cites Evidence outside its top-level closure");
|
|
182
|
+
};
|
|
183
|
+
const findingsFor = (evidence) => {
|
|
184
|
+
if (evidence.operation === "compare_process_captures")
|
|
185
|
+
return processFindings(evidence);
|
|
186
|
+
if (evidence.operation === "compare_functions")
|
|
187
|
+
return functionFindings(evidence);
|
|
188
|
+
return artifactFindings(evidence);
|
|
189
|
+
};
|
|
190
|
+
const processFindings = (evidence) => {
|
|
191
|
+
const result = processCaptureComparisonSchema.parse(evidence.normalized_result);
|
|
192
|
+
const dimensions = [
|
|
193
|
+
["terminal", result.terminal, "runtime"],
|
|
194
|
+
["exit", result.exit, "runtime"],
|
|
195
|
+
["filesystem", result.filesystem, "resource"],
|
|
196
|
+
["protocol", result.protocol, "protocol"],
|
|
197
|
+
["process", result.process, "runtime"],
|
|
198
|
+
];
|
|
199
|
+
return dimensions.flatMap(([dimension, status, scope]) => status === "unchanged"
|
|
200
|
+
? []
|
|
201
|
+
: [
|
|
202
|
+
makeFinding({
|
|
203
|
+
evidence,
|
|
204
|
+
dimension,
|
|
205
|
+
scope,
|
|
206
|
+
status,
|
|
207
|
+
classification: isChange(status)
|
|
208
|
+
? "observed_change"
|
|
209
|
+
: "unresolved_branch",
|
|
210
|
+
limitations: result.limitations,
|
|
211
|
+
}),
|
|
212
|
+
]);
|
|
213
|
+
};
|
|
214
|
+
const functionFindings = (evidence) => {
|
|
215
|
+
const result = functionComparisonResultSchema.parse(evidence.normalized_result);
|
|
216
|
+
return result.dimensions.flatMap((dimension) => dimension.status === "unchanged"
|
|
217
|
+
? []
|
|
218
|
+
: [
|
|
219
|
+
makeFinding({
|
|
220
|
+
evidence,
|
|
221
|
+
dimension: dimension.dimension,
|
|
222
|
+
scope: "static_candidate",
|
|
223
|
+
status: dimension.status,
|
|
224
|
+
classification: dimension.status === "unknown" || dimension.status === "truncated"
|
|
225
|
+
? "unresolved_branch"
|
|
226
|
+
: dimension.conclusion_kind,
|
|
227
|
+
limitations: dimension.limitations,
|
|
228
|
+
links: dimension.evidence_links,
|
|
229
|
+
}),
|
|
230
|
+
]);
|
|
231
|
+
};
|
|
232
|
+
const artifactFindings = (evidence) => {
|
|
233
|
+
const result = artifactComparisonResultSchema.parse(evidence.normalized_result);
|
|
234
|
+
const changes = result.changes.items.map((change) => makeFinding({
|
|
235
|
+
evidence,
|
|
236
|
+
dimension: `artifact:${change.logical_path}`,
|
|
237
|
+
scope: "static_candidate",
|
|
238
|
+
status: change.classification,
|
|
239
|
+
classification: change.classification === "unknown"
|
|
240
|
+
? "unresolved_branch"
|
|
241
|
+
: "derived_relationship",
|
|
242
|
+
limitations: result.limitations,
|
|
243
|
+
links: change.evidence_links,
|
|
244
|
+
}));
|
|
245
|
+
return (result.status === "unknown" || result.status === "truncated") &&
|
|
246
|
+
!changes.some(({ classification }) => classification === "unresolved_branch")
|
|
247
|
+
? [
|
|
248
|
+
...changes,
|
|
249
|
+
makeFinding({
|
|
250
|
+
evidence,
|
|
251
|
+
dimension: "artifact:coverage",
|
|
252
|
+
scope: "static_candidate",
|
|
253
|
+
status: result.status,
|
|
254
|
+
classification: "unresolved_branch",
|
|
255
|
+
limitations: result.limitations,
|
|
256
|
+
}),
|
|
257
|
+
]
|
|
258
|
+
: changes;
|
|
259
|
+
};
|
|
260
|
+
const makeFinding = (input) => findingSchema.parse({
|
|
261
|
+
scope: input.scope,
|
|
262
|
+
dimension: input.dimension,
|
|
263
|
+
classification: input.classification,
|
|
264
|
+
status: input.status,
|
|
265
|
+
source_comparison_id: input.evidence.evidence_id,
|
|
266
|
+
evidence_links: uniqueSorted([
|
|
267
|
+
input.evidence.evidence_id,
|
|
268
|
+
...(input.links ?? input.evidence.evidence_links),
|
|
269
|
+
]),
|
|
270
|
+
limitations: uniqueSorted(input.limitations),
|
|
271
|
+
});
|
|
272
|
+
const runtimeStatus = (evidence) => evidence.operation === "compare_process_captures"
|
|
273
|
+
? [processCaptureComparisonSchema.parse(evidence.normalized_result).status]
|
|
274
|
+
: [];
|
|
275
|
+
const behaviorStatus = (statuses) => {
|
|
276
|
+
if (statuses.length === 0)
|
|
277
|
+
return "unknown";
|
|
278
|
+
if (statuses.includes("truncated"))
|
|
279
|
+
return "truncated";
|
|
280
|
+
if (statuses.includes("unknown"))
|
|
281
|
+
return "unknown";
|
|
282
|
+
return statuses.some(isChange) ? "observed_changed" : "observed_unchanged";
|
|
283
|
+
};
|
|
284
|
+
const isChange = (status) => status === "added" || status === "removed" || status === "changed";
|
|
285
|
+
const count = (findings, classification) => findings.filter((finding) => finding.classification === classification)
|
|
286
|
+
.length;
|
|
287
|
+
const compareFindings = (left, right) => [left.scope, left.dimension, left.source_comparison_id]
|
|
288
|
+
.join("\0")
|
|
289
|
+
.localeCompare([right.scope, right.dimension, right.source_comparison_id].join("\0"), "en");
|
|
290
|
+
const uniqueSorted = (values) => [...new Set(values)].sort((left, right) => left.localeCompare(right, "en"));
|
|
291
|
+
const assertUnique = (values, label) => {
|
|
292
|
+
if (new Set(values).size !== values.length)
|
|
293
|
+
throw new TypeError(`Changed-behavior analysis rejects duplicate ${label}`);
|
|
294
|
+
};
|