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:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-25 05:41:54 -07:00
1 parent c1d3f1994b
commit 294b616ed8
3 files changed
+109 -2

No files matched your search

+30 -2
View File
@@ -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]
+33
View File
@@ -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]
+46
View File
@@ -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()
);
}