use std::sync::Arc;
use apcore::approval::{ApprovalHandler, ApprovalRequest, ApprovalResult};
use apcore::{ErrorCode, ModuleError};
use apcore_mcp::ElicitationApprovalHandler;
use async_trait::async_trait;
const NO_PROMPT_REASONS: [&str; 2] = [
"No context available for elicitation",
"No elicitation callback available",
];
const NO_ANSWER_REASON: &str = "Elicitation returned no response";
const APPROVAL_TOKEN_LOOKUP_MODULE_ID: &str = "<approval-token-lookup>";
#[derive(Debug, PartialEq, Eq)]
enum Disposition {
Granted,
Pending,
NoPromptAvailable,
NoAnswer,
Refused,
}
fn classify(result: &ApprovalResult) -> Disposition {
match result.status.as_str() {
"approved" => return Disposition::Granted,
"pending" => return Disposition::Pending,
_ => {}
}
match result.reason.as_deref() {
Some(reason) if NO_PROMPT_REASONS.contains(&reason) => Disposition::NoPromptAvailable,
Some(NO_ANSWER_REASON) => Disposition::NoAnswer,
_ => Disposition::Refused,
}
}
fn no_answer_reason(module_id: &str) -> String {
format!(
"Module '{module_id}' is marked `requires_approval` and no answer to the approval prompt \
came back. The client may not support MCP elicitation, or the connection may have \
dropped before the prompt was answered — this is not a human declining. Retry, connect a \
client that supports elicitation, use `--acl` to grant specific callers access to \
specific modules, or embed apexe as a library with an `ApprovalStore` for out-of-band \
approvals."
)
}
fn no_prompt_reason(module_id: &str) -> String {
format!(
"Module '{module_id}' is marked `requires_approval` and this connection cannot be \
prompted: the client declared no MCP elicitation support when it initialized, so there \
is nobody to ask. This is a refusal for want of a prompt, not a human declining one. \
Connect a client that supports elicitation, use `--acl` to grant specific callers access \
to specific modules, or embed apexe as a library with an `ApprovalStore` for \
out-of-band approvals."
)
}
pub struct ApprovalGate {
inner: Box<dyn ApprovalHandler>,
audit: Option<Arc<crate::governance::AuditManager>>,
}
impl ApprovalGate {
pub fn new() -> Self {
Self::with_audit(None)
}
pub fn with_audit(audit: Option<Arc<crate::governance::AuditManager>>) -> Self {
Self::wrapping(Box::new(ElicitationApprovalHandler::new(None)), audit)
}
pub fn wrapping(
inner: Box<dyn ApprovalHandler>,
audit: Option<Arc<crate::governance::AuditManager>>,
) -> Self {
Self { inner, audit }
}
async fn audit_refusal(
&self,
module_id: &str,
trace_id: &str,
caller_id: Option<&str>,
approval_id: Option<&str>,
) {
let Some(ref audit) = self.audit else {
return;
};
audit
.log_refusal(
module_id,
trace_id,
caller_id,
approval_id,
ErrorCode::ApprovalDenied,
0,
)
.await;
}
async fn audit_request_refusal(&self, request: &ApprovalRequest) {
let context = request.context.as_ref();
self.audit_refusal(
&request.module_id,
context.map_or("", |ctx| ctx.trace_id.as_str()),
context
.and_then(|ctx| ctx.identity.as_ref())
.map(|id| id.id()),
None,
)
.await;
}
}
impl std::fmt::Debug for ApprovalGate {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ApprovalGate")
.field("has_audit", &self.audit.is_some())
.finish()
}
}
impl Default for ApprovalGate {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl ApprovalHandler for ApprovalGate {
async fn request_approval(
&self,
request: &ApprovalRequest,
) -> Result<ApprovalResult, ModuleError> {
let mut result = self.inner.request_approval(request).await?;
match classify(&result) {
Disposition::Granted => {
tracing::info!(
module_id = %request.module_id,
"Approval granted"
);
return Ok(result);
}
Disposition::Pending => {
tracing::info!(
module_id = %request.module_id,
"Approval recorded as pending for out-of-band resolution"
);
return Ok(result);
}
Disposition::NoPromptAvailable => {
tracing::warn!(
module_id = %request.module_id,
"Denying approval-gated call: this client declared no elicitation support"
);
result.reason = Some(no_prompt_reason(&request.module_id));
}
Disposition::NoAnswer => {
tracing::warn!(
module_id = %request.module_id,
"Denying approval-gated call: the prompt went out and no answer came back"
);
result.reason = Some(no_answer_reason(&request.module_id));
}
Disposition::Refused => {
tracing::info!(
module_id = %request.module_id,
reason = ?result.reason,
"Approval refused"
);
}
}
self.audit_request_refusal(request).await;
Ok(result)
}
async fn check_approval(&self, approval_id: &str) -> Result<ApprovalResult, ModuleError> {
let result = self.inner.check_approval(approval_id).await?;
if matches!(
classify(&result),
Disposition::Granted | Disposition::Pending
) {
return Ok(result);
}
tracing::warn!(
approval_id,
reason = ?result.reason,
"Refusing an approval-token lookup: this gate holds no pending approvals"
);
self.audit_refusal(APPROVAL_TOKEN_LOOKUP_MODULE_ID, "", None, Some(approval_id))
.await;
Ok(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn request(module_id: &str) -> ApprovalRequest {
let mut request = ApprovalRequest::default();
request.module_id = module_id.to_string();
request
}
fn result_with(status: &str, reason: Option<&str>) -> ApprovalResult {
let mut result = ApprovalResult::default();
result.status = status.to_string();
result.reason = reason.map(str::to_string);
result
}
#[tokio::test]
async fn test_a_granted_approval_is_never_recorded_as_a_refusal() {
assert_eq!(
classify(&result_with("approved", None)),
Disposition::Granted
);
}
#[tokio::test]
async fn test_a_humans_refusal_keeps_its_own_reason() {
assert_eq!(
classify(&result_with("rejected", Some("User action: decline"))),
Disposition::Refused
);
}
#[tokio::test]
async fn test_an_undeliverable_prompt_is_distinguished_from_a_refusal() {
for reason in NO_PROMPT_REASONS {
assert_eq!(
classify(&result_with("rejected", Some(reason))),
Disposition::NoPromptAvailable,
"{reason} means no prompt reached anyone"
);
}
}
#[tokio::test]
async fn test_an_unanswered_prompt_does_not_claim_the_client_lacks_the_capability() {
assert_eq!(
classify(&result_with("rejected", Some(NO_ANSWER_REASON))),
Disposition::NoAnswer
);
let unanswered = no_answer_reason("cli.rm");
let unpromptable = no_prompt_reason("cli.rm");
assert_ne!(
unanswered, unpromptable,
"the two outcomes must not read as the same diagnosis"
);
assert!(
!unanswered.contains("declared no MCP elicitation support"),
"an unanswered prompt must not assert a cause it did not observe: {unanswered}"
);
assert!(
unanswered.contains("--acl"),
"it must still name the alternative: {unanswered}"
);
}
#[tokio::test]
async fn test_no_elicitation_path_is_reported_with_a_remedy() {
let result = ApprovalGate::new()
.request_approval(&request("cli.rm"))
.await
.expect("the gate answers rather than erroring");
assert_eq!(result.status, "rejected");
let reason = result.reason.expect("a reason must be attached");
assert!(
reason.contains("cli.rm"),
"reason names the module: {reason}"
);
assert!(
reason.contains("no MCP elicitation support"),
"reason says why no prompt arrived: {reason}"
);
assert!(
reason.contains("--acl"),
"reason names the alternative: {reason}"
);
}
#[tokio::test]
async fn test_refusal_reaches_the_audit_trail() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("audit.jsonl");
let gate =
ApprovalGate::with_audit(Some(Arc::new(crate::governance::AuditManager::new(&path))));
gate.request_approval(&request("cli.rm"))
.await
.expect("the gate answers rather than erroring");
let content = std::fs::read_to_string(&path).expect("a refusal must be recorded");
let entry: serde_json::Value = serde_json::from_str(content.trim()).unwrap();
assert_eq!(entry["event"], "refusal");
assert_eq!(entry["module_id"], "cli.rm");
assert_eq!(entry["error_code"], "APPROVAL_DENIED");
}
#[tokio::test]
async fn test_an_approval_token_lookup_is_audited_like_any_other_refusal() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("audit.jsonl");
let gate =
ApprovalGate::with_audit(Some(Arc::new(crate::governance::AuditManager::new(&path))));
let result = gate
.check_approval("caller-supplied-token")
.await
.expect("the gate answers rather than erroring");
assert_eq!(result.status, "rejected");
let content = std::fs::read_to_string(&path).expect("a refusal must be recorded");
let entry: serde_json::Value = serde_json::from_str(content.trim()).unwrap();
assert_eq!(entry["event"], "refusal");
assert_eq!(entry["error_code"], "APPROVAL_DENIED");
assert_eq!(
entry["module_id"], "<approval-token-lookup>",
"a caller-supplied token must never become the audit record's module_id: {entry}"
);
assert_eq!(
entry["approval_id"], "caller-supplied-token",
"the token must still be recorded, just not as module_id: {entry}"
);
assert!(
entry.get("trace_id").is_none(),
"no context reaches this path, so the join key is omitted rather than \
claimed blank: {entry}"
);
}
#[tokio::test]
async fn test_refusal_without_an_audit_sink_writes_nothing() {
let result = ApprovalGate::new()
.request_approval(&request("cli.rm"))
.await
.expect("the gate answers rather than erroring");
assert_eq!(result.status, "rejected");
}
#[tokio::test]
async fn test_check_approval_reports_nothing_pending() {
let result = ApprovalGate::new()
.check_approval("any-id")
.await
.expect("the gate answers rather than erroring");
assert_eq!(result.status, "rejected");
}
}