Skip to main content

agent_capability_token/
scope.rs

1//! Capability scopes.
2//!
3//! ANVIL section 5.3 binds tool tiers to scope patterns of the form
4//! `service:resource:action` - `tools:<tool-id>:invoke` for external tools and
5//! `runtime:platform:*` for runtime-granted platform capabilities. This module
6//! makes that grammar a parse step rather than a convention, so a malformed
7//! scope is rejected at the boundary instead of silently failing to match.
8
9use serde::{Deserialize, Deserializer, Serialize, Serializer};
10
11use crate::error::{ActError, ActResult};
12
13/// The wildcard segment, which matches any value in its position.
14pub const WILDCARD: &str = "*";
15
16/// Number of colon-separated segments in a well-formed scope.
17pub const SCOPE_SEGMENTS: usize = 3;
18
19/// A validated capability scope in `service:resource:action` form.
20///
21/// Serializes as a plain string, so the wire representation is a flat array of
22/// strings rather than a nested structure.
23///
24/// # Examples
25///
26/// ```
27/// use agent_capability_token::Scope;
28///
29/// let scope: Scope = "tools:calendar:invoke".parse()?;
30/// assert_eq!(scope.service(), "tools");
31/// assert_eq!(scope.action(), "invoke");
32///
33/// // A granted wildcard covers a specific request.
34/// let granted: Scope = "runtime:platform:*".parse()?;
35/// let requested: Scope = "runtime:platform:spawn".parse()?;
36/// assert!(granted.covers(&requested));
37/// # Ok::<(), agent_capability_token::ActError>(())
38/// ```
39#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
40pub struct Scope {
41    raw: String,
42    // Byte offsets of the two separators, so segment access does not re-scan.
43    first_colon: usize,
44    second_colon: usize,
45}
46
47impl Scope {
48    /// Parses and validates a scope string.
49    ///
50    /// # Errors
51    ///
52    /// Returns [`ActError::MalformedScope`] unless the input has exactly three
53    /// non-empty colon-separated segments.
54    pub fn parse(value: impl Into<String>) -> ActResult<Self> {
55        let raw = value.into();
56
57        let mut colons = raw.match_indices(':').map(|(i, _)| i);
58        let (Some(first_colon), Some(second_colon)) = (colons.next(), colons.next()) else {
59            return Err(ActError::MalformedScope {
60                value: raw,
61                reason: "expected three segments in service:resource:action form",
62            });
63        };
64        if colons.next().is_some() {
65            return Err(ActError::MalformedScope {
66                value: raw,
67                reason: "expected exactly three segments, found more",
68            });
69        }
70
71        let candidate = Self {
72            first_colon,
73            second_colon,
74            raw,
75        };
76        if candidate.service().is_empty()
77            || candidate.resource().is_empty()
78            || candidate.action().is_empty()
79        {
80            return Err(ActError::MalformedScope {
81                value: candidate.raw,
82                reason: "no segment may be empty",
83            });
84        }
85
86        Ok(candidate)
87    }
88
89    /// The service segment.
90    #[must_use]
91    pub fn service(&self) -> &str {
92        &self.raw[..self.first_colon]
93    }
94
95    /// The resource segment.
96    #[must_use]
97    pub fn resource(&self) -> &str {
98        &self.raw[self.first_colon + 1..self.second_colon]
99    }
100
101    /// The action segment.
102    #[must_use]
103    pub fn action(&self) -> &str {
104        &self.raw[self.second_colon + 1..]
105    }
106
107    /// The scope as it appears on the wire.
108    #[must_use]
109    pub fn as_str(&self) -> &str {
110        &self.raw
111    }
112
113    /// Whether this scope, treated as a grant, authorizes `requested`.
114    ///
115    /// A [`WILDCARD`] segment in the grant matches any value in the same
116    /// position. The comparison is directional: a wildcard in `requested` is
117    /// matched literally, so a caller cannot widen its own authority by asking
118    /// for `*`.
119    #[must_use]
120    pub fn covers(&self, requested: &Self) -> bool {
121        fn segment_covers(granted: &str, requested: &str) -> bool {
122            granted == WILDCARD || granted == requested
123        }
124
125        segment_covers(self.service(), requested.service())
126            && segment_covers(self.resource(), requested.resource())
127            && segment_covers(self.action(), requested.action())
128    }
129}
130
131impl std::str::FromStr for Scope {
132    type Err = ActError;
133
134    fn from_str(s: &str) -> Result<Self, Self::Err> {
135        Self::parse(s)
136    }
137}
138
139impl std::fmt::Display for Scope {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        f.write_str(&self.raw)
142    }
143}
144
145impl Serialize for Scope {
146    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
147        serializer.serialize_str(&self.raw)
148    }
149}
150
151impl<'de> Deserialize<'de> for Scope {
152    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
153        let raw = String::deserialize(deserializer)?;
154        Self::parse(raw).map_err(serde::de::Error::custom)
155    }
156}
157
158#[cfg(test)]
159#[allow(clippy::expect_used)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn parses_three_segments() -> ActResult<()> {
165        let scope = Scope::parse("tools:calendar:invoke")?;
166        assert_eq!(scope.service(), "tools");
167        assert_eq!(scope.resource(), "calendar");
168        assert_eq!(scope.action(), "invoke");
169        assert_eq!(scope.as_str(), "tools:calendar:invoke");
170        Ok(())
171    }
172
173    #[test]
174    fn rejects_two_segment_scopes() {
175        // The form both superseded verifiers documented.
176        let error = Scope::parse("tools:invoke").expect_err("two segments must be rejected");
177        assert!(matches!(error, ActError::MalformedScope { .. }));
178    }
179
180    #[test]
181    fn rejects_four_segments_and_empty_segments() {
182        assert!(Scope::parse("a:b:c:d").is_err());
183        assert!(Scope::parse("a::c").is_err());
184        assert!(Scope::parse(":b:c").is_err());
185        assert!(Scope::parse("a:b:").is_err());
186    }
187
188    #[test]
189    fn wildcard_grant_covers_specific_request() -> ActResult<()> {
190        let granted = Scope::parse("runtime:platform:*")?;
191        assert!(granted.covers(&Scope::parse("runtime:platform:spawn")?));
192        assert!(!granted.covers(&Scope::parse("runtime:network:spawn")?));
193        Ok(())
194    }
195
196    #[test]
197    fn requested_wildcard_does_not_widen_authority() -> ActResult<()> {
198        // A specific grant must not be satisfied by a wildcard request,
199        // otherwise any holder could escalate by asking for "*".
200        let granted = Scope::parse("tools:calendar:invoke")?;
201        assert!(!granted.covers(&Scope::parse("tools:calendar:*")?));
202        assert!(!granted.covers(&Scope::parse("tools:*:invoke")?));
203        Ok(())
204    }
205
206    #[test]
207    fn serializes_as_a_plain_string() -> ActResult<()> {
208        let scope = Scope::parse("tools:calendar:invoke")?;
209        let mut buf = Vec::new();
210        ciborium::into_writer(&scope, &mut buf).map_err(|e| ActError::Encode(e.to_string()))?;
211
212        let value: ciborium::Value = ciborium::from_reader(buf.as_slice())
213            .map_err(|e| ActError::ClaimsDecode(e.to_string()))?;
214        assert!(value.is_text(), "scope must encode as text, got {value:?}");
215        Ok(())
216    }
217}