Some checks failed
Code Review / ai-code-review (push) Successful in 13s
CD Pipeline / tests (push) Failing after 1m8s
CD Pipeline / build-and-deploy (push) Has been skipped
CD Pipeline / post-deploy-checks (push) Has been skipped
Ansible / Reboot Recovery Contract / validate (push) Has been cancelled
426 lines
16 KiB
Python
426 lines
16 KiB
Python
"""
|
|
Claude Agent SDK Remediator Replay Adapter
|
|
=========================================
|
|
|
|
Deterministic offline adapter for the `claude_agent_sdk_remediator` market
|
|
candidate. The Claude Agent SDK is not installed in this repo environment, so
|
|
this module models the remediation boundary without adding dependencies or
|
|
calling Anthropic/Claude APIs.
|
|
|
|
It never edits files, executes tools, writes production systems, sends
|
|
messages, or reads fixture labels.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
from src.services.agent_market_candidate_adapter import get_market_candidate_spec
|
|
from src.services.agent_replay_input import assert_no_evaluation_label_leak
|
|
|
|
CLAUDE_REMEDIATOR_CANDIDATE_ID = "claude_agent_sdk_remediator"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ClaudeRemediatorDecision:
|
|
"""Candidate replay result produced by the Claude-shaped remediator."""
|
|
|
|
payload: dict[str, Any]
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return dict(self.payload)
|
|
|
|
|
|
def build_claude_remediator_candidate_result(
|
|
candidate_input: dict[str, Any],
|
|
) -> ClaudeRemediatorDecision:
|
|
"""Build one offline Claude remediator replay result."""
|
|
started = time.perf_counter()
|
|
assert_no_evaluation_label_leak(candidate_input)
|
|
spec = get_market_candidate_spec(CLAUDE_REMEDIATOR_CANDIDATE_ID)
|
|
incident_id = str(candidate_input.get("incident_id", "")).strip()
|
|
run_id = str(candidate_input.get("run_id", "")).strip()
|
|
if not incident_id or not run_id:
|
|
raise ValueError("candidate input must include incident_id and run_id")
|
|
|
|
context = dict(candidate_input.get("incident_context") or {})
|
|
state = _build_state(context)
|
|
route = _remediation_route(state)
|
|
plan = _plan_for_route(state, route)
|
|
risk_level = _risk_level(state, plan)
|
|
requires_human_approval = _requires_human_approval(risk_level, plan)
|
|
trace_events = _trace_events(state, route, plan, risk_level, requires_human_approval)
|
|
latency_ms = (time.perf_counter() - started) * 1000
|
|
|
|
return ClaudeRemediatorDecision(
|
|
payload={
|
|
"schema_version": "agent_candidate_replay_result_v1",
|
|
"run_id": run_id,
|
|
"incident_id": incident_id,
|
|
"candidate_id": spec.candidate_id,
|
|
"candidate_role": spec.candidate_role,
|
|
"proposed_action": plan["proposed_action"],
|
|
"action_plan": plan["action_plan"],
|
|
"risk_level": risk_level,
|
|
"requires_human_approval": requires_human_approval,
|
|
"blocked_by_policy": plan["blocked_by_policy"],
|
|
"fallback_used": False,
|
|
"trace_complete": True,
|
|
"trace_events": trace_events,
|
|
"rca_correct": None,
|
|
"tool_dry_run_pass": None,
|
|
"repair_success": None,
|
|
"false_repair": False,
|
|
"latency_ms": latency_ms,
|
|
"cost_usd": 0,
|
|
"error": None,
|
|
"metadata": {
|
|
"adapter_mode": "deterministic_offline_remediation_boundary",
|
|
"candidate_framework": "claude_agent_sdk",
|
|
"sdk_dependency": "claude_agent_sdk_package_not_installed",
|
|
"anthropic_api_calls": False,
|
|
"new_dependency_added": False,
|
|
"tools_executed": False,
|
|
"files_edited": False,
|
|
"remediation_route": route,
|
|
"guardrail_checks": [
|
|
"answer_key_leak_check",
|
|
"no_file_edit_without_approval",
|
|
"no_tool_execution_without_approval",
|
|
"controlled_apply_for_low_medium_high_patch_or_runtime_change",
|
|
"trace_required",
|
|
],
|
|
"source": "claude_agent_sdk_remediator_offline_adapter",
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
def build_claude_remediator_candidate_results(
|
|
candidate_inputs: list[dict[str, Any]],
|
|
) -> list[ClaudeRemediatorDecision]:
|
|
"""Build many Claude remediator replay results."""
|
|
return [
|
|
build_claude_remediator_candidate_result(candidate_input)
|
|
for candidate_input in candidate_inputs
|
|
]
|
|
|
|
|
|
def _build_state(context: dict[str, Any]) -> dict[str, Any]:
|
|
haystack = json.dumps(context, ensure_ascii=False, sort_keys=True).lower()
|
|
severity = str(context.get("severity") or "P3").strip().upper()
|
|
status = str(context.get("status") or "").strip().lower()
|
|
category = str(context.get("alert_category") or "general").strip().lower()
|
|
alertname = str(context.get("alertname") or "").strip()
|
|
service = _primary_service(context)
|
|
namespace = _namespace(context)
|
|
return {
|
|
"alertname": alertname,
|
|
"category": category,
|
|
"severity": severity,
|
|
"status": status,
|
|
"service": service,
|
|
"namespace": namespace,
|
|
"haystack": haystack,
|
|
"is_resolved": status == "resolved",
|
|
"is_code": any(
|
|
marker in haystack
|
|
for marker in (
|
|
"traceback",
|
|
"exception",
|
|
"build",
|
|
"lint",
|
|
"type error",
|
|
"builderror",
|
|
"importerror",
|
|
"syntax",
|
|
"module",
|
|
)
|
|
),
|
|
"is_config": any(
|
|
marker in haystack
|
|
for marker in ("config", "env", "secret", "token", "certificate", "tls", "ingress")
|
|
),
|
|
"is_kubernetes": any(
|
|
marker in haystack
|
|
for marker in ("kubernetes", "k8s", "pod", "deployment", "namespace", "container")
|
|
),
|
|
"is_database": any(marker in haystack for marker in ("postgres", "deadlock", "migration", "schema")),
|
|
"is_backup": "backup" in haystack,
|
|
"is_aiops": any(marker in haystack for marker in ("openclaw", "awooop", "agent", "flywheel")),
|
|
}
|
|
|
|
|
|
def _remediation_route(state: dict[str, Any]) -> str:
|
|
if state["is_resolved"]:
|
|
return "observe_only"
|
|
if state["is_code"]:
|
|
return "code_patch_proposal"
|
|
if state["is_config"]:
|
|
return "config_patch_proposal"
|
|
if state["is_database"]:
|
|
return "migration_review"
|
|
if state["is_backup"]:
|
|
return "backup_runbook_patch"
|
|
if state["is_aiops"]:
|
|
return "agent_workflow_patch"
|
|
if state["is_kubernetes"]:
|
|
return "kubernetes_manifest_review"
|
|
return "incident_runbook_patch"
|
|
|
|
|
|
def _plan_for_route(state: dict[str, Any], route: str) -> dict[str, Any]:
|
|
if route == "observe_only":
|
|
return _observe_plan(state)
|
|
if route == "code_patch_proposal":
|
|
return _code_patch_plan(state)
|
|
if route == "config_patch_proposal":
|
|
return _config_patch_plan(state)
|
|
if route == "migration_review":
|
|
return _migration_plan(state)
|
|
if route == "backup_runbook_patch":
|
|
return _backup_plan(state)
|
|
if route == "agent_workflow_patch":
|
|
return _agent_workflow_plan(state)
|
|
if route == "kubernetes_manifest_review":
|
|
return _kubernetes_manifest_plan(state)
|
|
return _runbook_patch_plan(state)
|
|
|
|
|
|
def _observe_plan(state: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"proposed_action": (
|
|
f"CLAUDE_OBSERVE_ONLY: incident is resolved; preserve evidence for "
|
|
f"{state['alertname']} on {state['service']} and draft no patch"
|
|
),
|
|
"blocked_by_policy": True,
|
|
"action_plan": [
|
|
_step("inspect-timeline", "awoooi-api", ["GET", "/api/v1/incidents/{incident_id}/timeline"]),
|
|
_step("summarize-evidence", "remediator", ["no-patch-required"]),
|
|
_step("handoff", "human", ["review-if-recurs"]),
|
|
],
|
|
}
|
|
|
|
|
|
def _code_patch_plan(state: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"proposed_action": (
|
|
"CLAUDE_PATCH_PROPOSAL: inspect traceback/build evidence, identify likely "
|
|
"source file, draft a minimal patch, and require approval before editing"
|
|
),
|
|
"blocked_by_policy": False,
|
|
"action_plan": [
|
|
_step("inspect-error", "logs", [state["alertname"], state["service"]]),
|
|
_step("inspect-source", "repo", ["read-only", "related-files"]),
|
|
_step("draft-patch", "remediator", ["minimal-diff", "no-write"]),
|
|
_step("draft-tests", "remediator", ["targeted-tests", "no-execution"]),
|
|
_step("approval-gate", "human", ["approve-before-apply-patch"]),
|
|
],
|
|
}
|
|
|
|
|
|
def _config_patch_plan(state: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"proposed_action": (
|
|
"CLAUDE_CONFIG_REVIEW: inspect env/config/TLS evidence, draft a redacted "
|
|
"configuration change, and require approval before secret or deploy changes"
|
|
),
|
|
"blocked_by_policy": False,
|
|
"action_plan": [
|
|
_step("inspect-config", "repo", ["read-only", "config-and-deploy-files"]),
|
|
_step("inspect-runtime", "awoooi-api", ["read-only", state["service"]]),
|
|
_step("draft-redacted-change", "remediator", ["no-secret-disclosure"]),
|
|
_step("approval-gate", "human", ["approve-before-secret-or-config-change"]),
|
|
],
|
|
}
|
|
|
|
|
|
def _migration_plan(state: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"proposed_action": (
|
|
"CLAUDE_MIGRATION_REVIEW: inspect schema/migration evidence, draft an "
|
|
"additive migration or rollback note, and require approval before DB writes"
|
|
),
|
|
"blocked_by_policy": False,
|
|
"action_plan": [
|
|
_step("inspect-schema", "postgres", ["read-only", "information_schema"]),
|
|
_step("inspect-migrations", "repo", ["read-only", "migrations"]),
|
|
_step("draft-migration", "remediator", ["additive-only", "no-write"]),
|
|
_step("approval-gate", "human", ["approve-before-db-write"]),
|
|
],
|
|
}
|
|
|
|
|
|
def _backup_plan(state: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"proposed_action": (
|
|
"CLAUDE_BACKUP_RUNBOOK_PATCH: inspect backup evidence and draft runbook or "
|
|
"script patch; do not delete backups, rotate retention, or change secrets"
|
|
),
|
|
"blocked_by_policy": False,
|
|
"action_plan": [
|
|
_step("inspect-backup-evidence", "logs", [state["service"], "backup"]),
|
|
_step("inspect-scripts", "repo", ["read-only", "scripts/backup"]),
|
|
_step("draft-runbook-patch", "remediator", ["no-write"]),
|
|
_step("approval-gate", "human", ["approve-before-script-change"]),
|
|
],
|
|
}
|
|
|
|
|
|
def _agent_workflow_plan(state: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"proposed_action": (
|
|
"CLAUDE_AGENT_WORKFLOW_PATCH: inspect agent sessions, approval queue, and "
|
|
"workflow code; draft a guardrail patch without changing production routing"
|
|
),
|
|
"blocked_by_policy": False,
|
|
"action_plan": [
|
|
_step("inspect-agent-evidence", "database", ["read-only", "agent_sessions"]),
|
|
_step("inspect-approval-chain", "database", ["read-only", "approval_records"]),
|
|
_step("inspect-code", "repo", ["read-only", "agent-workflow-files"]),
|
|
_step("draft-guardrail-patch", "remediator", ["no-write"]),
|
|
_step("approval-gate", "human", ["approve-before-agent-routing-change"]),
|
|
],
|
|
}
|
|
|
|
|
|
def _kubernetes_manifest_plan(state: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"proposed_action": (
|
|
f"CLAUDE_K8S_MANIFEST_REVIEW: inspect workload manifests and runtime "
|
|
f"events for {state['service']}; draft patch but do not rollout"
|
|
),
|
|
"blocked_by_policy": False,
|
|
"action_plan": [
|
|
_step("inspect-manifest", "repo", ["read-only", "k8s", state["namespace"]]),
|
|
_step("inspect-events", "kubectl", ["get", "events", "-n", state["namespace"]]),
|
|
_step("draft-manifest-patch", "remediator", ["no-write"]),
|
|
_step("approval-gate", "human", ["approve-before-rollout"]),
|
|
],
|
|
}
|
|
|
|
|
|
def _runbook_patch_plan(state: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"proposed_action": (
|
|
"CLAUDE_RUNBOOK_PATCH: inspect incident evidence, draft runbook/playbook "
|
|
"improvement, and require replay validation before production use"
|
|
),
|
|
"blocked_by_policy": False,
|
|
"action_plan": [
|
|
_step("inspect-evidence", "awoooi-api", ["GET", "/api/v1/incidents/{incident_id}/evidence"]),
|
|
_step("inspect-docs", "repo", ["read-only", "docs/runbooks"]),
|
|
_step("draft-runbook-update", "remediator", ["no-write"]),
|
|
_step("approval-gate", "human", ["approve-before-runbook-change"]),
|
|
],
|
|
}
|
|
|
|
|
|
def _risk_level(state: dict[str, Any], plan: dict[str, Any]) -> str:
|
|
if state["severity"] == "P0":
|
|
return "critical"
|
|
if state["severity"] == "P1" or state["is_config"]:
|
|
return "high"
|
|
action = json.dumps(plan, ensure_ascii=False).lower()
|
|
if any(marker in action for marker in ("patch", "migration", "secret", "rollout", "db write")):
|
|
return "medium"
|
|
if state["severity"] == "P2":
|
|
return "medium"
|
|
return "low"
|
|
|
|
|
|
def _requires_human_approval(risk_level: str, plan: dict[str, Any]) -> bool:
|
|
action = json.dumps(plan, ensure_ascii=False).lower()
|
|
return risk_level == "critical" or any(
|
|
marker in action
|
|
for marker in (
|
|
"break-glass",
|
|
"migration",
|
|
"secret",
|
|
"credential",
|
|
"authorization header",
|
|
"private key",
|
|
"drop database",
|
|
"truncate",
|
|
"delete pvc",
|
|
"delete namespace",
|
|
"force push",
|
|
"ref deletion",
|
|
"external attack",
|
|
"paid provider",
|
|
)
|
|
)
|
|
|
|
|
|
def _trace_events(
|
|
state: dict[str, Any],
|
|
route: str,
|
|
plan: dict[str, Any],
|
|
risk_level: str,
|
|
requires_human_approval: bool,
|
|
) -> list[dict[str, Any]]:
|
|
return [
|
|
{"type": "input_loaded", "alertname": state["alertname"], "service": state["service"]},
|
|
{
|
|
"type": "guardrails_checked",
|
|
"answer_key_leak": False,
|
|
"external_api_called": False,
|
|
"files_edited": False,
|
|
"tools_executed": False,
|
|
},
|
|
{"type": "remediation_route_selected", "route": route},
|
|
{"type": "patch_boundary_set", "draft_only": True, "writes_allowed": False},
|
|
{
|
|
"type": "risk_reviewed",
|
|
"risk_level": risk_level,
|
|
"requires_human_approval": requires_human_approval,
|
|
},
|
|
{
|
|
"type": "read_only_plan_built",
|
|
"steps": len(plan["action_plan"]),
|
|
"blocked_by_policy": plan["blocked_by_policy"],
|
|
},
|
|
]
|
|
|
|
|
|
def _step(name: str, tool: str, args: list[str]) -> dict[str, Any]:
|
|
return {
|
|
"name": name,
|
|
"tool": tool,
|
|
"args": args,
|
|
"mode": "read_only",
|
|
}
|
|
|
|
|
|
def _primary_service(context: dict[str, Any]) -> str:
|
|
affected = context.get("affected_services")
|
|
if isinstance(affected, list) and affected:
|
|
return str(affected[0]).strip() or "unknown-service"
|
|
for signal in context.get("signals") or []:
|
|
if not isinstance(signal, dict):
|
|
continue
|
|
labels = signal.get("labels") or {}
|
|
if not isinstance(labels, dict):
|
|
continue
|
|
for key in ("deployment", "service", "container", "pod", "app", "instance"):
|
|
if labels.get(key):
|
|
return str(labels[key]).split(":")[0].strip() or "unknown-service"
|
|
service = context.get("service") or context.get("target_service")
|
|
return str(service or "unknown-service").strip()
|
|
|
|
|
|
def _namespace(context: dict[str, Any]) -> str:
|
|
namespace = context.get("namespace") or context.get("kubernetes_namespace")
|
|
if namespace:
|
|
return str(namespace).strip()
|
|
for signal in context.get("signals") or []:
|
|
if not isinstance(signal, dict):
|
|
continue
|
|
labels = signal.get("labels") or {}
|
|
if isinstance(labels, dict) and labels.get("namespace"):
|
|
return str(labels["namespace"]).strip()
|
|
return "awoooi-prod"
|