and links a single same-origin * stylesheet (assets/consent.css) through the enqueue API (wp_enqueue_style + * wp_print_styles), allowed under style-src 'self'; the logo is inlined (no * external image fetch under img-src data:). There is no JavaScript at all and no * inline style block - system fonts only - so it renders under the strict consent * CSP set in includes/oauth/authorize.php. * * @package AgentAbilitiesForMCP */ declare( strict_types=1 ); defined( 'ABSPATH' ) || exit; /** * Derive up to two uppercase initials from a site name, for the site avatar. * * Multibyte-safe. When the name has no whitespace-delimited words to abbreviate (for example a * name made only of separators), it falls back to the first character of the trimmed name, and * only when even that is empty to a neutral globe glyph (๐ŸŒ) - a recognisable "a website" mark, * rather than the old bare middle dot which read as a missing/placeholder character. * * @param string $site_name The site display name. * @return string One or two characters (already plain text; escape on output). */ function aafm_oauth_site_initials( string $site_name ): string { $trimmed = trim( $site_name ); $words = preg_split( '/\s+/', $trimmed ); if ( ! is_array( $words ) ) { $words = array(); } $initials = ''; foreach ( array_slice( $words, 0, 2 ) as $word ) { if ( '' === $word ) { continue; } $initials .= function_exists( 'mb_substr' ) ? mb_substr( $word, 0, 1 ) : substr( $word, 0, 1 ); } $initials = function_exists( 'mb_strtoupper' ) ? mb_strtoupper( $initials ) : strtoupper( $initials ); if ( '' !== $initials ) { return $initials; } // No abbreviatable words: take the first character of the trimmed name if there is one, // otherwise a neutral globe glyph so the avatar still reads as "a site". if ( '' !== $trimmed ) { $first = function_exists( 'mb_substr' ) ? mb_substr( $trimmed, 0, 1 ) : substr( $trimmed, 0, 1 ); return function_exists( 'mb_strtoupper' ) ? mb_strtoupper( $first ) : strtoupper( $first ); } return "\xF0\x9F\x8C\x90"; // U+1F310 GLOBE WITH MERIDIANS - neutral "a website" mark. } /** * Whether a Site Icon URL is served from this site's own origin. * * The consent page renders under a strict CSP whose img-src is `'self' data:` only, so an * off-origin Site Icon (WordPress can hand back a CDN/offload URL from get_site_icon_url()) is * silently blocked by the browser and the avatar renders empty. Rather than widen the CSP to * trust an arbitrary host, the caller falls back to the derived initials whenever the icon is not * same-origin. A URL that fails to parse a host, or whose host differs from the site host, is * treated as off-origin (fail closed to the initials). * * @param string $icon_url The Site Icon URL from get_site_icon_url(). * @return bool True only when the icon host equals the site host. */ function aafm_oauth_site_icon_is_same_origin( string $icon_url ): bool { if ( '' === $icon_url ) { return false; } $icon_host = wp_parse_url( $icon_url, PHP_URL_HOST ); if ( ! is_string( $icon_host ) || '' === $icon_host ) { return false; } $site_host = wp_parse_url( home_url(), PHP_URL_HOST ); return is_string( $site_host ) && '' !== $site_host && strtolower( $icon_host ) === strtolower( $site_host ); } /** * Return the connecting client's brand icon as an inline SVG, matched to the connector. * * The consent screen's client avatar sits on a dark (#0B1020) tile, so every mark below * is filled in a colour that reads on dark. Vendor brand marks are used with the operator's * explicit approval; each is a static, kses-safe primitive (path/line/polygon), no external * fetch. An unrecognised connector gets a neutral plug glyph - never a decorative star, which * would imply a rating or a "featured" status the plugin never confers. * * SECURITY: the vendor is chosen SOLELY from the redirect host, matched with an exact, * boundary-checked suffix against a small allowlist of each vendor's real fixed domains - never * from the client-supplied display name, and never with a loose substring. Dynamic client * registration lets a self-registered app pick any display name it likes AND register any * redirect host it can actually receive the authorization code at; the display name is therefore * pure attacker input, and a loose host substring (the old `strpos()` match) would let * `evil-openai.com` or `claude.ai.attacker.com` steal a trusted vendor logo and defeat the * "Unverified app" warning. Matching only the host, and only against the exact real domains an * impostor cannot receive a code at, closes that. Any host not on the allowlist renders the * neutral plug glyph; real vendors still get their icon. * * Every icon carries a `conn-` class so the choice is assertable in tests and survives * the SVG kses pass (class is allowlisted on ). * * @param string $client_name The client's self-declared display name (deliberately UNUSED for * vendor selection - see the security note above). * @param string $redirect_host The host portion of the client's redirect URI. * @return string Inline SVG markup (run it through aafm_svg_allowed_html() on output). */ function aafm_oauth_connector_icon( string $client_name, string $redirect_host ): string { unset( $client_name ); // Vendor selection is by verified host ONLY; the display name is attacker-controlled. // Normalise the host: lowercase, and strip any explicit :port suffix so a defensive // "host:443" cannot slip past the exact-match allowlist. A real vendor never carries a port. $host = strtolower( trim( $redirect_host ) ); $host = (string) preg_replace( '/:\d+$/', '', $host ); // Exact host, or a genuine subdomain of it (boundary-checked with a leading dot so // `evilclaude.ai` does NOT match `claude.ai` while `foo.claude.ai` does). No substring match. $host_is = static function ( string $host, array $domains ): bool { foreach ( $domains as $domain ) { if ( $host === $domain ) { return true; } $suffix = '.' . $domain; if ( strlen( $host ) > strlen( $suffix ) && substr( $host, -strlen( $suffix ) ) === $suffix ) { return true; } } return false; }; // Claude / Anthropic - the Anthropic radial burst, in the brand clay. if ( $host_is( $host, array( 'claude.ai', 'claude.com', 'anthropic.com' ) ) ) { return ''; } // ChatGPT / OpenAI - the official OpenAI knot mark (single path), in white for the dark tile. if ( $host_is( $host, array( 'chatgpt.com', 'openai.com' ) ) ) { return ''; } // Cursor - the isometric cube, three white faces at descending opacity (its layered look). if ( $host_is( $host, array( 'cursor.com', 'cursor.sh' ) ) ) { return ''; } // VS Code - the official ribbon mark (single path), in the VS Code blue. if ( $host_is( $host, array( 'vscode.dev', 'visualstudio.com' ) ) ) { return ''; } // Unknown connector - a neutral plug glyph (two prongs, body, cord). Not a star. return ''; } /** * Render the OAuth consent page as a complete standalone HTML document. * * Echoes the full page (DOCTYPE through ). Every dynamic value is escaped at * the point of output: esc_html() for the names, esc_url() for the form action. The * nonce field and hidden OAuth inputs arrive pre-rendered (and pre-escaped by the * caller) and are emitted as-is inside the single POST form. * * @param array $view View data: client_name, user_login, site_name, * action_url, nonce_field (pre-rendered hidden input), * and hidden_inputs (string[] of pre-escaped inputs). * @return void */ function aafm_oauth_render_consent_page( array $view ): void { $client_name = isset( $view['client_name'] ) ? (string) $view['client_name'] : ''; $user_login = isset( $view['user_login'] ) ? (string) $view['user_login'] : ''; $site_name = isset( $view['site_name'] ) ? (string) $view['site_name'] : ''; $redirect_host = isset( $view['redirect_host'] ) ? (string) $view['redirect_host'] : ''; $high_privilege = ! empty( $view['high_privilege'] ); // Absent key keeps the administrator wording, the louder of the two. $is_admin = ! array_key_exists( 'is_administrator', $view ) || ! empty( $view['is_administrator'] ); $action_url = isset( $view['action_url'] ) ? (string) $view['action_url'] : ''; $nonce_field = isset( $view['nonce_field'] ) ? (string) $view['nonce_field'] : ''; $hidden_inputs = isset( $view['hidden_inputs'] ) && is_array( $view['hidden_inputs'] ) ? $view['hidden_inputs'] : array(); $plain_site = esc_html( $site_name ); /* * Headline, safe-by-construction: the client name is the only untrusted input and is * escaped before interpolation into the trusted, plugin-shipped translation string. * The result is HTML-safe and echoed raw below. */ $headline = sprintf( /* translators: 1: client/app name (already bolded + escaped), 2: site name (escaped). */ esc_html__( '%1$s wants to connect to %2$s', 'agent-abilities-for-mcp' ), '' . esc_html( $client_name ) . '', $plain_site ); // "Acting as" note. The bold phrase and the username chip are pre-escaped HTML // substituted into an escaped translation string, the same safe-by-construction pattern. $acting = sprintf( /* translators: 1: bolded phrase "as your WordPress account", 2: the username chip. */ esc_html__( 'The agent acts %1$s %2$s. It can do what your account is permitted to do, nothing more.', 'agent-abilities-for-mcp' ), '' . esc_html__( 'as your WordPress account', 'agent-abilities-for-mcp' ) . '', '' . esc_html( $user_login ) . '' ); // The page's stylesheet lives in assets/consent.css and is linked below under the // consent CSP (style-src 'self'). It is a plain file rather than inline CSS so the // page passes the wp.org "enqueue your resources" check; the consent screen renders // outside wp-admin (custom headers + exit), so admin.css is never enqueued. The token // values in that file stay in lockstep with includes/admin/assets/admin.css (:root). // Static inline SVG (no dynamic data). The mark is the plugin's real brand logo, matching the // site favicon exactly (agentabilitieswp.com/favicon.svg): a white shield + check on a solid // brand-blue rounded square. The .mark CSS supplies the sizing and drop shadow. $mark_svg = '' . '' . '' . '' . ''; $client_glyph = aafm_oauth_connector_icon( $client_name, $redirect_host ); $flow_svg = ''; $acting_icon = ''; $dest_icon = ''; $warn_icon = ''; $tick_svg = ''; $shield_svg = ''; // Governance guarantees: each a bold lead + plain description, both translatable. The delete // guarantee is the one that depends on configuration - thirteen native abilities and any // destructive bridged one remove content for good - so it is read from the enabled set rather // than asserted. See aafm_delete_guarantee(). $delete_guarantee = aafm_delete_guarantee(); $guarantees = array( array( __( 'Off by default.', 'agent-abilities-for-mcp' ), __( 'The agent can only call abilities an admin has switched on in WordPress.', 'agent-abilities-for-mcp' ) ), array( __( 'Capped to your role.', 'agent-abilities-for-mcp' ), __( 'Every action runs inside your capabilities, never above them.', 'agent-abilities-for-mcp' ) ), array( __( 'Every action is logged.', 'agent-abilities-for-mcp' ), __( 'There is an audit trail of what the agent did and when.', 'agent-abilities-for-mcp' ) ), array( $delete_guarantee[0], $delete_guarantee[1] ), ); echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; /* translators: 1: client/app name, 2: site name. */ echo '' . esc_html( sprintf( __( 'Authorize %1$s ยท %2$s', 'agent-abilities-for-mcp' ), $client_name, $site_name ) ) . ''; // Register and flush the consent stylesheet through the enqueue API. This page builds // its own and exits (no wp_head), so we print the queued handle here directly; // the CSP allows style-src 'self' for the resulting same-origin . $consent_css_path = AAFM_PLUGIN_DIR . 'assets/consent.css'; $consent_css_ver = file_exists( $consent_css_path ) ? (string) filemtime( $consent_css_path ) : AAFM_VERSION; wp_enqueue_style( 'aafm-consent', plugins_url( 'assets/consent.css', AAFM_PLUGIN_FILE ), array(), $consent_css_ver ); wp_print_styles( 'aafm-consent' ); echo ''; echo ''; echo '
'; echo '
'; // Header: logo, eyebrow, headline. echo '
'; echo wp_kses( $mark_svg, aafm_svg_allowed_html() ); echo '

' . esc_html__( 'Authorize connection', 'agent-abilities-for-mcp' ) . '

'; echo '

' . wp_kses( $headline, aafm_admin_allowed_html() ) . '

'; echo '
'; // Client -> site connect row (decorative; the names are stated in the headline + note). echo ''; // Destination + unverified-app caution. The redirect host is the single most decision-relevant // fact for the human - it says WHERE approving this sends account access - and the app was // self-registered through public dynamic client registration, so it is flagged as unverified // rather than presented as a vetted integration. Both are plain text (esc_html); the SVGs go // through the SVG kses allowlist. This block is styled by assets/consent.css under the consent // CSP, but the words alone convey the warning even if the stylesheet is blocked. echo '
'; if ( '' !== $redirect_host ) { echo '
'; echo wp_kses( $dest_icon, aafm_svg_allowed_html() ); echo '

' . esc_html__( 'Will send access to', 'agent-abilities-for-mcp' ) . ' ' . esc_html( $redirect_host ) . '

'; echo '
'; } echo '

'; echo '' . wp_kses( $warn_icon, aafm_svg_allowed_html() ) . esc_html__( 'Unverified app', 'agent-abilities-for-mcp' ) . ' '; echo esc_html__( 'This app registered itself and has not been reviewed by anyone. Approve it only if you started this connection and recognise where it is sending access.', 'agent-abilities-for-mcp' ); echo '

'; echo '
'; // "Acting as" note. echo wp_kses( '
' . $acting_icon . '

' . $acting . '

', aafm_admin_allowed_html() ); // High-privilege warning. A token is minted with the approving user's own capabilities, so // approving as an administrator (or any user who can escalate) hands the app full administrative // reach. Make that unmissable and steer the operator toward connecting as a limited agent user. // Plain text so the warning survives even if the stylesheet is blocked. if ( $high_privilege ) { echo '
'; echo wp_kses( $warn_icon, aafm_svg_allowed_html() ); if ( $is_admin ) { echo '

' . esc_html__( 'This is an administrator account.', 'agent-abilities-for-mcp' ) . ' ' . esc_html__( 'Approving lets the app act with full administrator access, including changing settings, users, and content. If you do not need that, deny this and connect as a dedicated agent user on a limited role instead.', 'agent-abilities-for-mcp' ) . '

'; } else { echo '

' . esc_html__( 'This account has elevated permissions.', 'agent-abilities-for-mcp' ) . ' ' . esc_html__( "Approving lets the app do anything those permissions allow, such as editing other people's content or publishing unfiltered HTML. If you don't need that, deny this and connect as a dedicated agent user on a limited role instead.", 'agent-abilities-for-mcp' ) . '

'; } echo '
'; } // Governance guarantees. echo '
'; echo '

' . esc_html__( 'How this stays governed', 'agent-abilities-for-mcp' ) . '

'; echo '
    '; foreach ( $guarantees as $row ) { echo '
  • ' . wp_kses( $tick_svg, aafm_admin_allowed_html() ) . '' . esc_html( $row[0] ) . ' ' . esc_html( $row[1] ) . '
  • '; } echo '
'; echo '
'; // Decision form: primary Approve, secondary Deny. One POST, both submit buttons. echo '
'; echo wp_kses( $nonce_field, aafm_admin_allowed_html() ); foreach ( $hidden_inputs as $input ) { echo wp_kses( $input, aafm_admin_allowed_html() ); } if ( ! empty( $view['renewing'] ) ) { /* translators: %s: access preset name, for example "Read only". */ echo '

' . esc_html( sprintf( __( 'This connection ended. Approving renews it with the same access: %s.', 'agent-abilities-for-mcp' ), (string) $view['current_label'] ) ) . '

'; } elseif ( ! empty( $view['presets'] ) && is_array( $view['presets'] ) ) { echo '
' . esc_html__( 'What may this app do?', 'agent-abilities-for-mcp' ) . ''; foreach ( $view['presets'] as $preset ) { printf( '', esc_attr( $preset['slug'] ), $preset['checked'] ? ' checked' : '', esc_html( $preset['label'] ), esc_html( $preset['reach'] ), esc_html( $preset['summary'] ) ); } echo '

' . esc_html__( 'A preset only narrows what you have switched on. The app never does more than your account can.', 'agent-abilities-for-mcp' ) . '

'; echo '
'; } printf( '', esc_html__( 'Approve & connect', 'agent-abilities-for-mcp' ) ); printf( '', esc_html__( 'Deny', 'agent-abilities-for-mcp' ) ); echo '
'; echo '
'; // .card echo '

' . wp_kses( $shield_svg, aafm_svg_allowed_html() ) . esc_html__( 'Secured by Agent Abilities for MCP', 'agent-abilities-for-mcp' ) . '

'; echo '
'; echo ''; echo ''; }