Skip to content

Guard against agent git operations that require a branch-rule bypass - #449

Merged
ptr727 merged 13 commits into
developfrom
agent-git-bypass-guard
Jul 26, 2026
Merged

Guard against agent git operations that require a branch-rule bypass#449
ptr727 merged 13 commits into
developfrom
agent-git-bypass-guard

Conversation

@ptr727

Copy link
Copy Markdown
Owner

Extends the gh-write-guard PreToolUse hook with Rule 4: deny any git operation that would only succeed by bypassing an active branch rule - the gap behind the direct-push-to-develop incident this session, where an admin-bypass identity makes a plain git push origin develop silently land on a PR-gated branch.

What it denies

  • Direct update to a branch whose live rules require a pull request (every main; code-style develop). A config-style develop has no such rule and stays allowed - decided by the live rules, no hardcoded repo list.
  • Force where non_fast_forward/required_linear_history is active.
  • Delete where deletion is active.
  • Explicit-bypass flags: gh pr merge --admin, git commit/push --no-verify.

Behavior

  • The branch's rules come from repos/{owner}/{repo}/rules/branches/{branch} (visible even to a bypass actor).
  • Fail-closed on main/master/develop when the rules cannot be read; fail-open elsewhere (feature branches).
  • Each denial names the bypassed rule, the protocol path, and hands the exact command to the maintainer to run.
  • Injectable rules_lookup/current_branch keep --selftest deterministic and offline.

Verification

  • --selftest: 44/44 (existing 21 write-footgun cases + 22 new Rule-4 cases: code/config develop, main, feature, HEAD/sha/bare/+refspec/delete refspecs, fail-closed vs fail-open, the explicit flags).
  • Live end-to-end against real branch rules: develop/main deny, this feature branch allow, --admin/--no-verify deny.

Docs/tooling only - no release. Deploy to ~/.claude/hooks/ is held until this and the develop->main promotion both pass review.

🤖 Generated with Claude Code

The write-guard permitted a direct commit to a protected branch when the
authenticated user can bypass the rule, so a plain-looking update silently
lands on develop or main under admin bypass. Add Rule 4: inspect the target
branch's live rules and deny an operation that would only succeed by
bypassing one - a direct update where a pull request is required, a force
where history is protected, a delete where deletion is blocked - plus the
explicit-bypass flags (gh pr merge --admin, --no-verify). Code-style and
config-style develop are told apart by the live rules, not a hardcoded list;
the protected-default branches fail closed when the rules cannot be read.
Each denial names the bypassed rule and hands the command to the maintainer.
44/44 self-test cases pass; verified end-to-end against live branch rules.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CopilotAI review requested due to automatic review settings July 25, 2026 21:14

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extends the host-setup/agent-safety/gh-write-guard.py PreToolUse hook with a new Rule 4 that blocks git operations which would only succeed by bypassing active branch rules (plus explicit bypass flags like gh pr merge --admin and --no-verify).

Changes:

  • Adds live branch-rule lookups (gh api repos/{owner}/{repo}/rules/branches/{branch}) to deny direct pushes, force pushes, and branch deletions when the branch rules prohibit them.
  • Adds explicit bypass-flag detection for gh pr merge --admin and git commit/push --no-verify (plus git commit -n).
  • Expands --selftest to cover the new Rule 4 cases deterministically via injectable rules_lookup and current_branch.

Comment threadhost-setup/agent-safety/gh-write-guard.py Outdated
Comment threadhost-setup/agent-safety/gh-write-guard.py
Copilot review of #449 surfaced two parser gaps:
- _push_targets tokenized the segment with split(), so a bare push with a
redirect (... >push.log 2>&1) read the redirect as the remote and refspec
and skipped the current-branch resolution, missing a protected target.
Stop parsing at the first redirection token (> or <).
- _GH_ADMIN_MERGE excluded newlines, so a backslash-newline continued
admin merge slipped past. Fold backslash-newline continuations to spaces
in classify before the bypass checks run.
Two self-test cases added for each gap; 46/46 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CopilotAI review requested due to automatic review settings July 25, 2026 21:24

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 1 comment.

Comment threadhost-setup/agent-safety/gh-write-guard.py
Copilot review of #449: the push-bypass gate scanned the raw command, so a
`git push ...` appearing only inside a quoted --body/--message (for example
a gh issue comment that documents a command) tripped the check and could
falsely deny. Run the gate and the target parse on the command with quoted
spans removed, matching how the suppression and bypass-flag scans already
treat quoted text as non-executable. Self-test case added.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CopilotAI review requested due to automatic review settings July 25, 2026 21:28

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 4 comments.

