postgres-aiops 0.1.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.
- mcp_server/__init__.py +1 -0
- mcp_server/_shared.py +101 -0
- mcp_server/server.py +37 -0
- mcp_server/tools/__init__.py +1 -0
- mcp_server/tools/activity.py +52 -0
- mcp_server/tools/analysis.py +89 -0
- mcp_server/tools/indexes.py +65 -0
- mcp_server/tools/queries.py +60 -0
- mcp_server/tools/remediation.py +281 -0
- mcp_server/tools/replication.py +43 -0
- mcp_server/tools/server.py +86 -0
- mcp_server/tools/tables.py +46 -0
- postgres_aiops/__init__.py +9 -0
- postgres_aiops/cli/__init__.py +9 -0
- postgres_aiops/cli/_common.py +78 -0
- postgres_aiops/cli/_root.py +68 -0
- postgres_aiops/cli/activity.py +52 -0
- postgres_aiops/cli/analyze.py +53 -0
- postgres_aiops/cli/doctor.py +21 -0
- postgres_aiops/cli/index.py +55 -0
- postgres_aiops/cli/init.py +112 -0
- postgres_aiops/cli/overview.py +16 -0
- postgres_aiops/cli/query.py +70 -0
- postgres_aiops/cli/remediate.py +186 -0
- postgres_aiops/cli/replication.py +45 -0
- postgres_aiops/cli/secret.py +103 -0
- postgres_aiops/cli/server.py +69 -0
- postgres_aiops/cli/table.py +45 -0
- postgres_aiops/config.py +154 -0
- postgres_aiops/connection.py +178 -0
- postgres_aiops/doctor.py +84 -0
- postgres_aiops/governance/__init__.py +40 -0
- postgres_aiops/governance/audit.py +377 -0
- postgres_aiops/governance/budget.py +225 -0
- postgres_aiops/governance/decorators.py +474 -0
- postgres_aiops/governance/paths.py +23 -0
- postgres_aiops/governance/patterns.py +378 -0
- postgres_aiops/governance/policy.py +411 -0
- postgres_aiops/governance/sanitize.py +39 -0
- postgres_aiops/governance/undo.py +218 -0
- postgres_aiops/ops/__init__.py +1 -0
- postgres_aiops/ops/_util.py +102 -0
- postgres_aiops/ops/activity.py +193 -0
- postgres_aiops/ops/analysis.py +263 -0
- postgres_aiops/ops/indexes.py +211 -0
- postgres_aiops/ops/overview.py +51 -0
- postgres_aiops/ops/queries.py +123 -0
- postgres_aiops/ops/remediation.py +237 -0
- postgres_aiops/ops/replication.py +144 -0
- postgres_aiops/ops/server.py +151 -0
- postgres_aiops/ops/tables.py +146 -0
- postgres_aiops/secretstore.py +302 -0
- postgres_aiops-0.1.0.dist-info/METADATA +119 -0
- postgres_aiops-0.1.0.dist-info/RECORD +57 -0
- postgres_aiops-0.1.0.dist-info/WHEEL +4 -0
- postgres_aiops-0.1.0.dist-info/entry_points.txt +3 -0
- postgres_aiops-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""Unified audit logging engine — all Endpoint skills write to a single SQLite database.
|
|
2
|
+
|
|
3
|
+
Replaces 7 per-skill JSON Lines audit loggers with one shared ``~/.postgres-aiops/audit.db``.
|
|
4
|
+
Framework-agnostic: works with Claude, Codex, local agents, or any MCP client.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import getpass
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import sqlite3
|
|
14
|
+
import threading
|
|
15
|
+
from datetime import UTC, datetime, timedelta
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from postgres_aiops.governance.paths import ops_path
|
|
20
|
+
|
|
21
|
+
_log = logging.getLogger("postgres-aiops.audit")
|
|
22
|
+
|
|
23
|
+
# Back-compat override hook. When None (default) the path resolves via
|
|
24
|
+
# ops_path("audit.db") (honoring POSTGRES_AIOPS_HOME). Downstream code/tests may set this
|
|
25
|
+
# to a fixed Path to redirect the default DB — kept so the POSTGRES_AIOPS_HOME refactor
|
|
26
|
+
# does not break callers that monkeypatch `_DEFAULT_DB`.
|
|
27
|
+
_DEFAULT_DB: Path | None = None
|
|
28
|
+
|
|
29
|
+
_MAX_DB_SIZE_BYTES = 100 * 1024 * 1024 # 100 MB
|
|
30
|
+
_MAX_ARCHIVES = 5
|
|
31
|
+
_BUSY_TIMEOUT_MS = 5000
|
|
32
|
+
|
|
33
|
+
_CREATE_TABLE = """\
|
|
34
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
35
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
36
|
+
ts TEXT NOT NULL,
|
|
37
|
+
skill TEXT NOT NULL,
|
|
38
|
+
tool TEXT NOT NULL,
|
|
39
|
+
params TEXT NOT NULL DEFAULT '{}',
|
|
40
|
+
result TEXT NOT NULL DEFAULT '{}',
|
|
41
|
+
status TEXT NOT NULL DEFAULT 'ok',
|
|
42
|
+
duration_ms INTEGER NOT NULL DEFAULT 0,
|
|
43
|
+
agent TEXT NOT NULL DEFAULT 'unknown',
|
|
44
|
+
workflow_id TEXT NOT NULL DEFAULT '',
|
|
45
|
+
user TEXT NOT NULL DEFAULT 'unknown',
|
|
46
|
+
risk_level TEXT NOT NULL DEFAULT 'low',
|
|
47
|
+
rationale TEXT NOT NULL DEFAULT '',
|
|
48
|
+
approved_by TEXT NOT NULL DEFAULT '',
|
|
49
|
+
risk_tier TEXT NOT NULL DEFAULT ''
|
|
50
|
+
)
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
# Columns added after the original schema shipped. Each is applied with an
|
|
54
|
+
# idempotent ALTER TABLE so pre-existing audit.db files migrate in place (a
|
|
55
|
+
# fresh DB already has them via _CREATE_TABLE). Accountability fields per the
|
|
56
|
+
# SOC2 / 等保 "who authorized this, and why" requirement.
|
|
57
|
+
_MIGRATIONS = (
|
|
58
|
+
("rationale", "TEXT NOT NULL DEFAULT ''"),
|
|
59
|
+
("approved_by", "TEXT NOT NULL DEFAULT ''"),
|
|
60
|
+
("risk_tier", "TEXT NOT NULL DEFAULT ''"),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
_INSERT = """\
|
|
64
|
+
INSERT INTO audit_log (ts, skill, tool, params, result, status, duration_ms, agent, workflow_id, user, risk_level, rationale, approved_by, risk_tier)
|
|
65
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class AuditEngine:
|
|
70
|
+
"""Append-only audit logger backed by SQLite with WAL mode.
|
|
71
|
+
|
|
72
|
+
Thread-safe for concurrent writes from multiple skill processes.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, db_path: Path | str | None = None) -> None:
|
|
76
|
+
if db_path:
|
|
77
|
+
self._path = Path(db_path).expanduser()
|
|
78
|
+
else:
|
|
79
|
+
self._path = _DEFAULT_DB or ops_path("audit.db")
|
|
80
|
+
self._ok = False
|
|
81
|
+
try:
|
|
82
|
+
self._path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
83
|
+
self._init_db()
|
|
84
|
+
self._harden_permissions()
|
|
85
|
+
self._ok = True
|
|
86
|
+
except Exception:
|
|
87
|
+
_log.warning("Cannot initialize audit DB at %s", self._path, exc_info=True)
|
|
88
|
+
|
|
89
|
+
def _init_db(self) -> None:
|
|
90
|
+
conn = self._connect()
|
|
91
|
+
conn.execute(_CREATE_TABLE)
|
|
92
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
93
|
+
self._migrate(conn)
|
|
94
|
+
conn.commit()
|
|
95
|
+
conn.close()
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def _migrate(conn: sqlite3.Connection) -> None:
|
|
99
|
+
"""Add columns introduced after the original schema, in place.
|
|
100
|
+
|
|
101
|
+
Idempotent: only adds a column the table is missing, so existing
|
|
102
|
+
audit.db files gain rationale/approved_by/risk_tier without losing rows.
|
|
103
|
+
"""
|
|
104
|
+
existing = {row[1] for row in conn.execute("PRAGMA table_info(audit_log)")}
|
|
105
|
+
for name, decl in _MIGRATIONS:
|
|
106
|
+
if name not in existing:
|
|
107
|
+
conn.execute(f"ALTER TABLE audit_log ADD COLUMN {name} {decl}") # nosec B608
|
|
108
|
+
|
|
109
|
+
def _harden_permissions(self) -> None:
|
|
110
|
+
"""Restrict the audit dir to 0700 and DB files (incl. WAL/SHM) to 0600.
|
|
111
|
+
|
|
112
|
+
``mkdir(mode=...)`` is masked by umask, so set permissions explicitly.
|
|
113
|
+
Best-effort: never raises (audit must not break the tool call)."""
|
|
114
|
+
try:
|
|
115
|
+
os.chmod(self._path.parent, 0o700)
|
|
116
|
+
except OSError:
|
|
117
|
+
pass
|
|
118
|
+
for suffix in ("", "-wal", "-shm"):
|
|
119
|
+
candidate = self._path.with_name(self._path.name + suffix)
|
|
120
|
+
try:
|
|
121
|
+
if candidate.exists():
|
|
122
|
+
os.chmod(candidate, 0o600)
|
|
123
|
+
except OSError:
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
def _connect(self) -> sqlite3.Connection:
|
|
127
|
+
conn = sqlite3.connect(str(self._path), timeout=_BUSY_TIMEOUT_MS / 1000)
|
|
128
|
+
conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}")
|
|
129
|
+
return conn
|
|
130
|
+
|
|
131
|
+
def log(
|
|
132
|
+
self,
|
|
133
|
+
*,
|
|
134
|
+
skill: str,
|
|
135
|
+
tool: str,
|
|
136
|
+
params: dict[str, Any] | None = None,
|
|
137
|
+
result: Any = None,
|
|
138
|
+
status: str = "ok",
|
|
139
|
+
duration_ms: int = 0,
|
|
140
|
+
agent: str = "unknown",
|
|
141
|
+
workflow_id: str = "",
|
|
142
|
+
user: str = "",
|
|
143
|
+
risk_level: str = "low",
|
|
144
|
+
rationale: str = "",
|
|
145
|
+
approved_by: str = "",
|
|
146
|
+
risk_tier: str = "",
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Write one audit record. Never raises — swallows errors to avoid
|
|
149
|
+
disrupting the actual tool execution.
|
|
150
|
+
|
|
151
|
+
rationale / approved_by / risk_tier carry the accountability trail:
|
|
152
|
+
*why* a change was made, *who* signed off, and the *approval tier* the
|
|
153
|
+
policy engine assigned (none/confirm/dual/review).
|
|
154
|
+
"""
|
|
155
|
+
if not self._ok:
|
|
156
|
+
return
|
|
157
|
+
try:
|
|
158
|
+
self._maybe_rotate()
|
|
159
|
+
conn = self._connect()
|
|
160
|
+
conn.execute(
|
|
161
|
+
_INSERT,
|
|
162
|
+
(
|
|
163
|
+
datetime.now(tz=UTC).isoformat(),
|
|
164
|
+
skill,
|
|
165
|
+
tool,
|
|
166
|
+
_safe_json(params),
|
|
167
|
+
_safe_json(result),
|
|
168
|
+
status,
|
|
169
|
+
duration_ms,
|
|
170
|
+
agent,
|
|
171
|
+
workflow_id,
|
|
172
|
+
user or _current_user(),
|
|
173
|
+
risk_level,
|
|
174
|
+
rationale,
|
|
175
|
+
approved_by,
|
|
176
|
+
risk_tier,
|
|
177
|
+
),
|
|
178
|
+
)
|
|
179
|
+
conn.commit()
|
|
180
|
+
conn.close()
|
|
181
|
+
_log.debug("[AUDIT] %s.%s -> %s (%dms)", skill, tool, status, duration_ms)
|
|
182
|
+
except Exception:
|
|
183
|
+
_log.warning("Failed to write audit log", exc_info=True)
|
|
184
|
+
|
|
185
|
+
# ── Rotation ──────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
def _maybe_rotate(self) -> None:
|
|
188
|
+
"""Archive the DB if it exceeds size limit."""
|
|
189
|
+
try:
|
|
190
|
+
if not self._path.exists():
|
|
191
|
+
return
|
|
192
|
+
if self._path.stat().st_size < _MAX_DB_SIZE_BYTES:
|
|
193
|
+
return
|
|
194
|
+
archive_name = self._path.with_suffix(
|
|
195
|
+
f".{datetime.now(tz=UTC).strftime('%Y%m%d-%H%M%S')}.db"
|
|
196
|
+
)
|
|
197
|
+
# Checkpoint the WAL into the main file before renaming, otherwise
|
|
198
|
+
# un-checkpointed rows in audit.db-wal are silently lost (the
|
|
199
|
+
# sidecars belong to the OLD path and never follow the archive).
|
|
200
|
+
try:
|
|
201
|
+
conn = self._connect()
|
|
202
|
+
try:
|
|
203
|
+
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
204
|
+
finally:
|
|
205
|
+
conn.close()
|
|
206
|
+
except Exception:
|
|
207
|
+
_log.warning("WAL checkpoint before rotation failed", exc_info=True)
|
|
208
|
+
self._path.rename(archive_name)
|
|
209
|
+
# Drop now-stale sidecars (empty after a successful checkpoint).
|
|
210
|
+
for suffix in ("-wal", "-shm"):
|
|
211
|
+
sidecar = self._path.with_name(self._path.name + suffix)
|
|
212
|
+
try:
|
|
213
|
+
sidecar.unlink(missing_ok=True)
|
|
214
|
+
except OSError:
|
|
215
|
+
pass
|
|
216
|
+
self._init_db()
|
|
217
|
+
self._harden_permissions()
|
|
218
|
+
self._cleanup_archives()
|
|
219
|
+
_log.info("Audit DB rotated → %s", archive_name.name)
|
|
220
|
+
except Exception:
|
|
221
|
+
_log.warning("Audit DB rotation failed", exc_info=True)
|
|
222
|
+
|
|
223
|
+
def _cleanup_archives(self) -> None:
|
|
224
|
+
"""Keep only the most recent N archive files."""
|
|
225
|
+
archives = sorted(
|
|
226
|
+
self._path.parent.glob("audit.*.db"),
|
|
227
|
+
key=lambda p: p.stat().st_mtime,
|
|
228
|
+
reverse=True,
|
|
229
|
+
)
|
|
230
|
+
for old in archives[_MAX_ARCHIVES:]:
|
|
231
|
+
old.unlink(missing_ok=True)
|
|
232
|
+
|
|
233
|
+
# ── Query helpers (used by CLI) ───────────────────────────────────
|
|
234
|
+
|
|
235
|
+
def query(
|
|
236
|
+
self,
|
|
237
|
+
*,
|
|
238
|
+
skill: str | None = None,
|
|
239
|
+
tool: str | None = None,
|
|
240
|
+
status: str | None = None,
|
|
241
|
+
workflow_id: str | None = None,
|
|
242
|
+
since: str | None = None,
|
|
243
|
+
limit: int = 50,
|
|
244
|
+
) -> list[dict[str, Any]]:
|
|
245
|
+
"""Query audit records with optional filters."""
|
|
246
|
+
clauses: list[str] = []
|
|
247
|
+
values: list[Any] = []
|
|
248
|
+
|
|
249
|
+
if skill:
|
|
250
|
+
clauses.append("skill = ?")
|
|
251
|
+
values.append(skill)
|
|
252
|
+
if tool:
|
|
253
|
+
clauses.append("tool = ?")
|
|
254
|
+
values.append(tool)
|
|
255
|
+
if status:
|
|
256
|
+
clauses.append("status = ?")
|
|
257
|
+
values.append(status)
|
|
258
|
+
if workflow_id:
|
|
259
|
+
clauses.append("workflow_id = ?")
|
|
260
|
+
values.append(workflow_id)
|
|
261
|
+
if since:
|
|
262
|
+
clauses.append("ts >= ?")
|
|
263
|
+
values.append(since)
|
|
264
|
+
|
|
265
|
+
# ``clauses`` are hardcoded "col = ?" fragments; every user value is a
|
|
266
|
+
# bound parameter in ``values``. No user input is interpolated here.
|
|
267
|
+
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
268
|
+
sql = f"SELECT * FROM audit_log {where} ORDER BY id DESC LIMIT ?" # nosec B608
|
|
269
|
+
values.append(limit)
|
|
270
|
+
|
|
271
|
+
conn = self._connect()
|
|
272
|
+
try:
|
|
273
|
+
conn.row_factory = sqlite3.Row
|
|
274
|
+
rows = conn.execute(sql, values).fetchall()
|
|
275
|
+
return [dict(r) for r in rows]
|
|
276
|
+
finally:
|
|
277
|
+
conn.close()
|
|
278
|
+
|
|
279
|
+
def stats(self, days: int = 7) -> dict[str, Any]:
|
|
280
|
+
"""Aggregate statistics over the last N days."""
|
|
281
|
+
since = (datetime.now(tz=UTC) - timedelta(days=days)).isoformat()
|
|
282
|
+
conn = self._connect()
|
|
283
|
+
try:
|
|
284
|
+
conn.row_factory = sqlite3.Row
|
|
285
|
+
|
|
286
|
+
total = conn.execute(
|
|
287
|
+
"SELECT COUNT(*) as c FROM audit_log WHERE ts >= ?", (since,)
|
|
288
|
+
).fetchone()["c"]
|
|
289
|
+
|
|
290
|
+
by_status = {
|
|
291
|
+
r["status"]: r["c"]
|
|
292
|
+
for r in conn.execute(
|
|
293
|
+
"SELECT status, COUNT(*) as c FROM audit_log WHERE ts >= ? GROUP BY status",
|
|
294
|
+
(since,),
|
|
295
|
+
).fetchall()
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
by_skill = {
|
|
299
|
+
r["skill"]: r["c"]
|
|
300
|
+
for r in conn.execute(
|
|
301
|
+
"SELECT skill, COUNT(*) as c FROM audit_log WHERE ts >= ? GROUP BY skill",
|
|
302
|
+
(since,),
|
|
303
|
+
).fetchall()
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return {"total": total, "by_status": by_status, "by_skill": by_skill, "days": days}
|
|
307
|
+
finally:
|
|
308
|
+
conn.close()
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# ── Module-level helpers ──────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
def _current_user() -> str:
|
|
314
|
+
try:
|
|
315
|
+
return getpass.getuser()
|
|
316
|
+
except Exception:
|
|
317
|
+
return "unknown"
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _safe_json(obj: Any) -> str:
|
|
321
|
+
"""Serialize to JSON, falling back to str() for non-serializable objects."""
|
|
322
|
+
if obj is None:
|
|
323
|
+
return "{}"
|
|
324
|
+
try:
|
|
325
|
+
return json.dumps(obj, ensure_ascii=False, default=str)
|
|
326
|
+
except Exception:
|
|
327
|
+
return json.dumps({"_raw": str(obj)})
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def detect_agent() -> str:
|
|
331
|
+
"""Infer the calling AI agent from environment variables."""
|
|
332
|
+
if os.environ.get("CLAUDE_SESSION_ID") or os.environ.get("CLAUDE_CODE"):
|
|
333
|
+
return "claude"
|
|
334
|
+
# Note: OPENAI_API_KEY is deliberately NOT used as a codex marker — it is
|
|
335
|
+
# commonly set in shells for unrelated tooling and misattributes the agent.
|
|
336
|
+
if os.environ.get("CODEX_SESSION"):
|
|
337
|
+
return "codex"
|
|
338
|
+
if os.environ.get("OLLAMA_HOST"):
|
|
339
|
+
return "local"
|
|
340
|
+
if os.environ.get("DEERFLOW_SESSION"):
|
|
341
|
+
return "deerflow"
|
|
342
|
+
return "unknown"
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# Singleton — shared across all skills in the same process
|
|
346
|
+
_engine: AuditEngine | None = None
|
|
347
|
+
_engine_lock = threading.Lock()
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def get_engine(db_path: Path | str | None = None) -> AuditEngine:
|
|
351
|
+
"""Return the global AuditEngine singleton (lazy, lock-guarded).
|
|
352
|
+
|
|
353
|
+
A ``db_path`` differing from the one the singleton was created with is
|
|
354
|
+
ignored with a warning — call :func:`reset_engine` first to rebind.
|
|
355
|
+
"""
|
|
356
|
+
global _engine
|
|
357
|
+
if _engine is None:
|
|
358
|
+
with _engine_lock:
|
|
359
|
+
if _engine is None:
|
|
360
|
+
_engine = AuditEngine(db_path)
|
|
361
|
+
return _engine
|
|
362
|
+
if db_path is not None:
|
|
363
|
+
requested = Path(db_path).expanduser()
|
|
364
|
+
if requested != _engine._path:
|
|
365
|
+
_log.warning(
|
|
366
|
+
"get_engine(%s) ignored — singleton already initialized at %s; "
|
|
367
|
+
"call reset_engine() first to rebind.",
|
|
368
|
+
requested, _engine._path,
|
|
369
|
+
)
|
|
370
|
+
return _engine
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def reset_engine() -> None:
|
|
374
|
+
"""Reset the singleton. Mirrors patterns.reset_pattern_engine()."""
|
|
375
|
+
global _engine
|
|
376
|
+
with _engine_lock:
|
|
377
|
+
_engine = None
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Per-process tool-call budget + runaway-loop circuit breaker.
|
|
2
|
+
|
|
3
|
+
Implements the 2026 agentic-ops "termination condition" / "hard budget"
|
|
4
|
+
guardrail. An LLM agent that loops a tool (polling a long task, retrying a
|
|
5
|
+
flaky call) otherwise consumes unbounded calls and wall-time — exactly the
|
|
6
|
+
failure mode behind the "deleting one snapshot burned 26k tokens over 30 min"
|
|
7
|
+
incident. Two layers, both enforced from the ``@governed_tool`` pre-check:
|
|
8
|
+
|
|
9
|
+
1. **Hard ceilings (opt-in via env).** Total tool calls and cumulative
|
|
10
|
+
wall-time per process. ``POSTGRES_MAX_TOOL_CALLS`` / ``POSTGRES_MAX_TOOL_SECONDS``.
|
|
11
|
+
Unset (the default) means no ceiling — existing behavior is unchanged.
|
|
12
|
+
|
|
13
|
+
2. **Runaway breaker (on by default).** The same ``(tool, params)`` called more
|
|
14
|
+
than ``POSTGRES_RUNAWAY_MAX`` times within ``POSTGRES_RUNAWAY_WINDOW_SEC`` trips a
|
|
15
|
+
short cooldown. Catches a tight poll/retry loop without affecting normal,
|
|
16
|
+
varied tool use. Defaults are generous (25 identical calls in 120s).
|
|
17
|
+
|
|
18
|
+
Exceeding any limit raises :class:`BudgetExceeded`, a :class:`PolicyDenied`
|
|
19
|
+
subclass so the decorator and downstream handlers treat it as a denial carrying
|
|
20
|
+
a teaching message (and audit it as a denied call). The exception is a *hard
|
|
21
|
+
stop*: it forces the agent to break out of the loop rather than keep spending.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
from collections import deque
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
|
|
33
|
+
from postgres_aiops.governance.policy import PolicyResult
|
|
34
|
+
|
|
35
|
+
__all__ = ["BudgetExceeded", "BudgetTracker", "get_budget", "reset_budget"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Defaults — chosen so normal use never trips them, but a runaway loop does.
|
|
39
|
+
_DEFAULT_RUNAWAY_MAX = 25
|
|
40
|
+
_DEFAULT_RUNAWAY_WINDOW_SEC = 120
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class BudgetExceeded(Exception):
|
|
44
|
+
"""Raised when a tool call would exceed a budget / runaway limit.
|
|
45
|
+
|
|
46
|
+
Subclasses nothing Endpoint-specific; the decorator wraps it into a
|
|
47
|
+
PolicyResult so it shares the existing denial audit path. Kept as its own
|
|
48
|
+
type (not raw PolicyDenied) so callers can catch budget stops distinctly.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, reason: str, rule: str = "budget") -> None:
|
|
52
|
+
self.reason = reason
|
|
53
|
+
self.rule = rule
|
|
54
|
+
# A PolicyResult mirrors what the policy engine returns on denial, so
|
|
55
|
+
# the decorator can treat budget stops and policy denials uniformly.
|
|
56
|
+
self.policy_result = PolicyResult(allowed=False, rule=rule, reason=reason)
|
|
57
|
+
super().__init__(reason)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _env_int(name: str, default: int | None) -> int | None:
|
|
61
|
+
"""Read an int budget limit from env, falling back to ``default``."""
|
|
62
|
+
raw = os.environ.get(name)
|
|
63
|
+
if raw is None or raw == "":
|
|
64
|
+
return default
|
|
65
|
+
try:
|
|
66
|
+
return int(raw)
|
|
67
|
+
except ValueError:
|
|
68
|
+
return default
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class _State:
|
|
73
|
+
total_calls: int = 0
|
|
74
|
+
total_seconds: float = 0.0
|
|
75
|
+
# fingerprint -> sliding window of epoch-second timestamps
|
|
76
|
+
windows: dict[str, deque] = field(default_factory=dict)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class BudgetTracker:
|
|
80
|
+
"""Thread-safe per-process budget + runaway guard.
|
|
81
|
+
|
|
82
|
+
Limits are read from the environment on every check so they can be raised
|
|
83
|
+
mid-session (e.g. an operator sets ``POSTGRES_RUNAWAY_MAX`` higher) without a
|
|
84
|
+
restart. State is in-memory and per process — it resets when the MCP server
|
|
85
|
+
restarts, which is the correct scope for a single agent session.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(self) -> None:
|
|
89
|
+
self._lock = threading.Lock()
|
|
90
|
+
self._state = _State()
|
|
91
|
+
|
|
92
|
+
# ── Limits (read live from env) ───────────────────────────────────
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def _max_calls() -> int | None:
|
|
96
|
+
return _env_int("POSTGRES_MAX_TOOL_CALLS", None)
|
|
97
|
+
|
|
98
|
+
@staticmethod
|
|
99
|
+
def _max_seconds() -> int | None:
|
|
100
|
+
return _env_int("POSTGRES_MAX_TOOL_SECONDS", None)
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
def _runaway_max() -> int:
|
|
104
|
+
# 0 or negative disables the runaway breaker.
|
|
105
|
+
return _env_int("POSTGRES_RUNAWAY_MAX", _DEFAULT_RUNAWAY_MAX) or 0
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def _runaway_window() -> int:
|
|
109
|
+
return _env_int("POSTGRES_RUNAWAY_WINDOW_SEC", _DEFAULT_RUNAWAY_WINDOW_SEC) or 0
|
|
110
|
+
|
|
111
|
+
# ── Enforcement ───────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
def check_and_record(self, tool: str, params: dict | None) -> None:
|
|
114
|
+
"""Enforce ceilings + runaway guard, then record this call.
|
|
115
|
+
|
|
116
|
+
Raises :class:`BudgetExceeded` before the call proceeds when a limit
|
|
117
|
+
would be crossed. Only called for operations the policy engine already
|
|
118
|
+
allowed, so denied calls do not count against the budget.
|
|
119
|
+
"""
|
|
120
|
+
now = time.time()
|
|
121
|
+
fp = _fingerprint(tool, params)
|
|
122
|
+
with self._lock:
|
|
123
|
+
# 1. Hard call ceiling
|
|
124
|
+
max_calls = self._max_calls()
|
|
125
|
+
if max_calls is not None and self._state.total_calls >= max_calls:
|
|
126
|
+
raise BudgetExceeded(
|
|
127
|
+
f"Tool-call budget reached: {self._state.total_calls} calls "
|
|
128
|
+
f"(POSTGRES_MAX_TOOL_CALLS={max_calls}). Stop and summarize what "
|
|
129
|
+
f"is done; raise the ceiling only if the work genuinely needs "
|
|
130
|
+
f"more calls.",
|
|
131
|
+
rule="budget_calls",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# 2. Hard wall-time ceiling (cumulative across all tool calls)
|
|
135
|
+
max_seconds = self._max_seconds()
|
|
136
|
+
if max_seconds is not None and self._state.total_seconds >= max_seconds:
|
|
137
|
+
raise BudgetExceeded(
|
|
138
|
+
f"Cumulative tool wall-time budget reached: "
|
|
139
|
+
f"{self._state.total_seconds:.0f}s "
|
|
140
|
+
f"(POSTGRES_MAX_TOOL_SECONDS={max_seconds}). Stop and report "
|
|
141
|
+
f"progress rather than continuing.",
|
|
142
|
+
rule="budget_seconds",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# 3. Runaway breaker — same (tool, params) hammered in a short window
|
|
146
|
+
runaway_max = self._runaway_max()
|
|
147
|
+
window = self._runaway_window()
|
|
148
|
+
if runaway_max > 0 and window > 0:
|
|
149
|
+
dq = self._state.windows.get(fp)
|
|
150
|
+
if dq is None:
|
|
151
|
+
dq = deque()
|
|
152
|
+
self._state.windows[fp] = dq
|
|
153
|
+
cutoff = now - window
|
|
154
|
+
while dq and dq[0] < cutoff:
|
|
155
|
+
dq.popleft()
|
|
156
|
+
if len(dq) >= runaway_max:
|
|
157
|
+
raise BudgetExceeded(
|
|
158
|
+
f"Runaway guard tripped: '{tool}' called {len(dq)} times "
|
|
159
|
+
f"with identical arguments in {window}s. This usually means "
|
|
160
|
+
f"a poll/retry loop is stuck. Stop re-calling — check the "
|
|
161
|
+
f"operation's status once, or wait, instead of looping. "
|
|
162
|
+
f"(Raise POSTGRES_RUNAWAY_MAX if this is genuinely intended.)",
|
|
163
|
+
rule="budget_runaway",
|
|
164
|
+
)
|
|
165
|
+
dq.append(now)
|
|
166
|
+
|
|
167
|
+
self._state.total_calls += 1
|
|
168
|
+
|
|
169
|
+
def add_duration(self, seconds: float) -> None:
|
|
170
|
+
"""Accumulate a completed call's wall-time toward the time ceiling."""
|
|
171
|
+
if seconds <= 0:
|
|
172
|
+
return
|
|
173
|
+
with self._lock:
|
|
174
|
+
self._state.total_seconds += seconds
|
|
175
|
+
|
|
176
|
+
def snapshot(self) -> dict:
|
|
177
|
+
"""Return a copy of current counters (for CLI / introspection / tests)."""
|
|
178
|
+
with self._lock:
|
|
179
|
+
return {
|
|
180
|
+
"total_calls": self._state.total_calls,
|
|
181
|
+
"total_seconds": round(self._state.total_seconds, 1),
|
|
182
|
+
"tracked_fingerprints": len(self._state.windows),
|
|
183
|
+
"limits": {
|
|
184
|
+
"max_calls": self._max_calls(),
|
|
185
|
+
"max_seconds": self._max_seconds(),
|
|
186
|
+
"runaway_max": self._runaway_max(),
|
|
187
|
+
"runaway_window_sec": self._runaway_window(),
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _fingerprint(tool: str, params: dict | None) -> str:
|
|
193
|
+
"""Stable identity for a (tool, params) pair used by the runaway breaker.
|
|
194
|
+
|
|
195
|
+
Uses already-redacted params (secrets are '***'), so the fingerprint is
|
|
196
|
+
stable and safe. Falls back to repr on non-serializable values.
|
|
197
|
+
"""
|
|
198
|
+
try:
|
|
199
|
+
body = json.dumps(params or {}, sort_keys=True, default=str)
|
|
200
|
+
except Exception: # noqa: BLE001 — fingerprint must never raise
|
|
201
|
+
body = repr(params)
|
|
202
|
+
return f"{tool}|{body}"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ── Singleton ──────────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
_tracker: BudgetTracker | None = None
|
|
208
|
+
_tracker_lock = threading.Lock()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def get_budget() -> BudgetTracker:
|
|
212
|
+
"""Return the global per-process BudgetTracker singleton."""
|
|
213
|
+
global _tracker
|
|
214
|
+
if _tracker is None:
|
|
215
|
+
with _tracker_lock:
|
|
216
|
+
if _tracker is None:
|
|
217
|
+
_tracker = BudgetTracker()
|
|
218
|
+
return _tracker
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def reset_budget() -> None:
|
|
222
|
+
"""Reset the singleton. Tests use this between cases."""
|
|
223
|
+
global _tracker
|
|
224
|
+
with _tracker_lock:
|
|
225
|
+
_tracker = None
|