Add opt-in raw-git nudge hook, independent of anti-chaining hook

Forces a normal confirmation prompt (never a silent block, never a silent
allow) when Bash runs raw git instead of git_cmd.sh, worded more
insistently when the subcommand is one the wrapper already supports.
Raw git stays a valid fallback for unsupported subcommands and
unregistered repos - a hard deny would remove that fallback entirely.

Also documents in SKILL.md that repo ambiguity (which registered repo is
meant, or none at all) must be resolved by asking the user rather than
guessing - a hook only sees the literal command string, not the
conversation, so that judgment can't live in the hook itself.
This commit is contained in:
Henner M. Kruse
2026-08-04 13:58:30 +00:00
parent 47595ff160
commit 1d0b66df2e
5 changed files with 173 additions and 19 deletions
+14
View File
@@ -78,6 +78,20 @@ purpose as a guardrail, not an oversight. Those excluded subcommands can
rewrite or discard history/working-tree state in ways that are hard to rewrite or discard history/working-tree state in ways that are hard to
reverse; `tag` is allowed because tagging a commit doesn't carry that risk. reverse; `tag` is allowed because tagging a commit doesn't carry that risk.
## When it's unclear which repo is meant
Don't guess. If a task says "the repo" or otherwise implies a repo without
naming one, and it's genuinely ambiguous which registered repo (if any) it
refers to, or the request's context doesn't clearly match any registered
repo at all, ask the user before picking a `--repo` or falling back to raw
git. Concretely, this is already handled operationally rather than needing
new logic: call `git_cmd.sh` without `--repo` and let it resolve —
`resolve_repo_path` in `_lib.sh` errors out listing every registered name
when more than one repo is configured and none was specified, and errors
saying none are registered when the config is empty. Either error is the
signal to ask the user which repo (or whether to register a new one),
rather than picking one, or reaching for raw git, on your own.
## Multi-repo setup ## Multi-repo setup
If a task refers to a named repo (e.g. "push the homelab-notes vault") and If a task refers to a named repo (e.g. "push the homelab-notes vault") and
+34 -5
View File
@@ -28,11 +28,12 @@ git_cmd.sh <subcommand> [--repo <name>] [-- <git-args...>]
one chained command — which is exactly what keeps every single one of one chained command — which is exactly what keeps every single one of
them covered by the whitelist. them covered by the whitelist.
- **A fixed set of allowed subcommands:** `status`, `log`, `diff`, `show`, - **A fixed set of allowed subcommands:** `status`, `log`, `diff`, `show`,
`fetch`, `remote`, `branch`, `checkout`, `add`, `commit`, `push`, `pull`. `fetch`, `remote`, `branch`, `checkout`, `add`, `commit`, `push`, `pull`,
Anything else (`reset`, `rebase`, `filter-branch`, arbitrary `git config`, `tag`. Anything else (`reset`, `rebase`, `stash`, `merge`, `filter-branch`,
...) is rejected by the script itself, before git ever runs — not because arbitrary `git config`, ...) is rejected by the script itself, before git
those operations are inherently dangerous, but because they're ever runs — not because those operations are inherently dangerous, but
consequential enough that they should go through a normal confirmation because they can rewrite or discard history/working-tree state in ways
that are hard to reverse, so they should go through a normal confirmation
prompt rather than being auto-approved. prompt rather than being auto-approved.
## Setup ## Setup
@@ -56,6 +57,10 @@ This will:
silent pass-through) any time a Bash command contains shell chaining or silent pass-through) any time a Bash command contains shell chaining or
substitution characters (`&&`, `;`, `|`, `` ` ``, `$(...)`) — see substitution characters (`&&`, `;`, `|`, `` ` ``, `$(...)`) — see
"The anti-chaining hook" below. "The anti-chaining hook" below.
4. Ask whether you also want a second, independent hook installed: one that
forces the same kind of normal confirmation prompt any time a Bash
command runs raw `git` instead of `git_cmd.sh` — see "The raw-git nudge
hook" below.
## Usage examples ## Usage examples
@@ -110,6 +115,30 @@ up in the first place — this hook is a backstop for the rare cases they do
anyway (or for other tools/skills in your setup that aren't as careful anyway (or for other tools/skills in your setup that aren't as careful
about it). about it).
## The raw-git nudge hook
Optional, offered during `/git-manager:setup`, independent of the
anti-chaining hook above (you can install neither, either, or both). If
installed, it watches every Bash command and, when it sees raw `git` used
instead of `git_cmd.sh`, forces the normal confirmation prompt instead of
letting whitelist matching decide — the same "never silently block, never
silently allow" behavior as the anti-chaining hook.
This is deliberately **not** a hard block. Raw git stays a legitimate
fallback for subcommands `git_cmd.sh` doesn't support (`stash`, `merge`,
`rebase`, `reset`, ...) and for repos that aren't registered yet — a full
block would remove that fallback entirely. What the hook does instead is
make sure raw git is never invisible: every time it's used, it's a
confirmed, visible choice, worded more insistently when the subcommand is
one `git_cmd.sh` already supports (in which case there's rarely a good
reason to have bypassed it) and more permissively otherwise.
What this hook can't do — and isn't trying to do — is figure out from the
surrounding conversation whether raw git was actually the right call here.
It only ever sees the literal Bash command text, not the request that led
to it; that kind of judgment is the skill's own job (see `SKILL.md`), and
this hook is just the backstop for when that judgment gets skipped.
## What's deliberately *not* included ## What's deliberately *not* included
- No `Bash(git:*)` — that would also permit `git reset --hard`, `git clean - No `Bash(git:*)` — that would also permit `git reset --hard`, `git clean
+17 -5
View File
@@ -19,8 +19,8 @@ to locate the plugin itself.
## 1. Ask the user — three selectable questions first, repo path last ## 1. Ask the user — three selectable questions first, repo path last
**1a. First turn: three questions via the selection UI, fixed options for **1a. First turn: four questions via the selection UI, fixed options for
all three:** all four:**
- Whether to register any repos at all: yes/no. - Whether to register any repos at all: yes/no.
- Settings scope: `project` (`.claude/settings.json` in the current - Settings scope: `project` (`.claude/settings.json` in the current
project) or `user` (`~/.claude/settings.json`). Mention: if they work project) or `user` (`~/.claude/settings.json`). Mention: if they work
@@ -32,6 +32,16 @@ all three:**
`|`, backticks, `$(...)`), as a safety net given Claude Code's Bash `|`, backticks, `$(...)`), as a safety net given Claude Code's Bash
allow-list matching has had bugs around compound commands (see allow-list matching has had bugs around compound commands (see
`hooks/force-ask-on-chaining.sh` for details). `hooks/force-ask-on-chaining.sh` for details).
- Ask-before-raw-git hook: yes/no, after explaining it never silently
blocks or silently allows anything either — it only forces the normal
confirmation prompt when a Bash command runs raw `git` instead of the
`git_cmd.sh` wrapper. It never fully blocks raw git (that stays a valid
fallback for subcommands the wrapper deliberately doesn't support, e.g.
`stash`/`merge`/`rebase`/`reset`, or for repos that aren't registered
yet) — it just makes sure raw git use is always a visible, confirmed
choice rather than something that slips through unnoticed (see
`hooks/force-ask-on-raw-git.sh` for details). Independent of the
anti-chaining hook — either, both, or neither can be installed.
**1b. Only if the answer to "register any repos" was yes: a second turn, **1b. Only if the answer to "register any repos" was yes: a second turn,
plain chat message, no tool call.** Ask exactly: "Which repos should plain chat message, no tool call.** Ask exactly: "Which repos should
@@ -57,11 +67,13 @@ this has since changed), that's:
--settings-scope <project|user> \ --settings-scope <project|user> \
[--project-dir <path>] \ [--project-dir <path>] \
[--repo <name>=<path> ...] \ [--repo <name>=<path> ...] \
[--install-hook] [--install-hook] \
[--install-raw-git-hook]
``` ```
Include `--repo name=path` once per repo from step 1, `--install-hook` only Include `--repo name=path` once per repo from step 1, `--install-hook` and
if the user opted in, and `--project-dir` only if `--settings-scope `--install-raw-git-hook` only for the ones the user opted into (they're
independent flags), and `--project-dir` only if `--settings-scope
project` and the project isn't the current working directory. Try this project` and the project isn't the current working directory. Try this
path directly first — don't `find`/`ls` preemptively. path directly first — don't `find`/`ls` preemptively.
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# PreToolUse hook for the Bash tool. Nudges toward git-manager's wrapper
# without ever fully blocking raw git: it only ever forces the normal
# confirmation prompt (permissionDecision: "ask") when a Bash command
# invokes raw `git` outside of the git_cmd.sh wrapper. It never returns
# "deny" and never returns "allow" itself — either it stays out of the way
# (no JSON output) or it asks, exactly like force-ask-on-chaining.sh.
#
# Why "ask" and not "deny": raw git is a legitimate fallback for
# subcommands git-manager deliberately doesn't support (stash, merge,
# rebase, reset, ...) and for repos that aren't registered yet. A hard
# deny would remove that fallback entirely; forcing visibility instead
# keeps a human in the loop without closing off a path that's sometimes
# genuinely needed.
#
# What this hook deliberately can't do: tell whether raw git here is a
# mistake or intentional — it only ever sees the literal Bash command
# string, never the conversation that led to it. That kind of judgment
# belongs in SKILL.md guidance to the model, not here. This hook is a
# syntactic backstop for when that guidance gets skipped, not a semantic
# router.
#
# Register in settings.json under hooks.PreToolUse with a matcher of "Bash".
set -euo pipefail
INPUT="$(cat)"
if ! command -v jq >/dev/null 2>&1; then
exit 0 # fail open, same rationale as force-ask-on-chaining.sh
fi
TOOL_NAME="$(echo "$INPUT" | jq -r '.tool_name // empty')"
if [ "$TOOL_NAME" != "Bash" ]; then
exit 0
fi
CMD="$(echo "$INPUT" | jq -r '.tool_input.command // empty')"
# The wrapper's own invocation is always fine. Checked explicitly by
# filename rather than assumed, so this exemption survives a rename.
if echo "$CMD" | grep -q 'git_cmd\.sh'; then
exit 0
fi
# Look for a raw `git` invocation as a standalone word anywhere in the
# command (not "git_cmd.sh", not "git-manager", not "digit"). Deliberately
# broad, same philosophy as the chaining hook: better an unnecessary
# prompt than a missed raw-git call.
if ! echo "$CMD" | grep -qE '(^|[^A-Za-z0-9_-])git([^A-Za-z0-9_-]|$)'; then
exit 0
fi
# Best-effort extraction of the subcommand, purely to make the prompt's
# message more useful — it never affects the ask-vs-no-ask decision
# itself. Sourcing the wrapper's own _lib.sh (deployed to this same
# directory) means the supported-subcommand list can't drift out of sync
# with what git_cmd.sh actually accepts.
HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -f "$HOOK_DIR/_lib.sh" ]; then
# shellcheck source=_lib.sh
source "$HOOK_DIR/_lib.sh"
fi
SUBCOMMAND="$(echo "$CMD" | grep -oE '(^|[^A-Za-z0-9_-])git\s+[^&|;]*' | head -1 | sed -E 's/^.*git\s+//' | awk '{
for (i = 1; i <= NF; i++) {
if ($i ~ /^-/) { if ($i == "-C" || $i == "-c" || $i == "--git-dir" || $i == "--work-tree") i++; continue }
print $i; break
}
}')" || SUBCOMMAND=""
if [ -n "$SUBCOMMAND" ] && declare -f is_allowed_subcommand >/dev/null 2>&1 && is_allowed_subcommand "$SUBCOMMAND"; then
REASON="git-manager's wrapper already supports '$SUBCOMMAND' (${ALLOWED_SUBCOMMANDS[*]}). Prefer ~/.agent-skills/git-manager/bin/git_cmd.sh over raw git — it's already permission-whitelisted and handles multi-repo resolution consistently."
else
REASON="Raw git detected outside git-manager's wrapper. If this is a subcommand the wrapper deliberately doesn't support (stash, merge, rebase, reset, ...), or an unregistered repo, raw git is an accepted fallback — just confirm that's actually the case here."
fi
jq -n --arg reason "$REASON" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "ask",
permissionDecisionReason: $reason
}
}'
exit 0
+22 -9
View File
@@ -12,7 +12,7 @@
# #
# Usage: # Usage:
# setup.sh --settings-scope (project|user) [--project-dir <path>] # setup.sh --settings-scope (project|user) [--project-dir <path>]
# [--repo <name>=<path> ...] [--install-hook] # [--repo <name>=<path> ...] [--install-hook] [--install-raw-git-hook]
# #
# Prints a JSON summary of what changed to stdout at the end. # Prints a JSON summary of what changed to stdout at the end.
@@ -22,6 +22,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PLUGIN_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" PLUGIN_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
SKILL_DIR="$(realpath "$PLUGIN_DIR/skills/git-manager")" SKILL_DIR="$(realpath "$PLUGIN_DIR/skills/git-manager")"
SOURCE_HOOK_PATH="$PLUGIN_DIR/hooks/force-ask-on-chaining.sh" SOURCE_HOOK_PATH="$PLUGIN_DIR/hooks/force-ask-on-chaining.sh"
SOURCE_RAW_GIT_HOOK_PATH="$PLUGIN_DIR/hooks/force-ask-on-raw-git.sh"
if ! command -v python3 >/dev/null 2>&1; then if ! command -v python3 >/dev/null 2>&1; then
echo "Error: python3 is required." >&2 echo "Error: python3 is required." >&2
@@ -37,10 +38,12 @@ mkdir -p "$STABLE_DIR"
cp "$SKILL_DIR/scripts/git_cmd.sh" "$STABLE_DIR/git_cmd.sh" cp "$SKILL_DIR/scripts/git_cmd.sh" "$STABLE_DIR/git_cmd.sh"
cp "$SKILL_DIR/scripts/_lib.sh" "$STABLE_DIR/_lib.sh" cp "$SKILL_DIR/scripts/_lib.sh" "$STABLE_DIR/_lib.sh"
cp "$SOURCE_HOOK_PATH" "$STABLE_DIR/force-ask-on-chaining.sh" cp "$SOURCE_HOOK_PATH" "$STABLE_DIR/force-ask-on-chaining.sh"
chmod +x "$STABLE_DIR/git_cmd.sh" "$STABLE_DIR/force-ask-on-chaining.sh" cp "$SOURCE_RAW_GIT_HOOK_PATH" "$STABLE_DIR/force-ask-on-raw-git.sh"
chmod +x "$STABLE_DIR/git_cmd.sh" "$STABLE_DIR/force-ask-on-chaining.sh" "$STABLE_DIR/force-ask-on-raw-git.sh"
WRAPPER_PATH="$STABLE_DIR/git_cmd.sh" WRAPPER_PATH="$STABLE_DIR/git_cmd.sh"
HOOK_PATH="$STABLE_DIR/force-ask-on-chaining.sh" HOOK_PATH="$STABLE_DIR/force-ask-on-chaining.sh"
RAW_GIT_HOOK_PATH="$STABLE_DIR/force-ask-on-raw-git.sh"
# Tilde-form paths for permission rules and SKILL.md's example commands — # Tilde-form paths for permission rules and SKILL.md's example commands —
# Claude Code matches Bash permission rules against the literal, # Claude Code matches Bash permission rules against the literal,
# unexpanded command text, so a rule written with the resolved absolute # unexpanded command text, so a rule written with the resolved absolute
@@ -55,6 +58,7 @@ TILDE_CONFIG_FILE="~/.agent-skills/git-manager/config.json"
SETTINGS_SCOPE="" SETTINGS_SCOPE=""
PROJECT_DIR="$PWD" PROJECT_DIR="$PWD"
INSTALL_HOOK=0 INSTALL_HOOK=0
INSTALL_RAW_GIT_HOOK=0
REPOS=() REPOS=()
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
@@ -63,6 +67,7 @@ while [ $# -gt 0 ]; do
--project-dir) PROJECT_DIR="$2"; shift 2 ;; --project-dir) PROJECT_DIR="$2"; shift 2 ;;
--repo) REPOS+=("$2"); shift 2 ;; --repo) REPOS+=("$2"); shift 2 ;;
--install-hook) INSTALL_HOOK=1; shift ;; --install-hook) INSTALL_HOOK=1; shift ;;
--install-raw-git-hook) INSTALL_RAW_GIT_HOOK=1; shift ;;
*) echo "Error: unknown argument '$1'" >&2; exit 1 ;; *) echo "Error: unknown argument '$1'" >&2; exit 1 ;;
esac esac
done done
@@ -89,11 +94,12 @@ mkdir -p "$CONFIG_DIR"
# parsed arguments. --- # parsed arguments. ---
mkdir -p "$(dirname "$SETTINGS_FILE")" mkdir -p "$(dirname "$SETTINGS_FILE")"
python3 - "$SETTINGS_FILE" "$TILDE_WRAPPER_PATH" "$TILDE_CONFIG_FILE" "$HOOK_PATH" "$INSTALL_HOOK" << 'PYEOF' python3 - "$SETTINGS_FILE" "$TILDE_WRAPPER_PATH" "$TILDE_CONFIG_FILE" "$HOOK_PATH" "$INSTALL_HOOK" "$RAW_GIT_HOOK_PATH" "$INSTALL_RAW_GIT_HOOK" << 'PYEOF'
import json, os, sys import json, os, sys
settings_file, tilde_wrapper_path, tilde_config_file, hook_path, install_hook = sys.argv[1:6] settings_file, tilde_wrapper_path, tilde_config_file, hook_path, install_hook, raw_git_hook_path, install_raw_git_hook = sys.argv[1:8]
install_hook = install_hook == "1" install_hook = install_hook == "1"
install_raw_git_hook = install_raw_git_hook == "1"
settings = {} settings = {}
if os.path.exists(settings_file): if os.path.exists(settings_file):
@@ -114,12 +120,12 @@ for rule in new_rules:
if rule not in allow: if rule not in allow:
allow.append(rule) allow.append(rule)
if install_hook: def install_pre_tool_use_hook(path):
hooks = settings.setdefault("hooks", {}) hooks = settings.setdefault("hooks", {})
pre_tool_use = hooks.setdefault("PreToolUse", []) pre_tool_use = hooks.setdefault("PreToolUse", [])
already_present = any( already_present = any(
entry.get("matcher") == "Bash" entry.get("matcher") == "Bash"
and any(h.get("command") == hook_path for h in entry.get("hooks", [])) and any(h.get("command") == path for h in entry.get("hooks", []))
for entry in pre_tool_use for entry in pre_tool_use
) )
if not already_present: if not already_present:
@@ -128,9 +134,15 @@ if install_hook:
# Real absolute path here (not tilde) — this is executed # Real absolute path here (not tilde) — this is executed
# directly by Claude Code as a subprocess, not matched as text, # directly by Claude Code as a subprocess, not matched as text,
# so it needs to be an actually-invokable path. # so it needs to be an actually-invokable path.
"hooks": [{"type": "command", "command": hook_path}], "hooks": [{"type": "command", "command": path}],
}) })
if install_hook:
install_pre_tool_use_hook(hook_path)
if install_raw_git_hook:
install_pre_tool_use_hook(raw_git_hook_path)
with open(settings_file, "w") as f: with open(settings_file, "w") as f:
json.dump(settings, f, indent=2) json.dump(settings, f, indent=2)
f.write("\n") f.write("\n")
@@ -171,14 +183,15 @@ with open(config_file, "w") as f:
PYEOF PYEOF
# --- 5. Summary --- # --- 5. Summary ---
python3 - "$STABLE_DIR" "$SETTINGS_FILE" "$CONFIG_FILE" "$INSTALL_HOOK" "$REPOS_JSON" "$TILDE_WRAPPER_PATH" "$TILDE_CONFIG_FILE" << 'PYEOF' python3 - "$STABLE_DIR" "$SETTINGS_FILE" "$CONFIG_FILE" "$INSTALL_HOOK" "$REPOS_JSON" "$TILDE_WRAPPER_PATH" "$TILDE_CONFIG_FILE" "$INSTALL_RAW_GIT_HOOK" << 'PYEOF'
import json, sys import json, sys
stable_dir, settings_file, config_file, install_hook, repos_json, tilde_wrapper_path, tilde_config_file = sys.argv[1:8] stable_dir, settings_file, config_file, install_hook, repos_json, tilde_wrapper_path, tilde_config_file, install_raw_git_hook = sys.argv[1:9]
print(json.dumps({ print(json.dumps({
"stable_script_location": stable_dir, "stable_script_location": stable_dir,
"settings_file": settings_file, "settings_file": settings_file,
"config_file": config_file, "config_file": config_file,
"hook_installed": install_hook == "1", "hook_installed": install_hook == "1",
"raw_git_hook_installed": install_raw_git_hook == "1",
"repos_registered": json.loads(repos_json), "repos_registered": json.loads(repos_json),
"permission_rules_added": [f"Bash({tilde_wrapper_path}:*)", f"Read({tilde_config_file})"], "permission_rules_added": [f"Bash({tilde_wrapper_path}:*)", f"Read({tilde_config_file})"],
"note": "The permission rule points at a stable location this script controls (~/.agent-skills/git-manager/bin/), not at Claude Code's internal plugin cache — so it survives future plugin updates without changing. It's written in tilde form (~/...) to match the literal, unexpanded command text Claude Code matches against, not the resolved absolute $HOME path. Permissions are written to disk now, but Claude Code loads permissions at session start and does not always pick up changes made by its own file edits within the same session — if a git_cmd.sh call still prompts after this, start a fresh session rather than re-running setup." "note": "The permission rule points at a stable location this script controls (~/.agent-skills/git-manager/bin/), not at Claude Code's internal plugin cache — so it survives future plugin updates without changing. It's written in tilde form (~/...) to match the literal, unexpanded command text Claude Code matches against, not the resolved absolute $HOME path. Permissions are written to disk now, but Claude Code loads permissions at session start and does not always pick up changes made by its own file edits within the same session — if a git_cmd.sh call still prompts after this, start a fresh session rather than re-running setup."