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.
Files changed (49) hide show
  1. execweave/__init__.py +3 -0
  2. execweave/__main__.py +5 -0
  3. execweave/analysis.py +406 -0
  4. execweave/backends.py +63 -0
  5. execweave/benchmark.py +80 -0
  6. execweave/claude_adapter.py +448 -0
  7. execweave/claude_hook_cli.py +101 -0
  8. execweave/claude_record.py +106 -0
  9. execweave/cli.py +588 -0
  10. execweave/codex_adapter.py +314 -0
  11. execweave/codex_hook_cli.py +98 -0
  12. execweave/codex_record.py +111 -0
  13. execweave/collector.py +301 -0
  14. execweave/correlation.py +604 -0
  15. execweave/cursor_adapter.py +347 -0
  16. execweave/cursor_hook_cli.py +82 -0
  17. execweave/cursor_record.py +96 -0
  18. execweave/filesystem.py +103 -0
  19. execweave/focus.py +118 -0
  20. execweave/gemini_adapter.py +265 -0
  21. execweave/gemini_hook_cli.py +77 -0
  22. execweave/gemini_record.py +94 -0
  23. execweave/graph.py +300 -0
  24. execweave/graph_ops.py +446 -0
  25. execweave/inference_gateway.py +422 -0
  26. execweave/inference_gateway_cli.py +106 -0
  27. execweave/inference_identity.py +76 -0
  28. execweave/inference_identity_cli.py +60 -0
  29. execweave/live.py +275 -0
  30. execweave/model_runtime.py +535 -0
  31. execweave/model_runtime_cli.py +154 -0
  32. execweave/opencode_adapter.py +316 -0
  33. execweave/opencode_hook_cli.py +57 -0
  34. execweave/opencode_plugin_cli.py +110 -0
  35. execweave/opencode_record.py +96 -0
  36. execweave/overhead_benchmark.py +440 -0
  37. execweave/provider_record.py +215 -0
  38. execweave/schema.py +62 -0
  39. execweave/semantic.py +346 -0
  40. execweave/sink.py +33 -0
  41. execweave/strace_backend.py +682 -0
  42. execweave/validate.py +193 -0
  43. execweave/viewer.py +283 -0
  44. execweave/workflow.py +114 -0
  45. execweave-0.6.0.dist-info/METADATA +356 -0
  46. execweave-0.6.0.dist-info/RECORD +49 -0
  47. execweave-0.6.0.dist-info/WHEEL +4 -0
  48. execweave-0.6.0.dist-info/entry_points.txt +17 -0
  49. 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
+ )