PluginProbe
ZIP AI – AI Website Builder & AI Agent (Beta) / 0.0.8
ZIP AI – AI Website Builder & AI Agent (Beta) v0.0.8
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 / abilities / core / security-verifier-trait.php

security-verifier-trait.php in ZIP AI – AI Website Builder & AI Agent (Beta) 0.0.8, at inc/abilities/core/security-verifier-trait.php

359 lines 15.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Security Verifier Trait — a denylist. It blocks commands that violate
4 * the WordPress.org Plugin Guidelines. It also blocks commands that
5 * destroy block-editor content.
6 *
7 * The policy was moved out of RunWpCli into one focused file. The trait is
8 * a pure function on a parsed args array. It has no side effects. It does
9 * not depend on RunWpCli state. It calls `RunWpCli::parse_flags()` on
10 * purpose. The security decision must read the slots that execute. It must
11 * not run a second parse of its own (DSA-16, see `candidate_positionals()`).
12 *
13 * @package zip-ai
14 */
15
16 namespace ZipAI\MCP\Classes\Abilities\Core;
17
18 defined( 'ABSPATH' ) || exit;
19
20 use ZipAI\MCP\Classes\Security\Protected_Options_Filter;
21
22 /**
23 * Trait holding `verify_command_security`.
24 */
25 trait Security_Verifier_Trait {
26
27 /**
28 * Block commands that violate WordPress.org Plugin Guidelines or would
29 * destroy block-editor content.
30 *
31 * @param string[] $args Parsed command tokens.
32 * @return true|\WP_Error
33 */
34 private function verify_command_security( array $args ) {
35 // The native dispatcher does not interpret shell operators. The
36 // tokeniser treats them as ordinary tokens. So a chained command like
37 // `option update foo bar && option update baz qux` would dispatch only
38 // the first sub-command. It would drop the rest. Reject it upfront. So
39 // the model learns to issue one command per call.
40 $shell_operators = array( '&&', '||', ';' );
41 foreach ( $args as $arg ) {
42 if ( in_array( $arg, $shell_operators, true ) ) {
43 return new \WP_Error(
44 'security_blocked',
45 sprintf(
46 'Security policy: shell operator "%s" is not supported — run one command per call.',
47 $arg
48 )
49 );
50 }
51 }
52
53 // Every rule keys off positional slots. So a rule only holds when the
54 // verifier's word split matches the split that EXECUTES. Run the rules
55 // against every parse that could execute. Refuse the command if ANY
56 // rule trips. See `candidate_positionals()` for why there is more than
57 // one split.
58 foreach ( $this->candidate_positionals( $args ) as $positional ) {
59 $verdict = $this->verify_positional_rules( $args, $positional );
60 if ( is_wp_error( $verdict ) ) {
61 return $verdict;
62 }
63 }
64
65 return true;
66 }
67
68 /**
69 * Every word split this command could execute under, deduped.
70 *
71 * Two parsers can run the tokens this ability accepts. They disagree:
72 *
73 * • `parse_flags()` (`dispatch_native`) reads the NEXT token as the
74 * value of about 60 known keys (`format`, `search`, `fields`, `role`,
75 * …). It treats a single-dash `-x` as a positional.
76 * • The WP-CLI runtime (`run_via_wpcli_api`) binds values only with `=`.
77 * So every `-`-prefixed token is a standalone flag. The ability uses
78 * this runtime when a wp-cli process invokes it.
79 *
80 * DSA-16 came from picking ONE parser. The verifier stripped flag tokens
81 * but not their values. So `--format json user add-cap 5 manage_options`
82 * read as base `json`. No rule fired. But the dispatcher ran `user
83 * add-cap` and granted the capability. Picking the other parser only moves
84 * the hole. `--search db query "DROP …"` hides `db query` from
85 * `parse_flags`. It then runs under the WP-CLI runtime.
86 *
87 * So the code picks neither. It evaluates both. A rule that trips under
88 * either split refuses the command. This is fail-closed on ambiguity. It
89 * does not over-reject. An unambiguous command makes one identical split.
90 * That is the common case, deduped here. The valid space-form flag
91 * (`plugin list --format json`) stays allowed. No rule trips under either
92 * reading of it.
93 *
94 * Scope: these are the two FLAG-BINDING readings of the tokens
95 * `parse_command_to_args()` made. They do not model the WP-CLI TOKENIZER.
96 * That tokenizer re-splits the raw string on the passthrough path. It
97 * differs at the quoting and escaping margins. It honours `\` inside double
98 * quotes and nowhere else. It does not treat a newline as a separator. That
99 * gap is pre-existing. It is not a known bypass. It is the reason this
100 * docblock says "flag binding" and not "every possible split".
101 *
102 * @param string[] $args Parsed command tokens.
103 * @return array<int,string[]> One or two lowercased positional lists.
104 */
105 private function candidate_positionals( array $args ) {
106 list( $executor_tokens ) = $this->parse_flags( $args );
107 $executor = array_map( 'strtolower', $executor_tokens );
108
109 // The WP-CLI runtime split. Every `-`-prefixed token is a flag. It
110 // binds no following value.
111 $runtime = array();
112 foreach ( $args as $arg ) {
113 if ( ! str_starts_with( $arg, '-' ) ) {
114 $runtime[] = strtolower( $arg );
115 }
116 }
117
118 return $executor === $runtime ? array( $executor ) : array( $executor, $runtime );
119 }
120
121 /**
122 * The denylist itself, evaluated against one candidate word split.
123 *
124 * @param string[] $args Parsed command tokens (flag scans read these).
125 * @param string[] $positional Lowercased positional slots for this candidate.
126 * @return true|\WP_Error
127 */
128 private function verify_positional_rules( array $args, array $positional ) {
129 $base_command = $positional[0] ?? '';
130 $sub_command = $positional[1] ?? '';
131
132 // 1. Arbitrary code execution & shell access are forbidden.
133 $forbidden_commands = array( 'eval', 'eval-file', 'shell', 'package', 'server' );
134 if ( in_array( $base_command, $forbidden_commands, true ) ) {
135 return new \WP_Error( 'security_blocked', sprintf( 'Security policy: WP-CLI "%s" command is blocked.', $base_command ) );
136 }
137
138 // 2. Direct SQL is blocked (SQLi prevention).
139 if ( 'db' === $base_command && in_array( $sub_command, array( 'query', 'import', 'drop', 'reset' ), true ) ) {
140 return new \WP_Error( 'security_blocked', sprintf( 'Security policy: WP-CLI "db %s" is blocked.', $sub_command ) );
141 }
142
143 // 2a. `option add` and `option patch` remain blocked.
144 // `add` is rarely useful — `update` is idempotent (creates the option
145 // if missing). `patch` performs array-key surgery on serialized
146 // options, which is a sharp edge that's almost never the right path
147 // for an AI agent. `update` + `delete` cover the legitimate write
148 // surface; the keys neither may touch are rule 2a-bis below.
149 if ( 'option' === $base_command && in_array( $sub_command, array( 'add', 'patch' ), true ) ) {
150 return new \WP_Error(
151 'security_blocked',
152 sprintf( 'Security policy: WP-CLI "option %s" is blocked. Use "option update" instead (it creates the option when missing).', $sub_command )
153 );
154 }
155
156 // 2a-bis. Protected option keys (DSA-17). This sits in the VERIFIER, not
157 // only in `handle_option_update` / `handle_option_delete`, because
158 // `execute()` has two executors and the handlers are reachable from just
159 // one of them: when the ability runs inside a wp-cli process,
160 // `run_via_wpcli_api()` hands the raw string to WP-CLI and never touches
161 // `dispatch_native`. A guard here covers both, and inherits the
162 // two-candidate parse above for free.
163 //
164 // The handler checks stay as defense-in-depth — same
165 // `is_write_protected()` call, so there is still one key list — and they
166 // are what produce the precise error instead of a silent filter no-op.
167 if ( 'option' === $base_command && in_array( $sub_command, array( 'update', 'delete' ), true ) ) {
168 $option_key = $positional[2] ?? '';
169 if ( '' !== $option_key && Protected_Options_Filter::is_write_protected( $option_key ) ) {
170 return new \WP_Error(
171 'security_blocked',
172 sprintf(
173 'Security policy: option "%s" is protected and cannot be modified via the AI agent. Edit manually in wp-admin → Settings, or use the dedicated ability for it (theme activate / plugin activate).',
174 $option_key
175 )
176 );
177 }
178 }
179
180 // 2b. User creation/deletion and account mutation are blocked.
181 // WordPress account management has reauth, email-confirmation,
182 // password, and security-plugin hooks that generic automation must
183 // not bypass. Read-only user discovery remains available.
184 if ( 'user' === $base_command && in_array( $sub_command, array( 'create', 'update', 'delete' ), true ) ) {
185 return new \WP_Error(
186 'security_blocked',
187 sprintf( 'Security policy: WP-CLI "user %s" is blocked. Manage users from WordPress admin or a dedicated, reviewed flow.', $sub_command )
188 );
189 }
190
191 if ( 'user' === $base_command && 'meta' === $sub_command ) {
192 $meta_verb = $positional[2] ?? '';
193 if ( in_array( $meta_verb, array( 'add', 'update', 'set', 'delete' ), true ) ) {
194 return new \WP_Error(
195 'security_blocked',
196 sprintf( 'Security policy: WP-CLI "user meta %s" is blocked. User metadata writes can affect authentication and capabilities.', $meta_verb )
197 );
198 }
199 }
200
201 // 2b-bis. Block administrator-promotion / lockout via role and cap
202 // commands. Non-admin role changes (e.g. add-role <id> editor) are
203 // allowed through to dispatch_native; only grants/removals of admin-
204 // class roles and capabilities are refused here.
205 if ( 'user' === $base_command
206 && in_array( $sub_command, array( 'add-role', 'remove-role', 'set-role' ), true ) ) {
207 $role_arg = strtolower( $positional[3] ?? '' );
208 $high_risk_roles = array( 'administrator', 'super-admin' );
209 if ( '' !== $role_arg && in_array( $role_arg, $high_risk_roles, true ) ) {
210 return new \WP_Error(
211 'security_blocked',
212 sprintf(
213 'Security policy: `wp user %s <id> %s` is blocked. Promoting or demoting administrators must be done from wp-admin → Users.',
214 $sub_command,
215 $role_arg
216 )
217 );
218 }
219 }
220
221 if ( 'user' === $base_command
222 && in_array( $sub_command, array( 'add-cap', 'remove-cap' ), true ) ) {
223 $cap_arg = strtolower( $positional[3] ?? '' );
224 // Admin-class capabilities — granting any of these elevates a
225 // user to admin in practice; removing manage_options from the
226 // only admin can lock out site access.
227 $admin_caps = array(
228 'manage_options',
229 'install_plugins',
230 'activate_plugins',
231 'delete_plugins',
232 'edit_plugins',
233 'install_themes',
234 'switch_themes',
235 'edit_themes',
236 'delete_themes',
237 'unfiltered_html',
238 'create_users',
239 'delete_users',
240 'edit_users',
241 'promote_users',
242 'manage_network',
243 'manage_sites',
244 );
245 if ( '' !== $cap_arg && in_array( $cap_arg, $admin_caps, true ) ) {
246 return new \WP_Error(
247 'security_blocked',
248 sprintf(
249 'Security policy: `wp user %s <id> %s` is blocked. "%s" is an admin-class capability — manage it from wp-admin → Users.',
250 $sub_command,
251 $cap_arg,
252 $cap_arg
253 )
254 );
255 }
256 }
257
258 // 2c. `wp post|comment meta add/update/set/delete/patch` is blocked.
259 // Caller-supplied object id + caller-supplied meta key is a generic
260 // write primitive equivalent to `option update` — it can target
261 // `_edit_lock`, `_thumbnail_id`, theme/plugin private meta, or any
262 // custom field. Specific edits route through dedicated abilities.
263 if ( in_array( $base_command, array( 'post', 'comment' ), true ) && 'meta' === $sub_command ) {
264 $meta_verb = $positional[2] ?? '';
265 if ( in_array( $meta_verb, array( 'add', 'update', 'set', 'delete', 'patch' ), true ) ) {
266 return new \WP_Error(
267 'security_blocked',
268 sprintf( 'Security policy: WP-CLI "%s meta %s" is blocked. Use a dedicated ability for the specific meta key you need to modify.', $base_command, $meta_verb )
269 );
270 }
271 }
272
273 // 2d. `wp transient set` and `transient patch` are always blocked —
274 // caller-supplied key + value is a generic write primitive (same
275 // shape as `option update`) that can clobber internal WP transients
276 // like `update_plugins`, `update_themes`, session caches, etc.
277 if ( 'transient' === $base_command && in_array( $sub_command, array( 'set', 'patch' ), true ) ) {
278 return new \WP_Error(
279 'security_blocked',
280 sprintf( 'Security policy: WP-CLI "transient %s" is blocked.', $sub_command )
281 );
282 }
283 // `wp transient delete` is conditional:
284 // - `wp transient delete --expired` ALLOW (safe bulk-cleanup)
285 // - `wp transient delete --all` ALLOW (destructive cache flush, approval-gated)
286 // - `wp transient delete <key>` BLOCK (specific-key deletion can nuke
287 // `update_plugins` and break the WP update system)
288 if ( 'transient' === $base_command && 'delete' === $sub_command ) {
289 $has_expired_flag = false;
290 $has_all_flag = false;
291 $has_positional = false;
292 foreach ( $args as $arg ) {
293 $arg_lower = strtolower( $arg );
294 if ( '--expired' === $arg_lower || str_starts_with( $arg_lower, '--expired=' ) ) {
295 $has_expired_flag = true;
296 continue;
297 }
298 if ( '--all' === $arg_lower || str_starts_with( $arg_lower, '--all=' ) ) {
299 $has_all_flag = true;
300 continue;
301 }
302 if ( ! str_starts_with( $arg, '-' )
303 && 'transient' !== strtolower( $arg )
304 && 'delete' !== strtolower( $arg ) ) {
305 $has_positional = true;
306 }
307 }
308 if ( $has_positional ) {
309 return new \WP_Error(
310 'security_blocked',
311 'Security policy: WP-CLI "transient delete <key>" is blocked. Use "transient delete --expired" (safe bulk cleanup) or "transient delete --all" (full cache flush, approval required).'
312 );
313 }
314 if ( ! $has_expired_flag && ! $has_all_flag ) {
315 return new \WP_Error(
316 'security_blocked',
317 'Security policy: WP-CLI "transient delete" requires either --expired or --all. Specific-key deletion is blocked.'
318 );
319 }
320 }
321
322 // 3. Honor DISALLOW_FILE_MODS for install/update/delete on plugin/theme/core.
323 $modifying_commands = array( 'plugin', 'theme', 'core' );
324 $modifying_subs = array( 'install', 'update', 'delete' );
325 if ( in_array( $base_command, $modifying_commands, true ) && in_array( $sub_command, $modifying_subs, true ) ) {
326 if ( ! wp_is_file_mod_allowed( 'zipai_run_wp_cli_file_mods' ) ) {
327 return new \WP_Error( 'security_blocked', 'Security policy: file modifications are disabled on this site (DISALLOW_FILE_MODS or the file_mod_allowed filter).' );
328 }
329 }
330
331 // 4. Block code-execution and path-traversal flags.
332 $forbidden_flags = array( '--require', '--exec', '--ssh', '--config', '--path', '--prompt' );
333 foreach ( $args as $arg ) {
334 $arg_lower = strtolower( $arg );
335 foreach ( $forbidden_flags as $flag ) {
336 if ( $arg_lower === $flag || str_starts_with( $arg_lower, $flag . '=' ) ) {
337 return new \WP_Error( 'security_blocked', sprintf( 'Security policy: WP-CLI flag "%s" is blocked.', $flag ) );
338 }
339 }
340 }
341
342 // 5. Block post_content replacement — it destroys block markup.
343 if ( 'post' === $base_command && in_array( $sub_command, array( 'update', 'create' ), true ) ) {
344 foreach ( $args as $arg ) {
345 if ( str_starts_with( strtolower( $arg ), '--post_content' ) ) {
346 return new \WP_Error(
347 'security_blocked',
348 sprintf( 'Security policy: "wp post %s --post_content=" is blocked. It replaces the entire post_content with a plain string, destroying all block markup. ', $sub_command )
349 . 'For block edits, use editor__apply_change with the page open in the block editor. '
350 . 'There is no server-side path for editing page content without the editor — ask the user to open the page in the block editor.'
351 );
352 }
353 }
354 }
355
356 return true;
357 }
358 }
359