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,411 @@
|
|
|
1
|
+
"""Policy engine — rule-based access control for Endpoint MCP tools.
|
|
2
|
+
|
|
3
|
+
Rules are loaded from ``~/.postgres-aiops/rules.yaml`` with hot-reload on file change.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import threading
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import UTC
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from postgres_aiops.governance.paths import ops_path
|
|
17
|
+
|
|
18
|
+
_log = logging.getLogger("postgres-aiops.policy")
|
|
19
|
+
|
|
20
|
+
# ── Data structures ───────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class PolicyResult:
|
|
25
|
+
"""Outcome of a policy check."""
|
|
26
|
+
|
|
27
|
+
allowed: bool
|
|
28
|
+
rule: str = ""
|
|
29
|
+
reason: str = ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class TierDecision:
|
|
34
|
+
"""Graduated-autonomy outcome: the approval tier an operation needs.
|
|
35
|
+
|
|
36
|
+
tier is one of APPROVAL_TIERS. ``requires_approver`` is True for tiers that
|
|
37
|
+
must carry a named human approver (dual / review) — the decorator denies
|
|
38
|
+
such calls when no approver is recorded.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
tier: str = "none"
|
|
42
|
+
rule: str = "default"
|
|
43
|
+
reason: str = ""
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def requires_approver(self) -> bool:
|
|
47
|
+
return self.tier in ("dual", "review")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ── Risk levels ───────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
RISK_LEVELS = ("low", "medium", "high", "critical")
|
|
53
|
+
|
|
54
|
+
# Graduated autonomy tiers, least → most oversight.
|
|
55
|
+
# none — no gate (dev / low-risk)
|
|
56
|
+
# confirm — CLI double-confirm (informational at the harness layer)
|
|
57
|
+
# dual — requires a named approver to be recorded (two-person rule)
|
|
58
|
+
# review — requires a named approver + intended for explicit human review
|
|
59
|
+
APPROVAL_TIERS = ("none", "confirm", "dual", "review")
|
|
60
|
+
|
|
61
|
+
# Param keys whose string values are treated as resource tags / placement for
|
|
62
|
+
# tier matching (e.g. a VM's folder or environment tag: prod/staging/dev).
|
|
63
|
+
_TAG_PARAM_KEYS = ("tag", "tags", "folder", "resource_tag", "env_tier", "environment")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def risk_requires_confirmation(risk_level: str, env: str = "") -> bool:
|
|
67
|
+
"""Determine if a risk level requires human confirmation.
|
|
68
|
+
|
|
69
|
+
- critical: always requires confirmation + approval in production
|
|
70
|
+
- high: requires confirmation
|
|
71
|
+
- medium/low: no confirmation
|
|
72
|
+
"""
|
|
73
|
+
if risk_level == "critical":
|
|
74
|
+
return True
|
|
75
|
+
if risk_level == "high":
|
|
76
|
+
return True
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ── Rule loading with hot-reload ──────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class PolicyEngine:
|
|
84
|
+
"""Evaluate operations against a YAML rule set.
|
|
85
|
+
|
|
86
|
+
Rules file is re-read when its mtime changes (hot-reload, no restart needed).
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(self, rules_path: Path | str | None = None) -> None:
|
|
90
|
+
self._path = Path(rules_path).expanduser() if rules_path else ops_path("rules.yaml")
|
|
91
|
+
self._rules: dict[str, Any] = {}
|
|
92
|
+
self._mtime: float = 0.0
|
|
93
|
+
self._load_rules()
|
|
94
|
+
|
|
95
|
+
def _load_rules(self) -> None:
|
|
96
|
+
"""Load rules from YAML file. Missing file → empty rules (allow all)."""
|
|
97
|
+
if not self._path.exists():
|
|
98
|
+
self._rules = {}
|
|
99
|
+
self._mtime = 0.0
|
|
100
|
+
return
|
|
101
|
+
try:
|
|
102
|
+
import yaml
|
|
103
|
+
|
|
104
|
+
self._mtime = self._path.stat().st_mtime
|
|
105
|
+
with open(self._path) as fh:
|
|
106
|
+
self._rules = yaml.safe_load(fh) or {}
|
|
107
|
+
_log.debug("Loaded %d policy rules from %s", len(self._rules), self._path)
|
|
108
|
+
except Exception:
|
|
109
|
+
_log.warning("Failed to load policy rules from %s", self._path, exc_info=True)
|
|
110
|
+
self._rules = {}
|
|
111
|
+
|
|
112
|
+
def _maybe_reload(self) -> None:
|
|
113
|
+
"""Hot-reload if file changed."""
|
|
114
|
+
if not self._path.exists():
|
|
115
|
+
if self._rules:
|
|
116
|
+
_log.warning("Policy rules file deleted: %s — clearing rules (allow all)", self._path)
|
|
117
|
+
self._rules = {}
|
|
118
|
+
self._mtime = 0.0
|
|
119
|
+
return
|
|
120
|
+
try:
|
|
121
|
+
current_mtime = self._path.stat().st_mtime
|
|
122
|
+
if current_mtime != self._mtime:
|
|
123
|
+
self._load_rules()
|
|
124
|
+
except Exception:
|
|
125
|
+
_log.warning("Failed to check policy rules file: %s", self._path, exc_info=True)
|
|
126
|
+
|
|
127
|
+
def check_allowed(
|
|
128
|
+
self,
|
|
129
|
+
operation: str,
|
|
130
|
+
*,
|
|
131
|
+
env: str = "",
|
|
132
|
+
risk_level: str = "low",
|
|
133
|
+
params: dict[str, Any] | None = None,
|
|
134
|
+
) -> PolicyResult:
|
|
135
|
+
"""Check if an operation is allowed by policy.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
operation: Tool function name (e.g. 'delete_segment').
|
|
139
|
+
env: Target environment name (e.g. 'production').
|
|
140
|
+
risk_level: Risk level declared by @governed_tool.
|
|
141
|
+
params: Operation parameters for rule evaluation.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
PolicyResult with allowed=True/False and reason.
|
|
145
|
+
"""
|
|
146
|
+
# Bypass mode — log context for audit trail. Log only parameter NAMES,
|
|
147
|
+
# never values: param values may carry passwords/tokens, and this path
|
|
148
|
+
# can be reached by callers that did not pre-redact.
|
|
149
|
+
if os.environ.get("POSTGRES_POLICY_DISABLED") == "1":
|
|
150
|
+
param_names = sorted(params.keys()) if isinstance(params, dict) else []
|
|
151
|
+
_log.warning(
|
|
152
|
+
"Policy DISABLED — bypassing check: operation=%s env=%s risk=%s param_keys=%s",
|
|
153
|
+
operation, env, risk_level, param_names,
|
|
154
|
+
)
|
|
155
|
+
return PolicyResult(allowed=True, rule="policy_disabled")
|
|
156
|
+
|
|
157
|
+
self._maybe_reload()
|
|
158
|
+
|
|
159
|
+
# No rules file → allow everything
|
|
160
|
+
if not self._rules:
|
|
161
|
+
return PolicyResult(allowed=True, rule="no_rules")
|
|
162
|
+
|
|
163
|
+
# ── Evaluate deny rules ───────────────────────────────────────
|
|
164
|
+
deny_rules = self._rules.get("deny", [])
|
|
165
|
+
for rule in deny_rules:
|
|
166
|
+
if self._rule_matches(rule, operation, env, risk_level, params):
|
|
167
|
+
reason = rule.get("reason", f"Denied by rule: {rule.get('name', 'unnamed')}")
|
|
168
|
+
return PolicyResult(allowed=False, rule=rule.get("name", "deny"), reason=reason)
|
|
169
|
+
|
|
170
|
+
# ── Evaluate maintenance window ───────────────────────────────
|
|
171
|
+
window = self._rules.get("maintenance_window")
|
|
172
|
+
if window and risk_level in ("high", "critical"):
|
|
173
|
+
try:
|
|
174
|
+
in_window = self._in_maintenance_window(window)
|
|
175
|
+
except (ValueError, TypeError, AttributeError):
|
|
176
|
+
# Fail CLOSED: a malformed window must not silently allow
|
|
177
|
+
# high-risk operations around the clock.
|
|
178
|
+
_log.error(
|
|
179
|
+
"Malformed maintenance_window %r in %s — failing CLOSED: "
|
|
180
|
+
"high-risk operations are blocked until the rule is fixed. "
|
|
181
|
+
"Expected 'start' and 'end' as 'HH:MM' strings, e.g. "
|
|
182
|
+
"start: \"22:00\" / end: \"06:00\".",
|
|
183
|
+
window, self._path,
|
|
184
|
+
)
|
|
185
|
+
return PolicyResult(
|
|
186
|
+
allowed=False,
|
|
187
|
+
rule="maintenance_window_malformed",
|
|
188
|
+
reason=(
|
|
189
|
+
f"maintenance_window in {self._path} is malformed "
|
|
190
|
+
f"({window!r}). High-risk operations are blocked until it is "
|
|
191
|
+
"fixed. Expected 'start' and 'end' as 'HH:MM' strings, "
|
|
192
|
+
'e.g. start: "22:00" / end: "06:00".'
|
|
193
|
+
),
|
|
194
|
+
)
|
|
195
|
+
if not in_window:
|
|
196
|
+
return PolicyResult(
|
|
197
|
+
allowed=False,
|
|
198
|
+
rule="maintenance_window",
|
|
199
|
+
reason=f"High-risk operations only allowed during {window.get('start', '?')}-{window.get('end', '?')}",
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# ── Evaluate change limits (reserved, not implemented) ────────
|
|
203
|
+
# change_limits is NOT an enforced feature: _check_limits only warns
|
|
204
|
+
# that configured limits are ignored (it can't compute deltas without
|
|
205
|
+
# before-state). Kept so misconfiguration is surfaced, not silent.
|
|
206
|
+
limits = self._rules.get("change_limits", {})
|
|
207
|
+
if params and limits:
|
|
208
|
+
result = self._check_limits(limits, params, operation)
|
|
209
|
+
if result and not result.allowed:
|
|
210
|
+
return result
|
|
211
|
+
|
|
212
|
+
return PolicyResult(allowed=True, rule="default_allow")
|
|
213
|
+
|
|
214
|
+
def required_approval_tier(
|
|
215
|
+
self,
|
|
216
|
+
operation: str,
|
|
217
|
+
*,
|
|
218
|
+
env: str = "",
|
|
219
|
+
risk_level: str = "low",
|
|
220
|
+
params: dict[str, Any] | None = None,
|
|
221
|
+
) -> TierDecision:
|
|
222
|
+
"""Return the approval tier this operation needs (graduated autonomy).
|
|
223
|
+
|
|
224
|
+
Evaluated from a ``risk_tiers`` list in rules.yaml — each entry matches
|
|
225
|
+
on operation glob / environment / resource tag / minimum risk and maps
|
|
226
|
+
to a tier (none/confirm/dual/review). The FIRST matching, HIGHEST tier
|
|
227
|
+
wins so a prod-tagged destructive op can't be down-graded by a looser
|
|
228
|
+
rule listed earlier. No config → tier ``none`` (backward compatible).
|
|
229
|
+
"""
|
|
230
|
+
self._maybe_reload()
|
|
231
|
+
tiers = self._rules.get("risk_tiers") if self._rules else None
|
|
232
|
+
if not tiers:
|
|
233
|
+
return TierDecision(tier="none", rule="no_tiers")
|
|
234
|
+
|
|
235
|
+
tags = _extract_tags(params)
|
|
236
|
+
best: TierDecision | None = None
|
|
237
|
+
for rule in tiers:
|
|
238
|
+
tier = str(rule.get("tier", "")).lower()
|
|
239
|
+
if tier not in APPROVAL_TIERS:
|
|
240
|
+
continue
|
|
241
|
+
if not self._tier_rule_matches(rule, operation, env, risk_level, tags):
|
|
242
|
+
continue
|
|
243
|
+
if best is None or APPROVAL_TIERS.index(tier) > APPROVAL_TIERS.index(best.tier):
|
|
244
|
+
best = TierDecision(
|
|
245
|
+
tier=tier,
|
|
246
|
+
rule=str(rule.get("name", "risk_tier")),
|
|
247
|
+
reason=str(rule.get("reason", "")),
|
|
248
|
+
)
|
|
249
|
+
return best or TierDecision(tier="none", rule="no_tier_match")
|
|
250
|
+
|
|
251
|
+
def _tier_rule_matches(
|
|
252
|
+
self,
|
|
253
|
+
rule: dict[str, Any],
|
|
254
|
+
operation: str,
|
|
255
|
+
env: str,
|
|
256
|
+
risk_level: str,
|
|
257
|
+
tags: set[str],
|
|
258
|
+
) -> bool:
|
|
259
|
+
"""Match a risk_tiers entry against the current call."""
|
|
260
|
+
if "operations" in rule:
|
|
261
|
+
ops = rule["operations"]
|
|
262
|
+
if not ops or not any(self._pattern_match(op, operation) for op in ops):
|
|
263
|
+
return False
|
|
264
|
+
envs = rule.get("environments", [])
|
|
265
|
+
if envs and env and env not in envs:
|
|
266
|
+
return False
|
|
267
|
+
if envs and not env:
|
|
268
|
+
return False # rule scoped to envs but call has none → no match
|
|
269
|
+
rule_tags = {str(t) for t in (rule.get("tags") or [])}
|
|
270
|
+
if rule_tags and not (rule_tags & tags):
|
|
271
|
+
return False
|
|
272
|
+
min_risk = rule.get("min_risk_level")
|
|
273
|
+
if min_risk and RISK_LEVELS.index(risk_level) < RISK_LEVELS.index(min_risk):
|
|
274
|
+
return False
|
|
275
|
+
return True
|
|
276
|
+
|
|
277
|
+
def _rule_matches(
|
|
278
|
+
self,
|
|
279
|
+
rule: dict[str, Any],
|
|
280
|
+
operation: str,
|
|
281
|
+
env: str,
|
|
282
|
+
risk_level: str,
|
|
283
|
+
params: dict[str, Any] | None,
|
|
284
|
+
) -> bool:
|
|
285
|
+
"""Check if a deny rule matches the current operation."""
|
|
286
|
+
# Match by operation pattern
|
|
287
|
+
# Note: "operations" key absent → match all (no filter).
|
|
288
|
+
# "operations: []" → match nothing (explicit empty = no operations apply).
|
|
289
|
+
if "operations" in rule:
|
|
290
|
+
ops = rule["operations"]
|
|
291
|
+
if not ops or not any(self._pattern_match(op, operation) for op in ops):
|
|
292
|
+
return False
|
|
293
|
+
|
|
294
|
+
# Match by environment
|
|
295
|
+
envs = rule.get("environments", [])
|
|
296
|
+
if envs and env and env not in envs:
|
|
297
|
+
return False
|
|
298
|
+
|
|
299
|
+
# Match by risk level (minimum)
|
|
300
|
+
min_risk = rule.get("min_risk_level")
|
|
301
|
+
if min_risk:
|
|
302
|
+
if RISK_LEVELS.index(risk_level) < RISK_LEVELS.index(min_risk):
|
|
303
|
+
return False
|
|
304
|
+
|
|
305
|
+
return True
|
|
306
|
+
|
|
307
|
+
@staticmethod
|
|
308
|
+
def _pattern_match(pattern: str, value: str) -> bool:
|
|
309
|
+
"""Simple glob: 'delete_*' matches 'delete_segment'."""
|
|
310
|
+
if pattern == "*":
|
|
311
|
+
return True
|
|
312
|
+
if pattern.endswith("*"):
|
|
313
|
+
return value.startswith(pattern[:-1])
|
|
314
|
+
return pattern == value
|
|
315
|
+
|
|
316
|
+
@staticmethod
|
|
317
|
+
def _in_maintenance_window(window: dict[str, str]) -> bool:
|
|
318
|
+
"""Check if current time is within the maintenance window (UTC).
|
|
319
|
+
|
|
320
|
+
Raises ValueError/TypeError/AttributeError when the window is
|
|
321
|
+
malformed — the caller fails CLOSED with a teaching message.
|
|
322
|
+
"""
|
|
323
|
+
from datetime import datetime
|
|
324
|
+
|
|
325
|
+
now = datetime.now(tz=UTC)
|
|
326
|
+
start_h, start_m = map(int, str(window.get("start", "22:00")).split(":"))
|
|
327
|
+
end_h, end_m = map(int, str(window.get("end", "06:00")).split(":"))
|
|
328
|
+
|
|
329
|
+
current_minutes = now.hour * 60 + now.minute
|
|
330
|
+
start_minutes = start_h * 60 + start_m
|
|
331
|
+
end_minutes = end_h * 60 + end_m
|
|
332
|
+
|
|
333
|
+
if start_minutes <= end_minutes:
|
|
334
|
+
return start_minutes <= current_minutes <= end_minutes
|
|
335
|
+
# Wraps midnight (e.g. 22:00 - 06:00)
|
|
336
|
+
return current_minutes >= start_minutes or current_minutes <= end_minutes
|
|
337
|
+
|
|
338
|
+
@staticmethod
|
|
339
|
+
def _check_limits(
|
|
340
|
+
limits: dict[str, Any], params: dict[str, Any], operation: str
|
|
341
|
+
) -> PolicyResult | None:
|
|
342
|
+
"""Check parameter-based limits (e.g. max CPU change %).
|
|
343
|
+
|
|
344
|
+
NOTE: Not yet implemented — requires before-state to compute deltas.
|
|
345
|
+
Logs a warning when limits are configured so operators know they are
|
|
346
|
+
not being enforced.
|
|
347
|
+
"""
|
|
348
|
+
if limits:
|
|
349
|
+
_log.warning(
|
|
350
|
+
"change_limits configured for '%s' but limit enforcement is not yet "
|
|
351
|
+
"implemented — limits are NOT being enforced. Params: %s",
|
|
352
|
+
operation, list(params.keys()),
|
|
353
|
+
)
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _extract_tags(params: dict[str, Any] | None) -> set[str]:
|
|
358
|
+
"""Collect resource-tag-like string values from params for tier matching.
|
|
359
|
+
|
|
360
|
+
Looks at a fixed set of keys (tag/tags/folder/...) and flattens list values
|
|
361
|
+
so ``{"tags": ["prod", "pci"]}`` and ``{"folder": "prod"}`` both yield
|
|
362
|
+
``{"prod", ...}``.
|
|
363
|
+
"""
|
|
364
|
+
if not params:
|
|
365
|
+
return set()
|
|
366
|
+
out: set[str] = set()
|
|
367
|
+
for key in _TAG_PARAM_KEYS:
|
|
368
|
+
if key not in params:
|
|
369
|
+
continue
|
|
370
|
+
val = params[key]
|
|
371
|
+
if isinstance(val, str):
|
|
372
|
+
out.add(val)
|
|
373
|
+
elif isinstance(val, (list, tuple, set)):
|
|
374
|
+
out.update(str(v) for v in val)
|
|
375
|
+
return out
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ── Singleton ─────────────────────────────────────────────────────────
|
|
379
|
+
|
|
380
|
+
_engine: PolicyEngine | None = None
|
|
381
|
+
_engine_lock = threading.Lock()
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def get_policy_engine(rules_path: Path | str | None = None) -> PolicyEngine:
|
|
385
|
+
"""Return the global PolicyEngine singleton (lazy, lock-guarded).
|
|
386
|
+
|
|
387
|
+
A ``rules_path`` differing from the one the singleton was created with is
|
|
388
|
+
ignored with a warning — call :func:`reset_policy_engine` first to rebind.
|
|
389
|
+
"""
|
|
390
|
+
global _engine
|
|
391
|
+
if _engine is None:
|
|
392
|
+
with _engine_lock:
|
|
393
|
+
if _engine is None:
|
|
394
|
+
_engine = PolicyEngine(rules_path)
|
|
395
|
+
return _engine
|
|
396
|
+
if rules_path is not None:
|
|
397
|
+
requested = Path(rules_path).expanduser()
|
|
398
|
+
if requested != _engine._path:
|
|
399
|
+
_log.warning(
|
|
400
|
+
"get_policy_engine(%s) ignored — singleton already initialized "
|
|
401
|
+
"with %s; call reset_policy_engine() first to rebind.",
|
|
402
|
+
requested, _engine._path,
|
|
403
|
+
)
|
|
404
|
+
return _engine
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def reset_policy_engine() -> None:
|
|
408
|
+
"""Reset the singleton. Mirrors patterns.reset_pattern_engine()."""
|
|
409
|
+
global _engine
|
|
410
|
+
with _engine_lock:
|
|
411
|
+
_engine = None
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Prompt-injection defense: strip control characters and truncate untrusted text.
|
|
2
|
+
|
|
3
|
+
Consolidated from 22 duplicate ``_sanitize()`` implementations across 7 Endpoint skills.
|
|
4
|
+
All skills should import from here instead of defining their own copy.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import unicodedata
|
|
11
|
+
|
|
12
|
+
# C0 control chars (except tab \x09, LF \x0a, CR \x0d) + C1 control chars
|
|
13
|
+
_CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def sanitize(text: str | None, max_len: int = 500) -> str:
|
|
17
|
+
"""Strip control characters, Unicode format chars, and truncate.
|
|
18
|
+
|
|
19
|
+
Removes:
|
|
20
|
+
- C0/C1 control characters (except newline/tab)
|
|
21
|
+
- Unicode Format characters (Cf): zero-width spaces, bidi overrides,
|
|
22
|
+
zero-width joiners — used in prompt injection attacks
|
|
23
|
+
|
|
24
|
+
Stripping happens BEFORE truncation so an attacker cannot push the real
|
|
25
|
+
payload past the cut-off by padding with junk control characters.
|
|
26
|
+
``None`` sanitizes to ``""``.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
text: Untrusted text from Endpoint SCALE API responses.
|
|
30
|
+
max_len: Maximum length after truncation. Default 500.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Cleaned, truncated string safe for LLM consumption.
|
|
34
|
+
"""
|
|
35
|
+
if text is None:
|
|
36
|
+
return ""
|
|
37
|
+
stripped = _CONTROL_CHAR_RE.sub("", str(text))
|
|
38
|
+
cleaned = "".join(c for c in stripped if unicodedata.category(c) != "Cf")
|
|
39
|
+
return cleaned[:max_len]
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Undo-token primitive — record the inverse of a write operation.
|
|
2
|
+
|
|
3
|
+
Reversibility is a 2026 agentic-ops requirement: an operator (or an external orchestrator)
|
|
4
|
+
needs to roll a change back to a known-good state. The inverse of an operation
|
|
5
|
+
is domain-specific (power-off ↔ power-on, create-snapshot ↔ delete-snapshot,
|
|
6
|
+
and some ops — like delete-vm — have NO safe inverse), so a tool *declares* its
|
|
7
|
+
undo via ``@governed_tool(undo=...)``.
|
|
8
|
+
|
|
9
|
+
This module only RECORDS the inverse descriptor (a replayable
|
|
10
|
+
``{skill, tool, params}``) into ``~/.postgres-aiops/undo.db`` and hands back an
|
|
11
|
+
``undo_id``. It deliberately does NOT execute the inverse — coordinated,
|
|
12
|
+
multi-step rollback is an external orchestrator's job. Keeping recording and execution
|
|
13
|
+
separate matches the "separate the deterministic parts from the parts where the
|
|
14
|
+
LLM reasons" guardrail.
|
|
15
|
+
|
|
16
|
+
A tool's ``undo`` is a callable ``(params, result) -> dict | None``:
|
|
17
|
+
|
|
18
|
+
@governed_tool(risk_level="medium", undo=lambda p, r: {
|
|
19
|
+
"tool": "vm_power_on", "params": {"vm_name": p["vm_name"]}})
|
|
20
|
+
def vm_power_off(vm_name: str, target: str = "") -> dict: ...
|
|
21
|
+
|
|
22
|
+
Returning ``None`` means "no inverse for this call" (nothing is recorded).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import os
|
|
30
|
+
import sqlite3
|
|
31
|
+
import threading
|
|
32
|
+
import uuid
|
|
33
|
+
from datetime import UTC, datetime
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from postgres_aiops.governance.paths import ops_path
|
|
38
|
+
|
|
39
|
+
_log = logging.getLogger("postgres-aiops.undo")
|
|
40
|
+
|
|
41
|
+
_BUSY_TIMEOUT_MS = 5000
|
|
42
|
+
|
|
43
|
+
_CREATE_TABLE = """\
|
|
44
|
+
CREATE TABLE IF NOT EXISTS undo_log (
|
|
45
|
+
undo_id TEXT PRIMARY KEY,
|
|
46
|
+
ts TEXT NOT NULL,
|
|
47
|
+
skill TEXT NOT NULL DEFAULT '',
|
|
48
|
+
tool TEXT NOT NULL DEFAULT '',
|
|
49
|
+
undo_skill TEXT NOT NULL DEFAULT '',
|
|
50
|
+
undo_tool TEXT NOT NULL DEFAULT '',
|
|
51
|
+
undo_params TEXT NOT NULL DEFAULT '{}',
|
|
52
|
+
orig_params TEXT NOT NULL DEFAULT '{}',
|
|
53
|
+
status TEXT NOT NULL DEFAULT 'recorded',
|
|
54
|
+
workflow_id TEXT NOT NULL DEFAULT '',
|
|
55
|
+
note TEXT NOT NULL DEFAULT ''
|
|
56
|
+
)
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
_CREATE_INDEX = "CREATE INDEX IF NOT EXISTS idx_undo_status ON undo_log(status)"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class UndoStore:
|
|
63
|
+
"""Append-and-update store for recorded undo descriptors (SQLite/WAL)."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, db_path: Path | str | None = None) -> None:
|
|
66
|
+
self._path = Path(db_path).expanduser() if db_path else ops_path("undo.db")
|
|
67
|
+
self._ok = False
|
|
68
|
+
try:
|
|
69
|
+
self._path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
70
|
+
conn = self._connect()
|
|
71
|
+
conn.execute(_CREATE_TABLE)
|
|
72
|
+
conn.execute(_CREATE_INDEX)
|
|
73
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
74
|
+
conn.commit()
|
|
75
|
+
conn.close()
|
|
76
|
+
self._harden()
|
|
77
|
+
self._ok = True
|
|
78
|
+
except Exception:
|
|
79
|
+
_log.warning("Cannot initialize undo DB at %s", self._path, exc_info=True)
|
|
80
|
+
|
|
81
|
+
def _connect(self) -> sqlite3.Connection:
|
|
82
|
+
conn = sqlite3.connect(str(self._path), timeout=_BUSY_TIMEOUT_MS / 1000)
|
|
83
|
+
conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}")
|
|
84
|
+
return conn
|
|
85
|
+
|
|
86
|
+
def _harden(self) -> None:
|
|
87
|
+
try:
|
|
88
|
+
os.chmod(self._path.parent, 0o700)
|
|
89
|
+
except OSError:
|
|
90
|
+
pass
|
|
91
|
+
for suffix in ("", "-wal", "-shm"):
|
|
92
|
+
candidate = self._path.with_name(self._path.name + suffix)
|
|
93
|
+
try:
|
|
94
|
+
if candidate.exists():
|
|
95
|
+
os.chmod(candidate, 0o600)
|
|
96
|
+
except OSError:
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
def record(
|
|
100
|
+
self,
|
|
101
|
+
*,
|
|
102
|
+
skill: str,
|
|
103
|
+
tool: str,
|
|
104
|
+
undo_descriptor: dict[str, Any],
|
|
105
|
+
orig_params: dict[str, Any] | None = None,
|
|
106
|
+
workflow_id: str = "",
|
|
107
|
+
) -> str | None:
|
|
108
|
+
"""Persist an inverse descriptor; return its undo_id (or None on failure).
|
|
109
|
+
|
|
110
|
+
``undo_descriptor`` must carry at least ``tool`` (the inverse tool) and
|
|
111
|
+
``params``; ``skill`` defaults to the original skill. Never raises —
|
|
112
|
+
recording must not break the underlying tool call.
|
|
113
|
+
"""
|
|
114
|
+
if not self._ok:
|
|
115
|
+
return None
|
|
116
|
+
if not isinstance(undo_descriptor, dict) or not undo_descriptor.get("tool"):
|
|
117
|
+
_log.debug("undo descriptor for %s.%s missing 'tool' — not recorded", skill, tool)
|
|
118
|
+
return None
|
|
119
|
+
undo_id = uuid.uuid4().hex[:16]
|
|
120
|
+
try:
|
|
121
|
+
conn = self._connect()
|
|
122
|
+
conn.execute(
|
|
123
|
+
"INSERT INTO undo_log (undo_id, ts, skill, tool, undo_skill, undo_tool, "
|
|
124
|
+
"undo_params, orig_params, status, workflow_id, note) "
|
|
125
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'recorded', ?, ?)",
|
|
126
|
+
(
|
|
127
|
+
undo_id,
|
|
128
|
+
datetime.now(tz=UTC).isoformat(),
|
|
129
|
+
skill,
|
|
130
|
+
tool,
|
|
131
|
+
str(undo_descriptor.get("skill", skill)),
|
|
132
|
+
str(undo_descriptor["tool"]),
|
|
133
|
+
_safe_json(undo_descriptor.get("params", {})),
|
|
134
|
+
_safe_json(orig_params or {}),
|
|
135
|
+
workflow_id,
|
|
136
|
+
str(undo_descriptor.get("note", "")),
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
conn.commit()
|
|
140
|
+
conn.close()
|
|
141
|
+
return undo_id
|
|
142
|
+
except Exception:
|
|
143
|
+
_log.warning("Failed to record undo for %s.%s", skill, tool, exc_info=True)
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
def get(self, undo_id: str) -> dict[str, Any] | None:
|
|
147
|
+
if not self._ok:
|
|
148
|
+
return None
|
|
149
|
+
conn = self._connect()
|
|
150
|
+
try:
|
|
151
|
+
conn.row_factory = sqlite3.Row
|
|
152
|
+
row = conn.execute("SELECT * FROM undo_log WHERE undo_id = ?", (undo_id,)).fetchone()
|
|
153
|
+
return dict(row) if row else None
|
|
154
|
+
finally:
|
|
155
|
+
conn.close()
|
|
156
|
+
|
|
157
|
+
def list(self, *, status: str | None = None, limit: int = 50) -> list[dict[str, Any]]:
|
|
158
|
+
if not self._ok:
|
|
159
|
+
return []
|
|
160
|
+
conn = self._connect()
|
|
161
|
+
try:
|
|
162
|
+
conn.row_factory = sqlite3.Row
|
|
163
|
+
if status:
|
|
164
|
+
rows = conn.execute(
|
|
165
|
+
"SELECT * FROM undo_log WHERE status = ? ORDER BY ts DESC LIMIT ?",
|
|
166
|
+
(status, limit),
|
|
167
|
+
).fetchall()
|
|
168
|
+
else:
|
|
169
|
+
rows = conn.execute(
|
|
170
|
+
"SELECT * FROM undo_log ORDER BY ts DESC LIMIT ?", (limit,)
|
|
171
|
+
).fetchall()
|
|
172
|
+
return [dict(r) for r in rows]
|
|
173
|
+
finally:
|
|
174
|
+
conn.close()
|
|
175
|
+
|
|
176
|
+
def mark(self, undo_id: str, status: str) -> bool:
|
|
177
|
+
"""Update an undo record's status (e.g. 'applied' / 'expired')."""
|
|
178
|
+
if not self._ok:
|
|
179
|
+
return False
|
|
180
|
+
conn = self._connect()
|
|
181
|
+
try:
|
|
182
|
+
cur = conn.execute(
|
|
183
|
+
"UPDATE undo_log SET status = ? WHERE undo_id = ?", (status, undo_id)
|
|
184
|
+
)
|
|
185
|
+
conn.commit()
|
|
186
|
+
return cur.rowcount > 0
|
|
187
|
+
finally:
|
|
188
|
+
conn.close()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _safe_json(obj: Any) -> str:
|
|
192
|
+
try:
|
|
193
|
+
return json.dumps(obj, ensure_ascii=False, default=str)
|
|
194
|
+
except Exception:
|
|
195
|
+
return json.dumps({"_raw": str(obj)})
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ── Singleton ──────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
_store: UndoStore | None = None
|
|
201
|
+
_store_lock = threading.Lock()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def get_undo_store(db_path: Path | str | None = None) -> UndoStore:
|
|
205
|
+
"""Return the global UndoStore singleton (lazy, lock-guarded)."""
|
|
206
|
+
global _store
|
|
207
|
+
if _store is None:
|
|
208
|
+
with _store_lock:
|
|
209
|
+
if _store is None:
|
|
210
|
+
_store = UndoStore(db_path)
|
|
211
|
+
return _store
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def reset_undo_store() -> None:
|
|
215
|
+
"""Reset the singleton. Tests use this between cases."""
|
|
216
|
+
global _store
|
|
217
|
+
with _store_lock:
|
|
218
|
+
_store = None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Postgres AIops operations (business logic over the PostgreSQL catalogs)."""
|