true, 'email_alerts' => true, 'alert_emails' => array(), 'digest_emails' => array(), 'mcp_disabled' => false, 'mcp_disabled_reason' => '', 'mcp_disabled_at' => '', 'daily_digest' => false, 'rules' => array( 'velocity' => array( 'enabled' => true, 'threshold' => 50, 'window_minutes' => 5, 'severity' => 'critical', 'auto_disable' => false, ), 'destructive_tool' => array( 'enabled' => true, 'tools' => array( 'wp_delete_post', 'wp_delete_page', 'wp_delete_user', 'wp_delete_comment', 'wp_delete_media_item', 'clear-old-logs', ), 'severity' => 'critical', 'auto_disable' => false, ), 'error_storm' => array( 'enabled' => true, 'threshold' => 10, 'window_minutes' => 15, 'severity' => 'warning', 'auto_disable' => false, ), ), ); /** * Initialize security hooks. */ public static function init() { // Register daily digest cron. if ( ! wp_next_scheduled( 'alfmcp_daily_digest' ) ) { wp_schedule_event( time(), 'daily', 'alfmcp_daily_digest' ); } add_action( 'alfmcp_daily_digest', array( __CLASS__, 'send_daily_digest' ) ); // Kill switch handler for email links. add_action( 'admin_post_alfmcp_kill_switch', array( __CLASS__, 'handle_kill_switch_action' ) ); } /** * Evaluate security rules after a request is logged. * * @param int $log_id The log entry ID. * @param string|null $session_id MCP session ID. * @param string|null $tool_name Tool name. * @param string $tool_status Tool execution status. * @param bool $was_blocked Whether the request was blocked by the kill switch. */ public static function evaluate( $log_id, $session_id, $tool_name, $tool_status, $was_blocked = false ) { $settings = self::get_settings(); if ( ! $settings['enabled'] ) { return; } // Session-based rules require a session ID. if ( ! empty( $session_id ) ) { if ( $settings['rules']['velocity']['enabled'] ) { self::check_velocity( $session_id, $settings['rules']['velocity'], $settings, $was_blocked ); } if ( $settings['rules']['error_storm']['enabled'] && 'error' === $tool_status ) { self::check_error_storm( $session_id, $settings['rules']['error_storm'], $settings, $was_blocked ); } } // Destructive tool alert doesn't require session ID. if ( $settings['rules']['destructive_tool']['enabled'] && $tool_name ) { self::check_destructive_tool( $tool_name, $session_id, $settings['rules']['destructive_tool'], $settings, $was_blocked ); } } /** * Check velocity rule: too many requests in a time window. * * @param string $session_id MCP session ID. * @param array $rule Rule configuration. * @param array $settings Full settings. * @param bool $was_blocked Whether the triggering request was blocked. */ private static function check_velocity( $session_id, $rule, $settings, $was_blocked = false ) { global $wpdb; $table = ALFMCP_DB::get_table_name(); $cutoff = gmdate( 'Y-m-d H:i:s', strtotime( '-' . absint( $rule['window_minutes'] ) . ' minutes', strtotime( current_time( 'mysql' ) ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Real-time count for velocity detection; caching would defeat the rolling-window check. $count = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE mcp_session_id = %s AND created_at >= %s', $table, $session_id, $cutoff ) ); if ( $count >= absint( $rule['threshold'] ) ) { self::fire_alert( 'velocity', $session_id, $rule['severity'], $settings, array( 'count' => $count, 'threshold' => $rule['threshold'], 'window_minutes' => $rule['window_minutes'], ), $rule, $was_blocked ); } } /** * Check destructive tool rule. * * @param string $tool_name Tool name. * @param string|null $session_id MCP session ID. * @param array $rule Rule configuration. * @param array $settings Full settings. * @param bool $was_blocked Whether the triggering request was blocked. */ private static function check_destructive_tool( $tool_name, $session_id, $rule, $settings, $was_blocked = false ) { $tools = isset( $rule['tools'] ) ? $rule['tools'] : array(); if ( in_array( $tool_name, $tools, true ) ) { self::fire_alert( 'destructive_tool', $session_id, $rule['severity'], $settings, array( 'tool_name' => $tool_name, ), $rule, $was_blocked ); } } /** * Check error storm rule: too many errors in a time window. * * @param string $session_id MCP session ID. * @param array $rule Rule configuration. * @param array $settings Full settings. * @param bool $was_blocked Whether the triggering request was blocked. */ private static function check_error_storm( $session_id, $rule, $settings, $was_blocked = false ) { global $wpdb; $table = ALFMCP_DB::get_table_name(); $cutoff = gmdate( 'Y-m-d H:i:s', strtotime( '-' . absint( $rule['window_minutes'] ) . ' minutes', strtotime( current_time( 'mysql' ) ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Real-time count for error-storm detection; caching would defeat the rolling-window check. $count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE tool_status = 'error' AND mcp_session_id = %s AND created_at >= %s", $table, $session_id, $cutoff ) ); if ( $count >= absint( $rule['threshold'] ) ) { self::fire_alert( 'error_storm', $session_id, $rule['severity'], $settings, array( 'count' => $count, 'threshold' => $rule['threshold'], 'window_minutes' => $rule['window_minutes'], ), $rule, $was_blocked ); } } /** * Fire a security alert: log event, optionally send email, optionally disable MCP. * * @param string $rule_name Rule that fired. * @param string|null $session_id MCP session ID. * @param string $severity 'warning' or 'critical'. * @param array $settings Full settings. * @param array $details Event details. * @param array $rule Rule configuration. * @param bool $was_blocked Whether the triggering request was blocked by the kill switch. */ private static function fire_alert( $rule_name, $session_id, $severity, $settings, $details, $rule = array(), $was_blocked = false ) { $details['request_status'] = $was_blocked ? 'blocked' : 'executed'; // Always log the event. ALFMCP_DB::insert_security_event( array( 'rule_name' => sanitize_text_field( $rule_name ), 'session_id' => $session_id ? sanitize_text_field( $session_id ) : null, 'details' => wp_json_encode( $details ), 'severity' => in_array( $severity, array( 'warning', 'critical' ), true ) ? $severity : 'warning', ) ); // Auto-disable MCP if configured for this rule. if ( ! empty( $rule['auto_disable'] ) && ! $settings['mcp_disabled'] ) { $settings['mcp_disabled'] = true; $settings['mcp_disabled_reason'] = self::build_disable_reason( $rule_name, $details ); $settings['mcp_disabled_at'] = current_time( 'mysql' ); self::save_settings( $settings ); ALFMCP_DB::insert_security_event( array( 'rule_name' => 'mcp_auto_disabled', 'session_id' => $session_id ? sanitize_text_field( $session_id ) : null, 'details' => wp_json_encode( array( 'triggered_by' => $rule_name, 'request_status' => 'executed', 'note' => 'The triggering request was executed. All subsequent MCP requests are now blocked.', ) ), 'severity' => 'critical', ) ); } // Check cooldown before sending email. if ( ! $settings['email_alerts'] ) { return; } $cooldown_key = 'alfmcp_cd_' . $rule_name . '_' . md5( (string) $session_id ); if ( get_transient( $cooldown_key ) ) { return; } self::send_alert_email( $rule_name, $session_id, $severity, $details, $settings ); set_transient( $cooldown_key, true, 15 * MINUTE_IN_SECONDS ); } /** * Send an alert email. * * @param string $rule_name Rule name. * @param string|null $session_id Session ID. * @param string $severity Severity level. * @param array $details Event details. * @param array $settings Full settings. */ private static function send_alert_email( $rule_name, $session_id, $severity, $details, $settings ) { $recipients = self::get_recipients( 'alert', $settings ); if ( empty( $recipients ) ) { return; } $rule_labels = array( 'velocity' => 'Velocity Alert', 'destructive_tool' => 'Destructive Tool Detected', 'error_storm' => 'Error Storm Detected', ); $label = isset( $rule_labels[ $rule_name ] ) ? $rule_labels[ $rule_name ] : $rule_name; $subject = '[MCP Logs] Security Alert: ' . $label; // Build detail rows. $detail_rows = ''; if ( 'destructive_tool' === $rule_name && isset( $details['tool_name'] ) ) { $detail_rows .= self::email_detail_row( 'Tool', esc_html( $details['tool_name'] ) ); } elseif ( isset( $details['count'] ) ) { $detail_rows .= self::email_detail_row( 'Count', esc_html( $details['count'] ) . ' (threshold: ' . esc_html( $details['threshold'] ) . ')' ); $detail_rows .= self::email_detail_row( 'Window', esc_html( $details['window_minutes'] ) . ' minutes' ); } $detail_rows .= self::email_detail_row( 'Severity', esc_html( $severity ) ); if ( ! empty( $details['request_status'] ) ) { $status_label = 'executed' === $details['request_status'] ? 'Executed — the request was allowed through' : 'Blocked — the request was stopped'; $detail_rows .= self::email_detail_row( 'Request', $status_label ); } if ( $session_id ) { $detail_rows .= self::email_detail_row( 'Session', esc_html( $session_id ) ); } $detail_rows .= self::email_detail_row( 'Time', esc_html( current_time( 'mysql' ) ) ); /** * Filter alert email detail rows HTML. * * @param string $detail_rows HTML table rows. * @param string $rule_name Rule that fired. * @param array $details Event details. */ $detail_rows = apply_filters( 'alfmcp_alert_email_details', $detail_rows, $rule_name, $details ); $kill_url = admin_url( 'admin-post.php?action=alfmcp_kill_switch&_wpnonce=' . wp_create_nonce( 'alfmcp_kill_switch' ) ); $details_url = admin_url( 'tools.php?page=alfmcp-requests#security-events' ); $content = '' . $detail_rows . '
'; $content .= ''; $content .= ''; $content .= ''; $content .= '
Disable MCP AccessView Details
'; /** * Filter the alert email body content before wrapping in the template. * * @param string $content HTML content. * @param string $rule_name Rule name. * @param array $details Event details. */ $content = apply_filters( 'alfmcp_alert_email_content', $content, $rule_name, $details ); $body = self::email_template( 'Security Alert: ' . $label, $content ); self::send_html_email( $recipients, $subject, $body ); } /** * Handle kill switch action from email link. */ public static function handle_kill_switch_action() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'activity-log-for-mcp' ), 403 ); } if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'alfmcp_kill_switch' ) ) { wp_die( esc_html__( 'This link has expired. Please disable MCP access from the Activity Log for MCP settings page.', 'activity-log-for-mcp' ), 403 ); } $settings = self::get_settings(); $settings['mcp_disabled'] = true; $settings['mcp_disabled_reason'] = 'Manually disabled by administrator via email kill switch.'; $settings['mcp_disabled_at'] = current_time( 'mysql' ); self::save_settings( $settings ); wp_safe_redirect( admin_url( 'tools.php?page=alfmcp-requests#settings&mcp_disabled=1' ) ); exit; } /** * Check if MCP access is disabled (kill switch). * * @return bool */ public static function is_mcp_disabled() { $settings = self::get_settings(); return ! empty( $settings['mcp_disabled'] ); } /** * Get the reason MCP access was disabled. * * @return array { reason: string, disabled_at: string } or empty strings if not disabled. */ public static function get_disabled_reason() { $settings = self::get_settings(); return array( 'reason' => ! empty( $settings['mcp_disabled_reason'] ) ? $settings['mcp_disabled_reason'] : '', 'disabled_at' => ! empty( $settings['mcp_disabled_at'] ) ? $settings['mcp_disabled_at'] : '', ); } /** * Build a human-readable disable reason from a rule name and details. * * @param string $rule_name Rule that triggered the disable. * @param array $details Event details. * @return string */ private static function build_disable_reason( $rule_name, $details ) { $labels = array( 'velocity' => 'Velocity limit exceeded', 'destructive_tool' => 'Destructive tool detected', 'error_storm' => 'Error storm detected', ); $label = isset( $labels[ $rule_name ] ) ? $labels[ $rule_name ] : $rule_name; if ( 'destructive_tool' === $rule_name && isset( $details['tool_name'] ) ) { return sprintf( '%s: %s was invoked.', $label, $details['tool_name'] ); } if ( isset( $details['count'], $details['threshold'], $details['window_minutes'] ) ) { return sprintf( '%s: %d events in %d minutes (threshold: %d).', $label, $details['count'], $details['window_minutes'], $details['threshold'] ); } return $label . '.'; } /** * Send daily digest email. */ public static function send_daily_digest() { $settings = self::get_settings(); if ( ! $settings['enabled'] || ! $settings['daily_digest'] ) { return; } $recipients = self::get_recipients( 'digest', $settings ); if ( empty( $recipients ) ) { return; } $yesterday = gmdate( 'Y-m-d', strtotime( '-1 day', strtotime( current_time( 'mysql' ) ) ) ); $today = gmdate( 'Y-m-d', strtotime( current_time( 'mysql' ) ) ); $stats = ALFMCP_DB::get_stats( array( 'date_from' => $yesterday, 'date_to' => $today ) ); $sec_stats = ALFMCP_DB::get_security_stats(); $subject = '[MCP Logs] Daily Digest — ' . $yesterday; $content = ''; $content .= self::email_detail_row( 'Requests', esc_html( $stats['total_requests'] ) . ' across ' . esc_html( $stats['unique_sessions'] ) . ' sessions' ); $content .= self::email_detail_row( 'Security Events', esc_html( $sec_stats['total_alerts'] ) ); if ( ! empty( $stats['by_tool'] ) ) { $top = $stats['by_tool'][0]; $content .= self::email_detail_row( 'Top Tool', esc_html( $top['tool_name'] ) . ' (' . esc_html( $top['count'] ) . ' calls)' ); } $content .= self::email_detail_row( 'Success Rate', esc_html( $stats['success_rate'] ) . '%' ); $content .= '
'; if ( 0 === $sec_stats['total_alerts'] ) { $content .= '

No security events detected.

'; } $content .= '

View Full Report

'; /** * Filter the digest email body content before wrapping in the template. * * @param string $content HTML content. * @param array $stats Request stats. * @param array $sec_stats Security stats. */ $content = apply_filters( 'alfmcp_digest_email_content', $content, $stats, $sec_stats ); $body = self::email_template( 'Daily Digest — ' . $yesterday, $content ); self::send_html_email( $recipients, $subject, $body ); } /** * Send a test alert email. * * @return bool Whether wp_mail succeeded. */ public static function send_test_alert() { $settings = self::get_settings(); $recipients = self::get_recipients( 'alert', $settings ); if ( empty( $recipients ) ) { $recipients = array( get_option( 'admin_email' ) ); } $subject = '[MCP Logs] Test Alert — Email Delivery Verified'; $content = '

This is a test alert from Activity Log for MCP.

'; $content .= '

If you\'re reading this, your email alerts are working correctly.

'; $content .= '

Sent: ' . esc_html( current_time( 'mysql' ) ) . '

'; $body = self::email_template( 'Test Alert', $content ); return self::send_html_email( $recipients, $subject, $body ); } /** * Get email recipients for a given type. * * @param string $type 'alert' or 'digest'. * @param array $settings Security settings. * @return array Email addresses. */ private static function get_recipients( $type, $settings ) { $key = 'alert' === $type ? 'alert_emails' : 'digest_emails'; $emails = isset( $settings[ $key ] ) ? $settings[ $key ] : array(); // Legacy support: migrate string 'alert_email' to array. if ( empty( $emails ) && ! empty( $settings['alert_email'] ) ) { $emails = array( $settings['alert_email'] ); } // Filter out empty values and sanitize. $emails = array_filter( array_map( 'sanitize_email', (array) $emails ) ); // Fall back to admin email if no recipients configured. if ( empty( $emails ) ) { $emails = array( get_option( 'admin_email' ) ); } /** * Filter email recipients. * * @param array $emails Email addresses. * @param string $type 'alert' or 'digest'. */ return apply_filters( 'alfmcp_email_recipients', $emails, $type ); } /** * Send an HTML email. * * @param array $to Recipient email addresses. * @param string $subject Email subject. * @param string $body HTML body. * @return bool */ private static function send_html_email( $to, $subject, $body ) { $headers = array( 'Content-Type: text/html; charset=UTF-8' ); /** * Filter email headers. * * @param array $headers Email headers. * @param string $subject Email subject. */ $headers = apply_filters( 'alfmcp_email_headers', $headers, $subject ); return wp_mail( $to, $subject, $body, $headers ); } /** * Generate a single detail row for email tables. * * @param string $label Row label. * @param string $value Row value (pre-escaped). * @return string HTML table row. */ private static function email_detail_row( $label, $value ) { return '' . esc_html( $label ) . '' . $value . ''; } /** * Wrap content in the standard HTML email template. * * @param string $heading Email heading text. * @param string $content HTML content for the body. * @return string Full HTML email. */ private static function email_template( $heading, $content ) { $site_name = get_bloginfo( 'name' ); $settings_url = admin_url( 'tools.php?page=alfmcp-requests#settings' ); /** * Fires before the email template is assembled. Use to inject * additional content sections. * * @param string $heading Email heading. */ do_action( 'alfmcp_before_email_content', $heading ); $footer = '

You\'re receiving this because security monitoring is enabled in Activity Log for MCP on ' . esc_html( $site_name ) . '.
Manage settings

'; /** * Filter the email footer HTML. * * @param string $footer Footer HTML. * @param string $heading Email heading. */ $footer = apply_filters( 'alfmcp_email_footer', $footer, $heading ); /** * Filter the full email template. Return a complete HTML string * to replace the default template entirely. * * @param string $template Empty string (return non-empty to override). * @param string $heading Email heading. * @param string $content Body content HTML. * @param string $footer Footer HTML. */ $custom = apply_filters( 'alfmcp_email_template', '', $heading, $content, $footer ); if ( ! empty( $custom ) ) { return $custom; } $html = ''; $html .= ''; $html .= ''; $html .= '
'; $html .= ''; // Header. $html .= ''; // Body. $html .= ''; // Footer. $html .= ''; $html .= '
'; $html .= '

' . esc_html( $heading ) . '

'; $html .= '
' . $content . '
' . $footer . '
'; $html .= '
'; $html .= ''; return $html; } /** * Auto-populate destructive tools list by scanning registered MCP tools. * * @return array List of tool names matching destructive patterns. */ public static function auto_populate_destructive_tools() { $patterns = apply_filters( 'alfmcp_destructive_tool_patterns', array( 'delete', 'remove', 'drop' ) ); $tools = ALFMCP_DB::get_unique_tools(); $matches = array(); foreach ( $tools as $tool ) { foreach ( $patterns as $pattern ) { if ( false !== strpos( $tool, $pattern ) ) { $matches[] = $tool; break; } } } return $matches; } /** * Get security settings with defaults. * * @return array */ public static function get_settings() { $saved = get_option( 'alfmcp_security_settings', array() ); return self::recursive_parse_args( $saved, self::$defaults ); } /** * Save security settings. * * @param array $settings Settings to save. */ public static function save_settings( $settings ) { update_option( 'alfmcp_security_settings', $settings ); } /** * Recursive wp_parse_args for nested settings. * * @param array $args User-supplied values. * @param array $defaults Default values. * @return array */ private static function recursive_parse_args( $args, $defaults ) { $result = $defaults; foreach ( $args as $key => $value ) { if ( is_array( $value ) && isset( $defaults[ $key ] ) && is_array( $defaults[ $key ] ) && ! wp_is_numeric_array( $defaults[ $key ] ) ) { $result[ $key ] = self::recursive_parse_args( $value, $defaults[ $key ] ); } else { $result[ $key ] = $value; } } return $result; } }