Skip to main content

submilli_engine/stdlib/
capabilities.rs

1//! Catalog of the semantic-security capabilities the stdlib host functions
2//! gate via `check_security`. This is the single source of truth behind
3//! `submilli blueprint init`, the `submilli blueprint capability` verbs, and
4//! the server's `GET /v1/capabilities`, so an operator sees every capability
5//! the runtime can gate.
6//!
7//! **Keep in sync with the host functions.** When a host fn in `stdlib::fs` /
8//! `stdlib::http` (or a new gated module) starts or stops gating a capability,
9//! update the matching entry here — see AGENTS.md. The `capabilities` test in
10//! the `submilli` CLI asserts every `example_filter` below parses.
11
12use super::{OptionalPackage, Stdlib};
13
14/// One context field a capability's `filter:` expression can match on.
15pub struct FilterField {
16    pub name: &'static str,
17    /// The operand's shape in a filter comparison: `string`, `number`, or
18    /// `boolean`.
19    pub ty: &'static str,
20    /// One line on what the field carries.
21    pub doc: &'static str,
22    /// How the runtime rewrites the value before the policy sees it.
23    pub normalization: FieldNormalization,
24}
25
26/// A rewrite the runtime applies to a field's value before checking it. Call-site
27/// derivation applies the same rewrite to a literal argument, so the filter it
28/// writes into `requires` names the value the policy is asked about.
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub enum FieldNormalization {
31    /// Checked exactly as the caller passed it.
32    Verbatim,
33    /// Absolute guest path with `.` and `..` collapsed, as VFS I/O resolves it.
34    VfsPath,
35    /// The serialized form of the parsed HTTPS repository URL: lowercase host,
36    /// no default port, `/` for an empty path, percent-encoded.
37    RepositoryUrl,
38    /// ASCII letters uppercased, as an HTTP method is checked.
39    AsciiUppercase,
40}
41
42impl FieldNormalization {
43    /// The value the runtime checks for `value`, or why the runtime refuses it.
44    pub(crate) fn apply(self, value: &str) -> Result<String, String> {
45        match self {
46            Self::Verbatim => Ok(value.to_string()),
47            Self::VfsPath => {
48                crate::runtime::fs::guest_normalize("/", value).map_err(|error| error.to_string())
49            }
50            Self::RepositoryUrl => {
51                crate::stdlib::git::canonical_url(value).map_err(|error| error.to_string())
52            }
53            Self::AsciiUppercase => Ok(value.to_ascii_uppercase()),
54        }
55    }
56}
57
58const fn field(name: &'static str, ty: &'static str, doc: &'static str) -> FilterField {
59    FilterField {
60        name,
61        ty,
62        doc,
63        normalization: FieldNormalization::Verbatim,
64    }
65}
66
67const fn vfs_path_field(name: &'static str, doc: &'static str) -> FilterField {
68    normalized_string_field(name, doc, FieldNormalization::VfsPath)
69}
70
71const fn normalized_string_field(
72    name: &'static str,
73    doc: &'static str,
74    normalization: FieldNormalization,
75) -> FilterField {
76    FilterField {
77        name,
78        ty: "string",
79        doc,
80        normalization,
81    }
82}
83
84/// One gated capability and the policy `filter:` surface it exposes.
85pub struct Capability {
86    /// The name passed to `check()` — exactly what a policy rule's
87    /// `capability:` must equal (capability names are matched verbatim).
88    pub name: &'static str,
89    /// Why the runtime refuses this capability to `main` outright, ahead of the
90    /// policy engine — `None` for an ordinary capability a rule can grant to any
91    /// caller. A rule granting a marked capability to `main` is dead, so the
92    /// surfaces that scaffold, add, and lint rules read this one fact rather
93    /// than each restating the carve-out, and each shows the reason verbatim.
94    pub main_denial: Option<&'static str>,
95    /// One-line description of what gating the capability controls.
96    pub summary: &'static str,
97    /// Context fields a `filter:` expression can match on, as supplied by a
98    /// representative call. Documents what a rule can constrain.
99    pub filter_fields: &'static [FilterField],
100    /// A ready-to-uncomment example `filter:` expression for the scaffold.
101    pub example_filter: &'static str,
102}
103
104impl Capability {
105    /// Whether the name is a template (`mcp.<server>`) rather than a concrete
106    /// name a policy rule can use verbatim.
107    pub fn is_template(&self) -> bool {
108        self.name.contains('<')
109    }
110
111    /// Whether a policy rule granting this capability to `main` can take effect.
112    pub fn grantable_to_main(&self) -> bool {
113        self.main_denial.is_none()
114    }
115
116    /// The filter fields' names, for surfaces that don't show types.
117    pub fn field_names(&self) -> impl Iterator<Item = &'static str> {
118        self.filter_fields.iter().map(|f| f.name)
119    }
120}
121
122/// A group of capabilities sharing a source module, for scaffold sectioning.
123pub struct CapabilityGroup {
124    /// The owning stdlib module, e.g. `submilli:fs`.
125    pub module: &'static str,
126    pub capabilities: &'static [Capability],
127}
128
129const PATH: FilterField = vfs_path_field("path", "Normalized absolute VFS path the call targets");
130const RECURSIVE: FilterField = field(
131    "recursive",
132    "boolean",
133    "Whether the operation applies recursively",
134);
135const FROM: FilterField = vfs_path_field("from", "Normalized absolute source VFS path");
136const TO: FilterField = vfs_path_field("to", "Normalized absolute destination VFS path");
137/// The code module checks workspace access as recursive.
138const CODE_RECURSIVE: FilterField = field(
139    "recursive",
140    "boolean",
141    "Always true; supplied by the code module only",
142);
143const KEY: FilterField = field("key", "string", "Session key the call targets");
144const PREFIX: FilterField = field("prefix", "string", "Session key prefix being listed");
145
146const FS: &[Capability] = &[
147    Capability {
148        name: "fs.read",
149        main_denial: None,
150        summary: "Read files and code workspace content (including search and ignore rules)",
151        filter_fields: &[
152            PATH,
153            field(
154                "length",
155                "number",
156                "Bytes requested; supplied by `readBytes` only",
157            ),
158            field(
159                "chunkSize",
160                "number",
161                "Chunk size in bytes; supplied by `bytes` only",
162            ),
163            CODE_RECURSIVE,
164        ],
165        example_filter: "path glob \"*.csv\"",
166    },
167    Capability {
168        name: "fs.write",
169        main_denial: None,
170        summary: "Create, write, append, or apply code edits to files",
171        filter_fields: &[
172            PATH,
173            field(
174                "length",
175                "number",
176                "Content size in bytes; supplied by `write`, `writeText`, `append`, \
177                 `appendText`, and code edits",
178            ),
179            field(
180                "max_bytes",
181                "number",
182                "Requested download size cap in bytes; supplied by `http.download` and packages that stream downloads",
183            ),
184            field(
185                "diff",
186                "string",
187                "Unified diff of the edit; supplied by code edits only",
188            ),
189        ],
190        example_filter: "path glob \"/out/*\"",
191    },
192    Capability {
193        name: "fs.stat",
194        main_denial: None,
195        summary: "Inspect metadata (including code workspace discovery)",
196        filter_fields: &[PATH, CODE_RECURSIVE],
197        example_filter: "path glob \"/data/*\"",
198    },
199    Capability {
200        name: "fs.list",
201        main_denial: None,
202        summary: "List directory entries (including code search, glob and tree)",
203        filter_fields: &[PATH, RECURSIVE],
204        example_filter: "path glob \"/data/*\"",
205    },
206    Capability {
207        name: "fs.mkdir",
208        main_denial: None,
209        summary: "Create directories",
210        filter_fields: &[PATH, RECURSIVE],
211        example_filter: "path glob \"/tmp/*\"",
212    },
213    Capability {
214        name: "fs.remove",
215        main_denial: None,
216        summary: "Delete files or directories",
217        filter_fields: &[PATH, RECURSIVE],
218        example_filter: "path glob \"/tmp/*\"",
219    },
220    Capability {
221        name: "fs.move",
222        main_denial: None,
223        summary: "Move or rename a path",
224        filter_fields: &[FROM, TO],
225        example_filter: "to glob \"/archive/*\"",
226    },
227    Capability {
228        name: "fs.copy",
229        main_denial: None,
230        summary: "Copy a path",
231        filter_fields: &[FROM, TO, RECURSIVE],
232        example_filter: "to glob \"/backup/*\"",
233    },
234];
235
236const BRANCH: FilterField = field("branch", "string", "Local or requested remote branch name");
237const REMOTE: FilterField = normalized_string_field(
238    "remote",
239    "Canonical HTTPS repository URL",
240    FieldNormalization::RepositoryUrl,
241);
242const REMOTE_NAME: FilterField = field("remoteName", "string", "Named remote, for example origin");
243const GIT: &[Capability] = &[
244    Capability {
245        name: "git.init",
246        main_denial: None,
247        summary: "Create a local repository and its VFS directory",
248        filter_fields: &[PATH],
249        example_filter: "path == \"/repo\"",
250    },
251    Capability {
252        name: "git.clone",
253        main_denial: None,
254        summary: "Clone an HTTPS repository into a VFS directory",
255        filter_fields: &[PATH, REMOTE_NAME, REMOTE, BRANCH],
256        example_filter: "path == \"/repo\" and remote == \"https://github.com/pied-piper/project.git\"",
257    },
258    Capability {
259        name: "git.fetch",
260        main_denial: None,
261        summary: "Fetch or pull HTTPS remote branches into an existing repository",
262        filter_fields: &[PATH, REMOTE_NAME, REMOTE, BRANCH],
263        example_filter: "path == \"/repo\" and remote == \"https://github.com/pied-piper/project.git\"",
264    },
265    Capability {
266        name: "git.commit",
267        main_denial: None,
268        summary: "Commit staged changes with Blueprint identity",
269        filter_fields: &[PATH, BRANCH],
270        example_filter: "path == \"/repo\" and branch == \"main\"",
271    },
272];
273
274const HTTP_VERB_FIELDS: &[FilterField] = &[
275    field("host", "string", "Destination host, without port"),
276    field("path", "string", "URL path component"),
277    field("body_size", "number", "Request body size in bytes"),
278    field("timeout_ms", "number", "Request timeout in milliseconds"),
279];
280
281const HTTP: &[Capability] = &[
282    Capability {
283        name: "http.get",
284        main_denial: None,
285        summary: "HTTP GET",
286        filter_fields: HTTP_VERB_FIELDS,
287        example_filter: "host == \"api.example.com\"",
288    },
289    Capability {
290        name: "http.post",
291        main_denial: None,
292        summary: "HTTP POST",
293        filter_fields: HTTP_VERB_FIELDS,
294        example_filter: "host == \"api.example.com\"",
295    },
296    Capability {
297        name: "http.put",
298        main_denial: None,
299        summary: "HTTP PUT",
300        filter_fields: HTTP_VERB_FIELDS,
301        example_filter: "host == \"api.example.com\"",
302    },
303    Capability {
304        name: "http.patch",
305        main_denial: None,
306        summary: "HTTP PATCH",
307        filter_fields: HTTP_VERB_FIELDS,
308        example_filter: "host == \"api.example.com\"",
309    },
310    Capability {
311        name: "http.delete",
312        main_denial: None,
313        summary: "HTTP DELETE",
314        filter_fields: HTTP_VERB_FIELDS,
315        example_filter: "host == \"api.example.com\"",
316    },
317    Capability {
318        name: "http.head",
319        main_denial: None,
320        summary: "HTTP HEAD",
321        filter_fields: HTTP_VERB_FIELDS,
322        example_filter: "host == \"api.example.com\"",
323    },
324    Capability {
325        name: "http.options",
326        main_denial: None,
327        summary: "HTTP OPTIONS",
328        filter_fields: HTTP_VERB_FIELDS,
329        example_filter: "host == \"api.example.com\"",
330    },
331    Capability {
332        name: "http.request",
333        main_denial: None,
334        summary: "HTTP request with a method chosen at the call, through `http.request`. \
335                  The per-method functions (`http.get`, …) check their own capability instead",
336        filter_fields: &[
337            normalized_string_field(
338                "method",
339                "HTTP method, uppercased (`GET`, `TRACE`, …)",
340                FieldNormalization::AsciiUppercase,
341            ),
342            field("host", "string", "Destination host, without port"),
343            field("path", "string", "URL path component"),
344            field("body_size", "number", "Request body size in bytes"),
345            field("timeout_ms", "number", "Request timeout in milliseconds"),
346        ],
347        example_filter: "method == \"GET\" and host == \"api.example.com\"",
348    },
349    Capability {
350        name: "http.download",
351        main_denial: None,
352        summary: "Download a URL straight to the VFS",
353        filter_fields: &[
354            field("host", "string", "Download host, without port"),
355            field("url_path", "string", "URL path component"),
356            vfs_path_field(
357                "vfs_path",
358                "Normalized absolute destination path in the VFS",
359            ),
360            field(
361                "max_bytes",
362                "number",
363                "Requested download size cap in bytes",
364            ),
365            field(
366                "overwrite",
367                "boolean",
368                "Whether an existing file may be clobbered",
369            ),
370            field(
371                "decompress",
372                "boolean",
373                "Whether the response is decompressed on write",
374            ),
375        ],
376        example_filter: "host == \"cdn.example.com\" and overwrite == false",
377    },
378];
379
380/// Outbound MCP calls — one capability per declared server, `mcp.<server>` (the
381/// `<server>` placeholder is filled in per blueprint, whose `mcp:` block names its
382/// servers). The tool being called is in the filter context, so a rule can allow a
383/// server broadly or constrain it to specific tools.
384const MCP: &[Capability] = &[Capability {
385    name: "mcp.<server>",
386    main_denial: None,
387    summary: "Call tools on a declared outbound MCP server (streamable_http)",
388    filter_fields: &[
389        field("tool", "string", "Name of the MCP tool being called"),
390        field(
391            "transport",
392            "string",
393            "MCP transport; always \"streamable_http\" today",
394        ),
395    ],
396    example_filter: "tool == \"create_issue\"",
397}];
398
399/// The session's application tools, `@tools`: the harness that opened the
400/// session declares them and answers each call. The tool being called is in the
401/// filter context. Deny by default: the harness decides what it exposes, the
402/// blueprint decides what programs may call.
403const TOOLS: &[Capability] = &[Capability {
404    name: "tools.call",
405    main_denial: None,
406    summary: "Call an application tool the harness declared for the session (`@tools`). \
407              The harness answers each call, and may refuse it",
408    filter_fields: &[field(
409        "tool",
410        "string",
411        "Name of the application tool being called",
412    )],
413    example_filter: "tool == \"query_orders\"",
414}];
415
416const EMBEDDING: &[Capability] = &[Capability {
417    name: "embedding.embed",
418    // No `main_denial`: main-module access is the point. The program names an
419    // alias and supplies text; the credential is resolved inside the provider
420    // and never returned.
421    main_denial: None,
422    summary: "Embed text through a declared embedding alias (embed) and enumerate the aliases \
423              it may use (models). Narrowing `model` also narrows what `models()` reveals: \
424              every candidate is filtered through this same rule, so a listing never offers \
425              an alias the caller would be denied at call time. A policy allowing no \
426              candidates returns an empty listing",
427    filter_fields: &[
428        field(
429            "model",
430            "string",
431            "Alias the call targets or the candidate being listed. The runtime preflight \
432             does not ask policy about an empty name",
433        ),
434        field(
435            "input_count",
436            "number",
437            "Texts in this call — N for embed, 0 for models. Input text is never in this \
438             context",
439        ),
440    ],
441    example_filter: "model glob \"memory-*\"",
442}];
443
444const LLM: &[Capability] = &[Capability {
445    name: "llm.call",
446    // No `main_denial`: main-module access is the point. A model call is not a
447    // secret read — the program names a model and supplies a prompt, and the
448    // credential is resolved inside the provider and never returned.
449    main_denial: None,
450    summary: "Call a model (call, batch) and enumerate the models it may call (models). \
451              Narrowing `model` also narrows what `models()` reveals: every candidate is \
452              filtered through this same rule, so a listing never offers a model the \
453              caller would be denied at call time. A policy allowing no candidates \
454              returns an empty listing",
455    filter_fields: &[
456        field(
457            "model",
458            "string",
459            "Model name the call targets or the candidate being listed. \
460             The runtime preflight does not ask policy about an empty name",
461        ),
462        field(
463            "prompt_count",
464            "number",
465            "Prompts in this dispatch — 1 for call, N for batch, 0 for models. Prompt text \
466             is never in this context",
467        ),
468    ],
469    example_filter: "model glob \"claude-*\"",
470}];
471
472const SECRETS: &[Capability] = &[Capability {
473    name: "secrets.get",
474    main_denial: Some(
475        "secret values are never available to main-module code, and no policy can \
476         grant this. The package that needs this credential resolves it internally \
477         and never returns it — pass the secret NAME to that package's API instead",
478    ),
479    summary: "Read a Blueprint-declared secret value",
480    filter_fields: &[field("name", "string", "Declared secret name being read")],
481    example_filter: "name == \"STRIPE_API_KEY\"",
482}];
483
484const VARIABLES: &[Capability] = &[Capability {
485    name: "variables.get",
486    main_denial: Some(
487        "configuration variables are available only to packages; no policy can grant main-module access",
488    ),
489    summary: "Read an immutable Blueprint/session configuration variable",
490    filter_fields: &[field("name", "string", "Variable name being read")],
491    example_filter: "name == \"BROWSER_STORE_NAME\"",
492}];
493
494const SESSION: &[Capability] = &[
495    Capability {
496        name: "session.finalize",
497        main_denial: Some(
498            "finalizer registrations belong to packages; main-module access cannot be granted",
499        ),
500        summary: "Register or unregister durable package-owned session cleanup",
501        filter_fields: &[KEY],
502        example_filter: "key glob \"browser/*\"",
503    },
504    Capability {
505        name: "session.read",
506        main_denial: None,
507        summary: "Read session state (get, has), and decide which keys a list may reveal",
508        filter_fields: &[KEY],
509        example_filter: "key glob \"triage/*\"",
510    },
511    Capability {
512        name: "session.write",
513        main_denial: None,
514        summary: "Store or overwrite a session value (set)",
515        filter_fields: &[KEY],
516        example_filter: "key glob \"triage/*\"",
517    },
518    Capability {
519        name: "session.remove",
520        main_denial: None,
521        summary: "Delete a session key",
522        filter_fields: &[KEY],
523        example_filter: "key glob \"triage/*\"",
524    },
525    Capability {
526        name: "session.list",
527        main_denial: None,
528        summary: "Enumerate session keys under a prefix",
529        filter_fields: &[PREFIX],
530        example_filter: "prefix == \"triage/\"",
531    },
532];
533
534/// The capabilities of the core packages, which every embedder has.
535const CORE_CATALOG: &[CapabilityGroup] = &[
536    CapabilityGroup {
537        module: "submilli:embedding",
538        capabilities: EMBEDDING,
539    },
540    CapabilityGroup {
541        module: "submilli:fs",
542        capabilities: FS,
543    },
544    CapabilityGroup {
545        module: "submilli:git",
546        capabilities: GIT,
547    },
548    CapabilityGroup {
549        module: "submilli:http",
550        capabilities: HTTP,
551    },
552    CapabilityGroup {
553        module: "submilli:llm",
554        capabilities: LLM,
555    },
556    CapabilityGroup {
557        module: "@mcp",
558        capabilities: MCP,
559    },
560    CapabilityGroup {
561        module: "submilli:secrets",
562        capabilities: SECRETS,
563    },
564    CapabilityGroup {
565        module: "submilli:session",
566        capabilities: SESSION,
567    },
568    CapabilityGroup {
569        module: "@tools",
570        capabilities: TOOLS,
571    },
572    CapabilityGroup {
573        module: "submilli:variables",
574        capabilities: VARIABLES,
575    },
576];
577
578const AGENT: FilterField = field(
579    "agent",
580    "string",
581    "The sub-agent's name, as `submilli:agents` list() names it",
582);
583
584const AGENTS: CapabilityGroup = CapabilityGroup {
585    module: "submilli:agents",
586    capabilities: &[Capability {
587        name: "agent.run",
588        main_denial: None,
589        summary: "Hand work to a harness sub-agent, or list the agents",
590        filter_fields: &[AGENT],
591        example_filter: "agent == \"researcher\"",
592    }],
593};
594
595const SKILL: FilterField = field(
596    "name",
597    "string",
598    "The skill's name, as `submilli:skills` list() names it",
599);
600
601const SKILLS: CapabilityGroup = CapabilityGroup {
602    module: "submilli:skills",
603    capabilities: &[Capability {
604        name: "skill.load",
605        main_denial: None,
606        summary: "Load a harness skill, read its files, or list the skills",
607        filter_fields: &[SKILL],
608        example_filter: "name == \"code-review\"",
609    }],
610};
611
612/// The capabilities of one optional package.
613fn optional_group(package: OptionalPackage) -> &'static CapabilityGroup {
614    match package {
615        OptionalPackage::Agents => &AGENTS,
616        OptionalPackage::Skills => &SKILLS,
617    }
618}
619
620/// Every capability the core packages gate, grouped by source module. An
621/// optional package's capabilities are listed only by [`catalog_for`].
622pub fn catalog() -> &'static [CapabilityGroup] {
623    CORE_CATALOG
624}
625
626/// Every capability the packages of `stdlib` gate, grouped by source module,
627/// in [`catalog`]'s order: by module name without its `submilli:` or `@` prefix.
628pub fn catalog_for(stdlib: Stdlib) -> Vec<&'static CapabilityGroup> {
629    let mut groups: Vec<&'static CapabilityGroup> = CORE_CATALOG.iter().collect();
630    groups.extend(stdlib.optional_packages().map(optional_group));
631    groups.sort_by_key(|group| catalog_order(group.module));
632    groups
633}
634
635fn catalog_order(module: &str) -> &str {
636    module
637        .strip_prefix("submilli:")
638        .or_else(|| module.strip_prefix('@'))
639        .unwrap_or(module)
640}
641
642/// Every group the runtime can gate, whichever packages an embedder enables.
643/// Runtime checks use this: a capability reaches them only from a host
644/// function, which is installed only when its package is enabled.
645pub(crate) fn all_groups() -> impl Iterator<Item = &'static CapabilityGroup> {
646    CORE_CATALOG.iter().chain(
647        OptionalPackage::ALL
648            .iter()
649            .map(|package| optional_group(*package)),
650    )
651}
652
653/// Look up a core capability by its exact name (templates included, by their
654/// literal `mcp.<server>` spelling).
655pub fn find(name: &str) -> Option<&'static Capability> {
656    find_for(Stdlib::core(), name)
657}
658
659/// [`find`] among the capabilities of `stdlib`.
660pub fn find_for(stdlib: Stdlib, name: &str) -> Option<&'static Capability> {
661    catalog_for(stdlib)
662        .into_iter()
663        .flat_map(|group| group.capabilities)
664        .find(|cap| cap.name == name)
665}
666
667/// [`find`] among every group the runtime can gate.
668pub fn find_any(name: &str) -> Option<&'static Capability> {
669    all_groups()
670        .flat_map(|group| group.capabilities)
671        .find(|cap| cap.name == name)
672}
673
674#[cfg(test)]
675mod tests {
676    use super::*;
677    use std::collections::HashSet;
678
679    /// Marking a capability not-grantable-to-`main` changes the runtime's
680    /// answer for every caller, so growing the set is a deliberate act.
681    #[test]
682    fn package_configuration_and_secrets_are_refused_to_main() {
683        let refused: Vec<&str> = all_groups()
684            .flat_map(|group| group.capabilities)
685            .filter(|cap| !cap.grantable_to_main())
686            .map(|cap| cap.name)
687            .collect();
688        assert_eq!(
689            refused,
690            ["secrets.get", "session.finalize", "variables.get"]
691        );
692    }
693
694    /// The core set lists exactly the core catalog, in the same order, so every
695    /// surface that reads `catalog()` agrees with one that reads `catalog_for`.
696    #[test]
697    fn core_set_lists_the_core_catalog() {
698        let core: Vec<&str> = catalog().iter().map(|group| group.module).collect();
699        let for_core: Vec<&str> = catalog_for(Stdlib::core())
700            .iter()
701            .map(|group| group.module)
702            .collect();
703        assert_eq!(for_core, core);
704    }
705
706    #[test]
707    fn catalog_entries_are_well_formed() {
708        let mut seen = HashSet::new();
709        for group in all_groups() {
710            for cap in group.capabilities {
711                assert!(
712                    cap.name.contains('.') && !cap.name.is_empty(),
713                    "capability name '{}' must be `module.action`",
714                    cap.name
715                );
716                assert!(seen.insert(cap.name), "duplicate capability '{}'", cap.name);
717                assert!(!cap.summary.is_empty(), "{}: empty summary", cap.name);
718                // `filter_fields` may be empty for a capability gated by name alone
719                // (e.g. the `@mcp` family, whose name already pins server + tool).
720                assert!(
721                    !cap.example_filter.is_empty(),
722                    "{}: empty example_filter",
723                    cap.name
724                );
725                assert!(
726                    cap.main_denial.is_none_or(|reason| !reason.is_empty()),
727                    "{}: empty main_denial reason",
728                    cap.name
729                );
730                for f in cap.filter_fields {
731                    assert!(!f.name.is_empty(), "{}: unnamed filter field", cap.name);
732                    assert!(
733                        matches!(f.ty, "string" | "number" | "boolean"),
734                        "{}.{}: unknown field type '{}'",
735                        cap.name,
736                        f.name,
737                        f.ty
738                    );
739                    assert!(!f.doc.is_empty(), "{}.{}: empty doc", cap.name, f.name);
740                }
741            }
742        }
743    }
744
745    #[test]
746    fn session_group_is_cataloged() {
747        let group = catalog()
748            .iter()
749            .find(|g| g.module == "submilli:session")
750            .expect("submilli:session group");
751        let names: Vec<&str> = group.capabilities.iter().map(|cap| cap.name).collect();
752        assert_eq!(
753            names,
754            [
755                "session.finalize",
756                "session.read",
757                "session.write",
758                "session.remove",
759                "session.list"
760            ]
761        );
762        assert_eq!(
763            find("session.list")
764                .unwrap()
765                .field_names()
766                .collect::<Vec<_>>(),
767            ["prefix"]
768        );
769    }
770
771    #[test]
772    fn embedding_group_is_cataloged() {
773        let group = catalog()
774            .iter()
775            .find(|g| g.module == "submilli:embedding")
776            .expect("submilli:embedding group");
777        let names: Vec<&str> = group.capabilities.iter().map(|cap| cap.name).collect();
778        assert_eq!(names, ["embedding.embed"]);
779        let capability = find("embedding.embed").unwrap();
780        assert_eq!(
781            capability.field_names().collect::<Vec<_>>(),
782            ["model", "input_count"]
783        );
784        assert_eq!(capability.example_filter, "model glob \"memory-*\"");
785        assert!(capability.main_denial.is_none());
786        assert!(
787            capability.summary.contains("models()"),
788            "the summary must say narrowing `model` narrows discovery: {}",
789            capability.summary
790        );
791    }
792
793    #[test]
794    fn llm_group_is_cataloged() {
795        let group = catalog()
796            .iter()
797            .find(|g| g.module == "submilli:llm")
798            .expect("submilli:llm group");
799        let names: Vec<&str> = group.capabilities.iter().map(|cap| cap.name).collect();
800        assert_eq!(names, ["llm.call"]);
801        assert_eq!(
802            find("llm.call").unwrap().field_names().collect::<Vec<_>>(),
803            ["model", "prompt_count"]
804        );
805        // The double gate lives in the summary wording and the host fn, not in
806        // the struct, so an operator reading only the catalog still has to learn
807        // that narrowing `model` also shortens what `models()` returns.
808        let summary = find("llm.call").unwrap().summary;
809        assert!(
810            summary.contains("models()"),
811            "the summary must say narrowing `model` narrows discovery: {summary}"
812        );
813    }
814
815    #[test]
816    fn find_and_is_template() {
817        assert_eq!(find("fs.read").map(|c| c.name), Some("fs.read"));
818        assert!(find("fs.nope").is_none());
819        assert!(find("mcp.<server>").is_some_and(Capability::is_template));
820        assert!(!find("fs.read").unwrap().is_template());
821    }
822}