diff --git a/src/renderer/components/extensions/skills/SkillEditorDialog.tsx b/src/renderer/components/extensions/skills/SkillEditorDialog.tsx index 6ed48334..f5e625bf 100644 --- a/src/renderer/components/extensions/skills/SkillEditorDialog.tsx +++ b/src/renderer/components/extensions/skills/SkillEditorDialog.tsx @@ -35,6 +35,7 @@ import { import { toSuggestedSkillFolderName } from './skillFolderNameUtils'; import { resolveSkillProjectPath } from './skillProjectUtils'; import { SkillReviewDialog } from './SkillReviewDialog'; +import { validateSkillFolderName } from './skillValidationUtils'; import type { SkillDetail, @@ -305,6 +306,10 @@ export const SkillEditorDialog = ({ if (!folderName.trim()) { return 'Choose a folder name for this skill.'; } + const folderNameError = validateSkillFolderName(folderName); + if (folderNameError) { + return folderNameError; + } if (scope === 'project' && !effectiveProjectPath) { return 'Project skills need an active project.'; } diff --git a/src/renderer/components/extensions/skills/SkillImportDialog.tsx b/src/renderer/components/extensions/skills/SkillImportDialog.tsx index eaf7a038..2dc73c4b 100644 --- a/src/renderer/components/extensions/skills/SkillImportDialog.tsx +++ b/src/renderer/components/extensions/skills/SkillImportDialog.tsx @@ -25,6 +25,7 @@ import { FileSearch, FolderOpen, X } from 'lucide-react'; import { getSuggestedSkillFolderNameFromPath } from './skillFolderNameUtils'; import { SkillReviewDialog } from './SkillReviewDialog'; import { resolveSkillProjectPath } from './skillProjectUtils'; +import { validateSkillFolderName } from './skillValidationUtils'; import type { SkillReviewPreview } from '@shared/types/extensions'; @@ -126,6 +127,13 @@ export const SkillImportDialog = ({ } async function handleReview(): Promise { + const folderNameError = + folderName.trim().length > 0 ? validateSkillFolderName(folderName) : null; + if (folderNameError) { + setMutationError(folderNameError); + return; + } + setReviewLoading(true); setMutationError(null); try { diff --git a/src/renderer/components/extensions/skills/skillValidationUtils.ts b/src/renderer/components/extensions/skills/skillValidationUtils.ts new file mode 100644 index 00000000..6ec15845 --- /dev/null +++ b/src/renderer/components/extensions/skills/skillValidationUtils.ts @@ -0,0 +1,23 @@ +const MAX_SKILL_FOLDER_NAME_LENGTH = 255; +const INVALID_SKILL_FOLDER_NAME_CHARS = /[\x00-\x1f/\\:*?"<>|]/u; + +export function validateSkillFolderName(value: string): string | null { + const trimmed = value.trim(); + if (trimmed.length === 0) { + return 'Choose a folder name for this skill.'; + } + + if (trimmed.length > MAX_SKILL_FOLDER_NAME_LENGTH) { + return `Folder name must be ${MAX_SKILL_FOLDER_NAME_LENGTH} characters or fewer.`; + } + + if (trimmed === '.' || trimmed === '..') { + return 'Pick a simpler folder name using letters, numbers, dots, dashes, or underscores.'; + } + + if (INVALID_SKILL_FOLDER_NAME_CHARS.test(trimmed)) { + return 'Pick a simpler folder name using letters, numbers, dots, dashes, or underscores.'; + } + + return null; +} diff --git a/test/renderer/components/extensions/skills/SkillEditorDialog.test.ts b/test/renderer/components/extensions/skills/SkillEditorDialog.test.ts index 1f081ae3..fdf1f0a0 100644 --- a/test/renderer/components/extensions/skills/SkillEditorDialog.test.ts +++ b/test/renderer/components/extensions/skills/SkillEditorDialog.test.ts @@ -313,4 +313,51 @@ This file uses a freeform layout without generated sections. await Promise.resolve(); }); }); + + it('blocks review locally when the folder name is invalid', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(SkillEditorDialog, { + open: true, + mode: 'create', + projectPath: '/tmp/project', + projectLabel: 'Project', + detail: null, + onClose: vi.fn(), + onSaved: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const folderInput = host.querySelector('#skill-folder') as HTMLInputElement; + await act(async () => { + const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + setValue?.call(folderInput, 'bad/name'); + folderInput.dispatchEvent(new Event('input', { bubbles: true })); + await Promise.resolve(); + }); + + const reviewButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('Review And Create') + ) as HTMLButtonElement; + await act(async () => { + reviewButton.click(); + await Promise.resolve(); + }); + + expect(storeState.previewSkillUpsert).not.toHaveBeenCalled(); + expect(host.textContent).toContain( + 'Pick a simpler folder name using letters, numbers, dots, dashes, or underscores.' + ); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/extensions/skills/SkillImportDialog.test.ts b/test/renderer/components/extensions/skills/SkillImportDialog.test.ts index b52cd66b..7229b258 100644 --- a/test/renderer/components/extensions/skills/SkillImportDialog.test.ts +++ b/test/renderer/components/extensions/skills/SkillImportDialog.test.ts @@ -378,4 +378,52 @@ describe('SkillImportDialog', () => { await Promise.resolve(); }); }); + + it('blocks import review locally when the folder name is invalid', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(SkillImportDialog, { + open: true, + projectPath: null, + projectLabel: null, + onClose: vi.fn(), + onImported: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const sourceInput = host.querySelector('#skill-import-source') as HTMLInputElement; + const folderInput = host.querySelector('#skill-import-folder') as HTMLInputElement; + await act(async () => { + const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + setValue?.call(sourceInput, '/tmp/source-skill'); + sourceInput.dispatchEvent(new Event('input', { bubbles: true })); + setValue?.call(folderInput, 'bad/name'); + folderInput.dispatchEvent(new Event('input', { bubbles: true })); + await Promise.resolve(); + }); + + const reviewButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('Review And Import') + ) as HTMLButtonElement; + await act(async () => { + reviewButton.click(); + await Promise.resolve(); + }); + + expect(storeState.previewSkillImport).not.toHaveBeenCalled(); + expect(host.textContent).toContain( + 'Pick a simpler folder name using letters, numbers, dots, dashes, or underscores.' + ); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/extensions/skills/skillValidationUtils.test.ts b/test/renderer/components/extensions/skills/skillValidationUtils.test.ts new file mode 100644 index 00000000..208b7d2c --- /dev/null +++ b/test/renderer/components/extensions/skills/skillValidationUtils.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { validateSkillFolderName } from '../../../../../src/renderer/components/extensions/skills/skillValidationUtils'; + +describe('skillValidationUtils', () => { + it('accepts normal folder names', () => { + expect(validateSkillFolderName('review-helper')).toBeNull(); + }); + + it('rejects empty folder names', () => { + expect(validateSkillFolderName(' ')).toBe('Choose a folder name for this skill.'); + }); + + it('rejects invalid filesystem characters', () => { + expect(validateSkillFolderName('bad/name')).toBe( + 'Pick a simpler folder name using letters, numbers, dots, dashes, or underscores.' + ); + }); + + it('rejects dot segments', () => { + expect(validateSkillFolderName('..')).toBe( + 'Pick a simpler folder name using letters, numbers, dots, dashes, or underscores.' + ); + }); +});