From f9a241660a7072f51b5cc0bc8cc40cfaadfe8e5a Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Thu, 9 Jul 2026 10:17:29 +0200 Subject: [PATCH 1/4] Add HTTP-layer connector attribution, enforcement, and Connector Approval interop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopt the technique from the official WP AI plugin's experimental "Connector Approval" feature: enforce/attribute at the `pre_http_request` filter by scanning each outbound request for a configured connector credential. This gives an exact connector identity at request time and also covers plugins that read a credential option and call the provider API directly, bypassing wp_ai_client_prompt(). New: - Capture\Connector_Key_Index — builds {credential => connector_id} from core wp_get_connectors() (no dependency on the ai plugin), scans URL + headers. - Capture\Http_Guard — hooks pre_http_request (pri 5); enriches the Gatekeeper's pending intent to `exact` confidence + connector_id, and optionally blocks via the shared Enforcer (translating its bool decision into a WP_Error 403). Stays observe-only until a hard limit is configured; fails open on any error. - Interop\Connector_Approval_Bridge — read-only view of the ai plugin's connector-approval experiment (its blocks never reach our capture path), plus a GET /connector-approvals REST route and a dashboard panel surfacing them. Attribution ladder gains an `exact` tier above self-ID: - Caller_Resolver::CONFIDENCE_EXACT const + docblock. - Usage_Recorder allows `exact` through its confidence allowlist. - Limit_Evaluator ranks `exact` (3) above high, so it satisfies every min_confidence gate. Http_Guard is wired in Plugin::register_runtime() reusing the same Caller_Resolver and Enforcer as the Gatekeeper so both enforcement points evaluate limits identically. Verified live on WordPress 7.0 (real anthropic connector): a single real AI call captured at `exact` confidence with real tokens/provider/model and surfaced on the dashboard; plus simulated-request coverage for the 403 block on a breached hard limit, fail-open on error, interop active/inactive paths, and REST 200/401. Passes PHPCS (0 errors), PHPStan level 10, php-parallel-lint, and the JS build. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Filip Ilic --- assets/src/App.js | 127 ++++++++- src/Accounting/class-usage-recorder.php | 2 +- src/Admin/class-rest-controller.php | 33 +++ src/Attribution/class-caller-resolver.php | 13 + src/Capture/class-connector-key-index.php | 246 ++++++++++++++++++ src/Capture/class-gatekeeper.php | 63 +++++ src/Capture/class-http-guard.php | 221 ++++++++++++++++ .../class-connector-approval-bridge.php | 155 +++++++++++ src/Limits/class-limit-evaluator.php | 5 + src/class-plugin.php | 39 ++- 10 files changed, 901 insertions(+), 3 deletions(-) create mode 100644 src/Capture/class-connector-key-index.php create mode 100644 src/Capture/class-http-guard.php create mode 100644 src/Interop/class-connector-approval-bridge.php diff --git a/assets/src/App.js b/assets/src/App.js index 298263d..aa0840a 100644 --- a/assets/src/App.js +++ b/assets/src/App.js @@ -17,7 +17,7 @@ import { useState, useEffect, useMemo, useCallback } from '@wordpress/element'; import apiFetch from '@wordpress/api-fetch'; -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; import Limits from './Limits'; import { @@ -945,12 +945,137 @@ export default function App( { config } ) { ) : null } + { /* Interop: blocks from the WordPress AI plugin's Connector + Approval experiment never reach our capture path, so surface + them here. Self-fetching; renders nothing when inactive. */ } + { ! loading && ! error ? : null } + { /* Limits management is always available, even with no usage yet. */ } { ! loading && ! error ? : null } ); } +/** + * Interop panel for the WordPress AI plugin's "Connector Approval" experiment. + * + * That experiment blocks unapproved plugin/connector pairs at the HTTP layer, + * before a result is generated — so those blocked requests are invisible to our + * usage tracking. When the experiment is active we surface its pending (blocked) + * queue here so the gap is visible and explained. Renders nothing when the + * experiment is off or the endpoint is unavailable. + * + * @return {JSX.Element|null} The panel, or null when inactive. + */ +function ConnectorApprovalPanel() { + const [ state, setState ] = useState( null ); + + useEffect( () => { + let cancelled = false; + apiFetch( { path: 'wp-aiut/v1/connector-approvals' } ) + .then( ( res ) => { + if ( ! cancelled ) { + setState( res || null ); + } + } ) + .catch( () => { + // Non-fatal: the panel simply does not render. + if ( ! cancelled ) { + setState( null ); + } + } ); + return () => { + cancelled = true; + }; + }, [] ); + + if ( ! state || ! state.active ) { + return null; + } + + const pending = Array.isArray( state.pending ) ? state.pending : []; + const totalBlocked = pending.reduce( + ( sum, row ) => sum + ( Number( row.attempts ) || 0 ), + 0 + ); + + return ( +
+ + +

+ { __( 'Blocked by Connector Approval', 'wp-aiut' ) } +

+
+ +

+ { __( + 'The WordPress AI plugin’s Connector Approval experiment is active. It blocks unapproved plugins from using a connector before the request runs, so these attempts are not counted in the usage above.', + 'wp-aiut' + ) } +

+ + { pending.length === 0 ? ( +

+ { __( + 'No blocked requests are currently pending review.', + 'wp-aiut' + ) } +

+ ) : ( + <> +

+ { sprintf( + /* translators: 1: number of blocked attempts, 2: number of distinct plugin/connector pairs. */ + __( + '%1$s blocked request(s) across %2$s plugin/connector pair(s):', + 'wp-aiut' + ), + formatNumber( totalBlocked ), + formatNumber( pending.length ) + ) } +

+ + + + + + + + + + { pending.map( ( row ) => ( + + + + + + ) ) } + +
{ __( 'Plugin / theme', 'wp-aiut' ) }{ __( 'Connector', 'wp-aiut' ) } + { __( 'Attempts', 'wp-aiut' ) } +
+ { row.caller_name || + row.caller_basename || + __( + 'Unknown', + 'wp-aiut' + ) } + { row.connector_id } + { formatNumber( + row.attempts + ) } +
+ + ) } +
+
+
+ ); +} + /** * Provider/model breakdown sub-section under the totals strip. * diff --git a/src/Accounting/class-usage-recorder.php b/src/Accounting/class-usage-recorder.php index cc08f13..980c960 100644 --- a/src/Accounting/class-usage-recorder.php +++ b/src/Accounting/class-usage-recorder.php @@ -159,7 +159,7 @@ private static function normalize( array $row ) { } $confidence = isset( $row['plugin_confidence'] ) ? sanitize_key( (string) $row['plugin_confidence'] ) : 'low'; - if ( ! in_array( $confidence, [ 'high', 'medium', 'low' ], true ) ) { + if ( ! in_array( $confidence, [ 'exact', 'high', 'medium', 'low' ], true ) ) { $confidence = 'low'; } diff --git a/src/Admin/class-rest-controller.php b/src/Admin/class-rest-controller.php index 1a381bd..e10d346 100644 --- a/src/Admin/class-rest-controller.php +++ b/src/Admin/class-rest-controller.php @@ -155,6 +155,19 @@ public function register_routes() { ] ); + // Interop: state of the WordPress/ai "Connector Approval" experiment. + register_rest_route( + self::NAMESPACE, + '/connector-approvals', + [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_connector_approvals' ], + 'permission_callback' => [ $this, 'check_permission' ], + ], + ] + ); + // Limits collection: list + create (Phase 2). register_rest_route( self::NAMESPACE, @@ -472,6 +485,26 @@ public function get_scopes() { ); } + /** + * GET /connector-approvals — interop state of the WordPress/ai + * "Connector Approval" experiment. + * + * Returns {active:false} when that experiment is off. When on, returns its + * pending (blocked) queue and approval matrix, read-only, so the dashboard can + * surface upstream blocks that never reach our capture path. Degrades to + * {active:false} when the bridge class is unavailable. + * + * @return \WP_REST_Response + */ + public function get_connector_approvals() { + if ( class_exists( '\\WP_AIUT\\Interop\\Connector_Approval_Bridge' ) ) { + $bridge = new \WP_AIUT\Interop\Connector_Approval_Bridge(); + return rest_ensure_response( $bridge->get_state() ); + } + + return rest_ensure_response( [ 'active' => false ] ); + } + /** * GET /limits — list all configured limits. * diff --git a/src/Attribution/class-caller-resolver.php b/src/Attribution/class-caller-resolver.php index ec49e65..9ed06fc 100644 --- a/src/Attribution/class-caller-resolver.php +++ b/src/Attribution/class-caller-resolver.php @@ -13,6 +13,12 @@ * Resolves which plugin/theme and which user triggered an AI Client prompt. * * Layered, most-reliable first (spec §4 / Architecture §5): + * 0. Exact (exact) — the outbound HTTP request carried a configured + * connector credential, so the connector (and, via + * the backtrace, the caller) is known for certain. + * Set by the Http_Guard AFTER the pre-request hook, + * not produced by resolve() itself; see + * Connector_Key_Index / Http_Guard. * 1. Self-ID hook (high) — cooperating plugins call * do_action( 'wp_aiut_attribute', 'slug' ) * before their prompt; we read the top of a @@ -32,6 +38,13 @@ class Caller_Resolver { */ const ATTRIBUTE_ACTION = 'wp_aiut_attribute'; + /** + * Highest confidence tier: the request carried a configured connector + * credential, so attribution is certain (set by the Http_Guard, above the + * self-ID 'high' tier). resolve() never returns this itself. + */ + const CONFIDENCE_EXACT = 'exact'; + /** * Slug used when no caller can be resolved. */ diff --git a/src/Capture/class-connector-key-index.php b/src/Capture/class-connector-key-index.php new file mode 100644 index 0000000..c5eccc4 --- /dev/null +++ b/src/Capture/class-connector-key-index.php @@ -0,0 +1,246 @@ + connector_id. + * + * Null until the first lookup(). Built lazily on purpose: core's + * wp_get_connectors() returns an empty array until the connector registry is + * initialised (on '_wp_connectors_init'). Because lookup() first runs when an + * outbound HTTP request is actually made — well after init — a lazy build + * sees a populated registry. Do NOT build this eagerly at construction time. + * + * @var array|null + */ + private $key_to_connector = null; + + /** + * Find the connector ID whose credential appears in the request. + * + * Scans the URL and every header value (string or array of strings) for each + * configured credential, in both raw and rawurlencode()'d forms so a key + * serialised into a query string still matches. + * + * @param array $args Request arguments from 'pre_http_request'. + * @param string $url The request URL. + * @return string|null Connector ID, or null when nothing matched. + */ + public function lookup( array $args, $url ) { + $keys = $this->get_keys(); + + if ( [] === $keys ) { + return null; + } + + $haystacks = $this->collect_haystacks( $args, (string) $url ); + + if ( [] === $haystacks ) { + return null; + } + + foreach ( $keys as $key => $connector_id ) { + $encoded = rawurlencode( $key ); + + foreach ( $haystacks as $haystack ) { + if ( false !== strpos( $haystack, $key ) ) { + return $connector_id; + } + + if ( $encoded !== $key && false !== strpos( $haystack, $encoded ) ) { + return $connector_id; + } + } + } + + return null; + } + + /** + * Clear the cached index so the next lookup() rebuilds it. + * + * Useful when connector credentials change during the same request (tests, + * long-running CLI). Production requests get a fresh index anyway. + * + * @return void + */ + public function invalidate() { + $this->key_to_connector = null; + } + + /** + * Return the credential => connector_id map, building it on first access. + * + * @return array + */ + private function get_keys() { + if ( null !== $this->key_to_connector ) { + return $this->key_to_connector; + } + + $this->key_to_connector = $this->build_index(); + + return $this->key_to_connector; + } + + /** + * Build the credential => connector_id map from core-registered connectors. + * + * @return array + */ + private function build_index() { + $index = []; + + if ( ! function_exists( 'wp_get_connectors' ) ) { + return $index; + } + + $connectors = wp_get_connectors(); + + if ( ! is_array( $connectors ) ) { + return $index; + } + + foreach ( $connectors as $connector_id => $data ) { + if ( ! is_string( $connector_id ) || '' === $connector_id || ! is_array( $data ) ) { + continue; + } + + $auth = isset( $data['authentication'] ) && is_array( $data['authentication'] ) + ? $data['authentication'] + : []; + + foreach ( $this->read_credentials( $auth ) as $credential ) { + if ( strlen( $credential ) < self::MIN_KEY_LENGTH ) { + continue; + } + + // First writer wins; connectors rarely share a credential, and a + // deterministic map keeps attribution stable across a request. + if ( ! isset( $index[ $credential ] ) ) { + $index[ $credential ] = $connector_id; + } + } + } + + return $index; + } + + /** + * Read every credential string configured for an authentication block. + * + * A connector may populate any of: a DB option, an environment variable, or a + * PHP constant. We collect whichever are present. + * + * @param array $auth Authentication metadata from a connector. + * @return string[] + */ + private function read_credentials( array $auth ) { + $credentials = []; + + $setting_name = isset( $auth['setting_name'] ) && is_string( $auth['setting_name'] ) ? $auth['setting_name'] : ''; + if ( '' !== $setting_name ) { + $value = get_option( $setting_name, '' ); + if ( is_string( $value ) && '' !== $value ) { + $credentials[] = $value; + } + } + + $env_var_name = isset( $auth['env_var_name'] ) && is_string( $auth['env_var_name'] ) ? $auth['env_var_name'] : ''; + if ( '' !== $env_var_name ) { + $value = getenv( $env_var_name ); + if ( is_string( $value ) && '' !== $value ) { + $credentials[] = $value; + } + } + + $constant_name = isset( $auth['constant_name'] ) && is_string( $auth['constant_name'] ) ? $auth['constant_name'] : ''; + if ( '' !== $constant_name && defined( $constant_name ) ) { + $value = constant( $constant_name ); + if ( is_string( $value ) && '' !== $value ) { + $credentials[] = $value; + } + } + + return $credentials; + } + + /** + * Collect the strings that might carry a credential for a given request. + * + * @param array $args Request args. + * @param string $url Request URL. + * @return string[] + */ + private function collect_haystacks( array $args, $url ) { + $haystacks = []; + + if ( '' !== $url ) { + $haystacks[] = $url; + } + + $headers = isset( $args['headers'] ) ? $args['headers'] : []; + + if ( is_array( $headers ) ) { + foreach ( $headers as $value ) { + if ( is_string( $value ) && '' !== $value ) { + $haystacks[] = $value; + } elseif ( is_array( $value ) ) { + foreach ( $value as $sub ) { + if ( is_string( $sub ) && '' !== $sub ) { + $haystacks[] = $sub; + } + } + } + } + } + + return $haystacks; + } +} diff --git a/src/Capture/class-gatekeeper.php b/src/Capture/class-gatekeeper.php index 23ab7e5..66a1aab 100644 --- a/src/Capture/class-gatekeeper.php +++ b/src/Capture/class-gatekeeper.php @@ -364,6 +364,69 @@ private function match_max_age() { return $age > 0 ? $age : self::MATCH_MAX_AGE_SECONDS; } + /** + * Enrich the newest un-finalised pending intent with exact connector + * attribution discovered at the HTTP layer. + * + * The Http_Guard fires on 'pre_http_request' for the very request whose + * intent the prevent-prompt hook just recorded, and it knows — from a + * credential match in the outbound request — the exact connector and (via the + * backtrace) the caller. We upgrade that intent's attribution to 'exact' + * confidence and stamp the connector_id so the Result_Capturer persists the + * better attribution. + * + * Safe no-op when no un-finalised intent exists: a caller that bypasses + * wp_ai_client_prompt() and hits the provider API directly has no recorded + * intent — that path is enforcement-only, never tracked through intents. + * + * @param string $connector_id Connector the request is using. + * @param array $caller Resolved caller + * (source_slug, source_type[, confidence]). + * @return void + */ + public function enrich_latest_intent( $connector_id, array $caller ) { + try { + $best_key = null; + $best = null; + $min_age = microtime( true ) - $this->match_max_age(); + + foreach ( self::$pending as $key => $intent ) { + if ( ! empty( $intent['finalized'] ) ) { + continue; + } + + if ( isset( $intent['created_at'] ) && $intent['created_at'] < $min_age ) { + continue; + } + + if ( null === $best || $intent['created_at'] >= $best['created_at'] ) { + $best = $intent; + $best_key = $key; + } + } + + if ( null === $best_key ) { + return; + } + + self::$pending[ $best_key ]['connector_id'] = (string) $connector_id; + self::$pending[ $best_key ]['confidence'] = Caller_Resolver::CONFIDENCE_EXACT; + + // The credential match plus the HTTP-layer backtrace give a more + // reliable caller than the pre-request resolution; adopt it when present. + if ( isset( $caller['source_slug'] ) && '' !== (string) $caller['source_slug'] ) { + self::$pending[ $best_key ]['source_slug'] = (string) $caller['source_slug']; + } + + if ( isset( $caller['source_type'] ) && '' !== (string) $caller['source_type'] ) { + self::$pending[ $best_key ]['source_type'] = (string) $caller['source_type']; + } + } catch ( \Throwable $e ) { + // Enrichment is best-effort; never let it disturb the request. + unset( $e ); + } + } + /** * Mark a pending intent finalised so it is not matched again. * diff --git a/src/Capture/class-http-guard.php b/src/Capture/class-http-guard.php new file mode 100644 index 0000000..7d92419 --- /dev/null +++ b/src/Capture/class-http-guard.php @@ -0,0 +1,221 @@ +key_index = $key_index; + $this->resolver = $resolver; + $this->enforcer = $enforcer instanceof Enforcer ? $enforcer : null; + $this->gatekeeper = $gatekeeper instanceof Gatekeeper ? $gatekeeper : null; + } + + /** + * Register the pre-request HTTP filter. + * + * Priority 5 runs before the default (10) so a block here pre-empts other + * short-circuit callbacks; 3 args to receive $args and $url. + * + * @return void + */ + public function register() { + add_filter( 'pre_http_request', [ $this, 'maybe_block_request' ], 5, 3 ); + } + + /** + * Attribute (and optionally block) an outbound request carrying a credential. + * + * Fails open: any error, or no matched connector, returns $preempt unchanged + * so the request proceeds exactly as it would without this plugin. + * + * @param mixed $preempt Short-circuit value from earlier callbacks. + * @param array $args Parsed request arguments. + * @param string $url Request URL. + * @return mixed WP_Error to block, otherwise the incoming $preempt. + */ + public function maybe_block_request( $preempt, $args = [], $url = '' ) { + // Respect an existing short-circuit and avoid re-entrancy. + if ( false !== $preempt || $this->in_filter ) { + return $preempt; + } + + try { + if ( ! is_array( $args ) ) { + $args = []; + } + + if ( ! is_string( $url ) ) { + $url = ''; + } + + $this->in_filter = true; + + $connector_id = $this->key_index->lookup( $args, $url ); + + if ( null === $connector_id ) { + return $preempt; // Not an AI-connector request; leave it alone. + } + + $caller = $this->resolver->resolve(); + $user = $this->resolver->resolve_user(); + + // 1) Enrich the pending intent with exact attribution (tracking path). + if ( null !== $this->gatekeeper ) { + $this->gatekeeper->enrich_latest_intent( $connector_id, $caller ); + } + + // 2) Enforcement (Phase 2). Observe-only until a hard limit exists; the + // Enforcer short-circuits to false otherwise and fails open on error. + if ( null !== $this->enforcer ) { + $scopes = $this->build_scopes( $caller, $user ); + + if ( $this->enforcer->should_block( $scopes, Caller_Resolver::CONFIDENCE_EXACT ) ) { + return $this->blocked_error( $connector_id, $caller ); + } + } + + return $preempt; + } catch ( \Throwable $e ) { + // Fail open: a tracking/limiting plugin must never break a site's AI. + unset( $e ); + return $preempt; + } finally { + $this->in_filter = false; + } + } + + /** + * Build the scope_type => scope_key map for an enforcement decision. + * + * Mirrors the Gatekeeper's scopes and the Usage_Recorder's counter buckets so + * limits match the same buckets usage is counted into. Model scope is omitted + * (the model is not known at request time). + * + * @param array $caller Resolved caller (source_slug). + * @param array $user Resolved user (user_id, user_role). + * @return array scope_type => scope_key. + */ + private function build_scopes( array $caller, array $user ) { + return [ + 'plugin' => isset( $caller['source_slug'] ) ? (string) $caller['source_slug'] : '__unknown__', + 'user' => isset( $user['user_id'] ) ? (string) $user['user_id'] : '0', + 'role' => isset( $user['user_role'] ) ? (string) $user['user_role'] : '', + 'global' => '__all__', + ]; + } + + /** + * Build the WP_Error that blocks the outbound request. + * + * Returning a WP_Error from 'pre_http_request' short-circuits the HTTP stack; + * the AI Client surfaces it as a WP_Error to the caller (no exception). + * + * @param string $connector_id Connector being used. + * @param array $caller Resolved caller. + * @return WP_Error + */ + private function blocked_error( $connector_id, array $caller ) { + $slug = isset( $caller['source_slug'] ) ? (string) $caller['source_slug'] : '__unknown__'; + + return new WP_Error( + 'wp_aiut_connector_blocked', + sprintf( + /* translators: 1: caller slug, 2: connector ID. */ + __( 'AI usage limit reached: "%1$s" is blocked from using the "%2$s" connector.', 'wp-aiut' ), + $slug, + (string) $connector_id + ), + [ + 'status' => 403, + 'connector_id' => (string) $connector_id, + 'caller' => $slug, + ] + ); + } +} diff --git a/src/Interop/class-connector-approval-bridge.php b/src/Interop/class-connector-approval-bridge.php new file mode 100644 index 0000000..29c9da0 --- /dev/null +++ b/src/Interop/class-connector-approval-bridge.php @@ -0,0 +1,155 @@ + {connector_id => bool}}. + */ + const OPTION_APPROVALS = 'wpai_connector_approvals'; + + /** + * Option holding the pending (denied) block queue. + */ + const OPTION_PENDING = 'wpai_connector_approval_pending'; + + /** + * Whether the ai plugin's connector-approval experiment is enabled. + * + * Mirrors Abstract_Feature::is_enabled(): both the global experiments toggle + * and the per-feature toggle must be on. We cannot observe the ai plugin's + * runtime filter override, but the option state is authoritative for whether + * the feature is doing anything. + * + * @return bool + */ + public function is_active() { + return (bool) get_option( self::OPTION_FEATURES_ENABLED, false ) + && (bool) get_option( self::OPTION_FEATURE_ENABLED, false ); + } + + /** + * Return the interop state for the dashboard. + * + * When the experiment is inactive, returns {active:false} and reads nothing + * else. When active, returns the pending block queue and approval matrix + * normalised into flat lists for display. + * + * @return array + */ + public function get_state() { + if ( ! $this->is_active() ) { + return [ 'active' => false ]; + } + + return [ + 'active' => true, + 'pending' => $this->read_pending(), + 'approvals' => $this->read_approvals(), + ]; + } + + /** + * Read and normalise the pending-block queue. + * + * The ai plugin's raw option may store non-canonical caller basenames (a bare + * slug such as "ai" rather than "ai/ai.php"); we display them as stored and do + * not attempt to canonicalise (that logic lives in the ai plugin). + * + * @return array> + */ + private function read_pending() { + $raw = get_option( self::OPTION_PENDING, [] ); + + if ( ! is_array( $raw ) ) { + return []; + } + + $out = []; + + foreach ( $raw as $entry ) { + if ( ! is_array( $entry ) ) { + continue; + } + + $out[] = [ + 'caller_type' => isset( $entry['caller_type'] ) ? (string) $entry['caller_type'] : '', + 'caller_basename' => isset( $entry['caller_basename'] ) ? (string) $entry['caller_basename'] : '', + 'caller_name' => isset( $entry['caller_name'] ) ? (string) $entry['caller_name'] : '', + 'connector_id' => isset( $entry['connector_id'] ) ? (string) $entry['connector_id'] : '', + 'attempts' => isset( $entry['attempts'] ) ? (int) $entry['attempts'] : 0, + 'first_seen' => isset( $entry['first_seen'] ) ? (int) $entry['first_seen'] : 0, + 'last_seen' => isset( $entry['last_seen'] ) ? (int) $entry['last_seen'] : 0, + ]; + } + + return $out; + } + + /** + * Read and flatten the approval matrix into a list of approved pairs. + * + * @return array> + */ + private function read_approvals() { + $raw = get_option( self::OPTION_APPROVALS, [] ); + + if ( ! is_array( $raw ) ) { + return []; + } + + $out = []; + + foreach ( $raw as $caller => $connectors ) { + if ( ! is_string( $caller ) || ! is_array( $connectors ) ) { + continue; + } + + foreach ( $connectors as $connector_id => $approved ) { + if ( ! is_string( $connector_id ) || ! $approved ) { + continue; + } + + $out[] = [ + 'caller_basename' => $caller, + 'connector_id' => $connector_id, + ]; + } + } + + return $out; + } +} diff --git a/src/Limits/class-limit-evaluator.php b/src/Limits/class-limit-evaluator.php index 22e75d5..038ab50 100644 --- a/src/Limits/class-limit-evaluator.php +++ b/src/Limits/class-limit-evaluator.php @@ -31,6 +31,11 @@ class Limit_Evaluator { 'low' => 0, 'medium' => 1, 'high' => 2, + // The Http_Guard matched a configured connector credential in the + // outbound request, so attribution is certain — ranks above self-ID. + // A limit's min_confidence is only ever 'medium'|'high', so 'exact' + // satisfies every gate. + 'exact' => 3, ]; /** diff --git a/src/class-plugin.php b/src/class-plugin.php index 38146b3..1198f2d 100644 --- a/src/class-plugin.php +++ b/src/class-plugin.php @@ -33,6 +33,13 @@ class Plugin { */ private $gatekeeper = null; + /** + * HTTP-layer guard: exact connector attribution + optional enforcement. + * + * @var \WP_AIUT\Capture\Http_Guard|null + */ + private $http_guard = null; + /** * Admin settings page. * @@ -96,14 +103,44 @@ private function init() { * @return void */ public function register_runtime() { + // Shared collaborators so the pre-request hook (Gatekeeper) and the HTTP + // guard resolve attribution and evaluate limits identically. + $resolver = class_exists( '\\WP_AIUT\\Attribution\\Caller_Resolver' ) + ? new \WP_AIUT\Attribution\Caller_Resolver() + : null; + $enforcer = class_exists( '\\WP_AIUT\\Enforcement\\Enforcer' ) + ? new \WP_AIUT\Enforcement\Enforcer() + : null; + if ( null === $this->gatekeeper && class_exists( '\\WP_AIUT\\Capture\\Gatekeeper' ) ) { - $this->gatekeeper = new \WP_AIUT\Capture\Gatekeeper(); + $this->gatekeeper = new \WP_AIUT\Capture\Gatekeeper( $resolver, null, $enforcer ); if ( method_exists( $this->gatekeeper, 'register' ) ) { $this->gatekeeper->register(); } } + // HTTP-layer guard: exact connector attribution + optional enforcement. + // Reuses the same resolver + enforcer, and enriches the Gatekeeper's + // pending intents with exact attribution. + if ( null === $this->http_guard + && null !== $this->gatekeeper + && $resolver instanceof \WP_AIUT\Attribution\Caller_Resolver + && class_exists( '\\WP_AIUT\\Capture\\Http_Guard' ) + && class_exists( '\\WP_AIUT\\Capture\\Connector_Key_Index' ) ) { + + $this->http_guard = new \WP_AIUT\Capture\Http_Guard( + new \WP_AIUT\Capture\Connector_Key_Index(), + $resolver, + $enforcer, + $this->gatekeeper + ); + + if ( method_exists( $this->http_guard, 'register' ) ) { + $this->http_guard->register(); + } + } + if ( null === $this->rest_controller && class_exists( '\\WP_AIUT\\Admin\\Rest_Controller' ) ) { $this->rest_controller = new \WP_AIUT\Admin\Rest_Controller(); From 1e6786fc99286dc0321f55232898044975229efc Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Thu, 9 Jul 2026 10:30:08 +0200 Subject: [PATCH 2/4] Add tests for connector key index, HTTP guard, and the exact confidence tier - Connector_Key_Index_Test: credential match in header / url-encoded / array header, no-match on unrelated traffic, short-credential and empty-registry guards. - Http_Guard_Test: passthrough of non-connector and pre-empted requests, enrichment of the pending intent to `exact` + connector id, WP_Error 403 when the enforcer blocks, allow when it doesn't, and fail-open when it throws. - Limit_Evaluator: assert `exact` outranks high and satisfies a high gate. Adds a protected Connector_Key_Index::get_connectors() seam so tests supply a fixed connector set without depending on core's connector registry (which has no registration filter and varies by WP version). No production behaviour change. Full suite: 80 tests, 243 assertions, green. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Filip Ilic --- src/Capture/class-connector-key-index.php | 28 +++- tests/connector-key-index-test.php | 140 +++++++++++++++++ tests/http-guard-test.php | 176 ++++++++++++++++++++++ tests/limit-evaluator-test.php | 18 ++- 4 files changed, 354 insertions(+), 8 deletions(-) create mode 100644 tests/connector-key-index-test.php create mode 100644 tests/http-guard-test.php diff --git a/src/Capture/class-connector-key-index.php b/src/Capture/class-connector-key-index.php index c5eccc4..0039d59 100644 --- a/src/Capture/class-connector-key-index.php +++ b/src/Capture/class-connector-key-index.php @@ -135,13 +135,8 @@ private function get_keys() { * @return array */ private function build_index() { - $index = []; - - if ( ! function_exists( 'wp_get_connectors' ) ) { - return $index; - } - - $connectors = wp_get_connectors(); + $index = []; + $connectors = $this->get_connectors(); if ( ! is_array( $connectors ) ) { return $index; @@ -172,6 +167,25 @@ private function build_index() { return $index; } + /** + * Return the registered connectors keyed by ID. + * + * Reads WordPress core's connector registry, runtime-guarded so the class is + * inert (and analysable) when the WP 7.0 connector API is absent. A protected + * seam so tests can supply a fixed connector set without touching core state. + * + * @return array Connector data keyed by connector ID. + */ + protected function get_connectors() { + if ( ! function_exists( 'wp_get_connectors' ) ) { + return []; + } + + $connectors = wp_get_connectors(); + + return is_array( $connectors ) ? $connectors : []; + } + /** * Read every credential string configured for an authentication block. * diff --git a/tests/connector-key-index-test.php b/tests/connector-key-index-test.php new file mode 100644 index 0000000..36da72a --- /dev/null +++ b/tests/connector-key-index-test.php @@ -0,0 +1,140 @@ + [ + 'type' => 'ai_provider', + 'authentication' => [ + 'method' => 'api_key', + 'setting_name' => 'aiut_test_connector_key', + ], + ], + ]; + } + }; + } + + /** + * A credential in a header value identifies the connector. + */ + public function test_matches_credential_in_header() { + $idx = $this->make_index(); + + $hit = $idx->lookup( + [ 'headers' => [ 'x-api-key' => self::KEY, 'content-type' => 'application/json' ] ], + 'https://api.example.com/v1/messages' + ); + + $this->assertSame( 'testprov', $hit ); + } + + /** + * A credential url-encoded into the request URL still matches. + */ + public function test_matches_url_encoded_credential_in_url() { + $idx = $this->make_index(); + + $hit = $idx->lookup( [], 'https://api.example.com/v1/x?key=' . rawurlencode( self::KEY ) ); + + $this->assertSame( 'testprov', $hit ); + } + + /** + * A request carrying no configured credential is not attributed — unrelated + * HTTP traffic must pass through untouched. + */ + public function test_no_match_without_credential() { + $idx = $this->make_index(); + + $hit = $idx->lookup( + [ 'headers' => [ 'authorization' => 'Bearer something-else' ] ], + 'https://example.com/unrelated' + ); + + $this->assertNull( $hit ); + } + + /** + * A credential nested inside an array-valued header (as WP's HTTP API allows) + * is still scanned. + */ + public function test_matches_credential_in_array_header() { + $idx = $this->make_index(); + + $hit = $idx->lookup( + [ 'headers' => [ 'x-api-key' => [ self::KEY ] ] ], + 'https://api.example.com/v1/messages' + ); + + $this->assertSame( 'testprov', $hit ); + } + + /** + * A too-short credential is ignored, so it cannot false-positive against + * unrelated header content. + */ + public function test_short_credentials_are_ignored() { + update_option( 'aiut_test_short_key', 'short', false ); + + $idx = new class() extends Connector_Key_Index { + protected function get_connectors() { + return [ + 'shortprov' => [ + 'authentication' => [ + 'method' => 'api_key', + 'setting_name' => 'aiut_test_short_key', + ], + ], + ]; + } + }; + + $this->assertNull( $idx->lookup( [ 'headers' => [ 'x' => 'short' ] ], 'https://x/short' ) ); + } + + /** + * With no connectors registered the index never matches (and does not error). + */ + public function test_empty_registry_matches_nothing() { + $idx = new class() extends Connector_Key_Index { + protected function get_connectors() { + return []; + } + }; + + $this->assertNull( $idx->lookup( [ 'headers' => [ 'x-api-key' => self::KEY ] ], 'https://x/' ) ); + } +} diff --git a/tests/http-guard-test.php b/tests/http-guard-test.php new file mode 100644 index 0000000..8201864 --- /dev/null +++ b/tests/http-guard-test.php @@ -0,0 +1,176 @@ + [ + 'authentication' => [ + 'method' => 'api_key', + 'setting_name' => 'aiut_guard_connector_key', + ], + ], + ]; + } + }; + } + + /** + * A request carrying the credential. + * + * @return array{0:array,1:string} + */ + private function keyed_request() { + return [ + [ 'headers' => [ 'x-api-key' => self::KEY ] ], + 'https://api.example.com/v1/messages', + ]; + } + + /** + * Non-connector traffic is passed through unchanged (returns $preempt). + */ + public function test_passes_through_non_connector_request() { + $guard = new Http_Guard( $this->make_index(), new AIUT_Stub_Resolver() ); + + $this->assertFalse( $guard->maybe_block_request( false, [], 'https://example.com/' ) ); + } + + /** + * An already-preempted request is left exactly as-is. + */ + public function test_respects_existing_preempt() { + $guard = new Http_Guard( $this->make_index(), new AIUT_Stub_Resolver() ); + list( $a, $u ) = $this->keyed_request(); + $preempt = [ 'body' => 'cached' ]; + + $this->assertSame( $preempt, $guard->maybe_block_request( $preempt, $a, $u ) ); + } + + /** + * With no enforcer and a matched connector, the request passes through but the + * pending intent is upgraded to `exact` confidence with the connector id. + */ + public function test_enriches_pending_intent_to_exact() { + // Record an intent as a low-confidence unknown caller. + $gk = new Gatekeeper( new AIUT_Stub_Resolver( '__unknown__', 'low' ), null, null ); + $gk->observe_prompt( false, new stdClass() ); + + $before = $gk->match_pending( null ); + $this->assertSame( 'low', $before['confidence'] ); + + $guard = new Http_Guard( + $this->make_index(), + new AIUT_Stub_Resolver( 'acme-seo', 'medium' ), + null, + $gk + ); + + list( $a, $u ) = $this->keyed_request(); + $result = $guard->maybe_block_request( false, $a, $u ); + + $this->assertFalse( $result, 'No enforcer => request proceeds.' ); + + $after = $gk->match_pending( null ); + $this->assertSame( Caller_Resolver::CONFIDENCE_EXACT, $after['confidence'] ); + $this->assertSame( 'testprov', $after['connector_id'] ); + $this->assertSame( 'acme-seo', $after['source_slug'] ); + } + + /** + * When the Enforcer blocks, the guard returns a WP_Error 403 carrying the + * connector id. + */ + public function test_blocks_with_wp_error_when_enforcer_blocks() { + $guard = new Http_Guard( + $this->make_index(), + new AIUT_Stub_Resolver(), + new AIUT_Stub_Enforcer( true ) + ); + + list( $a, $u ) = $this->keyed_request(); + $result = $guard->maybe_block_request( false, $a, $u ); + + $this->assertWPError( $result ); + $this->assertSame( 'wp_aiut_connector_blocked', $result->get_error_code() ); + $data = $result->get_error_data(); + $this->assertSame( 403, $data['status'] ); + $this->assertSame( 'testprov', $data['connector_id'] ); + } + + /** + * With an enforcer that declines to block, the request proceeds. + */ + public function test_allows_when_enforcer_does_not_block() { + $guard = new Http_Guard( + $this->make_index(), + new AIUT_Stub_Resolver(), + new AIUT_Stub_Enforcer( false ) + ); + + list( $a, $u ) = $this->keyed_request(); + + $this->assertFalse( $guard->maybe_block_request( false, $a, $u ) ); + } + + /** + * A throwing enforcer must not break the request — the guard fails open and + * returns the incoming $preempt (false). + */ + public function test_fails_open_when_enforcer_throws() { + $throwing = new class() extends \WP_AIUT\Enforcement\Enforcer { + public function should_block( array $scopes, $confidence ) { + throw new \RuntimeException( 'boom' ); + } + }; + + $guard = new Http_Guard( $this->make_index(), new AIUT_Stub_Resolver(), $throwing ); + list( $a, $u ) = $this->keyed_request(); + + $result = $guard->maybe_block_request( false, $a, $u ); + + $this->assertFalse( is_wp_error( $result ), 'Guard must fail open, not block, on error.' ); + $this->assertFalse( $result ); + } +} diff --git a/tests/limit-evaluator-test.php b/tests/limit-evaluator-test.php index 14b3efa..7358536 100644 --- a/tests/limit-evaluator-test.php +++ b/tests/limit-evaluator-test.php @@ -123,12 +123,28 @@ public function test_current_usage_per_limit_type() { } /** - * confidence_allows ranks low < medium < high. + * confidence_allows ranks low < medium < high < exact. */ public function test_confidence_allows_ranking() { $this->assertTrue( $this->evaluator->confidence_allows( 'high', 'medium' ) ); $this->assertTrue( $this->evaluator->confidence_allows( 'medium', 'medium' ) ); $this->assertFalse( $this->evaluator->confidence_allows( 'low', 'medium' ) ); $this->assertFalse( $this->evaluator->confidence_allows( 'medium', 'high' ) ); + + // `exact` (the HTTP-guard credential-match tier) outranks self-ID, so it + // satisfies every configurable min_confidence gate. + $this->assertTrue( $this->evaluator->confidence_allows( 'exact', 'medium' ) ); + $this->assertTrue( $this->evaluator->confidence_allows( 'exact', 'high' ) ); + } + + /** + * An `exact`-confidence request satisfies a min_confidence=high hard limit and + * is therefore a breach (the tier must not fall through to the `low` default). + */ + public function test_exact_confidence_satisfies_high_gate() { + $this->repo->save( $this->base_limit( [ 'min_confidence' => 'high' ] ) ); + $this->seed_plugin_month_usage( 'acme', [ 'est_cost_micros' => 5000 ] ); + + $this->assertIsArray( $this->evaluator->first_hard_breach( [ 'plugin' => 'acme' ], 'exact' ) ); } } From f8323256b85eaaae3f3c663c305c02c0ace411f3 Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Thu, 9 Jul 2026 10:30:08 +0200 Subject: [PATCH 3/4] Run wp-scripts format to satisfy JS lint (prettier/prettier) `npm run lint:js` (CI js.yml) was failing on pre-existing prettier violations in App.js and Limits.js as well as the new dashboard panel. `npm run format` normalises both files; net -68 lines (collapses multi-line __() calls). No behaviour change. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Filip Ilic --- assets/src/App.js | 126 ++++++++++++------------------------------- assets/src/Limits.js | 16 ++---- 2 files changed, 37 insertions(+), 105 deletions(-) diff --git a/assets/src/App.js b/assets/src/App.js index aa0840a..3c65003 100644 --- a/assets/src/App.js +++ b/assets/src/App.js @@ -299,10 +299,7 @@ function RankedBreakdown( { scopeType, rows, nameHeader } ) { if ( ! rows.length ) { return (

- { __( - 'No usage recorded for this period yet.', - 'wp-aiut' - ) } + { __( 'No usage recorded for this period yet.', 'wp-aiut' ) }

); } @@ -439,10 +436,7 @@ function TimeSeriesChart( { series, metric } ) { if ( ! geom ) { return (

- { __( - 'No data points in this range yet.', - 'wp-aiut' - ) } + { __( 'No data points in this range yet.', 'wp-aiut' ) }

); } @@ -560,14 +554,11 @@ function TimeSeriesChart( { series, metric } ) { * @return {JSX.Element} Empty state card. */ function EmptyState( { config } ) { - const hook = - ( config && config.attribute ) || 'wp_aiut_attribute'; + const hook = ( config && config.attribute ) || 'wp_aiut_attribute'; return ( -

- { __( 'No AI usage recorded yet', 'wp-aiut' ) } -

+

{ __( 'No AI usage recorded yet', 'wp-aiut' ) }

{ __( 'Once a plugin or theme makes a WordPress AI Client request, it appears here attributed to the originating plugin and the current user.', @@ -734,9 +725,7 @@ export default function App( { config } ) { { loading ? (

- - { __( 'Loading usage…', 'wp-aiut' ) } - + { __( 'Loading usage…', 'wp-aiut' ) }
) : null } @@ -751,10 +740,7 @@ export default function App( { config } ) { @@ -815,10 +792,7 @@ export default function App( { config } ) { - { __( - 'Spend per plugin', - 'wp-aiut' - ) } + { __( 'Spend per plugin', 'wp-aiut' ) } { __( @@ -831,10 +805,7 @@ export default function App( { config } ) {
@@ -845,20 +816,14 @@ export default function App( { config } ) { - { __( - 'Spend per person', - 'wp-aiut' - ) } + { __( 'Spend per person', 'wp-aiut' ) } setPeopleScope( value ) @@ -866,17 +831,11 @@ export default function App( { config } ) { > @@ -899,20 +858,14 @@ export default function App( { config } ) { - { __( - 'Usage over time', - 'wp-aiut' - ) } + { __( 'Usage over time', 'wp-aiut' ) } setChartMetric( value ) @@ -920,17 +873,11 @@ export default function App( { config } ) { > @@ -1038,8 +985,15 @@ function ConnectorApprovalPanel() { - - + + @@ -1053,16 +1007,11 @@ function ConnectorApprovalPanel() { ) ) } @@ -1093,10 +1042,7 @@ function ProviderModelBreakdown( { totals } ) { if ( ! byProvider.length && ! byModel.length ) { return (

- { __( - 'No provider data for this period.', - 'wp-aiut' - ) } + { __( 'No provider data for this period.', 'wp-aiut' ) }

); } @@ -1114,20 +1060,14 @@ function ProviderModelBreakdown( { totals } ) {
  • { r.provider || - __( - '(unknown)', - 'wp-aiut' - ) } + __( '(unknown)', 'wp-aiut' ) } { formatMoney( r.est_cost_micros ) } { estimated ? ( ) : null } diff --git a/assets/src/Limits.js b/assets/src/Limits.js index 1820940..4ddd55a 100644 --- a/assets/src/Limits.js +++ b/assets/src/Limits.js @@ -155,10 +155,7 @@ export default function Limits() { } ); setLimits( ( res && res.limits ) || [] ); } catch ( e ) { - setError( - e.message || - __( 'Failed to load limits.', 'wp-aiut' ) - ); + setError( e.message || __( 'Failed to load limits.', 'wp-aiut' ) ); } finally { setLoading( false ); } @@ -201,8 +198,7 @@ export default function Limits() { await load(); } catch ( e ) { setError( - e.message || - __( 'Failed to save the limit.', 'wp-aiut' ) + e.message || __( 'Failed to save the limit.', 'wp-aiut' ) ); } finally { setSaving( false ); @@ -226,8 +222,7 @@ export default function Limits() { await load(); } catch ( e ) { setError( - e.message || - __( 'Failed to delete the limit.', 'wp-aiut' ) + e.message || __( 'Failed to delete the limit.', 'wp-aiut' ) ); } }; @@ -470,10 +465,7 @@ function LimitForm( { draft, set, onSave, onCancel, saving } ) { Date: Thu, 9 Jul 2026 10:31:28 +0200 Subject: [PATCH 4/4] docs: document the exact confidence tier and the HTTP-layer enforcement point Sync CLAUDE.md with the connector-approval work: the new `exact` attribution tier (HTTP-guard credential match, above self-ID) and its confidence-gating rank; the second enforcement point at pre_http_request sharing the Gatekeeper's Enforcer; the new Capture/ + Interop/ files; and the GET /connector-approvals route. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Filip Ilic --- CLAUDE.md | 48 ++++++++++++++++++++++++++++++++++-------------- 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8cbed19..f08b896 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,9 +92,12 @@ from the DTO, `estimated = 0`. It is hooked in `class-result-capturer.php` above). The intent→result match is by recency, since the event doesn't echo the builder identity. 2. **Attribution.** Nothing tells us which plugin made the call. Resolved by layering, in - confidence order: self-ID action hook (`wp_aiut_attribute`, high) → - `debug_backtrace` path-mapping to a plugin/theme slug (medium) → `__unknown__` (low). - User is `get_current_user_id()` / role, or `__system__` for cron/REST. + confidence order: credential match at the HTTP layer (`exact`) → self-ID action hook + (`wp_aiut_attribute`, high) → `debug_backtrace` path-mapping to a plugin/theme slug + (medium) → `__unknown__` (low). The `exact` tier comes from the HTTP guard (see below): + an outbound request that carries a configured connector credential is attributed with + certainty and its pending intent upgraded to `exact`. User is `get_current_user_id()` / + role, or `__system__` for cron/REST. **Watch:** if real-world data lands mostly as `estimated` or `__unknown__`, the capture design needs revisiting *before* any Phase 2 enforcement — never hard-block on numbers @@ -111,11 +114,15 @@ uninstall.php drops tables/options only if opted in src/ class-plugin.php wiring (hooks capture/rest/admin) Capture/ - class-gatekeeper.php hooks wp_ai_client_prevent_prompt (OBSERVE ONLY); pending-intent registry + class-gatekeeper.php hooks wp_ai_client_prevent_prompt (OBSERVE ONLY); pending-intent registry; enrich_latest_intent() upgrades an intent to exact class-result-capturer.php primary: wp_ai_client_after_generate_result event (real DTO tokens); fallbacks: wpai hook / transporter / estimate class-chaining-transporter.php fallback decorator that chains, never replaces + class-connector-key-index.php builds {credential -> connector_id} from core wp_get_connectors(); scans a request's URL+headers for a configured key + class-http-guard.php hooks pre_http_request; exact connector attribution + optional enforcement (2nd enforce point); OBSERVE ONLY until a hard limit; fails open Attribution/ - class-caller-resolver.php self-ID -> backtrace -> unknown; user/role + class-caller-resolver.php exact (HTTP guard) -> self-ID -> backtrace -> unknown; user/role + Interop/ + class-connector-approval-bridge.php read-only view of the WP AI plugin's "Connector Approval" experiment (its blocks never reach our capture path) Accounting/ class-usage-recorder.php single entry point: ::record($row) — writes event + fans out counters class-counter-store.php atomic INSERT ... ON DUPLICATE KEY UPDATE @@ -128,7 +135,7 @@ src/ Admin/ class-rest-controller.php namespace wp-aiut/v1 class-settings-page.php Tools -> AI Usage; enqueues the React build -assets/src/{index.js,App.js,style.scss} React dashboard (four views, spec §7) +assets/src/{index.js,App.js,style.scss} React dashboard (four views, spec §7; + a Connector-Approval interop panel) ``` ### Data model @@ -152,21 +159,34 @@ Tables are `{prefix}aiut_*` — `$wpdb->prefix` already ends in `_`, so the help - `src/Enforcement/class-enforcer.php`: `should_block(scopes, confidence)` — fast-path returns false when no hard limits; else first breach → `do_action('wp_aiut_blocked')` + return true. **Fails open** on any error. -- The Gatekeeper builds `scopes = {plugin, user, role, global}` (no model pre-request) and - calls the Enforcer after recording the intent. +- **Two enforcement points, one shared Enforcer** (wired in `Plugin::register_runtime` + with a shared `Caller_Resolver` + `Enforcer`): + - The Gatekeeper (`wp_ai_client_prevent_prompt`, pre-request) builds + `scopes = {plugin, user, role, global}` (no model pre-request) and calls the Enforcer + after recording the intent; a `true` return blocks the prompt. + - The `Http_Guard` (`pre_http_request`, pri 5) matches the exact connector by credential, + then calls the same Enforcer with `exact` confidence; a `true` return is translated into + a `WP_Error` 403 that short-circuits the HTTP call. This also covers callers that bypass + `wp_ai_client_prompt()` and hit the provider API directly. No double-block: a prompt + blocked at the pre-request hook never makes an HTTP request. Same observe-only / + fail-open invariants as the Gatekeeper. - `src/Alerts/`: `Threshold_Watcher` (hooks `wp_aiut_usage_recorded`, detects 80%/100% crossings, per-period transient dedup) + `Notifier` (`wp_mail`, plus `wp_aiut_notify` action and `wp_aiut_alert_email` filter). - Confidence gating answers "we can't assume plugins self-identify": **backtrace is the - no-cooperation default** (medium); self-ID is an optional accuracy upgrade (high). Hard - limits default to blocking medium+high; a limit's `min_confidence = high` restricts it to - self-identified callers only. `__unknown__` is never singled out (but can be capped as a - group via a global/wildcard limit). + no-cooperation default** (medium); self-ID is an optional accuracy upgrade (high); + `exact` (HTTP-guard credential match) outranks both. `Limit_Evaluator` ranks + low
  • { __( 'Plugin / theme', 'wp-aiut' ) }{ __( 'Connector', 'wp-aiut' ) } + { __( + 'Plugin / theme', + 'wp-aiut' + ) } + + { __( 'Connector', 'wp-aiut' ) } + { __( 'Attempts', 'wp-aiut' ) } { row.caller_name || row.caller_basename || - __( - 'Unknown', - 'wp-aiut' - ) } + __( 'Unknown', 'wp-aiut' ) } { row.connector_id } - { formatNumber( - row.attempts - ) } + { formatNumber( row.attempts ) }