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