1use thiserror::Error;
5
6#[derive(Debug, Error)]
13pub enum ApprovalError {
14 #[error("approval denied for module '{module_id}'")]
16 Denied { module_id: String },
17
18 #[error("no interactive terminal available for module '{module_id}'")]
20 NonInteractive { module_id: String },
21
22 #[error("approval timed out after {seconds}s for module '{module_id}'")]
24 Timeout { module_id: String, seconds: u64 },
25}
26
27fn 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
41fn 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
53fn 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
64async 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 eprint!("{}\nProceed? [y/N]: ", message);
89 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 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 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
148async 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
162pub 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 if auto_approve {
187 tracing::info!(
188 "Approval bypassed via --yes flag for module '{}'.",
189 module_id
190 );
191 return Ok(());
192 }
193
194 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 }
206 Ok(val) => {
207 tracing::warn!(
208 "APCORE_CLI_AUTO_APPROVE is set to '{}', expected '1'. Ignoring.",
209 val
210 );
211 }
212 }
213
214 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 let message = get_approval_message(module_def, &module_id);
230 prompt_with_timeout(&module_id, &message, 60).await
231}
232
233pub 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
258pub struct CliApprovalHandler {
267 pub auto_approve: bool,
269 pub timeout: u64,
271}
272
273impl CliApprovalHandler {
274 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#[cfg(test)]
288mod tests {
289 use super::*;
290 use serde_json::json;
291 use std::sync::Mutex;
292
293 static ENV_MUTEX: Mutex<()> = Mutex::new(());
296
297 #[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 #[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 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 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 #[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, || {
615 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 let result = check_approval_with_tty(&module_def, true, true).await;
641 assert!(result.is_ok());
642 }
643}