Skip to main content

relay_knowledge/interfaces/agent/
policy.rs

1use std::{error::Error, fmt};
2
3use crate::{api::AgentAccessPolicy, domain::SourceScope};
4
5/// Stable adapter error categories for protocol-level governance.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum AgentAdapterErrorKind {
8    PermissionDenied,
9    InvalidScope,
10    LimitExceeded,
11    QosRejected,
12    StorageUnavailable,
13    Timeout,
14    Cancelled,
15    UnsupportedOperation,
16    InvalidArgument,
17    Internal,
18}
19
20impl AgentAdapterErrorKind {
21    pub fn as_str(self) -> &'static str {
22        match self {
23            Self::PermissionDenied => "permission_denied",
24            Self::InvalidScope => "invalid_scope",
25            Self::LimitExceeded => "limit_exceeded",
26            Self::QosRejected => "qos_rejected",
27            Self::StorageUnavailable => "storage_unavailable",
28            Self::Timeout => "timeout",
29            Self::Cancelled => "cancelled",
30            Self::UnsupportedOperation => "unsupported_operation",
31            Self::InvalidArgument => "invalid_argument",
32            Self::Internal => "internal",
33        }
34    }
35}
36
37/// Error raised before or during agent adapter request mapping.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct AgentAdapterError {
40    pub kind: AgentAdapterErrorKind,
41    pub message: String,
42}
43
44impl AgentAdapterError {
45    pub fn new(kind: AgentAdapterErrorKind, message: impl Into<String>) -> Self {
46        Self {
47            kind,
48            message: message.into(),
49        }
50    }
51}
52
53impl fmt::Display for AgentAdapterError {
54    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
55        write!(formatter, "{}: {}", self.kind.as_str(), self.message)
56    }
57}
58
59impl Error for AgentAdapterError {}
60
61/// Validates and authorizes an optional source scope before service invocation.
62pub fn authorize_scope(
63    scope: Option<String>,
64    policy: &AgentAccessPolicy,
65) -> Result<Option<String>, AgentAdapterError> {
66    let Some(normalized) = normalize_scope_for_policy(scope, policy.allow_unspecified_scope)?
67    else {
68        return Ok(None);
69    };
70
71    if policy
72        .allowed_scopes
73        .iter()
74        .any(|allowed| allowed == &normalized)
75    {
76        return Ok(Some(normalized));
77    }
78
79    Err(scope_not_authorized(&normalized))
80}
81
82/// Normalizes optional source scope input while preserving policy semantics.
83pub fn normalize_scope_for_policy(
84    scope: Option<String>,
85    allow_unspecified_scope: bool,
86) -> Result<Option<String>, AgentAdapterError> {
87    let Some(scope) = scope else {
88        return if allow_unspecified_scope {
89            Ok(None)
90        } else {
91            Err(AgentAdapterError::new(
92                AgentAdapterErrorKind::InvalidScope,
93                "source_scope is required by the MCP access policy",
94            ))
95        };
96    };
97    let parsed = SourceScope::parse(scope).map_err(|error| {
98        AgentAdapterError::new(AgentAdapterErrorKind::InvalidScope, error.to_string())
99    })?;
100
101    Ok(Some(parsed.as_str().to_owned()))
102}
103
104/// Builds the shared scope authorization denial.
105pub fn scope_not_authorized(scope: &str) -> AgentAdapterError {
106    AgentAdapterError::new(
107        AgentAdapterErrorKind::PermissionDenied,
108        format!("source_scope '{scope}' is not authorized for this agent access policy"),
109    )
110}
111
112/// Validates tool limit without silently expanding caller budgets.
113pub fn authorize_limit(
114    limit: Option<usize>,
115    policy: &AgentAccessPolicy,
116) -> Result<usize, AgentAdapterError> {
117    let limit = limit.unwrap_or(policy.max_limit);
118    if limit == 0 {
119        return Err(AgentAdapterError::new(
120            AgentAdapterErrorKind::InvalidArgument,
121            "limit must be greater than zero",
122        ));
123    }
124    if limit > policy.max_limit {
125        return Err(AgentAdapterError::new(
126            AgentAdapterErrorKind::LimitExceeded,
127            format!("limit {limit} exceeds MCP max_limit {}", policy.max_limit),
128        ));
129    }
130
131    Ok(limit)
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    fn policy() -> AgentAccessPolicy {
139        AgentAccessPolicy::new(vec!["docs".to_owned()], false, 10, 1024, 1000, false)
140            .expect("policy should build")
141    }
142
143    #[test]
144    fn rejects_missing_scope_when_policy_requires_one() {
145        let error = authorize_scope(None, &policy()).expect_err("missing scope should fail");
146
147        assert_eq!(error.kind, AgentAdapterErrorKind::InvalidScope);
148    }
149
150    #[test]
151    fn authorizes_only_configured_scopes() {
152        let allowed = authorize_scope(Some(" docs ".to_owned()), &policy())
153            .expect("scope should be authorized");
154        let denied =
155            authorize_scope(Some("src".to_owned()), &policy()).expect_err("scope should be denied");
156
157        assert_eq!(allowed.as_deref(), Some("docs"));
158        assert_eq!(denied.kind, AgentAdapterErrorKind::PermissionDenied);
159        assert!(denied.message.contains("src"));
160        assert!(denied.message.contains("agent access policy"));
161    }
162
163    #[test]
164    fn rejects_limits_above_policy_budget() {
165        let error = authorize_limit(Some(11), &policy()).expect_err("limit should fail");
166
167        assert_eq!(error.kind, AgentAdapterErrorKind::LimitExceeded);
168    }
169}