From 5d9de5086f73445e39fc18c35924eb5246dbc3d9 Mon Sep 17 00:00:00 2001 From: Jacob <20714140+JacobPEvans@users.noreply.github.com> Date: Sun, 26 Apr 2026 14:06:56 +0000 Subject: [PATCH 1/8] fix(git-guards): update stale test fixtures for BLOCKED_ON_MAIN tightening (#256) [issue-solver-2026-04-26] --- git-guards/scripts/test_permission_guard.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/git-guards/scripts/test_permission_guard.py b/git-guards/scripts/test_permission_guard.py index fa7a773..13f7626 100755 --- a/git-guards/scripts/test_permission_guard.py +++ b/git-guards/scripts/test_permission_guard.py @@ -72,8 +72,8 @@ def check(label: str, cmd: str, expected_decision: str) -> bool: # git push --force origin main - must DENY (DENY_GIT_ONLY: force-push to protected branch) all_pass &= check("git push --force origin main", "git push --force origin main", "deny") -# git push --force to a feature branch - must ask (non-main target) -all_pass &= check("git push --force feature branch", "git push --force origin feature/my-branch", "ask") +# git push --force to a feature branch - BLOCKED_ON_MAIN denies push on main (tightened 2026-03-22) +all_pass &= check("git push --force feature branch", "git push --force origin feature/my-branch", "deny") # DENY: commit --no-verify all_pass &= check("git commit --no-verify", "git commit -m msg --no-verify", "deny") @@ -91,15 +91,16 @@ def check(label: str, cmd: str, expected_decision: str) -> bool: all_pass &= check("git -C -c core.hooksPath deny", "git -C /some/path -c core.hooksPath=/dev/null commit -m test", "deny") all_pass &= check("git -C restore ask", "git -C /some/path restore file.txt", "ask") -# core.hooksPath precision: value containing the string should not trigger deny -all_pass &= check("hooksPath in value only", "git -c some.key=echo-core.hooksPath commit -m test", "silent_allow") +# core.hooksPath precision: BLOCKED_ON_MAIN denies commit on main regardless of value content +all_pass &= check("hooksPath in value only", "git -c some.key=echo-core.hooksPath commit -m test", "deny") # Fallback bypass detection: unrecognised global option before -c breaks loop early all_pass &= check("--no-pager before -c hooksPath", "git --no-pager -c core.hooksPath=/dev/null commit -m msg", "deny") # Fallback also fires when loop parsed a prior -c but broke before a second -c hooksPath all_pass &= check("valid -c then --bare then -c hooksPath", "git -c user.name=test --bare -c core.hooksPath=/dev/null commit -m msg", "deny") -# False positive guard: commit message containing the bypass pattern as a substring must not deny -all_pass &= check("hooksPath in commit message", 'git -c user.name=test commit -m "allow -c core.hooksPath bypass example"', "silent_allow") +# False positive guard: tag message containing the bypass pattern as a substring must not deny +# Uses 'git tag' (not in BLOCKED_ON_MAIN) to test the tokenizer false-positive scenario on any branch +all_pass &= check("hooksPath in tag message", 'git -c user.name=test tag v99-test -m "allow -c core.hooksPath bypass example"', "silent_allow") print() print("ALL TESTS PASSED" if all_pass else "SOME TESTS FAILED") From f4ff15fe6fbfc55eaea019527bb57a5584ddaad9 Mon Sep 17 00:00:00 2001 From: Jacob <20714140+JacobPEvans@users.noreply.github.com> Date: Sun, 26 Apr 2026 14:07:02 +0000 Subject: [PATCH 2/8] fix(git-guards): extract config key before hooksPath match in fallback tokenizer (#256) [issue-solver-2026-04-26] --- git-guards/scripts/git-permission-guard.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/git-guards/scripts/git-permission-guard.py b/git-guards/scripts/git-permission-guard.py index 22be195..b6a245b 100755 --- a/git-guards/scripts/git-permission-guard.py +++ b/git-guards/scripts/git-permission-guard.py @@ -341,7 +341,8 @@ def main(): subcmd_tokens = [] for i, tok in enumerate(subcmd_tokens): if tok == "-c" and i + 1 < len(subcmd_tokens): - if re.match(r"core\.hooksPath\s*(?:=|$)", subcmd_tokens[i + 1], re.IGNORECASE): + config_key = subcmd_tokens[i + 1].split("=")[0] + if re.match(r"core\.hooksPath$", config_key, re.IGNORECASE): deny("This command bypasses configured hooks. Fix the underlying issue instead.") if sub_tokens and sub_tokens[0] in BLOCKED_ON_MAIN and _is_on_main_branch(): From be72c0b85e8f6b9aafbbf1184a70438c85baef3c Mon Sep 17 00:00:00 2001 From: Jacob <20714140+JacobPEvans@users.noreply.github.com> Date: Sun, 26 Apr 2026 14:14:17 +0000 Subject: [PATCH 3/8] fix(git-guards): fix test commands to be branch-agnostic (avoid BLOCKED_ON_MAIN) (#256) [issue-solver-2026-04-26] --- git-guards/scripts/test_permission_guard.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/git-guards/scripts/test_permission_guard.py b/git-guards/scripts/test_permission_guard.py index 13f7626..6ddaeff 100755 --- a/git-guards/scripts/test_permission_guard.py +++ b/git-guards/scripts/test_permission_guard.py @@ -72,7 +72,7 @@ def check(label: str, cmd: str, expected_decision: str) -> bool: # git push --force origin main - must DENY (DENY_GIT_ONLY: force-push to protected branch) all_pass &= check("git push --force origin main", "git push --force origin main", "deny") -# git push --force to a feature branch - BLOCKED_ON_MAIN denies push on main (tightened 2026-03-22) +# git push --force to any branch is now denied (DENY_GIT_ONLY, tightened policy) all_pass &= check("git push --force feature branch", "git push --force origin feature/my-branch", "deny") # DENY: commit --no-verify @@ -91,8 +91,8 @@ def check(label: str, cmd: str, expected_decision: str) -> bool: all_pass &= check("git -C -c core.hooksPath deny", "git -C /some/path -c core.hooksPath=/dev/null commit -m test", "deny") all_pass &= check("git -C restore ask", "git -C /some/path restore file.txt", "ask") -# core.hooksPath precision: BLOCKED_ON_MAIN denies commit on main regardless of value content -all_pass &= check("hooksPath in value only", "git -c some.key=echo-core.hooksPath commit -m test", "deny") +# core.hooksPath precision: value containing the substring must not trigger deny (uses fetch, not commit) +all_pass &= check("hooksPath in value only", "git -c some.key=echo-core.hooksPath fetch origin", "silent_allow") # Fallback bypass detection: unrecognised global option before -c breaks loop early all_pass &= check("--no-pager before -c hooksPath", "git --no-pager -c core.hooksPath=/dev/null commit -m msg", "deny") From 456ccead7b0414cff6b319b31eed9ee355cde40c Mon Sep 17 00:00:00 2001 From: Jacob <20714140+JacobPEvans@users.noreply.github.com> Date: Sun, 26 Apr 2026 14:14:22 +0000 Subject: [PATCH 4/8] fix(git-guards): deny all force-pushes in DENY_GIT_ONLY + key-only tokenizer match (#256) [issue-solver-2026-04-26] --- git-guards/scripts/git-permission-guard.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/git-guards/scripts/git-permission-guard.py b/git-guards/scripts/git-permission-guard.py index b6a245b..816900c 100755 --- a/git-guards/scripts/git-permission-guard.py +++ b/git-guards/scripts/git-permission-guard.py @@ -28,16 +28,13 @@ (r"cherry-pick\s+.*--no-verify", "bypasses commit hooks"), (r"rebase\s+.*--no-verify", "bypasses commit hooks"), (r"config\s+.*core\.hooksPath", "changes hook directory"), - (r"push\s+(--force|--force-with-lease|-f)\s+\S+\s+main\b", "force-pushes to the main branch"), + (r"push\s+(--force|--force-with-lease|-f)\b", "force-pushes overwrite remote history"), ] # Commands requiring explicit user confirmation # Ordered from most specific to least specific to avoid false matches ASK_GIT = [ ("commit --amend", "Rewrites the last commit"), - ("push --force-with-lease", "Overwrites remote history"), - ("push --force", "Overwrites remote history"), - ("push -f", "Overwrites remote history"), ("worktree remove --force", "Removes worktree directory, discarding uncommitted changes"), ("worktree remove -f", "Removes worktree directory, discarding uncommitted changes"), ("cherry-pick", "Rewrites commit history"), From 7dbe4c47cb113ba02e20e2048cea113ca64c0fb4 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans@users.noreply.github.com> Date: Wed, 29 Apr 2026 01:04:35 -0400 Subject: [PATCH 5/8] fix(git-guards): anchor force-push regex + tighten hooksPath matching - Prefix force-push pattern with ^ to prevent false positives from commit messages containing "push --force" - Add .* between push and force flag to match deferred-flag forms like `git push origin main --force` - Replace split("=")[0] + simple match with anchored regex for core.hooksPath detection in fallback tokenizer - Update stale test comment to reflect all-branch deny policy --- git-guards/scripts/git-permission-guard.py | 6 +++--- git-guards/scripts/test_permission_guard.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/git-guards/scripts/git-permission-guard.py b/git-guards/scripts/git-permission-guard.py index 816900c..432984c 100755 --- a/git-guards/scripts/git-permission-guard.py +++ b/git-guards/scripts/git-permission-guard.py @@ -28,7 +28,7 @@ (r"cherry-pick\s+.*--no-verify", "bypasses commit hooks"), (r"rebase\s+.*--no-verify", "bypasses commit hooks"), (r"config\s+.*core\.hooksPath", "changes hook directory"), - (r"push\s+(--force|--force-with-lease|-f)\b", "force-pushes overwrite remote history"), + (r"^push\s+.*(--force|--force-with-lease|-f)\b", "force-pushes overwrite remote history"), ] # Commands requiring explicit user confirmation @@ -338,8 +338,8 @@ def main(): subcmd_tokens = [] for i, tok in enumerate(subcmd_tokens): if tok == "-c" and i + 1 < len(subcmd_tokens): - config_key = subcmd_tokens[i + 1].split("=")[0] - if re.match(r"core\.hooksPath$", config_key, re.IGNORECASE): + config_token = subcmd_tokens[i + 1] + if re.match(r"^core\.hooksPath(=|$)", config_token, re.IGNORECASE): deny("This command bypasses configured hooks. Fix the underlying issue instead.") if sub_tokens and sub_tokens[0] in BLOCKED_ON_MAIN and _is_on_main_branch(): diff --git a/git-guards/scripts/test_permission_guard.py b/git-guards/scripts/test_permission_guard.py index 6ddaeff..32a12d3 100755 --- a/git-guards/scripts/test_permission_guard.py +++ b/git-guards/scripts/test_permission_guard.py @@ -69,7 +69,7 @@ def check(label: str, cmd: str, expected_decision: str) -> bool: # git reset - must ask: can lose uncommitted work all_pass &= check("git reset", "git reset --hard HEAD", "ask") -# git push --force origin main - must DENY (DENY_GIT_ONLY: force-push to protected branch) +# git push --force origin main - must DENY (DENY_GIT_ONLY: any force-push is denied) all_pass &= check("git push --force origin main", "git push --force origin main", "deny") # git push --force to any branch is now denied (DENY_GIT_ONLY, tightened policy) From 57d31d54bcf2d86e0c370ad0ec548b5b45efcedd Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans@users.noreply.github.com> Date: Wed, 29 Apr 2026 01:30:56 -0400 Subject: [PATCH 6/8] fix(git-guards): strip boolean global opts so push-force deny fires with --no-pager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add --no-pager, --bare, -p, -P, --paginate, --no-replace-objects to the global option extraction loop so they are stripped before ^-anchored DENY patterns are applied. Previously the loop only stripped -C and -c, so `git --no-pager push --force` left `--no-pager` at the start of subcommand, defeating the `^push` anchor and silently allowing the force-push. Adds regression test: git --no-pager push --force origin feature/my-branch → deny. (claude) --- git-guards/scripts/git-permission-guard.py | 5 +++++ git-guards/scripts/test_permission_guard.py | 3 +++ 2 files changed, 8 insertions(+) diff --git a/git-guards/scripts/git-permission-guard.py b/git-guards/scripts/git-permission-guard.py index 432984c..9802090 100755 --- a/git-guards/scripts/git-permission-guard.py +++ b/git-guards/scripts/git-permission-guard.py @@ -302,6 +302,11 @@ def main(): git_config_opts.append(m.group(1).strip("'\"")) rest = m.group(2).strip() continue + # Boolean global options (no argument) such as --no-pager, --bare + m = re.match(r'^(-p|-P|--paginate|--no-pager|--no-replace-objects|--bare)\s*(.*)', rest) + if m: + rest = m.group(2).strip() + continue break subcommand = rest else: diff --git a/git-guards/scripts/test_permission_guard.py b/git-guards/scripts/test_permission_guard.py index 32a12d3..ef990ac 100755 --- a/git-guards/scripts/test_permission_guard.py +++ b/git-guards/scripts/test_permission_guard.py @@ -75,6 +75,9 @@ def check(label: str, cmd: str, expected_decision: str) -> bool: # git push --force to any branch is now denied (DENY_GIT_ONLY, tightened policy) all_pass &= check("git push --force feature branch", "git push --force origin feature/my-branch", "deny") +# git global option before push must still deny (--no-pager is stripped by extraction loop) +all_pass &= check("git --no-pager push --force feature branch", "git --no-pager push --force origin feature/my-branch", "deny") + # DENY: commit --no-verify all_pass &= check("git commit --no-verify", "git commit -m msg --no-verify", "deny") From 2f2cd9dd28e5d6e3936ae8dfed3461050d5c4509 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans@users.noreply.github.com> Date: Wed, 29 Apr 2026 01:33:27 -0400 Subject: [PATCH 7/8] test(git-guards): update shlex ValueError fixture for --no-pager stripping The test expected silent_allow for git --no-pager -c core.hooksPath=/dev/null because the old loop broke on --no-pager and left the -c option unprocessed. Now that --no-pager is stripped by the loop, the following -c core.hooksPath option is extracted directly (no shlex fallback needed), so the correct outcome is deny. Updated the fixture and its comment accordingly. (claude) --- git-guards/scripts/test_shlex_valueerror.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/git-guards/scripts/test_shlex_valueerror.py b/git-guards/scripts/test_shlex_valueerror.py index ae7c9ac..23a6d33 100755 --- a/git-guards/scripts/test_shlex_valueerror.py +++ b/git-guards/scripts/test_shlex_valueerror.py @@ -45,15 +45,14 @@ def check(label: str, cmd: str, expected_decision: str) -> bool: all_pass = True -# ValueError path: loop breaks on unrecognised global option (--no-pager); -# remaining subcommand has an unclosed double quote → shlex.split() raises -# ValueError → subcmd_tokens = [] → no bypass detected → silent_allow. -# Without the fix, the old str.split() fallback would have tokenised on -# whitespace and incorrectly fired a deny for the -c core.hooksPath token. +# --no-pager is now stripped by the extraction loop before -c is processed. +# The loop extracts -c core.hooksPath=/dev/null directly → deny fires via the +# direct git_config_opts path even though the trailing commit message is +# malformed (unclosed quote). The shlex ValueError path is irrelevant here. all_pass &= check( "ValueError: unclosed double-quote with hooksPath in fallback subcommand", 'git --no-pager -c core.hooksPath=/dev/null commit -m "unclosed', - "silent_allow", + "deny", ) # ValueError path: unclosed single quote with bypass pattern in subcommand text. From 2c75da27cc6c87951f450540bb37edfc11489421 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans@users.noreply.github.com> Date: Wed, 29 Apr 2026 01:45:36 -0400 Subject: [PATCH 8/8] chore(git-guards): trim redundant boolean-options comment The comment listed examples that the regex itself enumerates. Reframe to explain WHY (no argument = different parse pattern from -C/-c). (claude) --- git-guards/scripts/git-permission-guard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git-guards/scripts/git-permission-guard.py b/git-guards/scripts/git-permission-guard.py index 9802090..55c8984 100755 --- a/git-guards/scripts/git-permission-guard.py +++ b/git-guards/scripts/git-permission-guard.py @@ -302,7 +302,7 @@ def main(): git_config_opts.append(m.group(1).strip("'\"")) rest = m.group(2).strip() continue - # Boolean global options (no argument) such as --no-pager, --bare + # Boolean global options take no argument (different parse from -C/-c) m = re.match(r'^(-p|-P|--paginate|--no-pager|--no-replace-objects|--bare)\s*(.*)', rest) if m: rest = m.group(2).strip()