Comment threadhost-setup/agent-safety/gh-write-guard.py Outdated
Comment threadhost-setup/agent-safety/gh-write-guard.py Outdated
Comment threadhost-setup/agent-safety/gh-write-guard.py Outdated
Comment threadhost-setup/agent-safety/gh-write-guard.py Outdated
Third Copilot round on #449:
- _push_targets hand-parsed quotes: a quoted refspec ('HEAD:develop') kept a
trailing quote and a fully-quoted refspec was lost when spans were removed
upstream. Tokenize with shlex and key off a real git-push argv adjacency, so
a quoted refspec is unquoted cleanly and a push named only inside a quoted
body forms no adjacency (no target). Removes the span-removal workaround.
- _live_branch_rules built the API path from the raw branch name, so a name
with a slash (feature/x) split the path and the lookup failed to None. URL
encode the branch.
- _handoff hardcoded a personal name; use the generic maintainer wording.
Self-test now 49 cases (quoted refspec, quoted-mention-before-real-push);
verified live: slashed-branch lookup returns a set, quoted refspec denies.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CopilotAI review requested due to automatic review settings July 25, 2026 21:37

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (2)

host-setup/agent-safety/gh-write-guard.py:186

  • _push_targets() only stops parsing at redirection tokens (>/<). Shell control operators like &&, ||, ;, |, and & can also appear after git push and are not part of git argv. Today git push && echo ok is mis-parsed as having a remote of &&, which can prevent the bare-push branch resolution and potentially let a protected-branch push slip past Rule 4.
 while i < len(toks):
t = toks[i]
if ">" in t or "<" in t:
break # a redirection operator (>, 2>, >>, <, 2>&1): end of git argv, start of shell syntax
if t in ("--force", "-f") or t.startswith("--force-with-lease"):

host-setup/agent-safety/gh-write-guard.py:303

  • Rule 4's gate if _GIT_PUSH.search(cmd): only matches git push with no global git options. A real push like git -C /path push origin develop (or git -c key=val push ...) won't run _check_push_bypass() at all, which defeats the branch-rule bypass protection for a common invocation style.
 # `_push_targets` tokenizes with shlex and keys off a real `git push` argv adjacency, so a push named
# only inside a quoted argument yields no target - the raw substring is just a cheap pre-filter.
if _GIT_PUSH.search(cmd):
dec, reason = _check_push_bypass(cmd, cwd, origin, current_branch, rules_lookup)
if dec == "deny":

Comment threadhost-setup/agent-safety/gh-write-guard.py Outdated
Copilot review of #449: the push detection required a bare git-push token
adjacency, so a global option between them (git -C <dir> push, git -c k=v
push, git --git-dir=... push) dodged Rule 4 entirely and a direct push to a
PR-gated branch could slip through. Add _git_push_args(), which skips git's
value-taking global options before the subcommand, and route all three sites
through it (the push parser, the write classifier, and the pre-filter). The
loose pre-filter regex now allows the intervening options.
Self-test now 54 cases (-C, -c k=v, --git-dir= forms deny; feature allowed);
verified live: git -C ... push origin develop denies.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CopilotAI review requested due to automatic review settings July 25, 2026 21:44

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 1 comment.

Comment threadhost-setup/agent-safety/gh-write-guard.py Outdated
Copilot review of #449: a push with only a remote positional was always
treated as a bare push resolving the current branch. That misclassified
whole-repo pushes: --all and --mirror update every branch (protected ones
included, so a current-branch-only check misses the bypass), while --tags
pushes no branch (so resolving one is a false deny). Detect the flags: --all
and --mirror target the protected-default branches (--mirror as a force,
matching its prune/rewrite), --tags yields no branch target. Non-existent
defaults return no rules and are skipped.
Self-test now 58 cases. (--all/--mirror scan the default branch names, not
arbitrary custom-protected refs - a documented precision-over-recall bound.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CopilotAI review requested due to automatic review settings July 25, 2026 21:49

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

host-setup/agent-safety/gh-write-guard.py:344

  • Rule-4's git push pre-filter runs on the raw command string, so a git push mentioned inside a quoted body/title can still trigger _check_push_bypass(), which in turn can run unnecessary git remote get-url origin / branch-resolution subprocesses. This is avoidable and was explicitly called out as a goal in earlier iterations: use the quoted-span-stripped string only to decide whether an executable push exists, but keep passing the original cmd into _check_push_bypass() for accurate parsing.
 # `_push_targets` tokenizes with shlex and keys off a real `git push` argv adjacency, so a push named
# only inside a quoted argument yields no target - the raw substring is just a cheap pre-filter.
if _GIT_PUSH.search(cmd):
dec, reason = _check_push_bypass(cmd, cwd, origin, current_branch, rules_lookup)
if dec == "deny":

Comment threadhost-setup/agent-safety/gh-write-guard.py Outdated
Copilot review of #449: the push parser stopped at any token merely
containing > or <, so a > inside a quoted option value (--push-option='a>b')
ended parsing before the refspec and fell back to current-branch resolution,
a potential bypass. Deeper, shlex.split does not isolate operators glued to a
token (develop;cmd) and only the first push in a compound was parsed.
Tokenize with shlex punctuation_chars so real shell operators are their own
tokens while a quoted > stays part of its word, and parse every git push in
the command (each argv runs up to the next operator), so develop;cmd, a
pipe, and push A && push B are all handled. _push_targets now returns every
(op, branch) pair.
Self-test 62 cases; verified live: quoted-value and compound pushes deny.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CopilotAI review requested due to automatic review settings July 25, 2026 21:57

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 2 comments.

Comment threadhost-setup/agent-safety/gh-write-guard.py
Comment threadhost-setup/agent-safety/gh-write-guard.py
Copilot review of #449: the push scan matched only a token exactly equal to
"git", and the commit -n short-form detection matched only a bare "git
commit", so an absolute-path or .exe invocation (/usr/bin/git push origin
develop, git.exe push, /usr/bin/git commit -n) bypassed Rule 4 entirely. Add
_is_git_exe(), which matches any token whose basename is git or git.exe, and
broaden _GIT_COMMIT to allow a path prefix and .exe suffix.
Self-test 65 cases (absolute-path push and commit -n, git.exe push).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CopilotAI review requested due to automatic review settings July 25, 2026 22:02

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 1 comment.

