From 74f944cebef84d38c9708697544a031b3142173d Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 6 Jul 2026 03:54:15 +0000 Subject: [PATCH] feat: enforce capability ceiling at ability-backed tool execution (#412 B+C) Resolve each ability-backed tool's required capability declaratively from the tool declaration and gate dispatch through the existing WP_Agent_WordPress_Authorization_Policy::can() before the ability runs. - WP_Agent_Tool_Declaration: add required_capability field + resolver; validate/normalize on server declarations (absent = no gate). - WP_Agent_Ability_Tool_Executor: consult the authorization policy before dispatch; deny with an auditable tool-error result when the principal's capability ceiling does not permit the capability. - WP_Agent_Conversation_Loop: thread the resolved execution principal into the run context so it reaches tool execution. Enforcement depends only on the ceiling being present on the principal, so it works with either an explicit ceiling or the future autonomous default. Part of #412 (Stages B+C). --- composer.json | 1 + .../class-wp-agent-conversation-loop.php | 8 + .../class-wp-agent-ability-tool-executor.php | 86 +++- src/Tools/class-wp-agent-tool-declaration.php | 43 ++ tests/ability-tool-ceiling-smoke.php | 402 ++++++++++++++++++ 5 files changed, 538 insertions(+), 2 deletions(-) create mode 100644 tests/ability-tool-ceiling-smoke.php 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 );