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#[derive(Debug, Clone, Copy)]
83pub struct ToolPack {
84 pub name: &'static str,
86 pub description: &'static str,
88 pub prefixes: &'static [&'static str],
90}
91
92pub static PACK_CONTINUITY: ToolPack = ToolPack {
94 name: "continuity",
95 description: "capture, search, resume, replay and checkpoint sessions",
96 prefixes: &["memory", "session", "gnosis"],
97};
98
99pub static PACK_RESEARCH: ToolPack = ToolPack {
101 name: "research",
102 description: "evidence-oriented search, reads, claims and gnosis",
103 prefixes: &[
104 "memory.search",
105 "memory.read",
106 "memory.hybrid_recall",
107 "memory.query",
108 "session.continuity",
109 "session.replay",
110 "claims",
111 "gnosis",
112 ],
113};
114
115pub static PACK_CODING: ToolPack = ToolPack {
117 name: "coding",
118 description: "project memory writes, sessions and transaction snapshots",
119 prefixes: &[
120 "memory.create",
121 "memory.update",
122 "memory.read",
123 "memory.search",
124 "session",
125 "transaction",
126 ],
127};
128
129pub static PACK_OPS: ToolPack = ToolPack {
131 name: "ops",
132 description: "telemetry records/rollups/retention, breakers, continuity reads",
133 prefixes: &[
134 "telemetry",
135 "breaker",
136 "memory.search",
137 "memory.read",
138 "session.continuity",
139 ],
140};
141
142pub static PACKS: &[&ToolPack] = &[&PACK_CONTINUITY, &PACK_RESEARCH, &PACK_CODING, &PACK_OPS];
144
145#[must_use]
147pub fn pack_from_name(name: &str) -> Option<&'static ToolPack> {
148 let name = name.trim().to_ascii_lowercase();
149 PACKS.iter().copied().find(|pack| pack.name == name)
150}
151
152#[must_use]
154pub fn pack_names() -> Vec<&'static str> {
155 PACKS.iter().map(|pack| pack.name).collect()
156}
157
158#[must_use]
161pub fn profile_from_pack(pack: &ToolPack) -> &'static ToolProfile {
162 Box::leak(Box::new(ToolProfile {
163 name: Box::leak(format!("pack:{}", pack.name).into_boxed_str()),
164 prefixes: pack.prefixes,
165 }))
166}
167
168#[must_use]
177pub fn resolve_tool_surface(
178 cli_profile: Option<&str>,
179 env_profile: Option<&str>,
180 env_allowlist: Option<&str>,
181 env_pack: Option<&str>,
182) -> &'static ToolProfile {
183 if let Some(allow) = env_allowlist {
184 if let Some(profile) = allowlist_from_env(allow) {
185 tracing::info!(
186 allowlist = %allow,
187 "WM_TOOL_ALLOWLIST tool surface in effect"
188 );
189 return Box::leak(Box::new(profile));
190 }
191 }
192 if let Some(name) = env_pack {
193 if let Some(pack) = pack_from_name(name) {
194 tracing::info!(pack = pack.name, "WM_TOOL_PACK tool surface in effect");
195 return profile_from_pack(pack);
196 }
197 tracing::warn!(
198 pack = name,
199 available = ?pack_names(),
200 "unknown tool pack — falling back to profile resolution"
201 );
202 }
203 resolve_tool_profile(cli_profile, env_profile, None)
204}
205
206#[must_use]
208pub fn profile_from_name(name: &str) -> Option<&'static ToolProfile> {
209 match name.trim().to_ascii_lowercase().as_str() {
210 "full" => Some(&PROFILE_FULL),
211 "curated" => Some(&PROFILE_CURATED),
212 "minimal" => Some(&PROFILE_MINIMAL),
213 "pray" => Some(&PROFILE_PRAY),
214 "prat" => Some(&PROFILE_PRAY),
216 _ => None,
217 }
218}
219
220#[must_use]
230pub fn resolve_tool_profile(
231 cli_profile: Option<&str>,
232 env_profile: Option<&str>,
233 env_allowlist: Option<&str>,
234) -> &'static ToolProfile {
235 if let Some(allow) = env_allowlist {
236 if let Some(profile) = allowlist_from_env(allow) {
237 tracing::info!(
238 allowlist = %allow,
239 "WM_TOOL_ALLOWLIST tool surface in effect"
240 );
241 return Box::leak(Box::new(profile));
242 }
243 }
244 match cli_profile.or(env_profile) {
245 Some(name) => profile_from_name(name).unwrap_or_else(|| {
246 tracing::warn!(
247 profile = name,
248 "unknown tool surface profile — using full tool surface"
249 );
250 &PROFILE_FULL
251 }),
252 None => &PROFILE_FULL,
253 }
254}
255
256#[must_use]
259pub fn allowlist_from_env(spec: &str) -> Option<ToolProfile> {
260 let prefixes: Vec<&'static str> = spec
261 .split(',')
262 .map(str::trim)
263 .filter(|p| !p.is_empty())
264 .collect::<Vec<_>>()
265 .into_iter()
266 .map(|p| Box::leak(p.to_string().into_boxed_str()) as &'static str)
267 .collect();
268 if prefixes.is_empty() {
269 return None;
270 }
271 Some(ToolProfile {
272 name: "allowlist",
273 prefixes: Box::leak(prefixes.into_boxed_slice()),
274 })
275}
276
277#[must_use]
280pub fn apply_profile(registry: ToolRegistry, profile: &ToolProfile) -> ToolRegistry {
281 if profile.prefixes.contains(&"*") {
282 return registry;
283 }
284 let mut builder = ToolRegistryBuilder::new();
285 for tool in registry.all() {
286 if matches_prefixes(tool.name(), profile.prefixes) {
287 builder.register(tool);
288 }
289 }
290 builder.build()
291}
292
293#[must_use]
295pub fn matches_prefixes(name: &str, prefixes: &[&str]) -> bool {
296 prefixes.iter().any(|p| name.starts_with(p))
297}
298
299#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
310pub struct ProfileContract {
311 pub profile: String,
313 pub prefixes: Vec<String>,
315 pub expected_count: usize,
317 pub registered_count: usize,
319 pub dead_prefixes: Vec<String>,
321 pub unexpected_tools: Vec<String>,
323 pub destructive_tools: Vec<String>,
327 pub verified_at: String,
329 #[serde(default)]
332 pub binary_version: Option<String>,
333 #[serde(default)]
339 pub surface_hash: Option<String>,
340 pub ok: bool,
342}
343
344#[must_use]
349pub fn surface_hash(registered: &[&str]) -> String {
350 use sha2::{Digest, Sha256};
351 use std::fmt::Write as _;
352 let mut names: Vec<&str> = registered.to_vec();
353 names.sort_unstable();
354 let mut h = Sha256::new();
355 for n in names {
356 h.update(n.as_bytes());
357 h.update(b"\n");
358 }
359 h.finalize().iter().fold(
361 String::with_capacity(sha2::Sha256::output_size() * 2),
362 |mut out, b| {
363 let _ = write!(out, "{b:02x}");
364 out
365 },
366 )
367}
368
369#[must_use]
371pub fn profile_contract(
372 full: &ToolRegistry,
373 filtered: &ToolRegistry,
374 profile: &ToolProfile,
375) -> ProfileContract {
376 let full_names: Vec<&str> = full.all_ref().iter().map(|t| t.name()).collect();
377 let registered: Vec<&str> = filtered.all_ref().iter().map(|t| t.name()).collect();
378
379 let star = profile.prefixes.contains(&"*");
380 let matches = |name: &str| star || profile.prefixes.iter().any(|p| name.starts_with(p));
381 let expected_count = full_names.iter().filter(|n| matches(n)).count();
382 let unexpected_tools: Vec<String> = registered
383 .iter()
384 .filter(|n| !matches(n))
385 .map(|n| (*n).to_string())
386 .collect();
387 let dead_prefixes: Vec<String> = profile
388 .prefixes
389 .iter()
390 .filter(|p| **p != "*" && !full_names.iter().any(|n| n.starts_with(**p)))
391 .map(|p| (*p).to_string())
392 .collect();
393 let destructive_tools: Vec<String> = filtered
394 .all_ref()
395 .iter()
396 .filter(|t| t.effects().destructive)
397 .map(|t| t.name().to_string())
398 .collect();
399
400 let ok = expected_count == registered.len()
401 && unexpected_tools.is_empty()
402 && dead_prefixes.is_empty();
403
404 ProfileContract {
405 profile: profile.name.to_string(),
406 prefixes: profile.prefixes.iter().map(|p| (*p).to_string()).collect(),
407 expected_count,
408 registered_count: registered.len(),
409 dead_prefixes,
410 unexpected_tools,
411 destructive_tools,
412 verified_at: wm_core::time::now_rfc3339(),
413 binary_version: Some(env!("CARGO_PKG_VERSION").to_string()),
414 surface_hash: Some(surface_hash(®istered)),
415 ok,
416 }
417}
418
419pub fn save_contract(root: &std::path::Path, contract: &ProfileContract) {
423 let path = root.join("profile_contract.json");
424 let tmp = root.join(".profile_contract.json.tmp");
425 let write = serde_json::to_string_pretty(contract)
426 .map(|body| std::fs::write(&tmp, body).and_then(|()| std::fs::rename(&tmp, &path)));
427 if let Err(e) = write {
428 tracing::warn!(
429 path = %path.display(),
430 error = %e,
431 "could not persist profile contract"
432 );
433 }
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439 use std::sync::Arc;
440 use wm_core::Tool;
441
442 #[test]
443 fn profile_names_resolve() {
444 assert_eq!(profile_from_name("full").map(|p| p.name), Some("full"));
445 assert_eq!(
446 profile_from_name("CURATED").map(|p| p.name),
447 Some("curated")
448 );
449 assert_eq!(
450 profile_from_name("minimal").map(|p| p.name),
451 Some("minimal")
452 );
453 assert!(profile_from_name("bogus").is_none());
454 }
455
456 #[test]
457 fn curated_has_no_dead_routes() {
458 assert!(
461 !PROFILE_CURATED
462 .prefixes
463 .iter()
464 .any(|p| p.starts_with("galaxy")),
465 "curated profile must not include galaxy prefixes"
466 );
467 }
468
469 #[test]
470 fn curated_is_the_product_surface() {
471 assert_eq!(
472 PROFILE_CURATED.prefixes,
473 &["memory", "session", "claims", "transaction", "gnosis"]
474 );
475 assert!(
476 !PROFILE_CURATED
477 .prefixes
478 .iter()
479 .any(|p| *p == "nlu.shadow_report" || *p == "tools.usage_report"),
480 "observability tools belong on the full surface"
481 );
482 }
483
484 #[test]
485 fn allowlist_parses_and_rejects_empty() {
486 assert!(allowlist_from_env("").is_none());
487 assert!(allowlist_from_env(" , ").is_none());
488 let profile = allowlist_from_env("memory, claims , session").unwrap();
489 assert_eq!(profile.name, "allowlist");
490 assert_eq!(profile.prefixes, &["memory", "claims", "session"]);
491 }
492
493 #[test]
494 fn full_profile_is_passthrough() {
495 let registry = ToolRegistry::new();
496 let out = apply_profile(registry, &PROFILE_FULL);
497 assert_eq!(out.len(), 0);
498 }
499
500 #[test]
501 fn resolve_profile_precedence() {
502 assert_eq!(
504 resolve_tool_profile(Some("curated"), Some("minimal"), None).name,
505 "curated"
506 );
507 assert_eq!(
509 resolve_tool_profile(None, Some("minimal"), None).name,
510 "minimal"
511 );
512 let resolved =
514 resolve_tool_profile(Some("curated"), Some("minimal"), Some("memory,session"));
515 assert_eq!(resolved.name, "allowlist");
516 assert_eq!(resolved.prefixes, &["memory", "session"]);
517 assert_eq!(resolve_tool_profile(None, None, None).name, "full");
519 assert_eq!(resolve_tool_profile(Some("bogus"), None, None).name, "full");
521 assert_eq!(resolve_tool_profile(None, Some("bogus"), None).name, "full");
522 }
523
524 struct ContractMock {
525 name: String,
526 effects: wm_core::EffectRow,
527 stats: wm_core::ToolStats,
528 }
529
530 #[async_trait::async_trait]
531 impl wm_core::Tool for ContractMock {
532 fn name(&self) -> &str {
533 &self.name
534 }
535 fn gana(&self) -> wm_core::Gana {
536 wm_core::Gana::Horn
537 }
538 fn effects(&self) -> &wm_core::EffectRow {
539 &self.effects
540 }
541 fn stats(&self) -> &wm_core::ToolStats {
542 &self.stats
543 }
544 async fn call(
545 &self,
546 _ctx: &mut wm_core::Context,
547 _args: wm_core::Args,
548 ) -> wm_core::Result<wm_core::Output> {
549 Ok(serde_json::json!({"ok": true}))
550 }
551 }
552
553 fn contract_tool(name: &str, destructive: bool) -> Arc<dyn Tool> {
554 let effects = if destructive {
555 wm_core::EffectRow {
556 destructive: true,
557 ..wm_core::EffectRow::default()
558 }
559 } else {
560 wm_core::EffectRow::default()
561 };
562 Arc::new(ContractMock {
563 name: name.into(),
564 effects,
565 stats: wm_core::ToolStats::default(),
566 })
567 }
568
569 fn contract_registry(tools: &[Arc<dyn Tool>]) -> ToolRegistry {
570 let mut builder = ToolRegistryBuilder::new();
571 for tool in tools {
572 builder.register(Arc::clone(tool));
573 }
574 builder.build()
575 }
576
577 fn minimal_registry(tools: &[Arc<dyn Tool>]) -> ToolRegistry {
580 let prefix_tools: Vec<Arc<dyn Tool>> = [
581 "memory.create",
582 "memory.read",
583 "memory.list",
584 "memory.query",
585 "memory.search",
586 "memory.chat",
587 "memory.associate",
588 "memory.associations",
589 "gnosis",
590 ]
591 .iter()
592 .map(|n| contract_tool(n, false) as Arc<dyn Tool>)
593 .collect();
594 let mut all = prefix_tools;
595 all.extend(tools.iter().cloned());
596 contract_registry(&all)
597 }
598
599 #[test]
600 fn contract_ok_when_surface_is_exact() {
601 let full = minimal_registry(&[]);
602 let filtered = contract_registry(&full.all());
603 let c = profile_contract(&full, &filtered, &PROFILE_MINIMAL);
604 assert!(c.ok);
605 assert_eq!(c.expected_count, 9);
606 assert_eq!(c.registered_count, 9);
607 assert!(c.dead_prefixes.is_empty());
608 assert!(c.unexpected_tools.is_empty());
609 }
610
611 #[test]
612 fn contract_detects_dead_prefixes_and_unexpected_tools() {
613 let alpha = contract_tool("alpha.one", false);
614 let sneaky = contract_tool("sneaky.tool", false);
615 let full = contract_registry(std::slice::from_ref(&alpha));
616 let filtered = contract_registry(&[alpha, sneaky]);
618 let c = profile_contract(
619 &full,
620 &filtered,
621 &allowlist_from_env("alpha,gamma").unwrap(),
622 );
623 assert!(!c.ok);
624 assert_eq!(c.dead_prefixes, vec!["gamma".to_string()]);
625 assert_eq!(c.unexpected_tools, vec!["sneaky.tool".to_string()]);
626 assert_eq!(c.expected_count, 1);
627 assert_eq!(c.registered_count, 2);
628 }
629
630 #[test]
631 fn contract_reports_destructive_tools_informationally() {
632 let full = minimal_registry(&[]);
633 let filtered = contract_registry(&full.all());
634 let c = profile_contract(&full, &filtered, &PROFILE_MINIMAL);
635 assert!(
636 c.ok,
637 "destructive presence is informational, not a violation"
638 );
639 assert!(c.destructive_tools.is_empty());
640
641 let curated_tools: Vec<Arc<dyn Tool>> = [
645 "memory.create",
646 "session.start",
647 "claims.list",
648 "transaction.begin",
649 "gnosis",
650 "tools.list",
651 "memory.delete",
652 "galaxy.purge",
653 ]
654 .iter()
655 .map(|n| contract_tool(n, *n == "memory.delete" || *n == "galaxy.purge") as Arc<dyn Tool>)
656 .collect();
657 let full2 = contract_registry(&curated_tools);
658 let filtered2 = apply_profile(full2.clone(), &PROFILE_CURATED);
659 let c2 = profile_contract(&full2, &filtered2, &PROFILE_CURATED);
660 assert_eq!(c2.destructive_tools, vec!["memory.delete".to_string()]);
661 assert!(c2.ok);
662 assert_eq!(c2.expected_count, 6);
663 assert_eq!(c2.registered_count, 6);
664 }
665
666 #[test]
667 fn full_profile_contract_counts_everything() {
668 let tools: Vec<Arc<dyn Tool>> = vec![
669 contract_tool("memory.create", false),
670 contract_tool("galaxy.purge", true),
671 ];
672 let full = contract_registry(&tools);
673 let filtered = contract_registry(&full.all().iter().map(Arc::clone).collect::<Vec<_>>());
674 let c = profile_contract(&full, &filtered, &PROFILE_FULL);
675 assert!(c.ok);
676 assert_eq!(c.expected_count, 2);
677 assert_eq!(c.registered_count, 2);
678 assert!(c.dead_prefixes.is_empty());
679 }
680
681 #[test]
685 fn surface_hash_is_order_insensitive_but_content_sensitive() {
686 let a = surface_hash(&["memory.create", "session.start", "gnosis"]);
687 let b = surface_hash(&["gnosis", "memory.create", "session.start"]);
688 assert_eq!(a, b, "registration order must not move the pin");
689 assert_eq!(a.len(), 64, "hex SHA-256 shape");
690 assert_ne!(
691 a,
692 surface_hash(&["memory.create", "session.start"]),
693 "removal must repin"
694 );
695 assert_ne!(
696 a,
697 surface_hash(&["memory.create", "session.start", "gnosis", "sneaky.tool"]),
698 "addition must repin"
699 );
700 assert_ne!(
701 a,
702 surface_hash(&["memory.create", "session.start", "gnosis2"]),
703 "rename must repin"
704 );
705 }
706
707 #[test]
708 fn contract_carries_binary_identity_and_surface_pin() {
709 let full = minimal_registry(&[]);
710 let filtered = contract_registry(&full.all());
711 let c = profile_contract(&full, &filtered, &PROFILE_MINIMAL);
712 assert!(c.ok);
713 assert_eq!(
714 c.binary_version.as_deref(),
715 Some(env!("CARGO_PKG_VERSION")),
716 "contract must name the binary that produced it"
717 );
718 let names: Vec<&str> = filtered.all_ref().iter().map(|t| t.name()).collect();
719 assert_eq!(
720 c.surface_hash.as_deref(),
721 Some(surface_hash(&names).as_str()),
722 "pin must cover exactly the registered surface"
723 );
724 }
725
726 #[test]
727 fn legacy_contract_without_pin_fields_deserializes() {
728 let legacy = serde_json::json!({
731 "profile": "curated",
732 "prefixes": ["memory"],
733 "expected_count": 1,
734 "registered_count": 1,
735 "dead_prefixes": [],
736 "unexpected_tools": [],
737 "destructive_tools": [],
738 "verified_at": "2026-08-29T00:00:00Z",
739 "ok": true,
740 });
741 let c: ProfileContract = serde_json::from_value(legacy).unwrap();
742 assert!(c.ok);
743 assert_eq!(c.binary_version, None);
744 assert_eq!(c.surface_hash, None);
745 }
746
747 #[test]
748 fn pray_profile_is_single_surface() {
749 assert_eq!(PROFILE_PRAY.prefixes, &["whitemagic"]);
750 assert_eq!(profile_from_name("pray").unwrap().name, "pray");
751 assert_eq!(profile_from_name("prat").unwrap().name, "pray");
753 }
754
755 #[test]
756 fn packs_resolve_and_are_unique() {
757 assert_eq!(
758 pack_from_name("Continuity").map(|p| p.name),
759 Some("continuity")
760 );
761 assert!(pack_from_name("bogus").is_none());
762 assert_eq!(
763 pack_names(),
764 vec!["continuity", "research", "coding", "ops"]
765 );
766 for pack in PACKS {
767 assert!(!pack.prefixes.is_empty(), "{} has prefixes", pack.name);
768 assert!(
769 !pack.description.is_empty(),
770 "{} has a description",
771 pack.name
772 );
773 }
774 }
775
776 #[test]
777 fn pack_precedence_and_unknown_fallback() {
778 let allow = resolve_tool_surface(None, None, Some("memory,session"), Some("continuity"));
780 assert_eq!(allow.name, "allowlist");
781 let pack = resolve_tool_surface(Some("minimal"), Some("minimal"), None, Some("continuity"));
783 assert_eq!(pack.name, "pack:continuity");
784 assert!(pack.prefixes.contains(&"session"));
785 let fallback = resolve_tool_surface(Some("minimal"), None, None, Some("bogus"));
787 assert_eq!(fallback.name, "minimal");
788 assert_eq!(
790 resolve_tool_surface(Some("curated"), None, None, None).name,
791 "curated"
792 );
793 }
794}