Skip to main content

wm_tools/
profiles.rs

1//! Tool surface profiles — curated release surfaces for the tool registry.
2//!
3//! 229 tools is an archive, not a v1 product. Profiles curate which tools the
4//! `wm` meta-tool can route to (and which are registered at the boundary) so
5//! `wm serve --profile curated` presents the differentiated surface — the
6//! learned memory hierarchy — instead of everything.
7//!
8//! The full registry is always built first (internal subsystems like the
9//! karma ledger, friction logging and the governance pipeline need their
10//! tools regardless), then filtered before the meta-tools are layered on.
11
12#![forbid(unsafe_code)]
13
14use wm_dispatch::{ToolRegistry, ToolRegistryBuilder};
15
16/// A named tool surface. `prefixes` are tool-name prefixes; a tool matches
17/// if its name starts with any prefix. `["*"]` means no filtering.
18#[derive(Debug, Clone, Copy)]
19pub struct ToolProfile {
20    /// Profile name (`full`, `curated`, `minimal`, or `allowlist`).
21    pub name: &'static str,
22    /// Matching prefixes, or `["*"]` for the full surface.
23    pub prefixes: &'static [&'static str],
24}
25
26/// The full surface — every tool registered. Library/daemon default.
27pub static PROFILE_FULL: ToolProfile = ToolProfile {
28    name: "full",
29    prefixes: &["*"],
30};
31
32/// The memory-hierarchy product surface: memory, sessions, transactions,
33/// the claims ledger, and read-only galaxy/observability helpers.
34///
35/// `tools.list` and `gnosis` need no prefix here: `tools.list` is layered
36/// on with the meta-tools *after* filtering, and `gnosis` matches its own
37/// base tool. (The contract check flags a stale `tools.list` prefix as a
38/// dead route — that is how it was found, 2026-08-29.)
39///
40/// Deliberately excludes: destructive galaxy operations (purge/transfer/
41/// restore), the RSI friction/redteam/improve loop, sangha mesh, self-play,
42/// imagination, and polyglot/cyberbrain internals — those stay reachable
43/// under `--profile full`.
44pub static PROFILE_CURATED: ToolProfile = ToolProfile {
45    name: "curated",
46    prefixes: &["memory", "session", "claims", "transaction", "gnosis"],
47};
48
49/// The tightest surface: create/read/list/query/search/chat + discovery.
50pub 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
65/// The ultimate single-tool PRAY meta-tool surface: zero schema bloat.
66/// (PRAY = Polymorphic Resonant Adaptive Yoga; renamed from PRAT in v9.2.)
67pub static PROFILE_PRAY: ToolProfile = ToolProfile {
68    name: "pray",
69    prefixes: &["whitemagic"],
70};
71
72/// Deprecated alias kept for back-compat — renamed to [`PROFILE_PRAY`] in v9.2.
73#[deprecated(since = "9.2.0", note = "renamed to PROFILE_PRAY")]
74pub use self::PROFILE_PRAY as PROFILE_PRAT;
75
76/// Look up a profile by name (`full`, `curated`, `minimal`, `pray`; `prat` kept as a deprecated alias).
77#[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        // Deprecated alias: pre-v9.2 configs used `--profile prat`.
85        "prat" => Some(&PROFILE_PRAY),
86        _ => None,
87    }
88}
89
90/// Resolve the active tool profile with explicit precedence:
91///
92/// 1. `WM_TOOL_ALLOWLIST` — an explicit prefix allowlist always wins.
93/// 2. CLI `--profile` flag.
94/// 3. `WM_TOOL_PROFILE` environment variable.
95/// 4. Default: `full` (library / `wm daemon`). `wm serve` overlays
96///    curated when flag and env are both absent.
97///
98/// Unknown profile names log a warning and fall back to the full surface.
99#[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/// Build a profile from a comma-separated allowlist of tool-name prefixes
127/// (e.g. `memory,session,claims`). Empty segments are ignored.
128#[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/// Filter a registry to a profile. `["*"]` profiles pass the registry
148/// through untouched (zero-copy — the registry is Arc-backed).
149#[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/// Whether a tool name matches any of a profile's prefixes.
164#[must_use]
165pub fn matches_prefixes(name: &str, prefixes: &[&str]) -> bool {
166    prefixes.iter().any(|p| name.starts_with(p))
167}
168
169/// The profile contract — proof that the advertised surface is the
170/// declared surface.
171///
172/// Computed at server startup from the pre-filter ("full") registry and
173/// the post-filter (registered) registry. `ok == false` means surface
174/// drift: the boundary is advertising or routing something the declared
175/// profile does not cover, or declares prefixes that match nothing (the
176/// dead-route class the curated `galaxy.list` regression came from).
177/// Persisted as `profile_contract.json` in the store root so `wm doctor`
178/// can grade the last server start against it.
179#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
180pub struct ProfileContract {
181    /// Declared profile name (`full`, `curated`, `minimal`, `allowlist`).
182    pub profile: String,
183    /// Declared prefixes (`["*"]` for full).
184    pub prefixes: Vec<String>,
185    /// Tools the declared profile should register.
186    pub expected_count: usize,
187    /// Tools actually registered post-filter.
188    pub registered_count: usize,
189    /// Declared prefixes matching zero tools (dead routes).
190    pub dead_prefixes: Vec<String>,
191    /// Registered tools the declared profile does not cover (drift).
192    pub unexpected_tools: Vec<String>,
193    /// Destructive tools on the registered surface. Informational —
194    /// destructive effects are confirm-gated in the dispatch pipeline;
195    /// curated deliberately includes `memory.delete` and friends.
196    pub destructive_tools: Vec<String>,
197    /// RFC 3339 timestamp of the check (`wm_core::time`).
198    pub verified_at: String,
199    /// `true` iff the registered surface is exactly the declared one.
200    pub ok: bool,
201}
202
203/// Compute the profile contract for a server start.
204#[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
251/// Persist the contract to `<store-root>/profile_contract.json` (atomic
252/// rename, same discipline as the other root state files). Best-effort:
253/// a persistence failure warns and never blocks the server start.
254pub 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        // Regression: the curated profile once contained a `galaxy.list`
291        // prefix that matched no registered tool.
292        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        // CLI flag wins over the environment variable.
335        assert_eq!(
336            resolve_tool_profile(Some("curated"), Some("minimal"), None).name,
337            "curated"
338        );
339        // Environment is used when the CLI flag is absent.
340        assert_eq!(
341            resolve_tool_profile(None, Some("minimal"), None).name,
342            "minimal"
343        );
344        // An explicit allowlist wins over both.
345        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        // All absent → full surface.
350        assert_eq!(resolve_tool_profile(None, None, None).name, "full");
351        // Unknown names fall back to full.
352        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    /// Build a registry covering every `PROFILE_MINIMAL` prefix so the
410    /// dead-prefix check has nothing to flag.
411    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        // Post-filter registry carries a tool the profile does not declare.
449        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        // Curated-style surface, computed through the real filter path:
474        // memory.delete rides the `memory` prefix by design — it must be
475        // listed, and must not fail the contract; galaxy.purge must not.
476        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        // Deprecated `prat` alias keeps resolving to the same surface.
518        assert_eq!(profile_from_name("prat").unwrap().name, "pray");
519    }
520}