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
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Audit artifacts — compliance evidence packs from the event outbox and run ledger."""
|
|
File without changes
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Read Microsoft Agent Governance Toolkit audit files into the canonical trail.
|
|
2
|
+
|
|
3
|
+
AGT governs agents you *build* -- Semantic Kernel, AutoGen, LangGraph, CrewAI --
|
|
4
|
+
and its `FileAuditSink` writes hash-chained, HMAC-signed JSONL. Agentmetry hooks
|
|
5
|
+
agents you *use*. Reading their file closes the gap between the two without
|
|
6
|
+
either project writing a framework integration, and it gives AGT-governed
|
|
7
|
+
activity the thing AGT does not do: sequence detection across a session, with
|
|
8
|
+
MITRE tagging, so a credential read and a later egress become one finding.
|
|
9
|
+
|
|
10
|
+
No dependency on their package. The integrity algorithm is reproduced here from
|
|
11
|
+
their `SignedAuditEntry`, and a round-trip against a file produced by the real
|
|
12
|
+
`FileAuditSink` 5.0.0 confirms it byte for byte. Depending on
|
|
13
|
+
`agent-governance-toolkit-core` would pull 65 transitive requirements into a
|
|
14
|
+
tool whose whole pitch is that it runs locally without them.
|
|
15
|
+
|
|
16
|
+
## What their file actually looks like
|
|
17
|
+
|
|
18
|
+
Their docs describe two different objects and it is easy to read one for the
|
|
19
|
+
other. `AuditEntry` is the in-memory record and carries `entry_hash`;
|
|
20
|
+
`SignedAuditEntry` is what `FileAuditSink` writes, and it carries
|
|
21
|
+
`content_hash`, `previous_hash` and `signature` instead. There is no
|
|
22
|
+
`entry_hash` on disk.
|
|
23
|
+
|
|
24
|
+
What no document states, and what an external verifier actually needs, had to
|
|
25
|
+
come from reading `SignedAuditEntry._canonical_payload`:
|
|
26
|
+
|
|
27
|
+
- The content hash covers exactly fourteen fields, serialised with
|
|
28
|
+
`sort_keys=True` and `default=str`. `sandbox_id`, `environment` and
|
|
29
|
+
`compute_driver` are deliberately excluded so they can be added without
|
|
30
|
+
invalidating existing chains, and including them breaks every hash.
|
|
31
|
+
- `previous_hash` links to the previous entry's **`content_hash`** -- not its
|
|
32
|
+
signature, which is the plausible wrong guess.
|
|
33
|
+
- `signature` is HMAC-SHA256 over the **hex string** of the content hash, not
|
|
34
|
+
over its raw bytes.
|
|
35
|
+
|
|
36
|
+
Also minor: `entry_id` is documented as a UUID and is in fact
|
|
37
|
+
`audit_<first 16 hex of a uuid4>`, so it will not parse as one.
|
|
38
|
+
|
|
39
|
+
All of this was established by generating a real file and reproducing its
|
|
40
|
+
hashes, not by reading the reference.
|
|
41
|
+
|
|
42
|
+
## Custody
|
|
43
|
+
|
|
44
|
+
Ingested events are marked `source.tier = "external"` and `source.app = "agt"`,
|
|
45
|
+
and they carry `provenance.captured_by = "agent-governance-toolkit"`. This is
|
|
46
|
+
not decoration. Agentmetry did not observe these tool calls; it read someone
|
|
47
|
+
else's record of them, and a trail that cannot tell the difference is making a
|
|
48
|
+
claim it has no basis for. AGT's own maintainers concede the same gap in
|
|
49
|
+
discussion #276: a sealed record does not prove faithful capture. Keeping the
|
|
50
|
+
distinction visible is the least we can do about it here.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
from __future__ import annotations
|
|
54
|
+
|
|
55
|
+
import hashlib
|
|
56
|
+
import hmac
|
|
57
|
+
import json
|
|
58
|
+
import uuid
|
|
59
|
+
from dataclasses import dataclass, field
|
|
60
|
+
from pathlib import Path
|
|
61
|
+
from typing import Any
|
|
62
|
+
|
|
63
|
+
#: Fields covered by AGT's content hash, in their order-independent canonical
|
|
64
|
+
#: payload. `sandbox_id`, `environment` and `compute_driver` are deliberately
|
|
65
|
+
#: excluded by AGT so they can be added without invalidating existing chains;
|
|
66
|
+
#: excluding them here too is what makes the hashes reproduce.
|
|
67
|
+
_HASHED_FIELDS = (
|
|
68
|
+
"entry_id",
|
|
69
|
+
"timestamp",
|
|
70
|
+
"event_type",
|
|
71
|
+
"agent_did",
|
|
72
|
+
"action",
|
|
73
|
+
"resource",
|
|
74
|
+
"target_did",
|
|
75
|
+
"data",
|
|
76
|
+
"outcome",
|
|
77
|
+
"policy_decision",
|
|
78
|
+
"matched_rule",
|
|
79
|
+
"trace_id",
|
|
80
|
+
"session_id",
|
|
81
|
+
"previous_hash",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
#: AGT event_type -> Agentmetry action.type.
|
|
85
|
+
_ACTION_TYPE = {
|
|
86
|
+
"tool_invocation": "tool_called",
|
|
87
|
+
"tool_blocked": "tool_denied",
|
|
88
|
+
"policy_evaluation": "tool_called",
|
|
89
|
+
"policy_violation": "tool_denied",
|
|
90
|
+
"rogue_detection": "detection",
|
|
91
|
+
"agent_invocation": "session_start",
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#: AGT policy verdict -> Agentmetry action.outcome, when AGT's own `outcome`
|
|
95
|
+
#: is missing. AGT carries both a verdict (`action`) and a result (`outcome`);
|
|
96
|
+
#: Agentmetry has one field, and the result is the more factual of the two.
|
|
97
|
+
_OUTCOME = {
|
|
98
|
+
"allow": "success",
|
|
99
|
+
"deny": "denied",
|
|
100
|
+
"quarantine": "denied",
|
|
101
|
+
"warning": "success",
|
|
102
|
+
"audit": "success",
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def content_hash(entry: dict[str, Any]) -> str:
|
|
107
|
+
"""Recompute AGT's SHA-256 content hash for one entry."""
|
|
108
|
+
payload = {key: entry.get(key) for key in _HASHED_FIELDS}
|
|
109
|
+
return hashlib.sha256(
|
|
110
|
+
json.dumps(payload, sort_keys=True, default=str).encode()
|
|
111
|
+
).hexdigest()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def signature(content_hash_hex: str, secret_key: bytes) -> str:
|
|
115
|
+
"""HMAC-SHA256 over the *hex string* of the content hash, as AGT does it."""
|
|
116
|
+
return hmac.new(secret_key, content_hash_hex.encode(), hashlib.sha256).hexdigest()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class AgtVerifyResult:
|
|
121
|
+
ok: bool
|
|
122
|
+
message: str
|
|
123
|
+
entries: int = 0
|
|
124
|
+
hash_failures: list[int] = field(default_factory=list)
|
|
125
|
+
chain_failures: list[int] = field(default_factory=list)
|
|
126
|
+
signature_failures: list[int] = field(default_factory=list)
|
|
127
|
+
signatures_checked: bool = False
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def read_agt_file(path: Path) -> list[dict[str, Any]]:
|
|
131
|
+
rows: list[dict[str, Any]] = []
|
|
132
|
+
with Path(path).open("r", encoding="utf-8", errors="replace") as fh:
|
|
133
|
+
for line in fh:
|
|
134
|
+
line = line.strip()
|
|
135
|
+
if not line:
|
|
136
|
+
continue
|
|
137
|
+
try:
|
|
138
|
+
record = json.loads(line)
|
|
139
|
+
except json.JSONDecodeError:
|
|
140
|
+
continue
|
|
141
|
+
if isinstance(record, dict) and "content_hash" in record:
|
|
142
|
+
rows.append(record)
|
|
143
|
+
return rows
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def verify_agt_chain(
|
|
147
|
+
rows: list[dict[str, Any]], secret_key: bytes | None = None
|
|
148
|
+
) -> AgtVerifyResult:
|
|
149
|
+
"""Check content hashes, chain linkage and (with a key) HMAC signatures.
|
|
150
|
+
|
|
151
|
+
Verified before ingest, never after. Importing someone else's audit record
|
|
152
|
+
into a hash-chained trail without checking it first would launder an
|
|
153
|
+
unverified claim into a chain that then vouches for it, and the chain would
|
|
154
|
+
be telling the truth about a lie.
|
|
155
|
+
|
|
156
|
+
The signature is optional because the key is optional. Without it the
|
|
157
|
+
content hashes and the linkage are still checkable, which catches editing
|
|
158
|
+
and reordering; only forgery by someone holding the key is out of reach,
|
|
159
|
+
and that is out of reach for AGT too.
|
|
160
|
+
"""
|
|
161
|
+
if not rows:
|
|
162
|
+
return AgtVerifyResult(ok=True, message="no AGT entries found", entries=0)
|
|
163
|
+
|
|
164
|
+
hash_failures: list[int] = []
|
|
165
|
+
chain_failures: list[int] = []
|
|
166
|
+
signature_failures: list[int] = []
|
|
167
|
+
previous = ""
|
|
168
|
+
|
|
169
|
+
for index, row in enumerate(rows):
|
|
170
|
+
if content_hash(row) != str(row.get("content_hash") or ""):
|
|
171
|
+
hash_failures.append(index)
|
|
172
|
+
if str(row.get("previous_hash") or "") != previous:
|
|
173
|
+
chain_failures.append(index)
|
|
174
|
+
if secret_key is not None:
|
|
175
|
+
expected = signature(str(row.get("content_hash") or ""), secret_key)
|
|
176
|
+
if not hmac.compare_digest(expected, str(row.get("signature") or "")):
|
|
177
|
+
signature_failures.append(index)
|
|
178
|
+
previous = str(row.get("content_hash") or "")
|
|
179
|
+
|
|
180
|
+
problems = []
|
|
181
|
+
if hash_failures:
|
|
182
|
+
problems.append(f"{len(hash_failures)} content hash mismatch(es)")
|
|
183
|
+
if chain_failures:
|
|
184
|
+
problems.append(f"{len(chain_failures)} broken chain link(s)")
|
|
185
|
+
if signature_failures:
|
|
186
|
+
problems.append(f"{len(signature_failures)} bad signature(s)")
|
|
187
|
+
|
|
188
|
+
if problems:
|
|
189
|
+
first = min(
|
|
190
|
+
[i for group in (hash_failures, chain_failures, signature_failures) for i in group]
|
|
191
|
+
)
|
|
192
|
+
return AgtVerifyResult(
|
|
193
|
+
ok=False,
|
|
194
|
+
message=f"{', '.join(problems)}; first bad entry at index {first}",
|
|
195
|
+
entries=len(rows),
|
|
196
|
+
hash_failures=hash_failures,
|
|
197
|
+
chain_failures=chain_failures,
|
|
198
|
+
signature_failures=signature_failures,
|
|
199
|
+
signatures_checked=secret_key is not None,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
checked = "hashes, chain and signatures" if secret_key is not None else "hashes and chain"
|
|
203
|
+
return AgtVerifyResult(
|
|
204
|
+
ok=True,
|
|
205
|
+
message=f"{len(rows)} AGT entries verified ({checked})",
|
|
206
|
+
entries=len(rows),
|
|
207
|
+
signatures_checked=secret_key is not None,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def agt_to_canonical(
|
|
212
|
+
entry: dict[str, Any], *, host_id: str = "", fleet_id: str = ""
|
|
213
|
+
) -> dict[str, Any]:
|
|
214
|
+
"""Map one AGT entry onto an Agentmetry canonical event.
|
|
215
|
+
|
|
216
|
+
MITRE tagging and traits are computed here rather than carried across,
|
|
217
|
+
because AGT does not produce them and they are what lets the sequence rules
|
|
218
|
+
see this activity at all. That is the point of the adapter: AGT decides
|
|
219
|
+
allow or deny per call, and Agentmetry says what a session of those calls
|
|
220
|
+
adds up to.
|
|
221
|
+
"""
|
|
222
|
+
from agentmetry.core.audit.detection.traits import classify_command
|
|
223
|
+
from agentmetry.core.audit.mitre import get_mitre_mapping
|
|
224
|
+
|
|
225
|
+
data = entry.get("data") if isinstance(entry.get("data"), dict) else {}
|
|
226
|
+
resource = str(entry.get("resource") or "")
|
|
227
|
+
command = str(data.get("command") or "")
|
|
228
|
+
event_type = str(entry.get("event_type") or "")
|
|
229
|
+
verdict = str(entry.get("action") or "")
|
|
230
|
+
outcome = str(entry.get("outcome") or "") or _OUTCOME.get(verdict, "success")
|
|
231
|
+
|
|
232
|
+
reason_parts = [p for p in (entry.get("policy_decision"), entry.get("matched_rule")) if p]
|
|
233
|
+
|
|
234
|
+
tool: dict[str, Any] = {
|
|
235
|
+
"name": resource.rsplit(".", 1)[-1] if resource else "",
|
|
236
|
+
"qualified": resource,
|
|
237
|
+
"server": resource.split(".", 1)[0] if "." in resource else "agt",
|
|
238
|
+
"arguments": data,
|
|
239
|
+
"parameters_redacted": False,
|
|
240
|
+
}
|
|
241
|
+
if command:
|
|
242
|
+
tool["command"] = command
|
|
243
|
+
tool["input_hash"] = hashlib.sha256(command.encode()).hexdigest()
|
|
244
|
+
tool["input_redaction"] = "hash+command"
|
|
245
|
+
traits = classify_command(command)
|
|
246
|
+
if traits:
|
|
247
|
+
tool["traits"] = traits
|
|
248
|
+
mapping = get_mitre_mapping(resource or "run", data or command)
|
|
249
|
+
if mapping:
|
|
250
|
+
tool["mitre"] = mapping
|
|
251
|
+
|
|
252
|
+
agent_did = str(entry.get("agent_did") or "")
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
"schema_version": "1.1.0",
|
|
256
|
+
# AGT's entry_id is `audit_<16 hex>`, not a UUID, so it cannot be used
|
|
257
|
+
# as an event_id directly. Derived deterministically so re-importing the
|
|
258
|
+
# same file does not mint new identities for the same events.
|
|
259
|
+
"event_id": str(uuid.uuid5(uuid.NAMESPACE_URL, f"agt:{entry.get('entry_id')}")),
|
|
260
|
+
"correlation_id": str(entry.get("trace_id") or entry.get("session_id") or ""),
|
|
261
|
+
"session_id": str(entry.get("session_id") or ""),
|
|
262
|
+
"timestamp_utc": str(entry.get("timestamp") or ""),
|
|
263
|
+
"host_id": host_id,
|
|
264
|
+
"fleet_id": fleet_id,
|
|
265
|
+
"source_topic": f"external/agt/{_ACTION_TYPE.get(event_type, event_type or 'tool_called')}",
|
|
266
|
+
"source": {"tier": "external", "app": "agt", "adapter": "agt_file"},
|
|
267
|
+
"actor": {"type": "agent", "id": agent_did, "role": "agent"},
|
|
268
|
+
"initiator": {"actor_type": "agent", "trigger": "agt", "operator_id": agent_did},
|
|
269
|
+
"action": {
|
|
270
|
+
"type": _ACTION_TYPE.get(event_type, "tool_called"),
|
|
271
|
+
"outcome": outcome,
|
|
272
|
+
"reason": "; ".join(str(p) for p in reason_parts),
|
|
273
|
+
},
|
|
274
|
+
"agent": {"name": agent_did or "agt", "skill_id": ""},
|
|
275
|
+
"model": {"id": "", "provider": ""},
|
|
276
|
+
"tool": tool,
|
|
277
|
+
# Custody. Agentmetry did not observe this call; it read AGT's record of
|
|
278
|
+
# it. A trail that cannot say so is asserting more than it knows.
|
|
279
|
+
"provenance": {
|
|
280
|
+
"captured_by": "agent-governance-toolkit",
|
|
281
|
+
"agt_entry_id": entry.get("entry_id"),
|
|
282
|
+
"agt_content_hash": entry.get("content_hash"),
|
|
283
|
+
"agt_previous_hash": entry.get("previous_hash"),
|
|
284
|
+
"agt_event_type": event_type,
|
|
285
|
+
"agt_verdict": verdict,
|
|
286
|
+
"verified_on_import": True,
|
|
287
|
+
},
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def agt_file_to_canonical(
|
|
292
|
+
path: Path,
|
|
293
|
+
*,
|
|
294
|
+
secret_key: bytes | None = None,
|
|
295
|
+
host_id: str = "",
|
|
296
|
+
fleet_id: str = "",
|
|
297
|
+
) -> tuple[AgtVerifyResult, list[dict[str, Any]]]:
|
|
298
|
+
"""Verify an AGT file, then map it. Returns no events if verification fails."""
|
|
299
|
+
rows = read_agt_file(path)
|
|
300
|
+
result = verify_agt_chain(rows, secret_key)
|
|
301
|
+
if not result.ok:
|
|
302
|
+
return result, []
|
|
303
|
+
events = [agt_to_canonical(r, host_id=host_id, fleet_id=fleet_id) for r in rows]
|
|
304
|
+
return result, events
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Map Agentmetry canonical events to CloudEvents v1.0 JSON envelopes.
|
|
2
|
+
|
|
3
|
+
CloudEvents is a CNCF spec for describing an event's *envelope* -- who emitted
|
|
4
|
+
it, what kind it is, when -- while leaving the payload alone. That is a good
|
|
5
|
+
fit here: the canonical event is already the thing worth keeping, and this adds
|
|
6
|
+
a routing header rather than a second schema to maintain.
|
|
7
|
+
|
|
8
|
+
Why bother when ECS and HEC adapters already exist. Those two speak to one
|
|
9
|
+
product each. CloudEvents is what a broker speaks: Knative, EventBridge, Azure
|
|
10
|
+
Event Grid, Dapr, NATS and Kafka bindings all consume it, so one adapter
|
|
11
|
+
reaches every consumer that is not a SIEM. It is also what Microsoft's Agent
|
|
12
|
+
Governance Toolkit emits, which makes a shared bus between the two possible
|
|
13
|
+
without either side writing a translator.
|
|
14
|
+
|
|
15
|
+
Two decisions worth stating.
|
|
16
|
+
|
|
17
|
+
**The canonical event travels whole, in `data`.** Flattening it into CloudEvents
|
|
18
|
+
attributes would mean maintaining a second field list that drifts, and it would
|
|
19
|
+
lose the parts that make the record worth having: the hash chain fields, the
|
|
20
|
+
MITRE tagging, the traits. Extension attributes carry only what a *router*
|
|
21
|
+
needs to make a decision without opening the payload.
|
|
22
|
+
|
|
23
|
+
**Extension attribute names are lowercase alphanumeric.** CloudEvents 1.0 §3.1
|
|
24
|
+
restricts them to `[a-z0-9]`, so no underscores and no dots. `agentmetryseq`
|
|
25
|
+
looks wrong and is correct; a consumer that rejects `agentmetry_seq` is right
|
|
26
|
+
to. This is the kind of rule that is easy to miss by copying an example, and
|
|
27
|
+
the reference implementation this was cross-checked against gets it right too
|
|
28
|
+
(`agentmeshentryhash`).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from datetime import datetime, timezone
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
CE_SPECVERSION = "1.0"
|
|
37
|
+
|
|
38
|
+
#: Reverse-DNS event types, per CloudEvents §3.1.1. Stable strings: a consumer
|
|
39
|
+
#: filters on these, so renaming one is a breaking change for a subscriber the
|
|
40
|
+
#: same way renaming a trait is for a stored event.
|
|
41
|
+
_TYPE_PREFIX = "ai.agentmetry"
|
|
42
|
+
|
|
43
|
+
_TYPE_MAP = {
|
|
44
|
+
"tool_called": f"{_TYPE_PREFIX}.tool.called",
|
|
45
|
+
"tool_denied": f"{_TYPE_PREFIX}.tool.denied",
|
|
46
|
+
"tool_failed": f"{_TYPE_PREFIX}.tool.failed",
|
|
47
|
+
"detection": f"{_TYPE_PREFIX}.detection.raised",
|
|
48
|
+
"detection_disposition": f"{_TYPE_PREFIX}.detection.dispositioned",
|
|
49
|
+
"approval_request": f"{_TYPE_PREFIX}.approval.requested",
|
|
50
|
+
"approval_response": f"{_TYPE_PREFIX}.approval.responded",
|
|
51
|
+
"session_start": f"{_TYPE_PREFIX}.session.started",
|
|
52
|
+
"session_end": f"{_TYPE_PREFIX}.session.ended",
|
|
53
|
+
"config_change": f"{_TYPE_PREFIX}.config.changed",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def cloudevent_type(action_type: str) -> str:
|
|
58
|
+
"""Reverse-DNS type for an action, falling back rather than dropping.
|
|
59
|
+
|
|
60
|
+
An unmapped action still gets a well-formed type. Returning None or raising
|
|
61
|
+
would mean a new event type silently stops being forwarded, which is the
|
|
62
|
+
failure mode this project keeps finding in its own code.
|
|
63
|
+
"""
|
|
64
|
+
action_type = (action_type or "unknown").strip() or "unknown"
|
|
65
|
+
return _TYPE_MAP.get(action_type, f"{_TYPE_PREFIX}.{action_type.replace('_', '.')}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _rfc3339(ts: str) -> str:
|
|
69
|
+
"""CloudEvents `time` must be RFC 3339. Canonical timestamps already are,
|
|
70
|
+
modulo the `+00:00` spelling of UTC, which is legal but reads oddly next to
|
|
71
|
+
every other CloudEvents producer."""
|
|
72
|
+
if not ts:
|
|
73
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
74
|
+
return ts.replace("+00:00", "Z") if ts.endswith("+00:00") else ts
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _source(canonical: dict[str, Any]) -> str:
|
|
78
|
+
"""A URI-reference identifying the producer (CloudEvents §3.1.1).
|
|
79
|
+
|
|
80
|
+
Host plus the app being recorded, because "which machine and which agent"
|
|
81
|
+
is the question a subscriber routes on. Falls back to the bare scheme
|
|
82
|
+
rather than an empty string: `source` is REQUIRED and non-empty, and an
|
|
83
|
+
envelope that violates that gets rejected by a strict consumer.
|
|
84
|
+
"""
|
|
85
|
+
host = str(canonical.get("host_id") or "").strip() or "unknown-host"
|
|
86
|
+
source = canonical.get("source") or {}
|
|
87
|
+
app = str(source.get("app") or "").strip()
|
|
88
|
+
return f"/agentmetry/{host}/{app}" if app else f"/agentmetry/{host}"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def canonical_to_cloudevent(canonical: dict[str, Any]) -> dict[str, Any]:
|
|
92
|
+
"""Wrap one canonical event in a CloudEvents v1.0 structured JSON envelope."""
|
|
93
|
+
action = canonical.get("action") or {}
|
|
94
|
+
tool = canonical.get("tool") or {}
|
|
95
|
+
detection = canonical.get("detection") or {}
|
|
96
|
+
|
|
97
|
+
action_type = str(action.get("type") or "")
|
|
98
|
+
|
|
99
|
+
envelope: dict[str, Any] = {
|
|
100
|
+
"specversion": CE_SPECVERSION,
|
|
101
|
+
"id": str(canonical.get("event_id") or ""),
|
|
102
|
+
"source": _source(canonical),
|
|
103
|
+
"type": cloudevent_type(action_type),
|
|
104
|
+
"time": _rfc3339(str(canonical.get("timestamp_utc") or "")),
|
|
105
|
+
"datacontenttype": "application/json",
|
|
106
|
+
"dataschema": f"https://agentmetry.ai/schemas/event/{canonical.get('schema_version') or '1.1.0'}",
|
|
107
|
+
# The canonical record, unflattened. It is the artifact worth keeping.
|
|
108
|
+
"data": canonical,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
# `subject` is what the event is *about*, and it is what a human reads in a
|
|
112
|
+
# broker console. A detection is about its rule; a tool call is about its
|
|
113
|
+
# tool. Omitted rather than blank when neither applies: CloudEvents forbids
|
|
114
|
+
# empty-string attributes.
|
|
115
|
+
subject = str(detection.get("rule_id") or tool.get("qualified") or tool.get("name") or "")
|
|
116
|
+
if subject:
|
|
117
|
+
envelope["subject"] = subject
|
|
118
|
+
|
|
119
|
+
# Extension attributes: only what a router needs in order to filter without
|
|
120
|
+
# parsing `data`.
|
|
121
|
+
#
|
|
122
|
+
# Names are lowercase alphanumeric and at most 20 characters, both from the
|
|
123
|
+
# CloudEvents 1.0 attribute-naming rules. The length limit is a SHOULD and
|
|
124
|
+
# easy to miss, since it appears in the spec prose and in none of the
|
|
125
|
+
# examples: the obvious `agentmetrycorrelationid` is 23 and had to be cut to
|
|
126
|
+
# `agentmetrycorrid`. Some brokers enforce it, and a rejected envelope is a
|
|
127
|
+
# dropped event.
|
|
128
|
+
extensions = {
|
|
129
|
+
"agentmetrycorrid": canonical.get("correlation_id"),
|
|
130
|
+
"agentmetrysession": canonical.get("session_id"),
|
|
131
|
+
"agentmetryfleet": canonical.get("fleet_id"),
|
|
132
|
+
"agentmetryoutcome": action.get("outcome"),
|
|
133
|
+
"agentmetryseverity": detection.get("severity"),
|
|
134
|
+
"agentmetryrule": detection.get("rule_id"),
|
|
135
|
+
}
|
|
136
|
+
for key, value in extensions.items():
|
|
137
|
+
text = str(value or "").strip()
|
|
138
|
+
if text:
|
|
139
|
+
envelope[key] = text
|
|
140
|
+
|
|
141
|
+
return envelope
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def canonical_to_cloudevents(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
145
|
+
return [canonical_to_cloudevent(event) for event in events]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
#: CloudEvents §3.1: extension attribute names are lowercase alphanumeric.
|
|
149
|
+
#: Exposed so a test can assert it over the envelopes rather than restating it.
|
|
150
|
+
_RESERVED = frozenset(
|
|
151
|
+
{
|
|
152
|
+
"specversion", "id", "source", "type", "time",
|
|
153
|
+
"datacontenttype", "dataschema", "subject", "data",
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def extension_attributes(envelope: dict[str, Any]) -> dict[str, Any]:
|
|
159
|
+
return {k: v for k, v in envelope.items() if k not in _RESERVED}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Map Agentmetry canonical events to Elastic Common Schema (ECS) documents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _parse_timestamp(ts: str) -> str:
|
|
10
|
+
if not ts:
|
|
11
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
12
|
+
return ts.replace("+00:00", "Z") if ts.endswith("+00:00") else ts
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _event_category(action_type: str) -> list[str]:
|
|
16
|
+
"""Map a canonical action type onto ECS `event.category`.
|
|
17
|
+
|
|
18
|
+
ECS categories drive Elastic's prebuilt dashboards and detection rules, so a
|
|
19
|
+
correlated finding filed under `process` disappears among the tool calls it
|
|
20
|
+
was raised about. `intrusion_detection` is the category Elastic reserves for
|
|
21
|
+
exactly this: an alert produced by a rule rather than an observation.
|
|
22
|
+
|
|
23
|
+
A denied tool call is both a process event and the enforcement of a policy,
|
|
24
|
+
which is why it carries two categories. ECS is explicitly multi-valued here.
|
|
25
|
+
"""
|
|
26
|
+
if action_type == "detection":
|
|
27
|
+
return ["intrusion_detection"]
|
|
28
|
+
if action_type in ("tool_denied", "tool_failed"):
|
|
29
|
+
return ["process", "intrusion_detection"]
|
|
30
|
+
if action_type in ("tool_called", "session_start", "session_end"):
|
|
31
|
+
return ["process"]
|
|
32
|
+
if action_type == "config_change":
|
|
33
|
+
return ["configuration"]
|
|
34
|
+
if action_type.startswith("approval") or action_type == "detection_disposition":
|
|
35
|
+
return ["iam"]
|
|
36
|
+
return ["process"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def canonical_to_ecs(canonical: dict[str, Any]) -> dict[str, Any]:
|
|
40
|
+
"""Best-effort ECS 8.x field mapping; full canonical nested under agentmetry."""
|
|
41
|
+
action = canonical.get("action") or {}
|
|
42
|
+
actor = canonical.get("actor") or {}
|
|
43
|
+
tool = canonical.get("tool") or {}
|
|
44
|
+
model = canonical.get("model") or {}
|
|
45
|
+
agent = canonical.get("agent") or {}
|
|
46
|
+
|
|
47
|
+
action_type = str(action.get("type") or "")
|
|
48
|
+
outcome = str(action.get("outcome") or "")
|
|
49
|
+
|
|
50
|
+
doc: dict[str, Any] = {
|
|
51
|
+
"@timestamp": _parse_timestamp(str(canonical.get("timestamp_utc") or "")),
|
|
52
|
+
"event": {
|
|
53
|
+
"id": canonical.get("event_id"),
|
|
54
|
+
"kind": "event",
|
|
55
|
+
"category": _event_category(action_type),
|
|
56
|
+
"type": ["info"],
|
|
57
|
+
"action": action_type,
|
|
58
|
+
"outcome": outcome,
|
|
59
|
+
"reason": action.get("reason") or "",
|
|
60
|
+
"sequence": canonical.get("seq"),
|
|
61
|
+
},
|
|
62
|
+
"host": {"name": canonical.get("host_id")},
|
|
63
|
+
"user": {
|
|
64
|
+
"id": actor.get("id"),
|
|
65
|
+
"roles": [actor.get("role")] if actor.get("role") else [],
|
|
66
|
+
},
|
|
67
|
+
"trace": {"id": canonical.get("correlation_id")},
|
|
68
|
+
"session": {"id": canonical.get("session_id")},
|
|
69
|
+
"observer": {
|
|
70
|
+
"type": "agentmetry",
|
|
71
|
+
"vendor": "agentmetry",
|
|
72
|
+
"product": "Agentmetry",
|
|
73
|
+
},
|
|
74
|
+
"agent": {
|
|
75
|
+
"name": agent.get("name"),
|
|
76
|
+
"version": canonical.get("schema_version"),
|
|
77
|
+
},
|
|
78
|
+
"agentmetry": canonical,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if tool:
|
|
82
|
+
doc["tool"] = {
|
|
83
|
+
"name": tool.get("name"),
|
|
84
|
+
"type": tool.get("qualified"),
|
|
85
|
+
}
|
|
86
|
+
doc["service"] = {"name": tool.get("server")}
|
|
87
|
+
|
|
88
|
+
if model.get("id"):
|
|
89
|
+
doc["gen_ai"] = {
|
|
90
|
+
"request": {
|
|
91
|
+
"model": model.get("id"),
|
|
92
|
+
},
|
|
93
|
+
"system": model.get("provider"),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if outcome == "denied":
|
|
97
|
+
doc["event"]["type"] = ["denied"]
|
|
98
|
+
|
|
99
|
+
fleet_id = str(canonical.get("fleet_id") or "").strip()
|
|
100
|
+
if fleet_id:
|
|
101
|
+
doc["organization"] = {"id": fleet_id}
|
|
102
|
+
|
|
103
|
+
return doc
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Splunk HEC envelope for Agentmetry canonical events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _epoch_seconds(ts: str) -> float:
|
|
10
|
+
if not ts:
|
|
11
|
+
return datetime.now(timezone.utc).timestamp()
|
|
12
|
+
normalized = ts.replace("Z", "+00:00")
|
|
13
|
+
try:
|
|
14
|
+
return datetime.fromisoformat(normalized).timestamp()
|
|
15
|
+
except ValueError:
|
|
16
|
+
return datetime.now(timezone.utc).timestamp()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def canonical_to_hec_event(
|
|
20
|
+
canonical: dict[str, Any],
|
|
21
|
+
*,
|
|
22
|
+
index: str,
|
|
23
|
+
sourcetype: str,
|
|
24
|
+
source: str = "Agentmetry",
|
|
25
|
+
) -> dict[str, Any]:
|
|
26
|
+
action = canonical.get("action") or {}
|
|
27
|
+
return {
|
|
28
|
+
"time": _epoch_seconds(str(canonical.get("timestamp_utc") or "")),
|
|
29
|
+
"host": canonical.get("host_id"),
|
|
30
|
+
"source": source,
|
|
31
|
+
"sourcetype": sourcetype,
|
|
32
|
+
"index": index,
|
|
33
|
+
"fields": {
|
|
34
|
+
"action_type": action.get("type"),
|
|
35
|
+
"action_outcome": action.get("outcome"),
|
|
36
|
+
"correlation_id": canonical.get("correlation_id"),
|
|
37
|
+
"actor_id": (canonical.get("actor") or {}).get("id"),
|
|
38
|
+
},
|
|
39
|
+
"event": canonical,
|
|
40
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Alerting engine for high-severity audit events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AlertWebhookSink:
|
|
14
|
+
"""Fires webhooks (Slack/Discord) for high-severity events."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, url: str, *, timeout_seconds: float = 5.0) -> None:
|
|
17
|
+
self._url = url
|
|
18
|
+
self._timeout = timeout_seconds
|
|
19
|
+
|
|
20
|
+
async def emit(self, canonical: dict[str, Any]) -> None:
|
|
21
|
+
action = canonical.get("action", {})
|
|
22
|
+
outcome = action.get("outcome")
|
|
23
|
+
|
|
24
|
+
# Only fire on denied or error
|
|
25
|
+
if outcome not in ("denied", "error"):
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
tool = canonical.get("tool", {})
|
|
29
|
+
tool_name = tool.get("qualified", "unknown_tool")
|
|
30
|
+
agent_name = canonical.get("agent", {}).get("skill_id") or canonical.get("source", {}).get("app", "unknown_agent")
|
|
31
|
+
|
|
32
|
+
text = f"🚨 *Agentmetry Alert*\nAgent `{agent_name}` attempted to run `{tool_name}` but the action resulted in `{outcome}`."
|
|
33
|
+
|
|
34
|
+
payload = {
|
|
35
|
+
"text": text,
|
|
36
|
+
"blocks": [
|
|
37
|
+
{
|
|
38
|
+
"type": "section",
|
|
39
|
+
"text": {
|
|
40
|
+
"type": "mrkdwn",
|
|
41
|
+
"text": text
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
|
49
|
+
response = await client.post(
|
|
50
|
+
self._url,
|
|
51
|
+
json=payload,
|
|
52
|
+
headers={"Content-Type": "application/json", "User-Agent": "Agentmetry-Alerts/1.0"},
|
|
53
|
+
)
|
|
54
|
+
response.raise_for_status()
|
|
55
|
+
except Exception:
|
|
56
|
+
logger.exception("Alert webhook POST failed → %s", self._url)
|