#![forbid(unsafe_code)]
use wm_dispatch::{ToolRegistry, ToolRegistryBuilder};
#[derive(Debug, Clone, Copy)]
pub struct ToolProfile {
pub name: &'static str,
pub prefixes: &'static [&'static str],
}
pub static PROFILE_FULL: ToolProfile = ToolProfile {
name: "full",
prefixes: &["*"],
};
pub static PROFILE_CURATED: ToolProfile = ToolProfile {
name: "curated",
prefixes: &["memory", "session", "claims", "transaction", "gnosis"],
};
pub static PROFILE_MINIMAL: ToolProfile = ToolProfile {
name: "minimal",
prefixes: &[
"memory.create",
"memory.read",
"memory.list",
"memory.query",
"memory.search",
"memory.chat",
"memory.associate",
"memory.associations",
"gnosis",
],
};
pub static PROFILE_PRAY: ToolProfile = ToolProfile {
name: "pray",
prefixes: &["whitemagic"],
};
#[deprecated(since = "9.2.0", note = "renamed to PROFILE_PRAY")]
pub use self::PROFILE_PRAY as PROFILE_PRAT;
#[must_use]
pub fn profile_from_name(name: &str) -> Option<&'static ToolProfile> {
match name.trim().to_ascii_lowercase().as_str() {
"full" => Some(&PROFILE_FULL),
"curated" => Some(&PROFILE_CURATED),
"minimal" => Some(&PROFILE_MINIMAL),
"pray" => Some(&PROFILE_PRAY),
"prat" => Some(&PROFILE_PRAY),
_ => None,
}
}
#[must_use]
pub fn resolve_tool_profile(
cli_profile: Option<&str>,
env_profile: Option<&str>,
env_allowlist: Option<&str>,
) -> &'static ToolProfile {
if let Some(allow) = env_allowlist {
if let Some(profile) = allowlist_from_env(allow) {
tracing::info!(
allowlist = %allow,
"WM_TOOL_ALLOWLIST tool surface in effect"
);
return Box::leak(Box::new(profile));
}
}
match cli_profile.or(env_profile) {
Some(name) => profile_from_name(name).unwrap_or_else(|| {
tracing::warn!(
profile = name,
"unknown tool surface profile — using full tool surface"
);
&PROFILE_FULL
}),
None => &PROFILE_FULL,
}
}
#[must_use]
pub fn allowlist_from_env(spec: &str) -> Option<ToolProfile> {
let prefixes: Vec<&'static str> = spec
.split(',')
.map(str::trim)
.filter(|p| !p.is_empty())
.collect::<Vec<_>>()
.into_iter()
.map(|p| Box::leak(p.to_string().into_boxed_str()) as &'static str)
.collect();
if prefixes.is_empty() {
return None;
}
Some(ToolProfile {
name: "allowlist",
prefixes: Box::leak(prefixes.into_boxed_slice()),
})
}
#[must_use]
pub fn apply_profile(registry: ToolRegistry, profile: &ToolProfile) -> ToolRegistry {
if profile.prefixes.contains(&"*") {
return registry;
}
let mut builder = ToolRegistryBuilder::new();
for tool in registry.all() {
if matches_prefixes(tool.name(), profile.prefixes) {
builder.register(tool);
}
}
builder.build()
}
#[must_use]
pub fn matches_prefixes(name: &str, prefixes: &[&str]) -> bool {
prefixes.iter().any(|p| name.starts_with(p))
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ProfileContract {
pub profile: String,
pub prefixes: Vec<String>,
pub expected_count: usize,
pub registered_count: usize,
pub dead_prefixes: Vec<String>,
pub unexpected_tools: Vec<String>,
pub destructive_tools: Vec<String>,
pub verified_at: String,
pub ok: bool,
}
#[must_use]
pub fn profile_contract(
full: &ToolRegistry,
filtered: &ToolRegistry,
profile: &ToolProfile,
) -> ProfileContract {
let full_names: Vec<&str> = full.all_ref().iter().map(|t| t.name()).collect();
let registered: Vec<&str> = filtered.all_ref().iter().map(|t| t.name()).collect();
let star = profile.prefixes.contains(&"*");
let matches = |name: &str| star || profile.prefixes.iter().any(|p| name.starts_with(p));
let expected_count = full_names.iter().filter(|n| matches(n)).count();
let unexpected_tools: Vec<String> = registered
.iter()
.filter(|n| !matches(n))
.map(|n| (*n).to_string())
.collect();
let dead_prefixes: Vec<String> = profile
.prefixes
.iter()
.filter(|p| **p != "*" && !full_names.iter().any(|n| n.starts_with(**p)))
.map(|p| (*p).to_string())
.collect();
let destructive_tools: Vec<String> = filtered
.all_ref()
.iter()
.filter(|t| t.effects().destructive)
.map(|t| t.name().to_string())
.collect();
let ok = expected_count == registered.len()
&& unexpected_tools.is_empty()
&& dead_prefixes.is_empty();
ProfileContract {
profile: profile.name.to_string(),
prefixes: profile.prefixes.iter().map(|p| (*p).to_string()).collect(),
expected_count,
registered_count: registered.len(),
dead_prefixes,
unexpected_tools,
destructive_tools,
verified_at: wm_core::time::now_rfc3339(),
ok,
}
}
pub fn save_contract(root: &std::path::Path, contract: &ProfileContract) {
let path = root.join("profile_contract.json");
let tmp = root.join(".profile_contract.json.tmp");
let write = serde_json::to_string_pretty(contract)
.map(|body| std::fs::write(&tmp, body).and_then(|()| std::fs::rename(&tmp, &path)));
if let Err(e) = write {
tracing::warn!(
path = %path.display(),
error = %e,
"could not persist profile contract"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use wm_core::Tool;
#[test]
fn profile_names_resolve() {
assert_eq!(profile_from_name("full").map(|p| p.name), Some("full"));
assert_eq!(
profile_from_name("CURATED").map(|p| p.name),
Some("curated")
);
assert_eq!(
profile_from_name("minimal").map(|p| p.name),
Some("minimal")
);
assert!(profile_from_name("bogus").is_none());
}
#[test]
fn curated_has_no_dead_routes() {
assert!(
!PROFILE_CURATED
.prefixes
.iter()
.any(|p| p.starts_with("galaxy")),
"curated profile must not include galaxy prefixes"
);
}
#[test]
fn curated_is_the_product_surface() {
assert_eq!(
PROFILE_CURATED.prefixes,
&["memory", "session", "claims", "transaction", "gnosis"]
);
assert!(
!PROFILE_CURATED
.prefixes
.iter()
.any(|p| *p == "nlu.shadow_report" || *p == "tools.usage_report"),
"observability tools belong on the full surface"
);
}
#[test]
fn allowlist_parses_and_rejects_empty() {
assert!(allowlist_from_env("").is_none());
assert!(allowlist_from_env(" , ").is_none());
let profile = allowlist_from_env("memory, claims , session").unwrap();
assert_eq!(profile.name, "allowlist");
assert_eq!(profile.prefixes, &["memory", "claims", "session"]);
}
#[test]
fn full_profile_is_passthrough() {
let registry = ToolRegistry::new();
let out = apply_profile(registry, &PROFILE_FULL);
assert_eq!(out.len(), 0);
}
#[test]
fn resolve_profile_precedence() {
assert_eq!(
resolve_tool_profile(Some("curated"), Some("minimal"), None).name,
"curated"
);
assert_eq!(
resolve_tool_profile(None, Some("minimal"), None).name,
"minimal"
);
let resolved =
resolve_tool_profile(Some("curated"), Some("minimal"), Some("memory,session"));
assert_eq!(resolved.name, "allowlist");
assert_eq!(resolved.prefixes, &["memory", "session"]);
assert_eq!(resolve_tool_profile(None, None, None).name, "full");
assert_eq!(resolve_tool_profile(Some("bogus"), None, None).name, "full");
assert_eq!(resolve_tool_profile(None, Some("bogus"), None).name, "full");
}
struct ContractMock {
name: String,
effects: wm_core::EffectRow,
stats: wm_core::ToolStats,
}
#[async_trait::async_trait]
impl wm_core::Tool for ContractMock {
fn name(&self) -> &str {
&self.name
}
fn gana(&self) -> wm_core::Gana {
wm_core::Gana::Horn
}
fn effects(&self) -> &wm_core::EffectRow {
&self.effects
}
fn stats(&self) -> &wm_core::ToolStats {
&self.stats
}
async fn call(
&self,
_ctx: &mut wm_core::Context,
_args: wm_core::Args,
) -> wm_core::Result<wm_core::Output> {
Ok(serde_json::json!({"ok": true}))
}
}
fn contract_tool(name: &str, destructive: bool) -> Arc<dyn Tool> {
let effects = if destructive {
wm_core::EffectRow {
destructive: true,
..wm_core::EffectRow::default()
}
} else {
wm_core::EffectRow::default()
};
Arc::new(ContractMock {
name: name.into(),
effects,
stats: wm_core::ToolStats::default(),
})
}
fn contract_registry(tools: &[Arc<dyn Tool>]) -> ToolRegistry {
let mut builder = ToolRegistryBuilder::new();
for tool in tools {
builder.register(Arc::clone(tool));
}
builder.build()
}
fn minimal_registry(tools: &[Arc<dyn Tool>]) -> ToolRegistry {
let prefix_tools: Vec<Arc<dyn Tool>> = [
"memory.create",
"memory.read",
"memory.list",
"memory.query",
"memory.search",
"memory.chat",
"memory.associate",
"memory.associations",
"gnosis",
]
.iter()
.map(|n| contract_tool(n, false) as Arc<dyn Tool>)
.collect();
let mut all = prefix_tools;
all.extend(tools.iter().cloned());
contract_registry(&all)
}
#[test]
fn contract_ok_when_surface_is_exact() {
let full = minimal_registry(&[]);
let filtered = contract_registry(&full.all());
let c = profile_contract(&full, &filtered, &PROFILE_MINIMAL);
assert!(c.ok);
assert_eq!(c.expected_count, 9);
assert_eq!(c.registered_count, 9);
assert!(c.dead_prefixes.is_empty());
assert!(c.unexpected_tools.is_empty());
}
#[test]
fn contract_detects_dead_prefixes_and_unexpected_tools() {
let alpha = contract_tool("alpha.one", false);
let sneaky = contract_tool("sneaky.tool", false);
let full = contract_registry(std::slice::from_ref(&alpha));
let filtered = contract_registry(&[alpha, sneaky]);
let c = profile_contract(
&full,
&filtered,
&allowlist_from_env("alpha,gamma").unwrap(),
);
assert!(!c.ok);
assert_eq!(c.dead_prefixes, vec!["gamma".to_string()]);
assert_eq!(c.unexpected_tools, vec!["sneaky.tool".to_string()]);
assert_eq!(c.expected_count, 1);
assert_eq!(c.registered_count, 2);
}
#[test]
fn contract_reports_destructive_tools_informationally() {
let full = minimal_registry(&[]);
let filtered = contract_registry(&full.all());
let c = profile_contract(&full, &filtered, &PROFILE_MINIMAL);
assert!(
c.ok,
"destructive presence is informational, not a violation"
);
assert!(c.destructive_tools.is_empty());
let curated_tools: Vec<Arc<dyn Tool>> = [
"memory.create",
"session.start",
"claims.list",
"transaction.begin",
"gnosis",
"tools.list",
"memory.delete",
"galaxy.purge",
]
.iter()
.map(|n| contract_tool(n, *n == "memory.delete" || *n == "galaxy.purge") as Arc<dyn Tool>)
.collect();
let full2 = contract_registry(&curated_tools);
let filtered2 = apply_profile(full2.clone(), &PROFILE_CURATED);
let c2 = profile_contract(&full2, &filtered2, &PROFILE_CURATED);
assert_eq!(c2.destructive_tools, vec!["memory.delete".to_string()]);
assert!(c2.ok);
assert_eq!(c2.expected_count, 6);
assert_eq!(c2.registered_count, 6);
}
#[test]
fn full_profile_contract_counts_everything() {
let tools: Vec<Arc<dyn Tool>> = vec![
contract_tool("memory.create", false),
contract_tool("galaxy.purge", true),
];
let full = contract_registry(&tools);
let filtered = contract_registry(&full.all().iter().map(Arc::clone).collect::<Vec<_>>());
let c = profile_contract(&full, &filtered, &PROFILE_FULL);
assert!(c.ok);
assert_eq!(c.expected_count, 2);
assert_eq!(c.registered_count, 2);
assert!(c.dead_prefixes.is_empty());
}
#[test]
fn pray_profile_is_single_surface() {
assert_eq!(PROFILE_PRAY.prefixes, &["whitemagic"]);
assert_eq!(profile_from_name("pray").unwrap().name, "pray");
assert_eq!(profile_from_name("prat").unwrap().name, "pray");
}
}