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 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
526const 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
604fn optional_group(package: OptionalPackage) -> &'static CapabilityGroup {
606 match package {
607 OptionalPackage::Agents => &AGENTS,
608 OptionalPackage::Skills => &SKILLS,
609 }
610}
611
612pub fn catalog() -> &'static [CapabilityGroup] {
615 CORE_CATALOG
616}
617
618pub 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
634pub(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
645pub fn find(name: &str) -> Option<&'static Capability> {
648 find_for(Stdlib::core(), name)
649}
650
651pub 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
659pub(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
666pub 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
674pub fn find_gating(name: &str) -> Option<&'static Capability> {
677 find_gating_for(Stdlib::core(), name)
678}
679
680pub 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
685fn 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 #[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]
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 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 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 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}