array( 'resource' => 'http', 'action' => 'request', ), 'wp.cache.clear' => array( 'resource' => 'cache', 'action' => 'clear', ), 'wp.rewrite_rules.flush' => array( 'resource' => 'rewrite_rules', 'action' => 'flush', ), 'wp.cron.run' => array( 'resource' => 'cron', 'action' => 'run', ), 'wp.performance.inspect' => array( 'resource' => 'performance', 'action' => 'inspect', ), 'wp.cron_events.query' => array( 'resource' => 'cron_events', 'action' => 'query', ), 'wp.cron_events.get' => array( 'resource' => 'cron_events', 'action' => 'get', ), 'wp.cron_events.create' => array( 'resource' => 'cron_events', 'action' => 'create', ), 'wp.cron_events.delete' => array( 'resource' => 'cron_events', 'action' => 'delete', ), 'wp.files.create' => array( 'resource' => 'files', 'action' => 'create', ), 'wp.files.update' => array( 'resource' => 'files', 'action' => 'update', ), 'wp.files.delete' => array( 'resource' => 'files', 'action' => 'delete', ), ); private string $resource; private string $action; public function __construct( string $capability ) { $metadata = self::CAPABILITIES[ $capability ] ?? array( 'resource' => '', 'action' => '', ); $this->resource = (string) $metadata['resource']; $this->action = (string) $metadata['action']; } /** * Run one grammar command. * * @param array $input Command input. * @param string $phase validate or execute. */ public function run( array $input, string $phase ): array { $validation = $this->validate_input( $input ); if ( isset( $validation['error'] ) ) { return $validation; } if ( 'validate' === $phase ) { return array( 'data' => array( 'valid' => true ) ); } return match ( $this->resource ) { 'http' => $this->http_request( $input ), 'cache' => $this->cache_clear(), 'rewrite_rules' => $this->rewrite_rules_flush( $input ), 'cron' => $this->cron_run( $input ), 'performance' => $this->performance_inspect( $input ), 'cron_events' => $this->cron_events( $input ), 'files' => $this->files_lifecycle( $input ), default => $this->error( 'unsupported_capability', 'This special capability is not supported.' ), }; } private function validate_input( array $input ): array { if ( isset( $input['select'] ) && ! $this->is_path_list( $input['select'] ) ) { return $this->error( 'invalid_command', 'select must be a list of array-segment paths.', array( 'input', 'select' ) ); } if ( isset( $input['limit'] ) && ( ! is_numeric( $input['limit'] ) || (int) $input['limit'] < 1 ) ) { return $this->error( 'invalid_value', 'limit must be a positive integer.', array( 'input', 'limit' ) ); } if ( isset( $input['limit'] ) && (int) $input['limit'] > self::MAX_LIMIT ) { return $this->error( 'limit_exceeded', 'limit exceeds the cron-events query cap.', array( 'input', 'limit' ), array( 'max' => self::MAX_LIMIT ) ); } if ( isset( $input['cursor'] ) && null !== $input['cursor'] && ! is_string( $input['cursor'] ) ) { return $this->error( 'invalid_value', 'cursor must be null or an opaque string.', array( 'input', 'cursor' ) ); } if ( 'cron_events' === $this->resource && 'query' === $this->action ) { $cursor = CommandPagination::validate( $input['cursor'] ?? null, $this->cron_cursor_context( $input ) ); if ( isset( $cursor['error'] ) ) { return $cursor; } $query_shape = $this->validate_cron_query_shape( $input ); if ( isset( $query_shape['error'] ) ) { return $query_shape; } } if ( 'http' === $this->resource ) { $method = $this->http_method_from_input( $input ); if ( ! in_array( $method, array( 'GET', 'HEAD', 'POST' ), true ) ) { return $this->error( 'invalid_value', 'HTTP method must be GET, HEAD, or POST.', array( 'input', 'method' ) ); } } if ( 'http' === $this->resource || 'performance' === $this->resource ) { return $this->validate_same_site_url( $this->url_from_input( $input ) ); } if ( 'files' === $this->resource ) { return $this->validate_file_target( $input ); } return array( 'data' => array( 'valid' => true ) ); } private function validate_same_site_url( string $url ): array { if ( '' === $url || ! filter_var( $url, FILTER_VALIDATE_URL ) ) { return $this->error( 'invalid_value', 'A valid absolute URL is required.', array( 'input', 'url' ) ); } $origin = $this->url_origin( $url ); if ( '' === $origin || ! in_array( $origin, $this->same_site_allowed_origins(), true ) ) { return $this->error( 'permission_denied', 'HTTP requests are limited to this WordPress site origin.', array( 'input', 'url' ), array( 'layer' => 'hard_rule', 'reason' => 'same_site_required', ) ); } return array( 'data' => array( 'valid' => true ) ); } /** * Return normalized origins accepted by the same-site URL policy. * * @return array */ private function same_site_allowed_origins(): array { $origins = array_map( fn ( string $candidate ): string => $this->url_origin( $candidate ), array( get_site_url(), get_home_url() ) ); return array_values( array_unique( array_filter( $origins ) ) ); } private function url_origin( string $url ): string { $scheme = strtolower( (string) wp_parse_url( $url, PHP_URL_SCHEME ) ); $host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ); if ( '' === $scheme || '' === $host ) { return ''; } $port = wp_parse_url( $url, PHP_URL_PORT ); $default_port = 'https' === $scheme ? 443 : ( 'http' === $scheme ? 80 : null ); $port_suffix = is_int( $port ) && $port !== $default_port ? ':' . (string) $port : ''; return $scheme . '://' . $host . $port_suffix; } private function validate_file_target( array $input ): array { $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : array(); $scope = isset( $target['scope'] ) && is_scalar( $target['scope'] ) ? (string) $target['scope'] : ''; $path = isset( $target['path'] ) && is_scalar( $target['path'] ) ? (string) $target['path'] : ''; if ( ! in_array( $scope, self::WRITABLE_FILE_SCOPES, true ) ) { return $this->error( 'invalid_target', 'Standard file writes are limited to the uploads sandbox.', array( 'input', 'target', 'scope' ), array( 'allowed_scopes' => self::WRITABLE_FILE_SCOPES ) ); } if ( '' === $path || str_contains( $path, '..' ) || str_starts_with( $path, '/' ) || str_contains( $path, '\\' ) ) { return $this->error( 'invalid_target', 'File path must be relative and stay inside its sandbox scope.', array( 'input', 'target', 'path' ) ); } return array( 'data' => array( 'valid' => true ) ); } private function http_request( array $input ): array { $url = $this->url_from_input( $input ); $method = $this->http_method_from_input( $input ); if ( function_exists( 'wp_remote_request' ) ) { // Redirects are reported, never followed: following could carry the // request off the validated same-site origin via an on-site redirect. $response = wp_remote_request( $url, array( 'method' => $method, 'headers' => $this->http_headers_from_input( $input ), 'timeout' => $this->http_timeout_from_input( $input ), 'redirection' => 0, ) ); if ( function_exists( 'is_wp_error' ) && is_wp_error( $response ) ) { return $this->error( 'execution_failed', $response->get_error_message() ); } $body = function_exists( 'wp_remote_retrieve_body' ) ? (string) wp_remote_retrieve_body( $response ) : ''; $body_truncated = strlen( $body ) > self::HTTP_BODY_MAX_BYTES; $response_data = array( 'status' => function_exists( 'wp_remote_retrieve_response_code' ) ? wp_remote_retrieve_response_code( $response ) : null, 'status_text' => function_exists( 'wp_remote_retrieve_response_message' ) ? wp_remote_retrieve_response_message( $response ) : '', 'headers' => $this->http_response_headers( $response ), 'redirects' => $this->http_redirects_from_response( $response ), 'final_url' => $this->http_final_url_from_response( $response, $url ), 'body' => $body_truncated ? substr( $body, 0, self::HTTP_BODY_MAX_BYTES ) : $body, ); if ( $body_truncated ) { $response_data['body_truncated'] = true; $response_data['body_bytes'] = strlen( $body ); } return array( 'data' => array( 'target' => array( 'url' => $url ), 'response' => $response_data, ), ); } return array( 'data' => array( 'target' => array( 'url' => $url ), 'response' => array( 'status' => null, 'status_text' => '', 'headers' => array(), 'redirects' => array(), 'final_url' => $url, 'body' => '', ), ), ); } private function http_method_from_input( array $input ): string { $method = isset( $input['method'] ) && is_scalar( $input['method'] ) ? strtoupper( (string) $input['method'] ) : 'GET'; return $method; } private function http_headers_from_input( array $input ): array { $raw_headers = isset( $input['headers'] ) && is_array( $input['headers'] ) ? $input['headers'] : array(); $headers = array(); foreach ( $raw_headers as $name => $value ) { if ( ! is_scalar( $name ) || ! is_scalar( $value ) || is_bool( $value ) ) { continue; } $header_name = trim( (string) $name ); $lower_name = strtolower( $header_name ); $header_value = trim( (string) preg_replace( '/[\r\n]+/', ' ', (string) $value ) ); if ( '' === $header_name || '' === $header_value || ! preg_match( '/^[A-Za-z0-9!#$%&\'*+.^_`|~-]+$/', $header_name ) || in_array( $lower_name, self::HTTP_FORBIDDEN_REQUEST_HEADERS, true ) ) { continue; } $headers[ $header_name ] = $header_value; } return $headers; } private function http_timeout_from_input( array $input ): float { $raw_timeout = $input['timeout'] ?? ( $input['timeout_s'] ?? self::HTTP_TIMEOUT_MAX ); if ( ! is_numeric( $raw_timeout ) ) { return self::HTTP_TIMEOUT_MAX; } return min( self::HTTP_TIMEOUT_MAX, max( self::HTTP_TIMEOUT_MIN, (float) $raw_timeout ) ); } private function http_response_headers( $response ): array { if ( ! function_exists( 'wp_remote_retrieve_headers' ) ) { return array(); } return $this->normalize_http_header_map( wp_remote_retrieve_headers( $response ) ); } private function normalize_http_header_map( $raw_headers ): array { if ( is_object( $raw_headers ) ) { if ( method_exists( $raw_headers, 'getAll' ) ) { $raw_headers = $raw_headers->getAll(); } elseif ( method_exists( $raw_headers, 'get_all' ) ) { $raw_headers = $raw_headers->get_all(); } } if ( ! is_array( $raw_headers ) ) { return array(); } $headers = array(); foreach ( $raw_headers as $name => $value ) { if ( ! is_scalar( $name ) ) { continue; } if ( is_array( $value ) ) { $parts = array(); foreach ( $value as $part ) { if ( is_scalar( $part ) && ! is_bool( $part ) ) { $parts[] = (string) $part; } } $value = implode( ', ', $parts ); } if ( ! is_scalar( $value ) || is_bool( $value ) ) { continue; } $header_name = strtolower( trim( (string) $name ) ); if ( '' === $header_name ) { continue; } $headers[ $header_name ] = (string) $value; } return $headers; } private function http_redirects_from_response( $response ): array { if ( ! is_array( $response ) || empty( $response['http_response'] ) || ! is_object( $response['http_response'] ) ) { return array(); } $http_response = $response['http_response']; if ( ! method_exists( $http_response, 'get_response_object' ) ) { return array(); } $response_object = $http_response->get_response_object(); $history = is_object( $response_object ) && isset( $response_object->history ) && is_array( $response_object->history ) ? $response_object->history : array(); $redirects = array(); foreach ( $history as $entry ) { if ( ! is_object( $entry ) ) { continue; } $redirect = array(); if ( isset( $entry->url ) && is_scalar( $entry->url ) ) { $redirect['url'] = (string) $entry->url; } if ( isset( $entry->status_code ) && is_numeric( $entry->status_code ) ) { $redirect['status'] = (int) $entry->status_code; } if ( isset( $entry->headers ) ) { $headers = $this->normalize_http_header_map( $entry->headers ); if ( isset( $headers['location'] ) ) { $redirect['location'] = $headers['location']; } } if ( array() !== $redirect ) { $redirects[] = $redirect; } } return $redirects; } private function http_final_url_from_response( $response, string $fallback ): string { if ( ! is_array( $response ) || empty( $response['http_response'] ) || ! is_object( $response['http_response'] ) ) { return $fallback; } $http_response = $response['http_response']; if ( ! method_exists( $http_response, 'get_response_object' ) ) { return $fallback; } $response_object = $http_response->get_response_object(); if ( is_object( $response_object ) && isset( $response_object->url ) && is_scalar( $response_object->url ) ) { $final_url = trim( (string) $response_object->url ); if ( '' !== $final_url ) { return $final_url; } } return $fallback; } private function cache_clear(): array { $flushed = function_exists( 'wp_cache_flush' ) ? (bool) wp_cache_flush() : true; return array( 'data' => array( 'cleared' => $flushed, 'target' => array( 'resource' => 'cache' ), ), ); } private function rewrite_rules_flush( array $input ): array { $hard = ! empty( $input['hard'] ); if ( function_exists( 'flush_rewrite_rules' ) ) { flush_rewrite_rules( $hard ); } return array( 'data' => array( 'flushed' => true, 'hard' => $hard, 'target' => array( 'resource' => 'rewrite_rules' ), ), ); } private function performance_inspect( array $input ): array { $url = $this->url_from_input( $input ); $started = microtime( true ); $http = $this->http_request( array( 'url' => $url, 'method' => 'GET', 'timeout' => min( 10.0, $this->http_timeout_from_input( $input ) ), ) ); $elapsed_ms = (int) round( ( microtime( true ) - $started ) * 1000 ); $response = isset( $http['data']['response'] ) && is_array( $http['data']['response'] ) ? $http['data']['response'] : array(); return array( 'data' => array( 'target' => array( 'url' => $url ), 'metrics' => array( 'status' => isset( $http['error'] ) ? 'http_error' : 'available', 'response_time_ms' => $elapsed_ms, 'http_status' => $response['status'] ?? null, 'body_bytes' => isset( $response['body'] ) && is_string( $response['body'] ) ? strlen( $response['body'] ) : 0, 'autoload_options' => $this->autoload_options_summary(), 'object_cache' => array( 'external' => function_exists( 'wp_using_ext_object_cache' ) && wp_using_ext_object_cache() ), 'cron_events_count' => count( $this->cron_event_documents() ), ), ), ); } private function autoload_options_summary(): array { $options = function_exists( 'wp_load_alloptions' ) ? wp_load_alloptions() : array(); $options = is_array( $options ) ? $options : array(); $bytes = 0; $top = array(); foreach ( $options as $name => $value ) { $encoded = function_exists( 'wp_json_encode' ) ? wp_json_encode( $value ) : json_encode( $value ); $size = is_string( $encoded ) ? strlen( $encoded ) : 0; $bytes += $size; $top[] = array( 'name' => (string) $name, 'bytes' => $size, ); } usort( $top, static fn ( array $left, array $right ): int => (int) $right['bytes'] <=> (int) $left['bytes'] ); return array( 'count' => count( $options ), 'bytes' => $bytes, 'top' => array_slice( $top, 0, 10 ), ); } /** * Call a cron scheduler function and fail if WordPress reports failure. * * @param string $function_name Scheduler function name. * @param array $args Scheduler arguments before optional wp_error. */ private function call_scheduler( string $function_name, array $args ): array { $call_args = $args; if ( $this->function_accepts_args( $function_name, count( $args ) + 1 ) ) { $call_args[] = true; } $result = $function_name( ...$call_args ); if ( function_exists( 'is_wp_error' ) && is_wp_error( $result ) ) { return $this->error( 'execution_failed', $result->get_error_message() ); } if ( false === $result ) { return $this->error( 'execution_failed', 'WordPress failed to schedule or unschedule the cron event.' ); } return array( 'data' => array( 'valid' => true ) ); } private function cron_events( array $input ): array { return match ( $this->action ) { 'query' => $this->cron_events_query( $input ), 'get' => $this->cron_event_get( $input ), 'create' => $this->cron_event_create( $input ), 'delete' => $this->cron_event_delete( $input ), default => $this->error( 'unsupported_capability', 'This cron-events action is not supported.' ), }; } private function cron_events_query( array $input ): array { $events = $this->cron_event_documents(); if ( isset( $input['where'] ) && is_array( $input['where'] ) && array() !== $input['where'] ) { $events = array_values( array_filter( $events, fn ( array $event ): bool => $this->cron_matches_where( $event['document'] ?? array(), $input['where'] ) ) ); } if ( isset( $input['search'] ) && is_scalar( $input['search'] ) && '' !== (string) $input['search'] ) { $needle = strtolower( (string) $input['search'] ); $events = array_values( array_filter( $events, static fn ( array $event ): bool => false !== strpos( strtolower( (string) json_encode( $event['document'] ?? array() ) ), $needle ) ) ); } $events = $this->order_cron_events( $events, $input['order_by'] ?? null ); $limit = isset( $input['limit'] ) ? (int) $input['limit'] : self::DEFAULT_LIMIT; $page = CommandPagination::page( $events, $limit, $input['cursor'] ?? null, $this->cron_cursor_context( $input ) ); if ( isset( $page['error'] ) ) { return $page; } $select = isset( $input['select'] ) && is_array( $input['select'] ) ? $input['select'] : null; $items = null === $select ? $page['items'] : array_map( fn ( array $event ): array => array( 'target' => $event['target'] ?? array(), 'document' => $this->project_cron_document( $event['document'] ?? array(), $select ), ), $page['items'] ); return array( 'data' => array( 'resource' => 'cron_events', 'items' => $items, 'page' => array( 'next_cursor' => $page['next_cursor'], 'limit' => $limit, ), ), 'meta' => array( 'total' => count( $events ), 'returned_count' => count( $page['items'] ), ), ); } private function cron_cursor_context( array $input ): array { return array( 'resource' => 'cron_events', 'where' => $input['where'] ?? null, 'search' => $input['search'] ?? null, 'order_by' => $input['order_by'] ?? null, ); } /** * Reject cron-events query shapes the runtime does not evaluate, keeping * the effective schema's queryable/orderable claims honest. */ private function validate_cron_query_shape( array $input ): array { if ( isset( $input['where'] ) && is_array( $input['where'] ) && array() !== $input['where'] ) { $where = $this->validate_cron_where_group( $input['where'], array( 'input', 'where' ) ); if ( isset( $where['error'] ) ) { return $where; } } if ( isset( $input['select'] ) && is_array( $input['select'] ) ) { foreach ( array_values( $input['select'] ) as $index => $path ) { $key = is_array( $path ) ? implode( '.', array_map( 'strval', array_values( $path ) ) ) : ''; if ( ! in_array( $key, array( 'hook', 'timestamp', 'schedule', 'args' ), true ) ) { return $this->error( 'invalid_path', 'select path is not readable for cron events.', array( 'input', 'select', $index ) ); } } } if ( ! isset( $input['order_by'] ) ) { return array( 'data' => array( 'valid' => true ) ); } if ( ! is_array( $input['order_by'] ) ) { return $this->error( 'invalid_command', 'order_by must be a list of {path, dir} entries.', array( 'input', 'order_by' ) ); } foreach ( array_values( $input['order_by'] ) as $index => $order ) { if ( ! is_array( $order ) || ! isset( $order['path'] ) || ! is_array( $order['path'] ) ) { return $this->error( 'invalid_command', 'order_by entries require an array-segment path.', array( 'input', 'order_by', $index ) ); } $key = implode( '.', array_map( 'strval', array_values( $order['path'] ) ) ); if ( ! in_array( $key, array( 'hook', 'timestamp', 'schedule' ), true ) ) { return $this->error( 'invalid_path', 'order_by path is not orderable for cron events.', array( 'input', 'order_by', $index, 'path' ) ); } $dir = isset( $order['dir'] ) && is_scalar( $order['dir'] ) ? strtolower( (string) $order['dir'] ) : 'asc'; if ( ! in_array( $dir, array( 'asc', 'desc' ), true ) ) { return $this->error( 'invalid_value', 'order_by dir must be asc or desc.', array( 'input', 'order_by', $index, 'dir' ) ); } } return array( 'data' => array( 'valid' => true ) ); } private function validate_cron_where_group( array $where, array $path ): array { foreach ( array( 'all', 'any' ) as $group ) { if ( ! isset( $where[ $group ] ) ) { continue; } if ( ! is_array( $where[ $group ] ) ) { return $this->error( 'invalid_command', 'where groups must be lists of predicates.', array_merge( $path, array( $group ) ) ); } foreach ( array_values( $where[ $group ] ) as $index => $predicate ) { if ( ! is_array( $predicate ) ) { return $this->error( 'invalid_command', 'where predicates must be objects.', array_merge( $path, array( $group, $index ) ) ); } $nested = $this->validate_cron_where_group( $predicate, array_merge( $path, array( $group, $index ) ) ); if ( isset( $nested['error'] ) ) { return $nested; } } } if ( ! isset( $where['all'] ) && ! isset( $where['any'] ) ) { if ( ! isset( $where['path'] ) || ! is_array( $where['path'] ) ) { return $this->error( 'invalid_command', 'where predicates require an array-segment path.', $path ); } $key = implode( '.', array_map( 'strval', array_values( $where['path'] ) ) ); // args stays readable but is not queryable: predicates have no // array-value matching semantics, mirroring the effective schema. if ( ! in_array( $key, array( 'hook', 'timestamp', 'schedule' ), true ) ) { return $this->error( 'invalid_path', 'where path is not queryable for cron events.', array_merge( $path, array( 'path' ) ) ); } $op = isset( $where['op'] ) && is_scalar( $where['op'] ) ? (string) $where['op'] : 'eq'; if ( ! in_array( $op, RegistryReadCapability::QUERY_OPS, true ) ) { return $this->error( 'invalid_value', 'where op is not supported by cron-events queries.', array_merge( $path, array( 'op' ) ), array( 'supported' => RegistryReadCapability::QUERY_OPS ) ); } } return array( 'data' => array( 'valid' => true ) ); } private function cron_matches_where( array $document, array $where ): bool { if ( isset( $where['all'] ) && is_array( $where['all'] ) ) { foreach ( $where['all'] as $predicate ) { if ( ! is_array( $predicate ) || ! $this->cron_matches_where( $document, $predicate ) ) { return false; } } return true; } if ( isset( $where['any'] ) && is_array( $where['any'] ) ) { foreach ( $where['any'] as $predicate ) { if ( is_array( $predicate ) && $this->cron_matches_where( $document, $predicate ) ) { return true; } } return false; } $key = implode( '.', array_map( 'strval', array_values( $where['path'] ?? array() ) ) ); $actual = $document[ $key ] ?? null; $expected = $where['value'] ?? null; $op = isset( $where['op'] ) && is_scalar( $where['op'] ) ? (string) $where['op'] : 'eq'; $exists = array_key_exists( $key, $document ); return match ( $op ) { 'eq' => $this->cron_value_equals( $actual, $expected ), 'neq' => ! $this->cron_value_equals( $actual, $expected ), 'in' => is_array( $expected ) && $this->cron_value_in( $actual, $expected ), 'not_in' => is_array( $expected ) && ! $this->cron_value_in( $actual, $expected ), 'contains' => is_scalar( $actual ) && is_scalar( $expected ) && false !== strpos( strtolower( (string) $actual ), strtolower( (string) $expected ) ), 'exists' => (bool) $expected === $exists, default => false, }; } private function cron_value_equals( $actual, $expected ): bool { if ( is_bool( $actual ) || is_bool( $expected ) ) { return (bool) $actual === (bool) $expected; } return is_scalar( $actual ) && is_scalar( $expected ) && (string) $actual === (string) $expected; } private function cron_value_in( $actual, array $expected ): bool { foreach ( $expected as $value ) { if ( $this->cron_value_equals( $actual, $value ) ) { return true; } } return false; } private function order_cron_events( array $events, $order_by ): array { if ( ! is_array( $order_by ) || array() === $order_by ) { return $events; } $orders = array(); foreach ( $order_by as $order ) { $orders[] = array( 'key' => implode( '.', array_map( 'strval', array_values( $order['path'] ) ) ), 'desc' => 'desc' === strtolower( (string) ( $order['dir'] ?? 'asc' ) ), ); } usort( $events, static function ( array $left, array $right ) use ( $orders ): int { foreach ( $orders as $order ) { $a = $left['document'][ $order['key'] ] ?? null; $b = $right['document'][ $order['key'] ] ?? null; $comparison = ( is_numeric( $a ) && is_numeric( $b ) ) ? ( (float) $a <=> (float) $b ) : strcasecmp( is_scalar( $a ) ? (string) $a : '', is_scalar( $b ) ? (string) $b : '' ) <=> 0; if ( 0 !== $comparison ) { return $order['desc'] ? -$comparison : $comparison; } } return 0; } ); return array_values( $events ); } private function project_cron_document( array $document, array $select ): array { $projected = array(); foreach ( $select as $path ) { $key = is_array( $path ) ? implode( '.', array_map( 'strval', array_values( $path ) ) ) : ''; if ( array_key_exists( $key, $document ) ) { $projected[ $key ] = $document[ $key ]; } } return $projected; } private function cron_event_get( array $input ): array { $key = $this->cron_target_key( $input ); foreach ( $this->cron_event_documents() as $event ) { if ( $key === $this->cron_document_key( $event['document'] ) ) { return array( 'data' => array( 'resource' => 'cron_events', 'target' => $event['target'], 'document' => $event['document'], ), ); } } return $this->error( 'not_found', 'No cron event matched the requested target.', array( 'input', 'target' ) ); } private function cron_event_create( array $input ): array { $values = isset( $input['values'] ) && is_array( $input['values'] ) ? $input['values'] : array(); $hook = isset( $values['hook'] ) && is_scalar( $values['hook'] ) ? (string) $values['hook'] : ''; $timestamp = isset( $values['timestamp'] ) && is_numeric( $values['timestamp'] ) ? (int) $values['timestamp'] : time(); $schedule = isset( $values['schedule'] ) && is_scalar( $values['schedule'] ) ? (string) $values['schedule'] : ''; $args = isset( $values['args'] ) && is_array( $values['args'] ) ? array_values( $values['args'] ) : array(); if ( '' === $hook ) { return $this->error( 'invalid_value', 'Cron events require values.hook.', array( 'input', 'values', 'hook' ) ); } if ( '' !== $schedule && function_exists( 'wp_schedule_event' ) ) { $scheduled = $this->call_scheduler( 'wp_schedule_event', array( $timestamp, $schedule, $hook, $args ) ); if ( isset( $scheduled['error'] ) ) { return $scheduled; } } elseif ( function_exists( 'wp_schedule_single_event' ) ) { $scheduled = $this->call_scheduler( 'wp_schedule_single_event', array( $timestamp, $hook, $args ) ); if ( isset( $scheduled['error'] ) ) { return $scheduled; } } else { $this->store_cron_event( array( 'hook' => $hook, 'timestamp' => $timestamp, 'schedule' => $schedule, 'args' => $args, ) ); } return array( 'data' => array( 'resource' => 'cron_events', 'target' => array( 'hook' => $hook, 'timestamp' => $timestamp, ), 'created' => true, ), ); } private function cron_event_delete( array $input ): array { $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : array(); $hook = isset( $target['hook'] ) && is_scalar( $target['hook'] ) ? (string) $target['hook'] : ''; $timestamp = isset( $target['timestamp'] ) && is_numeric( $target['timestamp'] ) ? (int) $target['timestamp'] : 0; $args = isset( $target['args'] ) && is_array( $target['args'] ) ? array_values( $target['args'] ) : array(); if ( '' === $hook ) { return $this->error( 'invalid_target', 'Cron event delete requires target.hook.', array( 'input', 'target', 'hook' ) ); } if ( 0 < $timestamp && function_exists( 'wp_unschedule_event' ) ) { $unscheduled = $this->call_scheduler( 'wp_unschedule_event', array( $timestamp, $hook, $args ) ); if ( isset( $unscheduled['error'] ) ) { return $unscheduled; } } else { $this->delete_stored_cron_event( $hook, $timestamp ); } return array( 'data' => array( 'resource' => 'cron_events', 'target' => array( 'hook' => $hook, 'timestamp' => $timestamp, ), 'deleted' => true, ), ); } private function cron_run( array $input ): array { $hook = isset( $input['hook'] ) && is_scalar( $input['hook'] ) ? (string) $input['hook'] : ''; $args = isset( $input['args'] ) && is_array( $input['args'] ) ? array_values( $input['args'] ) : array(); $due_events = $this->due_cron_event_documents( $hook ); $callback_count = '' === $hook ? null : $this->hook_callback_count( $hook ); $should_invoke = '' !== $hook && ( null === $callback_count || $callback_count > 0 ); $ran = array(); if ( $should_invoke && function_exists( 'do_action_ref_array' ) ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- Cron execution intentionally invokes the requested dynamic cron hook. do_action_ref_array( $hook, $args ); $ran[] = array( 'hook' => $hook, 'args' => $args, ); } $callback_outcome = 'unknown'; if ( '' === $hook ) { $callback_outcome = 'not_requested'; } elseif ( 0 === $callback_count ) { $callback_outcome = 'no_callbacks'; } return array( 'data' => array( 'target' => array( 'hook' => $hook ), 'invoked' => $should_invoke, 'callback_count' => $callback_count, 'callback_outcome' => $callback_outcome, 'due_count' => count( $due_events ), 'due_events' => array_slice( $due_events, 0, 20 ), 'ran' => $ran, ), ); } private function hook_callback_count( string $hook ): ?int { global $wp_filter; if ( isset( $wp_filter[ $hook ] ) ) { $raw = is_object( $wp_filter[ $hook ] ) && isset( $wp_filter[ $hook ]->callbacks ) ? $wp_filter[ $hook ]->callbacks : $wp_filter[ $hook ]; if ( is_array( $raw ) ) { $count = 0; foreach ( $raw as $callbacks ) { $count += is_array( $callbacks ) ? count( $callbacks ) : 0; } return $count; } } if ( function_exists( 'has_action' ) ) { return false === has_action( $hook ) ? 0 : null; } return null; } private function files_lifecycle( array $input ): array { $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : array(); $file = $this->resolve_file_target( $target ); if ( null === $file ) { return $this->error( 'invalid_target', 'Only uploads-scope file writes are executable in this sandbox slice.', array( 'input', 'target', 'scope' ) ); } if ( 'delete' === $this->action ) { if ( ! file_exists( $file ) ) { return $this->error( 'not_found', 'No file exists at the requested sandbox path.', array( 'input', 'target', 'path' ) ); } if ( ! wp_delete_file( $file ) ) { return $this->error( 'execution_failed', 'The file could not be deleted.', array( 'input', 'target', 'path' ) ); } return array( 'data' => array( 'resource' => 'files', 'target' => $target, 'deleted' => true, ), ); } $value = $this->file_content_from_input( $input ); $dir = dirname( $file ); if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) { return $this->error( 'execution_failed', 'The file directory could not be created.', array( 'input', 'target', 'path' ) ); } $bytes = file_put_contents( $file, $value ); if ( false === $bytes ) { return $this->error( 'execution_failed', 'The file could not be written.', array( 'input', 'target', 'path' ) ); } return array( 'data' => array( 'resource' => 'files', 'target' => $target, 'written' => true, 'bytes' => $bytes, ), ); } private function file_content_from_input( array $input ): string { if ( isset( $input['value'] ) && is_scalar( $input['value'] ) ) { return (string) $input['value']; } if ( isset( $input['values']['content'] ) && is_scalar( $input['values']['content'] ) ) { return (string) $input['values']['content']; } return ''; } private function cron_event_documents(): array { $events = array(); if ( function_exists( '_get_cron_array' ) ) { foreach ( (array) _get_cron_array() as $timestamp => $hooks ) { foreach ( (array) $hooks as $hook => $instances ) { foreach ( (array) $instances as $instance ) { $events[] = $this->cron_event_document( (string) $hook, (int) $timestamp, is_array( $instance ) ? $instance : array() ); } } } } foreach ( $this->stored_cron_events() as $event ) { $events[] = $this->cron_event_document( (string) ( $event['hook'] ?? '' ), (int) ( $event['timestamp'] ?? 0 ), $event ); } return array_values( array_filter( $events ) ); } private function due_cron_event_documents( string $hook = '' ): array { $now = time(); return array_values( array_filter( array_map( static fn ( array $event ): array => $event['document'] ?? array(), $this->cron_event_documents() ), static fn ( array $document ): bool => ( isset( $document['timestamp'] ) && (int) $document['timestamp'] <= $now && ( '' === $hook || (string) ( $document['hook'] ?? '' ) === $hook ) ) ) ); } private function cron_event_document( string $hook, int $timestamp, array $event ): array { $document = array( 'hook' => $hook, 'timestamp' => $timestamp, 'schedule' => isset( $event['schedule'] ) && is_scalar( $event['schedule'] ) ? (string) $event['schedule'] : null, 'args' => isset( $event['args'] ) && is_array( $event['args'] ) ? array_values( $event['args'] ) : array(), ); return array( 'target' => array( 'hook' => $hook, 'timestamp' => $timestamp, 'args' => $document['args'], ), 'document' => $document, ); } private function store_cron_event( array $event ): void { $events = $this->stored_cron_events(); $events[] = $event; if ( function_exists( 'update_option' ) ) { update_option( 'actionpanel_ai_command_grammar_cron_events', $events ); } } private function delete_stored_cron_event( string $hook, int $timestamp ): void { $events = array_values( array_filter( $this->stored_cron_events(), static fn ( array $event ): bool => (string) ( $event['hook'] ?? '' ) !== $hook || ( 0 < $timestamp && (int) ( $event['timestamp'] ?? 0 ) !== $timestamp ) ) ); if ( function_exists( 'update_option' ) ) { update_option( 'actionpanel_ai_command_grammar_cron_events', $events ); } } private function stored_cron_events(): array { $events = function_exists( 'get_option' ) ? get_option( 'actionpanel_ai_command_grammar_cron_events', array() ) : array(); return is_array( $events ) ? array_values( array_filter( $events, 'is_array' ) ) : array(); } private function cron_target_key( array $input ): string { $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : array(); $hook = isset( $target['hook'] ) && is_scalar( $target['hook'] ) ? (string) $target['hook'] : ''; $time = isset( $target['timestamp'] ) && is_numeric( $target['timestamp'] ) ? (int) $target['timestamp'] : 0; return $hook . '@' . $time; } private function cron_document_key( array $document ): string { return (string) ( $document['hook'] ?? '' ) . '@' . (int) ( $document['timestamp'] ?? 0 ); } private function resolve_file_target( array $target ): ?string { $scope = isset( $target['scope'] ) && is_scalar( $target['scope'] ) ? (string) $target['scope'] : ''; $path = isset( $target['path'] ) && is_scalar( $target['path'] ) ? trim( (string) $target['path'], '/' ) : ''; if ( 'uploads' !== $scope || '' === $path ) { return null; } if ( str_contains( $path, '..' ) || str_starts_with( $path, '/' ) || str_contains( $path, '\\' ) ) { return null; } $base = sys_get_temp_dir() . '/actionpanel-ai-command-files'; if ( function_exists( 'wp_upload_dir' ) ) { $uploads = wp_upload_dir(); if ( is_array( $uploads ) && isset( $uploads['basedir'] ) && is_scalar( $uploads['basedir'] ) ) { $base = (string) $uploads['basedir']; } } if ( ! is_dir( $base ) && ! wp_mkdir_p( $base ) ) { return null; } $base_real = realpath( $base ); if ( false === $base_real ) { return null; } $file = rtrim( $base_real, DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR . str_replace( '/', DIRECTORY_SEPARATOR, $path ); $file_real = realpath( $file ); if ( false !== $file_real ) { return $this->path_inside_base( $file_real, $base_real ) ? $file_real : null; } $parent_real = realpath( dirname( $file ) ); if ( false !== $parent_real && ! $this->path_inside_base( $parent_real, $base_real ) ) { return null; } return $this->path_inside_base( $file, $base_real ) ? $file : null; } private function path_inside_base( string $path, string $base ): bool { $base = rtrim( str_replace( '\\', '/', $base ), '/' ); $path = str_replace( '\\', '/', $path ); return $path === $base || str_starts_with( $path, $base . '/' ); } private function url_from_input( array $input ): string { if ( isset( $input['url'] ) && is_scalar( $input['url'] ) ) { return (string) $input['url']; } $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : array(); return isset( $target['url'] ) && is_scalar( $target['url'] ) ? (string) $target['url'] : ''; } private function is_path_list( $paths ): bool { if ( ! is_array( $paths ) ) { return false; } foreach ( $paths as $path ) { if ( ! is_array( $path ) ) { return false; } foreach ( $path as $segment ) { if ( ! is_scalar( $segment ) ) { return false; } } } return true; } private function function_accepts_args( string $function_name, int $count ): bool { if ( ! function_exists( $function_name ) ) { return false; } try { return ( new \ReflectionFunction( $function_name ) )->getNumberOfParameters() >= $count; } catch ( \ReflectionException ) { return false; } } private function error( string $code, string $message, array $path = array(), array $details = array() ): array { $error = array( 'code' => $code, 'message' => $message, ); if ( array() !== $path ) { $error['path'] = $path; } if ( array() !== $details ) { $error['details'] = $details; } return array( 'error' => $error ); } }