Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
"php tests/default-provider-turn-adapter-smoke.php",
"php tests/default-agents-chat-handler-smoke.php",
"php tests/ability-tool-executor-smoke.php",
"php tests/ability-tool-ceiling-smoke.php",
"php tests/tool-mediation-runner-smoke.php",
"php tests/conversation-loop-tool-execution-smoke.php",
"php tests/tool-call-gate-smoke.php",
Expand Down
8 changes: 8 additions & 0 deletions src/Runtime/class-wp-agent-conversation-loop.php
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,14 @@ public static function run( array $messages, ?callable $turn_runner = null, arra
$post_tool_diagnostics = self::resolve_post_tool_result_diagnostics( $options );
$interrupt_source = self::resolve_interrupt_source( $options );
$request = self::resolve_request( $messages, $options );
$principal = $request->principal();
if ( $principal instanceof WP_Agent_Execution_Principal && ! isset( $context['principal'] ) ) {
// Thread the resolved execution principal into the run context so it
// reaches tool execution (turn context -> tool context -> executor).
// Ability-backed tools that declare a required capability consult the
// principal's capability ceiling before dispatching.
$context['principal'] = $principal;
}
$lock_session_id = self::resolve_lock_session_id( $options, $request );
$run_id = self::resolve_run_id( $options, $request );
$lock_ttl = self::resolve_lock_ttl( $options );
Expand Down
86 changes: 84 additions & 2 deletions src/Tools/class-wp-agent-ability-tool-executor.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
namespace AgentsAPI\AI\Tools;

use AgentsAPI\AI\Abilities\WP_Agent_Ability_Dispatcher;
use AgentsAPI\AI\WP_Agent_Execution_Principal;
use WP_Agent_Authorization_Policy;
use WP_Agent_WordPress_Authorization_Policy;

defined( 'ABSPATH' ) || exit;

Expand All @@ -23,14 +26,18 @@ class WP_Agent_Ability_Tool_Executor implements WP_Agent_Tool_Executor {
* tool name differs from the registered ability name. Otherwise the tool name is
* used directly as the ability name.
*
* When a declaration names a `required_capability`, the existing authorization
* policy is consulted before dispatch: the ability only runs when the execution
* principal's capability ceiling (intersected with the acting WordPress user)
* permits the capability. A denial returns an auditable tool-error result so the
* model learns the tool was not permitted rather than receiving its output.
*
* @param array<mixed> $tool_call Normalized prepared tool call.
* @param array<mixed> $tool_definition Tool declaration selected for the call.
* @param array<mixed> $context Host runtime context for this invocation.
* @return array<mixed> Normalized tool execution result.
*/
public function executeWP_Agent_Tool_Call( array $tool_call, array $tool_definition, array $context = array() ): array {
unset( $context );

$tool_call = WP_Agent_Tool_Call::normalize( $tool_call );
$tool_name = is_string( $tool_call['tool_name'] ?? null ) ? $tool_call['tool_name'] : '';
$parameters = isset( $tool_call['parameters'] ) && is_array( $tool_call['parameters'] ) ? $tool_call['parameters'] : array();
Expand All @@ -44,6 +51,21 @@ public function executeWP_Agent_Tool_Call( array $tool_call, array $tool_definit
);
}

$required_capability = WP_Agent_Tool_Declaration::requiredCapability( $tool_definition );
if ( '' !== $required_capability ) {
$denial = $this->authorize_required_capability(
$tool_name,
$ability_name,
$required_capability,
$parameters,
$context
);

if ( null !== $denial ) {
return $denial;
}
}

$result = WP_Agent_Ability_Dispatcher::dispatch( $ability_name, $parameters );
if ( function_exists( 'is_wp_error' ) && is_wp_error( $result ) ) {
return WP_Agent_Tool_Result::error(
Expand All @@ -70,6 +92,66 @@ public function executeWP_Agent_Tool_Call( array $tool_call, array $tool_definit
);
}

/**
* Enforce a tool's required capability against the execution principal.
*
* Resolves the principal and authorization policy from the host runtime
* context, then asks the existing {@see WP_Agent_Authorization_Policy::can()}
* whether the principal's capability ceiling permits the required capability.
* The ceiling is the intersection of the acting WordPress user's capabilities
* and any token/client restrictions; this method depends only on the ceiling
* being present on the principal, not on how it was derived.
*
* @param string $tool_name Model-facing tool name.
* @param string $ability_name Resolved ability name.
* @param string $required_capability Required WordPress capability.
* @param array<mixed> $parameters Prepared tool parameters (redacted on denial).
* @param array<mixed> $context Host runtime context.
* @return array<string,mixed>|null Denial result when not permitted, or null to proceed.
*/
private function authorize_required_capability( string $tool_name, string $ability_name, string $required_capability, array $parameters, array $context ): ?array {
$principal = $context['principal'] ?? null;
$policy = $context['authorization_policy'] ?? null;

if ( $policy instanceof WP_Agent_Authorization_Policy ) {
$authorization_policy = $policy;
} else {
$authorization_policy = new WP_Agent_WordPress_Authorization_Policy();
}

if ( $principal instanceof WP_Agent_Execution_Principal ) {
$allowed = $authorization_policy->can( $principal, $required_capability );
$reason = 'capability_not_permitted';
$safe_metadata = $principal->to_safe_metadata();
} else {
$allowed = false;
$reason = 'principal_unavailable';
$safe_metadata = null;
}

if ( $allowed ) {
return null;
}

return WP_Agent_Tool_Result::error(
$tool_name,
sprintf( 'Tool "%1$s" requires the "%2$s" capability, which is not permitted for this execution.', $tool_name, $required_capability ),
array(
'error_type' => 'capability_denied',
'required_capability' => $required_capability,
'ability_name' => $ability_name,
'parameters' => WP_Agent_Ability_Dispatcher::redacted_parameters( $ability_name, $parameters ),
'parameters_redacted' => true,
'denial' => array(
'allowed' => false,
'operation' => 'tool_execution',
'reason' => $reason,
'principal' => $safe_metadata,
),
)
);
}

/**
* Resolve the registered ability name for a tool call.
*
Expand Down
43 changes: 43 additions & 0 deletions src/Tools/class-wp-agent-tool-declaration.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,21 @@ class WP_Agent_Tool_Declaration {
public const EXECUTOR_HOST = 'host';
public const SCOPE_RUN = 'run';

/**
* Top-level declaration field naming the WordPress capability an
* ability-backed tool requires.
*
* When a host/ability tool declaration sets this field, the ability tool
* executor asks the authorization policy whether the execution principal's
* capability ceiling permits the capability before dispatching the ability.
* Absent or empty means no capability gate, preserving the default behavior.
*
* This is a declarative, product-neutral convention: the substrate never
* special-cases consumer tool names. The capability vocabulary is owned by
* WordPress core and the host.
*/
public const REQUIRED_CAPABILITY = 'required_capability';

/**
* Fields owned by the canonical declaration envelope.
*/
Expand Down Expand Up @@ -209,9 +224,37 @@ public static function normalizeForServer( array $declaration ): array {

$normalized = array_merge( $normalized, self::normalizeExtensionFields( $declaration ), self::normalizeParameterBindingFields( $declaration ) );

$required_capability = self::requiredCapability( $declaration );
if ( '' !== $required_capability ) {
$normalized[ self::REQUIRED_CAPABILITY ] = $required_capability;
} else {
unset( $normalized[ self::REQUIRED_CAPABILITY ] );
}

return $normalized;
}

/**
* Resolve the required WordPress capability for a tool declaration.
*
* Returns the trimmed capability string, or an empty string when the
* declaration does not declare one. The empty-string sentinel means "no
* capability gate," so consumers can distinguish an unset gate from a real
* capability without a separate null check.
*
* @param array<mixed> $tool_declaration Normalized (or raw) tool declaration.
* @return string Trimmed capability name, or empty string when unset/non-string.
*/
public static function requiredCapability( array $tool_declaration ): string {
$value = $tool_declaration[ self::REQUIRED_CAPABILITY ] ?? null;
if ( ! is_string( $value ) ) {
return '';
}

$value = trim( $value );
return '' === $value ? '' : $value;
}

/**
* Validate a runtime tool declaration without throwing.
*
Expand Down
Loading