1#[cfg(feature = "alloc")]
15pub type ArgsJson = alloc::string::String;
16
17#[derive(Debug, Clone, PartialEq)]
19#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
20pub enum FileMode {
21 Read,
23 Write,
25 Append,
27 Delete,
29}
30
31#[derive(Debug, Clone, PartialEq)]
36pub enum PolicyError {
37 InvalidDocument,
39 UnknownAction,
41 EvaluationFailed,
43}
44
45#[derive(Debug, Clone, PartialEq)]
47#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
48pub enum PolicyDecision {
49 Allow,
51 Deny,
53 RequireApproval,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
72#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
73#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
74pub enum EnforcementMode {
75 #[default]
77 Enforce,
78 Observe,
80 Disabled,
82}
83
84impl EnforcementMode {
85 pub fn from_proto_i32(v: i32) -> Option<Self> {
90 match v {
91 1 => Some(Self::Enforce),
92 2 => Some(Self::Observe),
93 3 => Some(Self::Disabled),
94 _ => None,
95 }
96 }
97}
98
99#[cfg(feature = "alloc")]
103#[derive(Debug, Clone, PartialEq)]
104#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
105pub struct PolicyRule {
106 pub action_pattern: alloc::string::String,
108 pub decision: PolicyDecision,
110}
111
112#[cfg(feature = "alloc")]
119#[derive(Debug, Clone, PartialEq)]
120#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
121pub struct PolicyDocument {
122 pub version: u32,
124 pub name: alloc::string::String,
126 pub rules: alloc::vec::Vec<PolicyRule>,
128 #[cfg_attr(feature = "serde", serde(default))]
132 pub enforcement_mode: EnforcementMode,
133}
134
135#[cfg(feature = "alloc")]
139#[derive(Debug, Clone, PartialEq)]
140#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
141pub enum PolicyResult {
142 Allow,
144 Deny {
146 reason: alloc::string::String,
148 },
149 RequiresApproval {
151 timeout_secs: u32,
153 },
154}
155
156#[cfg(feature = "alloc")]
160#[derive(Debug, Clone, PartialEq)]
161#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
162pub enum GovernanceAction {
163 ToolCall {
165 name: alloc::string::String,
167 args: ArgsJson,
169 },
170 ToolResult {
178 tool_name: alloc::string::String,
180 result: ArgsJson,
182 },
183 FileAccess {
185 path: alloc::string::String,
187 mode: FileMode,
189 },
190 NetworkRequest {
192 url: alloc::string::String,
194 method: alloc::string::String,
196 },
197 ProcessExec {
199 command: alloc::string::String,
201 },
202 SendMessage {
204 source_team_id: Option<alloc::string::String>,
206 target_team_id: Option<alloc::string::String>,
208 channel_id: Option<alloc::string::String>,
210 },
211}
212
213#[cfg(feature = "alloc")]
221pub trait PolicyEvaluator {
222 fn evaluate(&self, ctx: &crate::AgentContext, action: &GovernanceAction) -> PolicyResult;
224
225 fn load_policy(&mut self, policy: &PolicyDocument) -> Result<(), PolicyError>;
230
231 fn validate_policy(&self, policy: &PolicyDocument) -> Result<(), alloc::vec::Vec<PolicyError>>;
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 #[test]
242 fn file_mode_clone_and_eq() {
243 let m = FileMode::Read;
244 assert_eq!(m.clone(), FileMode::Read);
245 assert_ne!(FileMode::Write, FileMode::Delete);
246 }
247
248 #[test]
249 fn file_mode_all_variants() {
250 assert_ne!(FileMode::Read, FileMode::Write);
252 assert_ne!(FileMode::Append, FileMode::Delete);
253 assert_ne!(FileMode::Write, FileMode::Append);
254 }
255
256 #[test]
257 #[cfg(feature = "alloc")]
258 fn governance_action_tool_call() {
259 let action = GovernanceAction::ToolCall {
260 name: alloc::string::String::from("list_files"),
261 args: alloc::string::String::from("{\"dir\":\"/tmp\"}"),
262 };
263 assert_eq!(action.clone(), action);
264 }
265
266 #[test]
267 #[cfg(feature = "alloc")]
268 fn governance_action_tool_result() {
269 let action = GovernanceAction::ToolResult {
270 tool_name: alloc::string::String::from("list_files"),
271 result: alloc::string::String::from("{\"entries\":[\"a.txt\"]}"),
272 };
273 assert_eq!(action.clone(), action);
274 }
275
276 #[test]
277 #[cfg(all(feature = "alloc", feature = "serde"))]
278 fn governance_action_tool_result_serde_round_trip() {
279 let action = GovernanceAction::ToolResult {
283 tool_name: alloc::string::String::from("read_file"),
284 result: alloc::string::String::from("{\"contents\":\"sk-test-abc\"}"),
285 };
286 let encoded = serde_json::to_string(&action).expect("serialize");
287 let decoded: GovernanceAction = serde_json::from_str(&encoded).expect("deserialize");
288 assert_eq!(decoded, action);
289 }
290
291 #[test]
292 #[cfg(feature = "alloc")]
293 fn governance_action_file_access() {
294 let action = GovernanceAction::FileAccess {
295 path: alloc::string::String::from("/etc/passwd"),
296 mode: FileMode::Read,
297 };
298 let cloned = action.clone();
299 assert_eq!(action, cloned);
300 }
301
302 #[test]
303 #[cfg(feature = "alloc")]
304 fn governance_action_network_request() {
305 let action = GovernanceAction::NetworkRequest {
306 url: alloc::string::String::from("https://example.com"),
307 method: alloc::string::String::from("GET"),
308 };
309 assert_eq!(action.clone(), action);
310 }
311
312 #[test]
313 #[cfg(feature = "alloc")]
314 fn governance_action_spawn() {
315 let action = GovernanceAction::ProcessExec {
316 command: alloc::string::String::from("ls -la"),
317 };
318 assert_eq!(action.clone(), action);
319 }
320
321 #[test]
322 #[cfg(feature = "alloc")]
323 fn policy_result_allow() {
324 assert_eq!(PolicyResult::Allow, PolicyResult::Allow);
325 assert_eq!(PolicyResult::Allow.clone(), PolicyResult::Allow);
326 }
327
328 #[test]
329 #[cfg(feature = "alloc")]
330 fn policy_result_deny_reason() {
331 let r = PolicyResult::Deny {
332 reason: alloc::string::String::from("blocked"),
333 };
334 if let PolicyResult::Deny { reason } = &r {
335 assert_eq!(reason, "blocked");
336 } else {
337 panic!("expected Deny");
338 }
339 }
340
341 #[test]
342 #[cfg(feature = "alloc")]
343 fn policy_result_requires_approval() {
344 let r = PolicyResult::RequiresApproval { timeout_secs: 30 };
345 if let PolicyResult::RequiresApproval { timeout_secs } = r {
346 assert_eq!(timeout_secs, 30);
347 } else {
348 panic!("expected RequiresApproval");
349 }
350 }
351
352 #[test]
353 fn policy_error_variants() {
354 assert_eq!(PolicyError::InvalidDocument, PolicyError::InvalidDocument);
355 assert_ne!(PolicyError::UnknownAction, PolicyError::EvaluationFailed);
356 }
357
358 #[test]
359 fn policy_decision_variants() {
360 assert_eq!(PolicyDecision::Allow, PolicyDecision::Allow);
361 assert_ne!(PolicyDecision::Deny, PolicyDecision::RequireApproval);
362 }
363
364 #[test]
365 fn enforcement_mode_default_is_enforce() {
366 assert_eq!(EnforcementMode::default(), EnforcementMode::Enforce);
368 }
369
370 #[test]
371 fn enforcement_mode_from_proto_i32_round_trips_known_values() {
372 assert_eq!(EnforcementMode::from_proto_i32(1), Some(EnforcementMode::Enforce));
376 assert_eq!(EnforcementMode::from_proto_i32(2), Some(EnforcementMode::Observe));
377 assert_eq!(EnforcementMode::from_proto_i32(3), Some(EnforcementMode::Disabled));
378 assert_eq!(EnforcementMode::from_proto_i32(0), None);
379 assert_eq!(EnforcementMode::from_proto_i32(-1), None);
380 assert_eq!(EnforcementMode::from_proto_i32(99), None);
381 }
382
383 #[cfg(feature = "serde")]
384 #[test]
385 fn enforcement_mode_serde_snake_case_round_trip() {
386 for (mode, expected) in [
389 (EnforcementMode::Enforce, "\"enforce\""),
390 (EnforcementMode::Observe, "\"observe\""),
391 (EnforcementMode::Disabled, "\"disabled\""),
392 ] {
393 let json = serde_json::to_string(&mode).unwrap();
394 assert_eq!(json, expected, "{mode:?} must serialise as {expected}");
395 let back: EnforcementMode = serde_json::from_str(&json).unwrap();
396 assert_eq!(back, mode, "{expected} must deserialise back to {mode:?}");
397 }
398 }
399
400 #[test]
401 #[cfg(feature = "alloc")]
402 fn policy_rule_field_access_clone_eq() {
403 let rule = PolicyRule {
404 action_pattern: alloc::string::String::from("tool_call/*"),
405 decision: PolicyDecision::Deny,
406 };
407 let cloned = rule.clone();
408 assert_eq!(rule, cloned);
409 assert_eq!(rule.action_pattern, "tool_call/*");
410 assert_eq!(rule.decision, PolicyDecision::Deny);
411 }
412
413 #[test]
414 #[cfg(feature = "alloc")]
415 fn policy_document_field_access_clone_eq() {
416 let doc = PolicyDocument {
417 version: 1,
418 name: alloc::string::String::from("test-policy"),
419 rules: alloc::vec![PolicyRule {
420 action_pattern: alloc::string::String::from("*"),
421 decision: PolicyDecision::Allow,
422 }],
423 enforcement_mode: EnforcementMode::default(),
424 };
425 let cloned = doc.clone();
426 assert_eq!(doc, cloned);
427 assert_eq!(doc.version, 1);
428 assert_eq!(doc.name, "test-policy");
429 assert_eq!(doc.rules.len(), 1);
430 assert_eq!(doc.rules[0].decision, PolicyDecision::Allow);
431 }
432
433 #[cfg(all(feature = "alloc", feature = "serde"))]
434 #[test]
435 fn policy_document_enforcement_mode_parses_observe_from_yaml() {
436 let yaml = "version: 1\nname: sandbox-policy\nenforcement_mode: observe\nrules: []\n";
439 let doc: PolicyDocument = serde_yaml::from_str(yaml).unwrap();
440 assert_eq!(doc.enforcement_mode, EnforcementMode::Observe);
441 }
442
443 #[cfg(all(feature = "alloc", feature = "serde"))]
444 #[test]
445 fn policy_document_enforcement_mode_defaults_to_enforce_when_absent() {
446 let yaml = "version: 1\nname: legacy-policy\nrules: []\n";
449 let doc: PolicyDocument = serde_yaml::from_str(yaml).unwrap();
450 assert_eq!(doc.enforcement_mode, EnforcementMode::Enforce);
451 }
452
453 #[test]
454 #[cfg(feature = "alloc")]
455 fn policy_result_cross_variant_inequality() {
456 assert_ne!(
457 PolicyResult::Allow,
458 PolicyResult::Deny {
459 reason: alloc::string::String::from("x")
460 }
461 );
462 assert_ne!(
463 PolicyResult::Deny {
464 reason: alloc::string::String::from("x")
465 },
466 PolicyResult::RequiresApproval { timeout_secs: 10 }
467 );
468 }
469}
470
471#[cfg(feature = "alloc")]
502pub fn is_host_allowed_by_egress_allowlist(host: &str, allowlist: &[alloc::string::String]) -> bool {
503 if allowlist.is_empty() {
504 return true;
505 }
506 let host_lower = host.to_ascii_lowercase();
507 for pattern in allowlist {
508 if egress_pattern_matches(pattern, &host_lower) {
509 return true;
510 }
511 }
512 false
513}
514
515#[cfg(feature = "alloc")]
516fn egress_pattern_matches(pattern: &str, host_lower: &str) -> bool {
517 let pattern_lower = pattern.to_ascii_lowercase();
518 if pattern_lower == "*" {
519 return true;
520 }
521 if let Some(suffix) = pattern_lower.strip_prefix("*.") {
522 let required_suffix = alloc::format!(".{suffix}");
526 return host_lower.ends_with(&required_suffix) && host_lower.len() > required_suffix.len();
527 }
528 pattern_lower == host_lower
529}
530
531#[cfg(all(test, feature = "alloc"))]
532mod egress_tests {
533 use alloc::string::ToString;
534 use alloc::vec;
535
536 use super::is_host_allowed_by_egress_allowlist;
537
538 #[test]
539 fn empty_allowlist_is_default_allow() {
540 assert!(is_host_allowed_by_egress_allowlist("api.example.com", &[]));
541 assert!(is_host_allowed_by_egress_allowlist("evil.attacker.net", &[]));
542 }
543
544 #[test]
545 fn exact_match_only_matches_exact_host() {
546 let list = vec!["api.openai.com".to_string()];
547 assert!(is_host_allowed_by_egress_allowlist("api.openai.com", &list));
548 assert!(!is_host_allowed_by_egress_allowlist("chat.openai.com", &list));
549 assert!(!is_host_allowed_by_egress_allowlist("openai.com", &list));
550 assert!(!is_host_allowed_by_egress_allowlist("attackerapi.openai.com", &list));
551 }
552
553 #[test]
554 fn case_insensitive_host_match() {
555 let list = vec!["API.OpenAI.com".to_string()];
556 assert!(is_host_allowed_by_egress_allowlist("api.openai.com", &list));
557 assert!(is_host_allowed_by_egress_allowlist("API.OPENAI.COM", &list));
558 }
559
560 #[test]
561 fn leftmost_wildcard_matches_subdomain() {
562 let list = vec!["*.openai.com".to_string()];
563 assert!(is_host_allowed_by_egress_allowlist("api.openai.com", &list));
564 assert!(is_host_allowed_by_egress_allowlist("chat.openai.com", &list));
565 assert!(is_host_allowed_by_egress_allowlist("a.b.openai.com", &list));
566 }
567
568 #[test]
569 fn leftmost_wildcard_does_not_match_bare_suffix() {
570 let list = vec!["*.openai.com".to_string()];
571 assert!(!is_host_allowed_by_egress_allowlist("openai.com", &list));
572 }
573
574 #[test]
575 fn leftmost_wildcard_does_not_match_attacker_crafted_suffix() {
576 let list = vec!["*.openai.com".to_string()];
577 assert!(!is_host_allowed_by_egress_allowlist(
580 "evil.openai.com.attacker.net",
581 &list
582 ));
583 }
584
585 #[test]
586 fn universal_wildcard_matches_any_host() {
587 let list = vec!["*".to_string()];
588 assert!(is_host_allowed_by_egress_allowlist("api.openai.com", &list));
589 assert!(is_host_allowed_by_egress_allowlist("evil.attacker.net", &list));
590 assert!(is_host_allowed_by_egress_allowlist("anything", &list));
591 }
592
593 #[test]
594 fn multiple_patterns_any_match_allows() {
595 let list = vec!["api.openai.com".to_string(), "*.anthropic.com".to_string()];
596 assert!(is_host_allowed_by_egress_allowlist("api.openai.com", &list));
597 assert!(is_host_allowed_by_egress_allowlist("api.anthropic.com", &list));
598 assert!(!is_host_allowed_by_egress_allowlist("api.cohere.com", &list));
599 }
600}