* element`). Nothing in wp-admin prints this; it is for the app reading the tool result, which may render it. * * Read tools are cleaned by default. RAW and EXACT name what must keep its exact bytes. The write layer is the reverse: * exact by default, with DISPLAY fields cleaned (for_write). */ final class SiteText { /** * What stays byte for byte, by tool: `*` for the whole result, a field name at any depth, or `/name` for a top-level * field only. Everything else is cleaned. * * These are values the tool exists to return exactly: content and settings an AI app reads in order to edit them, where * a stripped copy written back would damage the site, and code, regular expressions and JSON schemas, where `<` is * meaningful. Stripping them could also break a later write's precondition or a rollback, which compare exact values. * A result that holds markup here says so with `untrusted_html: true`. * * - wp_rest_get: core and plugin REST data, including `content.raw` and `title.raw` for editing (context=edit). * - run_readonly_ability: whatever another plugin's ability returns; its shape and meaning are unknown here. * - get_option, list_options, get_transient, list_transients, get_theme_mods, get_post_meta, get_user_meta: stored values * and their exact keys, which update_option, set_theme_mod, set_post_meta and set_user_meta take back. * - list_rewrite_rules: regular expressions, such as `(?P[0-9]+)`. * - read_file, search_files, get_file_outline, list_files: source code, and paths read_file takes back. * - get_page_html: `html` is the page's source. `text` (mode text) is what a visitor reads, with the source's tags * already gone. PageHtml neutralises it with defang(): stripping from a stray `<` would cut the rest of the page. A * summary's title, description and headings are cleaned. * - list_cron_events: `args` identify the event to core (run_cron_event, unschedule_cron_event). * - get_registered_meta: `default` is a stored value's default. * - get_ability: JSON schemas, whose patterns can hold `<`. * - get_*_page: the builder layout, which the matching save tool takes back. * - get_*_schema: default values and option keys to build a layout with; labels and descriptions are cleaned. */ const RAW = array( 'wp_rest_get' => array( '*' ), 'run_readonly_ability' => array( '*' ), 'get_option' => array( '*' ), 'list_options' => array( '*' ), 'get_transient' => array( '*' ), 'list_transients' => array( '*' ), 'get_theme_mods' => array( '*' ), 'get_post_meta' => array( '*' ), 'get_user_meta' => array( '*' ), 'list_rewrite_rules' => array( '*' ), 'read_file' => array( '*' ), 'list_files' => array( '*' ), 'search_files' => array( '*' ), 'get_file_outline' => array( '*' ), 'get_page_html' => array( '/html', '/text' ), 'list_cron_events' => array( 'args' ), 'get_registered_meta' => array( 'default' ), 'get_ability' => array( 'input_schema', 'output_schema' ), 'get_elementor_page' => array( 'elements' ), 'get_bricks_page' => array( 'elements' ), 'get_breakdance_page' => array( 'tree' ), 'get_elementor_widget_schema' => array( 'default', 'options', 'condition', 'conditions' ), 'get_elementor_style_schema' => array( 'default', 'options' ), ); /** * Values someone acts on or a report is about, which must name what really exists: slugs, ids, URLs, file paths, hooks. A write tool given a cleaned copy could act on a * different target, and an integrity report could call a malicious file by a harmless name. They stay exact, * since JSON carries them safely. When one could matter if an app rendered it (could_matter()), its row also gets * `_display`, the clean copy to show, and `identifier_unsafe: true`. Fields by tool, at any depth. */ const EXACT = array( 'get_site_info' => array( 'url', 'site_icon_url' ), 'get_health_triage' => array( 'id' ), 'get_update_readiness' => array( 'id' ), 'get_content_qa' => array( 'id', 'url' ), 'list_plugins' => array( 'slug' ), 'list_themes' => array( 'slug', 'active_theme' ), 'list_recent_content' => array( 'link' ), 'search_content' => array( 'link' ), 'get_core_updates' => array( 'version', 'locale' ), 'get_connection_info' => array( 'login', 'roles', 'slug' ), 'list_cron_events' => array( 'hook', 'schedule' ), 'list_roles' => array( 'slug', 'capabilities' ), 'list_image_sizes' => array( 'name' ), 'get_registered_meta' => array( 'object_type', 'subtype', 'name' ), 'list_abilities' => array( 'name', 'category' ), 'get_ability' => array( 'name', 'category' ), 'get_config' => array( 'value' ), 'get_page_html' => array( 'final_url' ), 'verify_core_checksums' => array( 'path', 'version', 'locale' ), 'verify_plugin_checksums' => array( 'path', 'slug', 'version' ), 'search_plugin_directory' => array( 'slug' ), 'list_orders' => array( 'number' ), 'list_products' => array( 'sku' ), 'list_elementor_widgets' => array( 'slug' ), 'get_elementor_widget_schema' => array( 'slug', 'name', 'type' ), 'get_elementor_style_schema' => array( 'name', 'type' ), 'list_bricks_elements' => array( 'name' ), 'list_breakdance_elements' => array( 'type' ), ); /** * Write-layer fields shown as text, at any depth: these are cleaned. */ const DISPLAY = array( 'summary', 'message', 'error', 'note', 'reason', 'title', 'label', 'client_name', 'featured_media_titles' ); /** * Write-layer fields holding exact values, never looked into, even for a DISPLAY field: `title` under `before` is the * real old title. */ const EXACT_VALUES = array( 'before', 'after', 'current', 'restored', 'args', 'rollback', 'fields', 'value' ); /** * A finding's or update's suggested tool call, whose arguments are passed back exactly. It stays as it is, or, when * cleaning would change it, goes: a changed argument could name something else, and the raw one would carry the markup. */ const FIX = 'fix'; /** * The exact fields that hold a URL. An app may put one in an attribute, so RISKY_URL flags it, and its clean copy is * percent-encoded. */ const URL_FIELDS = array( 'url', 'link', 'site_icon_url', 'final_url' ); /** * What flags a kept URL: a quote, a backtick, whitespace, a control character, an angle bracket, or a reference a browser * would decode, numeric or named, with or without a semicolon. */ const RISKY_URL = '/["\'`<>\x00-\x20\x7F]|&(?:#[0-9]|#[xX][0-9a-fA-F]|lt|LT|gt|GT|amp|AMP|[a-zA-Z][a-zA-Z0-9]*;)/'; /** * What flags any other kept value, besides what reads as an angle bracket: a double quote, a backtick or a control * character, which ordinary names, slugs and hooks do not hold. Spaces and apostrophes do not flag. */ const RISKY_NAME = '/["`\x00-\x1F\x7F]/'; /** * Most rounds of decoding a value gets. Real text needs two or three; each further round undoes one more layer of * encoding. A value still changing after this keeps its words, without a `<` or an `&`. */ const MAX_ROUNDS = 5; /** * What starts a comment, or a complete tag as the HTML tokenizer reads it: a ``, or a * name and attributes up to the next `>` outside a quoted attribute value. A quote that never closes counts for nothing, * so the text after it stays. A `<` that starts neither is left for harmless(). */ const TAG = '/` and `` end at once (after_tag()). */ const COMMENT_END = '/--!?>/'; /** * Plain text: real tags removed, with the contents of comments, scripts and styles; then entities decoded, repeated, because a * value can be encoded more than once. Whatever decoding reveals keeps its words, and harmless() makes sure none of it * can render. Cleaning twice changes nothing more, and a value with neither `<` nor `&` keeps its exact bytes. * * @param string $text Text from the site. * @return string */ public static function plain( string $text ): string { if ( false === strpos( $text, '<' ) && false === strpos( $text, '&' ) ) { return $text; } return self::settled( self::without_tags( str_replace( "\0", '', $text ) ) ); } /** * Plain text for a message that names a value: the same, except that a comment, script or style that never closes takes * only its opening with it, because the words after it are the message's own. `Option "nope