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