PluginProbe
ZIP AI – AI Website Builder & AI Agent (Beta) / trunk
ZIP AI – AI Website Builder & AI Agent (Beta) vtrunk
0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / inc / abilities / ability-loader.php

ability-loader.php in ZIP AI – AI Website Builder & AI Agent (Beta) trunk, at inc/abilities/ability-loader.php

332 lines 10.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Ability Loader Trait
4 *
5 * @package zip-ai
6 */
7
8 namespace ZipAI\MCP\Classes\Abilities;
9
10 // Exit if accessed directly.
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 use ZipAI\MCP\Classes\Abilities\Abstract_Ability;
16
17 /**
18 * Trait Ability_Loader
19 */
20 trait Ability_Loader {
21
22 /**
23 * Validate an ability ID against the canonical `{namespace}/{action}-{resource}` format.
24 *
25 * @param Abstract_Ability $ability Ability instance to validate.
26 * @return bool True when the ID matches the required format.
27 */
28 public function check_ability_format( $ability ) {
29 $id = $ability->get_id();
30 $canonical_actions = array(
31 // CRUD.
32 'list',
33 'get',
34 'create',
35 'update',
36 'delete',
37 // State.
38 'activate',
39 'deactivate',
40 'restore',
41 // Lifecycle.
42 'install',
43 'uninstall',
44 'upload',
45 // Data.
46 'import',
47 'export',
48 'flush',
49 'replace',
50 // Operations.
51 'check',
52 'clean',
53 'run',
54 'edit',
55 'read',
56 'search',
57 'scan',
58 );
59 $pattern = '/^[a-z0-9-]+\/(' . implode( '|', $canonical_actions ) . ')-[a-z0-9-]+$/';
60
61 if ( ! preg_match( $pattern, $id ) ) {
62 // Check if we are in export mode or debug mode.
63 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
64 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error -- WP_DEBUG-gated developer diagnostic for malformed ability IDs.
65 trigger_error(
66 esc_html(
67 sprintf(
68 'Invalid Ability ID format: "%s" in class %s. Expected format: "{namespace}/{action}-{resource}" (e.g., "zipai/update-post_format", "zipai/list-block-patterns"). Action must be one of: %s.',
69 $id,
70 get_class( $ability ),
71 implode( ', ', $canonical_actions )
72 )
73 ),
74 E_USER_WARNING
75 );
76 }
77 return false;
78 }
79 return true;
80 }
81
82 /**
83 * Abilities skipped at registration. Hidden from MCP discovery entirely
84 * (LLM cannot see them, internal callers cannot use them either). Use the
85 * `internal` visibility flag in an ability's get_meta() to keep it
86 * registered for backend/server-only callers while hiding from the LLM.
87 *
88 * ExecuteRestRequest stays registered (internal callers like
89 * PageDeliveryService, ParallelPageBuilderService still need it); it is
90 * hidden from the LLM via meta visibility instead.
91 *
92 * Currently empty — ExecuteAjaxAction and SearchEndpoints were removed
93 * outright (unused by the LLM and every internal caller).
94 *
95 * @var array<int,string>
96 */
97 protected static $disabled_abilities = array();
98
99 /**
100 * Build a basename index for disabled abilities.
101 *
102 * Accepts either `ClassName` or `Vendor\Ns\ClassName` entries and
103 * normalizes both to `ClassName` for the file-basename match used by
104 * directory scanning.
105 *
106 * @return array<string, true>
107 */
108 protected function get_disabled_abilities_index() {
109 $index = array();
110
111 foreach ( self::$disabled_abilities as $ability_class ) {
112 if ( '' === $ability_class ) {
113 continue;
114 }
115
116 $normalized_class_name = ltrim( $ability_class, '\\' );
117 $last_separator = strrpos( $normalized_class_name, '\\' );
118 $basename = false === $last_separator
119 ? $normalized_class_name
120 : substr( $normalized_class_name, $last_separator + 1 );
121
122 if ( '' !== $basename ) {
123 $index[ $basename ] = true;
124 }
125 }
126
127 return $index;
128 }
129
130 /**
131 * Assemble the `meta` array an ability is registered with.
132 *
133 * @param Abstract_Ability $ability Ability to describe.
134 * @return array<string,mixed>
135 */
136 protected function build_meta( $ability ) {
137 $meta = array(
138 'tool_type' => $ability->get_tool_type(),
139 'examples' => $ability->get_examples(),
140 'api_endpoint' => $ability->get_api_endpoint(),
141 );
142
143 // Add boost screens if defined.
144 $boost_screens = $ability->get_boost_screens();
145 if ( ! empty( $boost_screens ) ) {
146 $meta['boost_screens'] = $boost_screens;
147 }
148
149 // Add resource identifier if defined.
150 $resource = $ability->get_resource();
151 if ( ! empty( $resource ) ) {
152 $meta['resource'] = $resource;
153 }
154
155 // Read-only sub-action allowlist for multiplexed abilities.
156 // Forwarded through meta → MCP tools/list → the server so the
157 // writes-require-approval gate can treat safe sub-actions
158 // (e.g. `action:"list"` on an otherwise-destructive tool)
159 // as reads. Empty for single-purpose abilities.
160 $read_only_actions = $ability->get_read_only_actions();
161 if ( ! empty( $read_only_actions ) ) {
162 $meta['read_only_actions'] = array_values( $read_only_actions );
163 }
164
165 // Merge with any class-specific meta.
166 $meta = array_merge( $meta, $ability->get_meta_data() );
167
168 /*
169 * Both keys route around REST_API::check_permission(): `show_in_rest`
170 * makes the ability EXECUTABLE (not merely listed) on core's
171 * `/wp-abilities/v1/abilities/{name}/run`, which applies no capability
172 * beyond the ability's own; `mcp.public` exposes it on the bundled
173 * adapter's default server, whose floor is `read`. Exposing an ability
174 * there must be a reviewed decision, not a meta key.
175 *
176 * The whole `mcp` key goes, not just `mcp.public` — nothing here needs
177 * any of it (the adapter also reads `mcp.type`), and a partial strip
178 * would invite a fresh review of every sub-key someone adds later.
179 */
180 unset( $meta['show_in_rest'], $meta['mcp'] );
181
182 // Add constraints if the method exists.
183 if ( method_exists( $ability, 'get_constraints' ) ) {
184 $meta['constraints'] = $ability->get_constraints();
185 }
186
187 return $meta;
188 }
189
190 /**
191 * Discover and register ability classes found under a directory.
192 *
193 * @param string $directory Directory to scan for ability class files.
194 * @param string $namespace Base namespace for the discovered classes.
195 * @return void
196 */
197 protected function load_abilities_from_dir( $directory, $namespace ) {
198 // Normalize directory path.
199 $directory = trailingslashit( $directory );
200 $disabled_abilities_index = $this->get_disabled_abilities_index();
201
202 // Check if directory exists.
203 if ( ! is_dir( $directory ) ) {
204 return;
205 }
206
207 // Use RecursiveDirectoryIterator for recursive scanning.
208 $directory_iterator = new \RecursiveDirectoryIterator( $directory, \RecursiveDirectoryIterator::SKIP_DOTS );
209 $iterator = new \RecursiveIteratorIterator( $directory_iterator );
210
211 foreach ( $iterator as $file_info ) {
212 if ( ! $file_info instanceof \SplFileInfo ) {
213 continue;
214 }
215
216 if ( $file_info->isDir() || 'php' !== $file_info->getExtension() ) {
217 continue;
218 }
219
220 $file_path = $file_info->getPathname();
221 $class_name = $file_info->getBasename( '.php' );
222
223 // Skip index and handler files.
224 if ( 'index' === $class_name || 'handler' === $class_name ) {
225 continue;
226 }
227
228 if ( isset( $disabled_abilities_index[ $class_name ] ) ) {
229 continue;
230 }
231
232 // Calculate class namespace based on relative path.
233 $relative_path = str_replace( array( $directory, '.php' ), '', $file_path );
234 $path_parts = explode( DIRECTORY_SEPARATOR, $relative_path );
235 array_pop( $path_parts ); // Remove class name from parts.
236
237 // Capitalize each path part to match PSR-4 namespace convention.
238 $path_parts = array_map( 'ucfirst', $path_parts );
239
240 $sub_namespace = ! empty( $path_parts ) ? '\\' . implode( '\\', $path_parts ) : '';
241
242 // File names are WordPress-style (plugin-install.php) while class
243 // names may be CamelCase (PluginInstall) or Snake_Case
244 // (Plugin_Install) — try each candidate against the autoloader.
245 $candidates = array_unique(
246 array(
247 $class_name,
248 str_replace( '-', '', ucwords( $class_name, '-' ) ),
249 str_replace( '-', '_', ucwords( $class_name, '-' ) ),
250 )
251 );
252
253 $full_class_name = '';
254 foreach ( $candidates as $candidate ) {
255 $fqcn = $namespace . $sub_namespace . '\\' . $candidate;
256 // is_subclass_of also skips non-ability classes and the
257 // abstract base itself (instantiating it would fatal).
258 if ( class_exists( $fqcn ) && is_subclass_of( $fqcn, Abstract_Ability::class ) ) {
259 $full_class_name = $fqcn;
260 break;
261 }
262 }
263
264 if ( '' === $full_class_name ) {
265 continue;
266 }
267
268 // Instantiate the ability (guaranteed Abstract_Ability subclass).
269 $ability = new $full_class_name();
270
271 $meta = $this->build_meta( $ability );
272
273 // MCP tool annotations, declared BY the ability (derived from its tool
274 // type + destructive flag, overridden where those miss the point).
275 $meta['annotations'] = $ability->get_annotations();
276
277 // The MCP Adapter's `execute-ability` dispatcher gates on
278 // `meta.mcp.public`. This class classifies the external surface. So it
279 // sets the flag here. This lets an external agent reach Era's abilities
280 // by name. We do not need to advertise every schema.
281 //
282 // This is the ONE sanctioned writer of the flag. It must stay AFTER
283 // build_meta(). build_meta strips `show_in_rest` and `mcp` that an
284 // ability declares on ITSELF (DSA-19/20). Self-exposure is not a
285 // reviewed decision. This block IS the reviewed decision. Two gates
286 // guard it: the Connection toggle and the exposure policy.
287 //
288 // There is NO denylist. Every `zipai/*` ability gets the flag. The
289 // site's `zip_ai_external_allowed_abilities` filter can withhold it.
290 // `is_exposed()` enforces that filter. So narrowing the filter governs
291 // execution, not just the displayed list. Each ability's own capability
292 // is the floor. So an ADMIN application password pasted into an AI
293 // client can reach `run-wp-cli` and `run-snippet`. This runs arbitrary
294 // code. This is by design. The admin already has this reach in wp-admin.
295 //
296 // This is gated on the endpoint being switched on. The adapter's
297 // dispatcher reads the flag. It reads it on EVERY server, including the
298 // adapter's own. Without this gate, turning the Connection toggle off
299 // would stop advertising our tools. But the abilities would still run
300 // elsewhere.
301 if ( \ZipAI\MCP\Classes\Api\External_Mcp::is_enabled()
302 && \ZipAI\MCP\Classes\Core\External_Tool_Policy::is_exposed( $ability->get_id() ) ) {
303 $meta['mcp'] = array( 'public' => true );
304 }
305
306 // Validate ID format.
307 $this->check_ability_format( $ability );
308
309 // Register directly with wp_register_ability (no lazy loading).
310 // The skip is silent by design: check_ability_format() above has
311 // already trigger_error()'d under WP_DEBUG for malformed IDs, and
312 // this mirrors wp_register_ability's own lowercase-only rejection.
313 $ability_id = $ability->get_id();
314 if ( '' === $ability_id || '0' === $ability_id || strtolower( $ability_id ) !== $ability_id ) {
315 continue;
316 }
317 wp_register_ability(
318 $ability_id,
319 array(
320 'label' => $ability->get_label(),
321 'description' => $ability->get_description(),
322 'category' => $ability->get_category(),
323 'input_schema' => $ability->get_final_input_schema(),
324 'execute_callback' => array( $ability, 'handle_execute' ),
325 'permission_callback' => array( $ability, 'check_permission' ),
326 'meta' => $meta,
327 )
328 );
329 }
330 }
331 }
332