$input Command input. * @return array{documents?: array>, error?: array} */ public function query( array $input ): array { $locator = $this->locator_from_input( $input ); if ( '' !== $locator['scope'] && ! in_array( $locator['scope'], self::SCOPES, true ) ) { return $this->failure( 'invalid_target', 'File reads are limited to the configured sandbox scopes.', 'invalid_scope' ); } if ( '' !== $locator['path'] ) { $resolved = $this->read_target( $locator, false ); return isset( $resolved['error'] ) ? $resolved : array( 'documents' => array( $resolved['document'] ) ); } $scope = '' !== $locator['scope'] ? $locator['scope'] : 'theme'; return array( 'documents' => $this->list_scope_documents( $scope, $locator ) ); } /** * Return one document for a get-shaped input. * * @param array $input Command input. * @return array{document?: array, error?: array} */ public function get( array $input ): array { $locator = $this->locator_from_input( $input ); if ( '' === $locator['scope'] || ! in_array( $locator['scope'], self::SCOPES, true ) ) { return $this->failure( 'invalid_target', 'File reads require a canonical file scope.', 'invalid_scope' ); } return $this->read_target( $locator, true ); } /** * Tail a readable diagnostic log file. * * @param string $path Absolute path. * @param int $lines Line count. */ public function tail_text_file( string $path, int $lines ): string { $lines = max( 1, min( 200, $lines ) ); $offset = 0; $size = file_exists( $path ) ? filesize( $path ) : false; if ( false !== $size && $size > 65536 ) { $offset = $size - 65536; } $buffer = is_readable( $path ) ? file_get_contents( $path, false, null, $offset ) : ''; if ( ! is_string( $buffer ) || '' === $buffer ) { return ''; } $parts = explode( "\n", rtrim( $buffer, "\r\n" ) ); return Redactor::redact( implode( "\n", array_slice( $parts, -$lines ) ) ); } /** * @param array $locator File locator. * @return array{document?: array, error?: array} */ private function read_target( array $locator, bool $include_content ): array { if ( 'manifests' === $locator['scope'] && '' !== $locator['plugin'] && '' === $locator['path'] ) { $document = $this->plugin_manifest_document( $locator['plugin'] ); return null === $document ? $this->failure( 'not_found', 'No plugin manifest matched the requested target.', 'target_not_found' ) : array( 'document' => $document ); } $path = '' !== $locator['path'] ? $locator['path'] : $locator['plugin']; if ( '' === $path ) { return $this->failure( 'invalid_target', 'File reads require target.path.', 'missing_path' ); } if ( ! $this->safe_relative_path( $path ) || $this->is_sensitive_path( $path ) ) { return $this->failure( 'invalid_target', 'File path must be relative, non-sensitive, and stay inside its sandbox scope.', 'sensitive_or_invalid_path' ); } $scope_validation = $this->validate_scope_path( $locator['scope'], $path ); if ( isset( $scope_validation['error'] ) ) { return $scope_validation; } $root = $this->scope_root( $locator ); if ( '' === $root ) { return $this->failure( 'not_found', 'The requested file scope is unavailable.', 'scope_unavailable' ); } $real = $this->resolve_under_root( $root, $path ); if ( null === $real || ! is_file( $real ) || ! is_readable( $real ) ) { return $this->failure( 'not_found', 'No readable file matched the requested target.', 'not_found_or_unreadable' ); } return array( 'document' => $this->file_document( $locator['scope'], $path, $real, $include_content ) ); } /** * @param array $locator File locator. * @return array> */ private function list_scope_documents( string $scope, array $locator ): array { if ( 'manifests' === $scope ) { return $this->plugin_manifest_documents(); } if ( in_array( $scope, array( 'mu-plugins', 'drop-ins' ), true ) ) { return $this->metadata_scope_documents( $scope ); } $root = $this->scope_root( array_merge( $locator, array( 'scope' => $scope ) ) ); if ( '' === $root || ! is_dir( $root ) ) { return array(); } $documents = array(); foreach ( new \DirectoryIterator( $root ) as $entry ) { if ( $entry->isDot() || ! $entry->isFile() ) { continue; } $path = $entry->getFilename(); if ( ! $this->safe_relative_path( $path ) || $this->is_sensitive_path( $path ) ) { continue; } $documents[] = $this->file_document( $scope, $path, $entry->getPathname(), false ); if ( count( $documents ) >= self::MAX_LIST_ITEMS ) { break; } } return $documents; } /** * @param array $input Command input. * @return array{scope: string, path: string, theme: string, plugin: string} */ private function locator_from_input( array $input ): array { $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : array(); $where = isset( $input['where'] ) && is_array( $input['where'] ) ? $input['where'] : array(); return array( 'scope' => $this->scalar_from( $target, $where, 'scope' ), 'path' => $this->scalar_from( $target, $where, 'path', 'relative_path' ), 'theme' => $this->scalar_from( $target, $where, 'theme' ), 'plugin' => $this->scalar_from( $target, $where, 'plugin' ), ); } private function scalar_from( array $primary, array $secondary, string $field, ?string $alias = null ): string { foreach ( array( $field, $alias ) as $key ) { if ( null !== $key && isset( $primary[ $key ] ) && is_scalar( $primary[ $key ] ) ) { return trim( (string) $primary[ $key ] ); } if ( null !== $key && isset( $secondary[ $key ] ) && is_scalar( $secondary[ $key ] ) ) { return trim( (string) $secondary[ $key ] ); } } return ''; } /** * @param array $locator File locator. */ private function scope_root( array $locator ): string { return match ( $locator['scope'] ) { 'theme' => $this->theme_root( (string) $locator['theme'] ), 'uploads' => $this->uploads_root(), 'mu-plugins' => defined( 'WPMU_PLUGIN_DIR' ) ? (string) WPMU_PLUGIN_DIR : $this->wp_content_path( 'mu-plugins' ), 'drop-ins' => defined( 'WP_CONTENT_DIR' ) ? (string) WP_CONTENT_DIR : $this->wp_content_path( '' ), 'manifests' => defined( 'WP_PLUGIN_DIR' ) ? (string) WP_PLUGIN_DIR : $this->wp_content_path( 'plugins' ), default => '', }; } private function theme_root( string $theme_slug ): string { $theme = null; if ( function_exists( 'wp_get_theme' ) ) { $theme = '' !== $theme_slug && $this->function_accepts_args( 'wp_get_theme', 1 ) ? wp_get_theme( $theme_slug ) : wp_get_theme(); } if ( is_object( $theme ) && method_exists( $theme, 'get_stylesheet_directory' ) ) { return (string) $theme->get_stylesheet_directory(); } $slug = '' !== $theme_slug ? $theme_slug : ( function_exists( 'get_stylesheet' ) ? (string) get_stylesheet() : '' ); return '' === $slug ? '' : $this->wp_content_path( 'themes/' . $slug ); } 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 uploads_root(): string { $uploads = function_exists( 'wp_get_upload_dir' ) ? wp_get_upload_dir() : ( function_exists( 'wp_upload_dir' ) ? wp_upload_dir() : array() ); if ( is_array( $uploads ) && isset( $uploads['basedir'] ) && is_scalar( $uploads['basedir'] ) ) { return (string) $uploads['basedir']; } return sys_get_temp_dir() . '/actionpanel-ai-command-files'; } private function wp_content_path( string $suffix ): string { $base = defined( 'WP_CONTENT_DIR' ) ? (string) WP_CONTENT_DIR : rtrim( (string) ABSPATH, '/\\' ) . '/wp-content'; return '' === $suffix ? $base : rtrim( $base, '/\\' ) . '/' . ltrim( $suffix, '/\\' ); } private function safe_relative_path( string $path ): bool { return '' !== trim( $path ) && ! str_contains( $path, "\0" ) && ! str_contains( $path, '..' ) && ! str_starts_with( $path, '/' ) && ! str_contains( $path, '\\' ) && ! preg_match( '#^[a-z][a-z0-9+\-.]*://#i', $path ); } private function validate_scope_path( string $scope, string $path ): array { if ( 'manifests' === $scope && ! in_array( $path, $this->plugin_metadata_paths(), true ) ) { return $this->failure( 'invalid_target', 'Plugin manifest reads are limited to plugin header and readme metadata files.', 'manifest_path_not_allowed' ); } if ( 'drop-ins' === $scope && ! in_array( $path, $this->canonical_dropin_files(), true ) ) { return $this->failure( 'invalid_target', 'Drop-in reads are limited to canonical WordPress drop-in files.', 'dropin_path_not_allowed' ); } return array( 'data' => array( 'valid' => true ) ); } private function is_sensitive_path( string $path ): bool { $normalized = strtolower( '/' . trim( str_replace( '\\', '/', $path ), '/' ) ); $basename = strtolower( basename( $normalized ) ); if ( '' === $basename || preg_match( '/^wp-config(?:-[\w.\-]+)?\.php$/i', $basename ) ) { return true; } if ( in_array( $basename, self::DENIED_BASENAMES, true ) ) { return true; } if ( str_starts_with( $basename, '.' ) && '.htaccess' !== $basename ) { return true; } $ext = strtolower( pathinfo( $basename, PATHINFO_EXTENSION ) ); if ( in_array( $ext, self::DENIED_EXTENSIONS, true ) ) { return true; } foreach ( self::DENIED_PATH_SEGMENTS as $segment ) { if ( str_contains( $normalized, '/' . strtolower( $segment ) . '/' ) ) { return true; } } return false; } private function resolve_under_root( string $root, string $relative ): ?string { $root_real = realpath( $root ); if ( false === $root_real || ! is_dir( $root_real ) ) { return null; } $root_real = rtrim( str_replace( '\\', '/', $root_real ), '/' ); $dir_real = realpath( $root_real . '/' . dirname( $relative ) ); if ( false === $dir_real ) { return null; } $dir_real = rtrim( str_replace( '\\', '/', $dir_real ), '/' ); if ( $dir_real !== $root_real && ! str_starts_with( $dir_real, $root_real . '/' ) ) { return null; } return $dir_real . '/' . basename( $relative ); } private function file_document( string $scope, string $path, string $real, bool $include_content ): array { $size = filesize( $real ); $mtime = filemtime( $real ); $hash = sha1_file( $real ); $document = array( 'identity' => array( 'scope' => $scope, 'path' => $path, ), 'metadata' => array( 'readable' => true, 'size' => false === $size ? 0 : (int) $size, 'mtime' => false === $mtime ? null : (int) $mtime, 'mtime_iso' => false === $mtime ? null : gmdate( 'c', (int) $mtime ), 'content_hash' => false === $hash ? '' : substr( $hash, 0, 12 ), ), 'snippet' => '', ); if ( $this->text_snippet_allowed( $path, false === $size ? 0 : (int) $size ) ) { $contents = file_get_contents( $real, false, null, 0, self::MAX_SNIPPET_BYTES ); $document['snippet'] = is_string( $contents ) && preg_match( '//u', $contents ) ? Redactor::redact( $contents ) : ''; $document['metadata']['truncated'] = false !== $size && $size > self::MAX_SNIPPET_BYTES; if ( $include_content ) { $full = file_get_contents( $real ); $document['content'] = is_string( $full ) && preg_match( '//u', $full ) ? Redactor::redact( $full ) : ''; } } return $document; } private function text_snippet_allowed( string $path, int $size ): bool { if ( $size > 256 * 1024 ) { return false; } $basename = strtolower( basename( $path ) ); $ext = strtolower( pathinfo( $basename, PATHINFO_EXTENSION ) ); return '.htaccess' === $basename || in_array( $ext, self::ALLOWED_TEXT_EXTENSIONS, true ); } /** * @return array> */ private function plugin_manifest_documents(): array { $this->ensure_plugin_inventory_functions(); $plugins = function_exists( 'get_plugins' ) ? get_plugins() : array(); $documents = array(); foreach ( $plugins as $plugin => $_metadata ) { $document = $this->plugin_manifest_document( (string) $plugin ); if ( null !== $document ) { $documents[] = $document; } } return $documents; } private function plugin_manifest_document( string $plugin ): ?array { $this->ensure_plugin_inventory_functions(); $plugins = function_exists( 'get_plugins' ) ? get_plugins() : array(); if ( ! isset( $plugins[ $plugin ] ) || ! is_array( $plugins[ $plugin ] ) ) { return null; } $metadata = $plugins[ $plugin ]; return array( 'identity' => array( 'scope' => 'manifests', 'path' => $plugin, 'plugin' => $plugin, ), 'metadata' => Redactor::redact_recursive( array( 'name' => (string) ( $metadata['Name'] ?? basename( $plugin ) ), 'version' => (string) ( $metadata['Version'] ?? '' ), 'description' => (string) ( $metadata['Description'] ?? '' ), 'author' => (string) ( $metadata['AuthorName'] ?? $metadata['Author'] ?? '' ), ) ), 'snippet' => '', ); } /** * @return array */ private function plugin_metadata_paths(): array { $this->ensure_plugin_inventory_functions(); $plugins = function_exists( 'get_plugins' ) ? get_plugins() : array(); $paths = array(); foreach ( array_keys( is_array( $plugins ) ? $plugins : array() ) as $plugin ) { $plugin = trim( str_replace( '\\', '/', (string) $plugin ), '/' ); if ( '' === $plugin ) { continue; } $paths[ $plugin ] = true; $directory = dirname( $plugin ); if ( '.' === $directory ) { continue; } $prefix = rtrim( $directory, '/' ) . '/'; $paths[ $prefix . 'readme.txt' ] = true; $paths[ $prefix . 'readme.md' ] = true; } return array_keys( $paths ); } /** * @return array> */ private function metadata_scope_documents( string $scope ): array { $function = 'mu-plugins' === $scope ? 'get_mu_plugins' : 'get_dropins'; $this->ensure_plugin_inventory_functions(); if ( ! function_exists( $function ) ) { return array(); } $items = $function(); $documents = array(); foreach ( is_array( $items ) ? $items : array() as $path => $metadata ) { $documents[] = array( 'identity' => array( 'scope' => $scope, 'path' => (string) $path, ), 'metadata' => Redactor::redact_recursive( is_array( $metadata ) ? $metadata : array() ), 'snippet' => '', ); } return $documents; } /** * @return array */ private function canonical_dropin_files(): array { $this->ensure_plugin_inventory_functions(); if ( function_exists( '_get_dropins' ) ) { $dropins = _get_dropins(); return array_values( array_map( 'strval', array_keys( is_array( $dropins ) ? $dropins : array() ) ) ); } if ( function_exists( 'get_dropins' ) ) { $dropins = get_dropins(); return array_values( array_map( 'strval', array_keys( is_array( $dropins ) ? $dropins : array() ) ) ); } return array( 'advanced-cache.php', 'db.php', 'db-error.php', 'install.php', 'maintenance.php', 'object-cache.php', 'sunrise.php', 'wp-cache-config.php', ); } private function ensure_plugin_inventory_functions(): void { if ( function_exists( 'get_plugins' ) && function_exists( 'get_mu_plugins' ) && function_exists( 'get_dropins' ) ) { return; } if ( defined( 'ABSPATH' ) ) { $plugin_file = ABSPATH . 'wp-admin/includes/plugin.php'; if ( file_exists( $plugin_file ) ) { require_once $plugin_file; } } } /** * @return array{error: array} */ private function failure( string $code, string $message, string $reason ): array { return array( 'error' => array( 'code' => $code, 'message' => $message, 'details' => array( 'reason' => $reason ), ), ); } }