Protect main: git hooks + Claude Code deny rules #3

Merged
christian merged 4 commits from chore/deny-push-main into main 2026-10-21 00:58:31 +02:00
Owner

Keeps main human-merge-only, in layers:

  • Git hooks (.githooks/, copied into .git/hooks by npm install, where the sandbox can't rewrite them): pre-push rejects any push that would update refs/heads/main, however the command is spelled (bare push, +main, refs/heads/main, --all, --mirror, deletion). pre-commit and pre-merge-commit refuse commits while main is checked out. Covered by test/hooks.test.js.
  • Claude Code deny rules in .claude/settings.json for git push … main: an early stop only, since they match command text.
  • Gitea branch protection on main is the layer that can't be bypassed. It has to be set in the repo settings; the README documents all three.

Merge this before #1, whose workflow doc refers to these hooks.

🤖 Generated with Claude Code

Keeps `main` human-merge-only, in layers: - **Git hooks** (`.githooks/`, copied into `.git/hooks` by `npm install`, where the sandbox can't rewrite them): `pre-push` rejects any push that would update `refs/heads/main`, however the command is spelled (bare push, `+main`, `refs/heads/main`, `--all`, `--mirror`, deletion). `pre-commit` and `pre-merge-commit` refuse commits while `main` is checked out. Covered by `test/hooks.test.js`. - **Claude Code deny rules** in `.claude/settings.json` for `git push … main`: an early stop only, since they match command text. - **Gitea branch protection** on `main` is the layer that can't be bypassed. It has to be set in the repo settings; the README documents all three. Merge this before #1, whose workflow doc refers to these hooks. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
christian added 1 commit 2026-10-21 00:30:45 +02:00
Author
Owner

Needs fixes
Automated self-review by Claude; not an independent review.

The rules match command text, so many ways of pushing to main get past them:

  1. .claude/settings.json:20 — A bare git push while on main (or with push.default=current) doesn't contain main, so it falls through to the ask rule.
  2. .claude/settings.json:23 — HEAD:refs/heads/main, +main and +HEAD:main aren't matched. +main is also a force push that gets past the --force deny.
  3. .claude/settings.json:20 — git push --all / --mirror update main without naming it.
  4. .claude/settings.json:20 — git -C . push … / git -c k=v push … don't start with git push, so neither the deny nor the ask rule matches.
  5. .claude/settings.json:22 — If :* is a raw prefix match, git push origin main-hotfix / maintenance would be hard-denied.
  6. .claude/settings.json:13 — The existing --force deny only matches --force directly after push. -f, a trailing --force and +ref get past it.
  7. .claude/settings.json:20 — Two of the four rules are redundant: * main covers origin main, and *:main covers HEAD:main.

Recommendation: treat these rules as a speed bump, not protection. The real guard is Gitea branch protection on main (no direct pushes, merge only via PR), which catches every variant above. A local pre-push hook that rejects refs/heads/main would be a second layer that doesn't depend on how the command is spelled.

**Needs fixes** _Automated self-review by Claude; not an independent review._ The rules match command text, so many ways of pushing to `main` get past them: 1. `.claude/settings.json:20` — A bare `git push` while on `main` (or with `push.default=current`) doesn't contain `main`, so it falls through to the `ask` rule. 2. `.claude/settings.json:23` — `HEAD:refs/heads/main`, `+main` and `+HEAD:main` aren't matched. `+main` is also a force push that gets past the `--force` deny. 3. `.claude/settings.json:20` — `git push --all` / `--mirror` update `main` without naming it. 4. `.claude/settings.json:20` — `git -C . push …` / `git -c k=v push …` don't start with `git push`, so neither the deny nor the `ask` rule matches. 5. `.claude/settings.json:22` — If `:*` is a raw prefix match, `git push origin main-hotfix` / `maintenance` would be hard-denied. 6. `.claude/settings.json:13` — The existing `--force` deny only matches `--force` directly after `push`. `-f`, a trailing `--force` and `+ref` get past it. 7. `.claude/settings.json:20` — Two of the four rules are redundant: `* main` covers `origin main`, and `*:main` covers `HEAD:main`. **Recommendation:** treat these rules as a speed bump, not protection. The real guard is Gitea branch protection on `main` (no direct pushes, merge only via PR), which catches every variant above. A local `pre-push` hook that rejects `refs/heads/main` would be a second layer that doesn't depend on how the command is spelled.
christian added 1 commit 2026-10-21 00:40:41 +02:00
pre-push rejects any push that would update refs/heads/main, however the
command is spelled; pre-commit and pre-merge-commit refuse commits while
main is checked out. npm install enables them via core.hooksPath. The
README documents the three layers of protection for main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
christian changed title from Deny Claude Code pushes to main to Protect main: git hooks + Claude Code deny rules 2026-10-21 00:40:48 +02:00
christian added 1 commit 2026-10-21 00:45:05 +02:00
Linking core.hooksPath to the tracked, sandbox-writable .githooks/ let a
sandboxed command rewrite a hook that git commit/push then run unsandboxed.
Copies in .git/hooks can't be changed from the sandbox. This also keeps
existing .git/hooks scripts working and lets npm install succeed outside a
git checkout. pre-merge-commit now names itself in errors; hook tests clean
up, ignore GIT_* env and cover the installer; docs state what each layer
does and doesn't stop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

Needs fixes (stopping here: handing off to the human)
Automated self-review by Claude; not an independent review.

Round 3 (after 24541f0). The round-2 findings are fixed, including the hook sandbox-escape (hooks are now copied into .git/hooks). New findings worth knowing:

  1. scripts/install-hooks.sh:8 — Verified: if core.hooksPath is set globally, git rev-parse --git-path hooks returns that global directory, so the installer would put these hooks there and block main in every repo. It should copy to $(git rev-parse --git-common-dir)/hooks.
  2. scripts/install-hooks.sh:14 — It overwrites an existing local pre-commit/pre-push hook without a backup.
  3. package.json:10 — prepare fails all of npm install when .git/hooks isn't writable; it could warn and exit 0 instead.
  4. .githooks/pre-commit:6 — It blocks the merge commit from git pull on a local main that has diverged.

The rest (deny-rule pattern gaps, stale hooks after removal, test isolation from global git config) are minor or already documented as limitations.

**Needs fixes** (stopping here: handing off to the human) _Automated self-review by Claude; not an independent review._ Round 3 (after 24541f0). The round-2 findings are fixed, including the hook sandbox-escape (hooks are now copied into `.git/hooks`). New findings worth knowing: 1. `scripts/install-hooks.sh:8` — **Verified:** if `core.hooksPath` is set globally, `git rev-parse --git-path hooks` returns that global directory, so the installer would put these hooks there and block main in *every* repo. It should copy to `$(git rev-parse --git-common-dir)/hooks`. 2. `scripts/install-hooks.sh:14` — It overwrites an existing local pre-commit/pre-push hook without a backup. 3. `package.json:10` — `prepare` fails all of `npm install` when `.git/hooks` isn't writable; it could warn and exit 0 instead. 4. `.githooks/pre-commit:6` — It blocks the merge commit from `git pull` on a local main that has diverged. The rest (deny-rule pattern gaps, stale hooks after removal, test isolation from global git config) are minor or already documented as limitations.
christian added 1 commit 2026-10-21 00:55:53 +02:00
christian merged commit 2088568b5e into main 2026-10-21 00:58:31 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: christian/claude-playground#3