queue-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 (48) hide show
  1. mcp_server/__init__.py +1 -0
  2. mcp_server/_shared.py +107 -0
  3. mcp_server/server.py +38 -0
  4. mcp_server/tools/__init__.py +1 -0
  5. mcp_server/tools/analysis.py +148 -0
  6. mcp_server/tools/overview.py +23 -0
  7. mcp_server/tools/rabbit_reads.py +95 -0
  8. mcp_server/tools/redis_reads.py +98 -0
  9. mcp_server/tools/undo.py +121 -0
  10. mcp_server/tools/writes.py +355 -0
  11. queue_aiops/__init__.py +14 -0
  12. queue_aiops/cli/__init__.py +9 -0
  13. queue_aiops/cli/_common.py +78 -0
  14. queue_aiops/cli/_root.py +62 -0
  15. queue_aiops/cli/analyze.py +85 -0
  16. queue_aiops/cli/doctor.py +21 -0
  17. queue_aiops/cli/init.py +176 -0
  18. queue_aiops/cli/overview.py +16 -0
  19. queue_aiops/cli/rabbit_cmds.py +234 -0
  20. queue_aiops/cli/redis_cmds.py +147 -0
  21. queue_aiops/cli/secret.py +105 -0
  22. queue_aiops/cli/undo.py +62 -0
  23. queue_aiops/config.py +175 -0
  24. queue_aiops/connection.py +277 -0
  25. queue_aiops/doctor.py +98 -0
  26. queue_aiops/governance/__init__.py +40 -0
  27. queue_aiops/governance/audit.py +377 -0
  28. queue_aiops/governance/budget.py +225 -0
  29. queue_aiops/governance/decorators.py +482 -0
  30. queue_aiops/governance/paths.py +23 -0
  31. queue_aiops/governance/patterns.py +378 -0
  32. queue_aiops/governance/policy.py +430 -0
  33. queue_aiops/governance/sanitize.py +45 -0
  34. queue_aiops/governance/undo.py +218 -0
  35. queue_aiops/ops/__init__.py +1 -0
  36. queue_aiops/ops/_util.py +55 -0
  37. queue_aiops/ops/analysis.py +591 -0
  38. queue_aiops/ops/overview.py +90 -0
  39. queue_aiops/ops/rabbit_reads.py +218 -0
  40. queue_aiops/ops/redis_reads.py +237 -0
  41. queue_aiops/ops/writes.py +284 -0
  42. queue_aiops/platform.py +217 -0
  43. queue_aiops/secretstore.py +304 -0
  44. queue_aiops-0.1.0.dist-info/METADATA +168 -0
  45. queue_aiops-0.1.0.dist-info/RECORD +48 -0
  46. queue_aiops-0.1.0.dist-info/WHEEL +4 -0
  47. queue_aiops-0.1.0.dist-info/entry_points.txt +3 -0
  48. queue_aiops-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,482 @@
