Skip to content

Releases: karlkfi/claude-branch-guard

v1.4.2

Choose a tag to compare

@karlkfi karlkfi released this 03 Aug 22:12

Fewer git/gh approval prompts in Claude Code, with the human kept at the protected-branch boundary.

Warning

On Windows the guard was starting nothing at all. Up to and including v1.4.1 the hook was launched with a bare python3, which on Windows usually resolves to the Microsoft Store alias stub — it exits without running Python, and Claude Code treats a failed PreToolUse hook as non-blocking, so the tool call fell through to the session's own permission rules with no guard decision at all. If you run Claude Code on Windows, update. macOS and Linux were unaffected.

Highlights

The hook now finds a Python that actually runs. hooks/hooks.json invoked python3 "${CLAUDE_PLUGIN_ROOT}/hooks/branch-guard.py" directly — fine wherever python3 is a real interpreter, wrong on Windows, which ships a python3.exe alias stub that is always on PATH and fails at run time unless Python came from the Microsoft Store (exit 9009 under cmd.exe). Checking for presence does not catch it: where python3 and command -v python3 both find the stub. The new hooks/run-python-hook.cmd is a bash/cmd polyglot that resolves the interpreter by executing candidates and reading the exit code — py -3, python, python3 on Windows, python3 then python elsewhere, with python3 probed last precisely because it is the name most likely to be the stub. When none of them work it writes this guard is NOT enforcing to stderr rather than degrading quietly. Thanks to @smoochy for the fix — their first contribution to the project.

A launcher regression can no longer pass CI. The launcher is now the only route the suite takes to the hook: all 240 specs invoke hooks/run-python-hook.cmd branch-guard.py, the exact command hooks/hooks.json registers, instead of a bare interpreter. Previously a stray CRLF, a batch-label typo, or a dropped exec bit would have stayed green on every job while every real hook invocation failed — the same shape as the Windows bug, invisible for the same reason. A startup check aborts the run when the launcher's recorded mode is wrong, because a launcher that dies emits nothing and the harness reads nothing as a legitimate defer.

Upgrading

No required steps, and no decision changed. hooks/branch-guard.py is byte-identical to v1.4.1 — the classifier, the push policies, and PROTECTED_BRANCH_RE did not move, so every command auto-approved at v1.4.1 still is. What changed is how the hook gets started.

Third-party marketplaces have auto-update off by default, so an install pins its version until you turn it on or update by hand (Upgrade):

claude plugin marketplace update branch-guard
claude plugin update branch-guard@branch-guard

Either way a restart is needed — the hook is registered at startup, so a running session stays on the version it loaded.

One change that needs no action, but may be visible. The requirement is now "a working Python 3 on PATH" rather than specifically python3, so a machine that only has py -3 or python is now supported. If none of them runs, the launcher exits 1 and writes two lines to stderr naming the problem — on a Windows machine where the guard was never running, that message is the first sign of it.

Everything since v1.4.1

Five PRs, all of them shipped below — nothing withheld from this list. Four of the five are test and docs work that ships in no artifact; they are listed because the list is short enough to be complete.

  • fix: resolve Python interpreter portably so the hook runs on Windows by @smoochy in #32
  • test: drive the suite through the hook launcher by @karlkfi in #38
  • test: cover the launcher's missing-script error path by @karlkfi in #39
  • docs: codify the release-notes section menu by @karlkfi in #37
  • docs: require measuring a coverage claim before documenting it by @karlkfi in #40

Validation

240 specs, green on Linux across Python 3.10–3.13 and on Windows, on the release commit itself (run).

Every spec reaches the hook through the launcher, so it is covered by all 240 cases rather than by nothing. The two jobs are what cover both halves of the polyglot: Git Bash hands a .cmd to the Windows command processor, so windows-latest runs the batch branch while the Linux matrix runs the POSIX tail. That split is asserted rather than assumed — a fixture feeds the launcher a missing script name and checks both that it fails loudly and which path form comes back, since %~dp0 yields backslashes and the POSIX pwd yields forward slashes. If the routing ever flipped, the cmd.exe half would lose its only coverage without a single decision fixture going red.

What the suite does not assert: it drives the hook's JSON contract in a throwaway repo, not Claude Code's own permission plumbing, and it never exercises the no-interpreter path on a machine that genuinely lacks Python 3, because CI always has one. MSYS-shaped paths typed by hand in Git Bash still make the hook defer rather than decide — it over-defers, never silently allows.

