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.
- agentmetry/__init__.py +12 -0
- agentmetry/api/__init__.py +0 -0
- agentmetry/api/main.py +232 -0
- agentmetry/api/routes/__init__.py +0 -0
- agentmetry/api/routes/audit.py +396 -0
- agentmetry/api/websocket.py +53 -0
- agentmetry/api/ws_bridge.py +34 -0
- agentmetry/cli/__init__.py +941 -0
- agentmetry/cli/__main__.py +5 -0
- agentmetry/core/__init__.py +0 -0
- agentmetry/core/audit/__init__.py +1 -0
- agentmetry/core/audit/adapters/__init__.py +0 -0
- agentmetry/core/audit/adapters/agt.py +304 -0
- agentmetry/core/audit/adapters/cloudevents.py +159 -0
- agentmetry/core/audit/adapters/ecs.py +103 -0
- agentmetry/core/audit/adapters/splunk.py +40 -0
- agentmetry/core/audit/alerts.py +56 -0
- agentmetry/core/audit/canonical.py +150 -0
- agentmetry/core/audit/compliance_digest.py +299 -0
- agentmetry/core/audit/detection/__init__.py +9 -0
- agentmetry/core/audit/detection/benchmark.py +194 -0
- agentmetry/core/audit/detection/corpus/attack_approval_denied_then_executed.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/attack_arbitrary_host_stage_execute.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/attack_autonomous_unapproved_write.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/attack_credential_exfil.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_credential_then_cloud_api.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_destructive_delete_burst.jsonl +6 -0
- agentmetry/core/audit/detection/corpus/attack_discovery_then_collect.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/attack_dotfile_then_git_push.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_encoded_command_download.jsonl +1 -0
- agentmetry/core/audit/detection/corpus/attack_env_credential_exfil.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/attack_hashed_only_no_command.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_interpreter_egress.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_pr_merged_without_review.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_proc_substitution_cradle.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_remote_pipe_to_shell.jsonl +1 -0
- agentmetry/core/audit/detection/corpus/attack_remote_staging_then_execute.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_session_tool_burst.jsonl +42 -0
- agentmetry/core/audit/detection/corpus/attack_single_command_exfil.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_ssh_directory_exfil.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/attack_subagent_swarm.jsonl +6 -0
- agentmetry/core/audit/detection/corpus/attack_timestamp_collision.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_untrusted_input_then_action.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_authoring_merge_fixtures.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_autonomous_after_approval.jsonl +4 -0
- agentmetry/core/audit/detection/corpus/benign_build_artifact_cleanup.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_ci_artifact_download.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_database_migration.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_dependency_install_and_build.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_download_release_archive.jsonl +4 -0
- agentmetry/core/audit/detection/corpus/benign_fetch_data_then_run_repo_script.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_fetch_dataset_then_analyse.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_fetch_lockfile_then_install.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_git_review_and_push.jsonl +6 -0
- agentmetry/core/audit/detection/corpus/benign_human_driven_deletes.jsonl +6 -0
- agentmetry/core/audit/detection/corpus/benign_local_api_probing.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_long_but_calm_session.jsonl +30 -0
- agentmetry/core/audit/detection/corpus/benign_loopback_is_not_egress.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/benign_loopback_pipe_to_interpreter.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_ordinary_development.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_package_manager_after_fetch.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/benign_reading_config_that_is_not_secret.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_remote_api_call_no_credentials.jsonl +4 -0
- agentmetry/core/audit/detection/corpus/benign_research_then_docs.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_reversed_order_is_not_exfil.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/benign_test_and_fix_loop.jsonl +6 -0
- agentmetry/core/audit/detection/corpus/benign_writing_about_credentials.jsonl +6 -0
- agentmetry/core/audit/detection/corpus/corpus.yaml +443 -0
- agentmetry/core/audit/detection/disposition.py +651 -0
- agentmetry/core/audit/detection/engine.py +78 -0
- agentmetry/core/audit/detection/live.py +127 -0
- agentmetry/core/audit/detection/live_store.py +355 -0
- agentmetry/core/audit/detection/models.py +53 -0
- agentmetry/core/audit/detection/rules.py +1314 -0
- agentmetry/core/audit/detection/traits.py +648 -0
- agentmetry/core/audit/detection/yaml_config.py +91 -0
- agentmetry/core/audit/detection/yaml_rules.py +83 -0
- agentmetry/core/audit/dlp/__init__.py +4 -0
- agentmetry/core/audit/dlp/loader.py +29 -0
- agentmetry/core/audit/dlp/models.py +29 -0
- agentmetry/core/audit/dlp/scanner.py +96 -0
- agentmetry/core/audit/dogfood.py +398 -0
- agentmetry/core/audit/evidence_pack.py +500 -0
- agentmetry/core/audit/external.py +213 -0
- agentmetry/core/audit/hashing.py +21 -0
- agentmetry/core/audit/hook_bootstrap.py +451 -0
- agentmetry/core/audit/identity.py +39 -0
- agentmetry/core/audit/ingest.py +242 -0
- agentmetry/core/audit/migrate.py +73 -0
- agentmetry/core/audit/mitre.py +244 -0
- agentmetry/core/audit/policy.py +99 -0
- agentmetry/core/audit/redaction.py +50 -0
- agentmetry/core/audit/replay.py +54 -0
- agentmetry/core/audit/run_context.py +129 -0
- agentmetry/core/audit/sinks.py +235 -0
- agentmetry/core/audit/spool.py +394 -0
- agentmetry/core/audit/tool_policy/__init__.py +4 -0
- agentmetry/core/audit/tool_policy/evaluator.py +198 -0
- agentmetry/core/audit/tool_policy/loader.py +44 -0
- agentmetry/core/audit/tool_policy/models.py +25 -0
- agentmetry/core/audit/trail_chain.py +300 -0
- agentmetry/core/audit/trail_db.py +491 -0
- agentmetry/core/audit/trail_merkle.py +332 -0
- agentmetry/core/auth.py +54 -0
- agentmetry/core/bus/__init__.py +5 -0
- agentmetry/core/bus/audit_exporter.py +107 -0
- agentmetry/core/bus/bridges.py +26 -0
- agentmetry/core/bus/bus.py +102 -0
- agentmetry/core/bus/events.py +50 -0
- agentmetry/core/bus/outbox.py +124 -0
- agentmetry/core/config.py +177 -0
- agentmetry/core/diagnostics/__init__.py +0 -0
- agentmetry/core/diagnostics/autostart.py +563 -0
- agentmetry/core/diagnostics/doctor.py +535 -0
- agentmetry/core/diagnostics/driver_paths.py +156 -0
- agentmetry/core/diagnostics/env_file.py +45 -0
- agentmetry/core/drivers/__init__.py +4 -0
- agentmetry/core/drivers/host.py +263 -0
- agentmetry/core/drivers/permissions.py +37 -0
- agentmetry/core/drivers/spec.py +118 -0
- agentmetry/core/extensions.py +107 -0
- agentmetry/core/health.py +26 -0
- agentmetry/core/version.py +13 -0
- agentmetry/policies/detection/manifest.yaml +43 -0
- agentmetry/policies/dlp/manifest.yaml +161 -0
- agentmetry/policies/opa/agent_rules.rego +33 -0
- agentmetry/policies/tool/manifest.yaml +117 -0
- agentmetry-0.4.0.dist-info/METADATA +86 -0
- agentmetry-0.4.0.dist-info/RECORD +131 -0
- agentmetry-0.4.0.dist-info/WHEEL +4 -0
- 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)}
|
agentmetry/core/auth.py
ADDED
|
@@ -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,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)
|