}|null */ private static ?array $replaying_effect = null; /** * Constructor. * * @param Registry|null $extensions Extensions, whose replay providers run * writes held at an effect hook. */ public function __construct( private readonly ?Registry $extensions = null ) { } /** * Whether an approval is running this very write, held at an effect * hook. Another write the replay sets off is decided as usual. * * @param string $provider_id Replay provider id. * @param array $envelope Its envelope. */ public static function is_replaying_effect( string $provider_id, array $envelope ): bool { return array( $provider_id, $envelope ) === self::$replaying_effect; } /** * Whether this request is the one an approval is re-sending, so the * gatekeeper lets it through instead of evaluating it a second time. * * @param \WP_REST_Request $request Request being dispatched. */ public static function is_replaying( \WP_REST_Request $request ): bool { return $request === self::$replaying; } /** * Run a callback with one request marked as the replay, clearing the * mark afterward however the callback exits. * * @template T * @param \WP_REST_Request $request The request being re-sent. * @param callable(): T $callback Work to run. * @return T */ public static function during_replay( \WP_REST_Request $request, callable $callback ) { $previous = self::$replaying; self::$replaying = $request; try { return $callback(); } finally { self::$replaying = $previous; } } /** * REST param → post field mapping for create/update. */ private const POST_FIELDS = array( 'title' => 'post_title', 'content' => 'post_content', 'excerpt' => 'post_excerpt', 'status' => 'post_status', 'slug' => 'post_name', ); /** * Execute the stored payload. * * @param LedgerEntry $entry Ledger row of the original interception. * @param PendingAction $action Approved pending action. * @return array|WP_Error */ public function execute( LedgerEntry $entry, PendingAction $action ) { $reason = $this->why_not( $entry, $action ); if ( null !== $reason ) { return new WP_Error( 'adaf_not_executable', $reason ); } $effect = self::effect( $action ); if ( null !== $effect ) { return $this->replay_effect( $entry, $action, $effect[0], $effect[1] ); } if ( ! str_starts_with( $action->ability_name, 'rest:' ) ) { return $this->execute_ability( $action ); } if ( null !== $action->request ) { return $this->replay( $action->request ); } return $this->execute_rest( $entry, $action ); } /** * Why approving this action cannot run it, or null when it can. * * The single source of that answer: execute() refuses by it, and the * approval queue reads it to decide whether to offer Approve at all, so * the two can never disagree (AF-18.2). "Can run" means there is * something to send; whether core then accepts it is decided by the * replay itself. * * @param LedgerEntry $entry Ledger row of the original interception. * @param PendingAction $action Held action. */ public function why_not( LedgerEntry $entry, PendingAction $action ): ?string { // Held at an effect hook: its provider runs it, whatever the door. if ( is_array( $action->request ) && array_key_exists( 'effect_provider', $action->request ) ) { $effect = self::effect( $action ); return null !== $effect && null !== $this->extensions?->replay_provider( $effect[0] ) ? null : __( 'This write was held inside another plugin\'s code, and the extension that would run it is not active. Reject it, or reactivate that extension and approve it again.', 'agentic-daisy-ai-agent-firewall' ); } if ( 'unclassified' === $entry->source ) { return __( 'This write was caught inside another plugin\'s code rather than arriving as a request, so there is nothing to send again. Reject it, or make the change yourself.', 'agentic-daisy-ai-agent-firewall' ); } if ( 'mcp' === $entry->source ) { return __( 'This MCP tool is not backed by a WordPress ability, so the firewall cannot run it on your behalf. Reject it, or make the change yourself.', 'agentic-daisy-ai-agent-firewall' ); } if ( ! str_starts_with( $action->ability_name, 'rest:' ) ) { return wp_has_ability( $action->ability_name ) ? null : __( 'The ability for this action is no longer registered - the plugin providing it may have been deactivated. Reject it, or reactivate that plugin and approve it again.', 'agentic-daisy-ai-agent-firewall' ); } if ( null !== $action->request ) { return self::why_not_replay( $action->request ); } // Held before the request itself was kept: only the post-backed // cases the old field mapping covered can run. // Not a media create: the old mapping turned one into an ordinary // post, since the uploaded file was never kept. $post_backed = in_array( $entry->entity_type, array( 'post', 'page', 'media' ), true ); $targeted = null !== $entry->entity_id && in_array( $entry->action_cat, array( 'update', 'delete' ), true ); $creatable = 'create' === $entry->action_cat && 'media' !== $entry->entity_type; if ( $post_backed && ( $targeted || $creatable ) ) { return null; } return __( 'This action was held by an earlier version of the plugin, which did not keep the request, so it cannot be sent again. Reject it, or make the change yourself.', 'agentic-daisy-ai-agent-firewall' ); } /** * A hold's replay provider id and envelope, when it was held at an * effect hook and both are well formed. * * @param PendingAction $action Held action. * @return array{0: string, 1: array}|null */ private static function effect( PendingAction $action ): ?array { $request = $action->request ?? array(); if ( ! is_string( $request['effect_provider'] ?? null ) || ! is_array( $request['envelope'] ?? null ) ) { return null; } return array( $request['effect_provider'], $request['envelope'] ); } /** * Hand a write held at an effect hook to its replay provider. The * caller has switched to the agent's linked user. * * @param LedgerEntry $entry Ledger row of the hold. * @param PendingAction $action The hold. * @param string $provider_id Its replay provider. * @param array $envelope What the extension stored. * @return array|WP_Error */ private function replay_effect( LedgerEntry $entry, PendingAction $action, string $provider_id, array $envelope ) { // why_not() found the provider; still guarded, so a change there // could never turn a missing provider into an approval that ran // nothing. $provider = $this->extensions?->replay_provider( $provider_id ); $missing = new WP_Error( 'adaf_exec_failed', __( 'The extension that would run this write is not active.', 'agentic-daisy-ai-agent-firewall' ) ); $previous = self::$replaying_effect; self::$replaying_effect = array( $provider_id, $envelope ); try { return null === $provider ? $missing : $provider->replay( $entry->action( $action->stored_input ), $envelope ); } finally { self::$replaying_effect = $previous; } } /** * Why a stored request cannot be re-sent, or null when it can. * * @param array $envelope Replay envelope from the gatekeeper. */ private static function why_not_replay( array $envelope ): ?string { if ( ! is_string( $envelope['method'] ?? null ) || ! is_string( $envelope['route'] ?? null ) || '' === $envelope['method'] || '' === $envelope['route'] ) { return __( 'The stored request is incomplete, so it cannot be sent again. Reject it, or make the change yourself.', 'agentic-daisy-ai-agent-firewall' ); } if ( 1 === preg_match( self::OWN_NAMESPACE, $envelope['route'] ) ) { return __( 'Requests to the firewall\'s own routes are never run on an agent\'s behalf.', 'agentic-daisy-ai-agent-firewall' ); } if ( ! empty( $envelope['file'] ) ) { return __( 'This request uploaded a file, and the file is not kept while an action waits for approval, so it cannot be sent again. Reject it, and upload the file yourself or ask the agent to send it again.', 'agentic-daisy-ai-agent-firewall' ); } return null; } /** * Re-send a held REST request as it was sent. * * The caller has already switched to the agent's linked user, so core's * permission callbacks judge the replay by that user's capabilities, not * the approver's. * * @param array $envelope Replay envelope from the gatekeeper. * @return array|WP_Error */ private function replay( array $envelope ) { // why_not() has already vouched for both. $method = (string) $envelope['method']; $route = (string) $envelope['route']; $request = new \WP_REST_Request( $method, $route ); $request->set_query_params( self::params( $envelope, 'query' ) ); $request->set_body_params( self::params( $envelope, 'body' ) ); $json = self::params( $envelope, 'json' ); if ( array() !== $json ) { $request->set_header( 'Content-Type', 'application/json' ); $request->set_body( (string) wp_json_encode( $json ) ); } $response = self::during_replay( $request, static fn() => rest_do_request( $request ) ); $error = $response->as_error(); if ( $error instanceof WP_Error ) { return $error; } return array( 'replayed' => true, 'status' => $response->get_status(), ); } /** * One parameter source from the envelope. * * @param array $envelope Replay envelope. * @param string $key query|body|json. * @return array */ private static function params( array $envelope, string $key ): array { return is_array( $envelope[ $key ] ?? null ) ? $envelope[ $key ] : array(); } /** * Re-invoke a registered ability. * * @param PendingAction $action Pending action. * @return array|WP_Error */ private function execute_ability( PendingAction $action ) { // why_not() confirmed the ability is registered - and asked with // wp_has_ability() first, because wp_get_ability() on a missing name // raises _doing_it_wrong (WordPress 6.9+) into the site's debug log. // Still guarded, so a change to why_not() could never turn a missing // ability into an approval that silently ran nothing. $ability = wp_get_ability( $action->ability_name ); $missing = new WP_Error( 'adaf_exec_failed', __( 'The ability for this action is no longer registered.', 'agentic-daisy-ai-agent-firewall' ) ); $result = null === $ability ? $missing : $ability->execute( $action->stored_input ); if ( $result instanceof WP_Error ) { return $result; } return array( 'result' => $result ); } /** * Execute a REST-sourced action via core write functions. * * @param LedgerEntry $entry Ledger row (entity type/id). * @param PendingAction $action Pending action (stored input). * @return array|WP_Error */ private function execute_rest( LedgerEntry $entry, PendingAction $action ) { // why_not() admits only a post-backed delete or update with a target // id, or a post-backed create; the id checks below are for the // type checker. if ( 'delete' === $entry->action_cat && null !== $entry->entity_id ) { $force = ! empty( $action->stored_input['force'] ); $result = $force ? wp_delete_post( $entry->entity_id, true ) : wp_trash_post( $entry->entity_id ); if ( null === $result || false === $result ) { return new WP_Error( 'adaf_exec_failed', __( 'Deleting the target entity failed.', 'agentic-daisy-ai-agent-firewall' ) ); } return array( 'deleted' => true, 'forced' => $force, ); } $fields = $this->map_post_fields( $action->stored_input ); if ( 'update' === $entry->action_cat && null !== $entry->entity_id ) { if ( array() === $fields ) { return new WP_Error( 'adaf_exec_failed', __( 'The stored payload contains no updatable fields.', 'agentic-daisy-ai-agent-firewall' ) ); } $fields['ID'] = $entry->entity_id; $updated = wp_update_post( $fields, true ); if ( $updated instanceof WP_Error ) { return $updated; } return array( 'updated' => $updated ); } if ( 'page' === $entry->entity_type ) { $fields['post_type'] = 'page'; } $created = wp_insert_post( $fields, true ); if ( $created instanceof WP_Error ) { return $created; } return array( 'created' => $created ); } /** * Map whitelisted REST params to post fields. * * @param array $input Stored input. * @return array */ private function map_post_fields( array $input ): array { $fields = array(); foreach ( self::POST_FIELDS as $param => $field ) { if ( isset( $input[ $param ] ) && is_scalar( $input[ $param ] ) ) { $fields[ $field ] = (string) $input[ $param ]; } } return $fields; } }