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