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.
Files changed (72) hide show
  1. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/PKG-INFO +1 -1
  2. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/__init__.py +1 -1
  3. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/__main__.py +36 -0
  4. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/alerts.py +24 -3
  5. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/api/governance.py +10 -1
  6. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/config.py +4 -0
  7. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/engine.py +84 -1
  8. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/integrations/aegis.py +158 -5
  9. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/pg.py +2 -0
  10. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/server.py +49 -5
  11. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/store.py +39 -1
  12. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/web/pages/governance.js +39 -1
  13. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics.egg-info/PKG-INFO +1 -1
  14. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics.egg-info/SOURCES.txt +1 -0
  15. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/pyproject.toml +1 -1
  16. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/tests/test_aegis_integration.py +118 -0
  17. agentdynamics-0.8.0/tests/test_revocations.py +200 -0
  18. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/tests/test_scoped_keys.py +2 -0
  19. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/LICENSE +0 -0
  20. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/README.md +0 -0
  21. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/analysis.py +0 -0
  22. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/api/__init__.py +0 -0
  23. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/api/assess.py +0 -0
  24. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/api/base.py +0 -0
  25. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/api/diagnose.py +0 -0
  26. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/api/monitor.py +0 -0
  27. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/api/ops.py +0 -0
  28. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/autotrace.py +0 -0
  29. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/bootstrap/sitecustomize.py +0 -0
  30. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/collectors/__init__.py +0 -0
  31. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/collectors/aegis_audit.py +0 -0
  32. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/collectors/claude_code.py +0 -0
  33. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/collectors/generic.py +0 -0
  34. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/collectors/inbox.py +0 -0
  35. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/collectors/langfuse.py +0 -0
  36. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/collectors/langsmith.py +0 -0
  37. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/collectors/otlp.py +0 -0
  38. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/collectors/spans.py +0 -0
  39. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/flows.py +0 -0
  40. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/govern.py +0 -0
  41. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/integrations/__init__.py +0 -0
  42. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/phases.py +0 -0
  43. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/pricing.py +0 -0
  44. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/privacy.py +0 -0
  45. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/sdk.py +0 -0
  46. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/slo.py +0 -0
  47. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/web/app.js +0 -0
  48. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/web/charts.js +0 -0
  49. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/web/index.html +0 -0
  50. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/web/pages/assess.js +0 -0
  51. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/web/pages/configure.js +0 -0
  52. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/web/pages/diagnose.js +0 -0
  53. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/web/pages/monitor.js +0 -0
  54. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics/web/style.css +0 -0
  55. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics.egg-info/dependency_links.txt +0 -0
  56. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics.egg-info/entry_points.txt +0 -0
  57. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics.egg-info/requires.txt +0 -0
  58. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/agentdynamics.egg-info/top_level.txt +0 -0
  59. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/setup.cfg +0 -0
  60. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/tests/test_alerts.py +0 -0
  61. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/tests/test_autotrace.py +0 -0
  62. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/tests/test_console_ui.py +0 -0
  63. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/tests/test_core.py +0 -0
  64. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/tests/test_ecosystem.py +0 -0
  65. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/tests/test_incremental.py +0 -0
  66. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/tests/test_integrations.py +0 -0
  67. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/tests/test_live_anthropic.py +0 -0
  68. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/tests/test_outcomes.py +0 -0
  69. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/tests/test_policy_coverage.py +0 -0
  70. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/tests/test_postgres.py +0 -0
  71. {agentdynamics-0.7.0 → agentdynamics-0.8.0}/tests/test_rollups.py +0 -0
  72. {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.7.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
@@ -5,7 +5,7 @@
5
5
 
6
6
  See agentdynamics.autotrace for @trace / @tool / span.
7
7
  """
8
- __version__ = "0.7.0"
8
+ __version__ = "0.8.0"
9
9
 
10
10
  from .autotrace import flush, init, llm_call, outcome, record_llm, span, tool, trace # noqa: E402,F401
11
11
 
@@ -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"] == "event":
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
  ]