IDE postToolUse reports tool_name as MCP:pi_subagent_*; without stripping, guard-pi-dispatch skipped shape checks. Confirmed via live pi_subagent_roles.
391 lines
14 KiB
Python
391 lines
14 KiB
Python
"""Unit tests for project Cursor hooks (advisory gates) and hooks.json structure."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
SOURCE = Path(__file__).resolve().parents[1]
|
|
REPO = SOURCE.parent
|
|
HOOKS = REPO / ".cursor" / "hooks"
|
|
HOOKS_JSON = REPO / ".cursor" / "hooks.json"
|
|
RULES = REPO / ".cursor" / "rules"
|
|
|
|
|
|
def _run_hook(script: Path, payload: dict | str, *, env: dict | None = None, cwd: Path | None = None) -> tuple[int, dict | None, str]:
|
|
data = payload if isinstance(payload, str) else json.dumps(payload)
|
|
e = os.environ.copy()
|
|
e["CURSOR_HOOKS_NO_INVENTORY"] = "1"
|
|
if env:
|
|
e.update(env)
|
|
# Isolate audit/probe under temp repo root via CURSOR_PROJECT_DIR
|
|
r = subprocess.run(
|
|
[str(script)],
|
|
input=data,
|
|
text=True,
|
|
capture_output=True,
|
|
cwd=str(cwd or REPO),
|
|
env=e,
|
|
check=False,
|
|
)
|
|
out = (r.stdout or "").strip()
|
|
parsed = None
|
|
if out:
|
|
try:
|
|
parsed = json.loads(out)
|
|
except json.JSONDecodeError:
|
|
parsed = None
|
|
return r.returncode, parsed, r.stderr or ""
|
|
|
|
|
|
class CursorHooksStructureTest(unittest.TestCase):
|
|
def test_hooks_json_parseable_and_scripts_executable(self):
|
|
cfg = json.loads(HOOKS_JSON.read_text(encoding="utf-8"))
|
|
self.assertEqual(cfg.get("version"), 1)
|
|
hooks = cfg.get("hooks") or {}
|
|
self.assertTrue(hooks)
|
|
for event, entries in hooks.items():
|
|
self.assertIsInstance(entries, list, event)
|
|
for entry in entries:
|
|
cmd = entry.get("command")
|
|
self.assertTrue(cmd, f"{event} missing command")
|
|
path = REPO / cmd
|
|
self.assertTrue(path.is_file(), f"missing {path}")
|
|
mode = path.stat().st_mode
|
|
self.assertTrue(mode & stat.S_IXUSR, f"not executable: {path}")
|
|
matcher = entry.get("matcher")
|
|
if matcher:
|
|
# JavaScript RegExp must compile in Node
|
|
r = subprocess.run(
|
|
["node", "-e", f"new RegExp({json.dumps(matcher)})"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
self.assertEqual(r.returncode, 0, f"bad matcher {matcher!r}: {r.stderr}")
|
|
|
|
def test_rules_no_drift_markers(self):
|
|
text = (RULES / "test-reports-standard.mdc").read_text(encoding="utf-8")
|
|
self.assertNotIn("MiniMax-M2.7-highspeed", text)
|
|
self.assertNotIn("PROJECT.md) §8", text)
|
|
self.assertNotIn("PROJECT.md](../../PROJECT.md) §8", text)
|
|
# No unconditional full go test requirement as acceptance
|
|
self.assertNotIn("go test -v ./...", text)
|
|
self.assertIn("验证契约", text)
|
|
|
|
def test_mcp_relative_paths_for_agentctl_jev(self):
|
|
mcp = json.loads((REPO / ".cursor" / "mcp.json").read_text(encoding="utf-8"))
|
|
for name in ("agentctl", "jev", "pi-subagents"):
|
|
args = mcp["mcpServers"][name]["args"]
|
|
self.assertTrue(any(a.startswith("scripts/") for a in args), name)
|
|
self.assertFalse(any("${workspaceFolder}" in a for a in args), name)
|
|
|
|
|
|
class GuardGitTest(unittest.TestCase):
|
|
SCRIPT = HOOKS / "guard-git.sh"
|
|
|
|
def _perm(self, command: str) -> str:
|
|
code, parsed, err = _run_hook(self.SCRIPT, {"command": command})
|
|
self.assertEqual(code, 0, err)
|
|
self.assertIsInstance(parsed, dict)
|
|
return parsed.get("permission")
|
|
|
|
def test_allow_status(self):
|
|
self.assertEqual(self._perm("git status"), "allow")
|
|
|
|
def test_ask_push(self):
|
|
self.assertEqual(self._perm("git push origin dev"), "ask")
|
|
|
|
def test_ask_force(self):
|
|
self.assertEqual(self._perm("git push --force origin main"), "ask")
|
|
|
|
def test_ask_reset_hard(self):
|
|
self.assertEqual(self._perm("git reset --hard HEAD~1"), "ask")
|
|
|
|
def test_ask_clean_f(self):
|
|
self.assertEqual(self._perm("git clean -fd"), "ask")
|
|
|
|
def test_ask_branch_D(self):
|
|
self.assertEqual(self._perm("git branch -D feature/x"), "ask")
|
|
|
|
|
|
class GuardPiDispatchTest(unittest.TestCase):
|
|
SCRIPT = HOOKS / "guard-pi-dispatch.sh"
|
|
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.root = Path(self.tmp.name)
|
|
(self.root / ".cursor").mkdir()
|
|
self.env = {"CURSOR_PROJECT_DIR": str(self.root), "CURSOR_HOOKS_NO_INVENTORY": "1"}
|
|
|
|
def tearDown(self):
|
|
self.tmp.cleanup()
|
|
|
|
def _run(self, payload: dict) -> dict:
|
|
code, parsed, err = _run_hook(self.SCRIPT, payload, env=self.env, cwd=self.root)
|
|
self.assertEqual(code, 0, err)
|
|
self.assertIsInstance(parsed, dict)
|
|
return parsed
|
|
|
|
def _valid_run(self, **overrides) -> dict:
|
|
req = {
|
|
"role_id": "research",
|
|
"request_id": "research-001",
|
|
"goal": "read PROJECT.md",
|
|
"authorization_ref": "task auth",
|
|
"source_sha": "cfc80b34a40e8de78e77a44839571bb684d405d5",
|
|
"input_files": ["PROJECT.md"],
|
|
"timeout_seconds": 120,
|
|
}
|
|
req.update(overrides)
|
|
return {
|
|
"mcp_server_name": "pi-subagents",
|
|
"tool_name": "pi_subagent_run",
|
|
"tool_input": {"worktree": "/tmp/wt", "task_id": "t1", "request": req},
|
|
}
|
|
|
|
def test_allow_valid_run(self):
|
|
self.assertEqual(self._run(self._valid_run()).get("permission"), "allow")
|
|
|
|
def test_allow_roles(self):
|
|
p = {
|
|
"mcp_server_name": "pi-subagents",
|
|
"tool_name": "pi_subagent_roles",
|
|
"tool_input": {"worktree": "/tmp/wt", "task_id": "t1"},
|
|
}
|
|
self.assertEqual(self._run(p).get("permission"), "allow")
|
|
|
|
def test_allow_result(self):
|
|
p = {
|
|
"tool_name": "pi_subagent_result",
|
|
"arguments": {"worktree": "/tmp/wt", "task_id": "t1", "request_id": "r1"},
|
|
}
|
|
self.assertEqual(self._run(p).get("permission"), "allow")
|
|
|
|
def test_ask_source_sha_head(self):
|
|
self.assertEqual(self._run(self._valid_run(source_sha="HEAD")).get("permission"), "ask")
|
|
|
|
def test_ask_source_sha_short(self):
|
|
self.assertEqual(self._run(self._valid_run(source_sha="cfc80b34")).get("permission"), "ask")
|
|
|
|
def test_ask_test_missing_commands(self):
|
|
p = self._valid_run(role_id="test_run")
|
|
self.assertEqual(self._run(p).get("permission"), "ask")
|
|
|
|
def test_ask_test_bad_digest(self):
|
|
p = self._valid_run(
|
|
role_id="acceptance_check",
|
|
approved_commands=[["true"]],
|
|
plan_digest="not-a-digest",
|
|
)
|
|
self.assertEqual(self._run(p).get("permission"), "ask")
|
|
|
|
def test_allow_test_with_commands(self):
|
|
digest = "a" * 64
|
|
p = self._valid_run(
|
|
role_id="test_run",
|
|
approved_commands=[["python3", "-c", "print(1)"]],
|
|
plan_digest=digest,
|
|
)
|
|
self.assertEqual(self._run(p).get("permission"), "allow")
|
|
|
|
def test_ask_model_override(self):
|
|
p = self._valid_run(model_override={"provider": "x", "model": "y"})
|
|
self.assertEqual(self._run(p).get("permission"), "ask")
|
|
|
|
def test_fail_open_empty_json(self):
|
|
code, parsed, err = _run_hook(self.SCRIPT, "", env=self.env, cwd=self.root)
|
|
self.assertEqual(code, 0, err)
|
|
self.assertEqual(parsed.get("permission"), "allow")
|
|
audit = (self.root / ".cursor" / "hook-audit.jsonl").read_text(encoding="utf-8")
|
|
self.assertIn("UNRECOGNIZED_INPUT", audit)
|
|
|
|
def test_alt_field_names_arguments(self):
|
|
# tool_input via "arguments"
|
|
payload = {
|
|
"name": "pi_subagent_run",
|
|
"arguments": {
|
|
"request": {
|
|
"role_id": "planning",
|
|
"request_id": "p-1",
|
|
"goal": "g",
|
|
"authorization_ref": "a",
|
|
"source_sha": "cfc80b34a40e8de78e77a44839571bb684d405d5",
|
|
"input_files": [],
|
|
"timeout_seconds": 30,
|
|
}
|
|
},
|
|
}
|
|
self.assertEqual(self._run(payload).get("permission"), "allow")
|
|
|
|
def test_ask_mcp_prefixed_tool_name_bad_sha(self):
|
|
# IDE postToolUse/beforeMCP observed tool_name="MCP:pi_subagent_*"
|
|
payload = {
|
|
"mcp_server_name": "pi-subagents",
|
|
"tool_name": "MCP:pi_subagent_run",
|
|
"tool_input": {
|
|
"worktree": "/tmp/wt",
|
|
"task_id": "t1",
|
|
"request": {
|
|
"role_id": "research",
|
|
"request_id": "research-001",
|
|
"goal": "g",
|
|
"authorization_ref": "a",
|
|
"source_sha": "HEAD",
|
|
"input_files": [],
|
|
"timeout_seconds": 30,
|
|
},
|
|
},
|
|
}
|
|
self.assertEqual(self._run(payload).get("permission"), "ask")
|
|
|
|
def test_allow_mcp_prefixed_roles(self):
|
|
payload = {
|
|
"tool_name": "MCP:pi_subagent_roles",
|
|
"tool_input": {"worktree": "/tmp/wt", "task_id": "t1"},
|
|
}
|
|
self.assertEqual(self._run(payload).get("permission"), "allow")
|
|
|
|
|
|
class GuardPiShellTest(unittest.TestCase):
|
|
SCRIPT = HOOKS / "guard-pi-shell.sh"
|
|
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.root = Path(self.tmp.name)
|
|
(self.root / ".cursor").mkdir()
|
|
self.env = {"CURSOR_PROJECT_DIR": str(self.root)}
|
|
|
|
def tearDown(self):
|
|
self.tmp.cleanup()
|
|
|
|
def _perm(self, command: str) -> str:
|
|
code, parsed, err = _run_hook(
|
|
self.SCRIPT, {"command": command}, env=self.env, cwd=self.root
|
|
)
|
|
self.assertEqual(code, 0, err)
|
|
return parsed.get("permission")
|
|
|
|
def test_allow_roles(self):
|
|
self.assertEqual(self._perm("python3 -B scripts/pi_subagents.py roles"), "allow")
|
|
|
|
def test_ask_bare_run(self):
|
|
self.assertEqual(
|
|
self._perm("python3 -B scripts/pi_subagents.py run --request /tmp/r.json"),
|
|
"ask",
|
|
)
|
|
|
|
def test_allow_leased_run(self):
|
|
cmd = (
|
|
"python3 scripts/agentctl.py with --task t1 "
|
|
"--resource path:.doc/test-reports/assets/pi-subagents/t1/r1 -- "
|
|
"python3 -B scripts/pi_subagents.py run --request /tmp/r.json"
|
|
)
|
|
self.assertEqual(self._perm(cmd), "allow")
|
|
|
|
def test_ask_pkill_f(self):
|
|
self.assertEqual(self._perm("pkill -f pi_subagents"), "ask")
|
|
|
|
|
|
class PiReceiptContextTest(unittest.TestCase):
|
|
SCRIPT = HOOKS / "pi-receipt-context.sh"
|
|
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.root = Path(self.tmp.name)
|
|
(self.root / ".cursor").mkdir()
|
|
self.env = {"CURSOR_PROJECT_DIR": str(self.root)}
|
|
|
|
def tearDown(self):
|
|
self.tmp.cleanup()
|
|
|
|
def test_injects_for_pi_tool(self):
|
|
code, parsed, err = _run_hook(
|
|
self.SCRIPT,
|
|
{"tool_name": "pi_subagent_result", "mcp_server_name": "pi-subagents"},
|
|
env=self.env,
|
|
cwd=self.root,
|
|
)
|
|
self.assertEqual(code, 0, err)
|
|
self.assertIn("additional_context", parsed)
|
|
self.assertIn("run_status", parsed["additional_context"])
|
|
|
|
def test_injects_for_mcp_prefixed_tool_name(self):
|
|
code, parsed, err = _run_hook(
|
|
self.SCRIPT,
|
|
{"tool_name": "MCP:pi_subagent_roles", "mcp_server_name": "pi-subagents"},
|
|
env=self.env,
|
|
cwd=self.root,
|
|
)
|
|
self.assertEqual(code, 0, err)
|
|
self.assertIn("additional_context", parsed)
|
|
|
|
def test_noop_for_unrelated(self):
|
|
code, parsed, err = _run_hook(
|
|
self.SCRIPT,
|
|
{"tool_name": "Read", "tool_input": {"path": "x"}},
|
|
env=self.env,
|
|
cwd=self.root,
|
|
)
|
|
self.assertEqual(code, 0, err)
|
|
self.assertEqual(parsed, {})
|
|
|
|
|
|
class AuditSessionTest(unittest.TestCase):
|
|
SCRIPT = HOOKS / "audit-session.sh"
|
|
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.root = Path(self.tmp.name)
|
|
(self.root / ".cursor").mkdir()
|
|
# Minimal fake repo so inventory finds no agentctl and skips cleanly
|
|
self.env = {
|
|
"CURSOR_PROJECT_DIR": str(self.root),
|
|
"CURSOR_HOOKS_NO_INVENTORY": "1",
|
|
}
|
|
|
|
def tearDown(self):
|
|
self.tmp.cleanup()
|
|
|
|
def test_emits_empty_object(self):
|
|
code, parsed, err = _run_hook(
|
|
self.SCRIPT,
|
|
{"hook_event_name": "sessionStart"},
|
|
env=self.env,
|
|
cwd=self.root,
|
|
)
|
|
self.assertEqual(code, 0, err)
|
|
self.assertEqual(parsed, {})
|
|
audit = (self.root / ".cursor" / "hook-audit.jsonl").read_text(encoding="utf-8")
|
|
self.assertIn("SESSION_AUDIT", audit)
|
|
|
|
def test_probe_writes_when_enabled(self):
|
|
env = dict(self.env)
|
|
env["CURSOR_HOOKS_PROBE"] = "1"
|
|
code, _, err = _run_hook(
|
|
self.SCRIPT, {"event": "stop"}, env=env, cwd=self.root
|
|
)
|
|
self.assertEqual(code, 0, err)
|
|
probe = self.root / ".cursor" / "hook-probe.jsonl"
|
|
self.assertTrue(probe.is_file())
|
|
self.assertIn("audit-session", probe.read_text(encoding="utf-8"))
|
|
|
|
|
|
class SkillsPresentTest(unittest.TestCase):
|
|
def test_skills_exist(self):
|
|
for name in ("pi-subagent-dispatch", "subagent-stage-record"):
|
|
p = REPO / ".cursor" / "skills" / name / "SKILL.md"
|
|
self.assertTrue(p.is_file(), p)
|
|
text = p.read_text(encoding="utf-8")
|
|
self.assertIn("name:", text)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|