PluginProbe
ZIP AI – AI Website Builder & AI Agent / 0.0.13
ZIP AI – AI Website Builder & AI Agent v0.0.13
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.13, at inc/core/snippet-lint.php

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