Coverage for plugin/scripts/hook_bash_guard.py: 100%
131 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 14:46 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 14:46 +0000
1#!/usr/bin/env python3
2"""Guard the Bash calls this plugin's commands make, at the moment they run.
4The prose gates (``check_command_contracts.py``, ``check_prompt_wiring.py``) verify the
5commands *before* they ship: that a referenced script exists, that a ``--flag`` is real,
6that a ``/rhiza:<name>`` resolves. What they cannot verify is that a **correct command
7gets executed correctly**. Two rules in the prose are exactly the kind a model drops
8under pressure, and both fail quietly:
101. **Bare ``make <target>``.** ``skills/quality/SKILL.md`` asks for one bare ``make`` per
11 gate, because that matches the allow-listed ``Bash(make *)`` rule and runs without a
12 permission prompt. Piping to ``tail`` is the reflex when output is long, and the cost
13 lands on the *user* as a prompt per gate — eight of them in one ``/quality`` run.
142. **Never push to the default branch, never force-push.** Promised separately by
15 ``/init``, ``/update`` and ``/release``. Four prose promises, no invariant.
17This module is the ``PreToolUse`` hook that makes both structural. It reads the hook
18payload on stdin and writes a ``hookSpecificOutput`` decision on stdout.
20**It fails open, deliberately.** Unparseable input, an unrecognised payload, a missing
21``git``, an unreadable repo — every one of those returns *no decision*, so the normal
22permission flow applies. A guard that blocks when it is confused is worse than no guard:
23the user cannot argue with it, and the failure mode is a plugin that bricks a session.
24The only paths that decide are the two narrow ones above.
26The three decisions it can reach:
28- ``deny`` — ``make`` combined with a pipe/redirect/chain. The model reads the reason
29 and re-runs bare, so no human is involved. Denying is *less* intrusive than asking
30 here: asking would surface the very prompt the rule exists to avoid.
31- ``deny`` — ``git push --force`` / ``git tag -f``. Irreversible, and no rhiza command
32 has a legitimate use for either.
33- ``ask`` — a push whose target resolves to the default branch. Not denied, because a
34 session with this plugin installed may be doing unrelated work in an unrelated repo,
35 and this hook has no way to tell. Escalating puts the human in the loop without
36 making the call for them.
38Usage (not normally invoked by hand):
39 echo '<hook payload>' | python3 scripts/hook_bash_guard.py
41Always exits 0. Blocking is expressed as JSON on stdout, never as an exit code, so a
42crash can never be mistaken for a decision.
43"""
45from __future__ import annotations
47import json
48import re
49import shutil
50import subprocess # nosec B404
51import sys
52from typing import NamedTuple
54# Quoted spans are removed before any analysis, so `git commit -m "make it | better"`
55# is not read as a piped `make`. Replaced with a space rather than deleted, so the
56# tokens either side stay separate.
57_QUOTED = re.compile(r"'[^']*'|\"[^\"]*\"")
59# A heredoc body is *data*, not shell, and unlike a quoted span nothing about it is
60# quoted — so `git commit -F - <<EOF` carrying a message that happens to say
61# "…; make deptry is deprecated" would otherwise be read as a chained `make`. Blanked
62# for the same reason quoted spans are. The delimiter may itself be quoted (`<<'EOF'`)
63# and `<<-` allows an indented terminator; both are matched here rather than left to
64# `strip_quoted`, which would blank only the delimiter and leave the body behind.
65_HEREDOC = re.compile(r"<<-?\s*(['\"]?)(\w+)\1.*?^\s*\2\s*$", re.DOTALL | re.MULTILINE)
67# `make` as a *command word*: at the start of any line, or straight after an operator
68# that begins a new command. Prevents `git commit -m make-it-better` from matching.
69# `re.MULTILINE` is what lets `^` see the second line of a multi-line command; it is only
70# safe *because* heredoc bodies are blanked first, or a body line beginning with the word
71# `make` would start matching.
72_MAKE_WORD = re.compile(r"(?:^|[|&;(])\s*(?:sudo\s+)?make\b", re.MULTILINE)
74# Any shell metacharacter that turns one command into a compound one. `<` and `>` cover
75# redirects including `2>&1`; `|` covers both pipe and `||`; `&` covers `&&` and
76# backgrounding. A single class is used rather than an alternation of exact operators
77# because the question is only ever "is this bare?", never "which operator is it?".
78_COMPOUND = re.compile(r"[|;<>&]")
80# Operators that separate one command from the next, for splitting a line into segments.
81# A newline is one of them: a `git push` on the second line of a multi-line command is a
82# command like any other.
83_SEPARATOR = re.compile(r"\|\||&&|[|;&\n]")
85# `git` global options that consume the following token, so a subcommand scan can skip
86# past `git -C /path push` without mistaking `/path` for the subcommand.
87_GIT_OPTS_WITH_VALUE = frozenset({"-C", "-c", "--git-dir", "--work-tree", "--namespace"})
89# `git push` options that consume the following token, so the positional scan does not
90# mistake an option's value for a remote or refspec.
91_PUSH_OPTS_WITH_VALUE = frozenset({"-o", "--push-option", "--repo", "--receive-pack", "--exec"})
93_FORCE_FLAGS = frozenset({"-f", "--force"})
95# Fallback default-branch names, used only when git cannot tell us. `--force-with-lease`
96# is deliberately absent from _FORCE_FLAGS: it is the safe form, and blocking it would
97# push people toward the unsafe one.
98_LIKELY_DEFAULTS = frozenset({"main", "master"})
100_MAKE_REASON = (
101 "Run `make` bare — one target per Bash call. This command combines `make` with a "
102 "pipe, redirect, or chain, which no longer matches the allow-listed `Bash(make *)` "
103 "rule and so prompts the user on every gate. Re-run it as `make <target>` alone and "
104 "read the output directly from the tool result."
105)
107_FORCE_REASON = (
108 "Force-pushing and force-tagging are irreversible, and no rhiza command needs "
109 "either — /release stops before pushing and prints the commands instead. If this is "
110 "genuinely intended, run it outside the agent."
111)
114class Decision(NamedTuple):
115 """A permission decision to hand back to Claude Code."""
117 permission: str
118 """One of ``deny`` or ``ask``. ``allow`` is never returned — this guard only ever
119 restricts, and letting it *grant* permission would widen the session's surface."""
121 reason: str
122 """Shown to the model (and the user on ``ask``). Says what to do instead."""
125def strip_quoted(command: str) -> str:
126 """Blank out single- and double-quoted spans so their contents can't match."""
127 return _QUOTED.sub(" ", command)
130def strip_heredocs(command: str) -> str:
131 """Blank out heredoc bodies so prose inside them can't be read as shell syntax."""
132 # `<< ` is left behind so a genuinely compound command *around* the heredoc — a
133 # redirect into `make`, say — is still seen as compound.
134 return _HEREDOC.sub("<< ", command)
137def strip_data(command: str) -> str:
138 """Blank every span that is data rather than shell: heredoc bodies, then quotes."""
139 # Heredocs first: `strip_quoted` would otherwise blank a quoted `<<'EOF'` delimiter
140 # and leave the body, which is exactly the text that must not be analysed.
141 return strip_quoted(strip_heredocs(command))
144def compound_make(command: str) -> Decision | None:
145 """Deny a ``make`` invocation that is piped, redirected, chained, or backgrounded."""
146 bare = strip_data(command)
147 if not _MAKE_WORD.search(bare):
148 return None
149 if not _COMPOUND.search(bare):
150 return None
151 return Decision("deny", _MAKE_REASON)
154def segments(command: str) -> list[list[str]]:
155 """Split a command line into operator-delimited lists of tokens."""
156 parts = _SEPARATOR.split(strip_data(command))
157 return [tokens for tokens in (part.split() for part in parts) if tokens]
160def git_subcommand(tokens: list[str]) -> tuple[str, list[str]] | None:
161 """Return the git subcommand and its arguments, or ``None`` if this isn't git."""
162 index = 1 if tokens[:1] == ["sudo"] else 0
163 if tokens[index : index + 1] != ["git"]:
164 return None
165 index += 1
166 while index < len(tokens) and tokens[index].startswith("-"):
167 index += 2 if tokens[index] in _GIT_OPTS_WITH_VALUE else 1
168 if index >= len(tokens):
169 return None
170 return tokens[index], tokens[index + 1 :]
173def push_positionals(args: list[str]) -> list[str]:
174 """Strip flags (and their values) from ``git push`` arguments, leaving positionals."""
175 positionals: list[str] = []
176 index = 0
177 while index < len(args):
178 token = args[index]
179 if token.startswith("-"):
180 index += 2 if token in _PUSH_OPTS_WITH_VALUE else 1
181 continue
182 positionals.append(token)
183 index += 1
184 return positionals
187def _git(cwd: str, *args: str) -> str | None:
188 """Run a read-only git command in ``cwd``; ``None`` on any failure at all."""
189 # The resolved absolute path, not the bare name: it is needed anyway to know git
190 # exists, and passing it avoids resolving the command against PATH a second time.
191 git = shutil.which("git")
192 if git is None:
193 return None
194 try:
195 result = subprocess.run( # nosec B603
196 [git, "-C", cwd, *args],
197 capture_output=True,
198 text=True,
199 timeout=5,
200 check=False,
201 )
202 except (OSError, subprocess.SubprocessError):
203 return None
204 if result.returncode != 0:
205 return None
206 return result.stdout.strip() or None
209def default_branch(cwd: str) -> str | None:
210 """The remote's default branch, via the local ref — no network, no fetch."""
211 head = _git(cwd, "symbolic-ref", "--short", "refs/remotes/origin/HEAD")
212 if head is None:
213 return None
214 return head.split("/", 1)[1] if "/" in head else head
217def current_branch(cwd: str) -> str | None:
218 """The checked-out branch, or ``None`` when detached or unreadable."""
219 branch = _git(cwd, "rev-parse", "--abbrev-ref", "HEAD")
220 return None if branch == "HEAD" else branch
223def push_targets(args: list[str], cwd: str) -> list[str]:
224 """The branch names a ``git push`` would write to, as best they can be resolved."""
225 positionals = push_positionals(args)
226 refspecs = positionals[1:]
227 if not refspecs:
228 branch = current_branch(cwd)
229 return [branch] if branch else []
230 targets = []
231 for refspec in refspecs:
232 # `src:dst` writes to dst; a bare ref writes to itself; `+ref` is a force refspec.
233 target = refspec.split(":")[-1].lstrip("+")
234 if target in ("HEAD", ""):
235 branch = current_branch(cwd)
236 if branch:
237 targets.append(branch)
238 continue
239 targets.append(target.removeprefix("refs/heads/"))
240 return targets
243def is_default(target: str, default: str | None) -> bool:
244 """Whether ``target`` names the default branch, falling back to the usual names."""
245 return target == default if default else target in _LIKELY_DEFAULTS
248def git_guard(command: str, cwd: str) -> Decision | None:
249 """Deny force-push/force-tag; ask before a push that lands on the default branch."""
250 for tokens in segments(command):
251 parsed = git_subcommand(tokens)
252 if parsed is None:
253 continue
254 subcommand, args = parsed
255 if subcommand in ("push", "tag") and _FORCE_FLAGS & set(args):
256 return Decision("deny", _FORCE_REASON)
257 if subcommand != "push":
258 continue
259 default = default_branch(cwd)
260 for target in push_targets(args, cwd):
261 if is_default(target, default):
262 return Decision(
263 "ask",
264 f"This pushes to `{target}`, the default branch. Every rhiza command "
265 "delivers its work as a PR from a work branch and never pushes here. "
266 "Approve only if you intend to bypass that.",
267 )
268 return None
271def evaluate(command: str, cwd: str) -> Decision | None:
272 """Apply every guard to one Bash command; the first decision wins."""
273 return compound_make(command) or git_guard(command, cwd)
276def main() -> int:
277 """Read the hook payload on stdin, print any decision, and always exit 0."""
278 try:
279 payload = json.loads(sys.stdin.read())
280 except (json.JSONDecodeError, UnicodeDecodeError, ValueError):
281 return 0
282 if not isinstance(payload, dict) or payload.get("tool_name") != "Bash":
283 return 0
284 tool_input = payload.get("tool_input")
285 command = tool_input.get("command") if isinstance(tool_input, dict) else None
286 if not isinstance(command, str) or not command.strip():
287 return 0
288 cwd = payload.get("cwd")
289 decision = evaluate(command, cwd if isinstance(cwd, str) and cwd else ".")
290 if decision is None:
291 return 0
292 print(
293 json.dumps(
294 {
295 "hookSpecificOutput": {
296 "hookEventName": "PreToolUse",
297 "permissionDecision": decision.permission,
298 "permissionDecisionReason": decision.reason,
299 }
300 }
301 )
302 )
303 return 0
306if __name__ == "__main__":
307 sys.exit(main())