Comment threadhost-setup/agent-safety/gh-write-guard.py Outdated
Copilot review of #449: --follow-tags was grouped with --tags as tags-only,
so git push --follow-tags <remote> looked like it updated no branch and
skipped the checks - but --follow-tags pushes the current branch plus
reachable tags. Only --tags is tags-only; --follow-tags falls through to
normal bare-push branch resolution.
Self-test 66 cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CopilotAI review requested due to automatic review settings July 25, 2026 22:05

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 2 comments.

Comment threadhost-setup/agent-safety/gh-write-guard.py
Comment threadhost-setup/agent-safety/gh-write-guard.py Outdated
Copilot review of #449:
- --no-verify was denied on any command, so a non-git tool using the same
flag name (npm publish --no-verify) false-denied. Gate it to a git commit
or git push context.
- A comment still named _git_push_args, renamed to _push_arg_lists.
Self-test 67 cases (non-git --no-verify allowed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CopilotAI review requested due to automatic review settings July 25, 2026 22:10

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

host-setup/agent-safety/gh-write-guard.py:312

  • _check_bypass_flags() only recognizes git commit via the _GIT_COMMIT regex, which does not allow git global options between git and commit (for example git -C /repo commit -n ... or git -c k=v commit --no-verify ...). Those forms are valid and would bypass the intended --no-verify/-n denial.
 # --no-verify / commit -n skip the git hooks, so they only matter for a git commit or push - other
# tools use --no-verify for unrelated things, and denying those would be a false positive.
is_commit = _GIT_COMMIT.search(bare) is not None
is_push = bool(_push_arg_lists(bare))
long_no_verify = (is_commit or is_push) and _NO_VERIFY_LONG.search(bare)
short_n_commit = is_commit and _COMMIT_SHORT_N.search(bare) # `-n` is --no-verify for commit (push -n is dry-run)

Comment threadhost-setup/agent-safety/gh-write-guard.py
Copilot review of #449: the --no-verify/-n check used a regex that matched
only a bare "git commit", so git -C <dir> commit --no-verify (a global option
before the subcommand) bypassed it - the same gap already fixed for push.
Generalize the token scanner to _git_subcommand_arglists(cmd, sub) and detect
the bypass flag as an actual arg of the commit/push argv, which also drops the
three now-unused regexes (_GIT_COMMIT/_NO_VERIFY_LONG/_COMMIT_SHORT_N).
Self-test 69 cases (git -C/-c commit -n/--no-verify deny).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CopilotAI review requested due to automatic review settings July 25, 2026 22:17

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

host-setup/agent-safety/gh-write-guard.py:311

  • _check_bypass_flags() treats --no-verify / -n as a bypass whenever the token appears anywhere in the git commit/git push argv. That can false-deny valid commands when the token is actually a value to a value-taking flag (for example git commit -m --no-verify where the commit message is "--no-verify", or git push --push-option --no-verify ... where the push-option value happens to be "--no-verify"). Since the hook is explicitly "precision over recall", the bypass detection should skip values consumed by known value flags before deciding a bypass flag is present.
 commit_lists = _git_subcommand_arglists(cmd, "commit")
push_lists = _push_arg_lists(cmd)
commit_bypass = any(("--no-verify" in a) or ("-n" in a) for a in commit_lists)
push_bypass = any("--no-verify" in a for a in push_lists)
if commit_bypass or push_bypass:

Comment threadhost-setup/agent-safety/gh-write-guard.py
Copilot review of #449: _check_push_bypass resolved origin (a git subprocess)
before checking whether any executable push was found, so a command that only
mentions git push in a quoted argument still did git work. Compute the targets
first and return early when there are none. Also, the fail-closed message
assumed API unreachability, but the cause can be missing repo context (not a
git checkout) - distinguish the two reasons.
Self-test 69 cases, unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CopilotAI review requested due to automatic review settings July 25, 2026 22:22

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 2 comments.

Comment threadhost-setup/agent-safety/gh-write-guard.py
Comment threadhost-setup/agent-safety/gh-write-guard.py
Copilot review of #449: the argv collector stopped at any shell-operator
token, so a redirection placed before the refspec (git push 2>push.log origin
develop, which POSIX allows) had its fd digit read as a positional and parsing
stopped at >, dropping the real origin develop and falling back to current-
branch resolution - a bypass. Distinguish redirections (>, >>, <, >&, and a
leading fd digit) which are skipped so args continue, from command separators
(|, &&, ;) which end the invocation. Also catch TypeError in _shell_tokens so
the tokenizer degrades on a Python without punctuation_chars instead of
crashing (which would skip Rule 4 entirely).
Self-test 71 cases; verified live: leading-redirection push to develop denies.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CopilotAI review requested due to automatic review settings July 25, 2026 22:27

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

host-setup/agent-safety/gh-write-guard.py:334

  • _check_bypass_flags() treats any --no-verify token in a git commit argv as a bypass flag, but git commit -m --no-verify is valid and sets the commit message to --no-verify (i.e., --no-verify is an option value, not a flag). This can false-deny legitimate commits and contradicts the stated "precision over recall" goal.
 # --no-verify / commit -n skip the git hooks, so they only matter as an actual arg to a git commit or
# push (other tools use --no-verify for unrelated things; shlex keeps a quoted mention out of the argv).
# `-n` is --no-verify only for commit; `git push -n` is --dry-run.
commit_lists = _git_subcommand_arglists(cmd, "commit")
push_lists = _push_arg_lists(cmd)
commit_bypass = any(("--no-verify" in a) or ("-n" in a) for a in commit_lists)
push_bypass = any("--no-verify" in a for a in push_lists)
if commit_bypass or push_bypass:
return "deny", (
"This uses --no-verify, which skips the git hooks (signing, lint, and pre-push gates). "
"Skipping verification is a bypass; run the command without it." + _handoff(cmd)
)

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.

@ptr727
ptr727 merged commit bdb98ad into developJul 26, 2026
8 checks passed
@ptr727
ptr727 deleted the agent-git-bypass-guard branch July 26, 2026 00:16
ptr727 added a commit that referenced this pull request Jul 26, 2026
…450)
Adds one paragraph to the GitHub Copilot Review Runbook's Bounded Retry
Workflow.
## Why
While driving #449 through 13 review rounds in an hour, Copilot
throttled and posted its final re-review ~36 minutes after the request -
beyond a 15-minute poll window. The poll timed out, and the Bounded
Retry Workflow treated that as a *genuinely missing* review and
escalated. It was not missing, only pending.
## Change
Clarify that a slow review is **pending, not missing**: a poll timeout
is evidence only that the review has not landed yet, so report `review
still pending`, poll on a widening interval, and enter escalation only
when the `requestReviews` mutation no-ops/errors or after a genuinely
long confirmed-accepted wait - never on one fixed poll window elapsing.
This complements the #444 head-coverage gate (which prevents concluding
*clean* too early) by preventing the opposite error - concluding
*unresponsive/blocked* too early.
Docs only - no release.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
ptr727 added a commit that referenced this pull request Jul 26, 2026
…449, #450) (#451)
Promotes two develop PRs to main:
- **#449** - Rule 4 in `gh-write-guard.py`: deny any agent git operation
that would only land by bypassing an active branch rule (direct push to
a PR-gated branch, force where history is protected, delete where
deletion is blocked, and the explicit-bypass flags `gh pr merge --admin`
/ `git commit|push --no-verify`). Judged against the branch's live
rules, so a code-style develop denies and a config-style develop allows
with no hardcoded list. 71 self-test cases; 18 Copilot findings resolved
across the review.
- **#450** - Copilot Review Runbook: a slow/throttled Copilot review is
*pending, not missing* - poll with backoff and report "still pending"
rather than escalating on a timeout.
Carried-file/spec changes, so downstream repos re-vendor
`.github/copilot-instructions.md`; the guard lives under `host-setup/`
(hub tooling). Docs/tooling only - no release.
Deploy of the guard to `~/.claude/hooks/` is held until this promotion
passes review; the idempotent host installer + per-machine refresh
tracking (#365) follow separately.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@ptr727