Phase 5. The headline is not the coverage work — it is what looking for coverage found. A CAPTURED SLACK REQUEST AUTHENTICATED INDEFINITELY `slack_signature_valid` verified the HMAC correctly, and nothing anywhere checked how old the timestamp was. The timestamp is an input to the basestring, so an old request's signature verifies exactly as well as a fresh one — meaning anyone holding a single captured signed request (a proxy log, a mirrored packet, a leaked webhook body) could replay it forever, and every replay would authenticate. Slack's documented 5-minute window is now enforced IN THE BROKER, not the caller: the broker does not trust its caller (§15), and a check the caller can forget to make is one that will eventually be forgotten. Symmetric, so a far-future timestamp cannot mint a request valid for as long as the attacker chooses. Seven unit tests over the pure function with the clock injected, and the HTTP-level test now asserts an hour-old but validly signed request is refused. Negative control: removing the window fails the stale and future cases specifically. The existing slack_inbound test used the literal timestamp "12345" — a 1970 date — which passed only because nothing checked freshness. That is the shape of the whole finding: the fixture could not have failed, so it never told us anything. COVERAGE, RE-EXAMINED The review ranked crates by raw test count. That metric was misleading and found the wrong crates: cm-safety's seven tests already cover the decide CAS, grant double-consume, expiry and the approved/rejected split, and the audit_log immutability trigger is tested over in cm-db. Reading the API surface against the tests found the real gaps — verify_slack_signature above, and `credits_for_tokens`, pure pricing arithmetic that every existing billing test went through the database to reach without ever checking directly. Now pinned: the round-up contract, the deliberate one-credit floor, and that an absurd token count cannot wrap into a negative charge (a refund granted by an overflow). Still genuinely thin: cm-brain, where 6 of 9 tests need live clawbrainhub.com. Stubbing it means reproducing an external registry protocol we have no spec for — its own piece of work, not a coverage chore. Recorded rather than faked. GATEWAY PREFLIGHT ZEROCLAW_GATEWAY_URL and ZEROCLAW_TOKEN have no defaults and are read at FIRST USE, so a deployment missing them boots clean, serves every page, and fails the first time someone presses run. Third sibling of runtime_preflight and validator_preflight, same stance: a report, not a gate. The message names the consequence — "container-tier missions cannot run" — rather than only the unset variable. One process note: `cargo test -p cm-secrets` passed while the LIBRARY build was broken, because `time` is a dev-dependency there and my reference to it only resolved under cfg(test). Switched to std. Checking `cargo build --workspace` as well as the test profile is the guard. Full workspace suite green: 106 binaries, zero build errors. Co-Authored-By: Claude Opus 5 <[email protected]>
239 lines
8.0 KiB
Rust
239 lines
8.0 KiB
Rust
//! 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::<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 = 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::<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();
|
|
// 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::<Value>().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::<u64>().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::<uuid::Uuid>().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");
|
|
}
|