From aabf331e91fb78e9a4104f4893decc19c1c40cb5 Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Mon, 11 May 2026 18:58:55 +0300 Subject: [PATCH] fix(providers): guard invalid skill command namespaces Claude plugin ids come from local settings and installed plugin metadata. Invalid ids such as empty strings or @ should not become command namespaces. Skip plugin folders when no safe plugin name can be derived. This prevents malformed slash commands like /:command from reaching the UI. Add regression coverage for empty and @ plugin ids. Keyboard selection in the slash menu should match mouse selection. Only skills are inserted into the composer because they are provider invocations. Built-in and custom commands execute directly and close the menu on success or failure. --- .../list/claude/claude-skills.provider.ts | 17 +++++-- server/modules/providers/tests/skills.test.ts | 47 +++++++++++++++++++ src/components/chat/hooks/useSlashCommands.ts | 47 ++++++++++--------- 3 files changed, 85 insertions(+), 26 deletions(-) diff --git a/server/modules/providers/list/claude/claude-skills.provider.ts b/server/modules/providers/list/claude/claude-skills.provider.ts index 9795529a..d00b27e6 100644 --- a/server/modules/providers/list/claude/claude-skills.provider.ts +++ b/server/modules/providers/list/claude/claude-skills.provider.ts @@ -20,9 +20,14 @@ import { const getClaudeHomePath = (): string => path.join(os.homedir(), '.claude'); -const getClaudePluginName = (pluginId: string): string => { - const [pluginName] = pluginId.split('@'); - return pluginName || pluginId; +const getClaudePluginName = (pluginId: string): string | null => { + const normalizedPluginId = pluginId.trim(); + if (!normalizedPluginId || normalizedPluginId === '@') { + return null; + } + + const [pluginName] = normalizedPluginId.split('@'); + return readOptionalString(pluginName) ?? null; }; const stripMarkdownExtension = (filename: string): string => @@ -52,7 +57,7 @@ const listChildDirectories = async (directoryPath: string): Promise => const readClaudePluginName = async ( installPath: string, pluginId: string, -): Promise => { +): Promise => { try { const pluginConfig = await readJsonConfig( path.join(installPath, '.claude-plugin', 'plugin.json'), @@ -142,6 +147,10 @@ export class ClaudeSkillsProvider extends SkillsProvider { visitedPluginFolders.add(pluginFolderKey); const pluginName = await readClaudePluginName(pluginFolder, pluginId); + if (!pluginName) { + continue; + } + const commandsPath = path.join(pluginFolder, 'commands'); if (await pathExistsAsDirectory(commandsPath)) { skills.push( diff --git a/server/modules/providers/tests/skills.test.ts b/server/modules/providers/tests/skills.test.ts index c0690914..179ae400 100644 --- a/server/modules/providers/tests/skills.test.ts +++ b/server/modules/providers/tests/skills.test.ts @@ -101,6 +101,24 @@ test('providerSkillsService lists claude user, project, and enabled plugin skill 'disabled-skills', 'ghi789', ); + const emptyIdPluginInstallPath = path.join( + tempRoot, + '.claude', + 'plugins', + 'cache', + 'invalid-empty-plugin', + 'empty', + '000', + ); + const atIdPluginInstallPath = path.join( + tempRoot, + '.claude', + 'plugins', + 'cache', + 'invalid-at-plugin', + 'at', + '000', + ); const siblingSkillPluginPath = path.join(path.dirname(skillPluginInstallPath), 'legacy777'); await fs.mkdir(workspacePath, { recursive: true }); @@ -161,6 +179,16 @@ test('providerSkillsService lists claude user, project, and enabled plugin skill 'disabled-command', 'Disabled plugin command', ); + await writeClaudePluginCommand( + path.join(emptyIdPluginInstallPath, 'commands'), + 'invalid-empty-command', + 'Invalid empty id command', + ); + await writeClaudePluginCommand( + path.join(atIdPluginInstallPath, 'commands'), + 'invalid-at-command', + 'Invalid at id command', + ); await writeSkill( path.join( disabledPluginInstallPath, @@ -176,6 +204,8 @@ test('providerSkillsService lists claude user, project, and enabled plugin skill JSON.stringify( { enabledPlugins: { + '': true, + '@': true, 'notion@notion-marketplace': true, 'example-skills@anthropic-agent-skills': true, 'disabled-skills@disabled-marketplace': false, @@ -192,6 +222,20 @@ test('providerSkillsService lists claude user, project, and enabled plugin skill { version: 2, plugins: { + '': [ + { + scope: 'user', + installPath: emptyIdPluginInstallPath, + version: '000', + }, + ], + '@': [ + { + scope: 'user', + installPath: atIdPluginInstallPath, + version: '000', + }, + ], 'notion@notion-marketplace': [ { scope: 'user', @@ -265,6 +309,9 @@ test('providerSkillsService lists claude user, project, and enabled plugin skill assert.equal(siblingPluginSkill?.description, 'Sibling Claude plugin skill'); assert.equal(byName.has('disabled-command'), false); assert.equal(byName.has('disabled-plugin'), false); + assert.equal(byName.has('invalid-empty-command'), false); + assert.equal(byName.has('invalid-at-command'), false); + assert.equal(skills.some((skill) => skill.command.startsWith('/:')), false); } finally { restoreHomeDir(); await fs.rm(tempRoot, { recursive: true, force: true }); diff --git a/src/components/chat/hooks/useSlashCommands.ts b/src/components/chat/hooks/useSlashCommands.ts index 931354eb..db6eefaa 100644 --- a/src/components/chat/hooks/useSlashCommands.ts +++ b/src/components/chat/hooks/useSlashCommands.ts @@ -310,22 +310,36 @@ export function useSlashCommands({ [input, resetCommandMenuState, setInput, slashPosition, textareaRef], ); + const executeNonSkillCommand = useCallback( + (command: SlashCommand) => { + const executionResult = onExecuteCommand(command); + if (isPromiseLike(executionResult)) { + executionResult.then( + () => { + resetCommandMenuState(); + }, + () => { + resetCommandMenuState(); + // Keep behavior silent; execution errors are handled by caller. + }, + ); + } else { + resetCommandMenuState(); + } + }, + [onExecuteCommand, resetCommandMenuState], + ); + const selectCommandFromKeyboard = useCallback( (command: SlashCommand) => { - insertCommandIntoInput(command); - if (isSkillCommand(command)) { + insertCommandIntoInput(command); return; } - const executionResult = onExecuteCommand(command); - if (isPromiseLike(executionResult)) { - executionResult.catch(() => { - // Keep behavior silent; execution errors are handled by caller. - }); - } + executeNonSkillCommand(command); }, - [insertCommandIntoInput, onExecuteCommand], + [executeNonSkillCommand, insertCommandIntoInput], ); const handleCommandSelect = useCallback( @@ -345,20 +359,9 @@ export function useSlashCommands({ return; } - const executionResult = onExecuteCommand(command); - - if (isPromiseLike(executionResult)) { - executionResult.then(() => { - resetCommandMenuState(); - }); - executionResult.catch(() => { - // Keep behavior silent; execution errors are handled by caller. - }); - } else { - resetCommandMenuState(); - } + executeNonSkillCommand(command); }, - [selectedProject, trackCommandUsage, insertCommandIntoInput, onExecuteCommand, resetCommandMenuState], + [selectedProject, trackCommandUsage, insertCommandIntoInput, executeNonSkillCommand], ); const handleToggleCommandMenu = useCallback(() => {