PluginProbe
ZIP AI – AI Website Builder & AI Agent / 0.0.12
ZIP AI – AI Website Builder & AI Agent v0.0.12
0.0.14 0.0.13 0.0.12 0.0.11 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 / core / snippet-executor.php

snippet-executor.php in ZIP AI – AI Website Builder & AI Agent 0.0.12, at inc/core/snippet-executor.php

852 lines 30.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Snippet Executor
4 *
5 * Loads and executes enabled code snippets from wp-content/zip-ai-snippets/.
6 * Each snippet folder can contain: snippet.php, snippet.js, snippet.css.
7 *
8 * Execution is configurable per file type:
9 * - Hook: which WordPress action to run on (init, wp_footer, admin_head, etc.)
10 * - Priority: execution order (1-99)
11 * - Scope: where to run (frontend, admin, everywhere, login)
12 * - Conditions: conditional logic (page type, user role, post type, device)
13 *
14 * Guardrails:
15 * - Path containment check before include/file_get_contents
16 * - File integrity verification via SHA-256 hashes
17 * - Auto-disable on fatal error
18 * - Safe mode via URL param + secret key
19 * - Error isolation per snippet
20 *
21 * @since 0.0.5
22 * @package zip-ai
23 */
24
25 namespace ZipAI\MCP\Classes\Core;
26
27 if ( ! defined( 'ABSPATH' ) ) {
28 exit;
29 }
30
31 class Snippet_Executor {
32
33 /**
34 * Cached manifest — loaded once per request.
35 *
36 * @var array<string,mixed>|null
37 */
38 private static $manifest = null;
39
40 /**
41 * Currently executing snippet slug — used by shutdown handler.
42 *
43 * @var string|null
44 */
45 private static $executing_slug = null;
46
47 /**
48 * Map of file paths to snippet slugs — for deferred callback fatal error detection.
49 *
50 * @var array<string,string>
51 */
52 private static $file_to_slug = array();
53
54 /**
55 * Hooks that fire before the `wp` action (early hooks).
56 * Page conditions cannot be evaluated on these hooks.
57 *
58 * @since 0.0.5
59 * @var array<int,string>
60 */
61 private static $early_hooks = array( 'plugins_loaded', 'init', 'wp_loaded', 'admin_init' );
62
63 /**
64 * Per-slug ring buffer cap. Trace entries beyond this drop the oldest.
65 *
66 * @since 0.0.5
67 */
68 const TRACE_RING_SIZE = 50;
69
70 /**
71 * Whether trace recording is active for this request. Set on init via
72 * `?zip_ai_snippet_trace=1` (admin only) or filter. Avoids the cost of
73 * trace evaluation on every request.
74 *
75 * @since 0.0.5
76 * @var bool|null
77 */
78 private static $trace_active = null;
79
80 /**
81 * Whether trace recording is on. Honored when:
82 * - `?zip_ai_snippet_trace=1` is present and current user can `manage_options`
83 * - Filter `zip_ai_snippets_trace_active` returns true
84 *
85 * @since 0.0.5
86 */
87 private static function is_trace_active(): bool {
88 if ( null !== self::$trace_active ) {
89 return self::$trace_active;
90 }
91 $on = false;
92 // Trace output is snippet introspection, so it tracks the snippet read
93 // capability instead of a hard-coded `manage_options` — keeps the caps in
94 // one place when a site filters them.
95 if ( function_exists( 'current_user_can' ) && Snippet_Store::current_user_can_read() ) {
96 $uid = get_current_user_id();
97 if ( $uid > 0 && (bool) get_user_meta( $uid, 'zip_ai_snippets_trace_enabled', true ) ) {
98 $on = true;
99 }
100 }
101 self::$trace_active = (bool) apply_filters( 'zip_ai_snippets_trace_active', $on );
102 return self::$trace_active;
103 }
104
105 /**
106 * Evaluate conditions for a snippet and (optionally) record a trace.
107 *
108 * When trace mode is active, this:
109 * 1. Runs evaluate_with_trace instead of evaluate.
110 * 2. Pushes the result into a per-slug transient ring buffer.
111 * 3. Emits an HTML comment on the next render hook so the page source
112 * surfaces why a snippet did/didn't fire.
113 *
114 * Returns the boolean pass/fail so the callbacks treat it identically to
115 * the existing `Snippet_Conditions::evaluate()` call.
116 *
117 * @since 0.0.5
118 * @param string $slug Snippet slug.
119 * @param array<int,array<string,mixed>> $conditions Condition rows to evaluate.
120 * @param bool $is_early Whether the hook fires before the `wp` action.
121 * @param string $context Trace context label.
122 * @return bool Whether the snippet's conditions pass.
123 */
124 private static function check_conditions( $slug, $conditions, $is_early, $context = 'frontend' ) {
125 if ( ! self::is_trace_active() ) {
126 return Snippet_Conditions::evaluate( $conditions, $is_early );
127 }
128
129 $trace = Snippet_Conditions::evaluate_with_trace( $conditions, $is_early );
130 $entry = array(
131 'time' => gmdate( 'c' ),
132 'context' => $context,
133 'request_uri' => isset( $_SERVER['REQUEST_URI'] ) && is_string( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '',
134 'is_early' => (bool) $is_early,
135 'result' => ! empty( $trace['result'] ),
136 'evaluated_targeting' => Snippet_Store::describe_conditions( $conditions ),
137 'trace' => $trace,
138 );
139 self::record_trace_entry( $slug, $entry );
140 self::emit_trace_comment( $slug, $entry );
141 return ! empty( $trace['result'] );
142 }
143
144 /**
145 * Record a trace entry into the per-slug transient ring buffer.
146 *
147 * @param string $slug Snippet slug.
148 * @param array<string,mixed> $entry Evaluation trace entry to append.
149 * @return void
150 */
151 private static function record_trace_entry( $slug, array $entry ) {
152 $key = 'zip_ai_snippets_trace_' . $slug;
153 $ring = get_transient( $key );
154 if ( ! is_array( $ring ) ) {
155 $ring = array();
156 }
157 $ring[] = $entry;
158 if ( count( $ring ) > self::TRACE_RING_SIZE ) {
159 $ring = array_slice( $ring, -self::TRACE_RING_SIZE );
160 }
161 set_transient( $key, $ring, HOUR_IN_SECONDS );
162 }
163
164 /**
165 * Emit a single HTML comment summarising the evaluation for this slug.
166 * Hooks late on `wp_head`/`wp_footer` so it appears near the snippet
167 * output (or absence) and never lands inside `<title>` etc.
168 *
169 * @param string $slug Snippet slug.
170 * @param array<string,mixed> $entry Evaluation trace entry.
171 * @return void
172 */
173 private static function emit_trace_comment( $slug, array $entry ) {
174 $short_reason = ! empty( $entry['result'] ) ? 'FIRE' : 'SKIP';
175 $summary = sprintf(
176 '<!-- zip-ai-trace: %s %s — %s -->',
177 esc_html( $slug ),
178 $short_reason,
179 esc_html( is_string( $entry['evaluated_targeting'] ) ? $entry['evaluated_targeting'] : '' )
180 );
181 $emit = static function () use ( $summary ) {
182 echo "\n" . $summary . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
183 };
184 add_action( 'wp_footer', $emit, 9999 );
185 add_action( 'admin_footer', $emit, 9999 );
186 add_action( 'login_footer', $emit, 9999 );
187 }
188
189 /**
190 * Boot the executor: register the safe-mode notice, shutdown handler, and snippets.
191 *
192 * @return void
193 */
194 public static function init() {
195 if ( self::is_safe_mode() ) {
196 return;
197 }
198
199 // Show admin notice when safe mode is active.
200 add_action( 'admin_notices', array( __CLASS__, 'safe_mode_notice' ) );
201
202 register_shutdown_function( array( __CLASS__, 'shutdown_handler' ) );
203
204 self::register_snippets();
205 }
206
207 // ── Safe Mode ──────────────────────────────────────────────────────
208
209 /**
210 * Check if safe mode is active. Runs during plugin load — NO user functions available.
211 */
212 private static function is_safe_mode(): bool {
213 // Persistent safe mode (transient) — no auth check needed, just a boolean flag.
214 if ( get_transient( 'zip_ai_snippets_safe_mode' ) ) {
215 return true;
216 }
217
218 // One-time key mode (legacy).
219 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
220 if ( ! empty( $_GET['zip_ai_safe_mode'] ) ) {
221 $secret = self::get_safe_mode_key();
222 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
223 if ( is_string( $_GET['zip_ai_safe_mode'] ) && hash_equals( $secret, $_GET['zip_ai_safe_mode'] ) ) {
224 delete_option( 'zip_ai_snippets_safe_mode_key' );
225 error_log( '[ZIP AI Snippets] Safe mode activated — all snippets disabled for this request. Key rotated.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- diagnostic logging for snippet execution; no WP core logger equivalent.
226 return true;
227 }
228 }
229
230 return false;
231 }
232
233 /**
234 * Show admin notice when persistent safe mode is active.
235 *
236 * @since 0.0.5
237 */
238 public static function safe_mode_notice(): void {
239 $expires = get_transient( 'zip_ai_snippets_safe_mode' );
240 if ( ! $expires ) {
241 return;
242 }
243
244 $remaining = is_numeric( $expires ) ? max( 0, (int) $expires - time() ) : 0;
245 $minutes = $remaining > 0 ? ceil( $remaining / 60 ) : '?';
246
247 echo '<div class="notice notice-warning"><p>';
248 printf(
249 '🛡️ <strong>ZIP AI Snippets Safe Mode</strong> — All snippets are disabled (%s min remaining). Manage in <a href="%s">ZIP AI Code Snippets</a>.',
250 esc_html( (string) $minutes ),
251 esc_url( admin_url( 'options-general.php?page=zip-ai-snippets' ) )
252 );
253 echo '</p></div>';
254 }
255
256 /**
257 * Check if persistent safe mode is currently active.
258 *
259 * @since 0.0.5
260 * @return bool
261 */
262 public static function is_persistent_safe_mode() {
263 return (bool) get_transient( 'zip_ai_snippets_safe_mode' );
264 }
265
266 /**
267 * Get (or lazily generate) the one-time safe-mode secret key.
268 *
269 * @return string
270 */
271 private static function get_safe_mode_key() {
272 $key = get_option( 'zip_ai_snippets_safe_mode_key', '' );
273 if ( empty( $key ) ) {
274 $key = wp_generate_password( 32, false );
275 update_option( 'zip_ai_snippets_safe_mode_key', $key, false );
276 }
277 return is_string( $key ) ? $key : '';
278 }
279
280 // ── Fatal Error Auto-Disable ───────────────────────────────────────
281
282 /**
283 * Auto-disable the offending snippet when a fatal error traces back to the
284 * snippets base directory.
285 *
286 * @return void
287 */
288 public static function shutdown_handler() {
289 $error = error_get_last();
290 if ( ! $error || ! in_array( $error['type'], array( E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR ), true ) ) {
291 return;
292 }
293
294 $base = Snippet_Store::base_dir();
295 if ( strpos( $error['file'], $base ) !== 0 ) {
296 return;
297 }
298
299 $slug = self::$executing_slug;
300 if ( ! $slug && ! empty( self::$file_to_slug ) ) {
301 foreach ( self::$file_to_slug as $filepath => $s ) {
302 if ( strpos( $error['file'], dirname( $filepath ) ) === 0 ) {
303 $slug = $s;
304 break;
305 }
306 }
307 }
308
309 if ( ! $slug ) {
310 return;
311 }
312
313 error_log( sprintf( '[ZIP AI Snippets] Fatal error in "%s" — auto-disabling. Error: %s in %s:%d', $slug, $error['message'], $error['file'], $error['line'] ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- diagnostic logging for snippet execution; no WP core logger equivalent.
314
315 $manifest = Snippet_Store::load();
316 if ( isset( $manifest[ $slug ] ) && is_array( $manifest[ $slug ] ) ) {
317 $manifest[ $slug ]['enabled'] = false;
318 $manifest[ $slug ]['auto_disabled'] = true;
319 $manifest[ $slug ]['disabled_reason'] = $error['message'];
320 $manifest[ $slug ]['disabled_at'] = gmdate( 'Y-m-d H:i:s' );
321 Snippet_Store::save( $manifest );
322 }
323 }
324
325 // ── Core ───────────────────────────────────────────────────────────
326
327 /**
328 * Load the manifest once per request and memoize it.
329 *
330 * @return array<string,mixed>
331 */
332 private static function load_manifest() {
333 if ( null !== self::$manifest ) {
334 return self::$manifest;
335 }
336
337 self::$manifest = Snippet_Store::load();
338 return self::$manifest;
339 }
340
341 /**
342 * Verify a snippet file against its stored SHA-256 hash.
343 *
344 * @param string $filepath Absolute snippet file path.
345 * @param string $slug Snippet slug.
346 * @param string $type File type (php, js, css, html).
347 * @param array<string,mixed> $meta Snippet metadata (carries file_hashes).
348 * @return bool
349 */
350 private static function verify_integrity( $filepath, $slug, $type, $meta ) {
351 $hashes = isset( $meta['file_hashes'] ) && is_array( $meta['file_hashes'] ) ? $meta['file_hashes'] : array();
352 $expected = isset( $hashes[ $type ] ) && is_string( $hashes[ $type ] ) ? $hashes[ $type ] : '';
353 if ( ! $expected ) {
354 error_log( sprintf( '[ZIP AI Snippet] No hash for "%s/snippet.%s" — skipping.', $slug, $type ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- diagnostic logging for snippet execution; no WP core logger equivalent.
355 return false;
356 }
357 $actual = hash_file( 'sha256', $filepath );
358 if ( ! hash_equals( $expected, (string) $actual ) ) {
359 error_log( sprintf( '[ZIP AI Snippet] Hash mismatch for "%s/snippet.%s" — skipping.', $slug, $type ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- diagnostic logging for snippet execution; no WP core logger equivalent.
360 return false;
361 }
362 return true;
363 }
364
365 // ── Scope Checking ────────────────────────────────────────────────
366
367 /**
368 * Check if the current request matches the given scope.
369 *
370 * @since 0.0.5
371 * @param string $scope One of: frontend, admin, everywhere, login.
372 * @return bool
373 */
374 private static function matches_scope( $scope ) {
375 switch ( $scope ) {
376 case 'frontend':
377 return ! is_admin() && ! self::is_login_page();
378 case 'admin':
379 return is_admin();
380 case 'login':
381 return self::is_login_page();
382 case 'everywhere':
383 default:
384 return true;
385 }
386 }
387
388 /**
389 * Check if conditions array contains any late-evaluated types (page, post_type, post, device).
390 *
391 * @since 0.0.5
392 * @param array<int,array<string,mixed>> $conditions Conditions array.
393 * @return bool
394 */
395 private static function has_late_conditions( $conditions ) {
396 $late_types = array( 'page', 'post_type', 'post', 'device' );
397 foreach ( $conditions as $cond ) {
398 if ( isset( $cond['type'] ) && in_array( $cond['type'], $late_types, true ) ) {
399 return true;
400 }
401 }
402 return false;
403 }
404
405 /**
406 * Check if the current request is a login page.
407 *
408 * @since 0.0.5
409 * @return bool
410 */
411 private static function is_login_page() {
412 return in_array( $GLOBALS['pagenow'] ?? '', array( 'wp-login.php', 'wp-register.php' ), true );
413 }
414
415 // ── Registration ───────────────────────────────────────────────────
416
417 /**
418 * Register each enabled snippet on its configured hooks.
419 *
420 * @since 0.0.5
421 */
422 private static function register_snippets(): void {
423 $manifest = self::load_manifest();
424
425 foreach ( $manifest as $slug => $meta ) {
426 if ( '_version' === $slug ) {
427 continue;
428 }
429
430 // Normalize to get execution config + conditions + sandbox with defaults.
431 /**
432 * Narrowed type for `$meta_raw`.
433 *
434 * @var array<string,mixed> $meta_raw
435 */
436 $meta_raw = (array) $meta;
437 $meta = Snippet_Store::normalize_snippet( $meta_raw );
438
439 $is_sandbox = ! empty( $meta['sandbox'] );
440 $is_enabled = ! empty( $meta['enabled'] );
441
442 // Skip if neither enabled nor in sandbox.
443 if ( ! $is_enabled && ! $is_sandbox ) {
444 continue;
445 }
446
447 if ( empty( $meta['files'] ) || ! is_array( $meta['files'] ) ) {
448 continue;
449 }
450
451 $snippet_files = self::resolve_snippet_files( $slug, $meta );
452 if ( empty( $snippet_files ) ) {
453 continue;
454 }
455
456 /**
457 * Narrowed type for `$conditions`.
458 *
459 * @var array<int,array<string,mixed>> $conditions
460 */
461 $conditions = isset( $meta['conditions'] ) && is_array( $meta['conditions'] ) ? $meta['conditions'] : array();
462 $sandbox_user = $is_sandbox ? Utils::to_int( $meta['sandbox_user'] ?? null, 0 ) : 0;
463 $execution = isset( $meta['execution'] ) && is_array( $meta['execution'] ) ? $meta['execution'] : array();
464
465 foreach ( $snippet_files as $type => $filepath ) {
466 $exec = isset( $execution[ $type ] ) && is_array( $execution[ $type ] ) ? $execution[ $type ] : array();
467 $hook = isset( $exec['hook'] ) && is_string( $exec['hook'] ) ? $exec['hook'] : self::get_default_hook( $type );
468 $priority = Utils::to_int( $exec['priority'] ?? null, 10 );
469 $scope = isset( $exec['scope'] ) && is_string( $exec['scope'] ) ? $exec['scope'] : 'frontend';
470 $accepted_args = 'php' === $type ? Utils::to_int( $exec['accepted_args'] ?? null, 1 ) : 1;
471
472 $callback = self::make_callback( $type, $slug, $filepath, $scope, $conditions, $hook, $sandbox_user );
473 if ( $callback ) {
474 add_action( self::resolve_action_hook( $type, $hook ), $callback, $priority, $accepted_args );
475 }
476 }
477 }
478 }
479
480 /**
481 * Get the default hook for a file type.
482 *
483 * @since 0.0.5
484 * @param string $type File type (php, js, css).
485 * @return string
486 */
487 private static function get_default_hook( $type ) {
488 $defaults = Snippet_Store::default_execution();
489 $entry = $defaults[ $type ] ?? array();
490 return $entry['hook'] ?? 'init';
491 }
492
493 /**
494 * Resolve the WordPress action hook name for a file type + configured hook.
495 *
496 * JS and CSS need special handling — they use enqueue actions, not the hook directly.
497 *
498 * @since 0.0.5
499 * @param string $type File type (php, js, css).
500 * @param string $hook Configured hook name.
501 * @return string WordPress action name.
502 */
503 private static function resolve_action_hook( $type, $hook ) {
504 // PHP and HTML hooks map directly to WordPress actions.
505 // HTML snippets emit verbatim markup — no enqueue indirection.
506 if ( 'php' === $type || 'html' === $type ) {
507 return $hook;
508 }
509
510 // Footer CSS cannot use wp_add_inline_style because styles are printed
511 // during the enqueue/style-print phases. Run directly at wp_footer so
512 // the callback's explicit <style> output lands where configured.
513 if ( 'css' === $type && 'wp_footer' === $hook ) {
514 return 'wp_footer';
515 }
516
517 // JS and CSS: map configured hooks to enqueue actions.
518 $enqueue_map = array(
519 'wp_head' => 'wp_enqueue_scripts',
520 'wp_footer' => 'wp_enqueue_scripts',
521 'admin_head' => 'admin_enqueue_scripts',
522 'admin_footer' => 'admin_enqueue_scripts',
523 'login_head' => 'login_enqueue_scripts',
524 );
525
526 return $enqueue_map[ $hook ] ?? 'wp_enqueue_scripts';
527 }
528
529 /**
530 * Resolve and validate snippet files for execution.
531 *
532 * @since 0.1.0
533 * @param string $slug Snippet slug.
534 * @param array<string,mixed> $meta Snippet metadata.
535 * @return array<string,string> Array of [type => filepath] pairs that passed security checks.
536 */
537 private static function resolve_snippet_files( $slug, $meta ) {
538 $files = array();
539
540 $file_types = isset( $meta['files'] ) && is_array( $meta['files'] ) ? $meta['files'] : array();
541 foreach ( $file_types as $type ) {
542 if ( ! is_string( $type ) ) {
543 continue;
544 }
545 $filepath = Snippet_Store::snippet_file( $slug, $type );
546 if ( self::validate_snippet_file( $filepath, $slug, $type, $meta ) ) {
547 $files[ $type ] = $filepath;
548 }
549 }
550
551 return $files;
552 }
553
554 /**
555 * Validate a snippet file (path containment + existence + integrity).
556 *
557 * @since 0.0.5
558 * @param string $filepath Absolute snippet file path.
559 * @param string $slug Snippet slug.
560 * @param string $type File type (php, js, css, html).
561 * @param array<string,mixed> $meta Snippet metadata.
562 * @return bool
563 */
564 private static function validate_snippet_file( $filepath, $slug, $type, $meta ) {
565 if ( ! Snippet_Store::is_safe_path( $filepath ) ) {
566 error_log( sprintf( '[ZIP AI Snippet] Path escape blocked for "%s".', $slug ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- diagnostic logging for snippet execution; no WP core logger equivalent.
567 return false;
568 }
569 if ( ! file_exists( $filepath ) ) {
570 return false;
571 }
572 if ( ! self::verify_integrity( $filepath, $slug, $type, $meta ) ) {
573 return false;
574 }
575
576 self::$file_to_slug[ $filepath ] = $slug;
577 return true;
578 }
579
580 // ── Callback Factory ──────────────────────────────────────────────
581
582 /**
583 * Create an execution callback for a snippet file.
584 *
585 * The callback checks scope and conditions at runtime (not registration time)
586 * because WordPress context functions like is_admin() and is_home() may not
587 * be available when hooks are registered.
588 *
589 * @since 0.0.5
590 * @param string $type File type (php, js, css).
591 * @param string $slug Snippet slug.
592 * @param string $filepath Absolute file path.
593 * @param string $scope Execution scope.
594 * @param array<int,array<string,mixed>> $conditions Conditional logic array.
595 * @param string $hook Configured hook name.
596 * @param int $sandbox_user Sandbox user ID (0 = not sandboxed).
597 * @return \Closure|null
598 */
599 private static function make_callback( $type, $slug, $filepath, $scope, $conditions, $hook, $sandbox_user = 0 ) {
600 $is_early = in_array( $hook, self::$early_hooks, true );
601
602 switch ( $type ) {
603 case 'php':
604 return self::make_php_callback( $slug, $filepath, $scope, $conditions, $is_early, $sandbox_user );
605 case 'js':
606 return self::make_js_callback( $slug, $filepath, $scope, $conditions, $is_early, $hook, $sandbox_user );
607 case 'css':
608 return self::make_css_callback( $slug, $filepath, $scope, $conditions, $is_early, $hook, $sandbox_user );
609 case 'html':
610 return self::make_html_callback( $slug, $filepath, $scope, $conditions, $is_early, $sandbox_user );
611 default:
612 return null;
613 }
614 }
615
616 /**
617 * Check if the current user matches the sandbox user.
618 * Returns true if not in sandbox mode (sandbox_user = 0) or if user matches.
619 * Must be called AFTER WordPress loads the user (not on early hooks).
620 *
621 * @since 0.0.5
622 * @param int $sandbox_user User ID (0 = not sandboxed).
623 * @return bool
624 */
625 private static function is_sandbox_allowed( $sandbox_user ) {
626 if ( 0 === $sandbox_user ) {
627 return true; // Not sandboxed — run for everyone.
628 }
629 return get_current_user_id() === $sandbox_user;
630 }
631
632 /**
633 * Build the PHP-snippet execution callback (deferred when early hooks lack
634 * user/conditional context).
635 *
636 * @since 0.0.5
637 * @param string $slug Snippet slug.
638 * @param string $filepath Absolute snippet file path.
639 * @param string $scope Execution scope.
640 * @param array<int,array<string,mixed>> $conditions Condition rows to evaluate.
641 * @param bool $is_early Whether the hook fires before the `wp` action.
642 * @param int $sandbox_user Sandbox user ID (0 = not sandboxed).
643 * @return \Closure
644 */
645 private static function make_php_callback( $slug, $filepath, $scope, $conditions, $is_early, $sandbox_user = 0 ) {
646 // Sandbox or late conditions require deferral — user ID and conditional tags
647 // aren't available on early hooks (init, wp_loaded, admin_init).
648 $needs_defer = $is_early && ( $sandbox_user > 0 || self::has_late_conditions( $conditions ) );
649
650 if ( $needs_defer ) {
651 return function ( ...$hook_args ) use ( $slug, $filepath, $scope, $conditions, $sandbox_user ) {
652 add_action(
653 'template_redirect',
654 function () use ( $slug, $filepath, $scope, $conditions, $sandbox_user ) {
655 if ( ! self::matches_scope( $scope ) ) {
656 return;
657 }
658 if ( ! self::is_sandbox_allowed( $sandbox_user ) ) {
659 return;
660 }
661 if ( ! self::check_conditions( $slug, $conditions, false, 'php:deferred' ) ) {
662 return;
663 }
664
665 self::include_php_snippet( $slug, $filepath );
666 }
667 );
668 return $hook_args[0] ?? null;
669 };
670 }
671
672 return function ( ...$hook_args ) use ( $slug, $filepath, $scope, $conditions, $is_early, $sandbox_user ) {
673 if ( ! self::matches_scope( $scope ) ) {
674 return $hook_args[0] ?? null;
675 }
676 if ( ! self::is_sandbox_allowed( $sandbox_user ) ) {
677 return $hook_args[0] ?? null;
678 }
679 if ( ! self::check_conditions( $slug, $conditions, $is_early, 'php' ) ) {
680 return $hook_args[0] ?? null;
681 }
682
683 return self::include_php_snippet( $slug, $filepath, $hook_args );
684 };
685 }
686
687 /**
688 * Include PHP snippet code with optional hook/filter arguments.
689 *
690 * Snippet authors can read `$args`, `$zip_ai_hook_value`, or `$zip_ai_hook_args`.
691 * For filter hooks, returning a value from the snippet file wins; otherwise a
692 * mutated `$args` variable is returned. Action hooks ignore the return value.
693 *
694 * @since 0.0.5
695 * @param string $slug Snippet slug.
696 * @param string $filepath PHP snippet file path.
697 * @param array<int|string,mixed> $hook_args Arguments passed by the WordPress hook/filter.
698 * @return mixed
699 */
700 private static function include_php_snippet( $slug, $filepath, array $hook_args = array() ) {
701 $zip_ai_hook_args = array_values( $hook_args );
702 if ( array_key_exists( 0, $zip_ai_hook_args ) ) {
703 $args =& $zip_ai_hook_args[0];
704 $zip_ai_hook_value =& $zip_ai_hook_args[0];
705 } else {
706 $args = null;
707 $zip_ai_hook_value = null;
708 }
709 $result = null;
710
711 self::$executing_slug = $slug;
712 try {
713 // SECURITY — accepted residual (audit zip-ai#314 #4): this runs
714 // author-written PHP. RCE is by design for an admin-authored snippet.
715 // The only gate is the *enable* step's brain/client approval card
716 // (see ManageCodeSnippet::toggle_snippet) plus admin-only authoring —
717 // there is no server-side human check here, and the Snippet_Lint
718 // blocklist is defense-in-depth, not a security boundary. A snippet
719 // only reaches this line if it was enabled, so the trust decision
720 // lives at enable time, not execution time.
721 $result = include $filepath;
722 } catch ( \Throwable $e ) {
723 error_log( sprintf( '[ZIP AI Snippet] "%s" error: %s', $slug, $e->getMessage() ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- diagnostic logging for snippet execution; no WP core logger equivalent.
724 } finally {
725 self::$executing_slug = null;
726 }
727
728 if ( null !== $result && 1 !== $result ) {
729 return $result;
730 }
731
732 return $args;
733 }
734
735 /**
736 * Build the CSS-snippet enqueue/inline-style callback.
737 *
738 * @since 0.0.5
739 * @param string $slug Snippet slug.
740 * @param string $filepath Absolute snippet file path.
741 * @param string $scope Execution scope.
742 * @param array<int,array<string,mixed>> $conditions Condition rows to evaluate.
743 * @param bool $is_early Whether the hook fires before the `wp` action.
744 * @param string $hook Configured hook name.
745 * @param int $sandbox_user Sandbox user ID (0 = not sandboxed).
746 * @return \Closure
747 */
748 private static function make_css_callback( $slug, $filepath, $scope, $conditions, $is_early, $hook, $sandbox_user = 0 ) {
749 return function () use ( $slug, $filepath, $scope, $conditions, $is_early, $hook, $sandbox_user ) {
750 if ( ! self::matches_scope( $scope ) ) {
751 return;
752 }
753 if ( ! self::is_sandbox_allowed( $sandbox_user ) ) {
754 return;
755 }
756 if ( ! self::check_conditions( $slug, $conditions, $is_early, 'css' ) ) {
757 return;
758 }
759
760 $handle = 'zip-ai-snippet-' . $slug;
761 $content = file_get_contents( $filepath ); // phpcs:ignore WordPress.WP.AlternativeFunctions
762
763 if ( 'wp_footer' === $hook ) {
764 // wp_footer doesn't support wp_add_inline_style — output directly.
765 echo '<style id="' . esc_attr( $handle ) . '">' . $content . '</style>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
766 } else {
767 wp_register_style( $handle, false ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters
768 wp_enqueue_style( $handle );
769 wp_add_inline_style( $handle, (string) $content );
770 }
771 };
772 }
773
774 /**
775 * Build the JS-snippet enqueue/inline-script callback.
776 *
777 * @since 0.0.5
778 * @param string $slug Snippet slug.
779 * @param string $filepath Absolute snippet file path.
780 * @param string $scope Execution scope.
781 * @param array<int,array<string,mixed>> $conditions Condition rows to evaluate.
782 * @param bool $is_early Whether the hook fires before the `wp` action.
783 * @param string $hook Configured hook name.
784 * @param int $sandbox_user Sandbox user ID (0 = not sandboxed).
785 * @return \Closure
786 */
787 private static function make_js_callback( $slug, $filepath, $scope, $conditions, $is_early, $hook, $sandbox_user = 0 ) {
788 $in_footer = in_array( $hook, array( 'wp_footer', 'admin_footer' ), true );
789
790 return function () use ( $slug, $filepath, $scope, $conditions, $is_early, $in_footer, $sandbox_user ) {
791 if ( ! self::matches_scope( $scope ) ) {
792 return;
793 }
794 if ( ! self::is_sandbox_allowed( $sandbox_user ) ) {
795 return;
796 }
797 if ( ! self::check_conditions( $slug, $conditions, $is_early, 'js' ) ) {
798 return;
799 }
800
801 $handle = 'zip-ai-snippet-' . $slug;
802 $content = file_get_contents( $filepath ); // phpcs:ignore WordPress.WP.AlternativeFunctions
803 wp_register_script( $handle, false, array(), false, $in_footer ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters
804 wp_enqueue_script( $handle );
805 wp_add_inline_script( $handle, (string) $content );
806 };
807 }
808
809 /**
810 * Build an HTML-snippet callback. Emits the file contents verbatim on the
811 * configured WordPress hook (wp_head, wp_footer, login_head, etc.) wrapped
812 * in a tagged HTML comment for traceability. No enqueue-pipeline wrapping —
813 * authors are responsible for valid markup. Use case: external script tag
814 * loaders (GA gtag.js, GTM, FB Pixel, Hotjar), JSON-LD, OG/meta blocks,
815 * `<noscript>` tracking pixels — anything the JS/CSS pipeline would
816 * double-wrap in `<script>`/`<style>` tags.
817 *
818 * @since 0.0.5
819 * @param string $slug Snippet slug.
820 * @param string $filepath Absolute snippet file path.
821 * @param string $scope Execution scope.
822 * @param array<int,array<string,mixed>> $conditions Condition rows to evaluate.
823 * @param bool $is_early Whether the hook fires before the `wp` action.
824 * @param int $sandbox_user Sandbox user ID (0 = not sandboxed).
825 * @return \Closure
826 */
827 private static function make_html_callback( $slug, $filepath, $scope, $conditions, $is_early, $sandbox_user = 0 ) {
828 return function () use ( $slug, $filepath, $scope, $conditions, $is_early, $sandbox_user ) {
829 if ( ! self::matches_scope( $scope ) ) {
830 return;
831 }
832 if ( ! self::is_sandbox_allowed( $sandbox_user ) ) {
833 return;
834 }
835 if ( ! self::check_conditions( $slug, $conditions, $is_early, 'html' ) ) {
836 return;
837 }
838
839 $content = file_get_contents( $filepath ); // phpcs:ignore WordPress.WP.AlternativeFunctions
840 if ( false === $content || '' === $content ) {
841 return;
842 }
843
844 // Traceable wrapper so devtools "view source" surfaces the source
845 // snippet without altering the rendered markup semantics.
846 echo "\n<!-- zip-ai-snippet:" . esc_html( $slug ) . " -->\n";
847 echo $content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
848 echo "\n<!-- /zip-ai-snippet:" . esc_html( $slug ) . " -->\n";
849 };
850 }
851 }
852