Skip to main content

schema_risk/
guard.rs

1//! DangerGuard — intercept SQL migrations and gate execution behind explicit confirmation.
2//!
3//! This module is the crown feature of SchemaRisk. It prevents AI agents, CI scripts,
4//! and humans from running destructive SQL without informed, explicit confirmation.
5//!
6//! # Behavior by actor
7//! - **Human** (interactive TTY): Shows full impact panel and prompts for typed confirmation.
8//! - **CI Pipeline**: Prints impact to stderr, exits 4 (never auto-approves).
9//! - **AI Agent**: Exits 4 immediately with machine-readable JSON on stdout.
10//!
11//! # Guard triggers
12//! An operation is guarded when its **score ≥ 40** OR it involves:
13//! `DROP TABLE`, `DROP DATABASE`, `DROP SCHEMA`, `TRUNCATE`, `DROP COLUMN`, `RENAME TABLE/COLUMN`.
14
15use crate::config::Config;
16use crate::engine::RiskEngine;
17use crate::loader::load_file;
18use crate::parser;
19use crate::types::{ActorKind, GuardAuditLog, GuardDecision, MigrationReport, RiskLevel};
20use chrono::Utc;
21use colored::Colorize;
22use serde_json;
23use std::collections::HashMap;
24use std::io::{self, BufRead, Write};
25use std::path::Path;
26
27// ─────────────────────────────────────────────
28// Guard outcome
29// ─────────────────────────────────────────────
30
31/// Result of running the guard on a migration file.
32#[derive(Debug)]
33pub enum GuardOutcome {
34    /// No dangerous operations found — safe to run.
35    Safe,
36    /// All dangerous operations were confirmed by the user.
37    Approved(Vec<GuardDecision>),
38    /// At least one operation was declined or blocked.
39    Blocked {
40        reason: String,
41        operation: String,
42        impact: String,
43    },
44}
45
46impl GuardOutcome {
47    /// Return the process exit code for this outcome.
48    pub fn exit_code(&self) -> i32 {
49        match self {
50            GuardOutcome::Safe => 0,
51            GuardOutcome::Approved(_) => 0,
52            GuardOutcome::Blocked { .. } => 4,
53        }
54    }
55}
56
57// ─────────────────────────────────────────────
58// Actor detection
59// ─────────────────────────────────────────────
60
61/// Detect the runtime actor from environment variables and TTY state.
62///
63/// Priority: Agent > CI > Human
64pub fn detect_actor() -> ActorKind {
65    // Explicit override
66    if std::env::var("SCHEMARISK_ACTOR")
67        .map(|v| v.to_lowercase() == "agent")
68        .unwrap_or(false)
69    {
70        return ActorKind::Agent;
71    }
72
73    // AI provider API keys present → likely running inside an AI agent
74    if std::env::var("ANTHROPIC_API_KEY").is_ok()
75        || std::env::var("OPENAI_API_KEY").is_ok()
76        || std::env::var("OPENAI_API_BASE").is_ok()
77    {
78        return ActorKind::Agent;
79    }
80
81    // CI environment variables
82    if std::env::var("CI").is_ok()
83        || std::env::var("GITHUB_ACTIONS").is_ok()
84        || std::env::var("GITLAB_CI").is_ok()
85        || std::env::var("CIRCLECI").is_ok()
86        || std::env::var("JENKINS_URL").is_ok()
87        || std::env::var("BUILDKITE").is_ok()
88    {
89        return ActorKind::Ci;
90    }
91
92    ActorKind::Human
93}
94
95// ─────────────────────────────────────────────
96// Guard trigger logic
97// ─────────────────────────────────────────────
98
99/// Returns `true` if an operation should trigger the guard confirmation flow.
100///
101/// Triggers when score ≥ 40 OR the description matches a known destructive pattern.
102pub fn is_guarded_operation(desc: &str, score: u32) -> bool {
103    if score >= 40 {
104        return true;
105    }
106    let upper = desc.to_uppercase();
107    upper.contains("DROP TABLE")
108        || upper.contains("TRUNCATE")
109        || upper.contains("DROP DATABASE")
110        || upper.contains("DROP SCHEMA")
111        || upper.contains("DROP COLUMN")
112        || upper.contains("RENAME COLUMN")
113        || upper.contains("RENAME TO")
114}
115
116fn is_irreversible_operation(desc: &str) -> bool {
117    let upper = desc.to_uppercase();
118    upper.contains("DROP TABLE")
119        || upper.contains("DROP DATABASE")
120        || upper.contains("DROP SCHEMA")
121        || upper.contains("DROP COLUMN")
122        || upper.contains("TRUNCATE")
123}
124
125// ─────────────────────────────────────────────
126// Impact panel rendering
127// ─────────────────────────────────────────────
128
129/// Render the full impact panel to stderr for a single guarded operation.
130pub fn render_impact_panel(
131    report: &MigrationReport,
132    op_desc: &str,
133    risk: RiskLevel,
134    score: u32,
135    actor: &ActorKind,
136) {
137    let now = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
138    let divider = "-".repeat(78).dimmed().to_string();
139    let bullet = "•".dimmed();
140
141    let risk_str = match risk {
142        RiskLevel::Critical => "CRITICAL".red().bold().to_string(),
143        RiskLevel::High => "HIGH".truecolor(255, 140, 0).bold().to_string(),
144        RiskLevel::Medium => "MEDIUM".yellow().bold().to_string(),
145        RiskLevel::Low => "LOW".green().bold().to_string(),
146    };
147
148    let lock_type = if score >= 90 || op_desc.to_uppercase().contains("DROP TABLE") {
149        "ACCESS EXCLUSIVE"
150    } else if score >= 50 {
151        "SHARE"
152    } else {
153        "SHARE ROW EXCLUSIVE"
154    };
155    let desc_upper = op_desc.to_uppercase();
156
157    eprintln!();
158    eprintln!("{}", divider);
159    eprintln!("{}", "Dangerous migration operation detected".bold());
160    eprintln!("{}", divider);
161
162    eprintln!("  {} {}", "Operation:".bold(), op_desc);
163    eprintln!(
164        "  {} {} {}",
165        "Risk:".bold(),
166        risk_str,
167        format!("(score: {score})").dimmed()
168    );
169    eprintln!("  {} {}", "Lock:".bold(), lock_type);
170
171    if let Some(secs) = report.estimated_lock_seconds {
172        let lock_range = if secs < 5 {
173            "< 5s".to_string()
174        } else if secs < 60 {
175            format!("~{}s", secs)
176        } else {
177            format!("~{}m", secs / 60)
178        };
179        eprintln!("  {} {}", "Estimated lock:".bold(), lock_range);
180    }
181
182    if is_irreversible_operation(op_desc) {
183        eprintln!(
184            "\n  {} {}",
185            "Warning:".red().bold(),
186            "This operation is irreversible.".red()
187        );
188    }
189
190    if !report.affected_tables.is_empty() {
191        eprintln!("\n{}", "Database impact".bold());
192        for table in &report.affected_tables {
193            let impact_str = if desc_upper.contains("DROP TABLE") {
194                "DELETED"
195            } else if desc_upper.contains("TRUNCATE") {
196                "TRUNCATED"
197            } else {
198                "MODIFIED"
199            };
200            eprintln!("  {} {:<40} {}", bullet, shorten(table, 40), impact_str);
201        }
202        for fk in &report.fk_impacts {
203            if fk.cascade {
204                eprintln!(
205                    "  {} {:<40} CASCADE DELETE",
206                    bullet,
207                    shorten(&fk.from_table, 40)
208                );
209            }
210        }
211    }
212
213    eprintln!("\n{}", "Potential breakage".bold());
214    if desc_upper.contains("DROP TABLE") {
215        eprintln!("  {} All queries to the dropped table will fail", bullet);
216        eprintln!(
217            "  {} Foreign keys with CASCADE may delete dependent rows",
218            bullet
219        );
220        eprintln!(
221            "  {} Application code referencing this table will break",
222            bullet
223        );
224    } else if desc_upper.contains("DROP COLUMN") || desc_upper.contains("DROP COL") {
225        eprintln!("  {} Queries selecting this column will error", bullet);
226        eprintln!("  {} ORM models referencing this column will break", bullet);
227    } else if desc_upper.contains("RENAME") {
228        eprintln!(
229            "  {} Queries using the old name will fail immediately",
230            bullet
231        );
232        eprintln!(
233            "  {} Views, procedures, and constraints may need updates",
234            bullet
235        );
236    } else if desc_upper.contains("TRUNCATE") {
237        eprintln!("  {} Existing table data is permanently deleted", bullet);
238        eprintln!(
239            "  {} Application behavior may change with empty tables",
240            bullet
241        );
242    } else if desc_upper.contains("ALTER COLUMN") && desc_upper.contains("TYPE") {
243        eprintln!(
244            "  {} Table rewrite may block writes during migration",
245            bullet
246        );
247        eprintln!(
248            "  {} Data conversion or truncation errors are possible",
249            bullet
250        );
251    } else {
252        eprintln!(
253            "  {} Review migration impact carefully before continuing",
254            bullet
255        );
256    }
257
258    if desc_upper.contains("DROP TABLE") {
259        eprintln!("\n{}", "Safer rollout".bold());
260        eprintln!(
261            "  {} Rename first (e.g., to *_deprecated), validate traffic, then drop later",
262            bullet
263        );
264    } else if desc_upper.contains("DROP COLUMN") {
265        eprintln!("\n{}", "Safer rollout".bold());
266        eprintln!("  {} Remove app references first", bullet);
267        eprintln!("  {} Deploy application changes", bullet);
268        eprintln!("  {} Drop the column in a follow-up migration", bullet);
269    }
270
271    eprintln!(
272        "\n  {} {}   {} {}",
273        "Actor:".bold(),
274        actor,
275        "Time:".bold(),
276        now
277    );
278    eprintln!("{}", divider);
279    eprintln!();
280}
281
282fn shorten(s: &str, max: usize) -> String {
283    if s.len() <= max {
284        s.to_string()
285    } else {
286        format!("{}…", &s[..max.saturating_sub(1)])
287    }
288}
289
290// ─────────────────────────────────────────────
291// Confirmation prompt
292// ─────────────────────────────────────────────
293
294/// Prompt the user for confirmation.
295///
296/// - Critical ops: require `"yes i am sure"` (case-insensitive).
297/// - High ops: require `"yes"`.
298/// - Returns `true` if confirmed.
299fn prompt_confirmation(risk: RiskLevel) -> bool {
300    let (required_phrase, hint) = match risk {
301        RiskLevel::Critical => (
302            "yes i am sure",
303            "Type \"yes I am sure\" to confirm, or press Enter/Ctrl-C to abort: ",
304        ),
305        _ => (
306            "yes",
307            "Type \"yes\" to confirm, or press Enter/Ctrl-C to abort: ",
308        ),
309    };
310
311    eprint!("  {}", hint.yellow().bold());
312    let _ = io::stderr().flush();
313
314    let mut line = String::new();
315    match io::stdin().lock().read_line(&mut line) {
316        Ok(0) | Err(_) => {
317            // EOF / Ctrl-C
318            eprintln!("\n  Aborted.");
319            return false;
320        }
321        Ok(_) => {}
322    }
323
324    let trimmed = line.trim().to_lowercase();
325    trimmed == required_phrase
326}
327
328// ─────────────────────────────────────────────
329// Agent-blocked JSON output
330// ─────────────────────────────────────────────
331
332/// Print machine-readable JSON and return the `Blocked` outcome for agent actors.
333fn agent_blocked(op_desc: &str, impact: &str) -> GuardOutcome {
334    let json = serde_json::json!({
335        "blocked": true,
336        "reason": "CRITICAL operation requires human confirmation",
337        "operation": op_desc,
338        "impact": impact,
339        "required_action": "A human must run: schema-risk guard <file> --interactive"
340    });
341    println!(
342        "{}",
343        serde_json::to_string_pretty(&json).unwrap_or_default()
344    );
345    GuardOutcome::Blocked {
346        reason: "Agent actor — automatic block enforced".to_string(),
347        operation: op_desc.to_string(),
348        impact: impact.to_string(),
349    }
350}
351
352// ─────────────────────────────────────────────
353// Audit log
354// ─────────────────────────────────────────────
355
356fn write_audit_log(
357    file_path: &str,
358    actor: &ActorKind,
359    decisions: &[GuardDecision],
360    audit_path: &str,
361) {
362    let log = GuardAuditLog {
363        schemarisk_version: env!("CARGO_PKG_VERSION").to_string(),
364        file: file_path.to_string(),
365        timestamp: Utc::now().to_rfc3339(),
366        actor: actor.clone(),
367        decisions: decisions.to_vec(),
368    };
369    match serde_json::to_string_pretty(&log) {
370        Ok(json) => {
371            if let Err(e) = std::fs::write(audit_path, &json) {
372                eprintln!("warning: failed to write audit log to {audit_path}: {e}");
373            } else {
374                eprintln!(
375                    "\n  {} Confirmation log written to {}",
376                    "⚡".cyan(),
377                    audit_path.cyan()
378                );
379            }
380        }
381        Err(e) => eprintln!("warning: failed to serialize audit log: {e}"),
382    }
383}
384
385// ─────────────────────────────────────────────
386// Main guard entry point
387// ─────────────────────────────────────────────
388
389/// Options for `run_guard`.
390#[derive(Default)]
391pub struct GuardOptions {
392    /// Print impact panel but do not prompt. Exit code reflects risk.
393    pub dry_run: bool,
394    /// Skip interactive prompts (used in CI; blocks on dangerous ops).
395    pub non_interactive: bool,
396    /// Table row estimates for offline scoring.
397    pub row_counts: HashMap<String, u64>,
398    /// Configuration (thresholds, audit log path, etc.).
399    pub config: Config,
400}
401
402/// Intercepts a SQL migration and gates execution behind explicit confirmation.
403///
404/// # Behavior by actor
405/// - **Human** (interactive TTY): Shows full impact panel and prompts for typed confirmation.
406/// - **CI Pipeline**: Prints impact to stderr, exits 4 (never auto-approves).
407/// - **AI Agent**: Exits 4 immediately with machine-readable JSON on stdout.
408///
409/// # Returns
410/// - `Ok(GuardOutcome::Safe)` — no operations require guarding
411/// - `Ok(GuardOutcome::Approved(_))` — all operations confirmed
412/// - `Ok(GuardOutcome::Blocked { .. })` — one or more operations declined
413/// - `Err(SchemaRiskError)` — parse or I/O failure
414pub fn run_guard(path: &Path, opts: GuardOptions) -> crate::error::Result<GuardOutcome> {
415    let actor = detect_actor();
416    let migration = load_file(path)?;
417    let stmts = parser::parse(&migration.sql)?;
418    let engine = RiskEngine::new(opts.row_counts.clone());
419    let report = engine.analyze(&migration.name, &stmts);
420
421    // Collect operations that need guarding
422    let guarded_ops: Vec<_> = report
423        .operations
424        .iter()
425        .filter(|op| is_guarded_operation(&op.description, op.score))
426        .collect();
427
428    if guarded_ops.is_empty() {
429        eprintln!(
430            "  {} Safe to run — no dangerous operations detected.",
431            "✅".green()
432        );
433        return Ok(GuardOutcome::Safe);
434    }
435
436    // ── Dry-run mode ────────────────────────────────────────────────────
437    if opts.dry_run {
438        for op in &guarded_ops {
439            render_impact_panel(&report, &op.description, op.risk_level, op.score, &actor);
440        }
441        let max_risk = guarded_ops
442            .iter()
443            .map(|o| o.risk_level)
444            .max()
445            .unwrap_or(RiskLevel::Low);
446        let exit_code = match max_risk {
447            RiskLevel::Critical => 2,
448            RiskLevel::High => 1,
449            _ => 0,
450        };
451        // Return a Blocked outcome to signal the caller to use our exit code
452        if exit_code > 0 {
453            return Ok(GuardOutcome::Blocked {
454                reason: format!("dry-run: {} risk detected", max_risk),
455                operation: guarded_ops[0].description.clone(),
456                impact: format!("{} operations require confirmation", guarded_ops.len()),
457            });
458        }
459        return Ok(GuardOutcome::Safe);
460    }
461
462    // ── Agent actor: always block with machine-readable JSON ─────────────
463    if actor == ActorKind::Agent && opts.config.guard.block_agents {
464        let op = &guarded_ops[0];
465        let impact = format!(
466            "{} dangerous operations require human confirmation",
467            guarded_ops.len()
468        );
469        return Ok(agent_blocked(&op.description, &impact));
470    }
471
472    // ── CI actor in non-interactive mode ─────────────────────────────────
473    if (actor == ActorKind::Ci || opts.non_interactive) && opts.config.guard.block_ci {
474        for op in &guarded_ops {
475            render_impact_panel(&report, &op.description, op.risk_level, op.score, &actor);
476        }
477        eprintln!(
478            "  {} CI mode: dangerous operations blocked. Set block_ci: false to allow.",
479            "⛔".red()
480        );
481        return Ok(GuardOutcome::Blocked {
482            reason: "CI pipeline — non-interactive block".to_string(),
483            operation: guarded_ops[0].description.clone(),
484            impact: format!("{} operations require confirmation", guarded_ops.len()),
485        });
486    }
487
488    // CI non-interactive but block_ci is false → print warning but continue to confirmation
489    if actor == ActorKind::Ci || opts.non_interactive {
490        for op in &guarded_ops {
491            render_impact_panel(&report, &op.description, op.risk_level, op.score, &actor);
492        }
493        eprintln!(
494            "  {} Non-interactive mode: cannot prompt. Blocking.",
495            "⛔".red()
496        );
497        return Ok(GuardOutcome::Blocked {
498            reason: "Non-interactive mode — cannot prompt for confirmation".to_string(),
499            operation: guarded_ops[0].description.clone(),
500            impact: format!("{} operations require confirmation", guarded_ops.len()),
501        });
502    }
503
504    // ── Human actor: interactive confirmation loop ───────────────────────
505    let mut decisions: Vec<GuardDecision> = Vec::new();
506
507    for op in &guarded_ops {
508        render_impact_panel(&report, &op.description, op.risk_level, op.score, &actor);
509
510        let irreversible = is_irreversible_operation(&op.description);
511        if irreversible {
512            eprintln!(
513                "  {}",
514                "This operation is irreversible. Proceed only with a rollback strategy."
515                    .red()
516                    .bold()
517            );
518            eprintln!();
519        }
520
521        let confirmed = if opts.config.guard.require_typed_confirmation {
522            prompt_confirmation(op.risk_level)
523        } else {
524            prompt_confirmation(RiskLevel::Medium) // just "yes"
525        };
526
527        let typed_phrase = if confirmed {
528            match op.risk_level {
529                RiskLevel::Critical => Some("yes i am sure".to_string()),
530                _ => Some("yes".to_string()),
531            }
532        } else {
533            None
534        };
535
536        let decision = GuardDecision {
537            operation: op.description.clone(),
538            risk_level: op.risk_level,
539            score: op.score,
540            impact_summary: build_impact_summary(&report, &op.description),
541            confirmed,
542            typed_phrase,
543            timestamp: Utc::now().to_rfc3339(),
544            actor: actor.clone(),
545        };
546
547        if !confirmed {
548            decisions.push(decision);
549            eprintln!("  {} Aborted. Migration will NOT run.", "⛔".red().bold());
550            // Write audit log even for declined runs
551            write_audit_log(
552                &migration.name,
553                &actor,
554                &decisions,
555                &opts.config.guard.audit_log,
556            );
557            return Ok(GuardOutcome::Blocked {
558                reason: "User declined confirmation".to_string(),
559                operation: op.description.clone(),
560                impact: build_impact_summary(&report, &op.description),
561            });
562        }
563
564        decisions.push(decision);
565        eprintln!("  {} Confirmed — proceeding to next check...", "✓".green());
566    }
567
568    // All confirmed
569    eprintln!(
570        "\n  {} Proceeding. All {} operation(s) confirmed.",
571        "⚡".cyan(),
572        guarded_ops.len()
573    );
574    write_audit_log(
575        &migration.name,
576        &actor,
577        &decisions,
578        &opts.config.guard.audit_log,
579    );
580
581    Ok(GuardOutcome::Approved(decisions))
582}
583
584/// Build a one-sentence human-readable impact summary for an operation.
585fn build_impact_summary(report: &MigrationReport, op_desc: &str) -> String {
586    let tables_str = if report.affected_tables.is_empty() {
587        String::new()
588    } else {
589        format!(
590            " {} table(s): {}",
591            report.affected_tables.len(),
592            report
593                .affected_tables
594                .iter()
595                .take(3)
596                .cloned()
597                .collect::<Vec<_>>()
598                .join(", ")
599        )
600    };
601
602    let cascade_count = report.fk_impacts.iter().filter(|fk| fk.cascade).count();
603    let cascade_str = if cascade_count > 0 {
604        format!(", cascades to {} child table(s)", cascade_count)
605    } else {
606        String::new()
607    };
608
609    format!("{}{}{}", shorten(op_desc, 60), tables_str, cascade_str)
610}
611
612// ─────────────────────────────────────────────
613// Code Scanning Guard Mode
614// ─────────────────────────────────────────────
615
616use crate::impact::{ExtractedSql, SqlExtractionReport, SqlExtractor};
617
618/// Options for scanning source code for dangerous SQL.
619#[derive(Default)]
620pub struct CodeGuardOptions {
621    /// Base guard options.
622    pub base: GuardOptions,
623    /// Directory to scan for SQL in code.
624    pub scan_dir: std::path::PathBuf,
625    /// File extensions to scan (use config defaults if empty).
626    pub extensions: Vec<String>,
627}
628
629/// A dangerous SQL query found in source code.
630#[derive(Debug, Clone)]
631pub struct DangerousQuery {
632    /// The extracted SQL information.
633    pub source: ExtractedSql,
634    /// The risk analysis report for this SQL.
635    pub report: crate::types::MigrationReport,
636    /// Operations that require guarding.
637    pub guarded_operations: Vec<crate::types::DetectedOperation>,
638}
639
640/// Result of scanning code for dangerous SQL.
641#[derive(Debug)]
642pub struct CodeGuardReport {
643    /// All extracted SQL from the codebase.
644    pub extraction_report: SqlExtractionReport,
645    /// Dangerous queries that require attention.
646    pub dangerous_queries: Vec<DangerousQuery>,
647    /// Overall guard outcome.
648    pub overall_outcome: GuardOutcome,
649    /// Summary statistics.
650    pub stats: CodeGuardStats,
651}
652
653/// Statistics from code guard scanning.
654#[derive(Debug, Default, Clone)]
655pub struct CodeGuardStats {
656    /// Total files scanned.
657    pub files_scanned: usize,
658    /// Total SQL statements found.
659    pub total_sql_found: usize,
660    /// Number of dangerous SQL statements.
661    pub dangerous_count: usize,
662    /// Breakdown by ORM/context.
663    pub by_context: std::collections::HashMap<String, usize>,
664}
665
666/// Scan source code for SQL and guard against dangerous operations.
667///
668/// This function:
669/// 1. Scans the specified directory for SQL in source code
670/// 2. Parses and analyzes each SQL statement found
671/// 3. Identifies dangerous operations that require confirmation
672/// 4. Returns a comprehensive report
673pub fn guard_code_sql(opts: CodeGuardOptions) -> crate::error::Result<CodeGuardReport> {
674    let _actor = detect_actor(); // Reserved for future use
675    let extractor = SqlExtractor::new();
676    let extraction_report = extractor.scan_directory(&opts.scan_dir);
677
678    let engine = RiskEngine::new(opts.base.row_counts.clone());
679    let mut dangerous_queries: Vec<DangerousQuery> = Vec::new();
680
681    // Analyze each extracted SQL statement
682    for sql_item in &extraction_report.extracted {
683        // Try to parse the SQL
684        let stmts = match parser::parse(&sql_item.sql) {
685            Ok(s) => s,
686            Err(_) => continue, // Skip unparseable SQL
687        };
688
689        // Analyze for risks
690        let report = engine.analyze(&sql_item.source_file, &stmts);
691
692        // Check if any operations need guarding
693        let guarded_ops: Vec<_> = report
694            .operations
695            .iter()
696            .filter(|op| is_guarded_operation(&op.description, op.score))
697            .cloned()
698            .collect();
699
700        if !guarded_ops.is_empty() {
701            dangerous_queries.push(DangerousQuery {
702                source: sql_item.clone(),
703                report,
704                guarded_operations: guarded_ops,
705            });
706        }
707    }
708
709    // Build statistics
710    let stats = CodeGuardStats {
711        files_scanned: extraction_report.files_scanned,
712        total_sql_found: extraction_report.extracted.len(),
713        dangerous_count: dangerous_queries.len(),
714        by_context: extraction_report.by_context.clone(),
715    };
716
717    // Determine overall outcome
718    let overall_outcome = if dangerous_queries.is_empty() {
719        GuardOutcome::Safe
720    } else {
721        GuardOutcome::Blocked {
722            reason: format!(
723                "Found {} dangerous SQL statement(s) in source code",
724                dangerous_queries.len()
725            ),
726            operation: dangerous_queries
727                .first()
728                .map(|d| d.source.sql.chars().take(60).collect())
729                .unwrap_or_default(),
730            impact: format!(
731                "{} file(s) contain dangerous queries",
732                dangerous_queries
733                    .iter()
734                    .map(|d| &d.source.source_file)
735                    .collect::<std::collections::HashSet<_>>()
736                    .len()
737            ),
738        }
739    };
740
741    Ok(CodeGuardReport {
742        extraction_report,
743        dangerous_queries,
744        overall_outcome,
745        stats,
746    })
747}
748
749/// Render code guard results to stderr.
750pub fn render_code_guard_report(report: &CodeGuardReport, actor: &ActorKind) {
751    use colored::Colorize;
752
753    let divider = "-".repeat(78).dimmed().to_string();
754
755    eprintln!();
756    eprintln!("{}", divider);
757    eprintln!("{}", "SchemaRisk Code SQL Scanner".bold());
758    eprintln!("{}", divider);
759    eprintln!();
760
761    eprintln!(
762        "  {} files scanned, {} SQL statements found",
763        report.stats.files_scanned.to_string().cyan(),
764        report.stats.total_sql_found.to_string().cyan()
765    );
766
767    if !report.stats.by_context.is_empty() {
768        eprintln!();
769        eprintln!("  {} SQL by ORM/Framework:", "ℹ".cyan());
770        for (ctx, count) in &report.stats.by_context {
771            eprintln!("    • {}: {}", ctx, count);
772        }
773    }
774
775    eprintln!();
776
777    if report.dangerous_queries.is_empty() {
778        eprintln!(
779            "  {} No dangerous SQL found in source code",
780            "✅".green().bold()
781        );
782    } else {
783        eprintln!(
784            "  {} Found {} dangerous SQL statement(s)",
785            "⚠".yellow().bold(),
786            report.dangerous_queries.len()
787        );
788        eprintln!();
789
790        for (idx, dq) in report.dangerous_queries.iter().enumerate() {
791            let risk_str = match dq.report.overall_risk {
792                RiskLevel::Critical => "CRITICAL".red().bold().to_string(),
793                RiskLevel::High => "HIGH".truecolor(255, 140, 0).bold().to_string(),
794                RiskLevel::Medium => "MEDIUM".yellow().bold().to_string(),
795                RiskLevel::Low => "LOW".green().bold().to_string(),
796            };
797
798            eprintln!("  [{}] {} ({})", idx + 1, risk_str, dq.source.context);
799            eprintln!(
800                "      File: {}:{}",
801                dq.source.source_file.cyan(),
802                dq.source.line
803            );
804            eprintln!(
805                "      SQL: {}",
806                dq.source.sql.chars().take(60).collect::<String>().dimmed()
807            );
808
809            for op in &dq.guarded_operations {
810                eprintln!("      → {}", op.description);
811            }
812            eprintln!();
813        }
814    }
815
816    let now = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
817    eprintln!(
818        "  {} {}   {} {}",
819        "Actor:".bold(),
820        actor,
821        "Time:".bold(),
822        now
823    );
824    eprintln!("{}", divider);
825    eprintln!();
826}
827
828// ─────────────────────────────────────────────
829// Tests
830// ─────────────────────────────────────────────
831
832#[cfg(test)]
833mod tests {
834    use super::*;
835
836    #[test]
837    fn guarded_for_high_score() {
838        assert!(is_guarded_operation(
839            "ALTER TABLE x ALTER COLUMN y TYPE bigint",
840            80
841        ));
842    }
843
844    #[test]
845    fn guarded_for_drop_table_regardless_of_score() {
846        assert!(is_guarded_operation("DROP TABLE sessions", 5));
847    }
848
849    #[test]
850    fn not_guarded_for_create_table() {
851        assert!(!is_guarded_operation("CREATE TABLE new_table", 2));
852    }
853
854    #[test]
855    fn not_guarded_for_low_score_add_column() {
856        assert!(!is_guarded_operation(
857            "ALTER TABLE users ADD COLUMN last_seen timestamptz",
858            5
859        ));
860    }
861
862    #[test]
863    fn agent_detection_via_env() {
864        // This test sets env var; isolate from parallel tests
865        std::env::remove_var("SCHEMARISK_ACTOR");
866        std::env::remove_var("ANTHROPIC_API_KEY");
867        std::env::remove_var("OPENAI_API_KEY");
868        std::env::remove_var("CI");
869        std::env::remove_var("GITHUB_ACTIONS");
870        // Without any env var set, should default to Human
871        // (can't fully test CI/Agent without env isolation)
872        let actor = detect_actor();
873        // In test environment, CI may be set — just verify it returns a valid variant
874        let _ = actor;
875    }
876}