From 0634885e2faa172a86ab6027329869092e4f6fc6 Mon Sep 17 00:00:00 2001 From: Cristian Girlea Date: Thu, 15 Oct 2026 10:32:23 +0300 Subject: [PATCH 1/2] chore: refuse a push to main in every spelling with a pre-call hook settings.json's deny rules match spellings, so a bare git push on main, git push origin HEAD, an alias or a shell within a shell got through to GitHub's ruleset. The push guard from the owner's other repositories now reads every Bash and PowerShell command first and refuses a push that would land on main or a tag, a force push, git tag, gh release, gh pr merge --auto or --admin, and a write through gh api to releases or refs; what it cannot read and mentions one of those, it refuses too. Its own cases run from the container suite; the killed-git case needs an executable temporary directory and is skipped where /tmp is noexec. --- .claude/hooks/push-guard.sh | 645 +++++++++++++++++++++++++++ .claude/hooks/push-guard.test.sh | 360 +++++++++++++++ .claude/settings.json | 12 + .claude/skills/check/SKILL.md | 4 + AGENTS.md | 5 +- CLAUDE.md | 5 + docs/testing.md | 2 +- tests/python/test_push_guard_hook.py | 40 ++ 8 files changed, 1070 insertions(+), 3 deletions(-) create mode 100644 .claude/hooks/push-guard.sh create mode 100644 .claude/hooks/push-guard.test.sh create mode 100644 tests/python/test_push_guard_hook.py diff --git a/.claude/hooks/push-guard.sh b/.claude/hooks/push-guard.sh new file mode 100644 index 0000000..3209d82 --- /dev/null +++ b/.claude/hooks/push-guard.sh @@ -0,0 +1,645 @@ +#!/bin/sh +# Before a Bash or PowerShell call: refuse what only the owner does. That is a git push whose +# destination is main or a tag, a force push, any git tag, any gh release, gh pr merge with --auto +# or --admin, and a mutating gh api call on releases or git refs. Exit 2 blocks the call and shows +# the one-line reason; everything else exits 0. +# settings.json's deny rules match spellings, so a new spelling gets through. This reads what the +# command does, and refuses when it cannot tell. It runs under Git Bash on Windows and sh on Linux, +# before every call, so a command without a trigger word exits before any other process starts. +# Past that: sed for JSON escapes, tr for capitals, git to resolve a push or an alias, and a +# subshell for the hook's body and for each shell, eval, alias and heredoc whose text it checks. +LC_ALL=C +export LC_ALL +set -f +nl=' +' + +# One line: a pretty-printed input's newlines are only between JSON tokens. +input= +while IFS= read -r line || [ -n "$line" ]; do input="$input $line"; done + +refuse() { + printf 'push-guard: %s. This is the owner'"'"'s step: hand over the command.\n' "$1" >&2 + exit 2 +} +# mentions TEXT: TEXT names a push, a tag, a release, a merge or the API. +mentions() { + case $1 in + *[Pp][Uu][Ss][Hh]* | *[Tt][Aa][Gg]* | *[Rr][Ee][Ll][Ee][Aa][Ss][Ee]* | *[Mm][Ee][Rr][Gg][Ee]* | *[Aa][Pp][Ii]*) return 0 ;; + esac + return 1 +} +# lower WORD: sets lc to WORD in lower case; tr runs only when there is a capital. +lower() { + case $1 in + *[A-Z]*) lc=$(printf '%s' "$1" | tr 'A-Z' 'a-z') ;; + *) lc=$1 ;; + esac +} +# prog WORD: sets w to the program WORD names, without its directory or .exe, in lower case. +prog() { + p=${1##*/} + lower "${p##*\\}" + w=${lc%.exe} +} + +# json_string KEY: sets val to the still-escaped string value of "KEY": in the input, or fails. +# The value ends at the first quote that an odd run of backslashes does not escape. +json_string() { + rest=$input + while :; do + case $rest in *"\"$1\""*) ;; *) return 1 ;; esac + rest=${rest#*"\"$1\""} + t=${rest#"${rest%%[![:space:]]*}"} + case $t in :*) ;; *) continue ;; esac # the key's name was a value: look further + t=${t#:} + t=${t#"${t%%[![:space:]]*}"} + case $t in '"'*) t=${t#\"} ;; *) return 1 ;; esac + val= + while :; do + case $t in *'"'*) ;; *) return 1 ;; esac + head=${t%%\"*} + t=${t#*\"} + h=$head + while :; do case $h in *'\\') h=${h%??} ;; *) break ;; esac; done + case $h in + *'\') val=$val$head'"' ;; + *) val=$val$head; return 0 ;; + esac + done + done +} +# unescape: JSON escapes decoded, a line at a time. An escaped backslash is set aside first, so it +# cannot start another escape. A backslash or backtick before a newline joins the lines (sh and +# PowerShell line continuations); other newlines stay. CRLF and a lone CR are newlines (PowerShell +# ends a line at a CR). Control characters: spaces. +unescape() { + sed -e 's/\\\\/'"$soh"'/g' -e 's/\\r\\n/\\n/g' -e 's/\\r/\\n/g' \ + -e 's/'"$soh"'\\n/ /g' -e 's/`\\n/ /g' -e 's/\\n/\'"$nl"'/g' \ + -e 's/\\t/ /g' -e 's/\\"/"/g' -e 's/\\\//\//g' -e 's/\\[bf]/ /g' \ + -e 's/\\u0026/\&/g' -e 's/\\u003[cC]//g' -e "s/\\\\u0027/'/g" \ + -e 's/\\u[0-9a-fA-F]\{4\}/ /g' -e 's/'"$soh"'/\\/g' +} + +# use_lang LANG: how the text is quoted: sh (\ escapes), ps (PowerShell, ` escapes) or cmd (none, +# and ' is no quote). +use_lang() { + lang=$1 + case $1 in sh) esc='\' ;; ps) esc='`' ;; *) esc= ;; esac +} + +# words TEXT: sets wl to the words of one simple command, each followed by $soh, with one level of +# quotes taken off. Outside quotes, blanks, newlines and commas (PowerShell's argument arrays) end +# a word. +words() { + ws=$1 wl= wd= wq= wm=o + while :; do + case $wm in + o) wp=${ws%%[\"\'\\\`\ \ ,$nl]*} ;; + s) wp=${ws%%\'*} ;; + *) wp=${ws%%[\"\\\`]*} ;; + esac + wd=$wd$wp ws=${ws#"$wp"} + [ -n "$ws" ] || break + wc=${ws%"${ws#?}"} ws=${ws#?} + case $wm$wc in + s\' | d\") wm=o ;; + o\') if [ "$lang" = cmd ]; then wd=$wd$wc; else wm=s wq=1; fi ;; + o\") wm=d wq=1 ;; + [od]\\ | [od]\`) + if [ "$wc" = "$esc" ]; then + wn=${ws%"${ws#?}"} ws=${ws#?} + # In sh's double quotes, \ escapes only " \ $ and `. + case $wm$lang$wn in dsh[\"\\\$\`]) ;; dsh*) wn=$wc$wn ;; esac + wd=$wd$wn + else + wd=$wd$wc + fi ;; + *) + [ -z "$wd$wq" ] || wl=$wl$wd$soh + wd= wq= ;; + esac + done + [ -z "$wd$wq" ] || wl=$wl$wd$soh +} + +# git_in_dir ARGS: git in the directory the command runs in, with the command's own -C, -c and +# --git-dir options. Fails when that directory is unknown. +git_in_dir() { + [ -n "$dir" ] || return 1 + IFS=$soh + git -C "$dir" $gopts "$@" 2>/dev/null + gs=$? + IFS=' ' + return $gs +} +current_branch() { + b=$(git_in_dir rev-parse --abbrev-ref HEAD) && [ -n "$b" ] && [ "$b" != HEAD ] && printf '%s' "$b" +} + +# check_cmd TEXT: each simple command in TEXT, split at ; | & ( ) { } and newlines outside quotes. +# A command substitution, $( ) or sh's backticks, in double quotes too, is checked as commands, and +# the command around it gets a $ where it was, as what it adds cannot be seen. A comment is skipped, +# and so is a heredoc's body, unless its word is unquoted and the body has a substitution: then the +# body is checked as commands. +check_cmd() { + cs=$1 cur= md=o stk= lastc= hdq= + while [ -n "$cs" ]; do + case $md in + o) pre=${cs%%[\;\|\&\(\)\{\}\<\#\"\'\\\`$nl]*} ;; + s) pre=${cs%%\'*} ;; + *) pre=${cs%%[\"\\\`\(]*} ;; + esac + cur=$cur$pre cs=${cs#"$pre"} + case $pre in ?*) lastc=${pre#"${pre%?}"} ;; esac + [ -n "$cs" ] || break + ch=${cs%"${cs#?}"} cs=${cs#?} + case $md$ch in + s\' | d\") cur=$cur$ch md=o ;; + o\') cur=$cur$ch; [ "$lang" = cmd ] || md=s ;; + o\") cur=$cur$ch md=d ;; + [od]\\ | [od]\`) + if [ "$ch" = "$esc" ]; then + cur=$cur$ch${cs%"${cs#?}"} cs=${cs#?} + elif [ "$ch$lang" = '`sh' ]; then + case $md$stk in + o[OD]*) check_part "$cur"; sub_end ;; + o*) cur="$cur \$"; sub_start O ;; + *) cur="$cur \$"; sub_start D ;; + esac + else + cur=$cur$ch + fi ;; + d\() case $cur in *'$') cur=$cur$ch; sub_start d ;; *) cur=$cur$ch ;; esac ;; + o\() + # A ( straight after another one, as in $(( or ((, opens arithmetic, and so does any ( inside + # it: a << there is a shift. + case $stk in + a*) sub_start a ;; + *) if [ "$lastc" = '(' ] && [ -z "$cur" ]; then sub_start a; else cur=$cur$ch; sub_start o; fi ;; + esac ;; + o\)) check_part "$cur"; case $stk in [oda]*) sub_end ;; *) cur= ;; esac ;; + o\#) + case $lastc in + '' | [\ \ \;\&\|\(\)] | "$nl") case $cs in *"$nl"*) cs=$nl${cs#*"$nl"} ;; *) cs= ;; esac ;; + *) cur=$cur$ch ;; + esac ;; + o\<) + cur=$cur$ch + case $lang$cs in + sh\<\<*) cur=$cur'<<' cs=${cs#??} ;; # <<<, a here-string + sh\<*) cs=${cs#?}; cur=$cur'<'; case $stk in a*) ;; *) heredoc_start ;; esac ;; + esac ;; + o"$nl") check_part "$cur"; cur=; heredoc_skip ;; + *) check_part "$cur"; cur= ;; + esac + lastc=$ch + done + check_part "$cur" + while [ -n "$stk" ]; do sub_end; check_part "$cur"; done + return 0 +} +# heredoc_start: after <<, queues the heredoc's word, if it has one: u or q (unquoted, quoted), - or _ (<<- or <<), +# then the word without its quotes. +heredoc_start() { + # A heredoc's word starts with a letter, _, a quote or a backslash: $((1<<3)) is arithmetic. + hw=${cs#-} + hw=${hw#"${hw%%[!\ \ ]*}"} + case $hw in [A-Za-z_\'\"\\]*) ;; *) return 0 ;; esac + hd=_ + case $cs in -*) hd=- cs=${cs#-} cur=$cur- ;; esac + hb=${cs%%[!\ \ ]*} + cs=${cs#"$hb"} + hw=${cs%%[\ \ \;\|\&\<\>\(\)$nl]*} + cs=${cs#"$hw"} cur=$cur$hb$hw hq=u + while :; do + case $hw in *[\'\"\\]*) hq=q hw=${hw%%[\'\"\\]*}${hw#*[\'\"\\]} ;; *) break ;; esac + done + [ -z "$hw" ] || hdq=$hdq$hq$hd$hw$nl +} +# heredoc_skip: at a newline, takes the queued heredocs' bodies off the text. <<- ends at the word +# after blanks: a JSON tab arrives as a space. +heredoc_skip() { + while [ -n "$hdq" ]; do + he=${hdq%%"$nl"*} hdq=${hdq#*"$nl"} + hq=${he%"${he#?}"} he=${he#?} + hd=${he%"${he#?}"} he=${he#?} + hbody= + while [ -n "$cs" ]; do + case $cs in *"$nl"*) hl=${cs%%"$nl"*} cs=${cs#*"$nl"} ;; *) hl=$cs cs= ;; esac + [ "$hd" = - ] && hl=${hl#"${hl%%[!\ \ ]*}"} + [ "$hl" = "$he" ] && break + hbody=$hbody$hl$nl + done + case $hq$hbody in u*'$('* | u*'`'*) (check_cmd "$hbody") || exit $? ;; esac + done +} +# sub_start MODE: a substitution or a group begins; MODE (o, d, or O, D for backticks) is the mode to +# return to, and the command so far is set aside. MODE a is arithmetic, $(( or ((: back to o at its +# end, and a << inside it is a shift, not a heredoc. +sub_start() { + stk=$1$cur$soh$stk cur= md=o +} +# sub_end: back to the command around it. +sub_end() { + top=${stk%%"$soh"*} stk=${stk#*"$soh"} + cur=${top#?} + case $top in [oOa]*) md=o ;; *) md=d ;; esac +} + +# check_part TEXT: one simple command, in words. Wrappers (env, sudo, xargs, Start-Process...) are +# taken off to reach the program; the code a shell or eval is given (bash -c, powershell -Command, +# cmd /c, Invoke-Expression...) is checked as commands. +check_part() { + words "$1" + IFS=$soh + set -- $wl + IFS=' ' + fromstdin=0 gopts= + while [ $# -gt 0 ]; do + case $1 in + '') shift; continue ;; + '$'*) + # PowerShell: $x = runs the command. + case ${2-} in + =) shift 2; continue ;; + =*) w=${2#=}; shift 2; set -- "$w" "$@"; continue ;; + esac + case $1 in *=?*) w=${1#*=}; shift; set -- "$w" "$@"; continue ;; *=) shift; continue ;; esac + # A variable names the program: refuse when what follows reads like one of ours. + for t; do + lower "$t" + case $lc in push | tag | release | merge) refuse "a variable names the program in: $*" ;; esac + done + return 0 ;; + *=*) shift; continue ;; # NAME=value before a command + esac + prog "$1" + case $w in + then | do | else | elif | if | while | until) shift; continue ;; # a keyword before a command + env | sudo | doas | command | builtin | exec | nohup | time | nice | noglob | ! | timeout | xargs | \ + eval | iex | invoke-expression | start-process | saps | start) + [ "$w" = xargs ] && fromstdin=1 + shift + case $w in + eval | iex | invoke-expression) + case "$*" in *'$'*) mentions "$*$cmd" && refuse "$w runs text the hook cannot see: $*" ;; esac ;; + start-process | saps | start) + # -ArgumentList 'push origin main' is split as the program will see it. + t="$*"; IFS=" ,"; set -- $t; IFS=' ' ;; + esac + while [ $# -gt 0 ]; do + case $1 in + -u | -g | -C | -n | -L | -P | -d | -E | -s | -a | -k | -I) shift; [ $# -gt 0 ] && shift ;; + '' | -*) shift ;; + *) break ;; + esac + done + [ "$w" = timeout ] && [ $# -gt 0 ] && shift # the duration + case $w in + eval) (use_lang sh; check_cmd "$*") || exit $?; return 0 ;; + iex | invoke-expression) (use_lang ps; check_cmd "$*") || exit $?; return 0 ;; + esac + continue ;; + bash | sh | zsh | dash | ksh | ash | mksh | powershell | pwsh | cmd) + sh=$w + shift + while [ $# -gt 0 ]; do + lower "$1" + case $sh:$lc in + powershell:-e | powershell:-ec | powershell:-en* | pwsh:-e | pwsh:-ec | pwsh:-en*) + refuse "an encoded PowerShell command cannot be read" ;; + powershell:-f | powershell:-file | pwsh:-f | pwsh:-file) return 0 ;; # a script file: not read + *:-o | *:-O | *:--rcfile | *:--init-file | *:-executionpolicy | *:-ex | *:-ep | *:-windowstyle | \ + *:-w | *:-workingdirectory | *:-wd | *:-outputformat | *:-of | *:-inputformat | *:-if | \ + *:-configurationname) + shift; [ $# -gt 0 ] && shift ;; + *: | *:-* | *:/?) shift ;; # cmd's /c, /k, /s + *) break ;; + esac + done + [ $# -gt 0 ] || return 0 + case $lc in + *[\ \ \;\&\|$nl]*) ;; + *.sh | *.bash | *.ps1 | *.bat | *.cmd) return 0 ;; # a script file: not read + esac + case $sh in powershell | pwsh) l=ps ;; cmd) l=cmd ;; *) l=sh ;; esac + (use_lang $l; check_cmd "$*") || exit $? + return 0 ;; + esac + break + done + [ $# -gt 0 ] || return 0 + case $w in + git) shift; check_git "$@"; return ;; + gh) shift; check_gh "$@"; return ;; + cd | chdir | pushd | set-location | sl | push-location) shift; change_dir "$@"; return ;; + esac + # A path to git or gh further on is the program, as in & "C:\Program Files\Git\cmd\git.exe" push. + while [ $# -gt 0 ]; do + case $1 in + */[Gg][Ii][Tt] | */[Gg][Ii][Tt].[Ee][Xx][Ee] | *\\[Gg][Ii][Tt] | *\\[Gg][Ii][Tt].[Ee][Xx][Ee]) + shift; check_git "$@"; return ;; + */[Gg][Hh] | */[Gg][Hh].[Ee][Xx][Ee] | *\\[Gg][Hh] | *\\[Gg][Hh].[Ee][Xx][Ee]) + shift; check_gh "$@"; return ;; + esac + shift + done +} + +# change_dir ARGS: follow cd, so a later bare git push is resolved where it runs. +change_dir() { + while [ $# -gt 0 ]; do case $1 in -) break ;; '' | -*) shift ;; *) break ;; esac; done + case ${1-} in + '') dir=$HOME ;; + -) dir= ;; + /* | [A-Za-z]:* | \\*) dir=$1 ;; + '~' | '~/'* | '~\'*) dir=$HOME${1#\~} ;; + *) [ -n "$dir" ] && dir=$dir/$1 ;; + esac +} + +check_git() { + while [ $# -gt 0 ]; do + case $1 in + -C | -c | --git-dir | --work-tree | --namespace | --config-env | --attr-source) + [ $# -ge 2 ] || return 0 + gopts=$gopts$1$soh$2$soh + shift 2 ;; + --*=* | --bare) gopts=$gopts$1$soh; shift ;; + '' | -*) shift ;; + *) break ;; + esac + done + [ $# -gt 0 ] || return 0 + lower "$1" + sub=$lc + shift + case $sub in + *'$'* | *%*) refuse "a variable names the git command" ;; + push) check_push "$@" ;; + tag) refuse "git tag: tags are the owner's" ;; + # Built-in commands: git never lets an alias replace one. + add | am | apply | bisect | blame | branch | checkout | cherry-pick | clean | clone | commit | config | \ + describe | diff | fetch | grep | init | log | ls-files | ls-remote | merge | merge-base | mv | pull | \ + rebase | reflog | remote | reset | restore | rev-list | rev-parse | revert | rm | show | show-ref | \ + stash | status | switch | symbolic-ref | update-ref | worktree | cat-file | for-each-ref | \ + format-patch | help | version | submodule | shortlog | ls-tree | check-ignore | gc | notes) ;; + *) check_alias "$sub" "$@" ;; + esac +} + +# check_alias NAME ARGS: a git alias is checked as what it expands to. +check_alias() { + a=$(git_in_dir config --get "alias.$1") && [ -n "$a" ] || return 0 + shift + depth=$((depth + 1)) + [ "$depth" -le 5 ] || refuse "git aliases nest too deep to read" + case $a in + '!'*) (use_lang sh; check_cmd "${a#!} $*") || exit $? ;; + *) (IFS=' '; set -- $a "$@"; check_git "$@") || exit $? ;; + esac +} + +check_push() { + [ "$fromstdin" = 1 ] && refuse "git push under xargs takes arguments the hook cannot see" + remote= haveremote= nrefs=0 delete= endopts= repo= + while [ $# -gt 0 ]; do + t=$1 + shift + case $t in + '') continue ;; + *'>' | *'<') [ $# -gt 0 ] && shift; continue ;; # a redirection and its file + *'>'* | *'<'*) continue ;; + *'$'* | *%*) refuse "a variable in git push ($t) hides the destination" ;; + esac + if [ -z "$endopts" ]; then + case $t in + --) endopts=1; continue ;; + --*) + # git takes any unambiguous prefix of a long option. + o=${t%%=*} + for n in --force --force-with-lease --force-if-includes; do + case $n in "$o"*) refuse "a force push ($t)" ;; esac + done + for n in --mirror --all --branches --tags --follow-tags; do + case $n in "$o"*) refuse "git push $t pushes main or tags" ;; esac + done + case --repo in "$o"*) if [ ${#o} -ge 5 ]; then + haveremote=1 + case $t in *=*) remote=${t#*=} ;; *) remote=${1-}; [ $# -gt 0 ] && shift ;; esac + fi; continue ;; esac + case --delete in "$o"*) [ ${#o} -ge 4 ] && delete=1; continue ;; esac + case $t in *=*) continue ;; esac + for n in --receive-pack --exec --push-option --recurse-submodules; do + case $n in "$o"*) [ ${#o} -ge 4 ] && [ $# -gt 0 ] && shift; continue 2 ;; esac + done + continue ;; + -?*) + c=${t#-} + case $c in *f*) refuse "a force push ($t)" ;; esac + case $c in *d*) delete=1 ;; esac + case $c in *o) [ $# -gt 0 ] && shift ;; esac # -o takes a value + continue ;; + esac + fi + if [ -z "$haveremote" ]; then + haveremote=1 remote=$t + continue + fi + nrefs=$((nrefs + 1)) + check_refspec "$t" + done + [ "$nrefs" -gt 0 ] || [ -n "$delete" ] || check_bare +} + +# check_refspec REFSPEC: [+][:], or : to delete. +check_refspec() { + r=$1 + case $r in + +*) refuse "a force push ($r)" ;; + *'*'*) refuse "a wildcard refspec ($r) can reach main or tags" ;; + esac + lower "$r" + [ "$lc" = tag ] && refuse "git push ... tag pushes a tag" + [ -n "$repo" ] || git_in_dir rev-parse --git-dir >/dev/null || + refuse "cannot read the repository this git push runs in" + repo=1 + if [ -n "$delete" ]; then + check_deletion "$r" + return + fi + case $r in + :) refuse "git push : pushes every matching branch, main too" ;; + :*) check_deletion "${r#:}"; return ;; + *:*) src=${r%%:*} dst=${r#*:} ;; + *) src=$r dst= ;; + esac + if [ -z "$dst" ]; then + lower "$src" + case $lc in + head | @) src=$(current_branch) || refuse "cannot tell which branch HEAD is in git push $r" ;; + esac + fi + full=$(git_in_dir rev-parse --symbolic-full-name "$src") + lower "$src $full" + case $lc in *refs/tags/*) refuse "git push $r pushes a tag" ;; esac + # Without a destination, git pushes to the source's own full name. + [ -n "$dst" ] || dst=${full:-$src} + check_dst "$dst" "$r" +} +check_dst() { + lower "$1" + case ${lc#refs/heads/} in main) refuse "git push $2 reaches main" ;; esac + case $lc in refs/tags/*) refuse "git push $2 writes a tag" ;; esac +} +# check_deletion REF: deleting is let through only for a branch the remote is known to have. +check_deletion() { + check_dst "$1" ":$1" + case $remote in '' | */* | *:* | *\\*) refuse "cannot tell what deleting $1 on $remote removes" ;; esac + git_in_dir show-ref --verify --quiet "refs/remotes/$remote/${1#refs/heads/}" || + refuse "deleting $1: it is not a known branch of $remote" +} + +# check_bare: a git push without a refspec goes where git's configuration says; ask git. +check_bare() { + b=$(current_branch) || refuse "a bare git push from a detached or unreadable HEAD" + lower "$b" + [ "$lc" = main ] && refuse "git push from main pushes main" + cfg=$(git_in_dir config --get-regexp '^push\.default$|^remote\..*\.(push|mirror)$') + lower "$cfg" + case "$nl$lc$nl" in *"${nl}push.default matching$nl"*) refuse "push.default=matching pushes main too" ;; esac + case $lc in *.push\ *) refuse "a remote..push setting decides what a bare git push sends" ;; esac + case "$lc$nl" in *".mirror true$nl"* | *".mirror yes$nl"* | *".mirror on$nl"* | *".mirror 1$nl"*) + refuse "a mirror remote pushes every ref" ;; + esac + p=$(git_in_dir rev-parse --abbrev-ref --symbolic-full-name '@{push}') || return 0 + lower "$p" + case $lc in */main) refuse "git push from $b goes to $p" ;; esac + return 0 +} + +check_gh() { + while [ $# -gt 0 ]; do + case $1 in + -R | --repo) shift; [ $# -gt 0 ] && shift ;; + '' | -*) shift ;; + *) break ;; + esac + done + [ $# -gt 0 ] || return 0 + lower "$1" + sub=$lc + shift + case $sub in + *'$'* | *%*) refuse "a variable names the gh command" ;; + release) refuse "gh release: releases are the owner's" ;; + pr) + while [ $# -gt 0 ]; do + case $1 in -R | --repo) shift; [ $# -gt 0 ] && shift ;; '' | -*) shift ;; *) break ;; esac + done + lower "${1-}" + [ "$lc" = merge ] || return 0 + [ "$fromstdin" = 1 ] && refuse "gh pr merge under xargs takes arguments the hook cannot see" + for t; do + case $t in --auto | --auto=* | --admin | --admin=*) refuse "gh pr merge $t: merge through the PR by hand" ;; esac + done ;; + api) check_api "$@" ;; + esac +} + +# check_api ARGS: a call that writes (-X/--method other than GET, or fields, which make it a POST) +# on a release or a git ref endpoint, or on one the hook cannot see, is refused. The endpoint is the +# first argument that is not an option or an option's value. +check_api() { + method= fields= ep= target= hidden= + while [ $# -gt 0 ]; do + t=$1 + shift + case $t in + -X | --method) method=${1-}; [ $# -gt 0 ] && shift ;; + -X*) method=${t#-X} ;; + --method=*) method=${t#*=} ;; + -f | -F | --field | --raw-field | --input) fields=1; [ $# -gt 0 ] && shift ;; + -f* | -F* | --field=* | --raw-field=* | --input=*) fields=1 ;; + -H | --header | -q | --jq | -t | --template | -p | --preview | --hostname | --cache) + [ $# -gt 0 ] && shift ;; + -*) ;; + *) [ -n "$ep" ] || ep=$t ;; + esac + done + case $ep in *'$'* | *%*) hidden=1 ;; esac + lower "$ep" + case $lc in *releases* | *git/refs*) target=1 ;; esac + lower "$method" + m=$lc + if [ -z "$m" ]; then + if [ -n "$fields" ]; then m=post; else m=get; fi + fi + case $m in get | head) return 0 ;; esac + [ "$fromstdin" = 1 ] && refuse "gh api under xargs takes arguments the hook cannot see" + [ -n "$target$hidden" ] && refuse "gh api $m on a release or a git ref" + return 0 +} + +main() { + depth=0 fromstdin=0 gopts= + if ! json_string command; then + mentions "$input" && refuse "the hook could not read this command, and it mentions a push, a tag, a release, a merge or the API" + exit 0 + fi + cmd=$val + # The decoding below must give back what the shell will see. It reads a \u escape other than + # & < > ' as a space, and a backspace or form feed too, while the shell keeps the character in a + # word: a # after it is no comment, and escapes can spell a guarded word. Bash also keeps a CR in + # a word, where PowerShell ends a line. Claude Code escapes only rare control characters this + # way, so each such escape is refused; in the cwd, every escape but a quote or a slash is. + # Escaped backslashes are set aside first. A raw separator byte, which no valid JSON holds, + # would decode as a backslash: refuse it too. + case $input in *"$soh"*) refuse "the input holds a raw control character" ;; esac + use_lang sh + json_string tool_name && [ "$val" = PowerShell ] && use_lang ps + cwd= + json_string cwd && cwd=$val + case $cmd$cwd in + *\\*) + rest=$(printf '%s' "$cmd" | sed -e 's/\\\\//g' -e 's/\\u0026//g' -e 's/\\u0027//g' -e 's/\\u003[cCeE]//g') + case $rest in *\\[ubf]*) refuse "the command holds an escape the hook cannot read faithfully" ;; esac + case $lang$rest in sh*\\r*) refuse "the command holds a carriage return, which Bash keeps in a word" ;; esac + rest=$(printf '%s' "$cwd" | sed -e 's/\\\\//g' -e 's|\\["/]||g') + case $rest in *\\*) refuse "the cwd holds an escape the hook cannot read faithfully" ;; esac + ;; + esac + case $cwd$cmd in + *\\*) + # Both through one sed; the x keeps a trailing empty line. + out=$(printf '%s\n%s\n' "$cwd" "$cmd" | unescape; printf x) + out=${out%x} + out=${out%"$nl"} + dir=${out%%"$nl"*} cmd=${out#*"$nl"} ;; + *) dir=$cwd ;; + esac + [ -n "$dir" ] || dir=$PWD + check_cmd "$cmd" + exit 0 +} + +# Nothing guarded can run without one of these words: the common path ends here, before any other +# process starts. +if json_string command; then + case $val in + *[Gg][Ii][Tt]* | *[Gg][Hh]* | *[Pp][Uu][Ss][Hh]* | *[Tt][Aa][Gg]* | *[Rr][Ee][Ll][Ee][Aa][Ss][Ee]* | \ + *[Mm][Ee][Rr][Gg][Ee]* | *[Pp][Ww][Ss][Hh]* | *[Pp][Oo][Ww][Ee][Rr][Ss][Hh][Ee][Ll][Ll]* | *\\u*) ;; + *) exit 0 ;; + esac +fi +# The word separator is \037, not \001: bash 3.2, macOS's sh, uses \001 internally and drops it +# from IFS and expansions, so words never split and a git push went unseen. +soh=$(printf '\037') +# A failure of the hook itself fails closed for the commands it guards, open for the rest. +(main) +status=$? +case $status in 0 | 2) exit "$status" ;; esac +mentions "$input" || exit 0 +printf 'push-guard: the hook failed (status %s) on a command that mentions a push, a tag, a release, a merge or the API. Hand over the command.\n' "$status" >&2 +exit 2 diff --git a/.claude/hooks/push-guard.test.sh b/.claude/hooks/push-guard.test.sh new file mode 100644 index 0000000..a74fd66 --- /dev/null +++ b/.claude/hooks/push-guard.test.sh @@ -0,0 +1,360 @@ +#!/bin/sh +# Checks push-guard.sh on the spellings it must refuse (exit 2, one line on stderr) and the ones it +# must let through (exit 0). Each case is the hook's JSON input; commands are written JSON-escaped. +set -u +hook="$(cd "$(dirname "$0")" && pwd)/push-guard.sh" +fail() { printf 'push-guard.test: %s\n' "$1" >&2; exit 1; } +tmp=$(mktemp -d) || fail "mktemp failed" +trap 'rm -rf "$tmp"' EXIT + +# The owner's own git configuration must not change the outcome. +GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL="$tmp/gitconfig" +export GIT_CONFIG_NOSYSTEM GIT_CONFIG_GLOBAL +: > "$tmp/gitconfig" +g() { git -c user.name=t -c user.email=t@example.com "$@"; } + +# onmain: on main. onfeat: on my-branch, with a local tag v1.0 and a known remote branch +# old-feature. upmain: on feat, whose push destination is origin/main (push.default=upstream). +for r in onmain onfeat upmain; do + mkdir "$tmp/$r" && g -C "$tmp/$r" init -q && g -C "$tmp/$r" checkout -q -b main && + g -C "$tmp/$r" commit -q --allow-empty -m init && + g -C "$tmp/$r" remote add origin https://example.invalid/r.git || fail "setting up $r failed" +done +g -C "$tmp/onfeat" checkout -q -b my-branch && g -C "$tmp/onfeat" tag v1.0 && + g -C "$tmp/onfeat" update-ref refs/remotes/origin/old-feature HEAD && + g -C "$tmp/onfeat" config alias.st status && + g -C "$tmp/onfeat" config alias.ship "push origin main" && + g -C "$tmp/onfeat" config alias.yolo "!git push --force origin my-branch" || fail "setting up onfeat failed" +g -C "$tmp/upmain" checkout -q -b feat && g -C "$tmp/upmain" update-ref refs/remotes/origin/main HEAD && + g -C "$tmp/upmain" config branch.feat.remote origin && + g -C "$tmp/upmain" config branch.feat.merge refs/heads/main && + g -C "$tmp/upmain" config push.default upstream || fail "setting up upmain failed" +onmain=$tmp/onmain onfeat=$tmp/onfeat upmain=$tmp/upmain +# onfeat also knows old-feature on a remote named up/stream, and has aliases deep1 to deep7, each +# naming the next. detached: on no branch. +g -C "$onfeat" update-ref refs/remotes/up/stream/old-feature HEAD || fail "setting up up/stream failed" +for i in 1 2 3 4 5 6; do + g -C "$onfeat" config "alias.deep$i" "deep$((i + 1))" || fail "setting up the aliases failed" +done +g -C "$onfeat" config alias.deep7 status || fail "setting up the aliases failed" +detached=$tmp/detached +g clone -q "$onmain" "$detached" && g -C "$detached" checkout -q --detach || fail "setting up detached failed" +# spaced: on feat, in a directory whose name has a space. +spaced="$tmp/with space" +mkdir "$spaced" && g -C "$spaced" init -q && g -C "$spaced" checkout -q -b feat && + g -C "$spaced" commit -q --allow-empty -m init || fail "setting up spaced failed" + +n=0 +# run WANT COMMAND [CWD [TOOL]]: COMMAND is already JSON-escaped. +run() { + n=$((n + 1)) + printf '{"session_id":"t","transcript_path":"t","cwd":"%s","hook_event_name":"PreToolUse","tool_name":"%s","tool_input":{"command":"%s","description":"d"}}' \ + "${3:-$onfeat}" "${4:-Bash}" "$2" | sh "$hook" 2>"$tmp/err" + expect "$1" $? "$2" +} +# raw WANT INPUT: INPUT is fed as it is, for the cases where the JSON itself is unusual. +raw() { + n=$((n + 1)) + printf '%s' "$2" | sh "$hook" 2>"$tmp/err" + expect "$1" $? "$2" +} +expect() { + [ "$2" -eq "$1" ] || fail "$3: exit $2, want $1 (stderr: $(cat "$tmp/err"))" + if [ "$1" -eq 2 ]; then + [ "$(grep -c '' "$tmp/err")" -eq 1 ] || fail "$3: the reason is not one line: $(cat "$tmp/err")" + fi +} +refused() { run 2 "$@"; } +allowed() { run 0 "$@"; } + +# Pushes whose destination is main. +refused 'git push' "$onmain" +refused 'git push origin' "$onmain" +refused 'git push -u origin' "$onmain" +refused 'git push --set-upstream origin' "$onmain" +refused 'git push origin HEAD' "$onmain" +refused 'git push origin @' "$onmain" +refused 'git push origin main' +refused 'git push origin MAIN' +refused 'git push origin \"main\"' +refused "git push origin 'main'" +refused 'git push origin HEAD:refs/heads/main' +refused 'git push origin HEAD:main' +refused 'git push origin my-branch:main' +refused 'git push origin :main' +refused 'git push origin refs/heads/main' +refused 'git push origin heads/main' +refused 'git push origin --delete main' +refused 'git push origin main 2>&1' +refused 'git push' "$upmain" +refused 'git -c push.default=matching push' +refused "git -C $onmain push" +refused "git --git-dir=$onmain/.git push" +refused "cd $onmain && git push" +refused 'git push origin :' +refused "git push origin 'refs/heads/*:refs/heads/*'" +# Everything at once, or tags. +refused 'git push --mirror origin' +refused 'git push --all origin' +refused 'git push --branches origin' +refused 'git push --tags' +refused 'git push origin --tags' +refused 'git push --follow-tags origin my-branch' +refused 'git push origin tag v1.0' +refused 'git push origin refs/tags/v1.0' +refused 'git push origin v1.0' +refused 'git push origin --delete v1.0' +# Force pushes of any kind. +refused 'git push --force origin my-branch' +refused 'git push -f origin my-branch' +refused 'git push origin my-branch -f' +refused 'git push -uf origin my-branch' +refused 'git push origin +my-branch' +refused 'git push origin +HEAD:my-branch' +# What a bare push sends is decided by configuration, or cannot be told. +refused 'git -c remote.origin.push=HEAD:refs/heads/x push' +refused 'git -c remote.origin.mirror=true push' +refused 'git push' "$detached" +# Deleting is let through only for a branch the remote is known to have. +refused 'git push origin --delete no-such-branch' +refused 'git push up/stream --delete old-feature' +refused 'git push --repo=https://example.invalid/r.git --delete old-feature' +refused 'git push --force-with-lease origin my-branch' +refused 'git push --force-with-lease=my-branch:abc origin my-branch' +refused 'git push --force-if-includes origin my-branch' +refused 'git push --forc origin my-branch' +# Destinations the hook cannot see are refused. +refused 'git push origin $BRANCH' +refused 'git push origin $(git branch --show-current)' +refused 'git push origin `git branch --show-current`' +refused 'echo main | xargs git push origin' +refused 'git $sub origin main' +refused '$git push origin main' +refused 'eval $cmd; echo pushed' +refused 'iex $cmd; echo pushed' . PowerShell +refused 'echo 5 | xargs gh pr merge' +refused 'echo x | xargs gh api -X POST repos/o/r/pulls' +refused 'gh api -X POST repos/o/r/$path' +# Tags and releases. +refused 'git tag v1.1' +refused 'git tag -a v1.1 -m release' +refused 'git tag -l' +refused 'gh release create v1.0' +refused 'gh release list' +refused 'gh pr merge 5 --auto --merge' +refused 'gh pr merge 5 --merge --admin' +refused 'gh -R o/r pr merge 5 --admin' +refused 'gh pr -R o/r merge 5 --auto' +refused 'gh api -X POST repos/o/r/releases' +refused 'gh api --method PATCH repos/o/r/releases/1' +refused 'gh api --method=PUT repos/o/r/git/refs/heads/main' +refused 'gh api -XDELETE repos/o/r/git/refs/heads/main' +refused 'gh api repos/o/r/git/refs -f ref=refs/heads/main -f sha=abc' +refused 'gh api repos/o/r/releases -F tag_name=v1' +refused 'gh api repos/o/r/releases --input body.json' +refused 'gh api -H \"Accept: application/json\" -X POST repos/o/r/releases' +refused 'gh api --jq .ref -f ref=refs/heads/x repos/o/r/git/refs' +# Compound commands and wrappers. +refused 'git status && git push origin main' +refused 'git status || git push origin main' +refused 'git status; git push origin main' +refused 'git status | git push origin main' +refused 'git status\ngit push origin main' +refused 'git status\r\ngit tag v2' +refused 'git push origin \\\nmain' +refused 'git push origin `\r\nmain' . PowerShell +refused 'GIT_TRACE=1 git push origin main' +refused 'env GIT_TRACE=1 git push origin main' +refused 'sudo git push origin main' +refused 'GIT push origin main' +refused "bash -c 'git push origin main'" +refused 'sh -c \"git tag v2\"' +refused 'bash -lc \"git status; git push --force origin my-branch\"' +refused 'powershell -NoProfile -Command \"git push --force origin my-branch\"' . PowerShell +refused "pwsh -c 'gh release create v2'" . PowerShell +refused 'powershell -EncodedCommand ZwBpAHQA' . PowerShell +refused 'cmd /c git push origin main' . PowerShell +refused '& git push origin main' . PowerShell +refused '& \"C:\\Program Files\\Git\\cmd\\git.exe\" push origin main' . PowerShell +refused 'strace -f /usr/bin/git push origin main' +refused 'git push origin main; if ($?) { echo ok }' . PowerShell +refused '$out = git push origin main' . PowerShell +refused "Start-Process git -ArgumentList 'push','origin','main'" . PowerShell +refused 'Invoke-Expression \"git tag v3\"' . PowerShell +# Command substitutions run, in double quotes too; a comment ends at the line's end. +refused 'echo \"$(git push origin main)\"' +refused 'echo \"`git tag v2`\"' +refused 'git commit -m \"x $(date) y\" && git push origin main' +refused 'git push origin \"$(git branch --show-current)\"' +refused "git commit -m 'a; b' && git push origin main" +refused "git status # don't\\ngit push origin main" +refused 'echo ${#x}; git push origin main' +refused 'cd \"C:\\x\\\"; git push origin main' . PowerShell +# A heredoc's body is text up to its word; what follows it is read again. +refused 'cat <body.md\nhi\nEOF\ngit push origin main' +refused 'cat <&1 | tail -5' +allowed 'git status' +allowed 'git st' +allowed 'git log --oneline -5' +allowed 'git checkout main && git pull origin main' +allowed 'git fetch --tags origin' +allowed 'git log --grep=tag' +allowed 'git commit -m \"explain why we never git push to main\"' +allowed 'grep -rn \"git push origin main\" .' +allowed 'echo git push origin main' +# Quoted text is not a command. +allowed 'git commit -m \"docs: guard (git tag, git push)\"' +allowed 'gh pr create --title x --body \"refuses \\`git tag\\`\"' +allowed 'echo \"ok; git push origin main\"' +allowed 'git commit -m \"line one\nline two; git push origin main\"' +allowed "bash -c \\\"git commit -m 'x; git push origin main'\\\"" +allowed 'echo \"a`; git tag b\"' . PowerShell +allowed "git commit -F - <x\n\tgit tag v1\n\tEOF\ngit push origin main' +refused 'cat < ' is refused. +refused 'echo\u001f#; git push origin main' +refused 'echo\u0001#; git push origin main' +refused 'echo\u007f#; git push origin main' +refused 'echo\u263a#; git push origin main' +refused 'echo\ud83d\ude00#; git push origin main' +refused 'git \u0070ush origin main' +refused 'echo\u001f#; ls' +allowed 'ls \u0026\u0026 echo \u0027a\u0027 \u003e out \u003C in' +# A lone carriage return ends a line in PowerShell, so a # after it starts a comment there. +refused 'git push origin main\r# comment' . PowerShell +allowed 'git status\r\ngit log' . PowerShell +# The cwd is decoded too: an escape the hook cannot decode faithfully there is refused, an escaped +# backslash (a Windows path) is not, and neither is a literal backslash and u in the command. +raw 2 '{"cwd":"/tmp/a\u0001b","tool_input":{"command":"git push"}}' +grep -q 'the cwd holds' "$tmp/err" || fail "the cwd case was refused for another reason: $(cat "$tmp/err")" +raw 2 '{"cwd":"/tmp/a\nb","tool_input":{"command":"git push"}}' +grep -q 'the cwd holds' "$tmp/err" || fail "the cwd case was refused for another reason: $(cat "$tmp/err")" +raw 0 '{"cwd":"C:\\new\\x","tool_input":{"command":"git status"}}' +allowed 'echo \\u263a; git status' +# Bash keeps a CR inside a word, so for Bash any CR is refused; a backspace or form feed is +# refused for both shells. +refused 'echo\r#; git push origin main' +refused 'git push origin \\\r\nmain' +refused 'echo\b#; git push origin main' . PowerShell +refused 'echo\f#; git push origin main' +# A cwd with any escape but a quote or a slash is refused: a tab there is no space. +raw 2 '{"cwd":"/tmp/a\tb","tool_input":{"command":"git push"}}' +grep -q 'the cwd holds' "$tmp/err" || fail "the cwd case was refused for another reason: $(cat "$tmp/err")" +raw 2 '{"cwd":"/tmp/a\fb","tool_input":{"command":"git push"}}' +grep -q 'the cwd holds' "$tmp/err" || fail "the cwd case was refused for another reason: $(cat "$tmp/err")" +# A raw separator byte would turn into a backslash when the hook decodes escapes. +us=$(printf '\037') +raw 2 "{\"tool_input\":{\"command\":\"echo a$us; git push origin main \\u0026\"}}" +# A failure of the hook itself fails closed for a command that mentions a push: here git kills it. +mkdir "$tmp/shim" && printf '#!/bin/sh\nkill -TERM "$PPID"\n' > "$tmp/shim/git" && + printf '#!/bin/sh\nexit 0\n' > "$tmp/shim/probe" && chmod +x "$tmp/shim/git" "$tmp/shim/probe" || + fail "setting up the git shim failed" +# The shim needs an executable temporary directory; the test containers mount /tmp noexec, and there this case is skipped. +if "$tmp/shim/probe" 2>/dev/null; then + n=$((n + 1)) + printf '{"cwd":"%s","tool_name":"Bash","tool_input":{"command":"git push origin my-branch"}}' "$onfeat" | + PATH="$tmp/shim:$PATH" sh "$hook" 2>"$tmp/err" + # The shell may add its own line for the signal, so only the exit and the reason are checked. + [ $? -eq 2 ] && grep -q 'the hook failed' "$tmp/err" || + fail "git push under a git that kills the hook: not refused as a failure: $(cat "$tmp/err")" +else + echo "push-guard.test: skipped the killed-git case: $tmp is not executable" +fi +# A Windows cwd, backslashes JSON-escaped, as Claude Code sends it on Windows. +if command -v cygpath >/dev/null 2>&1; then + winmain=$(cygpath -w "$onmain" | sed 's/\\/\\\\/g') + refused 'git push' "$winmain" + winfeat=$(cygpath -w "$onfeat" | sed 's/\\/\\\\/g') + allowed 'git push' "$winfeat" + winspaced=$(cygpath -m "$spaced") + allowed "cd \\\"$winspaced\\\" && git push origin feat" +fi + +# settings.json runs the hook before every Bash and PowerShell call. +settings="$(dirname "$hook")/../settings.json" +tr -d ' \t\r\n' < "$settings" | + grep -qF '"PreToolUse":[{"matcher":"Bash|PowerShell","hooks":[{"type":"command","command":"sh\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/push-guard.sh"' || + fail "settings.json does not run push-guard.sh before Bash and PowerShell calls" + +echo "push-guard.test: ok ($n cases)" diff --git a/.claude/settings.json b/.claude/settings.json index db8524c..00d6f8e 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -185,6 +185,18 @@ ] }, "hooks": { + "PreToolUse": [ + { + "matcher": "Bash|PowerShell", + "hooks": [ + { + "type": "command", + "command": "sh \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/push-guard.sh", + "timeout": 20 + } + ] + } + ], "PostToolUse": [ { "matcher": "Edit|Write|MultiEdit", diff --git a/.claude/skills/check/SKILL.md b/.claude/skills/check/SKILL.md index 4390f3a..a4561e0 100644 --- a/.claude/skills/check/SKILL.md +++ b/.claude/skills/check/SKILL.md @@ -26,6 +26,10 @@ docker compose -f docker/compose.yaml run --rm --no-deps mcp-tests docker compose -f docker/compose.yaml --profile mcp --profile mcp-fetch down ``` +The `python-tests` step also runs both hooks' tests (`tests/python/test_repo_rules_hook.py` and +`tests/python/test_push_guard_hook.py`, which runs `.claude/hooks/push-guard.test.sh`); the containers +mount `/tmp` noexec, so the guard's killed-git case is skipped there and runs only on a host. + This runs the PHP steps on compose's default image, PHP 8.5. CI runs them on 8.2, 8.3, 8.4 and 8.5 and first checks that the image's PHP minor is the one the matrix expects; to reproduce another branch, set `BRIDGE_PHP_IMAGE` to its pinned image from `.github/workflows/tests.yml` and rerun the PHP and diff --git a/AGENTS.md b/AGENTS.md index 8a603a1..c324db2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,7 @@ and `docs/testing.md` are the guides. Everything builds, tests and downloads ins | `examples/` | Local autoloader, the CLI example, the FrankenPHP route and Caddyfile. | | `scripts/` | Model fetcher, benchmark, NER measurement, MCP stdio launchers, the LLM evaluation runner. | | `tests/php/` | `run.php` (offline contract checks), `integration.php` (the client against the real worker and broken peers over HTTP), `model.php` (ONNX smoke). | -| `tests/python/` | The worker's unittest suite, including the workflows' runner routing and the post-edit hook. | +| `tests/python/` | The worker's unittest suite, including the workflows' runners and both hooks (the pre-call push guard's own cases are in `.claude/hooks/push-guard.test.sh`). | | `tests/mcp/` | The MCP server's suite, offline against hash-pinned wheels; its fixtures and LLM evaluations. | | `tests/*.py` | Cross-boundary tests: `integration.py` (through the FrankenPHP worker), `model_integration.py`, `mcp_model_probe.py`, and `hostile_server.py`, a broken peer. | | `docker/compose.yaml` | Every service, profile and limit. | @@ -77,7 +77,8 @@ and update both together. Path-scoped detail is in `.claude/rules/`. independent review ran and its blocking findings are fixed, a security read of the diff has nothing open, and the head being merged is the commit all of that ran on. Merge `main` in only for a conflict, or once before those final checks and review. What could not be tested is said in the PR and waits for - the owner. Never `--auto` or `--admin`, never push to `main`: hand over the command. + the owner. Never `--auto` or `--admin`, never push to `main`: hand over the command (the pre-call + hook `.claude/hooks/push-guard.sh` refuses these in every spelling it can read, and what it cannot). - **Reviews are text, commits are changes.** A finding is blocking when it is a defect this branch brings in, a missing test for what it changes, a broken house rule or a security hole; fix it with the smallest change. Everything else (problems already on `main`, improvements, style) becomes an issue linked from diff --git a/CLAUDE.md b/CLAUDE.md index ef1cb7b..15728eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,6 +3,11 @@ Claude-specific: path-scoped rules are in `.claude/rules/` (the PHP client, the worker, the MCP server), `/check` runs what CI runs, and the post-edit hook reports a PHP file without `declare(strict_types=1)` or with tabs, and a worker module that imports a model dependency at top level: fix it before moving on. +The pre-call hook (`push-guard.sh`) reads every Bash and PowerShell command and refuses a push that +would land on `main` or a tag in any spelling (a bare `git push` on `main`, `HEAD`, aliases, shells +within shells), a force push, `git tag`, `gh release`, `gh pr merge --auto` or `--admin`, and writes +through `gh api` to releases or refs; when it cannot read a command that mentions one of those, it +refuses too. Those are the owner's steps: hand over the command, do not look for another spelling. Permissions in `.claude/settings.json` allow the exact compose commands CI runs and read-only git and gh, ask before `gh workflow run`, `gh pr create`, `gh pr merge` and the model `fetcher`, and deny pushes to `main`, force pushes and reading `.env`. Your own notes go in `CLAUDE.local.md` (ignored). diff --git a/docs/testing.md b/docs/testing.md index a8dd894..828a647 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -48,7 +48,7 @@ PHP branch support was verified locally on 2026-09-17 on PHP 8.2.33, 8.3.33, 8.4 | Layer | Scope and counting | | --- | --- | -| Python worker | 127 tests: lifecycle, validation and HTTP, process crashes, cancellation, timeouts, retention, allocation/exit races, redaction rules including IPv6, lower-case IBANs and email addresses in any script and their speed on hostile input, NER aggregation, name joining, the second title-cased reading, the uncased model's reading of the whole text and the merge driven by stubs, the fetcher's pins and the model directories an ONNX worker checks for before it starts, batched cross-encoder scoring, the NER measurement's sample and scoring, the workflows' runners, progress bounds, and the model-name table every result reports from | +| Python worker | 139 tests: lifecycle, validation and HTTP, process crashes, cancellation, timeouts, retention, allocation/exit races, redaction rules including IPv6, lower-case IBANs and email addresses in any script and their speed on hostile input, NER aggregation, name joining, the second title-cased reading, the uncased model's reading of the whole text and the merge driven by stubs, the fetcher's pins and the model directories an ONNX worker checks for before it starts, batched cross-encoder scoring, the NER measurement's sample and scoring, the workflows' runners, both Claude Code hooks (the post-edit rules, and the pre-call push guard through its own cases), progress bounds, and the model-name table every result reports from | | MCP server | 119 tests, offline with hash-pinned wheels: the protocol client against an in-process worker, every tool through the SDK's in-memory client with test tasks enabled, the entry point as a subprocess, result validators on synthetic data, the index builder (chunking, the format and each corruption it refuses, building against the worker, caching, its command line) `bridge_search` (the model gate, path confinement, reloads, rerank on and off), the reachability of every answer in the LLM evaluation, that a default search sees only part of its corpus and that its two-step question takes two steps, that the demo worker reaches none of the semantic evaluation's answers, that a tool waits out a full worker, before each of a search's jobs and within one budget, and says so when it stays full, the evaluation runner's command, isolation checks, scoring and per-backend launcher and index check without starting `claude`, a check that the server's model names match the worker's, that every path parameter says where it resolves and every position in a result says what it counts, and a check that every timing claim in a tool description is a measured median | | PHP contracts + HTTP | 134 checks per PHP version; this **includes** the 101 contract checks, not 134 + 101 | | FrankenPHP | 12 jobs submitted by six concurrent test threads through a reused PHP worker, a 512-document `top_k` request, embedding and redaction round trips, and invalid-input, missing-job and multibyte boundary checks | diff --git a/tests/python/test_push_guard_hook.py b/tests/python/test_push_guard_hook.py new file mode 100644 index 0000000..c7e732d --- /dev/null +++ b/tests/python/test_push_guard_hook.py @@ -0,0 +1,40 @@ +"""The pre-call hook in .claude/hooks/push-guard.sh refuses, before a Bash or PowerShell command runs, a push that +would land on main or a tag, a force push, git tag, gh release, gh pr merge --auto or --admin, and a write through +gh api to releases or refs, in every spelling it can read; what it cannot read and mentions one of those, it refuses +too. Its cases live beside it in push-guard.test.sh, as in the owner's other repositories; this runs them, and holds +the hook's wiring in settings.json in place. +""" + +import json +import shutil +import subprocess +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +HOOK = REPO / ".claude" / "hooks" / "push-guard.sh" +CASES = REPO / ".claude" / "hooks" / "push-guard.test.sh" +SETTINGS = REPO / ".claude" / "settings.json" + + +class PushGuardHookTests(unittest.TestCase): + def test_the_hook_and_its_cases_exist_and_are_lf(self): + for path in (HOOK, CASES): + with self.subTest(file=path.name): + self.assertTrue(path.is_file()) + self.assertNotIn(b"\r", path.read_bytes()) + + def test_settings_run_the_hook_before_every_bash_and_powershell_call(self): + hooks = json.loads(SETTINGS.read_text(encoding="utf-8"))["hooks"]["PreToolUse"] + commands = [hook["command"] for entry in hooks if entry["matcher"] == "Bash|PowerShell" for hook in entry["hooks"]] + self.assertEqual(commands, ['sh "$CLAUDE_PROJECT_DIR"/.claude/hooks/push-guard.sh']) + + def test_the_hooks_own_cases_pass(self): + # Several hundred cases, each a sh and a few git processes: seconds on Linux, minutes under Git Bash. + result = subprocess.run([shutil.which("sh") or "sh", str(CASES)], capture_output=True, text=True, timeout=900) + self.assertEqual(result.returncode, 0, result.stderr or result.stdout) + self.assertRegex(result.stdout, r"push-guard\.test: ok \(\d+ cases\)") + + +if __name__ == "__main__": + unittest.main() From e1ad78a97e6f37f626d81a353b4d894826c32e05 Mon Sep 17 00:00:00 2001 From: Cristian Girlea Date: Thu, 15 Oct 2026 10:39:27 +0300 Subject: [PATCH 2/2] docs: say what the push guard does not read The review found AGENTS.md and CLAUDE.md promising that the hook refuses a push in every spelling; it does not open script files or other programs, by design, so they now say so and name the ruleset on main as the guard. --- AGENTS.md | 3 ++- CLAUDE.md | 10 ++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c324db2..ca6d423 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,7 +78,8 @@ and update both together. Path-scoped detail is in `.claude/rules/`. open, and the head being merged is the commit all of that ran on. Merge `main` in only for a conflict, or once before those final checks and review. What could not be tested is said in the PR and waits for the owner. Never `--auto` or `--admin`, never push to `main`: hand over the command (the pre-call - hook `.claude/hooks/push-guard.sh` refuses these in every spelling it can read, and what it cannot). + hook `.claude/hooks/push-guard.sh` refuses these in the spellings it can read and an unreadable command + that mentions them; script files and other programs are not read: the ruleset on `main` is the guard). - **Reviews are text, commits are changes.** A finding is blocking when it is a defect this branch brings in, a missing test for what it changes, a broken house rule or a security hole; fix it with the smallest change. Everything else (problems already on `main`, improvements, style) becomes an issue linked from diff --git a/CLAUDE.md b/CLAUDE.md index 15728eb..95d7cdd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,10 +4,12 @@ Claude-specific: path-scoped rules are in `.claude/rules/` (the PHP client, the `/check` runs what CI runs, and the post-edit hook reports a PHP file without `declare(strict_types=1)` or with tabs, and a worker module that imports a model dependency at top level: fix it before moving on. The pre-call hook (`push-guard.sh`) reads every Bash and PowerShell command and refuses a push that -would land on `main` or a tag in any spelling (a bare `git push` on `main`, `HEAD`, aliases, shells -within shells), a force push, `git tag`, `gh release`, `gh pr merge --auto` or `--admin`, and writes -through `gh api` to releases or refs; when it cannot read a command that mentions one of those, it -refuses too. Those are the owner's steps: hand over the command, do not look for another spelling. +would land on `main` or a tag in the spellings it can read (a bare `git push` on `main`, `HEAD`, +aliases, shells within shells), a force push, `git tag`, `gh release`, `gh pr merge --auto` or +`--admin`, and writes through `gh api` to releases or refs; a command it cannot read that mentions one +of those is refused too. It does not open script files or other programs, so it is a second layer: +the ruleset on `main` is the guard. Those are the owner's steps: hand over the command, do not look +for another spelling. Permissions in `.claude/settings.json` allow the exact compose commands CI runs and read-only git and gh, ask before `gh workflow run`, `gh pr create`, `gh pr merge` and the model `fetcher`, and deny pushes to `main`, force pushes and reading `.env`. Your own notes go in `CLAUDE.local.md` (ignored).