agentmetry 0.4.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 (131) hide show
  1. agentmetry/__init__.py +12 -0
  2. agentmetry/api/__init__.py +0 -0
  3. agentmetry/api/main.py +232 -0
  4. agentmetry/api/routes/__init__.py +0 -0
  5. agentmetry/api/routes/audit.py +396 -0
  6. agentmetry/api/websocket.py +53 -0
  7. agentmetry/api/ws_bridge.py +34 -0
  8. agentmetry/cli/__init__.py +941 -0
  9. agentmetry/cli/__main__.py +5 -0
  10. agentmetry/core/__init__.py +0 -0
  11. agentmetry/core/audit/__init__.py +1 -0
  12. agentmetry/core/audit/adapters/__init__.py +0 -0
  13. agentmetry/core/audit/adapters/agt.py +304 -0
  14. agentmetry/core/audit/adapters/cloudevents.py +159 -0
  15. agentmetry/core/audit/adapters/ecs.py +103 -0
  16. agentmetry/core/audit/adapters/splunk.py +40 -0
  17. agentmetry/core/audit/alerts.py +56 -0
  18. agentmetry/core/audit/canonical.py +150 -0
  19. agentmetry/core/audit/compliance_digest.py +299 -0
  20. agentmetry/core/audit/detection/__init__.py +9 -0
  21. agentmetry/core/audit/detection/benchmark.py +194 -0
  22. agentmetry/core/audit/detection/corpus/attack_approval_denied_then_executed.jsonl +3 -0
  23. agentmetry/core/audit/detection/corpus/attack_arbitrary_host_stage_execute.jsonl +3 -0
  24. agentmetry/core/audit/detection/corpus/attack_autonomous_unapproved_write.jsonl +3 -0
  25. agentmetry/core/audit/detection/corpus/attack_credential_exfil.jsonl +2 -0
  26. agentmetry/core/audit/detection/corpus/attack_credential_then_cloud_api.jsonl +2 -0
  27. agentmetry/core/audit/detection/corpus/attack_destructive_delete_burst.jsonl +6 -0
  28. agentmetry/core/audit/detection/corpus/attack_discovery_then_collect.jsonl +5 -0
  29. agentmetry/core/audit/detection/corpus/attack_dotfile_then_git_push.jsonl +2 -0
  30. agentmetry/core/audit/detection/corpus/attack_encoded_command_download.jsonl +1 -0
  31. agentmetry/core/audit/detection/corpus/attack_env_credential_exfil.jsonl +3 -0
  32. agentmetry/core/audit/detection/corpus/attack_hashed_only_no_command.jsonl +2 -0
  33. agentmetry/core/audit/detection/corpus/attack_interpreter_egress.jsonl +2 -0
  34. agentmetry/core/audit/detection/corpus/attack_pr_merged_without_review.jsonl +2 -0
  35. agentmetry/core/audit/detection/corpus/attack_proc_substitution_cradle.jsonl +2 -0
  36. agentmetry/core/audit/detection/corpus/attack_remote_pipe_to_shell.jsonl +1 -0
  37. agentmetry/core/audit/detection/corpus/attack_remote_staging_then_execute.jsonl +2 -0
  38. agentmetry/core/audit/detection/corpus/attack_session_tool_burst.jsonl +42 -0
  39. agentmetry/core/audit/detection/corpus/attack_single_command_exfil.jsonl +2 -0
  40. agentmetry/core/audit/detection/corpus/attack_ssh_directory_exfil.jsonl +3 -0
  41. agentmetry/core/audit/detection/corpus/attack_subagent_swarm.jsonl +6 -0
  42. agentmetry/core/audit/detection/corpus/attack_timestamp_collision.jsonl +2 -0
  43. agentmetry/core/audit/detection/corpus/attack_untrusted_input_then_action.jsonl +3 -0
  44. agentmetry/core/audit/detection/corpus/benign_authoring_merge_fixtures.jsonl +3 -0
  45. agentmetry/core/audit/detection/corpus/benign_autonomous_after_approval.jsonl +4 -0
  46. agentmetry/core/audit/detection/corpus/benign_build_artifact_cleanup.jsonl +5 -0
  47. agentmetry/core/audit/detection/corpus/benign_ci_artifact_download.jsonl +3 -0
  48. agentmetry/core/audit/detection/corpus/benign_database_migration.jsonl +5 -0
  49. agentmetry/core/audit/detection/corpus/benign_dependency_install_and_build.jsonl +5 -0
  50. agentmetry/core/audit/detection/corpus/benign_download_release_archive.jsonl +4 -0
  51. agentmetry/core/audit/detection/corpus/benign_fetch_data_then_run_repo_script.jsonl +3 -0
  52. agentmetry/core/audit/detection/corpus/benign_fetch_dataset_then_analyse.jsonl +3 -0
  53. agentmetry/core/audit/detection/corpus/benign_fetch_lockfile_then_install.jsonl +3 -0
  54. agentmetry/core/audit/detection/corpus/benign_git_review_and_push.jsonl +6 -0
  55. agentmetry/core/audit/detection/corpus/benign_human_driven_deletes.jsonl +6 -0
  56. agentmetry/core/audit/detection/corpus/benign_local_api_probing.jsonl +5 -0
  57. agentmetry/core/audit/detection/corpus/benign_long_but_calm_session.jsonl +30 -0
  58. agentmetry/core/audit/detection/corpus/benign_loopback_is_not_egress.jsonl +2 -0
  59. agentmetry/core/audit/detection/corpus/benign_loopback_pipe_to_interpreter.jsonl +3 -0
  60. agentmetry/core/audit/detection/corpus/benign_ordinary_development.jsonl +5 -0
  61. agentmetry/core/audit/detection/corpus/benign_package_manager_after_fetch.jsonl +2 -0
  62. agentmetry/core/audit/detection/corpus/benign_reading_config_that_is_not_secret.jsonl +5 -0
  63. agentmetry/core/audit/detection/corpus/benign_remote_api_call_no_credentials.jsonl +4 -0
  64. agentmetry/core/audit/detection/corpus/benign_research_then_docs.jsonl +5 -0
  65. agentmetry/core/audit/detection/corpus/benign_reversed_order_is_not_exfil.jsonl +2 -0
  66. agentmetry/core/audit/detection/corpus/benign_test_and_fix_loop.jsonl +6 -0
  67. agentmetry/core/audit/detection/corpus/benign_writing_about_credentials.jsonl +6 -0
  68. agentmetry/core/audit/detection/corpus/corpus.yaml +443 -0
  69. agentmetry/core/audit/detection/disposition.py +651 -0
  70. agentmetry/core/audit/detection/engine.py +78 -0
  71. agentmetry/core/audit/detection/live.py +127 -0
  72. agentmetry/core/audit/detection/live_store.py +355 -0
  73. agentmetry/core/audit/detection/models.py +53 -0
  74. agentmetry/core/audit/detection/rules.py +1314 -0
  75. agentmetry/core/audit/detection/traits.py +648 -0
  76. agentmetry/core/audit/detection/yaml_config.py +91 -0
  77. agentmetry/core/audit/detection/yaml_rules.py +83 -0
  78. agentmetry/core/audit/dlp/__init__.py +4 -0
  79. agentmetry/core/audit/dlp/loader.py +29 -0
  80. agentmetry/core/audit/dlp/models.py +29 -0
  81. agentmetry/core/audit/dlp/scanner.py +96 -0
  82. agentmetry/core/audit/dogfood.py +398 -0
  83. agentmetry/core/audit/evidence_pack.py +500 -0
  84. agentmetry/core/audit/external.py +213 -0
  85. agentmetry/core/audit/hashing.py +21 -0
  86. agentmetry/core/audit/hook_bootstrap.py +451 -0
  87. agentmetry/core/audit/identity.py +39 -0
  88. agentmetry/core/audit/ingest.py +242 -0
  89. agentmetry/core/audit/migrate.py +73 -0
  90. agentmetry/core/audit/mitre.py +244 -0
  91. agentmetry/core/audit/policy.py +99 -0
  92. agentmetry/core/audit/redaction.py +50 -0
  93. agentmetry/core/audit/replay.py +54 -0
  94. agentmetry/core/audit/run_context.py +129 -0
  95. agentmetry/core/audit/sinks.py +235 -0
  96. agentmetry/core/audit/spool.py +394 -0
  97. agentmetry/core/audit/tool_policy/__init__.py +4 -0
  98. agentmetry/core/audit/tool_policy/evaluator.py +198 -0
  99. agentmetry/core/audit/tool_policy/loader.py +44 -0
  100. agentmetry/core/audit/tool_policy/models.py +25 -0
  101. agentmetry/core/audit/trail_chain.py +300 -0
  102. agentmetry/core/audit/trail_db.py +491 -0
  103. agentmetry/core/audit/trail_merkle.py +332 -0
  104. agentmetry/core/auth.py +54 -0
  105. agentmetry/core/bus/__init__.py +5 -0
  106. agentmetry/core/bus/audit_exporter.py +107 -0
  107. agentmetry/core/bus/bridges.py +26 -0
  108. agentmetry/core/bus/bus.py +102 -0
  109. agentmetry/core/bus/events.py +50 -0
  110. agentmetry/core/bus/outbox.py +124 -0
  111. agentmetry/core/config.py +177 -0
  112. agentmetry/core/diagnostics/__init__.py +0 -0
  113. agentmetry/core/diagnostics/autostart.py +563 -0
  114. agentmetry/core/diagnostics/doctor.py +535 -0
  115. agentmetry/core/diagnostics/driver_paths.py +156 -0
  116. agentmetry/core/diagnostics/env_file.py +45 -0
  117. agentmetry/core/drivers/__init__.py +4 -0
  118. agentmetry/core/drivers/host.py +263 -0
  119. agentmetry/core/drivers/permissions.py +37 -0
  120. agentmetry/core/drivers/spec.py +118 -0
  121. agentmetry/core/extensions.py +107 -0
  122. agentmetry/core/health.py +26 -0
  123. agentmetry/core/version.py +13 -0
  124. agentmetry/policies/detection/manifest.yaml +43 -0
  125. agentmetry/policies/dlp/manifest.yaml +161 -0
  126. agentmetry/policies/opa/agent_rules.rego +33 -0
  127. agentmetry/policies/tool/manifest.yaml +117 -0
  128. agentmetry-0.4.0.dist-info/METADATA +86 -0
  129. agentmetry-0.4.0.dist-info/RECORD +131 -0
  130. agentmetry-0.4.0.dist-info/WHEEL +4 -0
  131. agentmetry-0.4.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,78 @@
