PluginProbe ʕ •ᴥ•ʔ
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback / trunk
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback vtrunk
5.1.3 5.1.2 5.1.1 5.1 5.0 trunk 3.10 3.11 3.12 3.13 3.14 3.15 3.16 3.17 3.18 3.19 3.2.0 3.2.1 3.22 3.22.1 3.22.2 3.22.3 3.22.4 3.22.5 3.22.6 3.3.0 3.3.1 3.3.2 3.3.2.1 3.3.2.2 3.3.3 3.30 3.31 3.32 3.4 3.4.1 3.4.3 3.4.4 3.5 3.5.1 3.6 3.6.1 3.7 3.8 3.9 3.9.1 3.9.2 3.9.3 3.9.4 3.9.6 3.9.6.1 4.0 4.0.1 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.0.7 4.0.8 4.0.9 4.1.0 4.1.1 4.1.2 4.1.3 4.2 4.2.1 4.2.2 4.3 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 4.4
atarim-visual-collaboration / doit / abilities / class-avcf-abilities-readonly.php
atarim-visual-collaboration / doit / abilities Last commit date
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 }