Major additions: - marketplace/: Agent template registry with FTS5 search, ratings, versioning - observability/: Prometheus metrics, distributed tracing, structured logging - ledger/migrations/: Database migration scripts for multi-tenant support - tests/governance/: 15 new test files for phases 6-12 (295 total tests) - bin/validate-phases: Full 12-phase validation script New features: - Multi-tenant support with tenant isolation and quota enforcement - Agent marketplace with semantic versioning and search - Observability with metrics, tracing, and log correlation - Tier-1 agent bootstrap scripts Updated components: - ledger/api.py: Extended API for tenants, marketplace, observability - ledger/schema.sql: Added tenant, project, marketplace tables - testing/framework.ts: Enhanced test framework - checkpoint/checkpoint.py: Improved checkpoint management Archived: - External integrations (Slack/GitHub/PagerDuty) moved to .archive/ - Old checkpoint files cleaned up Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
395 lines
14 KiB
Python
395 lines
14 KiB
Python
"""
|
|
Slack Integration for Agent Governance System
|
|
|
|
Provides:
|
|
- Alert notifications
|
|
- Status updates
|
|
- Interactive approval workflows
|
|
- Agent activity feeds
|
|
"""
|
|
|
|
import os
|
|
import json
|
|
from typing import Dict, Any, Optional, List
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from enum import Enum
|
|
|
|
import sys
|
|
sys.path.insert(0, str(__file__).rsplit("/", 2)[0])
|
|
from common.base import BaseIntegration, IntegrationConfig, IntegrationEvent
|
|
|
|
|
|
class AlertSeverity(Enum):
|
|
INFO = "info"
|
|
WARNING = "warning"
|
|
ERROR = "error"
|
|
CRITICAL = "critical"
|
|
|
|
|
|
@dataclass
|
|
class SlackMessage:
|
|
"""Represents a Slack message"""
|
|
channel: str
|
|
text: str
|
|
blocks: Optional[List[Dict]] = None
|
|
attachments: Optional[List[Dict]] = None
|
|
thread_ts: Optional[str] = None
|
|
|
|
|
|
class SlackIntegration(BaseIntegration):
|
|
"""
|
|
Slack integration for agent governance.
|
|
|
|
Capabilities:
|
|
- Send alerts for violations
|
|
- Post status updates
|
|
- Request human approval
|
|
- Notify on promotions/revocations
|
|
"""
|
|
|
|
def __init__(self, webhook_url: str = None, bot_token: str = None):
|
|
config = IntegrationConfig(
|
|
name="slack",
|
|
enabled=webhook_url is not None or bot_token is not None,
|
|
api_key=bot_token or os.environ.get("SLACK_BOT_TOKEN"),
|
|
api_url=webhook_url or os.environ.get("SLACK_WEBHOOK_URL"),
|
|
extra={
|
|
"default_channel": os.environ.get("SLACK_DEFAULT_CHANNEL", "#agent-governance"),
|
|
"alert_channel": os.environ.get("SLACK_ALERT_CHANNEL", "#alerts")
|
|
}
|
|
)
|
|
super().__init__(config)
|
|
|
|
def test_connection(self) -> bool:
|
|
"""Test Slack connection"""
|
|
if self._dry_run:
|
|
self._audit("test_connection", True, {"dry_run": True})
|
|
return True
|
|
|
|
if not self.config.api_url and not self.config.api_key:
|
|
self._audit("test_connection", False, {"error": "no_credentials"})
|
|
return False
|
|
|
|
self._audit("test_connection", True)
|
|
return True
|
|
|
|
def send_event(self, event: IntegrationEvent) -> bool:
|
|
"""Route event to appropriate Slack notification"""
|
|
handlers = {
|
|
"plan_created": self._notify_plan,
|
|
"execution_started": self._notify_execution_start,
|
|
"execution_complete": self._notify_execution_complete,
|
|
"violation_detected": self._alert_violation,
|
|
"promotion_requested": self._notify_promotion,
|
|
"promotion_approved": self._notify_promotion_approved,
|
|
"agent_revoked": self._alert_revocation,
|
|
"approval_required": self._request_approval,
|
|
"heartbeat": self._update_status,
|
|
}
|
|
|
|
handler = handlers.get(event.event_type)
|
|
if handler:
|
|
return handler(event)
|
|
|
|
# Default: post as generic message
|
|
return self._post_generic(event)
|
|
|
|
def _notify_plan(self, event: IntegrationEvent) -> bool:
|
|
"""Notify about a new plan"""
|
|
plan = event.data.get("plan", {})
|
|
agent_id = event.source
|
|
|
|
message = self._build_message(
|
|
channel=self.config.extra["default_channel"],
|
|
text=f"New plan created by agent `{agent_id}`",
|
|
blocks=[
|
|
self._header_block(f":clipboard: Plan Created"),
|
|
self._section_block(f"*Agent:* `{agent_id}`\n*Title:* {plan.get('title', 'Untitled')}"),
|
|
self._section_block(f"*Objective:*\n{plan.get('objective', 'N/A')}"),
|
|
self._context_block(f"Confidence: {plan.get('confidence', 'N/A')} | Tier: {plan.get('tier_required', 'N/A')}")
|
|
]
|
|
)
|
|
|
|
return self._send_message(message)
|
|
|
|
def _notify_execution_start(self, event: IntegrationEvent) -> bool:
|
|
"""Notify that execution has started"""
|
|
agent_id = event.source
|
|
task = event.data.get("task", {})
|
|
|
|
message = self._build_message(
|
|
channel=self.config.extra["default_channel"],
|
|
text=f"Agent `{agent_id}` started execution",
|
|
blocks=[
|
|
self._header_block(":rocket: Execution Started"),
|
|
self._section_block(f"*Agent:* `{agent_id}`\n*Task:* {task.get('title', 'N/A')}"),
|
|
]
|
|
)
|
|
|
|
return self._send_message(message)
|
|
|
|
def _notify_execution_complete(self, event: IntegrationEvent) -> bool:
|
|
"""Notify that execution completed"""
|
|
result = event.data.get("result", {})
|
|
agent_id = event.source
|
|
success = result.get("success", False)
|
|
|
|
emoji = ":white_check_mark:" if success else ":x:"
|
|
status = "Success" if success else "Failed"
|
|
|
|
message = self._build_message(
|
|
channel=self.config.extra["default_channel"],
|
|
text=f"Agent `{agent_id}` execution {status.lower()}",
|
|
blocks=[
|
|
self._header_block(f"{emoji} Execution {status}"),
|
|
self._section_block(f"*Agent:* `{agent_id}`"),
|
|
self._section_block(f"*Duration:* {result.get('duration', 'N/A')}s"),
|
|
]
|
|
)
|
|
|
|
return self._send_message(message)
|
|
|
|
def _alert_violation(self, event: IntegrationEvent) -> bool:
|
|
"""Alert about a governance violation"""
|
|
violation = event.data.get("violation", {})
|
|
agent_id = event.source
|
|
severity = violation.get("severity", "medium")
|
|
|
|
emoji_map = {
|
|
"low": ":warning:",
|
|
"medium": ":warning:",
|
|
"high": ":rotating_light:",
|
|
"critical": ":fire:"
|
|
}
|
|
|
|
message = self._build_message(
|
|
channel=self.config.extra["alert_channel"],
|
|
text=f"Governance violation by agent `{agent_id}`",
|
|
blocks=[
|
|
self._header_block(f"{emoji_map.get(severity, ':warning:')} Violation Detected"),
|
|
self._section_block(
|
|
f"*Agent:* `{agent_id}`\n"
|
|
f"*Type:* {violation.get('type', 'Unknown')}\n"
|
|
f"*Severity:* {severity.upper()}"
|
|
),
|
|
self._section_block(f"*Details:*\n{violation.get('description', 'No details')}"),
|
|
],
|
|
attachments=[{
|
|
"color": self._severity_color(severity),
|
|
"text": f"Evidence: {json.dumps(violation.get('evidence', {}))[:500]}"
|
|
}]
|
|
)
|
|
|
|
return self._send_message(message)
|
|
|
|
def _notify_promotion(self, event: IntegrationEvent) -> bool:
|
|
"""Notify about a promotion request"""
|
|
promotion = event.data.get("promotion", {})
|
|
agent_id = event.source
|
|
|
|
message = self._build_message(
|
|
channel=self.config.extra["default_channel"],
|
|
text=f"Promotion requested for agent `{agent_id}`",
|
|
blocks=[
|
|
self._header_block(":arrow_up: Promotion Requested"),
|
|
self._section_block(
|
|
f"*Agent:* `{agent_id}`\n"
|
|
f"*From Tier:* {promotion.get('from_tier')}\n"
|
|
f"*To Tier:* {promotion.get('to_tier')}"
|
|
),
|
|
self._section_block(
|
|
f"*Metrics:*\n"
|
|
f"- Compliant Runs: {promotion.get('compliant_runs', 0)}\n"
|
|
f"- Consecutive: {promotion.get('consecutive_compliant', 0)}"
|
|
),
|
|
]
|
|
)
|
|
|
|
return self._send_message(message)
|
|
|
|
def _notify_promotion_approved(self, event: IntegrationEvent) -> bool:
|
|
"""Notify that a promotion was approved"""
|
|
promotion = event.data.get("promotion", {})
|
|
agent_id = event.source
|
|
|
|
message = self._build_message(
|
|
channel=self.config.extra["default_channel"],
|
|
text=f"Agent `{agent_id}` promoted!",
|
|
blocks=[
|
|
self._header_block(":tada: Promotion Approved"),
|
|
self._section_block(
|
|
f"*Agent:* `{agent_id}`\n"
|
|
f"*New Tier:* {promotion.get('to_tier')}\n"
|
|
f"*Approved By:* {promotion.get('approved_by', 'System')}"
|
|
),
|
|
]
|
|
)
|
|
|
|
return self._send_message(message)
|
|
|
|
def _alert_revocation(self, event: IntegrationEvent) -> bool:
|
|
"""Alert about agent revocation"""
|
|
revocation = event.data.get("revocation", {})
|
|
agent_id = event.source
|
|
|
|
message = self._build_message(
|
|
channel=self.config.extra["alert_channel"],
|
|
text=f"Agent `{agent_id}` has been revoked!",
|
|
blocks=[
|
|
self._header_block(":no_entry: Agent Revoked"),
|
|
self._section_block(
|
|
f"*Agent:* `{agent_id}`\n"
|
|
f"*Reason:* {revocation.get('reason', 'Unknown')}"
|
|
),
|
|
self._section_block(f"*Details:*\n{revocation.get('description', 'No details')}"),
|
|
],
|
|
attachments=[{
|
|
"color": "danger",
|
|
"text": "This agent's token has been revoked and it can no longer execute actions."
|
|
}]
|
|
)
|
|
|
|
return self._send_message(message)
|
|
|
|
def _request_approval(self, event: IntegrationEvent) -> bool:
|
|
"""Request human approval"""
|
|
request = event.data.get("request", {})
|
|
agent_id = event.source
|
|
|
|
message = self._build_message(
|
|
channel=self.config.extra["default_channel"],
|
|
text=f"Approval required for agent `{agent_id}`",
|
|
blocks=[
|
|
self._header_block(":raising_hand: Approval Required"),
|
|
self._section_block(
|
|
f"*Agent:* `{agent_id}`\n"
|
|
f"*Action:* {request.get('action', 'Unknown')}\n"
|
|
f"*Target:* {request.get('target', 'N/A')}"
|
|
),
|
|
self._section_block(f"*Details:*\n{request.get('details', 'No details')}"),
|
|
{
|
|
"type": "actions",
|
|
"elements": [
|
|
{
|
|
"type": "button",
|
|
"text": {"type": "plain_text", "text": "Approve"},
|
|
"style": "primary",
|
|
"action_id": f"approve_{event.data.get('request_id', 'unknown')}"
|
|
},
|
|
{
|
|
"type": "button",
|
|
"text": {"type": "plain_text", "text": "Deny"},
|
|
"style": "danger",
|
|
"action_id": f"deny_{event.data.get('request_id', 'unknown')}"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
)
|
|
|
|
return self._send_message(message)
|
|
|
|
def _update_status(self, event: IntegrationEvent) -> bool:
|
|
"""Update agent status (usually silent)"""
|
|
# Status updates typically don't need notifications
|
|
self._audit("heartbeat", True, {"agent": event.source})
|
|
return True
|
|
|
|
def _post_generic(self, event: IntegrationEvent) -> bool:
|
|
"""Post a generic event notification"""
|
|
message = self._build_message(
|
|
channel=self.config.extra["default_channel"],
|
|
text=f"Agent event: {event.event_type}",
|
|
blocks=[
|
|
self._header_block(f":robot_face: {event.event_type}"),
|
|
self._section_block(f"*Source:* `{event.source}`"),
|
|
self._section_block(f"```{json.dumps(event.data, indent=2)[:1000]}```")
|
|
]
|
|
)
|
|
|
|
return self._send_message(message)
|
|
|
|
# === Message Building Helpers ===
|
|
|
|
def _build_message(self, channel: str, text: str, blocks: List[Dict] = None,
|
|
attachments: List[Dict] = None) -> SlackMessage:
|
|
"""Build a Slack message"""
|
|
return SlackMessage(
|
|
channel=channel,
|
|
text=text,
|
|
blocks=blocks,
|
|
attachments=attachments
|
|
)
|
|
|
|
def _header_block(self, text: str) -> Dict:
|
|
"""Create a header block"""
|
|
return {
|
|
"type": "header",
|
|
"text": {"type": "plain_text", "text": text}
|
|
}
|
|
|
|
def _section_block(self, text: str) -> Dict:
|
|
"""Create a section block"""
|
|
return {
|
|
"type": "section",
|
|
"text": {"type": "mrkdwn", "text": text}
|
|
}
|
|
|
|
def _context_block(self, text: str) -> Dict:
|
|
"""Create a context block"""
|
|
return {
|
|
"type": "context",
|
|
"elements": [{"type": "mrkdwn", "text": text}]
|
|
}
|
|
|
|
def _severity_color(self, severity: str) -> str:
|
|
"""Get color for severity level"""
|
|
colors = {
|
|
"low": "#2eb886", # green
|
|
"medium": "#daa038", # yellow
|
|
"high": "#cc4444", # orange-red
|
|
"critical": "#ff0000" # red
|
|
}
|
|
return colors.get(severity, "#808080")
|
|
|
|
def _send_message(self, message: SlackMessage) -> bool:
|
|
"""Send a message to Slack"""
|
|
if self._dry_run:
|
|
self._audit("send_message", True, {
|
|
"dry_run": True,
|
|
"channel": message.channel,
|
|
"text": message.text[:100]
|
|
})
|
|
return True
|
|
|
|
if not self.config.api_url and not self.config.api_key:
|
|
return False
|
|
|
|
# Would send via webhook or API
|
|
self._audit("send_message", True, {
|
|
"channel": message.channel,
|
|
"has_blocks": message.blocks is not None
|
|
})
|
|
return True
|
|
|
|
# === Stub API Methods ===
|
|
|
|
def post_webhook(self, payload: Dict) -> bool:
|
|
"""Post to webhook URL (stub)"""
|
|
if self._dry_run or not self.config.api_url:
|
|
return True
|
|
|
|
# Would POST to webhook URL
|
|
self._audit("webhook_post", True)
|
|
return True
|
|
|
|
def api_call(self, method: str, **kwargs) -> Optional[Dict]:
|
|
"""Make Slack API call (stub)"""
|
|
if self._dry_run or not self.config.api_key:
|
|
return None
|
|
|
|
# Would call Slack API
|
|
self._audit("api_call", True, {"method": method})
|
|
return None
|