handcuff 0.2.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 (65) hide show
  1. handcuff/__init__.py +0 -0
  2. handcuff/alerts/__init__.py +0 -0
  3. handcuff/alerts/webhooks.py +56 -0
  4. handcuff/banner.py +128 -0
  5. handcuff/capture/__init__.py +0 -0
  6. handcuff/capture/base.py +28 -0
  7. handcuff/capture/file_watch.py +143 -0
  8. handcuff/capture/net_watch.py +139 -0
  9. handcuff/capture/psutil_backend.py +117 -0
  10. handcuff/capture/redact.py +67 -0
  11. handcuff/capture/tree.py +43 -0
  12. handcuff/cli.py +362 -0
  13. handcuff/config.py +65 -0
  14. handcuff/core/__init__.py +0 -0
  15. handcuff/core/bus.py +74 -0
  16. handcuff/core/events.py +159 -0
  17. handcuff/core/hashing.py +79 -0
  18. handcuff/core/session.py +95 -0
  19. handcuff/core/signing.py +78 -0
  20. handcuff/doctor.py +124 -0
  21. handcuff/enforce/__init__.py +0 -0
  22. handcuff/enforce/file_acl.py +145 -0
  23. handcuff/enforce/firewall.py +179 -0
  24. handcuff/enforce/scan.py +50 -0
  25. handcuff/export/__init__.py +0 -0
  26. handcuff/export/html_export.py +126 -0
  27. handcuff/export/json_export.py +94 -0
  28. handcuff/export/md_export.py +113 -0
  29. handcuff/gateway/__init__.py +0 -0
  30. handcuff/gateway/gateway.py +331 -0
  31. handcuff/gateway/log.py +66 -0
  32. handcuff/gateway/policy.py +93 -0
  33. handcuff/integrations/__init__.py +5 -0
  34. handcuff/integrations/langchain.py +459 -0
  35. handcuff/replay.py +119 -0
  36. handcuff/rules/__init__.py +0 -0
  37. handcuff/rules/defaults.yaml +25 -0
  38. handcuff/rules/dsl.py +209 -0
  39. handcuff/rules/engine.py +152 -0
  40. handcuff/rules/injection.py +150 -0
  41. handcuff/rules/trust.py +53 -0
  42. handcuff/runner.py +382 -0
  43. handcuff/storage/__init__.py +0 -0
  44. handcuff/storage/db.py +101 -0
  45. handcuff/storage/writer.py +140 -0
  46. handcuff/tui/__init__.py +0 -0
  47. handcuff/tui/app.py +235 -0
  48. handcuff/tui/risk.py +65 -0
  49. handcuff/tui/styles.tcss +106 -0
  50. handcuff/tui/theme.py +62 -0
  51. handcuff/tui/widgets/__init__.py +0 -0
  52. handcuff/tui/widgets/banner_header.py +102 -0
  53. handcuff/tui/widgets/flag_cards.py +63 -0
  54. handcuff/tui/widgets/footer.py +42 -0
  55. handcuff/tui/widgets/hero.py +336 -0
  56. handcuff/tui/widgets/logs.py +69 -0
  57. handcuff/tui/widgets/monitoring.py +53 -0
  58. handcuff/tui/widgets/recommendation.py +49 -0
  59. handcuff/tui/widgets/risk_overview.py +63 -0
  60. handcuff/tui/widgets/trust.py +92 -0
  61. handcuff/verify.py +76 -0
  62. handcuff-0.2.0.dist-info/METADATA +149 -0
  63. handcuff-0.2.0.dist-info/RECORD +65 -0
  64. handcuff-0.2.0.dist-info/WHEEL +4 -0
  65. handcuff-0.2.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,43 @@
