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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions agents-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
require_once AGENTS_API_PATH . 'src/Auth/class-wp-agent-capability-ceiling.php';
require_once AGENTS_API_PATH . 'src/Auth/class-wp-agent-access-grant.php';
require_once AGENTS_API_PATH . 'src/Auth/class-wp-agent-access-store.php';
require_once AGENTS_API_PATH . 'src/Auth/class-wp-agent-principal-access-store.php';
require_once AGENTS_API_PATH . 'src/Auth/class-wp-agent-access.php';
require_once AGENTS_API_PATH . 'src/Auth/class-wp-agent-token.php';
require_once AGENTS_API_PATH . 'src/Auth/class-wp-agent-token-store.php';
Expand Down
20 changes: 16 additions & 4 deletions src/Auth/class-wp-agent-access-grant.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

if ( ! class_exists( 'WP_Agent_Access_Grant' ) ) {
/**
* Role-based access grant between a WordPress user and an agent.
* Role-based access grant between a WordPress user/audience and an agent.
*/
final class WP_Agent_Access_Grant {

Expand All @@ -26,6 +26,7 @@ final class WP_Agent_Access_Grant {
* @param int|null $granted_by_user_id Optional WordPress user ID that created the grant.
* @param string|null $granted_at Optional UTC datetime string.
* @param array<string,mixed> $metadata Host-owned metadata.
* @param string|null $audience_id Optional non-user audience receiving access.
*/
public function __construct(
public readonly string $agent_id,
Expand All @@ -36,13 +37,22 @@ public function __construct(
public readonly ?int $granted_by_user_id = null,
public readonly ?string $granted_at = null,
public readonly array $metadata = array(),
public readonly ?string $audience_id = null,
) {
if ( '' === trim( $this->agent_id ) ) {
throw self::invalid( 'agent_id', 'must be a non-empty string' );
}

if ( $this->user_id <= 0 ) {
throw self::invalid( 'user_id', 'must be a positive integer' );
if ( $this->user_id < 0 ) {
throw self::invalid( 'user_id', 'must be zero or a positive integer' );
}

if ( 0 === $this->user_id && null === $this->audience_id ) {
throw self::invalid( 'user_id', 'must be positive unless audience_id is present' );
}

if ( null !== $this->audience_id && '' === trim( $this->audience_id ) ) {
throw self::invalid( 'audience_id', 'must be null or a non-empty string' );
}

if ( null !== $this->grant_id && $this->grant_id <= 0 ) {
Expand Down Expand Up @@ -94,7 +104,8 @@ public static function from_array( array $grant ): self {
isset( $grant['grant_id'] ) ? (int) $grant['grant_id'] : null,
isset( $grant['granted_by_user_id'] ) ? (int) $grant['granted_by_user_id'] : null,
array_key_exists( 'granted_at', $grant ) && null !== $grant['granted_at'] ? (string) $grant['granted_at'] : null,
isset( $grant['metadata'] ) && is_array( $grant['metadata'] ) ? $grant['metadata'] : array()
isset( $grant['metadata'] ) && is_array( $grant['metadata'] ) ? $grant['metadata'] : array(),
array_key_exists( 'audience_id', $grant ) && null !== $grant['audience_id'] ? (string) $grant['audience_id'] : null
);
}

Expand Down Expand Up @@ -124,6 +135,7 @@ public function to_array(): array {
'granted_by_user_id' => $this->granted_by_user_id,
'granted_at' => $this->granted_at,
'metadata' => $this->metadata,
'audience_id' => $this->audience_id,
);
}

Expand Down
20 changes: 17 additions & 3 deletions src/Auth/class-wp-agent-access.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
final class WP_Agent_Access {

private const CURRENT_USER_EFFECTIVE_AGENT_ID = '__wordpress_user__';
private const PUBLIC_AUDIENCE_ID = 'audience:public';

/**
* Resolve the host-provided access store.
Expand Down Expand Up @@ -46,7 +47,18 @@ public static function get_current_principal( array $context = array() ): ?Agent

$user_id = self::get_current_user_id();
if ( $user_id <= 0 ) {
return null;
if ( array_key_exists( 'allow_anonymous_audience', $context ) && false === (bool) $context['allow_anonymous_audience'] ) {
return null;
}

return AgentsAPI\AI\WP_Agent_Execution_Principal::audience(
self::PUBLIC_AUDIENCE_ID,
self::PUBLIC_AUDIENCE_ID,
isset( $context['request_context'] ) ? (string) $context['request_context'] : AgentsAPI\AI\WP_Agent_Execution_Principal::REQUEST_CONTEXT_REST,
isset( $context['request_metadata'] ) && is_array( $context['request_metadata'] ) ? $context['request_metadata'] : array(),
array_key_exists( 'workspace_id', $context ) && null !== $context['workspace_id'] ? (string) $context['workspace_id'] : null,
array_key_exists( 'client_id', $context ) && null !== $context['client_id'] ? (string) $context['client_id'] : null
);
}

return AgentsAPI\AI\WP_Agent_Execution_Principal::user_session(
Expand Down Expand Up @@ -121,11 +133,13 @@ public static function list_accessible_agents_for_principal( AgentsAPI\AI\WP_Age

$agent_ids = array();
$store = self::get_store( $context );
if ( $store instanceof WP_Agent_Access_Store ) {
if ( $store instanceof WP_Agent_Principal_Access_Store ) {
$agent_ids = $store->get_agent_ids_for_principal( $principal, $minimum_role, $principal->workspace_id );
} elseif ( $store instanceof WP_Agent_Access_Store && $principal->acting_user_id > 0 ) {
$agent_ids = $store->get_agent_ids_for_user( $principal->acting_user_id, $minimum_role, $principal->workspace_id );
}

if ( self::CURRENT_USER_EFFECTIVE_AGENT_ID !== $principal->effective_agent_id ) {
if ( null === $principal->audience_id && self::CURRENT_USER_EFFECTIVE_AGENT_ID !== $principal->effective_agent_id ) {
$agent_ids[] = $principal->effective_agent_id;
}

Expand Down
32 changes: 32 additions & 0 deletions src/Auth/class-wp-agent-principal-access-store.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php
/**
* WP_Agent_Principal_Access_Store optional contract.
*
* @package AgentsAPI
*/

defined( 'ABSPATH' ) || exit;

if ( ! interface_exists( 'WP_Agent_Principal_Access_Store' ) ) {
/**
* Optional store contract for principal-aware access grants.
*
* Stores can implement this alongside WP_Agent_Access_Store to grant access
* to non-user principals such as host-resolved audiences without changing the
* existing WordPress-user store contract.
*/
interface WP_Agent_Principal_Access_Store {

/**
* Fetch a principal's grant for an agent/workspace.
*/
public function get_access_for_principal( string $agent_id, AgentsAPI\AI\WP_Agent_Execution_Principal $principal, ?string $workspace_id = null ): ?WP_Agent_Access_Grant;

/**
* List agent IDs accessible to a principal at or above the optional role.
*
* @return string[]
*/
public function get_agent_ids_for_principal( AgentsAPI\AI\WP_Agent_Execution_Principal $principal, ?string $minimum_role = null, ?string $workspace_id = null ): array;
}
}
9 changes: 8 additions & 1 deletion src/Auth/class-wp-agent-wordpress-authorization-policy.php
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,14 @@ public function can_access_agent( AgentsAPI\AI\WP_Agent_Execution_Principal $pri
return false;
}

$grant = $access_store->get_access( $agent_id, $principal->acting_user_id, $principal->workspace_id );
if ( $access_store instanceof WP_Agent_Principal_Access_Store ) {
$grant = $access_store->get_access_for_principal( $agent_id, $principal, $principal->workspace_id );
} elseif ( $principal->acting_user_id > 0 ) {
$grant = $access_store->get_access( $agent_id, $principal->acting_user_id, $principal->workspace_id );
} else {
$grant = null;
}

return $grant instanceof WP_Agent_Access_Grant && $grant->role_meets( $minimum_role );
}

Expand Down
2 changes: 1 addition & 1 deletion src/Auth/register-agent-access-abilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ function agents_list_accessible_agents( array $input ): array {
* @param array<string,mixed> $input Ability input.
*/
function agents_access_permission( array $input ): bool {
$allowed = function_exists( 'is_user_logged_in' ) ? is_user_logged_in() : \WP_Agent_Access::get_current_principal( agents_access_request_scope( $input ) ) instanceof \AgentsAPI\AI\WP_Agent_Execution_Principal;
$allowed = \WP_Agent_Access::get_current_principal( agents_access_request_scope( $input ) ) instanceof \AgentsAPI\AI\WP_Agent_Execution_Principal;

return (bool) apply_filters( 'agents_access_permission', $allowed, $input );
}
Expand Down
46 changes: 44 additions & 2 deletions src/Runtime/class-wp-agent-execution-principal.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ final class WP_Agent_Execution_Principal {
public const AUTH_SOURCE_USER = 'user';
public const AUTH_SOURCE_APPLICATION_PASSWORD = 'application_password';
public const AUTH_SOURCE_AGENT_TOKEN = 'agent_token';
public const AUTH_SOURCE_AUDIENCE = 'audience';
public const AUTH_SOURCE_SYSTEM = 'system';

public const REQUEST_CONTEXT_REST = 'rest';
Expand All @@ -40,6 +41,8 @@ final class WP_Agent_Execution_Principal {
* @param string|null $client_id Optional client/login identifier.
* @param \WP_Agent_Capability_Ceiling|null $capability_ceiling Optional capability ceiling for this execution.
* @param \WP_Agent_Caller_Context|null $caller_context Optional cross-site caller context claims.
* @param string|null $audience_id Optional non-user audience/principal identifier.
* @param array<string,mixed> $audience_claims Optional host-owned audience claims.
*/
public function __construct(
public readonly int $acting_user_id,
Expand All @@ -52,6 +55,8 @@ public function __construct(
public readonly ?string $client_id = null,
public readonly ?\WP_Agent_Capability_Ceiling $capability_ceiling = null,
public readonly ?\WP_Agent_Caller_Context $caller_context = null,
public readonly ?string $audience_id = null,
public readonly array $audience_claims = array(),
) {
if ( $this->acting_user_id < 0 ) {
throw self::invalid( 'acting_user_id', 'must be zero or a positive integer' );
Expand All @@ -73,9 +78,17 @@ public function __construct(
throw self::invalid( 'token_id', 'must be null or a positive integer' );
}

if ( null !== $this->audience_id && '' === trim( $this->audience_id ) ) {
throw self::invalid( 'audience_id', 'must be null or a non-empty string' );
}

if ( false === self::jsonEncode( $this->request_metadata ) ) {
throw self::invalid( 'request_metadata', 'must be JSON serializable' );
}

if ( false === self::jsonEncode( $this->audience_claims ) ) {
throw self::invalid( 'audience_claims', 'must be JSON serializable' );
}
}

/**
Expand Down Expand Up @@ -133,6 +146,22 @@ public static function agent_token( int $acting_user_id, string $effective_agent
return new self( $acting_user_id, $effective_agent_id, self::AUTH_SOURCE_AGENT_TOKEN, $request_context, $token_id, $request_metadata, $workspace_id, $client_id, $capability_ceiling, $caller_context );
}

/**
* Build a principal for a non-user audience resolved by the host.
*
* @param string $audience_id Host-owned audience identifier.
* @param string $effective_agent_id Registered agent ID/slug effective for the run.
* @param string $request_context Request context.
* @param array $request_metadata Request metadata.
* @param string|null $workspace_id Optional host workspace/scope identifier.
* @param string|null $client_id Optional client/login identifier.
* @param array $audience_claims Host-owned audience claims.
* @return self
*/
public static function audience( string $audience_id, string $effective_agent_id, string $request_context = self::REQUEST_CONTEXT_REST, array $request_metadata = array(), ?string $workspace_id = null, ?string $client_id = null, array $audience_claims = array() ): self {
return new self( 0, $effective_agent_id, self::AUTH_SOURCE_AUDIENCE, $request_context, null, $request_metadata, $workspace_id, $client_id, null, null, $audience_id, $audience_claims );
}

/**
* Build a principal from a request/context array.
*
Expand Down Expand Up @@ -168,7 +197,9 @@ public static function from_array( array $principal ): self {
array_key_exists( 'workspace_id', $principal ) && null !== $principal['workspace_id'] ? (string) $principal['workspace_id'] : null,
array_key_exists( 'client_id', $principal ) && null !== $principal['client_id'] ? (string) $principal['client_id'] : null,
$capability_ceiling,
$caller_context
$caller_context,
array_key_exists( 'audience_id', $principal ) && null !== $principal['audience_id'] ? (string) $principal['audience_id'] : null,
isset( $principal['audience_claims'] ) && is_array( $principal['audience_claims'] ) ? $principal['audience_claims'] : array()
);
}

Expand All @@ -189,9 +220,18 @@ public function to_array(): array {
'client_id' => $this->client_id,
'capability_ceiling' => $this->capability_ceiling instanceof \WP_Agent_Capability_Ceiling ? $this->capability_ceiling->to_array() : null,
'caller_context' => $this->caller_context instanceof \WP_Agent_Caller_Context ? $this->caller_context->to_array() : null,
'audience_id' => $this->audience_id,
'audience_claims' => $this->audience_claims,
);
}

/**
* Whether this principal represents a host-resolved non-user audience.
*/
public function has_audience(): bool {
return null !== $this->audience_id;
}

/**
* Return a copy with additional request metadata.
*
Expand All @@ -209,7 +249,9 @@ public function with_request_metadata( array $request_metadata ): self {
$this->workspace_id,
$this->client_id,
$this->capability_ceiling,
$this->caller_context
$this->caller_context,
$this->audience_id,
$this->audience_claims
);
}

Expand Down
74 changes: 72 additions & 2 deletions tests/agents-access-ability-smoke.php
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,15 @@ static function (): void {

$grant = new WP_Agent_Access_Grant( 'editor-agent', 7, WP_Agent_Access_Grant::ROLE_OPERATOR, 'site:42' );

$access_store = new class( $grant ) implements WP_Agent_Access_Store {
$access_store = new class( $grant ) implements WP_Agent_Access_Store, WP_Agent_Principal_Access_Store {
private WP_Agent_Access_Grant $audience_grant;

/**
* @param WP_Agent_Access_Grant $grant Test grant.
*/
public function __construct( private WP_Agent_Access_Grant $grant ) {}
public function __construct( private WP_Agent_Access_Grant $grant ) {
$this->audience_grant = new WP_Agent_Access_Grant( 'admin-agent', 0, WP_Agent_Access_Grant::ROLE_OPERATOR, 'site:42', null, null, null, array(), 'audience:docs-readers' );
}

public function grant_access( WP_Agent_Access_Grant $grant ): WP_Agent_Access_Grant {
$this->grant = $grant;
Expand All @@ -104,6 +108,26 @@ public function get_agent_ids_for_user( int $user_id, ?string $minimum_role = nu
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();
}

public function get_access_for_principal( string $agent_id, AgentsAPI\AI\WP_Agent_Execution_Principal $principal, ?string $workspace_id = null ): ?WP_Agent_Access_Grant {
if ( null === $principal->audience_id ) {
return $this->get_access( $agent_id, $principal->acting_user_id, $workspace_id );
}

return $this->audience_grant->agent_id === $agent_id && $this->audience_grant->audience_id === $principal->audience_id && $this->audience_grant->workspace_id === $workspace_id ? $this->audience_grant : null;
}

public function get_agent_ids_for_principal( AgentsAPI\AI\WP_Agent_Execution_Principal $principal, ?string $minimum_role = null, ?string $workspace_id = null ): array {
if ( null === $principal->audience_id ) {
return $this->get_agent_ids_for_user( $principal->acting_user_id, $minimum_role, $workspace_id );
}

if ( $this->audience_grant->audience_id !== $principal->audience_id || $this->audience_grant->workspace_id !== $workspace_id ) {
return array();
}

return null === $minimum_role || $this->audience_grant->role_meets( $minimum_role ) ? array( $this->audience_grant->agent_id ) : array();
}
};

add_filter(
Expand Down Expand Up @@ -149,6 +173,52 @@ static function ( $store ) use ( $access_store ) {
$ability_list = AgentsAPI\AI\Auth\agents_list_accessible_agents( array( 'workspace_id' => 'site:42' ) );
agents_api_smoke_assert_equals( 'editor-agent', $ability_list['agents'][0]['slug'] ?? null, 'list-accessible ability returns granted registered agent', $failures, $passes );

$GLOBALS['__agents_api_smoke_current_user_id'] = 0;
add_filter(
'agents_api_execution_principal',
static function ( $principal, array $context ) {
if ( (int) $GLOBALS['__agents_api_smoke_current_user_id'] > 0 ) {
return $principal;
}

return AgentsAPI\AI\WP_Agent_Execution_Principal::audience(
'audience:docs-readers',
'audience-gateway',
$context['request_context'] ?? AgentsAPI\AI\WP_Agent_Execution_Principal::REQUEST_CONTEXT_REST,
array( 'source' => 'smoke-test' ),
$context['workspace_id'] ?? null
);
},
20,
2
);

$audience_principal = WP_Agent_Access::get_current_principal( array( 'workspace_id' => 'site:42' ) );
agents_api_smoke_assert_equals( 0, $audience_principal->acting_user_id, 'current principal can resolve anonymous audience', $failures, $passes );
agents_api_smoke_assert_equals( 'audience:docs-readers', $audience_principal->audience_id, 'anonymous audience principal carries audience id', $failures, $passes );
agents_api_smoke_assert_equals( true, AgentsAPI\AI\Auth\agents_access_permission( array( 'workspace_id' => 'site:42' ) ), 'access ability permission accepts resolved audience principal', $failures, $passes );

$audience_can_access = AgentsAPI\AI\Auth\agents_can_access_agent(
array(
'agent' => 'admin-agent',
'minimum_role' => WP_Agent_Access_Grant::ROLE_OPERATOR,
'workspace_id' => 'site:42',
)
);
agents_api_smoke_assert_equals( true, $audience_can_access['allowed'] ?? false, 'can-access ability returns allowed true for audience grant', $failures, $passes );

$audience_cannot_access = AgentsAPI\AI\Auth\agents_can_access_agent(
array(
'agent' => 'admin-agent',
'minimum_role' => WP_Agent_Access_Grant::ROLE_ADMIN,
'workspace_id' => 'site:42',
)
);
agents_api_smoke_assert_equals( false, $audience_cannot_access['allowed'] ?? true, 'can-access ability enforces audience grant role', $failures, $passes );

$audience_ability_list = AgentsAPI\AI\Auth\agents_list_accessible_agents( array( 'workspace_id' => 'site:42' ) );
agents_api_smoke_assert_equals( 'admin-agent', $audience_ability_list['agents'][0]['slug'] ?? null, 'list-accessible ability returns audience granted agent', $failures, $passes );

do_action( 'wp_abilities_api_categories_init' );
do_action( 'wp_abilities_api_init' );

Expand Down
Loading
Loading