@@ -374,4 +374,92 @@ public void testLoadInstructions_emptyFile() throws IOException {
374374 .hasMessageThat ()
375375 .contains ("Skill file must start with ---" );
376376 }
377+
378+ @ Test
379+ public void testLoadResource_pathTraversalBlocked () throws IOException {
380+ Path skillsBase = tempFolder .getRoot ().toPath ().resolve ("skills" );
381+ Files .createDirectory (skillsBase );
382+
383+ Path skillDir = skillsBase .resolve ("my-skill" );
384+ Files .createDirectory (skillDir );
385+ Files .createDirectory (skillDir .resolve ("references" ));
386+
387+ SkillSource source = new LocalSkillSource (skillsBase );
388+ // "references/../../../../etc/passwd" passes startsWith("references/") but escapes skillsBase
389+ var single = source .loadResource ("my-skill" , "references/../../../../etc/passwd" );
390+ RuntimeException exception = assertThrows (RuntimeException .class , single ::blockingGet );
391+ assertThat (exception ).hasCauseThat ().isInstanceOf (SkillSourceException .class );
392+ SkillSourceException cause = (SkillSourceException ) exception .getCause ();
393+ assertThat (cause .getErrorCode ()).isEqualTo (SkillSourceException .RESOURCE_NOT_FOUND );
394+ assertThat (cause ).hasMessageThat ().contains ("Path traversal detected" );
395+ }
396+
397+ @ Test
398+ public void testLoadResource_pathTraversalWithDoubleDotOnly () throws IOException {
399+ Path skillsBase = tempFolder .getRoot ().toPath ().resolve ("skills" );
400+ Files .createDirectory (skillsBase );
401+
402+ Path skillDir = skillsBase .resolve ("my-skill" );
403+ Files .createDirectory (skillDir );
404+
405+ SkillSource source = new LocalSkillSource (skillsBase );
406+ var single = source .loadResource ("my-skill" , "../../outside.txt" );
407+ RuntimeException exception = assertThrows (RuntimeException .class , single ::blockingGet );
408+ assertThat (exception ).hasCauseThat ().isInstanceOf (SkillSourceException .class );
409+ SkillSourceException cause = (SkillSourceException ) exception .getCause ();
410+ assertThat (cause .getErrorCode ()).isEqualTo (SkillSourceException .RESOURCE_NOT_FOUND );
411+ assertThat (cause ).hasMessageThat ().contains ("Path traversal detected" );
412+ }
413+
414+ @ Test
415+ public void testLoadResource_legitimatePathNotBlocked () throws IOException {
416+ Path skillsBase = tempFolder .getRoot ().toPath ().resolve ("skills" );
417+ Files .createDirectory (skillsBase );
418+
419+ Path skillDir = skillsBase .resolve ("my-skill" );
420+ Files .createDirectory (skillDir );
421+ Path referencesDir = skillDir .resolve ("references" );
422+ Files .createDirectory (referencesDir );
423+ Files .writeString (referencesDir .resolve ("readme.md" ), "legitimate content" );
424+
425+ SkillSource source = new LocalSkillSource (skillsBase );
426+ ByteSource resource =
427+ source .loadResource ("my-skill" , "references/readme.md" ).blockingGet ();
428+ assertThat (new String (resource .read (), UTF_8 )).isEqualTo ("legitimate content" );
429+ }
430+
431+ @ Test
432+ public void testListResources_pathTraversalInResourceDirectoryBlocked () throws IOException {
433+ Path skillsBase = tempFolder .getRoot ().toPath ().resolve ("skills" );
434+ Files .createDirectory (skillsBase );
435+
436+ Path skillDir = skillsBase .resolve ("my-skill" );
437+ Files .createDirectory (skillDir );
438+ Files .createDirectory (skillDir .resolve ("references" ));
439+
440+ SkillSource source = new LocalSkillSource (skillsBase );
441+ var single = source .listResources ("my-skill" , "references/../../../../etc" );
442+ RuntimeException exception = assertThrows (RuntimeException .class , single ::blockingGet );
443+ assertThat (exception ).hasCauseThat ().isInstanceOf (SkillSourceException .class );
444+ SkillSourceException cause = (SkillSourceException ) exception .getCause ();
445+ assertThat (cause .getErrorCode ()).isEqualTo (SkillSourceException .RESOURCE_NOT_FOUND );
446+ assertThat (cause ).hasMessageThat ().contains ("Path traversal detected" );
447+ }
448+
449+ @ Test
450+ public void testListResources_dotDotResourceDirectoryBlocked () throws IOException {
451+ Path skillsBase = tempFolder .getRoot ().toPath ().resolve ("skills" );
452+ Files .createDirectory (skillsBase );
453+
454+ Path skillDir = skillsBase .resolve ("my-skill" );
455+ Files .createDirectory (skillDir );
456+
457+ SkillSource source = new LocalSkillSource (skillsBase );
458+ var single = source .listResources ("my-skill" , "../other-skill" );
459+ RuntimeException exception = assertThrows (RuntimeException .class , single ::blockingGet );
460+ assertThat (exception ).hasCauseThat ().isInstanceOf (SkillSourceException .class );
461+ SkillSourceException cause = (SkillSourceException ) exception .getCause ();
462+ assertThat (cause .getErrorCode ()).isEqualTo (SkillSourceException .RESOURCE_NOT_FOUND );
463+ assertThat (cause ).hasMessageThat ().contains ("Path traversal detected" );
464+ }
377465}
0 commit comments