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

1#!/usr/bin/env python3 

2"""Guard the Bash calls this plugin's commands make, at the moment they run. 

3 

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: 

9 

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. 

16 

17This module is the ``PreToolUse`` hook that makes both structural. It reads the hook 

18payload on stdin and writes a ``hookSpecificOutput`` decision on stdout. 

19 

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. 

25 

26The three decisions it can reach: 

27 

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. 

37 

38Usage (not normally invoked by hand): 

39 echo '<hook payload>' | python3 scripts/hook_bash_guard.py 

40 

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""" 

44 

45from __future__ import annotations 

46 

47import json 

48import re 

49import shutil 

50import subprocess # nosec B404 

51import sys 

52from typing import NamedTuple 

53 

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"'[^']*'|\"[^\"]*\"") 

58 

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) 

66 

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) 

73 

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"[|;<>&]") 

79 

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]") 

84 

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"}) 

88 

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"}) 

92 

93_FORCE_FLAGS = frozenset({"-f", "--force"}) 

94 

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"}) 

99 

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) 

106 

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) 

112 

113 

114class Decision(NamedTuple): 

115 """A permission decision to hand back to Claude Code.""" 

116 

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.""" 

120 

121 reason: str 

122 """Shown to the model (and the user on ``ask``). Says what to do instead.""" 

123 

124 

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) 

128 

129 

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) 

135 

136 

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)) 

142 

143 

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) 

152 

153 

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] 

158 

159 

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 :] 

171 

172 

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 

185 

186 

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 

207 

208 

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 

215 

216 

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 

221 

222 

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 

241 

242 

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 

246 

247 

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 

269 

270 

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) 

274 

275 

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 

304 

305 

306if __name__ == "__main__": 

307 sys.exit(main())