Agent Toolbelt (spec §5). Shows the ability checklist * (enable/disable, persisted to the plugin option), the audit-log table, and a * status line. The checklist is the only setting in v1 — defaults are the * product. */ // Exit if accessed directly. if ( ! defined( 'ABSPATH' ) ) { exit; } if ( ! class_exists( 'Agent_Toolbelt_Admin' ) ) { class Agent_Toolbelt_Admin { const NONCE_ACTION = 'agent_toolbelt_save'; const NONCE_FIELD = 'agent_toolbelt_nonce'; const AUDIT_LIMIT = 20; const RUN_NONCE_ACTION = 'agent_toolbelt_run'; const RUN_NONCE_FIELD = 'agent_toolbelt_run_nonce'; /** * Adds the Settings -> Agent Toolbelt page. */ public static function register_menu() { add_options_page( __( 'Agent Toolbelt', 'agent-toolbelt' ), __( 'Agent Toolbelt', 'agent-toolbelt' ), 'manage_options', 'agent-toolbelt', array( __CLASS__, 'render_page' ) ); } /** * Adds a Settings action link on the Plugins screen, pointing to the * Settings -> Agent Toolbelt page (plugin_action_links_* filter). Kept * even while the kill switch is active — the page itself is where the * owner sees that state. * * @param array $atb_links Existing action links (Deactivate, ...). * @return array */ public static function action_links( $atb_links ) { $atb_settings = '' . esc_html__( 'Settings', 'agent-toolbelt' ) . ''; array_unshift( $atb_links, $atb_settings ); return $atb_links; } /** * The page tabs (v1.4 UX): everything a fresh install needs to feel * safe lives on the default Abilities tab; connecting an agent is * deliberately last and optional. * * @return array slug => label. */ public static function tabs() { return array( 'abilities' => __( 'Abilities', 'agent-toolbelt' ), 'run' => __( 'Run', 'agent-toolbelt' ), 'activity' => __( 'Audit log', 'agent-toolbelt' ), 'connect' => __( 'Connect an agent', 'agent-toolbelt' ), ); } /** * The active tab from the URL, whitelisted against tabs(). * * @return string */ public static function current_tab() { // Read-only display routing — no state changes. // phpcs:ignore WordPress.Security.NonceVerification.Recommended $atb_tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'abilities'; return array_key_exists( $atb_tab, self::tabs() ) ? $atb_tab : 'abilities'; } /** * Renders the admin page: status strip, tab bar, and the active tab. */ public static function render_page() { if ( ! current_user_can( 'manage_options' ) ) { return; } $atb_saved = self::maybe_save(); $atb_run = self::maybe_run(); // A POST decides where the user lands, whatever the URL says. $atb_tab = self::current_tab(); if ( null !== $atb_run ) { $atb_tab = 'run'; } elseif ( $atb_saved ) { $atb_tab = 'abilities'; } echo '
'; echo '

' . esc_html__( 'Agent Toolbelt', 'agent-toolbelt' ) . '

'; if ( $atb_saved ) { echo '

' . esc_html__( 'Abilities updated.', 'agent-toolbelt' ) . '

'; } self::render_status_strip(); self::render_tabs( $atb_tab ); switch ( $atb_tab ) { case 'run': self::render_run_panel( $atb_run ); break; case 'activity': self::render_audit_log(); break; case 'connect': self::render_connect(); break; default: self::render_checklist(); break; } echo '
'; } /** * Renders the tab bar. * * @param string $atb_active Active tab slug. */ protected static function render_tabs( $atb_active ) { echo ''; } /** * Persists the checklist on a valid POST. Only toggles the operations * shown in the form; any disabled slug that is not currently a * registered operation (e.g. an ability shipped in a later version) is * preserved, so its opt-in default is never silently cleared. * * @return bool True when a save happened. */ protected static function maybe_save() { $atb_method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : ''; if ( 'POST' !== $atb_method ) { return false; } if ( ! isset( $_POST[ self::NONCE_FIELD ] ) || ! check_admin_referer( self::NONCE_ACTION, self::NONCE_FIELD ) ) { return false; } if ( ! current_user_can( 'manage_options' ) ) { return false; } $atb_shown = array_keys( Agent_Toolbelt::get_operations( true ) ); $atb_checked = isset( $_POST['agent_toolbelt_enabled'] ) ? array_map( 'sanitize_key', (array) wp_unslash( $_POST['agent_toolbelt_enabled'] ) ) : array(); // A shown operation left unchecked becomes disabled. $atb_disabled = array_values( array_diff( $atb_shown, $atb_checked ) ); // Preserve disabled entries for slugs not shown in this form. $atb_options = Agent_Toolbelt::get_options(); $atb_existing = isset( $atb_options['disabled_abilities'] ) ? (array) $atb_options['disabled_abilities'] : array(); $atb_preserved = array_diff( $atb_existing, $atb_shown ); $atb_options['disabled_abilities'] = array_values( array_unique( array_merge( $atb_disabled, $atb_preserved ) ) ); // Safety settings (v1.1 spec §2.1, §2.4). The observe checkbox is // not saved while the constant forces the mode on (it is disabled // in the form; an absent field must not silently flip the option). if ( ! Agent_Toolbelt::is_observe_mode_forced() ) { $atb_options['observe_mode'] = ! empty( $_POST['agent_toolbelt_observe'] ); } $atb_options['notify_high_risk'] = ! empty( $_POST['agent_toolbelt_notify'] ); update_option( Agent_Toolbelt::OPTION_NAME, $atb_options ); return true; } /** * The always-visible status strip (v1.4 UX). One glance answers the * question a fresh install actually has — "am I safe right now?" — * and abnormal states (kill switch, observe mode, missing Abilities * API) surface here instead of a permanent wall of status prose. */ protected static function render_status_strip() { if ( Agent_Toolbelt::is_kill_switch_active() ) { echo '

