agentmetry 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. agentmetry/__init__.py +12 -0
  2. agentmetry/api/__init__.py +0 -0
  3. agentmetry/api/main.py +232 -0
  4. agentmetry/api/routes/__init__.py +0 -0
  5. agentmetry/api/routes/audit.py +396 -0
  6. agentmetry/api/websocket.py +53 -0
  7. agentmetry/api/ws_bridge.py +34 -0
  8. agentmetry/cli/__init__.py +941 -0
  9. agentmetry/cli/__main__.py +5 -0
  10. agentmetry/core/__init__.py +0 -0
  11. agentmetry/core/audit/__init__.py +1 -0
  12. agentmetry/core/audit/adapters/__init__.py +0 -0
  13. agentmetry/core/audit/adapters/agt.py +304 -0
  14. agentmetry/core/audit/adapters/cloudevents.py +159 -0
  15. agentmetry/core/audit/adapters/ecs.py +103 -0
  16. agentmetry/core/audit/adapters/splunk.py +40 -0
  17. agentmetry/core/audit/alerts.py +56 -0
  18. agentmetry/core/audit/canonical.py +150 -0
  19. agentmetry/core/audit/compliance_digest.py +299 -0
  20. agentmetry/core/audit/detection/__init__.py +9 -0
  21. agentmetry/core/audit/detection/benchmark.py +194 -0
  22. agentmetry/core/audit/detection/corpus/attack_approval_denied_then_executed.jsonl +3 -0
  23. agentmetry/core/audit/detection/corpus/attack_arbitrary_host_stage_execute.jsonl +3 -0
  24. agentmetry/core/audit/detection/corpus/attack_autonomous_unapproved_write.jsonl +3 -0
  25. agentmetry/core/audit/detection/corpus/attack_credential_exfil.jsonl +2 -0
  26. agentmetry/core/audit/detection/corpus/attack_credential_then_cloud_api.jsonl +2 -0
  27. agentmetry/core/audit/detection/corpus/attack_destructive_delete_burst.jsonl +6 -0
  28. agentmetry/core/audit/detection/corpus/attack_discovery_then_collect.jsonl +5 -0
  29. agentmetry/core/audit/detection/corpus/attack_dotfile_then_git_push.jsonl +2 -0
  30. agentmetry/core/audit/detection/corpus/attack_encoded_command_download.jsonl +1 -0
  31. agentmetry/core/audit/detection/corpus/attack_env_credential_exfil.jsonl +3 -0
  32. agentmetry/core/audit/detection/corpus/attack_hashed_only_no_command.jsonl +2 -0
  33. agentmetry/core/audit/detection/corpus/attack_interpreter_egress.jsonl +2 -0
  34. agentmetry/core/audit/detection/corpus/attack_pr_merged_without_review.jsonl +2 -0
  35. agentmetry/core/audit/detection/corpus/attack_proc_substitution_cradle.jsonl +2 -0
  36. agentmetry/core/audit/detection/corpus/attack_remote_pipe_to_shell.jsonl +1 -0
  37. agentmetry/core/audit/detection/corpus/attack_remote_staging_then_execute.jsonl +2 -0
  38. agentmetry/core/audit/detection/corpus/attack_session_tool_burst.jsonl +42 -0
  39. agentmetry/core/audit/detection/corpus/attack_single_command_exfil.jsonl +2 -0
  40. agentmetry/core/audit/detection/corpus/attack_ssh_directory_exfil.jsonl +3 -0
  41. agentmetry/core/audit/detection/corpus/attack_subagent_swarm.jsonl +6 -0
  42. agentmetry/core/audit/detection/corpus/attack_timestamp_collision.jsonl +2 -0
  43. agentmetry/core/audit/detection/corpus/attack_untrusted_input_then_action.jsonl +3 -0
  44. agentmetry/core/audit/detection/corpus/benign_authoring_merge_fixtures.jsonl +3 -0
  45. agentmetry/core/audit/detection/corpus/benign_autonomous_after_approval.jsonl +4 -0
  46. agentmetry/core/audit/detection/corpus/benign_build_artifact_cleanup.jsonl +5 -0
  47. agentmetry/core/audit/detection/corpus/benign_ci_artifact_download.jsonl +3 -0
  48. agentmetry/core/audit/detection/corpus/benign_database_migration.jsonl +5 -0
  49. agentmetry/core/audit/detection/corpus/benign_dependency_install_and_build.jsonl +5 -0
  50. agentmetry/core/audit/detection/corpus/benign_download_release_archive.jsonl +4 -0
  51. agentmetry/core/audit/detection/corpus/benign_fetch_data_then_run_repo_script.jsonl +3 -0
  52. agentmetry/core/audit/detection/corpus/benign_fetch_dataset_then_analyse.jsonl +3 -0
  53. agentmetry/core/audit/detection/corpus/benign_fetch_lockfile_then_install.jsonl +3 -0
  54. agentmetry/core/audit/detection/corpus/benign_git_review_and_push.jsonl +6 -0
  55. agentmetry/core/audit/detection/corpus/benign_human_driven_deletes.jsonl +6 -0
  56. agentmetry/core/audit/detection/corpus/benign_local_api_probing.jsonl +5 -0
  57. agentmetry/core/audit/detection/corpus/benign_long_but_calm_session.jsonl +30 -0
  58. agentmetry/core/audit/detection/corpus/benign_loopback_is_not_egress.jsonl +2 -0
  59. agentmetry/core/audit/detection/corpus/benign_loopback_pipe_to_interpreter.jsonl +3 -0
  60. agentmetry/core/audit/detection/corpus/benign_ordinary_development.jsonl +5 -0
  61. agentmetry/core/audit/detection/corpus/benign_package_manager_after_fetch.jsonl +2 -0
  62. agentmetry/core/audit/detection/corpus/benign_reading_config_that_is_not_secret.jsonl +5 -0
  63. agentmetry/core/audit/detection/corpus/benign_remote_api_call_no_credentials.jsonl +4 -0
  64. agentmetry/core/audit/detection/corpus/benign_research_then_docs.jsonl +5 -0
  65. agentmetry/core/audit/detection/corpus/benign_reversed_order_is_not_exfil.jsonl +2 -0
  66. agentmetry/core/audit/detection/corpus/benign_test_and_fix_loop.jsonl +6 -0
  67. agentmetry/core/audit/detection/corpus/benign_writing_about_credentials.jsonl +6 -0
  68. agentmetry/core/audit/detection/corpus/corpus.yaml +443 -0
  69. agentmetry/core/audit/detection/disposition.py +651 -0
  70. agentmetry/core/audit/detection/engine.py +78 -0
  71. agentmetry/core/audit/detection/live.py +127 -0
  72. agentmetry/core/audit/detection/live_store.py +355 -0
  73. agentmetry/core/audit/detection/models.py +53 -0
  74. agentmetry/core/audit/detection/rules.py +1314 -0
  75. agentmetry/core/audit/detection/traits.py +648 -0
  76. agentmetry/core/audit/detection/yaml_config.py +91 -0
  77. agentmetry/core/audit/detection/yaml_rules.py +83 -0
  78. agentmetry/core/audit/dlp/__init__.py +4 -0
  79. agentmetry/core/audit/dlp/loader.py +29 -0
  80. agentmetry/core/audit/dlp/models.py +29 -0
  81. agentmetry/core/audit/dlp/scanner.py +96 -0
  82. agentmetry/core/audit/dogfood.py +398 -0
  83. agentmetry/core/audit/evidence_pack.py +500 -0
  84. agentmetry/core/audit/external.py +213 -0
  85. agentmetry/core/audit/hashing.py +21 -0
  86. agentmetry/core/audit/hook_bootstrap.py +451 -0
  87. agentmetry/core/audit/identity.py +39 -0
  88. agentmetry/core/audit/ingest.py +242 -0
  89. agentmetry/core/audit/migrate.py +73 -0
  90. agentmetry/core/audit/mitre.py +244 -0
  91. agentmetry/core/audit/policy.py +99 -0
  92. agentmetry/core/audit/redaction.py +50 -0
  93. agentmetry/core/audit/replay.py +54 -0
  94. agentmetry/core/audit/run_context.py +129 -0
  95. agentmetry/core/audit/sinks.py +235 -0
  96. agentmetry/core/audit/spool.py +394 -0
  97. agentmetry/core/audit/tool_policy/__init__.py +4 -0
  98. agentmetry/core/audit/tool_policy/evaluator.py +198 -0
  99. agentmetry/core/audit/tool_policy/loader.py +44 -0
  100. agentmetry/core/audit/tool_policy/models.py +25 -0
  101. agentmetry/core/audit/trail_chain.py +300 -0
  102. agentmetry/core/audit/trail_db.py +491 -0
  103. agentmetry/core/audit/trail_merkle.py +332 -0
  104. agentmetry/core/auth.py +54 -0
  105. agentmetry/core/bus/__init__.py +5 -0
  106. agentmetry/core/bus/audit_exporter.py +107 -0
  107. agentmetry/core/bus/bridges.py +26 -0
  108. agentmetry/core/bus/bus.py +102 -0
  109. agentmetry/core/bus/events.py +50 -0
  110. agentmetry/core/bus/outbox.py +124 -0
  111. agentmetry/core/config.py +177 -0
  112. agentmetry/core/diagnostics/__init__.py +0 -0
  113. agentmetry/core/diagnostics/autostart.py +563 -0
  114. agentmetry/core/diagnostics/doctor.py +535 -0
  115. agentmetry/core/diagnostics/driver_paths.py +156 -0
  116. agentmetry/core/diagnostics/env_file.py +45 -0
  117. agentmetry/core/drivers/__init__.py +4 -0
  118. agentmetry/core/drivers/host.py +263 -0
  119. agentmetry/core/drivers/permissions.py +37 -0
  120. agentmetry/core/drivers/spec.py +118 -0
  121. agentmetry/core/extensions.py +107 -0
  122. agentmetry/core/health.py +26 -0
  123. agentmetry/core/version.py +13 -0
  124. agentmetry/policies/detection/manifest.yaml +43 -0
  125. agentmetry/policies/dlp/manifest.yaml +161 -0
  126. agentmetry/policies/opa/agent_rules.rego +33 -0
  127. agentmetry/policies/tool/manifest.yaml +117 -0
  128. agentmetry-0.4.0.dist-info/METADATA +86 -0
  129. agentmetry-0.4.0.dist-info/RECORD +131 -0
  130. agentmetry-0.4.0.dist-info/WHEEL +4 -0
  131. agentmetry-0.4.0.dist-info/entry_points.txt +2 -0
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()