#![allow(clippy::doc_markdown, clippy::uninlined_format_args)]
use std::fmt;
use std::io::{ErrorKind, Write};
use std::path::PathBuf;
use std::process::{Command, Stdio};
pub struct HookOutcome {
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
pub exit_code: i32,
pub stdin_sent: Vec<u8>,
pub home_dir: PathBuf,
}
impl HookOutcome {
pub fn stdout_str(&self) -> String {
String::from_utf8_lossy(&self.stdout).into_owned()
}
pub fn stderr_str(&self) -> String {
String::from_utf8_lossy(&self.stderr).into_owned()
}
pub fn stderr_contains(&self, needle: &str) -> bool {
self.stderr_str().contains(needle)
}
pub fn is_codex_block_shape(&self) -> bool {
self.exit_code == 2 && self.stdout.is_empty() && !self.stderr.is_empty()
}
pub fn is_claude_block_shape(&self) -> bool {
self.exit_code == 0
&& !self.stdout.is_empty()
&& self.stdout_str().contains("hookSpecificOutput")
}
pub fn is_allow_shape(&self) -> bool {
self.exit_code == 0 && self.stdout_str().trim().is_empty()
}
pub fn stdout_json(&self) -> serde_json::Value {
let s = self.stdout_str();
serde_json::from_str(s.trim())
.unwrap_or_else(|e| panic!("stdout not valid JSON: {e}\n{self}"))
}
}
impl fmt::Display for HookOutcome {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "--- HookOutcome postmortem ---")?;
writeln!(f, "exit_code: {}", self.exit_code)?;
writeln!(f, "home_dir: {}", self.home_dir.display())?;
writeln!(f, "stdin ({} bytes):", self.stdin_sent.len())?;
writeln!(f, " {}", String::from_utf8_lossy(&self.stdin_sent))?;
writeln!(f, "stdout ({} bytes):", self.stdout.len())?;
writeln!(f, " UTF-8: {}", String::from_utf8_lossy(&self.stdout))?;
if self.stdout.len() <= 256 {
write!(f, " hex: ")?;
for b in &self.stdout {
write!(f, "{b:02x} ")?;
}
writeln!(f)?;
}
writeln!(f, "stderr ({} bytes):", self.stderr.len())?;
writeln!(f, " {}", String::from_utf8_lossy(&self.stderr))?;
write!(f, "--- end postmortem ---")
}
}
impl fmt::Debug for HookOutcome {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(self, f)
}
}
fn dcg_binary() -> PathBuf {
let mut path = std::env::current_exe().unwrap();
path.pop(); path.pop(); path.push("dcg");
path
}
fn build_codex_payload(command: &str) -> String {
let escaped = command.replace('\\', "\\\\").replace('"', "\\\"");
format!(
r#"{{
"session_id": "019dd11d-b795-7261-a9cb-9b85a5dad632",
"turn_id": "turn-test-1",
"transcript_path": null,
"cwd": "/tmp/test-workdir",
"hook_event_name": "PreToolUse",
"model": "gpt-5.5",
"permission_mode": "bypassPermissions",
"tool_name": "Bash",
"tool_input": {{ "command": "{escaped}" }},
"tool_use_id": "call_test_abc123"
}}"#
)
}
fn build_claude_payload(command: &str) -> String {
let escaped = command.replace('\\', "\\\\").replace('"', "\\\"");
format!(
r#"{{
"session_id": "sess-claude-test",
"transcript_path": "/tmp/claude/transcript.jsonl",
"cwd": "/tmp/test-workdir",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {{ "command": "{escaped}" }},
"tool_use_id": "toolu_01TEST"
}}"#
)
}
fn build_gemini_payload(command: &str) -> String {
let escaped = command.replace('\\', "\\\\").replace('"', "\\\"");
format!(
r#"{{
"session_id": "gemini-test-session",
"transcript_path": "/tmp/gemini/transcript.json",
"cwd": "/tmp/test-workdir",
"hook_event_name": "BeforeTool",
"timestamp": "2026-05-01T00:00:00Z",
"tool_name": "run_shell_command",
"tool_input": {{ "command": "{escaped}" }}
}}"#
)
}
fn make_hermetic_home() -> tempfile::TempDir {
tempfile::tempdir().expect("failed to create hermetic HOME tempdir")
}
pub fn run_hook_raw(json_bytes: &[u8], extra_env: &[(&str, &str)]) -> HookOutcome {
let home = make_hermetic_home();
let home_path = home.path().to_path_buf();
let tmp_path = home.path().join("tmp");
std::fs::create_dir_all(&tmp_path).ok();
let system_path = std::env::var("PATH").unwrap_or_default();
let mut cmd = Command::new(dcg_binary());
cmd.env_clear()
.env("PATH", &system_path)
.env("HOME", home.path())
.env("TMPDIR", &tmp_path)
.env("NO_COLOR", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for (k, v) in extra_env {
cmd.env(k, v);
}
let mut child = cmd.spawn().expect("failed to spawn dcg process");
{
let stdin = child.stdin.as_mut().expect("failed to get stdin");
if let Err(err) = stdin.write_all(json_bytes) {
assert_eq!(
err.kind(),
ErrorKind::BrokenPipe,
"failed to write to stdin: {err}"
);
}
}
let output = child.wait_with_output().expect("failed to wait for dcg");
let keep = std::env::var_os("DCG_TEST_KEEP_TEMPDIRS").is_some();
if keep {
eprintln!(" [keep-tempdirs] hermetic HOME: {}", home.path().display());
let _ = home.keep();
}
HookOutcome {
stdout: output.stdout,
stderr: output.stderr,
exit_code: output.status.code().unwrap_or(-1),
stdin_sent: json_bytes.to_vec(),
home_dir: home_path,
}
}
pub fn run_hook_raw_with_config(
json_bytes: &[u8],
config_toml: &str,
extra_env: &[(&str, &str)],
) -> HookOutcome {
let home = make_hermetic_home();
let home_path = home.path().to_path_buf();
let tmp_path = home.path().join("tmp");
let config_dir = home.path().join(".config/dcg");
std::fs::create_dir_all(&tmp_path).ok();
std::fs::create_dir_all(&config_dir).expect("failed to create config dir");
std::fs::write(config_dir.join("config.toml"), config_toml).expect("failed to write config");
let system_path = std::env::var("PATH").unwrap_or_default();
let mut cmd = Command::new(dcg_binary());
cmd.env_clear()
.env("PATH", &system_path)
.env("HOME", home.path())
.env("TMPDIR", &tmp_path)
.env("NO_COLOR", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for (k, v) in extra_env {
cmd.env(k, v);
}
let mut child = cmd.spawn().expect("failed to spawn dcg process");
{
let stdin = child.stdin.as_mut().expect("failed to get stdin");
if let Err(err) = stdin.write_all(json_bytes) {
assert_eq!(
err.kind(),
ErrorKind::BrokenPipe,
"failed to write to stdin: {err}"
);
}
}
let output = child.wait_with_output().expect("failed to wait for dcg");
HookOutcome {
stdout: output.stdout,
stderr: output.stderr,
exit_code: output.status.code().unwrap_or(-1),
stdin_sent: json_bytes.to_vec(),
home_dir: home_path,
}
}
pub fn run_codex_hook(command: &str) -> HookOutcome {
run_codex_hook_with_env(command, &[], &[])
}
pub fn run_codex_hook_with_env(
command: &str,
extra_env: &[(&str, &str)],
_remove_env: &[&str],
) -> HookOutcome {
let payload = build_codex_payload(command);
run_hook_raw(payload.as_bytes(), extra_env)
}
pub fn run_claude_hook(command: &str) -> HookOutcome {
run_claude_hook_with_env(command, &[], &[])
}
pub fn run_claude_hook_with_env(
command: &str,
extra_env: &[(&str, &str)],
_remove_env: &[&str],
) -> HookOutcome {
let payload = build_claude_payload(command);
run_hook_raw(payload.as_bytes(), extra_env)
}
#[test]
fn smoke_codex_safe_command_allowed() {
let outcome = run_codex_hook("git status");
assert!(
outcome.is_allow_shape(),
"safe command via Codex should be allowed (exit 0, empty stdout)\n{outcome}"
);
}
#[test]
fn smoke_claude_safe_command_allowed() {
let outcome = run_claude_hook("git status");
assert!(
outcome.is_allow_shape(),
"safe command via Claude should be allowed (exit 0, empty stdout)\n{outcome}"
);
}
#[test]
fn smoke_codex_destructive_command_blocked() {
let outcome = run_codex_hook("git reset --hard HEAD~1");
assert!(
outcome.is_codex_block_shape(),
"destructive command via Codex should produce exit 2 + empty stdout + non-empty stderr\n{outcome}"
);
}
#[test]
fn smoke_claude_destructive_command_blocked() {
let outcome = run_claude_hook("git reset --hard HEAD~1");
assert!(
outcome.is_claude_block_shape(),
"destructive command via Claude should produce exit 0 + hookSpecificOutput JSON\n{outcome}"
);
}
#[test]
fn codex_powershell_wrapped_destructive_command_blocked() {
let outcome = run_codex_hook("powershell.exe -Command 'git reset --hard HEAD~1'");
assert!(
outcome.is_codex_block_shape(),
"PowerShell-wrapped destructive command via Codex must produce exit 2 + empty stdout + non-empty stderr\n{outcome}"
);
let full_path = "\"C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0\\powershell.exe\" -Command 'git reset --hard HEAD~1'";
let outcome_fp = run_codex_hook(full_path);
assert!(
outcome_fp.is_codex_block_shape(),
"quoted-full-path PowerShell-wrapped destructive command via Codex must be blocked\n{outcome_fp}"
);
let outcome_pwsh = run_codex_hook("pwsh -c 'git reset --hard HEAD~1'");
assert!(
outcome_pwsh.is_codex_block_shape(),
"pwsh -c wrapped destructive command via Codex must be blocked\n{outcome_pwsh}"
);
}
#[test]
fn codex_powershell_wrapped_safe_command_allowed() {
let outcome = run_codex_hook("powershell.exe -Command 'git status'");
assert!(
outcome.is_allow_shape(),
"safe PowerShell-wrapped command via Codex must be allowed (exit 0, empty stdout)\n{outcome}"
);
}
#[test]
fn copilot_tool_args_without_tool_name_blocks_destructive_command() {
let payload = serde_json::json!({
"event": "pre-tool-use",
"toolArgs": serde_json::json!({ "command": "git reset --hard" }).to_string(),
})
.to_string();
let outcome = run_hook_raw(payload.as_bytes(), &[]);
assert_eq!(
outcome.exit_code, 0,
"Copilot deny should exit 0 with JSON on stdout\n{outcome}"
);
assert!(
!outcome.stdout.is_empty(),
"Copilot deny must produce stdout JSON\n{outcome}"
);
let json = outcome.stdout_json();
assert_eq!(json["permissionDecision"], "deny", "{outcome}");
assert_eq!(json["ruleId"], "core.git:reset-hard", "{outcome}");
assert_eq!(json["continue"], false, "{outcome}");
}
#[test]
fn copilot_powershell_tool_args_blocks_destructive_command() {
let payload = serde_json::json!({
"event": "pre-tool-use",
"toolName": "powershell",
"toolArgs": {
"command": "git reset --hard"
},
})
.to_string();
let outcome = run_hook_raw(payload.as_bytes(), &[]);
assert_eq!(
outcome.exit_code, 0,
"Copilot PowerShell deny should exit 0 with JSON on stdout\n{outcome}"
);
assert!(
!outcome.stdout.is_empty(),
"Copilot PowerShell deny must produce stdout JSON\n{outcome}"
);
let json = outcome.stdout_json();
assert_eq!(json["permissionDecision"], "deny", "{outcome}");
assert_eq!(json["ruleId"], "core.git:reset-hard", "{outcome}");
assert_eq!(json["continue"], false, "{outcome}");
}
#[test]
fn codex_protocol_applies_codex_agent_profile_without_env() {
let payload = build_codex_payload("git reset --hard HEAD~1");
let outcome = run_hook_raw_with_config(
payload.as_bytes(),
r#"[agents.codex-cli]
additional_allowlist = ["git reset --hard HEAD~1"]
"#,
&[],
);
assert!(
outcome.is_allow_shape(),
"Codex hook protocol should select codex-cli profile even without CODEX_CLI env\n{outcome}"
);
}
#[test]
fn gemini_protocol_applies_gemini_agent_profile_without_env() {
let payload = build_gemini_payload("git reset --hard HEAD~1");
let outcome = run_hook_raw_with_config(
payload.as_bytes(),
r#"[agents.gemini-cli]
additional_allowlist = ["git reset --hard HEAD~1"]
"#,
&[],
);
assert!(
outcome.is_allow_shape(),
"Gemini hook protocol should select gemini-cli profile even without GEMINI_CLI env\n{outcome}"
);
}
#[test]
fn copilot_protocol_applies_copilot_agent_profile_without_env() {
let payload = serde_json::json!({
"event": "pre-tool-use",
"toolArgs": serde_json::json!({ "command": "git reset --hard HEAD~1" }).to_string(),
})
.to_string();
let outcome = run_hook_raw_with_config(
payload.as_bytes(),
r#"[agents.copilot-cli]
additional_allowlist = ["git reset --hard HEAD~1"]
"#,
&[],
);
assert!(
outcome.is_allow_shape(),
"Copilot hook protocol should select copilot-cli profile even without COPILOT_CLI env\n{outcome}"
);
}
#[test]
fn codex_deny_multiple_destructive_commands() {
let commands = [
("git reset --hard HEAD~5", "core.git:reset-hard"),
("git clean -fd", "core.git:clean-force"),
("git push --force origin main", "core.git"),
("rm -rf /important/data", "core.filesystem"),
];
for (cmd, expected_rule_fragment) in commands {
let outcome = run_codex_hook(cmd);
assert_eq!(
outcome.exit_code, 2,
"Codex deny must exit 2 for '{cmd}'\n{outcome}"
);
assert!(
outcome.stdout.is_empty(),
"Codex deny must produce 0 bytes stdout for '{cmd}'\n{outcome}"
);
assert!(
!outcome.stderr.is_empty(),
"Codex deny must produce non-empty stderr for '{cmd}'\n{outcome}"
);
assert!(
outcome.stderr_contains(expected_rule_fragment),
"stderr must contain rule fragment '{expected_rule_fragment}' for '{cmd}'\n{outcome}"
);
}
}
#[test]
fn codex_deny_stderr_is_not_empty_even_when_nosuggest() {
let outcome = run_codex_hook("git reset --hard");
assert_eq!(outcome.exit_code, 2, "exit code 2 expected\n{outcome}");
assert!(
outcome.stderr.len() > 10,
"stderr must be substantive (>10 bytes), got {} bytes\n{outcome}",
outcome.stderr.len()
);
}
#[test]
fn codex_allow_safe_commands_produce_no_output() {
let safe_commands = [
"git status",
"git log --oneline -5",
"git diff HEAD",
"git checkout -b new-feature",
"ls -la",
"echo hello",
"cat README.md",
];
for cmd in safe_commands {
let outcome = run_codex_hook(cmd);
assert_eq!(
outcome.exit_code, 0,
"safe command '{cmd}' must exit 0\n{outcome}"
);
assert!(
outcome.stdout.is_empty(),
"safe command '{cmd}' must produce 0 bytes stdout\n{outcome}"
);
}
}
#[test]
fn codex_allow_git_clean_dry_run_not_blocked() {
let outcome = run_codex_hook("git clean -n");
assert!(
outcome.is_allow_shape(),
"git clean -n (dry run) must be allowed\n{outcome}"
);
}
#[test]
fn regression_claude_tool_use_id_bash_stays_claude_path() {
let outcome = run_claude_hook("git reset --hard HEAD~1");
assert_eq!(
outcome.exit_code, 0,
"Claude path must exit 0, not 2\n{outcome}"
);
assert!(
outcome.is_claude_block_shape(),
"Claude deny must produce hookSpecificOutput JSON on stdout\n{outcome}"
);
let json = outcome.stdout_json();
assert_eq!(
json["hookSpecificOutput"]["permissionDecision"], "deny",
"Claude deny must have permissionDecision=deny\n{outcome}"
);
}
#[test]
fn regression_claude_tool_use_id_launch_process_stays_claude_path() {
let payload = r#"{
"session_id": "sess-claude-test",
"transcript_path": "/tmp/claude/transcript.jsonl",
"cwd": "/tmp/test-workdir",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "launch-process",
"tool_input": { "command": "git reset --hard HEAD~1" },
"tool_use_id": "toolu_01LAUNCH"
}"#
.to_string();
let outcome = run_hook_raw(payload.as_bytes(), &[]);
assert_eq!(
outcome.exit_code, 0,
"launch-process Claude path must exit 0\n{outcome}"
);
assert!(
outcome.is_claude_block_shape(),
"launch-process Claude deny must produce hookSpecificOutput JSON\n{outcome}"
);
}
#[test]
fn codex_warn_path_exits_zero_with_stderr_warning() {
let home = tempfile::tempdir().expect("tempdir");
let config_dir = home.path().join(".config/dcg");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.toml"),
b"[policy.rules]\n\"core.git:reset-hard\" = \"warn\"\n",
)
.unwrap();
let payload = build_codex_payload("git reset --hard HEAD~1");
let system_path = std::env::var("PATH").unwrap_or_default();
let mut cmd = Command::new(dcg_binary());
cmd.env_clear()
.env("PATH", &system_path)
.env("HOME", home.path())
.env("TMPDIR", home.path().join("tmp"))
.env("NO_COLOR", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().expect("spawn");
{
let stdin = child.stdin.as_mut().unwrap();
stdin.write_all(payload.as_bytes()).unwrap();
}
let output = child.wait_with_output().unwrap();
let outcome = HookOutcome {
stdout: output.stdout,
stderr: output.stderr,
exit_code: output.status.code().unwrap_or(-1),
stdin_sent: payload.into_bytes(),
home_dir: home.path().to_path_buf(),
};
assert_eq!(
outcome.exit_code, 0,
"Codex warn must exit 0 (not 2)\n{outcome}"
);
assert!(
outcome.stdout.is_empty(),
"Codex warn must produce 0 bytes stdout\n{outcome}"
);
assert!(
!outcome.stderr.is_empty(),
"Codex warn must produce non-empty stderr\n{outcome}"
);
assert!(
outcome.stderr_contains("WARNING") || outcome.stderr_contains("warn"),
"stderr must contain warning text\n{outcome}"
);
}
#[test]
fn codex_bypass_exits_zero_silently() {
let outcome = run_codex_hook_with_env("git reset --hard HEAD~1", &[("DCG_BYPASS", "1")], &[]);
assert_eq!(outcome.exit_code, 0, "bypass must exit 0, not 2\n{outcome}");
assert!(
outcome.stdout.is_empty(),
"bypass must produce no stdout\n{outcome}"
);
}
#[test]
fn claude_bypass_exits_zero_silently() {
let outcome = run_claude_hook_with_env("git reset --hard HEAD~1", &[("DCG_BYPASS", "1")], &[]);
assert_eq!(outcome.exit_code, 0, "Claude bypass must exit 0\n{outcome}");
assert!(
outcome.stdout.is_empty(),
"Claude bypass must produce no stdout\n{outcome}"
);
}
#[test]
fn claude_deny_matrix_multiple_destructive_commands() {
let commands = [
("git reset --hard HEAD~5", "core.git"),
("git clean -fd", "core.git"),
("git push --force origin main", "core.git"),
("rm -rf /important/data", "core.filesystem"),
];
for (cmd, expected_pack_fragment) in commands {
let outcome = run_claude_hook(cmd);
assert_eq!(
outcome.exit_code, 0,
"Claude deny must exit 0 for '{cmd}'\n{outcome}"
);
assert!(
!outcome.stdout.is_empty(),
"Claude deny must produce non-empty stdout JSON for '{cmd}'\n{outcome}"
);
assert!(
outcome.is_claude_block_shape(),
"Claude deny must have hookSpecificOutput for '{cmd}'\n{outcome}"
);
let json = outcome.stdout_json();
let hso = &json["hookSpecificOutput"];
assert_eq!(
hso["hookEventName"], "PreToolUse",
"hookEventName must be PreToolUse for '{cmd}'\n{outcome}"
);
assert_eq!(
hso["permissionDecision"], "deny",
"permissionDecision must be deny for '{cmd}'\n{outcome}"
);
assert!(
hso["permissionDecisionReason"].is_string()
&& !hso["permissionDecisionReason"].as_str().unwrap().is_empty(),
"permissionDecisionReason must be non-empty string for '{cmd}'\n{outcome}"
);
let code = hso["allowOnceCode"].as_str();
assert!(
code.is_some() && code.unwrap().len() >= 5,
"allowOnceCode must be >= 5 chars for '{cmd}', got: {code:?}\n{outcome}"
);
let hash = hso["allowOnceFullHash"].as_str();
assert!(
hash.is_some() && hash.unwrap().len() >= 16,
"allowOnceFullHash must be >= 16 chars for '{cmd}', got: {hash:?}\n{outcome}"
);
let pack_id = hso["packId"].as_str().unwrap_or("");
assert!(
pack_id.contains(expected_pack_fragment),
"packId must contain '{expected_pack_fragment}' for '{cmd}', got: '{pack_id}'\n{outcome}"
);
let rule_id = hso["ruleId"].as_str().unwrap_or("");
assert!(
rule_id.contains(expected_pack_fragment),
"ruleId must contain '{expected_pack_fragment}' for '{cmd}', got: '{rule_id}'\n{outcome}"
);
let severity = hso["severity"].as_str().unwrap_or("");
assert!(
["critical", "high", "medium", "low"].contains(&severity),
"severity must be a known level for '{cmd}', got: '{severity}'\n{outcome}"
);
assert!(
hso["remediation"].is_object(),
"remediation must be present as object for '{cmd}'\n{outcome}"
);
let remediation = &hso["remediation"];
let aoc = remediation["allowOnceCommand"].as_str().unwrap_or("");
assert!(
aoc.starts_with("dcg allow-once "),
"remediation.allowOnceCommand must start with 'dcg allow-once ' for '{cmd}', got: '{aoc}'\n{outcome}"
);
assert!(
!outcome.stderr.is_empty(),
"Claude deny stderr must be non-empty (colored deny block) for '{cmd}'\n{outcome}"
);
}
}
#[test]
fn claude_deny_git_checkout_file_restore() {
let outcome = run_claude_hook("git checkout -- important_file.rs");
assert!(
outcome.is_claude_block_shape(),
"git checkout -- <file> must be denied by Claude\n{outcome}"
);
let json = outcome.stdout_json();
assert_eq!(json["hookSpecificOutput"]["permissionDecision"], "deny");
}
#[test]
fn claude_deny_or_warn_git_stash_drop() {
let outcome = run_claude_hook("git stash drop");
assert_eq!(
outcome.exit_code, 0,
"git stash drop must exit 0 via Claude\n{outcome}"
);
assert!(
!outcome.stdout.is_empty(),
"git stash drop must produce stdout JSON via Claude\n{outcome}"
);
let json = outcome.stdout_json();
let decision = json["hookSpecificOutput"]["permissionDecision"]
.as_str()
.unwrap_or("");
assert!(
decision == "deny" || decision == "ask",
"git stash drop must be denied or warned (ask), got '{decision}'\n{outcome}"
);
}
#[test]
fn claude_allow_safe_commands_produce_no_output() {
let safe_commands = [
"git status",
"git log --oneline -5",
"git diff HEAD",
"git checkout -b new-feature",
"ls -la",
"echo hello",
"cat README.md",
];
for cmd in safe_commands {
let outcome = run_claude_hook(cmd);
assert_eq!(
outcome.exit_code, 0,
"safe command '{cmd}' must exit 0 via Claude\n{outcome}"
);
assert!(
outcome.stdout.is_empty(),
"safe command '{cmd}' must produce 0 bytes stdout via Claude\n{outcome}"
);
}
}
#[test]
fn claude_allow_git_clean_dry_run_not_blocked() {
let outcome = run_claude_hook("git clean -n");
assert!(
outcome.is_allow_shape(),
"git clean -n (dry run) must be allowed via Claude\n{outcome}"
);
}
#[test]
fn claude_warn_path_exits_zero_with_ask_json() {
let home = tempfile::tempdir().expect("tempdir");
let config_dir = home.path().join(".config/dcg");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.toml"),
b"[policy.rules]\n\"core.git:reset-hard\" = \"warn\"\n",
)
.unwrap();
let payload = build_claude_payload("git reset --hard HEAD~1");
let system_path = std::env::var("PATH").unwrap_or_default();
let mut cmd = Command::new(dcg_binary());
cmd.env_clear()
.env("PATH", &system_path)
.env("HOME", home.path())
.env("TMPDIR", home.path().join("tmp"))
.env("NO_COLOR", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().expect("spawn");
{
let stdin = child.stdin.as_mut().unwrap();
stdin.write_all(payload.as_bytes()).unwrap();
}
let output = child.wait_with_output().unwrap();
let outcome = HookOutcome {
stdout: output.stdout,
stderr: output.stderr,
exit_code: output.status.code().unwrap_or(-1),
stdin_sent: payload.into_bytes(),
home_dir: home.path().to_path_buf(),
};
assert_eq!(outcome.exit_code, 0, "Claude warn must exit 0\n{outcome}");
assert!(
!outcome.stdout.is_empty(),
"Claude warn must produce stdout JSON (unlike Codex warn which has empty stdout)\n{outcome}"
);
let json = outcome.stdout_json();
let hso = &json["hookSpecificOutput"];
assert_eq!(
hso["permissionDecision"], "ask",
"Claude warn must have permissionDecision='ask'\n{outcome}"
);
assert!(
hso["permissionDecisionReason"]
.as_str()
.unwrap_or("")
.contains("warn"),
"Claude warn reason must mention 'warn'\n{outcome}"
);
assert!(
!outcome.stderr.is_empty(),
"Claude warn stderr must be non-empty\n{outcome}"
);
assert!(
outcome.stderr_contains("WARNING") || outcome.stderr_contains("warn"),
"stderr must contain warning text\n{outcome}"
);
}
#[test]
fn copilot_warn_path_exits_zero_with_ask_json_and_continue_true() {
let payload = serde_json::json!({
"event": "pre-tool-use",
"toolName": "bash",
"toolArgs": {
"command": "git reset --hard HEAD~1"
},
})
.to_string();
let outcome = run_hook_raw_with_config(
payload.as_bytes(),
r#"[policy.rules]
"core.git:reset-hard" = "warn"
"#,
&[],
);
assert_eq!(outcome.exit_code, 0, "Copilot warn must exit 0\n{outcome}");
assert!(
!outcome.stdout.is_empty(),
"Copilot warn must produce stdout JSON\n{outcome}"
);
let json = outcome.stdout_json();
assert_eq!(
json["permissionDecision"], "ask",
"Copilot warn must have permissionDecision='ask'\n{outcome}"
);
assert_eq!(
json["continue"], true,
"Copilot warn must not emit the legacy stop signal\n{outcome}"
);
assert!(
json["permissionDecisionReason"]
.as_str()
.unwrap_or("")
.contains("warn"),
"Copilot warn reason must mention 'warn'\n{outcome}"
);
}
#[test]
fn claude_bypass_destructive_command_fully_silent() {
let outcome = run_claude_hook_with_env("git reset --hard HEAD~1", &[("DCG_BYPASS", "1")], &[]);
assert_eq!(outcome.exit_code, 0, "Claude bypass must exit 0\n{outcome}");
assert!(
outcome.stdout.is_empty(),
"Claude bypass must produce no stdout\n{outcome}"
);
assert!(
!outcome.stderr_contains("BLOCKED") && !outcome.stderr_contains("deny"),
"Claude bypass must not contain BLOCKED or deny text on stderr\n{outcome}"
);
}
#[test]
fn bypass_requires_explicit_truthy_value() {
for value in ["", "0", "false", "no", "off"] {
let codex =
run_codex_hook_with_env("git reset --hard HEAD~1", &[("DCG_BYPASS", value)], &[]);
assert!(
codex.is_codex_block_shape(),
"DCG_BYPASS={value:?} must not bypass Codex denial\n{codex}"
);
let claude =
run_claude_hook_with_env("git reset --hard HEAD~1", &[("DCG_BYPASS", value)], &[]);
assert!(
claude.is_claude_block_shape(),
"DCG_BYPASS={value:?} must not bypass Claude denial\n{claude}"
);
}
}
#[test]
fn claude_deny_writes_history_entry() {
let home = tempfile::tempdir().expect("tempdir");
let db_path = home.path().join("test-history.db");
let config_dir = home.path().join(".config/dcg");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.toml"),
b"[history]\nenabled = true\n",
)
.unwrap();
let payload = build_claude_payload("git reset --hard HEAD~1");
let system_path = std::env::var("PATH").unwrap_or_default();
let mut cmd = Command::new(dcg_binary());
cmd.env_clear()
.env("PATH", &system_path)
.env("HOME", home.path())
.env("TMPDIR", home.path().join("tmp"))
.env("NO_COLOR", "1")
.env("DCG_HISTORY_DB", &db_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().expect("spawn");
{
let stdin = child.stdin.as_mut().unwrap();
stdin.write_all(payload.as_bytes()).unwrap();
}
let output = child.wait_with_output().unwrap();
let outcome = HookOutcome {
stdout: output.stdout,
stderr: output.stderr,
exit_code: output.status.code().unwrap_or(-1),
stdin_sent: payload.into_bytes(),
home_dir: home.path().to_path_buf(),
};
assert!(
outcome.is_claude_block_shape(),
"Claude deny expected\n{outcome}"
);
assert!(
db_path.exists(),
"history DB must exist after Claude deny at {}\n{outcome}",
db_path.display()
);
let db_size = std::fs::metadata(&db_path)
.expect("failed to stat history DB")
.len();
assert!(
db_size > 4096,
"history DB must be > 4096 bytes (contains data), got {db_size} bytes\n{outcome}"
);
}
#[test]
fn claude_allow_once_round_trip() {
let home = tempfile::tempdir().expect("tempdir");
let home_path = home.path().to_path_buf();
let system_path = std::env::var("PATH").unwrap_or_default();
let deny_payload = build_claude_payload("git reset --hard HEAD~1");
let mut cmd = Command::new(dcg_binary());
cmd.env_clear()
.env("PATH", &system_path)
.env("HOME", &home_path)
.env("TMPDIR", home_path.join("tmp"))
.env("NO_COLOR", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().expect("spawn deny");
{
let stdin = child.stdin.as_mut().unwrap();
stdin.write_all(deny_payload.as_bytes()).unwrap();
}
let output = child.wait_with_output().unwrap();
let deny_outcome = HookOutcome {
stdout: output.stdout,
stderr: output.stderr,
exit_code: output.status.code().unwrap_or(-1),
stdin_sent: deny_payload.into_bytes(),
home_dir: home_path.clone(),
};
assert!(
deny_outcome.is_claude_block_shape(),
"initial deny expected\n{deny_outcome}"
);
let json = deny_outcome.stdout_json();
let allow_code = json["hookSpecificOutput"]["allowOnceCode"]
.as_str()
.unwrap_or_else(|| panic!("allowOnceCode must be present\n{deny_outcome}"));
assert!(
allow_code.len() >= 5,
"allowOnceCode too short: '{allow_code}'\n{deny_outcome}"
);
let redeem_output = Command::new(dcg_binary())
.arg("allow-once")
.arg(allow_code)
.arg("--yes")
.env_clear()
.env("PATH", &system_path)
.env("HOME", &home_path)
.env("TMPDIR", home_path.join("tmp"))
.env("NO_COLOR", "1")
.output()
.expect("failed to run allow-once redeem");
assert!(
redeem_output.status.success(),
"allow-once redeem must succeed (exit 0), got exit {}\nstdout: {}\nstderr: {}",
redeem_output.status.code().unwrap_or(-1),
String::from_utf8_lossy(&redeem_output.stdout),
String::from_utf8_lossy(&redeem_output.stderr),
);
let retry_payload = build_claude_payload("git reset --hard HEAD~1");
let mut cmd = Command::new(dcg_binary());
cmd.env_clear()
.env("PATH", &system_path)
.env("HOME", &home_path)
.env("TMPDIR", home_path.join("tmp"))
.env("NO_COLOR", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().expect("spawn retry");
{
let stdin = child.stdin.as_mut().unwrap();
stdin.write_all(retry_payload.as_bytes()).unwrap();
}
let output = child.wait_with_output().unwrap();
let retry_outcome = HookOutcome {
stdout: output.stdout,
stderr: output.stderr,
exit_code: output.status.code().unwrap_or(-1),
stdin_sent: retry_payload.into_bytes(),
home_dir: home_path.clone(),
};
assert!(
retry_outcome.is_allow_shape(),
"after allow-once redeem, same command must be allowed\n{retry_outcome}"
);
}
#[test]
fn cross_protocol_deny_structural_parity() {
let cmd = "git reset --hard HEAD~3";
let codex = run_codex_hook(cmd);
let claude = run_claude_hook(cmd);
assert!(
codex.is_codex_block_shape(),
"Codex block shape expected\n{codex}"
);
assert!(
claude.is_claude_block_shape(),
"Claude block shape expected\n{claude}"
);
assert_eq!(codex.exit_code, 2);
assert!(codex.stdout.is_empty());
assert_eq!(claude.exit_code, 0);
let json = claude.stdout_json();
assert_eq!(json["hookSpecificOutput"]["permissionDecision"], "deny");
assert!(!codex.stderr.is_empty(), "Codex must have stderr\n{codex}");
assert!(
!claude.stderr.is_empty(),
"Claude must have stderr\n{claude}"
);
assert!(
codex.stderr_contains("git reset --hard"),
"Codex stderr must mention the command\n{codex}"
);
assert!(
claude.stderr_contains("git reset --hard"),
"Claude stderr must mention the command\n{claude}"
);
}
#[test]
fn cross_protocol_allow_structural_parity() {
let cmd = "git status";
let codex = run_codex_hook(cmd);
let claude = run_claude_hook(cmd);
assert!(codex.is_allow_shape(), "Codex allow shape\n{codex}");
assert!(claude.is_allow_shape(), "Claude allow shape\n{claude}");
assert_eq!(codex.exit_code, 0);
assert_eq!(claude.exit_code, 0);
assert!(codex.stdout.is_empty());
assert!(claude.stdout.is_empty());
}
#[test]
fn failopen_not_json_at_all() {
let outcome = run_hook_raw(b"not-json-at-all", &[]);
assert_eq!(
outcome.exit_code, 0,
"malformed input must fail-open\n{outcome}"
);
assert!(
outcome.stdout.is_empty(),
"no stdout on fail-open\n{outcome}"
);
}
#[test]
fn failopen_incomplete_json_brace() {
let outcome = run_hook_raw(b"{", &[]);
assert_eq!(
outcome.exit_code, 0,
"incomplete JSON must fail-open\n{outcome}"
);
assert!(
outcome.stdout.is_empty(),
"no stdout on fail-open\n{outcome}"
);
}
#[test]
fn failopen_json_null() {
let outcome = run_hook_raw(b"null", &[]);
assert_eq!(outcome.exit_code, 0, "JSON null must fail-open\n{outcome}");
assert!(
outcome.stdout.is_empty(),
"no stdout on fail-open\n{outcome}"
);
}
#[test]
fn failopen_json_array() {
let outcome = run_hook_raw(b"[]", &[]);
assert_eq!(outcome.exit_code, 0, "JSON array must fail-open\n{outcome}");
assert!(
outcome.stdout.is_empty(),
"no stdout on fail-open\n{outcome}"
);
}
#[test]
fn failopen_json_missing_tool_input() {
let payload = br#"{ "tool_name": "Bash" }"#;
let outcome = run_hook_raw(payload, &[]);
assert_eq!(
outcome.exit_code, 0,
"missing tool_input must fail-open\n{outcome}"
);
assert!(
outcome.stdout.is_empty(),
"no stdout on fail-open\n{outcome}"
);
}
#[test]
fn failopen_json_tool_input_null() {
let payload = br#"{ "tool_name": "Bash", "tool_input": null }"#;
let outcome = run_hook_raw(payload, &[]);
assert_eq!(
outcome.exit_code, 0,
"null tool_input must fail-open\n{outcome}"
);
assert!(
outcome.stdout.is_empty(),
"no stdout on fail-open\n{outcome}"
);
}
#[test]
fn failopen_json_command_is_number() {
let payload = br#"{ "tool_name": "Bash", "tool_input": { "command": 42 } }"#;
let outcome = run_hook_raw(payload, &[]);
assert_eq!(
outcome.exit_code, 0,
"numeric command must fail-open\n{outcome}"
);
assert!(
outcome.stdout.is_empty(),
"no stdout on fail-open\n{outcome}"
);
}
#[test]
fn failopen_json_command_is_array() {
let payload = br#"{ "tool_name": "Bash", "tool_input": { "command": ["git", "reset"] } }"#;
let outcome = run_hook_raw(payload, &[]);
assert_eq!(
outcome.exit_code, 0,
"array command must fail-open\n{outcome}"
);
assert!(
outcome.stdout.is_empty(),
"no stdout on fail-open\n{outcome}"
);
}
#[test]
fn failopen_json_command_is_object() {
let payload = br#"{ "tool_name": "Bash", "tool_input": { "command": {"cmd": "git reset"} } }"#;
let outcome = run_hook_raw(payload, &[]);
assert_eq!(
outcome.exit_code, 0,
"object command must fail-open\n{outcome}"
);
assert!(
outcome.stdout.is_empty(),
"no stdout on fail-open\n{outcome}"
);
}
#[test]
fn failopen_empty_stdin() {
let outcome = run_hook_raw(b"", &[]);
assert_eq!(
outcome.exit_code, 0,
"empty stdin must fail-open\n{outcome}"
);
assert!(
outcome.stdout.is_empty(),
"no stdout on fail-open\n{outcome}"
);
}
#[test]
fn failopen_truncated_json() {
let payload = br#"{ "tool_name": "Bash", "tool_input": { "command": "git reset --ha"#;
let outcome = run_hook_raw(payload, &[]);
assert_eq!(
outcome.exit_code, 0,
"truncated JSON must fail-open\n{outcome}"
);
assert!(
outcome.stdout.is_empty(),
"no stdout on fail-open\n{outcome}"
);
}
#[test]
fn failopen_oversize_stdin() {
let padding = "x".repeat(300 * 1024);
let payload =
format!(r#"{{ "tool_name": "Bash", "tool_input": {{ "command": "{padding}" }} }}"#);
let outcome = run_hook_raw(payload.as_bytes(), &[]);
assert_eq!(
outcome.exit_code, 0,
"oversize stdin must fail-open\n{outcome}"
);
assert!(
outcome.stdout.is_empty(),
"no stdout on fail-open\n{outcome}"
);
assert!(
outcome.stderr_contains("exceeds limit"),
"stderr must mention 'exceeds limit' for oversize stdin\n{outcome}"
);
}
#[test]
fn failopen_oversize_command() {
let big_cmd = "echo ".to_string() + &"A".repeat(70 * 1024);
let payload = build_claude_payload(&big_cmd);
let outcome = run_hook_raw(payload.as_bytes(), &[]);
assert_eq!(
outcome.exit_code, 0,
"oversize command must fail-open\n{outcome}"
);
assert!(
outcome.stdout.is_empty(),
"no stdout on fail-open\n{outcome}"
);
assert!(
outcome.stderr_contains("exceeds limit"),
"stderr must mention 'exceeds limit' for oversize command\n{outcome}"
);
}
#[test]
fn failopen_turn_id_wrong_type() {
let payload = br#"{
"session_id": "test",
"turn_id": 42,
"cwd": "/tmp",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": "git reset --hard" },
"tool_use_id": "call_test"
}"#;
let outcome = run_hook_raw(payload, &[]);
assert!(
outcome.exit_code == 0 || outcome.exit_code == 2,
"wrong-type turn_id must not crash (exit 0 or 2), got {}\n{outcome}",
outcome.exit_code
);
}
#[test]
fn failopen_no_crash_signal_on_garbage() {
let garbage_payloads: &[&[u8]] = &[
b"\xff\xfe\x00\x01", b"\0\0\0\0", b"}{}{", b"true", b"42", b"\"just a string\"", b"{ \"tool_name\": 123 }", ];
for payload in garbage_payloads {
let outcome = run_hook_raw(payload, &[]);
assert_eq!(
outcome.exit_code,
0,
"garbage input must fail-open (exit 0), got {} for {:?}\n{outcome}",
outcome.exit_code,
String::from_utf8_lossy(payload)
);
assert!(
outcome.stdout.is_empty(),
"no stdout for garbage input {:?}\n{outcome}",
String::from_utf8_lossy(payload)
);
assert!(
!outcome.stderr_contains("panicked at"),
"must not panic on garbage input {:?}\n{outcome}",
String::from_utf8_lossy(payload)
);
}
}
#[test]
fn failopen_same_behavior_both_protocols() {
let codex_style = br#"{
"session_id": "test",
"turn_id": "turn-1",
"cwd": "/tmp",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {}
}"#;
let claude_style = br#"{
"session_id": "test",
"cwd": "/tmp",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {}
}"#;
let codex_outcome = run_hook_raw(codex_style, &[]);
let claude_outcome = run_hook_raw(claude_style, &[]);
assert_eq!(
codex_outcome.exit_code, 0,
"Codex-style missing command must fail-open\n{codex_outcome}"
);
assert_eq!(
claude_outcome.exit_code, 0,
"Claude-style missing command must fail-open\n{claude_outcome}"
);
assert!(codex_outcome.stdout.is_empty());
assert!(claude_outcome.stdout.is_empty());
}
#[test]
fn disable_core_git_still_blocks_due_to_core_reinsertion_codex() {
let outcome = run_codex_hook_with_env(
"git reset --hard HEAD~1",
&[("DCG_DISABLE", "core.git")],
&[],
);
assert_eq!(
outcome.exit_code, 2,
"DCG_DISABLE=core.git does NOT disable core.git with default config (known behavior)\n{outcome}"
);
}
#[test]
fn disable_core_git_still_blocks_due_to_core_reinsertion_claude() {
let outcome = run_claude_hook_with_env(
"git reset --hard HEAD~1",
&[("DCG_DISABLE", "core.git")],
&[],
);
assert!(
outcome.is_claude_block_shape(),
"DCG_DISABLE=core.git does NOT disable core.git with default config (known behavior)\n{outcome}"
);
}
#[test]
fn packs_only_core_git_allows_filesystem_destructive_codex() {
let outcome =
run_codex_hook_with_env("rm -rf /tmp/important", &[("DCG_PACKS", "core.git")], &[]);
assert_eq!(
outcome.exit_code, 0,
"DCG_PACKS=core.git must not block filesystem commands under Codex\n{outcome}"
);
}
#[test]
fn packs_only_core_git_allows_filesystem_destructive_claude() {
let outcome =
run_claude_hook_with_env("rm -rf /tmp/important", &[("DCG_PACKS", "core.git")], &[]);
assert!(
outcome.is_allow_shape(),
"DCG_PACKS=core.git must not block filesystem commands under Claude\n{outcome}"
);
}
#[test]
fn packs_core_git_still_blocks_git_destructive_codex() {
let outcome =
run_codex_hook_with_env("git reset --hard HEAD~1", &[("DCG_PACKS", "core.git")], &[]);
assert_eq!(
outcome.exit_code, 2,
"DCG_PACKS=core.git must still block git reset under Codex\n{outcome}"
);
}
#[test]
fn packs_core_git_still_blocks_git_destructive_claude() {
let outcome =
run_claude_hook_with_env("git reset --hard HEAD~1", &[("DCG_PACKS", "core.git")], &[]);
assert!(
outcome.is_claude_block_shape(),
"DCG_PACKS=core.git must still block git reset under Claude\n{outcome}"
);
}
#[test]
fn disable_core_filesystem_still_blocks_git_codex() {
let outcome = run_codex_hook_with_env(
"git reset --hard HEAD~1",
&[("DCG_DISABLE", "core.filesystem")],
&[],
);
assert_eq!(
outcome.exit_code, 2,
"DCG_DISABLE=core.filesystem must NOT affect git blocks under Codex\n{outcome}"
);
}
#[test]
fn disable_core_filesystem_still_blocks_git_claude() {
let outcome = run_claude_hook_with_env(
"git reset --hard HEAD~1",
&[("DCG_DISABLE", "core.filesystem")],
&[],
);
assert!(
outcome.is_claude_block_shape(),
"DCG_DISABLE=core.filesystem must NOT affect git blocks under Claude\n{outcome}"
);
}
fn extract_allow_once_code_from_pending_store(home: &std::path::Path) -> Option<String> {
let pending_path = home.join(".config/dcg/pending_exceptions.jsonl");
let content = std::fs::read_to_string(&pending_path).ok()?;
for line in content.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Ok(val) = serde_json::from_str::<serde_json::Value>(line) {
if let Some(code) = val["short_code"].as_str() {
if code.len() >= 5 {
return Some(code.to_string());
}
}
}
}
None
}
#[test]
fn codex_deny_creates_pending_exception_with_code() {
let home = tempfile::tempdir().expect("tempdir");
let home_path = home.path().to_path_buf();
let system_path = std::env::var("PATH").unwrap_or_default();
let payload = build_codex_payload("git reset --hard HEAD~1");
let mut cmd = Command::new(dcg_binary());
cmd.env_clear()
.env("PATH", &system_path)
.env("HOME", &home_path)
.env("TMPDIR", home_path.join("tmp"))
.env("NO_COLOR", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().expect("spawn");
{
let stdin = child.stdin.as_mut().unwrap();
stdin.write_all(payload.as_bytes()).unwrap();
}
let output = child.wait_with_output().unwrap();
let outcome = HookOutcome {
stdout: output.stdout,
stderr: output.stderr,
exit_code: output.status.code().unwrap_or(-1),
stdin_sent: payload.into_bytes(),
home_dir: home_path.clone(),
};
assert!(outcome.is_codex_block_shape(), "block expected\n{outcome}");
let code = extract_allow_once_code_from_pending_store(&home_path);
assert!(
code.is_some(),
"Codex deny must create a pending exception with short_code\n{outcome}"
);
assert!(
code.as_ref().unwrap().len() >= 5,
"short_code must be >= 5 chars, got {:?}\n{outcome}",
code
);
}
#[test]
fn codex_allow_once_round_trip() {
let home = tempfile::tempdir().expect("tempdir");
let home_path = home.path().to_path_buf();
let system_path = std::env::var("PATH").unwrap_or_default();
let deny_payload = build_codex_payload("git reset --hard HEAD~1");
let mut cmd = Command::new(dcg_binary());
cmd.env_clear()
.env("PATH", &system_path)
.env("HOME", &home_path)
.env("TMPDIR", home_path.join("tmp"))
.env("NO_COLOR", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().expect("spawn deny");
{
let stdin = child.stdin.as_mut().unwrap();
stdin.write_all(deny_payload.as_bytes()).unwrap();
}
let output = child.wait_with_output().unwrap();
let deny_outcome = HookOutcome {
stdout: output.stdout,
stderr: output.stderr,
exit_code: output.status.code().unwrap_or(-1),
stdin_sent: deny_payload.into_bytes(),
home_dir: home_path.clone(),
};
assert!(
deny_outcome.is_codex_block_shape(),
"initial Codex deny expected\n{deny_outcome}"
);
let allow_code = extract_allow_once_code_from_pending_store(&home_path)
.unwrap_or_else(|| panic!("pending store must contain short_code\n{deny_outcome}"));
let redeem_output = Command::new(dcg_binary())
.arg("allow-once")
.arg(&allow_code)
.arg("--yes")
.env_clear()
.env("PATH", &system_path)
.env("HOME", &home_path)
.env("TMPDIR", home_path.join("tmp"))
.env("NO_COLOR", "1")
.output()
.expect("failed to run allow-once redeem");
assert!(
redeem_output.status.success(),
"allow-once redeem must succeed (exit 0), got exit {}\nstdout: {}\nstderr: {}",
redeem_output.status.code().unwrap_or(-1),
String::from_utf8_lossy(&redeem_output.stdout),
String::from_utf8_lossy(&redeem_output.stderr),
);
let retry_payload = build_codex_payload("git reset --hard HEAD~1");
let mut cmd = Command::new(dcg_binary());
cmd.env_clear()
.env("PATH", &system_path)
.env("HOME", &home_path)
.env("TMPDIR", home_path.join("tmp"))
.env("NO_COLOR", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().expect("spawn retry");
{
let stdin = child.stdin.as_mut().unwrap();
stdin.write_all(retry_payload.as_bytes()).unwrap();
}
let output = child.wait_with_output().unwrap();
let retry_outcome = HookOutcome {
stdout: output.stdout,
stderr: output.stderr,
exit_code: output.status.code().unwrap_or(-1),
stdin_sent: retry_payload.into_bytes(),
home_dir: home_path.clone(),
};
assert!(
retry_outcome.is_allow_shape(),
"after allow-once redeem, Codex retry must be allowed (exit 0)\n{retry_outcome}"
);
}
fn run_with_user_allowlist(allowlist_toml: &str, command: &str, use_codex: bool) -> HookOutcome {
let home = tempfile::tempdir().expect("tempdir");
let config_dir = home.path().join(".config/dcg");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(config_dir.join("allowlist.toml"), allowlist_toml.as_bytes()).unwrap();
let payload = if use_codex {
build_codex_payload(command)
} else {
build_claude_payload(command)
};
let system_path = std::env::var("PATH").unwrap_or_default();
let mut cmd = Command::new(dcg_binary());
cmd.env_clear()
.env("PATH", &system_path)
.env("HOME", home.path())
.env("TMPDIR", home.path().join("tmp"))
.env("NO_COLOR", "1")
.env("DCG_ALLOWLIST_SYSTEM_PATH", "")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().expect("spawn");
{
let stdin = child.stdin.as_mut().unwrap();
stdin.write_all(payload.as_bytes()).unwrap();
}
let output = child.wait_with_output().unwrap();
HookOutcome {
stdout: output.stdout,
stderr: output.stderr,
exit_code: output.status.code().unwrap_or(-1),
stdin_sent: payload.into_bytes(),
home_dir: home.path().to_path_buf(),
}
}
#[test]
fn allowlist_user_rule_allows_codex() {
let allowlist = r#"
[[allow]]
rule = "core.git:reset-hard"
reason = "Team accepts this risk in CI"
"#;
let outcome = run_with_user_allowlist(allowlist, "git reset --hard HEAD~1", true);
assert!(
outcome.is_allow_shape(),
"user allowlist rule must produce silent allow under Codex\n{outcome}"
);
}
#[test]
fn allowlist_user_rule_allows_claude() {
let allowlist = r#"
[[allow]]
rule = "core.git:reset-hard"
reason = "Team accepts this risk in CI"
"#;
let outcome = run_with_user_allowlist(allowlist, "git reset --hard HEAD~1", false);
assert!(
outcome.is_allow_shape(),
"user allowlist rule must produce silent allow under Claude\n{outcome}"
);
}
#[test]
fn allowlist_exact_command_allows_codex() {
let allowlist = r#"
[[allow]]
exact_command = "git clean -fd"
reason = "Build script cleanup"
"#;
let outcome = run_with_user_allowlist(allowlist, "git clean -fd", true);
assert!(
outcome.is_allow_shape(),
"exact_command allowlist must produce silent allow under Codex\n{outcome}"
);
}
#[test]
fn allowlist_exact_command_allows_claude() {
let allowlist = r#"
[[allow]]
exact_command = "git clean -fd"
reason = "Build script cleanup"
"#;
let outcome = run_with_user_allowlist(allowlist, "git clean -fd", false);
assert!(
outcome.is_allow_shape(),
"exact_command allowlist must produce silent allow under Claude\n{outcome}"
);
}
#[test]
fn allowlist_does_not_affect_unrelated_commands_codex() {
let allowlist = r#"
[[allow]]
rule = "core.git:reset-hard"
reason = "Only allow reset-hard"
"#;
let outcome = run_with_user_allowlist(allowlist, "git clean -fd", true);
assert_eq!(
outcome.exit_code, 2,
"non-allowlisted command must still be blocked under Codex\n{outcome}"
);
}
#[test]
fn allowlist_does_not_affect_unrelated_commands_claude() {
let allowlist = r#"
[[allow]]
rule = "core.git:reset-hard"
reason = "Only allow reset-hard"
"#;
let outcome = run_with_user_allowlist(allowlist, "git clean -fd", false);
assert!(
outcome.is_claude_block_shape(),
"non-allowlisted command must still be blocked under Claude\n{outcome}"
);
}
#[test]
fn allowlist_empty_file_still_blocks_codex() {
let outcome = run_with_user_allowlist("", "git reset --hard HEAD~1", true);
assert_eq!(
outcome.exit_code, 2,
"empty allowlist must still block under Codex\n{outcome}"
);
}
#[test]
fn allowlist_cross_protocol_parity() {
let allowlist = r#"
[[allow]]
rule = "core.git:reset-hard"
reason = "Accepted risk"
"#;
let codex = run_with_user_allowlist(allowlist, "git reset --hard HEAD~1", true);
let claude = run_with_user_allowlist(allowlist, "git reset --hard HEAD~1", false);
assert!(codex.is_allow_shape(), "Codex must allow\n{codex}");
assert!(claude.is_allow_shape(), "Claude must allow\n{claude}");
}
#[test]
fn codex_deny_writes_history_entry_despite_exit_2() {
let home = tempfile::tempdir().expect("tempdir");
let db_path = home.path().join("codex-history.db");
let config_dir = home.path().join(".config/dcg");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.toml"),
b"[history]\nenabled = true\n",
)
.unwrap();
let payload = build_codex_payload("git reset --hard HEAD~1");
let system_path = std::env::var("PATH").unwrap_or_default();
let mut cmd = Command::new(dcg_binary());
cmd.env_clear()
.env("PATH", &system_path)
.env("HOME", home.path())
.env("TMPDIR", home.path().join("tmp"))
.env("NO_COLOR", "1")
.env("DCG_HISTORY_DB", &db_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().expect("spawn");
{
let stdin = child.stdin.as_mut().unwrap();
stdin.write_all(payload.as_bytes()).unwrap();
}
let output = child.wait_with_output().unwrap();
let outcome = HookOutcome {
stdout: output.stdout,
stderr: output.stderr,
exit_code: output.status.code().unwrap_or(-1),
stdin_sent: payload.into_bytes(),
home_dir: home.path().to_path_buf(),
};
assert_eq!(outcome.exit_code, 2, "Codex deny must exit 2\n{outcome}");
assert!(
db_path.exists(),
"history DB must exist after Codex deny (flush_sync before exit 2)\n{outcome}"
);
let db_size = std::fs::metadata(&db_path).expect("stat history DB").len();
assert!(
db_size >= 4096,
"history DB must contain at least one page (>= 4096 bytes), got {db_size}\n{outcome}"
);
}
#[test]
fn codex_deny_with_history_disabled_still_exits_2() {
let home = tempfile::tempdir().expect("tempdir");
let config_dir = home.path().join(".config/dcg");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.toml"),
b"[history]\nenabled = false\n",
)
.unwrap();
let payload = build_codex_payload("git reset --hard HEAD~1");
let system_path = std::env::var("PATH").unwrap_or_default();
let mut cmd = Command::new(dcg_binary());
cmd.env_clear()
.env("PATH", &system_path)
.env("HOME", home.path())
.env("TMPDIR", home.path().join("tmp"))
.env("NO_COLOR", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().expect("spawn");
{
let stdin = child.stdin.as_mut().unwrap();
stdin.write_all(payload.as_bytes()).unwrap();
}
let output = child.wait_with_output().unwrap();
assert_eq!(
output.status.code().unwrap_or(-1),
2,
"Codex deny must still exit 2 with history disabled"
);
assert!(
output.stdout.is_empty(),
"Codex deny must produce no stdout with history disabled"
);
assert!(
!output.stderr.is_empty(),
"Codex deny must still produce stderr with history disabled"
);
let default_db = home.path().join(".config/dcg/history.db");
assert!(
!default_db.exists(),
"no history DB should be created when history is disabled"
);
}
#[test]
fn codex_rapid_fire_denies_all_persist_to_history() {
let home = tempfile::tempdir().expect("tempdir");
let db_path = home.path().join("rapid-history.db");
let config_dir = home.path().join(".config/dcg");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.toml"),
b"[history]\nenabled = true\n",
)
.unwrap();
let system_path = std::env::var("PATH").unwrap_or_default();
let commands = [
"git reset --hard HEAD~1",
"git reset --hard HEAD~2",
"git clean -fd",
"git push --force origin main",
"git reset --hard HEAD~3",
];
for cmd in &commands {
let payload = build_codex_payload(cmd);
let mut child = Command::new(dcg_binary())
.env_clear()
.env("PATH", &system_path)
.env("HOME", home.path())
.env("TMPDIR", home.path().join("tmp"))
.env("NO_COLOR", "1")
.env("DCG_HISTORY_DB", &db_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn");
{
let stdin = child.stdin.as_mut().unwrap();
stdin.write_all(payload.as_bytes()).unwrap();
}
let output = child.wait_with_output().unwrap();
assert_eq!(
output.status.code().unwrap_or(-1),
2,
"Codex deny must exit 2 for '{cmd}'"
);
}
assert!(
db_path.exists(),
"history DB must exist after 5 rapid-fire Codex denies"
);
let db_size = std::fs::metadata(&db_path).expect("stat history DB").len();
assert!(
db_size >= 4096,
"history DB with 5 entries must be >= 4096 bytes, got {db_size}"
);
}
#[test]
fn codex_deny_history_write_protected_dir_no_panic() {
let home = tempfile::tempdir().expect("tempdir");
let config_dir = home.path().join(".config/dcg");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.toml"),
b"[history]\nenabled = true\n",
)
.unwrap();
let readonly_dir = home.path().join("readonly");
std::fs::create_dir_all(&readonly_dir).unwrap();
let db_path = readonly_dir.join("history.db");
let mut perms = std::fs::metadata(&readonly_dir).unwrap().permissions();
perms.set_readonly(true);
std::fs::set_permissions(&readonly_dir, perms.clone()).unwrap();
let payload = build_codex_payload("git reset --hard HEAD~1");
let system_path = std::env::var("PATH").unwrap_or_default();
let mut cmd = Command::new(dcg_binary());
cmd.env_clear()
.env("PATH", &system_path)
.env("HOME", home.path())
.env("TMPDIR", home.path().join("tmp"))
.env("NO_COLOR", "1")
.env("DCG_HISTORY_DB", &db_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().expect("spawn");
{
let stdin = child.stdin.as_mut().unwrap();
stdin.write_all(payload.as_bytes()).unwrap();
}
let output = child.wait_with_output().unwrap();
assert_eq!(
output.status.code().unwrap_or(-1),
2,
"Codex deny must exit 2 even when history DB creation fails"
);
assert!(
!output.stderr.is_empty(),
"Codex deny must produce stderr even when history fails"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.contains("panicked at"),
"must not panic when history write fails\nstderr: {stderr}"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
perms.set_mode(perms.mode() | 0o700);
}
#[cfg(not(unix))]
{
perms.set_readonly(false);
}
std::fs::set_permissions(&readonly_dir, perms).unwrap();
}
#[test]
fn smoke_hermetic_home_isolates_pending_exceptions() {
let outcome = run_codex_hook("git reset --hard HEAD~1");
assert!(outcome.is_codex_block_shape(), "block expected\n{outcome}");
let pending_dir = outcome.home_dir.join(".config/dcg/pending");
if pending_dir.exists() {
let entries: Vec<_> = std::fs::read_dir(&pending_dir)
.expect("failed to read pending dir")
.collect();
assert!(
!entries.is_empty(),
"pending dir exists but is empty — expected pending exception entry"
);
}
if let Ok(real_home) = std::env::var("HOME") {
assert_ne!(
PathBuf::from(&real_home),
outcome.home_dir,
"hermetic HOME must differ from real HOME"
);
}
}
#[test]
#[allow(clippy::needless_collect)]
fn parallel_spawn_storm_no_cross_contamination() {
let n = 16;
let outcomes: Vec<HookOutcome> = std::thread::scope(|s| {
let handles: Vec<_> = (0..n)
.map(|_| s.spawn(|| run_codex_hook("git reset --hard HEAD~1")))
.collect();
handles.into_iter().map(|h| h.join().unwrap()).collect()
});
for (i, o) in outcomes.iter().enumerate() {
assert!(
o.is_codex_block_shape(),
"spawn {i}: expected Codex block shape\n{o}"
);
}
let homes: std::collections::HashSet<_> = outcomes.iter().map(|o| o.home_dir.clone()).collect();
assert_eq!(
homes.len(),
n,
"all {n} spawns must use distinct hermetic HOMEs, got {} unique",
homes.len()
);
for (i, o) in outcomes.iter().enumerate() {
let pending_dir = o.home_dir.join(".config/dcg/pending");
if pending_dir.exists() {
let entries: Vec<_> = std::fs::read_dir(&pending_dir)
.expect("read pending dir")
.filter_map(std::result::Result::ok)
.collect();
assert_eq!(
entries.len(),
1,
"spawn {i}: pending dir must have exactly 1 entry, found {}",
entries.len()
);
}
}
}
#[test]
#[allow(clippy::needless_collect)]
fn sequential_vs_parallel_produce_same_exit_codes() {
let cmd = "git clean -fd";
let n = 8;
let seq_codes: Vec<i32> = (0..n).map(|_| run_codex_hook(cmd).exit_code).collect();
let par_codes: Vec<i32> = std::thread::scope(|s| {
let handles: Vec<_> = (0..n)
.map(|_| s.spawn(|| run_codex_hook(cmd).exit_code))
.collect();
handles.into_iter().map(|h| h.join().unwrap()).collect()
});
for code in &seq_codes {
assert_eq!(*code, 2, "sequential run must exit 2");
}
for code in &par_codes {
assert_eq!(*code, 2, "parallel run must exit 2");
}
}
#[test]
fn hermetic_tests_do_not_touch_real_home() {
let real_home = match std::env::var("HOME") {
Ok(h) => PathBuf::from(h),
Err(_) => return, };
let real_pending = real_home.join(".config/dcg/pending");
let mtime_before = std::fs::metadata(&real_pending)
.ok()
.and_then(|m| m.modified().ok());
let outcome = run_codex_hook("git reset --hard HEAD~1");
assert!(outcome.is_codex_block_shape());
assert_ne!(outcome.home_dir, real_home);
let mtime_after = std::fs::metadata(&real_pending)
.ok()
.and_then(|m| m.modified().ok());
assert_eq!(
mtime_before, mtime_after,
"real HOME pending dir mtime must not change during hermetic test"
);
}
#[test]
#[allow(clippy::needless_collect)]
fn parallel_mixed_protocol_storm() {
let n = 8; let cmd = "git reset --hard HEAD~1";
let (codex_outcomes, claude_outcomes): (Vec<HookOutcome>, Vec<HookOutcome>) =
std::thread::scope(|s| {
let codex_handles: Vec<_> = (0..n).map(|_| s.spawn(|| run_codex_hook(cmd))).collect();
let claude_handles: Vec<_> = (0..n).map(|_| s.spawn(|| run_claude_hook(cmd))).collect();
let codex: Vec<_> = codex_handles
.into_iter()
.map(|h| h.join().unwrap())
.collect();
let claude: Vec<_> = claude_handles
.into_iter()
.map(|h| h.join().unwrap())
.collect();
(codex, claude)
});
for (i, o) in codex_outcomes.iter().enumerate() {
assert!(
o.is_codex_block_shape(),
"parallel Codex {i}: expected block shape\n{o}"
);
}
for (i, o) in claude_outcomes.iter().enumerate() {
assert!(
o.is_claude_block_shape(),
"parallel Claude {i}: expected block shape\n{o}"
);
}
let all_homes: std::collections::HashSet<_> = codex_outcomes
.iter()
.chain(claude_outcomes.iter())
.map(|o| o.home_dir.clone())
.collect();
assert_eq!(
all_homes.len(),
n * 2,
"all 16 spawns must use distinct HOMEs, got {} unique",
all_homes.len()
);
}
fn build_codex_payload_raw(command: &str) -> String {
let escaped = command
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t");
format!(
r#"{{"session_id":"s","turn_id":"turn-test-1","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{{"command":"{escaped}"}},"tool_use_id":"call_test"}}"#
)
}
fn build_claude_payload_raw(command: &str) -> String {
let escaped = command
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t");
format!(
r#"{{"session_id":"s","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{{"command":"{escaped}"}},"tool_use_id":"toolu_01TEST"}}"#
)
}
fn run_codex_heredoc(command: &str) -> HookOutcome {
let payload = build_codex_payload_raw(command);
run_hook_raw(payload.as_bytes(), &[])
}
fn run_claude_heredoc(command: &str) -> HookOutcome {
let payload = build_claude_payload_raw(command);
run_hook_raw(payload.as_bytes(), &[])
}
#[test]
fn heredoc_python_shutil_rmtree_codex_deny() {
let cmd = r#"python3 -c "import shutil; shutil.rmtree('/tmp/data')""#;
let o = run_codex_hook(cmd);
assert!(
o.is_codex_block_shape(),
"python shutil.rmtree should be blocked under Codex\n{o}"
);
assert!(
o.stderr_contains("heredoc.python"),
"stderr should mention heredoc.python pack\n{o}"
);
assert!(
o.stderr_contains("shutil_rmtree"),
"stderr should mention shutil_rmtree pattern\n{o}"
);
}
#[test]
fn heredoc_python_shutil_rmtree_claude_deny() {
let cmd = r#"python3 -c "import shutil; shutil.rmtree('/tmp/data')""#;
let o = run_claude_hook(cmd);
assert!(
o.is_claude_block_shape(),
"python shutil.rmtree should be blocked under Claude\n{o}"
);
let json = o.stdout_json();
let rule_id = json["hookSpecificOutput"]["ruleId"].as_str().unwrap_or("");
assert_eq!(
rule_id, "heredoc.python:shutil_rmtree",
"ruleId should be heredoc.python:shutil_rmtree\n{o}"
);
assert_eq!(
json["hookSpecificOutput"]["permissionDecision"].as_str(),
Some("deny"),
"permissionDecision should be deny\n{o}"
);
assert_eq!(
json["hookSpecificOutput"]["packId"].as_str(),
Some("heredoc.python"),
"packId should be heredoc.python\n{o}"
);
}
#[test]
fn heredoc_javascript_fs_rmsync_codex_deny() {
let cmd = "node <<EOF\nconst fs = require('fs');\nfs.rmSync('/etc', { recursive: true });\nEOF";
let o = run_codex_heredoc(cmd);
assert!(
o.is_codex_block_shape(),
"node fs.rmSync heredoc should be blocked under Codex\n{o}"
);
assert!(
o.stderr_contains("heredoc.javascript"),
"stderr should mention heredoc.javascript pack\n{o}"
);
assert!(
o.stderr_contains("fs_rmsync"),
"stderr should mention fs_rmsync pattern\n{o}"
);
}
#[test]
fn heredoc_javascript_fs_rmsync_claude_deny() {
let cmd = "node <<EOF\nconst fs = require('fs');\nfs.rmSync('/etc', { recursive: true });\nEOF";
let o = run_claude_heredoc(cmd);
assert!(
o.is_claude_block_shape(),
"node fs.rmSync heredoc should be blocked under Claude\n{o}"
);
let json = o.stdout_json();
let rule_id = json["hookSpecificOutput"]["ruleId"].as_str().unwrap_or("");
assert!(
rule_id.starts_with("heredoc.javascript:fs_rmsync"),
"ruleId should start with heredoc.javascript:fs_rmsync, got: {rule_id}\n{o}"
);
assert_eq!(
json["hookSpecificOutput"]["packId"].as_str(),
Some("heredoc.javascript"),
"packId should be heredoc.javascript\n{o}"
);
}
#[test]
fn heredoc_python_cross_protocol_parity() {
let cmd = r#"python3 -c "import shutil; shutil.rmtree('/tmp/data')""#;
let codex = run_codex_hook(cmd);
let claude = run_claude_hook(cmd);
assert!(codex.is_codex_block_shape(), "Codex must block\n{codex}");
assert!(
claude.is_claude_block_shape(),
"Claude must block\n{claude}"
);
let json = claude.stdout_json();
let claude_rule = json["hookSpecificOutput"]["ruleId"].as_str().unwrap_or("");
let claude_pack = json["hookSpecificOutput"]["packId"].as_str().unwrap_or("");
assert!(
codex.stderr_contains(claude_pack),
"Codex stderr must mention same pack '{claude_pack}' as Claude JSON\n\
Codex stderr: {}\nClaude JSON: {}",
codex.stderr_str(),
claude.stdout_str()
);
let pattern_part = claude_rule.split(':').next_back().unwrap_or("");
assert!(
codex.stderr_contains(pattern_part),
"Codex stderr must mention same pattern '{pattern_part}' from Claude ruleId '{claude_rule}'\n\
Codex stderr: {}",
codex.stderr_str()
);
}
#[test]
fn heredoc_javascript_cross_protocol_parity() {
let cmd = "node <<EOF\nconst fs = require('fs');\nfs.rmSync('/etc', { recursive: true });\nEOF";
let codex = run_codex_heredoc(cmd);
let claude = run_claude_heredoc(cmd);
assert!(codex.is_codex_block_shape(), "Codex must block\n{codex}");
assert!(
claude.is_claude_block_shape(),
"Claude must block\n{claude}"
);
let json = claude.stdout_json();
let claude_pack = json["hookSpecificOutput"]["packId"].as_str().unwrap_or("");
assert!(
codex.stderr_contains(claude_pack),
"Codex stderr must mention same pack '{claude_pack}' as Claude JSON\n\
Codex stderr: {}\nClaude JSON: {}",
codex.stderr_str(),
claude.stdout_str()
);
}
#[test]
fn heredoc_node_inline_exec_codex_deny() {
let cmd = r#"node -e "require('child_process').execSync('rm -rf /')""#;
let o = run_codex_hook(cmd);
assert!(
o.is_codex_block_shape(),
"node -e with child_process execSync should be blocked under Codex\n{o}"
);
assert!(
o.stderr_contains("heredoc."),
"stderr should mention a heredoc pack\n{o}"
);
}
#[test]
fn heredoc_safe_python_codex_allows() {
let cmd = r#"python3 -c "print('hello world')""#;
let o = run_codex_hook(cmd);
assert!(
o.is_allow_shape(),
"safe python one-liner should be allowed\n{o}"
);
}