execweave 0.6.0__py3-none-any.whl
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.
- execweave/__init__.py +3 -0
- execweave/__main__.py +5 -0
- execweave/analysis.py +406 -0
- execweave/backends.py +63 -0
- execweave/benchmark.py +80 -0
- execweave/claude_adapter.py +448 -0
- execweave/claude_hook_cli.py +101 -0
- execweave/claude_record.py +106 -0
- execweave/cli.py +588 -0
- execweave/codex_adapter.py +314 -0
- execweave/codex_hook_cli.py +98 -0
- execweave/codex_record.py +111 -0
- execweave/collector.py +301 -0
- execweave/correlation.py +604 -0
- execweave/cursor_adapter.py +347 -0
- execweave/cursor_hook_cli.py +82 -0
- execweave/cursor_record.py +96 -0
- execweave/filesystem.py +103 -0
- execweave/focus.py +118 -0
- execweave/gemini_adapter.py +265 -0
- execweave/gemini_hook_cli.py +77 -0
- execweave/gemini_record.py +94 -0
- execweave/graph.py +300 -0
- execweave/graph_ops.py +446 -0
- execweave/inference_gateway.py +422 -0
- execweave/inference_gateway_cli.py +106 -0
- execweave/inference_identity.py +76 -0
- execweave/inference_identity_cli.py +60 -0
- execweave/live.py +275 -0
- execweave/model_runtime.py +535 -0
- execweave/model_runtime_cli.py +154 -0
- execweave/opencode_adapter.py +316 -0
- execweave/opencode_hook_cli.py +57 -0
- execweave/opencode_plugin_cli.py +110 -0
- execweave/opencode_record.py +96 -0
- execweave/overhead_benchmark.py +440 -0
- execweave/provider_record.py +215 -0
- execweave/schema.py +62 -0
- execweave/semantic.py +346 -0
- execweave/sink.py +33 -0
- execweave/strace_backend.py +682 -0
- execweave/validate.py +193 -0
- execweave/viewer.py +283 -0
- execweave/workflow.py +114 -0
- execweave-0.6.0.dist-info/METADATA +356 -0
- execweave-0.6.0.dist-info/RECORD +49 -0
- execweave-0.6.0.dist-info/WHEEL +4 -0
- execweave-0.6.0.dist-info/entry_points.txt +17 -0
- execweave-0.6.0.dist-info/licenses/LICENSE +21 -0
execweave/correlation.py
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from copy import deepcopy
|
|
8
|
+
from dataclasses import asdict, dataclass, field
|
|
9
|
+
from datetime import datetime, timedelta, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .schema import SCHEMA_VERSION
|
|
14
|
+
from .validate import validate_event_stream
|
|
15
|
+
|
|
16
|
+
_RELATION = "CORRELATED_WITH_PROCESS"
|
|
17
|
+
_EVENT_TYPE = "inference.tool_process.correlation"
|
|
18
|
+
_BUILTINS = {
|
|
19
|
+
".",
|
|
20
|
+
"[",
|
|
21
|
+
"alias",
|
|
22
|
+
"bg",
|
|
23
|
+
"break",
|
|
24
|
+
"cd",
|
|
25
|
+
"command",
|
|
26
|
+
"continue",
|
|
27
|
+
"echo",
|
|
28
|
+
"eval",
|
|
29
|
+
"exec",
|
|
30
|
+
"exit",
|
|
31
|
+
"export",
|
|
32
|
+
"false",
|
|
33
|
+
"fg",
|
|
34
|
+
"hash",
|
|
35
|
+
"jobs",
|
|
36
|
+
"printf",
|
|
37
|
+
"pwd",
|
|
38
|
+
"read",
|
|
39
|
+
"return",
|
|
40
|
+
"set",
|
|
41
|
+
"shift",
|
|
42
|
+
"source",
|
|
43
|
+
"test",
|
|
44
|
+
"trap",
|
|
45
|
+
"true",
|
|
46
|
+
"type",
|
|
47
|
+
"ulimit",
|
|
48
|
+
"umask",
|
|
49
|
+
"unalias",
|
|
50
|
+
"unset",
|
|
51
|
+
"wait",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class CorrelationResult:
|
|
57
|
+
session_id: str
|
|
58
|
+
input_event_count: int
|
|
59
|
+
output_event_count: int
|
|
60
|
+
tool_calls_considered: int
|
|
61
|
+
correlated_tool_calls: int
|
|
62
|
+
skipped_unsupported: int
|
|
63
|
+
skipped_no_match: int
|
|
64
|
+
skipped_ambiguous: int
|
|
65
|
+
max_window_ms: int
|
|
66
|
+
output: str
|
|
67
|
+
|
|
68
|
+
def to_dict(self) -> dict[str, object]:
|
|
69
|
+
return asdict(self)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class _Declaration:
|
|
74
|
+
event: dict[str, Any]
|
|
75
|
+
timestamp: datetime
|
|
76
|
+
tool_call: dict[str, Any]
|
|
77
|
+
command_entity: dict[str, Any]
|
|
78
|
+
command_token: str
|
|
79
|
+
command_head: str
|
|
80
|
+
command_argv: tuple[str, ...]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class _Candidate:
|
|
85
|
+
process: dict[str, Any]
|
|
86
|
+
latest_timestamp: datetime
|
|
87
|
+
latest_timestamp_text: str
|
|
88
|
+
supporting_event_ids: set[str] = field(default_factory=set)
|
|
89
|
+
has_exec_match: bool = False
|
|
90
|
+
has_process_exe_match: bool = False
|
|
91
|
+
has_cmdline_match: bool = False
|
|
92
|
+
has_argv_tail_match: bool = False
|
|
93
|
+
|
|
94
|
+
def observe(
|
|
95
|
+
self,
|
|
96
|
+
event: dict[str, Any],
|
|
97
|
+
*,
|
|
98
|
+
timestamp: datetime,
|
|
99
|
+
exec_match: bool = False,
|
|
100
|
+
process_exe_match: bool = False,
|
|
101
|
+
cmdline_match: bool = False,
|
|
102
|
+
argv_tail_match: bool = False,
|
|
103
|
+
) -> None:
|
|
104
|
+
if timestamp >= self.latest_timestamp:
|
|
105
|
+
self.latest_timestamp = timestamp
|
|
106
|
+
self.latest_timestamp_text = str(event.get("timestamp"))
|
|
107
|
+
event_id = event.get("event_id")
|
|
108
|
+
if isinstance(event_id, str) and event_id:
|
|
109
|
+
self.supporting_event_ids.add(event_id)
|
|
110
|
+
self.has_exec_match = self.has_exec_match or exec_match
|
|
111
|
+
self.has_process_exe_match = self.has_process_exe_match or process_exe_match
|
|
112
|
+
self.has_cmdline_match = self.has_cmdline_match or cmdline_match
|
|
113
|
+
self.has_argv_tail_match = self.has_argv_tail_match or argv_tail_match
|
|
114
|
+
|
|
115
|
+
def method_and_confidence(self) -> tuple[str, float]:
|
|
116
|
+
if self.has_exec_match and self.has_cmdline_match:
|
|
117
|
+
return "unique_exec_and_cmdline_match", 0.95
|
|
118
|
+
if self.has_exec_match:
|
|
119
|
+
return "unique_exec_identity_match", 0.90
|
|
120
|
+
if self.has_process_exe_match and self.has_cmdline_match:
|
|
121
|
+
return "unique_process_exe_and_cmdline_match", 0.90
|
|
122
|
+
if self.has_process_exe_match:
|
|
123
|
+
return "unique_process_exe_match", 0.85
|
|
124
|
+
if self.has_cmdline_match:
|
|
125
|
+
return "unique_process_cmdline_match", 0.80
|
|
126
|
+
if self.has_argv_tail_match:
|
|
127
|
+
return "unique_process_argv_tail_match", 0.80
|
|
128
|
+
raise RuntimeError("correlation candidate has no matching evidence")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _parse_timestamp(value: object, *, context: str) -> datetime:
|
|
132
|
+
if not isinstance(value, str) or not value:
|
|
133
|
+
raise ValueError(f"{context}: timestamp must be a non-empty ISO-8601 string")
|
|
134
|
+
try:
|
|
135
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
136
|
+
except ValueError as exc:
|
|
137
|
+
raise ValueError(f"{context}: timestamp must be ISO-8601") from exc
|
|
138
|
+
if parsed.tzinfo is None:
|
|
139
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
140
|
+
return parsed.astimezone(timezone.utc)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _load_jsonl(path: Path) -> list[dict[str, Any]]:
|
|
144
|
+
events: list[dict[str, Any]] = []
|
|
145
|
+
for line_number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
|
|
146
|
+
if not raw.strip():
|
|
147
|
+
continue
|
|
148
|
+
try:
|
|
149
|
+
payload = json.loads(raw)
|
|
150
|
+
except json.JSONDecodeError as exc:
|
|
151
|
+
raise ValueError(f"line {line_number}: invalid JSON: {exc.msg}") from exc
|
|
152
|
+
if not isinstance(payload, dict):
|
|
153
|
+
raise ValueError(f"line {line_number}: event must be an object")
|
|
154
|
+
events.append(payload)
|
|
155
|
+
return events
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _clean_executable(value: object) -> str | None:
|
|
159
|
+
if not isinstance(value, str) or not value.strip():
|
|
160
|
+
return None
|
|
161
|
+
return value.strip().strip('"').strip("'") or None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _normalize_executable(value: object) -> str | None:
|
|
165
|
+
text = _clean_executable(value)
|
|
166
|
+
if text is None:
|
|
167
|
+
return None
|
|
168
|
+
name = text.replace("\\", "/").rsplit("/", 1)[-1].lower()
|
|
169
|
+
if name.endswith(".exe"):
|
|
170
|
+
name = name[:-4]
|
|
171
|
+
return name or None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _canonical_executable_path(value: object) -> str | None:
|
|
175
|
+
text = _clean_executable(value)
|
|
176
|
+
if text is None:
|
|
177
|
+
return None
|
|
178
|
+
if "/" not in text and "\\" not in text and not Path(text).is_absolute():
|
|
179
|
+
return None
|
|
180
|
+
try:
|
|
181
|
+
resolved = Path(text).expanduser().resolve(strict=False)
|
|
182
|
+
except (OSError, RuntimeError, ValueError):
|
|
183
|
+
return None
|
|
184
|
+
return os.path.normcase(str(resolved))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _same_executable_identity(candidate: object, declaration: _Declaration) -> bool:
|
|
188
|
+
if _normalize_executable(candidate) == declaration.command_head:
|
|
189
|
+
return True
|
|
190
|
+
declared_path = _canonical_executable_path(declaration.command_token)
|
|
191
|
+
candidate_path = _canonical_executable_path(candidate)
|
|
192
|
+
return declared_path is not None and candidate_path is not None and declared_path == candidate_path
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _has_shell_control(command: str) -> bool:
|
|
196
|
+
quote: str | None = None
|
|
197
|
+
escaped = False
|
|
198
|
+
index = 0
|
|
199
|
+
while index < len(command):
|
|
200
|
+
char = command[index]
|
|
201
|
+
if escaped:
|
|
202
|
+
escaped = False
|
|
203
|
+
index += 1
|
|
204
|
+
continue
|
|
205
|
+
if quote == "'":
|
|
206
|
+
if char == "'":
|
|
207
|
+
quote = None
|
|
208
|
+
index += 1
|
|
209
|
+
continue
|
|
210
|
+
if quote == '"':
|
|
211
|
+
if char == '"':
|
|
212
|
+
quote = None
|
|
213
|
+
elif char == "\\":
|
|
214
|
+
escaped = True
|
|
215
|
+
index += 1
|
|
216
|
+
continue
|
|
217
|
+
if char in {"'", '"'}:
|
|
218
|
+
quote = char
|
|
219
|
+
index += 1
|
|
220
|
+
continue
|
|
221
|
+
if char in "\r\n;&|<>`":
|
|
222
|
+
return True
|
|
223
|
+
if char == "$" and index + 1 < len(command) and command[index + 1] == "(":
|
|
224
|
+
return True
|
|
225
|
+
if char == "\\":
|
|
226
|
+
escaped = True
|
|
227
|
+
index += 1
|
|
228
|
+
return quote is not None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _command_argv(command: str) -> tuple[str, ...] | None:
|
|
232
|
+
if _has_shell_control(command):
|
|
233
|
+
return None
|
|
234
|
+
tokens: list[str] = []
|
|
235
|
+
chars: list[str] = []
|
|
236
|
+
quote: str | None = None
|
|
237
|
+
token_started = False
|
|
238
|
+
index = 0
|
|
239
|
+
while index < len(command):
|
|
240
|
+
char = command[index]
|
|
241
|
+
if quote is not None:
|
|
242
|
+
if char == quote:
|
|
243
|
+
quote = None
|
|
244
|
+
token_started = True
|
|
245
|
+
elif (
|
|
246
|
+
quote == '"'
|
|
247
|
+
and char == "\\"
|
|
248
|
+
and index + 1 < len(command)
|
|
249
|
+
and command[index + 1] == '"'
|
|
250
|
+
):
|
|
251
|
+
chars.append('"')
|
|
252
|
+
token_started = True
|
|
253
|
+
index += 1
|
|
254
|
+
else:
|
|
255
|
+
chars.append(char)
|
|
256
|
+
token_started = True
|
|
257
|
+
elif char.isspace():
|
|
258
|
+
if token_started:
|
|
259
|
+
tokens.append("".join(chars))
|
|
260
|
+
chars = []
|
|
261
|
+
token_started = False
|
|
262
|
+
elif char in {"'", '"'}:
|
|
263
|
+
quote = char
|
|
264
|
+
token_started = True
|
|
265
|
+
else:
|
|
266
|
+
chars.append(char)
|
|
267
|
+
token_started = True
|
|
268
|
+
index += 1
|
|
269
|
+
if quote is not None:
|
|
270
|
+
return None
|
|
271
|
+
if token_started:
|
|
272
|
+
tokens.append("".join(chars))
|
|
273
|
+
return tuple(tokens)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _command_identity(command: str) -> tuple[str, str, tuple[str, ...]] | None:
|
|
277
|
+
argv = _command_argv(command)
|
|
278
|
+
if not argv:
|
|
279
|
+
return None
|
|
280
|
+
token = argv[0]
|
|
281
|
+
if "=" in token:
|
|
282
|
+
return None
|
|
283
|
+
head = _normalize_executable(token)
|
|
284
|
+
if head is None or head in _BUILTINS:
|
|
285
|
+
return None
|
|
286
|
+
return token, head, argv
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _declarations(events: list[dict[str, Any]]) -> list[_Declaration]:
|
|
290
|
+
result: list[_Declaration] = []
|
|
291
|
+
for index, event in enumerate(events, start=1):
|
|
292
|
+
if event.get("relation") != "DECLARED_COMMAND":
|
|
293
|
+
continue
|
|
294
|
+
source = event.get("source")
|
|
295
|
+
target = event.get("target")
|
|
296
|
+
if not isinstance(source, dict) or source.get("type") != "tool_call":
|
|
297
|
+
continue
|
|
298
|
+
if not isinstance(target, dict) or target.get("type") != "command":
|
|
299
|
+
continue
|
|
300
|
+
target_attributes = target.get("attributes") or {}
|
|
301
|
+
command = target_attributes.get("command") if isinstance(target_attributes, dict) else None
|
|
302
|
+
if not isinstance(command, str) or not command:
|
|
303
|
+
continue
|
|
304
|
+
identity = _command_identity(command)
|
|
305
|
+
if identity is None:
|
|
306
|
+
token, head, argv = "", "", ()
|
|
307
|
+
else:
|
|
308
|
+
token, head, argv = identity
|
|
309
|
+
result.append(
|
|
310
|
+
_Declaration(
|
|
311
|
+
event=event,
|
|
312
|
+
timestamp=_parse_timestamp(event.get("timestamp"), context=f"event {index}"),
|
|
313
|
+
tool_call=deepcopy(source),
|
|
314
|
+
command_entity=deepcopy(target),
|
|
315
|
+
command_token=token,
|
|
316
|
+
command_head=head,
|
|
317
|
+
command_argv=argv,
|
|
318
|
+
)
|
|
319
|
+
)
|
|
320
|
+
result.sort(key=lambda item: item.timestamp)
|
|
321
|
+
return result
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _result_times(events: list[dict[str, Any]]) -> dict[str, list[datetime]]:
|
|
325
|
+
result: dict[str, list[datetime]] = {}
|
|
326
|
+
for index, event in enumerate(events, start=1):
|
|
327
|
+
if event.get("relation") not in {"TOOL_CALL_SUCCEEDED", "TOOL_CALL_FAILED"}:
|
|
328
|
+
continue
|
|
329
|
+
source = event.get("source")
|
|
330
|
+
if not isinstance(source, dict):
|
|
331
|
+
continue
|
|
332
|
+
source_id = source.get("id")
|
|
333
|
+
if not isinstance(source_id, str) or not source_id:
|
|
334
|
+
continue
|
|
335
|
+
result.setdefault(source_id, []).append(
|
|
336
|
+
_parse_timestamp(event.get("timestamp"), context=f"event {index}")
|
|
337
|
+
)
|
|
338
|
+
for values in result.values():
|
|
339
|
+
values.sort()
|
|
340
|
+
return result
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _event_time(event: dict[str, Any], *, index: int) -> datetime:
|
|
344
|
+
return _parse_timestamp(event.get("timestamp"), context=f"event {index}")
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _candidate_processes(
|
|
348
|
+
events: list[dict[str, Any]],
|
|
349
|
+
*,
|
|
350
|
+
declaration: _Declaration,
|
|
351
|
+
start: datetime,
|
|
352
|
+
end: datetime,
|
|
353
|
+
) -> dict[str, _Candidate]:
|
|
354
|
+
candidates: dict[str, _Candidate] = {}
|
|
355
|
+
for index, event in enumerate(events, start=1):
|
|
356
|
+
event_type = event.get("event_type")
|
|
357
|
+
relation = event.get("relation")
|
|
358
|
+
if event_type not in {"process.started", "process.exec"}:
|
|
359
|
+
continue
|
|
360
|
+
timestamp = _event_time(event, index=index)
|
|
361
|
+
if timestamp < start or timestamp > end:
|
|
362
|
+
continue
|
|
363
|
+
|
|
364
|
+
process: dict[str, Any] | None = None
|
|
365
|
+
exec_match = False
|
|
366
|
+
process_exe_match = False
|
|
367
|
+
cmdline_match = False
|
|
368
|
+
argv_tail_match = False
|
|
369
|
+
if event_type == "process.exec" and relation == "EXECUTED":
|
|
370
|
+
source = event.get("source")
|
|
371
|
+
target = event.get("target")
|
|
372
|
+
if not isinstance(source, dict) or source.get("type") != "process":
|
|
373
|
+
continue
|
|
374
|
+
if not isinstance(target, dict) or target.get("type") != "executable":
|
|
375
|
+
continue
|
|
376
|
+
executable_value: object = target.get("name")
|
|
377
|
+
executable_id = target.get("id")
|
|
378
|
+
if isinstance(executable_id, str) and executable_id.startswith("executable:"):
|
|
379
|
+
executable_value = executable_id[len("executable:") :]
|
|
380
|
+
if not _same_executable_identity(executable_value, declaration):
|
|
381
|
+
continue
|
|
382
|
+
process = deepcopy(source)
|
|
383
|
+
exec_match = True
|
|
384
|
+
elif event_type == "process.started":
|
|
385
|
+
target = event.get("target")
|
|
386
|
+
if not isinstance(target, dict) or target.get("type") != "process":
|
|
387
|
+
continue
|
|
388
|
+
attributes = target.get("attributes") or {}
|
|
389
|
+
if not isinstance(attributes, dict):
|
|
390
|
+
continue
|
|
391
|
+
process_exe_match = _same_executable_identity(attributes.get("exe"), declaration)
|
|
392
|
+
cmdline = attributes.get("cmdline")
|
|
393
|
+
if isinstance(cmdline, list) and cmdline and all(isinstance(item, str) for item in cmdline):
|
|
394
|
+
cmdline_match = _same_executable_identity(cmdline[0], declaration)
|
|
395
|
+
argv_tail_match = (
|
|
396
|
+
len(declaration.command_argv) > 1
|
|
397
|
+
and len(cmdline) == len(declaration.command_argv)
|
|
398
|
+
and tuple(cmdline[1:]) == declaration.command_argv[1:]
|
|
399
|
+
)
|
|
400
|
+
if not process_exe_match and not cmdline_match and not argv_tail_match:
|
|
401
|
+
continue
|
|
402
|
+
process = deepcopy(target)
|
|
403
|
+
if process is None:
|
|
404
|
+
continue
|
|
405
|
+
|
|
406
|
+
process_id = process.get("id")
|
|
407
|
+
if not isinstance(process_id, str) or not process_id:
|
|
408
|
+
continue
|
|
409
|
+
candidate = candidates.get(process_id)
|
|
410
|
+
if candidate is None:
|
|
411
|
+
candidate = _Candidate(
|
|
412
|
+
process=process,
|
|
413
|
+
latest_timestamp=timestamp,
|
|
414
|
+
latest_timestamp_text=str(event.get("timestamp")),
|
|
415
|
+
)
|
|
416
|
+
candidates[process_id] = candidate
|
|
417
|
+
candidate.observe(
|
|
418
|
+
event,
|
|
419
|
+
timestamp=timestamp,
|
|
420
|
+
exec_match=exec_match,
|
|
421
|
+
process_exe_match=process_exe_match,
|
|
422
|
+
cmdline_match=cmdline_match,
|
|
423
|
+
argv_tail_match=argv_tail_match,
|
|
424
|
+
)
|
|
425
|
+
return candidates
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _derived_event(
|
|
429
|
+
declaration: _Declaration,
|
|
430
|
+
candidate: _Candidate,
|
|
431
|
+
*,
|
|
432
|
+
session_id: str,
|
|
433
|
+
max_window_ms: int,
|
|
434
|
+
) -> dict[str, Any]:
|
|
435
|
+
method, confidence = candidate.method_and_confidence()
|
|
436
|
+
support = set(candidate.supporting_event_ids)
|
|
437
|
+
declaration_event_id = declaration.event.get("event_id")
|
|
438
|
+
if isinstance(declaration_event_id, str) and declaration_event_id:
|
|
439
|
+
support.add(declaration_event_id)
|
|
440
|
+
|
|
441
|
+
process_id = str(candidate.process.get("id"))
|
|
442
|
+
tool_call_id = str(declaration.tool_call.get("id"))
|
|
443
|
+
digest_source = "|".join(
|
|
444
|
+
[session_id, tool_call_id, process_id, method, *sorted(support)]
|
|
445
|
+
).encode("utf-8", errors="replace")
|
|
446
|
+
digest = hashlib.sha256(digest_source).hexdigest()[:24]
|
|
447
|
+
delta_ms = max(
|
|
448
|
+
0.0,
|
|
449
|
+
(candidate.latest_timestamp - declaration.timestamp).total_seconds() * 1000.0,
|
|
450
|
+
)
|
|
451
|
+
return {
|
|
452
|
+
"schema_version": SCHEMA_VERSION,
|
|
453
|
+
"event_id": f"inference:tool-process:{digest}",
|
|
454
|
+
"session_id": session_id,
|
|
455
|
+
"timestamp": candidate.latest_timestamp_text,
|
|
456
|
+
"event_type": _EVENT_TYPE,
|
|
457
|
+
"relation": _RELATION,
|
|
458
|
+
"source": deepcopy(declaration.tool_call),
|
|
459
|
+
"target": deepcopy(candidate.process),
|
|
460
|
+
"sequence": None,
|
|
461
|
+
"attributes": {
|
|
462
|
+
"backend": "inference",
|
|
463
|
+
"attribution": "execweave_correlation",
|
|
464
|
+
"evidence_source": "derived",
|
|
465
|
+
"causal": False,
|
|
466
|
+
"inferred": True,
|
|
467
|
+
"inference_method": method,
|
|
468
|
+
"confidence": confidence,
|
|
469
|
+
"confidence_semantics": "heuristic_score_not_probability",
|
|
470
|
+
"candidate_count": 1,
|
|
471
|
+
"declared_command_head": declaration.command_head,
|
|
472
|
+
"declared_command_token": declaration.command_token,
|
|
473
|
+
"tool_call_id": tool_call_id,
|
|
474
|
+
"command_entity_id": declaration.command_entity.get("id"),
|
|
475
|
+
"time_delta_ms": round(delta_ms, 3),
|
|
476
|
+
"max_window_ms": max_window_ms,
|
|
477
|
+
"supporting_event_ids": sorted(support),
|
|
478
|
+
},
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def correlate_tool_process(
|
|
483
|
+
input_path: str | Path,
|
|
484
|
+
output_path: str | Path,
|
|
485
|
+
*,
|
|
486
|
+
max_window_ms: int = 3000,
|
|
487
|
+
) -> CorrelationResult:
|
|
488
|
+
if max_window_ms <= 0:
|
|
489
|
+
raise ValueError("max_window_ms must be greater than zero")
|
|
490
|
+
source = Path(input_path).expanduser().resolve()
|
|
491
|
+
output = Path(output_path).expanduser().resolve()
|
|
492
|
+
if output.exists() and output.stat().st_size > 0:
|
|
493
|
+
raise FileExistsError(f"ExecWeave correlation output already exists: {output}")
|
|
494
|
+
|
|
495
|
+
validation = validate_event_stream(source, require_complete_session=True)
|
|
496
|
+
if not validation.valid:
|
|
497
|
+
raise ValueError("invalid input event stream: " + "; ".join(validation.errors))
|
|
498
|
+
events = _load_jsonl(source)
|
|
499
|
+
session_id = validation.session_ids[0]
|
|
500
|
+
declarations = _declarations(events)
|
|
501
|
+
results = _result_times(events)
|
|
502
|
+
derived: list[dict[str, Any]] = []
|
|
503
|
+
skipped_unsupported = 0
|
|
504
|
+
skipped_no_match = 0
|
|
505
|
+
skipped_ambiguous = 0
|
|
506
|
+
|
|
507
|
+
for position, declaration in enumerate(declarations):
|
|
508
|
+
if not declaration.command_head:
|
|
509
|
+
skipped_unsupported += 1
|
|
510
|
+
continue
|
|
511
|
+
|
|
512
|
+
window_end = declaration.timestamp + timedelta(milliseconds=max_window_ms)
|
|
513
|
+
tool_call_id = declaration.tool_call.get("id")
|
|
514
|
+
if isinstance(tool_call_id, str):
|
|
515
|
+
for result_time in results.get(tool_call_id, []):
|
|
516
|
+
if result_time >= declaration.timestamp:
|
|
517
|
+
window_end = min(window_end, result_time)
|
|
518
|
+
break
|
|
519
|
+
if position + 1 < len(declarations):
|
|
520
|
+
next_time = declarations[position + 1].timestamp
|
|
521
|
+
if next_time > declaration.timestamp:
|
|
522
|
+
window_end = min(window_end, next_time)
|
|
523
|
+
|
|
524
|
+
candidates = _candidate_processes(
|
|
525
|
+
events,
|
|
526
|
+
declaration=declaration,
|
|
527
|
+
start=declaration.timestamp,
|
|
528
|
+
end=window_end,
|
|
529
|
+
)
|
|
530
|
+
if not candidates:
|
|
531
|
+
skipped_no_match += 1
|
|
532
|
+
continue
|
|
533
|
+
if len(candidates) != 1:
|
|
534
|
+
skipped_ambiguous += 1
|
|
535
|
+
continue
|
|
536
|
+
derived.append(
|
|
537
|
+
_derived_event(
|
|
538
|
+
declaration,
|
|
539
|
+
next(iter(candidates.values())),
|
|
540
|
+
session_id=session_id,
|
|
541
|
+
max_window_ms=max_window_ms,
|
|
542
|
+
)
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
starts = [event for event in events if event.get("event_type") == "session.started"]
|
|
546
|
+
finishes = [event for event in events if event.get("event_type") == "session.finished"]
|
|
547
|
+
if len(starts) != 1 or len(finishes) != 1:
|
|
548
|
+
raise ValueError("input event stream must contain exactly one session start and finish")
|
|
549
|
+
|
|
550
|
+
body = [
|
|
551
|
+
deepcopy(event)
|
|
552
|
+
for event in events
|
|
553
|
+
if event.get("event_type") not in {"session.started", "session.finished"}
|
|
554
|
+
]
|
|
555
|
+
decorated: list[tuple[datetime, int, int, dict[str, Any]]] = []
|
|
556
|
+
for index, event in enumerate(body):
|
|
557
|
+
decorated.append((_event_time(event, index=index + 1), 0, index, event))
|
|
558
|
+
for index, event in enumerate(derived):
|
|
559
|
+
decorated.append((_event_time(event, index=index + 1), 1, index, event))
|
|
560
|
+
decorated.sort(key=lambda item: (item[0], item[1], item[2]))
|
|
561
|
+
|
|
562
|
+
correlated = [deepcopy(starts[0]), *[item[3] for item in decorated], deepcopy(finishes[0])]
|
|
563
|
+
for sequence, event in enumerate(correlated, start=1):
|
|
564
|
+
event["sequence"] = sequence
|
|
565
|
+
|
|
566
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
567
|
+
fd, temp_name = tempfile.mkstemp(
|
|
568
|
+
prefix=".execweave-correlate-",
|
|
569
|
+
suffix=".jsonl",
|
|
570
|
+
dir=output.parent,
|
|
571
|
+
)
|
|
572
|
+
os.close(fd)
|
|
573
|
+
temp_path = Path(temp_name)
|
|
574
|
+
try:
|
|
575
|
+
temp_path.write_text(
|
|
576
|
+
"".join(
|
|
577
|
+
json.dumps(event, ensure_ascii=False, sort_keys=True) + "\n"
|
|
578
|
+
for event in correlated
|
|
579
|
+
),
|
|
580
|
+
encoding="utf-8",
|
|
581
|
+
)
|
|
582
|
+
correlated_validation = validate_event_stream(temp_path, require_complete_session=True)
|
|
583
|
+
if not correlated_validation.valid:
|
|
584
|
+
raise ValueError(
|
|
585
|
+
"correlated event stream is invalid: "
|
|
586
|
+
+ "; ".join(correlated_validation.errors)
|
|
587
|
+
)
|
|
588
|
+
temp_path.replace(output)
|
|
589
|
+
finally:
|
|
590
|
+
if temp_path.exists():
|
|
591
|
+
temp_path.unlink()
|
|
592
|
+
|
|
593
|
+
return CorrelationResult(
|
|
594
|
+
session_id=session_id,
|
|
595
|
+
input_event_count=len(events),
|
|
596
|
+
output_event_count=len(correlated),
|
|
597
|
+
tool_calls_considered=len(declarations),
|
|
598
|
+
correlated_tool_calls=len(derived),
|
|
599
|
+
skipped_unsupported=skipped_unsupported,
|
|
600
|
+
skipped_no_match=skipped_no_match,
|
|
601
|
+
skipped_ambiguous=skipped_ambiguous,
|
|
602
|
+
max_window_ms=max_window_ms,
|
|
603
|
+
output=str(output),
|
|
604
|
+
)
|