generalbots/src/basic/keywords/llm_macros.rs
Rodrigo Rodriguez (Pragmatismo) c67aaa677a feat(security): Complete security infrastructure implementation
SECURITY MODULES ADDED:
- security/auth.rs: Full RBAC with roles (Anonymous, User, Moderator, Admin, SuperAdmin, Service, Bot, BotOwner, BotOperator, BotViewer) and permissions
- security/cors.rs: Hardened CORS (no wildcard in production, env-based config)
- security/panic_handler.rs: Panic catching middleware with safe 500 responses
- security/path_guard.rs: Path traversal protection, null byte prevention
- security/request_id.rs: UUID request tracking with correlation IDs
- security/error_sanitizer.rs: Sensitive data redaction from responses
- security/zitadel_auth.rs: Zitadel token introspection and role mapping
- security/sql_guard.rs: SQL injection prevention with table whitelist
- security/command_guard.rs: Command injection prevention
- security/secrets.rs: Zeroizing secret management
- security/validation.rs: Input validation utilities
- security/rate_limiter.rs: Rate limiting with governor crate
- security/headers.rs: Security headers (CSP, HSTS, X-Frame-Options)

MAIN.RS UPDATES:
- Replaced tower_http::cors::Any with hardened create_cors_layer()
- Added panic handler middleware
- Added request ID tracking middleware
- Set global panic hook

SECURITY STATUS:
- 0 unwrap() in production code
- 0 panic! in production code
- 0 unsafe blocks
- cargo audit: PASS (no vulnerabilities)
- Estimated completion: ~98%

Remaining: Wire auth middleware to handlers, audit logs for sensitive data
2025-12-28 19:29:18 -03:00

425 lines
15 KiB
Rust

