Plugin Directory

Changeset 3751893


Ignore:
Timestamp:
10/17/2026 12:24:00 PM (2 weeks ago)
Author:
heera
Message:

Update to version 1.51.9 from GitHub

Location:
agentimus
Files:
22 edited
1 copied

Legend:

Unmodified
Added
Removed
  • agentimus/tags/1.51.9/agentimus.php

    r3750928 r3751893  
    44 * Plugin URI:        https://github.com/heera/agentimus
    55 * Description:       Make your site agent-ready (llms.txt, markdown, JSON-LD, a /.well-known discovery document, AI-crawl controls, agent-activity log) AND let the AI tools you use operate it over MCP — read your data and, opt-in, draft/edit/publish posts. Lightweight, no SEO bloat.
    6  * Version:           1.51.8
     6 * Version:           1.51.9
    77 * Requires at least: 6.0
    88 * Requires PHP:      7.4
    … …  
    2020defined( 'ABSPATH' ) || exit;
    2121
    22 define( 'AGENTIMUS_VERSION', '1.51.8' );
     22define( 'AGENTIMUS_VERSION', '1.51.9' );
    2323define( 'AGENTIMUS_FILE', __FILE__ );
    2424define( 'AGENTIMUS_DIR', plugin_dir_path( __FILE__ ) );
  • agentimus/tags/1.51.9/inc/Abilities/Registrar.php

    r3750405 r3751893  
    587587                                                        'origin'       => self::i( 'Requests that reached this server.' ),
    588588                                                        'blocked'      => self::i( 'Requests the edge turned away without contacting this server.' ),
     589                                                        'probes'       => self::i( 'Of the blocked requests, how many asked for a file no reader fetches (a .env, a .git folder, a wp-config copy): a scanner using this crawler\'s name. Never counted as the edge turning this crawler away.' ),
    589590                                                        'served'       => self::i( 'Requests that got a page: a 2xx the edge let through, not counting /robots.txt reads. Passing the edge is not taking content — a request the origin answered 403 or 404 took nothing.' ),
    590591                                                        'bytes'        => self::i(),
  • agentimus/tags/1.51.9/inc/Admin.php

    r3750928 r3751893  
    993993                                // ⭐ A security-only release gets ONE row (1.51.1): the fix is the
    994994                                // whole release, and there is one thing an owner may want to do.
    995                                 // ⭐ So does a one-fix release (1.51.8) — plain-permalink term pages
    996                                 // named in the log and the search screens.
     995                                // ⭐ So does a one-fix release (1.51.9) — a scanner's probes no longer
     996                                // raise the Cloudflare warning. ⛔ No company is named: the release
     997                                // text names no specific bot (his ruling, 1.51.3 and 1.51.4).
    997998                                array(
    998                                         'icon'  => 'page',
    999                                         'title' => 'Tag Pages Named on Plain Permalinks',
    1000                                         'text'  => 'On a site with plain permalinks, the Request Log called every tag and category page Homepage. It now shows the tag or category name, here and in your search results.',
     999                                        'icon'  => 'shield',
     1000                                        'title' => 'Scanners No Longer Trip the Cloudflare Warning',
     1001                                        'text'  => 'A scanner that used an AI crawler\'s name to look for secret files like .env could make it look as if Cloudflare was blocking that company. Those requests are now counted apart, and only real blocks raise the warning.',
    10011002                                ),
    10021003                        ),
  • agentimus/tags/1.51.9/inc/Cloudflare/Client.php

    r3722083 r3751893  
    215215
    216216        /**
     217         * Hourly edge-refused requests for a window, one row per (hour, user agent,
     218         * path) — the paths {@see Module::aggregate()} needs to tell a scanner's
     219         * probe from a reader being turned away. The filter is the same "blocked"
     220         * test as {@see blocked_sources()}. The paths are read here, classified in
     221         * the poll and never stored: the table keeps only the count.
     222         *
     223         * @param string $token   API token, plaintext.
     224         * @param string $zone_id Zone tag.
     225         * @param int    $since   Window start, Unix UTC.
     226         * @param int    $until   Window end, Unix UTC.
     227         * @return array { rows?: array<int,array{hour:string,ua:string,path:string,requests:int}>, error?: string }
     228         */
     229        public function blocked_paths( $token, $zone_id, $since, $until ) {
     230                $query = 'query ($zone: String!, $since: Time!, $until: Time!) {
     231                        viewer {
     232                                zones(filter: { zoneTag: $zone }) {
     233                                        httpRequestsAdaptiveGroups(
     234                                                limit: ' . self::GRAPHQL_LIMIT . ',
     235                                                filter: { datetime_geq: $since, datetime_lt: $until, requestSource: "eyeball", edgeResponseStatus_in: [403, 429], originResponseStatus: 0 },
     236                                                orderBy: [count_DESC]
     237                                        ) {
     238                                                count
     239                                                dimensions { datetimeHour userAgent clientRequestPath }
     240                                        }
     241                                }
     242                        }
     243                }';
     244
     245                $out = $this->graphql( $token, $query, array(
     246                        'zone'  => (string) $zone_id,
     247                        'since' => gmdate( 'Y-m-d\TH:i:s\Z', (int) $since ),
     248                        'until' => gmdate( 'Y-m-d\TH:i:s\Z', (int) $until ),
     249                ) );
     250                if ( isset( $out['error'] ) ) {
     251                        return $out;
     252                }
     253
     254                // The expected container, or it's an error — never "real empty data".
     255                if ( ! isset( $out['json']['data']['viewer']['zones'][0]['httpRequestsAdaptiveGroups'] ) ) {
     256                        return array( 'error' => __( 'Cloudflare returned an unexpected response shape.', 'agentimus' ) );
     257                }
     258
     259                $rows = array();
     260                foreach ( (array) $out['json']['data']['viewer']['zones'][0]['httpRequestsAdaptiveGroups'] as $g ) {
     261                        if ( ! isset( $g['dimensions'] ) ) {
     262                                continue;
     263                        }
     264                        $d      = (array) $g['dimensions'];
     265                        $rows[] = array(
     266                                'hour'     => (string) ( isset( $d['datetimeHour'] ) ? $d['datetimeHour'] : '' ),
     267                                'ua'       => (string) ( isset( $d['userAgent'] ) ? $d['userAgent'] : '' ),
     268                                'path'     => (string) ( isset( $d['clientRequestPath'] ) ? $d['clientRequestPath'] : '' ),
     269                                'requests' => (int) ( isset( $g['count'] ) ? $g['count'] : 0 ),
     270                        );
     271                }
     272
     273                return array( 'rows' => $rows );
     274        }
     275
     276        /**
    217277         * Hourly /robots.txt requests for a window, in the same row shape as
    218278         * {@see hourly_traffic()}. Those rows carry no path, so a crawler reading
  • agentimus/tags/1.51.9/inc/Cloudflare/Conflicts.php

    r3722083 r3751893  
    6262         * the owner fixing the cause, instead of shouting for a week about history.
    6363         *
    64          * @param array      $crawlers Per-crawler totals: { ua, name, operator, requests, cached, origin, blocked, served }.
     64         * @param array      $crawlers Per-crawler totals: { ua, name, operator, requests, cached, origin, blocked, probes?, served }.
    6565         * @param array      $policy   The owner's declarations:
    6666         *                             - ai_input (bool)        robots Content-Signal ai-input.
    … …  
    6969         *                             - allowed_agents (array) user-agent rules the OWNER allowed in the review queue.
    7070         * @param int        $days     The window the totals cover, for the copy.
    71          * @param array|null $recent   Last-24h per-crawler map { ua => { blocked, passed, served } }
     71         * @param array|null $recent   Last-24h per-crawler map { ua => { blocked, probes?, passed, served } }
    7272         *                             (see Table::recent()), or null to treat all
    7373         *                             evidence as current.
    … …  
    9494                                        continue;
    9595                                }
    96                                 $blocked  = (int) $c['blocked'];
    97                                 $requests = (int) $c['requests'];
     96                                // A blocked probe is no reader turned away ({@see refused()}) — and
     97                                // no reading attempt either, so it leaves the share's base too.
     98                                $blocked  = self::refused( $c );
     99                                $requests = (int) $c['requests'] - self::probes( $c );
    98100                                $op       = (string) $c['operator'];
    99101                                if ( '' === $op || $blocked < self::MIN_BLOCKED || $blocked < $requests * self::MIN_BLOCKED_SHARE ) {
    … …  
    103105                                // blocking fires — otherwise the fix already landed and the warning
    104106                                // would just be nagging about history.
    105                                 if ( null !== $recent && empty( $recent[ $token ]['blocked'] ) ) {
     107                                if ( null !== $recent && ( ! isset( $recent[ $token ] ) || 0 === self::refused( $recent[ $token ] ) ) ) {
    106108                                        continue;
    107109                                }
    … …  
    111113                                $per_operator[ $op ]['blocked']  += $blocked;
    112114                                $per_operator[ $op ]['requests'] += $requests;
    113                                 $per_operator[ $op ]['recent']   += null !== $recent ? (int) $recent[ $token ]['blocked'] : 0;
     115                                $per_operator[ $op ]['recent']   += null !== $recent ? self::refused( $recent[ $token ] ) : 0;
    114116                        }
    115117
    … …  
    294296
    295297        /**
     298         * The requests the edge turned away that were a READER being refused: the
     299         * blocked count less the scanner probes in it ({@see Module::is_probe_path()}).
     300         * A row stored before probes were counted has none, and counts as before.
     301         *
     302         * @param array $row Any totals row carrying `blocked` and optionally `probes`.
     303         * @return int
     304         */
     305        public static function refused( array $row ) {
     306                return max( 0, ( isset( $row['blocked'] ) ? (int) $row['blocked'] : 0 ) - self::probes( $row ) );
     307        }
     308
     309        /**
     310         * The scanner probes within a row's blocked count, never more than it.
     311         *
     312         * @param array $row Any totals row.
     313         * @return int
     314         */
     315        private static function probes( array $row ) {
     316                $blocked = isset( $row['blocked'] ) ? (int) $row['blocked'] : 0;
     317                return min( $blocked, isset( $row['probes'] ) ? max( 0, (int) $row['probes'] ) : 0 );
     318        }
     319
     320        /**
    296321         * The slug an operator's warn conflict carries in its id
    297322         * ("edge-blocks-<slug>"). One transform, shared with every reader that has
    … …  
    330355         * Pure — no DB, no time() — so it unit-tests standalone, like detect().
    331356         *
    332          * @param array  $daily    Day => ua => { requests, blocked, passed, served }, oldest first ({@see Table::daily()}).
     357         * @param array  $daily    Day => ua => { requests, blocked, probes?, passed, served }, oldest first ({@see Table::daily()}).
    333358         * @param string $id       The conflict id to date.
    334359         * @param array  $operators ua => operator name, for the per-operator rollup a warn conflict uses.
    … …  
    367392                                        continue;
    368393                                }
    369                                 $sums['blocked']  += (int) $t['blocked'];
    370                                 $sums['requests'] += (int) $t['requests'];
     394                                $sums['blocked']  += self::refused( $t );
     395                                $sums['requests'] += (int) $t['requests'] - self::probes( $t );
    371396                        }
    372397                        return $sums['blocked'] >= self::MIN_BLOCKED
  • agentimus/tags/1.51.9/inc/Cloudflare/Module.php

    r3722083 r3751893  
    152152                $since  = $latest > 0 ? $latest - self::OVERLAP_SECONDS : $now - self::BACKFILL_SECONDS;
    153153                $since  = max( $since, $now - self::BACKFILL_SECONDS );
     154                // ⛔ An hour stored before probes were counted keeps saying "no probes"
     155                // until it is re-read — and the overlap only re-reads two hours, so a
     156                // scanner's morning would go on firing the warning for a day and
     157                // inflating the week for seven. Once, re-read everything Cloudflare
     158                // still holds; a re-read hour replaces its row.
     159                $backfill = ! get_option( Table::PROBES_OPTION );
     160                if ( $backfill ) {
     161                        $since = $now - self::BACKFILL_SECONDS;
     162                }
    154163
    155164                while ( $since < $now ) {
    … …  
    164173                                }
    165174                        }
     175                        if ( ! isset( $out['error'] ) ) {
     176                                // Same rule for the blocked paths: without them every scanner probe
     177                                // would be stored as a reader turned away.
     178                                $paths = $this->client->blocked_paths( $token, $zone, $since, $until );
     179                                if ( isset( $paths['error'] ) ) {
     180                                        $out = $paths;
     181                                }
     182                        }
    166183                        if ( isset( $out['error'] ) ) {
    167184                                // Keep the last good data; the panel shows the numbers' age and this note.
    … …  
    169186                                return;
    170187                        }
    171                         Table::upsert( self::aggregate( (array) $out['rows'], (array) $robots['rows'] ) );
     188                        Table::upsert( self::aggregate( (array) $out['rows'], (array) $robots['rows'], (array) $paths['rows'] ) );
    172189                        $since = $until;
    173190                }
    174191
    175192                $this->settings->record_poll( '' );
     193                if ( $backfill ) {
     194                        update_option( Table::PROBES_OPTION, 1, false );
     195                }
    176196                Table::prune();
    177197
    … …  
    200220         * answers 403 or 404 took nothing. The training notice counts this.
    201221         *
     222         * Within `blocked`, `probes` counts the requests for a path no reader asks
     223         * for ({@see is_probe_path()}). ⛔⛔ A BLOCKED PROBE IS NOT A READER TURNED
     224         * AWAY. On heera.it (2026-10-17) "Cloudflare is blocking Anthropic" and
     225         * "…You.com" were one scanner on Google Cloud asking for .env files under
     226         * both names, stopped by his own bad-paths rule — the third time after
     227         * OpenAI (08-29, 09-17). Neither company publishes addresses to check, so
     228         * the spoof check could only say "unknown"; the path says it plainly.
     229         *
    202230         * @param array $raw    Rows from Client::hourly_traffic().
    203231         * @param array $robots Rows from Client::robots_fetches() for the same window.
     232         * @param array $paths  Rows from Client::blocked_paths() for the same window.
    204233         * @return array<int,array> Rows shaped for Table::upsert().
    205234         */
    206         public static function aggregate( array $raw, array $robots = array() ) {
     235        public static function aggregate( array $raw, array $robots = array(), array $paths = array() ) {
    207236                $cached_statuses = array( 'hit', 'stale', 'updating', 'revalidated' );
    208237                $buckets         = array();
    … …  
    227256                                        'origin'   => 0,
    228257                                        'blocked'  => 0,
     258                                        'probes'   => 0,
    229259                                        'served'   => 0,
    230260                                        'bytes'    => 0,
    … …  
    257287                }
    258288
     289                foreach ( $paths as $row ) {
     290                        $key = self::hour_key( (string) $row['hour'] ) . '|' . self::crawler_token( (string) $row['ua'] );
     291                        if ( isset( $buckets[ $key ] ) && self::is_probe_path( (string) $row['path'] ) ) {
     292                                $buckets[ $key ]['probes'] += (int) $row['requests'];
     293                        }
     294                }
     295                // Two reads of the same window can disagree by a few requests; a probe
     296                // count is a share of `blocked` and never more than it.
     297                foreach ( $buckets as $key => $bucket ) {
     298                        $buckets[ $key ]['probes'] = min( $bucket['probes'], $bucket['blocked'] );
     299                }
     300
    259301                return array_values( $buckets );
     302        }
     303
     304        /**
     305         * Whether a request path is a scanner's probe — a secret or a config file
     306         * that no reader of the site, human or AI, ever asks for.
     307         *
     308         * Deliberately narrow: hidden files and folders (/.env, /.git/config,
     309         * /.aws/credentials — every one except /.well-known/), anything ending in
     310         * .env, wp-config.* copies, database dumps and editor backups, phpinfo, and
     311         * Vite's /@fs/ file-read hole. An ordinary page, a feed or robots.txt is
     312         * never a probe. Decoded first, so /admin%2F.env is caught like /admin/.env.
     313         *
     314         * @param string $path Request path, as Cloudflare reports it.
     315         * @return bool
     316         */
     317        public static function is_probe_path( $path ) {
     318                $path = strtolower( (string) $path );
     319                for ( $i = 0; $i < 2 && false !== strpos( $path, '%' ); $i++ ) {
     320                        $path = rawurldecode( $path );
     321                }
     322                $path = (string) strtok( $path, '?' );
     323
     324                foreach ( explode( '/', str_replace( '\\', '/', $path ) ) as $segment ) {
     325                        if ( '' === $segment || '.' === $segment || '..' === $segment || '.well-known' === $segment ) {
     326                                continue;
     327                        }
     328                        if ( '.' === $segment[0] || '@fs' === $segment || 0 === strpos( $segment, 'wp-config' ) || 0 === strpos( $segment, 'phpinfo' ) ) {
     329                                return true;
     330                        }
     331                        if ( preg_match( '/\.(env|sql|bak|swp)$/', $segment ) ) {
     332                                return true;
     333                        }
     334                }
     335                return false;
    260336        }
    261337
  • agentimus/tags/1.51.9/inc/Cloudflare/SpoofCheck.php

    r3709876 r3751893  
    184184         * @param array $crawlers Rows with { ua, operator }.
    185185         * @param array $policy   The declarations {@see Conflicts::detect()} takes.
    186          * @param array $recent   ua => { blocked, passed } ({@see Table::recent()}).
     186         * @param array $recent   ua => { blocked, probes?, passed } ({@see Table::recent()}).
    187187         * @return array<string,int> operator => blocked requests.
    188188         */
    … …  
    196196                        }
    197197                        $out[ $op ] = ( isset( $out[ $op ] ) ? $out[ $op ] : 0 )
    198                                 + ( isset( $recent[ $ua ]['blocked'] ) ? (int) $recent[ $ua ]['blocked'] : 0 );
     198                                + ( isset( $recent[ $ua ] ) ? Conflicts::refused( (array) $recent[ $ua ] ) : 0 );
    199199                }
    200200                return $out;
  • agentimus/tags/1.51.9/inc/Cloudflare/Table.php

    r3722083 r3751893  
    33 * Edge aggregates table — one row per (UTC hour, crawler), holding the counts
    44 * the panel shows: total edge requests, cache-served, origin-served, blocked
    5  * at the edge, and bytes out.
     5 * at the edge (and how many of those were scanner probes), and bytes out.
    66 *
    77 * Cloudflare's Free plan forgets these numbers within days; this table is why
    … …  
    2020
    2121        /** @var string Schema version — bump on any structural change. */
    22         const VERSION = '2';
     22        const VERSION = '3';
    2323
    2424        /** @var string Option recording the installed schema version. */
    2525        const VERSION_OPTION = 'agentimus_edge_db_version';
     26
     27        /**
     28         * @var string Option set once a poll has re-read the whole backfill window
     29         * since probes began to be counted. Until then every stored hour reads as
     30         * no probes, so the poll re-reads as far back as it can
     31         * ({@see Module::run_poll()}). Keyed to the count, not the schema version:
     32         * a site already at version 3 from a dev build needs it too.
     33         */
     34        const PROBES_OPTION = 'agentimus_edge_probes_backfilled';
    2635
    2736        /** @var int Age-based retention (days). At ~15 crawlers × 24 rows/day this is small. */
    … …  
    7584                        origin int(10) unsigned NOT NULL DEFAULT 0,
    7685                        blocked int(10) unsigned NOT NULL DEFAULT 0,
     86                        probes int(10) unsigned NOT NULL DEFAULT 0,
    7787                        served int(10) unsigned NOT NULL DEFAULT 0,
    7888                        bytes bigint(20) unsigned NOT NULL DEFAULT 0,
    … …  
    8595                // can silently skip an ADD COLUMN on a drifted schema, and stamping the
    8696                // version anyway would break every query forever.
    87                 foreach ( array( 'hour_at', 'ua', 'requests', 'cached', 'origin', 'blocked', 'served', 'bytes' ) as $column ) {
     97                foreach ( array( 'hour_at', 'ua', 'requests', 'cached', 'origin', 'blocked', 'probes', 'served', 'bytes' ) as $column ) {
    8898                        $found = $wpdb->get_var( $wpdb->prepare( "SHOW COLUMNS FROM $table LIKE %s", $column ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from our own prefix helper.
    8999                        if ( null === $found ) {
    … …  
    100110         * double-count).
    101111         *
    102          * @param array $rows Rows shaped { hour_at, ua, requests, cached, origin, blocked, served, bytes }.
     112         * @param array $rows Rows shaped { hour_at, ua, requests, cached, origin, blocked, probes?, served, bytes }.
    103113         * @return void
    104114         */
    … …  
    112122                foreach ( $rows as $row ) {
    113123                        $values[] = $wpdb->prepare(
    114                                 '(%s,%s,%d,%d,%d,%d,%d,%d)',
     124                                '(%s,%s,%d,%d,%d,%d,%d,%d,%d)',
    115125                                (string) $row['hour_at'],
    116126                                (string) $row['ua'],
    … …  
    119129                                (int) $row['origin'],
    120130                                (int) $row['blocked'],
     131                                isset( $row['probes'] ) ? (int) $row['probes'] : 0,
    121132                                (int) $row['served'],
    122133                                (int) $row['bytes']
    … …  
    124135                }
    125136                $wpdb->query( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- every tuple prepared above; table name is our own.
    126                         "INSERT INTO $table (hour_at,ua,requests,cached,origin,blocked,served,bytes) VALUES " . implode( ',', $values ) . '
     137                        "INSERT INTO $table (hour_at,ua,requests,cached,origin,blocked,probes,served,bytes) VALUES " . implode( ',', $values ) . '
    127138                        ON DUPLICATE KEY UPDATE
    128139                                requests = VALUES(requests), cached = VALUES(cached), origin = VALUES(origin),
    129                                 blocked = VALUES(blocked), served = VALUES(served), bytes = VALUES(bytes)'
     140                                blocked = VALUES(blocked), probes = VALUES(probes), served = VALUES(served), bytes = VALUES(bytes)'
    130141                );
    131142        }
    … …  
    148159         *
    149160         * @param int $days Window length in days.
    150          * @return array<int,array{ua:string,requests:int,cached:int,origin:int,blocked:int,served:int,bytes:int}>
     161         * @return array<int,array{ua:string,requests:int,cached:int,origin:int,blocked:int,probes:int,served:int,bytes:int}>
    151162         */
    152163        public static function summary( $days ) {
    … …  
    157168                        "SELECT ua,
    158169                                SUM(requests) AS requests, SUM(cached) AS cached, SUM(origin) AS origin,
    159                                 SUM(blocked) AS blocked, SUM(served) AS served, SUM(bytes) AS bytes
     170                                SUM(blocked) AS blocked, SUM(probes) AS probes, SUM(served) AS served, SUM(bytes) AS bytes
    160171                        FROM $table WHERE hour_at >= %s GROUP BY ua ORDER BY requests DESC",
    161172                        $since
    … …  
    170181                                'origin'   => (int) $row['origin'],
    171182                                'blocked'  => (int) $row['blocked'],
     183                                'probes'   => (int) $row['probes'],
    172184                                'served'   => (int) $row['served'],
    173185                                'bytes'    => (int) $row['bytes'],
    … …  
    182194         *
    183195         * @param int $hours Window length in hours.
    184          * @return array<string,array{blocked:int,passed:int,served:int}> Keyed by crawler token.
     196         * @return array<string,array{blocked:int,probes:int,passed:int,served:int}> Keyed by crawler token.
    185197         */
    186198        public static function recent( $hours ) {
    … …  
    189201                $since = gmdate( 'Y-m-d H:i:s', time() - ( max( 1, (int) $hours ) * HOUR_IN_SECONDS ) );
    190202                $rows  = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- our own table.
    191                         "SELECT ua, SUM(blocked) AS blocked, SUM(cached) + SUM(origin) AS passed, SUM(served) AS served
     203                        "SELECT ua, SUM(blocked) AS blocked, SUM(probes) AS probes, SUM(cached) + SUM(origin) AS passed, SUM(served) AS served
    192204                        FROM $table WHERE hour_at >= %s GROUP BY ua",
    193205                        $since
    … …  
    198210                        $out[ (string) $row['ua'] ] = array(
    199211                                'blocked' => (int) $row['blocked'],
     212                                'probes'  => (int) $row['probes'],
    200213                                'passed'  => (int) $row['passed'],
    201214                                'served'  => (int) $row['served'],
    … …  
    222235         *
    223236         * @param int $days How far back to read.
    224          * @return array<string,array<string,array{requests:int,blocked:int,passed:int,served:int}>>
     237         * @return array<string,array<string,array{requests:int,blocked:int,probes:int,passed:int,served:int}>>
    225238         *         day (Y-m-d, UTC) => ua => totals.
    226239         */
    … …  
    231244                $rows  = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- our own table.
    232245                        "SELECT DATE(hour_at) AS day, ua,
    233                                 SUM(requests) AS requests, SUM(blocked) AS blocked,
     246                                SUM(requests) AS requests, SUM(blocked) AS blocked, SUM(probes) AS probes,
    234247                                SUM(cached) + SUM(origin) AS passed, SUM(served) AS served
    235248                        FROM $table WHERE hour_at >= %s GROUP BY day, ua ORDER BY day ASC",
    … …  
    242255                                'requests' => (int) $row['requests'],
    243256                                'blocked'  => (int) $row['blocked'],
     257                                'probes'   => (int) $row['probes'],
    244258                                'passed'   => (int) $row['passed'],
    245259                                'served'   => (int) $row['served'],
  • agentimus/tags/1.51.9/readme.txt

    r3750928 r3751893  
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 1.51.8
     7Stable tag: 1.51.9
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    298298
    299299== Changelog ==
     300
     301= 1.51.9 =
     302* Fixed: the Cloudflare warning "Cloudflare is blocking [company], but your policy says allow" could fire when Cloudflare was only stopping a scanner that used an AI crawler's name to look for secret files such as ".env". A blocked request for a file no reader asks for is now counted as a probe and no longer counts toward that warning. The edge numbers still show every blocked request, and the first update re-checks the last three days, so a warning a scanner raised in that time clears the next time the numbers update.
    300303
    301304= 1.51.8 =
    … …  
    409412== Upgrade Notice ==
    410413
     414= 1.51.9 =
     415The Cloudflare warning no longer counts scanners that use an AI crawler's name to look for secret files. Adds one column to the edge table. No breaking changes.
     416
    411417= 1.51.8 =
    412418On sites with plain permalinks, tag and category pages are named correctly instead of "Homepage". No breaking changes.
  • agentimus/tags/1.51.9/uninstall.php

    r3662264 r3751893  
    9999        delete_option( 'agentimus_cloudflare' );
    100100        delete_option( 'agentimus_edge_db_version' );
     101        delete_option( 'agentimus_edge_probes_backfilled' );
    101102        delete_option( 'agentimus_edge_lock' );
    102103        wp_clear_scheduled_hook( 'agentimus_edge_poll' );
  • agentimus/tags/1.51.9/vendor/composer/installed.php

    r3750928 r3751893  
    22    'root' => array(
    33        'name' => 'heera/agentimus',
    4         'pretty_version' => '1.51.8',
    5         'version' => '1.51.8.0',
    6         'reference' => '2f7226fbcc2ef9982ef5a3756711a4e0a86a4655',
     4        'pretty_version' => '1.51.9',
     5        'version' => '1.51.9.0',
     6        'reference' => 'b06236c81f3b4ab1aa387dfbe919014919239bad',
    77        'type' => 'wordpress-plugin',
    88        'install_path' => __DIR__ . '/../../',
    … …  
    2121        ),
    2222        'heera/agentimus' => array(
    23             'pretty_version' => '1.51.8',
    24             'version' => '1.51.8.0',
    25             'reference' => '2f7226fbcc2ef9982ef5a3756711a4e0a86a4655',
     23            'pretty_version' => '1.51.9',
     24            'version' => '1.51.9.0',
     25            'reference' => 'b06236c81f3b4ab1aa387dfbe919014919239bad',
    2626            'type' => 'wordpress-plugin',
    2727            'install_path' => __DIR__ . '/../../',
  • agentimus/trunk/agentimus.php

    r3750928 r3751893  
    44 * Plugin URI:        https://github.com/heera/agentimus
    55 * Description:       Make your site agent-ready (llms.txt, markdown, JSON-LD, a /.well-known discovery document, AI-crawl controls, agent-activity log) AND let the AI tools you use operate it over MCP — read your data and, opt-in, draft/edit/publish posts. Lightweight, no SEO bloat.
    6  * Version:           1.51.8
     6 * Version:           1.51.9
    77 * Requires at least: 6.0
    88 * Requires PHP:      7.4
    … …  
    2020defined( 'ABSPATH' ) || exit;
    2121
    22 define( 'AGENTIMUS_VERSION', '1.51.8' );
     22define( 'AGENTIMUS_VERSION', '1.51.9' );
    2323define( 'AGENTIMUS_FILE', __FILE__ );
    2424define( 'AGENTIMUS_DIR', plugin_dir_path( __FILE__ ) );
  • agentimus/trunk/inc/Abilities/Registrar.php

    r3750405 r3751893  
    587587                                                        'origin'       => self::i( 'Requests that reached this server.' ),
    588588                                                        'blocked'      => self::i( 'Requests the edge turned away without contacting this server.' ),
     589                                                        'probes'       => self::i( 'Of the blocked requests, how many asked for a file no reader fetches (a .env, a .git folder, a wp-config copy): a scanner using this crawler\'s name. Never counted as the edge turning this crawler away.' ),
    589590                                                        'served'       => self::i( 'Requests that got a page: a 2xx the edge let through, not counting /robots.txt reads. Passing the edge is not taking content — a request the origin answered 403 or 404 took nothing.' ),
    590591                                                        'bytes'        => self::i(),
  • agentimus/trunk/inc/Admin.php

    r3750928 r3751893  
    993993                                // ⭐ A security-only release gets ONE row (1.51.1): the fix is the
    994994                                // whole release, and there is one thing an owner may want to do.
    995                                 // ⭐ So does a one-fix release (1.51.8) — plain-permalink term pages
    996                                 // named in the log and the search screens.
     995                                // ⭐ So does a one-fix release (1.51.9) — a scanner's probes no longer
     996                                // raise the Cloudflare warning. ⛔ No company is named: the release
     997                                // text names no specific bot (his ruling, 1.51.3 and 1.51.4).
    997998                                array(
    998                                         'icon'  => 'page',
    999                                         'title' => 'Tag Pages Named on Plain Permalinks',
    1000                                         'text'  => 'On a site with plain permalinks, the Request Log called every tag and category page Homepage. It now shows the tag or category name, here and in your search results.',
     999                                        'icon'  => 'shield',
     1000                                        'title' => 'Scanners No Longer Trip the Cloudflare Warning',
     1001                                        'text'  => 'A scanner that used an AI crawler\'s name to look for secret files like .env could make it look as if Cloudflare was blocking that company. Those requests are now counted apart, and only real blocks raise the warning.',
    10011002                                ),
    10021003                        ),
  • agentimus/trunk/inc/Cloudflare/Client.php

    r3722083 r3751893  
    215215
    216216        /**
     217         * Hourly edge-refused requests for a window, one row per (hour, user agent,
     218         * path) — the paths {@see Module::aggregate()} needs to tell a scanner's
     219         * probe from a reader being turned away. The filter is the same "blocked"
     220         * test as {@see blocked_sources()}. The paths are read here, classified in
     221         * the poll and never stored: the table keeps only the count.
     222         *
     223         * @param string $token   API token, plaintext.
     224         * @param string $zone_id Zone tag.
     225         * @param int    $since   Window start, Unix UTC.
     226         * @param int    $until   Window end, Unix UTC.
     227         * @return array { rows?: array<int,array{hour:string,ua:string,path:string,requests:int}>, error?: string }
     228         */
     229        public function blocked_paths( $token, $zone_id, $since, $until ) {
     230                $query = 'query ($zone: String!, $since: Time!, $until: Time!) {
     231                        viewer {
     232                                zones(filter: { zoneTag: $zone }) {
     233                                        httpRequestsAdaptiveGroups(
     234                                                limit: ' . self::GRAPHQL_LIMIT . ',
     235                                                filter: { datetime_geq: $since, datetime_lt: $until, requestSource: "eyeball", edgeResponseStatus_in: [403, 429], originResponseStatus: 0 },
     236                                                orderBy: [count_DESC]
     237                                        ) {
     238                                                count
     239                                                dimensions { datetimeHour userAgent clientRequestPath }
     240                                        }
     241                                }
     242                        }
     243                }';
     244
     245                $out = $this->graphql( $token, $query, array(
     246                        'zone'  => (string) $zone_id,
     247                        'since' => gmdate( 'Y-m-d\TH:i:s\Z', (int) $since ),
     248                        'until' => gmdate( 'Y-m-d\TH:i:s\Z', (int) $until ),
     249                ) );
     250                if ( isset( $out['error'] ) ) {
     251                        return $out;
     252                }
     253
     254                // The expected container, or it's an error — never "real empty data".
     255                if ( ! isset( $out['json']['data']['viewer']['zones'][0]['httpRequestsAdaptiveGroups'] ) ) {
     256                        return array( 'error' => __( 'Cloudflare returned an unexpected response shape.', 'agentimus' ) );
     257                }
     258
     259                $rows = array();
     260                foreach ( (array) $out['json']['data']['viewer']['zones'][0]['httpRequestsAdaptiveGroups'] as $g ) {
     261                        if ( ! isset( $g['dimensions'] ) ) {
     262                                continue;
     263                        }
     264                        $d      = (array) $g['dimensions'];
     265                        $rows[] = array(
     266                                'hour'     => (string) ( isset( $d['datetimeHour'] ) ? $d['datetimeHour'] : '' ),
     267                                'ua'       => (string) ( isset( $d['userAgent'] ) ? $d['userAgent'] : '' ),
     268                                'path'     => (string) ( isset( $d['clientRequestPath'] ) ? $d['clientRequestPath'] : '' ),
     269                                'requests' => (int) ( isset( $g['count'] ) ? $g['count'] : 0 ),
     270                        );
     271                }
     272
     273                return array( 'rows' => $rows );
     274        }
     275
     276        /**
    217277         * Hourly /robots.txt requests for a window, in the same row shape as
    218278         * {@see hourly_traffic()}. Those rows carry no path, so a crawler reading
  • agentimus/trunk/inc/Cloudflare/Conflicts.php

    r3722083 r3751893  
    6262         * the owner fixing the cause, instead of shouting for a week about history.
    6363         *
    64          * @param array      $crawlers Per-crawler totals: { ua, name, operator, requests, cached, origin, blocked, served }.
     64         * @param array      $crawlers Per-crawler totals: { ua, name, operator, requests, cached, origin, blocked, probes?, served }.
    6565         * @param array      $policy   The owner's declarations:
    6666         *                             - ai_input (bool)        robots Content-Signal ai-input.
    … …  
    6969         *                             - allowed_agents (array) user-agent rules the OWNER allowed in the review queue.
    7070         * @param int        $days     The window the totals cover, for the copy.
    71          * @param array|null $recent   Last-24h per-crawler map { ua => { blocked, passed, served } }
     71         * @param array|null $recent   Last-24h per-crawler map { ua => { blocked, probes?, passed, served } }
    7272         *                             (see Table::recent()), or null to treat all
    7373         *                             evidence as current.
    … …  
    9494                                        continue;
    9595                                }
    96                                 $blocked  = (int) $c['blocked'];
    97                                 $requests = (int) $c['requests'];
     96                                // A blocked probe is no reader turned away ({@see refused()}) — and
     97                                // no reading attempt either, so it leaves the share's base too.
     98                                $blocked  = self::refused( $c );
     99                                $requests = (int) $c['requests'] - self::probes( $c );
    98100                                $op       = (string) $c['operator'];
    99101                                if ( '' === $op || $blocked < self::MIN_BLOCKED || $blocked < $requests * self::MIN_BLOCKED_SHARE ) {
    … …  
    103105                                // blocking fires — otherwise the fix already landed and the warning
    104106                                // would just be nagging about history.
    105                                 if ( null !== $recent && empty( $recent[ $token ]['blocked'] ) ) {
     107                                if ( null !== $recent && ( ! isset( $recent[ $token ] ) || 0 === self::refused( $recent[ $token ] ) ) ) {
    106108                                        continue;
    107109                                }
    … …  
    111113                                $per_operator[ $op ]['blocked']  += $blocked;
    112114                                $per_operator[ $op ]['requests'] += $requests;
    113                                 $per_operator[ $op ]['recent']   += null !== $recent ? (int) $recent[ $token ]['blocked'] : 0;
     115                                $per_operator[ $op ]['recent']   += null !== $recent ? self::refused( $recent[ $token ] ) : 0;
    114116                        }
    115117
    … …  
    294296
    295297        /**
     298         * The requests the edge turned away that were a READER being refused: the
     299         * blocked count less the scanner probes in it ({@see Module::is_probe_path()}).
     300         * A row stored before probes were counted has none, and counts as before.
     301         *
     302         * @param array $row Any totals row carrying `blocked` and optionally `probes`.
     303         * @return int
     304         */
     305        public static function refused( array $row ) {
     306                return max( 0, ( isset( $row['blocked'] ) ? (int) $row['blocked'] : 0 ) - self::probes( $row ) );
     307        }
     308
     309        /**
     310         * The scanner probes within a row's blocked count, never more than it.
     311         *
     312         * @param array $row Any totals row.
     313         * @return int
     314         */
     315        private static function probes( array $row ) {
     316                $blocked = isset( $row['blocked'] ) ? (int) $row['blocked'] : 0;
     317                return min( $blocked, isset( $row['probes'] ) ? max( 0, (int) $row['probes'] ) : 0 );
     318        }
     319
     320        /**
    296321         * The slug an operator's warn conflict carries in its id
    297322         * ("edge-blocks-<slug>"). One transform, shared with every reader that has
    … …  
    330355         * Pure — no DB, no time() — so it unit-tests standalone, like detect().
    331356         *
    332          * @param array  $daily    Day => ua => { requests, blocked, passed, served }, oldest first ({@see Table::daily()}).
     357         * @param array  $daily    Day => ua => { requests, blocked, probes?, passed, served }, oldest first ({@see Table::daily()}).
    333358         * @param string $id       The conflict id to date.
    334359         * @param array  $operators ua => operator name, for the per-operator rollup a warn conflict uses.
    … …  
    367392                                        continue;
    368393                                }
    369                                 $sums['blocked']  += (int) $t['blocked'];
    370                                 $sums['requests'] += (int) $t['requests'];
     394                                $sums['blocked']  += self::refused( $t );
     395                                $sums['requests'] += (int) $t['requests'] - self::probes( $t );
    371396                        }
    372397                        return $sums['blocked'] >= self::MIN_BLOCKED
  • agentimus/trunk/inc/Cloudflare/Module.php

    r3722083 r3751893  
    152152                $since  = $latest > 0 ? $latest - self::OVERLAP_SECONDS : $now - self::BACKFILL_SECONDS;
    153153                $since  = max( $since, $now - self::BACKFILL_SECONDS );
     154                // ⛔ An hour stored before probes were counted keeps saying "no probes"
     155                // until it is re-read — and the overlap only re-reads two hours, so a
     156                // scanner's morning would go on firing the warning for a day and
     157                // inflating the week for seven. Once, re-read everything Cloudflare
     158                // still holds; a re-read hour replaces its row.
     159                $backfill = ! get_option( Table::PROBES_OPTION );
     160                if ( $backfill ) {
     161                        $since = $now - self::BACKFILL_SECONDS;
     162                }
    154163
    155164                while ( $since < $now ) {
    … …  
    164173                                }
    165174                        }
     175                        if ( ! isset( $out['error'] ) ) {
     176                                // Same rule for the blocked paths: without them every scanner probe
     177                                // would be stored as a reader turned away.
     178                                $paths = $this->client->blocked_paths( $token, $zone, $since, $until );
     179                                if ( isset( $paths['error'] ) ) {
     180                                        $out = $paths;
     181                                }
     182                        }
    166183                        if ( isset( $out['error'] ) ) {
    167184                                // Keep the last good data; the panel shows the numbers' age and this note.
    … …  
    169186                                return;
    170187                        }
    171                         Table::upsert( self::aggregate( (array) $out['rows'], (array) $robots['rows'] ) );
     188                        Table::upsert( self::aggregate( (array) $out['rows'], (array) $robots['rows'], (array) $paths['rows'] ) );
    172189                        $since = $until;
    173190                }
    174191
    175192                $this->settings->record_poll( '' );
     193                if ( $backfill ) {
     194                        update_option( Table::PROBES_OPTION, 1, false );
     195                }
    176196                Table::prune();
    177197
    … …  
    200220         * answers 403 or 404 took nothing. The training notice counts this.
    201221         *
     222         * Within `blocked`, `probes` counts the requests for a path no reader asks
     223         * for ({@see is_probe_path()}). ⛔⛔ A BLOCKED PROBE IS NOT A READER TURNED
     224         * AWAY. On heera.it (2026-10-17) "Cloudflare is blocking Anthropic" and
     225         * "…You.com" were one scanner on Google Cloud asking for .env files under
     226         * both names, stopped by his own bad-paths rule — the third time after
     227         * OpenAI (08-29, 09-17). Neither company publishes addresses to check, so
     228         * the spoof check could only say "unknown"; the path says it plainly.
     229         *
    202230         * @param array $raw    Rows from Client::hourly_traffic().
    203231         * @param array $robots Rows from Client::robots_fetches() for the same window.
     232         * @param array $paths  Rows from Client::blocked_paths() for the same window.
    204233         * @return array<int,array> Rows shaped for Table::upsert().
    205234         */
    206         public static function aggregate( array $raw, array $robots = array() ) {
     235        public static function aggregate( array $raw, array $robots = array(), array $paths = array() ) {
    207236                $cached_statuses = array( 'hit', 'stale', 'updating', 'revalidated' );
    208237                $buckets         = array();
    … …  
    227256                                        'origin'   => 0,
    228257                                        'blocked'  => 0,
     258                                        'probes'   => 0,
    229259                                        'served'   => 0,
    230260                                        'bytes'    => 0,
    … …  
    257287                }
    258288
     289                foreach ( $paths as $row ) {
     290                        $key = self::hour_key( (string) $row['hour'] ) . '|' . self::crawler_token( (string) $row['ua'] );
     291                        if ( isset( $buckets[ $key ] ) && self::is_probe_path( (string) $row['path'] ) ) {
     292                                $buckets[ $key ]['probes'] += (int) $row['requests'];
     293                        }
     294                }
     295                // Two reads of the same window can disagree by a few requests; a probe
     296                // count is a share of `blocked` and never more than it.
     297                foreach ( $buckets as $key => $bucket ) {
     298                        $buckets[ $key ]['probes'] = min( $bucket['probes'], $bucket['blocked'] );
     299                }
     300
    259301                return array_values( $buckets );
     302        }
     303
     304        /**
     305         * Whether a request path is a scanner's probe — a secret or a config file
     306         * that no reader of the site, human or AI, ever asks for.
     307         *
     308         * Deliberately narrow: hidden files and folders (/.env, /.git/config,
     309         * /.aws/credentials — every one except /.well-known/), anything ending in
     310         * .env, wp-config.* copies, database dumps and editor backups, phpinfo, and
     311         * Vite's /@fs/ file-read hole. An ordinary page, a feed or robots.txt is
     312         * never a probe. Decoded first, so /admin%2F.env is caught like /admin/.env.
     313         *
     314         * @param string $path Request path, as Cloudflare reports it.
     315         * @return bool
     316         */
     317        public static function is_probe_path( $path ) {
     318                $path = strtolower( (string) $path );
     319                for ( $i = 0; $i < 2 && false !== strpos( $path, '%' ); $i++ ) {
     320                        $path = rawurldecode( $path );
     321                }
     322                $path = (string) strtok( $path, '?' );
     323
     324                foreach ( explode( '/', str_replace( '\\', '/', $path ) ) as $segment ) {
     325                        if ( '' === $segment || '.' === $segment || '..' === $segment || '.well-known' === $segment ) {
     326                                continue;
     327                        }
     328                        if ( '.' === $segment[0] || '@fs' === $segment || 0 === strpos( $segment, 'wp-config' ) || 0 === strpos( $segment, 'phpinfo' ) ) {
     329                                return true;
     330                        }
     331                        if ( preg_match( '/\.(env|sql|bak|swp)$/', $segment ) ) {
     332                                return true;
     333                        }
     334                }
     335                return false;
    260336        }
    261337
  • agentimus/trunk/inc/Cloudflare/SpoofCheck.php

    r3709876 r3751893  
    184184         * @param array $crawlers Rows with { ua, operator }.
    185185         * @param array $policy   The declarations {@see Conflicts::detect()} takes.
    186          * @param array $recent   ua => { blocked, passed } ({@see Table::recent()}).
     186         * @param array $recent   ua => { blocked, probes?, passed } ({@see Table::recent()}).
    187187         * @return array<string,int> operator => blocked requests.
    188188         */
    … …  
    196196                        }
    197197                        $out[ $op ] = ( isset( $out[ $op ] ) ? $out[ $op ] : 0 )
    198                                 + ( isset( $recent[ $ua ]['blocked'] ) ? (int) $recent[ $ua ]['blocked'] : 0 );
     198                                + ( isset( $recent[ $ua ] ) ? Conflicts::refused( (array) $recent[ $ua ] ) : 0 );
    199199                }
    200200                return $out;
  • agentimus/trunk/inc/Cloudflare/Table.php

    r3722083 r3751893  
    33 * Edge aggregates table — one row per (UTC hour, crawler), holding the counts
    44 * the panel shows: total edge requests, cache-served, origin-served, blocked
    5  * at the edge, and bytes out.
     5 * at the edge (and how many of those were scanner probes), and bytes out.
    66 *
    77 * Cloudflare's Free plan forgets these numbers within days; this table is why
    … …  
    2020
    2121        /** @var string Schema version — bump on any structural change. */
    22         const VERSION = '2';
     22        const VERSION = '3';
    2323
    2424        /** @var string Option recording the installed schema version. */
    2525        const VERSION_OPTION = 'agentimus_edge_db_version';
     26
     27        /**
     28         * @var string Option set once a poll has re-read the whole backfill window
     29         * since probes began to be counted. Until then every stored hour reads as
     30         * no probes, so the poll re-reads as far back as it can
     31         * ({@see Module::run_poll()}). Keyed to the count, not the schema version:
     32         * a site already at version 3 from a dev build needs it too.
     33         */
     34        const PROBES_OPTION = 'agentimus_edge_probes_backfilled';
    2635
    2736        /** @var int Age-based retention (days). At ~15 crawlers × 24 rows/day this is small. */
    … …  
    7584                        origin int(10) unsigned NOT NULL DEFAULT 0,
    7685                        blocked int(10) unsigned NOT NULL DEFAULT 0,
     86                        probes int(10) unsigned NOT NULL DEFAULT 0,
    7787                        served int(10) unsigned NOT NULL DEFAULT 0,
    7888                        bytes bigint(20) unsigned NOT NULL DEFAULT 0,
    … …  
    8595                // can silently skip an ADD COLUMN on a drifted schema, and stamping the
    8696                // version anyway would break every query forever.
    87                 foreach ( array( 'hour_at', 'ua', 'requests', 'cached', 'origin', 'blocked', 'served', 'bytes' ) as $column ) {
     97                foreach ( array( 'hour_at', 'ua', 'requests', 'cached', 'origin', 'blocked', 'probes', 'served', 'bytes' ) as $column ) {
    8898                        $found = $wpdb->get_var( $wpdb->prepare( "SHOW COLUMNS FROM $table LIKE %s", $column ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from our own prefix helper.
    8999                        if ( null === $found ) {
    … …  
    100110         * double-count).
    101111         *
    102          * @param array $rows Rows shaped { hour_at, ua, requests, cached, origin, blocked, served, bytes }.
     112         * @param array $rows Rows shaped { hour_at, ua, requests, cached, origin, blocked, probes?, served, bytes }.
    103113         * @return void
    104114         */
    … …  
    112122                foreach ( $rows as $row ) {
    113123                        $values[] = $wpdb->prepare(
    114                                 '(%s,%s,%d,%d,%d,%d,%d,%d)',
     124                                '(%s,%s,%d,%d,%d,%d,%d,%d,%d)',
    115125                                (string) $row['hour_at'],
    116126                                (string) $row['ua'],
    … …  
    119129                                (int) $row['origin'],
    120130                                (int) $row['blocked'],
     131                                isset( $row['probes'] ) ? (int) $row['probes'] : 0,
    121132                                (int) $row['served'],
    122133                                (int) $row['bytes']
    … …  
    124135                }
    125136                $wpdb->query( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- every tuple prepared above; table name is our own.
    126                         "INSERT INTO $table (hour_at,ua,requests,cached,origin,blocked,served,bytes) VALUES " . implode( ',', $values ) . '
     137                        "INSERT INTO $table (hour_at,ua,requests,cached,origin,blocked,probes,served,bytes) VALUES " . implode( ',', $values ) . '
    127138                        ON DUPLICATE KEY UPDATE
    128139                                requests = VALUES(requests), cached = VALUES(cached), origin = VALUES(origin),
    129                                 blocked = VALUES(blocked), served = VALUES(served), bytes = VALUES(bytes)'
     140                                blocked = VALUES(blocked), probes = VALUES(probes), served = VALUES(served), bytes = VALUES(bytes)'
    130141                );
    131142        }
    … …  
    148159         *
    149160         * @param int $days Window length in days.
    150          * @return array<int,array{ua:string,requests:int,cached:int,origin:int,blocked:int,served:int,bytes:int}>
     161         * @return array<int,array{ua:string,requests:int,cached:int,origin:int,blocked:int,probes:int,served:int,bytes:int}>
    151162         */
    152163        public static function summary( $days ) {
    … …  
    157168                        "SELECT ua,
    158169                                SUM(requests) AS requests, SUM(cached) AS cached, SUM(origin) AS origin,
    159                                 SUM(blocked) AS blocked, SUM(served) AS served, SUM(bytes) AS bytes
     170                                SUM(blocked) AS blocked, SUM(probes) AS probes, SUM(served) AS served, SUM(bytes) AS bytes
    160171                        FROM $table WHERE hour_at >= %s GROUP BY ua ORDER BY requests DESC",
    161172                        $since
    … …  
    170181                                'origin'   => (int) $row['origin'],
    171182                                'blocked'  => (int) $row['blocked'],
     183                                'probes'   => (int) $row['probes'],
    172184                                'served'   => (int) $row['served'],
    173185                                'bytes'    => (int) $row['bytes'],
    … …  
    182194         *
    183195         * @param int $hours Window length in hours.
    184          * @return array<string,array{blocked:int,passed:int,served:int}> Keyed by crawler token.
     196         * @return array<string,array{blocked:int,probes:int,passed:int,served:int}> Keyed by crawler token.
    185197         */
    186198        public static function recent( $hours ) {
    … …  
    189201                $since = gmdate( 'Y-m-d H:i:s', time() - ( max( 1, (int) $hours ) * HOUR_IN_SECONDS ) );
    190202                $rows  = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- our own table.
    191                         "SELECT ua, SUM(blocked) AS blocked, SUM(cached) + SUM(origin) AS passed, SUM(served) AS served
     203                        "SELECT ua, SUM(blocked) AS blocked, SUM(probes) AS probes, SUM(cached) + SUM(origin) AS passed, SUM(served) AS served
    192204                        FROM $table WHERE hour_at >= %s GROUP BY ua",
    193205                        $since
    … …  
    198210                        $out[ (string) $row['ua'] ] = array(
    199211                                'blocked' => (int) $row['blocked'],
     212                                'probes'  => (int) $row['probes'],
    200213                                'passed'  => (int) $row['passed'],
    201214                                'served'  => (int) $row['served'],
    … …  
    222235         *
    223236         * @param int $days How far back to read.
    224          * @return array<string,array<string,array{requests:int,blocked:int,passed:int,served:int}>>
     237         * @return array<string,array<string,array{requests:int,blocked:int,probes:int,passed:int,served:int}>>
    225238         *         day (Y-m-d, UTC) => ua => totals.
    226239         */
    … …  
    231244                $rows  = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- our own table.
    232245                        "SELECT DATE(hour_at) AS day, ua,
    233                                 SUM(requests) AS requests, SUM(blocked) AS blocked,
     246                                SUM(requests) AS requests, SUM(blocked) AS blocked, SUM(probes) AS probes,
    234247                                SUM(cached) + SUM(origin) AS passed, SUM(served) AS served
    235248                        FROM $table WHERE hour_at >= %s GROUP BY day, ua ORDER BY day ASC",
    … …  
    242255                                'requests' => (int) $row['requests'],
    243256                                'blocked'  => (int) $row['blocked'],
     257                                'probes'   => (int) $row['probes'],
    244258                                'passed'   => (int) $row['passed'],
    245259                                'served'   => (int) $row['served'],
  • agentimus/trunk/readme.txt

    r3750928 r3751893  
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 1.51.8
     7Stable tag: 1.51.9
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    298298
    299299== Changelog ==
     300
     301= 1.51.9 =
     302* Fixed: the Cloudflare warning "Cloudflare is blocking [company], but your policy says allow" could fire when Cloudflare was only stopping a scanner that used an AI crawler's name to look for secret files such as ".env". A blocked request for a file no reader asks for is now counted as a probe and no longer counts toward that warning. The edge numbers still show every blocked request, and the first update re-checks the last three days, so a warning a scanner raised in that time clears the next time the numbers update.
    300303
    301304= 1.51.8 =
    … …  
    409412== Upgrade Notice ==
    410413
     414= 1.51.9 =
     415The Cloudflare warning no longer counts scanners that use an AI crawler's name to look for secret files. Adds one column to the edge table. No breaking changes.
     416
    411417= 1.51.8 =
    412418On sites with plain permalinks, tag and category pages are named correctly instead of "Homepage". No breaking changes.
  • agentimus/trunk/uninstall.php

    r3662264 r3751893  
    9999        delete_option( 'agentimus_cloudflare' );
    100100        delete_option( 'agentimus_edge_db_version' );
     101        delete_option( 'agentimus_edge_probes_backfilled' );
    101102        delete_option( 'agentimus_edge_lock' );
    102103        wp_clear_scheduled_hook( 'agentimus_edge_poll' );
  • agentimus/trunk/vendor/composer/installed.php

    r3750928 r3751893  
    22    'root' => array(
    33        'name' => 'heera/agentimus',
    4         'pretty_version' => '1.51.8',
    5         'version' => '1.51.8.0',
    6         'reference' => '2f7226fbcc2ef9982ef5a3756711a4e0a86a4655',
     4        'pretty_version' => '1.51.9',
     5        'version' => '1.51.9.0',
     6        'reference' => 'b06236c81f3b4ab1aa387dfbe919014919239bad',
    77        'type' => 'wordpress-plugin',
    88        'install_path' => __DIR__ . '/../../',
    … …  
    2121        ),
    2222        'heera/agentimus' => array(
    23             'pretty_version' => '1.51.8',
    24             'version' => '1.51.8.0',
    25             'reference' => '2f7226fbcc2ef9982ef5a3756711a4e0a86a4655',
     23            'pretty_version' => '1.51.9',
     24            'version' => '1.51.9.0',
     25            'reference' => 'b06236c81f3b4ab1aa387dfbe919014919239bad',
    2626            'type' => 'wordpress-plugin',
    2727            'install_path' => __DIR__ . '/../../',
Note: See TracChangeset for help on using the changeset viewer.