Skip to content

Commit 08c3d0e

Browse files
committed
feat(hooks): add optional Cursor hooks pack
- Add deterministic Cursor hooks (shell guard, file-read guard, audit logger) - Add example `hooks.json` configs for project and user installs - Add idempotent installer script that merges and backs up hooks.json - Document hook usage and rollout guidance Enables enforceable guardrails (beyond prompt-based rules) for high-risk agent actions like destructive shell commands and reading secret files.
1 parent ffc9aa8 commit 08c3d0e

11 files changed

Lines changed: 763 additions & 0 deletions

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ Comprehensive, battle-tested Cursor IDE rules for professional software engineer
7878
Utility scripts for Cursor maintenance:
7979

8080
- **[cursor-maintenance.sh](scripts/cursor-maintenance.sh)** - Clean cache, logs, and temp files to reclaim disk space
81+
- **[cursor-hooks-install.sh](scripts/cursor-hooks-install.sh)** - Install optional deterministic Cursor hooks (guardrails + audit)
8182

8283
```bash
8384
# Preview cleanup
@@ -89,6 +90,13 @@ Utility scripts for Cursor maintenance:
8990

9091
See [scripts/README.md](scripts/README.md) for details.
9192

93+
### Hooks (optional)
94+
95+
Deterministic lifecycle hooks to observe/control agent behavior (for example: gate destructive shell commands, block reading `.env` files).
96+
97+
- Docs: **[`docs/HOOKS.md`](docs/HOOKS.md)**
98+
- Cursor hook pack: **[`hooks/cursor/`](hooks/cursor/)**
99+
92100
---
93101

94102
## Cursor Commands

docs/HOOKS.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
---
2+
title: Hooks
3+
description: Deterministic lifecycle hooks for controlling agent behavior (Cursor and compatible hook systems).
4+
---
5+
6+
# Hooks
7+
8+
Hooks are **deterministic scripts** that run at defined points in an agent loop (before shell execution, before reading files, after edits, at stop, etc). Unlike rules, hooks can **block, allow, or modify** actions reliably because they execute outside the model.
9+
10+
This repo ships an **optional Cursor hooks pack** under `hooks/cursor/`:
11+
12+
- Guardrails for risky commands (`beforeShellExecution`)
13+
- Guardrails for sensitive file reads (`beforeReadFile`)
14+
- Lightweight audit logging (optional)
15+
16+
> [!IMPORTANT]
17+
> Hooks are not enabled by default. Start with **project hooks** (per-repo) before enabling global user hooks.
18+
19+
## Cursor hook configuration
20+
21+
Cursor loads hooks from:
22+
23+
- **Project**: `<repo>/.cursor/hooks.json` (runs from repo root)
24+
- **User**: `~/.cursor/hooks.json` (runs from `~/.cursor`)
25+
26+
Hook scripts communicate via JSON over stdio (stdin input, stdout output). Cursor docs: `https://cursor.com/docs/agent/hooks`
27+
28+
## What we provide
29+
30+
### 1) Shell guard (`beforeShellExecution`)
31+
32+
- **File**: `hooks/cursor/guard_before_shell.py`
33+
- **Goal**: deterministically gate dangerous/destructive commands and remote writes.
34+
35+
Behavior:
36+
37+
- **deny**: clearly destructive commands that are almost never correct to run autonomously (example: `rm -rf /`)
38+
- **ask**: remote writes and high-blast-radius commands (example: `git push`, `terraform apply`)
39+
- **allow**: everything else
40+
41+
### 2) File read guard (`beforeReadFile`)
42+
43+
- **File**: `hooks/cursor/guard_before_read_file.py`
44+
- **Goal**: block sending sensitive file contents to the model.
45+
46+
Behavior:
47+
48+
- **deny**: obvious secret files (example: `.env`, private keys) and binary-ish content
49+
- **allow**: everything else
50+
51+
### 3) Audit logger (optional)
52+
53+
- **File**: `hooks/cursor/audit_log.py`
54+
- **Goal**: append a redacted JSON line log of hook events to a local file.
55+
56+
## Recommended rollout
57+
58+
### Step 1 - Project hooks (recommended)
59+
60+
Copy the example config and scripts into your repo:
61+
62+
```bash
63+
mkdir -p .cursor/hooks
64+
cp -R /path/to/dp-cursor-engineering-rules/hooks/cursor/*.py .cursor/hooks/
65+
cp /path/to/dp-cursor-engineering-rules/hooks/cursor/hooks.project.example.json .cursor/hooks.json
66+
chmod +x .cursor/hooks/*.py
67+
```
68+
69+
### Step 2 - User hooks (optional)
70+
71+
Only after you like the behavior in a few repos, install globally:
72+
73+
```bash
74+
/path/to/dp-cursor-engineering-rules/scripts/cursor-hooks-install.sh --user
75+
```
76+
77+
## Notes and safety
78+
79+
- Hooks run with your user permissions - treat them like any other local automation.
80+
- Prefer **fail-open** while iterating (Cursor supports `failClosed` if you want fail-closed later).
81+
- Keep matchers narrow to reduce noise and performance overhead.

