Persist destructive/infra footgun hooks in the repo and teach install.sh to sync them without clobbering a local hooks.json unless overwritten.
146 lines
4.4 KiB
Bash
Executable File
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"
|