Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cortex-app-server/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,9 @@ pub struct RateLimitConfig {
/// Rate limit by IP address.
#[serde(default = "default_true")]
pub by_ip: bool,
/// Trust proxy headers (X-Real-IP, X-Forwarded-For) for client IP detection.
#[serde(default)]
pub trust_proxy: bool,
/// Rate limit by API key.
#[serde(default)]
pub by_api_key: bool,
Expand All @@ -283,6 +286,7 @@ impl Default for RateLimitConfig {
requests_per_minute: default_rpm(),
burst_size: default_burst(),
by_ip: true,
trust_proxy: false,
by_api_key: false,
by_user: false,
exempt_paths: vec!["/health".to_string()],
Expand Down
24 changes: 18 additions & 6 deletions cortex-cli/src/agent_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1027,13 +1027,25 @@ async fn run_list(args: ListArgs) -> Result<()> {
async fn run_show(args: ShowArgs) -> Result<()> {
let agents = load_all_agents()?;

let agent = agents
.iter()
.find(|a| a.name == args.name)
.ok_or_else(|| anyhow::anyhow!("Agent '{}' not found", args.name))?;
let agent = match agents.iter().find(|a| a.name == args.name) {
Some(a) => a,
None => {
// Return JSON error if --json flag is used
if args.json {
let error_json = serde_json::json!({
"error": format!("Agent '{}' not found", args.name),
"available_agents": agents.iter().map(|a| &a.name).collect::<Vec<_>>()
});
println!("{}", serde_json::to_string_pretty(&error_json)?);
// Return Ok to prevent additional error output
return Ok(());
}
bail!("Agent '{}' not found", args.name);
}
};

// Warn if the agent is hidden
if agent.hidden {
// Warn if the agent is hidden (only in non-JSON mode)
if agent.hidden && !args.json {
eprintln!(
"Note: '{}' is a hidden agent (not shown in default listings).",
agent.name
Expand Down
109 changes: 105 additions & 4 deletions cortex-cli/src/debug_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,7 @@ fn generate_unified_diff(old_content: &str, new_content: &str) -> String {
#[derive(Debug, Parser)]
pub struct FileArgs {
/// Path to the file to inspect.
pub path: PathBuf,
pub path: Option<PathBuf>,

/// Output as JSON.
#[arg(long)]
Expand Down Expand Up @@ -456,13 +456,114 @@ struct FileMetadata {
readonly: bool,
}

/// Check if a path is within a safe directory (working directory or user home).
/// Returns an error message if the path is outside allowed directories.
fn validate_path_security(path: &std::path::Path) -> Result<()> {
let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let path_str = path.to_string_lossy();

// Block access to /proc filesystem - can leak environment variables and secrets
if path_str.starts_with("/proc") {
bail!(
"Access denied: /proc filesystem is blocked for security reasons.\n\
The /proc filesystem can expose sensitive runtime information like environment \
variables, command line arguments, and process details."
);
}

// Block access to /sys filesystem
if path_str.starts_with("/sys") {
bail!(
"Access denied: /sys filesystem is blocked for security reasons.\n\
The /sys filesystem can expose sensitive hardware and kernel information."
);
}

// Block access to sensitive system files
let sensitive_paths = [
"/etc/shadow",
"/etc/gshadow",
"/etc/sudoers",
"/etc/ssh/ssh_host",
"/root/.ssh",
"/root/.gnupg",
"/root/.bash_history",
"/root/.zsh_history",
"/var/lib/postgresql/.pgpass",
];

for sensitive in sensitive_paths {
if path_str.starts_with(sensitive) {
bail!(
"Access denied: '{}' is a sensitive system file.\n\
For security reasons, debug file cannot access sensitive system paths.",
path.display()
);
}
}

// Get current working directory and user home directory
let cwd = std::env::current_dir().ok();
let home = dirs::home_dir();

// Check if path is within allowed directories
let is_in_cwd = cwd.as_ref().map(|c| path.starts_with(c)).unwrap_or(false);
let is_in_home = home.as_ref().map(|h| path.starts_with(h)).unwrap_or(false);

// Allow relative paths that stay within cwd
if !is_in_cwd && !is_in_home && path.is_absolute() {
// Check if it's a system path that should be blocked
let is_system_path = path_str.starts_with("/etc/")
|| path_str.starts_with("/var/")
|| path_str.starts_with("/root/")
|| path_str.starts_with("/home/") && !is_in_home;

if is_system_path {
bail!(
"Access denied: '{}' is outside the working directory and user home.\n\
For security reasons, debug file can only access files within:\n\
- Current working directory: {}\n\
- User home directory: {}",
path.display(),
cwd.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "(unknown)".to_string()),
home.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "(unknown)".to_string())
);
}
}

Ok(())
}

async fn run_file(args: FileArgs) -> Result<()> {
let path = if args.path.is_absolute() {
args.path.clone()
// Handle missing path argument with JSON-aware error
let provided_path = match args.path {
Some(p) => p,
None => {
if args.json {
let error_json = serde_json::json!({
"error": "Missing required argument: <PATH>",
"usage": "cortex debug file <PATH> [--json]"
});
println!("{}", serde_json::to_string_pretty(&error_json)?);
return Ok(());
}
bail!("Missing required argument: <PATH>\n\nUsage: cortex debug file <PATH> [--json]");
}
};

let path = if provided_path.is_absolute() {
provided_path.clone()
} else {
std::env::current_dir()?.join(&args.path)
std::env::current_dir()?.join(&provided_path)
};

// Security check: validate path is within allowed directories
validate_path_security(&path)?;

let exists = path.exists();

// Detect special file types using stat() BEFORE attempting any reads
Expand Down
2 changes: 1 addition & 1 deletion cortex-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ fn cleanup_temp_files(temp_dir: &std::path::Path) {
/// Install a panic hook that tracks panics in background threads.
/// This ensures the main thread can detect background panics and exit
/// with an appropriate error code (#2805).
fn install_panic_hook() {
pub fn install_panic_hook() {
// Only install once
if PANIC_HOOK_INSTALLED.swap(true, Ordering::SeqCst) {
return;
Expand Down
21 changes: 21 additions & 0 deletions cortex-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,11 @@ struct ServeCommand {
#[arg(short, long, default_value = "3000")]
port: u16,

/// Model to use for AI requests (e.g., gpt-4o, claude-sonnet-4-20250514).
/// Overrides the default model from config.
#[arg(short, long)]
model: Option<String>,

/// Host address to bind the server to.
/// Examples:
/// 127.0.0.1 - Local only (default, most secure)
Expand Down Expand Up @@ -2072,10 +2077,21 @@ async fn run_serve(serve_cli: ServeCommand) -> Result<()> {
vec![]
};

// Configure provider settings with model override if specified
let providers = if let Some(ref model) = serve_cli.model {
cortex_app_server::config::ProviderConfig {
default_model: model.clone(),
..Default::default()
}
} else {
cortex_app_server::config::ProviderConfig::default()
};

let config = cortex_app_server::ServerConfig {
listen_addr: format!("{}:{}", serve_cli.host, serve_cli.port),
auth: auth_config,
cors_origins,
providers,
..Default::default()
};

Expand All @@ -2090,6 +2106,11 @@ async fn run_serve(serve_cli: ServeCommand) -> Result<()> {
serve_cli.host, serve_cli.port, auth_status
));

// Print model information if specified
if let Some(ref model) = serve_cli.model {
println!("Model: {}", model);
}

if serve_cli.cors || !serve_cli.cors_origins.is_empty() {
if serve_cli.cors_origins.is_empty() {
println!("CORS: Allowing all origins (*)");
Expand Down
54 changes: 54 additions & 0 deletions cortex-cli/src/mcp_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,18 @@ fn validate_env_var_value(value: &str) -> Result<()> {
Ok(())
}

/// Dangerous shell patterns that could cause resource exhaustion
const DANGEROUS_SHELL_PATTERNS: &[&str] = &[
"while true",
"while :",
"while 1",
"for ((",
":(){", // Fork bomb pattern
"yes |",
"cat /dev/zero",
"cat /dev/urandom",
];

/// Validates command arguments for stdio transport.
fn validate_command_args(args: &[String]) -> Result<()> {
if args.is_empty() {
Expand All @@ -211,6 +223,11 @@ fn validate_command_args(args: &[String]) -> Result<()> {
MAX_COMMAND_ARGS
);
}

// Concatenate all args to check for dangerous patterns
let full_command = args.join(" ");
let command_lower = full_command.to_lowercase();

for (i, arg) in args.iter().enumerate() {
if arg.len() > MAX_COMMAND_ARG_LENGTH {
bail!(
Expand All @@ -224,6 +241,43 @@ fn validate_command_args(args: &[String]) -> Result<()> {
bail!("Command argument {} contains null bytes", i + 1);
}
}

// Check for dangerous shell patterns that could cause resource exhaustion
for pattern in DANGEROUS_SHELL_PATTERNS {
if command_lower.contains(pattern) {
bail!(
"Command contains potentially dangerous pattern '{}' which could cause resource exhaustion.\n\
Infinite loops and resource-intensive patterns are not allowed in MCP server commands.\n\
If this is intentional, consider wrapping the command in a script with proper resource limits.",
pattern
);
}
}

// Warn about bash -c with complex commands (common vector for dangerous commands)
if (args.first().map(|s| s.as_str()) == Some("bash")
|| args.first().map(|s| s.as_str()) == Some("sh"))
&& args.iter().any(|a| a == "-c")
{
// Check the -c argument for infinite loops
if let Some(c_idx) = args.iter().position(|a| a == "-c") {
if let Some(script) = args.get(c_idx + 1) {
let script_lower = script.to_lowercase();
if script_lower.contains("while")
&& (script_lower.contains("true")
|| script_lower.contains(":")
|| script_lower.contains("1;"))
{
bail!(
"Shell command appears to contain an infinite loop.\n\
Commands that run indefinitely are not suitable for MCP servers.\n\
MCP server processes should respond to requests and exit gracefully."
);
}
}
}
}

Ok(())
}

Expand Down
8 changes: 4 additions & 4 deletions cortex-cli/src/run_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,8 @@ pub struct RunCli {

/// Output format alias (same as --format).
/// Valid values: default, json, jsonl.
#[arg(long = "output", value_enum, conflicts_with = "format")]
pub output: Option<OutputFormat>,
#[arg(long = "output-format", value_enum, conflicts_with = "format")]
pub output_format_alias: Option<OutputFormat>,

/// File(s) to attach to the message.
/// Can be specified multiple times.
Expand Down Expand Up @@ -675,8 +675,8 @@ impl RunCli {
return self.run_dry_run(message, attachments).await;
}

// Use --output if provided, otherwise use --format
let effective_format = self.output.unwrap_or(self.format);
// Use --output-format if provided, otherwise use --format
let effective_format = self.output_format_alias.unwrap_or(self.format);
let is_json = matches!(effective_format, OutputFormat::Json | OutputFormat::Jsonl);
let is_terminal = io::stdout().is_terminal();
let streaming_enabled = self.is_streaming_enabled();
Expand Down
Loading