' . esc_html__( 'Kill switch active.', 'agent-toolbelt' ) . ' ' . esc_html__( 'The AGENT_TOOLBELT_DISABLED constant is set: no abilities are registered and agents cannot call anything.', 'agent-toolbelt' ) . '

'; return; } $atb_total = 0; $atb_enabled = 0; $atb_high_total = 0; $atb_high_on = 0; foreach ( Agent_Toolbelt::get_operations( true ) as $atb_slug => $atb_operation ) { $atb_meta = $atb_operation->describe(); $atb_total++; $atb_is_enabled = ! Agent_Toolbelt::is_ability_disabled( $atb_slug ); if ( $atb_is_enabled ) { $atb_enabled++; } if ( isset( $atb_meta['risk'] ) && 'high' === $atb_meta['risk'] ) { $atb_high_total++; if ( $atb_is_enabled ) { $atb_high_on++; } } } $atb_parts = array(); $atb_parts[] = sprintf( /* translators: 1: enabled ability count, 2: total ability count. */ __( '%1$d of %2$d abilities enabled', 'agent-toolbelt' ), $atb_enabled, $atb_total ); if ( 0 === $atb_high_on ) { $atb_parts[] = sprintf( /* translators: %d: number of high-risk abilities. */ __( 'all %d high-risk abilities are OFF', 'agent-toolbelt' ), $atb_high_total ); } else { $atb_parts[] = sprintf( /* translators: 1: enabled high-risk count, 2: total high-risk count. */ __( '%1$d of %2$d high-risk abilities are ON — each real run still requires a fresh dry-run confirm token', 'agent-toolbelt' ), $atb_high_on, $atb_high_total ); } $atb_parts[] = __( 'every call is recorded in the audit log', 'agent-toolbelt' ); echo '

' . esc_html__( 'Safe by default — nothing to configure.', 'agent-toolbelt' ) . ' ' . esc_html( implode( ' · ', $atb_parts ) . '.' ) . '

'; if ( Agent_Toolbelt::is_observe_mode() ) { echo '

' . esc_html__( 'Observe mode is ON.', 'agent-toolbelt' ) . ' ' . esc_html__( 'Agents can look (read-only abilities and dry-runs) but cannot change anything.', 'agent-toolbelt' ) . '

'; } if ( ! function_exists( 'wp_register_ability' ) ) { echo '

' . esc_html__( 'This site does not expose the WordPress Abilities API (requires WordPress 6.9+), so network agents cannot connect. The Run tab and the WP-CLI commands still work.', 'agent-toolbelt' ) . '

