From a072a7c3b00ce70a732448476c89110bf628e92c Mon Sep 17 00:00:00 2001 From: Alberto Pou Date: Tue, 4 Aug 2026 22:48:45 +0200 Subject: [PATCH 1/8] feat: deny reads of AWS credentials and env files 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 Claude-Session: https://claude.ai/code/session_01N5SpXqrwzM6uKCEPR4EGkN --- .claude/settings.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.claude/settings.json b/.claude/settings.json index e0c8cff..2aaaae6 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -18,7 +18,9 @@ "Bash(cat /etc/shadow)", "Bash(cat /etc/ssh/**)", "Bash(cat ~/.ssh/*)", - "Read(~/.ssh/**)" + "Read(~/.ssh/**)", + "Read(~/.aws/**)", + "Read(**/.env)" ], "defaultMode": "auto" }, From 22eba17fef7f741549234e4c573326c1e176f2bf Mon Sep 17 00:00:00 2001 From: Alberto Pou Date: Tue, 4 Aug 2026 22:49:14 +0200 Subject: [PATCH 2/8] feat: prompt before rm instead of denying recursive deletes 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 Claude-Session: https://claude.ai/code/session_01N5SpXqrwzM6uKCEPR4EGkN --- .claude/settings.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.claude/settings.json b/.claude/settings.json index 2aaaae6..6abd212 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -7,7 +7,10 @@ }, "permissions": { "deny": [ - "Bash(rm -rf *)", + "Bash(rm -rf /)", + "Bash(rm -rf ~)", + "Bash(rm -rf ~/)", + "Bash(sudo rm *)", "Bash(dd *)", "Bash(mkfs *)", "Bash(shred *)", @@ -22,6 +25,9 @@ "Read(~/.aws/**)", "Read(**/.env)" ], + "ask": [ + "Bash(rm *)" + ], "defaultMode": "auto" }, "model": "sonnet", From 68f85c8d9f827d547068520063c0470001a2db70 Mon Sep 17 00:00:00 2001 From: Alberto Pou Date: Tue, 4 Aug 2026 22:49:28 +0200 Subject: [PATCH 3/8] feat: prompt before destructive git and merge operations 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 Claude-Session: https://claude.ai/code/session_01N5SpXqrwzM6uKCEPR4EGkN --- .claude/settings.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.claude/settings.json b/.claude/settings.json index 6abd212..c338d73 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -26,7 +26,10 @@ "Read(**/.env)" ], "ask": [ - "Bash(rm *)" + "Bash(rm *)", + "Bash(git reset --hard*)", + "Bash(git clean -*)", + "Bash(gh pr merge *)" ], "defaultMode": "auto" }, From 5c08a5fbbb925c913967a8f19a5108be0909a4d9 Mon Sep 17 00:00:00 2001 From: Alberto Pou Date: Tue, 4 Aug 2026 22:49:45 +0200 Subject: [PATCH 4/8] feat: prompt before pushing to protected branches 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 Claude-Session: https://claude.ai/code/session_01N5SpXqrwzM6uKCEPR4EGkN --- .claude/hooks/git-push-protected-branch.sh | 18 ++++++++++++++++++ .claude/settings.json | 11 +++++++++++ 2 files changed, 29 insertions(+) create mode 100755 .claude/hooks/git-push-protected-branch.sh diff --git a/.claude/hooks/git-push-protected-branch.sh b/.claude/hooks/git-push-protected-branch.sh new file mode 100755 index 0000000..ba0553c --- /dev/null +++ b/.claude/hooks/git-push-protected-branch.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Prompt before a git push that could land on main or master. +# Emits no output for any other push, letting normal permission rules decide. +set -uo pipefail + +command=$(jq -r '.tool_input.command // ""') +branch=$(git symbolic-ref --quiet --short HEAD 2>/dev/null || echo "") + +if [[ "$branch" =~ ^(main|master)$ ]] || + [[ "$command" =~ (^|[[:space:]:])(main|master)([[:space:]]|$) ]]; then + jq -n --arg b "${branch:-detached HEAD}" '{ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "ask", + permissionDecisionReason: ("Push may target a protected branch. Current branch: " + $b) + } + }' +fi diff --git a/.claude/settings.json b/.claude/settings.json index c338d73..39c9c56 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -54,6 +54,17 @@ "command": "rtk hook claude" } ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "if": "Bash(git push*)", + "command": "bash ~/.dotfiles/.claude/hooks/git-push-protected-branch.sh", + "statusMessage": "Checking push target" + } + ] } ] }, From 36591f7697b78946e847457eea94bac12853ff65 Mon Sep 17 00:00:00 2001 From: Alberto Pou Date: Tue, 4 Aug 2026 22:49:59 +0200 Subject: [PATCH 5/8] feat: extend auto mode classifier with credential and git rules 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 Claude-Session: https://claude.ai/code/session_01N5SpXqrwzM6uKCEPR4EGkN --- .claude/settings.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.claude/settings.json b/.claude/settings.json index 39c9c56..0a874f5 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -33,6 +33,18 @@ ], "defaultMode": "auto" }, + "autoMode": { + "hard_deny": [ + "$defaults", + "Never read, print, copy, or transmit SSH keys, cloud credentials, or .env contents, by any means including cat, head, grep, cp, base64, or a script" + ], + "soft_deny": [ + "$defaults", + "Never discard uncommitted work (git reset --hard, git clean, git checkout --)", + "Never rewrite published history, force-push, or delete a remote branch", + "Never merge, close, or delete a pull request, release, or repository" + ] + }, "model": "sonnet", "hooks": { "SessionStart": [ From bb5bc6efb2196a79fe1178031695d235e5e2d6b9 Mon Sep 17 00:00:00 2001 From: Alberto Pou Date: Tue, 4 Aug 2026 22:50:14 +0200 Subject: [PATCH 6/8] chore: set default model to opus Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01N5SpXqrwzM6uKCEPR4EGkN --- .claude/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/settings.json b/.claude/settings.json index 0a874f5..46b4006 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -45,7 +45,7 @@ "Never merge, close, or delete a pull request, release, or repository" ] }, - "model": "sonnet", + "model": "opus", "hooks": { "SessionStart": [ { From 3913368bcb55389022c20edd02422a4b49084655 Mon Sep 17 00:00:00 2001 From: Alberto Pou Date: Tue, 4 Aug 2026 23:06:35 +0200 Subject: [PATCH 7/8] fix: guard the push hook against running without stdin 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 Claude-Session: https://claude.ai/code/session_01N5SpXqrwzM6uKCEPR4EGkN --- .claude/hooks/git-push-protected-branch.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.claude/hooks/git-push-protected-branch.sh b/.claude/hooks/git-push-protected-branch.sh index ba0553c..e2b069c 100755 --- a/.claude/hooks/git-push-protected-branch.sh +++ b/.claude/hooks/git-push-protected-branch.sh @@ -3,7 +3,8 @@ # Emits no output for any other push, letting normal permission rules decide. set -uo pipefail -command=$(jq -r '.tool_input.command // ""') +[[ -t 0 ]] && exit 0 +command=$(jq -r '.tool_input.command // ""' 2>/dev/null) || exit 0 branch=$(git symbolic-ref --quiet --short HEAD 2>/dev/null || echo "") if [[ "$branch" =~ ^(main|master)$ ]] || From 4ffe204755d7aedb6d747c2766c9c3e050126b46 Mon Sep 17 00:00:00 2001 From: Alberto Pou Date: Tue, 4 Aug 2026 23:06:48 +0200 Subject: [PATCH 8/8] fix: resolve the real push target instead of matching the command text 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 Claude-Session: https://claude.ai/code/session_01N5SpXqrwzM6uKCEPR4EGkN --- .claude/hooks/git-push-protected-branch.sh | 31 +++++++++++++++++----- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/.claude/hooks/git-push-protected-branch.sh b/.claude/hooks/git-push-protected-branch.sh index e2b069c..73e7f7d 100755 --- a/.claude/hooks/git-push-protected-branch.sh +++ b/.claude/hooks/git-push-protected-branch.sh @@ -1,19 +1,38 @@ #!/usr/bin/env bash -# Prompt before a git push that could land on main or master. +# Prompt before a git push whose target branch is main or master. # Emits no output for any other push, letting normal permission rules decide. set -uo pipefail [[ -t 0 ]] && exit 0 command=$(jq -r '.tool_input.command // ""' 2>/dev/null) || exit 0 -branch=$(git symbolic-ref --quiet --short HEAD 2>/dev/null || echo "") -if [[ "$branch" =~ ^(main|master)$ ]] || - [[ "$command" =~ (^|[[:space:]:])(main|master)([[:space:]]|$) ]]; then - jq -n --arg b "${branch:-detached HEAD}" '{ +# Positional arguments after `git push` are the remote and its refspecs. +read -ra parts <<<"$command" +args=() +for part in "${parts[@]:2}"; do + [[ "$part" == -* ]] || args+=("$part") +done + +# 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 + target=HEAD +else + target="${args[-1]}" + [[ "$target" == *:* ]] && target="${target##*:}" +fi + +target="${target#refs/heads/}" +if [[ -z "$target" || "$target" == HEAD ]]; then + target=$(git symbolic-ref --quiet --short HEAD 2>/dev/null || echo "") +fi + +if [[ "$target" =~ ^(main|master)$ ]]; then + jq -n --arg t "$target" '{ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "ask", - permissionDecisionReason: ("Push may target a protected branch. Current branch: " + $b) + permissionDecisionReason: ("Push targets protected branch: " + $t) } }' fi