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.
This commit is contained in:
parent
044501bd72
commit
9dc84bf8f5
@ -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
21
cursor/hooks.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
59
cursor/hooks/block-destructive-shell.sh
Executable file
59
cursor/hooks/block-destructive-shell.sh
Executable 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"
|
||||
145
cursor/hooks/block-infra-footguns.sh
Executable file
145
cursor/hooks/block-infra-footguns.sh
Executable 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"
|
||||
57
cursor/hooks/strip-cursor-coauthor-shell.sh
Executable file
57
cursor/hooks/strip-cursor-coauthor-shell.sh
Executable 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"
|
||||
66
cursor/hooks/test-hooks.py
Normal file
66
cursor/hooks/test-hooks.py
Normal 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())
|
||||
31
install.sh
31
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-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)"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user