'; } } /** * Splits an ability summary into a lead sentence and the rest, so the * checklist shows one line per ability with the details collapsed. * The split point must start a real sentence (next chunk begins with * an uppercase letter, digit, or parenthesis), so "e.g. foo" and * "wp-content/debug.log" never split. * * @param string $atb_text * @return array { 0: lead sentence, 1: remainder ('' when none) }. */ public static function split_summary( $atb_text ) { $atb_text = trim( (string) $atb_text ); if ( preg_match( '/^(.{20,}?[.!?])\s+(?=[A-Z0-9("])/s', $atb_text, $atb_match ) ) { $atb_rest = trim( substr( $atb_text, strlen( $atb_match[0] ) ) ); if ( '' !== $atb_rest ) { return array( $atb_match[1], $atb_rest ); } } return array( $atb_text, '' ); } /** * The checklist groups, worst last (v1.4 UX): a fresh install reads * "read-only" first and sees the scary group explicitly OFF. * * @param string $atb_risk describe()['risk']. * @return string Group key: read|maintenance|high. */ protected static function checklist_group( $atb_risk ) { if ( 'high' === $atb_risk ) { return 'high'; } return ( 'none' === $atb_risk ) ? 'read' : 'maintenance'; } /** * Ability checklist: grouped by risk, one-line descriptions with the * rest collapsed, a Run shortcut per enabled ability, and the safety * settings in the same form. */ protected static function render_checklist() { echo '

' . esc_html__( 'Defaults are safe — you don\'t have to change anything here. Untick what agents may not use, tick what you trust them with, then save. The Run button tries an ability yourself, without any agent.', 'agent-toolbelt' ) . '

'; $atb_group_labels = array( 'read' => __( 'Read-only — these can never change the site', 'agent-toolbelt' ), 'maintenance' => __( 'Maintenance — reversible changes with guardrails', 'agent-toolbelt' ), 'high' => __( 'High-risk — OFF by default; a real run always needs a dry run + confirm token first', 'agent-toolbelt' ), ); $atb_groups = array( 'read' => array(), 'maintenance' => array(), 'high' => array(), ); foreach ( Agent_Toolbelt::get_operations( true ) as $atb_slug => $atb_operation ) { $atb_meta = $atb_operation->describe(); $atb_risk = isset( $atb_meta['risk'] ) ? (string) $atb_meta['risk'] : 'none'; $atb_groups[ self::checklist_group( $atb_risk ) ][ $atb_slug ] = $atb_meta; } echo '
'; wp_nonce_field( self::NONCE_ACTION, self::NONCE_FIELD ); echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; foreach ( $atb_groups as $atb_group => $atb_rows ) { if ( empty( $atb_rows ) ) { continue; } echo ''; foreach ( $atb_rows as $atb_slug => $atb_meta ) { $atb_enabled = ! Agent_Toolbelt::is_ability_disabled( $atb_slug ); $atb_risk = isset( $atb_meta['risk'] ) ? (string) $atb_meta['risk'] : 'none'; $atb_summary = isset( $atb_meta['summary'] ) ? (string) $atb_meta['summary'] : ''; list( $atb_lead, $atb_more ) = self::split_summary( $atb_summary ); echo ''; echo ''; echo ''; echo ''; echo ''; if ( $atb_enabled ) { $atb_run_url = add_query_arg( array( 'page' => 'agent-toolbelt', 'tab' => 'run', 'ability' => $atb_slug, ), admin_url( 'options-general.php' ) ); echo ''; } else { echo ''; } echo ''; } } echo '
' . esc_html__( 'Enabled', 'agent-toolbelt' ) . '' . esc_html__( 'Ability', 'agent-toolbelt' ) . '' . esc_html__( 'Risk', 'agent-toolbelt' ) . '' . esc_html__( 'What it does', 'agent-toolbelt' ) . '' . esc_html__( 'Run', 'agent-toolbelt' ) . '
' . esc_html( $atb_group_labels[ $atb_group ] ) . '
' . esc_html( Agent_Toolbelt_Abilities::NAMESPACE_PREFIX . $atb_slug ) . '' . wp_kses( self::risk_badge( $atb_risk ), array( 'span' => array( 'style' => true ) ) ) . '' . esc_html( $atb_lead ); if ( '' !== $atb_more ) { echo '
' . esc_html__( 'More', 'agent-toolbelt' ) . '' . '' . esc_html( $atb_more ) . '
'; } echo '
' . esc_html__( 'Run', 'agent-toolbelt' ) . '
'; self::render_safety_settings(); submit_button( __( 'Save abilities', 'agent-toolbelt' ) ); echo '
'; } /** * Safety settings inside the same form (v1.1 spec §2.1, §2.4). */ protected static function render_safety_settings() { $atb_options = Agent_Toolbelt::get_options(); $atb_forced = Agent_Toolbelt::is_observe_mode_forced(); $atb_observe = Agent_Toolbelt::is_observe_mode(); $atb_notify = ! empty( $atb_options['notify_high_risk'] ); echo '

' . esc_html__( 'Safety', 'agent-toolbelt' ) . '

'; echo '

'; if ( $atb_forced ) { echo ' ' . esc_html__( '(forced on by the AGENT_TOOLBELT_OBSERVE constant)', 'agent-toolbelt' ) . ''; } echo '

'; echo '

'; if ( Agent_Toolbelt::is_rate_limit_bypassed() ) { echo '

' . esc_html__( 'Rate budget is bypassed by the AGENT_TOOLBELT_NO_RATE_LIMIT constant.', 'agent-toolbelt' ) . '

'; } } /** * "Connect an agent" tab (v1.2 spec §3.2, reframed optional in v1.4): * a plain-language router first — an agent that already works on the * server needs nothing here — then the REST quick start, the core * application-password authorization link, MCP status, and a copyable * briefing block. Strictly informational — this section never creates * credentials, accepts secrets, or executes anything. */ protected static function render_connect() { $atb_abilities_url = rest_url( 'wp-abilities/v1/abilities' ); $atb_ro_run_url = rest_url( 'wp-abilities/v1/abilities/agent-toolbelt/site-status/run' ); $atb_mut_run_url = rest_url( 'wp-abilities/v1/abilities/agent-toolbelt/purge-all-caches/run' ); echo '

' . esc_html__( 'Connect an agent (optional)', 'agent-toolbelt' ) . '

'; echo '

' . esc_html__( 'You only need this tab if you want an AI agent to operate this site. Which of these is your situation?', 'agent-toolbelt' ) . '

'; echo '

' . esc_html__( 'My agent already works on this server (SSH / WP-CLI)', 'agent-toolbelt' ) . '

'; echo '

' . esc_html__( 'Nothing to set up — no password needed. Agents like Claude Code with shell access can call the same guarded abilities through the built-in WP-CLI commands; every guardrail, dry-run, and audit-log entry applies exactly as it does over REST:', 'agent-toolbelt' ) . '

'; echo '

wp agent-toolbelt list
'; echo 'wp agent-toolbelt run site-status
'; echo '' . esc_html( 'wp agent-toolbelt run purge-all-caches --input=\'{"dry_run":true}\'' ) . '

'; echo '

' . esc_html__( 'The briefing in step 3 below is still worth pasting into your agent\'s instructions — it explains the dry-run/confirm rules.', 'agent-toolbelt' ) . '

