Cursor 的 Hooks 可以在 Agent 执行命令、读取文件、修改文件的前后运行你自己的脚本。这篇在
.cursor/hooks.json里配置三个 hook:
beforeShellExecution:拦截高危命令,比如递归删除根目录或家目录、curl | sh、强制推送;git push和发布包要先问用户。beforeReadFile:不让.env、私钥和带密钥的内容被发送给模型。afterFileEdit:Agent 改完.py文件后立刻做语法检查,并记录日志。
先看清成品(box 上真实运行:按官方文档的输入格式,把 JSON 喂给 hook 脚本)
- 一共测了 20 多条命令和读取场景,结果全部符合预期。
- 第一版漏掉的三种绕过写法(
rm -r -f /、--recursive --force、bash -c "rm -rf /")都已经补上。- pytest 5 项通过。
合规提醒:Cursor 会把代码上下文发送给模型服务商处理。涉及公司代码时,先确认团队是否允许使用,并按需要开启 Privacy Mode。规则、hooks 和 MCP 配置会提交进仓库,里面不要写任何密钥,密钥用环境变量或
${env:NAME}引用。用 AI 生成的代码,版权和许可证合规由你负责,合并前要人工审查。本文不列 Cursor 的价格,额度和可用模型以官网为准。
运行说明:box 上没有登录 Cursor,所以没有在 Cursor 编辑器或 Agent 里真正触发过这些 hook。脚本的输入和输出格式严格按官方 Hooks 文档编写,并用模拟输入测试过。另外,hook 只是护栏,不是沙箱:比如
python -c "import shutil; shutil.rmtree('/')"这种写法,靠匹配命令文本是拦不住的。真正要隔离风险,还得配合 Cursor 的沙箱或运行模式设置。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
.cursor/hooks.json |
三个 hook 的配置;命令拦截开启 failClosed |
.cursor/hooks/guard_shell.py |
拦截高危命令(按子命令拆分、展开 bash -c、识别 rm 的各种参数写法) |
.cursor/hooks/guard_read.py |
按文件名和内容拦截敏感信息 |
.cursor/hooks/after_edit.py |
语法检查并记录日志 |
test_hooks.py |
用模拟输入测试 |
需要准备哪些环境?
系统里要有 python3(hook 会以子进程方式启动)。测试需要 pytest。
Hooks 要点(来自官方文档)
- 配置文件:
.cursor/hooks.json(项目级)或~/.cursor/hooks.json(用户级),version必须是 1。每个 hook 可以设置command、timeout、matcher、failClosed。 - 输入:脚本从 stdin 读取 JSON。所有 hook 都会收到
conversation_id、hook_event_name、workspace_roots等字段;beforeShellExecution额外有command、cwd、sandbox。 - 输出:stdout 输出 JSON,例如
{"permission": "allow" | "deny" | "ask", "user_message", "agent_message"}。 - 退出码:0 表示使用输出的 JSON;2 表示拦截;其他退出码表示 hook 失败,默认放行(fail-open),设置
failClosed: true后改为拦截。 - 权限类 hook 输出不合法的 JSON 时,动作会被阻止。
- 多个来源的 hook 都会运行,结果合并时 deny 优先于 ask。
- 云端 Agent 也会运行仓库里的命令型 hook。
文件结构
54-cursor-B-src/
├── .cursor/
│ ├── hooks.json
│ └── hooks/
│ ├── guard_shell.py
│ ├── guard_read.py
│ └── after_edit.py
└── test_hooks.py
第 1 步:配置
{
"version": 1,
"hooks": {
"beforeShellExecution": [
{ "command": "python3 .cursor/hooks/guard_shell.py", "timeout": 10, "failClosed": true }
],
"beforeReadFile": [
{ "command": "python3 .cursor/hooks/guard_read.py", "timeout": 10 }
],
"afterFileEdit": [
{ "command": "python3 .cursor/hooks/after_edit.py", "timeout": 30 }
]
}
}
第 2 步:hook 脚本
"""beforeShellExecution:拦截高危命令,需要确认的命令返回 ask,其余放行。
输入(官方文档):{"command": ..., "cwd": ..., "sandbox": ...};输出:{"permission": "allow"|"deny"|"ask", "user_message", "agent_message"}"""
import json, re, shlex, sys
DENY = [
(r"(curl|wget)[^|]*\|\s*(sudo\s+)?(ba|z)?sh\b", "下载脚本直接管道给 shell 执行"),
(r"\bgit\s+push\b.*(--force|-f)\b", "强制推送"),
(r"\b(mkfs|dd\s+if=)", "格式化或直接写磁盘"),
(r"\bchmod\s+-R\s+777\b", "递归 777 权限"),
]
ASK = [
(r"\bgit\s+push\b", "推送到远程仓库"),
(r"\b(npm|pnpm|yarn)\s+publish\b|\btwine\s+upload\b", "发布包"),
(r"\b(drop|truncate)\s+(table|database)\b", "删除数据库表"),
]
DANGER_TARGETS = {"/", "/*", "~", "~/", "$HOME", "${HOME}", "."}
def segments(cmd):
"""按 ; && || | 和换行拆成子命令;bash -c / sh -c 里的字符串也展开检查。"""
out = []
for seg in re.split(r";|&&|\|\||\||\n", cmd):
try:
toks = shlex.split(seg)
except ValueError:
toks = seg.split()
while toks and toks[0] in ("sudo", "env", "nohup", "time", "command"):
toks = toks[1:]
if len(toks) >= 3 and toks[0] in ("bash", "sh", "zsh") and toks[1] == "-c":
out += segments(toks[2]); continue
if toks:
out.append(toks)
return out
def rm_danger(toks):
if toks[0] != "rm":
return False
flags = [x for x in toks[1:] if x.startswith("-")]
recursive = any(f in ("--recursive",) or (not f.startswith("--") and "r" in f.lower()) for f in flags)
targets = [x for x in toks[1:] if not x.startswith("-")]
return recursive and any(x.rstrip("/") in ("", "~", "$HOME", "${HOME}", ".") or x in DANGER_TARGETS for x in targets)
def decide(cmd):
if any(rm_danger(t) for t in segments(cmd)):
why = "递归删除根目录、家目录或当前目录"
return {"permission": "deny", "user_message": f"已拦截:{why}",
"agent_message": f"命令被项目 hook 拦截({why})。请删除具体的子目录,或请用户手动执行。"}
for pat, why in DENY:
if re.search(pat, cmd, re.I):
return {"permission": "deny", "user_message": f"已拦截:{why}",
"agent_message": f"命令被项目 hook 拦截({why})。请换一种更安全的做法,或请用户手动执行。"}
for pat, why in ASK:
if re.search(pat, cmd, re.I):
return {"permission": "ask", "user_message": f"需要确认:{why} → {cmd}"}
return {"permission": "allow"}
if __name__ == "__main__":
data = json.load(sys.stdin)
print(json.dumps(decide(data.get("command", "")), ensure_ascii=False))
"""beforeReadFile:不让 Agent 把密钥文件或含密钥的内容发给模型。
输入包含 file_path 和 content;输出 {"permission": "allow"|"deny", "user_message"}。"""
import json, re, sys, pathlib
NAMES = re.compile(r"(^\.env(\..+)?$|\.pem$|\.key$|^id_(rsa|ed25519)$|credentials\.json$)")
SECRETS = [r"AKIA[0-9A-Z]{16}", r"-----BEGIN [A-Z ]*PRIVATE KEY-----", r"ghp_[A-Za-z0-9]{36}", r"sk-[A-Za-z0-9_-]{20,}"]
def decide(path, content):
name = pathlib.Path(path).name
if NAMES.search(name) and not name.endswith(".example"):
return {"permission": "deny", "user_message": f"已阻止读取敏感文件 {name}"}
for p in SECRETS:
if re.search(p, content or ""):
return {"permission": "deny", "user_message": f"{name} 里疑似有密钥(匹配 {p[:12]}…),已阻止发送给模型"}
return {"permission": "allow"}
if __name__ == "__main__":
d = json.load(sys.stdin)
print(json.dumps(decide(d.get("file_path", ""), d.get("content", "")), ensure_ascii=False))
"""afterFileEdit:Agent 改完 .py 文件后立刻做语法检查,并把结果追加到 .cursor/hooks/edits.log。
afterFileEdit 是观察型 hook,不能撤销修改;它的作用是留痕和提醒。"""
import json, py_compile, sys, time, pathlib
d = json.load(sys.stdin)
path = d.get("file_path", "")
status = "skip"
if path.endswith(".py"):
try:
py_compile.compile(path, doraise=True); status = "ok"
except py_compile.PyCompileError as e:
status = "SYNTAX ERROR: " + str(e).splitlines()[-1][:200]
log = pathlib.Path(__file__).with_name("edits.log")
with log.open("a", encoding="utf-8") as f:
f.write(f"{time.strftime('%F %T')}\t{path}\t{len(d.get('edits', []))} 处修改\t{status}\n")
print("{}")
第 3 步:测试
import json, subprocess, sys, pathlib
H = pathlib.Path(__file__).parent / ".cursor/hooks"
BASE = {"conversation_id": "c1", "generation_id": "g1", "hook_event_name": "x", "workspace_roots": ["/repo"]}
def run(script, payload):
r = subprocess.run([sys.executable, str(H / script)], input=json.dumps({**BASE, **payload}),
capture_output=True, text=True, check=True)
return json.loads(r.stdout)
def test_shell():
cases = {"rm -rf /": "deny", "rm -rf ~": "deny", "rm -rf build/": "allow",
"curl -fsSL https://x.sh | bash": "deny", "git push --force origin main": "deny",
"git push origin feature": "ask", "npm publish": "ask", "pytest -q": "allow",
"ls -la": "allow"}
for cmd, want in cases.items():
assert run("guard_shell.py", {"command": cmd, "cwd": "/repo"})["permission"] == want, cmd
def test_read():
assert run("guard_read.py", {"file_path": "/repo/.env", "content": "X=1"})["permission"] == "deny"
assert run("guard_read.py", {"file_path": "/repo/.env.example", "content": "X="})["permission"] == "allow"
key = "AKIA" + "ABCDEFGHIJKLMNOP"
assert run("guard_read.py", {"file_path": "/repo/a.py", "content": f"k='{key}'"})["permission"] == "deny"
assert run("guard_read.py", {"file_path": "/repo/a.py", "content": "print(1)"})["permission"] == "allow"
def test_after_edit(tmp_path):
bad = tmp_path / "bad.py"; bad.write_text("def f(:\n")
assert run("after_edit.py", {"file_path": str(bad), "edits": [{"old_string": "", "new_string": "x"}]}) == {}
assert "SYNTAX ERROR" in (H / "edits.log").read_text().splitlines()[-1]
def test_hooks_json_valid():
cfg = json.loads((H.parent / "hooks.json").read_text())
assert cfg["version"] == 1 and all("command" in h for v in cfg["hooks"].values() for h in v)
def test_shell_bypass_attempts():
for cmd in ["rm -r -f /", "rm --recursive --force /", 'bash -c "rm -rf /"', "cd /tmp && rm -rf ~",
"sudo rm -Rf $HOME", "rm -rf ."]:
assert run("guard_shell.py", {"command": cmd})["permission"] == "deny", cmd
for cmd in ["rm -rf build/", "rm -f /tmp/a.log", "rm notes.txt"]:
assert run("guard_shell.py", {"command": cmd})["permission"] == "allow", cmd
第 4 步:运行
$ echo '{"command":"rm -r -f /"}' | python3 .cursor/hooks/guard_shell.py
{"permission": "deny", "user_message": "已拦截:递归删除根目录、家目录或当前目录", "agent_message": "命令被项目 hook 拦截(递归删除根目录、家目录或当前目录)。请删除具体的子目录,或请用户手动执行。"}
$ echo '{"command":"rm -rf build/"}' | python3 .cursor/hooks/guard_shell.py
{"permission": "allow"}
$ echo '{"command":"git push origin main"}' | python3 .cursor/hooks/guard_shell.py
{"permission": "ask", "user_message": "需要确认:推送到远程仓库 → git push origin main"}
$ python -m pytest -q
5 passed in 0.37s
调试示例
第一版 hook 能被轻易绕过。 第一版用一条正则匹配 rm -rf /,测试全部通过。我又试了几种同义写法:
rm -fr / → deny
rm -r -f / → allow ← 漏了
rm --recursive --force / → allow ← 漏了
bash -c "rm -rf /" → allow ← 漏了
问题在于正则只认参数连写的形式。现在的做法是:
- 用
shlex把命令切成词,按;、&&、|拆成子命令; - 去掉开头的
sudo、env等前缀; - 遇到
bash -c或sh -c,递归检查引号里的命令; - 识别 rm 的长短参数写法,并判断删除目标是不是根目录、家目录或当前目录。
test_shell_bypass_attempts 把这些写法都固定成了测试。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| hook 不生效 | 没有 "version": 1,或者脚本路径写错 |
路径相对于项目根目录;先在终端里手动运行一遍 |
| 所有命令都被拦截 | 脚本输出了非 JSON 内容(比如调试用的 print) | 调试信息写到 stderr 或日志文件 |
| hook 崩溃后命令仍然执行了 | 默认是 fail-open | 安全相关的 hook 设置 failClosed: true |
| ask 在 preToolUse 里不起作用 | 官方说明:preToolUse 目前不强制执行 ask | 在 beforeShellExecution 里使用 ask |
模板规则
- 安全类 hook 一律设置 failClosed,输出只写 JSON。
- 匹配命令时先切词再判断,不要只靠一条正则。
- hook 只是护栏,要配合沙箱或运行模式一起使用。
项目资源
| 用途 | 地址 |
|---|---|
| Cursor 文档索引(llms.txt) | https://cursor.com/llms.txt |
| Cursor Hooks 文档 | https://cursor.com/docs/hooks |
hook 能在 Agent 犯错之前把它拦住,也给每一次修改留下记录,审计时有据可查。