Skip to main content

astrid_core/
capability_grammar.rs

1//! Shared grammar and validation for static capability strings (issue #670).
2//!
3//! The management-API policy model uses a colon-delimited identifier
4//! namespace for capabilities:
5//!
6//! ```text
7//! capability  := segment (':' segment)*
8//! segment     := '*' | [a-zA-Z0-9_-]+
9//! ```
10//!
11//! This is a **different namespace** from the runtime
12//! [`CapabilityToken`](../../../astrid-capabilities/src/token.rs) resource
13//! patterns (URI-based `mcp://server:tool`, globset-powered). Static
14//! capabilities identify role membership and are stored in principal
15//! profiles and group configs; runtime tokens gate individual tool calls.
16//!
17//! The grammar is deliberately restrictive — ASCII-only, no shell
18//! metacharacters, no double-glob — so that capability strings round-trip
19//! through TOML and the audit log without escaping surprises.
20
21use thiserror::Error;
22
23/// Upper bound on the total length of a capability pattern, in bytes.
24///
25/// A single capability identifier never legitimately approaches this
26/// limit; the cap exists purely to reject pathological entries before
27/// they reach the matcher.
28pub const MAX_CAPABILITY_LEN: usize = 256;
29
30/// Capability that exempts a principal from per-principal capsule resource
31/// bounds (run-loop CPU epoch interrupt + linear-memory cap).
32///
33/// A principal holding this capability — directly, via a grant, or via a
34/// group whose capability set matches it (the built-in `admin` group's `*`
35/// matches everything) — runs its capsules **unbounded**: never
36/// epoch-interrupt-trapped, and the full
37/// [`DEFAULT_MAX_MEMORY_BYTES`](crate::DEFAULT_MAX_MEMORY_BYTES) linear-memory
38/// ceiling.
39///
40/// The exemption axis is the **capability**, never a group-name string match:
41/// admin is unbounded automatically because `*` matches this string
42/// ([`capability_matches`](crate::capability_matches)), with no special case.
43/// A capsule can never influence its own exemption — it does not choose its
44/// load principal (always [`PrincipalId::default`](crate::PrincipalId::default))
45/// nor its operator-owned profile capabilities. The grammar already accepts
46/// this string (colon-delimited `a-zA-Z0-9_-` segments); no grammar change is
47/// needed. The default for every non-holder is **bounded** (fail-secure).
48pub const CAP_RESOURCES_UNBOUNDED: &str = "system:resources:unbounded";
49
50/// Capability that authorizes a principal to bind/accept network sockets
51/// (the CLI/web/Discord uplink proxy pattern).
52///
53/// This is the **principal-profile** capability — operator/user-granted via
54/// groups or grants — NOT the capsule-authored `[capabilities] net_bind`
55/// manifest field. The manifest field is untrusted self-declaration; THIS
56/// string is what an operator grants a trusted uplink's load principal. A
57/// holder's run-loop is exempt from the per-principal CPU+memory bound,
58/// because a uplink legitimately blocks indefinitely on socket-accept and
59/// must never be epoch-trapped. admin holds it via `*`. A capsule that merely
60/// *declares* `net_bind` in its own manifest without the principal holding
61/// this granted capability is **bounded** (not exempt). Bare single segment,
62/// grammar-valid (`a-zA-Z0-9_-`); no grammar change needed.
63pub const CAP_NET_BIND: &str = "net_bind";
64
65/// Capability that authorizes a principal to register as a long-lived uplink
66/// daemon (parallel to [`CAP_NET_BIND`] for the manifest `uplink` bool).
67///
68/// Operator/user-granted on the principal profile; a holder's run-loop is
69/// exempt from the per-principal CPU+memory bound for the same reason as
70/// [`CAP_NET_BIND`] — a uplink daemon blocks indefinitely and must not be
71/// epoch-trapped. admin holds it via `*`. Manifest self-declaration of
72/// `uplink` does NOT confer it.
73pub const CAP_UPLINK: &str = "uplink";
74
75/// The capabilities that exempt a principal from the per-principal CPU+memory
76/// bound — the single source of truth shared by every site that decides
77/// exemption.
78///
79/// A principal is exempt iff it holds ANY of these (admin matches all three via
80/// `*`). Both the enforcement path
81/// (`astrid_capsule::engine::wasm::resolve_exemption`) and the read path
82/// (`astrid quota`'s usage report) iterate this array, so displayed-exempt can
83/// never drift from enforced-exempt — adding or removing an exemption is a
84/// one-line edit here, reflected on both sides. The default for a holder of
85/// none is **bounded** (fail-secure).
86pub const EXEMPT_CAPABILITIES: [&str; 3] = [CAP_RESOURCES_UNBOUNDED, CAP_NET_BIND, CAP_UPLINK];
87
88/// Errors raised by [`validate_capability`].
89#[derive(Debug, Error, PartialEq, Eq)]
90pub enum CapabilityGrammarError {
91    /// Capability string is empty.
92    #[error("capability must not be empty")]
93    Empty,
94    /// Capability string exceeds [`MAX_CAPABILITY_LEN`] bytes.
95    #[error("capability exceeds {MAX_CAPABILITY_LEN} bytes")]
96    TooLong,
97    /// Capability contains the double-glob sequence `**` (reserved).
98    #[error("capability may not contain '**' (double glob is reserved)")]
99    DoubleStar,
100    /// A segment between `:` separators is empty (leading, trailing,
101    /// or consecutive colons).
102    #[error("capability contains an empty segment (leading, trailing, or consecutive ':')")]
103    EmptySegment,
104    /// A segment contains a character outside the allowed grammar.
105    #[error(
106        "capability segment {segment:?} contains invalid character {bad:?} (allowed: a-z, A-Z, 0-9, -, _, or literal '*')"
107    )]
108    InvalidCharacter {
109        /// Segment that failed validation.
110        segment: String,
111        /// First offending character.
112        bad: char,
113    },
114    /// A segment mixes `*` with other characters (e.g. `foo*`).
115    #[error(
116        "capability segment {segment:?} mixes '*' with other characters; '*' must stand alone in a segment"
117    )]
118    PartialStar {
119        /// Segment that failed validation.
120        segment: String,
121    },
122}
123
124/// Validate a capability string against the colon-delimited grammar.
125///
126/// Accepts both exact identifiers (`system:shutdown`) and patterns
127/// (`self:*`, `a:*:b`, `*`). Rejects empty segments, double-globs,
128/// non-ASCII characters, shell metacharacters, and segments that mix
129/// `*` with literal characters.
130///
131/// # Errors
132///
133/// Returns the first [`CapabilityGrammarError`] encountered; rule order
134/// is not part of the public contract.
135pub 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/// Test whether a capability `pattern` matches the concrete capability
171/// `cap`. Both inputs are expected to be pre-validated via
172/// [`validate_capability`]; behaviour on malformed input is unspecified.
173///
174/// Matching rules:
175///
176/// - `*` alone matches any capability.
177/// - A trailing `*` segment (`self:*`) matches one-or-more remaining
178///   segments (`self:capsule:install`).
179/// - A `*` segment elsewhere matches exactly one segment.
180/// - Otherwise segments must match literally and the segment counts
181///   must agree.
182#[must_use]
183pub fn capability_matches(pattern: &str, cap: &str) -> bool {
184    if pattern == "*" {
185        return true;
186    }
187
188    // Walk both strings segment-by-segment with iterators — no Vec
189    // allocation on the hot path. The enforcement preamble evaluates
190    // this on every admin-API request and per-group-capability, so
191    // saving the two `Vec<&str>` collections is worthwhile.
192    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                // Trailing `*` absorbs every remaining resource segment.
199                // A middle `*` matches exactly one and we continue the loop.
200                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            // Pattern and resource had different segment counts.
211            (Some(_), None) | (None, Some(_)) => return false,
212        }
213    }
214}
215
216/// Coarse category for dashboard grouping. Dashboards bucket
217/// capabilities by category to render permissions panels
218/// Discord-style (one collapsible section per family).
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
220#[serde(rename_all = "snake_case")]
221pub enum CapabilityCategory {
222    /// Agent (principal) lifecycle: create, delete, enable, modify, list.
223    Agent,
224    /// Direct capability grants and revokes on a principal.
225    Caps,
226    /// Per-principal resource quotas (RAM / CPU / IPC).
227    Quota,
228    /// Capability-group definitions and memberships.
229    Group,
230    /// Invite-token lifecycle for onboarding new principals.
231    Invite,
232    /// Capsule install / list / reload / inspect.
233    Capsule,
234    /// Daemon-wide system operations (status, shutdown).
235    System,
236    /// Approval responses for capability requests held in escrow.
237    Approval,
238}
239
240/// Self vs global scope. `Self_` capabilities only let a principal
241/// act on their own state; `Global` lets the holder act on every
242/// principal. The kernel's static tables determine which form
243/// applies to which operation.
244#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
245#[serde(rename_all = "snake_case")]
246pub enum CapabilityScope {
247    /// Operation targets the caller's own principal only.
248    #[serde(rename = "self")]
249    Self_,
250    /// Operation can target any principal / system-wide state.
251    Global,
252}
253
254/// Risk tier for dashboard rendering. Dashboards use this to decide
255/// whether to require a confirmation prompt, paint the toggle red,
256/// or hide the capability behind an "advanced" disclosure.
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
258#[serde(rename_all = "snake_case")]
259pub enum CapabilityDanger {
260    /// Read-only or limited to the caller's own state. No
261    /// cross-principal effects.
262    Safe,
263    /// Routine mutation visible to others (e.g. `agent:create`).
264    Normal,
265    /// Permission management — grants / revokes / group edits.
266    /// Compounds: holding it lets the principal grant more caps to
267    /// themselves or others. Confirmation prompt recommended.
268    Elevated,
269    /// System-wide impact (`system:shutdown`, `capsule:install`).
270    /// Confirmation + audit emphasis strongly recommended.
271    Extreme,
272}
273
274/// Structured catalog entry describing one capability.
275///
276/// Single source of truth shared by the kernel's drift tests and
277/// the HTTP gateway's `/api/sys/capabilities` route. Dashboards
278/// consume this directly to render permissions panels without
279/// hardcoding any per-capability metadata client-side.
280#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
281pub struct CapabilityInfo {
282    /// The capability identifier as it appears in policy. Stable
283    /// wire format — never change without a policy-version bump.
284    pub id: &'static str,
285    /// Short human-readable label for the dashboard toggle.
286    pub label: &'static str,
287    /// One-sentence operator-facing description. Suitable for a
288    /// tooltip or inline hint.
289    pub description: &'static str,
290    /// Family bucket for UI grouping.
291    pub category: CapabilityCategory,
292    /// Self vs global authority scope.
293    pub scope: CapabilityScope,
294    /// Risk tier for confirmation prompts.
295    pub danger: CapabilityDanger,
296}
297
298/// Structured catalog of every static capability the kernel
299/// recognises. **Single source of truth** for grantable
300/// capabilities. Mirrors the static match tables in
301/// `astrid-kernel::kernel_router::{required_capability,
302/// admin::required_capability_for_admin_request}`.
303///
304/// Order is part of the public API — dashboards render in this
305/// order for a stable display. Within each category the
306/// `Global` variant precedes its `self:`-prefixed sibling so the
307/// operator-facing form is the visual default.
308pub 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        // ── System ──
314        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        // ── Capsules ──
331        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        // ── Agents (principals) ──
380        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        // ── Quotas ──
437        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        // ── Groups ──
470        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        // ── Caps (direct grant/revoke) ──
511        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        // ── Invites ──
528        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        // ── Audit ──
561        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        // ── Approval ──
570        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        // ── Auth (pair-device) ──
579        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
598/// Borrow the catalog as a flat slice of ids — the historical shape
599/// used by kernel drift tests. Now a thin view over
600/// [`CAPABILITY_CATALOG`]; the structured catalog is the canonical
601/// declaration.
602///
603/// Kept as a function rather than a `const` because the constant
604/// version would require an extra hand-mirrored array; the function
605/// makes the kernel-test drift check call
606/// `known_capabilities().any(|c| c == cap)` which is unambiguous.
607pub fn known_capabilities() -> impl Iterator<Item = &'static str> {
608    CAPABILITY_CATALOG.iter().map(|c| c.id)
609}
610
611/// Backwards-compatible flat-list view. Materialises once on
612/// first access and re-uses the cached slice on subsequent calls.
613pub 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
618/// Compile-time pin on the size of [`CAPABILITY_CATALOG`]. Bumped
619/// in the same commit that adds a new capability so a kernel
620/// addition without updating the catalog fails the consuming
621/// crate's tests.
622pub 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    // ── validate_capability ─────────────────────────────────────────────
634
635    #[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    // ── capability_matches ──────────────────────────────────────────────
729
730    #[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}