#!/usr/bin/env python3 """PreToolUse(Bash) hook: auto-approve read-only Bash commands, single, piped, or &&-chained. Claude Code's built-in matcher prompts on `|` pipelines even when every stage is individually allow-listed, and (observed) sometimes on single commands whose allow-list rule ought to match. This hook covers both: it approves a command when every stage is a vetted read-only stage. Stages are separated by `|`, `&&` or `;`. Chaining reads is the whole point of batching several independent inspections into one call, so both sequencing operators get the same treatment as a pipe: they only ever order the stages, never change which ones run, and each is vetted regardless. A *lone* `&` is backgrounding and still drops through. Shell metacharacters are only honoured when the shell would honour them: a `|`, `>`, `;`, `&` etc. inside quotes is literal data (`grep "a|b" file`), not structure, so it neither splits stages nor trips the guard. The quoting rules are the reason this can't be done with a flat regex. Fail-closed: it only ever emits an "allow" decision. For anything it can't fully vouch for -- an unterminated quote, an unrecognised stage, a metacharacter it can't rule out -- it stays silent and lets the normal permission flow (allow-list / prompt) decide. It never denies, so it can never block a legitimate command on its own. """ import json import os import re import sys def absolute_build_script_prefixes(repo_root): """The absolute-path spellings of `python -u .../Scripts/build.py` for a repo at `repo_root`. Derived rather than hardcoded so the hook keeps working from any checkout path. Both the Windows (`C:/repo`) and Git Bash (`/c/repo`) forms are produced, since either can reach a Bash command line. """ drive, remainder = os.path.splitdrive(repo_root) remainder = remainder.replace("\\", "/").rstrip("/") if not drive: return ("python -u {}/Scripts/build.py".format(remainder),) return ("python -u {}{}/Scripts/build.py".format(drive, remainder), "python -u /{}{}/Scripts/build.py".format(drive[0].lower(), remainder)) # This file lives at /.claude/hooks/, so the repo root is three levels up. REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # A pipeline stage may start with one of these (first token, or the explicit multi-token forms). This # mirrors the read-only Bash entries already vetted in settings.json's allow list; keep the two in # sync. The `awk --sandbox` entry is multi-token because bare awk can shell out via system() and # write files with redirected print, and --sandbox is what disables both. # # Deliberately absent, do not add: sed. Even `sed -n` isn't read-only -- a script can write files # (`w`, `W`, the `s///w` flag) or, in GNU sed, run shell commands (the `e` command and `s///e` flag). # There's no sandbox flag as there is for awk, and the only way to clear an arbitrary sed script is to # parse it, which is precisely the kind of check that fails open. So sed always falls through to a # prompt. # # The `svn` entries name their subcommand for the same reason: bare `svn` covers commit/revert/update # too, so only the six read-only subcommands settings.json already allows are listed. Subcommand # aliases (`svn st`, `svn di`) are deliberately absent -- they simply prompt, which is the safe miss. SAFE_PREFIXES = ( "ls", "cat", "head", "tail", "wc", "grep", "rg", "sort", "uniq", "diff", "file", "which", "echo", "pwd", "basename", "dirname", "realpath", "tr", "cut", "nl", "awk --sandbox", "python -m json.tool", "python -u build.py", "python -u Scripts/build.py", "svn status", "svn diff", "svn log", "svn info", "svn list", "svn cat", ) + absolute_build_script_prefixes(REPO_ROOT) # Stands in for any character we've decided the shell won't treat as syntax (quoted, or backslash # escaped). NUL can't appear in a real command line, so it can never be confused with live input, and # it preserves length/offsets so a masked string still lines up with the original. _FILLER = "\x00" def _mask(command): """Splits `command` into two same-length views with quoted regions blanked out to `_FILLER`. Returns `(single_masked, full_masked)`, or `(None, None)` if the quoting never closes (an unterminated quote or a trailing line-continuation backslash -- both of which mean we're only seeing part of the real command and shouldn't vouch for it). `single_masked` blanks only single-quoted spans; it exists solely to spot command substitution, which single quotes fully neutralise but double quotes do not (`"$(x)"` still runs). `full_masked` blanks single quotes, double quotes, and backslash-escaped characters. It's the view used for stage splitting and the metacharacter guard, where a double-quoted `>` or `|` is just data. """ single = list(command) full = list(command) mode = None # None (unquoted), "'" or '"'. escaped = False for index, char in enumerate(command): if mode is None: if escaped: full[index] = _FILLER # The escaped character is a literal, not shell syntax. escaped = False elif char == "\\": escaped = True full[index] = _FILLER elif char == "'": mode = "'" single[index] = _FILLER full[index] = _FILLER elif char == '"': mode = '"' full[index] = _FILLER # Otherwise a live, unquoted character: leave it in both views. elif mode == "'": single[index] = _FILLER full[index] = _FILLER if char == "'": mode = None else: # Inside double quotes. full[index] = _FILLER if escaped: escaped = False elif char == "\\": escaped = True elif char == '"': mode = None if mode is not None or escaped: return None, None return "".join(single), "".join(full) def _has_live_substitution(single_masked): """True if a `$(...)` or backtick survives outside single quotes, where the shell would run it.""" return "$(" in single_masked or "`" in single_masked def _scrub_safe_redirections(text): """Removes the read-only redirections (`2>&1`, `2>/dev/null`) so the guard doesn't see their > / &. Both patterns end at a token boundary, or `2>/dev/nullx` would scrub to a bare `x` and hand the guard a command with no `>` left in it -- while the shell still redirects to a writable path. The boundary has to admit the stage separators too, since `... 2>/dev/null; ls` ends the redirection on a `;` rather than on whitespace. """ boundary = r"(?=[\s;|&)]|$)" text = re.sub(r"\d?>\s*&\s*\d" + boundary, "", text) # 2>&1, >&1 text = re.sub(r"\d?>\s*/dev/null" + boundary, "", text) # >/dev/null, 2>/dev/null return text def is_safe_stage(stage): stage = stage.strip() for prefix in SAFE_PREFIXES: if stage == prefix or stage.startswith(prefix + " "): return True return False # Several commands above are read-only only in their ordinary mode: a single flag flips them into # writing a file or launching another program, and there's no sandbox to fall back on the way awk has # one. A stage whose command carries one of these flags is refused even though its prefix is vetted. # Matched as whole words against the masked stage; `startswith` also catches an attached value such as # `sort -ofile` or `--output=x`, and single-letter entries are additionally matched inside a short-flag # cluster (`sort -uo f`). Still no real arg parser here, so a flag whose *value* happens to spell a # guarded long option is the remaining soft spot -- it errs toward a prompt, which is the safe way. # # svn's are all routes to running an arbitrary program: --diff-cmd and --editor-cmd name one outright, # while --config-option can set `config:helpers:diff-cmd` and --config-dir can point at a config file # that already does. Guarding only --diff-cmd would leave the other two as trivial bypasses. _WRITE_OR_EXEC_FLAGS = { "rg": ("--pre", "--pre-glob", "--hostname-bin", "--search-zip", "-z"), # --pre runs a command "sort": ("-o", "--output", "--compress-program"), # --compress-program runs a command "file": ("-C", "--compile"), "svn": ("--diff-cmd", "--editor-cmd", "--config-option", "--config-dir"), } # Commands that quietly treat a trailing bare operand as an output file to overwrite: # uniq [INPUT [OUTPUT]] python -m json.tool [infile] [outfile] # The number is how many bare (non-option) operands stay read-only. Counting bare operands over-counts # when a value option is spelled with a separate argument (`uniq -f 2 in`), so the check errs toward an # extra prompt, never toward waving a write through. In pipeline use these take zero operands, so this # only ever bites the rare single-command two-file form. _MAX_READONLY_OPERANDS = ( (("uniq",), 1), (("python", "-m", "json.tool"), 1), ) def _has_write_or_exec_flag(words): flags = _WRITE_OR_EXEC_FLAGS.get(words[0]) if words else None if not flags: return False # Short flags cluster, so the guarded letter can hide mid-word in `sort -uo f` or `rg -iz x`. # Clustering is a normal habit rather than a contrivance, which is what makes this worth catching; # the cost is the odd false prompt when the letter is really an option's value (`rg -tzig`). clustered = {flag[1] for flag in flags if len(flag) == 2 and flag.startswith("-")} for word in words[1:]: if any(word == flag or word.startswith(flag) for flag in flags): return True if clustered and word.startswith("-") and not word.startswith("--"): if clustered.intersection(word[1:]): return True return False def _writes_via_operand(words): for prefix, max_operands in _MAX_READONLY_OPERANDS: if tuple(words[:len(prefix)]) != prefix: continue operands = 0 after_double_dash = False for word in words[len(prefix):]: if word == "--" and not after_double_dash: after_double_dash = True # A lone `-` is stdin, an operand -- miss that and `uniq - out.txt` looks like it has only # the one operand it's allowed, with the output file hidden behind the option test. elif after_double_dash or word == "-" or not word.startswith("-"): operands += 1 return operands > max_operands return False def _stage_has_hidden_side_effect(stage_masked): words = stage_masked.split() return _has_write_or_exec_flag(words) or _writes_via_operand(words) def is_allowed(command): """True only if every stage of `command` is a vetted read-only command with no live side effects.""" if not command: return False single_masked, full_masked = _mask(command) if full_masked is None: return False if _has_live_substitution(single_masked): return False guarded = _scrub_safe_redirections(full_masked) # `&&` is only a stage separator; drop it before the guard so its `&` doesn't read as backgrounding. # By now the redirection scrub has already consumed the `&` of any `2>&1`, so a surviving bare `&` # really is backgrounding. guarded = guarded.replace("&&", "") if any(token in guarded for token in ("<", ">", "&", "\n")): return False # A trailing `;` is punctuation rather than an empty command, so strip it instead of failing closed # on the empty stage it would otherwise split off. separated = full_masked.rstrip().rstrip(";") # Split on the masked view so a quoted separator doesn't split a stage, but keep the stage text from # the masked view too -- a safe prefix is always unquoted, so it survives masking intact, while # `||` and `;;` leave an empty stage that matches no prefix and so fail closed on their own. stages = re.split(r"&&|\||;", separated) if not all(is_safe_stage(stage) for stage in stages): return False return not any(_stage_has_hidden_side_effect(stage) for stage in stages) def main(): try: payload = json.load(sys.stdin) except Exception: return if payload.get("tool_name") != "Bash": return command = (payload.get("tool_input") or {}).get("command", "") if not is_allowed(command): return print(json.dumps({ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "allow", "permissionDecisionReason": "pipeline of vetted read-only stages", } })) if __name__ == "__main__": main()