Skip to content

feat: harden Claude Code permissions around recoverable failure - #11

Merged
bertini36 merged 8 commits into
mainfrom
feat/harden-claude-code-permissions
Aug 4, 2026
Merged

feat: harden Claude Code permissions around recoverable failure#11
bertini36 merged 8 commits into
mainfrom
feat/harden-claude-code-permissions

Conversation

@bertini36

@bertini36 bertini36 commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Description

The permission rules in .claude/settings.json were tuned for catastrophic system damage while leaving the likelier everyday accidents unguarded. Bash(rm -rf *) made legitimate deletes impossible yet let plain rm file through untouched, since the pattern required the -rf prefix. Discarding uncommitted work and pushing to main had 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 rm now prompts. The deny list keeps only literal root and home wipes plus sudo rm, the cases with no recovery path.

Closed the uncommitted-work gap. git reset --hard and git clean now 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 push would land on, which is precisely the dangerous case. .claude/hooks/git-push-protected-branch.sh resolves the current branch and prompts when the push could reach main or master. 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 chained cd x && rm -rf y, and base64 ~/.ssh/id_rsa all slip past them. The classifier reads intent, so one rule covers every spelling. Secrets sit in hard_deny, which stated intent cannot clear; destructive git operations sit in soft_deny, since those are sometimes genuinely wanted. Both keep $defaults so the built-in rules still apply.

Switched the default model to opus. Unrelated to the permission work, but it belongs in this branch: /model had 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

  • Workflow / tooling configuration

Test Procedure

The hook script was pipe-tested against synthesized PreToolUse payloads covering four cases: bare git push on main (prompts), explicit non-protected branch while standing on main (prompts, fail-safe), unrelated branch outside a git repo (silent), and explicit main in the command (prompts). Settings JSON and hook nesting validated with jq -e after each commit.

The hook resolves the actual push target rather than pattern-matching the command, so git push origin feat/x from main stays silent while git push, git push origin main, and git push origin HEAD:refs/heads/main all prompt. Verified across nine invocation forms from both a feature branch and main.

What could break: target resolution assumes a conventional git push [opts] [remote] [refspec] shape. Exotic forms (--mirror, a configured push.default of matching, multi-refspec pushes where an earlier refspec targets main) 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 /hooks or a restart before the guard is active.

Post-deploy steps

  1. Open /hooks once, or restart Claude Code, so the new PreToolUse entry is loaded. Until then the push guard is inert.
  2. Confirm on the next real push from main that the prompt appears.

bertini36 and others added 6 commits August 4, 2026 22:48
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 bertini36 self-assigned this Aug 4, 2026
@bertini36
bertini36 requested a lite review from Copilot August 4, 2026 20:52
@bertini36
bertini36 marked this pull request as ready for review August 4, 2026 20:52
@bertini36 bertini36 changed the title 🚧 feat: harden Claude Code permissions around recoverable failure feat: harden Claude Code permissions around recoverable failure Aug 4, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.json permission rules: expanded denies for irrecoverable damage, added prompts for rm, destructive git cleanup/reset, and gh pr merge, and introduced autoMode hard/soft deny classifiers.
  • Added a PreToolUse bash hook to detect pushes that may hit main/master and 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.

Comment thread .claude/hooks/git-push-protected-branch.sh Outdated
Comment thread .claude/hooks/git-push-protected-branch.sh Outdated
Comment thread .claude/settings.json
Comment thread .claude/settings.json
bertini36 and others added 2 commits August 4, 2026 23:06
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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 feature or --mirror/--all from a feature branch) and can also false-positive on tag-only pushes (git push origin --tags will currently prompt if you happen to be on main). Consider scanning all refspecs for a protected destination and treating --mirror/--all as always targeting protected branches when they exist; also treat --tags with 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

@bertini36
bertini36 merged commit 07e5bb0 into main Aug 4, 2026
1 check passed
@bertini36
bertini36 deleted the feat/harden-claude-code-permissions branch August 4, 2026 21:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants