From 61aa20bb5a23496092434d07b7ee3bc2402c4c60 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 6 Jul 2026 03:12:07 +0000 Subject: [PATCH] feat: derive a write-excluding safe default ceiling for autonomous executions Part of #412 (Stage A). Adds WP_Agent_Autonomous_Capability_Policy, a substrate helper that derives a filterable, write-excluding capability ceiling for autonomous execution principals (system/runtime/agent_token) when the host has not made an explicit grant. Introduces an additive denied_capabilities deny-list on WP_Agent_Capability_Ceiling that composes with the existing allow-list (deny takes precedence) without widening prior restrictions, and an is_autonomous_execution() accessor on the principal. --- agents-api.php | 1 + composer.json | 1 + ...-wp-agent-autonomous-capability-policy.php | 215 +++++++++++++++ .../class-wp-agent-capability-ceiling.php | 41 ++- .../class-wp-agent-execution-principal.php | 16 ++ tests/autonomous-capability-ceiling-smoke.php | 257 ++++++++++++++++++ tests/bootstrap-smoke.php | 1 + 7 files changed, 528 insertions(+), 4 deletions(-) create mode 100644 src/Auth/class-wp-agent-autonomous-capability-policy.php create mode 100644 tests/autonomous-capability-ceiling-smoke.php diff --git a/agents-api.php b/agents-api.php index 82607a9..6d74ef3 100644 --- a/agents-api.php +++ b/agents-api.php @@ -118,6 +118,7 @@ require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-citation-metadata.php'; require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-message.php'; require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-execution-principal.php'; +require_once AGENTS_API_PATH . 'src/Auth/class-wp-agent-autonomous-capability-policy.php'; require_once AGENTS_API_PATH . 'src/Transcripts/register-agents-conversation-session-abilities.php'; require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-effective-agent-resolver.php'; require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-compaction-item.php'; diff --git a/composer.json b/composer.json index 2d83eb3..09d7266 100644 --- a/composer.json +++ b/composer.json @@ -36,6 +36,7 @@ "php tests/effective-agent-resolver-smoke.php", "php tests/caller-context-smoke.php", "php tests/authorization-smoke.php", + "php tests/autonomous-capability-ceiling-smoke.php", "php tests/agents-access-ability-smoke.php", "php tests/agents-conversation-session-abilities-smoke.php", "php tests/conversation-session-store-contract-smoke.php", diff --git a/src/Auth/class-wp-agent-autonomous-capability-policy.php b/src/Auth/class-wp-agent-autonomous-capability-policy.php new file mode 100644 index 0000000..b481842 --- /dev/null +++ b/src/Auth/class-wp-agent-autonomous-capability-policy.php @@ -0,0 +1,215 @@ +auth_source, $sources, true ); + + if ( function_exists( 'apply_filters' ) ) { + $autonomous = (bool) apply_filters( 'agents_api_principal_is_autonomous', $autonomous, $principal ); + } + + return $autonomous; + } + + /** + * Capabilities excluded from autonomous executions by default. + * + * Hosts and Core can adjust the set through the + * `agents_api_autonomous_denied_capabilities` filter. + * + * @return string[] + */ + public static function denied_capabilities(): array { + $denied = self::DEFAULT_DENIED_CAPABILITIES; + + if ( function_exists( 'apply_filters' ) ) { + $denied = apply_filters( 'agents_api_autonomous_denied_capabilities', $denied ); + } + + return self::string_list( $denied ); + } + + /** + * Build the safe default ceiling for an autonomous execution. + * + * The ceiling leaves the allow-list unrestricted (null) and records the + * content-mutating capabilities on the deny-list, so the ceiling blocks + * those capabilities while permitting everything else the acting + * identity can otherwise use. + * + * @param int $user_id WordPress user ID bound to the ceiling. 0 for non-user principals. + * @return WP_Agent_Capability_Ceiling + */ + public static function safe_default_ceiling( int $user_id ): WP_Agent_Capability_Ceiling { + return new WP_Agent_Capability_Ceiling( + $user_id, + null, + array( + 'autonomous_safe_default' => true, + 'source' => 'wp_agent_autonomous_capability_policy', + ), + self::denied_capabilities() + ); + } + + /** + * Resolve the effective capability ceiling for a principal. + * + * Composition rules: + * - An explicit host grant (a ceiling carrying any capability shaping, + * whether an allow-list or a deny-list) is always respected and + * returned unchanged. The safe default never widens, narrows, or + * overrides a deliberate host decision. + * - An autonomous principal without an explicit grant receives the + * safe default ceiling, which excludes content-mutating capabilities. + * - Any other principal is returned with its ceiling untouched (null or + * unrestricted as the host supplied). + * + * @param AgentsAPI\AI\WP_Agent_Execution_Principal $principal Execution principal. + * @return WP_Agent_Capability_Ceiling|null + */ + public static function resolve_ceiling( AgentsAPI\AI\WP_Agent_Execution_Principal $principal ): ?WP_Agent_Capability_Ceiling { + $existing = $principal->capability_ceiling; + + if ( null !== $existing && ( $existing->has_capability_restrictions() || $existing->has_denied_capabilities() ) ) { + return $existing; + } + + if ( ! self::is_autonomous( $principal ) ) { + return $existing; + } + + return self::safe_default_ceiling( $principal->acting_user_id ); + } + + /** + * Normalize a raw list into unique, trimmed, non-empty capability strings. + * + * @param mixed $value Raw capability list. + * @return string[] + */ + private static function string_list( $value ): array { + if ( ! is_array( $value ) ) { + return array(); + } + + $strings = array(); + foreach ( $value as $item ) { + if ( ! is_scalar( $item ) ) { + continue; + } + + $trimmed = trim( (string) $item ); + if ( '' === $trimmed ) { + continue; + } + + $strings[] = $trimmed; + } + + return array_values( array_unique( $strings ) ); + } + } +} diff --git a/src/Auth/class-wp-agent-capability-ceiling.php b/src/Auth/class-wp-agent-capability-ceiling.php index aac8458..969b8c8 100644 --- a/src/Auth/class-wp-agent-capability-ceiling.php +++ b/src/Auth/class-wp-agent-capability-ceiling.php @@ -21,11 +21,13 @@ final class WP_Agent_Capability_Ceiling { * @param int $user_id WordPress user ID that bounds execution. * @param string[]|null $allowed_capabilities Optional capability allow-list. Null means unrestricted by token/client. * @param array $metadata Host-owned metadata about how this ceiling was derived. + * @param string[]|null $denied_capabilities Optional capability deny-list. Takes precedence over the allow-list. */ public function __construct( public readonly int $user_id, public readonly ?array $allowed_capabilities = null, public readonly array $metadata = array(), + public readonly ?array $denied_capabilities = null, ) { if ( $this->user_id < 0 ) { throw self::invalid( 'user_id', 'must be zero or a positive integer' ); @@ -39,6 +41,14 @@ public function __construct( } } + if ( null !== $this->denied_capabilities ) { + foreach ( $this->denied_capabilities as $capability ) { + if ( '' === trim( $capability ) ) { + throw self::invalid( 'denied_capabilities', 'must contain non-empty capability strings' ); + } + } + } + if ( false === self::json_encode( $this->metadata ) ) { throw self::invalid( 'metadata', 'must be JSON serializable' ); } @@ -53,22 +63,31 @@ public static function from_array( array $ceiling ): self { return new self( isset( $ceiling['user_id'] ) && is_scalar( $ceiling['user_id'] ) ? self::int_value( $ceiling['user_id'] ) : 0, array_key_exists( 'allowed_capabilities', $ceiling ) && null !== $ceiling['allowed_capabilities'] ? self::string_list( $ceiling['allowed_capabilities'] ) : null, - isset( $ceiling['metadata'] ) && is_array( $ceiling['metadata'] ) ? self::string_keyed_array( $ceiling['metadata'] ) : array() + isset( $ceiling['metadata'] ) && is_array( $ceiling['metadata'] ) ? self::string_keyed_array( $ceiling['metadata'] ) : array(), + array_key_exists( 'denied_capabilities', $ceiling ) && null !== $ceiling['denied_capabilities'] ? self::string_list( $ceiling['denied_capabilities'] ) : null, ); } /** - * Whether this ceiling has a token/client capability restriction. + * Whether this ceiling has a token/client capability allow-list restriction. */ public function has_capability_restrictions(): bool { return null !== $this->allowed_capabilities; } + /** + * Whether this ceiling carries a capability deny-list restriction. + */ + public function has_denied_capabilities(): bool { + return null !== $this->denied_capabilities; + } + /** * Whether token/client restrictions allow the capability. * * This does not call WordPress. It only answers whether the local ceiling - * restrictions include the requested capability. + * restrictions include the requested capability. A denied capability is + * never allowed, even when the allow-list is unrestricted. * * @param string $capability WordPress capability name. */ @@ -78,6 +97,10 @@ public function allows_capability( string $capability ): bool { return false; } + if ( null !== $this->denied_capabilities && in_array( $capability, $this->denied_capabilities, true ) ) { + return false; + } + if ( null === $this->allowed_capabilities ) { return true; } @@ -91,7 +114,16 @@ public function allows_capability( string $capability ): bool { * @param string[] $allowed_capabilities Allowed capability names. */ public function with_allowed_capabilities( array $allowed_capabilities ): self { - return new self( $this->user_id, array_values( $allowed_capabilities ), $this->metadata ); + return new self( $this->user_id, array_values( $allowed_capabilities ), $this->metadata, $this->denied_capabilities ); + } + + /** + * Return a copy with an additional capability deny-list. + * + * @param string[] $denied_capabilities Denied capability names. + */ + public function with_denied_capabilities( array $denied_capabilities ): self { + return new self( $this->user_id, $this->allowed_capabilities, $this->metadata, array_values( $denied_capabilities ) ); } /** @@ -103,6 +135,7 @@ public function to_array(): array { return array( 'user_id' => $this->user_id, 'allowed_capabilities' => $this->allowed_capabilities, + 'denied_capabilities' => $this->denied_capabilities, 'metadata' => $this->metadata, ); } diff --git a/src/Runtime/class-wp-agent-execution-principal.php b/src/Runtime/class-wp-agent-execution-principal.php index 8528585..d70ee97 100644 --- a/src/Runtime/class-wp-agent-execution-principal.php +++ b/src/Runtime/class-wp-agent-execution-principal.php @@ -401,6 +401,22 @@ public function conversation_owner(): ?array { return null; } + /** + * Whether this principal represents an autonomous execution. + * + * Autonomous executions are driven by automation rather than a live human + * authorizing each action. The determination delegates to the substrate + * autonomous capability policy so hosts can adjust it through the + * `agents_api_autonomous_auth_sources` and `agents_api_principal_is_autonomous` + * filters. + * + * @return bool + */ + public function is_autonomous_execution(): bool { + return class_exists( 'WP_Agent_Autonomous_Capability_Policy' ) + && \WP_Agent_Autonomous_Capability_Policy::is_autonomous( $this ); + } + /** * Whether this principal represents a host-resolved non-user audience. */ diff --git a/tests/autonomous-capability-ceiling-smoke.php b/tests/autonomous-capability-ceiling-smoke.php new file mode 100644 index 0000000..44fa098 --- /dev/null +++ b/tests/autonomous-capability-ceiling-smoke.php @@ -0,0 +1,257 @@ +has_denied_capabilities(), 'ceiling reports denied capability restriction', $failures, $passes ); +agents_api_smoke_assert_equals( false, $deny_ceiling->allows_capability( 'publish_posts' ), 'deny-list blocks a denied capability', $failures, $passes ); +agents_api_smoke_assert_equals( false, $deny_ceiling->allows_capability( 'delete_posts' ), 'deny-list blocks a second denied capability', $failures, $passes ); +agents_api_smoke_assert_equals( true, $deny_ceiling->allows_capability( 'read' ), 'deny-list still permits an unrestricted capability', $failures, $passes ); + +$deny_with_allow = new WP_Agent_Capability_Ceiling( 7, array( 'edit_posts', 'publish_posts', 'read' ), array(), array( 'publish_posts' ) ); +agents_api_smoke_assert_equals( false, $deny_with_allow->allows_capability( 'publish_posts' ), 'deny-list overrides an allowed capability', $failures, $passes ); +agents_api_smoke_assert_equals( true, $deny_with_allow->allows_capability( 'edit_posts' ), 'allow-list still gates a non-denied capability', $failures, $passes ); + +$plain = new WP_Agent_Capability_Ceiling( 7, array( 'read' ) ); +agents_api_smoke_assert_equals( false, $plain->has_denied_capabilities(), 'ceiling without deny-list reports no denied restriction', $failures, $passes ); +agents_api_smoke_assert_equals( true, $plain->allows_capability( 'read' ), 'ceiling without deny-list keeps existing allow-list behavior', $failures, $passes ); + +$with_denied_added = $plain->with_denied_capabilities( array( 'manage_options' ) ); +agents_api_smoke_assert_equals( array( 'manage_options' ), $with_denied_added->denied_capabilities, 'with_denied_capabilities records the deny-list', $failures, $passes ); +agents_api_smoke_assert_equals( false, $with_denied_added->allows_capability( 'manage_options' ), 'with_denied_capabilities blocks the new deny entry', $failures, $passes ); +agents_api_smoke_assert_equals( true, $with_denied_added->allows_capability( 'read' ), 'with_denied_capabilities preserves the existing allow-list', $failures, $passes ); + +$with_allowed_kept = $deny_ceiling->with_allowed_capabilities( array( 'edit_posts' ) ); +agents_api_smoke_assert_equals( array( 'publish_posts', 'delete_posts' ), $with_allowed_kept->denied_capabilities, 'with_allowed_capabilities preserves the existing deny-list', $failures, $passes ); +agents_api_smoke_assert_equals( false, $with_allowed_kept->allows_capability( 'publish_posts' ), 'with_allowed_capabilities does not widen an existing deny', $failures, $passes ); + +$round_trip = WP_Agent_Capability_Ceiling::from_array( + array( + 'user_id' => 9, + 'allowed_capabilities' => array( 'read', 'edit_posts' ), + 'denied_capabilities' => array( 'edit_posts' ), + 'metadata' => array( 'origin' => 'round-trip' ), + ) +); +agents_api_smoke_assert_equals( 9, $round_trip->user_id, 'from_array restores denied-ceiling user id', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'read', 'edit_posts' ), $round_trip->allowed_capabilities, 'from_array restores the allow-list alongside the deny-list', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'edit_posts' ), $round_trip->denied_capabilities, 'from_array restores the deny-list', $failures, $passes ); +agents_api_smoke_assert_equals( false, $round_trip->allows_capability( 'edit_posts' ), 'round-tripped ceiling honors the deny-list over the allow-list', $failures, $passes ); + +$null_denied_round_trip = WP_Agent_Capability_Ceiling::from_array( + array( + 'user_id' => 3, + 'allowed_capabilities' => array( 'read' ), + ) +); +agents_api_smoke_assert_equals( null, $null_denied_round_trip->denied_capabilities, 'from_array leaves denied capabilities null when absent', $failures, $passes ); + +$exported = $deny_with_allow->to_array(); +agents_api_smoke_assert_equals( array( 'publish_posts' ), $exported['denied_capabilities'], 'to_array exports the deny-list', $failures, $passes ); + +try { + new WP_Agent_Capability_Ceiling( 1, null, array(), array( 'read', '' ) ); + agents_api_smoke_assert_equals( true, false, 'empty denied capability string is rejected', $failures, $passes ); +} catch ( InvalidArgumentException $e ) { + agents_api_smoke_assert_equals( true, str_contains( $e->getMessage(), 'denied_capabilities' ), 'empty denied capability string is rejected', $failures, $passes ); +} + +echo "\n[2] Autonomous principal without an explicit grant receives the write-excluding safe default:\n"; + +$system_principal = new $principal_class( + 0, + 'system-agent', + $principal_class::AUTH_SOURCE_SYSTEM, + $principal_class::REQUEST_CONTEXT_CRON +); +$system_ceiling = $policy::resolve_ceiling( $system_principal ); +agents_api_smoke_assert_equals( true, $system_ceiling instanceof WP_Agent_Capability_Ceiling, 'system principal receives a derived ceiling', $failures, $passes ); +agents_api_smoke_assert_equals( true, $system_principal->is_autonomous_execution(), 'system principal is autonomous', $failures, $passes ); +agents_api_smoke_assert_equals( false, $system_ceiling->allows_capability( 'publish_posts' ), 'autonomous system principal cannot publish posts', $failures, $passes ); +agents_api_smoke_assert_equals( false, $system_ceiling->allows_capability( 'edit_posts' ), 'autonomous system principal cannot edit posts', $failures, $passes ); +agents_api_smoke_assert_equals( false, $system_ceiling->allows_capability( 'manage_options' ), 'autonomous system principal cannot manage options', $failures, $passes ); +agents_api_smoke_assert_equals( true, $system_ceiling->allows_capability( 'read' ), 'autonomous system principal keeps read access', $failures, $passes ); +agents_api_smoke_assert_equals( true, $system_ceiling->has_denied_capabilities(), 'autonomous safe default carries a deny-list', $failures, $passes ); +agents_api_smoke_assert_equals( true, null === $system_ceiling->allowed_capabilities, 'autonomous safe default keeps the allow-list unrestricted', $failures, $passes ); +agents_api_smoke_assert_equals( true, true === $system_ceiling->metadata['autonomous_safe_default'], 'autonomous safe default is marked in metadata', $failures, $passes ); + +$token_principal = $principal_class::agent_token( 7, 'automation-agent', 456, $principal_class::REQUEST_CONTEXT_REST ); +agents_api_smoke_assert_equals( true, $token_principal->is_autonomous_execution(), 'agent token principal is autonomous', $failures, $passes ); +$token_ceiling = $policy::resolve_ceiling( $token_principal ); +agents_api_smoke_assert_equals( 7, $token_ceiling->user_id, 'autonomous token ceiling is bound to the token owner', $failures, $passes ); +agents_api_smoke_assert_equals( false, $token_ceiling->allows_capability( 'publish_posts' ), 'autonomous token principal cannot publish posts by default', $failures, $passes ); +agents_api_smoke_assert_equals( false, $token_ceiling->allows_capability( 'delete_others_posts' ), 'autonomous token principal cannot delete others posts by default', $failures, $passes ); +agents_api_smoke_assert_equals( true, $token_ceiling->allows_capability( 'read' ), 'autonomous token principal keeps read access', $failures, $passes ); + +$runtime_principal = $principal_class::runtime( 'runtime-session-1', 'delegated-runtime-agent' ); +agents_api_smoke_assert_equals( true, $runtime_principal->is_autonomous_execution(), 'runtime principal is autonomous', $failures, $passes ); +$runtime_ceiling = $policy::resolve_ceiling( $runtime_principal ); +agents_api_smoke_assert_equals( false, $runtime_ceiling->allows_capability( 'edit_pages' ), 'autonomous runtime principal cannot edit pages by default', $failures, $passes ); +agents_api_smoke_assert_equals( false, $runtime_ceiling->allows_capability( 'upload_files' ), 'autonomous runtime principal cannot upload files by default', $failures, $passes ); + +echo "\n[3] Interactive principals are unaffected and keep their host-supplied ceiling:\n"; + +$interactive = $principal_class::user_session( 5, 'editor-agent', $principal_class::REQUEST_CONTEXT_REST ); +agents_api_smoke_assert_equals( false, $interactive->is_autonomous_execution(), 'user session principal is not autonomous', $failures, $passes ); +agents_api_smoke_assert_equals( null, $policy::resolve_ceiling( $interactive ), 'interactive principal without a ceiling stays unrestricted', $failures, $passes ); + +$interactive_unrestricted = new $principal_class( + 5, + 'editor-agent', + $principal_class::AUTH_SOURCE_APPLICATION_PASSWORD, + $principal_class::REQUEST_CONTEXT_REST +); +agents_api_smoke_assert_equals( false, $interactive_unrestricted->is_autonomous_execution(), 'application password principal is not autonomous', $failures, $passes ); +agents_api_smoke_assert_equals( null, $policy::resolve_ceiling( $interactive_unrestricted ), 'application password principal without a ceiling stays unrestricted', $failures, $passes ); + +echo "\n[4] Explicit host grants are respected and the safe default never overrides or widens them:\n"; + +$explicit_allow = new WP_Agent_Capability_Ceiling( 7, array( 'edit_posts', 'read' ) ); +$explicit_principal = $principal_class::agent_token( 7, 'automation-agent', 456, $principal_class::REQUEST_CONTEXT_REST ); +$explicit_principal_with_ceiling = new $principal_class( + 7, + 'automation-agent', + $principal_class::AUTH_SOURCE_AGENT_TOKEN, + $principal_class::REQUEST_CONTEXT_REST, + 456, + array(), + null, + null, + $explicit_allow +); +$resolved_explicit = $policy::resolve_ceiling( $explicit_principal_with_ceiling ); +agents_api_smoke_assert_equals( true, $resolved_explicit === $explicit_allow, 'explicit host grant is returned unchanged by identity', $failures, $passes ); +agents_api_smoke_assert_equals( true, $resolved_explicit->allows_capability( 'edit_posts' ), 'explicit host grant keeps an allowed capability', $failures, $passes ); +agents_api_smoke_assert_equals( false, $resolved_explicit->allows_capability( 'publish_posts' ), 'explicit host grant keeps a non-granted capability denied', $failures, $passes ); +agents_api_smoke_assert_equals( false, $resolved_explicit->has_denied_capabilities(), 'explicit host grant does not receive the autonomous deny-list', $failures, $passes ); + +$explicit_grant_write = new WP_Agent_Capability_Ceiling( 7, array( 'publish_posts', 'read' ) ); +$explicit_write_principal = new $principal_class( + 7, + 'automation-agent', + $principal_class::AUTH_SOURCE_AGENT_TOKEN, + $principal_class::REQUEST_CONTEXT_REST, + 456, + array(), + null, + null, + $explicit_grant_write +); +$resolved_write = $policy::resolve_ceiling( $explicit_write_principal ); +agents_api_smoke_assert_equals( true, $resolved_write->allows_capability( 'publish_posts' ), 'explicit write grant to an autonomous principal is respected', $failures, $passes ); + +$unrestricted_attached = new WP_Agent_Capability_Ceiling( 7 ); +$unrestricted_principal = new $principal_class( + 7, + 'automation-agent', + $principal_class::AUTH_SOURCE_AGENT_TOKEN, + $principal_class::REQUEST_CONTEXT_REST, + 456, + array(), + null, + null, + $unrestricted_attached +); +agents_api_smoke_assert_equals( false, $policy::resolve_ceiling( $unrestricted_principal )->allows_capability( 'publish_posts' ), 'unrestricted ceiling on an autonomous principal still receives the safe default', $failures, $passes ); + +$autonomous_no_ceiling = $policy::resolve_ceiling( $explicit_principal ); +agents_api_smoke_assert_equals( false, $autonomous_no_ceiling === $explicit_allow, 'principal without attached ceiling derives a fresh ceiling', $failures, $passes ); + +echo "\n[5] Default content-mutating capability set is documented and conservative:\n"; + +$denied_defaults = $policy::denied_capabilities(); +$expected_denied = array( + 'edit_posts', + 'edit_others_posts', + 'edit_published_posts', + 'publish_posts', + 'delete_posts', + 'delete_others_posts', + 'delete_published_posts', + 'edit_pages', + 'edit_others_pages', + 'edit_published_pages', + 'publish_pages', + 'delete_pages', + 'delete_others_pages', + 'delete_published_pages', + 'manage_categories', + 'manage_options', + 'upload_files', + 'unfiltered_html', + 'create_users', + 'delete_users', + 'promote_users', +); +foreach ( $expected_denied as $capability ) { + agents_api_smoke_assert_equals( true, in_array( $capability, $denied_defaults, true ), 'default deny-list includes ' . $capability, $failures, $passes ); +} +agents_api_smoke_assert_equals( false, in_array( 'read', $denied_defaults, true ), 'default deny-list excludes read access', $failures, $passes ); + +echo "\n[6] Filters let hosts adjust the autonomous definition and the denied capability set:\n"; + +add_filter( + 'agents_api_autonomous_auth_sources', + static function ( array $sources ) use ( $principal_class ): array { + return array_filter( + $sources, + static function ( string $source ) use ( $principal_class ): bool { + return $principal_class::AUTH_SOURCE_AGENT_TOKEN !== $source; + } + ); + } +); +agents_api_smoke_assert_equals( false, in_array( $principal_class::AUTH_SOURCE_AGENT_TOKEN, $policy::autonomous_auth_sources(), true ), 'filter removes an auth source from the autonomous set', $failures, $passes ); +$token_after_filter = $policy::resolve_ceiling( $token_principal ); +agents_api_smoke_assert_equals( null, $token_after_filter, 'principal reclassified as non-autonomous is left unrestricted', $failures, $passes ); + +add_filter( + 'agents_api_principal_is_autonomous', + static function ( bool $autonomous, AgentsAPI\AI\WP_Agent_Execution_Principal $principal ) use ( $principal_class ): bool { + if ( $principal_class::AUTH_SOURCE_AUDIENCE === $principal->auth_source ) { + return true; + } + + return $autonomous; + }, + 10, + 2 +); +$audience_principal = $principal_class::audience( 'audience:docs-readers', 'audience-gateway', $principal_class::REQUEST_CONTEXT_REST ); +$audience_ceiling = $policy::resolve_ceiling( $audience_principal ); +agents_api_smoke_assert_equals( true, $audience_ceiling instanceof WP_Agent_Capability_Ceiling, 'per-principal filter can mark an audience autonomous', $failures, $passes ); +agents_api_smoke_assert_equals( false, $audience_ceiling->allows_capability( 'publish_posts' ), 'filter-declared autonomous principal receives the safe default', $failures, $passes ); + +add_filter( + 'agents_api_autonomous_denied_capabilities', + static function ( array $denied ): array { + return array_merge( $denied, array( 'read' ) ); + } +); +$runtime_ceiling_filtered = $policy::resolve_ceiling( $runtime_principal ); +agents_api_smoke_assert_equals( true, in_array( 'read', $runtime_ceiling_filtered->denied_capabilities, true ), 'filter extends the denied capability set', $failures, $passes ); +agents_api_smoke_assert_equals( false, $runtime_ceiling_filtered->allows_capability( 'read' ), 'extended deny-list blocks the added capability', $failures, $passes ); + +agents_api_smoke_finish( 'Agents API autonomous capability ceiling', $failures, $passes ); diff --git a/tests/bootstrap-smoke.php b/tests/bootstrap-smoke.php index f853812..d904dfb 100644 --- a/tests/bootstrap-smoke.php +++ b/tests/bootstrap-smoke.php @@ -94,6 +94,7 @@ agents_api_smoke_assert_equals( true, interface_exists( 'WP_Agent_Authorization_Policy' ), 'WP_Agent_Authorization_Policy contract is available', $failures, $passes ); agents_api_smoke_assert_equals( true, class_exists( 'WP_Agent_WordPress_Authorization_Policy' ), 'WP_Agent_WordPress_Authorization_Policy service is available', $failures, $passes ); agents_api_smoke_assert_equals( true, class_exists( 'WP_Agent_Capability_Ceiling' ), 'WP_Agent_Capability_Ceiling value object is available', $failures, $passes ); +agents_api_smoke_assert_equals( true, class_exists( 'WP_Agent_Autonomous_Capability_Policy' ), 'WP_Agent_Autonomous_Capability_Policy derivation helper is available', $failures, $passes ); agents_api_smoke_assert_equals( true, class_exists( 'WP_Agent_Memory_Registry' ), 'WP_Agent_Memory_Registry facade is available', $failures, $passes ); agents_api_smoke_assert_equals( true, class_exists( 'WP_Agent_Memory_Layer' ), 'WP_Agent_Memory_Layer vocabulary is available', $failures, $passes ); agents_api_smoke_assert_equals( true, class_exists( 'WP_Agent_Context_Section_Registry' ), 'WP_Agent_Context_Section_Registry facade is available', $failures, $passes );