//! Inbound Slack @mentions: signature verified BY the broker, a verified //! mention drives a real run, and the agent's reply is gated as usual. use std::sync::Arc; use std::time::Duration; use cm_api::AppState; use cm_auth::AuthService; use cm_domain::{Role, User, UserId, Workspace, WorkspaceId}; use cm_llm::ScriptedProvider; use cm_runtime::{Runtime, RuntimeConfig}; use cm_secrets::{BrokerServer, FileKey}; use hmac::{Hmac, Mac}; use serde_json::{json, Value}; const SCENARIOS: &str = r##" [[scenario]] marker = "[[scenario:mention]]" [[scenario.turns]] events = [ { type = "tool_use", name = "slack.post", input = { channel = "#general", text = "On it!" } }, ] [[scenario.turns]] events = [ { type = "text", text = " Replied in Slack." }, ] "##; fn sign(secret: &str, timestamp: &str, body: &str) -> String { let mut mac = Hmac::::new_from_slice(secret.as_bytes()).unwrap(); mac.update(format!("v0:{timestamp}:{body}").as_bytes()); format!("v0={}", hex::encode(mac.finalize().into_bytes())) } async fn spawn_broker(pool: sqlx::PgPool) -> std::path::PathBuf { let dir = std::env::temp_dir().join(format!("tc-in-{}", uuid::Uuid::now_v7().simple())); std::fs::create_dir_all(&dir).unwrap(); let key_path = dir.join("broker.key"); FileKey::generate(&key_path).unwrap(); let key = FileKey::load(&key_path).unwrap(); let short = uuid::Uuid::now_v7().simple().to_string(); let socket = std::path::PathBuf::from(format!("/tmp/tci-{}.sock", &short[short.len() - 12..])); let server = BrokerServer::new(pool, key, socket.clone()); tokio::spawn(async move { server.serve().await.unwrap(); }); for _ in 0..50 { if socket.exists() { break; } tokio::time::sleep(Duration::from_millis(20)).await; } socket } #[tokio::test] async fn mention_round_trip_verifies_runs_and_gates_the_reply() { let pool = cm_testkit::test_pool().await; let socket = spawn_broker(pool.clone()).await; let runtime = Runtime::new( pool.clone(), Arc::new(ScriptedProvider::from_toml(SCENARIOS).unwrap()), RuntimeConfig { model: "scripted".into(), max_tokens: 1024, sandboxes: None, browser: None, terminals: None, broker_socket: Some(socket.clone()), slack_base_url: "http://127.0.0.1:1".into(), // never reached here providers: Default::default(), }, ); let app = cm_api::router(AppState::new(pool.clone(), runtime).with_broker(socket.clone())); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); let base = format!("http://{addr}"); let client = reqwest::Client::new(); // Seed + connect Slack with a JSON secret (bot token + signing secret). let ws = Workspace { id: WorkspaceId::new(), name: "Acme".into(), plan: "team".into(), }; cm_db::repo::workspaces::insert(&pool, &ws).await.unwrap(); let owner = User { id: UserId::new(), workspace_id: ws.id, email: format!("{}@acme.test", UserId::new()), role: Role::Owner, display_name: "Owner".into(), created_at: time::OffsetDateTime::UNIX_EPOCH, }; cm_db::repo::users::insert(&pool, &owner).await.unwrap(); AuthService::new(pool.clone()) .set_password(owner.id, "pw") .await .unwrap(); let token = client .post(format!("{base}/api/auth/login")) .json(&json!({"email": owner.email, "password": "pw"})) .send() .await .unwrap() .json::() .await .unwrap()["token"] .as_str() .unwrap() .to_owned(); let claw: Value = client .post(format!("{base}/api/claws")) .bearer_auth(&token) .json(&json!({"name": "Scout", "job_title": "Analyst"})) .send() .await .unwrap() .json() .await .unwrap(); let claw_id = claw["id"].as_str().unwrap(); let signing_secret = "shh-signing"; let connect = client .post(format!("{base}/api/apps/connect")) .bearer_auth(&token) .json(&json!({ "clawId": claw_id, "provider": "slack", "authType": "keys", "secret": json!({"bot_token": "xoxb-in", "signing_secret": signing_secret}).to_string(), })) .send() .await .unwrap(); assert_eq!(connect.status(), 201); // A forged signature is rejected outright. let body = json!({ "type": "event_callback", "event": {"type": "app_mention", "text": "summarize [[scenario:mention]]"} }) .to_string(); // A CURRENT timestamp. It used to be the literal "12345" — a 1970 date — // which passed only because nothing checked freshness. The broker now // enforces Slack's 5-minute replay window, so a fixture that never moves // starts failing the moment the guard is real. let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs() .to_string(); let forged = client .post(format!("{base}/api/slack/events")) .header("x-slack-request-timestamp", &now) .header("x-slack-signature", "v0=deadbeef") .body(body.clone()) .send() .await .unwrap(); assert_eq!(forged.status(), 401); // url_verification handshake echoes the challenge when signed. let challenge_body = json!({"type": "url_verification", "challenge": "abc123"}).to_string(); let challenge = client .post(format!("{base}/api/slack/events")) .header("x-slack-request-timestamp", &now) .header( "x-slack-signature", sign(signing_secret, &now, &challenge_body), ) .body(challenge_body.clone()) .send() .await .unwrap(); assert_eq!(challenge.status(), 200); assert_eq!( challenge.json::().await.unwrap()["challenge"], "abc123" ); // A correctly signed request from outside the window is refused. The // signature is genuine — that is the point of a replay: an attacker holding // one captured request must not be able to use it forever. let stale_ts = (now.parse::().unwrap() - 3600).to_string(); let replayed = client .post(format!("{base}/api/slack/events")) .header("x-slack-request-timestamp", &stale_ts) .header("x-slack-signature", sign(signing_secret, &stale_ts, &body)) .body(body.clone()) .send() .await .unwrap(); assert_eq!( replayed.status(), 401, "a validly signed but hour-old request must be refused — otherwise one \ captured request authenticates forever" ); // A properly signed mention starts a run in the '💬 Slack' session and // the agent's reply is intercepted by the approval gate. let mention = client .post(format!("{base}/api/slack/events")) .header("x-slack-request-timestamp", &now) .header("x-slack-signature", sign(signing_secret, &now, &body)) .body(body) .send() .await .unwrap(); assert_eq!(mention.status(), 200); let agent_id = cm_domain::AgentId::from(claw_id.parse::().unwrap()); let mut gated = false; for _ in 0..100 { let sessions = cm_db::repo::sessions::list_by_agent(&pool, agent_id) .await .unwrap(); if sessions.iter().any(|s| s.title == "💬 Slack") { let pending = cm_safety::approvals::list_pending(&pool, ws.id) .await .unwrap(); if pending.len() == 1 && pending[0].action_type == "slack.post" { gated = true; break; } } tokio::time::sleep(Duration::from_millis(50)).await; } assert!(gated, "mention must drive a run whose reply is gated"); }