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/__init__.py
ADDED
execweave/__main__.py
ADDED
execweave/analysis.py
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
from collections import Counter, defaultdict, deque
|
|
5
|
+
from dataclasses import asdict, dataclass, field
|
|
6
|
+
from pathlib import PurePath
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
_SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2, "info": 3}
|
|
11
|
+
_SENSITIVE_MARKERS = (
|
|
12
|
+
"/.ssh/",
|
|
13
|
+
"\\.ssh\\",
|
|
14
|
+
"/.aws/credentials",
|
|
15
|
+
"\\.aws\\credentials",
|
|
16
|
+
"/.config/gcloud/",
|
|
17
|
+
"\\.config\\gcloud\\",
|
|
18
|
+
"/.azure/",
|
|
19
|
+
"\\.azure\\",
|
|
20
|
+
"/.kube/config",
|
|
21
|
+
"\\.kube\\config",
|
|
22
|
+
"/.docker/config.json",
|
|
23
|
+
"\\.docker\\config.json",
|
|
24
|
+
"/.npmrc",
|
|
25
|
+
"\\.npmrc",
|
|
26
|
+
"/.pypirc",
|
|
27
|
+
"\\.pypirc",
|
|
28
|
+
"/.netrc",
|
|
29
|
+
"\\.netrc",
|
|
30
|
+
)
|
|
31
|
+
_SENSITIVE_BASENAMES = {
|
|
32
|
+
".env",
|
|
33
|
+
"id_rsa",
|
|
34
|
+
"id_dsa",
|
|
35
|
+
"id_ecdsa",
|
|
36
|
+
"id_ed25519",
|
|
37
|
+
"credentials",
|
|
38
|
+
"credentials.json",
|
|
39
|
+
"service-account.json",
|
|
40
|
+
}
|
|
41
|
+
_FILE_RELATIONS = {"OPENED_READ", "OPENED_READ_WRITE", "OPENED_WRITE"}
|
|
42
|
+
_NETWORK_RELATIONS = {"CONNECTED_TO", "CONNECT_ATTEMPTED"}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class Finding:
|
|
47
|
+
rule_id: str
|
|
48
|
+
severity: str
|
|
49
|
+
title: str
|
|
50
|
+
summary: str
|
|
51
|
+
node_ids: list[str] = field(default_factory=list)
|
|
52
|
+
edge_ids: list[str] = field(default_factory=list)
|
|
53
|
+
evidence_event_ids: list[str] = field(default_factory=list)
|
|
54
|
+
attributes: dict[str, Any] = field(default_factory=dict)
|
|
55
|
+
|
|
56
|
+
def to_dict(self) -> dict[str, Any]:
|
|
57
|
+
return asdict(self)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _node_map(graph: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
61
|
+
return {
|
|
62
|
+
node["id"]: node
|
|
63
|
+
for node in graph.get("nodes", [])
|
|
64
|
+
if isinstance(node, dict) and isinstance(node.get("id"), str)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _resource_text(node: dict[str, Any]) -> str:
|
|
69
|
+
return f"{node.get('id', '')} {node.get('name', '')}".lower()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _is_sensitive_file(node: dict[str, Any]) -> bool:
|
|
73
|
+
if node.get("type") != "file":
|
|
74
|
+
return False
|
|
75
|
+
text = _resource_text(node)
|
|
76
|
+
if any(marker in text for marker in _SENSITIVE_MARKERS):
|
|
77
|
+
return True
|
|
78
|
+
name = str(node.get("name") or "").lower()
|
|
79
|
+
if name in _SENSITIVE_BASENAMES:
|
|
80
|
+
return True
|
|
81
|
+
node_id = str(node.get("id") or "")
|
|
82
|
+
_, _, raw_path = node_id.partition(":")
|
|
83
|
+
if raw_path:
|
|
84
|
+
try:
|
|
85
|
+
return PurePath(raw_path).name.lower() in _SENSITIVE_BASENAMES
|
|
86
|
+
except (TypeError, ValueError):
|
|
87
|
+
return False
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _endpoint_host(node: dict[str, Any]) -> str | None:
|
|
92
|
+
if node.get("type") != "network_endpoint":
|
|
93
|
+
return None
|
|
94
|
+
raw = str(node.get("name") or "")
|
|
95
|
+
if not raw:
|
|
96
|
+
node_id = str(node.get("id") or "")
|
|
97
|
+
_, _, raw = node_id.partition(":")
|
|
98
|
+
if not raw:
|
|
99
|
+
return None
|
|
100
|
+
if raw.startswith("[") and "]" in raw:
|
|
101
|
+
return raw[1 : raw.index("]")]
|
|
102
|
+
if raw.count(":") == 1:
|
|
103
|
+
return raw.rsplit(":", 1)[0]
|
|
104
|
+
return raw
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _is_external_endpoint(node: dict[str, Any]) -> bool:
|
|
108
|
+
host = _endpoint_host(node)
|
|
109
|
+
if host is None:
|
|
110
|
+
return False
|
|
111
|
+
normalized = host.strip().lower()
|
|
112
|
+
if normalized in {"localhost", "ip6-localhost"}:
|
|
113
|
+
return False
|
|
114
|
+
try:
|
|
115
|
+
address = ipaddress.ip_address(normalized)
|
|
116
|
+
except ValueError:
|
|
117
|
+
return True
|
|
118
|
+
return not (
|
|
119
|
+
address.is_loopback
|
|
120
|
+
or address.is_private
|
|
121
|
+
or address.is_link_local
|
|
122
|
+
or address.is_multicast
|
|
123
|
+
or address.is_unspecified
|
|
124
|
+
or address.is_reserved
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _event_ids(edge: dict[str, Any], limit: int = 24) -> list[str]:
|
|
129
|
+
values = edge.get("event_ids") or []
|
|
130
|
+
return [str(value) for value in values if isinstance(value, str)][:limit]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _edge_sequence(edge: dict[str, Any], key: str) -> int | None:
|
|
134
|
+
value = edge.get(key)
|
|
135
|
+
return value if isinstance(value, int) and not isinstance(value, bool) else None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _causal_spawn_adjacency(
|
|
139
|
+
edges: list[dict[str, Any]],
|
|
140
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
141
|
+
adjacency: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
142
|
+
for edge in edges:
|
|
143
|
+
if edge.get("relation") != "SPAWNED" or edge.get("causal") is not True:
|
|
144
|
+
continue
|
|
145
|
+
source = edge.get("source")
|
|
146
|
+
target = edge.get("target")
|
|
147
|
+
if isinstance(source, str) and isinstance(target, str):
|
|
148
|
+
adjacency[source].append(edge)
|
|
149
|
+
for values in adjacency.values():
|
|
150
|
+
values.sort(
|
|
151
|
+
key=lambda edge: (
|
|
152
|
+
_edge_sequence(edge, "first_sequence") or 0,
|
|
153
|
+
str(edge.get("target") or ""),
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
return dict(adjacency)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _descendant_spawn_paths(
|
|
160
|
+
process_id: str,
|
|
161
|
+
adjacency: dict[str, list[dict[str, Any]]],
|
|
162
|
+
*,
|
|
163
|
+
after_sequence: int | None,
|
|
164
|
+
max_depth: int = 4,
|
|
165
|
+
) -> list[tuple[str, list[dict[str, Any]]]]:
|
|
166
|
+
"""Return chronological causal SPAWNED paths created after a source observation."""
|
|
167
|
+
results: list[tuple[str, list[dict[str, Any]]]] = []
|
|
168
|
+
queue: deque[tuple[str, list[dict[str, Any]], set[str], int | None]] = deque()
|
|
169
|
+
queue.append((process_id, [], {process_id}, after_sequence))
|
|
170
|
+
|
|
171
|
+
while queue:
|
|
172
|
+
current, path, seen, minimum_sequence = queue.popleft()
|
|
173
|
+
if len(path) >= max_depth:
|
|
174
|
+
continue
|
|
175
|
+
for edge in adjacency.get(current, []):
|
|
176
|
+
child = edge.get("target")
|
|
177
|
+
if not isinstance(child, str) or child in seen:
|
|
178
|
+
continue
|
|
179
|
+
spawn_sequence = _edge_sequence(edge, "first_sequence")
|
|
180
|
+
if minimum_sequence is not None:
|
|
181
|
+
if spawn_sequence is None or spawn_sequence < minimum_sequence:
|
|
182
|
+
continue
|
|
183
|
+
next_path = [*path, edge]
|
|
184
|
+
results.append((child, next_path))
|
|
185
|
+
queue.append(
|
|
186
|
+
(
|
|
187
|
+
child,
|
|
188
|
+
next_path,
|
|
189
|
+
{*seen, child},
|
|
190
|
+
spawn_sequence if spawn_sequence is not None else minimum_sequence,
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
return results
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def analyze_graph(graph: dict[str, Any]) -> dict[str, Any]:
|
|
197
|
+
"""Run conservative, explainable rules over one execution graph.
|
|
198
|
+
|
|
199
|
+
Findings never upgrade co-occurrence into byte-level data-flow. Sensitive-file
|
|
200
|
+
access followed by network activity is a prioritization signal, not proof that
|
|
201
|
+
file contents were transmitted. The same rule applies across child processes:
|
|
202
|
+
SPAWNED proves process lineage, not data inheritance or IPC.
|
|
203
|
+
"""
|
|
204
|
+
nodes = _node_map(graph)
|
|
205
|
+
edges = [edge for edge in graph.get("edges", []) if isinstance(edge, dict)]
|
|
206
|
+
findings: list[Finding] = []
|
|
207
|
+
sensitive_by_process: dict[str, list[dict[str, Any]]] = {}
|
|
208
|
+
external_by_process: dict[str, list[dict[str, Any]]] = {}
|
|
209
|
+
|
|
210
|
+
for edge in edges:
|
|
211
|
+
source_id = edge.get("source")
|
|
212
|
+
target_id = edge.get("target")
|
|
213
|
+
relation = edge.get("relation")
|
|
214
|
+
if not isinstance(source_id, str) or not isinstance(target_id, str):
|
|
215
|
+
continue
|
|
216
|
+
source = nodes.get(source_id)
|
|
217
|
+
target = nodes.get(target_id)
|
|
218
|
+
if source is None or target is None or source.get("type") != "process":
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
if relation in _FILE_RELATIONS and _is_sensitive_file(target):
|
|
222
|
+
causal = edge.get("causal") is True
|
|
223
|
+
severity = "high" if relation in {"OPENED_READ_WRITE", "OPENED_WRITE"} else "medium"
|
|
224
|
+
if not causal:
|
|
225
|
+
severity = "low"
|
|
226
|
+
findings.append(
|
|
227
|
+
Finding(
|
|
228
|
+
rule_id="sensitive-file-access",
|
|
229
|
+
severity=severity,
|
|
230
|
+
title="Process accessed a sensitive-looking file",
|
|
231
|
+
summary=(
|
|
232
|
+
f"{source_id} has {relation} evidence for {target_id}. "
|
|
233
|
+
"Review whether this resource was required for the agent task."
|
|
234
|
+
),
|
|
235
|
+
node_ids=[source_id, target_id],
|
|
236
|
+
edge_ids=[str(edge.get("id") or "")],
|
|
237
|
+
evidence_event_ids=_event_ids(edge),
|
|
238
|
+
attributes={
|
|
239
|
+
"relation": relation,
|
|
240
|
+
"causal": edge.get("causal"),
|
|
241
|
+
"data_flow_proven": False,
|
|
242
|
+
},
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
if causal:
|
|
246
|
+
sensitive_by_process.setdefault(source_id, []).append(edge)
|
|
247
|
+
|
|
248
|
+
if relation in _NETWORK_RELATIONS and _is_external_endpoint(target):
|
|
249
|
+
causal = edge.get("causal") is True
|
|
250
|
+
findings.append(
|
|
251
|
+
Finding(
|
|
252
|
+
rule_id="external-network-contact",
|
|
253
|
+
severity="info" if relation == "CONNECTED_TO" else "low",
|
|
254
|
+
title="Process contacted an external network endpoint",
|
|
255
|
+
summary=f"{source_id} emitted {relation} evidence for {target_id}.",
|
|
256
|
+
node_ids=[source_id, target_id],
|
|
257
|
+
edge_ids=[str(edge.get("id") or "")],
|
|
258
|
+
evidence_event_ids=_event_ids(edge),
|
|
259
|
+
attributes={
|
|
260
|
+
"relation": relation,
|
|
261
|
+
"causal": edge.get("causal"),
|
|
262
|
+
"endpoint_host": _endpoint_host(target),
|
|
263
|
+
},
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
if causal:
|
|
267
|
+
external_by_process.setdefault(source_id, []).append(edge)
|
|
268
|
+
|
|
269
|
+
# Same-process correlation with strict chronological ordering when sequences exist.
|
|
270
|
+
for process_id in sorted(set(sensitive_by_process).intersection(external_by_process)):
|
|
271
|
+
for file_edge in sensitive_by_process[process_id]:
|
|
272
|
+
file_target = str(file_edge.get("target") or "")
|
|
273
|
+
file_last = _edge_sequence(file_edge, "last_sequence")
|
|
274
|
+
for net_edge in external_by_process[process_id]:
|
|
275
|
+
net_target = str(net_edge.get("target") or "")
|
|
276
|
+
net_first = _edge_sequence(net_edge, "first_sequence")
|
|
277
|
+
if file_last is not None and net_first is not None and net_first < file_last:
|
|
278
|
+
continue
|
|
279
|
+
relation = str(net_edge.get("relation") or "")
|
|
280
|
+
severity = "high" if relation == "CONNECTED_TO" else "medium"
|
|
281
|
+
findings.append(
|
|
282
|
+
Finding(
|
|
283
|
+
rule_id="possible-sensitive-file-to-network-path",
|
|
284
|
+
severity=severity,
|
|
285
|
+
title="Sensitive-file access was followed by external network activity",
|
|
286
|
+
summary=(
|
|
287
|
+
f"{process_id} accessed {file_target} and later produced {relation} "
|
|
288
|
+
f"evidence for {net_target}. This is a prioritization signal only: "
|
|
289
|
+
"ExecWeave has not proven that bytes from the file were transmitted."
|
|
290
|
+
),
|
|
291
|
+
node_ids=[process_id, file_target, net_target],
|
|
292
|
+
edge_ids=[
|
|
293
|
+
str(file_edge.get("id") or ""),
|
|
294
|
+
str(net_edge.get("id") or ""),
|
|
295
|
+
],
|
|
296
|
+
evidence_event_ids=[
|
|
297
|
+
*_event_ids(file_edge, 12),
|
|
298
|
+
*_event_ids(net_edge, 12),
|
|
299
|
+
],
|
|
300
|
+
attributes={
|
|
301
|
+
"file_relation": file_edge.get("relation"),
|
|
302
|
+
"network_relation": relation,
|
|
303
|
+
"file_last_sequence": file_last,
|
|
304
|
+
"network_first_sequence": net_first,
|
|
305
|
+
"causal_process_attribution": True,
|
|
306
|
+
"data_flow_proven": False,
|
|
307
|
+
"exfiltration_proven": False,
|
|
308
|
+
},
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# Graph-native delegated path: sensitive access -> causal SPAWNED chain -> network.
|
|
313
|
+
# This proves chronological process lineage only. It does not prove inheritance,
|
|
314
|
+
# IPC, taint propagation, or that the child received bytes from the sensitive file.
|
|
315
|
+
spawn_adjacency = _causal_spawn_adjacency(edges)
|
|
316
|
+
for source_process, file_edges in sorted(sensitive_by_process.items()):
|
|
317
|
+
for file_edge in file_edges:
|
|
318
|
+
file_target = str(file_edge.get("target") or "")
|
|
319
|
+
file_last = _edge_sequence(file_edge, "last_sequence")
|
|
320
|
+
for descendant, spawn_path in _descendant_spawn_paths(
|
|
321
|
+
source_process,
|
|
322
|
+
spawn_adjacency,
|
|
323
|
+
after_sequence=file_last,
|
|
324
|
+
):
|
|
325
|
+
spawn_last = _edge_sequence(spawn_path[-1], "first_sequence")
|
|
326
|
+
for net_edge in external_by_process.get(descendant, []):
|
|
327
|
+
net_first = _edge_sequence(net_edge, "first_sequence")
|
|
328
|
+
if spawn_last is not None:
|
|
329
|
+
if net_first is None or net_first < spawn_last:
|
|
330
|
+
continue
|
|
331
|
+
relation = str(net_edge.get("relation") or "")
|
|
332
|
+
net_target = str(net_edge.get("target") or "")
|
|
333
|
+
process_chain = [source_process]
|
|
334
|
+
process_chain.extend(str(edge.get("target") or "") for edge in spawn_path)
|
|
335
|
+
spawn_edge_ids = [str(edge.get("id") or "") for edge in spawn_path]
|
|
336
|
+
spawn_event_ids = [
|
|
337
|
+
event_id
|
|
338
|
+
for edge in spawn_path
|
|
339
|
+
for event_id in _event_ids(edge, 8)
|
|
340
|
+
]
|
|
341
|
+
findings.append(
|
|
342
|
+
Finding(
|
|
343
|
+
rule_id="possible-delegated-sensitive-file-to-network-path",
|
|
344
|
+
severity="medium" if relation == "CONNECTED_TO" else "low",
|
|
345
|
+
title=(
|
|
346
|
+
"Sensitive-file access was followed by child-process "
|
|
347
|
+
"external network activity"
|
|
348
|
+
),
|
|
349
|
+
summary=(
|
|
350
|
+
f"{source_process} accessed {file_target}, then a causal SPAWNED "
|
|
351
|
+
f"chain reached {descendant}, which later produced {relation} "
|
|
352
|
+
f"evidence for {net_target}. Process lineage is proven, but "
|
|
353
|
+
"ExecWeave has not proven data inheritance, IPC, or exfiltration."
|
|
354
|
+
),
|
|
355
|
+
node_ids=[source_process, file_target, *process_chain[1:], net_target],
|
|
356
|
+
edge_ids=[
|
|
357
|
+
str(file_edge.get("id") or ""),
|
|
358
|
+
*spawn_edge_ids,
|
|
359
|
+
str(net_edge.get("id") or ""),
|
|
360
|
+
],
|
|
361
|
+
evidence_event_ids=[
|
|
362
|
+
*_event_ids(file_edge, 8),
|
|
363
|
+
*spawn_event_ids,
|
|
364
|
+
*_event_ids(net_edge, 8),
|
|
365
|
+
],
|
|
366
|
+
attributes={
|
|
367
|
+
"delegation_hops": len(spawn_path),
|
|
368
|
+
"process_chain": process_chain,
|
|
369
|
+
"file_last_sequence": file_last,
|
|
370
|
+
"spawn_sequences": [
|
|
371
|
+
_edge_sequence(edge, "first_sequence")
|
|
372
|
+
for edge in spawn_path
|
|
373
|
+
],
|
|
374
|
+
"network_first_sequence": net_first,
|
|
375
|
+
"causal_process_lineage": True,
|
|
376
|
+
"data_inheritance_proven": False,
|
|
377
|
+
"ipc_proven": False,
|
|
378
|
+
"data_flow_proven": False,
|
|
379
|
+
"exfiltration_proven": False,
|
|
380
|
+
},
|
|
381
|
+
)
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
findings.sort(
|
|
385
|
+
key=lambda finding: (
|
|
386
|
+
_SEVERITY_ORDER.get(finding.severity, 99),
|
|
387
|
+
finding.rule_id,
|
|
388
|
+
finding.node_ids,
|
|
389
|
+
)
|
|
390
|
+
)
|
|
391
|
+
counts = Counter(finding.severity for finding in findings)
|
|
392
|
+
return {
|
|
393
|
+
"analysis_schema_version": "0.2",
|
|
394
|
+
"session_id": graph.get("session_id"),
|
|
395
|
+
"finding_count": len(findings),
|
|
396
|
+
"severity_counts": {
|
|
397
|
+
severity: counts.get(severity, 0) for severity in ("high", "medium", "low", "info")
|
|
398
|
+
},
|
|
399
|
+
"limitations": [
|
|
400
|
+
"Findings are rule-based prioritization signals, not proof of malicious intent.",
|
|
401
|
+
"Sensitive-file-to-network findings do not prove byte-level data flow or exfiltration.",
|
|
402
|
+
"SPAWNED lineage does not prove data inheritance, IPC, or taint propagation.",
|
|
403
|
+
"Collector coverage and attribution strength depend on the backend.",
|
|
404
|
+
],
|
|
405
|
+
"findings": [finding.to_dict() for finding in findings],
|
|
406
|
+
}
|
execweave/backends.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from .collector import RuntimeCollector
|
|
8
|
+
from .sink import JsonlSink
|
|
9
|
+
from .strace_backend import StraceRuntimeCollector, strace_available
|
|
10
|
+
|
|
11
|
+
BackendName = Literal["auto", "portable", "strace"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def resolve_backend(requested: BackendName) -> str:
|
|
15
|
+
if requested == "portable":
|
|
16
|
+
return "portable"
|
|
17
|
+
if requested == "strace":
|
|
18
|
+
if not strace_available():
|
|
19
|
+
raise RuntimeError("strace backend requested, but Linux strace is not available")
|
|
20
|
+
return "strace"
|
|
21
|
+
if requested != "auto":
|
|
22
|
+
raise ValueError(f"unknown backend: {requested}")
|
|
23
|
+
return "strace" if strace_available() else "portable"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def create_collector(
|
|
27
|
+
*,
|
|
28
|
+
backend: BackendName,
|
|
29
|
+
session_id: str,
|
|
30
|
+
sink: JsonlSink,
|
|
31
|
+
watch_root: Path,
|
|
32
|
+
poll_interval: float,
|
|
33
|
+
collect_filesystem: bool,
|
|
34
|
+
collect_network: bool,
|
|
35
|
+
keep_raw_trace: bool = False,
|
|
36
|
+
):
|
|
37
|
+
resolved = resolve_backend(backend)
|
|
38
|
+
if resolved == "strace":
|
|
39
|
+
return StraceRuntimeCollector(
|
|
40
|
+
session_id=session_id,
|
|
41
|
+
sink=sink,
|
|
42
|
+
watch_root=watch_root,
|
|
43
|
+
collect_filesystem=collect_filesystem,
|
|
44
|
+
collect_network=collect_network,
|
|
45
|
+
keep_raw_trace=keep_raw_trace,
|
|
46
|
+
)
|
|
47
|
+
return RuntimeCollector(
|
|
48
|
+
session_id=session_id,
|
|
49
|
+
sink=sink,
|
|
50
|
+
watch_root=watch_root,
|
|
51
|
+
poll_interval=poll_interval,
|
|
52
|
+
collect_filesystem=collect_filesystem,
|
|
53
|
+
collect_network=collect_network,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def backend_diagnostics() -> dict[str, object]:
|
|
58
|
+
return {
|
|
59
|
+
"platform": sys.platform,
|
|
60
|
+
"portable": True,
|
|
61
|
+
"strace": strace_available(),
|
|
62
|
+
"auto_selected": resolve_backend("auto"),
|
|
63
|
+
}
|
execweave/benchmark.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import statistics
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from uuid import uuid4
|
|
11
|
+
|
|
12
|
+
from .backends import BackendName, create_collector, resolve_backend
|
|
13
|
+
from .sink import JsonlSink
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _workload_command() -> list[str]:
|
|
17
|
+
code = (
|
|
18
|
+
"from pathlib import Path; "
|
|
19
|
+
"p=Path('execweave-bench.tmp'); "
|
|
20
|
+
"p.write_text('x'*4096, encoding='utf-8'); "
|
|
21
|
+
"p.read_text(encoding='utf-8'); "
|
|
22
|
+
"p.unlink()"
|
|
23
|
+
)
|
|
24
|
+
return [sys.executable, "-c", code]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def run_benchmark(
|
|
28
|
+
*, backend: BackendName = "auto", iterations: int = 5
|
|
29
|
+
) -> dict[str, object]:
|
|
30
|
+
if iterations < 1:
|
|
31
|
+
raise ValueError("iterations must be >= 1")
|
|
32
|
+
resolved = resolve_backend(backend)
|
|
33
|
+
baseline: list[float] = []
|
|
34
|
+
instrumented: list[float] = []
|
|
35
|
+
with tempfile.TemporaryDirectory(prefix="execweave-benchmark-") as temp:
|
|
36
|
+
root = Path(temp)
|
|
37
|
+
command = _workload_command()
|
|
38
|
+
for _ in range(iterations):
|
|
39
|
+
start = time.perf_counter()
|
|
40
|
+
subprocess.run(
|
|
41
|
+
command,
|
|
42
|
+
cwd=root,
|
|
43
|
+
check=True,
|
|
44
|
+
stdout=subprocess.DEVNULL,
|
|
45
|
+
stderr=subprocess.DEVNULL,
|
|
46
|
+
)
|
|
47
|
+
baseline.append(time.perf_counter() - start)
|
|
48
|
+
for _ in range(iterations):
|
|
49
|
+
session_id = uuid4().hex
|
|
50
|
+
sink = JsonlSink(root / ".execweave" / "runs" / f"{session_id}.jsonl")
|
|
51
|
+
collector = create_collector(
|
|
52
|
+
backend=backend,
|
|
53
|
+
session_id=session_id,
|
|
54
|
+
sink=sink,
|
|
55
|
+
watch_root=root,
|
|
56
|
+
poll_interval=0.05,
|
|
57
|
+
collect_filesystem=True,
|
|
58
|
+
collect_network=False,
|
|
59
|
+
)
|
|
60
|
+
start = time.perf_counter()
|
|
61
|
+
rc = collector.run(command)
|
|
62
|
+
if rc != 0:
|
|
63
|
+
raise RuntimeError(f"benchmark workload failed with exit code {rc}")
|
|
64
|
+
instrumented.append(time.perf_counter() - start)
|
|
65
|
+
base_median = statistics.median(baseline)
|
|
66
|
+
instrumented_median = statistics.median(instrumented)
|
|
67
|
+
ratio = instrumented_median / base_median if base_median else None
|
|
68
|
+
return {
|
|
69
|
+
"backend": resolved,
|
|
70
|
+
"iterations": iterations,
|
|
71
|
+
"baseline_seconds": baseline,
|
|
72
|
+
"instrumented_seconds": instrumented,
|
|
73
|
+
"baseline_median_seconds": base_median,
|
|
74
|
+
"instrumented_median_seconds": instrumented_median,
|
|
75
|
+
"overhead_ratio": ratio,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def format_benchmark(result: dict[str, object]) -> str:
|
|
80
|
+
return json.dumps(result, indent=2, sort_keys=True)
|