@@ -1151,6 +1151,28 @@ fn prompt_build_limits_from_config(config: &moltis_config::MoltisConfig) -> Prom
11511151 }
11521152}
11531153
1154+ /// Discover skills from the default filesystem paths, honoring `[skills] enabled`.
1155+ ///
1156+ /// Returns an empty list when `config.skills.enabled` is `false`, so callers can
1157+ /// unconditionally feed the result into prompt building / tool filtering without
1158+ /// injecting skills into the LLM context when the operator has disabled them.
1159+ async fn discover_skills_if_enabled(
1160+ config: &moltis_config::MoltisConfig,
1161+ ) -> Vec<moltis_skills::types::SkillMetadata> {
1162+ if !config.skills.enabled {
1163+ return Vec::new();
1164+ }
1165+ let search_paths = moltis_skills::discover::FsSkillDiscoverer::default_paths();
1166+ let discoverer = moltis_skills::discover::FsSkillDiscoverer::new(search_paths);
1167+ match discoverer.discover().await {
1168+ Ok(skills) => skills,
1169+ Err(e) => {
1170+ warn!("failed to discover skills: {e}");
1171+ Vec::new()
1172+ },
1173+ }
1174+ }
1175+
11541176fn resolve_channel_runtime_context(
11551177 session_key: &str,
11561178 session_entry: Option<&SessionEntry>,
@@ -3707,16 +3729,10 @@ impl ChatService for LiveChatService {
37073729 run_id: Some(run_id.clone()),
37083730 };
37093731
3710- // Discover enabled skills/plugins for prompt injection.
3711- let search_paths = moltis_skills::discover::FsSkillDiscoverer::default_paths();
3712- let discoverer = moltis_skills::discover::FsSkillDiscoverer::new(search_paths);
3713- let discovered_skills = match discoverer.discover().await {
3714- Ok(s) => s,
3715- Err(e) => {
3716- warn!("failed to discover skills: {e}");
3717- Vec::new()
3718- },
3719- };
3732+ // Discover enabled skills/plugins for prompt injection (gated on
3733+ // `[skills] enabled` — see #655).
3734+ let discovered_skills =
3735+ discover_skills_if_enabled(&moltis_config::discover_and_load()).await;
37203736
37213737 // Check if MCP tools are disabled for this session and capture
37223738 // per-session sandbox override details for prompt runtime context.
@@ -5014,23 +5030,20 @@ impl ChatService for LiveChatService {
50145030 "promptSymbol": exec_prompt_symbol,
50155031 });
50165032
5017- // Discover enabled skills/plugins (only if provider supports tools)
5033+ // Discover enabled skills/plugins (only if provider supports tools and
5034+ // `[skills] enabled` is true — see #655).
50185035 let skills_list: Vec<Value> = if supports_tools {
5019- let search_paths = moltis_skills::discover::FsSkillDiscoverer::default_paths();
5020- let discoverer = moltis_skills::discover::FsSkillDiscoverer::new(search_paths);
5021- match discoverer.discover().await {
5022- Ok(s) => s
5023- .iter()
5024- .map(|s| {
5025- serde_json::json!({
5026- "name": s.name,
5027- "description": s.description,
5028- "source": s.source,
5029- })
5036+ discover_skills_if_enabled(&config)
5037+ .await
5038+ .iter()
5039+ .map(|s| {
5040+ serde_json::json!({
5041+ "name": s.name,
5042+ "description": s.description,
5043+ "source": s.source,
50305044 })
5031- .collect(),
5032- Err(_) => vec![],
5033- }
5045+ })
5046+ .collect()
50345047 } else {
50355048 vec![]
50365049 };
@@ -5108,16 +5121,8 @@ impl ChatService for LiveChatService {
51085121 .resolve_project_context(&session_key, conn_id.as_deref())
51095122 .await;
51105123
5111- // Discover skills.
5112- let search_paths = moltis_skills::discover::FsSkillDiscoverer::default_paths();
5113- let discoverer = moltis_skills::discover::FsSkillDiscoverer::new(search_paths);
5114- let discovered_skills = match discoverer.discover().await {
5115- Ok(s) => s,
5116- Err(e) => {
5117- warn!("failed to discover skills: {e}");
5118- Vec::new()
5119- },
5120- };
5124+ // Discover skills (gated on `[skills] enabled` — see #655).
5125+ let discovered_skills = discover_skills_if_enabled(&persona.config).await;
51215126
51225127 // Check MCP disabled.
51235128 let mcp_disabled = session_entry
@@ -5233,16 +5238,8 @@ impl ChatService for LiveChatService {
52335238 .resolve_project_context(&session_key, conn_id.as_deref())
52345239 .await;
52355240
5236- // Discover skills.
5237- let search_paths = moltis_skills::discover::FsSkillDiscoverer::default_paths();
5238- let discoverer = moltis_skills::discover::FsSkillDiscoverer::new(search_paths);
5239- let discovered_skills = match discoverer.discover().await {
5240- Ok(s) => s,
5241- Err(e) => {
5242- warn!("failed to discover skills: {e}");
5243- Vec::new()
5244- },
5245- };
5241+ // Discover skills (gated on `[skills] enabled` — see #655).
5242+ let discovered_skills = discover_skills_if_enabled(&persona.config).await;
52465243
52475244 // Check MCP disabled.
52485245 let mcp_disabled = session_entry
@@ -14221,4 +14218,78 @@ mod tests {
1422114218 "error should mention timeout: {err}"
1422214219 );
1422314220 }
14221+
14222+ /// Serializes tests that mutate the global `moltis_config` data_dir
14223+ /// override so they don't race within the chat crate's test binary.
14224+ /// A `Semaphore` with a single permit is used here instead of
14225+ /// `Mutex<()>` because CLAUDE.md forbids bare `Mutex<()>` — a mutex must
14226+ /// guard real state. This semaphore is a pure serialization primitive
14227+ /// for a separately-owned global (`moltis_config`'s data_dir override).
14228+ static SKILLS_TEST_DATA_DIR_LOCK: Semaphore = Semaphore::const_new(1);
14229+
14230+ /// Regression test for #655: `[skills] enabled = false` must short-circuit
14231+ /// skill discovery so nothing from the filesystem ends up in the LLM prompt.
14232+ #[tokio::test]
14233+ async fn discover_skills_if_enabled_short_circuits_when_disabled() {
14234+ let _permit = SKILLS_TEST_DATA_DIR_LOCK
14235+ .acquire()
14236+ .await
14237+ .expect("semaphore closed");
14238+
14239+ // Point data_dir at a temp dir containing a real SKILL.md so that if
14240+ // the helper *did* fall through to the discoverer, it would return a
14241+ // non-empty list and fail this assertion.
14242+ let tmp = tempfile::tempdir().expect("tempdir");
14243+ let skills_dir = tmp.path().join("skills").join("planted-skill");
14244+ std::fs::create_dir_all(&skills_dir).expect("mkdir");
14245+ std::fs::write(
14246+ skills_dir.join("SKILL.md"),
14247+ "---\nname: planted-skill\ndescription: should not appear\n---\nbody",
14248+ )
14249+ .expect("write");
14250+ moltis_config::set_data_dir(tmp.path().to_path_buf());
14251+
14252+ let mut cfg = moltis_config::MoltisConfig::default();
14253+ cfg.skills.enabled = false;
14254+
14255+ let result = discover_skills_if_enabled(&cfg).await;
14256+ moltis_config::clear_data_dir();
14257+
14258+ assert!(
14259+ result.is_empty(),
14260+ "disabled skills must yield no discovered skills, got: {result:?}",
14261+ );
14262+ }
14263+
14264+ /// Complement to the short-circuit test: when enabled, the helper actually
14265+ /// invokes the filesystem discoverer. Validates the other arm of the
14266+ /// `enabled` branch so we don't accidentally hard-code `Vec::new()`.
14267+ #[tokio::test]
14268+ async fn discover_skills_if_enabled_runs_discoverer_when_enabled() {
14269+ let _permit = SKILLS_TEST_DATA_DIR_LOCK
14270+ .acquire()
14271+ .await
14272+ .expect("semaphore closed");
14273+
14274+ let tmp = tempfile::tempdir().expect("tempdir");
14275+ let skills_dir = tmp.path().join("skills").join("live-skill");
14276+ std::fs::create_dir_all(&skills_dir).expect("mkdir");
14277+ std::fs::write(
14278+ skills_dir.join("SKILL.md"),
14279+ "---\nname: live-skill\ndescription: visible to prompt\n---\nbody",
14280+ )
14281+ .expect("write");
14282+ moltis_config::set_data_dir(tmp.path().to_path_buf());
14283+
14284+ let mut cfg = moltis_config::MoltisConfig::default();
14285+ cfg.skills.enabled = true;
14286+
14287+ let result = discover_skills_if_enabled(&cfg).await;
14288+ moltis_config::clear_data_dir();
14289+
14290+ assert!(
14291+ result.iter().any(|s| s.name == "live-skill"),
14292+ "enabled skills must be discovered from data_dir, got: {result:?}",
14293+ );
14294+ }
1422414295}
0 commit comments