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.
Files changed (57) hide show
  1. mcp_server/__init__.py +1 -0
  2. mcp_server/_shared.py +101 -0
  3. mcp_server/server.py +37 -0
  4. mcp_server/tools/__init__.py +1 -0
  5. mcp_server/tools/activity.py +52 -0
  6. mcp_server/tools/analysis.py +89 -0
  7. mcp_server/tools/indexes.py +65 -0
  8. mcp_server/tools/queries.py +60 -0
  9. mcp_server/tools/remediation.py +281 -0
  10. mcp_server/tools/replication.py +43 -0
  11. mcp_server/tools/server.py +86 -0
  12. mcp_server/tools/tables.py +46 -0
  13. postgres_aiops/__init__.py +9 -0
  14. postgres_aiops/cli/__init__.py +9 -0
  15. postgres_aiops/cli/_common.py +78 -0
  16. postgres_aiops/cli/_root.py +68 -0
  17. postgres_aiops/cli/activity.py +52 -0
  18. postgres_aiops/cli/analyze.py +53 -0
  19. postgres_aiops/cli/doctor.py +21 -0
  20. postgres_aiops/cli/index.py +55 -0
  21. postgres_aiops/cli/init.py +112 -0
  22. postgres_aiops/cli/overview.py +16 -0
  23. postgres_aiops/cli/query.py +70 -0
  24. postgres_aiops/cli/remediate.py +186 -0
  25. postgres_aiops/cli/replication.py +45 -0
  26. postgres_aiops/cli/secret.py +103 -0
  27. postgres_aiops/cli/server.py +69 -0
  28. postgres_aiops/cli/table.py +45 -0
  29. postgres_aiops/config.py +154 -0
  30. postgres_aiops/connection.py +178 -0
  31. postgres_aiops/doctor.py +84 -0
  32. postgres_aiops/governance/__init__.py +40 -0
  33. postgres_aiops/governance/audit.py +377 -0
  34. postgres_aiops/governance/budget.py +225 -0
  35. postgres_aiops/governance/decorators.py +474 -0
  36. postgres_aiops/governance/paths.py +23 -0
  37. postgres_aiops/governance/patterns.py +378 -0
  38. postgres_aiops/governance/policy.py +411 -0
  39. postgres_aiops/governance/sanitize.py +39 -0
  40. postgres_aiops/governance/undo.py +218 -0
  41. postgres_aiops/ops/__init__.py +1 -0
  42. postgres_aiops/ops/_util.py +102 -0
  43. postgres_aiops/ops/activity.py +193 -0
  44. postgres_aiops/ops/analysis.py +263 -0
  45. postgres_aiops/ops/indexes.py +211 -0
  46. postgres_aiops/ops/overview.py +51 -0
  47. postgres_aiops/ops/queries.py +123 -0
  48. postgres_aiops/ops/remediation.py +237 -0
  49. postgres_aiops/ops/replication.py +144 -0
  50. postgres_aiops/ops/server.py +151 -0
  51. postgres_aiops/ops/tables.py +146 -0
  52. postgres_aiops/secretstore.py +302 -0
  53. postgres_aiops-0.1.0.dist-info/METADATA +119 -0
  54. postgres_aiops-0.1.0.dist-info/RECORD +57 -0
  55. postgres_aiops-0.1.0.dist-info/WHEEL +4 -0
  56. postgres_aiops-0.1.0.dist-info/entry_points.txt +3 -0
  57. 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