IDE postToolUse reports tool_name as MCP:pi_subagent_*; without stripping, guard-pi-dispatch skipped shape checks. Confirmed via live pi_subagent_roles.
167 lines
4.4 KiB
Python
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
|