validate-hooks-json.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. #!/usr/bin/env python3
  2. # Lint a hooks.json (or the "hooks" block of a settings.json) against the
  3. # current Claude Code hook contract. Offline / structural only — no network.
  4. #
  5. # Usage: validate-hooks-json.py [--json] [--strict] [PATH]
  6. # Input: PATH to a hooks.json or settings.json (positional). Default: the
  7. # repo's hooks/hooks.json if present (resolved from cwd or git root).
  8. # Output: stdout = findings (plain text, or JSON envelope with --json) — data only
  9. # Stderr: headers, progress, per-finding human framing, summary, errors
  10. # Exit: 0 clean, 2 usage, 3 file-not-found, 4 malformed-JSON,
  11. # 10 findings present (DOMAIN SIGNAL — "ran fine, found issues")
  12. #
  13. # --strict makes warnings count toward exit 10 (default: only errors do).
  14. #
  15. # The 30-event catalog and the matcher/hook-type/output rules enforced here
  16. # are derived from the authoritative reference shipped alongside this script:
  17. # ../references/hooks-reference.md (the Event Catalog + Hook Types tables).
  18. # Keep KNOWN_EVENTS / HOOK_TYPES in sync with that file when the contract moves.
  19. #
  20. # Examples:
  21. # validate-hooks-json.py hooks/hooks.json
  22. # validate-hooks-json.py --json .claude/settings.json | jq '.data[]'
  23. # validate-hooks-json.py --strict ./hooks.json # warnings also fail
  24. import argparse
  25. import json
  26. import os
  27. import subprocess
  28. import sys
  29. SCHEMA = "claude-mods.claude-code-ops.hooks-lint/v1"
  30. EXIT_OK = 0
  31. EXIT_USAGE = 2
  32. EXIT_NOT_FOUND = 3
  33. EXIT_MALFORMED = 4
  34. EXIT_FINDINGS = 10
  35. # --- Source of truth: ../references/hooks-reference.md "Event Catalog" table. ---
  36. # The 30 hook events Claude Code recognises (June 2026 contract). An event key
  37. # outside this set is a finding — almost always a typo or a stale name.
  38. KNOWN_EVENTS = [
  39. "SessionStart", "SessionEnd", "Setup",
  40. "UserPromptSubmit", "UserPromptExpansion",
  41. "PreToolUse", "PermissionRequest", "PermissionDenied",
  42. "PostToolUse", "PostToolUseFailure", "PostToolBatch",
  43. "Stop", "StopFailure",
  44. "SubagentStart", "SubagentStop",
  45. "TaskCreated", "TaskCompleted",
  46. "TeammateIdle", "Notification", "MessageDisplay",
  47. "ConfigChange", "CwdChanged", "FileChanged",
  48. "PreCompact", "PostCompact",
  49. "InstructionsLoaded",
  50. "WorktreeCreate", "WorktreeRemove",
  51. "Elicitation", "ElicitationResult",
  52. ] # len == 30
  53. # Source of truth: ../references/hooks-reference.md "Hook Types" section.
  54. HOOK_TYPES = ["command", "http", "mcp_tool", "prompt", "agent"]
  55. # Portability-recommended placeholders for command paths.
  56. ROOTED_PLACEHOLDERS = ("${CLAUDE_PLUGIN_ROOT}", "${CLAUDE_PROJECT_DIR}",
  57. "${CLAUDE_PLUGIN_DATA}", "${CLAUDE_SKILL_DIR}")
  58. class Finding:
  59. __slots__ = ("pointer", "severity", "message")
  60. def __init__(self, pointer, severity, message):
  61. self.pointer = pointer
  62. self.severity = severity # "error" | "warning"
  63. self.message = message
  64. def as_dict(self):
  65. return {"pointer": self.pointer, "severity": self.severity,
  66. "message": self.message}
  67. def add(findings, pointer, severity, message):
  68. findings.append(Finding(pointer, severity, message))
  69. def looks_like_permission_rule(s):
  70. # Permission-rule syntax: "Tool(args)" e.g. Bash(git *), Edit(*.ts).
  71. if not isinstance(s, str) or "(" not in s or not s.endswith(")"):
  72. return False
  73. head = s.split("(", 1)[0]
  74. return bool(head) and head[0].isalpha()
  75. def check_hook_entry(findings, entry, ptr):
  76. if not isinstance(entry, dict):
  77. add(findings, ptr, "error",
  78. "hook entry must be an object, got %s" % type(entry).__name__)
  79. return
  80. htype = entry.get("type")
  81. if htype is None:
  82. add(findings, ptr, "error", "hook entry missing 'type'")
  83. elif htype not in HOOK_TYPES:
  84. add(findings, ptr, "error",
  85. "unknown hook type %r (expected one of: %s)"
  86. % (htype, ", ".join(HOOK_TYPES)))
  87. if htype == "command":
  88. cmd = entry.get("command")
  89. if not cmd or not isinstance(cmd, str):
  90. add(findings, ptr, "error",
  91. "command hook must have a non-empty string 'command'")
  92. elif not any(p in cmd for p in ROOTED_PLACEHOLDERS):
  93. add(findings, ptr, "warning",
  94. "command path is not rooted at ${CLAUDE_PLUGIN_ROOT}/"
  95. "${CLAUDE_PROJECT_DIR} — may break when cwd varies")
  96. elif htype == "http":
  97. if not entry.get("url"):
  98. add(findings, ptr, "error", "http hook must have a 'url'")
  99. elif htype == "mcp_tool":
  100. if not entry.get("server") or not entry.get("tool"):
  101. add(findings, ptr, "error",
  102. "mcp_tool hook must have 'server' and 'tool'")
  103. elif htype in ("prompt", "agent"):
  104. if not entry.get("prompt"):
  105. add(findings, ptr, "warning",
  106. "%s hook usually needs a 'prompt'" % htype)
  107. iff = entry.get("if")
  108. if iff is not None:
  109. if not isinstance(iff, str):
  110. add(findings, ptr, "error", "'if' filter must be a string")
  111. elif not looks_like_permission_rule(iff):
  112. add(findings, ptr, "warning",
  113. "'if' filter %r does not look like a permission rule "
  114. "(e.g. \"Bash(git *)\", \"Edit(*.ts)\")" % iff)
  115. def check_matcher_group(findings, group, ptr):
  116. if not isinstance(group, dict):
  117. add(findings, ptr, "error",
  118. "matcher group must be an object, got %s" % type(group).__name__)
  119. return
  120. if "matcher" in group and not isinstance(group["matcher"], str):
  121. if isinstance(group["matcher"], list):
  122. add(findings, ptr + "/matcher", "error",
  123. "'matcher' must be a STRING (use \"Edit|Write\"), not an array "
  124. "— an array is a schema error and the hook is silently dropped")
  125. else:
  126. add(findings, ptr + "/matcher", "error",
  127. "'matcher' must be a string, got %s"
  128. % type(group["matcher"]).__name__)
  129. hooks = group.get("hooks")
  130. if hooks is None:
  131. add(findings, ptr, "error", "matcher group missing 'hooks' list")
  132. elif not isinstance(hooks, list):
  133. add(findings, ptr + "/hooks", "error",
  134. "'hooks' must be a list, got %s" % type(hooks).__name__)
  135. else:
  136. for i, entry in enumerate(hooks):
  137. check_hook_entry(findings, entry, "%s/hooks/%d" % (ptr, i))
  138. def lint(doc):
  139. """Return list[Finding] for a parsed hooks.json / settings.json document."""
  140. findings = []
  141. if not isinstance(doc, dict):
  142. add(findings, "", "error",
  143. "top-level value must be an object "
  144. '({"hooks": {...}} or a bare event map)')
  145. return findings
  146. # Accept either {"hooks": {<Event>: [...]}} or a bare event map.
  147. if "hooks" in doc and isinstance(doc["hooks"], dict):
  148. events = doc["hooks"]
  149. base = "/hooks"
  150. else:
  151. # Bare event map only if keys look like events; otherwise flag shape.
  152. keys = list(doc.keys())
  153. if keys and any(k in KNOWN_EVENTS for k in keys):
  154. events = doc
  155. base = ""
  156. else:
  157. add(findings, "", "error",
  158. 'expected {"hooks": {<Event>: [...]}} or a bare event map; '
  159. "found object with keys: %s" % ", ".join(keys) or "(empty)")
  160. return findings
  161. for event, groups in events.items():
  162. eptr = "%s/%s" % (base, event)
  163. if event not in KNOWN_EVENTS:
  164. add(findings, eptr, "error",
  165. "unknown hook event %r — not in the 30-event catalog "
  166. "(see references/hooks-reference.md)" % event)
  167. # still structurally validate its groups below
  168. if not isinstance(groups, list):
  169. add(findings, eptr, "error",
  170. "event value must be a list of matcher groups, got %s"
  171. % type(groups).__name__)
  172. continue
  173. for i, group in enumerate(groups):
  174. check_matcher_group(findings, group, "%s/%d" % (eptr, i))
  175. return findings
  176. def default_path():
  177. """Repo's hooks/hooks.json: try cwd, then git toplevel."""
  178. cand = os.path.join(os.getcwd(), "hooks", "hooks.json")
  179. if os.path.isfile(cand):
  180. return cand
  181. try:
  182. top = subprocess.run(
  183. ["git", "rev-parse", "--show-toplevel"],
  184. capture_output=True, text=True, timeout=5)
  185. if top.returncode == 0:
  186. cand = os.path.join(top.stdout.strip(), "hooks", "hooks.json")
  187. if os.path.isfile(cand):
  188. return cand
  189. except (OSError, subprocess.SubprocessError):
  190. pass
  191. return None
  192. def main(argv):
  193. p = argparse.ArgumentParser(
  194. prog="validate-hooks-json.py",
  195. description="Lint a hooks.json / settings.json hooks block against "
  196. "the Claude Code hook contract (offline, structural).",
  197. epilog="EXAMPLES:\n"
  198. " validate-hooks-json.py hooks/hooks.json\n"
  199. " validate-hooks-json.py --json .claude/settings.json | jq '.data[]'\n"
  200. " validate-hooks-json.py --strict ./hooks.json\n"
  201. "\nEXIT: 0 clean, 2 usage, 3 not-found, 4 malformed-JSON, "
  202. "10 findings present.",
  203. formatter_class=argparse.RawDescriptionHelpFormatter)
  204. p.add_argument("path", nargs="?",
  205. help="hooks.json or settings.json (default: repo hooks/hooks.json)")
  206. p.add_argument("--json", action="store_true",
  207. help="emit a JSON envelope (schema %s)" % SCHEMA)
  208. p.add_argument("--strict", action="store_true",
  209. help="count warnings toward the exit-10 signal")
  210. try:
  211. args = p.parse_args(argv)
  212. except SystemExit as e:
  213. # argparse exits 0 for --help (good), 2 for bad args (matches USAGE).
  214. return e.code if e.code is not None else EXIT_USAGE
  215. path = args.path or default_path()
  216. if not path:
  217. msg = ("no path given and no repo hooks/hooks.json found "
  218. "(pass a path explicitly)")
  219. if args.json:
  220. print(json.dumps({"error": {"code": "NOT_FOUND", "message": msg}}))
  221. print("ERROR: %s" % msg, file=sys.stderr)
  222. return EXIT_NOT_FOUND
  223. if not os.path.isfile(path):
  224. msg = "file not found: %s" % path
  225. if args.json:
  226. print(json.dumps({"error": {"code": "NOT_FOUND", "message": msg}}))
  227. print("ERROR: %s" % msg, file=sys.stderr)
  228. return EXIT_NOT_FOUND
  229. try:
  230. with open(path, "r", encoding="utf-8") as fh:
  231. doc = json.load(fh)
  232. except (json.JSONDecodeError, UnicodeDecodeError) as e:
  233. msg = "malformed JSON in %s: %s" % (path, e)
  234. if args.json:
  235. print(json.dumps({"error": {"code": "VALIDATION", "message": msg}}))
  236. print("ERROR: %s" % msg, file=sys.stderr)
  237. return EXIT_MALFORMED
  238. print("=== hooks-lint: %s ===" % path, file=sys.stderr)
  239. findings = lint(doc)
  240. errors = [f for f in findings if f.severity == "error"]
  241. warnings = [f for f in findings if f.severity == "warning"]
  242. if args.json:
  243. print(json.dumps({
  244. "data": [f.as_dict() for f in findings],
  245. "meta": {"count": len(findings),
  246. "errors": len(errors), "warnings": len(warnings),
  247. "path": path, "schema": SCHEMA},
  248. }, indent=2))
  249. else:
  250. for f in findings:
  251. print("%s\t%s\t%s" % (f.severity, f.pointer or "/", f.message))
  252. # Human framing → stderr.
  253. for f in findings:
  254. tag = "ERROR" if f.severity == "error" else "warn "
  255. print(" [%s] %s: %s" % (tag, f.pointer or "/", f.message),
  256. file=sys.stderr)
  257. if not findings:
  258. print(" clean — no findings", file=sys.stderr)
  259. print("--- %d error(s), %d warning(s) ---" % (len(errors), len(warnings)),
  260. file=sys.stderr)
  261. if errors or (args.strict and warnings):
  262. return EXIT_FINDINGS
  263. return EXIT_OK
  264. if __name__ == "__main__":
  265. sys.exit(main(sys.argv[1:]))