send( 'pending_approval', sprintf( /* translators: 1: site host name, 2: agent name, 3: agent id, 4: action category, 5: target description, 6: URL of the approval queue. */ __( 'Agent Firewall on %1$s: %2$s (Agent #%3$d) asked to %4$s %5$s, and it is held for your approval. Review it here: %6$s', 'agentic-daisy-ai-agent-firewall' ), $this->site_host(), $agent->name, $agent->id, $action->action_cat, $this->entity_phrase( $action ), $review_url ), array( 'pending_id' => $pending_id, 'agent_id' => $agent->id, 'agent_name' => $agent->name, 'agent_key' => $agent->agent_key, 'action_cat' => $action->action_cat, 'entity_type' => $action->entity_type, 'entity_id' => $action->entity_id, 'ability_name' => $action->ability_name, 'ledger_id' => $ledger_id, 'review_url' => $review_url, ), 'adaf_webhook_pending_' . $pending_id ); } /** * Announce a denial worth reading. * * Most denials are not. See Evaluation::NOTABLE_REASONS for which are, and why the * rest are deliberately silent here. * * @param Agent $agent Agent that was refused. * @param ActionDescriptor $action What it tried to do. * @param string|null $reason Denial reason, an Evaluation::REASON_* value. * @param int $ledger_id Ledger row recording the decision. */ public function on_denied( Agent $agent, ActionDescriptor $action, ?string $reason, int $ledger_id ): void { if ( null === $reason || ! in_array( $reason, Evaluation::NOTABLE_REASONS, true ) ) { return; } $this->send( 'denied', sprintf( /* translators: 1: site host name, 2: agent name, 3: agent id, 4: action category, 5: target description, 6: why it was refused. */ __( 'Agent Firewall on %1$s: %2$s (Agent #%3$d) was denied - it tried to %4$s %5$s %6$s. Further alerts of this kind for this agent are muted for one hour.', 'agentic-daisy-ai-agent-firewall' ), $this->site_host(), $agent->name, $agent->id, $action->action_cat, $this->entity_phrase( $action ), $this->denial_phrase( $reason ) ), array( 'reason' => $reason, 'agent_id' => $agent->id, 'agent_name' => $agent->name, 'agent_key' => $agent->agent_key, 'action_cat' => $action->action_cat, 'entity_type' => $action->entity_type, 'entity_id' => $action->entity_id, 'ability_name' => $action->ability_name, 'ledger_id' => $ledger_id, ), 'adaf_webhook_denied_' . $agent->id . '_' . $reason ); } /** * What a denial reason means, in a sentence rather than a slug. * * @param string $reason An Evaluation::REASON_* value. */ private function denial_phrase( string $reason ): string { switch ( $reason ) { case Evaluation::REASON_LOCKDOWN: return __( 'while the kill switch is engaged', 'agentic-daisy-ai-agent-firewall' ); case Evaluation::REASON_CONTROL_PLANE: return __( "which would have reached the firewall's own controls", 'agentic-daisy-ai-agent-firewall' ); default: return __( 'which is outside the scopes its identity was issued with', 'agentic-daisy-ai-agent-firewall' ); } } /** * The target of an action, named for a one-line message. * * @param ActionDescriptor $action Action. */ private function entity_phrase( ActionDescriptor $action ): string { return null === $action->entity_id ? $action->entity_type : $action->entity_type . ' #' . $action->entity_id; } /** * Deliver a ledger that failed verification (AF-20.2). Unthrottled here: * HealthCheck sends once per problem with a daily reminder. * * @param int $entry_id First ledger entry that fails to verify. * @param bool $reminder Whether this repeats an earlier alarm. * @param ChainBreak|null $found What verification found (AF-24.6); null * from a caller that passes only the entry. */ public function on_chain_broken( int $entry_id, bool $reminder, ?ChainBreak $found = null ): void { $this->send( 'chain_broken', sprintf( /* translators: 1: site host name, 2: ledger entry id, 3: what verification found. */ __( 'Agent Firewall on %1$s: the audit ledger fails verification from entry #%2$d. %3$s', 'agentic-daisy-ai-agent-firewall' ), $this->site_host(), $entry_id, null === $found ? __( 'Entries were changed outside the plugin.', 'agentic-daisy-ai-agent-firewall' ) : $found->describe() ), array( 'entry_id' => $entry_id, 'reminder' => $reminder, 'reason' => $found?->reason, ) ); } /** * Deliver an approval-queue backlog (AF-20.2). Unthrottled here, as above. * * @param int $count Held actions waiting. * @param \DateTimeImmutable $oldest When the longest-waiting one was held. */ public function on_queue_backlog( int $count, \DateTimeImmutable $oldest ): void { $this->send( 'queue_backlog', sprintf( /* translators: 1: site host name, 2: number of held actions, 3: date and time. */ __( 'Agent Firewall on %1$s: %2$d held action(s) waiting for approval, the oldest since %3$s UTC.', 'agentic-daisy-ai-agent-firewall' ), $this->site_host(), $count, $oldest->format( 'Y-m-d H:i' ) ), array( 'count' => $count, 'oldest_held_at' => $oldest->format( 'Y-m-d H:i:s' ), 'queue' => Sections::url( 'pending' ), ) ); } /** * Deliver a rate-limit breach. * * @param int $agent_id Agent that exceeded its limit. * @param array $rule The matched limit rule. */ public function on_rate_limit( int $agent_id, array $rule ): void { $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; $this->send( 'rate_limit', sprintf( /* translators: 1: site host name, 2: agent id, 3: allowed writes, 4: window in seconds. */ __( 'Agent Firewall on %1$s: Agent #%2$d exceeded its write rate limit (%3$d writes per %4$d seconds) and further writes were denied.', 'agentic-daisy-ai-agent-firewall' ), $this->site_host(), $agent_id, $writes, $window ), array( 'agent_id' => $agent_id, 'writes' => $writes, 'window' => $window, ), 'adaf_webhook_rate_' . $agent_id ); } /** * Deliver a scan's worth of ability drift as one digest. * * Only the ability names travel. The label, description and category an * ability describes itself with belong to whoever registered it, and a * poisoned description reaching a chat channel would be reading as this * plugin's own voice to whoever opens it. * * @param list $new Previously unseen ability names. * @param list $modified Abilities whose definition changed. * @param array $details Definitions either side of each change. * Unused here; accepted so the three- * argument event signature still matches. */ public function on_ability_drift( array $new, array $modified, array $details = array() ): void { unset( $details ); if ( array() === $new && array() === $modified ) { return; } $parts = array(); if ( array() !== $new ) { $parts[] = sprintf( /* translators: %d: number of newly registered abilities. */ _n( '%d new ability was registered', '%d new abilities were registered', count( $new ), 'agentic-daisy-ai-agent-firewall' ), count( $new ) ); } if ( array() !== $modified ) { $parts[] = sprintf( /* translators: %d: number of abilities whose definition changed. */ _n( '%d ability definition changed', '%d ability definitions changed', count( $modified ), 'agentic-daisy-ai-agent-firewall' ), count( $modified ) ); } $this->send( 'ability_drift', sprintf( /* translators: 1: site host name, 2: summary of what changed. */ __( 'Agent Firewall on %1$s: %2$s. A benign tool being silently replaced is a known attack; verify the change is expected.', 'agentic-daisy-ai-agent-firewall' ), $this->site_host(), implode( ', ', $parts ) ), array( 'new' => array_values( $new ), 'modified' => array_values( $modified ), ), 'adaf_webhook_drift_' . substr( md5( (string) wp_json_encode( array( $new, $modified ) ) ), 0, 20 ) ); } /** * Post a test message and wait for the answer. * * Unlike an alert this blocks, because the whole point is to report what * happened: a configuration check that returned silently would be * indistinguishable from the misconfiguration it is meant to catch. * * @return array{delivered: bool, message: string} */ public function deliver_test(): array { $payload = $this->payload( 'test', sprintf( /* translators: %s: site host name. */ __( 'Agent Firewall test message from %s. If you can read this, the endpoint is configured correctly.', 'agentic-daisy-ai-agent-firewall' ), $this->site_host() ), array() ); $destinations = $this->destinations( 'test', $payload ); if ( array() === $destinations ) { return array( 'delivered' => false, 'message' => __( 'No webhook URL is configured yet.', 'agentic-daisy-ai-agent-firewall' ), ); } foreach ( $destinations as $destination ) { $response = $this->post( $destination, $payload, 'test', true ); if ( is_wp_error( $response ) ) { return array( 'delivered' => false, 'message' => sprintf( /* translators: %s: HTTP error message. */ __( 'The request never arrived: %s', 'agentic-daisy-ai-agent-firewall' ), $response->get_error_message() ), ); } $code = (int) wp_remote_retrieve_response_code( $response ); if ( $code < 200 || $code > 299 ) { return array( 'delivered' => false, 'message' => sprintf( /* translators: %d: HTTP status code. */ __( 'The endpoint answered with HTTP %d, so it did not accept the message.', 'agentic-daisy-ai-agent-firewall' ), $code ), ); } } return array( 'delivered' => true, 'message' => __( 'Test message delivered.', 'agentic-daisy-ai-agent-firewall' ), ); } /** * Build one event and send it to every destination it resolves to. * * Public so a new kind of notification - a later story's, or another * plugin's - is one call rather than a new method here. The two filters * below apply to whatever comes through it. * * @param string $event Event name. * @param string $text Human-readable one-liner. * @param array $data Structured event fields. * @param string|null $throttle_key Transient key muting repeats, * or null to send every time. */ public function send( string $event, string $text, array $data = array(), ?string $throttle_key = null ): void { $payload = $this->payload( $event, $text, $data ); $destinations = $this->destinations( $event, $payload ); // Checked before the throttle is claimed: an unconfigured site must // not spend a window it never used, or saving a URL mid-incident // would buy an hour of silence. if ( array() === $destinations ) { return; } if ( null !== $throttle_key && ! $this->claim_throttle( $throttle_key ) ) { return; } foreach ( $destinations as $destination ) { $this->post( $destination, $payload, $event, false ); } } /** * The endpoints one event is delivered to. * * @param string $event Event name. * @param array $payload The event body, before formatting. * @return list */ private function destinations( string $event, array $payload ): array { $url = trim( (string) get_option( self::OPTION_URL, '' ) ); $default = '' === $url ? array() : array( array( 'url' => $url, 'secret' => (string) get_option( self::OPTION_SECRET, '' ), ), ); /** * Filters the endpoints one firewall event is delivered to. * * The routing seam. Returning more than one entry fans a single * event out; returning an empty list drops it. * * @param list $default One entry for the configured URL, or none. * @param string $event Event name. * @param array $payload The event body, before per-destination formatting. */ $destinations = apply_filters( 'adaf_webhook_destinations', $default, $event, $payload ); if ( ! is_array( $destinations ) ) { return array(); } $valid = array(); foreach ( $destinations as $destination ) { if ( ! is_array( $destination ) ) { continue; } $target = isset( $destination['url'] ) ? trim( (string) $destination['url'] ) : ''; if ( '' === $target ) { continue; } $valid[] = array( 'url' => $target, 'secret' => isset( $destination['secret'] ) ? (string) $destination['secret'] : '', ); } return $valid; } /** * The generic event body. * * The message line is first-class rather than a courtesy: it is what makes * one endpoint setting reach a chat channel unaided. It is carried twice * because the two conventions disagree - Slack and Google Chat read * `text`, Discord reads `content` and rejects a body that has neither - * and one duplicated string is a cheaper way to support both than a * provider picker in the settings. * * `data` is cast to an object so it encodes as `{}` rather than `[]` * when an event carries no fields. PHP does not distinguish the two, but * JSON does, and a receiver that parses `data` as a map should not have * to handle it arriving as a list - least of all for the test message, * which is the delivery people build their parser against. The cast is * shallow, so lists nested inside stay lists. * * @param string $event Event name. * @param string $text Human-readable one-liner. * @param array $data Structured event fields. * @return array */ private function payload( string $event, string $text, array $data ): array { return array( 'event' => $event, 'text' => $text, 'content' => $text, 'site' => home_url(), 'timestamp' => gmdate( 'c' ), 'version' => defined( 'ADAF_VERSION' ) ? ADAF_VERSION : '', 'data' => (object) $data, ); } /** * Post one payload to one destination. * * @param array{url: string, secret: string} $destination Where to send it. * @param array $payload The event body. * @param string $event Event name. * @param bool $blocking Whether to wait for the answer. * @return array|\WP_Error */ private function post( array $destination, array $payload, string $event, bool $blocking ) { /** * Filters the body delivered to one destination. * * The formatting seam: a provider-shaped payload can replace the * generic one per endpoint. * * @param array $payload Generic event body. * @param array{url: string, secret: string} $destination The endpoint it is bound for. * @param string $event Event name. */ $formatted = apply_filters( 'adaf_webhook_payload', $payload, $destination, $event ); $body = (string) wp_json_encode( is_array( $formatted ) ? $formatted : $payload ); $headers = array( 'Content-Type' => 'application/json; charset=utf-8', 'X-ADAF-Event' => $event, ); // Signed only when a secret exists. A signature computed over an // empty key would look authentic and prove nothing. if ( '' !== $destination['secret'] ) { $headers['X-ADAF-Signature'] = 'sha256=' . hash_hmac( 'sha256', $body, $destination['secret'] ); } // wp_safe_remote_post(), not wp_remote_post(): the URL is operator- // supplied and must not become a way to make the site call its own // private network. return wp_safe_remote_post( $destination['url'], array( // Alerts never block. The rate-limit event fires inside // policy evaluation, which has a 1ms budget; a slow endpoint // must not become the site's latency. 'blocking' => $blocking, 'timeout' => $blocking ? self::TEST_TIMEOUT : self::TIMEOUT, 'headers' => $headers, 'body' => $body, ) ); } /** * Take the throttle window, or report that it is already taken. * * @param string $key Transient key. */ private function claim_throttle( string $key ): bool { if ( false !== get_transient( $key ) ) { return false; } set_transient( $key, 1, self::THROTTLE ); return true; } /** * The sending site's host name. One channel often watches several sites; * a message that names the site saves the reader from guessing which. */ private function site_host(): string { $home = home_url(); $host = wp_parse_url( $home, PHP_URL_HOST ); return is_string( $host ) && '' !== $host ? $host : $home; } }