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