'; echo '

' . esc_html__( 'My agent connects over the network (desktop or cloud agent)', 'agent-toolbelt' ) . '

'; echo '

' . esc_html__( 'Then it needs its own credentials for the REST API — the three steps below.', 'agent-toolbelt' ) . '

'; // 1. Application password — the core flow; the secret is created and // shown by WordPress itself, never by this plugin. echo '

' . esc_html__( '1. Give your agent credentials', 'agent-toolbelt' ) . '

'; echo '

' . esc_html__( 'An application password is WordPress\'s standard mechanism for exactly this: a separate password that only works for the REST API (it cannot be used to log in to wp-admin) and that you can revoke at any time without changing your real password.', 'agent-toolbelt' ) . '

'; if ( wp_is_application_passwords_available() ) { $atb_authorize_url = add_query_arg( array( 'app_name' => rawurlencode( __( 'AI agent (Agent Toolbelt)', 'agent-toolbelt' ) ) ), admin_url( 'authorize-application.php' ) ); echo '

'; echo '' . esc_html__( 'Create an application password', 'agent-toolbelt' ) . ''; echo '

'; echo '

' . esc_html__( 'WordPress will show the password once — give it to your agent together with your username. You can revoke it any time on your profile page.', 'agent-toolbelt' ) . '

'; } else { echo '

' . esc_html__( 'Application passwords are not available on this site (WordPress requires HTTPS for them unless the environment is local). Enable HTTPS, then create one under Users → Profile.', 'agent-toolbelt' ) . '

'; } // 2. Endpoints. echo '

' . esc_html__( '2. Endpoints', 'agent-toolbelt' ) . '

'; echo '

' . esc_html__( 'REST (works everywhere, no extra plugins):', 'agent-toolbelt' ) . '

'; echo '

' . esc_html( 'curl -u "USER:APP_PASSWORD" ' . $atb_abilities_url ) . '
'; echo '' . esc_html( 'curl -u "USER:APP_PASSWORD" "' . $atb_ro_run_url . '"' ) . '
'; echo '' . esc_html( 'curl -u "USER:APP_PASSWORD" -X POST -H "Content-Type: application/json" -d \'{"input":{"dry_run":true}}\' ' . $atb_mut_run_url ) . '

'; if ( self::mcp_adapter_active() ) { echo '

' . esc_html__( 'MCP: the official WordPress MCP Adapter is active — your enabled abilities are published to its default MCP server:', 'agent-toolbelt' ) . '
'; echo '' . esc_html( rest_url( 'mcp/mcp-adapter-default-server' ) ) . ' '; echo esc_html__( '(streamable HTTP, same application-password auth; or serve over STDIO with `wp mcp serve`).', 'agent-toolbelt' ) . '

'; } else { echo '

