class-avcf-abilities-base.php
3 weeks ago
class-avcf-abilities-block-navigation.php
3 weeks ago
class-avcf-abilities-cache.php
3 weeks ago
class-avcf-abilities-content.php
1 week ago
class-avcf-abilities-core.php
3 days ago
class-avcf-abilities-execute-php.php
2 weeks ago
class-avcf-abilities-global-styles.php
3 weeks ago
class-avcf-abilities-gutenberg.php
2 weeks ago
class-avcf-abilities-media.php
1 week ago
class-avcf-abilities-metadata.php
1 week ago
class-avcf-abilities-navigation.php
3 weeks ago
class-avcf-abilities-patterns.php
3 weeks ago
class-avcf-abilities-plugins.php
3 days ago
class-avcf-abilities-readonly.php
2 weeks ago
class-avcf-abilities-settings.php
3 weeks ago
class-avcf-abilities-taxonomies.php
3 weeks ago
class-avcf-abilities-templates.php
3 weeks ago
class-avcf-abilities-theme-files.php
2 weeks ago
class-avcf-abilities-themes.php
3 days ago
class-avcf-abilities-users.php
3 weeks ago
class-avcf-abilities-wp-cli.php
3 days ago
class-avcf-abilities-readonly.php
361 lines
| 1 | <?php |
| 2 | /** |
| 3 | * Read-only floor abilities: filesystem read + read-only SQL. |
| 4 | * |
| 5 | * These grant an agent READ access to the WordPress install's files and |
| 6 | * database. They are powerful (information disclosure risk), so: |
| 7 | * - Plugin-side they are guarded by manage_options, the secret protections |
| 8 | * below, ABSPATH containment and size/row caps. Opt-in / consent for |
| 9 | * invoking them is handled on the AGENT side (the MCP client prompts the |
| 10 | * operator), so they are not additionally blocklisted plugin-side. |
| 11 | * - Secrets are protected: the FS tool refuses wp-config.php / .env / key |
| 12 | * material; the SQL tool redacts credential columns (user_pass, etc.). |
| 13 | * - Reads are contained to ABSPATH (no traversal) and size-capped. |
| 14 | * - SQL is restricted to a single read-only statement. |
| 15 | * |
| 16 | * @package Atarim |
| 17 | */ |
| 18 | |
| 19 | if ( ! defined( 'ABSPATH' ) ) { |
| 20 | exit; |
| 21 | } |
| 22 | |
| 23 | class AVCF_Abilities_ReadOnly { |
| 24 | |
| 25 | /** Max bytes read-file will return inline. Larger files return metadata only. */ |
| 26 | const MAX_READ_BYTES = 1048576; // 1 MB |
| 27 | |
| 28 | /** Max rows run-select-query will return. */ |
| 29 | const MAX_ROWS = 500; |
| 30 | |
| 31 | public function register() { |
| 32 | if ( ! function_exists( 'wp_register_ability' ) ) { |
| 33 | return; |
| 34 | } |
| 35 | $this->register_read_file(); |
| 36 | $this->register_list_files(); |
| 37 | $this->register_run_select_query(); |
| 38 | } |
| 39 | |
| 40 | /* --------------------------------------------------------------------- */ |
| 41 | /* Abilities */ |
| 42 | /* --------------------------------------------------------------------- */ |
| 43 | |
| 44 | private function register_read_file() { |
| 45 | $self = $this; |
| 46 | wp_register_ability( 'atarim/read-file', [ |
| 47 | 'label' => 'Read File (read-only floor)', |
| 48 | 'description' => 'Read a single file from the WordPress installation for inspection. Paths are relative to the WordPress root (ABSPATH) and are realpath-contained — no traversal outside the install. Secret files (wp-config.php, .env, *.key/*.pem and similar credential material) are refused. Text is returned inline (UTF-8); binary is returned base64-encoded with is_binary=true. Files larger than 1 MB return metadata only (size + a note), not content. Read-only.', |
| 49 | 'category' => 'atarim', |
| 50 | 'input_schema' => [ |
| 51 | 'type' => 'object', |
| 52 | 'properties' => [ |
| 53 | 'path' => [ 'type' => 'string', 'description' => 'File path relative to the WordPress root, e.g. "wp-content/themes/foo/style.css".' ], |
| 54 | ], |
| 55 | 'required' => [ 'path' ], |
| 56 | 'additionalProperties' => false, |
| 57 | ], |
| 58 | 'output_schema' => [ |
| 59 | 'type' => 'object', |
| 60 | 'properties' => [ |
| 61 | 'success' => [ 'type' => 'boolean' ], |
| 62 | 'path' => [ 'type' => 'string' ], |
| 63 | 'bytes' => [ 'type' => 'integer' ], |
| 64 | 'is_binary' => [ 'type' => 'boolean' ], |
| 65 | 'truncated' => [ 'type' => 'boolean' ], |
| 66 | 'sha1' => [ 'type' => 'string' ], |
| 67 | 'content' => [ 'type' => 'string' ], |
| 68 | 'content_base64' => [ 'type' => 'string' ], |
| 69 | 'message' => [ 'type' => 'string' ], |
| 70 | ], |
| 71 | 'required' => [ 'success', 'message' ], |
| 72 | ], |
| 73 | 'execute_callback' => function( $input = [] ) use ( $self ) { |
| 74 | $rel = isset( $input['path'] ) ? (string) $input['path'] : ''; |
| 75 | $loc = $self->resolve_within_abspath( $rel ); |
| 76 | if ( isset( $loc['error'] ) ) { |
| 77 | return [ 'success' => false, 'message' => $loc['error'] ]; |
| 78 | } |
| 79 | $abs = $loc['abs']; |
| 80 | if ( is_dir( $abs ) ) { |
| 81 | return [ 'success' => false, 'path' => $loc['rel'], 'message' => 'That path is a directory. Use list-files to list its contents.' ]; |
| 82 | } |
| 83 | if ( $self->is_secret_file( $abs ) ) { |
| 84 | return [ 'success' => false, 'path' => $loc['rel'], 'message' => 'Refused: this file may contain credentials or secrets and is not readable through this tool.' ]; |
| 85 | } |
| 86 | $size = @filesize( $abs ); |
| 87 | if ( false === $size ) { |
| 88 | return [ 'success' => false, 'path' => $loc['rel'], 'message' => 'Could not stat the file.' ]; |
| 89 | } |
| 90 | if ( $size > $self::MAX_READ_BYTES ) { |
| 91 | return [ |
| 92 | 'success' => true, |
| 93 | 'path' => $loc['rel'], |
| 94 | 'bytes' => (int) $size, |
| 95 | 'truncated' => true, |
| 96 | 'message' => sprintf( 'File is %d bytes, larger than the %d-byte inline limit; content not returned. Read a smaller file or a specific part another way.', (int) $size, $self::MAX_READ_BYTES ), |
| 97 | ]; |
| 98 | } |
| 99 | $content = @file_get_contents( $abs ); |
| 100 | if ( false === $content ) { |
| 101 | return [ 'success' => false, 'path' => $loc['rel'], 'message' => 'Could not read the file (permissions?).' ]; |
| 102 | } |
| 103 | $is_binary = ( strpos( $content, "\0" ) !== false ) || ! mb_check_encoding( $content, 'UTF-8' ); |
| 104 | $out = [ |
| 105 | 'success' => true, |
| 106 | 'path' => $loc['rel'], |
| 107 | 'bytes' => strlen( $content ), |
| 108 | 'is_binary' => $is_binary, |
| 109 | 'truncated' => false, |
| 110 | 'sha1' => sha1( $content ), |
| 111 | ]; |
| 112 | if ( $is_binary ) { |
| 113 | $out['content_base64'] = base64_encode( $content ); |
| 114 | $out['message'] = 'Binary file returned base64-encoded.'; |
| 115 | } else { |
| 116 | $out['content'] = $content; |
| 117 | $out['message'] = sprintf( 'Read %d bytes from "%s".', strlen( $content ), $loc['rel'] ); |
| 118 | } |
| 119 | return $out; |
| 120 | }, |
| 121 | 'permission_callback' => function() { return current_user_can( 'manage_options' ); }, |
| 122 | 'meta' => $self->ro_meta(), |
| 123 | ] ); |
| 124 | } |
| 125 | |
| 126 | private function register_list_files() { |
| 127 | $self = $this; |
| 128 | wp_register_ability( 'atarim/list-files', [ |
| 129 | 'label' => 'List Files (read-only floor)', |
| 130 | 'description' => 'List the immediate contents of a directory within the WordPress installation. Path is relative to the WordPress root (ABSPATH), realpath-contained (no traversal), and defaults to the root. Each entry reports name, type (file|dir), size, and is_secret (true for files read-file will refuse, e.g. wp-config.php). Non-recursive — drill down one directory at a time. Read-only.', |
| 131 | 'category' => 'atarim', |
| 132 | 'input_schema' => [ |
| 133 | 'type' => 'object', |
| 134 | 'properties' => [ |
| 135 | 'path' => [ 'type' => 'string', 'description' => 'Directory relative to the WordPress root. Omit for the root.' ], |
| 136 | ], |
| 137 | 'additionalProperties' => false, |
| 138 | ], |
| 139 | 'output_schema' => [ |
| 140 | 'type' => 'object', |
| 141 | 'properties' => [ |
| 142 | 'success' => [ 'type' => 'boolean' ], |
| 143 | 'path' => [ 'type' => 'string' ], |
| 144 | 'entries' => [ 'type' => 'array' ], |
| 145 | 'message' => [ 'type' => 'string' ], |
| 146 | ], |
| 147 | 'required' => [ 'success', 'message' ], |
| 148 | ], |
| 149 | 'execute_callback' => function( $input = [] ) use ( $self ) { |
| 150 | $rel = isset( $input['path'] ) ? (string) $input['path'] : ''; |
| 151 | $loc = $self->resolve_within_abspath( $rel === '' ? '.' : $rel ); |
| 152 | if ( isset( $loc['error'] ) ) { |
| 153 | return [ 'success' => false, 'message' => $loc['error'] ]; |
| 154 | } |
| 155 | $abs = $loc['abs']; |
| 156 | if ( ! is_dir( $abs ) ) { |
| 157 | return [ 'success' => false, 'path' => $loc['rel'], 'message' => 'That path is not a directory. Use read-file for files.' ]; |
| 158 | } |
| 159 | $items = @scandir( $abs ); |
| 160 | if ( false === $items ) { |
| 161 | return [ 'success' => false, 'path' => $loc['rel'], 'message' => 'Could not read the directory.' ]; |
| 162 | } |
| 163 | $entries = []; |
| 164 | foreach ( $items as $name ) { |
| 165 | if ( '.' === $name || '..' === $name ) { |
| 166 | continue; |
| 167 | } |
| 168 | $child = $abs . '/' . $name; |
| 169 | $is_dir = is_dir( $child ); |
| 170 | $entries[] = [ |
| 171 | 'name' => $name, |
| 172 | 'type' => $is_dir ? 'dir' : 'file', |
| 173 | 'size' => $is_dir ? null : (int) @filesize( $child ), |
| 174 | 'is_secret' => $is_dir ? false : $self->is_secret_file( $child ), |
| 175 | ]; |
| 176 | } |
| 177 | return [ |
| 178 | 'success' => true, |
| 179 | 'path' => $loc['rel'], |
| 180 | 'entries' => $entries, |
| 181 | 'message' => sprintf( '%d entr%s in "%s".', count( $entries ), ( 1 === count( $entries ) ? 'y' : 'ies' ), $loc['rel'] ), |
| 182 | ]; |
| 183 | }, |
| 184 | 'permission_callback' => function() { return current_user_can( 'manage_options' ); }, |
| 185 | 'meta' => $self->ro_meta(), |
| 186 | ] ); |
| 187 | } |
| 188 | |
| 189 | private function register_run_select_query() { |
| 190 | $self = $this; |
| 191 | wp_register_ability( 'atarim/run-select-query', [ |
| 192 | 'label' => 'Run Read-only SQL Query (read-only floor)', |
| 193 | 'description' => 'Run a SINGLE read-only SQL statement against the WordPress database and return the rows. Only one statement is allowed and it must begin with SELECT, SHOW, DESCRIBE, EXPLAIN or WITH; stacked statements, INTO OUTFILE/DUMPFILE and LOAD_FILE are rejected. Credential columns (user_pass, user_activation_key) and auth meta (session_tokens, application passwords) are redacted in the results. At most 500 rows are returned (truncated=true if more). NOTE: read-only is enforced by statement validation, which is defense-in-depth, not a hard database guarantee.', |
| 194 | 'category' => 'atarim', |
| 195 | 'input_schema' => [ |
| 196 | 'type' => 'object', |
| 197 | 'properties' => [ |
| 198 | 'query' => [ 'type' => 'string', 'description' => 'A single read-only SQL statement (SELECT/SHOW/DESCRIBE/EXPLAIN/WITH). Use the site table prefix as-is (e.g. wp_posts).' ], |
| 199 | ], |
| 200 | 'required' => [ 'query' ], |
| 201 | 'additionalProperties' => false, |
| 202 | ], |
| 203 | 'output_schema' => [ |
| 204 | 'type' => 'object', |
| 205 | 'properties' => [ |
| 206 | 'success' => [ 'type' => 'boolean' ], |
| 207 | 'columns' => [ 'type' => 'array' ], |
| 208 | 'rows' => [ 'type' => 'array' ], |
| 209 | 'row_count' => [ 'type' => 'integer' ], |
| 210 | 'truncated' => [ 'type' => 'boolean' ], |
| 211 | 'message' => [ 'type' => 'string' ], |
| 212 | ], |
| 213 | 'required' => [ 'success', 'message' ], |
| 214 | ], |
| 215 | 'execute_callback' => function( $input = [] ) use ( $self ) { |
| 216 | global $wpdb; |
| 217 | $query = isset( $input['query'] ) ? trim( (string) $input['query'] ) : ''; |
| 218 | $guard = $self->validate_select( $query ); |
| 219 | if ( isset( $guard['error'] ) ) { |
| 220 | return [ 'success' => false, 'message' => $guard['error'] ]; |
| 221 | } |
| 222 | $sql = $guard['sql']; |
| 223 | |
| 224 | $rows = $wpdb->get_results( $sql, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL |
| 225 | if ( $wpdb->last_error ) { |
| 226 | return [ 'success' => false, 'message' => 'SQL error: ' . $wpdb->last_error ]; |
| 227 | } |
| 228 | if ( ! is_array( $rows ) ) { |
| 229 | $rows = []; |
| 230 | } |
| 231 | $truncated = false; |
| 232 | if ( count( $rows ) > $self::MAX_ROWS ) { |
| 233 | $rows = array_slice( $rows, 0, $self::MAX_ROWS ); |
| 234 | $truncated = true; |
| 235 | } |
| 236 | $rows = $self->redact_rows( $rows ); |
| 237 | $columns = ! empty( $rows ) ? array_keys( $rows[0] ) : []; |
| 238 | |
| 239 | return [ |
| 240 | 'success' => true, |
| 241 | 'columns' => $columns, |
| 242 | 'rows' => $rows, |
| 243 | 'row_count' => count( $rows ), |
| 244 | 'truncated' => $truncated, |
| 245 | 'message' => sprintf( '%d row(s) returned%s.', count( $rows ), $truncated ? sprintf( ' (capped at %d)', $self::MAX_ROWS ) : '' ), |
| 246 | ]; |
| 247 | }, |
| 248 | 'permission_callback' => function() { return current_user_can( 'manage_options' ); }, |
| 249 | 'meta' => $self->ro_meta(), |
| 250 | ] ); |
| 251 | } |
| 252 | |
| 253 | /* --------------------------------------------------------------------- */ |
| 254 | /* Helpers */ |
| 255 | /* --------------------------------------------------------------------- */ |
| 256 | |
| 257 | /** Read-only ability meta. */ |
| 258 | public function ro_meta() { |
| 259 | return [ |
| 260 | 'mcp' => [ 'public' => true, 'type' => 'tool' ], |
| 261 | 'annotations' => [ 'readonly' => true, 'destructive' => false, 'idempotent' => true ], |
| 262 | ]; |
| 263 | } |
| 264 | |
| 265 | /** |
| 266 | * Resolve an ABSPATH-relative path to a real, contained absolute path. |
| 267 | * Returns [ 'abs' => ..., 'rel' => ... ] or [ 'error' => ... ]. |
| 268 | */ |
| 269 | public function resolve_within_abspath( $rel ) { |
| 270 | $root = realpath( ABSPATH ); |
| 271 | if ( false === $root ) { |
| 272 | return [ 'error' => 'Could not resolve the WordPress root.' ]; |
| 273 | } |
| 274 | $root = rtrim( str_replace( '\\', '/', $root ), '/' ); |
| 275 | $rel = ltrim( str_replace( '\\', '/', (string) $rel ), '/' ); |
| 276 | $rel = preg_replace( '#/+#', '/', $rel ); |
| 277 | |
| 278 | $candidate = ( '' === $rel || '.' === $rel ) ? $root : $root . '/' . $rel; |
| 279 | $real = realpath( $candidate ); |
| 280 | if ( false === $real ) { |
| 281 | return [ 'error' => 'Path not found within the WordPress installation.' ]; |
| 282 | } |
| 283 | $real = str_replace( '\\', '/', $real ); |
| 284 | if ( $real !== $root && strpos( $real, $root . '/' ) !== 0 ) { |
| 285 | return [ 'error' => 'Refused: path resolves outside the WordPress installation.' ]; |
| 286 | } |
| 287 | $rel_out = ( $real === $root ) ? '.' : ltrim( substr( $real, strlen( $root ) ), '/' ); |
| 288 | return [ 'abs' => $real, 'rel' => $rel_out ]; |
| 289 | } |
| 290 | |
| 291 | /** Whether a file is credential/secret material that must not be read. */ |
| 292 | public function is_secret_file( $abs ) { |
| 293 | $base = strtolower( basename( $abs ) ); |
| 294 | $ext = strtolower( pathinfo( $abs, PATHINFO_EXTENSION ) ); |
| 295 | |
| 296 | $secret_names = [ |
| 297 | 'wp-config.php', 'wp-config-local.php', 'wp-config-sample.php', |
| 298 | '.htpasswd', 'auth.json', '.netrc', 'id_rsa', 'id_dsa', 'id_ecdsa', 'id_ed25519', |
| 299 | ]; |
| 300 | if ( in_array( $base, $secret_names, true ) ) { |
| 301 | return true; |
| 302 | } |
| 303 | if ( in_array( $ext, [ 'key', 'pem', 'p12', 'pfx', 'crt', 'cer', 'ppk', 'keystore', 'jks' ], true ) ) { |
| 304 | return true; |
| 305 | } |
| 306 | // .env and its variants (.env.local, .env.production, etc.) |
| 307 | if ( 0 === strpos( $base, '.env' ) ) { |
| 308 | return true; |
| 309 | } |
| 310 | return false; |
| 311 | } |
| 312 | |
| 313 | /** |
| 314 | * Validate a single read-only statement. Returns [ 'sql' => ... ] or |
| 315 | * [ 'error' => ... ]. A single statement beginning with a read verb cannot |
| 316 | * mutate data; we additionally reject stacked statements and file access. |
| 317 | */ |
| 318 | public function validate_select( $query ) { |
| 319 | $query = trim( (string) $query ); |
| 320 | if ( '' === $query ) { |
| 321 | return [ 'error' => 'query is required.' ]; |
| 322 | } |
| 323 | // Strip a single trailing semicolon; anything after one is a stacked statement. |
| 324 | $query = rtrim( $query ); |
| 325 | if ( ';' === substr( $query, -1 ) ) { |
| 326 | $query = rtrim( substr( $query, 0, -1 ) ); |
| 327 | } |
| 328 | if ( preg_match( '/;\s*\S/', $query ) ) { |
| 329 | return [ 'error' => 'Only a single statement is allowed (stacked statements are rejected).' ]; |
| 330 | } |
| 331 | if ( ! preg_match( '/^\s*(SELECT|SHOW|DESCRIBE|DESC|EXPLAIN|WITH)\b/i', $query ) ) { |
| 332 | return [ 'error' => 'Only read-only statements are allowed: begin with SELECT, SHOW, DESCRIBE, EXPLAIN or WITH.' ]; |
| 333 | } |
| 334 | if ( preg_match( '/\bINTO\s+(OUTFILE|DUMPFILE)\b/i', $query ) || preg_match( '/\bLOAD_FILE\s*\(/i', $query ) ) { |
| 335 | return [ 'error' => 'File access via SQL (INTO OUTFILE/DUMPFILE, LOAD_FILE) is not allowed.' ]; |
| 336 | } |
| 337 | return [ 'sql' => $query ]; |
| 338 | } |
| 339 | |
| 340 | /** Redact credential columns / auth meta in query results. */ |
| 341 | public function redact_rows( $rows ) { |
| 342 | $secret_cols = [ 'user_pass', 'user_activation_key' ]; |
| 343 | $secret_meta = [ 'session_tokens', '_application_passwords' ]; |
| 344 | foreach ( $rows as &$row ) { |
| 345 | if ( ! is_array( $row ) ) { |
| 346 | continue; |
| 347 | } |
| 348 | foreach ( $row as $col => $val ) { |
| 349 | if ( in_array( strtolower( (string) $col ), $secret_cols, true ) ) { |
| 350 | $row[ $col ] = '[REDACTED]'; |
| 351 | } |
| 352 | } |
| 353 | // usermeta-shaped rows: redact auth meta values by key. |
| 354 | if ( isset( $row['meta_key'], $row['meta_value'] ) && in_array( $row['meta_key'], $secret_meta, true ) ) { |
| 355 | $row['meta_value'] = '[REDACTED]'; |
| 356 | } |
| 357 | } |
| 358 | unset( $row ); |
| 359 | return $rows; |
| 360 | } |
| 361 | } |