diff options
Diffstat (limited to 'makima/src/server/handlers/mesh_red_team.rs')
| -rw-r--r-- | makima/src/server/handlers/mesh_red_team.rs | 497 |
1 files changed, 497 insertions, 0 deletions
diff --git a/makima/src/server/handlers/mesh_red_team.rs b/makima/src/server/handlers/mesh_red_team.rs new file mode 100644 index 0000000..c5af60e --- /dev/null +++ b/makima/src/server/handlers/mesh_red_team.rs @@ -0,0 +1,497 @@ +//! HTTP handlers for red team mesh operations. +//! +//! These endpoints are used by red team tasks (via the makima CLI) to notify +//! supervisors of potential issues and query their own status. + +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + response::IntoResponse, + Json, +}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::db::repository; +use crate::server::handlers::mesh::{extract_auth, AuthSource}; +use crate::server::messages::ApiError; +use crate::server::state::{DaemonCommand, SharedState}; + +// ============================================================================= +// Request/Response Types +// ============================================================================= + +/// Severity level for red team notifications. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum RedTeamSeverity { + /// Informational notice - minor issue or suggestion + Info, + /// Warning - potential problem that should be reviewed + Warning, + /// Critical - serious issue requiring immediate attention + Critical, +} + +impl Default for RedTeamSeverity { + fn default() -> Self { + Self::Warning + } +} + +impl std::fmt::Display for RedTeamSeverity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Info => write!(f, "INFO"), + Self::Warning => write!(f, "WARNING"), + Self::Critical => write!(f, "CRITICAL"), + } + } +} + +/// Request to notify the supervisor of a potential issue. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct RedTeamNotifyRequest { + /// The issue description/message to send to the supervisor + pub message: String, + /// Severity level of the issue + #[serde(default)] + pub severity: RedTeamSeverity, + /// ID of the task being reviewed (optional - if not provided, assumes general contract concern) + pub related_task_id: Option<Uuid>, + /// File path related to the issue (optional) + pub file_path: Option<String>, + /// Additional context about the issue + pub context: Option<String>, +} + +/// Response from the notify endpoint. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct RedTeamNotifyResponse { + /// Unique ID for this notification + pub notification_id: Uuid, + /// Whether the notification was successfully delivered to the supervisor + pub delivered: bool, + /// The supervisor task ID that received the notification + pub supervisor_task_id: Option<Uuid>, +} + +/// Response from the status endpoint. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct RedTeamStatusResponse { + /// Contract ID being monitored + pub contract_id: Uuid, + /// Red team task ID + pub red_team_task_id: Uuid, + /// Current task status + pub status: String, + /// Number of notifications sent so far + pub notifications_sent: i64, +} + +/// Red team notification record stored in database. +#[derive(Debug, Clone, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct RedTeamNotification { + pub id: Uuid, + pub red_team_task_id: Uuid, + pub contract_id: Uuid, + pub message: String, + pub severity: String, + pub related_task_id: Option<Uuid>, + pub file_path: Option<String>, + pub context: Option<String>, + pub delivered: bool, + pub created_at: chrono::DateTime<chrono::Utc>, +} + +// ============================================================================= +// Helper Functions +// ============================================================================= + +/// Verify the request comes from a red team task and extract ownership info. +/// +/// Returns (task_id, owner_id, contract_id) on success. +async fn verify_red_team_auth( + state: &SharedState, + headers: &HeaderMap, +) -> Result<(Uuid, Uuid, Uuid), (StatusCode, Json<ApiError>)> { + let auth = extract_auth(state, headers); + + let task_id = match auth { + AuthSource::ToolKey(task_id) => task_id, + _ => { + return Err(( + StatusCode::UNAUTHORIZED, + Json(ApiError::new( + "UNAUTHORIZED", + "Red team endpoints require tool key auth", + )), + )); + } + }; + + // Get the task to verify it's a red team task and get owner_id + let pool = state.db_pool.as_ref().ok_or_else(|| { + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + })?; + + let task = repository::get_task(pool, task_id) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to get red team task"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", "Failed to verify red team task")), + ) + })? + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Task not found")), + ) + })?; + + // Verify task is a red team task + // NOTE: This requires the is_red_team field to be added to the Task struct. + // For now, we check if the task name contains "red-team" or "red_team" as a fallback. + let is_red_team = task.name.to_lowercase().contains("red-team") + || task.name.to_lowercase().contains("red_team") + || task.name.to_lowercase().contains("redteam"); + + if !is_red_team { + return Err(( + StatusCode::FORBIDDEN, + Json(ApiError::new( + "NOT_RED_TEAM", + "Only red team tasks can use these endpoints", + )), + )); + } + + // Red team tasks must be associated with a contract + let contract_id = task.contract_id.ok_or_else(|| { + ( + StatusCode::BAD_REQUEST, + Json(ApiError::new( + "NO_CONTRACT", + "Red team task must be associated with a contract", + )), + ) + })?; + + Ok((task_id, task.owner_id, contract_id)) +} + +/// Format an alert message for the supervisor. +/// +/// Creates a formatted alert with clear visual markers to grab attention. +fn format_alert_message( + severity: &RedTeamSeverity, + message: &str, + related_task_id: Option<Uuid>, + file_path: Option<&str>, + context: Option<&str>, +) -> String { + let severity_marker = match severity { + RedTeamSeverity::Info => "âšī¸", + RedTeamSeverity::Warning => "â ī¸", + RedTeamSeverity::Critical => "đ¨", + }; + + let border = match severity { + RedTeamSeverity::Info => "â".repeat(60), + RedTeamSeverity::Warning => "â".repeat(60), + RedTeamSeverity::Critical => "â".repeat(60), + }; + + let mut alert = format!( + r#" +{} +{} [RED TEAM ALERT] - {} +{} + +Issue: {} +"#, + border, severity_marker, severity, border, message + ); + + if let Some(task_id) = related_task_id { + alert.push_str(&format!("\nRelated Task: {}\n", task_id)); + } + + if let Some(path) = file_path { + alert.push_str(&format!("File: {}\n", path)); + } + + if let Some(ctx) = context { + alert.push_str(&format!("\nContext:\n{}\n", ctx)); + } + + // Add action suggestions based on severity + let actions = match severity { + RedTeamSeverity::Info => { + "Suggested Actions:\n- Review when convenient\n- Consider if changes are needed" + } + RedTeamSeverity::Warning => { + "Suggested Actions:\n- Review the flagged item soon\n- Check if this deviates from the contract\n- Consider pausing related work until reviewed" + } + RedTeamSeverity::Critical => { + "Suggested Actions:\n- STOP related work immediately\n- Review the flagged item urgently\n- Verify compliance with contract requirements\n- Consider reverting recent changes if necessary" + } + }; + + alert.push_str(&format!("\n{}\n{}\n", actions, border)); + + alert +} + +// ============================================================================= +// Handlers +// ============================================================================= + +/// Notify the supervisor of a potential issue. +/// +/// POST /api/v1/mesh/red-team/notify +/// +/// This endpoint allows red team tasks to alert supervisors about issues they've +/// identified during code review. The notification is sent as a message to the +/// supervisor task. +#[utoipa::path( + post, + path = "/api/v1/mesh/red-team/notify", + request_body = RedTeamNotifyRequest, + responses( + (status = 200, description = "Notification sent", body = RedTeamNotifyResponse), + (status = 401, description = "Unauthorized - tool key required"), + (status = 403, description = "Forbidden - not a red team task"), + (status = 404, description = "Task not found"), + (status = 503, description = "Database not available"), + (status = 500, description = "Internal server error"), + ), + security( + ("tool_key" = []) + ), + tag = "Mesh Red Team" +)] +pub async fn notify_supervisor( + State(state): State<SharedState>, + headers: HeaderMap, + Json(request): Json<RedTeamNotifyRequest>, +) -> impl IntoResponse { + let (red_team_task_id, owner_id, contract_id) = + match verify_red_team_auth(&state, &headers).await { + Ok(ids) => ids, + Err(e) => return e.into_response(), + }; + + let pool = state.db_pool.as_ref().unwrap(); + + // Generate notification ID + let notification_id = Uuid::new_v4(); + + // Get the contract to find the supervisor task + let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { + Ok(Some(c)) => c, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!(error = %e, "Failed to get contract"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", "Failed to get contract")), + ) + .into_response(); + } + }; + + let supervisor_task_id = contract.supervisor_task_id; + + // Format the alert message + let alert_message = format_alert_message( + &request.severity, + &request.message, + request.related_task_id, + request.file_path.as_deref(), + request.context.as_deref(), + ); + + // Record the notification in the database as a history event + let event_data = serde_json::json!({ + "notification_id": notification_id.to_string(), + "red_team_task_id": red_team_task_id.to_string(), + "severity": request.severity.to_string(), + "message": request.message, + "related_task_id": request.related_task_id.map(|id| id.to_string()), + "file_path": request.file_path, + "context": request.context, + }); + + let _ = repository::record_history_event( + pool, + owner_id, + Some(contract_id), + Some(red_team_task_id), + "red_team_alert", + Some(&request.severity.to_string().to_lowercase()), + Some(&request.message), + event_data, + ) + .await; + + // Try to send the message to the supervisor + let mut delivered = false; + if let Some(sup_task_id) = supervisor_task_id { + // Get the supervisor task to find its daemon + if let Ok(Some(supervisor_task)) = repository::get_task(pool, sup_task_id).await { + if let Some(daemon_id) = supervisor_task.daemon_id { + // Send the alert message to the supervisor + let cmd = DaemonCommand::SendMessage { + task_id: sup_task_id, + message: alert_message.clone(), + }; + + if let Err(e) = state.send_daemon_command(daemon_id, cmd).await { + tracing::warn!( + error = %e, + supervisor_task_id = %sup_task_id, + daemon_id = %daemon_id, + "Failed to send red team alert to supervisor" + ); + } else { + delivered = true; + tracing::info!( + notification_id = %notification_id, + red_team_task_id = %red_team_task_id, + supervisor_task_id = %sup_task_id, + severity = %request.severity, + "Red team alert delivered to supervisor" + ); + } + } else { + tracing::warn!( + supervisor_task_id = %sup_task_id, + "Supervisor task has no assigned daemon - alert not delivered" + ); + } + } + } else { + tracing::warn!( + contract_id = %contract_id, + "Contract has no supervisor task - alert not delivered" + ); + } + + ( + StatusCode::OK, + Json(RedTeamNotifyResponse { + notification_id, + delivered, + supervisor_task_id, + }), + ) + .into_response() +} + +/// Get the status of the red team task. +/// +/// GET /api/v1/mesh/red-team/status +/// +/// Returns information about the current red team task including the contract +/// being monitored and notification statistics. +#[utoipa::path( + get, + path = "/api/v1/mesh/red-team/status", + responses( + (status = 200, description = "Red team status", body = RedTeamStatusResponse), + (status = 401, description = "Unauthorized - tool key required"), + (status = 403, description = "Forbidden - not a red team task"), + (status = 404, description = "Task not found"), + (status = 503, description = "Database not available"), + (status = 500, description = "Internal server error"), + ), + security( + ("tool_key" = []) + ), + tag = "Mesh Red Team" +)] +pub async fn get_status( + State(state): State<SharedState>, + headers: HeaderMap, +) -> impl IntoResponse { + let (red_team_task_id, owner_id, contract_id) = + match verify_red_team_auth(&state, &headers).await { + Ok(ids) => ids, + Err(e) => return e.into_response(), + }; + + let pool = state.db_pool.as_ref().unwrap(); + + // Get the red team task status + let task = match repository::get_task(pool, red_team_task_id).await { + Ok(Some(t)) => t, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Red team task not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!(error = %e, "Failed to get red team task"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", "Failed to get task")), + ) + .into_response(); + } + }; + + // Count notifications sent by this red team task + // Query history_events for red_team_alert events from this task + let notifications_sent = match sqlx::query_scalar::<_, i64>( + r#" + SELECT COUNT(*) + FROM history_events + WHERE owner_id = $1 + AND contract_id = $2 + AND task_id = $3 + AND event_type = 'red_team_alert' + "#, + ) + .bind(owner_id) + .bind(contract_id) + .bind(red_team_task_id) + .fetch_one(pool) + .await + { + Ok(count) => count, + Err(e) => { + tracing::warn!(error = %e, "Failed to count red team notifications"); + 0 + } + }; + + ( + StatusCode::OK, + Json(RedTeamStatusResponse { + contract_id, + red_team_task_id, + status: task.status, + notifications_sent, + }), + ) + .into_response() +} |
