Add Cursor shell hooks for vault and ansible apply gates #7

Merged
ilia merged 1 commits from feat/cursor-infra-hooks into master 2026-07-30 15:52:59 -05:00
7 changed files with 382 additions and 6 deletions

View File

@ -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.

21
cursor/hooks.json Normal file
View File

@ -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
}
]
}
}

View File

@ -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"

View File

@ -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"

View File

@ -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*<cursoragent@cursor\.com>\1""",
r"""\s*--trailer\s+Co-authored-by:\s*Cursor\s*<cursoragent@cursor\.com>""",
r"""\s*--footer\s+(['"])Co-authored-by:\s*Cursor\s*<cursoragent@cursor\.com>\1""",
r"""\s*--footer\s+Co-authored-by:\s*Cursor\s*<cursoragent@cursor\.com>""",
]
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"

View File

@ -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())

View File

@ -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-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)"