Make WordPress Core

Changeset 61795


Ignore:
Timestamp:
03/03/2026 02:00:40 PM (5 weeks ago)
Author:
gziolo
Message:

AI: Sync Ability_Function_Resolver API enhancement to harden security

Make WP_AI_Client_Ability_Function_Resolver non-static and require specifying the allowed abilities list in the constructor. This hardens security by ensuring that only explicitly specified abilities can be executed, preventing potential vulnerabilities such as prompt injection from triggering arbitrary abilities.

The constructor accepts either WP_Ability objects or ability name strings. If an ability is not in the allowed list, an error response with code ability_not_allowed is returned.

Developed in https://github.com/WordPress/wordpress-develop/pull/11103.
Upstream: https://github.com/WordPress/wp-ai-client/pull/61.

Props felixarntz, gziolo, JasonTheAdams, dkotter, johnbillion.
Fixes #64769.

Location:
trunk
Files:
2 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-includes/ai-client/class-wp-ai-client-ability-function-resolver.php

    r61700 r61795  
    1717 * Resolves and executes WordPress Abilities API function calls from AI models.
    1818 *
     19 * This class must be instantiated with the specific abilities that the AI model
     20 * is allowed to execute, ensuring that only explicitly specified abilities can
     21 * be called. This prevents the model from executing arbitrary abilities.
     22 *
    1923 * @since 7.0.0
    2024 */
     
    3034
    3135    /**
     36     * Map of allowed ability names for this instance.
     37     *
     38     * Keys are ability name strings, values are `true` for O(1) lookup.
     39     *
     40     * @since 7.0.0
     41     * @var array<string, true>
     42     */
     43    private array $allowed_abilities;
     44
     45    /**
     46     * Constructor.
     47     *
     48     * @since 7.0.0
     49     *
     50     * @param WP_Ability|string ...$abilities The abilities that this resolver is allowed to execute.
     51     */
     52    public function __construct( ...$abilities ) {
     53        $this->allowed_abilities = array();
     54
     55        foreach ( $abilities as $ability ) {
     56            if ( $ability instanceof WP_Ability ) {
     57                $this->allowed_abilities[ $ability->get_name() ] = true;
     58            } elseif ( is_string( $ability ) ) {
     59                $this->allowed_abilities[ $ability ] = true;
     60            }
     61        }
     62    }
     63
     64    /**
    3265     * Checks if a function call is an ability call.
    3366     *
     
    3770     * @return bool True if the function call is an ability call, false otherwise.
    3871     */
    39     public static function is_ability_call( FunctionCall $call ): bool {
     72    public function is_ability_call( FunctionCall $call ): bool {
    4073        $name = $call->getName();
    4174        if ( null === $name ) {
     
    4982     * Executes a WordPress ability from a function call.
    5083     *
     84     * Only abilities that were specified in the constructor are allowed to be
     85     * executed. If the ability is not in the allowed list, an error response
     86     * with code `ability_not_allowed` is returned.
     87     *
    5188     * @since 7.0.0
    5289     *
     
    5491     * @return FunctionResponse The response from executing the ability.
    5592     */
    56     public static function execute_ability( FunctionCall $call ): FunctionResponse {
     93    public function execute_ability( FunctionCall $call ): FunctionResponse {
    5794        $function_name = $call->getName() ?? 'unknown';
    5895        $function_id   = $call->getId() ?? 'unknown';
    5996
    60         if ( ! self::is_ability_call( $call ) ) {
     97        if ( ! $this->is_ability_call( $call ) ) {
    6198            return new FunctionResponse(
    6299                $function_id,
     
    70107
    71108        $ability_name = self::function_name_to_ability_name( $function_name );
    72         $ability      = wp_get_ability( $ability_name );
     109
     110        if ( ! isset( $this->allowed_abilities[ $ability_name ] ) ) {
     111            return new FunctionResponse(
     112                $function_id,
     113                $function_name,
     114                array(
     115                    /* translators: %s: ability name */
     116                    'error' => sprintf( __( 'Ability "%s" was not specified in the allowed abilities list.' ), $ability_name ),
     117                    'code'  => 'ability_not_allowed',
     118                )
     119            );
     120        }
     121
     122        $ability = wp_get_ability( $ability_name );
    73123
    74124        if ( ! $ability instanceof WP_Ability ) {
     
    114164     * @return bool True if the message contains ability calls, false otherwise.
    115165     */
    116     public static function has_ability_calls( Message $message ): bool {
    117         return null !== array_find(
    118             $message->getParts(),
    119             static function ( MessagePart $part ): bool {
    120                 return $part->getType()->isFunctionCall()
    121                     && $part->getFunctionCall() instanceof FunctionCall
    122                     && self::is_ability_call( $part->getFunctionCall() );
     166    public function has_ability_calls( Message $message ): bool {
     167        foreach ( $message->getParts() as $part ) {
     168            if ( $part->getType()->isFunctionCall() ) {
     169                $function_call = $part->getFunctionCall();
     170                if ( $function_call instanceof FunctionCall && $this->is_ability_call( $function_call ) ) {
     171                    return true;
     172                }
    123173            }
    124         );
     174        }
     175
     176        return false;
    125177    }
    126178
     
    133185     * @return Message A new message with function responses.
    134186     */
    135     public static function execute_abilities( Message $message ): Message {
     187    public function execute_abilities( Message $message ): Message {
    136188        $response_parts = array();
    137189
     
    140192                $function_call = $part->getFunctionCall();
    141193                if ( $function_call instanceof FunctionCall ) {
    142                     $function_response = self::execute_ability( $function_call );
     194                    $function_response = $this->execute_ability( $function_call );
    143195                    $response_parts[]  = new MessagePart( $function_response );
    144196                }
  • trunk/tests/phpunit/tests/ai-client/wpAiClientAbilityFunctionResolver.php

    r61777 r61795  
    4242     */
    4343    public function test_is_ability_call_returns_true_for_valid_ability() {
    44         $call = new FunctionCall(
     44        $resolver = new WP_AI_Client_Ability_Function_Resolver( 'tec/create_event' );
     45        $call     = new FunctionCall(
    4546            'test-id',
    4647            'wpab__tec__create_event',
     
    4849        );
    4950
    50         $result = WP_AI_Client_Ability_Function_Resolver::is_ability_call( $call );
     51        $result = $resolver->is_ability_call( $call );
    5152
    5253        $this->assertTrue( $result );
     
    5960     */
    6061    public function test_is_ability_call_returns_true_for_nested_namespace() {
    61         $call = new FunctionCall(
     62        $resolver = new WP_AI_Client_Ability_Function_Resolver( 'tec/v1/create_event' );
     63        $call     = new FunctionCall(
    6264            'test-id',
    6365            'wpab__tec__v1__create_event',
     
    6567        );
    6668
    67         $result = WP_AI_Client_Ability_Function_Resolver::is_ability_call( $call );
     69        $result = $resolver->is_ability_call( $call );
    6870
    6971        $this->assertTrue( $result );
     
    7678     */
    7779    public function test_is_ability_call_returns_false_for_non_ability() {
    78         $call = new FunctionCall(
     80        $resolver = new WP_AI_Client_Ability_Function_Resolver();
     81        $call     = new FunctionCall(
    7982            'test-id',
    8083            'regular_function',
     
    8285        );
    8386
    84         $result = WP_AI_Client_Ability_Function_Resolver::is_ability_call( $call );
     87        $result = $resolver->is_ability_call( $call );
    8588
    8689        $this->assertFalse( $result );
     
    9396     */
    9497    public function test_is_ability_call_returns_false_when_name_is_null() {
    95         $call = new FunctionCall(
     98        $resolver = new WP_AI_Client_Ability_Function_Resolver();
     99        $call     = new FunctionCall(
    96100            'test-id',
    97101            null,
     
    99103        );
    100104
    101         $result = WP_AI_Client_Ability_Function_Resolver::is_ability_call( $call );
     105        $result = $resolver->is_ability_call( $call );
    102106
    103107        $this->assertFalse( $result );
     
    110114     */
    111115    public function test_is_ability_call_returns_false_for_partial_prefix() {
    112         $call = new FunctionCall(
     116        $resolver = new WP_AI_Client_Ability_Function_Resolver();
     117        $call     = new FunctionCall(
    113118            'test-id',
    114119            'wpab_single_underscore',
     
    116121        );
    117122
    118         $result = WP_AI_Client_Ability_Function_Resolver::is_ability_call( $call );
     123        $result = $resolver->is_ability_call( $call );
    119124
    120125        $this->assertFalse( $result );
     
    127132     */
    128133    public function test_execute_ability_returns_error_for_non_ability_call() {
    129         $call = new FunctionCall(
     134        $resolver = new WP_AI_Client_Ability_Function_Resolver();
     135        $call     = new FunctionCall(
    130136            'test-id',
    131137            'regular_function',
     
    133139        );
    134140
    135         $response = WP_AI_Client_Ability_Function_Resolver::execute_ability( $call );
     141        $response = $resolver->execute_ability( $call );
    136142
    137143        $this->assertInstanceOf( FunctionResponse::class, $response );
     
    154160        $this->setExpectedIncorrectUsage( 'WP_Abilities_Registry::get_registered' );
    155161
    156         $call = new FunctionCall(
     162        $resolver = new WP_AI_Client_Ability_Function_Resolver( 'nonexistent/ability' );
     163        $call     = new FunctionCall(
    157164            'test-id',
    158165            'wpab__nonexistent__ability',
     
    160167        );
    161168
    162         $response = WP_AI_Client_Ability_Function_Resolver::execute_ability( $call );
     169        $response = $resolver->execute_ability( $call );
    163170
    164171        $this->assertInstanceOf( FunctionResponse::class, $response );
     
    181188        $this->setExpectedIncorrectUsage( 'WP_Abilities_Registry::get_registered' );
    182189
    183         $call = new FunctionCall(
     190        $resolver = new WP_AI_Client_Ability_Function_Resolver( 'nonexistent/ability' );
     191        $call     = new FunctionCall(
    184192            null,
    185193            'wpab__nonexistent__ability',
     
    187195        );
    188196
    189         $response = WP_AI_Client_Ability_Function_Resolver::execute_ability( $call );
     197        $response = $resolver->execute_ability( $call );
    190198
    191199        $this->assertInstanceOf( FunctionResponse::class, $response );
     
    199207     */
    200208    public function test_has_ability_calls_returns_true_when_present() {
    201         $call = new FunctionCall(
     209        $resolver = new WP_AI_Client_Ability_Function_Resolver( 'tec/create_event' );
     210        $call     = new FunctionCall(
    202211            'test-id',
    203212            'wpab__tec__create_event',
     
    212221        );
    213222
    214         $result = WP_AI_Client_Ability_Function_Resolver::has_ability_calls( $message );
     223        $result = $resolver->has_ability_calls( $message );
    215224
    216225        $this->assertTrue( $result );
     
    223232     */
    224233    public function test_has_ability_calls_returns_false_when_not_present() {
    225         $call = new FunctionCall(
     234        $resolver = new WP_AI_Client_Ability_Function_Resolver();
     235        $call     = new FunctionCall(
    226236            'test-id',
    227237            'regular_function',
     
    236246        );
    237247
    238         $result = WP_AI_Client_Ability_Function_Resolver::has_ability_calls( $message );
     248        $result = $resolver->has_ability_calls( $message );
    239249
    240250        $this->assertFalse( $result );
     
    247257     */
    248258    public function test_has_ability_calls_returns_false_for_text_only() {
    249         $message = new UserMessage(
     259        $resolver = new WP_AI_Client_Ability_Function_Resolver();
     260        $message  = new UserMessage(
    250261            array(
    251262                new MessagePart( 'Just some text' ),
     
    253264        );
    254265
    255         $result = WP_AI_Client_Ability_Function_Resolver::has_ability_calls( $message );
     266        $result = $resolver->has_ability_calls( $message );
    256267
    257268        $this->assertFalse( $result );
     
    264275     */
    265276    public function test_has_ability_calls_returns_true_with_mixed_content() {
     277        $resolver = new WP_AI_Client_Ability_Function_Resolver( 'tec/create_event' );
     278
    266279        $regular_call = new FunctionCall(
    267280            'regular-id',
     
    284297        );
    285298
    286         $result = WP_AI_Client_Ability_Function_Resolver::has_ability_calls( $message );
     299        $result = $resolver->has_ability_calls( $message );
    287300
    288301        $this->assertTrue( $result );
     
    295308     */
    296309    public function test_has_ability_calls_with_empty_message() {
    297         $message = new ModelMessage( array() );
    298 
    299         $result = WP_AI_Client_Ability_Function_Resolver::has_ability_calls( $message );
     310        $resolver = new WP_AI_Client_Ability_Function_Resolver();
     311        $message  = new ModelMessage( array() );
     312
     313        $result = $resolver->has_ability_calls( $message );
    300314
    301315        $this->assertFalse( $result );
     
    308322     */
    309323    public function test_execute_abilities_with_empty_message() {
    310         $message = new ModelMessage( array() );
    311 
    312         $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message );
     324        $resolver = new WP_AI_Client_Ability_Function_Resolver();
     325        $message  = new ModelMessage( array() );
     326
     327        $result = $resolver->execute_abilities( $message );
    313328
    314329        $this->assertInstanceOf( UserMessage::class, $result );
     
    324339        $this->setExpectedIncorrectUsage( 'WP_Abilities_Registry::get_registered' );
    325340
    326         $call = new FunctionCall(
     341        $resolver = new WP_AI_Client_Ability_Function_Resolver( 'nonexistent/ability' );
     342        $call     = new FunctionCall(
    327343            'test-id',
    328344            'wpab__nonexistent__ability',
     
    336352        );
    337353
    338         $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message );
     354        $result = $resolver->execute_abilities( $message );
    339355
    340356        $this->assertInstanceOf( UserMessage::class, $result );
     
    356372        $this->setExpectedIncorrectUsage( 'WP_Abilities_Registry::get_registered' );
    357373
    358         $call = new FunctionCall(
     374        $resolver = new WP_AI_Client_Ability_Function_Resolver( 'nonexistent/ability' );
     375        $call     = new FunctionCall(
    359376            'test-id',
    360377            'wpab__nonexistent__ability',
     
    368385        );
    369386
    370         $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message );
     387        $result = $resolver->execute_abilities( $message );
    371388
    372389        $this->assertInstanceOf( UserMessage::class, $result );
     
    380397    public function test_execute_abilities_processes_multiple_calls() {
    381398        $this->setExpectedIncorrectUsage( 'WP_Abilities_Registry::get_registered' );
     399
     400        $resolver = new WP_AI_Client_Ability_Function_Resolver( 'nonexistent/ability1', 'nonexistent/ability2' );
    382401
    383402        $call1 = new FunctionCall(
     
    400419        );
    401420
    402         $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message );
     421        $result = $resolver->execute_abilities( $message );
    403422
    404423        $this->assertInstanceOf( UserMessage::class, $result );
     
    415434        $this->setExpectedIncorrectUsage( 'WP_Abilities_Registry::get_registered' );
    416435
    417         $call = new FunctionCall(
     436        $resolver = new WP_AI_Client_Ability_Function_Resolver( 'nonexistent/ability' );
     437        $call     = new FunctionCall(
    418438            'test-id',
    419439            'wpab__nonexistent__ability',
     
    429449        );
    430450
    431         $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message );
     451        $result = $resolver->execute_abilities( $message );
    432452
    433453        $this->assertInstanceOf( UserMessage::class, $result );
     
    465485     */
    466486    public function test_execute_ability_success() {
    467         $call = new FunctionCall(
     487        $resolver = new WP_AI_Client_Ability_Function_Resolver( 'wpaiclienttests/simple' );
     488        $call     = new FunctionCall(
    468489            'test-id',
    469490            'wpab__wpaiclienttests__simple',
     
    471492        );
    472493
    473         $response = WP_AI_Client_Ability_Function_Resolver::execute_ability( $call );
     494        $response = $resolver->execute_ability( $call );
    474495
    475496        $this->assertInstanceOf( FunctionResponse::class, $response );
     
    488509     */
    489510    public function test_execute_ability_with_parameters() {
    490         $call = new FunctionCall(
     511        $resolver = new WP_AI_Client_Ability_Function_Resolver( 'wpaiclienttests/with-params' );
     512        $call     = new FunctionCall(
    491513            'test-id',
    492514            'wpab__wpaiclienttests__with-params',
     
    494516        );
    495517
    496         $response = WP_AI_Client_Ability_Function_Resolver::execute_ability( $call );
     518        $response = $resolver->execute_ability( $call );
    497519
    498520        $this->assertInstanceOf( FunctionResponse::class, $response );
     
    513535     */
    514536    public function test_execute_ability_handles_wp_error() {
    515         $call = new FunctionCall(
     537        $resolver = new WP_AI_Client_Ability_Function_Resolver( 'wpaiclienttests/returns-error' );
     538        $call     = new FunctionCall(
    516539            'test-id',
    517540            'wpab__wpaiclienttests__returns-error',
     
    519542        );
    520543
    521         $response = WP_AI_Client_Ability_Function_Resolver::execute_ability( $call );
     544        $response = $resolver->execute_ability( $call );
    522545
    523546        $this->assertInstanceOf( FunctionResponse::class, $response );
     
    538561     */
    539562    public function test_execute_abilities_success() {
    540         $call = new FunctionCall(
     563        $resolver = new WP_AI_Client_Ability_Function_Resolver( 'wpaiclienttests/simple' );
     564        $call     = new FunctionCall(
    541565            'test-id',
    542566            'wpab__wpaiclienttests__simple',
     
    550574        );
    551575
    552         $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message );
     576        $result = $resolver->execute_abilities( $message );
    553577
    554578        $this->assertInstanceOf( UserMessage::class, $result );
     
    569593     */
    570594    public function test_execute_abilities_multiple_success() {
     595        $resolver = new WP_AI_Client_Ability_Function_Resolver( 'wpaiclienttests/simple', 'wpaiclienttests/hyphen-test' );
     596
    571597        $call1 = new FunctionCall(
    572598            'call-1',
     
    588614        );
    589615
    590         $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message );
     616        $result = $resolver->execute_abilities( $message );
    591617
    592618        $this->assertInstanceOf( UserMessage::class, $result );
     
    615641     */
    616642    public function test_execute_abilities_with_mixed_content() {
    617         $call = new FunctionCall(
     643        $resolver = new WP_AI_Client_Ability_Function_Resolver( 'wpaiclienttests/simple' );
     644        $call     = new FunctionCall(
    618645            'test-id',
    619646            'wpab__wpaiclienttests__simple',
     
    629656        );
    630657
    631         $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message );
     658        $result = $resolver->execute_abilities( $message );
    632659
    633660        $this->assertInstanceOf( UserMessage::class, $result );
     
    646673     */
    647674    public function test_execute_abilities_with_parameters() {
    648         $call = new FunctionCall(
     675        $resolver = new WP_AI_Client_Ability_Function_Resolver( 'wpaiclienttests/with-params' );
     676        $call     = new FunctionCall(
    649677            'test-id',
    650678            'wpab__wpaiclienttests__with-params',
     
    658686        );
    659687
    660         $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message );
     688        $result = $resolver->execute_abilities( $message );
    661689
    662690        $this->assertInstanceOf( UserMessage::class, $result );
     
    672700        $this->assertSame( 'Integration Test', $data['title'] );
    673701    }
     702
     703    /**
     704     * Test execute_ability rejects ability not in allowed list.
     705     *
     706     * @ticket 64769
     707     */
     708    public function test_execute_ability_rejects_ability_not_in_allowed_list() {
     709        $resolver = new WP_AI_Client_Ability_Function_Resolver( 'wpaiclienttests/simple' );
     710        $call     = new FunctionCall(
     711            'test-id',
     712            'wpab__wpaiclienttests__with-params',
     713            array( 'title' => 'Test' )
     714        );
     715
     716        $response = $resolver->execute_ability( $call );
     717
     718        $this->assertInstanceOf( FunctionResponse::class, $response );
     719        $data = $response->getResponse();
     720        $this->assertIsArray( $data );
     721        $this->assertArrayHasKey( 'error', $data );
     722        $this->assertStringContainsString( 'not specified in the allowed abilities list', $data['error'] );
     723        $this->assertArrayHasKey( 'code', $data );
     724        $this->assertSame( 'ability_not_allowed', $data['code'] );
     725    }
     726
     727    /**
     728     * Test execute_ability rejects all abilities when constructed with no abilities.
     729     *
     730     * @ticket 64769
     731     */
     732    public function test_execute_ability_rejects_all_when_no_abilities_specified() {
     733        $resolver = new WP_AI_Client_Ability_Function_Resolver();
     734        $call     = new FunctionCall(
     735            'test-id',
     736            'wpab__wpaiclienttests__simple',
     737            array()
     738        );
     739
     740        $response = $resolver->execute_ability( $call );
     741
     742        $this->assertInstanceOf( FunctionResponse::class, $response );
     743        $data = $response->getResponse();
     744        $this->assertIsArray( $data );
     745        $this->assertArrayHasKey( 'code', $data );
     746        $this->assertSame( 'ability_not_allowed', $data['code'] );
     747    }
     748
     749    /**
     750     * Test execute_abilities filters by allowed list.
     751     *
     752     * @ticket 64769
     753     */
     754    public function test_execute_abilities_filters_by_allowed_list() {
     755        $resolver = new WP_AI_Client_Ability_Function_Resolver( 'wpaiclienttests/simple' );
     756
     757        $call1 = new FunctionCall(
     758            'call-1',
     759            'wpab__wpaiclienttests__simple',
     760            array()
     761        );
     762
     763        $call2 = new FunctionCall(
     764            'call-2',
     765            'wpab__wpaiclienttests__with-params',
     766            array( 'title' => 'Test' )
     767        );
     768
     769        $message = new ModelMessage(
     770            array(
     771                new MessagePart( $call1 ),
     772                new MessagePart( $call2 ),
     773            )
     774        );
     775
     776        $result = $resolver->execute_abilities( $message );
     777
     778        $parts = $result->getParts();
     779        $this->assertCount( 2, $parts );
     780
     781        $response1_data = $parts[0]->getFunctionResponse()->getResponse();
     782        $this->assertArrayHasKey( 'success', $response1_data );
     783        $this->assertTrue( $response1_data['success'] );
     784
     785        $response2_data = $parts[1]->getFunctionResponse()->getResponse();
     786        $this->assertSame( 'ability_not_allowed', $response2_data['code'] );
     787    }
     788
     789    /**
     790     * Test constructor accepts WP_Ability objects.
     791     *
     792     * @ticket 64769
     793     */
     794    public function test_constructor_accepts_wp_ability_objects() {
     795        $ability  = wp_get_ability( 'wpaiclienttests/simple' );
     796        $resolver = new WP_AI_Client_Ability_Function_Resolver( $ability );
     797        $call     = new FunctionCall(
     798            'test-id',
     799            'wpab__wpaiclienttests__simple',
     800            array()
     801        );
     802
     803        $response = $resolver->execute_ability( $call );
     804
     805        $data = $response->getResponse();
     806        $this->assertArrayHasKey( 'success', $data );
     807        $this->assertTrue( $data['success'] );
     808    }
     809
     810    /**
     811     * Test constructor accepts mixed WP_Ability objects and strings.
     812     *
     813     * @ticket 64769
     814     */
     815    public function test_constructor_accepts_mixed_ability_types() {
     816        $ability  = wp_get_ability( 'wpaiclienttests/simple' );
     817        $resolver = new WP_AI_Client_Ability_Function_Resolver( $ability, 'wpaiclienttests/with-params' );
     818
     819        $call1     = new FunctionCall(
     820            'call-1',
     821            'wpab__wpaiclienttests__simple',
     822            array()
     823        );
     824        $response1 = $resolver->execute_ability( $call1 );
     825        $this->assertArrayHasKey( 'success', $response1->getResponse() );
     826
     827        $call2     = new FunctionCall(
     828            'call-2',
     829            'wpab__wpaiclienttests__with-params',
     830            array( 'title' => 'Test' )
     831        );
     832        $response2 = $resolver->execute_ability( $call2 );
     833        $this->assertArrayHasKey( 'success', $response2->getResponse() );
     834    }
    674835}
Note: See TracChangeset for help on using the changeset viewer.