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 @@ -118,6 +118,7 @@
require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-citation-metadata.php';
require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-message.php';
require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-execution-principal.php';
require_once AGENTS_API_PATH . 'src/Auth/class-wp-agent-autonomous-capability-policy.php';
require_once AGENTS_API_PATH . 'src/Transcripts/register-agents-conversation-session-abilities.php';
require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-effective-agent-resolver.php';
require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-compaction-item.php';
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"php tests/effective-agent-resolver-smoke.php",
"php tests/caller-context-smoke.php",
"php tests/authorization-smoke.php",
"php tests/autonomous-capability-ceiling-smoke.php",
"php tests/agents-access-ability-smoke.php",
"php tests/agents-conversation-session-abilities-smoke.php",
"php tests/conversation-session-store-contract-smoke.php",
Expand Down
215 changes: 215 additions & 0 deletions src/Auth/class-wp-agent-autonomous-capability-policy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
<?php
/**
* WP_Agent_Autonomous_Capability_Policy derivation helper.
*
* @package AgentsAPI
*/

defined( 'ABSPATH' ) || exit;

if ( ! class_exists( 'WP_Agent_Autonomous_Capability_Policy' ) ) {
/**
* Derives a safe default capability ceiling for autonomous executions.
*
* An autonomous execution is one where no human is directly authorizing
* actions in real time. Agents API detects autonomy from the execution
* principal's own fields (authentication source) rather than from any
* consumer orchestration vocabulary, and applies a substrate-defined,
* filterable default ceiling that excludes content-mutating WordPress
* capabilities unless the host made an explicit grant.
*/
final class WP_Agent_Autonomous_Capability_Policy {

/**
* Default content-mutating capabilities excluded from autonomous
* executions when no explicit host grant is present.
*
* The set is intentionally conservative: it covers post and page
* authoring at every lifecycle stage, taxonomy and options
* administration, media uploads, unfiltered markup, and plugin, theme,
* and user management. Hosts and Core can adjust it through the
* `agents_api_autonomous_denied_capabilities` filter.
*
* @var string[]
*/
public const DEFAULT_DENIED_CAPABILITIES = array(
'edit_posts',
'edit_others_posts',
'edit_published_posts',
'publish_posts',
'delete_posts',
'delete_others_posts',
'delete_published_posts',
'edit_pages',
'edit_others_pages',
'edit_published_pages',
'publish_pages',
'delete_pages',
'delete_others_pages',
'delete_published_pages',
'manage_categories',
'manage_options',
'upload_files',
'unfiltered_html',
'edit_themes',
'update_themes',
'edit_plugins',
'update_plugins',
'install_plugins',
'activate_plugins',
'create_users',
'delete_users',
'promote_users',
);

/**
* Default authentication sources treated as autonomous.
*
* @var string[]
*/
public const DEFAULT_AUTONOMOUS_AUTH_SOURCES = array(
AgentsAPI\AI\WP_Agent_Execution_Principal::AUTH_SOURCE_SYSTEM,
AgentsAPI\AI\WP_Agent_Execution_Principal::AUTH_SOURCE_RUNTIME,
AgentsAPI\AI\WP_Agent_Execution_Principal::AUTH_SOURCE_AGENT_TOKEN,
);

/**
* Authentication sources that Agents API treats as autonomous by default.
*
* Hosts and Core can adjust this set through the
* `agents_api_autonomous_auth_sources` filter.
*
* @return string[]
*/
public static function autonomous_auth_sources(): array {
$sources = self::DEFAULT_AUTONOMOUS_AUTH_SOURCES;

if ( function_exists( 'apply_filters' ) ) {
$sources = apply_filters( 'agents_api_autonomous_auth_sources', $sources );
}

return self::string_list( $sources );
}

/**
* Whether an execution principal represents an autonomous execution.
*
* Autonomous means the run is driven by automation rather than a live
* human authorizing each action in real time. The default signal is the
* principal's authentication source; hosts can override the per-principal
* decision through the `agents_api_principal_is_autonomous` filter.
*
* @param AgentsAPI\AI\WP_Agent_Execution_Principal $principal Execution principal.
* @return bool
*/
public static function is_autonomous( AgentsAPI\AI\WP_Agent_Execution_Principal $principal ): bool {
$sources = self::autonomous_auth_sources();
$autonomous = in_array( $principal->auth_source, $sources, true );

if ( function_exists( 'apply_filters' ) ) {
$autonomous = (bool) apply_filters( 'agents_api_principal_is_autonomous', $autonomous, $principal );
}

return $autonomous;
}

/**
* Capabilities excluded from autonomous executions by default.
*
* Hosts and Core can adjust the set through the
* `agents_api_autonomous_denied_capabilities` filter.
*
* @return string[]
*/
public static function denied_capabilities(): array {
$denied = self::DEFAULT_DENIED_CAPABILITIES;

if ( function_exists( 'apply_filters' ) ) {
$denied = apply_filters( 'agents_api_autonomous_denied_capabilities', $denied );
}

return self::string_list( $denied );
}

/**
* Build the safe default ceiling for an autonomous execution.
*
* The ceiling leaves the allow-list unrestricted (null) and records the
* content-mutating capabilities on the deny-list, so the ceiling blocks
* those capabilities while permitting everything else the acting
* identity can otherwise use.
*
* @param int $user_id WordPress user ID bound to the ceiling. 0 for non-user principals.
* @return WP_Agent_Capability_Ceiling
*/
public static function safe_default_ceiling( int $user_id ): WP_Agent_Capability_Ceiling {
return new WP_Agent_Capability_Ceiling(
$user_id,
null,
array(
'autonomous_safe_default' => true,
'source' => 'wp_agent_autonomous_capability_policy',
),
self::denied_capabilities()
);
}

/**
* Resolve the effective capability ceiling for a principal.
*
* Composition rules:
* - An explicit host grant (a ceiling carrying any capability shaping,
* whether an allow-list or a deny-list) is always respected and
* returned unchanged. The safe default never widens, narrows, or
* overrides a deliberate host decision.
* - An autonomous principal without an explicit grant receives the
* safe default ceiling, which excludes content-mutating capabilities.
* - Any other principal is returned with its ceiling untouched (null or
* unrestricted as the host supplied).
*
* @param AgentsAPI\AI\WP_Agent_Execution_Principal $principal Execution principal.
* @return WP_Agent_Capability_Ceiling|null
*/
public static function resolve_ceiling( AgentsAPI\AI\WP_Agent_Execution_Principal $principal ): ?WP_Agent_Capability_Ceiling {
$existing = $principal->capability_ceiling;

if ( null !== $existing && ( $existing->has_capability_restrictions() || $existing->has_denied_capabilities() ) ) {
return $existing;
}

if ( ! self::is_autonomous( $principal ) ) {
return $existing;
}

return self::safe_default_ceiling( $principal->acting_user_id );
}

/**
* Normalize a raw list into unique, trimmed, non-empty capability strings.
*
* @param mixed $value Raw capability list.
* @return string[]
*/
private static function string_list( $value ): array {
if ( ! is_array( $value ) ) {
return array();
}

$strings = array();
foreach ( $value as $item ) {
if ( ! is_scalar( $item ) ) {
continue;
}

$trimmed = trim( (string) $item );
if ( '' === $trimmed ) {
continue;
}

$strings[] = $trimmed;
}

return array_values( array_unique( $strings ) );
}
}
}
41 changes: 37 additions & 4 deletions src/Auth/class-wp-agent-capability-ceiling.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@ final class WP_Agent_Capability_Ceiling {
* @param int $user_id WordPress user ID that bounds execution.
* @param string[]|null $allowed_capabilities Optional capability allow-list. Null means unrestricted by token/client.
* @param array<string,mixed> $metadata Host-owned metadata about how this ceiling was derived.
* @param string[]|null $denied_capabilities Optional capability deny-list. Takes precedence over the allow-list.
*/
public function __construct(
public readonly int $user_id,
public readonly ?array $allowed_capabilities = null,
public readonly array $metadata = array(),
public readonly ?array $denied_capabilities = null,
) {
if ( $this->user_id < 0 ) {
throw self::invalid( 'user_id', 'must be zero or a positive integer' );
Expand All @@ -39,6 +41,14 @@ public function __construct(
}
}

if ( null !== $this->denied_capabilities ) {
foreach ( $this->denied_capabilities as $capability ) {
if ( '' === trim( $capability ) ) {
throw self::invalid( 'denied_capabilities', 'must contain non-empty capability strings' );
}
}
}

if ( false === self::json_encode( $this->metadata ) ) {
throw self::invalid( 'metadata', 'must be JSON serializable' );
}
Expand All @@ -53,22 +63,31 @@ public static function from_array( array $ceiling ): self {
return new self(
isset( $ceiling['user_id'] ) && is_scalar( $ceiling['user_id'] ) ? self::int_value( $ceiling['user_id'] ) : 0,
array_key_exists( 'allowed_capabilities', $ceiling ) && null !== $ceiling['allowed_capabilities'] ? self::string_list( $ceiling['allowed_capabilities'] ) : null,
isset( $ceiling['metadata'] ) && is_array( $ceiling['metadata'] ) ? self::string_keyed_array( $ceiling['metadata'] ) : array()
isset( $ceiling['metadata'] ) && is_array( $ceiling['metadata'] ) ? self::string_keyed_array( $ceiling['metadata'] ) : array(),
array_key_exists( 'denied_capabilities', $ceiling ) && null !== $ceiling['denied_capabilities'] ? self::string_list( $ceiling['denied_capabilities'] ) : null,
);
}

/**
* Whether this ceiling has a token/client capability restriction.
* Whether this ceiling has a token/client capability allow-list restriction.
*/
public function has_capability_restrictions(): bool {
return null !== $this->allowed_capabilities;
}

/**
* Whether this ceiling carries a capability deny-list restriction.
*/
public function has_denied_capabilities(): bool {
return null !== $this->denied_capabilities;
}

/**
* Whether token/client restrictions allow the capability.
*
* This does not call WordPress. It only answers whether the local ceiling
* restrictions include the requested capability.
* restrictions include the requested capability. A denied capability is
* never allowed, even when the allow-list is unrestricted.
*
* @param string $capability WordPress capability name.
*/
Expand All @@ -78,6 +97,10 @@ public function allows_capability( string $capability ): bool {
return false;
}

if ( null !== $this->denied_capabilities && in_array( $capability, $this->denied_capabilities, true ) ) {
return false;
}

if ( null === $this->allowed_capabilities ) {
return true;
}
Expand All @@ -91,7 +114,16 @@ public function allows_capability( string $capability ): bool {
* @param string[] $allowed_capabilities Allowed capability names.
*/
public function with_allowed_capabilities( array $allowed_capabilities ): self {
return new self( $this->user_id, array_values( $allowed_capabilities ), $this->metadata );
return new self( $this->user_id, array_values( $allowed_capabilities ), $this->metadata, $this->denied_capabilities );
}

/**
* Return a copy with an additional capability deny-list.
*
* @param string[] $denied_capabilities Denied capability names.
*/
public function with_denied_capabilities( array $denied_capabilities ): self {
return new self( $this->user_id, $this->allowed_capabilities, $this->metadata, array_values( $denied_capabilities ) );
}

/**
Expand All @@ -103,6 +135,7 @@ public function to_array(): array {
return array(
'user_id' => $this->user_id,
'allowed_capabilities' => $this->allowed_capabilities,
'denied_capabilities' => $this->denied_capabilities,
'metadata' => $this->metadata,
);
}
Expand Down
16 changes: 16 additions & 0 deletions src/Runtime/class-wp-agent-execution-principal.php
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,22 @@ public function conversation_owner(): ?array {
return null;
}

/**
* Whether this principal represents an autonomous execution.
*
* Autonomous executions are driven by automation rather than a live human
* authorizing each action. The determination delegates to the substrate
* autonomous capability policy so hosts can adjust it through the
* `agents_api_autonomous_auth_sources` and `agents_api_principal_is_autonomous`
* filters.
*
* @return bool
*/
public function is_autonomous_execution(): bool {
return class_exists( 'WP_Agent_Autonomous_Capability_Policy' )
&& \WP_Agent_Autonomous_Capability_Policy::is_autonomous( $this );
}

/**
* Whether this principal represents a host-resolved non-user audience.
*/
Expand Down
Loading