Skip to content

Commit c1d8e27

Browse files
authored
feat(summon): make skill supporting files individually loadable via load() (#7583)
1 parent ff4a859 commit c1d8e27

2 files changed

Lines changed: 329 additions & 36 deletions

File tree

crates/goose/src/agents/platform_extensions/summon.rs

Lines changed: 328 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,14 @@ fn parse_frontmatter<T: for<'de> Deserialize<'de>>(content: &str) -> Option<(T,
169169
fn 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-
"\nUse the file tools to read these files or run scripts as directed.\n",
1121+
"\nUse 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+
"---\nname: my-skill\ndescription: A skill\n---\nSee references.",
2034+
)
2035+
.unwrap();
2036+
fs::write(
2037+
skill_dir.join("references/ops.md"),
2038+
"# Ops Guide\n\nDo the thing.",
2039+
)
2040+
.unwrap();
2041+
fs::write(skill_dir.join("run.sh"), "#!/bin/bash\necho 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+
"---\nname: my-skill\ndescription: A skill\n---\nSee references.",
2086+
)
2087+
.unwrap();
2088+
fs::write(
2089+
skill_dir.join("references/ops.md"),
2090+
"# Ops Guide\n\nDo the thing.",
2091+
)
2092+
.unwrap();
2093+
fs::write(skill_dir.join("run.sh"), "#!/bin/bash\necho 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+
"---\nname: my-skill\ndescription: A skill\n---\nSee references.",
2128+
)
2129+
.unwrap();
2130+
fs::write(
2131+
skill_dir.join("references/ops.md"),
2132+
"# Ops Guide\n\nDo 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+
"---\nname: my-skill\ndescription: A skill\n---\nContent.",
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+
"---\nname: my-skill\ndescription: A skill\n---\nContent.",
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+
"---\nname: bad/skill\ndescription: A skill with slash\n---\nContent.",
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

Comments
 (0)