| @@ -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 | } |