1
+ """The ``@governed_tool`` decorator — mandatory wrapper for all queue MCP tool functions.
2
+
3
+ Responsibilities:
4
+ 1. Pre-check: evaluate policy rules (deny, maintenance window)
5
+ 2. Execute: run the actual tool function
6
+ 3. Post-log: write audit record to ``~/.queue-aiops/audit.db``
7
+ 4. Metadata: attach risk_level, idempotent, timeout, sensitive_params
8
+
9
+ Usage::
10
+
11
+ from queue_aiops.governance import governed_tool
12
+
13
+ @governed_tool(risk_level="high", sensitive_params=["password"])
14
+ def delete_segment(name: str, env: str) -> dict:
15
+ ...
16
+
17
+ Registration enforcement::
18
+
19
+ # In your MCP server startup
20
+ for tool in tools:
21
+ assert getattr(tool, "_is_governed_tool", False), f"{tool.__name__} missing @governed_tool"
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import inspect
27
+ import logging
28
+ import os
29
+ import re
30
+ import time
31
+ import traceback
32
+ from functools import wraps
33
+ from typing import Any
34
+
35
+ from queue_aiops.governance.audit import detect_agent, get_engine
36
+ from queue_aiops.governance.budget import BudgetExceeded, get_budget
37
+ from queue_aiops.governance.patterns import PatternMatch, get_pattern_engine
38
+ from queue_aiops.governance.policy import PolicyResult, get_policy_engine
39
+ from queue_aiops.governance.sanitize import sanitize
40
+
41
+ _log = logging.getLogger("queue-aiops.decorators")
42
+
43
+
44
+ class PolicyDenied(Exception):
45
+ """Raised when an operation is denied by policy."""
46
+
47
+ def __init__(self, result: PolicyResult) -> None:
48
+ self.result = result
49
+ super().__init__(result.reason)
50
+
51
+
52
+ def governed_tool(
53
+ fn: Any = None,
54
+ *,
55
+ risk_level: str = "low",
56
+ idempotent: bool = False,
57
+ timeout_seconds: int = 300,
58
+ sensitive_params: list[str] | None = None,
59
+ undo: Any = None,
60
+ ) -> Any:
61
+ """Decorator for all queue MCP tool functions.
62
+
63
+ Can be used with or without arguments::
64
+
65
+ @governed_tool
66
+ def list_segments(...): ...
67
+
68
+ @governed_tool(risk_level="critical", sensitive_params=["password"])
69
+ def delete_vm(...): ...
70
+
71
+ Args:
72
+ risk_level: One of 'low', 'medium', 'high', 'critical'.
73
+ idempotent: Whether the operation can be safely retried on failure.
74
+ timeout_seconds: Maximum execution time before warning — exceeding it
75
+ logs a warning (no hard cancellation).
76
+ sensitive_params: Parameter names to redact in audit logs.
77
+ undo: Optional callable ``(params, result) -> dict | None`` returning an
78
+ inverse descriptor ``{"tool", "params", "skill"?, "note"?}``. On a
79
+ successful call the inverse is recorded to ~/.queue-aiops/undo.db and the
80
+ result dict gains an ``_undo_id``. Return None for "no safe inverse".
81
+ Recording only — execution is an external orchestrator's job.
82
+ """
83
+ _sensitive = set(sensitive_params or [])
84
+
85
+ def decorator(func: Any) -> Any:
86
+ # Cache the signature at decoration time so positional args can be
87
+ # mapped to parameter names on every call (audit + env scoping).
88
+ signature = inspect.signature(func)
89
+
90
+ if inspect.iscoroutinefunction(func):
91
+ # ── Async tools get an async wrapper with identical audit /
92
+ # policy / circuit-breaker semantics (a sync wrapper would return
93
+ # an un-awaited coroutine and audit it as "ok").
94
+ @wraps(func)
95
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
96
+ state = _CallState(
97
+ func, args, kwargs, signature, _sensitive, risk_level,
98
+ timeout_seconds, undo,
99
+ )
100
+ try:
101
+ _pre_check(state)
102
+ return _annotate_result(state, await func(*args, **kwargs))
103
+ except (PolicyDenied, BudgetExceeded):
104
+ raise
105
+ except Exception as exc:
106
+ _capture_error(state, exc)
107
+ raise
108
+ finally:
109
+ _finalize(state)
110
+ else:
111
+ @wraps(func)
112
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
113
+ state = _CallState(
114
+ func, args, kwargs, signature, _sensitive, risk_level,
115
+ timeout_seconds, undo,
116
+ )
117
+ try:
118
+ _pre_check(state)
119
+ return _annotate_result(state, func(*args, **kwargs))
120
+ except (PolicyDenied, BudgetExceeded):
121
+ raise
122
+ except Exception as exc:
123
+ _capture_error(state, exc)
124
+ raise
125
+ finally:
126
+ _finalize(state)
127
+
128
+ # ── Attach metadata for harness / introspection ───────────
129
+ wrapper._is_governed_tool = True
130
+ wrapper._risk_level = risk_level
131
+ wrapper._idempotent = idempotent
132
+ wrapper._timeout_seconds = timeout_seconds
133
+ wrapper._sensitive_params = list(_sensitive)
134
+ return wrapper
135
+
136
+ # Support @governed_tool and @governed_tool(...)
137
+ if fn is not None:
138
+ return decorator(fn)
139
+ return decorator
140
+
141
+
142
+ # ── Internal helpers ──────────────────────────────────────────────────
143
+
144
+
145
+ class _CallState:
146
+ """Per-call context shared by the sync and async wrapper bodies.
147
+
148
+ Built once per invocation; the helper functions (`_pre_check`,
149
+ `_annotate_result`, `_capture_error`, `_finalize`) read and mutate it so
150
+ both wrappers keep identical audit / policy / circuit-breaker semantics.
151
+ """
152
+
153
+ __slots__ = (
154
+ "skill", "tool_name", "agent", "start", "status", "result",
155
+ "policy_result", "pattern_match", "audit", "policy",
156
+ "safe_params", "env", "risk_level", "timeout_seconds",
157
+ "rationale", "approved_by", "risk_tier", "undo",
158
+ )
159
+
160
+ def __init__(
161
+ self,
162
+ func: Any,
163
+ args: tuple[Any, ...],
164
+ kwargs: dict[str, Any],
165
+ signature: inspect.Signature,
166
+ sensitive: set[str],
167
+ risk_level: str,
168
+ timeout_seconds: int,
169
+ undo: Any = None,
170
+ ) -> None:
171
+ self.undo = undo
172
+ self.skill = _infer_skill(func)
173
+ self.tool_name = func.__name__
174
+ self.agent = detect_agent()
175
+ self.start = time.time()
176
+ self.status = "ok"
177
+ self.result: Any = None
178
+ self.policy_result: PolicyResult | None = None
179
+ self.pattern_match: PatternMatch | None = None
180
+ self.risk_level = risk_level
181
+ self.timeout_seconds = timeout_seconds
182
+ self.audit = get_engine()
183
+ self.policy = get_policy_engine()
184
+
185
+ # Map positional args to parameter names so they appear in the audit
186
+ # log and participate in env scoping (previously only kwargs did).
187
+ params = _bind_params(signature, args, kwargs)
188
+ self.safe_params = _redact(params, sensitive)
189
+ env = params.get("target", params.get("env", ""))
190
+ self.env = str(env) if env else ""
191
+
192
+ # Accountability trail (SOC2 / 等保: who authorized this, and why).
193
+ # Sourced from env so an approval gate / pilot can inject context
194
+ # without changing every tool signature. risk_tier is filled by the
195
+ # policy pre-check (graduated autonomy).
196
+ self.rationale = os.environ.get("QUEUE_AUDIT_RATIONALE", "")
197
+ self.approved_by = os.environ.get("QUEUE_AUDIT_APPROVED_BY", "")
198
+ self.risk_tier = ""
199
+
200
+
201
+ def _bind_params(
202
+ signature: inspect.Signature, args: tuple[Any, ...], kwargs: dict[str, Any]
203
+ ) -> dict[str, Any]:
204
+ """Build a full name→value param dict from positional + keyword args.
205
+
206
+ Falls back to kwargs-only if binding fails (the actual call will raise
207
+ the matching TypeError; audit should not mask it with its own).
208
+ """
209
+ try:
210
+ bound = signature.bind_partial(*args, **kwargs)
211
+ # Apply declared defaults so env scoping and risk-tier matching see the
212
+ # effective target/tags even when the caller relied on a default value
213
+ # (bind_partial alone only captures explicitly-passed arguments).
214
+ bound.apply_defaults()
215
+ except TypeError:
216
+ return dict(kwargs)
217
+ params: dict[str, Any] = {}
218
+ for name, value in bound.arguments.items():
219
+ kind = signature.parameters[name].kind
220
+ if kind == inspect.Parameter.VAR_KEYWORD:
221
+ params.update(value)
222
+ elif kind == inspect.Parameter.VAR_POSITIONAL:
223
+ params[name] = list(value)
224
+ else:
225
+ params[name] = value
226
+ return params
227
+
228
+
229
+ def _pre_check(state: _CallState) -> None:
230
+ """Policy pre-check + L5 auto-remediation pattern consult.
231
+
232
+ Raises PolicyDenied when policy denies the call. Pattern engine failures
233
+ never block the call (fail-open by design — a broken pattern file must
234
+ not take down every MCP tool).
235
+ """
236
+ state.policy_result = state.policy.check_allowed(
237
+ state.tool_name,
238
+ env=state.env,
239
+ risk_level=state.risk_level,
240
+ params=state.safe_params,
241
+ )
242
+ if not state.policy_result.allowed:
243
+ state.status = "denied"
244
+ state.result = {
245
+ "error": state.policy_result.reason,
246
+ "rule": state.policy_result.rule,
247
+ }
248
+ raise PolicyDenied(state.policy_result)
249
+
250
+ # Graduated autonomy — what approval tier does this op need? Record it on
251
+ # the audit trail, and enforce: tiers that require a named approver (dual /
252
+ # review) are denied when none was recorded (QUEUE_AUDIT_APPROVED_BY).
253
+ tier = state.policy.required_approval_tier(
254
+ state.tool_name,
255
+ env=state.env,
256
+ risk_level=state.risk_level,
257
+ params=state.safe_params,
258
+ )
259
+ state.risk_tier = tier.tier
260
+ if tier.requires_approver and not state.approved_by:
261
+ reason = (
262
+ f"Operation '{state.tool_name}' on '{state.env or 'target'}' requires "
263
+ f"'{tier.tier}' approval (rule: {tier.rule}) but no approver is recorded. "
264
+ f"Set QUEUE_AUDIT_APPROVED_BY to the authorizing human (and "
265
+ f"QUEUE_AUDIT_RATIONALE to why) before retrying."
266
+ )
267
+ if tier.reason:
268
+ reason += f" Policy note: {tier.reason}"
269
+ denial = PolicyResult(allowed=False, rule=f"approval_tier:{tier.tier}", reason=reason)
270
+ state.policy_result = denial
271
+ state.status = "denied"
272
+ state.result = {"error": reason, "rule": denial.rule}
273
+ raise PolicyDenied(denial)
274
+
275
+ # Budget / runaway guard — only for calls policy already allowed, so denied
276
+ # calls do not count. A trip raises BudgetExceeded (a hard stop); record the
277
+ # denial on state so _finalize audits it.
278
+ try:
279
+ get_budget().check_and_record(state.tool_name, state.safe_params)
280
+ except BudgetExceeded as exc:
281
+ state.status = "budget_exceeded"
282
+ state.result = {"error": exc.reason, "rule": exc.rule}
283
+ raise
284
+
285
+ try:
286
+ state.pattern_match = get_pattern_engine().match(
287
+ skill=state.skill, tool=state.tool_name, target=state.env
288
+ )
289
+ except Exception: # noqa: BLE001 — fail-open by design
290
+ state.pattern_match = None
291
+
292
+
293
+ def _annotate_result(state: _CallState, result: Any) -> Any:
294
+ """Record the result, surface pattern context, and record an undo token.
295
+
296
+ Runs only on the success path (the wrapper calls it with the function's
297
+ return value), so a recorded undo always corresponds to a change that
298
+ actually happened.
299
+ """
300
+ state.result = result
301
+ if (
302
+ state.pattern_match
303
+ and state.pattern_match.armed
304
+ and isinstance(result, dict)
305
+ ):
306
+ result.setdefault("_pattern_id", state.pattern_match.pattern.pattern_id)
307
+ result.setdefault("_pattern_armed", True)
308
+ _record_undo(state, result)
309
+ return result
310
+
311
+
312
+ def _record_undo(state: _CallState, result: Any) -> None:
313
+ """Compute and persist the inverse descriptor for a successful write.
314
+
315
+ Best-effort: a broken undo callable or store must never fail the call.
316
+ Attaches ``_undo_id`` to dict results so the agent / pilot can reference it.
317
+ """
318
+ if state.undo is None:
319
+ return
320
+ if isinstance(result, dict) and result.get("error"):
321
+ return # sanitized failure — no change happened, so no inverse to record
322
+ try:
323
+ descriptor = state.undo(state.safe_params, result)
324
+ except Exception: # noqa: BLE001 — undo computation must not fail the call
325
+ _log.warning("undo callable for %s.%s raised", state.skill, state.tool_name,
326
+ exc_info=True)
327
+ return
328
+ if not descriptor:
329
+ return
330
+ try:
331
+ from queue_aiops.governance.undo import get_undo_store
332
+
333
+ undo_id = get_undo_store().record(
334
+ skill=state.skill,
335
+ tool=state.tool_name,
336
+ undo_descriptor=descriptor,
337
+ orig_params=state.safe_params,
338
+ )
339
+ if undo_id and isinstance(result, dict):
340
+ result.setdefault("_undo_id", undo_id)
341
+ except Exception: # noqa: BLE001 — recording is best-effort
342
+ _log.warning("failed to record undo for %s.%s", state.skill, state.tool_name,
343
+ exc_info=True)
344
+
345
+
346
+ def _capture_error(state: _CallState, exc: Exception) -> None:
347
+ """Record a failed call. Exception text and tracebacks can carry
348
+ connection strings, credentials, internal paths — sanitize before
349
+ persisting to the audit row."""
350
+ state.status = "error"
351
+ state.result = {
352
+ "error": sanitize(_redact_secrets_text(str(exc)), 500),
353
+ "traceback": sanitize(
354
+ _redact_secrets_text(traceback.format_exc()[-500:]), 500
355
+ ),
356
+ }
357
+
358
+
359
+ def _finalize(state: _CallState) -> None:
360
+ """Audit + circuit-breaker bookkeeping. Runs in the wrapper's finally."""
361
+ # A sanitized failure (@tool_errors converts exceptions into {"error": ...}
362
+ # dicts BEFORE this harness sees them) must not be audited as success —
363
+ # compliance exception reports are built from this status.
364
+ if state.status == "ok" and isinstance(state.result, dict) and state.result.get("error"):
365
+ state.status = "error"
366
+
367
+ duration = int((time.time() - state.start) * 1000)
368
+
369
+ # Accumulate wall-time toward the cumulative time budget (best-effort).
370
+ try:
371
+ get_budget().add_duration(time.time() - state.start)
372
+ except Exception: # noqa: BLE001 — bookkeeping must never fail the call
373
+ pass
374
+
375
+ # timeout_seconds is advisory: exceeding it logs a warning, no hard
376
+ # cancellation (cancelling mid-flight queue calls is worse).
377
+ if state.timeout_seconds and duration > state.timeout_seconds * 1000:
378
+ _log.warning(
379
+ "%s.%s took %dms — exceeded timeout_seconds=%d (advisory, not cancelled)",
380
+ state.skill, state.tool_name, duration, state.timeout_seconds,
381
+ )
382
+
383
+ bypassed = state.policy_result and state.policy_result.rule == "policy_disabled"
384
+ final_status = f"{state.status}_bypassed" if bypassed else state.status
385
+
386
+ # Update circuit-breaker state for armed patterns
387
+ if state.pattern_match and state.pattern_match.armed:
388
+ try:
389
+ get_pattern_engine().report_outcome(
390
+ pattern_id=state.pattern_match.pattern.pattern_id,
391
+ target=state.env,
392
+ success=(state.status == "ok"),
393
+ )
394
+ except Exception: # noqa: BLE001 — never let bookkeeping fail the call
395
+ pass
396
+
397
+ pattern_id = state.pattern_match.pattern.pattern_id if state.pattern_match else ""
398
+ pattern_armed = bool(state.pattern_match and state.pattern_match.armed)
399
+
400
+ state.audit.log(
401
+ skill=state.skill,
402
+ tool=state.tool_name,
403
+ params=state.safe_params,
404
+ result=_with_pattern_context(state.result, pattern_id, pattern_armed),
405
+ status=final_status,
406
+ duration_ms=duration,
407
+ agent=state.agent,
408
+ user="",
409
+ risk_level=state.risk_level,
410
+ rationale=state.rationale,
411
+ approved_by=state.approved_by,
412
+ risk_tier=state.risk_tier,
413
+ )
414
+
415
+
416
+ def _infer_skill(func: Any) -> str:
417
+ """Infer the skill name from the function's module path.
418
+
419
+ ``queue_aiops.ops.jobs`` → ``queue-aiops``
420
+ ``mcp_server.server`` → ``queue-aiops`` (the only consumer here).
421
+ """
422
+ module = getattr(func, "__module__", "") or ""
423
+ if module.startswith("queue_aiops") or module.startswith("mcp_server"):
424
+ return "queue-aiops"
425
+ return "unknown"
426
+
427
+
428
+ def _redact(params: dict[str, Any], sensitive: set[str]) -> dict[str, Any]:
429
+ """Return a copy of params with sensitive values replaced by '***'.
430
+
431
+ Recurses into nested dicts AND lists/tuples so credentials buried inside
432
+ collections (e.g. ``{"targets": [{"password": "x"}]}``) are redacted too.
433
+ """
434
+ if not sensitive:
435
+ return params
436
+ result: dict[str, Any] = {}
437
+ for k, v in params.items():
438
+ if k in sensitive:
439
+ result[k] = "***"
440
+ else:
441
+ result[k] = _redact_value(v, sensitive)
442
+ return result
443
+
444
+
445
+ def _redact_value(value: Any, sensitive: set[str]) -> Any:
446
+ """Recursively redact sensitive keys inside dicts, lists, and tuples."""
447
+ if isinstance(value, dict):
448
+ return _redact(value, sensitive)
449
+ if isinstance(value, (list, tuple)):
450
+ return type(value)(_redact_value(item, sensitive) for item in value)
451
+ return value
452
+
453
+
454
+ # Matches ``key=value`` / ``key: value`` / ``key"="value`` for common secret
455
+ # keys in free-form exception text. Value runs until whitespace, quote, comma,
456
+ # or '@' (to keep host:port that often follows a credential in DSNs).
457
+ _SECRET_TEXT_RE = re.compile(
458
+ r"(?i)\b(password|passwd|pwd|token|secret|api[_-]?key|authorization|bearer)"
459
+ r"(\s*[=:]\s*|\s+)"
460
+ r"['\"]?[^\s'\",@]+",
461
+ )
462
+
463
+
464
+ def _redact_secrets_text(text: str) -> str:
465
+ """Redact ``password=...`` / ``token: ...`` style secrets in free-form text."""
466
+ return _SECRET_TEXT_RE.sub(r"\1\2***", text)
467
+
468
+
469
+ def _with_pattern_context(result: Any, pattern_id: str, armed: bool) -> Any:
470
+ """Attach pattern metadata to an audit row's result field.
471
+
472
+ Only mutates dict results; non-dict results (errors, primitives) are
473
+ returned unchanged so the audit log preserves them faithfully.
474
+ """
475
+ if not pattern_id:
476
+ return result
477
+ if isinstance(result, dict):
478
+ annotated = dict(result)
479
+ annotated.setdefault("_pattern_id", pattern_id)
480
+ annotated.setdefault("_pattern_armed", armed)
481
+ return annotated
482
+ return result
@@ -0,0 +1,23 @@
1
+ """Home-directory resolution for the governance harness.
2
+
3
+ State lives under ``ops_home()`` — by default ``~/.queue-aiops``, overridable
4
+ via the ``QUEUE_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 = "~/.queue-aiops"
14
+
15
+
16
+ def ops_home() -> Path:
17
+ """Return the harness state directory, honoring ``QUEUE_AIOPS_HOME``."""
18
+ return Path(os.environ.get("QUEUE_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)