use crate::core::manifest::{Provider, Tool};
use thiserror::Error;
pub fn matches_wildcard(name: &str, pattern: &str) -> bool {
if pattern == "*" {
return true;
}
if pattern == name {
return true;
}
if let Some(prefix) = pattern.strip_suffix('*') {
if name.starts_with(prefix) {
return true;
}
}
false
}
fn legacy_tool_scope_alias(tool_scope: &str) -> Option<String> {
let suffix = tool_scope.strip_prefix("tool:")?;
let colon_pos = suffix.find(':')?;
let mut alias = String::with_capacity(tool_scope.len());
alias.push_str("tool:");
alias.push_str(&suffix[..colon_pos]);
alias.push('_');
alias.push_str(&suffix[colon_pos + 1..]);
Some(alias)
}
#[derive(Error, Debug)]
pub enum ScopeError {
#[error("Scopes have expired (expired at {0})")]
Expired(u64),
#[error("Access denied: '{0}' is not in your scopes")]
AccessDenied(String),
}
#[derive(Debug, Clone)]
pub struct ScopeConfig {
pub scopes: Vec<String>,
pub sub: String,
pub expires_at: u64,
pub rate_config: Option<crate::core::rate::RateConfig>,
}
impl ScopeConfig {
pub fn from_jwt(claims: &crate::core::jwt::TokenClaims) -> Self {
let rate_config = claims.ati.as_ref().and_then(|ns| {
if ns.rate.is_empty() {
None
} else {
crate::core::rate::parse_rate_config(&ns.rate).ok()
}
});
ScopeConfig {
scopes: claims.scopes(),
sub: claims.sub.clone(),
expires_at: claims.exp,
rate_config,
}
}
pub fn unrestricted() -> Self {
ScopeConfig {
scopes: vec!["*".to_string()],
sub: "dev".to_string(),
expires_at: 0,
rate_config: None,
}
}
pub fn is_expired(&self) -> bool {
if self.expires_at == 0 {
return false;
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
now > self.expires_at
}
pub fn is_allowed(&self, tool_scope: &str) -> bool {
if self.is_expired() {
return false;
}
if tool_scope.is_empty() {
return true;
}
let legacy_alias = legacy_tool_scope_alias(tool_scope);
for scope in &self.scopes {
if matches_wildcard(tool_scope, scope)
|| legacy_alias
.as_deref()
.is_some_and(|alias| matches_wildcard(alias, scope))
{
return true;
}
}
false
}
pub fn check_access(&self, tool_name: &str, tool_scope: &str) -> Result<(), ScopeError> {
if self.is_expired() {
return Err(ScopeError::Expired(self.expires_at));
}
if !self.is_allowed(tool_scope) {
return Err(ScopeError::AccessDenied(tool_name.to_string()));
}
Ok(())
}
pub fn time_remaining(&self) -> Option<u64> {
if self.expires_at == 0 {
return None;
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if now >= self.expires_at {
Some(0)
} else {
Some(self.expires_at - now)
}
}
pub fn tool_scope_count(&self) -> usize {
self.scopes
.iter()
.filter(|s| s.starts_with("tool:"))
.count()
}
pub fn skill_scope_count(&self) -> usize {
self.scopes
.iter()
.filter(|s| s.starts_with("skill:"))
.count()
}
pub fn help_enabled(&self) -> bool {
self.is_wildcard() || self.scopes.iter().any(|s| s == "help")
}
pub fn is_wildcard(&self) -> bool {
self.scopes.iter().any(|s| s == "*")
}
}
pub fn filter_tools_by_scope<'a>(
tools: Vec<(&'a Provider, &'a Tool)>,
scopes: &ScopeConfig,
) -> Vec<(&'a Provider, &'a Tool)> {
if scopes.is_wildcard() {
return tools;
}
tools
.into_iter()
.filter(|(_, tool)| match &tool.scope {
Some(scope) => scopes.is_allowed(scope),
None => true, })
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_scopes(scopes: &[&str]) -> ScopeConfig {
ScopeConfig {
scopes: scopes.iter().map(|s| s.to_string()).collect(),
sub: "test-agent".into(),
expires_at: 0,
rate_config: None,
}
}
#[test]
fn test_exact_match() {
let config = make_scopes(&["tool:web_search", "tool:web_fetch"]);
assert!(config.is_allowed("tool:web_search"));
assert!(config.is_allowed("tool:web_fetch"));
assert!(!config.is_allowed("tool:patent_search"));
}
#[test]
fn test_wildcard_suffix() {
let config = make_scopes(&["tool:github:*"]);
assert!(config.is_allowed("tool:github:search_repos"));
assert!(config.is_allowed("tool:github:create_issue"));
assert!(!config.is_allowed("tool:linear:list_issues"));
}
#[test]
fn test_legacy_underscore_scope_matches_canonical_tool_scope() {
let config = make_scopes(&["tool:test_api_get_data"]);
assert!(config.is_allowed("tool:test_api:get_data"));
}
#[test]
fn test_legacy_underscore_wildcard_matches_canonical_tool_scope() {
let config = make_scopes(&["tool:github_*"]);
assert!(config.is_allowed("tool:github:search_repos"));
assert!(config.is_allowed("tool:github:create_issue"));
assert!(!config.is_allowed("tool:linear:list_issues"));
}
#[test]
fn test_global_wildcard() {
let config = make_scopes(&["*"]);
assert!(config.is_allowed("tool:anything"));
assert!(config.is_allowed("help"));
assert!(config.is_allowed("skill:whatever"));
}
#[test]
fn test_empty_scope_always_allowed() {
let config = make_scopes(&["tool:web_search"]);
assert!(config.is_allowed(""));
}
#[test]
fn test_expired_denies_all() {
let config = ScopeConfig {
scopes: vec!["tool:web_search".into()],
sub: "test".into(),
expires_at: 1,
rate_config: None,
};
assert!(config.is_expired());
assert!(!config.is_allowed("tool:web_search"));
}
#[test]
fn test_zero_expiry_means_no_expiry() {
let config = ScopeConfig {
scopes: vec!["tool:web_search".into()],
sub: "test".into(),
expires_at: 0,
rate_config: None,
};
assert!(!config.is_expired());
assert!(config.is_allowed("tool:web_search"));
}
#[test]
fn test_check_access_denied() {
let config = make_scopes(&["tool:web_search"]);
let result = config.check_access("patent_search", "tool:patent_search");
assert!(result.is_err());
}
#[test]
fn test_check_access_expired() {
let config = ScopeConfig {
scopes: vec!["tool:web_search".into()],
sub: "test".into(),
expires_at: 1,
rate_config: None,
};
let result = config.check_access("web_search", "tool:web_search");
assert!(result.is_err());
}
#[test]
fn test_help_enabled() {
assert!(make_scopes(&["tool:web_search", "help"]).help_enabled());
assert!(!make_scopes(&["tool:web_search"]).help_enabled());
assert!(make_scopes(&["*"]).help_enabled());
}
#[test]
fn test_scope_counts() {
let config = make_scopes(&["tool:a", "tool:b", "skill:c", "help"]);
assert_eq!(config.tool_scope_count(), 2);
assert_eq!(config.skill_scope_count(), 1);
}
#[test]
fn test_unrestricted() {
let config = ScopeConfig::unrestricted();
assert!(config.is_wildcard());
assert!(config.is_allowed("anything"));
assert!(config.help_enabled());
}
#[test]
fn test_mixed_patterns() {
let config = make_scopes(&["tool:web_search", "tool:github:*", "skill:research-*"]);
assert!(config.is_allowed("tool:web_search"));
assert!(config.is_allowed("tool:github:search_repos"));
assert!(config.is_allowed("skill:research-general"));
assert!(!config.is_allowed("tool:linear:list_issues"));
assert!(!config.is_allowed("skill:patent-analysis"));
}
}