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:
+86
@@ -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
|
||||
Reference in New Issue
Block a user