prefix . 'agent_toolbelt_log'; } /** * Creates (or upgrades) the audit-log table via dbDelta. */ public static function create_table() { global $wpdb; $atb_table = self::table_name(); $atb_charset_collate = $wpdb->get_charset_collate(); $atb_sql = "CREATE TABLE {$atb_table} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, created_at DATETIME NOT NULL, ability VARCHAR(100) NOT NULL, caller VARCHAR(20) NOT NULL, user_id BIGINT UNSIGNED NOT NULL, dry_run TINYINT(1) NOT NULL DEFAULT 0, result VARCHAR(20) NOT NULL, summary TEXT NOT NULL, duration_ms INT UNSIGNED NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY created_at (created_at) ) {$atb_charset_collate};"; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta( $atb_sql ); } /** * Drops the audit-log table (uninstall only). */ public static function drop_table() { global $wpdb; $atb_table = self::table_name(); // Uninstall-time schema change on our own table; the name comes from // $wpdb->prefix and cannot be a placeholder. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $wpdb->query( "DROP TABLE IF EXISTS {$atb_table}" ); } /** * Schedules the daily retention cron if not already scheduled. */ public static function schedule_retention_cron() { if ( ! wp_next_scheduled( self::CRON_HOOK ) ) { wp_schedule_event( time(), 'daily', self::CRON_HOOK ); } } /** * Clears the retention cron (on deactivation). */ public static function clear_retention_cron() { $atb_timestamp = wp_next_scheduled( self::CRON_HOOK ); if ( $atb_timestamp ) { wp_unschedule_event( $atb_timestamp, self::CRON_HOOK ); } } /* -------------------------------------------------------------------- * Writer / reader (spec §4) * ------------------------------------------------------------------ */ /** * Allowed caller contexts and result codes (spec §4). */ const CALLERS = array( 'rest', 'cli', 'admin', 'other' ); const RESULTS = array( 'ok', 'error', 'rolled_back' ); /** * Writes one audit entry. Missing/invalid fields are normalized to safe * defaults; caller is auto-detected when not supplied. * * @param array $atb_entry { * @type string $ability Operation slug. * @type int $user_id Acting user (0 for none). * @type bool $dry_run Whether this was a dry run. * @type string $result One of RESULTS. * @type string $summary Sanitized input digest + outcome. * @type int $duration_ms Wall-clock duration. * @type string $caller Optional; auto-detected when omitted. * } * @return int Inserted row id (0 on failure). */ public static function write( $atb_entry ) { global $wpdb; $atb_caller = isset( $atb_entry['caller'] ) ? (string) $atb_entry['caller'] : self::detect_caller(); if ( ! in_array( $atb_caller, self::CALLERS, true ) ) { $atb_caller = 'other'; } $atb_result = isset( $atb_entry['result'] ) ? (string) $atb_entry['result'] : 'ok'; if ( ! in_array( $atb_result, self::RESULTS, true ) ) { $atb_result = 'error'; } $atb_row = array( 'created_at' => current_time( 'mysql', true ), 'ability' => substr( (string) ( isset( $atb_entry['ability'] ) ? $atb_entry['ability'] : '' ), 0, 100 ), 'caller' => $atb_caller, 'user_id' => isset( $atb_entry['user_id'] ) ? (int) $atb_entry['user_id'] : 0, 'dry_run' => empty( $atb_entry['dry_run'] ) ? 0 : 1, 'result' => $atb_result, 'summary' => (string) ( isset( $atb_entry['summary'] ) ? $atb_entry['summary'] : '' ), 'duration_ms' => isset( $atb_entry['duration_ms'] ) ? (int) $atb_entry['duration_ms'] : 0, ); // Audit rows live in the plugin's own table; $wpdb->insert prepares internally. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $atb_ok = $wpdb->insert( self::table_name(), $atb_row, array( '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%d' ) ); return $atb_ok ? (int) $wpdb->insert_id : 0; } /** * Reads audit entries, newest first (spec §3.6). * * @param array $atb_args { * @type int $limit 1-100, default 20. * @type string $ability Optional ability-slug filter. * } * @return array[] Rows as associative arrays. */ public static function read( $atb_args = array() ) { global $wpdb; $atb_limit = isset( $atb_args['limit'] ) ? (int) $atb_args['limit'] : 20; if ( $atb_limit < 1 ) { $atb_limit = 1; } if ( $atb_limit > 100 ) { $atb_limit = 100; } $atb_table = self::table_name(); // Both branches fully $wpdb->prepare() the query; the table name comes // from $wpdb->prefix and cannot be a placeholder. Fresh reads are the // point of an audit log, so no object-cache layer. if ( ! empty( $atb_args['ability'] ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $atb_sql = $wpdb->prepare( "SELECT * FROM {$atb_table} WHERE ability = %s ORDER BY id DESC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared (string) $atb_args['ability'], $atb_limit ); } else { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $atb_sql = $wpdb->prepare( "SELECT * FROM {$atb_table} ORDER BY id DESC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $atb_limit ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared $atb_rows = $wpdb->get_results( $atb_sql, ARRAY_A ); return is_array( $atb_rows ) ? $atb_rows : array(); } /** * Detects the caller context (spec §4). CLI and REST (the MCP path) take * precedence over the generic admin check. * * @return string One of CALLERS. */ public static function detect_caller() { if ( defined( 'WP_CLI' ) && WP_CLI ) { return 'cli'; } if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { return 'rest'; } if ( function_exists( 'is_admin' ) && is_admin() ) { return 'admin'; } return 'other'; } /* -------------------------------------------------------------------- * Retention (spec §4): delete rows older than RETENTION_DAYS days AND * beyond the newest RETENTION_MAX_ROWS. A row survives if it is either * recent OR within the newest N — both conditions must hold to delete. * ------------------------------------------------------------------ */ /** * Retention sweep, run by the daily cron. */ public static function run_retention() { global $wpdb; $atb_cutoff = gmdate( 'Y-m-d H:i:s', time() - ( self::RETENTION_DAYS * DAY_IN_SECONDS ) ); // retention_delete_sql() returns a fully $wpdb->prepare()d statement // (split out so the query itself is unit-testable). // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $wpdb->query( self::retention_delete_sql( $atb_cutoff ) ); } /** * Builds the retention DELETE statement for a given cutoff timestamp. * Split out so the query is unit-testable. * * @param string $atb_cutoff GMT 'Y-m-d H:i:s' threshold. * @return string Prepared SQL. */ public static function retention_delete_sql( $atb_cutoff ) { global $wpdb; $atb_table = self::table_name(); // Table name comes from $wpdb->prefix and cannot be a placeholder; the // derived-table wrapper is required by MySQL (see 08 §M1 finding). // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared return $wpdb->prepare( "DELETE FROM {$atb_table} WHERE created_at < %s AND id NOT IN ( SELECT id FROM ( SELECT id FROM {$atb_table} ORDER BY id DESC LIMIT %d ) AS atb_keep )", $atb_cutoff, self::RETENTION_MAX_ROWS ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared } } }