diff --git a/.claude/settings.json b/.claude/settings.json index eee067c4a..c44efda52 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -18,6 +18,15 @@ "command": "~/.local/bin/graphify hook-guard read" } ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/tools/agent-guard/git_guard.py" + } + ] } ] }, diff --git a/.codex/hooks.json b/.codex/hooks.json index 1d587b120..af56321f0 100644 --- a/.codex/hooks.json +++ b/.codex/hooks.json @@ -9,6 +9,15 @@ "command": "~/.local/bin/graphify hook-check" } ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "\"$(git rev-parse --show-toplevel)\"/tools/agent-guard/git_guard.py" + } + ] } ] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1cf0cb8b3..56908021d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,13 +90,15 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v7 - # Pure Node, no toolchain setup needed. + # Node and the runner's Python 3, no toolchain setup needed. - name: Run the classifier self-tests run: node --test .github/scripts/changed-paths.test.mjs - name: Run the image-scan issue logic self-tests run: node --test .github/scripts/image-scan-issues.test.mjs - name: Run the release-line decision self-tests run: node --test .github/scripts/release-line.test.mjs + - name: Run the agent git-guard table test + run: python3 tools/agent-guard/git_guard_test.py changes: name: Classify the changed paths diff --git a/AGENTS.md b/AGENTS.md index 1e5471424..63045470e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,6 +123,7 @@ CI rejects a PR when a **production** dependency has a known **high+** advisory: - `origin` = GitHub (`github.com/mforce/cluckwork`); `gitea` = backup mirror. Use `gh` for PRs. - **`main` is protected** — work on a branch, push it, and open a PR; never commit to `main`. Use `feat/…`, `chore/…`, `docs/…`, or `spec/…` branch names. PRs squash-merge. +- **A hook refuses forbidden git commands from agents.** `tools/agent-guard/git_guard.py` runs before every Bash call in Claude Code (`.claude/settings.json`) and Codex (`.codex/hooks.json`). A command that mentions `git push`, `git commit` or a PR merge, or could spell one through `$`, a backtick or a glob, must be one simple command, optionally after `cd &&`; chains, pipes, subshells, `$(…)`, variables and wrappers are refused, not parsed. In that form it blocks `gh pr merge`, a non-GET `gh api` call to `pulls//merge`, a literal `mergePullRequest` GraphQL call, `gh alias set`, `src:dst` and `+` refspecs, `--force`/`-f`/`--mirror`/`--all`/`--prune`, pushes whose source or configured destination is `main`, and `git commit` on `main`. It does not see gh or git aliases defined earlier, a merge query read from a file, commits made by merge, rebase, cherry-pick or pull, a push destination the remote resolves as a symbolic ref to `main`, or a Codex `workdir`. `git_guard_test.py` is its table test in CI. Codex skips a new or changed hook until it is trusted in `/hooks`. - **The PR title is the release note.** It is always the squashed commit subject (`squash_merge_commit_title=PR_TITLE`), which release-please parses for the changelog. A typo or non-conventional prefix silently drops the entry. - **The version comes from the branch, not from commit types (#351).** Every release from `main` bumps the middle digit (`"versioning": "always-bump-minor"` in `release-please-config.json`); every release from a hotfix line `release/vX.Y.x` bumps the last digit (`always-bump-patch` in that line's own copy). The first digit moves only by hand, with `Release-As: 2.0.0` added **at merge time** (`gh pr merge --squash --body "Release-As: 2.0.0"` or the squash dialog's extended description): squash commits here carry the PR title and an empty body (`squash_merge_commit_message=BLANK`), so a footer in a branch commit or the PR description is dropped. Commit types choose only the changelog section. A `!` in the PR title marks a breaking change there and moves no digit; a `BREAKING CHANGE:` footer is dropped like any other body text. - **A commit-body parse error drops the whole commit** (no changelog entry, green run). A body reaches `main` only as a description typed at merge time; in one, never start a line with `word(` that has another `(` inside it. `.githooks/commit-msg` checks branch commits, which no longer reach `main`, and no local hook sees a **PR title** or merge-time text. diff --git a/tools/agent-guard/git_guard.py b/tools/agent-guard/git_guard.py new file mode 100755 index 000000000..61510abae --- /dev/null +++ b/tools/agent-guard/git_guard.py @@ -0,0 +1,390 @@ +#!/usr/bin/env python3 +"""PreToolUse hook for Claude Code and Codex: blocks agent git commands that take +a merge away from the maintainer or rewrite main (AGENTS.md, "Git / PR workflow"). + +Both harnesses send one JSON object on stdin with the shell string in +`tool_input.command` and the session directory in `cwd`; both block the call on +exit 2 and show stderr to the agent. + +A command that mentions a git write (`git push`, `git commit`) or a PR merge must +be one simple command, optionally after `cd &&`. Anything else is refused +rather than parsed, so the agent re-runs the write on its own. + +Not covered: gh aliases defined earlier, a GraphQL merge whose query comes from a +file, git aliases from config, other commands that create commits (merge, rebase, +cherry-pick, pull), a verb spelled by brace or extglob expansion (`pu{s..s}h`, +`p@(u)sh`), a configured push destination that the remote resolves as a +symbolic ref to main (no remote lookups), and Codex `exec_command` calls whose +`workdir` differs from the session `cwd` (Codex does not send `workdir`). +""" +import json +import os +import re +import shlex +import subprocess +import sys +import urllib.parse + +MAIN = "main" +MAIN_REFS = {MAIN, f"heads/{MAIN}", f"refs/heads/{MAIN}"} +GIT_FALSE = {"false", "no", "off", "0", ""} +MENTIONS_WRITE = re.compile(r"\bgit\b.*\b(push|commit)\b|\bgh\b.*\b(merge|alias)\b|mergePullRequest|AutoMerge", re.S) +MENTIONS_TOOL_OR_VERB = re.compile(r"\b(git|gh|push|commit|merge|alias)\b|mergePullRequest|AutoMerge") +EXPANSIONS = re.compile(r"[$`*?\[]") # can spell a program or verb the text check cannot read +ALLOWED_SUFFIX = re.compile(r"[ \t]+(2>&1|2>/dev/null)$") +INERT_PROGRAMS = {"cat", "echo", "egrep", "fgrep", "grep", "head", "ls", "printf", "tail", "wc"} +GIT_GLOBAL_FLAGS = {"--no-pager", "-P", "-p", "--paginate", "--no-optional-locks", "--literal-pathspecs", "--no-replace-objects"} +GIT_GLOBAL_VALUES = {"-C", "-c", "--git-dir", "--work-tree"} +PUSH_LONG_OPTIONS = [ + "all", "atomic", "branches", "delete", "dry-run", "exec", "follow-tags", "force", "force-if-includes", + "force-with-lease", "ipv4", "ipv6", "mirror", "porcelain", "progress", "prune", "push-option", "quiet", + "receive-pack", "recurse-submodules", "repo", "set-upstream", "signed", "tags", "thin", "verbose", "verify", +] +GH_API_VALUES = {"-X", "--method", "-H", "--header", "-f", "--raw-field", "-F", "--field", "--input", + "-q", "--jq", "-t", "--template", "--hostname", "-p", "--preview", "--cache"} +GH_API_FLAGS = {"--paginate", "-i", "--include", "--silent", "--verbose", "--slurp"} +GH_API_BODY = {"-f", "--raw-field", "-F", "--field", "--input"} + +PUSH_YOUR_BRANCH = "Run `git branch --show-current`, then `git push -u origin ` (or plain `git push`)." +MESSAGES = { + "simple": ( + "it contains, or may spell, a git write or PR merge, so it must run as its own simple command: one command, optionally " + "after `cd &&`, with no `;`, `||`, `|`, `&`, newlines, comments, subshells, braces, if/for/while, " + "heredocs, redirections other than a trailing `2>&1` or `2>/dev/null`, `$(…)`, backticks, variables, " + "unquoted globs, env assignments or wrappers (env, command, timeout, sudo, bash -c). " + "Allowed forms: `git push -u origin `, `cd && git commit -m \"\"`. " + "For a multi-line message use `git commit -F ` or repeated `-m`." + ), + "merge": "only the maintainer merges a PR. Report the PR as ready instead.", + "alias": "a gh alias can hide `gh pr merge`. Run the gh command it stands for directly.", + "git-config": "a `-c` override of alias.*, push.*, remote.* or branch.* can change what the command does. " + "Run it without the override.", + "refspec": "a src:dst push can land one branch's commits on another branch (#900). " + PUSH_YOUR_BRANCH, + "force": "a fix is a new commit, never a rewrite of pushed history. Commit the fix and push normally.", + "all": "it pushes or deletes more than your branch (--all, --branches, --prune). " + PUSH_YOUR_BRANCH, + "push-main": "main changes only through a merged PR. Run `git switch -c /`, then push that branch.", + "push-config": "the push configuration (push.default=matching, remote..mirror, or a forced, matching, " + "wildcard or main remote..push) can update main or force. Push to a remote without it.", + "upstream-main": "with push.default=upstream this branch would push to its upstream, main. " + "Run `git branch --unset-upstream`, then `git push -u origin `.", + "commit-main": "main is protected. Run `git switch -c /` first, then commit there.", + "option": "git-guard does not know this option, so it cannot tell what the write does. Drop it or spell it out.", +} + + +class Blocked(Exception): + pass + + +def block(rule, shown): + raise Blocked(f"`{shown}` is blocked: {MESSAGES[rule]}") + + +def has_simple_shape(command): + """True when the only unquoted shell operator is `&&`.""" + quote = None + i = 0 + while i < len(command): + c = command[i] + if c == "\\" and quote != "'": + if command[i + 1 : i + 2] in ("", "\n"): + return False + i += 2 + continue + if quote == "'": + quote = None if c == "'" else quote + elif c in "`$": + return False + elif quote == '"': + quote = None if c == '"' else quote + elif c in "'\"": + quote = c + elif c in "\n;|(){}<>*?[" or (c == "#" and (i == 0 or command[i - 1] in " \t")): + return False + i += 1 + return True # an unclosed quote fails in shlex below + + +def simple_command(command, cwd): + """(argv, cwd) for `[cd &&] [2>&1 | 2>/dev/null]`; block any other shape.""" + command = ALLOWED_SUFFIX.sub("", command) + if not has_simple_shape(command): + block("simple", command) + lex = shlex.shlex(command, posix=True, punctuation_chars="&") + lex.whitespace_split = True + lex.commenters = "" + parts = [[]] + for token in lex: + if token == "&&": + parts.append([]) + else: + parts[-1].append(token) + if len(parts) > 2 or not all(parts) or (len(parts) == 2 and (parts[0][0] != "cd" or len(parts[0]) != 2)): + block("simple", command) + if len(parts) == 2: + cwd = cd_dir(cwd, parts[0][1], command) + argv = parts[-1] + if any(set(token) <= {"&"} for token in argv): + block("simple", command) + return argv, cwd + + +def cd_dir(base, operand, shown): + """Directory `cd ` lands in (bash's default -L resolves `..` by text, as normpath does).""" + searched = os.environ.get("CDPATH") and not operand.startswith("/") and operand.split("/")[0] not in (".", "..") + if operand.startswith(("-", "~")) or searched: + raise Blocked(f"`{shown}` is blocked: where `cd {operand}` lands depends on the shell (an option, `~` or " + "CDPATH). Use `cd /absolute/path &&` or `cd ./relative/path &&`.") + return os.path.normpath(os.path.join(base, operand)) + + +class Repo: + """Read-only git lookups in the directory the command runs in; a failed lookup blocks.""" + + def __init__(self, cwd, options, shown): + if not os.path.isdir(cwd): + raise Blocked(f"`{shown}` is blocked: git-guard cannot find the directory it runs in. " + "Run it from an existing directory, or with `cd &&`.") + self.cwd, self.options, self.shown = cwd, options, shown + + def git(self, *args): + return subprocess.run(["git", *self.options, *args], cwd=self.cwd, capture_output=True, text=True) + + def lookup_failed(self, what, result): + return Blocked(f"`{self.shown}` is blocked: git-guard could not read {what} in {self.cwd} " + f"({result.stderr.strip() or 'exit ' + str(result.returncode)}). Fix that, then retry.") + + def branch(self): + """Checked-out branch from HEAD's full ref (`--short` can say heads/main), or None when detached.""" + result = self.git("symbolic-ref", "--quiet", "HEAD") + if result.returncode not in (0, 1): + raise self.lookup_failed("the current branch", result) + return result.stdout.strip().removeprefix("refs/heads/") or None + + def config(self): + """Effective config, `-c` overrides included: key -> values (None for a bare boolean).""" + result = self.git("config", "--list", "-z") + if result.returncode != 0: + raise self.lookup_failed("git config", result) + entries = {} + for entry in filter(None, result.stdout.split("\0")): + key, _, value = entry.partition("\n") + entries.setdefault(key, []).append(value if "\n" in entry else None) + return entries + + +def long_option(arg, shown): + """Full name of a push long option, resolving git's unambiguous prefixes.""" + name = arg[2:].split("=", 1)[0] + negated = name.startswith("no-") + base = name[3:] if negated else name + matches = [base] if base in PUSH_LONG_OPTIONS else [o for o in PUSH_LONG_OPTIONS if o.startswith(base)] + if len(matches) != 1: + block("option", shown) + return ("no-" if negated else "") + matches[0] + + +def check_push(args, repo): + shown = repo.shown + positional = [] + i = 0 + while i < len(args): + arg = args[i] + i += 1 + if arg == "--": + positional += args[i:] + break + if arg.startswith("--"): + name = long_option(arg, shown) + if name in ("force", "mirror"): + block("force", shown) + if name in ("all", "branches", "prune"): + block("all", shown) + if name in ("repo", "receive-pack", "exec"): + block("option", shown) + if name == "push-option" and "=" not in arg: + i += 1 + elif arg.startswith("-") and len(arg) > 1: + for j, flag in enumerate(arg[1:], start=1): + if flag == "f": + block("force", shown) + if flag == "o": + i += 0 if arg[j + 1 :] else 1 + break + else: + positional.append(arg) + + branch = repo.branch() + config = repo.config() + + def setting(key, default=None): + return (config.get(key) or [default])[-1] + + remotes = {key[len("remote."):].rsplit(".", 1)[0] for key in config if key.startswith("remote.") and key.count(".") > 1} + remote = positional[0] if positional else ( + setting(f"branch.{branch}.pushremote") or setting("remote.pushdefault") or setting(f"branch.{branch}.remote") + or (remotes.pop() if len(remotes) == 1 else "origin")) # git falls back to a sole remote before origin + push_default = (setting("push.default") or "simple").lower() + mirror = config.get(f"remote.{remote}.mirror") + if push_default == "matching" or (mirror and (mirror[-1] is None or mirror[-1].lower() not in GIT_FALSE)): + block("push-config", shown) + for configured in config.get(f"remote.{remote}.push") or []: + destination = (configured or "").split(":")[-1] + if not destination or configured.startswith("+") or "*" in configured or destination in MAIN_REFS: + block("push-config", shown) + + sources = [] + for ref in positional[1:]: + if ref.startswith("+"): + block("force", shown) + if ":" in ref: + block("refspec", shown) + sources.append(branch if ref in ("HEAD", "@") else ref.removeprefix("refs/heads/")) + for source in sources or [branch]: + if source in MAIN_REFS: + block("push-main", shown) + if push_default in ("upstream", "tracking") and setting(f"branch.{source}.merge") in MAIN_REFS: + block("upstream-main", shown) + + +def check_git(args, cwd, shown): + options = [] + i = 0 + while i < len(args) and args[i].startswith("-"): + arg = args[i] + name = arg.split("=", 1)[0] + if arg in GIT_GLOBAL_VALUES and i + 1 < len(args): + value = args[i + 1] + if arg == "-c" and re.match(r"(alias|push|remote|branch)\.", value, re.I): + block("git-config", shown) + options += [arg, value] # -C included: git resolves the path itself, symlinks and all + i += 2 + elif arg in GIT_GLOBAL_FLAGS or (name in ("--git-dir", "--work-tree") and "=" in arg): + options.append(arg) + i += 1 + else: + block("option", shown) + if i >= len(args) or args[i] not in ("commit", "push"): + return + repo = Repo(cwd, options, shown) + if args[i] == "push": + check_push(args[i + 1 :], repo) + elif repo.branch() == MAIN: + block("commit-main", shown) + + +def check_gh_api(args, shown): + method = None + fields = [] + positional = [] + i = 0 + while i < len(args): + arg = args[i] + i += 1 + if arg in GH_API_FLAGS: + continue + if arg.startswith("--") and "=" in arg: + name, value = arg.split("=", 1) + elif re.fullmatch(r"-[A-Za-z].+", arg): + name, value = arg[:2], arg[2:] + elif arg in GH_API_VALUES and i < len(args): + name, value = arg, args[i] + i += 1 + elif arg.startswith("-"): + block("option", shown) + else: + positional.append(arg) + continue + if name not in GH_API_VALUES: + block("option", shown) + if name in ("-X", "--method"): + method = value.upper() + if name in GH_API_BODY: + fields.append(value) + method = method or ("POST" if fields else "GET") + path = urllib.parse.unquote(urllib.parse.urlparse(positional[0]).path) if positional else "" # URL, query or not + if method != "GET" and re.search(r"(^|/)pulls/[^/]+/merge/?$", path): + block("merge", shown) + if re.search(r"(^|/)graphql/?$", path) and any(re.search(r"mergePullRequest|AutoMerge", f) for f in fields): + block("merge", shown) + + +def check_gh(args, shown): + words = [] # (index, word) of the first two non-flag arguments + i = 0 + while i < len(args) and len(words) < 2: + if args[i] in ("-R", "--repo"): + i += 2 + continue + if not args[i].startswith("-"): + words.append((i, args[i])) + i += 1 + names = [word for _, word in words] + if names == ["pr", "merge"]: + block("merge", shown) + if names[:1] == ["alias"] and names[1:] != ["list"]: + block("alias", shown) + if names[:1] == ["api"]: + check_gh_api(args[words[0][0] + 1 :], shown) + + +def mentions_write(command): + """Text check before parsing: quotes, backslashes and %-escapes undone, as `pu""sh` and `merg%65` would be. + An expansion beside git, gh or a write verb counts too, since it can spell the rest (`/usr/bin/gi? push`).""" + plain = urllib.parse.unquote(re.sub(r"[\"'\\]", "", command.replace("\\\n", ""))) + return MENTIONS_WRITE.search(plain) or (EXPANSIONS.search(command) and MENTIONS_TOOL_OR_VERB.search(plain)) + + +def check(command, cwd): + if not mentions_write(command): + return + argv, cwd = simple_command(command, cwd) + program = os.path.basename(argv[0]) + shown = " ".join(argv) + if program == "git": + check_git(argv[1:], cwd, shown) + elif program == "gh": + check_gh(argv[1:], shown) + elif program not in INERT_PROGRAMS: + block("simple", shown) + + +def read_payload(text): + """(command, cwd) from a Bash PreToolUse payload, or None for another tool.""" + payload = json.loads(text) + tool = payload.get("tool_name", "Bash") + if not isinstance(tool, str) or not tool: + raise ValueError("tool_name is not a non-empty string") + if tool != "Bash": + return None + tool_input = payload.get("tool_input") + command = tool_input.get("command") if isinstance(tool_input, dict) else None + cwd = payload.get("cwd") + if not isinstance(command, str) or not isinstance(cwd, str) or not cwd: + raise ValueError("tool_input.command or cwd is missing, empty or not a string") + return command, cwd + + +def main(): + try: + request = read_payload(sys.stdin.read()) + except Exception as error: + print(f"git-guard: unreadable Bash hook payload ({type(error).__name__}: {str(error)[:200]}). " + "The harness payload format may have changed; fix tools/agent-guard/git_guard.py.", file=sys.stderr) + return 2 + if request is None: + return 0 + try: + check(*request) + except Blocked as reason: + print(f"git-guard: {reason}", file=sys.stderr) + return 2 + except Exception as error: # any failure while checking a command that mentions a write blocks it + print(f"git-guard: cannot check this command ({type(error).__name__}: {error}). " + "Run the git write as its own simple command.", file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/agent-guard/git_guard_test.py b/tools/agent-guard/git_guard_test.py new file mode 100644 index 000000000..a16237efc --- /dev/null +++ b/tools/agent-guard/git_guard_test.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python3 +"""Table test for git_guard.py, fed through stdin in both harness payload shapes. + +Run: python3 tools/agent-guard/git_guard_test.py +""" +import json +import os +import subprocess +import sys +import tempfile +import unittest + +GUARD = os.path.join(os.path.dirname(os.path.abspath(__file__)), "git_guard.py") + +# (command, directory the session runs in, expected verdict). Fixtures: +# main repo on main +# feature repo on feat/x inside main, with `link` -> main/sub and directories `--` and `~`; remotes `backup` (push = HEAD:refs/heads/main), `mirror` +# (mirror = true) and `paseo` (push = HEAD:refs/heads/feat/y, as Paseo PR checkouts have) +# tracking repo on feat/t, upstream origin/main, push.default=upstream +# matching repo on feat/m with push.default=matching +# plain a directory outside any repo +# solo repo on feat/x whose only remote, `backup`, has push = HEAD:refs/heads/main +# tagged repo on main that also has a tag named main +# feature also has remotes `matchall` (push = :), `mirror2` (mirror = 2), `nomirror` (mirror = false) +CASES = [ + # Plain forms agents need. + ("git push", "feature", "allow"), + ("git push -u origin feat/x", "feature", "allow"), + ("git push -u origin HEAD", "feature", "allow"), + ("git push --force-with-lease", "feature", "allow"), + ("git push --force-w origin feat/x", "feature", "allow"), + ("git push -o ci.skip origin feat/x", "feature", "allow"), + ("git push paseo", "feature", "allow"), + ("git push 2>&1", "feature", "allow"), + ("git push origin feat/x 2>/dev/null", "feature", "allow"), + ("git commit -m 'a > b && c'", "feature", "allow"), + ("git commit -m 'wip'", "feature", "allow"), + ("git commit -m \"subject\n\nbody line\"", "feature", "allow"), + ("cd feature && git commit -m x", "main", "allow"), + ("git -C feature push", "main", "allow"), + ("git status && git log --oneline -3", "main", "allow"), + ("git fetch origin main:main", "main", "allow"), + ("git log --grep 'push' --oneline", "main", "allow"), + ("gh pr create -R mforce/cluckwork --title x", "feature", "allow"), + ("gh pr view 12 --json state", "main", "allow"), + ("gh api repos/mforce/cluckwork/pulls/1158/merge", "feature", "allow"), + ("gh api -X GET repos/mforce/cluckwork/pulls/1158/merge", "feature", "allow"), + ("gh api -XGET repos/mforce/cluckwork/pulls/1158/merge", "feature", "allow"), + ("gh api 'repos/mforce/cluckwork/pulls/1158/merge?x=1'", "feature", "allow"), + ("gh api https://api.github.com/graphql -f query='query { viewer { login } }'", "feature", "allow"), + ("echo 'git push --force origin HEAD:main'", "main", "allow"), + ("echo ';' git push --force", "main", "allow"), + ("grep -rn 'gh pr merge' docs", "main", "allow"), + ("ls -la | wc -l", "main", "allow"), + ("ls *.py | wc -l", "main", "allow"), + ("git rev-parse @{upstream}", "tracking", "allow"), + ("git log @{u}..HEAD --oneline", "tracking", "allow"), + ("git show HEAD@{1}", "tracking", "allow"), + ("gh api repos/{owner}/{repo}/pulls/1158", "feature", "allow"), + ("git log --format=\"%h %s (%cr)\" | head -20", "tracking", "allow"), + ("gh pr view 1158 --json number,title --jq '{number, title}' | cat", "feature", "allow"), + ("gh api graphql -f query='query { repository(owner:\"mforce\", name:\"cluckwork\") { name } }' | cat", "feature", "allow"), + ("gh api 'repos/mforce/cluckwork/pulls?state=open' --jq '.[].number'", "main", "allow"), + # Merges. + ("gh pr merge 12 --squash", "feature", "block"), + ("gh -R mforce/cluckwork pr merge 12", "feature", "block"), + ("gh api -X PUT repos/mforce/cluckwork/pulls/12/merge", "feature", "block"), + ("gh api --method=PUT repos/mforce/cluckwork/pulls/12/merge", "feature", "block"), + ("gh api -XPUT repos/mforce/cluckwork/pulls/12/merge", "feature", "block"), + ("gh api repos/mforce/cluckwork/pulls/12/merge -f merge_method=squash", "feature", "block"), + ("gh api graphql -f query='mutation { mergePullRequest(input: {pullRequestId: \"PR_x\"}) { clientMutationId } }'", + "feature", "block"), + ("gh api https://api.github.com/graphql -f query='mutation { mergePullRequest(input: {pullRequestId: \"PR_x\"}) " + "{ clientMutationId } }'", "feature", "block"), + ("gh api /graphql -f query='mutation { mergePullRequest(input: {}) { clientMutationId } }'", "feature", "block"), + ("gh alias set land 'pr merge'", "feature", "block"), + ("gh alias set land \"pr merge\" && gh land 1158", "feature", "block"), + ("gh alias import aliases.yml", "feature", "block"), + ("gh api graphql -f query='mutation { enablePullRequestAutoMerge(input: {}) { clientMutationId } }'", + "feature", "block"), + # Refspecs, force, main. + ("git push origin HEAD:main", "feature", "block"), + ("git push origin feat/x:feat/y", "feature", "block"), + ("git push origin :old-branch", "feature", "block"), + ("git push --force", "feature", "block"), + ("git push -f origin feat/x", "feature", "block"), + ("git push -uf origin feat/x", "feature", "block"), + ("git push origin +feat/x", "feature", "block"), + ("git push --mirror", "feature", "block"), + ("git push --all origin", "feature", "block"), + ("git push --mir origin", "feature", "block"), + ("git push --al origin", "feature", "block"), + ("git push --pru origin", "feature", "block"), + ("git push --forc origin", "feature", "block"), + ("git push -ofoo origin HEAD:main", "feature", "block"), + ("git push origin main", "feature", "block"), + ("git push origin refs/heads/main", "feature", "block"), + ("git push --delete origin main", "feature", "block"), + ("git push origin @", "main", "block"), + ("git push", "main", "block"), + ("git push -u origin HEAD", "main", "block"), + ("git push origin 2>/dev/null", "main", "block"), + ("git commit -m x", "main", "block"), + ("cd .. && git commit -m x", "feature", "block"), + ("git -C .. commit -m x", "feature", "block"), + ("cd new-dir && git push", "feature", "block"), + ("cd - && git commit -m x", "feature", "block"), + ("cd -- && git commit -m x", "feature", "block"), + ("cd -P feature && git commit -m x", "main", "block"), + ("cd \"~\" && git commit -m x", "feature", "block"), + ("cd ~ && git commit -m x", "feature", "block"), + ("git -C link/.. commit -m x", "feature", "block"), + ("git -C missing-dir commit -m x", "feature", "block"), + ("git commit -m x", "plain", "block"), + # Push configuration (finding 3). + ("git push", "tracking", "block"), + ("git push -u origin feat/t", "tracking", "block"), + ("git push origin feat/t", "tracking", "block"), + ("git push --force-with-lease origin feat/t", "tracking", "block"), + ("git -c remote.origin.push=refs/heads/feat/x:refs/heads/main push -u origin feat/x", "feature", "block"), + ("git -c remote.origin.push=+refs/heads/feat/x:refs/heads/feat/x push -u origin feat/x", "feature", "block"), + ("git -c push.default=matching push", "feature", "block"), + ("git -c remote.origin.mirror=true push", "feature", "block"), + ("git -c alias.p=push p", "feature", "block"), + ("git --config-env=push.default=MODE push", "feature", "block"), + ("git -c remote.origin.pushurl=../other.git push", "feature", "block"), + ("git push backup", "feature", "block"), + ("git push mirror", "feature", "block"), + ("git push", "matching", "block"), + ("git push", "solo", "block"), + ("git push matchall", "feature", "block"), + ("git push mirror2", "feature", "block"), + ("git push nomirror", "feature", "allow"), + ("git commit -m x", "tagged", "block"), + ("git push", "tagged", "block"), + # Shapes the guard refuses instead of parsing (findings 1, 2, 4). + ("git status &&\ngit push origin HEAD:main", "feature", "block"), + ("git status;\ngh pr merge 1158", "feature", "block"), + ("git status\n\ngh pr merge 1158", "feature", "block"), + ("git status # inspect first\ngit push --force", "feature", "block"), + ("if true; then git push origin HEAD:main; fi", "feature", "block"), + ("for branch in main; do git push origin main; done", "feature", "block"), + ("{ git push --force; }", "feature", "block"), + ("bash <<'EOF'\ngh pr merge 1158\nEOF", "feature", "block"), + ("echo \"$(git push --force)\"", "feature", "block"), + ("echo `git push --force`", "feature", "block"), + ("git commit -F - <<'EOF'\nsubject\nEOF", "feature", "block"), + ("git switch main && git commit -m x", "tracking", "block"), + ("git checkout main && git push", "tracking", "block"), + ("git switch -c feat/new && git commit -m x", "main", "block"), + ("(cd feature && git status); git commit -m x", "main", "block"), + ("cd feature | cat; git commit -m x", "main", "block"), + ("false && cd feature; git commit -m x", "main", "block"), + ("git add -A && git commit -m x", "feature", "block"), + ("git push 2>&1 | tail -5", "feature", "block"), + ("/usr/bin/env git push --force", "feature", "block"), + ("command -- gh pr merge 1158", "feature", "block"), + ("command -p git push --force", "feature", "block"), + ("timeout --signal TERM 60 git push --force", "feature", "block"), + ("env -C .. git commit -m x", "feature", "block"), + ("GIT_DIR=../.git git commit -m x", "feature", "block"), + ("bash -lc 'git push origin feat/x'", "feature", "block"), + ("git push origin $BRANCH", "feature", "block"), + ("git push origin \\\nHEAD:main", "feature", "block"), + ("git push \\\n origin", "main", "block"), + ("git push # note", "main", "block"), + ("git status & git commit -m x", "main", "block"), + ("git commit -m \"unclosed", "feature", "block"), + # Quoting that the shell joins back into a write (parser differentials). + ("git pu\"\"sh origin HEAD:main", "feature", "block"), + ("g'i't push --force", "feature", "block"), + ("git p\\ush --force", "feature", "block"), + ("git $'\\x70ush' --force", "feature", "block"), + ("gh pr mer''ge 12", "feature", "block"), + ("git push origin ma?n", "feature", "block"), + ("gh api -X PUT 'repos/mforce/cluckwork/pulls/12/merge?x=1'", "feature", "block"), + ("gh api -X PUT 'repos/mforce/cluckwork/pulls/12/merge#x'", "feature", "block"), + ("gh api -X PUT repos/mforce/cluckwork/pulls/12/merg%65", "feature", "block"), + ("gh api -X PUT https://api.github.com/repos/mforce/cluckwork/pulls/12/merge/", "feature", "block"), + ("gh api -X PATCH repos/mforce/cluckwork/pulls/12 -f state=open --jq .mergeable", "feature", "allow"), + ("rg --pre ./run.sh 'git push' .", "feature", "block"), + ("/usr/bin/gi? push --force", "feature", "block"), + ("git pu\\\nsh --force", "feature", "block"), + ("g\\\nit push --force", "feature", "block"), + ("gh pr mer\\\nge 1158", "feature", "block"), + ("git com\\\nmit -m x", "feature", "block"), + ("git push 2 > /tmp/push.log main", "feature", "block"), + ("git push origin 1158 > /tmp/push.log", "feature", "block"), + ("git push origin feat/x >> /tmp/push.log", "feature", "block"), + ("git push origin feat/x &> /tmp/push.log", "feature", "block"), + ("$G push --force", "feature", "block"), + ("git $VERB --force", "feature", "block"), + ("git -C \"$WT\" status", "feature", "block"), +] + +# Known limits, documented in the PR body as contrived: brace and extglob expansion can spell a verb. +CASES += [ + ("git pu{s..s}h origin HEAD:main", "feature", "allow"), + ("git p@(u)sh --force", "feature", "allow"), +] + +MALFORMED = ["", "not json", "{\"tool_name\": \"Bash\"", "{}", "[]", "null", "[" * 100000, + json.dumps({"tool_name": "Bash", "tool_input": {"command": 7}}), + json.dumps({"tool_name": "Bash", "tool_input": {"cmd": "git push --force"}}), + json.dumps({"tool_name": "Bash", "tool_input": {"command": "git commit -m x"}, "cwd": 3})] +MALFORMED += [json.dumps({"tool_name": "Bash", "tool_input": {"command": "git commit -m x"}, **cwd}) + for cwd in ({}, {"cwd": None}, {"cwd": ""}, {"cwd": 0}, {"cwd": False}, {"cwd": []}, {"cwd": {}})] +MALFORMED += [json.dumps({"tool_name": name, "tool_input": {"command": "git commit -m x"}, "cwd": "/"}) + for name in (None, 0, False, [], {}, "")] + + +def payload(harness, command, cwd): + if harness == "claude": # captured from Claude Code 2.1.296 + return {"session_id": "s", "transcript_path": "/t.jsonl", "cwd": cwd, "permission_mode": "default", + "hook_event_name": "PreToolUse", "tool_name": "Bash", + "tool_input": {"command": command, "description": "d"}, "tool_use_id": "toolu_1"} + # Codex: codex-rs hook_runtime.rs PreToolUseRequest; exec_command sends only {"command"} + return {"session_id": "s", "turn_id": "t", "cwd": cwd, "transcript_path": None, "model": "m", + "permission_mode": "default", "hook_event_name": "PreToolUse", "tool_name": "Bash", + "tool_use_id": "call_1", "tool_input": {"command": command}} + + +def run_guard(body): + result = subprocess.run([sys.executable, GUARD], input=body, capture_output=True, text=True) + return result.returncode, result.stderr.strip() + + +def git(*args): + subprocess.run(["git", "-c", "user.name=t", "-c", "user.email=t@t", *args], check=True, capture_output=True) + + +class GitGuardTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.tmp = tempfile.TemporaryDirectory() + root = cls.tmp.name + main = os.path.join(root, "repo") + cls.dirs = {"main": main, "feature": os.path.join(main, "feature"), + "tracking": os.path.join(root, "tracking"), "matching": os.path.join(root, "matching"), "plain": root, + "solo": os.path.join(root, "solo"), "tagged": os.path.join(root, "tagged")} + feature, tracking, remote = cls.dirs["feature"], cls.dirs["tracking"], os.path.join(root, "remote.git") + git("init", "-q", "-b", "main", main) + git("init", "-q", "-b", "feat/x", feature) + os.mkdir(os.path.join(main, "sub")) + os.symlink(os.path.join(main, "sub"), os.path.join(feature, "link")) + for name in ("--", "~"): # bash's `cd --` and `cd ~` go to HOME, never to these + os.mkdir(os.path.join(feature, name)) + git("-C", feature, "config", "remote.backup.url", remote) + git("-C", feature, "config", "remote.backup.push", "HEAD:refs/heads/main") + git("-C", feature, "config", "remote.mirror.url", remote) + git("-C", feature, "config", "remote.mirror.mirror", "true") + git("-C", feature, "config", "remote.paseo.url", remote) + git("-C", feature, "config", "remote.paseo.push", "HEAD:refs/heads/feat/y") + for name, key, value in (("matchall", "push", ":"), ("mirror2", "mirror", "2"), ("nomirror", "mirror", "false")): + git("-C", feature, "config", f"remote.{name}.url", remote) + git("-C", feature, "config", f"remote.{name}.{key}", value) + git("init", "-q", "-b", "feat/x", cls.dirs["solo"]) + git("-C", cls.dirs["solo"], "config", "remote.backup.url", remote) + git("-C", cls.dirs["solo"], "config", "remote.backup.push", "HEAD:refs/heads/main") + git("init", "-q", "-b", "main", cls.dirs["tagged"]) + git("-C", cls.dirs["tagged"], "commit", "-q", "--no-verify", "--allow-empty", "-m", "init") + git("-C", cls.dirs["tagged"], "tag", "main") + git("init", "-q", "-b", "feat/m", cls.dirs["matching"]) + git("-C", cls.dirs["matching"], "config", "push.default", "matching") + git("init", "-q", "--bare", "-b", "main", remote) + git("init", "-q", "-b", "main", tracking) + git("-C", tracking, "commit", "-q", "--no-verify", "--allow-empty", "-m", "init") + git("-C", tracking, "remote", "add", "origin", remote) + git("-C", tracking, "update-ref", "refs/remotes/origin/main", "HEAD") + git("-C", tracking, "switch", "-q", "-c", "feat/t", "--track", "origin/main") + git("-C", tracking, "config", "push.default", "upstream") + + @classmethod + def tearDownClass(cls): + cls.tmp.cleanup() + + def test_table(self): + counts = {} + for harness in ("claude", "codex"): + for command, where, expected in CASES: + code, stderr = run_guard(json.dumps(payload(harness, command, self.dirs[where]))) + verdict = {0: "allow", 2: "block"}.get(code, f"exit {code}") + counts[verdict] = counts.get(verdict, 0) + 1 + print(f"{harness:6} {verdict:5} [{where:8}] {command!r}" + (f"\n{'':23}{stderr}" if stderr else "")) + with self.subTest(harness=harness, command=command): + self.assertEqual(verdict, expected, stderr) + if expected == "block": + self.assertIn("git-guard:", stderr) + print(f"{len(CASES)} commands x 2 payload shapes: {counts}") + + def test_malformed_bash_payload_blocks(self): + for body in MALFORMED: + code, stderr = run_guard(body) + with self.subTest(body=body[:60]): + self.assertEqual(code, 2, stderr) + self.assertIn("unreadable Bash hook payload", stderr) + + def test_other_tools_pass(self): + code, _ = run_guard(json.dumps({"tool_name": "Read", "tool_input": {"file_path": "/x"}})) + self.assertEqual(code, 0) + + def test_cdpath_makes_a_bare_cd_operand_ambiguous(self): + env = {**os.environ, "CDPATH": self.tmp.name} + for command, expected in (("cd feature && git commit -m x", 2), ("cd ./feature && git commit -m x", 0)): + body = json.dumps(payload("claude", command, self.dirs["main"])) + result = subprocess.run([sys.executable, GUARD], input=body, capture_output=True, text=True, env=env) + self.assertEqual(result.returncode, expected, f"{command}: {result.stderr}") + + def test_switch_then_separate_commit(self): + with tempfile.TemporaryDirectory() as repo: + git("init", "-q", "-b", "main", repo) + for command, expected in (("git commit -m x", 2), ("git switch -c feat/new", 0), ("git commit -m x", 0)): + code, stderr = run_guard(json.dumps(payload("claude", command, repo))) + self.assertEqual(code, expected, f"{command}: {stderr}") + if command.startswith("git switch"): + git("-C", repo, "switch", "-q", "-c", "feat/new") + + +if __name__ == "__main__": + unittest.main()