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
agentmetry/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Agentmetry: a local-first flight recorder for AI coding agents.
|
|
2
|
+
|
|
3
|
+
Everything lives under this one top-level package on purpose. The orchestrator
|
|
4
|
+
used to ship `core`, `api` and `cli` as top-level names, which is harmless for an
|
|
5
|
+
editable install inside this repo and antisocial once published: three of the
|
|
6
|
+
most generic importable names in Python, dropped into someone else's
|
|
7
|
+
site-packages, where they collide with whatever else claims them.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from agentmetry.core.version import __version__
|
|
11
|
+
|
|
12
|
+
__all__ = ["__version__"]
|
|
File without changes
|
agentmetry/api/main.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from logging.handlers import RotatingFileHandler
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from fastapi import FastAPI, Query, WebSocket, WebSocketDisconnect
|
|
10
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
11
|
+
|
|
12
|
+
from agentmetry.api.routes.audit import router as audit_router
|
|
13
|
+
from agentmetry.api.websocket import ws_manager
|
|
14
|
+
from agentmetry.api.ws_bridge import ws_event_bridge
|
|
15
|
+
from agentmetry.core.auth import verify_ws_token
|
|
16
|
+
from agentmetry.core.bus.audit_exporter import audit_exporter
|
|
17
|
+
from agentmetry.core.bus.bridges import outbox_persister
|
|
18
|
+
from agentmetry.core.bus.bus import bus
|
|
19
|
+
from agentmetry.core.bus.outbox import get_outbox
|
|
20
|
+
from agentmetry.core.config import settings
|
|
21
|
+
from agentmetry.core.extensions import load_extensions
|
|
22
|
+
from agentmetry.core.health import get_system_health
|
|
23
|
+
from agentmetry.core.version import __version__
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
_LOG_DIR = Path(__file__).resolve().parents[2] / "data" / "logs"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _setup_logging() -> None:
|
|
31
|
+
"""Persist app logs to disk so they survive a closed terminal."""
|
|
32
|
+
root = logging.getLogger()
|
|
33
|
+
if any(isinstance(h, RotatingFileHandler) for h in root.handlers):
|
|
34
|
+
return
|
|
35
|
+
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
formatter = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
|
|
37
|
+
|
|
38
|
+
file_handler = RotatingFileHandler(
|
|
39
|
+
_LOG_DIR / "orchestrator.log",
|
|
40
|
+
maxBytes=5_000_000,
|
|
41
|
+
backupCount=3,
|
|
42
|
+
encoding="utf-8",
|
|
43
|
+
)
|
|
44
|
+
file_handler.setFormatter(formatter)
|
|
45
|
+
root.addHandler(file_handler)
|
|
46
|
+
|
|
47
|
+
console = logging.StreamHandler()
|
|
48
|
+
console.setFormatter(formatter)
|
|
49
|
+
root.addHandler(console)
|
|
50
|
+
|
|
51
|
+
if root.level in (logging.NOTSET, logging.WARNING):
|
|
52
|
+
root.setLevel(logging.INFO)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_setup_logging()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@asynccontextmanager
|
|
61
|
+
async def lifespan(app: FastAPI):
|
|
62
|
+
from agentmetry.core.audit.migrate import backfill_db_from_jsonl
|
|
63
|
+
|
|
64
|
+
# Backfill SQLite from the existing JSONL trail. A broken or locked audit DB
|
|
65
|
+
# must degrade the dashboard, never stop the daemon from booting: the
|
|
66
|
+
# recorder's job is to keep recording.
|
|
67
|
+
try:
|
|
68
|
+
await asyncio.to_thread(backfill_db_from_jsonl)
|
|
69
|
+
except Exception:
|
|
70
|
+
logger.exception("Audit trail backfill failed; continuing without it")
|
|
71
|
+
|
|
72
|
+
# Replay events the hooks captured while this process was down. Same
|
|
73
|
+
# non-fatal contract as the backfill: a broken spool must not stop the
|
|
74
|
+
# recorder from booting.
|
|
75
|
+
#
|
|
76
|
+
# Deliberately not awaited. Draining a few thousand spooled events takes
|
|
77
|
+
# minutes, and awaiting it here holds the ingest port closed for the whole
|
|
78
|
+
# time — so every hook that fires during the drain is refused and spooled,
|
|
79
|
+
# which makes the next drain larger. The recorder has to be reachable first
|
|
80
|
+
# and catch up second.
|
|
81
|
+
from agentmetry.core.audit.spool import drain_forever, drain_spool
|
|
82
|
+
|
|
83
|
+
async def _boot_drain() -> None:
|
|
84
|
+
try:
|
|
85
|
+
await drain_spool()
|
|
86
|
+
except Exception:
|
|
87
|
+
logger.exception("Hook spool drain failed; continuing without it")
|
|
88
|
+
|
|
89
|
+
# Bring the triage index back in step with the trail. `reconcile_at_boot`
|
|
90
|
+
# declines rather than rebuilding when the trail cannot account for a
|
|
91
|
+
# decision the index already holds — a pruned or repointed trail must not
|
|
92
|
+
# silently erase the corrective-action record.
|
|
93
|
+
from agentmetry.core.audit.detection.disposition import reconcile_at_boot
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
await asyncio.to_thread(reconcile_at_boot)
|
|
97
|
+
except Exception:
|
|
98
|
+
logger.exception("Disposition reconcile failed; continuing without it")
|
|
99
|
+
|
|
100
|
+
# Event bus first: everything downstream publishes onto it.
|
|
101
|
+
bus.set_initial_seq(get_outbox().max_seq())
|
|
102
|
+
bridge_tasks = [
|
|
103
|
+
asyncio.create_task(ws_event_bridge(), name="ws-bridge"),
|
|
104
|
+
asyncio.create_task(outbox_persister(), name="outbox-persister"),
|
|
105
|
+
asyncio.create_task(audit_exporter(), name="audit-exporter"),
|
|
106
|
+
asyncio.create_task(_boot_drain(), name="spool-boot-drain"),
|
|
107
|
+
# Keep draining for as long as we run. A boot-only drain leaves a
|
|
108
|
+
# backlog growing unnoticed whenever ingest is unreachable while the
|
|
109
|
+
# process itself stays up.
|
|
110
|
+
asyncio.create_task(drain_forever(), name="spool-drain"),
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
# Drivers mount in the background: a slow npx download must not delay boot.
|
|
114
|
+
from agentmetry.core.drivers.host import get_mcp_host
|
|
115
|
+
|
|
116
|
+
mount_task = asyncio.create_task(get_mcp_host().mount_all(), name="driver-mounts")
|
|
117
|
+
|
|
118
|
+
from agentmetry.core.audit.hook_bootstrap import bootstrap_tier_b_hooks
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
hook_paths = bootstrap_tier_b_hooks()
|
|
122
|
+
if hook_paths.get("cursor"):
|
|
123
|
+
logger.info("Global Cursor hooks ready: %s", hook_paths["cursor"])
|
|
124
|
+
if hook_paths.get("claude"):
|
|
125
|
+
logger.info("Global Claude hooks ready: %s", hook_paths["claude"])
|
|
126
|
+
except Exception as exc:
|
|
127
|
+
logger.warning("Tier B hook bootstrap failed: %s", exc)
|
|
128
|
+
|
|
129
|
+
# Launch transcript watcher for Antigravity in the background
|
|
130
|
+
import subprocess
|
|
131
|
+
import sys
|
|
132
|
+
watcher_process = None
|
|
133
|
+
try:
|
|
134
|
+
watcher_path = Path(__file__).resolve().parents[4] / "scripts" / "antigravity_transcript_watcher.py"
|
|
135
|
+
if watcher_path.exists():
|
|
136
|
+
watcher_process = subprocess.Popen(
|
|
137
|
+
[sys.executable, str(watcher_path)],
|
|
138
|
+
stdout=subprocess.DEVNULL,
|
|
139
|
+
stderr=subprocess.DEVNULL,
|
|
140
|
+
)
|
|
141
|
+
logger.info("Started Antigravity transcript watcher (PID: %s)", watcher_process.pid)
|
|
142
|
+
except Exception as exc:
|
|
143
|
+
logger.warning("Failed to start Antigravity transcript watcher: %s", exc)
|
|
144
|
+
|
|
145
|
+
yield
|
|
146
|
+
mount_task.cancel()
|
|
147
|
+
await get_mcp_host().unmount_all()
|
|
148
|
+
for task in bridge_tasks:
|
|
149
|
+
task.cancel()
|
|
150
|
+
if watcher_process:
|
|
151
|
+
watcher_process.terminate()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
app = FastAPI(
|
|
155
|
+
title="Agentmetry",
|
|
156
|
+
description="Open-source SIEM flight recorder for AI agent tool-use",
|
|
157
|
+
version=__version__,
|
|
158
|
+
lifespan=lifespan,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
app.add_middleware(
|
|
162
|
+
CORSMiddleware,
|
|
163
|
+
allow_origins=[
|
|
164
|
+
"http://localhost:3000",
|
|
165
|
+
"http://127.0.0.1:3000",
|
|
166
|
+
"http://localhost:3001",
|
|
167
|
+
"http://127.0.0.1:3001",
|
|
168
|
+
"http://dashboard:3000",
|
|
169
|
+
],
|
|
170
|
+
allow_origin_regex=r"http://(localhost|127\.0\.0\.1):\d+",
|
|
171
|
+
allow_credentials=True,
|
|
172
|
+
allow_methods=["*"],
|
|
173
|
+
allow_headers=["*"],
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
app.include_router(audit_router, prefix="/api/v1")
|
|
177
|
+
|
|
178
|
+
load_extensions(app, settings=settings)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@app.get("/api/v1/health")
|
|
182
|
+
async def health():
|
|
183
|
+
return await get_system_health()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@app.websocket("/ws/{session_id}")
|
|
190
|
+
async def websocket_endpoint(
|
|
191
|
+
websocket: WebSocket,
|
|
192
|
+
session_id: str,
|
|
193
|
+
token: str | None = Query(None),
|
|
194
|
+
):
|
|
195
|
+
if not verify_ws_token(token, websocket):
|
|
196
|
+
await websocket.close(code=4401)
|
|
197
|
+
return
|
|
198
|
+
await ws_manager.connect(websocket, session_id)
|
|
199
|
+
try:
|
|
200
|
+
while True:
|
|
201
|
+
await websocket.receive_text()
|
|
202
|
+
except WebSocketDisconnect:
|
|
203
|
+
ws_manager.disconnect(websocket, session_id)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
_DASHBOARD_DIR = Path(__file__).resolve().parents[3] / "dashboard" / "out"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def mount_dashboard(target: FastAPI, directory: Path = _DASHBOARD_DIR) -> bool:
|
|
210
|
+
"""Serve the dashboard's static export when it has been built.
|
|
211
|
+
|
|
212
|
+
Single-process mode: `npm run build` in apps/dashboard emits `out/`, which
|
|
213
|
+
the orchestrator serves at the root. Mounted last so it only catches paths
|
|
214
|
+
not already claimed by the API or WebSocket. Dev uses the :3000 dev server
|
|
215
|
+
instead, so this is a no-op when the export is absent.
|
|
216
|
+
"""
|
|
217
|
+
if not directory.is_dir():
|
|
218
|
+
logger.info(
|
|
219
|
+
"Dashboard export not found at %s — run 'npm run build' in "
|
|
220
|
+
"apps/dashboard for single-process serving",
|
|
221
|
+
directory,
|
|
222
|
+
)
|
|
223
|
+
return False
|
|
224
|
+
|
|
225
|
+
from fastapi.staticfiles import StaticFiles
|
|
226
|
+
|
|
227
|
+
target.mount("/", StaticFiles(directory=str(directory), html=True), name="dashboard")
|
|
228
|
+
logger.info("Serving dashboard from %s", directory)
|
|
229
|
+
return True
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
mount_dashboard(app)
|
|
File without changes
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"""Agentmetry API — JSONL tail + external Tier B ingest."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
10
|
+
from fastapi.responses import FileResponse
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
|
|
13
|
+
from agentmetry.core.auth import require_api_key
|
|
14
|
+
from agentmetry.core.audit.detection.disposition import STATUSES, get_disposition_store
|
|
15
|
+
from agentmetry.core.audit.ingest import ingest_external_event
|
|
16
|
+
from agentmetry.core.config import settings
|
|
17
|
+
|
|
18
|
+
router = APIRouter(prefix="/audit", tags=["audit"])
|
|
19
|
+
|
|
20
|
+
_RUN_ACTION_TYPES = frozenset({
|
|
21
|
+
"session_start",
|
|
22
|
+
"session_end",
|
|
23
|
+
"tool_called",
|
|
24
|
+
"approval_request",
|
|
25
|
+
"approval_response",
|
|
26
|
+
"detection", # correlated findings — must not be filtered out of the feed
|
|
27
|
+
"detection_disposition", # and the human's answer to them
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class IngestToolBody(BaseModel):
|
|
32
|
+
qualified: str = ""
|
|
33
|
+
server: str = ""
|
|
34
|
+
arguments: dict[str, Any] | None = None
|
|
35
|
+
input_hash: str = ""
|
|
36
|
+
command: str = ""
|
|
37
|
+
# Hook-side detection features, computed from the plaintext command before
|
|
38
|
+
# it was hashed away: category labels (`traits`) and the ATT&CK mapping.
|
|
39
|
+
# Without these fields pydantic silently drops them and default-config
|
|
40
|
+
# (hashed-only) events are invisible to every command-based sequence rule.
|
|
41
|
+
traits: list[str] = Field(default_factory=list)
|
|
42
|
+
mitre: dict[str, str] | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ExternalIngestBody(BaseModel):
|
|
46
|
+
"""Adapter payload — normalized to canonical v1.1 on ingest."""
|
|
47
|
+
|
|
48
|
+
source_app: str = Field(
|
|
49
|
+
...,
|
|
50
|
+
description="cursor | claude | antigravity | codex | mcp_proxy | qwen | kimi | qoder | codebuddy | trae | crewai | opensre",
|
|
51
|
+
)
|
|
52
|
+
event_type: str = Field(
|
|
53
|
+
...,
|
|
54
|
+
description="session_start | session_end | tool_called | tool_denied | tool_failed | approval_request | approval_response",
|
|
55
|
+
)
|
|
56
|
+
correlation_id: str = ""
|
|
57
|
+
session_id: str = ""
|
|
58
|
+
outcome: str = ""
|
|
59
|
+
reason: str = ""
|
|
60
|
+
skill_id: str = ""
|
|
61
|
+
tool_qualified: str = ""
|
|
62
|
+
tool: IngestToolBody | None = None
|
|
63
|
+
input_hash: str = ""
|
|
64
|
+
model_id: str = ""
|
|
65
|
+
adapter: str = ""
|
|
66
|
+
triggered_by: str = "manual"
|
|
67
|
+
timestamp_utc: str = ""
|
|
68
|
+
gated_action: dict[str, str] | None = None
|
|
69
|
+
# DLP verdict from the hook process. It scans plaintext before hashing, so
|
|
70
|
+
# this is the only place the finding can be captured — without this field
|
|
71
|
+
# pydantic drops it and a `log`-mode match is silently lost.
|
|
72
|
+
dlp: dict[str, Any] | None = None
|
|
73
|
+
tool_policy: dict[str, Any] | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _parse_event_ts(event: dict[str, Any]) -> datetime | None:
|
|
77
|
+
ts = event.get("timestamp_utc")
|
|
78
|
+
if not isinstance(ts, str):
|
|
79
|
+
return None
|
|
80
|
+
try:
|
|
81
|
+
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
82
|
+
if dt.tzinfo is None:
|
|
83
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
84
|
+
return dt
|
|
85
|
+
except ValueError:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _parse_query_ts(value: str) -> datetime:
|
|
90
|
+
try:
|
|
91
|
+
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
92
|
+
if dt.tzinfo is None:
|
|
93
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
94
|
+
return dt
|
|
95
|
+
except ValueError as exc:
|
|
96
|
+
raise HTTPException(status_code=400, detail=f"Invalid timestamp: {value}") from exc
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# Events written before the Agentmetry rename carry the legacy first-party name.
|
|
100
|
+
# Normalize on read so an existing trail keeps rendering instead of silently
|
|
101
|
+
# vanishing behind the source filter.
|
|
102
|
+
_LEGACY_SOURCE_APPS = frozenset({"blackbox"})
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _normalize_source_app(name: str) -> str:
|
|
106
|
+
return "agentmetry" if name in _LEGACY_SOURCE_APPS else name
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _event_source_app(event: dict[str, Any]) -> str:
|
|
110
|
+
source = event.get("source")
|
|
111
|
+
if isinstance(source, dict) and source.get("app"):
|
|
112
|
+
return _normalize_source_app(str(source["app"]).lower())
|
|
113
|
+
agent = event.get("agent")
|
|
114
|
+
if isinstance(agent, dict) and agent.get("name"):
|
|
115
|
+
name = _normalize_source_app(str(agent["name"]).lower())
|
|
116
|
+
if name != "agentmetry":
|
|
117
|
+
return name
|
|
118
|
+
return "agentmetry"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@router.post("/ingest", dependencies=[Depends(require_api_key)])
|
|
122
|
+
async def audit_ingest(body: ExternalIngestBody):
|
|
123
|
+
"""Accept canonical adapter events from Cursor, Claude, MCP proxy, etc."""
|
|
124
|
+
if not settings.audit_ingest_enabled:
|
|
125
|
+
raise HTTPException(status_code=503, detail="External audit ingest is disabled")
|
|
126
|
+
if not settings.audit_export_enabled:
|
|
127
|
+
raise HTTPException(status_code=503, detail="Audit export is disabled")
|
|
128
|
+
|
|
129
|
+
payload = body.model_dump(exclude_none=True)
|
|
130
|
+
try:
|
|
131
|
+
canonical = await ingest_external_event(payload)
|
|
132
|
+
except ValueError as exc:
|
|
133
|
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
134
|
+
except RuntimeError as exc:
|
|
135
|
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
"status": "accepted",
|
|
139
|
+
"event_id": canonical.get("event_id"),
|
|
140
|
+
"correlation_id": canonical.get("correlation_id"),
|
|
141
|
+
"action": canonical.get("action"),
|
|
142
|
+
"source": canonical.get("source"),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@router.get("/tail", dependencies=[Depends(require_api_key)])
|
|
147
|
+
async def audit_tail(
|
|
148
|
+
limit: int = Query(50, ge=1, le=500),
|
|
149
|
+
scope: Literal["runs", "all"] = Query(
|
|
150
|
+
"runs",
|
|
151
|
+
description="runs = session/tool/approval only; all = include driver config_change",
|
|
152
|
+
),
|
|
153
|
+
session_id: str | None = Query(
|
|
154
|
+
None,
|
|
155
|
+
description="When set, Agentmetry events filter to session; external apps always shown",
|
|
156
|
+
),
|
|
157
|
+
sources: str | None = Query(
|
|
158
|
+
None,
|
|
159
|
+
description="Comma-separated source apps: agentmetry,cursor,claude,antigravity,mcp_proxy",
|
|
160
|
+
),
|
|
161
|
+
since_minutes: int | None = Query(
|
|
162
|
+
None,
|
|
163
|
+
ge=1,
|
|
164
|
+
le=10080,
|
|
165
|
+
description="Only events within the last N minutes",
|
|
166
|
+
),
|
|
167
|
+
before_utc: str | None = Query(
|
|
168
|
+
None,
|
|
169
|
+
description="Return events strictly before this ISO timestamp (page older)",
|
|
170
|
+
),
|
|
171
|
+
after_utc: str | None = Query(
|
|
172
|
+
None,
|
|
173
|
+
description="Return events strictly after this ISO timestamp (page newer)",
|
|
174
|
+
),
|
|
175
|
+
focus: Literal["denied", "dlp", "policy", "detection"] | None = Query(
|
|
176
|
+
None,
|
|
177
|
+
description=(
|
|
178
|
+
"Server-side slice matching Analytics dogfood counts: "
|
|
179
|
+
"denied | dlp | policy | detection"
|
|
180
|
+
),
|
|
181
|
+
),
|
|
182
|
+
):
|
|
183
|
+
"""Return canonical audit events from the local JSONL forwarder."""
|
|
184
|
+
from agentmetry.core.audit.trail_db import get_trail_db
|
|
185
|
+
path = Path(settings.audit_export_path)
|
|
186
|
+
if not settings.audit_export_enabled:
|
|
187
|
+
return {"events": [], "path": str(path), "enabled": False, "pagination": {"has_older": False, "has_newer": False, "count": 0}}
|
|
188
|
+
|
|
189
|
+
source_set: set[str] | None = None
|
|
190
|
+
if sources:
|
|
191
|
+
source_set = {s.strip().lower() for s in sources.split(",") if s.strip()}
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
events, pagination = get_trail_db().tail(
|
|
195
|
+
limit=limit,
|
|
196
|
+
scope=scope,
|
|
197
|
+
sources=source_set,
|
|
198
|
+
session_id=session_id,
|
|
199
|
+
since_minutes=since_minutes,
|
|
200
|
+
before_utc=before_utc,
|
|
201
|
+
after_utc=after_utc,
|
|
202
|
+
focus=focus,
|
|
203
|
+
)
|
|
204
|
+
except HTTPException:
|
|
205
|
+
raise
|
|
206
|
+
except ValueError as exc:
|
|
207
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
208
|
+
except Exception as exc:
|
|
209
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
210
|
+
return {"events": events, "path": str(path), "enabled": True, "pagination": pagination}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@router.get("/session/{correlation_id}", dependencies=[Depends(require_api_key)])
|
|
214
|
+
async def audit_session(
|
|
215
|
+
correlation_id: str,
|
|
216
|
+
limit: int = Query(2000, ge=1, le=10000),
|
|
217
|
+
):
|
|
218
|
+
"""Return every event for one correlation_id across the whole trail.
|
|
219
|
+
|
|
220
|
+
The dashboard's in-panel search only sees the loaded window, so viewing a
|
|
221
|
+
full session — especially an older one — needs a server-side lookup that
|
|
222
|
+
scans the entire JSONL rather than the last N lines.
|
|
223
|
+
"""
|
|
224
|
+
from agentmetry.core.audit.trail_db import get_trail_db
|
|
225
|
+
if not settings.audit_export_enabled:
|
|
226
|
+
return {"events": [], "correlation_id": correlation_id, "enabled": False, "count": 0}
|
|
227
|
+
try:
|
|
228
|
+
events = get_trail_db().session(correlation_id, limit=limit)
|
|
229
|
+
except Exception as exc:
|
|
230
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
231
|
+
return {
|
|
232
|
+
"events": events,
|
|
233
|
+
"correlation_id": correlation_id,
|
|
234
|
+
"enabled": True,
|
|
235
|
+
"count": len(events),
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@router.get("/detections/{correlation_id}", dependencies=[Depends(require_api_key)])
|
|
240
|
+
async def audit_detections(correlation_id: str):
|
|
241
|
+
"""Run correlated behavioral rules over one session and return detections.
|
|
242
|
+
|
|
243
|
+
A detection is a named, ordered pattern of events (e.g. credential access
|
|
244
|
+
then network egress) — the signal per-event MITRE tags can't express on
|
|
245
|
+
their own. Scans the whole trail for the session, then correlates.
|
|
246
|
+
"""
|
|
247
|
+
from agentmetry.core.audit.detection import run_detections
|
|
248
|
+
from agentmetry.core.audit.trail_db import get_trail_db
|
|
249
|
+
|
|
250
|
+
if not settings.audit_export_enabled:
|
|
251
|
+
return {"detections": [], "correlation_id": correlation_id, "enabled": False, "count": 0}
|
|
252
|
+
try:
|
|
253
|
+
events = get_trail_db().events_for_detection(correlation_id)
|
|
254
|
+
except Exception as exc:
|
|
255
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
256
|
+
detections = run_detections(events)
|
|
257
|
+
dispositions = get_disposition_store().for_correlation(correlation_id)
|
|
258
|
+
for detection in detections:
|
|
259
|
+
detection.disposition = dispositions.get(detection.rule_id)
|
|
260
|
+
return {
|
|
261
|
+
"detections": [d.as_dict() for d in detections],
|
|
262
|
+
"correlation_id": correlation_id,
|
|
263
|
+
"enabled": True,
|
|
264
|
+
"count": len(detections),
|
|
265
|
+
"untriaged": sum(1 for d in detections if d.disposition is None),
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class DispositionBody(BaseModel):
|
|
270
|
+
"""A triage decision. `note` is operator prose, never captured content."""
|
|
271
|
+
|
|
272
|
+
correlation_id: str = ""
|
|
273
|
+
rule_id: str
|
|
274
|
+
status: str
|
|
275
|
+
assignee: str = ""
|
|
276
|
+
note: str = ""
|
|
277
|
+
decided_by: str = ""
|
|
278
|
+
severity: str = ""
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@router.post("/detections/disposition", dependencies=[Depends(require_api_key)])
|
|
282
|
+
async def audit_set_disposition(body: DispositionBody):
|
|
283
|
+
"""Record what a human decided about a detection.
|
|
284
|
+
|
|
285
|
+
The decision is appended to the trail as a `detection_disposition` event
|
|
286
|
+
before the index is updated, so it lands on the same hash chain as the
|
|
287
|
+
finding it answers. Superseding a disposition keeps the previous one in
|
|
288
|
+
`history`: "false positive" later becoming "confirmed" is precisely the
|
|
289
|
+
transition an auditor needs to see.
|
|
290
|
+
"""
|
|
291
|
+
from agentmetry.core.audit.detection.disposition import DispositionError, apply_disposition
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
current = await apply_disposition(
|
|
295
|
+
correlation_id=body.correlation_id,
|
|
296
|
+
rule_id=body.rule_id,
|
|
297
|
+
status=body.status,
|
|
298
|
+
assignee=body.assignee,
|
|
299
|
+
note=body.note,
|
|
300
|
+
decided_by=body.decided_by,
|
|
301
|
+
severity=body.severity,
|
|
302
|
+
)
|
|
303
|
+
except DispositionError as exc:
|
|
304
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
305
|
+
except Exception as exc:
|
|
306
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
307
|
+
return {"disposition": current}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@router.get("/detections/dispositions/all", dependencies=[Depends(require_api_key)])
|
|
311
|
+
async def audit_list_dispositions():
|
|
312
|
+
"""Every triage decision in force, plus the status breakdown."""
|
|
313
|
+
store = get_disposition_store()
|
|
314
|
+
return {
|
|
315
|
+
"dispositions": store.all(),
|
|
316
|
+
"counts": store.counts(),
|
|
317
|
+
"statuses": list(STATUSES),
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@router.get("/dogfood", dependencies=[Depends(require_api_key)])
|
|
322
|
+
async def audit_dogfood():
|
|
323
|
+
"""Progress against the four-week beta gate.
|
|
324
|
+
|
|
325
|
+
Exposed so the dashboard can show it. A gate you have to remember to run a
|
|
326
|
+
command for is one you stop running, which is how this one went weeks
|
|
327
|
+
without being started in the first place.
|
|
328
|
+
"""
|
|
329
|
+
from agentmetry.core.audit.dogfood import assess
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
return assess().as_dict()
|
|
333
|
+
except Exception as exc:
|
|
334
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@router.get("/export/evidence", dependencies=[Depends(require_api_key)])
|
|
338
|
+
async def audit_export_evidence():
|
|
339
|
+
"""Generate and download a tamper-evident evidence pack (SHA-256 integrity manifest)."""
|
|
340
|
+
from agentmetry.core.audit.evidence_pack import build_evidence_pack, default_export_path, write_evidence_pack
|
|
341
|
+
from datetime import datetime, timezone
|
|
342
|
+
|
|
343
|
+
# Dates, not datetimes: `default_export_path` embeds them in the filename,
|
|
344
|
+
# and a datetime's isoformat carries colons, which Windows rejects.
|
|
345
|
+
to_date = datetime.now(timezone.utc).date()
|
|
346
|
+
from_date = to_date - timedelta(days=30) # Export last 30 days
|
|
347
|
+
|
|
348
|
+
pack = build_evidence_pack(from_date, to_date)
|
|
349
|
+
out_path = default_export_path(from_date, to_date)
|
|
350
|
+
write_evidence_pack(pack, out_path)
|
|
351
|
+
|
|
352
|
+
return FileResponse(
|
|
353
|
+
path=out_path,
|
|
354
|
+
media_type="application/json",
|
|
355
|
+
filename=out_path.name,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
@router.get("/status", dependencies=[Depends(require_api_key)])
|
|
360
|
+
async def audit_status():
|
|
361
|
+
"""Freshness + per-source counts so the dashboard can show 'last event N min ago'.
|
|
362
|
+
|
|
363
|
+
Powers the freshness badge and `selftest` — makes silent hook failure visible
|
|
364
|
+
instead of the operator falsely believing they are being audited.
|
|
365
|
+
"""
|
|
366
|
+
from agentmetry.core.audit.spool import spool_depth, spool_oldest_age_seconds
|
|
367
|
+
from agentmetry.core.audit.trail_db import get_trail_db
|
|
368
|
+
path = Path(settings.audit_export_path)
|
|
369
|
+
if not settings.audit_export_enabled:
|
|
370
|
+
return {"enabled": False, "last_event_utc": None, "recent": 0, "by_source": {}, "path": str(path)}
|
|
371
|
+
|
|
372
|
+
status_data = get_trail_db().status()
|
|
373
|
+
# Pending spool depth belongs in the same payload as freshness. A feed that
|
|
374
|
+
# looks quiet because nothing happened and a feed that looks quiet because
|
|
375
|
+
# capture is backing up are indistinguishable without it, and that
|
|
376
|
+
# ambiguity is how a multi-day gap goes unnoticed.
|
|
377
|
+
return {
|
|
378
|
+
"enabled": True,
|
|
379
|
+
"last_event_utc": status_data["last_event_utc"],
|
|
380
|
+
"recent": status_data["recent"],
|
|
381
|
+
"by_source": status_data["by_source"],
|
|
382
|
+
"path": str(path),
|
|
383
|
+
"spool_pending": spool_depth(),
|
|
384
|
+
"spool_oldest_age_seconds": spool_oldest_age_seconds(),
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
@router.get("/stats", dependencies=[Depends(require_api_key)])
|
|
389
|
+
async def audit_stats(days: int = Query(7, ge=1, le=90)):
|
|
390
|
+
"""Weekly dogfood metrics — same data as `agentmetry stats --days N`."""
|
|
391
|
+
from agentmetry.core.audit.trail_db import get_trail_db
|
|
392
|
+
|
|
393
|
+
if not settings.audit_export_enabled:
|
|
394
|
+
return {"enabled": False, "window_days": days}
|
|
395
|
+
|
|
396
|
+
return {"enabled": True, **get_trail_db().stats(window_days=days)}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""WebSocket connection manager for real-time telemetry streaming."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fastapi import WebSocket
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# Session id that mirrors every event; the dashboard subscribes to it to
|
|
12
|
+
# observe autonomous runs that happen in other sessions.
|
|
13
|
+
GLOBAL_SESSION = "global"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConnectionManager:
|
|
17
|
+
def __init__(self):
|
|
18
|
+
self.active: dict[str, list[WebSocket]] = {}
|
|
19
|
+
|
|
20
|
+
async def connect(self, websocket: WebSocket, session_id: str) -> None:
|
|
21
|
+
await websocket.accept()
|
|
22
|
+
if session_id not in self.active:
|
|
23
|
+
self.active[session_id] = []
|
|
24
|
+
self.active[session_id].append(websocket)
|
|
25
|
+
|
|
26
|
+
def disconnect(self, websocket: WebSocket, session_id: str) -> None:
|
|
27
|
+
if session_id in self.active:
|
|
28
|
+
self.active[session_id] = [
|
|
29
|
+
ws for ws in self.active[session_id] if ws != websocket
|
|
30
|
+
]
|
|
31
|
+
if not self.active[session_id]:
|
|
32
|
+
del self.active[session_id]
|
|
33
|
+
|
|
34
|
+
async def broadcast(self, session_id: str, event: dict[str, Any]) -> None:
|
|
35
|
+
await self._send(session_id, event)
|
|
36
|
+
if session_id != GLOBAL_SESSION:
|
|
37
|
+
await self._send(GLOBAL_SESSION, {**event, "origin_session": session_id})
|
|
38
|
+
|
|
39
|
+
async def _send(self, session_id: str, event: dict[str, Any]) -> None:
|
|
40
|
+
if session_id not in self.active:
|
|
41
|
+
return
|
|
42
|
+
payload = json.dumps(event)
|
|
43
|
+
dead: list[WebSocket] = []
|
|
44
|
+
for ws in self.active[session_id]:
|
|
45
|
+
try:
|
|
46
|
+
await ws.send_text(payload)
|
|
47
|
+
except Exception:
|
|
48
|
+
dead.append(ws)
|
|
49
|
+
for ws in dead:
|
|
50
|
+
self.disconnect(ws, session_id)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
ws_manager = ConnectionManager()
|