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