use thiserror::Error;
#[derive(Debug, Error)]
pub enum ApprovalError {
#[error("approval denied for module '{module_id}'")]
Denied { module_id: String },
#[error("no interactive terminal available for module '{module_id}'")]
NonInteractive { module_id: String },
#[error("approval timed out after {seconds}s for module '{module_id}'")]
Timeout { module_id: String, seconds: u64 },
}
fn get_requires_approval(module_def: &serde_json::Value) -> bool {
module_def
.get("annotations")
.and_then(|a| a.get("requires_approval"))
.and_then(|v| v.as_bool())
== Some(true)
}
fn get_approval_message(module_def: &serde_json::Value, module_id: &str) -> String {
module_def
.get("annotations")
.and_then(|a| a.get("approval_message"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("Module '{module_id}' requires approval to execute."))
}
fn get_module_id(module_def: &serde_json::Value) -> String {
module_def
.get("module_id")
.or_else(|| module_def.get("canonical_id"))
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string()
}
async fn prompt_with_reader<F>(
module_id: &str,
message: &str,
timeout_secs: u64,
reader: F,
) -> Result<(), ApprovalError>
where
F: FnOnce() -> std::io::Result<String> + Send + 'static,
{
eprint!("{}\nProceed? [y/N]: ", message);
use std::io::Write;
let _ = std::io::stderr().flush();
let module_id_owned = module_id.to_string();
let read_handle = tokio::task::spawn_blocking(reader);
tokio::select! {
result = read_handle => {
match result {
Ok(Ok(line)) => {
let input = line.trim().to_lowercase();
if input == "y" || input == "yes" {
tracing::info!(
"User approved execution of module '{}'.",
module_id_owned
);
Ok(())
} else {
tracing::warn!(
"Approval rejected by user for module '{}'.",
module_id_owned
);
eprintln!("Error: Approval denied.");
Err(ApprovalError::Denied { module_id: module_id_owned })
}
}
Ok(Err(io_err)) => {
tracing::warn!(
"stdin read error for module '{}': {}",
module_id_owned,
io_err
);
eprintln!("Error: Approval denied.");
Err(ApprovalError::Denied { module_id: module_id_owned })
}
Err(join_err) => {
tracing::error!("spawn_blocking panicked: {}", join_err);
Err(ApprovalError::Denied { module_id: module_id_owned })
}
}
}
_ = tokio::time::sleep(tokio::time::Duration::from_secs(timeout_secs)) => {
tracing::warn!(
"Approval timed out after {}s for module '{}'.",
timeout_secs,
module_id_owned
);
eprintln!("Error: Approval prompt timed out after {} seconds.", timeout_secs);
Err(ApprovalError::Timeout {
module_id: module_id_owned,
seconds: timeout_secs,
})
}
}
}
async fn prompt_with_timeout(
module_id: &str,
message: &str,
timeout_secs: u64,
) -> Result<(), ApprovalError> {
prompt_with_reader(module_id, message, timeout_secs, || {
let mut line = String::new();
std::io::stdin().read_line(&mut line)?;
Ok(line)
})
.await
}
pub const DEFAULT_APPROVAL_TIMEOUT_SECS: u64 = 60;
pub async fn check_approval_with_tty(
module_def: &serde_json::Value,
auto_approve: bool,
is_tty: bool,
) -> Result<(), ApprovalError> {
check_approval_with_tty_timeout(
module_def,
auto_approve,
is_tty,
DEFAULT_APPROVAL_TIMEOUT_SECS,
)
.await
}
pub async fn check_approval_with_tty_timeout(
module_def: &serde_json::Value,
auto_approve: bool,
is_tty: bool,
timeout_secs: u64,
) -> Result<(), ApprovalError> {
if !get_requires_approval(module_def) {
return Ok(());
}
let module_id = get_module_id(module_def);
if auto_approve {
tracing::info!(
"Approval bypassed via --yes flag for module '{}'.",
module_id
);
return Ok(());
}
match std::env::var("APCORE_CLI_AUTO_APPROVE").as_deref() {
Ok("1") => {
tracing::info!(
"Approval bypassed via APCORE_CLI_AUTO_APPROVE for module '{}'.",
module_id
);
return Ok(());
}
Ok("") | Err(_) => {
}
Ok(val) => {
eprintln!(
"Warning: APCORE_CLI_AUTO_APPROVE is set to '{val}', expected '1'. Ignoring."
);
}
}
if !is_tty {
eprintln!(
"Error: Module '{}' requires approval but no interactive terminal is available. \
Use --yes or set APCORE_CLI_AUTO_APPROVE=1 to bypass.",
module_id
);
tracing::error!(
"Non-interactive environment, no bypass provided for module '{}'.",
module_id
);
return Err(ApprovalError::NonInteractive { module_id });
}
let message = get_approval_message(module_def, &module_id);
prompt_with_timeout(&module_id, &message, timeout_secs).await
}
pub async fn check_approval(
module_def: &serde_json::Value,
auto_approve: bool,
timeout: Option<u64>,
) -> Result<(), ApprovalError> {
let secs = timeout.unwrap_or(DEFAULT_APPROVAL_TIMEOUT_SECS);
check_approval_with_timeout(module_def, auto_approve, secs).await
}
pub async fn check_approval_with_timeout(
module_def: &serde_json::Value,
auto_approve: bool,
timeout_secs: u64,
) -> Result<(), ApprovalError> {
use std::io::IsTerminal;
let is_tty = std::io::stdin().is_terminal();
check_approval_with_tty_timeout(module_def, auto_approve, is_tty, timeout_secs).await
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApprovalStatus {
Approved,
Rejected,
Timeout,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApprovalResult {
pub status: ApprovalStatus,
pub approved_by: Option<String>,
pub reason: Option<String>,
}
impl ApprovalResult {
pub fn approved_via(approved_by: impl Into<String>) -> Self {
Self {
status: ApprovalStatus::Approved,
approved_by: Some(approved_by.into()),
reason: None,
}
}
pub fn rejected(reason: impl Into<String>) -> Self {
Self {
status: ApprovalStatus::Rejected,
approved_by: None,
reason: Some(reason.into()),
}
}
pub fn timed_out(reason: impl Into<String>) -> Self {
Self {
status: ApprovalStatus::Timeout,
approved_by: None,
reason: Some(reason.into()),
}
}
}
pub struct CliApprovalHandler {
pub auto_approve: bool,
pub timeout_secs: u64,
}
impl CliApprovalHandler {
pub fn new(auto_approve: bool, timeout_secs: u64) -> Self {
Self {
auto_approve,
timeout_secs,
}
}
pub async fn request_approval(&self, module_def: &serde_json::Value) -> ApprovalResult {
let module_id = get_module_id(module_def);
if !get_requires_approval(module_def) {
return ApprovalResult::approved_via("not_required");
}
if self.auto_approve {
tracing::info!(
"Approval bypassed via --yes flag for module '{}'.",
module_id
);
return ApprovalResult::approved_via("auto_approve");
}
match std::env::var("APCORE_CLI_AUTO_APPROVE").as_deref() {
Ok("1") => {
tracing::info!(
"Approval bypassed via APCORE_CLI_AUTO_APPROVE for module '{}'.",
module_id
);
return ApprovalResult::approved_via("env_auto_approve");
}
Ok("") | Err(_) => {}
Ok(val) => {
tracing::warn!(
"APCORE_CLI_AUTO_APPROVE is set to '{}', expected '1'. Ignoring.",
val
);
}
}
use std::io::IsTerminal;
if !std::io::stdin().is_terminal() {
tracing::error!(
"Non-interactive environment, no bypass provided for module '{}'.",
module_id
);
return ApprovalResult::rejected(format!(
"Module '{module_id}' requires approval but no interactive terminal is available. \
Use --yes or set APCORE_CLI_AUTO_APPROVE=1 to bypass."
));
}
let message = get_approval_message(module_def, &module_id);
match prompt_with_timeout(&module_id, &message, self.timeout_secs).await {
Ok(()) => ApprovalResult::approved_via("tty_user"),
Err(ApprovalError::Timeout { seconds, .. }) => ApprovalResult::timed_out(format!(
"Approval prompt timed out after {seconds} seconds."
)),
Err(_) => ApprovalResult::rejected("User denied approval".to_string()),
}
}
pub async fn check_approval(&self, module_def: &serde_json::Value) -> ApprovalResult {
self.request_approval(module_def).await
}
}
pub type ApprovalDeniedError = ApprovalError;
pub type ApprovalTimeoutError = ApprovalError;
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::sync::Mutex;
static ENV_MUTEX: Mutex<()> = Mutex::new(());
#[test]
fn error_denied_display() {
let e = ApprovalError::Denied {
module_id: "my-module".into(),
};
assert_eq!(e.to_string(), "approval denied for module 'my-module'");
}
#[test]
fn error_non_interactive_display() {
let e = ApprovalError::NonInteractive {
module_id: "my-module".into(),
};
assert_eq!(
e.to_string(),
"no interactive terminal available for module 'my-module'"
);
}
#[test]
fn error_timeout_display() {
let e = ApprovalError::Timeout {
module_id: "my-module".into(),
seconds: 60,
};
assert_eq!(
e.to_string(),
"approval timed out after 60s for module 'my-module'"
);
}
#[test]
fn error_variants_are_debug() {
let d = format!(
"{:?}",
ApprovalError::Denied {
module_id: "x".into()
}
);
assert!(d.contains("Denied"));
}
#[test]
fn requires_approval_true_returns_true() {
let v = json!({"annotations": {"requires_approval": true}});
assert!(get_requires_approval(&v));
}
#[test]
fn requires_approval_false_returns_false() {
let v = json!({"annotations": {"requires_approval": false}});
assert!(!get_requires_approval(&v));
}
#[test]
fn requires_approval_string_true_returns_false() {
let v = json!({"annotations": {"requires_approval": "true"}});
assert!(!get_requires_approval(&v));
}
#[test]
fn requires_approval_int_one_returns_false() {
let v = json!({"annotations": {"requires_approval": 1}});
assert!(!get_requires_approval(&v));
}
#[test]
fn requires_approval_null_returns_false() {
let v = json!({"annotations": {"requires_approval": null}});
assert!(!get_requires_approval(&v));
}
#[test]
fn requires_approval_absent_returns_false() {
let v = json!({"annotations": {}});
assert!(!get_requires_approval(&v));
}
#[test]
fn requires_approval_no_annotations_returns_false() {
let v = json!({});
assert!(!get_requires_approval(&v));
}
#[test]
fn requires_approval_annotations_null_returns_false() {
let v = json!({"annotations": null});
assert!(!get_requires_approval(&v));
}
#[test]
fn approval_message_custom() {
let v = json!({"annotations": {"approval_message": "Please confirm."}});
assert_eq!(get_approval_message(&v, "mod-x"), "Please confirm.");
}
#[test]
fn approval_message_default_when_absent() {
let v = json!({"annotations": {}});
assert_eq!(
get_approval_message(&v, "mod-x"),
"Module 'mod-x' requires approval to execute."
);
}
#[test]
fn approval_message_default_when_not_string() {
let v = json!({"annotations": {"approval_message": 42}});
assert_eq!(
get_approval_message(&v, "mod-x"),
"Module 'mod-x' requires approval to execute."
);
}
#[test]
fn module_id_from_module_id_field() {
let v = json!({"module_id": "my-module"});
assert_eq!(get_module_id(&v), "my-module");
}
#[test]
fn module_id_from_canonical_id_field() {
let v = json!({"canonical_id": "canon-module"});
assert_eq!(get_module_id(&v), "canon-module");
}
#[test]
fn module_id_unknown_when_absent() {
let v = json!({});
assert_eq!(get_module_id(&v), "unknown");
}
fn module(requires: bool) -> serde_json::Value {
json!({
"module_id": "test-module",
"annotations": { "requires_approval": requires }
})
}
#[tokio::test]
async fn skip_when_requires_approval_false() {
let result = check_approval(
&json!({"annotations": {"requires_approval": false}}),
false,
None,
)
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn skip_when_no_annotations() {
let result = check_approval(&json!({}), false, None).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn skip_when_requires_approval_string_true() {
let result = check_approval(
&json!({"annotations": {"requires_approval": "true"}}),
false,
None,
)
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn bypass_auto_approve_true() {
let result = check_approval(&module(true), true, None).await;
assert!(result.is_ok(), "auto_approve=true must bypass");
}
#[tokio::test]
async fn explicit_timeout_some_delegates_to_with_timeout() {
let result = check_approval(&module(true), true, Some(0)).await;
assert!(
result.is_ok(),
"auto_approve must bypass before timeout matters"
);
}
#[test]
fn bypass_env_var_one() {
let _guard = ENV_MUTEX.lock().unwrap();
unsafe { std::env::set_var("APCORE_CLI_AUTO_APPROVE", "1") };
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(check_approval(&module(true), false, None));
unsafe { std::env::remove_var("APCORE_CLI_AUTO_APPROVE") };
assert!(result.is_ok(), "APCORE_CLI_AUTO_APPROVE=1 must bypass");
}
#[test]
fn yes_flag_priority_over_env_var() {
let _guard = ENV_MUTEX.lock().unwrap();
unsafe { std::env::set_var("APCORE_CLI_AUTO_APPROVE", "1") };
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(check_approval(&module(true), true, None));
unsafe { std::env::remove_var("APCORE_CLI_AUTO_APPROVE") };
assert!(result.is_ok());
}
fn module_requiring_approval() -> serde_json::Value {
json!({
"module_id": "test-module",
"annotations": { "requires_approval": true }
})
}
#[test]
fn non_tty_no_bypass_returns_non_interactive_error() {
let _guard = ENV_MUTEX.lock().unwrap();
unsafe { std::env::remove_var("APCORE_CLI_AUTO_APPROVE") };
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(check_approval_with_tty(
&module_requiring_approval(),
false,
false,
));
match result {
Err(ApprovalError::NonInteractive { module_id }) => {
assert_eq!(module_id, "test-module");
}
other => panic!("expected NonInteractive error, got {:?}", other),
}
}
#[tokio::test]
async fn non_tty_with_yes_flag_bypasses_before_tty_check() {
let result = check_approval_with_tty(&module_requiring_approval(), true, false).await;
assert!(result.is_ok(), "auto_approve bypasses TTY check");
}
#[test]
fn non_tty_with_env_var_bypasses_before_tty_check() {
let _guard = ENV_MUTEX.lock().unwrap();
unsafe { std::env::set_var("APCORE_CLI_AUTO_APPROVE", "1") };
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(check_approval_with_tty(
&module_requiring_approval(),
false,
false,
));
unsafe { std::env::remove_var("APCORE_CLI_AUTO_APPROVE") };
assert!(result.is_ok(), "env var bypass happens before TTY check");
}
#[test]
fn non_tty_env_var_not_one_returns_non_interactive() {
let _guard = ENV_MUTEX.lock().unwrap();
unsafe { std::env::set_var("APCORE_CLI_AUTO_APPROVE", "true") };
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(check_approval_with_tty(
&module_requiring_approval(),
false,
false,
));
unsafe { std::env::remove_var("APCORE_CLI_AUTO_APPROVE") };
assert!(matches!(result, Err(ApprovalError::NonInteractive { .. })));
}
#[tokio::test]
async fn user_types_y_returns_ok() {
let result = prompt_with_reader("test-module", "Requires approval.", 60, || {
Ok("y\n".to_string())
})
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn user_types_yes_returns_ok() {
let result = prompt_with_reader("test-module", "Requires approval.", 60, || {
Ok("yes\n".to_string())
})
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn user_types_yes_uppercase_returns_ok() {
let result = prompt_with_reader("test-module", "Requires approval.", 60, || {
Ok("YES\n".to_string())
})
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn user_types_n_returns_denied() {
let result = prompt_with_reader("test-module", "Requires approval.", 60, || {
Ok("n\n".to_string())
})
.await;
assert!(matches!(result, Err(ApprovalError::Denied { .. })));
}
#[tokio::test]
async fn user_presses_enter_returns_denied() {
let result = prompt_with_reader("test-module", "Requires approval.", 60, || {
Ok("\n".to_string())
})
.await;
assert!(matches!(result, Err(ApprovalError::Denied { .. })));
}
#[tokio::test]
async fn user_types_garbage_returns_denied() {
let result = prompt_with_reader("test-module", "Requires approval.", 60, || {
Ok("maybe\n".to_string())
})
.await;
assert!(matches!(result, Err(ApprovalError::Denied { .. })));
}
#[tokio::test]
async fn timeout_returns_timeout_error() {
let result = prompt_with_reader(
"test-module",
"Requires approval.",
0, || {
std::thread::sleep(std::time::Duration::from_secs(10));
Ok("y\n".to_string())
},
)
.await;
match result {
Err(ApprovalError::Timeout { module_id, seconds }) => {
assert_eq!(module_id, "test-module");
assert_eq!(seconds, 0);
}
other => panic!("expected Timeout, got {:?}", other),
}
}
#[tokio::test]
async fn check_approval_custom_message_displayed() {
let module_def = json!({
"module_id": "mod-custom",
"annotations": {
"requires_approval": true,
"approval_message": "Custom: please confirm."
}
});
let result = check_approval_with_tty(&module_def, true, true).await;
assert!(result.is_ok());
}
async fn check_approval_with_tty_timeout_honors_custom_value_before_prompt_inner() {
let module_def = json!({
"module_id": "mod-non-interactive",
"annotations": {"requires_approval": true}
});
let result = check_approval_with_tty_timeout(&module_def, false, false, 42).await;
match result {
Err(ApprovalError::NonInteractive { module_id }) => {
assert_eq!(module_id, "mod-non-interactive");
}
other => panic!("expected NonInteractive, got {other:?}"),
}
}
#[test]
fn check_approval_with_tty_timeout_honors_custom_value_before_prompt() {
let _guard = ENV_MUTEX.lock().unwrap();
unsafe { std::env::remove_var("APCORE_CLI_AUTO_APPROVE") };
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(check_approval_with_tty_timeout_honors_custom_value_before_prompt_inner());
}
#[tokio::test]
async fn check_approval_with_timeout_honors_auto_approve_bypass() {
let module_def = json!({
"module_id": "mod-bypass",
"annotations": {"requires_approval": true}
});
let result = check_approval_with_timeout(&module_def, true, 7).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn prompt_with_reader_timeout_respects_nonzero_value() {
let result = prompt_with_reader("mod-threaded", "Needs approval.", 3, || {
std::thread::sleep(std::time::Duration::from_secs(30));
Ok("y\n".to_string())
})
.await;
match result {
Err(ApprovalError::Timeout { module_id, seconds }) => {
assert_eq!(module_id, "mod-threaded");
assert_eq!(seconds, 3, "timeout must propagate caller value, not 60");
}
other => panic!("expected Timeout with seconds=3, got {other:?}"),
}
}
#[tokio::test]
async fn handler_returns_approved_via_auto_approve_for_yes_flag() {
let handler = CliApprovalHandler::new(true, 60);
let result = handler.request_approval(&module(true)).await;
assert_eq!(result.status, ApprovalStatus::Approved);
assert_eq!(result.approved_by.as_deref(), Some("auto_approve"));
assert!(result.reason.is_none());
}
#[tokio::test]
async fn handler_returns_approved_not_required_when_no_annotation() {
let handler = CliApprovalHandler::new(false, 60);
let result = handler.request_approval(&module(false)).await;
assert_eq!(result.status, ApprovalStatus::Approved);
assert_eq!(result.approved_by.as_deref(), Some("not_required"));
}
#[test]
fn handler_returns_approved_via_env_for_one_value() {
let _guard = ENV_MUTEX.lock().unwrap();
unsafe { std::env::set_var("APCORE_CLI_AUTO_APPROVE", "1") };
let rt = tokio::runtime::Runtime::new().unwrap();
let handler = CliApprovalHandler::new(false, 60);
let result = rt.block_on(handler.request_approval(&module(true)));
unsafe { std::env::remove_var("APCORE_CLI_AUTO_APPROVE") };
assert_eq!(result.status, ApprovalStatus::Approved);
assert_eq!(result.approved_by.as_deref(), Some("env_auto_approve"));
}
#[test]
fn handler_yes_flag_priority_over_env() {
let _guard = ENV_MUTEX.lock().unwrap();
unsafe { std::env::set_var("APCORE_CLI_AUTO_APPROVE", "1") };
let rt = tokio::runtime::Runtime::new().unwrap();
let handler = CliApprovalHandler::new(true, 60);
let result = rt.block_on(handler.request_approval(&module(true)));
unsafe { std::env::remove_var("APCORE_CLI_AUTO_APPROVE") };
assert_eq!(result.status, ApprovalStatus::Approved);
assert_eq!(result.approved_by.as_deref(), Some("auto_approve"));
}
#[test]
fn approval_result_constructors_set_status_and_fields() {
let approved = ApprovalResult::approved_via("tty_user");
assert_eq!(approved.status, ApprovalStatus::Approved);
assert_eq!(approved.approved_by.as_deref(), Some("tty_user"));
assert!(approved.reason.is_none());
let rejected = ApprovalResult::rejected("user said no");
assert_eq!(rejected.status, ApprovalStatus::Rejected);
assert!(rejected.approved_by.is_none());
assert_eq!(rejected.reason.as_deref(), Some("user said no"));
let timeout = ApprovalResult::timed_out("60s expired");
assert_eq!(timeout.status, ApprovalStatus::Timeout);
assert!(timeout.approved_by.is_none());
assert_eq!(timeout.reason.as_deref(), Some("60s expired"));
}
#[tokio::test]
async fn handler_check_approval_aliases_request_approval() {
let handler = CliApprovalHandler::new(true, 60);
let request_result = handler.request_approval(&module(true)).await;
let check_result = handler.check_approval(&module(true)).await;
assert_eq!(request_result, check_result);
}
}