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
258#[cfg(test)]
263mod tests {
264 use super::*;
265 use serde_json::json;
266 use std::sync::Mutex;
267
268 static ENV_MUTEX: Mutex<()> = Mutex::new(());
271
272 #[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 #[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 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 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 #[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, || {
590 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 let result = check_approval_with_tty(&module_def, true, true).await;
616 assert!(result.is_ok());
617 }
618}