Skip to content

Commit 6fad581

Browse files
committed
feat(chutes): add Chutes provider with dynamic TEE model validation
Adds Chutes as a new AI provider with Trusted Execution Environment (TEE) security requirements. Any model ending with '-TEE' suffix is accepted, enabling custom model support via --model flag. Provider configuration: - ID: chutes - Name: Chutes (TEE) - API Key: CHUTES_API_KEY - Base URL: https://llm.chutes.ai Security enforcement (defense-in-depth): - Dynamic -TEE suffix validation (case-insensitive) - Validation at all entry points: config load, provider switch, model set, API calls - Input sanitization: null bytes, control chars, Unicode homoglyphs rejected - Only safe ASCII characters allowed in model names Default model: moonshotai/Kimi-K2.5-TEE
1 parent 334f682 commit 6fad581

7 files changed

Lines changed: 411 additions & 28 deletions

File tree

src/cortex-common/src/model_presets/mod.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ pub use types::{ModelAlias, ModelPreset, ModelResolution};
1818
pub use constants::{DEFAULT_MODEL, DEFAULT_MODELS, DEFAULT_PROVIDER};
1919

2020
// Re-export preset data and helpers
21-
pub use presets::{MODEL_PRESETS, get_model_preset, get_models_for_provider};
21+
pub use presets::{
22+
DEFAULT_CHUTES_MODEL, MODEL_PRESETS, get_model_preset, get_models_for_provider,
23+
provider_allows_custom_models, validate_chutes_model,
24+
};
2225

2326
// Re-export alias data and helpers
2427
pub use aliases::{MODEL_ALIASES, list_model_aliases, resolve_model_alias};

src/cortex-common/src/model_presets/presets.rs

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
33
use super::types::ModelPreset;
44

