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    /// Package version of the binary that computed the contract.
200    /// `None` = pre-P-PROV-5 contract (serde default keeps them readable).
201    #[serde(default)]
202    pub binary_version: Option<String>,
203    /// Surface pin (P-PROV-5, 2026-09-10, rug-pull tripwire): hex SHA-256
204    /// over the sorted registered tool names (see [`surface_hash`]). Any
205    /// tool added, removed, or renamed changes the pin; `wm doctor`
206    /// discloses it so a reviewed release surface can be pinned externally.
207    /// `None` = pre-P-PROV-5 contract.
208    #[serde(default)]
209    pub surface_hash: Option<String>,
210    /// `true` iff the registered surface is exactly the declared one.
211    pub ok: bool,
212}
213
214/// Compute the surface pin: hex SHA-256 over sorted tool names.
215///
216/// Order-insensitive, content-sensitive — the same surface always pins
217/// identically regardless of registration order.
218#[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    // Byte-wise hex (sha2 0.11's digest output has no LowerHex impl).
230    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/// Compute the profile contract for a server start.
240#[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(&registered)),
285        ok,
286    }
287}
288
289/// Persist the contract to `<store-root>/profile_contract.json` (atomic
290/// rename, same discipline as the other root state files). Best-effort:
291/// a persistence failure warns and never blocks the server start.
292pub 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        // Regression: the curated profile once contained a `galaxy.list`
329        // prefix that matched no registered tool.
330        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        // CLI flag wins over the environment variable.
373        assert_eq!(
374            resolve_tool_profile(Some("curated"), Some("minimal"), None).name,
375            "curated"
376        );
377        // Environment is used when the CLI flag is absent.
378        assert_eq!(
379            resolve_tool_profile(None, Some("minimal"), None).name,
380            "minimal"
381        );
382        // An explicit allowlist wins over both.
383        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        // All absent → full surface.
388        assert_eq!(resolve_tool_profile(None, None, None).name, "full");
389        // Unknown names fall back to full.
390        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    /// Build a registry covering every `PROFILE_MINIMAL` prefix so the
448    /// dead-prefix check has nothing to flag.
449    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        // Post-filter registry carries a tool the profile does not declare.
487        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        // Curated-style surface, computed through the real filter path:
512        // memory.delete rides the `memory` prefix by design — it must be
513        // listed, and must not fail the contract; galaxy.purge must not.
514        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    // P-PROV-5 (2026-09-10, rug-pull tripwire): the surface pin must be
552    // order-insensitive (registration order is not surface content) and
553    // content-sensitive (any add/remove/rename repins).
554    #[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        // Pre-P-PROV-5 contracts have neither pin field; serde defaults
599        // must admit them (store roots hold live history).
600        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        // Deprecated `prat` alias keeps resolving to the same surface.
622        assert_eq!(profile_from_name("prat").unwrap().name, "pray");
623    }
624}