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// Unit tests
260// ---------------------------------------------------------------------------
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use serde_json::json;
266    use std::sync::Mutex;
267
268    /// Global mutex serializes all tests that read or write env vars.
269    /// Env vars are process-global; parallel tokio tests will race without this.
270    static ENV_MUTEX: Mutex<()> = Mutex::new(());
271
272    // --- Task 1: error-types ---
273
274    #[test]
275    fn error_denied_display() {
276        let e = ApprovalError::Denied {
277            module_id: "my-module".into(),
278        };
279        assert_eq!(e.to_string(), "approval denied for module 'my-module'");
280    }
281
282    #[test]
283    fn error_non_interactive_display() {
284        let e = ApprovalError::NonInteractive {
285            module_id: "my-module".into(),
286        };
287        assert_eq!(
288            e.to_string(),
289            "no interactive terminal available for module 'my-module'"
290        );
291    }
292
293    #[test]
294    fn error_timeout_display() {
295        let e = ApprovalError::Timeout {
296            module_id: "my-module".into(),
297            seconds: 60,
298        };
299        assert_eq!(
300            e.to_string(),
301            "approval timed out after 60s for module 'my-module'"
302        );
303    }
304
305    #[test]
306    fn error_variants_are_debug() {
307        let d = format!(
308            "{:?}",
309            ApprovalError::Denied {
310                module_id: "x".into()
311            }
312        );
313        assert!(d.contains("Denied"));
314    }
315
316    // --- Task 2: annotation-extraction ---
317
318    #[test]
319    fn requires_approval_true_returns_true() {
320        let v = json!({"annotations": {"requires_approval": true}});
321        assert!(get_requires_approval(&v));
322    }
323
324    #[test]
325    fn requires_approval_false_returns_false() {
326        let v = json!({"annotations": {"requires_approval": false}});
327        assert!(!get_requires_approval(&v));
328    }
329
330    #[test]
331    fn requires_approval_string_true_returns_false() {
332        let v = json!({"annotations": {"requires_approval": "true"}});
333        assert!(!get_requires_approval(&v));
334    }
335
336    #[test]
337    fn requires_approval_int_one_returns_false() {
338        let v = json!({"annotations": {"requires_approval": 1}});
339        assert!(!get_requires_approval(&v));
340    }
341
342    #[test]
343    fn requires_approval_null_returns_false() {
344        let v = json!({"annotations": {"requires_approval": null}});
345        assert!(!get_requires_approval(&v));
346    }
347
348    #[test]
349    fn requires_approval_absent_returns_false() {
350        let v = json!({"annotations": {}});
351        assert!(!get_requires_approval(&v));
352    }
353
354    #[test]
355    fn requires_approval_no_annotations_returns_false() {
356        let v = json!({});
357        assert!(!get_requires_approval(&v));
358    }
359
360    #[test]
361    fn requires_approval_annotations_null_returns_false() {
362        let v = json!({"annotations": null});
363        assert!(!get_requires_approval(&v));
364    }
365
366    #[test]
367    fn approval_message_custom() {
368        let v = json!({"annotations": {"approval_message": "Please confirm."}});
369        assert_eq!(get_approval_message(&v, "mod-x"), "Please confirm.");
370    }
371
372    #[test]
373    fn approval_message_default_when_absent() {
374        let v = json!({"annotations": {}});
375        assert_eq!(
376            get_approval_message(&v, "mod-x"),
377            "Module 'mod-x' requires approval to execute."
378        );
379    }
380
381    #[test]
382    fn approval_message_default_when_not_string() {
383        let v = json!({"annotations": {"approval_message": 42}});
384        assert_eq!(
385            get_approval_message(&v, "mod-x"),
386            "Module 'mod-x' requires approval to execute."
387        );
388    }
389
390    #[test]
391    fn module_id_from_module_id_field() {
392        let v = json!({"module_id": "my-module"});
393        assert_eq!(get_module_id(&v), "my-module");
394    }
395
396    #[test]
397    fn module_id_from_canonical_id_field() {
398        let v = json!({"canonical_id": "canon-module"});
399        assert_eq!(get_module_id(&v), "canon-module");
400    }
401
402    #[test]
403    fn module_id_unknown_when_absent() {
404        let v = json!({});
405        assert_eq!(get_module_id(&v), "unknown");
406    }
407
408    // --- Task 3: bypass-logic ---
409
410    fn module(requires: bool) -> serde_json::Value {
411        json!({
412            "module_id": "test-module",
413            "annotations": { "requires_approval": requires }
414        })
415    }
416
417    #[tokio::test]
418    async fn skip_when_requires_approval_false() {
419        let result =
420            check_approval(&json!({"annotations": {"requires_approval": false}}), false).await;
421        assert!(result.is_ok());
422    }
423
424    #[tokio::test]
425    async fn skip_when_no_annotations() {
426        let result = check_approval(&json!({}), false).await;
427        assert!(result.is_ok());
428    }
429
430    #[tokio::test]
431    async fn skip_when_requires_approval_string_true() {
432        let result = check_approval(
433            &json!({"annotations": {"requires_approval": "true"}}),
434            false,
435        )
436        .await;
437        assert!(result.is_ok());
438    }
439
440    #[tokio::test]
441    async fn bypass_auto_approve_true() {
442        let result = check_approval(&module(true), true).await;
443        assert!(result.is_ok(), "auto_approve=true must bypass");
444    }
445
446    #[test]
447    fn bypass_env_var_one() {
448        let _guard = ENV_MUTEX.lock().unwrap();
449        unsafe { std::env::set_var("APCORE_CLI_AUTO_APPROVE", "1") };
450        let rt = tokio::runtime::Runtime::new().unwrap();
451        let result = rt.block_on(check_approval(&module(true), false));
452        unsafe { std::env::remove_var("APCORE_CLI_AUTO_APPROVE") };
453        assert!(result.is_ok(), "APCORE_CLI_AUTO_APPROVE=1 must bypass");
454    }
455
456    #[test]
457    fn yes_flag_priority_over_env_var() {
458        let _guard = ENV_MUTEX.lock().unwrap();
459        unsafe { std::env::set_var("APCORE_CLI_AUTO_APPROVE", "1") };
460        let rt = tokio::runtime::Runtime::new().unwrap();
461        let result = rt.block_on(check_approval(&module(true), true));
462        unsafe { std::env::remove_var("APCORE_CLI_AUTO_APPROVE") };
463        assert!(result.is_ok());
464    }
465
466    // --- Task 4: non-tty-rejection ---
467
468    fn module_requiring_approval() -> serde_json::Value {
469        json!({
470            "module_id": "test-module",
471            "annotations": { "requires_approval": true }
472        })
473    }
474
475    #[test]
476    fn non_tty_no_bypass_returns_non_interactive_error() {
477        let _guard = ENV_MUTEX.lock().unwrap();
478        unsafe { std::env::remove_var("APCORE_CLI_AUTO_APPROVE") };
479        let rt = tokio::runtime::Runtime::new().unwrap();
480        let result = rt.block_on(check_approval_with_tty(
481            &module_requiring_approval(),
482            false,
483            false,
484        ));
485        match result {
486            Err(ApprovalError::NonInteractive { module_id }) => {
487                assert_eq!(module_id, "test-module");
488            }
489            other => panic!("expected NonInteractive error, got {:?}", other),
490        }
491    }
492
493    #[tokio::test]
494    async fn non_tty_with_yes_flag_bypasses_before_tty_check() {
495        let result = check_approval_with_tty(&module_requiring_approval(), true, false).await;
496        assert!(result.is_ok(), "auto_approve bypasses TTY check");
497    }
498
499    #[test]
500    fn non_tty_with_env_var_bypasses_before_tty_check() {
501        let _guard = ENV_MUTEX.lock().unwrap();
502        unsafe { std::env::set_var("APCORE_CLI_AUTO_APPROVE", "1") };
503        let rt = tokio::runtime::Runtime::new().unwrap();
504        let result = rt.block_on(check_approval_with_tty(
505            &module_requiring_approval(),
506            false,
507            false,
508        ));
509        unsafe { std::env::remove_var("APCORE_CLI_AUTO_APPROVE") };
510        assert!(result.is_ok(), "env var bypass happens before TTY check");
511    }
512
513    #[test]
514    fn non_tty_env_var_not_one_returns_non_interactive() {
515        let _guard = ENV_MUTEX.lock().unwrap();
516        unsafe { std::env::set_var("APCORE_CLI_AUTO_APPROVE", "true") };
517        let rt = tokio::runtime::Runtime::new().unwrap();
518        let result = rt.block_on(check_approval_with_tty(
519            &module_requiring_approval(),
520            false,
521            false,
522        ));
523        unsafe { std::env::remove_var("APCORE_CLI_AUTO_APPROVE") };
524        assert!(matches!(result, Err(ApprovalError::NonInteractive { .. })));
525    }
526
527    // --- Task 5: tty-prompt-timeout ---
528
529    #[tokio::test]
530    async fn user_types_y_returns_ok() {
531        let result = prompt_with_reader("test-module", "Requires approval.", 60, || {
532            Ok("y\n".to_string())
533        })
534        .await;
535        assert!(result.is_ok());
536    }
537
538    #[tokio::test]
539    async fn user_types_yes_returns_ok() {
540        let result = prompt_with_reader("test-module", "Requires approval.", 60, || {
541            Ok("yes\n".to_string())
542        })
543        .await;
544        assert!(result.is_ok());
545    }
546
547    #[tokio::test]
548    async fn user_types_yes_uppercase_returns_ok() {
549        let result = prompt_with_reader("test-module", "Requires approval.", 60, || {
550            Ok("YES\n".to_string())
551        })
552        .await;
553        assert!(result.is_ok());
554    }
555
556    #[tokio::test]
557    async fn user_types_n_returns_denied() {
558        let result = prompt_with_reader("test-module", "Requires approval.", 60, || {
559            Ok("n\n".to_string())
560        })
561        .await;
562        assert!(matches!(result, Err(ApprovalError::Denied { .. })));
563    }
564
565    #[tokio::test]
566    async fn user_presses_enter_returns_denied() {
567        let result = prompt_with_reader("test-module", "Requires approval.", 60, || {
568            Ok("\n".to_string())
569        })
570        .await;
571        assert!(matches!(result, Err(ApprovalError::Denied { .. })));
572    }
573
574    #[tokio::test]
575    async fn user_types_garbage_returns_denied() {
576        let result = prompt_with_reader("test-module", "Requires approval.", 60, || {
577            Ok("maybe\n".to_string())
578        })
579        .await;
580        assert!(matches!(result, Err(ApprovalError::Denied { .. })));
581    }
582
583    #[tokio::test]
584    async fn timeout_returns_timeout_error() {
585        let result = prompt_with_reader(
586            "test-module",
587            "Requires approval.",
588            0, // fires immediately
589            || {
590                // Simulate a slow/blocking read that never returns in time.
591                std::thread::sleep(std::time::Duration::from_secs(10));
592                Ok("y\n".to_string())
593            },
594        )
595        .await;
596        match result {
597            Err(ApprovalError::Timeout { module_id, seconds }) => {
598                assert_eq!(module_id, "test-module");
599                assert_eq!(seconds, 0);
600            }
601            other => panic!("expected Timeout, got {:?}", other),
602        }
603    }
604
605    #[tokio::test]
606    async fn check_approval_custom_message_displayed() {
607        let module_def = json!({
608            "module_id": "mod-custom",
609            "annotations": {
610                "requires_approval": true,
611                "approval_message": "Custom: please confirm."
612            }
613        });
614        // With auto_approve=true, bypass fires before TTY prompt.
615        let result = check_approval_with_tty(&module_def, true, true).await;
616        assert!(result.is_ok());
617    }
618}