PluginProbe
ZIP AI – AI Website Builder & AI Agent / 0.0.10
ZIP AI – AI Website Builder & AI Agent v0.0.10
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-lint.php

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

733 lines 26.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Snippet Lint — production gate that prevents agent + human authors from
4 * embedding targeting/routing checks inside snippet code instead of using
5 * the structured `conditions[]` field.
6 *
7 * Why this exists:
8 * When the agent slips up — or a schema rejection elsewhere makes it think
9 * `conditions[]` "doesn't work" — it falls back to rewriting the snippet
10 * body with `if ( ! is_home() ) return;` or `if ( get_the_ID() !== 2805 )`
11 * wrappers. Once that pattern lands in HEAD, it survives migrations, leaks
12 * into versions, and is invisible at the manifest layer (executor sees the
13 * snippet as "global" and runs it everywhere; the body silently filters).
14 * We block it at write time so it never reaches disk.
15 *
16 * This is the production version of what was previously enforced only via
17 * tool-description nudges. Author intent: "if a snippet wants targeting,
18 * it MUST come through `conditions[]` — code is for what to do, not where."
19 *
20 * Filterable:
21 * - `zip_ai_snippets_lint_enabled` (bool) — kill switch.
22 * - `zip_ai_snippets_lint_targeting_functions` (array) — extend or override the
23 * forbidden function map (key = function name, value = remediation hint).
24 *
25 * @since 0.0.5
26 * @package zip-ai
27 */
28
29 namespace ZipAI\MCP\Classes\Core;
30
31 if ( ! defined( 'ABSPATH' ) ) {
32 exit;
33 }
34
35 class Snippet_Lint {
36
37 /**
38 * Default forbidden-targeting function → remediation map.
39 *
40 * Each entry says: "if you find this function in snippet code, the author
41 * is doing routing/targeting; the right place for that is the
42 * `conditions[]` payload. Here is the equivalent shape."
43 *
44 * @return array<string,string>
45 */
46 public static function targeting_function_map() {
47 $map = array(
48 // Page-type routing
49 'is_home' => "conditions:[{type:'page',operator:'is',value:'home'}]",
50 'is_front_page' => "conditions:[{type:'page',operator:'is',value:'home'}]",
51 'is_single' => "conditions:[{type:'page',operator:'is',value:'single'}]",
52 'is_singular' => "conditions:[{type:'page',operator:'is',value:'single'}]",
53 'is_page' => "conditions:[{type:'page',operator:'is',value:'page'}] (or {type:'post',value:'<id>'} for a specific page)",
54 'is_archive' => "conditions:[{type:'page',operator:'is',value:'archive'}]",
55 'is_category' => "conditions:[{type:'page',operator:'is',value:'archive'}]",
56 'is_tag' => "conditions:[{type:'page',operator:'is',value:'archive'}]",
57 'is_tax' => "conditions:[{type:'page',operator:'is',value:'archive'}]",
58 'is_search' => "conditions:[{type:'page',operator:'is',value:'search'}]",
59 'is_404' => "conditions:[{type:'page',operator:'is',value:'404'}]",
60
61 // Specific post / post-type
62 'get_the_ID' => "conditions:[{type:'post',operator:'is',value:'<id>'}]",
63 'get_post_type' => "conditions:[{type:'post_type',operator:'is',value:'<type>'}]",
64
65 // User / role
66 'is_user_logged_in' => "conditions:[{type:'user',operator:'is',value:'logged_in'}]",
67 'current_user_can' => "conditions:[{type:'user_role',operator:'in',values:['administrator','editor',...]}]",
68
69 // Device
70 'wp_is_mobile' => "conditions:[{type:'device',operator:'is',value:'mobile'}]",
71
72 // Admin / frontend split
73 'is_admin' => "execution.<type>.scope = 'admin' (admin-only) or 'frontend' (frontend-only)",
74 );
75 $filtered = apply_filters( 'zip_ai_snippets_lint_targeting_functions', $map );
76 if ( is_array( $filtered ) ) {
77 /**
78 * Narrowed type for `$filtered`.
79 *
80 * @var array<string,string> $filtered
81 */
82 return $filtered;
83 }
84 return $map;
85 }
86
87 /**
88 * Detect forbidden inline-targeting calls inside snippet PHP code.
89 *
90 * Token-based scan — comments and strings are skipped automatically.
91 *
92 * @param string $php_code Raw snippet body (with or without `<?php`).
93 * @return array<int,array{function:string,line:int,hint:string}>
94 */
95 public static function detect_inline_targeting( $php_code ) {
96 if ( ! apply_filters( 'zip_ai_snippets_lint_enabled', true ) ) {
97 return array();
98 }
99 if ( '' === trim( (string) $php_code ) ) {
100 return array();
101 }
102
103 // Ensure opening tag so `token_get_all` parses correctly even when
104 // callers strip the leading `<?php`.
105 $prefixed = ltrim( $php_code );
106 $has_tag = stripos( $prefixed, '<?php' ) === 0 || stripos( $prefixed, '<?' ) === 0;
107 $source = $has_tag ? $php_code : ( '<?php ' . $php_code );
108
109 try {
110 $tokens = @token_get_all( $source ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
111 } catch ( \Throwable $e ) {
112 // If parsing fails outright, downstream syntax check will report it.
113 return array();
114 }
115
116 $map = self::targeting_function_map();
117 $hits = array();
118 $name_tokens = array( T_STRING );
119 if ( defined( 'T_NAME_FULLY_QUALIFIED' ) ) {
120 $name_tokens[] = T_NAME_FULLY_QUALIFIED;
121 }
122
123 // Track preceding non-whitespace token so we can ignore method calls
124 // (`$obj->is_home()`) while still catching global calls such as
125 // `\is_page()` and mixed-case variants.
126 $prev_meaningful = null;
127
128 foreach ( $tokens as $token ) {
129 if ( is_array( $token ) ) {
130 $id = $token[0];
131 $text = $token[1];
132 $line = $token[2];
133
134 if ( in_array( $id, array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ) {
135 continue;
136 }
137
138 $function_name = strtolower( ltrim( $text, '\\' ) );
139 if ( in_array( $id, $name_tokens, true ) && isset( $map[ $function_name ] ) ) {
140 $is_method = ( T_OBJECT_OPERATOR === $prev_meaningful );
141 if ( ! $is_method ) {
142 // Adjust line number when we synthesized the leading tag.
143 $reported_line = $has_tag ? $line : max( 1, $line );
144 $hits[] = array(
145 'function' => $function_name,
146 'line' => $reported_line,
147 'hint' => $map[ $function_name ],
148 );
149 }
150 }
151
152 $prev_meaningful = $id;
153 } else {
154 // Single-character tokens (`(`, `;`, `?` etc.) — track as
155 // non-method-operator so following identifiers count.
156 $prev_meaningful = null;
157 }
158 }
159
160 // Dedupe by function name keeping earliest line — agent only needs
161 // to know each fingerprint once.
162 $seen = array();
163 $out = array();
164 foreach ( $hits as $h ) {
165 if ( isset( $seen[ $h['function'] ] ) ) {
166 continue;
167 }
168 $seen[ $h['function'] ] = true;
169 $out[] = $h;
170 }
171 return $out;
172 }
173
174 /**
175 * Detect code that wraps the same hook the snippet executor already uses.
176 *
177 * The executor includes PHP snippet files on execution.<type>.hook. If the
178 * body then calls add_action( '<same hook>', ... ), that callback is
179 * registered while the hook is already running and will not fire in the
180 * current request. The snippet saves cleanly but appears broken.
181 *
182 * @param string $php_code Raw snippet body (with or without `<?php`).
183 * @param string $execution_hook Hook configured in execution.php.hook.
184 * @return array<int,array{function:string,hook:string,line:int}>
185 */
186 public static function detect_redundant_hook_wrappers( $php_code, $execution_hook ) {
187 if ( ! apply_filters( 'zip_ai_snippets_lint_enabled', true ) ) {
188 return array();
189 }
190 $execution_hook = (string) $execution_hook;
191 if ( '' === $execution_hook || '' === trim( (string) $php_code ) ) {
192 return array();
193 }
194
195 $prefixed = ltrim( $php_code );
196 $has_tag = stripos( $prefixed, '<?php' ) === 0 || stripos( $prefixed, '<?' ) === 0;
197 $source = $has_tag ? $php_code : ( '<?php ' . $php_code );
198
199 try {
200 $tokens = @token_get_all( $source ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
201 } catch ( \Throwable $e ) {
202 return array();
203 }
204
205 $hits = array();
206 $count = count( $tokens );
207 for ( $i = 0; $i < $count; $i++ ) {
208 $token = $tokens[ $i ];
209 if ( ! is_array( $token ) || T_STRING !== $token[0] ) {
210 continue;
211 }
212 $function = strtolower( $token[1] );
213 if ( ! in_array( $function, array( 'add_action', 'add_filter' ), true ) ) {
214 continue;
215 }
216
217 $arg = self::first_string_argument( $tokens, $i + 1 );
218 if ( ! $arg || $arg['value'] !== $execution_hook ) {
219 continue;
220 }
221
222 $hits[] = array(
223 'function' => $function,
224 'hook' => $arg['value'],
225 'line' => $arg['line'],
226 );
227 }
228
229 return $hits;
230 }
231
232 /**
233 * Detect top-level output on hooks that run before headers are safe.
234 *
235 * Direct echo/print on `init` or `wp_loaded` sends output before REST,
236 * redirects, cookies, and admin headers can be emitted. Output snippets
237 * should run on a render hook such as `wp_head`, `wp_footer`, or register
238 * their later callback from the early hook.
239 *
240 * @param string $php_code Raw snippet body (with or without `<?php`).
241 * @param string $execution_hook Hook configured in execution.php.hook.
242 * @return array<int,array{token:string,line:int,hook:string}>
243 */
244 public static function detect_early_direct_output( $php_code, $execution_hook ) {
245 $no_direct_output_hooks = array( 'plugins_loaded', 'init', 'wp_loaded', 'wp_enqueue_scripts', 'template_redirect', 'admin_init' );
246 if ( ! in_array( (string) $execution_hook, $no_direct_output_hooks, true ) || '' === trim( (string) $php_code ) ) {
247 return array();
248 }
249
250 $prefixed = ltrim( $php_code );
251 $has_tag = stripos( $prefixed, '<?php' ) === 0 || stripos( $prefixed, '<?' ) === 0;
252 $source = $has_tag ? $php_code : ( '<?php ' . $php_code );
253
254 try {
255 $tokens = @token_get_all( $source ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
256 } catch ( \Throwable $e ) {
257 return array();
258 }
259
260 $depth = 0;
261 $hits = array();
262 foreach ( $tokens as $token ) {
263 $text = is_array( $token ) ? $token[1] : $token;
264 if ( '{' === $text ) {
265 ++$depth;
266 continue;
267 }
268 if ( '}' === $text ) {
269 $depth = max( 0, $depth - 1 );
270 continue;
271 }
272 if ( $depth > 0 || ! is_array( $token ) ) {
273 continue;
274 }
275 if (
276 in_array( $token[0], array( T_ECHO, T_PRINT, T_OPEN_TAG_WITH_ECHO ), true )
277 || ( T_INLINE_HTML === $token[0] && '' !== trim( $token[1] ) )
278 ) {
279 $hits[] = array(
280 'token' => strtolower( token_name( $token[0] ) ),
281 'line' => $token[2],
282 'hook' => (string) $execution_hook,
283 );
284 }
285 }
286
287 return $hits;
288 }
289
290 /**
291 * Find the first string argument after a function token.
292 *
293 * @param array<int,string|array{0:int,1:string,2:int}> $tokens Token stream from token_get_all().
294 * @param int $offset Search offset.
295 * @return array{value:string,line:int}|null
296 */
297 private static function first_string_argument( array $tokens, $offset ) {
298 $depth = 0;
299 $count = count( $tokens );
300 for ( $i = $offset; $i < $count; $i++ ) {
301 $token = $tokens[ $i ];
302 $text = is_array( $token ) ? $token[1] : $token;
303
304 if ( '(' === $text ) {
305 ++$depth;
306 continue;
307 }
308 if ( ')' === $text ) {
309 --$depth;
310 if ( $depth <= 0 ) {
311 return null;
312 }
313 continue;
314 }
315 if ( $depth < 1 ) {
316 continue;
317 }
318 if ( is_array( $token ) && in_array( $token[0], array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ) {
319 continue;
320 }
321 if ( is_array( $token ) && T_CONSTANT_ENCAPSED_STRING === $token[0] ) {
322 $value = trim( $token[1], "'\"" );
323 return array(
324 'value' => stripcslashes( $value ),
325 'line' => $token[2],
326 );
327 }
328 if ( ',' === $text ) {
329 return null;
330 }
331 }
332 return null;
333 }
334
335 /**
336 * Format the detected hits into a single human + agent-readable error
337 * string. Agents can parse the deterministic prefix; humans can read
338 * the inline guidance.
339 *
340 * @param array<int,array{function:string,line:int,hint:string}> $hits Result of `detect_inline_targeting()`.
341 * @return string
342 */
343 public static function format_violation( array $hits ) {
344 if ( empty( $hits ) ) {
345 return '';
346 }
347 $lines = array();
348 $lines[] = 'Inline targeting/routing detected in snippet code. Targeting MUST be expressed via the `conditions[]` field on the run-snippet tool call — not as `if ( ... ) return;` guards inside the snippet body.';
349 $lines[] = 'Move each of the following calls out of the code and into `conditions[]`, then resubmit:';
350 foreach ( $hits as $h ) {
351 $lines[] = sprintf(
352 ' • Line %d: `%s()` → %s',
353 $h['line'],
354 $h['function'],
355 $h['hint']
356 );
357 }
358 $lines[] = 'If you genuinely need one of these for non-routing logic (e.g. `current_user_can()` to gate a feature toggle, not page access), apply the `zip_ai_snippets_lint_targeting_functions` filter to whitelist it for this site.';
359 return implode( "\n", $lines );
360 }
361
362 /**
363 * Format redundant hook wrapper hits.
364 *
365 * @param array<int,array{function:string,hook:string,line:int}> $hits Result of `detect_redundant_hook_wrappers()`.
366 * @return string
367 */
368 public static function format_redundant_hook_violation( array $hits ) {
369 if ( empty( $hits ) ) {
370 return '';
371 }
372
373 $first = $hits[0];
374 return sprintf(
375 'Redundant hook wrapper detected. This snippet is already configured to run on `%1$s`, so `%2$s( \'%1$s\', ... )` inside the snippet body registers too late and will not run for the current request. Remove the wrapper and perform the work directly in the snippet body, or choose an earlier execution hook if the body must register a later hook.',
376 $first['hook'],
377 $first['function']
378 );
379 }
380
381 /**
382 * Format early direct output hits.
383 *
384 * @param array<int,array{token:string,line:int,hook:string}> $hits Result of `detect_early_direct_output()`.
385 * @return string
386 */
387 public static function format_early_output_violation( array $hits ) {
388 if ( empty( $hits ) ) {
389 return '';
390 }
391
392 $first = $hits[0];
393 return sprintf(
394 'Direct output detected on early/bootstrap hook `%s` at line %d. This can send headers too early, print in the wrong document phase, or break REST, redirects, cookies, and admin screens. Move execution.php.hook to a render hook such as `wp_footer`/`wp_head`, or register a later callback instead of echoing at top level.',
395 $first['hook'],
396 $first['line']
397 );
398 }
399
400 /**
401 * Detect HTML wrapper tags inside JS or CSS snippet bodies.
402 *
403 * The executor pipes JS/CSS snippet contents through wp_add_inline_script /
404 * wp_add_inline_style, which already wraps the payload in a `<script>` /
405 * `<style>` tag. Per HTML5 raw-text-element parsing, the *closing* tag
406 * (`</script>`/`</style>`) inside the body terminates the wrapper —
407 * everything after becomes regular HTML, so the intended JS/CSS never
408 * executes (this is exactly the failure mode of pasting the canonical GA
409 * gtag.js snippet into a `js` snippet — its `</script>` killed the inline
410 * wrapper and the loader tag became dead text).
411 *
412 * We only flag the *closing* tag, not the opening: a literal `<script` or
413 * `<style` substring inside raw-text is inert and shows up legitimately in
414 * regex/string literals (`const re = /<script>/g`, `'<style>'.length`). An
415 * over-aggressive opening-tag block would false-positive too often.
416 *
417 * `<!--` is also flagged because it puts the HTML parser into the
418 * "script-data-escaped" state, changing how subsequent `</script>` tokens
419 * are interpreted and confusing JS strict-mode parsers; modern JS/CSS
420 * bodies have no reason to contain raw HTML comments — use JS/CSS comment syntax instead.
421 *
422 * @param string $code Raw snippet body.
423 * @param string $type Snippet file type (js|css).
424 * @return array<int,array{tag:string,line:int}> Hits — empty if clean.
425 */
426 public static function detect_wrapper_tags( $code, $type ) {
427 if ( ! apply_filters( 'zip_ai_snippets_lint_enabled', true ) ) {
428 return array();
429 }
430 $code = (string) $code;
431 if ( '' === trim( $code ) ) {
432 return array();
433 }
434
435 if ( 'js' === $type ) {
436 $patterns = array(
437 '</script>' => '/<\/script\s*>/i',
438 '<!--' => '/<!--/',
439 );
440 } elseif ( 'css' === $type ) {
441 $patterns = array(
442 '</style>' => '/<\/style\s*>/i',
443 '<!--' => '/<!--/',
444 );
445 } else {
446 return array();
447 }
448
449 $hits = array();
450 foreach ( $patterns as $label => $regex ) {
451 if ( preg_match( $regex, $code, $m, PREG_OFFSET_CAPTURE ) ) {
452 $offset = (int) $m[0][1];
453 $line = substr_count( substr( $code, 0, $offset ), "\n" ) + 1;
454 $hits[] = array(
455 'tag' => $label,
456 'line' => $line,
457 );
458 }
459 }
460 return $hits;
461 }
462
463 /**
464 * Format wrapper-tag violation for agent + human consumption.
465 *
466 * @param array<int,array{tag:string,line:int}> $hits Result of `detect_wrapper_tags()`.
467 * @param string $type Snippet type (js|css).
468 * @return string
469 */
470 public static function format_wrapper_tag_violation( array $hits, $type ) {
471 if ( empty( $hits ) ) {
472 return '';
473 }
474 $type = (string) $type;
475 $first = $hits[0];
476 $pipeline = 'js' === $type ? 'wp_add_inline_script' : 'wp_add_inline_style';
477 $wrapper = 'js' === $type ? '<script>' : '<style>';
478 $bullets = array();
479 foreach ( $hits as $h ) {
480 $bullets[] = sprintf( ' • Line %d: `%s`', $h['line'], $h['tag'] );
481 }
482 return sprintf(
483 "%s snippet body contains HTML wrapper tags — found `%s` at line %d. The executor wraps %s snippets in a `%s...%s` tag via %s, so an inner closing tag terminates the wrapper prematurely and the surrounding markup never executes.\nHits:\n%s\nFix: pass only the body that belongs inside `%s` (no `<script>`/`<style>` tags, no HTML comments). For verbatim head/footer markup (external script loaders, JSON-LD, `<noscript>`, GA/GTM/FB Pixel tags), use `type:\"html\"` instead — its executor emits the file contents as-is on the configured hook.",
484 strtoupper( $type ),
485 $first['tag'],
486 $first['line'],
487 $type,
488 $wrapper,
489 str_replace( '<', '</', $wrapper ),
490 $pipeline,
491 implode( "\n", $bullets ),
492 $wrapper
493 );
494 }
495
496 /**
497 * Lint a non-PHP snippet body. JS/CSS run the wrapper-tag detector; HTML
498 * is emitted verbatim so it gets no body lint, but we reject `<?php` /
499 * `<?=` open tags inside HTML because the executor `echo`s the file as a
500 * static string — PHP open tags become literal text, never execute, and
501 * the author thinks their server-side logic is broken.
502 *
503 * @param string $code Raw snippet body.
504 * @param string $type Snippet type (js|css|html).
505 * @return \WP_Error|null
506 */
507 public static function check_non_php( $code, $type ) {
508 $type = (string) $type;
509 if ( 'html' === $type ) {
510 if ( preg_match( '/<\?(php|=)/i', (string) $code, $m, PREG_OFFSET_CAPTURE ) ) {
511 $offset = (int) $m[0][1];
512 $line = substr_count( substr( (string) $code, 0, $offset ), "\n" ) + 1;
513 return new \WP_Error(
514 'php_in_html_forbidden',
515 sprintf(
516 'HTML snippet body contains a PHP open tag (`%s`) at line %d. HTML snippets are emitted verbatim by `echo $content` on the configured hook — PHP open tags become literal text and never execute. For server-side logic, switch to `type:"php"`. For verbatim head/footer markup, remove the PHP and inline the resolved value.',
517 $m[0][0],
518 $line
519 ),
520 array(
521 'line' => $line,
522 'tag' => $m[0][0],
523 )
524 );
525 }
526 return null;
527 }
528 if ( ! in_array( $type, array( 'js', 'css' ), true ) ) {
529 return null;
530 }
531 $hits = self::detect_wrapper_tags( $code, $type );
532 if ( empty( $hits ) ) {
533 return null;
534 }
535 return new \WP_Error(
536 'wrapper_tags_forbidden',
537 self::format_wrapper_tag_violation( $hits, $type ),
538 array(
539 'hits' => $hits,
540 'type' => $type,
541 )
542 );
543 }
544
545 /**
546 * Convenience: returns WP_Error if violation found, null otherwise.
547 * Lets callers do `is_wp_error( Snippet_Lint::check( $code ) )`.
548 *
549 * @param string $php_code
550 * @param string $execution_hook Optional configured execution hook.
551 * @return \WP_Error|null
552 */
553 /**
554 * Scan a snippet body (any type) for likely hard-coded secrets.
555 *
556 * Pattern-matches common credential shapes — `api[_-]?key`, `bearer`,
557 * `secret`, `token`, AWS-style access keys, JWTs, GitHub tokens, etc. —
558 * paired with a high-entropy value or an obvious assignment. Returns a
559 * list of human-readable warning strings; an empty list means "looks
560 * clean to the eye". Non-blocking by design: secrets are sometimes the
561 * point of a snippet (analytics measurement IDs, public site keys), so
562 * we surface a heads-up instead of refusing the write.
563 *
564 * @since 0.0.5
565 * @param string $code Raw snippet body.
566 * @return array<int,string>
567 */
568 public static function detect_secrets( $code ) {
569 $code = (string) $code;
570 if ( '' === trim( $code ) ) {
571 return array();
572 }
573 $warnings = array();
574
575 // 1. Named secret-ish keys with a quoted/literal value.
576 // Captures `api_key = "..."`, `apiKey: '...'`, `BEARER_TOKEN="..."`, etc.
577 $named_regex = '/\b(api[_-]?key|api[_-]?secret|secret[_-]?key|client[_-]?secret|access[_-]?token|auth[_-]?token|bearer[_-]?token|private[_-]?key|password|passwd|webhook[_-]?secret)\b\s*[:=]\s*[\'"]([^\'"\\s]{8,})[\'"]/i';
578 if ( preg_match_all( $named_regex, $code, $matches, PREG_SET_ORDER ) ) {
579 foreach ( $matches as $m ) {
580 $key = strtolower( $m[1] );
581 $value = $m[2];
582 if ( self::looks_like_placeholder( $value ) ) {
583 continue;
584 }
585 $warnings[] = sprintf(
586 'Looks like a hard-coded `%s` literal — move to a constant in wp-config.php or store in an option/secrets manager.',
587 $key
588 );
589 }
590 }
591
592 // 2. Well-known token formats with low false-positive rate.
593 $token_patterns = array(
594 '/\b(sk-[A-Za-z0-9]{20,})\b/' => 'OpenAI-style secret key',
595 '/\bAKIA[0-9A-Z]{16}\b/' => 'AWS access key ID',
596 '/\bghp_[A-Za-z0-9]{36,}\b/' => 'GitHub personal access token',
597 '/\bgho_[A-Za-z0-9]{36,}\b/' => 'GitHub OAuth token',
598 '/\bxox[abprs]-[A-Za-z0-9-]{10,}\b/' => 'Slack token',
599 '/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/' => 'JWT',
600 );
601 foreach ( $token_patterns as $pattern => $label ) {
602 if ( preg_match( $pattern, $code ) ) {
603 $warnings[] = sprintf(
604 '%s detected in snippet body — store in wp-config.php or an encrypted option, not in code.',
605 $label
606 );
607 }
608 }
609
610 return array_values( array_unique( $warnings ) );
611 }
612
613 /**
614 * True if the candidate looks like an obvious placeholder/example —
615 * "YOUR_KEY_HERE", "G-0.0.210.0.21", repeated digits, dashes-only.
616 *
617 * @param string $value Candidate secret value.
618 * @return bool
619 */
620 private static function looks_like_placeholder( $value ) {
621 $lower = strtolower( $value );
622 if ( preg_match( '/^(your|sample|example|placeholder|test|fake|dummy)[-_]/', $lower ) ) {
623 return true;
624 }
625 if ( preg_match( '/^[x]+$/i', $value ) || preg_match( '/^[-_]+$/', $value ) ) {
626 return true;
627 }
628 // Very low character variety = unlikely real credential.
629 if ( strlen( count_chars( $value, 3 ) ) <= 3 ) {
630 return true;
631 }
632 return false;
633 }
634
635 /**
636 * Detect a fatal PHP parse error in a snippet body WITHOUT executing it.
637 *
638 * The most common agent mistake: a `php` body that drops literal markup
639 * (`<div>…`) into PHP context with no `?>` to exit first. That is a fatal
640 * parse error — `include` aborts and the snippet renders nothing on every
641 * hook — yet it writes and enables silently because the lenient
642 * `token_get_all()` used elsewhere never flags it. `TOKEN_PARSE` makes
643 * `token_get_all()` validate the body as a complete file and throw on
644 * invalid syntax. Unlike an `if(false){…}` eval wrapper it does NOT
645 * false-positive on a correct body that closes `?>` and ends in trailing
646 * HTML, and it cannot fatal the request.
647 *
648 * @param string $php_code Raw snippet body (with or without `<?php`).
649 * @return string|null Parser error message, or null when the body parses.
650 */
651 public static function detect_syntax_error( $php_code ) {
652 // Own switch — NOT the style-lint kill-switch (`zip_ai_snippets_lint_enabled`).
653 // Disabling style lints (inline targeting, redundant hook wrappers) to
654 // dodge a false-positive must not silently void fatal-parse-error
655 // protection — the exact failure this gate exists to catch.
656 if ( ! apply_filters( 'zip_ai_snippets_syntax_check_enabled', true ) ) {
657 return null;
658 }
659 $code = (string) $php_code;
660 if ( '' === trim( $code ) ) {
661 return null;
662 }
663 // Parse-mode tokenizer unavailable — cannot validate without executing.
664 if ( ! defined( 'TOKEN_PARSE' ) ) {
665 return null;
666 }
667
668 $trimmed = ltrim( $code );
669 $has_tag = stripos( $trimmed, '<?php' ) === 0 || stripos( $trimmed, '<?' ) === 0;
670 $source = $has_tag ? $code : "<?php\n" . $code;
671 $tag_offset = $has_tag ? 0 : 1; // We prepended one "<?php\n" line.
672
673 try {
674 // Called purely for its side effect: TOKEN_PARSE throws on invalid syntax.
675 $parsed = token_get_all( $source, TOKEN_PARSE ); // phpcs:ignore
676 unset( $parsed );
677 } catch ( \ParseError $e ) {
678 // Report the body-relative line so the author/agent can self-correct.
679 $line = max( 1, $e->getLine() - $tag_offset );
680 return sprintf( 'Line %d: %s', $line, $e->getMessage() );
681 } catch ( \Throwable $e ) {
682 return $e->getMessage();
683 }
684 return null;
685 }
686
687 /**
688 * Run all PHP-snippet lint gates and return the first violation found.
689 *
690 * @param string $php_code Raw snippet body (with or without `<?php`).
691 * @param string $execution_hook Optional configured execution hook.
692 * @return \WP_Error|null WP_Error on the first failed gate, null when clean.
693 */
694 public static function check( $php_code, $execution_hook = '' ) {
695 $syntax_error = self::detect_syntax_error( $php_code );
696 if ( null !== $syntax_error ) {
697 return new \WP_Error(
698 'php_syntax_error',
699 sprintf(
700 'PHP syntax error in snippet body: %s. All literal HTML must sit OUTSIDE <?php ?> — close PHP with ?> before any markup, and reopen <?php only around dynamic output.',
701 $syntax_error
702 ),
703 array( 'error' => $syntax_error )
704 );
705 }
706 $hits = self::detect_inline_targeting( $php_code );
707 if ( empty( $hits ) ) {
708 $hook_hits = self::detect_redundant_hook_wrappers( $php_code, $execution_hook );
709 if ( ! empty( $hook_hits ) ) {
710 return new \WP_Error(
711 'redundant_hook_wrapper',
712 self::format_redundant_hook_violation( $hook_hits ),
713 array( 'hits' => $hook_hits )
714 );
715 }
716 $output_hits = self::detect_early_direct_output( $php_code, $execution_hook );
717 if ( ! empty( $output_hits ) ) {
718 return new \WP_Error(
719 'early_direct_output',
720 self::format_early_output_violation( $output_hits ),
721 array( 'hits' => $output_hits )
722 );
723 }
724 return null;
725 }
726 return new \WP_Error(
727 'inline_targeting_forbidden',
728 self::format_violation( $hits ),
729 array( 'hits' => $hits )
730 );
731 }
732 }
733