use crate::evaluator::MatchSpan;
use crate::highlight::HighlightSpan;
use crate::output::auto_theme;
use crate::output::denial::DenialBox;
use crate::output::theme::Severity as ThemeSeverity;
use crate::packs::PatternSuggestion;
use colored::Colorize;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::io::{self, IsTerminal, Read, Write};
use std::time::Duration;
#[derive(Debug, Deserialize)]
pub struct HookInput {
pub event: Option<String>,
#[serde(alias = "hookEventName")]
pub hook_event_name: Option<String>,
pub session_id: Option<String>,
pub transcript_path: Option<String>,
pub cwd: Option<String>,
pub timestamp: Option<String>,
#[serde(alias = "toolName")]
pub tool_name: Option<String>,
#[serde(alias = "toolInput")]
pub tool_input: Option<ToolInput>,
#[serde(alias = "toolArgs")]
pub tool_args: Option<serde_json::Value>,
#[serde(alias = "turnId")]
pub turn_id: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ToolInput {
pub command: Option<serde_json::Value>,
}
#[derive(Debug, Serialize)]
pub struct HookOutput<'a> {
#[serde(rename = "hookSpecificOutput")]
pub hook_specific_output: HookSpecificOutput<'a>,
}
#[derive(Debug, Serialize)]
pub struct HookSpecificOutput<'a> {
#[serde(rename = "hookEventName")]
pub hook_event_name: &'static str,
#[serde(rename = "permissionDecision")]
pub permission_decision: &'static str,
#[serde(rename = "permissionDecisionReason")]
pub permission_decision_reason: Cow<'a, str>,
#[serde(rename = "allowOnceCode", skip_serializing_if = "Option::is_none")]
pub allow_once_code: Option<String>,
#[serde(rename = "allowOnceFullHash", skip_serializing_if = "Option::is_none")]
pub allow_once_full_hash: Option<String>,
#[serde(rename = "ruleId", skip_serializing_if = "Option::is_none")]
pub rule_id: Option<String>,
#[serde(rename = "packId", skip_serializing_if = "Option::is_none")]
pub pack_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub severity: Option<crate::packs::Severity>,
#[serde(skip_serializing_if = "Option::is_none")]
pub confidence: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub remediation: Option<Remediation>,
}
#[derive(Debug, Serialize)]
pub struct CopilotHookOutput<'a> {
#[serde(rename = "continue")]
pub continue_execution: bool,
#[serde(rename = "stopReason")]
pub stop_reason: Cow<'a, str>,
#[serde(rename = "permissionDecision")]
pub permission_decision: &'static str,
#[serde(rename = "permissionDecisionReason")]
pub permission_decision_reason: Cow<'a, str>,
#[serde(rename = "allowOnceCode", skip_serializing_if = "Option::is_none")]
pub allow_once_code: Option<String>,
#[serde(rename = "allowOnceFullHash", skip_serializing_if = "Option::is_none")]
pub allow_once_full_hash: Option<String>,
#[serde(rename = "ruleId", skip_serializing_if = "Option::is_none")]
pub rule_id: Option<String>,
#[serde(rename = "packId", skip_serializing_if = "Option::is_none")]
pub pack_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub severity: Option<crate::packs::Severity>,
#[serde(skip_serializing_if = "Option::is_none")]
pub confidence: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub remediation: Option<Remediation>,
}
#[derive(Debug, Serialize)]
pub struct GeminiHookOutput<'a> {
pub decision: &'static str,
pub reason: Cow<'a, str>,
#[serde(rename = "systemMessage", skip_serializing_if = "Option::is_none")]
pub system_message: Option<Cow<'a, str>>,
#[serde(rename = "allowOnceCode", skip_serializing_if = "Option::is_none")]
pub allow_once_code: Option<String>,
#[serde(rename = "allowOnceFullHash", skip_serializing_if = "Option::is_none")]
pub allow_once_full_hash: Option<String>,
#[serde(rename = "ruleId", skip_serializing_if = "Option::is_none")]
pub rule_id: Option<String>,
#[serde(rename = "packId", skip_serializing_if = "Option::is_none")]
pub pack_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub severity: Option<crate::packs::Severity>,
#[serde(skip_serializing_if = "Option::is_none")]
pub confidence: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub remediation: Option<Remediation>,
}
#[derive(Debug, Serialize)]
pub struct HermesHookOutput<'a> {
pub decision: &'static str,
pub reason: Cow<'a, str>,
pub action: &'static str,
pub message: Cow<'a, str>,
#[serde(rename = "allowOnceCode", skip_serializing_if = "Option::is_none")]
pub allow_once_code: Option<String>,
#[serde(rename = "allowOnceFullHash", skip_serializing_if = "Option::is_none")]
pub allow_once_full_hash: Option<String>,
#[serde(rename = "ruleId", skip_serializing_if = "Option::is_none")]
pub rule_id: Option<String>,
#[serde(rename = "packId", skip_serializing_if = "Option::is_none")]
pub pack_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub severity: Option<crate::packs::Severity>,
#[serde(skip_serializing_if = "Option::is_none")]
pub confidence: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub remediation: Option<Remediation>,
}
#[derive(Debug, Serialize)]
pub struct GrokHookOutput<'a> {
pub decision: &'static str,
pub reason: Cow<'a, str>,
#[serde(rename = "allowOnceCode", skip_serializing_if = "Option::is_none")]
pub allow_once_code: Option<String>,
#[serde(rename = "allowOnceFullHash", skip_serializing_if = "Option::is_none")]
pub allow_once_full_hash: Option<String>,
#[serde(rename = "ruleId", skip_serializing_if = "Option::is_none")]
pub rule_id: Option<String>,
#[serde(rename = "packId", skip_serializing_if = "Option::is_none")]
pub pack_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub severity: Option<crate::packs::Severity>,
#[serde(skip_serializing_if = "Option::is_none")]
pub confidence: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub remediation: Option<Remediation>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookProtocol {
ClaudeCompatible,
Copilot,
Gemini,
Codex,
Hermes,
Grok,
}
#[derive(Debug, Clone)]
pub struct AllowOnceInfo {
pub code: String,
pub full_hash: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct Remediation {
#[serde(rename = "safeAlternative", skip_serializing_if = "Option::is_none")]
pub safe_alternative: Option<String>,
pub explanation: String,
#[serde(rename = "allowOnceCommand")]
pub allow_once_command: String,
}
#[derive(Debug)]
pub enum HookResult {
Allow,
Deny {
command: String,
reason: String,
pack: Option<String>,
pattern_name: Option<String>,
},
Skip,
ParseError,
}
#[derive(Debug)]
pub enum HookReadError {
Io(io::Error),
InputTooLarge(usize),
Json(serde_json::Error),
}
pub fn read_hook_input(max_bytes: usize) -> Result<HookInput, HookReadError> {
let mut input = String::with_capacity(256);
{
let stdin = io::stdin();
let mut handle = stdin.lock().take(max_bytes as u64 + 1);
handle
.read_to_string(&mut input)
.map_err(HookReadError::Io)?;
}
if input.len() > max_bytes {
return Err(HookReadError::InputTooLarge(input.len()));
}
serde_json::from_str(&input).map_err(HookReadError::Json)
}
#[must_use]
pub fn detect_protocol(input: &HookInput) -> HookProtocol {
let tool_name = input
.tool_name
.as_deref()
.map(str::to_ascii_lowercase)
.unwrap_or_default();
let hook_event_name = input.hook_event_name.as_deref().unwrap_or_default();
let is_hermes_event = hook_event_name == "pre_tool_call";
let is_hermes_tool = tool_name == "terminal";
if is_hermes_event || is_hermes_tool {
if input.event.is_none() && input.tool_args.is_none() {
return HookProtocol::Hermes;
}
}
let is_grok_event = hook_event_name == "pre_tool_use";
let is_grok_tool = tool_name == "run_terminal_cmd";
if (is_grok_event || is_grok_tool) && input.event.is_none() && input.tool_args.is_none() {
return HookProtocol::Grok;
}
if input.event.is_some() || input.tool_args.is_some() {
return HookProtocol::Copilot;
}
let is_claude_compatible_shell_tool = matches!(
tool_name.as_str(),
"bash" | "launch-process" | "powershell" | "pwsh"
);
let has_codex_turn_id = input
.turn_id
.as_deref()
.is_some_and(|s| !s.trim().is_empty());
if is_claude_compatible_shell_tool && has_codex_turn_id {
return HookProtocol::Codex;
}
if is_claude_compatible_shell_tool {
return HookProtocol::ClaudeCompatible;
}
let is_claude_event =
hook_event_name.is_empty() || hook_event_name.eq_ignore_ascii_case("pretooluse");
let has_claude_env = std::env::var_os("CLAUDE_CODE").is_some()
|| std::env::var_os("CLAUDE_SESSION_ID").is_some();
if has_claude_env && is_claude_event {
return HookProtocol::ClaudeCompatible;
}
let is_gemini_tool = matches!(
tool_name.as_str(),
"run_shell_command" | "run-shell-command"
);
let is_gemini_event = hook_event_name.eq_ignore_ascii_case("beforetool");
let has_gemini_envelope = input.session_id.is_some()
|| input.transcript_path.is_some()
|| input.cwd.is_some()
|| input.timestamp.is_some();
if is_gemini_event && is_gemini_tool {
return HookProtocol::Gemini;
}
if is_gemini_event && has_gemini_envelope {
return HookProtocol::Gemini;
}
if has_gemini_envelope && is_gemini_tool {
return HookProtocol::Gemini;
}
if is_gemini_tool {
return HookProtocol::Copilot;
}
HookProtocol::ClaudeCompatible
}
pub(crate) fn is_supported_shell_tool(tool_name: Option<&str>) -> bool {
let Some(tool_name) = tool_name else {
return false;
};
matches!(
tool_name.to_ascii_lowercase().as_str(),
"bash"
| "launch-process"
| "powershell"
| "pwsh"
| "run_shell_command"
| "run-shell-command"
| "terminal"
| "run_terminal_cmd"
)
}
pub(crate) fn is_shell_hook_candidate(input: &HookInput) -> bool {
if is_supported_shell_tool(input.tool_name.as_deref()) {
return true;
}
input.tool_name.is_none()
&& matches!(detect_protocol(input), HookProtocol::Copilot)
&& (input.tool_input.is_some() || input.tool_args.is_some())
}
fn extract_command_from_tool_input(tool_input: &ToolInput) -> Option<String> {
match tool_input.command.as_ref() {
Some(serde_json::Value::String(s)) if !s.is_empty() => Some(s.clone()),
_ => None,
}
}
fn extract_command_from_tool_args(tool_args: &serde_json::Value) -> Option<String> {
match tool_args {
serde_json::Value::Object(map) => map.get("command").and_then(|v| match v {
serde_json::Value::String(s) if !s.is_empty() => Some(s.clone()),
_ => None,
}),
serde_json::Value::String(s) => {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(s) {
extract_command_from_tool_args(&parsed)
} else if s.is_empty() {
None
} else {
Some(s.clone())
}
}
_ => None,
}
}
#[must_use]
pub fn extract_command_with_protocol(input: &HookInput) -> Option<(String, HookProtocol)> {
let protocol = detect_protocol(input);
if !is_shell_hook_candidate(input) {
return None;
}
if let Some(tool_input) = input.tool_input.as_ref() {
if let Some(command) = extract_command_from_tool_input(tool_input) {
return Some((command, protocol));
}
}
if let Some(tool_args) = input.tool_args.as_ref() {
if let Some(command) = extract_command_from_tool_args(tool_args) {
return Some((command, protocol));
}
}
None
}
#[must_use]
pub fn extract_command(input: &HookInput) -> Option<String> {
extract_command_with_protocol(input).map(|(command, _)| command)
}
pub fn configure_colors() {
if std::env::var_os("NO_COLOR").is_some() || crate::output::env_flag_enabled("DCG_NO_COLOR") {
colored::control::set_override(false);
return;
}
if !io::stderr().is_terminal() {
colored::control::set_override(false);
}
}
fn format_explain_hint(command: &str) -> String {
let escaped = command.replace('"', "\\\"");
format!("Tip: dcg explain \"{escaped}\"")
}
fn build_rule_id(pack: Option<&str>, pattern: Option<&str>) -> Option<String> {
match (pack, pattern) {
(Some(pack_id), Some(pattern_name)) => Some(format!("{pack_id}:{pattern_name}")),
_ => None,
}
}
fn format_explanation_text(
explanation: Option<&str>,
rule_id: Option<&str>,
pack: Option<&str>,
) -> String {
let trimmed = explanation.map(str::trim).filter(|text| !text.is_empty());
if let Some(text) = trimmed {
return text.to_string();
}
if let Some(rule) = rule_id {
return format!(
"Matched destructive pattern {rule}. No additional explanation is available yet. See pack documentation for details."
);
}
if let Some(pack_name) = pack {
return format!(
"Matched destructive pack {pack_name}. No additional explanation is available yet. See pack documentation for details."
);
}
"Matched a destructive pattern. No additional explanation is available yet. See pack documentation for details."
.to_string()
}
fn format_explanation_block(explanation: &str) -> String {
let mut lines = explanation.lines();
let Some(first) = lines.next() else {
return "Explanation:".to_string();
};
let mut output = format!("Explanation: {first}");
for line in lines {
output.push('\n');
output.push_str(" ");
output.push_str(line);
}
output
}
#[must_use]
pub fn format_denial_message(
command: &str,
reason: &str,
explanation: Option<&str>,
pack: Option<&str>,
pattern: Option<&str>,
) -> String {
let explain_hint = format_explain_hint(command);
let rule_id = build_rule_id(pack, pattern);
let explanation_text = format_explanation_text(explanation, rule_id.as_deref(), pack);
let explanation_block = format_explanation_block(&explanation_text);
let rule_line = rule_id.as_deref().map_or_else(
|| {
pack.map(|pack_name| format!("Pack: {pack_name}\n\n"))
.unwrap_or_default()
},
|rule| format!("Rule: {rule}\n\n"),
);
format!(
"BLOCKED by dcg\n\n\
{explain_hint}\n\n\
Reason: {reason}\n\n\
{explanation_block}\n\n\
{rule_line}\
Command: {command}\n\n\
If this operation is truly needed, ask the user for explicit \
permission and have them run the command manually."
)
}
fn to_output_severity(s: crate::packs::Severity) -> ThemeSeverity {
match s {
crate::packs::Severity::Critical => ThemeSeverity::Critical,
crate::packs::Severity::High => ThemeSeverity::High,
crate::packs::Severity::Medium => ThemeSeverity::Medium,
crate::packs::Severity::Low => ThemeSeverity::Low,
}
}
const MAX_SUGGESTIONS: usize = 4;
#[allow(clippy::too_many_lines)]
pub(crate) fn print_colorful_warning_to(
writer: &mut impl Write,
command: &str,
_reason: &str,
pack: Option<&str>,
pattern: Option<&str>,
explanation: Option<&str>,
allow_once_code: Option<&str>,
matched_span: Option<&MatchSpan>,
pattern_suggestions: &[PatternSuggestion],
severity: Option<crate::packs::Severity>,
branch_context: Option<&crate::evaluator::BranchContext>,
audience: WarningAudience,
) {
let theme = auto_theme();
let rule_id = build_rule_id(pack, pattern);
let pattern_display = rule_id.as_deref().or(pack).unwrap_or("unknown pattern");
let theme_severity = severity
.map(to_output_severity)
.unwrap_or(ThemeSeverity::High);
let explanation_text = explanation.map(str::trim).filter(|text| !text.is_empty());
let span = matched_span
.map(|s| HighlightSpan::new(s.start, s.end))
.unwrap_or_else(|| HighlightSpan::new(0, 0));
let alternatives = pattern_suggestion_alternatives(
command,
crate::output::suggestions_enabled(),
pattern_suggestions,
);
let mut denial = DenialBox::new(command, span, pattern_display, theme_severity)
.with_alternatives(alternatives);
if let (Some(pack_id), Some(pattern_name)) = (pack, pattern) {
if let Some(regex) = crate::highlight::find_pattern_regex(pack_id, pattern_name) {
denial = denial.with_pattern_regex(regex);
}
}
if let Some(text) = explanation_text {
denial = denial.with_explanation(text);
}
if audience == WarningAudience::HumanOperator
&& let Some(code) = allow_once_code
{
denial = denial.with_allow_once_code(code);
}
if let Some(ctx) = branch_context {
if let Some(name) = &ctx.branch_name {
denial = denial.with_branch_context(name, ctx.is_protected);
}
}
let _ = writeln!(writer, "{}", denial.render(&theme));
let escaped_cmd = command.replace('"', "\\\"");
let truncated_cmd = truncate_for_display(&escaped_cmd, 45);
let explain_cmd = format!("dcg explain \"{truncated_cmd}\"");
let footer_style = if theme.colors_enabled { "\x1b[90m" } else { "" };
let reset = if theme.colors_enabled { "\x1b[0m" } else { "" };
let cyan = if theme.colors_enabled { "\x1b[36m" } else { "" };
match audience {
WarningAudience::HumanOperator => {
let _ = writeln!(writer, "{footer_style}Learn more:{reset}");
let _ = writeln!(writer, " $ {cyan}{explain_cmd}{reset}");
if let Some(ref rule) = rule_id {
let _ = writeln!(
writer,
" $ {cyan}dcg allowlist add {rule} --project{reset}"
);
}
let _ = writeln!(writer);
let _ = writeln!(
writer,
"{footer_style}False positive? File an issue:{reset}"
);
let _ = writeln!(
writer,
"{footer_style}https://github.com/Dicklesworthstone/destructive_command_guard/issues/new?template=false_positive.yml{reset}"
);
let _ = writeln!(writer);
}
WarningAudience::CodexModel => {
if let Some(ref rule) = rule_id {
let _ = writeln!(writer, "{footer_style}Rule: {rule}{reset}");
}
let _ = writeln!(
writer,
"{footer_style}This command is blocked. Do not retry it, create a bypass, or change dcg policy yourself. Ask the user for explicit permission if the operation is truly required.{reset}"
);
let _ = writeln!(writer);
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum WarningAudience {
HumanOperator,
CodexModel,
}
fn pattern_suggestion_alternatives(
command: &str,
suggestions_enabled: bool,
pattern_suggestions: &[PatternSuggestion],
) -> Vec<String> {
if !suggestions_enabled {
return Vec::new();
}
let mut alternatives: Vec<String> = pattern_suggestions
.iter()
.filter(|suggestion| suggestion.platform.matches_current())
.take(MAX_SUGGESTIONS)
.map(|suggestion| format!("{}: {}", suggestion.description, suggestion.command))
.collect();
if alternatives.is_empty() {
if let Some(suggestion) = get_contextual_suggestion(command) {
alternatives.push(suggestion.to_string());
}
}
alternatives
}
#[allow(clippy::too_many_lines)]
pub fn print_colorful_warning(
command: &str,
reason: &str,
pack: Option<&str>,
pattern: Option<&str>,
explanation: Option<&str>,
allow_once_code: Option<&str>,
matched_span: Option<&MatchSpan>,
pattern_suggestions: &[PatternSuggestion],
severity: Option<crate::packs::Severity>,
) {
let stderr = io::stderr();
let mut handle = stderr.lock();
print_colorful_warning_to(
&mut handle,
command,
reason,
pack,
pattern,
explanation,
allow_once_code,
matched_span,
pattern_suggestions,
severity,
None,
WarningAudience::HumanOperator,
);
}
fn truncate_for_display(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
let target = max_len.saturating_sub(3);
let boundary = s
.char_indices()
.take_while(|(i, _)| *i < target)
.last()
.map_or(0, |(i, c)| i + c.len_utf8());
format!("{}...", &s[..boundary])
}
}
fn get_contextual_suggestion(command: &str) -> Option<&'static str> {
if command.contains("reset") || command.contains("checkout") {
Some("Consider using 'git stash' first to save your changes.")
} else if command.contains("clean") {
Some("Use 'git clean -n' first to preview what would be deleted.")
} else if command.contains("push") && command.contains("force") {
Some("Consider using '--force-with-lease' for safer force pushing.")
} else if command.contains("rm -rf") || command.contains("rm -r") {
Some("Verify the path carefully before running rm -rf manually.")
} else if command.contains("DROP") || command.contains("drop") {
Some("Consider backing up the database/table before dropping.")
} else if command.contains("kubectl") && command.contains("delete") {
Some("Use 'kubectl delete --dry-run=client' to preview changes first.")
} else if command.contains("docker") && command.contains("prune") {
Some("Use 'docker system df' to see what would be affected.")
} else if command.contains("terraform") && command.contains("destroy") {
Some("Use 'terraform plan -destroy' to preview changes first.")
} else {
None
}
}
#[cold]
#[inline(never)]
#[allow(clippy::too_many_arguments)]
pub fn write_denial_to(
stdout: &mut impl Write,
stderr: &mut impl Write,
protocol: HookProtocol,
command: &str,
reason: &str,
pack: Option<&str>,
pattern: Option<&str>,
explanation: Option<&str>,
allow_once: Option<&AllowOnceInfo>,
matched_span: Option<&MatchSpan>,
severity: Option<crate::packs::Severity>,
confidence: Option<f64>,
pattern_suggestions: &[PatternSuggestion],
branch_context: Option<&crate::evaluator::BranchContext>,
) {
let allow_once_code = allow_once.map(|info| info.code.as_str());
let warning_audience = match protocol {
HookProtocol::Codex => WarningAudience::CodexModel,
HookProtocol::ClaudeCompatible
| HookProtocol::Copilot
| HookProtocol::Gemini
| HookProtocol::Hermes
| HookProtocol::Grok => WarningAudience::HumanOperator,
};
print_colorful_warning_to(
stderr,
command,
reason,
pack,
pattern,
explanation,
allow_once_code,
matched_span,
pattern_suggestions,
severity,
branch_context,
warning_audience,
);
let message = format_denial_message(command, reason, explanation, pack, pattern);
let rule_id = build_rule_id(pack, pattern);
let remediation = allow_once.map(|info| {
let explanation_text = format_explanation_text(explanation, rule_id.as_deref(), pack);
Remediation {
safe_alternative: get_contextual_suggestion(command).map(String::from),
explanation: explanation_text,
allow_once_command: format!("dcg allow-once {}", info.code),
}
});
match protocol {
HookProtocol::ClaudeCompatible => {
let output = HookOutput {
hook_specific_output: HookSpecificOutput {
hook_event_name: "PreToolUse",
permission_decision: "deny",
permission_decision_reason: Cow::Owned(message.clone()),
allow_once_code: allow_once.map(|info| info.code.clone()),
allow_once_full_hash: allow_once.map(|info| info.full_hash.clone()),
rule_id,
pack_id: pack.map(String::from),
severity,
confidence,
remediation,
},
};
let _ = serde_json::to_writer(&mut *stdout, &output);
let _ = writeln!(stdout);
}
HookProtocol::Codex => {
}
HookProtocol::Copilot => {
let output = CopilotHookOutput {
continue_execution: false,
stop_reason: Cow::Owned(format!("BLOCKED by dcg: {reason}")),
permission_decision: "deny",
permission_decision_reason: Cow::Owned(message.clone()),
allow_once_code: allow_once.map(|info| info.code.clone()),
allow_once_full_hash: allow_once.map(|info| info.full_hash.clone()),
rule_id,
pack_id: pack.map(String::from),
severity,
confidence,
remediation,
};
let _ = serde_json::to_writer(&mut *stdout, &output);
let _ = writeln!(stdout);
}
HookProtocol::Gemini => {
let output = GeminiHookOutput {
decision: "deny",
reason: Cow::Owned(message),
system_message: Some(Cow::Owned(format!("BLOCKED by dcg: {reason}"))),
allow_once_code: allow_once.map(|info| info.code.clone()),
allow_once_full_hash: allow_once.map(|info| info.full_hash.clone()),
rule_id,
pack_id: pack.map(String::from),
severity,
confidence,
remediation,
};
let _ = serde_json::to_writer(&mut *stdout, &output);
let _ = writeln!(stdout);
}
HookProtocol::Hermes => {
let output = HermesHookOutput {
decision: "block",
reason: Cow::Owned(message.clone()),
action: "block",
message: Cow::Owned(message),
allow_once_code: allow_once.map(|info| info.code.clone()),
allow_once_full_hash: allow_once.map(|info| info.full_hash.clone()),
rule_id,
pack_id: pack.map(String::from),
severity,
confidence,
remediation,
};
let _ = serde_json::to_writer(&mut *stdout, &output);
let _ = writeln!(stdout);
}
HookProtocol::Grok => {
let output = GrokHookOutput {
decision: "deny",
reason: Cow::Owned(message),
allow_once_code: allow_once.map(|info| info.code.clone()),
allow_once_full_hash: allow_once.map(|info| info.full_hash.clone()),
rule_id,
pack_id: pack.map(String::from),
severity,
confidence,
remediation,
};
let _ = serde_json::to_writer(&mut *stdout, &output);
let _ = writeln!(stdout);
}
}
}
#[cold]
#[inline(never)]
#[allow(clippy::too_many_arguments)]
pub fn output_denial_for_protocol(
protocol: HookProtocol,
command: &str,
reason: &str,
pack: Option<&str>,
pattern: Option<&str>,
explanation: Option<&str>,
allow_once: Option<&AllowOnceInfo>,
matched_span: Option<&MatchSpan>,
severity: Option<crate::packs::Severity>,
confidence: Option<f64>,
pattern_suggestions: &[PatternSuggestion],
branch_context: Option<&crate::evaluator::BranchContext>,
) {
let out = io::stdout();
let mut out_handle = out.lock();
let err = io::stderr();
let mut err_handle = err.lock();
write_denial_to(
&mut out_handle,
&mut err_handle,
protocol,
command,
reason,
pack,
pattern,
explanation,
allow_once,
matched_span,
severity,
confidence,
pattern_suggestions,
branch_context,
);
}
#[cold]
#[inline(never)]
#[allow(clippy::too_many_arguments)]
pub fn output_denial(
command: &str,
reason: &str,
pack: Option<&str>,
pattern: Option<&str>,
explanation: Option<&str>,
allow_once: Option<&AllowOnceInfo>,
matched_span: Option<&MatchSpan>,
severity: Option<crate::packs::Severity>,
confidence: Option<f64>,
pattern_suggestions: &[PatternSuggestion],
) {
output_denial_for_protocol(
HookProtocol::ClaudeCompatible,
command,
reason,
pack,
pattern,
explanation,
allow_once,
matched_span,
severity,
confidence,
pattern_suggestions,
None,
);
}
#[cold]
#[inline(never)]
pub(crate) fn write_warning_to(
stdout: &mut impl Write,
stderr: &mut impl Write,
protocol: HookProtocol,
command: &str,
reason: &str,
pack: Option<&str>,
pattern: Option<&str>,
explanation: Option<&str>,
) {
{
let _ = writeln!(stderr);
let _ = writeln!(stderr, "{} {}", "dcg WARNING:".yellow().bold(), reason);
let rule_id = build_rule_id(pack, pattern);
let explanation_text = format_explanation_text(explanation, rule_id.as_deref(), pack);
let mut explanation_lines = explanation_text.lines();
if let Some(first) = explanation_lines.next() {
let _ = writeln!(stderr, " {} {}", "Explanation:".bright_black(), first);
for line in explanation_lines {
let _ = writeln!(stderr, " {line}");
}
}
if let Some(ref rule) = rule_id {
let _ = writeln!(stderr, " {} {}", "Rule:".bright_black(), rule);
} else if let Some(pack_name) = pack {
let _ = writeln!(stderr, " {} {}", "Pack:".bright_black(), pack_name);
}
let _ = writeln!(stderr, " {} {}", "Command:".bright_black(), command);
}
let rule_id = build_rule_id(pack, pattern);
let warn_reason = format!("DCG warn: {reason}");
match protocol {
HookProtocol::ClaudeCompatible => {
let output = HookOutput {
hook_specific_output: HookSpecificOutput {
hook_event_name: "PreToolUse",
permission_decision: "ask",
permission_decision_reason: Cow::Owned(warn_reason),
allow_once_code: None,
allow_once_full_hash: None,
rule_id,
pack_id: pack.map(String::from),
severity: None,
confidence: None,
remediation: None,
},
};
let _ = serde_json::to_writer(&mut *stdout, &output);
let _ = writeln!(stdout);
}
HookProtocol::Copilot => {
let output = CopilotHookOutput {
continue_execution: true,
stop_reason: Cow::Owned(format!("DCG warn: {reason}")),
permission_decision: "ask",
permission_decision_reason: Cow::Owned(warn_reason),
allow_once_code: None,
allow_once_full_hash: None,
rule_id,
pack_id: pack.map(String::from),
severity: None,
confidence: None,
remediation: None,
};
let _ = serde_json::to_writer(&mut *stdout, &output);
let _ = writeln!(stdout);
}
HookProtocol::Gemini => {
let output = GeminiHookOutput {
decision: "allow",
reason: Cow::Owned(warn_reason.clone()),
system_message: Some(Cow::Owned(warn_reason)),
allow_once_code: None,
allow_once_full_hash: None,
rule_id,
pack_id: pack.map(String::from),
severity: None,
confidence: None,
remediation: None,
};
let _ = serde_json::to_writer(&mut *stdout, &output);
let _ = writeln!(stdout);
}
HookProtocol::Codex => {
}
HookProtocol::Hermes => {
#[derive(Serialize)]
struct HermesWarningOutput<'a> {
context: Cow<'a, str>,
}
let output = HermesWarningOutput {
context: Cow::Owned(warn_reason),
};
let _ = serde_json::to_writer(&mut *stdout, &output);
let _ = writeln!(stdout);
}
HookProtocol::Grok => {
let output = GrokHookOutput {
decision: "allow",
reason: Cow::Owned(warn_reason),
allow_once_code: None,
allow_once_full_hash: None,
rule_id,
pack_id: pack.map(String::from),
severity: None,
confidence: None,
remediation: None,
};
let _ = serde_json::to_writer(&mut *stdout, &output);
let _ = writeln!(stdout);
}
}
}
#[cold]
#[inline(never)]
pub fn output_warning_for_protocol(
protocol: HookProtocol,
command: &str,
reason: &str,
pack: Option<&str>,
pattern: Option<&str>,
explanation: Option<&str>,
) {
let out = io::stdout();
let mut out_handle = out.lock();
let err = io::stderr();
let mut err_handle = err.lock();
write_warning_to(
&mut out_handle,
&mut err_handle,
protocol,
command,
reason,
pack,
pattern,
explanation,
);
}
pub fn log_blocked_command(
log_file: &str,
command: &str,
reason: &str,
pack: Option<&str>,
) -> io::Result<()> {
use std::fs::OpenOptions;
let path = if log_file.starts_with("~/") {
dirs::home_dir().map_or_else(
|| std::path::PathBuf::from(log_file),
|h| h.join(&log_file[2..]),
)
} else {
std::path::PathBuf::from(log_file)
};
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut file = OpenOptions::new().create(true).append(true).open(path)?;
let timestamp = chrono_lite_timestamp();
let pack_str = pack.unwrap_or("unknown");
writeln!(file, "[{timestamp}] [{pack_str}] {reason}")?;
writeln!(file, " Command: {command}")?;
writeln!(file)?;
Ok(())
}
pub fn log_budget_skip(
log_file: &str,
command: &str,
stage: &str,
elapsed: Duration,
budget: Duration,
) -> io::Result<()> {
use std::fs::OpenOptions;
let path = if log_file.starts_with("~/") {
dirs::home_dir().map_or_else(
|| std::path::PathBuf::from(log_file),
|h| h.join(&log_file[2..]),
)
} else {
std::path::PathBuf::from(log_file)
};
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut file = OpenOptions::new().create(true).append(true).open(path)?;
let timestamp = chrono_lite_timestamp();
writeln!(
file,
"[{timestamp}] [budget] evaluation skipped due to budget at {stage}"
)?;
writeln!(
file,
" Budget: {}ms, Elapsed: {}ms",
budget.as_millis(),
elapsed.as_millis()
)?;
writeln!(file, " Command: {command}")?;
writeln!(file)?;
Ok(())
}
fn chrono_lite_timestamp() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
let secs = duration.as_secs();
format!("{secs}")
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
struct EnvVarGuard {
key: &'static str,
previous: Option<String>,
}
impl EnvVarGuard {
fn set(key: &'static str, value: &str) -> Self {
let previous = std::env::var(key).ok();
unsafe { std::env::set_var(key, value) };
Self { key, previous }
}
#[allow(dead_code)]
fn remove(key: &'static str) -> Self {
let previous = std::env::var(key).ok();
unsafe { std::env::remove_var(key) };
Self { key, previous }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
if let Some(value) = self.previous.take() {
unsafe { std::env::set_var(self.key, value) };
} else {
unsafe { std::env::remove_var(self.key) };
}
}
}
#[test]
fn test_parse_valid_bash_input() {
let json = r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(extract_command(&input), Some("git status".to_string()));
}
#[test]
fn test_codex_protocol_detected_via_turn_id() {
let json = r#"{
"session_id":"019dd11d-b795-7261-a9cb-9b85a5dad632",
"turn_id":"turn-1",
"transcript_path":null,
"cwd":"/tmp/x",
"hook_event_name":"PreToolUse",
"model":"gpt-5.5",
"permission_mode":"bypassPermissions",
"tool_name":"Bash",
"tool_input":{"command":"git reset --hard"},
"tool_use_id":"call_abc123"
}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(detect_protocol(&input), HookProtocol::Codex);
assert_eq!(
extract_command(&input),
Some("git reset --hard".to_string())
);
}
#[test]
fn test_empty_turn_id_is_not_treated_as_codex() {
let json = r#"{
"tool_name":"Bash",
"tool_input":{"command":"git status"},
"turn_id":""
}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(detect_protocol(&input), HookProtocol::ClaudeCompatible);
}
#[test]
fn test_claude_code_with_tool_use_id_is_not_codex() {
let json = r#"{
"session_id":"abc123",
"transcript_path":"/home/user/.claude/projects/x/transcript.jsonl",
"cwd":"/home/user/my-project",
"permission_mode":"default",
"hook_event_name":"PreToolUse",
"tool_name":"Bash",
"tool_input":{"command":"git status"},
"tool_use_id":"toolu_01ABC"
}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(detect_protocol(&input), HookProtocol::ClaudeCompatible);
}
#[test]
fn test_parse_non_bash_input() {
let json = r#"{"tool_name":"Read","tool_input":{"command":"git status"}}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(extract_command(&input), None);
}
#[test]
fn test_parse_missing_command() {
let json = r#"{"tool_name":"Bash","tool_input":{}}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(extract_command(&input), None);
}
#[test]
fn test_parse_copilot_tool_input_command() {
let json = r#"{"event":"pre-tool-use","toolName":"run_shell_command","toolInput":{"command":"git status"}}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(extract_command(&input), Some("git status".to_string()));
assert_eq!(detect_protocol(&input), HookProtocol::Copilot);
}
#[test]
fn test_parse_copilot_tool_args_json_string() {
let json = r#"{"event":"pre-tool-use","toolName":"bash","toolArgs":"{\"command\":\"rm -rf /tmp/build\"}"}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(
extract_command(&input),
Some("rm -rf /tmp/build".to_string())
);
assert_eq!(detect_protocol(&input), HookProtocol::Copilot);
}
#[test]
fn test_parse_copilot_powershell_tool_args_object() {
let json = r#"{"event":"pre-tool-use","toolName":"powershell","toolArgs":{"command":"git reset --hard"}}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(detect_protocol(&input), HookProtocol::Copilot);
assert_eq!(
extract_command(&input),
Some("git reset --hard".to_string())
);
}
#[test]
fn test_parse_copilot_powershell_tool_input_command() {
let json = r#"{"event":"pre-tool-use","toolName":"powershell","toolInput":{"command":"git reset --hard"}}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(detect_protocol(&input), HookProtocol::Copilot);
assert_eq!(
extract_command(&input),
Some("git reset --hard".to_string())
);
}
#[test]
fn test_parse_copilot_tool_args_without_tool_name() {
let json = r#"{"event":"pre-tool-use","toolArgs":"{\"command\":\"git reset --hard\"}"}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(detect_protocol(&input), HookProtocol::Copilot);
assert_eq!(
extract_command(&input),
Some("git reset --hard".to_string())
);
}
#[test]
fn test_parse_copilot_tool_input_without_tool_name() {
let json = r#"{"event":"pre-tool-use","toolInput":{"command":"git status"}}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(detect_protocol(&input), HookProtocol::Copilot);
assert_eq!(extract_command(&input), Some("git status".to_string()));
}
#[test]
fn test_parse_gemini_before_tool_input() {
let json = r#"{
"session_id":"session-123",
"transcript_path":"/tmp/transcript.json",
"cwd":"/tmp",
"hook_event_name":"BeforeTool",
"timestamp":"2026-02-24T00:00:00Z",
"tool_name":"run_shell_command",
"tool_input":{"command":"git status"}
}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(extract_command(&input), Some("git status".to_string()));
assert_eq!(detect_protocol(&input), HookProtocol::Gemini);
}
#[test]
fn test_hook_event_name_alone_does_not_force_gemini_protocol() {
let json = r#"{
"hook_event_name":"BeforeTool",
"tool_name":"Bash",
"tool_input":{"command":"git status"}
}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(extract_command(&input), Some("git status".to_string()));
assert_eq!(detect_protocol(&input), HookProtocol::ClaudeCompatible);
}
#[test]
fn test_gemini_before_tool_marker_detects_gemini_without_session_fields() {
let json = r#"{
"hook_event_name":"BeforeTool",
"tool_name":"run_shell_command",
"tool_input":{"command":"git status"}
}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(extract_command(&input), Some("git status".to_string()));
assert_eq!(detect_protocol(&input), HookProtocol::Gemini);
}
#[test]
fn test_gemini_hook_output_json_shape() {
let output = GeminiHookOutput {
decision: "deny",
reason: Cow::Borrowed("blocked for safety"),
system_message: Some(Cow::Borrowed("BLOCKED by dcg: test")),
allow_once_code: None,
allow_once_full_hash: None,
rule_id: Some("core.git:reset-hard".to_string()),
pack_id: Some("core.git".to_string()),
severity: None,
confidence: None,
remediation: None,
};
let json = serde_json::to_value(&output).unwrap();
assert_eq!(json["decision"], "deny");
assert_eq!(json["reason"], "blocked for safety");
assert_eq!(json["systemMessage"], "BLOCKED by dcg: test");
assert!(json.get("continue").is_none());
assert!(json.get("stopReason").is_none());
assert_eq!(json["ruleId"], "core.git:reset-hard");
assert_eq!(json["packId"], "core.git");
}
#[test]
fn test_parse_non_string_command() {
let json = r#"{"tool_name":"Bash","tool_input":{"command":123}}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(extract_command(&input), None);
}
#[test]
fn test_format_denial_message_includes_explanation_and_rule() {
let message = format_denial_message(
"git reset --hard",
"destructive",
Some("This is irreversible."),
Some("core.git"),
Some("reset-hard"),
);
assert!(message.contains("Reason: destructive"));
assert!(message.contains("Explanation: This is irreversible."));
assert!(message.contains("Rule: core.git:reset-hard"));
assert!(message.contains("Tip: dcg explain"));
}
#[test]
fn test_claude_compatible_warn_ask_json_shape() {
let output = HookOutput {
hook_specific_output: HookSpecificOutput {
hook_event_name: "PreToolUse",
permission_decision: "ask",
permission_decision_reason: Cow::Borrowed("DCG warn: risky pattern"),
allow_once_code: None,
allow_once_full_hash: None,
rule_id: Some("core.git:checkout-dot".to_string()),
pack_id: Some("core.git".to_string()),
severity: None,
confidence: None,
remediation: None,
},
};
let json = serde_json::to_value(&output).unwrap();
let specific = &json["hookSpecificOutput"];
assert_eq!(specific["hookEventName"], "PreToolUse");
assert_eq!(specific["permissionDecision"], "ask");
assert!(
specific["permissionDecisionReason"]
.as_str()
.unwrap()
.starts_with("DCG warn:")
);
assert_eq!(specific["ruleId"], "core.git:checkout-dot");
assert_eq!(specific["packId"], "core.git");
}
#[test]
fn test_copilot_warn_ask_json_shape() {
let output = CopilotHookOutput {
continue_execution: true,
stop_reason: Cow::Borrowed("DCG warn: risky pattern"),
permission_decision: "ask",
permission_decision_reason: Cow::Borrowed("DCG warn: risky pattern"),
allow_once_code: None,
allow_once_full_hash: None,
rule_id: None,
pack_id: None,
severity: None,
confidence: None,
remediation: None,
};
let json = serde_json::to_value(&output).unwrap();
assert_eq!(json["permissionDecision"], "ask");
assert_eq!(json["continue"], true);
}
#[test]
fn test_gemini_warn_allow_json_shape() {
let output = GeminiHookOutput {
decision: "allow",
reason: Cow::Borrowed("DCG warn: risky pattern"),
system_message: Some(Cow::Borrowed("DCG warn: risky pattern")),
allow_once_code: None,
allow_once_full_hash: None,
rule_id: None,
pack_id: None,
severity: None,
confidence: None,
remediation: None,
};
let json = serde_json::to_value(&output).unwrap();
assert_eq!(json["decision"], "allow");
assert!(json["reason"].as_str().unwrap().starts_with("DCG warn:"));
}
#[test]
fn test_parse_hermes_pre_tool_call_input() {
let json = r#"{
"hook_event_name":"pre_tool_call",
"tool_name":"terminal",
"tool_input":{"command":"rm -rf /"},
"session_id":"sess_abc123",
"cwd":"/home/user/project",
"extra":{"task_id":"task-1","tool_call_id":"call-1"}
}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(extract_command(&input), Some("rm -rf /".to_string()));
assert_eq!(detect_protocol(&input), HookProtocol::Hermes);
}
#[test]
fn test_hermes_detected_via_event_alone() {
let json = r#"{
"hook_event_name":"pre_tool_call",
"tool_name":"bash",
"tool_input":{"command":"git status"}
}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(detect_protocol(&input), HookProtocol::Hermes);
}
#[test]
fn test_hermes_detected_via_terminal_tool_alone() {
let json = r#"{
"tool_name":"terminal",
"tool_input":{"command":"echo hi"}
}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(detect_protocol(&input), HookProtocol::Hermes);
}
#[test]
fn test_hermes_loses_to_copilot_when_event_field_present() {
let json = r#"{
"event":"pre-tool-use",
"hook_event_name":"pre_tool_call",
"tool_name":"terminal",
"tool_input":{"command":"git status"}
}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(detect_protocol(&input), HookProtocol::Copilot);
}
#[test]
fn test_hermes_loses_to_copilot_when_tool_args_present() {
let json = r#"{
"hook_event_name":"pre_tool_call",
"tool_name":"terminal",
"tool_args":"{\"command\":\"git status\"}"
}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(detect_protocol(&input), HookProtocol::Copilot);
}
#[test]
fn test_hermes_pre_tool_call_with_session_envelope_not_gemini() {
let json = r#"{
"session_id":"sess",
"cwd":"/tmp",
"hook_event_name":"pre_tool_call",
"tool_name":"terminal",
"tool_input":{"command":"ls"}
}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(detect_protocol(&input), HookProtocol::Hermes);
}
#[test]
fn test_hermes_hook_output_block_decision_json_shape() {
let output = HermesHookOutput {
decision: "block",
reason: Cow::Borrowed("blocked for safety"),
action: "block",
message: Cow::Borrowed("blocked for safety"),
allow_once_code: None,
allow_once_full_hash: None,
rule_id: Some("core.git:reset-hard".to_string()),
pack_id: Some("core.git".to_string()),
severity: None,
confidence: None,
remediation: None,
};
let json = serde_json::to_value(&output).unwrap();
assert_eq!(json["decision"], "block");
assert_eq!(json["reason"], "blocked for safety");
assert_eq!(json["action"], "block");
assert_eq!(json["message"], "blocked for safety");
assert_eq!(json["ruleId"], "core.git:reset-hard");
assert_eq!(json["packId"], "core.git");
assert!(json.get("permissionDecision").is_none());
assert!(json.get("continue").is_none());
assert!(json.get("hookSpecificOutput").is_none());
}
#[test]
fn test_write_denial_hermes_produces_block_json() {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
write_denial_to(
&mut stdout,
&mut stderr,
HookProtocol::Hermes,
"rm -rf /",
"catastrophic filesystem deletion",
Some("core.filesystem"),
Some("rm-rf-root"),
None,
None,
None,
Some(crate::packs::Severity::Critical),
None,
&[],
None,
);
let stdout_str = String::from_utf8_lossy(&stdout);
let json: serde_json::Value = serde_json::from_str(stdout_str.trim())
.unwrap_or_else(|e| panic!("stdout not valid JSON: {e}\nstdout: {stdout_str}"));
assert_eq!(json["decision"], "block");
assert_eq!(json["action"], "block");
assert!(
json["reason"]
.as_str()
.unwrap()
.contains("catastrophic filesystem deletion")
);
assert!(
json["message"]
.as_str()
.unwrap()
.contains("catastrophic filesystem deletion")
);
assert!(
!stderr.is_empty(),
"Hermes denial must still surface stderr warning text"
);
}
#[test]
fn test_write_warning_hermes_produces_context_json() {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
write_warning_to(
&mut stdout,
&mut stderr,
HookProtocol::Hermes,
"git stash drop",
"drops stashed changes",
Some("core.git"),
Some("stash-drop"),
None,
);
let stdout_str = String::from_utf8_lossy(&stdout);
let json: serde_json::Value = serde_json::from_str(stdout_str.trim())
.unwrap_or_else(|e| panic!("stdout not valid JSON: {e}\nstdout: {stdout_str}"));
assert!(json["context"].as_str().unwrap().starts_with("DCG warn:"));
assert!(json.get("decision").is_none());
assert!(json.get("action").is_none());
assert!(!stderr.is_empty(), "stderr must contain warn text");
}
#[test]
fn test_grok_detected_via_event_alone() {
let json = r#"{
"hookEventName":"pre_tool_use",
"toolName":"bash",
"toolInput":{"command":"git status"}
}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(detect_protocol(&input), HookProtocol::Grok);
}
#[test]
fn test_grok_detected_via_run_terminal_cmd_tool_alone() {
let json = r#"{
"toolName":"run_terminal_cmd",
"toolInput":{"command":"echo hi"}
}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(detect_protocol(&input), HookProtocol::Grok);
}
#[test]
fn test_grok_full_envelope_camelcase() {
let json = r#"{
"hookEventName":"pre_tool_use",
"sessionId":"sess-abc",
"cwd":"/home/user/proj",
"workspaceRoot":"/home/user/proj",
"toolName":"run_terminal_cmd",
"toolInput":{"command":"rm -rf /"},
"timestamp":"2026-05-14T12:00:00Z"
}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(detect_protocol(&input), HookProtocol::Grok);
}
#[test]
fn test_grok_loses_to_copilot_when_event_field_present() {
let json = r#"{
"event":"pre-tool-use",
"hookEventName":"pre_tool_use",
"toolName":"run_terminal_cmd",
"toolInput":{"command":"git status"}
}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(detect_protocol(&input), HookProtocol::Copilot);
}
#[test]
fn test_grok_loses_to_copilot_when_tool_args_present() {
let json = r#"{
"hookEventName":"pre_tool_use",
"toolName":"run_terminal_cmd",
"toolArgs":"{\"command\":\"git status\"}"
}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(detect_protocol(&input), HookProtocol::Copilot);
}
#[test]
fn test_grok_event_does_not_misroute_to_hermes() {
let json = r#"{
"hook_event_name":"pre_tool_use",
"tool_name":"run_terminal_cmd",
"tool_input":{"command":"ls"}
}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(detect_protocol(&input), HookProtocol::Grok);
}
#[test]
fn test_grok_hook_output_deny_decision_json_shape() {
let output = GrokHookOutput {
decision: "deny",
reason: Cow::Borrowed("blocked for safety"),
allow_once_code: None,
allow_once_full_hash: None,
rule_id: Some("core.git:reset-hard".to_string()),
pack_id: Some("core.git".to_string()),
severity: None,
confidence: None,
remediation: None,
};
let json = serde_json::to_value(&output).unwrap();
assert_eq!(json["decision"], "deny");
assert_eq!(json["reason"], "blocked for safety");
assert_eq!(json["ruleId"], "core.git:reset-hard");
assert_eq!(json["packId"], "core.git");
assert!(json.get("action").is_none(), "no Hermes 'action'");
assert!(json.get("message").is_none(), "no Hermes 'message'");
assert!(
json.get("permissionDecision").is_none(),
"no Claude 'permissionDecision'"
);
assert!(
json.get("hookSpecificOutput").is_none(),
"no Claude 'hookSpecificOutput'"
);
assert!(json.get("continue").is_none(), "no Copilot 'continue'");
}
#[test]
fn test_write_denial_grok_produces_deny_json() {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
write_denial_to(
&mut stdout,
&mut stderr,
HookProtocol::Grok,
"rm -rf /",
"catastrophic filesystem deletion",
Some("core.filesystem"),
Some("rm-rf-root"),
None,
None,
None,
Some(crate::packs::Severity::Critical),
None,
&[],
None,
);
let stdout_str = String::from_utf8_lossy(&stdout);
let json: serde_json::Value = serde_json::from_str(stdout_str.trim())
.unwrap_or_else(|e| panic!("stdout not valid JSON: {e}\nstdout: {stdout_str}"));
assert_eq!(json["decision"], "deny");
assert!(
json["reason"]
.as_str()
.unwrap()
.contains("catastrophic filesystem deletion"),
"reason must surface the human-readable explanation, got: {}",
json["reason"]
);
assert!(json.get("action").is_none());
assert!(json.get("message").is_none());
assert!(json.get("hookSpecificOutput").is_none());
assert!(
!stderr.is_empty(),
"Grok denial must still surface stderr warning text"
);
}
#[test]
fn test_write_warning_grok_produces_allow_with_reason() {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
write_warning_to(
&mut stdout,
&mut stderr,
HookProtocol::Grok,
"git stash drop",
"drops stashed changes",
Some("core.git"),
Some("stash-drop"),
None,
);
let stdout_str = String::from_utf8_lossy(&stdout);
let json: serde_json::Value = serde_json::from_str(stdout_str.trim())
.unwrap_or_else(|e| panic!("stdout not valid JSON: {e}\nstdout: {stdout_str}"));
assert_eq!(
json["decision"], "allow",
"warn must NOT escalate to deny on Grok"
);
assert!(
json["reason"].as_str().unwrap().starts_with("DCG warn:"),
"reason should be prefixed so the model knows this is advisory"
);
assert!(!stderr.is_empty(), "stderr must contain warn text");
}
#[test]
fn test_grok_run_terminal_cmd_recognized_as_shell_tool() {
assert!(is_supported_shell_tool(Some("run_terminal_cmd")));
assert!(is_supported_shell_tool(Some("RUN_TERMINAL_CMD")));
}
#[test]
fn test_env_var_guard_restores_value() {
let _lock = ENV_LOCK.lock().unwrap();
let key = "DCG_TEST_ENV_GUARD";
unsafe { std::env::remove_var(key) };
{
let _guard = EnvVarGuard::set(key, "1");
assert_eq!(std::env::var(key).as_deref(), Ok("1"));
}
assert!(std::env::var(key).is_err());
}
#[test]
fn test_claude_code_with_session_fields_not_gemini_issue_77() {
let json = r#"{
"session_id": "sess-abc123",
"transcript_path": "/tmp/claude/transcript.json",
"cwd": "/home/user/project",
"tool_name": "Bash",
"tool_input": {"command": "git reset --hard HEAD~1"}
}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(
detect_protocol(&input),
HookProtocol::ClaudeCompatible,
"Claude Code payload with session_id/cwd must NOT be classified as Gemini"
);
assert_eq!(
extract_command(&input),
Some("git reset --hard HEAD~1".to_string())
);
}
#[test]
fn test_claude_code_full_payload_with_all_shared_fields() {
let json = r#"{
"session_id": "sess-xyz",
"transcript_path": "/tmp/transcript",
"cwd": "/data/projects",
"timestamp": "2026-03-20T00:00:00Z",
"tool_name": "Bash",
"tool_input": {"command": "rm -rf /tmp/build"}
}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(
detect_protocol(&input),
HookProtocol::ClaudeCompatible,
"tool_name=Bash is a definitive Claude Code indicator regardless of envelope fields"
);
}
#[test]
fn test_claude_code_with_cwd_only() {
let json = r#"{
"cwd": "/home/user/project",
"tool_name": "Bash",
"tool_input": {"command": "ls"}
}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(detect_protocol(&input), HookProtocol::ClaudeCompatible);
}
#[test]
fn test_claude_code_launch_process_with_session_fields() {
let json = r#"{
"session_id": "sess-abc",
"cwd": "/tmp",
"tool_name": "launch-process",
"tool_input": {"command": "git status"}
}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(detect_protocol(&input), HookProtocol::ClaudeCompatible);
}
#[test]
fn test_gemini_not_affected_by_fix() {
let json = r#"{
"session_id": "gemini-session",
"transcript_path": "/tmp/gemini/transcript",
"cwd": "/home/user",
"hook_event_name": "BeforeTool",
"timestamp": "2026-03-20T00:00:00Z",
"tool_name": "run_shell_command",
"tool_input": {"command": "git reset --hard"}
}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(
detect_protocol(&input),
HookProtocol::Gemini,
"Genuine Gemini payloads must still be classified as Gemini"
);
}
#[test]
fn test_copilot_with_event_field_takes_priority() {
let json = r#"{
"event": "pre-tool-use",
"session_id": "some-session",
"cwd": "/tmp",
"tool_name": "bash",
"tool_args": "{\"command\":\"git status\"}"
}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(
detect_protocol(&input),
HookProtocol::Copilot,
"Copilot event field must take priority over shared envelope fields"
);
}
#[test]
fn test_bare_run_shell_command_without_context_is_copilot() {
let json = r#"{
"tool_name": "run_shell_command",
"tool_input": {"command": "git status"}
}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(detect_protocol(&input), HookProtocol::Copilot);
}
#[test]
fn test_minimal_bash_payload_is_claude_compatible() {
let json = r#"{"tool_name":"Bash","tool_input":{"command":"echo hello"}}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(detect_protocol(&input), HookProtocol::ClaudeCompatible);
}
#[test]
fn test_empty_payload_defaults_to_claude_compatible() {
let json = r"{}";
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(detect_protocol(&input), HookProtocol::ClaudeCompatible);
}
fn test_allow_once() -> AllowOnceInfo {
AllowOnceInfo {
code: "abc123".to_string(),
full_hash: "sha256:deadbeef".to_string(),
}
}
#[test]
fn test_write_denial_claude_produces_valid_json_on_stdout() {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let allow = test_allow_once();
write_denial_to(
&mut stdout,
&mut stderr,
HookProtocol::ClaudeCompatible,
"git reset --hard HEAD~1",
"destroys uncommitted changes",
Some("core.git"),
Some("reset-hard"),
Some("Rewrites history and discards uncommitted changes."),
Some(&allow),
None,
Some(crate::packs::Severity::Critical),
Some(0.95),
&[],
None,
);
let stdout_str = String::from_utf8_lossy(&stdout);
let json: serde_json::Value = serde_json::from_str(stdout_str.trim())
.unwrap_or_else(|e| panic!("stdout not valid JSON: {e}\nstdout bytes: {stdout_str}"));
let specific = &json["hookSpecificOutput"];
assert_eq!(specific["permissionDecision"], "deny");
assert_eq!(specific["hookEventName"], "PreToolUse");
assert_eq!(specific["ruleId"], "core.git:reset-hard");
assert_eq!(specific["packId"], "core.git");
assert_eq!(specific["allowOnceCode"], "abc123");
assert!(!stderr.is_empty(), "stderr must contain colorful warning");
}
#[test]
fn test_pattern_suggestion_alternatives_formats_platform_matches() {
let suggestions = [
PatternSuggestion::new("git stash", "Save uncommitted changes"),
PatternSuggestion::new("git clean -n", "Preview untracked file cleanup"),
];
let alternatives = pattern_suggestion_alternatives("git reset --hard", true, &suggestions);
assert_eq!(
alternatives,
vec![
"Save uncommitted changes: git stash",
"Preview untracked file cleanup: git clean -n"
]
);
}
#[test]
fn test_pattern_suggestion_alternatives_respects_disable_flag() {
let suggestions = [PatternSuggestion::new(
"git stash",
"Save uncommitted changes",
)];
let alternatives = pattern_suggestion_alternatives("git reset --hard", false, &suggestions);
assert!(alternatives.is_empty());
}
#[test]
fn test_pattern_suggestion_alternatives_falls_back_to_contextual() {
let alternatives = pattern_suggestion_alternatives("git clean -fd", true, &[]);
assert_eq!(
alternatives,
vec!["Use 'git clean -n' first to preview what would be deleted."]
);
}
#[test]
fn test_pattern_suggestion_alternatives_limits_display_count() {
let suggestions = [
PatternSuggestion::new("cmd1", "one"),
PatternSuggestion::new("cmd2", "two"),
PatternSuggestion::new("cmd3", "three"),
PatternSuggestion::new("cmd4", "four"),
PatternSuggestion::new("cmd5", "five"),
];
let alternatives = pattern_suggestion_alternatives("rm -rf /tmp/x", true, &suggestions);
assert_eq!(alternatives.len(), MAX_SUGGESTIONS);
assert!(alternatives.iter().any(|item| item == "one: cmd1"));
assert!(!alternatives.iter().any(|item| item == "five: cmd5"));
}
#[test]
fn test_write_denial_codex_produces_empty_stdout() {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let allow = test_allow_once();
write_denial_to(
&mut stdout,
&mut stderr,
HookProtocol::Codex,
"git reset --hard HEAD~1",
"destroys uncommitted changes",
Some("core.git"),
Some("reset-hard"),
Some("Rewrites history."),
Some(&allow),
None,
Some(crate::packs::Severity::Critical),
Some(0.95),
&[],
None,
);
assert!(
stdout.is_empty(),
"Codex deny must produce zero bytes on stdout; got {} bytes: {:?}",
stdout.len(),
String::from_utf8_lossy(&stdout)
);
assert!(
!stderr.is_empty(),
"Codex deny must produce non-empty stderr"
);
let stderr_str = String::from_utf8_lossy(&stderr);
assert!(
stderr_str.contains("git reset --hard HEAD~1"),
"stderr must contain the blocked command; got: {stderr_str}"
);
assert!(
stderr_str.contains("core.git:reset-hard"),
"stderr must contain the rule id for agent parsing; got: {stderr_str}"
);
assert!(
stderr_str.contains("Rule: core.git:reset-hard"),
"Codex stderr must expose the full rule id as a parseable footer; got: {stderr_str}"
);
assert!(
!stderr_str.contains("dcg allowlist add"),
"Codex stderr must not teach the model to self-allowlist; got: {stderr_str}"
);
assert!(
!stderr_str.contains("dcg allow-once") && !stderr_str.contains("abc123"),
"Codex stderr must not expose allow-once bypass details; got: {stderr_str}"
);
assert!(
stderr_str.contains("Do not retry it, create a bypass, or change dcg policy yourself"),
"Codex stderr should give an explicit no-bypass instruction; got: {stderr_str}"
);
}
#[test]
fn test_write_denial_copilot_produces_valid_json_on_stdout() {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
write_denial_to(
&mut stdout,
&mut stderr,
HookProtocol::Copilot,
"rm -rf /",
"catastrophic filesystem deletion",
Some("core.filesystem"),
Some("rm-rf-root"),
None,
None,
None,
Some(crate::packs::Severity::Critical),
None,
&[],
None,
);
let stdout_str = String::from_utf8_lossy(&stdout);
let json: serde_json::Value = serde_json::from_str(stdout_str.trim())
.unwrap_or_else(|e| panic!("stdout not valid JSON: {e}\nstdout: {stdout_str}"));
assert_eq!(json["continue"], false);
assert_eq!(json["permissionDecision"], "deny");
assert!(
json["stopReason"]
.as_str()
.unwrap()
.contains("BLOCKED by dcg")
);
}
#[test]
fn test_write_denial_gemini_produces_valid_json_on_stdout() {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
write_denial_to(
&mut stdout,
&mut stderr,
HookProtocol::Gemini,
"git clean -fd",
"removes untracked files",
Some("core.git"),
Some("clean-force"),
None,
None,
None,
Some(crate::packs::Severity::High),
None,
&[],
None,
);
let stdout_str = String::from_utf8_lossy(&stdout);
let json: serde_json::Value = serde_json::from_str(stdout_str.trim())
.unwrap_or_else(|e| panic!("stdout not valid JSON: {e}\nstdout: {stdout_str}"));
assert_eq!(json["decision"], "deny");
assert!(
json["systemMessage"]
.as_str()
.unwrap()
.contains("BLOCKED by dcg")
);
}
#[test]
fn test_write_warning_claude_produces_ask_json() {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
write_warning_to(
&mut stdout,
&mut stderr,
HookProtocol::ClaudeCompatible,
"git checkout -- file.txt",
"may discard local changes",
Some("core.git"),
Some("checkout-dot"),
Some("Check git diff first."),
);
let stdout_str = String::from_utf8_lossy(&stdout);
let json: serde_json::Value = serde_json::from_str(stdout_str.trim())
.unwrap_or_else(|e| panic!("stdout not valid JSON: {e}\nstdout: {stdout_str}"));
let specific = &json["hookSpecificOutput"];
assert_eq!(specific["permissionDecision"], "ask");
assert!(
specific["permissionDecisionReason"]
.as_str()
.unwrap()
.starts_with("DCG warn:")
);
assert!(!stderr.is_empty(), "stderr must contain warning text");
}
#[test]
fn test_write_warning_codex_produces_empty_stdout() {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
write_warning_to(
&mut stdout,
&mut stderr,
HookProtocol::Codex,
"git checkout -- file.txt",
"may discard local changes",
Some("core.git"),
Some("checkout-dot"),
None,
);
assert!(
stdout.is_empty(),
"Codex warn must produce zero bytes on stdout; got {} bytes: {:?}",
stdout.len(),
String::from_utf8_lossy(&stdout)
);
assert!(
!stderr.is_empty(),
"Codex warn must produce non-empty stderr"
);
let stderr_str = String::from_utf8_lossy(&stderr);
assert!(
stderr_str.contains("WARNING"),
"stderr must contain WARNING marker; got: {stderr_str}"
);
assert!(
stderr_str.contains("core.git:checkout-dot"),
"stderr must contain rule id; got: {stderr_str}"
);
}
#[test]
fn test_write_warning_copilot_produces_ask_json() {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
write_warning_to(
&mut stdout,
&mut stderr,
HookProtocol::Copilot,
"git stash drop",
"drops stashed changes",
Some("core.git"),
Some("stash-drop"),
None,
);
let stdout_str = String::from_utf8_lossy(&stdout);
let json: serde_json::Value = serde_json::from_str(stdout_str.trim())
.unwrap_or_else(|e| panic!("stdout not valid JSON: {e}\nstdout: {stdout_str}"));
assert_eq!(json["permissionDecision"], "ask");
assert_eq!(json["continue"], true);
}
#[test]
fn test_write_warning_gemini_produces_allow_json() {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
write_warning_to(
&mut stdout,
&mut stderr,
HookProtocol::Gemini,
"git stash drop",
"drops stashed changes",
Some("core.git"),
Some("stash-drop"),
None,
);
let stdout_str = String::from_utf8_lossy(&stdout);
let json: serde_json::Value = serde_json::from_str(stdout_str.trim())
.unwrap_or_else(|e| panic!("stdout not valid JSON: {e}\nstdout: {stdout_str}"));
assert_eq!(json["decision"], "allow");
assert!(json["reason"].as_str().unwrap().starts_with("DCG warn:"));
}
#[test]
fn test_detect_protocol_non_shell_tool_with_turn_id_is_not_codex() {
let json = r#"{"tool_name":"Read","tool_input":{},"turn_id":"turn-1"}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(detect_protocol(&input), HookProtocol::ClaudeCompatible);
}
#[test]
fn test_detect_protocol_launch_process_with_turn_id_is_codex() {
let json =
r#"{"tool_name":"launch-process","tool_input":{"command":"ls"},"turn_id":"turn-2"}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(detect_protocol(&input), HookProtocol::Codex);
}
#[test]
fn test_detect_protocol_powershell_with_turn_id_is_codex() {
let json = r#"{"tool_name":"powershell","tool_input":{"command":"git status"},"turn_id":"turn-ps"}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(detect_protocol(&input), HookProtocol::Codex);
}
#[test]
fn test_detect_protocol_whitespace_only_turn_id_is_not_codex() {
let json = r#"{"tool_name":"Bash","tool_input":{"command":"ls"},"turn_id":" "}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(detect_protocol(&input), HookProtocol::ClaudeCompatible);
}
#[test]
fn test_detect_protocol_uppercase_bash_with_turn_id_is_codex() {
let json = r#"{"tool_name":"BASH","tool_input":{"command":"ls"},"turn_id":"turn-3"}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(detect_protocol(&input), HookProtocol::Codex);
}
#[test]
fn test_detect_protocol_lowercase_bash_with_turn_id_is_codex() {
let json = r#"{"tool_name":"bash","tool_input":{"command":"ls"},"turn_id":"turn-4"}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(detect_protocol(&input), HookProtocol::Codex);
}
#[test]
fn test_detect_protocol_copilot_event_overrides_turn_id() {
let json = r#"{
"event":"pre-tool-use",
"tool_name":"bash",
"tool_input":{"command":"ls"},
"turn_id":"turn-5"
}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(detect_protocol(&input), HookProtocol::Copilot);
}
#[test]
fn test_detect_protocol_gemini_envelope_overrides_turn_id() {
let json = r#"{
"hook_event_name":"BeforeTool",
"tool_name":"run_shell_command",
"tool_input":{"command":"ls"},
"turn_id":"turn-6"
}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(detect_protocol(&input), HookProtocol::Gemini);
}
#[test]
fn test_detect_protocol_bash_tool_use_id_no_turn_id_is_claude() {
let json = r#"{
"tool_name":"Bash",
"tool_input":{"command":"ls"},
"tool_use_id":"toolu_01XYZ"
}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(detect_protocol(&input), HookProtocol::ClaudeCompatible);
}
}