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,394 @@
1
+ """Drain the hook spool.
2
+
3
+ The hook client posts events to the ingest API and must never block or crash the
4
+ IDE, so a failed POST used to print to stderr and drop the event. That punched
5
+ holes in the trail on every orchestrator restart, update, and IDE-launch race —
6
+ in a product whose entire claim is a complete record of what the agent did.
7
+
8
+ The hook now appends unreachable payloads to `data/hook-spool.jsonl`; this
9
+ module replays them through the normal ingest path so spooled events get the
10
+ same canonical build, detection correlation, and sink forwarding as live ones.
11
+ Replay is ordered by spool time, which is the order the hooks fired.
12
+
13
+ Non-fatal by design, like the JSONL backfill next door: a broken spool must never
14
+ stop the recorder from booting.
15
+
16
+ Three properties this module has to hold, each of which it failed to hold once:
17
+
18
+ **Draining must not delete what it never read.** The first version read the whole
19
+ file, replayed it, then unlinked the path. A drain of a few thousand events takes
20
+ minutes, and the hooks keep appending the whole time, so the unlink destroyed
21
+ every event captured during the drain — silently, and worst on exactly the busy
22
+ machines the spool exists to protect. The file is now rotated aside first: hooks
23
+ immediately start a fresh spool, and only the rotated copy is ever deleted.
24
+
25
+ **Draining must not be the reason the recorder is unreachable.** Awaiting the
26
+ drain inside the FastAPI lifespan kept the ingest port closed until it finished,
27
+ so every hook firing during a large drain was refused and spooled, which made the
28
+ next drain larger. The boot drain now runs as a background task.
29
+
30
+ **An expired payload must leave a scar.** Payloads past `MAX_AGE_SECONDS` are not
31
+ replayed, because injecting a week-old tool call into today's correlation window
32
+ produces false sequences, which is worse than a gap. They are moved to
33
+ `hook-spool.expired.jsonl` rather than deleted. A gap in an audit trail is a fact
34
+ about the audit trail, and this product does not get to quietly forget one.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import asyncio
40
+ import json
41
+ import logging
42
+ import os
43
+ import time
44
+ from datetime import datetime, timedelta, timezone
45
+ from pathlib import Path
46
+ from typing import Any
47
+
48
+ from agentmetry.core.config import settings
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+ # Matches scripts/agentmetry_ingest.py — a payload older than this is not
53
+ # replayed. It is quarantined, never discarded.
54
+ MAX_AGE_SECONDS = 7 * 24 * 3600
55
+
56
+ # How often the background drain looks for work once the recorder is up.
57
+ DRAIN_INTERVAL_SECONDS = 60
58
+
59
+ # Windows will refuse the rename while a hook process has the file open for
60
+ # append. That window is one short write, so a few retries clear it.
61
+ _ROTATE_ATTEMPTS = 5
62
+ _ROTATE_BACKOFF_SECONDS = 0.2
63
+
64
+ # One drain at a time. The boot drain and the periodic drain would otherwise
65
+ # rotate and replay the same file concurrently and double every event in it.
66
+ _drain_lock = asyncio.Lock()
67
+
68
+
69
+ def spool_path() -> Path:
70
+ return Path(settings.audit_export_path).parent / "hook-spool.jsonl"
71
+
72
+
73
+ def expired_path(path: Path | None = None) -> Path:
74
+ target = path or spool_path()
75
+ return target.with_name("hook-spool.expired.jsonl")
76
+
77
+
78
+ def _draining_glob(path: Path) -> str:
79
+ return path.name + ".draining.*"
80
+
81
+
82
+ def _pending_files(path: Path) -> list[Path]:
83
+ """Rotated files a previous drain did not finish, oldest first.
84
+
85
+ Names embed a monotonic-enough timestamp, so lexical order is chronological
86
+ order, which is the order the hooks fired.
87
+ """
88
+ try:
89
+ return sorted(path.parent.glob(_draining_glob(path)))
90
+ except OSError:
91
+ return []
92
+
93
+
94
+ # ----------------------------------------------------------------------
95
+ # Inspection — read-only, safe to call from doctor/dogfood/API handlers
96
+ # ----------------------------------------------------------------------
97
+
98
+
99
+ def _count_lines(target: Path) -> int:
100
+ if not target.is_file():
101
+ return 0
102
+ total = 0
103
+ try:
104
+ with target.open("rb") as fh:
105
+ for chunk in iter(lambda: fh.read(1 << 20), b""):
106
+ total += chunk.count(b"\n")
107
+ except OSError:
108
+ return 0
109
+ return total
110
+
111
+
112
+ def spool_depth(path: Path | None = None) -> int:
113
+ """Events waiting to be replayed, including any mid-drain rotated files.
114
+
115
+ Counts newlines rather than parsing, so this stays cheap enough to call on
116
+ every status poll.
117
+ """
118
+ target = path or spool_path()
119
+ return _count_lines(target) + sum(_count_lines(p) for p in _pending_files(target))
120
+
121
+
122
+ def _first_spooled_at(target: Path) -> datetime | None:
123
+ if not target.is_file():
124
+ return None
125
+ try:
126
+ with target.open("r", encoding="utf-8", errors="replace") as fh:
127
+ for line in fh:
128
+ line = line.strip()
129
+ if not line:
130
+ continue
131
+ try:
132
+ row = json.loads(line)
133
+ except json.JSONDecodeError:
134
+ continue
135
+ ts = _parse_ts(str((row or {}).get("spooled_at") or ""))
136
+ if ts is not None:
137
+ return ts
138
+ except OSError:
139
+ return None
140
+ return None
141
+
142
+
143
+ def spool_oldest_age_seconds(path: Path | None = None) -> float | None:
144
+ """Age of the oldest pending payload, or None when nothing is pending.
145
+
146
+ This is the number that matters operationally: depth tells you how much is
147
+ waiting, age tells you how close it is to being unreplayable.
148
+ """
149
+ target = path or spool_path()
150
+ candidates = [*_pending_files(target), target]
151
+ oldest: datetime | None = None
152
+ for candidate in candidates:
153
+ ts = _first_spooled_at(candidate)
154
+ if ts is not None and (oldest is None or ts < oldest):
155
+ oldest = ts
156
+ if oldest is None:
157
+ return None
158
+ return max(0.0, (datetime.now(timezone.utc) - oldest).total_seconds())
159
+
160
+
161
+ def _parse_ts(raw: str) -> datetime | None:
162
+ if not raw:
163
+ return None
164
+ try:
165
+ ts = datetime.fromisoformat(raw.replace("Z", "+00:00"))
166
+ except ValueError:
167
+ return None
168
+ if ts.tzinfo is None:
169
+ ts = ts.replace(tzinfo=timezone.utc)
170
+ return ts
171
+
172
+
173
+ def _too_old(spooled_at: str, *, now: datetime) -> bool:
174
+ ts = _parse_ts(spooled_at)
175
+ if ts is None:
176
+ # An unparseable or absent timestamp is not evidence of age. Replay it;
177
+ # a duplicate is recoverable and a silent drop is not.
178
+ return False
179
+ return ts < now - timedelta(seconds=MAX_AGE_SECONDS)
180
+
181
+
182
+ def read_spool(path: Path | None = None) -> tuple[list[dict[str, Any]], int]:
183
+ """Return (replayable payloads, unreplayable count) without touching the file.
184
+
185
+ Pure inspection: `doctor` and the dogfood report call this to size the
186
+ backlog, and neither should have a side effect on the trail.
187
+ """
188
+ target = path or spool_path()
189
+ payloads, expired, corrupt = _parse_file(target)
190
+ return payloads, len(expired) + corrupt
191
+
192
+
193
+ def _parse_file(target: Path) -> tuple[list[dict[str, Any]], list[str], int]:
194
+ """(replayable payloads, raw expired lines, corrupt line count)."""
195
+ if not target.is_file():
196
+ return [], [], 0
197
+
198
+ now = datetime.now(timezone.utc)
199
+ payloads: list[dict[str, Any]] = []
200
+ expired: list[str] = []
201
+ corrupt = 0
202
+ try:
203
+ with target.open("r", encoding="utf-8", errors="replace") as fh:
204
+ for line in fh:
205
+ stripped = line.strip()
206
+ if not stripped:
207
+ continue
208
+ try:
209
+ row = json.loads(stripped)
210
+ except json.JSONDecodeError:
211
+ corrupt += 1
212
+ continue
213
+ if not isinstance(row, dict):
214
+ corrupt += 1
215
+ continue
216
+ payload = row.get("payload")
217
+ if not isinstance(payload, dict):
218
+ corrupt += 1
219
+ continue
220
+ spooled_at = str(row.get("spooled_at") or "")
221
+ if _too_old(spooled_at, now=now):
222
+ expired.append(stripped)
223
+ continue
224
+ # When the tool call happened, not when we got round to it. The
225
+ # orchestrator falls back to its own clock for an event with no
226
+ # timestamp, so replaying a five-day-old spool used to record
227
+ # every event as having happened during the replay. `spooled_at`
228
+ # is written by the hook at capture, so it is the closest thing
229
+ # to the truth we still hold. Hooks now send `timestamp_utc`
230
+ # themselves; this covers spools written before they did.
231
+ if spooled_at:
232
+ payload.setdefault("timestamp_utc", spooled_at)
233
+ payloads.append(payload)
234
+ except OSError:
235
+ logger.exception("Could not read hook spool at %s", target)
236
+ return [], [], corrupt
237
+ return payloads, expired, corrupt
238
+
239
+
240
+ # ----------------------------------------------------------------------
241
+ # Draining — mutates the spool, one caller at a time
242
+ # ----------------------------------------------------------------------
243
+
244
+
245
+ def _rotate(path: Path) -> Path | None:
246
+ """Move the live spool aside so hooks keep appending to a fresh file.
247
+
248
+ Returns the rotated path, or None when there was nothing to rotate or the
249
+ rename could not be taken. Failing to rotate is safe: nothing has been read
250
+ and nothing has been deleted, so the next drain tries again.
251
+ """
252
+ if not path.is_file() or path.stat().st_size == 0:
253
+ return None
254
+
255
+ stamp = time.strftime("%Y%m%dT%H%M%S", time.gmtime())
256
+ for attempt in range(_ROTATE_ATTEMPTS):
257
+ target = path.with_name(f"{path.name}.draining.{stamp}.{attempt}")
258
+ try:
259
+ os.replace(path, target)
260
+ return target
261
+ except OSError:
262
+ time.sleep(_ROTATE_BACKOFF_SECONDS)
263
+ logger.warning(
264
+ "Could not rotate hook spool at %s; leaving it for the next drain", path
265
+ )
266
+ return None
267
+
268
+
269
+ def _quarantine(lines: list[str], *, path: Path) -> int:
270
+ """Append expired raw lines to the quarantine file. Never deletes."""
271
+ if not lines:
272
+ return 0
273
+ target = expired_path(path)
274
+ try:
275
+ target.parent.mkdir(parents=True, exist_ok=True)
276
+ with target.open("a", encoding="utf-8") as fh:
277
+ for line in lines:
278
+ fh.write(line + "\n")
279
+ except OSError:
280
+ logger.exception(
281
+ "Could not quarantine %d expired spool payload(s) to %s; keeping them "
282
+ "in place rather than dropping them",
283
+ len(lines),
284
+ target,
285
+ )
286
+ return 0
287
+ return len(lines)
288
+
289
+
290
+ async def _drain_one(target: Path) -> dict[str, int]:
291
+ from agentmetry.core.audit.ingest import ingest_external_event
292
+
293
+ payloads, expired, corrupt = _parse_file(target)
294
+ quarantined = _quarantine(expired, path=target)
295
+
296
+ replayed = 0
297
+ failed = 0
298
+ for payload in payloads:
299
+ try:
300
+ await ingest_external_event(payload)
301
+ replayed += 1
302
+ except Exception:
303
+ failed += 1
304
+
305
+ # Only let go of the rotated file once every payload in it is accounted for.
306
+ # Expired payloads count as accounted for exactly when they reached
307
+ # quarantine; if that write failed, keep the file.
308
+ if failed == 0 and quarantined == len(expired):
309
+ _remove(target)
310
+ else:
311
+ logger.warning(
312
+ "Hook spool: %d payload(s) failed to replay and %d expired payload(s) "
313
+ "could not be quarantined; keeping %s",
314
+ failed,
315
+ len(expired) - quarantined,
316
+ target,
317
+ )
318
+
319
+ return {
320
+ "replayed": replayed,
321
+ "failed": failed,
322
+ "expired": quarantined,
323
+ "corrupt": corrupt,
324
+ }
325
+
326
+
327
+ async def drain_spool(path: Path | None = None) -> dict[str, int]:
328
+ """Replay spooled hook payloads through the ingest path. Returns counts.
329
+
330
+ Picks up rotated files a previous drain left behind before rotating the live
331
+ spool, so a crash mid-drain resumes rather than restarts.
332
+
333
+ Replay is idempotent on `event_id`, except that spooled payloads have no
334
+ `event_id` yet (it is minted in `build_external_canonical`), so an
335
+ interrupted drain can duplicate. Duplicated events are visible and harmless;
336
+ a lost event is neither. That trade is deliberate.
337
+ """
338
+ target = path or spool_path()
339
+
340
+ if _drain_lock.locked():
341
+ logger.debug("Hook spool drain already in progress; skipping this pass")
342
+ return {"replayed": 0, "failed": 0, "expired": 0, "corrupt": 0, "skipped": 1}
343
+
344
+ async with _drain_lock:
345
+ pending = _pending_files(target)
346
+ rotated = _rotate(target)
347
+ if rotated is not None:
348
+ pending.append(rotated)
349
+
350
+ totals = {"replayed": 0, "failed": 0, "expired": 0, "corrupt": 0}
351
+ for candidate in pending:
352
+ result = await _drain_one(candidate)
353
+ for key in totals:
354
+ totals[key] += result[key]
355
+
356
+ if any(totals.values()):
357
+ logger.info(
358
+ "Hook spool drained: %d replayed, %d failed, %d expired, %d corrupt",
359
+ totals["replayed"],
360
+ totals["failed"],
361
+ totals["expired"],
362
+ totals["corrupt"],
363
+ )
364
+ return totals
365
+
366
+
367
+ async def drain_forever(interval: float = DRAIN_INTERVAL_SECONDS) -> None:
368
+ """Background task: keep the spool drained for as long as the recorder runs.
369
+
370
+ A boot-only drain means an orchestrator that stays up while the network path
371
+ to it breaks accumulates a backlog until someone happens to restart it. The
372
+ recorder should heal without a human noticing there was anything to heal.
373
+ """
374
+ while True:
375
+ try:
376
+ await asyncio.sleep(interval)
377
+ result = await drain_spool()
378
+ if result.get("replayed"):
379
+ logger.info(
380
+ "Hook spool: replayed %d event(s) captured while ingest was "
381
+ "unreachable",
382
+ result["replayed"],
383
+ )
384
+ except asyncio.CancelledError:
385
+ raise
386
+ except Exception:
387
+ logger.exception("Periodic hook spool drain failed; will retry")
388
+
389
+
390
+ def _remove(path: Path) -> None:
391
+ try:
392
+ path.unlink()
393
+ except OSError:
394
+ logger.exception("Could not remove drained hook spool at %s", path)
@@ -0,0 +1,4 @@
1
+ from .evaluator import evaluate, reset_policy
2
+ from .models import ToolPolicyMatch, ToolPolicyRule, ToolPolicyVerdict
3
+
4
+ __all__ = ["ToolPolicyMatch", "ToolPolicyRule", "ToolPolicyVerdict", "evaluate", "reset_policy"]
@@ -0,0 +1,198 @@
1
+ import fnmatch
2
+ import json
3
+ import logging
4
+ import re
5
+ from typing import Any
6
+
7
+ from .loader import load_tool_policy
8
+ from .models import ToolPolicyMatch, ToolPolicyRule, ToolPolicyVerdict
9
+ from ...config import settings
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ _POLICY: tuple[list[ToolPolicyRule], str] | None = None
14
+
15
+
16
+ def reset_policy() -> None:
17
+ """Clear cached policy (tests / live reload)."""
18
+ global _POLICY
19
+ _POLICY = None
20
+
21
+
22
+ def _init_policy() -> tuple[list[ToolPolicyRule], str]:
23
+ global _POLICY
24
+ if _POLICY is None:
25
+ _POLICY = load_tool_policy(settings.tool_policy_path)
26
+ return _POLICY
27
+
28
+
29
+ def _norm(name: str) -> str:
30
+ return name.lower().replace("_", "").replace("-", "")
31
+
32
+
33
+ def _tool_matches(pattern: str, qualified: str) -> bool:
34
+ q = qualified.lower()
35
+ p = pattern.lower()
36
+ if fnmatch.fnmatch(q, p):
37
+ return True
38
+ short = q.split(".")[-1] if "." in q else q
39
+ return fnmatch.fnmatch(short, p) or fnmatch.fnmatch(_norm(short), _norm(p))
40
+
41
+
42
+ _COMMAND_KEYS = ("command", "cmd", "script", "CommandLine")
43
+ # Mirrors extract_command in scripts/agentmetry_ingest.py, so a policy can target
44
+ # a file path (agent config, hooks) and not only a shell string.
45
+ _PATH_KEYS = ("path", "filepath", "file_path", "AbsolutePath", "TargetFile", "target_path")
46
+
47
+
48
+ def _nested_arg_containers(hook_data: dict[str, Any]) -> tuple[list[dict[str, Any]], str]:
49
+ """Every dict an IDE may hide tool arguments in, plus a raw string fallback.
50
+
51
+ Cursor shell hooks put `command` at the top level, but Claude and Codex nest
52
+ it under `tool_input` and Antigravity under `toolCall.args`. Without those,
53
+ a `command_pattern` rule matched nothing on three of the four supported IDEs
54
+ — the shipped block_shell_rm rule was Cursor-only in practice.
55
+ """
56
+ containers: list[dict[str, Any]] = []
57
+ raw = ""
58
+ for key in ("arguments", "args", "input", "tool_input", "toolInput"):
59
+ val = hook_data.get(key)
60
+ if isinstance(val, str):
61
+ try:
62
+ val = json.loads(val)
63
+ except json.JSONDecodeError:
64
+ raw = raw or val
65
+ continue
66
+ if isinstance(val, dict):
67
+ containers.append(val)
68
+ tool_call = hook_data.get("toolCall")
69
+ if isinstance(tool_call, dict) and isinstance(tool_call.get("args"), dict):
70
+ containers.append(tool_call["args"])
71
+ return containers, raw
72
+
73
+
74
+ def _extract_command(hook_data: dict[str, Any] | str, qualified: str = "") -> str:
75
+ if isinstance(hook_data, str):
76
+ return hook_data
77
+ if not isinstance(hook_data, dict):
78
+ return ""
79
+
80
+ nested, raw = _nested_arg_containers(hook_data)
81
+
82
+ for key in _COMMAND_KEYS:
83
+ val = hook_data.get(key)
84
+ if val is not None and str(val).strip():
85
+ return str(val)
86
+ for container in nested:
87
+ for key in (*_COMMAND_KEYS, "value"):
88
+ val = container.get(key)
89
+ if val is not None and str(val).strip():
90
+ return str(val)
91
+ if raw:
92
+ return raw
93
+
94
+ q = (qualified or "").lower()
95
+ if q.endswith(".run_command") or q in ("bash", "shell.run", "shell"):
96
+ val = hook_data.get("value")
97
+ if val is not None and str(val).strip():
98
+ return str(val)
99
+
100
+ # Path fallback: lets a rule deny writes to agent-execution config.
101
+ for container in (hook_data, *nested):
102
+ for key in _PATH_KEYS:
103
+ val = container.get(key)
104
+ if val is not None and str(val).strip():
105
+ return str(val)
106
+ return ""
107
+
108
+
109
+ def _server_matches(rule: ToolPolicyRule, server: str) -> bool:
110
+ if not rule.servers:
111
+ return True
112
+ if not server:
113
+ return False
114
+ s = server.lower()
115
+ return any(fnmatch.fnmatch(s, pat.lower()) for pat in rule.servers)
116
+
117
+
118
+ def _rule_matches(rule: ToolPolicyRule, qualified: str, server: str, command: str) -> bool:
119
+ if not rule.id or not rule.tools:
120
+ return False
121
+ if not any(_tool_matches(pat, qualified) for pat in rule.tools):
122
+ return False
123
+ if not _server_matches(rule, server):
124
+ return False
125
+ if rule.command_pattern:
126
+ if not command:
127
+ return False
128
+ try:
129
+ if not re.search(rule.command_pattern, command):
130
+ return False
131
+ except re.error as exc:
132
+ logger.warning("[tool_policy] invalid regex for rule %s: %s", rule.id, exc)
133
+ return False
134
+ return True
135
+
136
+
137
+ def evaluate(
138
+ tool_qualified: str,
139
+ hook_data: dict[str, Any] | str,
140
+ *,
141
+ server: str = "",
142
+ mode: str | None = None,
143
+ ) -> ToolPolicyVerdict:
144
+ """Evaluate tool allow/deny policy. Runs on plaintext hook data before hashing."""
145
+ if mode is None:
146
+ mode = settings.tool_policy_mode
147
+ if mode == "disable":
148
+ return ToolPolicyVerdict(matched=False, blocked=False, mode=mode)
149
+
150
+ rules, default_action = _init_policy()
151
+ if not rules and default_action == "allow":
152
+ return ToolPolicyVerdict(matched=False, blocked=False, mode=mode)
153
+
154
+ command = _extract_command(hook_data, tool_qualified)
155
+ deny_hits: list[ToolPolicyRule] = []
156
+ allow_hits: list[ToolPolicyRule] = []
157
+
158
+ for rule in rules:
159
+ if _rule_matches(rule, tool_qualified, server, command):
160
+ if rule.action == "deny":
161
+ deny_hits.append(rule)
162
+ else:
163
+ allow_hits.append(rule)
164
+
165
+ if default_action == "allow":
166
+ if deny_hits:
167
+ hit = deny_hits[0]
168
+ return ToolPolicyVerdict(
169
+ matched=True,
170
+ blocked=True,
171
+ mode=mode,
172
+ match=ToolPolicyMatch(rule_id=hit.id, action="deny"),
173
+ )
174
+ return ToolPolicyVerdict(matched=False, blocked=False, mode=mode)
175
+
176
+ # default deny — must match an allow rule and not be overridden by deny
177
+ if deny_hits:
178
+ hit = deny_hits[0]
179
+ return ToolPolicyVerdict(
180
+ matched=True,
181
+ blocked=True,
182
+ mode=mode,
183
+ match=ToolPolicyMatch(rule_id=hit.id, action="deny"),
184
+ )
185
+ if allow_hits:
186
+ hit = allow_hits[0]
187
+ return ToolPolicyVerdict(
188
+ matched=True,
189
+ blocked=False,
190
+ mode=mode,
191
+ match=ToolPolicyMatch(rule_id=hit.id, action="allow"),
192
+ )
193
+ return ToolPolicyVerdict(
194
+ matched=True,
195
+ blocked=True,
196
+ mode=mode,
197
+ match=ToolPolicyMatch(rule_id="default_deny", action="deny"),
198
+ )
@@ -0,0 +1,44 @@
1
+ import yaml
2
+ from pathlib import Path
3
+
4
+ from .models import ToolPolicyRule
5
+
6
+
7
+ def load_tool_policy(manifest_path: Path | str) -> tuple[list[ToolPolicyRule], str]:
8
+ """Load tool policy rules and default action (allow | deny) from YAML."""
9
+ path = Path(manifest_path)
10
+ if not path.exists():
11
+ return [], "allow"
12
+
13
+ with open(path, encoding="utf-8") as fh:
14
+ data = yaml.safe_load(fh)
15
+
16
+ if not data or "rules" not in data:
17
+ return [], str(data.get("default", "allow") if data else "allow")
18
+
19
+ default = str(data.get("default", "allow")).lower()
20
+ if default not in ("allow", "deny"):
21
+ default = "allow"
22
+
23
+ rules: list[ToolPolicyRule] = []
24
+ for raw in data["rules"]:
25
+ action = str(raw.get("action", "deny")).lower()
26
+ if action not in ("allow", "deny"):
27
+ continue
28
+ tools = raw.get("tools") or []
29
+ if isinstance(tools, str):
30
+ tools = [tools]
31
+ servers = raw.get("servers") or []
32
+ if isinstance(servers, str):
33
+ servers = [servers]
34
+ rules.append(
35
+ ToolPolicyRule(
36
+ id=str(raw.get("id", "")),
37
+ action=action,
38
+ tools=[str(t) for t in tools],
39
+ command_pattern=str(raw.get("command_pattern", "") or ""),
40
+ servers=[str(s) for s in servers],
41
+ description=str(raw.get("description", "") or ""),
42
+ )
43
+ )
44
+ return rules, default
@@ -0,0 +1,25 @@
1
+ from dataclasses import dataclass, field
2
+
3
+
4
+ @dataclass
5
+ class ToolPolicyRule:
6
+ id: str
7
+ action: str # allow | deny
8
+ tools: list[str] = field(default_factory=list)
9
+ command_pattern: str = ""
10
+ servers: list[str] = field(default_factory=list)
11
+ description: str = ""
12
+
13
+
14
+ @dataclass
15
+ class ToolPolicyMatch:
16
+ rule_id: str
17
+ action: str
18
+
19
+
20
+ @dataclass
21
+ class ToolPolicyVerdict:
22
+ matched: bool
23
+ blocked: bool
24
+ mode: str = "disable"
25
+ match: ToolPolicyMatch | None = None