11import { createHash } from 'crypto'
22import { cp , mkdir , mkdtemp , readFile , rm , stat , writeFile } from 'fs/promises'
33import { tmpdir } from 'os'
4- import { basename , dirname , join , resolve } from 'path'
4+ import { basename , dirname , isAbsolute , join , relative , resolve } from 'path'
55import { getCwd } from '../../utils/cwd.js'
66import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
77import { getDisplayPath } from '../../utils/file.js'
@@ -35,6 +35,7 @@ type SkillRegistryEntry = {
3535
3636const DEFAULT_SKILLS_REGISTRY_URL =
3737 'https://raw.githubusercontent.com/Gitlawb/openclaude-skills/main/registry.json'
38+ const VALID_INSTALL_SKILL_NAME = / ^ [ a - z 0 - 9 ] [ a - z 0 - 9 - ] * (?: : [ a - z 0 - 9 ] [ a - z 0 - 9 - ] * ) * $ /
3839
3940function isPlainObject ( value : unknown ) : value is Record < string , unknown > {
4041 return typeof value === 'object' && value !== null && ! Array . isArray ( value )
@@ -180,6 +181,34 @@ function skillNameFromSource(source: string): string {
180181 return leaf . replace ( / \. m d $ / i, '' ) || 'skill'
181182}
182183
184+ function normalizeInstallSkillName ( value : string ) : string {
185+ const skillName = value . trim ( )
186+ if ( ! VALID_INSTALL_SKILL_NAME . test ( skillName ) ) {
187+ throw new Error (
188+ `Invalid skill name "${ value } ". Use lowercase letters, numbers, dashes, and optional colon namespaces.` ,
189+ )
190+ }
191+ return skillName
192+ }
193+
194+ function resolveContainedPath ( root : string , child : string ) : string {
195+ const resolvedRoot = resolve ( root )
196+ const resolvedChild = resolve ( resolvedRoot , child )
197+ const relativePath = relative ( resolvedRoot , resolvedChild )
198+
199+ if (
200+ relativePath === '' ||
201+ relativePath . startsWith ( '..' ) ||
202+ isAbsolute ( relativePath )
203+ ) {
204+ throw new Error (
205+ `Invalid skill install path "${ child } ". Skill paths must stay inside ${ getDisplayPath ( resolvedRoot ) } .` ,
206+ )
207+ }
208+
209+ return resolvedChild
210+ }
211+
183212async function prepareSkillFromMarkdown ( {
184213 markdown,
185214 fallbackName,
@@ -188,13 +217,14 @@ async function prepareSkillFromMarkdown({
188217 markdown : string
189218 fallbackName : string
190219 registryEntry ?: SkillRegistryEntry
191- } ) : Promise < { tempDir : string ; skillName : string } > {
192- const skillName =
220+ } ) : Promise < { tempRoot : string ; tempDir : string ; skillName : string } > {
221+ const skillName = normalizeInstallSkillName (
193222 typeof registryEntry ?. name === 'string'
194223 ? registryEntry . name
195- : getSkillNameFromMarkdown ( markdown , fallbackName )
224+ : getSkillNameFromMarkdown ( markdown , fallbackName ) ,
225+ )
196226 const tempRoot = await mkdtemp ( join ( tmpdir ( ) , 'openclaude-skill-install-' ) )
197- const tempDir = join ( tempRoot , skillName )
227+ const tempDir = resolveContainedPath ( tempRoot , skillName )
198228 await mkdir ( tempDir , { recursive : true } )
199229 await writeFile ( join ( tempDir , 'SKILL.md' ) , markdown , 'utf8' )
200230 if ( registryEntry ) {
@@ -204,14 +234,15 @@ async function prepareSkillFromMarkdown({
204234 'utf8' ,
205235 )
206236 }
207- return { tempDir, skillName }
237+ return { tempRoot , tempDir, skillName }
208238}
209239
210240async function prepareInstallCandidate (
211241 spec : string ,
212242 options : InstallOptions ,
213243) : Promise < {
214244 tempDir : string
245+ tempRoot : string
215246 skillName : string
216247 sourceDescription : string
217248 trust : string
@@ -220,16 +251,17 @@ async function prepareInstallCandidate(
220251 const sourcePath = resolve ( spec )
221252 const sourceStats = await stat ( sourcePath )
222253 if ( sourceStats . isDirectory ( ) ) {
223- const skillName = basename ( sourcePath )
254+ const skillName = normalizeInstallSkillName ( basename ( sourcePath ) )
224255 const tempRoot = await mkdtemp ( join ( tmpdir ( ) , 'openclaude-skill-install-' ) )
225- const tempDir = join ( tempRoot , skillName )
256+ const tempDir = resolveContainedPath ( tempRoot , skillName )
226257 await cp ( sourcePath , tempDir , {
227258 recursive : true ,
228259 errorOnExist : true ,
229260 force : false ,
230261 preserveTimestamps : false ,
231262 } )
232263 return {
264+ tempRoot,
233265 tempDir,
234266 skillName,
235267 sourceDescription : getDisplayPath ( sourcePath ) ,
@@ -308,7 +340,7 @@ export async function skillsInstallHandler(
308340 }
309341
310342 const root = installRoot ( options )
311- const targetDir = join ( root , candidate . skillName )
343+ const targetDir = resolveContainedPath ( root , candidate . skillName )
312344 if ( ( await pathExists ( targetDir ) ) && ! options . force ) {
313345 console . error (
314346 `Skill "${ candidate . skillName } " already exists at ${ getDisplayPath ( targetDir ) } . Use --force to overwrite.` ,
@@ -339,7 +371,7 @@ export async function skillsInstallHandler(
339371 process . exitCode = 1
340372 } finally {
341373 if ( candidate ) {
342- await rm ( dirname ( candidate . tempDir ) , { recursive : true , force : true } )
374+ await rm ( candidate . tempRoot , { recursive : true , force : true } )
343375 }
344376 }
345377}
0 commit comments