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 composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"php tests/tool-executor-registry-smoke.php",
"php tests/tool-result-error-type-smoke.php",
"php tests/runtime-tool-policy-smoke.php",
"php tests/write-capable-default-smoke.php",
"php tests/runtime-tool-lifecycle-abilities-smoke.php",
"php tests/tool-tier-resolver-smoke.php",
"php tests/action-policy-resolver-smoke.php",
Expand Down
110 changes: 110 additions & 0 deletions src/Tools/class-wp-agent-tool-policy-filter.php
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,22 @@ public function filter_runtime_tools_by_policy_opt_in( array $tools, array $allo
return $filtered;
}

/**
* Default write-capable category slugs.
*
* Generic classification vocabulary — not consumer tool names. The
* substrate classifies by declared nature (the `write`/`mutating` tool
* flag) or by membership in one of these generic category slugs.
* Consumers and Core can extend or replace this set via the
* `agents_api_write_capable_categories` filter.
*/
private const DEFAULT_WRITE_CAPABLE_CATEGORIES = array(
'write',
'publishing',
'mutating',
'destructive',
);

/**
* Whether a declaration represents a caller-provided runtime tool.
*
Expand All @@ -196,6 +212,100 @@ public function is_runtime_tool( array $tool ): bool {
|| 'client' === ( $tool['executor'] ?? null );
}

/**
* Return the filterable set of write-capable category slugs.
*
* The built-in list is intentionally conservative and generic. Extend
* it through the `agents_api_write_capable_categories` filter rather
* than hardcoding consumer vocabulary in the substrate.
*
* @param array<string, mixed> $context Runtime context.
* @return string[] Write-capable category slugs.
*/
public function write_capable_categories( array $context = array() ): array {
$categories = self::DEFAULT_WRITE_CAPABLE_CATEGORIES;

if ( function_exists( 'apply_filters' ) ) {
$filtered = apply_filters( 'agents_api_write_capable_categories', $categories, $context, $this );
if ( is_array( $filtered ) ) {
$categories = $filtered;
}
}

return $this->string_list( $categories );
}

/**
* Whether a tool declaration is classified as write-capable.
*
* A tool is write-capable when it declares a `write` or `mutating`
* flag set to true (the layer-pure path — the tool declares its own
* nature), OR when one of its categories matches a write-capable
* category slug.
*
* @param array<string, mixed> $tool Tool definition.
* @param string[] $categories Write-capable category slugs.
* @return bool Whether the tool is write-capable.
*/
public function is_write_capable_tool( array $tool, array $categories = array() ): bool {
if ( true === ( $tool['write'] ?? false ) || true === ( $tool['mutating'] ?? false ) ) {
return true;
}

return $this->tool_matches_categories( $tool, $this->string_list( $categories ) );
}

/**
* Exclude write-capable tools unless explicitly opted in or preserved.
*
* Applied only for autonomous principals (no human in the loop) as a
* tool-surface CURATION default — see
* {@see WP_Agent_Tool_Policy::is_autonomous_principal_context()}.
* This is defense-in-depth, NOT the enforcement boundary; capability
* enforcement is tracked in #412. Read-only tools, mandatory
* plumbing, and explicitly opted-in tools are unaffected.
*
* @param array<string, array<string, mixed>> $tools Tool definitions keyed by tool name.
* @param string[] $allowed_tools Tool names explicitly allowed.
* @param string[] $allowed_categories Tool categories explicitly allowed.
* @param string[] $write_categories Write-capable category slugs.
* @param callable|null $preserve_tool Optional callback for mandatory tools.
* @return array<string, array<string, mixed>> Filtered tools.
*/
public function filter_write_capable_by_policy_opt_in( array $tools, array $allowed_tools, array $allowed_categories, array $write_categories, ?callable $preserve_tool = null ): array {
$allowed_tools = $this->string_list( $allowed_tools );
$allowed_categories = $this->string_list( $allowed_categories );
$write_categories = $this->string_list( $write_categories );

if ( empty( $write_categories ) ) {
return $tools;
}

$filtered = array();
foreach ( $tools as $name => $tool ) {
if ( ! $this->is_write_capable_tool( $tool, $write_categories ) ) {
$filtered[ $name ] = $tool;
continue;
}

if ( in_array( $name, $allowed_tools, true ) ) {
$filtered[ $name ] = $tool;
continue;
}

if ( $this->tool_matches_categories( $tool, $allowed_categories ) ) {
$filtered[ $name ] = $tool;
continue;
}

if ( $preserve_tool && $preserve_tool( $tool, $name ) ) {
$filtered[ $name ] = $tool;
}
}

return $filtered;
}

