0) and * capped at AAFM_SETTINGS_NUMERIC_MAX. Note max( 0, (int) ) rather than absint(), so a * negative value clamps down to 0 instead of flipping to its positive magnitude. * - aafm_force_draft, aafm_high_risk_abilities_unlocked, aafm_read_only_mode: a plain bool from * presence of the field (unchecked checkbox -> false). Every reader defaults off on a missing * option, so unlike the OAuth pair below there is nothing to work around: a stored false reads as * off either way. * - aafm_oauth_enabled: the STRING '1' when the checkbox is present, '0' when absent. The OAuth * reader treats every falsy stored form as off, so the off state must be the literal '0' string - * a PHP bool false would not store as false on a never-created option, leaving the toggle stuck * on. Dynamic client registration is not part of this form: it is saved by the Connection tab's * lifetimes card, so an absent field here can never turn it off. * - aafm_ip_allowlist: split on newlines, trimmed, blanks dropped, and every surviving line * must clear aafm_is_valid_ip_or_cidr(). Invalid lines are dropped (fail-closed), so a * stored non-empty list is always made up entirely of usable entries. * * @param array $posted Raw $_POST payload (slashes handled by the caller). * @return array{aafm_rate_limit_per_min:int,aafm_max_title_len:int,aafm_log_retention_days:int,aafm_force_draft:bool,aafm_block_guard_strict:bool,aafm_delete_data_on_uninstall:bool,aafm_high_risk_abilities_unlocked:bool,aafm_read_only_mode:bool,aafm_oauth_enabled:string,aafm_site_brief:string,aafm_default_server_mode:string,aafm_notifications:array{mode:string,email:string,triggers:array},aafm_ip_allowlist:list,aafm_oauth_connect_roles:?array,aafm_trusted_proxy:?array{mode:string,header:string,ranges:list}} */ function aafm_sanitize_settings_input( array $posted ): array { $rate = min( AAFM_SETTINGS_NUMERIC_MAX, max( 0, (int) ( $posted['aafm_rate_limit_per_min'] ?? 0 ) ) ); $title = min( AAFM_SETTINGS_NUMERIC_MAX, max( 0, (int) ( $posted['aafm_max_title_len'] ?? 0 ) ) ); // Retention has its own tighter ceiling (ten years); 0 keeps every entry forever. $retention = min( 3650, max( 0, (int) ( $posted['aafm_log_retention_days'] ?? 30 ) ) ); $draft = ! empty( $posted['aafm_force_draft'] ); $block_guard = ! empty( $posted['aafm_block_guard_strict'] ); $delete_on = ! empty( $posted['aafm_delete_data_on_uninstall'] ); $high_risk = ! empty( $posted['aafm_high_risk_abilities_unlocked'] ); $read_only = ! empty( $posted['aafm_read_only_mode'] ); $oauth = empty( $posted['aafm_oauth_enabled'] ) ? '0' : '1'; $raw = isset( $posted['aafm_ip_allowlist'] ) ? (string) $posted['aafm_ip_allowlist'] : ''; $lines = array_values( array_filter( aafm_split_and_trim_lines( $raw ), 'aafm_is_valid_ip_or_cidr' ) ); $brief_raw = isset( $posted['aafm_site_brief'] ) && is_scalar( $posted['aafm_site_brief'] ) ? (string) $posted['aafm_site_brief'] : ''; $mode_raw = isset( $posted['aafm_default_server_mode'] ) && is_string( $posted['aafm_default_server_mode'] ) ? $posted['aafm_default_server_mode'] : 'auto'; $mode = in_array( $mode_raw, aafm_default_server_modes(), true ) ? $mode_raw : 'auto'; return array( 'aafm_rate_limit_per_min' => $rate, 'aafm_max_title_len' => $title, 'aafm_log_retention_days' => $retention, 'aafm_force_draft' => $draft, 'aafm_block_guard_strict' => $block_guard, 'aafm_delete_data_on_uninstall' => $delete_on, 'aafm_high_risk_abilities_unlocked' => $high_risk, 'aafm_read_only_mode' => $read_only, 'aafm_oauth_enabled' => $oauth, 'aafm_site_brief' => aafm_clean_site_brief( $brief_raw ), 'aafm_default_server_mode' => $mode, 'aafm_notifications' => aafm_notifications_sanitize( $posted ), 'aafm_ip_allowlist' => array_values( array_unique( $lines ) ), 'aafm_oauth_connect_roles' => aafm_sanitize_connect_roles_input( $posted ), 'aafm_trusted_proxy' => aafm_sanitize_proxy_input( $posted ), ); } /** * Count how many submitted allowlist lines are invalid and would be dropped on save. * * Mirrors the sanitizer's line handling - split on newlines, trim, drop blanks - then counts * the non-blank lines that fail aafm_is_valid_ip_or_cidr(). Counting invalid lines explicitly * (rather than diffing submitted vs. kept counts) keeps a duplicate-but-valid line from being * miscounted as a drop. The result drives the save-time warning so an admin who pastes only * garbage - collapsing the list to empty, which means allow-all - is told instead of seeing a * bare "Saved". * * @param string $raw Raw newline-separated allowlist text as posted. * @return int Number of non-blank lines that are not a valid IP or CIDR range. */ function aafm_count_dropped_ip_lines( string $raw ): int { $invalid = array_filter( aafm_split_and_trim_lines( $raw ), static function ( string $line ): bool { return ! aafm_is_valid_ip_or_cidr( $line ); } ); return count( $invalid ); } /** * Split a raw newline-separated textarea value into cleaned, non-blank lines: split on any * line ending, run each through sanitize_text_field(), trim it (invisible and non-ASCII spaces * included, aafm_trim_unicode()), and drop anything left blank. * Shared by aafm_sanitize_settings_input() and aafm_count_dropped_ip_lines(), which each then * decide what to do with the result (keep the valid ones vs. count the invalid ones). * * @param string $raw Raw textarea value as posted. * @return list Cleaned, non-blank lines in their original order. */ function aafm_split_and_trim_lines( string $raw ): array { $lines = array(); foreach ( (array) preg_split( '/\r\n|\r|\n/', $raw ) as $line ) { $line = aafm_trim_unicode( sanitize_text_field( (string) $line ) ); if ( '' !== $line ) { $lines[] = $line; } } return $lines; } /** * The roles the Settings form ticks for an install that never saved the list: today's edit_posts * floor, plus Administrators. The render and the sanitizer share it so the two cannot drift. * * @return array */ function aafm_oauth_legacy_default_roles(): array { $slugs = array( 'administrator' ); foreach ( wp_roles()->role_objects as $slug => $role ) { if ( $role->has_cap( 'edit_posts' ) ) { $slugs[] = (string) $slug; } } return array_values( array_unique( $slugs ) ); } /** * Validate the shape of the posted roles list before anything is written. * * @param array $posted Raw posted fields. * @return string|null A message, or null when the shape is valid or the list is not part of this save. */ function aafm_connect_roles_input_error( array $posted ): ?string { if ( empty( $posted['aafm_oauth_connect_roles_present'] ) || ! isset( $posted['aafm_oauth_connect_roles'] ) ) { return null; // Not part of this save, or no box ticked (Administrators only): nothing to validate. } $sent = $posted['aafm_oauth_connect_roles']; $ok = is_array( $sent ) && count( $sent ) <= count( wp_roles()->role_names ); if ( $ok ) { foreach ( $sent as $slug ) { if ( ! is_string( $slug ) || strlen( $slug ) > 64 ) { $ok = false; break; } } } return $ok ? null : __( 'The roles list could not be read. Reload this page and try again.', 'agent-abilities-for-mcp' ); } /** * The posted roles that can connect, or null when nothing should be written. * * Null when the marker is absent (a stale script), or when the option was never saved and the * submitted list equals the legacy default the form shows: the form always posts the marker, so an * unrelated save must not create the option (and switch on the use-time role check) on an existing * install. * * @param array $posted Raw posted fields. * @return array|null */ function aafm_sanitize_connect_roles_input( array $posted ): ?array { if ( empty( $posted['aafm_oauth_connect_roles_present'] ) ) { return null; } // aafm_connect_roles_input_error() validated the shape first; this filtering is a second line. $known = array_keys( wp_roles()->role_names ); $sent = isset( $posted['aafm_oauth_connect_roles'] ) && is_array( $posted['aafm_oauth_connect_roles'] ) ? $posted['aafm_oauth_connect_roles'] : array(); $roles = array( 'administrator' ); foreach ( $sent as $slug ) { if ( is_string( $slug ) && in_array( $slug, $known, true ) ) { $roles[] = $slug; } } $roles = array_values( array_unique( $roles ) ); if ( null === aafm_oauth_connect_roles() ) { $default = aafm_oauth_legacy_default_roles(); sort( $default ); $check = $roles; sort( $check ); if ( $check === $default ) { return null; } } return $roles; } /** * The posted trusted-proxy setting, or null when the form did not carry it. * * @param array $posted Raw posted fields. * @return array{mode:string,header:string,ranges:list}|null */ function aafm_sanitize_proxy_input( array $posted ): ?array { if ( ! isset( $posted['aafm_proxy_mode'] ) || ! is_string( $posted['aafm_proxy_mode'] ) ) { return null; } $mode = in_array( $posted['aafm_proxy_mode'], array( 'cloudflare', 'custom' ), true ) ? $posted['aafm_proxy_mode'] : 'off'; $out = array( 'mode' => $mode, 'header' => '', 'ranges' => array(), ); if ( 'custom' === $mode ) { $header = isset( $posted['aafm_proxy_header'] ) && is_string( $posted['aafm_proxy_header'] ) ? $posted['aafm_proxy_header'] : ''; $out['header'] = in_array( $header, aafm_proxy_headers(), true ) ? $header : ''; $raw = isset( $posted['aafm_proxy_ranges'] ) && is_string( $posted['aafm_proxy_ranges'] ) ? $posted['aafm_proxy_ranges'] : ''; $out['ranges'] = array_values( array_unique( array_filter( aafm_split_and_trim_lines( $raw ), 'aafm_proxy_range_ok' ) ) ); } return $out; } /** * Why the posted trusted-proxy setting cannot be saved, or null when it can. * * @param array $posted Raw posted fields. * @return string|null */ function aafm_proxy_input_error( array $posted ): ?string { if ( isset( $posted['aafm_proxy_mode'] ) && ! is_string( $posted['aafm_proxy_mode'] ) ) { return __( 'The proxy setting could not be read. Reload this page and try again.', 'agent-abilities-for-mcp' ); } $clean = aafm_sanitize_proxy_input( $posted ); if ( null === $clean || 'custom' !== $clean['mode'] ) { return null; } if ( '' === $clean['header'] ) { return __( 'Choose which header your proxy sends the visitor address in.', 'agent-abilities-for-mcp' ); } if ( array() === $clean['ranges'] ) { return __( 'Add at least one address or range your proxy connects from. A range wider than /8 (IPv4) or /16 (IPv6) is not accepted.', 'agent-abilities-for-mcp' ); } return null; } /** * The connection address of this request (REMOTE_ADDR), validated, or ''. * * @return string */ function aafm_settings_remote_addr(): string { $remote = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; return false === filter_var( $remote, FILTER_VALIDATE_IP ) ? '' : $remote; } /** * The sentence that says how a proxy setting reads this request against the allowlist. * * @param array{mode:string,header:string,ranges:list} $proxy Proxy config to test. * @param array $allowlist IP allowlist. * @return string '' when the allowlist is empty. */ function aafm_proxy_allowlist_sentence( array $proxy, array $allowlist ): string { if ( array() === $allowlist ) { return ''; } $seen = aafm_trusted_proxy_visitor( aafm_settings_remote_addr(), aafm_trusted_proxy_normalize( $proxy ) ); return aafm_ip_in_ranges( $seen, $allowlist ) /* translators: %s: an IP address. */ ? sprintf( __( 'With this setting the plugin reads this request as %s, which your IP allowlist admits.', 'agent-abilities-for-mcp' ), $seen ) /* translators: %s: an IP address. */ : sprintf( __( 'With this setting the plugin reads this request as %s, which your IP allowlist does not admit. This setting may lock your agents out.', 'agent-abilities-for-mcp' ), $seen ); } /** * AJAX: save the safety settings. * * Nonce + manage_options gated. The sanitizer bounds every value, so the stored options are * always safe. The cleaned values (with the allowlist as both an array and a newline string * for the textarea) are echoed back, along with a count of dropped invalid IP lines, so the UI * can warn when lines were silently removed - including the dangerous case where every line is * invalid and the list collapses to empty (allow-all). * * @return void */ function aafm_ajax_save_settings(): void { check_ajax_referer( 'aafm_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'agent-abilities-for-mcp' ) ), 403 ); } $posted = wp_unslash( $_POST ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above. $clean = aafm_sanitize_settings_input( $posted ); // Notifications are posted-only, like the Site Brief: a save that does not carry the mode (a stale // cached script) leaves them alone. A bad address or an empty trigger list is refused HERE, before // any write, so one bad field never half-saves the form. $ntf_posted = isset( $posted['aafm_notify_mode'] ) && is_string( $posted['aafm_notify_mode'] ); $ntf_before = aafm_notifications_settings(); if ( $ntf_posted ) { $ntf_raw = isset( $posted['aafm_notify_email'] ) && is_string( $posted['aafm_notify_email'] ) ? trim( $posted['aafm_notify_email'] ) : ''; $ntf_error = aafm_notifications_validate( $clean['aafm_notifications'], $ntf_raw ); if ( null !== $ntf_error ) { wp_send_json_error( array( 'message' => $ntf_error ) ); } } // Roles and proxy are refused here too, before any write. $access_error = aafm_connect_roles_input_error( $posted ) ?? aafm_proxy_input_error( $posted ); if ( null !== $access_error ) { wp_send_json_error( array( 'message' => $access_error ) ); } $roles_before = aafm_oauth_connect_roles(); $proxy_before = aafm_trusted_proxy_config(); // Free-text field: a save that does not carry it (a stale cached admin.js, a script) leaves the // stored brief alone instead of clearing it, and so does a value that is not text (an array). // Checkboxes in this form read "absent" as off; this is not a checkbox. The default-server mode // is a select with the same rule: no field, or a field that is not text, leaves it alone. $brief_posted = isset( $posted['aafm_site_brief'] ) && is_scalar( $posted['aafm_site_brief'] ); $brief_before = aafm_site_brief(); $mode_posted = isset( $posted['aafm_default_server_mode'] ) && is_string( $posted['aafm_default_server_mode'] ); $mode_before = aafm_default_server_mode(); $raw_allowlist = isset( $posted['aafm_ip_allowlist'] ) ? (string) $posted['aafm_ip_allowlist'] : ''; $dropped = aafm_count_dropped_ip_lines( $raw_allowlist ); // Read the stored master switch before anything is written, so the audit row below compares // against what was actually in place. The raw option, not aafm_high_risk_unlocked(), because a // site that hard-blocks the category through the filter still has an operator setting to record. $high_risk_before = (bool) get_option( 'aafm_high_risk_abilities_unlocked', false ); // Same again for read-only mode. The raw option, not aafm_read_only_mode(), so a site that // forces the mode on through the filter still records what the operator actually changed. $read_only_before = (bool) get_option( 'aafm_read_only_mode', false ); // Delete rather than store false: locked is the option's out-of-the-box (row-absent) state, and // both readers (this file's checked() call and aafm_high_risk_unlocked()) already default to // off on a missing row, so a stored false and an absent row behave identically today. But an // explicit false is a state a fresh install can never be in, and it is the one state the UI // itself cannot restore to once the switch has been touched once. Deleting on "off" keeps a // site that never unlocks the category, or that unlocks then re-locks it, in the same absent // row either way. // // The two switches are applied restrictive direction first, and BEFORE anything else in this // save touches the database: locking high-risk abilities and turning read-only mode on both // narrow what an agent can reach, so either one that was requested persists before the ordinary // settings loop below gets a chance to fail and cut the save short. A verified-settings failure // used to abort the whole handler above this point, which could leave a requested lock or // read-only-on never even attempted while everything else the operator posted had already // changed - narrower than intended is tolerable, wider than intended or wider than requested is // not. A switch whose requested direction is itself restrictive, or that was not touched by this // save at all, has nothing to defer and is simply left at $high_risk_persisted / a bool true. // A permissive transition (unlock high-risk, turn read-only off) is deferred to the permissive // block further down and is represented here only by a placeholder `true` - no write has been // attempted for it yet. Track that separately from $high_risk_persisted / $read_only_persisted so // an early exit below can tell "attempted, and this is its real result" apart from "not attempted // yet, this is only a placeholder". Logging a deferred transition here would record a write that // never ran as a success. $high_risk_attempted = ! $clean['aafm_high_risk_abilities_unlocked']; $read_only_attempted = $clean['aafm_read_only_mode']; $high_risk_persisted = $clean['aafm_high_risk_abilities_unlocked'] ? true : aafm_set_high_risk_unlocked( false ); $read_only_persisted = $clean['aafm_read_only_mode'] ? aafm_set_read_only_mode( true ) : true; if ( ! $high_risk_persisted || ! $read_only_persisted ) { if ( $high_risk_attempted ) { aafm_log_high_risk_switch_change( $high_risk_before, $clean['aafm_high_risk_abilities_unlocked'], $high_risk_persisted ); } if ( $read_only_attempted ) { aafm_log_read_only_switch_change( $read_only_before, $clean['aafm_read_only_mode'], $read_only_persisted ); } if ( ! $high_risk_persisted ) { wp_send_json_error( array( 'message' => aafm_switch_not_persisted_message( __( 'The high-risk abilities switch', 'agent-abilities-for-mcp' ) ) ) ); } wp_send_json_error( array( 'message' => aafm_switch_not_persisted_message( __( 'Read-only mode', 'agent-abilities-for-mcp' ) ) ) ); } // OAuth gets the same restrictive-first treatment as the two switches above: turning it OFF // closes an attack surface, so that direction is written now, before the ordinary settings loop - // including the IP allowlist - below. Turning it ON is the permissive direction and is deferred until every // restrictive write, including a narrowed IP allowlist, has certified: an // operator asking for "OAuth on, restricted to these IPs" must never end up with OAuth on and // the old, wider allowlist because the allowlist write failed after OAuth had already been // flipped on. $oauth_persisted = ( '1' === $clean['aafm_oauth_enabled'] ) ? true : aafm_update_option_verified( 'aafm_oauth_enabled', '0' ); if ( ! $oauth_persisted ) { // The high-risk / read-only switches above already certified by this point (their own // failure branch already returned above if either had failed), so an attempted one has no // audit row yet - it is only written on failure there, or unconditionally much further down // once every write in this handler has succeeded. Without this, an OAuth OFF failure // here would return before either log call ever ran, leaving an applied restrictive change // with no activity-log row at all. if ( $high_risk_attempted ) { aafm_log_high_risk_switch_change( $high_risk_before, $clean['aafm_high_risk_abilities_unlocked'], $high_risk_persisted ); } if ( $read_only_attempted ) { aafm_log_read_only_switch_change( $read_only_before, $clean['aafm_read_only_mode'], $read_only_persisted ); } wp_send_json_error( array( 'message' => aafm_switch_not_persisted_message( __( 'Enable OAuth', 'agent-abilities-for-mcp' ) ) ) ); } // Every value below only takes effect once it reads back from the database as what was just // written (aafm_update_option_verified()), not merely once update_option() has been called. A // persistent object cache that still disagrees with the database can make a plain // update_option() silently no-op, or write against a row that no longer exists and return // false without touching anything (see that function's docblock) - a save must not tell the // operator a setting changed when the database still holds the old value. This runs only once // the requested restrictive posture above is safely in place. $verified_settings = array( 'aafm_rate_limit_per_min' => array( $clean['aafm_rate_limit_per_min'], __( 'The rate limit', 'agent-abilities-for-mcp' ) ), 'aafm_max_title_len' => array( $clean['aafm_max_title_len'], __( 'The maximum title length', 'agent-abilities-for-mcp' ) ), 'aafm_log_retention_days' => array( $clean['aafm_log_retention_days'], __( 'The activity log retention setting', 'agent-abilities-for-mcp' ) ), 'aafm_force_draft' => array( $clean['aafm_force_draft'], __( 'Force draft on create', 'agent-abilities-for-mcp' ) ), 'aafm_block_guard_strict' => array( $clean['aafm_block_guard_strict'], __( 'Strict block validation', 'agent-abilities-for-mcp' ) ), 'aafm_delete_data_on_uninstall' => array( $clean['aafm_delete_data_on_uninstall'], __( 'Delete data on uninstall', 'agent-abilities-for-mcp' ) ), // aafm_oauth_enabled is NOT here: its OFF direction already persisted above and its ON // direction is deferred below, restrictive-first. The IP allowlist // stays in this ordinary loop, which runs before that deferred ON write, so a narrowed // allowlist is always in place before OAuth can turn on. 'aafm_ip_allowlist' => array( $clean['aafm_ip_allowlist'], __( 'The IP allowlist', 'agent-abilities-for-mcp' ) ), ); // After the allowlist entry, so a narrowed allowlist is in place before a proxy turns on. if ( null !== $clean['aafm_oauth_connect_roles'] ) { $verified_settings['aafm_oauth_connect_roles'] = array( $clean['aafm_oauth_connect_roles'], __( 'Roles that can connect', 'agent-abilities-for-mcp' ) ); } if ( null !== $clean['aafm_trusted_proxy'] ) { $verified_settings['aafm_trusted_proxy'] = array( $clean['aafm_trusted_proxy'], __( 'The visitor address setting', 'agent-abilities-for-mcp' ) ); } if ( $brief_posted ) { // Never autoloaded: the text is read when the MCP server is built, not on every request. $verified_settings['aafm_site_brief'] = array( $clean['aafm_site_brief'], __( 'The site brief', 'agent-abilities-for-mcp' ), false ); } if ( $mode_posted ) { $verified_settings['aafm_default_server_mode'] = array( $clean['aafm_default_server_mode'], __( 'The default server setting', 'agent-abilities-for-mcp' ) ); } foreach ( $verified_settings as $verified_option => $verified_pair ) { list( $verified_value, $verified_label ) = $verified_pair; if ( ! aafm_update_option_verified( $verified_option, $verified_value, $verified_pair[2] ?? null ) ) { // The requested restrictive posture above already persisted and is not being undone by // this failure, so it still needs its own audit row before the save reports the error - but // only for a switch that was actually attempted. A deferred permissive transition (see the // $high_risk_attempted / $read_only_attempted comment above) must not be logged here: it was // never written, so logging it now would claim a success that never happened. if ( $high_risk_attempted ) { aafm_log_high_risk_switch_change( $high_risk_before, $clean['aafm_high_risk_abilities_unlocked'], $high_risk_persisted ); } if ( $read_only_attempted ) { aafm_log_read_only_switch_change( $read_only_before, $clean['aafm_read_only_mode'], $read_only_persisted ); } wp_send_json_error( array( 'message' => aafm_switch_not_persisted_message( $verified_label ) ) ); } } if ( null !== $clean['aafm_oauth_connect_roles'] && $roles_before !== $clean['aafm_oauth_connect_roles'] ) { $names = wp_roles()->get_names(); aafm_log_setting_change( 'aafm/oauth-connect-roles', sprintf( /* translators: %s: comma-separated role names. */ __( 'Roles that can connect changed to: %s', 'agent-abilities-for-mcp' ), implode( ', ', array_map( static fn( string $slug ): string => isset( $names[ $slug ] ) ? translate_user_role( $names[ $slug ] ) : $slug, $clean['aafm_oauth_connect_roles'] ) ) ) ); } if ( null !== $clean['aafm_trusted_proxy'] && $proxy_before['mode'] !== $clean['aafm_trusted_proxy']['mode'] ) { aafm_log_setting_change( 'aafm/trusted-proxy', sprintf( /* translators: 1: off, cloudflare or custom, 2: number of trusted ranges. */ __( 'Visitor address behind a proxy set to %1$s (%2$d ranges)', 'agent-abilities-for-mcp' ), $clean['aafm_trusted_proxy']['mode'], count( $clean['aafm_trusted_proxy']['ranges'] ) ) ); } if ( $brief_posted ) { aafm_log_site_brief_change( $brief_before, $clean['aafm_site_brief'] ); } else { $clean['aafm_site_brief'] = $brief_before; // The response reports what is stored, not the empty default. } if ( $mode_posted ) { aafm_log_default_server_mode_change( $mode_before, $clean['aafm_default_server_mode'] ); } else { $clean['aafm_default_server_mode'] = $mode_before; } // Change history. Posted-only through a marker field, like the Site Brief, so a save that does not // carry the control leaves the switch alone. It follows the read-only switch's path: the off-switch // row is written through aafm_persist_operator_switch(), never the verified-settings map, because // that map would store a row for the on state. A turn-off or turn-on that did not take is logged // as an error and answered, never reported as done. $undo_posted = isset( $posted['aafm_undo_capture_shown'] ); if ( $undo_posted ) { $undo_was_off = aafm_stored_switch_on( 'aafm_undo_capture_off' ); $undo_to_off = empty( $posted['aafm_undo_capture'] ); if ( $undo_was_off !== $undo_to_off ) { $undo_persisted = aafm_persist_operator_switch( 'aafm_undo_capture_off', $undo_to_off ); $undo_user = wp_get_current_user(); if ( $undo_persisted ) { $undo_detail = $undo_to_off ? __( 'Change history turned off', 'agent-abilities-for-mcp' ) : __( 'Change history turned on', 'agent-abilities-for-mcp' ); } else { $undo_detail = $undo_to_off ? __( 'Change history could not be turned off', 'agent-abilities-for-mcp' ) : __( 'Change history could not be turned on', 'agent-abilities-for-mcp' ); } aafm_log_activity( array( 'ability' => 'aafm/change-history', 'principal_user_id' => (int) $undo_user->ID, 'principal_login' => $undo_user->user_login ? (string) $undo_user->user_login : '', 'status' => $undo_persisted ? 'success' : 'error', 'event_type' => 'setting_changed', 'detail' => $undo_detail, ) ); if ( ! $undo_persisted ) { wp_send_json_error( array( 'message' => aafm_switch_not_persisted_message( __( 'Change history', 'agent-abilities-for-mcp' ) ) ) ); } } } // Every requested restrictive change is safely in place and every ordinary setting verified, so // only now is either governance switch allowed to move in the permissive direction. if ( $clean['aafm_high_risk_abilities_unlocked'] ) { $high_risk_persisted = aafm_set_high_risk_unlocked( true ); } if ( ! $clean['aafm_read_only_mode'] ) { // Off deletes the row here too, for the reason spelled out above the high-risk branch. $read_only_persisted = aafm_set_read_only_mode( false ); } // Same deferred-permissive rule for OAuth (see the restrictive-first block above): the IP // allowlist and every other ordinary setting are already verified by this point, so turning it // on now can never leave a wider surface than requested. if ( '1' === $clean['aafm_oauth_enabled'] ) { $oauth_persisted = aafm_update_option_verified( 'aafm_oauth_enabled', '1' ); } aafm_log_high_risk_switch_change( $high_risk_before, $clean['aafm_high_risk_abilities_unlocked'], $high_risk_persisted ); aafm_log_read_only_switch_change( $read_only_before, $clean['aafm_read_only_mode'], $read_only_persisted ); // Every other setting above is already stored. These two are the governance switches, and a // save that reports success while one of them silently kept its old value is exactly the // failure seen live behind a host Redis drop-in. Say what happened instead. if ( ! $high_risk_persisted ) { wp_send_json_error( array( 'message' => aafm_switch_not_persisted_message( __( 'The high-risk abilities switch', 'agent-abilities-for-mcp' ) ) ) ); } if ( ! $read_only_persisted ) { wp_send_json_error( array( 'message' => aafm_switch_not_persisted_message( __( 'Read-only mode', 'agent-abilities-for-mcp' ) ) ) ); } // Reached here only once every other governance switch, and every ordinary setting including // the IP allowlist, has already certified above - so a failure at this point is a genuine // partial save where everything else took and only this one permissive write did not, leaving // the site correctly stricter (not wider) than requested. aafm_paired_write_partial_failure_message()'s // claim holds here, unlike the mixed-direction OFF-write pair earlier in this function. if ( ! $oauth_persisted ) { wp_send_json_error( array( 'message' => aafm_paired_write_partial_failure_message( __( 'Every other setting', 'agent-abilities-for-mcp' ), __( 'Enable OAuth', 'agent-abilities-for-mcp' ) ), ) ); } // Notification settings are written last. The flip alerts above (the two aafm_log_*_switch_change() // calls) read the settings that were stored when this request ARRIVED, so a save that flips read-only // mode and turns alerts off at once still tells the owner about the flip. A save that returned an // error earlier never reaches this point and leaves the notification settings as they were. if ( $ntf_posted ) { if ( ! aafm_update_option_verified( 'aafm_notifications', $clean['aafm_notifications'], true ) ) { wp_send_json_error( array( 'message' => aafm_switch_not_persisted_message( __( 'The notification settings', 'agent-abilities-for-mcp' ) ) ) ); } aafm_log_notifications_change( $ntf_before, $clean['aafm_notifications'] ); aafm_notify_sync_schedule(); aafm_notify_settle_queue( $ntf_before, $clean['aafm_notifications'] ); aafm_notify_settings_weakened( $ntf_before, $clean['aafm_notifications'] ); } else { $clean['aafm_notifications'] = $ntf_before; } wp_send_json_success( array_merge( $clean, array( 'aafm_ip_allowlist_text' => implode( "\n", $clean['aafm_ip_allowlist'] ), 'aafm_ip_dropped' => $dropped, // Plain text for the "Currently running" line beside the select; reflects the saved mode. 'aafm_default_server_state' => aafm_default_server_state_label(), 'warning' => null === $clean['aafm_trusted_proxy'] || 'off' === $clean['aafm_trusted_proxy']['mode'] ? '' : aafm_proxy_allowlist_sentence( $clean['aafm_trusted_proxy'], $clean['aafm_ip_allowlist'] ), ) ) ); } /** * Record a change to the high-risk abilities master switch. * * Unlocking this category is the single highest-consequence setting in the plugin: it is what * makes refunds, gateway settings, and account creation reachable at all. It gets the same * treatment as an ability toggle, for the same reason, so an incident timeline shows who opened * the category and when, not only which call fired afterwards. * * @param bool $before Previous value. * @param bool $after New value. * @param bool $persisted Whether the write actually took (aafm_persist_operator_switch()). * @return void */ function aafm_log_high_risk_switch_change( bool $before, bool $after, bool $persisted = true ): void { if ( $before === $after && $persisted ) { return; } $user = wp_get_current_user(); if ( $persisted ) { $detail = $after ? __( 'High-risk abilities unlocked', 'agent-abilities-for-mcp' ) : __( 'High-risk abilities locked', 'agent-abilities-for-mcp' ); } else { // The write did not take (a stale persistent object cache, see // aafm_persist_operator_switch()). A "locked" row over a category that is still open would // be the worst possible lie in this log, so the row says what actually happened. $detail = $after ? __( 'High-risk abilities could not be unlocked: object cache stale', 'agent-abilities-for-mcp' ) : __( 'High-risk abilities could not be locked: object cache stale', 'agent-abilities-for-mcp' ); } $row_id = aafm_log_activity( array( // Both the admin table (page.php's Event column) and the AJAX-paginated re-render put // the raw `ability` value straight into the Event cell - neither reads `event_type` at // all. A blank ability therefore rendered as a blank Event cell for every master-switch // row, while every other non-call event (log_cleared) already carries a synthetic // ability-like name for exactly this reason. Mirrors aafm_log_activity_cleared_marker()'s // 'aafm/activity-log-cleared'; the direction (locked/unlocked) stays in detail below. 'ability' => 'aafm/high-risk-abilities-unlocked', 'principal_user_id' => (int) $user->ID, 'principal_login' => $user->user_login ? (string) $user->user_login : '', 'status' => $persisted ? 'success' : 'error', 'event_type' => 'setting_changed', 'detail' => $detail, ) ); if ( $persisted && $before !== $after ) { aafm_notify_switch_flip( $after ? 'high_risk_unlocked' : 'high_risk_locked', $row_id ); } } /** * Every option key that a plugin reset clears. * * This is the single source of truth for "what a reset clears" - the enabled abilities, the * exposed post types and meta keys, and the safety controls. It deliberately excludes the * activity log (its own table) and anything outside the plugin's own option namespace, and it * never lists users or content. * * One configuration option is intentionally NOT listed here: `aafm_delete_data_on_uninstall`. * That flag governs whether uninstall wipes the site's data, so a "reset to defaults" must not * silently flip the operator's data-retention choice - it is preserved across a reset by design. * Because of that omission this is the reset set, not literally every stored option. Keep it in * sync when a new resettable configuration option is introduced. * * @return list Option names a reset clears, in a stable order. */ function aafm_config_option_names(): array { return array( 'aafm_enabled_abilities', 'aafm_enabled_bridged_abilities', 'aafm_bridge_review', 'aafm_allowed_post_types', 'aafm_allowed_meta_keys', 'aafm_rate_limit_per_min', 'aafm_max_title_len', 'aafm_log_retention_days', 'aafm_force_draft', 'aafm_block_guard_strict', 'aafm_oauth_enabled', 'aafm_oauth_dcr_enabled', 'aafm_oauth_access_ttl', 'aafm_oauth_refresh_ttl', 'aafm_ip_allowlist', 'aafm_denied_meta_keys', 'aafm_exposed_user_meta_keys', 'aafm_denied_user_meta_keys', 'aafm_exposed_term_meta_keys', 'aafm_denied_term_meta_keys', // Per-tool role limits set on the Abilities tab, read by includes/allowlist.php. 'aafm_ability_allowlist_overrides', // First-run onboarding state. Clearing these on reset returns the site to first-run, so the // Quick Connect wizard opens again; the menu-pointer flag is included so uninstall cleans it too. 'aafm_quickconnect_finished', 'aafm_quickconnect_dismissed', 'aafm_menu_pointer_active', // The review-request notice's whole state (status, first-success stamp, snooze bookkeeping). // Clearing it on reset re-arms the ask, but only after a fresh 7 days plus 10 new successful // calls, because reset also empties the activity log; uninstall-with-delete-data removes it. 'aafm_review_request', // The one-time agent-user marker backfill guard. Listed so a reset clears it (letting the // backfill re-run against a legacy install) and uninstall-with-delete-data removes the row // rather than orphaning it. The marker USER META is intentionally NOT touched here - uninstall // keeps the agent user, so its marker travels with the account. 'aafm_agent_user_marker_backfilled', // The high-risk unlock switch. Locked is the safe default, so a reset must land the // money-moving abilities back behind the floor rather than leave them reachable because // someone unlocked the category once; uninstall-with-delete-data removes the row outright. 'aafm_high_risk_abilities_unlocked', // Change history (undo): the capture off-switch (absent means on, so a reset turns capture back on) // and the one-time upgrade notice's dismissed flag. 'aafm_undo_capture_off', 'aafm_undo_notice_dismissed', // Read-only mode. Cleared for the same reason the switch above is, from the other side: a // reset returns the site to out-of-the-box, and out of the box the mode is off. Nothing is // exposed by that on its own, because a reset also clears every enabled ability. 'aafm_read_only_mode', // Derived state, not user config: the set of abilities the registration-time preflight left // out of the server (schema over the bounds, or over the tool cap). It is regenerated on the // next MCP request, so clearing it here is safe and correct - a reset wipes the enabled set // that produced any breach, and a delete-data uninstall must not leak the row. Listed here // (rather than deleted ad hoc) so it travels with the canonical cleanup like every sibling. // The literal is AAFM_OMITTED_ABILITIES_OPTION (includes/server.php); it is spelled out here // because uninstall.php loads this file but NOT server.php, so the constant is not defined in // the uninstall context. A drift guard in OmittedAbilitiesPreflightTest keeps the two in step. 'aafm_omitted_abilities', // The owner's standing rules for every agent; reset and delete-data uninstall clear it. 'aafm_site_brief', // The owner's playbooks; reset and delete-data uninstall clear them. 'aafm_playbooks', // The adapter default-server mode; a reset returns it to Automatic (the absent row). 'aafm_default_server_mode', // The once-only marker for the "started automatically" log row; a reset logs it afresh. 'aafm_default_server_auto_noted', // Owner notification settings; off is the absent row. 'aafm_notifications', // Digest events waiting to be sent. 'aafm_notify_queue', // Admin-notice entries for the Notifications card. 'aafm_notify_pending', // 1.10.0 access controls: pause-all, roles that can connect, trusted proxy, consent default preset. 'aafm_pause_all', 'aafm_oauth_connect_roles', 'aafm_trusted_proxy', 'aafm_oauth_default_preset', ); } /** * Whether the current blog's options TABLE - not the object cache - actually stores a truthy * delete-on-uninstall flag. * * `get_option()` trusts the `alloptions` cache blob without ever consulting the database * (wp-includes/option.php), so a persistent object cache that still disagrees with the database * would let a stale cached "true" authorize `aafm_uninstall_site()` and the OAuth table drops * below, even with the flag's own row absent or false. A decision this destructive has to be as * good as the row actually on disk, not whatever the cache still remembers, so this reads the * table directly rather than going through get_option(). This is the one read in the whole plugin * that is allowed to cost a query it would not otherwise need, because what it gates cannot be * undone. * * A bare $wpdb->get_var() returns the PREVIOUS query's row when the current * query itself fails - $wpdb->query() returns false, without ever touching last_result, on some * failure paths (see aafm_wpdb_scalar()'s docblock) - so a failed read here could inherit a * leftover non-null value from whatever query ran just before it and authorize deletion on data * that says nothing about this flag. Routed through aafm_wpdb_scalar() so a failed read is * detected and refused rather than certified as permission to delete. * * @return bool */ function aafm_uninstall_should_delete_data(): bool { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- deliberately bypassing the object cache; see the docblock above. $view = aafm_wpdb_scalar( $wpdb->prepare( "SELECT option_value FROM $wpdb->options WHERE option_name = %s LIMIT 1", 'aafm_delete_data_on_uninstall' ) ); if ( ! $view['ok'] || null === $view['value'] ) { return false; } return (bool) maybe_unserialize( $view['value'] ); } /** * Remove this plugin's data for the current blog. * * Clears this site's cron registrations (prune, OAuth cleanup, notification digest) FIRST, unconditionally - cron registrations are * executable plugin machinery, not retained user data, so they go regardless of the * data-retention choice below them. Without this, a default (retain-data) uninstall left both * daily events behind: their callbacks no longer exist once the plugin is gone, so WordPress * reschedules and examines two orphaned hooks on every site forever. The plugin's own * deactivation callbacks (agent-abilities-for-mcp.php) clear both correctly, but only in the * CURRENT blog context - they cannot reach every site on a multisite network the way * aafm_run_uninstall() (uninstall.php) does by switching through each one. * * Reads the per-site data-retention flag next - straight off the database * (aafm_uninstall_should_delete_data()), not through get_option(), because a stale persistent * object cache must never be able to authorize this. When the stored value is not truthy (the * default: no row, or a row holding false), data is kept and the function returns before touching * anything else. When it is explicitly turned on by the site admin, the full teardown runs: every * configuration option, the activity-log table, and the four OAuth tables are all removed. The * flag itself is deleted last, cache-safely, so it cannot leak after uninstall. * * Called once per site by aafm_run_uninstall() in uninstall.php. Declared here (settings.php) * so the PHPUnit suite can call it directly without bootstrapping the uninstall context. * * @return void */ function aafm_uninstall_site_data(): void { wp_clear_scheduled_hook( 'aafm_prune_activity_log_daily' ); wp_clear_scheduled_hook( 'aafm_oauth_cleanup' ); wp_clear_scheduled_hook( 'aafm_notifications_digest' ); // Before-values are content, so change history goes on every uninstall, whatever the flag says. aafm_drop_changes_table(); if ( ! aafm_uninstall_should_delete_data() ) { return; } // The write-outcome observer stays detached for the whole teardown: with the plugin loaded (how // the test suite calls this function), the six deletes below would otherwise insert into the // activity-log table this same teardown just dropped, and wpdb would print its own error. In // production this function only ever runs from uninstall.php, which never loads the helper file // that attaches the observer, so nothing would have emitted there either way. $observer_priority = has_action( 'aafm_write_completed', 'aafm_activity_log_write_outcome' ); if ( false !== $observer_priority ) { remove_action( 'aafm_write_completed', 'aafm_activity_log_write_outcome', $observer_priority ); } $failed = aafm_uninstall_site(); aafm_drop_oauth_tables(); // The schema versions; the one-time OAuth upgrade-preserve guard and the DCR default-on adoption // guard (both written by the plugins_loaded migrations), cleared so a delete-data uninstall leaves // nothing behind but deliberately NOT in the reset set, since clearing a guard on reset would let // its migration re-run and could flip a toggle back on; the second, independently keyed DCR // migration signal (see aafm_oauth_dcr_adopt_on_by_default()'s docblock), treated the same way; // and the uninstall flag itself, last. foreach ( array( 'aafm_oauth_schema_version', 'aafm_activity_log_schema_version', 'aafm_oauth_toggle_migrated', 'aafm_oauth_dcr_default_on_migrated', 'aafm_oauth_dcr_default_on_touched', 'aafm_delete_data_on_uninstall' ) as $option ) { if ( ! aafm_delete_option_cache_safe( $option ) ) { $failed[] = $option; } } if ( array() !== $failed ) { // Uninstall has no screen to report to, so the server's error log is the only place a // leftover option can be named. Option names only, never a value. error_log( 'Agent Abilities for MCP: uninstall could not delete these options: ' . implode( ', ', $failed ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- uninstall has no other surface; option names only. } if ( false !== $observer_priority ) { add_action( 'aafm_write_completed', 'aafm_activity_log_write_outcome', $observer_priority, 2 ); } } /** * Reset the plugin to its out-of-the-box state. * * Deletes every configuration option (so each setting falls back to its safe default), empties the * activity log, and empties the four OAuth data tables (clients, codes, access tokens, consents). * It deliberately does NOT touch the agent user, its Application Passwords, or any content the * agent created (posts, terms, media, etc.) - this clears the plugin's own configuration, audit * trail, and OAuth state only. The activity-log and OAuth tables themselves are kept (rows cleared) * so the plugin keeps working immediately afterwards. This cannot be undone. * * The old notification address is warned before anything is deleted (aafm_notify_reset_warning()), and the * two governance switches a reset moves back are logged after the clear so their rows survive it. * * Wiping the activity log is itself security-relevant, so a tamper-evident marker is written * after the clear (L4, shared with the direct "Clear log" action in * aafm_ajax_clear_log()) - the emptied log always shows who reset the plugin and when. * * Every step now reports its own real outcome instead of being fired and forgotten: * a delete that silently failed used to leave a stale option live - most dangerously * aafm_enabled_abilities, which reset deliberately leaves the agent user and its application * passwords able to reach - while the caller still told the operator everything was cleared. * * @return bool True when every configuration option, the activity log (plus its marker), and * all four OAuth tables are confirmed cleared. */ function aafm_reset_plugin(): bool { $ok = true; // A reset quietly weakens the alerts: it deletes the notification settings and the queue, empties the // log, and moves the two governance switches back to their defaults. Capture what is about to go and // warn the old address first, while the settings that name it still exist. $ntf_before = aafm_notifications_settings(); $was_read_only = aafm_read_only_mode_stored(); $was_unlocked = aafm_high_risk_unlocked(); aafm_notify_reset_warning( $ntf_before, $was_read_only, $was_unlocked, aafm_notify_queue_list() ); // Cache-safe on purpose: reset is what an operator reaches for when a setting looks stuck, and // a stale persistent object cache is one way a setting gets stuck (aafm_forget_option_caches()). foreach ( aafm_config_option_names() as $option ) { if ( ! aafm_delete_option_cache_safe( $option ) ) { $ok = false; } } // The Connection Doctor's header-seen memo is configuration-adjacent state; reset clears it too. delete_transient( 'aafm_doctor_header_seen' ); if ( ! aafm_clear_activity_log() ) { $ok = false; } elseif ( ! aafm_log_activity_cleared_marker() ) { $ok = false; } // Reset also empties change history. A missing table counts as empty. if ( ! aafm_undo_clear() ) { $ok = false; } // Written after the clear so these rows survive it: the log shows the reset and what it changed. if ( $was_read_only && ! aafm_read_only_mode_stored() ) { aafm_log_read_only_switch_change( true, false ); } if ( $was_unlocked && ! aafm_high_risk_unlocked() ) { aafm_log_high_risk_switch_change( true, false ); } if ( ! aafm_truncate_oauth_tables() ) { $ok = false; } return $ok; } /** * AJAX: reset the plugin to defaults. * * Nonce + manage_options gated, mirroring the other admin actions. The destructive scope is fixed * server-side (config options + activity log only) - there is no client-supplied target, so a * tampered request can never widen what gets deleted. The browser confirms intent before calling. * * @return void */ function aafm_ajax_reset_plugin(): void { check_ajax_referer( 'aafm_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'agent-abilities-for-mcp' ) ), 403 ); } if ( ! aafm_reset_plugin() ) { wp_send_json_error( array( 'message' => __( 'Reset did not fully complete. Some settings, the activity log, or OAuth data may still hold their old state - please try again.', 'agent-abilities-for-mcp' ), ) ); } wp_send_json_success( array( 'message' => __( 'Plugin reset. Every setting and the activity log were cleared; your agent user and its content were left alone.', 'agent-abilities-for-mcp' ), ) ); } /** * Render the Settings tab: an OAuth card with its enable toggle (and a note that dynamic client * registration follows it), then one Safety controls card of * labelled rows - the two governance switches first, then the optional per-behaviour controls - * and the Danger zone last. Both cards sit inside #aafm-settings-form and share the one sticky * save bar at its foot. * * Each control reads its current value through its safety.php getter (filterable, bounded, * default off) and writes via the aafm_save_settings AJAX action. Everything is escaped on * output; the IP-lockout caution is rendered through the shared warning notice next to the * field it warns about. * * @return void */ function aafm_render_settings_tab(): void { echo '
'; wp_nonce_field( 'aafm_admin', 'aafm_settings_nonce' ); echo '
'; // OAuth: one switch row plus a note. Same .aafm-switch / .aafm-set-row markup as the // force-draft row below; the name/value/checked() contract is what the save // handler binds to, not this markup. The OAuth reader defaults off (discovery.php), and // dynamic client registration now follows it rather than carrying its own toggle. // // This card leads the tab because connecting an agent is the first thing an operator does // here, and the safety controls below only start mattering once something is connected. ob_start(); echo '

' . wp_kses( sprintf( /* translators: %s: link to the Connection tab. */ __( 'Token lifetimes and dynamic client registration are on the %s.', 'agent-abilities-for-mcp' ), '' . esc_html__( 'Connection tab', 'agent-abilities-for-mcp' ) . '' ), array( 'a' => array( 'href' => true ) ) ) . '

'; // Scope-is-not-a-boundary note. A connecting app can request an OAuth scope, and the consent // screen tells the approving human the app acts with their full capabilities either way - but // nothing said that on the admin side until now. Stated once, here, next to the two switches. echo '

' . esc_html__( 'If a connecting app requests a narrower OAuth scope, this plugin does not use it to limit what the resulting token can do. Every grant acts with the full capabilities of the account that approved it, unless a developer has wired the aafm_oauth_token_capabilities filter to narrow it.', 'agent-abilities-for-mcp' ) . '

'; // Enable OAuth. The row title and the sentence label each carry an id, and the checkbox // points at both with aria-labelledby, so the toggle's accessible name is the title plus the // descriptive sentence. The sentence
'; }