diff --git a/includes/abilities-manifest.php b/includes/abilities-manifest.php
index 975ab7b..9708167 100644
--- a/includes/abilities-manifest.php
+++ b/includes/abilities-manifest.php
@@ -67,12 +67,6 @@ function agentic_admin_core_abilities(): array {
// Knowledge.
'web-search' => 'agentic_admin_register_web_search',
- // RAG infrastructure (used by the knowledge base — always on).
- 'schema-extract' => 'agentic_admin_register_schema_extract',
- 'wp-api-extract' => 'agentic_admin_register_wp_api_extract',
- 'docs-extract' => 'agentic_admin_register_docs_extract',
- 'codebase-extract' => 'agentic_admin_register_codebase_extract',
-
// Plugin abilities platform — lets the assistant discover and run
// abilities that other plugins register via the WordPress Abilities API.
'discover-plugin-abilities' => 'agentic_admin_register_discover_plugin_abilities',
@@ -84,14 +78,12 @@ function agentic_admin_core_abilities(): array {
* Local-only abilities — registered server-side, but the JS layer hides
* them from the LLM whenever an external AI provider is active.
*
- * Note: wp-config-list is JS-only (no PHP register function) so it lives
- * only in the JS manifest.
- *
* @return array
*/
function agentic_admin_local_only_abilities(): array {
return array(
- 'read-file' => 'agentic_admin_register_read_file',
+ 'read-file' => 'agentic_admin_register_read_file',
+ 'wp-config-list' => 'agentic_admin_register_wp_config_list',
);
}
diff --git a/includes/abilities/codebase-extract.php b/includes/abilities/codebase-extract.php
deleted file mode 100644
index 0b9f8b4..0000000
--- a/includes/abilities/codebase-extract.php
+++ /dev/null
@@ -1,522 +0,0 @@
- __( 'Extract Codebase', 'agentic-admin' ),
- 'description' => __( 'Extract code chunks from active theme and plugins for indexing.', 'agentic-admin' ),
- 'category' => 'sre-tools',
- 'input_schema' => array(
- 'type' => 'object',
- 'default' => array(
- 'offset' => 0,
- 'limit' => 50,
- ),
- 'properties' => array(
- 'offset' => array(
- 'type' => 'integer',
- 'default' => 0,
- 'description' => __( 'File offset for pagination.', 'agentic-admin' ),
- ),
- 'limit' => array(
- 'type' => 'integer',
- 'default' => 50,
- 'description' => __( 'Max files per page.', 'agentic-admin' ),
- ),
- ),
- 'additionalProperties' => false,
- ),
- 'output_schema' => array(
- 'type' => 'object',
- 'properties' => array(
- 'chunks' => array(
- 'type' => 'array',
- 'description' => __( 'Code chunks with path, line range, and content.', 'agentic-admin' ),
- ),
- 'total_files' => array(
- 'type' => 'integer',
- 'description' => __( 'Total number of scannable files.', 'agentic-admin' ),
- ),
- 'has_more' => array(
- 'type' => 'boolean',
- 'description' => __( 'Whether more pages are available.', 'agentic-admin' ),
- ),
- ),
- ),
- 'execute_callback' => 'agentic_admin_execute_codebase_extract',
- 'permission_callback' => function () {
- return current_user_can( 'manage_options' );
- },
- 'meta' => array(
- 'show_in_rest' => true,
- 'annotations' => array(
- 'readonly' => true,
- 'destructive' => false,
- 'idempotent' => true,
- ),
- ),
- ),
- // JS configuration for chat interface.
- array(
- 'keywords' => array( 'extract', 'codebase', 'code', 'source' ),
- 'initialMessage' => __( 'Extracting code from your site...', 'agentic-admin' ),
- )
- );
-}
-
-/**
- * Collect all scannable files from active theme and plugins.
- *
- * @return string[] Array of absolute file paths.
- */
-function agentic_admin_collect_code_files(): array {
- $files = array();
-
- // Skip directories.
- $skip_dirs = array( 'node_modules', 'vendor', 'build', 'dist', '.git', 'tests', 'test' );
-
- // Skip file patterns.
- $skip_patterns = array( '.min.js', '.min.css', '.map', '.lock' );
-
- // Allowed extensions.
- $extensions = array( 'php', 'js' );
-
- // Max file size: 100KB.
- $max_size = 100 * 1024;
-
- // 1. Active theme.
- $theme_dir = get_stylesheet_directory();
- if ( is_dir( $theme_dir ) ) {
- $files = array_merge(
- $files,
- agentic_admin_scan_directory( $theme_dir, $extensions, $skip_dirs, $skip_patterns, $max_size )
- );
- }
-
- // 2. Active plugins (exclude agentic-admin itself).
- $active_plugins = get_option( 'active_plugins', array() );
- $plugins_dir = WP_PLUGIN_DIR;
-
- foreach ( $active_plugins as $plugin_file ) {
- $plugin_slug = dirname( $plugin_file );
-
- // Skip single-file plugins (no directory).
- if ( '.' === $plugin_slug ) {
- // Include the single file directly.
- $single_file = $plugins_dir . '/' . $plugin_file;
- if ( file_exists( $single_file ) && filesize( $single_file ) <= $max_size ) {
- $files[] = $single_file;
- }
- continue;
- }
-
- // Skip ourselves.
- if ( 'agentic-admin' === $plugin_slug ) {
- continue;
- }
-
- $plugin_dir = $plugins_dir . '/' . $plugin_slug;
- if ( is_dir( $plugin_dir ) ) {
- $files = array_merge(
- $files,
- agentic_admin_scan_directory( $plugin_dir, $extensions, $skip_dirs, $skip_patterns, $max_size )
- );
- }
- }
-
- sort( $files );
- return $files;
-}
-
-/**
- * Recursively scan a directory for code files.
- *
- * @param string $dir Directory to scan.
- * @param string[] $extensions Allowed file extensions.
- * @param string[] $skip_dirs Directory names to skip.
- * @param string[] $skip_patterns File name patterns to skip.
- * @param int $max_size Maximum file size in bytes.
- * @param int $depth Current recursion depth.
- * @return string[] Array of file paths.
- */
-function agentic_admin_scan_directory(
- string $dir,
- array $extensions,
- array $skip_dirs,
- array $skip_patterns,
- int $max_size,
- int $depth = 0
-): array {
- $files = array();
-
- if ( $depth > 8 || ! is_dir( $dir ) || ! is_readable( $dir ) ) {
- return $files;
- }
-
- $iterator = new DirectoryIterator( $dir );
-
- foreach ( $iterator as $item ) {
- if ( $item->isDot() ) {
- continue;
- }
-
- $name = $item->getFilename();
-
- if ( $item->isDir() ) {
- if ( in_array( $name, $skip_dirs, true ) ) {
- continue;
- }
- $files = array_merge(
- $files,
- agentic_admin_scan_directory(
- $item->getPathname(),
- $extensions,
- $skip_dirs,
- $skip_patterns,
- $max_size,
- $depth + 1
- )
- );
- continue;
- }
-
- if ( ! $item->isFile() ) {
- continue;
- }
-
- // Check extension.
- $ext = strtolower( $item->getExtension() );
- if ( ! in_array( $ext, $extensions, true ) ) {
- continue;
- }
-
- // Check skip patterns.
- $skip = false;
- foreach ( $skip_patterns as $pattern ) {
- if ( str_ends_with( $name, $pattern ) ) {
- $skip = true;
- break;
- }
- }
- if ( $skip ) {
- continue;
- }
-
- // Check size.
- if ( $item->getSize() > $max_size ) {
- continue;
- }
-
- $files[] = $item->getPathname();
- }
-
- return $files;
-}
-
-/**
- * Chunk a file's content by function/class boundaries.
- *
- * @param string $file_path Absolute path to the file.
- * @param string $base_path Base path to strip for relative paths.
- * @return array Array of chunk arrays with path, start_line, end_line, content, type.
- */
-function agentic_admin_chunk_file( string $file_path, string $base_path ): array {
- global $wp_filesystem;
- if ( ! $wp_filesystem ) {
- require_once ABSPATH . 'wp-admin/includes/file.php';
- WP_Filesystem();
- }
- $content = $wp_filesystem->get_contents( $file_path );
- if ( false === $content ) {
- return array();
- }
-
- $relative_path = str_replace( $base_path, '', $file_path );
- $relative_path = ltrim( $relative_path, '/' );
- $lines = explode( "\n", $content );
- $total_lines = count( $lines );
-
- // For small files (< 50 lines), return as a single chunk.
- if ( $total_lines < 50 ) {
- return array(
- array(
- 'path' => $relative_path,
- 'start_line' => 1,
- 'end_line' => $total_lines,
- 'content' => $content,
- 'type' => 'file',
- ),
- );
- }
-
- $ext = strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) );
- $chunks = array();
-
- if ( 'php' === $ext ) {
- $chunks = agentic_admin_chunk_php( $lines, $relative_path );
- } elseif ( 'js' === $ext ) {
- $chunks = agentic_admin_chunk_js( $lines, $relative_path );
- }
-
- // Fallback: if no chunks found, split into ~40-line blocks.
- if ( empty( $chunks ) ) {
- $chunks = agentic_admin_chunk_by_lines( $lines, $relative_path, 40 );
- }
-
- return $chunks;
-}
-
-/**
- * Chunk PHP code by function/class boundaries.
- *
- * @param string[] $lines Array of lines.
- * @param string $relative_path Relative file path.
- * @return array Array of chunks.
- */
-function agentic_admin_chunk_php( array $lines, string $relative_path ): array {
- $chunks = array();
- $current_start = 0;
- $brace_depth = 0;
- $in_block = false;
- $block_type = 'code';
- $line_count = count( $lines );
-
- for ( $i = 0; $i < $line_count; $i++ ) {
- $line = $lines[ $i ];
- $trimmed = trim( $line );
-
- // Detect function/class/interface/trait start.
- if ( ! $in_block && preg_match( '/^(abstract\s+)?(class|interface|trait|function)\s+/i', $trimmed ) ) {
- // Save preceding code as a chunk if significant.
- if ( $i - $current_start > 2 ) {
- $chunk_lines = array_slice( $lines, $current_start, $i - $current_start );
- $chunk_text = implode( "\n", $chunk_lines );
- if ( strlen( trim( $chunk_text ) ) > 20 ) {
- $chunks[] = array(
- 'path' => $relative_path,
- 'start_line' => $current_start + 1,
- 'end_line' => $i,
- 'content' => $chunk_text,
- 'type' => 'code',
- );
- }
- }
- $current_start = $i;
- $in_block = true;
- $brace_depth = 0;
-
- if ( preg_match( '/^(abstract\s+)?(class|interface|trait)\s+/i', $trimmed ) ) {
- $block_type = 'class';
- } else {
- $block_type = 'function';
- }
- }
-
- // Track brace depth.
- $brace_depth += substr_count( $line, '{' );
- $brace_depth -= substr_count( $line, '}' );
-
- // End of block: braces balanced back to 0.
- if ( $in_block && $brace_depth <= 0 && strpos( $line, '}' ) !== false ) {
- $chunk_lines = array_slice( $lines, $current_start, $i - $current_start + 1 );
- $chunks[] = array(
- 'path' => $relative_path,
- 'start_line' => $current_start + 1,
- 'end_line' => $i + 1,
- 'content' => implode( "\n", $chunk_lines ),
- 'type' => $block_type,
- );
- $current_start = $i + 1;
- $in_block = false;
- $brace_depth = 0;
- }
- }
-
- // Remaining lines.
- if ( $current_start < $line_count ) {
- $chunk_lines = array_slice( $lines, $current_start );
- $chunk_text = implode( "\n", $chunk_lines );
- if ( strlen( trim( $chunk_text ) ) > 20 ) {
- $chunks[] = array(
- 'path' => $relative_path,
- 'start_line' => $current_start + 1,
- 'end_line' => $line_count,
- 'content' => $chunk_text,
- 'type' => 'code',
- );
- }
- }
-
- return $chunks;
-}
-
-/**
- * Chunk JavaScript code by function/class/export boundaries.
- *
- * @param string[] $lines Array of lines.
- * @param string $relative_path Relative file path.
- * @return array Array of chunks.
- */
-function agentic_admin_chunk_js( array $lines, string $relative_path ): array {
- $chunks = array();
- $current_start = 0;
- $brace_depth = 0;
- $in_block = false;
- $block_type = 'code';
- $line_count = count( $lines );
-
- for ( $i = 0; $i < $line_count; $i++ ) {
- $line = $lines[ $i ];
- $trimmed = trim( $line );
-
- // Detect function/class/export start.
- if ( ! $in_block && preg_match( '/^(export\s+)?(default\s+)?(async\s+)?(function|class|const|let|var)\s+/i', $trimmed ) ) {
- // Save preceding code as a chunk if significant.
- if ( $i - $current_start > 2 ) {
- $chunk_lines = array_slice( $lines, $current_start, $i - $current_start );
- $chunk_text = implode( "\n", $chunk_lines );
- if ( strlen( trim( $chunk_text ) ) > 20 ) {
- $chunks[] = array(
- 'path' => $relative_path,
- 'start_line' => $current_start + 1,
- 'end_line' => $i,
- 'content' => $chunk_text,
- 'type' => 'code',
- );
- }
- }
- $current_start = $i;
- $in_block = true;
- $brace_depth = 0;
-
- if ( preg_match( '/class\s+/i', $trimmed ) ) {
- $block_type = 'class';
- } else {
- $block_type = 'function';
- }
- }
-
- // Track brace depth.
- $brace_depth += substr_count( $line, '{' );
- $brace_depth -= substr_count( $line, '}' );
-
- // End of block: braces balanced back to 0.
- if ( $in_block && $brace_depth <= 0 && strpos( $line, '}' ) !== false ) {
- $chunk_lines = array_slice( $lines, $current_start, $i - $current_start + 1 );
- $chunks[] = array(
- 'path' => $relative_path,
- 'start_line' => $current_start + 1,
- 'end_line' => $i + 1,
- 'content' => implode( "\n", $chunk_lines ),
- 'type' => $block_type,
- );
- $current_start = $i + 1;
- $in_block = false;
- $brace_depth = 0;
- }
- }
-
- // Remaining lines.
- if ( $current_start < $line_count ) {
- $chunk_lines = array_slice( $lines, $current_start );
- $chunk_text = implode( "\n", $chunk_lines );
- if ( strlen( trim( $chunk_text ) ) > 20 ) {
- $chunks[] = array(
- 'path' => $relative_path,
- 'start_line' => $current_start + 1,
- 'end_line' => $line_count,
- 'content' => $chunk_text,
- 'type' => 'code',
- );
- }
- }
-
- return $chunks;
-}
-
-/**
- * Chunk content into fixed-size line blocks.
- *
- * @param string[] $lines Array of lines.
- * @param string $relative_path Relative file path.
- * @param int $block_size Lines per block.
- * @return array Array of chunks.
- */
-function agentic_admin_chunk_by_lines( array $lines, string $relative_path, int $block_size = 40 ): array {
- $chunks = array();
- $total = count( $lines );
-
- for ( $i = 0; $i < $total; $i += $block_size ) {
- $chunk_lines = array_slice( $lines, $i, $block_size );
- $chunk_text = implode( "\n", $chunk_lines );
-
- if ( strlen( trim( $chunk_text ) ) > 20 ) {
- $chunks[] = array(
- 'path' => $relative_path,
- 'start_line' => $i + 1,
- 'end_line' => min( $i + $block_size, $total ),
- 'content' => $chunk_text,
- 'type' => 'block',
- );
- }
- }
-
- return $chunks;
-}
-
-/**
- * Execute the codebase-extract ability.
- *
- * @param array $input Input parameters.
- * @return array
- */
-function agentic_admin_execute_codebase_extract( array $input = array() ): array {
- $offset = isset( $input['offset'] ) ? max( 0, absint( $input['offset'] ) ) : 0;
- $limit = isset( $input['limit'] ) ? min( absint( $input['limit'] ), 100 ) : 50;
-
- $all_files = agentic_admin_collect_code_files();
- $total_files = count( $all_files );
-
- // Paginate files.
- $page_files = array_slice( $all_files, $offset, $limit );
- $has_more = ( $offset + $limit ) < $total_files;
-
- // Determine base path for relative paths.
- $wp_content_dir = WP_CONTENT_DIR;
-
- $chunks = array();
-
- foreach ( $page_files as $file_path ) {
- $file_chunks = agentic_admin_chunk_file( $file_path, $wp_content_dir );
- foreach ( $file_chunks as $chunk ) {
- $chunks[] = $chunk;
- }
- }
-
- return array(
- 'chunks' => $chunks,
- 'total_files' => $total_files,
- 'files_page' => count( $page_files ),
- 'offset' => $offset,
- 'has_more' => $has_more,
- );
-}
diff --git a/includes/abilities/docs-extract.php b/includes/abilities/docs-extract.php
deleted file mode 100644
index 93c8854..0000000
--- a/includes/abilities/docs-extract.php
+++ /dev/null
@@ -1,181 +0,0 @@
- __( 'Extract Reference Docs', 'agentic-admin' ),
- 'description' => __( 'Extract bundled WordPress reference documentation for knowledge base indexing.', 'agentic-admin' ),
- 'category' => 'sre-tools',
- 'input_schema' => array(
- 'type' => 'object',
- 'default' => array(),
- 'properties' => array(),
- 'additionalProperties' => false,
- ),
- 'output_schema' => array(
- 'type' => 'object',
- 'properties' => array(
- 'chunks' => array(
- 'type' => 'array',
- 'description' => __( 'Doc section chunks.', 'agentic-admin' ),
- ),
- 'total_files' => array(
- 'type' => 'integer',
- 'description' => __( 'Total doc files processed.', 'agentic-admin' ),
- ),
- ),
- ),
- 'execute_callback' => 'agentic_admin_execute_docs_extract',
- 'permission_callback' => function () {
- return current_user_can( 'manage_options' );
- },
- 'meta' => array(
- 'show_in_rest' => true,
- 'annotations' => array(
- 'readonly' => true,
- 'destructive' => false,
- 'idempotent' => true,
- ),
- ),
- ),
- // JS configuration for chat interface.
- array(
- 'keywords' => array( 'docs', 'documentation', 'reference', 'extract docs' ),
- 'initialMessage' => __( 'Extracting reference documentation...', 'agentic-admin' ),
- )
- );
-}
-
-/**
- * Chunk a markdown file by heading boundaries (## or ###).
- *
- * @param string $content File content.
- * @param string $file_name File name for chunk path.
- * @return array Array of chunk arrays.
- */
-function agentic_admin_chunk_markdown( string $content, string $file_name ): array {
- $lines = explode( "\n", $content );
- $chunks = array();
- $current = array();
- $current_heading = '';
- $start_line = 1;
-
- foreach ( $lines as $idx => $line ) {
- // Detect ## or ### headings (not # which is the doc title).
- if ( preg_match( '/^#{2,3}\s+(.+)$/', $line, $matches ) ) {
- // Save previous section if it has content.
- if ( ! empty( $current ) ) {
- $section_text = implode( "\n", $current );
- if ( strlen( trim( $section_text ) ) > 20 ) {
- $chunks[] = array(
- 'path' => 'docs://' . $file_name,
- 'start_line' => $start_line,
- 'end_line' => $idx,
- 'content' => $section_text,
- 'type' => 'docs',
- );
- }
- }
- $current = array( $line );
- $current_heading = $matches[1];
- $start_line = $idx + 1;
- } else {
- $current[] = $line;
- }
- }
-
- // Save last section.
- if ( ! empty( $current ) ) {
- $section_text = implode( "\n", $current );
- if ( strlen( trim( $section_text ) ) > 20 ) {
- $chunks[] = array(
- 'path' => 'docs://' . $file_name,
- 'start_line' => $start_line,
- 'end_line' => count( $lines ),
- 'content' => $section_text,
- 'type' => 'docs',
- );
- }
- }
-
- return $chunks;
-}
-
-/**
- * Execute the docs-extract ability.
- *
- * @param array $input Input parameters (unused).
- * @return array
- */
-function agentic_admin_execute_docs_extract( array $input = array() ): array {
- global $wp_filesystem;
-
- if ( ! $wp_filesystem ) {
- require_once ABSPATH . 'wp-admin/includes/file.php';
- WP_Filesystem();
- }
-
- $docs_dir = AGENTIC_ADMIN_PLUGIN_DIR . 'docs/knowledge/';
-
- if ( ! is_dir( $docs_dir ) ) {
- return array(
- 'success' => false,
- 'message' => 'Knowledge docs directory not found.',
- 'chunks' => array(),
- 'total_files' => 0,
- );
- }
-
- $md_files = glob( $docs_dir . '*.md' );
-
- if ( empty( $md_files ) ) {
- return array(
- 'success' => false,
- 'message' => 'No markdown files found in docs/knowledge/.',
- 'chunks' => array(),
- 'total_files' => 0,
- );
- }
-
- $chunks = array();
- $total_files = 0;
-
- foreach ( $md_files as $file_path ) {
- $content = $wp_filesystem->get_contents( $file_path );
-
- if ( false === $content || empty( trim( $content ) ) ) {
- continue;
- }
-
- $file_name = basename( $file_path, '.md' );
- $file_chunks = agentic_admin_chunk_markdown( $content, $file_name );
- $chunks = array_merge( $chunks, $file_chunks );
- ++$total_files;
- }
-
- return array(
- 'success' => true,
- 'chunks' => $chunks,
- 'total_files' => $total_files,
- );
-}
diff --git a/includes/abilities/read-file.php b/includes/abilities/read-file.php
index 7c90976..17e8fbc 100644
--- a/includes/abilities/read-file.php
+++ b/includes/abilities/read-file.php
@@ -23,12 +23,12 @@ function agentic_admin_register_read_file(): void {
// PHP configuration for WordPress Abilities API.
array(
'label' => __( 'Read File', 'agentic-admin' ),
- 'description' => __( 'Read a WordPress file with sensitive data (credentials, keys, salts) automatically redacted.', 'agentic-admin' ),
+ 'description' => __( 'Read a WordPress file. Files that can hold credentials, keys, or salts (such as wp-config.php) are never read.', 'agentic-admin' ),
'category' => 'sre-tools',
'input_schema' => array(
'type' => 'object',
'default' => array(
- 'file_path' => 'wp-config.php',
+ 'file_path' => '.htaccess',
'offset' => 0,
'lines' => 100,
),
@@ -36,7 +36,7 @@ function agentic_admin_register_read_file(): void {
'properties' => array(
'file_path' => array(
'type' => 'string',
- 'description' => __( 'Path to the file relative to ABSPATH (e.g. wp-config.php or wp-content/themes/mytheme/functions.php).', 'agentic-admin' ),
+ 'description' => __( 'Path to the file relative to the site root (e.g. .htaccess or wp-content/themes/mytheme/functions.php).', 'agentic-admin' ),
),
'offset' => array(
'type' => 'integer',
@@ -67,7 +67,7 @@ function agentic_admin_register_read_file(): void {
),
'file_path' => array(
'type' => 'string',
- 'description' => __( 'Resolved file path relative to ABSPATH.', 'agentic-admin' ),
+ 'description' => __( 'Resolved file path relative to the site root.', 'agentic-admin' ),
),
'content' => array(
'type' => 'string',
@@ -102,7 +102,7 @@ function agentic_admin_register_read_file(): void {
),
// JS configuration for chat interface.
array(
- 'keywords' => array( 'read', 'show', 'view', 'open', 'display', 'cat', 'file', 'config', 'htaccess', 'functions.php', 'wp-config', 'wp-config.php', 'contents', 'source' ),
+ 'keywords' => array( 'read', 'show', 'view', 'open', 'display', 'cat', 'file', 'config', 'htaccess', 'functions.php', 'contents', 'source' ),
'initialMessage' => __( "I'll read that file for you...", 'agentic-admin' ),
)
);
@@ -127,8 +127,12 @@ function agentic_admin_execute_read_file( array $input = array() ): array {
);
}
- // Resolve the absolute path: support both absolute paths and paths relative to ABSPATH.
- $abspath = wp_normalize_path( ABSPATH );
+ // Resolve the absolute path: support both absolute paths and paths
+ // relative to the site root (get_home_path()).
+ if ( ! function_exists( 'get_home_path' ) ) {
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ }
+ $site_root = trailingslashit( wp_normalize_path( get_home_path() ) );
// Detect absolute paths: starts with / (Unix) or drive letter (Windows e.g. C:\).
$is_absolute = ( '/' === $raw_path[0] ) || ( strlen( $raw_path ) > 2 && ':' === $raw_path[1] );
@@ -137,13 +141,13 @@ function agentic_admin_execute_read_file( array $input = array() ): array {
$absolute_path = wp_normalize_path( $raw_path );
} else {
// For bare filenames with no directory component, try common WordPress locations.
- // e.g. "functions.php" → active theme, "style.css" → active theme, "wp-config.php" → ABSPATH.
+ // e.g. "functions.php" → active theme, "style.css" → active theme, ".htaccess" → site root.
$bare_name = basename( $raw_path );
if ( $bare_name === $raw_path && false === strpos( $raw_path, '/' ) ) {
- $absolute_path = agentic_admin_resolve_bare_filename( $raw_path, $abspath );
+ $absolute_path = agentic_admin_resolve_bare_filename( $raw_path, $site_root );
} else {
// Strip any leading slash before joining.
- $absolute_path = wp_normalize_path( $abspath . ltrim( $raw_path, '/\\' ) );
+ $absolute_path = wp_normalize_path( $site_root . ltrim( $raw_path, '/\\' ) );
}
}
@@ -161,11 +165,11 @@ function agentic_admin_execute_read_file( array $input = array() ): array {
);
}
- $real_path = wp_normalize_path( $real_path );
- $real_abspath = wp_normalize_path( realpath( ABSPATH ) );
+ $real_path = wp_normalize_path( $real_path );
+ $real_root = trailingslashit( wp_normalize_path( (string) realpath( $site_root ) ) );
- // Security: ensure the file is within the WordPress root directory.
- if ( ! str_starts_with( $real_path, $real_abspath ) ) {
+ // Security: ensure the file is within the site root directory.
+ if ( '/' === $real_root || ! str_starts_with( $real_path, $real_root ) ) {
return array(
'success' => false,
'message' => __( 'Access denied: file is outside the WordPress root directory.', 'agentic-admin' ),
@@ -186,6 +190,16 @@ function agentic_admin_execute_read_file( array $input = array() ): array {
);
}
+ // Security: never return files that hold credentials, keys, or salts.
+ // These are refused outright rather than redacted, because pattern-based
+ // redaction cannot reliably catch every way a secret can be written.
+ if ( agentic_admin_is_secret_file( $real_path ) ) {
+ return array(
+ 'success' => false,
+ 'message' => __( 'Access denied: this file can contain credentials, authentication keys, or salts, so it is never read. Use the wp-config-list ability to see non-sensitive configuration constants.', 'agentic-admin' ),
+ );
+ }
+
// Read the file lines.
$file = new \SplFileObject( $real_path, 'r' );
$file->seek( PHP_INT_MAX );
@@ -211,7 +225,7 @@ function agentic_admin_execute_read_file( array $input = array() ): array {
// Redact sensitive values before the content leaves PHP.
list( $content, $was_redacted ) = agentic_admin_redact_sensitive_data( $content );
- $relative_path = ltrim( str_replace( $real_abspath, '', $real_path ), '/' );
+ $relative_path = substr( $real_path, strlen( $real_root ) );
return array(
'success' => true,
@@ -231,18 +245,18 @@ function agentic_admin_execute_read_file( array $input = array() ): array {
/**
* Resolve a bare filename (no directory) to its most likely WordPress path.
*
- * Tries ABSPATH first, then the active theme directory, so that e.g.
- * "functions.php" resolves to the theme rather than the WordPress root.
+ * Root-level files resolve to the site root; anything else tries the active
+ * theme directory, so that e.g. "functions.php" resolves to the theme.
*
* @param string $filename Bare filename (e.g. 'functions.php').
- * @param string $abspath Normalised ABSPATH.
+ * @param string $site_root Normalised site root with trailing slash.
* @return string Absolute path to the best candidate (may not exist).
*/
-function agentic_admin_resolve_bare_filename( string $filename, string $abspath ): string {
+function agentic_admin_resolve_bare_filename( string $filename, string $site_root ): string {
// Always-at-root files.
- $root_files = array( 'wp-config.php', '.htaccess', 'robots.txt', 'wp-login.php', 'wp-cron.php', 'xmlrpc.php', 'index.php' );
+ $root_files = array( '.htaccess', 'robots.txt', 'wp-login.php', 'wp-cron.php', 'xmlrpc.php', 'index.php' );
if ( in_array( $filename, $root_files, true ) ) {
- return wp_normalize_path( $abspath . $filename );
+ return wp_normalize_path( $site_root . $filename );
}
// Theme files: check active (child) theme, then parent theme.
@@ -256,8 +270,50 @@ function agentic_admin_resolve_bare_filename( string $filename, string $abspath
}
}
- // Fall back to ABSPATH root.
- return wp_normalize_path( $abspath . $filename );
+ // Fall back to the site root.
+ return wp_normalize_path( $site_root . $filename );
+}
+
+/**
+ * Whether a file must never be returned because it can hold secrets.
+ *
+ * Matches by name first (wp-config.php and its variants, .env files,
+ * password files, private keys, database dumps), then scans the whole file
+ * for define() calls of the authentication keys and salts or the database
+ * password, so a secrets file with an unexpected name is refused too.
+ *
+ * @param string $real_path Canonical absolute path of the file.
+ * @return bool True when the file must not be read.
+ */
+function agentic_admin_is_secret_file( string $real_path ): bool {
+ $name = strtolower( basename( $real_path ) );
+
+ if ( preg_match( '/^wp-config.*\.php$/', $name ) ) {
+ return true;
+ }
+
+ if ( str_starts_with( $name, '.env' ) || in_array( $name, array( '.htpasswd', 'auth.json', '.netrc', '.pgpass', '.my.cnf' ), true ) ) {
+ return true;
+ }
+
+ if ( preg_match( '/^id_(rsa|dsa|ecdsa|ed25519)/', $name ) || preg_match( '/\.(pem|key|p12|pfx|sql|sqlite|sqlite3|db)$/', $name ) ) {
+ return true;
+ }
+
+ if ( preg_match( '#/\.(git|ssh)/#', wp_normalize_path( $real_path ) ) ) {
+ return true;
+ }
+
+ // Content check over the whole file, not just the requested line range.
+ $handle = new \SplFileObject( $real_path, 'r' );
+ while ( ! $handle->eof() ) {
+ $line = (string) $handle->fgets();
+ if ( preg_match( '/define\s*\(\s*[\'"](?:(?:SECURE_AUTH|LOGGED_IN|AUTH|NONCE)_(?:KEY|SALT)|DB_PASSWORD)[\'"]/i', $line ) ) {
+ return true;
+ }
+ }
+
+ return false;
}
/**
diff --git a/includes/abilities/schema-extract.php b/includes/abilities/schema-extract.php
deleted file mode 100644
index e7f3654..0000000
--- a/includes/abilities/schema-extract.php
+++ /dev/null
@@ -1,205 +0,0 @@
- __( 'Extract Database Schema', 'agentic-admin' ),
- 'description' => __( 'Extract database table schemas for knowledge base indexing.', 'agentic-admin' ),
- 'category' => 'sre-tools',
- 'input_schema' => array(
- 'type' => 'object',
- 'default' => array(),
- 'properties' => array(),
- 'additionalProperties' => false,
- ),
- 'output_schema' => array(
- 'type' => 'object',
- 'properties' => array(
- 'chunks' => array(
- 'type' => 'array',
- 'description' => __( 'Schema chunks with table info.', 'agentic-admin' ),
- ),
- 'total_tables' => array(
- 'type' => 'integer',
- 'description' => __( 'Total tables extracted.', 'agentic-admin' ),
- ),
- ),
- ),
- 'execute_callback' => 'agentic_admin_execute_schema_extract',
- 'permission_callback' => function () {
- return current_user_can( 'manage_options' );
- },
- 'meta' => array(
- 'show_in_rest' => true,
- 'annotations' => array(
- 'readonly' => true,
- 'destructive' => false,
- 'idempotent' => true,
- ),
- ),
- ),
- // JS configuration for chat interface.
- array(
- 'keywords' => array( 'schema', 'database', 'tables', 'extract schema' ),
- 'initialMessage' => __( 'Extracting database schema...', 'agentic-admin' ),
- )
- );
-}
-
-/**
- * Get semantic context descriptions for core WordPress tables.
- *
- * @return array Map of short table name to description.
- */
-function agentic_admin_get_core_table_context(): array {
- return array(
- 'posts' => 'Stores blog posts, pages, custom post types, and revisions. Each row is a content item with title, content, status, author, and dates.',
- 'postmeta' => 'Key-value metadata for posts. Stores custom fields, plugin data, and internal WordPress post metadata.',
- 'comments' => 'Stores comments on posts. Includes author info, content, approval status, and parent comment for threading.',
- 'commentmeta' => 'Key-value metadata for comments. Used by plugins like Akismet for spam detection data.',
- 'terms' => 'Stores taxonomy terms (categories, tags, custom taxonomy terms). Contains term name and slug.',
- 'term_taxonomy' => 'Links terms to taxonomies. Stores the taxonomy type (category, post_tag, etc.) and hierarchy (parent).',
- 'term_relationships' => 'Links posts to taxonomy terms. Maps object IDs (posts) to term_taxonomy_id entries.',
- 'termmeta' => 'Key-value metadata for taxonomy terms.',
- 'users' => 'WordPress user accounts. Stores login, display name, and registration date.',
- 'usermeta' => 'Key-value metadata for users. Stores roles, capabilities, preferences, and plugin user data.',
- 'options' => 'Site-wide settings and configuration. Key-value store for WordPress settings, plugin options, and widget data. autoload column controls which options load on every page.',
- 'links' => 'Blogroll links (legacy feature, rarely used in modern WordPress).',
- );
-}
-
-/**
- * Execute the schema-extract ability.
- *
- * Runs SHOW TABLES, then DESCRIBE + SHOW INDEX for each table.
- * Redacts sensitive columns (user_pass, user_email, etc.).
- *
- * @param array $input Input parameters (unused).
- * @return array
- */
-function agentic_admin_execute_schema_extract( array $input = array() ): array {
- global $wpdb;
-
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
- $tables = $wpdb->get_col( 'SHOW TABLES' );
-
- if ( empty( $tables ) ) {
- return array(
- 'success' => false,
- 'message' => 'No tables found.',
- 'chunks' => array(),
- 'total_tables' => 0,
- );
- }
-
- $core_context = agentic_admin_get_core_table_context();
- $sensitive_columns = array( 'user_pass', 'user_email', 'user_activation_key', 'session_tokens' );
- $prefix = $wpdb->prefix;
- $chunks = array();
-
- foreach ( $tables as $table ) {
- // Validate table name to prevent SQL injection — only allow alphanumeric + underscore.
- if ( ! preg_match( '/^[a-zA-Z0-9_]+$/', $table ) ) {
- continue;
- }
-
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
- $columns = $wpdb->get_results( "DESCRIBE `{$table}`", ARRAY_A );
-
- if ( empty( $columns ) ) {
- continue;
- }
-
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
- $indexes = $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A );
-
- // Build human-readable schema.
- $schema_lines = array();
- $schema_lines[] = "Table: {$table}";
-
- // Add semantic context for core WP tables.
- $short_name = str_replace( $prefix, '', $table );
- if ( isset( $core_context[ $short_name ] ) ) {
- $schema_lines[] = 'Purpose: ' . $core_context[ $short_name ];
- }
-
- $schema_lines[] = '';
- $schema_lines[] = 'Columns:';
-
- foreach ( $columns as $col ) {
- $col_name = $col['Field'];
-
- // Redact sensitive columns.
- if ( in_array( $col_name, $sensitive_columns, true ) ) {
- $schema_lines[] = " {$col_name}: [REDACTED] ({$col['Type']})";
- continue;
- }
-
- $nullable = 'YES' === $col['Null'] ? ', nullable' : '';
- $default = null !== $col['Default'] ? ", default: {$col['Default']}" : '';
- $key = $col['Key'] ? ", key: {$col['Key']}" : '';
- $extra = $col['Extra'] ? ", {$col['Extra']}" : '';
-
- $schema_lines[] = " {$col_name}: {$col['Type']}{$nullable}{$default}{$key}{$extra}";
- }
-
- // Add index info.
- if ( ! empty( $indexes ) ) {
- $index_groups = array();
-
- foreach ( $indexes as $idx ) {
- $idx_name = $idx['Key_name'];
- if ( ! isset( $index_groups[ $idx_name ] ) ) {
- $index_groups[ $idx_name ] = array(
- 'unique' => ! $idx['Non_unique'],
- 'columns' => array(),
- );
- }
- $index_groups[ $idx_name ]['columns'][] = $idx['Column_name'];
- }
-
- $schema_lines[] = '';
- $schema_lines[] = 'Indexes:';
-
- foreach ( $index_groups as $idx_name => $idx_info ) {
- $type = $idx_info['unique'] ? 'UNIQUE' : 'INDEX';
- $cols = implode( ', ', $idx_info['columns'] );
- $schema_lines[] = " {$idx_name} ({$type}): {$cols}";
- }
- }
-
- $chunks[] = array(
- 'path' => "db://{$table}",
- 'start_line' => 1,
- 'end_line' => count( $schema_lines ),
- 'content' => implode( "\n", $schema_lines ),
- 'type' => 'schema',
- );
- }
-
- return array(
- 'success' => true,
- 'chunks' => $chunks,
- 'total_tables' => count( $tables ),
- );
-}
diff --git a/includes/abilities/security-scan.php b/includes/abilities/security-scan.php
index 09c844f..dae5f77 100644
--- a/includes/abilities/security-scan.php
+++ b/includes/abilities/security-scan.php
@@ -95,8 +95,8 @@ function agentic_admin_execute_security_scan( array $input = array() ): array {
);
// 3. Check wp-config.php file permissions.
- $wp_config = ABSPATH . 'wp-config.php';
- if ( file_exists( $wp_config ) ) {
+ $wp_config = agentic_admin_locate_wp_config();
+ if ( '' !== $wp_config ) {
$perms = fileperms( $wp_config ) & 0777;
$is_secure = $perms <= 0640;
$checks[] = array(
@@ -109,19 +109,7 @@ function agentic_admin_execute_security_scan( array $input = array() ): array {
);
}
- // 4. Check for default auth salts.
- $salt = wp_salt( 'auth' );
- $is_default = ( 'put your unique phrase here' === $salt );
- $checks[] = array(
- 'check' => 'Authentication salts',
- 'status' => $is_default ? 'fail' : 'pass',
- 'severity' => 'critical',
- 'message' => $is_default
- ? 'Using default salts. Generate new ones immediately.'
- : 'Custom salts are configured.',
- );
-
- // 5. Check WordPress version exposure.
+ // 4. Check WordPress version exposure.
$checks[] = array(
'check' => 'Version in HTML',
'status' => has_action( 'wp_head', 'wp_generator' ) ? 'fail' : 'pass',
@@ -131,7 +119,7 @@ function agentic_admin_execute_security_scan( array $input = array() ): array {
: 'WordPress version is hidden.',
);
- // 6. Check directory listing.
+ // 5. Check directory listing.
$uploads_dir = wp_upload_dir();
$uploads_path = $uploads_dir['basedir'];
$htaccess = $uploads_path . '/.htaccess';
diff --git a/includes/abilities/security/file-scan.php b/includes/abilities/security/file-scan.php
index 79a41eb..59c3361 100644
--- a/includes/abilities/security/file-scan.php
+++ b/includes/abilities/security/file-scan.php
@@ -204,11 +204,11 @@ function agentic_admin_execute_file_scan( array $input = array() ): array {
$dirs_to_scan = array();
if ( $scan_plugins ) {
- $dirs_to_scan[] = WP_PLUGIN_DIR;
+ $dirs_to_scan[] = agentic_admin_plugins_dir();
- // Also scan must-use plugins if the directory exists.
- $mu_dir = WPMU_PLUGIN_DIR;
- if ( is_dir( $mu_dir ) ) {
+ // Also scan must-use plugins if any are installed.
+ $mu_dir = agentic_admin_mu_plugins_dir();
+ if ( '' !== $mu_dir && is_dir( $mu_dir ) ) {
$dirs_to_scan[] = $mu_dir;
}
}
@@ -286,9 +286,9 @@ function ( $current, $key, $iterator ) use ( $skip_dirs, $excluded_paths ) {
);
$normalized_dir = wp_normalize_path( $dir );
- $is_plugin_dir = wp_normalize_path( WP_PLUGIN_DIR ) === $normalized_dir;
- $mu_plugin_dir = WPMU_PLUGIN_DIR;
- $is_mu_dir = wp_normalize_path( $mu_plugin_dir ) === $normalized_dir;
+ $is_plugin_dir = agentic_admin_plugins_dir() === $normalized_dir;
+ $mu_plugin_dir = agentic_admin_mu_plugins_dir();
+ $is_mu_dir = '' !== $mu_plugin_dir && $mu_plugin_dir === $normalized_dir;
foreach ( $iterator as $file_info ) {
if ( ! $file_info->isFile() ) {
@@ -506,13 +506,17 @@ function ( $a, $b ) {
}
/**
- * Get a file path relative to wp-content for display.
+ * Get a file path relative to the content directory for display.
+ *
+ * The content directory is taken as the parent of the plugins directory;
+ * paths outside it are returned unchanged.
*
* @param string $absolute_path Absolute file path.
- * @return string Relative path from wp-content.
+ * @return string Relative path from the content directory.
*/
function agentic_admin_get_content_relative_path( string $absolute_path ): string {
- $content_dir = WP_CONTENT_DIR;
+ $absolute_path = wp_normalize_path( $absolute_path );
+ $content_dir = dirname( agentic_admin_plugins_dir() );
if ( str_starts_with( $absolute_path, $content_dir ) ) {
return substr( $absolute_path, strlen( $content_dir ) + 1 );
diff --git a/includes/abilities/security/uploads-scan.php b/includes/abilities/security/uploads-scan.php
index 87ac99e..52e79af 100644
--- a/includes/abilities/security/uploads-scan.php
+++ b/includes/abilities/security/uploads-scan.php
@@ -154,10 +154,17 @@ function agentic_admin_execute_uploads_scan( array $input = array() ): array {
);
}
- // 2. Scan WordPress root for unexpected PHP files.
+ // The site root (document root) is get_home_path(). It can differ from
+ // the WordPress directory on subdirectory installs.
+ if ( ! function_exists( 'get_home_path' ) ) {
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ }
+ $site_root = untrailingslashit( wp_normalize_path( get_home_path() ) );
+
+ // 2. Scan the site root for unexpected PHP files.
// Core files are already covered by verify-core-checksums. Here we look for
// non-wp-* PHP files that attackers drop at the root (e.g., db.php, x.php, shell.php).
- $root_dir = untrailingslashit( ABSPATH );
+ $root_dir = $site_root;
if ( is_dir( $root_dir ) ) {
$areas_scanned[] = 'root';
agentic_admin_scan_root_for_dangerous_files(
@@ -172,12 +179,8 @@ function agentic_admin_execute_uploads_scan( array $input = array() ): array {
// 3. Scan .well-known directory (recursive).
// Attackers hide backdoors in .well-known/ because admins rarely check it
// and some security scanners skip dotfiles/dotdirs.
- // .well-known lives at the site root (document root), which is get_home_path()
- // and can differ from ABSPATH on subdirectory installs.
- if ( ! function_exists( 'get_home_path' ) ) {
- require_once ABSPATH . 'wp-admin/includes/file.php';
- }
- $well_known_dir = trailingslashit( get_home_path() ) . '.well-known';
+ // .well-known lives at the site root.
+ $well_known_dir = $site_root . '/.well-known';
if ( is_dir( $well_known_dir ) ) {
$areas_scanned[] = '.well-known';
agentic_admin_scan_directory_for_dangerous_files(
@@ -332,7 +335,7 @@ function agentic_admin_scan_directory_for_dangerous_files(
* since those are covered by verify-core-checksums. Flags anything else
* with a dangerous extension.
*
- * @param string $root_dir Absolute path to ABSPATH.
+ * @param string $root_dir Absolute path to the site root.
* @param array $dangerous_extensions Extensions to flag.
* @param int $total_files Running count (by reference).
* @param array $suspicious_files Running list (by reference).
diff --git a/includes/abilities/security/verify-core-checksums.php b/includes/abilities/security/verify-core-checksums.php
index f3ee414..76d7379 100644
--- a/includes/abilities/security/verify-core-checksums.php
+++ b/includes/abilities/security/verify-core-checksums.php
@@ -139,10 +139,10 @@ function agentic_admin_execute_verify_core_checksums( array $input = array() ):
// Build a set of known core files for extra-file detection.
$known_files = array();
- // Derive the content directory prefix relative to ABSPATH.
- // WP_CONTENT_DIR is the absolute path (e.g., /var/www/html/wp-content or /var/www/html/content).
- // We need the relative prefix to match against checksums entries.
- $content_dir_prefix = trailingslashit( substr( WP_CONTENT_DIR, strlen( ABSPATH ) ) );
+ // The checksums API lists content files under their location in the
+ // release package ("wp-content/..."), whatever the site's own content
+ // directory is, so skip that literal prefix.
+ $content_dir_prefix = 'wp-content/';
foreach ( $checksums as $file => $expected_md5 ) {
// Skip content directory files — those are user-managed.
diff --git a/includes/abilities/security/verify-plugin-checksums.php b/includes/abilities/security/verify-plugin-checksums.php
index 4cae129..6400d04 100644
--- a/includes/abilities/security/verify-plugin-checksums.php
+++ b/includes/abilities/security/verify-plugin-checksums.php
@@ -132,10 +132,10 @@ function agentic_admin_execute_verify_plugin_checksums( array $input = array() )
}
// Verify files against checksums.
- $plugin_dir = WP_PLUGIN_DIR . '/' . dirname( $plugin_file );
- $is_single = ! str_contains( $plugin_file, '/' );
- $base_dir = $is_single ? WP_PLUGIN_DIR : $plugin_dir;
- $issues = array();
+ $plugins_dir = agentic_admin_plugins_dir();
+ $is_single = ! str_contains( $plugin_file, '/' );
+ $base_dir = $is_single ? $plugins_dir : $plugins_dir . '/' . dirname( $plugin_file );
+ $issues = array();
foreach ( $checksums as $file => $hashes ) {
$file_path = $base_dir . '/' . $file;
diff --git a/includes/abilities/shared/diff-helpers.php b/includes/abilities/shared/diff-helpers.php
index a61dd14..0ea5ba5 100644
--- a/includes/abilities/shared/diff-helpers.php
+++ b/includes/abilities/shared/diff-helpers.php
@@ -57,39 +57,11 @@ function agentic_admin_get_remote_file_diff( string $original_url, string $file_
* @return string Unified diff output.
*/
function agentic_admin_generate_unified_diff( array $old_lines, array $new_lines, string $old_label, string $new_label ): string {
- // Use WordPress built-in Text_Diff if available.
- if ( ! class_exists( 'Text_Diff', false ) ) {
- $diff_file = ABSPATH . WPINC . '/Text/Diff.php';
- if ( file_exists( $diff_file ) ) {
- require_once $diff_file;
- }
- }
-
- if ( ! class_exists( 'Text_Diff_Renderer_unified', false ) ) {
- $renderer_file = ABSPATH . WPINC . '/Text/Diff/Renderer/unified.php';
- if ( file_exists( $renderer_file ) ) {
- require_once $renderer_file;
- }
- }
-
- if ( class_exists( 'Text_Diff' ) && class_exists( 'Text_Diff_Renderer_unified' ) ) {
- $diff = new \Text_Diff( 'auto', array( $old_lines, $new_lines ) );
- $renderer = new \Text_Diff_Renderer_unified();
- $output = $renderer->render( $diff );
-
- if ( empty( $output ) ) {
- return '';
- }
-
- return "--- {$old_label}\n+++ {$new_label}\n{$output}";
- }
-
- // Fallback: simple line-by-line comparison.
return agentic_admin_simple_diff( $old_lines, $new_lines, $old_label, $new_label );
}
/**
- * Simple fallback diff when Text_Diff is not available.
+ * Simple line-by-line diff.
*
* @param array $old_lines Lines from the original file.
* @param array $new_lines Lines from the modified file.
diff --git a/includes/abilities/shared/plugin-helpers.php b/includes/abilities/shared/plugin-helpers.php
index fdd59a0..4788daf 100644
--- a/includes/abilities/shared/plugin-helpers.php
+++ b/includes/abilities/shared/plugin-helpers.php
@@ -12,6 +12,29 @@
exit;
}
+/**
+ * Absolute path of the plugins directory, without a trailing slash.
+ *
+ * Derived from this plugin's own location (plugin_dir_path( __FILE__ ),
+ * saved in AGENTIC_ADMIN_PLUGIN_DIR), which sits directly inside it.
+ *
+ * @return string
+ */
+function agentic_admin_plugins_dir(): string {
+ return wp_normalize_path( dirname( AGENTIC_ADMIN_PLUGIN_DIR ) );
+}
+
+/**
+ * Absolute path of the must-use plugins directory, or '' when no
+ * must-use plugins are installed.
+ *
+ * @return string
+ */
+function agentic_admin_mu_plugins_dir(): string {
+ $mu_plugins = wp_get_mu_plugins();
+ return empty( $mu_plugins ) ? '' : wp_normalize_path( dirname( $mu_plugins[0] ) );
+}
+
/**
* Get all installed plugins with their status.
*
diff --git a/includes/abilities/wp-api-extract.php b/includes/abilities/wp-api-extract.php
deleted file mode 100644
index 3b51fbd..0000000
--- a/includes/abilities/wp-api-extract.php
+++ /dev/null
@@ -1,300 +0,0 @@
- __( 'Extract WP API Signatures', 'agentic-admin' ),
- 'description' => __( 'Extract WordPress core function signatures and docblocks for knowledge base indexing.', 'agentic-admin' ),
- 'category' => 'sre-tools',
- 'input_schema' => array(
- 'type' => 'object',
- 'default' => array(),
- 'properties' => array(),
- 'additionalProperties' => false,
- ),
- 'output_schema' => array(
- 'type' => 'object',
- 'properties' => array(
- 'chunks' => array(
- 'type' => 'array',
- 'description' => __( 'API signature chunks.', 'agentic-admin' ),
- ),
- 'total_files' => array(
- 'type' => 'integer',
- 'description' => __( 'Total files scanned.', 'agentic-admin' ),
- ),
- ),
- ),
- 'execute_callback' => 'agentic_admin_execute_wp_api_extract',
- 'permission_callback' => function () {
- return current_user_can( 'manage_options' );
- },
- 'meta' => array(
- 'show_in_rest' => true,
- 'annotations' => array(
- 'readonly' => true,
- 'destructive' => false,
- 'idempotent' => true,
- ),
- ),
- ),
- // JS configuration for chat interface.
- array(
- 'keywords' => array( 'wp api', 'wordpress functions', 'core api', 'extract api' ),
- 'initialMessage' => __( 'Extracting WordPress API signatures...', 'agentic-admin' ),
- )
- );
-}
-
-/**
- * Collect PHP files from wp-includes/ (skipping non-PHP subdirectories).
- *
- * @return string[] Array of absolute file paths.
- */
-function agentic_admin_collect_wp_includes_files(): array {
- $wp_includes = ABSPATH . WPINC . '/';
-
- if ( ! is_dir( $wp_includes ) ) {
- return array();
- }
-
- // Skip directories that don't contain PHP API functions.
- $skip_dirs = array( 'blocks', 'js', 'css', 'fonts', 'images', 'ID3', 'sodium_compat', 'Requests', 'SimplePie', 'Text' );
-
- $files = array();
- $iterator = new DirectoryIterator( $wp_includes );
-
- foreach ( $iterator as $item ) {
- if ( $item->isDot() ) {
- continue;
- }
-
- // Skip subdirectories (only scan top-level wp-includes/ PHP files).
- if ( $item->isDir() ) {
- $name = $item->getFilename();
-
- if ( in_array( $name, $skip_dirs, true ) ) {
- continue;
- }
-
- // Scan specific subdirectories that have useful API functions.
- $sub_files = agentic_admin_scan_wp_includes_subdir( $item->getPathname() );
- $files = array_merge( $files, $sub_files );
- continue;
- }
-
- if ( ! $item->isFile() ) {
- continue;
- }
-
- // Only PHP files.
- if ( 'php' !== strtolower( $item->getExtension() ) ) {
- continue;
- }
-
- // Skip very large files (> 200KB).
- if ( $item->getSize() > 200 * 1024 ) {
- continue;
- }
-
- $files[] = $item->getPathname();
- }
-
- sort( $files );
- return $files;
-}
-
-/**
- * Scan a wp-includes subdirectory for PHP files (non-recursive).
- *
- * @param string $dir Directory path.
- * @return string[] Array of file paths.
- */
-function agentic_admin_scan_wp_includes_subdir( string $dir ): array {
- $files = array();
- $iterator = new DirectoryIterator( $dir );
-
- foreach ( $iterator as $item ) {
- if ( $item->isDot() || ! $item->isFile() ) {
- continue;
- }
-
- if ( 'php' !== strtolower( $item->getExtension() ) ) {
- continue;
- }
-
- if ( $item->getSize() > 200 * 1024 ) {
- continue;
- }
-
- $files[] = $item->getPathname();
- }
-
- return $files;
-}
-
-/**
- * Extract function signatures and docblocks from a PHP file.
- *
- * Captures docblock + function signature line only (not the body).
- * Groups ~5 related functions per chunk.
- *
- * @param string $file_path Absolute path to the PHP file.
- * @param string $relative_path Relative path for chunk identification.
- * @return array Array of signature entries.
- */
-function agentic_admin_extract_signatures( string $file_path, string $relative_path ): array {
- global $wp_filesystem;
-
- if ( ! $wp_filesystem ) {
- require_once ABSPATH . 'wp-admin/includes/file.php';
- WP_Filesystem();
- }
-
- $content = $wp_filesystem->get_contents( $file_path );
-
- if ( false === $content ) {
- return array();
- }
-
- $signatures = array();
-
- // Match docblock + function signature (not body).
- // Pattern: optional /** ... */ followed by function declaration.
- $pattern = '/
- (\/\*\*[\s\S]*?\*\/\s*)? # Optional docblock
- ^[ \t]*(?:(?:abstract|static|final|public|protected|private)\s+)* # Optional modifiers
- function\s+(\w+)\s* # Function name
- \(([^)]*)\) # Parameters
- /mx';
-
- if ( preg_match_all( $pattern, $content, $matches, PREG_SET_ORDER ) ) {
- foreach ( $matches as $match ) {
- $docblock = isset( $match[1] ) ? trim( $match[1] ) : '';
- $func_name = $match[2];
- $params = trim( $match[3] );
-
- // Skip internal/private functions (prefixed with _).
- if ( str_starts_with( $func_name, '__' ) && 'construct' !== substr( $func_name, 2 ) ) {
- continue;
- }
-
- // Build concise signature.
- $sig = "function {$func_name}( {$params} )";
-
- $entry = '';
- if ( $docblock ) {
- // Trim docblock to description + @param + @return only.
- $entry = agentic_admin_trim_docblock( $docblock ) . "\n";
- }
- $entry .= $sig;
-
- $signatures[] = $entry;
- }
- }
-
- return $signatures;
-}
-
-/**
- * Trim a docblock to keep only description, @param, and @return lines.
- *
- * @param string $docblock Full docblock string.
- * @return string Trimmed docblock.
- */
-function agentic_admin_trim_docblock( string $docblock ): string {
- $lines = explode( "\n", $docblock );
- $kept = array();
- $keeping = true;
-
- foreach ( $lines as $line ) {
- $trimmed = trim( $line, " \t*/" );
-
- // Keep the opening.
- if ( str_starts_with( trim( $line ), '/**' ) ) {
- $kept[] = '/**';
- continue;
- }
-
- // Keep description lines (before first @tag).
- if ( $keeping && ! str_starts_with( $trimmed, '@' ) ) {
- if ( '' !== $trimmed ) {
- $kept[] = ' * ' . $trimmed;
- }
- continue;
- }
-
- // Once we hit tags, only keep @param, @return, @since.
- if ( str_starts_with( $trimmed, '@' ) ) {
- $keeping = false;
- if ( preg_match( '/^@(param|return|since)\b/', $trimmed ) ) {
- $kept[] = ' * ' . $trimmed;
- }
- }
- }
-
- $kept[] = ' */';
- return implode( "\n", $kept );
-}
-
-/**
- * Execute the wp-api-extract ability.
- *
- * @param array $input Input parameters (unused).
- * @return array
- */
-function agentic_admin_execute_wp_api_extract( array $input = array() ): array {
- $files = agentic_admin_collect_wp_includes_files();
- $total_files = count( $files );
- $chunks = array();
- $wp_includes = ABSPATH . WPINC . '/';
-
- foreach ( $files as $file_path ) {
- $relative_path = str_replace( $wp_includes, '', $file_path );
- $signatures = agentic_admin_extract_signatures( $file_path, $relative_path );
-
- if ( empty( $signatures ) ) {
- continue;
- }
-
- // Group ~5 signatures per chunk.
- $groups = array_chunk( $signatures, 5 );
-
- foreach ( $groups as $group_idx => $group ) {
- $chunks[] = array(
- 'path' => 'wp-api://' . $relative_path,
- 'start_line' => $group_idx * 5 + 1,
- 'end_line' => $group_idx * 5 + count( $group ),
- 'content' => implode( "\n\n", $group ),
- 'type' => 'api',
- );
- }
- }
-
- return array(
- 'success' => true,
- 'chunks' => $chunks,
- 'total_files' => $total_files,
- );
-}
diff --git a/includes/abilities/wp-config-list.php b/includes/abilities/wp-config-list.php
new file mode 100644
index 0000000..3d66df5
--- /dev/null
+++ b/includes/abilities/wp-config-list.php
@@ -0,0 +1,254 @@
+ __( 'List wp-config constants', 'agentic-admin' ),
+ 'description' => __( 'List the constants defined in wp-config.php. Credentials, authentication keys, and salts are never returned.', 'agentic-admin' ),
+ 'category' => 'sre-tools',
+ 'input_schema' => array(
+ 'type' => 'object',
+ 'default' => array(),
+ 'properties' => array(),
+ 'additionalProperties' => false,
+ ),
+ 'output_schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'success' => array(
+ 'type' => 'boolean',
+ 'description' => __( 'Whether the operation was successful.', 'agentic-admin' ),
+ ),
+ 'message' => array(
+ 'type' => 'string',
+ 'description' => __( 'Status message.', 'agentic-admin' ),
+ ),
+ 'constants' => array(
+ 'type' => 'array',
+ 'description' => __( 'Constants as name/value pairs. Sensitive values are replaced by [REDACTED].', 'agentic-admin' ),
+ ),
+ 'total' => array(
+ 'type' => 'integer',
+ 'description' => __( 'Number of constants found.', 'agentic-admin' ),
+ ),
+ 'was_redacted' => array(
+ 'type' => 'boolean',
+ 'description' => __( 'Whether any sensitive constants were withheld.', 'agentic-admin' ),
+ ),
+ ),
+ ),
+ 'execute_callback' => 'agentic_admin_execute_wp_config_list',
+ 'permission_callback' => function () {
+ return current_user_can( 'manage_options' );
+ },
+ 'meta' => array(
+ 'show_in_rest' => true,
+ 'annotations' => array(
+ 'readonly' => true,
+ 'destructive' => false,
+ 'idempotent' => true,
+ ),
+ ),
+ ),
+ // JS configuration for chat interface.
+ array(
+ 'keywords' => array( 'constants', 'defined', 'define', 'wp-config', 'configuration', 'WP_DEBUG', 'config constants', 'wp-config constants' ),
+ 'initialMessage' => __( "I'll list the wp-config.php constants...", 'agentic-admin' ),
+ )
+ );
+}
+
+/**
+ * Execute the wp-config-list ability.
+ *
+ * @param array $input Input parameters (unused).
+ * @return array
+ */
+function agentic_admin_execute_wp_config_list( array $input = array() ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- Abilities API signature.
+ $config_file = agentic_admin_locate_wp_config();
+
+ if ( '' === $config_file ) {
+ return array(
+ 'success' => false,
+ 'message' => __( 'Could not locate wp-config.php.', 'agentic-admin' ),
+ );
+ }
+
+ $names = agentic_admin_wp_config_define_names( $config_file );
+ $constants = array();
+ $was_redacted = false;
+
+ foreach ( $names as $name ) {
+ if ( agentic_admin_is_sensitive_constant( $name ) ) {
+ $value = '[REDACTED]';
+ $was_redacted = true;
+ } elseif ( defined( $name ) ) {
+ $value = agentic_admin_format_constant_value( constant( $name ) );
+ } else {
+ $value = __( '(not defined at runtime)', 'agentic-admin' );
+ }
+
+ $constants[] = array(
+ 'name' => $name,
+ 'value' => $value,
+ );
+ }
+
+ return array(
+ 'success' => true,
+ 'message' => sprintf(
+ /* translators: %d: number of constants */
+ _n( '%d constant defined in wp-config.php.', '%d constants defined in wp-config.php.', count( $constants ), 'agentic-admin' ),
+ count( $constants )
+ ),
+ 'constants' => $constants,
+ 'total' => count( $constants ),
+ 'was_redacted' => $was_redacted,
+ );
+}
+
+/**
+ * Locate wp-config.php the same way wp-load.php does: in the WordPress
+ * directory, or one level above it when that directory is not itself
+ * another WordPress install.
+ *
+ * @return string Absolute path, or an empty string when not found.
+ */
+function agentic_admin_locate_wp_config(): string {
+ $wp_dir = untrailingslashit( wp_normalize_path( ABSPATH ) );
+
+ if ( file_exists( $wp_dir . '/wp-config.php' ) ) {
+ return $wp_dir . '/wp-config.php';
+ }
+
+ $parent = dirname( $wp_dir );
+ if ( file_exists( $parent . '/wp-config.php' ) && ! file_exists( $parent . '/wp-settings.php' ) ) {
+ return $parent . '/wp-config.php';
+ }
+
+ return '';
+}
+
+/**
+ * Collect the constant names passed to define() in a PHP file.
+ *
+ * Uses the PHP tokenizer so only literal names are collected. Values in the
+ * file are never extracted.
+ *
+ * @param string $file Absolute path to the PHP file.
+ * @return string[] Unique constant names in file order.
+ */
+function agentic_admin_wp_config_define_names( string $file ): array {
+ $source = file_get_contents( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file, tokenized only.
+ if ( false === $source ) {
+ return array();
+ }
+
+ $tokens = token_get_all( $source );
+ unset( $source );
+
+ $names = array();
+ $count = count( $tokens );
+
+ for ( $i = 0; $i < $count; $i++ ) {
+ $token = $tokens[ $i ];
+ if ( ! is_array( $token ) || ! in_array( $token[0], array( T_STRING, T_NAME_FULLY_QUALIFIED ), true ) || 'define' !== strtolower( ltrim( $token[1], '\\' ) ) ) {
+ continue;
+ }
+
+ // Next significant tokens must be "(" then a literal string name.
+ $j = agentic_admin_next_significant_token( $tokens, $i + 1 );
+ if ( null === $j || '(' !== $tokens[ $j ] ) {
+ continue;
+ }
+ $j = agentic_admin_next_significant_token( $tokens, $j + 1 );
+ if ( null === $j || ! is_array( $tokens[ $j ] ) || T_CONSTANT_ENCAPSED_STRING !== $tokens[ $j ][0] ) {
+ continue;
+ }
+
+ $name = substr( $tokens[ $j ][1], 1, -1 );
+ if ( preg_match( '/^[A-Za-z_][A-Za-z0-9_]*$/', $name ) ) {
+ $names[ $name ] = true;
+ }
+ }
+
+ return array_keys( $names );
+}
+
+/**
+ * Find the index of the next token that is not whitespace or a comment.
+ *
+ * @param array $tokens Token list from token_get_all().
+ * @param int $start Index to start from.
+ * @return int|null Token index, or null at end of input.
+ */
+function agentic_admin_next_significant_token( array $tokens, int $start ): ?int {
+ $count = count( $tokens );
+ for ( $i = $start; $i < $count; $i++ ) {
+ if ( is_array( $tokens[ $i ] ) && in_array( $tokens[ $i ][0], array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ) {
+ continue;
+ }
+ return $i;
+ }
+ return null;
+}
+
+/**
+ * Whether a constant holds a credential, key, salt, or similar secret.
+ *
+ * @param string $name Constant name.
+ * @return bool
+ */
+function agentic_admin_is_sensitive_constant( string $name ): bool {
+ $upper = strtoupper( $name );
+
+ if ( in_array( $upper, array( 'DB_NAME', 'DB_USER', 'DB_PASSWORD', 'DB_HOST' ), true ) ) {
+ return true;
+ }
+
+ return (bool) preg_match( '/(KEY|SALT|SECRET|TOKEN|PASS|PASSWORD|PASSWD|AUTH|CREDENTIAL|PRIVATE|LICENSE)/', $upper );
+}
+
+/**
+ * Format a constant's runtime value for display.
+ *
+ * @param mixed $value Constant value.
+ * @return string
+ */
+function agentic_admin_format_constant_value( $value ): string {
+ if ( is_bool( $value ) ) {
+ return $value ? 'true' : 'false';
+ }
+ if ( null === $value ) {
+ return 'null';
+ }
+ if ( is_scalar( $value ) ) {
+ return (string) $value;
+ }
+ return (string) wp_json_encode( $value );
+}
diff --git a/includes/class-connectors.php b/includes/class-connectors.php
index 9960acf..f5358b8 100644
--- a/includes/class-connectors.php
+++ b/includes/class-connectors.php
@@ -134,12 +134,7 @@ public static function list_connectors(): \WP_REST_Response {
continue;
}
- // "Connected" = the connector's API credential is present. This is
- // deterministic. The AI Client registry's isProviderConfigured()
- // proved flaky here (it can report a configured provider as not
- // configured between calls), which made the connector list flap and
- // show "no connectors configured" even when keys were set.
- $is_connected = self::is_connector_configured( $data );
+ $is_connected = self::is_connector_configured( $id );
$models = array();
if ( $is_connected && null !== $ai_registry ) {
@@ -173,34 +168,40 @@ public static function list_connectors(): \WP_REST_Response {
}
/**
- * Whether a connector has its API credential configured.
+ * Whether a connector's provider is configured in the AI Client.
*
- * Deterministic check against the connector's declared authentication
- * sources (a defined constant, an environment variable, or a saved
- * option), in that order. Used instead of the AI Client registry's
- * isProviderConfigured(), which proved non-deterministic.
+ * Uses the same check as the core Connectors screen
+ * (hasProvider() && isProviderConfigured()), so the plugin never reads
+ * API keys itself. isProviderConfigured() can briefly report a configured
+ * provider as unconfigured, which made the connector list flap, so a
+ * positive result is remembered for a few minutes. Only the boolean is
+ * cached, never a credential.
*
- * @param array $data Connector definition from wp_get_connectors().
- * @return bool True when a non-empty credential is found.
+ * @param string $connector_id Connector / provider ID.
+ * @return bool True when the provider is configured.
*/
- private static function is_connector_configured( array $data ): bool {
- $auth = isset( $data['authentication'] ) && is_array( $data['authentication'] )
- ? $data['authentication']
- : array();
+ private static function is_connector_configured( string $connector_id ): bool {
+ if ( '' === $connector_id || ! class_exists( '\\WordPress\\AiClient\\AiClient' ) ) {
+ return false;
+ }
- if ( ! empty( $auth['constant_name'] ) && defined( $auth['constant_name'] ) && constant( $auth['constant_name'] ) ) {
+ $cache_key = 'agentic_admin_conn_ok_' . md5( $connector_id );
+ if ( get_transient( $cache_key ) ) {
return true;
}
- if ( ! empty( $auth['env_var_name'] ) && getenv( $auth['env_var_name'] ) ) {
- return true;
+ try {
+ $registry = \WordPress\AiClient\AiClient::defaultRegistry();
+ $configured = $registry->hasProvider( $connector_id ) && $registry->isProviderConfigured( $connector_id );
+ } catch ( \Exception $e ) {
+ $configured = false;
}
- if ( ! empty( $auth['setting_name'] ) && ! empty( \get_option( $auth['setting_name'] ) ) ) {
- return true;
+ if ( $configured ) {
+ set_transient( $cache_key, 1, 5 * MINUTE_IN_SECONDS );
}
- return false;
+ return $configured;
}
/**
@@ -276,8 +277,7 @@ public static function chat_completion( \WP_REST_Request $request ) {
array( 'status' => 404 )
);
}
- $connectors_meta = function_exists( '\\wp_get_connectors' ) ? \wp_get_connectors() : array();
- if ( ! self::is_connector_configured( $connectors_meta[ $connector_id ] ?? array() ) ) {
+ if ( ! self::is_connector_configured( $connector_id ) ) {
return new \WP_Error(
'agentic_admin_unconfigured_connector',
sprintf( 'Connector "%s" is not configured (missing API key).', $connector_id ),
diff --git a/includes/class-utils.php b/includes/class-utils.php
index 428b89a..0a22699 100644
--- a/includes/class-utils.php
+++ b/includes/class-utils.php
@@ -89,11 +89,20 @@ public static function get_browser_requirements(): array {
* @return string|false
*/
public static function get_debug_log_path() {
+ // When WP_DEBUG_LOG is on, wp_debug_mode() points PHP's error_log
+ // setting at the log file, custom path or default location alike.
+ if ( self::is_debug_log_enabled() ) {
+ $log_file = ini_get( 'error_log' );
+ if ( is_string( $log_file ) && '' !== $log_file ) {
+ return $log_file;
+ }
+ }
+
if ( defined( 'WP_DEBUG_LOG' ) && is_string( WP_DEBUG_LOG ) ) {
return WP_DEBUG_LOG;
}
- return WP_CONTENT_DIR . '/debug.log';
+ return false;
}
/**
diff --git a/readme.txt b/readme.txt
index f97526a..8a4a74f 100644
--- a/readme.txt
+++ b/readme.txt
@@ -41,7 +41,7 @@ Agentic Admin transforms your WordPress admin panel into an intelligent command
1. The Agentic Admin chat tab in wp-admin, mid-conversation. The model has just answered a question about installed plugins by calling the `plugin-list` tool locally — full ReAct trace (user question, thought process, tool call, answer) visible.
2. First-run model download in progress. The Qwen 3 1.7B weights (~1.2 GB) are fetched from the MLC-AI / HuggingFace CDN — once per browser, cancellable, cached for subsequent sessions.
3. The Abilities browser, listing every tool the assistant can call against the WordPress Abilities API on this site.
-4. Settings panel. Build the local knowledge base, see detected GPU + VRAM, tune context-window size per model based on your hardware, toggle thinking mode, and switch between the local engine (WebLLM + WebGPU), a remote OpenAI-compatible endpoint, or the WordPress 7.0 Connector.
+4. Settings panel. See detected GPU + VRAM, tune context-window size per model based on your hardware, toggle thinking mode, and switch between the local engine (WebLLM + WebGPU), a remote OpenAI-compatible endpoint, or the WordPress 7.0 Connector.
5. Multi-step workflow execution. "Do a performance check" is recognized as a 2-step workflow — the assistant runs `site-health` and `error-log-read` in sequence, then summarizes the environment (WP version, PHP, memory, debug mode, error log status) in one answer.
6. WordPress 7.0 AI Connector integration. The Connector tab picks up any AI provider registered via WP 7.0's built-in Connector API — Anthropic, Google, OpenAI, or any third-party `ai_provider` plugin — and uses it as the model backend with zero extra setup.
@@ -51,14 +51,10 @@ This plugin runs AI locally in your browser by default, and your prompts and cha
Separately from AI inference, some abilities query public data sources to do their job: a security scan checks your plugin versions against CVE databases, a checksum verification compares your files against WordPress.org, and a web search sends your query to a search engine. Every external request the plugin makes is listed here:
-**Model weights CDN (MLC-AI / HuggingFace)** — Always for the local engine.
-On first use the browser downloads the selected model (Qwen 3 1.7B by default, ~1.2 GB) from `https://huggingface.co/mlc-ai/` and `https://raw.githubusercontent.com/mlc-ai/binary-mlc-llm-libs/`. Only HTTP GET requests for static model files are made; no prompts, admin data, or telemetry are sent. Weights are cached in the browser; subsequent sessions are offline.
-HuggingFace terms: https://huggingface.co/terms-of-service — Privacy: https://huggingface.co/privacy
-
-**Transformers.js library + embedding model (jsDelivr + Hugging Face)** — Only when the optional local knowledge base is enabled in settings.
-This performs in-browser semantic embedding of your documentation and code so the assistant can answer from a local knowledge base; the computation runs entirely in your browser. The Transformers.js library is loaded from jsDelivr (`https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.8.1/`) and the embedding model (`Xenova/all-MiniLM-L6-v2`, ~23 MB, one-time) from Hugging Face. Both are static files cached in the browser after first use; no prompts or admin data are sent.
-jsDelivr terms: https://www.jsdelivr.com/terms — Privacy: https://www.jsdelivr.com/privacy-policy-jsdelivr-net
+**AI model download service (MLC-AI, hosted on Hugging Face and GitHub)** — Only when the local engine is used.
+The local engine is the plugin's core service: it runs a language model in the administrator's browser. The model is not part of the plugin, because model files are over 1 GB and are published and versioned by the MLC-AI project. When the site owner selects a local model in the plugin's settings (Qwen 3 1.7B by default, ~1.2 GB, or Qwen 2.5 7B, ~4.5 GB), the administrator's browser downloads that model's weights from `https://huggingface.co/mlc-ai/` and its compiled model library from `https://raw.githubusercontent.com/mlc-ai/binary-mlc-llm-libs/`. The site owner's choice of engine and model determines what is downloaded. No account or API key is needed. Only HTTP GET requests for static files are made, directly from the browser (the WordPress server makes no requests for them), and no prompts, admin data, or telemetry are sent. Files are cached in the browser after the first download.
Hugging Face terms: https://huggingface.co/terms-of-service — Privacy: https://huggingface.co/privacy
+GitHub terms: https://docs.github.com/en/site-policy/github-terms/github-terms-of-service — Privacy: https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement
**External LLM provider (user-configured)** — Only when you switch the engine from "Local" to "Remote" in settings.
When enabled, chat messages, tool descriptions, and tool results are sent through this plugin's REST proxy (`/wp-json/agentic-admin/v1/llm-proxy/`) to the OpenAI-compatible endpoint URL you configure (e.g. Ollama, LM Studio, vLLM, OpenAI, Groq, Together). You choose the endpoint; the plugin does not preselect or default to any third-party provider. No data is sent until you save an endpoint and start a chat in Remote mode.
@@ -89,12 +85,12 @@ Agentic Admin is fully open source under GPL-2.0-or-later. The complete, human-r
https://github.com/pluginslab/wp-agentic-admin
-The files under `build-extensions/` are generated from the sources in `src/` with @wordpress/scripts (webpack). They contain only JavaScript and CSS: the bundles (`index.js`, `sw.js`, `indexing-worker.js`, and code-split chunks) and the stylesheets. No WebAssembly, binaries, or other compiled artifacts are distributed with the plugin. To regenerate them from a checkout:
+The files under `build-extensions/` are generated from the sources in `src/` with @wordpress/scripts (webpack). They contain only JavaScript and CSS: the bundles (`index.js`, `sw.js`, and code-split chunks) and the stylesheets. No WebAssembly, binaries, or other compiled artifacts are distributed with the plugin. To regenerate them from a checkout:
1. `npm install`
2. `npm run build`
-There is no build step for the PHP. The WebLLM engine is bundled into the plugin from its npm package. The optional knowledge-base embedding library (Transformers.js) and all AI model weights are loaded at runtime from the providers documented under External services above; these are large provider-hosted files, not part of the plugin code.
+There is no build step for the PHP. The WebLLM engine is bundled into the plugin from its npm package. The AI model weights and their compiled model libraries are loaded at runtime from the provider documented under External services above; these are large provider-hosted model files (over 1 GB), not part of the plugin code.
== Changelog ==
@@ -112,13 +108,16 @@ There is no build step for the PHP. The WebLLM engine is bundled into the plugin
* Fixed: AI model no longer preloads on every wp-admin page, deferred until the user opens the sidebar for the first time (#116).
* Improved: post-tool summarization is brief (no re-listing items the user already sees in the tool result UI).
* Improved: ChatInput keyboard handling simplified (Space inserts a space, no push-to-talk hijacking).
-* Improved: KB embedding moved to a Web Worker with persistent progress across tab switches.
-* Pinned: Transformers.js CDN URL to @3.8.1 (was floating @3 range), privacy-first plugin shouldn't depend on a CDN range that can ship new code without a deliberate bump.
-* Removed: the voy-search dependency and the WebAssembly module it distributed. Vector search is now plain JavaScript (exhaustive cosine over L2-normalised embeddings), so the plugin ships no compiled binaries at all and every distributed file has readable source in the repository.
-* Fixed: re-running the knowledge base index no longer leaves vectors and chunk metadata misaligned, which could return the wrong code chunk for a query.
+* Removed: the local knowledge base (in-browser embeddings, vector store, and the codebase-index, code-search, schema-extract, wp-api-extract, docs-extract, and codebase-extract abilities). The plugin no longer loads Transformers.js from a CDN or ships any WebAssembly module.
+* Security: read-file now refuses files that can hold credentials, keys, or salts (wp-config.php, .env, private keys, database dumps, and any file defining the auth keys, salts, or DB password) instead of relying on redaction.
+* Security: wp-config-list now has its own PHP backend that reads constant names with the PHP tokenizer and never reads the values of credentials, keys, or salts.
+* Security: removed the security-scan salts check, which read the auth salt and could never fail.
+* Changed: AI Connectors are detected through the AI Client registry, as on the core Connectors screen, instead of reading API-key options.
+* Fixed: plugin, must-use plugin, and content paths are derived from the plugin's own location and WordPress functions instead of WP_PLUGIN_DIR, WPMU_PLUGIN_DIR, and WP_CONTENT_DIR. Core checksum verification no longer miscounts files when the content directory is moved. The debug log path follows WordPress's own error_log setting.
+* Fixed: read-file and uploads-scan resolve the site root with get_home_path(), so subdirectory installs work.
* Fixed: `.well-known` scanning resolves via get_home_path() instead of ABSPATH, so subdirectory installs scan the real site root.
* Removed: 7 stale tab references and 6+ stale docs files (FEEDBACK-DEV.md).
-* Tests: 96 unit tests passing, plus the new manifest test suite (7 cases), index test suite (6 cases), knowledge-base test suite (16 cases), and react-agent regression tests (3 cases for the per-call state cleanup fix).
+* Tests: 96 unit tests passing, plus the new manifest test suite (7 cases), index test suite (6 cases), and react-agent regression tests (3 cases for the per-call state cleanup fix).
= 0.10.0 =
* CloudFest Hackathon 2026 release: 78 PRs merged, 42+ abilities shipped by 11 contributors
diff --git a/src/extensions/abilities/__tests__/manifest.test.js b/src/extensions/abilities/__tests__/manifest.test.js
index e49a11c..cb6cc74 100644
--- a/src/extensions/abilities/__tests__/manifest.test.js
+++ b/src/extensions/abilities/__tests__/manifest.test.js
@@ -76,8 +76,6 @@ describe( 'abilities manifest', () => {
'current-user-role',
'core-site-info',
'core-environment-info',
- 'codebase-index',
- 'code-search',
'discover-plugin-abilities',
'run-plugin-ability',
];
@@ -108,9 +106,6 @@ describe( 'abilities manifest', () => {
'current-user-role',
'core-site-info',
'core-environment-info',
- 'codebase-index',
- 'code-search',
- 'wp-config-list', // also LOCAL_ONLY
].sort()
);
} );
diff --git a/src/extensions/abilities/code-search.js b/src/extensions/abilities/code-search.js
deleted file mode 100644
index 61d3a5a..0000000
--- a/src/extensions/abilities/code-search.js
+++ /dev/null
@@ -1,145 +0,0 @@
-/**
- * Code Search Ability
- *
- * Searches the local knowledge base using semantic vector search.
- * The knowledge base includes site code, database schemas,
- * WordPress API signatures, and reference documentation.
- *
- * @see src/extensions/services/vector-store.js for the vector store service
- * @see src/extensions/abilities/codebase-index.js for the indexing ability
- */
-
-import { registerAbility } from '../services/agentic-abilities-api';
-import vectorStore from '../services/vector-store';
-import { createLogger } from '../utils/logger';
-
-const log = createLogger( 'CodeSearch' );
-
-/**
- * Register the code-search ability with the chat system.
- */
-export function registerCodeSearch() {
- registerAbility( 'agentic-admin/code-search', {
- label: 'Search knowledge base',
- description:
- 'Search the knowledge base for code, database schema, WordPress API docs, or reference information. Use when users ask to find, search, or look up anything about the site or WordPress.',
-
- keywords: [
- 'search code',
- 'find function',
- 'where is',
- 'codebase',
- 'find class',
- 'find code',
- 'code search',
- 'search codebase',
- 'database',
- 'schema',
- 'wordpress',
- 'documentation',
- 'how does',
- 'what is',
- ],
-
- initialMessage: 'Searching your knowledge base...',
-
- parseIntent: ( message ) => {
- return { query: message };
- },
-
- summarize: ( result ) => {
- if ( result.error ) {
- return result.error;
- }
-
- if ( ! result.matches || result.matches.length === 0 ) {
- return 'No matching results found in the knowledge base.';
- }
-
- let summary = `Found **${ result.matches.length }** relevant results:\n\n`;
-
- result.matches.forEach( ( match, i ) => {
- summary += `**${ i + 1 }. ${ match.path }** (lines ${
- match.start_line
- }-${ match.end_line })\n`;
- summary += '```\n';
- // Truncate for display.
- const preview =
- match.content.length > 500
- ? match.content.slice( 0, 500 ) + '\n...'
- : match.content;
- summary += preview + '\n';
- summary += '```\n\n';
- } );
-
- return summary;
- },
-
- interpretResult: ( result ) => {
- if ( result.error ) {
- return result.error;
- }
-
- if ( ! result.matches || result.matches.length === 0 ) {
- return 'No matching results found in the knowledge base.';
- }
-
- // Strict budget: max 2 snippets, ~400 chars each, total under 800 chars.
- const snippets = result.matches.slice( 0, 2 );
- const parts = snippets.map( ( match ) => {
- const content = match.content.slice( 0, 400 );
- return `${ match.path }:${ match.start_line }: ${ content }`;
- } );
-
- return parts.join( '\n' );
- },
-
- execute: async ( params ) => {
- try {
- // Initialize vector store (restores index from IndexedDB).
- await vectorStore.init();
-
- if ( ! vectorStore.isReady() ) {
- return {
- error: "Knowledge base not indexed yet. Run 'index the codebase' first.",
- };
- }
-
- // Extract query from params.
- const query = params.query || params.userMessage || 'code';
-
- log.info( `Query: "${ query }"` );
- log.info( `Index has ${ vectorStore.getChunkCount() } chunks` );
-
- const matches = await vectorStore.search( query, 3 );
-
- matches.forEach( ( match, i ) => {
- log.info(
- `Result ${ i + 1 }: ${ match.path }:${
- match.start_line
- }-${ match.end_line } (score: ${ match.score }) type: ${
- match.type
- }`
- );
- log.info(
- ` Preview: ${ match.content.slice( 0, 150 ) }...`
- );
- } );
-
- return {
- matches,
- total: matches.length,
- indexed_chunks: vectorStore.getChunkCount(),
- };
- } catch ( err ) {
- return {
- error: `Search failed: ${ err.message }`,
- };
- }
- },
-
- requiresConfirmation: false,
- } );
-}
-
-export default registerCodeSearch;
diff --git a/src/extensions/abilities/codebase-index.js b/src/extensions/abilities/codebase-index.js
deleted file mode 100644
index 4e90fe8..0000000
--- a/src/extensions/abilities/codebase-index.js
+++ /dev/null
@@ -1,100 +0,0 @@
-/**
- * Codebase Index Ability
- *
- * Thin wrapper that delegates to the Knowledge Base service in Settings.
- * When invoked from chat, runs the full indexing pipeline.
- * Users can also build the index from the Settings tab.
- *
- * @see src/extensions/services/knowledge-base.js for the indexing service
- * @see src/extensions/services/vector-store.js for the vector store
- */
-
-import { registerAbility } from '../services/agentic-abilities-api';
-import { buildIndex, getKBStatus } from '../services/knowledge-base';
-
-/**
- * Register the codebase-index ability with the chat system.
- */
-export function registerCodebaseIndex() {
- registerAbility( 'agentic-admin/codebase-index', {
- label: 'Build knowledge base index',
- description:
- 'Build a search index from site code, database schema, WordPress API, and reference docs. Only use when the user explicitly says "index", "reindex", or "build knowledge base". Do NOT use for searching.',
-
- keywords: [
- 'index codebase',
- 'reindex',
- 'build index',
- 'scan code',
- 'build knowledge base',
- ],
-
- initialMessage:
- 'Building your knowledge base... This may take 1-3 minutes.',
-
- requiresConfirmation: true,
- confirmationMessage:
- 'This will build a search index from your site code, database schema, WordPress API signatures, and reference docs. This may take 1-3 minutes. Continue?',
-
- summarize: ( result ) => {
- if ( result.error ) {
- return `Indexing failed: ${ result.error }`;
- }
- let summary = `Indexed **${ result.totalChunks }** chunks from **${ result.codeFiles }** code files.`;
- if ( result.schemaTables ) {
- summary += ` Includes **${ result.schemaTables }** database table schemas.`;
- }
- if ( result.apiChunks ) {
- summary += ` Includes **${ result.apiChunks }** WP API signature chunks.`;
- }
- if ( result.docsChunks ) {
- summary += ` Includes **${ result.docsChunks }** reference doc chunks.`;
- }
- summary += ' Your knowledge base is ready to search.';
- return summary;
- },
-
- interpretResult: ( result ) => {
- if ( result.error ) {
- return `Indexing failed: ${ result.error }`;
- }
- let summary = `Indexed ${ result.totalChunks } chunks from ${ result.codeFiles } files.`;
- if ( result.schemaTables ) {
- summary += ` ${ result.schemaTables } DB schemas.`;
- }
- if ( result.apiChunks ) {
- summary += ` ${ result.apiChunks } API signatures.`;
- }
- if ( result.docsChunks ) {
- summary += ` ${ result.docsChunks } doc sections.`;
- }
- summary += ' Knowledge base is now searchable.';
- return summary;
- },
-
- execute: async () => {
- try {
- // Check if already indexed recently (< 5 min ago).
- const existing = getKBStatus();
- if (
- existing &&
- Date.now() - existing.lastIndexed < 5 * 60 * 1000
- ) {
- return {
- ...existing,
- note: 'Knowledge base was already built recently. You can rebuild from Settings if needed.',
- };
- }
-
- const status = await buildIndex();
- return status;
- } catch ( err ) {
- return {
- error: err.message,
- };
- }
- },
- } );
-}
-
-export default registerCodebaseIndex;
diff --git a/src/extensions/abilities/manifest.js b/src/extensions/abilities/manifest.js
index 620cf79..ef0e94d 100644
--- a/src/extensions/abilities/manifest.js
+++ b/src/extensions/abilities/manifest.js
@@ -41,8 +41,6 @@ import { registerVerifyPluginChecksums } from './verify-plugin-checksums';
import { registerDatabaseCheck } from './database-check';
import { registerFileScan } from './file-scan';
import { registerRoleCapabilitiesCheck } from './role-capabilities-check';
-import { registerCodebaseIndex } from './codebase-index';
-import { registerCodeSearch } from './code-search';
// Local-only abilities (hidden from LLM when external provider is active).
import { registerReadFile } from './read-file';
@@ -100,8 +98,6 @@ export const REGISTRARS = {
// CORE — knowledge.
'web-search': registerWebSearch,
- 'codebase-index': registerCodebaseIndex,
- 'code-search': registerCodeSearch,
// LOCAL_ONLY — sensitive abilities.
'read-file': registerReadFile,
@@ -129,7 +125,4 @@ export const JS_ONLY_ABILITIES = new Set( [
'current-user-role',
'core-site-info',
'core-environment-info',
- 'codebase-index',
- 'code-search',
- 'wp-config-list',
] );
diff --git a/src/extensions/abilities/security-scan.js b/src/extensions/abilities/security-scan.js
index 3631286..d1dde98 100644
--- a/src/extensions/abilities/security-scan.js
+++ b/src/extensions/abilities/security-scan.js
@@ -18,7 +18,7 @@ export function registerSecurityScan() {
registerAbility( 'agentic-admin/security-scan', {
label: 'Run basic security scan',
description:
- 'Run basic WordPress security checks including debug mode, file permissions, salts, and version exposure. Use for security audits.',
+ 'Run basic WordPress security checks including debug mode, file permissions, and version exposure. Use for security audits.',
keywords: [
'security',
diff --git a/src/extensions/abilities/wp-config-list.js b/src/extensions/abilities/wp-config-list.js
index 6dc47a6..9828630 100644
--- a/src/extensions/abilities/wp-config-list.js
+++ b/src/extensions/abilities/wp-config-list.js
@@ -5,11 +5,10 @@
* =================
* Lists and categorizes all PHP constants defined in wp-config.php.
*
- * Delegates file reading to the read-file ability so that all path
- * resolution, ABSPATH security checks, and sensitive-value redaction
- * (DB_PASSWORD, auth keys, salts) are handled by the existing PHP backend.
- * The JS layer then parses define() calls from the already-redacted content
- * and groups them by purpose.
+ * The PHP backend tokenizes wp-config.php for constant names only and
+ * returns runtime values for non-sensitive constants. Credentials, auth
+ * keys, and salts come back as [REDACTED] without their value being read.
+ * The JS layer groups the constants by purpose.
*
* EXECUTE RETURNS:
* {
@@ -23,7 +22,7 @@
* Uses preferSummarize: true — summarize() renders a grouped markdown list
* directly, bypassing the LLM to avoid truncation of long constant tables.
*
- * @see includes/abilities/read-file.php — file reading and redaction backend
+ * @see includes/abilities/wp-config-list.php — PHP backend
* @see docs/ABILITIES-GUIDE.md — registration API reference
*/
@@ -139,35 +138,6 @@ function getCategory( name ) {
return 'custom';
}
-/**
- * Parse define() constants from PHP file content.
- *
- * Handles boolean, integer, float, null, and string (single- or double-quoted)
- * values. Strings may contain [REDACTED] after server-side redaction.
- *
- * @param {string} content - PHP file content (already redacted server-side).
- * @return {Array<{name: string, value: string, category: string}>} Parsed constants.
- */
-function parseDefineConstants( content ) {
- const constants = [];
- const defineRe =
- /define\s*\(\s*['"]([A-Z][A-Z0-9_]*)["']\s*,\s*(true|false|null|-?\d+(?:\.\d+)?|'[^']*'|"[^"]*")\s*\)/gi;
- let match;
- while ( ( match = defineRe.exec( content ) ) !== null ) {
- const name = match[ 1 ];
- let value = match[ 2 ];
- // Strip surrounding quotes from string values.
- if (
- ( value.startsWith( "'" ) && value.endsWith( "'" ) ) ||
- ( value.startsWith( '"' ) && value.endsWith( '"' ) )
- ) {
- value = value.slice( 1, -1 );
- }
- constants.push( { name, value, category: getCategory( name ) } );
- }
- return constants;
-}
-
/**
* Format parsed constants as markdown grouped by category.
*
@@ -235,34 +205,36 @@ export function registerWpConfigList() {
initialMessage: "I'll list the wp-config.php constants...",
/**
- * Read wp-config.php via the read-file ability and parse its constants.
- *
- * Delegates to read-file so that path resolution, ABSPATH validation,
- * and sensitive-value redaction are handled by the existing PHP backend.
+ * Fetch the constants from the PHP backend and categorize them.
*
- * @return {Promise
-
-
-
Knowledge Base
-
-
-
-
-
-
- Build a local search index from your
- site's code, database schema, WordPress
- API signatures, and reference documentation.
- The AI assistant automatically consults this
- knowledge base when answering questions.
-