Skip to content

Promote Develop: Read a Push's Remote in the Repository Git Uses, Read Unread Moves Whole, and Name uv's No-Cache Setting - #3715

Merged
ptr727 merged 8 commits into
mainfrom
develop
Oct 31, 2026
Merged

ptr727 merged 8 commits into
mainfrom
develop

Conversation

@ptr727

@ptr727 ptr727 commented Oct 31, 2026 •

Copy link
Copy Markdown
Owner

Promotes the changes merged into develop since promotion #3703 to main, the third round of a backlog burndown.

  • #3709 Reads a push's remote, and the branch of a push with no refspec, in the repository git actually uses in the write guard's owner check (requirement 4). Before, --git-dir and a GIT_DIR on the push were ignored, so a constructed git --git-dir=<other>/.git push origin develop was checked against the working directory's repository rules and allowed. A shape whose repository the guard cannot determine (an earlier GIT_DIR, a -c or --config-env override of a remote. or url. key, any GIT_CONFIG* variable, an unresolvable --git-dir) now blocks main, master and develop. The self-test covers each shape and host-setup/agent-safety/README.md states the requirement.

  • #3712 Reads a file moved out of a source some rule never read in full for that rule in the prose gate: a narrowed directory scope, a home_path_exempt file, instruction text for issue-ref, and a move that changes the file's lexer. Skips YAML block scalars when reading a Compose file's volume entries.

  • #3707 Names uv's no-cache setting, and its remedies, when the repo gate's resolved PyYAML interpreter is gone at launch, rather than reporting a bare missing-file error.

  • #3708 Moves the section-name lookup key into one section_key() in spec/fidelity_text.py, used by the carry, the audit, the skill build and the review ledger, with no behavior change.

  • #3722 Applies env's directory and environment options to the command the write guard judges, answering this promotion's first review round: env -C/--chdir move the repository a relative GIT_DIR and the remote resolve in, env -i/-u clear a GIT_DIR that no longer steers the check, and env -S is read rather than skipped. A run-time directory reads as undetermined, which blocks main, master and develop.

  • #3721 Finds a Compose block-scalar opener through the YAML lexer the gate already uses rather than a hand-written pattern, so a key holding #, quoted or not, opens the opaque region. Also answers this promotion's first review round.

  • #3727 Reads an env option the write guard does not recognize, such as GNU env's --ch abbreviation of --chdir, as leaving the push's repository undetermined, so main, master and develop fail closed, answering this promotion's second review round. A feature-branch push is never newly blocked.

  • #3731 Removes the fallback Read an Unrecognized env Option as an Undetermined Directory in the Write Guard #3727 kept for a push naming no refspec behind an unrecognized env option, answering this promotion's third review round. Such a push resolves its branch only through an absolute --git-dir, git's own -C, or a cd in a wrapper body, and is otherwise read as writing every protected default, so it fails closed even from a feature branch.

Deferred review findings: #3705 (scripts/README.md's uvx promise), #3706 (validate.py's exact section-name match), #3710 (whether section_key should casefold), #3711 (Compose-name and directory moves in the prose gate), #3713 (the review reader's open review findings: spelling), #3714 (an edited shebang that changes a file's lexer), #3720 (the write guard's reading of git push -o<value>), #3723 (unused _bare_push_branch parameters), #3729 (a bare push after a branch switch earlier in the same command), #3732 (the review reader's Review findings remain: spelling).

For the maintainer: #3730 asks whether every bare push whose repository the guard cannot determine should fail closed, as #3731 now does behind an unrecognized env option.

Fixes #3695
Fixes #3684
Fixes #3694
Fixes #2358
Fixes #3686
Fixes #3716
Fixes #3717
Fixes #3725
Fixes #3728

🤖 Generated with Claude Code

ptr727 and others added 4 commits October 31, 2026 10:08
Moves the section-name lookup key (strip, then case-fold) into
`spec/fidelity_text.py` as `section_key()` and points every section-name
site at it: `section_span` and `strip_sections`, `scripts/build_dist.py`
`heading_body`, `scripts/carry.py` `fold()`, `spec/audit.py`,
`spec/audit_markdown.py` (including `readme_section_findings`), and
`scripts/canonical_review.py`. A future change to heading-key
normalization now reaches every lookup path in those files. No behavior
change: the existing tests are the proof, plus one new test of the
helper. OPERATIONS.md's primitives entry names the new function.

