diff --git a/composer.json b/composer.json index 9d16a9d..98dce74 100644 --- a/composer.json +++ b/composer.json @@ -128,6 +128,7 @@ "php tests/routine-smoke.php", "php tests/event-trigger-smoke.php", "php tests/subagents-smoke.php", + "php tests/access-decision-filter-smoke.php", "php tests/no-product-imports-smoke.php" ], "test": [ diff --git a/src/Auth/class-wp-agent-access.php b/src/Auth/class-wp-agent-access.php index d3ebbb5..29ee717 100644 --- a/src/Auth/class-wp-agent-access.php +++ b/src/Auth/class-wp-agent-access.php @@ -121,6 +121,13 @@ public static function list_accessible_agents_for_current_principal( string $min /** * List registered agents accessible to a principal. * + * Iterates every registered agent through the same + * {@see WP_Agent_WordPress_Authorization_Policy::can_access_agent()} + * decision used by the check path. This guarantees that + * `agents/list-accessible-agents` and `agents/can-access-agent` can + * never disagree: the list is literally the set of agents for which + * the check returns true. + * * @param AgentsAPI\AI\WP_Agent_Execution_Principal $principal Execution principal. * @param string $minimum_role Minimum access role. * @param array $context Host-owned request context. @@ -131,29 +138,26 @@ public static function list_accessible_agents_for_principal( AgentsAPI\AI\WP_Age return array(); } - $agent_ids = array(); - $store = self::get_store( $context ); - if ( $store instanceof WP_Agent_Access_Store && $principal->acting_user_id > 0 ) { - $agent_ids = array_merge( $agent_ids, $store->get_agent_ids_for_user( $principal->acting_user_id, $minimum_role, $principal->workspace_id ) ); - } + $store = self::get_store( $context ); + $policy = new WP_Agent_WordPress_Authorization_Policy( $store ); - if ( $store instanceof WP_Agent_Principal_Access_Store ) { - foreach ( self::access_principals_for( $principal, $context ) as $access_principal ) { - $agent_ids = array_merge( $agent_ids, $store->get_agent_ids_for_principal( $access_principal, $minimum_role, $principal->workspace_id ) ); + $agents = array(); + $seen = array(); + + $registered = function_exists( 'wp_get_agents' ) ? wp_get_agents() : array(); + foreach ( $registered as $agent ) { + $slug = sanitize_title( $agent->get_slug() ); + if ( '' === $slug || isset( $seen[ $slug ] ) ) { + continue; } - } - if ( null === $principal->audience_id && self::CURRENT_USER_EFFECTIVE_AGENT_ID !== $principal->effective_agent_id ) { - $agent_ids[] = $principal->effective_agent_id; - } + $seen[ $slug ] = true; - $agent_ids = array_values( array_unique( array_filter( array_map( 'sanitize_title', $agent_ids ) ) ) ); - $agents = array(); - foreach ( $agent_ids as $agent_id ) { - $agent = function_exists( 'wp_get_agent' ) ? wp_get_agent( $agent_id ) : null; - if ( $agent instanceof WP_Agent ) { - $agents[] = self::agent_to_access_summary( $agent ); + if ( ! $policy->can_access_agent( $principal, $slug, $minimum_role, $context ) ) { + continue; } + + $agents[] = self::agent_to_access_summary( $agent ); } return $agents; diff --git a/src/Auth/class-wp-agent-wordpress-authorization-policy.php b/src/Auth/class-wp-agent-wordpress-authorization-policy.php index 7ef4a4e..76bdee7 100644 --- a/src/Auth/class-wp-agent-wordpress-authorization-policy.php +++ b/src/Auth/class-wp-agent-wordpress-authorization-policy.php @@ -55,6 +55,11 @@ public function can( AgentsAPI\AI\WP_Agent_Execution_Principal $principal, strin /** * Check whether a principal can access an agent at a minimum role. * + * The store-derived decision — including the effective-agent + * short-circuit — is wrapped in the {@see 'wp_agent_can_access_agent'} + * filter so hosts can tighten as well as widen access without + * materializing grant rows. + * * @param AgentsAPI\AI\WP_Agent_Execution_Principal $principal Execution principal. * @param string $agent_id Agent identifier. * @param string $minimum_role Minimum access role. @@ -65,6 +70,42 @@ public function can_access_agent( AgentsAPI\AI\WP_Agent_Execution_Principal $pri return false; } + $allowed = $this->resolve_default_access_decision( $principal, $agent_id, $minimum_role, $context ); + + if ( function_exists( 'apply_filters' ) ) { + /** + * Filters the final agent access decision. + * + * Hosts that derive access from live state (capabilities, roles, + * memberships) can grant or deny access without materializing + * grant rows. The filter wraps the store-derived decision — + * including the effective-agent short-circuit — so hosts can + * tighten as well as widen access. + * + * @param bool $allowed Default store-derived decision. + * @param AgentsAPI\AI\WP_Agent_Execution_Principal $principal Execution principal. + * @param string $agent_id Agent identifier. + * @param string $minimum_role Minimum access role. + * @param array $context Host-owned authorization context. + */ + $allowed = (bool) apply_filters( 'wp_agent_can_access_agent', $allowed, $principal, $agent_id, $minimum_role, $context ); + } + + return $allowed; + } + + /** + * Resolve the default (store-derived) access decision before host filtering. + * + * Includes the effective-agent short-circuit so the host filter can + * tighten as well as widen the decision. + * + * @param AgentsAPI\AI\WP_Agent_Execution_Principal $principal Execution principal. + * @param string $agent_id Agent identifier. + * @param string $minimum_role Minimum access role. + * @param array $context Host-owned authorization context. + */ + private function resolve_default_access_decision( AgentsAPI\AI\WP_Agent_Execution_Principal $principal, string $agent_id, string $minimum_role, array $context ): bool { if ( $principal->effective_agent_id === $agent_id ) { return true; } diff --git a/tests/access-decision-filter-smoke.php b/tests/access-decision-filter-smoke.php new file mode 100644 index 0000000..5dc6fe8 --- /dev/null +++ b/tests/access-decision-filter-smoke.php @@ -0,0 +1,320 @@ + 0; + } +} + +if ( ! function_exists( 'wp_has_ability_category' ) ) { + function wp_has_ability_category( string $category ): bool { + return isset( $GLOBALS['__agents_api_smoke_categories'][ $category ] ); + } +} + +if ( ! function_exists( 'wp_register_ability_category' ) ) { + function wp_register_ability_category( string $category, array $args ): void { + $GLOBALS['__agents_api_smoke_categories'][ $category ] = $args; + } +} + +if ( ! function_exists( 'wp_has_ability' ) ) { + function wp_has_ability( string $ability ): bool { + return isset( $GLOBALS['__agents_api_smoke_abilities'][ $ability ] ); + } +} + +if ( ! function_exists( 'wp_register_ability' ) ) { + function wp_register_ability( string $ability, array $args ): void { + $GLOBALS['__agents_api_smoke_abilities'][ $ability ] = $args; + } +} + +agents_api_smoke_require_module(); + +// ----------------------------------------------------------------------- +// Register three test agents. +// ----------------------------------------------------------------------- +add_action( + 'wp_agents_api_init', + static function (): void { + wp_register_agent( + 'alpha-agent', + array( + 'label' => 'Alpha Agent', + 'description' => 'Has a store grant.', + ) + ); + wp_register_agent( + 'beta-agent', + array( + 'label' => 'Beta Agent', + 'description' => 'No store grant; filter-granted.', + ) + ); + wp_register_agent( + 'gamma-agent', + array( + 'label' => 'Gamma Agent', + 'description' => 'No store grant; filter-granted.', + ) + ); + } +); + +do_action( 'init' ); + +// ----------------------------------------------------------------------- +// Store with a single grant for alpha-agent (user 7, operator, site:42). +// ----------------------------------------------------------------------- +$grant = new WP_Agent_Access_Grant( 'alpha-agent', 7, WP_Agent_Access_Grant::ROLE_OPERATOR, 'site:42' ); + +$access_store = new class( $grant ) implements WP_Agent_Access_Store { + public function __construct( private WP_Agent_Access_Grant $grant ) {} + + public function grant_access( WP_Agent_Access_Grant $grant ): WP_Agent_Access_Grant { + $this->grant = $grant; + return $grant; + } + + public function revoke_access( string $agent_id, int $user_id, ?string $workspace_id = null ): bool { + return $this->grant->agent_id === $agent_id && $this->grant->user_id === $user_id && $this->grant->workspace_id === $workspace_id; + } + + public function get_access( string $agent_id, int $user_id, ?string $workspace_id = null ): ?WP_Agent_Access_Grant { + return $this->grant->agent_id === $agent_id && $this->grant->user_id === $user_id && $this->grant->workspace_id === $workspace_id ? $this->grant : null; + } + + public function get_agent_ids_for_user( int $user_id, ?string $minimum_role = null, ?string $workspace_id = null ): array { + if ( $this->grant->user_id !== $user_id || $this->grant->workspace_id !== $workspace_id ) { + return array(); + } + + return null === $minimum_role || $this->grant->role_meets( $minimum_role ) ? array( $this->grant->agent_id ) : array(); + } + + public function get_users_for_agent( string $agent_id, ?string $workspace_id = null ): array { + return $this->grant->agent_id === $agent_id && $this->grant->workspace_id === $workspace_id ? array( $this->grant ) : array(); + } +}; + +add_filter( + 'wp_agent_access_store', + static function ( $store ) use ( $access_store ) { + return $store instanceof WP_Agent_Access_Store ? $store : $access_store; + } +); + +$context = array( 'workspace_id' => 'site:42', 'access_store' => $access_store ); + +$principal = AgentsAPI\AI\WP_Agent_Execution_Principal::user_session( + 7, + 'test-user', + AgentsAPI\AI\WP_Agent_Execution_Principal::REQUEST_CONTEXT_REST, + array( 'source' => 'smoke-test' ), + 'site:42' +); + +// Helper: extract slugs from an accessible-agents list. +function access_decision_filter_slugs( array $agents ): array { + return array_column( $agents, 'slug' ); +} + +// Helper: assert list/check agreement for a single agent. +function access_decision_filter_assert_agreement( AgentsAPI\AI\WP_Agent_Execution_Principal $principal, string $agent_id, array $context, string $label, array &$failures, int &$passes ): void { + $can = WP_Agent_Access::can_principal_access_agent( $principal, $agent_id, WP_Agent_Access_Grant::ROLE_VIEWER, $context ); + $list = WP_Agent_Access::list_accessible_agents_for_principal( $principal, WP_Agent_Access_Grant::ROLE_VIEWER, $context ); + $listed = in_array( $agent_id, access_decision_filter_slugs( $list ), true ); + agents_api_smoke_assert_equals( $can, $listed, $label, $failures, $passes ); +} + +// ======================================================================= +// Phase 1: Default behavior — no wp_agent_can_access_agent filter hooked. +// ======================================================================= + +agents_api_smoke_assert_equals( true, WP_Agent_Access::can_principal_access_agent( $principal, 'alpha-agent', WP_Agent_Access_Grant::ROLE_VIEWER, $context ), 'default: store grant allows alpha at viewer', $failures, $passes ); +agents_api_smoke_assert_equals( true, WP_Agent_Access::can_principal_access_agent( $principal, 'alpha-agent', WP_Agent_Access_Grant::ROLE_OPERATOR, $context ), 'default: store grant allows alpha at operator', $failures, $passes ); +agents_api_smoke_assert_equals( false, WP_Agent_Access::can_principal_access_agent( $principal, 'alpha-agent', WP_Agent_Access_Grant::ROLE_ADMIN, $context ), 'default: store grant denies alpha above role', $failures, $passes ); +agents_api_smoke_assert_equals( false, WP_Agent_Access::can_principal_access_agent( $principal, 'beta-agent', WP_Agent_Access_Grant::ROLE_VIEWER, $context ), 'default: no grant denies beta', $failures, $passes ); +agents_api_smoke_assert_equals( false, WP_Agent_Access::can_principal_access_agent( $principal, 'gamma-agent', WP_Agent_Access_Grant::ROLE_VIEWER, $context ), 'default: no grant denies gamma', $failures, $passes ); + +$default_list = WP_Agent_Access::list_accessible_agents_for_principal( $principal, WP_Agent_Access_Grant::ROLE_VIEWER, $context ); +$default_slugs = access_decision_filter_slugs( $default_list ); +agents_api_smoke_assert_equals( true, in_array( 'alpha-agent', $default_slugs, true ), 'default: list includes alpha', $failures, $passes ); +agents_api_smoke_assert_equals( false, in_array( 'beta-agent', $default_slugs, true ), 'default: list excludes beta', $failures, $passes ); +agents_api_smoke_assert_equals( false, in_array( 'gamma-agent', $default_slugs, true ), 'default: list excludes gamma', $failures, $passes ); + +// ======================================================================= +// Hook the filter with a mutable override map. +// ======================================================================= + +$filter_overrides = array(); + +add_filter( + 'wp_agent_can_access_agent', + static function ( $allowed, $p, string $agent_id, string $minimum_role, array $ctx ) use ( &$filter_overrides ) { + if ( array_key_exists( $agent_id, $filter_overrides ) ) { + return $filter_overrides[ $agent_id ]; + } + return $allowed; + }, + 10, + 5 +); + +// ======================================================================= +// Phase 2: Filter grants beta (no store row). +// ======================================================================= + +$filter_overrides = array( 'beta-agent' => true ); + +agents_api_smoke_assert_equals( true, WP_Agent_Access::can_principal_access_agent( $principal, 'beta-agent', WP_Agent_Access_Grant::ROLE_VIEWER, $context ), 'filter grants beta: check returns true with no store row', $failures, $passes ); +agents_api_smoke_assert_equals( true, WP_Agent_Access::can_principal_access_agent( $principal, 'alpha-agent', WP_Agent_Access_Grant::ROLE_VIEWER, $context ), 'filter grants beta: alpha still allowed (passthrough)', $failures, $passes ); +agents_api_smoke_assert_equals( false, WP_Agent_Access::can_principal_access_agent( $principal, 'gamma-agent', WP_Agent_Access_Grant::ROLE_VIEWER, $context ), 'filter grants beta: gamma still denied (passthrough)', $failures, $passes ); + +$grant_list = WP_Agent_Access::list_accessible_agents_for_principal( $principal, WP_Agent_Access_Grant::ROLE_VIEWER, $context ); +$grant_slugs = access_decision_filter_slugs( $grant_list ); +agents_api_smoke_assert_equals( true, in_array( 'alpha-agent', $grant_slugs, true ), 'filter grants beta: list includes alpha', $failures, $passes ); +agents_api_smoke_assert_equals( true, in_array( 'beta-agent', $grant_slugs, true ), 'filter grants beta: list includes beta', $failures, $passes ); +agents_api_smoke_assert_equals( false, in_array( 'gamma-agent', $grant_slugs, true ), 'filter grants beta: list excludes gamma', $failures, $passes ); + +access_decision_filter_assert_agreement( $principal, 'beta-agent', $context, 'list/check agree: beta granted by filter', $failures, $passes ); + +// ======================================================================= +// Phase 3: Filter denies alpha (despite store grant). +// ======================================================================= + +$filter_overrides = array( 'alpha-agent' => false ); + +agents_api_smoke_assert_equals( false, WP_Agent_Access::can_principal_access_agent( $principal, 'alpha-agent', WP_Agent_Access_Grant::ROLE_VIEWER, $context ), 'filter denies alpha: check returns false despite store grant', $failures, $passes ); +agents_api_smoke_assert_equals( false, WP_Agent_Access::can_principal_access_agent( $principal, 'alpha-agent', WP_Agent_Access_Grant::ROLE_OPERATOR, $context ), 'filter denies alpha: check returns false at operator too', $failures, $passes ); + +$deny_list = WP_Agent_Access::list_accessible_agents_for_principal( $principal, WP_Agent_Access_Grant::ROLE_VIEWER, $context ); +$deny_slugs = access_decision_filter_slugs( $deny_list ); +agents_api_smoke_assert_equals( false, in_array( 'alpha-agent', $deny_slugs, true ), 'filter denies alpha: list excludes alpha despite store grant', $failures, $passes ); + +access_decision_filter_assert_agreement( $principal, 'alpha-agent', $context, 'list/check agree: alpha denied by filter', $failures, $passes ); + +// ======================================================================= +// Phase 4: Bidirectional agreement — grant beta+gamma, deny alpha. +// ======================================================================= + +$filter_overrides = array( + 'alpha-agent' => false, + 'beta-agent' => true, + 'gamma-agent' => true, +); + +$bidir_list = WP_Agent_Access::list_accessible_agents_for_principal( $principal, WP_Agent_Access_Grant::ROLE_VIEWER, $context ); +$bidir_slugs = access_decision_filter_slugs( $bidir_list ); + +foreach ( array( 'alpha-agent', 'beta-agent', 'gamma-agent' ) as $agent_id ) { + access_decision_filter_assert_agreement( $principal, $agent_id, $context, "bidirectional agreement: list/check match for {$agent_id}", $failures, $passes ); +} + +agents_api_smoke_assert_equals( false, in_array( 'alpha-agent', $bidir_slugs, true ), 'bidirectional: alpha excluded', $failures, $passes ); +agents_api_smoke_assert_equals( true, in_array( 'beta-agent', $bidir_slugs, true ), 'bidirectional: beta included', $failures, $passes ); +agents_api_smoke_assert_equals( true, in_array( 'gamma-agent', $bidir_slugs, true ), 'bidirectional: gamma included', $failures, $passes ); + +// ======================================================================= +// Phase 5: Filter can tighten the effective-agent short-circuit. +// ======================================================================= + +$sc_principal = AgentsAPI\AI\WP_Agent_Execution_Principal::user_session( + 7, + 'beta-agent', + AgentsAPI\AI\WP_Agent_Execution_Principal::REQUEST_CONTEXT_REST, + array( 'source' => 'smoke-test' ), + 'site:42' +); + +// Without override, short-circuit grants access to beta (effective agent). +$filter_overrides = array(); +agents_api_smoke_assert_equals( true, WP_Agent_Access::can_principal_access_agent( $sc_principal, 'beta-agent', WP_Agent_Access_Grant::ROLE_VIEWER, $context ), 'short-circuit: effective agent id grants access by default', $failures, $passes ); + +$sc_default_list = WP_Agent_Access::list_accessible_agents_for_principal( $sc_principal, WP_Agent_Access_Grant::ROLE_VIEWER, $context ); +$sc_default_slugs = access_decision_filter_slugs( $sc_default_list ); +agents_api_smoke_assert_equals( true, in_array( 'beta-agent', $sc_default_slugs, true ), 'short-circuit: list includes effective agent by default', $failures, $passes ); + +// With override, host can deny the short-circuit. +$filter_overrides = array( 'beta-agent' => false ); +agents_api_smoke_assert_equals( false, WP_Agent_Access::can_principal_access_agent( $sc_principal, 'beta-agent', WP_Agent_Access_Grant::ROLE_VIEWER, $context ), 'short-circuit: filter can deny effective agent id', $failures, $passes ); + +$sc_deny_list = WP_Agent_Access::list_accessible_agents_for_principal( $sc_principal, WP_Agent_Access_Grant::ROLE_VIEWER, $context ); +$sc_deny_slugs = access_decision_filter_slugs( $sc_deny_list ); +agents_api_smoke_assert_equals( false, in_array( 'beta-agent', $sc_deny_slugs, true ), 'short-circuit: list excludes denied effective agent', $failures, $passes ); + +// ======================================================================= +// Phase 6: Ability functions agree through the full path. +// ======================================================================= + +$filter_overrides = array( + 'alpha-agent' => false, + 'beta-agent' => true, + 'gamma-agent' => true, +); + +$GLOBALS['__agents_api_smoke_current_user_id'] = 7; + +do_action( 'wp_abilities_api_categories_init' ); +do_action( 'wp_abilities_api_init' ); + +$ability_input = array( + 'minimum_role' => WP_Agent_Access_Grant::ROLE_VIEWER, + 'workspace_id' => 'site:42', +); + +$ability_list = AgentsAPI\AI\Auth\agents_list_accessible_agents( $ability_input ); +$ability_slugs = array_column( $ability_list['agents'] ?? array(), 'slug' ); + +foreach ( array( 'alpha-agent', 'beta-agent', 'gamma-agent' ) as $agent_id ) { + $can_result = AgentsAPI\AI\Auth\agents_can_access_agent( + array( + 'agent' => $agent_id, + 'minimum_role' => WP_Agent_Access_Grant::ROLE_VIEWER, + 'workspace_id' => 'site:42', + ) + ); + $can_allowed = (bool) ( $can_result['allowed'] ?? false ); + $listed = in_array( $agent_id, $ability_slugs, true ); + agents_api_smoke_assert_equals( $can_allowed, $listed, "ability list/check agree: {$agent_id}", $failures, $passes ); +} + +agents_api_smoke_assert_equals( false, in_array( 'alpha-agent', $ability_slugs, true ), 'ability: alpha excluded', $failures, $passes ); +agents_api_smoke_assert_equals( true, in_array( 'beta-agent', $ability_slugs, true ), 'ability: beta included', $failures, $passes ); +agents_api_smoke_assert_equals( true, in_array( 'gamma-agent', $ability_slugs, true ), 'ability: gamma included', $failures, $passes ); + +agents_api_smoke_finish( 'Access decision filter', $failures, $passes );