From 3a2ccf466d1771c836a647c84c6a616bf18c1eaa Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Sun, 9 Aug 2026 00:14:48 +0200 Subject: [PATCH 1/7] Expose plugin-wide command ownership lookup --- .../framework/lifecycle/FeatureCommandOwnership.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/framework/lifecycle/FeatureCommandOwnership.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/framework/lifecycle/FeatureCommandOwnership.java index 1dff2a0c8..576bba70d 100644 --- a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/framework/lifecycle/FeatureCommandOwnership.java +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/framework/lifecycle/FeatureCommandOwnership.java @@ -32,6 +32,10 @@ synchronized void release(Object owner, Collection labels) { } } + synchronized boolean isClaimed(String label) { + return label != null && ownersByLabel.containsKey(normalize(label)); + } + private static String normalize(String label) { return label.toLowerCase(Locale.ROOT); } From 093bb14db91a051b1dd375c74f315c6f445f6402 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Sun, 9 Aug 2026 00:15:30 +0200 Subject: [PATCH 2/7] Expose ServerFeatures command label ownership --- .../framework/lifecycle/FeatureCommandManager.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/framework/lifecycle/FeatureCommandManager.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/framework/lifecycle/FeatureCommandManager.java index ab6d6cd62..36926bd06 100644 --- a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/framework/lifecycle/FeatureCommandManager.java +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/framework/lifecycle/FeatureCommandManager.java @@ -179,7 +179,7 @@ private void doRegisterBrigadier(BrigadierCommand command) { } catch (Throwable throwable) { ownership.release(command, labels); plugin.getLogger().warning("[Brigadier] Failed to prepare labels for /" + name - + ": " + throwable.getMessage()); + + "': " + throwable.getMessage()); return; } if (!claim.claimed()) { @@ -254,6 +254,13 @@ public Set getAllRegisteredCommandNames() { return Collections.unmodifiableSet(names); } + /** + * Returns whether a command label is currently claimed by any ServerFeatures feature. + */ + public boolean isLabelOwnedByServerFeatures(String label) { + return ownership.isClaimed(label); + } + public Map getRegisteredFeatureCommands() { return Map.copyOf(registeredCommands); } From 2d3b48006bd1b9a06fed7033d4b42a4d717061a6 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Sun, 9 Aug 2026 00:16:04 +0200 Subject: [PATCH 3/7] Exclude ServerFeatures-owned commands from builtin discovery --- .../internal/BuiltinCommandDiscovery.java | 36 +++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/builtincommandblocker/internal/BuiltinCommandDiscovery.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/builtincommandblocker/internal/BuiltinCommandDiscovery.java index 33a6a62c9..1e7756139 100644 --- a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/builtincommandblocker/internal/BuiltinCommandDiscovery.java +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/builtincommandblocker/internal/BuiltinCommandDiscovery.java @@ -11,6 +11,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.function.Predicate; public final class BuiltinCommandDiscovery { @@ -31,6 +32,15 @@ public static BuiltinCommandSnapshot discover( Map knownCommands, Map serverAliases, BuiltinCommandBlockerSettings settings + ) { + return discover(knownCommands, serverAliases, settings, ignored -> false); + } + + public static BuiltinCommandSnapshot discover( + Map knownCommands, + Map serverAliases, + BuiltinCommandBlockerSettings settings, + Predicate serverFeaturesOwnedLabel ) { IdentityHashMap> registrations = new IdentityHashMap<>(); knownCommands.forEach((key, command) -> { @@ -44,6 +54,14 @@ public static BuiltinCommandSnapshot discover( LinkedHashMap detectedSources = emptySourceCounts(); registrations.forEach((command, keys) -> { + // Paper can expose custom Brigadier roots through VanillaCommandWrapper. An unnamespaced registration + // claimed by ServerFeatures is authoritative ownership evidence and must win over wrapper/package + // heuristics. Only plain registrations are considered here so displaced built-in fallbacks such as + // minecraft:restart remain independently discoverable and blockable. + if (hasServerFeaturesOwnedRegistration(keys, serverFeaturesOwnedLabel)) { + return; + } + BuiltinCommandSource source = classify(command, keys); if (source == null || !settings.blocks(source) || isAllowed(command, keys, source, settings)) { return; @@ -79,14 +97,14 @@ public static BuiltinCommandSnapshot discover( static BuiltinCommandSource classify(Command command, Collection registrationKeys) { String className = command.getClass().getName().toLowerCase(Locale.ROOT); - // Modern Paper command registrations are exposed through PluginVanillaCommandWrapper, which lives in - // io.papermc.paper.*. Plugin ownership therefore has to win over implementation-package heuristics. + // Plugin ownership is stronger evidence than Paper implementation packages or fallback namespaces. + // Spark is the one intentional exception because Paper bundles it as a platform command scope. if (command instanceof PluginIdentifiableCommand identifiable) { String pluginName = identifiable.getPlugin().getName().toLowerCase(Locale.ROOT); if (pluginName.equals("spark") || pluginName.equals("spark-paper")) { return BuiltinCommandSource.SPARK; } - return sourceFromRegistrationNamespace(registrationKeys); + return null; } if (isBundledSparkCommand(command, className)) { @@ -130,6 +148,18 @@ static boolean isAliasRegistration(Command command, String registrationKey) { return true; } + private static boolean hasServerFeaturesOwnedRegistration( + Collection registrationKeys, + Predicate serverFeaturesOwnedLabel + ) { + for (String key : registrationKeys) { + if (!key.isEmpty() && key.indexOf(':') < 0 && serverFeaturesOwnedLabel.test(key)) { + return true; + } + } + return false; + } + private static void addBlockedServerAliases( Map knownCommands, Map serverAliases, From 1c67a905bb7121e31c2c1dff46165eb5966f154d Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Sun, 9 Aug 2026 00:16:21 +0200 Subject: [PATCH 4/7] Use ServerFeatures ownership during builtin discovery --- .../internal/BuiltinCommandBlockerService.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/builtincommandblocker/internal/BuiltinCommandBlockerService.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/builtincommandblocker/internal/BuiltinCommandBlockerService.java index e3f59355b..e7a86b11e 100644 --- a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/builtincommandblocker/internal/BuiltinCommandBlockerService.java +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/builtincommandblocker/internal/BuiltinCommandBlockerService.java @@ -62,7 +62,8 @@ public boolean refresh() { BuiltinCommandSnapshot next = BuiltinCommandDiscovery.discover( effectiveCommands, serverAliases, - settings + settings, + feature.getLifecycleManager().getCommandManager()::isLabelOwnedByServerFeatures ); if (settings.removeFromCommandMap()) { From b19abde76a6880b107dca012c7cf0fe6162f1695 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Sun, 9 Aug 2026 00:16:44 +0200 Subject: [PATCH 5/7] Add builtin command ownership regressions --- .../internal/BuiltinCommandOwnershipTest.java | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/builtincommandblocker/internal/BuiltinCommandOwnershipTest.java diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/builtincommandblocker/internal/BuiltinCommandOwnershipTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/builtincommandblocker/internal/BuiltinCommandOwnershipTest.java new file mode 100644 index 000000000..74ee5cc29 --- /dev/null +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/builtincommandblocker/internal/BuiltinCommandOwnershipTest.java @@ -0,0 +1,129 @@ +package nl.hauntedmc.serverfeatures.features.builtincommandblocker.internal; + +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.PluginIdentifiableCommand; +import org.bukkit.plugin.Plugin; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; + +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class BuiltinCommandOwnershipTest { + + @Test + void serverFeaturesOwnedVanillaWrapperIsNotClassifiedAsMinecraft() { + Command command = new FakeVanillaCommandWrapper("autopickup"); + Map commands = registrations( + "autopickup", command, + "minecraft:autopickup", command + ); + + BuiltinCommandSnapshot snapshot = BuiltinCommandDiscovery.discover( + commands, + Map.of(), + allBlocked(), + Set.of("autopickup")::contains + ); + + assertTrue(snapshot.blockedCommands().isEmpty()); + assertEquals(0, snapshot.detectedSources().get("minecraft")); + } + + @Test + void namespacedBuiltinFallbackRemainsBlockableWhenPlainLabelIsOwned() { + Command command = new FakeVanillaCommandWrapper("restart"); + + BuiltinCommandSnapshot snapshot = BuiltinCommandDiscovery.discover( + registrations("minecraft:restart", command), + Map.of(), + allBlocked(), + Set.of("restart")::contains + ); + + assertEquals(Set.of("minecraft:restart"), snapshot.blockedCommands()); + assertEquals(1, snapshot.detectedSources().get("minecraft")); + } + + @Test + void pluginIdentifiableCommandWinsOverBuiltinNamespaceHeuristics() { + Plugin plugin = mock(Plugin.class); + when(plugin.getName()).thenReturn("ExamplePlugin"); + Command command = new PluginOwnedCommand("friends", plugin); + + BuiltinCommandSnapshot snapshot = BuiltinCommandDiscovery.discover( + registrations( + "friends", command, + "minecraft:friends", command + ), + allBlocked() + ); + + assertTrue(snapshot.blockedCommands().isEmpty()); + } + + private static BuiltinCommandBlockerSettings allBlocked() { + return new BuiltinCommandBlockerSettings( + EnumSet.allOf(BuiltinCommandSource.class), + true, + false, + Set.of() + ); + } + + private static Map registrations(Object... entries) { + LinkedHashMap result = new LinkedHashMap<>(); + for (int index = 0; index < entries.length; index += 2) { + result.put((String) entries[index], (Command) entries[index + 1]); + } + return result; + } + + private static class FakeVanillaCommandWrapper extends Command { + + private FakeVanillaCommandWrapper(String name) { + super(name); + } + + @Override + public boolean execute( + @NotNull CommandSender sender, + @NotNull String commandLabel, + @NotNull String @NotNull [] args + ) { + return true; + } + } + + private static final class PluginOwnedCommand extends Command implements PluginIdentifiableCommand { + + private final Plugin plugin; + + private PluginOwnedCommand(String name, Plugin plugin) { + super(name); + this.plugin = plugin; + } + + @Override + public @NotNull Plugin getPlugin() { + return plugin; + } + + @Override + public boolean execute( + @NotNull CommandSender sender, + @NotNull String commandLabel, + @NotNull String @NotNull [] args + ) { + return true; + } + } +} From 3cf7eaa1d7dce7f7e6d15fe6130519df35f80879 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Sun, 9 Aug 2026 00:19:09 +0200 Subject: [PATCH 6/7] Restore Brigadier warning text --- .../framework/lifecycle/FeatureCommandManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/framework/lifecycle/FeatureCommandManager.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/framework/lifecycle/FeatureCommandManager.java index 36926bd06..4efdc4e92 100644 --- a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/framework/lifecycle/FeatureCommandManager.java +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/framework/lifecycle/FeatureCommandManager.java @@ -179,7 +179,7 @@ private void doRegisterBrigadier(BrigadierCommand command) { } catch (Throwable throwable) { ownership.release(command, labels); plugin.getLogger().warning("[Brigadier] Failed to prepare labels for /" + name - + "': " + throwable.getMessage()); + + ": " + throwable.getMessage()); return; } if (!claim.claimed()) { From fdcaee277f6f31ed6e20cbb1d7e18796ffd3116c Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Sun, 9 Aug 2026 21:08:39 +0200 Subject: [PATCH 7/7] Potential fix for pull request finding 'Array index out of bounds' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Signed-off-by: Remy Duijsens --- .../internal/BuiltinCommandOwnershipTest.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/builtincommandblocker/internal/BuiltinCommandOwnershipTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/builtincommandblocker/internal/BuiltinCommandOwnershipTest.java index 74ee5cc29..00467a00f 100644 --- a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/builtincommandblocker/internal/BuiltinCommandOwnershipTest.java +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/builtincommandblocker/internal/BuiltinCommandOwnershipTest.java @@ -80,6 +80,10 @@ private static BuiltinCommandBlockerSettings allBlocked() { } private static Map registrations(Object... entries) { + if (entries.length % 2 != 0) { + throw new IllegalArgumentException("registrations requires key/value pairs"); + } + LinkedHashMap result = new LinkedHashMap<>(); for (int index = 0; index < entries.length; index += 2) { result.put((String) entries[index], (Command) entries[index + 1]);