#!/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