hooks/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
## Hooks
2+
3+
This repo primarily provides Cursor rules, commands, and skills.
4+
5+
The `hooks/` directory provides **optional deterministic hook scripts** to observe and control agent behavior at well-defined lifecycle points.
6+
7+
- Cursor: `hooks/cursor/`
8+
9+
Start here: `docs/HOOKS.md`

hooks/cursor/README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
## Cursor Hooks Pack
2+
3+
This directory contains **optional** hook scripts and example `hooks.json` configs for Cursor.
4+
5+
Hooks are deterministic programs that run at defined points in the agent loop and can block, allow, or modify actions.
6+
7+
### Files
8+
9+
- `guard_before_shell.py`: Intended for `beforeShellExecution`
10+
- Denies obviously catastrophic delete commands
11+
- Asks for approval on remote writes / high-blast-radius commands
12+
- `guard_before_read_file.py`: Intended for `beforeReadFile`
13+
- Denies reading common secret files (for example `.env`, private keys)
14+
- `audit_log.py`: Intended for `preToolUse` (or other events)
15+
- Writes a redacted JSONL audit record to `.cursor/hooks/state/hook-audit.jsonl` (project) or `~/.cursor/hooks/state/hook-audit.jsonl` (user)
16+
- `hooks.project.example.json`: Example project config (paths like `.cursor/hooks/...`)
17+
- `hooks.user.example.json`: Example user config (paths like `./hooks/...`)
18+
19+
### Quick start (project)
20+
21+
From your repo root:
22+
23+
```bash
24+
mkdir -p .cursor/hooks
25+
cp -R /path/to/dp-cursor-engineering-rules/hooks/cursor/*.py .cursor/hooks/
26+
cp /path/to/dp-cursor-engineering-rules/hooks/cursor/hooks.project.example.json .cursor/hooks.json
27+
chmod +x .cursor/hooks/*.py
28+
```

