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
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Iterable
|
|
13
|
+
|
|
14
|
+
from .collector import infer_agent_name
|
|
15
|
+
from .schema import Entity, RuntimeEvent
|
|
16
|
+
from .sink import JsonlSink
|
|
17
|
+
|
|
18
|
+
_TIMESTAMP_RE = re.compile(r"^(?P<ts>\d+(?:\.\d+)?)\s+(?P<body>.*)$")
|
|
19
|
+
_SYSCALL_RE = re.compile(r"^(?P<name>[a-zA-Z0-9_]+)\((?P<args>.*)\)\s+=\s+(?P<result>.*)$")
|
|
20
|
+
_CLONE_RESULT_RE = re.compile(r"^(?P<pid>\d+)(?:\s|$)")
|
|
21
|
+
_CONNECT_ERROR_RE = re.compile(r"^-1\s+(?P<errno>[A-Z0-9_]+)(?:\s|$)")
|
|
22
|
+
_EXIT_RE = re.compile(r"^\+\+\+ exited with (?P<code>-?\d+) \+\+\+$")
|
|
23
|
+
_KILLED_RE = re.compile(r"^\+\+\+ killed by (?P<signal>[A-Z0-9]+).*$")
|
|
24
|
+
_QUOTED_RE = re.compile(r'"((?:\\.|[^"\\])*)"')
|
|
25
|
+
_IPV4_RE = re.compile(
|
|
26
|
+
r'sin_port=htons\((?P<port>\d+)\).*sin_addr=inet_addr\("(?P<host>[^"]+)"\)'
|
|
27
|
+
)
|
|
28
|
+
_IPV6_RE = re.compile(
|
|
29
|
+
r'sin6_port=htons\((?P<port>\d+)\).*inet_pton\(AF_INET6, "(?P<host>[^"]+)"'
|
|
30
|
+
)
|
|
31
|
+
_UNIX_RE = re.compile(r'sun_path="(?P<path>[^"]+)"')
|
|
32
|
+
_DIRFD_RE = re.compile(r"^[^,]*<(?P<path>/[^>]*)>")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class TraceRecord:
|
|
37
|
+
timestamp: float
|
|
38
|
+
pid: int
|
|
39
|
+
body: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def strace_available() -> bool:
|
|
43
|
+
return sys.platform.startswith("linux") and shutil.which("strace") is not None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _iso_timestamp(value: float) -> str:
|
|
47
|
+
return datetime.fromtimestamp(value, timezone.utc).isoformat().replace("+00:00", "Z")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _decode_quoted(value: str) -> str:
|
|
51
|
+
try:
|
|
52
|
+
return str(ast.literal_eval(f'"{value}"'))
|
|
53
|
+
except (SyntaxError, ValueError):
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _quoted_arguments(args: str) -> list[str]:
|
|
58
|
+
return [_decode_quoted(match.group(1)) for match in _QUOTED_RE.finditer(args)]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _pid_from_trace_path(path: Path) -> int | None:
|
|
62
|
+
suffix = path.name.rsplit(".", 1)[-1]
|
|
63
|
+
try:
|
|
64
|
+
return int(suffix)
|
|
65
|
+
except ValueError:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _merge_unfinished(lines: Iterable[str]) -> list[str]:
|
|
70
|
+
merged: list[str] = []
|
|
71
|
+
pending: dict[str, str] = {}
|
|
72
|
+
for raw_line in lines:
|
|
73
|
+
line = raw_line.rstrip("\n")
|
|
74
|
+
timestamp_match = _TIMESTAMP_RE.match(line)
|
|
75
|
+
if timestamp_match is None:
|
|
76
|
+
merged.append(line)
|
|
77
|
+
continue
|
|
78
|
+
body = timestamp_match.group("body")
|
|
79
|
+
if body.endswith("<unfinished ...>"):
|
|
80
|
+
prefix = body[: -len("<unfinished ...>")].rstrip()
|
|
81
|
+
syscall = prefix.split("(", 1)[0]
|
|
82
|
+
pending[syscall] = f"{timestamp_match.group('ts')} {prefix}"
|
|
83
|
+
continue
|
|
84
|
+
resumed = re.match(
|
|
85
|
+
r"^<\.\.\. (?P<name>[a-zA-Z0-9_]+) resumed>(?P<rest>.*)$",
|
|
86
|
+
body,
|
|
87
|
+
)
|
|
88
|
+
if resumed is not None and resumed.group("name") in pending:
|
|
89
|
+
original = pending.pop(resumed.group("name"))
|
|
90
|
+
merged.append(original + resumed.group("rest"))
|
|
91
|
+
continue
|
|
92
|
+
merged.append(line)
|
|
93
|
+
merged.extend(pending.values())
|
|
94
|
+
return merged
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def read_trace_records(trace_dir: Path, prefix: str = "trace") -> list[TraceRecord]:
|
|
98
|
+
records: list[TraceRecord] = []
|
|
99
|
+
for path in sorted(trace_dir.glob(f"{prefix}.*")):
|
|
100
|
+
pid = _pid_from_trace_path(path)
|
|
101
|
+
if pid is None:
|
|
102
|
+
continue
|
|
103
|
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
104
|
+
for line in _merge_unfinished(lines):
|
|
105
|
+
match = _TIMESTAMP_RE.match(line)
|
|
106
|
+
if match is None:
|
|
107
|
+
continue
|
|
108
|
+
records.append(TraceRecord(float(match.group("ts")), pid, match.group("body")))
|
|
109
|
+
records.sort(key=lambda record: (record.timestamp, record.pid))
|
|
110
|
+
return records
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class StraceParser:
|
|
114
|
+
"""Convert Linux strace output into graph-ready runtime events.
|
|
115
|
+
|
|
116
|
+
This backend uses syscall evidence and therefore can attribute process creation,
|
|
117
|
+
file-open/mutation operations, and outbound connect() calls to a concrete PID.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def __init__(
|
|
121
|
+
self,
|
|
122
|
+
*,
|
|
123
|
+
session_id: str,
|
|
124
|
+
sink: JsonlSink,
|
|
125
|
+
watch_root: Path,
|
|
126
|
+
command: list[str],
|
|
127
|
+
) -> None:
|
|
128
|
+
self.session_id = session_id
|
|
129
|
+
self.sink = sink
|
|
130
|
+
self.watch_root = watch_root.expanduser().resolve()
|
|
131
|
+
self.command = command
|
|
132
|
+
self.session = Entity(
|
|
133
|
+
type="session",
|
|
134
|
+
id=f"session:{session_id}",
|
|
135
|
+
name=session_id,
|
|
136
|
+
attributes={
|
|
137
|
+
"command": command,
|
|
138
|
+
"cwd": str(self.watch_root),
|
|
139
|
+
"backend": "strace",
|
|
140
|
+
},
|
|
141
|
+
)
|
|
142
|
+
self._known_processes: set[int] = set()
|
|
143
|
+
self._cwd_by_pid: dict[int, Path] = {}
|
|
144
|
+
self._parent_by_pid: dict[int, int] = {}
|
|
145
|
+
|
|
146
|
+
def process_entity(self, pid: int) -> Entity:
|
|
147
|
+
return Entity(
|
|
148
|
+
type="process",
|
|
149
|
+
id=f"process:{self.session_id}:{pid}",
|
|
150
|
+
name=str(pid),
|
|
151
|
+
attributes={
|
|
152
|
+
"pid": pid,
|
|
153
|
+
"identity_scope": "session",
|
|
154
|
+
"backend": "strace",
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def parse(self, records: Iterable[TraceRecord]) -> None:
|
|
159
|
+
materialized = list(records)
|
|
160
|
+
self._index_process_parents(materialized)
|
|
161
|
+
for record in materialized:
|
|
162
|
+
self._parse_record(record)
|
|
163
|
+
|
|
164
|
+
def _index_process_parents(self, records: Iterable[TraceRecord]) -> None:
|
|
165
|
+
"""Index fork/clone relationships before emitting events.
|
|
166
|
+
|
|
167
|
+
strace timestamps can tie across per-PID trace files. Without a pre-pass,
|
|
168
|
+
a child record that sorts before its parent's clone() record can be mistaken
|
|
169
|
+
for a session root. The parent index makes process identity independent of
|
|
170
|
+
cross-file ordering at equal timestamps.
|
|
171
|
+
"""
|
|
172
|
+
for record in records:
|
|
173
|
+
syscall = _SYSCALL_RE.match(record.body)
|
|
174
|
+
if syscall is None or syscall.group("name") not in {"clone", "clone3", "fork", "vfork"}:
|
|
175
|
+
continue
|
|
176
|
+
child_match = _CLONE_RESULT_RE.match(syscall.group("result"))
|
|
177
|
+
if child_match is None:
|
|
178
|
+
continue
|
|
179
|
+
self._parent_by_pid[int(child_match.group("pid"))] = record.pid
|
|
180
|
+
|
|
181
|
+
def _ensure_process(
|
|
182
|
+
self,
|
|
183
|
+
pid: int,
|
|
184
|
+
timestamp: float,
|
|
185
|
+
*,
|
|
186
|
+
parent_pid: int | None = None,
|
|
187
|
+
relation: str | None = None,
|
|
188
|
+
) -> None:
|
|
189
|
+
if pid in self._known_processes:
|
|
190
|
+
return
|
|
191
|
+
if parent_pid is None:
|
|
192
|
+
parent_pid = self._parent_by_pid.get(pid)
|
|
193
|
+
self._known_processes.add(pid)
|
|
194
|
+
if parent_pid is not None:
|
|
195
|
+
self._cwd_by_pid[pid] = self._cwd_by_pid.get(parent_pid, self.watch_root)
|
|
196
|
+
else:
|
|
197
|
+
self._cwd_by_pid.setdefault(pid, self.watch_root)
|
|
198
|
+
source = self.session if parent_pid is None else self.process_entity(parent_pid)
|
|
199
|
+
relation = relation or ("LAUNCHED" if parent_pid is None else "SPAWNED")
|
|
200
|
+
self.sink.emit(
|
|
201
|
+
RuntimeEvent.create(
|
|
202
|
+
session_id=self.session_id,
|
|
203
|
+
event_type="process.started",
|
|
204
|
+
relation=relation,
|
|
205
|
+
source=source,
|
|
206
|
+
target=self.process_entity(pid),
|
|
207
|
+
timestamp=_iso_timestamp(timestamp),
|
|
208
|
+
attributes={
|
|
209
|
+
"attribution": "syscall",
|
|
210
|
+
"causal": True,
|
|
211
|
+
"backend": "strace",
|
|
212
|
+
},
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
def _parse_record(self, record: TraceRecord) -> None:
|
|
217
|
+
exit_match = _EXIT_RE.match(record.body)
|
|
218
|
+
if exit_match is not None:
|
|
219
|
+
self._ensure_process(record.pid, record.timestamp)
|
|
220
|
+
self.sink.emit(
|
|
221
|
+
RuntimeEvent.create(
|
|
222
|
+
session_id=self.session_id,
|
|
223
|
+
event_type="process.exited",
|
|
224
|
+
relation="EXITED",
|
|
225
|
+
source=self.process_entity(record.pid),
|
|
226
|
+
timestamp=_iso_timestamp(record.timestamp),
|
|
227
|
+
attributes={
|
|
228
|
+
"exit_code": int(exit_match.group("code")),
|
|
229
|
+
"backend": "strace",
|
|
230
|
+
},
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
killed_match = _KILLED_RE.match(record.body)
|
|
236
|
+
if killed_match is not None:
|
|
237
|
+
self._ensure_process(record.pid, record.timestamp)
|
|
238
|
+
self.sink.emit(
|
|
239
|
+
RuntimeEvent.create(
|
|
240
|
+
session_id=self.session_id,
|
|
241
|
+
event_type="process.exited",
|
|
242
|
+
relation="KILLED_BY",
|
|
243
|
+
source=self.process_entity(record.pid),
|
|
244
|
+
timestamp=_iso_timestamp(record.timestamp),
|
|
245
|
+
attributes={
|
|
246
|
+
"signal": killed_match.group("signal"),
|
|
247
|
+
"backend": "strace",
|
|
248
|
+
},
|
|
249
|
+
)
|
|
250
|
+
)
|
|
251
|
+
return
|
|
252
|
+
|
|
253
|
+
syscall = _SYSCALL_RE.match(record.body)
|
|
254
|
+
if syscall is None:
|
|
255
|
+
return
|
|
256
|
+
name = syscall.group("name")
|
|
257
|
+
args = syscall.group("args")
|
|
258
|
+
result = syscall.group("result")
|
|
259
|
+
self._ensure_process(record.pid, record.timestamp)
|
|
260
|
+
|
|
261
|
+
if name in {"clone", "clone3", "fork", "vfork"}:
|
|
262
|
+
self._parse_process_spawn(record, result)
|
|
263
|
+
elif name in {"execve", "execveat"}:
|
|
264
|
+
self._parse_exec(record, name, args, result)
|
|
265
|
+
elif name == "chdir":
|
|
266
|
+
self._parse_chdir(record, args, result)
|
|
267
|
+
elif name in {"open", "openat", "openat2", "creat"}:
|
|
268
|
+
self._parse_open(record, name, args, result)
|
|
269
|
+
elif name in {"unlink", "unlinkat", "rmdir"}:
|
|
270
|
+
self._parse_delete(record, name, args, result)
|
|
271
|
+
elif name in {"mkdir", "mkdirat"}:
|
|
272
|
+
self._parse_mkdir(record, name, args, result)
|
|
273
|
+
elif name in {"rename", "renameat", "renameat2"}:
|
|
274
|
+
self._parse_rename(record, name, args, result)
|
|
275
|
+
elif name == "connect":
|
|
276
|
+
self._parse_connect(record, args, result)
|
|
277
|
+
|
|
278
|
+
def _parse_process_spawn(self, record: TraceRecord, result: str) -> None:
|
|
279
|
+
child_match = _CLONE_RESULT_RE.match(result)
|
|
280
|
+
if child_match is None:
|
|
281
|
+
return
|
|
282
|
+
child_pid = int(child_match.group("pid"))
|
|
283
|
+
self._ensure_process(
|
|
284
|
+
child_pid,
|
|
285
|
+
record.timestamp,
|
|
286
|
+
parent_pid=record.pid,
|
|
287
|
+
relation="SPAWNED",
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
def _parse_exec(self, record: TraceRecord, name: str, args: str, result: str) -> None:
|
|
291
|
+
if not result.startswith("0"):
|
|
292
|
+
return
|
|
293
|
+
quoted = _quoted_arguments(args)
|
|
294
|
+
if not quoted:
|
|
295
|
+
return
|
|
296
|
+
executable = quoted[0]
|
|
297
|
+
target_path = self._resolve_path(record.pid, executable)
|
|
298
|
+
target = Entity(
|
|
299
|
+
type="executable",
|
|
300
|
+
id=f"executable:{target_path}",
|
|
301
|
+
name=target_path.name,
|
|
302
|
+
)
|
|
303
|
+
self.sink.emit(
|
|
304
|
+
RuntimeEvent.create(
|
|
305
|
+
session_id=self.session_id,
|
|
306
|
+
event_type="process.exec",
|
|
307
|
+
relation="EXECUTED",
|
|
308
|
+
source=self.process_entity(record.pid),
|
|
309
|
+
target=target,
|
|
310
|
+
timestamp=_iso_timestamp(record.timestamp),
|
|
311
|
+
attributes={
|
|
312
|
+
"syscall": name,
|
|
313
|
+
"argument_count": max(0, len(quoted) - 1),
|
|
314
|
+
"backend": "strace",
|
|
315
|
+
"attribution": "syscall",
|
|
316
|
+
"causal": True,
|
|
317
|
+
},
|
|
318
|
+
)
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
def _parse_chdir(self, record: TraceRecord, args: str, result: str) -> None:
|
|
322
|
+
if not result.startswith("0"):
|
|
323
|
+
return
|
|
324
|
+
quoted = _quoted_arguments(args)
|
|
325
|
+
if not quoted:
|
|
326
|
+
return
|
|
327
|
+
new_cwd = self._resolve_path(record.pid, quoted[0])
|
|
328
|
+
self._cwd_by_pid[record.pid] = new_cwd
|
|
329
|
+
target = Entity(
|
|
330
|
+
type="directory",
|
|
331
|
+
id=f"directory:{new_cwd}",
|
|
332
|
+
name=new_cwd.name,
|
|
333
|
+
)
|
|
334
|
+
self.sink.emit(
|
|
335
|
+
RuntimeEvent.create(
|
|
336
|
+
session_id=self.session_id,
|
|
337
|
+
event_type="filesystem.chdir",
|
|
338
|
+
relation="CHANGED_CWD_TO",
|
|
339
|
+
source=self.process_entity(record.pid),
|
|
340
|
+
target=target,
|
|
341
|
+
timestamp=_iso_timestamp(record.timestamp),
|
|
342
|
+
attributes={
|
|
343
|
+
"attribution": "syscall",
|
|
344
|
+
"causal": True,
|
|
345
|
+
"backend": "strace",
|
|
346
|
+
},
|
|
347
|
+
)
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
def _parse_open(self, record: TraceRecord, name: str, args: str, result: str) -> None:
|
|
351
|
+
if result.startswith("-1"):
|
|
352
|
+
return
|
|
353
|
+
quoted = _quoted_arguments(args)
|
|
354
|
+
if not quoted:
|
|
355
|
+
return
|
|
356
|
+
raw_path = quoted[0]
|
|
357
|
+
path = self._resolve_open_path(record.pid, name, args, raw_path)
|
|
358
|
+
flags = self._open_flags(name, args)
|
|
359
|
+
relation = self._open_relation(flags, name)
|
|
360
|
+
entity_type = "directory" if "O_DIRECTORY" in flags else "file"
|
|
361
|
+
target = Entity(
|
|
362
|
+
type=entity_type,
|
|
363
|
+
id=f"{entity_type}:{path}",
|
|
364
|
+
name=path.name,
|
|
365
|
+
)
|
|
366
|
+
self.sink.emit(
|
|
367
|
+
RuntimeEvent.create(
|
|
368
|
+
session_id=self.session_id,
|
|
369
|
+
event_type="filesystem.open",
|
|
370
|
+
relation=relation,
|
|
371
|
+
source=self.process_entity(record.pid),
|
|
372
|
+
target=target,
|
|
373
|
+
timestamp=_iso_timestamp(record.timestamp),
|
|
374
|
+
attributes={
|
|
375
|
+
"syscall": name,
|
|
376
|
+
"flags": flags,
|
|
377
|
+
"raw_path": raw_path,
|
|
378
|
+
"within_watch_root": self._within_watch_root(path),
|
|
379
|
+
"attribution": "syscall",
|
|
380
|
+
"causal": True,
|
|
381
|
+
"backend": "strace",
|
|
382
|
+
},
|
|
383
|
+
)
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
def _parse_delete(self, record: TraceRecord, name: str, args: str, result: str) -> None:
|
|
387
|
+
if not result.startswith("0"):
|
|
388
|
+
return
|
|
389
|
+
quoted = _quoted_arguments(args)
|
|
390
|
+
if not quoted:
|
|
391
|
+
return
|
|
392
|
+
path = self._resolve_open_path(record.pid, name, args, quoted[0])
|
|
393
|
+
entity_type = "directory" if name == "rmdir" else "file"
|
|
394
|
+
target = Entity(
|
|
395
|
+
type=entity_type,
|
|
396
|
+
id=f"{entity_type}:{path}",
|
|
397
|
+
name=path.name,
|
|
398
|
+
)
|
|
399
|
+
self.sink.emit(
|
|
400
|
+
RuntimeEvent.create(
|
|
401
|
+
session_id=self.session_id,
|
|
402
|
+
event_type="filesystem.delete",
|
|
403
|
+
relation="DELETED",
|
|
404
|
+
source=self.process_entity(record.pid),
|
|
405
|
+
target=target,
|
|
406
|
+
timestamp=_iso_timestamp(record.timestamp),
|
|
407
|
+
attributes={
|
|
408
|
+
"syscall": name,
|
|
409
|
+
"within_watch_root": self._within_watch_root(path),
|
|
410
|
+
"attribution": "syscall",
|
|
411
|
+
"causal": True,
|
|
412
|
+
"backend": "strace",
|
|
413
|
+
},
|
|
414
|
+
)
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
def _parse_mkdir(self, record: TraceRecord, name: str, args: str, result: str) -> None:
|
|
418
|
+
if not result.startswith("0"):
|
|
419
|
+
return
|
|
420
|
+
quoted = _quoted_arguments(args)
|
|
421
|
+
if not quoted:
|
|
422
|
+
return
|
|
423
|
+
path = self._resolve_open_path(record.pid, name, args, quoted[0])
|
|
424
|
+
target = Entity(
|
|
425
|
+
type="directory",
|
|
426
|
+
id=f"directory:{path}",
|
|
427
|
+
name=path.name,
|
|
428
|
+
)
|
|
429
|
+
self.sink.emit(
|
|
430
|
+
RuntimeEvent.create(
|
|
431
|
+
session_id=self.session_id,
|
|
432
|
+
event_type="filesystem.create",
|
|
433
|
+
relation="CREATED",
|
|
434
|
+
source=self.process_entity(record.pid),
|
|
435
|
+
target=target,
|
|
436
|
+
timestamp=_iso_timestamp(record.timestamp),
|
|
437
|
+
attributes={
|
|
438
|
+
"syscall": name,
|
|
439
|
+
"within_watch_root": self._within_watch_root(path),
|
|
440
|
+
"attribution": "syscall",
|
|
441
|
+
"causal": True,
|
|
442
|
+
"backend": "strace",
|
|
443
|
+
},
|
|
444
|
+
)
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
def _parse_rename(self, record: TraceRecord, name: str, args: str, result: str) -> None:
|
|
448
|
+
if not result.startswith("0"):
|
|
449
|
+
return
|
|
450
|
+
quoted = _quoted_arguments(args)
|
|
451
|
+
if len(quoted) < 2:
|
|
452
|
+
return
|
|
453
|
+
source_path = self._resolve_path(record.pid, quoted[-2])
|
|
454
|
+
destination_path = self._resolve_path(record.pid, quoted[-1])
|
|
455
|
+
target = Entity(
|
|
456
|
+
type="file",
|
|
457
|
+
id=f"file:{destination_path}",
|
|
458
|
+
name=destination_path.name,
|
|
459
|
+
)
|
|
460
|
+
self.sink.emit(
|
|
461
|
+
RuntimeEvent.create(
|
|
462
|
+
session_id=self.session_id,
|
|
463
|
+
event_type="filesystem.rename",
|
|
464
|
+
relation="RENAMED_TO",
|
|
465
|
+
source=self.process_entity(record.pid),
|
|
466
|
+
target=target,
|
|
467
|
+
timestamp=_iso_timestamp(record.timestamp),
|
|
468
|
+
attributes={
|
|
469
|
+
"syscall": name,
|
|
470
|
+
"source_path": str(source_path),
|
|
471
|
+
"destination_path": str(destination_path),
|
|
472
|
+
"within_watch_root": self._within_watch_root(destination_path),
|
|
473
|
+
"attribution": "syscall",
|
|
474
|
+
"causal": True,
|
|
475
|
+
"backend": "strace",
|
|
476
|
+
},
|
|
477
|
+
)
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
def _parse_connect(self, record: TraceRecord, args: str, result: str) -> None:
|
|
481
|
+
family: str | None = None
|
|
482
|
+
endpoint: str | None = None
|
|
483
|
+
ipv4 = _IPV4_RE.search(args)
|
|
484
|
+
if ipv4 is not None:
|
|
485
|
+
family = "AF_INET"
|
|
486
|
+
endpoint = f"{ipv4.group('host')}:{ipv4.group('port')}"
|
|
487
|
+
else:
|
|
488
|
+
ipv6 = _IPV6_RE.search(args)
|
|
489
|
+
if ipv6 is not None:
|
|
490
|
+
family = "AF_INET6"
|
|
491
|
+
endpoint = f"[{ipv6.group('host')}]:{ipv6.group('port')}"
|
|
492
|
+
else:
|
|
493
|
+
unix = _UNIX_RE.search(args)
|
|
494
|
+
if unix is not None:
|
|
495
|
+
family = "AF_UNIX"
|
|
496
|
+
endpoint = unix.group("path")
|
|
497
|
+
if endpoint is None:
|
|
498
|
+
return
|
|
499
|
+
|
|
500
|
+
error_match = _CONNECT_ERROR_RE.match(result)
|
|
501
|
+
connected = error_match is None and not result.startswith("-1")
|
|
502
|
+
relation = "CONNECTED_TO" if connected else "CONNECT_ATTEMPTED"
|
|
503
|
+
event_type = "network.connection" if connected else "network.connection_attempt"
|
|
504
|
+
errno = error_match.group("errno") if error_match is not None else None
|
|
505
|
+
|
|
506
|
+
target_type = "unix_socket" if family == "AF_UNIX" else "network_endpoint"
|
|
507
|
+
target = Entity(
|
|
508
|
+
type=target_type,
|
|
509
|
+
id=f"{target_type}:{endpoint}",
|
|
510
|
+
name=endpoint,
|
|
511
|
+
)
|
|
512
|
+
self.sink.emit(
|
|
513
|
+
RuntimeEvent.create(
|
|
514
|
+
session_id=self.session_id,
|
|
515
|
+
event_type=event_type,
|
|
516
|
+
relation=relation,
|
|
517
|
+
source=self.process_entity(record.pid),
|
|
518
|
+
target=target,
|
|
519
|
+
timestamp=_iso_timestamp(record.timestamp),
|
|
520
|
+
attributes={
|
|
521
|
+
"family": family,
|
|
522
|
+
"endpoint": endpoint,
|
|
523
|
+
"syscall": "connect",
|
|
524
|
+
"result": result,
|
|
525
|
+
"errno": errno,
|
|
526
|
+
"connected": connected,
|
|
527
|
+
"attribution": "syscall",
|
|
528
|
+
"causal": True,
|
|
529
|
+
"backend": "strace",
|
|
530
|
+
},
|
|
531
|
+
)
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
def _resolve_open_path(self, pid: int, syscall: str, args: str, raw_path: str) -> Path:
|
|
535
|
+
path = Path(raw_path).expanduser()
|
|
536
|
+
if path.is_absolute():
|
|
537
|
+
return path.resolve(strict=False)
|
|
538
|
+
if syscall in {"openat", "openat2", "unlinkat", "mkdirat"}:
|
|
539
|
+
dirfd_match = _DIRFD_RE.search(args)
|
|
540
|
+
if dirfd_match is not None:
|
|
541
|
+
return (Path(dirfd_match.group("path")) / path).resolve(strict=False)
|
|
542
|
+
return self._resolve_path(pid, raw_path)
|
|
543
|
+
|
|
544
|
+
def _resolve_path(self, pid: int, raw_path: str) -> Path:
|
|
545
|
+
path = Path(raw_path).expanduser()
|
|
546
|
+
if path.is_absolute():
|
|
547
|
+
return path.resolve(strict=False)
|
|
548
|
+
cwd = self._cwd_by_pid.get(pid, self.watch_root)
|
|
549
|
+
return (cwd / path).resolve(strict=False)
|
|
550
|
+
|
|
551
|
+
def _within_watch_root(self, path: Path) -> bool:
|
|
552
|
+
return path == self.watch_root or self.watch_root in path.parents
|
|
553
|
+
|
|
554
|
+
@staticmethod
|
|
555
|
+
def _open_flags(syscall: str, args: str) -> str:
|
|
556
|
+
if syscall == "creat":
|
|
557
|
+
return "O_WRONLY|O_CREAT|O_TRUNC"
|
|
558
|
+
quoted = list(_QUOTED_RE.finditer(args))
|
|
559
|
+
if not quoted:
|
|
560
|
+
return ""
|
|
561
|
+
after_path = args[quoted[0].end() :].lstrip(", ")
|
|
562
|
+
return after_path.split(",", 1)[0].strip()
|
|
563
|
+
|
|
564
|
+
@staticmethod
|
|
565
|
+
def _open_relation(flags: str, syscall: str) -> str:
|
|
566
|
+
if syscall == "creat" or "O_CREAT" in flags:
|
|
567
|
+
return "OPENED_WRITE"
|
|
568
|
+
if "O_RDWR" in flags:
|
|
569
|
+
return "OPENED_READ_WRITE"
|
|
570
|
+
if any(flag in flags for flag in ("O_WRONLY", "O_TRUNC", "O_APPEND")):
|
|
571
|
+
return "OPENED_WRITE"
|
|
572
|
+
return "OPENED_READ"
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
class StraceRuntimeCollector:
|
|
576
|
+
"""Linux reference backend with syscall-attributed runtime evidence."""
|
|
577
|
+
|
|
578
|
+
backend_name = "strace"
|
|
579
|
+
|
|
580
|
+
def __init__(
|
|
581
|
+
self,
|
|
582
|
+
*,
|
|
583
|
+
session_id: str,
|
|
584
|
+
sink: JsonlSink,
|
|
585
|
+
watch_root: Path,
|
|
586
|
+
collect_filesystem: bool = True,
|
|
587
|
+
collect_network: bool = True,
|
|
588
|
+
trace_root: Path | None = None,
|
|
589
|
+
keep_raw_trace: bool = False,
|
|
590
|
+
) -> None:
|
|
591
|
+
self.session_id = session_id
|
|
592
|
+
self.sink = sink
|
|
593
|
+
self.watch_root = watch_root.expanduser().resolve()
|
|
594
|
+
self.collect_filesystem = collect_filesystem
|
|
595
|
+
self.collect_network = collect_network
|
|
596
|
+
self.trace_root = (
|
|
597
|
+
trace_root or (self.watch_root / ".execweave" / "traces" / session_id)
|
|
598
|
+
).resolve()
|
|
599
|
+
self.keep_raw_trace = keep_raw_trace
|
|
600
|
+
|
|
601
|
+
def run(self, command: list[str]) -> int:
|
|
602
|
+
if not command:
|
|
603
|
+
raise ValueError("command must not be empty")
|
|
604
|
+
executable = shutil.which("strace")
|
|
605
|
+
if not sys.platform.startswith("linux") or executable is None:
|
|
606
|
+
raise RuntimeError("The strace backend requires Linux and the strace executable")
|
|
607
|
+
|
|
608
|
+
self.trace_root.mkdir(parents=True, exist_ok=True)
|
|
609
|
+
trace_prefix = self.trace_root / "trace"
|
|
610
|
+
agent_name = infer_agent_name(command)
|
|
611
|
+
agent = Entity(type="agent", id=f"agent:{agent_name}", name=agent_name)
|
|
612
|
+
session = Entity(
|
|
613
|
+
type="session",
|
|
614
|
+
id=f"session:{self.session_id}",
|
|
615
|
+
name=self.session_id,
|
|
616
|
+
attributes={
|
|
617
|
+
"command": command,
|
|
618
|
+
"cwd": str(self.watch_root),
|
|
619
|
+
"backend": self.backend_name,
|
|
620
|
+
},
|
|
621
|
+
)
|
|
622
|
+
self.sink.emit(
|
|
623
|
+
RuntimeEvent.create(
|
|
624
|
+
session_id=self.session_id,
|
|
625
|
+
event_type="session.started",
|
|
626
|
+
relation="STARTED_SESSION",
|
|
627
|
+
source=agent,
|
|
628
|
+
target=session,
|
|
629
|
+
attributes={"collector_pid": os.getpid(), "backend": self.backend_name},
|
|
630
|
+
)
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
trace_expression = "%process"
|
|
634
|
+
if self.collect_filesystem:
|
|
635
|
+
trace_expression += ",%file"
|
|
636
|
+
if self.collect_network:
|
|
637
|
+
trace_expression += ",%network"
|
|
638
|
+
strace_command = [
|
|
639
|
+
executable,
|
|
640
|
+
"-ff",
|
|
641
|
+
"-ttt",
|
|
642
|
+
"-T",
|
|
643
|
+
"-yy",
|
|
644
|
+
"-s",
|
|
645
|
+
"256",
|
|
646
|
+
"-o",
|
|
647
|
+
str(trace_prefix),
|
|
648
|
+
"-e",
|
|
649
|
+
f"trace={trace_expression}",
|
|
650
|
+
"--",
|
|
651
|
+
*command,
|
|
652
|
+
]
|
|
653
|
+
return_code = 1
|
|
654
|
+
try:
|
|
655
|
+
completed = subprocess.run(strace_command, cwd=str(self.watch_root), check=False)
|
|
656
|
+
return_code = int(completed.returncode)
|
|
657
|
+
records = read_trace_records(self.trace_root)
|
|
658
|
+
parser = StraceParser(
|
|
659
|
+
session_id=self.session_id,
|
|
660
|
+
sink=self.sink,
|
|
661
|
+
watch_root=self.watch_root,
|
|
662
|
+
command=command,
|
|
663
|
+
)
|
|
664
|
+
parser.parse(records)
|
|
665
|
+
return return_code
|
|
666
|
+
finally:
|
|
667
|
+
if not self.keep_raw_trace:
|
|
668
|
+
shutil.rmtree(self.trace_root, ignore_errors=True)
|
|
669
|
+
self.sink.emit(
|
|
670
|
+
RuntimeEvent.create(
|
|
671
|
+
session_id=self.session_id,
|
|
672
|
+
event_type="session.finished",
|
|
673
|
+
relation="FINISHED_SESSION",
|
|
674
|
+
source=session,
|
|
675
|
+
attributes={
|
|
676
|
+
"return_code": return_code,
|
|
677
|
+
"backend": self.backend_name,
|
|
678
|
+
"raw_trace_kept": self.keep_raw_trace,
|
|
679
|
+
"trace_directory": str(self.trace_root) if self.keep_raw_trace else None,
|
|
680
|
+
},
|
|
681
|
+
)
|
|
682
|
+
)
|