diff --git a/composer.json b/composer.json index 2d83eb3..c958ca8 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/src/Runtime/class-wp-agent-conversation-loop.php b/src/Runtime/class-wp-agent-conversation-loop.php index 64a26b7..5177830 100644 --- a/src/Runtime/class-wp-agent-conversation-loop.php +++ b/src/Runtime/class-wp-agent-conversation-loop.php @@ -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 ); diff --git a/src/Tools/class-wp-agent-ability-tool-executor.php b/src/Tools/class-wp-agent-ability-tool-executor.php index 5a4bc79..3f91c41 100644 --- a/src/Tools/class-wp-agent-ability-tool-executor.php +++ b/src/Tools/class-wp-agent-ability-tool-executor.php @@ -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; @@ -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 $tool_call Normalized prepared tool call. * @param array $tool_definition Tool declaration selected for the call. * @param array $context Host runtime context for this invocation. * @return array 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(); @@ -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( @@ -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 $parameters Prepared tool parameters (redacted on denial). + * @param array $context Host runtime context. + * @return array|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. * diff --git a/src/Tools/class-wp-agent-tool-declaration.php b/src/Tools/class-wp-agent-tool-declaration.php index cc0a5e0..6d62d40 100644 --- a/src/Tools/class-wp-agent-tool-declaration.php +++ b/src/Tools/class-wp-agent-tool-declaration.php @@ -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. */ @@ -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 $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. * diff --git a/tests/ability-tool-ceiling-smoke.php b/tests/ability-tool-ceiling-smoke.php new file mode 100644 index 0000000..a02b975 --- /dev/null +++ b/tests/ability-tool-ceiling-smoke.php @@ -0,0 +1,402 @@ +code = $code; + $this->message = $message; + } + + public function get_error_code(): string { + return $this->code; + } + + public function get_error_message(): string { + return $this->message; + } + } +} + +if ( ! class_exists( 'WP_Ability' ) ) { + class WP_Ability { + private string $name; + /** @var callable */ + private $callback; + + public function __construct( string $name, callable $callback ) { + $this->name = $name; + $this->callback = $callback; + } + + public function get_name(): string { + return $this->name; + } + + public function get_input_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'draft' => array( 'type' => 'string' ), + ), + ); + } + + public function get_meta_item( string $key, $default = null ) { + return $default; + } + + public function execute( $input = null ) { + return call_user_func( $this->callback, $input ); + } + } +} + +if ( ! function_exists( 'wp_get_ability' ) ) { + function wp_get_ability( string $name ): ?WP_Ability { + return $GLOBALS['__agents_api_ceiling_abilities'][ $name ] ?? null; + } +} + +/** + * Deterministic WordPress capability check for the default-policy path. + * + * Mirrors a small user/capability matrix so the substrate default + * WP_Agent_WordPress_Authorization_Policy can be exercised without WordPress. + */ +if ( ! function_exists( 'user_can' ) ) { + function user_can( int $user_id, string $capability ): bool { + $matrix = $GLOBALS['__agents_api_ceiling_user_can'] ?? array(); + $caps = $matrix[ $user_id ] ?? array(); + + return in_array( $capability, $caps, true ); + } +} + +$failures = array(); +$passes = 0; + +echo "agents-api-ability-tool-ceiling-smoke\n"; + +require_once __DIR__ . '/agents-api-smoke-helpers.php'; +agents_api_smoke_require_module(); + +use AgentsAPI\AI\Tools\WP_Agent_Ability_Tool_Executor; +use AgentsAPI\AI\Tools\WP_Agent_Tool_Declaration; +use AgentsAPI\AI\Tools\WP_Agent_Tool_Execution_Core; +use AgentsAPI\AI\WP_Agent_Execution_Principal; + +/** + * Register a smoke ability that records each dispatch. + * + * @param string $name Ability name. + */ +function ceiling_smoke_register_ability( string $name ): void { + $GLOBALS['__agents_api_ceiling_abilities'][ $name ] = new WP_Ability( + $name, + static function ( $input ) use ( $name ): array { + $GLOBALS['__agents_api_ceiling_dispatched'][ $name ] = ( $GLOBALS['__agents_api_ceiling_dispatched'][ $name ] ?? 0 ) + 1; + + return array( + 'published' => true, + 'input' => is_array( $input ) ? $input : array(), + ); + } + ); +} + +/** + * Reset per-scenario dispatch tracking. + */ +function ceiling_smoke_reset_dispatch(): void { + $GLOBALS['__agents_api_ceiling_dispatched'] = array(); +} + +/** + * Build a host tool declaration with a required capability. + * + * @param string $tool_name Model-facing tool name. + * @param string $ability_name Mapped ability name. + * @param string $required_cap Required WordPress capability. + * @return array Normalized server tool declaration. + */ +function ceiling_smoke_publish_tool( string $tool_name, string $ability_name, string $required_cap ): array { + return WP_Agent_Tool_Declaration::normalizeForServer( + array( + 'name' => $tool_name, + 'source' => 'ability', + 'description' => 'Publish a draft through the host runtime.', + 'executor' => WP_Agent_Tool_Declaration::EXECUTOR_HOST, + 'ability' => $ability_name, + 'required_capability' => $required_cap, + 'parameters' => array( + 'type' => 'object', + 'properties' => array( + 'draft' => array( 'type' => 'string' ), + ), + ), + ) + ); +} + +/** + * Build an ability tool declaration with no required capability. + * + * @param string $tool_name Model-facing tool name. + * @param string $ability_name Mapped ability name. + * @return array Normalized server tool declaration. + */ +function ceiling_smoke_open_tool( string $tool_name, string $ability_name ): array { + return WP_Agent_Tool_Declaration::normalizeForServer( + array( + 'name' => $tool_name, + 'source' => 'ability', + 'description' => 'Open tool with no capability gate.', + 'executor' => WP_Agent_Tool_Declaration::EXECUTOR_HOST, + 'ability' => $ability_name, + 'parameters' => array( + 'type' => 'object', + 'properties' => array( + 'draft' => array( 'type' => 'string' ), + ), + ), + ) + ); +} + +ceiling_smoke_register_ability( 'local/publish-draft' ); +ceiling_smoke_register_ability( 'local/open-info' ); + +$GLOBALS['__agents_api_ceiling_user_can'] = array( + 7 => array( 'publish_posts', 'edit_posts' ), + 9 => array( 'edit_posts' ), +); + +$executor = new WP_Agent_Ability_Tool_Executor(); +$core = new WP_Agent_Tool_Execution_Core(); + +echo "\n[1] Declarative required_capability is resolved and normalized on server declarations:\n"; +$publish_tool = ceiling_smoke_publish_tool( 'host/publish', 'local/publish-draft', 'publish_posts' ); + +agents_api_smoke_assert_equals( 'publish_posts', WP_Agent_Tool_Declaration::requiredCapability( $publish_tool ), 'normalized server declaration exposes required capability', $failures, $passes ); +agents_api_smoke_assert_equals( 'publish_posts', $publish_tool[ WP_Agent_Tool_Declaration::REQUIRED_CAPABILITY ] ?? '', 'normalized server declaration carries required capability field', $failures, $passes ); + +$open_tool = ceiling_smoke_open_tool( 'host/info', 'local/open-info' ); +agents_api_smoke_assert_equals( '', WP_Agent_Tool_Declaration::requiredCapability( $open_tool ), 'declaration without required capability resolves to empty string', $failures, $passes ); +agents_api_smoke_assert_equals( false, array_key_exists( WP_Agent_Tool_Declaration::REQUIRED_CAPABILITY, $open_tool ), 'normalized declaration omits the field when no capability is declared', $failures, $passes ); + +$non_string_capability = WP_Agent_Tool_Declaration::normalizeForServer( + array( + 'name' => 'host/bad-cap', + 'source' => 'ability', + 'description' => 'Bad capability shape.', + 'executor' => WP_Agent_Tool_Declaration::EXECUTOR_HOST, + 'required_capability' => array( 'not', 'a', 'string' ), + ) +); +agents_api_smoke_assert_equals( '', WP_Agent_Tool_Declaration::requiredCapability( $non_string_capability ), 'non-string required capability is rejected to empty string', $failures, $passes ); +agents_api_smoke_assert_equals( false, array_key_exists( WP_Agent_Tool_Declaration::REQUIRED_CAPABILITY, $non_string_capability ), 'non-string required capability is dropped from normalized declaration', $failures, $passes ); + +echo "\n[2] Ceiling denies the required capability -> ability is NOT dispatched:\n"; +ceiling_smoke_reset_dispatch(); +$denied_principal = WP_Agent_Execution_Principal::agent_token( + 7, + 'publishing-agent', + 901, + WP_Agent_Execution_Principal::REQUEST_CONTEXT_REST, + array(), + 'site:42', + 'ci', + new WP_Agent_Capability_Ceiling( 7, array( 'edit_posts' ) ) +); + +$denied_result = $core->executeTool( + 'host/publish', + array( 'draft' => 'hello-world' ), + array( 'host/publish' => $publish_tool ), + $executor, + array( + 'principal' => $denied_principal, + 'tool_call_id' => 'call-denied-1', + ) +); + +agents_api_smoke_assert_equals( false, $denied_result['success'] ?? true, 'capability denial becomes failed tool result', $failures, $passes ); +agents_api_smoke_assert_equals( 'capability_denied', $denied_result['metadata']['error_type'] ?? '', 'denial records capability_denied error type', $failures, $passes ); +agents_api_smoke_assert_equals( 'publish_posts', $denied_result['metadata']['required_capability'] ?? '', 'denial records the required capability', $failures, $passes ); +agents_api_smoke_assert_equals( 'local/publish-draft', $denied_result['metadata']['ability_name'] ?? '', 'denial records the mapped ability name', $failures, $passes ); +agents_api_smoke_assert_equals( 'capability_not_permitted', $denied_result['metadata']['denial']['reason'] ?? '', 'denial records stable reason code', $failures, $passes ); +agents_api_smoke_assert_equals( false, $denied_result['metadata']['denial']['allowed'] ?? true, 'denial envelope marks operation not allowed', $failures, $passes ); +agents_api_smoke_assert_equals( 'publishing-agent', $denied_result['metadata']['denial']['principal']['effective_agent_id'] ?? '', 'denial carries audit-safe principal metadata', $failures, $passes ); +agents_api_smoke_assert_equals( true, ( $GLOBALS['__agents_api_ceiling_dispatched']['local/publish-draft'] ?? 0 ) === 0, 'denied ability is never dispatched', $failures, $passes ); + +echo "\n[3] Ceiling permits the required capability and WordPress user allows it -> ability EXECUTES:\n"; +ceiling_smoke_reset_dispatch(); +$allowed_principal = WP_Agent_Execution_Principal::agent_token( + 7, + 'publishing-agent', + 902, + WP_Agent_Execution_Principal::REQUEST_CONTEXT_REST, + array(), + 'site:42', + 'ci', + new WP_Agent_Capability_Ceiling( 7, array( 'publish_posts', 'edit_posts' ) ) +); + +$allowed_result = $core->executeTool( + 'host/publish', + array( 'draft' => 'hello-world' ), + array( 'host/publish' => $publish_tool ), + $executor, + array( + 'principal' => $allowed_principal, + 'tool_call_id' => 'call-allowed-1', + ) +); + +agents_api_smoke_assert_equals( true, $allowed_result['success'] ?? false, 'permitted capability executes the ability', $failures, $passes ); +agents_api_smoke_assert_equals( 'local/publish-draft', $allowed_result['metadata']['ability_name'] ?? '', 'permitted execution records ability name', $failures, $passes ); +agents_api_smoke_assert_equals( 1, $GLOBALS['__agents_api_ceiling_dispatched']['local/publish-draft'] ?? 0, 'permitted ability is dispatched exactly once', $failures, $passes ); + +echo "\n[4] Ceiling permits the capability but WordPress user lacks it -> DENIED:\n"; +ceiling_smoke_reset_dispatch(); +$no_wp_cap_principal = WP_Agent_Execution_Principal::agent_token( + 9, + 'publishing-agent', + 903, + WP_Agent_Execution_Principal::REQUEST_CONTEXT_REST, + array(), + 'site:42', + 'ci', + new WP_Agent_Capability_Ceiling( 9, array( 'publish_posts', 'edit_posts' ) ) +); + +$no_wp_cap_result = $core->executeTool( + 'host/publish', + array( 'draft' => 'hello-world' ), + array( 'host/publish' => $publish_tool ), + $executor, + array( + 'principal' => $no_wp_cap_principal, + 'tool_call_id' => 'call-no-wp-cap-1', + ) +); + +agents_api_smoke_assert_equals( false, $no_wp_cap_result['success'] ?? true, 'ceiling permits but WordPress user lacks capability -> denied', $failures, $passes ); +agents_api_smoke_assert_equals( 'capability_denied', $no_wp_cap_result['metadata']['error_type'] ?? '', 'WordPress-user denial records capability_denied', $failures, $passes ); +agents_api_smoke_assert_equals( 0, $GLOBALS['__agents_api_ceiling_dispatched']['local/publish-draft'] ?? 0, 'WordPress-user denial never dispatches ability', $failures, $passes ); + +echo "\n[5] Required capability declared but no principal threaded -> DENIED (fail closed):\n"; +ceiling_smoke_reset_dispatch(); +$no_principal_result = $core->executeTool( + 'host/publish', + array( 'draft' => 'hello-world' ), + array( 'host/publish' => $publish_tool ), + $executor, + array( 'tool_call_id' => 'call-no-principal-1' ) +); + +agents_api_smoke_assert_equals( false, $no_principal_result['success'] ?? true, 'missing principal with required capability fails closed', $failures, $passes ); +agents_api_smoke_assert_equals( 'capability_denied', $no_principal_result['metadata']['error_type'] ?? '', 'missing principal denial records capability_denied', $failures, $passes ); +agents_api_smoke_assert_equals( 'principal_unavailable', $no_principal_result['metadata']['denial']['reason'] ?? '', 'missing principal denial records principal_unavailable reason', $failures, $passes ); +$no_principal_meta = $no_principal_result['metadata']['denial']['principal'] ?? null; +agents_api_smoke_assert_equals( true, null === $no_principal_meta || ( is_array( $no_principal_meta ) && array() === $no_principal_meta ), 'missing principal denial carries no principal metadata', $failures, $passes ); +agents_api_smoke_assert_equals( 0, $GLOBALS['__agents_api_ceiling_dispatched']['local/publish-draft'] ?? 0, 'missing principal denial never dispatches ability', $failures, $passes ); + +echo "\n[6] No required capability declared -> unchanged, executes without a principal:\n"; +ceiling_smoke_reset_dispatch(); +$open_result = $core->executeTool( + 'host/info', + array( 'draft' => 'context' ), + array( 'host/info' => $open_tool ), + $executor, + array( 'tool_call_id' => 'call-open-1' ) +); + +agents_api_smoke_assert_equals( true, $open_result['success'] ?? false, 'tool without required capability executes without a principal', $failures, $passes ); +agents_api_smoke_assert_equals( 1, $GLOBALS['__agents_api_ceiling_dispatched']['local/open-info'] ?? 0, 'ungated ability is dispatched exactly once', $failures, $passes ); + +echo "\n[7] Default substrate policy is used when context omits an authorization policy:\n"; +ceiling_smoke_reset_dispatch(); +$default_policy_result = $executor->executeWP_Agent_Tool_Call( + array( + 'tool_name' => 'host/publish', + 'parameters' => array( 'draft' => 'direct-call' ), + ), + $publish_tool, + array( + 'principal' => $allowed_principal, + ) +); + +agents_api_smoke_assert_equals( true, $default_policy_result['success'] ?? false, 'default substrate policy permits allowed capability', $failures, $passes ); +agents_api_smoke_assert_equals( 1, $GLOBALS['__agents_api_ceiling_dispatched']['local/publish-draft'] ?? 0, 'default policy path dispatches the ability', $failures, $passes ); + +echo "\n[8] Host-supplied authorization policy in context overrides the substrate default:\n"; +ceiling_smoke_reset_dispatch(); +$stub_policy = new WP_Agent_WordPress_Authorization_Policy( + null, + static function ( int $user_id, string $capability ): bool { + return false; + } +); + +$override_result = $executor->executeWP_Agent_Tool_Call( + array( + 'tool_name' => 'host/publish', + 'parameters' => array( 'draft' => 'override' ), + ), + $publish_tool, + array( + 'principal' => $allowed_principal, + 'authorization_policy' => $stub_policy, + ) +); + +agents_api_smoke_assert_equals( false, $override_result['success'] ?? true, 'host-supplied policy overrides substrate default and denies', $failures, $passes ); +agents_api_smoke_assert_equals( 'capability_denied', $override_result['metadata']['error_type'] ?? '', 'host policy denial records capability_denied', $failures, $passes ); +agents_api_smoke_assert_equals( 0, $GLOBALS['__agents_api_ceiling_dispatched']['local/publish-draft'] ?? 0, 'host policy denial never dispatches ability', $failures, $passes ); + +echo "\n[9] Principal with no ceiling restriction falls back to WordPress user capabilities:\n"; +ceiling_smoke_reset_dispatch(); +$unrestricted_principal = WP_Agent_Execution_Principal::user_session( + 7, + 'publishing-agent', + WP_Agent_Execution_Principal::REQUEST_CONTEXT_REST, + array(), + 'site:42' +); + +$unrestricted_result = $core->executeTool( + 'host/publish', + array( 'draft' => 'no-ceiling' ), + array( 'host/publish' => $publish_tool ), + $executor, + array( + 'principal' => $unrestricted_principal, + 'tool_call_id' => 'call-unrestricted-1', + ) +); + +agents_api_smoke_assert_equals( true, $unrestricted_result['success'] ?? false, 'principal without ceiling restriction executes when WordPress user allows capability', $failures, $passes ); +agents_api_smoke_assert_equals( 1, $GLOBALS['__agents_api_ceiling_dispatched']['local/publish-draft'] ?? 0, 'unrestricted ceiling dispatches the ability', $failures, $passes ); + +agents_api_smoke_finish( 'ability tool ceiling smoke', $failures, $passes );