hooks/cursor/audit_log.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Cursor hook script: write a redacted JSONL audit record for hook events.
4+
5+
This script is designed to be called by Cursor hooks (e.g., preToolUse) and is:
6+
- dependency-free (stdlib only)
7+
- safe-by-default (redacts common secret-like fields)
8+
- resilient (always prints a JSON response)
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import json
14+
import os
15+
import re
16+
import sys
17+
from dataclasses import dataclass
18+
from datetime import datetime, timezone
19+
from pathlib import Path
20+
from typing import Any
21+
22+
23+
REDACT_KEY_RE = re.compile(
24+
r"(token|secret|password|passwd|authorization|api[_-]?key|private[_-]?key)",
25+
re.IGNORECASE,
26+
)
27+
28+
29+
def _now_iso() -> str:
30+
return datetime.now(timezone.utc).isoformat()
31+
32+
33+
def _safe_load_stdin() -> dict[str, Any]:
34+
raw = sys.stdin.read()
35+
if not raw.strip():
36+
return {}
37+
try:
38+
data = json.loads(raw)
39+
if isinstance(data, dict):
40+
return data
41+
except Exception:
42+
pass
43+
return {"_raw": raw[:4096]}
44+
45+
46+
def _redact(value: Any) -> Any:
47+
if isinstance(value, dict):
48+
redacted: dict[str, Any] = {}
49+
for k, v in value.items():
50+
if REDACT_KEY_RE.search(str(k)):
51+
redacted[k] = "***REDACTED***"
52+
else:
53+
redacted[k] = _redact(v)
54+
return redacted
55+
if isinstance(value, list):
56+
return [_redact(v) for v in value]
57+
if isinstance(value, str):
58+
if len(value) > 20000:
59+
return value[:20000] + "...<truncated>"
60+
return value
61+
return value
62+
63+
64+
@dataclass(frozen=True)
65+
class Output:
66+
# Cursor accepts a minimal output object. Returning {} is also OK.
67+
# We keep a stable shape to avoid accidental blocking.
68+
continue_: bool = True
69+
70+
def to_json(self) -> str:
71+
return json.dumps({"continue": self.continue_})
72+
73+
74+
def _default_log_path() -> Path:
75+
# User hooks run from ~/.cursor; project hooks run from repo root.
76+
# Prefer a local state directory if present, otherwise write under ~/.cursor.
77+
cwd = Path(os.getcwd())
78+
local_state = cwd / ".cursor" / "hooks" / "state"
79+
if (cwd / ".cursor").exists():
80+
return local_state / "hook-audit.jsonl"
81+
82+
home = Path.home()
83+
return home / ".cursor" / "hooks" / "state" / "hook-audit.jsonl"
84+
85+
86+
def main() -> int:
87+
payload = _safe_load_stdin()
88+
record = {
89+
"ts_utc": _now_iso(),
90+
"hook_event_name": payload.get("hook_event_name"),
91+
"tool_name": payload.get("tool_name"),
92+
"file_path": payload.get("file_path") or (payload.get("tool_input") or {}).get("file_path"),
93+
"cwd": payload.get("cwd"),
94+
"conversation_id": payload.get("conversation_id"),
95+
"generation_id": payload.get("generation_id"),
96+
"model": payload.get("model"),
97+
"payload": _redact(payload),
98+
}
99+
100+
log_path = _default_log_path()
101+
log_path.parent.mkdir(parents=True, exist_ok=True)
102+
with log_path.open("a", encoding="utf-8") as f:
103+
f.write(json.dumps(record, ensure_ascii=False) + "\n")
104+
105+
sys.stdout.write(Output().to_json() + "\n")
106+
return 0
107+
108+
109+
if __name__ == "__main__":
110+
raise SystemExit(main())
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Cursor hook script: guard before sending file contents to the model.
4+
5+
Designed for the `beforeReadFile` hook. It can deny reads of common secret files.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import json
11+
import sys
12+
from pathlib import Path
13+
from typing import Any
14+
15+
16+
DENY_BASENAMES = {
17+
".env",
18+
".env.local",
19+
".env.development",
20+
".env.test",
21+
".env.production",
22+
"credentials",
23+
"credentials.json",
24+
"id_rsa",
25+
"id_ed25519",
26+
"config.json",
27+
}
28+
29+
DENY_SUFFIXES = {
30+
".pem",
31+
".p12",
32+
".pfx",
33+
".key",
34+
".keystore",
35+
".kdb",
36+
".mobileprovision",
37+
}
38+
39+
40+
def _load_payload() -> dict[str, Any]:
41+
raw = sys.stdin.read()
42+
if not raw.strip():
43+
return {}
44+
try:
45+
data = json.loads(raw)
46+
return data if isinstance(data, dict) else {}
47+
except Exception:
48+
return {}
49+
50+
51+
def _deny(user_message: str, agent_message: str) -> dict[str, Any]:
52+
return {
53+
"continue": True,
54+
"permission": "deny",
55+
"user_message": user_message,
56+
"agent_message": agent_message,
57+
}
58+
59+
60+
def _allow() -> dict[str, Any]:
61+
return {"continue": True, "permission": "allow"}
62+
63+
64+
def main() -> int:
65+
payload = _load_payload()
66+
file_path = str(payload.get("file_path") or "")
67+
basename = Path(file_path).name
68+
69+
if basename in DENY_BASENAMES:
70+
out = _deny(
71+
user_message=f"Blocked reading sensitive file: {basename}",
72+
agent_message=(
73+
f"Do not read or exfiltrate `{basename}`. Ask the user to provide a redacted snippet or "
74+
"use a committed sample file (for example `.env.example`)."
75+
),
76+
)
77+
sys.stdout.write(json.dumps(out) + "\n")
78+
return 0
79+
80+
for suffix in DENY_SUFFIXES:
81+
if basename.endswith(suffix):
82+
out = _deny(
83+
user_message=f"Blocked reading sensitive file: *{suffix}",
84+
agent_message=(
85+
f"Do not read private key / certificate material (`{basename}`). Ask for a redacted, "
86+
"non-sensitive excerpt if needed."
87+
),
88+
)
89+
sys.stdout.write(json.dumps(out) + "\n")
90+
return 0
91+
92+
# Heuristic: block very large content payloads to avoid accidental binary-ish exfil.
93+
content = payload.get("content")
94+
if isinstance(content, str) and len(content) > 2_000_000:
95+
out = _deny(
96+
user_message="Blocked reading very large file content (likely binary or vendored)",
97+
agent_message=(
98+
"Avoid sending very large files to the model. Prefer narrow reads (specific ranges), "
99+
"or summarize structure without ingesting full content."
100+
),
101+
)
102+
sys.stdout.write(json.dumps(out) + "\n")
103+
return 0
104+
105+
sys.stdout.write(json.dumps(_allow()) + "\n")
106+
return 0
107+
108+
109+
if __name__ == "__main__":
110+
raise SystemExit(main())

0 commit comments

Comments
 (0)