Skip to content

Commit 87acebe

Browse files
authored
Merge pull request #1 from Rahul-2k4/copilot/review-pull-75-and-suggest-fixes
fix(skills-hub): path traversal in `remove()` + add `/skill remove` Discord command
2 parents 3f7c5f9 + 5fe341e commit 87acebe

3 files changed

Lines changed: 106 additions & 1 deletion

File tree

core/src/channels/discord/mod.rs

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -915,7 +915,8 @@ async fn tmux(
915915
"skill_view",
916916
"skill_search",
917917
"skill_install",
918-
"skill_hub_list"
918+
"skill_hub_list",
919+
"skill_remove"
919920
)
920921
)]
921922
async fn skill(
@@ -1170,6 +1171,42 @@ async fn skill_hub_list(
11701171
Ok(())
11711172
}
11721173

1174+
/// Remove an installed hub skill (member role required)
1175+
#[poise::command(slash_command)]
1176+
async fn skill_remove(
1177+
ctx: poise::Context<'_, Data, DiscordError>,
1178+
#[description = "skill name to remove"] name: String,
1179+
) -> std::result::Result<(), DiscordError> {
1180+
let _ = ctx.channel_id().start_typing(&ctx.serenity_context().http);
1181+
1182+
let roles = DiscordChannel::get_user_roles(ctx.guild_id(), ctx.http(), ctx.author().id).await;
1183+
let channel = DiscordChannel::from_context(&ctx);
1184+
1185+
if !channel.is_member(&roles) {
1186+
ctx.say("error: remove skill requires member role").await?;
1187+
return Ok(());
1188+
}
1189+
1190+
match ctx.data().hub_client.remove(name.trim()) {
1191+
Ok(true) => {
1192+
ctx.say(format!("✓ removed hub skill `{}`", name.trim()))
1193+
.await?;
1194+
}
1195+
Ok(false) => {
1196+
ctx.say(format!(
1197+
"hub skill `{}` was not found. use `/skill hub-list` to see installed skills",
1198+
name.trim()
1199+
))
1200+
.await?;
1201+
}
1202+
Err(e) => {
1203+
ctx.say(format!("error removing skill: {}", e)).await?;
1204+
}
1205+
}
1206+
1207+
Ok(())
1208+
}
1209+
11731210
/// Parse skill name and version from "name@version" format
11741211
fn parse_skill_name_version(input: &str) -> (&str, Option<String>) {
11751212
let trimmed = input.trim();

core/src/skills/hub.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,7 @@ impl SkillHubClient {
399399

400400
/// Remove an installed skill
401401
pub fn remove(&self, name: &str) -> Result<bool> {
402+
validate_skill_name(name)?;
402403
let mut removed = false;
403404
let skill_dir = self.config.managed_root.join(name);
404405
if skill_dir.exists() {
@@ -654,4 +655,37 @@ mod tests {
654655
"partial install directory should be removed on failure"
655656
);
656657
}
658+
659+
#[test]
660+
fn remove_rejects_path_traversal_name() {
661+
let cache_root = TempDir::new().unwrap();
662+
let managed_root = TempDir::new().unwrap();
663+
let config = SkillHubClientConfig::for_mofaclaw()
664+
.with_managed_root(managed_root.path())
665+
.with_cache_root(cache_root.path());
666+
let client = SkillHubClient::new(config).unwrap();
667+
668+
let err = client.remove("../outside").unwrap_err();
669+
assert!(
670+
err.to_string().contains("invalid skill name"),
671+
"unexpected error: {err}"
672+
);
673+
assert!(
674+
!managed_root.path().join("outside").exists(),
675+
"path traversal should not remove files outside managed root"
676+
);
677+
}
678+
679+
#[test]
680+
fn remove_returns_false_for_nonexistent_skill() {
681+
let cache_root = TempDir::new().unwrap();
682+
let managed_root = TempDir::new().unwrap();
683+
let config = SkillHubClientConfig::for_mofaclaw()
684+
.with_managed_root(managed_root.path())
685+
.with_cache_root(cache_root.path());
686+
let client = SkillHubClient::new(config).unwrap();
687+
688+
let removed = client.remove("no-such-skill").unwrap();
689+
assert!(!removed, "expected false when skill does not exist");
690+
}
657691
}

core/tests/skills_hub.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,4 +311,38 @@ mod skills_hub_tests {
311311
);
312312
}
313313

314+
#[tokio::test]
315+
async fn test_hub_client_remove_uninstalls_skill() {
316+
let _guard = env_lock().lock().unwrap_or_else(|e| e.into_inner());
317+
let home_dir = TempDir::new().unwrap();
318+
let _home_guard = HomeEnvGuard::set(home_dir.path());
319+
320+
let (catalog_url, server_handle) =
321+
spawn_test_hub_server("removable-skill", "A skill to be removed");
322+
let hub_client = SkillHubClient::new(
323+
SkillHubClientConfig::for_mofaclaw().with_catalog_url(catalog_url),
324+
)
325+
.unwrap();
326+
327+
// Install then remove
328+
let record = hub_client
329+
.install("removable-skill", None)
330+
.await
331+
.unwrap();
332+
assert!(record.installed_path.exists(), "skill should be installed");
333+
334+
let removed = hub_client.remove("removable-skill").unwrap();
335+
assert!(removed, "remove should return true for an installed skill");
336+
assert!(
337+
!record.installed_path.exists(),
338+
"skill directory should be deleted after remove"
339+
);
340+
assert!(
341+
hub_client.get_installed("removable-skill").unwrap().is_none(),
342+
"registry record should be deleted after remove"
343+
);
344+
345+
server_handle.join().unwrap();
346+
}
347+
314348
}

0 commit comments

Comments
 (0)