array( 'update-plugin', 'cleanup-database', 'toggle-plugin', 'rollback-plugin' ), 'observe_mode' => false, 'notify_high_risk' => true, ); } /** * One-time-per-upgrade housekeeping (v1.4 F9). New opt-in abilities * must stay disabled on EXISTING installs too: a site that ever saved * the settings form has a stored disabled_abilities list without the * new slugs, and since the stored list replaces the default wholesale * in get_options(), the new high-risk abilities would silently surface * as enabled after the upgrade. The form-save code preserves unknown * disabled slugs — but only once they are in the stored list, which is * exactly what this migration ensures. */ public static function maybe_upgrade() { $atb_stored_version = (string) get_option( self::VERSION_OPTION, '' ); if ( defined( 'AGENT_TOOLBELT_VERSION' ) && AGENT_TOOLBELT_VERSION === $atb_stored_version ) { return; } $atb_saved = get_option( self::OPTION_NAME, false ); if ( is_array( $atb_saved ) && isset( $atb_saved['disabled_abilities'] ) && is_array( $atb_saved['disabled_abilities'] ) ) { $atb_new_opt_ins = array(); if ( '' === $atb_stored_version || version_compare( $atb_stored_version, '1.4.0', '<' ) ) { $atb_new_opt_ins = array( 'toggle-plugin', 'rollback-plugin' ); } $atb_missing = array_diff( $atb_new_opt_ins, $atb_saved['disabled_abilities'] ); if ( ! empty( $atb_missing ) ) { $atb_saved['disabled_abilities'] = array_values( array_merge( $atb_saved['disabled_abilities'], $atb_missing ) ); update_option( self::OPTION_NAME, $atb_saved ); } } if ( defined( 'AGENT_TOOLBELT_VERSION' ) ) { update_option( self::VERSION_OPTION, AGENT_TOOLBELT_VERSION ); } } /** * Returns the plugin options merged over defaults. */ public static function get_options() { $atb_options = get_option( self::OPTION_NAME, array() ); if ( ! is_array( $atb_options ) ) { $atb_options = array(); } return array_merge( self::default_options(), $atb_options ); } /** * True when the given ability slug is disabled via options. */ public static function is_ability_disabled( $atb_ability ) { $atb_options = self::get_options(); return in_array( $atb_ability, $atb_options['disabled_abilities'], true ); } /** * True when AGENT_TOOLBELT_DISABLED is defined truthy — nothing registers (spec §2.7). */ public static function is_kill_switch_active() { return defined( 'AGENT_TOOLBELT_DISABLED' ) && AGENT_TOOLBELT_DISABLED; } /** * True when observe mode is active: agents can call read-only abilities * and dry-runs, but every real mutating execution is refused (v1.1 spec * §2.1). The AGENT_TOOLBELT_OBSERVE constant forces it on regardless of * the option. */ public static function is_observe_mode() { if ( self::is_observe_mode_forced() ) { return true; } $atb_options = self::get_options(); return ! empty( $atb_options['observe_mode'] ); } /** * True when observe mode is forced on by the AGENT_TOOLBELT_OBSERVE constant. */ public static function is_observe_mode_forced() { return defined( 'AGENT_TOOLBELT_OBSERVE' ) && AGENT_TOOLBELT_OBSERVE; } /** * True when the rate budget is bypassed via AGENT_TOOLBELT_NO_RATE_LIMIT * (for CI / supervised bulk-maintenance sessions, v1.1 spec §2.2). */ public static function is_rate_limit_bypassed() { return defined( 'AGENT_TOOLBELT_NO_RATE_LIMIT' ) && AGENT_TOOLBELT_NO_RATE_LIMIT; } /** * Per-hour execution budgets (real mutating executions only), filterable * via `agent_toolbelt_rate_budgets` (v1.1 spec §2.2). * * @return array { high: int, total: int } */ public static function rate_budgets() { $atb_defaults = array( 'high' => 5, 'total' => 30, ); $atb_budgets = apply_filters( 'agent_toolbelt_rate_budgets', $atb_defaults ); if ( ! is_array( $atb_budgets ) ) { return $atb_defaults; } return array( 'high' => isset( $atb_budgets['high'] ) ? max( 1, (int) $atb_budgets['high'] ) : $atb_defaults['high'], 'total' => isset( $atb_budgets['total'] ) ? max( 1, (int) $atb_budgets['total'] ) : $atb_defaults['total'], ); } /* -------------------------------------------------------------------- * Operation registry (spec §7) * ------------------------------------------------------------------ */ /** * Registers an operation instance under its slug. */ public static function register_operation( ATB_Operation $atb_operation ) { self::$operations[ $atb_operation->slug() ] = $atb_operation; } /** * Returns a registered operation by slug, or null. * * @return ATB_Operation|null */ public static function get_operation( $atb_slug ) { return isset( self::$operations[ $atb_slug ] ) ? self::$operations[ $atb_slug ] : null; } /** * Returns registered operations. By default only the enabled ones * (disabled abilities are invisible — spec §2.6); pass true to include all. * * @return ATB_Operation[] */ public static function get_operations( $atb_include_disabled = false ) { if ( $atb_include_disabled ) { return self::$operations; } $atb_enabled = array(); foreach ( self::$operations as $atb_slug => $atb_operation ) { if ( ! self::is_ability_disabled( $atb_slug ) ) { $atb_enabled[ $atb_slug ] = $atb_operation; } } return $atb_enabled; } /** * Clears the registry (test helper / re-init safety). */ public static function reset_operations() { self::$operations = array(); } /* -------------------------------------------------------------------- * Execution service (spec §2.1-2.3, §2.9) * * The single place operations are invoked. Every wrapper — abilities, * WP-CLI, and (later) the admin UI — funnels through here so the * dry-run/confirm guardrail and the audit log live in exactly one spot * (PBC "thin wrappers" rule). Wrappers carry zero business logic. * ------------------------------------------------------------------ */ /** * Runs a registered operation with the full guardrail: resolves the * effective dry-run mode, enforces the confirm token for high-risk * executions, times the call, and writes an audit-log row. * * Effective dry-run: * - explicit `dry_run` in the input always wins; * - otherwise high-risk operations default to dry-run TRUE (the agent * must opt into a real execution), everything else to FALSE. * * Confirm token (high-risk real execution only): the input must echo the * token issued by the matching dry run. A missing/expired/mismatched * token is refused, and since v1.1 the refusal is written to the audit * log as a denied attempt (reason `confirm_token`), like every other * refusal. * * @param string $atb_slug Operation slug (without the ability namespace). * @param array $atb_input Caller input. * @param string $atb_caller Optional caller context; auto-detected when null. * @return array Common output shape, with details.confirm_token added on a * high-risk dry run and details.refused on a confirm refusal. */ public static function execute( $atb_slug, $atb_input = array(), $atb_caller = null ) { if ( ! is_array( $atb_input ) ) { $atb_input = array(); } $atb_user_id = function_exists( 'get_current_user_id' ) ? (int) get_current_user_id() : 0; $atb_operation = self::get_operation( $atb_slug ); if ( null === $atb_operation || self::is_ability_disabled( $atb_slug ) ) { self::log_denied( $atb_slug, $atb_user_id, false, 'disabled', $atb_input, $atb_caller ); return array( 'performed' => false, 'dry_run' => false, 'details' => array( 'refused' => true, 'reason' => 'disabled', 'error' => 'unknown or disabled ability: ' . $atb_slug, ), 'warnings' => array(), ); } $atb_meta = $atb_operation->describe(); $atb_readonly = isset( $atb_meta['risk'] ) && 'none' === $atb_meta['risk']; $atb_high = $atb_operation->is_high_risk(); $atb_dry_run = array_key_exists( 'dry_run', $atb_input ) ? (bool) $atb_input['dry_run'] : $atb_high; $atb_observe = self::is_observe_mode(); // Observe mode: read-only abilities and dry-runs stay available, // every real mutating execution is refused (v1.1 spec §2.1). if ( $atb_observe && ! $atb_readonly && ! $atb_dry_run ) { self::log_denied( $atb_slug, $atb_user_id, $atb_dry_run, 'observe_mode', $atb_input, $atb_caller ); return array( 'performed' => false, 'dry_run' => false, 'details' => array( 'refused' => true, 'reason' => 'observe_mode', ), 'warnings' => array( 'observe mode is active; ask the site owner to disable it in Settings -> Agent Toolbelt' ), ); } // Rate budget: real mutating executions only (v1.1 spec §2.2). if ( ! $atb_dry_run && ! $atb_readonly && ! self::is_rate_limit_bypassed() ) { $atb_budget_state = self::check_rate_budget( $atb_user_id, $atb_high ); if ( true !== $atb_budget_state ) { self::log_denied( $atb_slug, $atb_user_id, $atb_dry_run, 'rate_budget', $atb_input, $atb_caller ); return array( 'performed' => false, 'dry_run' => false, 'details' => array( 'refused' => true, 'reason' => 'rate_budget', 'retry_after_s' => $atb_budget_state['retry_after_s'], 'budget' => $atb_budget_state['budget'], ), 'warnings' => array( 'hourly execution budget exhausted; retry later or ask the site owner' ), ); } } // High-risk real execution: require the single-use confirm token. if ( $atb_high && ! $atb_dry_run ) { $atb_confirm = isset( $atb_input['confirm'] ) ? (string) $atb_input['confirm'] : ''; if ( ! self::verify_confirm_token( $atb_slug, $atb_user_id, $atb_input, $atb_confirm ) ) { self::log_denied( $atb_slug, $atb_user_id, $atb_dry_run, 'confirm_token', $atb_input, $atb_caller ); return array( 'performed' => false, 'dry_run' => false, 'details' => array( 'refused' => true, 'reason' => 'confirm_token', ), 'warnings' => array( 'confirm token missing, expired, or not matching: the token from a high-risk dry run is bound to this exact ability, user, and input — execute by re-sending the IDENTICAL input plus "dry_run": false and "confirm": ""; tokens are single-use and expire after 15 minutes' ), ); } } // Every gate passed: a real mutating execution consumes budget even // if the operation itself later fails (it did run). if ( ! $atb_dry_run && ! $atb_readonly && ! self::is_rate_limit_bypassed() ) { self::consume_rate_budget( $atb_user_id, $atb_high ); } $atb_start = microtime( true ); try { $atb_output = $atb_dry_run ? $atb_operation->dry_run( $atb_input ) : $atb_operation->run( $atb_input ); } catch ( \Throwable $atb_e ) { $atb_duration = (int) round( ( microtime( true ) - $atb_start ) * 1000 ); self::log_execution( $atb_slug, $atb_user_id, $atb_dry_run, 'error', 'exception: ' . $atb_e->getMessage(), $atb_duration, $atb_caller ); if ( $atb_high && ! $atb_dry_run ) { self::notify_high_risk( $atb_slug, $atb_user_id, 'error', 'exception during execution', $atb_caller ); } return array( 'performed' => false, 'dry_run' => $atb_dry_run, // Class name only — exception MESSAGES may carry file // paths or credentials and never leave the server (F3). 'details' => array( 'reason' => 'exception', 'exception_class' => get_class( $atb_e ), 'error' => 'operation error — details are recorded in the audit log', ), 'warnings' => array(), ); } $atb_duration = (int) round( ( microtime( true ) - $atb_start ) * 1000 ); if ( ! is_array( $atb_output ) ) { $atb_output = array( 'performed' => false, 'dry_run' => $atb_dry_run, 'details' => array(), 'warnings' => array() ); } if ( ! isset( $atb_output['details'] ) || ! is_array( $atb_output['details'] ) ) { $atb_output['details'] = array(); } // A high-risk dry run issues the confirm token the execute step // needs — unless the operation refused the input, or observe mode is // on (a token for a request that can never execute would only // mislead the agent). if ( $atb_high && $atb_dry_run && empty( $atb_output['details']['refused'] ) && ! $atb_observe ) { $atb_output['details']['confirm_token'] = self::issue_confirm_token( $atb_slug, $atb_user_id, $atb_input ); } $atb_result = ! empty( $atb_output['details']['rolled_back'] ) ? 'rolled_back' : 'ok'; $atb_summary = self::build_summary( $atb_input, $atb_output ); self::log_execution( $atb_slug, $atb_user_id, $atb_dry_run, $atb_result, $atb_summary, $atb_duration, $atb_caller ); if ( $atb_high && ! $atb_dry_run ) { self::notify_high_risk( $atb_slug, $atb_user_id, $atb_result, $atb_summary, $atb_caller ); } return $atb_output; } /* -------------------------------------------------------------------- * Rate budget (v1.1 spec §2.2) * * Fixed 1-hour window per user, stored in one transient. Only real * mutating executions count; dry-runs and read-only abilities are never * limited (diagnosis must stay free). Worst case across a window * boundary or a get/set race is one extra operation — accepted. * ------------------------------------------------------------------ */ /** * Checks the caller's remaining budget WITHOUT consuming it. * * @param int $atb_user_id * @param bool $atb_high Whether this execution is high-risk. * @return true|array True when within budget, otherwise * { retry_after_s: int, budget: {high_used, high_max, total_used, total_max} }. */ public static function check_rate_budget( $atb_user_id, $atb_high ) { $atb_budgets = self::rate_budgets(); $atb_state = self::rate_state( $atb_user_id ); $atb_over = ( $atb_state['total'] >= $atb_budgets['total'] ) || ( $atb_high && $atb_state['high'] >= $atb_budgets['high'] ); if ( ! $atb_over ) { return true; } return array( 'retry_after_s' => max( 1, self::RATE_WINDOW - ( time() - $atb_state['window_start'] ) ), 'budget' => array( 'high_used' => $atb_state['high'], 'high_max' => $atb_budgets['high'], 'total_used' => $atb_state['total'], 'total_max' => $atb_budgets['total'], ), ); } /** * Consumes one unit of budget (and one high-risk unit when applicable). */ public static function consume_rate_budget( $atb_user_id, $atb_high ) { $atb_state = self::rate_state( $atb_user_id ); $atb_state['total']++; if ( $atb_high ) { $atb_state['high']++; } set_transient( self::RATE_PREFIX . (int) $atb_user_id, $atb_state, self::RATE_WINDOW ); } /** * Returns the caller's current window state, resetting an expired window. * * @return array { window_start: int, high: int, total: int } */ protected static function rate_state( $atb_user_id ) { $atb_state = get_transient( self::RATE_PREFIX . (int) $atb_user_id ); if ( ! is_array( $atb_state ) || ! isset( $atb_state['window_start'], $atb_state['high'], $atb_state['total'] ) || ( time() - (int) $atb_state['window_start'] ) >= self::RATE_WINDOW ) { return array( 'window_start' => time(), 'high' => 0, 'total' => 0 ); } return array( 'window_start' => (int) $atb_state['window_start'], 'high' => (int) $atb_state['high'], 'total' => (int) $atb_state['total'], ); } /* -------------------------------------------------------------------- * Denied-attempt logging (v1.1 spec §2.3) * ------------------------------------------------------------------ */ /** * Writes a 'denied' audit row for a refusal produced by execute() * itself, rate-limited to one row per (ability, user, reason) per * DENIED_TTL seconds so a probing agent cannot flood the log. */ protected static function log_denied( $atb_slug, $atb_user_id, $atb_dry_run, $atb_reason, $atb_input, $atb_caller ) { $atb_marker = self::DENIED_PREFIX . md5( $atb_slug . '|' . (int) $atb_user_id . '|' . $atb_reason ); if ( false !== get_transient( $atb_marker ) ) { return; } set_transient( $atb_marker, 1, self::DENIED_TTL ); self::log_execution( $atb_slug, $atb_user_id, $atb_dry_run, 'denied', $atb_reason . ': ' . self::input_digest( $atb_input ), 0, $atb_caller ); } /* -------------------------------------------------------------------- * High-risk email notification (v1.1 spec §2.4) * ------------------------------------------------------------------ */ /** * Emails the site admin after a real high-risk execution (any result). * Strictly best-effort: mail failures never affect the operation, and * the confirm token is never included. */ protected static function notify_high_risk( $atb_slug, $atb_user_id, $atb_result, $atb_summary, $atb_caller ) { $atb_options = self::get_options(); if ( empty( $atb_options['notify_high_risk'] ) || ! function_exists( 'wp_mail' ) ) { return; } $atb_admin_email = get_option( 'admin_email' ); if ( ! is_string( $atb_admin_email ) || '' === $atb_admin_email ) { return; } $atb_site = function_exists( 'get_bloginfo' ) ? (string) get_bloginfo( 'name' ) : ''; $atb_subject = sprintf( '[%s] Agent Toolbelt: %s executed (%s)', $atb_site, $atb_slug, $atb_result ); $atb_body = 'A high-risk Agent Toolbelt operation was executed on this site.' . "\n\n" . 'Ability: ' . $atb_slug . "\n" . 'User ID: ' . (int) $atb_user_id . "\n" . 'Caller: ' . ( null !== $atb_caller ? $atb_caller : 'auto' ) . "\n" . 'Result: ' . $atb_result . "\n" . 'Summary: ' . $atb_summary . "\n" . 'Time (UTC): ' . gmdate( 'Y-m-d H:i:s' ) . "\n\n" . 'Review the full audit log in Settings -> Agent Toolbelt.'; try { wp_mail( $atb_admin_email, $atb_subject, $atb_body ); } catch ( \Throwable $atb_e ) { // Best-effort only. unset( $atb_e ); } } /** * Writes one audit-log row for an execution. No-op when the log class is * unavailable (e.g. in unit tests without the wpdb stub loaded). */ protected static function log_execution( $atb_slug, $atb_user_id, $atb_dry_run, $atb_result, $atb_summary, $atb_duration_ms, $atb_caller ) { if ( ! class_exists( 'Agent_Toolbelt_Log' ) ) { return; } $atb_entry = array( 'ability' => $atb_slug, 'user_id' => (int) $atb_user_id, 'dry_run' => (bool) $atb_dry_run, 'result' => $atb_result, 'summary' => $atb_summary, 'duration_ms' => (int) $atb_duration_ms, ); if ( null !== $atb_caller ) { $atb_entry['caller'] = $atb_caller; } Agent_Toolbelt_Log::write( $atb_entry ); } /** * Builds the audit summary: a sanitized input digest plus the outcome. * The confirm token is never recorded. * * @return string */ public static function build_summary( $atb_input, $atb_output ) { $atb_outcome = ! empty( $atb_output['performed'] ) ? 'performed' : 'no-op'; $atb_warnings = ( isset( $atb_output['warnings'] ) && is_array( $atb_output['warnings'] ) ) ? count( $atb_output['warnings'] ) : 0; if ( $atb_warnings > 0 ) { $atb_outcome .= ' (' . $atb_warnings . ' warning' . ( 1 === $atb_warnings ? '' : 's' ) . ')'; } return self::input_digest( $atb_input ) . ' => ' . $atb_outcome; } /** * Deterministic, confirm-token-free digest of the operative input for the * audit log (dry_run and confirm are stripped — they live in their own * column / never persist). * * @return string */ public static function input_digest( $atb_input ) { return self::canonicalize_input( $atb_input ); } /* -------------------------------------------------------------------- * Confirm-token service (spec §2.3) * * A dry-run of a high-risk ability issues a token bound to * ( ability | user_id | canonical input ). Execution must echo that token * back, forcing look-then-act: a single injected prompt cannot one-shot a * destructive change. Tokens are single-use and expire after CONFIRM_TTL. * ------------------------------------------------------------------ */ /** * Issues and stores a confirm token for the given ability/user/input. * * @return string The 12-char token. */ public static function issue_confirm_token( $atb_ability, $atb_user_id, $atb_input ) { $atb_token = self::random_token( self::CONFIRM_TOKEN_LENGTH ); $atb_key = self::confirm_transient_key( $atb_ability, $atb_user_id, $atb_input ); set_transient( $atb_key, $atb_token, self::CONFIRM_TTL ); return $atb_token; } /** * Verifies a confirm token against the stored one for this exact * ability/user/input. On a successful match the token is consumed * (single-use). Returns false on any mismatch, expiry, or empty input. * * @return bool */ public static function verify_confirm_token( $atb_ability, $atb_user_id, $atb_input, $atb_token ) { if ( ! is_string( $atb_token ) || '' === $atb_token ) { return false; } $atb_key = self::confirm_transient_key( $atb_ability, $atb_user_id, $atb_input ); $atb_stored = get_transient( $atb_key ); if ( ! is_string( $atb_stored ) || '' === $atb_stored ) { return false; } if ( ! hash_equals( $atb_stored, $atb_token ) ) { return false; } delete_transient( $atb_key ); return true; } /** * Builds the transient key: md5( ability | user_id | canonical input ), * prefixed. Canonicalization drops dry_run/confirm so the token issued * during a dry run matches the corresponding execute call. * * @return string */ public static function confirm_transient_key( $atb_ability, $atb_user_id, $atb_input ) { $atb_canonical = self::canonicalize_input( $atb_input ); return self::CONFIRM_PREFIX . md5( $atb_ability . '|' . (int) $atb_user_id . '|' . $atb_canonical ); } /** * Deterministic serialization of the operative input: strips dry_run and * confirm, then recursively sorts keys before JSON-encoding. * * @return string */ protected static function canonicalize_input( $atb_input ) { if ( ! is_array( $atb_input ) ) { $atb_input = array(); } unset( $atb_input['dry_run'], $atb_input['confirm'] ); self::ksort_recursive( $atb_input ); return (string) wp_json_encode( $atb_input ); } /** * Recursively ksorts an array in place. */ protected static function ksort_recursive( &$atb_array ) { if ( ! is_array( $atb_array ) ) { return; } ksort( $atb_array ); foreach ( $atb_array as &$atb_value ) { if ( is_array( $atb_value ) ) { self::ksort_recursive( $atb_value ); } } unset( $atb_value ); } /** * Generates a random alphanumeric token of the given length. * * @return string */ protected static function random_token( $atb_length ) { $atb_token = wp_generate_password( $atb_length, false, false ); return substr( $atb_token, 0, $atb_length ); } } }