hard_rule_denial( $facts, $settings ); if ( null !== $hard ) { return $hard; } $global_settings_denial = $this->global_settings_denial( $facts, $settings ); if ( null !== $global_settings_denial ) { return $global_settings_denial; } if ( array_key_exists( 'wp_capability_allowed', $facts ) ) { if ( true !== $facts['wp_capability_allowed'] ) { $required_capability = $this->scalar_fact( $facts, 'wp_capability_required' ); return $this->deny( 'wp_capability', 'wordpress_capability_denied', 'The current WordPress user cannot perform this command.', array( 'capability' => $required_capability ) ); } } elseif ( $this->is_mutating( $facts ) ) { // Fail closed: a mutating command with no resolved WordPress capability // fact must never execute. This keeps future endpoint-permission // expansion from silently opening an unchecked write path. return $this->deny( 'wp_capability', 'capability_unresolved', 'No WordPress capability could be resolved for this mutating command.' ); } $settings_denial = $this->settings_denial( $facts, $settings ); if ( null !== $settings_denial ) { return $settings_denial; } return array( 'allowed' => true, 'details' => array( 'layer' => 'allowed' ), ); } /** * Authorize facts against current plugin settings. * * @param array $facts Command facts. */ public function authorize_current( array $facts ): array { return $this->decide( $facts, array(), $this->current_settings() ); } /** * Return concrete approval requirement for command facts. */ public function approval_requirement( array $facts ): array { return $this->approval_requirement_with_settings( $facts, $this->current_settings() ); } /** * Return the action segment from a grammar capability. */ public static function action_from_capability( string $capability ): string { $parts = explode( '.', $capability ); return (string) end( $parts ); } /** * Return the resource segment from a grammar capability. */ public static function resource_from_capability( string $capability ): string { $parts = explode( '.', $capability ); return $parts[1] ?? ''; } /** * Return whether the given settings require approval for command facts. */ private function approval_requirement_with_settings( array $facts, array $settings ): array { $capability = $this->scalar_fact( $facts, 'capability' ); if ( empty( $settings['read_only'] ) && ! empty( $settings['auto_approve_safe_write_operations'] ) && isset( $facts['safe_write'] ) && is_array( $facts['safe_write'] ) && true === ( $facts['safe_write']['safe'] ?? false ) ) { return array( 'required' => false, 'reason' => 'draft_safe_write', ); } if ( ! empty( $facts['has_unresolved_reference'] ) ) { return array( 'required' => true, 'reason' => 'unresolved_reference', ); } if ( 'wp.http.request' === $capability ) { $required = $this->is_http_mutating_request( $facts ); return array( 'required' => $required, 'reason' => $required ? 'http_mutating_request' : 'same_site_http_get', ); } return array( 'required' => $this->is_mutating( $facts ), 'reason' => 'default_policy', ); } /** * Return the currently configured policy settings. */ public function current_settings(): array { if ( null === $this->settings ) { return array(); } $settings = $this->settings->get_command_policy_settings(); $settings['auto_approve_safe_write_operations'] = $this->settings->get_auto_approve_safe_write_operations(); if ( ! $this->settings->get_allow_requests() ) { $settings['requests_disabled'] = true; } if ( ! $this->settings->get_allow_write_operations() ) { $settings['read_only'] = true; } return $settings; } /** * Return whether a capability should appear in settings-aware discovery. */ public function capability_enabled_for_discovery( string $capability, bool $dev_mode = false ): bool { if ( null === $this->settings ) { return true; } $settings = $this->current_settings(); if ( ! empty( $settings['requests_disabled'] ) ) { return false; } $denied_capabilities = isset( $settings['denied_capabilities'] ) && is_array( $settings['denied_capabilities'] ) ? array_map( 'strval', $settings['denied_capabilities'] ) : array(); if ( in_array( $capability, $denied_capabilities, true ) ) { return false; } if ( in_array( $capability, self::MAINTENANCE_DISABLED_BY_DEFAULT, true ) && ! ( $this->is_file_write_capability( $capability ) && $dev_mode ) && ! $this->maintenance_capability_enabled( $capability, $settings ) ) { return false; } return true; } /** * Return whether a command mutates site state. * * Mutating is identified by an explicit, exhaustive predicate: a CRUD write * action, or membership in the enumerated side-effecting special set. Any * future side-effecting capability must be added here or carry a resolved * WordPress capability fact, or the fail-closed gate denies it. * * @param array $facts Command facts. */ private function is_mutating( array $facts ): bool { $action = $this->scalar_fact( $facts, 'action' ); if ( in_array( $action, self::MUTATING_ACTIONS, true ) ) { return true; } $capability = $this->scalar_fact( $facts, 'capability' ); if ( 'wp.http.request' === $capability ) { return $this->is_http_mutating_request( $facts ); } return in_array( $capability, self::MUTATING_SPECIALS, true ); } /** * Return whether an HTTP request method can mutate site state. * * @param array $facts Command facts. */ private function is_http_mutating_request( array $facts ): bool { $target = isset( $facts['target'] ) && is_array( $facts['target'] ) ? $facts['target'] : array(); $method = isset( $target['method'] ) && is_scalar( $target['method'] ) ? strtoupper( (string) $target['method'] ) : 'GET'; return ! in_array( $method, array( 'GET', 'HEAD' ), true ); } /** * Return a hard-rule denial, if one applies. */ private function hard_rule_denial( array $facts, array $settings ): ?array { $capability = $this->scalar_fact( $facts, 'capability' ); if ( in_array( $capability, self::MAINTENANCE_DISABLED_BY_DEFAULT, true ) && ! ( $this->is_file_write_capability( $capability ) && ! empty( $facts['dev_mode'] ) ) && ! $this->maintenance_capability_enabled( $capability, $settings ) ) { return $this->deny( 'hard_rule', 'maintenance_disabled', 'This maintenance capability is disabled until ActionPanel settings explicitly enable it.' ); } foreach ( $this->touched_paths( $facts ) as $path ) { if ( $this->is_actionpanel_settings_path( $path ) ) { return $this->deny( 'hard_rule', 'actionpanel_settings', 'Assistant commands cannot modify ActionPanel settings.' ); } if ( $this->is_credential_path( $path ) ) { return $this->deny( 'hard_rule', 'credential_path', 'Credential and auth-material paths are blocked.' ); } } return null; } /** * Return site-wide settings denials that should mask command-specific checks. */ private function global_settings_denial( array $facts, array $settings ): ?array { if ( ! empty( $settings['requests_disabled'] ) ) { return $this->deny( 'settings', 'actionpanel_ai_requests_disabled', 'WordPress is not allowed to send requests to ActionPanel AI.' ); } if ( ! empty( $settings['read_only'] ) && $this->is_mutating( $facts ) ) { return $this->deny( 'settings', 'read_only', 'ActionPanel settings are in read-only mode.' ); } return null; } /** * Return a settings-layer denial, if one applies. */ private function settings_denial( array $facts, array $settings ): ?array { $capability = $this->scalar_fact( $facts, 'capability' ); $denied_capabilities = isset( $settings['denied_capabilities'] ) && is_array( $settings['denied_capabilities'] ) ? $settings['denied_capabilities'] : array(); if ( in_array( $capability, array_map( 'strval', $denied_capabilities ), true ) ) { return $this->deny( 'settings', 'capability_denied', 'ActionPanel settings deny this capability.' ); } $denied_paths = isset( $settings['denied_paths'] ) && is_array( $settings['denied_paths'] ) ? $settings['denied_paths'] : array(); foreach ( $this->touched_paths( $facts ) as $path ) { foreach ( $denied_paths as $pattern ) { if ( is_array( $pattern ) && $this->path_matches_pattern( $path, $pattern ) ) { return $this->deny( 'settings', 'path_denied', 'ActionPanel settings deny this path.' ); } } } return null; } /** * Return whether a hard-blocked maintenance capability is explicitly enabled. */ private function maintenance_capability_enabled( string $capability, array $settings ): bool { $enabled = isset( $settings['enabled_capabilities'] ) && is_array( $settings['enabled_capabilities'] ) ? array_map( 'strval', $settings['enabled_capabilities'] ) : array(); return in_array( $capability, $enabled, true ); } /** * Return whether a capability is one of the file-write shapes. */ private function is_file_write_capability( string $capability ): bool { return in_array( $capability, array( 'wp.files.create', 'wp.files.update', 'wp.files.delete' ), true ); } /** * Build a denial result. */ private function deny( string $layer, string $reason, string $message, array $extra_details = array() ): array { return array( 'allowed' => false, 'code' => 'permission_denied', 'message' => $message, 'details' => array_merge( array( 'layer' => $layer, 'reason' => $reason, ), $extra_details ), ); } /** * Return touched paths from command facts. * * @return array */ private function touched_paths( array $facts ): array { if ( ! isset( $facts['touched_paths'] ) || ! is_array( $facts['touched_paths'] ) ) { return array(); } return array_values( array_filter( $facts['touched_paths'], 'is_array' ) ); } /** * Return whether a path targets ActionPanel's own settings. */ private function is_actionpanel_settings_path( array $path ): bool { foreach ( $path as $segment ) { if ( is_scalar( $segment ) && str_starts_with( (string) $segment, 'actionpanel_ai' ) ) { return true; } } return false; } /** * Return whether a path names credential/auth material. */ private function is_credential_path( array $path ): bool { foreach ( $path as $segment ) { if ( ! is_scalar( $segment ) ) { continue; } $text = strtolower( (string) $segment ); foreach ( self::CREDENTIAL_PATH_NEEDLES as $needle ) { if ( false !== strpos( $text, $needle ) ) { return true; } } if ( in_array( $text, self::CREDENTIAL_PATH_NAMES, true ) ) { return true; } } return false; } /** * Return whether a concrete path matches a simple segment pattern. */ private function path_matches_pattern( array $path, array $pattern ): bool { if ( count( $pattern ) > count( $path ) ) { return false; } foreach ( $pattern as $index => $segment_pattern ) { $actual = isset( $path[ $index ] ) && is_scalar( $path[ $index ] ) ? (string) $path[ $index ] : ''; if ( ! is_scalar( $segment_pattern ) ) { return false; } $pattern_text = (string) $segment_pattern; if ( '*' === $pattern_text ) { continue; } if ( str_ends_with( $pattern_text, '*' ) ) { if ( ! str_starts_with( $actual, substr( $pattern_text, 0, -1 ) ) ) { return false; } continue; } if ( $actual !== $pattern_text ) { return false; } } return true; } private function scalar_fact( array $facts, string $key ): string { return isset( $facts[ $key ] ) && is_scalar( $facts[ $key ] ) ? (string) $facts[ $key ] : ''; } }