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, 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    /// Skip execution; return this as the tool result instead.
34    #[serde(rename = "replace_with")]
35    ReplaceWith(String),
36}
37
38/// Maximum length of an operator-supplied `DenyWithEdit` / `ReplaceWith`
39/// replacement, in bytes. The replacement is operator-authored but still
40/// untrusted input that becomes a tool result fed back to the model — cap it
41/// so a runaway paste can't blow up the context window.
42pub const MAX_REPLACEMENT_LEN: usize = 64 * 1024;
43
44/// Sanitize an operator-supplied tool-result replacement before it is fed back
45/// to the model: drop control characters (except `\n`, `\r`, `\t`) that could
46/// corrupt rendering or smuggle terminal escapes, and truncate to
47/// [`MAX_REPLACEMENT_LEN`] on a char boundary.
48#[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/// A single audit log entry for an approval decision.
65#[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
81// ── ApprovalManager ──────────────────────────────────────────────
82
83/// Manages the approval workflow for tool calls.
84///
85/// - Checks config-level `auto_approve` / `always_ask` lists
86/// - Maintains a session-scoped "always" allowlist
87/// - Records an audit trail of all decisions
88///
89/// Two modes:
90/// - **Interactive** (CLI): tools needing approval trigger a stdin prompt.
91/// - **Non-interactive** (channels): tools needing approval are auto-denied
92///   because there is no interactive operator to approve them. `auto_approve`
93///   policy is still enforced, and `always_ask` / supervised-default tools are
94///   denied rather than silently allowed.
95/// - **Non-interactive back-channel** (ACP/WS): tools needing approval are sent
96///   through a client approval channel instead of trusting tool arguments.
97pub struct ApprovalManager {
98    /// Tools that never need approval (from config).
99    auto_approve: HashSet<String>,
100    /// Tools that always need approval, ignoring session allowlist.
101    always_ask: HashSet<String>,
102    /// Autonomy level from config.
103    autonomy_level: AutonomyLevel,
104    /// When `true`, tools that would require interactive approval are
105    /// auto-denied instead. Used for channel-driven (non-CLI) runs.
106    non_interactive: bool,
107    /// When `true`, shell calls in non-interactive mode still enter the outer
108    /// approval flow because a real client approval channel exists.
109    non_interactive_shell_requires_approval: bool,
110    /// Session-scoped allowlist built from "Always" responses.
111    session_allowlist: Mutex<HashSet<String>>,
112    /// Audit trail of approval decisions.
113    audit_log: Mutex<Vec<ApprovalLogEntry>>,
114}
115
116impl ApprovalManager {
117    /// Create an interactive (CLI) approval manager from a risk profile.
118    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    /// Create a non-interactive approval manager for channel-driven runs.
131    ///
132    /// Enforces the same `auto_approve` / `always_ask` / supervised policies
133    /// as the CLI manager, but tools that would require interactive approval
134    /// are auto-denied instead of prompting (since there is no operator).
135    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    /// Create a non-interactive manager for direct agents with a human
148    /// approval back-channel, such as ACP and the web dashboard WebSocket.
149    /// Reads from the same per-agent risk profile as
150    /// [`Self::for_non_interactive`]; the only difference is that shell
151    /// invocations route through the operator-driven backchannel rather
152    /// than auto-denying.
153    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    /// Returns `true` when this manager operates in non-interactive mode
166    /// (i.e. for channel-driven runs where no operator can approve).
167    pub fn is_non_interactive(&self) -> bool {
168        self.non_interactive
169    }
170
171    /// Check whether a tool call requires interactive approval.
172    ///
173    /// Returns `true` if the call needs a prompt, `false` if it can proceed.
174    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        // Full autonomy never prompts.
180        if self.autonomy_level == AutonomyLevel::Full {
181            return ApprovalRequirement::Approved;
182        }
183
184        // ReadOnly blocks everything — handled elsewhere; no prompt needed.
185        if self.autonomy_level == AutonomyLevel::ReadOnly {
186            return ApprovalRequirement::NotRequired;
187        }
188
189        // always_ask overrides everything.
190        if self.always_ask.contains("*") || self.always_ask.contains(tool_name) {
191            return ApprovalRequirement::Prompt;
192        }
193
194        // Channel-driven shell execution is still guarded by the shell tool's
195        // own command allowlist and risk policy. Skipping the outer approval
196        // gate here lets low-risk allowlisted commands (e.g. `ls`) work in
197        // non-interactive channels without silently allowing medium/high-risk
198        // commands.
199        if self.non_interactive
200            && tool_name == "shell"
201            && !self.non_interactive_shell_requires_approval
202        {
203            return ApprovalRequirement::NotRequired;
204        }
205
206        // auto_approve skips the prompt.
207        if self.auto_approve.contains("*") || self.auto_approve.contains(tool_name) {
208            return ApprovalRequirement::Approved;
209        }
210
211        // Session allowlist (from prior "Always" responses).
212        let allowlist = self.session_allowlist.lock();
213        if allowlist.contains(tool_name) {
214            return ApprovalRequirement::Approved;
215        }
216
217        // Default: supervised mode requires approval.
218        ApprovalRequirement::Prompt
219    }
220
221    /// Record an approval decision and update session state.
222    pub fn record_decision(
223        &self,
224        tool_name: &str,
225        args: &serde_json::Value,
226        decision: &ApprovalResponse,
227        channel: &str,
228    ) {
229        // If "Always", add to session allowlist.
230        if *decision == ApprovalResponse::Always {
231            let mut allowlist = self.session_allowlist.lock();
232            allowlist.insert(tool_name.to_string());
233        }
234
235        // Append to audit log.
236        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    /// Get a snapshot of the audit log.
249    pub fn audit_log(&self) -> Vec<ApprovalLogEntry> {
250        self.audit_log.lock().clone()
251    }
252
253    /// Get the current session allowlist.
254    pub fn session_allowlist(&self) -> HashSet<String> {
255        self.session_allowlist.lock().clone()
256    }
257
258    /// Prompt the user on the CLI and return their decision.
259    ///
260    /// Only called for interactive (CLI) managers. Non-interactive managers
261    /// auto-deny in the tool-call loop before reaching this point.
262    pub fn prompt_cli(&self, request: &ApprovalRequest) -> ApprovalResponse {
263        prompt_cli_interactive(request)
264    }
265}
266
267// ── CLI prompt ───────────────────────────────────────────────────
268
269/// Display the approval prompt and read user input from stdin.
270fn 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
291/// Produce a short human-readable summary of tool arguments. Argument keys
292/// whose names suggest a credential get their value replaced with
293/// `[redacted]` before truncation, so summaries that cross security
294/// boundaries (e.g. the gateway WebSocket `approval_request` frame) cannot
295/// leak secret-bearing fields. Operators MUST treat the summary as
296/// best-effort: a tool that names its credential field something other than
297/// the patterns below still surfaces. The tool author's typed config and
298/// `#[secret]` annotations are the long-term truth source.
299pub 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            // Prioritize "path" (used by file_write/file_edit etc.) so approval
305            // popups and audit logs always surface the target file first.
306            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
347/// Heuristic for argument keys that should have their value redacted in
348/// human-readable summaries. Matches anywhere in the (lowercased) key:
349/// covers `api_key`, `api-key`, `apiKey`, `oauth_token`, `secret`,
350/// `password`, `auth_token`, `bearer`, `client_secret`, `private_key`, etc.
351fn 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// ── Tests ────────────────────────────────────────────────────────
383
384#[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); // 2 bytes each
399        let clean = sanitize_tool_replacement(&big);
400        assert!(clean.len() <= MAX_REPLACEMENT_LEN);
401        // Truncation must land on a char boundary (no panic, valid UTF-8).
402        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    // ── needs_approval ───────────────────────────────────────
422
423    #[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    // ── session allowlist ────────────────────────────────────
462
463    #[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        // Now file_write should be in session allowlist.
476        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        // Even after "Always" for shell, it should still prompt.
484        mgr.record_decision(
485            "shell",
486            &serde_json::json!({"command": "ls"}),
487            &ApprovalResponse::Always,
488            "cli",
489        );
490
491        // shell is in always_ask, so it still needs approval.
492        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    // ── audit log ────────────────────────────────────────────
508
509    #[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    // ── summarize_args ───────────────────────────────────────
551
552    #[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    // ── non-interactive (channel) mode ────────────────────────
599
600    #[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        // auto_approve tools (file_read, memory_recall) should not need approval.
616        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        // always_ask tools (shell) still report as needing approval,
637        // so the tool-call loop will auto-deny them in non-interactive mode.
638        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        // Unknown tools in supervised mode need approval (will be auto-denied
645        // by the tool-call loop for non-interactive managers).
646        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        // Full autonomy means no approval needed, even in non-interactive mode.
654        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        // ReadOnly blocks execution elsewhere; approval manager does not prompt.
667        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        // Simulate an "Always" decision (would come from a prior channel run
676        // if the tool was auto-approved somehow, e.g. via config change).
677        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        // shell is in always_ask, so it still needs approval even after "Always".
699        assert!(mgr.needs_approval("shell"));
700    }
701
702    // ── ApprovalResponse serde ───────────────────────────────
703
704    #[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    // ── ApprovalRequest ──────────────────────────────────────
717
718    #[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    // ── Regression: #4247 default approved tools in channels ──
730
731    #[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    // ── ChannelApprovalResponse → ApprovalResponse mapping ──────
778
779    #[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        // AlwaysApprove serializes to "always" to match the CLI-side
862        // ApprovalResponse::Always and keep audit logs consistent.
863        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    // ── summarize_args secret-key redaction ────────────────────
872
873    #[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}