relay_knowledge/interfaces/agent/policy/
mod.rs1use 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#[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#[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
64pub 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
85pub 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
107pub 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
115pub 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;