diff --git a/Cargo.lock b/Cargo.lock index 53e68fc..343af88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -549,7 +549,7 @@ checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" [[package]] name = "clx" -version = "0.1.0" +version = "0.2.1" dependencies = [ "anyhow", "assert_cmd", @@ -578,7 +578,7 @@ dependencies = [ [[package]] name = "clx-core" -version = "0.1.0" +version = "0.2.1" dependencies = [ "anyhow", "chrono", @@ -603,7 +603,7 @@ dependencies = [ [[package]] name = "clx-hook" -version = "0.1.0" +version = "0.2.1" dependencies = [ "anyhow", "chrono", @@ -622,7 +622,7 @@ dependencies = [ [[package]] name = "clx-mcp" -version = "0.1.0" +version = "0.2.1" dependencies = [ "anyhow", "chrono", diff --git a/crates/clx-core/src/config.rs b/crates/clx-core/src/config.rs index 2567ebe..7eb3ee5 100644 --- a/crates/clx-core/src/config.rs +++ b/crates/clx-core/src/config.rs @@ -13,6 +13,7 @@ //! - `CLX_VALIDATOR_CACHE_ENABLED` (enable `SQLite` decision cache) //! - `CLX_VALIDATOR_CACHE_ALLOW_TTL` (TTL for cached allow decisions, seconds) //! - `CLX_VALIDATOR_CACHE_ASK_TTL` (TTL for cached ask decisions, seconds) +//! - `CLX_VALIDATOR_PROMPT_SENSITIVITY` (high/standard/low/custom) //! - `CLX_CONTEXT_ENABLED` //! - `CLX_CONTEXT_AUTO_SNAPSHOT` //! - `CLX_CONTEXT_EMBEDDING_MODEL` @@ -98,6 +99,52 @@ impl PartialEq<&str> for ContextPressureMode { } } +/// Validator prompt sensitivity level. +/// +/// Controls which built-in prompt template is used when no custom prompt +/// file is found. The sensitivity changes the **prompt content** (how +/// suspicious the LLM is told to be), not the score thresholds. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum PromptSensitivity { + /// Strict: treats ambiguous commands as suspicious, flags network access + High, + /// Balanced: current default behaviour + #[default] + Standard, + /// Relaxed: trusts common dev tools, fewer interruptions + Low, + /// User-edited prompt in ~/.clx/prompts/validator.txt + Custom, +} + +impl fmt::Display for PromptSensitivity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::High => write!(f, "high"), + Self::Standard => write!(f, "standard"), + Self::Low => write!(f, "low"), + Self::Custom => write!(f, "custom"), + } + } +} + +impl FromStr for PromptSensitivity { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "high" => Ok(Self::High), + "standard" => Ok(Self::Standard), + "low" => Ok(Self::Low), + "custom" => Ok(Self::Custom), + _ => Err(format!( + "Invalid prompt sensitivity: '{s}'. Expected: high, standard, low, custom" + )), + } + } +} + /// Default decision for policy evaluation. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "lowercase")] @@ -286,6 +333,10 @@ pub struct ValidatorConfig { /// TTL for cached "ask" decisions in seconds (default: 15 minutes) #[serde(default = "default_cache_ask_ttl")] pub cache_ask_ttl_secs: u64, + + /// Prompt sensitivity level for LLM-based validation + #[serde(default)] + pub prompt_sensitivity: PromptSensitivity, } /// Context configuration @@ -579,6 +630,7 @@ impl Default for ValidatorConfig { cache_enabled: default_true(), cache_allow_ttl_secs: default_cache_allow_ttl(), cache_ask_ttl_secs: default_cache_ask_ttl(), + prompt_sensitivity: PromptSensitivity::Standard, } } } @@ -802,6 +854,13 @@ impl Config { &mut self.validator.cache_ask_ttl_secs, ); } + if let Ok(val) = env::var("CLX_VALIDATOR_PROMPT_SENSITIVITY") { + apply_enum_override::( + &val, + "CLX_VALIDATOR_PROMPT_SENSITIVITY", + &mut self.validator.prompt_sensitivity, + ); + } // Context overrides if let Ok(val) = env::var("CLX_CONTEXT_ENABLED") { @@ -2209,6 +2268,124 @@ validator: assert_eq!(config.ollama.host, "http://127.0.0.1:11434"); } + // --- PromptSensitivity tests --- + + #[test] + fn test_prompt_sensitivity_default_is_standard() { + let config = Config::default(); + assert_eq!( + config.validator.prompt_sensitivity, + PromptSensitivity::Standard + ); + } + + #[test] + fn test_prompt_sensitivity_yaml_parsing() { + for (yaml_val, expected) in [ + ("high", PromptSensitivity::High), + ("standard", PromptSensitivity::Standard), + ("low", PromptSensitivity::Low), + ("custom", PromptSensitivity::Custom), + ] { + let yaml = format!("validator:\n prompt_sensitivity: \"{yaml_val}\"\n"); + let config: Config = serde_yml::from_str(&yaml).unwrap(); + assert_eq!( + config.validator.prompt_sensitivity, expected, + "Failed for yaml value: {yaml_val}" + ); + } + } + + #[test] + fn test_prompt_sensitivity_missing_uses_default() { + let yaml = "validator:\n enabled: true\n"; + let config: Config = serde_yml::from_str(yaml).unwrap(); + assert_eq!( + config.validator.prompt_sensitivity, + PromptSensitivity::Standard + ); + } + + #[test] + fn test_prompt_sensitivity_from_str() { + assert_eq!( + "high".parse::().unwrap(), + PromptSensitivity::High + ); + assert_eq!( + "standard".parse::().unwrap(), + PromptSensitivity::Standard + ); + assert_eq!( + "low".parse::().unwrap(), + PromptSensitivity::Low + ); + assert_eq!( + "custom".parse::().unwrap(), + PromptSensitivity::Custom + ); + assert_eq!( + "HIGH".parse::().unwrap(), + PromptSensitivity::High + ); + assert!("invalid".parse::().is_err()); + } + + #[test] + fn test_prompt_sensitivity_display() { + assert_eq!(PromptSensitivity::High.to_string(), "high"); + assert_eq!(PromptSensitivity::Standard.to_string(), "standard"); + assert_eq!(PromptSensitivity::Low.to_string(), "low"); + assert_eq!(PromptSensitivity::Custom.to_string(), "custom"); + } + + #[test] + fn test_prompt_sensitivity_serialization_roundtrip() { + let config = Config::default(); + let yaml = serde_yml::to_string(&config).unwrap(); + let parsed: Config = serde_yml::from_str(&yaml).unwrap(); + assert_eq!( + config.validator.prompt_sensitivity, + parsed.validator.prompt_sensitivity + ); + } + + #[test] + #[serial_test::serial] + #[allow(unsafe_code)] + fn test_prompt_sensitivity_env_override() { + let _guard = EnvGuard::new(&["CLX_VALIDATOR_PROMPT_SENSITIVITY"]); + + // SAFETY: Serialized via #[serial_test::serial], no concurrent mutation. + unsafe { + env::set_var("CLX_VALIDATOR_PROMPT_SENSITIVITY", "high"); + } + + let mut config = Config::default(); + config.apply_env_overrides(); + assert_eq!(config.validator.prompt_sensitivity, PromptSensitivity::High); + } + + #[test] + #[serial_test::serial] + #[allow(unsafe_code)] + fn test_prompt_sensitivity_env_invalid_keeps_default() { + let _guard = EnvGuard::new(&["CLX_VALIDATOR_PROMPT_SENSITIVITY"]); + + // SAFETY: Serialized via #[serial_test::serial], no concurrent mutation. + unsafe { + env::set_var("CLX_VALIDATOR_PROMPT_SENSITIVITY", "extreme"); + } + + let mut config = Config::default(); + config.apply_env_overrides(); + assert_eq!( + config.validator.prompt_sensitivity, + PromptSensitivity::Standard, + "Invalid env value should keep default" + ); + } + // ---- T35: Property tests for config safety ---- mod prop_tests { diff --git a/crates/clx-core/src/policy/llm.rs b/crates/clx-core/src/policy/llm.rs index 8e07c98..fa4aed8 100644 --- a/crates/clx-core/src/policy/llm.rs +++ b/crates/clx-core/src/policy/llm.rs @@ -4,12 +4,15 @@ //! (deterministic rules) returns Ask. use std::fs; +use std::path::Path; use tracing::{debug, warn}; +use crate::config::PromptSensitivity; use crate::ollama::OllamaClient; use super::PolicyEngine; use super::cache::{ValidationCache, compute_cache_key}; +use super::prompts::{PROMPT_HIGH, PROMPT_LOW, PROMPT_STANDARD}; use super::types::{LlmValidationResponse, PolicyDecision}; /// Default prompt template for LLM-based command validation @@ -74,6 +77,7 @@ impl PolicyEngine { working_dir: &str, ollama: &OllamaClient, cache: Option<&ValidationCache>, + sensitivity: &PromptSensitivity, ) -> PolicyDecision { // Check rate limit first if !self.rate_limiter.check() { @@ -92,8 +96,8 @@ impl PolicyEngine { return cached_decision; } - // Load prompt template - let prompt_template = load_validator_prompt(); + // Load prompt template (3-tier: per-project > global > built-in) + let prompt_template = load_validator_prompt(working_dir, sensitivity); // JSON-encode both command and working_dir to prevent prompt injection let escaped_command = serde_json::to_string(command).unwrap_or_else(|_| { @@ -377,32 +381,88 @@ pub(crate) fn validate_prompt_template(content: &str) -> Result<(), String> { Ok(()) } -/// Load the validator prompt template from ~/.clx/prompts/validator.txt or use default -pub(crate) fn load_validator_prompt() -> String { - let prompt_path = crate::paths::validator_prompt_path(); - if prompt_path.exists() { - if !is_file_safe(&prompt_path) { - warn!( - "Validator prompt file {} has unsafe permissions (world-writable), using default prompt", - prompt_path.display() - ); - return DEFAULT_VALIDATOR_PROMPT.to_string(); - } - if let Ok(content) = fs::read_to_string(&prompt_path) { +/// Load the validator prompt template using a 3-tier lookup: +/// +/// 1. **Per-project**: `/.clx/prompts/validator.txt` +/// 2. **Global**: `~/.clx/prompts/validator.txt` +/// 3. **Built-in preset**: based on the configured `PromptSensitivity` +/// +/// Each file-based prompt must pass `validate_prompt_template()` and +/// `is_file_safe()` checks. If a file fails validation, we fall through +/// to the next tier. +pub(crate) fn load_validator_prompt(cwd: &str, sensitivity: &PromptSensitivity) -> String { + // 1. Try per-project prompt + let project_prompt = Path::new(cwd).join(".clx/prompts/validator.txt"); + if let Some(content) = try_load_prompt_file(&project_prompt) { + debug!( + "Loaded per-project validator prompt from {}", + project_prompt.display() + ); + return content; + } + + // 2. Try global prompt + let global_prompt = crate::paths::validator_prompt_path(); + if let Some(content) = try_load_prompt_file(&global_prompt) { + debug!( + "Loaded global validator prompt from {}", + global_prompt.display() + ); + return content; + } + + // 3. Built-in fallback based on sensitivity + let preset = sensitivity_to_prompt(sensitivity); + debug!( + "Using built-in {} sensitivity validator prompt", + sensitivity + ); + preset.to_string() +} + +/// Attempt to load and validate a prompt file. Returns `None` if the file +/// does not exist, has unsafe permissions, cannot be read, or fails validation. +fn try_load_prompt_file(path: &Path) -> Option { + if !path.exists() { + return None; + } + if !is_file_safe(path) { + warn!( + "Validator prompt file {} has unsafe permissions (world-writable), skipping", + path.display() + ); + return None; + } + match fs::read_to_string(path) { + Ok(content) => { if let Err(reason) = validate_prompt_template(&content) { warn!( - "Validator prompt file {} failed validation: {}, using default prompt", - prompt_path.display(), + "Validator prompt file {} failed validation: {}, skipping", + path.display(), reason ); - return DEFAULT_VALIDATOR_PROMPT.to_string(); + return None; } - debug!("Loaded validator prompt from {}", prompt_path.display()); - return content; + Some(content) + } + Err(e) => { + warn!( + "Failed to read validator prompt file {}: {}, skipping", + path.display(), + e + ); + None } } - debug!("Using default validator prompt"); - DEFAULT_VALIDATOR_PROMPT.to_string() +} + +/// Map a `PromptSensitivity` variant to its corresponding built-in prompt. +fn sensitivity_to_prompt(sensitivity: &PromptSensitivity) -> &'static str { + match sensitivity { + PromptSensitivity::High => PROMPT_HIGH, + PromptSensitivity::Standard | PromptSensitivity::Custom => PROMPT_STANDARD, + PromptSensitivity::Low => PROMPT_LOW, + } } /// Check if a file has safe permissions (not world-writable). @@ -500,3 +560,128 @@ pub(crate) fn risk_score_to_decision( }, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sensitivity_to_prompt_high() { + let prompt = sensitivity_to_prompt(&PromptSensitivity::High); + assert!(prompt.contains("HIGH SENSITIVITY")); + assert!(prompt.contains("STRICT")); + } + + #[test] + fn test_sensitivity_to_prompt_standard() { + let prompt = sensitivity_to_prompt(&PromptSensitivity::Standard); + assert!(!prompt.contains("HIGH SENSITIVITY")); + assert!(!prompt.contains("LOW SENSITIVITY")); + } + + #[test] + fn test_sensitivity_to_prompt_low() { + let prompt = sensitivity_to_prompt(&PromptSensitivity::Low); + assert!(prompt.contains("LOW SENSITIVITY")); + assert!(prompt.contains("Trust standard dev tools")); + } + + #[test] + fn test_sensitivity_to_prompt_custom_falls_back_to_standard() { + let standard = sensitivity_to_prompt(&PromptSensitivity::Standard); + let custom = sensitivity_to_prompt(&PromptSensitivity::Custom); + assert_eq!(standard, custom); + } + + #[test] + fn test_load_validator_prompt_nonexistent_cwd_skips_per_project() { + // When cwd doesn't exist, per-project file won't exist. + // Result is either global prompt or built-in preset. + let prompt = load_validator_prompt("/nonexistent/path", &PromptSensitivity::High); + // Must be a valid prompt regardless of which tier it came from + assert!(validate_prompt_template(&prompt).is_ok()); + assert!(prompt.contains("{{command}}")); + assert!(prompt.contains("{{working_dir}}")); + } + + #[test] + fn test_load_validator_prompt_per_project_takes_precedence() { + let tmp = tempfile::tempdir().unwrap(); + let project_dir = tmp.path(); + let prompt_dir = project_dir.join(".clx/prompts"); + std::fs::create_dir_all(&prompt_dir).unwrap(); + + // Write a valid custom prompt + let custom_prompt = "You are a validator.\n\nCommand: {{command}}\nDir: {{working_dir}}\n\nRespond in JSON only.\n"; + std::fs::write(prompt_dir.join("validator.txt"), custom_prompt).unwrap(); + + let loaded = + load_validator_prompt(project_dir.to_str().unwrap(), &PromptSensitivity::Standard); + assert_eq!(loaded, custom_prompt); + } + + #[test] + fn test_load_validator_prompt_invalid_per_project_falls_through() { + let tmp = tempfile::tempdir().unwrap(); + let project_dir = tmp.path(); + let prompt_dir = project_dir.join(".clx/prompts"); + std::fs::create_dir_all(&prompt_dir).unwrap(); + + // Write an invalid prompt (missing placeholders) + std::fs::write(prompt_dir.join("validator.txt"), "bad prompt").unwrap(); + + // Should fall through past the invalid per-project prompt. + // The result will be either the global prompt (if ~/.clx/prompts/validator.txt + // exists on this machine) or the built-in preset. Either way, it must NOT + // be the invalid "bad prompt" content. + let loaded = load_validator_prompt(project_dir.to_str().unwrap(), &PromptSensitivity::Low); + assert_ne!( + loaded, "bad prompt", + "Invalid per-project prompt must be skipped" + ); + // Must still be a valid prompt + assert!(validate_prompt_template(&loaded).is_ok()); + } + + #[test] + fn test_load_validator_prompt_each_sensitivity_loads_distinct_prompt() { + // Use nonexistent cwd so per-project file is skipped. If the global + // prompt exists on this machine it will be used for all sensitivities + // (same file), so skip this test in that case. + let global_prompt = crate::paths::validator_prompt_path(); + if global_prompt.exists() { + // Can't distinguish sensitivities when a global file overrides all + return; + } + + let high = load_validator_prompt("/nonexistent", &PromptSensitivity::High); + let standard = load_validator_prompt("/nonexistent", &PromptSensitivity::Standard); + let low = load_validator_prompt("/nonexistent", &PromptSensitivity::Low); + + assert_ne!(high, standard); + assert_ne!(standard, low); + assert_ne!(high, low); + } + + #[test] + fn test_try_load_prompt_file_nonexistent() { + assert!(try_load_prompt_file(Path::new("/nonexistent/prompt.txt")).is_none()); + } + + #[test] + fn test_try_load_prompt_file_invalid_content() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("bad.txt"); + std::fs::write(&path, "no placeholders here").unwrap(); + assert!(try_load_prompt_file(&path).is_none()); + } + + #[test] + fn test_try_load_prompt_file_valid_content() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("good.txt"); + let content = "Validate {{command}} in {{working_dir}}. Respond in JSON.\n"; + std::fs::write(&path, content).unwrap(); + assert_eq!(try_load_prompt_file(&path).unwrap(), content); + } +} diff --git a/crates/clx-core/src/policy/mod.rs b/crates/clx-core/src/policy/mod.rs index 912dec8..5bbf26d 100644 --- a/crates/clx-core/src/policy/mod.rs +++ b/crates/clx-core/src/policy/mod.rs @@ -22,6 +22,7 @@ mod file_util; mod llm; pub mod matching; pub mod mcp; +pub mod prompts; mod rate_limiter; pub mod read_only; mod rules; @@ -35,6 +36,7 @@ pub use file_util::ensure_default_rules_file; pub use llm::DEFAULT_VALIDATOR_PROMPT; pub use matching::glob_match; pub use mcp::{McpExtraction, extract_mcp_command}; +pub use prompts::{PROMPT_HIGH, PROMPT_LOW, PROMPT_STANDARD}; pub use read_only::is_read_only_command; pub use types::*; diff --git a/crates/clx-core/src/policy/prompts.rs b/crates/clx-core/src/policy/prompts.rs new file mode 100644 index 0000000..da94576 --- /dev/null +++ b/crates/clx-core/src/policy/prompts.rs @@ -0,0 +1,86 @@ +//! Built-in validator prompt templates for different sensitivity levels. +//! +//! Each template is embedded at compile time via `include_str!` and contains +//! the `{{command}}` and `{{working_dir}}` placeholders required by the +//! prompt validation logic. + +/// Standard sensitivity prompt (balanced, current default behavior). +pub const PROMPT_STANDARD: &str = include_str!("prompts/validator-standard.txt"); + +/// High sensitivity prompt (stricter, more suspicious scoring). +pub const PROMPT_HIGH: &str = include_str!("prompts/validator-high.txt"); + +/// Low sensitivity prompt (relaxed, trusts common dev tools). +pub const PROMPT_LOW: &str = include_str!("prompts/validator-low.txt"); + +#[cfg(test)] +mod tests { + use super::*; + use crate::policy::llm::validate_prompt_template; + + #[test] + fn test_standard_prompt_is_valid() { + assert!( + validate_prompt_template(PROMPT_STANDARD).is_ok(), + "Standard prompt must pass validation" + ); + } + + #[test] + fn test_high_prompt_is_valid() { + assert!( + validate_prompt_template(PROMPT_HIGH).is_ok(), + "High prompt must pass validation" + ); + } + + #[test] + fn test_low_prompt_is_valid() { + assert!( + validate_prompt_template(PROMPT_LOW).is_ok(), + "Low prompt must pass validation" + ); + } + + #[test] + fn test_all_prompts_contain_required_placeholders() { + for (name, prompt) in [ + ("standard", PROMPT_STANDARD), + ("high", PROMPT_HIGH), + ("low", PROMPT_LOW), + ] { + assert!( + prompt.contains("{{command}}"), + "{name} prompt missing {{{{command}}}} placeholder" + ); + assert!( + prompt.contains("{{working_dir}}"), + "{name} prompt missing {{{{working_dir}}}} placeholder" + ); + } + } + + #[test] + fn test_all_prompts_contain_json_keyword() { + for (name, prompt) in [ + ("standard", PROMPT_STANDARD), + ("high", PROMPT_HIGH), + ("low", PROMPT_LOW), + ] { + assert!( + prompt.to_lowercase().contains("json"), + "{name} prompt missing JSON keyword" + ); + } + } + + #[test] + fn test_prompts_are_distinct() { + assert_ne!( + PROMPT_STANDARD, PROMPT_HIGH, + "Standard and High must differ" + ); + assert_ne!(PROMPT_STANDARD, PROMPT_LOW, "Standard and Low must differ"); + assert_ne!(PROMPT_HIGH, PROMPT_LOW, "High and Low must differ"); + } +} diff --git a/crates/clx-core/src/policy/prompts/validator-high.txt b/crates/clx-core/src/policy/prompts/validator-high.txt new file mode 100644 index 0000000..ba6f106 --- /dev/null +++ b/crates/clx-core/src/policy/prompts/validator-high.txt @@ -0,0 +1,40 @@ +You are a STRICT command safety validator. Evaluate WHAT a command DOES, not WHO runs it. When in doubt, score higher. + +Working directory: {{working_dir}} +Command: {{command}} + +Respond in JSON only: +{"risk_score": 1-10, "reasoning": "brief explanation", "category": "safe|caution|dangerous|critical"} + +Scoring: +- 1-3 (safe): Read-only, informational, no side effects +- 4-7 (caution): Modifies state but reversible, or unclear intent +- 8-10 (dangerous): Destructive, irreversible, or data loss risk + +CRITICAL: Evaluate the ACTION, ignore access level. +- User (root, admin, etc.) is IRRELEVANT. The developer already has authorized access. +- "ssh root@host read-command" is SAFE. The risk is in WHAT runs, not WHERE or AS WHOM. + +SSH commands: evaluate the REMOTE command only. +- Read-only remote (list, info, show, status, get, describe, view, check, version, log, cat, ls, grep, tail, df, free, uptime, ps) = safe (1-3) +- Destructive remote (rm, kill, stop, restart, reboot, dd, mkfs, drop, delete, truncate) = dangerous (8-10) +- No remote command (interactive session) = caution (5) + +Command name heuristics: if a command name contains "list", "info", "show", "status", "get", "check", "view", "version", "describe", "search" — it is almost certainly read-only (1-2). + +Security patterns to scan for (score 8-10): +- Supply chain: `curl|bash`, `wget|sh`, custom registry URLs (--index-url, --registry), typosquatted package names +- Credential exposure: API keys in arguments, printing .env files, exporting secrets via env vars +- Container escapes: `docker run --privileged`, `--pid=host`, `--net=host`, mounting host root (`-v /:/`) +- Network exfiltration: curl/wget posting sensitive files, `nc -e` reverse shells, DNS tunneling +- Destructive ops: `rm -rf /`, `dd if=/dev/zero`, `mkfs` on mounted volumes, recursive `chmod 777` + +HIGH SENSITIVITY additional rules: +- Treat ANY network access (curl, wget, ssh, nc, telnet, ftp, scp, rsync) as suspicious (score >= 5) +- Flag environment variable reads/writes (export, env, printenv, set) (score >= 4) +- Flag any file write outside the current project directory (score >= 6) +- Treat package installation as moderate risk (npm install, pip install, cargo install, apt install) (score >= 5) +- Flag any command that accesses dotfiles or config files (~/.*, /etc/*) (score >= 4) +- When in doubt, score higher + +Only evaluate the command itself. Do not consider intent. \ No newline at end of file diff --git a/crates/clx-core/src/policy/prompts/validator-low.txt b/crates/clx-core/src/policy/prompts/validator-low.txt new file mode 100644 index 0000000..3062dbd --- /dev/null +++ b/crates/clx-core/src/policy/prompts/validator-low.txt @@ -0,0 +1,40 @@ +You are a command safety validator. Evaluate WHAT a command DOES, not WHO runs it. When in doubt, score lower. + +Working directory: {{working_dir}} +Command: {{command}} + +Respond in JSON only: +{"risk_score": 1-10, "reasoning": "brief explanation", "category": "safe|caution|dangerous|critical"} + +Scoring: +- 1-3 (safe): Read-only, informational, no side effects +- 4-7 (caution): Modifies state but reversible, or unclear intent +- 8-10 (dangerous): Destructive, irreversible, or data loss risk + +CRITICAL: Evaluate the ACTION, ignore access level. +- User (root, admin, etc.) is IRRELEVANT. The developer already has authorized access. +- "ssh root@host read-command" is SAFE. The risk is in WHAT runs, not WHERE or AS WHOM. + +SSH commands: evaluate the REMOTE command only. +- Read-only remote (list, info, show, status, get, describe, view, check, version, log, cat, ls, grep, tail, df, free, uptime, ps) = safe (1-3) +- Destructive remote (rm, kill, stop, restart, reboot, dd, mkfs, drop, delete, truncate) = dangerous (8-10) +- No remote command (interactive session) = caution (4) + +Command name heuristics: if a command name contains "list", "info", "show", "status", "get", "check", "view", "version", "describe", "search" — it is almost certainly read-only (1-2). + +Security patterns to scan for (score 8-10): +- Supply chain: `curl|bash`, `wget|sh`, custom registry URLs (--index-url, --registry), typosquatted package names +- Credential exposure: API keys in arguments, printing .env files, exporting secrets via env vars +- Container escapes: `docker run --privileged`, `--pid=host`, `--net=host`, mounting host root (`-v /:/`) +- Network exfiltration: curl/wget posting sensitive files, `nc -e` reverse shells, DNS tunneling +- Destructive ops: `rm -rf /`, `dd if=/dev/zero`, `mkfs` on mounted volumes, recursive `chmod 777` + +LOW SENSITIVITY additional rules: +- Trust standard dev tools: npm, yarn, pnpm, cargo, pip, poetry, go, docker, git, make, cmake +- Package install/update with standard registries = safe (1-2) +- Docker commands with standard flags = safe (1-3) +- Git operations = safe (1-2) +- Only flag clearly destructive operations: rm -rf /, dd if=/dev/zero, mkfs on mounted volumes +- When in doubt, score lower + +Only evaluate the command itself. Do not consider intent. \ No newline at end of file diff --git a/crates/clx-core/src/policy/prompts/validator-standard.txt b/crates/clx-core/src/policy/prompts/validator-standard.txt new file mode 100644 index 0000000..01e424c --- /dev/null +++ b/crates/clx-core/src/policy/prompts/validator-standard.txt @@ -0,0 +1,32 @@ +You are a command safety validator. Evaluate WHAT a command DOES, not WHO runs it. + +Working directory: {{working_dir}} +Command: {{command}} + +Respond in JSON only: +{"risk_score": 1-10, "reasoning": "brief explanation", "category": "safe|caution|dangerous|critical"} + +Scoring: +- 1-3 (safe): Read-only, informational, no side effects +- 4-7 (caution): Modifies state but reversible, or unclear intent +- 8-10 (dangerous): Destructive, irreversible, or data loss risk + +CRITICAL: Evaluate the ACTION, ignore access level. +- User (root, admin, etc.) is IRRELEVANT. The developer already has authorized access. +- "ssh root@host read-command" is SAFE. The risk is in WHAT runs, not WHERE or AS WHOM. + +SSH commands: evaluate the REMOTE command only. +- Read-only remote (list, info, show, status, get, describe, view, check, version, log, cat, ls, grep, tail, df, free, uptime, ps) = safe (1-3) +- Destructive remote (rm, kill, stop, restart, reboot, dd, mkfs, drop, delete, truncate) = dangerous (8-10) +- No remote command (interactive session) = caution (4) + +Command name heuristics: if a command name contains "list", "info", "show", "status", "get", "check", "view", "version", "describe", "search" — it is almost certainly read-only (1-2). + +Security patterns to scan for (score 8-10): +- Supply chain: `curl|bash`, `wget|sh`, custom registry URLs (--index-url, --registry), typosquatted package names +- Credential exposure: API keys in arguments, printing .env files, exporting secrets via env vars +- Container escapes: `docker run --privileged`, `--pid=host`, `--net=host`, mounting host root (`-v /:/`) +- Network exfiltration: curl/wget posting sensitive files, `nc -e` reverse shells, DNS tunneling +- Destructive ops: `rm -rf /`, `dd if=/dev/zero`, `mkfs` on mounted volumes, recursive `chmod 777` + +Only evaluate the command itself. Do not consider intent. \ No newline at end of file diff --git a/crates/clx-hook/src/hooks/pre_tool_use.rs b/crates/clx-hook/src/hooks/pre_tool_use.rs index 3a67735..28a4415 100644 --- a/crates/clx-hook/src/hooks/pre_tool_use.rs +++ b/crates/clx-hook/src/hooks/pre_tool_use.rs @@ -337,7 +337,14 @@ pub(crate) async fn handle_pre_tool_use(input: HookInput) -> Result<()> { // In-memory cache is not useful in a short-lived hook process; // SQLite cache (above) handles cross-process caching instead. let l1_decision = policy_engine - .evaluate_with_llm("Bash", command, &input.cwd, &ollama, None) + .evaluate_with_llm( + "Bash", + command, + &input.cwd, + &ollama, + None, + &config.validator.prompt_sensitivity, + ) .await; // Handle LLM generation failure: evaluate_with_llm returns Ask("LLM unavailable") diff --git a/crates/clx/src/commands/install.rs b/crates/clx/src/commands/install.rs index b2e7d22..23c166e 100644 --- a/crates/clx/src/commands/install.rs +++ b/crates/clx/src/commands/install.rs @@ -468,13 +468,29 @@ pub async fn cmd_install(cli: &Cli) -> Result<()> { } } - // Step 4: Write default prompts/validator.txt + // Step 4: Write prompt templates + active validator.txt + let prompts_dir = clx_core::paths::prompts_dir(); + let prompt_templates: &[(&str, &str)] = &[ + ("validator-standard.txt", clx_core::policy::PROMPT_STANDARD), + ("validator-high.txt", clx_core::policy::PROMPT_HIGH), + ("validator-low.txt", clx_core::policy::PROMPT_LOW), + ]; + for &(filename, content) in prompt_templates { + let path = prompts_dir.join(filename); + if !path.exists() { + fs::write(&path, content)?; + if !cli.json { + println!(" {} Created {}", "+".green(), path.display()); + } + installed_items.push(format!("prompts/{filename}")); + } else if !cli.json { + println!(" {} Exists {}", "*".dimmed(), path.display()); + } + } + // Write active validator.txt (standard by default) if not present let validator_prompt_path = clx_core::paths::validator_prompt_path(); if !validator_prompt_path.exists() { - fs::write( - &validator_prompt_path, - clx_core::policy::DEFAULT_VALIDATOR_PROMPT, - )?; + fs::write(&validator_prompt_path, clx_core::policy::PROMPT_STANDARD)?; if !cli.json { println!( " {} Created {}",