settings = $settings; } /** * Hook the rewrite and the front controller. * * @return void */ public function register() { add_action( 'init', array( __CLASS__, 'add_rules' ) ); add_action( 'template_redirect', array( $this, 'route' ), 0 ); } /** * The /agentimus/connect rewrite. Registered before priority 20, so the * self-healing flush (whose signature includes the plugin version) carries * it in with the same flush that adds the OAuth well-known routes. * * @return void */ public static function add_rules() { add_rewrite_rule( '^agentimus/connect/?$', 'index.php?' . self::QUERY_VAR . '=1', 'top' ); add_rewrite_tag( '%' . self::QUERY_VAR . '%', '1' ); } /** * Front controller. Login is required (browser round-trips through * wp-login and comes back), consent requires manage_options — the same * bar as creating a connection token. * * @return void */ public function route() { if ( '1' !== (string) get_query_var( self::QUERY_VAR ) ) { return; } if ( ! $this->settings->enabled( 'enable_mcp_server' ) ) { $this->not_found(); // No MCP server, no connects — a clean 404, not a hint. return; } if ( ! is_user_logged_in() ) { auth_redirect(); exit; } if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Only an administrator can connect an assistant to this site.', 'agentimus' ), 403 ); } nocache_headers(); $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET'; if ( 'POST' === $method ) { $this->handle_decision(); return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- OAuth authorize params come from the client, by spec; nothing changes state on GET. $req = Server::validate_authorize_request( wp_unslash( $_GET ) ); if ( is_wp_error( $req ) ) { $this->render_error( $req ); return; } $this->render( $req ); } /** * POST: the owner decided. The original authorize params ride hidden * fields and are RE-validated — the form is a courier, never an authority. * * @return void */ private function handle_decision() { check_admin_referer( 'agentimus_oauth_consent' ); $req = Server::validate_authorize_request( wp_unslash( $_POST ) ); if ( is_wp_error( $req ) ) { $this->render_error( $req ); return; } $approved = isset( $_POST['decision'] ) && 'approve' === sanitize_text_field( wp_unslash( $_POST['decision'] ) ); if ( ! $approved ) { $this->redirect_back( $req, array( 'error' => 'access_denied' ) ); return; } // The owner's choice (the `grant` radios — deliberately NOT named `scope`, // which is the hidden courier of the client's original request), capped by // the walls: a forged value can never out-rank max_grantable(). $max = Server::max_grantable( $req['wants_write'], $this->settings->enabled( 'enable_agent_writes' ) ); $wanted = isset( $_POST['grant'] ) ? sanitize_text_field( wp_unslash( $_POST['grant'] ) ) : 'read'; $scope = ( 'write' === $wanted && 'write' === $max ) ? 'write' : 'read'; $code = Server::issue_code( $req, $scope, get_current_user_id() ); $this->redirect_back( $req, array( 'code' => $code ) ); } /** * Send the browser back to the client's VALIDATED redirect URI. Deliberate * wp_redirect: the target is external by design and was checked against * the client's registration — wp_safe_redirect would refuse it. * * @param array $req Validated authorize request. * @param array $params code or error. * @return void */ private function redirect_back( array $req, array $params ) { if ( '' !== $req['state'] ) { $params['state'] = $req['state']; } $url = add_query_arg( array_map( 'rawurlencode', $params ), $req['redirect_uri'] ); wp_redirect( $url ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- validated against the registered redirect_uris. exit; } /** * Render the consent screen. * * @param array $req Validated authorize request. * @return void */ private function render( array $req ) { status_header( 200 ); header( 'Content-Type: text/html; charset=UTF-8' ); echo $this->html( $req ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built with escaping below. exit; } /** * Render a validation failure as a page. Plain words, no redirect — a * request that failed validation has no address we trust to send it to. * * @param \WP_Error $error The validation error. * @return void */ private function render_error( $error ) { status_header( 400 ); header( 'Content-Type: text/html; charset=UTF-8' ); $body = '

' . esc_html__( 'This connection request is not valid.', 'agentimus' ) . '

' . '

' . esc_html( $error->get_error_message() ) . ' ' . esc_html__( 'Ask the assistant to start the connection again.', 'agentimus' ) . '

'; echo $this->page( $body ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped above. exit; } /** * The consent form, per the approved prototype. * * @param array $req Validated authorize request. * @return string */ public function html( array $req ) { $name = $req['client_name']; $host = wp_parse_url( $req['redirect_uri'], PHP_URL_HOST ); $host = is_string( $host ) ? $host : ''; $walls = $this->settings->enabled( 'enable_agent_writes' ); $max = Server::max_grantable( $req['wants_write'], $walls ); $down = $req['wants_write'] && 'write' !== $max; // Asked write, walls said read. $user = wp_get_current_user(); $b = '

' . sprintf( esc_html__( '“%s” asks to connect to your site', 'agentimus' ), esc_html( $name ) ) . '

'; $b .= '

' . sprintf( /* translators: 1: client-claimed name, 2: redirect host. */ esc_html__( 'It calls itself %1$s and will return to %2$s after you decide. If that is not where you started, close this tab.', 'agentimus' ), '' . esc_html( $name ) . '', '' . esc_html( $host ) . '' ) . '

'; $b .= '
'; $b .= wp_nonce_field( 'agentimus_oauth_consent', '_wpnonce', true, false ); foreach ( array( 'client_id', 'redirect_uri', 'response_type', 'code_challenge', 'code_challenge_method', 'scope', 'state' ) as $key ) { // The original params ride along and are re-validated on POST. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- render-only echo of the authorize request. $val = isset( $_GET[ $key ] ) ? sanitize_text_field( wp_unslash( $_GET[ $key ] ) ) : ''; $b .= ''; } $b .= '
'; $b .= ''; if ( 'write' === $max ) { $b .= ''; } $b .= '
'; if ( $down ) { $b .= '

' . esc_html__( 'This site does not allow agent writes.', 'agentimus' ) . ' ' . sprintf( /* translators: %s: client-claimed name. */ esc_html__( '“%s” asked for write access, but your settings keep writing off — so it can connect as read-only. You can allow writes any time in Settings.', 'agentimus' ), esc_html( $name ) ) . '

'; } $b .= '

' . sprintf( /* translators: %s: user login. */ esc_html__( 'Approval acts as your account: %s · one approval, only for this assistant · revoke any time in Settings.', 'agentimus' ), '' . esc_html( $user instanceof \WP_User ? $user->user_login : '' ) . '' ) . '

'; $b .= '
' . '' . '' . '
'; $b .= '

' . sprintf( /* translators: %s: client-claimed name. */ esc_html__( 'Approving gives “%s” its own key to this site’s MCP tools — nothing else. Every call it makes shows in Agent Access.', 'agentimus' ), esc_html( $name ) ) . '

'; $b .= '
'; return $this->page( $b ); } /** * The page shell: the prototype's cream card, self-contained CSS, no * theme, no scripts. * * @param string $body Escaped body HTML. * @return string */ private function page( $body ) { $site = get_bloginfo( 'name' ); $host = wp_parse_url( home_url( '/' ), PHP_URL_HOST ); return '' . '' . '' . '' . esc_html__( 'Connect an assistant', 'agentimus' ) . ' · ' . esc_html( $site ) . '' . '
' . '
' . esc_html( $site ) . '' . '' . esc_html( is_string( $host ) ? $host : '' ) . ' · Agentimus
' . $body . '
'; } /** * A clean 404 for the gated-off state. * * @return void */ private function not_found() { global $wp_query; if ( $wp_query instanceof \WP_Query ) { $wp_query->set_404(); } status_header( 404 ); nocache_headers(); } }