apache-aiops 0.2.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.
@@ -0,0 +1,23 @@
1
+ """Home-directory resolution for the governance harness.
2
+
3
+ State lives under ``ops_home()`` — by default ``~/.apache-aiops``, overridable
4
+ via the ``APACHE_AIOPS_HOME`` environment variable so an operator can relocate
5
+ the audit / policy / budget / undo store.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+
13
+ _DEFAULT_HOME = "~/.apache-aiops"
14
+
15
+
16
+ def ops_home() -> Path:
17
+ """Return the harness state directory, honoring ``APACHE_AIOPS_HOME``."""
18
+ return Path(os.environ.get("APACHE_AIOPS_HOME") or _DEFAULT_HOME).expanduser()
19
+
20
+
21
+ def ops_path(*parts: str) -> Path:
22
+ """Resolve a file under the harness home, e.g. ``ops_path('audit.db')``."""
23
+ return ops_home().joinpath(*parts)
@@ -0,0 +1,396 @@
1
+ """L5 auto-remediation pattern engine.
2
+
3
+ Implements the pattern matcher described in
4
+ ``docs/auto-remediation-patterns.md``. Patterns are loaded from
5
+ ``~/.apache-aiops/auto-remediation-patterns/*.yaml`` (hot-reload on mtime change)
6
+ and consulted by the ``@governed_tool`` decorator on every call.
7
+
8
+ Scope (PoC):
9
+ - Pattern loading and signature validation
10
+ - Action matching (is the current tool+params an armed pattern's action?)
11
+ - Rate limiting (per-pattern, per-target)
12
+ - Circuit breaker (3 consecutive validation failures → 24h disable)
13
+ - In-memory state — survives the MCP server process lifetime, NOT restarts
14
+
15
+ Out of scope (future work — separate worker / daemon):
16
+ - Trigger matching against historical audit events
17
+ - Automatic action execution
18
+ - Validation post-step
19
+ - Persistent rate-limit / circuit-breaker state across restarts
20
+ - Approval channel for human signing
21
+
22
+ Failure modes are deliberately fail-open: if pattern loading or matching
23
+ errors out, the decorator falls through to normal behavior with a warning.
24
+ A broken pattern file must not block all MCP tool calls.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ import threading
31
+ import time
32
+ from dataclasses import dataclass, field
33
+ from datetime import UTC, datetime
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+ from apache_aiops.governance.paths import ops_path
38
+
39
+ _log = logging.getLogger("apache-aiops.patterns")
40
+
41
+ _REQUIRED_TOP_LEVEL_KEYS = ("schema_version", "pattern_id", "classification", "action")
42
+ _REQUIRED_CLASSIFICATION_KEYS = ("risk", "reversible", "repeatable")
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Pattern:
47
+ """A loaded, signature-validated auto-remediation pattern."""
48
+
49
+ pattern_id: str
50
+ skill: str # action.skill
51
+ tool: str # action.tool
52
+ risk: str # classification.risk — must be "low" to be armable
53
+ reversible: bool
54
+ repeatable: bool
55
+ expires_at: str # ISO date or "" if never
56
+ rate_max_per_hour_per_target: int
57
+ rate_max_per_day_per_target: int
58
+ circuit_threshold: int # consecutive failures → disable
59
+ circuit_disable_seconds: int
60
+ raw: dict[str, Any] = field(default_factory=dict)
61
+
62
+ @property
63
+ def is_armable(self) -> bool:
64
+ """The hard preconditions per the design doc."""
65
+ return (
66
+ self.risk == "low"
67
+ and self.reversible is True
68
+ and self.repeatable is True
69
+ and not self.is_expired
70
+ )
71
+
72
+ @property
73
+ def is_expired(self) -> bool:
74
+ if not self.expires_at:
75
+ return False
76
+ try:
77
+ exp = datetime.fromisoformat(self.expires_at.replace("Z", "+00:00"))
78
+ if exp.tzinfo is None:
79
+ exp = exp.replace(tzinfo=UTC)
80
+ return datetime.now(tz=UTC) > exp
81
+ except (ValueError, TypeError):
82
+ _log.warning(
83
+ "pattern %s has malformed expires_at=%r — treating as expired",
84
+ self.pattern_id,
85
+ self.expires_at,
86
+ )
87
+ return True
88
+
89
+
90
+ @dataclass
91
+ class PatternMatch:
92
+ """Result of consulting the pattern engine for a tool call."""
93
+
94
+ pattern: Pattern
95
+ armed: bool # True if rate limit + circuit breaker permit firing
96
+ reason: str = "" # Human-readable explanation (esp. when not armed)
97
+
98
+
99
+ @dataclass
100
+ class _Counters:
101
+ """Per-(pattern_id, target) sliding-window state."""
102
+
103
+ arm_timestamps: list[float] = field(default_factory=list) # epoch seconds
104
+ consecutive_failures: int = 0
105
+ disabled_until: float = 0.0 # epoch seconds
106
+
107
+
108
+ class PatternEngine:
109
+ """Singleton pattern matcher integrated with @governed_tool."""
110
+
111
+ def __init__(self, patterns_dir: Path | str | None = None) -> None:
112
+ self._dir = (
113
+ Path(patterns_dir).expanduser()
114
+ if patterns_dir
115
+ else ops_path("auto-remediation-patterns")
116
+ )
117
+ self._patterns: dict[str, Pattern] = {}
118
+ self._mtimes: dict[Path, float] = {}
119
+ # _counters keyed by (pattern_id, target) — target may be ""
120
+ self._counters: dict[tuple[str, str], _Counters] = {}
121
+ self._lock = threading.Lock()
122
+ self._load()
123
+
124
+ # ── Pattern loading ───────────────────────────────────────────────
125
+
126
+ def _load(self) -> None:
127
+ """Load all *.yaml files from the patterns directory.
128
+
129
+ Bad files are skipped with a warning; good files replace prior state.
130
+ """
131
+ if not self._dir.exists():
132
+ self._patterns = {}
133
+ self._mtimes = {}
134
+ return
135
+
136
+ try:
137
+ import yaml
138
+ except ImportError:
139
+ _log.warning("PyYAML not installed — pattern engine disabled")
140
+ self._patterns = {}
141
+ return
142
+
143
+ new_patterns: dict[str, Pattern] = {}
144
+ new_mtimes: dict[Path, float] = {}
145
+ for path in sorted(self._dir.glob("*.yaml")):
146
+ try:
147
+ new_mtimes[path] = path.stat().st_mtime
148
+ with open(path) as fh:
149
+ raw = yaml.safe_load(fh) or {}
150
+ pat = self._validate(raw, path)
151
+ if pat is not None:
152
+ if pat.pattern_id in new_patterns:
153
+ _log.warning(
154
+ "duplicate pattern_id %r in %s — keeping first", pat.pattern_id, path
155
+ )
156
+ continue
157
+ new_patterns[pat.pattern_id] = pat
158
+ except Exception:
159
+ _log.warning("failed to load pattern %s", path, exc_info=True)
160
+
161
+ self._patterns = new_patterns
162
+ self._mtimes = new_mtimes
163
+
164
+ def _maybe_reload(self) -> None:
165
+ """Reload if any file's mtime changed or new files appeared."""
166
+ if not self._dir.exists():
167
+ if self._patterns:
168
+ _log.warning("patterns dir deleted — clearing %d patterns", len(self._patterns))
169
+ self._patterns = {}
170
+ self._mtimes = {}
171
+ return
172
+ try:
173
+ current_files = set(self._dir.glob("*.yaml"))
174
+ tracked_files = set(self._mtimes.keys())
175
+ if current_files != tracked_files:
176
+ self._load()
177
+ return
178
+ for path in current_files:
179
+ if path.stat().st_mtime != self._mtimes.get(path, 0):
180
+ self._load()
181
+ return
182
+ except Exception:
183
+ _log.warning("failed to check patterns dir", exc_info=True)
184
+
185
+ @staticmethod
186
+ def _validate(raw: dict[str, Any], path: Path) -> Pattern | None:
187
+ """Validate signature + return a Pattern, or None if invalid."""
188
+ for k in _REQUIRED_TOP_LEVEL_KEYS:
189
+ if k not in raw:
190
+ _log.warning("pattern %s missing required key %r — skipped", path, k)
191
+ return None
192
+
193
+ if raw.get("schema_version") != 1:
194
+ _log.warning(
195
+ "pattern %s has schema_version=%r (expected 1) — skipped",
196
+ path,
197
+ raw.get("schema_version"),
198
+ )
199
+ return None
200
+
201
+ cls_block = raw.get("classification", {})
202
+ for k in _REQUIRED_CLASSIFICATION_KEYS:
203
+ if k not in cls_block:
204
+ _log.warning("pattern %s missing classification.%s — skipped", path, k)
205
+ return None
206
+
207
+ action = raw.get("action", {})
208
+ skill = action.get("skill")
209
+ tool = action.get("tool")
210
+ if not skill or not tool:
211
+ _log.warning("pattern %s missing action.skill or action.tool — skipped", path)
212
+ return None
213
+
214
+ # Patterns with status != approved are NOT armable. We still load them
215
+ # so callers can introspect, but is_armable returns False.
216
+ approval_status = (raw.get("approval", {}) or {}).get("status", "")
217
+ # Effectively makes risk=high if not approved — but cleaner to keep the
218
+ # explicit approval gate. We honor the YAML's classification.risk and let
219
+ # the approval status influence armability via signed_by check below.
220
+ signed_by = (raw.get("approval", {}) or {}).get("signed_by") or ""
221
+
222
+ rate = raw.get("rate_limit", {}) or {}
223
+ cb = raw.get("circuit_breaker", {}) or {}
224
+
225
+ # Force unsigned patterns to be non-armable by setting risk to a
226
+ # non-"low" sentinel. Loading still succeeds for inspection.
227
+ effective_risk = cls_block.get("risk", "")
228
+ # Both gates required: signed AND approved. A signed-but-rejected pattern
229
+ # must NOT be armable (yjs review 2026-05-06 — previously the OR-style
230
+ # check let signed-rejected slip through with original risk).
231
+ if not signed_by or approval_status != "approved":
232
+ effective_risk = "unsigned"
233
+
234
+ return Pattern(
235
+ pattern_id=str(raw.get("pattern_id")),
236
+ skill=str(skill),
237
+ tool=str(tool),
238
+ risk=str(effective_risk),
239
+ reversible=bool(cls_block.get("reversible", False)),
240
+ repeatable=bool(cls_block.get("repeatable", False)),
241
+ expires_at=str(raw.get("expires_at") or ""),
242
+ rate_max_per_hour_per_target=int(
243
+ rate.get("max_per_hour", rate.get("max_per_hour_per_host", 0)) or 0
244
+ ),
245
+ rate_max_per_day_per_target=int(
246
+ rate.get("max_per_day", rate.get("max_per_day_per_cluster", 0)) or 0
247
+ ),
248
+ circuit_threshold=int(cb.get("consecutive_validation_failures", 3) or 3),
249
+ circuit_disable_seconds=int(cb.get("disable_seconds", 86400) or 86400),
250
+ raw=raw,
251
+ )
252
+
253
+ # ── Public matching API ───────────────────────────────────────────
254
+
255
+ def match(self, skill: str, tool: str, target: str) -> PatternMatch | None:
256
+ """Return a PatternMatch if any loaded pattern's action matches.
257
+
258
+ Returns None if no pattern's action matches this (skill, tool).
259
+
260
+ When a pattern matches, the result indicates whether it is currently
261
+ armed — i.e., whether rate limit allows it AND circuit breaker is
262
+ not tripped. The decorator can use armed=True as a hint to skip
263
+ double-confirm; armed=False means the pattern exists but is rate-limited
264
+ or in cooldown.
265
+ """
266
+ self._maybe_reload()
267
+
268
+ # Collect every pattern whose action matches, then prefer the first
269
+ # ARMABLE one. Previously the first (skill, tool) match won even when
270
+ # not armable, shadowing a later armable pattern.
271
+ candidates = [p for p in self._patterns.values() if p.skill == skill and p.tool == tool]
272
+ if not candidates:
273
+ return None
274
+
275
+ armable = [p for p in candidates if p.is_armable]
276
+ if not armable:
277
+ pat = candidates[0]
278
+ return PatternMatch(
279
+ pattern=pat,
280
+ armed=False,
281
+ reason=f"pattern {pat.pattern_id} is not armable "
282
+ f"(risk={pat.risk}, expired={pat.is_expired})",
283
+ )
284
+
285
+ for pat in armable:
286
+ with self._lock:
287
+ key = (pat.pattern_id, target or "")
288
+ ctr = self._counters.setdefault(key, _Counters())
289
+
290
+ # Circuit breaker check
291
+ now = time.time()
292
+ if now < ctr.disabled_until:
293
+ remaining = int(ctr.disabled_until - now)
294
+ return PatternMatch(
295
+ pattern=pat,
296
+ armed=False,
297
+ reason=f"pattern {pat.pattern_id} circuit-broken for "
298
+ f"~{remaining}s after {ctr.consecutive_failures} failures",
299
+ )
300
+
301
+ # Rate limit check (sliding 1h and 24h windows)
302
+ hour_ago = now - 3600
303
+ day_ago = now - 86400
304
+ ctr.arm_timestamps = [t for t in ctr.arm_timestamps if t > day_ago]
305
+ arms_last_hour = sum(1 for t in ctr.arm_timestamps if t > hour_ago)
306
+ arms_last_day = len(ctr.arm_timestamps)
307
+
308
+ if (
309
+ pat.rate_max_per_hour_per_target
310
+ and arms_last_hour >= pat.rate_max_per_hour_per_target
311
+ ):
312
+ return PatternMatch(
313
+ pattern=pat,
314
+ armed=False,
315
+ reason=f"pattern {pat.pattern_id} hit hourly cap "
316
+ f"({pat.rate_max_per_hour_per_target}/h) on target {target!r}",
317
+ )
318
+ if (
319
+ pat.rate_max_per_day_per_target
320
+ and arms_last_day >= pat.rate_max_per_day_per_target
321
+ ):
322
+ return PatternMatch(
323
+ pattern=pat,
324
+ armed=False,
325
+ reason=f"pattern {pat.pattern_id} hit daily cap "
326
+ f"({pat.rate_max_per_day_per_target}/d) on target {target!r}",
327
+ )
328
+
329
+ # Pattern is armed — record the arming timestamp
330
+ ctr.arm_timestamps.append(now)
331
+
332
+ return PatternMatch(pattern=pat, armed=True, reason=f"pattern {pat.pattern_id} armed")
333
+
334
+ return None
335
+
336
+ def report_outcome(self, pattern_id: str, target: str, success: bool) -> None:
337
+ """Update circuit-breaker state after an armed pattern's action ran.
338
+
339
+ Called by the decorator's finally block once the underlying tool
340
+ function returns or raises. Successful runs reset the failure
341
+ counter; failures increment it and may trip the breaker.
342
+ """
343
+ with self._lock:
344
+ key = (pattern_id, target or "")
345
+ ctr = self._counters.setdefault(key, _Counters())
346
+ pat = self._patterns.get(pattern_id)
347
+ threshold = pat.circuit_threshold if pat else 3
348
+ disable_for = pat.circuit_disable_seconds if pat else 86400
349
+
350
+ if success:
351
+ ctr.consecutive_failures = 0
352
+ return
353
+
354
+ ctr.consecutive_failures += 1
355
+ if ctr.consecutive_failures >= threshold:
356
+ ctr.disabled_until = time.time() + disable_for
357
+ _log.warning(
358
+ "pattern %s circuit-broken on target %r after %d failures — disabled for %ds",
359
+ pattern_id,
360
+ target,
361
+ ctr.consecutive_failures,
362
+ disable_for,
363
+ )
364
+
365
+ # ── Introspection / testing helpers ──────────────────────────────
366
+
367
+ def loaded_patterns(self) -> list[Pattern]:
368
+ """Return a snapshot of currently loaded patterns."""
369
+ return list(self._patterns.values())
370
+
371
+ def reset_state(self) -> None:
372
+ """Clear in-memory counters. Used by tests; not for production paths."""
373
+ with self._lock:
374
+ self._counters = {}
375
+
376
+
377
+ # ── Singleton ─────────────────────────────────────────────────────────
378
+
379
+ _engine: PatternEngine | None = None
380
+ _engine_lock = threading.Lock()
381
+
382
+
383
+ def get_pattern_engine(patterns_dir: Path | str | None = None) -> PatternEngine:
384
+ """Return the global PatternEngine singleton."""
385
+ global _engine
386
+ if _engine is None:
387
+ with _engine_lock:
388
+ if _engine is None:
389
+ _engine = PatternEngine(patterns_dir)
390
+ return _engine
391
+
392
+
393
+ def reset_pattern_engine() -> None:
394
+ """Reset the singleton. Tests use this to swap directories cleanly."""
395
+ global _engine
396
+ _engine = None
@@ -0,0 +1,67 @@
1
+ """Risk-tier tagging for the audit trail — NOT authorization.
2
+
3
+ Whether a read or a write is permitted is deliberately not the skill's decision.
4
+ That belongs to the agent's judgement, or to the permissions of the account the
5
+ tool connects with — give it a read-only role and writes fail at the server, the
6
+ place that actually owns the permission. The skill's job is to run the operation
7
+ accurately and record it, both over MCP and over the CLI; it does not police.
8
+
9
+ An earlier design put a whole authorization layer here — read-only mode, YAML
10
+ deny rules, a secure-by-default approver gate. All of that is gone: it duplicated
11
+ a decision the agent and the account already own, and every gate was one more way
12
+ for a correct operation to be refused.
13
+
14
+ What remains is a stateless classifier that maps a tool's declared ``risk_level``
15
+ to a tier name carried into the audit log, so a reviewer can see at a glance that
16
+ a row was, say, a high-risk delete. It gates nothing.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import threading
22
+
23
+ RISK_LEVELS = ("low", "medium", "high", "critical")
24
+
25
+ # risk_level → audit tier. Purely descriptive: no tier blocks anything, it only
26
+ # labels the audit row. 'confirm' echoes that the CLI double-confirms a medium
27
+ # write; 'review' marks the destructive operations a human is most likely to
28
+ # want to see in the trail.
29
+ _TIER_BY_RISK = {
30
+ "low": "none",
31
+ "medium": "confirm",
32
+ "high": "review",
33
+ "critical": "review",
34
+ }
35
+
36
+
37
+ class PolicyEngine:
38
+ """Stateless risk-tier classifier.
39
+
40
+ Kept as a lock-guarded singleton so the ``reset_policy_engine()`` hook the
41
+ test fixtures call stays valid, but it holds no rules and denies nothing.
42
+ """
43
+
44
+ def tier_for(self, risk_level: str) -> str:
45
+ """Return the audit tier name for a declared risk level."""
46
+ return _TIER_BY_RISK.get(risk_level, "none")
47
+
48
+
49
+ _engine: PolicyEngine | None = None
50
+ _engine_lock = threading.Lock()
51
+
52
+
53
+ def get_policy_engine() -> PolicyEngine:
54
+ """Return the global PolicyEngine singleton (lazy, lock-guarded)."""
55
+ global _engine
56
+ if _engine is None:
57
+ with _engine_lock:
58
+ if _engine is None:
59
+ _engine = PolicyEngine()
60
+ return _engine
61
+
62
+
63
+ def reset_policy_engine() -> None:
64
+ """Reset the singleton. Tests use this between cases."""
65
+ global _engine
66
+ with _engine_lock:
67
+ _engine = None
@@ -0,0 +1,79 @@
1
+ """Output hygiene: strip control/format characters and truncate untrusted text.
2
+
3
+ Defense-in-depth against *encoding-level* tricks: control characters,
4
+ zero-width / bidi format characters, and payload-pushing padding. It does NOT
5
+ neutralize natural-language prompt injection — "ignore previous instructions"
6
+ passes through unchanged; semantic injection resistance must come from the
7
+ consuming agent's own prompt boundaries.
8
+
9
+ Consolidated from 22 duplicate ``_sanitize()`` implementations across the tool line.
10
+ All skills should import from here instead of defining their own copy.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ import unicodedata
17
+
18
+ # C0 control chars (except tab \x09, LF \x0a, CR \x0d) + C1 control chars
19
+ _CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
20
+
21
+
22
+ def sanitize(text: str | None, max_len: int = 500) -> str:
23
+ """Strip control characters, Unicode format chars, and truncate.
24
+
25
+ Removes:
26
+ - C0/C1 control characters (except newline/tab)
27
+ - Unicode Format characters (Cf): zero-width spaces, bidi overrides,
28
+ zero-width joiners — used to smuggle or disguise injected text
29
+
30
+ Stripping happens BEFORE truncation so an attacker cannot push the real
31
+ payload past the cut-off by padding with junk control characters.
32
+ ``None`` sanitizes to ``""``.
33
+
34
+ A truncated result ends in an ellipsis (U+2026) so the cut is visible. This
35
+ line already treats a silent cut as a defect for lists — a capped page that
36
+ looks complete gets reported as the whole set — and a capped string is the
37
+ same failure at a smaller scale: a shortened error message reads as the
38
+ whole explanation, and its remediation sentence, which comes last, is gone
39
+ without a trace.
40
+
41
+ Args:
42
+ text: Untrusted text from upstream API or command responses.
43
+ max_len: Maximum length after truncation. Default 500.
44
+
45
+ Returns:
46
+ Cleaned string, ellipsis-terminated when it had to be cut.
47
+ """
48
+ if text is None:
49
+ return ""
50
+ stripped = _CONTROL_CHAR_RE.sub("", str(text))
51
+ cleaned = "".join(c for c in stripped if unicodedata.category(c) != "Cf")
52
+ if len(cleaned) <= max_len:
53
+ return cleaned
54
+ return cleaned[: max(0, max_len - 1)] + "\u2026"
55
+
56
+
57
+ def opt_str(value: object | None, max_len: int = 500) -> str | None:
58
+ """Sanitize a value that may legitimately be absent, preserving that absence.
59
+
60
+ Companion to :func:`sanitize`, which folds ``None`` into ``""``. That
61
+ conflation is invisible downstream: an empty string reads as "the field
62
+ exists and is empty" when the truth may be "the source never returned this
63
+ field at all". A consumer — and a smaller local model especially — cannot
64
+ recover the difference, and tends to invent one.
65
+
66
+ So: absence comes back as ``None`` (JSON ``null``), and only genuinely empty
67
+ values come back as ``""``. Use this for any optional API field; keep
68
+ :func:`sanitize` for values that are always present.
69
+
70
+ Args:
71
+ value: Raw value straight from an API response, or None when absent.
72
+ max_len: Maximum length after truncation. Default 500.
73
+
74
+ Returns:
75
+ The sanitized string, or None when the source had no value at all.
76
+ """
77
+ if value is None:
78
+ return None
79
+ return sanitize(str(value), max_len)