Skip to main content

zeroclaw_runtime/approval/
mod.rs

1//! Interactive approval workflow for supervised mode.
2//!
3//! Provides a pre-execution hook that prompts the user before tool calls,
4//! with session-scoped "Always" allowlists and audit logging.
5
6use 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// ── Types ────────────────────────────────────────────────────────
15
16/// A request to approve a tool call before execution.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ApprovalRequest {
19    pub tool_name: String,
20    pub arguments: serde_json::Value,
21}
22
23/// The user's response to an approval request.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "lowercase")]
26pub enum ApprovalResponse {
27    /// Execute this one call.
28    Yes,
29    /// Deny this call.
30    No,
31    /// Execute and add tool to session-scoped allowlist.
32    Always,
33}
34
35/// A single audit log entry for an approval decision.
36#[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
52// ── ApprovalManager ──────────────────────────────────────────────
53
54/// Manages the approval workflow for tool calls.
55///
56/// - Checks config-level `auto_approve` / `always_ask` lists
57/// - Maintains a session-scoped "always" allowlist
58/// - Records an audit trail of all decisions
59///
60/// Two modes:
61/// - **Interactive** (CLI): tools needing approval trigger a stdin prompt.
62/// - **Non-interactive** (channels): tools needing approval are auto-denied
63///   because there is no interactive operator to approve them. `auto_approve`
64///   policy is still enforced, and `always_ask` / supervised-default tools are
65///   denied rather than silently allowed.
66/// - **Non-interactive back-channel** (ACP/WS): tools needing approval are sent
67///   through a client approval channel instead of trusting tool arguments.
68pub struct ApprovalManager {
69    /// Tools that never need approval (from config).
70    auto_approve: HashSet<String>,
71    /// Tools that always need approval, ignoring session allowlist.
72    always_ask: HashSet<String>,
73    /// Autonomy level from config.
74    autonomy_level: AutonomyLevel,
75    /// When `true`, tools that would require interactive approval are
76    /// auto-denied instead. Used for channel-driven (non-CLI) runs.
77    non_interactive: bool,
78    /// When `true`, shell calls in non-interactive mode still enter the outer
79    /// approval flow because a real client approval channel exists.
80    non_interactive_shell_requires_approval: bool,
81    /// Session-scoped allowlist built from "Always" responses.
82    session_allowlist: Mutex<HashSet<String>>,
83    /// Audit trail of approval decisions.
84    audit_log: Mutex<Vec<ApprovalLogEntry>>,
85}
86
87impl ApprovalManager {
88    /// Create an interactive (CLI) approval manager from a risk profile.
89    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    /// Create a non-interactive approval manager for channel-driven runs.
102    ///
103    /// Enforces the same `auto_approve` / `always_ask` / supervised policies
104    /// as the CLI manager, but tools that would require interactive approval
105    /// are auto-denied instead of prompting (since there is no operator).
106    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    /// Create a non-interactive manager for direct agents with a human
119    /// approval back-channel, such as ACP and the web dashboard WebSocket.
120    /// Reads from the same per-agent risk profile as
121    /// [`Self::for_non_interactive`]; the only difference is that shell
122    /// invocations route through the operator-driven backchannel rather
123    /// than auto-denying.
124    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    /// Returns `true` when this manager operates in non-interactive mode
137    /// (i.e. for channel-driven runs where no operator can approve).
138    pub fn is_non_interactive(&self) -> bool {
139        self.non_interactive
140    }
141
142    /// Check whether a tool call requires interactive approval.
143    ///
144    /// Returns `true` if the call needs a prompt, `false` if it can proceed.
145    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        // Full autonomy never prompts.
151        if self.autonomy_level == AutonomyLevel::Full {
152            return ApprovalRequirement::Approved;
153        }
154
155        // ReadOnly blocks everything — handled elsewhere; no prompt needed.
156        if self.autonomy_level == AutonomyLevel::ReadOnly {
157            return ApprovalRequirement::NotRequired;
158        }
159
160        // always_ask overrides everything.
161        if self.always_ask.contains("*") || self.always_ask.contains(tool_name) {
162            return ApprovalRequirement::Prompt;
163        }
164
165        // Channel-driven shell execution is still guarded by the shell tool's
166        // own command allowlist and risk policy. Skipping the outer approval
167        // gate here lets low-risk allowlisted commands (e.g. `ls`) work in
168        // non-interactive channels without silently allowing medium/high-risk
169        // commands.
170        if self.non_interactive
171            && tool_name == "shell"
172            && !self.non_interactive_shell_requires_approval
173        {
174            return ApprovalRequirement::NotRequired;
175        }
176
177        // auto_approve skips the prompt.
178        if self.auto_approve.contains("*") || self.auto_approve.contains(tool_name) {
179            return ApprovalRequirement::Approved;
180        }
181
182        // Session allowlist (from prior "Always" responses).
183        let allowlist = self.session_allowlist.lock();
184        if allowlist.contains(tool_name) {
185            return ApprovalRequirement::Approved;
186        }
187
188        // Default: supervised mode requires approval.
189        ApprovalRequirement::Prompt
190    }
191
192    /// Record an approval decision and update session state.
193    pub fn record_decision(
194        &self,
195        tool_name: &str,
196        args: &serde_json::Value,
197        decision: ApprovalResponse,
198        channel: &str,
199    ) {
200        // If "Always", add to session allowlist.
201        if decision == ApprovalResponse::Always {
202            let mut allowlist = self.session_allowlist.lock();
203            allowlist.insert(tool_name.to_string());
204        }
205
206        // Append to audit log.
207        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    /// Get a snapshot of the audit log.
220    pub fn audit_log(&self) -> Vec<ApprovalLogEntry> {
221        self.audit_log.lock().clone()
222    }
223
224    /// Get the current session allowlist.
225    pub fn session_allowlist(&self) -> HashSet<String> {
226        self.session_allowlist.lock().clone()
227    }
228
229    /// Prompt the user on the CLI and return their decision.
230    ///
231    /// Only called for interactive (CLI) managers. Non-interactive managers
232    /// auto-deny in the tool-call loop before reaching this point.
233    pub fn prompt_cli(&self, request: &ApprovalRequest) -> ApprovalResponse {
234        prompt_cli_interactive(request)
235    }
236}
237
238// ── CLI prompt ───────────────────────────────────────────────────
239
240/// Display the approval prompt and read user input from stdin.
241fn 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
262/// Produce a short human-readable summary of tool arguments. Argument keys
263/// whose names suggest a credential get their value replaced with
264/// `[redacted]` before truncation, so summaries that cross security
265/// boundaries (e.g. the gateway WebSocket `approval_request` frame) cannot
266/// leak secret-bearing fields. Operators MUST treat the summary as
267/// best-effort: a tool that names its credential field something other than
268/// the patterns below still surfaces. The tool author's typed config and
269/// `#[secret]` annotations are the long-term truth source.
270pub 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
299/// Heuristic for argument keys that should have their value redacted in
300/// human-readable summaries. Matches anywhere in the (lowercased) key:
301/// covers `api_key`, `api-key`, `apiKey`, `oauth_token`, `secret`,
302/// `password`, `auth_token`, `bearer`, `client_secret`, `private_key`, etc.
303fn 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// ── Tests ────────────────────────────────────────────────────────
335
336#[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    // ── needs_approval ───────────────────────────────────────
358
359    #[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    // ── session allowlist ────────────────────────────────────
398
399    #[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        // Now file_write should be in session allowlist.
412        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        // Even after "Always" for shell, it should still prompt.
420        mgr.record_decision(
421            "shell",
422            &serde_json::json!({"command": "ls"}),
423            ApprovalResponse::Always,
424            "cli",
425        );
426
427        // shell is in always_ask, so it still needs approval.
428        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    // ── audit log ────────────────────────────────────────────
444
445    #[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    // ── summarize_args ───────────────────────────────────────
487
488    #[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    // ── non-interactive (channel) mode ────────────────────────
522
523    #[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        // auto_approve tools (file_read, memory_recall) should not need approval.
539        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        // always_ask tools (shell) still report as needing approval,
560        // so the tool-call loop will auto-deny them in non-interactive mode.
561        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        // Unknown tools in supervised mode need approval (will be auto-denied
568        // by the tool-call loop for non-interactive managers).
569        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        // Full autonomy means no approval needed, even in non-interactive mode.
577        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        // ReadOnly blocks execution elsewhere; approval manager does not prompt.
590        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        // Simulate an "Always" decision (would come from a prior channel run
599        // if the tool was auto-approved somehow, e.g. via config change).
600        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        // shell is in always_ask, so it still needs approval even after "Always".
622        assert!(mgr.needs_approval("shell"));
623    }
624
625    // ── ApprovalResponse serde ───────────────────────────────
626
627    #[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    // ── ApprovalRequest ──────────────────────────────────────
636
637    #[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    // ── Regression: #4247 default approved tools in channels ──
649
650    #[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    // ── ChannelApprovalResponse → ApprovalResponse mapping ──────
697
698    #[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        // AlwaysApprove serializes to "always" to match the CLI-side
749        // ApprovalResponse::Always and keep audit logs consistent.
750        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    // ── summarize_args secret-key redaction ────────────────────
759
760    #[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}