403, 'reason' => $fields['reason'], 'retryable' => $fields['retryable'], 'human_review' => $fields['human_review'], ); if ( $fields['retryable'] && null !== $retry_after ) { $data['retry_after'] = $retry_after; } return new WP_Error( 'adaf_denied', $fields['message'], $data ); } /** * Build the denial as a REST response rather than a WP_Error. * * Returning a WP_Error from rest_pre_dispatch is only safe on the normal * dispatch path, which converts it with error_to_response(). Batched * sub-requests skip that conversion and hand the value straight to * rest_post_dispatch, where core's rest_send_allow_header() calls * get_matched_route() on it and fatals. Emitting the same envelope as a * response is safe on both paths and identical on the wire. * * @param string|null $reason Denial reason (Evaluation constants). * @param string $code Error code. * @param string|null $message Override message. * @param int|null $retry_after Seconds until a retry could succeed. */ public static function rest_response( ?string $reason, string $code = 'adaf_denied', ?string $message = null, ?int $retry_after = null ): WP_REST_Response { $fields = self::fields( $reason ); $data = array( 'status' => 403, 'reason' => $fields['reason'], 'retryable' => $fields['retryable'], 'human_review' => $fields['human_review'], ); if ( $fields['retryable'] && null !== $retry_after ) { $data['retry_after'] = $retry_after; } $response = new WP_REST_Response( array( 'code' => $code, 'message' => $message ?? $fields['message'], 'data' => $data, ), 403 ); // The standard header too, so HTTP-aware clients back off correctly // without knowing anything about this plugin's payload shape. if ( isset( $data['retry_after'] ) ) { $response->header( 'Retry-After', (string) $data['retry_after'] ); } return $response; } /** * Message and flags for a denial reason. * * @param string|null $reason Denial reason. * @return array{message: string, reason: string, retryable: bool, human_review: bool} */ private static function fields( ?string $reason ): array { switch ( $reason ) { case Evaluation::REASON_RATE_LIMITED: $message = __( 'Denied: this agent exceeded its write rate limit. The limit is a sliding window, so waiting will clear it. Do not retry in a loop.', 'agentic-daisy-ai-agent-firewall' ); $retryable = true; $review = false; break; case Evaluation::REASON_LOCKDOWN: $message = __( 'Denied: a site administrator has paused all agent activity on this site (kill switch engaged). Every agent action is refused, so retrying will not help. Activity resumes only when an administrator releases the pause.', 'agentic-daisy-ai-agent-firewall' ); $retryable = false; $review = false; break; case Evaluation::REASON_CONTROL_PLANE: $message = __( 'Denied: this is the Agent Firewall control plane, which only humans may use. Agents cannot read the audit ledger or change the agents, policies and approvals that govern them. Retrying will not help, and no token or scope grants access. The one exception is polling the status of your own held action.', 'agentic-daisy-ai-agent-firewall' ); $retryable = false; $review = false; break; case Evaluation::REASON_OUT_OF_SCOPE: $message = __( 'Denied: this action is outside the scopes granted to this agent token. Retrying will not help. A site administrator must widen the token scopes for this action to be possible.', 'agentic-daisy-ai-agent-firewall' ); $retryable = false; $review = false; break; default: $message = __( 'Denied by Agent Firewall policy. Retrying will not help. If this action is legitimate, a site administrator can adjust the policy or run it manually.', 'agentic-daisy-ai-agent-firewall' ); $retryable = false; $review = true; break; } return array( 'message' => $message, 'reason' => $reason ?? Evaluation::REASON_POLICY, 'retryable' => $retryable, 'human_review' => $review, ); } }