agentmetry 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. agentmetry/__init__.py +12 -0
  2. agentmetry/api/__init__.py +0 -0
  3. agentmetry/api/main.py +232 -0
  4. agentmetry/api/routes/__init__.py +0 -0
  5. agentmetry/api/routes/audit.py +396 -0
  6. agentmetry/api/websocket.py +53 -0
  7. agentmetry/api/ws_bridge.py +34 -0
  8. agentmetry/cli/__init__.py +941 -0
  9. agentmetry/cli/__main__.py +5 -0
  10. agentmetry/core/__init__.py +0 -0
  11. agentmetry/core/audit/__init__.py +1 -0
  12. agentmetry/core/audit/adapters/__init__.py +0 -0
  13. agentmetry/core/audit/adapters/agt.py +304 -0
  14. agentmetry/core/audit/adapters/cloudevents.py +159 -0
  15. agentmetry/core/audit/adapters/ecs.py +103 -0
  16. agentmetry/core/audit/adapters/splunk.py +40 -0
  17. agentmetry/core/audit/alerts.py +56 -0
  18. agentmetry/core/audit/canonical.py +150 -0
  19. agentmetry/core/audit/compliance_digest.py +299 -0
  20. agentmetry/core/audit/detection/__init__.py +9 -0
  21. agentmetry/core/audit/detection/benchmark.py +194 -0
  22. agentmetry/core/audit/detection/corpus/attack_approval_denied_then_executed.jsonl +3 -0
  23. agentmetry/core/audit/detection/corpus/attack_arbitrary_host_stage_execute.jsonl +3 -0
  24. agentmetry/core/audit/detection/corpus/attack_autonomous_unapproved_write.jsonl +3 -0
  25. agentmetry/core/audit/detection/corpus/attack_credential_exfil.jsonl +2 -0
  26. agentmetry/core/audit/detection/corpus/attack_credential_then_cloud_api.jsonl +2 -0
  27. agentmetry/core/audit/detection/corpus/attack_destructive_delete_burst.jsonl +6 -0
  28. agentmetry/core/audit/detection/corpus/attack_discovery_then_collect.jsonl +5 -0
  29. agentmetry/core/audit/detection/corpus/attack_dotfile_then_git_push.jsonl +2 -0
  30. agentmetry/core/audit/detection/corpus/attack_encoded_command_download.jsonl +1 -0
  31. agentmetry/core/audit/detection/corpus/attack_env_credential_exfil.jsonl +3 -0
  32. agentmetry/core/audit/detection/corpus/attack_hashed_only_no_command.jsonl +2 -0
  33. agentmetry/core/audit/detection/corpus/attack_interpreter_egress.jsonl +2 -0
  34. agentmetry/core/audit/detection/corpus/attack_pr_merged_without_review.jsonl +2 -0
  35. agentmetry/core/audit/detection/corpus/attack_proc_substitution_cradle.jsonl +2 -0
  36. agentmetry/core/audit/detection/corpus/attack_remote_pipe_to_shell.jsonl +1 -0
  37. agentmetry/core/audit/detection/corpus/attack_remote_staging_then_execute.jsonl +2 -0
  38. agentmetry/core/audit/detection/corpus/attack_session_tool_burst.jsonl +42 -0
  39. agentmetry/core/audit/detection/corpus/attack_single_command_exfil.jsonl +2 -0
  40. agentmetry/core/audit/detection/corpus/attack_ssh_directory_exfil.jsonl +3 -0
  41. agentmetry/core/audit/detection/corpus/attack_subagent_swarm.jsonl +6 -0
  42. agentmetry/core/audit/detection/corpus/attack_timestamp_collision.jsonl +2 -0
  43. agentmetry/core/audit/detection/corpus/attack_untrusted_input_then_action.jsonl +3 -0
  44. agentmetry/core/audit/detection/corpus/benign_authoring_merge_fixtures.jsonl +3 -0
  45. agentmetry/core/audit/detection/corpus/benign_autonomous_after_approval.jsonl +4 -0
  46. agentmetry/core/audit/detection/corpus/benign_build_artifact_cleanup.jsonl +5 -0
  47. agentmetry/core/audit/detection/corpus/benign_ci_artifact_download.jsonl +3 -0
  48. agentmetry/core/audit/detection/corpus/benign_database_migration.jsonl +5 -0
  49. agentmetry/core/audit/detection/corpus/benign_dependency_install_and_build.jsonl +5 -0
  50. agentmetry/core/audit/detection/corpus/benign_download_release_archive.jsonl +4 -0
  51. agentmetry/core/audit/detection/corpus/benign_fetch_data_then_run_repo_script.jsonl +3 -0
  52. agentmetry/core/audit/detection/corpus/benign_fetch_dataset_then_analyse.jsonl +3 -0
  53. agentmetry/core/audit/detection/corpus/benign_fetch_lockfile_then_install.jsonl +3 -0
  54. agentmetry/core/audit/detection/corpus/benign_git_review_and_push.jsonl +6 -0
  55. agentmetry/core/audit/detection/corpus/benign_human_driven_deletes.jsonl +6 -0
  56. agentmetry/core/audit/detection/corpus/benign_local_api_probing.jsonl +5 -0
  57. agentmetry/core/audit/detection/corpus/benign_long_but_calm_session.jsonl +30 -0
  58. agentmetry/core/audit/detection/corpus/benign_loopback_is_not_egress.jsonl +2 -0
  59. agentmetry/core/audit/detection/corpus/benign_loopback_pipe_to_interpreter.jsonl +3 -0
  60. agentmetry/core/audit/detection/corpus/benign_ordinary_development.jsonl +5 -0
  61. agentmetry/core/audit/detection/corpus/benign_package_manager_after_fetch.jsonl +2 -0
  62. agentmetry/core/audit/detection/corpus/benign_reading_config_that_is_not_secret.jsonl +5 -0
  63. agentmetry/core/audit/detection/corpus/benign_remote_api_call_no_credentials.jsonl +4 -0
  64. agentmetry/core/audit/detection/corpus/benign_research_then_docs.jsonl +5 -0
  65. agentmetry/core/audit/detection/corpus/benign_reversed_order_is_not_exfil.jsonl +2 -0
  66. agentmetry/core/audit/detection/corpus/benign_test_and_fix_loop.jsonl +6 -0
  67. agentmetry/core/audit/detection/corpus/benign_writing_about_credentials.jsonl +6 -0
  68. agentmetry/core/audit/detection/corpus/corpus.yaml +443 -0
  69. agentmetry/core/audit/detection/disposition.py +651 -0
  70. agentmetry/core/audit/detection/engine.py +78 -0
  71. agentmetry/core/audit/detection/live.py +127 -0
  72. agentmetry/core/audit/detection/live_store.py +355 -0
  73. agentmetry/core/audit/detection/models.py +53 -0
  74. agentmetry/core/audit/detection/rules.py +1314 -0
  75. agentmetry/core/audit/detection/traits.py +648 -0
  76. agentmetry/core/audit/detection/yaml_config.py +91 -0
  77. agentmetry/core/audit/detection/yaml_rules.py +83 -0
  78. agentmetry/core/audit/dlp/__init__.py +4 -0
  79. agentmetry/core/audit/dlp/loader.py +29 -0
  80. agentmetry/core/audit/dlp/models.py +29 -0
  81. agentmetry/core/audit/dlp/scanner.py +96 -0
  82. agentmetry/core/audit/dogfood.py +398 -0
  83. agentmetry/core/audit/evidence_pack.py +500 -0
  84. agentmetry/core/audit/external.py +213 -0
  85. agentmetry/core/audit/hashing.py +21 -0
  86. agentmetry/core/audit/hook_bootstrap.py +451 -0
  87. agentmetry/core/audit/identity.py +39 -0
  88. agentmetry/core/audit/ingest.py +242 -0
  89. agentmetry/core/audit/migrate.py +73 -0
  90. agentmetry/core/audit/mitre.py +244 -0
  91. agentmetry/core/audit/policy.py +99 -0
  92. agentmetry/core/audit/redaction.py +50 -0
  93. agentmetry/core/audit/replay.py +54 -0
  94. agentmetry/core/audit/run_context.py +129 -0
  95. agentmetry/core/audit/sinks.py +235 -0
  96. agentmetry/core/audit/spool.py +394 -0
  97. agentmetry/core/audit/tool_policy/__init__.py +4 -0
  98. agentmetry/core/audit/tool_policy/evaluator.py +198 -0
  99. agentmetry/core/audit/tool_policy/loader.py +44 -0
  100. agentmetry/core/audit/tool_policy/models.py +25 -0
  101. agentmetry/core/audit/trail_chain.py +300 -0
  102. agentmetry/core/audit/trail_db.py +491 -0
  103. agentmetry/core/audit/trail_merkle.py +332 -0
  104. agentmetry/core/auth.py +54 -0
  105. agentmetry/core/bus/__init__.py +5 -0
  106. agentmetry/core/bus/audit_exporter.py +107 -0
  107. agentmetry/core/bus/bridges.py +26 -0
  108. agentmetry/core/bus/bus.py +102 -0
  109. agentmetry/core/bus/events.py +50 -0
  110. agentmetry/core/bus/outbox.py +124 -0
  111. agentmetry/core/config.py +177 -0
  112. agentmetry/core/diagnostics/__init__.py +0 -0
  113. agentmetry/core/diagnostics/autostart.py +563 -0
  114. agentmetry/core/diagnostics/doctor.py +535 -0
  115. agentmetry/core/diagnostics/driver_paths.py +156 -0
  116. agentmetry/core/diagnostics/env_file.py +45 -0
  117. agentmetry/core/drivers/__init__.py +4 -0
  118. agentmetry/core/drivers/host.py +263 -0
  119. agentmetry/core/drivers/permissions.py +37 -0
  120. agentmetry/core/drivers/spec.py +118 -0
  121. agentmetry/core/extensions.py +107 -0
  122. agentmetry/core/health.py +26 -0
  123. agentmetry/core/version.py +13 -0
  124. agentmetry/policies/detection/manifest.yaml +43 -0
  125. agentmetry/policies/dlp/manifest.yaml +161 -0
  126. agentmetry/policies/opa/agent_rules.rego +33 -0
  127. agentmetry/policies/tool/manifest.yaml +117 -0
  128. agentmetry-0.4.0.dist-info/METADATA +86 -0
  129. agentmetry-0.4.0.dist-info/RECORD +131 -0
  130. agentmetry-0.4.0.dist-info/WHEEL +4 -0
  131. agentmetry-0.4.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,941 @@
