array( 'content_hash', 'modified_gmt', 'revision_id' ), 'wp.posts.delete' => array( 'content_hash', 'modified_gmt', 'revision_id' ), 'wp.templates.update' => array( 'content_hash' ), 'wp.templates.delete' => array( 'content_hash' ), 'wp.template_parts.update' => array( 'content_hash' ), 'wp.template_parts.delete' => array( 'content_hash' ), ); /** * Map of grammar capability name to its handler. * * @var array */ private array $handlers; private CommandPolicy $policy; private bool $dev_mode = false; /** * Create the runtime. * * @param PostsGetCapability|null $posts_get wp.posts.get handler. * @param PostsQueryCapability|null $posts_query wp.posts.query handler. * @param SchemaGetCapability|null $schema_get wp.schema.get handler. * @param PostsWriteCapability|null $posts_create wp.posts.create handler. * @param PostsWriteCapability|null $posts_update wp.posts.update handler. * @param PostsWriteCapability|null $posts_delete wp.posts.delete handler. * @param CommandPolicy|null $policy Command policy. */ public function __construct( ?PostsGetCapability $posts_get = null, ?PostsQueryCapability $posts_query = null, ?SchemaGetCapability $schema_get = null, ?PostsWriteCapability $posts_create = null, ?PostsWriteCapability $posts_update = null, ?PostsWriteCapability $posts_delete = null, ?CommandPolicy $policy = null ) { $this->policy = $policy ?? new CommandPolicy(); $this->handlers = array( PostsGetCapability::NAME => $posts_get ?? new PostsGetCapability(), PostsQueryCapability::NAME => $posts_query ?? new PostsQueryCapability(), SchemaGetCapability::NAME => $schema_get ?? new SchemaGetCapability( $this->policy ), PostsWriteCapability::CREATE_NAME => $posts_create ?? new PostsWriteCapability( PostsWriteCapability::CREATE_NAME ), PostsWriteCapability::UPDATE_NAME => $posts_update ?? new PostsWriteCapability( PostsWriteCapability::UPDATE_NAME ), PostsWriteCapability::DELETE_NAME => $posts_delete ?? new PostsWriteCapability( PostsWriteCapability::DELETE_NAME ), PostsCloneCapability::NAME => new PostsCloneCapability(), PostTypesWriteCapability::CREATE_NAME => new PostTypesWriteCapability( PostTypesWriteCapability::CREATE_NAME ), PostTypesWriteCapability::UPDATE_NAME => new PostTypesWriteCapability( PostTypesWriteCapability::UPDATE_NAME ), ); foreach ( array_keys( RegistryReadCapability::CAPABILITIES ) as $capability ) { $this->handlers[ $capability ] = new RegistryReadCapability( $capability ); } foreach ( array_keys( CoreContentCapability::CAPABILITIES ) as $capability ) { $this->handlers[ $capability ] = new CoreContentCapability( $capability ); } foreach ( array_keys( MenusCapability::CAPABILITIES ) as $capability ) { $this->handlers[ $capability ] = new MenusCapability( $capability ); } foreach ( array_keys( ThemeIdentityCapability::CAPABILITIES ) as $capability ) { $this->handlers[ $capability ] = new ThemeIdentityCapability( $capability ); } foreach ( array_keys( SpecialsMaintenanceCapability::CAPABILITIES ) as $capability ) { $this->handlers[ $capability ] = new SpecialsMaintenanceCapability( $capability ); } $this->handlers[ SeoWriteCapability::NAME ] = new SeoWriteCapability(); } /** * Return the new-grammar capability names this runtime executes. * * @return array */ public function catalog(): array { $catalog = array(); foreach ( array_keys( $this->handlers ) as $capability ) { if ( $this->policy->capability_enabled_for_discovery( $capability, $this->dev_mode ) ) { $catalog[] = $capability; } } return $catalog; } /** * Return whether a capability routes through the new grammar runtime. * * @param string $capability Capability name. */ public function handles( string $capability ): bool { return isset( $this->handlers[ $capability ] ); } /** * Register a capability handler from an optional module. * * @param string $capability Capability name. * @param object $handler Handler exposing run( array $input, string $phase ). */ public function register_handler( string $capability, object $handler ): void { $this->handlers[ $capability ] = $handler; } /** * Mark whether the optional internal dev module is active. * * Propagates the flag to any handler that opts into it (e.g. capabilities * whose read-disclosure policy is relaxed for internal builds), so the * read-access boundary stays in one place rather than per-dispatch. */ public function set_dev_mode( bool $enabled ): void { $this->dev_mode = $enabled; foreach ( $this->handlers as $handler ) { if ( is_callable( array( $handler, 'set_dev_mode' ) ) ) { $handler->set_dev_mode( $enabled ); } } } /** * Return whether the optional internal dev module is active. */ public function is_dev_mode(): bool { return $this->dev_mode; } /** * Build the flat-shape discovery block for new-grammar capabilities. * * Names only, plus the interface version and the coarse fingerprints used * by Backend for lazy cache invalidation. No phases, contract IDs, or * per-capability versions. */ public function discovery(): array { $catalog = $this->catalog(); return array( 'interface_version' => self::INTERFACE_VERSION, 'capabilities' => $catalog, 'site_shape' => $this->site_shape(), 'settings_rev' => $this->settings_rev(), ); } /** * Execute one new-grammar command and return its terminal result envelope. * * @param array $command Raw command envelope. */ public function run( array $command ): array { $command_id = $this->scalar( $command, 'command_id' ); if ( '' === $command_id ) { $command_id = 'cmd_missing_command_id'; } $capability = $this->scalar( $command, 'capability' ); $handler = $this->handlers[ $capability ] ?? null; if ( null === $handler ) { return $this->error_envelope( $command_id, $capability, 'unsupported_capability', 'This capability is not in the installed command catalog.' ); } $phase = $this->scalar( $command, 'phase' ); if ( 'validate' !== $phase && 'execute' !== $phase ) { return $this->error_envelope( $command_id, $capability, 'invalid_command', 'phase must be either validate or execute.' ); } if ( ! isset( $command['input'] ) || ! is_array( $command['input'] ) ) { return $this->error_envelope( $command_id, $capability, 'invalid_command', 'Command input object is required.' ); } $reference_validation = $this->validate_reference_shapes( $command['input'] ); if ( isset( $reference_validation['error'] ) ) { return $this->error_envelope_from( $command_id, $capability, $reference_validation['error'] ); } $precondition_error = $this->precondition_support_error( $capability, $command['input'] ); if ( null !== $precondition_error ) { return $this->error_envelope_from( $command_id, $capability, $precondition_error ); } $has_unresolved_reference = 'validate' === $phase && true === ( $reference_validation['has_reference'] ?? false ); $validation = $has_unresolved_reference ? $this->validate_unresolved_reference_command_shape( $capability, $command['input'] ) : $handler->run( $command['input'], 'validate' ); if ( isset( $validation['error'] ) && is_array( $validation['error'] ) ) { return $this->error_envelope_from( $command_id, $capability, $validation['error'] ); } $facts = $this->command_facts( $capability, $command['input'], $has_unresolved_reference ); $policy_verdict = $this->policy->authorize_current( $facts ); if ( false === ( $policy_verdict['allowed'] ?? false ) ) { return $this->error_envelope_from( $command_id, $capability, array( 'code' => 'permission_denied', 'message' => isset( $policy_verdict['message'] ) ? (string) $policy_verdict['message'] : 'Command policy denied this command.', 'details' => isset( $policy_verdict['details'] ) && is_array( $policy_verdict['details'] ) ? $policy_verdict['details'] : array(), ) ); } $approval_requirement = $this->policy->approval_requirement( $facts ); if ( 'validate' === $phase ) { return $this->success_envelope( $command_id, $capability, array( 'valid' => true, 'policy' => $policy_verdict, 'approval' => $approval_requirement, ) ); } $result = $handler->run( $command['input'], $phase ); if ( isset( $result['error'] ) && is_array( $result['error'] ) ) { return $this->error_envelope_from( $command_id, $capability, $result['error'] ); } return $this->success_envelope( $command_id, $capability, $result['data'] ?? array(), $result['meta'] ?? array() ); } /** * Return the coarse site-shape fingerprint carried in every result. * * The fingerprint intentionally stays coarse: it moves when provider shape * inputs move, so Backend can lazily invalidate cached effective schemas. */ public function site_shape(): string { return 'f_' . substr( md5( $this->encode_fingerprint_value( $this->site_shape_facts() ) ), 0, 16 ); } /** * Return the settings-revision fingerprint. */ public function settings_rev(): string { $revision = array( 'legacy_revision' => function_exists( 'get_option' ) ? get_option( 'actionpanel_ai_settings_revision', '0' ) : '0', 'policy' => $this->policy->current_settings(), ); return 's_' . substr( md5( $this->encode_fingerprint_value( $revision ) ), 0, 12 ); } /** * Build a succeeded result envelope. * * @param string $command_id Command id. * @param string $capability Capability name. * @param array $data Operation data. * @param array $operation_meta Operation meta returned by the handler. */ private function success_envelope( string $command_id, string $capability, array $data, array $operation_meta = array() ): array { $meta = array_merge( $operation_meta, array( 'site_shape' => $this->site_shape() ) ); return array( 'command_id' => $command_id, 'capability' => $capability, 'status' => 'succeeded', 'data' => $data, 'artifacts' => array(), 'meta' => $meta, ); } /** * Build a failed result envelope from a handler error. * * @param string $command_id Command id. * @param string $capability Capability name. * @param array $error Handler error (`code`, `message`, optional `path`/`details`). */ private function error_envelope_from( string $command_id, string $capability, array $error ): array { return array( 'command_id' => $command_id, 'capability' => $capability, 'status' => 'failed', 'error' => $error, 'artifacts' => array(), 'meta' => array( 'site_shape' => $this->site_shape() ), ); } /** * Build a failed result envelope for a runtime-level error. * * @param string $command_id Command id. * @param string $capability Capability name. * @param string $code Twelve-code error code. * @param string $message Human/LLM message. */ private function error_envelope( string $command_id, string $capability, string $code, string $message ): array { return $this->error_envelope_from( $command_id, $capability, array( 'code' => $code, 'message' => $message, ) ); } /** * Read a scalar command field as a string. * * @param array $command Command envelope. * @param string $field Field name. */ private function scalar( array $command, string $field ): string { return isset( $command[ $field ] ) && is_scalar( $command[ $field ] ) ? (string) $command[ $field ] : ''; } /** * Collect policy facts for a command. */ private function command_facts( string $capability, array $input, bool $has_unresolved_reference = false ): array { $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : array(); if ( 'wp.http.request' === $capability ) { if ( isset( $input['url'] ) && is_scalar( $input['url'] ) ) { $target['url'] = (string) $input['url']; } elseif ( isset( $target['url'] ) && is_scalar( $target['url'] ) ) { $target['url'] = (string) $target['url']; } $target['method'] = isset( $input['method'] ) && is_scalar( $input['method'] ) ? strtoupper( (string) $input['method'] ) : 'GET'; } $facts = array( 'capability' => $capability, 'resource' => CommandPolicy::resource_from_capability( $capability ), 'action' => CommandPolicy::action_from_capability( $capability ), 'target' => $target, 'touched_paths' => $this->touched_paths( $capability, $input ), 'ops' => $this->command_ops( $capability, $input ), 'dev_mode' => $this->dev_mode, ); if ( $has_unresolved_reference ) { $facts['has_unresolved_reference'] = true; } $safe_write = $this->safe_write_facts( $capability, $input, $has_unresolved_reference ); if ( null !== $safe_write ) { $facts['safe_write'] = $safe_write; } $wp_capability = $this->wp_capability_check( $capability, $input ); if ( null !== $wp_capability ) { $facts['wp_capability_required'] = $wp_capability['capability']; $facts['wp_capability_allowed'] = $wp_capability['allowed']; } return $facts; } /** * Return conservative safe-write facts for content writes. */ private function safe_write_facts( string $capability, array $input, bool $has_unresolved_reference ): ?array { if ( PostsCloneCapability::NAME === $capability ) { return array( 'classifiable' => true, 'safe' => true, 'final_status' => 'draft', 'reason' => 'draft_clone', ); } if ( ! in_array( $capability, array( PostsWriteCapability::CREATE_NAME, PostsWriteCapability::UPDATE_NAME, PostsWriteCapability::DELETE_NAME ), true ) ) { return null; } if ( PostsWriteCapability::DELETE_NAME === $capability ) { return array( 'classifiable' => true, 'safe' => false, 'reason' => 'delete_requires_approval', ); } if ( PostsWriteCapability::CREATE_NAME === $capability ) { $status_value = $input['values']['status'] ?? 'draft'; if ( is_array( $status_value ) || ! is_scalar( $status_value ) ) { return array( 'classifiable' => false, 'safe' => false, 'reason' => 'unresolved_status', ); } $status = (string) $status_value; return array( 'classifiable' => true, 'safe' => 'draft' === $status, 'final_status' => $status, 'reason' => 'draft' === $status ? ( $has_unresolved_reference || $this->contains_reference( $input ) ? 'draft_clone' : 'draft_create' ) : 'non_draft_create', ); } if ( $has_unresolved_reference || $this->contains_reference( $input ) ) { return array( 'classifiable' => false, 'safe' => false, 'reason' => 'unresolved_reference', ); } $id = isset( $input['target']['id'] ) && is_numeric( $input['target']['id'] ) ? (int) $input['target']['id'] : 0; if ( $id <= 0 ) { return array( 'classifiable' => false, 'safe' => false, 'reason' => 'unresolved_target', ); } $post = get_post( $id ); if ( null === $post ) { return array( 'classifiable' => false, 'safe' => false, 'reason' => 'target_not_found', ); } $current_status = isset( $post->post_status ) && is_scalar( $post->post_status ) ? (string) $post->post_status : ''; $final_status = $this->final_status_after_changes( $current_status, $input['changes'] ?? null ); if ( null === $final_status ) { return array( 'classifiable' => false, 'safe' => false, 'current_status' => $current_status, 'reason' => 'unclassifiable_status_change', ); } return array( 'classifiable' => true, 'safe' => 'draft' === $current_status && 'draft' === $final_status, 'current_status' => $current_status, 'final_status' => $final_status, 'reason' => 'draft' === $current_status && 'draft' === $final_status ? 'draft_update' : 'non_draft_update', ); } /** * Simulate only status changes from a write command. */ private function final_status_after_changes( string $current_status, $changes ): ?string { if ( ! is_array( $changes ) ) { return null; } $final_status = $current_status; foreach ( $changes as $change ) { if ( ! is_array( $change ) || ! isset( $change['path'] ) || ! is_array( $change['path'] ) ) { continue; } if ( array( 'status' ) !== array_values( $change['path'] ) ) { continue; } if ( 'set' !== ( isset( $change['op'] ) && is_scalar( $change['op'] ) ? (string) $change['op'] : '' ) || ! isset( $change['value'] ) || ! is_scalar( $change['value'] ) ) { return null; } $final_status = (string) $change['value']; } return $final_status; } /** * Return whether a nested value contains a `$from` reference object. */ private function contains_reference( $value ): bool { if ( ! is_array( $value ) ) { return false; } if ( array_key_exists( '$from', $value ) ) { return true; } foreach ( $value as $child ) { if ( $this->contains_reference( $child ) ) { return true; } } return false; } /** * Return touched command paths for policy. */ private function touched_paths( string $capability, array $input ): array { if ( PostsWriteCapability::UPDATE_NAME === $capability && isset( $input['changes'] ) && is_array( $input['changes'] ) ) { return $this->change_paths( $input['changes'] ); } if ( PostTypesWriteCapability::UPDATE_NAME === $capability && isset( $input['changes'] ) && is_array( $input['changes'] ) ) { return $this->change_paths( $input['changes'] ); } if ( $this->is_core_content_capability( $capability ) && isset( $input['changes'] ) && is_array( $input['changes'] ) ) { $paths = $this->change_paths( $input['changes'] ); if ( str_starts_with( $capability, 'wp.options.' ) && isset( $input['target']['name'] ) && is_scalar( $input['target']['name'] ) ) { $paths[] = array( 'options', (string) $input['target']['name'] ); } return $paths; } if ( $this->is_menus_capability( $capability ) && isset( $input['changes'] ) && is_array( $input['changes'] ) ) { return $this->change_paths( $input['changes'] ); } if ( $this->is_theme_identity_capability( $capability ) && isset( $input['changes'] ) && is_array( $input['changes'] ) ) { return $this->change_paths( $input['changes'] ); } if ( $this->is_specials_maintenance_capability( $capability ) ) { if ( isset( $input['changes'] ) && is_array( $input['changes'] ) ) { return $this->change_paths( $input['changes'] ); } if ( isset( $input['target'] ) && is_array( $input['target'] ) && str_starts_with( $capability, 'wp.files.' ) ) { $target_path = $input['target']['path'] ?? ( $input['target']['absolute_path'] ?? '' ); return array( array( 'files', (string) ( $input['target']['scope'] ?? '' ), is_scalar( $target_path ) ? (string) $target_path : '' ) ); } } if ( PostsWriteCapability::CREATE_NAME === $capability && isset( $input['values'] ) && is_array( $input['values'] ) ) { return $this->document_leaf_paths( $input['values'] ); } if ( PostTypesWriteCapability::CREATE_NAME === $capability && isset( $input['values'] ) && is_array( $input['values'] ) ) { return $this->document_leaf_paths( $input['values'] ); } if ( PostsCloneCapability::NAME === $capability ) { return array( array( 'posts', 'clone' ) ); } if ( $this->is_core_content_capability( $capability ) && isset( $input['values'] ) && is_array( $input['values'] ) ) { return $this->document_leaf_paths( $input['values'] ); } if ( $this->is_menus_capability( $capability ) && isset( $input['values'] ) && is_array( $input['values'] ) ) { return $this->document_leaf_paths( $input['values'] ); } if ( PostsQueryCapability::NAME === $capability ) { return array_merge( $this->query_predicate_paths( $input['where'] ?? null ), $this->path_list( $input['select'] ?? null ), $this->order_paths( $input['order_by'] ?? null ) ); } if ( PostsGetCapability::NAME === $capability ) { return $this->path_list( $input['select'] ?? null ); } return array(); } /** * Return command operation names for policy. */ private function command_ops( string $capability, array $input ): array { if ( PostsWriteCapability::UPDATE_NAME === $capability && isset( $input['changes'] ) && is_array( $input['changes'] ) ) { return $this->change_ops( $input['changes'] ); } if ( PostTypesWriteCapability::UPDATE_NAME === $capability && isset( $input['changes'] ) && is_array( $input['changes'] ) ) { return $this->change_ops( $input['changes'] ); } if ( $this->is_core_content_capability( $capability ) && isset( $input['changes'] ) && is_array( $input['changes'] ) ) { return $this->change_ops( $input['changes'] ); } if ( $this->is_menus_capability( $capability ) && isset( $input['changes'] ) && is_array( $input['changes'] ) ) { return $this->change_ops( $input['changes'] ); } if ( $this->is_theme_identity_capability( $capability ) && isset( $input['changes'] ) && is_array( $input['changes'] ) ) { return $this->change_ops( $input['changes'] ); } if ( $this->is_specials_maintenance_capability( $capability ) && isset( $input['changes'] ) && is_array( $input['changes'] ) ) { return $this->change_ops( $input['changes'] ); } return array( CommandPolicy::action_from_capability( $capability ) ); } /** * Return whether a capability is served by the core-content grammar. */ private function is_core_content_capability( string $capability ): bool { return isset( CoreContentCapability::CAPABILITIES[ $capability ] ); } private function is_menus_capability( string $capability ): bool { return isset( MenusCapability::CAPABILITIES[ $capability ] ); } /** * Return whether a capability is served by the theme-identity grammar. */ private function is_theme_identity_capability( string $capability ): bool { return isset( ThemeIdentityCapability::CAPABILITIES[ $capability ] ); } /** * Return whether a capability is served by the specials/maintenance grammar. */ private function is_specials_maintenance_capability( string $capability ): bool { return isset( SpecialsMaintenanceCapability::CAPABILITIES[ $capability ] ) || in_array( $capability, self::DEV_MAINTENANCE_CAPABILITIES, true ); } /** * Extract normalized paths from a change algebra list. */ private function change_paths( array $changes ): array { $paths = array(); foreach ( $changes as $change ) { if ( is_array( $change ) && isset( $change['path'] ) && is_array( $change['path'] ) ) { $paths[] = array_values( $change['path'] ); } } return $paths; } /** * Extract operation names from a change algebra list. */ private function change_ops( array $changes ): array { $ops = array(); foreach ( $changes as $change ) { if ( is_array( $change ) && isset( $change['op'] ) && is_scalar( $change['op'] ) ) { $ops[] = (string) $change['op']; } } return $ops; } /** * Return WP capability check facts when the target is known enough. */ private function wp_capability_check( string $capability, array $input ): ?array { if ( PostsGetCapability::NAME === $capability ) { $id = isset( $input['target']['id'] ) && is_numeric( $input['target']['id'] ) ? (int) $input['target']['id'] : 0; if ( $id > 0 && null !== get_post( $id ) ) { return array( 'capability' => 'read_post', 'allowed' => current_user_can( 'read_post', $id ), ); } } if ( PostsWriteCapability::UPDATE_NAME === $capability || PostsWriteCapability::DELETE_NAME === $capability ) { $id = isset( $input['target']['id'] ) && is_numeric( $input['target']['id'] ) ? (int) $input['target']['id'] : 0; $required = PostsWriteCapability::DELETE_NAME === $capability ? 'delete_post' : 'edit_post'; if ( $id > 0 && null !== get_post( $id ) ) { return array( 'capability' => $required, 'allowed' => current_user_can( $required, $id ), ); } // Missing/invalid target: resolve against the generic primitive so the // fail-closed gate does not turn a not_found/invalid_target into a // permission error; the handler still surfaces the real target error. $generic = PostsWriteCapability::DELETE_NAME === $capability ? 'delete_posts' : 'edit_posts'; return array( 'capability' => $required, 'allowed' => current_user_can( $generic ) || current_user_can( 'edit_pages' ), ); } if ( PostsWriteCapability::CREATE_NAME === $capability ) { $post_type = isset( $input['values']['post_type'] ) && is_scalar( $input['values']['post_type'] ) ? sanitize_key( (string) $input['values']['post_type'] ) : 'post'; $required = $this->post_type_create_capability( $post_type ); return array( 'capability' => $required, 'allowed' => current_user_can( $required ), ); } if ( PostsCloneCapability::NAME === $capability ) { $id = 0; if ( isset( $input['target'] ) && is_array( $input['target'] ) ) { foreach ( array( 'source_post_id', 'id', 'post_id' ) as $key ) { if ( isset( $input['target'][ $key ] ) && is_numeric( $input['target'][ $key ] ) ) { $id = (int) $input['target'][ $key ]; break; } } } $post = $id > 0 ? get_post( $id ) : null; if ( null !== $post ) { $required = $this->post_type_create_capability( (string) $post->post_type ); return array( 'capability' => $required, 'allowed' => current_user_can( $required ), ); } return array( 'capability' => 'edit_posts', 'allowed' => current_user_can( 'edit_posts' ) || current_user_can( 'edit_pages' ), ); } if ( in_array( $capability, array( PostTypesWriteCapability::CREATE_NAME, PostTypesWriteCapability::UPDATE_NAME ), true ) ) { $required = PostTypesWriteCapability::acf_capability(); return array( 'capability' => $required, 'allowed' => current_user_can( $required ), ); } if ( $this->is_theme_identity_capability( $capability ) && in_array( CommandPolicy::action_from_capability( $capability ), array( 'update', 'delete' ), true ) ) { return array( 'capability' => 'edit_theme_options', 'allowed' => current_user_can( 'edit_theme_options' ), ); } if ( $this->is_specials_maintenance_capability( $capability ) ) { if ( str_starts_with( $capability, 'wp.plugins.' ) ) { return array( 'capability' => 'activate_plugins', 'allowed' => current_user_can( 'activate_plugins' ), ); } if ( str_starts_with( $capability, 'wp.themes.' ) || str_starts_with( $capability, 'wp.core.' ) ) { $required = str_starts_with( $capability, 'wp.core.' ) ? 'update_core' : 'edit_theme_options'; return array( 'capability' => $required, 'allowed' => current_user_can( $required ), ); } if ( str_starts_with( $capability, 'wp.files.' ) ) { return array( 'capability' => 'edit_files', 'allowed' => current_user_can( 'edit_files' ) || current_user_can( 'upload_files' ), ); } if ( in_array( $capability, array( 'wp.cache.clear', 'wp.rewrite_rules.flush', 'wp.cron.run', 'wp.cron_events.create', 'wp.cron_events.delete', 'wp.http.request' ), true ) ) { return array( 'capability' => 'manage_options', 'allowed' => current_user_can( 'manage_options' ), ); } } if ( 'wp.transients.delete' === $capability ) { return array( 'capability' => 'manage_options', 'allowed' => current_user_can( 'manage_options' ), ); } if ( $this->is_menus_capability( $capability ) && in_array( CommandPolicy::action_from_capability( $capability ), array( 'create', 'update', 'delete' ), true ) ) { return array( 'capability' => 'edit_theme_options', 'allowed' => current_user_can( 'edit_theme_options' ), ); } if ( SeoWriteCapability::NAME === $capability ) { $id = 0; if ( isset( $input['target'] ) && is_array( $input['target'] ) ) { foreach ( array( 'post_id', 'id' ) as $key ) { if ( isset( $input['target'][ $key ] ) && is_numeric( $input['target'][ $key ] ) ) { $id = (int) $input['target'][ $key ]; break; } } } if ( $id > 0 && null !== get_post( $id ) ) { return array( 'capability' => 'edit_post', 'allowed' => current_user_can( 'edit_post', $id ), ); } return array( 'capability' => 'edit_post', 'allowed' => current_user_can( 'edit_posts' ) || current_user_can( 'edit_pages' ), ); } if ( in_array( $capability, array( 'wp.db.check', 'wp.db.query' ), true ) ) { return array( 'capability' => 'manage_options', 'allowed' => current_user_can( 'manage_options' ), ); } if ( $this->is_core_content_capability( $capability ) ) { return $this->core_content_capability_check( $capability, $input ); } return null; } /** * Resolve the WordPress capability floor for a core-content write family. * * Reads (query/get) return null so the fail-closed write gate does not apply; * mutating actions resolve a per-resource, per-target capability so options, * terms, media, and comments writes cannot exceed the current user's * WordPress permissions. * * @param string $capability Capability name. * @param array $input Command input. */ private function core_content_capability_check( string $capability, array $input ): ?array { $resource = CommandPolicy::resource_from_capability( $capability ); $action = CommandPolicy::action_from_capability( $capability ); if ( ! in_array( $action, array( 'create', 'update', 'delete' ), true ) ) { return null; } return match ( $resource ) { 'options' => array( 'capability' => 'manage_options', 'allowed' => current_user_can( 'manage_options' ), ), 'terms' => $this->term_capability_check( $action, $input ), 'media' => $this->media_capability_check( $action, $input ), 'comments' => $this->comment_capability_check( $action, $input ), default => null, }; } /** * Resolve the taxonomy-aware capability for a terms write. * * @param string $action create/update/delete. * @param array $input Command input. */ private function term_capability_check( string $action, array $input ): array { $taxonomy = 'create' === $action ? ( isset( $input['values']['taxonomy'] ) && is_scalar( $input['values']['taxonomy'] ) ? (string) $input['values']['taxonomy'] : '' ) : ( isset( $input['target']['taxonomy'] ) && is_scalar( $input['target']['taxonomy'] ) ? (string) $input['target']['taxonomy'] : '' ); $required = 'manage_categories'; if ( '' !== $taxonomy && function_exists( 'get_taxonomy' ) ) { $taxonomy_object = get_taxonomy( $taxonomy ); if ( false !== $taxonomy_object && isset( $taxonomy_object->cap ) ) { $caps = $taxonomy_object->cap; $required = match ( $action ) { 'delete' => (string) ( $caps->delete_terms ?? 'manage_categories' ), default => (string) ( $caps->edit_terms ?? 'manage_categories' ), }; } } return array( 'capability' => $required, 'allowed' => current_user_can( $required ), ); } /** * Resolve the attachment capability for a media write. * * @param string $action create/update/delete. * @param array $input Command input. */ private function media_capability_check( string $action, array $input ): array { if ( 'create' === $action ) { return array( 'capability' => 'upload_files', 'allowed' => current_user_can( 'upload_files' ), ); } $id = isset( $input['target']['id'] ) && is_numeric( $input['target']['id'] ) ? (int) $input['target']['id'] : 0; $required = 'delete' === $action ? 'delete_post' : 'edit_post'; return array( 'capability' => $required, 'allowed' => $id > 0 ? current_user_can( $required, $id ) : current_user_can( 'upload_files' ), ); } /** * Resolve the comment capability for a comments write. * * @param string $action create/update/delete. * @param array $input Command input. */ private function comment_capability_check( string $action, array $input ): array { if ( 'create' === $action ) { $post_id = isset( $input['values']['post_id'] ) && is_numeric( $input['values']['post_id'] ) ? (int) $input['values']['post_id'] : 0; if ( $post_id > 0 ) { return array( 'capability' => 'edit_post', 'allowed' => current_user_can( 'edit_post', $post_id ), ); } return array( 'capability' => 'moderate_comments', 'allowed' => current_user_can( 'moderate_comments' ), ); } $id = isset( $input['target']['id'] ) && is_numeric( $input['target']['id'] ) ? (int) $input['target']['id'] : 0; if ( $id > 0 ) { return array( 'capability' => 'edit_comment', 'allowed' => current_user_can( 'edit_comment', $id ), ); } return array( 'capability' => 'moderate_comments', 'allowed' => current_user_can( 'moderate_comments' ), ); } /** * Resolve the post-type-specific create capability. */ private function post_type_create_capability( string $post_type ): string { if ( function_exists( 'get_post_type_object' ) ) { $post_type_object = get_post_type_object( $post_type ); if ( is_object( $post_type_object ) && isset( $post_type_object->cap ) ) { return (string) ( $post_type_object->cap->create_posts ?? $post_type_object->cap->edit_posts ?? 'edit_posts' ); } } return 'page' === $post_type ? 'edit_pages' : 'edit_posts'; } private function document_leaf_paths( array $document, array $prefix = array() ): array { $paths = array(); foreach ( $document as $key => $value ) { $path = array_merge( $prefix, array( $key ) ); if ( is_array( $value ) && array() !== $value ) { $paths = array_merge( $paths, $this->document_leaf_paths( $value, $path ) ); continue; } $paths[] = $path; } return $paths; } private function path_list( $paths ): array { if ( ! is_array( $paths ) ) { return array(); } return array_values( array_filter( array_values( $paths ), 'is_array' ) ); } private function query_predicate_paths( $group ): array { if ( ! is_array( $group ) ) { return array(); } $paths = array(); foreach ( array( 'all', 'any' ) as $key ) { if ( ! isset( $group[ $key ] ) || ! is_array( $group[ $key ] ) ) { continue; } foreach ( $group[ $key ] as $item ) { if ( is_array( $item ) && ( isset( $item['all'] ) || isset( $item['any'] ) ) ) { $paths = array_merge( $paths, $this->query_predicate_paths( $item ) ); } elseif ( is_array( $item ) && isset( $item['path'] ) && is_array( $item['path'] ) ) { $paths[] = array_values( $item['path'] ); } } } return $paths; } private function order_paths( $order_by ): array { if ( ! is_array( $order_by ) ) { return array(); } $paths = array(); foreach ( $order_by as $order ) { if ( is_array( $order ) && isset( $order['path'] ) && is_array( $order['path'] ) ) { $paths[] = array_values( $order['path'] ); } } return $paths; } /** * Reject `target.if` preconditions a capability does not compare. * * Preconditions are only honest when the handler actually compares them * before mutating. Capabilities without precondition support and unknown * fact names both fail here instead of silently skipping the guard the * caller believes it has. * * @param string $capability Capability name. * @param array $input Command input. */ private function precondition_support_error( string $capability, array $input ): ?array { $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : array(); if ( array_key_exists( '$from', $target ) || ! array_key_exists( 'if', $target ) ) { return null; } $supported_facts = self::PRECONDITION_FACTS[ $capability ] ?? null; if ( null === $supported_facts ) { return array( 'code' => 'invalid_command', 'message' => 'target.if preconditions are not supported for this capability.', 'path' => array( 'input', 'target', 'if' ), ); } if ( ! is_array( $target['if'] ) ) { return array( 'code' => 'invalid_command', 'message' => 'target.if must be an object of expected-state facts.', 'path' => array( 'input', 'target', 'if' ), ); } foreach ( array_keys( $target['if'] ) as $fact ) { if ( ! in_array( (string) $fact, $supported_facts, true ) ) { return array( 'code' => 'invalid_command', 'message' => 'target.if contains a fact this capability does not compare.', 'path' => array( 'input', 'target', 'if', (string) $fact ), 'details' => array( 'supported_facts' => $supported_facts ), ); } } return null; } /** * Shape-validate `$from` references without resolving them. * * @param mixed $value Input value to scan. */ private function validate_reference_shapes( $value ): array { if ( ! is_array( $value ) ) { return array( 'has_reference' => false ); } if ( array_key_exists( '$from', $value ) ) { if ( 2 !== count( $value ) || ! isset( $value['$from'] ) || ! is_string( $value['$from'] ) || '' === $value['$from'] || ! isset( $value['path'] ) || ! is_array( $value['path'] ) ) { return array( 'error' => array( 'code' => 'invalid_reference', 'message' => 'Reference objects must contain a string $from and a string/integer path.', ), ); } foreach ( $value['path'] as $segment ) { if ( ! is_string( $segment ) && ! is_int( $segment ) ) { return array( 'error' => array( 'code' => 'invalid_reference', 'message' => 'Reference path must contain only string or integer segments.', ), ); } } return array( 'has_reference' => true ); } $has_reference = false; foreach ( $value as $child ) { $result = $this->validate_reference_shapes( $child ); if ( isset( $result['error'] ) ) { return $result; } $has_reference = $has_reference || true === ( $result['has_reference'] ?? false ); } return array( 'has_reference' => $has_reference ); } /** * Validate command structure when validate-phase inputs contain unresolved references. * * References intentionally are not resolved during preflight. This validator * checks the surrounding command shape so a stray `$from` cannot turn a * malformed write into a successful approval-required preflight. */ private function validate_unresolved_reference_command_shape( string $capability, array $input ): array { if ( PostsWriteCapability::CREATE_NAME === $capability ) { return $this->validate_reference_posts_create_shape( $input ); } if ( PostsWriteCapability::UPDATE_NAME === $capability ) { return $this->validate_reference_posts_update_shape( $input ); } if ( PostsWriteCapability::DELETE_NAME === $capability ) { return $this->validate_reference_posts_delete_shape( $input ); } if ( PostsCloneCapability::NAME === $capability ) { return $this->validate_reference_posts_clone_shape( $input ); } if ( PostTypesWriteCapability::CREATE_NAME === $capability ) { return $this->validate_reference_post_types_create_shape( $input ); } if ( PostTypesWriteCapability::UPDATE_NAME === $capability ) { return $this->validate_reference_post_types_update_shape( $input ); } if ( str_starts_with( $capability, 'wp.files.' ) ) { return $this->validate_reference_file_write_shape( $input ); } if ( $this->is_menus_capability( $capability ) ) { return $this->validate_reference_menus_shape( $capability, $input ); } if ( $this->is_core_content_capability( $capability ) ) { return $this->validate_reference_core_content_shape( $capability, $input ); } if ( $this->is_theme_identity_capability( $capability ) ) { return $this->validate_reference_theme_identity_shape( $capability, $input ); } if ( $this->is_specials_maintenance_capability( $capability ) ) { return $this->validate_reference_specials_maintenance_shape( $capability, $input ); } if ( SeoWriteCapability::NAME === $capability ) { return $this->validate_reference_seo_patch_shape( $input ); } return array( 'data' => array( 'valid' => true ) ); } private function validate_reference_posts_create_shape( array $input ): array { $values = isset( $input['values'] ) && is_array( $input['values'] ) ? $input['values'] : null; if ( null === $values || $this->is_reference_object( $values ) ) { return $this->validation_error( 'invalid_command', 'wp.posts.create requires a values object.', array( 'input', 'values' ) ); } foreach ( array_keys( $values ) as $field ) { if ( ! in_array( (string) $field, PostsWriteCapability::CREATE_FIELDS, true ) ) { return $this->validation_error( 'invalid_path', 'values contains a field wp.posts.create does not accept.', array( 'input', 'values', (string) $field ) ); } } if ( ! array_key_exists( 'post_type', $values ) || ! $this->is_reference_or_scalar( $values['post_type'] ) ) { return $this->validation_error( 'invalid_value', 'values.post_type must be a registered post type or unresolved reference.', array( 'input', 'values', 'post_type' ) ); } if ( is_scalar( $values['post_type'] ) ) { $post_type = sanitize_key( (string) $values['post_type'] ); if ( '' === $post_type || ! in_array( $post_type, $this->registered_post_types(), true ) ) { return $this->validation_error( 'invalid_value', 'values.post_type must be a registered post type.', array( 'input', 'values', 'post_type' ) ); } } if ( ! array_key_exists( 'title', $values ) ) { return $this->validation_error( 'invalid_value', 'wp.posts.create requires values.title.raw.', array( 'input', 'values', 'title', 'raw' ) ); } $title = $values['title']; if ( ! $this->is_reference_object( $title ) ) { if ( ! is_array( $title ) || ! array_key_exists( 'raw', $title ) || ! $this->is_reference_or_scalar( $title['raw'] ) ) { return $this->validation_error( 'invalid_value', 'wp.posts.create requires values.title.raw.', array( 'input', 'values', 'title', 'raw' ) ); } if ( is_scalar( $title['raw'] ) && '' === (string) $title['raw'] ) { return $this->validation_error( 'invalid_value', 'wp.posts.create requires values.title.raw.', array( 'input', 'values', 'title', 'raw' ) ); } } if ( array_key_exists( 'status', $values ) && is_scalar( $values['status'] ) && ! in_array( (string) $values['status'], array( 'draft', 'pending', 'private', 'publish', 'future', 'trash' ), true ) ) { return $this->validation_error( 'invalid_value', 'Post status is not allowed.', array( 'input', 'values', 'status' ) ); } return array( 'data' => array( 'valid' => true ) ); } private function validate_reference_posts_update_shape( array $input ): array { $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : null; if ( null === $target ) { return $this->validation_error( 'invalid_target', 'Posts writes require a target with a positive integer id or unresolved reference.', array( 'input', 'target' ) ); } if ( ! $this->is_reference_object( $target ) && ! $this->is_reference_or_positive_id( $target['id'] ?? null ) ) { return $this->validation_error( 'invalid_target', 'Posts writes require a target with a positive integer id or unresolved reference.', array( 'input', 'target' ) ); } $changes = isset( $input['changes'] ) && is_array( $input['changes'] ) ? array_values( $input['changes'] ) : array(); if ( array() === $changes ) { return $this->validation_error( 'invalid_command', 'wp.posts.update requires a non-empty changes list.', array( 'input', 'changes' ) ); } return $this->validate_reference_change_list_shape( $changes ); } private function validate_reference_posts_delete_shape( array $input ): array { $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : null; if ( null === $target ) { return $this->validation_error( 'invalid_target', 'Posts writes require a target with a positive integer id or unresolved reference.', array( 'input', 'target' ) ); } if ( ! $this->is_reference_object( $target ) && ! $this->is_reference_or_positive_id( $target['id'] ?? null ) ) { return $this->validation_error( 'invalid_target', 'Posts writes require a target with a positive integer id or unresolved reference.', array( 'input', 'target' ) ); } return array( 'data' => array( 'valid' => true ) ); } private function validate_reference_posts_clone_shape( array $input ): array { $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : null; if ( null === $target ) { return $this->validation_error( 'invalid_target', 'wp.posts.clone requires target.source_post_id or unresolved reference.', array( 'input', 'target', 'source_post_id' ) ); } $source_id = $target['source_post_id'] ?? ( $target['id'] ?? null ); if ( ! $this->is_reference_object( $target ) && ! $this->is_reference_or_positive_id( $source_id ) ) { return $this->validation_error( 'invalid_target', 'wp.posts.clone requires target.source_post_id or unresolved reference.', array( 'input', 'target', 'source_post_id' ) ); } $values = $input['values'] ?? array(); if ( ! is_array( $values ) ) { return $this->validation_error( 'invalid_command', 'wp.posts.clone values must be an object.', array( 'input', 'values' ) ); } foreach ( array_keys( $values ) as $key ) { if ( 'title' !== (string) $key ) { return $this->validation_error( 'invalid_command', 'wp.posts.clone values may contain only title.', array( 'input', 'values', (string) $key ) ); } } if ( array() !== $values ) { $title = $values['title']; if ( ! $this->is_reference_object( $title ) ) { if ( is_array( $title ) && array_key_exists( 'raw', $title ) ) { $title = $title['raw']; } if ( ! $this->is_reference_or_scalar( $title ) ) { return $this->validation_error( 'invalid_value', 'wp.posts.clone title must be a scalar or unresolved reference.', array( 'input', 'values', 'title' ) ); } } } return array( 'data' => array( 'valid' => true ) ); } private function validate_reference_post_types_create_shape( array $input ): array { $values = isset( $input['values'] ) && is_array( $input['values'] ) ? $input['values'] : null; if ( null === $values || $this->is_reference_object( $values ) ) { return $this->validation_error( 'invalid_command', 'wp.post_types.create requires a values object.', array( 'input', 'values' ) ); } if ( ! array_key_exists( 'post_type', $values ) || ! $this->is_reference_or_non_empty_scalar( $values['post_type'] ) ) { return $this->validation_error( 'invalid_value', 'wp.post_types.create requires values.post_type.', array( 'input', 'values', 'post_type' ) ); } $has_singular = ( array_key_exists( 'label', $values ) && $this->is_reference_or_non_empty_scalar( $values['label'] ) ) || ( isset( $values['labels'] ) && is_array( $values['labels'] ) && array_key_exists( 'singular_name', $values['labels'] ) && $this->is_reference_or_non_empty_scalar( $values['labels']['singular_name'] ) ); if ( ! $has_singular ) { return $this->validation_error( 'invalid_value', 'wp.post_types.create requires values.label or values.labels.singular_name.', array( 'input', 'values', 'label' ) ); } $has_plural = ( array_key_exists( 'plural_label', $values ) && $this->is_reference_or_non_empty_scalar( $values['plural_label'] ) ) || ( isset( $values['labels'] ) && is_array( $values['labels'] ) && array_key_exists( 'name', $values['labels'] ) && $this->is_reference_or_non_empty_scalar( $values['labels']['name'] ) ); if ( ! $has_plural ) { return $this->validation_error( 'invalid_value', 'wp.post_types.create requires values.plural_label or values.labels.name.', array( 'input', 'values', 'plural_label' ) ); } return array( 'data' => array( 'valid' => true ) ); } private function validate_reference_post_types_update_shape( array $input ): array { $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : null; if ( null === $target ) { return $this->validation_error( 'invalid_target', 'wp.post_types.update requires target.post_type or target.name.', array( 'input', 'target' ) ); } $post_type = $target['post_type'] ?? ( $target['name'] ?? null ); if ( ! $this->is_reference_object( $target ) && ! $this->is_reference_or_non_empty_scalar( $post_type ) ) { return $this->validation_error( 'invalid_target', 'wp.post_types.update requires target.post_type or target.name.', array( 'input', 'target', 'post_type' ) ); } $changes = isset( $input['changes'] ) && is_array( $input['changes'] ) ? array_values( $input['changes'] ) : array(); if ( array() === $changes ) { return $this->validation_error( 'invalid_command', 'wp.post_types.update requires a non-empty changes list.', array( 'input', 'changes' ) ); } return $this->validate_reference_change_list_shape( $changes ); } private function validate_reference_file_write_shape( array $input ): array { $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : null; if ( null === $target || $this->is_reference_object( $target ) ) { return $this->validation_error( 'invalid_target', 'File writes require a target object.', array( 'input', 'target' ) ); } $scope = $target['scope'] ?? null; if ( ! $this->is_reference_or_scalar( $scope ) ) { return $this->validation_error( 'invalid_target', 'File writes require target.scope.', array( 'input', 'target', 'scope' ) ); } if ( is_scalar( $scope ) && 'uploads' !== (string) $scope && ! ( $this->dev_mode && 'all' === (string) $scope ) ) { return $this->validation_error( 'invalid_target', 'Standard file writes are limited to the uploads sandbox.', array( 'input', 'target', 'scope' ) ); } $path = $target['path'] ?? ( $this->dev_mode && 'all' === (string) $scope ? ( $target['absolute_path'] ?? null ) : null ); if ( ! $this->is_reference_or_scalar( $path ) ) { return $this->validation_error( 'invalid_target', 'File path must be relative and stay inside its sandbox scope.', array( 'input', 'target', 'path' ) ); } if ( is_scalar( $path ) && 'all' === (string) $scope && $this->dev_mode ) { $path_string = (string) $path; if ( '' === $path_string || ! ( str_starts_with( $path_string, '/' ) || ( '\\' === DIRECTORY_SEPARATOR && 1 === preg_match( '/^[A-Za-z]:[\\\\\\/]/', $path_string ) ) ) ) { return $this->validation_error( 'invalid_target', 'Dev all-scope file writes require an absolute path.', array( 'input', 'target', 'path' ) ); } return array( 'data' => array( 'valid' => true ) ); } if ( is_scalar( $path ) && ( '' === (string) $path || str_contains( (string) $path, '..' ) || str_starts_with( (string) $path, '/' ) || str_contains( (string) $path, '\\' ) ) ) { return $this->validation_error( 'invalid_target', 'File path must be relative and stay inside its sandbox scope.', array( 'input', 'target', 'path' ) ); } return array( 'data' => array( 'valid' => true ) ); } private function validate_reference_core_content_shape( string $capability, array $input ): array { $action = CommandPolicy::action_from_capability( $capability ); if ( ! in_array( $action, array( 'create', 'update', 'delete' ), true ) ) { return array( 'data' => array( 'valid' => true ) ); } $resource = CommandPolicy::resource_from_capability( $capability ); if ( 'create' === $action ) { return $this->validate_reference_core_create_shape( $resource, $input ); } if ( 'update' === $action ) { $target = $this->validate_reference_core_target_shape( $resource, $input, 'update' ); if ( isset( $target['error'] ) ) { return $target; } $changes = isset( $input['changes'] ) && is_array( $input['changes'] ) ? array_values( $input['changes'] ) : array(); if ( array() === $changes ) { return $this->validation_error( 'invalid_command', 'update requires a non-empty changes list.', array( 'input', 'changes' ) ); } return $this->validate_reference_change_list_shape( $changes ); } return $this->validate_reference_core_target_shape( $resource, $input, 'delete' ); } private function validate_reference_menus_shape( string $capability, array $input ): array { $action = CommandPolicy::action_from_capability( $capability ); if ( 'create' === $action ) { $values = isset( $input['values'] ) && is_array( $input['values'] ) ? $input['values'] : null; if ( null === $values || $this->is_reference_object( $values ) ) { return $this->validation_error( 'invalid_command', 'menus create requires a values object.', array( 'input', 'values' ) ); } foreach ( array_keys( $values ) as $key ) { if ( 'name' !== (string) $key ) { return $this->validation_error( 'invalid_command', 'menus create supports only values.name.', array( 'input', 'values', (string) $key ) ); } } if ( ! array_key_exists( 'name', $values ) || ! $this->is_reference_or_non_empty_scalar( $values['name'] ) ) { return $this->validation_error( 'invalid_value', 'menus create requires values.name.', array( 'input', 'values', 'name' ) ); } return array( 'data' => array( 'valid' => true ) ); } if ( ! in_array( $action, array( 'update', 'delete' ), true ) ) { return array( 'data' => array( 'valid' => true ) ); } $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : null; if ( null === $target ) { return $this->validation_error( 'invalid_target', 'menus target requires id, slug, or name.', array( 'input', 'target' ) ); } if ( ! $this->is_reference_object( $target ) ) { $has_identity = false; foreach ( array( 'id', 'slug', 'name' ) as $field ) { if ( array_key_exists( $field, $target ) && $this->is_reference_or_non_empty_scalar( $target[ $field ] ) ) { $has_identity = true; break; } } if ( ! $has_identity ) { return $this->validation_error( 'invalid_target', 'menus target requires id, slug, or name.', array( 'input', 'target' ) ); } } if ( 'update' === $action ) { $changes = isset( $input['changes'] ) && is_array( $input['changes'] ) ? array_values( $input['changes'] ) : array(); if ( array() === $changes ) { return $this->validation_error( 'invalid_command', 'menus update requires a non-empty changes list.', array( 'input', 'changes' ) ); } return $this->validate_reference_change_list_shape( $changes ); } return array( 'data' => array( 'valid' => true ) ); } private function validate_reference_core_create_shape( string $resource_name, array $input ): array { $values = isset( $input['values'] ) && is_array( $input['values'] ) ? $input['values'] : null; if ( null === $values || $this->is_reference_object( $values ) ) { return $this->validation_error( 'invalid_command', 'create requires a values object.', array( 'input', 'values' ) ); } if ( 'terms' === $resource_name ) { if ( ! array_key_exists( 'taxonomy', $values ) || ! $this->is_reference_or_non_empty_scalar( $values['taxonomy'] ) || ! array_key_exists( 'name', $values ) || ! $this->is_reference_or_non_empty_scalar( $values['name'] ) ) { return $this->validation_error( 'invalid_value', 'terms create requires taxonomy and name.', array( 'input', 'values' ) ); } } if ( 'media' === $resource_name ) { $source_url = $values['source']['url'] ?? ( $values['url'] ?? null ); if ( is_scalar( $source_url ) && '' !== (string) $source_url && ! filter_var( (string) $source_url, FILTER_VALIDATE_URL ) ) { return $this->validation_error( 'invalid_value', 'media create requires a valid source URL.', array( 'input', 'values', 'source', 'url' ) ); } } return array( 'data' => array( 'valid' => true ) ); } private function validate_reference_core_target_shape( string $resource_name, array $input, string $action ): array { $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : null; if ( null === $target ) { return $this->validation_error( 'invalid_target', $resource_name . ' ' . $action . ' requires target.', array( 'input', 'target' ) ); } if ( $this->is_reference_object( $target ) ) { return array( 'data' => array( 'valid' => true ) ); } if ( 'options' === $resource_name ) { if ( ! array_key_exists( 'name', $target ) || ! $this->is_reference_or_non_empty_scalar( $target['name'] ) ) { return $this->validation_error( 'invalid_target', 'options ' . $action . ' requires target.name.', array( 'input', 'target', 'name' ) ); } return array( 'data' => array( 'valid' => true ) ); } if ( 'terms' === $resource_name ) { if ( ! array_key_exists( 'id', $target ) || ! $this->is_reference_or_positive_id( $target['id'] ) || ! array_key_exists( 'taxonomy', $target ) || ! $this->is_reference_or_non_empty_scalar( $target['taxonomy'] ) ) { return $this->validation_error( 'invalid_target', 'terms ' . $action . ' requires taxonomy and id.', array( 'input', 'target' ) ); } return array( 'data' => array( 'valid' => true ) ); } if ( in_array( $resource_name, array( 'media', 'comments' ), true ) ) { if ( ! array_key_exists( 'id', $target ) || ! $this->is_reference_or_positive_id( $target['id'] ) ) { return $this->validation_error( 'invalid_target', $resource_name . ' ' . $action . ' requires id.', array( 'input', 'target', 'id' ) ); } return array( 'data' => array( 'valid' => true ) ); } return array( 'data' => array( 'valid' => true ) ); } private function validate_reference_theme_identity_shape( string $capability, array $input ): array { $action = CommandPolicy::action_from_capability( $capability ); $resource = CommandPolicy::resource_from_capability( $capability ); if ( ! in_array( $action, array( 'update', 'delete' ), true ) ) { return array( 'data' => array( 'valid' => true ) ); } if ( in_array( $resource, array( 'templates', 'template_parts' ), true ) ) { $target = $this->validate_reference_template_target_shape( $input ); if ( isset( $target['error'] ) ) { return $target; } } if ( 'update' === $action ) { $changes = isset( $input['changes'] ) && is_array( $input['changes'] ) ? array_values( $input['changes'] ) : array(); if ( array() === $changes ) { return $this->validation_error( 'invalid_command', 'Template update requires a non-empty changes list.', array( 'input', 'changes' ) ); } return $this->validate_reference_change_list_shape( $changes ); } return array( 'data' => array( 'valid' => true ) ); } private function validate_reference_template_target_shape( array $input ): array { $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : null; if ( null === $target ) { return $this->validation_error( 'invalid_target', 'Template targets require id as theme//slug or theme plus slug.', array( 'input', 'target' ) ); } if ( $this->is_reference_object( $target ) ) { return array( 'data' => array( 'valid' => true ) ); } if ( array_key_exists( 'id', $target ) ) { if ( $this->is_reference_object( $target['id'] ) ) { return array( 'data' => array( 'valid' => true ) ); } if ( is_scalar( $target['id'] ) ) { $parts = explode( '//', (string) $target['id'], 2 ); if ( 2 === count( $parts ) && '' !== $parts[0] && '' !== $parts[1] ) { return array( 'data' => array( 'valid' => true ) ); } } return $this->validation_error( 'invalid_target', 'Template targets require id as theme//slug or theme plus slug.', array( 'input', 'target' ) ); } if ( ! array_key_exists( 'slug', $target ) || ! $this->is_reference_or_non_empty_scalar( $target['slug'] ) || ( array_key_exists( 'theme', $target ) && ! $this->is_reference_or_non_empty_scalar( $target['theme'] ) ) ) { return $this->validation_error( 'invalid_target', 'Template targets require id as theme//slug or theme plus slug.', array( 'input', 'target' ) ); } return array( 'data' => array( 'valid' => true ) ); } private function validate_reference_specials_maintenance_shape( string $capability, array $input ): array { if ( in_array( $capability, array( 'wp.http.request', 'wp.performance.inspect' ), true ) ) { $url = $input['url'] ?? ( $input['target']['url'] ?? null ); if ( ! $this->is_reference_or_non_empty_scalar( $url ) ) { return $this->validation_error( 'invalid_value', 'A valid absolute URL is required.', array( 'input', 'url' ) ); } if ( is_scalar( $url ) && ! filter_var( (string) $url, FILTER_VALIDATE_URL ) ) { return $this->validation_error( 'invalid_value', 'A valid absolute URL is required.', array( 'input', 'url' ) ); } return array( 'data' => array( 'valid' => true ) ); } if ( 'wp.plugins.update' === $capability ) { return $this->validate_reference_plugin_update_shape( $input ); } if ( 'wp.themes.activate' === $capability ) { return $this->validate_reference_theme_activate_shape( $input ); } if ( 'wp.cron_events.create' === $capability ) { $values = isset( $input['values'] ) && is_array( $input['values'] ) ? $input['values'] : null; if ( null === $values || $this->is_reference_object( $values ) || ! array_key_exists( 'hook', $values ) || ! $this->is_reference_or_non_empty_scalar( $values['hook'] ) ) { return $this->validation_error( 'invalid_value', 'Cron events require values.hook.', array( 'input', 'values', 'hook' ) ); } return array( 'data' => array( 'valid' => true ) ); } if ( 'wp.cron_events.delete' === $capability ) { $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : null; if ( null === $target || ( ! $this->is_reference_object( $target ) && ( ! array_key_exists( 'hook', $target ) || ! $this->is_reference_or_non_empty_scalar( $target['hook'] ) ) ) ) { return $this->validation_error( 'invalid_target', 'Cron event delete requires target.hook.', array( 'input', 'target', 'hook' ) ); } } return array( 'data' => array( 'valid' => true ) ); } private function validate_reference_plugin_update_shape( array $input ): array { $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : null; if ( null === $target || ( ! $this->is_reference_object( $target ) && ( ! array_key_exists( 'plugin', $target ) || ! $this->is_reference_or_non_empty_scalar( $target['plugin'] ) ) ) ) { return $this->validation_error( 'invalid_target', 'plugins.update requires target.plugin.', array( 'input', 'target', 'plugin' ) ); } $changes = isset( $input['changes'] ) && is_array( $input['changes'] ) ? array_values( $input['changes'] ) : array(); if ( array() === $changes ) { return $this->validation_error( 'invalid_command', 'plugins.update requires a changes list.', array( 'input', 'changes' ) ); } foreach ( $changes as $index => $change ) { $path = is_array( $change ) && isset( $change['path'] ) && is_array( $change['path'] ) ? array_values( $change['path'] ) : array(); $value = is_array( $change ) && array_key_exists( 'value', $change ) ? $change['value'] : null; if ( ! is_array( $change ) || 'set' !== ( $change['op'] ?? null ) || ( array( 'active' ) !== $path && array( 'status', 'active' ) !== $path ) || ! ( is_bool( $value ) || $this->is_reference_object( $value ) ) ) { return $this->validation_error( 'invalid_command', 'Plugin activation changes must be set operations on ["status","active"].', array( 'input', 'changes', $index ) ); } } return array( 'data' => array( 'valid' => true ) ); } private function validate_reference_theme_activate_shape( array $input ): array { $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : null; if ( null === $target || $this->is_reference_object( $target ) ) { return null === $target ? $this->validation_error( 'invalid_target', 'themes.activate requires target.stylesheet.', array( 'input', 'target', 'stylesheet' ) ) : array( 'data' => array( 'valid' => true ) ); } $stylesheet = $target['stylesheet'] ?? ( $target['theme'] ?? null ); if ( ! $this->is_reference_or_non_empty_scalar( $stylesheet ) ) { return $this->validation_error( 'invalid_target', 'themes.activate requires target.stylesheet.', array( 'input', 'target', 'stylesheet' ) ); } return array( 'data' => array( 'valid' => true ) ); } private function validate_reference_seo_patch_shape( array $input ): array { $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : null; if ( null === $target || ( ! $this->is_reference_object( $target ) && ! $this->is_reference_or_positive_id( $target['post_id'] ?? ( $target['id'] ?? null ) ) ) ) { return $this->validation_error( 'invalid_target', 'wp.seo.patch requires a target post_id.', array( 'input', 'target', 'post_id' ) ); } $values = isset( $input['values'] ) && is_array( $input['values'] ) ? $input['values'] : null; if ( null === $values || $this->is_reference_object( $values ) ) { return $this->validation_error( 'invalid_value', 'wp.seo.patch requires at least one supported SEO field in values.', array( 'input', 'values' ) ); } foreach ( array( 'title', 'description', 'canonical', 'robots_noindex', 'robots_nofollow', 'og_title', 'og_description', 'og_image' ) as $field ) { if ( array_key_exists( $field, $values ) ) { return array( 'data' => array( 'valid' => true ) ); } } return $this->validation_error( 'invalid_value', 'wp.seo.patch requires at least one supported SEO field in values.', array( 'input', 'values' ) ); } private function validate_reference_change_list_shape( array $changes ): array { foreach ( $changes as $index => $change ) { if ( ! is_array( $change ) ) { return $this->validation_error( 'invalid_command', 'Change items must be objects.', array( 'input', 'changes', $index ) ); } foreach ( $change as $key => $_ ) { if ( ! in_array( (string) $key, array( 'op', 'path', 'value', 'provider' ), true ) ) { return $this->validation_error( 'invalid_command', 'Change items may contain only op, path, value, and provider.', array( 'input', 'changes', $index, (string) $key ) ); } } $op = isset( $change['op'] ) && is_scalar( $change['op'] ) ? (string) $change['op'] : ''; if ( ! in_array( $op, array( 'set', 'remove', 'insert' ), true ) ) { return $this->validation_error( 'invalid_command', 'Change op must be set, remove, or insert.', array( 'input', 'changes', $index, 'op' ) ); } $path = isset( $change['path'] ) && is_array( $change['path'] ) ? array_values( $change['path'] ) : array(); if ( array() === $path || ! $this->is_reference_path( $path ) ) { return $this->validation_error( 'invalid_path', 'Change path must be a non-empty array-segment path.', array( 'input', 'changes', $index, 'path' ) ); } if ( in_array( $op, array( 'set', 'insert' ), true ) && ! array_key_exists( 'value', $change ) ) { return $this->validation_error( 'invalid_value', 'set and insert changes require a value.', array( 'input', 'changes', $index, 'value' ) ); } if ( isset( $change['provider'] ) && ! is_scalar( $change['provider'] ) ) { return $this->validation_error( 'invalid_command', 'provider must be a scalar disambiguator.', array( 'input', 'changes', $index, 'provider' ) ); } } return array( 'data' => array( 'valid' => true ) ); } private function is_reference_object( $value ): bool { return is_array( $value ) && array_key_exists( '$from', $value ); } private function is_reference_or_scalar( $value ): bool { return $this->is_reference_object( $value ) || is_scalar( $value ); } private function is_reference_or_non_empty_scalar( $value ): bool { return $this->is_reference_object( $value ) || ( is_scalar( $value ) && '' !== (string) $value ); } private function is_reference_or_positive_id( $value ): bool { return $this->is_reference_object( $value ) || ( is_numeric( $value ) && (int) $value > 0 ); } private function is_reference_path( array $path ): bool { foreach ( $path as $segment ) { if ( ! is_scalar( $segment ) ) { return false; } } return true; } private function validation_error( string $code, string $message, array $path ): array { return array( 'error' => array( 'code' => $code, 'message' => $message, 'path' => $path, ), ); } /** * Build the site-shape facts included in the command fingerprint. */ private function site_shape_facts(): array { return array( 'active_plugins' => $this->active_plugin_versions(), 'theme' => $this->theme_facts(), 'settings_rev' => $this->settings_rev(), 'post_types' => $this->registered_post_types(), 'acf_post_types' => PostTypesWriteCapability::acf_post_type_shape_facts(), 'acf_fields' => $this->acf_field_shape_facts(), 'jetengine_post_types' => PostTypesWriteCapability::jetengine_post_type_shape_facts(), ); } /** * Return ACF field definition facts that affect effective post schemas. */ private function acf_field_shape_facts(): array { $facts = array(); foreach ( $this->registered_post_types() as $post_type ) { $fields = array(); foreach ( PostTypesWriteCapability::exposed_acf_fields_for_post_type( $post_type ) as $field ) { $name = isset( $field['name'] ) && is_scalar( $field['name'] ) ? (string) $field['name'] : ''; if ( '' === $name ) { continue; } $fields[ $name ] = array( 'key' => isset( $field['key'] ) && is_scalar( $field['key'] ) ? (string) $field['key'] : '', 'type' => isset( $field['type'] ) && is_scalar( $field['type'] ) ? (string) $field['type'] : '', ); } if ( array() !== $fields ) { ksort( $fields ); $facts[ $post_type ] = $fields; } } return $facts; } /** * Return active plugin names and versions. */ private function active_plugin_versions(): array { $active = function_exists( 'get_option' ) ? get_option( 'active_plugins', array() ) : array(); $active = is_array( $active ) ? array_values( array_map( 'strval', $active ) ) : array(); sort( $active ); if ( ! function_exists( 'get_plugins' ) && defined( 'ABSPATH' ) ) { $plugin_file = ABSPATH . 'wp-admin/includes/plugin.php'; if ( file_exists( $plugin_file ) ) { require_once $plugin_file; } } $plugins = function_exists( 'get_plugins' ) ? get_plugins() : array(); $facts = array(); foreach ( $active as $plugin ) { $metadata = isset( $plugins[ $plugin ] ) && is_array( $plugins[ $plugin ] ) ? $plugins[ $plugin ] : array(); $facts[ $plugin ] = isset( $metadata['Version'] ) && is_scalar( $metadata['Version'] ) ? (string) $metadata['Version'] : ''; } return $facts; } /** * Return active theme identity facts. */ private function theme_facts(): array { if ( ! function_exists( 'wp_get_theme' ) ) { return array( 'stylesheet' => '', 'version' => '', ); } $theme = wp_get_theme(); $stylesheet = is_object( $theme ) && method_exists( $theme, 'get_stylesheet' ) ? (string) $theme->get_stylesheet() : ''; $version = is_object( $theme ) && method_exists( $theme, 'get' ) ? (string) $theme->get( 'Version' ) : ''; return array( 'stylesheet' => $stylesheet, 'version' => $version, ); } /** * Return registered post type names. */ private function registered_post_types(): array { $post_types = function_exists( 'get_post_types' ) ? get_post_types( array(), 'names' ) : array( 'post', 'page' ); $post_types = is_array( $post_types ) ? array_values( array_map( 'strval', $post_types ) ) : array(); sort( $post_types ); return $post_types; } /** * Encode fingerprint inputs deterministically. * * @param mixed $value Value to encode. */ private function encode_fingerprint_value( $value ): string { if ( function_exists( 'wp_json_encode' ) ) { return (string) wp_json_encode( $value ); } return (string) json_encode( $value ); } }