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,651 @@
1
+ """Detection triage — what the human decided, and when.
2
+
3
+ Until now Agentmetry recorded findings and stopped. That is half a control: a
4
+ detection nobody dispositioned is an alert, not evidence. ISO/IEC 42001 cl. 10
5
+ and EN 18286 cl. 8 both ask for the corrective action, not just the observation,
6
+ and the compliance digest was already asking for a triage note the product had
7
+ nowhere to store.
8
+
9
+ Two rules shape this module.
10
+
11
+ **The decision is an event.** Every disposition change is appended to the
12
+ canonical trail as an `action.type = "detection_disposition"` event, so it lands
13
+ on the same hash chain as the finding it answers and forwards to the same SIEM.
14
+ The table below is a materialized view of those events, not the system of
15
+ record — `rebuild_from_trail` regenerates it, which is the same recomputable
16
+ property the rest of the product has.
17
+
18
+ **History is append-only.** A disposition is never edited or deleted, only
19
+ superseded. "It was a false positive" changing to "actually it was real" is
20
+ exactly the transition an auditor cares about, so both entries survive.
21
+
22
+ A detection has no database id: rules are recomputed from events on demand. Its
23
+ stable identity is the pair the live checkpoint already uses, the scope it fired
24
+ in and the rule that fired, which is what `detection_key` builds.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import logging
31
+ import sqlite3
32
+ import threading
33
+ import uuid
34
+ from datetime import datetime, timezone
35
+ from pathlib import Path
36
+ from typing import Any
37
+
38
+ from agentmetry.core.audit.identity import identity_fields
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+ DISPOSITION_EVENT_TYPE = "detection_disposition"
43
+
44
+ #: Triage states, in the order they appear in the dashboard.
45
+ STATUSES: tuple[str, ...] = (
46
+ "new",
47
+ "acknowledged",
48
+ "in_progress",
49
+ "resolved",
50
+ "false_positive",
51
+ "risk_accepted",
52
+ )
53
+
54
+ DEFAULT_STATUS = "new"
55
+
56
+ #: States that close a finding without confirming it. An auditor will ask why,
57
+ #: so the product asks first: a bare "false positive" is not a disposition, it
58
+ #: is a dismissal wearing one.
59
+ _NOTE_REQUIRED = frozenset({"false_positive", "risk_accepted"})
60
+
61
+ #: States that mean no further action is expected.
62
+ CLOSED_STATUSES = frozenset({"resolved", "false_positive", "risk_accepted"})
63
+
64
+ _MAX_NOTE_CHARS = 4000
65
+ _MAX_ASSIGNEE_CHARS = 128
66
+
67
+ _SCHEMA_SQL = """
68
+ CREATE TABLE IF NOT EXISTS detection_dispositions (
69
+ detection_key TEXT PRIMARY KEY,
70
+ correlation_id TEXT NOT NULL DEFAULT '',
71
+ rule_id TEXT NOT NULL DEFAULT '',
72
+ status TEXT NOT NULL DEFAULT 'new',
73
+ assignee TEXT NOT NULL DEFAULT '',
74
+ note TEXT NOT NULL DEFAULT '',
75
+ decided_by TEXT NOT NULL DEFAULT '',
76
+ decided_at_utc TEXT NOT NULL DEFAULT '',
77
+ first_seen_utc TEXT NOT NULL DEFAULT '',
78
+ event_id TEXT NOT NULL DEFAULT '',
79
+ history_json TEXT NOT NULL DEFAULT '[]'
80
+ );
81
+
82
+ CREATE INDEX IF NOT EXISTS idx_disp_status ON detection_dispositions(status);
83
+ CREATE INDEX IF NOT EXISTS idx_disp_corr ON detection_dispositions(correlation_id);
84
+ CREATE INDEX IF NOT EXISTS idx_disp_rule ON detection_dispositions(rule_id);
85
+ """
86
+
87
+
88
+ class DispositionError(ValueError):
89
+ """Rejected disposition — surfaced to the caller as a 400, not a 500."""
90
+
91
+
92
+ def detection_key(correlation_id: str, rule_id: str) -> str:
93
+ """Stable identity for a detection across recomputations.
94
+
95
+ Rules are re-run over the trail rather than stored, so the key is the scope
96
+ plus the rule, matching the pair `mark_detection_emitted` checkpoints on.
97
+ Host-level rules carry their host in `correlation_id` already.
98
+
99
+ The rule id is canonicalised through `RULE_ALIASES` first. A rule id stops
100
+ being an implementation detail the moment someone dispositions a finding
101
+ under it: rename it in place and every "we checked, it was our CI bot" ever
102
+ recorded is orphaned, and the evidence pack reports those periods as
103
+ untriaged. Renames are declared, and the key follows the rule.
104
+ """
105
+ from .rules import canonical_rule_id
106
+
107
+ corr = (correlation_id or "").strip()
108
+ rule = (rule_id or "").strip()
109
+ if not rule:
110
+ raise DispositionError("rule_id is required")
111
+ return f"{corr}::{canonical_rule_id(rule)}"
112
+
113
+
114
+ def _candidate_keys(correlation_id: str, rule_id: str) -> list[str]:
115
+ """The canonical key first, then any this detection used to be stored under."""
116
+ from .rules import canonical_rule_id, historical_rule_ids
117
+
118
+ corr = (correlation_id or "").strip()
119
+ canonical = canonical_rule_id(rule_id)
120
+ keys = [f"{corr}::{canonical}"]
121
+ keys.extend(f"{corr}::{old}" for old in historical_rule_ids(canonical))
122
+ return keys
123
+
124
+
125
+ def _now() -> str:
126
+ return datetime.now(timezone.utc).isoformat()
127
+
128
+
129
+ def validate_rule_id(rule_id: str) -> str:
130
+ """Reject a decision about a rule that does not exist.
131
+
132
+ The cheapest place to stop an orphan is before it is written. A typo in a
133
+ rule id produces a disposition nothing will ever match, which reads later as
134
+ a finding somebody reviewed and a separate finding nobody did.
135
+
136
+ Deliberately not applied on replay: the trail is the record, and an event
137
+ naming a since-retired rule still happened. `orphaned()` surfaces those.
138
+ """
139
+ from .rules import canonical_rule_id, known_rule_ids
140
+
141
+ rule = (rule_id or "").strip()
142
+ if not rule:
143
+ raise DispositionError("rule_id is required")
144
+ known = known_rule_ids()
145
+ if rule not in known and canonical_rule_id(rule) not in known:
146
+ raise DispositionError(
147
+ f"unknown rule_id {rule!r}. Dispositioning a rule that does not "
148
+ "exist would create a decision nothing can ever match."
149
+ )
150
+ return canonical_rule_id(rule)
151
+
152
+
153
+ def _validate(status: str, note: str) -> tuple[str, str]:
154
+ normalized = (status or "").strip().lower()
155
+ if normalized not in STATUSES:
156
+ raise DispositionError(
157
+ f"unknown status {status!r}; expected one of {', '.join(STATUSES)}"
158
+ )
159
+ clean_note = (note or "").strip()
160
+ if len(clean_note) > _MAX_NOTE_CHARS:
161
+ raise DispositionError(f"note exceeds {_MAX_NOTE_CHARS} characters")
162
+ if normalized in _NOTE_REQUIRED and not clean_note:
163
+ raise DispositionError(
164
+ f"status {normalized!r} requires a note explaining the decision"
165
+ )
166
+ return normalized, clean_note
167
+
168
+
169
+ class DispositionStore:
170
+ """Thread-safe SQLite store for current triage state."""
171
+
172
+ def __init__(self, db_path: Path | str) -> None:
173
+ self._db_path = str(db_path)
174
+ self._local = threading.local()
175
+ Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
176
+ self._init_schema()
177
+
178
+ def _get_conn(self) -> sqlite3.Connection:
179
+ conn = getattr(self._local, "conn", None)
180
+ if conn is None:
181
+ conn = sqlite3.connect(self._db_path, timeout=30.0)
182
+ conn.row_factory = sqlite3.Row
183
+ conn.execute("PRAGMA journal_mode=WAL")
184
+ self._local.conn = conn
185
+ return conn
186
+
187
+ def _init_schema(self) -> None:
188
+ conn = self._get_conn()
189
+ conn.executescript(_SCHEMA_SQL)
190
+ conn.commit()
191
+
192
+ # ------------------------------------------------------------------
193
+ # Write
194
+ # ------------------------------------------------------------------
195
+
196
+ def record(
197
+ self,
198
+ *,
199
+ correlation_id: str,
200
+ rule_id: str,
201
+ status: str,
202
+ assignee: str = "",
203
+ note: str = "",
204
+ decided_by: str = "",
205
+ decided_at_utc: str = "",
206
+ event_id: str = "",
207
+ ) -> dict[str, Any]:
208
+ """Upsert current state and append to this detection's history."""
209
+ from .rules import canonical_rule_id
210
+
211
+ normalized, clean_note = _validate(status, note)
212
+ key = detection_key(correlation_id, rule_id)
213
+ decided_at = decided_at_utc or _now()
214
+ clean_assignee = (assignee or "").strip()[:_MAX_ASSIGNEE_CHARS]
215
+
216
+ conn = self._get_conn()
217
+ # Look under historical keys too. A rule renamed since the last decision
218
+ # must continue that decision's history rather than starting a second
219
+ # row beside it, which would read as an untriaged finding plus an
220
+ # orphan. Writing under the canonical key migrates the row in passing.
221
+ row = self._find_row(correlation_id, rule_id)
222
+ history: list[dict[str, Any]] = []
223
+ first_seen = decided_at
224
+ if row is not None:
225
+ try:
226
+ history = json.loads(row["history_json"]) or []
227
+ except (json.JSONDecodeError, TypeError):
228
+ history = []
229
+ first_seen = row["first_seen_utc"] or decided_at
230
+ stale_key = str(row["detection_key"])
231
+ if stale_key != key:
232
+ conn.execute(
233
+ "DELETE FROM detection_dispositions WHERE detection_key = ?",
234
+ (stale_key,),
235
+ )
236
+ logger.info("Migrated disposition %s -> %s after rule rename", stale_key, key)
237
+
238
+ history.append({
239
+ "status": normalized,
240
+ "assignee": clean_assignee,
241
+ "note": clean_note,
242
+ "decided_by": decided_by.strip(),
243
+ "decided_at_utc": decided_at,
244
+ "event_id": event_id,
245
+ })
246
+
247
+ conn.execute(
248
+ """INSERT INTO detection_dispositions
249
+ (detection_key, correlation_id, rule_id, status, assignee, note,
250
+ decided_by, decided_at_utc, first_seen_utc, event_id, history_json)
251
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
252
+ ON CONFLICT(detection_key) DO UPDATE SET
253
+ status = excluded.status,
254
+ assignee = excluded.assignee,
255
+ note = excluded.note,
256
+ decided_by = excluded.decided_by,
257
+ decided_at_utc = excluded.decided_at_utc,
258
+ event_id = excluded.event_id,
259
+ history_json = excluded.history_json""",
260
+ (
261
+ key,
262
+ (correlation_id or "").strip(),
263
+ # Store the canonical id so `orphaned()` and the dashboard agree
264
+ # with the key. The name used at decision time survives in the
265
+ # trail event, which is the record.
266
+ canonical_rule_id(rule_id),
267
+ normalized,
268
+ clean_assignee,
269
+ clean_note,
270
+ decided_by.strip(),
271
+ decided_at,
272
+ first_seen,
273
+ event_id,
274
+ json.dumps(history),
275
+ ),
276
+ )
277
+ conn.commit()
278
+ return self.get(correlation_id, rule_id) or {}
279
+
280
+ def clear(self) -> None:
281
+ """Test helper — drop all triage state."""
282
+ conn = self._get_conn()
283
+ conn.execute("DELETE FROM detection_dispositions")
284
+ conn.commit()
285
+
286
+ # ------------------------------------------------------------------
287
+ # Read
288
+ # ------------------------------------------------------------------
289
+
290
+ def _find_row(self, correlation_id: str, rule_id: str) -> sqlite3.Row | None:
291
+ """Look under the current key, then under any pre-rename key."""
292
+ conn = self._get_conn()
293
+ for key in _candidate_keys(correlation_id, rule_id):
294
+ row = conn.execute(
295
+ "SELECT * FROM detection_dispositions WHERE detection_key = ?",
296
+ (key,),
297
+ ).fetchone()
298
+ if row is not None:
299
+ return row
300
+ return None
301
+
302
+ def get(self, correlation_id: str, rule_id: str) -> dict[str, Any] | None:
303
+ row = self._find_row(correlation_id, rule_id)
304
+ return _row_to_dict(row) if row is not None else None
305
+
306
+ def for_correlation(self, correlation_id: str) -> dict[str, dict[str, Any]]:
307
+ """Current state for one session, keyed by the rule id in force today.
308
+
309
+ Rows written before a rename are returned under the new name, so a
310
+ caller holding today's detections finds yesterday's decisions.
311
+ """
312
+ from .rules import canonical_rule_id
313
+
314
+ rows = self._get_conn().execute(
315
+ "SELECT * FROM detection_dispositions WHERE correlation_id = ?",
316
+ ((correlation_id or "").strip(),),
317
+ ).fetchall()
318
+ return {
319
+ canonical_rule_id(str(row["rule_id"])): _row_to_dict(row) for row in rows
320
+ }
321
+
322
+ def orphaned(self) -> list[dict[str, Any]]:
323
+ """Dispositions whose rule no longer exists.
324
+
325
+ Deleting a rule does not delete the decisions made about it, and it must
326
+ not: they are evidence of what a human concluded, and quietly dropping
327
+ them would make a reviewed period look unreviewed. They are surfaced
328
+ instead, so an auditor sees "decided, rule since retired" rather than a
329
+ silent gap.
330
+ """
331
+ from .rules import known_rule_ids
332
+
333
+ known = known_rule_ids()
334
+ return [row for row in self.all() if row["rule_id"] not in known]
335
+
336
+ def all(self) -> list[dict[str, Any]]:
337
+ rows = self._get_conn().execute(
338
+ "SELECT * FROM detection_dispositions ORDER BY decided_at_utc DESC"
339
+ ).fetchall()
340
+ return [_row_to_dict(row) for row in rows]
341
+
342
+ def counts(self) -> dict[str, int]:
343
+ rows = self._get_conn().execute(
344
+ "SELECT status, COUNT(*) AS n FROM detection_dispositions GROUP BY status"
345
+ ).fetchall()
346
+ return {str(row["status"]): int(row["n"]) for row in rows}
347
+
348
+
349
+ def _row_to_dict(row: sqlite3.Row) -> dict[str, Any]:
350
+ try:
351
+ history = json.loads(row["history_json"]) or []
352
+ except (json.JSONDecodeError, TypeError):
353
+ history = []
354
+ return {
355
+ "detection_key": row["detection_key"],
356
+ "correlation_id": row["correlation_id"],
357
+ "rule_id": row["rule_id"],
358
+ "status": row["status"],
359
+ "assignee": row["assignee"],
360
+ "note": row["note"],
361
+ "decided_by": row["decided_by"],
362
+ "decided_at_utc": row["decided_at_utc"],
363
+ "first_seen_utc": row["first_seen_utc"],
364
+ "event_id": row["event_id"],
365
+ "history": history,
366
+ "closed": row["status"] in CLOSED_STATUSES,
367
+ }
368
+
369
+
370
+ # ---------------------------------------------------------------------------
371
+ # Canonical event
372
+ # ---------------------------------------------------------------------------
373
+
374
+ def build_disposition_event(
375
+ *,
376
+ correlation_id: str,
377
+ rule_id: str,
378
+ status: str,
379
+ assignee: str = "",
380
+ note: str = "",
381
+ decided_by: str = "",
382
+ previous_status: str = DEFAULT_STATUS,
383
+ severity: str = "",
384
+ ) -> dict[str, Any]:
385
+ """Wrap a triage decision as a canonical event.
386
+
387
+ `action.outcome` carries the new status so a SIEM can alert on
388
+ `action.type:detection_disposition AND action.outcome:risk_accepted`
389
+ without understanding Agentmetry's vocabulary. The note is operator-written
390
+ text, never captured command content, so it is safe to forward.
391
+ """
392
+ from agentmetry.core.config import settings
393
+
394
+ return {
395
+ "schema_version": "1.1.0",
396
+ "event_id": str(uuid.uuid4()),
397
+ "correlation_id": correlation_id,
398
+ "session_id": "",
399
+ "timestamp_utc": _now(),
400
+ **identity_fields(),
401
+ "source_topic": f"disposition/{rule_id}",
402
+ "source": {"tier": "detection", "app": "agentmetry"},
403
+ "actor": {"id": decided_by or settings.operator_id, "type": "human"},
404
+ "initiator": {"type": "human", "id": decided_by or settings.operator_id},
405
+ "action": {
406
+ "type": DISPOSITION_EVENT_TYPE,
407
+ "outcome": status,
408
+ "reason": note or f"disposition set to {status}",
409
+ },
410
+ "agent": {"name": "agentmetry"},
411
+ "disposition": {
412
+ "detection_key": detection_key(correlation_id, rule_id),
413
+ "rule_id": rule_id,
414
+ "severity": severity,
415
+ "status": status,
416
+ "previous_status": previous_status,
417
+ "assignee": assignee,
418
+ "note": note,
419
+ "decided_by": decided_by or settings.operator_id,
420
+ },
421
+ }
422
+
423
+
424
+ def extract_dispositions(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
425
+ """Pull disposition decisions out of a canonical event list, oldest first."""
426
+ found: list[dict[str, Any]] = []
427
+ for event in events:
428
+ action = event.get("action") or {}
429
+ if action.get("type") != DISPOSITION_EVENT_TYPE:
430
+ continue
431
+ disposition = event.get("disposition")
432
+ if not isinstance(disposition, dict):
433
+ continue
434
+ found.append({
435
+ "ts": event.get("timestamp_utc"),
436
+ "correlation_id": event.get("correlation_id"),
437
+ # The trail event's own id, so a replayed row still points back at
438
+ # the line that recorded the decision.
439
+ "event_id": event.get("event_id"),
440
+ **{
441
+ k: disposition.get(k)
442
+ for k in (
443
+ "detection_key",
444
+ "rule_id",
445
+ "severity",
446
+ "status",
447
+ "previous_status",
448
+ "assignee",
449
+ "note",
450
+ "decided_by",
451
+ )
452
+ },
453
+ })
454
+ return found
455
+
456
+
457
+ # ---------------------------------------------------------------------------
458
+ # Module singleton
459
+ # ---------------------------------------------------------------------------
460
+
461
+ _store: DispositionStore | None = None
462
+ _store_lock = threading.Lock()
463
+
464
+
465
+ def get_disposition_store() -> DispositionStore:
466
+ global _store
467
+ if _store is None:
468
+ with _store_lock:
469
+ if _store is None:
470
+ from agentmetry.core.config import settings
471
+
472
+ _store = DispositionStore(settings.detection_disposition_db_path)
473
+ return _store
474
+
475
+
476
+ def reset_disposition_store() -> None:
477
+ """Test helper — drop the singleton so a new path takes effect."""
478
+ global _store
479
+ _store = None
480
+
481
+
482
+ async def apply_disposition(
483
+ *,
484
+ correlation_id: str,
485
+ rule_id: str,
486
+ status: str,
487
+ assignee: str = "",
488
+ note: str = "",
489
+ decided_by: str = "",
490
+ severity: str = "",
491
+ ) -> dict[str, Any]:
492
+ """Record a triage decision: trail first, then index, then forward.
493
+
494
+ Order matters. The trail insert is the durability guarantee, exactly as it
495
+ is for detections themselves — if it fails, nothing is written and the
496
+ caller gets an error rather than a UI that says "saved" over a decision
497
+ that was never recorded. Sink forwarding is best-effort: a down SIEM must
498
+ not lose the operator's decision.
499
+ """
500
+ from agentmetry.core.audit.ingest import _get_sink
501
+ from agentmetry.core.audit.trail_db import get_trail_db
502
+
503
+ normalized, clean_note = _validate(status, note)
504
+ rule_id = validate_rule_id(rule_id)
505
+ store = get_disposition_store()
506
+ existing = store.get(correlation_id, rule_id)
507
+ previous = existing["status"] if existing else DEFAULT_STATUS
508
+
509
+ event = build_disposition_event(
510
+ correlation_id=correlation_id,
511
+ rule_id=rule_id,
512
+ status=normalized,
513
+ assignee=assignee,
514
+ note=clean_note,
515
+ decided_by=decided_by,
516
+ previous_status=previous,
517
+ severity=severity,
518
+ )
519
+ get_trail_db().insert(event)
520
+
521
+ current = store.record(
522
+ correlation_id=correlation_id,
523
+ rule_id=rule_id,
524
+ status=normalized,
525
+ assignee=assignee,
526
+ note=clean_note,
527
+ decided_by=decided_by,
528
+ decided_at_utc=event["timestamp_utc"],
529
+ event_id=event["event_id"],
530
+ )
531
+
532
+ sink = _get_sink()
533
+ if sink is not None:
534
+ try:
535
+ await sink.emit(event)
536
+ except Exception:
537
+ logger.exception("Failed to forward disposition for %s", rule_id)
538
+
539
+ logger.info(
540
+ "DISPOSITION %s %s -> %s by %s",
541
+ rule_id,
542
+ previous,
543
+ normalized,
544
+ decided_by or "operator",
545
+ )
546
+ return current
547
+
548
+
549
+ class DispositionRebuildRefused(RuntimeError):
550
+ """The trail cannot account for decisions the index already holds.
551
+
552
+ Raised instead of destroying them. Carries the orphaned keys so the
553
+ operator is told exactly what would have been lost.
554
+ """
555
+
556
+ def __init__(self, missing: set[str]) -> None:
557
+ self.missing = missing
558
+ super().__init__(
559
+ f"{len(missing)} disposition(s) in the index have no matching event "
560
+ "in the trail; refusing to rebuild. Restore the trail, or pass "
561
+ "force=True to accept the loss."
562
+ )
563
+
564
+
565
+ def _replay_decisions(decisions: list[dict[str, Any]], store: DispositionStore) -> int:
566
+ replayed = 0
567
+ for decision in decisions:
568
+ try:
569
+ store.record(
570
+ correlation_id=str(decision.get("correlation_id") or ""),
571
+ rule_id=str(decision.get("rule_id") or ""),
572
+ status=str(decision.get("status") or DEFAULT_STATUS),
573
+ assignee=str(decision.get("assignee") or ""),
574
+ note=str(decision.get("note") or ""),
575
+ decided_by=str(decision.get("decided_by") or ""),
576
+ decided_at_utc=str(decision.get("ts") or ""),
577
+ event_id=str(decision.get("event_id") or ""),
578
+ )
579
+ replayed += 1
580
+ except DispositionError as exc:
581
+ logger.warning("skipping unreplayable disposition: %s", exc)
582
+ return replayed
583
+
584
+
585
+ def rebuild_from_trail(
586
+ trail_db: Any | None = None, *, force: bool = False
587
+ ) -> int:
588
+ """Replay disposition events from the trail into the index.
589
+
590
+ The trail is the record and this table is an index over it, so replaying is
591
+ normally safe. It is not unconditionally safe, and that distinction is the
592
+ whole point of this function's shape.
593
+
594
+ Rebuilding starts by emptying the index. If the trail has been pruned,
595
+ rotated, restored from a partial backup, or simply repointed at a different
596
+ path, replay produces fewer decisions than the index held and the missing
597
+ ones are gone. Losing triage history is worse than losing detections: the
598
+ findings survive, so the period reads as *untriaged* rather than *unknown*,
599
+ which is exactly backwards for ISO/IEC 42001 cl. 10 evidence.
600
+
601
+ So a rebuild that cannot account for every key already in the index raises
602
+ `DispositionRebuildRefused` rather than proceeding. `force=True` accepts the
603
+ loss and is for an operator who knows why.
604
+ """
605
+ if trail_db is None:
606
+ from agentmetry.core.audit.trail_db import get_trail_db
607
+
608
+ trail_db = get_trail_db()
609
+
610
+ decisions = extract_dispositions(
611
+ trail_db.events_by_action_type(DISPOSITION_EVENT_TYPE)
612
+ )
613
+
614
+ store = get_disposition_store()
615
+ in_trail = {
616
+ detection_key(
617
+ str(d.get("correlation_id") or ""), str(d.get("rule_id") or "")
618
+ )
619
+ for d in decisions
620
+ if d.get("rule_id")
621
+ }
622
+ in_index = {str(row["detection_key"]) for row in store.all()}
623
+
624
+ missing = in_index - in_trail
625
+ if missing and not force:
626
+ raise DispositionRebuildRefused(missing)
627
+
628
+ store.clear()
629
+ return _replay_decisions(decisions, store)
630
+
631
+
632
+ def reconcile_at_boot(trail_db: Any | None = None) -> int:
633
+ """Bring the index up to date at startup without ever destroying it.
634
+
635
+ Returns the number of decisions replayed, or -1 when reconciliation was
636
+ declined. Declining is a warning, never a failure: an orchestrator that
637
+ refuses to start because its triage index disagrees with its trail is worse
638
+ than one that starts and says so.
639
+ """
640
+ try:
641
+ replayed = rebuild_from_trail(trail_db)
642
+ except DispositionRebuildRefused as exc:
643
+ logger.warning(
644
+ "Triage index NOT rebuilt: %s Sample: %s",
645
+ exc,
646
+ ", ".join(sorted(exc.missing)[:5]),
647
+ )
648
+ return -1
649
+ if replayed:
650
+ logger.info("Replayed %d disposition(s) from the trail", replayed)
651
+ return replayed