Security

No advisory, and no dependencies to bump — the hook is stdlib-only Python and the plugin ships no third-party code.

The Windows fix is security-relevant even without an advisory, because a guard that fails open is worse than no guard: the operator believes it is there. On an affected Windows machine the protected-branch and destructive-command asks never fired, and whether anything else stopped the command came down to the session's own permission rules — a session with broad Bash allow-rules is exactly the setup branch-guard exists to backstop. None of the decisions changed in this release; they now get made.

Guarantees stay best-effort by design. Claude Code ignores hook decisions entirely under bypassPermissions, so anything you need to hold unconditionally still belongs in a git pre-push hook or server-side branch protection.

Full changelog: v1.4.1...v1.4.2

v1.4.1

Choose a tag to compare

@karlkfi karlkfi released this 03 Aug 17:27

Fewer git/gh approval prompts in Claude Code, with the human kept at the protected-branch boundary.

Note

One visible behavior change: two more spellings of a tag push now ask under strict. Nothing to do on upgrade, but a session that ran git push --tags without a prompt will now get one. See Upgrading.

Highlights

A tag push asks under strict, however it's spelled. Three spellings of the same act disagreed: git push origin v1.3.0 asked, while git push origin refs/tags/v1.3.0 and git push --tags were auto-approved. ref_to_branch mapped any ref that isn't refs/heads/… to None, and push_decision reads None as "this side names no branch, nothing to object to" — so a fully-qualified tag ref sailed past every branch check into the strict auto-approve. Publishing a tag is a release, and a release is worth a keystroke. git push --follow-tags stays auto-approved on purpose: it publishes only annotated tags already reachable from the branch being pushed, and push.followTags can turn that on from config where the hook can't see it.

A non-interactive denial now reads as a denial. In auto, dontAsk, and bypassPermissions there is no human to answer a prompt, so confirm() flips a would-be ask into a deny — but it re-emitted the ask's wording verbatim, ending in "confirm before proceeding". An agent reads that as a prompt still waiting and retries a command it can never get approved from the session. Both paths now state the same cause and differ in the closing clause: the denial names the permission mode and says retrying won't help, so the agent hands the command off instead of looping on it.

Upgrading

No required steps. The version string is the only thing that moved; no configuration, hook registration, or decision default changes shape.

Third-party marketplaces have auto-update off by default, so an install pins its version until you turn it on or update by hand (Upgrade):

claude plugin marketplace update branch-guard
claude plugin update branch-guard@branch-guard

Either way a restart is needed — the hook is registered at startup, so a running session stays on the version it loaded.

One change that needs no action, but will be visible. Under the default strict policy, git push origin refs/tags/vX.Y.Z and git push --tags now ask where they previously auto-approved. Under protected they defer, as before — that policy only guards main/master. If a release script drives Claude non-interactively, that ask becomes a deny by design, and the denial now says so in words rather than inviting a retry.

Everything since v1.4.0

Three PRs, all of them shipped below — nothing withheld from this list.

  • fix: ask before a tag push under strict, however it's spelled by @karlkfi in #35
  • fix: word a non-interactive denial as a denial, not a confirmation by @karlkfi in #34
  • test: run the suite on Windows by @karlkfi in #36

Decision surface

One row is added to the behavior table and nothing is removed, so every command that was auto-approved at v1.4.0 still is, except the two tag-push spellings above:

command v1.4.0 v1.4.1
git push origin v1.3.0 ask ask
git push origin refs/tags/v1.3.0 allow ask
git push --tags allow ask
git push --follow-tags allow allow

Reason wording is now part of the contract, not incidental. The suite asserts that the cause survives both paths, that an ask ends in "confirm before proceeding", and that a deny never does.

Validation

237 specs, green on Linux across Python 3.10–3.13 and — new this release — on Windows, on the release commit itself (run). The Windows job runs the same suite under Git Bash, which is the shell Claude Code's Bash tool uses there.

Windows was worth a job of its own because ntpath reads a leading slash as drive-relative: an MSYS-shaped path resolves onto the hook process's drive, git -C misses the repo, and a protected-branch ask degrades into no decision at all. That is the failure mode where the guard looks installed and enforces nothing.

What the suite does not assert: it drives the hook's JSON contract in a throwaway repo, not Claude Code's own permission plumbing, and MSYS-shaped paths typed by hand in Git Bash still make the hook defer rather than decide — it over-defers, never silently allows.

Security

