true, 'enable_llms_full' => true, 'enable_markdown' => true, 'enable_robots' => true, 'enable_schema' => true, 'enable_topics' => true, // Per-page AI topics → JSON-LD keywords + per-page markdown front matter. On by default; each page still controls its own topics via the editor meta box. 'enable_ai_description' => true, // Per-page AI description → JSON-LD `description` + the per-page markdown lead, plus (when ai_description_meta_tag is on) the HTML . On by default; blank pages fall back to the excerpt. Each page sets its own value via the editor meta box. 'ai_description_meta_tag' => true, // Sub-option of enable_ai_description: also make the AI description the page's — replacing the theme's, still deferring wholesale to a real SEO plugin. Off = enrich only JSON-LD + .md and never touch the
. See Description::should_emit()/meta_tag_enabled(). 'enable_seo_titles' => true, // Solo mode only (SeoContext): a per-page "SEO title" field that replaces the title part of the document title, site-name suffix kept. Gap-only in spirit — with no per-page value nothing changes, and with an SEO suite active the field hides and the filter stands down at request time, so it's safe on by default. See Seo. 'enable_social_cards' => true, // Solo mode only: Open Graph/X card tags on the front page + singular views, so shared links preview with a real title, description and image. GAP-ONLY like the description meta tag: the head is buffered and ours print only when no theme/plugin (Jetpack, a social plugin, the-alpha) already emitted og: tags. See Seo::buffer_end(). 'enable_canonicals' => true, // Solo mode only: rel=canonical on the views core leaves bare — blog-as-front + main archive kinds. Gap-only via the same head buffer (a theme may fill these too); core already covers every singular view; paginated pages 2+ get NOTHING (self-referential paged canonicals are a later refinement, a page-1 pointer would be wrong). See Seo::output_canonical(). 'social_default_image' => 0, // Attachment ID of a site-wide fallback card image, used when a shared page has no featured image (the Site Icon is the final fallback). 0 = none. Picked via the media button in Settings → Discovery → Search basics. 'enable_ask_ai' => true, // "Ask AI about this post" bar after singular post content — assistant links (ChatGPT, Claude, Perplexity, Google AI Mode, Grok) prefilled with the post's URL, so one reader click becomes an assistant fetch the request log can see. On by default: plain links, no script, nothing sent until a reader clicks. Post types via the agentimus_ask_ai_post_types filter. See AskAi. 'topics_derive_default' => true, // Default for new posts: also derive topics from the page's tags & categories (a manual list still wins). 'topics_max' => 12, // Hard cap on emitted topics per page, to keep the machine surfaces lean. 'enable_activity' => true, // How long agent hits + AI-referral counts are KEPT. The dashboard reports on at // most 30 days of that (Repository::report_days), so keeping more gives the // Request log a deeper history without stretching the summary cards. Flagged IPs // are NOT governed by this — they are the only PII stored and keep their own // shorter retention (FlaggedIps::RETENTION_DAYS). 'activity_retention_days' => 30, // Age-based pruning. OFF does not mean "keep forever": the row cap below still // trims oldest-first, so records are kept until the log reaches that size. There // is deliberately no "no limit" option — an unbounded table is how a shared host // runs out of disk. See Repository::prune() / trim_to_cap(). 'activity_auto_prune' => true, // Hard ceiling on stored rows, whatever the retention. ~300–700 bytes/row all-in, // so 50,000 rows is roughly 13–33 MB. 'activity_max_rows' => 50000, 'enable_referral_beacon' => false, // Opt-in "CDN mode": count AI referrals via a tiny front-end beacon instead of server-side, so the count survives a full-page CDN/edge cache (which hides server-side hits). OFF by default — a normal site adds NO front-end script and counts server-side. When ON, the server-side recorder stands down (the two can't be deduped: no per-visit id). See Activity\Referrals::beacon_enabled(). 'log_unknown_referrers' => false, // Opt-in diagnostic: when a human browser arrives from a referrer/utm_source the AI-source map does NOT recognise, record just that host/token so the owner can see what "Traffic from AI" is missing (a miss is otherwise invisible). OFF by default because, unlike the referral counter, it writes on EVERY externally referred page view — it's meant to run for a week, not forever. See Activity\UnknownSources. 'enable_page_checks' => true, // Editor-only "AI Readability" panel: per-page pass/warn checks (headings, summary, thin content, link density, image alt). No front-end output — an authoring aid — so it's safe on by default. 'enable_share_copy' => true, // Editor-only "Share" tab in the Agentimus panel: per-network share drafts written locally (optional per-card AI polish when a provider is configured). No front-end output, nothing posted anywhere — an authoring aid like the readability panel, so on by default. Off = the tab is not composed AND its REST route refuses. See ShareCopy. 'enable_visibility' => false, // Opt-in "AI Visibility" (citation tracking): enable the AI Visibility screen (More menu) AND the Cited rung in the score. OFF by default — it's BYOK (spends the owner's AI credit) and most sites won't use it, so citation only enters the UI + the composite when explicitly turned on. When off the score is a clean 4-rung ladder (Cited excluded, weight redistributed). 'enable_sitemap' => true, // Gap-only fallback: stands down when core/SEO provides one, so it's safe on by default. 'enable_changes' => true, // JSON change feed at /agentimus-changes.json — recently added/updated pages, with a `?since=` delta filter so agents re-check only what changed. Read-only and cheap (one bounded, cached query), so on by default like the sitemap. 'bypass_shared_cache' => false, // Opt-in: send `Cache-Control: no-store` on the AI endpoints (llms.txt, the .well-known docs, robots.txt, the change feed) so a shared cache/CDN in front of WordPress doesn't serve stored copies — keeping agent fetches counted in the activity log and freshness-sensitive endpoints current. OFF by default (the docs are edge-cacheable for performance). Only affects caches that RESPECT Cache-Control. See CacheHeaders. 'purge_cache_on_change' => true, // ON by default: when content changes, ask every detected page cache (WP Rocket, Nginx Helper, W3TC, LiteSpeed, WP Super Cache, Cache Enabler, or an `agentimus_purge_url` listener) to drop the agent files — llms.txt/llms-full.txt/robots.txt/the change feed/the .well-known docs, plus the edited post's own .md twin — so a cache can't serve a stale copy after an edit. Feature-detected (no-op with no cache present). Freshness only, NOT the activity-log under-count (that needs the fetch to reach WP; see bypass_shared_cache). See CachePurge. 'enable_security_txt' => false, // Opt-in: generate /.well-known/security.txt only when asked AND no file/other plugin already provides one. 'enable_signing' => true, // On by default: RFC 9421 signatures on the discovery docs + a published Web Bot Auth key directory. Feature-detected — silently stands down when libsodium is unavailable, so it's safe as a default. 'enable_webmcp' => false, // Experimental, opt-in: register the site's READ-ONLY tools with in-browser AI agents via the WebMCP browser API (navigator.modelContext). Off by default — it's an emerging, Chrome-experimental standard and it adds a small front-end script, so it ships only when the owner asks. The script is inert in browsers without the API. 'enable_mcp_server' => false, // Opt-in: boot the BUNDLED WordPress MCP Adapter and publish the scoped, read-only Agentimus MCP server at /wp-json/agentimus/v1/mcp (application-password/OAuth authenticated; every tool keeps its own permission check). OFF by default — it opens an authenticated machine endpoint, so it ships only when the owner asks. No-ops below WP 6.9 (needs the Abilities API). See Abilities\AdapterBootstrap + Abilities\Registrar::register_mcp_server(). 'enable_agent_writes' => false, // Second, separate opt-in on top of the MCP server: register the WRITE abilities (create/edit drafts, set a page's AI topics/description, apply a readiness fix). OFF by default so "the MCP server is read-only" stays true until the owner deliberately says otherwise. Gates REGISTRATION, not just MCP exposure — off means the write abilities don't exist on any surface (MCP, abilities REST, the in-admin assistant). Requires enable_mcp_server: it renders as a sub-toggle of the MCP card, so sanitize() cascades it off when the server is off (no invisible armed switch). Every write still runs as the authenticated user with that user's capabilities. See Abilities\Registrar::register_write_abilities(). 'agent_writes_publish' => false, // Sub-option of enable_agent_writes (and cascaded off with it, same rule): allow an agent to PUBLISH (status=publish) rather than only draft/pending. OFF by default — "write drafts I review" and "publish to my live site" are different levels of trust, so going live stays a human decision until this is flipped. The user's publish_posts capability is still required on top. See Abilities\ContentWriter::validate_status(). 'webmcp_hidden_tools' => array(), // Owner opt-OUT: names of WebMCP tools to hide from browser agents (deny-list; empty = expose every registered tool). Lets the owner curate per-tool which to expose. 'llms_full_posts' => 50, 'llms_full_max_kb' => 900, // Hard byte budget for /llms-full.txt (KB): generation stops cleanly here and links the index. Deliberately UNDER 1024, not at it: a ~1 MB body sits exactly at the common memcached item ceiling, so with the key + serialization overhead the object cache silently REJECTS it — then every request re-runs the full build. 900 KB leaves headroom so the document actually caches. Filterable higher for sites whose cache (or lack of one) can take it. 'post_types' => self::default_post_types(), 'evergreen_categories' => array(), // Category term IDs whose posts are exempt from the content "freshness" check — timeless content (references, tutorials, legal) that doesn't go stale with age. Empty = every post is age-checked. 'optimize_ignored' => array(), // Post IDs the owner marked "not cited content" from the Optimize worklist — pages that aren't meant to be quoted (landing/utility/index). Left out of citability grading entirely; always shown as a visible "set aside" count so the score stays honest. 'rest_namespaces' => array(), // Owner-curated REST namespaces to publish in discovery (opt-in; empty = none). 'oauth_auth_server' => '', // Optional OAuth authorization-server URL; when set, serve RFC 9728 protected-resource metadata. Never fabricate RFC 8414. 'suppressed_resources' => array(), // Owner opt-OUT: ids of provider-registered Resources to hide from all output. Declared Resources default to published (spec §04), so empty = publish everything a provider declared. 'identity' => array( 'entity_type' => 'Person', // Person, or an Organization subtype (Organization, LocalBusiness, Store, …) — see entity_types(). 'name' => get_bloginfo( 'name' ), 'role' => '', 'about' => '', 'not_description' => '', // What the site is NOT — a disambiguation so agents don't miscategorize it (Schema.org disambiguatingDescription). 'audience' => '', // Who the site is for (Schema.org audience.audienceType). 'contact_email' => '', // Opt-in; published in discovery.json only if set. 'expertise' => array(), 'same_as' => array(), 'services' => array(), // Opt-in: owner-declared services [{name, description, url}] → Schema.org Service. Empty = none (never guessed from content). ), 'security' => array( 'contacts' => array(), // Extra Contact URIs/emails beyond identity.contact_email (which seeds the first one). 'policy' => '', // URL to the vuln-disclosure policy. 'acknowledgments' => '', // URL to a hall-of-fame / thanks page. 'encryption' => '', // URL to an OpenPGP key. 'hiring' => '', // URL to security job listings. 'preferred_languages' => '', // e.g. "en, fr"; empty falls back to the site locale. 'expires_days' => 182, // RFC 9116 SHOULD keep Expires < 1 year. ), 'content_signal' => array( 'search' => true, 'ai_input' => true, 'ai_train' => false, ), 'blocked_trainers' => self::known_trainers(), // AI-usage signals beyond robots.txt: publish the SAME ai-train decision // where robots.txt can't reach — a W3C TDM Reservation Protocol response // header and /.well-known/tdmrep.json. On by default so the existing // "no AI training" default stance is asserted in every channel, not just // the advisory one ("one decision, every channel"). 'enable_ai_header' => true, // tdm-reservation header on content pages (only when training is blocked). 'enable_tdmrep' => true, // serve /.well-known/tdmrep.json (only when training is blocked). 'ai_noai_header' => false, // also send the non-standard X-Robots-Tag: noai (best-effort). 'tdm_policy_url' => '', // optional URL to the owner's TDM / AI-usage policy. // Optional HARD enforcement (opt-in). robots.txt above is advisory; this // actually refuses the request with a 403. Off by default so a fresh // install never silently blocks anyone. 'block_agents' => false, // Master switch for denying agents at the generated endpoints. 'block_spoofed' => true, // When blocking is on, also deny the DECEPTIVE classes: spoofed/legacy-device UAs (the "Likely spoof/scanner" class) and — with verify_bots on — PROVEN impostors (a claimed verified bot whose address conclusively failed the operator's published check; see Guard::conclusively_forged). No effect while block_agents is false. 'verify_bots' => false, // Opt-in: forward-confirm a claimed search engine by reverse DNS. Two independent effects — it flags a proven impersonator in the review queue (and records the verdict for "Check this bot"), and, WHEN blocking is on, stops a spoofed "Googlebot" UA inheriting a real crawler's always-allow. OFF by default because it makes outbound DNS lookups (all this plugin ever makes). 'store_flagged_ips' => false, // Opt-in, minimised IP capture: store the source IP ONLY for clients flagged as an impersonator (failed reverse-DNS) or a legacy-device spoof, so the review card can show real addresses to block. OFF by default — the ONLY setting that makes the plugin keep personal data. Short retention, cleared with the log, PURGED when switched back off. See Activity\FlaggedIps. 'identify_bots' => false, // Opt-in: reverse-DNS EVERY recorded bot hit (not just claimed search engines) and store the owning NETWORK (e.g. amazonaws.com, googlebot.com) — the "who is accessing me" attribution — WITHOUT the IP (the resolved network is org-level, not personal). Verifiable engines additionally get their verified/impostor verdict from the same lookup. OFF by default because it makes outbound DNS (bounded by the same budget/breaker as verify_bots). See BotVerifier::attribute_ip. 'blocked_agents' => array(), // Owner's custom user-agent substrings to deny (case-insensitive). Empty = none. 'allowed_agents' => array(), // Owner's trust-list (via the activity panel's "Allow"): never blocked, never flagged for review. Empty = none. 'verifier_disabled' => array(), // Tokens of BUILT-IN verified-bot registry entries the owner switched off. A disabled entry's bot simply becomes unverifiable (verdicts stay 0) — nothing is ever flagged by an absence. See VerifierRegistry. 'verifier_custom' => array(), // Owner-ADDED verified-bot entries: { token, label, ua, domains[], url }. For operators that publish verification data (rDNS suffixes and/or an IP-range file) after this release — the owner shouldn't wait for a plugin update to trust them. Sanitised hard in sanitize(); bounded count. // Exposure controls — reduce what an ANONYMOUS visitor (crawler / bot / // scanner) can read about the site. All opt-in / OFF by default: each one // changes a stock WordPress behaviour, so it ships only when the owner asks, // and is scoped to logged-out requests (admins/the editor are unaffected). 'hide_user_enumeration' => false, // Block anonymous author/user enumeration: wp/v2/users, ?author=N, the core users-sitemap, and the oEmbed author fields. 'disable_author_archives' => false, // Turn /author/* archive pages into 404s for anonymous visitors (also catches ?author=N) — removes another surface that carries the username slug. 'hide_wp_version' => false, // Remove the WordPress version fingerprint: the tag, the feed generator, and the core ?ver= on core assets. 'tidy_head_links' => false, // Strip rarely-used auto-generated discovery links (shortlink, oEmbed discovery, RSD/WLW) from the page head + Link header — trims the scrapeable footprint. Keeps the REST api.w.org link (intentional discovery). 'disable_xmlrpc' => false, // Disable legacy XML-RPC (xmlrpc.php) — the pingback / system.multicall brute-force-amplification + DDoS surface. Modern clients use the REST API. 'exposed_extra_paths' => array(), // Owner-added paths for the "exposed files" self-check (Settings → Exposure), on top of the built-in list. Scan runs browser-side from Readiness; see Exposure::sensitive_paths(). // Agent access — record who authenticates to, and acts on, the machine surface this // plugin creates: application passwords being minted/used/revoked, and abilities being // invoked. ON by default, unlike the opt-in controls above, because it is the rare // one with nothing to trade off: it stores no personal data (no IP, no location — see // AgentAccess\Table), makes no outbound request, and its hot path is an object-cache // read. It observes and reports; it never blocks anything. 'agent_access_events' => true, // Weekly email digest — once a week, a short note to the owner about the AI // activity the plugin already records: agent reads, referral visits, impostors, // score movement. Built ONLY from local data and sent with wp_mail to the site // owner — nothing leaves the server except the email itself. ON by default // because silent background work is invisible work; the very first email // explains itself and every email carries a one-click stop link (a stored key, // not a nonce — nonces expire in a day and digest links get clicked later). // Quiet weeks (nothing to report) send nothing at all. See Digest\Module. 'digest_enabled' => true, 'digest_recipient' => '', // Optional override; empty = the site admin email. 'digest_day' => 1, // Send day, ISO-8601: 1 = Monday … 7 = Sunday. 'digest_hour' => 8, // Send hour (0–23), site time. WP-cron fires on the first visit after it. ); /** * Filter the default settings. * * @param array $defaults Default settings. */ $filtered = apply_filters( 'agentimus_default_settings', $defaults ); // A misbehaving plugin must not be able to hand back a non-array here: the // whole admin app and every endpoint resolve their config through this. return is_array( $filtered ) ? $filtered : $defaults; } /** * The canonical catalogue of known model-training crawlers. Seeds the default * blocklist and powers the "add a known crawler" suggestions in the admin, so * a user who removes one can always find its exact user-agent again. * * @return string[] */ public static function known_trainers() { $known = array( 'Amazonbot', 'Applebot-Extended', 'Bytespider', 'CCBot', 'ClaudeBot', 'GPTBot', 'Google-Extended', 'meta-externalagent', ); /** * Filter the known training-crawler catalogue. * * @param string[] $known User-agent tokens. */ $known = (array) apply_filters( 'agentimus_known_trainers', $known ); return array_values( array_unique( array_filter( array_map( 'trim', $known ) ) ) ); } /** * Suggested user-agents for the hard-block denylist: aggressive SEO/scraper * crawlers that frequently ignore robots.txt. Powers the "add a known scanner" * chips in the admin — purely suggestions; nothing is denied until the owner * adds it AND turns blocking on. (The legacy-device spoof class is handled * separately by the block_spoofed heuristic, so it isn't duplicated here.) * * @return string[] */ public static function known_scanners() { $known = array( 'AhrefsBot', 'SemrushBot', 'DotBot', 'MJ12bot', 'BLEXBot', 'PetalBot', 'DataForSeoBot', 'SeekportBot', 'serpstatbot', 'ZoominfoBot', ); /** * Filter the suggested scanner catalogue shown in the admin denylist UI. * * @param string[] $known User-agent tokens. */ $known = (array) apply_filters( 'agentimus_known_scanners', $known ); return array_values( array_unique( array_filter( array_map( 'trim', $known ) ) ) ); } /** * Suggested user-agents for the always-allow trust-list: well-known AI agents * that read or answer on a user's behalf (assistants and answer engines). * Powers the "add a trusted AI agent" chips under the allow-list — purely * suggestions; nothing is trusted until the owner adds it. The built-in search * engines (Googlebot, Bingbot, …) are NOT here: they are always allowed * structurally by Guard::engine_signatures(), so listing them would be noise. * * Deliberately EXCLUDES training crawlers (GPTBot, ClaudeBot, CCBot, * Google-Extended, Bytespider, Amazonbot, meta-externalagent) — those belong to * the training opt-out (blocked_trainers / the ai-train signal), not the trust * list, and allow-listing a crawler you may be reserving against would * contradict that choice. * * @return string[] */ public static function known_allowed() { $known = array( 'ChatGPT-User', // OpenAI — fetches a page when a ChatGPT user asks about it. 'OAI-SearchBot', // OpenAI — powers ChatGPT search citations. 'Claude-User', // Anthropic — Claude fetching on a user's behalf. 'Claude-SearchBot', // Anthropic — Claude search. 'PerplexityBot', // Perplexity — answer-engine index. 'Perplexity-User', // Perplexity — on-demand user fetch. 'DuckAssistBot', // DuckDuckGo — AI-assist answers. 'MistralAI-User', // Mistral — Le Chat on-demand fetch. 'Meta-ExternalFetcher', // Meta — on-demand fetch (not the meta-externalagent trainer). ); /** * Filter the suggested always-allow catalogue shown in the admin trust-list UI. * * @param string[] $known User-agent tokens. */ $known = (array) apply_filters( 'agentimus_known_allowed', $known ); return array_values( array_unique( array_filter( array_map( 'trim', $known ) ) ) ); } /** * The privacy-safe default content selection: posts and pages only. Every * other public post type (WooCommerce products, CRM/support/forms/boards * CPTs, …) is opt-IN, so a fresh install never advertises or dumps content * the owner didn't choose. Falls back to all public types only on the rare * site that registers neither `post` nor `page`. * * @return string[] */ public static function default_post_types() { $available = Content::available(); $safe = array_values( array_intersect( array( 'post', 'page' ), $available ) ); return ! empty( $safe ) ? $safe : $available; } /** * The full, merged settings array. * * @return array */ public function all() { $saved = get_option( self::OPTION, array() ); $saved = is_array( $saved ) ? $saved : array(); $defaults = $this->defaults(); $all = wp_parse_args( $saved, $defaults ); // wp_parse_args is shallow, so a stored ASSOCIATIVE nested setting (identity, // security, content_signal) would replace its default wholesale and lose any // sub-key added in a later version — silently dropping, e.g., a content-signal / // AI-training default. Deep-merge each so a stored value keeps its own keys but // still inherits new ones. List defaults (blocked_trainers, post_types…) are left // alone: an empty stored list is a deliberate "none", not "inherit the defaults". foreach ( $defaults as $key => $default_value ) { if ( is_array( $default_value ) && self::is_assoc( $default_value ) ) { $all[ $key ] = wp_parse_args( isset( $saved[ $key ] ) && is_array( $saved[ $key ] ) ? $saved[ $key ] : array(), $default_value ); } } /** * Filter the resolved settings on read. * * @param array $all Resolved settings. */ $filtered = apply_filters( 'agentimus_settings', $all ); // Keep the valid resolved array if a filter mangles the shape — this feeds // wp_localize_script (the admin boot) and every reader of the settings. return is_array( $filtered ) ? $filtered : $all; } /** * A single top-level value. * * @param string $key Setting key. * @param mixed $default Fallback. * @return mixed */ public function get( $key, $default = null ) { $all = $this->all(); return array_key_exists( $key, $all ) ? $all[ $key ] : $default; } /** * A boolean toggle. * * @param string $key Setting key. * @return bool */ public function enabled( $key ) { return (bool) $this->get( $key, false ); } /** * An identity sub-value (name, about, expertise, …). * * @param string $key Identity key. * @param mixed $default Fallback. * @return mixed */ public function identity( $key, $default = null ) { $identity = (array) $this->get( 'identity', array() ); return array_key_exists( $key, $identity ) ? $identity[ $key ] : $default; } /** * Persist a (raw) settings array after sanitising it. * * @param array $input Raw input (e.g. from REST). * @return array The sanitised, stored settings. */ public function update( array $input ) { $prev = get_option( self::OPTION, array() ); $clean = $this->sanitize( $input ); update_option( self::OPTION, $clean ); $this->record_decisions( is_array( $prev ) ? $prev : array(), $clean ); Cache::flush(); // The review-queue badge counts against the block/trust lists and the activity // toggle — any settings write may have changed its number, and its 60s cache // must never outlive the owner's own action (Block, Allow, Unblock, …). ReviewBadge::forget(); // Opted back OUT of flagged-IP storage → purge what was kept, so the plugin never // holds personal data the owner just declined. A listener (Activity\Module) clears // the store; firing an action keeps Settings decoupled from the table. if ( ! empty( $prev['store_flagged_ips'] ) && empty( $clean['store_flagged_ips'] ) ) { /** Fires when the owner disables flagged-IP capture; purge the stored IPs. */ do_action( 'agentimus_flagged_ips_purge' ); } return $clean; } /** * Add one user-agent token to the hard-block denylist AND arm enforcement — * the persistence side of the activity panel's one-click "Block this client". * The denylist is inert while block_agents is off, so a "block" that didn't turn * blocking on would silently do nothing; flipping it on here makes the see-it → * block-it loop actually bite. Reads the full resolved settings and writes them * back through update() (a partial update would reset omitted keys to defaults). * Idempotent: a token already present (case-insensitively) is not duplicated. * * @param string $token Raw user-agent substring (or glob/regex) to deny. * @return array The stored settings. */ public function block_agent( $token ) { $token = substr( trim( (string) $token ), 0, 200 ); $all = $this->all(); if ( '' !== $token ) { $list = array_values( (array) $all['blocked_agents'] ); $lower = array_map( 'strtolower', $list ); if ( ! in_array( strtolower( $token ), $lower, true ) ) { $list[] = $token; } $all['blocked_agents'] = $list; } $all['block_agents'] = true; return $this->update( $all ); } /** * Arm hard enforcement for the spoofed/legacy-device class — the action behind * "Block scanners" on a flagged spoof row. Turns on the master switch and the * spoof heuristic together (no per-UA token: the class is matched by * {@see \Agentimus\Activity\Classifier::is_spoof()}). Full read-modify-write, as * in {@see block_agent()}. * * @return array The stored settings. */ public function block_spoofed_class() { $all = $this->all(); $all['block_agents'] = true; $all['block_spoofed'] = true; return $this->update( $all ); } /** * Record when a token entered the block/trust lists, and forget the date when it * leaves. Compares the two settings snapshots case-insensitively (the lists dedupe * that way) and only writes when something actually changed. Times are unix GMT. * * @param array $prev Stored settings before this update (may be empty). * @param array $clean Sanitized settings just written. */ private function record_decisions( array $prev, array $clean ) { $map = get_option( self::DECISIONS_OPTION, array() ); $map = is_array( $map ) ? $map : array(); $changed = false; foreach ( array( 'allow' => 'allowed_agents', 'block' => 'blocked_agents', ) as $kind => $list_key ) { $old = array_map( 'strtolower', array_map( 'strval', (array) ( $prev[ $list_key ] ?? array() ) ) ); $new = array_map( 'strtolower', array_map( 'strval', (array) ( $clean[ $list_key ] ?? array() ) ) ); if ( ! isset( $map[ $kind ] ) || ! is_array( $map[ $kind ] ) ) { $map[ $kind ] = array(); } foreach ( array_diff( $new, $old ) as $token ) { $map[ $kind ][ $token ] = time(); $changed = true; } foreach ( array_diff( $old, $new ) as $token ) { if ( isset( $map[ $kind ][ $token ] ) ) { unset( $map[ $kind ][ $token ] ); $changed = true; } } } if ( $changed ) { update_option( self::DECISIONS_OPTION, $map, false ); } } /** * The decision-date map: { allow: { lowercased token → unix }, block: { … } }. * * @return array{allow:array