◉CLAUDE LABJP
●2.1.289 — Claude Code 2.1.289 (Oct 3) fixes deny/ask rules on nested parts of compound shell commands and a terminal freeze on code blocks●10/07 — 2 days left until the old spellings of Claude Desktop / Cowork managed-config keys stop being accepted. After the cutoff they fail closed●ENV — A report (#89386) says setting DISABLE_TELEMETRY to 0 does not turn it off. How the value is read as a boolean is the open question●NEW — claude-sonnet-4-5 stops on November 30 — I checked the bill before calling its replacement cheaper●MODS — Claude Code 2.1.287 introduced Claude Mods, which let plugins change deeper behavior●CONN — A request to use one Connector with different accounts (#27302) has passed 260 comments. A good case for thinking about how to separate work and personal use●2.1.289 — Claude Code 2.1.289 (Oct 3) fixes deny/ask rules on nested parts of compound shell commands and a terminal freeze on code blocks●10/07 — 2 days left until the old spellings of Claude Desktop / Cowork managed-config keys stop being accepted. After the cutoff they fail closed●ENV — A report (#89386) says setting DISABLE_TELEMETRY to 0 does not turn it off. How the value is read as a boolean is the open question●NEW — claude-sonnet-4-5 stops on November 30 — I checked the bill before calling its replacement cheaper●MODS — Claude Code 2.1.287 introduced Claude Mods, which let plugins change deeper behavior●CONN — A request to use one Connector with different accounts (#27302) has passed 260 comments. A good case for thinking about how to separate work and personal use
Articles/Claude Code
⟐ Claude Code/2026-10-05Intermediate

I Wrote "I'll Do the Commits" in CLAUDE.md. The Next Morning git log Had One More Line.

When Claude Code commits despite being told not to: undo with git reset --soft, then stop it physically with permissions.deny plus a PreToolUse hook. Covers the git -c form that deny rules let through, and how to test the hook without starting Claude Code.

Claude Code259permissions.denyPreToolUse3hooks20git committroubleshooting91

It happened the week I was moving Firebase in my wallpaper app from CocoaPods to Swift Package Manager. I'd handed Claude Code the whole stretch — drop the Podfile, rewrite the package dependencies, get the build green — and the first line of my CLAUDE.md said, in plain words, that commits and pushes were mine to make and that changes should stay in the working tree. The next morning, before even checking the build, I ran git log out of habit. There was a commit I hadn't made.

The message was tidy. The diff wasn't broken. But the one step I'd reserved for myself — reading the diff with my own eyes before it enters history — had been completed without me. My stomach sank a little.

I'm clearly not alone. The Claude Code repository has several issues titled some version of "committed without confirmation despite being explicitly told not to," closed as duplicates and reopened again. My first reaction was the same as most people's: I made the instruction bold, added "never," and explained why. A few days later it happened again, and that's when I changed my approach.

What I'd most like to pass on is this distinction: an instruction is something that gets read, not something that takes effect. If I want commits stopped, the fast, reliable, and frankly calming route is to put a mechanism in place that stops them. Below is the order I now follow — undo, diagnose, deny rule, hook, and what to try when none of that bites.

Recognizing the symptom: undo first, then confirm who committed

A commit isn't lost work, so there's no rush. If it's the most recent one, a single line removes it from history while leaving the changes staged in your working tree.

# Undo the last commit, keep the changes staged
git reset --soft HEAD~1
git status --short

Next, confirm that Claude Code actually made it. On my machine Claude Code runs with my own git identity, so the author field tells me nothing. What helps is lining up the commit timestamp against when the session was running, plus the reflog.

git log -3 --format='%h %ad %an | %s' --date=iso
git reflog -5

If the reflog shows a commit: entry at a time the session was active, that's your answer. Automatic rebases and stashes can also make history look like it moved, so I'm careful not to confuse those rebase or stash lines with a real commit.

Narrowing the cause: which layer was supposed to stop it

At this point I did an inventory of what stops what. Claude Code gives you three layers — the instructions you write, the permissions you configure, and hooks that run before a tool executes — and each one stops a different kind of thing.

LayerWhat it stopsWhat it can't stop
CLAUDE.md instructionThe model's judgment (reads and complies)Interpretation drift, oversights in long sessions
permissions.denyCommand text matching the rule's shapeThe same operation written another way
PreToolUse hookCommand text checked by your own logicOperations that never appear in command text
SandboxReaching the filesystem or network at all—

I had been leaning on the top layer alone. The CLAUDE.md text is read, reliably — but when a judgment like "the build passed, so let me record this as a checkpoint" slips in, the text can lose to that judgment. I've come to see this less as a model defect and more as the nature of a promise delivered in prose.

The two lower layers don't involve judgment. If the command text meets the condition, it stops, no matter the reasoning. Operations I want stopped should be blocked, not read about. That sentence replaced the paragraph in my CLAUDE.md.

Fix 1: add one line to permissions.deny

The first thing I added was a deny rule, in the project's .claude/settings.json (or .claude/settings.local.json if you don't want to share it with a team).

{
  "permissions": {
    "deny": [
      "Bash(git commit *)",
      "Bash(git push *)"
    ]
  }
}

With that, git commit -m "..." is refused before it runs and Claude Code is told it was denied. According to the official docs, deny and ask rules apply to any subcommand of a compound command, so cd ios && git commit -am wip is still stopped even with the cd in front. The same goes for commands nested in a subshell or $().

In my case, though, this wasn't the end of it — and the reason was a habit of my own.

For my website work I write commits as git -c user.email=... -c user.name=... commit -m "..." so that no git identity is left behind in the environment, and I'd put that exact form in CLAUDE.md as the procedure. Claude Code imitated it faithfully, with the -c options in between. Bash(git commit *) only matches text where commit comes right after git, so the -c form passed straight through.

