Skip to main content

apcore_cli/
approval.rs

1// apcore-cli — Human-in-the-loop approval gate.
2// Protocol spec: FE-05 (check_approval, ApprovalError)
3
4use thiserror::Error;
5
6// ---------------------------------------------------------------------------
7// Error types  (Task 1: error-types)
8// ---------------------------------------------------------------------------
9
10/// Errors returned by the approval gate.
11/// All variants map to exit code 46 (EXIT_APPROVAL_DENIED).
12#[derive(Debug, Error)]
13pub enum ApprovalError {
14    /// The operator denied execution.
15    #[error("approval denied for module '{module_id}'")]
16    Denied { module_id: String },
17
18    /// No interactive TTY is available to prompt the user.
19    #[error("no interactive terminal available for module '{module_id}'")]
20    NonInteractive { module_id: String },
21
22    /// The approval prompt timed out.
23    #[error("approval timed out after {seconds}s for module '{module_id}'")]
24    Timeout { module_id: String, seconds: u64 },
25}
26
27// ---------------------------------------------------------------------------
28// Annotation extraction helpers  (Task 2: annotation-extraction)
29// ---------------------------------------------------------------------------
30
31/// Returns true only when `module_def["annotations"]["requires_approval"]`
32/// is exactly `Value::Bool(true)`. Strings, integers, and null all return false.
33fn get_requires_approval(module_def: &serde_json::Value) -> bool {
34    module_def
35        .get("annotations")
36        .and_then(|a| a.get("requires_approval"))
37        .and_then(|v| v.as_bool())
38        == Some(true)
39}
40
41/// Returns the custom approval message if present and non-empty, otherwise
42/// the default: "Module '{module_id}' requires approval to execute."
43fn get_approval_message(module_def: &serde_json::Value, module_id: &str) -> String {
44    module_def
45        .get("annotations")
46        .and_then(|a| a.get("approval_message"))
47        .and_then(|v| v.as_str())
48        .filter(|s| !s.is_empty())
49        .map(|s| s.to_string())
50        .unwrap_or_else(|| format!("Module '{module_id}' requires approval to execute."))
51}
52
53/// Returns `module_def["module_id"]` or `module_def["canonical_id"]` if
54/// either is a string, otherwise `"unknown"`.
55fn get_module_id(module_def: &serde_json::Value) -> String {
56    module_def
57        .get("module_id")
58        .or_else(|| module_def.get("canonical_id"))
59        .and_then(|v| v.as_str())
60        .unwrap_or("unknown")
61        .to_string()
62}
63
64// ---------------------------------------------------------------------------
65// Prompt with injectable reader  (Task 5: tty-prompt-timeout)
66// ---------------------------------------------------------------------------
67
68/// Internal prompt implementation with an injectable reader function.
69/// This enables unit testing without a real TTY.
70///
71/// `reader` is called on a blocking thread via `spawn_blocking`; it must
72/// read one line from whatever source is appropriate (real stdin in
73/// production, a mock in tests).
74///
75/// On timeout the blocking thread remains parked — this is acceptable
76/// because the process exits immediately with code 46 after this function
77/// returns `Err(ApprovalError::Timeout)`.
78async fn prompt_with_reader<F>(
79    module_id: &str,
80    message: &str,
81    timeout_secs: u64,
82    reader: F,
83) -> Result<(), ApprovalError>
84where
85    F: FnOnce() -> std::io::Result<String> + Send + 'static,
86{
87    // Display message and prompt to stderr.
88    eprint!("{}\nProceed? [y/N]: ", message);
89    // Flush stderr so the prompt appears before blocking.
90    use std::io::Write;
91    let _ = std::io::stderr().flush();
92
93    let module_id_owned = module_id.to_string();
94    let read_handle = tokio::task::spawn_blocking(reader);
95
96    tokio::select! {
97        result = read_handle => {
98            match result {
99                Ok(Ok(line)) => {
100                    let input = line.trim().to_lowercase();
101                    if input == "y" || input == "yes" {
102                        tracing::info!(
103                            "User approved execution of module '{}'.",
104                            module_id_owned
105                        );
106                        Ok(())
107                    } else {
108                        tracing::warn!(
109                            "Approval rejected by user for module '{}'.",
110                            module_id_owned
111                        );
112                        eprintln!("Error: Approval denied.");
113                        Err(ApprovalError::Denied { module_id: module_id_owned })
114                    }
115                }
116                Ok(Err(io_err)) => {
117                    // stdin closed (EOF) without input — treat as denial.
118                    tracing::warn!(
119                        "stdin read error for module '{}': {}",
120                        module_id_owned,
121                        io_err
122                    );
123                    eprintln!("Error: Approval denied.");
124                    Err(ApprovalError::Denied { module_id: module_id_owned })
125                }
126                Err(join_err) => {
127                    // spawn_blocking task panicked.
128                    tracing::error!("spawn_blocking panicked: {}", join_err);
129                    Err(ApprovalError::Denied { module_id: module_id_owned })
130                }
131            }
132        }
133        _ = tokio::time::sleep(tokio::time::Duration::from_secs(timeout_secs)) => {
134            tracing::warn!(
135                "Approval timed out after {}s for module '{}'.",
136                timeout_secs,
137                module_id_owned
138            );
139            eprintln!("Error: Approval prompt timed out after {} seconds.", timeout_secs);
140            Err(ApprovalError::Timeout {
141                module_id: module_id_owned,
142                seconds: timeout_secs,
143            })
144        }
145    }
146}
147
148/// Production prompt: uses real stdin with a 60-second timeout.
149async fn prompt_with_timeout(
150    module_id: &str,
151    message: &str,
152    timeout_secs: u64,
153) -> Result<(), ApprovalError> {
154    prompt_with_reader(module_id, message, timeout_secs, || {
155        let mut line = String::new();
156        std::io::stdin().read_line(&mut line)?;
157        Ok(line)
158    })
159    .await
160}
161
162// ---------------------------------------------------------------------------
163// check_approval_with_tty  (Tasks 3, 4, 5)
164// ---------------------------------------------------------------------------
165
166/// Internal implementation accepting `is_tty` for testability.
167///
168/// Decision order:
169/// 1. Skip entirely if `requires_approval` is not strict bool `true`.
170/// 2. Bypass if `auto_approve == true` (--yes flag).
171/// 3. Bypass if `APCORE_CLI_AUTO_APPROVE == "1"` (exact match).
172/// 4. Reject if `!is_tty` (NonInteractive).
173/// 5. Prompt interactively with 60-second timeout.
174pub async fn check_approval_with_tty(
175    module_def: &serde_json::Value,
176    auto_approve: bool,
177    is_tty: bool,
178) -> Result<(), ApprovalError> {
179    if !get_requires_approval(module_def) {
180        return Ok(());
181    }
182
183    let module_id = get_module_id(module_def);
184
185    // Bypass: --yes flag (highest priority)
186    if auto_approve {
187        tracing::info!(
188            "Approval bypassed via --yes flag for module '{}'.",
189            module_id
190        );
191        return Ok(());
192    }
193
194    // Bypass: APCORE_CLI_AUTO_APPROVE env var
195    match std::env::var("APCORE_CLI_AUTO_APPROVE").as_deref() {
196        Ok("1") => {
197            tracing::info!(
198                "Approval bypassed via APCORE_CLI_AUTO_APPROVE for module '{}'.",
199                module_id
200            );
201            return Ok(());
202        }
203        Ok("") | Err(_) => {
204            // Not set or empty — fall through silently.
205        }
206        Ok(val) => {
207            tracing::warn!(
208                "APCORE_CLI_AUTO_APPROVE is set to '{}', expected '1'. Ignoring.",
209                val
210            );
211        }
212    }
213
214    // Non-TTY rejection
215    if !is_tty {
216        eprintln!(
217            "Error: Module '{}' requires approval but no interactive terminal is available. \
218             Use --yes or set APCORE_CLI_AUTO_APPROVE=1 to bypass.",
219            module_id
220        );
221        tracing::error!(
222            "Non-interactive environment, no bypass provided for module '{}'.",
223            module_id
224        );
225        return Err(ApprovalError::NonInteractive { module_id });
226    }
227
228    // TTY prompt with timeout.
229    let message = get_approval_message(module_def, &module_id);
230    prompt_with_timeout(&module_id, &message, 60).await
231}
232
233// ---------------------------------------------------------------------------
234// check_approval — public API  (Task 5)
235// ---------------------------------------------------------------------------
236
237/// Gate module execution behind an interactive approval prompt.
238///
239/// Returns `Ok(())` immediately if `requires_approval` is not `true`.
240/// Bypasses the prompt if `auto_approve` is `true` or the env var
241/// `APCORE_CLI_AUTO_APPROVE` is set to exactly `"1"`.
242/// Returns `Err(ApprovalError::NonInteractive)` if stdin is not a TTY.
243/// Otherwise prompts the user with a 60-second timeout.
244///
245/// # Errors
246/// * `ApprovalError::NonInteractive` — stdin is not an interactive terminal
247/// * `ApprovalError::Denied`         — user typed anything other than `y`/`yes`
248/// * `ApprovalError::Timeout`        — prompt timed out
249pub async fn check_approval(
250    module_def: &serde_json::Value,
251    auto_approve: bool,
252) -> Result<(), ApprovalError> {
253    use std::io::IsTerminal;
254    let is_tty = std::io::stdin().is_terminal();
255    check_approval_with_tty(module_def, auto_approve, is_tty).await
256}
257
258// ---------------------------------------------------------------------------
259// CliApprovalHandler -- implements apcore ApprovalHandler protocol (FE-11 S3.5)
260// ---------------------------------------------------------------------------
261
262/// CLI-side approval handler that wraps the TTY prompt logic.
263///
264/// Designed to be compatible with the apcore ApprovalHandler trait/protocol.
265/// Pass to Executor via `executor.set_approval_handler(handler)` if available.
266pub struct CliApprovalHandler {
267    /// If true, all approvals are auto-granted (--yes flag).
268    pub auto_approve: bool,
269    /// Timeout in seconds for interactive prompts.
270    pub timeout: u64,
271}
272
273impl CliApprovalHandler {
274    /// Create a new handler with the given settings.
275    pub fn new(auto_approve: bool, timeout: u64) -> Self {
276        Self {
277            auto_approve,
278            timeout: timeout.clamp(1, 3600),
279        }
280    }
281}
282
283// ---------------------------------------------------------------------------
284// Unit tests
285// ---------------------------------------------------------------------------
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use serde_json::json;
291    use std::sync::Mutex;
292
293    /// Global mutex serializes all tests that read or write env vars.
294    /// Env vars are process-global; parallel tokio tests will race without this.
295    static ENV_MUTEX: Mutex<()> = Mutex::new(());
296
297    // --- Task 1: error-types ---
298
299    #[test]
300    fn error_denied_display() {
301        let e = ApprovalError::Denied {
302            module_id: "my-module".into(),
303        };
304        assert_eq!(e.to_string(), "approval denied for module 'my-module'");
305    }
306
307    #[test]
308    fn error_non_interactive_display() {
309        let e = ApprovalError::NonInteractive {
310            module_id: "my-module".into(),
311        };
312        assert_eq!(
313            e.to_string(),
314            "no interactive terminal available for module 'my-module'"
315        );
316    }
317
318    #[test]
319    fn error_timeout_display() {
320        let e = ApprovalError::Timeout {
321            module_id: "my-module".into(),
322            seconds: 60,
323        };
324        assert_eq!(
325            e.to_string(),
326            "approval timed out after 60s for module 'my-module'"
327        );
328    }
329
330    #[test]
331    fn error_variants_are_debug() {
332        let d = format!(
333            "{:?}",
334            ApprovalError::Denied {
335                module_id: "x".into()
336            }
337        );
338        assert!(d.contains("Denied"));
339    }
340
341    // --- Task 2: annotation-extraction ---
342
343    #[test]
344    fn requires_approval_true_returns_true() {
345        let v = json!({"annotations": {"requires_approval": true}});
346        assert!(get_requires_approval(&v));
347    }
348
349    #[test]
350    fn requires_approval_false_returns_false() {
351        let v = json!({"annotations": {"requires_approval": false}});
352        assert!(!get_requires_approval(&v));
353    }
354
355    #[test]
356    fn requires_approval_string_true_returns_false() {
357        let v = json!({"annotations": {"requires_approval": "true"}});
358        assert!(!get_requires_approval(&v));
359    }
360
361    #[test]
362    fn requires_approval_int_one_returns_false() {
363        let v = json!({"annotations": {"requires_approval": 1}});
364        assert!(!get_requires_approval(&v));
365    }
366
367    #[test]
368    fn requires_approval_null_returns_false() {
369        let v = json!({"annotations": {"requires_approval": null}});
370        assert!(!get_requires_approval(&v));
371    }
372
373    #[test]
374    fn requires_approval_absent_returns_false() {
375        let v = json!({"annotations": {}});
376        assert!(!get_requires_approval(&v));
377    }
378
379    #[test]
380    fn requires_approval_no_annotations_returns_false() {
381        let v = json!({});
382        assert!(!get_requires_approval(&v));
383    }
384
385    #[test]
386    fn requires_approval_annotations_null_returns_false() {
387        let v = json!({"annotations": null});
388        assert!(!get_requires_approval(&v));
389    }
390
391    #[test]
392    fn approval_message_custom() {
393        let v = json!({"annotations": {"approval_message": "Please confirm."}});
394        assert_eq!(get_approval_message(&v, "mod-x"), "Please confirm.");
395    }
396
397    #[test]
398    fn approval_message_default_when_absent() {
399        let v = json!({"annotations": {}});
400        assert_eq!(
401            get_approval_message(&v, "mod-x"),
402            "Module 'mod-x' requires approval to execute."
403        );
404    }
405
406    #[test]
407    fn approval_message_default_when_not_string() {
408        let v = json!({"annotations": {"approval_message": 42}});
409        assert_eq!(
410            get_approval_message(&v, "mod-x"),
411            "Module 'mod-x' requires approval to execute."
412        );
413    }
414
415    #[test]
416    fn module_id_from_module_id_field() {
417        let v = json!({"module_id": "my-module"});
418        assert_eq!(get_module_id(&v), "my-module");
419    }
420
421    #[test]
422    fn module_id_from_canonical_id_field() {
423        let v = json!({"canonical_id": "canon-module"});
424        assert_eq!(get_module_id(&v), "canon-module");
425    }
426
427    #[test]
428    fn module_id_unknown_when_absent() {
429        let v = json!({});
430        assert_eq!(get_module_id(&v), "unknown");
431    }
432
433    // --- Task 3: bypass-logic ---
434
435    fn module(requires: bool) -> serde_json::Value {
436        json!({
437            "module_id": "test-module",
438            "annotations": { "requires_approval": requires }
439        })
440    }
441
442    #[tokio::test]
443    async fn skip_when_requires_approval_false() {
444        let result =
445            check_approval(&json!({"annotations": {"requires_approval": false}}), false).await;
446        assert!(result.is_ok());
447    }
448
449    #[tokio::test]
450    async fn skip_when_no_annotations() {
451        let result = check_approval(&json!({}), false).await;
452        assert!(result.is_ok());
453    }
454
455    #[tokio::test]
456    async fn skip_when_requires_approval_string_true() {
457        let result = check_approval(
458            &json!({"annotations": {"requires_approval": "true"}}),
459            false,
460        )
461        .await;
462        assert!(result.is_ok());
463    }
464
465    #[tokio::test]
466    async fn bypass_auto_approve_true() {
467        let result = check_approval(&module(true), true).await;
468        assert!(result.is_ok(), "auto_approve=true must bypass");
469    }
470
471    #[test]
472    fn bypass_env_var_one() {
473        let _guard = ENV_MUTEX.lock().unwrap();
474        unsafe { std::env::set_var("APCORE_CLI_AUTO_APPROVE", "1") };
475        let rt = tokio::runtime::Runtime::new().unwrap();
476        let result = rt.block_on(check_approval(&module(true), false));
477        unsafe { std::env::remove_var("APCORE_CLI_AUTO_APPROVE") };
478        assert!(result.is_ok(), "APCORE_CLI_AUTO_APPROVE=1 must bypass");
479    }
480
481    #[test]
482    fn yes_flag_priority_over_env_var() {
483        let _guard = ENV_MUTEX.lock().unwrap();
484        unsafe { std::env::set_var("APCORE_CLI_AUTO_APPROVE", "1") };
485        let rt = tokio::runtime::Runtime::new().unwrap();
486        let result = rt.block_on(check_approval(&module(true), true));
487        unsafe { std::env::remove_var("APCORE_CLI_AUTO_APPROVE") };
488        assert!(result.is_ok());
489    }
490
491    // --- Task 4: non-tty-rejection ---
492
493    fn module_requiring_approval() -> serde_json::Value {
494        json!({
495            "module_id": "test-module",
496            "annotations": { "requires_approval": true }
497        })
498    }
499
500    #[test]
501    fn non_tty_no_bypass_returns_non_interactive_error() {
502        let _guard = ENV_MUTEX.lock().unwrap();
503        unsafe { std::env::remove_var("APCORE_CLI_AUTO_APPROVE") };
504        let rt = tokio::runtime::Runtime::new().unwrap();
505        let result = rt.block_on(check_approval_with_tty(
506            &module_requiring_approval(),
507            false,
508            false,
509        ));
510        match result {
511            Err(ApprovalError::NonInteractive { module_id }) => {
512                assert_eq!(module_id, "test-module");
513            }
514            other => panic!("expected NonInteractive error, got {:?}", other),
515        }
516    }
517
518    #[tokio::test]
519    async fn non_tty_with_yes_flag_bypasses_before_tty_check() {
520        let result = check_approval_with_tty(&module_requiring_approval(), true, false).await;
521        assert!(result.is_ok(), "auto_approve bypasses TTY check");
522    }
523
524    #[test]
525    fn non_tty_with_env_var_bypasses_before_tty_check() {
526        let _guard = ENV_MUTEX.lock().unwrap();
527        unsafe { std::env::set_var("APCORE_CLI_AUTO_APPROVE", "1") };
528        let rt = tokio::runtime::Runtime::new().unwrap();
529        let result = rt.block_on(check_approval_with_tty(
530            &module_requiring_approval(),
531            false,
532            false,
533        ));
534        unsafe { std::env::remove_var("APCORE_CLI_AUTO_APPROVE") };
535        assert!(result.is_ok(), "env var bypass happens before TTY check");
536    }
537
538    #[test]
539    fn non_tty_env_var_not_one_returns_non_interactive() {
540        let _guard = ENV_MUTEX.lock().unwrap();
541        unsafe { std::env::set_var("APCORE_CLI_AUTO_APPROVE", "true") };
542        let rt = tokio::runtime::Runtime::new().unwrap();
543        let result = rt.block_on(check_approval_with_tty(
544            &module_requiring_approval(),
545            false,
546            false,
547        ));
548        unsafe { std::env::remove_var("APCORE_CLI_AUTO_APPROVE") };
549        assert!(matches!(result, Err(ApprovalError::NonInteractive { .. })));
550    }
551
552    // --- Task 5: tty-prompt-timeout ---
553
554    #[tokio::test]
555    async fn user_types_y_returns_ok() {
556        let result = prompt_with_reader("test-module", "Requires approval.", 60, || {
557            Ok("y\n".to_string())
558        })
559        .await;
560        assert!(result.is_ok());
561    }
562
563    #[tokio::test]
564    async fn user_types_yes_returns_ok() {
565        let result = prompt_with_reader("test-module", "Requires approval.", 60, || {
566            Ok("yes\n".to_string())
567        })
568        .await;
569        assert!(result.is_ok());
570    }
571
572    #[tokio::test]
573    async fn user_types_yes_uppercase_returns_ok() {
574        let result = prompt_with_reader("test-module", "Requires approval.", 60, || {
575            Ok("YES\n".to_string())
576        })
577        .await;
578        assert!(result.is_ok());
579    }
580
581    #[tokio::test]
582    async fn user_types_n_returns_denied() {
583        let result = prompt_with_reader("test-module", "Requires approval.", 60, || {
584            Ok("n\n".to_string())
585        })
586        .await;
587        assert!(matches!(result, Err(ApprovalError::Denied { .. })));
588    }
589
590    #[tokio::test]
591    async fn user_presses_enter_returns_denied() {
592        let result = prompt_with_reader("test-module", "Requires approval.", 60, || {
593            Ok("\n".to_string())
594        })
595        .await;
596        assert!(matches!(result, Err(ApprovalError::Denied { .. })));
597    }
598
599    #[tokio::test]
600    async fn user_types_garbage_returns_denied() {
601        let result = prompt_with_reader("test-module", "Requires approval.", 60, || {
602            Ok("maybe\n".to_string())
603        })
604        .await;
605        assert!(matches!(result, Err(ApprovalError::Denied { .. })));
606    }
607
608    #[tokio::test]
609    async fn timeout_returns_timeout_error() {
610        let result = prompt_with_reader(
611            "test-module",
612            "Requires approval.",
613            0, // fires immediately
614            || {
615                // Simulate a slow/blocking read that never returns in time.
616                std::thread::sleep(std::time::Duration::from_secs(10));
617                Ok("y\n".to_string())
618            },
619        )
620        .await;
621        match result {
622            Err(ApprovalError::Timeout { module_id, seconds }) => {
623                assert_eq!(module_id, "test-module");
624                assert_eq!(seconds, 0);
625            }
626            other => panic!("expected Timeout, got {:?}", other),
627        }
628    }
629
630    #[tokio::test]
631    async fn check_approval_custom_message_displayed() {
632        let module_def = json!({
633            "module_id": "mod-custom",
634            "annotations": {
635                "requires_approval": true,
636                "approval_message": "Custom: please confirm."
637            }
638        });
639        // With auto_approve=true, bypass fires before TTY prompt.
640        let result = check_approval_with_tty(&module_def, true, true).await;
641        assert!(result.is_ok());
642    }
643}