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, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "lowercase")]
26pub enum ApprovalResponse {
27 Yes,
29 No,
31 Always,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct ApprovalLogEntry {
38 pub timestamp: String,
39 pub tool_name: String,
40 pub arguments_summary: String,
41 pub decision: ApprovalResponse,
42 pub channel: String,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum ApprovalRequirement {
47 Prompt,
48 Approved,
49 NotRequired,
50}
51
52pub struct ApprovalManager {
69 auto_approve: HashSet<String>,
71 always_ask: HashSet<String>,
73 autonomy_level: AutonomyLevel,
75 non_interactive: bool,
78 non_interactive_shell_requires_approval: bool,
81 session_allowlist: Mutex<HashSet<String>>,
83 audit_log: Mutex<Vec<ApprovalLogEntry>>,
85}
86
87impl ApprovalManager {
88 pub fn from_risk_profile(risk_profile: &RiskProfileConfig) -> Self {
90 Self {
91 auto_approve: risk_profile.auto_approve.iter().cloned().collect(),
92 always_ask: risk_profile.always_ask.iter().cloned().collect(),
93 autonomy_level: risk_profile.level,
94 non_interactive: false,
95 non_interactive_shell_requires_approval: false,
96 session_allowlist: Mutex::new(HashSet::new()),
97 audit_log: Mutex::new(Vec::new()),
98 }
99 }
100
101 pub fn for_non_interactive(risk_profile: &RiskProfileConfig) -> Self {
107 Self {
108 auto_approve: risk_profile.auto_approve.iter().cloned().collect(),
109 always_ask: risk_profile.always_ask.iter().cloned().collect(),
110 autonomy_level: risk_profile.level,
111 non_interactive: true,
112 non_interactive_shell_requires_approval: false,
113 session_allowlist: Mutex::new(HashSet::new()),
114 audit_log: Mutex::new(Vec::new()),
115 }
116 }
117
118 pub fn for_non_interactive_backchannel(risk_profile: &RiskProfileConfig) -> Self {
125 Self {
126 auto_approve: risk_profile.auto_approve.iter().cloned().collect(),
127 always_ask: risk_profile.always_ask.iter().cloned().collect(),
128 autonomy_level: risk_profile.level,
129 non_interactive: true,
130 non_interactive_shell_requires_approval: true,
131 session_allowlist: Mutex::new(HashSet::new()),
132 audit_log: Mutex::new(Vec::new()),
133 }
134 }
135
136 pub fn is_non_interactive(&self) -> bool {
139 self.non_interactive
140 }
141
142 pub fn needs_approval(&self, tool_name: &str) -> bool {
146 self.approval_requirement(tool_name) == ApprovalRequirement::Prompt
147 }
148
149 pub fn approval_requirement(&self, tool_name: &str) -> ApprovalRequirement {
150 if self.autonomy_level == AutonomyLevel::Full {
152 return ApprovalRequirement::Approved;
153 }
154
155 if self.autonomy_level == AutonomyLevel::ReadOnly {
157 return ApprovalRequirement::NotRequired;
158 }
159
160 if self.always_ask.contains("*") || self.always_ask.contains(tool_name) {
162 return ApprovalRequirement::Prompt;
163 }
164
165 if self.non_interactive
171 && tool_name == "shell"
172 && !self.non_interactive_shell_requires_approval
173 {
174 return ApprovalRequirement::NotRequired;
175 }
176
177 if self.auto_approve.contains("*") || self.auto_approve.contains(tool_name) {
179 return ApprovalRequirement::Approved;
180 }
181
182 let allowlist = self.session_allowlist.lock();
184 if allowlist.contains(tool_name) {
185 return ApprovalRequirement::Approved;
186 }
187
188 ApprovalRequirement::Prompt
190 }
191
192 pub fn record_decision(
194 &self,
195 tool_name: &str,
196 args: &serde_json::Value,
197 decision: ApprovalResponse,
198 channel: &str,
199 ) {
200 if decision == ApprovalResponse::Always {
202 let mut allowlist = self.session_allowlist.lock();
203 allowlist.insert(tool_name.to_string());
204 }
205
206 let summary = summarize_args(args);
208 let entry = ApprovalLogEntry {
209 timestamp: Utc::now().to_rfc3339(),
210 tool_name: tool_name.to_string(),
211 arguments_summary: summary,
212 decision,
213 channel: channel.to_string(),
214 };
215 let mut log = self.audit_log.lock();
216 log.push(entry);
217 }
218
219 pub fn audit_log(&self) -> Vec<ApprovalLogEntry> {
221 self.audit_log.lock().clone()
222 }
223
224 pub fn session_allowlist(&self) -> HashSet<String> {
226 self.session_allowlist.lock().clone()
227 }
228
229 pub fn prompt_cli(&self, request: &ApprovalRequest) -> ApprovalResponse {
234 prompt_cli_interactive(request)
235 }
236}
237
238fn prompt_cli_interactive(request: &ApprovalRequest) -> ApprovalResponse {
242 let summary = summarize_args(&request.arguments);
243 eprintln!();
244 eprintln!("🔧 Agent wants to execute: {}", request.tool_name);
245 eprintln!(" {summary}");
246 eprint!(" [Y]es / [N]o / [A]lways for {}: ", request.tool_name);
247 let _ = io::stderr().flush();
248
249 let stdin = io::stdin();
250 let mut line = String::new();
251 if stdin.lock().read_line(&mut line).is_err() {
252 return ApprovalResponse::No;
253 }
254
255 match line.trim().to_ascii_lowercase().as_str() {
256 "y" | "yes" => ApprovalResponse::Yes,
257 "a" | "always" => ApprovalResponse::Always,
258 _ => ApprovalResponse::No,
259 }
260}
261
262pub fn summarize_args(args: &serde_json::Value) -> String {
271 match args {
272 serde_json::Value::Object(map) => {
273 let parts: Vec<String> = map
274 .iter()
275 .map(|(k, v)| {
276 let val = if looks_like_secret_key(k) {
277 "[redacted]".to_string()
278 } else {
279 match v {
280 serde_json::Value::String(s) => truncate_for_summary(s, 80),
281 other => {
282 let s = other.to_string();
283 truncate_for_summary(&s, 80)
284 }
285 }
286 };
287 format!("{k}: {val}")
288 })
289 .collect();
290 parts.join(", ")
291 }
292 other => {
293 let s = other.to_string();
294 truncate_for_summary(&s, 120)
295 }
296 }
297}
298
299fn looks_like_secret_key(key: &str) -> bool {
304 let lower = key.to_ascii_lowercase();
305 [
306 "secret",
307 "password",
308 "passwd",
309 "token",
310 "api_key",
311 "api-key",
312 "apikey",
313 "auth",
314 "bearer",
315 "private_key",
316 "private-key",
317 "privatekey",
318 "credential",
319 ]
320 .iter()
321 .any(|needle| lower.contains(needle))
322}
323
324fn truncate_for_summary(input: &str, max_chars: usize) -> String {
325 let mut chars = input.chars();
326 let truncated: String = chars.by_ref().take(max_chars).collect();
327 if chars.next().is_some() {
328 format!("{truncated}…")
329 } else {
330 input.to_string()
331 }
332}
333
334#[cfg(test)]
337mod tests {
338 use super::*;
339 use zeroclaw_config::schema::RiskProfileConfig;
340
341 fn supervised_config() -> RiskProfileConfig {
342 RiskProfileConfig {
343 level: AutonomyLevel::Supervised,
344 auto_approve: vec!["file_read".into(), "memory_recall".into()],
345 always_ask: vec!["shell".into()],
346 ..RiskProfileConfig::default()
347 }
348 }
349
350 fn full_config() -> RiskProfileConfig {
351 RiskProfileConfig {
352 level: AutonomyLevel::Full,
353 ..RiskProfileConfig::default()
354 }
355 }
356
357 #[test]
360 fn auto_approve_tools_skip_prompt() {
361 let mgr = ApprovalManager::from_risk_profile(&supervised_config());
362 assert!(!mgr.needs_approval("file_read"));
363 assert!(!mgr.needs_approval("memory_recall"));
364 }
365
366 #[test]
367 fn always_ask_tools_always_prompt() {
368 let mgr = ApprovalManager::from_risk_profile(&supervised_config());
369 assert!(mgr.needs_approval("shell"));
370 }
371
372 #[test]
373 fn unknown_tool_needs_approval_in_supervised() {
374 let mgr = ApprovalManager::from_risk_profile(&supervised_config());
375 assert!(mgr.needs_approval("file_write"));
376 assert!(mgr.needs_approval("http_request"));
377 }
378
379 #[test]
380 fn full_autonomy_never_prompts() {
381 let mgr = ApprovalManager::from_risk_profile(&full_config());
382 assert!(!mgr.needs_approval("shell"));
383 assert!(!mgr.needs_approval("file_write"));
384 assert!(!mgr.needs_approval("anything"));
385 }
386
387 #[test]
388 fn readonly_never_prompts() {
389 let config = RiskProfileConfig {
390 level: AutonomyLevel::ReadOnly,
391 ..RiskProfileConfig::default()
392 };
393 let mgr = ApprovalManager::from_risk_profile(&config);
394 assert!(!mgr.needs_approval("shell"));
395 }
396
397 #[test]
400 fn always_response_adds_to_session_allowlist() {
401 let mgr = ApprovalManager::from_risk_profile(&supervised_config());
402 assert!(mgr.needs_approval("file_write"));
403
404 mgr.record_decision(
405 "file_write",
406 &serde_json::json!({"path": "test.txt"}),
407 ApprovalResponse::Always,
408 "cli",
409 );
410
411 assert!(!mgr.needs_approval("file_write"));
413 }
414
415 #[test]
416 fn always_ask_overrides_session_allowlist() {
417 let mgr = ApprovalManager::from_risk_profile(&supervised_config());
418
419 mgr.record_decision(
421 "shell",
422 &serde_json::json!({"command": "ls"}),
423 ApprovalResponse::Always,
424 "cli",
425 );
426
427 assert!(mgr.needs_approval("shell"));
429 }
430
431 #[test]
432 fn yes_response_does_not_add_to_allowlist() {
433 let mgr = ApprovalManager::from_risk_profile(&supervised_config());
434 mgr.record_decision(
435 "file_write",
436 &serde_json::json!({}),
437 ApprovalResponse::Yes,
438 "cli",
439 );
440 assert!(mgr.needs_approval("file_write"));
441 }
442
443 #[test]
446 fn audit_log_records_decisions() {
447 let mgr = ApprovalManager::from_risk_profile(&supervised_config());
448
449 mgr.record_decision(
450 "shell",
451 &serde_json::json!({"command": "rm -rf ./build/"}),
452 ApprovalResponse::No,
453 "cli",
454 );
455 mgr.record_decision(
456 "file_write",
457 &serde_json::json!({"path": "out.txt", "content": "hello"}),
458 ApprovalResponse::Yes,
459 "cli",
460 );
461
462 let log = mgr.audit_log();
463 assert_eq!(log.len(), 2);
464 assert_eq!(log[0].tool_name, "shell");
465 assert_eq!(log[0].decision, ApprovalResponse::No);
466 assert_eq!(log[1].tool_name, "file_write");
467 assert_eq!(log[1].decision, ApprovalResponse::Yes);
468 }
469
470 #[test]
471 fn audit_log_contains_timestamp_and_channel() {
472 let mgr = ApprovalManager::from_risk_profile(&supervised_config());
473 mgr.record_decision(
474 "shell",
475 &serde_json::json!({"command": "ls"}),
476 ApprovalResponse::Yes,
477 "telegram",
478 );
479
480 let log = mgr.audit_log();
481 assert_eq!(log.len(), 1);
482 assert!(!log[0].timestamp.is_empty());
483 assert_eq!(log[0].channel, "telegram");
484 }
485
486 #[test]
489 pub fn summarize_args_object() {
490 let args = serde_json::json!({"command": "ls -la", "cwd": "/tmp"});
491 let summary = summarize_args(&args);
492 assert!(summary.contains("command: ls -la"));
493 assert!(summary.contains("cwd: /tmp"));
494 }
495
496 #[test]
497 pub fn summarize_args_truncates_long_values() {
498 let long_val = "x".repeat(200);
499 let args = serde_json::json!({ "content": long_val });
500 let summary = summarize_args(&args);
501 assert!(summary.contains('…'));
502 assert!(summary.len() < 200);
503 }
504
505 #[test]
506 pub fn summarize_args_unicode_safe_truncation() {
507 let long_val = "🦀".repeat(120);
508 let args = serde_json::json!({ "content": long_val });
509 let summary = summarize_args(&args);
510 assert!(summary.contains("content:"));
511 assert!(summary.contains('…'));
512 }
513
514 #[test]
515 pub fn summarize_args_non_object() {
516 let args = serde_json::json!("just a string");
517 let summary = summarize_args(&args);
518 assert!(summary.contains("just a string"));
519 }
520
521 #[test]
524 fn non_interactive_manager_reports_non_interactive() {
525 let mgr = ApprovalManager::for_non_interactive(&supervised_config());
526 assert!(mgr.is_non_interactive());
527 }
528
529 #[test]
530 fn interactive_manager_reports_interactive() {
531 let mgr = ApprovalManager::from_risk_profile(&supervised_config());
532 assert!(!mgr.is_non_interactive());
533 }
534
535 #[test]
536 fn non_interactive_auto_approve_tools_skip_approval() {
537 let mgr = ApprovalManager::for_non_interactive(&supervised_config());
538 assert!(!mgr.needs_approval("file_read"));
540 assert!(!mgr.needs_approval("memory_recall"));
541 }
542
543 #[test]
544 fn non_interactive_shell_skips_outer_approval_by_default() {
545 let mgr = ApprovalManager::for_non_interactive(&RiskProfileConfig::default());
546 assert!(!mgr.needs_approval("shell"));
547 }
548
549 #[test]
550 fn non_interactive_backchannel_shell_requires_outer_approval() {
551 let mgr = ApprovalManager::for_non_interactive_backchannel(&RiskProfileConfig::default());
552 assert!(mgr.is_non_interactive());
553 assert!(mgr.needs_approval("shell"));
554 }
555
556 #[test]
557 fn non_interactive_always_ask_tools_need_approval() {
558 let mgr = ApprovalManager::for_non_interactive(&supervised_config());
559 assert!(mgr.needs_approval("shell"));
562 }
563
564 #[test]
565 fn non_interactive_unknown_tools_need_approval_in_supervised() {
566 let mgr = ApprovalManager::for_non_interactive(&supervised_config());
567 assert!(mgr.needs_approval("file_write"));
570 assert!(mgr.needs_approval("http_request"));
571 }
572
573 #[test]
574 fn non_interactive_full_autonomy_never_needs_approval() {
575 let mgr = ApprovalManager::for_non_interactive(&full_config());
576 assert!(!mgr.needs_approval("shell"));
578 assert!(!mgr.needs_approval("file_write"));
579 assert!(!mgr.needs_approval("anything"));
580 }
581
582 #[test]
583 fn non_interactive_readonly_never_needs_approval() {
584 let config = RiskProfileConfig {
585 level: AutonomyLevel::ReadOnly,
586 ..RiskProfileConfig::default()
587 };
588 let mgr = ApprovalManager::for_non_interactive(&config);
589 assert!(!mgr.needs_approval("shell"));
591 }
592
593 #[test]
594 fn non_interactive_session_allowlist_still_works() {
595 let mgr = ApprovalManager::for_non_interactive(&supervised_config());
596 assert!(mgr.needs_approval("file_write"));
597
598 mgr.record_decision(
601 "file_write",
602 &serde_json::json!({"path": "test.txt"}),
603 ApprovalResponse::Always,
604 "telegram",
605 );
606
607 assert!(!mgr.needs_approval("file_write"));
608 }
609
610 #[test]
611 fn non_interactive_always_ask_overrides_session_allowlist() {
612 let mgr = ApprovalManager::for_non_interactive(&supervised_config());
613
614 mgr.record_decision(
615 "shell",
616 &serde_json::json!({"command": "ls"}),
617 ApprovalResponse::Always,
618 "telegram",
619 );
620
621 assert!(mgr.needs_approval("shell"));
623 }
624
625 #[test]
628 fn approval_response_serde_roundtrip() {
629 let json = serde_json::to_string(&ApprovalResponse::Always).unwrap();
630 assert_eq!(json, "\"always\"");
631 let parsed: ApprovalResponse = serde_json::from_str("\"no\"").unwrap();
632 assert_eq!(parsed, ApprovalResponse::No);
633 }
634
635 #[test]
638 fn approval_request_serde() {
639 let req = ApprovalRequest {
640 tool_name: "shell".into(),
641 arguments: serde_json::json!({"command": "echo hi"}),
642 };
643 let json = serde_json::to_string(&req).unwrap();
644 let parsed: ApprovalRequest = serde_json::from_str(&json).unwrap();
645 assert_eq!(parsed.tool_name, "shell");
646 }
647
648 #[test]
651 fn non_interactive_allows_default_auto_approve_tools() {
652 let config = RiskProfileConfig::default();
653 let mgr = ApprovalManager::for_non_interactive(&config);
654
655 for tool in &config.auto_approve {
656 assert!(
657 !mgr.needs_approval(tool),
658 "default auto_approve tool '{tool}' should not need approval in non-interactive mode"
659 );
660 }
661 }
662
663 #[test]
664 fn non_interactive_denies_unknown_tools() {
665 let config = RiskProfileConfig::default();
666 let mgr = ApprovalManager::for_non_interactive(&config);
667 assert!(
668 mgr.needs_approval("some_unknown_tool"),
669 "unknown tool should need approval"
670 );
671 }
672
673 #[test]
674 fn non_interactive_weather_is_auto_approved() {
675 let config = RiskProfileConfig::default();
676 let mgr = ApprovalManager::for_non_interactive(&config);
677 assert!(
678 !mgr.needs_approval("weather"),
679 "weather tool must not need approval — it is in the default auto_approve list"
680 );
681 }
682
683 #[test]
684 fn always_ask_overrides_auto_approve() {
685 let config = RiskProfileConfig {
686 always_ask: vec!["weather".into()],
687 ..RiskProfileConfig::default()
688 };
689 let mgr = ApprovalManager::for_non_interactive(&config);
690 assert!(
691 mgr.needs_approval("weather"),
692 "always_ask must override auto_approve"
693 );
694 }
695
696 #[test]
699 fn channel_approve_maps_to_yes() {
700 use zeroclaw_api::channel::ChannelApprovalResponse;
701 let mapped = match ChannelApprovalResponse::Approve {
702 ChannelApprovalResponse::Approve => ApprovalResponse::Yes,
703 ChannelApprovalResponse::AlwaysApprove => ApprovalResponse::Always,
704 ChannelApprovalResponse::Deny => ApprovalResponse::No,
705 };
706 assert_eq!(mapped, ApprovalResponse::Yes);
707 }
708
709 #[test]
710 fn channel_always_approve_maps_to_always() {
711 use zeroclaw_api::channel::ChannelApprovalResponse;
712 let mapped = match ChannelApprovalResponse::AlwaysApprove {
713 ChannelApprovalResponse::Approve => ApprovalResponse::Yes,
714 ChannelApprovalResponse::AlwaysApprove => ApprovalResponse::Always,
715 ChannelApprovalResponse::Deny => ApprovalResponse::No,
716 };
717 assert_eq!(mapped, ApprovalResponse::Always);
718 }
719
720 #[test]
721 fn channel_deny_maps_to_no() {
722 use zeroclaw_api::channel::ChannelApprovalResponse;
723 let mapped = match ChannelApprovalResponse::Deny {
724 ChannelApprovalResponse::Approve => ApprovalResponse::Yes,
725 ChannelApprovalResponse::AlwaysApprove => ApprovalResponse::Always,
726 ChannelApprovalResponse::Deny => ApprovalResponse::No,
727 };
728 assert_eq!(mapped, ApprovalResponse::No);
729 }
730
731 #[test]
732 fn channel_approval_request_serde_roundtrip() {
733 use zeroclaw_api::channel::ChannelApprovalRequest;
734 let req = ChannelApprovalRequest {
735 tool_name: "shell".into(),
736 arguments_summary: "command: ls -la".into(),
737 raw_arguments: None,
738 };
739 let json = serde_json::to_string(&req).unwrap();
740 let parsed: ChannelApprovalRequest = serde_json::from_str(&json).unwrap();
741 assert_eq!(parsed.tool_name, "shell");
742 assert_eq!(parsed.arguments_summary, "command: ls -la");
743 }
744
745 #[test]
746 fn channel_approval_response_serde_roundtrip() {
747 use zeroclaw_api::channel::ChannelApprovalResponse;
748 let json = serde_json::to_string(&ChannelApprovalResponse::AlwaysApprove).unwrap();
751 assert_eq!(json, "\"always\"");
752 let parsed: ChannelApprovalResponse = serde_json::from_str("\"always\"").unwrap();
753 assert_eq!(parsed, ChannelApprovalResponse::AlwaysApprove);
754 let parsed: ChannelApprovalResponse = serde_json::from_str("\"deny\"").unwrap();
755 assert_eq!(parsed, ChannelApprovalResponse::Deny);
756 }
757
758 #[test]
761 fn summarize_args_redacts_known_secret_key_names() {
762 let args = serde_json::json!({
763 "endpoint": "https://api.example.com",
764 "api_key": "sk-very-secret-key-value",
765 "oauth_token": "oauth-secret",
766 "client_secret": "client-secret",
767 "password": "hunter2",
768 "private_key": "-----BEGIN PRIVATE KEY-----abc",
769 "bearer_token": "bearer-thing",
770 });
771 let summary = summarize_args(&args);
772 for needle in [
773 "sk-very-secret-key-value",
774 "oauth-secret",
775 "client-secret",
776 "hunter2",
777 "-----BEGIN PRIVATE KEY-----",
778 "bearer-thing",
779 ] {
780 assert!(
781 !summary.contains(needle),
782 "summary leaked secret value {needle:?}: {summary}"
783 );
784 }
785 assert!(summary.contains("endpoint:"));
786 assert!(summary.contains("api.example.com"));
787 }
788
789 #[test]
790 fn summarize_args_keeps_non_secret_values() {
791 let args = serde_json::json!({
792 "path": "/tmp/file.txt",
793 "limit": 42,
794 });
795 let summary = summarize_args(&args);
796 assert!(summary.contains("/tmp/file.txt"));
797 assert!(summary.contains("42"));
798 }
799
800 #[test]
801 fn summarize_args_redaction_is_case_insensitive_and_substring_aware() {
802 let args = serde_json::json!({
803 "X-API-Key": "hdrsecret",
804 "DBPassword": "dbpw",
805 "AuthHeader": "auth-thing",
806 });
807 let summary = summarize_args(&args);
808 for leaked in ["hdrsecret", "dbpw", "auth-thing"] {
809 assert!(
810 !summary.contains(leaked),
811 "redaction missed {leaked:?}: {summary}"
812 );
813 }
814 }
815}