namespace = 'agenticdaisy-firewall/v1'; $this->rest_base = 'settings'; } /** * Register routes. */ public function register_routes(): void { register_rest_route( 'agenticdaisy-firewall/v1', '/' . $this->rest_base, array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_settings' ), 'permission_callback' => array( $this, 'permissions_check' ), ), array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'update_settings' ), 'permission_callback' => array( $this, 'permissions_check' ), 'args' => array( 'anomaly_email' => array( 'description' => 'Email the site administrator when an agent trips a rate limit or an ability changes definition.', 'type' => 'boolean', 'required' => false, // Strictly boolean: coercing a typo would silently // disable security alerting, which is the one // outcome nobody would notice. 'validate_callback' => 'rest_validate_request_arg', ), 'lockdown' => array( 'description' => 'Kill switch: while engaged, every agent action (reads included) is denied before policy runs.', 'type' => 'boolean', 'required' => false, // Strictly boolean for the same reason, in both // directions: a typo must neither lift a lockdown // nor impose one. 'validate_callback' => 'rest_validate_request_arg', ), 'block_app_password_writes' => array( 'description' => 'Refuse REST writes authenticated with an application password that no agent claims.', 'type' => 'boolean', 'required' => false, // Strictly boolean: coercing a typo would open the // site to ungoverned automation, which is the // failure nobody would see happen. 'validate_callback' => 'rest_validate_request_arg', ), 'pending_email' => array( 'description' => 'Email the site administrator when an action is held for approval.', 'type' => 'boolean', 'required' => false, // Strictly boolean: a coerced typo would either // subscribe a site to mail it never asked for or // silence the one it did. 'validate_callback' => 'rest_validate_request_arg', ), 'webhook_url' => array( 'description' => 'Endpoint the anomaly alerts are posted to. Empty turns delivery off.', 'type' => 'string', 'required' => false, 'validate_callback' => array( $this, 'validate_webhook_url' ), 'sanitize_callback' => 'esc_url_raw', ), 'report_agency' => array( 'description' => 'Name shown on the printable client report. Empty falls back to the site name.', 'type' => 'string', 'required' => false, 'maxLength' => 120, 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'sanitize_text_field', ), ), ), ) ); // Its own route rather than a magic parameter on the settings write: // sending a test message is an action, not a stored setting, and it // must not be reachable by a request that only meant to save a URL. register_rest_route( 'agenticdaisy-firewall/v1', '/' . $this->rest_base . '/webhook-test', array( array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'test_webhook' ), 'permission_callback' => array( $this, 'permissions_check' ), ), ) ); } /** * Refuse a webhook URL the site could not, or should not, post to. * * Deliberately syntactic, plus the internal addresses that can be * recognized without asking a resolver. The authoritative guard is * wp_safe_remote_post() at delivery time, which is the one place that * knows what a host resolves to; duplicating it here would mean two * checks that can disagree, and would put a blocking DNS lookup in the * path of saving a setting. * * @param mixed $value Submitted URL. * @param WP_REST_Request $request Request. * @param string $param Parameter name. * @return true|\WP_Error */ public function validate_webhook_url( $value, $request, $param ) { unset( $request, $param ); $url = trim( (string) $value ); // Empty is how delivery is turned off. if ( '' === $url ) { return true; } $parts = wp_parse_url( $url ); $scheme = is_array( $parts ) && isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : ''; $host = is_array( $parts ) && isset( $parts['host'] ) ? strtolower( trim( (string) $parts['host'], '.' ) ) : ''; if ( ! in_array( $scheme, array( 'http', 'https' ), true ) || '' === $host ) { return new \WP_Error( 'adaf_webhook_url_scheme', __( 'The webhook URL must be a complete address starting with http:// or https://.', 'agentic-daisy-ai-agent-firewall' ), array( 'status' => 400 ) ); } // Credentials in the URL would be stored in the options table in plain // text and logged by whatever sits in front of the endpoint. Webhook // providers put the secret in the path; this one signs the body. if ( is_array( $parts ) && ( isset( $parts['user'] ) || isset( $parts['pass'] ) ) ) { return new \WP_Error( 'adaf_webhook_url_credentials', __( 'Remove the username and password from the webhook URL. Deliveries are signed instead, using the secret shown once the endpoint is saved.', 'agentic-daisy-ai-agent-firewall' ), array( 'status' => 400 ) ); } // A stored URL is a standing instruction to make this server issue a // request, so an address that is plainly on its own network is refused // here rather than left to fail at send time with less to say. $is_ip = false !== filter_var( $host, FILTER_VALIDATE_IP ); if ( 'localhost' === $host || ( $is_ip && false === filter_var( $host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) ) { return new \WP_Error( 'adaf_webhook_url_blocked', __( 'That address is on this server or its private network. A webhook endpoint has to be reachable on the public internet.', 'agentic-daisy-ai-agent-firewall' ), array( 'status' => 400 ) ); } return true; } /** * Post a test message to the configured endpoint and report the result. * * @param WP_REST_Request $request Request. */ public function test_webhook( $request ): WP_REST_Response { unset( $request ); return new WP_REST_Response( $this->webhook->deliver_test(), 200 ); } /** * Admin capability gate; never __return_true (checklist ยง1.14). * * @param WP_REST_Request $request Request. * @return true|\WP_Error */ public function permissions_check( $request ) { if ( current_user_can( 'manage_options' ) ) { return true; } return new \WP_Error( 'rest_forbidden', __( 'You are not allowed to change these settings.', 'agentic-daisy-ai-agent-firewall' ), array( 'status' => rest_authorization_required_code() ) ); } /** * Read the settings. * * @param WP_REST_Request $request Request. */ public function get_settings( $request ): WP_REST_Response { return new WP_REST_Response( $this->current(), 200 ); } /** * Write the settings. * * @param WP_REST_Request $request Request. */ public function update_settings( $request ): WP_REST_Response { // Partial updates: only the provided switches change, so flipping // the kill switch mid-incident cannot silently touch alerting. if ( $request->has_param( 'anomaly_email' ) ) { // Stored as '1'/'0' because that is what AnomalyNotifier compares // against; the API speaks booleans in both directions. update_option( AnomalyNotifier::OPTION_EMAIL, true === $request->get_param( 'anomaly_email' ) ? '1' : '0' ); } if ( $request->has_param( 'block_app_password_writes' ) ) { // Stored as '1'/'0' because that is what RestGatekeeper compares // against; the API speaks booleans in both directions. update_option( RestGatekeeper::OPTION_BLOCK_APP_PASSWORD_WRITES, true === $request->get_param( 'block_app_password_writes' ) ? '1' : '0' ); } if ( $request->has_param( 'report_agency' ) ) { update_option( ReportView::OPTION_AGENCY, (string) $request->get_param( 'report_agency' ) ); } if ( $request->has_param( 'pending_email' ) ) { update_option( AnomalyNotifier::OPTION_PENDING_EMAIL, true === $request->get_param( 'pending_email' ) ? '1' : '0' ); } if ( $request->has_param( 'webhook_url' ) ) { $url = trim( (string) $request->get_param( 'webhook_url' ) ); update_option( WebhookDispatcher::OPTION_URL, $url ); // Minted once, on the first endpoint saved, and kept afterwards: // rotating it when the URL changes would silently break every // receiver already verifying signatures. if ( '' !== $url && '' === (string) get_option( WebhookDispatcher::OPTION_SECRET, '' ) ) { update_option( WebhookDispatcher::OPTION_SECRET, wp_generate_password( 40, false ) ); } } if ( $request->has_param( 'lockdown' ) ) { if ( true === $request->get_param( 'lockdown' ) ) { $this->lockdown->engage(); } else { $this->lockdown->release(); } } return new WP_REST_Response( $this->current(), 200 ); } /** * Current settings. * * @return array */ private function current(): array { return array( 'anomaly_email' => '1' === (string) get_option( AnomalyNotifier::OPTION_EMAIL, '1' ), 'lockdown' => $this->lockdown->engaged(), 'block_app_password_writes' => '1' === (string) get_option( RestGatekeeper::OPTION_BLOCK_APP_PASSWORD_WRITES, '0' ), // Default '0': off unless someone ticked it, which is what keeps an // upgrade from mailing a site that never asked. 'pending_email' => '1' === (string) get_option( AnomalyNotifier::OPTION_PENDING_EMAIL, '0' ), 'webhook_url' => (string) get_option( WebhookDispatcher::OPTION_URL, '' ), // Returned so the receiving end can be configured to verify // signatures. Behind manage_options, like every other route here. 'webhook_secret' => (string) get_option( WebhookDispatcher::OPTION_SECRET, '' ), // Empty rather than the site name: this is the stored setting, and // a field pre-filled with a fallback reads as a value somebody // chose. ReportView applies the fallback at render time. 'report_agency' => (string) get_option( ReportView::OPTION_AGENCY, '' ), ); } /** * Item schema. * * @return array */ public function get_item_schema(): array { return array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'adaf_settings', 'type' => 'object', 'properties' => array( 'anomaly_email' => array( 'type' => 'boolean' ), 'lockdown' => array( 'type' => 'boolean' ), 'block_app_password_writes' => array( 'description' => 'Whether REST writes authenticated with an unbound application password are refused rather than recorded as untracked.', 'type' => 'boolean', ), 'pending_email' => array( 'description' => 'Whether the site administrator is emailed when an action is held for approval.', 'type' => 'boolean', ), 'webhook_url' => array( 'description' => 'Endpoint the anomaly alerts are posted to, or an empty string when delivery is off.', 'type' => 'string', ), 'webhook_secret' => array( 'description' => 'Shared secret the webhook body is signed with, as X-ADAF-Signature.', 'type' => 'string', ), 'report_agency' => array( 'description' => 'Name shown on the printable client report; empty means the site name is used.', 'type' => 'string', ), ), ); } }