No advisory, and no dependencies to bump — the hook is stdlib-only Python and the plugin ships no third-party code.

The tag-push fix tightens the guard rather than patching a reported vulnerability: a form that was silently auto-approved now asks. Guarantees stay best-effort by design. Claude Code ignores hook decisions entirely under bypassPermissions, so anything you need to hold unconditionally still belongs in a git pre-push hook or server-side branch protection.

Full changelog: v1.4.0...v1.4.1

v1.4.0

Choose a tag to compare

@karlkfi karlkfi released this 18 Jul 19:10

Minor release: two parser-hardening improvements to the Bash auto-approve classifier, plus docs.

  • feat: treat heredoc bodies as opaque data by @karlkfi in #31
  • feat: recognize pure substitutions in auto-approved git chains by @karlkfi in #30
  • docs: document autoUpdate opt-in for the third-party marketplace by @karlkfi in #29
  • docs: note Autofix wake-up interaction with auto-allowed pushes by @karlkfi in #25
  • docs: point already-installed users from Install to Upgrade by @karlkfi in #23

Full Changelog: v1.3.1...v1.4.0

v1.3.1

Choose a tag to compare

@karlkfi karlkfi released this 28 Jun 18:14

Patch release: a fix for resolving relative edit paths.

  • fix: resolve relative edit file_path against payload cwd, not hook process cwd by @karlkfi in #22

Full Changelog: v1.3.0...v1.3.1

v1.3.0

Choose a tag to compare

@karlkfi karlkfi released this 21 Jun 07:24

Minor release: the destructive-gh-delete guard now covers a much wider set of subcommands.

  • feat: ask before destructive gh deletes (branch / repo / label) by @karlkfi in #18
  • feat: ask before more destructive gh deletes (release/secret/variable/gist/cache, workflow disable, release delete-asset, api repo delete) by @karlkfi in #19
  • test: isolate per-run tmp dir to fix harness flakiness by @karlkfi in #20

Full Changelog: v1.2.0...v1.3.0

v1.2.0

Choose a tag to compare

@karlkfi karlkfi released this 14 Jun 17:19

Minor release: expands the read-only gh allowlist, plus docs improvements.

  • feat: auto-approve read-only gh reads (run watch / search / api GET) by @karlkfi in #17
  • docs: document plugin upgrade procedure by @karlkfi in #15
  • docs: correct marketplace name in /plugin commands by @karlkfi in #16

Full Changelog: v1.1.0...v1.2.0

v1.1.0

Choose a tag to compare

@karlkfi karlkfi released this 08 Jun 00:28

Minor release: auto-approve more safe command shapes — read-only filters and benign label/no-op segments piped or chained after a git/gh command — plus parsing-hardening fixes for redirects.

  • feat: allow safe git/gh piped into a read-only filter by @karlkfi in #11
  • fix: handle fd redirects (2>&1) so they aren't read as git args by @karlkfi in #12
  • feat: auto-approve benign label/no-op segments; harden file-write redirects by @karlkfi in #14

Full Changelog: v1.0.0...v1.1.0

v1.0.0

Choose a tag to compare

@karlkfi karlkfi released this 07 Jun 04:39

First stable release of branch-guard — a Claude Code PreToolUse hook that cuts routine git/gh approval prompts while keeping a human in the loop for protected-branch and destructive operations.

Highlights

  • git/gh command classifier — auto-approves read-only, staging, and branch/worktree-create commands; allows feature-branch commits & pushes; asks before protected-branch or destructive operations (reset --hard, clean -f, branch -D, …); defers on anything it can't classify.
  • shlex-based lexing (tokenizecommand_segmentsparse_invocation) instead of substring matching, so a non-git segment can never ride along into an allow (git status && rm -rf foo is not auto-approved). Shell command/process substitution downgrades a would-be allow to defer.
  • Configurable push guard via BRANCH_GUARD_PUSH_POLICYstrict (default), protected, or off.
  • Edit/Write/MultiEdit/NotebookEdit guarding resolved against the file's own repository (git -C <dir-of-file>), not the session cwd.
  • Non-interactive fail-safe — in auto/dontAsk/bypassPermissions modes an ask becomes a deny, since no human can answer.
  • Stdlib-only Python hook — no third-party dependencies; tested across Python 3.10–3.13 in CI.

See the README for the full behavior tables and activation steps.

Full Changelog: https://github.com/karlkfi/claude-branch-guard/commits/v1.0.0