use std::path::Path;
use std::sync::Arc;
use apcore::middleware::logging::LoggingMiddleware;
use apcore::middleware::retry::{RetryConfig, RetryMiddleware};
use apcore::registry::registry::ModuleDescriptor;
use apcore::{Config, ErrorCode, Executor, ModuleError, Registry};
use apcore_mcp::{ApprovalStore, StorageBackedApprovalHandler};
use apcore_toolkit::ScannedModule;
use crate::module::{ApprovalGate, CliModule, FailureLogMiddleware, HealthOnlyCircuitBreaker};
use crate::output::load_modules_from_dir;
#[derive(Debug, Default, Clone)]
pub struct ModuleFilter {
pub prefix: Option<String>,
pub tags: Option<Vec<String>>,
}
impl ModuleFilter {
pub fn admits(&self, module: &ScannedModule) -> bool {
if let Some(ref prefix) = self.prefix {
if !module.module_id.starts_with(prefix.as_str()) {
return false;
}
}
if let Some(ref required) = self.tags {
if !required.iter().all(|tag| module.tags.contains(tag)) {
return false;
}
}
true
}
fn is_active(&self) -> bool {
self.prefix.is_some() || self.tags.is_some()
}
}
pub struct ExecutorOptions<'a> {
pub modules_dir: Option<&'a Path>,
pub timeout_ms: u64,
pub acl_path: Option<&'a Path>,
pub filter: ModuleFilter,
pub audit_path: Option<&'a Path>,
pub enable_logging: bool,
pub log_arguments: bool,
pub enable_approval: bool,
pub enable_circuit_breaker: bool,
pub enable_retry: bool,
pub approval_store: Option<Arc<dyn ApprovalStore>>,
}
#[allow(clippy::result_large_err)]
pub fn build_executor(opts: &ExecutorOptions<'_>) -> Result<Arc<Executor>, ModuleError> {
let modules = load_scanned_modules(opts.modules_dir)?;
let audit = opts
.audit_path
.map(|p| Arc::new(crate::governance::AuditManager::new(p)));
let admitted = admit_modules(modules, &opts.filter);
let registry = Registry::new();
register_modules(&admitted, ®istry, opts.timeout_ms, audit.clone());
tracing::info!(count = registry.count(), "Registered CLI modules");
let registered_ids: Vec<String> = registry.module_ids_full(true);
let mut executor = Executor::new(registry, Config::default());
install_middleware(&executor, opts, audit.clone());
install_acl(&mut executor, opts, ®istered_ids, audit.as_ref())?;
install_approval_handler(&mut executor, opts, audit.clone());
Ok(Arc::new(executor))
}
fn admit_modules(modules: Vec<ScannedModule>, filter: &ModuleFilter) -> Vec<ScannedModule> {
let loaded = modules.len();
let admitted: Vec<ScannedModule> = modules
.into_iter()
.filter(|module| filter.admits(module))
.collect();
if !filter.is_active() {
return admitted;
}
if admitted.is_empty() && loaded > 0 {
tracing::warn!(
prefix = ?filter.prefix,
tags = ?filter.tags,
loaded,
"Module filter excluded every loaded module; this server has NO callable \
tools. Check the spelling of --tags/--prefix against `apexe list`."
);
} else {
tracing::info!(
prefix = ?filter.prefix,
tags = ?filter.tags,
admitted = admitted.len(),
"Module filter active; excluded modules are neither listed nor callable"
);
}
admitted
}
fn install_middleware(
executor: &Executor,
opts: &ExecutorOptions<'_>,
audit: Option<Arc<crate::governance::AuditManager>>,
) {
if opts.enable_logging {
install_logging_middleware(executor, opts.log_arguments);
}
install_failure_log(executor, opts, audit);
if opts.enable_circuit_breaker {
let breaker = HealthOnlyCircuitBreaker::with_defaults();
if let Err(e) = executor.use_middleware(Box::new(breaker)) {
tracing::warn!(error = %e, "Failed to add HealthOnlyCircuitBreaker");
}
}
if opts.enable_retry {
let retry = RetryMiddleware::new(RetryConfig::default());
if let Err(e) = executor.use_middleware(Box::new(retry)) {
tracing::warn!(error = %e, "Failed to add RetryMiddleware");
}
}
}
#[allow(clippy::result_large_err)] fn install_acl(
executor: &mut Executor,
opts: &ExecutorOptions<'_>,
registered_ids: &[String],
audit: Option<&Arc<crate::governance::AuditManager>>,
) -> Result<(), ModuleError> {
let Some(acl_path) = opts.acl_path else {
return Ok(());
};
if !acl_path.exists() {
return Err(ModuleError::new(
ErrorCode::GeneralInvalidInput,
format!(
"ACL file not found: {} — refusing to start without the requested access control",
acl_path.display()
),
));
}
let mut acl = crate::governance::AclManager::from_config(acl_path)?.into_inner();
let report = crate::governance::validate_acl_rules(acl.rules(), registered_ids);
report.emit_warnings();
if let Some(error) = report.fatal_error(acl_path) {
return Err(error);
}
if let Some(audit) = audit {
let audit = audit.clone();
acl.set_audit_logger(move |entry| audit.log_acl_decision(entry));
}
executor.set_acl(acl);
tracing::info!(acl = %acl_path.display(), "ACL enforcement active");
Ok(())
}
fn install_approval_handler(
executor: &mut Executor,
opts: &ExecutorOptions<'_>,
audit: Option<Arc<crate::governance::AuditManager>>,
) {
if !opts.enable_approval {
return;
}
match &opts.approval_store {
Some(store) => {
executor.set_approval_handler(Box::new(ApprovalGate::wrapping(
Box::new(StorageBackedApprovalHandler::new(store.clone())),
audit.clone(),
)));
tracing::info!(
store_backed = true,
"Approval handler enabled for destructive commands"
);
}
None => {
executor.set_approval_handler(Box::new(ApprovalGate::with_audit(audit.clone())));
tracing::info!(
"Approval gate enabled: a call to a module marked `requires_approval` prompts \
the connected MCP client for a human decision. A client that declared no \
elicitation support cannot be prompted, so its calls are refused — use \
`--acl` for a per-caller boundary, or embed apexe as a library with an \
ApprovalStore for out-of-band approvals."
);
}
}
}
fn install_logging_middleware(executor: &Executor, log_arguments: bool) {
let logging = LoggingMiddleware::new(log_arguments, log_arguments, log_arguments);
if let Err(e) = executor.use_middleware(Box::new(logging)) {
tracing::warn!(error = %e, "Failed to add LoggingMiddleware");
}
}
fn install_failure_log(
executor: &Executor,
opts: &ExecutorOptions<'_>,
audit: Option<Arc<crate::governance::AuditManager>>,
) {
let emit_tracing_record = opts.enable_logging && !opts.log_arguments;
if !emit_tracing_record && audit.is_none() {
return;
}
let failure_log = FailureLogMiddleware::with_audit(audit, emit_tracing_record);
if let Err(e) = executor.use_middleware(Box::new(failure_log)) {
tracing::warn!(
error = %e,
"Failed to add FailureLogMiddleware; refused calls will reach neither \
the log nor the audit trail"
);
}
}
#[allow(clippy::result_large_err)] fn load_scanned_modules(modules_dir: Option<&Path>) -> Result<Vec<ScannedModule>, ModuleError> {
match modules_dir {
Some(dir) if dir.is_dir() => load_modules_from_dir(dir),
Some(dir) => {
tracing::warn!(
dir = %dir.display(),
"Modules directory not found, starting with zero tools"
);
Ok(vec![])
}
None => Ok(vec![]),
}
}
fn strip_mcp_alias(display: &mut serde_json::Value) {
if let Some(mcp) = display
.get_mut("mcp")
.and_then(serde_json::Value::as_object_mut)
{
mcp.remove("alias");
}
}
fn build_descriptor(scanned: &ScannedModule) -> ModuleDescriptor {
let mut metadata = scanned.metadata.clone();
if let Some(display) = metadata.get_mut("display") {
strip_mcp_alias(display);
}
let mut display = scanned.display.clone();
if let Some(display) = display.as_mut() {
strip_mcp_alias(display);
}
ModuleDescriptor {
module_id: scanned.module_id.clone(),
name: None,
description: scanned.description.clone(),
documentation: scanned.documentation.clone(),
input_schema: scanned.input_schema.clone(),
output_schema: scanned.output_schema.clone(),
version: scanned.version.clone(),
tags: scanned.tags.clone(),
annotations: Some(scanned.annotations.clone().unwrap_or_default()),
examples: scanned.examples.clone(),
metadata,
display,
sunset_date: None,
dependencies: vec![],
enabled: true,
}
}
fn register_modules(
modules: &[ScannedModule],
registry: &Registry,
timeout_ms: u64,
audit: Option<Arc<crate::governance::AuditManager>>,
) {
for scanned in modules {
let cli_module = match CliModule::from_scanned(scanned, timeout_ms) {
Ok(cli_module) => cli_module.with_audit(audit.clone()),
Err(e) => {
tracing::warn!(
module_id = scanned.module_id,
error = %e,
"Failed to create CliModule"
);
continue;
}
};
let module_id = scanned.module_id.clone();
let descriptor = build_descriptor(scanned);
if let Err(e) = registry.register(&module_id, Box::new(cli_module), descriptor) {
tracing::warn!(module_id, error = %e, "Failed to register module");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::output::YamlOutput;
use serde_json::json;
use tempfile::TempDir;
fn opts(modules_dir: Option<&Path>) -> ExecutorOptions<'_> {
ExecutorOptions {
modules_dir,
timeout_ms: 30_000,
acl_path: None,
filter: ModuleFilter::default(),
audit_path: None,
enable_logging: true,
log_arguments: true,
enable_approval: false,
enable_circuit_breaker: true,
enable_retry: true,
approval_store: None,
}
}
fn write_two_modules(dir: &Path) {
let git = ScannedModule::new(
"cli.git.log".to_string(),
"Show commit logs".to_string(),
json!({"type": "object"}),
json!({"type": "object"}),
vec!["cli".to_string(), "git".to_string()],
"exec:///usr/bin/git log".to_string(),
);
let cp = ScannedModule::new(
"cli.cp".to_string(),
"Copy files".to_string(),
json!({"type": "object"}),
json!({"type": "object"}),
vec!["cli".to_string(), "fileops".to_string()],
"exec:///bin/cp".to_string(),
);
YamlOutput::without_verification()
.write(&[git, cp], dir, false)
.unwrap();
}
fn installed_middleware(opts: &ExecutorOptions<'_>) -> Vec<String> {
let executor = Executor::new(Registry::new(), Config::default());
let audit = opts
.audit_path
.map(|p| Arc::new(crate::governance::AuditManager::new(p)));
install_middleware(&executor, opts, audit);
executor.middlewares()
}
#[test]
fn test_log_arguments_off_swaps_in_the_payload_free_failure_log() {
let mut opts = opts(None);
opts.log_arguments = false;
let installed = installed_middleware(&opts);
assert!(
installed.iter().any(|name| name == "apexe_failure_log"),
"a refused call must still produce an ERROR record; got {installed:?}"
);
}
#[test]
fn test_log_arguments_on_keeps_apcores_record_and_adds_no_second_one() {
let installed = installed_middleware(&opts(None));
assert!(installed.iter().any(|name| name == "logging"));
assert!(
!installed.iter().any(|name| name == "apexe_failure_log"),
"apcore already logs the failure here; got {installed:?}"
);
}
#[test]
fn test_refusal_auditing_survives_no_logging() {
let tmp = tempfile::TempDir::new().unwrap();
let audit_path = tmp.path().join("audit.jsonl");
let mut opts = opts(None);
opts.enable_logging = false;
opts.audit_path = Some(&audit_path);
let installed = installed_middleware(&opts);
assert!(
!installed.iter().any(|name| name == "logging"),
"--no-logging must still suppress apcore's logging middleware"
);
assert!(
installed.iter().any(|name| name == "apexe_failure_log"),
"refusals must keep reaching the audit trail; got {installed:?}"
);
}
#[test]
fn test_no_logging_installs_neither_logging_middleware() {
let dir = TempDir::new().unwrap();
write_two_modules(dir.path());
let mut options = opts(Some(dir.path()));
options.enable_logging = false;
options.log_arguments = false;
let executor = build_executor(&options).unwrap();
let installed = executor.middlewares();
assert!(!installed.iter().any(|name| name == "logging"));
assert!(!installed.iter().any(|name| name == "apexe_failure_log"));
}
#[test]
fn test_build_executor_prefix_filter_excludes_from_registry() {
let dir = TempDir::new().unwrap();
write_two_modules(dir.path());
let mut opts = opts(Some(dir.path()));
opts.filter = ModuleFilter {
prefix: Some("cli.git".to_string()),
tags: None,
};
let executor = build_executor(&opts).unwrap();
assert_eq!(executor.registry().count(), 1);
assert!(executor
.registry()
.get_definition("cli.cp")
.unwrap()
.is_none());
}
#[test]
fn test_build_executor_tags_filter_excludes_from_registry() {
let dir = TempDir::new().unwrap();
write_two_modules(dir.path());
let mut opts = opts(Some(dir.path()));
opts.filter = ModuleFilter {
prefix: None,
tags: Some(vec!["git".to_string()]),
};
let executor = build_executor(&opts).unwrap();
assert_eq!(executor.registry().count(), 1);
assert!(executor
.registry()
.get_definition("cli.cp")
.unwrap()
.is_none());
}
#[tokio::test]
async fn test_build_executor_filtered_module_is_not_callable() {
let dir = TempDir::new().unwrap();
write_two_modules(dir.path());
let mut opts = opts(Some(dir.path()));
opts.filter = ModuleFilter {
prefix: Some("zzz.".to_string()),
tags: None,
};
let executor = build_executor(&opts).unwrap();
let err = executor
.call("cli.cp", json!({}), None, None)
.await
.expect_err("a filtered-out module must not be callable");
assert_eq!(err.code, ErrorCode::ModuleNotFound);
}
#[test]
fn test_module_filter_never_admits_an_empty_tag() {
let dir = TempDir::new().unwrap();
write_two_modules(dir.path());
let mut opts = opts(Some(dir.path()));
opts.filter = ModuleFilter {
prefix: None,
tags: Some(vec!["git".to_string(), String::new()]),
};
let executor = build_executor(&opts).unwrap();
assert_eq!(executor.registry().count(), 0);
}
#[test]
fn test_module_filter_admits_everything_when_empty() {
let module = ScannedModule::new(
"cli.ls".to_string(),
"List".to_string(),
json!({"type": "object"}),
json!({"type": "object"}),
vec!["cli".to_string()],
"exec:///bin/ls".to_string(),
);
assert!(ModuleFilter::default().admits(&module));
}
#[test]
fn test_module_filter_tags_require_all() {
let module = ScannedModule::new(
"cli.ls".to_string(),
"List".to_string(),
json!({"type": "object"}),
json!({"type": "object"}),
vec!["cli".to_string(), "readonly".to_string()],
"exec:///bin/ls".to_string(),
);
let one = ModuleFilter {
prefix: None,
tags: Some(vec!["readonly".to_string()]),
};
assert!(one.admits(&module));
let both = ModuleFilter {
prefix: None,
tags: Some(vec!["readonly".to_string(), "git".to_string()]),
};
assert!(!both.admits(&module));
}
#[test]
fn test_build_executor_no_modules_dir() {
let executor = build_executor(&opts(None)).unwrap();
assert_eq!(executor.registry().count(), 0);
}
#[test]
fn test_build_executor_wires_resilience_middleware() {
let executor = build_executor(&opts(None)).unwrap();
let names = executor.middlewares();
assert!(names.contains(&"circuit_breaker".to_string()));
assert!(names.contains(&"retry".to_string()));
}
#[test]
fn test_build_executor_resilience_middleware_optional() {
let mut opts = opts(None);
opts.enable_circuit_breaker = false;
opts.enable_retry = false;
let executor = build_executor(&opts).unwrap();
let names = executor.middlewares();
assert!(!names.contains(&"circuit_breaker".to_string()));
assert!(!names.contains(&"retry".to_string()));
}
#[test]
fn test_build_executor_fails_closed_on_missing_acl_file() {
let mut opts = opts(None);
let missing = Path::new("/nonexistent/does-not-exist.acl.yaml");
opts.acl_path = Some(missing);
let result = build_executor(&opts);
assert!(
result.is_err(),
"build_executor must fail when --acl points to a missing file"
);
}
#[test]
fn test_build_executor_fails_closed_on_malformed_acl_file() {
let dir = TempDir::new().unwrap();
let acl_path = dir.path().join("acl.yaml");
std::fs::write(&acl_path, "this: is: not: valid: acl: [[[").unwrap();
let mut opts = opts(None);
opts.acl_path = Some(&acl_path);
let result = build_executor(&opts);
assert!(
result.is_err(),
"build_executor must fail when --acl file is malformed"
);
}
fn write_acl(dir: &Path, rules_yaml: &str) -> std::path::PathBuf {
let path = dir.join("acl.yaml");
std::fs::write(&path, format!("default_effect: deny\nrules:\n{rules_yaml}")).unwrap();
path
}
#[test]
fn test_build_executor_fails_closed_on_empty_acl_target_list() {
let dir = TempDir::new().unwrap();
write_two_modules(dir.path());
let acl_path = write_acl(
dir.path(),
" - callers: [\"*\"]\n targets: []\n effect: deny\n",
);
let mut opts = opts(Some(dir.path()));
opts.acl_path = Some(&acl_path);
let err = build_executor(&opts).expect_err("an inert deny rule must refuse to start");
assert_eq!(err.code, ErrorCode::GeneralInvalidInput);
assert!(
err.message.contains("empty list"),
"error should name the defect: {}",
err.message
);
}
#[test]
fn test_build_executor_fails_closed_on_misspelled_acl_target() {
let dir = TempDir::new().unwrap();
write_two_modules(dir.path());
let acl_path = write_acl(
dir.path(),
" - callers: [\"*\"]\n targets: [\"cp\"]\n effect: deny\n",
);
let mut opts = opts(Some(dir.path()));
opts.acl_path = Some(&acl_path);
let err = build_executor(&opts).expect_err("a near-miss target must refuse to start");
assert!(
err.message.contains("cli.cp"),
"error should name the spelling that works: {}",
err.message
);
}
#[test]
fn test_build_executor_accepts_acl_targeting_registered_modules() {
let dir = TempDir::new().unwrap();
write_two_modules(dir.path());
let acl_path = write_acl(
dir.path(),
" - callers: [\"*\"]\n targets: [\"cli.cp\"]\n effect: deny\n \
- callers: [\"*\"]\n targets: [\"cli.git.*\"]\n effect: allow\n",
);
let mut opts = opts(Some(dir.path()));
opts.acl_path = Some(&acl_path);
assert!(build_executor(&opts).is_ok(), "a correct ACL must load");
}
#[test]
fn test_build_executor_tolerates_acl_target_for_filtered_out_module() {
let dir = TempDir::new().unwrap();
write_two_modules(dir.path());
let acl_path = write_acl(
dir.path(),
" - callers: [\"*\"]\n targets: [\"cli.cp\"]\n effect: deny\n",
);
let mut opts = opts(Some(dir.path()));
opts.acl_path = Some(&acl_path);
opts.filter = ModuleFilter {
prefix: Some("cli.git".to_string()),
tags: None,
};
assert!(
build_executor(&opts).is_ok(),
"a target excluded by the module filter must warn, not refuse"
);
}
#[test]
fn test_build_executor_registers_modules_with_display_metadata() {
let dir = TempDir::new().unwrap();
let modules = vec![ScannedModule::new(
"echo.hello".to_string(),
"Echo hello".to_string(),
json!({"type": "object"}),
json!({"type": "object"}),
vec!["cli".to_string()],
"exec:///bin/echo hello".to_string(),
)];
let output = YamlOutput::without_verification();
output.write(&modules, dir.path(), false).unwrap();
let executor = build_executor(&opts(Some(dir.path()))).unwrap();
assert_eq!(executor.registry().count(), 1);
let descriptor = executor
.registry()
.get_definition("echo.hello")
.unwrap()
.expect("module should be registered");
assert_eq!(descriptor.module_id, "echo.hello");
assert_eq!(descriptor.description, "Echo hello");
}
#[tokio::test]
async fn test_build_executor_approval_store_makes_calls_non_blocking() {
use apcore::module::ModuleAnnotations;
use apcore_mcp::InMemoryApprovalStore;
let dir = TempDir::new().unwrap();
let mut module = ScannedModule::new(
"cli.destroy".to_string(),
"Destroy something".to_string(),
json!({"type": "object"}),
json!({"type": "object"}),
vec!["cli".to_string()],
"exec:///bin/echo destroyed".to_string(),
);
module.annotations = Some(ModuleAnnotations {
destructive: true,
requires_approval: true,
..Default::default()
});
YamlOutput::without_verification()
.write(&[module], dir.path(), false)
.unwrap();
let store: Arc<dyn ApprovalStore> = Arc::new(InMemoryApprovalStore::new());
let mut opts = opts(Some(dir.path()));
opts.enable_approval = true;
opts.approval_store = Some(store);
let executor = build_executor(&opts).unwrap();
let result = executor.call("cli.destroy", json!({}), None, None).await;
assert!(
result.is_err(),
"requires_approval module should not execute before approval"
);
}
}