5+
/// Default model for Chutes provider.
6+
/// This is the fallback model when no specific model is provided.
7+
pub const DEFAULT_CHUTES_MODEL: &str = "moonshotai/Kimi-K2.5-TEE";
8+
59
/// Available model presets.
610
pub const MODEL_PRESETS: &[ModelPreset] = &[
711
ModelPreset {
@@ -806,6 +810,17 @@ pub const MODEL_PRESETS: &[ModelPreset] = &[
806810
supports_tools: true,
807811
supports_reasoning: false,
808812
},
813+
// Chutes TEE models (Trusted Execution Environment)
814+
// Security requirement: Only models with '-TEE' suffix are allowed
815+
ModelPreset {
816+
id: "moonshotai/Kimi-K2.5-TEE",
817+
name: "Kimi K2.5 (TEE)",
818+
provider: "chutes",
819+
context_window: 262_144,
820+
supports_vision: false,
821+
supports_tools: true,
822+
supports_reasoning: true,
823+
},
809824
];
810825

811826
/// Get a model preset by ID.
@@ -820,3 +835,249 @@ pub fn get_models_for_provider(provider: &str) -> Vec<&'static ModelPreset> {
820835
.filter(|m| m.provider == provider)
821836
.collect()
822837
}
838+
839+
/// Validates that a model is allowed for the Chutes provider.
840+
/// Chutes only allows TEE (Trusted Execution Environment) models for security.
841+
/// Any model ending with '-TEE' suffix (case-insensitive) is accepted.
842+
/// Returns Ok(()) if valid, Err with message if invalid.
843+
///
844+
/// # Security
845+
/// This function performs strict validation to prevent bypass attacks:
846+
/// - Rejects null bytes and control characters (prevents C-string truncation attacks)
847+
/// - Only allows safe ASCII characters: alphanumeric, hyphen, underscore, dot, forward slash
848+
/// - Case-insensitive suffix check for -TEE
849+
pub fn validate_chutes_model(model: &str) -> Result<(), String> {
850+
let model = model.trim();
851+
852+
// Check for empty model
853+
if model.is_empty() {
854+
return Err("Model name cannot be empty for Chutes provider".to_string());
855+
}
856+
857+
// SECURITY: Reject null bytes and control characters (CWE-626, CWE-158)
858+
// This prevents null byte injection attacks where "malicious\0-TEE" would
859+
// pass validation but be truncated to "malicious" by C libraries/APIs
860+
if model.bytes().any(|b| b == 0 || b < 0x20) {
861+
return Err(
862+
"Model name contains invalid characters (null bytes or control characters)".to_string(),
863+
);
864+
}
865+
866+
// SECURITY: Only allow safe ASCII characters for model names
867+
// Allowed: a-z, A-Z, 0-9, hyphen (-), underscore (_), dot (.), forward slash (/)
868+
// This prevents Unicode homoglyph attacks and other encoding-based bypasses
869+
if !model
870+
.chars()
871+
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/'))
872+
{
873+
return Err(
874+
"Model name contains invalid characters. Only alphanumeric characters, \
875+
hyphens, underscores, dots, and forward slashes are allowed."
876+
.to_string(),
877+
);
878+
}
879+
880+
// Check suffix (case-insensitive) - any model ending with -TEE is allowed
881+
if !model.to_uppercase().ends_with("-TEE") {
882+
return Err(format!(
883+
"Chutes provider only allows TEE models (models ending with '-TEE'). \
884+
Model '{}' is not a TEE model. Default model: {}",
885+
model, DEFAULT_CHUTES_MODEL
886+
));
887+
}
888+
889+
Ok(())
890+
}
891+
892+
/// Checks if a provider restricts custom models.
893+
/// All providers allow custom models, but Chutes requires -TEE suffix.
894+
pub fn provider_allows_custom_models(provider: &str) -> bool {
895+
// All providers allow custom models
896+
// Chutes allows any model with -TEE suffix (validated via validate_chutes_model)
897+
let _ = provider; // Used for potential future provider-specific restrictions
898+
true
899+
}
900+
901+
#[cfg(test)]
902+
mod tests {
903+
use super::*;
904+
905+
#[test]
906+
fn test_validate_chutes_model_valid() {
907+
// Default TEE model
908+
assert!(validate_chutes_model("moonshotai/Kimi-K2.5-TEE").is_ok());
909+
// Case insensitive
910+
assert!(validate_chutes_model("moonshotai/kimi-k2.5-tee").is_ok());
911+
assert!(validate_chutes_model("MOONSHOTAI/KIMI-K2.5-TEE").is_ok());
912+
// Whitespace handling
913+
assert!(validate_chutes_model(" moonshotai/Kimi-K2.5-TEE ").is_ok());
914+
// Any model with -TEE suffix is valid
915+
assert!(validate_chutes_model("custom-model-TEE").is_ok());
916+
assert!(validate_chutes_model("some-provider/my-model-TEE").is_ok());
917+
assert!(validate_chutes_model("another-model-tee").is_ok());
918+
assert!(validate_chutes_model("UPPERCASE-MODEL-TEE").is_ok());
919+
// Allowed special characters
920+
assert!(validate_chutes_model("provider_name/model.v1-TEE").is_ok());
921+
assert!(validate_chutes_model("my_custom_model-TEE").is_ok());
922+
}
923+
924+
#[test]
925+
fn test_validate_chutes_model_invalid() {
926+
// Not a TEE model (no -TEE suffix)
927+
assert!(validate_chutes_model("gpt-4").is_err());
928+
assert!(validate_chutes_model("claude-3").is_err());
929+
assert!(validate_chutes_model("some-model").is_err());
930+
931+
// TEE in wrong position (not at the end)
932+
assert!(validate_chutes_model("model-TEE-v2").is_err());
933+
assert!(validate_chutes_model("TEE-model").is_err());
934+
assert!(validate_chutes_model("my-TEE-model-v1").is_err());
935+
936+
// Empty string
937+
let result = validate_chutes_model("");
938+
assert!(result.is_err());
939+
assert!(result.unwrap_err().contains("cannot be empty"));
940+
941+
// Whitespace only
942+
let result = validate_chutes_model(" ");
943+
assert!(result.is_err());
944+
assert!(result.unwrap_err().contains("cannot be empty"));
945+
}
946+
947+
// ===========================================
948+
// SECURITY TESTS: Bypass attempt prevention
949+
// ===========================================
950+
951+
#[test]
952+
fn test_validate_chutes_model_null_byte_injection() {
953+
// SECURITY: Null byte injection attack (CWE-626, CWE-158)
954+
// Attacker tries to bypass TEE check by appending -TEE after a null byte
955+
// C libraries would see only "gpt-4" but our validation would see "gpt-4\0-TEE"
956+
let malicious_with_null = "gpt-4\0-TEE";
957+
let result = validate_chutes_model(malicious_with_null);
958+
assert!(result.is_err(), "Null byte injection should be rejected");
959+
assert!(
960+
result.unwrap_err().contains("invalid characters"),
961+
"Error should mention invalid characters"
962+
);
963+
964+
// More null byte attack variants
965+
assert!(validate_chutes_model("claude-3\0-TEE").is_err());
966+
assert!(validate_chutes_model("\0model-TEE").is_err());
967+
assert!(validate_chutes_model("model\0-TEE\0").is_err());
968+
}
969+
970+
#[test]
971+
fn test_validate_chutes_model_control_characters() {
972+
// SECURITY: Control character injection
973+
// Characters below 0x20 (space) could cause parsing issues
974+
assert!(validate_chutes_model("model\t-TEE").is_err()); // Tab
975+
assert!(validate_chutes_model("model\n-TEE").is_err()); // Newline
976+
assert!(validate_chutes_model("model\r-TEE").is_err()); // Carriage return
977+
assert!(validate_chutes_model("model\x1b-TEE").is_err()); // Escape
978+
assert!(validate_chutes_model("model\x07-TEE").is_err()); // Bell
979+
}
980+
981+
#[test]
982+
fn test_validate_chutes_model_unicode_attacks() {
983+
// SECURITY: Unicode homoglyph attacks
984+
// Attacker tries to use visually similar Unicode characters
985+
986+
// Cyrillic 'Е' (U+0415) looks like Latin 'E' but is different
987+
assert!(validate_chutes_model("model-TЕЕ").is_err()); // Cyrillic E
988+
989+
// Fullwidth characters
990+
assert!(validate_chutes_model("model-TEE").is_err()); // Fullwidth TEE
991+
992+
// Other Unicode tricks
993+
assert!(validate_chutes_model("model-TEE\u{200B}").is_err()); // Zero-width space at end
994+
assert!(validate_chutes_model("model\u{FEFF}-TEE").is_err()); // BOM in middle
995+
996+
// Combining characters
997+
assert!(validate_chutes_model("model-TE\u{0301}E").is_err()); // E with combining acute
998+
}
999+
1000+
#[test]
1001+
fn test_validate_chutes_model_special_characters() {
1002+
// SECURITY: Reject potentially dangerous special characters
1003+
// These could cause issues in shell commands, URLs, or other contexts
1004+
1005+
assert!(validate_chutes_model("model;-TEE").is_err()); // Semicolon (command separator)
1006+
assert!(validate_chutes_model("model&-TEE").is_err()); // Ampersand
1007+
assert!(validate_chutes_model("model|-TEE").is_err()); // Pipe
1008+
assert!(validate_chutes_model("model`-TEE").is_err()); // Backtick
1009+
assert!(validate_chutes_model("model$-TEE").is_err()); // Dollar sign
1010+
assert!(validate_chutes_model("model'-TEE").is_err()); // Single quote
1011+
assert!(validate_chutes_model("model\"-TEE").is_err()); // Double quote
1012+
assert!(validate_chutes_model("model<-TEE").is_err()); // Less than
1013+
assert!(validate_chutes_model("model>-TEE").is_err()); // Greater than
1014+
assert!(validate_chutes_model("model(-TEE").is_err()); // Parenthesis
1015+
assert!(validate_chutes_model("model)-TEE").is_err());
1016+
assert!(validate_chutes_model("model{-TEE").is_err()); // Braces
1017+
assert!(validate_chutes_model("model}-TEE").is_err());
1018+
assert!(validate_chutes_model("model[-TEE").is_err()); // Brackets
1019+
assert!(validate_chutes_model("model]-TEE").is_err());
1020+
assert!(validate_chutes_model("model\\-TEE").is_err()); // Backslash
1021+
assert!(validate_chutes_model("model!-TEE").is_err()); // Exclamation
1022+
assert!(validate_chutes_model("model@-TEE").is_err()); // At sign
1023+
assert!(validate_chutes_model("model#-TEE").is_err()); // Hash
1024+
assert!(validate_chutes_model("model%-TEE").is_err()); // Percent
1025+
assert!(validate_chutes_model("model^-TEE").is_err()); // Caret
1026+
assert!(validate_chutes_model("model*-TEE").is_err()); // Asterisk
1027+
assert!(validate_chutes_model("model=-TEE").is_err()); // Equals
1028+
assert!(validate_chutes_model("model+-TEE").is_err()); // Plus
1029+
assert!(validate_chutes_model("model~-TEE").is_err()); // Tilde
1030+
assert!(validate_chutes_model("model?-TEE").is_err()); // Question mark
1031+
assert!(validate_chutes_model("model:-TEE").is_err()); // Colon
1032+
assert!(validate_chutes_model("model,-TEE").is_err()); // Comma
1033+
assert!(validate_chutes_model("model -TEE").is_err()); // Space in middle
1034+
}
1035+
1036+
#[test]
1037+
fn test_validate_chutes_model_allowed_characters() {
1038+
// Verify that only allowed characters pass
1039+
// Allowed: a-z, A-Z, 0-9, hyphen (-), underscore (_), dot (.), forward slash (/)
1040+
1041+
// All allowed characters
1042+
assert!(validate_chutes_model("abc123-TEE").is_ok());
1043+
assert!(validate_chutes_model("ABC123-TEE").is_ok());
1044+
assert!(validate_chutes_model("model_name-TEE").is_ok());
1045+
assert!(validate_chutes_model("model.v1-TEE").is_ok());
1046+
assert!(validate_chutes_model("provider/model-TEE").is_ok());
1047+
assert!(validate_chutes_model("my-model-TEE").is_ok());
1048+
assert!(validate_chutes_model("Provider123/Model_v1.0-TEE").is_ok());
1049+
}
1050+
1051+
#[test]
1052+
fn test_validate_chutes_model_error_message() {
1053+
let result = validate_chutes_model("invalid-model");
1054+
assert!(result.is_err());
1055+
let err = result.unwrap_err();
1056+
// Error message should mention the default model
1057+
assert!(err.contains(DEFAULT_CHUTES_MODEL));
1058+
assert!(err.contains("-TEE"));
1059+
}
1060+
1061+
#[test]
1062+
fn test_provider_allows_custom_models() {
1063+
// All providers allow custom models
1064+
assert!(provider_allows_custom_models("chutes"));
1065+
assert!(provider_allows_custom_models("Chutes"));
1066+
assert!(provider_allows_custom_models("CHUTES"));
1067+
assert!(provider_allows_custom_models("cortex"));
1068+
assert!(provider_allows_custom_models("openai"));
1069+
assert!(provider_allows_custom_models("anthropic"));
1070+
}
1071+
1072+
#[test]
1073+
fn test_default_chutes_model() {
1074+
// Verify the default model is a valid TEE model
1075+
assert!(DEFAULT_CHUTES_MODEL.to_uppercase().ends_with("-TEE"));
1076+
assert_eq!(DEFAULT_CHUTES_MODEL, "moonshotai/Kimi-K2.5-TEE");
1077+
// Verify the default model passes our own validation
1078+
assert!(
1079+
validate_chutes_model(DEFAULT_CHUTES_MODEL).is_ok(),
1080+
"Default Chutes model must pass validation"
1081+
);
1082+
}
1083+
}

