PluginProbe
ZIP AI – AI Website Builder & AI Agent / 0.0.10
ZIP AI – AI Website Builder & AI Agent v0.0.10
0.0.14 0.0.13 0.0.12 0.0.11 0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / inc / core / external-tool-policy.php

external-tool-policy.php in ZIP AI – AI Website Builder & AI Agent 0.0.10, at inc/core/external-tool-policy.php

150 lines 5.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * External tool policy — which abilities a third-party AI client may reach, and
4 * how each one is annotated.
5 *
6 * Decides which of Era's abilities an external AI client can reach, and which
7 * are advertised as first-class MCP tools rather than reached by name through the
8 * adapter's dispatcher.
9 *
10 * There is deliberately NO denylist. The caller authenticated with an Application
11 * Password a real WP user created, every ability still enforces its own
12 * capability, and MCP annotations tell the client what to confirm first.
13 * Withholding `run-wp-cli` or `run-snippet` from an administrator who can
14 * already run both from wp-admin buys nothing and breaks the agent-as-operator
15 * workflow. A site that wants a narrower surface uses the
16 * `zip_ai_external_allowed_abilities` filter — enforced in {@see self::is_exposed()},
17 * which is what actually flags an ability reachable.
18 *
19 * Reachability is the adapter's own `meta.mcp.public` flag, set per ability by
20 * the loader from this policy. The flag is read by EVERY adapter server on the
21 * site, not just ours — if another plugin stands up a server with the
22 * dispatcher trio, our public abilities are dispatchable there too. Accepted:
23 * each ability's own capability is the real floor, and `zipai/run-rest-request`
24 * dispatches under the caller's own REST permissions. The flag is only set
25 * while the Connection toggle is ON, so switching off closes every server at
26 * once.
27 *
28 * @since 0.0.8
29 * @package zip-ai
30 */
31
32 namespace ZipAI\MCP\Classes\Core;
33
34 defined( 'ABSPATH' ) || exit;
35
36 /**
37 * Decides the external AI-client tool surface.
38 */
39 class External_Tool_Policy {
40
41 /**
42 * Namespaces an external client may reach at all. Only `zipai` — the
43 * abilities this plugin registers, and therefore the only ones whose meta we
44 * set. `core/*` and other plugins' abilities are theirs to expose on their own
45 * terms; we neither flag nor block them.
46 */
47 const ALLOWED_NAMESPACES = array( 'zipai' );
48
49 /**
50 * Advertised as first-class MCP tools (full schema at connect time).
51 *
52 * The first three are the MCP Adapter's OWN dispatcher — discovery plus
53 * execute-by-name. Reused rather than reimplemented: they already resolve
54 * abilities, enforce each target's `permission_callback`, and gate on
55 * `meta.mcp.public`, which is exactly the switch this class owns per ability.
56 * An agent therefore reaches the whole allowed surface for the cost of three
57 * schemas instead of ~20.
58 *
59 * The import door stays advertised on top, deliberately: the write path must
60 * be the obvious thing to call, never something an agent discovers its way
61 * around into raw REST.
62 */
63 const ADVERTISED = array(
64 'mcp-adapter/discover-abilities',
65 'mcp-adapter/get-ability-info',
66 'mcp-adapter/execute-ability',
67 'zipai/get-site-context',
68 'zipai/import-html',
69 );
70
71 /**
72 * Every ability an external client may reach, advertised or dispatched.
73 *
74 * Derived from the live registry rather than hardcoded, so a newly added
75 * ability is reachable without editing this file — while the namespace
76 * bound still applies. Display surface only; execution is gated per ability
77 * by {@see self::is_exposed()}, which applies the same filter.
78 *
79 * @return string[] Ability ids, sorted.
80 */
81 public static function allowed() {
82 if ( ! class_exists( '\WP_Abilities_Registry' ) ) {
83 return array();
84 }
85 $registry = \WP_Abilities_Registry::get_instance();
86 if ( ! $registry instanceof \WP_Abilities_Registry ) {
87 return array();
88 }
89
90 $allowed = array();
91 foreach ( $registry->get_all_registered() as $name => $ability ) {
92 $id = $ability->get_name() ? $ability->get_name() : (string) $name;
93 if ( self::is_allowed( $id ) ) {
94 $allowed[] = $id;
95 }
96 }
97 sort( $allowed );
98
99 /**
100 * Filter the abilities an external AI client may reach.
101 *
102 * Use this to LOCK DOWN a site — return a narrower list to withhold
103 * abilities from connected agents. The namespace bound is re-applied
104 * afterwards, so a filter cannot widen the surface past Era's own
105 * abilities. Treat it as a membership question: it may be handed the
106 * full list (here) or a single-ability list ({@see self::is_exposed()}),
107 * so filter the ids you are given rather than inspecting the set.
108 *
109 * @param string[] $allowed Ability ids.
110 */
111 $filtered = apply_filters( 'zip_ai_external_allowed_abilities', $allowed );
112 $filtered = array_filter( (array) $filtered, 'is_string' );
113
114 return array_values( array_filter( $filtered, array( self::class, 'is_allowed' ) ) );
115 }
116
117 /**
118 * Whether one ability id is reachable externally.
119 *
120 * @param string $id Ability id.
121 * @return bool
122 */
123 public static function is_allowed( string $id ) {
124 $namespace = strtok( $id, '/' );
125
126 return in_array( $namespace, self::ALLOWED_NAMESPACES, true );
127 }
128
129 /**
130 * Whether one ability may be flagged `meta.mcp.public` — the check that
131 * governs EXECUTION, not just the displayed list. The namespace bound and
132 * the site's `zip_ai_external_allowed_abilities` lockdown filter both
133 * apply; without the filter here, a site narrowing its surface would trim
134 * the Connection screen while every ability stayed dispatchable.
135 *
136 * @param string $id Ability id.
137 * @return bool
138 */
139 public static function is_exposed( string $id ) {
140 if ( ! self::is_allowed( $id ) ) {
141 return false;
142 }
143
144 /** This filter is documented on {@see self::allowed()}. */
145 $filtered = array_filter( (array) apply_filters( 'zip_ai_external_allowed_abilities', array( $id ) ), 'is_string' );
146
147 return in_array( $id, $filtered, true );
148 }
149 }
150