[ 'label' => __( 'Content', 'albert-ai-butler' ), 'description' => __( 'Posts, pages, and media management.', 'albert-ai-butler' ), ], 'site' => [ 'label' => __( 'Site', 'albert-ai-butler' ), 'description' => __( 'Site-level information and guidance.', 'albert-ai-butler' ), ], 'user' => [ 'label' => __( 'Users', 'albert-ai-butler' ), 'description' => __( 'User accounts, roles, and profiles.', 'albert-ai-butler' ), ], 'taxonomy' => [ 'label' => __( 'Taxonomies', 'albert-ai-butler' ), 'description' => __( 'Categories, tags, and custom taxonomies.', 'albert-ai-butler' ), ], 'comments' => [ 'label' => __( 'Comments', 'albert-ai-butler' ), 'description' => __( 'Comment management.', 'albert-ai-butler' ), ], 'commerce' => [ 'label' => __( 'Commerce', 'albert-ai-butler' ), 'description' => __( 'Store and order management.', 'albert-ai-butler' ), ], 'seo' => [ 'label' => __( 'SEO', 'albert-ai-butler' ), 'description' => __( 'Search engine optimization.', 'albert-ai-butler' ), ], 'fields' => [ 'label' => __( 'Custom Fields', 'albert-ai-butler' ), 'description' => __( 'Custom field management.', 'albert-ai-butler' ), ], 'forms' => [ 'label' => __( 'Forms', 'albert-ai-butler' ), 'description' => __( 'Form management.', 'albert-ai-butler' ), ], 'lms' => [ 'label' => __( 'Learning', 'albert-ai-butler' ), 'description' => __( 'Learning management.', 'albert-ai-butler' ), ], 'maintenance' => [ 'label' => __( 'Maintenance', 'albert-ai-butler' ), 'description' => __( 'Site maintenance and monitoring.', 'albert-ai-butler' ), ], ]; // Register WooCommerce-specific categories when WooCommerce is active. if ( class_exists( 'WooCommerce' ) ) { $categories['woo-products'] = [ 'label' => __( 'Products', 'albert-ai-butler' ), 'description' => __( 'WooCommerce product management.', 'albert-ai-butler' ), ]; $categories['woo-orders'] = [ 'label' => __( 'Orders', 'albert-ai-butler' ), 'description' => __( 'WooCommerce order management.', 'albert-ai-butler' ), ]; $categories['woo-customers'] = [ 'label' => __( 'Customers', 'albert-ai-butler' ), 'description' => __( 'WooCommerce customer management.', 'albert-ai-butler' ), ]; } foreach ( $categories as $slug => $args ) { if ( ! wp_has_ability_category( $slug ) ) { wp_register_ability_category( $slug, $args ); } } } /** * Add an ability instance to the manager. * * @param BaseAbility $ability Ability instance. * * @return void * @since 1.0.0 */ public function add_ability( BaseAbility $ability ): void { $this->abilities[ $ability->get_id() ] = $ability; } /** * Register all abilities with WordPress. * * Registers every Albert-managed ability unconditionally. Disabled * abilities are removed from the global registry afterwards by * enforce_disabled() so the admin management page can still see them * via the same wp_get_abilities() API. * * @return void * @since 1.0.0 */ public function register_abilities(): void { foreach ( $this->abilities as $ability ) { $ability->register_ability(); } } /** * Remove disabled abilities from the global registry. * * Runs at PHP_INT_MAX on wp_abilities_api_init so every ability — Albert's * built-ins, abilities contributed by add-ons via `albert/abilities/register`, * and abilities registered directly by third-party plugins — is already * registered. We then walk the registry and strip out anything that should * not be executable in this request, so MCP, REST, the WP Abilities client, * and any other consumer all see only enabled abilities. * * The Albert → Abilities admin page intentionally keeps the full registry * (so the user can re-enable disabled abilities). is_abilities_management_context() * detects that page and short-circuits this method. * * Per ability we apply two checks in order: * 1. Effective disabled list (option + `albert/abilities/disabled_list` * filter). Applies to every ability regardless of who registered it, * so toggling a third-party ability off in Albert's UI removes it * globally and add-ons can extend the list at runtime. * 2. is_executable() pipeline, only for Albert-managed abilities. Lets * the `albert/abilities/is_executable` filter return a reasoned * WP_Error (e.g. licence-blocked) and unregister on that. * * @return void * @since 1.2.0 */ public function enforce_disabled(): void { if ( ! function_exists( 'wp_unregister_ability' ) ) { return; } if ( $this->is_abilities_management_context() ) { return; } $disabled_list = $this->get_effective_disabled_list(); // Count before unregistering anything. This is the only moment in a // normal request when the registry still holds every ability, and it is // what makes an honest "57 of 103" possible on screens that render // later. Counting afterwards gave "57 of 57" on the Dashboard: the // disabled ones were already gone, so the total and the enabled figure // were the same number counted twice. $this->ability_counts = $this->count_registry( $disabled_list ); // Raw registry, not wp_get_abilities(): an ability hidden from the filtered // view is still registered and still executable, so it must still be // unregistered here. See AbilitiesRegistry::get_all_raw(). foreach ( AbilitiesRegistry::get_all_raw() as $ability ) { $id = $ability->get_name(); // The MCP transport meta-tools must always stay registered — protocol // discovery and execution depend on them. Never unregister them, whatever // the disabled list, the fresh-install default, or the is_executable() // pipeline says. This is belt-and-braces on top of get_effective_disabled_list() // already stripping them from the blocklist. if ( AbilitiesRegistry::is_transport_ability( $id ) ) { continue; } if ( in_array( $id, $disabled_list, true ) ) { wp_unregister_ability( $id ); continue; } $albert_instance = $this->abilities[ $id ] ?? null; if ( $albert_instance instanceof BaseAbility ) { $check = $albert_instance->is_executable(); if ( is_wp_error( $check ) ) { wp_unregister_ability( $id ); } } } } /** * Self-heal the persisted disabled-abilities option. * * The MCP transport meta-tools (discover / get-info / execute) must never be * disabled — the whole MCP transport depends on them. A site upgraded from a * build that wrongly auto-disabled them can still carry their IDs in the * `albert_disabled_abilities` option; this actively removes any transport * meta-tool ID from that option so those sites get the transport back * automatically on this update, with no admin action. * * There is a version-keyed migration hook ({@see \Albert\Database\Installer::maybe_upgrade()}), * but it only fires when the plugin version advances, so it cannot guarantee * the heal on a release that ships this fix without a version bump. Instead this * runs as a cheap defensive scrub early in the abilities lifecycle (priority 1 * on the abilities-init hooks, before reconcile and enforce). It reads the * option once and writes ONLY when a transport meta-tool was actually present, * so the steady state performs no writes and there is no option churn. * * @return void * @since 1.3.0 */ public function heal_transport_tools(): void { $disabled = get_option( AbilitiesPage::DISABLED_ABILITIES_OPTION, [] ); if ( ! is_array( $disabled ) || $disabled === [] ) { return; } $disabled = array_map( 'strval', $disabled ); $scrubbed = array_values( array_filter( $disabled, static fn( string $id ): bool => ! AbilitiesRegistry::is_transport_ability( $id ) ) ); // Only write when a transport meta-tool was actually present, to avoid churn. if ( count( $scrubbed ) !== count( $disabled ) ) { update_option( AbilitiesPage::DISABLED_ABILITIES_OPTION, $scrubbed ); } } /** * Reconcile abilities added since the site last saved its toggles. * * Albert's fresh-install default gives an out-of-the-box starting point * (reads on, writes off). But once a site has saved its toggles, an upgrade * must not silently expand what a connected AI can reach: any ability added * later is absent from the persisted disabled list and would fall through to * enabled. Expanding the agent's reach — even a new read, which can expose a * new category of data — should be the admin's explicit choice. So on an * already-configured site this method disables EVERY newly-seen ability by * default (the admin opts in), without ever retroactively changing toggles * the admin already set. * * Keyed off the `albert_known_abilities` option (the set of ability IDs the * site has already accounted for): * * 1. Fresh install (`albert_abilities_saved` unset) → return; the existing * default fallback already covers it. * 2. Capture every currently registered ability ID. Runs before * enforce_disabled() prunes the registry, so the set is complete. * 3. Baseline (`albert_known_abilities` unset) → record the current set as * known and return WITHOUT touching the disabled list. The current state * is the baseline; we never retroactively re-disable. Also covers the * first reconcile right after the admin's first save. * 4. Compute the newly-seen IDs. None → return (no option writes in the * steady state). * 5. Disable ALL the new IDs by merging them into the persisted disabled * option, and flag them in a transient so the admin can be told. * 6. Fold the new IDs into the known set. * * Hooked on `wp_abilities_api_init` / `abilities_api_init` at * `PHP_INT_MAX - 1`. * * @return void * @since 1.2.0 */ public function reconcile_new_abilities(): void { if ( ! get_option( 'albert_abilities_saved' ) ) { return; } // Raw registry, not wp_get_abilities(): if a filter narrowed the view, the // abilities it removed would look "newly seen" the moment it stopped // applying and be auto-disabled behind the admin's back. $registered = []; foreach ( AbilitiesRegistry::get_all_raw() as $ability ) { $registered[] = $ability->get_name(); } $registered = array_values( array_unique( array_map( 'strval', $registered ) ) ); // No registry (Abilities API absent, or filtered away by the host) means we // cannot tell new abilities from missing ones. Stop before the baseline // write below records an empty known-set that would later mark every real // ability as newly seen. if ( $registered === [] ) { return; } $known = get_option( 'albert_known_abilities', null ); // Baseline: an already-configured site seeing this feature for the first // time (or the first reconcile right after the admin's first save). Record // the current set and stop — never retroactively disable. if ( $known === null ) { update_option( 'albert_known_abilities', $registered, false ); return; } $known = array_values( array_unique( array_map( 'strval', (array) $known ) ) ); $new = array_values( array_diff( $registered, $known ) ); // The MCP transport meta-tools must always stay enabled, so they are never // auto-disabled as "newly seen". Excluding them here means an update can // never add them to the disabled option (and, since they're left out of the // known set too, they simply stay ignored — no option churn either way). $new = array_values( array_filter( $new, static fn( string $id ): bool => ! AbilitiesRegistry::is_transport_ability( $id ) ) ); if ( $new === [] ) { return; } // Every newly-seen ability on an already-configured site is disabled by // default — the admin opts in. Expanding what the AI can reach (even a new // read, which may expose new data) requires explicit review. $disabled = (array) get_option( AbilitiesPage::DISABLED_ABILITIES_OPTION, [] ); $disabled = array_values( array_unique( array_merge( array_map( 'strval', $disabled ), $new ) ) ); update_option( AbilitiesPage::DISABLED_ABILITIES_OPTION, $disabled ); set_transient( 'albert_new_abilities_disabled', $new, DAY_IN_SECONDS ); update_option( 'albert_known_abilities', array_values( array_unique( array_merge( $known, $new ) ) ), false ); } /** * Show a one-time admin notice when an upgrade disabled new abilities. * * Reads the transient set by {@see self::reconcile_new_abilities()} and, when * present, tells the admin that newly-added abilities were disabled by default * for safety, linking to the Abilities page. The transient is cleared once the * notice has been rendered so it shows only once. The richer per-item review * UX belongs to the abilities-page redo, not here. * * Note: this follows the standard single-shot transient pattern — the first * `manage_options` user to load any admin page consumes it. On a multi-admin * site a second admin may not see the notice; the safe default state still * applies regardless, and the Abilities page remains the source of truth. * * @return void * @since 1.2.0 */ public function render_new_abilities_notice(): void { if ( ! current_user_can( 'manage_options' ) ) { return; } $disabled = get_transient( 'albert_new_abilities_disabled' ); if ( empty( $disabled ) || ! is_array( $disabled ) ) { return; } delete_transient( 'albert_new_abilities_disabled' ); $count = count( $disabled ); $url = menu_page_url( AbilitiesPage::PAGE_SLUG, false ); $message = sprintf( /* translators: %s: number of new abilities. */ _n( 'Albert added %s new ability and disabled it by default for safety.', 'Albert added %s new abilities and disabled them by default for safety.', $count, 'albert-ai-butler' ), number_format_i18n( $count ) ); ?>
Ability IDs that should not be executable. * @since 1.2.0 */ private function get_effective_disabled_list(): array { $disabled = get_option( AbilitiesPage::DISABLED_ABILITIES_OPTION, [] ); if ( empty( $disabled ) && ! get_option( 'albert_abilities_saved' ) ) { $disabled = AbilitiesRegistry::get_default_disabled_abilities(); } $disabled = array_values( array_unique( array_map( 'strval', (array) $disabled ) ) ); /** * Filters the effective list of disabled ability IDs. * * Lets add-ons contribute extra IDs to be unregistered for the current * request without writing to the persisted option. Useful for state * that changes per-request (e.g. licence/plan checks computed by an * add-on, time-of-day windows, kill switches). * * @since 1.2.0 * * @param array