'2024-11-05', 'capabilities' => [ 'tools' => [ 'listChanged' => false ], ], 'serverInfo' => [ 'name' => 'AICOM', 'version' => defined( 'AICOM_VERSION' ) ? AICOM_VERSION : '0.0.0', ], 'instructions' => implode( "\n", [ 'ALWAYS start with these 3 calls in order:', '1. tools/list → {"jsonrpc":"2.0","method":"tools/list","params":{},"id":1}', '2. aicom.recipes(task:"what you want to do") → get step-by-step guidance scoped to your key', '3. session.open(name:"label", description:"what and why") → required before any write tool', '', 'Errors:', ' NO_ACTIVE_SESSION → do step 3 first', ' TOOL_NOT_FOUND → do step 1; call aicom.recipes for task guidance', ' DENIED_SCOPE → your key lacks the required scope; ask the site admin', ' SOFT_LOCK_ACTIVE → site is read-only; only read/discovery tools work', ' CONFIRM_REQUIRED → add "confirm":true to arguments', ] ), 'request_id' => $request_id, ], $rpc_id ); } if ( $rpc_method === 'notifications/initialized' ) { // Notification — no response body. Some clients still expect a 200. return self::jsonrpc_wrap( [ 'request_id' => $request_id ], $rpc_id ); } if ( $rpc_method === 'ping' ) { return self::jsonrpc_wrap( [ 'request_id' => $request_id ], $rpc_id ); } if ( $tool_name === '' ) { // Try to give a more specific hint based on what the model actually sent if ( $rpc_method === '' && ! isset( $payload['tool'] ) ) { $hint = 'Request has no "method" and no "tool" field. To call a tool: {"jsonrpc":"2.0","method":"tools/call","params":{"name":"TOOL_NAME","arguments":{}},"id":1}. To list tools: {"jsonrpc":"2.0","method":"tools/list","params":{},"id":1}'; } else { $hint = 'Missing tool name. Correct format: {"jsonrpc":"2.0","method":"tools/call","params":{"name":"TOOL_NAME","arguments":{}},"id":1}'; } return self::early_error( 'parse_error', $hint, 400, $request_id, $remote_ip, '', 'unknown', $start, 0, '', $rpc_id ); } // Redirect: model called tools/call with name="tools" or "list_tools" instead of using the method. // "tools/list" is excluded — it goes through the normal pipeline to handle_tools_list which returns full schemas. // Returns compact list (name + class only) to avoid flooding small context windows. if ( in_array( $tool_name, [ 'tools', 'list_tools' ], true ) ) { $full = AICOM_Tool_Registry::to_mcp_list( AICOM_Module_Detector::get_active_modules() ); $compact = array_map( fn( $t ) => [ 'name' => $t['name'], 'class' => $t['class'] ?? '' ], $full ); return self::jsonrpc_wrap( [ 'tools' => $compact, 'total' => count( $compact ), 'request_id' => $request_id, '_note' => 'Compact list (name+class only). Use {"method":"tools/list"} for full schemas with inputSchema, or call aicom.recipes for task guidance.', ], $rpc_id ); } // Detect: model used "method":"tools/wp.posts.create" instead of tools/call + params.name if ( strpos( $rpc_method, 'tools/' ) === 0 && $rpc_method !== 'tools/list' && $rpc_method !== 'tools/call' ) { $guessed = substr( $rpc_method, strlen( 'tools/' ) ); return self::early_error( 'parse_error', 'Use method:"tools/call" with params.name, not method:"' . $rpc_method . '". Correct: {"jsonrpc":"2.0","method":"tools/call","params":{"name":"' . esc_html( $guessed ) . '","arguments":{}},"id":1}', 400, $request_id, $remote_ip, '', 'unknown', $start, 0, '', $rpc_id ); } // Resolve metadata early (needed for lock check) $tool_meta = AICOM_Tool_Registry::get( $tool_name ); $tool_class = $tool_meta['class'] ?? 'unknown'; $tool_module = $tool_meta['module'] ?? 'unknown'; // ── Step 2: Lock check ──────────────────────────────────────────── $effective_lock = AICOM_Lock_Manager::get_effective_lock(); // If tool exists AND its class is blocked — reject before auth. // Unknown tools fall through to step 4 (tool not found) after auth. if ( $tool_meta !== null && ! AICOM_Policy_Engine::is_tool_allowed_by_lock( $tool_class, $effective_lock ) ) { $lock_status = $effective_lock === 'hard_locked' ? 'blocked_hard_lock' : 'blocked_soft_lock'; $lock_code = $effective_lock === 'hard_locked' ? 'HARD_LOCK_ACTIVE' : 'SOFT_LOCK_ACTIVE'; AICOM_Audit_Logger::log( [ 'request_id' => $request_id, 'remote_ip' => $remote_ip, 'tool_name' => $tool_name, 'module' => $tool_module, 'status' => $lock_status, 'http_status' => 403, 'duration_ms' => self::elapsed( $start ), 'error_code' => $lock_code, 'params_json' => self::safe_json( $arguments ), ] ); return self::mcp_error( $lock_code, ucwords( str_replace( '_', ' ', $lock_status ) ), $request_id, $rpc_id ); } // ── Step 3: Auth ────────────────────────────────────────────────── $plain_key = AICOM_Auth::extract_key_from_request(); if ( ! $plain_key ) { return self::early_error( 'auth_failed', 'API key missing', 401, $request_id, $remote_ip, $tool_name, $tool_module, $start, 0, '', $rpc_id ); } if ( self::is_rate_limited( $remote_ip ) ) { return self::early_error( 'rate_limited', 'Too many failed authentication attempts — try again later', 429, $request_id, $remote_ip, $tool_name, $tool_module, $start, 0, '', $rpc_id ); } $key_record = AICOM_Auth::validate_key( $plain_key ); if ( ! $key_record ) { self::record_auth_failure( $remote_ip ); // Detect truncated key: right prefix format but too short $expected_len = 55; // "aicom_" (6) + 8-char prefix + "_" (1) + 40-char hex $msg = 'Invalid or revoked API key'; if ( strpos( $plain_key, 'aicom_' ) === 0 && strlen( $plain_key ) < $expected_len ) { $msg = sprintf( 'API key is incomplete (%d of %d chars). Make sure you copy and use the entire key — do not truncate or shorten it.', strlen( $plain_key ), $expected_len ); } return self::early_error( 'auth_failed', $msg, 403, $request_id, $remote_ip, $tool_name, $tool_module, $start, 0, '', $rpc_id ); } AICOM_Auth::maybe_auto_bind_ip( $key_record, $remote_ip ); if ( ! AICOM_Auth::check_ip_allowlist( $key_record, $remote_ip ) ) { return self::early_error( 'auth_failed', 'IP not in allowlist', 403, $request_id, $remote_ip, $tool_name, $tool_module, $start, (int) $key_record['id'], $key_record['label'], $rpc_id ); } // Per-key Working Hours: optional schedule restriction, mirrors the // site-wide Safety schedule but scoped to this single key. if ( ! AICOM_Auth::check_working_hours( $key_record ) ) { return self::early_error( 'blocked_working_hours', 'This key is outside its working hours', 403, $request_id, $remote_ip, $tool_name, $tool_module, $start, (int) $key_record['id'], $key_record['label'], $rpc_id ); } $key_id = (int) $key_record['id']; $key_label = $key_record['label']; // ── Step 3.5: Session enforcement ───────────────────────────────── // Write/destructive/admin_sensitive tools require an open named session. // Exempt: session.open, session.close, tools/list, and read/discovery tools. $session_exempt = in_array( $tool_name, [ 'session.open', 'session.close', 'tools/list', 'skills.suggestions' ], true ) || in_array( $tool_class, [ 'read', 'discovery', 'public' ], true ) || $tool_meta === null; // unknown tools → rejected at step 4 if ( $session_exempt ) { // Read/discovery tools don't require a session, but associate with one // if open — so they appear in the session's audit log for skill analysis. $ambient = AICOM_Sessions::get_active( $key_id ); self::$current_session_id = $ambient ? (int) $ambient['id'] : 0; } else { $active_session = AICOM_Sessions::get_active( $key_id ); if ( ! $active_session ) { return self::keyed_error( $request_id, $remote_ip, $key_id, $key_label, $tool_name, $tool_module, 'NO_ACTIVE_SESSION', 'REQUIRED — not optional. Open a session first: session.open(name: "Short label", description: "What you plan to do and why"). Read/discovery tools work without a session.', 'validation_failed', 400, $arguments, $start, $rpc_id ); } self::$current_session_id = (int) $active_session['id']; } // ── Step 4: Tool exists ─────────────────────────────────────────── if ( $tool_meta === null ) { $suggestions = self::suggest_tools( $tool_name ); $hint = $suggestions ? "Tool not found: $tool_name. Did you mean: " . implode( ', ', $suggestions ) . '? Call tools/list to see all tools.' : "Tool not found: $tool_name. Call tools/list to see all tools, or aicom.recipes for task guidance."; return self::keyed_error( $request_id, $remote_ip, $key_id, $key_label, $tool_name, $tool_module, 'TOOL_NOT_FOUND', $hint, 'error', 404, $arguments, $start, $rpc_id ); } // ── Step 5: Dependency check ────────────────────────────────────── $dependency = $tool_meta['dependency']; if ( $dependency !== null ) { if ( $dependency === 'woocommerce' ) { $dep_active = AICOM_Module_Detector::is_woocommerce_active(); } elseif ( $dependency === 'elementor' ) { $dep_active = AICOM_Module_Detector::is_elementor_active(); } elseif ( $dependency === 'polylang' ) { $dep_active = AICOM_Module_Detector::is_polylang_active(); } elseif ( $dependency === 'ecs' ) { $dep_active = AICOM_Module_Detector::is_ecs_active(); } elseif ( $dependency === 'ecs_pro' ) { $dep_active = AICOM_Module_Detector::is_ecs_pro_active(); } elseif ( $dependency === 'clautron' ) { $dep_active = AICOM_Module_Detector::is_clautron_active(); } elseif ( $dependency === 'yoast' ) { $dep_active = AICOM_Module_Detector::is_yoast_active(); } else { $dep_active = false; } if ( ! $dep_active ) { return self::keyed_error( $request_id, $remote_ip, $key_id, $key_label, $tool_name, $tool_module, 'DEPENDENCY_MISSING', "Required plugin not active: $dependency", 'dependency_missing', 422, $arguments, $start, $rpc_id ); } } // ── Step 6: Scope check ─────────────────────────────────────────── $missing_scopes = AICOM_Auth::missing_scopes( $key_record, $tool_meta['required_scopes'] ); if ( $missing_scopes ) { $hint = 'Insufficient scope. Required: ' . implode( ', ', $missing_scopes ) . '. Ask the site admin to enable it on your API key.'; return self::keyed_error( $request_id, $remote_ip, $key_id, $key_label, $tool_name, $tool_module, 'DENIED_SCOPE', $hint, 'denied_scope', 403, $arguments, $start, $rpc_id ); } // ── Step 7: (Delegated) Allowlist checks are done inside handlers ─ // ── Step 8: Confirm flag ────────────────────────────────────────── if ( ! AICOM_Policy_Engine::check_confirm_flag( $tool_meta, $arguments ) ) { return self::keyed_error( $request_id, $remote_ip, $key_id, $key_label, $tool_name, $tool_module, 'CONFIRM_REQUIRED', 'This operation requires arguments.confirm = true', 'validation_failed', 400, $arguments, $start, $rpc_id ); } // ── Step 9: Dry-run mode ────────────────────────────────────────── $is_dry_run = ! empty( $arguments['dry_run'] ) || AICOM_Policy_Engine::is_dry_run_only( $key_record ); // ── Step 9b: Unknown parameter detection ───────────────────────── // Resolve common aliases before checking unknown params. $aliases = [ 'status' => 'post_status', 'title' => 'post_title', 'content' => 'post_content', 'excerpt' => 'post_excerpt', 'post_id' => 'id', // e.g. wp.posts.update uses 'id' ]; $schema_keys = array_keys( $tool_meta['input_schema'] ?? [] ); foreach ( $aliases as $wrong => $correct ) { if ( isset( $arguments[ $wrong ] ) && ! isset( $arguments[ $correct ] ) ) { // Only apply alias if correct key is in schema and wrong key is not if ( in_array( $correct, $schema_keys, true ) && ! in_array( $wrong, $schema_keys, true ) ) { $arguments[ $correct ] = $arguments[ $wrong ]; unset( $arguments[ $wrong ] ); } } } // Warn about remaining unknown params if ( ! empty( $schema_keys ) ) { $reserved = [ 'dry_run', 'confirm' ]; $unknown = array_diff( array_keys( $arguments ), $schema_keys, $reserved ); if ( ! empty( $unknown ) ) { $hints = []; foreach ( $unknown as $bad_key ) { $best = ''; $best_pct = 0; foreach ( $schema_keys as $good_key ) { similar_text( $bad_key, $good_key, $pct ); if ( $pct > $best_pct ) { $best_pct = $pct; $best = $good_key; } } $hints[] = $best_pct >= 50 ? "\"$bad_key\" is not a valid parameter — did you mean \"$best\"?" : "\"$bad_key\" is not a valid parameter and was ignored"; } // Inject warning into arguments so it surfaces in the result $arguments['_param_warnings'] = $hints; } } // ── Step 10: Execute ────────────────────────────────────────────── $handler = $tool_meta['handler']; if ( ! is_callable( $handler ) ) { return self::keyed_error( $request_id, $remote_ip, $key_id, $key_label, $tool_name, $tool_module, 'HANDLER_MISSING', 'Tool handler not callable', 'error', 500, $arguments, $start, $rpc_id ); } // Grant only the WP capabilities that the key's scopes actually need. // Scoped to tool execution only — cleared immediately in finally. self::$current_key_scopes = json_decode( $key_record['scopes_json'] ?? '[]', true ) ?: []; add_filter( 'user_has_cap', [ 'AICOM_Tool_Router', 'grant_api_request_caps' ], 999 ); $exec_exception = null; try { $result = call_user_func( $handler, $arguments, $key_record, $is_dry_run ); } catch ( Throwable $e ) { $exec_exception = $e; } finally { remove_filter( 'user_has_cap', [ 'AICOM_Tool_Router', 'grant_api_request_caps' ], 999 ); self::$current_key_scopes = []; } if ( $exec_exception !== null ) { return self::keyed_error( $request_id, $remote_ip, $key_id, $key_label, $tool_name, $tool_module, 'EXECUTION_ERROR', $exec_exception->getMessage(), 'error', 500, $arguments, $start, $rpc_id ); } // ── Step 11: Touch key ──────────────────────────────────────────── AICOM_Auth::touch_key( $key_id, $remote_ip ); // ── Step 12: Audit log + return ─────────────────────────────────── $duration = self::elapsed( $start ); $is_error = isset( $result['error'] ); $meta = $result['_meta'] ?? []; AICOM_Audit_Logger::log( [ 'request_id' => $request_id, 'session_id' => self::$current_session_id ?: null, 'remote_ip' => $remote_ip, 'api_key_id' => $key_id, 'api_key_label' => $key_label, 'tool_name' => $tool_name, 'module' => $tool_module, 'tool_class' => $tool_class, 'status' => $is_error ? ( $result['error']['status_code'] ?? 'error' ) : 'success', 'http_status' => $is_error ? ( $result['http_status'] ?? 500 ) : 200, 'duration_ms' => $duration, 'target_type' => $meta['target_type'] ?? null, 'target_id' => isset( $meta['target_id'] ) ? (string) $meta['target_id'] : null, 'is_dry_run' => $is_dry_run ? 1 : 0, 'error_code' => $is_error ? ( $result['error']['code'] ?? 'ERROR' ) : null, 'error_message' => $is_error ? ( $result['error']['message'] ?? '' ) : null, 'params_json' => self::safe_json( $arguments ), 'result_summary_json' => ! $is_error ? self::safe_json( $meta['summary'] ?? [] ) : null, ] ); // Strip internal keys before returning unset( $result['_meta'], $result['http_status'] ); $result['request_id'] = $request_id; // Surface unknown parameter warnings in the result if ( isset( $arguments['_param_warnings'] ) ) { $result['_warnings'] = $arguments['_param_warnings']; } return self::jsonrpc_wrap( $result, $rpc_id ); } // ── Error Builders ──────────────────────────────────────────────────── /** * Error before auth (no key_id known). Logs with minimal data. */ private static function early_error( string $status_code, string $message, int $http_status, string $request_id, string $remote_ip, string $tool_name, string $tool_module, float $start, int $key_id = 0, string $key_label = '', $rpc_id = null ): array { AICOM_Audit_Logger::log( [ 'request_id' => $request_id, 'remote_ip' => $remote_ip, 'api_key_id' => $key_id ?: null, 'api_key_label' => $key_label ?: null, 'tool_name' => $tool_name ?: 'unknown', 'module' => $tool_module, 'status' => $status_code, 'http_status' => $http_status, 'duration_ms' => self::elapsed( $start ), 'error_code' => strtoupper( $status_code ), 'error_message' => $message, ] ); return self::mcp_error( strtoupper( $status_code ), $message, $request_id, $rpc_id ); } /** * Error after auth (key_id known). */ private static function keyed_error( string $request_id, string $remote_ip, int $key_id, string $key_label, string $tool_name, string $tool_module, string $error_code, string $message, string $status_code, int $http_status, array $arguments, float $start, $rpc_id = null ): array { AICOM_Audit_Logger::log( [ 'request_id' => $request_id, 'remote_ip' => $remote_ip, 'api_key_id' => $key_id, 'api_key_label' => $key_label, 'tool_name' => $tool_name, 'module' => $tool_module, 'status' => $status_code, 'http_status' => $http_status, 'duration_ms' => self::elapsed( $start ), 'error_code' => $error_code, 'error_message' => $message, 'params_json' => self::safe_json( $arguments ), ] ); return self::mcp_error( $error_code, $message, $request_id, $rpc_id ); } private static function mcp_error( string $code, string $message, string $request_id, $rpc_id = null ): array { $response = [ 'error' => [ 'code' => $code, 'message' => $message ], 'request_id' => $request_id, ]; return self::jsonrpc_wrap( $response, $rpc_id ); } /** * Wrap response in JSON-RPC 2.0 envelope when client sent a jsonrpc request (id present). * For error responses, error stays at top level per JSON-RPC spec. * For success responses, payload moves into result key. */ private static function jsonrpc_wrap( array $response, $rpc_id ): array { if ( $rpc_id === null ) { return $response; // legacy / shorthand format — no wrapping } $base = [ 'jsonrpc' => '2.0', 'id' => $rpc_id ]; if ( isset( $response['error'] ) ) { return $base + [ 'error' => $response['error'], 'request_id' => $response['request_id'] ?? null, ]; } return $base + [ 'result' => $response ]; } // ── Helpers ─────────────────────────────────────────────────────────── private static function elapsed( float $start ): int { return (int) round( ( microtime( true ) - $start ) * 1000 ); } private static function safe_json( array $data ): string { return wp_json_encode( self::strip_sensitive( $data ) ) ?: '{}'; } private static function strip_sensitive( $data ) { if ( ! is_array( $data ) ) { return $data; } static $deny = [ 'password', 'pass', 'user_pass', 'secret', 'token', 'api_key', 'auth_key', 'auth_pass', 'auth' ]; foreach ( $data as $k => $v ) { if ( in_array( strtolower( (string) $k ), $deny, true ) ) { $data[ $k ] = '***'; } elseif ( is_array( $v ) ) { $data[ $k ] = self::strip_sensitive( $v ); } } return $data; } /** * Grant only the WP capabilities that the current key's scopes require. * Runs only during step 10 (Execute) and is always removed in finally. */ public static function grant_api_request_caps( array $allcaps ): array { $s = self::$current_key_scopes; $allcaps['read'] = true; if ( in_array( 'read.wp', $s, true ) ) { $allcaps['edit_posts'] = true; // WP_Query / get_post() needs this for non-public post types } if ( in_array( 'write.wp.posts', $s, true ) ) { $allcaps['edit_posts'] = true; $allcaps['edit_others_posts'] = true; $allcaps['edit_published_posts'] = true; $allcaps['delete_posts'] = true; $allcaps['delete_others_posts'] = true; $allcaps['delete_published_posts'] = true; $allcaps['publish_posts'] = true; $allcaps['manage_categories'] = true; } if ( in_array( 'manage.meta', $s, true ) ) { $allcaps['edit_posts'] = true; $allcaps['edit_others_posts'] = true; $allcaps['edit_published_posts'] = true; } if ( in_array( 'manage.media', $s, true ) ) { $allcaps['upload_files'] = true; $allcaps['edit_posts'] = true; $allcaps['edit_others_posts'] = true; $allcaps['edit_published_posts'] = true; $allcaps['delete_posts'] = true; $allcaps['delete_others_posts'] = true; $allcaps['delete_published_posts'] = true; } if ( in_array( 'manage.files', $s, true ) ) { $allcaps['upload_files'] = true; } if ( in_array( 'manage.wordpress.settings', $s, true ) ) { $allcaps['manage_options'] = true; } if ( in_array( 'read.users', $s, true ) ) { $allcaps['list_users'] = true; } if ( in_array( 'manage.users', $s, true ) ) { $allcaps['list_users'] = true; $allcaps['edit_users'] = true; $allcaps['promote_users'] = true; $allcaps['create_users'] = true; } if ( in_array( 'delete.users', $s, true ) ) { $allcaps['delete_users'] = true; $allcaps['list_users'] = true; } if ( in_array( 'manage.roles', $s, true ) ) { $allcaps['list_users'] = true; $allcaps['edit_users'] = true; $allcaps['promote_users'] = true; } if ( in_array( 'manage.backups', $s, true ) ) { $allcaps['edit_posts'] = true; $allcaps['edit_others_posts'] = true; $allcaps['edit_published_posts'] = true; $allcaps['delete_posts'] = true; $allcaps['delete_others_posts'] = true; $allcaps['publish_posts'] = true; $allcaps['manage_categories'] = true; } if ( in_array( 'manage.woocommerce.products', $s, true ) || in_array( 'manage.woocommerce.settings', $s, true ) ) { $allcaps['manage_woocommerce'] = true; $allcaps['edit_posts'] = true; $allcaps['edit_others_posts'] = true; $allcaps['edit_published_posts'] = true; $allcaps['delete_posts'] = true; $allcaps['delete_others_posts'] = true; $allcaps['delete_published_posts'] = true; $allcaps['publish_posts'] = true; } if ( in_array( 'manage.elementor', $s, true ) ) { $allcaps['edit_posts'] = true; $allcaps['edit_others_posts'] = true; $allcaps['edit_published_posts'] = true; $allcaps['publish_posts'] = true; } return $allcaps; } public static function remote_ip(): string { $remote_addr = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '0.0.0.0'; // Only trust X-Forwarded-For when the direct connection comes from a configured trusted proxy. // Configure via: add_filter('aicom_trusted_proxies', fn() => ['10.0.0.1', '10.0.0.2']); $trusted_proxies = (array) apply_filters( 'aicom_trusted_proxies', [] ); if ( ! empty( $trusted_proxies ) && in_array( $remote_addr, $trusted_proxies, true ) ) { $forwarded = isset( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) : ''; if ( $forwarded ) { return sanitize_text_field( trim( explode( ',', $forwarded )[0] ) ); } } return $remote_addr; } /** * Best-effort JSON repair for payloads from weak local models. * Fixes: UTF-8 BOM, literal control chars inside strings, trailing commas. */ private static function repair_json( string $raw ): string { // Strip UTF-8 BOM if ( substr( $raw, 0, 3 ) === "\xEF\xBB\xBF" ) { $raw = substr( $raw, 3 ); } // State-machine pass: escape literal control characters inside JSON strings $out = ''; $in_string = false; $escape_next = false; $len = strlen( $raw ); for ( $i = 0; $i < $len; $i++ ) { $c = $raw[ $i ]; $ord = ord( $c ); if ( $escape_next ) { $out .= $c; $escape_next = false; continue; } if ( $c === '\\' && $in_string ) { $out .= $c; $escape_next = true; continue; } if ( $c === '"' ) { $in_string = ! $in_string; $out .= $c; continue; } if ( $in_string && $ord < 0x20 ) { switch ( $c ) { case "\n": $out .= '\\n'; break; case "\r": $out .= '\\r'; break; case "\t": $out .= '\\t'; break; default: $out .= sprintf( '\\u%04x', $ord ); break; } continue; } $out .= $c; } // Remove trailing commas before ] or } (another common model mistake) $out = preg_replace( '/,(\s*[\]}])/', '$1', $out ); return $out ?? $raw; } private static function suggest_tools( string $bad_name ): array { $all = array_keys( AICOM_Tool_Registry::get_all() ); $found = []; foreach ( $all as $name ) { similar_text( $bad_name, $name, $pct ); if ( $pct >= 55 ) { $found[ $name ] = $pct; } } arsort( $found ); return array_slice( array_keys( $found ), 0, 3 ); } private static function is_rate_limited( string $ip ): bool { return (int) get_transient( 'aicom_rl_' . $ip ) >= 5; } private static function record_auth_failure( string $ip ): void { $key = 'aicom_rl_' . $ip; $count = (int) get_transient( $key ); // Exponential backoff: 1 min → 2 → 4 → 8 ... capped at 24 h $ttl = min( MINUTE_IN_SECONDS * ( 2 ** $count ), DAY_IN_SECONDS ); set_transient( $key, $count + 1, $ttl ); } }