1use thiserror::Error;
22
23pub const MAX_CAPABILITY_LEN: usize = 256;
29
30pub const CAP_RESOURCES_UNBOUNDED: &str = "system:resources:unbounded";
49
50pub const CAP_NET_BIND: &str = "net_bind";
64
65pub const CAP_UPLINK: &str = "uplink";
74
75pub const EXEMPT_CAPABILITIES: [&str; 3] = [CAP_RESOURCES_UNBOUNDED, CAP_NET_BIND, CAP_UPLINK];
87
88#[derive(Debug, Error, PartialEq, Eq)]
90pub enum CapabilityGrammarError {
91 #[error("capability must not be empty")]
93 Empty,
94 #[error("capability exceeds {MAX_CAPABILITY_LEN} bytes")]
96 TooLong,
97 #[error("capability may not contain '**' (double glob is reserved)")]
99 DoubleStar,
100 #[error("capability contains an empty segment (leading, trailing, or consecutive ':')")]
103 EmptySegment,
104 #[error(
106 "capability segment {segment:?} contains invalid character {bad:?} (allowed: a-z, A-Z, 0-9, -, _, or literal '*')"
107 )]
108 InvalidCharacter {
109 segment: String,
111 bad: char,
113 },
114 #[error(
116 "capability segment {segment:?} mixes '*' with other characters; '*' must stand alone in a segment"
117 )]
118 PartialStar {
119 segment: String,
121 },
122}
123
124pub fn validate_capability(cap: &str) -> Result<(), CapabilityGrammarError> {
136 if cap.is_empty() {
137 return Err(CapabilityGrammarError::Empty);
138 }
139 if cap.len() > MAX_CAPABILITY_LEN {
140 return Err(CapabilityGrammarError::TooLong);
141 }
142 if cap.contains("**") {
143 return Err(CapabilityGrammarError::DoubleStar);
144 }
145 for segment in cap.split(':') {
146 if segment.is_empty() {
147 return Err(CapabilityGrammarError::EmptySegment);
148 }
149 if segment == "*" {
150 continue;
151 }
152 if segment.contains('*') {
153 return Err(CapabilityGrammarError::PartialStar {
154 segment: segment.to_string(),
155 });
156 }
157 if let Some(bad) = segment
158 .chars()
159 .find(|c| !c.is_ascii_alphanumeric() && *c != '-' && *c != '_')
160 {
161 return Err(CapabilityGrammarError::InvalidCharacter {
162 segment: segment.to_string(),
163 bad,
164 });
165 }
166 }
167 Ok(())
168}
169
170#[must_use]
183pub fn capability_matches(pattern: &str, cap: &str) -> bool {
184 if pattern == "*" {
185 return true;
186 }
187
188 let mut pat_iter = pattern.split(':').peekable();
193 let mut cap_iter = cap.split(':');
194
195 loop {
196 match (pat_iter.next(), cap_iter.next()) {
197 (Some("*"), Some(_)) => {
198 if pat_iter.peek().is_none() {
201 return true;
202 }
203 },
204 (Some(p), Some(c)) => {
205 if p != c {
206 return false;
207 }
208 },
209 (None, None) => return true,
210 (Some(_), None) | (None, Some(_)) => return false,
212 }
213 }
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
220#[serde(rename_all = "snake_case")]
221pub enum CapabilityCategory {
222 Agent,
224 Caps,
226 Quota,
228 Group,
230 Invite,
232 Capsule,
234 System,
236 Approval,
238}
239
240#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
245#[serde(rename_all = "snake_case")]
246pub enum CapabilityScope {
247 #[serde(rename = "self")]
249 Self_,
250 Global,
252}
253
254#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
258#[serde(rename_all = "snake_case")]
259pub enum CapabilityDanger {
260 Safe,
263 Normal,
265 Elevated,
269 Extreme,
272}
273
274#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
281pub struct CapabilityInfo {
282 pub id: &'static str,
285 pub label: &'static str,
287 pub description: &'static str,
290 pub category: CapabilityCategory,
292 pub scope: CapabilityScope,
294 pub danger: CapabilityDanger,
296}
297
298pub const CAPABILITY_CATALOG: &[CapabilityInfo] = {
309 use CapabilityCategory::{Agent, Approval, Caps, Capsule, Group, Invite, Quota, System};
310 use CapabilityDanger::{Elevated, Extreme, Normal, Safe};
311 use CapabilityScope::{Global, Self_};
312 &[
313 CapabilityInfo {
315 id: "system:shutdown",
316 label: "Shut down daemon",
317 description: "Gracefully stop the Astrid daemon. The CLI and dashboard disconnect; pending work is allowed to finish under the configured shutdown grace period.",
318 category: System,
319 scope: Global,
320 danger: Extreme,
321 },
322 CapabilityInfo {
323 id: "system:status",
324 label: "Read daemon status",
325 description: "View daemon PID, uptime, connected-client count, and loaded-capsule list.",
326 category: System,
327 scope: Global,
328 danger: Safe,
329 },
330 CapabilityInfo {
332 id: "capsule:install",
333 label: "Install capsules",
334 description: "Install a new capsule into the system-wide capsule directory. Affects every principal on the host.",
335 category: Capsule,
336 scope: Global,
337 danger: Extreme,
338 },
339 CapabilityInfo {
340 id: "self:capsule:install",
341 label: "Install capsules (own workspace)",
342 description: "Install a capsule into the caller's own workspace. Future kernel work; see also: capsule:install.",
343 category: Capsule,
344 scope: Self_,
345 danger: Elevated,
346 },
347 CapabilityInfo {
348 id: "capsule:reload",
349 label: "Reload all capsules",
350 description: "Trigger a re-discovery of installed capsules system-wide. Causes a brief pause as capsules unload and reload.",
351 category: Capsule,
352 scope: Global,
353 danger: Normal,
354 },
355 CapabilityInfo {
356 id: "self:capsule:reload",
357 label: "Reload capsules (self)",
358 description: "Self-scoped variant of capsule:reload.",
359 category: Capsule,
360 scope: Self_,
361 danger: Normal,
362 },
363 CapabilityInfo {
364 id: "capsule:list",
365 label: "List all capsules",
366 description: "Enumerate every capsule installed on the host, including manifest metadata.",
367 category: Capsule,
368 scope: Global,
369 danger: Safe,
370 },
371 CapabilityInfo {
372 id: "self:capsule:list",
373 label: "List capsules (self)",
374 description: "Self-scoped variant of capsule:list. Always granted to the agent built-in.",
375 category: Capsule,
376 scope: Self_,
377 danger: Safe,
378 },
379 CapabilityInfo {
381 id: "agent:create",
382 label: "Create agents",
383 description: "Provision a new agent principal. Doesn't grant any caps by itself — combine with caps:grant or move the new agent into a group.",
384 category: Agent,
385 scope: Global,
386 danger: Normal,
387 },
388 CapabilityInfo {
389 id: "agent:delete",
390 label: "Delete agents",
391 description: "Remove an agent principal. Cannot delete the bootstrap `default` principal. The principal's home directory is NOT scrubbed (ops concern).",
392 category: Agent,
393 scope: Global,
394 danger: Elevated,
395 },
396 CapabilityInfo {
397 id: "agent:enable",
398 label: "Enable agents",
399 description: "Re-enable a previously disabled agent. New invocations resume.",
400 category: Agent,
401 scope: Global,
402 danger: Normal,
403 },
404 CapabilityInfo {
405 id: "agent:disable",
406 label: "Disable agents",
407 description: "Suspend an agent without deleting it. In-flight invocations finish under the old value; new ones are refused.",
408 category: Agent,
409 scope: Global,
410 danger: Elevated,
411 },
412 CapabilityInfo {
413 id: "agent:modify",
414 label: "Modify agent groups",
415 description: "Add or remove group memberships on an agent. Changes which capabilities the agent inherits.",
416 category: Agent,
417 scope: Global,
418 danger: Elevated,
419 },
420 CapabilityInfo {
421 id: "agent:list",
422 label: "List all agents",
423 description: "Enumerate every agent principal on this host with their groups, grants, and revokes.",
424 category: Agent,
425 scope: Global,
426 danger: Safe,
427 },
428 CapabilityInfo {
429 id: "self:agent:list",
430 label: "View own agent row",
431 description: "Read this principal's own AgentSummary. Always granted to the agent built-in so members can introspect their own permissions.",
432 category: Agent,
433 scope: Self_,
434 danger: Safe,
435 },
436 CapabilityInfo {
438 id: "quota:set",
439 label: "Set agent quotas",
440 description: "Set resource ceilings (RAM, CPU time, IPC throughput) on any agent.",
441 category: Quota,
442 scope: Global,
443 danger: Normal,
444 },
445 CapabilityInfo {
446 id: "self:quota:set",
447 label: "Set own quotas",
448 description: "Self-scoped quota:set — typically only used to relax quotas the operator already permits.",
449 category: Quota,
450 scope: Self_,
451 danger: Normal,
452 },
453 CapabilityInfo {
454 id: "quota:get",
455 label: "Read agent quotas",
456 description: "View the resource ceilings configured on any agent.",
457 category: Quota,
458 scope: Global,
459 danger: Safe,
460 },
461 CapabilityInfo {
462 id: "self:quota:get",
463 label: "Read own quotas",
464 description: "Read the caller's own resource ceilings. Always granted to the agent built-in.",
465 category: Quota,
466 scope: Self_,
467 danger: Safe,
468 },
469 CapabilityInfo {
471 id: "group:create",
472 label: "Create capability groups",
473 description: "Define a new custom capability group. Members inherit the group's capabilities.",
474 category: Group,
475 scope: Global,
476 danger: Elevated,
477 },
478 CapabilityInfo {
479 id: "group:delete",
480 label: "Delete capability groups",
481 description: "Remove a custom capability group. Built-in groups (admin, agent, restricted) cannot be deleted.",
482 category: Group,
483 scope: Global,
484 danger: Elevated,
485 },
486 CapabilityInfo {
487 id: "group:modify",
488 label: "Modify capability groups",
489 description: "Edit the capabilities, description, or `unsafe_admin` flag on a custom group. Changes propagate to every member on the next authz check.",
490 category: Group,
491 scope: Global,
492 danger: Elevated,
493 },
494 CapabilityInfo {
495 id: "group:list",
496 label: "List all groups",
497 description: "Enumerate every group (built-in + custom) with its capability set.",
498 category: Group,
499 scope: Global,
500 danger: Safe,
501 },
502 CapabilityInfo {
503 id: "self:group:list",
504 label: "List groups (self-membership)",
505 description: "Self-scoped group:list — for resolving the caller's own inherited capabilities. Always granted to the agent built-in.",
506 category: Group,
507 scope: Self_,
508 danger: Safe,
509 },
510 CapabilityInfo {
512 id: "caps:grant",
513 label: "Grant capabilities",
514 description: "Append capability patterns to a principal's grants. With `unsafe_admin`, can mint wildcard (`*`) grants. Effectively a meta-permission — anyone with this can elevate themselves.",
515 category: Caps,
516 scope: Global,
517 danger: Extreme,
518 },
519 CapabilityInfo {
520 id: "caps:revoke",
521 label: "Revoke capabilities",
522 description: "Append capability patterns to a principal's revokes (highest-precedence deny). Cannot revoke from the bootstrap `default` principal.",
523 category: Caps,
524 scope: Global,
525 danger: Elevated,
526 },
527 CapabilityInfo {
529 id: "invite:issue",
530 label: "Issue invite tokens",
531 description: "Mint invite tokens that let new principals self-enroll into a designated group. The token IS the auth — anyone holding it can redeem.",
532 category: Invite,
533 scope: Global,
534 danger: Elevated,
535 },
536 CapabilityInfo {
537 id: "invite:redeem",
538 label: "Redeem invite tokens (no-op grant)",
539 description: "Capability name preserved for completeness — the kernel bypasses the cap check on redemption because the token itself is the auth. Granting this to anyone is a no-op.",
540 category: Invite,
541 scope: Global,
542 danger: Normal,
543 },
544 CapabilityInfo {
545 id: "invite:list",
546 label: "List outstanding invites",
547 description: "Enumerate outstanding invite tokens by fingerprint (never the raw token).",
548 category: Invite,
549 scope: Global,
550 danger: Safe,
551 },
552 CapabilityInfo {
553 id: "invite:revoke",
554 label: "Revoke invites",
555 description: "Invalidate an outstanding invite token before it's redeemed.",
556 category: Invite,
557 scope: Global,
558 danger: Normal,
559 },
560 CapabilityInfo {
562 id: "audit:read_all",
563 label: "View full audit firehose",
564 description: "Subscribe to every audit entry across every principal via /api/events. Without this cap, the SSE stream is filtered to the caller's own entries only.",
565 category: System,
566 scope: Global,
567 danger: Elevated,
568 },
569 CapabilityInfo {
571 id: "self:approval:respond",
572 label: "Approve own capability requests",
573 description: "Respond to capability-approval prompts addressed to this principal. Always granted to the agent built-in (an agent can only approve its own requests, never another's).",
574 category: Approval,
575 scope: Self_,
576 danger: Safe,
577 },
578 CapabilityInfo {
580 id: "self:auth:pair",
581 label: "Pair an additional device",
582 description: "Mint a short-lived pair-device token that lets a new device add its ed25519 public key to this principal's AuthConfig.public_keys. The kernel always binds the token to the caller's own principal regardless of wire-level hints.",
583 category: Approval,
584 scope: Self_,
585 danger: Normal,
586 },
587 CapabilityInfo {
588 id: "auth:pair:redeem",
589 label: "Redeem pair-device tokens (no-op grant)",
590 description: "Capability name preserved for completeness — the kernel bypasses the cap check on pair-device redemption because the token itself is the auth. Granting this to anyone is a no-op.",
591 category: Approval,
592 scope: Global,
593 danger: Normal,
594 },
595 ]
596};
597
598pub fn known_capabilities() -> impl Iterator<Item = &'static str> {
608 CAPABILITY_CATALOG.iter().map(|c| c.id)
609}
610
611pub fn known_capabilities_list() -> &'static [&'static str] {
614 static CACHED: std::sync::OnceLock<Vec<&'static str>> = std::sync::OnceLock::new();
615 CACHED.get_or_init(|| known_capabilities().collect())
616}
617
618pub const KNOWN_CAPABILITIES_COUNT: usize = 34;
623
624const _: () = assert!(
625 CAPABILITY_CATALOG.len() == KNOWN_CAPABILITIES_COUNT,
626 "KNOWN_CAPABILITIES_COUNT is stale; bump it when adding a capability"
627);
628
629#[cfg(test)]
630mod tests {
631 use super::*;
632
633 #[test]
636 fn accepts_literal() {
637 validate_capability("system:shutdown").unwrap();
638 validate_capability("self:capsule:install").unwrap();
639 validate_capability("audit:read:alice").unwrap();
640 validate_capability("agent-007").unwrap();
641 }
642
643 #[test]
644 fn accepts_universal_and_prefix_patterns() {
645 validate_capability("*").unwrap();
646 validate_capability("self:*").unwrap();
647 validate_capability("a:*:b").unwrap();
648 }
649
650 #[test]
651 fn rejects_empty() {
652 assert_eq!(validate_capability(""), Err(CapabilityGrammarError::Empty));
653 }
654
655 #[test]
656 fn rejects_double_glob() {
657 assert_eq!(
658 validate_capability("**"),
659 Err(CapabilityGrammarError::DoubleStar)
660 );
661 assert_eq!(
662 validate_capability("self:**"),
663 Err(CapabilityGrammarError::DoubleStar)
664 );
665 assert_eq!(
666 validate_capability("**:read"),
667 Err(CapabilityGrammarError::DoubleStar)
668 );
669 }
670
671 #[test]
672 fn rejects_empty_segment() {
673 assert_eq!(
674 validate_capability(":read"),
675 Err(CapabilityGrammarError::EmptySegment)
676 );
677 assert_eq!(
678 validate_capability("read:"),
679 Err(CapabilityGrammarError::EmptySegment)
680 );
681 assert_eq!(
682 validate_capability("a::b"),
683 Err(CapabilityGrammarError::EmptySegment)
684 );
685 }
686
687 #[test]
688 fn rejects_shell_metachars() {
689 for bad in [
690 "system:shut down",
691 "system:shutdown;rm",
692 "system:`whoami`",
693 "system:$(pwd)",
694 "system:shutdown|id",
695 "self:>log",
696 ] {
697 assert!(
698 matches!(
699 validate_capability(bad),
700 Err(CapabilityGrammarError::InvalidCharacter { .. })
701 ),
702 "{bad:?} should be rejected",
703 );
704 }
705 }
706
707 #[test]
708 fn rejects_partial_star() {
709 assert!(matches!(
710 validate_capability("self:foo*"),
711 Err(CapabilityGrammarError::PartialStar { .. })
712 ));
713 assert!(matches!(
714 validate_capability("*foo"),
715 Err(CapabilityGrammarError::PartialStar { .. })
716 ));
717 }
718
719 #[test]
720 fn rejects_over_length() {
721 let long = "a".repeat(MAX_CAPABILITY_LEN + 1);
722 assert_eq!(
723 validate_capability(&long),
724 Err(CapabilityGrammarError::TooLong)
725 );
726 }
727
728 #[test]
731 fn universal_matches_everything() {
732 assert!(capability_matches("*", "system:shutdown"));
733 assert!(capability_matches("*", "self:capsule:install"));
734 assert!(capability_matches("*", "anything"));
735 }
736
737 #[test]
738 fn exact_match() {
739 assert!(capability_matches("system:shutdown", "system:shutdown"));
740 assert!(!capability_matches("system:shutdown", "system:status"));
741 assert!(!capability_matches(
742 "system:shutdown",
743 "self:system:shutdown"
744 ));
745 }
746
747 #[test]
748 fn trailing_star_matches_one_or_more() {
749 assert!(capability_matches("self:*", "self:capsule"));
750 assert!(capability_matches("self:*", "self:capsule:install"));
751 assert!(capability_matches("self:*", "self:capsule:install:alice"));
752 assert!(!capability_matches("self:*", "self"));
753 assert!(!capability_matches("self:*", "capsule:install"));
754 }
755
756 #[test]
757 fn middle_star_matches_single_segment() {
758 assert!(capability_matches("a:*:b", "a:x:b"));
759 assert!(!capability_matches("a:*:b", "a:x:y:b"));
760 assert!(!capability_matches("a:*:b", "a:b"));
761 }
762
763 #[test]
764 fn mixed_patterns() {
765 assert!(capability_matches("audit:read:*", "audit:read:alice"));
766 assert!(!capability_matches("audit:read:*", "audit:write:alice"));
767 }
768}