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, $retry_after ); $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. * @param int|null $retry_after Seconds until a retry could succeed. * @return array{message: string, reason: string, retryable: bool, human_review: bool} */ private static function fields( ?string $reason, ?int $retry_after ): array { switch ( $reason ) { case Evaluation::REASON_BUDGET: // Without a time to come back, the action alone exceeds the // budget and no amount of waiting lets it through. $retryable = null !== $retry_after; $message = $retryable ? __( 'Denied: this action would take the agent over a budget for its window. Earlier actions free budget as they age out of the window, so wait the retry_after seconds. Do not retry in a loop.', 'agentic-daisy-ai-agent-firewall' ) : __( 'Denied: this action on its own is larger than the agent\'s whole budget for the window, so waiting will not help. A site administrator can run it manually or raise the budget.', 'agentic-daisy-ai-agent-firewall' ); $review = false; break; 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_LEDGER_BUSY: $message = __( 'Not run: the firewall could not record this action just now, and it never runs an action it has not recorded. Wait the retry_after seconds, then send it again once.', '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_FIREWALL_PROTECTED: $message = __( 'Denied: this agent runs as an administrator, and such agents may never deactivate, change or delete Agent Firewall or write its settings. Retrying will not help, and no rule allows it. A site administrator can make the change themselves.', '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, ); } }