' . esc_html__( 'MCP: install the official WordPress MCP Adapter plugin to expose the enabled abilities as an MCP server — they are already flagged for it. Any other MCP bridge that reads WordPress Abilities works too.', 'agent-toolbelt' ) . '

'; } echo '

' . esc_html__( 'Whatever the transport, agents only see the abilities enabled on the Abilities tab — everything else is invisible to them.', 'agent-toolbelt' ) . '

'; // 3. Copyable briefing. echo '

' . esc_html__( '3. Brief your agent', 'agent-toolbelt' ) . '

'; echo '

' . esc_html__( 'Paste this into your agent\'s instructions (system prompt, CLAUDE.md, or similar):', 'agent-toolbelt' ) . '

'; echo '
'; echo ''; echo ''; echo ''; } /** * The agent-facing briefing with the site's real URLs. Deliberately * plain text: owners paste it into an agent's instruction file. * * @return string */ protected static function agent_briefing_text() { $atb_lines = array( sprintf( 'You can operate the WordPress site %s through Agent Toolbelt (guarded maintenance abilities).', home_url( '/' ) ), '', 'Discovery: GET ' . rest_url( 'wp-abilities/v1/abilities' ) . ' lists every ability you may use; abilities named agent-toolbelt/* are the guarded operations. Only what the site owner enabled is visible.', '', 'Rules:', '1. Call agent-toolbelt/site-status first: versions, pending updates, enabled abilities, and whether observe mode is on.', '2. Preview before changing: every mutating ability accepts "dry_run": true. High-risk abilities REQUIRE a dry-run first — it returns a one-time confirm token you must send back to execute the same input.', '3. Refusals are answers, not obstacles. {"details":{"refused":true,"reason":"..."}} tells you why: observe_mode (site is read-only for agents), rate_budget (wait retry_after_s seconds), confirm_token (redo the dry-run). Report refusals to the owner; never retry blindly.', '4. Everything you do lands in an audit log the owner reads. agent-toolbelt/read-audit-log shows it to you too — use it to explain your past actions.', '', 'REST calls: read-only abilities use GET (' . rest_url( 'wp-abilities/v1/abilities/agent-toolbelt/site-status/run' ) . '); pass GET input as PHP-array query params, e.g. ?input[limit]=3 — bare query args like ?limit=3 are silently ignored. Mutating abilities use POST with {"input":{...}} (' . rest_url( 'wp-abilities/v1/abilities/agent-toolbelt/purge-all-caches/run' ) . '). Authenticate with the application password (HTTP Basic auth).', '', 'Shell access instead? The same abilities (same guardrails, same audit log) are available via WP-CLI: wp agent-toolbelt list · wp agent-toolbelt run --input=\'{"dry_run":true}\' · wp agent-toolbelt log.', ); if ( self::mcp_adapter_active() ) { $atb_lines[] = ''; $atb_lines[] = 'MCP: the same abilities are exposed on the default MCP server at ' . rest_url( 'mcp/mcp-adapter-default-server' ) . ' (streamable HTTP; send the Mcp-Session-Id header from initialize on every later call).'; } return implode( "\n", $atb_lines ); } /* ---------------------------------------------------------------- * "Run an ability" panel (v1.4 plan §2) — the owner-side runner. * Runs through the SAME pipeline agents use: when the Abilities API * is present the call goes through wp_get_ability()->execute() (so * schema validation and the permission callback fire exactly as they * do for agents); guardrails (dry-run, confirm tokens, budgets, * observe mode, high-risk email) apply unchanged, and the run lands * in the audit log below with caller `admin`. * -------------------------------------------------------------- */ /** * Handles the Run-panel POST. Returns null when this request is not a * run submission; otherwise a render context: * { slug, input_raw, error } or { slug, input, input_raw, result }. * * @return array|null */ protected static function maybe_run() { $atb_method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : ''; if ( 'POST' !== $atb_method || ! isset( $_POST[ self::RUN_NONCE_FIELD ] ) ) { return null; } if ( ! check_admin_referer( self::RUN_NONCE_ACTION, self::RUN_NONCE_FIELD ) ) { return null; } if ( ! current_user_can( 'manage_options' ) ) { return null; } // Field-level sanitization happens in parse_run_request (slug is // validated against the operation registry, input must parse as a // JSON object); the raw JSON text itself is data, not markup. // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $atb_parsed = self::parse_run_request( wp_unslash( $_POST ) ); if ( isset( $atb_parsed['error'] ) ) { return $atb_parsed; } $atb_result = self::execute_for_admin( $atb_parsed['slug'], $atb_parsed['input'] ); return array( 'slug' => $atb_parsed['slug'], 'input' => $atb_parsed['input'], 'input_raw' => self::operative_input_json( $atb_parsed['input'] ), 'result' => $atb_result, ); } /** * Validates a Run-panel submission (already unslashed). Pure enough * for unit tests: no nonce/capability concerns here. * * The dry-run checkbox is the single source of truth for `dry_run` — * any dry_run/confirm keys inside the JSON are dropped first. The * hidden execute-for-real form posts atb_run_execute=1 plus the * confirm token from the preceding dry run. * * @param array $atb_post POST fields. * @return array { slug, input } or { slug, input_raw, error }. */ public static function parse_run_request( $atb_post ) { $atb_slug = isset( $atb_post['atb_run_ability'] ) ? (string) $atb_post['atb_run_ability'] : ''; if ( null === Agent_Toolbelt::get_operation( $atb_slug ) || Agent_Toolbelt::is_ability_disabled( $atb_slug ) ) { return array( 'slug' => $atb_slug, 'input_raw' => '{}', 'error' => __( 'Unknown or disabled ability.', 'agent-toolbelt' ), ); } $atb_raw = isset( $atb_post['atb_run_input'] ) ? trim( (string) $atb_post['atb_run_input'] ) : ''; $atb_input = array(); if ( '' !== $atb_raw ) { $atb_decoded = json_decode( $atb_raw, true ); if ( ! is_array( $atb_decoded ) ) { return array( 'slug' => $atb_slug, 'input_raw' => $atb_raw, 'error' => __( 'Input must be a JSON object, e.g. {"limit": 5}.', 'agent-toolbelt' ), ); } $atb_input = $atb_decoded; } unset( $atb_input['dry_run'], $atb_input['confirm'] ); if ( ! empty( $atb_post['atb_run_execute'] ) ) { // Execute-for-real follow-up: same input + the issued token. $atb_input['dry_run'] = false; $atb_confirm = isset( $atb_post['atb_run_confirm'] ) ? (string) $atb_post['atb_run_confirm'] : ''; if ( '' !== $atb_confirm ) { $atb_input['confirm'] = $atb_confirm; } } else { $atb_input['dry_run'] = ! empty( $atb_post['atb_run_dry'] ); } return array( 'slug' => $atb_slug, 'input' => $atb_input, ); } /** * Executes through the ability layer when available so the owner hits * the exact agent pipeline (schema validation included); on WordPress * without the Abilities API (< 6.9) the operation layer is called * directly — the guardrail lives in Agent_Toolbelt::execute() either * way, only schema validation is skipped there. * * @param string $atb_slug * @param array $atb_input * @return array|WP_Error Ability/common output, or WP_Error exactly as * an agent would receive it. */ public static function execute_for_admin( $atb_slug, $atb_input ) { if ( function_exists( 'wp_get_ability' ) ) { $atb_ability = wp_get_ability( Agent_Toolbelt_Abilities::NAMESPACE_PREFIX . $atb_slug ); if ( is_object( $atb_ability ) && method_exists( $atb_ability, 'execute' ) ) { return $atb_ability->execute( $atb_input ); } } return Agent_Toolbelt::execute( $atb_slug, $atb_input, 'admin' ); } /** * The operative input (guardrail keys stripped) as compact JSON — used * to refill the textarea and to carry the input through the hidden * execute-for-real form so the confirm token still matches. * * @param array $atb_input * @return string */ public static function operative_input_json( $atb_input ) { if ( ! is_array( $atb_input ) ) { $atb_input = array(); } unset( $atb_input['dry_run'], $atb_input['confirm'] ); if ( empty( $atb_input ) ) { return '{}'; } return (string) wp_json_encode( $atb_input ); } /** * Renders the Run-an-ability section: the form, input examples, and * (after a submission) the verdict + full JSON result. * * @param array|null $atb_run maybe_run() render context. */ protected static function render_run_panel( $atb_run ) { echo '

' . esc_html__( 'Run an ability', 'agent-toolbelt' ) . '

'; echo '

' . esc_html__( 'Try any enabled ability yourself — to test it before wiring an agent, or just to use it. This runs through the exact pipeline agents use: the same guardrails (dry-run, confirm tokens, budgets, observe mode) apply, and the run is recorded in the audit log below with caller "admin".', 'agent-toolbelt' ) . '

'; $atb_selected = 'site-status'; $atb_input_raw = '{}'; if ( null !== $atb_run ) { $atb_selected = isset( $atb_run['slug'] ) ? $atb_run['slug'] : $atb_selected; $atb_input_raw = isset( $atb_run['input_raw'] ) ? $atb_run['input_raw'] : $atb_input_raw; } else { // Prefill from a checklist Run button (?ability=slug): select // the ability and seed the input with its example. Read-only // display state — no state changes. // phpcs:ignore WordPress.Security.NonceVerification.Recommended $atb_prefill = isset( $_GET['ability'] ) ? sanitize_key( wp_unslash( $_GET['ability'] ) ) : ''; if ( '' !== $atb_prefill && null !== Agent_Toolbelt::get_operation( $atb_prefill ) ) { $atb_selected = $atb_prefill; $atb_meta = Agent_Toolbelt::get_operation( $atb_prefill )->describe(); if ( isset( $atb_meta['example_input'] ) && is_array( $atb_meta['example_input'] ) && ! empty( $atb_meta['example_input'] ) ) { $atb_input_raw = (string) wp_json_encode( $atb_meta['example_input'] ); } } } echo '
'; wp_nonce_field( self::RUN_NONCE_ACTION, self::RUN_NONCE_FIELD ); echo '

'; echo '


'; echo '

'; echo '

'; submit_button( __( 'Run', 'agent-toolbelt' ), 'secondary', 'atb_run_submit', false ); echo '
'; self::render_run_examples(); if ( null !== $atb_run ) { self::render_run_result( $atb_run ); } } /** * Collapsible per-ability input examples (describe()['example_input']). */ protected static function render_run_examples() { $atb_examples = array(); foreach ( Agent_Toolbelt::get_operations() as $atb_slug => $atb_operation ) { $atb_meta = $atb_operation->describe(); if ( isset( $atb_meta['example_input'] ) && is_array( $atb_meta['example_input'] ) && ! empty( $atb_meta['example_input'] ) ) { $atb_examples[ $atb_slug ] = (string) wp_json_encode( $atb_meta['example_input'] ); } } if ( empty( $atb_examples ) ) { return; } echo '
' . esc_html__( 'Input examples (most abilities run fine with {})', 'agent-toolbelt' ) . ''; echo '
'; } /** * Renders the outcome of a Run-panel submission: an error notice, or * the verdict line, the execute-for-real form when a confirm token was * issued, and the full pretty-printed JSON. * * @param array $atb_run maybe_run() render context. */ protected static function render_run_result( $atb_run ) { if ( isset( $atb_run['error'] ) ) { echo '

' . esc_html( $atb_run['error'] ) . '

'; return; } $atb_result = $atb_run['result']; if ( is_wp_error( $atb_result ) ) { // Exactly what an agent would get back (e.g. schema validation). echo '

' . esc_html( $atb_result->get_error_code() ) . ': ' . esc_html( $atb_result->get_error_message() ) . '

'; return; } $atb_details = ( isset( $atb_result['details'] ) && is_array( $atb_result['details'] ) ) ? $atb_result['details'] : array(); if ( ! empty( $atb_details['refused'] ) ) { $atb_reason = isset( $atb_details['reason'] ) ? (string) $atb_details['reason'] : ''; echo '

' . esc_html( sprintf( /* translators: %s: machine refusal reason code. */ __( 'Refused — reason: %s. This is an answer, not an error; agents get the same response.', 'agent-toolbelt' ), $atb_reason ) ) . '

'; } elseif ( ! empty( $atb_result['dry_run'] ) ) { echo '

' . esc_html__( 'Preview (dry run) — nothing was changed.', 'agent-toolbelt' ) . '

'; } elseif ( ! empty( $atb_result['performed'] ) ) { echo '

' . esc_html__( 'Executed.', 'agent-toolbelt' ) . '

'; } else { echo '

' . esc_html__( 'Completed without changes (see details below).', 'agent-toolbelt' ) . '

'; } if ( isset( $atb_details['confirm_token'] ) && is_string( $atb_details['confirm_token'] ) && '' !== $atb_details['confirm_token'] ) { echo '
'; wp_nonce_field( self::RUN_NONCE_ACTION, self::RUN_NONCE_FIELD ); echo ''; echo ''; echo ''; echo ''; echo '

' . esc_html__( 'This dry run issued a one-time confirm token (valid 15 minutes, bound to exactly this input). Review the preview below, then:', 'agent-toolbelt' ) . '

'; submit_button( __( 'Execute for real with this token', 'agent-toolbelt' ), 'primary', 'atb_run_execute_submit', false ); echo '
'; } echo '
'
                . esc_html( (string) wp_json_encode( $atb_result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) )
                . '
'; } /** * Whether the official MCP Adapter actually BOOTS on this site. * `class_exists( McpAdapter )` is NOT the signal: WooCommerce 10.9+ * bundles the adapter as a Composer library, so the class is loadable * on any Woo site while the adapter never runs (Woo only boots it on * rest_api_init when its `mcp_integration` feature flag is on — and * the adapter can't be probed via did_action() here, because admin * requests never fire rest_api_init). * * @return bool */ protected static function mcp_adapter_active() { if ( ! function_exists( 'is_plugin_active' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } if ( is_plugin_active( 'mcp-adapter/mcp-adapter.php' ) ) { return true; } if ( class_exists( '\\Automattic\\WooCommerce\\Utilities\\FeaturesUtil' ) && is_callable( array( '\\Automattic\\WooCommerce\\Utilities\\FeaturesUtil', 'feature_is_enabled' ) ) && \Automattic\WooCommerce\Utilities\FeaturesUtil::feature_is_enabled( 'mcp_integration' ) ) { return true; } return false; } /** * Renders a small colored risk badge. * * @param string $atb_risk * @return string Escaped HTML. */ protected static function risk_badge( $atb_risk ) { $atb_colors = array( 'none' => '#3858e9', 'low' => '#00a32a', 'medium' => '#dba617', 'high' => '#d63638', ); $atb_color = isset( $atb_colors[ $atb_risk ] ) ? $atb_colors[ $atb_risk ] : '#787c82'; return '' . esc_html( $atb_risk ) . ''; } /** * Audit-log table (newest first, spec §4/§5). Since v1.4 the table is * filterable by result (e.g. `denied` to review refused attempts) and * the row count is selectable — read-only GET display filters. */ protected static function render_audit_log() { echo '

' . esc_html__( 'Audit log', 'agent-toolbelt' ) . '

'; // Read-only display filters: no state changes, both values are // validated against fixed whitelists below. // phpcs:disable WordPress.Security.NonceVerification.Recommended $atb_result = isset( $_GET['atb_result'] ) ? sanitize_key( wp_unslash( $_GET['atb_result'] ) ) : ''; $atb_limit = isset( $_GET['atb_limit'] ) ? (int) $_GET['atb_limit'] : self::AUDIT_LIMIT; // phpcs:enable WordPress.Security.NonceVerification.Recommended if ( ! in_array( $atb_result, Agent_Toolbelt_Log::RESULTS, true ) ) { $atb_result = ''; } if ( ! in_array( $atb_limit, array( 20, 50, 100 ), true ) ) { $atb_limit = self::AUDIT_LIMIT; } echo '
'; echo ''; echo ''; echo ' '; echo ' '; submit_button( __( 'Filter', 'agent-toolbelt' ), 'secondary', '', false ); echo '
'; $atb_read_args = array( 'limit' => $atb_limit ); if ( '' !== $atb_result ) { $atb_read_args['result'] = $atb_result; } $atb_rows = class_exists( 'Agent_Toolbelt_Log' ) ? Agent_Toolbelt_Log::read( $atb_read_args ) : array(); if ( empty( $atb_rows ) ) { echo '

' . esc_html( '' === $atb_result ? __( 'No operations have been recorded yet.', 'agent-toolbelt' ) : __( 'No entries match this filter.', 'agent-toolbelt' ) ) . '

'; return; } echo ''; echo ''; $atb_headers = array( __( 'When (UTC)', 'agent-toolbelt' ), __( 'Ability', 'agent-toolbelt' ), __( 'Caller', 'agent-toolbelt' ), __( 'User', 'agent-toolbelt' ), __( 'Dry run', 'agent-toolbelt' ), __( 'Result', 'agent-toolbelt' ), __( 'ms', 'agent-toolbelt' ), __( 'Summary', 'agent-toolbelt' ), ); foreach ( $atb_headers as $atb_head ) { echo ''; } echo ''; foreach ( $atb_rows as $atb_row ) { echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; } echo '
' . esc_html( $atb_head ) . '
' . esc_html( isset( $atb_row['created_at'] ) ? $atb_row['created_at'] : '' ) . '' . esc_html( isset( $atb_row['ability'] ) ? $atb_row['ability'] : '' ) . '' . esc_html( isset( $atb_row['caller'] ) ? $atb_row['caller'] : '' ) . '' . esc_html( isset( $atb_row['user_id'] ) ? $atb_row['user_id'] : '0' ) . '' . ( empty( $atb_row['dry_run'] ) ? esc_html__( 'no', 'agent-toolbelt' ) : esc_html__( 'yes', 'agent-toolbelt' ) ) . '' . esc_html( isset( $atb_row['result'] ) ? $atb_row['result'] : '' ) . '' . esc_html( isset( $atb_row['duration_ms'] ) ? $atb_row['duration_ms'] : '0' ) . '' . esc_html( isset( $atb_row['summary'] ) ? $atb_row['summary'] : '' ) . '
'; } } }