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 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 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 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
507const 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
581fn optional_group(package: OptionalPackage) -> &'static CapabilityGroup {
583 match package {
584 OptionalPackage::Agents => &AGENTS,
585 OptionalPackage::Skills => &SKILLS,
586 }
587}
588
589pub fn catalog() -> &'static [CapabilityGroup] {
592 CORE_CATALOG
593}
594
595pub 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
611pub(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
622pub fn find(name: &str) -> Option<&'static Capability> {
625 find_for(Stdlib::core(), name)
626}
627
628pub 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
636pub(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
643pub 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
651pub fn find_gating(name: &str) -> Option<&'static Capability> {
654 find_gating_for(Stdlib::core(), name)
655}
656
657pub 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
662fn 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 #[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 #[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 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 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 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}