agentdynamics 0.7.0__tar.gz → 0.8.0__tar.gz
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.
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/PKG-INFO +1 -1
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/__init__.py +1 -1
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/__main__.py +36 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/alerts.py +24 -3
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/api/governance.py +10 -1
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/config.py +4 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/engine.py +84 -1
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/integrations/aegis.py +158 -5
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/pg.py +2 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/server.py +49 -5
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/store.py +39 -1
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/web/pages/governance.js +39 -1
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics.egg-info/PKG-INFO +1 -1
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics.egg-info/SOURCES.txt +1 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/pyproject.toml +1 -1
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/tests/test_aegis_integration.py +118 -0
- agentdynamics-0.8.0/tests/test_revocations.py +200 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/tests/test_scoped_keys.py +2 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/LICENSE +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/README.md +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/analysis.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/api/__init__.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/api/assess.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/api/base.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/api/diagnose.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/api/monitor.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/api/ops.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/autotrace.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/bootstrap/sitecustomize.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/collectors/__init__.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/collectors/aegis_audit.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/collectors/claude_code.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/collectors/generic.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/collectors/inbox.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/collectors/langfuse.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/collectors/langsmith.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/collectors/otlp.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/collectors/spans.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/flows.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/govern.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/integrations/__init__.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/phases.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/pricing.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/privacy.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/sdk.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/slo.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/web/app.js +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/web/charts.js +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/web/index.html +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/web/pages/assess.js +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/web/pages/configure.js +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/web/pages/diagnose.js +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/web/pages/monitor.js +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/web/style.css +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics.egg-info/dependency_links.txt +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics.egg-info/entry_points.txt +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics.egg-info/requires.txt +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics.egg-info/top_level.txt +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/setup.cfg +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/tests/test_alerts.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/tests/test_autotrace.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/tests/test_console_ui.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/tests/test_core.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/tests/test_ecosystem.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/tests/test_incremental.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/tests/test_integrations.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/tests/test_live_anthropic.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/tests/test_outcomes.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/tests/test_policy_coverage.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/tests/test_postgres.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/tests/test_rollups.py +0 -0
- {agentdynamics-0.7.0 → agentdynamics-0.8.0}/tests/test_token_accounting.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentdynamics
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Application performance monitoring for AI agents: flows, cost, reliability and process quality
|
|
5
5
|
License-Expression: Apache-2.0
|
|
6
6
|
Project-URL: Homepage, https://github.com/Aditya31398/agentdynamics
|
|
@@ -187,6 +187,32 @@ def cmd_alerts(a, data):
|
|
|
187
187
|
return 1 if failed else 0
|
|
188
188
|
|
|
189
189
|
|
|
190
|
+
def cmd_revoke(a, data):
|
|
191
|
+
from . import config as cfgmod, store
|
|
192
|
+
con = store.connect(*store.target(cfgmod.load(data), data))
|
|
193
|
+
now = time.time()
|
|
194
|
+
if a.clear:
|
|
195
|
+
n = store.clear_revocation(con, a.clear, now)
|
|
196
|
+
print("cleared" if n else f"no active directive {a.clear}")
|
|
197
|
+
return 0 if n else 1
|
|
198
|
+
if a.list:
|
|
199
|
+
rows_ = store.revocations(con, now)
|
|
200
|
+
for d in rows_:
|
|
201
|
+
status = "cleared" if d["cleared"] else "expired" if d["expires"] <= now else "active"
|
|
202
|
+
print(f"{d['id']} {status:<8} {d['project'] or '(all projects)':<18} {d['agent'] or '(all agents)':<18} "
|
|
203
|
+
f"until {time.strftime('%Y-%m-%d %H:%M', time.localtime(d['expires']))} [{d['source']}] {d['reason']}")
|
|
204
|
+
if not rows_:
|
|
205
|
+
print("no directives")
|
|
206
|
+
return 0
|
|
207
|
+
if not a.reason:
|
|
208
|
+
print("--reason is required: it goes into the kernel's audit log")
|
|
209
|
+
return 2
|
|
210
|
+
rid = store.add_revocation(con, a.project, a.agent, a.reason, "operator", now, now + a.minutes * 60)
|
|
211
|
+
print(f"{rid}: {'agent ' + a.agent if a.agent else 'every agent'} in {a.project or 'every project'} is revoked "
|
|
212
|
+
f"for {a.minutes:g} min, in processes using agentdynamics.integrations.aegis.instrument(..., revocations=True)")
|
|
213
|
+
return 0
|
|
214
|
+
|
|
215
|
+
|
|
190
216
|
def cmd_keys(a, data):
|
|
191
217
|
from .config import keys_path, load_keys, save_keys
|
|
192
218
|
keys = load_keys(data)
|
|
@@ -373,6 +399,14 @@ def main(argv=None):
|
|
|
373
399
|
al.add_argument("action", choices=["status", "test"],
|
|
374
400
|
help="status: destinations, queue and firing SLO alerts; test: send a test alert to each")
|
|
375
401
|
al.add_argument("--to", metavar="NAME", help="test only this destination (its name, as status shows it)")
|
|
402
|
+
rv = sub.add_parser("revoke", help="revoke an agent's Aegis grants wherever it runs (applied in-process by "
|
|
403
|
+
"agentdynamics.integrations.aegis), or list/clear directives")
|
|
404
|
+
rv.add_argument("--agent", help="the agent (grant) name; omit to revoke every grant in the project")
|
|
405
|
+
rv.add_argument("--project", help="omit for every project")
|
|
406
|
+
rv.add_argument("--reason", help="why (it goes into the kernel's audit log)")
|
|
407
|
+
rv.add_argument("--minutes", type=float, default=60, help="how long new grants of that agent are refused too")
|
|
408
|
+
rv.add_argument("--list", action="store_true", help="list directives")
|
|
409
|
+
rv.add_argument("--clear", metavar="ID", help="stop a directive (grants it revoked stay revoked)")
|
|
376
410
|
sub.add_parser("ingest", help="scan sources once and rebuild the database")
|
|
377
411
|
rp = sub.add_parser("report", help="print a text summary")
|
|
378
412
|
rp.add_argument("--project")
|
|
@@ -397,6 +431,8 @@ def main(argv=None):
|
|
|
397
431
|
return cmd_keys(a, a.data)
|
|
398
432
|
if cmd == "alerts":
|
|
399
433
|
return cmd_alerts(a, a.data)
|
|
434
|
+
if cmd == "revoke":
|
|
435
|
+
return cmd_revoke(a, a.data)
|
|
400
436
|
|
|
401
437
|
from .engine import Engine
|
|
402
438
|
eng = Engine(a.data, a.claude_root or None)
|
|
@@ -10,7 +10,8 @@ Destinations are `[[alerts.webhooks]]` in agentdynamics.toml:
|
|
|
10
10
|
format = "pagerduty" # json (default) | slack | pagerduty
|
|
11
11
|
routing_key_env = "PD_ROUTING_KEY" # PagerDuty Events API v2 integration key (or routing_key = "...")
|
|
12
12
|
min_severity = "critical" # info | warning (default) | critical
|
|
13
|
-
kinds = ["events", "slos"] # default ["events"]; "slos" adds SLO burn-rate alerts
|
|
13
|
+
kinds = ["events", "slos"] # default ["events"]; "slos" adds SLO burn-rate alerts,
|
|
14
|
+
# "revocations" the directives that stop an agent (server-side #8)
|
|
14
15
|
projects = ["checkout"] # optional: only these projects
|
|
15
16
|
rules = ["run_failed"] # optional: only these health rules
|
|
16
17
|
|
|
@@ -37,7 +38,7 @@ from collections import OrderedDict
|
|
|
37
38
|
from urllib.parse import quote
|
|
38
39
|
|
|
39
40
|
FORMATS = ("json", "slack", "pagerduty")
|
|
40
|
-
KINDS = ("events", "slos")
|
|
41
|
+
KINDS = ("events", "slos", "revocations")
|
|
41
42
|
SEV = {"info": 1, "warning": 2, "critical": 3}
|
|
42
43
|
# https://raw.githubusercontent.com/PagerDuty/api-schema/main/reference/events-v2/openapiv3.json
|
|
43
44
|
PAGERDUTY_URL = "https://events.pagerduty.com/v2/enqueue"
|
|
@@ -135,6 +136,15 @@ def from_slo(key, c, action, now):
|
|
|
135
136
|
"project": (c.get("scope") or {}).get("project") or None, "slo": c["slo"], "ts": now, "condition": c}
|
|
136
137
|
|
|
137
138
|
|
|
139
|
+
def from_revocation(d):
|
|
140
|
+
who = f"agent {d['agent']}" if d.get("agent") else "every agent"
|
|
141
|
+
where = f" in project {d['project']}" if d.get("project") else ""
|
|
142
|
+
until = time.strftime("%Y-%m-%d %H:%M", time.localtime(d["expires"]))
|
|
143
|
+
return {"kind": "revocation", "action": "trigger", "key": f"revocation/{d['id']}", "severity": "critical",
|
|
144
|
+
"summary": f"Revoked {who}{where} until {until}: {d['reason']}", "project": d.get("project"),
|
|
145
|
+
"ts": d["created"], "revocation": d}
|
|
146
|
+
|
|
147
|
+
|
|
138
148
|
# ---------------------------------------------------------------- formats
|
|
139
149
|
|
|
140
150
|
def _dedup_key(key):
|
|
@@ -148,6 +158,8 @@ def link(console_url, alert):
|
|
|
148
158
|
base = console_url.rstrip("/")
|
|
149
159
|
if alert["kind"] == "event":
|
|
150
160
|
return f"{base}/#/task/{quote(alert['event']['task_id'], safe='')}"
|
|
161
|
+
if alert["kind"] == "revocation":
|
|
162
|
+
return f"{base}/#/governance"
|
|
151
163
|
return f"{base}/#/slos"
|
|
152
164
|
|
|
153
165
|
|
|
@@ -172,6 +184,9 @@ def render(dest, alerts, console_url=""):
|
|
|
172
184
|
"link": link(console_url, a), **a["condition"]} for a in alerts if a["kind"] == "slo"]
|
|
173
185
|
if slos:
|
|
174
186
|
bodies.append({"source": "agentdynamics", "slo_alerts": slos})
|
|
187
|
+
revs = [dict(a["revocation"], summary=a["summary"]) for a in alerts if a["kind"] == "revocation"]
|
|
188
|
+
if revs:
|
|
189
|
+
bodies.append({"source": "agentdynamics", "revocations": revs})
|
|
175
190
|
return bodies
|
|
176
191
|
# one line (Slack) or one alert (PagerDuty) per dedup key, so a burst of the same violation reads as one
|
|
177
192
|
groups = OrderedDict()
|
|
@@ -185,6 +200,9 @@ def render(dest, alerts, console_url=""):
|
|
|
185
200
|
where = " / ".join(_slack_escape(x) for x in (a.get("project"), a.get("task_type")) if x)
|
|
186
201
|
url = link(console_url, a)
|
|
187
202
|
ref = f" · <{url}|{'latest' if len(g) > 1 else 'open'}>" if url else ""
|
|
203
|
+
if a["kind"] == "revocation":
|
|
204
|
+
lines.append(f"*REVOKED* · {_slack_escape(a['summary'])}{ref}")
|
|
205
|
+
continue
|
|
188
206
|
if a["kind"] == "event":
|
|
189
207
|
e = a["event"]
|
|
190
208
|
times = f" ×{len(g)}" if len(g) > 1 else ""
|
|
@@ -200,7 +218,10 @@ def render(dest, alerts, console_url=""):
|
|
|
200
218
|
if a["action"] == "resolve":
|
|
201
219
|
bodies.append({"event_action": "resolve", "dedup_key": _dedup_key(key)})
|
|
202
220
|
continue
|
|
203
|
-
if a["kind"] == "
|
|
221
|
+
if a["kind"] == "revocation":
|
|
222
|
+
d = a["revocation"]
|
|
223
|
+
details, group, klass = dict(d), d.get("agent") or "all agents", "revocation"
|
|
224
|
+
elif a["kind"] == "event":
|
|
204
225
|
e = a["event"]
|
|
205
226
|
details = {"rule": e["rule"], "rule_id": e["rule_id"], "message": e["message"], "value": e.get("value"),
|
|
206
227
|
"task_id": e["task_id"], "run_id": e.get("run_id"), "project": e["project"],
|
|
@@ -4,7 +4,7 @@ import time
|
|
|
4
4
|
from collections import Counter, defaultdict
|
|
5
5
|
|
|
6
6
|
from ..analysis import pct
|
|
7
|
-
from ..store import rows
|
|
7
|
+
from ..store import revocations as list_revocations, rows
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
|
|
@@ -23,6 +23,15 @@ class GovernanceMixin:
|
|
|
23
23
|
"ORDER BY task_id, seq", chunk)
|
|
24
24
|
return out
|
|
25
25
|
|
|
26
|
+
def revocations(self, q):
|
|
27
|
+
"""Revocation directives (#8), newest first, each with its status. `active=1` and `project=` are what
|
|
28
|
+
the in-process poller asks for."""
|
|
29
|
+
now = time.time()
|
|
30
|
+
out = list_revocations(self.con, now, active=q.get("active") == "1", project=q.get("project"))
|
|
31
|
+
for d in out:
|
|
32
|
+
d["status"] = "cleared" if d["cleared"] else "expired" if d["expires"] <= now else "active"
|
|
33
|
+
return {"revocations": out, "now": now, "probing": (self.e.cfg.get("enforcement") or {}).get("probing")}
|
|
34
|
+
|
|
26
35
|
def governance(self, q):
|
|
27
36
|
ts = self._governed(q)
|
|
28
37
|
ids = [t["id"] for t in ts]
|
|
@@ -24,6 +24,9 @@ Example agentdynamics.toml:
|
|
|
24
24
|
url = "postgresql://agentdynamics@db.internal/agentdynamics" # needs pip install "agentdynamics[postgres]"
|
|
25
25
|
schema = "agentdynamics"
|
|
26
26
|
|
|
27
|
+
[enforcement] # server-side revocation (off unless set): an agent the policy keeps
|
|
28
|
+
probing = { denials = 10, runs = 3, window_minutes = 30, revoke_minutes = 60 } # refusing across runs
|
|
29
|
+
|
|
27
30
|
[retention]
|
|
28
31
|
days = 90 # spans/runs older than this are purged; daily totals are kept
|
|
29
32
|
|
|
@@ -78,6 +81,7 @@ DEFAULTS = {
|
|
|
78
81
|
"retention": {"days": 0},
|
|
79
82
|
"alerts": {"webhooks": [], "console_url": "", "slo_min_tasks": 10},
|
|
80
83
|
"store": {"url": "", "schema": "agentdynamics"},
|
|
84
|
+
"enforcement": {},
|
|
81
85
|
"analysis": {"interval": 15, "idle_cap_seconds": 300},
|
|
82
86
|
"sources": [],
|
|
83
87
|
}
|
|
@@ -17,7 +17,7 @@ import sys
|
|
|
17
17
|
import threading
|
|
18
18
|
import time
|
|
19
19
|
import traceback
|
|
20
|
-
from collections import defaultdict
|
|
20
|
+
from collections import Counter, defaultdict
|
|
21
21
|
|
|
22
22
|
from . import alerts as alertmod, analysis, config as cfgmod, pricing, slo as slomod, store
|
|
23
23
|
from .collectors import aegis_audit, claude_code, generic, inbox, langfuse, langsmith, otlp, spans as spanmod
|
|
@@ -105,6 +105,7 @@ class Engine:
|
|
|
105
105
|
# ingest (both go through the store), and one of them takes over if the writer goes away.
|
|
106
106
|
self.writer = store.claim_writer(self.con, self.db_schema)
|
|
107
107
|
self._grades_sig = self._rules_sig = None
|
|
108
|
+
self._revocations_seen = None # newest directive already alerted on (None: not looked yet)
|
|
108
109
|
self._store_sdk_ids = set() # ids of SDK runs read from the store, as _files holds file ones
|
|
109
110
|
self._store_sdk_sig = {} # run id -> hash of the payload last read
|
|
110
111
|
self.lock = threading.RLock()
|
|
@@ -449,6 +450,86 @@ class Engine:
|
|
|
449
450
|
self._wake.set()
|
|
450
451
|
return len(items)
|
|
451
452
|
|
|
453
|
+
# ------------------------------------------------------------------ server-side revocation (#8)
|
|
454
|
+
# A directive says "revoke this agent's grants in this project until then". The in-process Aegis
|
|
455
|
+
# integration polls for directives and applies them through Kernel.revoke (integrations/aegis.py):
|
|
456
|
+
# the kernel enforces and audits, and a directive can only take privileges away (invariant 6).
|
|
457
|
+
|
|
458
|
+
def revoke(self, agent=None, project=None, reason="operator", minutes=60, source="operator"):
|
|
459
|
+
"""Issue a directive. `agent` None revokes a process's whole grant tree; `project` None, every project."""
|
|
460
|
+
now = self._clock()
|
|
461
|
+
with self.lock:
|
|
462
|
+
rid = store.add_revocation(self.con, project, agent, reason, source, now, now + float(minutes) * 60)
|
|
463
|
+
self._wake.set()
|
|
464
|
+
return rid
|
|
465
|
+
|
|
466
|
+
def clear_revocation(self, rid):
|
|
467
|
+
"""Stop a directive applying to new grants; ones it revoked stay revoked (Aegis can't un-revoke)."""
|
|
468
|
+
with self.lock:
|
|
469
|
+
return store.clear_revocation(self.con, rid, self._clock())
|
|
470
|
+
|
|
471
|
+
def _detect_probing(self):
|
|
472
|
+
"""[enforcement] probing: an agent whose calls the policy keeps refusing across several runs is probing
|
|
473
|
+
it. The in-process Watchdog sees one run; this sees them all, and issues a directive. Off unless
|
|
474
|
+
configured: it acts on running agents."""
|
|
475
|
+
p = (self.cfg.get("enforcement") or {}).get("probing")
|
|
476
|
+
if not p:
|
|
477
|
+
return []
|
|
478
|
+
now = self._clock()
|
|
479
|
+
since = now - float(p.get("window_minutes", 30)) * 60
|
|
480
|
+
issued = store.revocations(self.con, now, limit=10000)
|
|
481
|
+
# a directive restarts the count: denials from before it were what it acted on
|
|
482
|
+
last = {}
|
|
483
|
+
for d in issued:
|
|
484
|
+
if d["source"] == "probing":
|
|
485
|
+
last[(d["project"], d["agent"])] = max(last.get((d["project"], d["agent"]), 0), d["created"])
|
|
486
|
+
active = {(d["project"], d["agent"]) for d in issued if d["cleared"] is None and d["expires"] > now}
|
|
487
|
+
seen = defaultdict(lambda: [0, set(), Counter()])
|
|
488
|
+
for run in self._runs.values():
|
|
489
|
+
if (run.get("ended") or run.get("started") or 0) < since:
|
|
490
|
+
continue # only recent runs: this runs every refresh
|
|
491
|
+
project = run.get("project") or "default"
|
|
492
|
+
for s in run["steps"]:
|
|
493
|
+
if not (s.get("denied") and s.get("governed") and s.get("agent")):
|
|
494
|
+
continue
|
|
495
|
+
key = (project, s["agent"])
|
|
496
|
+
if (s.get("ts") or 0) < max(since, last.get(key, 0)):
|
|
497
|
+
continue
|
|
498
|
+
c = seen[key]
|
|
499
|
+
c[0] += 1
|
|
500
|
+
c[1].add(run["id"])
|
|
501
|
+
c[2][s.get("rule") or "denied"] += 1
|
|
502
|
+
out = []
|
|
503
|
+
for (project, agent), (n, runs, rules) in seen.items():
|
|
504
|
+
if n < int(p.get("denials", 10)) or len(runs) < int(p.get("runs", 3)) or (project, agent) in active:
|
|
505
|
+
continue
|
|
506
|
+
top = ", ".join(f"{r} x{k}" for r, k in rules.most_common(3))
|
|
507
|
+
reason = (f"probing: {n} denied calls across {len(runs)} runs in "
|
|
508
|
+
f"{float(p.get('window_minutes', 30)):g} min ({top})")
|
|
509
|
+
out.append(store.add_revocation(self.con, project, agent, reason, "probing", now,
|
|
510
|
+
now + float(p.get("revoke_minutes", 60)) * 60))
|
|
511
|
+
return out
|
|
512
|
+
|
|
513
|
+
def _alert_new_revocations(self):
|
|
514
|
+
"""Alert on directives issued since the last look, from wherever they came (this process, another
|
|
515
|
+
instance, the CLI): an agent was stopped, and someone should know why. On the alert tick, so a
|
|
516
|
+
directive issued while no traffic arrives is still announced."""
|
|
517
|
+
if not self.writer:
|
|
518
|
+
return 0
|
|
519
|
+
with self.lock:
|
|
520
|
+
return self._alert_new_revocations_locked()
|
|
521
|
+
|
|
522
|
+
def _alert_new_revocations_locked(self):
|
|
523
|
+
now = self._clock()
|
|
524
|
+
recent = store.revocations(self.con, now, limit=500)
|
|
525
|
+
newest = max((d["created"] for d in recent), default=0)
|
|
526
|
+
if self._revocations_seen is None: # a process's first look: that's history
|
|
527
|
+
self._revocations_seen = newest
|
|
528
|
+
return 0
|
|
529
|
+
new = [d for d in recent if d["created"] > self._revocations_seen]
|
|
530
|
+
self._revocations_seen = max(self._revocations_seen, newest)
|
|
531
|
+
return self._queue([alertmod.from_revocation(d) for d in reversed(new)], now) if new else 0
|
|
532
|
+
|
|
452
533
|
# ------------------------------------------------------------------ settings saved from the console
|
|
453
534
|
# Health rules and SLOs. With SQLite they are files in the data directory. On Postgres a saved one goes in
|
|
454
535
|
# the store, where every instance reads it (a file in the data directory still works, as the default).
|
|
@@ -668,6 +749,7 @@ class Engine:
|
|
|
668
749
|
gone - changed, events, baselines, meta, self.redactor)
|
|
669
750
|
self.stats["tasks_rescored"] = len(self._cache.changed)
|
|
670
751
|
self._queue_event_alerts(events)
|
|
752
|
+
self._detect_probing()
|
|
671
753
|
self._first = False
|
|
672
754
|
self.last_refresh = time.time()
|
|
673
755
|
self.last_duration = round(self.last_refresh - t0, 2)
|
|
@@ -847,6 +929,7 @@ class Engine:
|
|
|
847
929
|
self._alert_wake.clear()
|
|
848
930
|
try:
|
|
849
931
|
self.check_slos()
|
|
932
|
+
self._alert_new_revocations()
|
|
850
933
|
self.deliver_alerts()
|
|
851
934
|
except Exception:
|
|
852
935
|
traceback.print_exc()
|
|
@@ -29,6 +29,9 @@ import json
|
|
|
29
29
|
import math
|
|
30
30
|
import threading
|
|
31
31
|
import time
|
|
32
|
+
import urllib.parse
|
|
33
|
+
import urllib.request
|
|
34
|
+
import weakref
|
|
32
35
|
from collections import Counter
|
|
33
36
|
|
|
34
37
|
from .. import autotrace as at
|
|
@@ -115,7 +118,13 @@ def _wrap_kernel(kernel):
|
|
|
115
118
|
return
|
|
116
119
|
orig_invoke, orig_ainvoke, orig_spawn, orig_revoke = kernel.invoke, kernel.ainvoke, kernel.spawn, kernel.revoke
|
|
117
120
|
|
|
121
|
+
def check(grant):
|
|
122
|
+
rv = getattr(kernel, "_agentdynamics_revocations", None) # this kernel's, not another's in-process
|
|
123
|
+
if rv is not None:
|
|
124
|
+
rv.check_use(grant)
|
|
125
|
+
|
|
118
126
|
def invoke(grant, tool, /, **args):
|
|
127
|
+
check(grant)
|
|
119
128
|
t0 = time.time()
|
|
120
129
|
with bind(grant):
|
|
121
130
|
try:
|
|
@@ -127,6 +136,7 @@ def _wrap_kernel(kernel):
|
|
|
127
136
|
return r
|
|
128
137
|
|
|
129
138
|
async def ainvoke(grant, tool, /, **args):
|
|
139
|
+
check(grant)
|
|
130
140
|
t0 = time.time()
|
|
131
141
|
tok = _grant.set(grant)
|
|
132
142
|
try:
|
|
@@ -140,6 +150,7 @@ def _wrap_kernel(kernel):
|
|
|
140
150
|
return r
|
|
141
151
|
|
|
142
152
|
def spawn(parent, req):
|
|
153
|
+
check(parent)
|
|
143
154
|
t0 = time.time()
|
|
144
155
|
try:
|
|
145
156
|
child = orig_spawn(parent, req)
|
|
@@ -151,6 +162,7 @@ def _wrap_kernel(kernel):
|
|
|
151
162
|
"start_ts": t0, "end_ts": time.time(), "agent": parent.agent_name, "governed": True,
|
|
152
163
|
"rule": "spawn.granted", "text": f"spawned {child.agent_name} (depth {child.depth}) "
|
|
153
164
|
f"with {sorted(req.tools)}"}, "aegis.spawn")
|
|
165
|
+
check(child) # a new grant for an agent a directive names is revoked before it acts
|
|
154
166
|
return child
|
|
155
167
|
|
|
156
168
|
def revoke(grant, reason="operator"):
|
|
@@ -292,27 +304,158 @@ class Watchdog:
|
|
|
292
304
|
|
|
293
305
|
# ---------------------------------------------------------------- entry point
|
|
294
306
|
|
|
307
|
+
# ---------------------------------------------------------------- 5. server-side revocation (#8)
|
|
308
|
+
|
|
309
|
+
class Revocations:
|
|
310
|
+
"""Directives from the AgentDynamics server to revoke an agent's grants, applied through Kernel.revoke.
|
|
311
|
+
|
|
312
|
+
The server issues one when an operator asks (`agentdynamics revoke`, POST /api/revocations) or when it sees
|
|
313
|
+
an agent probing its policy across runs ([enforcement] probing), which no single process can see. This polls
|
|
314
|
+
for the directives that apply to this process's project and revokes the grants they name: at once in every
|
|
315
|
+
grant tree it has seen (apps often make a root per conversation, not only the one given to instrument()),
|
|
316
|
+
and at the point of use -- before any governed call, and on spawn -- for a grant not seen yet. The kernel
|
|
317
|
+
enforces and audits each revocation.
|
|
318
|
+
A directive can only take privileges away (invariant 6), and clearing it restores nothing: Aegis revocation
|
|
319
|
+
is permanent. If the server can't be reached nothing is revoked and the agent carries on, as without this.
|
|
320
|
+
|
|
321
|
+
It uses the URL, key and project given to agentdynamics.init(). The key needs the ingest role.
|
|
322
|
+
"""
|
|
323
|
+
|
|
324
|
+
def __init__(self, interval=10.0):
|
|
325
|
+
self.interval = interval
|
|
326
|
+
self.active = {} # directive id -> directive, as of the last successful poll
|
|
327
|
+
self.applied = [] # {"directive", "agent", "grant_id", "ts"} for each grant revoked here
|
|
328
|
+
self._done = set() # (directive id, grant id)
|
|
329
|
+
self._roots = weakref.WeakValueDictionary() # grant id -> root, for every tree seen (kept only while alive)
|
|
330
|
+
self._lock = threading.RLock()
|
|
331
|
+
self._stop = threading.Event()
|
|
332
|
+
self.kernel = self.root = None
|
|
333
|
+
|
|
334
|
+
def start(self, kernel, root):
|
|
335
|
+
self.kernel, self.root = kernel, root
|
|
336
|
+
if self.interval:
|
|
337
|
+
threading.Thread(target=self._loop, daemon=True, name="agentdynamics-revocations").start()
|
|
338
|
+
|
|
339
|
+
def stop(self):
|
|
340
|
+
self._stop.set()
|
|
341
|
+
|
|
342
|
+
def _loop(self):
|
|
343
|
+
while True:
|
|
344
|
+
try:
|
|
345
|
+
self.poll_once()
|
|
346
|
+
except Exception as ex: # never into the agent
|
|
347
|
+
at._warn("revocations-err", f"revocation poll failed: {ex}")
|
|
348
|
+
if self._stop.wait(self.interval):
|
|
349
|
+
return
|
|
350
|
+
|
|
351
|
+
def poll_once(self):
|
|
352
|
+
"""Fetch the active directives and apply them; returns the number of grants revoked."""
|
|
353
|
+
url = at._cfg.get("url")
|
|
354
|
+
if not url:
|
|
355
|
+
at._warn("revocations-nourl", "revocations=True needs agentdynamics.init(url=...); nothing is polled")
|
|
356
|
+
return 0
|
|
357
|
+
q = urllib.parse.urlencode({"active": "1", "project": at._cfg.get("project") or "default"})
|
|
358
|
+
headers = {"Authorization": f"Bearer {at._cfg['key']}"} if at._cfg.get("key") else {}
|
|
359
|
+
try:
|
|
360
|
+
with urllib.request.urlopen(urllib.request.Request(f"{url}/api/revocations?{q}", headers=headers),
|
|
361
|
+
timeout=5) as r:
|
|
362
|
+
data = json.loads(r.read())
|
|
363
|
+
except Exception as ex:
|
|
364
|
+
at._warn("revocations-down", f"could not fetch revocation directives from {url} ({ex}); "
|
|
365
|
+
"nothing is revoked until it answers")
|
|
366
|
+
return 0
|
|
367
|
+
with self._lock:
|
|
368
|
+
self.active = {d["id"]: d for d in data.get("revocations", [])}
|
|
369
|
+
return self.apply()
|
|
370
|
+
|
|
371
|
+
def _live(self):
|
|
372
|
+
now = time.time()
|
|
373
|
+
with self._lock:
|
|
374
|
+
return [d for d in self.active.values() if (d.get("expires") or 0) > now]
|
|
375
|
+
|
|
376
|
+
def apply(self):
|
|
377
|
+
"""Revoke every live grant an active directive names; returns how many."""
|
|
378
|
+
if self.kernel is None or self.root is None:
|
|
379
|
+
return 0
|
|
380
|
+
return sum(self._revoke(d, g) for d in self._live() for g in self._targets(d))
|
|
381
|
+
|
|
382
|
+
def note(self, grant):
|
|
383
|
+
root = grant # up the parent chain (Grant.root is a constructor, not this)
|
|
384
|
+
while root.parent is not None:
|
|
385
|
+
root = root.parent
|
|
386
|
+
self._roots[root.grant_id] = root
|
|
387
|
+
|
|
388
|
+
def _all_roots(self):
|
|
389
|
+
return [self.root] + [r for r in list(self._roots.values()) if r is not self.root]
|
|
390
|
+
|
|
391
|
+
def _targets(self, d):
|
|
392
|
+
if not d.get("agent"):
|
|
393
|
+
return self._all_roots() # every agent: every tree
|
|
394
|
+
out, stack = [], self._all_roots()
|
|
395
|
+
while stack:
|
|
396
|
+
g = stack.pop()
|
|
397
|
+
if g.agent_name == d["agent"]:
|
|
398
|
+
out.append(g) # its subtree goes with it
|
|
399
|
+
else:
|
|
400
|
+
stack.extend(g.children)
|
|
401
|
+
return out
|
|
402
|
+
|
|
403
|
+
def _revoke(self, d, grant):
|
|
404
|
+
key = (d["id"], grant.grant_id)
|
|
405
|
+
with self._lock:
|
|
406
|
+
if key in self._done or not grant.is_active():
|
|
407
|
+
return 0
|
|
408
|
+
self._done.add(key)
|
|
409
|
+
self.kernel.revoke(grant, reason=f"agentdynamics directive {d['id']}: {d.get('reason')}")
|
|
410
|
+
self.applied.append({"directive": d["id"], "agent": grant.agent_name, "grant_id": grant.grant_id,
|
|
411
|
+
"ts": time.time()})
|
|
412
|
+
return 1
|
|
413
|
+
|
|
414
|
+
def check_use(self, grant):
|
|
415
|
+
"""Before a grant acts: if an active directive names its agent or an ancestor's (or every agent),
|
|
416
|
+
revoke first, so the call is refused. Free while no directive is active."""
|
|
417
|
+
try:
|
|
418
|
+
self.note(grant) # so a later directive finds this tree even while it sits idle
|
|
419
|
+
if not self.active:
|
|
420
|
+
return
|
|
421
|
+
chain, g = [], grant
|
|
422
|
+
while g is not None:
|
|
423
|
+
chain.append(g)
|
|
424
|
+
g = g.parent
|
|
425
|
+
for d in self._live():
|
|
426
|
+
hit = chain[-1] if not d.get("agent") else next((g for g in chain if g.agent_name == d["agent"]), None)
|
|
427
|
+
if hit is not None:
|
|
428
|
+
self._revoke(d, hit)
|
|
429
|
+
return
|
|
430
|
+
except Exception as ex: # the check itself must not break the agent
|
|
431
|
+
at._warn("revocations-check", f"could not check a grant against revocation directives: {ex}")
|
|
432
|
+
|
|
433
|
+
|
|
295
434
|
class Governance:
|
|
296
|
-
def __init__(self, kernel, root, gate, watchdog, unregister):
|
|
435
|
+
def __init__(self, kernel, root, gate, watchdog, unregister, revocations=None):
|
|
297
436
|
self.kernel, self.root, self.gate, self.watchdog, self._unregister = kernel, root, gate, watchdog, unregister
|
|
437
|
+
self.revocations = revocations
|
|
298
438
|
|
|
299
439
|
def uninstall(self):
|
|
300
440
|
self._unregister()
|
|
301
441
|
|
|
302
442
|
|
|
303
|
-
def instrument(kernel, root=None, *, gate_models=True, watchdog=None, correlate=True, record_decisions=True
|
|
443
|
+
def instrument(kernel, root=None, *, gate_models=True, watchdog=None, correlate=True, record_decisions=True,
|
|
444
|
+
revocations=None):
|
|
304
445
|
"""Connect an Aegis kernel to AgentDynamics. Call after `agentdynamics.init()` and `build_kernel()`.
|
|
305
446
|
|
|
306
447
|
kernel the Aegis Kernel
|
|
307
448
|
root the root Grant (model calls outside a bound grant are charged to it)
|
|
308
449
|
gate_models reserve model spend against the Aegis budget before each call
|
|
309
450
|
watchdog a Watchdog, or None
|
|
451
|
+
revocations True (or a Revocations) to apply the server's revocation directives: off by default, since it
|
|
452
|
+
lets the server stop agents in this process. Needs `root`.
|
|
310
453
|
"""
|
|
311
454
|
_require_aegis()
|
|
312
455
|
_state.update(kernel=kernel, root=root)
|
|
313
456
|
undo = []
|
|
314
|
-
if record_decisions:
|
|
315
|
-
_wrap_kernel(kernel)
|
|
457
|
+
if record_decisions or revocations:
|
|
458
|
+
_wrap_kernel(kernel) # the spawn hook applies directives to new grants
|
|
316
459
|
if correlate:
|
|
317
460
|
try:
|
|
318
461
|
from aegis.observe import register_context_provider
|
|
@@ -356,10 +499,20 @@ def instrument(kernel, root=None, *, gate_models=True, watchdog=None, correlate=
|
|
|
356
499
|
at._hooks["run_meta"].append(meta)
|
|
357
500
|
undo.append(lambda: at._hooks["run_meta"].remove(meta))
|
|
358
501
|
|
|
502
|
+
rv = None
|
|
503
|
+
if revocations:
|
|
504
|
+
if root is None:
|
|
505
|
+
at._warn("revocations-noroot", "revocations need the root grant: instrument(kernel, root, revocations=True)")
|
|
506
|
+
else:
|
|
507
|
+
rv = revocations if isinstance(revocations, Revocations) else Revocations()
|
|
508
|
+
kernel._agentdynamics_revocations = rv
|
|
509
|
+
rv.start(kernel, root)
|
|
510
|
+
undo += [rv.stop, lambda: setattr(kernel, "_agentdynamics_revocations", None)]
|
|
511
|
+
|
|
359
512
|
def unregister():
|
|
360
513
|
for u in undo:
|
|
361
514
|
try:
|
|
362
515
|
u()
|
|
363
516
|
except ValueError:
|
|
364
517
|
pass
|
|
365
|
-
return Governance(kernel, root, gate, watchdog, unregister)
|
|
518
|
+
return Governance(kernel, root, gate, watchdog, unregister, rv)
|
|
@@ -349,6 +349,8 @@ def ddl(schema_version, derived):
|
|
|
349
349
|
f"CREATE TABLE IF NOT EXISTS alert_outbox (id BIGSERIAL PRIMARY KEY, dest TEXT, body TEXT, created {NUM}, "
|
|
350
350
|
f"attempts {NUM} DEFAULT 0, next_try {NUM}, last_error TEXT)",
|
|
351
351
|
f"CREATE TABLE IF NOT EXISTS alert_state (key TEXT PRIMARY KEY, since {NUM}, data TEXT)",
|
|
352
|
+
"CREATE TABLE IF NOT EXISTS revocations (id TEXT PRIMARY KEY, project TEXT, agent TEXT, reason TEXT, "
|
|
353
|
+
f"source TEXT, created {NUM}, expires {NUM}, cleared {NUM})",
|
|
352
354
|
f"CREATE TABLE IF NOT EXISTS rollup_daily ({dims}, {', '.join(f'{c} {NUM}' for c in s.ROLLUP_SUMS)}, "
|
|
353
355
|
f"PRIMARY KEY ({', '.join(s.ROLLUP_DIMS)}))",
|
|
354
356
|
]
|