/**
* Whether a tool matches any category in a policy.
*
Expand Down
168 changes: 165 additions & 3 deletions src/Tools/class-wp-agent-tool-policy.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,20 @@ class WP_Agent_Tool_Policy {
public const MODE_ALLOW = 'allow';
public const MODE_DENY = 'deny';

public const RUNTIME_CHAT = 'chat';
public const RUNTIME_PIPELINE = 'pipeline';
public const RUNTIME_SYSTEM = 'system';
/**
* Canonical interactive runtime mode.
*
* Default value for the generic tool `mode` filter
* ({@see WP_Agent_Tool_Policy_Filter::filter_by_mode()}), which lets
* tools declare the modes they are available in. This is a
* substrate-native interactive surface and is intentionally kept.
*
* NOTE: The write-capable curation gate does NOT key off this mode.
* It keys off the execution principal's autonomy instead — see
* {@see is_autonomous_principal_context()}. Mode-name coupling was
* removed from the gate so the substrate names no consumer modes.
*/
public const RUNTIME_CHAT = 'chat';

/**
* @var WP_Agent_Tool_Policy_Filter
Expand Down Expand Up @@ -95,6 +106,16 @@ public function resolve( array $tools, array $context = array() ): array {
$preserve_tool
);

if ( $this->is_autonomous_principal_context( $context ) && ! $this->allows_ambient_write_tools( $context, $policies ) ) {
$tools = $this->filter->filter_write_capable_by_policy_opt_in(
$tools,
$runtime_opt_in['tools'],
$runtime_opt_in['categories'],
$this->filter->write_capable_categories( $context ),
$preserve_tool
);
}

$deny = $this->filter->string_list( $context['deny'] ?? array() );
foreach ( $policies as $policy ) {
$deny = array_merge( $deny, $this->filter->string_list( $policy['deny'] ?? array() ) );
Expand Down Expand Up @@ -235,6 +256,34 @@ private function string_value( $value ): string {
return is_scalar( $value ) || $value instanceof Stringable ? (string) $value : '';
}

/**
* Convert scalar/Stringable input to a non-negative integer.
*
* @param mixed $value Raw value.
* @return int Integer value (0 for non-stringable/negative input).
*/
private function int_value( $value ): int {
if ( is_int( $value ) ) {
return $value < 0 ? 0 : $value;
}

if ( is_bool( $value ) ) {
return $value ? 1 : 0;
}

if ( is_float( $value ) ) {
return $value > 0 ? (int) $value : 0;
}

if ( is_string( $value ) || $value instanceof Stringable ) {
$int = (int) (string) $value;

return $int < 0 ? 0 : $int;
}

return 0;
}

/**
* Keep only string-keyed entries from a policy map.
*
Expand Down Expand Up @@ -307,5 +356,118 @@ private function collect_runtime_tool_opt_in( array $policies, array $context, a
'categories' => array_values( array_unique( $allowed_categories ) ),
);
}

/**
* Whether the runtime context is driven by an autonomous principal.
*
* TOOL-SURFACE CURATION (defense-in-depth), NOT the enforcement
* boundary. This gate only decides whether write-capable tools are
* *offered* to the model. It reduces prompt surface and stops an
* autonomous agent from even trying a write tool. The actual
* security boundary is capability-ceiling ENFORCEMENT at
* ability/tool execution — tracked in #412. Consumers must not
* treat this listing gate as authorization.
*
* Autonomy is read from the execution principal — which the
* substrate already models precisely — instead of a mode/interactive
* string. The substrate therefore names no consumer modes here. A
* principal is autonomous when EITHER:
*
* - its `auth_source` is an automation source
* (`system` / `runtime` / `agent_token`), OR
* - it has no human backing it (`acting_user_id` === 0).
*
* The principal is resolved from, in order: a `principal` entry on
* the context (a {@see \AgentsAPI\AI\WP_Agent_Execution_Principal}
* instance or its array shape), or flat `auth_source` /
* `acting_user_id` fields on the context root. When no principal
* information is present at all, the gate falls back SAFE: autonomy
* is assumed and the gate engages. Prefer the principal at the call
* site; the fallback exists so an unannotated context cannot
* silently expose write tools.
*
* @param array<string, mixed> $context Runtime context.
* @return bool Whether the principal is autonomous (gate engages).
*/
private function is_autonomous_principal_context( array $context ): bool {
$signals = $this->principal_autonomy_signals( $context );

if ( ! $signals['present'] ) {
return true;
}

if ( 0 === $signals['acting_user_id'] ) {
return true;
}

return in_array( $signals['auth_source'], array( 'system', 'runtime', 'agent_token' ), true );
}

/**
* Resolve principal autonomy signals from the runtime context.
*
* @param array<string, mixed> $context Runtime context.
* @return array{auth_source: string, acting_user_id: int, present: bool} Resolved signals.
*/
private function principal_autonomy_signals( array $context ): array {
$principal = $context['principal'] ?? null;

if ( $principal instanceof AgentsAPI\AI\WP_Agent_Execution_Principal ) {
return array(
'auth_source' => $principal->auth_source,
'acting_user_id' => $principal->acting_user_id,
'present' => true,
);
}

if ( is_array( $principal ) && ( array_key_exists( 'auth_source', $principal ) || array_key_exists( 'acting_user_id', $principal ) ) ) {
return array(
'auth_source' => $this->string_value( $principal['auth_source'] ?? '' ),
'acting_user_id' => $this->int_value( $principal['acting_user_id'] ?? 0 ),
'present' => true,
);
}

if ( array_key_exists( 'auth_source', $context ) || array_key_exists( 'acting_user_id', $context ) ) {
return array(
'auth_source' => $this->string_value( $context['auth_source'] ?? '' ),
'acting_user_id' => $this->int_value( $context['acting_user_id'] ?? 0 ),
'present' => true,
);
}

return array(
'auth_source' => '',
'acting_user_id' => 0,
'present' => false,
);
}

