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,332 @@
1
+ """Merkle tree over the hash-chained trail, for proofs that disclose one event.
2
+
3
+ The linear chain in trail_chain.py answers "has this file been altered". It
4
+ cannot answer "did this specific event happen" without handing over the entire
5
+ file, because every record's hash depends on the one before it. For an audit
6
+ that is the wrong shape: proving one tool call should not require disclosing a
7
+ month of unrelated work to whoever is asking.
8
+
9
+ A Merkle tree over the same `record_sha256` values answers it in O(log n)
10
+ hashes. Publish the root somewhere the audited machine cannot rewrite, and any
11
+ single event can later be proved against it while the rest of the trail stays
12
+ private.
13
+
14
+ **Additive by construction.** Nothing here changes the envelope, the canonical
15
+ event JSON, or `record_sha256`. Leaves are the record hashes the chain already
16
+ computes, so every trail ever written is compatible and no stored event needs
17
+ migrating. The root lives in the sidecar and is derived, never authoritative:
18
+ delete it and it recomputes from the file.
19
+
20
+ Deliberately not maintained on the append path. The root is what you publish
21
+ periodically, not per event, and `append_chained_line` is already the hottest
22
+ and least protected code in the system (it has no cross-process lock). Adding
23
+ an incremental tree update there would buy latency we do not need at a risk we
24
+ should not take.
25
+
26
+ ## Why this is not the reference implementation it was modelled on
27
+
28
+ Microsoft's Agent Governance Toolkit ships a `MerkleAuditChain` doing the same
29
+ job, MIT licensed and readable. Two properties of that implementation are the
30
+ reason this one was written from RFC 6962 instead:
31
+
32
+ 1. **No domain separation.** Leaves are hashed as the entry hash itself and
33
+ internal nodes as `sha256(left || right)`, so the two are indistinguishable.
34
+ That is the textbook Merkle second-preimage weakness: an internal node can be
35
+ presented as a leaf, and a proof for a node that is not an event verifies
36
+ against the root. RFC 6962 §2.1 prefixes leaves with 0x00 and internal nodes
37
+ with 0x01 for exactly this reason, which is what this module does.
38
+
39
+ 2. **Duplicated last node when a level is odd** (`right = left` where no sibling
40
+ exists). That is the shape behind CVE-2012-2459: two different leaf sequences
41
+ can produce the same root, so a proof can be valid for a tree that was never
42
+ written. RFC 6962 splits at the largest power of two below `n` instead and
43
+ never duplicates or pads, so distinct inputs always give distinct roots.
44
+
45
+ Neither is exotic and both are well documented; the point is only that a
46
+ tamper-evidence primitive is the wrong place to inherit a subtle weakness for
47
+ the sake of matching someone else's bytes. Interoperating on the *format* is
48
+ still worth doing, and a CT-style tree is the more interoperable choice anyway
49
+ since it is what Certificate Transparency, Trillian and Sigstore already speak.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import hashlib
55
+ import json
56
+ from dataclasses import dataclass
57
+ from pathlib import Path
58
+ from typing import Any
59
+
60
+ from agentmetry.core.audit.trail_chain import chain_sidecar_path, is_chained_record
61
+
62
+ # RFC 6962 §2.1 domain separation. Without these two bytes a leaf and an
63
+ # internal node hash identically and the tree loses second-preimage resistance.
64
+ _LEAF_PREFIX = b"\x00"
65
+ _NODE_PREFIX = b"\x01"
66
+
67
+ MERKLE_VERSION = 1
68
+
69
+
70
+ def _leaf_hash(record_sha256: str) -> bytes:
71
+ return hashlib.sha256(_LEAF_PREFIX + bytes.fromhex(record_sha256)).digest()
72
+
73
+
74
+ def _node_hash(left: bytes, right: bytes) -> bytes:
75
+ return hashlib.sha256(_NODE_PREFIX + left + right).digest()
76
+
77
+
78
+ def _split(n: int) -> int:
79
+ """Largest power of two strictly less than n (RFC 6962's k).
80
+
81
+ Splitting here rather than padding to a power of two is what keeps distinct
82
+ leaf sequences mapping to distinct roots.
83
+ """
84
+ k = 1
85
+ while k << 1 < n:
86
+ k <<= 1
87
+ return k
88
+
89
+
90
+ def _root(leaves: list[bytes]) -> bytes:
91
+ if not leaves:
92
+ # MTH({}) = SHA-256() of the empty string, per RFC 6962.
93
+ return hashlib.sha256(b"").digest()
94
+ if len(leaves) == 1:
95
+ return leaves[0]
96
+ k = _split(len(leaves))
97
+ return _node_hash(_root(leaves[:k]), _root(leaves[k:]))
98
+
99
+
100
+ def _audit_path(index: int, leaves: list[bytes]) -> list[bytes]:
101
+ if len(leaves) <= 1:
102
+ return []
103
+ k = _split(len(leaves))
104
+ if index < k:
105
+ return _audit_path(index, leaves[:k]) + [_root(leaves[k:])]
106
+ return _audit_path(index - k, leaves[k:]) + [_root(leaves[:k])]
107
+
108
+
109
+ def _root_from_path(index: int, tree_size: int, leaf: bytes, path: list[bytes]) -> bytes:
110
+ """Recompute the root from a leaf and its audit path (RFC 6962 §2.1.1).
111
+
112
+ The index and tree size carry the shape, which is why a proof cannot be
113
+ replayed against a differently sized tree.
114
+ """
115
+ if index >= tree_size:
116
+ raise ValueError(f"leaf index {index} outside tree of size {tree_size}")
117
+ fn, sn = index, tree_size - 1
118
+ node = leaf
119
+ for sibling in path:
120
+ if fn & 1 or fn == sn:
121
+ node = _node_hash(sibling, node)
122
+ while fn & 1 == 0 and fn != 0:
123
+ fn >>= 1
124
+ sn >>= 1
125
+ else:
126
+ node = _node_hash(node, sibling)
127
+ fn >>= 1
128
+ sn >>= 1
129
+ if sn != 0:
130
+ raise ValueError("audit path too short for the stated tree size")
131
+ return node
132
+
133
+
134
+ # ----------------------------------------------------------------------
135
+ # Reading the trail
136
+ # ----------------------------------------------------------------------
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class TrailLeaves:
141
+ """Record hashes in file order, with the seq each came from.
142
+
143
+ Legacy unchained lines carry no `record_sha256` and are skipped rather than
144
+ invented for. A trail that predates chaining simply has fewer leaves than
145
+ lines, and `verify_trail_file` already reports that count separately.
146
+ """
147
+
148
+ record_hashes: list[str]
149
+ seqs: list[int]
150
+ skipped_legacy: int
151
+
152
+ def __len__(self) -> int:
153
+ return len(self.record_hashes)
154
+
155
+ def index_of_seq(self, seq: int) -> int | None:
156
+ try:
157
+ return self.seqs.index(seq)
158
+ except ValueError:
159
+ return None
160
+
161
+
162
+ def read_leaves(trail_path: Path) -> TrailLeaves:
163
+ hashes: list[str] = []
164
+ seqs: list[int] = []
165
+ skipped = 0
166
+ if not trail_path.is_file():
167
+ return TrailLeaves([], [], 0)
168
+ with trail_path.open("r", encoding="utf-8", errors="replace") as fh:
169
+ for line in fh:
170
+ line = line.strip()
171
+ if not line:
172
+ continue
173
+ try:
174
+ record = json.loads(line)
175
+ except json.JSONDecodeError:
176
+ skipped += 1
177
+ continue
178
+ if not is_chained_record(record):
179
+ skipped += 1
180
+ continue
181
+ trail = record["trail"]
182
+ digest = str(trail.get("record_sha256") or "")
183
+ if len(digest) != 64:
184
+ skipped += 1
185
+ continue
186
+ hashes.append(digest)
187
+ seqs.append(int(trail.get("seq") or 0))
188
+ return TrailLeaves(hashes, seqs, skipped)
189
+
190
+
191
+ def merkle_root(trail_path: Path, tree_size: int | None = None) -> tuple[str, int]:
192
+ """(root hex, tree size) for a trail file, optionally at a historical size.
193
+
194
+ `tree_size` exists because the trail is append-only and live: the root
195
+ changes on every event. A proof issued this morning names the tree it was
196
+ issued against, and checking it against tonight's root fails for the most
197
+ boring possible reason. Recomputing over the first `tree_size` leaves gives
198
+ the root as it stood then, which is the thing the proof actually claims.
199
+
200
+ Without this the whole feature is unusable on a running recorder, which is
201
+ the only kind anybody has.
202
+ """
203
+ leaves = read_leaves(trail_path)
204
+ hashes = leaves.record_hashes
205
+ if tree_size is not None:
206
+ if tree_size > len(hashes):
207
+ raise ValueError(
208
+ f"trail holds {len(hashes)} chained records, cannot reconstruct "
209
+ f"a tree of size {tree_size}. The proof is either forged or from "
210
+ "a different trail."
211
+ )
212
+ hashes = hashes[:tree_size]
213
+ nodes = [_leaf_hash(h) for h in hashes]
214
+ return _root(nodes).hex(), len(nodes)
215
+
216
+
217
+ # ----------------------------------------------------------------------
218
+ # Proofs
219
+ # ----------------------------------------------------------------------
220
+
221
+
222
+ @dataclass(frozen=True)
223
+ class InclusionProof:
224
+ seq: int
225
+ leaf_index: int
226
+ tree_size: int
227
+ record_sha256: str
228
+ path: list[str]
229
+ root_sha256: str
230
+
231
+ def to_dict(self) -> dict[str, Any]:
232
+ return {
233
+ "v": MERKLE_VERSION,
234
+ "alg": "rfc6962-sha256",
235
+ "seq": self.seq,
236
+ "leaf_index": self.leaf_index,
237
+ "tree_size": self.tree_size,
238
+ "record_sha256": self.record_sha256,
239
+ "path": list(self.path),
240
+ "root_sha256": self.root_sha256,
241
+ }
242
+
243
+ @staticmethod
244
+ def from_dict(data: dict[str, Any]) -> InclusionProof:
245
+ return InclusionProof(
246
+ seq=int(data.get("seq") or 0),
247
+ leaf_index=int(data["leaf_index"]),
248
+ tree_size=int(data["tree_size"]),
249
+ record_sha256=str(data["record_sha256"]),
250
+ path=[str(p) for p in (data.get("path") or [])],
251
+ root_sha256=str(data.get("root_sha256") or ""),
252
+ )
253
+
254
+
255
+ def build_proof(trail_path: Path, seq: int) -> InclusionProof:
256
+ leaves = read_leaves(trail_path)
257
+ index = leaves.index_of_seq(seq)
258
+ if index is None:
259
+ raise ValueError(f"seq {seq} is not a chained record in {trail_path}")
260
+ nodes = [_leaf_hash(h) for h in leaves.record_hashes]
261
+ path = _audit_path(index, nodes)
262
+ return InclusionProof(
263
+ seq=seq,
264
+ leaf_index=index,
265
+ tree_size=len(nodes),
266
+ record_sha256=leaves.record_hashes[index],
267
+ path=[p.hex() for p in path],
268
+ root_sha256=_root(nodes).hex(),
269
+ )
270
+
271
+
272
+ def verify_proof(proof: InclusionProof, expected_root: str | None = None) -> tuple[bool, str]:
273
+ """Check a proof, optionally against an independently held root.
274
+
275
+ `expected_root` is the whole point. Verifying against the root carried
276
+ inside the proof only shows the proof is internally consistent, which a
277
+ forger can also arrange. The check that means something compares it to a
278
+ root recorded somewhere the audited machine could not rewrite.
279
+ """
280
+ try:
281
+ leaf = _leaf_hash(proof.record_sha256)
282
+ path = [bytes.fromhex(p) for p in proof.path]
283
+ computed = _root_from_path(proof.leaf_index, proof.tree_size, leaf, path).hex()
284
+ except (ValueError, TypeError) as exc:
285
+ return False, f"malformed proof: {exc}"
286
+
287
+ if proof.root_sha256 and computed != proof.root_sha256:
288
+ return False, (
289
+ f"proof does not reconstruct its own root (computed {computed[:16]}…, "
290
+ f"stated {proof.root_sha256[:16]}…)"
291
+ )
292
+ if expected_root:
293
+ if computed != expected_root:
294
+ return False, (
295
+ f"proof is valid but for a different tree (computed {computed[:16]}…, "
296
+ f"expected {expected_root[:16]}…)"
297
+ )
298
+ return True, f"verified against the supplied root {computed[:16]}…"
299
+ return True, (
300
+ f"internally consistent (root {computed[:16]}…). No external root was "
301
+ "supplied, so this shows the proof is well formed, not that the trail "
302
+ "was not rewritten."
303
+ )
304
+
305
+
306
+ # ----------------------------------------------------------------------
307
+ # Sidecar
308
+ # ----------------------------------------------------------------------
309
+
310
+
311
+ def record_root(trail_path: Path) -> dict[str, Any]:
312
+ """Recompute the root and store it alongside the chain head.
313
+
314
+ Written as extra keys in the existing sidecar. `_load_sidecar` reads only
315
+ `seq` and `last_sha256` and ignores everything else, so an older build reads
316
+ a newer sidecar without noticing, which is the property that makes this
317
+ additive rather than a format change.
318
+ """
319
+ root, size = merkle_root(trail_path)
320
+ sidecar = chain_sidecar_path(trail_path)
321
+ data: dict[str, Any] = {}
322
+ if sidecar.is_file():
323
+ try:
324
+ data = json.loads(sidecar.read_text(encoding="utf-8"))
325
+ except (OSError, json.JSONDecodeError):
326
+ data = {}
327
+ data["merkle_root"] = root
328
+ data["merkle_tree_size"] = size
329
+ data["merkle_alg"] = "rfc6962-sha256"
330
+ sidecar.parent.mkdir(parents=True, exist_ok=True)
331
+ sidecar.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
332
+ return {"root": root, "tree_size": size, "sidecar": str(sidecar)}
@@ -0,0 +1,54 @@
1
+ """Optional API key authentication for mutating endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import secrets
6
+
7
+ from fastapi import HTTPException, Request, Security
8
+ from fastapi.security import APIKeyHeader
9
+
10
+ from agentmetry.core.config import settings
11
+
12
+
13
+ def _token_matches(token: str | None) -> bool:
14
+ """Constant-time compare so the key can't be recovered a byte at a time.
15
+
16
+ A plain `==` short-circuits on the first differing byte, leaking the shared
17
+ key's prefix through response timing to anyone who can reach the endpoint.
18
+ `compare_digest` runs in time independent of where the mismatch is.
19
+ """
20
+ return secrets.compare_digest(token or "", settings.api_key)
21
+
22
+ _api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
23
+
24
+
25
+ async def require_api_key(
26
+ request: Request,
27
+ api_key: str | None = Security(_api_key_header),
28
+ ) -> None:
29
+ """Skip auth when AGENTMETRY_API_KEY is unset (local dev)."""
30
+ if not settings.api_key:
31
+ return
32
+
33
+ token = api_key
34
+ if not token:
35
+ auth = request.headers.get("Authorization", "")
36
+ if auth.lower().startswith("bearer "):
37
+ token = auth[7:].strip()
38
+
39
+ if not _token_matches(token):
40
+ raise HTTPException(status_code=401, detail="Invalid or missing API key")
41
+
42
+
43
+ def verify_ws_token(query_token: str | None, request: Request) -> bool:
44
+ """Validate WebSocket connection when AGENTMETRY_API_KEY is set."""
45
+ if not settings.api_key:
46
+ return True
47
+ token = query_token
48
+ if not token:
49
+ token = request.headers.get("X-API-Key")
50
+ if not token:
51
+ auth = request.headers.get("Authorization", "")
52
+ if auth.lower().startswith("bearer "):
53
+ token = auth[7:].strip()
54
+ return _token_matches(token)
@@ -0,0 +1,5 @@
1
+ """In-process event bus — decoupled pub/sub with a durable SQLite outbox.
2
+
3
+ Import from the submodules (core.bus.bus, core.bus.events, core.bus.outbox);
4
+ bridges live in core.bus.bridges and api.ws_bridge and are wired in lifespan.
5
+ """
@@ -0,0 +1,107 @@
1
+ """Subscribe to the event bus and forward canonical audit events to configured sinks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ from agentmetry.core.audit.alerts import AlertWebhookSink
8
+ from agentmetry.core.audit.canonical import normalize_outbox_row
9
+ from agentmetry.core.audit.sinks import MultiAuditSink, build_audit_sinks, parse_sink_modes
10
+ from agentmetry.core.bus.bus import EventBus, bus
11
+ from agentmetry.core.bus.events import Event, LLM_TOKEN
12
+ from agentmetry.core.config import settings
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def event_to_outbox_row(event: Event) -> dict:
18
+ return {
19
+ "seq": event.seq,
20
+ "ts": event.ts,
21
+ "topic": event.topic,
22
+ "session_id": event.session_id,
23
+ "thread_id": event.thread_id,
24
+ "payload": event.payload,
25
+ }
26
+
27
+
28
+ def _describe_sink_targets(modes: set[str]) -> list[str]:
29
+ targets: list[str] = []
30
+ if "file" in modes:
31
+ targets.append(str(settings.audit_export_path))
32
+ if "webhook" in modes and settings.audit_webhook_url.strip():
33
+ targets.append(settings.audit_webhook_url.strip())
34
+ if "elastic" in modes and settings.audit_elastic_url.strip():
35
+ targets.append(f"elastic:{settings.audit_elastic_index}")
36
+ if "splunk" in modes and settings.audit_splunk_hec_url.strip():
37
+ targets.append(f"splunk:{settings.audit_splunk_index}")
38
+ return targets
39
+
40
+
41
+ async def audit_exporter(
42
+ bus_: EventBus = bus,
43
+ *,
44
+ enabled: bool | None = None,
45
+ ) -> None:
46
+ """Forward normalized bus events to configured sinks (best-effort)."""
47
+ if enabled is None:
48
+ enabled = settings.audit_export_enabled
49
+ if not enabled:
50
+ logger.info("Audit export disabled (AGENTMETRY_AUDIT_EXPORT_ENABLED=0)")
51
+ return
52
+
53
+ modes = parse_sink_modes(settings.audit_sink)
54
+
55
+ sink = build_audit_sinks(
56
+ modes=modes,
57
+ file_path=settings.audit_export_path,
58
+ webhook_url=settings.audit_webhook_url,
59
+ webhook_timeout_seconds=settings.audit_webhook_timeout_seconds,
60
+ webhook_format=settings.audit_webhook_format,
61
+ elastic_url=settings.audit_elastic_url,
62
+ elastic_index=settings.audit_elastic_index,
63
+ elastic_api_key=settings.audit_elastic_api_key,
64
+ elastic_verify_tls=settings.audit_elastic_verify_tls,
65
+ splunk_hec_url=settings.audit_splunk_hec_url,
66
+ splunk_hec_token=settings.audit_splunk_hec_token,
67
+ splunk_index=settings.audit_splunk_index,
68
+ splunk_sourcetype=settings.audit_splunk_sourcetype,
69
+ splunk_verify_tls=settings.audit_splunk_verify_tls,
70
+ )
71
+ if sink is None and not settings.audit_alert_webhook_url.strip():
72
+ logger.warning(
73
+ "Audit export enabled but no sinks configured "
74
+ "(check AGENTMETRY_AUDIT_SINK and backend credentials)"
75
+ )
76
+ return
77
+
78
+ # Add Alert Sink if configured
79
+ if settings.audit_alert_webhook_url.strip():
80
+ alert_sink = AlertWebhookSink(
81
+ settings.audit_alert_webhook_url.strip(),
82
+ timeout_seconds=settings.audit_webhook_timeout_seconds,
83
+ )
84
+ if sink is None:
85
+ sink = alert_sink
86
+ elif isinstance(sink, MultiAuditSink):
87
+ sink._sinks.append(alert_sink)
88
+ else:
89
+ sink = MultiAuditSink([sink, alert_sink])
90
+
91
+ targets = _describe_sink_targets(modes)
92
+ if settings.audit_alert_webhook_url.strip():
93
+ targets.append(f"alerts:{settings.audit_alert_webhook_url.strip()}")
94
+ logger.info("Audit export sinks → %s", ", ".join(targets))
95
+
96
+ sub = bus_.subscribe("audit-export", exclude={LLM_TOKEN})
97
+ try:
98
+ while True:
99
+ event = await sub.get()
100
+ try:
101
+ canonical = normalize_outbox_row(event_to_outbox_row(event))
102
+ if canonical is not None:
103
+ await sink.emit(canonical)
104
+ except Exception:
105
+ logger.exception("Audit export failed for %s seq=%s", event.topic, event.seq)
106
+ finally:
107
+ bus_.unsubscribe(sub)
@@ -0,0 +1,26 @@
1
+ """Core-side bus subscribers: durable persistence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ from agentmetry.core.bus.bus import EventBus, bus
8
+ from agentmetry.core.bus.events import LLM_TOKEN
9
+ from agentmetry.core.bus.outbox import EventOutbox, get_outbox
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ async def outbox_persister(bus_: EventBus = bus, outbox: EventOutbox | None = None) -> None:
15
+ """Persist every event except the token firehose. Never drops."""
16
+ ob = outbox or get_outbox()
17
+ sub = bus_.subscribe("outbox", exclude={LLM_TOKEN})
18
+ try:
19
+ while True:
20
+ event = await sub.get()
21
+ try:
22
+ ob.append(event)
23
+ except Exception:
24
+ logger.exception("Outbox write failed for %s", event.topic)
25
+ finally:
26
+ bus_.unsubscribe(sub)
@@ -0,0 +1,102 @@
1
+ """EventBus — synchronous publish, per-subscriber queues, explicit drop policy.
2
+
3
+ Loop-thread only: publish() must be called from the event loop (the vault
4
+ watcher already marshals via call_soon_threadsafe). Publishing never blocks —
5
+ each subscriber declares whether overflow drops its oldest events (WS bridge)
6
+ or is unbounded (outbox persister, which must never lose audit events).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import logging
13
+ from dataclasses import dataclass, field
14
+ from typing import Any
15
+
16
+ from agentmetry.core.bus.events import Event
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ @dataclass
22
+ class Subscription:
23
+ name: str
24
+ queue: asyncio.Queue
25
+ topics: frozenset[str] | None # None = all topics
26
+ exclude: frozenset[str]
27
+ drop_oldest: bool
28
+
29
+ async def get(self) -> Event:
30
+ return await self.queue.get()
31
+
32
+
33
+ @dataclass
34
+ class EventBus:
35
+ _subs: list[Subscription] = field(default_factory=list)
36
+ _seq: int = 0
37
+
38
+ def set_initial_seq(self, seq: int) -> None:
39
+ """Continue numbering after the outbox's last persisted event."""
40
+ self._seq = max(self._seq, seq)
41
+
42
+ def subscribe(
43
+ self,
44
+ name: str,
45
+ *,
46
+ topics: set[str] | None = None,
47
+ exclude: set[str] | None = None,
48
+ maxsize: int = 0,
49
+ drop_oldest: bool = False,
50
+ ) -> Subscription:
51
+ sub = Subscription(
52
+ name=name,
53
+ queue=asyncio.Queue(maxsize=maxsize),
54
+ topics=frozenset(topics) if topics is not None else None,
55
+ exclude=frozenset(exclude or ()),
56
+ drop_oldest=drop_oldest,
57
+ )
58
+ self._subs.append(sub)
59
+ return sub
60
+
61
+ def unsubscribe(self, sub: Subscription) -> None:
62
+ self._subs = [s for s in self._subs if s is not sub]
63
+
64
+ def publish(
65
+ self,
66
+ topic: str,
67
+ payload: dict[str, Any],
68
+ *,
69
+ session_id: str = "",
70
+ thread_id: str = "",
71
+ ) -> Event:
72
+ self._seq += 1
73
+ event = Event(
74
+ topic=topic,
75
+ payload=payload,
76
+ seq=self._seq,
77
+ session_id=session_id,
78
+ thread_id=thread_id,
79
+ )
80
+ for sub in self._subs:
81
+ if sub.topics is not None and topic not in sub.topics:
82
+ continue
83
+ if topic in sub.exclude:
84
+ continue
85
+ try:
86
+ sub.queue.put_nowait(event)
87
+ except asyncio.QueueFull:
88
+ if sub.drop_oldest:
89
+ try:
90
+ sub.queue.get_nowait()
91
+ except asyncio.QueueEmpty:
92
+ pass
93
+ try:
94
+ sub.queue.put_nowait(event)
95
+ except asyncio.QueueFull:
96
+ pass
97
+ else:
98
+ logger.warning("Bus subscriber %s full — dropped %s", sub.name, topic)
99
+ return event
100
+
101
+
102
+ bus = EventBus()
@@ -0,0 +1,50 @@
1
+ """Event envelope and topic constants."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime, timezone
7
+ from typing import Any
8
+
9
+ # Run lifecycle
10
+ RUN_STARTED = "run/started"
11
+ RUN_NODE = "run/node"
12
+ RUN_WAITING = "run/approval_required"
13
+ RUN_APPROVAL_GRANTED = "run/approval_granted"
14
+ RUN_APPROVAL_DENIED = "run/approval_denied"
15
+ RUN_COMPLETED = "run/completed"
16
+ RUN_FAILED = "run/failed"
17
+ RUN_TERMINATED = "run/terminated"
18
+ FLYWHEEL_CAPTURE = "run/flywheel_capture"
19
+ ALERT_COST = "run/cost_alert"
20
+ ALERT_DRIFT = "run/drift_alert"
21
+
22
+ # High-volume, ephemeral — excluded from the outbox.
23
+ LLM_TOKEN = "llm/token"
24
+
25
+ # Vault
26
+ VAULT_FILE_CHANGED = "vault/file_changed"
27
+
28
+ # Interrupt vector table
29
+ INTERRUPT_RAISED = "interrupt/raised"
30
+ INTERRUPT_RESOLVED = "interrupt/resolved"
31
+
32
+ # Driver subsystem (MCP)
33
+ DRIVER_MOUNTED = "driver/mounted"
34
+ DRIVER_FAILED = "driver/failed"
35
+ TOOL_CALLED = "run/tool_called"
36
+ TOOL_DENIED = "run/tool_denied"
37
+
38
+
39
+ def _now_iso() -> str:
40
+ return datetime.now(timezone.utc).isoformat()
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Event:
45
+ topic: str
46
+ payload: dict[str, Any]
47
+ seq: int = 0
48
+ session_id: str = "" # WebSocket routing hint; empty = not for the UI
49
+ thread_id: str = ""
50
+ ts: str = field(default_factory=_now_iso)