apache-aiops 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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)