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 VARIABLES: &[Capability] = &[Capability {
477    name: "variables.get",
478    main_denial: Some(
479        "configuration variables are available only to packages; no policy can grant main-module access",
480    ),
481    summary: "Read an immutable Blueprint/session configuration variable",
482    filter_fields: &[field("name", "string", "Variable name being read")],
483    example_filter: "name == \"BROWSER_STORE_NAME\"",
484}];
485
486const SESSION: &[Capability] = &[
487    Capability {
488        name: "session.finalize",
489        main_denial: Some(
490            "finalizer registrations belong to packages; main-module access cannot be granted",
491        ),
492        summary: "Register or unregister durable package-owned session cleanup",
493        filter_fields: &[KEY],
494        example_filter: "key glob \"browser/*\"",
495    },
496    Capability {
497        name: "session.read",
498        main_denial: None,
499        summary: "Read session state (get, has), and decide which keys a list may reveal",
500        filter_fields: &[KEY],
501        example_filter: "key glob \"triage/*\"",
502    },
503    Capability {
504        name: "session.write",
505        main_denial: None,
506        summary: "Store or overwrite a session value (set)",
507        filter_fields: &[KEY],
508        example_filter: "key glob \"triage/*\"",
509    },
510    Capability {
511        name: "session.remove",
512        main_denial: None,
513        summary: "Delete a session key",
514        filter_fields: &[KEY],
515        example_filter: "key glob \"triage/*\"",
516    },
517    Capability {
518        name: "session.list",
519        main_denial: None,
520        summary: "Enumerate session keys under a prefix",
521        filter_fields: &[PREFIX],
522        example_filter: "prefix == \"triage/\"",
523    },
524];
525
526/// The capabilities of the core packages, which every embedder has.
527const CORE_CATALOG: &[CapabilityGroup] = &[
528    CapabilityGroup {
529        module: "submilli:embedding",
530        capabilities: EMBEDDING,
531    },
532    CapabilityGroup {
533        module: "submilli:fs",
534        capabilities: FS,
535    },
536    CapabilityGroup {
537        module: "submilli:git",
538        capabilities: GIT,
539    },
540    CapabilityGroup {
541        module: "submilli:http",
542        capabilities: HTTP,
543    },
544    CapabilityGroup {
545        module: "submilli:llm",
546        capabilities: LLM,
547    },
548    CapabilityGroup {
549        module: "@mcp",
550        capabilities: MCP,
551    },
552    CapabilityGroup {
553        module: "submilli:secrets",
554        capabilities: SECRETS,
555    },
556    CapabilityGroup {
557        module: "submilli:session",
558        capabilities: SESSION,
559    },
560    CapabilityGroup {
561        module: "@tools",
562        capabilities: TOOLS,
563    },
564    CapabilityGroup {
565        module: "submilli:variables",
566        capabilities: VARIABLES,
567    },
568];
569
570const AGENT: FilterField = field(
571    "agent",
572    "string",
573    "The sub-agent's name, as `submilli:agents` list() names it",
574);
575
576const AGENTS: CapabilityGroup = CapabilityGroup {
577    module: "submilli:agents",
578    capabilities: &[Capability {
579        name: "agent.run",
580        main_denial: None,
581        summary: "Hand work to a harness sub-agent, or list the agents",
582        filter_fields: &[AGENT],
583        example_filter: "agent == \"researcher\"",
584    }],
585};
586
587const SKILL: FilterField = field(
588    "name",
589    "string",
590    "The skill's name, as `submilli:skills` list() names it",
591);
592
593const SKILLS: CapabilityGroup = CapabilityGroup {
594    module: "submilli:skills",
595    capabilities: &[Capability {
596        name: "skill.load",
597        main_denial: None,
598        summary: "Load a harness skill, read its files, or list the skills",
599        filter_fields: &[SKILL],
600        example_filter: "name == \"code-review\"",
601    }],
602};
603
604/// The capabilities of one optional package.
605fn optional_group(package: OptionalPackage) -> &'static CapabilityGroup {
606    match package {
607        OptionalPackage::Agents => &AGENTS,
608        OptionalPackage::Skills => &SKILLS,
609    }
610}
611
612/// Every capability the core packages gate, grouped by source module. An
613/// optional package's capabilities are listed only by [`catalog_for`].
614pub fn catalog() -> &'static [CapabilityGroup] {
615    CORE_CATALOG
616}
617
618/// Every capability the packages of `stdlib` gate, grouped by source module,
619/// in [`catalog`]'s order: by module name without its `submilli:` or `@` prefix.
620pub fn catalog_for(stdlib: Stdlib) -> Vec<&'static CapabilityGroup> {
621    let mut groups: Vec<&'static CapabilityGroup> = CORE_CATALOG.iter().collect();
622    groups.extend(stdlib.optional_packages().map(optional_group));
623    groups.sort_by_key(|group| catalog_order(group.module));
624    groups
625}
626
627fn catalog_order(module: &str) -> &str {
628    module
629        .strip_prefix("submilli:")
630        .or_else(|| module.strip_prefix('@'))
631        .unwrap_or(module)
632}
633
634/// Every group the runtime can gate, whichever packages an embedder enables.
635/// Runtime checks use this: a capability reaches them only from a host
636/// function, which is installed only when its package is enabled.
637pub(crate) fn all_groups() -> impl Iterator<Item = &'static CapabilityGroup> {
638    CORE_CATALOG.iter().chain(
639        OptionalPackage::ALL
640            .iter()
641            .map(|package| optional_group(*package)),
642    )
643}
644
645/// Look up a core capability by its exact name (templates included, by their
646/// literal `mcp.<server>` spelling).
647pub fn find(name: &str) -> Option<&'static Capability> {
648    find_for(Stdlib::core(), name)
649}
650
651/// [`find`] among the capabilities of `stdlib`.
652pub fn find_for(stdlib: Stdlib, name: &str) -> Option<&'static Capability> {
653    catalog_for(stdlib)
654        .into_iter()
655        .flat_map(|group| group.capabilities)
656        .find(|cap| cap.name == name)
657}
658
659/// [`find`] among every group the runtime can gate.
660pub(crate) fn find_any(name: &str) -> Option<&'static Capability> {
661    all_groups()
662        .flat_map(|group| group.capabilities)
663        .find(|cap| cap.name == name)
664}
665
666/// The method of a name that fills [`HTTP_OTHER_METHOD`]: `http.` and a method
667/// token in the lowercase form the runtime checks, which the catalog has no
668/// entry for.
669pub fn uncataloged_http_method(name: &str) -> Option<&str> {
670    let method = name.strip_prefix("http.")?;
671    (is_lowercase_http_token(method) && find(name).is_none()).then_some(method)
672}
673
674/// The entry describing `name`: its catalog entry, or [`HTTP_OTHER_METHOD`]
675/// for a name that fills it.
676pub fn find_gating(name: &str) -> Option<&'static Capability> {
677    find_gating_for(Stdlib::core(), name)
678}
679
680/// [`find_gating`] among the capabilities of `stdlib`.
681pub fn find_gating_for(stdlib: Stdlib, name: &str) -> Option<&'static Capability> {
682    find_for(stdlib, name).or_else(|| uncataloged_http_method(name).map(|_| &HTTP_OTHER_METHOD))
683}
684
685/// An RFC 9110 method token, the set `http.request` accepts, in lowercase.
686fn is_lowercase_http_token(method: &str) -> bool {
687    !method.is_empty()
688        && method.bytes().all(|byte| {
689            byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"!#$%&'*+-.^_`|~".contains(&byte)
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    /// The core set lists exactly the core catalog, in the same order, so every
714    /// surface that reads `catalog()` agrees with one that reads `catalog_for`.
715    #[test]
716    fn core_set_lists_the_core_catalog() {
717        let core: Vec<&str> = catalog().iter().map(|group| group.module).collect();
718        let for_core: Vec<&str> = catalog_for(Stdlib::core())
719            .iter()
720            .map(|group| group.module)
721            .collect();
722        assert_eq!(for_core, core);
723    }
724
725    #[test]
726    fn catalog_entries_are_well_formed() {
727        let mut seen = HashSet::new();
728        for group in all_groups() {
729            for cap in group.capabilities {
730                assert!(
731                    cap.name.contains('.') && !cap.name.is_empty(),
732                    "capability name '{}' must be `module.action`",
733                    cap.name
734                );
735                assert!(seen.insert(cap.name), "duplicate capability '{}'", cap.name);
736                assert!(!cap.summary.is_empty(), "{}: empty summary", cap.name);
737                // `filter_fields` may be empty for a capability gated by name alone
738                // (e.g. the `@mcp` family, whose name already pins server + tool).
739                assert!(
740                    !cap.example_filter.is_empty(),
741                    "{}: empty example_filter",
742                    cap.name
743                );
744                assert!(
745                    cap.main_denial.is_none_or(|reason| !reason.is_empty()),
746                    "{}: empty main_denial reason",
747                    cap.name
748                );
749                for f in cap.filter_fields {
750                    assert!(!f.name.is_empty(), "{}: unnamed filter field", cap.name);
751                    assert!(
752                        matches!(f.ty, "string" | "number" | "boolean"),
753                        "{}.{}: unknown field type '{}'",
754                        cap.name,
755                        f.name,
756                        f.ty
757                    );
758                    assert!(!f.doc.is_empty(), "{}.{}: empty doc", cap.name, f.name);
759                }
760            }
761        }
762    }
763
764    #[test]
765    fn session_group_is_cataloged() {
766        let group = catalog()
767            .iter()
768            .find(|g| g.module == "submilli:session")
769            .expect("submilli:session group");
770        let names: Vec<&str> = group.capabilities.iter().map(|cap| cap.name).collect();
771        assert_eq!(
772            names,
773            [
774                "session.finalize",
775                "session.read",
776                "session.write",
777                "session.remove",
778                "session.list"
779            ]
780        );
781        assert_eq!(
782            find("session.list")
783                .unwrap()
784                .field_names()
785                .collect::<Vec<_>>(),
786            ["prefix"]
787        );
788    }
789
790    #[test]
791    fn embedding_group_is_cataloged() {
792        let group = catalog()
793            .iter()
794            .find(|g| g.module == "submilli:embedding")
795            .expect("submilli:embedding group");
796        let names: Vec<&str> = group.capabilities.iter().map(|cap| cap.name).collect();
797        assert_eq!(names, ["embedding.embed"]);
798        let capability = find("embedding.embed").unwrap();
799        assert_eq!(
800            capability.field_names().collect::<Vec<_>>(),
801            ["model", "input_count"]
802        );
803        assert_eq!(capability.example_filter, "model glob \"memory-*\"");
804        assert!(capability.main_denial.is_none());
805        assert!(
806            capability.summary.contains("models()"),
807            "the summary must say narrowing `model` narrows discovery: {}",
808            capability.summary
809        );
810    }
811
812    #[test]
813    fn llm_group_is_cataloged() {
814        let group = catalog()
815            .iter()
816            .find(|g| g.module == "submilli:llm")
817            .expect("submilli:llm group");
818        let names: Vec<&str> = group.capabilities.iter().map(|cap| cap.name).collect();
819        assert_eq!(names, ["llm.call"]);
820        assert_eq!(
821            find("llm.call").unwrap().field_names().collect::<Vec<_>>(),
822            ["model", "prompt_count"]
823        );
824        // The double gate lives in the summary wording and the host fn, not in
825        // the struct, so an operator reading only the catalog still has to learn
826        // that narrowing `model` also shortens what `models()` returns.
827        let summary = find("llm.call").unwrap().summary;
828        assert!(
829            summary.contains("models()"),
830            "the summary must say narrowing `model` narrows discovery: {summary}"
831        );
832    }
833
834    #[test]
835    fn uncataloged_http_methods_fill_the_template() {
836        assert_eq!(uncataloged_http_method("http.trace"), Some("trace"));
837        assert_eq!(uncataloged_http_method("http.propfind"), Some("propfind"));
838        // Cataloged, the template itself, uppercase (the runtime lowercases),
839        // empty, or not a token.
840        for name in [
841            "http.get",
842            "http.<method>",
843            "http.TRACE",
844            "http.",
845            "http.a b",
846            "fs.trace",
847        ] {
848            assert!(uncataloged_http_method(name).is_none(), "{name}");
849        }
850        assert!(HTTP_OTHER_METHOD.is_template());
851        assert_eq!(
852            find_gating("http.trace").map(|c| c.name),
853            Some("http.<method>")
854        );
855        assert_eq!(find_gating("http.get").map(|c| c.name), Some("http.get"));
856    }
857
858    #[test]
859    fn find_and_is_template() {
860        assert_eq!(find("fs.read").map(|c| c.name), Some("fs.read"));
861        assert!(find("fs.nope").is_none());
862        assert!(find("mcp.<server>").is_some_and(Capability::is_template));
863        assert!(!find("fs.read").unwrap().is_template());
864    }
865}