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,599 @@
|
|
|
1
|
+
"""The ``@governed_tool`` decorator — mandatory wrapper for all container-host MCP tool functions.
|
|
2
|
+
|
|
3
|
+
Responsibilities:
|
|
4
|
+
1. Pre-check: tag the risk tier for audit + run the runaway/budget safety guard
|
|
5
|
+
(no authorization — the skill does not decide read vs write)
|
|
6
|
+
2. Execute: run the actual tool function
|
|
7
|
+
3. Post-log: write audit record to ``~/.apache-aiops/audit.db``
|
|
8
|
+
4. Metadata: attach risk_level, idempotent, timeout, sensitive_params
|
|
9
|
+
|
|
10
|
+
Usage::
|
|
11
|
+
|
|
12
|
+
from apache_aiops.governance import governed_tool
|
|
13
|
+
|
|
14
|
+
@governed_tool(risk_level="high", sensitive_params=["password"])
|
|
15
|
+
def delete_segment(name: str, env: str) -> dict:
|
|
16
|
+
...
|
|
17
|
+
|
|
18
|
+
Registration enforcement::
|
|
19
|
+
|
|
20
|
+
# In your MCP server startup
|
|
21
|
+
for tool in tools:
|
|
22
|
+
assert getattr(tool, "_is_governed_tool", False), f"{tool.__name__} missing @governed_tool"
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import inspect
|
|
28
|
+
import json
|
|
29
|
+
import logging
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
import time
|
|
33
|
+
import traceback
|
|
34
|
+
from functools import wraps
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from apache_aiops.governance.audit import detect_agent, get_engine
|
|
38
|
+
from apache_aiops.governance.budget import BudgetExceeded, get_budget
|
|
39
|
+
from apache_aiops.governance.outcome import (
|
|
40
|
+
clear_prior_state,
|
|
41
|
+
is_unknown,
|
|
42
|
+
take_prior_state,
|
|
43
|
+
)
|
|
44
|
+
from apache_aiops.governance.patterns import PatternMatch, get_pattern_engine
|
|
45
|
+
from apache_aiops.governance.policy import get_policy_engine
|
|
46
|
+
from apache_aiops.governance.sanitize import sanitize
|
|
47
|
+
|
|
48
|
+
_log = logging.getLogger("apache-aiops.decorators")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class PolicyDenied(Exception):
|
|
52
|
+
"""Retained for backward compatibility with callers that catch it.
|
|
53
|
+
|
|
54
|
+
The skill no longer makes authorization decisions — read-only mode, deny
|
|
55
|
+
rules and the approver gate were all removed — so nothing raises this any
|
|
56
|
+
more. It stays defined and caught so existing ``except PolicyDenied`` sites
|
|
57
|
+
keep compiling; the live stop path is :class:`BudgetExceeded` (runaway).
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def governed_tool(
|
|
62
|
+
fn: Any = None,
|
|
63
|
+
*,
|
|
64
|
+
risk_level: str = "low",
|
|
65
|
+
idempotent: bool = False,
|
|
66
|
+
timeout_seconds: int = 300,
|
|
67
|
+
sensitive_params: list[str] | None = None,
|
|
68
|
+
undo: Any = None,
|
|
69
|
+
) -> Any:
|
|
70
|
+
"""Decorator for all container-host MCP tool functions.
|
|
71
|
+
|
|
72
|
+
Can be used with or without arguments::
|
|
73
|
+
|
|
74
|
+
@governed_tool
|
|
75
|
+
def list_segments(...): ...
|
|
76
|
+
|
|
77
|
+
@governed_tool(risk_level="critical", sensitive_params=["password"])
|
|
78
|
+
def delete_vm(...): ...
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
risk_level: One of 'low', 'medium', 'high', 'critical'.
|
|
82
|
+
idempotent: Whether the operation can be safely retried on failure.
|
|
83
|
+
timeout_seconds: Maximum execution time before warning — exceeding it
|
|
84
|
+
logs a warning (no hard cancellation).
|
|
85
|
+
sensitive_params: Parameter names to redact in audit logs.
|
|
86
|
+
undo: Optional callable ``(params, result) -> dict | None`` returning an
|
|
87
|
+
inverse descriptor ``{"tool", "params", "skill"?, "note"?}``. On a
|
|
88
|
+
successful call the inverse is recorded to ~/.apache-aiops/undo.db and the
|
|
89
|
+
result dict gains an ``_undo_id``. Return None for "no safe inverse".
|
|
90
|
+
Recording only — execution is an external orchestrator's job.
|
|
91
|
+
"""
|
|
92
|
+
_sensitive = set(sensitive_params or [])
|
|
93
|
+
|
|
94
|
+
def decorator(func: Any) -> Any:
|
|
95
|
+
# Cache the signature at decoration time so positional args can be
|
|
96
|
+
# mapped to parameter names on every call (audit + env scoping).
|
|
97
|
+
signature = inspect.signature(func)
|
|
98
|
+
|
|
99
|
+
if inspect.iscoroutinefunction(func):
|
|
100
|
+
# ── Async tools get an async wrapper with identical audit /
|
|
101
|
+
# policy / circuit-breaker semantics (a sync wrapper would return
|
|
102
|
+
# an un-awaited coroutine and audit it as "ok").
|
|
103
|
+
@wraps(func)
|
|
104
|
+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
105
|
+
state = _CallState(
|
|
106
|
+
func,
|
|
107
|
+
args,
|
|
108
|
+
kwargs,
|
|
109
|
+
signature,
|
|
110
|
+
_sensitive,
|
|
111
|
+
risk_level,
|
|
112
|
+
timeout_seconds,
|
|
113
|
+
undo,
|
|
114
|
+
)
|
|
115
|
+
try:
|
|
116
|
+
_pre_check(state)
|
|
117
|
+
_begin_write(state)
|
|
118
|
+
return _annotate_result(state, await func(*args, **kwargs))
|
|
119
|
+
except (PolicyDenied, BudgetExceeded):
|
|
120
|
+
raise
|
|
121
|
+
except Exception as exc:
|
|
122
|
+
_capture_error(state, exc)
|
|
123
|
+
raise
|
|
124
|
+
finally:
|
|
125
|
+
_finalize(state)
|
|
126
|
+
else:
|
|
127
|
+
|
|
128
|
+
@wraps(func)
|
|
129
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
130
|
+
state = _CallState(
|
|
131
|
+
func,
|
|
132
|
+
args,
|
|
133
|
+
kwargs,
|
|
134
|
+
signature,
|
|
135
|
+
_sensitive,
|
|
136
|
+
risk_level,
|
|
137
|
+
timeout_seconds,
|
|
138
|
+
undo,
|
|
139
|
+
)
|
|
140
|
+
try:
|
|
141
|
+
_pre_check(state)
|
|
142
|
+
_begin_write(state)
|
|
143
|
+
return _annotate_result(state, func(*args, **kwargs))
|
|
144
|
+
except (PolicyDenied, BudgetExceeded):
|
|
145
|
+
raise
|
|
146
|
+
except Exception as exc:
|
|
147
|
+
_capture_error(state, exc)
|
|
148
|
+
raise
|
|
149
|
+
finally:
|
|
150
|
+
_finalize(state)
|
|
151
|
+
|
|
152
|
+
# ── Attach metadata for harness / introspection ───────────
|
|
153
|
+
wrapper._is_governed_tool = True
|
|
154
|
+
wrapper._risk_level = risk_level
|
|
155
|
+
wrapper._idempotent = idempotent
|
|
156
|
+
wrapper._timeout_seconds = timeout_seconds
|
|
157
|
+
wrapper._sensitive_params = list(_sensitive)
|
|
158
|
+
return wrapper
|
|
159
|
+
|
|
160
|
+
# Support @governed_tool and @governed_tool(...)
|
|
161
|
+
if fn is not None:
|
|
162
|
+
return decorator(fn)
|
|
163
|
+
return decorator
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ── Internal helpers ──────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class _CallState:
|
|
170
|
+
"""Per-call context shared by the sync and async wrapper bodies.
|
|
171
|
+
|
|
172
|
+
Built once per invocation; the helper functions (`_pre_check`,
|
|
173
|
+
`_annotate_result`, `_capture_error`, `_finalize`) read and mutate it so
|
|
174
|
+
both wrappers keep identical audit / policy / circuit-breaker semantics.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
__slots__ = (
|
|
178
|
+
"skill",
|
|
179
|
+
"tool_name",
|
|
180
|
+
"agent",
|
|
181
|
+
"start",
|
|
182
|
+
"status",
|
|
183
|
+
"result",
|
|
184
|
+
"pattern_match",
|
|
185
|
+
"audit",
|
|
186
|
+
"safe_params",
|
|
187
|
+
"env",
|
|
188
|
+
"risk_level",
|
|
189
|
+
"timeout_seconds",
|
|
190
|
+
"rationale",
|
|
191
|
+
"approved_by",
|
|
192
|
+
"risk_tier",
|
|
193
|
+
"undo",
|
|
194
|
+
"dry_run",
|
|
195
|
+
"intent_id",
|
|
196
|
+
"workflow_id",
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
def __init__(
|
|
200
|
+
self,
|
|
201
|
+
func: Any,
|
|
202
|
+
args: tuple[Any, ...],
|
|
203
|
+
kwargs: dict[str, Any],
|
|
204
|
+
signature: inspect.Signature,
|
|
205
|
+
sensitive: set[str],
|
|
206
|
+
risk_level: str,
|
|
207
|
+
timeout_seconds: int,
|
|
208
|
+
undo: Any = None,
|
|
209
|
+
) -> None:
|
|
210
|
+
# A previous call on this thread/task may have captured before-state
|
|
211
|
+
# that its own wrapper never consumed (it raised past _annotate_result).
|
|
212
|
+
# Start clean so one call's prior state can never be recorded as another's.
|
|
213
|
+
clear_prior_state()
|
|
214
|
+
self.undo = undo
|
|
215
|
+
self.skill = _infer_skill(func)
|
|
216
|
+
self.tool_name = func.__name__
|
|
217
|
+
self.agent = detect_agent()
|
|
218
|
+
self.start = time.time()
|
|
219
|
+
self.status = "ok"
|
|
220
|
+
self.result: Any = None
|
|
221
|
+
self.pattern_match: PatternMatch | None = None
|
|
222
|
+
self.risk_level = risk_level
|
|
223
|
+
self.timeout_seconds = timeout_seconds
|
|
224
|
+
self.audit = get_engine()
|
|
225
|
+
self.intent_id: int | None = None
|
|
226
|
+
candidate = os.environ.get("AIOPS_WORKFLOW_ID", "")
|
|
227
|
+
self.workflow_id = candidate if re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", candidate) else ""
|
|
228
|
+
|
|
229
|
+
# Map positional args to parameter names so they appear in the audit
|
|
230
|
+
# log and participate in env scoping (previously only kwargs did).
|
|
231
|
+
params = _bind_params(signature, args, kwargs)
|
|
232
|
+
self.safe_params = _redact(params, sensitive)
|
|
233
|
+
# Previews are governed like any other call — audited, budget-counted —
|
|
234
|
+
# but they change nothing, so undo recording must not apply to them.
|
|
235
|
+
self.dry_run = bool(params.get("dry_run", False))
|
|
236
|
+
env = params.get("target", params.get("env", ""))
|
|
237
|
+
self.env = str(env) if env else ""
|
|
238
|
+
|
|
239
|
+
# Optional accountability context for the audit trail (SOC2 / 等保: who
|
|
240
|
+
# authorized this, and why). Read from env so an operator or pilot can
|
|
241
|
+
# annotate the trail without changing any tool signature. Purely
|
|
242
|
+
# recorded — never required, never enforced. risk_tier is filled by the
|
|
243
|
+
# pre-check as a descriptive label for the row.
|
|
244
|
+
self.rationale = os.environ.get("APACHE_AUDIT_RATIONALE", "")
|
|
245
|
+
self.approved_by = os.environ.get("APACHE_AUDIT_APPROVED_BY", "")
|
|
246
|
+
self.risk_tier = ""
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _bind_params(
|
|
250
|
+
signature: inspect.Signature, args: tuple[Any, ...], kwargs: dict[str, Any]
|
|
251
|
+
) -> dict[str, Any]:
|
|
252
|
+
"""Build a full name→value param dict from positional + keyword args.
|
|
253
|
+
|
|
254
|
+
Falls back to kwargs-only if binding fails (the actual call will raise
|
|
255
|
+
the matching TypeError; audit should not mask it with its own).
|
|
256
|
+
"""
|
|
257
|
+
try:
|
|
258
|
+
bound = signature.bind_partial(*args, **kwargs)
|
|
259
|
+
# Apply declared defaults so env scoping and risk-tier matching see the
|
|
260
|
+
# effective target/tags even when the caller relied on a default value
|
|
261
|
+
# (bind_partial alone only captures explicitly-passed arguments).
|
|
262
|
+
bound.apply_defaults()
|
|
263
|
+
except TypeError:
|
|
264
|
+
return dict(kwargs)
|
|
265
|
+
params: dict[str, Any] = {}
|
|
266
|
+
for name, value in bound.arguments.items():
|
|
267
|
+
kind = signature.parameters[name].kind
|
|
268
|
+
if kind == inspect.Parameter.VAR_KEYWORD:
|
|
269
|
+
params.update(value)
|
|
270
|
+
elif kind == inspect.Parameter.VAR_POSITIONAL:
|
|
271
|
+
params[name] = list(value)
|
|
272
|
+
else:
|
|
273
|
+
params[name] = value
|
|
274
|
+
return params
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _pre_check(state: _CallState) -> None:
|
|
278
|
+
"""Risk-tier tagging + runaway guard + auto-remediation pattern consult.
|
|
279
|
+
|
|
280
|
+
Deliberately does NO authorization. Whether a read or a write is permitted
|
|
281
|
+
is the agent's decision or the connecting account's permissions, not the
|
|
282
|
+
skill's — read-only mode, deny rules and the approver gate were all removed.
|
|
283
|
+
What happens here: the operation's risk tier is recorded for the audit
|
|
284
|
+
trail, the runaway/budget guard (a safety stop, not an authz gate) is
|
|
285
|
+
applied, and the pattern engine is consulted. A broken pattern file must
|
|
286
|
+
never take down a tool, so that consult is fail-open.
|
|
287
|
+
"""
|
|
288
|
+
# Descriptive label for the audit row (e.g. a high-risk delete). Gates nothing.
|
|
289
|
+
state.risk_tier = get_policy_engine().tier_for(state.risk_level)
|
|
290
|
+
|
|
291
|
+
# Runaway / budget guard — a safety backstop for a stuck loop, not an
|
|
292
|
+
# authorization decision. A trip raises BudgetExceeded (a hard stop); mark
|
|
293
|
+
# it on state so _finalize audits the stopped call.
|
|
294
|
+
try:
|
|
295
|
+
get_budget().check_and_record(state.tool_name, state.safe_params)
|
|
296
|
+
except BudgetExceeded as exc:
|
|
297
|
+
state.status = "budget_exceeded"
|
|
298
|
+
state.result = {"error": exc.reason, "rule": exc.rule}
|
|
299
|
+
raise
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
state.pattern_match = get_pattern_engine().match(
|
|
303
|
+
skill=state.skill, tool=state.tool_name, target=state.env
|
|
304
|
+
)
|
|
305
|
+
except Exception: # noqa: BLE001 — fail-open by design
|
|
306
|
+
state.pattern_match = None
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _begin_write(state: _CallState) -> None:
|
|
310
|
+
"""Refuse an upstream write unless its intent is durably committed."""
|
|
311
|
+
if state.risk_level == "low" or state.dry_run:
|
|
312
|
+
return
|
|
313
|
+
state.intent_id = state.audit.begin_write(
|
|
314
|
+
skill=state.skill,
|
|
315
|
+
tool=state.tool_name,
|
|
316
|
+
params=state.safe_params,
|
|
317
|
+
agent=state.agent,
|
|
318
|
+
workflow_id=state.workflow_id,
|
|
319
|
+
risk_level=state.risk_level,
|
|
320
|
+
rationale=state.rationale,
|
|
321
|
+
approved_by=state.approved_by,
|
|
322
|
+
risk_tier=state.risk_tier,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _annotate_result(state: _CallState, result: Any) -> Any:
|
|
327
|
+
"""Record the result, surface pattern context, and record an undo token.
|
|
328
|
+
|
|
329
|
+
Runs on the path where the tool returned a value — which includes the
|
|
330
|
+
failures ``tool_errors`` sanitized into an ``{"error": ...}`` dict. Those
|
|
331
|
+
are not all alike: one whose response was merely lost may well have taken
|
|
332
|
+
effect, so :func:`_record_undo` treats it separately rather than assuming
|
|
333
|
+
nothing happened.
|
|
334
|
+
"""
|
|
335
|
+
state.result = result
|
|
336
|
+
if state.pattern_match and state.pattern_match.armed and isinstance(result, dict):
|
|
337
|
+
result.setdefault("_pattern_id", state.pattern_match.pattern.pattern_id)
|
|
338
|
+
result.setdefault("_pattern_armed", True)
|
|
339
|
+
_record_undo(state, result)
|
|
340
|
+
return result
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _record_undo(state: _CallState, result: Any) -> None:
|
|
344
|
+
"""Compute and persist the inverse descriptor for a write.
|
|
345
|
+
|
|
346
|
+
Two paths:
|
|
347
|
+
|
|
348
|
+
* **Confirmed** — the tool returned normally, so ``result`` carries the
|
|
349
|
+
before-state its undo callable needs. Recorded ``effect_verified=True``.
|
|
350
|
+
* **Undetermined** — the response was lost (see
|
|
351
|
+
:mod:`apache_aiops.governance.outcome`). The change may be live,
|
|
352
|
+
so an inverse still matters, but ``result`` is only an error payload. An
|
|
353
|
+
inverse is recorded ONLY when the write explicitly stashed its
|
|
354
|
+
before-state via ``capture_prior_state``; synthesizing one from an empty
|
|
355
|
+
result would produce a *wrong* inverse, which is worse than none.
|
|
356
|
+
|
|
357
|
+
Best-effort throughout: a broken undo callable or store must never fail the
|
|
358
|
+
call. Attaches ``_undo_id`` to dict results so the caller can reference it.
|
|
359
|
+
"""
|
|
360
|
+
if state.undo is None:
|
|
361
|
+
return
|
|
362
|
+
if state.dry_run:
|
|
363
|
+
# A preview changed nothing, so there is nothing to invert — and the
|
|
364
|
+
# descriptor it would produce is actively dangerous. An undo callable
|
|
365
|
+
# reads the before-state out of the result, and a preview has none, so
|
|
366
|
+
# permissive defaults (``(result.get("priorState") or {}).get("running",
|
|
367
|
+
# True)``) fill the gap with a guess. That yielded a REAL, applicable
|
|
368
|
+
# start_container token for a stop that never happened.
|
|
369
|
+
return
|
|
370
|
+
|
|
371
|
+
verified = True
|
|
372
|
+
undo_input = result
|
|
373
|
+
if is_unknown(result):
|
|
374
|
+
prior = take_prior_state()
|
|
375
|
+
if prior is None:
|
|
376
|
+
_log.warning(
|
|
377
|
+
"%s.%s outcome undetermined and no prior state captured — "
|
|
378
|
+
"no inverse recorded; the change may be live and unrecorded",
|
|
379
|
+
state.skill,
|
|
380
|
+
state.tool_name,
|
|
381
|
+
)
|
|
382
|
+
return
|
|
383
|
+
verified = False
|
|
384
|
+
undo_input = {"priorState": prior, "outcomeUnknown": True}
|
|
385
|
+
elif isinstance(result, dict) and result.get("error"):
|
|
386
|
+
return # definite failure — no change happened, so no inverse to record
|
|
387
|
+
|
|
388
|
+
try:
|
|
389
|
+
descriptor = state.undo(state.safe_params, undo_input)
|
|
390
|
+
except Exception: # noqa: BLE001 — undo computation must not fail the call
|
|
391
|
+
_log.warning("undo callable for %s.%s raised", state.skill, state.tool_name, exc_info=True)
|
|
392
|
+
return
|
|
393
|
+
if not descriptor:
|
|
394
|
+
return
|
|
395
|
+
try:
|
|
396
|
+
from apache_aiops.governance.undo import get_undo_store
|
|
397
|
+
|
|
398
|
+
undo_id = get_undo_store().record(
|
|
399
|
+
skill=state.skill,
|
|
400
|
+
tool=state.tool_name,
|
|
401
|
+
undo_descriptor=descriptor,
|
|
402
|
+
orig_params=state.safe_params,
|
|
403
|
+
effect_verified=verified,
|
|
404
|
+
)
|
|
405
|
+
if undo_id and isinstance(result, dict):
|
|
406
|
+
result.setdefault("_undo_id", undo_id)
|
|
407
|
+
except Exception: # noqa: BLE001 — recording is best-effort
|
|
408
|
+
_log.warning("failed to record undo for %s.%s", state.skill, state.tool_name, exc_info=True)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _capture_error(state: _CallState, exc: Exception) -> None:
|
|
412
|
+
"""Record a failed call. Exception text and tracebacks can carry
|
|
413
|
+
connection strings, credentials, internal paths — sanitize before
|
|
414
|
+
persisting to the audit row."""
|
|
415
|
+
state.status = "error"
|
|
416
|
+
state.result = {
|
|
417
|
+
"error": sanitize(_redact_secrets_text(str(exc)), 500),
|
|
418
|
+
"traceback": sanitize(_redact_secrets_text(traceback.format_exc()[-500:]), 500),
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _finalize(state: _CallState) -> None:
|
|
423
|
+
"""Audit + circuit-breaker bookkeeping. Runs in the wrapper's finally."""
|
|
424
|
+
# A sanitized failure (@tool_errors converts exceptions into {"error": ...}
|
|
425
|
+
# dicts BEFORE this harness sees them) must not be audited as success —
|
|
426
|
+
# compliance exception reports are built from this status. 'unknown' is a
|
|
427
|
+
# distinct verdict, not a softer 'error': the request may have taken effect,
|
|
428
|
+
# so the row must not assert a failure the tool cannot actually vouch for.
|
|
429
|
+
if state.status == "ok" and isinstance(state.result, dict):
|
|
430
|
+
# is_unknown is checked FIRST and independently of ``error``. A write
|
|
431
|
+
# can be undetermined while looking perfectly successful — an upstream
|
|
432
|
+
# async task that has not finished yet returns a normal payload — and
|
|
433
|
+
# gating this on ``error`` audited those as 'ok', re-creating the very
|
|
434
|
+
# lie the unknown verdict exists to prevent.
|
|
435
|
+
if is_unknown(state.result):
|
|
436
|
+
state.status = "unknown"
|
|
437
|
+
elif state.result.get("error"):
|
|
438
|
+
state.status = "error"
|
|
439
|
+
|
|
440
|
+
duration = int((time.time() - state.start) * 1000)
|
|
441
|
+
|
|
442
|
+
# Accumulate wall-time toward the cumulative time budget (best-effort).
|
|
443
|
+
try:
|
|
444
|
+
get_budget().add_duration(time.time() - state.start)
|
|
445
|
+
except Exception: # noqa: BLE001 — bookkeeping must never fail the call
|
|
446
|
+
pass
|
|
447
|
+
|
|
448
|
+
# timeout_seconds is advisory: exceeding it logs a warning, no hard
|
|
449
|
+
# cancellation (cancelling mid-flight container-host calls is worse).
|
|
450
|
+
if state.timeout_seconds and duration > state.timeout_seconds * 1000:
|
|
451
|
+
_log.warning(
|
|
452
|
+
"%s.%s took %dms — exceeded timeout_seconds=%d (advisory, not cancelled)",
|
|
453
|
+
state.skill,
|
|
454
|
+
state.tool_name,
|
|
455
|
+
duration,
|
|
456
|
+
state.timeout_seconds,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
final_status = state.status
|
|
460
|
+
|
|
461
|
+
# Update circuit-breaker state for armed patterns
|
|
462
|
+
if state.pattern_match and state.pattern_match.armed:
|
|
463
|
+
try:
|
|
464
|
+
get_pattern_engine().report_outcome(
|
|
465
|
+
pattern_id=state.pattern_match.pattern.pattern_id,
|
|
466
|
+
target=state.env,
|
|
467
|
+
success=(state.status == "ok"),
|
|
468
|
+
)
|
|
469
|
+
except Exception: # noqa: BLE001 — never let bookkeeping fail the call
|
|
470
|
+
pass
|
|
471
|
+
|
|
472
|
+
pattern_id = state.pattern_match.pattern.pattern_id if state.pattern_match else ""
|
|
473
|
+
pattern_armed = bool(state.pattern_match and state.pattern_match.armed)
|
|
474
|
+
|
|
475
|
+
audit_id = state.intent_id
|
|
476
|
+
persisted = False
|
|
477
|
+
try:
|
|
478
|
+
if audit_id is not None:
|
|
479
|
+
state.audit.finish_write(
|
|
480
|
+
audit_id,
|
|
481
|
+
result=_with_pattern_context(state.result, pattern_id, pattern_armed),
|
|
482
|
+
status=final_status,
|
|
483
|
+
duration_ms=duration,
|
|
484
|
+
)
|
|
485
|
+
persisted = True
|
|
486
|
+
else:
|
|
487
|
+
audit_id = state.audit.log(
|
|
488
|
+
skill=state.skill,
|
|
489
|
+
tool=state.tool_name,
|
|
490
|
+
params=state.safe_params,
|
|
491
|
+
result=_with_pattern_context(state.result, pattern_id, pattern_armed),
|
|
492
|
+
status=final_status,
|
|
493
|
+
duration_ms=duration,
|
|
494
|
+
agent=state.agent,
|
|
495
|
+
workflow_id=state.workflow_id,
|
|
496
|
+
user="",
|
|
497
|
+
risk_level=state.risk_level,
|
|
498
|
+
rationale=state.rationale,
|
|
499
|
+
approved_by=state.approved_by,
|
|
500
|
+
risk_tier=state.risk_tier,
|
|
501
|
+
)
|
|
502
|
+
persisted = audit_id is not None
|
|
503
|
+
finally:
|
|
504
|
+
_emit_telemetry(
|
|
505
|
+
state, audit_id, final_status if persisted else "unknown", duration, persisted
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _emit_telemetry(
|
|
510
|
+
state: _CallState, audit_id: int | None, status: str, duration_ms: int, persisted: bool
|
|
511
|
+
) -> None:
|
|
512
|
+
"""Optional metadata-only event for a local log collector."""
|
|
513
|
+
if os.environ.get("AIOPS_TELEMETRY") != "1":
|
|
514
|
+
return
|
|
515
|
+
truncated = state.result.get("truncated") if isinstance(state.result, dict) else None
|
|
516
|
+
if type(truncated) is not bool:
|
|
517
|
+
truncated = None
|
|
518
|
+
payload = {
|
|
519
|
+
"event": "aiops.tool",
|
|
520
|
+
"skill": state.skill,
|
|
521
|
+
"tool": state.tool_name,
|
|
522
|
+
"workflowId": state.workflow_id,
|
|
523
|
+
"auditId": audit_id,
|
|
524
|
+
"riskLevel": state.risk_level,
|
|
525
|
+
"status": status,
|
|
526
|
+
"durationMs": duration_ms,
|
|
527
|
+
"auditPersisted": persisted,
|
|
528
|
+
"truncated": truncated,
|
|
529
|
+
}
|
|
530
|
+
_log.info("AIOPS_TELEMETRY %s", json.dumps(payload, ensure_ascii=False))
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _infer_skill(func: Any) -> str:
|
|
534
|
+
"""Infer the skill name from the function's module path.
|
|
535
|
+
|
|
536
|
+
``apache_aiops.ops.jobs`` → ``apache-aiops``
|
|
537
|
+
``mcp_server.server`` → ``apache-aiops`` (the only consumer here).
|
|
538
|
+
"""
|
|
539
|
+
module = getattr(func, "__module__", "") or ""
|
|
540
|
+
if module.startswith("apache_aiops") or module.startswith("mcp_server"):
|
|
541
|
+
return "apache-aiops"
|
|
542
|
+
return "unknown"
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _redact(params: dict[str, Any], sensitive: set[str]) -> dict[str, Any]:
|
|
546
|
+
"""Return a copy of params with sensitive values replaced by '***'.
|
|
547
|
+
|
|
548
|
+
Recurses into nested dicts AND lists/tuples so credentials buried inside
|
|
549
|
+
collections (e.g. ``{"targets": [{"password": "x"}]}``) are redacted too.
|
|
550
|
+
"""
|
|
551
|
+
if not sensitive:
|
|
552
|
+
return params
|
|
553
|
+
result: dict[str, Any] = {}
|
|
554
|
+
for k, v in params.items():
|
|
555
|
+
if k in sensitive:
|
|
556
|
+
result[k] = "***"
|
|
557
|
+
else:
|
|
558
|
+
result[k] = _redact_value(v, sensitive)
|
|
559
|
+
return result
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _redact_value(value: Any, sensitive: set[str]) -> Any:
|
|
563
|
+
"""Recursively redact sensitive keys inside dicts, lists, and tuples."""
|
|
564
|
+
if isinstance(value, dict):
|
|
565
|
+
return _redact(value, sensitive)
|
|
566
|
+
if isinstance(value, (list, tuple)):
|
|
567
|
+
return type(value)(_redact_value(item, sensitive) for item in value)
|
|
568
|
+
return value
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
# Matches ``key=value`` / ``key: value`` / ``key"="value`` for common secret
|
|
572
|
+
# keys in free-form exception text. Value runs until whitespace, quote, comma,
|
|
573
|
+
# or '@' (to keep host:port that often follows a credential in DSNs).
|
|
574
|
+
_SECRET_TEXT_RE = re.compile(
|
|
575
|
+
r"(?i)\b(password|passwd|pwd|token|secret|api[_-]?key|authorization|bearer)"
|
|
576
|
+
r"(\s*[=:]\s*|\s+)"
|
|
577
|
+
r"['\"]?[^\s'\",@]+",
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _redact_secrets_text(text: str) -> str:
|
|
582
|
+
"""Redact ``password=...`` / ``token: ...`` style secrets in free-form text."""
|
|
583
|
+
return _SECRET_TEXT_RE.sub(r"\1\2***", text)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _with_pattern_context(result: Any, pattern_id: str, armed: bool) -> Any:
|
|
587
|
+
"""Attach pattern metadata to an audit row's result field.
|
|
588
|
+
|
|
589
|
+
Only mutates dict results; non-dict results (errors, primitives) are
|
|
590
|
+
returned unchanged so the audit log preserves them faithfully.
|
|
591
|
+
"""
|
|
592
|
+
if not pattern_id:
|
|
593
|
+
return result
|
|
594
|
+
if isinstance(result, dict):
|
|
595
|
+
annotated = dict(result)
|
|
596
|
+
annotated.setdefault("_pattern_id", pattern_id)
|
|
597
|
+
annotated.setdefault("_pattern_armed", armed)
|
|
598
|
+
return annotated
|
|
599
|
+
return result
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Tell "the write did not happen" apart from "we cannot tell whether it happened".
|
|
2
|
+
|
|
3
|
+
A governed write that loses its response is not a failed write. The request may
|
|
4
|
+
already have landed; the only thing that certainly failed is our knowledge of
|
|
5
|
+
the outcome. The harness used to collapse both into ``status='error'`` with no
|
|
6
|
+
undo token — and it did so precisely where the operation was most dangerous,
|
|
7
|
+
because the two coincide:
|
|
8
|
+
|
|
9
|
+
a write whose target is the very thing serving this tool's API
|
|
10
|
+
→ the change takes effect
|
|
11
|
+
→ the response never comes back, because what would have sent it is gone
|
|
12
|
+
→ sanitized to {"error": ...} → audited as a FAILURE → no inverse recorded
|
|
13
|
+
|
|
14
|
+
The audit trail then reports that the destructive call failed while the change
|
|
15
|
+
is live and unrecorded. For a line whose product is "governed and reversible",
|
|
16
|
+
a row that confidently states the wrong outcome is worse than a missing one.
|
|
17
|
+
|
|
18
|
+
Two primitives fix that:
|
|
19
|
+
|
|
20
|
+
``mark_unknown`` / ``is_unknown``
|
|
21
|
+
The MCP layer's ``tool_errors`` classifies the exception it caught. Only
|
|
22
|
+
errors that leave the request's fate genuinely undetermined (a read
|
|
23
|
+
timeout, a half-closed socket, a protocol error mid-response) are marked.
|
|
24
|
+
A connect error or a pool timeout means the request never left, and an HTTP
|
|
25
|
+
status error means the server answered — both are ordinary failures and are
|
|
26
|
+
NOT marked. The distinction is per-tool knowledge, so each tool's
|
|
27
|
+
``mcp_server/_shared.py`` owns the exception tuple; this module only carries
|
|
28
|
+
the marker.
|
|
29
|
+
|
|
30
|
+
``capture_prior_state`` / ``take_prior_state``
|
|
31
|
+
An undo callable receives ``(params, result)``. When the write raises, the
|
|
32
|
+
before-state that was read moments earlier dies with the exception and no
|
|
33
|
+
inverse can be computed. A write that wants its undo to survive a lost
|
|
34
|
+
response stashes the before-state *before* issuing the mutating request::
|
|
35
|
+
|
|
36
|
+
before = conn.get(...)
|
|
37
|
+
capture_prior_state({"running": before["running"]})
|
|
38
|
+
conn.post(...) # may never return
|
|
39
|
+
|
|
40
|
+
The harness then records the inverse even on the unknown path, flagged
|
|
41
|
+
``effect_verified=False`` so nobody mistakes it for a confirmed change.
|
|
42
|
+
Opt-in by design: fabricating a descriptor from an empty result would
|
|
43
|
+
produce a *wrong* inverse, which is worse than none.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
from contextvars import ContextVar
|
|
49
|
+
from typing import Any
|
|
50
|
+
|
|
51
|
+
# Public result field. Explicit rather than inferred: a caller must never have
|
|
52
|
+
# to guess from the error text whether the operation may have taken effect.
|
|
53
|
+
UNKNOWN_FIELD = "outcomeUnknown"
|
|
54
|
+
|
|
55
|
+
_UNKNOWN_NOTE = (
|
|
56
|
+
"The request was sent but no usable response came back, so this operation "
|
|
57
|
+
"MAY have taken effect on the server. Verify the current state before "
|
|
58
|
+
"retrying — a blind retry could apply it twice."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Set by a write immediately before it issues its mutating request; consumed by
|
|
62
|
+
# the harness. A ContextVar (not a module global) so concurrent calls and async
|
|
63
|
+
# tools never read each other's state.
|
|
64
|
+
_prior_state: ContextVar[dict[str, Any] | None] = ContextVar("governed_prior_state", default=None)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def mark_unknown(payload: dict[str, Any]) -> dict[str, Any]:
|
|
68
|
+
"""Return a NEW error payload flagged as having an undetermined outcome."""
|
|
69
|
+
marked = dict(payload)
|
|
70
|
+
marked[UNKNOWN_FIELD] = True
|
|
71
|
+
marked["note"] = _UNKNOWN_NOTE
|
|
72
|
+
return marked
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def is_unknown(result: Any) -> bool:
|
|
76
|
+
"""True when ``result`` is an error payload whose effect is undetermined."""
|
|
77
|
+
return isinstance(result, dict) and result.get(UNKNOWN_FIELD) is True
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def capture_prior_state(state: dict[str, Any]) -> None:
|
|
81
|
+
"""Stash before-state so the inverse survives a lost response.
|
|
82
|
+
|
|
83
|
+
Call this AFTER reading the before-state and BEFORE issuing the mutating
|
|
84
|
+
request. Overwrites any previously captured state for this call.
|
|
85
|
+
"""
|
|
86
|
+
_prior_state.set(dict(state))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def take_prior_state() -> dict[str, Any] | None:
|
|
90
|
+
"""Consume the captured before-state (harness-internal).
|
|
91
|
+
|
|
92
|
+
Consuming rather than peeking keeps one call's state from leaking into the
|
|
93
|
+
next on a reused thread or task.
|
|
94
|
+
"""
|
|
95
|
+
state = _prior_state.get()
|
|
96
|
+
_prior_state.set(None)
|
|
97
|
+
return state
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def clear_prior_state() -> None:
|
|
101
|
+
"""Drop any captured state. Called at the start of every governed call."""
|
|
102
|
+
_prior_state.set(None)
|