fix(skills): validate registry keys before using them as cache directories

Cause: sync_one_skill joined the registry index key straight onto the skill
cache directory and later removed, renamed and wrote under it, while the
installed-skill path already ran validate_skill_name_segment.

Fix: run validate_skill_name_segment on the key first and report a Failed
outcome for a key that is not one path-safe segment.

Test: registry_sync_refuses_a_key_that_is_not_a_single_segment (`../escape`,
`..`, `a/b`, `/abs`); fails with the check disabled (1 failed).
- cargo test -p codewhale-tui --lib -- skills::install:
  test result: ok. 21 passed; 0 failed

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-26 23:59:50 -07:00
1 parent 68d1377f0d
commit 92a44ba540
3 files changed
+37

No files matched your search

+3
View File
@@ -178,6 +178,9 @@ quieter, and Fleet runs can be checked before they spend anything.
- Computer Use: screenshot and zoom output paths must be `.png`/`.jpg`/`.jpeg`
files inside the recordings directory, and zoom always crops the last
captured raster instead of a caller-named source file.
- Skill registry sync refuses an index key that is not a single path-safe
name before it is used as a cache directory, the same check an installed
skill name already gets.
### Removed
+3
View File
@@ -178,6 +178,9 @@ quieter, and Fleet runs can be checked before they spend anything.
- Computer Use: screenshot and zoom output paths must be `.png`/`.jpg`/`.jpeg`
files inside the recordings directory, and zoom always crops the last
captured raster instead of a caller-named source file.
- Skill registry sync refuses an index key that is not a single path-safe
name before it is used as a cache directory, the same check an installed
skill name already gets.
### Removed
+31
View File
@@ -656,6 +656,14 @@ async fn sync_one_skill(
cache_dir: &Path,
max_size: u64,
) -> SkillSyncOutcome {
// The registry key names the cache directory joined below; it gets the
// same single-segment check as an installed skill name.
if let Err(err) = validate_skill_name_segment(name) {
return SkillSyncOutcome::Failed {
name: name.to_string(),
reason: format!("{err:#}"),
};
}
// Resolve the source to a concrete URL list.
let source = match InstallSource::parse(&entry.source) {
Ok(s) => s,
@@ -1668,6 +1676,29 @@ fn hex_bytes(bytes: impl AsRef<[u8]>) -> String {
mod tests {
use super::*;
#[tokio::test]
async fn registry_sync_refuses_a_key_that_is_not_a_single_segment() {
let root = tempfile::tempdir().expect("tempdir");
let cache_dir = root.path().join("cache");
std::fs::create_dir_all(&cache_dir).expect("cache dir");
let entry = RegistryEntry {
source: "https://example.invalid/skill.tar.gz".to_string(),
description: None,
keywords: Vec::new(),
domains: Vec::new(),
};
for name in ["../escape", "..", "a/b", "/abs"] {
let outcome =
sync_one_skill(name, &entry, &NetworkPolicy::default(), &cache_dir, 1024).await;
assert!(
matches!(outcome, SkillSyncOutcome::Failed { ref reason, .. }
if reason.contains("single path-safe segment")),
"{name}: {outcome:?}"
);
}
assert!(!root.path().join("escape").exists());
}
#[test]
fn parse_github_source() {
let s = InstallSource::parse("github:Hmbown/test-skill").unwrap();