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', 'denied' ); /** * 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. * @type string $result Optional result filter (one of RESULTS, * e.g. 'denied' to review refused attempts). * } * @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(); $atb_where = array(); $atb_values = array(); if ( ! empty( $atb_args['ability'] ) ) { $atb_where[] = 'ability = %s'; $atb_values[] = (string) $atb_args['ability']; } if ( ! empty( $atb_args['result'] ) && in_array( (string) $atb_args['result'], self::RESULTS, true ) ) { $atb_where[] = 'result = %s'; $atb_values[] = (string) $atb_args['result']; } $atb_where_sql = empty( $atb_where ) ? '' : ' WHERE ' . implode( ' AND ', $atb_where ); $atb_values[] = $atb_limit; // The query is fully $wpdb->prepare()d; the table name comes from // $wpdb->prefix and the WHERE clauses are fixed strings above, so // neither can be a placeholder. Fresh reads are the point of an // audit log, so no object-cache layer. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $atb_sql = $wpdb->prepare( "SELECT * FROM {$atb_table}{$atb_where_sql} ORDER BY id DESC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $atb_values ); // 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 } } }