1use super::{OptionalPackage, Stdlib};
13
14pub struct FilterField {
16 pub name: &'static str,
17 pub ty: &'static str,
20 pub doc: &'static str,
22 pub normalization: FieldNormalization,
24}
25
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub enum FieldNormalization {
31 Verbatim,
33 VfsPath,
35 RepositoryUrl,
38}
39
40impl FieldNormalization {
41 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
81pub struct Capability {
83 pub name: &'static str,
86 pub main_denial: Option<&'static str>,
92 pub summary: &'static str,
94 pub filter_fields: &'static [FilterField],
97 pub example_filter: &'static str,
99}
100
101impl Capability {
102 pub fn is_template(&self) -> bool {
105 self.name.contains('<')
106 }
107
108 pub fn grantable_to_main(&self) -> bool {
110 self.main_denial.is_none()
111 }
112
113 pub fn field_names(&self) -> impl Iterator<Item = &'static str> {
115 self.filter_fields.iter().map(|f| f.name)
116 }
117}
118
119pub struct CapabilityGroup {
121 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");
134const 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
359pub 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
372const MCP: &[Capability] = &[Capability {
377 name: "mcp.<server>",
378 main_denial: None,
379 summary: "Call tools on a declared outbound MCP server (streamable_http)",
380 filter_fields: &[
381 field("tool", "string", "Name of the MCP tool being called"),
382 field(
383 "transport",
384 "string",
385 "MCP transport; always \"streamable_http\" today",
386 ),
387 ],
388 example_filter: "tool == \"create_issue\"",
389}];
390
391const EMBEDDING: &[Capability] = &[Capability {
392 name: "embedding.embed",
393 main_denial: None,
397 summary: "Embed text through a declared embedding alias (embed) and enumerate the aliases \
398 it may use (models). Narrowing `model` also narrows what `models()` reveals: \
399 every candidate is filtered through this same rule, so a listing never offers \
400 an alias the caller would be denied at call time. A policy allowing no \
401 candidates returns an empty listing",
402 filter_fields: &[
403 field(
404 "model",
405 "string",
406 "Alias the call targets or the candidate being listed. The runtime preflight \
407 does not ask policy about an empty name",
408 ),
409 field(
410 "input_count",
411 "number",
412 "Texts in this call — N for embed, 0 for models. Input text is never in this \
413 context",
414 ),
415 ],
416 example_filter: "model glob \"memory-*\"",
417}];
418
419const LLM: &[Capability] = &[Capability {
420 name: "llm.call",
421 main_denial: None,
425 summary: "Call a model (call, batch) and enumerate the models it may call (models). \
426 Narrowing `model` also narrows what `models()` reveals: every candidate is \
427 filtered through this same rule, so a listing never offers a model the \
428 caller would be denied at call time. A policy allowing no candidates \
429 returns an empty listing",
430 filter_fields: &[
431 field(
432 "model",
433 "string",
434 "Model name the call targets or the candidate being listed. \
435 The runtime preflight does not ask policy about an empty name",
436 ),
437 field(
438 "prompt_count",
439 "number",
440 "Prompts in this dispatch — 1 for call, N for batch, 0 for models. Prompt text \
441 is never in this context",
442 ),
443 ],
444 example_filter: "model glob \"claude-*\"",
445}];
446
447const SECRETS: &[Capability] = &[Capability {
448 name: "secrets.get",
449 main_denial: Some(
450 "secret values are never available to main-module code, and no policy can \
451 grant this. The package that needs this credential resolves it internally \
452 and never returns it — pass the secret NAME to that package's API instead",
453 ),
454 summary: "Read a Blueprint-declared secret value",
455 filter_fields: &[field("name", "string", "Declared secret name being read")],
456 example_filter: "name == \"STRIPE_API_KEY\"",
457}];
458
459const SESSION: &[Capability] = &[
460 Capability {
461 name: "session.read",
462 main_denial: None,
463 summary: "Read session state (get, has), and decide which keys a list may reveal",
464 filter_fields: &[KEY],
465 example_filter: "key glob \"triage/*\"",
466 },
467 Capability {
468 name: "session.write",
469 main_denial: None,
470 summary: "Store or overwrite a session value (set)",
471 filter_fields: &[KEY],
472 example_filter: "key glob \"triage/*\"",
473 },
474 Capability {
475 name: "session.remove",
476 main_denial: None,
477 summary: "Delete a session key",
478 filter_fields: &[KEY],
479 example_filter: "key glob \"triage/*\"",
480 },
481 Capability {
482 name: "session.list",
483 main_denial: None,
484 summary: "Enumerate session keys under a prefix",
485 filter_fields: &[PREFIX],
486 example_filter: "prefix == \"triage/\"",
487 },
488];
489
490const CORE_CATALOG: &[CapabilityGroup] = &[
492 CapabilityGroup {
493 module: "submilli:embedding",
494 capabilities: EMBEDDING,
495 },
496 CapabilityGroup {
497 module: "submilli:fs",
498 capabilities: FS,
499 },
500 CapabilityGroup {
501 module: "submilli:git",
502 capabilities: GIT,
503 },
504 CapabilityGroup {
505 module: "submilli:http",
506 capabilities: HTTP,
507 },
508 CapabilityGroup {
509 module: "submilli:llm",
510 capabilities: LLM,
511 },
512 CapabilityGroup {
513 module: "@mcp",
514 capabilities: MCP,
515 },
516 CapabilityGroup {
517 module: "submilli:secrets",
518 capabilities: SECRETS,
519 },
520 CapabilityGroup {
521 module: "submilli:session",
522 capabilities: SESSION,
523 },
524];
525
526const AGENT: FilterField = field(
527 "agent",
528 "string",
529 "The sub-agent's name, as `submilli:agents` list() names it",
530);
531
532const AGENTS: CapabilityGroup = CapabilityGroup {
533 module: "submilli:agents",
534 capabilities: &[Capability {
535 name: "agent.run",
536 main_denial: None,
537 summary: "Hand work to a harness sub-agent, or list the agents",
538 filter_fields: &[AGENT],
539 example_filter: "agent == \"researcher\"",
540 }],
541};
542
543const SKILL: FilterField = field(
544 "name",
545 "string",
546 "The skill's name, as `submilli:skills` list() names it",
547);
548
549const SKILLS: CapabilityGroup = CapabilityGroup {
550 module: "submilli:skills",
551 capabilities: &[Capability {
552 name: "skill.load",
553 main_denial: None,
554 summary: "Load a harness skill, read its files, or list the skills",
555 filter_fields: &[SKILL],
556 example_filter: "name == \"code-review\"",
557 }],
558};
559
560fn optional_group(package: OptionalPackage) -> &'static CapabilityGroup {
562 match package {
563 OptionalPackage::Agents => &AGENTS,
564 OptionalPackage::Skills => &SKILLS,
565 }
566}
567
568pub fn catalog() -> &'static [CapabilityGroup] {
571 CORE_CATALOG
572}
573
574pub fn catalog_for(stdlib: Stdlib) -> Vec<&'static CapabilityGroup> {
577 let mut groups: Vec<&'static CapabilityGroup> = CORE_CATALOG.iter().collect();
578 groups.extend(stdlib.optional_packages().map(optional_group));
579 groups.sort_by_key(|group| catalog_order(group.module));
580 groups
581}
582
583fn catalog_order(module: &str) -> &str {
584 module
585 .strip_prefix("submilli:")
586 .or_else(|| module.strip_prefix('@'))
587 .unwrap_or(module)
588}
589
590pub(crate) fn all_groups() -> impl Iterator<Item = &'static CapabilityGroup> {
594 CORE_CATALOG.iter().chain(
595 OptionalPackage::ALL
596 .iter()
597 .map(|package| optional_group(*package)),
598 )
599}
600
601pub fn find(name: &str) -> Option<&'static Capability> {
604 find_for(Stdlib::core(), name)
605}
606
607pub fn find_for(stdlib: Stdlib, name: &str) -> Option<&'static Capability> {
609 catalog_for(stdlib)
610 .into_iter()
611 .flat_map(|group| group.capabilities)
612 .find(|cap| cap.name == name)
613}
614
615pub(crate) fn find_any(name: &str) -> Option<&'static Capability> {
617 all_groups()
618 .flat_map(|group| group.capabilities)
619 .find(|cap| cap.name == name)
620}
621
622pub fn uncataloged_http_method(name: &str) -> Option<&str> {
626 let method = name.strip_prefix("http.")?;
627 (is_lowercase_http_token(method) && find(name).is_none()).then_some(method)
628}
629
630pub fn find_gating(name: &str) -> Option<&'static Capability> {
633 find_gating_for(Stdlib::core(), name)
634}
635
636pub fn find_gating_for(stdlib: Stdlib, name: &str) -> Option<&'static Capability> {
638 find_for(stdlib, name).or_else(|| uncataloged_http_method(name).map(|_| &HTTP_OTHER_METHOD))
639}
640
641fn is_lowercase_http_token(method: &str) -> bool {
643 !method.is_empty()
644 && method.bytes().all(|byte| {
645 byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"!#$%&'*+-.^_`|~".contains(&byte)
646 })
647}
648
649#[cfg(test)]
650mod tests {
651 use super::*;
652 use std::collections::HashSet;
653
654 #[test]
657 fn only_secrets_get_is_refused_to_main() {
658 let refused: Vec<&str> = all_groups()
659 .flat_map(|group| group.capabilities)
660 .filter(|cap| !cap.grantable_to_main())
661 .map(|cap| cap.name)
662 .collect();
663 assert_eq!(refused, ["secrets.get"]);
664 }
665
666 #[test]
669 fn core_set_lists_the_core_catalog() {
670 let core: Vec<&str> = catalog().iter().map(|group| group.module).collect();
671 let for_core: Vec<&str> = catalog_for(Stdlib::core())
672 .iter()
673 .map(|group| group.module)
674 .collect();
675 assert_eq!(for_core, core);
676 }
677
678 #[test]
679 fn catalog_entries_are_well_formed() {
680 let mut seen = HashSet::new();
681 for group in all_groups() {
682 for cap in group.capabilities {
683 assert!(
684 cap.name.contains('.') && !cap.name.is_empty(),
685 "capability name '{}' must be `module.action`",
686 cap.name
687 );
688 assert!(seen.insert(cap.name), "duplicate capability '{}'", cap.name);
689 assert!(!cap.summary.is_empty(), "{}: empty summary", cap.name);
690 assert!(
693 !cap.example_filter.is_empty(),
694 "{}: empty example_filter",
695 cap.name
696 );
697 assert!(
698 cap.main_denial.is_none_or(|reason| !reason.is_empty()),
699 "{}: empty main_denial reason",
700 cap.name
701 );
702 for f in cap.filter_fields {
703 assert!(!f.name.is_empty(), "{}: unnamed filter field", cap.name);
704 assert!(
705 matches!(f.ty, "string" | "number" | "boolean"),
706 "{}.{}: unknown field type '{}'",
707 cap.name,
708 f.name,
709 f.ty
710 );
711 assert!(!f.doc.is_empty(), "{}.{}: empty doc", cap.name, f.name);
712 }
713 }
714 }
715 }
716
717 #[test]
718 fn session_group_is_cataloged() {
719 let group = catalog()
720 .iter()
721 .find(|g| g.module == "submilli:session")
722 .expect("submilli:session group");
723 let names: Vec<&str> = group.capabilities.iter().map(|cap| cap.name).collect();
724 assert_eq!(
725 names,
726 [
727 "session.read",
728 "session.write",
729 "session.remove",
730 "session.list"
731 ]
732 );
733 assert_eq!(
734 find("session.list")
735 .unwrap()
736 .field_names()
737 .collect::<Vec<_>>(),
738 ["prefix"]
739 );
740 }
741
742 #[test]
743 fn embedding_group_is_cataloged() {
744 let group = catalog()
745 .iter()
746 .find(|g| g.module == "submilli:embedding")
747 .expect("submilli:embedding group");
748 let names: Vec<&str> = group.capabilities.iter().map(|cap| cap.name).collect();
749 assert_eq!(names, ["embedding.embed"]);
750 let capability = find("embedding.embed").unwrap();
751 assert_eq!(
752 capability.field_names().collect::<Vec<_>>(),
753 ["model", "input_count"]
754 );
755 assert_eq!(capability.example_filter, "model glob \"memory-*\"");
756 assert!(capability.main_denial.is_none());
757 assert!(
758 capability.summary.contains("models()"),
759 "the summary must say narrowing `model` narrows discovery: {}",
760 capability.summary
761 );
762 }
763
764 #[test]
765 fn llm_group_is_cataloged() {
766 let group = catalog()
767 .iter()
768 .find(|g| g.module == "submilli:llm")
769 .expect("submilli:llm group");
770 let names: Vec<&str> = group.capabilities.iter().map(|cap| cap.name).collect();
771 assert_eq!(names, ["llm.call"]);
772 assert_eq!(
773 find("llm.call").unwrap().field_names().collect::<Vec<_>>(),
774 ["model", "prompt_count"]
775 );
776 let summary = find("llm.call").unwrap().summary;
780 assert!(
781 summary.contains("models()"),
782 "the summary must say narrowing `model` narrows discovery: {summary}"
783 );
784 }
785
786 #[test]
787 fn uncataloged_http_methods_fill_the_template() {
788 assert_eq!(uncataloged_http_method("http.trace"), Some("trace"));
789 assert_eq!(uncataloged_http_method("http.propfind"), Some("propfind"));
790 for name in [
793 "http.get",
794 "http.<method>",
795 "http.TRACE",
796 "http.",
797 "http.a b",
798 "fs.trace",
799 ] {
800 assert!(uncataloged_http_method(name).is_none(), "{name}");
801 }
802 assert!(HTTP_OTHER_METHOD.is_template());
803 assert_eq!(
804 find_gating("http.trace").map(|c| c.name),
805 Some("http.<method>")
806 );
807 assert_eq!(find_gating("http.get").map(|c| c.name), Some("http.get"));
808 }
809
810 #[test]
811 fn find_and_is_template() {
812 assert_eq!(find("fs.read").map(|c| c.name), Some("fs.read"));
813 assert!(find("fs.nope").is_none());
814 assert!(find("mcp.<server>").is_some_and(Capability::is_template));
815 assert!(!find("fs.read").unwrap().is_template());
816 }
817}