The docs don't hide this. Their table shows that Bash(git push *) stops git push origin main but not git -C . push origin main or git -c push.default=current push origin main. A deny rule covers the invocation Claude usually produces, they say, and isn't a security boundary around the program.

One more detail: writing the rule against the argument name, as in Bash(command:git commit *), is ignored because it can be bypassed with a compound command — Claude Code emits a startup warning instead. I wrote it that way once and saw the warning.

Fix 2: read the whole command text in a PreToolUse hook

To cover the other spellings too, you put a hook in front that inspects the full command text with your own logic. PreToolUse runs right before a tool executes and receives the command as JSON on stdin.

What this solves, stated up front: "no matter how many options sit between git and the verb, if the verb is commit or push, deny it."

#!/bin/bash
# .claude/hooks/no-commit.sh
# Find git <options…> commit / push anywhere in the command text and stop it
COMMAND=$(jq -r '.tool_input.command // empty')
[ -z "$COMMAND" ] && exit 0
 
if echo "$COMMAND" | grep -Eq '(^|[^[:alnum:]_./-])git([[:space:]]+-[^[:space:]]+([[:space:]]+[^[:space:]-][^[:space:]]*)?)*[[:space:]]+(commit|push)([[:space:]]|$)'; then
  jq -n '{
    hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: "deny",
      permissionDecisionReason: "Commits and pushes are done by a human here. Leave the diff in the working tree and do not run this command."
    }
  }'
fi
exit 0

Don't forget chmod +x .claude/hooks/no-commit.sh. On the settings side, bind it with a Bash matcher.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": ".claude/hooks/no-commit.sh" }
        ]
      }
    ]
  }
}

Why it's written this way: the middle part of the regex, ([[:space:]]+-[^[:space:]]+(...)?)*, swallows any run of options like -c key=value or -C path. That's what lets one pattern catch git commit, git -c user.email=a@b.c commit, and git -C . push alike. The permissionDecisionReason text is handed to Claude Code verbatim, so stating the reason and the expected next behavior in one sentence made repeat attempts noticeably rarer for me.

You can test the hook without starting Claude Code — just feed JSON to stdin.

for c in 'git commit -m "fix"' \
         'git -c user.email=a@b.c -c user.name=x commit -m "x"' \
         'cd ios && git commit -am wip' \
         'git -C . push origin main' \
         'git status && git diff --stat' \
         'git log --oneline -3'; do
  printf '%-55s -> ' "$c"
  jq -n --arg c "$c" '{tool_name:"Bash",tool_input:{command:$c}}' \
    | .claude/hooks/no-commit.sh \
    | jq -r '.hookSpecificOutput.permissionDecision // "pass"'
done

On my machine the first four come back deny and the last two pass. In fairness I should add that echo "git commit is mine" also comes back deny, because the string appears inside quotes. The hook reads the command text; it doesn't understand meaning. I've chosen to treat that as an error on the safe side rather than a flaw.

That's layers two and three. A command wrapped as bash -c '...' is still read as text, so it's stopped too. What the hook can't see is an operation that never appears in the command text — writing a script to a file and then running it, for example. If I ever need to fence that in, the bottom row of the table, sandboxing, is the next step.

When it still isn't stopped

If the deny rule and the hook are in place and a commit still goes through, I check these in order.

  1. Does /hooks show the registration? A settings file in the wrong location, or one extra brace in the JSON, has been the most common cause for me.
  2. Execute permission and jq. Is chmod +x missing? Does which jq resolve? Without jq the hook produces no output and ends up letting the command through.
  3. Try in a fresh session. Right after changing settings, I close the session and reopen it before testing — that habit has saved me from chasing ghosts.
  4. Is it written in yet another form? /usr/bin/git or sh -c would call for revisiting the leading character class in the regex, or moving on to sandboxing.

When the hook itself needs debugging, I've written up the procedure in Debugging Claude Code Hooks in Production — Where to Start When Logs Are Missing.

Diffs to Claude, history to me

Looking back, what I wanted wasn't "a Claude that doesn't commit" but "a working environment where committing isn't possible." The first depends on luck every time; the second on one configuration.

Claude makes the diff; I write it into history. Since drawing that line, the small dread of opening git log in the morning has gone. The few minutes it takes to read a diff and commit by hand feel, if anything, like a cheap price for being able to hand over more.

I'd suggest starting with just the one line — Bash(git commit *) in .claude/settings.json — and checking git log the next morning. If a form slips through, that's the moment to add the hook. For deciding which event a hook belongs on and what it should return, I've gathered my reasoning as an indie developer in Claude Code Hooks: A Field Guide to the 8 Core Hooks — and the 33-Event Catalog They Now Live In.

Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $15 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⟐ Claude Code2026-09-02
Two hooks that record every model switch and stop the ones you never agreed to
Build a PostModelSwitch hook that logs every model change and a PreModelSwitch hook that blocks unapproved switches during unattended runs. Complete scripts, measured timings, and the reason the two jobs must stay separate.
⟐ Claude Code2026-08-30
Your Config Asks for effort xhigh With Thinking Off, and It Now Quietly Runs at high
Disabling thinking while asking for effort xhigh or max returns a 400. That failure now degrades silently to high instead, so here is a small preflight that catches the mismatch before you send the request.
⟐ Claude Code2026-08-28
Don't let your verification script's full output flow back through a hook
The check was working the whole time. The conversation was what ran out of room. Measured side by side: 58 bytes from one scan of 833 files, 42KB from another. Which paths actually reach the model, and how to enforce a budget on them.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links