From d2b10dd196d594c4a1757f28367c1e1abd0ae01b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 11:42:25 +0000 Subject: [PATCH 1/2] test: stop the tag signing test depending on ambient git config test_tag_respects_repo_git_overrides_and_cli_can_override_them asserts that a repo-configured gpg.program is consulted, by pointing it at a script that always fails and expecting the tag creation to fail with it. That only holds when gpg.format is openpgp. On a machine whose global config sets gpg.format=ssh, git routes signing to gpg.ssh.program, the stub never runs, the tag is signed successfully by the ssh signer, and the assertion evaporates -- the test fails claiming Committy ignored the override when Committy had passed it through correctly. Verified: the tag git produced in that environment carried a valid SSH signature. The test now pins gpg.format=openpgp alongside the other overrides, so it exercises the path it means to exercise regardless of the host. Separately, the three config::hierarchy tests call MergedConfig::load, which reads the user config from $HOME. The tests in config.rs mutate HOME and are marked #[serial]; these were not, so they could read a half-written or relocated user config while one of those held it, and MergedConfig::load(...).unwrap() would panic. They are now serial too. Caveat on that second change: it closes a real race but is unproven. The failure was observed once, and did not recur in thirteen subsequent runs (eight lib-only, five full) even with the fix reverted, so causality is reasoned rather than demonstrated. It is cheap and correct regardless -- a test reading global state that other tests mutate belongs in the same serial group. --- src/config/hierarchy.rs | 4 ++++ tests/tag_tests.rs | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/src/config/hierarchy.rs b/src/config/hierarchy.rs index ae3ba05..5b6e975 100644 --- a/src/config/hierarchy.rs +++ b/src/config/hierarchy.rs @@ -169,9 +169,11 @@ mod tests { use crate::config::repository::{ RepositoryMetadata, RepositoryType, VersioningConfig, VersioningRules, VersioningStrategy, }; + use serial_test::serial; use tempfile::TempDir; #[test] + #[serial] fn test_merged_config_without_repository() { let temp_dir = TempDir::new().unwrap(); let merged = MergedConfig::load(temp_dir.path()).unwrap(); @@ -181,6 +183,7 @@ mod tests { } #[test] + #[serial] fn test_regex_fallback_to_user_config() { let temp_dir = TempDir::new().unwrap(); let merged = MergedConfig::load(temp_dir.path()).unwrap(); @@ -192,6 +195,7 @@ mod tests { } #[test] + #[serial] fn test_repository_config_overrides_user() { let temp_dir = TempDir::new().unwrap(); diff --git a/tests/tag_tests.rs b/tests/tag_tests.rs index f347f5a..f676b19 100644 --- a/tests/tag_tests.rs +++ b/tests/tag_tests.rs @@ -266,6 +266,11 @@ fn test_tag_respects_repo_git_overrides_and_cli_can_override_them() { dir.path(), &[ "tag.gpgsign=true".to_string(), + // Pin the signing format. `gpg.program` is only consulted for + // openpgp; a machine with `gpg.format=ssh` in its global config + // routes signing to `gpg.ssh.program` instead, the stub below + // never runs, and this test silently asserts nothing. + "gpg.format=openpgp".to_string(), format!("gpg.program={}", failing_gpg.display()), ], ); From fbb114479653891b8fe8dcf857522a398e03517a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 12:15:56 +0000 Subject: [PATCH 2/2] test(hooks): add a behaviour matrix for the guardrail, and fix what it caught The rewritten guardrail shipped unvalidated: nothing executed it, so its deny rules were assumed rather than checked. That matters more than for ordinary code, because a guardrail that silently stops refusing looks exactly like one that works. Twelve tests feed the hook the same JSON the harness does and assert the exit code. Writing them found three defects in the hook, all now fixed: - `git push -f origin main` was not refused. The force check was one ordered regex whose `-f` alternative required a preceding space, which the `push ` match had already consumed. Force detection is now two independent conditions -- a push, and a force flag anywhere -- so flag order and adjacency stop mattering. - A heredoc feeding an interpreter was dropped along with every other heredoc body, so `bash < PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".claude/hooks/block-dangerous-git.sh") +} + +/// Feed a command to the hook exactly as the harness would, and report whether +/// it was refused. +fn is_blocked(command: &str) -> bool { + let payload = serde_json::json!({ "tool_input": { "command": command } }).to_string(); + + let mut child = Command::new("bash") + .arg(hook_path()) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("failed to run the guardrail hook"); + + child + .stdin + .take() + .expect("hook stdin") + .write_all(payload.as_bytes()) + .expect("failed to write hook input"); + + let status = child.wait().expect("hook did not exit"); + match status.code() { + Some(0) => false, + Some(2) => true, + other => panic!("hook returned unexpected exit code {other:?} for: {command}"), + } +} + +// --------------------------------------------------------------------------- +// Deny side — the reason the hook exists +// --------------------------------------------------------------------------- + +#[test] +fn refuses_unguarded_force_push() { + for command in [ + "git push --force origin main", + "git push -f origin main", + "git push origin main --force", + ] { + assert!(is_blocked(command), "must refuse: {command}"); + } +} + +#[test] +fn refuses_hard_reset() { + assert!(is_blocked("git reset --hard HEAD~1")); + assert!(is_blocked("git reset --hard origin/main")); +} + +#[test] +fn refuses_forced_clean() { + for command in ["git clean -f", "git clean -fd", "git clean -xfd"] { + assert!(is_blocked(command), "must refuse: {command}"); + } +} + +#[test] +fn refuses_forced_branch_delete() { + assert!(is_blocked("git branch -D some-branch")); +} + +#[test] +fn refuses_bulk_working_tree_discard() { + for command in ["git checkout .", "git restore .", "git checkout -- ."] { + assert!(is_blocked(command), "must refuse: {command}"); + } +} + +/// The old hook matched raw substrings, so these evaded it entirely. +#[test] +fn refuses_destructive_commands_that_evaded_substring_matching() { + for command in [ + "git reset --hard HEAD~1", // two spaces + "env FOO=1 git reset --hard HEAD~1", // leading assignment + "git -C . push --force origin main", // -C before the subcommand + "cd /tmp && git clean -fd", // not at the start of the command + ] { + assert!(is_blocked(command), "must refuse: {command}"); + } +} + +// --------------------------------------------------------------------------- +// Allow side — over-blocking is what deadlocked this repo's own workflow +// --------------------------------------------------------------------------- + +#[test] +fn allows_ordinary_pushes() { + for command in [ + "git push", + "git push -u origin my-branch", + "git push origin --delete stale-branch", + "git push --force-with-lease origin my-branch", + ] { + assert!(!is_blocked(command), "must allow: {command}"); + } +} + +#[test] +fn allows_non_destructive_git() { + for command in [ + "git status --short", + "git commit -m 'feat: something'", + "git checkout -b new-branch", + "git restore --staged src/main.rs", + "git branch -d merged-branch", + "git reset HEAD~1", + ] { + assert!(!is_blocked(command), "must allow: {command}"); + } +} + +#[test] +fn allows_commands_that_are_not_git() { + for command in ["ls -la", "cargo test", "rm -rf target"] { + assert!(!is_blocked(command), "must allow: {command}"); + } +} + +/// The failure that motivated the rewrite: the old hook matched the *text* of +/// a command, so writing documentation that merely named one was refused — +/// including, circularly, the heredoc that would have replaced the hook. +#[test] +fn allows_a_heredoc_that_writes_prose_naming_a_dangerous_command() { + let command = "cat > notes.md <<'EOF'\n\ + Run git push --force to overwrite.\n\ + git reset --hard undoes work.\n\ + EOF"; + assert!( + !is_blocked(command), + "a heredoc written to a file is data, not an invocation" + ); +} + +/// A heredoc fed to an interpreter *is* executed, so its body must still be +/// scanned. Dropping every heredoc body would make this a trivial bypass. +#[test] +fn still_refuses_a_destructive_command_inside_an_interpreter_heredoc() { + for command in [ + "bash <<'EOF'\ngit reset --hard HEAD~1\nEOF", + "sh < /tmp/note.txt"), + "quoted text is not distinguishable from `bash -c \"...\"`, so it stays blocked" + ); +}