src/cortex-tui/src/modal/providers.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,10 @@ impl ProviderInfo {
7070

7171
/// Returns the list of known providers with their default information.
7272
pub fn known_providers() -> Vec<ProviderInfo> {
73-
vec![ProviderInfo::new("cortex", "Cortex").with_description("Cortex AI Gateway")]
73+
vec![
74+
ProviderInfo::new("cortex", "Cortex").with_description("Cortex AI Gateway"),
75+
ProviderInfo::new("chutes", "Chutes (TEE)").with_description("TEE-secured models only"),
76+
]
7477
}
7578

7679
// ============================================================================
@@ -395,15 +398,16 @@ mod tests {
395398
let modal = ProvidersModal::with_known_providers(Some("cortex".to_string()));
396399

397400
assert_eq!(modal.title(), "Providers");
398-
assert_eq!(modal.providers.len(), 1);
401+
assert_eq!(modal.providers.len(), 2);
399402
}
400403

401404
#[test]
402405
fn test_known_providers() {
403406
let providers = known_providers();
404407

405-
assert_eq!(providers.len(), 1);
408+
assert_eq!(providers.len(), 2);
406409
assert!(providers.iter().any(|p| p.id == "cortex"));
410+
assert!(providers.iter().any(|p| p.id == "chutes"));
407411
}
408412

409413
#[test]

src/cortex-tui/src/providers/config.rs

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,23 @@ pub const CONFIG_FILE: &str = "config.json";
2727
/// Sessions directory name
2828
pub const SESSIONS_DIR: &str = "sessions";
2929

30-
/// Single provider - Cortex Backend (for compatibility with old code)
31-
pub const PROVIDERS: &[ProviderInfo] = &[ProviderInfo {
32-
id: "cortex",
33-
name: "Cortex",
34-
env_var: "CORTEX_AUTH_TOKEN",
35-
base_url: "https://api.cortex.foundation",
36-
requires_key: true,
37-
}];
30+
/// Supported providers
31+
pub const PROVIDERS: &[ProviderInfo] = &[
32+
ProviderInfo {
33+
id: "cortex",
34+
name: "Cortex",
35+
env_var: "CORTEX_AUTH_TOKEN",
36+
base_url: "https://api.cortex.foundation",
37+
requires_key: true,
38+
},
39+
ProviderInfo {
40+
id: "chutes",
41+
name: "Chutes (TEE)",
42+
env_var: "CHUTES_API_KEY",
43+
base_url: "https://llm.chutes.ai",
44+
requires_key: true,
45+
},
46+
];
3847

3948
// ============================================================
4049
// TYPES

0 commit comments

Comments
 (0)