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
37 changes: 36 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`
Expand All @@ -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:

Expand All @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions agents-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
152 changes: 152 additions & 0 deletions src/Auth/class-wp-agent-access-grant.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
<?php
/**
* WP_Agent_Access_Grant value object.
*
* @package AgentsAPI
*/

defined( 'ABSPATH' ) || exit;

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

public const ROLE_ADMIN = 'admin';
public const ROLE_OPERATOR = 'operator';
public const ROLE_VIEWER = 'viewer';

/**
* @param string $agent_id Registered/effective agent identifier.
* @param int $user_id WordPress user ID receiving access.
* @param string $role Access role.
* @param string|null $workspace_id Optional host workspace/scope identifier.
* @param int|null $grant_id Optional store-owned grant ID.
* @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.
*/
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<string,mixed> $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<string,mixed>
*/
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 );
}
}
}
45 changes: 45 additions & 0 deletions src/Auth/class-wp-agent-access-store-interface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php
/**
* WP_Agent_Access_Store_Interface contract.
*
* @package AgentsAPI
*/

defined( 'ABSPATH' ) || exit;

if ( ! interface_exists( 'WP_Agent_Access_Store_Interface' ) ) {
/**
* Store contract for agent access grants.
*/
interface WP_Agent_Access_Store_Interface {

/**
* Create or update a grant.
*/
public function grant_access( WP_Agent_Access_Grant $grant ): WP_Agent_Access_Grant;

/**
* Revoke a user's grant for an agent/workspace.
*/
public function revoke_access( string $agent_id, int $user_id, ?string $workspace_id = null ): bool;

/**
* Fetch a user's grant for an agent/workspace.
*/
public function get_access( string $agent_id, int $user_id, ?string $workspace_id = null ): ?WP_Agent_Access_Grant;

/**
* List agent IDs accessible to a user at or above the optional role.
*
* @return string[]
*/
public function get_agent_ids_for_user( int $user_id, ?string $minimum_role = null, ?string $workspace_id = null ): array;

/**
* List grants for an agent/workspace.
*
* @return WP_Agent_Access_Grant[]
*/
public function get_users_for_agent( string $agent_id, ?string $workspace_id = null ): array;
}
}
35 changes: 35 additions & 0 deletions src/Auth/class-wp-agent-authorization-policy-interface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php
/**
* WP_Agent_Authorization_Policy_Interface contract.
*
* @package AgentsAPI
*/

defined( 'ABSPATH' ) || exit;

if ( ! interface_exists( 'WP_Agent_Authorization_Policy_Interface' ) ) {
/**
* Contract for host-extensible agent authorization checks.
*/
interface WP_Agent_Authorization_Policy_Interface {

/**
* Check whether a principal can use a WordPress capability.
*
* @param AgentsAPI\AI\AgentExecutionPrincipal $principal Execution principal.
* @param string $capability Required WordPress capability.
* @param array<string,mixed> $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<string,mixed> $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;
}
}
Loading
Loading