dotfiles/cursor/hooks/block-infra-footguns.sh
ilia 9dc84bf8f5
All checks were successful
CI / skip-ci-check (pull_request) Successful in 23s
CI / secret-scan (pull_request) Successful in 22s
Add Cursor shell hooks for vault and ansible apply gates
Persist destructive/infra footgun hooks in the repo and teach install.sh
to sync them without clobbering a local hooks.json unless overwritten.
2026-07-30 16:50:56 -04:00

146 lines
4.4 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# Cursor hook: beforeShellExecution
# Purpose: deny vault / unsolicited ansible apply / --no-verify by default.
# Overrides (env or command prefix): ALLOW_VAULT=1, ALLOW_ANSIBLE_APPLY=1
# Note: read stdin before Python (a heredoc would consume hook JSON).
INPUT="$(cat)"
python3 -c '
import json, os, re, sys
raw = sys.argv[1]
try:
data = json.loads(raw) if raw.strip() else {}
except Exception:
print(json.dumps({
"permission": "deny",
"user_message": "Blocked: infra safety hook could not parse shell request input.",
"agent_message": "Hook failed to parse input JSON; failing closed."
}))
raise SystemExit(0)
cmd = (data.get("command") or "").strip()
cmd_l = cmd.lower()
def env_or_cmd_flag(name: str) -> bool:
val = (os.environ.get(name) or "").strip().lower()
if val in {"1", "true", "yes", "on"}:
return True
return bool(re.search(
rf"(?:^|[\s;|&]){re.escape(name)}=(?:1|true|yes|on)\b",
cmd,
re.IGNORECASE,
))
allow_vault = env_or_cmd_flag("ALLOW_VAULT")
allow_apply = env_or_cmd_flag("ALLOW_ANSIBLE_APPLY")
def deny(msg: str) -> None:
print(json.dumps({
"permission": "deny",
"user_message": msg,
"agent_message": (
f"{msg} Command was: {cmd}. "
"Overrides: ALLOW_VAULT=1 (vault), ALLOW_ANSIBLE_APPLY=1 (apply)."
),
}))
# --- git --no-verify ---
if re.search(r"\bgit\b.*\b(?:commit|push|rebase|amend)\b.*--no-verify\b", cmd_l) or \
re.search(r"\bgit\b.*--no-verify\b.*\b(?:commit|push|rebase|amend)\b", cmd_l):
deny("Blocked: git --no-verify is not allowed (Cursor infra safety hook).")
raise SystemExit(0)
# --- ansible-vault (direct) ---
if re.search(r"\bansible-vault\b", cmd_l) and not allow_vault:
deny(
"Blocked: ansible-vault without ALLOW_VAULT=1 "
"(Cursor infra safety hook)."
)
raise SystemExit(0)
# --- make vault edit targets ---
if re.search(
r"\bmake\b(?:\s+\S+)*\s+edit-(?:group-)?vault(?:-all)?\b",
cmd_l,
) and not allow_vault:
deny(
"Blocked: make edit-vault* without ALLOW_VAULT=1 "
"(Cursor infra safety hook)."
)
raise SystemExit(0)
# --- raw ansible-playbook without check/syntax-check ---
if re.search(r"\bansible-playbook\b", cmd_l) and not allow_apply:
has_check = bool(re.search(r"(?:^|[\s=])(?:--check|--syntax-check)\b", cmd_l))
if not has_check:
deny(
"Blocked: ansible-playbook without --check/--syntax-check "
"(set ALLOW_ANSIBLE_APPLY=1 to apply for real)."
)
raise SystemExit(0)
# --- high-risk make apply targets without CHECK= / --check ---
risky_make = [
r"sites-static-apply",
r"site\b(?!-)",
r"dev\b",
r"servers\b",
r"local\b",
r"workstations\b",
r"security\b(?!-)",
r"fleet-security\b",
r"security-hardening\b",
r"maintenance\b(?!-)",
r"docker\b",
r"shell\b(?!-)",
r"shell-all\b",
r"apps\b",
r"tailscale\b(?!-)",
r"caddy-(?:auth|levkin|talos|mailcow|grafana|monitoring-sites)\b",
r"homelab-(?:apps-oidc|talos-remediation)\b",
r"cal-oidc\b(?!-)",
r"linkwarden-windmill-oidc\b",
r"fix-sso-regressions\b",
r"observability(?:-agents)?\b",
r"\w+-apply\b",
]
has_make_check = bool(
re.search(r"\bcheck=(?:1|true|yes)\b", cmd_l)
or re.search(r"(?:^|[\s])--check\b", cmd_l)
)
safe_make_goals = re.compile(
r"\b(?:check|test-syntax|sites-static-check|tailscale-check|"
r"cal-oidc-check|homelab-apps-oidc-check|maintenance-check|"
r"network-check|control-ui-manifest-check|help|ping|facts|"
r"vault-export-env|lint|test)\b"
)
if re.search(r"\bmake\b", cmd_l) and not allow_apply and not has_make_check:
# Only inspect make goal words (skip VAR=value tokens)
goals = [
t for t in re.findall(r"(?:^|\s)([A-Za-z0-9_.-]+)(?=\s|$)", cmd_l)
if t != "make" and "=" not in t and not t.startswith("-")
]
goals_s = " ".join(goals)
if not safe_make_goals.search(goals_s):
for pat in risky_make:
if re.search(rf"(?:^|\s){pat}", goals_s):
deny(
"Blocked: make apply-like target without CHECK=true/--check "
"(set ALLOW_ANSIBLE_APPLY=1 when the user asked to apply)."
)
raise SystemExit(0)
print(json.dumps({"permission": "allow"}))
' "$INPUT"