feat: add config to enable/disable commands and set their permission level (#2799)

This commit is contained in:
Dark Star
2026-08-12 21:47:40 +07:00
committed by GitHub
parent 449c7162c0
commit 55312adfa1
8 changed files with 525 additions and 8 deletions

View File

@@ -1,3 +1,5 @@
use std::collections::HashMap;
use pumpkin_util::PermissionLvl;
use serde::{Deserialize, Serialize};
@@ -19,6 +21,26 @@ pub struct CommandsConfig {
pub broadcast_console_to_ops: bool,
/// The `op` permission level of everyone that is not in the `ops` file.
pub default_op_level: PermissionLvl,
/// Per-command settings, so you can turn individual commands off or change
/// who is allowed to use them.
///
/// Each entry is named after the command it applies to (without the leading
/// slash), for example `gamemode` or `tp`. You only need to list the
/// commands you actually want to change; everything you leave out keeps its
/// normal behaviour.
///
/// Example:
///
/// ```toml
/// # Only server owners may change gamemodes
/// [commands.overrides.gamemode]
/// permission_level = 4
///
/// # Turn the /tp command off completely
/// [commands.overrides.tp]
/// enabled = false
/// ```
pub overrides: HashMap<String, CommandOverride>,
}
impl Default for CommandsConfig {
@@ -29,6 +51,38 @@ impl Default for CommandsConfig {
use_tty: true,
broadcast_console_to_ops: true,
default_op_level: PermissionLvl::Zero,
overrides: HashMap::new(),
}
}
}
/// Settings for a single command, letting a server owner turn it off or change
/// who is allowed to run it.
#[derive(Deserialize, Serialize)]
#[serde(default)]
pub struct CommandOverride {
/// Whether this command can be used at all. When set to `false` the command
/// is hidden completely: it won't run, won't show up in the command list,
/// and won't appear in tab-completion. Players who try to use it simply get
/// the normal "unknown command" message. Set to `true` (the default) to
/// leave the command available.
pub enabled: bool,
/// Who is allowed to use this command, given as a permission level:
///
/// - `0` = everyone can use it
/// - `2` = normal operators (the usual level for most cheat-style commands)
/// - `3` = admins (player management, kicking, banning, and so on)
/// - `4` = the server owner only (full server management)
///
/// Leave this out to keep the command's normal requirement.
pub permission_level: Option<PermissionLvl>,
}
impl Default for CommandOverride {
fn default() -> Self {
Self {
enabled: true,
permission_level: None,
}
}
}

View File

@@ -30,7 +30,7 @@ pub mod recipe;
pub mod resource_pack;
pub use chat::ChatConfig;
pub use commands::CommandsConfig;
pub use commands::{CommandOverride, CommandsConfig};
pub use networking::auth::AuthenticationConfig;
pub use networking::bedrock::BedrockConfig;
pub use networking::compression::CompressionConfig;

View File

@@ -125,6 +125,27 @@ impl PermissionRegistry {
self.permissions.get(node)
}
/// Overrides the default behaviour of an already-registered permission.
///
/// Used to apply per-command permission overrides from the server
/// configuration after the built-in permissions have been registered.
///
/// # Parameters
/// - `node`: The full permission node string to update.
/// - `default`: The new default behaviour to apply.
///
/// # Returns
/// - `true` if the node existed and its default was updated.
/// - `false` if no permission with that node is registered.
pub fn set_default(&mut self, node: &str, default: PermissionDefault) -> bool {
if let Some(permission) = self.permissions.get_mut(node) {
permission.default = default;
true
} else {
false
}
}
/// Checks whether a permission node exists in the registry.
///
/// # Parameters

View File