1
+ """Detection engine — orders a session's events and runs the rule registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from typing import Any
7
+
8
+ from .models import SEVERITY_RANK, Detection
9
+ from .rules import HOST_REGISTRY, REGISTRY
10
+ from .yaml_rules import build_yaml_rules
11
+
12
+ _EPOCH = datetime.min.replace(tzinfo=timezone.utc)
13
+
14
+
15
+ def _session_rules():
16
+ return [*REGISTRY, *build_yaml_rules()]
17
+
18
+
19
+ def _event_ts(event: dict[str, Any]) -> datetime:
20
+ ts = event.get("timestamp_utc")
21
+ if not isinstance(ts, str):
22
+ return _EPOCH
23
+ try:
24
+ dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
25
+ return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt
26
+ except ValueError:
27
+ return _EPOCH
28
+
29
+
30
+ def _sorted(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
31
+ """Time order, falling back to the order the caller supplied.
32
+
33
+ The tie-break used to end with `event_id`, which is a random UUID. Every
34
+ sequence rule in this package asks "did A happen before B", so on a
35
+ timestamp tie the answer was decided by a coin flip.
36
+
37
+ Ties are not rare. Windows clock granularity is around 15 ms, so two tool
38
+ calls from one agent turn routinely share a timestamp; this surfaced as a
39
+ Windows-only CI failure where `credential-read-then-cloud-api` fired or did
40
+ not depending on which UUID sorted first. The README claims ordering is
41
+ enforced by position rather than co-occurrence, and a random tie-break
42
+ quietly made that untrue.
43
+
44
+ `sorted` is stable, so omitting the UUID preserves the input order on ties.
45
+ Callers hand us events straight from the trail, which returns them ordered
46
+ by `timestamp_utc` then insert id, so input order *is* arrival order: the
47
+ best available evidence of what actually happened first.
48
+ """
49
+ def _key(event: dict[str, Any]) -> tuple[datetime, int]:
50
+ seq = event.get("seq")
51
+ seq_int = seq if isinstance(seq, int) else 0
52
+ return (_event_ts(event), seq_int)
53
+
54
+ return sorted(events, key=_key)
55
+
56
+
57
+ def run_detections(events: list[dict[str, Any]]) -> list[Detection]:
58
+ """Run every rule over one session's events, ranked most-severe first.
59
+
60
+ `events` should be the events for a single correlation_id; ordering is
61
+ handled here so callers can pass them straight from the trail.
62
+ """
63
+ ordered = _sorted(events)
64
+ detections: list[Detection] = []
65
+ for rule in _session_rules():
66
+ detections.extend(rule(ordered))
67
+ detections.sort(key=lambda d: (SEVERITY_RANK.get(d.severity, 99), d.first_seen_utc))
68
+ return detections
69
+
70
+
71
+ def run_host_detections(events: list[dict[str, Any]]) -> list[Detection]:
72
+ """Run host-scoped rules over events aggregated by host_id."""
73
+ ordered = _sorted(events)
74
+ detections: list[Detection] = []
75
+ for rule in HOST_REGISTRY:
76
+ detections.extend(rule(ordered))
77
+ detections.sort(key=lambda d: (SEVERITY_RANK.get(d.severity, 99), d.first_seen_utc))
78
+ return detections
@@ -0,0 +1,127 @@
1
+ """Live detection — correlate as events arrive, not only when someone asks.
2
+
3
+ The rule engine is a pure function over a session's events. That is fine for a
4
+ forensic query, but a detection nobody sees is not a control: until this module
5
+ existed, a `credential-exfil` finding only appeared if an operator happened to
6
+ open that session in the dashboard. Nothing streamed, nothing reached a SIEM,
7
+ nothing alerted.
8
+
9
+ This keeps a bounded window of each live session, re-runs the rules on every
10
+ ingested event, and returns only detections that are *new* for that session —
11
+ so a firing rule is emitted once, as a canonical event, down the same sinks as
12
+ everything else.
13
+
14
+ Live state (event windows + emitted rule IDs) is checkpointed in SQLite so an
15
+ orchestrator restart does not re-alert or lose context for active sessions.
16
+ The JSONL trail remains authoritative; `GET /audit/detections/{id}` still
17
+ recomputes from it.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import uuid
23
+ from typing import Any
24
+
25
+ from agentmetry.core.audit.identity import identity_fields
26
+
27
+ from .engine import run_detections, run_host_detections
28
+ from .live_store import get_live_store
29
+ from .models import Detection
30
+
31
+ def reset_live_state() -> None:
32
+ """Test helper — clear live detection checkpoint."""
33
+ get_live_store().clear_all()
34
+
35
+
36
+ def observe(canonical: dict[str, Any]) -> list[Detection]:
37
+ """Record an event and return detections that are NEW for its session.
38
+
39
+ Does NOT checkpoint here. The caller must call `mark_detection_emitted` only
40
+ after the detection is durably stored and forwarded, so a failed emit lets
41
+ the rule fire again on the next session event instead of being lost.
42
+ """
43
+ corr = str(canonical.get("correlation_id") or "")
44
+ if not corr:
45
+ return []
46
+
47
+ store = get_live_store()
48
+ events = store.append_event(corr, canonical)
49
+
50
+ fresh: list[Detection] = []
51
+ seen_in_batch: set[str] = set()
52
+ for detection in run_detections(events):
53
+ # Dedup within this call (a rule may return several findings) without
54
+ # persisting — persistence is the caller's job, post-emit.
55
+ if detection.rule_id in seen_in_batch or store.is_emitted(corr, detection.rule_id):
56
+ continue
57
+ seen_in_batch.add(detection.rule_id)
58
+ fresh.append(detection)
59
+ return fresh
60
+
61
+
62
+ def observe_host(canonical: dict[str, Any]) -> list[Detection]:
63
+ """Record an event in the host-level window and return new host-scoped detections."""
64
+ host_id = str(canonical.get("host_id") or "")
65
+ if not host_id:
66
+ return []
67
+
68
+ store = get_live_store()
69
+ events = store.append_host_event(host_id, canonical)
70
+
71
+ fresh: list[Detection] = []
72
+ seen_in_batch: set[str] = set()
73
+ for detection in run_host_detections(events):
74
+ if detection.rule_id in seen_in_batch or store.is_host_emitted(host_id, detection.rule_id):
75
+ continue
76
+ seen_in_batch.add(detection.rule_id)
77
+ fresh.append(detection)
78
+ return fresh
79
+
80
+
81
+ def mark_host_detection_emitted(host_id: str, rule_id: str, emitted_at: str = "") -> None:
82
+ """Checkpoint a host-level detection as emitted."""
83
+ if not host_id or not rule_id:
84
+ return
85
+ get_live_store().mark_host_emitted(host_id, rule_id, emitted_at=emitted_at)
86
+
87
+
88
+ def mark_detection_emitted(correlation_id: str, rule_id: str, emitted_at: str = "") -> None:
89
+ """Checkpoint a detection as emitted for its session.
90
+
91
+ Call ONLY after the detection event is durably stored and forwarded. Marking
92
+ is idempotent (INSERT OR IGNORE), so a re-fired detection that finally
93
+ succeeds does not double-alert.
94
+ """
95
+ if not correlation_id or not rule_id:
96
+ return
97
+ get_live_store().mark_emitted(correlation_id, rule_id, emitted_at=emitted_at)
98
+
99
+
100
+ def build_detection_event(detection: Detection, source_event: dict[str, Any]) -> dict[str, Any]:
101
+ """Wrap a Detection as a canonical event so it flows to every sink.
102
+
103
+ `action.outcome` carries the severity: a SIEM can alert on
104
+ `action.type:detection AND action.outcome:critical` without understanding
105
+ Agentmetry's rule vocabulary.
106
+ """
107
+ source = source_event.get("source")
108
+ agent = source_event.get("agent")
109
+ return {
110
+ "schema_version": source_event.get("schema_version", "1.1.0"),
111
+ "event_id": str(uuid.uuid4()),
112
+ "session_id": source_event.get("session_id", ""),
113
+ "correlation_id": detection.correlation_id or source_event.get("correlation_id", ""),
114
+ "timestamp_utc": source_event.get("timestamp_utc", ""),
115
+ **identity_fields(),
116
+ "source_topic": f"detection/{detection.rule_id}",
117
+ "source": source if isinstance(source, dict) else {"tier": "detection", "app": "agentmetry"},
118
+ "initiator": source_event.get("initiator", {}),
119
+ "actor": source_event.get("actor", {}),
120
+ "action": {
121
+ "type": "detection",
122
+ "outcome": detection.severity,
123
+ "reason": detection.summary,
124
+ },
125
+ "agent": agent if isinstance(agent, dict) else {"name": "agentmetry"},
126
+ "detection": detection.as_dict(),
127
+ }
@@ -0,0 +1,355 @@
1
+ """SQLite checkpoint for live detection — survives orchestrator restarts.
2
+
3
+ Persists per-session event windows (bounded) and emitted rule IDs so a restart
4
+ does not re-fire detections or lose correlation context for active sessions.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import sqlite3
12
+ import threading
13
+ import time
14
+ from datetime import datetime, timedelta, timezone
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ _MAX_SESSIONS = 256
21
+ _MAX_EVENTS_PER_SESSION = 500
22
+
23
+ # How long a host-level detection stays suppressed after firing. Session marks
24
+ # are permanent (a session ends); host marks are not (a host does not), so
25
+ # without this a single firing silenced the rule on that machine forever.
26
+ HOST_EMIT_TTL_SECONDS = 6 * 3600
27
+
28
+
29
+ def _is_expired(emitted_at: str, *, ttl_seconds: int = HOST_EMIT_TTL_SECONDS) -> bool:
30
+ """True when a host checkpoint is old enough to re-arm.
31
+
32
+ An unparsable timestamp counts as expired: re-alerting is a smaller failure
33
+ than a rule that is silently off.
34
+ """
35
+ try:
36
+ ts = datetime.fromisoformat(emitted_at.replace("Z", "+00:00"))
37
+ except (ValueError, AttributeError):
38
+ return True
39
+ if ts.tzinfo is None:
40
+ ts = ts.replace(tzinfo=timezone.utc)
41
+ return ts < datetime.now(timezone.utc) - timedelta(seconds=ttl_seconds)
42
+
43
+ _SCHEMA_SQL = """
44
+ CREATE TABLE IF NOT EXISTS live_session_meta (
45
+ correlation_id TEXT PRIMARY KEY,
46
+ last_touch REAL NOT NULL
47
+ );
48
+
49
+ CREATE TABLE IF NOT EXISTS live_events (
50
+ correlation_id TEXT NOT NULL,
51
+ seq INTEGER NOT NULL,
52
+ event_json TEXT NOT NULL,
53
+ PRIMARY KEY (correlation_id, seq)
54
+ );
55
+
56
+ CREATE INDEX IF NOT EXISTS idx_live_events_corr ON live_events(correlation_id);
57
+
58
+ CREATE TABLE IF NOT EXISTS live_emitted (
59
+ correlation_id TEXT NOT NULL,
60
+ rule_id TEXT NOT NULL,
61
+ emitted_at TEXT NOT NULL DEFAULT '',
62
+ PRIMARY KEY (correlation_id, rule_id)
63
+ );
64
+
65
+ CREATE TABLE IF NOT EXISTS live_host_meta (
66
+ host_id TEXT PRIMARY KEY,
67
+ last_touch REAL NOT NULL
68
+ );
69
+
70
+ CREATE TABLE IF NOT EXISTS live_host_events (
71
+ host_id TEXT NOT NULL,
72
+ seq INTEGER NOT NULL,
73
+ event_json TEXT NOT NULL,
74
+ PRIMARY KEY (host_id, seq)
75
+ );
76
+
77
+ CREATE INDEX IF NOT EXISTS idx_live_host_events_host ON live_host_events(host_id);
78
+
79
+ CREATE TABLE IF NOT EXISTS live_host_emitted (
80
+ host_id TEXT NOT NULL,
81
+ rule_id TEXT NOT NULL,
82
+ emitted_at TEXT NOT NULL DEFAULT '',
83
+ PRIMARY KEY (host_id, rule_id)
84
+ );
85
+ """
86
+
87
+
88
+ class LiveDetectionStore:
89
+ """Thread-safe SQLite store for live detection state."""
90
+
91
+ def __init__(self, db_path: Path | str) -> None:
92
+ self._db_path = str(db_path)
93
+ Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
94
+ self._local = threading.local()
95
+ self._lock = threading.Lock()
96
+ self._init_schema()
97
+
98
+ def _get_conn(self) -> sqlite3.Connection:
99
+ conn = getattr(self._local, "conn", None)
100
+ if conn is None:
101
+ conn = sqlite3.connect(self._db_path, timeout=10)
102
+ conn.execute("PRAGMA journal_mode=WAL")
103
+ conn.execute("PRAGMA synchronous=NORMAL")
104
+ conn.execute("PRAGMA busy_timeout=5000")
105
+ self._local.conn = conn
106
+ return conn
107
+
108
+ def _init_schema(self) -> None:
109
+ conn = self._get_conn()
110
+ conn.executescript(_SCHEMA_SQL)
111
+ conn.commit()
112
+
113
+ def clear_all(self) -> None:
114
+ """Test helper — wipe all live detection state."""
115
+ with self._lock:
116
+ conn = self._get_conn()
117
+ conn.execute("DELETE FROM live_events")
118
+ conn.execute("DELETE FROM live_emitted")
119
+ conn.execute("DELETE FROM live_session_meta")
120
+ conn.execute("DELETE FROM live_host_events")
121
+ conn.execute("DELETE FROM live_host_emitted")
122
+ conn.execute("DELETE FROM live_host_meta")
123
+ conn.commit()
124
+
125
+ def close(self) -> None:
126
+ conn = getattr(self._local, "conn", None)
127
+ if conn is not None:
128
+ conn.close()
129
+ self._local.conn = None
130
+
131
+ def append_event(self, correlation_id: str, event: dict[str, Any]) -> list[dict[str, Any]]:
132
+ """Append an event and return the bounded session window."""
133
+ with self._lock:
134
+ conn = self._get_conn()
135
+ # Wall clock, not monotonic: last_touch is persisted, and monotonic
136
+ # resets on reboot — pre-reboot sessions would outrank new ones and
137
+ # LRU eviction would evict the newest sessions first.
138
+ now = time.time()
139
+ conn.execute(
140
+ """
141
+ INSERT INTO live_session_meta (correlation_id, last_touch)
142
+ VALUES (?, ?)
143
+ ON CONFLICT(correlation_id) DO UPDATE SET last_touch = excluded.last_touch
144
+ """,
145
+ (correlation_id, now),
146
+ )
147
+ row = conn.execute(
148
+ "SELECT COALESCE(MAX(seq), 0) FROM live_events WHERE correlation_id = ?",
149
+ (correlation_id,),
150
+ ).fetchone()
151
+ next_seq = int(row[0]) + 1
152
+ conn.execute(
153
+ "INSERT INTO live_events (correlation_id, seq, event_json) VALUES (?, ?, ?)",
154
+ (correlation_id, next_seq, json.dumps(event, separators=(",", ":"), sort_keys=True)),
155
+ )
156
+ min_seq = max(1, next_seq - _MAX_EVENTS_PER_SESSION + 1)
157
+ conn.execute(
158
+ "DELETE FROM live_events WHERE correlation_id = ? AND seq < ?",
159
+ (correlation_id, min_seq),
160
+ )
161
+ self._evict_lru(conn)
162
+ conn.commit()
163
+ return self._load_events(conn, correlation_id)
164
+
165
+ def _load_events(self, conn: sqlite3.Connection, correlation_id: str) -> list[dict[str, Any]]:
166
+ rows = conn.execute(
167
+ """
168
+ SELECT event_json FROM live_events
169
+ WHERE correlation_id = ?
170
+ ORDER BY seq ASC
171
+ """,
172
+ (correlation_id,),
173
+ ).fetchall()
174
+ events: list[dict[str, Any]] = []
175
+ for (raw,) in rows:
176
+ try:
177
+ events.append(json.loads(raw))
178
+ except json.JSONDecodeError:
179
+ continue
180
+ return events
181
+
182
+ def _evict_lru(self, conn: sqlite3.Connection) -> None:
183
+ count = conn.execute("SELECT COUNT(*) FROM live_session_meta").fetchone()[0]
184
+ if count <= _MAX_SESSIONS:
185
+ return
186
+ overflow = count - _MAX_SESSIONS
187
+ rows = conn.execute(
188
+ """
189
+ SELECT correlation_id FROM live_session_meta
190
+ ORDER BY last_touch ASC
191
+ LIMIT ?
192
+ """,
193
+ (overflow,),
194
+ ).fetchall()
195
+ for (corr,) in rows:
196
+ conn.execute("DELETE FROM live_events WHERE correlation_id = ?", (corr,))
197
+ conn.execute("DELETE FROM live_session_meta WHERE correlation_id = ?", (corr,))
198
+
199
+ def is_emitted(self, correlation_id: str, rule_id: str) -> bool:
200
+ with self._lock:
201
+ conn = self._get_conn()
202
+ row = conn.execute(
203
+ """
204
+ SELECT 1 FROM live_emitted
205
+ WHERE correlation_id = ? AND rule_id = ?
206
+ """,
207
+ (correlation_id, rule_id),
208
+ ).fetchone()
209
+ return row is not None
210
+
211
+ def mark_emitted(self, correlation_id: str, rule_id: str, emitted_at: str = "") -> None:
212
+ with self._lock:
213
+ conn = self._get_conn()
214
+ conn.execute(
215
+ """
216
+ INSERT OR IGNORE INTO live_emitted (correlation_id, rule_id, emitted_at)
217
+ VALUES (?, ?, ?)
218
+ """,
219
+ (correlation_id, rule_id, emitted_at),
220
+ )
221
+ conn.commit()
222
+
223
+ def append_host_event(self, host_id: str, event: dict[str, Any]) -> list[dict[str, Any]]:
224
+ """Append an event to the host-level window and return bounded events."""
225
+ if not host_id:
226
+ return []
227
+ with self._lock:
228
+ conn = self._get_conn()
229
+ now = time.time()
230
+ conn.execute(
231
+ """
232
+ INSERT INTO live_host_meta (host_id, last_touch)
233
+ VALUES (?, ?)
234
+ ON CONFLICT(host_id) DO UPDATE SET last_touch = excluded.last_touch
235
+ """,
236
+ (host_id, now),
237
+ )
238
+ row = conn.execute(
239
+ "SELECT COALESCE(MAX(seq), 0) FROM live_host_events WHERE host_id = ?",
240
+ (host_id,),
241
+ ).fetchone()
242
+ next_seq = int(row[0]) + 1
243
+ conn.execute(
244
+ "INSERT INTO live_host_events (host_id, seq, event_json) VALUES (?, ?, ?)",
245
+ (host_id, next_seq, json.dumps(event, separators=(",", ":"), sort_keys=True)),
246
+ )
247
+ min_seq = max(1, next_seq - _MAX_EVENTS_PER_SESSION + 1)
248
+ conn.execute(
249
+ "DELETE FROM live_host_events WHERE host_id = ? AND seq < ?",
250
+ (host_id, min_seq),
251
+ )
252
+ conn.commit()
253
+ return self._load_host_events(conn, host_id)
254
+
255
+ def _load_host_events(self, conn: sqlite3.Connection, host_id: str) -> list[dict[str, Any]]:
256
+ rows = conn.execute(
257
+ """
258
+ SELECT event_json FROM live_host_events
259
+ WHERE host_id = ?
260
+ ORDER BY seq ASC
261
+ """,
262
+ (host_id,),
263
+ ).fetchall()
264
+ events: list[dict[str, Any]] = []
265
+ for (raw,) in rows:
266
+ try:
267
+ events.append(json.loads(raw))
268
+ except json.JSONDecodeError:
269
+ continue
270
+ return events
271
+
272
+ def is_host_emitted(self, host_id: str, rule_id: str) -> bool:
273
+ """Has this host-level rule fired recently enough to stay suppressed?
274
+
275
+ Host checkpoints expire; session ones do not. A session ends, so a
276
+ permanent mark is correct there. A host does not: without an expiry the
277
+ first firing — quite possibly a false positive — silenced the rule on
278
+ that machine forever, and only deleting a SQLite row brought it back.
279
+ A recorder that goes permanently blind after one alert is worse than one
280
+ that occasionally repeats itself.
281
+ """
282
+ with self._lock:
283
+ conn = self._get_conn()
284
+ row = conn.execute(
285
+ """
286
+ SELECT emitted_at FROM live_host_emitted
287
+ WHERE host_id = ? AND rule_id = ?
288
+ """,
289
+ (host_id, rule_id),
290
+ ).fetchone()
291
+ if row is None:
292
+ return False
293
+ emitted_at = str(row[0] or "")
294
+ if not emitted_at:
295
+ # Pre-expiry rows carry no usable timestamp. Treat them as stale
296
+ # so an upgrade re-arms the rule instead of inheriting silence.
297
+ conn.execute(
298
+ "DELETE FROM live_host_emitted WHERE host_id = ? AND rule_id = ?",
299
+ (host_id, rule_id),
300
+ )
301
+ conn.commit()
302
+ return False
303
+ if _is_expired(emitted_at):
304
+ conn.execute(
305
+ "DELETE FROM live_host_emitted WHERE host_id = ? AND rule_id = ?",
306
+ (host_id, rule_id),
307
+ )
308
+ conn.commit()
309
+ return False
310
+ return True
311
+
312
+ def mark_host_emitted(self, host_id: str, rule_id: str, emitted_at: str = "") -> None:
313
+ """Checkpoint a host-level detection, refreshing the suppression clock.
314
+
315
+ Upsert rather than INSERT OR IGNORE: the timestamp drives expiry, so a
316
+ re-fire must move it forward or the rule would re-arm on the original
317
+ schedule regardless of recent activity. An absent timestamp falls back to
318
+ now, so a row always carries a usable clock.
319
+ """
320
+ stamp = emitted_at or datetime.now(timezone.utc).isoformat()
321
+ with self._lock:
322
+ conn = self._get_conn()
323
+ conn.execute(
324
+ """
325
+ INSERT INTO live_host_emitted (host_id, rule_id, emitted_at)
326
+ VALUES (?, ?, ?)
327
+ ON CONFLICT(host_id, rule_id) DO UPDATE SET emitted_at = excluded.emitted_at
328
+ """,
329
+ (host_id, rule_id, stamp),
330
+ )
331
+ conn.commit()
332
+
333
+
334
+ _store: LiveDetectionStore | None = None
335
+ _store_lock = threading.Lock()
336
+
337
+
338
+ def get_live_store() -> LiveDetectionStore:
339
+ global _store
340
+ if _store is None:
341
+ with _store_lock:
342
+ if _store is None:
343
+ from agentmetry.core.config import settings
344
+
345
+ _store = LiveDetectionStore(settings.detection_live_db_path)
346
+ return _store
347
+
348
+
349
+ def reset_live_store_singleton() -> None:
350
+ """Drop the singleton without deleting data — simulates process restart."""
351
+ global _store
352
+ with _store_lock:
353
+ if _store is not None:
354
+ _store.close()
355
+ _store = None
@@ -0,0 +1,53 @@
1
+ """Data model for correlated detections (sequence rules over a session)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ # Severity ordering for ranking detections most-severe first.
9
+ SEVERITY_RANK: dict[str, int] = {
10
+ "critical": 0,
11
+ "high": 1,
12
+ "medium": 2,
13
+ "low": 3,
14
+ }
15
+
16
+
17
+ @dataclass
18
+ class Detection:
19
+ """A named, ordered pattern of events — an incident, not a single event.
20
+
21
+ `event_ids` are the canonical events that formed the pattern, in order, so
22
+ the dashboard can link a detection back to the exact rows in the trail.
23
+ """
24
+
25
+ rule_id: str
26
+ title: str
27
+ severity: str # critical | high | medium | low
28
+ summary: str
29
+ correlation_id: str
30
+ tactic_ids: list[str] = field(default_factory=list)
31
+ technique_ids: list[str] = field(default_factory=list)
32
+ event_ids: list[str] = field(default_factory=list)
33
+ first_seen_utc: str = ""
34
+ last_seen_utc: str = ""
35
+ # Triage state, attached on read from the disposition store rather than
36
+ # produced by the rule. A detection nobody dispositioned is an alert, not a
37
+ # control, so every detection carries the field even when it is untouched.
38
+ disposition: dict[str, Any] | None = None
39
+
40
+ def as_dict(self) -> dict[str, Any]:
41
+ return {
42
+ "rule_id": self.rule_id,
43
+ "title": self.title,
44
+ "severity": self.severity,
45
+ "summary": self.summary,
46
+ "correlation_id": self.correlation_id,
47
+ "tactic_ids": self.tactic_ids,
48
+ "technique_ids": self.technique_ids,
49
+ "event_ids": self.event_ids,
50
+ "first_seen_utc": self.first_seen_utc,
51
+ "last_seen_utc": self.last_seen_utc,
52
+ "disposition": self.disposition,
53
+ }