diff --git a/src/main.rs b/src/main.rs index f96526c..9d618d3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,7 +25,7 @@ use gating_contract::{ AuditEntry, CategoryStats, ContractRunner, GatingRequest, RedTeamCategory, RedTeamSummary, RegressionBaseline, RegressionHarness, TestCase, TestHarness, Verdict, }; -use policy_oracle::{ActionType, DirectoryScanResult, Oracle, Policy, Proposal}; +use policy_oracle::{ActionType, DirectoryScanResult, Oracle, Policy, Proposal, ScanOptions}; use std::path::{Path, PathBuf}; use uuid::Uuid; @@ -115,7 +115,7 @@ struct Cli { #[arg(long, global = true)] no_color: bool, - /// Custom policy file (Nickel .ncl or JSON) + /// Custom policy file in JSON format (the embedded Nickel file is not loaded at runtime) #[arg(short, long, global = true)] policy_file: Option, @@ -413,23 +413,39 @@ fn main() { tracing_subscriber::fmt::init(); let cli = Cli::parse(); - let oracle = Oracle::with_rsr_defaults(); + let oracle = match cli.policy_file.as_deref() { + Some(path) => match load_policy_oracle(path) { + Ok(oracle) => oracle, + Err(error) => { + eprintln!("Failed to load policy: {error}"); + std::process::exit(3); + } + }, + None => Oracle::with_rsr_defaults(), + }; let exit_code = match cli.command { Commands::Scan { path, format, - include_hidden: _, - depth: _, - include: _, - exclude: _, + include_hidden, + depth, + include, + exclude, } => { + let scan_options = ScanOptions { + include_hidden, + max_depth: (depth != 0).then_some(depth), + include, + exclude, + }; if cli.dry_run { println!("[dry-run] Would scan: {}", path.display()); println!("[dry-run] Format: {:?}", format); + println!("[dry-run] Options: {:?}", scan_options); 0 } else { - scan_directory(&oracle, &path, &format, &cli.verbosity) + scan_directory(&oracle, &path, &format, &cli.verbosity, &scan_options) } } Commands::Check { @@ -550,17 +566,31 @@ fn main() { std::process::exit(exit_code); } +fn load_policy_oracle(path: &Path) -> Result { + let content = std::fs::read_to_string(path).map_err(|error| error.to_string())?; + if path.extension().is_some_and(|extension| extension == "ncl") { + return Err( + "Nickel policy loading is not available in the Rust CLI yet; provide a JSON policy export" + .to_string(), + ); + } + let policy: Policy = serde_json::from_str(&content) + .map_err(|error| format!("invalid JSON policy {}: {error}", path.display()))?; + Ok(Oracle::new(policy)) +} + fn scan_directory( oracle: &Oracle, path: &Path, format: &OutputFormat, verbosity: &Verbosity, + options: &ScanOptions, ) -> i32 { if matches!(verbosity, Verbosity::Verbose | Verbosity::Debug) { eprintln!("Scanning: {}", path.display()); } - match oracle.scan_directory(path) { + match oracle.scan_directory_with_options(path, options) { Ok(result) => { match format { OutputFormat::Json => { diff --git a/src/oracle/src/lib.rs b/src/oracle/src/lib.rs index 2370d6e..ced0ef3 100644 --- a/src/oracle/src/lib.rs +++ b/src/oracle/src/lib.rs @@ -7,6 +7,7 @@ //! before the SLM evaluates spirit violations. #![forbid(unsafe_code)] +use glob::Pattern; use regex::Regex; use serde::{Deserialize, Serialize}; use std::fs; @@ -188,6 +189,25 @@ pub struct DirectoryScanResult { pub concerns: Vec, } +/// Controls which files are visited by [`Oracle::scan_directory_with_options`]. +/// +/// Patterns use glob syntax and are matched against the path relative to the +/// scan root, the complete path, and the file name. An empty `include` list +/// includes every file that is not excluded. For a directory, `Some(1)` scans +/// files immediately inside the root; `None` means unlimited depth. A root +/// file is scanned regardless of the depth value. +#[derive(Debug, Clone, Default)] +pub struct ScanOptions { + /// Include dot-files and dot-directories (generated directories remain skipped). + pub include_hidden: bool, + /// Maximum directory depth, measured from the scan root. + pub max_depth: Option, + /// Glob patterns for files to include. + pub include: Vec, + /// Glob patterns for files or directories to exclude. + pub exclude: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FileViolation { pub file: PathBuf, @@ -212,6 +232,8 @@ pub enum OracleError { IoError(#[from] std::io::Error), #[error("Invalid regex: {0}")] RegexError(#[from] regex::Error), + #[error("Invalid glob pattern: {0}")] + GlobError(String), } // ============ Oracle Implementation ============ @@ -348,51 +370,109 @@ impl Oracle { }) } - /// Scan a directory for policy violations + /// Scan a directory for policy violations using the default scan options. pub fn scan_directory(&self, path: &Path) -> Result { + self.scan_directory_with_options(path, &ScanOptions::default()) + } + + /// Scan files for both path-based and content-based policy violations. + /// + /// The original scanner only inspected extensions. That made directory + /// scans materially weaker than `check` and allowed content-only rules, + /// including hard-coded-secret detection, to be bypassed by placing the + /// content in an otherwise innocuous file. This method deliberately uses + /// the same proposal evaluator as single-file checks. + pub fn scan_directory_with_options( + &self, + path: &Path, + options: &ScanOptions, + ) -> Result { + let include = compile_patterns(&options.include)?; + let exclude = compile_patterns(&options.exclude)?; + let files = collect_files(path, path, options, &include, &exclude, 0)?; + let files_scanned = files.len(); let mut violations = Vec::new(); let mut concerns = Vec::new(); - let mut files_scanned = 0; - for entry in walkdir(path)? { - files_scanned += 1; - let file_path = entry.as_path(); + for file_path in files { + let file = file_path.to_string_lossy().to_string(); - // Check file extension against forbidden languages + // Extension checks are useful even when a file is empty or binary. for lang in &self.policy.languages.forbidden { - if self.file_matches_language(&file_path.to_string_lossy(), lang) { - let is_excepted = self - .check_exception(&[file_path.to_string_lossy().to_string()], &lang.name); - if !is_excepted { - violations.push(FileViolation { - file: file_path.to_path_buf(), + if self.file_matches_language(&file, lang) + && !self.check_exception(std::slice::from_ref(&file), &lang.name) + { + push_unique_violation( + &mut violations, + FileViolation { + file: file_path.clone(), violation: ViolationType::ForbiddenLanguage { language: lang.name.clone(), - file: file_path.to_string_lossy().to_string(), + file: file.clone(), context: "File extension".to_string(), }, - }); - } + }, + ); } } - // Check tier2 languages for lang in &self.policy.languages.tier2 { - if self.file_matches_language(&file_path.to_string_lossy(), lang) { - concerns.push(FileConcern { - file: file_path.to_path_buf(), - concern: ConcernType::Tier2Language { - language: lang.name.clone(), + if self.file_matches_language(&file, lang) + && !self.check_exception(std::slice::from_ref(&file), &lang.name) + { + push_unique_concern( + &mut concerns, + FileConcern { + file: file_path.clone(), + concern: ConcernType::Tier2Language { + language: lang.name.clone(), + }, }, - }); + ); } } + + // Invalid UTF-8 is still scanned by path; content rules apply to + // text files only because the proposal contract is UTF-8 text. + let content = match fs::read_to_string(&file_path) { + Ok(content) => content, + Err(error) if error.kind() == std::io::ErrorKind::InvalidData => continue, + Err(error) => return Err(OracleError::IoError(error)), + }; + + let proposal = Proposal { + id: Uuid::new_v4(), + action_type: ActionType::CreateFile { path: file.clone() }, + content, + files_affected: vec![file.clone()], + llm_confidence: 1.0, + }; + let evaluation = self.check_proposal(&proposal)?; + + for violation in evaluation.violations { + push_unique_violation( + &mut violations, + FileViolation { + file: file_path.clone(), + violation: violation.violation_type, + }, + ); + } + for concern in evaluation.concerns { + push_unique_concern( + &mut concerns, + FileConcern { + file: file_path.clone(), + concern: concern.concern_type, + }, + ); + } } - let verdict = if !violations.is_empty() { - PolicyVerdict::HardViolation(violations[0].violation.clone()) - } else if !concerns.is_empty() { - PolicyVerdict::SoftConcern(concerns[0].concern.clone()) + let verdict = if let Some(first) = violations.first() { + PolicyVerdict::HardViolation(first.violation.clone()) + } else if let Some(first) = concerns.first() { + PolicyVerdict::SoftConcern(first.concern.clone()) } else { PolicyVerdict::Compliant }; @@ -467,31 +547,71 @@ impl Oracle { } } -// Simple directory walker -fn walkdir(path: &Path) -> Result, OracleError> { - let mut files = Vec::new(); +fn compile_patterns(patterns: &[String]) -> Result, OracleError> { + patterns + .iter() + .map(|pattern| { + Pattern::new(pattern).map_err(|error| OracleError::GlobError(error.to_string())) + }) + .collect() +} + +fn collect_files( + root: &Path, + current: &Path, + options: &ScanOptions, + include: &[Pattern], + exclude: &[Pattern], + depth: usize, +) -> Result, OracleError> { + if !current.exists() { + return Ok(Vec::new()); + } - if path.is_file() { - files.push(path.to_path_buf()); - return Ok(files); + if current.is_file() { + return if matches_scan_patterns(root, current, include, exclude) { + Ok(vec![current.to_path_buf()]) + } else { + Ok(Vec::new()) + }; } - if !path.exists() { - return Ok(files); + if options + .max_depth + .is_some_and(|max_depth| depth >= max_depth) + { + return Ok(Vec::new()); } - for entry in fs::read_dir(path)? { - let entry = entry?; - let entry_path = entry.path(); + let mut entries = fs::read_dir(current)? + .map(|entry| entry.map(|entry| entry.path())) + .collect::, _>>()?; + entries.sort(); + let mut files = Vec::new(); + for entry_path in entries { let name = entry_path.file_name().unwrap_or_default().to_string_lossy(); - if name.starts_with('.') || name == "node_modules" || name == "target" || name == "_build" { + let is_hidden = name.starts_with('.'); + let is_generated = + name == "node_modules" || name == "target" || name == "_build" || name == ".git"; + + if is_generated || (is_hidden && !options.include_hidden) { + continue; + } + if matches_any_scan_pattern(root, &entry_path, exclude) { continue; } if entry_path.is_dir() { - files.extend(walkdir(&entry_path)?); - } else { + files.extend(collect_files( + root, + &entry_path, + options, + include, + exclude, + depth + 1, + )?); + } else if matches_scan_patterns(root, &entry_path, include, &[]) { files.push(entry_path); } } @@ -499,6 +619,44 @@ fn walkdir(path: &Path) -> Result, OracleError> { Ok(files) } +fn matches_scan_patterns( + root: &Path, + path: &Path, + include: &[Pattern], + exclude: &[Pattern], +) -> bool { + !matches_any_scan_pattern(root, path, exclude) + && (include.is_empty() || matches_any_scan_pattern(root, path, include)) +} + +fn matches_any_scan_pattern(root: &Path, path: &Path, patterns: &[Pattern]) -> bool { + let relative = path.strip_prefix(root).unwrap_or(path); + let file_name = path.file_name().unwrap_or_default(); + let candidates = [relative, path, Path::new(file_name)]; + patterns.iter().any(|pattern| { + candidates + .iter() + .any(|candidate| pattern.matches_path(candidate)) + }) +} + +fn push_unique_violation(violations: &mut Vec, candidate: FileViolation) { + if !violations.iter().any(|existing| { + existing.file == candidate.file && existing.violation == candidate.violation + }) { + violations.push(candidate); + } +} + +fn push_unique_concern(concerns: &mut Vec, candidate: FileConcern) { + if !concerns + .iter() + .any(|existing| existing.file == candidate.file && existing.concern == candidate.concern) + { + concerns.push(candidate); + } +} + // ============ Default Policy ============ impl Policy { @@ -1062,4 +1220,68 @@ mod tests { let result = oracle.check_proposal(&proposal).unwrap(); assert!(matches!(result.verdict, PolicyVerdict::HardViolation(_))); } + + #[test] + fn test_directory_scan_evaluates_file_content() { + let oracle = oracle(); + let root = std::env::temp_dir().join(format!("conative-scan-{}", Uuid::new_v4())); + fs::create_dir_all(&root).unwrap(); + let file = root.join("notes.txt"); + let content = format!( + r#"password = "{}""#, + ["policy", "-fixture", "-value-123"].concat() + ); + fs::write(&file, content).unwrap(); + + let result = oracle.scan_directory(&root).unwrap(); + + assert_eq!(result.files_scanned, 1); + assert!(matches!(result.verdict, PolicyVerdict::HardViolation(_))); + assert!(result.violations.iter().any(|violation| { + matches!(violation.violation, ViolationType::ForbiddenPattern { .. }) + })); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn test_directory_scan_options_control_hidden_and_depth() { + let oracle = oracle(); + let root = std::env::temp_dir().join(format!("conative-scan-{}", Uuid::new_v4())); + fs::create_dir_all(root.join("nested")).unwrap(); + fs::write(root.join("visible.rs"), "fn main() {}").unwrap(); + fs::write(root.join(".hidden.ts"), "const x: string = 'blocked'").unwrap(); + fs::write(root.join("nested/deep.py"), "import os").unwrap(); + + let default_result = oracle.scan_directory(&root).unwrap(); + assert_eq!(default_result.files_scanned, 2); + + let options = ScanOptions { + include_hidden: true, + max_depth: Some(1), + include: vec!["*.ts".to_string()], + exclude: Vec::new(), + }; + let filtered_result = oracle.scan_directory_with_options(&root, &options).unwrap(); + assert_eq!(filtered_result.files_scanned, 1); + assert!(matches!( + filtered_result.verdict, + PolicyVerdict::HardViolation(_) + )); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn test_directory_scan_rejects_invalid_glob() { + let oracle = oracle(); + let options = ScanOptions { + include: vec!["[".to_string()], + ..ScanOptions::default() + }; + + assert!(matches!( + oracle.scan_directory_with_options(Path::new("."), &options), + Err(OracleError::GlobError(_)) + )); + } } diff --git a/src/slm/src/lib.rs b/src/slm/src/lib.rs index 50f0562..3d76f1b 100644 --- a/src/slm/src/lib.rs +++ b/src/slm/src/lib.rs @@ -26,7 +26,11 @@ pub struct SlmEvaluation { pub should_block: bool, } -/// SLM evaluator (placeholder for future implementation) +/// SLM evaluator configuration. +/// +/// The inference backend is intentionally not bundled yet. Until a model is +/// loaded, evaluation fails closed with [`SlmError::ModelNotLoaded`] instead +/// of returning a false compliant result. pub struct SlmEvaluator { #[allow(dead_code)] model_path: Option, @@ -50,17 +54,18 @@ impl SlmEvaluator { } } - /// Placeholder: In v2, this will run actual SLM inference + /// Evaluate content with the configured local model. + /// + /// The model backend is not implemented in this prototype. Returning an + /// error is deliberate: a missing evaluator must never be interpreted as + /// an affirmative policy decision by a downstream arbiter. pub fn evaluate(&self, _content: &str, _context: &str) -> Result { - // Placeholder implementation - always returns compliant - // Real implementation will use llama.cpp bindings - Ok(SlmEvaluation { - proposal_id: Uuid::new_v4(), - spirit_score: 0.0, - confidence: 0.0, - reasoning: "SLM evaluation not yet implemented".to_string(), - should_block: false, - }) + match self.model_path.as_deref() { + None => Err(SlmError::ModelNotLoaded), + Some(path) => Err(SlmError::InferenceError(format!( + "SLM backend is not available for model {path}" + ))), + } } } @@ -75,54 +80,31 @@ mod tests { use super::*; #[test] - fn test_placeholder_evaluation() { + fn evaluation_fails_closed_when_model_is_missing() { let evaluator = SlmEvaluator::new(); - let result = evaluator.evaluate("test content", "test context").unwrap(); - assert!(!result.should_block); + assert!(matches!( + evaluator.evaluate("even forbidden content", "context"), + Err(SlmError::ModelNotLoaded) + )); } #[test] - fn test_evaluator_default() { + fn default_evaluator_is_not_silently_compliant() { let evaluator = SlmEvaluator::default(); - let result = evaluator.evaluate("test", "ctx").unwrap(); - assert!(!result.should_block); - } - - #[test] - fn test_slm_evaluation_always_compliant_placeholder() { - let evaluator = SlmEvaluator::new(); - let result = evaluator - .evaluate("even forbidden content", "context") - .unwrap(); - // Placeholder always returns compliant - assert_eq!(result.should_block, false); - assert_eq!(result.spirit_score, 0.0); - assert_eq!(result.confidence, 0.0); + let result = evaluator.evaluate("test", "ctx"); + assert!(result.is_err()); } #[test] - fn test_slm_evaluation_has_valid_uuid() { - let evaluator = SlmEvaluator::new(); - let result = evaluator.evaluate("test", "ctx").unwrap(); - // UUID should be valid - assert!(!result.proposal_id.to_string().is_empty()); - } - - #[test] - fn test_slm_evaluation_includes_reasoning() { - let evaluator = SlmEvaluator::new(); - let result = evaluator.evaluate("test", "ctx").unwrap(); - assert!(!result.reasoning.is_empty()); - assert!(result.reasoning.contains("not yet implemented")); - } - - #[test] - fn test_slm_evaluation_different_ids_on_each_call() { - let evaluator = SlmEvaluator::new(); - let result1 = evaluator.evaluate("test", "ctx").unwrap(); - let result2 = evaluator.evaluate("test", "ctx").unwrap(); - // Each evaluation should get a new UUID - assert_ne!(result1.proposal_id, result2.proposal_id); + fn configured_model_reports_unavailable_backend() { + let evaluator = SlmEvaluator { + model_path: Some("model.gguf".to_string()), + block_threshold: 0.7, + }; + assert!(matches!( + evaluator.evaluate("test", "ctx"), + Err(SlmError::InferenceError(_)) + )); } #[test]