Repository navigation
Read Each Refspec's Push Operation, Include Overrides, and Fused Separators in the Write Guard - #2963
Conversation
… Fused Separators in the Guard Each refspec now carries its own force or delete, so one refspec's + or : no longer taints the other branches in the push, and a force or delete is checked against a pull_request rule as an update is. A git command whose subcommand git does not list, run under an include.path or includeIf override, is denied as an unreadable push, since the included file may define it as a push alias. A separator fused with a redirection, such as ;< or &<, now ends the argument list at its separator, and its separator part is read for a background job. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A quoted word made of operator characters, such as ';<', is an ordinary word or a redirection target, so it no longer ends the argument list. The break now needs the quote mask to mark the token unquoted, and a push beside a fused separator in a command whose quoting the guard cannot read fails closed. The push-specific split-word deny no longer fires on a command the include pre-filter alone sent to the push check, and one helper reads a config key for both the hooks-path and include checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The quote-keeping fallback mask cannot see a word inside a backtick substitution or a $'...' quote, so a fused separator there read as one that ends the argument list, hiding the push's later refspecs and a later --no-verify. The fused break now reads only the mask `_marked_lex` gives, and a push beside a fused separator in a command it cannot model fails closed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… Quote Mask No quote mask the guard has can tell a fused separator such as ;< from a quoted word or one inside a parameter expansion or a substitution, and each narrowing of the mask opened another shape. A push whose words hold such a token is now read twice, once ending at the separator and once reading the token as a redirection as before, and both readings are checked. Every other rule keeps develop's reading. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The split reading of a push holding a fused separator joined the list a whole-command scan matches its pushes against, so it could stand in for a push only that scan finds and drop the unresolved read that push needs. Split readings are now appended after that bookkeeping, which leaves develop's readings exactly as develop reads them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With each refspec's operation read on its own, a forced branch pattern such as +refs/heads/*:refs/heads/* beside a lone : no longer forced the branches the : matched, while the pattern itself read as a branch named *. A pattern whose destination can name a branch is now read like a lone :, writing every protected default with its own operations, and a tag pattern writes no branch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reading a branch pattern only as matching the protected defaults dropped develop's lookup of the name the pattern spells, which a ruleset pattern such as release/* matches. The pattern's literal destination is read as a target again, beside the matched defaults. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
📝 Walkthrough
Merge Risk: ⚪ Minimal · up to
|
|
A recorded local strict-review pass covers head |
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved guard correctness and bypass issues remain, including a critical fused-separator parsing issue.
2 open findings
What changed in this PR
This PR hardens the Git write guard against refspec, include-config, and fused-separator bypasses.
Changes:
- Tracks force/delete operations per refspec.
- Denies unreadable include-based aliases.
- Adds separator handling, documentation, and self-tests.
Review findings:
- Critical (1 vote): Numeric file-descriptor parsing can miss a fused separator and allow a protected bare push.
- Moderate (3 votes): Wildcard operations are incorrectly applied to unrelated refspecs.
- Moderate (1 vote): Non-
.pathincludeIfkeys are incorrectly treated as file includes. - Nit (1 vote): Force/delete denial messages are inaccurate.
| File | Summary |
|---|---|
host-setup/agent-safety/README.md |
Documents refspec and include protections. |
host-setup/agent-safety/claude/README.md |
Updates the guard behavior summary. |
host-setup/agent-safety/claude/gh-write-guard.py |
Implements guard logic and self-tests; contains the review findings above. |
🧠 Review effort: Lite
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## develop #2963 +/- ##
===========================================
+ Coverage 59.96% 60.02% +0.06%
===========================================
Files 17 17
Lines 8778 8784 +6
===========================================
+ Hits 5264 5273 +9
+ Misses 3514 3511 -3
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
A number before a fused separator, as in 2;<, is a word of the push, not the descriptor of a redirection, so the split reading now ends there. An includeIf key other than its path includes no file, so only the path key reads as an include, and the pull_request deny names a push that writes the branch rather than a direct push. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reading only an includeIf key ending in .path missed a --config-env value whose condition holds an =, since git splits that option at its last = while the guard reads the key up to the first. Every includeIf key reads as an include again, a constructed case pins the shape, and the argument reader's docstring states the split reading's descriptor rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
A recorded local strict-review pass covers head |
|
Answers to the four entries under
The pull request body's own "Local Review" list, answered the same way:
|
|
@coderabbitai review |
✅ Action performedReview finished.
|
…ps, and the Refusal Field Wording to Main (#2965) - #2963 makes `gh-write-guard` read each push refspec's own operation, so a `+src` forces and a `:dst` deletes only its own branch while `--force` and `--delete` reach every refspec, and every operation, force and delete included, is checked against `pull_request`. A git command under an `include.path` or `includeIf.<condition>.path` override whose subcommand is not a known git command now denies, since the included file may alias it to a push. A separator fused with a redirection (`;<`, `&&<`, `|<` and similar) is read both ways, so the next command's words no longer read as the push's refspecs. A differential against the previous guard found every allow flip in the intended per-refspec class and none under a rule set holding `pull_request`. The known new over-denials are listed in that pull request, and the fixed-prefix branch pattern case is #2964 - #2956 tightens the CLI usage gate: `tests/test_cli_usage.py` declares the entry points that need no argument and fails one declared nowhere, `CODESTYLE.md` "Command-Line Arguments" states the gate's stricter reading of help and unknown-argument refusals, `scripts/carry.py` shares one porcelain status parser with the gate, and the Windows UTF-8 decode probe in `tests/test_prose_lint.py` now sees the reader thread's exception - #2955 records the attached `-c<name>=<value>` / `-C<dir>` git option disproof in the Disproved Claims ledger and states in `scripts/README.md` when the digest's `refusal=` field and `wait`'s exit code differ Closes #2941 Closes #2946 Closes #2947 Closes #2948 Closes #2949 Closes #2951 🤖 Generated with [Claude Code](https://claude.com/claude-code)


Summary
Closes the three write-guard ALLOW gaps #2951 names, in
host-setup/agent-safety/claude/gh-write-guard.py, with each of the issue's commands added as a constructed self-test case.Changes
_push_targetsno longer keeps one aggregate force and delete flag per push. Each refspec carries its own operation set:+srcforces only its own branch,:dstdeletes only its own, and--force/--deleteapply to every refspec._check_push_bypassnow checks every operation, a force and a delete included, againstpull_request, after the force and delete checks. A lone:and a branch pattern (a destination holding*that can name a branch) are read as writing every protected default and each branch named beside them with their own operations. A pattern is also still read as the branch its literal destination spells, so a ruleset pattern such asrelease/*is still consulted. A tag pattern writes no branch.include.pathorincludeIf.<condition>.pathoverride, given by-cor--config-env, whose subcommand is not ingit --list-cmds=builtins,main,others, is an unreadable push and denies, since the included file may define it as a push alias. Theclassifypre-filter gains_GIT_INCLUDEso a command holding nopushword reaches the check._config_keyis shared with the hooks-path check.;<,&<,&&<,|<, newline+<and similar into one token, which was read as a redirection, so the next command's words read as the push's own refspecs. A push holding such a token is now read twice in_push_invocations: once as before, and once ending at the separator (_collect_arglist(..., split_fused=True)). Both readings are checked, and in the split one a number before the fused token is a word rather than a file descriptor. Every other caller of_collect_arglistkeeps develop's reading, because no quote mask the guard has can tell a fused separator from a quoted word or one inside an expansion._forks_out_of_reachreads the separator part of such a token (_leading_separator) for a background&._GIT_CASES,_PUSH_DIR_CASES, and the wait table, and the_GIT_COMMAND_NAMESpin now covers the_GIT_CASESloop so the include cases do not depend on the host's git.host-setup/agent-safety/README.mdand the guard summary inhost-setup/agent-safety/claude/README.mddescribe the per-refspec reading and the include deny.Differential Against Develop's Guard
As for #2945 and #2952, develop's guard (the merge base) and this branch's guard were loaded side by side and run over the same calls.
$'...',${...},$(...),$(<...), process-substitution and comment spellings, branch and tag patterns, include overrides, and gh and wait shapes. A pipeline continued onto the next line and a separator fused with a redirection are both in it.classifycall the self-test makes (815) plus 10,245 extra shapes under 20 current-branch and rule seams (205,715 calls).Every allow flip (develop denies, this branch allows) is in the intended per-refspec class, and none occurs under a rule set holding
pull_request. In each, another refspec's+or:dstno longer forces or deletes a branch it does not name:git push origin +feature/x developandgit push origin :feature/z develop, with develop protected only bynon_fast_forwardordeletion.git push origin : +feature/yandgit push origin '+refs/tags/*' developno longer force develop.git push origin +develop feature/y, with rules onfeature/yonly, no longer forcesfeature/y.The separator fix flips nothing toward allow by construction, since develop's reading is still checked. Every other flip is toward deny. Those are the issue's fixes, a force or delete checked against
pull_request, the include denies, and the over-denials below.Known New Over-Denials
git push ;</dev/null origin develop, whereorigin developis really a separate command, denies.refs/heads/feature/*:refs/heads/feature/*, is read as writing the protected defaults too, so it denies where a default holdspull_request.&, such as&<, now reads as a background job for the unbounded-wait rule.includeIf.key reads as an include, one git ignores included, since a--config-envcondition holding an=cuts the key the guard reads short of its.path.Local Review
Seven frontier passes ran. The first six each found an allow flip that an earlier fix had introduced, and each is fixed:
';<') ending the argument list.$'...',${...}or$(<...)that the quote mask marked unquoted. The mask was then dropped for the two-reading design above.:.This ran past the two-round edit budget
local-strict-reviewsets, deliberately. Each remaining finding was a new allow in a safety hook, and stopping would have shipped it. The seventh pass, recorded on the pushed head, found no unintended allow flip. Its outcomes:refs/*-style patterns and a pattern matching a non-default protected branch, added to Close the Write Guard's Remaining Push Detection Gaps Found Reviewing the #2936 Fix #2943.Other pre-existing gaps the passes found, each equal on develop:
GIT.rebase --exec,submodule foreachandeval.--exec-pathbeside an include.Copilot Round
Copilot's first round raised two threads and two body-only findings, each answered on the pull request. A number before a fused separator is now a word in the split reading, and the
pull_requestdeny message says the push writes the branch. Narrowing the include check to.pathkeys, as one finding asked, opened the--config-envshape above, so a ninth local pass caught it and the narrowing is reverted. The pattern over-denial is #2964. Two local passes ran on the fix commits, and the last found no new allow.Verification
gh-write-guard.py --selftest: 858 cases, SELFTEST PASS. Each new deny case fails against develop's guard, and the split-bookkeeping case also fails against the commit before its fix.ruff check,ruff format --check,mypy,python3 -m unittest discover -s tests,test_install.py,build_dist.py --check,repo_gate.py(default,eol,eol-coverage),prose_lint.pyon the changed files and--diff origin/develop,spec/validate.py, anddocker_lint.pyall pass.Closes on promotion: #2951
🤖 Generated with Claude Code
Summary by CodeRabbit