Skip to content

Commit b5d48d4

Browse files
committed
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 #11103. Upstream: WordPress/wp-ai-client#61. Props felixarntz, gziolo, JasonTheAdams, dkotter, johnbillion. Fixes #64769. git-svn-id: https://develop.svn.wordpress.org/trunk@61795 602fd350-edb4-49c9-b593-d223f7449a82
1 parent 8db1867 commit b5d48d4

2 files changed

Lines changed: 274 additions & 61 deletions

File tree

src/wp-includes/ai-client/class-wp-ai-client-ability-function-resolver.php

Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
/**
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
*/
2125
class WP_AI_Client_Ability_Function_Resolver {
@@ -28,6 +32,35 @@ class WP_AI_Client_Ability_Function_Resolver {
2832
*/
2933
private const ABILITY_PREFIX = 'wpab__';
3034

35+
/**
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+
3164
/**
3265
* Checks if a function call is an ability call.
3366
*
@@ -36,7 +69,7 @@ class WP_AI_Client_Ability_Function_Resolver {
3669
* @param FunctionCall $call The function call to check.
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 ) {
4275
return false;
@@ -48,16 +81,20 @@ public static function is_ability_call( FunctionCall $call ): bool {
4881
/**
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
*
5390
* @param FunctionCall $call The function call to execute.
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,
63100
$function_name,
@@ -69,7 +106,20 @@ public static function execute_ability( FunctionCall $call ): FunctionResponse {
69106
}
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 ) {
75125
return new FunctionResponse(
@@ -113,15 +163,17 @@ public static function execute_ability( FunctionCall $call ): FunctionResponse {
113163
* @param Message $message The message to check.
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

127179
/**
@@ -132,14 +184,14 @@ static function ( MessagePart $part ): bool {
132184
* @param Message $message The message containing function calls.
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

138190
foreach ( $message->getParts() as $part ) {
139191
if ( $part->getType()->isFunctionCall() ) {
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
}
145197
}

0 commit comments

Comments
 (0)