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
- { __(
- '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 } ) {
>
@@ -945,12 +892,139 @@ 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 )
+ ) }
+
+
+
+
+ |
+ { __(
+ 'Plugin / theme',
+ 'wp-aiut'
+ ) }
+ |
+
+ { __( 'Connector', 'wp-aiut' ) }
+ |
+
+ { __( 'Attempts', 'wp-aiut' ) }
+ |
+
+
+
+ { pending.map( ( row ) => (
+
+ |
+ { row.caller_name ||
+ row.caller_basename ||
+ __( 'Unknown', 'wp-aiut' ) }
+ |
+ { row.connector_id } |
+
+ { formatNumber( row.attempts ) }
+ |
+
+ ) ) }
+
+
+ >
+ ) }
+
+
+
+ );
+}
+
/**
* Provider/model breakdown sub-section under the totals strip.
*
@@ -968,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' ) }
);
}
@@ -989,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 } ) {
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..0039d59
--- /dev/null
+++ b/src/Capture/class-connector-key-index.php
@@ -0,0 +1,260 @@
+ 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 = [];
+ $connectors = $this->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;
+ }
+
+ /**
+ * 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.
+ *
+ * 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();
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' ) );
}
}