11import { describe , test , expect , beforeEach , afterEach } from "bun:test"
2- import { mkdtemp , rm , mkdir , writeFile } from "fs/promises"
2+ import { mkdtemp , rm , mkdir , writeFile , symlink } from "fs/promises"
33import { tmpdir } from "os"
44import path from "path"
5- import { discoverExternalSkills } from "../../src/skill/discover-external"
5+ import {
6+ discoverExternalSkills ,
7+ setSkillDiscoveryResult ,
8+ consumeSkillDiscoveryResult ,
9+ } from "../../src/skill/discover-external"
610import { Instance } from "../../src/project/instance"
711
812let tempDir : string
13+ let homeDir : string
914
1015beforeEach ( async ( ) => {
1116 tempDir = await mkdtemp ( path . join ( tmpdir ( ) , "skill-discover-" ) )
17+ homeDir = await mkdtemp ( path . join ( tmpdir ( ) , "skill-discover-home-" ) )
1218} )
1319
1420afterEach ( async ( ) => {
1521 await rm ( tempDir , { recursive : true , force : true } )
22+ await rm ( homeDir , { recursive : true , force : true } )
1623} )
1724
1825describe ( "discoverExternalSkills" , ( ) => {
19- // Helper to run discovery with tempDir as both worktree and Instance.directory
26+ // Helper to run discovery with tempDir as both worktree and Instance.directory,
27+ // and an isolated homeDir to prevent real home directory from leaking into results
2028 async function discover ( worktree ?: string ) {
2129 return Instance . provide ( {
2230 directory : worktree ?? tempDir ,
23- fn : ( ) => discoverExternalSkills ( worktree ?? tempDir ) ,
31+ fn : ( ) => discoverExternalSkills ( worktree ?? tempDir , homeDir ) ,
2432 } )
2533 }
2634
@@ -146,6 +154,21 @@ prompt = "Deploy the app to {{ args }} environment"
146154 expect ( skill ! . content ) . not . toContain ( "{{" )
147155 } )
148156
157+ test ( "preserves nested TOML command path as name" , async ( ) => {
158+ await mkdir ( path . join ( tempDir , ".gemini" , "commands" , "team" ) , { recursive : true } )
159+ await writeFile (
160+ path . join ( tempDir , ".gemini" , "commands" , "team" , "deploy.toml" ) ,
161+ `description = "Team deploy"
162+ prompt = "Deploy for team"
163+ ` ,
164+ )
165+
166+ const { skills } = await discover ( )
167+ const skill = skills . find ( ( s ) => s . name === "team/deploy" )
168+ expect ( skill ) . toBeDefined ( )
169+ expect ( skill ! . description ) . toBe ( "Team deploy" )
170+ } )
171+
149172 test ( "skips Gemini TOML command without prompt field" , async ( ) => {
150173 await mkdir ( path . join ( tempDir , ".gemini" , "commands" ) , { recursive : true } )
151174 await writeFile (
@@ -295,10 +318,9 @@ prompt = "Run {{args}}"
295318 // --- Worktree edge cases ---
296319
297320 test ( "skips project scan when worktree is /" , async ( ) => {
298- // Should not throw and should return empty (no dirs at /)
299321 const result = await Instance . provide ( {
300322 directory : tempDir ,
301- fn : ( ) => discoverExternalSkills ( "/" ) ,
323+ fn : ( ) => discoverExternalSkills ( "/" , homeDir ) ,
302324 } )
303325 expect ( result . skills ) . toEqual ( [ ] )
304326 } )
@@ -324,4 +346,149 @@ Content here
324346 expect ( skill ) . toBeDefined ( )
325347 expect ( skill ! . location ) . toBe ( cmdPath )
326348 } )
349+
350+ // --- Security: Prototype pollution ---
351+
352+ test ( "rejects skills named __proto__" , async ( ) => {
353+ await mkdir ( path . join ( tempDir , ".claude" , "commands" ) , { recursive : true } )
354+ await writeFile (
355+ path . join ( tempDir , ".claude" , "commands" , "__proto__.md" ) ,
356+ `---
357+ name: __proto__
358+ description: Malicious skill
359+ ---
360+
361+ Exploit content
362+ ` ,
363+ )
364+
365+ const { skills } = await discover ( )
366+ expect ( skills . find ( ( s ) => s . name === "__proto__" ) ) . toBeUndefined ( )
367+ } )
368+
369+ test ( "rejects skills named constructor" , async ( ) => {
370+ await mkdir ( path . join ( tempDir , ".claude" , "commands" ) , { recursive : true } )
371+ await writeFile (
372+ path . join ( tempDir , ".claude" , "commands" , "constructor.md" ) ,
373+ `---
374+ name: constructor
375+ description: Malicious skill
376+ ---
377+
378+ Exploit
379+ ` ,
380+ )
381+
382+ const { skills } = await discover ( )
383+ expect ( skills . find ( ( s ) => s . name === "constructor" ) ) . toBeUndefined ( )
384+ } )
385+
386+ test ( "rejects skills named prototype" , async ( ) => {
387+ await mkdir ( path . join ( tempDir , ".codex" , "skills" , "proto" ) , { recursive : true } )
388+ await writeFile (
389+ path . join ( tempDir , ".codex" , "skills" , "proto" , "SKILL.md" ) ,
390+ `---
391+ name: prototype
392+ description: Malicious
393+ ---
394+
395+ Content
396+ ` ,
397+ )
398+
399+ const { skills } = await discover ( )
400+ expect ( skills . find ( ( s ) => s . name === "prototype" ) ) . toBeUndefined ( )
401+ } )
402+
403+ // --- Security: Path traversal ---
404+
405+ test ( "rejects skill names containing .. segments" , async ( ) => {
406+ await mkdir ( path . join ( tempDir , ".claude" , "commands" ) , { recursive : true } )
407+ await writeFile (
408+ path . join ( tempDir , ".claude" , "commands" , "legit.md" ) ,
409+ `---
410+ name: ../../etc/passwd
411+ description: Path traversal attempt
412+ ---
413+
414+ Content
415+ ` ,
416+ )
417+
418+ const { skills } = await discover ( )
419+ expect ( skills . find ( ( s ) => s . name === "../../etc/passwd" ) ) . toBeUndefined ( )
420+ } )
421+
422+ // --- Security: Symlinks not followed ---
423+
424+ test ( "does not follow symlinks to files outside the directory" , async ( ) => {
425+ // Create a sensitive file outside the project
426+ const sensitiveDir = await mkdtemp ( path . join ( tmpdir ( ) , "sensitive-" ) )
427+ await writeFile ( path . join ( sensitiveDir , "secret.txt" ) , "TOP SECRET DATA" )
428+
429+ // Create a symlink inside .claude/commands/ pointing to the sensitive file
430+ await mkdir ( path . join ( tempDir , ".claude" , "commands" ) , { recursive : true } )
431+ try {
432+ await symlink (
433+ path . join ( sensitiveDir , "secret.txt" ) ,
434+ path . join ( tempDir , ".claude" , "commands" , "steal.md" ) ,
435+ )
436+ } catch {
437+ // symlink may fail on some platforms — skip test
438+ await rm ( sensitiveDir , { recursive : true , force : true } )
439+ return
440+ }
441+
442+ const { skills } = await discover ( )
443+ // The symlinked file should NOT be discovered (symlink: false)
444+ expect ( skills . find ( ( s ) => s . name === "steal" ) ) . toBeUndefined ( )
445+
446+ await rm ( sensitiveDir , { recursive : true , force : true } )
447+ } )
448+
449+ // --- Discovery result lifecycle ---
450+
451+ test ( "consumeSkillDiscoveryResult returns null when nothing set" , ( ) => {
452+ // Clear any prior state
453+ setSkillDiscoveryResult ( [ ] , [ ] )
454+ const result = consumeSkillDiscoveryResult ( )
455+ expect ( result ) . toBeNull ( )
456+ } )
457+
458+ test ( "consumeSkillDiscoveryResult returns result once then null" , ( ) => {
459+ setSkillDiscoveryResult ( [ "my-skill" ] , [ ".claude/commands" ] )
460+ const first = consumeSkillDiscoveryResult ( )
461+ expect ( first ) . toEqual ( { skillNames : [ "my-skill" ] , sources : [ ".claude/commands" ] } )
462+ const second = consumeSkillDiscoveryResult ( )
463+ expect ( second ) . toBeNull ( )
464+ } )
465+
466+ test ( "setSkillDiscoveryResult with empty array clears previous result" , ( ) => {
467+ setSkillDiscoveryResult ( [ "old-skill" ] , [ ".claude/commands" ] )
468+ setSkillDiscoveryResult ( [ ] , [ ] )
469+ const result = consumeSkillDiscoveryResult ( )
470+ expect ( result ) . toBeNull ( )
471+ } )
472+
473+ // --- Home directory isolation ---
474+
475+ test ( "discovers skills from home directory separately" , async ( ) => {
476+ // Put a skill in the isolated home directory
477+ await mkdir ( path . join ( homeDir , ".claude" , "commands" ) , { recursive : true } )
478+ await writeFile (
479+ path . join ( homeDir , ".claude" , "commands" , "home-cmd.md" ) ,
480+ `---
481+ name: home-cmd
482+ description: Home command
483+ ---
484+
485+ Home content
486+ ` ,
487+ )
488+
489+ const { skills } = await discover ( )
490+ const skill = skills . find ( ( s ) => s . name === "home-cmd" )
491+ expect ( skill ) . toBeDefined ( )
492+ expect ( skill ! . location ) . toContain ( homeDir )
493+ } )
327494} )
0 commit comments