Skip to main content

destructive_command_guard/
hook.rs

1//! Claude Code hook protocol handling.
2//!
3//! This module handles the JSON input/output for the Claude Code `PreToolUse` hook.
4//! It parses incoming hook requests and formats denial responses.
5
6use crate::evaluator::MatchSpan;
7use crate::highlight::HighlightSpan;
8use crate::output::auto_theme;
9#[cfg(feature = "rich-output")]
10use crate::output::console::console;
11use crate::output::denial::DenialBox;
12use crate::output::theme::Severity as ThemeSeverity;
13use crate::packs::PatternSuggestion;
14use colored::Colorize;
15#[cfg(feature = "rich-output")]
16#[allow(unused_imports)]
17use rich_rust::prelude::*;
18use serde::{Deserialize, Serialize};
19use std::borrow::Cow;
20use std::io::{self, IsTerminal, Read, Write};
21use std::time::Duration;
22
23/// Input structure from Claude Code's `PreToolUse` hook.
24#[derive(Debug, Deserialize)]
25pub struct HookInput {
26    /// Hook event name (used by some clients, e.g. Copilot CLI: "pre-tool-use").
27    pub event: Option<String>,
28
29    /// The name of the tool being invoked (e.g., "Bash", "Read", "Write").
30    #[serde(alias = "toolName")]
31    pub tool_name: Option<String>,
32
33    /// Tool-specific input parameters.
34    #[serde(alias = "toolInput")]
35    pub tool_input: Option<ToolInput>,
36
37    /// Alternate tool arguments format used by some clients.
38    /// May be a JSON string (e.g. "{\"command\":\"...\"}") or an object.
39    #[serde(alias = "toolArgs")]
40    pub tool_args: Option<serde_json::Value>,
41}
42
43/// Tool-specific input containing the command to execute.
44#[derive(Debug, Deserialize)]
45pub struct ToolInput {
46    /// The command string (for Bash tools).
47    pub command: Option<serde_json::Value>,
48}
49
50/// Output structure for denying a command.
51#[derive(Debug, Serialize)]
52pub struct HookOutput<'a> {
53    /// Hook-specific output with the decision.
54    #[serde(rename = "hookSpecificOutput")]
55    pub hook_specific_output: HookSpecificOutput<'a>,
56}
57
58/// Hook-specific output with decision and reason.
59#[derive(Debug, Serialize)]
60pub struct HookSpecificOutput<'a> {
61    /// Always "`PreToolUse`" for this hook.
62    #[serde(rename = "hookEventName")]
63    pub hook_event_name: &'static str,
64
65    /// The permission decision: "allow" or "deny".
66    #[serde(rename = "permissionDecision")]
67    pub permission_decision: &'static str,
68
69    /// Human-readable explanation of the decision.
70    #[serde(rename = "permissionDecisionReason")]
71    pub permission_decision_reason: Cow<'a, str>,
72
73    /// Short allow-once code (if a pending exception was recorded).
74    #[serde(rename = "allowOnceCode", skip_serializing_if = "Option::is_none")]
75    pub allow_once_code: Option<String>,
76
77    /// Full hash for allow-once disambiguation (if available).
78    #[serde(rename = "allowOnceFullHash", skip_serializing_if = "Option::is_none")]
79    pub allow_once_full_hash: Option<String>,
80
81    // --- New fields for AI agent ergonomics (git_safety_guard-e4fl.1) ---
82    /// Stable rule identifier (e.g., "core.git:reset-hard").
83    /// Format: "{packId}:{patternName}"
84    #[serde(rename = "ruleId", skip_serializing_if = "Option::is_none")]
85    pub rule_id: Option<String>,
86
87    /// Pack identifier that matched (e.g., "core.git").
88    #[serde(rename = "packId", skip_serializing_if = "Option::is_none")]
89    pub pack_id: Option<String>,
90
91    /// Severity level of the matched pattern.
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub severity: Option<crate::packs::Severity>,
94
95    /// Confidence score for this match (0.0-1.0).
96    /// Higher values indicate higher confidence that this is a true positive.
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub confidence: Option<f64>,
99
100    /// Remediation suggestions for the blocked command.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub remediation: Option<Remediation>,
103}
104
105/// Copilot-compatible denial output for pre-tool-use hooks.
106///
107/// Copilot hooks can consume either:
108/// - `continue=false` with `stopReason`
109/// - `permissionDecision=deny` with `permissionDecisionReason`
110///
111/// We emit both for compatibility across documented variants.
112#[derive(Debug, Serialize)]
113pub struct CopilotHookOutput<'a> {
114    /// Whether execution should continue.
115    #[serde(rename = "continue")]
116    pub continue_execution: bool,
117
118    /// Human-readable stop reason.
119    #[serde(rename = "stopReason")]
120    pub stop_reason: Cow<'a, str>,
121
122    /// Permission decision (`deny`).
123    #[serde(rename = "permissionDecision")]
124    pub permission_decision: &'static str,
125
126    /// Human-readable explanation of the decision.
127    #[serde(rename = "permissionDecisionReason")]
128    pub permission_decision_reason: Cow<'a, str>,
129
130    /// Short allow-once code (if a pending exception was recorded).
131    #[serde(rename = "allowOnceCode", skip_serializing_if = "Option::is_none")]
132    pub allow_once_code: Option<String>,
133
134    /// Full hash for allow-once disambiguation (if available).
135    #[serde(rename = "allowOnceFullHash", skip_serializing_if = "Option::is_none")]
136    pub allow_once_full_hash: Option<String>,
137
138    /// Stable rule identifier (e.g., "core.git:reset-hard").
139    #[serde(rename = "ruleId", skip_serializing_if = "Option::is_none")]
140    pub rule_id: Option<String>,
141
142    /// Pack identifier that matched (e.g., "core.git").
143    #[serde(rename = "packId", skip_serializing_if = "Option::is_none")]
144    pub pack_id: Option<String>,
145
146    /// Severity level of the matched pattern.
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub severity: Option<crate::packs::Severity>,
149
150    /// Confidence score for this match (0.0-1.0).
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub confidence: Option<f64>,
153
154    /// Remediation suggestions for the blocked command.
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub remediation: Option<Remediation>,
157}
158
159/// Hook protocol variant for response formatting.
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub enum HookProtocol {
162    /// Claude Code / Augment-compatible `hookSpecificOutput` protocol.
163    ClaudeCompatible,
164    /// Copilot hook protocol (`continue` / `stopReason` + permission fields).
165    Copilot,
166}
167
168/// Allow-once metadata for denial output.
169#[derive(Debug, Clone)]
170pub struct AllowOnceInfo {
171    pub code: String,
172    pub full_hash: String,
173}
174
175/// Remediation suggestions for blocked commands.
176///
177/// Provides actionable alternatives and context for users to safely
178/// accomplish their intended goal.
179#[derive(Debug, Clone, Serialize)]
180pub struct Remediation {
181    /// A safe alternative command that accomplishes a similar goal.
182    #[serde(rename = "safeAlternative", skip_serializing_if = "Option::is_none")]
183    pub safe_alternative: Option<String>,
184
185    /// Detailed explanation of why the command was blocked and what to do instead.
186    pub explanation: String,
187
188    /// The command to run to allow this specific command once (e.g., "dcg allow-once abc12").
189    #[serde(rename = "allowOnceCommand")]
190    pub allow_once_command: String,
191}
192
193/// Result of processing a hook request.
194#[derive(Debug)]
195pub enum HookResult {
196    /// Command is allowed (no output needed).
197    Allow,
198
199    /// Command is denied with a reason.
200    Deny {
201        /// The original command that was blocked.
202        command: String,
203        /// Why the command was blocked.
204        reason: String,
205        /// Which pack blocked it (optional).
206        pack: Option<String>,
207        /// Which pattern matched (optional).
208        pattern_name: Option<String>,
209    },
210
211    /// Not a Bash command, skip processing.
212    Skip,
213
214    /// Error parsing input.
215    ParseError,
216}
217
218/// Error type for reading and parsing hook input.
219#[derive(Debug)]
220pub enum HookReadError {
221    /// Failed to read from stdin.
222    Io(io::Error),
223    /// Input exceeded the configured size limit.
224    InputTooLarge(usize),
225    /// Failed to parse JSON input.
226    Json(serde_json::Error),
227}
228
229/// Read and parse hook input from stdin.
230///
231/// # Errors
232///
233/// Returns [`HookReadError::Io`] if stdin cannot be read, [`HookReadError::Json`]
234/// if the input is not valid hook JSON, or [`HookReadError::InputTooLarge`] if
235/// the input exceeds `max_bytes`.
236pub fn read_hook_input(max_bytes: usize) -> Result<HookInput, HookReadError> {
237    let mut input = String::with_capacity(256);
238    {
239        let stdin = io::stdin();
240        // Read up to limit + 1 to detect overflow
241        let mut handle = stdin.lock().take(max_bytes as u64 + 1);
242        handle
243            .read_to_string(&mut input)
244            .map_err(HookReadError::Io)?;
245    }
246
247    if input.len() > max_bytes {
248        return Err(HookReadError::InputTooLarge(input.len()));
249    }
250
251    serde_json::from_str(&input).map_err(HookReadError::Json)
252}
253
254/// Detect which hook protocol should be used for output formatting.
255#[must_use]
256pub fn detect_protocol(input: &HookInput) -> HookProtocol {
257    let tool_name = input
258        .tool_name
259        .as_deref()
260        .map(str::to_ascii_lowercase)
261        .unwrap_or_default();
262
263    if input.event.is_some()
264        || input.tool_args.is_some()
265        || matches!(
266            tool_name.as_str(),
267            "run_shell_command" | "run-shell-command"
268        )
269    {
270        HookProtocol::Copilot
271    } else {
272        HookProtocol::ClaudeCompatible
273    }
274}
275
276fn is_supported_shell_tool(tool_name: Option<&str>) -> bool {
277    let Some(tool_name) = tool_name else {
278        return false;
279    };
280
281    matches!(
282        tool_name.to_ascii_lowercase().as_str(),
283        "bash" | "launch-process" | "run_shell_command" | "run-shell-command"
284    )
285}
286
287fn extract_command_from_tool_args(tool_args: &serde_json::Value) -> Option<String> {
288    match tool_args {
289        serde_json::Value::Object(map) => map.get("command").and_then(|v| match v {
290            serde_json::Value::String(s) if !s.is_empty() => Some(s.clone()),
291            _ => None,
292        }),
293        serde_json::Value::String(s) => {
294            if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(s) {
295                extract_command_from_tool_args(&parsed)
296            } else if s.is_empty() {
297                None
298            } else {
299                Some(s.clone())
300            }
301        }
302        _ => None,
303    }
304}
305
306/// Extract command and protocol from hook input.
307#[must_use]
308pub fn extract_command_with_protocol(input: &HookInput) -> Option<(String, HookProtocol)> {
309    // Only process shell-command invocations for supported clients.
310    if !is_supported_shell_tool(input.tool_name.as_deref()) {
311        return None;
312    }
313
314    let protocol = detect_protocol(input);
315
316    if let Some(tool_input) = input.tool_input.as_ref() {
317        if let Some(serde_json::Value::String(s)) = tool_input.command.as_ref() {
318            if !s.is_empty() {
319                return Some((s.clone(), protocol));
320            }
321        }
322    }
323
324    if let Some(tool_args) = input.tool_args.as_ref() {
325        if let Some(command) = extract_command_from_tool_args(tool_args) {
326            return Some((command, protocol));
327        }
328    }
329
330    None
331}
332
333/// Extract the command string from hook input.
334#[must_use]
335pub fn extract_command(input: &HookInput) -> Option<String> {
336    extract_command_with_protocol(input).map(|(command, _)| command)
337}
338
339/// Configure colored output based on TTY detection.
340pub fn configure_colors() {
341    if std::env::var_os("NO_COLOR").is_some() || std::env::var_os("DCG_NO_COLOR").is_some() {
342        colored::control::set_override(false);
343        return;
344    }
345
346    if !io::stderr().is_terminal() {
347        colored::control::set_override(false);
348    }
349}
350
351/// Format the explain hint line for copy-paste convenience.
352fn format_explain_hint(command: &str) -> String {
353    // Escape double quotes in command for safe copy-paste
354    let escaped = command.replace('"', "\\\"");
355    format!("Tip: dcg explain \"{escaped}\"")
356}
357
358fn build_rule_id(pack: Option<&str>, pattern: Option<&str>) -> Option<String> {
359    match (pack, pattern) {
360        (Some(pack_id), Some(pattern_name)) => Some(format!("{pack_id}:{pattern_name}")),
361        _ => None,
362    }
363}
364
365fn format_explanation_text(
366    explanation: Option<&str>,
367    rule_id: Option<&str>,
368    pack: Option<&str>,
369) -> String {
370    let trimmed = explanation.map(str::trim).filter(|text| !text.is_empty());
371
372    if let Some(text) = trimmed {
373        return text.to_string();
374    }
375
376    if let Some(rule) = rule_id {
377        return format!(
378            "Matched destructive pattern {rule}. No additional explanation is available yet. See pack documentation for details."
379        );
380    }
381
382    if let Some(pack_name) = pack {
383        return format!(
384            "Matched destructive pack {pack_name}. No additional explanation is available yet. See pack documentation for details."
385        );
386    }
387
388    "Matched a destructive pattern. No additional explanation is available yet. See pack documentation for details."
389        .to_string()
390}
391
392fn format_explanation_block(explanation: &str) -> String {
393    let mut lines = explanation.lines();
394    let Some(first) = lines.next() else {
395        return "Explanation:".to_string();
396    };
397
398    let mut output = format!("Explanation: {first}");
399    for line in lines {
400        output.push('\n');
401        output.push_str("             ");
402        output.push_str(line);
403    }
404    output
405}
406
407/// Format the denial message for the JSON output (plain text).
408#[must_use]
409pub fn format_denial_message(
410    command: &str,
411    reason: &str,
412    explanation: Option<&str>,
413    pack: Option<&str>,
414    pattern: Option<&str>,
415) -> String {
416    let explain_hint = format_explain_hint(command);
417    let rule_id = build_rule_id(pack, pattern);
418    let explanation_text = format_explanation_text(explanation, rule_id.as_deref(), pack);
419    let explanation_block = format_explanation_block(&explanation_text);
420
421    let rule_line = rule_id.as_deref().map_or_else(
422        || {
423            pack.map(|pack_name| format!("Pack: {pack_name}\n\n"))
424                .unwrap_or_default()
425        },
426        |rule| format!("Rule: {rule}\n\n"),
427    );
428
429    format!(
430        "BLOCKED by dcg\n\n\
431         {explain_hint}\n\n\
432         Reason: {reason}\n\n\
433         {explanation_block}\n\n\
434         {rule_line}\
435         Command: {command}\n\n\
436         If this operation is truly needed, ask the user for explicit \
437         permission and have them run the command manually."
438    )
439}
440
441/// Convert packs::Severity to theme::Severity
442fn to_output_severity(s: crate::packs::Severity) -> ThemeSeverity {
443    match s {
444        crate::packs::Severity::Critical => ThemeSeverity::Critical,
445        crate::packs::Severity::High => ThemeSeverity::High,
446        crate::packs::Severity::Medium => ThemeSeverity::Medium,
447        crate::packs::Severity::Low => ThemeSeverity::Low,
448    }
449}
450
451const MAX_SUGGESTIONS: usize = 4;
452
453/// Print a colorful warning to stderr for human visibility.
454#[allow(clippy::too_many_lines)]
455pub fn print_colorful_warning(
456    command: &str,
457    _reason: &str,
458    pack: Option<&str>,
459    pattern: Option<&str>,
460    explanation: Option<&str>,
461    allow_once_code: Option<&str>,
462    matched_span: Option<&MatchSpan>,
463    pattern_suggestions: &[PatternSuggestion],
464    severity: Option<crate::packs::Severity>,
465) {
466    #[cfg(feature = "rich-output")]
467    let console_instance = console();
468    let theme = auto_theme();
469
470    // Prepare content for DenialBox
471    let rule_id = build_rule_id(pack, pattern);
472    let pattern_display = rule_id.as_deref().or(pack).unwrap_or("unknown pattern");
473
474    let theme_severity = severity
475        .map(to_output_severity)
476        .unwrap_or(ThemeSeverity::High);
477
478    let explanation_text = explanation.map(str::trim).filter(|text| !text.is_empty());
479
480    // Create span for highlighting
481    let span = matched_span
482        .map(|s| HighlightSpan::new(s.start, s.end))
483        .unwrap_or_else(|| HighlightSpan::new(0, 0)); // Fallback
484
485    let suggestions_enabled = crate::output::suggestions_enabled();
486
487    // Convert suggestions to alternatives (platform-filtered, capped)
488    let filtered_suggestions: Vec<&PatternSuggestion> = if suggestions_enabled {
489        pattern_suggestions
490            .iter()
491            .filter(|s| s.platform.matches_current())
492            .collect()
493    } else {
494        Vec::new()
495    };
496    let mut alternatives: Vec<String> = filtered_suggestions
497        .iter()
498        .take(MAX_SUGGESTIONS)
499        .map(|s| format!("{}: {}", s.description, s.command))
500        .collect();
501
502    // Add contextual suggestion if available and no pattern suggestions
503    if suggestions_enabled && alternatives.is_empty() {
504        if let Some(sugg) = get_contextual_suggestion(command) {
505            alternatives.push(sugg.to_string());
506        }
507    }
508
509    let mut denial = DenialBox::new(command, span, pattern_display, theme_severity)
510        .with_alternatives(alternatives);
511
512    if let Some(text) = explanation_text {
513        denial = denial.with_explanation(text);
514    }
515
516    if let Some(code) = allow_once_code {
517        denial = denial.with_allow_once_code(code);
518    }
519
520    // Render the denial box
521    // Note: DcgConsole auto-detects stderr usage
522    eprintln!("{}", denial.render(&theme));
523
524    // Secondary info (Legacy: printed after box; Rich: could use panels)
525    #[cfg(feature = "rich-output")]
526    if !console_instance.is_plain() {
527        // In rich mode, we might want additional panels or info
528        // For now, let's keep it simple as DenialBox handles most things
529        // But we might want to print the "Learn more" links
530    }
531
532    // "Learn more" section (common to both modes, usually printed after the main warning)
533    let escaped_cmd = command.replace('"', "\\\"");
534    let truncated_cmd = truncate_for_display(&escaped_cmd, 45);
535    let explain_cmd = format!("dcg explain \"{truncated_cmd}\"");
536
537    // Let's print the footer links
538    let footer_style = if theme.colors_enabled { "\x1b[90m" } else { "" }; // Bright black
539    let reset = if theme.colors_enabled { "\x1b[0m" } else { "" };
540    let cyan = if theme.colors_enabled { "\x1b[36m" } else { "" };
541
542    eprintln!("{footer_style}Learn more:{reset}");
543    eprintln!("  $ {cyan}{explain_cmd}{reset}");
544
545    if let Some(ref rule) = rule_id {
546        eprintln!("  $ {cyan}dcg allowlist add {rule} --project{reset}");
547    }
548
549    eprintln!();
550    eprintln!("{footer_style}False positive? File an issue:{reset}");
551    eprintln!(
552        "{footer_style}https://github.com/Dicklesworthstone/destructive_command_guard/issues/new?template=false_positive.yml{reset}"
553    );
554    eprintln!();
555}
556
557#[cfg(feature = "rich-output")]
558#[allow(dead_code)] // TODO: Integrate into rich output path
559fn render_suggestions_panel(suggestions: &[PatternSuggestion]) -> String {
560    use rich_rust::r#box::ROUNDED;
561    use rich_rust::prelude::*;
562
563    // Build content as a Vec of lines, then join
564    let mut lines = Vec::new();
565    if !crate::output::suggestions_enabled() {
566        return String::new();
567    }
568
569    let filtered: Vec<&PatternSuggestion> = suggestions
570        .iter()
571        .filter(|s| s.platform.matches_current())
572        .take(MAX_SUGGESTIONS)
573        .collect();
574
575    for (i, s) in filtered.iter().enumerate() {
576        lines.push(format!("[bold cyan]{}.[/] {}", i + 1, s.description));
577        lines.push(format!("   [green]$[/] [cyan]{}[/]", s.command));
578    }
579    let content_str = lines.join("\n");
580
581    let width = crate::output::terminal_width() as usize;
582    Panel::from_text(&content_str)
583        .title("[yellow bold] 💡 Suggestions [/]")
584        .box_style(&ROUNDED)
585        .border_style(Style::new().color(Color::parse("yellow").unwrap_or_default()))
586        .render_plain(width)
587}
588
589/// Truncate a string for display, appending "..." if truncated.
590fn truncate_for_display(s: &str, max_len: usize) -> String {
591    if s.len() <= max_len {
592        s.to_string()
593    } else {
594        // Find a safe UTF-8 boundary for truncation
595        let target = max_len.saturating_sub(3);
596        let boundary = s
597            .char_indices()
598            .take_while(|(i, _)| *i < target)
599            .last()
600            .map_or(0, |(i, c)| i + c.len_utf8());
601        format!("{}...", &s[..boundary])
602    }
603}
604
605/// Get context-specific suggestion based on the blocked command.
606fn get_contextual_suggestion(command: &str) -> Option<&'static str> {
607    if command.contains("reset") || command.contains("checkout") {
608        Some("Consider using 'git stash' first to save your changes.")
609    } else if command.contains("clean") {
610        Some("Use 'git clean -n' first to preview what would be deleted.")
611    } else if command.contains("push") && command.contains("force") {
612        Some("Consider using '--force-with-lease' for safer force pushing.")
613    } else if command.contains("rm -rf") || command.contains("rm -r") {
614        Some("Verify the path carefully before running rm -rf manually.")
615    } else if command.contains("DROP") || command.contains("drop") {
616        Some("Consider backing up the database/table before dropping.")
617    } else if command.contains("kubectl") && command.contains("delete") {
618        Some("Use 'kubectl delete --dry-run=client' to preview changes first.")
619    } else if command.contains("docker") && command.contains("prune") {
620        Some("Use 'docker system df' to see what would be affected.")
621    } else if command.contains("terraform") && command.contains("destroy") {
622        Some("Use 'terraform plan -destroy' to preview changes first.")
623    } else {
624        None
625    }
626}
627
628/// Output a denial response to stdout (JSON for hook protocol).
629#[cold]
630#[inline(never)]
631#[allow(clippy::too_many_arguments)]
632pub fn output_denial_for_protocol(
633    protocol: HookProtocol,
634    command: &str,
635    reason: &str,
636    pack: Option<&str>,
637    pattern: Option<&str>,
638    explanation: Option<&str>,
639    allow_once: Option<&AllowOnceInfo>,
640    matched_span: Option<&MatchSpan>,
641    severity: Option<crate::packs::Severity>,
642    confidence: Option<f64>,
643    pattern_suggestions: &[PatternSuggestion],
644) {
645    // Print colorful warning to stderr (visible to user)
646    let allow_once_code = allow_once.map(|info| info.code.as_str());
647    print_colorful_warning(
648        command,
649        reason,
650        pack,
651        pattern,
652        explanation,
653        allow_once_code,
654        matched_span,
655        pattern_suggestions,
656        severity,
657    );
658
659    // Build JSON response for hook protocol (stdout)
660    let message = format_denial_message(command, reason, explanation, pack, pattern);
661    let rule_id = build_rule_id(pack, pattern);
662    let remediation = allow_once.map(|info| {
663        let explanation_text = format_explanation_text(explanation, rule_id.as_deref(), pack);
664        Remediation {
665            safe_alternative: get_contextual_suggestion(command).map(String::from),
666            explanation: explanation_text,
667            allow_once_command: format!("dcg allow-once {}", info.code),
668        }
669    });
670
671    let stdout = io::stdout();
672    let mut handle = stdout.lock();
673
674    match protocol {
675        HookProtocol::ClaudeCompatible => {
676            let output = HookOutput {
677                hook_specific_output: HookSpecificOutput {
678                    hook_event_name: "PreToolUse",
679                    permission_decision: "deny",
680                    permission_decision_reason: Cow::Owned(message),
681                    allow_once_code: allow_once.map(|info| info.code.clone()),
682                    allow_once_full_hash: allow_once.map(|info| info.full_hash.clone()),
683                    rule_id,
684                    pack_id: pack.map(String::from),
685                    severity,
686                    confidence,
687                    remediation,
688                },
689            };
690
691            let _ = serde_json::to_writer(&mut handle, &output);
692            let _ = writeln!(handle);
693        }
694        HookProtocol::Copilot => {
695            let output = CopilotHookOutput {
696                continue_execution: false,
697                stop_reason: Cow::Owned(format!("BLOCKED by dcg: {reason}")),
698                permission_decision: "deny",
699                permission_decision_reason: Cow::Owned(message),
700                allow_once_code: allow_once.map(|info| info.code.clone()),
701                allow_once_full_hash: allow_once.map(|info| info.full_hash.clone()),
702                rule_id,
703                pack_id: pack.map(String::from),
704                severity,
705                confidence,
706                remediation,
707            };
708
709            let _ = serde_json::to_writer(&mut handle, &output);
710            let _ = writeln!(handle);
711        }
712    }
713}
714
715/// Output a denial response to stdout (JSON for hook protocol).
716#[cold]
717#[inline(never)]
718#[allow(clippy::too_many_arguments)]
719pub fn output_denial(
720    command: &str,
721    reason: &str,
722    pack: Option<&str>,
723    pattern: Option<&str>,
724    explanation: Option<&str>,
725    allow_once: Option<&AllowOnceInfo>,
726    matched_span: Option<&MatchSpan>,
727    severity: Option<crate::packs::Severity>,
728    confidence: Option<f64>,
729    pattern_suggestions: &[PatternSuggestion],
730) {
731    output_denial_for_protocol(
732        HookProtocol::ClaudeCompatible,
733        command,
734        reason,
735        pack,
736        pattern,
737        explanation,
738        allow_once,
739        matched_span,
740        severity,
741        confidence,
742        pattern_suggestions,
743    );
744}
745
746/// Output a warning to stderr (no JSON deny; command is allowed).
747#[cold]
748#[inline(never)]
749pub fn output_warning(
750    command: &str,
751    reason: &str,
752    pack: Option<&str>,
753    pattern: Option<&str>,
754    explanation: Option<&str>,
755) {
756    let stderr = io::stderr();
757    let mut handle = stderr.lock();
758
759    let _ = writeln!(handle);
760    let _ = writeln!(
761        handle,
762        "{} {}",
763        "dcg WARNING (allowed by policy):".yellow().bold(),
764        reason
765    );
766
767    // Build rule_id from pack and pattern
768    let rule_id = build_rule_id(pack, pattern);
769    let explanation_text = format_explanation_text(explanation, rule_id.as_deref(), pack);
770    let mut explanation_lines = explanation_text.lines();
771
772    if let Some(first) = explanation_lines.next() {
773        let _ = writeln!(handle, "  {} {}", "Explanation:".bright_black(), first);
774        for line in explanation_lines {
775            let _ = writeln!(handle, "               {line}");
776        }
777    }
778
779    if let Some(ref rule) = rule_id {
780        let _ = writeln!(handle, "  {} {}", "Rule:".bright_black(), rule);
781    } else if let Some(pack_name) = pack {
782        let _ = writeln!(handle, "  {} {}", "Pack:".bright_black(), pack_name);
783    }
784
785    let _ = writeln!(handle, "  {} {}", "Command:".bright_black(), command);
786    let _ = writeln!(
787        handle,
788        "  {}",
789        "No hook JSON deny was emitted; this warning is informational.".bright_black()
790    );
791}
792
793/// Log a blocked command to a file (if logging is enabled).
794///
795/// # Errors
796///
797/// Returns any I/O errors encountered while creating directories or appending
798/// to the log file.
799pub fn log_blocked_command(
800    log_file: &str,
801    command: &str,
802    reason: &str,
803    pack: Option<&str>,
804) -> io::Result<()> {
805    use std::fs::OpenOptions;
806
807    // Expand ~ in path
808    let path = if log_file.starts_with("~/") {
809        dirs::home_dir().map_or_else(
810            || std::path::PathBuf::from(log_file),
811            |h| h.join(&log_file[2..]),
812        )
813    } else {
814        std::path::PathBuf::from(log_file)
815    };
816
817    // Ensure parent directory exists
818    if let Some(parent) = path.parent() {
819        std::fs::create_dir_all(parent)?;
820    }
821
822    let mut file = OpenOptions::new().create(true).append(true).open(path)?;
823
824    let timestamp = chrono_lite_timestamp();
825    let pack_str = pack.unwrap_or("unknown");
826
827    writeln!(file, "[{timestamp}] [{pack_str}] {reason}")?;
828    writeln!(file, "  Command: {command}")?;
829    writeln!(file)?;
830
831    Ok(())
832}
833
834/// Log a budget skip to a file (if logging is enabled).
835///
836/// # Errors
837///
838/// Returns any I/O errors encountered while creating directories or appending
839/// to the log file.
840pub fn log_budget_skip(
841    log_file: &str,
842    command: &str,
843    stage: &str,
844    elapsed: Duration,
845    budget: Duration,
846) -> io::Result<()> {
847    use std::fs::OpenOptions;
848
849    // Expand ~ in path
850    let path = if log_file.starts_with("~/") {
851        dirs::home_dir().map_or_else(
852            || std::path::PathBuf::from(log_file),
853            |h| h.join(&log_file[2..]),
854        )
855    } else {
856        std::path::PathBuf::from(log_file)
857    };
858
859    // Ensure parent directory exists
860    if let Some(parent) = path.parent() {
861        std::fs::create_dir_all(parent)?;
862    }
863
864    let mut file = OpenOptions::new().create(true).append(true).open(path)?;
865
866    let timestamp = chrono_lite_timestamp();
867    writeln!(
868        file,
869        "[{timestamp}] [budget] evaluation skipped due to budget at {stage}"
870    )?;
871    writeln!(
872        file,
873        "  Budget: {}ms, Elapsed: {}ms",
874        budget.as_millis(),
875        elapsed.as_millis()
876    )?;
877    writeln!(file, "  Command: {command}")?;
878    writeln!(file)?;
879
880    Ok(())
881}
882
883/// Simple timestamp without chrono dependency.
884/// Returns Unix epoch seconds as a string (e.g., "1704672000").
885fn chrono_lite_timestamp() -> String {
886    use std::time::{SystemTime, UNIX_EPOCH};
887
888    let duration = SystemTime::now()
889        .duration_since(UNIX_EPOCH)
890        .unwrap_or_default();
891
892    let secs = duration.as_secs();
893    format!("{secs}")
894}
895
896#[cfg(test)]
897mod tests {
898    use super::*;
899    use std::sync::Mutex;
900
901    static ENV_LOCK: Mutex<()> = Mutex::new(());
902
903    struct EnvVarGuard {
904        key: &'static str,
905        previous: Option<String>,
906    }
907
908    impl EnvVarGuard {
909        fn set(key: &'static str, value: &str) -> Self {
910            let previous = std::env::var(key).ok();
911            // SAFETY: We hold ENV_LOCK during all tests that use this guard,
912            // ensuring no concurrent access to environment variables.
913            unsafe { std::env::set_var(key, value) };
914            Self { key, previous }
915        }
916
917        #[allow(dead_code)]
918        fn remove(key: &'static str) -> Self {
919            let previous = std::env::var(key).ok();
920            // SAFETY: We hold ENV_LOCK during all tests that use this guard,
921            // ensuring no concurrent access to environment variables.
922            unsafe { std::env::remove_var(key) };
923            Self { key, previous }
924        }
925    }
926
927    impl Drop for EnvVarGuard {
928        fn drop(&mut self) {
929            if let Some(value) = self.previous.take() {
930                // SAFETY: We hold ENV_LOCK during all tests that use this guard,
931                // ensuring no concurrent access to environment variables.
932                unsafe { std::env::set_var(self.key, value) };
933            } else {
934                // SAFETY: We hold ENV_LOCK during all tests that use this guard,
935                // ensuring no concurrent access to environment variables.
936                unsafe { std::env::remove_var(self.key) };
937            }
938        }
939    }
940
941    #[test]
942    fn test_parse_valid_bash_input() {
943        let json = r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#;
944        let input: HookInput = serde_json::from_str(json).unwrap();
945        assert_eq!(extract_command(&input), Some("git status".to_string()));
946    }
947
948    #[test]
949    fn test_parse_non_bash_input() {
950        let json = r#"{"tool_name":"Read","tool_input":{"command":"git status"}}"#;
951        let input: HookInput = serde_json::from_str(json).unwrap();
952        assert_eq!(extract_command(&input), None);
953    }
954
955    #[test]
956    fn test_parse_missing_command() {
957        let json = r#"{"tool_name":"Bash","tool_input":{}}"#;
958        let input: HookInput = serde_json::from_str(json).unwrap();
959        assert_eq!(extract_command(&input), None);
960    }
961
962    #[test]
963    fn test_parse_copilot_tool_input_command() {
964        let json = r#"{"event":"pre-tool-use","toolName":"run_shell_command","toolInput":{"command":"git status"}}"#;
965        let input: HookInput = serde_json::from_str(json).unwrap();
966        assert_eq!(extract_command(&input), Some("git status".to_string()));
967        assert_eq!(detect_protocol(&input), HookProtocol::Copilot);
968    }
969
970    #[test]
971    fn test_parse_copilot_tool_args_json_string() {
972        let json = r#"{"event":"pre-tool-use","toolName":"bash","toolArgs":"{\"command\":\"rm -rf /tmp/build\"}"}"#;
973        let input: HookInput = serde_json::from_str(json).unwrap();
974        assert_eq!(
975            extract_command(&input),
976            Some("rm -rf /tmp/build".to_string())
977        );
978        assert_eq!(detect_protocol(&input), HookProtocol::Copilot);
979    }
980
981    #[test]
982    fn test_parse_non_string_command() {
983        let json = r#"{"tool_name":"Bash","tool_input":{"command":123}}"#;
984        let input: HookInput = serde_json::from_str(json).unwrap();
985        assert_eq!(extract_command(&input), None);
986    }
987
988    #[test]
989    fn test_format_denial_message_includes_explanation_and_rule() {
990        let message = format_denial_message(
991            "git reset --hard",
992            "destructive",
993            Some("This is irreversible."),
994            Some("core.git"),
995            Some("reset-hard"),
996        );
997
998        assert!(message.contains("Reason: destructive"));
999        assert!(message.contains("Explanation: This is irreversible."));
1000        assert!(message.contains("Rule: core.git:reset-hard"));
1001        assert!(message.contains("Tip: dcg explain"));
1002    }
1003
1004    #[test]
1005    fn test_env_var_guard_restores_value() {
1006        let _lock = ENV_LOCK.lock().unwrap();
1007        let key = "DCG_TEST_ENV_GUARD";
1008        // SAFETY: We hold ENV_LOCK to prevent concurrent env modifications
1009        unsafe { std::env::remove_var(key) };
1010
1011        {
1012            let _guard = EnvVarGuard::set(key, "1");
1013            assert_eq!(std::env::var(key).as_deref(), Ok("1"));
1014        }
1015
1016        assert!(std::env::var(key).is_err());
1017    }
1018}