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,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,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
|