diff --git a/composer.json b/composer.json index 2d83eb3..ea307c2 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/src/Tools/class-wp-agent-tool-policy-filter.php b/src/Tools/class-wp-agent-tool-policy-filter.php index dab0b6d..87cdbe9 100644 --- a/src/Tools/class-wp-agent-tool-policy-filter.php +++ b/src/Tools/class-wp-agent-tool-policy-filter.php @@ -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. * @@ -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 $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 $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> $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> 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. * diff --git a/src/Tools/class-wp-agent-tool-policy.php b/src/Tools/class-wp-agent-tool-policy.php index 73fd559..bd9e4b8 100644 --- a/src/Tools/class-wp-agent-tool-policy.php +++ b/src/Tools/class-wp-agent-tool-policy.php @@ -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 @@ -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() ) ); @@ -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. * @@ -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 $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 $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 $context Runtime context. + * @param array> $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; + } } } diff --git a/src/Transcripts/class-wp-agent-conversation-store.php b/src/Transcripts/class-wp-agent-conversation-store.php index 61a0d90..88b0e4d 100644 --- a/src/Transcripts/class-wp-agent-conversation-store.php +++ b/src/Transcripts/class-wp-agent-conversation-store.php @@ -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 $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; diff --git a/src/Transcripts/class-wp-agent-principal-conversation-store.php b/src/Transcripts/class-wp-agent-principal-conversation-store.php index 89e2c7b..d9a2323 100644 --- a/src/Transcripts/class-wp-agent-principal-conversation-store.php +++ b/src/Transcripts/class-wp-agent-principal-conversation-store.php @@ -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 $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; diff --git a/tests/tool-policy-contracts-smoke.php b/tests/tool-policy-contracts-smoke.php index 86f7383..467fe41 100644 --- a/tests/tool-policy-contracts-smoke.php +++ b/tests/tool-policy-contracts-smoke.php @@ -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 ); @@ -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', @@ -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' ), @@ -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' ), ) ); @@ -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' ), diff --git a/tests/write-capable-default-smoke.php b/tests/write-capable-default-smoke.php new file mode 100644 index 0000000..bd5bfb7 --- /dev/null +++ b/tests/write-capable-default-smoke.php @@ -0,0 +1,328 @@ + array( + 'name' => 'host/read-meta', + 'categories' => array( 'read' ), + 'modes' => array( 'chat', 'pipeline', 'system' ), + ), + 'host/write-post' => array( + 'name' => 'host/write-post', + 'categories' => array( 'write' ), + 'modes' => array( 'chat', 'pipeline', 'system' ), + ), + 'host/publish-post' => array( + 'name' => 'host/publish-post', + 'categories' => array( 'publishing' ), + 'modes' => array( 'chat', 'pipeline', 'system' ), + ), + 'host/flagged-write' => array( + 'name' => 'host/flagged-write', + 'categories' => array( 'misc' ), + 'modes' => array( 'chat', 'pipeline', 'system' ), + 'write' => true, + ), + 'host/flagged-mutate' => array( + 'name' => 'host/flagged-mutate', + 'categories' => array( 'misc' ), + 'modes' => array( 'chat', 'pipeline', 'system' ), + 'mutating' => true, + ), + 'host/mandatory-fetch' => array( + 'name' => 'host/mandatory-fetch', + 'categories' => array( 'plumbing' ), + 'modes' => array( 'chat', 'pipeline', 'system' ), + 'mandatory' => true, + ), +); + +// A runtime principal (auth_source=runtime, acting_user_id=0) is autonomous. +$autonomous_runtime = $principal_class::runtime( 'runtime-1', 'smoke-agent' ); + +// An agent-token principal (auth_source=agent_token) is autonomous even when +// it carries an acting user, because automation drives the loop. +$autonomous_token = $principal_class::agent_token( 5, 'smoke-agent', 9 ); + +// An audience principal (acting_user_id=0) is autonomous by the no-human signal. +$autonomous_audience = $principal_class::audience( 'aud-1', 'smoke-agent' ); + +// A user-session principal (auth_source=user, acting_user_id>0) is interactive. +$interactive_user = $principal_class::user_session( 7, 'smoke-agent' ); + +// An application-password principal is a human-bound session, not autonomous. +$interactive_app_password = $principal_class::from_array( + array( + 'acting_user_id' => 7, + 'effective_agent_id' => 'smoke-agent', + 'auth_source' => $principal_class::AUTH_SOURCE_APPLICATION_PASSWORD, + 'request_context' => $principal_class::REQUEST_CONTEXT_REST, + ) +); + +echo "\n[2] An autonomous runtime principal excludes every write-capable tool while read + mandatory survive:\n"; +$resolved = $resolver->resolve( $tools, array( 'principal' => $autonomous_runtime ) ); +$resolved_names = array_keys( $resolved ); +sort( $resolved_names ); +agents_api_smoke_assert_equals( + array( 'host/mandatory-fetch', 'host/read-meta' ), + $resolved_names, + 'an autonomous runtime principal curates out every write-capable tool (category or flag) but keeps read and mandatory tools', + $failures, + $passes +); + +echo "\n[3] Declared write/mutating flag is gated exactly like category-classified tools:\n"; +agents_api_smoke_assert_equals( false, array_key_exists( 'host/write-post', $resolved ), 'write-category tool is excluded for an autonomous principal', $failures, $passes ); +agents_api_smoke_assert_equals( false, array_key_exists( 'host/publish-post', $resolved ), 'publishing-category tool is excluded for an autonomous principal', $failures, $passes ); +agents_api_smoke_assert_equals( false, array_key_exists( 'host/flagged-write', $resolved ), 'declared write-flag tool is excluded for an autonomous principal', $failures, $passes ); +agents_api_smoke_assert_equals( false, array_key_exists( 'host/flagged-mutate', $resolved ), 'declared mutating-flag tool is excluded for an autonomous principal', $failures, $passes ); +agents_api_smoke_assert_equals( true, array_key_exists( 'host/read-meta', $resolved ), 'non-write read tool survives', $failures, $passes ); + +echo "\n[4] Both autonomy signals engage the gate independently:\n"; +$resolved = $resolver->resolve( $tools, array( 'principal' => $autonomous_token ) ); +agents_api_smoke_assert_equals( false, array_key_exists( 'host/write-post', $resolved ), 'agent_token auth_source engages the gate even with an acting user', $failures, $passes ); + +$resolved = $resolver->resolve( $tools, array( 'principal' => $autonomous_audience ) ); +agents_api_smoke_assert_equals( false, array_key_exists( 'host/write-post', $resolved ), 'acting_user_id=0 engages the gate even for a non-automation auth source', $failures, $passes ); + +echo "\n[5] A human-backed principal keeps write tools regardless of the mode string:\n"; +$resolved = $resolver->resolve( $tools, array( 'principal' => $interactive_user ) ); +$resolved_names = array_keys( $resolved ); +sort( $resolved_names ); +agents_api_smoke_assert_equals( + array( 'host/flagged-mutate', 'host/flagged-write', 'host/mandatory-fetch', 'host/publish-post', 'host/read-meta', 'host/write-post' ), + $resolved_names, + 'a user-session principal keeps every tool regardless of write classification', + $failures, + $passes +); + +$resolved = $resolver->resolve( + $tools, + array( + 'principal' => $interactive_app_password, + ) +); +agents_api_smoke_assert_equals( true, array_key_exists( 'host/write-post', $resolved ), 'an application-password (human-bound) principal keeps write tools', $failures, $passes ); + +echo "\n[6] The gate keys off the principal, NOT the mode string (decisive contract):\n"; +$resolved = $resolver->resolve( + $tools, + array( + 'principal' => $autonomous_runtime, + 'mode' => 'chat', + ) +); +agents_api_smoke_assert_equals( false, array_key_exists( 'host/write-post', $resolved ), 'an autonomous principal is gated even in chat mode (mode does not rescue autonomy)', $failures, $passes ); + +$resolved = $resolver->resolve( + $tools, + array( + 'principal' => $interactive_user, + 'mode' => 'pipeline', + ) +); +agents_api_smoke_assert_equals( true, array_key_exists( 'host/write-post', $resolved ), 'a user principal keeps write tools even in an opaque automation mode (mode does not create autonomy)', $failures, $passes ); + +echo "\n[7] The principal may be supplied as an array shape or as flat context fields:\n"; +$resolved = $resolver->resolve( + $tools, + array( + 'principal' => array( + 'acting_user_id' => 0, + 'effective_agent_id' => 'smoke-agent', + 'auth_source' => 'system', + 'request_context' => 'runtime', + ), + ) +); +agents_api_smoke_assert_equals( false, array_key_exists( 'host/write-post', $resolved ), 'a system principal in array shape engages the gate', $failures, $passes ); + +$resolved = $resolver->resolve( + $tools, + array( + 'auth_source' => 'runtime', + 'acting_user_id' => 0, + ) +); +agents_api_smoke_assert_equals( false, array_key_exists( 'host/write-post', $resolved ), 'flat auth_source/acting_user_id context fields engage the gate', $failures, $passes ); + +$resolved = $resolver->resolve( + $tools, + array( + 'auth_source' => 'user', + 'acting_user_id' => 7, + ) +); +agents_api_smoke_assert_equals( true, array_key_exists( 'host/write-post', $resolved ), 'flat user fields keep write tools', $failures, $passes ); + +echo "\n[8] Safe fallback: a context with no principal information is treated as autonomous:\n"; +$resolved = $resolver->resolve( $tools, array( 'mode' => 'pipeline' ) ); +agents_api_smoke_assert_equals( false, array_key_exists( 'host/write-post', $resolved ), 'a context with no principal falls back to autonomous (gate engages, safe)', $failures, $passes ); +agents_api_smoke_assert_equals( true, array_key_exists( 'host/read-meta', $resolved ), 'read tool survives in the no-principal fallback', $failures, $passes ); + +echo "\n[9] Explicit opt-in paths restore write tools for an autonomous principal:\n"; +$resolved = $resolver->resolve( + $tools, + array( + 'principal' => $autonomous_runtime, + 'allow_only' => array( 'host/write-post' ), + ) +); +agents_api_smoke_assert_equals( true, array_key_exists( 'host/write-post', $resolved ), 'allow_only opts a write tool back in', $failures, $passes ); + +$resolved = $resolver->resolve( + $tools, + array( + 'principal' => $autonomous_runtime, + 'runtime_categories' => array( 'publishing' ), + ) +); +agents_api_smoke_assert_equals( true, array_key_exists( 'host/publish-post', $resolved ), 'runtime_categories opts a write-category tool back in', $failures, $passes ); + +$resolved = $resolver->resolve( + $tools, + array( + 'principal' => $autonomous_runtime, + 'tool_policy' => array( + 'mode' => 'allow', + 'tools' => array( 'host/write-post' ), + ), + ) +); +agents_api_smoke_assert_equals( true, array_key_exists( 'host/write-post', $resolved ), 'ALLOW-mode policy opts a write tool back in', $failures, $passes ); + +$resolved = $resolver->resolve( + $tools, + array( + 'principal' => $autonomous_runtime, + 'runtime_tools' => array( 'host/flagged-write' ), + ) +); +agents_api_smoke_assert_equals( true, array_key_exists( 'host/flagged-write', $resolved ), 'runtime_tools opts a write-flag tool back in', $failures, $passes ); + +echo "\n[10] Mandatory tools are preserved regardless of write classification:\n"; +$resolved = $resolver->resolve( + $tools, + array( + 'principal' => $autonomous_runtime, + 'tool_policy' => array( + 'mandatory_tools' => array( 'host/publish-post' ), + ), + ) +); +agents_api_smoke_assert_equals( true, array_key_exists( 'host/publish-post', $resolved ), 'mandatory_tools preserves a write tool even for an autonomous principal', $failures, $passes ); +agents_api_smoke_assert_equals( true, array_key_exists( 'host/mandatory-fetch', $resolved ), 'declared-mandatory flag tool is preserved', $failures, $passes ); + +echo "\n[11] allow_write_tools escape hatch disables the curation gate:\n"; +$resolved = $resolver->resolve( + $tools, + array( + 'principal' => $autonomous_runtime, + 'allow_write_tools' => true, + ) +); +$resolved_names = array_keys( $resolved ); +sort( $resolved_names ); +agents_api_smoke_assert_equals( + array( 'host/flagged-mutate', 'host/flagged-write', 'host/mandatory-fetch', 'host/publish-post', 'host/read-meta', 'host/write-post' ), + $resolved_names, + 'context allow_write_tools flag restores ambient write tools for an autonomous principal', + $failures, + $passes +); + +$resolved = $resolver->resolve( + $tools, + array( + 'principal' => $autonomous_runtime, + 'agent_config' => array( + 'tool_policy' => array( + 'allow_write_tools' => true, + ), + ), + ) +); +agents_api_smoke_assert_equals( true, array_key_exists( 'host/write-post', $resolved ), 'policy allow_write_tools flag restores ambient write tools', $failures, $passes ); + +echo "\n[12] agents_api_write_capable_categories filter extends the classification set:\n"; +add_filter( + 'agents_api_write_capable_categories', + static function ( array $categories ): array { + $categories[] = 'sensitive'; + return $categories; + } +); +$sensitive_tools = array( + 'host/sensitive-op' => array( + 'name' => 'host/sensitive-op', + 'categories' => array( 'sensitive' ), + 'modes' => array( 'chat', 'pipeline' ), + ), +); +$resolved = $resolver->resolve( $sensitive_tools, array( 'principal' => $autonomous_runtime ) ); +agents_api_smoke_assert_equals( false, array_key_exists( 'host/sensitive-op', $resolved ), 'filtered-in write-capable category is gated for an autonomous principal', $failures, $passes ); + +$resolved = $resolver->resolve( $sensitive_tools, array( 'principal' => $interactive_user ) ); +agents_api_smoke_assert_equals( true, array_key_exists( 'host/sensitive-op', $resolved ), 'filtered-in category is still visible for a user principal', $failures, $passes ); + +echo "\n[13] agents_api_resolved_tools final filter still runs after the write gate:\n"; +add_filter( + 'agents_api_resolved_tools', + static function ( array $resolved_tools ) { + $resolved_tools['host/filter-injected'] = array( 'name' => 'host/filter-injected' ); + return $resolved_tools; + } +); +$resolved = $resolver->resolve( array(), array( 'principal' => $autonomous_runtime ) ); +agents_api_smoke_assert_equals( true, array_key_exists( 'host/filter-injected', $resolved ), 'resolved_tools final filter runs after write gate', $failures, $passes ); + +agents_api_smoke_finish( 'Write-capable default', $failures, $passes );