feat: harden Claude Code permissions around recoverable failure - #11
Merged
Conversation
The existing rules covered ~/.ssh via the Read tool but left cloud credentials and .env files unguarded. Both routinely hold live secrets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N5SpXqrwzM6uKCEPR4EGkN
Bash(rm -rf *) was blunt in the wrong direction: it made legitimate deletes impossible while leaving plain `rm file` completely unguarded, since the pattern required the -rf prefix. Invert it. Every rm now prompts, and the deny list keeps only the unrecoverable cases: literal root and home wipes, plus sudo rm. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N5SpXqrwzM6uKCEPR4EGkN
git reset --hard and git clean discard uncommitted work, so there is no reflog entry and no object left to recover. That is a likelier loss than the system-level disasters the deny list already covers. gh pr merge joins them as outward-facing and awkward to undo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N5SpXqrwzM6uKCEPR4EGkN
A permission pattern only sees the command string, so it cannot tell which branch a bare `git push` would land on. That is exactly the risky case, so this needs a hook rather than a rule. The hook prompts when the current branch is main or master, or when the command names either one. It stays silent otherwise and lets the normal permission rules decide. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N5SpXqrwzM6uKCEPR4EGkN
Permission rules match command prefixes, so `rm -fr`, a chained `cd x && rm -rf y`, and `base64 ~/.ssh/id_rsa` all slip past them. The auto mode classifier reads intent instead, so one rule covers every spelling. Secrets go in hard_deny, which stated intent cannot clear. Destructive git operations go in soft_deny, since those are sometimes genuinely wanted. Both lists keep $defaults so the built-in rules still apply. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N5SpXqrwzM6uKCEPR4EGkN
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N5SpXqrwzM6uKCEPR4EGkN
bertini36
marked this pull request as ready for review
August 4, 2026 20:52
There was a problem hiding this comment.
Pull request overview
This PR hardens Claude Code’s tool permissions by shifting from “block only catastrophic patterns” to a layered model that (a) denies clearly unrecoverable actions, (b) prompts on common recoverable-but-risky actions, and (c) adds a PreToolUse hook to prompt when git push may target protected branches.
Changes:
- Reworked
.claude/settings.jsonpermission rules: expanded denies for irrecoverable damage, added prompts forrm, destructive git cleanup/reset, andgh pr merge, and introducedautoModehard/soft deny classifiers. - Added a
PreToolUsebash hook to detect pushes that may hitmain/masterand request an “ask” permission decision. - Extended credential read-deny coverage to include
~/.aws/**and**/.env.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
.claude/settings.json |
Updates Claude Code permission rules, adds auto-mode classifiers, and wires a git push pre-hook. |
.claude/hooks/git-push-protected-branch.sh |
New PreToolUse hook that prompts when a push may target protected branches. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Run by hand with a terminal attached, jq blocked waiting on input that never arrives. Match python-worktree-venv.sh and bail out when stdin is a TTY, and swallow parse errors so a malformed payload cannot leak onto the session's stderr. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N5SpXqrwzM6uKCEPR4EGkN
The hook prompted whenever the current branch was main or master, or whenever the command mentioned either name anywhere. That fired on pushes it had no business questioning, such as pushing a feature branch while standing on main, and a prompt you learn to dismiss protects nothing. It also missed `git push origin HEAD:refs/heads/main`, because a slash was not a word boundary in the pattern. Parse the refspec instead. With no refspec the target is the current branch; otherwise it is whatever follows the colon in the last one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N5SpXqrwzM6uKCEPR4EGkN
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (1)
.claude/hooks/git-push-protected-branch.sh:23
- The push-target parsing only inspects the last non-flag positional argument (and treats options as ignorable), which can miss protected-branch pushes (e.g. multi-refspec
git push origin main featureor--mirror/--allfrom a feature branch) and can also false-positive on tag-only pushes (git push origin --tagswill currently prompt if you happen to be onmain). Consider scanning all refspecs for a protected destination and treating--mirror/--allas always targeting protected branches when they exist; also treat--tagswith no refspecs as tag-only (no branch target).
# With no refspec git pushes the current branch; otherwise the last refspec
# wins, and its destination is whatever follows the colon.
if [[ ${#args[@]} -le 1 ]]; then
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
The permission rules in
.claude/settings.jsonwere tuned for catastrophic system damage while leaving the likelier everyday accidents unguarded.Bash(rm -rf *)made legitimate deletes impossible yet let plainrm filethrough untouched, since the pattern required the-rfprefix. Discarding uncommitted work and pushing tomainhad no guard at all.This reworks the permissions section around one idea: deny only what is unrecoverable, prompt for everything else.
Highlights
Inverted the rm policy. Every non-sudo
rmnow prompts. The deny list keeps only literal root and home wipes plussudo rm, the cases with no recovery path.Closed the uncommitted-work gap.
git reset --hardandgit cleannow prompt. Neither leaves a reflog entry or a reachable object, so a mistake there is unrecoverable in a way that a bad commit is not.Guarded pushes to protected branches with a hook, not a rule. A permission pattern only sees the command string, so it cannot know which branch a bare
git pushwould land on, which is precisely the dangerous case..claude/hooks/git-push-protected-branch.shresolves the current branch and prompts when the push could reachmainormaster. It stays silent otherwise and defers to the normal rules.Added a semantic layer via the auto mode classifier. Prefix rules are structurally blind to variants:
rm -fr, a chainedcd x && rm -rf y, andbase64 ~/.ssh/id_rsaall slip past them. The classifier reads intent, so one rule covers every spelling. Secrets sit inhard_deny, which stated intent cannot clear; destructive git operations sit insoft_deny, since those are sometimes genuinely wanted. Both keep$defaultsso the built-in rules still apply.Switched the default model to
opus. Unrelated to the permission work, but it belongs in this branch:/modelhad written it to the working copy and rebuilding the commits reverted it. Kept deliberately, and isolated in its own commit so it can be dropped without touching anything else.Extended credential denies to
~/.aws/**and**/.env, which the previous~/.ssh-only coverage missed.Type of Change
Test Procedure
The hook script was pipe-tested against synthesized
PreToolUsepayloads covering four cases: baregit pushonmain(prompts), explicit non-protected branch while standing onmain(prompts, fail-safe), unrelated branch outside a git repo (silent), and explicitmainin the command (prompts). Settings JSON and hook nesting validated withjq -eafter each commit.The hook resolves the actual push target rather than pattern-matching the command, so
git push origin feat/xfrommainstays silent whilegit push,git push origin main, andgit push origin HEAD:refs/heads/mainall prompt. Verified across nine invocation forms from both a feature branch andmain.What could break: target resolution assumes a conventional
git push [opts] [remote] [refspec]shape. Exotic forms (--mirror, a configuredpush.defaultofmatching, multi-refspec pushes where an earlier refspec targetsmain) are not covered and fall through silently.Live hook execution is not yet verified. New hook entries are not picked up by an already-running session, so this needs
/hooksor a restart before the guard is active.Post-deploy steps
/hooksonce, or restart Claude Code, so the newPreToolUseentry is loaded. Until then the push guard is inert.mainthat the prompt appears.