mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 08:04:25 -10:00
fix(skills): project skills load only in a trusted workspace
Repository skill roots (`.agents/skills`, `skills/`, `.opencode`, `.claude`, `.cursor`, `.codewhale/skills` under the workspace) were read into runtime discovery without workspace trust, so a cloned repo's SKILL.md reached the model's skill list (and shadowed a same-named global skill) before the user ever ran /trust. Runtime discovery now skips project-scope roots until is_workspace_trusted(workspace) — the same predicate hooks and MCP use — checked lazily, only when such a root exists. Skipping is not silent: discovery adds a warning naming the skipped directories and `/trust`, visible in /skills and the skills block. Once trusted, project skills keep their precedence and a shadowed global skill keeps the existing "is shadowed by" warning. Audit/mutation catalogs are unchanged. Tests: codewhale-tui skills::tests::project_skills_require_workspace_trust 1 passed / 0 failed (new). The wider skill/plugin sweep did not run: the shared build volume filled (ENOSPC) before it could link. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h
This commit is contained in:
1 parent
c1d3f1994b
commit
294b616ed8
3 files changed
+109
-2
No files matched your search
@@ -1026,7 +1026,34 @@ pub fn discover_in_workspace_with_mode_and_plugins(
|
||||
mode: SkillDiscoveryMode,
|
||||
plugins: Option<&crate::plugins::PluginRegistry>,
|
||||
) -> SkillRegistry {
|
||||
discover_from_directories_with_plugins(skills_directories_for_mode(workspace, mode), plugins)
|
||||
let registry = discover_from_directories_with_plugins(
|
||||
skills_directories_for_mode(workspace, mode),
|
||||
plugins,
|
||||
);
|
||||
with_untrusted_project_skills_warning(registry, workspace)
|
||||
}
|
||||
|
||||
/// Name the project skill directories an untrusted workspace kept out, so
|
||||
/// `/skills` and the model see why a repository's skills are missing.
|
||||
fn with_untrusted_project_skills_warning(
|
||||
mut registry: SkillRegistry,
|
||||
workspace: &Path,
|
||||
) -> SkillRegistry {
|
||||
let home = crate::config::effective_home_dir();
|
||||
let skipped = roots::untrusted_project_skill_dirs(workspace, home.as_deref());
|
||||
if !skipped.is_empty() {
|
||||
let dirs = skipped
|
||||
.iter()
|
||||
.map(|dir| dir.display().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
// Not `push_warning`: discovery runs per turn, and this state is
|
||||
// expected until the user trusts the workspace, so it is not logged.
|
||||
registry.warnings.push(format!(
|
||||
"Project skills in {dirs} were not loaded: this workspace is not trusted. Run /trust to load them."
|
||||
));
|
||||
}
|
||||
registry
|
||||
}
|
||||
|
||||
/// Discover skills from the workspace search set plus the configured install
|
||||
@@ -1042,7 +1069,8 @@ pub fn discover_for_workspace_and_dir_with_mode_and_plugins(
|
||||
plugins: Option<&crate::plugins::PluginRegistry>,
|
||||
) -> SkillRegistry {
|
||||
let dirs = skill_directories_for_workspace_and_dir(workspace, skills_dir, mode);
|
||||
discover_from_directories_with_plugins(dirs, plugins)
|
||||
let registry = discover_from_directories_with_plugins(dirs, plugins);
|
||||
with_untrusted_project_skills_warning(registry, workspace)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
|
||||
@@ -330,11 +330,22 @@ impl SkillRootCatalog {
|
||||
) -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
// Repository-supplied skills are instructions the user never
|
||||
// reviewed; they load only once the workspace is trusted. Resolved
|
||||
// lazily so a workspace without project skill dirs never reads config.
|
||||
let mut workspace_trusted = None;
|
||||
|
||||
for root in &self.roots {
|
||||
if !root.active_for_runtime {
|
||||
continue;
|
||||
}
|
||||
if root.scope == SkillScope::Project
|
||||
&& path_is_existing_dir(&root.path)
|
||||
&& !*workspace_trusted
|
||||
.get_or_insert_with(|| crate::config::is_workspace_trusted(workspace))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
match mode {
|
||||
super::SkillDiscoveryMode::Compatible => {}
|
||||
super::SkillDiscoveryMode::CodeWhaleOnly => {
|
||||
@@ -408,6 +419,28 @@ impl SkillRootCatalog {
|
||||
}
|
||||
}
|
||||
|
||||
/// Project skill directories that exist but were not loaded because the
|
||||
/// workspace is not trusted, so discovery can say so instead of dropping them
|
||||
/// silently.
|
||||
#[must_use]
|
||||
pub fn untrusted_project_skill_dirs(workspace: &Path, home_dir: Option<&Path>) -> Vec<PathBuf> {
|
||||
let catalog = SkillRootCatalog::build(workspace, home_dir, None);
|
||||
let present: Vec<PathBuf> = catalog
|
||||
.roots
|
||||
.iter()
|
||||
.filter(|root| {
|
||||
root.scope == SkillScope::Project
|
||||
&& root.active_for_runtime
|
||||
&& path_is_existing_dir(&root.path)
|
||||
})
|
||||
.map(|root| root.path.clone())
|
||||
.collect();
|
||||
if present.is_empty() || crate::config::is_workspace_trusted(workspace) {
|
||||
return Vec::new();
|
||||
}
|
||||
present
|
||||
}
|
||||
|
||||
/// Resolve candidate skill directories for runtime discovery (existing paths
|
||||
/// only), preserving historical precedence.
|
||||
#[must_use]
|
||||
|
||||
@@ -1962,3 +1962,49 @@ fn global_skill_roots_come_from_the_os_home_only() {
|
||||
"every runtime root is under the OS home or the workspace: {dirs:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Repository-supplied skills are instructions nobody reviewed: they stay out
|
||||
/// of runtime discovery until the workspace is trusted, and discovery says so
|
||||
/// rather than dropping them silently.
|
||||
#[test]
|
||||
fn project_skills_require_workspace_trust() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let workspace = tmp.path();
|
||||
write_skill(
|
||||
&workspace.join(".claude").join("skills"),
|
||||
"repo-skill",
|
||||
"from the repository",
|
||||
"do repo things",
|
||||
);
|
||||
write_skill(
|
||||
&workspace.join(".codewhale").join("skills"),
|
||||
"owned-project-skill",
|
||||
"project owned",
|
||||
"owned",
|
||||
);
|
||||
|
||||
let registry = super::discover_in_workspace(workspace);
|
||||
assert!(registry.get("repo-skill").is_none());
|
||||
assert!(registry.get("owned-project-skill").is_none());
|
||||
assert!(
|
||||
registry
|
||||
.warnings()
|
||||
.iter()
|
||||
.any(|warning| warning.contains("not trusted") && warning.contains("/trust")),
|
||||
"{:?}",
|
||||
registry.warnings()
|
||||
);
|
||||
|
||||
crate::config::save_workspace_trust(workspace).expect("trust workspace");
|
||||
let registry = super::discover_in_workspace(workspace);
|
||||
assert!(registry.get("repo-skill").is_some());
|
||||
assert!(registry.get("owned-project-skill").is_some());
|
||||
assert!(
|
||||
!registry
|
||||
.warnings()
|
||||
.iter()
|
||||
.any(|warning| warning.contains("not trusted")),
|
||||
"{:?}",
|
||||
registry.warnings()
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user