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,398 @@
1
+ """Four green weeks: is the recorder actually being used, and answered?
2
+
3
+ The beta gate is four consecutive green dogfood weeks. It has never started, and
4
+ the reason is worth naming: there was no way to answer "was this week green?"
5
+ short of a twenty-minute manual checklist, so it never got asked.
6
+
7
+ A week is green on four counts, and each one exists because its absence is a
8
+ failure that looks like success:
9
+
10
+ * **Capture continuity.** Silence is indistinguishable from a quiet week. A
11
+ recorder that was switched off produces exactly the same empty trail as a
12
+ developer on holiday, and only the calendar knows which. Active days are
13
+ counted so a gap has to be explained rather than assumed benign.
14
+ * **Chain integrity.** A trail that no longer verifies is not evidence.
15
+ * **Triage.** Detections raised and never answered mean the loop is running open.
16
+ This is the count that separates a product being *used* from one merely being
17
+ *installed*.
18
+ * **No spool backlog.** A spool that keeps growing means the orchestrator is not
19
+ coming up, and the operator is looking at a healthy trail that is quietly
20
+ missing sessions.
21
+
22
+ The verdict is deliberately harsh about capture gaps and deliberately lenient
23
+ about volume. A slow week is fine. A week the recorder missed is not.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import hashlib
29
+ import json
30
+ from dataclasses import dataclass, field
31
+ from datetime import date, datetime, timedelta, timezone
32
+ from pathlib import Path
33
+ from typing import Any
34
+
35
+ #: A working week, not seven days. Nobody should fail the gate for resting.
36
+ MIN_ACTIVE_DAYS = 3
37
+
38
+ #: Severities that must not be left unanswered.
39
+ MUST_TRIAGE = frozenset({"critical", "high"})
40
+
41
+ MARKER_NAME = "dogfood.json"
42
+
43
+
44
+ def marker_path() -> Path:
45
+ from agentmetry.core.config import settings
46
+
47
+ return Path(settings.audit_db_path).parent / MARKER_NAME
48
+
49
+
50
+ def read_marker() -> dict[str, Any] | None:
51
+ path = marker_path()
52
+ if not path.is_file():
53
+ return None
54
+ try:
55
+ return json.loads(path.read_text(encoding="utf-8"))
56
+ except (json.JSONDecodeError, OSError):
57
+ return None
58
+
59
+
60
+ # Files whose contents define what a detection means. A change to any of them
61
+ # changes what the gate is measuring, which is why the fingerprint covers whole
62
+ # sources rather than just the rule id list: today's two fixes changed severities
63
+ # and trait classification without adding or removing a single rule.
64
+ _RULESET_SOURCES = (
65
+ "core/audit/detection/rules.py",
66
+ "core/audit/detection/traits.py",
67
+ "core/audit/detection/engine.py",
68
+ "core/audit/mitre.py",
69
+ )
70
+
71
+
72
+ def _package_root() -> Path:
73
+ """The `agentmetry` package directory. Fingerprinted paths are relative to it."""
74
+ return Path(__file__).resolve().parents[2]
75
+
76
+
77
+ def ruleset_fingerprint() -> str:
78
+ """A hash of everything that decides whether a detection fires, and how hard.
79
+
80
+ The gate exists to produce a number worth quoting. Four green weeks measured
81
+ against four different rulesets is not that number, and nothing was stopping
82
+ it: three detection changes shipped into week one on the day the clock
83
+ started, each one an improvement, and collectively they made the week
84
+ meaningless.
85
+
86
+ A promise to freeze the rules is the same kind of promise as an install
87
+ command nobody runs. This makes the freeze checkable.
88
+
89
+ Whole-file hashing is deliberately blunt. It flags a comment-only edit as a
90
+ change, which is a small annoyance, and it cannot miss a real one, which is
91
+ the point. Being told to look is cheap; a silently invalidated month is not.
92
+ """
93
+ digest = hashlib.sha256()
94
+ root = _package_root()
95
+ for rel in _RULESET_SOURCES:
96
+ path = root / rel
97
+ digest.update(rel.encode("utf-8"))
98
+ try:
99
+ digest.update(path.read_bytes())
100
+ except OSError:
101
+ digest.update(b"<unreadable>")
102
+
103
+ # The YAML rules are ruleset too, and they are the half an operator is most
104
+ # likely to edit without thinking of it as changing the product.
105
+ try:
106
+ from agentmetry.core.config import settings
107
+
108
+ manifest = Path(settings.detection_rules_path)
109
+ digest.update(b"detection-manifest")
110
+ digest.update(manifest.read_bytes() if manifest.is_file() else b"<absent>")
111
+ except Exception:
112
+ digest.update(b"<manifest-unavailable>")
113
+
114
+ return digest.hexdigest()
115
+
116
+
117
+ def start_clock(when: date | None = None, *, operator: str = "") -> dict[str, Any]:
118
+ """Record the start of the dogfood period.
119
+
120
+ Deliberately an explicit act rather than inferred from the first event. The
121
+ gate is a commitment to watch for four weeks, and a commitment nobody made
122
+ on a particular day is one nobody is keeping.
123
+
124
+ The ruleset fingerprint is recorded here so the commitment includes what was
125
+ being measured, not just when the measuring began.
126
+ """
127
+ marker = {
128
+ "started_utc": (when or datetime.now(timezone.utc).date()).isoformat(),
129
+ "operator": operator,
130
+ "ruleset_fingerprint": ruleset_fingerprint(),
131
+ "note": "Four consecutive green weeks. See core/audit/dogfood.py for what green means.",
132
+ }
133
+ path = marker_path()
134
+ path.parent.mkdir(parents=True, exist_ok=True)
135
+ path.write_text(json.dumps(marker, indent=2) + "\n", encoding="utf-8")
136
+ return marker
137
+
138
+
139
+ @dataclass
140
+ class Week:
141
+ index: int
142
+ start: date
143
+ end: date
144
+ events: int = 0
145
+ active_days: int = 0
146
+ sessions: int = 0
147
+ detections: int = 0
148
+ untriaged: int = 0
149
+ reasons: list[str] = field(default_factory=list)
150
+ complete: bool = True
151
+
152
+ @property
153
+ def green(self) -> bool:
154
+ return not self.reasons
155
+
156
+ @property
157
+ def verdict(self) -> str:
158
+ if not self.complete:
159
+ return "IN PROGRESS"
160
+ return "GREEN" if self.green else "RED"
161
+
162
+
163
+ @dataclass
164
+ class DogfoodReport:
165
+ started: date | None
166
+ weeks: list[Week] = field(default_factory=list)
167
+ chain_ok: bool = True
168
+ chain_message: str = ""
169
+ spooled: int = 0
170
+ # False when the detection rules have changed since the clock started, which
171
+ # means the weeks measured different products and cannot be added together.
172
+ ruleset_frozen: bool = True
173
+
174
+ @property
175
+ def consecutive_green(self) -> int:
176
+ """Green weeks in an unbroken run ending at the most recent complete week."""
177
+ run = 0
178
+ for week in self.weeks:
179
+ if not week.complete:
180
+ continue
181
+ if week.green:
182
+ run += 1
183
+ else:
184
+ run = 0
185
+ return run
186
+
187
+ @property
188
+ def passed(self) -> bool:
189
+ # A changed ruleset does not turn individual weeks red, because the
190
+ # operator's behaviour that week was real and worth recording. It does
191
+ # stop the run adding up to a pass, because four weeks measured against
192
+ # four different rulesets is not four weeks of evidence about one
193
+ # product.
194
+ return self.consecutive_green >= 4 and self.ruleset_frozen
195
+
196
+ def as_dict(self) -> dict[str, Any]:
197
+ return {
198
+ "started": self.started.isoformat() if self.started else None,
199
+ "consecutive_green": self.consecutive_green,
200
+ "required": 4,
201
+ "passed": self.passed,
202
+ "chain_ok": self.chain_ok,
203
+ "chain_message": self.chain_message,
204
+ "spooled": self.spooled,
205
+ "ruleset_frozen": self.ruleset_frozen,
206
+ "weeks": [
207
+ {
208
+ "index": w.index,
209
+ "start": w.start.isoformat(),
210
+ "end": w.end.isoformat(),
211
+ "events": w.events,
212
+ "active_days": w.active_days,
213
+ "sessions": w.sessions,
214
+ "detections": w.detections,
215
+ "untriaged": w.untriaged,
216
+ "complete": w.complete,
217
+ "verdict": w.verdict,
218
+ "reasons": w.reasons,
219
+ }
220
+ for w in self.weeks
221
+ ],
222
+ }
223
+
224
+
225
+ def _week_bounds(start: date, index: int) -> tuple[date, date]:
226
+ begin = start + timedelta(days=7 * index)
227
+ return begin, begin + timedelta(days=6)
228
+
229
+
230
+ def assess(
231
+ *,
232
+ trail_db: Any | None = None,
233
+ started: date | None = None,
234
+ today: date | None = None,
235
+ ) -> DogfoodReport:
236
+ """Score every week since the clock started."""
237
+ from agentmetry.core.audit.detection.disposition import get_disposition_store
238
+
239
+ if trail_db is None:
240
+ from agentmetry.core.audit.trail_db import get_trail_db
241
+
242
+ trail_db = get_trail_db()
243
+
244
+ marker = read_marker()
245
+ if started is None and marker:
246
+ started = date.fromisoformat(marker["started_utc"])
247
+
248
+ report = DogfoodReport(started=started)
249
+ # A marker written before fingerprints existed has nothing to compare, and
250
+ # assuming it drifted would be inventing a failure. Absence is not evidence.
251
+ recorded = (marker or {}).get("ruleset_fingerprint")
252
+ if recorded:
253
+ report.ruleset_frozen = recorded == ruleset_fingerprint()
254
+ _attach_health(report)
255
+ if started is None:
256
+ return report
257
+
258
+ now = today or datetime.now(timezone.utc).date()
259
+ events = trail_db.read_between(
260
+ datetime.combine(started, datetime.min.time(), timezone.utc).isoformat(),
261
+ datetime.combine(now + timedelta(days=1), datetime.min.time(), timezone.utc).isoformat(),
262
+ )
263
+
264
+ store = get_disposition_store()
265
+ total_weeks = max(1, ((now - started).days // 7) + 1)
266
+
267
+ for index in range(total_weeks):
268
+ begin, end = _week_bounds(started, index)
269
+ week = Week(index=index + 1, start=begin, end=end, complete=end < now)
270
+
271
+ days: set[str] = set()
272
+ sessions: set[str] = set()
273
+ for event in events:
274
+ stamp = str(event.get("timestamp_utc") or "")[:10]
275
+ if not stamp or not (begin.isoformat() <= stamp <= end.isoformat()):
276
+ continue
277
+ week.events += 1
278
+ days.add(stamp)
279
+ if event.get("correlation_id"):
280
+ sessions.add(str(event["correlation_id"]))
281
+
282
+ action = event.get("action") or {}
283
+ if action.get("type") != "detection":
284
+ continue
285
+ detection = event.get("detection") or {}
286
+ week.detections += 1
287
+ if str(action.get("outcome") or "").lower() not in MUST_TRIAGE:
288
+ continue
289
+ current = store.get(
290
+ str(event.get("correlation_id") or ""),
291
+ str(detection.get("rule_id") or ""),
292
+ )
293
+ if current is None or current["status"] == "new":
294
+ week.untriaged += 1
295
+
296
+ week.active_days = len(days)
297
+ week.sessions = len(sessions)
298
+
299
+ if week.complete:
300
+ if week.active_days < MIN_ACTIVE_DAYS:
301
+ week.reasons.append(
302
+ f"only {week.active_days} active day(s); the recorder was not "
303
+ "running, or was not being used"
304
+ )
305
+ if week.untriaged:
306
+ week.reasons.append(
307
+ f"{week.untriaged} critical/high detection(s) never dispositioned"
308
+ )
309
+ report.weeks.append(week)
310
+
311
+ if not report.chain_ok:
312
+ for week in report.weeks:
313
+ week.reasons.append("trail chain does not verify")
314
+ if report.spooled:
315
+ for week in report.weeks[-1:]:
316
+ week.reasons.append(
317
+ f"{report.spooled} event(s) stuck in the hook spool; the "
318
+ "orchestrator is not draining them"
319
+ )
320
+ return report
321
+
322
+
323
+ def _attach_health(report: DogfoodReport) -> None:
324
+ from agentmetry.core.config import settings
325
+
326
+ trail = Path(settings.audit_export_path)
327
+ if trail.is_file():
328
+ from agentmetry.core.audit.trail_chain import verify_trail_file
329
+
330
+ result = verify_trail_file(trail)
331
+ report.chain_ok = result.ok
332
+ report.chain_message = result.message
333
+ else:
334
+ # No trail file is not a broken chain. On day one there is nothing to
335
+ # verify, and failing the gate for that would make the first week
336
+ # unpassable by construction. A week with no events is already caught by
337
+ # the active-days check, which is the honest place for it.
338
+ report.chain_ok = True
339
+ report.chain_message = "no trail file yet"
340
+
341
+ try:
342
+ from agentmetry.core.audit.spool import read_spool, spool_path
343
+
344
+ path = spool_path()
345
+ if path.is_file():
346
+ payloads, _dropped = read_spool(path)
347
+ report.spooled = len(payloads)
348
+ except Exception:
349
+ report.spooled = 0
350
+
351
+
352
+ def render(report: DogfoodReport) -> str:
353
+ if report.started is None:
354
+ return (
355
+ "Dogfood clock has not started.\n\n"
356
+ " Start it with: agentmetry dogfood --start\n\n"
357
+ " Four consecutive green weeks is the beta gate. A week is green when\n"
358
+ " the recorder ran on at least three days, the trail chain verifies,\n"
359
+ " every critical or high detection was dispositioned, and no events\n"
360
+ " are stuck in the hook spool.\n"
361
+ )
362
+
363
+ lines = [
364
+ f"Dogfood clock started {report.started.isoformat()}",
365
+ "",
366
+ f" {'Week':<6}{'Dates':<26}{'Days':>5}{'Events':>9}{'Sessions':>10}"
367
+ f"{'Detect':>8}{'Untriaged':>11} Verdict",
368
+ ]
369
+ for week in report.weeks:
370
+ lines.append(
371
+ f" {week.index:<6}"
372
+ f"{week.start.isoformat()} to {week.end.isoformat():<7}"
373
+ f"{week.active_days:>5}{week.events:>9}{week.sessions:>10}"
374
+ f"{week.detections:>8}{week.untriaged:>11} {week.verdict}"
375
+ )
376
+ for reason in week.reasons:
377
+ lines.append(f" - {reason}")
378
+
379
+ lines += ["", f" Consecutive green weeks: {report.consecutive_green} of 4"]
380
+ if report.passed:
381
+ lines.append(" Gate PASSED.")
382
+ else:
383
+ remaining = 4 - report.consecutive_green
384
+ lines.append(f" {remaining} more green week(s) needed.")
385
+
386
+ if not report.ruleset_frozen:
387
+ lines += [
388
+ "",
389
+ " RULESET CHANGED since the clock started, so these weeks did not all",
390
+ " measure the same product and the run cannot pass. Land rule changes",
391
+ " and restart the clock: agentmetry dogfood --start --restart",
392
+ ]
393
+
394
+ if not report.chain_ok:
395
+ lines.append(f" Trail chain: BROKEN - {report.chain_message}")
396
+ if report.spooled:
397
+ lines.append(f" Spool backlog: {report.spooled} event(s) pending replay")
398
+ return "\n".join(lines) + "\n"