@@ -32,6 +32,10 @@ pub async fn send_c_commands_packet(
let fallback_dispatcher = &dispatcher.fallback_dispatcher;
for key in fallback_dispatcher.commands.keys() {
if dispatcher.is_disabled(key) {
continue;
}
let Ok(tree) = fallback_dispatcher.get_tree(key) else {
continue;
};
@@ -91,7 +95,30 @@ pub async fn send_c_commands_packet(
match node {
AttachedNode::Root(_) => {
root_node_children_second = children;
// Drop disabled commands from the root's child list so they
// disappear from the client's command graph (and tab-completion)
// entirely. The nodes themselves stay in `proto_nodes` to keep
// every other node's indices valid; they simply become
// unreachable.
root_node_children_second = node
.children_ref()
.values()
.copied()
.filter(|id| {
let disabled = match &dispatcher.tree[*id] {
AttachedNode::Literal(child) => {
dispatcher.is_disabled(&child.meta.literal_lowercase)
}
AttachedNode::Command(child) => {
dispatcher.is_disabled(&child.meta.literal_lowercase)
}
_ => false,
};
!disabled
})
.map(|id| resolve_node_id(id, node_id_offset, root_node_index))
.map(|i| i.try_into().expect("i32 limit reached for ids"))
.collect();
}
AttachedNode::Literal(literal_attached_node) => {
let node = ProtoNode {
@@ -272,6 +299,10 @@ pub async fn send_bedrock_commands_packet(
let fallback_dispatcher = &dispatcher.fallback_dispatcher;
for key in fallback_dispatcher.commands.keys() {
if dispatcher.is_disabled(key) {
continue;
}
let Ok(tree) = fallback_dispatcher.get_tree(key) else {
continue;
};
@@ -335,6 +366,10 @@ pub async fn send_bedrock_commands_packet(
_ => continue,
};
if dispatcher.is_disabled(&name.to_ascii_lowercase()) {
continue;
}
let mut ctx = BuilderContext {
enum_values: &mut enum_values,
enums: &mut enums,

View File

@@ -1,10 +1,12 @@
use crate::command::node::dispatcher::CommandDispatcher;
use pumpkin_config::BasicConfiguration;
use crate::command::tree::Command;
use pumpkin_config::{BasicConfiguration, CommandsConfig};
use pumpkin_util::{
PermissionLvl,
permission::{Permission, PermissionDefault, PermissionRegistry},
};
use tokio::sync::RwLock;
use tracing::{info, warn};
mod advancement;
mod attribute;
@@ -88,6 +90,7 @@ mod worldborder;
pub async fn default_dispatcher(
registry: &RwLock<PermissionRegistry>,
_basic_config: &BasicConfiguration,
commands_config: &CommandsConfig,
) -> CommandDispatcher {
let mut dispatcher = crate::command::dispatcher::CommandDispatcher::default();
@@ -206,9 +209,113 @@ pub async fn default_dispatcher(
clone::register(&mut dispatcher, registry);
attribute::register(&mut dispatcher, registry);
fetchprofile::register(&mut dispatcher, registry);
apply_command_overrides(&mut dispatcher, registry, commands_config);
dispatcher
}
/// Applies the per-command settings from the server configuration on top of the
/// freshly built dispatcher.
///
/// Two kinds of override are supported:
/// - Disabling a command, which removes it from the legacy dispatcher and marks
/// its name so the wrapper dispatcher hides it everywhere else.
/// - Changing a command's required permission level, which is done by rewriting
/// the default of its permission node in the registry. Because command
/// requirements look their permission up in the registry at execution time,
/// this affects both the legacy and the node-based dispatchers uniformly.
fn apply_command_overrides(
dispatcher: &mut CommandDispatcher,
registry: &mut PermissionRegistry,
commands_config: &CommandsConfig,
) {
for (raw_name, settings) in &commands_config.overrides {
// Command names are always lowercase, so normalise here to be forgiving
// of how the owner wrote them in the config file.
let name = raw_name.to_ascii_lowercase();
// Catch typos: an override for a command that does not exist almost
// always means the owner misspelled it, so tell them instead of silently
// doing nothing.
if !dispatcher.has_command(&name) {
warn!(
"Ignoring the command setting for \"{raw_name}\" because there is no command with that name (check the spelling in your config)"
);
continue;
}
if !settings.enabled {
// If the owner named an alias (e.g. `tp` for `teleport`), turn off
// the whole command, not just that one alias.
let primary = match dispatcher.fallback_dispatcher.commands.get(&name) {
Some(Command::Alias(target)) => target.clone(),
_ => name.clone(),
};
dispatcher.disable_command(name.clone());
dispatcher.disable_command(primary.clone());
// Node-based commands keep their aliases as redirecting root nodes,
// so flag those too. (Legacy aliases are handled by the unregister
// below, which removes them from the dispatcher outright.)
for alias in dispatcher.tree_alias_names(&primary) {
dispatcher.disable_command(alias);
}
// Unregistering the primary name cascades to every alias in the
// legacy dispatcher.
dispatcher.fallback_dispatcher.unregister(&primary);
info!("The /{primary} command has been turned off in the configuration");
// A disabled command can never be run, so its permission level is
// irrelevant; skip the rest.
continue;
}
if let Some(level) = settings.permission_level {
let default = if level == PermissionLvl::Zero {
PermissionDefault::Allow
} else {
PermissionDefault::Op(level)
};
if let Some(node) = resolve_permission_node(dispatcher, registry, &name) {
registry.set_default(&node, default);
info!(
"The /{name} command now needs permission level {} to use",
level as u8
);
} else {
warn!(
"Command override for /{name} sets a permission level, but no matching permission node could be found; leaving it unchanged"
);
}
}
}
}
/// Finds the permission node associated with a command name.
///
/// Legacy commands record their node in the dispatcher directly. Node-based
/// commands do not, but they follow the `<namespace>:command.<name>` convention,
/// so we fall back to probing the registry for those.
fn resolve_permission_node(
dispatcher: &CommandDispatcher,
registry: &PermissionRegistry,
name: &str,
) -> Option<String> {
if let Some(node) = dispatcher.fallback_dispatcher.permissions.get(name) {
return Some(node.clone());
}
for namespace in ["minecraft", "pumpkin"] {
let candidate = format!("{namespace}:command.{name}");
if registry.get_permission(&candidate).is_some() {
return Some(candidate);
}
}
None
}
fn register_permissions(registry: &mut PermissionRegistry) {
// Register level 0 permissions (allowed by default)
register_level_0_permissions(registry);
@@ -549,3 +656,172 @@ fn register_level_3_permissions(registry: &mut PermissionRegistry) {
))
.expect("Permission already registered");
}
#[cfg(test)]
mod override_tests {
use pumpkin_config::{BasicConfiguration, CommandOverride, CommandsConfig};
use pumpkin_util::PermissionLvl;
use pumpkin_util::permission::{PermissionDefault, PermissionRegistry};
use tokio::sync::RwLock;
use super::default_dispatcher;
fn disabled(config: &mut CommandsConfig, name: &str) {
config.overrides.insert(
name.to_string(),
CommandOverride {
enabled: false,
permission_level: None,
},
);
}
fn permission(config: &mut CommandsConfig, name: &str, level: PermissionLvl) {
config.overrides.insert(
name.to_string(),
CommandOverride {
enabled: true,
permission_level: Some(level),
},
);
}
#[tokio::test]
async fn disabling_a_command_removes_and_hides_it() {
let basic = BasicConfiguration::default();
let mut commands = CommandsConfig::default();
// `gamemode` lives on the legacy dispatcher; disabling it should remove
// it there and flag it on the wrapper.
disabled(&mut commands, "gamemode");
let registry = RwLock::new(PermissionRegistry::new());
let dispatcher = default_dispatcher(&registry, &basic, &commands).await;
assert!(dispatcher.is_disabled("gamemode"));
assert!(
dispatcher.fallback_dispatcher.get_tree("gamemode").is_err(),
"disabled command should be unregistered from the legacy dispatcher"
);
assert!(
!dispatcher.is_disabled("give"),
"untouched commands stay on"
);
}
#[tokio::test]
async fn disabling_an_alias_turns_off_the_whole_command() {
let basic = BasicConfiguration::default();
let mut commands = CommandsConfig::default();
// `tp` is an alias of `teleport`; disabling it should take the whole
// command down, including the primary name.
disabled(&mut commands, "tp");
let registry = RwLock::new(PermissionRegistry::new());
let dispatcher = default_dispatcher(&registry, &basic, &commands).await;
assert!(dispatcher.is_disabled("tp"));
assert!(dispatcher.is_disabled("teleport"));
assert!(dispatcher.fallback_dispatcher.get_tree("tp").is_err());
assert!(dispatcher.fallback_dispatcher.get_tree("teleport").is_err());
}
#[tokio::test]
async fn disabling_a_node_command_also_disables_its_aliases() {
let basic = BasicConfiguration::default();
let mut commands = CommandsConfig::default();
// `help` is a node-based command with the aliases `h` and `?`.
disabled(&mut commands, "help");
let registry = RwLock::new(PermissionRegistry::new());
let dispatcher = default_dispatcher(&registry, &basic, &commands).await;
assert!(dispatcher.is_disabled("help"));
assert!(dispatcher.is_disabled("h"));
assert!(dispatcher.is_disabled("?"));
}
#[tokio::test]
async fn override_for_unknown_command_is_ignored() {
let basic = BasicConfiguration::default();
let mut commands = CommandsConfig::default();
// A command name that does not exist (usually a typo in the config)
// should be ignored, not silently swallow a real command or panic.
disabled(&mut commands, "notacommand");
let registry = RwLock::new(PermissionRegistry::new());
let dispatcher = default_dispatcher(&registry, &basic, &commands).await;
assert!(!dispatcher.is_disabled("notacommand"));
assert!(dispatcher.fallback_dispatcher.get_tree("gamemode").is_ok());
}
#[tokio::test]
async fn override_is_case_insensitive() {
let basic = BasicConfiguration::default();
let mut commands = CommandsConfig::default();
disabled(&mut commands, "GameMode");
let registry = RwLock::new(PermissionRegistry::new());
let dispatcher = default_dispatcher(&registry, &basic, &commands).await;
assert!(dispatcher.is_disabled("gamemode"));
}
#[tokio::test]
async fn permission_level_override_rewrites_the_registry_default() {
let basic = BasicConfiguration::default();
let mut commands = CommandsConfig::default();
// `gamemode` is normally level 2; bump it to owner-only.
permission(&mut commands, "gamemode", PermissionLvl::Four);
let registry = RwLock::new(PermissionRegistry::new());
let _dispatcher = default_dispatcher(&registry, &basic, &commands).await;
let registry = registry.read().await;
let permission = registry
.get_permission("minecraft:command.gamemode")
.expect("gamemode permission should be registered");
assert_eq!(
permission.default,
PermissionDefault::Op(PermissionLvl::Four)
);
}
#[tokio::test]
async fn permission_override_resolves_node_command_by_convention() {
let basic = BasicConfiguration::default();
let mut commands = CommandsConfig::default();
// `kill` is a node-based command whose permission node is not recorded in
// the legacy dispatcher, so the override must fall back to the
// `minecraft:command.kill` naming convention.
permission(&mut commands, "kill", PermissionLvl::Four);
let registry = RwLock::new(PermissionRegistry::new());
let _dispatcher = default_dispatcher(&registry, &basic, &commands).await;
let registry = registry.read().await;
let permission = registry
.get_permission("minecraft:command.kill")
.expect("kill permission should be registered");
assert_eq!(
permission.default,
PermissionDefault::Op(PermissionLvl::Four)
);
}
#[tokio::test]
async fn permission_level_zero_allows_everyone() {
let basic = BasicConfiguration::default();
let mut commands = CommandsConfig::default();
permission(&mut commands, "gamemode", PermissionLvl::Zero);
let registry = RwLock::new(PermissionRegistry::new());
let _dispatcher = default_dispatcher(&registry, &basic, &commands).await;
let registry = registry.read().await;
let permission = registry
.get_permission("minecraft:command.gamemode")
.expect("gamemode permission should be registered");
assert_eq!(permission.default, PermissionDefault::Allow);
}
}

View File

@@ -727,8 +727,9 @@ mod test {
#[tokio::test]
async fn dynamic_command() {
let config = BasicConfiguration::default();
let commands_config = pumpkin_config::CommandsConfig::default();
let registry = RwLock::new(PermissionRegistry::new());
let mut dispatcher = default_dispatcher(&registry, &config)
let mut dispatcher = default_dispatcher(&registry, &config, &commands_config)
.await
.fallback_dispatcher;
let tree = CommandTree::new(["test"], "test_desc");
@@ -738,8 +739,9 @@ mod test {
#[tokio::test]
async fn pumpkin_command_aliases() {
let config = BasicConfiguration::default();
let commands_config = pumpkin_config::CommandsConfig::default();
let registry = RwLock::new(PermissionRegistry::new());
let dispatcher = default_dispatcher(&registry, &config)
let dispatcher = default_dispatcher(&registry, &config, &commands_config)
.await
.fallback_dispatcher;

View File

@@ -21,7 +21,7 @@ use pumpkin_protocol::java::client::play::CommandSuggestion;
use pumpkin_util::text::TextComponent;
use pumpkin_util::text::click::ClickEvent;
use pumpkin_util::text::color::{Color, NamedColor};
use rustc_hash::FxHashMap;
use rustc_hash::{FxHashMap, FxHashSet};
use std::collections::BTreeMap;
use std::pin::Pin;
use std::sync::{Arc, LazyLock};
@@ -103,6 +103,11 @@ pub struct CommandDispatcher {
// We add this because we have a lot of commands
// still dependent on this dispatcher.
pub fallback_dispatcher: crate::command::dispatcher::CommandDispatcher,
/// Primary names of commands that have been turned off through the server
/// configuration. A disabled command behaves as if it does not exist: it
/// cannot be executed and is left out of listings and suggestions.
disabled: FxHashSet<String>,
}
impl Default for CommandDispatcher {
@@ -124,9 +129,70 @@ impl CommandDispatcher {
tree,
consumer: RESULT_DEFERRER.clone(),
fallback_dispatcher: crate::command::dispatcher::CommandDispatcher::default(),
disabled: FxHashSet::default(),
}
}
/// Turns a command off. A disabled command's primary name is recorded here
/// so that it can no longer be executed, listed, or suggested, regardless of
/// which internal dispatcher it lives on.
pub fn disable_command(&mut self, name: impl Into<String>) {
self.disabled.insert(name.into());
}
/// Returns `true` if the command with the given primary name has been turned
/// off through the server configuration.
#[must_use]
pub fn is_disabled(&self, name: &str) -> bool {
self.disabled.contains(name)
}
/// Returns `true` if a command (or alias) with the given name is registered
/// on either the node-based tree or the legacy dispatcher.
#[must_use]
pub fn has_command(&self, name: &str) -> bool {
self.tree.get(name).is_some() || self.fallback_dispatcher.commands.contains_key(name)
}
/// Collects the names of every root-level alias that redirects to the command
/// with the given primary name.
///
/// Node-based commands model their aliases as extra root literals that
/// redirect to the primary node (see `register_with_aliases`). When a command
/// is disabled we also need to turn those aliases off, otherwise a player
/// could still reach the live executor through one of them.
#[must_use]
pub fn tree_alias_names(&self, primary: &str) -> Vec<String> {
let Some(primary_id) = self.tree.get(primary) else {
return Vec::new();
};
let primary_node: NodeId = primary_id.into();
let mut names = Vec::new();
for child in self.tree.get_root_children() {
// Index by `NodeId` so we get the `AttachedNode` enum (which exposes
// `redirect`/`name`) rather than the inner command node.
let node_id: NodeId = child.into();
let node = &self.tree[node_id];
if let Some(redirect) = node.redirect()
&& self.tree.resolve(redirect) == Some(primary_node)
{
names.push(node.name().to_ascii_lowercase());
}
}
names
}
/// Extracts the command name (the first whitespace-separated token) from a
/// raw input string, ignoring any leading slash.
fn command_name(input: &str) -> &str {
input
.trim_start_matches('/')
.split_whitespace()
.next()
.unwrap_or("")
}
/// Registers a command which can then be dispatched.
/// Returns the local ID of the node attached to the tree.
///
@@ -204,6 +270,15 @@ impl CommandDispatcher {
source: &CommandSource,
) -> Result<i32, CommandSyntaxError> {
let mut reader = StringReader::new(input);
// A disabled command must behave as if it does not exist from every
// execution path, not just `handle_command`. This backstop covers
// programmatic callers such as `/execute run <command>`, which reach the
// dispatcher here without going through `handle_command`.
if self.is_disabled(Self::command_name(input)) {
return Err(DISPATCHER_UNKNOWN_COMMAND.create(&reader));
}
self.execute_reader(&mut reader, source).await
}
@@ -386,6 +461,16 @@ impl CommandDispatcher {
input = sliced;
}
// A command that has been turned off in the configuration must behave as
// if it does not exist, so we report the usual "unknown command" error
// before either dispatcher gets a chance to run it.
if self.is_disabled(Self::command_name(input)) {
let reader = StringReader::new(input);
Self::send_error_to_source(source, DISPATCHER_UNKNOWN_COMMAND.create(&reader), input)
.await;
return;
}
let output = self.execute_input(input, source).await;
if let Err(error) = output {
@@ -562,6 +647,11 @@ impl CommandDispatcher {
/// This function currently panics if the source provided was a dummy source.
/// This is subject to change in the future.
pub async fn suggest(&self, input: &str, source: &CommandSource) -> Vec<CommandSuggestion> {
// Never suggest arguments for a command that has been turned off.
if self.is_disabled(Self::command_name(input)) {
return Vec::new();
}
let future1 = async move {
let parsed = self.parse_input(input, source).await;
let suggestions = self.get_completion_suggestions_at_end(parsed).await;
@@ -596,11 +686,17 @@ impl CommandDispatcher {
for command in self.tree.get_root_children() {
let meta = &self.tree[command].meta;
if self.is_disabled(&meta.literal_lowercase) {
continue;
}
commands.insert(&meta.literal_lowercase, &meta.description);
}
for fallback_command in self.fallback_dispatcher.commands.values() {
if let Command::Tree(command_tree) = fallback_command {
if self.is_disabled(&command_tree.names[0]) {
continue;
}
for name in &command_tree.names {
commands.insert(name, &command_tree.description);
}
@@ -621,12 +717,18 @@ impl CommandDispatcher {
for command in self.tree.get_root_children() {
if self.tree.can_use(command.into(), source).await {
let meta = &self.tree[command].meta;
if self.is_disabled(&meta.literal_lowercase) {
continue;
}
commands.insert(&meta.literal_lowercase, &meta.description);
}
}
for fallback_command in self.fallback_dispatcher.commands.values() {
if let Command::Tree(command_tree) = fallback_command {
if self.is_disabled(&command_tree.names[0]) {
continue;
}
if let Some(permission) = self
.fallback_dispatcher
.permissions
@@ -662,12 +764,16 @@ impl CommandDispatcher {
for (command_node_id, usage) in self.get_usage_of_commands(source).await {
let meta = &self.tree[command_node_id].meta;
let command_name = meta.literal.as_ref();
if self.is_disabled(&meta.literal_lowercase) {
continue;
}
let command_description = meta.description.as_ref();
commands.insert(command_name, (command_description, usage.into_boxed_str()));
}
for fallback_command in self.fallback_dispatcher.commands.values() {
if let Command::Tree(command_tree) = fallback_command
&& !self.is_disabled(&command_tree.names[0])
&& let Some(permission) = self
.fallback_dispatcher
.permissions
@@ -977,6 +1083,23 @@ mod test {
assert_eq!(result, Ok(1));
}
#[tokio::test]
async fn disabled_command_cannot_be_executed_directly() {
// Guards the `/execute run <command>` bypass: a disabled command must be
// rejected even when reached through `execute_input` rather than
// `handle_command`.
let mut dispatcher = CommandDispatcher::new();
let executor: for<'c> fn(&'c CommandContext) -> CommandExecutorResult<'c> =
|_| Box::pin(async move { Ok(1) });
dispatcher
.register(CommandArgumentBuilder::new("simple", "A simple command").executes(executor));
dispatcher.disable_command("simple");
let source = CommandSource::dummy();
let result = dispatcher.execute_input("simple", &source).await;
assert!(result.is_err_and(|error| error.error_type == &DISPATCHER_UNKNOWN_COMMAND));
}
#[tokio::test]
async fn arithmetic_command() {
enum Operation {

View File

@@ -154,8 +154,14 @@ impl Server {
) -> Arc<Self> {
let permission_registry = Arc::new(RwLock::new(PermissionRegistry::new()));
// First register the default commands. After that, plugins can put in their own.
let command_dispatcher =
RwLock::new(default_dispatcher(&permission_registry, &basic_config).await);
let command_dispatcher = RwLock::new(
default_dispatcher(
&permission_registry,
&basic_config,
&advanced_config.commands,
)
.await,
);
crate::command::set_broadcast_console_to_ops(
advanced_config.commands.broadcast_console_to_ops,