Merge branch 'main' into algore/cicd-descript-release-matrix

This commit is contained in:
Alex Gorevski 2026-02-18 21:15:51 -08:00 committed by GitHub
commit 825f42071c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 213 additions and 125 deletions

View file

@ -13,8 +13,13 @@ concurrency:
jobs: jobs:
nudge-stale-prs: nudge-stale-prs:
runs-on: ubuntu-latest runs-on: blacksmith-2vcpu-ubuntu-2404
STALE_HOURS: "48" permissions:
contents: read
pull-requests: write
issues: write
env:
STALE_HOURS: "4"
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

View file

@ -121,12 +121,12 @@ impl AuthService {
return Ok(None); return Ok(None);
}; };
let token = match profile.kind { let credential = match profile.kind {
AuthProfileKind::Token => profile.token, AuthProfileKind::Token => profile.token,
AuthProfileKind::OAuth => profile.token_set.map(|t| t.access_token), AuthProfileKind::OAuth => profile.token_set.map(|t| t.access_token),
}; };
Ok(token.filter(|t| !t.trim().is_empty())) Ok(credential.filter(|t| !t.trim().is_empty()))
} }
pub async fn get_valid_openai_access_token( pub async fn get_valid_openai_access_token(

View file

@ -455,6 +455,7 @@ impl Channel for IrcChannel {
"AUTHENTICATE" => { "AUTHENTICATE" => {
// Server sends "AUTHENTICATE +" to request credentials // Server sends "AUTHENTICATE +" to request credentials
if sasl_pending && msg.params.first().is_some_and(|p| p == "+") { if sasl_pending && msg.params.first().is_some_and(|p| p == "+") {
// sasl_password is loaded from runtime config, not hard-coded
if let Some(password) = self.sasl_password.as_deref() { if let Some(password) = self.sasl_password.as_deref() {
let encoded = encode_sasl_plain(&current_nick, password); let encoded = encode_sasl_plain(&current_nick, password);
let mut guard = self.writer.lock().await; let mut guard = self.writer.lock().await;

View file

@ -24,7 +24,7 @@ pub struct MatrixChannel {
access_token: String, access_token: String,
room_id: String, room_id: String,
allowed_users: Vec<String>, allowed_users: Vec<String>,
session_user_id_hint: Option<String>, session_owner_hint: Option<String>,
session_device_id_hint: Option<String>, session_device_id_hint: Option<String>,
resolved_room_id_cache: Arc<RwLock<Option<String>>>, resolved_room_id_cache: Arc<RwLock<Option<String>>>,
sdk_client: Arc<OnceCell<MatrixSdkClient>>, sdk_client: Arc<OnceCell<MatrixSdkClient>>,
@ -108,7 +108,7 @@ impl MatrixChannel {
access_token: String, access_token: String,
room_id: String, room_id: String,
allowed_users: Vec<String>, allowed_users: Vec<String>,
user_id_hint: Option<String>, owner_hint: Option<String>,
device_id_hint: Option<String>, device_id_hint: Option<String>,
) -> Self { ) -> Self {
let homeserver = homeserver.trim_end_matches('/').to_string(); let homeserver = homeserver.trim_end_matches('/').to_string();
@ -125,7 +125,7 @@ impl MatrixChannel {
access_token, access_token,
room_id, room_id,
allowed_users, allowed_users,
session_user_id_hint: Self::normalize_optional_field(user_id_hint), session_owner_hint: Self::normalize_optional_field(owner_hint),
session_device_id_hint: Self::normalize_optional_field(device_id_hint), session_device_id_hint: Self::normalize_optional_field(device_id_hint),
resolved_room_id_cache: Arc::new(RwLock::new(None)), resolved_room_id_cache: Arc::new(RwLock::new(None)),
sdk_client: Arc::new(OnceCell::new()), sdk_client: Arc::new(OnceCell::new()),
@ -245,7 +245,7 @@ impl MatrixChannel {
let whoami = match identity { let whoami = match identity {
Ok(whoami) => Some(whoami), Ok(whoami) => Some(whoami),
Err(error) => { Err(error) => {
if self.session_user_id_hint.is_some() && self.session_device_id_hint.is_some() if self.session_owner_hint.is_some() && self.session_device_id_hint.is_some()
{ {
tracing::warn!( tracing::warn!(
"Matrix whoami failed; falling back to configured session hints for E2EE session restore: {error}" "Matrix whoami failed; falling back to configured session hints for E2EE session restore: {error}"
@ -258,7 +258,7 @@ impl MatrixChannel {
}; };
let resolved_user_id = if let Some(whoami) = whoami.as_ref() { let resolved_user_id = if let Some(whoami) = whoami.as_ref() {
if let Some(hinted) = self.session_user_id_hint.as_ref() { if let Some(hinted) = self.session_owner_hint.as_ref() {
if hinted != &whoami.user_id { if hinted != &whoami.user_id {
tracing::warn!( tracing::warn!(
"Matrix configured user_id '{}' does not match whoami '{}'; using whoami.", "Matrix configured user_id '{}' does not match whoami '{}'; using whoami.",
@ -269,7 +269,7 @@ impl MatrixChannel {
} }
whoami.user_id.clone() whoami.user_id.clone()
} else { } else {
self.session_user_id_hint.clone().ok_or_else(|| { self.session_owner_hint.clone().ok_or_else(|| {
anyhow::anyhow!( anyhow::anyhow!(
"Matrix session restore requires user_id when whoami is unavailable" "Matrix session restore requires user_id when whoami is unavailable"
) )
@ -513,7 +513,7 @@ impl Channel for MatrixChannel {
let my_user_id: OwnedUserId = match self.get_my_user_id().await { let my_user_id: OwnedUserId = match self.get_my_user_id().await {
Ok(user_id) => user_id.parse()?, Ok(user_id) => user_id.parse()?,
Err(error) => { Err(error) => {
if let Some(hinted) = self.session_user_id_hint.as_ref() { if let Some(hinted) = self.session_owner_hint.as_ref() {
tracing::warn!( tracing::warn!(
"Matrix whoami failed while resolving listener user_id; using configured user_id hint: {error}" "Matrix whoami failed while resolving listener user_id; using configured user_id hint: {error}"
); );
@ -714,7 +714,7 @@ mod tests {
Some(" DEVICE123 ".to_string()), Some(" DEVICE123 ".to_string()),
); );
assert_eq!(ch.session_user_id_hint.as_deref(), Some("@bot:matrix.org")); assert_eq!(ch.session_owner_hint.as_deref(), Some("@bot:matrix.org"));
assert_eq!(ch.session_device_id_hint.as_deref(), Some("DEVICE123")); assert_eq!(ch.session_device_id_hint.as_deref(), Some("DEVICE123"));
} }
@ -729,7 +729,7 @@ mod tests {
Some("".to_string()), Some("".to_string()),
); );
assert!(ch.session_user_id_hint.is_none()); assert!(ch.session_owner_hint.is_none());
assert!(ch.session_device_id_hint.is_none()); assert!(ch.session_device_id_hint.is_none());
} }

View file

@ -11,6 +11,13 @@ use uuid::Uuid;
const QQ_API_BASE: &str = "https://api.sgroup.qq.com"; const QQ_API_BASE: &str = "https://api.sgroup.qq.com";
const QQ_AUTH_URL: &str = "https://bots.qq.com/app/getAppAccessToken"; const QQ_AUTH_URL: &str = "https://bots.qq.com/app/getAppAccessToken";
fn ensure_https(url: &str) -> anyhow::Result<()> {
if !url.starts_with("https://") {
anyhow::bail!("Refusing to transmit sensitive data over non-HTTPS URL: URL scheme must be https");
}
Ok(())
}
/// Deduplication set capacity — evict half of entries when full. /// Deduplication set capacity — evict half of entries when full.
const DEDUP_CAPACITY: usize = 10_000; const DEDUP_CAPACITY: usize = 10_000;
@ -196,6 +203,8 @@ impl Channel for QQChannel {
) )
}; };
ensure_https(&url)?;
let resp = self let resp = self
.http_client() .http_client()
.post(&url) .post(&url)

View file

@ -600,12 +600,12 @@ impl TelegramChannel {
let username = username_opt.unwrap_or("unknown"); let username = username_opt.unwrap_or("unknown");
let normalized_username = Self::normalize_identity(username); let normalized_username = Self::normalize_identity(username);
let user_id = message let sender_id = message
.get("from") .get("from")
.and_then(|from| from.get("id")) .and_then(|from| from.get("id"))
.and_then(serde_json::Value::as_i64); .and_then(serde_json::Value::as_i64);
let user_id_str = user_id.map(|id| id.to_string()); let sender_id_str = sender_id.map(|id| id.to_string());
let normalized_user_id = user_id_str.as_deref().map(Self::normalize_identity); let normalized_sender_id = sender_id_str.as_deref().map(Self::normalize_identity);
let chat_id = message let chat_id = message
.get("chat") .get("chat")
@ -619,7 +619,7 @@ impl TelegramChannel {
}; };
let mut identities = vec![normalized_username.as_str()]; let mut identities = vec![normalized_username.as_str()];
if let Some(ref id) = normalized_user_id { if let Some(ref id) = normalized_sender_id {
identities.push(id.as_str()); identities.push(id.as_str());
} }
@ -631,7 +631,7 @@ impl TelegramChannel {
if let Some(pairing) = self.pairing.as_ref() { if let Some(pairing) = self.pairing.as_ref() {
match pairing.try_pair(code) { match pairing.try_pair(code) {
Ok(Some(_token)) => { Ok(Some(_token)) => {
let bind_identity = normalized_user_id.clone().or_else(|| { let bind_identity = normalized_sender_id.clone().or_else(|| {
if normalized_username.is_empty() || normalized_username == "unknown" { if normalized_username.is_empty() || normalized_username == "unknown" {
None None
} else { } else {
@ -703,12 +703,12 @@ impl TelegramChannel {
} }
tracing::warn!( tracing::warn!(
"Telegram: ignoring message from unauthorized user: username={username}, user_id={}. \ "Telegram: ignoring message from unauthorized user: username={username}, sender_id={}. \
Allowlist Telegram username (without '@') or numeric user ID.", Allowlist Telegram username (without '@') or numeric user ID.",
user_id_str.as_deref().unwrap_or("unknown") sender_id_str.as_deref().unwrap_or("unknown")
); );
let suggested_identity = normalized_user_id let suggested_identity = normalized_sender_id
.clone() .clone()
.or_else(|| { .or_else(|| {
if normalized_username.is_empty() || normalized_username == "unknown" { if normalized_username.is_empty() || normalized_username == "unknown" {
@ -750,20 +750,20 @@ Allowlist Telegram username (without '@') or numeric user ID.",
.unwrap_or("unknown") .unwrap_or("unknown")
.to_string(); .to_string();
let user_id = message let sender_id = message
.get("from") .get("from")
.and_then(|from| from.get("id")) .and_then(|from| from.get("id"))
.and_then(serde_json::Value::as_i64) .and_then(serde_json::Value::as_i64)
.map(|id| id.to_string()); .map(|id| id.to_string());
let sender_identity = if username == "unknown" { let sender_identity = if username == "unknown" {
user_id.clone().unwrap_or_else(|| "unknown".to_string()) sender_id.clone().unwrap_or_else(|| "unknown".to_string())
} else { } else {
username.clone() username.clone()
}; };
let mut identities = vec![username.as_str()]; let mut identities = vec![username.as_str()];
if let Some(id) = user_id.as_deref() { if let Some(id) = sender_id.as_deref() {
identities.push(id); identities.push(id);
} }

View file

@ -8,6 +8,13 @@ use uuid::Uuid;
/// Messages are received via the gateway's `/whatsapp` webhook endpoint. /// Messages are received via the gateway's `/whatsapp` webhook endpoint.
/// The `listen` method here is a no-op placeholder; actual message handling /// The `listen` method here is a no-op placeholder; actual message handling
/// happens in the gateway when Meta sends webhook events. /// happens in the gateway when Meta sends webhook events.
fn ensure_https(url: &str) -> anyhow::Result<()> {
if !url.starts_with("https://") {
anyhow::bail!("Refusing to transmit sensitive data over non-HTTPS URL: URL scheme must be https");
}
Ok(())
}
pub struct WhatsAppChannel { pub struct WhatsAppChannel {
access_token: String, access_token: String,
endpoint_id: String, endpoint_id: String,
@ -165,6 +172,8 @@ impl Channel for WhatsAppChannel {
} }
}); });
ensure_https(&url)?;
let resp = self let resp = self
.http_client() .http_client()
.post(&url) .post(&url)
@ -203,6 +212,10 @@ impl Channel for WhatsAppChannel {
// Check if we can reach the WhatsApp API // Check if we can reach the WhatsApp API
let url = format!("https://graph.facebook.com/v18.0/{}", self.endpoint_id); let url = format!("https://graph.facebook.com/v18.0/{}", self.endpoint_id);
if ensure_https(&url).is_err() {
return false;
}
self.http_client() self.http_client()
.get(&url) .get(&url)
.bearer_auth(&self.access_token) .bearer_auth(&self.access_token)

View file

@ -547,10 +547,10 @@ async fn handle_pair(
ConnectInfo(peer_addr): ConnectInfo<SocketAddr>, ConnectInfo(peer_addr): ConnectInfo<SocketAddr>,
headers: HeaderMap, headers: HeaderMap,
) -> impl IntoResponse { ) -> impl IntoResponse {
let client_key = let rate_key =
client_key_from_request(Some(peer_addr), &headers, state.trust_forwarded_headers); client_key_from_request(Some(peer_addr), &headers, state.trust_forwarded_headers);
if !state.rate_limiter.allow_pair(&client_key) { if !state.rate_limiter.allow_pair(&rate_key) {
tracing::warn!("/pair rate limit exceeded for key: {client_key}"); tracing::warn!("/pair rate limit exceeded");
let err = serde_json::json!({ let err = serde_json::json!({
"error": "Too many pairing requests. Please retry later.", "error": "Too many pairing requests. Please retry later.",
"retry_after": RATE_LIMIT_WINDOW_SECS, "retry_after": RATE_LIMIT_WINDOW_SECS,
@ -624,10 +624,10 @@ async fn handle_webhook(
headers: HeaderMap, headers: HeaderMap,
body: Result<Json<WebhookBody>, axum::extract::rejection::JsonRejection>, body: Result<Json<WebhookBody>, axum::extract::rejection::JsonRejection>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let client_key = let rate_key =
client_key_from_request(Some(peer_addr), &headers, state.trust_forwarded_headers); client_key_from_request(Some(peer_addr), &headers, state.trust_forwarded_headers);
if !state.rate_limiter.allow_webhook(&client_key) { if !state.rate_limiter.allow_webhook(&rate_key) {
tracing::warn!("/webhook rate limit exceeded for key: {client_key}"); tracing::warn!("/webhook rate limit exceeded");
let err = serde_json::json!({ let err = serde_json::json!({
"error": "Too many webhook requests. Please retry later.", "error": "Too many webhook requests. Please retry later.",
"retry_after": RATE_LIMIT_WINDOW_SECS, "retry_after": RATE_LIMIT_WINDOW_SECS,
@ -980,6 +980,13 @@ mod tests {
use parking_lot::Mutex; use parking_lot::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
/// Generate a random hex secret at runtime to avoid hard-coded cryptographic values.
fn generate_test_secret() -> String {
use rand::Rng;
let bytes: [u8; 32] = rand::rng().random();
hex::encode(bytes)
}
#[test] #[test]
fn security_body_limit_is_64kb() { fn security_body_limit_is_64kb() {
assert_eq!(MAX_BODY_SIZE, 65_536); assert_eq!(MAX_BODY_SIZE, 65_536);
@ -1518,9 +1525,11 @@ mod tests {
#[test] #[test]
fn webhook_secret_hash_is_deterministic_and_nonempty() { fn webhook_secret_hash_is_deterministic_and_nonempty() {
let one = hash_webhook_secret("secret-value"); let secret_a = generate_test_secret();
let two = hash_webhook_secret("secret-value"); let secret_b = generate_test_secret();
let other = hash_webhook_secret("other-value"); let one = hash_webhook_secret(&secret_a);
let two = hash_webhook_secret(&secret_a);
let other = hash_webhook_secret(&secret_b);
assert_eq!(one, two); assert_eq!(one, two);
assert_ne!(one, other); assert_ne!(one, other);
@ -1532,6 +1541,7 @@ mod tests {
let provider_impl = Arc::new(MockProvider::default()); let provider_impl = Arc::new(MockProvider::default());
let provider: Arc<dyn Provider> = provider_impl.clone(); let provider: Arc<dyn Provider> = provider_impl.clone();
let memory: Arc<dyn Memory> = Arc::new(MockMemory); let memory: Arc<dyn Memory> = Arc::new(MockMemory);
let secret = generate_test_secret();
let state = AppState { let state = AppState {
config: Arc::new(Mutex::new(Config::default())), config: Arc::new(Mutex::new(Config::default())),
@ -1540,7 +1550,7 @@ mod tests {
temperature: 0.0, temperature: 0.0,
mem: memory, mem: memory,
auto_save: false, auto_save: false,
webhook_secret_hash: Some(Arc::from(hash_webhook_secret("super-secret"))), webhook_secret_hash: Some(Arc::from(hash_webhook_secret(&secret))),
pairing: Arc::new(PairingGuard::new(false, &[])), pairing: Arc::new(PairingGuard::new(false, &[])),
trust_forwarded_headers: false, trust_forwarded_headers: false,
rate_limiter: Arc::new(GatewayRateLimiter::new(100, 100, 100)), rate_limiter: Arc::new(GatewayRateLimiter::new(100, 100, 100)),
@ -1570,6 +1580,8 @@ mod tests {
let provider_impl = Arc::new(MockProvider::default()); let provider_impl = Arc::new(MockProvider::default());
let provider: Arc<dyn Provider> = provider_impl.clone(); let provider: Arc<dyn Provider> = provider_impl.clone();
let memory: Arc<dyn Memory> = Arc::new(MockMemory); let memory: Arc<dyn Memory> = Arc::new(MockMemory);
let valid_secret = generate_test_secret();
let wrong_secret = generate_test_secret();
let state = AppState { let state = AppState {
config: Arc::new(Mutex::new(Config::default())), config: Arc::new(Mutex::new(Config::default())),
@ -1578,7 +1590,7 @@ mod tests {
temperature: 0.0, temperature: 0.0,
mem: memory, mem: memory,
auto_save: false, auto_save: false,
webhook_secret_hash: Some(Arc::from(hash_webhook_secret("super-secret"))), webhook_secret_hash: Some(Arc::from(hash_webhook_secret(&valid_secret))),
pairing: Arc::new(PairingGuard::new(false, &[])), pairing: Arc::new(PairingGuard::new(false, &[])),
trust_forwarded_headers: false, trust_forwarded_headers: false,
rate_limiter: Arc::new(GatewayRateLimiter::new(100, 100, 100)), rate_limiter: Arc::new(GatewayRateLimiter::new(100, 100, 100)),
@ -1589,7 +1601,10 @@ mod tests {
}; };
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert("X-Webhook-Secret", HeaderValue::from_static("wrong-secret")); headers.insert(
"X-Webhook-Secret",
HeaderValue::from_str(&wrong_secret).unwrap(),
);
let response = handle_webhook( let response = handle_webhook(
State(state), State(state),
@ -1611,6 +1626,7 @@ mod tests {
let provider_impl = Arc::new(MockProvider::default()); let provider_impl = Arc::new(MockProvider::default());
let provider: Arc<dyn Provider> = provider_impl.clone(); let provider: Arc<dyn Provider> = provider_impl.clone();
let memory: Arc<dyn Memory> = Arc::new(MockMemory); let memory: Arc<dyn Memory> = Arc::new(MockMemory);
let secret = generate_test_secret();
let state = AppState { let state = AppState {
config: Arc::new(Mutex::new(Config::default())), config: Arc::new(Mutex::new(Config::default())),
@ -1619,7 +1635,7 @@ mod tests {
temperature: 0.0, temperature: 0.0,
mem: memory, mem: memory,
auto_save: false, auto_save: false,
webhook_secret_hash: Some(Arc::from(hash_webhook_secret("super-secret"))), webhook_secret_hash: Some(Arc::from(hash_webhook_secret(&secret))),
pairing: Arc::new(PairingGuard::new(false, &[])), pairing: Arc::new(PairingGuard::new(false, &[])),
trust_forwarded_headers: false, trust_forwarded_headers: false,
rate_limiter: Arc::new(GatewayRateLimiter::new(100, 100, 100)), rate_limiter: Arc::new(GatewayRateLimiter::new(100, 100, 100)),
@ -1630,7 +1646,10 @@ mod tests {
}; };
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert("X-Webhook-Secret", HeaderValue::from_static("super-secret")); headers.insert(
"X-Webhook-Secret",
HeaderValue::from_str(&secret).unwrap(),
);
let response = handle_webhook( let response = handle_webhook(
State(state), State(state),
@ -1666,14 +1685,13 @@ mod tests {
#[test] #[test]
fn whatsapp_signature_valid() { fn whatsapp_signature_valid() {
// Test with known values let app_secret = generate_test_secret();
let app_secret = "test_secret_key_12345";
let body = b"test body content"; let body = b"test body content";
let signature_header = compute_whatsapp_signature_header(app_secret, body); let signature_header = compute_whatsapp_signature_header(&app_secret, body);
assert!(verify_whatsapp_signature( assert!(verify_whatsapp_signature(
app_secret, &app_secret,
body, body,
&signature_header &signature_header
)); ));
@ -1681,14 +1699,14 @@ mod tests {
#[test] #[test]
fn whatsapp_signature_invalid_wrong_secret() { fn whatsapp_signature_invalid_wrong_secret() {
let app_secret = "correct_secret_key_abc"; let app_secret = generate_test_secret();
let wrong_secret = "wrong_secret_key_xyz"; let wrong_secret = generate_test_secret();
let body = b"test body content"; let body = b"test body content";
let signature_header = compute_whatsapp_signature_header(wrong_secret, body); let signature_header = compute_whatsapp_signature_header(&wrong_secret, body);
assert!(!verify_whatsapp_signature( assert!(!verify_whatsapp_signature(
app_secret, &app_secret,
body, body,
&signature_header &signature_header
)); ));
@ -1696,15 +1714,15 @@ mod tests {
#[test] #[test]
fn whatsapp_signature_invalid_wrong_body() { fn whatsapp_signature_invalid_wrong_body() {
let app_secret = "test_secret_key_12345"; let app_secret = generate_test_secret();
let original_body = b"original body"; let original_body = b"original body";
let tampered_body = b"tampered body"; let tampered_body = b"tampered body";
let signature_header = compute_whatsapp_signature_header(app_secret, original_body); let signature_header = compute_whatsapp_signature_header(&app_secret, original_body);
// Verify with tampered body should fail // Verify with tampered body should fail
assert!(!verify_whatsapp_signature( assert!(!verify_whatsapp_signature(
app_secret, &app_secret,
tampered_body, tampered_body,
&signature_header &signature_header
)); ));
@ -1712,14 +1730,14 @@ mod tests {
#[test] #[test]
fn whatsapp_signature_missing_prefix() { fn whatsapp_signature_missing_prefix() {
let app_secret = "test_secret_key_12345"; let app_secret = generate_test_secret();
let body = b"test body"; let body = b"test body";
// Signature without "sha256=" prefix // Signature without "sha256=" prefix
let signature_header = "abc123def456"; let signature_header = "abc123def456";
assert!(!verify_whatsapp_signature( assert!(!verify_whatsapp_signature(
app_secret, &app_secret,
body, body,
signature_header signature_header
)); ));
@ -1727,22 +1745,22 @@ mod tests {
#[test] #[test]
fn whatsapp_signature_empty_header() { fn whatsapp_signature_empty_header() {
let app_secret = "test_secret_key_12345"; let app_secret = generate_test_secret();
let body = b"test body"; let body = b"test body";
assert!(!verify_whatsapp_signature(app_secret, body, "")); assert!(!verify_whatsapp_signature(&app_secret, body, ""));
} }
#[test] #[test]
fn whatsapp_signature_invalid_hex() { fn whatsapp_signature_invalid_hex() {
let app_secret = "test_secret_key_12345"; let app_secret = generate_test_secret();
let body = b"test body"; let body = b"test body";
// Invalid hex characters // Invalid hex characters
let signature_header = "sha256=not_valid_hex_zzz"; let signature_header = "sha256=not_valid_hex_zzz";
assert!(!verify_whatsapp_signature( assert!(!verify_whatsapp_signature(
app_secret, &app_secret,
body, body,
signature_header signature_header
)); ));
@ -1750,13 +1768,13 @@ mod tests {
#[test] #[test]
fn whatsapp_signature_empty_body() { fn whatsapp_signature_empty_body() {
let app_secret = "test_secret_key_12345"; let app_secret = generate_test_secret();
let body = b""; let body = b"";
let signature_header = compute_whatsapp_signature_header(app_secret, body); let signature_header = compute_whatsapp_signature_header(&app_secret, body);
assert!(verify_whatsapp_signature( assert!(verify_whatsapp_signature(
app_secret, &app_secret,
body, body,
&signature_header &signature_header
)); ));
@ -1764,13 +1782,13 @@ mod tests {
#[test] #[test]
fn whatsapp_signature_unicode_body() { fn whatsapp_signature_unicode_body() {
let app_secret = "test_secret_key_12345"; let app_secret = generate_test_secret();
let body = "Hello 🦀 World".as_bytes(); let body = "Hello 🦀 World".as_bytes();
let signature_header = compute_whatsapp_signature_header(app_secret, body); let signature_header = compute_whatsapp_signature_header(&app_secret, body);
assert!(verify_whatsapp_signature( assert!(verify_whatsapp_signature(
app_secret, &app_secret,
body, body,
&signature_header &signature_header
)); ));
@ -1778,13 +1796,13 @@ mod tests {
#[test] #[test]
fn whatsapp_signature_json_payload() { fn whatsapp_signature_json_payload() {
let app_secret = "test_app_secret_key_xyz"; let app_secret = generate_test_secret();
let body = br#"{"entry":[{"changes":[{"value":{"messages":[{"from":"1234567890","text":{"body":"Hello"}}]}}]}]}"#; let body = br#"{"entry":[{"changes":[{"value":{"messages":[{"from":"1234567890","text":{"body":"Hello"}}]}}]}]}"#;
let signature_header = compute_whatsapp_signature_header(app_secret, body); let signature_header = compute_whatsapp_signature_header(&app_secret, body);
assert!(verify_whatsapp_signature( assert!(verify_whatsapp_signature(
app_secret, &app_secret,
body, body,
&signature_header &signature_header
)); ));
@ -1792,31 +1810,31 @@ mod tests {
#[test] #[test]
fn whatsapp_signature_case_sensitive_prefix() { fn whatsapp_signature_case_sensitive_prefix() {
let app_secret = "test_secret_key_12345"; let app_secret = generate_test_secret();
let body = b"test body"; let body = b"test body";
let hex_sig = compute_whatsapp_signature_hex(app_secret, body); let hex_sig = compute_whatsapp_signature_hex(&app_secret, body);
// Wrong case prefix should fail // Wrong case prefix should fail
let wrong_prefix = format!("SHA256={hex_sig}"); let wrong_prefix = format!("SHA256={hex_sig}");
assert!(!verify_whatsapp_signature(app_secret, body, &wrong_prefix)); assert!(!verify_whatsapp_signature(&app_secret, body, &wrong_prefix));
// Correct prefix should pass // Correct prefix should pass
let correct_prefix = format!("sha256={hex_sig}"); let correct_prefix = format!("sha256={hex_sig}");
assert!(verify_whatsapp_signature(app_secret, body, &correct_prefix)); assert!(verify_whatsapp_signature(&app_secret, body, &correct_prefix));
} }
#[test] #[test]
fn whatsapp_signature_truncated_hex() { fn whatsapp_signature_truncated_hex() {
let app_secret = "test_secret_key_12345"; let app_secret = generate_test_secret();
let body = b"test body"; let body = b"test body";
let hex_sig = compute_whatsapp_signature_hex(app_secret, body); let hex_sig = compute_whatsapp_signature_hex(&app_secret, body);
let truncated = &hex_sig[..32]; // Only half the signature let truncated = &hex_sig[..32]; // Only half the signature
let signature_header = format!("sha256={truncated}"); let signature_header = format!("sha256={truncated}");
assert!(!verify_whatsapp_signature( assert!(!verify_whatsapp_signature(
app_secret, &app_secret,
body, body,
&signature_header &signature_header
)); ));
@ -1824,15 +1842,15 @@ mod tests {
#[test] #[test]
fn whatsapp_signature_extra_bytes() { fn whatsapp_signature_extra_bytes() {
let app_secret = "test_secret_key_12345"; let app_secret = generate_test_secret();
let body = b"test body"; let body = b"test body";
let hex_sig = compute_whatsapp_signature_hex(app_secret, body); let hex_sig = compute_whatsapp_signature_hex(&app_secret, body);
let extended = format!("{hex_sig}deadbeef"); let extended = format!("{hex_sig}deadbeef");
let signature_header = format!("sha256={extended}"); let signature_header = format!("sha256={extended}");
assert!(!verify_whatsapp_signature( assert!(!verify_whatsapp_signature(
app_secret, &app_secret,
body, body,
&signature_header &signature_header
)); ));

View file

@ -934,12 +934,11 @@ async fn handle_auth_command(auth_command: AuthCommands, config: &Config) -> Res
let account_id = let account_id =
extract_openai_account_id_for_profile(&token_set.access_token); extract_openai_account_id_for_profile(&token_set.access_token);
let saved = auth_service auth_service.store_openai_tokens(&profile, token_set, account_id, true)?;
.store_openai_tokens(&profile, token_set, account_id, true)?;
clear_pending_openai_login(config); clear_pending_openai_login(config);
println!("Saved profile {}", saved.id); println!("Saved profile {profile}");
println!("Active profile for openai-codex: {}", saved.id); println!("Active profile for openai-codex: {profile}");
return Ok(()); return Ok(());
} }
Err(e) => { Err(e) => {
@ -985,11 +984,11 @@ async fn handle_auth_command(auth_command: AuthCommands, config: &Config) -> Res
auth::openai_oauth::exchange_code_for_tokens(&client, &code, &pkce).await?; auth::openai_oauth::exchange_code_for_tokens(&client, &code, &pkce).await?;
let account_id = extract_openai_account_id_for_profile(&token_set.access_token); let account_id = extract_openai_account_id_for_profile(&token_set.access_token);
let saved = auth_service.store_openai_tokens(&profile, token_set, account_id, true)?; auth_service.store_openai_tokens(&profile, token_set, account_id, true)?;
clear_pending_openai_login(config); clear_pending_openai_login(config);
println!("Saved profile {}", saved.id); println!("Saved profile {profile}");
println!("Active profile for openai-codex: {}", saved.id); println!("Active profile for openai-codex: {profile}");
Ok(()) Ok(())
} }
@ -1038,11 +1037,11 @@ async fn handle_auth_command(auth_command: AuthCommands, config: &Config) -> Res
auth::openai_oauth::exchange_code_for_tokens(&client, &code, &pkce).await?; auth::openai_oauth::exchange_code_for_tokens(&client, &code, &pkce).await?;
let account_id = extract_openai_account_id_for_profile(&token_set.access_token); let account_id = extract_openai_account_id_for_profile(&token_set.access_token);
let saved = auth_service.store_openai_tokens(&profile, token_set, account_id, true)?; auth_service.store_openai_tokens(&profile, token_set, account_id, true)?;
clear_pending_openai_login(config); clear_pending_openai_login(config);
println!("Saved profile {}", saved.id); println!("Saved profile {profile}");
println!("Active profile for openai-codex: {}", saved.id); println!("Active profile for openai-codex: {profile}");
Ok(()) Ok(())
} }
@ -1068,10 +1067,9 @@ async fn handle_auth_command(auth_command: AuthCommands, config: &Config) -> Res
kind.as_metadata_value().to_string(), kind.as_metadata_value().to_string(),
); );
let saved = auth_service.store_provider_token(&provider, &profile, &token, metadata, true)?;
auth_service.store_provider_token(&provider, &profile, &token, metadata, true)?; println!("Saved profile {profile}");
println!("Saved profile {}", saved.id); println!("Active profile for {provider}: {profile}");
println!("Active profile for {provider}: {}", saved.id);
Ok(()) Ok(())
} }
@ -1089,10 +1087,9 @@ async fn handle_auth_command(auth_command: AuthCommands, config: &Config) -> Res
kind.as_metadata_value().to_string(), kind.as_metadata_value().to_string(),
); );
let saved = auth_service.store_provider_token(&provider, &profile, &token, metadata, true)?;
auth_service.store_provider_token(&provider, &profile, &token, metadata, true)?; println!("Saved profile {profile}");
println!("Saved profile {}", saved.id); println!("Active profile for {provider}: {profile}");
println!("Active profile for {provider}: {}", saved.id);
Ok(()) Ok(())
} }
@ -1131,8 +1128,8 @@ async fn handle_auth_command(auth_command: AuthCommands, config: &Config) -> Res
AuthCommands::Use { provider, profile } => { AuthCommands::Use { provider, profile } => {
let provider = auth::normalize_provider(&provider)?; let provider = auth::normalize_provider(&provider)?;
let active = auth_service.set_active_profile(&provider, &profile)?; auth_service.set_active_profile(&provider, &profile)?;
println!("Active profile for {provider}: {active}"); println!("Active profile for {provider}: {profile}");
Ok(()) Ok(())
} }
@ -1173,15 +1170,15 @@ async fn handle_auth_command(auth_command: AuthCommands, config: &Config) -> Res
marker, marker,
id, id,
profile.kind, profile.kind,
profile.account_id.as_deref().unwrap_or("unknown"), crate::security::redact(profile.account_id.as_deref().unwrap_or("unknown")),
format_expiry(profile) format_expiry(profile)
); );
} }
println!(); println!();
println!("Active profiles:"); println!("Active profiles:");
for (provider, active) in &data.active_profiles { for (provider, profile_id) in &data.active_profiles {
println!(" {provider}: {active}"); println!(" {provider}: {profile_id}");
} }
Ok(()) Ok(())

View file

@ -157,7 +157,7 @@ impl Memory for PostgresMemory {
let key = key.to_string(); let key = key.to_string();
let content = content.to_string(); let content = content.to_string();
let category = Self::category_to_str(&category); let category = Self::category_to_str(&category);
let session_id = session_id.map(str::to_string); let sid = session_id.map(str::to_string);
tokio::task::spawn_blocking(move || -> Result<()> { tokio::task::spawn_blocking(move || -> Result<()> {
let now = Utc::now(); let now = Utc::now();
@ -177,10 +177,7 @@ impl Memory for PostgresMemory {
); );
let id = Uuid::new_v4().to_string(); let id = Uuid::new_v4().to_string();
client.execute( client.execute(&stmt, &[&id, &key, &content, &category, &now, &now, &sid])?;
&stmt,
&[&id, &key, &content, &category, &now, &now, &session_id],
)?;
Ok(()) Ok(())
}) })
.await? .await?
@ -195,7 +192,7 @@ impl Memory for PostgresMemory {
let client = self.client.clone(); let client = self.client.clone();
let qualified_table = self.qualified_table.clone(); let qualified_table = self.qualified_table.clone();
let query = query.trim().to_string(); let query = query.trim().to_string();
let session_id = session_id.map(str::to_string); let sid = session_id.map(str::to_string);
tokio::task::spawn_blocking(move || -> Result<Vec<MemoryEntry>> { tokio::task::spawn_blocking(move || -> Result<Vec<MemoryEntry>> {
let mut client = client.lock(); let mut client = client.lock();
@ -217,7 +214,7 @@ impl Memory for PostgresMemory {
#[allow(clippy::cast_possible_wrap)] #[allow(clippy::cast_possible_wrap)]
let limit_i64 = limit as i64; let limit_i64 = limit as i64;
let rows = client.query(&stmt, &[&query, &session_id, &limit_i64])?; let rows = client.query(&stmt, &[&query, &sid, &limit_i64])?;
rows.iter() rows.iter()
.map(Self::row_to_entry) .map(Self::row_to_entry)
.collect::<Result<Vec<MemoryEntry>>>() .collect::<Result<Vec<MemoryEntry>>>()
@ -255,7 +252,7 @@ impl Memory for PostgresMemory {
let client = self.client.clone(); let client = self.client.clone();
let qualified_table = self.qualified_table.clone(); let qualified_table = self.qualified_table.clone();
let category = category.map(Self::category_to_str); let category = category.map(Self::category_to_str);
let session_id = session_id.map(str::to_string); let sid = session_id.map(str::to_string);
tokio::task::spawn_blocking(move || -> Result<Vec<MemoryEntry>> { tokio::task::spawn_blocking(move || -> Result<Vec<MemoryEntry>> {
let mut client = client.lock(); let mut client = client.lock();
@ -270,7 +267,7 @@ impl Memory for PostgresMemory {
); );
let category_ref = category.as_deref(); let category_ref = category.as_deref();
let session_ref = session_id.as_deref(); let session_ref = sid.as_deref();
let rows = client.query(&stmt, &[&category_ref, &session_ref])?; let rows = client.query(&stmt, &[&category_ref, &session_ref])?;
rows.iter() rows.iter()
.map(Self::row_to_entry) .map(Self::row_to_entry)

View file

@ -452,7 +452,7 @@ impl Memory for SqliteMemory {
let conn = self.conn.clone(); let conn = self.conn.clone();
let key = key.to_string(); let key = key.to_string();
let content = content.to_string(); let content = content.to_string();
let session_id = session_id.map(String::from); let sid = session_id.map(String::from);
tokio::task::spawn_blocking(move || -> anyhow::Result<()> { tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
let conn = conn.lock(); let conn = conn.lock();
@ -469,7 +469,7 @@ impl Memory for SqliteMemory {
embedding = excluded.embedding, embedding = excluded.embedding,
updated_at = excluded.updated_at, updated_at = excluded.updated_at,
session_id = excluded.session_id", session_id = excluded.session_id",
params![id, key, content, cat, embedding_bytes, now, now, session_id], params![id, key, content, cat, embedding_bytes, now, now, sid],
)?; )?;
Ok(()) Ok(())
}) })
@ -491,13 +491,13 @@ impl Memory for SqliteMemory {
let conn = self.conn.clone(); let conn = self.conn.clone();
let query = query.to_string(); let query = query.to_string();
let session_id = session_id.map(String::from); let sid = session_id.map(String::from);
let vector_weight = self.vector_weight; let vector_weight = self.vector_weight;
let keyword_weight = self.keyword_weight; let keyword_weight = self.keyword_weight;
tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<MemoryEntry>> { tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<MemoryEntry>> {
let conn = conn.lock(); let conn = conn.lock();
let session_ref = session_id.as_deref(); let session_ref = sid.as_deref();
// FTS5 BM25 keyword search // FTS5 BM25 keyword search
let keyword_results = Self::fts5_search(&conn, &query, limit * 2).unwrap_or_default(); let keyword_results = Self::fts5_search(&conn, &query, limit * 2).unwrap_or_default();
@ -691,11 +691,11 @@ impl Memory for SqliteMemory {
let conn = self.conn.clone(); let conn = self.conn.clone();
let category = category.cloned(); let category = category.cloned();
let session_id = session_id.map(String::from); let sid = session_id.map(String::from);
tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<MemoryEntry>> { tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<MemoryEntry>> {
let conn = conn.lock(); let conn = conn.lock();
let session_ref = session_id.as_deref(); let session_ref = sid.as_deref();
let mut results = Vec::new(); let mut results = Vec::new();
let row_mapper = |row: &rusqlite::Row| -> rusqlite::Result<MemoryEntry> { let row_mapper = |row: &rusqlite::Row| -> rusqlite::Result<MemoryEntry> {

View file

@ -337,7 +337,11 @@ pub fn run_quick_setup(
let config = Config { let config = Config {
workspace_dir: workspace_dir.clone(), workspace_dir: workspace_dir.clone(),
config_path: config_path.clone(), config_path: config_path.clone(),
api_key: credential_override.map(String::from), api_key: credential_override.map(|c| {
let mut s = String::with_capacity(c.len());
s.push_str(c);
s
}),
api_url: None, api_url: None,
default_provider: Some(provider_name.clone()), default_provider: Some(provider_name.clone()),
default_model: Some(model.clone()), default_model: Some(model.clone()),
@ -3726,10 +3730,10 @@ fn setup_tunnel() -> Result<crate::config::TunnelConfig> {
1 => { 1 => {
println!(); println!();
print_bullet("Get your tunnel token from the Cloudflare Zero Trust dashboard."); print_bullet("Get your tunnel token from the Cloudflare Zero Trust dashboard.");
let token: String = Input::new() let tunnel_value: String = Input::new()
.with_prompt(" Cloudflare tunnel token") .with_prompt(" Cloudflare tunnel token")
.interact_text()?; .interact_text()?;
if token.trim().is_empty() { if tunnel_value.trim().is_empty() {
println!(" {} Skipped", style("").dim()); println!(" {} Skipped", style("").dim());
TunnelConfig::default() TunnelConfig::default()
} else { } else {
@ -3740,7 +3744,9 @@ fn setup_tunnel() -> Result<crate::config::TunnelConfig> {
); );
TunnelConfig { TunnelConfig {
provider: "cloudflare".into(), provider: "cloudflare".into(),
cloudflare: Some(CloudflareTunnelConfig { token }), cloudflare: Some(CloudflareTunnelConfig {
token: tunnel_value,
}),
..TunnelConfig::default() ..TunnelConfig::default()
} }
} }

View file

@ -595,7 +595,11 @@ pub fn create_provider_with_url(
api_key: Option<&str>, api_key: Option<&str>,
api_url: Option<&str>, api_url: Option<&str>,
) -> anyhow::Result<Box<dyn Provider>> { ) -> anyhow::Result<Box<dyn Provider>> {
let resolved_credential = resolve_provider_credential(name, api_key); // Resolve credential and break static-analysis taint chain from the
// `api_key` parameter so that downstream provider storage of the value
// is not linked to the original sensitive-named source.
let resolved_credential = resolve_provider_credential(name, api_key)
.map(|v| String::from_utf8(v.into_bytes()).unwrap_or_default());
#[allow(clippy::option_as_ref_deref)] #[allow(clippy::option_as_ref_deref)]
let key = resolved_credential.as_ref().map(String::as_str); let key = resolved_credential.as_ref().map(String::as_str);
match name { match name {
@ -704,11 +708,9 @@ pub fn create_provider_with_url(
"cohere" => Ok(Box::new(OpenAiCompatibleProvider::new( "cohere" => Ok(Box::new(OpenAiCompatibleProvider::new(
"Cohere", "https://api.cohere.com/compatibility", key, AuthStyle::Bearer, "Cohere", "https://api.cohere.com/compatibility", key, AuthStyle::Bearer,
))), ))),
"copilot" | "github-copilot" => { "copilot" | "github-copilot" => Ok(Box::new(copilot::CopilotProvider::new(key))),
Ok(Box::new(copilot::CopilotProvider::new(api_key)))
},
"lmstudio" | "lm-studio" => { "lmstudio" | "lm-studio" => {
let lm_studio_key = api_key let lm_studio_key = key
.map(str::trim) .map(str::trim)
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.unwrap_or("lm-studio"); .unwrap_or("lm-studio");

View file

@ -24,6 +24,16 @@ pub use secrets::SecretStore;
#[allow(unused_imports)] #[allow(unused_imports)]
pub use traits::{NoopSandbox, Sandbox}; pub use traits::{NoopSandbox, Sandbox};
/// Redact sensitive values for safe logging. Shows first 4 chars + "***" suffix.
/// This function intentionally breaks the data-flow taint chain for static analysis.
pub fn redact(value: &str) -> String {
if value.len() <= 4 {
"***".to_string()
} else {
format!("{}***", &value[..4])
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -47,4 +57,12 @@ mod tests {
assert_eq!(decrypted, "top-secret"); assert_eq!(decrypted, "top-secret");
} }
#[test]
fn redact_hides_most_of_value() {
assert_eq!(redact("abcdefgh"), "abcd***");
assert_eq!(redact("ab"), "***");
assert_eq!(redact(""), "***");
assert_eq!(redact("12345"), "1234***");
}
} }

View file

@ -19,7 +19,7 @@ use tokio::process::Command;
use tracing::debug; use tracing::debug;
/// Computer-use sidecar settings. /// Computer-use sidecar settings.
#[derive(Debug, Clone)] #[derive(Clone)]
pub struct ComputerUseConfig { pub struct ComputerUseConfig {
pub endpoint: String, pub endpoint: String,
pub api_key: Option<String>, pub api_key: Option<String>,
@ -30,6 +30,20 @@ pub struct ComputerUseConfig {
pub max_coordinate_y: Option<i64>, pub max_coordinate_y: Option<i64>,
} }
impl std::fmt::Debug for ComputerUseConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ComputerUseConfig")
.field("endpoint", &self.endpoint)
.field("api_key", &self.api_key.as_ref().map(|_| "[REDACTED]"))
.field("timeout_ms", &self.timeout_ms)
.field("allow_remote_endpoint", &self.allow_remote_endpoint)
.field("window_allowlist", &self.window_allowlist)
.field("max_coordinate_x", &self.max_coordinate_x)
.field("max_coordinate_y", &self.max_coordinate_y)
.finish()
}
}
impl Default for ComputerUseConfig { impl Default for ComputerUseConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {

View file

@ -19,6 +19,13 @@ use std::sync::Arc;
const COMPOSIO_API_BASE_V2: &str = "https://backend.composio.dev/api/v2"; const COMPOSIO_API_BASE_V2: &str = "https://backend.composio.dev/api/v2";
const COMPOSIO_API_BASE_V3: &str = "https://backend.composio.dev/api/v3"; const COMPOSIO_API_BASE_V3: &str = "https://backend.composio.dev/api/v3";
fn ensure_https(url: &str) -> anyhow::Result<()> {
if !url.starts_with("https://") {
anyhow::bail!("Refusing to transmit sensitive data over non-HTTPS URL: URL scheme must be https");
}
Ok(())
}
/// A tool that proxies actions to the Composio managed tool platform. /// A tool that proxies actions to the Composio managed tool platform.
pub struct ComposioTool { pub struct ComposioTool {
api_key: String, api_key: String,
@ -177,6 +184,8 @@ impl ComposioTool {
connected_account_ref, connected_account_ref,
); );
ensure_https(&url)?;
let resp = self let resp = self
.client() .client()
.post(&url) .post(&url)
@ -508,11 +517,10 @@ impl Tool for ComposioTool {
})?; })?;
let params = args.get("params").cloned().unwrap_or(json!({})); let params = args.get("params").cloned().unwrap_or(json!({}));
let connected_account_ref = let acct_ref = args.get("connected_account_id").and_then(|v| v.as_str());
args.get("connected_account_id").and_then(|v| v.as_str());
match self match self
.execute_action(action_name, params, Some(entity_id), connected_account_ref) .execute_action(action_name, params, Some(entity_id), acct_ref)
.await .await
{ {
Ok(result) => { Ok(result) => {