1use crate::security::AutonomyLevel;
7use chrono::Utc;
8use parking_lot::Mutex;
9use serde::{Deserialize, Serialize};
10use std::collections::HashSet;
11use std::io::{self, BufRead, Write};
12use zeroclaw_config::schema::RiskProfileConfig;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ApprovalRequest {
19 pub tool_name: String,
20 pub arguments: serde_json::Value,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "lowercase")]
26pub enum ApprovalResponse {
27 Yes,
29 No,
31 Always,
33 #[serde(rename = "replace_with")]
35 ReplaceWith(String),
36}
37
38pub const MAX_REPLACEMENT_LEN: usize = 64 * 1024;
43
44#[must_use]
49pub fn sanitize_tool_replacement(replacement: &str) -> String {
50 let cleaned: String = replacement
51 .chars()
52 .filter(|c| !c.is_control() || matches!(c, '\n' | '\r' | '\t'))
53 .collect();
54 if cleaned.len() <= MAX_REPLACEMENT_LEN {
55 return cleaned;
56 }
57 let mut end = MAX_REPLACEMENT_LEN;
58 while end > 0 && !cleaned.is_char_boundary(end) {
59 end -= 1;
60 }
61 cleaned[..end].to_string()
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct ApprovalLogEntry {
67 pub timestamp: String,
68 pub tool_name: String,
69 pub arguments_summary: String,
70 pub decision: ApprovalResponse,
71 pub channel: String,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum ApprovalRequirement {
76 Prompt,
77 Approved,
78 NotRequired,
79}
80
81pub struct ApprovalManager {
98 auto_approve: HashSet<String>,
100 always_ask: HashSet<String>,
102 autonomy_level: AutonomyLevel,
104 non_interactive: bool,
107 non_interactive_shell_requires_approval: bool,
110 session_allowlist: Mutex<HashSet<String>>,
112 audit_log: Mutex<Vec<ApprovalLogEntry>>,
114}
115
116impl ApprovalManager {
117 pub fn from_risk_profile(risk_profile: &RiskProfileConfig) -> Self {
119 Self {
120 auto_approve: risk_profile.auto_approve.iter().cloned().collect(),
121 always_ask: risk_profile.always_ask.iter().cloned().collect(),
122 autonomy_level: risk_profile.level,
123 non_interactive: false,
124 non_interactive_shell_requires_approval: false,
125 session_allowlist: Mutex::new(HashSet::new()),
126 audit_log: Mutex::new(Vec::new()),
127 }
128 }
129
130 pub fn for_non_interactive(risk_profile: &RiskProfileConfig) -> Self {
136 Self {
137 auto_approve: risk_profile.auto_approve.iter().cloned().collect(),
138 always_ask: risk_profile.always_ask.iter().cloned().collect(),
139 autonomy_level: risk_profile.level,
140 non_interactive: true,
141 non_interactive_shell_requires_approval: false,
142 session_allowlist: Mutex::new(HashSet::new()),
143 audit_log: Mutex::new(Vec::new()),
144 }
145 }
146
147 pub fn for_non_interactive_backchannel(risk_profile: &RiskProfileConfig) -> Self {
154 Self {
155 auto_approve: risk_profile.auto_approve.iter().cloned().collect(),
156 always_ask: risk_profile.always_ask.iter().cloned().collect(),
157 autonomy_level: risk_profile.level,
158 non_interactive: true,
159 non_interactive_shell_requires_approval: true,
160 session_allowlist: Mutex::new(HashSet::new()),
161 audit_log: Mutex::new(Vec::new()),
162 }
163 }
164
165 pub fn is_non_interactive(&self) -> bool {
168 self.non_interactive
169 }
170
171 pub fn needs_approval(&self, tool_name: &str) -> bool {
175 self.approval_requirement(tool_name) == ApprovalRequirement::Prompt
176 }
177
178 pub fn approval_requirement(&self, tool_name: &str) -> ApprovalRequirement {
179 if self.autonomy_level == AutonomyLevel::Full {
181 return ApprovalRequirement::Approved;
182 }
183
184 if self.autonomy_level == AutonomyLevel::ReadOnly {
186 return ApprovalRequirement::NotRequired;
187 }
188
189 if self.always_ask.contains("*") || self.always_ask.contains(tool_name) {
191 return ApprovalRequirement::Prompt;
192 }
193
194 if self.non_interactive
200 && tool_name == "shell"
201 && !self.non_interactive_shell_requires_approval
202 {
203 return ApprovalRequirement::NotRequired;
204 }
205
206 if self.auto_approve.contains("*") || self.auto_approve.contains(tool_name) {
208 return ApprovalRequirement::Approved;
209 }
210
211 let allowlist = self.session_allowlist.lock();
213 if allowlist.contains(tool_name) {
214 return ApprovalRequirement::Approved;
215 }
216
217 ApprovalRequirement::Prompt
219 }
220
221 pub fn record_decision(
223 &self,
224 tool_name: &str,
225 args: &serde_json::Value,
226 decision: &ApprovalResponse,
227 channel: &str,
228 ) {
229 if *decision == ApprovalResponse::Always {
231 let mut allowlist = self.session_allowlist.lock();
232 allowlist.insert(tool_name.to_string());
233 }
234
235 let summary = summarize_args(args);
237 let entry = ApprovalLogEntry {
238 timestamp: Utc::now().to_rfc3339(),
239 tool_name: tool_name.to_string(),
240 arguments_summary: summary,
241 decision: decision.clone(),
242 channel: channel.to_string(),
243 };
244 let mut log = self.audit_log.lock();
245 log.push(entry);
246 }
247
248 pub fn audit_log(&self) -> Vec<ApprovalLogEntry> {
250 self.audit_log.lock().clone()
251 }
252
253 pub fn session_allowlist(&self) -> HashSet<String> {
255 self.session_allowlist.lock().clone()
256 }
257
258 pub fn prompt_cli(&self, request: &ApprovalRequest) -> ApprovalResponse {
263 prompt_cli_interactive(request)
264 }
265}
266
267fn prompt_cli_interactive(request: &ApprovalRequest) -> ApprovalResponse {
271 let summary = summarize_args(&request.arguments);
272 eprintln!();
273 eprintln!("🔧 Agent wants to execute: {}", request.tool_name);
274 eprintln!(" {summary}");
275 eprint!(" [Y]es / [N]o / [A]lways for {}: ", request.tool_name);
276 let _ = io::stderr().flush();
277
278 let stdin = io::stdin();
279 let mut line = String::new();
280 if stdin.lock().read_line(&mut line).is_err() {
281 return ApprovalResponse::No;
282 }
283
284 match line.trim().to_ascii_lowercase().as_str() {
285 "y" | "yes" => ApprovalResponse::Yes,
286 "a" | "always" => ApprovalResponse::Always,
287 _ => ApprovalResponse::No,
288 }
289}
290
291pub fn summarize_args(args: &serde_json::Value) -> String {
300 match args {
301 serde_json::Value::Object(map) => {
302 let mut parts: Vec<String> = Vec::with_capacity(map.len());
303
304 if let Some(v) = map.get("path") {
307 let val = if looks_like_secret_key("path") {
308 "[redacted]".to_string()
309 } else {
310 match v {
311 serde_json::Value::String(s) => truncate_for_summary(s, 80),
312 other => {
313 let s = other.to_string();
314 truncate_for_summary(&s, 80)
315 }
316 }
317 };
318 parts.push(format!("path: {val}"));
319 }
320
321 for (k, v) in map.iter() {
322 if k == "path" {
323 continue;
324 }
325 let val = if looks_like_secret_key(k) {
326 "[redacted]".to_string()
327 } else {
328 match v {
329 serde_json::Value::String(s) => truncate_for_summary(s, 80),
330 other => {
331 let s = other.to_string();
332 truncate_for_summary(&s, 80)
333 }
334 }
335 };
336 parts.push(format!("{k}: {val}"));
337 }
338 parts.join(", ")
339 }
340 other => {
341 let s = other.to_string();
342 truncate_for_summary(&s, 120)
343 }
344 }
345}
346
347fn looks_like_secret_key(key: &str) -> bool {
352 let lower = key.to_ascii_lowercase();
353 [
354 "secret",
355 "password",
356 "passwd",
357 "token",
358 "api_key",
359 "api-key",
360 "apikey",
361 "auth",
362 "bearer",
363 "private_key",
364 "private-key",
365 "privatekey",
366 "credential",
367 ]
368 .iter()
369 .any(|needle| lower.contains(needle))
370}
371
372fn truncate_for_summary(input: &str, max_chars: usize) -> String {
373 let mut chars = input.chars();
374 let truncated: String = chars.by_ref().take(max_chars).collect();
375 if chars.next().is_some() {
376 format!("{truncated}…")
377 } else {
378 input.to_string()
379 }
380}
381
382#[cfg(test)]
385mod tests {
386 use super::*;
387 use zeroclaw_config::schema::RiskProfileConfig;
388
389 #[test]
390 fn sanitize_replacement_strips_control_chars_keeps_whitespace() {
391 let dirty = "ok\u{0007}line\nnext\ttab\u{001b}[31m";
392 let clean = sanitize_tool_replacement(dirty);
393 assert_eq!(clean, "okline\nnext\ttab[31m");
394 }
395
396 #[test]
397 fn sanitize_replacement_truncates_on_char_boundary() {
398 let big = "é".repeat(MAX_REPLACEMENT_LEN); let clean = sanitize_tool_replacement(&big);
400 assert!(clean.len() <= MAX_REPLACEMENT_LEN);
401 assert!(clean.chars().all(|c| c == 'é'));
403 }
404
405 fn supervised_config() -> RiskProfileConfig {
406 RiskProfileConfig {
407 level: AutonomyLevel::Supervised,
408 auto_approve: vec!["file_read".into(), "memory_recall".into()],
409 always_ask: vec!["shell".into()],
410 ..RiskProfileConfig::default()
411 }
412 }
413
414 fn full_config() -> RiskProfileConfig {
415 RiskProfileConfig {
416 level: AutonomyLevel::Full,
417 ..RiskProfileConfig::default()
418 }
419 }
420
421 #[test]
424 fn auto_approve_tools_skip_prompt() {
425 let mgr = ApprovalManager::from_risk_profile(&supervised_config());
426 assert!(!mgr.needs_approval("file_read"));
427 assert!(!mgr.needs_approval("memory_recall"));
428 }
429
430 #[test]
431 fn always_ask_tools_always_prompt() {
432 let mgr = ApprovalManager::from_risk_profile(&supervised_config());
433 assert!(mgr.needs_approval("shell"));
434 }
435
436 #[test]
437 fn unknown_tool_needs_approval_in_supervised() {
438 let mgr = ApprovalManager::from_risk_profile(&supervised_config());
439 assert!(mgr.needs_approval("file_write"));
440 assert!(mgr.needs_approval("http_request"));
441 }
442
443 #[test]
444 fn full_autonomy_never_prompts() {
445 let mgr = ApprovalManager::from_risk_profile(&full_config());
446 assert!(!mgr.needs_approval("shell"));
447 assert!(!mgr.needs_approval("file_write"));
448 assert!(!mgr.needs_approval("anything"));
449 }
450
451 #[test]
452 fn readonly_never_prompts() {
453 let config = RiskProfileConfig {
454 level: AutonomyLevel::ReadOnly,
455 ..RiskProfileConfig::default()
456 };
457 let mgr = ApprovalManager::from_risk_profile(&config);
458 assert!(!mgr.needs_approval("shell"));
459 }
460
461 #[test]
464 fn always_response_adds_to_session_allowlist() {
465 let mgr = ApprovalManager::from_risk_profile(&supervised_config());
466 assert!(mgr.needs_approval("file_write"));
467
468 mgr.record_decision(
469 "file_write",
470 &serde_json::json!({"path": "test.txt"}),
471 &ApprovalResponse::Always,
472 "cli",
473 );
474
475 assert!(!mgr.needs_approval("file_write"));
477 }
478
479 #[test]
480 fn always_ask_overrides_session_allowlist() {
481 let mgr = ApprovalManager::from_risk_profile(&supervised_config());
482
483 mgr.record_decision(
485 "shell",
486 &serde_json::json!({"command": "ls"}),
487 &ApprovalResponse::Always,
488 "cli",
489 );
490
491 assert!(mgr.needs_approval("shell"));
493 }
494
495 #[test]
496 fn yes_response_does_not_add_to_allowlist() {
497 let mgr = ApprovalManager::from_risk_profile(&supervised_config());
498 mgr.record_decision(
499 "file_write",
500 &serde_json::json!({}),
501 &ApprovalResponse::Yes,
502 "cli",
503 );
504 assert!(mgr.needs_approval("file_write"));
505 }
506
507 #[test]
510 fn audit_log_records_decisions() {
511 let mgr = ApprovalManager::from_risk_profile(&supervised_config());
512
513 mgr.record_decision(
514 "shell",
515 &serde_json::json!({"command": "rm -rf ./build/"}),
516 &ApprovalResponse::No,
517 "cli",
518 );
519 mgr.record_decision(
520 "file_write",
521 &serde_json::json!({"path": "out.txt", "content": "hello"}),
522 &ApprovalResponse::Yes,
523 "cli",
524 );
525
526 let log = mgr.audit_log();
527 assert_eq!(log.len(), 2);
528 assert_eq!(log[0].tool_name, "shell");
529 assert_eq!(log[0].decision, ApprovalResponse::No);
530 assert_eq!(log[1].tool_name, "file_write");
531 assert_eq!(log[1].decision, ApprovalResponse::Yes);
532 }
533
534 #[test]
535 fn audit_log_contains_timestamp_and_channel() {
536 let mgr = ApprovalManager::from_risk_profile(&supervised_config());
537 mgr.record_decision(
538 "shell",
539 &serde_json::json!({"command": "ls"}),
540 &ApprovalResponse::Yes,
541 "telegram",
542 );
543
544 let log = mgr.audit_log();
545 assert_eq!(log.len(), 1);
546 assert!(!log[0].timestamp.is_empty());
547 assert_eq!(log[0].channel, "telegram");
548 }
549
550 #[test]
553 pub fn summarize_args_object() {
554 let args = serde_json::json!({"command": "ls -la", "cwd": "/tmp"});
555 let summary = summarize_args(&args);
556 assert!(summary.contains("command: ls -la"));
557 assert!(summary.contains("cwd: /tmp"));
558 }
559
560 #[test]
561 pub fn summarize_args_puts_path_first_for_file_tools() {
562 let args = serde_json::json!({
563 "path": "src/main.rs",
564 "old_string": "foo",
565 "new_string": "bar"
566 });
567 let summary = summarize_args(&args);
568 assert!(summary.starts_with("path: src/main.rs"));
569 assert!(summary.contains("old_string: foo"));
570 assert!(summary.contains("new_string: bar"));
571 }
572
573 #[test]
574 pub fn summarize_args_truncates_long_values() {
575 let long_val = "x".repeat(200);
576 let args = serde_json::json!({ "content": long_val });
577 let summary = summarize_args(&args);
578 assert!(summary.contains('…'));
579 assert!(summary.len() < 200);
580 }
581
582 #[test]
583 pub fn summarize_args_unicode_safe_truncation() {
584 let long_val = "🦀".repeat(120);
585 let args = serde_json::json!({ "content": long_val });
586 let summary = summarize_args(&args);
587 assert!(summary.contains("content:"));
588 assert!(summary.contains('…'));
589 }
590
591 #[test]
592 pub fn summarize_args_non_object() {
593 let args = serde_json::json!("just a string");
594 let summary = summarize_args(&args);
595 assert!(summary.contains("just a string"));
596 }
597
598 #[test]
601 fn non_interactive_manager_reports_non_interactive() {
602 let mgr = ApprovalManager::for_non_interactive(&supervised_config());
603 assert!(mgr.is_non_interactive());
604 }
605
606 #[test]
607 fn interactive_manager_reports_interactive() {
608 let mgr = ApprovalManager::from_risk_profile(&supervised_config());
609 assert!(!mgr.is_non_interactive());
610 }
611
612 #[test]
613 fn non_interactive_auto_approve_tools_skip_approval() {
614 let mgr = ApprovalManager::for_non_interactive(&supervised_config());
615 assert!(!mgr.needs_approval("file_read"));
617 assert!(!mgr.needs_approval("memory_recall"));
618 }
619
620 #[test]
621 fn non_interactive_shell_skips_outer_approval_by_default() {
622 let mgr = ApprovalManager::for_non_interactive(&RiskProfileConfig::default());
623 assert!(!mgr.needs_approval("shell"));
624 }
625
626 #[test]
627 fn non_interactive_backchannel_shell_requires_outer_approval() {
628 let mgr = ApprovalManager::for_non_interactive_backchannel(&RiskProfileConfig::default());
629 assert!(mgr.is_non_interactive());
630 assert!(mgr.needs_approval("shell"));
631 }
632
633 #[test]
634 fn non_interactive_always_ask_tools_need_approval() {
635 let mgr = ApprovalManager::for_non_interactive(&supervised_config());
636 assert!(mgr.needs_approval("shell"));
639 }
640
641 #[test]
642 fn non_interactive_unknown_tools_need_approval_in_supervised() {
643 let mgr = ApprovalManager::for_non_interactive(&supervised_config());
644 assert!(mgr.needs_approval("file_write"));
647 assert!(mgr.needs_approval("http_request"));
648 }
649
650 #[test]
651 fn non_interactive_full_autonomy_never_needs_approval() {
652 let mgr = ApprovalManager::for_non_interactive(&full_config());
653 assert!(!mgr.needs_approval("shell"));
655 assert!(!mgr.needs_approval("file_write"));
656 assert!(!mgr.needs_approval("anything"));
657 }
658
659 #[test]
660 fn non_interactive_readonly_never_needs_approval() {
661 let config = RiskProfileConfig {
662 level: AutonomyLevel::ReadOnly,
663 ..RiskProfileConfig::default()
664 };
665 let mgr = ApprovalManager::for_non_interactive(&config);
666 assert!(!mgr.needs_approval("shell"));
668 }
669
670 #[test]
671 fn non_interactive_session_allowlist_still_works() {
672 let mgr = ApprovalManager::for_non_interactive(&supervised_config());
673 assert!(mgr.needs_approval("file_write"));
674
675 mgr.record_decision(
678 "file_write",
679 &serde_json::json!({"path": "test.txt"}),
680 &ApprovalResponse::Always,
681 "telegram",
682 );
683
684 assert!(!mgr.needs_approval("file_write"));
685 }
686
687 #[test]
688 fn non_interactive_always_ask_overrides_session_allowlist() {
689 let mgr = ApprovalManager::for_non_interactive(&supervised_config());
690
691 mgr.record_decision(
692 "shell",
693 &serde_json::json!({"command": "ls"}),
694 &ApprovalResponse::Always,
695 "telegram",
696 );
697
698 assert!(mgr.needs_approval("shell"));
700 }
701
702 #[test]
705 fn approval_response_serde_roundtrip() {
706 let json = serde_json::to_string(&ApprovalResponse::Always).unwrap();
707 assert_eq!(json, "\"always\"");
708 let parsed: ApprovalResponse = serde_json::from_str("\"no\"").unwrap();
709 assert_eq!(parsed, ApprovalResponse::No);
710 let json =
711 serde_json::to_string(&ApprovalResponse::ReplaceWith("foo".to_string())).unwrap();
712 let parsed: ApprovalResponse = serde_json::from_str(&json).unwrap();
713 assert_eq!(parsed, ApprovalResponse::ReplaceWith("foo".to_string()));
714 }
715
716 #[test]
719 fn approval_request_serde() {
720 let req = ApprovalRequest {
721 tool_name: "shell".into(),
722 arguments: serde_json::json!({"command": "echo hi"}),
723 };
724 let json = serde_json::to_string(&req).unwrap();
725 let parsed: ApprovalRequest = serde_json::from_str(&json).unwrap();
726 assert_eq!(parsed.tool_name, "shell");
727 }
728
729 #[test]
732 fn non_interactive_allows_default_auto_approve_tools() {
733 let config = RiskProfileConfig::default();
734 let mgr = ApprovalManager::for_non_interactive(&config);
735
736 for tool in &config.auto_approve {
737 assert!(
738 !mgr.needs_approval(tool),
739 "default auto_approve tool '{tool}' should not need approval in non-interactive mode"
740 );
741 }
742 }
743
744 #[test]
745 fn non_interactive_denies_unknown_tools() {
746 let config = RiskProfileConfig::default();
747 let mgr = ApprovalManager::for_non_interactive(&config);
748 assert!(
749 mgr.needs_approval("some_unknown_tool"),
750 "unknown tool should need approval"
751 );
752 }
753
754 #[test]
755 fn non_interactive_weather_is_auto_approved() {
756 let config = RiskProfileConfig::default();
757 let mgr = ApprovalManager::for_non_interactive(&config);
758 assert!(
759 !mgr.needs_approval("weather"),
760 "weather tool must not need approval — it is in the default auto_approve list"
761 );
762 }
763
764 #[test]
765 fn always_ask_overrides_auto_approve() {
766 let config = RiskProfileConfig {
767 always_ask: vec!["weather".into()],
768 ..RiskProfileConfig::default()
769 };
770 let mgr = ApprovalManager::for_non_interactive(&config);
771 assert!(
772 mgr.needs_approval("weather"),
773 "always_ask must override auto_approve"
774 );
775 }
776
777 #[test]
780 fn channel_approve_maps_to_yes() {
781 use zeroclaw_api::channel::ChannelApprovalResponse;
782 let mapped = match ChannelApprovalResponse::Approve {
783 ChannelApprovalResponse::Approve => ApprovalResponse::Yes,
784 ChannelApprovalResponse::AlwaysApprove => ApprovalResponse::Always,
785 ChannelApprovalResponse::Deny => ApprovalResponse::No,
786 ChannelApprovalResponse::DenyWithEdit { replacement } => {
787 ApprovalResponse::ReplaceWith(replacement)
788 }
789 };
790 assert_eq!(mapped, ApprovalResponse::Yes);
791 }
792
793 #[test]
794 fn channel_always_approve_maps_to_always() {
795 use zeroclaw_api::channel::ChannelApprovalResponse;
796 let mapped = match ChannelApprovalResponse::AlwaysApprove {
797 ChannelApprovalResponse::Approve => ApprovalResponse::Yes,
798 ChannelApprovalResponse::AlwaysApprove => ApprovalResponse::Always,
799 ChannelApprovalResponse::Deny => ApprovalResponse::No,
800 ChannelApprovalResponse::DenyWithEdit { replacement } => {
801 ApprovalResponse::ReplaceWith(replacement)
802 }
803 };
804 assert_eq!(mapped, ApprovalResponse::Always);
805 }
806
807 #[test]
808 fn channel_deny_maps_to_no() {
809 use zeroclaw_api::channel::ChannelApprovalResponse;
810 let mapped = match ChannelApprovalResponse::Deny {
811 ChannelApprovalResponse::Approve => ApprovalResponse::Yes,
812 ChannelApprovalResponse::AlwaysApprove => ApprovalResponse::Always,
813 ChannelApprovalResponse::Deny => ApprovalResponse::No,
814 ChannelApprovalResponse::DenyWithEdit { replacement } => {
815 ApprovalResponse::ReplaceWith(replacement)
816 }
817 };
818 assert_eq!(mapped, ApprovalResponse::No);
819 }
820
821 #[test]
822 fn channel_deny_with_edit_maps_to_replace_with() {
823 use zeroclaw_api::channel::ChannelApprovalResponse;
824 let mapped = match (ChannelApprovalResponse::DenyWithEdit {
825 replacement: "x".to_string(),
826 }) {
827 ChannelApprovalResponse::Approve => ApprovalResponse::Yes,
828 ChannelApprovalResponse::AlwaysApprove => ApprovalResponse::Always,
829 ChannelApprovalResponse::Deny => ApprovalResponse::No,
830 ChannelApprovalResponse::DenyWithEdit { replacement } => {
831 ApprovalResponse::ReplaceWith(replacement)
832 }
833 };
834 assert!(matches!(mapped, ApprovalResponse::ReplaceWith(s) if s == "x"));
835 }
836
837 #[test]
838 fn replace_with_is_not_yes_or_no() {
839 let r = ApprovalResponse::ReplaceWith("new text".to_string());
840 assert_ne!(r, ApprovalResponse::Yes);
841 assert_ne!(r, ApprovalResponse::No);
842 }
843
844 #[test]
845 fn channel_approval_request_serde_roundtrip() {
846 use zeroclaw_api::channel::ChannelApprovalRequest;
847 let req = ChannelApprovalRequest {
848 tool_name: "shell".into(),
849 arguments_summary: "command: ls -la".into(),
850 raw_arguments: None,
851 };
852 let json = serde_json::to_string(&req).unwrap();
853 let parsed: ChannelApprovalRequest = serde_json::from_str(&json).unwrap();
854 assert_eq!(parsed.tool_name, "shell");
855 assert_eq!(parsed.arguments_summary, "command: ls -la");
856 }
857
858 #[test]
859 fn channel_approval_response_serde_roundtrip() {
860 use zeroclaw_api::channel::ChannelApprovalResponse;
861 let json = serde_json::to_string(&ChannelApprovalResponse::AlwaysApprove).unwrap();
864 assert_eq!(json, "\"always\"");
865 let parsed: ChannelApprovalResponse = serde_json::from_str("\"always\"").unwrap();
866 assert_eq!(parsed, ChannelApprovalResponse::AlwaysApprove);
867 let parsed: ChannelApprovalResponse = serde_json::from_str("\"deny\"").unwrap();
868 assert_eq!(parsed, ChannelApprovalResponse::Deny);
869 }
870
871 #[test]
874 fn summarize_args_redacts_known_secret_key_names() {
875 let args = serde_json::json!({
876 "endpoint": "https://api.example.com",
877 "api_key": "sk-very-secret-key-value",
878 "oauth_token": "oauth-secret",
879 "client_secret": "client-secret",
880 "password": "hunter2",
881 "private_key": "-----BEGIN PRIVATE KEY-----abc",
882 "bearer_token": "bearer-thing",
883 });
884 let summary = summarize_args(&args);
885 for needle in [
886 "sk-very-secret-key-value",
887 "oauth-secret",
888 "client-secret",
889 "hunter2",
890 "-----BEGIN PRIVATE KEY-----",
891 "bearer-thing",
892 ] {
893 assert!(
894 !summary.contains(needle),
895 "summary leaked secret value {needle:?}: {summary}"
896 );
897 }
898 assert!(summary.contains("endpoint:"));
899 assert!(summary.contains("api.example.com"));
900 }
901
902 #[test]
903 fn summarize_args_keeps_non_secret_values() {
904 let args = serde_json::json!({
905 "path": "/tmp/file.txt",
906 "limit": 42,
907 });
908 let summary = summarize_args(&args);
909 assert!(summary.contains("/tmp/file.txt"));
910 assert!(summary.contains("42"));
911 }
912
913 #[test]
914 fn summarize_args_redaction_is_case_insensitive_and_substring_aware() {
915 let args = serde_json::json!({
916 "X-API-Key": "hdrsecret",
917 "DBPassword": "dbpw",
918 "AuthHeader": "auth-thing",
919 });
920 let summary = summarize_args(&args);
921 for leaked in ["hdrsecret", "dbpw", "auth-thing"] {
922 assert!(
923 !summary.contains(leaked),
924 "redaction missed {leaked:?}: {summary}"
925 );
926 }
927 }
928}