use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use async_trait::async_trait;
use mecha_core::tool::{Approver, Decision, Tool};
use serde_json::Value;
use tokio::sync::{mpsc, oneshot};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Ask,
Allow,
ReadOnly,
}
impl Mode {
pub fn parse(s: &str) -> Option<Mode> {
match s {
"ask" => Some(Mode::Ask),
"allow" => Some(Mode::Allow),
"read-only" | "read_only" => Some(Mode::ReadOnly),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Mode::Ask => "ask",
Mode::Allow => "allow",
Mode::ReadOnly => "read-only",
}
}
}
#[derive(Debug)]
pub struct Request {
pub thread_key: String,
pub tool: String,
pub summary: String,
pub reply: oneshot::Sender<Answer>,
}
#[derive(Debug, Clone)]
pub enum Answer {
Approve,
ApproveForRun,
Reject(String),
}
pub struct SlackApprover {
thread_key: String,
mode: Arc<Mutex<Mode>>,
tx: mpsc::Sender<Request>,
timeout: Duration,
blanket: Mutex<std::collections::HashSet<String>>,
unanswered: Arc<AtomicBool>,
}
impl SlackApprover {
pub fn new(
thread_key: impl Into<String>,
mode: Arc<Mutex<Mode>>,
tx: mpsc::Sender<Request>,
timeout: Duration,
) -> Self {
Self {
thread_key: thread_key.into(),
mode,
tx,
timeout,
blanket: Mutex::new(std::collections::HashSet::new()),
unanswered: Arc::new(AtomicBool::new(false)),
}
}
pub fn unanswered_latch(&self) -> Arc<AtomicBool> {
Arc::clone(&self.unanswered)
}
fn mode(&self) -> Mode {
self.mode.lock().map(|m| *m).unwrap_or(Mode::Ask)
}
}
fn humanise(d: Duration) -> String {
let secs = d.as_secs();
if secs >= 60 && secs.is_multiple_of(60) {
format!("{}m", secs / 60)
} else {
format!("{secs}s")
}
}
#[async_trait]
impl Approver for SlackApprover {
async fn approve(&self, tool: &dyn Tool, input: &Value) -> Decision {
match self.mode() {
Mode::Allow => return Decision::Allow,
Mode::ReadOnly if !tool.read_only() => {
return Decision::Blocked(format!(
"`{}` modifies state and this thread is read-only",
tool.name()
));
}
_ => {}
}
if self.blanket.lock().is_ok_and(|b| b.contains(tool.name())) {
return Decision::Allow;
}
if self.unanswered.load(Ordering::Relaxed) {
return Decision::Blocked(
"nobody answered an earlier approval card in this run, so this \
call was not asked. Reply in the thread, or press a button on \
an approval card, and this run will ask again on its next \
gated call."
.into(),
);
}
let (reply, answer) = oneshot::channel();
let request = Request {
thread_key: self.thread_key.clone(),
tool: tool.name().to_string(),
summary: summarise(tool.name(), input),
reply,
};
if self.tx.send(request).await.is_err() {
return Decision::Blocked(
"the Slack connection is gone, so nobody could be asked".into(),
);
}
match tokio::time::timeout(self.timeout, answer).await {
Ok(Ok(Answer::Approve)) => Decision::Allow,
Ok(Ok(Answer::ApproveForRun)) => {
if let Ok(mut b) = self.blanket.lock() {
b.insert(tool.name().to_string());
}
Decision::Allow
}
Ok(Ok(Answer::Reject(reason))) => Decision::Deny(reason),
Ok(Err(_)) => {
Decision::Blocked("the approval was dropped before anyone answered it".into())
}
Err(_) => {
self.unanswered.store(true, Ordering::Relaxed);
Decision::Blocked(format!(
"nobody answered in Slack within {}",
humanise(self.timeout)
))
}
}
}
}
fn summarise(tool: &str, input: &Value) -> String {
let detail = input
.get("command")
.or_else(|| input.get("path"))
.or_else(|| input.get("url"))
.and_then(Value::as_str)
.map(|s| s.chars().take(160).collect::<String>());
match detail {
Some(d) => format!("{tool}: {d}"),
None => tool.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use mecha_core::tool::ToolCtx;
use serde_json::json;
struct Fake {
name: &'static str,
read_only: bool,
}
#[async_trait]
impl Tool for Fake {
fn name(&self) -> &str {
self.name
}
fn description(&self) -> &str {
"a test tool"
}
fn input_schema(&self) -> Value {
json!({"type": "object"})
}
fn read_only(&self) -> bool {
self.read_only
}
async fn call(
&self,
_input: Value,
_ctx: &ToolCtx,
) -> anyhow::Result<mecha_core::tool::ToolOutput> {
unreachable!("the approver never runs the tool")
}
}
fn writer() -> Fake {
Fake {
name: "fs_write",
read_only: false,
}
}
fn approver(mode: Mode, timeout: Duration) -> (SlackApprover, mpsc::Receiver<Request>) {
let (tx, rx) = mpsc::channel(8);
(
SlackApprover::new("D1-1.0", Arc::new(Mutex::new(mode)), tx, timeout),
rx,
)
}
#[tokio::test]
async fn allow_mode_never_asks() {
let (a, mut rx) = approver(Mode::Allow, Duration::from_secs(1));
assert!(matches!(
a.approve(&writer(), &json!({})).await,
Decision::Allow
));
assert!(rx.try_recv().is_err(), "nothing should have been asked");
}
#[tokio::test]
async fn read_only_blocks_a_writer_without_asking_and_it_is_not_a_user_denial() {
let (a, mut rx) = approver(Mode::ReadOnly, Duration::from_secs(1));
match a.approve(&writer(), &json!({})).await {
Decision::Blocked(reason) => assert!(reason.contains("read-only"), "{reason}"),
other => panic!("expected Blocked, got {other:?}"),
}
assert!(rx.try_recv().is_err());
}
#[tokio::test]
async fn a_human_rejection_is_a_denial_and_carries_the_reason() {
let (a, mut rx) = approver(Mode::Ask, Duration::from_secs(5));
let task = tokio::spawn(async move {
let req = rx.recv().await.expect("a request");
req.reply.send(Answer::Reject("not that file".into())).ok();
});
match a.approve(&writer(), &json!({"path": "secrets.txt"})).await {
Decision::Deny(reason) => assert_eq!(reason, "not that file"),
other => panic!("expected Deny, got {other:?}"),
}
task.await.unwrap();
}
#[tokio::test]
async fn an_unanswered_approval_is_blocked_and_never_denied() {
let (a, _rx) = approver(Mode::Ask, Duration::from_millis(30));
match a.approve(&writer(), &json!({})).await {
Decision::Blocked(reason) => {
assert!(reason.contains("nobody answered"), "{reason}");
assert!(reason.contains("30s") || reason.contains("0m") || !reason.is_empty());
}
other => panic!("a timeout must never be a user denial, got {other:?}"),
}
}
#[tokio::test]
async fn after_one_timeout_the_run_stops_asking_and_is_refused_at_once() {
let (a, mut rx) = approver(Mode::Ask, Duration::from_millis(50));
assert!(matches!(
a.approve(&writer(), &json!({})).await,
Decision::Blocked(_)
));
assert!(rx.try_recv().is_ok(), "the first ask posted a card");
let start = std::time::Instant::now();
match a.approve(&writer(), &json!({})).await {
Decision::Blocked(reason) => {
assert!(reason.contains("earlier"), "{reason}");
assert!(reason.contains("ask again"), "{reason}");
assert!(!reason.contains("fresh run"), "{reason}");
}
other => panic!("expected Blocked, got {other:?}"),
}
assert!(
start.elapsed() < Duration::from_millis(50),
"the second ask must not wait out another timeout"
);
assert!(rx.try_recv().is_err(), "no second card was posted");
}
#[tokio::test]
async fn clearing_the_shared_latch_makes_the_next_call_ask_again() {
let (a, mut rx) = approver(Mode::Ask, Duration::from_millis(250));
assert!(matches!(
a.approve(&writer(), &json!({})).await,
Decision::Blocked(_)
));
assert!(rx.try_recv().is_ok(), "the first ask posted a card");
assert!(matches!(
a.approve(&writer(), &json!({})).await,
Decision::Blocked(_)
));
assert!(rx.try_recv().is_err(), "no card while latched");
a.unanswered_latch().store(false, Ordering::Relaxed);
let responder = tokio::spawn(async move {
let req = rx
.recv()
.await
.expect("a fresh card once the latch is cleared");
req.reply.send(Answer::Approve).ok();
});
assert!(matches!(
a.approve(&writer(), &json!({})).await,
Decision::Allow
));
responder.await.unwrap();
}
#[tokio::test]
async fn a_dropped_or_closed_channel_fails_closed() {
let (a, rx) = approver(Mode::Ask, Duration::from_secs(5));
drop(rx);
assert!(matches!(
a.approve(&writer(), &json!({})).await,
Decision::Blocked(_)
));
let (a, mut rx) = approver(Mode::Ask, Duration::from_secs(5));
let task = tokio::spawn(async move {
let req = rx.recv().await.expect("a request");
drop(req.reply); });
assert!(matches!(
a.approve(&writer(), &json!({})).await,
Decision::Blocked(_)
));
task.await.unwrap();
}
#[tokio::test]
async fn a_mode_change_takes_effect_on_the_next_call() {
let mode = Arc::new(Mutex::new(Mode::Ask));
let (tx, mut rx) = mpsc::channel(8);
let a = SlackApprover::new("D1-1.0", Arc::clone(&mode), tx, Duration::from_secs(5));
let task = tokio::spawn(async move {
let req = rx.recv().await.expect("a request while asking");
req.reply.send(Answer::Approve).ok();
rx.recv().await
});
assert!(matches!(
a.approve(&writer(), &json!({})).await,
Decision::Allow
));
*mode.lock().unwrap() = Mode::Allow;
assert!(matches!(
a.approve(&writer(), &json!({})).await,
Decision::Allow
));
drop(a);
assert!(
task.await.unwrap().is_none(),
"the second call asked nobody"
);
}
#[tokio::test]
async fn approving_for_the_run_stops_asking_about_that_tool_only() {
let (a, mut rx) = approver(Mode::Ask, Duration::from_secs(5));
let task = tokio::spawn(async move {
let first = rx.recv().await.expect("the first ask");
first.reply.send(Answer::ApproveForRun).ok();
rx.recv().await.map(|r| {
let name = r.tool.clone();
r.reply.send(Answer::Approve).ok();
name
})
});
assert!(matches!(
a.approve(&writer(), &json!({})).await,
Decision::Allow
));
assert!(matches!(
a.approve(&writer(), &json!({})).await,
Decision::Allow
));
let other = Fake {
name: "shell",
read_only: false,
};
assert!(matches!(
a.approve(&other, &json!({})).await,
Decision::Allow
));
assert_eq!(
task.await.unwrap().as_deref(),
Some("shell"),
"a blanket on one tool must not cover another"
);
}
#[test]
fn modes_round_trip_through_their_names() {
for mode in [Mode::Ask, Mode::Allow, Mode::ReadOnly] {
assert_eq!(Mode::parse(mode.as_str()), Some(mode));
}
assert_eq!(Mode::parse("bypass"), None, "unknown modes are not guessed");
}
#[test]
fn a_summary_names_what_the_call_will_actually_do() {
assert_eq!(
summarise("shell", &json!({"command": "rm -rf build"})),
"shell: rm -rf build"
);
assert_eq!(summarise("todo", &json!({})), "todo");
let long = summarise("shell", &json!({"command": "x".repeat(500)}));
assert!(long.chars().count() <= 160 + "shell: ".len());
}
}