$new Previously unseen ability names. * @param list $modified Abilities whose definition changed. * @param array $details Definitions either side of each change * and the WordPress version at this scan * and the last (AF-12.9). Optional, so a * caller firing the older two-argument * form still sends a usable digest. */ public function notify_ability_drift( array $new, array $modified, array $details = array() ): void { if ( array() === $new && array() === $modified ) { return; } if ( '1' !== (string) get_option( self::OPTION_EMAIL, '1' ) ) { return; } $throttle_key = 'adaf_anomaly_tool_' . substr( md5( (string) wp_json_encode( array( $new, $modified ) ) ), 0, 20 ); if ( false !== get_transient( $throttle_key ) ) { return; } set_transient( $throttle_key, 1, self::THROTTLE ); $paragraphs = array(); if ( array() !== $new ) { $paragraphs[] = sprintf( /* translators: 1: number of new abilities, 2: comma-separated ability names. */ _n( '%1$d new ability was registered on your site: %2$s. If you did not just install or update a plugin that provides it, investigate before agents can use it.', '%1$d new abilities were registered on your site: %2$s. If you did not just install or update a plugin that provides them, investigate before agents can use them.', count( $new ), 'agentic-daisy-ai-agent-firewall' ), count( $new ), implode( ', ', $new ) ); } if ( array() !== $modified ) { $paragraphs[] = sprintf( /* translators: 1: number of changed abilities, 2: comma-separated ability names. */ _n( 'The definition of %1$d ability changed: %2$s. A benign tool being silently replaced is a known attack (tool poisoning); verify the change is expected.', 'The definitions of %1$d abilities changed: %2$s. A benign tool being silently replaced is a known attack (tool poisoning); verify the changes are expected.', count( $modified ), 'agentic-daisy-ai-agent-firewall' ), count( $modified ), implode( ', ', $modified ) ); } foreach ( $this->drift_detail_paragraphs( $details ) as $paragraph ) { $paragraphs[] = $paragraph; } $paragraphs[] = $this->site_line(); wp_mail( (string) get_option( 'admin_email' ), sprintf( /* translators: %s: site host name. */ __( '[Agent Firewall] Ability registry changed on %s', 'agentic-daisy-ai-agent-firewall' ), $this->site_host() ), implode( "\n\n", $paragraphs ) ); } /** * Everything the digest can say about what actually changed. * * Empty when the caller sent no details, which keeps the older * two-argument form working unchanged. * * @param array $details Drift details from the monitor. * @return list */ private function drift_detail_paragraphs( array $details ): array { $changed = isset( $details['changed'] ) && is_array( $details['changed'] ) ? $details['changed'] : array(); $fresh = isset( $details['new'] ) && is_array( $details['new'] ) ? $details['new'] : array(); $paragraphs = array(); $upgrade = $this->wp_upgrade_note( $details ); if ( '' !== $upgrade ) { $paragraphs[] = $upgrade; } if ( array() === $changed && array() === $fresh ) { return $paragraphs; } // Said before any of it is shown. These strings belong to whoever // registered the ability, and a poisoned description reading like the // firewall clearing itself must not be able to borrow our voice. $paragraphs[] = __( 'What follows is quoted from the ability registry, not from Agent Firewall. Treat it as the claim being made, not as a fact about the tool.', 'agentic-daisy-ai-agent-firewall' ); foreach ( $changed as $name => $sides ) { $paragraphs[] = $this->changed_block( (string) $name, is_array( $sides ) ? $sides : array() ); } foreach ( $fresh as $name => $definition ) { $paragraphs[] = $this->new_block( (string) $name, is_array( $definition ) ? $definition : array() ); } return $paragraphs; } /** * Name a WordPress upgrade that sits between the two scans. * * It explains; it never clears. "Core upgraded, so core/* drift is fine" * is precisely the window a poisoned tool would choose, and the `core/` * prefix is a naming convention any plugin may adopt. * * @param array $details Drift details from the monitor. */ private function wp_upgrade_note( array $details ): string { $previous = isset( $details['wp']['previous'] ) ? (string) $details['wp']['previous'] : ''; $current = isset( $details['wp']['current'] ) ? (string) $details['wp']['current'] : ''; if ( '' === $previous || '' === $current || $previous === $current ) { return ''; } return sprintf( /* translators: 1: previous WordPress version, 2: current WordPress version. */ __( 'WordPress was updated from %1$s to %2$s between these scans, which commonly rewrites the wording of its own abilities. That explains a change; it does not confirm one. Any plugin may register an ability under a core-looking name.', 'agentic-daisy-ai-agent-firewall' ), $previous, $current ); } /** * Both sides of one changed ability, field by field. * * @param string $name Ability name. * @param array $sides before/after definitions. */ private function changed_block( string $name, array $sides ): string { $before = isset( $sides['before'] ) && is_array( $sides['before'] ) ? $sides['before'] : null; $after = isset( $sides['after'] ) && is_array( $sides['after'] ) ? $sides['after'] : array(); $lines = array( $name ); if ( null === $before ) { $lines[] = ' ' . __( 'The previous definition was not recorded: this site was fingerprinting abilities before the plugin began keeping their text.', 'agentic-daisy-ai-agent-firewall' ); foreach ( $this->fields() as $field => $label ) { $lines[] = sprintf( ' %s now: "%s"', $label, $this->value( $after, $field ) ); } return implode( "\n", $lines ); } $differing = 0; foreach ( $this->fields() as $field => $label ) { $was = $this->value( $before, $field ); $now = $this->value( $after, $field ); if ( $was === $now ) { continue; } ++$differing; $lines[] = sprintf( ' %s was: "%s"', $label, $was ); $lines[] = sprintf( ' %s now: "%s"', $label, $now ); } if ( 0 === $differing ) { // The fingerprint covers the whole definition; only an excerpt is // stored. Printing two identical strings would read as a bug. $lines[] = ' ' . sprintf( /* translators: %d: number of characters kept per field. */ __( 'The change is beyond the recorded excerpt: the first %d characters of every field are unchanged, so the difference lies past that point.', 'agentic-daisy-ai-agent-firewall' ), ToolRegistryMonitor::EXCERPT ); } return implode( "\n", $lines ); } /** * What a newly registered ability says about itself. * * @param string $name Ability name. * @param array $definition Its definition. */ private function new_block( string $name, array $definition ): string { $lines = array( $name ); foreach ( $this->fields() as $field => $label ) { $lines[] = sprintf( ' %s: "%s"', $label, $this->value( $definition, $field ) ); } return implode( "\n", $lines ); } /** * The definition fields the digest reports, and their display names. * * @return array */ private function fields(): array { return array( 'label' => __( 'label', 'agentic-daisy-ai-agent-firewall' ), 'description' => __( 'description', 'agentic-daisy-ai-agent-firewall' ), 'category' => __( 'category', 'agentic-daisy-ai-agent-firewall' ), ); } /** * One field of a definition, flattened for a plain-text mail. * * Newlines are folded so registry text cannot forge the indented, * line-per-field shape this block uses. * * @param array $definition Definition. * @param string $field Field name. */ private function value( array $definition, string $field ): string { $value = isset( $definition[ $field ] ) ? (string) $definition[ $field ] : ''; return trim( (string) preg_replace( '/\s+/', ' ', $value ) ); } /** * The sending site's host name, for email subjects. One admin inbox * often serves several sites; a subject that names the site saves the * reader from hunting through headers to find which one alerted. */ private function site_host(): string { $home = home_url(); $host = wp_parse_url( $home, PHP_URL_HOST ); return is_string( $host ) && '' !== $host ? $host : $home; } /** * Body line naming the sending site by full URL. */ private function site_line(): string { return sprintf( /* translators: %s: site URL. */ __( 'Site: %s', 'agentic-daisy-ai-agent-firewall' ), home_url() ); } /** * Send the (throttled) alert email. * * @param int $agent_id Agent that exceeded its limit. * @param array $rule The matched limit rule. */ public function notify( int $agent_id, array $rule ): void { if ( '1' !== (string) get_option( self::OPTION_EMAIL, '1' ) ) { return; } $throttle_key = 'adaf_anomaly_mail_' . $agent_id; if ( false !== get_transient( $throttle_key ) ) { return; } set_transient( $throttle_key, 1, self::THROTTLE ); $limit = isset( $rule['limit'] ) && is_array( $rule['limit'] ) ? $rule['limit'] : array(); $writes = isset( $limit['writes'] ) ? (int) $limit['writes'] : 0; $window = isset( $limit['window'] ) ? (int) $limit['window'] : 0; $subject = sprintf( /* translators: 1: agent id, 2: site host name. */ __( '[Agent Firewall] Rate limit exceeded by Agent #%1$d on %2$s', 'agentic-daisy-ai-agent-firewall' ), $agent_id, $this->site_host() ); $body = sprintf( /* translators: 1: agent id, 2: allowed writes, 3: window in seconds. */ __( 'Agent #%1$d exceeded its write rate limit (%2$d writes per %3$d seconds) and further writes were denied. Review the Agent Firewall ledger; a runaway loop or a hijacked agent may be at work. Further alerts for this agent are muted for one hour.', 'agentic-daisy-ai-agent-firewall' ), $agent_id, $writes, $window ) . "\n\n" . $this->site_line(); wp_mail( (string) get_option( 'admin_email' ), $subject, $body ); } }