site_host() ); $lead = $reminder /* translators: %d: ledger entry id. */ ? __( 'The audit ledger still fails verification, first at entry #%d.', 'agentic-daisy-ai-agent-firewall' ) /* translators: %d: ledger entry id. */ : __( 'The audit ledger failed its daily verification, first at entry #%d.', 'agentic-daisy-ai-agent-firewall' ); $what = null === $found ? __( 'An entry from there on was changed, inserted or removed outside the plugin.', 'agentic-daisy-ai-agent-firewall' ) : $found->describe(); $body = sprintf( $lead, $entry_id ) . ' ' . $what . ' ' . __( 'The record of what agents did can no longer be relied on past that point. Confirm with `wp agent-firewall verify`, and check database access and backups restored since then.', 'agentic-daisy-ai-agent-firewall' ) . "\n\n" . sprintf( /* translators: %s: URL of the ledger. */ __( 'Ledger: %s', 'agentic-daisy-ai-agent-firewall' ), Sections::url( 'ledger' ) ) . "\n\n" . $this->site_line(); wp_mail( (string) get_option( 'admin_email' ), $subject, $body ); } /** * Email that held actions have waited over a day. * * About held actions, so it rides the held-action switch, off by * default: an upgrade sends no new kind of mail unasked. * * @param int $count Held actions waiting. * @param DateTimeImmutable $oldest When the longest-waiting one was held. */ public function notify_queue_backlog( int $count, DateTimeImmutable $oldest ): void { if ( '1' !== (string) get_option( self::OPTION_PENDING_EMAIL, '0' ) ) { return; } $subject = sprintf( /* translators: %s: site host name. */ __( '[Agent Firewall] Held actions are still waiting for review on %s', 'agentic-daisy-ai-agent-firewall' ), $this->site_host() ); $body = sprintf( /* translators: 1: number of held actions, 2: date and time the oldest was held. */ _n( '%1$d agent action is waiting for approval, held since %2$s. Held actions expire unapproved, and the agent that asked is still waiting on the answer.', '%1$d agent actions are waiting for approval, the oldest held since %2$s. Held actions expire unapproved, and the agents that asked are still waiting on the answer.', $count, 'agentic-daisy-ai-agent-firewall' ), $count, $oldest->format( 'Y-m-d H:i' ) . ' UTC' ) . "\n\n" . sprintf( /* translators: %s: URL of the approval queue. */ __( 'Review them here: %s', 'agentic-daisy-ai-agent-firewall' ), Sections::url( 'pending' ) ) . "\n\n" . $this->site_line(); wp_mail( (string) get_option( 'admin_email' ), $subject, $body ); } /** * Email the administrator that an action is waiting on them. * * Deduplicated by pending row rather than throttled per agent: each held * action is a separate decision someone has to make, so swallowing the * second for an hour would leave a specific piece of work silently stuck. * * @param int $pending_id Pending row id. * @param Agent $agent Agent whose action was held. * @param ActionDescriptor $action The held action. * @param int $ledger_id Ledger row recording the decision. * @param bool $awaits_ai Whether the AI evaluator reviews it first. */ public function notify_pending( int $pending_id, Agent $agent, ActionDescriptor $action, int $ledger_id, bool $awaits_ai = false ): void { unset( $ledger_id ); // The AI evaluator reviews it first; if it escalates, the hook fires // again for a human and the mail goes then (ADR-15 ยง5). if ( $awaits_ai ) { return; } // Independent of OPTION_EMAIL on purpose. Someone who turned the // anomaly alerts off did so about rate limits and ability changes; // this switch was ticked separately and says what it sends. if ( '1' !== (string) get_option( self::OPTION_PENDING_EMAIL, '0' ) ) { return; } $throttle_key = 'adaf_pending_mail_' . $pending_id; if ( false !== get_transient( $throttle_key ) ) { return; } set_transient( $throttle_key, 1, self::THROTTLE ); $target = null === $action->entity_id ? $action->entity_type : $action->entity_type . ' #' . $action->entity_id; $subject = sprintf( /* translators: %s: site host name. */ __( '[Agent Firewall] An action is waiting for your approval on %s', 'agentic-daisy-ai-agent-firewall' ), $this->site_host() ); $body = sprintf( /* translators: 1: agent name, 2: agent id, 3: action category, 4: target description. */ __( '%1$s (Agent #%2$d) asked to %3$s %4$s. The action has not run: it is held in the approval queue until you approve or reject it, and the agent has been told it is pending.', 'agentic-daisy-ai-agent-firewall' ), $agent->name, $agent->id, $action->action_cat, $target ) . "\n\n" . sprintf( /* translators: %s: URL of the approval queue. */ __( 'Review it here: %s', 'agentic-daisy-ai-agent-firewall' ), Sections::url( 'pending' ) ) . "\n\n" . $this->site_line(); wp_mail( (string) get_option( 'admin_email' ), $subject, $body ); } /** * Alert on a scan's worth of ability drift (PRD STORY-9.4, MCP03), as a * single digest naming everything that appeared or changed. * * Throttled by the drift's content: the same set within the window is * muted, while a genuinely different drift (a second plugin update) still * alerts. * * @param list $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 ); } }