/*****************************************************************************\
| █████ █████ ██ █ █████ █████ ████ ██ ████ █████ █████ ███ ® |
| ██ █ ███ █ █ ██ ██ ██ ██ ██ ██ █ ██ ██ █ █ |
| ██ ███ ████ █ ██ █ ████ █████ ██████ ██ ████ █ █ █ ██ |
| ██ ██ █ █ ██ █ █ ██ ██ ██ ██ ██ ██ █ ██ ██ █ █ |
| █████ █████ █ ███ █████ ██ ██ ██ ██ █████ ████ █████ █ ███ |
| |
| General Bots Copyright (c) pragmatismo.com.br. All rights reserved. |
| Licensed under the AGPL-3.0. |
| |
| According to our dual licensing model, this program can be used either |
| under the terms of the GNU Affero General Public License, version 3, |
| or under a proprietary license. |
| |
| The texts of the GNU Affero General Public License with an additional |
| permission and of our proprietary license can be found at and |
| in the LICENSE file you have received along with this program. |
| |
| This program is distributed in the hope that it will be useful, |
| but WITHOUT ANY WARRANTY, without even the implied warranty of |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| GNU Affero General Public License for more details. |
| |
| "General Bots" is a registered trademark of pragmatismo.com.br. |
| The licensing of the program under the AGPLv3 does not imply a |
| trademark license. Therefore any rights, title and interest in |
| our trademarks remain entirely with us. |
| |
\*****************************************************************************/
use crate::core::config::ConfigManager;
use crate::shared::models::UserSession;
use crate::shared::state::AppState;
use log::{error, trace};
use rhai::{Array, Dynamic, Engine, Map};
use std::sync::Arc;
use std::time::Duration;
use uuid::Uuid;
pub fn register_llm_macros(state: Arc<AppState>, user: UserSession, engine: &mut Engine) {
register_calculate_keyword(state.clone(), user.clone(), engine);
register_validate_keyword(state.clone(), user.clone(), engine);
register_translate_keyword(state.clone(), user.clone(), engine);
register_summarize_keyword(state, user, engine);
}
async fn call_llm(
state: &AppState,
prompt: &str,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let config_manager = ConfigManager::new(state.conn.clone());
let model = config_manager
.get_config(&Uuid::nil(), "llm-model", None)
.unwrap_or_default();
let key = config_manager
.get_config(&Uuid::nil(), "llm-key", None)
.unwrap_or_default();
let handler = crate::llm::llm_models::get_handler(&model);
let raw_response = state
.llm_provider
.generate(prompt, &serde_json::Value::Null, &model, &key)
.await?;
let processed = handler.process_content(&raw_response);
Ok(processed)
}
fn run_llm_with_timeout(
state: Arc<AppState>,
prompt: String,
timeout_secs: u64,
) -> Result<String, Box<rhai::EvalAltResult>> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build();
let send_err = if let Ok(rt) = rt {
let result = rt.block_on(async move { call_llm(&state, &prompt).await });
tx.send(result).err()
} else {
tx.send(Err("Failed to build tokio runtime".into())).err()
};
if send_err.is_some() {
error!("Failed to send LLM result from thread");
}
});
match rx.recv_timeout(Duration::from_secs(timeout_secs)) {
Ok(Ok(result)) => Ok(result),
Ok(Err(e)) => Err(Box::new(rhai::EvalAltResult::ErrorRuntime(
format!("LLM call failed: {}", e).into(),
rhai::Position::NONE,
))),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(Box::new(
rhai::EvalAltResult::ErrorRuntime("LLM call timed out".into(), rhai::Position::NONE),
)),
Err(e) => Err(Box::new(rhai::EvalAltResult::ErrorRuntime(
format!("LLM thread failed: {}", e).into(),
rhai::Position::NONE,
))),
}
}
pub fn register_calculate_keyword(state: Arc<AppState>, _user: UserSession, engine: &mut Engine) {
let state_clone = Arc::clone(&state);
engine
.register_custom_syntax(
["CALCULATE", "$expr$", ",", "$expr$"],
false,
move |context, inputs| {
let formula = context.eval_expression_tree(&inputs[0])?.to_string();
let variables = context.eval_expression_tree(&inputs[1])?;
trace!(
"CALCULATE: formula='{}', variables={:?}",
formula,
variables
);
let state_for_task = Arc::clone(&state_clone);
let prompt = build_calculate_prompt(&formula, &variables);
let result = run_llm_with_timeout(state_for_task, prompt, 60)?;
parse_calculate_result(&result)
},
)
.expect("valid syntax registration");
}
fn build_calculate_prompt(formula: &str, variables: &Dynamic) -> String {
let vars_str = if variables.is_map() {
let map = variables.clone().cast::<Map>();
let pairs: Vec<String> = map.iter().map(|(k, v)| format!("{} = {}", k, v)).collect();
pairs.join(", ")
} else if variables.is_unit() {
"none".to_string()
} else {
variables.to_string()
};
format!(
r"You are a precise calculator. Evaluate the following expression.
Formula: {}
Variables: {}
Instructions:
1. Substitute the variables into the formula
2. Perform the calculation
3. Return ONLY the final result (number, boolean, or text)
4. No explanations, just the result
Result:",
formula, vars_str
)
}
fn parse_calculate_result(result: &str) -> Result<Dynamic, Box<rhai::EvalAltResult>> {
let trimmed = result.trim();
if let Ok(i) = trimmed.parse::<i64>() {
return Ok(Dynamic::from(i));
}
if let Ok(f) = trimmed.parse::<f64>() {
return Ok(Dynamic::from(f));
}
match trimmed.to_lowercase().as_str() {
"true" | "yes" => return Ok(Dynamic::from(true)),
"false" | "no" => return Ok(Dynamic::from(false)),
_ => {}
}
if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed) {
return Ok(json_to_dynamic(&json));
}
Ok(Dynamic::from(trimmed.to_string()))
}
pub fn register_validate_keyword(state: Arc<AppState>, _user: UserSession, engine: &mut Engine) {
let state_clone = Arc::clone(&state);
engine
.register_custom_syntax(
["VALIDATE", "$expr$", ",", "$expr$"],
false,
move |context, inputs| {
let data = context.eval_expression_tree(&inputs[0])?;
let rules = context.eval_expression_tree(&inputs[1])?.to_string();
trace!("VALIDATE: data={:?}, rules='{}'", data, rules);
let state_for_task = Arc::clone(&state_clone);
let prompt = build_validate_prompt(&data, &rules);
let result = run_llm_with_timeout(state_for_task, prompt, 60)?;
parse_validate_result(&result)
},
)
.expect("valid syntax registration");
}
fn build_validate_prompt(data: &Dynamic, rules: &str) -> String {
let data_str = if data.is_map() {
let map = data.clone().cast::<Map>();
serde_json::to_string_pretty(&map_to_json(&map)).unwrap_or_else(|_| data.to_string())
} else {
data.to_string()
};
format!(
r#"You are a data validator. Validate the following data against the rules.
Data:
{}
Rules:
{}
Return a JSON object:
{{
"is_valid": true/false,
"errors": ["list of error messages"],
"warnings": ["list of warning messages"]
}}
Return ONLY the JSON:"#,
data_str, rules
)
}
fn parse_validate_result(result: &str) -> Result<Dynamic, Box<rhai::EvalAltResult>> {
let trimmed = result.trim();
let json_str = if trimmed.starts_with("```") {
trimmed
.lines()
.skip(1)
.take_while(|l| !l.starts_with("```"))
.collect::<Vec<_>>()
.join("\n")
} else {
trimmed.to_string()
};
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&json_str) {
let mut map = Map::new();
let is_valid = json["is_valid"].as_bool().unwrap_or(false);
map.insert("is_valid".into(), Dynamic::from(is_valid));
let errors: Array = json["errors"]
.as_array()
.map(|arr| {
arr.iter()
.map(|v| Dynamic::from(v.as_str().unwrap_or("").to_string()))
.collect()
})
.unwrap_or_default();
map.insert("errors".into(), Dynamic::from(errors));
let warnings: Array = json["warnings"]
.as_array()
.map(|arr| {
arr.iter()
.map(|v| Dynamic::from(v.as_str().unwrap_or("").to_string()))
.collect()
})
.unwrap_or_default();
map.insert("warnings".into(), Dynamic::from(warnings));
return Ok(Dynamic::from(map));
}
let is_valid =
trimmed.to_lowercase().contains("valid") && !trimmed.to_lowercase().contains("invalid");
let mut map = Map::new();
map.insert("is_valid".into(), Dynamic::from(is_valid));
map.insert("errors".into(), Dynamic::from(Array::new()));
map.insert("warnings".into(), Dynamic::from(Array::new()));
Ok(Dynamic::from(map))
}
pub fn register_translate_keyword(state: Arc<AppState>, _user: UserSession, engine: &mut Engine) {
let state_clone = Arc::clone(&state);
engine
.register_custom_syntax(
["TRANSLATE", "$expr$", ",", "$expr$"],
false,
move |context, inputs| {
let text = context.eval_expression_tree(&inputs[0])?.to_string();
let language = context.eval_expression_tree(&inputs[1])?.to_string();
trace!(
"TRANSLATE: text length={}, target='{}'",
text.len(),
language
);
let state_for_task = Arc::clone(&state_clone);
let prompt = build_translate_prompt(&text, &language);
run_llm_with_timeout(state_for_task, prompt, 120).map(Dynamic::from)
},
)
.expect("valid syntax registration");
}
fn build_translate_prompt(text: &str, language: &str) -> String {
format!(
r"Translate the following text to {}.
Text:
{}
Return ONLY the translated text, no explanations:
Translation:",
language, text
)
}
pub fn register_summarize_keyword(state: Arc<AppState>, _user: UserSession, engine: &mut Engine) {
let state_clone = Arc::clone(&state);
engine
.register_custom_syntax(["SUMMARIZE", "$expr$"], false, move |context, inputs| {
let text = context.eval_expression_tree(&inputs[0])?.to_string();
trace!("SUMMARIZE: text length={}", text.len());
let state_for_task = Arc::clone(&state_clone);
let prompt = build_summarize_prompt(&text);
run_llm_with_timeout(state_for_task, prompt, 120).map(Dynamic::from)
})
.expect("valid syntax registration");
}
fn build_summarize_prompt(text: &str) -> String {
format!(
r"Summarize the following text concisely.
Text:
{}
Return ONLY the summary:
Summary:",
text
)
}
fn json_to_dynamic(value: &serde_json::Value) -> Dynamic {
match value {
serde_json::Value::Null => Dynamic::UNIT,
serde_json::Value::Bool(b) => Dynamic::from(*b),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Dynamic::from(i)
} else if let Some(f) = n.as_f64() {
Dynamic::from(f)
} else {
Dynamic::from(n.to_string())
}
}
serde_json::Value::String(s) => Dynamic::from(s.clone()),
serde_json::Value::Array(arr) => {
let rhai_arr: Array = arr.iter().map(json_to_dynamic).collect();
Dynamic::from(rhai_arr)
}
serde_json::Value::Object(obj) => {
let mut map = Map::new();
for (k, v) in obj {
map.insert(k.clone().into(), json_to_dynamic(v));
}
Dynamic::from(map)
}
}
}
fn map_to_json(map: &Map) -> serde_json::Value {
let mut obj = serde_json::Map::new();
for (k, v) in map.iter() {
obj.insert(k.to_string(), dynamic_to_json(v));
}
serde_json::Value::Object(obj)
}
fn dynamic_to_json(data: &Dynamic) -> serde_json::Value {
if data.is_unit() {
serde_json::Value::Null
} else if data.is_bool() {
serde_json::Value::Bool(data.as_bool().unwrap_or(false))
} else if data.is_int() {
serde_json::Value::Number(serde_json::Number::from(data.as_int().unwrap_or(0)))
} else if data.is_float() {
if let Some(n) = serde_json::Number::from_f64(data.as_float().unwrap_or(0.0)) {
serde_json::Value::Number(n)
} else {
serde_json::Value::Null
}
} else if data.is_string() {
serde_json::Value::String(data.to_string())
} else if data.is_array() {
let arr = data.clone().cast::<Array>();
serde_json::Value::Array(arr.iter().map(dynamic_to_json).collect())
} else if data.is_map() {
let map = data.clone().cast::<Map>();
map_to_json(&map)
} else {
serde_json::Value::String(data.to_string())
}
}