use std::collections::HashSet;
use std::sync::Arc;
use crate::cm_types::CommandApprovalDecision;
use log::debug;
use tokio::sync::{Mutex, mpsc};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SensitiveCapability {
HostShell,
OutboundHttpRead,
OutboundHttpWrite,
WorkflowGate,
WorkspaceExternalPath,
}
pub struct WebApprovalSink<'a> {
pub out_tx: &'a mpsc::Sender<String>,
pub approval_rx_shared: &'a Arc<Mutex<mpsc::Receiver<CommandApprovalDecision>>>,
pub approval_request_guard: &'a Arc<Mutex<()>>,
}
#[derive(Debug, Clone)]
pub struct ApprovalRequestSpec {
pub capability: SensitiveCapability,
pub sse_command: String,
pub sse_args: String,
pub allowlist_key: Option<String>,
pub cli_title: &'static str,
pub cli_detail: String,
pub web_timeline_prefix_zh: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WebApprovalChannelMode {
Strict,
Lenient,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolApprovalWebError {
ChannelUnavailable,
}
pub struct SharedAllowlistHandles<'a> {
pub web: Option<&'a Arc<Mutex<HashSet<String>>>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InteractiveGateOutcome {
Allowed,
Denied(String),
}
pub(crate) fn web_timeline_detail(spec: &ApprovalRequestSpec) -> String {
let a = spec.sse_args.trim();
if a.is_empty() {
spec.sse_command.clone()
} else {
format!("{} {}", spec.sse_command, a)
}
}
pub async fn persist_allowlist_key(handles: &SharedAllowlistHandles<'_>, key: &str) {
if let Some(w) = handles.web {
w.lock().await.insert(key.to_string());
}
}
pub async fn run_web_tool_approval(
sink: WebApprovalSink<'_>,
spec: &ApprovalRequestSpec,
sse_log_label: &'static str,
channel_mode: WebApprovalChannelMode,
) -> Result<CommandApprovalDecision, ToolApprovalWebError> {
debug!(
target: "crabmate",
"tool_approval web round capability={:?} command={} mode={:?}",
spec.capability,
spec.sse_command,
channel_mode
);
let decision = {
let _guard = sink.approval_request_guard.lock().await;
let line = crate::cm_sse_protocol::sse::encode_message(
crate::cm_sse_protocol::sse::SsePayload::CommandApproval {
command_approval_request: crate::cm_sse_protocol::sse::CommandApprovalBody {
command: spec.sse_command.clone(),
args: spec.sse_args.clone(),
allowlist_key: spec.allowlist_key.clone(),
},
},
);
let sent =
crate::cm_sse_protocol::sse::send_string_logged(sink.out_tx, line, sse_log_label).await;
if matches!(channel_mode, WebApprovalChannelMode::Strict) && !sent {
return Err(ToolApprovalWebError::ChannelUnavailable);
}
let mut rx_guard = sink.approval_rx_shared.lock().await;
rx_guard
.recv()
.await
.unwrap_or(CommandApprovalDecision::Deny)
};
let detail = web_timeline_detail(spec);
crate::cm_sse_protocol::sse::web_approval::send_timeline_approval_decision(
sink.out_tx,
spec.web_timeline_prefix_zh,
Some(detail),
decision,
"tool_approval::web_timeline",
)
.await;
Ok(decision)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn web_timeline_detail_empty_args() {
let spec = ApprovalRequestSpec {
capability: SensitiveCapability::HostShell,
sse_command: "git".to_string(),
sse_args: " ".to_string(),
allowlist_key: None,
cli_title: "t",
cli_detail: String::new(),
web_timeline_prefix_zh: "p",
};
assert_eq!(web_timeline_detail(&spec), "git");
}
#[test]
fn web_timeline_detail_with_args() {
let spec = ApprovalRequestSpec {
capability: SensitiveCapability::OutboundHttpRead,
sse_command: "http_fetch".to_string(),
sse_args: "GET https://a/".to_string(),
allowlist_key: None,
cli_title: "t",
cli_detail: String::new(),
web_timeline_prefix_zh: "p",
};
assert_eq!(web_timeline_detail(&spec), "http_fetch GET https://a/");
}
}