From 9dc84bf8f54d7775c9b64a6606559ef2c0ea32f7 Mon Sep 17 00:00:00 2001 From: ilia Date: Thu, 30 Jul 2026 16:50:56 -0400 Subject: [PATCH] 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. --- README.md | 3 + cursor/hooks.json | 21 +++ cursor/hooks/block-destructive-shell.sh | 59 ++++++++ cursor/hooks/block-infra-footguns.sh | 145 ++++++++++++++++++++ cursor/hooks/strip-cursor-coauthor-shell.sh | 57 ++++++++ cursor/hooks/test-hooks.py | 66 +++++++++ install.sh | 37 ++++- 7 files changed, 382 insertions(+), 6 deletions(-) create mode 100644 cursor/hooks.json create mode 100755 cursor/hooks/block-destructive-shell.sh create mode 100755 cursor/hooks/block-infra-footguns.sh create mode 100755 cursor/hooks/strip-cursor-coauthor-shell.sh create mode 100644 cursor/hooks/test-hooks.py diff --git a/README.md b/README.md index 9fae03f..82ca0ea 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ exec zsh | `zsh/zshrc.local.example` | → `~/.zshrc.local` for machine-only tweaks | | `cursor/rules/` | Shared Cursor rules (`family-machine`, `new-project-standards`) | | `cursor/skills/` | Shared skills (`humanizer`, `frontend-design`, `ui-ux-pro-max`) | +| `cursor/hooks/` + `cursor/hooks.json` | Cursor agent shell guardrails (destructive / vault / ansible apply) | | `cursor/mcp.json.example` | Optional Playwright MCP (no Hermes SSH) | | `scripts/git-hooks/` | Local gitleaks pre-commit | @@ -67,4 +68,6 @@ targets for Irina expect `~/Documents/code/personal-scripts`. - `install.sh` backs up existing `~/.zshrc` / `~/.p10k.zsh` before replacing. - Does not overwrite an existing `~/.cursor/mcp.json`. +- Syncs `cursor/hooks/*.sh` into `~/.cursor/hooks/`; installs `hooks.json` only if missing (use `--overwrite-cursor-hooks` to replace). +- Cursor hooks block destructive shell, `ansible-vault` / apply without check, and `git --no-verify` unless `ALLOW_VAULT=1` / `ALLOW_ANSIBLE_APPLY=1`. - No secrets in this repo — use Vaultwarden and gitignored `.env*` files. diff --git a/cursor/hooks.json b/cursor/hooks.json new file mode 100644 index 0000000..26baf69 --- /dev/null +++ b/cursor/hooks.json @@ -0,0 +1,21 @@ +{ + "version": 1, + "hooks": { + "preToolUse": [ + { + "command": "bash hooks/strip-cursor-coauthor-shell.sh", + "matcher": "Shell" + } + ], + "beforeShellExecution": [ + { + "command": "bash hooks/block-destructive-shell.sh", + "failClosed": true + }, + { + "command": "bash hooks/block-infra-footguns.sh", + "failClosed": true + } + ] + } +} diff --git a/cursor/hooks/block-destructive-shell.sh b/cursor/hooks/block-destructive-shell.sh new file mode 100755 index 0000000..13612aa --- /dev/null +++ b/cursor/hooks/block-destructive-shell.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Cursor hook: beforeShellExecution +# Purpose: deny obviously destructive commands by default, cursor-wide. +# Note: read stdin before the Python heredoc (heredoc would otherwise consume it). + +INPUT="$(cat)" + +python3 -c ' +import json, 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: 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() + +blocked_patterns = [ + r"\brm\b.*\s-(?:rf|fr)\b", + r"\brm\b.*\s--no-preserve-root\b", + r"\bmkfs(\.\w+)?\b", + r"\bdd\b\s+if=", + r"\bwipefs\b", + r"\bshred\b", + r"\btruncate\b.*\s-s\s*0\b", + r"\bgit\s+push\b.*\s--force(?:-with-lease)?\b", + r"\bgit\s+reset\b.*\s--hard\b", + r"\bgit\s+clean\b.*\s-(?:ff|fd|xdf)\b", + r"\bterraform\s+destroy\b", + r"\bterraform\s+apply\b(?!.*\s-auto-approve\b)", + r"\bkubectl\s+delete\b", + r"\bansible-playbook\b.*\s--flush-cache\b", +] + +blocked_literals = { + "sudo rm -rf /", + "rm -rf /", +} + +is_blocked = cmd_l in blocked_literals or any(re.search(p, cmd_l) for p in blocked_patterns) + +if is_blocked: + print(json.dumps({ + "permission": "deny", + "user_message": "Blocked: potentially destructive command (Cursor safety hook).", + "agent_message": f"Denied by global hook. Command was: {cmd}" + })) +else: + print(json.dumps({"permission": "allow"})) +' "$INPUT" diff --git a/cursor/hooks/block-infra-footguns.sh b/cursor/hooks/block-infra-footguns.sh new file mode 100755 index 0000000..34c5d02 --- /dev/null +++ b/cursor/hooks/block-infra-footguns.sh @@ -0,0 +1,145 @@ +#!/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" diff --git a/cursor/hooks/strip-cursor-coauthor-shell.sh b/cursor/hooks/strip-cursor-coauthor-shell.sh new file mode 100755 index 0000000..d6216ba --- /dev/null +++ b/cursor/hooks/strip-cursor-coauthor-shell.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Cursor hook: preToolUse (Shell) +# Strip Cursor co-author trailers from git commit/amend commands before execution. +# Note: read stdin before Python (a heredoc would consume hook JSON). + +INPUT="$(cat)" + +python3 -c ' +import json +import re +import sys + +raw = sys.argv[1] +try: + data = json.loads(raw) if raw.strip() else {} +except Exception: + print(json.dumps({"permission": "allow"})) + raise SystemExit(0) + +tool_input = data.get("tool_input") or {} +if not isinstance(tool_input, dict): + print(json.dumps({"permission": "allow"})) + raise SystemExit(0) + +command = (tool_input.get("command") or "").strip() +if not command or "git" not in command: + print(json.dumps({"permission": "allow"})) + raise SystemExit(0) + +if not re.search(r"\bgit\b.*\b(commit|commit\s+-a|commit\s+--amend)\b", command): + print(json.dumps({"permission": "allow"})) + raise SystemExit(0) + +cleaned = command +patterns = [ + r"""\s*--trailer\s+(['"])Co-authored-by:\s*Cursor\s*\1""", + r"""\s*--trailer\s+Co-authored-by:\s*Cursor\s*""", + r"""\s*--footer\s+(['"])Co-authored-by:\s*Cursor\s*\1""", + r"""\s*--footer\s+Co-authored-by:\s*Cursor\s*""", +] +for pattern in patterns: + cleaned = re.sub(pattern, "", cleaned, flags=re.I) + +cleaned = re.sub(r"\s{2,}", " ", cleaned).strip() + +if cleaned == command: + print(json.dumps({"permission": "allow"})) +else: + tool_input["command"] = cleaned + print(json.dumps({ + "permission": "allow", + "updated_input": tool_input, + "agent_message": "Removed Cursor co-author trailer from git commit command." + })) +' "$INPUT" diff --git a/cursor/hooks/test-hooks.py b/cursor/hooks/test-hooks.py new file mode 100644 index 0000000..6cc50ec --- /dev/null +++ b/cursor/hooks/test-hooks.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Self-test Cursor infra/destructive hooks without embedding deny patterns in the shell command line.""" +import json +import subprocess +import sys + +INFRA = "/Users/macilia/.cursor/hooks/block-infra-footguns.sh" +DEST = "/Users/macilia/.cursor/hooks/block-destructive-shell.sh" + + +def run(script: str, cmd: str): + p = subprocess.run( + ["bash", script], + input=json.dumps({"command": cmd}).encode(), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + out = json.loads(p.stdout.decode() or "{}") + return out.get("permission"), out + + +def main() -> int: + # Build patterns without writing them as contiguous deny strings in this file's + # execution path for the outer shell — this file is executed as a path only. + ap = "ansible" + "-playbook" + av = "ansible" + "-vault" + nv = "--" + "no-verify" + rfm = "rm" + " -" + "rf" + + cases = [ + ("playbook no check", f"{ap} playbooks/x.yml", "deny"), + ("playbook check", f"{ap} playbooks/x.yml --check --diff", "allow"), + ("playbook override", f"ALLOW_ANSIBLE_APPLY=1 {ap} playbooks/x.yml", "allow"), + ("vault deny", f"{av} view inventories/production/group_vars/all/vault.yml", "deny"), + ("vault override", f"ALLOW_VAULT=1 {av} view inventories/production/group_vars/all/vault.yml", "allow"), + ("make apply deny", "make sites-static-apply", "deny"), + ("make check allow", "make sites-static-check", "allow"), + ("make apply with CHECK", "make maintenance CHECK=true", "allow"), + ("make ping allow", "make ping HOST=edge", "allow"), + ("no-verify deny", f"git commit {nv} -m msg", "deny"), + ("edit-vault deny", "make edit-group-vault", "deny"), + ("vault-export allow", "make vault-export-env", "allow"), + ("make help allow", "make help", "allow"), + ("make dev deny", "make -C ansible dev HOST=x", "deny"), + ] + fail = 0 + for label, cmd, expect in cases: + perm, out = run(INFRA, cmd) + ok = perm == expect + print(("OK " if ok else "FAIL"), label, "→", perm, "" if ok else out) + fail += 0 if ok else 1 + + for label, cmd, expect in [ + ("rm deny", f"{rfm} /tmp/foo", "deny"), + ("echo allow", "echo hi", "allow"), + ]: + perm, out = run(DEST, cmd) + ok = perm == expect + print(("OK " if ok else "FAIL"), label, "→", perm, "" if ok else out) + fail += 0 if ok else 1 + + return fail + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/install.sh b/install.sh index ecc6531..995f7ae 100755 --- a/install.sh +++ b/install.sh @@ -8,17 +8,19 @@ BACKUP_DIR="${BACKUP_DIR:-$HOME/.dotfiles-backup-$(date +%Y%m%d-%H%M%S)}" usage() { cat <<'EOF' -Usage: ./install.sh [--ira] [--no-cursor] [--no-zsh] [--dry-run] +Usage: ./install.sh [--ira] [--no-cursor] [--no-zsh] [--overwrite-cursor-hooks] [--dry-run] - --ira Print Irina follow-up steps (clone personal-scripts, Gmail app password) - --no-cursor Skip ~/.cursor rules/skills/mcp example - --no-zsh Skip ~/.zshrc and ~/.p10k.zsh - --dry-run Show actions only + --ira Print Irina follow-up steps (clone personal-scripts, Gmail app password) + --no-cursor Skip ~/.cursor rules/skills/hooks/mcp example + --no-zsh Skip ~/.zshrc and ~/.p10k.zsh + --overwrite-cursor-hooks Replace existing ~/.cursor/hooks.json (backed up first) + --dry-run Show actions only EOF } DO_ZSH=1 DO_CURSOR=1 +OVERWRITE_CURSOR_HOOKS=0 IRA=0 DRY=0 while [[ $# -gt 0 ]]; do @@ -26,6 +28,7 @@ while [[ $# -gt 0 ]]; do --ira) IRA=1 ;; --no-cursor) DO_CURSOR=0 ;; --no-zsh) DO_ZSH=0 ;; + --overwrite-cursor-hooks) OVERWRITE_CURSOR_HOOKS=1 ;; --dry-run) DRY=1 ;; -h|--help) usage; exit 0 ;; *) echo "Unknown option: $1" >&2; usage; exit 1 ;; @@ -93,9 +96,31 @@ if [[ "$DO_ZSH" -eq 1 ]]; then fi if [[ "$DO_CURSOR" -eq 1 ]]; then - run mkdir -p "$HOME/.cursor/rules" "$HOME/.cursor/skills" + run mkdir -p "$HOME/.cursor/rules" "$HOME/.cursor/skills" "$HOME/.cursor/hooks" copy_tree_merge "$ROOT/cursor/rules" "$HOME/.cursor/rules" copy_tree_merge "$ROOT/cursor/skills" "$HOME/.cursor/skills" + if [[ -d "$ROOT/cursor/hooks" ]]; then + # Sync hook scripts; skip test helpers + if [[ "$DRY" -eq 1 ]]; then + echo "+ rsync -a --exclude 'test-hooks.py' $ROOT/cursor/hooks/ $HOME/.cursor/hooks/" + else + rsync -a --exclude 'test-hooks.py' "$ROOT/cursor/hooks/" "$HOME/.cursor/hooks/" + chmod +x "$HOME/.cursor/hooks/"*.sh 2>/dev/null || true + fi + echo "Synced $ROOT/cursor/hooks → $HOME/.cursor/hooks" + fi + if [[ -f "$ROOT/cursor/hooks.json" ]]; then + if [[ ! -f "$HOME/.cursor/hooks.json" ]]; then + run cp "$ROOT/cursor/hooks.json" "$HOME/.cursor/hooks.json" + echo "Installed ~/.cursor/hooks.json from repo" + elif [[ "$OVERWRITE_CURSOR_HOOKS" -eq 1 ]]; then + backup_if_exists "$HOME/.cursor/hooks.json" + run cp "$ROOT/cursor/hooks.json" "$HOME/.cursor/hooks.json" + echo "Overwrote ~/.cursor/hooks.json from repo (see --overwrite-cursor-hooks)" + else + echo "Left existing ~/.cursor/hooks.json alone (re-run with --overwrite-cursor-hooks to replace)" + fi + fi if [[ ! -f "$HOME/.cursor/mcp.json" && -f "$ROOT/cursor/mcp.json.example" ]]; then run cp "$ROOT/cursor/mcp.json.example" "$HOME/.cursor/mcp.json" echo "Installed ~/.cursor/mcp.json from example (edit if needed)" -- 2.49.1