Closes on promotion: #3686

Remaining findings:
- #3706: `spec/validate.py` compares files.json section names to H2 text
exactly, which disagrees with `section_key` (class: pre-existing,
filed).

- #3710: `section_key` lowercases rather than casefolds (raised by
Copilot; class: pre-existing, filed).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
…te Guard (#3709)

## Summary

#3695 asked whether the write guard's requirement 4 reads a push's
remote in the repository a `--git-dir` names. It did not. A check with
two constructed checkouts, `a` with origin `github.com/alpha/x` and `b`
with origin `github.com/beta/y`, run from `a`, showed the guard querying
`alpha/x` for each of these:

- `git --git-dir=<b>/.git push origin :feature/x`, and the same with a
separate `--git-dir` value
- `git --git-dir=<b>/.git push origin develop`, where `beta/y` required
a pull request on `develop` and the guard allowed the push
- `GIT_DIR=<b>/.git git push ...`, `env GIT_DIR=... git push ...`, and
`export GIT_DIR=...; git push ...`

`-C <b>` and `-C <b> --git-dir=.git` already read `beta/y`, and
`--work-tree=<b>` reads `alpha/x`, as git itself does.

- `_push_repo` now reads the remote's push URL in the repository git
uses: the one a `--git-dir`, or else a `GIT_DIR` the push itself carries
(as a prefix or through `env`), names, resolved from the directory after
`-C`.
- A `GIT_DIR` set earlier in the text reads as undetermined, since
whether git sees it turns on an export or an `unset` the reading does
not track.
- A `-c` or `--config-env` override of a `remote.` or `url.` key, a
`GIT_CONFIG` variable in the text, and a `--git-dir` the reading cannot
resolve each leave the repository undetermined. That fails closed for
`main`, `master`, and `develop`, as the spec already states for an
unresolvable origin.
- A push naming no refspec now reads its branch in the repository a
`--git-dir` or the push's own `GIT_DIR` names, an absolute `--git-dir`
even after an unresolved `cd`. It was left unjudged before, so such a
push into a `develop` checkout passed. One whose repository stays
undetermined is still allowed, as the README now states.
- A URL remote now resolves even where the working directory does not.
- The self-test gains a live check over two real temporary checkouts,
one for each shape's repository and one for each bare push's branch.
- `host-setup/agent-safety/README.md` requirement 4 states which
repository the remote is read in.

## Verification

- `python3 host-setup/agent-safety/claude/gh-write-guard.py --selftest`:
SELFTEST PASS, each new repository and branch case `ok`.
- Local strict review: one pass raised three findings (the bare-push
branch, README wording, a self-test case that also passed on the old
code). Each is fixed in the second commit. Copilot's round then raised
two (an earlier, unexported `GIT_DIR` trusted, and an absolute
`--git-dir` after an unresolved `cd`), fixed in the third. The last
local pass, over the full diff at the head, raised none.
- ruff format and check, mypy, `tests/test_guard_size.py`, and the prose
gate on the diff: clean.

Closes on promotion: #3695

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ishes (#3707)

When PyYAML is not importable, the repo gate's `composite-actions` check
resolves an interpreter through `uvx --with pyyaml python` and then runs
its reader on that interpreter. Under `UV_NO_CACHE` or uv's `no-cache =
true`, uv deletes that environment when uvx exits, so the launch failed
with a bare "No such file or directory: '<temp path>'".

Design: name the cause and the remedy in the existing single host
finding, rather than falling back to running the reader inside `uvx` per
file. The naming is a few lines and adds no second read path. A per-file
fallback would add a path that re-resolves the environment for every
action file and is only reachable on this host shape. The finding now
reads: "the interpreter uvx provided could not start: [Errno 2] ...:
'/gone', because uv deletes the environment when uvx exits under
UV_NO_CACHE or uv's no-cache = true (unset UV_NO_CACHE, turn off uv's
no-cache setting, or make PyYAML importable), so no run body was
shellchecked". Only `FileNotFoundError` gets the explanation (it is also
what Windows reports as WinError 2); other start failures keep the plain
message. The `load_action` docstring notes the exception.
`scripts/repo_gate.py` is a runpy wrapper, so only the action file
changed.

Tests pin the full rendered sentence with a stubbed `subprocess.run`,
and a PermissionError case that must not blame the uv cache.

Closes on promotion: #2358

Remaining findings:
- #3705: `scripts/README.md` lines 3 and 127 still promise that `uvx`
alone suffices; held by open PR #3704, filed for follow-up.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
…alar in the Prose Gate (#3712)

## Summary

Two fixes to the prose gate, both in
`.github/actions/prose-gate/prose_lint.py`.

- **#3684, a moved file's unread source.** In diff mode, `changed_lines`
read a renamed file's destination in full only when its source sat under
a generated tree or matched an `--exclude`. It now also does so when:
- the source sat outside a run narrowed to directories. Only directories
narrow, since a file list is what a pre-commit hook passes from a
commit, and it names the destination alone.
  - the move changes the lexer the file reads through.
- **#3684, per rule.** A move out of a file some rule never read is read
in full for that rule alone, with every other rule keeping the move's
own hunks. The rule-to-list mapping is data, a new `file_exemption` key
on the `home-path` rule in `prose-gate.json`. The same comparison covers
`issue-ref` on a move into instruction text. `rule_home_path` now reads
its exemption through the same `exempt_rules` helper. The scope note
counts a file read whole for a rule in full.
- **#3694, a Compose block scalar.** `container_paths` treats a key or
sequence line ending in a `|` or `>` block-scalar indicator, with
chomping and indentation indicators, as opening opaque text. It skips
deeper lines until indentation returns to the opening node's column. So
a `volumes:` key or a mount-shaped line inside a scalar is no longer
taken as a volume entry.

## Verification

- `tests/test_prose_lint.py`: 655 passed. Each new case fails against
`develop`'s gate.
- The gate on its own diff exits 0. `mypy` and `ruff` are clean.
- Local strict review ran three rounds at the `frontier` tier. Round one
found a pre-commit file-list regression, sibling path dependencies, a
consolidation, and a stale scope count. Round two found a `dead-path`
over-read. Round three found nothing. The consolidation with
`nested_spans`' YAML lexer walk was declined: `home-path` reads only the
`source` unit, and `check_file` skips Pygments when no requested rule
needs more. The reviewer accepted that.

## Deferred

- #3711: a move that changes a Compose name, or a Markdown file's
directory, changes only part of what `home-path` and `dead-path` report.
A whole read there would surface every older finding in the moved file,
so these cases are deferred to a finding comparison across the move.

## Pre-existing findings, not filed

- `.github/actions/prose-gate/prose_lint.py` `changed_lines`,
correctness: an extensionless script whose edited shebang changes its
lexer brings only the edited line into scope, with or without a move.

Closes on promotion: #3684, #3694

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings October 31, 2026 17:24
@coderabbitai

coderabbitai Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Important

  • Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

Run configuration
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

Copilot AI 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.

🟡 Changes recommended

A critical active finding remains because env directory changes can cause the guard to resolve and validate the wrong repository, owner, or branch instead of failing closed.

1 open issue
What changed in this PR

This pull request promotes the third backlog-burndown round from develop to main, improving push repository resolution, prose-gate move and YAML handling, uv diagnostics, and shared section-name normalization.

Changes:

  • Resolve push remotes and branches from Git’s effective repository configuration, failing closed when unresolved.
  • Expand prose-gate handling for moved files and skip Compose block-scalar contents during volume analysis.
  • Clarify uv no-cache interpreter failures.
  • Centralize section-name lookup through section_key().
  • Outstanding findings:
    • Critical: env -C and env --chdir changes are not reflected when resolving GIT_DIR.
    • Moderate: valid YAML mapping keys containing # are not recognized by block-scalar detection.
File Description
tests/​test_repo_gate.py Tests uv interpreter startup diagnostics.
tests/​test_prose_lint.py Tests moved-file scope and Compose scalar handling.
tests/​test_fidelity_text.py Tests section-key normalization.
spec/​fidelity_text.py Adds the shared section-name lookup helper.
spec/​audit.py Uses the shared section key for section presence checks.
spec/​audit_markdown.py Uses the shared section key across Markdown checks.
scripts/​carry.py Routes carry section folding through the shared helper.
scripts/​canonical_review.py Uses the shared helper for review-unit matching.
scripts/​build_dist.py Uses the shared helper for include heading lookup.
OPERATIONS.md Documents the section-key primitive.
host-setup/​agent-safety/​README.md Documents push repository and branch resolution behavior.
host-setup/​agent-safety/​claude/​gh-write-guard.py Resolves push repositories and branches from Git options and command environments.
.github/​actions/​repo-gate/​repo_gate.py Explains missing uv-resolved interpreters caused by no-cache settings.
.github/​actions/​prose-gate/​prose-gate.json Declares rule-specific file exemption metadata.
.github/​actions/​prose-gate/​prose_lint.py Adds per-rule moved-file scope handling and Compose block-scalar skipping.

This pull request requires changes.

Review effort: Lite

@codecov

codecov Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 82.02%. Comparing base (b30ce55) to head (5ad2acf).
⚠️ Report is 454 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #3715   +/-   ##
=======================================
  Coverage   82.02%   82.02%           
=======================================
  Files          27       27           
  Lines        9273     9275    +2     
=======================================
+ Hits         7606     7608    +2     
  Misses       1667     1667           
Flag Coverage Δ
python-3.13 82.02% <100.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

ptr727 and others added 2 commits October 31, 2026 11:05
…se Gate (#3721)

Closes on promotion: #3717

Fixes a finding raised on promotion #3715.

The prose gate's Compose reader missed a block scalar opened by a valid
mapping key containing `#` (for example `x-a#b: |`, or a quoted key such
as `"x #b": |`), so mount-shaped text inside it was read as a volume
entry and the home-path rule skipped it. `container_paths` now finds
block scalar openers through the existing pygments YAML tokenization,
reusing `Starts`, and the hand-written `BLOCK_SCALAR` regex is removed.
Tests cover a key with `#`, a trailing comment after the indicator, and
quoted keys.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Fixes a finding raised on promotion #3715, against the change #3709
made.

## What was wrong

`_launch` in `host-setup/agent-safety/claude/gh-write-guard.py` read
past an `env` launcher without applying it. Constructed scratch
repositories reproduced each shape, two checkouts whose origins name
different owners, one with a pull-request rule on `main`:

- `env -C <dir>`, `env --chdir=<dir>`, and `env --chdir <dir>` left the
push judged in the caller's repository. So `env --chdir=/srv/other
GIT_DIR=.git git push origin main` read the wrong remote and allowed.
- `GIT_DIR=<mine>/.git env -i git -C <other> push origin main` and the
same with `env -u GIT_DIR` read the cleared `GIT_DIR`, so the check
again ran against the wrong repository.
- `env -S '<command>'` and `env -S'<command>'` were never read at all,
so every requirement missed the command, including requirements 1, 3,
and 6.

## What changed

- `_launch` now returns a `_Launch` carrying what an `env` changes.
`-C`/`--chdir` moves the directory the command runs in, composed before
a git `-C`. A run-time directory leaves it undetermined, which fails
closed for `main`, `master`, and `develop` under requirement 4, as #3709
treats an unresolved `GIT_DIR`. `-i`/`-` and `-u`/`--unset` clear what
the command inherits, and `-S`/`--split-string` is split into the words
it runs. A short cluster such as `-iC dir`, an unambiguous long prefix,
and the value-taking `-a` and `-P` read the way `env` reads them.
- `_read` applies those to each command's environment, its own
assignments, and its directory, and passes the moved directory into an
`sh -c` body.
- Self-test cases cover each shape, against the real two-owner checkouts
for requirement 4's repository and branch, and against the fixtures for
requirements 3 and 6.
- A push naming no refspec now resolves an absolute `git -C` from an
unnamed directory, after a `cd "$X"` or an `env -C "$D"`, as the push's
remote already did. Raised in review here, and it was already reachable
through `cd "$X"`.
- `host-setup/agent-safety/README.md` states how an `env` is read, and
that an `env -C` moves the directory requirement 6 tests.

## Local strict review

Three recorded passes at the `frontier` tier, the last over the final
content with no findings. The first pass's findings on an `env -S`
comment, a repeated `-C`, and long-option prefix matching are fixed
here, and the second pass's README wording finding is fixed here too.

Two more recorded passes covered the review fix on the second push. The
first of them caught a signature change that broke `test_install.py`,
which is held by #3704, so the signature was restored.

### Pre-existing findings, filed

- `host-setup/agent-safety/claude/gh-write-guard.py` `_push_targets`,
`pre-existing`: an all-letter word after `-o` is read as a flag cluster,
so `git push -ofoo origin develop` is misread. Filed as #3720.

### Deferred

- `host-setup/agent-safety/claude/gh-write-guard.py`
`_bare_push_branch`, `introduced`, design: its `leading_cd` and `moved`
parameters are now reached only from `test_install.py`. Removing them
needs that file, so it is filed as #3723.

### Dropped findings

- `host-setup/agent-safety/claude/gh-write-guard.py` `_env_option`,
`_skips_hooks`, `_push_targets`, consolidation: one shared short-option
cluster reader. The other two are untouched code with different value
letters, so merging them is a change of its own.
- `scripts/docker_lint.py` `shell_shebang_interpreter`, consolidation:
it also walks `env` options. Declined because the hook installs as one
standalone file and cannot import it.

Closes on promotion: #3716

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@ptr727

ptr727 commented Oct 31, 2026

Copy link
Copy Markdown
Owner Author

Answering the first round's outstanding findings on 27870ae. Critical, env -C/env --chdir not reflected when resolving GIT_DIR: fixed in #3722 (8d71126), answered on its thread. Moderate, a YAML mapping key containing # not recognized by block-scalar detection: reproduced and fixed in #3721 (5f04434). The Compose reader now finds block-scalar openers through the YAML lexer the gate already uses, so x-a#b: |, "x #b": | and 'x # b': | all open the opaque region; a fuzz of about 15,000 generated documents matched PyYAML's block-scalar rows exactly. Tracked as #3717. Both fixes are in this head.

Copilot AI 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.

🟡 Changes recommended

A critical active finding remains because unambiguous GNU env long-option prefixes can bypass the guard’s command analysis and allow an unsafe git push.

1 open issue
1 resolved since last review

Review effort: Lite

…rite Guard (#3727)

## Summary

Fixes a finding raised on promotion #3715. GNU `env` accepts a unique
abbreviation of a long option, so a constructed `env --ch=/srv/other
GIT_DIR=.git git push origin main` runs git in `/srv/other`. The write
guard read `--ch` as a no-op and resolved the push's repository from the
working directory.

- An `env` option the guard does not recognize, long or short, now
leaves the directory a push runs in unnamed for requirement 4.
`_push_repo` and the bare-push branch reader take `None` in place of the
working directory, the value `env -C "$D"` already produces, so
requirement 4 fails closed only for `main`, `master`, and `develop`, as
#3709 and #3722 treat a shape they cannot resolve. An absolute
`--git-dir`, git's own `-C`, or an absolute `cd` in a wrapper body still
names the repository. A `GIT_DIR` behind such an option is not read for
the repository, since the option may abbreviate `--unset`. Prefix
matching was decided against.
- Each unrecognized option may or may not take the next word, and a
value it takes may be an `-S` string, which reaches an abbreviated
`--split-string`. `_launches` reads every combination of up to four of
those choices. A reading past the first adds commands only and changes
no shell state, and a subshell restores the body's undetermined state
with its directory.
- A push naming no refspec or `HEAD` behind such an option, whose branch
nothing else names, reads it as if the option were absent. So it is
never weaker than before and never newly blocks a feature-branch push.
- `--null`, `--debug`, and the GNU signal options are now listed, so
they keep reading as no-ops. `-0`, `-v`, `-i` and the value letters keep
their behavior.
- Requirement 6 keeps judging the working directory. The extra readings
can only add a deny there, for example `env --chd /path/wt git commit`
run from a primary checkout.
- `host-setup/agent-safety/README.md` states the new reading in its
lexer paragraph, in requirement 4, and in requirement 6.

## Verification

- Reproduced first with `classify` on two constructed scratch checkouts,
origins `alpha/x` and `beta/y`, with a stubbed live-rules lookup
requiring a pull request on `beta/y`'s `main`. Before the fix
`--ch=<b>`, `--chd <b>`, and `-X <b>` pushes to `main` were allowed.
After it they are denied, and feature-branch pushes stay allowed.
- New self-test cases cover `--ch=<dir>`, `--chd <dir>`, an unknown
short letter, two unrecognized options of different arity, an
abbreviated split string, an unknown option ahead of `sh -c`, a subshell
in the body, absolute `-C`/`--git-dir`/body `cd` re-anchoring, a URL
remote, bare and `HEAD` pushes, `GH_REPO` not leaking from a speculative
reading, each recognized option unchanged, and allow on `feature/x`.
Each new repository and branch row fails with its fix reverted.
- `gh-write-guard.py --selftest` passes. Pinned ruff format and check,
mypy, `tests/test_guard_size.py`, and the prose gate pass.
- Seven local strict-review passes, the last raising nothing.

## Dropped findings

- `gh-write-guard.py` `_env_option`, `style`: reading an unknown short
letter as unrecognized was named as over-hardening. The decided fix for
#3725 names short options explicitly.

## Pre-existing findings, not filed

- `gh-write-guard.py` `_bare_push_branch`, `pre-existing`: its
`leading_cd` and `moved` parameters answer no caller, since every caller
passes `None` and `False`. One caller is in
`host-setup/agent-safety/claude/test_install.py`, outside this change.

Closes on promotion: #3725

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI 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.

🟡 Changes recommended

A critical active finding shows that an unknown environment option can cause a protected-branch push to be incorrectly allowed, requiring changes before approval.

1 open issue
1 resolved since last review

Review effort: Lite

…n in the Write Guard (#3731)

Fixes a finding raised on promotion #3715.

Closes on promotion: #3728

## What changes

`_bare_branch` in `host-setup/agent-safety/claude/gh-write-guard.py`
read the branch a push naming no refspec (or `HEAD`) writes. Behind an
`env` option the guard does not recognize, it fell back to reading that
branch in the caller's directory with the command-carried `GIT_DIR`, as
if the option were absent. So from a feature branch, `env
--ch=/srv/other git push origin` was judged on the caller's feature
branch while git may push `main` in `/srv/other`.

- The fallback is deleted, along with the `lenient` read of
`_named_git_dir` it alone used.
- Behind such an option the branch now resolves only where an absolute
`--git-dir`, git `-C`, or `cd` names the repository, as the existing
reading already does.
- Otherwise `_bypass_denial` reads the push as writing every branch
protected by default, as `--all` is, so a protected default fails
closed. `_push_targets` now takes the branches a bare or `HEAD` push may
write as a tuple, so that expansion has one home.
- A denial from that expansion carries its own reason, naming the
unrecognized option and the ways to anchor the push, rather than
claiming a write to `main`.
- Requirement 4 in `host-setup/agent-safety/README.md` states the new
behavior and its cost once: such a push may be refused even from a
feature branch.

## Verification

- Reproduced first with the guard's own `classify` on two constructed
scratch repositories, one checked out on `main` and the caller on a
feature branch. Before: `env --ch=<main repo> git push origin` allowed.
After: denied. `env --ch=<main repo> git push origin feature/x` stays
allowed, and an absolute `git -C` or `--git-dir` naming the
feature-branch repository is judged on its branch and allowed.
- New and changed self-test rows: the no-refspec case and `HEAD` behind
an unknown option are refused, an absolute `--git-dir` or `git -C`
behind one resolves to that repository's branch, an absolute `git -C`
naming a feature-branch checkout behind one is allowed, the
explicit-refspec feature push stays allowed, and an unresolved bare push
with no `env` option stays allowed.
- `gh-write-guard.py --selftest`: SELFTEST PASS. Prose gate, ruff check
and format, and pyright are clean.

## Local strict-review findings

The two findings the opening push listed as open (no row checked which
reason a denial carries, and the `_bare_branch` docstring naming a plain
`cd` as an anchor) are fixed in later commits on this branch. Self-test
rows now mark the dedicated reason, pin both halves of the condition
that chooses it, and, through the rules seam, pin that the expansion
reaches each of `main`, `master`, and `develop` for a bare, `HEAD`, and
`@` push. Each row was checked against a mutant it must kill. The last
local pass over the pushed head raised no findings.

## Copilot review on 7a3b468

- Its three threads (docstring anchor scope, the wrapper-body `cd`
remedy, a reason-regression self-test) are fixed and resolved.
- Its overview also listed, with no thread, "Scope the unresolved-branch
denial reason to only the targets produced by the expansion". Fixed: the
dedicated reason now applies only to a denied target the push does not
name, and named targets are judged first, so `HEAD develop` with a rule
on `develop` keeps the ordinary reason.

## Filed separately

- #3729: a bare push after an earlier branch switch in the same command
is judged on the old branch (pre-existing).
- #3732: `pr_review.py` has no spelling for Copilot's `Review findings
remain:` label, seen on this PR.
- #3730: whether every bare push with an undetermined repository should
fail closed, and whether requirements 4 and 6 should read an
unrecognized `env` option the same way (decision for the maintainer).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI 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.

🔵 Needs a closer look

Human assessment is needed because a critical hidden finding indicates that multiple ordered env -C directory changes are mishandled, potentially applying the wrong remote and branch rules.

No open issues 🎉

1 resolved since last review

Review effort: Lite

@ptr727

ptr727 commented Oct 31, 2026

Copy link
Copy Markdown
Owner Author

Answering the round's needs a closer look headline on 5ad2acf, which counts no finding and names one: "multiple ordered env -C directory changes are mishandled, potentially applying the wrong remote and branch rules." Declined, the claim does not reproduce.

Ground truth from GNU coreutils env, run against constructed directories: within one env only the last -C takes effect, relative to the starting directory (env -C a -C b pwd gives s/b), and separate env launchers stack (env -C a env -C b pwd gives s/a/b). The guard models exactly that: _launch overwrites chdir within one env, appends one entry to chdirs per launcher, and _moved composes those entries and then any git -C values in order.

Checked with three constructed repositories whose origin names a different owner (alpha/x on main requiring a pull request, beta/y nested inside it, gamma/z alongside), classifying each command through the guard's real resolution path and comparing the owner, the branch a no-refspec push writes, and both verdicts with what git remote get-url --push origin and git rev-parse --abbrev-ref HEAD give under the same launcher text. All 33 shapes where git resolves a repository match, covering stacked env launchers, several -C or --chdir in one env, env -C mixed with git -C, cd and sh -c, relative GIT_DIR set before or after the -C, and env -i/-u. The alpha main cases are denied for both an explicit and a no-refspec push.

A separate, pre-existing question surfaced while checking (a no-refspec push whose branch the guard cannot read for a reason other than an unrecognized env option is allowed) is recorded on #3730, which already asks it.

@ptr727
ptr727 merged commit ba3669a into main Oct 31, 2026
14 checks passed
ptr727 added a commit that referenced this pull request Nov 1, 2026
…nd Add a backlog-burndown Away Mode (#3736)

Promotes the changes merged into `develop` since promotion #3715 to
`main`.

- [#3704](#3704):
squash-merges a gate-met feature pull request through `pr_review.py
merge`, and the host-safety kit now owns its `autoMode` entries.
- [#3735](#3735) adds an
away mode to `backlog-burndown`:
- Parallel file-claimed rounds with no maintainer present, run by a lean
orchestrator.
- A group meeting a question parks behind a `decision` issue instead of
asking.
- Merges go only as far as the `develop`, `main` or `release` scope
named at invocation.
  - The end report names each held-back issue's real reason.

It also adds the attended `decision-triage` skill, which researches and
asks every open question at once, and retires `unattended-handoff`.
- [#3737](#3737) reads a
pull request that reads back closed after `pr_review.py merge`'s merge
call as a refusal that merged nothing, rather than as an unconfirmed
merge. It answers this promotion's first review round.

After the release, the host-safety installer re-run that #3704 needs is
still the maintainer's, per its handoff #3734. That handoff's step 3
names an `unattended-handoff` worker. That skill is retired here, so the
verification runs from a `backlog-burndown` worker instead.

Fixes #3375

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment