`; disabled abilities are not * registered at all (invisible to agents). Thin wrappers — every callback * funnels straight into Agent_Toolbelt::execute(), which owns the guardrail * and the audit log. */ // Exit if accessed directly. if ( ! defined( 'ABSPATH' ) ) { exit; } if ( ! class_exists( 'Agent_Toolbelt_Abilities' ) ) { class Agent_Toolbelt_Abilities { /** * Ability namespace prefix (matches the plugin slug). */ const NAMESPACE_PREFIX = 'agent-toolbelt/'; /** * Registers all enabled abilities. No-op on WordPress without the * Abilities API, or when the kill switch is active. */ public static function register() { if ( ! function_exists( 'wp_register_ability' ) ) { return; } if ( Agent_Toolbelt::is_kill_switch_active() ) { return; } // get_operations() already hides disabled abilities (spec §2.6). foreach ( Agent_Toolbelt::get_operations() as $atb_slug => $atb_operation ) { self::register_one( $atb_slug, $atb_operation ); } } /** * Registers a single operation as an ability. * * @param string $atb_slug * @param ATB_Operation $atb_operation */ protected static function register_one( $atb_slug, ATB_Operation $atb_operation ) { $atb_meta = $atb_operation->describe(); // Complete the annotation triple. The MCP Adapter maps the // WordPress-format keys readonly/destructive/idempotent onto the // MCP ToolAnnotations *Hint fields but drops null/absent ones, so // a partial declaration loses the safety signal on that transport. // Declared keys always win; the fallbacks assume a read-only op is // harmless and an undeclared mutating op is not. $atb_annotations = isset( $atb_meta['annotations'] ) && is_array( $atb_meta['annotations'] ) ? $atb_meta['annotations'] : array(); $atb_is_readonly = ! empty( $atb_annotations['readonly'] ); $atb_annotations += array( 'readonly' => false, 'destructive' => ! $atb_is_readonly, 'idempotent' => $atb_is_readonly, ); wp_register_ability( self::NAMESPACE_PREFIX . $atb_slug, array( 'label' => self::label_for( $atb_slug ), 'description' => isset( $atb_meta['summary'] ) ? (string) $atb_meta['summary'] : '', 'category' => 'site', 'input_schema' => self::input_schema( $atb_meta ), 'output_schema' => self::output_schema(), 'execute_callback' => static function ( $atb_input = array() ) use ( $atb_slug ) { return Agent_Toolbelt::execute( $atb_slug, is_array( $atb_input ) ? $atb_input : array() ); }, 'permission_callback' => static function () use ( $atb_operation ) { return Agent_Toolbelt_Abilities::user_can_run( $atb_operation ); }, 'meta' => array( 'show_in_rest' => true, 'annotations' => $atb_annotations, // The official MCP Adapter's default server only exposes // abilities flagged meta.mcp.public; permission_callback // still gates every call on every transport. The filter // lets a site keep an ability REST-only. 'mcp' => array( 'public' => (bool) apply_filters( 'agent_toolbelt_mcp_public', true, $atb_slug ), ), ), ) ); } /** * Permission gate (spec §2.5): every operation needs `manage_options`; * an operation may declare one extra required capability in * describe()['capability'] (update-plugin declares `update_plugins` — * a domain cap, not a blanket high-risk one: on hosts with * DISALLOW_FILE_MODS that cap is denied to everyone, which must block * plugin updates but not unrelated high-risk operations like * cleanup-database). Core's WP_Ability::execute() enforces this before * our callback runs. * * @param ATB_Operation $atb_operation * @return bool */ public static function user_can_run( ATB_Operation $atb_operation ) { if ( ! current_user_can( 'manage_options' ) ) { return false; } $atb_meta = $atb_operation->describe(); if ( ! empty( $atb_meta['capability'] ) && ! current_user_can( (string) $atb_meta['capability'] ) ) { return false; } return true; } /** * Builds the ability input schema from the operation's own schema, * injecting the guardrail fields: `dry_run` for any mutating (non * read-only) operation, and `confirm` for high-risk ones. * * @param array $atb_meta describe() output. * @return array */ protected static function input_schema( $atb_meta ) { $atb_schema = isset( $atb_meta['input_schema'] ) && is_array( $atb_meta['input_schema'] ) ? $atb_meta['input_schema'] : array( 'type' => 'object', 'properties' => array() ); if ( ! isset( $atb_schema['properties'] ) || ! is_array( $atb_schema['properties'] ) ) { $atb_schema['properties'] = array(); } $atb_annotations = isset( $atb_meta['annotations'] ) && is_array( $atb_meta['annotations'] ) ? $atb_meta['annotations'] : array(); $atb_readonly = ! empty( $atb_annotations['readonly'] ); $atb_high = isset( $atb_meta['risk'] ) && 'high' === $atb_meta['risk']; if ( ! $atb_readonly && ! isset( $atb_schema['properties']['dry_run'] ) ) { $atb_schema['properties']['dry_run'] = array( 'type' => 'boolean', 'description' => 'Preview the operation without changing anything.', 'default' => $atb_high, ); } if ( $atb_high && ! isset( $atb_schema['properties']['confirm'] ) ) { $atb_schema['properties']['confirm'] = array( 'type' => 'string', 'description' => 'The confirm token returned by a prior dry run. Required to execute.', ); } return $atb_schema; } /** * The common mutating-output shape (spec §2.1), shared by every ability. * * @return array */ protected static function output_schema() { return array( 'type' => 'object', 'properties' => array( 'performed' => array( 'type' => 'boolean' ), 'dry_run' => array( 'type' => 'boolean' ), 'details' => array( 'type' => 'object' ), 'warnings' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ), ), ), ); } /** * Short human label for the admin/REST listing, per slug. * * @param string $atb_slug * @return string */ protected static function label_for( $atb_slug ) { $atb_labels = array( 'purge-all-caches' => __( 'Purge all caches', 'agent-toolbelt' ), 'flush-rewrite-rules' => __( 'Flush rewrite rules', 'agent-toolbelt' ), 'site-status' => __( 'Site status', 'agent-toolbelt' ), 'maintenance-mode' => __( 'Maintenance mode', 'agent-toolbelt' ), 'update-plugin' => __( 'Update plugin', 'agent-toolbelt' ), 'read-audit-log' => __( 'Read audit log', 'agent-toolbelt' ), 'regenerate-thumbnails' => __( 'Regenerate thumbnails', 'agent-toolbelt' ), 'cleanup-database' => __( 'Clean up database', 'agent-toolbelt' ), 'inspect-cron' => __( 'Inspect cron', 'agent-toolbelt' ), 'verify-checksums' => __( 'Verify checksums', 'agent-toolbelt' ), 'run-site-health-checks' => __( 'Run Site Health checks', 'agent-toolbelt' ), 'list-available-updates' => __( 'List available updates', 'agent-toolbelt' ), 'read-debug-log' => __( 'Read debug log', 'agent-toolbelt' ), ); return isset( $atb_labels[ $atb_slug ] ) ? $atb_labels[ $atb_slug ] : $atb_slug; } } }