#!/usr/bin/env python3 """rules-to-guards hook: blocks a tool call that breaks one of the rules embedded below. Made by rules-to-guards (https://github.com/RyanAlberts/best-of-Agent-Harnesses). It reads one pre-tool hook event as JSON on stdin (Claude Code and Codex PreToolUse, Gemini CLI BeforeTool, Cursor preToolUse) and blocks the call when it breaks a rule. Python 3.9+, standard library only. Any error inside this script lets the call through, so a bug here never stops your work. """ from __future__ import annotations import json import os import re import shlex import sys RULES = [] # rules-to-guards: `rules.py generate` writes your rules here MAX_CHARS = 200000 # longer commands are checked on their first 200,000 characters MAX_DEPTH = 4 # nesting limit for $(...), sh -c '...', and eval MAX_COMMANDS = 5000 # simple commands checked per tool call # --------------------------------------------------------------------------- # Shell commands: split into simple commands, then unwrap each one # --------------------------------------------------------------------------- # The delimiter word runs up to an unquoted blank or shell metacharacter. _HEREDOC_RE = re.compile(r"<<(-?)[ \t]*((?:'[^'\n]*'|\"[^\"\n]*\"|\\[^\n]|[^ \t\n|&;()<>'\"\\])+)") _UNQUOTE_RE = re.compile(r"'([^']*)'|\"([^\"]*)\"|\\(.)") def split_commands(script): """The simple commands in a shell script, as written: split on unquoted ;, &, |, newlines, and parentheses, with $(...) and backtick bodies added as their own commands, also inside a heredoc whose delimiter is not quoted, and the whole body of a heredoc fed to a shell such as bash. Other heredoc text and comments are skipped.""" out = [] _scan(script if isinstance(script, str) else "", out, 0, None) return out def _scan(s, out, depth, blank, marks=False): """With marks, `out` also gets ("(",) and (")",) where a subshell opens and closes, ("|",) for each pipe, and ("$", body) in place of the commands of each $(...) and backtick body, so the caller can follow cd in each scope.""" s = s[:MAX_CHARS] n, i, buf, word_start, heredocs = len(s), 0, [], True, [] def keep(text, shown=None): buf.append(text) if blank is not None: blank.append(text if shown is None else shown) def flush(): text = "".join(buf).strip() for doc in heredocs: # the command a heredoc belongs to is now known if doc[3] is None: doc[3] = _feeds_shell(text) if text: out.append(text) del buf[:] def nested(text): if marks: out.append(("$", text)) elif depth < MAX_DEPTH: _scan(text, out, depth + 1, None) while i < n: c = s[i] if c == "\\": if s.startswith("\\\n", i): # line continuation i += 2 continue keep(s[i:i + 2]) i, word_start = i + 2, False elif c == "'": j = s.find("'", i + 1) if j < 0: # a quote that never closes is a literal character keep(c) i, word_start = i + 1, False continue keep(s[i:j + 1], "''") i, word_start = j + 1, False elif c == '"': inner, j = _expansions(s, i + 1, '"') if j >= n: # a quote that never closes is a literal character keep(c) i, word_start = i + 1, False continue for text in inner: nested(text) keep(s[i:j + 1], '""') i, word_start = j + 1, False elif s.startswith("$(", i): inner, j = _balanced(s, i + 2) nested(inner) keep(s[i:j]) i, word_start = j, False elif c == "`" and s.find("`", i + 1) > 0: k = s.find("`", i + 1) nested(s[i + 1:k]) keep(s[i:k + 1]) i, word_start = k + 1, False elif c == "#" and word_start: k = s.find("\n", i) i = n if k < 0 else k elif s.startswith("<<", i) and not s.startswith("<<<", i) and _HEREDOC_RE.match(s, i): m = _HEREDOC_RE.match(s, i) heredocs.append(_heredoc(m)) keep(m.group(0)) i, word_start = m.end(), False elif c == "\n": flush() if blank is not None: blank.append("\n") i, word_start = i + 1, True if heredocs: i, heredocs = _skip_heredocs(s, i, heredocs, nested), [] elif (c == "&" and (s.startswith("&>", i) or (buf and buf[-1].endswith(">")))) or \ (c == "|" and buf and buf[-1].endswith(">")): keep(c) # part of a redirection: &>, >&, >| i += 1 elif c in ";&|()": flush() if blank is not None: blank.append(c) if marks and (c in "()" or (c == "|" and "|" not in (s[i - 1:i], s[i + 1:i + 2]))): out.append((c,)) # a subshell opens or closes, or a pipe (not ||) joins two i, word_start = i + 1, True else: keep(c) i, word_start = i + 1, c in " \t" flush() def _balanced(s, start): """(text inside $( ... ), index after the closing parenthesis). Heredoc bodies are skipped and a quote that never closes is a literal character, so an apostrophe in a commit message cannot swallow the rest of the command.""" depth, j, n, heredocs = 1, start, len(s), [] while j < n: c = s[j] if c == "\\": j += 2 continue if c in "'\"": k = _quote_end(s, j) j = j + 1 if k < 0 else k + 1 continue if s.startswith("<<", j) and not s.startswith("<<<", j) and _HEREDOC_RE.match(s, j): m = _HEREDOC_RE.match(s, j) heredocs.append(_heredoc(m)) j = m.end() continue if c == "\n" and heredocs: j, heredocs = _skip_heredocs(s, j + 1, heredocs), [] continue if c == "(": depth += 1 elif c == ")": depth -= 1 if depth == 0: return s[start:j], j + 1 j += 1 return s[start:], n def _quote_end(s, j): """Index of the quote that closes the one at s[j], or -1 when it never closes.""" if s[j] == "'": return s.find("'", j + 1) k = j + 1 while k < len(s): if s[k] == "\\": k += 2 elif s[k] == '"': return k else: k += 1 return -1 def _expansions(s, j=0, stop=None): """(the $(...) and backtick bodies in text that starts at s[j], index of the `stop` character that ends it or len(s)). The text is double-quoted, or a heredoc body whose delimiter is not quoted: the shell runs these bodies.""" inner, n = [], len(s) while j < n and s[j] != stop: if s[j] == "\\": j += 2 elif s.startswith("$(", j): text, j = _balanced(s, j + 2) inner.append(text) elif s[j] == "`" and s.find("`", j + 1) > 0: k = s.find("`", j + 1) inner.append(s[j + 1:k]) j = k + 1 else: j += 1 return inner, j def _heredoc(m): """[delimiter, expands, strips tabs, fed to a shell] for a heredoc operator. The delimiter is the word with its quotes and backslashes removed, as bash does. The body expands $(...) and backticks only when no part of the word is quoted: <", ">>", "<", "&>", "&>>", ">|", "1>", "1>>", "2>", "2>>"} _REDIRECT_RE = re.compile(r"^(?:\d*|&)(>>?|<|>\|)(.+)$") def _tokens(segment): lex = shlex.shlex(segment, posix=True) lex.whitespace_split = True lex.commenters = "" try: return list(lex) except ValueError: # unbalanced quotes return segment.split() def _base(word): return word.rsplit("/", 1)[-1] if "/" in word else word def _pull_redirections(tokens): """(tokens without redirections, input targets, output targets).""" kept, inputs, outputs, i = [], [], [], 0 while i < len(tokens): t = tokens[i] if t in _REDIRECT_OPS: if i + 1 < len(tokens): (inputs if t == "<" else outputs).append(tokens[i + 1]) i += 2 continue m = _REDIRECT_RE.match(t) if m and not t[:1].isalpha(): if not m.group(2).startswith("&"): (inputs if m.group(1) == "<" else outputs).append(m.group(2)) i += 1 continue kept.append(t) i += 1 return kept, inputs, outputs def _unwrap(tokens): """Drop leading variable assignments, shell keywords, and wrapper commands such as sudo, env, timeout, and xargs, with their options.""" i, n = 0, len(tokens) while i < n: t = tokens[i] if t in _KEYWORDS or _ASSIGN_RE.match(t): i += 1 continue spec = _WRAPPERS.get(_base(t)) if spec is None: break base, i = _base(t), i + 1 while i < n and tokens[i].startswith("-") and tokens[i] != "-": opt, i = tokens[i], i + 1 if opt == "--": break if base == "command" and not opt.startswith("--") and ("v" in opt or "V" in opt): return [] # command -v npm looks npm up; it runs nothing if opt in spec: i += 1 if base == "env": while i < n and _ASSIGN_RE.match(tokens[i]): i += 1 elif base == "timeout" and i < n and _DURATION_RE.match(tokens[i]): i += 1 return tokens[i:] def _feeds_shell(text): """True when the simple command `text` is a shell, such as bash or sudo sh: a heredoc on it is a script the shell runs, quoted or not.""" body = _unwrap(_pull_redirections(_tokens(_HEREDOC_RE.sub(" ", text)))[0]) return bool(body) and _base(body[0]) in _SHELLS def _shell_script(args): """The script of `sh -c '