[ '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 $disabled Ability IDs that should not be executable. */ $filtered = apply_filters( 'albert/abilities/disabled_list', $disabled ); $filtered = array_values( array_unique( array_map( 'strval', (array) $filtered ) ) ); // The MCP adapter's own tools must never be unregistered — doing so breaks // protocol discovery/execution. Strip them whatever the option, the // fresh-install default, or add-on filters say. return array_values( array_diff( $filtered, AbilitiesRegistry::get_protected_abilities() ) ); } /** * Add WordPress abilities to settings page. * * @param array> $abilities Existing abilities. * * @return array> Modified abilities. * @since 1.0.0 */ public function add_wordpress_abilities_to_settings( array $abilities ): array { foreach ( $this->abilities as $ability ) { $abilities[ $ability->get_id() ] = $ability->get_settings_data(); } return $abilities; } /** * Get all registered abilities. * * @return BaseAbility[] * @since 1.0.0 */ public function get_abilities(): array { return $this->abilities; } /** * Get a specific ability by ID. * * @param string $id Ability ID. * * @return BaseAbility|null * @since 1.0.0 */ public function get_ability( string $id ): ?BaseAbility { return $this->abilities[ $id ] ?? null; } /** * Resolve an ability ID to its human-readable label. * * Reads the label from the in-memory ability instances the manager holds, * which are populated during bootstrap independently of the WordPress * Abilities API registration timing. This lets callers resolve labels in * contexts where wp_get_ability() is not yet safe to call (e.g. admin pages * that render before the abilities registry is populated). * * Returns null for IDs the manager does not hold (e.g. third-party * abilities registered directly with WordPress), so callers can fall back * to another resolver. * * @param string $id Ability ID. * * @return string|null Human-readable label, or null when not held. * @since 1.2.0 */ public function get_label( string $id ): ?string { $ability = $this->abilities[ $id ] ?? null; return $ability?->get_label(); } /** * Determine whether the current request is the abilities management context. * * The Albert → Abilities admin page must always show every registered * ability, enabled or disabled, so the user can re-enable disabled ones. * On every other request, AbilitiesManager::enforce_disabled() removes * disabled abilities from the global registry. This helper tells the * enforcer to skip itself when the user is on the management page. * * Add-ons that ship admin pages listing abilities can opt themselves into * the same "show all" behaviour via the `albert/abilities/is_management_context` * filter — return true to suppress unregistration on that request. * * @return bool True when on a management context, false otherwise. * @since 1.2.0 */ private function is_abilities_management_context(): bool { // phpcs:disable WordPress.Security.NonceVerification.Recommended $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; $is_page = is_admin() && AbilitiesPage::PAGE_SLUG === $page; // phpcs:enable WordPress.Security.NonceVerification.Recommended /** * Filters whether the current request is an abilities management context. * * When true, AbilitiesManager::enforce_disabled() leaves the global * abilities registry untouched so admin UIs can list every ability. * Add-ons hook this to opt their own admin pages into the same * "show all" semantics without the core having to know about them. * * @since 1.2.0 * * @param bool $is_management_context Whether this request is a management context. */ return (bool) apply_filters( 'albert/abilities/is_management_context', $is_page ); } /** * Ensure all abilities are exposed via MCP. * * The mcp-adapter checks `meta.mcp.public` to determine if an ability * should be discoverable. This filter ensures all registered abilities * are exposed to MCP clients. * * Here we expose the registered core abilities too for the MCP so we can use them. * * @param array $args Ability arguments. * @param string $name Ability name. * * @return array Modified arguments. * @since 1.0.0 */ public function normalize_mcp_metadata( array $args, string $name ): array { if ( ! str_starts_with( $name, 'core/' ) ) { return $args; } if ( ! isset( $args['meta']['mcp'] ) ) { $args['meta']['mcp'] = []; } $args['meta']['mcp']['public'] = true; return $args; } /** * Wrap an ability's permission_callback with the Albert permission filter. * * Runs the ability's own permission_callback first (preserving its capability * / REST-delegated check as the baseline), then passes the result through the * internal `albert/abilities/check_permission` filter, which Albert Premium's * advanced permission rules use to gate per role or per user. Applies to EVERY * registered ability — Albert's, WooCommerce's, ACF's, and any third party — so * those rules gate the whole registry the admin screen lists. * * The callback runs in WP_Ability::check_permissions() with the connected user * already set (OAuth via TokenValidator), which is the meaningful "who" for MCP * calls. WordPress treats a permission_callback as denied unless it returns * exactly `true`, so add-ons must return `true` to allow or a WP_Error to deny. * * @param array $args Ability registration arguments. * @param string $name Ability name. * * @return array Modified arguments. * @since 1.3.0 */ public function wrap_permission_callback( array $args, string $name ): array { $original = $args['permission_callback'] ?? null; $args['permission_callback'] = static function ( $input = null ) use ( $original, $name ) { $result = is_callable( $original ) ? call_user_func( $original, $input ) : true; /** * Filters the permission result for an ability. * * @internal Internal seam for Albert's own permission layer (Albert * Premium's advanced permission rules). Not a public extension point — * it may change or be removed without notice; third-party code should * not rely on it. * * The baseline `$result` is the ability's own permission_callback output * (true or WP_Error). A callback returns `true` to allow or a WP_Error to * deny; any other value is treated as denied by WordPress. Runs with the * connected user set, so per-user rules can evaluate against * `get_current_user_id()` and its roles. * * @since 1.3.0 * * @param bool|\WP_Error $result Baseline permission result. * @param string $ability_id Ability identifier. * @param int $user_id Current user ID. */ return apply_filters( 'albert/abilities/check_permission', $result, $name, get_current_user_id() ); }; return $args; } /** * How many abilities this site has, and how many are switched on. * * Prefer this over counting {@see AbilitiesRegistry::get_all_raw()} on an * admin screen. That registry is pruned by {@see self::enforce_disabled()} * on every request except the Abilities page, so a screen counting it * later sees only the enabled ones and reports every ability as enabled. * * The MCP transport meta-tools are excluded, matching * {@see \Albert\Admin\AbilitiesPayload::build()}. They cannot be switched * off and are hidden from the Abilities screen, so counting them here would * make two screens disagree about the same site by exactly three. * * @since 1.4.0 * * @return array{total: int, enabled: int} */ public function get_ability_counts(): array { if ( $this->ability_counts !== null ) { return $this->ability_counts; } $this->prime_registry(); if ( $this->ability_counts !== null ) { return $this->ability_counts; } // Still nothing means enforcement did not prune (the Abilities page, or // a WordPress without wp_unregister_ability), so the registry is whole // and safe to count directly. return $this->count_registry( $this->get_effective_disabled_list() ); } /** * Make sure the abilities registry has been built for this request. * * Reading the registry is what fires `wp_abilities_api_init`, and * {@see self::enforce_disabled()} rides that action, so this call is also * what causes the count snapshot to be taken. Without it, the fallback in * {@see self::get_ability_counts()} triggers the pruning itself and then * counts what is left: every ability looks enabled, because the disabled * ones were unregistered a moment earlier by the very call meant to count * them. * * A method on `$this` rather than the static call it wraps, so the side * effect on `$this->ability_counts` is visible where it happens. * * @since 1.4.0 * * @return void */ private function prime_registry(): void { AbilitiesRegistry::get_all_raw(); } /** * Count the registry as it stands right now. * * @since 1.4.0 * * @param array $disabled_list Effective disabled ability ids. * * @return array{total: int, enabled: int} */ private function count_registry( array $disabled_list ): array { $total = 0; $enabled = 0; foreach ( AbilitiesRegistry::get_all_raw() as $ability ) { $id = $ability->get_name(); if ( AbilitiesRegistry::is_transport_ability( $id ) ) { continue; } ++$total; if ( in_array( $id, $disabled_list, true ) ) { continue; } // enforce_disabled() unregisters on two grounds, not one: the // disabled list, and an is_executable() refusal — the hook add-ons // use for licence validity, plan tier and kill switches. Counting // only the first over-reported "enabled" on any site whose Premium // licence had lapsed: the ability was gone from the registry a few // lines later, and the tile still claimed it was on. $instance = $this->abilities[ $id ] ?? null; if ( $instance instanceof BaseAbility && is_wp_error( $instance->is_executable() ) ) { continue; } ++$enabled; } return [ 'total' => $total, 'enabled' => $enabled, ]; } }