1use std::collections::HashMap;
34use std::sync::Mutex;
35
36use regex::Regex;
37use serde::{Deserialize, Serialize};
38use serde_json::Value;
39
40use chio_core::capability::scope::Constraint;
41use chio_kernel::{Guard, GuardContext, GuardDecision, KernelError};
42
43use crate::action::{extract_action_checked, ToolAction};
44
45#[derive(Debug, thiserror::Error)]
47pub enum MemoryGovernanceError {
48 #[error("invalid deny pattern `{pattern}`: {source}")]
50 InvalidPattern {
51 pattern: String,
52 #[source]
53 source: regex::Error,
54 },
55}
56
57#[derive(Clone, Debug, Deserialize, Serialize)]
59#[serde(deny_unknown_fields)]
60pub struct MemoryGovernanceConfig {
61 #[serde(default = "default_true")]
63 pub enabled: bool,
64 #[serde(default)]
68 pub store_allowlist: Vec<String>,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub max_memory_entries: Option<u64>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub max_retention_ttl_secs: Option<u64>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub max_content_size_bytes: Option<u64>,
82 #[serde(default)]
84 pub deny_patterns: Vec<String>,
85}
86
87fn default_true() -> bool {
88 true
89}
90
91impl Default for MemoryGovernanceConfig {
92 fn default() -> Self {
93 Self {
94 enabled: true,
95 store_allowlist: Vec::new(),
96 max_memory_entries: None,
97 max_retention_ttl_secs: None,
98 max_content_size_bytes: None,
99 deny_patterns: Vec::new(),
100 }
101 }
102}
103
104type SessionKey = (String, String); pub struct MemoryGovernanceGuard {
109 enabled: bool,
110 store_allowlist: Vec<String>,
111 max_memory_entries: Option<u64>,
112 max_retention_ttl_secs: Option<u64>,
113 max_content_size_bytes: Option<u64>,
114 deny_patterns: Vec<Regex>,
115 counters: Mutex<HashMap<SessionKey, u64>>,
116}
117
118impl MemoryGovernanceGuard {
119 pub fn new() -> Self {
123 Self::with_config(MemoryGovernanceConfig::default()).unwrap_or_else(|_| Self {
124 enabled: true,
125 store_allowlist: Vec::new(),
126 max_memory_entries: None,
127 max_retention_ttl_secs: None,
128 max_content_size_bytes: None,
129 deny_patterns: Vec::new(),
130 counters: Mutex::new(HashMap::new()),
131 })
132 }
133
134 pub fn with_config(config: MemoryGovernanceConfig) -> Result<Self, MemoryGovernanceError> {
136 let mut deny_patterns = Vec::with_capacity(config.deny_patterns.len());
137 for pat in &config.deny_patterns {
138 let re = Regex::new(pat).map_err(|e| MemoryGovernanceError::InvalidPattern {
139 pattern: pat.clone(),
140 source: e,
141 })?;
142 deny_patterns.push(re);
143 }
144 Ok(Self {
145 enabled: config.enabled,
146 store_allowlist: config.store_allowlist,
147 max_memory_entries: config.max_memory_entries,
148 max_retention_ttl_secs: config.max_retention_ttl_secs,
149 max_content_size_bytes: config.max_content_size_bytes,
150 deny_patterns,
151 counters: Mutex::new(HashMap::new()),
152 })
153 }
154
155 pub fn session_count(&self, agent_id: &str, capability_id: &str) -> u64 {
157 self.counters
158 .lock()
159 .ok()
160 .and_then(|g| {
161 g.get(&(agent_id.to_string(), capability_id.to_string()))
162 .copied()
163 })
164 .unwrap_or(0)
165 }
166
167 fn effective_store_allowlist<'a>(&'a self, ctx: &'a GuardContext<'a>) -> Option<Vec<String>> {
171 let mut combined: Vec<String> = self.store_allowlist.clone();
172 if let Some(grant) = ctx
173 .matched_grant_index
174 .and_then(|i| ctx.scope.grants.get(i))
175 {
176 for c in &grant.constraints {
177 if let Constraint::MemoryStoreAllowlist(list) = c {
178 combined.extend(list.iter().cloned());
179 }
180 }
181 }
182 if combined.is_empty() {
183 None
184 } else {
185 Some(combined)
186 }
187 }
188
189 fn bump_counter(&self, key: SessionKey) -> Result<u64, KernelError> {
192 let mut guard = self.counters.lock().map_err(|_| {
193 KernelError::Internal("memory-governance guard counter mutex poisoned".to_string())
194 })?;
195 let entry = guard.entry(key).or_insert(0);
196 *entry = entry.saturating_add(1);
197 Ok(*entry)
198 }
199}
200
201impl Default for MemoryGovernanceGuard {
202 fn default() -> Self {
203 Self::new()
204 }
205}
206
207impl Guard for MemoryGovernanceGuard {
208 fn name(&self) -> &str {
209 "memory-governance"
210 }
211
212 fn evaluate(&self, ctx: &GuardContext) -> Result<GuardDecision, KernelError> {
213 if !self.enabled {
214 return Ok(GuardDecision::allow());
215 }
216
217 let action = match extract_action_checked(&ctx.request.tool_name, &ctx.request.arguments) {
218 Ok(action) => action,
219 Err(_) => return Ok(GuardDecision::deny(Vec::new())),
220 };
221
222 match action {
223 ToolAction::MemoryWrite { store, .. } => self.evaluate_write(ctx, &store),
224 ToolAction::MemoryRead { store, .. } => self.evaluate_read(ctx, &store),
225 _ => Ok(GuardDecision::allow()),
226 }
227 }
228}
229
230impl MemoryGovernanceGuard {
231 fn evaluate_write(
232 &self,
233 ctx: &GuardContext,
234 store: &str,
235 ) -> Result<GuardDecision, KernelError> {
236 if let Some(allow) = self.effective_store_allowlist(ctx) {
238 if !allow.iter().any(|s| store_matches(s, store)) {
239 return Ok(GuardDecision::deny(Vec::new()));
240 }
241 }
242
243 if let Some(max_ttl) = self.max_retention_ttl_secs {
245 let requested = extract_retention_ttl(&ctx.request.arguments);
246 match requested {
247 None => {
248 return Ok(GuardDecision::deny(Vec::new()));
251 }
252 Some(ttl) if ttl > max_ttl => {
253 return Ok(GuardDecision::deny(Vec::new()));
254 }
255 Some(_) => {}
256 }
257 }
258
259 if let Some(max_bytes) = self.max_content_size_bytes {
261 match extract_content_size_bytes(&ctx.request.arguments) {
262 Some(size) => {
263 if size > max_bytes {
264 return Ok(GuardDecision::deny(Vec::new()));
265 }
266 }
267 None => {
268 return Ok(GuardDecision::deny(Vec::new()));
269 }
270 }
271 }
272
273 if !self.deny_patterns.is_empty() {
275 if let Some(content) = extract_content_text(&ctx.request.arguments) {
276 for re in &self.deny_patterns {
277 if re.is_match(&content) {
278 return Ok(GuardDecision::deny(Vec::new()));
279 }
280 }
281 }
282 }
283
284 if let Some(max_entries) = self.max_memory_entries {
287 let key = (ctx.agent_id.to_string(), ctx.request.capability.id.clone());
288 let count = self.bump_counter(key)?;
289 if count > max_entries {
290 return Ok(GuardDecision::deny(Vec::new()));
291 }
292 }
293
294 Ok(GuardDecision::allow())
295 }
296
297 fn evaluate_read(&self, ctx: &GuardContext, store: &str) -> Result<GuardDecision, KernelError> {
298 if let Some(allow) = self.effective_store_allowlist(ctx) {
301 if !allow.iter().any(|s| store_matches(s, store)) {
302 return Ok(GuardDecision::deny(Vec::new()));
303 }
304 }
305 Ok(GuardDecision::allow())
306 }
307}
308
309fn store_matches(pattern: &str, store: &str) -> bool {
311 if pattern == "*" {
312 return true;
313 }
314 if let Some(prefix) = pattern.strip_suffix('*') {
315 return store.starts_with(prefix);
316 }
317 pattern == store
318}
319
320fn extract_retention_ttl(arguments: &Value) -> Option<u64> {
322 for key in [
323 "retention_ttl",
324 "retentionTtl",
325 "retention_ttl_secs",
326 "retentionTtlSecs",
327 "ttl",
328 "ttl_secs",
329 "expires_in",
330 "expiresIn",
331 ] {
332 if let Some(v) = arguments.get(key).and_then(|v| v.as_u64()) {
333 return Some(v);
334 }
335 }
336 None
337}
338
339fn extract_content_size_bytes(arguments: &Value) -> Option<u64> {
342 for key in ["content_size", "contentSize", "content_bytes", "size"] {
343 if let Some(v) = arguments.get(key).and_then(|v| v.as_u64()) {
344 return Some(v);
345 }
346 }
347 extract_content_text(arguments).map(|s| s.len() as u64)
348}
349
350fn extract_content_text(arguments: &Value) -> Option<String> {
352 for key in ["content", "text", "value", "vector_text", "payload"] {
353 if let Some(v) = arguments.get(key).and_then(|v| v.as_str()) {
354 if !v.is_empty() {
355 return Some(v.to_string());
356 }
357 }
358 }
359 None
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 #[test]
367 fn store_matches_wildcards() {
368 assert!(store_matches("*", "anything"));
369 assert!(store_matches("agent-*", "agent-notes"));
370 assert!(!store_matches("agent-*", "other"));
371 assert!(store_matches("agent-notes", "agent-notes"));
372 }
373
374 #[test]
375 fn extract_retention_ttl_reads_common_keys() {
376 let args = serde_json::json!({"ttl": 600});
377 assert_eq!(extract_retention_ttl(&args), Some(600));
378 let camel = serde_json::json!({"retentionTtl": 120});
379 assert_eq!(extract_retention_ttl(&camel), Some(120));
380 let none = serde_json::json!({});
381 assert_eq!(extract_retention_ttl(&none), None);
382 }
383
384 #[test]
385 fn content_size_falls_back_to_text_length() {
386 let args = serde_json::json!({"content": "hello"});
387 assert_eq!(extract_content_size_bytes(&args), Some(5));
388 }
389}