P4: Slack inbound @mention — broker-verified signatures drive real runs
- Broker op VerifySlackSignature: v0 HMAC-SHA256 computed INSIDE the broker
(constant-time compare); the signing secret never crosses the socket.
Slack secrets are one JSON credential {bot_token, signing_secret}; the
broker extracts the right field per operation
- Public POST /api/slack/events: signature verified against connected slack
connections via the broker; forged signatures 401; url_verification
handshake echoed only when signed; app_mention starts a real run in the
agent's dedicated '💬 Slack' session — and the agent's reply is itself a
gated outbound post
- SlackApp Connection tab captures bot token + signing secret
- Integration test: forged 401, signed challenge, signed mention -> run ->
slack.post pending in the approval queue
- E2E: full loop — connect, gated outbound (sink empty -> exactly one post),
then a node-crypto-signed mention -> approval card -> approve -> 'On it!'
lands in the sink
134 Rust + 63 frontend tests + 21 Playwright journeys.
Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
000b9b3a4b
commit
6dbdd20ee0
+56
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT id, workspace_id, agent_id, provider, auth_type, status, secret_ref\n FROM app_connections WHERE provider = 'slack' AND status = 'connected'",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "workspace_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "agent_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "provider",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "auth_type",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "status",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "secret_ref",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": []
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "fbc9d96386a0a1b3f1138f4116cf59042c38fe1d9bf8d8db014fc23fd117800b"
|
||||||
|
}
|
||||||
Generated
+5
@@ -2850,9 +2850,12 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"eventsource-stream",
|
"eventsource-stream",
|
||||||
"futures",
|
"futures",
|
||||||
|
"hex",
|
||||||
|
"hmac",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tc-auth",
|
"tc-auth",
|
||||||
"tc-db",
|
"tc-db",
|
||||||
@@ -3028,9 +3031,11 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"chacha20poly1305",
|
"chacha20poly1305",
|
||||||
"hex",
|
"hex",
|
||||||
|
"hmac",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tc-db",
|
"tc-db",
|
||||||
"tc-domain",
|
"tc-domain",
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ reqwest = { version = "0.12", default-features = false, features = [
|
|||||||
] }
|
] }
|
||||||
tc-llm = { path = "../tc-llm" }
|
tc-llm = { path = "../tc-llm" }
|
||||||
tc-testkit = { path = "../tc-testkit" }
|
tc-testkit = { path = "../tc-testkit" }
|
||||||
|
hex = "0.4"
|
||||||
|
hmac = "0.12"
|
||||||
|
sha2 = "0.10"
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/api/skills/uninstall", post(routes::skills::uninstall))
|
.route("/api/skills/uninstall", post(routes::skills::uninstall))
|
||||||
.route("/api/openclaw/files", get(routes::files::openclaw_files))
|
.route("/api/openclaw/files", get(routes::files::openclaw_files))
|
||||||
.route("/api/shared-drive/files", get(routes::files::shared_files))
|
.route("/api/shared-drive/files", get(routes::files::shared_files))
|
||||||
|
.route("/api/slack/events", post(routes::slack::events))
|
||||||
.route("/api/apps", get(routes::apps::directory))
|
.route("/api/apps", get(routes::apps::directory))
|
||||||
.route("/api/apps/connect", post(routes::apps::connect))
|
.route("/api/apps/connect", post(routes::apps::connect))
|
||||||
.route("/api/apps/disconnect", post(routes::apps::disconnect))
|
.route("/api/apps/disconnect", post(routes::apps::disconnect))
|
||||||
|
|||||||
@@ -10,4 +10,5 @@ pub mod identity;
|
|||||||
pub mod routines;
|
pub mod routines;
|
||||||
pub mod sessions;
|
pub mod sessions;
|
||||||
pub mod skills;
|
pub mod skills;
|
||||||
|
pub mod slack;
|
||||||
pub mod team;
|
pub mod team;
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
//! Slack inbound events (§7.3: "Agent replies on @mention"). This route is
|
||||||
|
//! public — authenticity comes from Slack's request signature, which the
|
||||||
|
//! BROKER verifies against the stored signing secret (it never leaves the
|
||||||
|
//! broker). A verified @mention starts a real run in the agent's dedicated
|
||||||
|
//! Slack session; any reply the agent attempts is itself a gated outbound
|
||||||
|
//! post.
|
||||||
|
|
||||||
|
use axum::body::Bytes;
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
|
use axum::Json;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use tc_domain::AgentId;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
const SLACK_SESSION_TITLE: &str = "💬 Slack";
|
||||||
|
|
||||||
|
/// Finds the slack connection whose signing secret validates this request.
|
||||||
|
async fn verified_connection(
|
||||||
|
state: &AppState,
|
||||||
|
timestamp: &str,
|
||||||
|
body: &str,
|
||||||
|
signature: &str,
|
||||||
|
) -> Option<tc_db::repo::connections::AppConnection> {
|
||||||
|
let socket = state.broker_socket.as_ref()?;
|
||||||
|
let connections = sqlx::query!(
|
||||||
|
r#"SELECT id, workspace_id, agent_id, provider, auth_type, status, secret_ref
|
||||||
|
FROM app_connections WHERE provider = 'slack' AND status = 'connected'"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
for row in connections {
|
||||||
|
let Some(secret_ref) = row.secret_ref else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(mut broker) = tc_secrets::BrokerClient::connect(socket).await else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
if broker
|
||||||
|
.verify_slack_signature(secret_ref, timestamp, body, signature)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return Some(tc_db::repo::connections::AppConnection {
|
||||||
|
id: row.id,
|
||||||
|
workspace_id: row.workspace_id,
|
||||||
|
agent_id: row.agent_id,
|
||||||
|
provider: row.provider,
|
||||||
|
auth_type: row.auth_type,
|
||||||
|
status: row.status,
|
||||||
|
secret_ref: row.secret_ref,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/slack/events
|
||||||
|
pub async fn events(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Bytes,
|
||||||
|
) -> Result<Json<Value>, StatusCode> {
|
||||||
|
let timestamp = headers
|
||||||
|
.get("x-slack-request-timestamp")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let signature = headers
|
||||||
|
.get("x-slack-signature")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let raw = String::from_utf8_lossy(&body).into_owned();
|
||||||
|
|
||||||
|
let connection = verified_connection(&state, timestamp, &raw, signature)
|
||||||
|
.await
|
||||||
|
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
let event: Value = serde_json::from_str(&raw).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||||
|
// Slack's endpoint handshake.
|
||||||
|
if event["type"] == "url_verification" {
|
||||||
|
return Ok(Json(json!({ "challenge": event["challenge"] })));
|
||||||
|
}
|
||||||
|
if event["type"] == "event_callback" && event["event"]["type"] == "app_mention" {
|
||||||
|
let text = event["event"]["text"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_owned();
|
||||||
|
let Some(agent_uuid) = connection.agent_id else {
|
||||||
|
return Ok(Json(json!({ "ok": true })));
|
||||||
|
};
|
||||||
|
let agent_id = AgentId::from(agent_uuid);
|
||||||
|
let Ok(agent) = tc_db::repo::agents::get(&state.pool, agent_id).await else {
|
||||||
|
return Ok(Json(json!({ "ok": true })));
|
||||||
|
};
|
||||||
|
|
||||||
|
// One recognizable session per agent for Slack traffic.
|
||||||
|
let session = match tc_db::repo::sessions::list_by_agent(&state.pool, agent_id)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|sessions| {
|
||||||
|
sessions
|
||||||
|
.into_iter()
|
||||||
|
.find(|s| s.title == SLACK_SESSION_TITLE)
|
||||||
|
}) {
|
||||||
|
Some(existing) => existing,
|
||||||
|
None => tc_db::repo::sessions::create(
|
||||||
|
&state.pool,
|
||||||
|
agent_id,
|
||||||
|
agent.workspace_id,
|
||||||
|
SLACK_SESSION_TITLE,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
|
||||||
|
};
|
||||||
|
// Fire-and-forget: Slack expects a fast 200; the run streams into
|
||||||
|
// the session (and any outbound reply is gated as usual).
|
||||||
|
let runtime = state.runtime.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = runtime.send_message(session.id, &text).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Json(json!({ "ok": true })))
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
//! 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 hmac::{Hmac, Mac};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use tc_api::AppState;
|
||||||
|
use tc_auth::AuthService;
|
||||||
|
use tc_domain::{Role, User, UserId, Workspace, WorkspaceId};
|
||||||
|
use tc_llm::ScriptedProvider;
|
||||||
|
use tc_runtime::{Runtime, RuntimeConfig};
|
||||||
|
use tc_secrets::{BrokerServer, FileKey};
|
||||||
|
|
||||||
|
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::<sha2::Sha256>::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 = tc_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,
|
||||||
|
broker_socket: Some(socket.clone()),
|
||||||
|
slack_base_url: "http://127.0.0.1:1".into(), // never reached here
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let app = tc_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(),
|
||||||
|
};
|
||||||
|
tc_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,
|
||||||
|
};
|
||||||
|
tc_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::<Value>()
|
||||||
|
.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();
|
||||||
|
let forged = client
|
||||||
|
.post(format!("{base}/api/slack/events"))
|
||||||
|
.header("x-slack-request-timestamp", "12345")
|
||||||
|
.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", "12345")
|
||||||
|
.header(
|
||||||
|
"x-slack-signature",
|
||||||
|
sign(signing_secret, "12345", &challenge_body),
|
||||||
|
)
|
||||||
|
.body(challenge_body.clone())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(challenge.status(), 200);
|
||||||
|
assert_eq!(
|
||||||
|
challenge.json::<Value>().await.unwrap()["challenge"],
|
||||||
|
"abc123"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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", "12345")
|
||||||
|
.header("x-slack-signature", sign(signing_secret, "12345", &body))
|
||||||
|
.body(body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(mention.status(), 200);
|
||||||
|
|
||||||
|
let agent_id = tc_domain::AgentId::from(claw_id.parse::<uuid::Uuid>().unwrap());
|
||||||
|
let mut gated = false;
|
||||||
|
for _ in 0..100 {
|
||||||
|
let sessions = tc_db::repo::sessions::list_by_agent(&pool, agent_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
if sessions.iter().any(|s| s.title == "💬 Slack") {
|
||||||
|
let pending = tc_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");
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ publish.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
chacha20poly1305 = "0.10"
|
chacha20poly1305 = "0.10"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
|
hmac = "0.12"
|
||||||
|
sha2 = "0.10"
|
||||||
reqwest = { version = "0.12", default-features = false, features = [
|
reqwest = { version = "0.12", default-features = false, features = [
|
||||||
"json",
|
"json",
|
||||||
"rustls-tls",
|
"rustls-tls",
|
||||||
|
|||||||
@@ -52,6 +52,29 @@ impl BrokerClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Asks the broker to verify a Slack signature; the signing secret
|
||||||
|
/// never crosses the socket.
|
||||||
|
pub async fn verify_slack_signature(
|
||||||
|
&mut self,
|
||||||
|
secret_id: Uuid,
|
||||||
|
timestamp: &str,
|
||||||
|
body: &str,
|
||||||
|
signature: &str,
|
||||||
|
) -> Result<bool, BrokerError> {
|
||||||
|
match self
|
||||||
|
.round_trip(Request::VerifySlackSignature {
|
||||||
|
secret_id,
|
||||||
|
timestamp: timestamp.to_owned(),
|
||||||
|
body: body.to_owned(),
|
||||||
|
signature: signature.to_owned(),
|
||||||
|
})
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
Response::Verified { valid } => Ok(valid),
|
||||||
|
other => Err(BrokerError::Io(format!("unexpected response: {other:?}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn invoke_http(
|
pub async fn invoke_http(
|
||||||
&mut self,
|
&mut self,
|
||||||
approval_id: Uuid,
|
approval_id: Uuid,
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ pub enum Request {
|
|||||||
SecretKind {
|
SecretKind {
|
||||||
secret_id: Uuid,
|
secret_id: Uuid,
|
||||||
},
|
},
|
||||||
|
/// Verifies a Slack request signature (v0 HMAC-SHA256) against the
|
||||||
|
/// connection's signing secret — which never leaves the broker.
|
||||||
|
VerifySlackSignature {
|
||||||
|
secret_id: Uuid,
|
||||||
|
timestamp: String,
|
||||||
|
body: String,
|
||||||
|
signature: String,
|
||||||
|
},
|
||||||
/// Performs an HTTP POST with the secret injected as a bearer token.
|
/// Performs an HTTP POST with the secret injected as a bearer token.
|
||||||
/// Requires consuming the approval's single-use execution grant.
|
/// Requires consuming the approval's single-use execution grant.
|
||||||
InvokeHttp {
|
InvokeHttp {
|
||||||
@@ -37,6 +45,7 @@ pub enum Response {
|
|||||||
SecretStored { secret_id: Uuid },
|
SecretStored { secret_id: Uuid },
|
||||||
SecretKind { kind: String },
|
SecretKind { kind: String },
|
||||||
HttpDone { status: u16 },
|
HttpDone { status: u16 },
|
||||||
|
Verified { valid: bool },
|
||||||
Error { kind: ErrorKind, message: String },
|
Error { kind: ErrorKind, message: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,38 @@ use crate::protocol::{read_frame, write_frame, Request, Response};
|
|||||||
use crate::store::SecretStore;
|
use crate::store::SecretStore;
|
||||||
use crate::BrokerError;
|
use crate::BrokerError;
|
||||||
|
|
||||||
|
/// Secrets may be plain strings or JSON objects holding multiple fields
|
||||||
|
/// (e.g. Slack's bot token + signing secret). Returns the requested field
|
||||||
|
/// for JSON secrets, the whole value otherwise.
|
||||||
|
fn credential_field(secret: &str, field: &str) -> String {
|
||||||
|
serde_json::from_str::<serde_json::Value>(secret)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.get(field).and_then(|f| f.as_str()).map(str::to_owned))
|
||||||
|
.unwrap_or_else(|| secret.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Slack request signing: v0=hex(HMAC-SHA256(secret, "v0:{ts}:{body}")).
|
||||||
|
pub(crate) fn slack_signature_valid(
|
||||||
|
signing_secret: &str,
|
||||||
|
timestamp: &str,
|
||||||
|
body: &str,
|
||||||
|
signature: &str,
|
||||||
|
) -> bool {
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
let Ok(mut mac) = Hmac::<sha2::Sha256>::new_from_slice(signing_secret.as_bytes()) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
mac.update(format!("v0:{timestamp}:{body}").as_bytes());
|
||||||
|
let expected = format!("v0={}", hex::encode(mac.finalize().into_bytes()));
|
||||||
|
// Constant-time comparison: do not leak prefix matches.
|
||||||
|
expected.len() == signature.len()
|
||||||
|
&& expected
|
||||||
|
.bytes()
|
||||||
|
.zip(signature.bytes())
|
||||||
|
.fold(0u8, |acc, (a, b)| acc | (a ^ b))
|
||||||
|
== 0
|
||||||
|
}
|
||||||
|
|
||||||
/// The broker daemon: listens on a unix socket reachable only by the
|
/// The broker daemon: listens on a unix socket reachable only by the
|
||||||
/// server process (never mounted into agent sandboxes).
|
/// server process (never mounted into agent sandboxes).
|
||||||
pub struct BrokerServer {
|
pub struct BrokerServer {
|
||||||
@@ -76,6 +108,18 @@ impl BrokerServer {
|
|||||||
Request::SecretKind { secret_id } => Ok(Response::SecretKind {
|
Request::SecretKind { secret_id } => Ok(Response::SecretKind {
|
||||||
kind: store.kind(secret_id).await?,
|
kind: store.kind(secret_id).await?,
|
||||||
}),
|
}),
|
||||||
|
Request::VerifySlackSignature {
|
||||||
|
secret_id,
|
||||||
|
timestamp,
|
||||||
|
body,
|
||||||
|
signature,
|
||||||
|
} => {
|
||||||
|
let secret = store.reveal_internal(secret_id).await?;
|
||||||
|
let signing = credential_field(&secret, "signing_secret");
|
||||||
|
let valid =
|
||||||
|
crate::server::slack_signature_valid(&signing, ×tamp, &body, &signature);
|
||||||
|
Ok(Response::Verified { valid })
|
||||||
|
}
|
||||||
Request::InvokeHttp {
|
Request::InvokeHttp {
|
||||||
approval_id,
|
approval_id,
|
||||||
secret_id,
|
secret_id,
|
||||||
@@ -94,9 +138,10 @@ impl BrokerServer {
|
|||||||
.map_err(|_| BrokerError::GrantRefused)?;
|
.map_err(|_| BrokerError::GrantRefused)?;
|
||||||
|
|
||||||
let credential = store.reveal_internal(secret_id).await?;
|
let credential = store.reveal_internal(secret_id).await?;
|
||||||
|
let bearer = credential_field(&credential, "bot_token");
|
||||||
let response = reqwest::Client::new()
|
let response = reqwest::Client::new()
|
||||||
.post(&url)
|
.post(&url)
|
||||||
.bearer_auth(credential)
|
.bearer_auth(bearer)
|
||||||
.json(&body)
|
.json(&body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
Q2 revenue is up 14%.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
Q2 revenue is up 14%.
|
||||||
@@ -77,3 +77,16 @@ events = [
|
|||||||
events = [
|
events = [
|
||||||
{ type = "text", text = " Posted to #general." },
|
{ type = "text", text = " Posted to #general." },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[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." },
|
||||||
|
]
|
||||||
|
|||||||
@@ -35,7 +35,12 @@ export default function SlackApp({ agent }: { agent: Agent }) {
|
|||||||
clawId: agent.id,
|
clawId: agent.id,
|
||||||
provider: "slack",
|
provider: "slack",
|
||||||
authType: "keys",
|
authType: "keys",
|
||||||
secret: data.get("token"),
|
// One broker-held secret carrying both credentials; the signing
|
||||||
|
// secret never leaves the broker (it verifies inbound mentions).
|
||||||
|
secret: JSON.stringify({
|
||||||
|
bot_token: data.get("token"),
|
||||||
|
signing_secret: data.get("signing_secret"),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
@@ -80,6 +85,16 @@ export default function SlackApp({ agent }: { agent: Agent }) {
|
|||||||
even by admins.
|
even by admins.
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||||
|
Signing secret
|
||||||
|
<input
|
||||||
|
name="signing_secret"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
placeholder="Slack app signing secret"
|
||||||
|
className="rounded-(--radius) border border-input bg-subtle px-2 py-1.5 text-sm text-foreground outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { createHmac } from "node:crypto";
|
||||||
|
|
||||||
import { expect, test, type Page } from "@playwright/test";
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
// P4 exit criterion (spec §17): connect an app, and Slack outbound is
|
// P4 exit criterion (spec §17): connect an app, and Slack outbound is
|
||||||
@@ -29,6 +31,7 @@ test("connect Slack, then an outbound post is gated and broker-executed", async
|
|||||||
await panel.getByRole("button", { name: "Slack" }).click();
|
await panel.getByRole("button", { name: "Slack" }).click();
|
||||||
await panel.getByRole("button", { name: "Connect Slack" }).first().click();
|
await panel.getByRole("button", { name: "Connect Slack" }).first().click();
|
||||||
await panel.getByLabel(/Bot token/).fill("xoxb-e2e-token");
|
await panel.getByLabel(/Bot token/).fill("xoxb-e2e-token");
|
||||||
|
await panel.getByLabel(/Signing secret/).fill("e2e-signing-secret");
|
||||||
await panel.getByRole("button", { name: "Connect Slack" }).click();
|
await panel.getByRole("button", { name: "Connect Slack" }).click();
|
||||||
await expect(panel.getByText("Slack is connected")).toBeVisible();
|
await expect(panel.getByText("Slack is connected")).toBeVisible();
|
||||||
await panel.getByRole("button", { name: "Close computer" }).click();
|
await panel.getByRole("button", { name: "Close computer" }).click();
|
||||||
@@ -58,4 +61,42 @@ test("connect Slack, then an outbound post is gated and broker-executed", async
|
|||||||
// The audit trail shows the decision.
|
// The audit trail shows the decision.
|
||||||
await page.getByRole("link", { name: "Approvals" }).click();
|
await page.getByRole("link", { name: "Approvals" }).click();
|
||||||
await expect(page.getByText(/All clear/)).toBeVisible();
|
await expect(page.getByText(/All clear/)).toBeVisible();
|
||||||
|
|
||||||
|
// INBOUND: a signed @mention drives a run whose reply is gated too.
|
||||||
|
const mention = JSON.stringify({
|
||||||
|
type: "event_callback",
|
||||||
|
event: { type: "app_mention", text: "reply please [[scenario:mention]]" },
|
||||||
|
});
|
||||||
|
const timestamp = "1234567890";
|
||||||
|
const signature =
|
||||||
|
"v0=" +
|
||||||
|
createHmac("sha256", "e2e-signing-secret")
|
||||||
|
.update(`v0:${timestamp}:${mention}`)
|
||||||
|
.digest("hex");
|
||||||
|
const inbound = await request.post("http://127.0.0.1:8080/api/slack/events", {
|
||||||
|
headers: {
|
||||||
|
"x-slack-request-timestamp": timestamp,
|
||||||
|
"x-slack-signature": signature,
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
data: mention,
|
||||||
|
});
|
||||||
|
expect(inbound.status()).toBe(200);
|
||||||
|
|
||||||
|
// The reply lands in the approval queue (asynchronously — the mention
|
||||||
|
// spawns the run); poll the page until the card shows up.
|
||||||
|
const inboundCard = page.getByRole("region", { name: "Review and approve" });
|
||||||
|
await expect(async () => {
|
||||||
|
await page.reload();
|
||||||
|
await expect(inboundCard).toBeVisible({ timeout: 1000 });
|
||||||
|
}).toPass({ timeout: 15000 });
|
||||||
|
await expect(inboundCard).toContainText("Post to Slack #general");
|
||||||
|
await inboundCard.getByRole("button", { name: "Approve" }).click();
|
||||||
|
await expect(page.getByText(/All clear/)).toBeVisible();
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const res = await request.get("http://127.0.0.1:8080/__slack/posts");
|
||||||
|
return ((await res.json()) as { text: string }[]).map((p) => p.text);
|
||||||
|
})
|
||||||
|
.toContain("On it!");
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user