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 #[serde(default)]
202 pub binary_version: Option<String>,
203 #[serde(default)]
209 pub surface_hash: Option<String>,
210 pub ok: bool,
212}
213
214#[must_use]
219pub fn surface_hash(registered: &[&str]) -> String {
220 use sha2::{Digest, Sha256};
221 use std::fmt::Write as _;
222 let mut names: Vec<&str> = registered.to_vec();
223 names.sort_unstable();
224 let mut h = Sha256::new();
225 for n in names {
226 h.update(n.as_bytes());
227 h.update(b"\n");
228 }
229 h.finalize().iter().fold(
231 String::with_capacity(sha2::Sha256::output_size() * 2),
232 |mut out, b| {
233 let _ = write!(out, "{b:02x}");
234 out
235 },
236 )
237}
238
239#[must_use]
241pub fn profile_contract(
242 full: &ToolRegistry,
243 filtered: &ToolRegistry,
244 profile: &ToolProfile,
245) -> ProfileContract {
246 let full_names: Vec<&str> = full.all_ref().iter().map(|t| t.name()).collect();
247 let registered: Vec<&str> = filtered.all_ref().iter().map(|t| t.name()).collect();
248
249 let star = profile.prefixes.contains(&"*");
250 let matches = |name: &str| star || profile.prefixes.iter().any(|p| name.starts_with(p));
251 let expected_count = full_names.iter().filter(|n| matches(n)).count();
252 let unexpected_tools: Vec<String> = registered
253 .iter()
254 .filter(|n| !matches(n))
255 .map(|n| (*n).to_string())
256 .collect();
257 let dead_prefixes: Vec<String> = profile
258 .prefixes
259 .iter()
260 .filter(|p| **p != "*" && !full_names.iter().any(|n| n.starts_with(**p)))
261 .map(|p| (*p).to_string())
262 .collect();
263 let destructive_tools: Vec<String> = filtered
264 .all_ref()
265 .iter()
266 .filter(|t| t.effects().destructive)
267 .map(|t| t.name().to_string())
268 .collect();
269
270 let ok = expected_count == registered.len()
271 && unexpected_tools.is_empty()
272 && dead_prefixes.is_empty();
273
274 ProfileContract {
275 profile: profile.name.to_string(),
276 prefixes: profile.prefixes.iter().map(|p| (*p).to_string()).collect(),
277 expected_count,
278 registered_count: registered.len(),
279 dead_prefixes,
280 unexpected_tools,
281 destructive_tools,
282 verified_at: wm_core::time::now_rfc3339(),
283 binary_version: Some(env!("CARGO_PKG_VERSION").to_string()),
284 surface_hash: Some(surface_hash(®istered)),
285 ok,
286 }
287}
288
289pub fn save_contract(root: &std::path::Path, contract: &ProfileContract) {
293 let path = root.join("profile_contract.json");
294 let tmp = root.join(".profile_contract.json.tmp");
295 let write = serde_json::to_string_pretty(contract)
296 .map(|body| std::fs::write(&tmp, body).and_then(|()| std::fs::rename(&tmp, &path)));
297 if let Err(e) = write {
298 tracing::warn!(
299 path = %path.display(),
300 error = %e,
301 "could not persist profile contract"
302 );
303 }
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309 use std::sync::Arc;
310 use wm_core::Tool;
311
312 #[test]
313 fn profile_names_resolve() {
314 assert_eq!(profile_from_name("full").map(|p| p.name), Some("full"));
315 assert_eq!(
316 profile_from_name("CURATED").map(|p| p.name),
317 Some("curated")
318 );
319 assert_eq!(
320 profile_from_name("minimal").map(|p| p.name),
321 Some("minimal")
322 );
323 assert!(profile_from_name("bogus").is_none());
324 }
325
326 #[test]
327 fn curated_has_no_dead_routes() {
328 assert!(
331 !PROFILE_CURATED
332 .prefixes
333 .iter()
334 .any(|p| p.starts_with("galaxy")),
335 "curated profile must not include galaxy prefixes"
336 );
337 }
338
339 #[test]
340 fn curated_is_the_product_surface() {
341 assert_eq!(
342 PROFILE_CURATED.prefixes,
343 &["memory", "session", "claims", "transaction", "gnosis"]
344 );
345 assert!(
346 !PROFILE_CURATED
347 .prefixes
348 .iter()
349 .any(|p| *p == "nlu.shadow_report" || *p == "tools.usage_report"),
350 "observability tools belong on the full surface"
351 );
352 }
353
354 #[test]
355 fn allowlist_parses_and_rejects_empty() {
356 assert!(allowlist_from_env("").is_none());
357 assert!(allowlist_from_env(" , ").is_none());
358 let profile = allowlist_from_env("memory, claims , session").unwrap();
359 assert_eq!(profile.name, "allowlist");
360 assert_eq!(profile.prefixes, &["memory", "claims", "session"]);
361 }
362
363 #[test]
364 fn full_profile_is_passthrough() {
365 let registry = ToolRegistry::new();
366 let out = apply_profile(registry, &PROFILE_FULL);
367 assert_eq!(out.len(), 0);
368 }
369
370 #[test]
371 fn resolve_profile_precedence() {
372 assert_eq!(
374 resolve_tool_profile(Some("curated"), Some("minimal"), None).name,
375 "curated"
376 );
377 assert_eq!(
379 resolve_tool_profile(None, Some("minimal"), None).name,
380 "minimal"
381 );
382 let resolved =
384 resolve_tool_profile(Some("curated"), Some("minimal"), Some("memory,session"));
385 assert_eq!(resolved.name, "allowlist");
386 assert_eq!(resolved.prefixes, &["memory", "session"]);
387 assert_eq!(resolve_tool_profile(None, None, None).name, "full");
389 assert_eq!(resolve_tool_profile(Some("bogus"), None, None).name, "full");
391 assert_eq!(resolve_tool_profile(None, Some("bogus"), None).name, "full");
392 }
393
394 struct ContractMock {
395 name: String,
396 effects: wm_core::EffectRow,
397 stats: wm_core::ToolStats,
398 }
399
400 #[async_trait::async_trait]
401 impl wm_core::Tool for ContractMock {
402 fn name(&self) -> &str {
403 &self.name
404 }
405 fn gana(&self) -> wm_core::Gana {
406 wm_core::Gana::Horn
407 }
408 fn effects(&self) -> &wm_core::EffectRow {
409 &self.effects
410 }
411 fn stats(&self) -> &wm_core::ToolStats {
412 &self.stats
413 }
414 async fn call(
415 &self,
416 _ctx: &mut wm_core::Context,
417 _args: wm_core::Args,
418 ) -> wm_core::Result<wm_core::Output> {
419 Ok(serde_json::json!({"ok": true}))
420 }
421 }
422
423 fn contract_tool(name: &str, destructive: bool) -> Arc<dyn Tool> {
424 let effects = if destructive {
425 wm_core::EffectRow {
426 destructive: true,
427 ..wm_core::EffectRow::default()
428 }
429 } else {
430 wm_core::EffectRow::default()
431 };
432 Arc::new(ContractMock {
433 name: name.into(),
434 effects,
435 stats: wm_core::ToolStats::default(),
436 })
437 }
438
439 fn contract_registry(tools: &[Arc<dyn Tool>]) -> ToolRegistry {
440 let mut builder = ToolRegistryBuilder::new();
441 for tool in tools {
442 builder.register(Arc::clone(tool));
443 }
444 builder.build()
445 }
446
447 fn minimal_registry(tools: &[Arc<dyn Tool>]) -> ToolRegistry {
450 let prefix_tools: Vec<Arc<dyn Tool>> = [
451 "memory.create",
452 "memory.read",
453 "memory.list",
454 "memory.query",
455 "memory.search",
456 "memory.chat",
457 "memory.associate",
458 "memory.associations",
459 "gnosis",
460 ]
461 .iter()
462 .map(|n| contract_tool(n, false) as Arc<dyn Tool>)
463 .collect();
464 let mut all = prefix_tools;
465 all.extend(tools.iter().cloned());
466 contract_registry(&all)
467 }
468
469 #[test]
470 fn contract_ok_when_surface_is_exact() {
471 let full = minimal_registry(&[]);
472 let filtered = contract_registry(&full.all());
473 let c = profile_contract(&full, &filtered, &PROFILE_MINIMAL);
474 assert!(c.ok);
475 assert_eq!(c.expected_count, 9);
476 assert_eq!(c.registered_count, 9);
477 assert!(c.dead_prefixes.is_empty());
478 assert!(c.unexpected_tools.is_empty());
479 }
480
481 #[test]
482 fn contract_detects_dead_prefixes_and_unexpected_tools() {
483 let alpha = contract_tool("alpha.one", false);
484 let sneaky = contract_tool("sneaky.tool", false);
485 let full = contract_registry(std::slice::from_ref(&alpha));
486 let filtered = contract_registry(&[alpha, sneaky]);
488 let c = profile_contract(
489 &full,
490 &filtered,
491 &allowlist_from_env("alpha,gamma").unwrap(),
492 );
493 assert!(!c.ok);
494 assert_eq!(c.dead_prefixes, vec!["gamma".to_string()]);
495 assert_eq!(c.unexpected_tools, vec!["sneaky.tool".to_string()]);
496 assert_eq!(c.expected_count, 1);
497 assert_eq!(c.registered_count, 2);
498 }
499
500 #[test]
501 fn contract_reports_destructive_tools_informationally() {
502 let full = minimal_registry(&[]);
503 let filtered = contract_registry(&full.all());
504 let c = profile_contract(&full, &filtered, &PROFILE_MINIMAL);
505 assert!(
506 c.ok,
507 "destructive presence is informational, not a violation"
508 );
509 assert!(c.destructive_tools.is_empty());
510
511 let curated_tools: Vec<Arc<dyn Tool>> = [
515 "memory.create",
516 "session.start",
517 "claims.list",
518 "transaction.begin",
519 "gnosis",
520 "tools.list",
521 "memory.delete",
522 "galaxy.purge",
523 ]
524 .iter()
525 .map(|n| contract_tool(n, *n == "memory.delete" || *n == "galaxy.purge") as Arc<dyn Tool>)
526 .collect();
527 let full2 = contract_registry(&curated_tools);
528 let filtered2 = apply_profile(full2.clone(), &PROFILE_CURATED);
529 let c2 = profile_contract(&full2, &filtered2, &PROFILE_CURATED);
530 assert_eq!(c2.destructive_tools, vec!["memory.delete".to_string()]);
531 assert!(c2.ok);
532 assert_eq!(c2.expected_count, 6);
533 assert_eq!(c2.registered_count, 6);
534 }
535
536 #[test]
537 fn full_profile_contract_counts_everything() {
538 let tools: Vec<Arc<dyn Tool>> = vec![
539 contract_tool("memory.create", false),
540 contract_tool("galaxy.purge", true),
541 ];
542 let full = contract_registry(&tools);
543 let filtered = contract_registry(&full.all().iter().map(Arc::clone).collect::<Vec<_>>());
544 let c = profile_contract(&full, &filtered, &PROFILE_FULL);
545 assert!(c.ok);
546 assert_eq!(c.expected_count, 2);
547 assert_eq!(c.registered_count, 2);
548 assert!(c.dead_prefixes.is_empty());
549 }
550
551 #[test]
555 fn surface_hash_is_order_insensitive_but_content_sensitive() {
556 let a = surface_hash(&["memory.create", "session.start", "gnosis"]);
557 let b = surface_hash(&["gnosis", "memory.create", "session.start"]);
558 assert_eq!(a, b, "registration order must not move the pin");
559 assert_eq!(a.len(), 64, "hex SHA-256 shape");
560 assert_ne!(
561 a,
562 surface_hash(&["memory.create", "session.start"]),
563 "removal must repin"
564 );
565 assert_ne!(
566 a,
567 surface_hash(&["memory.create", "session.start", "gnosis", "sneaky.tool"]),
568 "addition must repin"
569 );
570 assert_ne!(
571 a,
572 surface_hash(&["memory.create", "session.start", "gnosis2"]),
573 "rename must repin"
574 );
575 }
576
577 #[test]
578 fn contract_carries_binary_identity_and_surface_pin() {
579 let full = minimal_registry(&[]);
580 let filtered = contract_registry(&full.all());
581 let c = profile_contract(&full, &filtered, &PROFILE_MINIMAL);
582 assert!(c.ok);
583 assert_eq!(
584 c.binary_version.as_deref(),
585 Some(env!("CARGO_PKG_VERSION")),
586 "contract must name the binary that produced it"
587 );
588 let names: Vec<&str> = filtered.all_ref().iter().map(|t| t.name()).collect();
589 assert_eq!(
590 c.surface_hash.as_deref(),
591 Some(surface_hash(&names).as_str()),
592 "pin must cover exactly the registered surface"
593 );
594 }
595
596 #[test]
597 fn legacy_contract_without_pin_fields_deserializes() {
598 let legacy = serde_json::json!({
601 "profile": "curated",
602 "prefixes": ["memory"],
603 "expected_count": 1,
604 "registered_count": 1,
605 "dead_prefixes": [],
606 "unexpected_tools": [],
607 "destructive_tools": [],
608 "verified_at": "2026-08-29T00:00:00Z",
609 "ok": true,
610 });
611 let c: ProfileContract = serde_json::from_value(legacy).unwrap();
612 assert!(c.ok);
613 assert_eq!(c.binary_version, None);
614 assert_eq!(c.surface_hash, None);
615 }
616
617 #[test]
618 fn pray_profile_is_single_surface() {
619 assert_eq!(PROFILE_PRAY.prefixes, &["whitemagic"]);
620 assert_eq!(profile_from_name("pray").unwrap().name, "pray");
621 assert_eq!(profile_from_name("prat").unwrap().name, "pray");
623 }
624}