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.
- apache_aiops/__init__.py +8 -0
- apache_aiops/cli/__init__.py +9 -0
- apache_aiops/cli/_common.py +118 -0
- apache_aiops/cli/_root.py +206 -0
- apache_aiops/cli/undo.py +66 -0
- apache_aiops/config.py +74 -0
- apache_aiops/connection.py +222 -0
- apache_aiops/governance/__init__.py +50 -0
- apache_aiops/governance/audit.py +526 -0
- apache_aiops/governance/budget.py +222 -0
- apache_aiops/governance/decorators.py +599 -0
- apache_aiops/governance/outcome.py +102 -0
- apache_aiops/governance/paths.py +23 -0
- apache_aiops/governance/patterns.py +396 -0
- apache_aiops/governance/policy.py +67 -0
- apache_aiops/governance/sanitize.py +79 -0
- apache_aiops/governance/undo.py +259 -0
- apache_aiops/mcp_server/__init__.py +1 -0
- apache_aiops/mcp_server/_annotations.py +66 -0
- apache_aiops/mcp_server/_shared.py +67 -0
- apache_aiops/mcp_server/server.py +14 -0
- apache_aiops/mcp_server/tools/__init__.py +1 -0
- apache_aiops/mcp_server/tools/httpd.py +143 -0
- apache_aiops/mcp_server/tools/undo.py +204 -0
- apache_aiops/ops/__init__.py +1 -0
- apache_aiops/ops/httpd.py +81 -0
- apache_aiops-0.2.0.dist-info/METADATA +50 -0
- apache_aiops-0.2.0.dist-info/RECORD +31 -0
- apache_aiops-0.2.0.dist-info/WHEEL +4 -0
- apache_aiops-0.2.0.dist-info/entry_points.txt +3 -0
- apache_aiops-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|