1
+ """Process-tree tracker (AL-022).
2
+
3
+ Maintains the live set of PIDs descended from a root PID by polling
4
+ `psutil`. Short-lived processes that spawn and exit between polls are
5
+ inherently missable with this approach (documented limitation) — the
6
+ capture backend built on top of this is expected to emit `LOSS` for
7
+ detected gaps.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import psutil
13
+
14
+
15
+ class ProcessTreeTracker:
16
+ def __init__(self, root_pid: int) -> None:
17
+ self.root_pid = root_pid
18
+ self._pids: set[int] = set()
19
+
20
+ @property
21
+ def pids(self) -> set[int]:
22
+ return set(self._pids)
23
+
24
+ def refresh(self) -> tuple[set[int], set[int]]:
25
+ """Poll the system for descendants of root_pid. Returns
26
+ (added, removed) PID sets relative to the previous snapshot."""
27
+ current: set[int] = set()
28
+ if psutil.pid_exists(self.root_pid):
29
+ current.add(self.root_pid)
30
+ try:
31
+ root = psutil.Process(self.root_pid)
32
+ for child in root.children(recursive=True):
33
+ current.add(child.pid)
34
+ except psutil.NoSuchProcess:
35
+ pass
36
+
37
+ added = current - self._pids
38
+ removed = self._pids - current
39
+ self._pids = current
40
+ return added, removed
41
+
42
+ def contains(self, pid: int) -> bool:
43
+ return pid in self._pids
handcuff/cli.py ADDED
@@ -0,0 +1,362 @@
1
+ """Handcuff CLI (Typer app). AL-001/AL-024.
2
+
3
+ Commands implemented for the vertical slice: `watch`, `sessions`.
4
+ Remaining commands are placeholders so `--help` lists the full surface
5
+ described in the PRD/TRD; they raise NotImplementedError until their
6
+ tickets land.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from datetime import UTC, datetime
13
+ from pathlib import Path
14
+
15
+ import typer
16
+
17
+ from handcuff.banner import print_banner, should_show_banner
18
+ from handcuff.config import DEFAULT_DB_PATH
19
+ from handcuff.export.json_export import export_session_json
20
+ from handcuff.runner import list_sessions, watch_command
21
+ from handcuff.storage.db import connect
22
+
23
+ app = typer.Typer(help="A dashcam for your AI agents.")
24
+
25
+
26
+ @app.callback(invoke_without_command=True)
27
+ def main(
28
+ ctx: typer.Context,
29
+ no_banner: bool = typer.Option(
30
+ False, "--no-banner", help="Skip the startup banner for this invocation."
31
+ ),
32
+ ) -> None:
33
+ """A dashcam for your AI agents."""
34
+ # The full welcome panel is a startup/session-entry thing (like
35
+ # Claude Code's splash), not something that should reprint on every
36
+ # one-shot command — showing it for `handcuff sessions`, `export`,
37
+ # etc. every single time floods the terminal with a large repeated
38
+ # panel (confirmed directly from a real screenshot: back-to-back
39
+ # invocations produced stacked, overlapping panel text). Only show
40
+ # it when entering the interactive dashboard (`tui`) or when
41
+ # `handcuff` is run bare with no subcommand.
42
+ show_for = {"tui", None}
43
+ if not no_banner and ctx.invoked_subcommand in show_for:
44
+ print_banner()
45
+
46
+
47
+ @app.command()
48
+ def watch(
49
+ command: list[str] = typer.Argument(..., help="Command to run and record, e.g. -- bash -c ..."),
50
+ label: str = typer.Option(None, help="Human-readable label for this session"),
51
+ quiet: bool = typer.Option(False, "--quiet", help="Headless: record with no UI"),
52
+ watch_path: list[str] = typer.Option(
53
+ None,
54
+ "--watch-path",
55
+ help="Directory to watch for file events (repeatable). Default: cwd + home.",
56
+ ),
57
+ enforce: bool = typer.Option(
58
+ False,
59
+ "--enforce",
60
+ help=(
61
+ "Real OS-level blocking: deny-ACL sensitive files under watch paths "
62
+ "(no admin needed) and, if elevated, block outbound network via "
63
+ "Windows Firewall except --allow-domain. See `handcuff doctor` for "
64
+ "what's actually active."
65
+ ),
66
+ ),
67
+ protect_path: list[str] = typer.Option(
68
+ None, "--protect-path", help="Extra path to scan for sensitive files under --enforce."
69
+ ),
70
+ allow_domain: list[str] = typer.Option(
71
+ None, "--allow-domain", help="Domain allowed through the firewall block (repeatable)."
72
+ ),
73
+ webhook_url: str = typer.Option(
74
+ None,
75
+ "--webhook-url",
76
+ help="Post a content-light JSON payload here when a critical alert fires. "
77
+ "HTTPS required (plain HTTP only allowed to localhost).",
78
+ ),
79
+ hash_content: bool = typer.Option(
80
+ False,
81
+ "--hash-content",
82
+ help="Record a SHA-256 of written file contents (files up to 1 MiB). "
83
+ "Only the hash is stored, never the content.",
84
+ ),
85
+ ) -> None:
86
+ """Launch COMMAND under observation; record process activity to a session."""
87
+ paths = [Path(p) for p in watch_path] if watch_path else None
88
+ protected = [Path(p) for p in protect_path] if protect_path else None
89
+ session_id = asyncio.run(
90
+ watch_command(
91
+ list(command),
92
+ label=label,
93
+ watch_paths=paths,
94
+ enforce=enforce,
95
+ protected_paths=protected,
96
+ allowed_domains=list(allow_domain) if allow_domain else None,
97
+ webhook_url=webhook_url,
98
+ hash_content=hash_content,
99
+ )
100
+ )
101
+ typer.echo(f"session {session_id} closed")
102
+ if enforce:
103
+ conn = connect(DEFAULT_DB_PATH)
104
+ marks = conn.execute(
105
+ "SELECT payload_json FROM events WHERE session_id=? AND kind='MARK'", (session_id,)
106
+ ).fetchall()
107
+ for m in marks:
108
+ typer.echo(f" [enforce] {m['payload_json']}")
109
+
110
+
111
+ @app.command()
112
+ def sessions() -> None:
113
+ """List recorded sessions."""
114
+ rows = list_sessions()
115
+ if not rows:
116
+ typer.echo("no sessions recorded yet")
117
+ raise typer.Exit()
118
+ for row in rows:
119
+ started = datetime.fromtimestamp(row["started_at"] / 1000, tz=UTC).isoformat()
120
+ typer.echo(
121
+ f"{row['id']} {row['status']:<24} {started} {row['label'] or ''} {row['target_cmd']}"
122
+ )
123
+
124
+
125
+ @app.command()
126
+ def attach(
127
+ pid: int,
128
+ label: str = typer.Option(None, help="Human-readable label for this session"),
129
+ duration: float = typer.Option(
130
+ None, "--duration", help="Stop recording after this many seconds (default: until pid exits)"
131
+ ),
132
+ ) -> None:
133
+ """Attach to an already-running process tree and record it until it exits."""
134
+ from handcuff.runner import attach_command
135
+
136
+ session_id = asyncio.run(attach_command(pid, label=label, duration_s=duration))
137
+ typer.echo(f"session {session_id} closed")
138
+
139
+
140
+ @app.command()
141
+ def tui(
142
+ session_id: str = typer.Argument(None, help="Session to watch (default: most recent)"),
143
+ ) -> None:
144
+ """Open the terminal dashboard: live event stream, alerts, process tree, stats."""
145
+ import time
146
+
147
+ from handcuff.tui.app import run_tui
148
+
149
+ if should_show_banner():
150
+ # Hold the banner briefly before Textual clears the screen and
151
+ # takes over — otherwise it flashes by unseen, since Textual's
152
+ # alt-screen switch happens almost immediately on run().
153
+ time.sleep(0.6)
154
+
155
+ run_tui(session_id=session_id)
156
+
157
+
158
+ @app.command()
159
+ def replay(
160
+ session_id: str,
161
+ to_alert: int = typer.Option(
162
+ None, "--to-alert", help="Jump straight to the Nth alert (1-indexed) and print it"
163
+ ),
164
+ ) -> None:
165
+ """Scrub through a past session.
166
+
167
+ Interactive by default: n=next, p=previous, a=next alert,
168
+ A=previous alert, q=quit. Pass --to-alert N for a non-interactive
169
+ jump-and-print (useful for scripting/CI).
170
+ """
171
+ from handcuff.replay import ReplaySession
172
+
173
+ conn = connect(DEFAULT_DB_PATH)
174
+ rs = ReplaySession(conn, session_id)
175
+ if rs.total == 0:
176
+ typer.echo("session has no events")
177
+ raise typer.Exit()
178
+
179
+ def render(ev) -> str:
180
+ marker = f" [ALERT: {', '.join(ev.alert_rule_ids)}]" if ev.alert_rule_ids else ""
181
+ return f"[{rs.cursor + 1}/{rs.total}] seq={ev.seq} {ev.kind}{marker} {ev.payload}"
182
+
183
+ if to_alert is not None:
184
+ rs.cursor = 0
185
+ event = None
186
+ for _ in range(to_alert):
187
+ event = rs.next_alert()
188
+ if event is None:
189
+ typer.echo(f"only {rs.alert_count()} alert(s) in this session")
190
+ raise typer.Exit(code=1)
191
+ typer.echo(render(event))
192
+ return
193
+
194
+ typer.echo(render(rs.current()))
195
+ typer.echo("commands: n=next p=prev a=next-alert A=prev-alert q=quit")
196
+ while True:
197
+ try:
198
+ cmd = input("> ").strip()
199
+ except EOFError:
200
+ break
201
+ if cmd == "q":
202
+ break
203
+ elif cmd == "n":
204
+ ev = rs.step_forward()
205
+ elif cmd == "p":
206
+ ev = rs.step_backward()
207
+ elif cmd == "a":
208
+ ev = rs.next_alert() or rs.current()
209
+ elif cmd == "A":
210
+ ev = rs.prev_alert() or rs.current()
211
+ else:
212
+ typer.echo("unknown command")
213
+ continue
214
+ typer.echo(render(ev))
215
+
216
+
217
+ @app.command()
218
+ def export(
219
+ session_id: str,
220
+ format: str = typer.Option("json", help="Export format: json, md, or html"),
221
+ output: str = typer.Option(None, help="Output file path (default: <session_id>.<format>)"),
222
+ ) -> None:
223
+ """Export a session report."""
224
+ from handcuff.export.html_export import export_session_html
225
+ from handcuff.export.md_export import export_session_md
226
+
227
+ exporters = {"json": export_session_json, "md": export_session_md, "html": export_session_html}
228
+ if format not in exporters:
229
+ typer.echo(f"unknown format {format!r}; choose from: {', '.join(exporters)}")
230
+ raise typer.Exit(code=2)
231
+ conn = connect(DEFAULT_DB_PATH)
232
+ out_path = Path(output) if output else Path(f"{session_id}.{format}")
233
+ exporters[format](conn, session_id, out_path)
234
+ typer.echo(f"exported to {out_path}")
235
+
236
+
237
+ @app.command()
238
+ def verify(
239
+ session_id: str = typer.Argument(None, help="Session id to verify from the live DB"),
240
+ file: str = typer.Option(None, "--file", help="Verify a JSON export file instead"),
241
+ ) -> None:
242
+ """Re-validate a session's hash chain and signature."""
243
+ from handcuff.verify import verify_export_file, verify_session_db
244
+
245
+ if file:
246
+ result = verify_export_file(Path(file))
247
+ elif session_id:
248
+ conn = connect(DEFAULT_DB_PATH)
249
+ result = verify_session_db(conn, session_id)
250
+ else:
251
+ typer.echo("provide a session_id or --file")
252
+ raise typer.Exit(code=2)
253
+
254
+ typer.echo(f"chain: {'OK' if result.chain.ok else 'FAILED'}")
255
+ if not result.chain.ok:
256
+ typer.echo(f" failed at seq={result.chain.failed_seq} reason={result.chain.reason}")
257
+ if result.signature_ok is not None:
258
+ typer.echo(f"signature: {'OK' if result.signature_ok else 'FAILED'}")
259
+
260
+ if not result.chain.ok or result.signature_ok is False:
261
+ raise typer.Exit(code=1)
262
+
263
+
264
+ rules_app = typer.Typer(help="Manage alert rules / trust list.")
265
+ trust_app = typer.Typer(help="Manage the trust allowlist (domains/paths/cmds).")
266
+ rules_app.add_typer(trust_app, name="trust")
267
+ app.add_typer(rules_app, name="rules")
268
+
269
+
270
+ @trust_app.command("add")
271
+ def trust_add(
272
+ kind: str = typer.Argument(..., help="domain|path|cmd"),
273
+ value: str = typer.Argument(...),
274
+ ) -> None:
275
+ """Add an entry to the trust list."""
276
+ from handcuff.rules.trust import add_trust
277
+
278
+ conn = connect(DEFAULT_DB_PATH)
279
+ add_trust(conn, kind, value)
280
+ typer.echo(f"trusted {kind}: {value}")
281
+
282
+
283
+ @trust_app.command("list")
284
+ def trust_list(kind: str = typer.Option(None, help="Filter by kind (domain|path|cmd)")) -> None:
285
+ """List trust list entries."""
286
+ from handcuff.rules.trust import list_trust
287
+
288
+ conn = connect(DEFAULT_DB_PATH)
289
+ entries = list_trust(conn, kind=kind)
290
+ if not entries:
291
+ typer.echo("no trust entries")
292
+ return
293
+ for e in entries:
294
+ typer.echo(f"{e.kind:<8} {e.value}")
295
+
296
+
297
+ @trust_app.command("rm")
298
+ def trust_rm(
299
+ kind: str = typer.Argument(..., help="domain|path|cmd"),
300
+ value: str = typer.Argument(...),
301
+ ) -> None:
302
+ """Remove an entry from the trust list."""
303
+ from handcuff.rules.trust import remove_trust
304
+
305
+ conn = connect(DEFAULT_DB_PATH)
306
+ removed = remove_trust(conn, kind, value)
307
+ typer.echo(f"removed: {removed}")
308
+
309
+
310
+ config_app = typer.Typer(help="Show/edit configuration (~/.config/handcuff/config.yaml).")
311
+ app.add_typer(config_app, name="config")
312
+
313
+
314
+ @config_app.command("list")
315
+ def config_list() -> None:
316
+ """Show all config values (defaults merged with the config file)."""
317
+ from handcuff.config import DEFAULT_CONFIG_PATH, load_config
318
+
319
+ typer.echo(f"config file: {DEFAULT_CONFIG_PATH} (exists={DEFAULT_CONFIG_PATH.exists()})")
320
+ typer.echo(f"db path: {DEFAULT_DB_PATH}")
321
+ for k, v in load_config().items():
322
+ typer.echo(f"{k} = {v}")
323
+
324
+
325
+ @config_app.command("get")
326
+ def config_get(key: str) -> None:
327
+ """Print one config value."""
328
+ from handcuff.config import load_config
329
+
330
+ cfg = load_config()
331
+ if key not in cfg:
332
+ typer.echo(f"unknown key {key!r}; known: {', '.join(cfg)}")
333
+ raise typer.Exit(code=2)
334
+ typer.echo(f"{cfg[key]}")
335
+
336
+
337
+ @config_app.command("set")
338
+ def config_set(key: str, value: str) -> None:
339
+ """Set a config value, e.g. `handcuff config set retention_days 14`."""
340
+ from handcuff.config import CONFIG_KEYS, set_config_value
341
+
342
+ try:
343
+ typed = set_config_value(key, value)
344
+ except KeyError:
345
+ typer.echo(f"unknown key {key!r}; known: {', '.join(CONFIG_KEYS)}")
346
+ raise typer.Exit(code=2) from None
347
+ except ValueError as exc:
348
+ typer.echo(f"invalid value: {exc}")
349
+ raise typer.Exit(code=2) from None
350
+ typer.echo(f"{key} = {typed}")
351
+
352
+
353
+ @app.command()
354
+ def doctor() -> None:
355
+ """Environment diagnostics: platform, capture capabilities, DB/key health."""
356
+ from handcuff.doctor import format_report, run_doctor
357
+
358
+ typer.echo(format_report(run_doctor()))
359
+
360
+
361
+ if __name__ == "__main__":
362
+ app()
handcuff/config.py ADDED
@@ -0,0 +1,65 @@
1
+ """Default paths (TRD §10) + user config file.
2
+
3
+ The config file is a small flat YAML at ~/.config/handcuff/config.yaml,
4
+ read/written via `handcuff config get|set|list`. Only known keys are
5
+ accepted so a typo'd `config set` fails loudly instead of silently doing
6
+ nothing."""
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from ruamel.yaml import YAML
14
+
15
+ CONFIG_DIR = Path.home() / ".config" / "handcuff"
16
+ DATA_DIR = Path.home() / ".local" / "share" / "handcuff"
17
+ DEFAULT_DB_PATH = DATA_DIR / "handcuff.db"
18
+ DEFAULT_KEY_PATH = CONFIG_DIR / "ed25519.key"
19
+ DEFAULT_CONFIG_PATH = CONFIG_DIR / "config.yaml"
20
+
21
+ # key -> (default, parser). Parsers turn the CLI string into the typed value.
22
+ def _parse_bool(s: str) -> bool:
23
+ if s.lower() in {"true", "1", "yes", "on"}:
24
+ return True
25
+ if s.lower() in {"false", "0", "no", "off"}:
26
+ return False
27
+ raise ValueError(f"not a boolean: {s!r}")
28
+
29
+
30
+ CONFIG_KEYS: dict[str, tuple[Any, Any]] = {
31
+ "webhook_url": (None, str),
32
+ "retention_days": (30, int),
33
+ "redaction": (True, _parse_bool),
34
+ "hash_content": (False, _parse_bool),
35
+ }
36
+
37
+
38
+ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> dict[str, Any]:
39
+ """Defaults merged with whatever the config file overrides."""
40
+ cfg = {k: default for k, (default, _) in CONFIG_KEYS.items()}
41
+ if path.exists():
42
+ data = YAML(typ="safe").load(path.read_text(encoding="utf-8")) or {}
43
+ for k, v in data.items():
44
+ if k in CONFIG_KEYS:
45
+ cfg[k] = v
46
+ return cfg
47
+
48
+
49
+ def set_config_value(key: str, raw_value: str, path: Path = DEFAULT_CONFIG_PATH) -> Any:
50
+ """Parse and persist one key. Returns the typed value. Raises
51
+ KeyError/ValueError on unknown key or unparseable value."""
52
+ if key not in CONFIG_KEYS:
53
+ raise KeyError(key)
54
+ _, parser = CONFIG_KEYS[key]
55
+ value = parser(raw_value)
56
+ yaml = YAML(typ="safe")
57
+ yaml.default_flow_style = False
58
+ data: dict[str, Any] = {}
59
+ if path.exists():
60
+ data = yaml.load(path.read_text(encoding="utf-8")) or {}
61
+ data[key] = value
62
+ path.parent.mkdir(parents=True, exist_ok=True)
63
+ with path.open("w", encoding="utf-8") as f:
64
+ yaml.dump(data, f)
65
+ return value
File without changes
handcuff/core/bus.py ADDED
@@ -0,0 +1,74 @@
1
+ """Async event bus + fan-out with backpressure (AL-020).
2
+
3
+ A single bounded ingress queue (maxsize=10_000) fans out to N consumer
4
+ queues (StorageWriter, RulesEngine, TUI, ...). On ingress overflow we drop
5
+ the *oldest* queued item, increment a counter, and synthesize a `LOSS`
6
+ event summarizing the drop — the capture pipeline must never block the
7
+ observed agent (NFR-2).
8
+
9
+ Each consumer gets its own unbounded-ish fan-out queue so a slow consumer
10
+ (e.g. a laggy TUI) cannot block a fast one (e.g. the storage writer):
11
+ consumer queues are themselves bounded and drop-oldest independently.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import time
18
+ from collections.abc import AsyncIterator
19
+
20
+ from handcuff.core.events import EventKind, RawEvent
21
+
22
+ INGRESS_MAXSIZE = 10_000
23
+ CONSUMER_MAXSIZE = 10_000
24
+
25
+
26
+ class EventBus:
27
+ def __init__(self, maxsize: int = INGRESS_MAXSIZE) -> None:
28
+ self._maxsize = maxsize
29
+ self._consumers: dict[str, asyncio.Queue[RawEvent]] = {}
30
+ self.dropped_count = 0
31
+
32
+ def subscribe(self, name: str, maxsize: int = CONSUMER_MAXSIZE) -> asyncio.Queue[RawEvent]:
33
+ q: asyncio.Queue[RawEvent] = asyncio.Queue(maxsize=maxsize)
34
+ self._consumers[name] = q
35
+ return q
36
+
37
+ def unsubscribe(self, name: str) -> None:
38
+ self._consumers.pop(name, None)
39
+
40
+ async def publish(self, event: RawEvent) -> None:
41
+ """Fan out to every consumer. Each consumer queue drops its own
42
+ oldest items on overflow rather than blocking the publisher."""
43
+ for name, q in self._consumers.items():
44
+ overflowed = False
45
+ # Ensure room for both the LOSS marker and the real event
46
+ # (2 free slots), for queues with capacity to hold both;
47
+ # a maxsize==1 queue can only ever hold the latest event.
48
+ room_needed = 2 if q.maxsize > 1 else 1
49
+ while q.maxsize - q.qsize() < room_needed:
50
+ try:
51
+ q.get_nowait()
52
+ except asyncio.QueueEmpty:
53
+ break
54
+ self.dropped_count += 1
55
+ overflowed = True
56
+ if overflowed and room_needed == 2:
57
+ loss = RawEvent(
58
+ kind=EventKind.LOSS,
59
+ ts=int(time.time() * 1000),
60
+ pid=None,
61
+ ppid=None,
62
+ payload={
63
+ "reason": "buffer_overflow",
64
+ "dropped_estimate": self.dropped_count,
65
+ "consumer": name,
66
+ },
67
+ )
68
+ q.put_nowait(loss)
69
+ q.put_nowait(event)
70
+
71
+ async def consume(self, name: str) -> AsyncIterator[RawEvent]:
72
+ q = self._consumers[name]
73
+ while True:
74
+ yield await q.get()
@@ -0,0 +1,159 @@
1
+ """Event & session dataclasses (AL-010).
2
+
3
+ `RawEvent` is what capture backends emit (pre-sequencing). `Event` is the
4
+ persisted, chain-hashed form written by the storage layer. Payloads are
5
+ kind-specific TypedDicts documented in TRD §3.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from enum import Enum
12
+ from typing import Any, TypedDict
13
+
14
+
15
+ class EventKind(str, Enum):
16
+ PROCESS_SPAWN = "PROCESS_SPAWN"
17
+ PROCESS_EXIT = "PROCESS_EXIT"
18
+ FILE_READ = "FILE_READ"
19
+ FILE_WRITE = "FILE_WRITE"
20
+ FILE_DELETE = "FILE_DELETE"
21
+ FILE_RENAME = "FILE_RENAME"
22
+ NET_CONNECT = "NET_CONNECT"
23
+ LOSS = "LOSS"
24
+ ALERT = "ALERT"
25
+ MARK = "MARK"
26
+ GATEWAY_DENY = "GATEWAY_DENY"
27
+ INJECTION_DETECTED = "INJECTION_DETECTED"
28
+ AGENT_STEP = "AGENT_STEP"
29
+ TOOL_CALL = "TOOL_CALL"
30
+ TOOL_RESULT = "TOOL_RESULT"
31
+ RETRIEVAL = "RETRIEVAL"
32
+
33
+
34
+ class ProcessSpawnPayload(TypedDict, total=False):
35
+ argv: list[str]
36
+ cwd: str
37
+ exe: str
38
+ user: str
39
+
40
+
41
+ class ProcessExitPayload(TypedDict, total=False):
42
+ exit_code: int
43
+ duration_ms: int
44
+
45
+
46
+ class FileReadPayload(TypedDict, total=False):
47
+ path: str
48
+ bytes: int
49
+
50
+
51
+ class GatewayDenyPayload(TypedDict, total=False):
52
+ op: str
53
+ path: str
54
+ reason: str
55
+
56
+
57
+ class FileWritePayload(TypedDict, total=False):
58
+ path: str
59
+ bytes_delta: int
60
+ op: str
61
+ content_hash: str
62
+
63
+
64
+ class FileDeletePayload(TypedDict, total=False):
65
+ path: str
66
+
67
+
68
+ class FileRenamePayload(TypedDict, total=False):
69
+ path: str
70
+ to: str
71
+
72
+
73
+ class NetConnectPayload(TypedDict, total=False):
74
+ dst_ip: str
75
+ domain: str | None
76
+ port: int
77
+ proto: str
78
+ bytes: int
79
+
80
+
81
+ class LossPayload(TypedDict, total=False):
82
+ expected_seq: int
83
+ reason: str
84
+ dropped_estimate: int
85
+
86
+
87
+ class AlertPayload(TypedDict, total=False):
88
+ rule_id: str
89
+ severity: str
90
+ message: str
91
+
92
+
93
+ class MarkPayload(TypedDict, total=False):
94
+ note: str
95
+
96
+
97
+ class AgentStepPayload(TypedDict, total=False):
98
+ agent: str
99
+ run_id: str
100
+ parent_run_id: str | None
101
+ kind: str # "chain_start" | "chain_end" | "agent_action" | "agent_finish"
102
+ name: str
103
+
104
+
105
+ class ToolCallPayload(TypedDict, total=False):
106
+ agent: str
107
+ run_id: str
108
+ tool: str
109
+ input_excerpt: str
110
+
111
+
112
+ class ToolResultPayload(TypedDict, total=False):
113
+ agent: str
114
+ run_id: str
115
+ tool: str
116
+ output_excerpt: str
117
+ error: bool
118
+ blocked: bool
119
+
120
+
121
+ class RetrievalPayload(TypedDict, total=False):
122
+ agent: str
123
+ run_id: str
124
+ retriever: str
125
+ doc_count: int
126
+
127
+
128
+ class InjectionDetectedPayload(TypedDict, total=False):
129
+ source: str # path or URL the tainting content came from
130
+ signature: str # which heuristic signature matched
131
+ reason: str
132
+ excerpt: str # short, already-redacted-adjacent excerpt for audit
133
+ taint_expires_at: int # epoch ms; sensitive ops before this are gated
134
+
135
+
136
+ @dataclass(slots=True)
137
+ class RawEvent:
138
+ """Emitted by a capture backend, before sequencing/hashing."""
139
+
140
+ kind: EventKind
141
+ ts: int # epoch ms
142
+ pid: int | None
143
+ ppid: int | None
144
+ payload: dict[str, Any] = field(default_factory=dict)
145
+
146
+
147
+ @dataclass(slots=True)
148
+ class Event:
149
+ """Persisted, hash-chained event row (mirrors the `events` table)."""
150
+
151
+ session_id: str
152
+ seq: int
153
+ ts: int
154
+ kind: EventKind
155
+ pid: int | None
156
+ ppid: int | None
157
+ payload: dict[str, Any]
158
+ prev_hash: str
159
+ hash: str