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.
- mcp_server/__init__.py +1 -0
- mcp_server/_shared.py +107 -0
- mcp_server/server.py +38 -0
- mcp_server/tools/__init__.py +1 -0
- mcp_server/tools/analysis.py +148 -0
- mcp_server/tools/overview.py +23 -0
- mcp_server/tools/rabbit_reads.py +95 -0
- mcp_server/tools/redis_reads.py +98 -0
- mcp_server/tools/undo.py +121 -0
- mcp_server/tools/writes.py +355 -0
- queue_aiops/__init__.py +14 -0
- queue_aiops/cli/__init__.py +9 -0
- queue_aiops/cli/_common.py +78 -0
- queue_aiops/cli/_root.py +62 -0
- queue_aiops/cli/analyze.py +85 -0
- queue_aiops/cli/doctor.py +21 -0
- queue_aiops/cli/init.py +176 -0
- queue_aiops/cli/overview.py +16 -0
- queue_aiops/cli/rabbit_cmds.py +234 -0
- queue_aiops/cli/redis_cmds.py +147 -0
- queue_aiops/cli/secret.py +105 -0
- queue_aiops/cli/undo.py +62 -0
- queue_aiops/config.py +175 -0
- queue_aiops/connection.py +277 -0
- queue_aiops/doctor.py +98 -0
- queue_aiops/governance/__init__.py +40 -0
- queue_aiops/governance/audit.py +377 -0
- queue_aiops/governance/budget.py +225 -0
- queue_aiops/governance/decorators.py +482 -0
- queue_aiops/governance/paths.py +23 -0
- queue_aiops/governance/patterns.py +378 -0
- queue_aiops/governance/policy.py +430 -0
- queue_aiops/governance/sanitize.py +45 -0
- queue_aiops/governance/undo.py +218 -0
- queue_aiops/ops/__init__.py +1 -0
- queue_aiops/ops/_util.py +55 -0
- queue_aiops/ops/analysis.py +591 -0
- queue_aiops/ops/overview.py +90 -0
- queue_aiops/ops/rabbit_reads.py +218 -0
- queue_aiops/ops/redis_reads.py +237 -0
- queue_aiops/ops/writes.py +284 -0
- queue_aiops/platform.py +217 -0
- queue_aiops/secretstore.py +304 -0
- queue_aiops-0.1.0.dist-info/METADATA +168 -0
- queue_aiops-0.1.0.dist-info/RECORD +48 -0
- queue_aiops-0.1.0.dist-info/WHEEL +4 -0
- queue_aiops-0.1.0.dist-info/entry_points.txt +3 -0
- 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)
|