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 AsciiUppercase,
40}
41
42impl FieldNormalization {
43 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
84pub struct Capability {
86 pub name: &'static str,
89 pub main_denial: Option<&'static str>,
95 pub summary: &'static str,
97 pub filter_fields: &'static [FilterField],
100 pub example_filter: &'static str,
102}
103
104impl Capability {
105 pub fn is_template(&self) -> bool {
108 self.name.contains('<')
109 }
110
111 pub fn grantable_to_main(&self) -> bool {
113 self.main_denial.is_none()
114 }
115
116 pub fn can_ask(&self) -> bool {
120 !self.name.starts_with("git.")
121 }
122
123 pub fn field_names(&self) -> impl Iterator<Item = &'static str> {
125 self.filter_fields.iter().map(|f| f.name)
126 }
127}
128
129pub struct CapabilityGroup {
131 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");
144const 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
387const 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
406const 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 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 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
541const 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
619fn optional_group(package: OptionalPackage) -> &'static CapabilityGroup {
621 match package {
622 OptionalPackage::Agents => &AGENTS,
623 OptionalPackage::Skills => &SKILLS,
624 }
625}
626
627pub fn catalog() -> &'static [CapabilityGroup] {
630 CORE_CATALOG
631}
632
633pub 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
649pub(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
660pub fn find(name: &str) -> Option<&'static Capability> {
663 find_for(Stdlib::core(), name)
664}
665
666pub 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
674pub 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
681pub 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 #[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 #[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 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 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}