diff --git a/README.md b/README.md index 919b2c0..a466c32 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Agents API sits between tool/action discovery and product-specific automation. I - Agent registration and lookup. - Runtime message, request, result, and completion value objects. - Agent execution principal/context value objects. +- Agent access grant, token, token authenticator, authorization policy, and capability ceiling contracts. - Multi-turn orchestration contracts. - Agent package and package-artifact contracts. - Shared `wp_guideline` / `wp_guideline_type` storage substrate polyfill when Core/Gutenberg do not provide it. @@ -94,6 +95,14 @@ wp_register_agent( - `WP_Agent` - `WP_Agents_Registry` - `WP_Agent_Package*` value objects and artifact registry helpers +- `WP_Agent_Access_Grant` +- `WP_Agent_Access_Store_Interface` +- `WP_Agent_Token` +- `WP_Agent_Token_Store_Interface` +- `WP_Agent_Token_Authenticator` +- `WP_Agent_Authorization_Policy_Interface` +- `WP_Agent_WordPress_Authorization_Policy` +- `WP_Agent_Capability_Ceiling` - `wp_guideline_types()` and `WP_Guidelines_Substrate` - `AgentsAPI\AI\AgentMessageEnvelope` - `AgentsAPI\AI\AgentExecutionPrincipal` @@ -119,7 +128,7 @@ wp_register_agent( ## Execution Principals -`AgentsAPI\AI\AgentExecutionPrincipal` represents the actor and agent context for one runtime request. It records the acting WordPress user ID, effective agent ID/slug, auth source, request context, optional token ID, and JSON-friendly request metadata. +`AgentsAPI\AI\AgentExecutionPrincipal` represents the actor and agent context for one runtime request. It records the acting WordPress user ID, effective agent ID/slug, auth source, request context, optional token ID, workspace ID, client ID, capability ceiling, and JSON-friendly request metadata. Host plugins can resolve the current principal from REST, CLI, cron, bearer-token, or session state through the `agents_api_execution_principal` filter: @@ -142,6 +151,32 @@ add_filter( ); ``` +## Agent Authorization + +Agents API provides generic authorization substrate shapes without owning product tables, workflows, or UI. + +```text +request bearer token + -> WP_Agent_Token_Authenticator + -> WP_Agent_Token_Store_Interface resolves hash only + -> AgentExecutionPrincipal records actor, agent, token, workspace, client + -> WP_Agent_Capability_Ceiling intersects token/client restrictions + -> WP_Agent_WordPress_Authorization_Policy calls user_can() for the owner/user ceiling +``` + +`WP_Agent_Access_Grant` models a role-based grant between a WordPress user and an agent, optionally scoped by a host workspace. Roles are generic and ordered: `viewer`, `operator`, `admin`. Concrete storage belongs to hosts via `WP_Agent_Access_Store_Interface`. + +`WP_Agent_Token` models token metadata for bearer-token authentication. It stores token hash, prefix, label, expiry, last-used timestamp, optional client/workspace identifiers, and optional capability restrictions. It never exposes raw token material in metadata exports. + +`WP_Agent_Token_Authenticator` accepts a raw bearer token at the request edge, hashes it, asks a host token store to resolve the hash, rejects expired tokens, touches successful tokens, and returns an `AgentExecutionPrincipal` populated with token/client/workspace context. + +`WP_Agent_WordPress_Authorization_Policy` is the default WordPress-shaped policy. It denies a capability unless both are true: + +- The token/client ceiling allows the requested capability, when a ceiling allow-list exists. +- The acting/owner WordPress user has the requested capability via `user_can()`. + +Hosts can replace this policy by implementing `WP_Agent_Authorization_Policy_Interface`, or pass host-owned access/token stores while keeping the generic value objects. + ## Conversation Compaction Agents can declare support for runtime conversation compaction without tying Agents API to a provider or model executor: diff --git a/agents-api.php b/agents-api.php index ec7a65d..6547f90 100644 --- a/agents-api.php +++ b/agents-api.php @@ -33,6 +33,14 @@ require_once AGENTS_API_PATH . 'src/Packages/class-wp-agent-package-adoption-diff.php'; require_once AGENTS_API_PATH . 'src/Packages/class-wp-agent-package-adoption-result.php'; require_once AGENTS_API_PATH . 'src/Packages/class-wp-agent-package-adopter-interface.php'; +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-interface.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-interface.php'; +require_once AGENTS_API_PATH . 'src/Auth/class-wp-agent-authorization-policy-interface.php'; +require_once AGENTS_API_PATH . 'src/Auth/class-wp-agent-token-authenticator.php'; +require_once AGENTS_API_PATH . 'src/Auth/class-wp-agent-wordpress-authorization-policy.php'; require_once AGENTS_API_PATH . 'src/Registry/class-wp-agents-registry.php'; require_once AGENTS_API_PATH . 'src/Registry/register-agents.php'; require_once AGENTS_API_PATH . 'src/Packages/register-agent-package-artifacts.php'; diff --git a/composer.json b/composer.json index 5eccd16..f0f485d 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ "php tests/message-envelope-smoke.php", "php tests/registry-smoke.php", "php tests/execution-principal-smoke.php", + "php tests/authorization-smoke.php", "php tests/action-policy-values-smoke.php", "php tests/tool-runtime-smoke.php", "php tests/pending-action-store-contract-smoke.php", diff --git a/src/Auth/class-wp-agent-access-grant.php b/src/Auth/class-wp-agent-access-grant.php new file mode 100644 index 0000000..b14e518 --- /dev/null +++ b/src/Auth/class-wp-agent-access-grant.php @@ -0,0 +1,152 @@ + $metadata Host-owned metadata. + */ + public function __construct( + public readonly string $agent_id, + public readonly int $user_id, + public readonly string $role = self::ROLE_VIEWER, + public readonly ?string $workspace_id = null, + public readonly ?int $grant_id = null, + public readonly ?int $granted_by_user_id = null, + public readonly ?string $granted_at = null, + public readonly array $metadata = array(), + ) { + 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 ( null !== $this->grant_id && $this->grant_id <= 0 ) { + throw self::invalid( 'grant_id', 'must be null or a positive integer' ); + } + + if ( null !== $this->granted_by_user_id && $this->granted_by_user_id <= 0 ) { + throw self::invalid( 'granted_by_user_id', 'must be null or a positive integer' ); + } + + if ( ! self::is_valid_role( $this->role ) ) { + throw self::invalid( 'role', 'must be admin, operator, or viewer' ); + } + + if ( false === self::json_encode( $this->metadata ) ) { + throw self::invalid( 'metadata', 'must be JSON serializable' ); + } + } + + /** + * Return all valid access roles from lowest to highest privilege. + * + * @return string[] + */ + public static function roles(): array { + return array( self::ROLE_VIEWER, self::ROLE_OPERATOR, self::ROLE_ADMIN ); + } + + /** + * Determine whether a role is valid. + * + * @param string $role Role value. + */ + public static function is_valid_role( string $role ): bool { + return in_array( $role, self::roles(), true ); + } + + /** + * Build a grant from a raw array. + * + * @param array $grant Raw grant fields. + */ + public static function from_array( array $grant ): self { + return new self( + isset( $grant['agent_id'] ) ? (string) $grant['agent_id'] : '', + isset( $grant['user_id'] ) ? (int) $grant['user_id'] : 0, + isset( $grant['role'] ) ? (string) $grant['role'] : self::ROLE_VIEWER, + array_key_exists( 'workspace_id', $grant ) && null !== $grant['workspace_id'] ? (string) $grant['workspace_id'] : null, + 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() + ); + } + + /** + * Whether this grant's role meets or exceeds the required role. + */ + public function role_meets( string $minimum_role ): bool { + $roles = self::roles(); + $actual_index = array_search( $this->role, $roles, true ); + $required_index = array_search( $minimum_role, $roles, true ); + + return false !== $actual_index && false !== $required_index && $actual_index >= $required_index; + } + + /** + * Export the grant to a stable JSON-friendly shape. + * + * @return array + */ + public function to_array(): array { + return array( + 'grant_id' => $this->grant_id, + 'agent_id' => $this->agent_id, + 'user_id' => $this->user_id, + 'role' => $this->role, + 'workspace_id' => $this->workspace_id, + 'granted_by_user_id' => $this->granted_by_user_id, + 'granted_at' => $this->granted_at, + 'metadata' => $this->metadata, + ); + } + + /** + * Encode JSON without throwing on older PHP configurations. + * + * @param mixed $value Value to encode. + * @return string|false + */ + private static function json_encode( $value ) { + try { + // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- Pure value object also runs outside WordPress in smoke tests. + return json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR ); + } catch ( JsonException $e ) { + return false; + } + } + + /** + * Build a machine-readable validation exception. + */ + private static function invalid( string $path, string $reason ): InvalidArgumentException { + return new InvalidArgumentException( 'invalid_wp_agent_access_grant: ' . $path . ' ' . $reason ); + } + } +} diff --git a/src/Auth/class-wp-agent-access-store-interface.php b/src/Auth/class-wp-agent-access-store-interface.php new file mode 100644 index 0000000..1bfc6f2 --- /dev/null +++ b/src/Auth/class-wp-agent-access-store-interface.php @@ -0,0 +1,45 @@ + $context Host-owned authorization context. + */ + public function can( AgentsAPI\AI\AgentExecutionPrincipal $principal, string $capability, array $context = array() ): bool; + + /** + * Check whether a principal can access an agent at a minimum role. + * + * @param AgentsAPI\AI\AgentExecutionPrincipal $principal Execution principal. + * @param string $agent_id Agent identifier. + * @param string $minimum_role Minimum access role. + * @param array $context Host-owned authorization context. + */ + public function can_access_agent( AgentsAPI\AI\AgentExecutionPrincipal $principal, string $agent_id, string $minimum_role = WP_Agent_Access_Grant::ROLE_VIEWER, array $context = array() ): bool; + } +} diff --git a/src/Auth/class-wp-agent-capability-ceiling.php b/src/Auth/class-wp-agent-capability-ceiling.php new file mode 100644 index 0000000..d1af2bb --- /dev/null +++ b/src/Auth/class-wp-agent-capability-ceiling.php @@ -0,0 +1,132 @@ + $metadata Host-owned metadata about how this ceiling was derived. + */ + public function __construct( + public readonly int $user_id, + public readonly ?array $allowed_capabilities = null, + public readonly array $metadata = array(), + ) { + if ( $this->user_id < 0 ) { + throw self::invalid( 'user_id', 'must be zero or a positive integer' ); + } + + if ( null !== $this->allowed_capabilities ) { + foreach ( $this->allowed_capabilities as $capability ) { + if ( '' === trim( $capability ) ) { + throw self::invalid( 'allowed_capabilities', 'must contain non-empty capability strings' ); + } + } + } + + if ( false === self::json_encode( $this->metadata ) ) { + throw self::invalid( 'metadata', 'must be JSON serializable' ); + } + } + + /** + * Build a ceiling from a raw array. + * + * @param array $ceiling Raw ceiling fields. + */ + public static function from_array( array $ceiling ): self { + return new self( + isset( $ceiling['user_id'] ) ? (int) $ceiling['user_id'] : 0, + array_key_exists( 'allowed_capabilities', $ceiling ) && null !== $ceiling['allowed_capabilities'] ? array_values( array_map( 'strval', (array) $ceiling['allowed_capabilities'] ) ) : null, + isset( $ceiling['metadata'] ) && is_array( $ceiling['metadata'] ) ? $ceiling['metadata'] : array() + ); + } + + /** + * Whether this ceiling has a token/client capability restriction. + */ + public function has_capability_restrictions(): bool { + return null !== $this->allowed_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. + * + * @param string $capability WordPress capability name. + */ + public function allows_capability( string $capability ): bool { + $capability = trim( $capability ); + if ( '' === $capability ) { + return false; + } + + if ( null === $this->allowed_capabilities ) { + return true; + } + + return in_array( $capability, $this->allowed_capabilities, true ); + } + + /** + * Return a copy with a narrower capability allow-list. + * + * @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 ); + } + + /** + * Export the ceiling to a stable JSON-friendly shape. + * + * @return array + */ + public function to_array(): array { + return array( + 'user_id' => $this->user_id, + 'allowed_capabilities' => $this->allowed_capabilities, + 'metadata' => $this->metadata, + ); + } + + /** + * Encode JSON without throwing on older PHP configurations. + * + * @param mixed $value Value to encode. + * @return string|false + */ + private static function json_encode( $value ) { + try { + // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- Pure value object also runs outside WordPress in smoke tests. + return json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR ); + } catch ( JsonException $e ) { + return false; + } + } + + /** + * Build a machine-readable validation exception. + */ + private static function invalid( string $path, string $reason ): InvalidArgumentException { + return new InvalidArgumentException( 'invalid_wp_agent_capability_ceiling: ' . $path . ' ' . $reason ); + } + } +} diff --git a/src/Auth/class-wp-agent-token-authenticator.php b/src/Auth/class-wp-agent-token-authenticator.php new file mode 100644 index 0000000..3ba6afe --- /dev/null +++ b/src/Auth/class-wp-agent-token-authenticator.php @@ -0,0 +1,72 @@ + $metadata Additional request metadata. + */ + public function authenticate_bearer_token( string $raw_token, string $request_context = AgentsAPI\AI\AgentExecutionPrincipal::REQUEST_CONTEXT_REST, array $metadata = array() ): ?AgentsAPI\AI\AgentExecutionPrincipal { + $raw_token = trim( $raw_token ); + if ( '' === $raw_token ) { + return null; + } + + if ( null !== $this->token_prefix && ! str_starts_with( $raw_token, $this->token_prefix ) ) { + return null; + } + + $token = $this->token_store->resolve_token_hash( WP_Agent_Token::hash_token( $raw_token ) ); + if ( null === $token || $token->is_expired() ) { + return null; + } + + $this->token_store->touch_token( $token->token_id ); + + $metadata = array_merge( + $metadata, + array( + 'token_prefix' => $token->token_prefix, + 'token_label' => $token->label, + 'client_id' => $token->client_id, + 'workspace_id' => $token->workspace_id, + 'has_capability_restrictions' => null !== $token->allowed_capabilities, + ) + ); + + return AgentsAPI\AI\AgentExecutionPrincipal::agent_token( + $token->owner_user_id, + $token->agent_id, + $token->token_id, + $request_context, + $metadata, + $token->workspace_id, + $token->client_id, + $token->capability_ceiling() + ); + } + } +} diff --git a/src/Auth/class-wp-agent-token-store-interface.php b/src/Auth/class-wp-agent-token-store-interface.php new file mode 100644 index 0000000..4d4373a --- /dev/null +++ b/src/Auth/class-wp-agent-token-store-interface.php @@ -0,0 +1,53 @@ + $metadata Host-owned metadata. + */ + public function __construct( + public readonly int $token_id, + public readonly string $agent_id, + public readonly int $owner_user_id, + public readonly string $token_hash, + public readonly string $token_prefix, + public readonly string $label = '', + public readonly ?array $allowed_capabilities = null, + public readonly ?string $expires_at = null, + public readonly ?string $last_used_at = null, + public readonly ?string $created_at = null, + public readonly ?string $client_id = null, + public readonly ?string $workspace_id = null, + public readonly array $metadata = array(), + ) { + if ( $this->token_id <= 0 ) { + throw self::invalid( 'token_id', 'must be a positive integer' ); + } + + if ( '' === trim( $this->agent_id ) ) { + throw self::invalid( 'agent_id', 'must be a non-empty string' ); + } + + if ( $this->owner_user_id <= 0 ) { + throw self::invalid( 'owner_user_id', 'must be a positive integer' ); + } + + if ( ! self::is_sha256_hash( $this->token_hash ) ) { + throw self::invalid( 'token_hash', 'must be a SHA-256 hex hash' ); + } + + if ( '' === trim( $this->token_prefix ) ) { + throw self::invalid( 'token_prefix', 'must be a non-empty string' ); + } + + if ( null !== $this->allowed_capabilities ) { + foreach ( $this->allowed_capabilities as $capability ) { + if ( '' === trim( $capability ) ) { + throw self::invalid( 'allowed_capabilities', 'must contain non-empty capability strings' ); + } + } + } + + if ( false === self::json_encode( $this->metadata ) ) { + throw self::invalid( 'metadata', 'must be JSON serializable' ); + } + } + + /** + * Hash raw token material for storage/lookup. + */ + public static function hash_token( string $raw_token ): string { + return hash( 'sha256', $raw_token ); + } + + /** + * Build token metadata from a raw array. + * + * @param array $token Raw token fields. + */ + public static function from_array( array $token ): self { + return new self( + isset( $token['token_id'] ) ? (int) $token['token_id'] : 0, + isset( $token['agent_id'] ) ? (string) $token['agent_id'] : '', + isset( $token['owner_user_id'] ) ? (int) $token['owner_user_id'] : 0, + isset( $token['token_hash'] ) ? (string) $token['token_hash'] : '', + isset( $token['token_prefix'] ) ? (string) $token['token_prefix'] : '', + isset( $token['label'] ) ? (string) $token['label'] : '', + array_key_exists( 'allowed_capabilities', $token ) && null !== $token['allowed_capabilities'] ? array_values( array_map( 'strval', (array) $token['allowed_capabilities'] ) ) : null, + array_key_exists( 'expires_at', $token ) && null !== $token['expires_at'] ? (string) $token['expires_at'] : null, + array_key_exists( 'last_used_at', $token ) && null !== $token['last_used_at'] ? (string) $token['last_used_at'] : null, + array_key_exists( 'created_at', $token ) && null !== $token['created_at'] ? (string) $token['created_at'] : null, + array_key_exists( 'client_id', $token ) && null !== $token['client_id'] ? (string) $token['client_id'] : null, + array_key_exists( 'workspace_id', $token ) && null !== $token['workspace_id'] ? (string) $token['workspace_id'] : null, + isset( $token['metadata'] ) && is_array( $token['metadata'] ) ? $token['metadata'] : array() + ); + } + + /** + * Whether the token is expired at the given Unix timestamp. + */ + public function is_expired( ?int $now = null ): bool { + if ( null === $this->expires_at || '' === $this->expires_at ) { + return false; + } + + $expires = strtotime( $this->expires_at ); + if ( false === $expires ) { + return true; + } + + return $expires <= ( $now ?? time() ); + } + + /** + * Return this token's execution ceiling. + */ + public function capability_ceiling(): WP_Agent_Capability_Ceiling { + return new WP_Agent_Capability_Ceiling( + $this->owner_user_id, + $this->allowed_capabilities, + array( + 'token_id' => $this->token_id, + 'client_id' => $this->client_id, + 'workspace_id' => $this->workspace_id, + ) + ); + } + + /** + * Export token metadata without token hash or raw token material. + * + * @return array + */ + public function to_metadata_array(): array { + return array( + 'token_id' => $this->token_id, + 'agent_id' => $this->agent_id, + 'owner_user_id' => $this->owner_user_id, + 'token_prefix' => $this->token_prefix, + 'label' => $this->label, + 'allowed_capabilities' => $this->allowed_capabilities, + 'expires_at' => $this->expires_at, + 'last_used_at' => $this->last_used_at, + 'created_at' => $this->created_at, + 'client_id' => $this->client_id, + 'workspace_id' => $this->workspace_id, + 'metadata' => $this->metadata, + ); + } + + /** + * Validate a SHA-256 hex hash. + */ + private static function is_sha256_hash( string $hash ): bool { + return 1 === preg_match( '/\A[a-f0-9]{64}\z/i', $hash ); + } + + /** + * Encode JSON without throwing on older PHP configurations. + * + * @param mixed $value Value to encode. + * @return string|false + */ + private static function json_encode( $value ) { + try { + // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- Pure value object also runs outside WordPress in smoke tests. + return json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR ); + } catch ( JsonException $e ) { + return false; + } + } + + /** + * Build a machine-readable validation exception. + */ + private static function invalid( string $path, string $reason ): InvalidArgumentException { + return new InvalidArgumentException( 'invalid_wp_agent_token: ' . $path . ' ' . $reason ); + } + } +} diff --git a/src/Auth/class-wp-agent-wordpress-authorization-policy.php b/src/Auth/class-wp-agent-wordpress-authorization-policy.php new file mode 100644 index 0000000..903f47d --- /dev/null +++ b/src/Auth/class-wp-agent-wordpress-authorization-policy.php @@ -0,0 +1,92 @@ + $context Host-owned authorization context. + */ + public function can( AgentsAPI\AI\AgentExecutionPrincipal $principal, string $capability, array $context = array() ): bool { + $capability = trim( $capability ); + if ( '' === $capability ) { + return false; + } + + $ceiling = $context['capability_ceiling'] ?? $principal->capability_ceiling; + if ( is_array( $ceiling ) ) { + $ceiling = WP_Agent_Capability_Ceiling::from_array( $ceiling ); + } + + if ( $ceiling instanceof WP_Agent_Capability_Ceiling && ! $ceiling->allows_capability( $capability ) ) { + return false; + } + + $user_id = $ceiling instanceof WP_Agent_Capability_Ceiling ? $ceiling->user_id : $principal->acting_user_id; + if ( $user_id <= 0 ) { + return false; + } + + return $this->user_can( $user_id, $capability ); + } + + /** + * Check whether a principal can access an agent at a minimum role. + * + * @param AgentsAPI\AI\AgentExecutionPrincipal $principal Execution principal. + * @param string $agent_id Agent identifier. + * @param string $minimum_role Minimum access role. + * @param array $context Host-owned authorization context. + */ + public function can_access_agent( AgentsAPI\AI\AgentExecutionPrincipal $principal, string $agent_id, string $minimum_role = WP_Agent_Access_Grant::ROLE_VIEWER, array $context = array() ): bool { + if ( '' === trim( $agent_id ) || ! WP_Agent_Access_Grant::is_valid_role( $minimum_role ) ) { + return false; + } + + if ( $principal->effective_agent_id === $agent_id ) { + return true; + } + + $access_store = $context['access_store'] ?? $this->access_store; + if ( ! $access_store instanceof WP_Agent_Access_Store_Interface ) { + return false; + } + + $grant = $access_store->get_access( $agent_id, $principal->acting_user_id, $principal->workspace_id ); + return $grant instanceof WP_Agent_Access_Grant && $grant->role_meets( $minimum_role ); + } + + /** + * Run the configured WordPress capability check. + */ + private function user_can( int $user_id, string $capability ): bool { + if ( null !== $this->user_can_callback ) { + return (bool) call_user_func( $this->user_can_callback, $user_id, $capability ); + } + + return function_exists( 'user_can' ) && user_can( $user_id, $capability ); + } + } +} diff --git a/src/Runtime/AgentExecutionPrincipal.php b/src/Runtime/AgentExecutionPrincipal.php index d4cb6f3..588d07b 100644 --- a/src/Runtime/AgentExecutionPrincipal.php +++ b/src/Runtime/AgentExecutionPrincipal.php @@ -12,9 +12,9 @@ /** * Immutable identity context for one agent execution. * - * This class records who is acting, which agent is effective for the run, and - * how the request was authenticated. It intentionally does not decide access, - * grant scoped resources, or persist tokens. + * This class records who is acting, which agent is effective for the run, which + * workspace/client scope applies, and how the request was authenticated. It + * intentionally does not decide access, grant scoped resources, or persist tokens. */ // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Validation exceptions are not rendered output. final class AgentExecutionPrincipal { @@ -36,6 +36,9 @@ final class AgentExecutionPrincipal { * @param string $request_context Request context such as rest, cli, cron, or chat. * @param int|null $token_id Optional caller-owned token identifier. Agents API does not load or store the token. * @param array $request_metadata JSON-serializable request metadata supplied by the caller. + * @param string|null $workspace_id Optional host workspace/scope identifier. + * @param string|null $client_id Optional client/login identifier. + * @param \WP_Agent_Capability_Ceiling|null $capability_ceiling Optional capability ceiling for this execution. */ public function __construct( public readonly int $acting_user_id, @@ -44,6 +47,9 @@ public function __construct( public readonly string $request_context, public readonly ?int $token_id = null, public readonly array $request_metadata = array(), + public readonly ?string $workspace_id = null, + public readonly ?string $client_id = null, + public readonly ?\WP_Agent_Capability_Ceiling $capability_ceiling = null, ) { if ( $this->acting_user_id < 0 ) { throw self::invalid( 'acting_user_id', 'must be zero or a positive integer' ); @@ -107,8 +113,8 @@ public static function resolve( array $request_context = array() ): ?self { * @param array $request_metadata Request metadata. * @return self */ - public static function user_session( int $acting_user_id, string $effective_agent_id, string $request_context = self::REQUEST_CONTEXT_REST, array $request_metadata = array() ): self { - return new self( $acting_user_id, $effective_agent_id, self::AUTH_SOURCE_USER, $request_context, null, $request_metadata ); + public static function user_session( int $acting_user_id, string $effective_agent_id, string $request_context = self::REQUEST_CONTEXT_REST, array $request_metadata = array(), ?string $workspace_id = null, ?string $client_id = null, ?\WP_Agent_Capability_Ceiling $capability_ceiling = null ): self { + return new self( $acting_user_id, $effective_agent_id, self::AUTH_SOURCE_USER, $request_context, null, $request_metadata, $workspace_id, $client_id, $capability_ceiling ); } /** @@ -121,8 +127,8 @@ public static function user_session( int $acting_user_id, string $effective_agen * @param array $request_metadata Request metadata. * @return self */ - public static function agent_token( int $acting_user_id, string $effective_agent_id, int $token_id, string $request_context = self::REQUEST_CONTEXT_REST, array $request_metadata = array() ): self { - return new self( $acting_user_id, $effective_agent_id, self::AUTH_SOURCE_AGENT_TOKEN, $request_context, $token_id, $request_metadata ); + public static function agent_token( int $acting_user_id, string $effective_agent_id, int $token_id, string $request_context = self::REQUEST_CONTEXT_REST, array $request_metadata = array(), ?string $workspace_id = null, ?string $client_id = null, ?\WP_Agent_Capability_Ceiling $capability_ceiling = null ): self { + 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 ); } /** @@ -132,13 +138,25 @@ public static function agent_token( int $acting_user_id, string $effective_agent * @return self */ public static function from_array( array $principal ): self { + $capability_ceiling = null; + if ( isset( $principal['capability_ceiling'] ) ) { + if ( $principal['capability_ceiling'] instanceof \WP_Agent_Capability_Ceiling ) { + $capability_ceiling = $principal['capability_ceiling']; + } elseif ( is_array( $principal['capability_ceiling'] ) && class_exists( '\WP_Agent_Capability_Ceiling' ) ) { + $capability_ceiling = \WP_Agent_Capability_Ceiling::from_array( $principal['capability_ceiling'] ); + } + } + return new self( isset( $principal['acting_user_id'] ) ? (int) $principal['acting_user_id'] : 0, isset( $principal['effective_agent_id'] ) ? (string) $principal['effective_agent_id'] : '', isset( $principal['auth_source'] ) ? (string) $principal['auth_source'] : '', isset( $principal['request_context'] ) ? (string) $principal['request_context'] : '', isset( $principal['token_id'] ) ? (int) $principal['token_id'] : null, - isset( $principal['request_metadata'] ) && is_array( $principal['request_metadata'] ) ? $principal['request_metadata'] : array() + isset( $principal['request_metadata'] ) && is_array( $principal['request_metadata'] ) ? $principal['request_metadata'] : array(), + 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 ); } @@ -155,6 +173,9 @@ public function to_array(): array { 'request_context' => $this->request_context, 'token_id' => $this->token_id, 'request_metadata' => $this->request_metadata, + 'workspace_id' => $this->workspace_id, + 'client_id' => $this->client_id, + 'capability_ceiling' => $this->capability_ceiling instanceof \WP_Agent_Capability_Ceiling ? $this->capability_ceiling->to_array() : null, ); } @@ -171,7 +192,10 @@ public function with_request_metadata( array $request_metadata ): self { $this->auth_source, $this->request_context, $this->token_id, - $request_metadata + $request_metadata, + $this->workspace_id, + $this->client_id, + $this->capability_ceiling ); } diff --git a/tests/authorization-smoke.php b/tests/authorization-smoke.php new file mode 100644 index 0000000..2b4172f --- /dev/null +++ b/tests/authorization-smoke.php @@ -0,0 +1,157 @@ +role_meets( WP_Agent_Access_Grant::ROLE_VIEWER ), 'operator grant meets viewer role', $failures, $passes ); +agents_api_smoke_assert_equals( true, $grant->role_meets( WP_Agent_Access_Grant::ROLE_OPERATOR ), 'operator grant meets operator role', $failures, $passes ); +agents_api_smoke_assert_equals( false, $grant->role_meets( WP_Agent_Access_Grant::ROLE_ADMIN ), 'operator grant does not meet admin role', $failures, $passes ); +agents_api_smoke_assert_equals( 'site:42', WP_Agent_Access_Grant::from_array( $grant->to_array() )->workspace_id, 'access grant round-trips workspace scope', $failures, $passes ); + +$raw_token = 'wp_agent_editor-agent_test-secret'; +$token_hash = WP_Agent_Token::hash_token( $raw_token ); +$token = new WP_Agent_Token( + 33, + 'editor-agent', + 7, + $token_hash, + 'wp_agent_ed', + 'CI login', + array( 'edit_posts', 'read' ), + '2099-01-01 00:00:00', + null, + '2026-05-04 00:00:00', + 'ci', + 'site:42' +); + +$metadata = $token->to_metadata_array(); +agents_api_smoke_assert_equals( false, array_key_exists( 'token_hash', $metadata ), 'token metadata excludes token hash', $failures, $passes ); +agents_api_smoke_assert_equals( false, in_array( $raw_token, $metadata, true ), 'token metadata excludes raw token', $failures, $passes ); +agents_api_smoke_assert_equals( false, $token->is_expired( strtotime( '2026-05-04 00:00:00' ) ), 'future token is not expired', $failures, $passes ); + +$expired = new WP_Agent_Token( 34, 'editor-agent', 7, WP_Agent_Token::hash_token( 'expired' ), 'wp_agent_ex', 'Expired', null, '2020-01-01 00:00:00' ); +agents_api_smoke_assert_equals( true, $expired->is_expired( strtotime( '2026-05-04 00:00:00' ) ), 'expired token is expired', $failures, $passes ); + +$token_store = new class( $token ) implements WP_Agent_Token_Store_Interface { + public int $touches = 0; + + public function __construct( private WP_Agent_Token $token ) {} + + public function create_token( WP_Agent_Token $token ): WP_Agent_Token { + $this->token = $token; + return $token; + } + + public function resolve_token_hash( string $token_hash ): ?WP_Agent_Token { + return hash_equals( $this->token->token_hash, $token_hash ) ? $this->token : null; + } + + public function touch_token( int $token_id, ?string $used_at = null ): void { + unset( $used_at ); + if ( $token_id === $this->token->token_id ) { + ++$this->touches; + } + } + + public function revoke_token( int $token_id, string $agent_id ): bool { + return $token_id === $this->token->token_id && $agent_id === $this->token->agent_id; + } + + public function revoke_all_tokens_for_agent( string $agent_id ): int { + return $agent_id === $this->token->agent_id ? 1 : 0; + } + + public function get_token( int $token_id ): ?WP_Agent_Token { + return $token_id === $this->token->token_id ? $this->token : null; + } + + public function list_tokens( string $agent_id ): array { + return $agent_id === $this->token->agent_id ? array( $this->token ) : array(); + } +}; + +$authenticator = new WP_Agent_Token_Authenticator( $token_store, 'wp_agent_' ); +$principal = $authenticator->authenticate_bearer_token( $raw_token ); + +agents_api_smoke_assert_equals( 7, $principal->acting_user_id, 'authenticator returns token owner as acting user', $failures, $passes ); +agents_api_smoke_assert_equals( 'editor-agent', $principal->effective_agent_id, 'authenticator returns token agent', $failures, $passes ); +agents_api_smoke_assert_equals( 33, $principal->token_id, 'authenticator records token id', $failures, $passes ); +agents_api_smoke_assert_equals( 'site:42', $principal->workspace_id, 'authenticator records workspace id', $failures, $passes ); +agents_api_smoke_assert_equals( 'ci', $principal->client_id, 'authenticator records client id', $failures, $passes ); +agents_api_smoke_assert_equals( 1, $token_store->touches, 'authenticator touches successful token', $failures, $passes ); +agents_api_smoke_assert_equals( null, $authenticator->authenticate_bearer_token( 'other_prefix_secret' ), 'authenticator ignores non-owned token prefix', $failures, $passes ); + +$policy = new WP_Agent_WordPress_Authorization_Policy( + null, + static function ( int $user_id, string $capability ): bool { + return 7 === $user_id && in_array( $capability, array( 'edit_posts', 'delete_posts' ), true ); + } +); + +agents_api_smoke_assert_equals( true, $policy->can( $principal, 'edit_posts' ), 'policy allows capability present in token ceiling and WordPress capabilities', $failures, $passes ); +agents_api_smoke_assert_equals( false, $policy->can( $principal, 'delete_posts' ), 'policy denies WordPress capability outside token ceiling', $failures, $passes ); +agents_api_smoke_assert_equals( false, $policy->can( $principal, 'read' ), 'policy denies token capability absent from WordPress user capabilities', $failures, $passes ); + +$access_store = new class( $grant ) implements WP_Agent_Access_Store_Interface { + 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(); + } +}; + +$access_policy = new WP_Agent_WordPress_Authorization_Policy( $access_store ); +$other_agent = AgentsAPI\AI\AgentExecutionPrincipal::user_session( 7, 'other-agent', AgentsAPI\AI\AgentExecutionPrincipal::REQUEST_CONTEXT_REST, array(), 'site:42' ); + +agents_api_smoke_assert_equals( true, $access_policy->can_access_agent( $other_agent, 'editor-agent', WP_Agent_Access_Grant::ROLE_VIEWER ), 'policy accepts access grant at viewer level', $failures, $passes ); +agents_api_smoke_assert_equals( true, $access_policy->can_access_agent( $other_agent, 'editor-agent', WP_Agent_Access_Grant::ROLE_OPERATOR ), 'policy accepts access grant at operator level', $failures, $passes ); +agents_api_smoke_assert_equals( false, $access_policy->can_access_agent( $other_agent, 'editor-agent', WP_Agent_Access_Grant::ROLE_ADMIN ), 'policy rejects access grant below admin level', $failures, $passes ); + +agents_api_smoke_finish( 'Agents API authorization', $failures, $passes ); diff --git a/tests/bootstrap-smoke.php b/tests/bootstrap-smoke.php index 7f10fab..ed51ea6 100644 --- a/tests/bootstrap-smoke.php +++ b/tests/bootstrap-smoke.php @@ -63,6 +63,14 @@ agents_api_smoke_assert_equals( true, class_exists( 'WP_Agent_Package_Artifact' ), 'WP_Agent_Package_Artifact value object is available', $failures, $passes ); agents_api_smoke_assert_equals( true, class_exists( 'WP_Agent_Package_Artifact_Type' ), 'WP_Agent_Package_Artifact_Type value object is available', $failures, $passes ); agents_api_smoke_assert_equals( true, class_exists( 'WP_Agent_Package_Artifacts_Registry' ), 'WP_Agent_Package_Artifacts_Registry facade is available', $failures, $passes ); +agents_api_smoke_assert_equals( true, class_exists( 'WP_Agent_Access_Grant' ), 'WP_Agent_Access_Grant value object is available', $failures, $passes ); +agents_api_smoke_assert_equals( true, interface_exists( 'WP_Agent_Access_Store_Interface' ), 'WP_Agent_Access_Store_Interface contract is available', $failures, $passes ); +agents_api_smoke_assert_equals( true, class_exists( 'WP_Agent_Token' ), 'WP_Agent_Token value object is available', $failures, $passes ); +agents_api_smoke_assert_equals( true, interface_exists( 'WP_Agent_Token_Store_Interface' ), 'WP_Agent_Token_Store_Interface contract is available', $failures, $passes ); +agents_api_smoke_assert_equals( true, class_exists( 'WP_Agent_Token_Authenticator' ), 'WP_Agent_Token_Authenticator service is available', $failures, $passes ); +agents_api_smoke_assert_equals( true, interface_exists( 'WP_Agent_Authorization_Policy_Interface' ), 'WP_Agent_Authorization_Policy_Interface 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, defined( 'AGENTS_API_PLUGIN_FILE' ), 'plugin file constant is available', $failures, $passes ); agents_api_smoke_assert_equals( true, class_exists( 'AgentsAPI\\AI\\AgentMarkdownSectionCompactionAdapter' ), 'AgentsAPI\\AI\\AgentMarkdownSectionCompactionAdapter contract is available', $failures, $passes ); agents_api_smoke_assert_equals( true, class_exists( 'AgentsAPI\\AI\\AgentConversationLoop' ), 'AgentConversationLoop facade is available', $failures, $passes ); @@ -110,6 +118,7 @@ echo "\n[3] Module source tree uses Agents API vocabulary:\n"; $expected_source_directories = array( 'Approvals', + 'Auth', 'Guidelines', 'Identity', 'Memory', diff --git a/tests/execution-principal-smoke.php b/tests/execution-principal-smoke.php index 5506e37..44612fa 100644 --- a/tests/execution-principal-smoke.php +++ b/tests/execution-principal-smoke.php @@ -27,7 +27,10 @@ array( 'request_id' => 'req-abc', 'transport' => AgentsAPI\AI\AgentExecutionPrincipal::REQUEST_CONTEXT_REST, - ) + ), + 'site:42', + 'kimaki', + new WP_Agent_Capability_Ceiling( 123, array( 'edit_posts' ) ) ); agents_api_smoke_assert_equals( 123, $principal->acting_user_id, 'principal records acting user id', $failures, $passes ); @@ -36,6 +39,9 @@ agents_api_smoke_assert_equals( AgentsAPI\AI\AgentExecutionPrincipal::REQUEST_CONTEXT_REST, $principal->request_context, 'principal records request context', $failures, $passes ); agents_api_smoke_assert_equals( 456, $principal->token_id, 'principal records optional token id', $failures, $passes ); agents_api_smoke_assert_equals( 'req-abc', $principal->request_metadata['request_id'], 'principal records request metadata', $failures, $passes ); +agents_api_smoke_assert_equals( 'site:42', $principal->workspace_id, 'principal records workspace id', $failures, $passes ); +agents_api_smoke_assert_equals( 'kimaki', $principal->client_id, 'principal records client id', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'edit_posts' ), $principal->capability_ceiling->allowed_capabilities, 'principal records capability ceiling', $failures, $passes ); $principal_array = $principal->to_array(); agents_api_smoke_assert_equals( 123, $principal_array['acting_user_id'], 'principal exports acting user id', $failures, $passes ); @@ -43,6 +49,9 @@ agents_api_smoke_assert_equals( AgentsAPI\AI\AgentExecutionPrincipal::AUTH_SOURCE_AGENT_TOKEN, $principal_array['auth_source'], 'principal exports auth source', $failures, $passes ); agents_api_smoke_assert_equals( AgentsAPI\AI\AgentExecutionPrincipal::REQUEST_CONTEXT_REST, $principal_array['request_context'], 'principal exports request context', $failures, $passes ); agents_api_smoke_assert_equals( 456, $principal_array['token_id'], 'principal exports token id without token contents', $failures, $passes ); +agents_api_smoke_assert_equals( 'site:42', $principal_array['workspace_id'], 'principal exports workspace id', $failures, $passes ); +agents_api_smoke_assert_equals( 'kimaki', $principal_array['client_id'], 'principal exports client id', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'edit_posts' ), $principal_array['capability_ceiling']['allowed_capabilities'], 'principal exports capability ceiling', $failures, $passes ); $from_array = AgentsAPI\AI\AgentExecutionPrincipal::from_array( array( @@ -51,6 +60,12 @@ 'auth_source' => AgentsAPI\AI\AgentExecutionPrincipal::AUTH_SOURCE_USER, 'request_context' => AgentsAPI\AI\AgentExecutionPrincipal::REQUEST_CONTEXT_CHAT, 'request_metadata' => array( 'ip_hash' => 'abc123' ), + 'workspace_id' => 'site:99', + 'client_id' => 'browser', + 'capability_ceiling' => array( + 'user_id' => 7, + 'allowed_capabilities' => array( 'read' ), + ), ) ); agents_api_smoke_assert_equals( 7, $from_array->acting_user_id, 'from_array normalizes acting user id', $failures, $passes ); @@ -58,6 +73,9 @@ agents_api_smoke_assert_equals( null, $from_array->token_id, 'from_array allows absent token id', $failures, $passes ); agents_api_smoke_assert_equals( AgentsAPI\AI\AgentExecutionPrincipal::REQUEST_CONTEXT_CHAT, $from_array->request_context, 'from_array normalizes request context', $failures, $passes ); agents_api_smoke_assert_equals( array( 'ip_hash' => 'abc123' ), $from_array->request_metadata, 'from_array keeps metadata array', $failures, $passes ); +agents_api_smoke_assert_equals( 'site:99', $from_array->workspace_id, 'from_array normalizes workspace id', $failures, $passes ); +agents_api_smoke_assert_equals( 'browser', $from_array->client_id, 'from_array normalizes client id', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'read' ), $from_array->capability_ceiling->allowed_capabilities, 'from_array normalizes capability ceiling', $failures, $passes ); $user_session = AgentsAPI\AI\AgentExecutionPrincipal::user_session( 99,