array( 'update-plugin' ), ); } /** * 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; } /* -------------------------------------------------------------------- * 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 — per spec §2.9 — is NOT logged (denied-attempt * logging is deferred to v1.1 to avoid discovery-time spam). * * @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_operation = self::get_operation( $atb_slug ); if ( null === $atb_operation || self::is_ability_disabled( $atb_slug ) ) { return array( 'performed' => false, 'dry_run' => false, 'details' => array( 'error' => 'unknown or disabled ability: ' . $atb_slug ), 'warnings' => array(), ); } $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_user_id = function_exists( 'get_current_user_id' ) ? (int) get_current_user_id() : 0; // 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 ) ) { return array( 'performed' => false, 'dry_run' => false, 'details' => array( 'refused' => true, 'reason' => 'missing or expired confirm token; run this ability with dry_run first, then pass the returned confirm_token', ), 'warnings' => array( 'confirm token required for this operation' ), ); } } $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 ); return array( 'performed' => false, 'dry_run' => $atb_dry_run, 'details' => array( 'error' => 'operation error' ), '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 (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_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'; self::log_execution( $atb_slug, $atb_user_id, $atb_dry_run, $atb_result, self::build_summary( $atb_input, $atb_output ), $atb_duration, $atb_caller ); return $atb_output; } /** * 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 ); } } }