1
+ """agentmetry — local ops CLI for the Agentmetry appliance.
2
+
3
+ Commands: start, stop, status, logs, backup, restore, export, verify, install, uninstall.
4
+ Pure stdlib + httpx; never imports the FastAPI app (fast startup, no side effects).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import os
11
+ import shutil
12
+ import socket
13
+ import sqlite3
14
+ import subprocess
15
+ import sys
16
+ import tempfile
17
+ import time
18
+ import zipfile
19
+ from datetime import datetime, timezone
20
+ from pathlib import Path
21
+
22
+ import httpx
23
+
24
+ _ORCH_ROOT = Path(__file__).resolve().parents[2] # apps/orchestrator
25
+ _REPO_ROOT = _ORCH_ROOT.parents[1] # repo root
26
+ _DATA_DIR = _ORCH_ROOT / "data"
27
+ _PID_FILE = _DATA_DIR / "agentmetry.pid"
28
+ _TASK_NAME = "Agentmetry Orchestrator"
29
+
30
+ # Paths bundled by backup, relative to the repo root.
31
+ _BACKUP_PREFIXES = ("vault/", "apps/orchestrator/data/")
32
+ _BACKUP_EXCLUDE_DIRS = {"logs"}
33
+ _BACKUP_EXCLUDE_SUFFIXES = {".pid"}
34
+
35
+
36
+ def _base_url(port: int, host: str = "127.0.0.1") -> str:
37
+ display = host if host != "0.0.0.0" else "127.0.0.1"
38
+ return f"http://{display}:{port}"
39
+
40
+
41
+ def _lan_ip() -> str | None:
42
+ """Best-effort local IPv4 for phone/LAN access hints."""
43
+ try:
44
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
45
+ sock.connect(("8.8.8.8", 80))
46
+ ip = sock.getsockname()[0]
47
+ sock.close()
48
+ return ip
49
+ except OSError:
50
+ return None
51
+
52
+
53
+ def _print_lan_hint(port: int) -> None:
54
+ ip = _lan_ip()
55
+ if not ip:
56
+ print("LAN: could not detect local IP — run ipconfig and use http://<your-ip>:8000")
57
+ return
58
+ print(f"Phone / LAN dashboard: http://{ip}:{port}")
59
+ print(" (Needs dashboard built — run scripts\\serve.bat or scripts\\mobile.bat first)")
60
+
61
+
62
+ def _fetch_health(port: int) -> dict | None:
63
+ try:
64
+ # Generous: /health probes optional services whose ports may black-hole.
65
+ resp = httpx.get(f"{_base_url(port)}/api/v1/health", timeout=10.0)
66
+ if resp.status_code == 200:
67
+ return resp.json()
68
+ except Exception:
69
+ pass
70
+ return None
71
+
72
+
73
+ # ---------------------------------------------------------------- start/stop
74
+
75
+
76
+ def cmd_start(args: argparse.Namespace) -> int:
77
+ host = getattr(args, "host", "127.0.0.1")
78
+ if _fetch_health(args.port):
79
+ print(f"Already running on {_base_url(args.port, host)}")
80
+ if host == "0.0.0.0":
81
+ _print_lan_hint(args.port)
82
+ return 0
83
+
84
+ _DATA_DIR.joinpath("logs").mkdir(parents=True, exist_ok=True)
85
+ out_log = _DATA_DIR / "logs" / "uvicorn.out"
86
+
87
+ flags = 0
88
+ if os.name == "nt":
89
+ flags = subprocess.CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP
90
+
91
+ with out_log.open("ab") as out:
92
+ proc = subprocess.Popen(
93
+ [
94
+ sys.executable,
95
+ "-m",
96
+ "uvicorn",
97
+ "agentmetry.api.main:app",
98
+ "--host",
99
+ host,
100
+ "--port",
101
+ str(args.port),
102
+ ],
103
+ cwd=str(_ORCH_ROOT),
104
+ stdout=out,
105
+ stderr=out,
106
+ creationflags=flags,
107
+ )
108
+ _PID_FILE.parent.mkdir(parents=True, exist_ok=True)
109
+ _PID_FILE.write_text(str(proc.pid), encoding="utf-8")
110
+
111
+ deadline = time.monotonic() + 20
112
+ while time.monotonic() < deadline:
113
+ if _fetch_health(args.port):
114
+ print(f"Agentmetry running on {_base_url(args.port, host)} (pid {proc.pid})")
115
+ if host == "0.0.0.0":
116
+ _print_lan_hint(args.port)
117
+ return 0
118
+ if proc.poll() is not None:
119
+ print(f"Orchestrator exited early (code {proc.returncode}) - see {out_log}")
120
+ return 1
121
+ time.sleep(0.5)
122
+ print(f"Started pid {proc.pid} but health did not respond in 20s - see {out_log}")
123
+ return 1
124
+
125
+
126
+ def cmd_serve(args: argparse.Namespace) -> int:
127
+ """Run the orchestrator in the foreground, logging to a file.
128
+
129
+ This is the entry point autostart registers, and it exists because of a
130
+ specific Windows failure. A background task must not flash a console window,
131
+ which means `pythonw.exe` — and under `pythonw.exe` there is no console, so
132
+ `sys.stdout` and `sys.stderr` are None. Uvicorn configures a logging handler
133
+ against `sys.stdout` on startup and dies before serving a single request.
134
+ The scheduled task ran, exited 1, and left no trace of why, which is the
135
+ same shape of silent failure the spool had.
136
+
137
+ So: point the streams at the same log `agentmetry start` uses, then run in
138
+ the foreground. Foreground matters. A supervisor watching a process that
139
+ forks and exits is watching the wrong process, and would never restart the
140
+ one that actually died.
141
+ """
142
+ log_dir = _DATA_DIR / "logs"
143
+ log_dir.mkdir(parents=True, exist_ok=True)
144
+ out_log = log_dir / "uvicorn.out"
145
+
146
+ stream = out_log.open("a", buffering=1, encoding="utf-8", errors="replace")
147
+ sys.stdout = stream
148
+ sys.stderr = stream
149
+
150
+ import uvicorn
151
+
152
+ uvicorn.run("agentmetry.api.main:app", host=args.host, port=args.port)
153
+ return 0
154
+
155
+
156
+ def cmd_stop(args: argparse.Namespace) -> int:
157
+ if not _PID_FILE.exists():
158
+ if _fetch_health(args.port):
159
+ print("Running, but no pid file (started manually?) — stop that process directly.")
160
+ return 1
161
+ print("Not running.")
162
+ return 0
163
+
164
+ pid = _PID_FILE.read_text(encoding="utf-8").strip()
165
+ if os.name == "nt":
166
+ # Hard kill of the tree. Equivalent to closing the terminal today;
167
+ # SQLite is crash-safe and pending approvals recover on next start.
168
+ result = subprocess.run(
169
+ ["taskkill", "/PID", pid, "/T", "/F"], capture_output=True, text=True
170
+ )
171
+ ok = result.returncode == 0 or "not found" in (result.stderr or "").lower()
172
+ else:
173
+ try:
174
+ os.kill(int(pid), 15)
175
+ ok = True
176
+ except ProcessLookupError:
177
+ ok = True
178
+ except Exception:
179
+ ok = False
180
+ _PID_FILE.unlink(missing_ok=True)
181
+ print(f"Stopped (pid {pid})." if ok else f"Could not stop pid {pid}.")
182
+ return 0 if ok else 1
183
+
184
+
185
+ # -------------------------------------------------------------------- status
186
+
187
+
188
+ def cmd_status(args: argparse.Namespace) -> int:
189
+ health = _fetch_health(args.port)
190
+ if health is None:
191
+ print(f"Agentmetry: not running ({_base_url(args.port)})")
192
+ return 1
193
+
194
+ print(f"Agentmetry: {health.get('status', '?')} on {_base_url(args.port)}")
195
+ print(f" Mode: {health.get('mode', 'siem')}")
196
+ audit = health.get("audit_export") or {}
197
+ if audit:
198
+ print(f" Export: {'enabled' if audit.get('enabled') else 'disabled'} → {audit.get('path', '?')}")
199
+ return 0
200
+
201
+
202
+ def cmd_stats(args: argparse.Namespace) -> int:
203
+ """Audit trail metrics over a window — dogfood weekly counts."""
204
+ try:
205
+ data = httpx.get(
206
+ f"{_base_url(args.port)}/api/v1/audit/stats",
207
+ params={"days": args.days},
208
+ timeout=10.0,
209
+ ).json()
210
+ except Exception:
211
+ print("Not running - start Agentmetry first (stats reads via the API).")
212
+ return 1
213
+
214
+ if not data.get("enabled", True):
215
+ print("Audit export disabled — enable AGENTMETRY_AUDIT_EXPORT to collect stats.")
216
+ return 1
217
+
218
+ days = data.get("window_days", args.days)
219
+ print(f"Audit trail — last {days} day(s):")
220
+ print(f" Events: {data.get('total_events', 0)}")
221
+ print(f" Sessions: {data.get('sessions', 0)}")
222
+ print(f" Detections: {data.get('detections', 0)}")
223
+ print(f" Denied: {data.get('denied', 0)}")
224
+ print(f" DLP matches: {data.get('dlp_matches', 0)}")
225
+ print(f" Tool policy: {data.get('tool_policy_hits', 0)} hits / "
226
+ f"{data.get('tool_policy_blocks', 0)} blocked")
227
+ by_source = data.get("by_source") or {}
228
+ if by_source:
229
+ parts = ", ".join(f"{k}={v}" for k, v in by_source.items())
230
+ print(f" By source: {parts}")
231
+ last = data.get("last_event_utc")
232
+ if last:
233
+ print(f" Last event: {last}")
234
+ return 0
235
+
236
+
237
+ def cmd_logs(args: argparse.Namespace) -> int:
238
+ log = _DATA_DIR / "logs" / "orchestrator.log"
239
+ if not log.exists():
240
+ print(f"No log file yet at {log}")
241
+ return 1
242
+
243
+ def tail() -> list[str]:
244
+ return log.read_text(encoding="utf-8", errors="replace").splitlines()[-args.lines:]
245
+
246
+ for line in tail():
247
+ print(line)
248
+ if args.follow:
249
+ seen = log.stat().st_size
250
+ try:
251
+ while True:
252
+ time.sleep(1.0)
253
+ size = log.stat().st_size
254
+ if size > seen:
255
+ with log.open("r", encoding="utf-8", errors="replace") as f:
256
+ f.seek(seen)
257
+ print(f.read(), end="")
258
+ seen = size
259
+ except KeyboardInterrupt:
260
+ pass
261
+ return 0
262
+
263
+
264
+ # ----------------------------------------------------------- backup/restore
265
+
266
+
267
+ def _iter_backup_files(repo_root: Path):
268
+ for prefix in _BACKUP_PREFIXES:
269
+ root = repo_root / prefix
270
+ if not root.is_dir():
271
+ continue
272
+ for path in sorted(root.rglob("*")):
273
+ if not path.is_file():
274
+ continue
275
+ rel = path.relative_to(repo_root).as_posix()
276
+ parts = set(path.relative_to(root).parts[:-1])
277
+ if parts & _BACKUP_EXCLUDE_DIRS:
278
+ continue
279
+ if path.suffix in _BACKUP_EXCLUDE_SUFFIXES:
280
+ continue
281
+ yield path, rel
282
+
283
+
284
+ def create_backup(repo_root: Path = _REPO_ROOT, out_path: Path | None = None) -> Path:
285
+ """Zip the vault (runtime dirs included) and data stores.
286
+
287
+ SQLite files are snapshotted via the backup API so a live orchestrator
288
+ never yields a torn copy.
289
+ """
290
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
291
+ if out_path is None:
292
+ out_path = repo_root / "backups" / f"agentmetry-backup-{stamp}.zip"
293
+ out_path.parent.mkdir(parents=True, exist_ok=True)
294
+
295
+ count = 0
296
+ with tempfile.TemporaryDirectory() as tmp, zipfile.ZipFile(
297
+ out_path, "w", zipfile.ZIP_DEFLATED
298
+ ) as zf:
299
+ for path, rel in _iter_backup_files(repo_root):
300
+ if path.suffix == ".db":
301
+ snapshot = Path(tmp) / f"{count}-{path.name}"
302
+ src = sqlite3.connect(str(path))
303
+ try:
304
+ dst = sqlite3.connect(str(snapshot))
305
+ with dst:
306
+ src.backup(dst)
307
+ dst.close()
308
+ finally:
309
+ src.close()
310
+ zf.write(snapshot, rel)
311
+ else:
312
+ zf.write(path, rel)
313
+ count += 1
314
+ # ASCII only: Windows consoles often run cp1252.
315
+ print(f"Backed up {count} files -> {out_path}")
316
+ return out_path
317
+
318
+
319
+ def restore_backup(zip_path: Path, repo_root: Path = _REPO_ROOT) -> int:
320
+ """Extract a backup over vault/ and data/. Zip-slip guarded."""
321
+ allowed_roots = [(repo_root / prefix).resolve() for prefix in _BACKUP_PREFIXES]
322
+
323
+ def _validated_target(name: str) -> Path:
324
+ normalized = name.replace("\\", "/")
325
+ target = (repo_root / normalized).resolve()
326
+ # Resolved containment, not string prefixes: rejects vault/../evil.txt.
327
+ if not any(root == target or root in target.parents for root in allowed_roots):
328
+ raise ValueError(f"Backup member outside allowed roots: {name}")
329
+ return target
330
+
331
+ with zipfile.ZipFile(zip_path) as zf:
332
+ members = [n for n in zf.namelist() if not n.endswith("/")]
333
+ targets = {name: _validated_target(name) for name in members} # validate all first
334
+ for name, target in targets.items():
335
+ target.parent.mkdir(parents=True, exist_ok=True)
336
+ with zf.open(name) as src, target.open("wb") as dst:
337
+ shutil.copyfileobj(src, dst)
338
+ print(f"Restored {len(members)} files from {zip_path}")
339
+ return len(members)
340
+
341
+
342
+ def cmd_backup(args: argparse.Namespace) -> int:
343
+ create_backup(out_path=Path(args.out) if args.out else None)
344
+ return 0
345
+
346
+
347
+ def cmd_restore(args: argparse.Namespace) -> int:
348
+ if _fetch_health(args.port):
349
+ print("Refusing to restore while Agentmetry is running — run 'agentmetry stop' first.")
350
+ return 1
351
+ zip_path = Path(args.backup_zip)
352
+ if not zip_path.exists():
353
+ print(f"No such backup: {zip_path}")
354
+ return 1
355
+ # Safety net: snapshot current state before overwriting it.
356
+ pre = create_backup(
357
+ out_path=_REPO_ROOT / "backups" / f"pre-restore-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}.zip"
358
+ )
359
+ print(f"Current state saved to {pre}")
360
+ restore_backup(zip_path)
361
+ return 0
362
+
363
+
364
+ # --------------------------------------------------------- install/uninstall
365
+
366
+
367
+ def cmd_install(args: argparse.Namespace) -> int:
368
+ """Register the orchestrator to start by itself and stay up.
369
+
370
+ This used to be Windows-only, and used to start at logon with no restart
371
+ policy, so a crashed recorder stayed dead until the next logon. Both are
372
+ fixed. It is still opt-in: a persistent background process is the user's
373
+ decision, and `doctor` only points at this command rather than running it.
374
+ """
375
+ from agentmetry.core.diagnostics import autostart
376
+
377
+ current = autostart.status()
378
+ # "Already configured" is the right answer only for a registration that
379
+ # works. A broken one used to get the same reply, which sent the operator
380
+ # to the command they had just run: doctor said run install, install said
381
+ # already installed, and the recorder stayed down. Re-registering is how a
382
+ # stale launch command gets repaired, so a failing task is exactly when
383
+ # this must not short-circuit.
384
+ if current.configured and current.healthy is not False:
385
+ print(f"Already configured via {current.backend}: {current.detail}")
386
+ return 0
387
+ if current.configured:
388
+ print(f"Re-registering a failing autostart ({current.backend}): {current.detail}")
389
+
390
+ ok, message = autostart.install()
391
+ print(message)
392
+ if not ok:
393
+ return 1
394
+ print("The recorder now starts by itself. Check it with `agentmetry doctor`.")
395
+ return 0
396
+
397
+
398
+ def cmd_uninstall(args: argparse.Namespace) -> int:
399
+ from agentmetry.core.diagnostics import autostart
400
+
401
+ current = autostart.status()
402
+ if not current.configured:
403
+ print(f"Nothing to remove ({current.backend}: {current.detail}).")
404
+ return 0
405
+
406
+ ok, message = autostart.remove()
407
+ print(message)
408
+ if not ok:
409
+ return 1
410
+ print(
411
+ "Autostart removed. The hooks keep capturing whether or not the recorder "
412
+ "is up, but events only reach the trail while it is running."
413
+ )
414
+ return 0
415
+
416
+
417
+ # ------------------------------------------------------------------- export
418
+
419
+
420
+ def _write_compliance_digest(from_date, to_date, args: argparse.Namespace) -> int:
421
+ """Periodic governance summary — the artifact a reviewer files monthly."""
422
+ import json
423
+
424
+ from agentmetry.core.audit.compliance_digest import build_digest, render_markdown
425
+ from agentmetry.core.audit.evidence_pack import default_export_path
426
+
427
+ digest = build_digest(from_date, to_date)
428
+ as_json = getattr(args, "json", False)
429
+
430
+ if args.output:
431
+ out = Path(args.output)
432
+ else:
433
+ out = default_export_path(from_date, to_date).with_name(
434
+ f"digest-{from_date.isoformat()}_to_{to_date.isoformat()}"
435
+ f".{'json' if as_json else 'md'}"
436
+ )
437
+
438
+ out.parent.mkdir(parents=True, exist_ok=True)
439
+ body = (
440
+ json.dumps(digest, indent=2, default=str) + "\n"
441
+ if as_json
442
+ else render_markdown(digest)
443
+ )
444
+ out.write_text(body, encoding="utf-8")
445
+
446
+ activity = digest["activity"]
447
+ oversight = digest["oversight"]
448
+ print(f"Compliance digest -> {out}")
449
+ print(
450
+ f" {activity['events']} events, {activity['sessions']} sessions, "
451
+ f"{activity['tool_denials']} denials"
452
+ )
453
+ print(
454
+ f" {len(digest['findings'])} distinct finding(s); "
455
+ f"{oversight['inferred']}/{oversight['approval_gates']} approvals inferred"
456
+ )
457
+ return 0
458
+
459
+
460
+ def cmd_export(args: argparse.Namespace) -> int:
461
+ from agentmetry.core.audit.evidence_pack import (
462
+ build_evidence_pack,
463
+ default_export_path,
464
+ parse_date,
465
+ write_evidence_pack,
466
+ )
467
+
468
+ digest_mode = getattr(args, "compliance_digest", False)
469
+ if not args.evidence and not digest_mode:
470
+ print(
471
+ "Use: agentmetry export --evidence --from YYYY-MM-DD --to YYYY-MM-DD\n"
472
+ " or: agentmetry export --compliance-digest --from … --to …"
473
+ )
474
+ return 1
475
+ if not args.date_from or not args.date_to:
476
+ print("--from and --to are required (YYYY-MM-DD)")
477
+ return 1
478
+
479
+ try:
480
+ from_date = parse_date(args.date_from)
481
+ to_date = parse_date(args.date_to)
482
+ except ValueError as exc:
483
+ print(f"Invalid date: {exc}")
484
+ return 1
485
+
486
+ if digest_mode:
487
+ return _write_compliance_digest(from_date, to_date, args)
488
+
489
+ pack = build_evidence_pack(from_date, to_date)
490
+ out = Path(args.output) if args.output else default_export_path(from_date, to_date)
491
+ write_evidence_pack(pack, out)
492
+
493
+ summary = pack.get("summary", {})
494
+ print(f"Evidence pack -> {out}")
495
+ print(
496
+ f" {summary.get('event_count', 0)} events, "
497
+ f"{summary.get('sessions', 0)} sessions, "
498
+ f"{summary.get('tool_calls', 0)} tool calls, "
499
+ f"{summary.get('tool_denials', 0)} denials"
500
+ )
501
+ print(
502
+ f" {summary.get('approval_gates', 0)} approval gates "
503
+ f"({summary.get('approvals_inferred', 0)} inferred), "
504
+ f"{summary.get('detections', 0)} detections"
505
+ )
506
+ chain = pack["meta"].get("trail_chain", {})
507
+ if chain.get("head_sha256"):
508
+ print(f" chain head: seq {chain.get('head_seq')} {chain['head_sha256'][:16]}…")
509
+ print(f" integrity: {pack['meta']['integrity_sha256'][:16]}…")
510
+ return 0
511
+
512
+
513
+ def cmd_import_agt(args: argparse.Namespace) -> int:
514
+ """Ingest a Microsoft Agent Governance Toolkit audit file into the trail.
515
+
516
+ AGT decides allow or deny per call; this says what a session of those calls
517
+ adds up to. In testing it read three calls AGT had individually allowed and
518
+ raised one critical `credential-exfil` across them.
519
+ """
520
+ import os
521
+
522
+ from agentmetry.core.audit.adapters.agt import agt_file_to_canonical
523
+ from agentmetry.core.audit.trail_chain import append_chained_line
524
+ from agentmetry.core.config import settings
525
+
526
+ path = Path(args.path)
527
+ if not path.is_file():
528
+ print(f"No such file: {path}")
529
+ return 1
530
+
531
+ key: bytes | None = None
532
+ raw_key = args.key or os.environ.get("AGENTMETRY_AGT_HMAC_KEY", "")
533
+ if raw_key:
534
+ key = raw_key.encode()
535
+
536
+ result, events = agt_file_to_canonical(
537
+ path,
538
+ secret_key=key,
539
+ host_id=args.host_id or settings.operator_id or "",
540
+ fleet_id=settings.fleet_id or "",
541
+ )
542
+
543
+ if not result.ok:
544
+ # Nothing is imported. Writing an unverified record into a hash-chained
545
+ # trail would have the chain vouch for a claim nobody checked.
546
+ print(f"FAILED — {result.message}")
547
+ print(" Nothing imported. The trail does not launder unverified records.")
548
+ return 1
549
+
550
+ print(f"OK — {result.message}")
551
+ if key is None:
552
+ print(
553
+ " No HMAC key supplied, so signatures were not checked. Hashes and "
554
+ "chain linkage were, which catches editing and reordering. Pass --key "
555
+ "or set AGENTMETRY_AGT_HMAC_KEY to check forgery too."
556
+ )
557
+
558
+ if args.dry_run:
559
+ print(f" Dry run: {len(events)} event(s) would be appended.")
560
+ from agentmetry.core.audit.detection.engine import run_detections
561
+
562
+ detections = run_detections(events)
563
+ for detection in detections:
564
+ print(f" [{detection.severity}] {detection.rule_id}: {detection.title}")
565
+ if not detections:
566
+ print(" No detections would fire on this session.")
567
+ return 0
568
+
569
+ trail = Path(settings.audit_export_path)
570
+ for event in events:
571
+ append_chained_line(trail, event)
572
+ print(f" Appended {len(events)} event(s) to {trail}")
573
+ print(" Marked source.tier=external, app=agt: Agentmetry read this record "
574
+ "rather than observing the calls.")
575
+ return 0
576
+
577
+
578
+ def cmd_prove(args: argparse.Namespace) -> int:
579
+ """Produce or check a Merkle inclusion proof for one trail record.
580
+
581
+ The point of the separate command is disclosure. Handing an auditor the
582
+ trail to prove one tool call happened also hands them every other tool call,
583
+ which is why "just send the log" is not an answer anyone likes giving. A
584
+ proof is the one record plus about log2(n) sibling hashes.
585
+ """
586
+ import json
587
+
588
+ from agentmetry.core.audit.trail_merkle import (
589
+ InclusionProof,
590
+ build_proof,
591
+ merkle_root,
592
+ record_root,
593
+ verify_proof,
594
+ )
595
+
596
+ path = Path(args.path)
597
+
598
+ if args.check:
599
+ proof_path = Path(args.check)
600
+ if not proof_path.is_file():
601
+ print(f"No such proof file: {proof_path}")
602
+ return 1
603
+ try:
604
+ proof = InclusionProof.from_dict(json.loads(proof_path.read_text(encoding="utf-8")))
605
+ except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
606
+ print(f"Not a readable proof: {exc}")
607
+ return 1
608
+ expected = args.root
609
+ if not expected and path.is_file():
610
+ # At the proof's tree size, not the current one. The trail is
611
+ # append-only and live, so today's root is not the root the proof
612
+ # was issued against and comparing them fails for no useful reason.
613
+ try:
614
+ expected, _ = merkle_root(path, tree_size=proof.tree_size)
615
+ except ValueError as exc:
616
+ print(f"FAILED — {exc}")
617
+ return 1
618
+ ok, message = verify_proof(proof, expected_root=expected)
619
+ print(("OK — " if ok else "FAILED — ") + message)
620
+ if ok and not args.root:
621
+ print(
622
+ " Supply --root with a value you recorded elsewhere to make this "
623
+ "a real check; a trail can always vouch for itself."
624
+ )
625
+ return 0 if ok else 1
626
+
627
+ if not path.is_file():
628
+ print(f"No such file: {path}")
629
+ return 1
630
+ if args.record_root:
631
+ result = record_root(path)
632
+ print(f"Recorded merkle root {result['root']}")
633
+ print(f" tree size: {result['tree_size']}")
634
+ print(f" sidecar: {result['sidecar']}")
635
+ return 0
636
+ if args.seq is None:
637
+ print("Give --seq N to prove a record, --record-root to store the root, "
638
+ "or --check PROOF to verify one.")
639
+ return 1
640
+
641
+ try:
642
+ proof = build_proof(path, args.seq)
643
+ except ValueError as exc:
644
+ print(str(exc))
645
+ return 1
646
+
647
+ payload = json.dumps(proof.to_dict(), indent=2)
648
+ if args.out:
649
+ Path(args.out).write_text(payload + "\n", encoding="utf-8")
650
+ print(f"Wrote proof for seq {proof.seq} to {args.out}")
651
+ print(f" root: {proof.root_sha256}")
652
+ print(f" path length: {len(proof.path)} of tree size {proof.tree_size}")
653
+ else:
654
+ print(payload)
655
+ return 0
656
+
657
+
658
+ def cmd_verify(args: argparse.Namespace) -> int:
659
+ import json
660
+
661
+ path = Path(args.path)
662
+ if not path.exists():
663
+ print(f"No such file: {path}")
664
+ return 1
665
+
666
+ if getattr(args, "trail", False):
667
+ from agentmetry.core.audit.trail_chain import verify_trail_file
668
+
669
+ result = verify_trail_file(path)
670
+ if result.ok:
671
+ print(f"OK — {result.message}")
672
+ if result.lines_total:
673
+ print(
674
+ f" lines: {result.lines_total} total, "
675
+ f"{result.lines_chained} chained, {result.lines_legacy} legacy"
676
+ )
677
+ if result.head_sha256:
678
+ # Record this pair somewhere the audited agent cannot write
679
+ # (a git commit, a note) — comparing it later is the only
680
+ # defense against someone deleting the newest lines.
681
+ print(f" head: seq {result.head_seq}, sha256 {result.head_sha256}")
682
+
683
+ from agentmetry.core.audit.trail_merkle import merkle_root
684
+
685
+ root, size = merkle_root(path)
686
+ if size:
687
+ # The head proves the file is intact end to end. The root is
688
+ # what lets a single event be proved later without handing over
689
+ # the file, so it is the more useful of the two to publish.
690
+ print(f" merkle root: {root}")
691
+ print(f" tree size: {size} (rfc6962-sha256)")
692
+ return 0
693
+ print(f"FAILED — {result.message}")
694
+ if result.first_bad_line:
695
+ print(f" first bad line: {result.first_bad_line}")
696
+ return 1
697
+
698
+ from agentmetry.core.audit.evidence_pack import verify_evidence_pack
699
+
700
+ try:
701
+ pack = json.loads(path.read_text(encoding="utf-8"))
702
+ except json.JSONDecodeError as exc:
703
+ print(f"Invalid JSON: {exc}")
704
+ return 1
705
+
706
+ ok, message = verify_evidence_pack(pack)
707
+ if ok:
708
+ print(f"OK — {message}")
709
+ meta = pack.get("meta", {})
710
+ print(
711
+ f" {meta.get('date_from')} .. {meta.get('date_to')} "
712
+ f"schema {meta.get('schema_version')}"
713
+ )
714
+ return 0
715
+ print(f"FAILED — {message}")
716
+ return 1
717
+
718
+
719
+ def cmd_replay(args: argparse.Namespace) -> int:
720
+ sys.path.insert(0, str(_ORCH_ROOT))
721
+ from agentmetry.core.audit.replay import format_timeline
722
+ from agentmetry.core.bus.outbox import get_outbox
723
+
724
+ thread_id = args.thread_id.strip()
725
+ if not thread_id:
726
+ print("thread_id is required")
727
+ return 1
728
+ rows = get_outbox().read_by_thread_id(thread_id)
729
+ print(format_timeline(rows, thread_id=thread_id))
730
+ return 0 if rows else 1
731
+
732
+
733
+ def cmd_dogfood(args: argparse.Namespace) -> int:
734
+ """Score the dogfood period, or start the clock.
735
+
736
+ The beta gate is four consecutive green weeks. It went unstarted for weeks
737
+ because checking a week meant a twenty-minute manual pass, so it never got
738
+ checked. This makes the question cheap enough to actually ask.
739
+ """
740
+ sys.path.insert(0, str(_ORCH_ROOT))
741
+ from agentmetry.core.audit.dogfood import assess, read_marker, render, start_clock
742
+
743
+ if getattr(args, "start", False):
744
+ existing = read_marker()
745
+ if existing and not getattr(args, "restart", False):
746
+ print(f"Clock already started {existing['started_utc']}. "
747
+ "Use --restart to reset it, which discards the current run.")
748
+ return 1
749
+ from agentmetry.core.config import settings
750
+
751
+ marker = start_clock(operator=settings.operator_id)
752
+ print(f"Dogfood clock started {marker['started_utc']}.")
753
+ print("Check progress any time with: agentmetry dogfood")
754
+ return 0
755
+
756
+ report = assess()
757
+ print(render(report))
758
+ # Exit non-zero only when a *finished* week failed, so this can be run as a
759
+ # weekly check without crying wolf on day one for the crime of not yet
760
+ # having four weeks of history.
761
+ return 1 if any(w.complete and not w.green for w in report.weeks) else 0
762
+
763
+
764
+ def cmd_benchmark(args: argparse.Namespace) -> int:
765
+ """Replay the recorded detection corpus and score the rules.
766
+
767
+ Exists so the product's central claim is checkable rather than asserted.
768
+ Anyone can clone this repo and run it: which rules fired on which recorded
769
+ sessions, and how many times they fired on benign ones. A false-positive
770
+ count you publish is worth more than a detection count you assert.
771
+ """
772
+ sys.path.insert(0, str(_ORCH_ROOT))
773
+ from agentmetry.core.audit.detection.benchmark import render_report, run_benchmark
774
+
775
+ report = run_benchmark(getattr(args, "corpus", None))
776
+ print(render_report(report))
777
+ return 0 if report.passed else 1
778
+
779
+
780
+ def cmd_doctor(args: argparse.Namespace) -> int:
781
+ """SIEM preflight: manifests, trail chain, orchestrator health, hooks."""
782
+ sys.path.insert(0, str(_ORCH_ROOT))
783
+ from agentmetry.core.diagnostics.doctor import format_report, run_doctor
784
+
785
+ report = run_doctor(fix_drivers=getattr(args, "fix", False))
786
+ print("Agentmetry doctor\n" + format_report(report))
787
+ return report.exit_code
788
+
789
+
790
+ # ---------------------------------------------------------------------- main
791
+
792
+
793
+ def main(argv: list[str] | None = None) -> int:
794
+ # A Windows console defaults to cp1252 and cannot encode the dashes this CLI
795
+ # prints, so `verify --trail` rendered as "OK ? 422 chained line(s)". That is
796
+ # the flagship trust command; it must not look broken on the primary dogfood
797
+ # platform. Same guard as scripts/demo.py.
798
+ try:
799
+ sys.stdout.reconfigure(encoding="utf-8") # type: ignore[union-attr]
800
+ except Exception: # pragma: no cover - depends on the host console
801
+ pass
802
+
803
+ parser = argparse.ArgumentParser(prog="agentmetry", description="Agentmetry local ops")
804
+ parser.add_argument("--port", type=int, default=8000)
805
+ sub = parser.add_subparsers(dest="command", required=True)
806
+
807
+ sub.add_parser("stop", help="stop the orchestrator")
808
+ start = sub.add_parser("start", help="start the orchestrator (detached)")
809
+ start.add_argument(
810
+ "--host",
811
+ default="127.0.0.1",
812
+ help="bind address — use 0.0.0.0 for phone/LAN access (default: 127.0.0.1)",
813
+ )
814
+ serve = sub.add_parser(
815
+ "serve",
816
+ help="run the orchestrator in the foreground (what autostart registers)",
817
+ )
818
+ serve.add_argument("--host", default="127.0.0.1")
819
+ serve.add_argument("--port", type=int, default=8000)
820
+ sub.add_parser("status", help="orchestrator health and audit export status")
821
+ stats = sub.add_parser("stats", help="audit trail metrics for dogfood (events, detections)")
822
+ stats.add_argument("--days", type=int, default=7)
823
+ logs = sub.add_parser("logs", help="tail the orchestrator log")
824
+ logs.add_argument("-n", "--lines", type=int, default=50)
825
+ logs.add_argument("-f", "--follow", action="store_true")
826
+ backup = sub.add_parser("backup", help="zip vault + data stores")
827
+ backup.add_argument("--out", default=None)
828
+ restore = sub.add_parser("restore", help="restore a backup (server must be stopped)")
829
+ restore.add_argument("backup_zip")
830
+ sub.add_parser(
831
+ "install",
832
+ help="keep the recorder running: at-logon start plus restart on failure",
833
+ )
834
+ sub.add_parser("uninstall", help="remove the autostart registration")
835
+ export = sub.add_parser("export", help="export audit artifacts")
836
+ export.add_argument(
837
+ "--evidence", action="store_true",
838
+ help="build EU AI Act-oriented evidence pack (JSON)",
839
+ )
840
+ export.add_argument(
841
+ "--compliance-digest", dest="compliance_digest", action="store_true",
842
+ help="periodic governance summary for control review (Markdown)",
843
+ )
844
+ export.add_argument(
845
+ "--json", action="store_true",
846
+ help="with --compliance-digest: emit JSON instead of Markdown",
847
+ )
848
+ export.add_argument("--from", dest="date_from", metavar="DATE", required=False)
849
+ export.add_argument("--to", dest="date_to", metavar="DATE", required=False)
850
+ export.add_argument("-o", "--output", default=None, help="output path (default: vault/30-Archive/exports/)")
851
+ import_agt = sub.add_parser(
852
+ "import-agt",
853
+ help="ingest a Microsoft Agent Governance Toolkit audit file into the trail",
854
+ )
855
+ import_agt.add_argument("path", help="AGT FileAuditSink JSONL")
856
+ import_agt.add_argument(
857
+ "--key", default=None,
858
+ help="HMAC secret key, to verify signatures as well as hashes "
859
+ "(or AGENTMETRY_AGT_HMAC_KEY)",
860
+ )
861
+ import_agt.add_argument("--host-id", dest="host_id", default="", help="host to attribute events to")
862
+ import_agt.add_argument(
863
+ "--dry-run", dest="dry_run", action="store_true",
864
+ help="verify and show what would fire, without writing to the trail",
865
+ )
866
+ prove = sub.add_parser(
867
+ "prove",
868
+ help="Merkle inclusion proof for one trail record (prove an event without the file)",
869
+ )
870
+ prove.add_argument("path", help="JSONL trail file")
871
+ prove.add_argument("--seq", type=int, default=None, help="record sequence number to prove")
872
+ prove.add_argument("-o", "--out", default=None, help="write the proof JSON here")
873
+ prove.add_argument("--check", metavar="PROOF", default=None, help="verify a proof file")
874
+ prove.add_argument(
875
+ "--root", default=None,
876
+ help="with --check: the root you recorded elsewhere. Without it a trail vouches for itself.",
877
+ )
878
+ prove.add_argument(
879
+ "--record-root", dest="record_root", action="store_true",
880
+ help="recompute the root and store it in the chain sidecar",
881
+ )
882
+ verify = sub.add_parser("verify", help="verify evidence pack or JSONL trail chain")
883
+ verify.add_argument(
884
+ "path",
885
+ help="evidence JSON file, or JSONL trail with --trail",
886
+ )
887
+ verify.add_argument(
888
+ "--trail",
889
+ action="store_true",
890
+ help="verify tamper-evident hash chain on an audit JSONL file",
891
+ )
892
+ doctor = sub.add_parser(
893
+ "doctor", help="SIEM preflight (manifests, trail chain, health, hooks)"
894
+ )
895
+ doctor.add_argument(
896
+ "--fix",
897
+ action="store_true",
898
+ help="rewrite drivers.json to portable {PYTHON}/{VAULT_PATH} tokens",
899
+ )
900
+ benchmark = sub.add_parser(
901
+ "benchmark",
902
+ help="replay the recorded detection corpus and score the rules",
903
+ )
904
+ benchmark.add_argument(
905
+ "--corpus",
906
+ type=Path,
907
+ default=None,
908
+ help="corpus directory (default: the corpus shipped inside the package)",
909
+ )
910
+ dogfood = sub.add_parser(
911
+ "dogfood", help="score the four-week dogfood gate, or start the clock"
912
+ )
913
+ dogfood.add_argument("--start", action="store_true", help="start the clock today")
914
+ dogfood.add_argument(
915
+ "--restart", action="store_true", help="with --start, discard the current run"
916
+ )
917
+ replay = sub.add_parser("replay", help="ASCII timeline of audit events for one run")
918
+ replay.add_argument("thread_id", help="correlation_id / session id to replay from audit trail")
919
+
920
+ args = parser.parse_args(argv)
921
+ handlers = {
922
+ "start": cmd_start,
923
+ "serve": cmd_serve,
924
+ "stop": cmd_stop,
925
+ "status": cmd_status,
926
+ "stats": cmd_stats,
927
+ "logs": cmd_logs,
928
+ "backup": cmd_backup,
929
+ "restore": cmd_restore,
930
+ "install": cmd_install,
931
+ "uninstall": cmd_uninstall,
932
+ "export": cmd_export,
933
+ "verify": cmd_verify,
934
+ "prove": cmd_prove,
935
+ "import-agt": cmd_import_agt,
936
+ "doctor": cmd_doctor,
937
+ "benchmark": cmd_benchmark,
938
+ "dogfood": cmd_dogfood,
939
+ "replay": cmd_replay,
940
+ }
941
+ return handlers[args.command](args)