@@ -169,6 +169,14 @@ fn parse_frontmatter<T: for<'de> Deserialize<'de>>(content: &str) -> Option<(T,
169169fn parse_skill_content ( content : & str , path : PathBuf ) -> Option < Source > {
170170 let ( metadata, body) : ( SkillMetadata , String ) = parse_frontmatter ( content) ?;
171171
172+ if metadata. name . contains ( '/' ) {
173+ warn ! (
174+ "Skill name '{}' contains '/' which is not allowed, skipping" ,
175+ metadata. name
176+ ) ;
177+ return None ;
178+ }
179+
172180 Some ( Source {
173181 name : metadata. name ,
174182 kind : SourceKind :: Skill ,
@@ -693,15 +701,58 @@ impl SummonClient {
693701 session_id : & str ,
694702 name : & str ,
695703 working_dir : & Path ,
696- ) -> Option < Source > {
704+ ) -> Result < Option < Source > , String > {
697705 let sources = self . get_sources ( session_id, working_dir) . await ;
698- let mut source = sources. into_iter ( ) . find ( |s| s. name == name) ?;
699706
700- if source. kind == SourceKind :: Subrecipe && source. content . is_empty ( ) {
701- source. content = self . load_subrecipe_content ( session_id, & source. name ) . await ;
707+ if let Some ( mut source) = sources. iter ( ) . find ( |s| s. name == name) . cloned ( ) {
708+ if source. kind == SourceKind :: Subrecipe && source. content . is_empty ( ) {
709+ source. content = self . load_subrecipe_content ( session_id, & source. name ) . await ;
710+ }
711+ return Ok ( Some ( source) ) ;
712+ }
713+
714+ if let Some ( ( skill_name, raw_relative_path) ) = name. split_once ( '/' ) {
715+ let relative_path = raw_relative_path. replace ( '\\' , "/" ) ;
716+ if let Some ( skill) = sources. iter ( ) . find ( |s| {
717+ s. name == skill_name
718+ && matches ! ( s. kind, SourceKind :: Skill | SourceKind :: BuiltinSkill )
719+ } ) {
720+ let canonical_skill_dir = skill
721+ . path
722+ . canonicalize ( )
723+ . unwrap_or_else ( |_| skill. path . clone ( ) ) ;
724+
725+ for file_path in & skill. supporting_files {
726+ if let Ok ( rel) = file_path. strip_prefix ( & skill. path ) {
727+ let rel_normalized = rel. to_string_lossy ( ) . replace ( '\\' , "/" ) ;
728+ if rel_normalized == relative_path {
729+ let canonical_file = file_path
730+ . canonicalize ( )
731+ . map_err ( |e| format ! ( "Failed to resolve '{}': {}" , name, e) ) ?;
732+ if !canonical_file. starts_with ( & canonical_skill_dir) {
733+ return Err ( format ! (
734+ "Refusing to load '{}': file resolves outside the skill directory" ,
735+ name
736+ ) ) ;
737+ }
738+ return match std:: fs:: read_to_string ( & canonical_file) {
739+ Ok ( content) => Ok ( Some ( Source {
740+ name : name. to_string ( ) ,
741+ kind : SourceKind :: Skill ,
742+ description : format ! ( "Supporting file for {}" , skill_name) ,
743+ path : file_path. clone ( ) ,
744+ content,
745+ supporting_files : vec ! [ ] ,
746+ } ) ) ,
747+ Err ( e) => Err ( format ! ( "Failed to read '{}': {}" , name, e) ) ,
748+ } ;
749+ }
750+ }
751+ }
752+ }
702753 }
703754
704- Some ( source )
755+ Ok ( None )
705756 }
706757
707758 async fn load_subrecipe_content ( & self , session_id : & str , name : & str ) -> String {
@@ -1041,7 +1092,7 @@ impl SummonClient {
10411092 name : & str ,
10421093 working_dir : & Path ,
10431094 ) -> Result < Vec < Content > , String > {
1044- let source = self . resolve_source ( session_id, name, working_dir) . await ;
1095+ let source = self . resolve_source ( session_id, name, working_dir) . await ? ;
10451096
10461097 match source {
10471098 Some ( source) => {
@@ -1059,11 +1110,15 @@ impl SummonClient {
10591110 ) ) ;
10601111 for file in & source. supporting_files {
10611112 if let Ok ( relative) = file. strip_prefix ( & source. path ) {
1062- output. push_str ( & format ! ( "- {}\n " , relative. display( ) ) ) ;
1113+ let rel_str = relative. to_string_lossy ( ) . replace ( '\\' , "/" ) ;
1114+ output. push_str ( & format ! (
1115+ "- {} → load(source: \" {}/{}\" )\n " ,
1116+ rel_str, source. name, rel_str
1117+ ) ) ;
10631118 }
10641119 }
10651120 output. push_str (
1066- "\n Use the file tools to read these files or run scripts as directed .\n " ,
1121+ "\n Use load(source: \" <skill-name>/<path> \" ) to load individual files into context, or use file tools to read/run them directly .\n " ,
10671122 ) ;
10681123 }
10691124
@@ -1073,6 +1128,42 @@ impl SummonClient {
10731128 }
10741129 None => {
10751130 let sources = self . get_sources ( session_id, working_dir) . await ;
1131+
1132+ if let Some ( ( skill_name, _) ) = name. split_once ( '/' ) {
1133+ if let Some ( skill) = sources. iter ( ) . find ( |s| {
1134+ s. name == skill_name
1135+ && matches ! ( s. kind, SourceKind :: Skill | SourceKind :: BuiltinSkill )
1136+ } ) {
1137+ let available: Vec < String > = skill
1138+ . supporting_files
1139+ . iter ( )
1140+ . filter_map ( |f| {
1141+ f. strip_prefix ( & skill. path )
1142+ . ok ( )
1143+ . map ( |r| r. to_string_lossy ( ) . replace ( '\\' , "/" ) )
1144+ } )
1145+ . collect ( ) ;
1146+ if !available. is_empty ( ) {
1147+ let total = available. len ( ) ;
1148+ let display: Vec < _ > = available. into_iter ( ) . take ( 10 ) . collect ( ) ;
1149+ let suffix = if total > 10 {
1150+ format ! ( " (and {} more)" , total - 10 )
1151+ } else {
1152+ String :: new ( )
1153+ } ;
1154+ return Err ( format ! (
1155+ "Source '{}' not found. Available files for {}: {}{}" ,
1156+ name,
1157+ skill_name,
1158+ display. join( ", " ) ,
1159+ suffix
1160+ ) ) ;
1161+ } else {
1162+ return Err ( format ! ( "Skill '{}' has no supporting files." , skill_name) ) ;
1163+ }
1164+ }
1165+ }
1166+
10761167 let suggestions: Vec < & str > = sources
10771168 . iter ( )
10781169 . filter ( |s| {
@@ -1244,9 +1335,18 @@ impl SummonClient {
12441335 ) -> Result < Recipe , String > {
12451336 let source = self
12461337 . resolve_source ( session_id, source_name, working_dir)
1247- . await
1338+ . await ?
12481339 . ok_or_else ( || format ! ( "Source '{}' not found" , source_name) ) ?;
12491340
1341+ if source_name. contains ( '/' )
1342+ && matches ! ( source. kind, SourceKind :: Skill | SourceKind :: BuiltinSkill )
1343+ {
1344+ return Err ( format ! (
1345+ "Cannot delegate to supporting file '{}'. Use load() to read it instead." ,
1346+ source_name
1347+ ) ) ;
1348+ }
1349+
12501350 let mut recipe = match source. kind {
12511351 SourceKind :: Recipe | SourceKind :: Subrecipe => {
12521352 self . build_recipe_from_source ( & source, params, session_id)
@@ -1922,6 +2022,225 @@ You review code."#;
19222022 assert ! ( file_names. contains( & "report.txt" . to_string( ) ) ) ;
19232023 }
19242024
2025+ #[ tokio:: test]
2026+ async fn test_load_source_lists_supporting_files_with_load_names ( ) {
2027+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
2028+
2029+ let skill_dir = temp_dir. path ( ) . join ( ".goose/skills/my-skill" ) ;
2030+ fs:: create_dir_all ( skill_dir. join ( "references" ) ) . unwrap ( ) ;
2031+ fs:: write (
2032+ skill_dir. join ( "SKILL.md" ) ,
2033+ "---\n name: my-skill\n description: A skill\n ---\n See references." ,
2034+ )
2035+ . unwrap ( ) ;
2036+ fs:: write (
2037+ skill_dir. join ( "references/ops.md" ) ,
2038+ "# Ops Guide\n \n Do the thing." ,
2039+ )
2040+ . unwrap ( ) ;
2041+ fs:: write ( skill_dir. join ( "run.sh" ) , "#!/bin/bash\n echo ok" ) . unwrap ( ) ;
2042+
2043+ let client = SummonClient :: new ( create_test_context ( ) ) . unwrap ( ) ;
2044+ let result = client
2045+ . handle_load_source ( "test" , "my-skill" , temp_dir. path ( ) )
2046+ . await
2047+ . unwrap ( ) ;
2048+
2049+ let text = & result[ 0 ] . as_text ( ) . expect ( "expected text content" ) . text ;
2050+
2051+ assert ! (
2052+ !text. contains( "Ops Guide" ) ,
2053+ "md file content should not be inlined"
2054+ ) ;
2055+ assert ! (
2056+ !text. contains( "Do the thing." ) ,
2057+ "md file content should not be inlined"
2058+ ) ;
2059+ assert ! (
2060+ !text. contains( "#!/bin/bash" ) ,
2061+ "script content should not be inlined"
2062+ ) ;
2063+ assert ! (
2064+ text. contains( "load(source: \" my-skill/references/ops.md\" )" ) ,
2065+ "md file should be listed with load() name"
2066+ ) ;
2067+ assert ! (
2068+ text. contains( "load(source: \" my-skill/run.sh\" )" ) ,
2069+ "script should be listed with load() name"
2070+ ) ;
2071+ assert ! (
2072+ text. contains( "load(source: \" <skill-name>/<path>\" )" ) ,
2073+ "should include usage hint"
2074+ ) ;
2075+ }
2076+
2077+ #[ tokio:: test]
2078+ async fn test_load_supporting_file_by_path ( ) {
2079+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
2080+
2081+ let skill_dir = temp_dir. path ( ) . join ( ".goose/skills/my-skill" ) ;
2082+ fs:: create_dir_all ( skill_dir. join ( "references" ) ) . unwrap ( ) ;
2083+ fs:: write (
2084+ skill_dir. join ( "SKILL.md" ) ,
2085+ "---\n name: my-skill\n description: A skill\n ---\n See references." ,
2086+ )
2087+ . unwrap ( ) ;
2088+ fs:: write (
2089+ skill_dir. join ( "references/ops.md" ) ,
2090+ "# Ops Guide\n \n Do the thing." ,
2091+ )
2092+ . unwrap ( ) ;
2093+ fs:: write ( skill_dir. join ( "run.sh" ) , "#!/bin/bash\n echo ok" ) . unwrap ( ) ;
2094+
2095+ let client = SummonClient :: new ( create_test_context ( ) ) . unwrap ( ) ;
2096+
2097+ let md_result = client
2098+ . handle_load_source ( "test" , "my-skill/references/ops.md" , temp_dir. path ( ) )
2099+ . await
2100+ . unwrap ( ) ;
2101+ let md_text = & md_result[ 0 ] . as_text ( ) . expect ( "expected text content" ) . text ;
2102+ assert ! (
2103+ md_text. contains( "Ops Guide" ) ,
2104+ "markdown content should be loaded"
2105+ ) ;
2106+ assert ! ( md_text. contains( "Do the thing." ) ) ;
2107+
2108+ let sh_result = client
2109+ . handle_load_source ( "test" , "my-skill/run.sh" , temp_dir. path ( ) )
2110+ . await
2111+ . unwrap ( ) ;
2112+ let sh_text = & sh_result[ 0 ] . as_text ( ) . expect ( "expected text content" ) . text ;
2113+ assert ! (
2114+ sh_text. contains( "#!/bin/bash" ) ,
2115+ "script content should be loaded"
2116+ ) ;
2117+ }
2118+
2119+ #[ tokio:: test]
2120+ async fn test_load_supporting_file_not_found_suggests_available ( ) {
2121+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
2122+
2123+ let skill_dir = temp_dir. path ( ) . join ( ".goose/skills/my-skill" ) ;
2124+ fs:: create_dir_all ( skill_dir. join ( "references" ) ) . unwrap ( ) ;
2125+ fs:: write (
2126+ skill_dir. join ( "SKILL.md" ) ,
2127+ "---\n name: my-skill\n description: A skill\n ---\n See references." ,
2128+ )
2129+ . unwrap ( ) ;
2130+ fs:: write (
2131+ skill_dir. join ( "references/ops.md" ) ,
2132+ "# Ops Guide\n \n Do the thing." ,
2133+ )
2134+ . unwrap ( ) ;
2135+
2136+ let client = SummonClient :: new ( create_test_context ( ) ) . unwrap ( ) ;
2137+ let err = client
2138+ . handle_load_source (
2139+ "test" ,
2140+ "my-skill/references/nonexistent.md" ,
2141+ temp_dir. path ( ) ,
2142+ )
2143+ . await
2144+ . unwrap_err ( ) ;
2145+
2146+ assert ! (
2147+ err. contains( "references/ops.md" ) ,
2148+ "error should list available files: {}" ,
2149+ err
2150+ ) ;
2151+ assert ! (
2152+ err. contains( "my-skill" ) ,
2153+ "error should name the skill: {}" ,
2154+ err
2155+ ) ;
2156+ }
2157+
2158+ #[ cfg( unix) ]
2159+ #[ tokio:: test]
2160+ async fn test_resolve_source_blocks_symlink_outside_skill_dir ( ) {
2161+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
2162+ let outside_dir = TempDir :: new ( ) . unwrap ( ) ;
2163+
2164+ let skill_dir = temp_dir. path ( ) . join ( ".goose/skills/my-skill" ) ;
2165+ fs:: create_dir_all ( & skill_dir) . unwrap ( ) ;
2166+ fs:: write (
2167+ skill_dir. join ( "SKILL.md" ) ,
2168+ "---\n name: my-skill\n description: A skill\n ---\n Content." ,
2169+ )
2170+ . unwrap ( ) ;
2171+
2172+ let secret_file = outside_dir. path ( ) . join ( "secret.txt" ) ;
2173+ fs:: write ( & secret_file, "top secret data" ) . unwrap ( ) ;
2174+ std:: os:: unix:: fs:: symlink ( & secret_file, skill_dir. join ( "evil.md" ) ) . unwrap ( ) ;
2175+
2176+ let client = SummonClient :: new ( create_test_context ( ) ) . unwrap ( ) ;
2177+ let result = client
2178+ . handle_load_source ( "test" , "my-skill/evil.md" , temp_dir. path ( ) )
2179+ . await ;
2180+
2181+ assert ! (
2182+ result. is_err( ) ,
2183+ "symlink outside skill dir should be blocked"
2184+ ) ;
2185+ let err = result. unwrap_err ( ) ;
2186+ assert ! (
2187+ err. contains( "resolves outside the skill directory" ) ,
2188+ "error should mention path traversal: {}" ,
2189+ err
2190+ ) ;
2191+ }
2192+
2193+ #[ tokio:: test]
2194+ async fn test_resolve_source_blocks_path_traversal_input ( ) {
2195+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
2196+
2197+ let skill_dir = temp_dir. path ( ) . join ( ".goose/skills/my-skill" ) ;
2198+ fs:: create_dir_all ( & skill_dir) . unwrap ( ) ;
2199+ fs:: write (
2200+ skill_dir. join ( "SKILL.md" ) ,
2201+ "---\n name: my-skill\n description: A skill\n ---\n Content." ,
2202+ )
2203+ . unwrap ( ) ;
2204+ fs:: write ( skill_dir. join ( "legit.md" ) , "legit content" ) . unwrap ( ) ;
2205+
2206+ let client = SummonClient :: new ( create_test_context ( ) ) . unwrap ( ) ;
2207+
2208+ // ../../../etc/passwd won't match any supporting_files entry, so it returns Ok (not found)
2209+ // which becomes the "not found" error path in handle_load_source
2210+ let result = client
2211+ . handle_load_source ( "test" , "my-skill/../../../etc/passwd" , temp_dir. path ( ) )
2212+ . await ;
2213+
2214+ assert ! ( result. is_err( ) , "traversal path should not load content" ) ;
2215+ let err = result. unwrap_err ( ) ;
2216+ assert ! (
2217+ !err. contains( "root:" ) ,
2218+ "should not contain /etc/passwd content: {}" ,
2219+ err
2220+ ) ;
2221+ }
2222+
2223+ #[ tokio:: test]
2224+ async fn test_skill_name_with_slash_is_rejected ( ) {
2225+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
2226+
2227+ let skill_dir = temp_dir. path ( ) . join ( ".goose/skills/bad-skill" ) ;
2228+ fs:: create_dir_all ( & skill_dir) . unwrap ( ) ;
2229+ fs:: write (
2230+ skill_dir. join ( "SKILL.md" ) ,
2231+ "---\n name: bad/skill\n description: A skill with slash\n ---\n Content." ,
2232+ )
2233+ . unwrap ( ) ;
2234+
2235+ let client = SummonClient :: new ( create_test_context ( ) ) . unwrap ( ) ;
2236+ let sources = client. get_sources ( "test" , temp_dir. path ( ) ) . await ;
2237+
2238+ assert ! (
2239+ !sources. iter( ) . any( |s| s. name == "bad/skill" ) ,
2240+ "skill with '/' in name should be rejected"
2241+ ) ;
2242+ }
2243+
19252244 #[ tokio:: test]
19262245 async fn test_client_tools_and_unknown_tool ( ) {
19272246 let client = SummonClient :: new ( create_test_context ( ) ) . unwrap ( ) ;
0 commit comments