1#![forbid(unsafe_code)]
13
14use wm_dispatch::{ToolRegistry, ToolRegistryBuilder};
15
16#[derive(Debug, Clone, Copy)]
19pub struct ToolProfile {
20 pub name: &'static str,
22 pub prefixes: &'static [&'static str],
24}
25
26pub static PROFILE_FULL: ToolProfile = ToolProfile {
28 name: "full",
29 prefixes: &["*"],
30};
31
32pub static PROFILE_CURATED: ToolProfile = ToolProfile {
45 name: "curated",
46 prefixes: &["memory", "session", "claims", "transaction", "gnosis"],
47};
48
49pub static PROFILE_MINIMAL: ToolProfile = ToolProfile {
51 name: "minimal",
52 prefixes: &[
53 "memory.create",
54 "memory.read",
55 "memory.list",
56 "memory.query",
57 "memory.search",
58 "memory.chat",
59 "memory.associate",
60 "memory.associations",
61 "gnosis",
62 ],
63};
64
65pub static PROFILE_PRAY: ToolProfile = ToolProfile {
68 name: "pray",
69 prefixes: &["whitemagic"],
70};
71
72#[deprecated(since = "9.2.0", note = "renamed to PROFILE_PRAY")]
74pub use self::PROFILE_PRAY as PROFILE_PRAT;
75
76#[must_use]
78pub fn profile_from_name(name: &str) -> Option<&'static ToolProfile> {
79 match name.trim().to_ascii_lowercase().as_str() {
80 "full" => Some(&PROFILE_FULL),
81 "curated" => Some(&PROFILE_CURATED),
82 "minimal" => Some(&PROFILE_MINIMAL),
83 "pray" => Some(&PROFILE_PRAY),
84 "prat" => Some(&PROFILE_PRAY),
86 _ => None,
87 }
88}
89
90#[must_use]
100pub fn resolve_tool_profile(
101 cli_profile: Option<&str>,
102 env_profile: Option<&str>,
103 env_allowlist: Option<&str>,
104) -> &'static ToolProfile {
105 if let Some(allow) = env_allowlist {
106 if let Some(profile) = allowlist_from_env(allow) {
107 tracing::info!(
108 allowlist = %allow,
109 "WM_TOOL_ALLOWLIST tool surface in effect"
110 );
111 return Box::leak(Box::new(profile));
112 }
113 }
114 match cli_profile.or(env_profile) {
115 Some(name) => profile_from_name(name).unwrap_or_else(|| {
116 tracing::warn!(
117 profile = name,
118 "unknown tool surface profile — using full tool surface"
119 );
120 &PROFILE_FULL
121 }),
122 None => &PROFILE_FULL,
123 }
124}
125
126#[must_use]
129pub fn allowlist_from_env(spec: &str) -> Option<ToolProfile> {
130 let prefixes: Vec<&'static str> = spec
131 .split(',')
132 .map(str::trim)
133 .filter(|p| !p.is_empty())
134 .collect::<Vec<_>>()
135 .into_iter()
136 .map(|p| Box::leak(p.to_string().into_boxed_str()) as &'static str)
137 .collect();
138 if prefixes.is_empty() {
139 return None;
140 }
141 Some(ToolProfile {
142 name: "allowlist",
143 prefixes: Box::leak(prefixes.into_boxed_slice()),
144 })
145}
146
147#[must_use]
150pub fn apply_profile(registry: ToolRegistry, profile: &ToolProfile) -> ToolRegistry {
151 if profile.prefixes.contains(&"*") {
152 return registry;
153 }
154 let mut builder = ToolRegistryBuilder::new();
155 for tool in registry.all() {
156 if matches_prefixes(tool.name(), profile.prefixes) {
157 builder.register(tool);
158 }
159 }
160 builder.build()
161}
162
163#[must_use]
165pub fn matches_prefixes(name: &str, prefixes: &[&str]) -> bool {
166 prefixes.iter().any(|p| name.starts_with(p))
167}
168
169#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
180pub struct ProfileContract {
181 pub profile: String,
183 pub prefixes: Vec<String>,
185 pub expected_count: usize,
187 pub registered_count: usize,
189 pub dead_prefixes: Vec<String>,
191 pub unexpected_tools: Vec<String>,
193 pub destructive_tools: Vec<String>,
197 pub verified_at: String,
199 pub ok: bool,
201}
202
203#[must_use]
205pub fn profile_contract(
206 full: &ToolRegistry,
207 filtered: &ToolRegistry,
208 profile: &ToolProfile,
209) -> ProfileContract {
210 let full_names: Vec<&str> = full.all_ref().iter().map(|t| t.name()).collect();
211 let registered: Vec<&str> = filtered.all_ref().iter().map(|t| t.name()).collect();
212
213 let star = profile.prefixes.contains(&"*");
214 let matches = |name: &str| star || profile.prefixes.iter().any(|p| name.starts_with(p));
215 let expected_count = full_names.iter().filter(|n| matches(n)).count();
216 let unexpected_tools: Vec<String> = registered
217 .iter()
218 .filter(|n| !matches(n))
219 .map(|n| (*n).to_string())
220 .collect();
221 let dead_prefixes: Vec<String> = profile
222 .prefixes
223 .iter()
224 .filter(|p| **p != "*" && !full_names.iter().any(|n| n.starts_with(**p)))
225 .map(|p| (*p).to_string())
226 .collect();
227 let destructive_tools: Vec<String> = filtered
228 .all_ref()
229 .iter()
230 .filter(|t| t.effects().destructive)
231 .map(|t| t.name().to_string())
232 .collect();
233
234 let ok = expected_count == registered.len()
235 && unexpected_tools.is_empty()
236 && dead_prefixes.is_empty();
237
238 ProfileContract {
239 profile: profile.name.to_string(),
240 prefixes: profile.prefixes.iter().map(|p| (*p).to_string()).collect(),
241 expected_count,
242 registered_count: registered.len(),
243 dead_prefixes,
244 unexpected_tools,
245 destructive_tools,
246 verified_at: wm_core::time::now_rfc3339(),
247 ok,
248 }
249}
250
251pub fn save_contract(root: &std::path::Path, contract: &ProfileContract) {
255 let path = root.join("profile_contract.json");
256 let tmp = root.join(".profile_contract.json.tmp");
257 let write = serde_json::to_string_pretty(contract)
258 .map(|body| std::fs::write(&tmp, body).and_then(|()| std::fs::rename(&tmp, &path)));
259 if let Err(e) = write {
260 tracing::warn!(
261 path = %path.display(),
262 error = %e,
263 "could not persist profile contract"
264 );
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271 use std::sync::Arc;
272 use wm_core::Tool;
273
274 #[test]
275 fn profile_names_resolve() {
276 assert_eq!(profile_from_name("full").map(|p| p.name), Some("full"));
277 assert_eq!(
278 profile_from_name("CURATED").map(|p| p.name),
279 Some("curated")
280 );
281 assert_eq!(
282 profile_from_name("minimal").map(|p| p.name),
283 Some("minimal")
284 );
285 assert!(profile_from_name("bogus").is_none());
286 }
287
288 #[test]
289 fn curated_has_no_dead_routes() {
290 assert!(
293 !PROFILE_CURATED
294 .prefixes
295 .iter()
296 .any(|p| p.starts_with("galaxy")),
297 "curated profile must not include galaxy prefixes"
298 );
299 }
300
301 #[test]
302 fn curated_is_the_product_surface() {
303 assert_eq!(
304 PROFILE_CURATED.prefixes,
305 &["memory", "session", "claims", "transaction", "gnosis"]
306 );
307 assert!(
308 !PROFILE_CURATED
309 .prefixes
310 .iter()
311 .any(|p| *p == "nlu.shadow_report" || *p == "tools.usage_report"),
312 "observability tools belong on the full surface"
313 );
314 }
315
316 #[test]
317 fn allowlist_parses_and_rejects_empty() {
318 assert!(allowlist_from_env("").is_none());
319 assert!(allowlist_from_env(" , ").is_none());
320 let profile = allowlist_from_env("memory, claims , session").unwrap();
321 assert_eq!(profile.name, "allowlist");
322 assert_eq!(profile.prefixes, &["memory", "claims", "session"]);
323 }
324
325 #[test]
326 fn full_profile_is_passthrough() {
327 let registry = ToolRegistry::new();
328 let out = apply_profile(registry, &PROFILE_FULL);
329 assert_eq!(out.len(), 0);
330 }
331
332 #[test]
333 fn resolve_profile_precedence() {
334 assert_eq!(
336 resolve_tool_profile(Some("curated"), Some("minimal"), None).name,
337 "curated"
338 );
339 assert_eq!(
341 resolve_tool_profile(None, Some("minimal"), None).name,
342 "minimal"
343 );
344 let resolved =
346 resolve_tool_profile(Some("curated"), Some("minimal"), Some("memory,session"));
347 assert_eq!(resolved.name, "allowlist");
348 assert_eq!(resolved.prefixes, &["memory", "session"]);
349 assert_eq!(resolve_tool_profile(None, None, None).name, "full");
351 assert_eq!(resolve_tool_profile(Some("bogus"), None, None).name, "full");
353 assert_eq!(resolve_tool_profile(None, Some("bogus"), None).name, "full");
354 }
355
356 struct ContractMock {
357 name: String,
358 effects: wm_core::EffectRow,
359 stats: wm_core::ToolStats,
360 }
361
362 #[async_trait::async_trait]
363 impl wm_core::Tool for ContractMock {
364 fn name(&self) -> &str {
365 &self.name
366 }
367 fn gana(&self) -> wm_core::Gana {
368 wm_core::Gana::Horn
369 }
370 fn effects(&self) -> &wm_core::EffectRow {
371 &self.effects
372 }
373 fn stats(&self) -> &wm_core::ToolStats {
374 &self.stats
375 }
376 async fn call(
377 &self,
378 _ctx: &mut wm_core::Context,
379 _args: wm_core::Args,
380 ) -> wm_core::Result<wm_core::Output> {
381 Ok(serde_json::json!({"ok": true}))
382 }
383 }
384
385 fn contract_tool(name: &str, destructive: bool) -> Arc<dyn Tool> {
386 let effects = if destructive {
387 wm_core::EffectRow {
388 destructive: true,
389 ..wm_core::EffectRow::default()
390 }
391 } else {
392 wm_core::EffectRow::default()
393 };
394 Arc::new(ContractMock {
395 name: name.into(),
396 effects,
397 stats: wm_core::ToolStats::default(),
398 })
399 }
400
401 fn contract_registry(tools: &[Arc<dyn Tool>]) -> ToolRegistry {
402 let mut builder = ToolRegistryBuilder::new();
403 for tool in tools {
404 builder.register(Arc::clone(tool));
405 }
406 builder.build()
407 }
408
409 fn minimal_registry(tools: &[Arc<dyn Tool>]) -> ToolRegistry {
412 let prefix_tools: Vec<Arc<dyn Tool>> = [
413 "memory.create",
414 "memory.read",
415 "memory.list",
416 "memory.query",
417 "memory.search",
418 "memory.chat",
419 "memory.associate",
420 "memory.associations",
421 "gnosis",
422 ]
423 .iter()
424 .map(|n| contract_tool(n, false) as Arc<dyn Tool>)
425 .collect();
426 let mut all = prefix_tools;
427 all.extend(tools.iter().cloned());
428 contract_registry(&all)
429 }
430
431 #[test]
432 fn contract_ok_when_surface_is_exact() {
433 let full = minimal_registry(&[]);
434 let filtered = contract_registry(&full.all());
435 let c = profile_contract(&full, &filtered, &PROFILE_MINIMAL);
436 assert!(c.ok);
437 assert_eq!(c.expected_count, 9);
438 assert_eq!(c.registered_count, 9);
439 assert!(c.dead_prefixes.is_empty());
440 assert!(c.unexpected_tools.is_empty());
441 }
442
443 #[test]
444 fn contract_detects_dead_prefixes_and_unexpected_tools() {
445 let alpha = contract_tool("alpha.one", false);
446 let sneaky = contract_tool("sneaky.tool", false);
447 let full = contract_registry(std::slice::from_ref(&alpha));
448 let filtered = contract_registry(&[alpha, sneaky]);
450 let c = profile_contract(
451 &full,
452 &filtered,
453 &allowlist_from_env("alpha,gamma").unwrap(),
454 );
455 assert!(!c.ok);
456 assert_eq!(c.dead_prefixes, vec!["gamma".to_string()]);
457 assert_eq!(c.unexpected_tools, vec!["sneaky.tool".to_string()]);
458 assert_eq!(c.expected_count, 1);
459 assert_eq!(c.registered_count, 2);
460 }
461
462 #[test]
463 fn contract_reports_destructive_tools_informationally() {
464 let full = minimal_registry(&[]);
465 let filtered = contract_registry(&full.all());
466 let c = profile_contract(&full, &filtered, &PROFILE_MINIMAL);
467 assert!(
468 c.ok,
469 "destructive presence is informational, not a violation"
470 );
471 assert!(c.destructive_tools.is_empty());
472
473 let curated_tools: Vec<Arc<dyn Tool>> = [
477 "memory.create",
478 "session.start",
479 "claims.list",
480 "transaction.begin",
481 "gnosis",
482 "tools.list",
483 "memory.delete",
484 "galaxy.purge",
485 ]
486 .iter()
487 .map(|n| contract_tool(n, *n == "memory.delete" || *n == "galaxy.purge") as Arc<dyn Tool>)
488 .collect();
489 let full2 = contract_registry(&curated_tools);
490 let filtered2 = apply_profile(full2.clone(), &PROFILE_CURATED);
491 let c2 = profile_contract(&full2, &filtered2, &PROFILE_CURATED);
492 assert_eq!(c2.destructive_tools, vec!["memory.delete".to_string()]);
493 assert!(c2.ok);
494 assert_eq!(c2.expected_count, 6);
495 assert_eq!(c2.registered_count, 6);
496 }
497
498 #[test]
499 fn full_profile_contract_counts_everything() {
500 let tools: Vec<Arc<dyn Tool>> = vec![
501 contract_tool("memory.create", false),
502 contract_tool("galaxy.purge", true),
503 ];
504 let full = contract_registry(&tools);
505 let filtered = contract_registry(&full.all().iter().map(Arc::clone).collect::<Vec<_>>());
506 let c = profile_contract(&full, &filtered, &PROFILE_FULL);
507 assert!(c.ok);
508 assert_eq!(c.expected_count, 2);
509 assert_eq!(c.registered_count, 2);
510 assert!(c.dead_prefixes.is_empty());
511 }
512
513 #[test]
514 fn pray_profile_is_single_surface() {
515 assert_eq!(PROFILE_PRAY.prefixes, &["whitemagic"]);
516 assert_eq!(profile_from_name("pray").unwrap().name, "pray");
517 assert_eq!(profile_from_name("prat").unwrap().name, "pray");
519 }
520}