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