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
391/// The session's application tools, `@tools`: the harness that opened the
392/// session declares them and answers each call. The tool being called is in the
393/// filter context. Deny by default: the harness decides what it exposes, the
394/// blueprint decides what programs may call.
395const TOOLS: &[Capability] = &[Capability {
396    name: "tools.call",
397    main_denial: None,
398    summary: "Call an application tool the harness declared for the session (`@tools`). \
399              The harness answers each call, and may refuse it",
400    filter_fields: &[field(
401        "tool",
402        "string",
403        "Name of the application tool being called",
404    )],
405    example_filter: "tool == \"query_orders\"",
406}];
407
408const EMBEDDING: &[Capability] = &[Capability {
409    name: "embedding.embed",
410    // No `main_denial`: main-module access is the point. The program names an
411    // alias and supplies text; the credential is resolved inside the provider
412    // and never returned.
413    main_denial: None,
414    summary: "Embed text through a declared embedding alias (embed) and enumerate the aliases \
415              it may use (models). Narrowing `model` also narrows what `models()` reveals: \
416              every candidate is filtered through this same rule, so a listing never offers \
417              an alias the caller would be denied at call time. A policy allowing no \
418              candidates returns an empty listing",
419    filter_fields: &[
420        field(
421            "model",
422            "string",
423            "Alias the call targets or the candidate being listed. The runtime preflight \
424             does not ask policy about an empty name",
425        ),
426        field(
427            "input_count",
428            "number",
429            "Texts in this call — N for embed, 0 for models. Input text is never in this \
430             context",
431        ),
432    ],
433    example_filter: "model glob \"memory-*\"",
434}];
435
436const LLM: &[Capability] = &[Capability {
437    name: "llm.call",
438    // No `main_denial`: main-module access is the point. A model call is not a
439    // secret read — the program names a model and supplies a prompt, and the
440    // credential is resolved inside the provider and never returned.
441    main_denial: None,
442    summary: "Call a model (call, batch) and enumerate the models it may call (models). \
443              Narrowing `model` also narrows what `models()` reveals: every candidate is \
444              filtered through this same rule, so a listing never offers a model the \
445              caller would be denied at call time. A policy allowing no candidates \
446              returns an empty listing",
447    filter_fields: &[
448        field(
449            "model",
450            "string",
451            "Model name the call targets or the candidate being listed. \
452             The runtime preflight does not ask policy about an empty name",
453        ),
454        field(
455            "prompt_count",
456            "number",
457            "Prompts in this dispatch — 1 for call, N for batch, 0 for models. Prompt text \
458             is never in this context",
459        ),
460    ],
461    example_filter: "model glob \"claude-*\"",
462}];
463
464const SECRETS: &[Capability] = &[Capability {
465    name: "secrets.get",
466    main_denial: Some(
467        "secret values are never available to main-module code, and no policy can \
468         grant this. The package that needs this credential resolves it internally \
469         and never returns it — pass the secret NAME to that package's API instead",
470    ),
471    summary: "Read a Blueprint-declared secret value",
472    filter_fields: &[field("name", "string", "Declared secret name being read")],
473    example_filter: "name == \"STRIPE_API_KEY\"",
474}];
475
476const SESSION: &[Capability] = &[
477    Capability {
478        name: "session.read",
479        main_denial: None,
480        summary: "Read session state (get, has), and decide which keys a list may reveal",
481        filter_fields: &[KEY],
482        example_filter: "key glob \"triage/*\"",
483    },
484    Capability {
485        name: "session.write",
486        main_denial: None,
487        summary: "Store or overwrite a session value (set)",
488        filter_fields: &[KEY],
489        example_filter: "key glob \"triage/*\"",
490    },
491    Capability {
492        name: "session.remove",
493        main_denial: None,
494        summary: "Delete a session key",
495        filter_fields: &[KEY],
496        example_filter: "key glob \"triage/*\"",
497    },
498    Capability {
499        name: "session.list",
500        main_denial: None,
501        summary: "Enumerate session keys under a prefix",
502        filter_fields: &[PREFIX],
503        example_filter: "prefix == \"triage/\"",
504    },
505];
506
507/// The capabilities of the core packages, which every embedder has.
508const CORE_CATALOG: &[CapabilityGroup] = &[
509    CapabilityGroup {
510        module: "submilli:embedding",
511        capabilities: EMBEDDING,
512    },
513    CapabilityGroup {
514        module: "submilli:fs",
515        capabilities: FS,
516    },
517    CapabilityGroup {
518        module: "submilli:git",
519        capabilities: GIT,
520    },
521    CapabilityGroup {
522        module: "submilli:http",
523        capabilities: HTTP,
524    },
525    CapabilityGroup {
526        module: "submilli:llm",
527        capabilities: LLM,
528    },
529    CapabilityGroup {
530        module: "@mcp",
531        capabilities: MCP,
532    },
533    CapabilityGroup {
534        module: "submilli:secrets",
535        capabilities: SECRETS,
536    },
537    CapabilityGroup {
538        module: "submilli:session",
539        capabilities: SESSION,
540    },
541    CapabilityGroup {
542        module: "@tools",
543        capabilities: TOOLS,
544    },
545];
546
547const AGENT: FilterField = field(
548    "agent",
549    "string",
550    "The sub-agent's name, as `submilli:agents` list() names it",
551);
552
553const AGENTS: CapabilityGroup = CapabilityGroup {
554    module: "submilli:agents",
555    capabilities: &[Capability {
556        name: "agent.run",
557        main_denial: None,
558        summary: "Hand work to a harness sub-agent, or list the agents",
559        filter_fields: &[AGENT],
560        example_filter: "agent == \"researcher\"",
561    }],
562};
563
564const SKILL: FilterField = field(
565    "name",
566    "string",
567    "The skill's name, as `submilli:skills` list() names it",
568);
569
570const SKILLS: CapabilityGroup = CapabilityGroup {
571    module: "submilli:skills",
572    capabilities: &[Capability {
573        name: "skill.load",
574        main_denial: None,
575        summary: "Load a harness skill, read its files, or list the skills",
576        filter_fields: &[SKILL],
577        example_filter: "name == \"code-review\"",
578    }],
579};
580
581/// The capabilities of one optional package.
582fn optional_group(package: OptionalPackage) -> &'static CapabilityGroup {
583    match package {
584        OptionalPackage::Agents => &AGENTS,
585        OptionalPackage::Skills => &SKILLS,
586    }
587}
588
589/// Every capability the core packages gate, grouped by source module. An
590/// optional package's capabilities are listed only by [`catalog_for`].
591pub fn catalog() -> &'static [CapabilityGroup] {
592    CORE_CATALOG
593}
594
595/// Every capability the packages of `stdlib` gate, grouped by source module,
596/// in [`catalog`]'s order: by module name without its `submilli:` or `@` prefix.
597pub fn catalog_for(stdlib: Stdlib) -> Vec<&'static CapabilityGroup> {
598    let mut groups: Vec<&'static CapabilityGroup> = CORE_CATALOG.iter().collect();
599    groups.extend(stdlib.optional_packages().map(optional_group));
600    groups.sort_by_key(|group| catalog_order(group.module));
601    groups
602}
603
604fn catalog_order(module: &str) -> &str {
605    module
606        .strip_prefix("submilli:")
607        .or_else(|| module.strip_prefix('@'))
608        .unwrap_or(module)
609}
610
611/// Every group the runtime can gate, whichever packages an embedder enables.
612/// Runtime checks use this: a capability reaches them only from a host
613/// function, which is installed only when its package is enabled.
614pub(crate) fn all_groups() -> impl Iterator<Item = &'static CapabilityGroup> {
615    CORE_CATALOG.iter().chain(
616        OptionalPackage::ALL
617            .iter()
618            .map(|package| optional_group(*package)),
619    )
620}
621
622/// Look up a core capability by its exact name (templates included, by their
623/// literal `mcp.<server>` spelling).
624pub fn find(name: &str) -> Option<&'static Capability> {
625    find_for(Stdlib::core(), name)
626}
627
628/// [`find`] among the capabilities of `stdlib`.
629pub fn find_for(stdlib: Stdlib, name: &str) -> Option<&'static Capability> {
630    catalog_for(stdlib)
631        .into_iter()
632        .flat_map(|group| group.capabilities)
633        .find(|cap| cap.name == name)
634}
635
636/// [`find`] among every group the runtime can gate.
637pub(crate) fn find_any(name: &str) -> Option<&'static Capability> {
638    all_groups()
639        .flat_map(|group| group.capabilities)
640        .find(|cap| cap.name == name)
641}
642
643/// The method of a name that fills [`HTTP_OTHER_METHOD`]: `http.` and a method
644/// token in the lowercase form the runtime checks, which the catalog has no
645/// entry for.
646pub fn uncataloged_http_method(name: &str) -> Option<&str> {
647    let method = name.strip_prefix("http.")?;
648    (is_lowercase_http_token(method) && find(name).is_none()).then_some(method)
649}
650
651/// The entry describing `name`: its catalog entry, or [`HTTP_OTHER_METHOD`]
652/// for a name that fills it.
653pub fn find_gating(name: &str) -> Option<&'static Capability> {
654    find_gating_for(Stdlib::core(), name)
655}
656
657/// [`find_gating`] among the capabilities of `stdlib`.
658pub fn find_gating_for(stdlib: Stdlib, name: &str) -> Option<&'static Capability> {
659    find_for(stdlib, name).or_else(|| uncataloged_http_method(name).map(|_| &HTTP_OTHER_METHOD))
660}
661
662/// An RFC 9110 method token, the set `http.request` accepts, in lowercase.
663fn is_lowercase_http_token(method: &str) -> bool {
664    !method.is_empty()
665        && method.bytes().all(|byte| {
666            byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"!#$%&'*+-.^_`|~".contains(&byte)
667        })
668}
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673    use std::collections::HashSet;
674
675    /// Marking a capability not-grantable-to-`main` changes the runtime's
676    /// answer for every caller, so growing the set is a deliberate act.
677    #[test]
678    fn only_secrets_get_is_refused_to_main() {
679        let refused: Vec<&str> = all_groups()
680            .flat_map(|group| group.capabilities)
681            .filter(|cap| !cap.grantable_to_main())
682            .map(|cap| cap.name)
683            .collect();
684        assert_eq!(refused, ["secrets.get"]);
685    }
686
687    /// The core set lists exactly the core catalog, in the same order, so every
688    /// surface that reads `catalog()` agrees with one that reads `catalog_for`.
689    #[test]
690    fn core_set_lists_the_core_catalog() {
691        let core: Vec<&str> = catalog().iter().map(|group| group.module).collect();
692        let for_core: Vec<&str> = catalog_for(Stdlib::core())
693            .iter()
694            .map(|group| group.module)
695            .collect();
696        assert_eq!(for_core, core);
697    }
698
699    #[test]
700    fn catalog_entries_are_well_formed() {
701        let mut seen = HashSet::new();
702        for group in all_groups() {
703            for cap in group.capabilities {
704                assert!(
705                    cap.name.contains('.') && !cap.name.is_empty(),
706                    "capability name '{}' must be `module.action`",
707                    cap.name
708                );
709                assert!(seen.insert(cap.name), "duplicate capability '{}'", cap.name);
710                assert!(!cap.summary.is_empty(), "{}: empty summary", cap.name);
711                // `filter_fields` may be empty for a capability gated by name alone
712                // (e.g. the `@mcp` family, whose name already pins server + tool).
713                assert!(
714                    !cap.example_filter.is_empty(),
715                    "{}: empty example_filter",
716                    cap.name
717                );
718                assert!(
719                    cap.main_denial.is_none_or(|reason| !reason.is_empty()),
720                    "{}: empty main_denial reason",
721                    cap.name
722                );
723                for f in cap.filter_fields {
724                    assert!(!f.name.is_empty(), "{}: unnamed filter field", cap.name);
725                    assert!(
726                        matches!(f.ty, "string" | "number" | "boolean"),
727                        "{}.{}: unknown field type '{}'",
728                        cap.name,
729                        f.name,
730                        f.ty
731                    );
732                    assert!(!f.doc.is_empty(), "{}.{}: empty doc", cap.name, f.name);
733                }
734            }
735        }
736    }
737
738    #[test]
739    fn session_group_is_cataloged() {
740        let group = catalog()
741            .iter()
742            .find(|g| g.module == "submilli:session")
743            .expect("submilli:session group");
744        let names: Vec<&str> = group.capabilities.iter().map(|cap| cap.name).collect();
745        assert_eq!(
746            names,
747            [
748                "session.read",
749                "session.write",
750                "session.remove",
751                "session.list"
752            ]
753        );
754        assert_eq!(
755            find("session.list")
756                .unwrap()
757                .field_names()
758                .collect::<Vec<_>>(),
759            ["prefix"]
760        );
761    }
762
763    #[test]
764    fn embedding_group_is_cataloged() {
765        let group = catalog()
766            .iter()
767            .find(|g| g.module == "submilli:embedding")
768            .expect("submilli:embedding group");
769        let names: Vec<&str> = group.capabilities.iter().map(|cap| cap.name).collect();
770        assert_eq!(names, ["embedding.embed"]);
771        let capability = find("embedding.embed").unwrap();
772        assert_eq!(
773            capability.field_names().collect::<Vec<_>>(),
774            ["model", "input_count"]
775        );
776        assert_eq!(capability.example_filter, "model glob \"memory-*\"");
777        assert!(capability.main_denial.is_none());
778        assert!(
779            capability.summary.contains("models()"),
780            "the summary must say narrowing `model` narrows discovery: {}",
781            capability.summary
782        );
783    }
784
785    #[test]
786    fn llm_group_is_cataloged() {
787        let group = catalog()
788            .iter()
789            .find(|g| g.module == "submilli:llm")
790            .expect("submilli:llm group");
791        let names: Vec<&str> = group.capabilities.iter().map(|cap| cap.name).collect();
792        assert_eq!(names, ["llm.call"]);
793        assert_eq!(
794            find("llm.call").unwrap().field_names().collect::<Vec<_>>(),
795            ["model", "prompt_count"]
796        );
797        // The double gate lives in the summary wording and the host fn, not in
798        // the struct, so an operator reading only the catalog still has to learn
799        // that narrowing `model` also shortens what `models()` returns.
800        let summary = find("llm.call").unwrap().summary;
801        assert!(
802            summary.contains("models()"),
803            "the summary must say narrowing `model` narrows discovery: {summary}"
804        );
805    }
806
807    #[test]
808    fn uncataloged_http_methods_fill_the_template() {
809        assert_eq!(uncataloged_http_method("http.trace"), Some("trace"));
810        assert_eq!(uncataloged_http_method("http.propfind"), Some("propfind"));
811        // Cataloged, the template itself, uppercase (the runtime lowercases),
812        // empty, or not a token.
813        for name in [
814            "http.get",
815            "http.<method>",
816            "http.TRACE",
817            "http.",
818            "http.a b",
819            "fs.trace",
820        ] {
821            assert!(uncataloged_http_method(name).is_none(), "{name}");
822        }
823        assert!(HTTP_OTHER_METHOD.is_template());
824        assert_eq!(
825            find_gating("http.trace").map(|c| c.name),
826            Some("http.<method>")
827        );
828        assert_eq!(find_gating("http.get").map(|c| c.name), Some("http.get"));
829    }
830
831    #[test]
832    fn find_and_is_template() {
833        assert_eq!(find("fs.read").map(|c| c.name), Some("fs.read"));
834        assert!(find("fs.nope").is_none());
835        assert!(find("mcp.<server>").is_some_and(Capability::is_template));
836        assert!(!find("fs.read").unwrap().is_template());
837    }
838}