Skip to main content

relay_knowledge/interfaces/agent/policy/
mod.rs

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