diff --git a/src/config/agent.rs b/src/config/agent.rs index 2a9f1ba..dce814c 100644 --- a/src/config/agent.rs +++ b/src/config/agent.rs @@ -400,6 +400,10 @@ impl Agent { self.config.enabled_skills.as_deref() } + pub fn enabled_macros(&self) -> Option<&[String]> { + self.config.enabled_macros.as_deref() + } + pub fn memory(&self) -> Option { self.config.memory } diff --git a/src/config/macro_policy.rs b/src/config/macro_policy.rs new file mode 100644 index 0000000..bee02e6 --- /dev/null +++ b/src/config/macro_policy.rs @@ -0,0 +1,929 @@ +use super::agent::Agent; +use super::app_config::AppConfig; +use super::macros::Macro; +use super::paths; +use super::role::Role; +use super::session::Session; + +use log::warn; +use std::collections::HashSet; +use std::fmt; +use std::fs::{read_dir, read_to_string}; +use std::path::PathBuf; + +/// Names that cannot be used as macro names because they are reserved for +/// `.macro enable ` / `.macro disable `. +pub const RESERVED_MACRO_NAMES: [&str; 2] = ["enable", "disable"]; + +/// Where a macro definition file was discovered. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MacroSource { + Workspace, + Global, +} + +impl fmt::Display for MacroSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + MacroSource::Workspace => write!(f, "workspace"), + MacroSource::Global => write!(f, "global"), + } + } +} + +/// The configuration level whose `enabled_macros` allowlist won the +/// first-`Some`-wins precedence chain (session > agent > role > global). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MacroAllowlistLevel { + Session, + Agent, + Role, + Global, +} + +impl fmt::Display for MacroAllowlistLevel { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + MacroAllowlistLevel::Session => write!(f, "session"), + MacroAllowlistLevel::Agent => write!(f, "agent"), + MacroAllowlistLevel::Role => write!(f, "role"), + MacroAllowlistLevel::Global => write!(f, "global"), + } + } +} + +/// The effective state of a macro within the active context. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MacroState { + /// Visible and invocable. + Enabled, + /// Excluded by the GLOBAL-level `enabled_macros` list (config file or + /// runtime toggle — the two are indistinguishable by design). Can be + /// re-enabled at runtime with `.macro enable `. + DisabledRuntime, + /// Excluded by a role/agent/session `enabled_macros` allowlist. Not + /// enableable from the REPL; the owning config is the source of truth. + /// `level` is never `Global` — a global exclusion is `DisabledRuntime`. + Locked { level: MacroAllowlistLevel }, + /// Named by the effective allowlist, but no such macro is installed. + Missing, + /// The macro name collides with a built-in REPL command; invocable only + /// via `.macro `, never as a top-level `.name` command. + ShadowedBuiltin, + /// The definition file failed to parse, or the name is reserved. + Invalid { reason: String }, +} + +impl MacroState { + #[allow(dead_code)] + pub fn is_invocable(&self) -> bool { + matches!(self, MacroState::Enabled | MacroState::ShadowedBuiltin) + } +} + +/// A macro definition file found on disk, before allowlist resolution. +#[derive(Debug, Clone)] +pub struct DiscoveredMacro { + pub name: String, + pub source: MacroSource, + /// The parsed definition, or the parse failure reason. + pub definition: Result, + /// True for a GLOBAL entry whose name is shadowed by a workspace entry. + pub shadowed_by_workspace: bool, +} + +/// One row of the resolved macro set. Missing allowlist entries produce rows +/// with `source: None`. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct ResolvedMacro { + pub name: String, + pub source: Option, + pub description: Option, + pub isolated: Option, + /// True for a global entry shadowed by a workspace entry of the same + /// name; the row is kept for display but is never the invocation target. + pub shadowed_by_workspace: bool, + pub state: MacroState, +} + +/// The visible macro set for the active context, resolved lazily on demand +/// from the discovered definition files and the effective `enabled_macros` +/// allowlist. +#[derive(Debug)] +pub struct MacroPolicy { + pub macros: Vec, +} + +impl MacroPolicy { + #[allow(dead_code)] + pub fn effective( + global: &AppConfig, + role: Option<&Role>, + agent: Option<&Agent>, + session: Option<&Session>, + builtin_commands: &[&str], + no_workspace_macros: bool, + ) -> Self { + Self::effective_with( + discover_macros(no_workspace_macros), + session.and_then(|s| s.enabled_macros()), + agent.and_then(|a| a.enabled_macros()), + role.and_then(|r| r.enabled_macros()), + global.enabled_macros.as_deref(), + builtin_commands, + ) + } + + fn effective_with( + discovered: Vec, + session_list: Option<&[String]>, + agent_list: Option<&[String]>, + role_list: Option<&[String]>, + global_list: Option<&[String]>, + builtin_commands: &[&str], + ) -> Self { + let allowlist = session_list + .map(|list| (MacroAllowlistLevel::Session, list)) + .or_else(|| agent_list.map(|list| (MacroAllowlistLevel::Agent, list))) + .or_else(|| role_list.map(|list| (MacroAllowlistLevel::Role, list))) + .or_else(|| global_list.map(|list| (MacroAllowlistLevel::Global, list))); + + let mut macros: Vec = discovered + .into_iter() + .map(|discovered_macro| { + let state = resolve_state(&discovered_macro, allowlist, builtin_commands); + let (description, isolated) = match &discovered_macro.definition { + Ok(value) => (value.description.clone(), Some(value.isolated)), + Err(_) => (None, None), + }; + ResolvedMacro { + name: discovered_macro.name, + source: Some(discovered_macro.source), + description, + isolated, + shadowed_by_workspace: discovered_macro.shadowed_by_workspace, + state, + } + }) + .collect(); + + if let Some((_, list)) = allowlist { + let known: HashSet<&str> = macros.iter().map(|m| m.name.as_str()).collect(); + let mut missing: Vec = vec![]; + for name in list { + if !known.contains(name.as_str()) && !missing.iter().any(|m| &m.name == name) { + warn!("enabled_macros references macro '{name}' which is not installed"); + missing.push(ResolvedMacro { + name: name.clone(), + source: None, + description: None, + isolated: None, + shadowed_by_workspace: false, + state: MacroState::Missing, + }); + } + } + macros.extend(missing); + } + + macros.sort_by(|a, b| { + a.name + .cmp(&b.name) + .then_with(|| source_rank(a.source).cmp(&source_rank(b.source))) + }); + + Self { macros } + } + + /// The invocation target for `name`: the workspace entry when one shadows + /// a global entry, otherwise the single discovered entry. Missing rows + /// are never returned. + #[allow(dead_code)] + pub fn find(&self, name: &str) -> Option<&ResolvedMacro> { + self.macros + .iter() + .find(|m| m.name == name && m.source.is_some() && !m.shadowed_by_workspace) + } +} + +fn source_rank(source: Option) -> u8 { + match source { + Some(MacroSource::Workspace) => 0, + Some(MacroSource::Global) => 1, + None => 2, + } +} + +fn resolve_state( + discovered: &DiscoveredMacro, + allowlist: Option<(MacroAllowlistLevel, &[String])>, + builtin_commands: &[&str], +) -> MacroState { + if RESERVED_MACRO_NAMES.contains(&discovered.name.as_str()) { + warn!( + "Ignoring macro '{}': the name is reserved for '.macro {}'", + discovered.name, discovered.name + ); + return MacroState::Invalid { + reason: format!("'{}' is a reserved macro name", discovered.name), + }; + } + + if let Err(reason) = &discovered.definition { + return MacroState::Invalid { + reason: reason.clone(), + }; + } + + if let Some((level, list)) = allowlist + && !list.iter().any(|name| name == &discovered.name) + { + return match level { + MacroAllowlistLevel::Global => MacroState::DisabledRuntime, + level => MacroState::Locked { level }, + }; + } + + if builtin_commands.contains(&discovered.name.as_str()) { + return MacroState::ShadowedBuiltin; + } + + MacroState::Enabled +} + +/// Rescans the workspace and global macro directories. Workspace entries +/// shadow global entries of the same name; the shadowed global entry is kept +/// and flagged so both remain listable. +pub fn discover_macros(no_workspace_macros: bool) -> Vec { + let mut dirs = vec![]; + if !no_workspace_macros { + dirs.push((MacroSource::Workspace, paths::workspace_macros_dir())); + } + dirs.push((MacroSource::Global, paths::macros_dir())); + discover_macros_in(&dirs) +} + +fn discover_macros_in(dirs: &[(MacroSource, PathBuf)]) -> Vec { + let mut seen: HashSet = HashSet::new(); + let mut output = vec![]; + + for (source, dir) in dirs { + let Ok(rd) = read_dir(dir) else { + continue; + }; + let mut entries: Vec<_> = rd.flatten().collect(); + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let is_file = entry + .file_type() + .map(|file_type| file_type.is_file()) + .unwrap_or(false); + if !is_file { + continue; + } + let Some(name) = entry + .file_name() + .to_str() + .and_then(|v| v.strip_suffix(".yaml")) + .map(str::to_string) + else { + continue; + }; + if name.is_empty() { + continue; + } + let definition = read_to_string(entry.path()) + .map_err(|err| err.to_string()) + .and_then(|content| { + serde_yaml::from_str::(&content).map_err(|err| err.to_string()) + }); + let shadowed_by_workspace = !seen.insert(name.clone()); + output.push(DiscoveredMacro { + name, + source: *source, + definition, + shadowed_by_workspace, + }); + } + } + + output +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::get_env_name; + use serial_test::serial; + use std::path::Path; + use std::{env, fs, time}; + + fn valid_macro() -> Macro { + Macro { + description: Some("a test macro".to_string()), + isolated: true, + variables: vec![], + steps: vec![".help".to_string()], + } + } + + fn disc(name: &str, source: MacroSource) -> DiscoveredMacro { + DiscoveredMacro { + name: name.to_string(), + source, + definition: Ok(valid_macro()), + shadowed_by_workspace: false, + } + } + + fn disc_invalid(name: &str, reason: &str) -> DiscoveredMacro { + DiscoveredMacro { + name: name.to_string(), + source: MacroSource::Global, + definition: Err(reason.to_string()), + shadowed_by_workspace: false, + } + } + + fn globals(names: &[&str]) -> Vec { + names.iter().map(|n| disc(n, MacroSource::Global)).collect() + } + + fn list(names: &[&str]) -> Vec { + names.iter().map(|s| s.to_string()).collect() + } + + fn resolve( + discovered: Vec, + session: Option<&[String]>, + agent: Option<&[String]>, + role: Option<&[String]>, + global: Option<&[String]>, + ) -> MacroPolicy { + MacroPolicy::effective_with(discovered, session, agent, role, global, &[]) + } + + fn state_of<'a>(policy: &'a MacroPolicy, name: &str) -> &'a MacroState { + &policy + .macros + .iter() + .find(|m| m.name == name) + .unwrap_or_else(|| panic!("no row for macro '{name}'")) + .state + } + + #[test] + fn all_none_enables_everything() { + let policy = resolve(globals(&["a", "b"]), None, None, None, None); + assert_eq!(state_of(&policy, "a"), &MacroState::Enabled); + assert_eq!(state_of(&policy, "b"), &MacroState::Enabled); + } + + #[test] + fn global_empty_list_disables_all_as_runtime() { + let l = list(&[]); + let policy = resolve(globals(&["a", "b"]), None, None, None, Some(&l)); + assert_eq!(state_of(&policy, "a"), &MacroState::DisabledRuntime); + assert_eq!(state_of(&policy, "b"), &MacroState::DisabledRuntime); + } + + #[test] + fn global_populated_partitions_enabled_and_disabled_runtime() { + let l = list(&["a"]); + let policy = resolve(globals(&["a", "b"]), None, None, None, Some(&l)); + assert_eq!(state_of(&policy, "a"), &MacroState::Enabled); + assert_eq!(state_of(&policy, "b"), &MacroState::DisabledRuntime); + } + + #[test] + fn role_populated_locks_excluded_at_role_level() { + let l = list(&["a"]); + let policy = resolve(globals(&["a", "b"]), None, None, Some(&l), None); + assert_eq!(state_of(&policy, "a"), &MacroState::Enabled); + assert_eq!( + state_of(&policy, "b"), + &MacroState::Locked { + level: MacroAllowlistLevel::Role + } + ); + } + + #[test] + fn agent_populated_locks_excluded_at_agent_level() { + let l = list(&["a"]); + let policy = resolve(globals(&["a", "b"]), None, Some(&l), None, None); + assert_eq!( + state_of(&policy, "b"), + &MacroState::Locked { + level: MacroAllowlistLevel::Agent + } + ); + } + + #[test] + fn session_populated_locks_excluded_at_session_level() { + let l = list(&["a"]); + let policy = resolve(globals(&["a", "b"]), Some(&l), None, None, None); + assert_eq!( + state_of(&policy, "b"), + &MacroState::Locked { + level: MacroAllowlistLevel::Session + } + ); + } + + #[test] + fn role_empty_list_locks_everything_at_role_level() { + let l = list(&[]); + let policy = resolve(globals(&["a"]), None, None, Some(&l), None); + assert_eq!( + state_of(&policy, "a"), + &MacroState::Locked { + level: MacroAllowlistLevel::Role + } + ); + } + + #[test] + fn agent_empty_list_locks_everything_at_agent_level() { + let l = list(&[]); + let policy = resolve(globals(&["a"]), None, Some(&l), None, None); + assert_eq!( + state_of(&policy, "a"), + &MacroState::Locked { + level: MacroAllowlistLevel::Agent + } + ); + } + + #[test] + fn session_empty_list_locks_everything_at_session_level() { + let l = list(&[]); + let policy = resolve(globals(&["a"]), Some(&l), None, None, None); + assert_eq!( + state_of(&policy, "a"), + &MacroState::Locked { + level: MacroAllowlistLevel::Session + } + ); + } + + #[test] + fn session_wins_over_agent() { + let session = list(&["a"]); + let agent = list(&["b"]); + let policy = resolve( + globals(&["a", "b"]), + Some(&session), + Some(&agent), + None, + None, + ); + assert_eq!(state_of(&policy, "a"), &MacroState::Enabled); + assert_eq!( + state_of(&policy, "b"), + &MacroState::Locked { + level: MacroAllowlistLevel::Session + } + ); + } + + #[test] + fn agent_wins_over_role() { + let agent = list(&["a"]); + let role = list(&["b"]); + let policy = resolve(globals(&["a", "b"]), None, Some(&agent), Some(&role), None); + assert_eq!(state_of(&policy, "a"), &MacroState::Enabled); + assert_eq!( + state_of(&policy, "b"), + &MacroState::Locked { + level: MacroAllowlistLevel::Agent + } + ); + } + + #[test] + fn role_wins_over_global() { + let role = list(&["a"]); + let global = list(&["b"]); + let policy = resolve(globals(&["a", "b"]), None, None, Some(&role), Some(&global)); + assert_eq!(state_of(&policy, "a"), &MacroState::Enabled); + assert_eq!( + state_of(&policy, "b"), + &MacroState::Locked { + level: MacroAllowlistLevel::Role + } + ); + } + + #[test] + fn empty_list_at_session_beats_populated_global() { + let session = list(&[]); + let global = list(&["a"]); + let policy = resolve(globals(&["a"]), Some(&session), None, None, Some(&global)); + assert_eq!( + state_of(&policy, "a"), + &MacroState::Locked { + level: MacroAllowlistLevel::Session + } + ); + } + + #[test] + fn unknown_allowlist_name_yields_missing_row_without_error() { + let l = list(&["a", "ghost"]); + let policy = resolve(globals(&["a"]), None, None, None, Some(&l)); + assert_eq!(state_of(&policy, "a"), &MacroState::Enabled); + assert_eq!(state_of(&policy, "ghost"), &MacroState::Missing); + let ghost = policy.macros.iter().find(|m| m.name == "ghost").unwrap(); + assert_eq!(ghost.source, None); + } + + #[test] + fn missing_row_deduplicated_for_repeated_allowlist_names() { + let l = list(&["ghost", "ghost"]); + let policy = resolve(vec![], None, None, None, Some(&l)); + assert_eq!(policy.macros.len(), 1); + assert_eq!(state_of(&policy, "ghost"), &MacroState::Missing); + } + + #[test] + fn no_missing_rows_without_an_allowlist() { + let policy = resolve(globals(&["a"]), None, None, None, None); + assert_eq!(policy.macros.len(), 1); + } + + #[test] + fn builtin_name_collision_is_shadowed() { + let policy = + MacroPolicy::effective_with(globals(&["help", "a"]), None, None, None, None, &["help"]); + assert_eq!(state_of(&policy, "help"), &MacroState::ShadowedBuiltin); + assert_eq!(state_of(&policy, "a"), &MacroState::Enabled); + } + + #[test] + fn locked_wins_over_shadowed_builtin() { + let l = list(&["a"]); + let policy = MacroPolicy::effective_with( + globals(&["help", "a"]), + Some(&l), + None, + None, + None, + &["help"], + ); + assert_eq!( + state_of(&policy, "help"), + &MacroState::Locked { + level: MacroAllowlistLevel::Session + } + ); + } + + #[test] + fn allowlisted_builtin_collision_stays_shadowed() { + let l = list(&["help"]); + let policy = + MacroPolicy::effective_with(globals(&["help"]), None, None, None, Some(&l), &["help"]); + assert_eq!(state_of(&policy, "help"), &MacroState::ShadowedBuiltin); + } + + #[test] + fn reserved_names_are_invalid() { + let policy = resolve(globals(&["enable", "disable"]), None, None, None, None); + for name in RESERVED_MACRO_NAMES { + assert_eq!( + state_of(&policy, name), + &MacroState::Invalid { + reason: format!("'{name}' is a reserved macro name") + } + ); + } + } + + #[test] + fn reserved_name_invalid_even_when_allowlisted() { + let l = list(&["enable"]); + let policy = resolve(globals(&["enable"]), Some(&l), None, None, None); + assert_eq!( + state_of(&policy, "enable"), + &MacroState::Invalid { + reason: "'enable' is a reserved macro name".to_string() + } + ); + } + + #[test] + fn reserved_name_invalid_wins_over_builtin_collision() { + let policy = + MacroPolicy::effective_with(globals(&["enable"]), None, None, None, None, &["enable"]); + assert_eq!( + state_of(&policy, "enable"), + &MacroState::Invalid { + reason: "'enable' is a reserved macro name".to_string() + } + ); + } + + #[test] + fn parse_failure_is_invalid() { + let policy = resolve(vec![disc_invalid("bad", "boom")], None, None, None, None); + assert_eq!( + state_of(&policy, "bad"), + &MacroState::Invalid { + reason: "boom".to_string() + } + ); + let bad = policy.macros.iter().find(|m| m.name == "bad").unwrap(); + assert_eq!(bad.description, None); + assert_eq!(bad.isolated, None); + } + + #[test] + fn invalid_wins_over_allowlist_exclusion() { + let l = list(&["other"]); + let policy = resolve( + vec![disc_invalid("bad", "boom")], + Some(&l), + None, + None, + None, + ); + assert_eq!( + state_of(&policy, "bad"), + &MacroState::Invalid { + reason: "boom".to_string() + } + ); + } + + #[test] + fn workspace_shadowing_keeps_both_rows_and_find_returns_workspace() { + let discovered = vec![ + disc("a", MacroSource::Workspace), + DiscoveredMacro { + shadowed_by_workspace: true, + ..disc("a", MacroSource::Global) + }, + ]; + let policy = resolve(discovered, None, None, None, None); + assert_eq!(policy.macros.len(), 2); + assert_eq!(policy.macros[0].source, Some(MacroSource::Workspace)); + assert!(!policy.macros[0].shadowed_by_workspace); + assert_eq!(policy.macros[1].source, Some(MacroSource::Global)); + assert!(policy.macros[1].shadowed_by_workspace); + let found = policy.find("a").unwrap(); + assert_eq!(found.source, Some(MacroSource::Workspace)); + } + + #[test] + fn find_skips_missing_rows() { + let l = list(&["ghost"]); + let policy = resolve(vec![], None, None, None, Some(&l)); + assert!(policy.find("ghost").is_none()); + } + + #[test] + fn rows_are_sorted_by_name() { + let policy = resolve(globals(&["c", "a", "b"]), None, None, None, None); + let names: Vec<&str> = policy.macros.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(names, vec!["a", "b", "c"]); + } + + #[test] + fn resolved_rows_carry_description_and_isolated() { + let policy = resolve(globals(&["a"]), None, None, None, None); + let row = policy.macros.first().unwrap(); + assert_eq!(row.description.as_deref(), Some("a test macro")); + assert_eq!(row.isolated, Some(true)); + } + + #[test] + fn is_invocable_only_for_enabled_and_shadowed() { + assert!(MacroState::Enabled.is_invocable()); + assert!(MacroState::ShadowedBuiltin.is_invocable()); + assert!(!MacroState::DisabledRuntime.is_invocable()); + assert!( + !MacroState::Locked { + level: MacroAllowlistLevel::Role + } + .is_invocable() + ); + assert!(!MacroState::Missing.is_invocable()); + assert!( + !MacroState::Invalid { + reason: "x".to_string() + } + .is_invocable() + ); + } + + #[test] + fn level_and_source_display() { + assert_eq!(MacroAllowlistLevel::Session.to_string(), "session"); + assert_eq!(MacroAllowlistLevel::Agent.to_string(), "agent"); + assert_eq!(MacroAllowlistLevel::Role.to_string(), "role"); + assert_eq!(MacroAllowlistLevel::Global.to_string(), "global"); + assert_eq!(MacroSource::Workspace.to_string(), "workspace"); + assert_eq!(MacroSource::Global.to_string(), "global"); + } + + fn with_macro_dirs(f: F) { + let unique = time::SystemTime::now() + .duration_since(time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = env::temp_dir().join(format!("coyote-macro-policy-test-{unique}")); + let workspace = root.join("workspace-macros"); + let global = root.join("global-macros"); + fs::create_dir_all(&workspace).unwrap(); + fs::create_dir_all(&global).unwrap(); + f(&workspace, &global); + let _ = fs::remove_dir_all(&root); + } + + fn write_macro(dir: &Path, name: &str, content: &str) { + fs::write(dir.join(format!("{name}.yaml")), content).unwrap(); + } + + const VALID_YAML: &str = "steps:\n - \".help\"\n"; + + #[test] + fn discovery_scans_workspace_then_global_with_shadowing() { + with_macro_dirs(|workspace, global| { + write_macro(workspace, "both", VALID_YAML); + write_macro(workspace, "ws-only", VALID_YAML); + write_macro(global, "both", VALID_YAML); + write_macro(global, "global-only", VALID_YAML); + + let discovered = discover_macros_in(&[ + (MacroSource::Workspace, workspace.to_path_buf()), + (MacroSource::Global, global.to_path_buf()), + ]); + + assert_eq!(discovered.len(), 4); + let both_ws = discovered + .iter() + .find(|d| d.name == "both" && d.source == MacroSource::Workspace) + .unwrap(); + assert!(!both_ws.shadowed_by_workspace); + let both_global = discovered + .iter() + .find(|d| d.name == "both" && d.source == MacroSource::Global) + .unwrap(); + assert!(both_global.shadowed_by_workspace); + let global_only = discovered.iter().find(|d| d.name == "global-only").unwrap(); + assert!(!global_only.shadowed_by_workspace); + }); + } + + #[test] + fn discovery_ignores_non_yaml_files_and_directories() { + with_macro_dirs(|_, global| { + write_macro(global, "good", VALID_YAML); + fs::write(global.join("notes.txt"), "not a macro").unwrap(); + fs::write(global.join(".yaml"), VALID_YAML).unwrap(); + fs::create_dir_all(global.join("subdir.yaml")).unwrap(); + + let discovered = discover_macros_in(&[(MacroSource::Global, global.to_path_buf())]); + + assert_eq!(discovered.len(), 1); + assert_eq!(discovered[0].name, "good"); + }); + } + + #[test] + fn discovery_records_parse_failures() { + with_macro_dirs(|_, global| { + write_macro(global, "broken", "steps: {not valid"); + + let discovered = discover_macros_in(&[(MacroSource::Global, global.to_path_buf())]); + + assert_eq!(discovered.len(), 1); + assert!(discovered[0].definition.is_err()); + }); + } + + #[test] + fn discovery_of_nonexistent_dirs_is_empty() { + let discovered = discover_macros_in(&[( + MacroSource::Global, + PathBuf::from("/nonexistent/coyote-macro-policy-test"), + )]); + assert!(discovered.is_empty()); + } + + fn with_macro_dir_envs(workspace: &Path, global: &Path, f: F) { + let ws_env = get_env_name("workspace_config_dir"); + let global_env = get_env_name("macros_dir"); + let prev_ws = env::var_os(&ws_env); + let prev_global = env::var_os(&global_env); + unsafe { + env::set_var(&ws_env, workspace); + env::set_var(&global_env, global); + } + f(); + unsafe { + match prev_ws { + Some(v) => env::set_var(&ws_env, v), + None => env::remove_var(&ws_env), + } + match prev_global { + Some(v) => env::set_var(&global_env, v), + None => env::remove_var(&global_env), + } + } + } + + #[test] + #[serial] + fn discover_macros_honors_no_workspace_macros() { + with_macro_dirs(|workspace_root, global| { + let macros_subdir = workspace_root.join("macros"); + fs::create_dir_all(¯os_subdir).unwrap(); + write_macro(¯os_subdir, "ws-macro", VALID_YAML); + write_macro(global, "global-macro", VALID_YAML); + + with_macro_dir_envs(workspace_root, global, || { + let with_workspace = discover_macros(false); + let names: Vec<&str> = with_workspace.iter().map(|d| d.name.as_str()).collect(); + assert!(names.contains(&"ws-macro")); + assert!(names.contains(&"global-macro")); + + let without_workspace = discover_macros(true); + let names: Vec<&str> = without_workspace.iter().map(|d| d.name.as_str()).collect(); + assert!(!names.contains(&"ws-macro")); + assert!(names.contains(&"global-macro")); + }); + }); + } + + #[test] + #[serial] + fn effective_resolves_role_session_and_global_levels() { + with_macro_dirs(|workspace_root, global_dir| { + write_macro(global_dir, "a", VALID_YAML); + write_macro(global_dir, "b", VALID_YAML); + + with_macro_dir_envs(workspace_root, global_dir, || { + let global = AppConfig { + enabled_macros: Some(vec!["a".to_string()]), + ..AppConfig::default() + }; + + let policy = MacroPolicy::effective(&global, None, None, None, &[], false); + assert_eq!(state_of(&policy, "a"), &MacroState::Enabled); + assert_eq!(state_of(&policy, "b"), &MacroState::DisabledRuntime); + + let role = Role::new("test", "---\nenabled_macros: b\n---\nbody"); + let policy = MacroPolicy::effective(&global, Some(&role), None, None, &[], false); + assert_eq!( + state_of(&policy, "a"), + &MacroState::Locked { + level: MacroAllowlistLevel::Role + } + ); + assert_eq!(state_of(&policy, "b"), &MacroState::Enabled); + + let session: Session = serde_yaml::from_str( + "model: provider:test\nenabled_macros: \"a\"\nmessages: []", + ) + .unwrap(); + let policy = + MacroPolicy::effective(&global, Some(&role), None, Some(&session), &[], false); + assert_eq!(state_of(&policy, "a"), &MacroState::Enabled); + assert_eq!( + state_of(&policy, "b"), + &MacroState::Locked { + level: MacroAllowlistLevel::Session + } + ); + }); + }); + } + + #[test] + #[serial] + fn effective_pins_empty_string_role_allowlist_as_explicit_zero() { + with_macro_dirs(|workspace_root, global_dir| { + write_macro(global_dir, "a", VALID_YAML); + + with_macro_dir_envs(workspace_root, global_dir, || { + let global = AppConfig { + enabled_macros: Some(vec!["a".to_string()]), + ..AppConfig::default() + }; + let role = Role::new("test", "---\nenabled_macros: \"\"\n---\nbody"); + + let policy = MacroPolicy::effective(&global, Some(&role), None, None, &[], false); + assert_eq!( + state_of(&policy, "a"), + &MacroState::Locked { + level: MacroAllowlistLevel::Role + } + ); + }); + }); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index ad25b6e..bb0c408 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -4,6 +4,7 @@ mod app_state; mod input; mod install_remote; pub(crate) mod instructions; +mod macro_policy; mod macros; mod mcp_factory; pub(crate) mod memory; @@ -31,6 +32,11 @@ pub use self::app_state::AppState; pub use self::input::Input; pub use self::install_remote::{install_remote, install_remote_from_repl_args}; #[allow(unused_imports)] +pub use self::macro_policy::{ + DiscoveredMacro, MacroAllowlistLevel, MacroPolicy, MacroSource, MacroState, + RESERVED_MACRO_NAMES, ResolvedMacro, discover_macros, +}; +#[allow(unused_imports)] pub use self::request_context::{RenderMode, RequestContext, should_inject_skill_instructions}; pub use self::role::{ CODE_ROLE, CREATE_TITLE_ROLE, EXPLAIN_SHELL_ROLE, Role, RoleLike, SHELL_ROLE, diff --git a/src/config/paths.rs b/src/config/paths.rs index 5beeeb7..10fe403 100644 --- a/src/config/paths.rs +++ b/src/config/paths.rs @@ -214,6 +214,10 @@ pub fn workspace_skill_file(name: &str) -> PathBuf { workspace_skills_dir().join(name).join("SKILL.md") } +pub fn workspace_macros_dir() -> PathBuf { + workspace_config_dir().join(MACROS_DIR_NAME) +} + pub fn workspace_mcp_config_file() -> Option { workspace_mcp_config_file_in(&env::current_dir().unwrap_or_default()) } diff --git a/src/config/role.rs b/src/config/role.rs index 69281f8..213f848 100644 --- a/src/config/role.rs +++ b/src/config/role.rs @@ -368,6 +368,10 @@ impl Role { self.enabled_skills.as_deref() } + pub fn enabled_macros(&self) -> Option<&[String]> { + self.enabled_macros.as_deref() + } + pub fn append_to_prompt(&mut self, text: &str) { self.prompt.push_str(text); } diff --git a/src/config/session.rs b/src/config/session.rs index cd7f04e..59d334f 100644 --- a/src/config/session.rs +++ b/src/config/session.rs @@ -113,6 +113,10 @@ impl Session { self.enabled_skills.as_deref() } + pub fn enabled_macros(&self) -> Option<&[String]> { + self.enabled_macros.as_deref() + } + pub fn set_skills_enabled(&mut self, value: Option) { if self.skills_enabled != value { self.skills_enabled = value;