/**
* Whether the context or any policy fragment opts in to ambient write tools.
*
* When true, the write-capable curation gate is skipped entirely
* for this resolution. This is the explicit escape hatch for consumers
* that intentionally want ambient write tools surfaced to an
* autonomous principal (defense-in-depth curation only; capability
* enforcement is tracked in #412).
*
* @param array<string, mixed> $context Runtime context.
* @param array<int, array<string, mixed>> $policies Policy fragments.
* @return bool Whether ambient write tools are allowed.
*/
private function allows_ambient_write_tools( array $context, array $policies ): bool {
if ( true === ( $context['allow_write_tools'] ?? false ) ) {
return true;
}

foreach ( $policies as $policy ) {
if ( true === ( $policy['allow_write_tools'] ?? false ) ) {
return true;
}
}

return false;
}
}
}
2 changes: 1 addition & 1 deletion src/Transcripts/class-wp-agent-conversation-store.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ interface WP_Agent_Conversation_Store {
* @param int $user_id WordPress user ID owning the session.
* @param string $agent_slug Registered agent slug, or empty string for agent-less sessions.
* @param array<mixed> $metadata Arbitrary session metadata (JSON-serializable).
* @param string $context Execution mode ('chat', 'pipeline', 'system').
* @param string $context Execution mode. An opaque, consumer-defined string. The substrate treats `chat`/`interactive`/`rest` as interactive and every other value as non-interactive; it never enumerates consumer automation modes.
* @return string Session ID (UUIDv4), or empty string on failure.
*/
public function create_session( WP_Agent_Workspace_Scope $workspace, int $user_id, string $agent_slug = '', array $metadata = array(), string $context = 'chat' ): string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ interface WP_Agent_Principal_Conversation_Store extends WP_Agent_Conversation_St
* @param array{type:string,key:string} $owner Canonical principal owner.
* @param string $agent_slug Registered agent slug, or empty string for agent-less sessions.
* @param array<mixed> $metadata Arbitrary session metadata (JSON-serializable).
* @param string $context Execution mode ('chat', 'pipeline', 'system').
* @param string $context Execution mode. An opaque, consumer-defined string. The substrate treats `chat`/`interactive`/`rest` as interactive and every other value as non-interactive; it never enumerates consumer automation modes.
* @return string Session ID (UUIDv4), or empty string on failure.
*/
public function create_session_for_owner( WP_Agent_Workspace_Scope $workspace, array $owner, string $agent_slug = '', array $metadata = array(), string $context = 'chat' ): string;
Expand Down
14 changes: 13 additions & 1 deletion tests/tool-policy-contracts-smoke.php
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,18 @@ public function get_tool_policy( array $context ): ?array {
agents_api_smoke_assert_equals( array( 'client/read' ), array_keys( $resolved ), 'explicit deny removes mandatory provider tool', $failures, $passes );

echo "\n[5] Runtime/client tools require explicit opt-in while non-runtime tools remain visible:\n";
// This section exercises generic runtime-tool opt-in. It models an
// interactive human session via a user principal so the write-capable
// curation gate (now keyed off principal autonomy) stays out of the way:
// the host `client/write` tool remains ambient and the assertions can
// focus on runtime-tool opt-in behavior.
$user_principal = AgentsAPI\AI\WP_Agent_Execution_Principal::user_session( 1, 'contracts-agent' );

$resolved = $resolver->resolve(
$tools,
array(
'mode' => 'chat',
'mode' => 'chat',
'principal' => $user_principal,
)
);
$resolved_names = array_keys( $resolved );
Expand All @@ -155,6 +163,7 @@ public function get_tool_policy( array $context ): ?array {
$tools,
array(
'mode' => 'chat',
'principal' => $user_principal,
'agent_config' => array(
'tool_policy' => array(
'mode' => 'allow',
Expand All @@ -171,6 +180,7 @@ public function get_tool_policy( array $context ): ?array {
$tools,
array(
'mode' => 'chat',
'principal' => $user_principal,
'tool_policy' => array(
'mode' => 'deny',
'tools' => array( 'client/write' ),
Expand All @@ -186,6 +196,7 @@ public function get_tool_policy( array $context ): ?array {
$tools,
array(
'mode' => 'chat',
'principal' => $user_principal,
'allow_only' => array( 'client/read', 'client/runtime' ),
)
);
Expand All @@ -197,6 +208,7 @@ public function get_tool_policy( array $context ): ?array {
$tools,
array(
'mode' => 'chat',
'principal' => $user_principal,
'tool_policy' => array(
'mode' => 'deny',
'runtime_categories' => array( 'read' ),
Expand Down
Loading