class-avcf-abilities-base.php
4 days ago
class-avcf-abilities-block-navigation.php
4 days ago
class-avcf-abilities-cache.php
4 days ago
class-avcf-abilities-content.php
4 days ago
class-avcf-abilities-core.php
4 days ago
class-avcf-abilities-execute-php.php
4 days ago
class-avcf-abilities-global-styles.php
4 days ago
class-avcf-abilities-gutenberg.php
4 days ago
class-avcf-abilities-media.php
4 days ago
class-avcf-abilities-metadata.php
4 days ago
class-avcf-abilities-navigation.php
4 days ago
class-avcf-abilities-patterns.php
4 days ago
class-avcf-abilities-plugins.php
4 days ago
class-avcf-abilities-readonly.php
4 days ago
class-avcf-abilities-settings.php
4 days ago
class-avcf-abilities-taxonomies.php
4 days ago
class-avcf-abilities-templates.php
4 days ago
class-avcf-abilities-theme-files.php
4 days ago
class-avcf-abilities-themes.php
4 days ago
class-avcf-abilities-users.php
4 days ago
class-avcf-abilities-wp-cli.php
4 days ago
class-avcf-abilities-execute-php.php
142 lines
| 1 | <?php |
| 2 | /** |
| 3 | * execute-php: run arbitrary PHP (developer / debug capability). |
| 4 | * |
| 5 | * This is the most powerful ability in the plugin — arbitrary code execution, |
| 6 | * equivalent in reach to WP-CLI `wp eval`, Code Snippets, or WP Console. It is |
| 7 | * therefore heavily gated: |
| 8 | * |
| 9 | * - Refuses when DISALLOW_FILE_EDIT / DISALLOW_FILE_MODS is set — a site that |
| 10 | * forbids code changes should not have an eval endpoint either. This is the |
| 11 | * plugin-side gate. Opt-in / consent for actually invoking this ability is |
| 12 | * handled on the AGENT side (the MCP client prompts the operator before the |
| 13 | * call), so this ability is not additionally blocklisted plugin-side. |
| 14 | * - Requires `manage_options` (administrator). |
| 15 | * - Every execution is audit-logged (user, time, code hash) via error_log and |
| 16 | * the `avcf_execute_php_audit` action so a site can capture it. |
| 17 | * - Output and return value are size-capped; execution is wrapped in a |
| 18 | * Throwable catch (catches Errors/ParseErrors; true fatals like OOM are not |
| 19 | * catchable in PHP and will surface as a request failure). |
| 20 | * |
| 21 | * @package atarim-visual-collaboration |
| 22 | */ |
| 23 | |
| 24 | if ( ! defined( 'ABSPATH' ) ) { |
| 25 | exit; |
| 26 | } |
| 27 | |
| 28 | class AVCF_Abilities_ExecutePHP { |
| 29 | |
| 30 | /** Max characters returned for captured output and for the return value each. */ |
| 31 | const MAX_OUTPUT = 102400; // 100 KB |
| 32 | |
| 33 | public function register() { |
| 34 | if ( ! function_exists( 'wp_register_ability' ) ) { |
| 35 | return; |
| 36 | } |
| 37 | $self = $this; |
| 38 | wp_register_ability( 'atarim/execute-php', [ |
| 39 | 'label' => 'Execute PHP (developer)', |
| 40 | 'description' => 'Run a snippet of PHP on the server and return its echoed output and return value. Powerful developer/debug tool — equivalent to WP-CLI "wp eval". Do NOT include a "<?php" opening tag; the code is evaluated directly (use "return $x;" to return a value). WordPress is fully loaded, so core/plugin functions and $wpdb are available. Every call is audit-logged. Refused when the site disables file editing (DISALLOW_FILE_EDIT). Output and return value are capped at 100 KB each.', |
| 41 | 'category' => 'atarim', |
| 42 | 'input_schema' => [ |
| 43 | 'type' => 'object', |
| 44 | 'properties' => [ |
| 45 | 'code' => [ 'type' => 'string', 'description' => 'PHP code to evaluate (no <?php tag). Use return to hand back a value; echo/print is captured as output.' ], |
| 46 | ], |
| 47 | 'required' => [ 'code' ], |
| 48 | 'additionalProperties' => false, |
| 49 | ], |
| 50 | 'output_schema' => [ |
| 51 | 'type' => 'object', |
| 52 | 'properties' => [ |
| 53 | 'success' => [ 'type' => 'boolean' ], |
| 54 | 'executed' => [ 'type' => 'boolean' ], |
| 55 | 'return_value' => [ 'type' => 'string' ], |
| 56 | 'output' => [ 'type' => 'string' ], |
| 57 | 'output_truncated' => [ 'type' => 'boolean' ], |
| 58 | 'error' => [ 'type' => 'string' ], |
| 59 | 'message' => [ 'type' => 'string' ], |
| 60 | ], |
| 61 | 'required' => [ 'success', 'message' ], |
| 62 | ], |
| 63 | 'execute_callback' => function( $input = [] ) use ( $self ) { |
| 64 | if ( ( defined( 'DISALLOW_FILE_EDIT' ) && DISALLOW_FILE_EDIT ) || ( defined( 'DISALLOW_FILE_MODS' ) && DISALLOW_FILE_MODS ) ) { |
| 65 | return [ 'success' => false, 'executed' => false, 'message' => 'Refused: PHP execution is turned off because this site disables code/file modifications at the configuration level (DISALLOW_FILE_EDIT or DISALLOW_FILE_MODS is set in wp-config.php). This is a permanent, site-wide setting, so retrying will not help — an administrator would need to change the site configuration to allow it.' ]; |
| 66 | } |
| 67 | $code = isset( $input['code'] ) ? (string) $input['code'] : ''; |
| 68 | if ( '' === trim( $code ) ) { |
| 69 | return [ 'success' => false, 'executed' => false, 'message' => 'code is required.' ]; |
| 70 | } |
| 71 | // A leading <?php would be a parse error under eval(); strip one if present. |
| 72 | $code = preg_replace( '/^\s*<\?php\s*/', '', $code ); |
| 73 | |
| 74 | $self->audit( $code ); |
| 75 | |
| 76 | $error = null; |
| 77 | $ret = null; |
| 78 | ob_start(); |
| 79 | try { |
| 80 | $ret = eval( $code ); // phpcs:ignore Squiz.PHP.Eval |
| 81 | } catch ( \Throwable $e ) { |
| 82 | $error = $e->getMessage() . ' (' . get_class( $e ) . ')'; |
| 83 | } |
| 84 | $output = (string) ob_get_clean(); |
| 85 | |
| 86 | $ret_str = $self->cap( ( null === $ret ) ? 'null' : var_export( $ret, true ) ); |
| 87 | $out_cap = $self->cap( $output ); |
| 88 | |
| 89 | if ( null !== $error ) { |
| 90 | return [ |
| 91 | 'success' => false, |
| 92 | 'executed' => true, |
| 93 | 'output' => $out_cap['text'], |
| 94 | 'error' => $error, |
| 95 | 'message' => 'PHP raised an error: ' . $error, |
| 96 | ]; |
| 97 | } |
| 98 | return [ |
| 99 | 'success' => true, |
| 100 | 'executed' => true, |
| 101 | 'return_value' => $ret_str['text'], |
| 102 | 'output' => $out_cap['text'], |
| 103 | 'output_truncated' => ( $out_cap['truncated'] || $ret_str['truncated'] ), |
| 104 | 'message' => 'Executed.' . ( ( $out_cap['truncated'] || $ret_str['truncated'] ) ? ' Output/return value was truncated at 100 KB.' : '' ), |
| 105 | ]; |
| 106 | }, |
| 107 | 'permission_callback' => function() { return current_user_can( 'manage_options' ); }, |
| 108 | 'meta' => [ |
| 109 | 'mcp' => [ 'public' => true, 'type' => 'tool' ], |
| 110 | 'annotations' => [ 'readonly' => false, 'destructive' => true, 'idempotent' => false ], |
| 111 | ], |
| 112 | ] ); |
| 113 | } |
| 114 | |
| 115 | /** Record an execution to the error log and an action hook. */ |
| 116 | public function audit( $code ) { |
| 117 | $user = function_exists( 'wp_get_current_user' ) ? wp_get_current_user() : null; |
| 118 | $entry = [ |
| 119 | 'time' => gmdate( 'c' ), |
| 120 | 'user_id' => ( $user && isset( $user->ID ) ) ? (int) $user->ID : 0, |
| 121 | 'login' => ( $user && isset( $user->user_login ) ) ? (string) $user->user_login : '', |
| 122 | 'sha1' => sha1( (string) $code ), |
| 123 | 'bytes' => strlen( (string) $code ), |
| 124 | 'preview' => substr( (string) $code, 0, 200 ), |
| 125 | ]; |
| 126 | error_log( sprintf( // phpcs:ignore |
| 127 | '[atarim/execute-php] user=%d(%s) bytes=%d sha1=%s', |
| 128 | $entry['user_id'], $entry['login'], $entry['bytes'], $entry['sha1'] |
| 129 | ) ); |
| 130 | do_action( 'avcf_execute_php_audit', $entry ); |
| 131 | } |
| 132 | |
| 133 | /** Cap a string to MAX_OUTPUT; returns [ 'text' => ..., 'truncated' => bool ]. */ |
| 134 | public function cap( $text ) { |
| 135 | $text = (string) $text; |
| 136 | if ( strlen( $text ) > self::MAX_OUTPUT ) { |
| 137 | return [ 'text' => substr( $text, 0, self::MAX_OUTPUT ), 'truncated' => true ]; |
| 138 | } |
| 139 | return [ 'text' => $text, 'truncated' => false ]; |
| 140 | } |
| 141 | } |
| 142 |