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