PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.10
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.10
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
← All changes | includes/ai-copilot/search.php +178 -14 1.1.21.1.10 View file →
@@ -381,10 +381,16 @@
381 381 * Keyword-searches published posts or pages with WordPress's native search
382 382 * (`WP_Query` `s=`), returning data rich enough for the agent to compare
383 383 * AND for the UI to render links.
384 384 *
385 - * No AI analysis is required — every published post/page is searchable.
385 + * No AI analysis is required — every published, non-password-protected post/page is searchable.
386 386 *
387 + * Password-protected posts are excluded (`has_password => false`): `publish`
388 + * is also the status of a password-protected post, and this tool emits the
389 + * stored body as an excerpt without ever passing through `post_password_required()`.
390 + * Filtering at the query level keeps them out of both `items` and `found_posts`,
391 + * so the `total` counter cannot become an oracle for their contents either.
392 + *
387 393 * @param string $post_type 'post' | 'page'.
388 394 * @param string $query Keyword search terms (may be empty to list newest).
389 395 * @param int $offset
390 396 * @return array
@@ -393,8 +399,9 @@
393 399 $wp_query = new WP_Query(
394 400 array(
395 401 'post_type' => $post_type,
396 402 'post_status' => 'publish',
403 + 'has_password' => false,
397 404 's' => (string) $query,
398 405 'posts_per_page' => OPENSTATION_AI_SEARCH_BATCH_SIZE,
399 406 'offset' => $offset,
400 407 'no_found_rows' => false,
@@ -446,8 +453,82 @@
446 453 return (string) mb_substr( $text, 0, 300 );
447 454 }
448 455
449 456 /**
457 + * Whether the current user may read a post the comment tools are about to
458 + * surface.
459 + *
460 + * A comment being `approved` is a moderation decision — it says nothing about
461 + * who may see the discussion. An approved comment can hang on a private,
462 + * draft, or password-protected post the caller cannot reach, so the comment
463 + * search tools must gate on the PARENT POST's visibility before returning the
464 + * comment text or the parent title. Mirrors Core's
465 + * `WP_REST_Comments_Controller::check_read_post_permission()`:
466 + *
467 + * - a password-protected parent needs the password satisfied or `edit_post`.
468 + * `post_password_required()` honours the `wp-postpass` cookie Core's
469 + * password form sets, and that is deliberate Core parity, not a gap: the
470 + * cookie only exists because the caller already entered the correct
471 + * password, and Core's comments controller reads the same cookie. The
472 + * ability itself has no password input, so a caller who never unlocked
473 + * the post front-end is refused;
474 + * - a publicly viewable parent (public status AND viewable post type) is
475 + * readable by anyone the ability admits;
476 + * - a parent whose post TYPE is not viewable (an internal/admin-only CPT)
477 + * needs `edit_post` — `read_post` cannot stand in, because a public status
478 + * resolves it to plain `read` whatever the type's visibility, which is how
479 + * Core's REST layer needs its own post-type gate too;
480 + * - any other parent (private, draft, pending, …) needs `read_post`.
481 + *
482 + * @param int|WP_Post $post Post ID or object.
483 + * @return bool
484 + */
485 +function openstation_ai_can_read_post( $post ) {
486 + // An id of 0 must stay unreadable: get_post( 0 ) falls back to the global
487 + // $post, which would judge an orphaned comment against an unrelated post.
488 + if ( is_numeric( $post ) && (int) $post <= 0 ) {
489 + return false;
490 + }
491 +
492 + $post = get_post( $post );
493 + if ( ! $post instanceof WP_Post ) {
494 + return false;
495 + }
496 +
497 + if ( post_password_required( $post ) && ! current_user_can( 'edit_post', $post->ID ) ) {
498 + return false;
499 + }
500 +
501 + if ( is_post_publicly_viewable( $post ) ) {
502 + return true;
503 + }
504 +
505 + $post_type = get_post_type_object( $post->post_type );
506 + if ( ! $post_type || ! is_post_type_viewable( $post_type ) ) {
507 + return current_user_can( 'edit_post', $post->ID );
508 + }
509 +
510 + return current_user_can( 'read_post', $post->ID );
511 +}
512 +
513 +/**
514 + * Whether the current user may read the post a comment is attached to.
515 + *
516 + * Used to drop comments on posts the caller cannot see from the comment
517 + * search results. See {@see openstation_ai_can_read_post()}.
518 + *
519 + * @param int|WP_Comment $comment Comment ID or object.
520 + * @return bool
521 + */
522 +function openstation_ai_can_read_comment_parent( $comment ) {
523 + $comment = get_comment( $comment );
524 + if ( ! $comment instanceof WP_Comment ) {
525 + return false;
526 + }
527 + return openstation_ai_can_read_post( (int) $comment->comment_post_ID );
528 +}
529 +
530 +/**
450 531 * Keyword-searches approved comments across all posts with WordPress's
451 532 * native comment search (`get_comments` `search=`).
452 533 *
453 534 * No AI analysis is required — every approved comment is searchable.
@@ -489,12 +570,28 @@
489 570 if ( $parent_ids ) {
490 571 _prime_post_caches( $parent_ids, false, false );
491 572 }
492 573
574 + // "Approved" is a moderation decision, not a visibility one: drop comments
575 + // whose parent post the caller cannot read (private / draft / password /
576 + // internal CPT), so the comment text and the parent title never leak. See
577 + // openstation_ai_can_read_comment_parent().
578 + //
579 + // This runs per row, after the batch, and that is the price of gating on
580 + // per-caller readability: an Administrator reads comments on private
581 + // posts and a reader who entered a post password reads that post's
582 + // discussion, neither of which a single `post_status` or `has_password`
583 + // query var can express. `total` therefore counts rows this caller does
584 + // not get, and a batch can come back short. The alternative — a blanket
585 + // publish-only, no-password query — would be exact and would also hide
586 + // those discussions from the people entitled to them.
587 + $comments = array_values( array_filter( $comments, 'openstation_ai_can_read_comment_parent' ) );
588 +
493 589 $items = array();
494 590 foreach ( $comments as $comment ) {
591 + // Readable, per the filter above.
495 592 $parent_post = get_post( $comment->comment_post_ID );
496 - $parent_title = $parent_post ? wp_strip_all_tags( $parent_post->post_title ) : '';
593 + $parent_title = wp_strip_all_tags( $parent_post->post_title );
497 594
498 595 $items[] = array(
499 596 'id' => (int) $comment->comment_ID,
500 597 'type' => 'comment',
@@ -504,9 +601,9 @@
504 601 // Links.
505 602 'url' => (string) get_comment_link( $comment ),
506 603 'edit_url' => admin_url( 'comment.php?action=editcomment&c=' . (int) $comment->comment_ID ),
507 604 'post_id' => (int) $comment->comment_post_ID,
508 - 'post_url' => $parent_post ? (string) get_permalink( $parent_post ) : '',
605 + 'post_url' => (string) get_permalink( $parent_post ),
509 606 );
510 607 }
511 608
512 609 return array(
@@ -553,8 +650,25 @@
553 650 'error' => 'post_id must be a positive integer.',
554 651 );
555 652 }
556 653
654 + // The model picks the post id, so it is untrusted the same way an entity
655 + // id is. Comments inherit their parent's reach: a thread on a private,
656 + // draft, password-protected or internal-CPT post is not this user's to
657 + // read, and the envelope below would otherwise echo its title back.
658 + if ( ! openstation_ai_can_read_post( $post_id ) ) {
659 + return array(
660 + 'tool' => 'search_comments_by_post',
661 + 'post_id' => $post_id,
662 + 'offset' => $offset,
663 + 'items' => array(),
664 + 'count' => 0,
665 + 'total' => 0,
666 + 'has_more' => false,
667 + 'error' => 'Post not found or not readable.',
668 + );
669 + }
670 +
557 671 $base_args = array(
558 672 'post_id' => $post_id,
559 673 'status' => 'approve',
560 674 'type' => 'comment',
@@ -573,10 +687,11 @@
573 687 );
574 688
575 689 $total = (int) get_comments( array_merge( $base_args, array( 'count' => true ) ) );
576 690
691 + // Readable, per the gate above.
577 692 $parent_post = get_post( $post_id );
578 - $parent_title = $parent_post ? wp_strip_all_tags( $parent_post->post_title ) : '';
693 + $parent_title = wp_strip_all_tags( $parent_post->post_title );
579 694
580 695 $items = array();
581 696 foreach ( $comments as $comment ) {
582 697 $items[] = array(
@@ -616,8 +731,22 @@
616 731 * required. Comments opportunistically surface the `spam` / `harmful`
617 732 * verdict when the comment-moderation analysis happens to have run, but
618 733 * its absence never blocks the entity from being returned.
619 734 *
735 + * The id arrives from the MODEL's final answer, and model output is
736 + * untrusted — a search turn can be driven by attacker-controlled content, so
737 + * an injected instruction could name an entity the search tools never
738 + * surfaced. Hydration therefore re-checks readability itself instead of
739 + * trusting that the id came out of a filtered tool result: posts/pages go
740 + * through {@see openstation_ai_can_read_post()}, and so does a comment's
741 + * PARENT, because the comment record carries that post's title and permalink
742 + * — approval is a moderation decision, not a visibility one, and an approved
743 + * comment outlives its post being switched to private or back to draft.
744 + * Reading an unapproved comment needs `edit_comment`, mirroring Core's
745 + * `WP_REST_Comments_Controller::check_read_permission()`; the AI moderation
746 + * verdicts and the wp-admin edit link are narrower still. Unreadable ids
747 + * resolve to null, indistinguishable from nonexistent ones.
748 + *
620 749 * @param string $entity_type 'post' | 'page' | 'comment'.
621 750 * @param int $entity_id
622 751 * @return array|null
623 752 */
@@ -625,11 +754,22 @@
625 754 $entity_id = (int) $entity_id;
626 755
627 756 if ( in_array( $entity_type, array( 'post', 'page' ), true ) ) {
628 757 $post = get_post( $entity_id );
629 - if ( ! $post instanceof WP_Post ) {
758 +
759 + // The id must resolve to an actual post or page. The gate below answers
760 + // type visibility on its own, so this is the contract rather than the
761 + // lock: the record's `type` is what the client renders the card from,
762 + // and post/page is what the search tools surface. A viewable CPT row
763 + // would pass the gate and still have no card to land in.
764 + if ( ! $post instanceof WP_Post || ! in_array( $post->post_type, array( 'post', 'page' ), true ) ) {
630 765 return null;
631 766 }
767 +
768 + if ( ! openstation_ai_can_read_post( $post ) ) {
769 + return null;
770 + }
771 +
632 772 return array(
633 773 'id' => $entity_id,
634 774 'type' => $post->post_type,
635 775 'title' => wp_strip_all_tags( $post->post_title ),
@@ -642,25 +782,49 @@
642 782 }
643 783
644 784 if ( 'comment' === $entity_type ) {
645 785 $comment = get_comment( $entity_id );
646 - if ( ! $comment instanceof WP_Comment ) {
786 +
787 + // The parent's reach bounds the comment's: approval is a moderation
788 + // decision, not a visibility one, and this record carries the parent's
789 + // title and permalink — so without this check, naming a comment id
790 + // would walk straight around the post branch's gate above.
791 + if ( ! $comment instanceof WP_Comment || ! openstation_ai_can_read_comment_parent( $comment ) ) {
647 792 return null;
648 793 }
649 - $meta = openstation_ai_get_meta( 'comment', $entity_id );
650 - $parent_post = get_post( $comment->comment_post_ID );
651 - return array(
794 +
795 + // Reading an unapproved comment is an editor's business, per Core's
796 + // WP_REST_Comments_Controller::check_read_permission().
797 + if ( '1' !== (string) $comment->comment_approved && ! current_user_can( 'edit_comment', $entity_id ) ) {
798 + return null;
799 + }
800 +
801 + // The AI verdicts are the moderation queue's data, so they follow the
802 + // moderation capability rather than the per-comment edit one.
803 + $can_moderate = current_user_can( 'moderate_comments' );
804 + $parent_post = get_post( (int) $comment->comment_post_ID );
805 +
806 + $meta = $can_moderate ? openstation_ai_get_meta( 'comment', $entity_id ) : null;
807 + $entity = array(
652 808 'id' => $entity_id,
653 809 'type' => 'comment',
654 810 'excerpt' => openstation_ai_search_excerpt( $comment->comment_content ),
655 811 'post_id' => (int) $comment->comment_post_ID,
656 - 'post_title' => $parent_post ? wp_strip_all_tags( $parent_post->post_title ) : '',
657 - 'post_url' => $parent_post ? (string) get_permalink( $parent_post ) : '',
812 + 'post_title' => wp_strip_all_tags( $parent_post->post_title ),
813 + 'post_url' => (string) get_permalink( $parent_post ),
658 814 'url' => (string) get_comment_link( $comment ),
659 - 'edit_url' => admin_url( 'comment.php?action=editcomment&c=' . $entity_id ),
660 - 'harmful' => $meta ? (bool) ( $meta['harmful'] ?? false ) : false,
661 - 'spam' => $meta ? (bool) ( $meta['spam'] ?? false ) : false,
815 + 'edit_url' => current_user_can( 'edit_comment', $entity_id )
816 + ? admin_url( 'comment.php?action=editcomment&c=' . $entity_id )
817 + : '',
662 818 );
819 +
820 + // Moderation verdicts are for moderators only.
821 + if ( $can_moderate ) {
822 + $entity['harmful'] = $meta ? (bool) ( $meta['harmful'] ?? false ) : false;
823 + $entity['spam'] = $meta ? (bool) ( $meta['spam'] ?? false ) : false;
824 + }
825 +
826 + return $entity;
663 827 }
664 828
665 829 return null;
666 830 }