From 1f7d8f546d1be73e1471cf7f3102c514bdb96201 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 5 Jul 2026 19:38:02 +0000 Subject: [PATCH 1/3] feat: make write-capable tools opt-in by default in non-interactive modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a safe-default gate to WP_Agent_Tool_Policy that excludes write-capable tools in non-interactive runtime modes (pipeline, system) unless they are mandatory plumbing or explicitly opted in. Classification mechanism (layer-pure): - Per-tool declaration: 'write' => true or 'mutating' => true on the tool/ability-tool definition. The tool declares its own nature; the policy enforces the default. - Category fallback: a tool whose category matches a write-capable category slug (write, publishing, mutating, destructive by default) is also gated. Exemptions (unchanged behavior): - Interactive chat mode is not affected. - Mandatory tools/categories and declared-mandatory plumbing survive. - Explicit opt-in via allow_only, runtime_tools, runtime_categories, or an ALLOW-mode named policy restores write tools. Escape hatch: - Context or policy fragment key 'allow_write_tools' => true disables the gate entirely for that resolution. - The 'agents_api_write_capable_categories' filter lets Core and consumers extend or replace the built-in category set. The gate reuses the existing collect_runtime_tool_opt_in() aggregate so every opt-in path works uniformly. No vendor/consumer tool slugs are hardcoded — classification is by declared nature or generic category, never by tool name. Addresses Automattic/agents-api#408. --- composer.json | 1 + .../class-wp-agent-tool-policy-filter.php | 107 +++++++++ src/Tools/class-wp-agent-tool-policy.php | 55 +++++ tests/write-capable-default-smoke.php | 221 ++++++++++++++++++ 4 files changed, 384 insertions(+) create mode 100644 tests/write-capable-default-smoke.php 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..d0dc792 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,97 @@ 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 in non-interactive runtime modes as a safe default. + * 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..3a0d1d5 100644 --- a/src/Tools/class-wp-agent-tool-policy.php +++ b/src/Tools/class-wp-agent-tool-policy.php @@ -20,6 +20,16 @@ class WP_Agent_Tool_Policy { public const RUNTIME_PIPELINE = 'pipeline'; public const RUNTIME_SYSTEM = 'system'; + /** + * Runtime modes without a human in the loop. + * + * In these modes, write-capable tools (declared via the `write`/ + * `mutating` tool flag or a write-capable category) are opt-in by + * default rather than ambient. Interactive `chat` mode is excluded — + * it is request-user-scoped and already access-gated. + */ + public const NON_INTERACTIVE_MODES = array( self::RUNTIME_PIPELINE, self::RUNTIME_SYSTEM ); + /** * @var WP_Agent_Tool_Policy_Filter */ @@ -95,6 +105,16 @@ public function resolve( array $tools, array $context = array() ): array { $preserve_tool ); + if ( $this->is_non_interactive_mode( $mode ) && ! $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() ) ); @@ -307,5 +327,40 @@ private function collect_runtime_tool_opt_in( array $policies, array $context, a 'categories' => array_values( array_unique( $allowed_categories ) ), ); } + + /** + * Whether a runtime mode is non-interactive (no human in the loop). + * + * @param string $mode Runtime mode. + * @return bool Whether the mode is non-interactive. + */ + private function is_non_interactive_mode( string $mode ): bool { + return in_array( $mode, self::NON_INTERACTIVE_MODES, true ); + } + + /** + * Whether the context or any policy fragment opts in to ambient write tools. + * + * When true, the write-capable safe-default gate is skipped entirely + * for this resolution. This is the explicit escape hatch for consumers + * that intentionally want ambient write tools in a non-interactive mode. + * + * @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/tests/write-capable-default-smoke.php b/tests/write-capable-default-smoke.php new file mode 100644 index 0000000..2a5a241 --- /dev/null +++ b/tests/write-capable-default-smoke.php @@ -0,0 +1,221 @@ + 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, + ), +); + +echo "\n[2] Pipeline mode excludes write-capable tools by default while read tools survive:\n"; +$resolved = $resolver->resolve( $tools, array( 'mode' => 'pipeline' ) ); +$resolved_names = array_keys( $resolved ); +sort( $resolved_names ); +agents_api_smoke_assert_equals( + array( 'host/mandatory-fetch', 'host/read-meta' ), + $resolved_names, + 'pipeline mode excludes 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 in pipeline mode', $failures, $passes ); +agents_api_smoke_assert_equals( false, array_key_exists( 'host/publish-post', $resolved ), 'publishing-category tool is excluded in pipeline mode', $failures, $passes ); +agents_api_smoke_assert_equals( false, array_key_exists( 'host/flagged-write', $resolved ), 'declared write-flag tool is excluded in pipeline mode', $failures, $passes ); +agents_api_smoke_assert_equals( false, array_key_exists( 'host/flagged-mutate', $resolved ), 'declared mutating-flag tool is excluded in pipeline mode', $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] Chat mode is unchanged — all tools remain visible:\n"; +$resolved = $resolver->resolve( $tools, array( 'mode' => 'chat' ) ); +$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, + 'chat mode keeps every tool regardless of write classification', + $failures, + $passes +); + +echo "\n[5] System mode also applies the safe default:\n"; +$resolved = $resolver->resolve( $tools, array( 'mode' => 'system' ) ); +agents_api_smoke_assert_equals( false, array_key_exists( 'host/write-post', $resolved ), 'write-category tool is excluded in system mode', $failures, $passes ); +agents_api_smoke_assert_equals( true, array_key_exists( 'host/read-meta', $resolved ), 'read tool survives in system mode', $failures, $passes ); + +echo "\n[6] Explicit opt-in paths restore write tools in pipeline mode:\n"; +$resolved = $resolver->resolve( + $tools, + array( + 'mode' => 'pipeline', + '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( + 'mode' => 'pipeline', + '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( + 'mode' => 'pipeline', + '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( + 'mode' => 'pipeline', + '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[7] Mandatory tools are preserved regardless of write classification:\n"; +$resolved = $resolver->resolve( + $tools, + array( + 'mode' => 'pipeline', + '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 in pipeline mode', $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[8] allow_write_tools escape hatch disables the safe default:\n"; +$resolved = $resolver->resolve( + $tools, + array( + 'mode' => 'pipeline', + '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 in pipeline mode', + $failures, + $passes +); + +$resolved = $resolver->resolve( + $tools, + array( + 'mode' => 'pipeline', + '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[9] 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( 'mode' => 'pipeline' ) ); +agents_api_smoke_assert_equals( false, array_key_exists( 'host/sensitive-op', $resolved ), 'filtered-in write-capable category is gated in pipeline mode', $failures, $passes ); + +$resolved = $resolver->resolve( $sensitive_tools, array( 'mode' => 'chat' ) ); +agents_api_smoke_assert_equals( true, array_key_exists( 'host/sensitive-op', $resolved ), 'filtered-in category is still visible in chat mode', $failures, $passes ); + +echo "\n[10] 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( 'mode' => 'pipeline' ) ); +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 ); From 5bf9eac02016a98a8fd33b1a646b0f00c115c345 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 5 Jul 2026 20:49:10 +0000 Subject: [PATCH 2/3] refactor: make the write-capable gate mode-name neutral The tool policy is a neutral substrate and must not enumerate consumer-specific automation mode names. Remove the layer-purity violation introduced by RUNTIME_PIPELINE/RUNTIME_SYSTEM and the NON_INTERACTIVE_MODES membership list: - Delete RUNTIME_PIPELINE and RUNTIME_SYSTEM constants (consumer orchestration vocabulary the substrate defined but never acted on). - Delete the NON_INTERACTIVE_MODES enumeration added in this PR. - Replace is_non_interactive_mode($mode) with a descriptive is_non_interactive_context($context): non-interactive = NOT interactive, reusing the same interactive detection the consent policy already uses (interactive => true OR mode in chat/interactive/rest). - Keep RUNTIME_CHAT = 'chat' (the substrate's own interactive vocabulary). - Rewrite the conversation-store $context docstrings to describe modes as consumer-defined opaque strings with interactive/non-interactive as the substrate's axis. - Rewrite the write-capable smoke test to drive the gate via the interactive axis rather than nuked constants, including cases for an opaque consumer mode string, interactive => true/false, and the interactive markers. The substrate is now fully mode-name neutral. Consumers keep their own product-shaped mode vocabulary and map onto the interactive axis at the call site. Data Machine (the primary consumer) needs zero changes: its chat path already passes interactive => true, and all of its ability categories are datamachine-* namespaced, so none match the substrate's generic write-capable category set (write/publishing/mutating/destructive) and no tool sets write/mutating flags. --- src/Tools/class-wp-agent-tool-policy.php | 59 +++++++++---- .../class-wp-agent-conversation-store.php | 2 +- ...-wp-agent-principal-conversation-store.php | 2 +- tests/write-capable-default-smoke.php | 82 ++++++++++++++----- 4 files changed, 106 insertions(+), 39 deletions(-) diff --git a/src/Tools/class-wp-agent-tool-policy.php b/src/Tools/class-wp-agent-tool-policy.php index 3a0d1d5..42deda4 100644 --- a/src/Tools/class-wp-agent-tool-policy.php +++ b/src/Tools/class-wp-agent-tool-policy.php @@ -16,19 +16,32 @@ 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. + * + * The substrate's OWN interactive vocabulary. The tool policy treats + * any context carrying `interactive => true` OR one of the recognized + * interactive mode markers as a human-in-the-loop flow; every other + * value is non-interactive. See {@see INTERACTIVE_MODES}. + */ + public const RUNTIME_CHAT = 'chat'; /** - * Runtime modes without a human in the loop. + * Substrate-recognized interactive runtime mode markers. * - * In these modes, write-capable tools (declared via the `write`/ - * `mutating` tool flag or a write-capable category) are opt-in by - * default rather than ambient. Interactive `chat` mode is excluded — - * it is request-user-scoped and already access-gated. + * This is the substrate's complete interactive vocabulary. It is + * intentionally NOT a list of automation/non-interactive mode names: + * the substrate is mode-name-neutral and never names consumer + * product modes (pipeline, system, editor, ...). A context is + * non-interactive when it carries no recognized interactive signal, + * so the write-capable safe default applies without the substrate + * having to enumerate the consumer's automation modes. + * + * Consumers keep their own product-shaped mode vocabulary and map + * onto this axis at the call site, either by setting the explicit + * `interactive` flag or by passing one of these mode markers. */ - public const NON_INTERACTIVE_MODES = array( self::RUNTIME_PIPELINE, self::RUNTIME_SYSTEM ); + private const INTERACTIVE_MODES = array( self::RUNTIME_CHAT, 'interactive', 'rest' ); /** * @var WP_Agent_Tool_Policy_Filter @@ -105,7 +118,7 @@ public function resolve( array $tools, array $context = array() ): array { $preserve_tool ); - if ( $this->is_non_interactive_mode( $mode ) && ! $this->allows_ambient_write_tools( $context, $policies ) ) { + if ( $this->is_non_interactive_context( $context ) && ! $this->allows_ambient_write_tools( $context, $policies ) ) { $tools = $this->filter->filter_write_capable_by_policy_opt_in( $tools, $runtime_opt_in['tools'], @@ -329,13 +342,29 @@ private function collect_runtime_tool_opt_in( array $policies, array $context, a } /** - * Whether a runtime mode is non-interactive (no human in the loop). + * Whether the runtime context is non-interactive (no human in the loop). + * + * The substrate models only the interactive-vs-non-interactive axis. + * It never enumerates consumer-specific automation mode names. A + * context is interactive when it sets the explicit `interactive` flag + * to true OR carries a mode the substrate recognizes as interactive + * ({@see INTERACTIVE_MODES}) — mirroring the consent policy's + * interactive detection. Anything else is non-interactive, so the + * write-capable safe default applies. Consumers keep their own + * product-shaped mode vocabulary and map onto this axis at the call + * site. * - * @param string $mode Runtime mode. - * @return bool Whether the mode is non-interactive. + * @param array $context Runtime context. + * @return bool Whether the context is non-interactive. */ - private function is_non_interactive_mode( string $mode ): bool { - return in_array( $mode, self::NON_INTERACTIVE_MODES, true ); + private function is_non_interactive_context( array $context ): bool { + if ( true === ( $context['interactive'] ?? null ) ) { + return false; + } + + $mode = strtolower( $this->string_value( $context['mode'] ?? '' ) ); + + return '' === $mode || ! in_array( $mode, self::INTERACTIVE_MODES, true ); } /** 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/write-capable-default-smoke.php b/tests/write-capable-default-smoke.php index 2a5a241..2c6c63c 100644 --- a/tests/write-capable-default-smoke.php +++ b/tests/write-capable-default-smoke.php @@ -2,8 +2,15 @@ /** * Pure-PHP smoke test for the safe-by-default write-capable tool policy. * - * In non-interactive runtime modes (pipeline, system), write-capable tools - * are opt-in rather than ambient. Interactive chat mode is unchanged. + * The substrate models only the interactive-vs-non-interactive axis. In a + * non-interactive context (no human in the loop), write-capable tools are + * opt-in rather than ambient. Interactive contexts are unchanged. + * + * The substrate never enumerates consumer automation mode names: a context + * is non-interactive when it carries no recognized interactive signal + * (`interactive => true` or a mode in chat/interactive/rest). Consumer mode + * strings like `pipeline`/`system` are opaque to the substrate and land as + * non-interactive simply because they are not interactive markers. * * Run with: php tests/write-capable-default-smoke.php * @@ -22,8 +29,11 @@ require_once __DIR__ . '/agents-api-smoke-helpers.php'; agents_api_smoke_require_module(); -echo "\n[1] Safe-default contracts are available:\n"; -agents_api_smoke_assert_equals( true, defined( 'WP_Agent_Tool_Policy::NON_INTERACTIVE_MODES' ), 'NON_INTERACTIVE_MODES constant is declared', $failures, $passes ); +echo "\n[1] Safe-default contracts are available and the gate is mode-name neutral:\n"; +agents_api_smoke_assert_equals( false, defined( 'WP_Agent_Tool_Policy::NON_INTERACTIVE_MODES' ), 'NON_INTERACTIVE_MODES enumeration is gone (substrate is mode-name neutral)', $failures, $passes ); +agents_api_smoke_assert_equals( false, defined( 'WP_Agent_Tool_Policy::RUNTIME_PIPELINE' ), 'RUNTIME_PIPELINE consumer-mode constant is gone', $failures, $passes ); +agents_api_smoke_assert_equals( false, defined( 'WP_Agent_Tool_Policy::RUNTIME_SYSTEM' ), 'RUNTIME_SYSTEM consumer-mode constant is gone', $failures, $passes ); +agents_api_smoke_assert_equals( true, defined( 'WP_Agent_Tool_Policy::RUNTIME_CHAT' ), 'RUNTIME_CHAT interactive vocabulary is retained', $failures, $passes ); agents_api_smoke_assert_equals( true, method_exists( 'WP_Agent_Tool_Policy_Filter', 'write_capable_categories' ), 'write_capable_categories method is available', $failures, $passes ); agents_api_smoke_assert_equals( true, method_exists( 'WP_Agent_Tool_Policy_Filter', 'is_write_capable_tool' ), 'is_write_capable_tool method is available', $failures, $passes ); agents_api_smoke_assert_equals( true, method_exists( 'WP_Agent_Tool_Policy_Filter', 'filter_write_capable_by_policy_opt_in' ), 'filter_write_capable_by_policy_opt_in method is available', $failures, $passes ); @@ -66,26 +76,35 @@ ), ); -echo "\n[2] Pipeline mode excludes write-capable tools by default while read tools survive:\n"; +echo "\n[2] An opaque consumer automation mode (e.g. 'pipeline') is non-interactive, so write-capable tools are gated while read tools survive:\n"; $resolved = $resolver->resolve( $tools, array( 'mode' => 'pipeline' ) ); $resolved_names = array_keys( $resolved ); sort( $resolved_names ); agents_api_smoke_assert_equals( array( 'host/mandatory-fetch', 'host/read-meta' ), $resolved_names, - 'pipeline mode excludes every write-capable tool (category or flag) but keeps read and mandatory tools', + 'an opaque consumer mode string excludes 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 in pipeline mode', $failures, $passes ); -agents_api_smoke_assert_equals( false, array_key_exists( 'host/publish-post', $resolved ), 'publishing-category tool is excluded in pipeline mode', $failures, $passes ); -agents_api_smoke_assert_equals( false, array_key_exists( 'host/flagged-write', $resolved ), 'declared write-flag tool is excluded in pipeline mode', $failures, $passes ); -agents_api_smoke_assert_equals( false, array_key_exists( 'host/flagged-mutate', $resolved ), 'declared mutating-flag tool is excluded in pipeline mode', $failures, $passes ); +agents_api_smoke_assert_equals( false, array_key_exists( 'host/write-post', $resolved ), 'write-category tool is excluded in a non-interactive context', $failures, $passes ); +agents_api_smoke_assert_equals( false, array_key_exists( 'host/publish-post', $resolved ), 'publishing-category tool is excluded in a non-interactive context', $failures, $passes ); +agents_api_smoke_assert_equals( false, array_key_exists( 'host/flagged-write', $resolved ), 'declared write-flag tool is excluded in a non-interactive context', $failures, $passes ); +agents_api_smoke_assert_equals( false, array_key_exists( 'host/flagged-mutate', $resolved ), 'declared mutating-flag tool is excluded in a non-interactive context', $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] Chat mode is unchanged — all tools remain visible:\n"; +echo "\n[4] The explicit interactive flag controls the gate, not the mode name:\n"; +$resolved = $resolver->resolve( $tools, array( 'mode' => 'pipeline', 'interactive' => true ) ); +agents_api_smoke_assert_equals( true, array_key_exists( 'host/write-post', $resolved ), 'interactive=true overrides an opaque automation mode and keeps write tools', $failures, $passes ); +agents_api_smoke_assert_equals( true, array_key_exists( 'host/flagged-write', $resolved ), 'interactive=true keeps declared write-flag tools', $failures, $passes ); + +$resolved = $resolver->resolve( $tools, array( 'mode' => 'pipeline', 'interactive' => false ) ); +agents_api_smoke_assert_equals( false, array_key_exists( 'host/write-post', $resolved ), 'interactive=false keeps the gate engaged for an automation mode', $failures, $passes ); +agents_api_smoke_assert_equals( true, array_key_exists( 'host/read-meta', $resolved ), 'read tools still survive with interactive=false', $failures, $passes ); + +echo "\n[5] Recognized interactive modes keep every tool regardless of write classification:\n"; $resolved = $resolver->resolve( $tools, array( 'mode' => 'chat' ) ); $resolved_names = array_keys( $resolved ); sort( $resolved_names ); @@ -97,12 +116,31 @@ $passes ); -echo "\n[5] System mode also applies the safe default:\n"; +// 'interactive' and 'rest' are also substrate-recognized interactive markers. +// Use a tool that declares them in its modes list so the mode filter does not +// obscure the write-gate behavior under test. +$interactive_marker_tools = array( + 'host/write-iv' => array( + 'name' => 'host/write-iv', + 'categories' => array( 'write' ), + 'modes' => array( 'interactive', 'rest' ), + ), +); +$resolved = $resolver->resolve( $interactive_marker_tools, array( 'mode' => 'interactive' ) ); +agents_api_smoke_assert_equals( true, array_key_exists( 'host/write-iv', $resolved ), "'interactive' mode is a recognized interactive marker (write tool kept)", $failures, $passes ); + +$resolved = $resolver->resolve( $interactive_marker_tools, array( 'mode' => 'rest' ) ); +agents_api_smoke_assert_equals( true, array_key_exists( 'host/write-iv', $resolved ), "'rest' mode is a recognized interactive marker (write tool kept)", $failures, $passes ); + +echo "\n[6] Any other opaque consumer mode string lands as non-interactive too:\n"; $resolved = $resolver->resolve( $tools, array( 'mode' => 'system' ) ); -agents_api_smoke_assert_equals( false, array_key_exists( 'host/write-post', $resolved ), 'write-category tool is excluded in system mode', $failures, $passes ); -agents_api_smoke_assert_equals( true, array_key_exists( 'host/read-meta', $resolved ), 'read tool survives in system mode', $failures, $passes ); +agents_api_smoke_assert_equals( false, array_key_exists( 'host/write-post', $resolved ), "opaque 'system' mode is treated as non-interactive", $failures, $passes ); +agents_api_smoke_assert_equals( true, array_key_exists( 'host/read-meta', $resolved ), 'read tool survives in an opaque automation mode', $failures, $passes ); + +$resolved = $resolver->resolve( $tools, array( 'mode' => 'editor' ) ); +agents_api_smoke_assert_equals( false, array_key_exists( 'host/write-post', $resolved ), "an unknown consumer mode like 'editor' is also non-interactive (not in the interactive set)", $failures, $passes ); -echo "\n[6] Explicit opt-in paths restore write tools in pipeline mode:\n"; +echo "\n[7] Explicit opt-in paths restore write tools in a non-interactive context:\n"; $resolved = $resolver->resolve( $tools, array( @@ -142,7 +180,7 @@ ); 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[7] Mandatory tools are preserved regardless of write classification:\n"; +echo "\n[8] Mandatory tools are preserved regardless of write classification:\n"; $resolved = $resolver->resolve( $tools, array( @@ -152,10 +190,10 @@ ), ) ); -agents_api_smoke_assert_equals( true, array_key_exists( 'host/publish-post', $resolved ), 'mandatory_tools preserves a write tool even in pipeline mode', $failures, $passes ); +agents_api_smoke_assert_equals( true, array_key_exists( 'host/publish-post', $resolved ), 'mandatory_tools preserves a write tool even in a non-interactive context', $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[8] allow_write_tools escape hatch disables the safe default:\n"; +echo "\n[9] allow_write_tools escape hatch disables the safe default:\n"; $resolved = $resolver->resolve( $tools, array( @@ -168,7 +206,7 @@ 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 in pipeline mode', + 'context allow_write_tools flag restores ambient write tools in a non-interactive context', $failures, $passes ); @@ -186,7 +224,7 @@ ); 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[9] agents_api_write_capable_categories filter extends the classification set:\n"; +echo "\n[10] agents_api_write_capable_categories filter extends the classification set:\n"; add_filter( 'agents_api_write_capable_categories', static function ( array $categories ): array { @@ -202,12 +240,12 @@ static function ( array $categories ): array { ), ); $resolved = $resolver->resolve( $sensitive_tools, array( 'mode' => 'pipeline' ) ); -agents_api_smoke_assert_equals( false, array_key_exists( 'host/sensitive-op', $resolved ), 'filtered-in write-capable category is gated in pipeline mode', $failures, $passes ); +agents_api_smoke_assert_equals( false, array_key_exists( 'host/sensitive-op', $resolved ), 'filtered-in write-capable category is gated in a non-interactive context', $failures, $passes ); $resolved = $resolver->resolve( $sensitive_tools, array( 'mode' => 'chat' ) ); agents_api_smoke_assert_equals( true, array_key_exists( 'host/sensitive-op', $resolved ), 'filtered-in category is still visible in chat mode', $failures, $passes ); -echo "\n[10] agents_api_resolved_tools final filter still runs after the write gate:\n"; +echo "\n[11] agents_api_resolved_tools final filter still runs after the write gate:\n"; add_filter( 'agents_api_resolved_tools', static function ( array $resolved_tools ) { From 62fb8b977a6c6d2949dea4ad6a3d40f3a3024095 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 6 Jul 2026 03:31:34 +0000 Subject: [PATCH 3/3] refactor: key write-tool curation off principal autonomy, not mode Reposition the write-capable gate to key off the execution principal's autonomy instead of a mode/interactive string. The substrate already models autonomy precisely (WP_Agent_Execution_Principal auth_source / acting_user_id), so a mode string was a weaker proxy. A principal is autonomous when its auth_source is an automation source (system / runtime / agent_token) OR it has no human backing it (acting_user_id === 0). For autonomous principals, write-capable tools remain opt-in (curation); human-backed principals are unchanged. The principal is read from a `principal` entry (instance or array shape) or flat auth_source/acting_user_id on the context root. With no principal information present, the gate falls back safe (autonomous = engages). Drop the INTERACTIVE_MODES mode-marker list and the is_non_interactive_context mode check entirely so the gate names NO consumer modes at all. RUNTIME_CHAT is retained as the generic tool mode-filter default (a substrate-native interactive surface the gate no longer touches). Document the gate explicitly as TOOL-SURFACE CURATION (defense-in-depth), NOT the enforcement boundary. Capability-ceiling enforcement at ability/tool execution is tracked separately in #412. Rewrite tests/write-capable-default-smoke.php to drive the gate via principal autonomy (autonomous principal -> write tools curated out; user/interactive principal -> retained; explicit opt-in -> retained; no-principal -> safe fallback engages; mode string does not move the gate). Update tests/tool-policy-contracts-smoke.php section [5] to model an interactive user principal so the generic runtime-tool opt-in behavior stays in focus without the write curation gate interfering. Part of #412. --- .../class-wp-agent-tool-policy-filter.php | 9 +- src/Tools/class-wp-agent-tool-policy.php | 158 ++++++++++---- tests/tool-policy-contracts-smoke.php | 14 +- tests/write-capable-default-smoke.php | 203 ++++++++++++------ 4 files changed, 273 insertions(+), 111 deletions(-) diff --git a/src/Tools/class-wp-agent-tool-policy-filter.php b/src/Tools/class-wp-agent-tool-policy-filter.php index d0dc792..87cdbe9 100644 --- a/src/Tools/class-wp-agent-tool-policy-filter.php +++ b/src/Tools/class-wp-agent-tool-policy-filter.php @@ -258,9 +258,12 @@ public function is_write_capable_tool( array $tool, array $categories = array() /** * Exclude write-capable tools unless explicitly opted in or preserved. * - * Applied only in non-interactive runtime modes as a safe default. - * Read-only tools, mandatory plumbing, and explicitly opted-in tools - * are unaffected. + * 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. diff --git a/src/Tools/class-wp-agent-tool-policy.php b/src/Tools/class-wp-agent-tool-policy.php index 42deda4..bd9e4b8 100644 --- a/src/Tools/class-wp-agent-tool-policy.php +++ b/src/Tools/class-wp-agent-tool-policy.php @@ -19,29 +19,17 @@ class WP_Agent_Tool_Policy { /** * Canonical interactive runtime mode. * - * The substrate's OWN interactive vocabulary. The tool policy treats - * any context carrying `interactive => true` OR one of the recognized - * interactive mode markers as a human-in-the-loop flow; every other - * value is non-interactive. See {@see INTERACTIVE_MODES}. - */ - public const RUNTIME_CHAT = 'chat'; - - /** - * Substrate-recognized interactive runtime mode markers. - * - * This is the substrate's complete interactive vocabulary. It is - * intentionally NOT a list of automation/non-interactive mode names: - * the substrate is mode-name-neutral and never names consumer - * product modes (pipeline, system, editor, ...). A context is - * non-interactive when it carries no recognized interactive signal, - * so the write-capable safe default applies without the substrate - * having to enumerate the consumer's automation modes. + * 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. * - * Consumers keep their own product-shaped mode vocabulary and map - * onto this axis at the call site, either by setting the explicit - * `interactive` flag or by passing one of these mode markers. + * 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. */ - private const INTERACTIVE_MODES = array( self::RUNTIME_CHAT, 'interactive', 'rest' ); + public const RUNTIME_CHAT = 'chat'; /** * @var WP_Agent_Tool_Policy_Filter @@ -118,7 +106,7 @@ public function resolve( array $tools, array $context = array() ): array { $preserve_tool ); - if ( $this->is_non_interactive_context( $context ) && ! $this->allows_ambient_write_tools( $context, $policies ) ) { + 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'], @@ -268,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. * @@ -342,37 +358,99 @@ private function collect_runtime_tool_opt_in( array $policies, array $context, a } /** - * Whether the runtime context is non-interactive (no human in the loop). + * 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: * - * The substrate models only the interactive-vs-non-interactive axis. - * It never enumerates consumer-specific automation mode names. A - * context is interactive when it sets the explicit `interactive` flag - * to true OR carries a mode the substrate recognizes as interactive - * ({@see INTERACTIVE_MODES}) — mirroring the consent policy's - * interactive detection. Anything else is non-interactive, so the - * write-capable safe default applies. Consumers keep their own - * product-shaped mode vocabulary and map onto this axis at the call - * site. + * - 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 bool Whether the context is non-interactive. + * @return array{auth_source: string, acting_user_id: int, present: bool} Resolved signals. */ - private function is_non_interactive_context( array $context ): bool { - if ( true === ( $context['interactive'] ?? null ) ) { - return false; + 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, + ); } - $mode = strtolower( $this->string_value( $context['mode'] ?? '' ) ); + 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, + ); + } - return '' === $mode || ! in_array( $mode, self::INTERACTIVE_MODES, 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 safe-default gate is skipped entirely + * 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 in a non-interactive mode. + * 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. 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 index 2c6c63c..bd5bfb7 100644 --- a/tests/write-capable-default-smoke.php +++ b/tests/write-capable-default-smoke.php @@ -1,17 +1,21 @@ true` or a mode in chat/interactive/rest). Consumer mode - * strings like `pipeline`/`system` are opaque to the substrate and land as - * non-interactive simply because they are not interactive markers. + * The gate keys off the execution PRINCIPAL'S AUTONOMY — which the substrate + * already models precisely — instead of a mode/interactive string. A + * principal is autonomous when its `auth_source` is an automation source + * (system / runtime / agent_token) OR it has no human backing it + * (`acting_user_id` === 0). For autonomous principals, write-capable tools + * are opt-in rather than ambient. Human-backed principals (interactive user + * sessions) are unchanged. * + * The gate names NO consumer modes: a `mode` string does not move the gate. * Run with: php tests/write-capable-default-smoke.php * * @package AgentsAPI\Tests @@ -29,11 +33,15 @@ require_once __DIR__ . '/agents-api-smoke-helpers.php'; agents_api_smoke_require_module(); -echo "\n[1] Safe-default contracts are available and the gate is mode-name neutral:\n"; +$principal_class = 'AgentsAPI\AI\WP_Agent_Execution_Principal'; + +echo "\n[1] Curation contracts are available and the gate names no consumer modes:\n"; agents_api_smoke_assert_equals( false, defined( 'WP_Agent_Tool_Policy::NON_INTERACTIVE_MODES' ), 'NON_INTERACTIVE_MODES enumeration is gone (substrate is mode-name neutral)', $failures, $passes ); +agents_api_smoke_assert_equals( false, defined( 'WP_Agent_Tool_Policy::INTERACTIVE_MODES' ), 'INTERACTIVE_MODES mode-marker list is gone from the write gate', $failures, $passes ); agents_api_smoke_assert_equals( false, defined( 'WP_Agent_Tool_Policy::RUNTIME_PIPELINE' ), 'RUNTIME_PIPELINE consumer-mode constant is gone', $failures, $passes ); agents_api_smoke_assert_equals( false, defined( 'WP_Agent_Tool_Policy::RUNTIME_SYSTEM' ), 'RUNTIME_SYSTEM consumer-mode constant is gone', $failures, $passes ); -agents_api_smoke_assert_equals( true, defined( 'WP_Agent_Tool_Policy::RUNTIME_CHAT' ), 'RUNTIME_CHAT interactive vocabulary is retained', $failures, $passes ); +agents_api_smoke_assert_equals( true, defined( 'WP_Agent_Tool_Policy::RUNTIME_CHAT' ), 'RUNTIME_CHAT is retained as the generic tool mode-filter default', $failures, $passes ); +agents_api_smoke_assert_equals( true, method_exists( 'WP_Agent_Tool_Policy', 'resolve' ), 'WP_Agent_Tool_Policy resolver is available', $failures, $passes ); agents_api_smoke_assert_equals( true, method_exists( 'WP_Agent_Tool_Policy_Filter', 'write_capable_categories' ), 'write_capable_categories method is available', $failures, $passes ); agents_api_smoke_assert_equals( true, method_exists( 'WP_Agent_Tool_Policy_Filter', 'is_write_capable_tool' ), 'is_write_capable_tool method is available', $failures, $passes ); agents_api_smoke_assert_equals( true, method_exists( 'WP_Agent_Tool_Policy_Filter', 'filter_write_capable_by_policy_opt_in' ), 'filter_write_capable_by_policy_opt_in method is available', $failures, $passes ); @@ -76,75 +84,136 @@ ), ); -echo "\n[2] An opaque consumer automation mode (e.g. 'pipeline') is non-interactive, so write-capable tools are gated while read tools survive:\n"; -$resolved = $resolver->resolve( $tools, array( 'mode' => 'pipeline' ) ); +// 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 opaque consumer mode string excludes every write-capable tool (category or flag) but keeps read and mandatory tools', + '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 in a non-interactive context', $failures, $passes ); -agents_api_smoke_assert_equals( false, array_key_exists( 'host/publish-post', $resolved ), 'publishing-category tool is excluded in a non-interactive context', $failures, $passes ); -agents_api_smoke_assert_equals( false, array_key_exists( 'host/flagged-write', $resolved ), 'declared write-flag tool is excluded in a non-interactive context', $failures, $passes ); -agents_api_smoke_assert_equals( false, array_key_exists( 'host/flagged-mutate', $resolved ), 'declared mutating-flag tool is excluded in a non-interactive context', $failures, $passes ); +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] The explicit interactive flag controls the gate, not the mode name:\n"; -$resolved = $resolver->resolve( $tools, array( 'mode' => 'pipeline', 'interactive' => true ) ); -agents_api_smoke_assert_equals( true, array_key_exists( 'host/write-post', $resolved ), 'interactive=true overrides an opaque automation mode and keeps write tools', $failures, $passes ); -agents_api_smoke_assert_equals( true, array_key_exists( 'host/flagged-write', $resolved ), 'interactive=true keeps declared write-flag tools', $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( 'mode' => 'pipeline', 'interactive' => false ) ); -agents_api_smoke_assert_equals( false, array_key_exists( 'host/write-post', $resolved ), 'interactive=false keeps the gate engaged for an automation mode', $failures, $passes ); -agents_api_smoke_assert_equals( true, array_key_exists( 'host/read-meta', $resolved ), 'read tools still survive with interactive=false', $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] Recognized interactive modes keep every tool regardless of write classification:\n"; -$resolved = $resolver->resolve( $tools, array( 'mode' => 'chat' ) ); +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, - 'chat mode keeps every tool regardless of write classification', + 'a user-session principal keeps every tool regardless of write classification', $failures, $passes ); -// 'interactive' and 'rest' are also substrate-recognized interactive markers. -// Use a tool that declares them in its modes list so the mode filter does not -// obscure the write-gate behavior under test. -$interactive_marker_tools = array( - 'host/write-iv' => array( - 'name' => 'host/write-iv', - 'categories' => array( 'write' ), - 'modes' => array( 'interactive', 'rest' ), - ), +$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', + ) ); -$resolved = $resolver->resolve( $interactive_marker_tools, array( 'mode' => 'interactive' ) ); -agents_api_smoke_assert_equals( true, array_key_exists( 'host/write-iv', $resolved ), "'interactive' mode is a recognized interactive marker (write tool kept)", $failures, $passes ); +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 ); -$resolved = $resolver->resolve( $interactive_marker_tools, array( 'mode' => 'rest' ) ); -agents_api_smoke_assert_equals( true, array_key_exists( 'host/write-iv', $resolved ), "'rest' mode is a recognized interactive marker (write tool kept)", $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 ); -echo "\n[6] Any other opaque consumer mode string lands as non-interactive too:\n"; -$resolved = $resolver->resolve( $tools, array( 'mode' => 'system' ) ); -agents_api_smoke_assert_equals( false, array_key_exists( 'host/write-post', $resolved ), "opaque 'system' mode is treated as non-interactive", $failures, $passes ); -agents_api_smoke_assert_equals( true, array_key_exists( 'host/read-meta', $resolved ), 'read tool survives in an opaque automation mode', $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( 'mode' => 'editor' ) ); -agents_api_smoke_assert_equals( false, array_key_exists( 'host/write-post', $resolved ), "an unknown consumer mode like 'editor' is also non-interactive (not in the interactive set)", $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[7] Explicit opt-in paths restore write tools in a non-interactive context:\n"; +echo "\n[9] Explicit opt-in paths restore write tools for an autonomous principal:\n"; $resolved = $resolver->resolve( $tools, array( - 'mode' => 'pipeline', + 'principal' => $autonomous_runtime, 'allow_only' => array( 'host/write-post' ), ) ); @@ -153,7 +222,7 @@ $resolved = $resolver->resolve( $tools, array( - 'mode' => 'pipeline', + 'principal' => $autonomous_runtime, 'runtime_categories' => array( 'publishing' ), ) ); @@ -162,7 +231,7 @@ $resolved = $resolver->resolve( $tools, array( - 'mode' => 'pipeline', + 'principal' => $autonomous_runtime, 'tool_policy' => array( 'mode' => 'allow', 'tools' => array( 'host/write-post' ), @@ -174,30 +243,30 @@ $resolved = $resolver->resolve( $tools, array( - 'mode' => 'pipeline', - 'runtime_tools' => array( 'host/flagged-write' ), + '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[8] Mandatory tools are preserved regardless of write classification:\n"; +echo "\n[10] Mandatory tools are preserved regardless of write classification:\n"; $resolved = $resolver->resolve( $tools, array( - 'mode' => 'pipeline', + '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 in a non-interactive context', $failures, $passes ); +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[9] allow_write_tools escape hatch disables the safe default:\n"; +echo "\n[11] allow_write_tools escape hatch disables the curation gate:\n"; $resolved = $resolver->resolve( $tools, array( - 'mode' => 'pipeline', + 'principal' => $autonomous_runtime, 'allow_write_tools' => true, ) ); @@ -206,7 +275,7 @@ 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 in a non-interactive context', + 'context allow_write_tools flag restores ambient write tools for an autonomous principal', $failures, $passes ); @@ -214,7 +283,7 @@ $resolved = $resolver->resolve( $tools, array( - 'mode' => 'pipeline', + 'principal' => $autonomous_runtime, 'agent_config' => array( 'tool_policy' => array( 'allow_write_tools' => true, @@ -224,7 +293,7 @@ ); 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[10] agents_api_write_capable_categories filter extends the classification set:\n"; +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 { @@ -239,13 +308,13 @@ static function ( array $categories ): array { 'modes' => array( 'chat', 'pipeline' ), ), ); -$resolved = $resolver->resolve( $sensitive_tools, array( 'mode' => 'pipeline' ) ); -agents_api_smoke_assert_equals( false, array_key_exists( 'host/sensitive-op', $resolved ), 'filtered-in write-capable category is gated in a non-interactive context', $failures, $passes ); +$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( 'mode' => 'chat' ) ); -agents_api_smoke_assert_equals( true, array_key_exists( 'host/sensitive-op', $resolved ), 'filtered-in category is still visible in chat mode', $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[11] agents_api_resolved_tools final filter still runs after the write gate:\n"; +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 ) { @@ -253,7 +322,7 @@ static function ( array $resolved_tools ) { return $resolved_tools; } ); -$resolved = $resolver->resolve( array(), array( 'mode' => 'pipeline' ) ); +$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 );