) so the PHPUnit suite can force an integration on * WITHOUT installing the host plugin, then stub the host API in the fixture. SEO is * detected per plugin: yoast (defined WPSEO_VERSION), rankmath (class RankMath), * aioseo (function aioseo). Each per-plugin set registers on its own predicate, so a * site running two SEO plugins genuinely contributes both surfaces (no first-match-wins * exclusivity). The shared SEO permission gates and the recursive JSON-LD sanitizer also * live here so the per-plugin ability files can reuse them. * * @package AgentAbilitiesForMCP */ declare( strict_types=1 ); defined( 'ABSPATH' ) || exit; /** * Whether a given integration's host plugin is active (so its abilities should * register / discover). * * @param string $slug One of 'yoast' | 'rankmath' | 'aioseo' | 'acf' | 'woocommerce' | 'slim_seo' | 'tec' | 'event_tickets' | 'avada' | 'geodirectory'. * @return bool */ function aafm_integration_active( string $slug ): bool { switch ( $slug ) { case 'yoast': $active = aafm_yoast_active(); break; case 'rankmath': $active = aafm_rankmath_active(); break; case 'aioseo': $active = aafm_aioseo_active(); break; case 'acf': $active = aafm_acf_active(); break; case 'woocommerce': $active = aafm_woocommerce_active(); break; case 'slim_seo': $active = aafm_slim_seo_active(); break; case 'tec': $active = aafm_tec_active(); break; case 'event_tickets': $active = aafm_event_tickets_active(); break; case 'avada': $active = aafm_avada_active(); break; case 'geodirectory': $active = aafm_geodirectory_active(); break; default: return false; } /** * Filters whether an integration is active. Used by the test suite to force-enable an * integration without installing the host plugin. Production passes the real detection * through unchanged. * * @param bool $active Detected active state. */ return (bool) apply_filters( 'aafm_integration_active_' . $slug, $active ); } /** * Whether Yoast SEO is active, behind a filterable seam. * * Real detection is defined('WPSEO_VERSION'). Wrapped in its own filter so the PHPUnit suite can * pin it deterministically: the suite stubs Yoast by defining WPSEO_VERSION process-wide, and a * defined constant can never be undefined, so the host-inactive registry test cannot flip detection * back off by removing the aafm_integration_active_yoast force filter alone. Driving this seam to * false through aafm_yoast_active lets that test force detection OFF deterministically. Production * passes the real detection through unchanged. * * @return bool */ function aafm_yoast_active(): bool { $active = defined( 'WPSEO_VERSION' ); /** * Filters whether Yoast SEO is reported active. * * @param bool $active Detected active state. */ return (bool) apply_filters( 'aafm_yoast_active', $active ); } /** * Whether Rank Math is active, behind a filterable seam (see aafm_yoast_active for the rationale; * Rank Math is stubbed with a process-wide RankMath marker class). * * @return bool */ function aafm_rankmath_active(): bool { $active = class_exists( 'RankMath' ); /** * Filters whether Rank Math is reported active. * * @param bool $active Detected active state. */ return (bool) apply_filters( 'aafm_rankmath_active', $active ); } /** * The minimum AIOSEO version aafm/aioseo-update-post requires. * * Fix round 2 (sweep review security finding, 208-sweep-review-security.md): AIOSEO's own docblock * at Post.php:908-910 dates its patch-aware partial-save behavior ("only syncs meta for keys * present in $data, so partial saves ... don't warn on, or wipe, fields they didn't touch") to * this exact version. Before 4.9.8, per the vendor's own words, a partial save through * Post::savePost() could wipe fields the caller never touched. This plugin's delegation to * savePost() (fix round 1) depends on that patch-aware behavior being present. */ if ( ! defined( 'AAFM_AIOSEO_MIN_VERSION' ) ) { define( 'AAFM_AIOSEO_MIN_VERSION', '4.9.8' ); } /** * The AIOSEO version this site reports, or null when undetectable. * * Wrapped in its own filter, mirroring aafm_woocommerce_version() and for the same reason: the * PHPUnit suite can pin an arbitrary version for the floor check without ever defining the real * AIOSEO_VERSION constant, since a PHP constant can never be undefined once defined and would * otherwise leak one test's pinned version into every later test in the same process. Production * passes the real AIOSEO_VERSION through unchanged. * * @return string|null */ function aafm_aioseo_version(): ?string { $version = defined( 'AIOSEO_VERSION' ) ? (string) AIOSEO_VERSION : null; /** * Filters the AIOSEO version reported for the ability floor check. * * @param string|null $version Detected version, or null when AIOSEO_VERSION is undefined. */ return apply_filters( 'aafm_aioseo_version', $version ); } /** * Whether All in One SEO is active AND at or above the abilities' required version floor, behind * a filterable seam (see aafm_yoast_active for the rationale; AIOSEO is stubbed with a * process-wide aioseo() marker function). * * Fail-closed by deliberate choice, unlike aafm_woocommerce_active()'s fail-open when its version * is undetectable. The two floors protect different things: the WooCommerce floor exists so a * detection quirk never disables a working store's abilities (fail-open is the safer default * there). This floor exists specifically because the vendor's own patch-aware partial-save * behavior is dated to 4.9.8 (see AAFM_AIOSEO_MIN_VERSION above) - registering the write ability * against an AIOSEO whose version this plugin cannot confirm risks the exact silent field-wipe * this floor exists to prevent, so an undetectable version is treated as NOT meeting the floor. * In practice AIOSEO always defines AIOSEO_VERSION once loaded (confirmed in vendor source, * app/AIOSEO.php: derived from the plugin's own header on every load), so this only bites a * hypothetical fork that omits the constant - the same narrow theoretical case the WooCommerce * floor already tolerates in the other direction. * * @return bool */ function aafm_aioseo_active(): bool { $active = function_exists( 'aioseo' ); if ( $active ) { $version = aafm_aioseo_version(); $active = null !== $version && version_compare( $version, AAFM_AIOSEO_MIN_VERSION, '>=' ); } /** * Filters whether AIOSEO is reported active. * * @param bool $active Detected active state. */ return (bool) apply_filters( 'aafm_aioseo_active', $active ); } /** * Whether Slim SEO is active, behind a filterable seam (see aafm_yoast_active() for the * rationale). No version floor: Slim SEO's single-serialized-meta-key shape * (`slim_seo`, six fields) has shown no breaking-change history worth gating on, unlike AIOSEO's * partial-save fix - a deliberate choice, not an oversight. * * SLIM_SEO_VER (not SLIM_SEO_VERSION) is the constant Slim SEO actually defines, confirmed * 2026-09-03 against plugins.svn.wordpress.org/slim-seo/trunk/slim-seo.php. * * @return bool */ function aafm_slim_seo_active(): bool { $active = defined( 'SLIM_SEO_VER' ); /** * Filters whether Slim SEO is reported active. * * @param bool $active Detected active state. */ return (bool) apply_filters( 'aafm_slim_seo_active', $active ); } /** * The Events Calendar's minimum required version. * * Pinned to the version verified locally when this integration was built * (wp/wp-content/plugins/the-events-calendar/src/Tribe/Main.php:43); raise only with a stated * reason. */ if ( ! defined( 'AAFM_TEC_MIN_VERSION' ) ) { define( 'AAFM_TEC_MIN_VERSION', '6.17.3.1' ); } /** * The TEC version this site reports, or null when undetectable. Filterable, mirroring * aafm_aioseo_version() so the test suite can pin an arbitrary version without defining the real * Tribe__Events__Main::VERSION constant. * * @return string|null */ function aafm_tec_version(): ?string { $version = class_exists( 'Tribe__Events__Main' ) && defined( 'Tribe__Events__Main::VERSION' ) ? (string) Tribe__Events__Main::VERSION : null; /** * Filters the TEC version reported for the ability floor check. * * @param string|null $version Detected version, or null when undetectable. */ return apply_filters( 'aafm_tec_version', $version ); } /** * Whether The Events Calendar is active AND at or above AAFM_TEC_MIN_VERSION, behind a * filterable seam. * * @return bool */ function aafm_tec_active(): bool { $active = class_exists( 'Tribe__Events__Main' ) && function_exists( 'tribe_events' ); if ( $active ) { $version = aafm_tec_version(); $active = null !== $version && version_compare( $version, AAFM_TEC_MIN_VERSION, '>=' ); } /** * Filters whether TEC is reported active. * * @param bool $active Detected active state. */ return (bool) apply_filters( 'aafm_tec_active', $active ); } /** * Event Tickets' minimum required version. * * Pinned to the version verified locally when this integration was built * (wp/wp-content/plugins/event-tickets/src/Tribe/Main.php:21); raise only with a stated reason. */ if ( ! defined( 'AAFM_EVENT_TICKETS_MIN_VERSION' ) ) { define( 'AAFM_EVENT_TICKETS_MIN_VERSION', '5.29.3.1' ); } /** * The Event Tickets version this site reports, or null when undetectable. * * @return string|null */ function aafm_event_tickets_version(): ?string { $version = class_exists( 'Tribe__Tickets__Main' ) && defined( 'Tribe__Tickets__Main::VERSION' ) ? (string) Tribe__Tickets__Main::VERSION : null; /** * Filters the Event Tickets version reported for the ability floor check. * * @param string|null $version Detected version, or null when undetectable. */ return apply_filters( 'aafm_event_tickets_version', $version ); } /** * Whether Event Tickets is active AND at or above AAFM_EVENT_TICKETS_MIN_VERSION, behind a * filterable seam. A distinct slug from 'tec': Event Tickets is a separate plugin that can * theoretically be absent while The Events Calendar is present. * * @return bool */ function aafm_event_tickets_active(): bool { $active = class_exists( 'Tribe__Tickets__Main' ) && function_exists( 'tribe_tickets' ); if ( $active ) { $version = aafm_event_tickets_version(); $active = null !== $version && version_compare( $version, AAFM_EVENT_TICKETS_MIN_VERSION, '>=' ); } /** * Filters whether Event Tickets is reported active. * * @param bool $active Detected active state. */ return (bool) apply_filters( 'aafm_event_tickets_active', $active ); } /** * Whether Avada/Fusion Builder is active, behind a filterable seam. * * Avada is a theme, not a plugin, and Fusion Builder ships as the bundled * fusion-builder plugin - detection keys on the plugin's own runtime, confirmed * (2026-09-05) against a real installed copy (Fusion Builder 3.16.1): the class * FusionBuilder always exists once the plugin has loaded, and fusion_builder_map() * is one of its own core registration functions. No version floor is imposed - unlike * TEC/Event Tickets, this plan pins no minimum Avada/Fusion Builder version. * * @return bool */ function aafm_avada_active(): bool { $active = class_exists( 'FusionBuilder' ) && function_exists( 'fusion_builder_map' ); /** * Filters whether Avada/Fusion Builder is reported active. * * @param bool $active Detected active state. */ return (bool) apply_filters( 'aafm_avada_active', $active ); } /** * Whether GeoDirectory is active, behind a filterable seam. * * Real detection is the same pairing Task 22's own research used to confirm the plugin's * runtime: the geodir_get_post_info() function (includes/post-functions.php) plus the gd_place * post type it registers (includes/class-geodir-post-types.php) both being present, so a partial * or mid-activation state does not falsely report active. * * @return bool */ function aafm_geodirectory_active(): bool { $active = function_exists( 'geodir_get_post_info' ) && post_type_exists( 'gd_place' ); /** * Filters whether GeoDirectory is reported active. * * @param bool $active Detected active state. */ return (bool) apply_filters( 'aafm_geodirectory_active', $active ); } /** * Per-object permission shared by every per-plugin SEO read/write: the caller may edit THIS post * (SEO meta is post content). Relocated here from the deleted unified seo.php so the yoast / rankmath * / aioseo ability files reuse one gate. * * @param array $input Validated input. * @return bool */ function aafm_perm_seo_post_object( array $input ): bool { $id = absint( $input['post_id'] ?? 0 ); $post = $id > 0 ? get_post( $id ) : null; // Delegate to the shared content-edit gate (not a bare edit_post): it enforces the operator's // post-type exposure allowlist AND the map_meta_cap===true fail-open guard, so SEO meta on a // non-exposed or non-mapped type is refused exactly as the core content writes are. return $post instanceof WP_Post && aafm_can_edit_post_object( $post ); } /** * Object-independent floor for the per-plugin *-get-head abilities: the caller can author posts at * all. The per-object edit_post($id) refinement runs inside execute. This floor lets discovery * (empty input) advertise the tool to a capable user, mirroring the documented FSE/floor pattern. * * @return bool */ function aafm_perm_seo_get_head_floor(): bool { return current_user_can( 'edit_posts' ); } /** * Recursively sanitize a JSON-LD schema array. Relocated here from the deleted unified seo.php so the * Rank Math schema writer can reuse it; integrations.php loads before the ability files, so the * function exists at registration time. * * At every depth: arrays recurse; a value under a url-ish key (url / image / logo / sameAs / @id, * case-insensitive) is run through esc_url_raw so a javascript: scheme is dropped; every other scalar * leaf is run through aafm_sanitize_plain_text, which strips