Files
Agent-System/.cursor/hooks/_lib.py
T
weichen 476e97c993 fix(tooling): strip MCP: prefix in Cursor hook tool_name matching
IDE postToolUse reports tool_name as MCP:pi_subagent_*; without stripping,
guard-pi-dispatch skipped shape checks. Confirmed via live pi_subagent_roles.
2026-09-24 10:35:51 +00:00

167 lines
4.4 KiB
Python

#!/usr/bin/env python3
"""Shared helpers for project Cursor hooks (advisory gates + audit/probe)."""
from __future__ import annotations
import json
import os
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
KNOWN_ROLES = frozenset(
{
"research",
"planning",
"requirements_review",
"plan_review",
"code_review",
"diagnosis",
"test_run",
"acceptance_check",
"stage_notes",
"report",
"auxiliary",
}
)
TEST_ROLES = frozenset({"test_run", "acceptance_check"})
FULL_SHA = re.compile(r"^[0-9a-fA-F]{40}$")
REQUEST_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
def repo_root() -> Path:
env = os.environ.get("CURSOR_PROJECT_DIR") or os.environ.get("CURSOR_WORKSPACE_ROOT")
if env:
return Path(env).resolve()
# hooks run with cwd = project root
return Path.cwd().resolve()
def audit_path() -> Path:
return repo_root() / ".cursor" / "hook-audit.jsonl"
def probe_path() -> Path:
return repo_root() / ".cursor" / "hook-probe.jsonl"
def inventory_dir() -> Path:
return repo_root() / ".cursor" / "hook-audit"
def append_jsonl(path: Path, record: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
record = dict(record)
record.setdefault("ts", datetime.now(timezone.utc).isoformat())
with path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
def read_stdin() -> str:
return sys.stdin.read()
def maybe_probe(raw: str, hook: str) -> None:
if os.environ.get("CURSOR_HOOKS_PROBE", "").strip() not in ("1", "true", "TRUE", "yes"):
return
append_jsonl(
probe_path(),
{"hook": hook, "raw": raw[:65536], "raw_len": len(raw)},
)
def parse_json_loose(raw: str) -> dict[str, Any] | None:
raw = (raw or "").strip()
if not raw:
return None
try:
data = json.loads(raw)
except json.JSONDecodeError:
return None
return data if isinstance(data, dict) else None
def first_key(data: dict[str, Any], *names: str) -> Any:
for name in names:
if name in data and data[name] is not None:
return data[name]
return None
def normalize_tool_name(name: str | None) -> str | None:
"""Strip Cursor MCP prefixes such as 'MCP:' / 'MCP: pi-subagents/'."""
if name is None:
return None
n = str(name).strip()
# Observed IDE postToolUse: tool_name == "MCP:pi_subagent_roles"
if n.upper().startswith("MCP:"):
n = n[4:].lstrip()
# Rare: "MCP: pi-subagents/pi_subagent_run"
if "/" in n and n.rsplit("/", 1)[-1].startswith("pi_subagent_"):
n = n.rsplit("/", 1)[-1]
return n or None
def tool_name(data: dict[str, Any]) -> str | None:
v = first_key(data, "tool_name", "mcp_tool_name", "name", "toolName")
return normalize_tool_name(str(v) if v is not None else None)
def mcp_server(data: dict[str, Any]) -> str | None:
v = first_key(data, "mcp_server_name", "server", "mcpServer", "server_name")
return str(v) if v is not None else None
def tool_input(data: dict[str, Any]) -> dict[str, Any] | None:
v = first_key(data, "tool_input", "arguments", "input", "toolInput", "params")
if isinstance(v, dict):
return v
if isinstance(v, str):
try:
parsed = json.loads(v)
return parsed if isinstance(parsed, dict) else None
except json.JSONDecodeError:
return None
return None
def shell_command(data: dict[str, Any]) -> str:
v = first_key(data, "command", "shell_command", "cmd")
return str(v) if v is not None else ""
def emit(obj: dict[str, Any]) -> None:
sys.stdout.write(json.dumps(obj, ensure_ascii=False))
sys.stdout.flush()
def allow() -> None:
emit({"permission": "allow"})
raise SystemExit(0)
def ask(user_message: str, agent_message: str) -> None:
emit(
{
"permission": "ask",
"user_message": user_message,
"agent_message": agent_message,
}
)
raise SystemExit(0)
def additional_context(text: str) -> None:
emit({"additional_context": text})
raise SystemExit(0)
def audit(hook: str, code: str, **extra: Any) -> None:
rec = {"hook": hook, "code": code}
rec.update(extra)
try:
append_jsonl(audit_path(), rec)
except OSError:
pass