From 3c8a0084a65eec8b73c0dd4ca1de3d0020d313d5 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 13 Oct 2026 13:56:30 -0700 Subject: [PATCH 1/3] Record the Attached git Option Disproof and Qualify the Refusal Field Sentence (#2955) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Disproved Claims entry for the claim that git accepts an attached `-c=` or `-C` global option, and rewords the closing sentence of the `scripts/README.md` exit `41` paragraph to state when the digest's `refusal=` field and the exit code differ. Prose only, no code change. Fixes #2941 A local strict review raised two findings on the first wording of the README sentence, both fixed. A second pass over the fix found none. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .github/copilot-instructions.md | 4 ++++ scripts/README.md | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 005073974..dd530f28a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -46,6 +46,10 @@ Review effort is user-controlled. The automation observes `Lite`, `Balanced`, or - **Disproved by** - reading the sentence against the snippet it describes. The subject is the noun phrase "the agent check", meaning the check for the signing agent, and "branches" is its verb, which is what the `if [ ... = ssh ]; then ssh-add -L; else gpg --list-secret-keys; fi` line does. The proposed reading needs "branches" as a plural noun, and the paragraph is section 0, before a repository exists, where the alternatives it names are the SSH and GPG forms rather than refs. - **Proved against** - the paragraph following the agent snippet in `STANDUP.md` section 0 on `develop` at `676a2bd`, unchanged since `77be3a3`. - **Delete when** - the sentence is reworded for any reason, since the entry is about this phrasing rather than about the rule it states. +- **Git accepts an attached `-c=` or `-C` global option.** Raised as a P1 in both review rounds of the change that made `host-setup/agent-safety/claude/gh-write-guard.py` read an attached `-c`. + - **Disproved by** - running `git -cfoo.bar=1 status` and `git -C. status` on git 2.47.3. Both print `unknown option` and the usage text for the separate `-c =` and `-C ` forms. Git exits without running the subcommand, so an attached spelling never reaches a repository. + - **Proved against** - git 2.47.3 and `develop` at `ac78916f`. + - **Delete when** - the guard stops reading an attached `-c` or `-C`, or a git release accepts either attached form. ## When in Doubt diff --git a/scripts/README.md b/scripts/README.md index e6eb978ac..dcabc6393 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -193,7 +193,7 @@ Everything else is decidable and says so. One of the reviewer's own nodes in vie The digest reports the completed head review's effective effort as `lite`, `balanced`, or `max` when its metadata provides one. It reports `effort_source=default` for `Default ()` and `effort_source=explicit` for a bare level. Missing metadata reports `unknown` for both fields. Effort is informational and never changes the coverage or completion verdict. The workflow never selects or changes the user-controlled setting. -`wait` exits `41` when the review carrying the head is a **refusal**, meaning its body opens by saying it did not review. That answer arrives as a formal review, `state: COMMENTED`, with the correct commit and zero inline threads, so it satisfies every coverage check a clean pass does and renders a digest byte for byte identical to one. The `40` reasoning does not reach it, because that reasoning rests on a comment carrying no commit, and this carries the right one. A pull request of 301 changed files, one over the reviewer's limit of 300, read as `rounds=1 review_on_head=yes threads=0 unresolved=0 merge=CLEAN` and was one command from merging on a review that never ran. A refusal is therefore not coverage: `review_on_head` reports `NO`, the summary line carries a `refusal=YES` of its own, since `rounds=1 review_on_head=NO` is equally what a stale round looks like and the two want opposite responses, and the body prints whole because its wording is the only thing separating a file-count refusal, cleared by splitting the pull request, from a quota one, cleared by waiting. One body names no cause at all, "Copilot encountered an error and was unable to review this pull request", which is what the weekly rate limit posts, so `status` and `wait` read the job log of the reviewer's own failed Actions run on that commit: a logged rate limit reports as `refusal=QUOTA` with the reset time it states, and anything else as `refusal=ERROR`, a possible quota hit. `wait` sends no request while the pull request's newest Copilot review is a quota or error refusal, on this head or an earlier one, since a request into a reached limit spends what it cannot recover, and exits `46` unless a request already pending after a refusal on an earlier head lands. Where nothing covers the head, the digest's `refusal=` field reads that refusal from the earlier head too. Past the quota and error refusals, the script reads no cause, only that the round declined. The match is on the body's **opening line**, since a refusal is the whole body where a review that merely quotes the wording carries it below its own overview, and this script and this file are exactly that quotation. One line rather than two, because a review's first line is its heading and its second is the overview prose: reading two passed every case except the review describing this check, which reported itself as a refusal of itself. The cost is the other direction, that a refusal introduced by a heading would sit below the opening and be missed, and answering that shape means telling a refusal from an overview rather than reading one line further. It is an alternation over the runbook's phrasings for the same reason the suppressed heading is, and a case asserts the script's pattern is the one the runbook publishes. The `41` reading is **head-scoped**, unlike a suppressed finding, because a refusal is a statement about one commit that a push retires, and a genuine review of that same head outranks it only where it is the newer of the two or the round a `--min-rounds` baseline waited for, coverage that landed being coverage. The field is spent by that coverage as well as the exit code is, or the summary line reads `review_on_head=yes refusal=YES` and tells a reader to split a pull request the reviewer has just reviewed. The liveness query carries no bodies, so a refusal reads there as ordinary coverage. That is deliberate: it ends the wait, which is what a terminal outcome should do, and the full read every wait finishes with is what tells the two apart, so no exit code comes from the cheaper reading. +`wait` exits `41` when the review carrying the head is a **refusal**, meaning its body opens by saying it did not review. That answer arrives as a formal review, `state: COMMENTED`, with the correct commit and zero inline threads, so it satisfies every coverage check a clean pass does and renders a digest byte for byte identical to one. The `40` reasoning does not reach it, because that reasoning rests on a comment carrying no commit, and this carries the right one. A pull request of 301 changed files, one over the reviewer's limit of 300, read as `rounds=1 review_on_head=yes threads=0 unresolved=0 merge=CLEAN` and was one command from merging on a review that never ran. A refusal is therefore not coverage: `review_on_head` reports `NO`, the summary line carries a `refusal=YES` of its own, since `rounds=1 review_on_head=NO` is equally what a stale round looks like and the two want opposite responses, and the body prints whole because its wording is the only thing separating a file-count refusal, cleared by splitting the pull request, from a quota one, cleared by waiting. One body names no cause at all, "Copilot encountered an error and was unable to review this pull request", which is what the weekly rate limit posts, so `status` and `wait` read the job log of the reviewer's own failed Actions run on that commit: a logged rate limit reports as `refusal=QUOTA` with the reset time it states, and anything else as `refusal=ERROR`, a possible quota hit. `wait` sends no request while the pull request's newest Copilot review is a quota or error refusal, on this head or an earlier one, since a request into a reached limit spends what it cannot recover, and exits `46` unless a request already pending after a refusal on an earlier head lands. Where nothing covers the head, the digest's `refusal=` field reads that refusal from the earlier head too. Past the quota and error refusals, the script reads no cause, only that the round declined. The match is on the body's **opening line**, since a refusal is the whole body where a review that merely quotes the wording carries it below its own overview, and this script and this file are exactly that quotation. One line rather than two, because a review's first line is its heading and its second is the overview prose: reading two passed every case except the review describing this check, which reported itself as a refusal of itself. The cost is the other direction, that a refusal introduced by a heading would sit below the opening and be missed, and answering that shape means telling a refusal from an overview rather than reading one line further. It is an alternation over the runbook's phrasings for the same reason the suppressed heading is, and a case asserts the script's pattern is the one the runbook publishes. The `41` reading is **head-scoped**, unlike a suppressed finding, because a refusal is a statement about one commit that a push retires, and a genuine review of that same head outranks it only where it is the newer of the two or the round a `--min-rounds` baseline waited for, coverage that landed being coverage. For a plain refusal the digest's `refusal=` field is spent by any genuine round on the head. The exit code follows the ordering above instead. A `--min-rounds` baseline falling between a genuine round and a newer refusal therefore exits `41` beside `refusal=no`. The liveness query carries no bodies, so a refusal reads there as ordinary coverage. That is deliberate: it ends the wait, which is what a terminal outcome should do, and the full read every wait finishes with is what tells the two apart, so no exit code comes from the cheaper reading. `status` and `wait` both exit `42` where the round covering the head read **fewer files than the pull request changed**, or where an earlier round read fewer and the round covering the head states no coverage of its own, and `43` where it states its coverage in a wording this script does not read. Coverage of the head was the only coverage anything checked, and coverage of the diff is a second reading stated in a line nothing parsed: a partial round carries the right `commit.oid`, raises no threads, and reports "generated no comments", so it is the clean pass byte for byte in everything read. Over 332 Copilot review bodies on this repository, five rounds across three pull requests reported reading fewer files than were changed and all three merged, one of them leaving a file of three unread across **both** its rounds. This is the third instance of the shape `refusal` and `suppressed` are the first two, and the only one nothing was reading. From ee8531c480bd384aaefa45a3ddbbeef45f65ebf6 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 13 Oct 2026 14:25:21 -0700 Subject: [PATCH 2/3] Tighten the CLI Usage Gate, Share Its Status Parser, and Fix the Windows Decode Probe (#2956) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Four follow-ups to the CLI usage gate, for #2946, #2947, #2948, and #2949. - **#2946.** The Windows reader-thread `UnicodeDecodeError` comes from `locale_patch_bites()` in `tests/test_prose_lint.py`, the probe whose call deliberately names no encoding, so adding `encoding=` was not the fix. On Windows `communicate()` decodes in a reader thread, so the error never reached the probe's `except`. The probe returned False, and every `TestGitOutputDecodesAsUtf8` case skipped on the one platform the class exists for. The probe now records the reader thread's exception through `threading.excepthook`, which also stops the unhandled traceback. Every other text-mode call under `tests/` already names an encoding, which `tests/test_tooling_encoding.py` enforces. - **#2947.** `scripts/carry.py` gains `git_status_records(root, *, ignored=False)`, which returns `(status, path)` pairs and refuses malformed or incomplete records as before. `git_status_paths()` is now a projection of it, and the CLI gate's `tree_state()` calls it. `tests/test_carry.py` covers status pairing, the ignored option, rename order, and each refusal. - **#2948.** `tests/test_cli_usage.py` declares `NO_REQUIRED_ARGUMENT`. Each member was checked by reading its parser. The gate fails an entry point declared nowhere, and also a stale or duplicate declaration. `catalog/snippets/hub-fetch-run.py` takes a required hub path but refuses a leading flag before reading it, so it fits neither set. It is declared in a one-entry `REFUSES_A_LEADING_FLAG` with that reason. The module docstring now describes the declarations. - **#2949.** `CODESTYLE.md` "Command-Line Arguments" states the gate's requirements: the same help text for `-h` and `--help` on stdout, opening on a usage line that names the script, and a refusal that names the argument as an unknown or unrecognized option, argument, flag, or parameter. PowerShell's own binder refusal also counts. The gate's `REFUSED_AS_OPTION` now accepts exactly that set. Before this change it accepted only "unrecognized argument/option". The python-codestyle and shell-codestyle Skills are regenerated. ## Verification - Ran the full local gate set from `OPERATIONS.md` "Local Verification", including the unit suite (2720 tests OK), `spec/validate.py`, `build_dist.py --check`, `repo_gate.py --check eol` and `--check eol-coverage`, both prose invocations, a `--diff` run with and without `sentence-length`, and `docker_lint.py`. All passed. - Ran two `local-strict-review` passes. The first raised five findings, all fixed in 1a8640c3. The second raised none. - The Windows probe path was exercised on Linux by simulating the reader-thread exception. The Windows CI leg is the real check. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .agents/skills/python-codestyle/SKILL.md | 4 +- .agents/skills/shell-codestyle/SKILL.md | 4 +- .../.source-digests/python-codestyle | 2 +- .../.source-digests/shell-codestyle | 2 +- .../skills/python-codestyle/SKILL.md | 4 +- .../skills/shell-codestyle/SKILL.md | 4 +- .github/skills/python-codestyle/SKILL.md | 4 +- .github/skills/shell-codestyle/SKILL.md | 4 +- CODESTYLE.md | 4 +- scripts/carry.py | 31 +++--- tests/test_carry.py | 69 ++++++++++++ tests/test_cli_usage.py | 101 ++++++++++++++---- tests/test_prose_lint.py | 12 ++- 13 files changed, 196 insertions(+), 49 deletions(-) diff --git a/.agents/skills/python-codestyle/SKILL.md b/.agents/skills/python-codestyle/SKILL.md index a8be8314e..c03ad315b 100644 --- a/.agents/skills/python-codestyle/SKILL.md +++ b/.agents/skills/python-codestyle/SKILL.md @@ -254,8 +254,8 @@ Before pushing or opening a PR: A command-line script is one a person or an agent runs by name. A hook its host runs, taking its input from that host rather than from a command line, is not one. -- **Every command-line script answers `--help` and `-h`** by printing its usage and exiting 0, changing nothing. An agent learns a script's interface by asking it, so a script that reads `--help` as an input acts in a way nobody predicted. -- **An argument the script does not recognize is refused**, with a non-zero exit before any read or write. An unrecognized first argument is never taken as a default mode or as a target. +- **Every command-line script answers `--help` and `-h`** with the same text on stdout, then exits 0, changing nothing. That text opens on a usage line naming the script. An agent learns a script's interface by asking it, so a script that reads `--help` as an input acts in a way nobody predicted. +- **An argument the script does not recognize is refused**, with a non-zero exit before any read or write. The refusal names it as an unknown or unrecognized option, argument, flag, or parameter, and PowerShell's own refusal of a parameter it cannot bind counts. An unrecognized first argument is never taken as a default mode or as a target. diff --git a/.agents/skills/shell-codestyle/SKILL.md b/.agents/skills/shell-codestyle/SKILL.md index 99f15b37e..5d4ff4b63 100644 --- a/.agents/skills/shell-codestyle/SKILL.md +++ b/.agents/skills/shell-codestyle/SKILL.md @@ -83,7 +83,7 @@ depend on Python either. Everything else is Python, with a test under its own sc A command-line script is one a person or an agent runs by name. A hook its host runs, taking its input from that host rather than from a command line, is not one. -- **Every command-line script answers `--help` and `-h`** by printing its usage and exiting 0, changing nothing. An agent learns a script's interface by asking it, so a script that reads `--help` as an input acts in a way nobody predicted. -- **An argument the script does not recognize is refused**, with a non-zero exit before any read or write. An unrecognized first argument is never taken as a default mode or as a target. +- **Every command-line script answers `--help` and `-h`** with the same text on stdout, then exits 0, changing nothing. That text opens on a usage line naming the script. An agent learns a script's interface by asking it, so a script that reads `--help` as an input acts in a way nobody predicted. +- **An argument the script does not recognize is refused**, with a non-zero exit before any read or write. The refusal names it as an unknown or unrecognized option, argument, flag, or parameter, and PowerShell's own refusal of a parameter it cannot bind counts. An unrecognized first argument is never taken as a default mode or as a target. diff --git a/.claude-plugin/fleet-skills/.source-digests/python-codestyle b/.claude-plugin/fleet-skills/.source-digests/python-codestyle index 43960883c..b68630856 100644 --- a/.claude-plugin/fleet-skills/.source-digests/python-codestyle +++ b/.claude-plugin/fleet-skills/.source-digests/python-codestyle @@ -1 +1 @@ -ab0cce16e02761c6 +7bbaad0be74556f3 diff --git a/.claude-plugin/fleet-skills/.source-digests/shell-codestyle b/.claude-plugin/fleet-skills/.source-digests/shell-codestyle index b98d14882..f888ec871 100644 --- a/.claude-plugin/fleet-skills/.source-digests/shell-codestyle +++ b/.claude-plugin/fleet-skills/.source-digests/shell-codestyle @@ -1 +1 @@ -3880094e1fe1db20 +0eec487f8178228e diff --git a/.claude-plugin/fleet-skills/skills/python-codestyle/SKILL.md b/.claude-plugin/fleet-skills/skills/python-codestyle/SKILL.md index a8be8314e..c03ad315b 100644 --- a/.claude-plugin/fleet-skills/skills/python-codestyle/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/python-codestyle/SKILL.md @@ -254,8 +254,8 @@ Before pushing or opening a PR: A command-line script is one a person or an agent runs by name. A hook its host runs, taking its input from that host rather than from a command line, is not one. -- **Every command-line script answers `--help` and `-h`** by printing its usage and exiting 0, changing nothing. An agent learns a script's interface by asking it, so a script that reads `--help` as an input acts in a way nobody predicted. -- **An argument the script does not recognize is refused**, with a non-zero exit before any read or write. An unrecognized first argument is never taken as a default mode or as a target. +- **Every command-line script answers `--help` and `-h`** with the same text on stdout, then exits 0, changing nothing. That text opens on a usage line naming the script. An agent learns a script's interface by asking it, so a script that reads `--help` as an input acts in a way nobody predicted. +- **An argument the script does not recognize is refused**, with a non-zero exit before any read or write. The refusal names it as an unknown or unrecognized option, argument, flag, or parameter, and PowerShell's own refusal of a parameter it cannot bind counts. An unrecognized first argument is never taken as a default mode or as a target. diff --git a/.claude-plugin/fleet-skills/skills/shell-codestyle/SKILL.md b/.claude-plugin/fleet-skills/skills/shell-codestyle/SKILL.md index 99f15b37e..5d4ff4b63 100644 --- a/.claude-plugin/fleet-skills/skills/shell-codestyle/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/shell-codestyle/SKILL.md @@ -83,7 +83,7 @@ depend on Python either. Everything else is Python, with a test under its own sc A command-line script is one a person or an agent runs by name. A hook its host runs, taking its input from that host rather than from a command line, is not one. -- **Every command-line script answers `--help` and `-h`** by printing its usage and exiting 0, changing nothing. An agent learns a script's interface by asking it, so a script that reads `--help` as an input acts in a way nobody predicted. -- **An argument the script does not recognize is refused**, with a non-zero exit before any read or write. An unrecognized first argument is never taken as a default mode or as a target. +- **Every command-line script answers `--help` and `-h`** with the same text on stdout, then exits 0, changing nothing. That text opens on a usage line naming the script. An agent learns a script's interface by asking it, so a script that reads `--help` as an input acts in a way nobody predicted. +- **An argument the script does not recognize is refused**, with a non-zero exit before any read or write. The refusal names it as an unknown or unrecognized option, argument, flag, or parameter, and PowerShell's own refusal of a parameter it cannot bind counts. An unrecognized first argument is never taken as a default mode or as a target. diff --git a/.github/skills/python-codestyle/SKILL.md b/.github/skills/python-codestyle/SKILL.md index a8be8314e..c03ad315b 100644 --- a/.github/skills/python-codestyle/SKILL.md +++ b/.github/skills/python-codestyle/SKILL.md @@ -254,8 +254,8 @@ Before pushing or opening a PR: A command-line script is one a person or an agent runs by name. A hook its host runs, taking its input from that host rather than from a command line, is not one. -- **Every command-line script answers `--help` and `-h`** by printing its usage and exiting 0, changing nothing. An agent learns a script's interface by asking it, so a script that reads `--help` as an input acts in a way nobody predicted. -- **An argument the script does not recognize is refused**, with a non-zero exit before any read or write. An unrecognized first argument is never taken as a default mode or as a target. +- **Every command-line script answers `--help` and `-h`** with the same text on stdout, then exits 0, changing nothing. That text opens on a usage line naming the script. An agent learns a script's interface by asking it, so a script that reads `--help` as an input acts in a way nobody predicted. +- **An argument the script does not recognize is refused**, with a non-zero exit before any read or write. The refusal names it as an unknown or unrecognized option, argument, flag, or parameter, and PowerShell's own refusal of a parameter it cannot bind counts. An unrecognized first argument is never taken as a default mode or as a target. diff --git a/.github/skills/shell-codestyle/SKILL.md b/.github/skills/shell-codestyle/SKILL.md index 99f15b37e..5d4ff4b63 100644 --- a/.github/skills/shell-codestyle/SKILL.md +++ b/.github/skills/shell-codestyle/SKILL.md @@ -83,7 +83,7 @@ depend on Python either. Everything else is Python, with a test under its own sc A command-line script is one a person or an agent runs by name. A hook its host runs, taking its input from that host rather than from a command line, is not one. -- **Every command-line script answers `--help` and `-h`** by printing its usage and exiting 0, changing nothing. An agent learns a script's interface by asking it, so a script that reads `--help` as an input acts in a way nobody predicted. -- **An argument the script does not recognize is refused**, with a non-zero exit before any read or write. An unrecognized first argument is never taken as a default mode or as a target. +- **Every command-line script answers `--help` and `-h`** with the same text on stdout, then exits 0, changing nothing. That text opens on a usage line naming the script. An agent learns a script's interface by asking it, so a script that reads `--help` as an input acts in a way nobody predicted. +- **An argument the script does not recognize is refused**, with a non-zero exit before any read or write. The refusal names it as an unknown or unrecognized option, argument, flag, or parameter, and PowerShell's own refusal of a parameter it cannot bind counts. An unrecognized first argument is never taken as a default mode or as a target. diff --git a/CODESTYLE.md b/CODESTYLE.md index 94ca521d9..cb68b1809 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -38,8 +38,8 @@ Each language defines a **clean-compile** verification: the combination of build A command-line script is one a person or an agent runs by name. A hook its host runs, taking its input from that host rather than from a command line, is not one. -- **Every command-line script answers `--help` and `-h`** by printing its usage and exiting 0, changing nothing. An agent learns a script's interface by asking it, so a script that reads `--help` as an input acts in a way nobody predicted. -- **An argument the script does not recognize is refused**, with a non-zero exit before any read or write. An unrecognized first argument is never taken as a default mode or as a target. +- **Every command-line script answers `--help` and `-h`** with the same text on stdout, then exits 0, changing nothing. That text opens on a usage line naming the script. An agent learns a script's interface by asking it, so a script that reads `--help` as an input acts in a way nobody predicted. +- **An argument the script does not recognize is refused**, with a non-zero exit before any read or write. The refusal names it as an unknown or unrecognized option, argument, flag, or parameter, and PowerShell's own refusal of a parameter it cannot bind counts. An unrecognized first argument is never taken as a default mode or as a target. ### Markdown and Spelling diff --git a/scripts/carry.py b/scripts/carry.py index 30bfe1b4e..54569dc8f 100755 --- a/scripts/carry.py +++ b/scripts/carry.py @@ -227,32 +227,39 @@ def git_is_ancestor(root: pathlib.Path, ancestor: str, descendant: str) -> bool: return result.returncode == 0 -def git_status_paths(root: pathlib.Path) -> list[str]: - result = subprocess.run( - ["git", "status", "--porcelain=v1", "-z", "--untracked-files=all"], - cwd=root, - capture_output=True, - check=False, - ) +def git_status_records(root: pathlib.Path, *, ignored: bool = False) -> list[tuple[str, str]]: + """Each changed or untracked path, and each ignored one when asked, with its status code. + + A rename or copy yields its destination and then its source, each with the record's status. + """ + command = ["git", "status", "--porcelain=v1", "-z", "--untracked-files=all"] + if ignored: + command.append("--ignored") + result = subprocess.run(command, cwd=root, capture_output=True, check=False) if result.returncode != 0: raise CarryError(os.fsdecode(result.stderr).strip() or "git status failed") fields = result.stdout.split(b"\0") if fields and not fields[-1]: fields.pop() - paths: list[str] = [] + records: list[tuple[str, str]] = [] index = 0 while index < len(fields): entry = fields[index] if len(entry) < 4 or entry[2:3] != b" ": raise CarryError("git status returned malformed porcelain output") - paths.append(os.fsdecode(entry[3:])) - if b"R" in entry[:2] or b"C" in entry[:2]: + status = os.fsdecode(entry[:2]) + records.append((status, os.fsdecode(entry[3:]))) + if "R" in status or "C" in status: index += 1 if index >= len(fields): raise CarryError("git status returned an incomplete rename or copy") - paths.append(os.fsdecode(fields[index])) + records.append((status, os.fsdecode(fields[index]))) index += 1 - return paths + return records + + +def git_status_paths(root: pathlib.Path) -> list[str]: + return [path for _, path in git_status_records(root)] def normalized_origin(value: str) -> str: diff --git a/tests/test_carry.py b/tests/test_carry.py index d0b273efa..e78d60b87 100755 --- a/tests/test_carry.py +++ b/tests/test_carry.py @@ -865,5 +865,74 @@ def test_the_manifest_declares_sections_this_hub_actually_carries(self) -> None: self.assertIsNotNone(carry.section_span(lines, name)) +class CarryStatusTests(unittest.TestCase): + """The porcelain parser carry.py and the CLI usage gate share.""" + + def repository(self) -> pathlib.Path: + root = pathlib.Path(self.enterContext(tempfile.TemporaryDirectory())) + + def git(*args: str) -> None: + subprocess.run(["git", "-C", str(root), *args], check=True, capture_output=True) + + git("init", "-q") + git("config", "commit.gpgsign", "false") + git("config", "user.name", "Example") + git("config", "user.email", "example@example.invalid") + (root / ".gitignore").write_text("ignored.txt\n", encoding="utf-8") + (root / "moved.txt").write_text("moved\n", encoding="utf-8") + (root / "edited.txt").write_text("edited\n", encoding="utf-8") + git("add", ".gitignore", "moved.txt", "edited.txt") + git("commit", "-q", "-m", "Example") + git("mv", "moved.txt", "renamed.txt") + (root / "edited.txt").write_text("changed\n", encoding="utf-8") + (root / "new.txt").write_text("new\n", encoding="utf-8") + (root / "ignored.txt").write_text("ignored\n", encoding="utf-8") + return root + + def test_records_pair_each_path_with_its_status(self) -> None: + root = self.repository() + self.assertEqual( + sorted(carry.git_status_records(root)), + [(" M", "edited.txt"), ("??", "new.txt"), ("R ", "moved.txt"), ("R ", "renamed.txt")], + ) + records = carry.git_status_records(root) + destination = records.index(("R ", "renamed.txt")) + self.assertEqual(records[destination + 1], ("R ", "moved.txt")) + + def test_ignored_paths_are_listed_only_when_asked(self) -> None: + root = self.repository() + self.assertNotIn("ignored.txt", carry.git_status_paths(root)) + self.assertIn(("!!", "ignored.txt"), carry.git_status_records(root, ignored=True)) + + def test_paths_keep_both_sides_of_a_rename(self) -> None: + root = self.repository() + self.assertEqual( + sorted(carry.git_status_paths(root)), + ["edited.txt", "moved.txt", "new.txt", "renamed.txt"], + ) + + def test_a_malformed_or_incomplete_record_is_refused(self) -> None: + for output, message in ( + (b"?? \0", "malformed"), + (b"??new.txt\0", "malformed"), + (b"R renamed.txt\0", "incomplete rename"), + ): + with self.subTest(output=output): + listed = subprocess.CompletedProcess([], 0, stdout=output, stderr=b"") + with ( + mock.patch.object(carry.subprocess, "run", return_value=listed), + self.assertRaisesRegex(carry.CarryError, message), + ): + carry.git_status_records(pathlib.Path(".")) + + def test_a_failed_status_is_refused(self) -> None: + failed = subprocess.CompletedProcess([], 128, stdout=b"", stderr=b"not a git repository") + with ( + mock.patch.object(carry.subprocess, "run", return_value=failed), + self.assertRaisesRegex(carry.CarryError, "not a git repository"), + ): + carry.git_status_paths(pathlib.Path(".")) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cli_usage.py b/tests/test_cli_usage.py index 1eefed851..94c03d530 100755 --- a/tests/test_cli_usage.py +++ b/tests/test_cli_usage.py @@ -15,8 +15,10 @@ subcommand or a positional refuses on the missing argument before it reaches the flag. So BEFORE_FLAG gives it arguments that satisfy its parser, and the run with them must name the flag. Those arguments alone exit 0 or leave an effect, so a listed script that warns and carries on still -fails. An unlisted script must still name the flag, but nothing runs it without the flag. So one -that warns on the flag and then stops on a missing argument passes until it is listed. +fails. Every other entry point is declared in NO_REQUIRED_ARGUMENT, or in REFUSES_A_LEADING_FLAG +where its parser reads a leading flag before its required argument. Its run with the flag alone +must name the flag. An entry point declared nowhere fails, since one needing an argument could warn +on the flag and then stop on the missing argument. No run reaches a stub or the listener, leaves a process running, or changes the working tree. The tree check covers ignored paths and a directory created or emptied anywhere in the tree. @@ -52,6 +54,8 @@ from contextlib import contextmanager from pathlib import Path, PurePosixPath +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) +import carry from host_capability import bash_or_skip from test_configure_archived import lift, run_bash @@ -77,9 +81,49 @@ "spec/resolve_description.py": ("registry/repos.json", "Example"), } +NO_REQUIRED_ARGUMENT = frozenset( + { + ".github/actions/prose-gate/prose_lint.py", + ".github/actions/python-directories/python_directories.py", + ".github/actions/repo-gate/repo_gate.py", + "host-setup/agent-safety/claude/install.ps1", + "host-setup/agent-safety/claude/install.py", + "host-setup/agent-safety/claude/install.sh", + "host-setup/bootstrap.ps1", + "host-setup/bootstrap.sh", + "host-setup/linux/install-skills.sh", + "host-setup/linux/install-tools.sh", + "host-setup/linux/setup-github.sh", + "host-setup/linux/upgrade-host.sh", + "host-setup/menu.ps1", + "host-setup/menu.sh", + "host-setup/windows/install-skills.ps1", + "host-setup/windows/install-tools.ps1", + "host-setup/windows/setup-github.ps1", + "host-setup/windows/setup-wsl.ps1", + "host-setup/windows/upgrade-host.ps1", + "scripts/build_dist.py", + "scripts/docker_lint.py", + "scripts/host_gate.py", + "scripts/prose_lint.py", + "scripts/repo_gate.py", + "scripts/skills_install.ps1", + "scripts/skills_install.py", + "scripts/skills_install.sh", + "spec/audit.py", + "spec/fidelity_honesty.py", + "spec/validate.py", + "spec/workflow_reuse.py", + } +) + +REFUSES_A_LEADING_FLAG = { + "catalog/snippets/hub-fetch-run.py": "forwards every argument after its hub path", +} + REFUSED_AS_OPTION = re.compile( - r"(unrecogni[sz]ed (argument|option)|unknown (option|argument|flag|parameter)" - r"|cannot be found that matches parameter name)[^\n]*bogus-flag", + r"((unknown|unrecogni[sz]ed) (option|argument|flag|parameter)" + r"|cannot be found that matches parameter name)[^\n]*(? list[str]: return sorted(found) +def undeclared(paths: list[str]) -> list[str]: + """The entry points declared in none of the sets that say how the flag alone is refused.""" + declared = BEFORE_FLAG.keys() | NO_REQUIRED_ARGUMENT | REFUSES_A_LEADING_FLAG.keys() + return [path for path in paths if path not in declared] + + def interpreter(root: Path, path: str) -> str | None: """The runtime an entry point needs, from its extension or else its shebang, or None. @@ -201,22 +251,8 @@ def tree_state() -> dict[str, tuple[str, int, int]]: alone would not show. A directory's time moves when an entry in it is created or removed, which is what catches an empty directory, since git lists none. """ - listed = subprocess.run( - ["git", "-C", str(ROOT), "status", "--porcelain=v1", "-z", "-uall", "--ignored"], - capture_output=True, - text=True, - encoding="utf-8", - errors="surrogateescape", - check=True, - ).stdout - records = iter(listed.split("\0")) state = {} - for record in records: - if not record: - continue - status, path = record[:2], record[3:] - if "R" in status or "C" in status: - next(records, None) + for status, path in carry.git_status_records(ROOT, ignored=True): try: stat = os.lstat(ROOT / path) except FileNotFoundError: @@ -483,6 +519,22 @@ def test_discovery_finds_each_kind(self) -> None: (ROOT / excluded).is_file(), "an exclusion names a file the tree no longer has" ) + def test_each_entry_point_declares_how_the_flag_alone_is_refused(self) -> None: + found = entry_points() + self.assertEqual( + undeclared(found), + [], + "declare each entry point above in BEFORE_FLAG if it needs an argument," + " or in NO_REQUIRED_ARGUMENT if it takes none, or in REFUSES_A_LEADING_FLAG if it" + " refuses a leading flag before reading its required argument", + ) + sets = (BEFORE_FLAG.keys(), NO_REQUIRED_ARGUMENT, REFUSES_A_LEADING_FLAG.keys()) + for index, declared in enumerate(sets): + with self.subTest(index=index): + self.assertEqual(sorted(set(declared) - set(found)), [], "a stale declaration") + for other in sets[index + 1 :]: + self.assertEqual(sorted(set(declared) & set(other)), [], "declared twice") + def test_discovery_finds_an_extensionless_shebang_script(self) -> None: """The hub tracks no such script yet, so a constructed tree stands in for one.""" files = { @@ -504,6 +556,9 @@ def test_discovery_finds_an_extensionless_shebang_script(self) -> None: class ControlCase(unittest.TestCase): """The controls for the hardened checks: each catches the shape it exists for.""" + def test_an_undeclared_entry_point_is_named(self) -> None: + self.assertEqual(undeclared(["scripts/carry.py", "bin/example"]), ["bin/example"]) + def test_a_global_git_config_write_is_seen(self) -> None: write = "import os; open(os.environ['GIT_CONFIG_GLOBAL'], 'w')" _, effects = run_sandboxed([sys.executable, "-c", write]) @@ -515,6 +570,14 @@ def test_a_refusal_must_name_the_flag_as_an_option(self) -> None: REFUSED_AS_OPTION.search("error: unrecognized arguments: --bogus-flag") ) self.assertIsNotNone(REFUSED_AS_OPTION.search("getopt: unrecognized option '--bogus-flag'")) + self.assertIsNotNone(REFUSED_AS_OPTION.search("unrecognized flag '--bogus-flag'")) + self.assertIsNotNone( + REFUSED_AS_OPTION.search( + "A parameter cannot be found that matches parameter name 'bogus-flag'." + ) + ) + self.assertIsNone(REFUSED_AS_OPTION.search("unknown option --bogus-flag-extra")) + self.assertIsNone(REFUSED_AS_OPTION.search("unrecognized flag --not-bogus-flag")) def test_help_must_open_on_a_usage_line_naming_the_script(self) -> None: self.assertFalse(opens_on_usage("Disk usage report: nothing to do\n", "bin/example.py")) diff --git a/tests/test_prose_lint.py b/tests/test_prose_lint.py index f6c436ea4..f9bbeb2c0 100755 --- a/tests/test_prose_lint.py +++ b/tests/test_prose_lint.py @@ -21,6 +21,7 @@ import subprocess import sys import tempfile +import threading import time import unittest from collections.abc import Iterator @@ -5222,9 +5223,16 @@ def locale_patch_bites() -> bool: an exotic one. Measuring it beats reading `sys.flags.utf8_mode`, which answers only one of the several ways an interpreter arrives at a UTF-8 default. + On Windows the pipe decodes in a reader thread, whose UnicodeDecodeError never reaches this + thread, so the thread's own exception hook records it too. + Measured once, since answering it spawns an interpreter. """ - with locale_patched_as_cp1252(): + raised: list[type[BaseException]] = [] + with ( + locale_patched_as_cp1252(), + mock.patch.object(threading, "excepthook", lambda args: raised.append(args.exc_type)), + ): try: # Naming no encoding is the whole of the probe, so a sweep must never fix this call. subprocess.run( @@ -5235,7 +5243,7 @@ def locale_patch_bites() -> bool: ) except UnicodeDecodeError: return True - return False + return any(issubclass(kind, UnicodeDecodeError) for kind in raised) class TestGitOutputDecodesAsUtf8(unittest.TestCase): From d2ed08c4b1d77cae0cf07a655f9b651e6bae80d3 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 13 Oct 2026 17:51:18 -0700 Subject: [PATCH 3/3] Read Each Refspec's Push Operation, Include Overrides, and Fused Separators in the Write Guard (#2963) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 - **Per-refspec push operations.** `_push_targets` no longer keeps one aggregate force and delete flag per push. Each refspec carries its own operation set: `+src` forces only its own branch, `:dst` deletes only its own, and `--force`/`--delete` apply to every refspec. `_check_push_bypass` now checks every operation, a force and a delete included, against `pull_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 as `release/*` is still consulted. A tag pattern writes no branch. - **Include overrides.** A git command under an `include.path` or `includeIf..path` override, given by `-c` or `--config-env`, whose subcommand is not in `git --list-cmds=builtins,main,others`, is an unreadable push and denies, since the included file may define it as a push alias. The `classify` pre-filter gains `_GIT_INCLUDE` so a command holding no `push` word reaches the check. `_config_key` is shared with the hooks-path check. - **A separator fused with a redirection.** The tokenizer fuses `;<`, `&<`, `&&<`, `|<`, 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_arglist` keeps 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_reach` reads the separator part of such a token (`_leading_separator`) for a background `&`. - **Self-test.** New cases in `_GIT_CASES`, `_PUSH_DIR_CASES`, and the wait table, and the `_GIT_COMMAND_NAMES` pin now covers the `_GIT_CASES` loop so the include cases do not depend on the host's git. - **READMEs.** Requirement 4 in `host-setup/agent-safety/README.md` and the guard summary in `host-setup/agent-safety/claude/README.md` describe 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. - **Real-repository mode:** 12,855 constructed commands, each in a develop checkout and a feature worktree of a constructed repository, under five rule sets, with no current-branch seam (128,550 calls). The corpus crosses push heads with separators, fused separator-redirection tokens, quoted, backslash-escaped, backtick, `$'...'`, `${...}`, `$(...)`, `$(<...)`, 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. - **Seam mode:** every `classify` call the self-test makes (815) plus 10,245 extra shapes under 20 current-branch and rule seams (205,715 calls). - **Rule 6:** 3,042 primary-checkout calls. No verdict differs. 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 `:dst` no longer forces or deletes a branch it does not name: - `git push origin +feature/x develop` and `git push origin :feature/z develop`, with develop protected only by `non_fast_forward` or `deletion`. - `git push origin : +feature/y` and `git push origin '+refs/tags/*' develop` no longer force develop. - `git push origin +develop feature/y`, with rules on `feature/y` only, no longer forces `feature/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 - A push whose words hold a fused separator is checked under both readings. A feature-checkout push such as `git push ; ## Summary by CodeRabbit * **Bug Fixes** * Push checks now evaluate each refspec independently, correctly distinguishing branch updates, force-pushes, and deletions. * Writes to branches that require pull requests are denied, including force-pushes and deletions. * Pushes with targets the safety check cannot inspect are denied when Git configuration may redirect the command through an alias. * Command checks more reliably recognize shell separators, redirections, wrapped commands, and explicit Git directory settings. --------- Co-authored-by: Claude Opus 5.5 --- host-setup/agent-safety/README.md | 9 +- host-setup/agent-safety/claude/README.md | 2 +- .../agent-safety/claude/gh-write-guard.py | 438 ++++++++++++++++-- 3 files changed, 401 insertions(+), 48 deletions(-) diff --git a/host-setup/agent-safety/README.md b/host-setup/agent-safety/README.md index 499a2d4a0..2cf776784 100644 --- a/host-setup/agent-safety/README.md +++ b/host-setup/agent-safety/README.md @@ -57,6 +57,9 @@ text says, because the harm it covers was never in the text. protected, a delete where deletion is blocked, or an explicit-bypass flag (`--admin` on a merge, `--no-verify` or a `-c core.hooksPath=` override on a commit/push). Judge branch-rule cases against that branch's *live* rules, so a code-style `develop` denies and a config-style `develop` allows with no per-repo configuration. + Each refspec's operation is judged on its own, so a `+src` or a `:dst` refspec forces or + deletes only its own branch. A force-push and a delete each write the branch too, so a branch + whose rules require a pull request denies them as it denies a direct push. **This one fails closed, but only for a branch protected by default** (`main`, `master`, `develop`): when that branch's rules cannot be determined at all (network unreachable, origin unresolvable), deny rather than allow, because the harm is a silent success under the @@ -64,8 +67,10 @@ text says, because the harm it covers was never in the text. passes this requirement instead, since there is nothing yet on record to bypass. Apart from that case, a push whose target the hook cannot read at all is denied whatever branch it names. That covers a push in text the hook does not parse and a push carrying a `-c`/`--config-env` - override. It also covers a bare push or a `HEAD`/`@` refspec whose directory or branch may - change before it runs. The workaround is an explicit refspec run as its own command. Every other + override. It covers a git command under an `include.path`/`includeIf` override too, where git + lists no such subcommand, since the included file may define it as a push alias. It also covers + a bare push or a `HEAD`/`@` refspec whose directory or branch may change before it runs. The + workaround is an explicit refspec run as its own command. Every other requirement here favors precision over recall throughout, denying only a positively-identified dangerous shape, since a hook that fails closed on an unrelated resolution failure blocks legitimate work far more often than it catches a real bypass. diff --git a/host-setup/agent-safety/claude/README.md b/host-setup/agent-safety/claude/README.md index a8fa768db..a8069da8e 100644 --- a/host-setup/agent-safety/claude/README.md +++ b/host-setup/agent-safety/claude/README.md @@ -12,7 +12,7 @@ installation and operational detail. Codex and opencode have no equivalent hook Into `~/.claude/` (or `%USERPROFILE%\.claude\` on Windows): -- **`hooks/gh-write-guard.py`**: a PreToolUse hook that denies four classes of dangerous action. First, the GitHub **write** footguns behind the cross-repo comment incident: a state-changing `gh` call whose output is discarded, a GraphQL mutation passing a **literal** node id instead of a `$variable`, and a `gh` write whose explicit target is under an owner other than the checkout origin's. Sibling repositories under the same owner are allowed, since the harm this guards is reaching a stranger's repository rather than working across one maintainer's own fleet, and a different owner is allowed only when the maintainer names it in `GH_WRITE_GUARD_ALLOW` (an `owner/repo` list, where `owner/*` grants a whole owner). That variable is read from the environment the session was launched with, which is the one channel an agent cannot set for itself: a hook runs as its own process, so an inline `VAR=x cmd` prefix or an `export` inside a Bash call never reaches it. Second, a **git operation that bypasses a repository protection**. The branch-rule cases (a direct push to a branch that requires a pull request, a force-push where history is protected, a branch delete where deletion is blocked) are judged against the branch's **live** rules, so a code-style `develop` is denied while a config-style `develop` is allowed with no per-repo configuration. A push to a protected-default branch fails closed when its rules cannot be determined, either because the API is unreachable or because the checkout's origin cannot be resolved to query them. A push whose target the hook cannot read, such as one in text it does not parse or one carrying a `-c` config override, is denied whatever branch it names. The explicit-bypass flags are denied **unconditionally**, since the flag is itself the bypass and needs no branch query: `gh pr merge --admin` overrides the server-side merge gate, and `git commit`/`git push --no-verify` skips the local git hooks, as does a `git -c core.hooksPath=` override on either, which points git at another hooks directory. Third, a **mutating git operation run directly against a primary checkout** (`checkout`/`switch`/`pull`/`reset`/`rebase`/`merge`/`cherry-pick`/`revert`/`restore`/`add`/`commit`/`rm`/`mv`/`apply`/`am`/`push`/`stash` (anything but `list`/`show`)/`clean -f`/`worktree remove -f`), the harm behind a second incident where an agent reused the maintainer's own primary checkout instead of a worktree despite having read the prose rule against it. "Primary" is decided by comparing `git rev-parse --git-dir` against `--git-common-dir`, not a `.git`-is-a-directory guess. `worktree add`/`list`/`prune`, an unforced `worktree remove`, `merge --ff-only`/`pull --ff-only`, and a `checkout `/`switch ` carrying no force flag (bundled or attached forms like `-qf`/`-Bname` recognized, not only an exact token) stay exempt even in a primary checkout, matching the documented base-clone cleanup step the `repo-worktree` skill already carries. A subcommand this rule does not recognize is resolved through a chain of git aliases, inline and persisted, before falling through, so a custom alias cannot hide a denied builtin behind an unrecognized name. A `!`-prefixed shell alias is denied outright rather than interpreted. A directory named with a `..` component is not resolved, so a mutating command naming one is denied. That holds for `-C`, any `cd`, `pushd`, `popd`, or `chdir` word in the command, `--work-tree`, `--git-dir`, and a `GIT_DIR=`/`GIT_WORK_TREE=` prefix. The target directory follows real git's own `-C`/`--work-tree`/`--git-dir` priority, not a last-option-wins scan. A different owner-style grant, `GH_WRITE_GUARD_ALLOW_PRIMARY_CHECKOUT`, follows the same channel as `GH_WRITE_GUARD_ALLOW`. Reads and everything else pass through. Fourth, an **unbounded shell wait**, which the root spec's requirement 7 defines in full rather than this bullet redefining it. In outline: a loop that sleeps, carrying no bound the rule recognizes. It is the shape an agent writes when a brief tells it to poll. It is also the harm behind a third incident, where seven such loops outlived the subagents that wrote them and the run that dispatched those subagents. A shell started by a tool call runs in a session of its own, so the agent could not have reaped it even had it tried. A nested loop is judged on its own terms, and a command that forks anything out of a `timeout`'s reach, by backgrounding it or by `setsid`, is bounded by nothing. A heredoc body is data rather than a command line unless a shell is what it is fed to, so a document quoting the forbidden shape is written rather than denied. It fires even in autonomous / bypass-permissions sessions, which is how the first incident happened. See the root spec's requirements 6 and 7 for the exact rules. +- **`hooks/gh-write-guard.py`**: a PreToolUse hook that denies four classes of dangerous action. First, the GitHub **write** footguns behind the cross-repo comment incident: a state-changing `gh` call whose output is discarded, a GraphQL mutation passing a **literal** node id instead of a `$variable`, and a `gh` write whose explicit target is under an owner other than the checkout origin's. Sibling repositories under the same owner are allowed, since the harm this guards is reaching a stranger's repository rather than working across one maintainer's own fleet, and a different owner is allowed only when the maintainer names it in `GH_WRITE_GUARD_ALLOW` (an `owner/repo` list, where `owner/*` grants a whole owner). That variable is read from the environment the session was launched with, which is the one channel an agent cannot set for itself: a hook runs as its own process, so an inline `VAR=x cmd` prefix or an `export` inside a Bash call never reaches it. Second, a **git operation that bypasses a repository protection**. The branch-rule cases (a direct push, a force-push, or a branch delete to a branch that requires a pull request, a force-push where history is protected, a branch delete where deletion is blocked) are judged per refspec against the branch's **live** rules, so a code-style `develop` is denied while a config-style `develop` is allowed with no per-repo configuration. A push to a protected-default branch fails closed when its rules cannot be determined, either because the API is unreachable or because the checkout's origin cannot be resolved to query them. A push whose target the hook cannot read, such as one in text it does not parse, one carrying a `-c` config override, or a git command an `include.path`/`includeIf` override may define as a push alias, is denied whatever branch it names. The explicit-bypass flags are denied **unconditionally**, since the flag is itself the bypass and needs no branch query: `gh pr merge --admin` overrides the server-side merge gate, and `git commit`/`git push --no-verify` skips the local git hooks, as does a `git -c core.hooksPath=` override on either, which points git at another hooks directory. Third, a **mutating git operation run directly against a primary checkout** (`checkout`/`switch`/`pull`/`reset`/`rebase`/`merge`/`cherry-pick`/`revert`/`restore`/`add`/`commit`/`rm`/`mv`/`apply`/`am`/`push`/`stash` (anything but `list`/`show`)/`clean -f`/`worktree remove -f`), the harm behind a second incident where an agent reused the maintainer's own primary checkout instead of a worktree despite having read the prose rule against it. "Primary" is decided by comparing `git rev-parse --git-dir` against `--git-common-dir`, not a `.git`-is-a-directory guess. `worktree add`/`list`/`prune`, an unforced `worktree remove`, `merge --ff-only`/`pull --ff-only`, and a `checkout `/`switch ` carrying no force flag (bundled or attached forms like `-qf`/`-Bname` recognized, not only an exact token) stay exempt even in a primary checkout, matching the documented base-clone cleanup step the `repo-worktree` skill already carries. A subcommand this rule does not recognize is resolved through a chain of git aliases, inline and persisted, before falling through, so a custom alias cannot hide a denied builtin behind an unrecognized name. A `!`-prefixed shell alias is denied outright rather than interpreted. A directory named with a `..` component is not resolved, so a mutating command naming one is denied. That holds for `-C`, any `cd`, `pushd`, `popd`, or `chdir` word in the command, `--work-tree`, `--git-dir`, and a `GIT_DIR=`/`GIT_WORK_TREE=` prefix. The target directory follows real git's own `-C`/`--work-tree`/`--git-dir` priority, not a last-option-wins scan. A different owner-style grant, `GH_WRITE_GUARD_ALLOW_PRIMARY_CHECKOUT`, follows the same channel as `GH_WRITE_GUARD_ALLOW`. Reads and everything else pass through. Fourth, an **unbounded shell wait**, which the root spec's requirement 7 defines in full rather than this bullet redefining it. In outline: a loop that sleeps, carrying no bound the rule recognizes. It is the shape an agent writes when a brief tells it to poll. It is also the harm behind a third incident, where seven such loops outlived the subagents that wrote them and the run that dispatched those subagents. A shell started by a tool call runs in a session of its own, so the agent could not have reaped it even had it tried. A nested loop is judged on its own terms, and a command that forks anything out of a `timeout`'s reach, by backgrounding it or by `setsid`, is bounded by nothing. A heredoc body is data rather than a command line unless a shell is what it is fed to, so a document quoting the forbidden shape is written rather than denied. It fires even in autonomous / bypass-permissions sessions, which is how the first incident happened. See the root spec's requirements 6 and 7 for the exact rules. - **`hooks/tool-containment.py`**: a shell prefix, registered as `CLAUDE_CODE_SHELL_PREFIX` in the `env` block of `settings.json`, that runs every Bash tool call inside a transient systemd user scope carrying `TasksMax`, `MemoryMax`, and `MemorySwapMax=0`, the last so a runaway at the memory ceiling is killed rather than spilled into the host's swap. A runaway fan-out then fails at the ceiling with `fork: Resource temporarily unavailable` instead of growing until the host has to be reset, the harm behind the probe-shim incident in the root spec's requirement 9. The guard above reads the command text, and that incident's recursion lived in a script file the command only named. The ceilings default to 8192 tasks and 25% of RAM. A `claude` session started from a tool call is nested in the session that ran it. Every scope of such a chain also joins one slice, `claude-tree-.slice`. That slice carries a total ceiling for the whole tree, 32768 tasks and 50% of RAM. The root's id reaches nested sessions as `AGENT_CONTAINMENT_ROOT`, which every contained command inherits. The tree's ceiling is a runtime drop-in put on when its slice is first created, and the sweep below reverts it. The maintainer raises any ceiling in the same `env` block. An inline `VAR=x` in a command cannot change one, since the prefix reads it before the command runs. The variables are `AGENT_CONTAINMENT_TASKS_MAX` and `AGENT_CONTAINMENT_MEMORY_MAX` for a scope, and `AGENT_CONTAINMENT_TREE_TASKS_MAX` and `AGENT_CONTAINMENT_TREE_MEMORY_MAX` for the tree. A tree ceiling set below a scope's binds first. Each scope is named `claude-tool---`, so the sweep below can stop one session's tool calls and nothing else. Its description records the pid of the agent that ran it. That record is how the sweep tells a scope whose session has ended from one whose session still runs. The installer sets the prefix only where it can contain, per the Linux bullet under "Scope and Limits", and never over a `CLAUDE_CODE_SHELL_PREFIX` value that names anything else. - **`hooks/stray-process-sweep.py`**: a SessionEnd hook in two phases. First, it stops every `claude-tool--*` scope this session left loaded and reports what each held, since the scope's name proves which session started it. It then stops every scope in this session's tree whose recorded agent is no longer running, and names each one's session. That is how a nested session's scopes end, since stopping the parent's scopes kills the nested `claude` before its own sweep runs. Each stop can end an agent nested deeper, so it judges again, up to four rounds. The root session then stops its tree's slice and reverts the ceiling put on it, once nothing is left in it. Second, it reports, and never ends, the process trees outside those scopes that outlive the session. The guard above stops a leak from being written. This finds the ones already running: the ones started before the guard was installed, and the shapes the guard deliberately does not reach. It reports every descendant of the agent process running outside the agent's own session. It names each subtree root's command and age, and hands over the `kill` line. This phase kills nothing itself. A long build backgrounded on purpose reads identically to a leaked wait from there. What is still wanted on the machine is the maintainer's call. A SessionEnd hook cannot block, and its JSON output is discarded, so stderr on exit 2 is the one channel that reaches the maintainer. The sweep uses that for a report rather than for a refusal. It registers under a group carrying no matcher, which fires on every exit reason. It also carries an explicit timeout, since the event's own budget is 1.5 seconds and only a declared per-hook timeout raises it. See the root spec's requirement 8. - **The whole of `CLAUDE.md`, rendered on every run**, loaded into every session on the machine (including ad-hoc work outside any project). It holds a header, two marker-delimited blocks of host-wide content, and the host-local file. Nothing else survives an install. Content outside the blocks was never written or checked, and it restated fleet rules in wording the fleet had since changed. diff --git a/host-setup/agent-safety/claude/gh-write-guard.py b/host-setup/agent-safety/claude/gh-write-guard.py index 83931f576..ca9c783d3 100755 --- a/host-setup/agent-safety/claude/gh-write-guard.py +++ b/host-setup/agent-safety/claude/gh-write-guard.py @@ -880,11 +880,15 @@ def _is_gh_exe(tok): return base in ("gh", "gh.exe") -def _collect_arglist(toks, start): +def _collect_arglist(toks, start, split_fused=False): """Collect argv tokens from `start` up to the next shell separator (|, &&, ;, newline), skipping a redirection operator and the file-descriptor number or target token attached to it. Shared by `_git_subcommand_arglists` and `_gh_arg_lists` so a command's own argv, not text living inside an unrelated --body/--title/-f value elsewhere in the line, is what either scans for a target. + The tokenizer fuses a separator with a redirection after it, such as `;<` or `&<`, and drops + quoting, so such a token may also be a quoted word or text inside an expansion. It is read as a + redirection, and with `split_fused` as the end of the invocation at its separator instead, + where a number before it is then a word rather than a file descriptor. Returns (args, index_after_this_invocation). """ @@ -893,9 +897,14 @@ def _collect_arglist(toks, start): args = [] while k < n: t = toks[k] - if _is_separator(t): + if _is_separator(t) or (split_fused and _opens_with_separator(t)): break # a command separator ends this invocation - if t.isdigit() and k + 1 < n and _is_redir_op(toks[k + 1]): + if ( + t.isdigit() + and k + 1 < n + and _is_redir_op(toks[k + 1]) + and not (split_fused and _opens_with_separator(toks[k + 1])) + ): k += 1 # a file-descriptor number before a redirection is shell syntax, not argv continue if _is_redir_op(t): @@ -964,11 +973,14 @@ def _git_config_overrides(global_opts): return values +def _config_key(value): + """The key a `-c`/`--config-env` value sets, lowercased, since git matches a key without case.""" + return value.split("=", 1)[0].lower() + + def _overrides_hooks_path(global_opts): """True when the global options set `core.hooksPath` for this one invocation, by `-c` or `--config-env`.""" - return any( - v.split("=", 1)[0].lower() == "core.hookspath" for v in _git_config_overrides(global_opts) - ) + return any(_config_key(v) == "core.hookspath" for v in _git_config_overrides(global_opts)) # --- Rule 6: a mutating git op against a primary checkout --------------------------------------------- @@ -2167,6 +2179,21 @@ def _is_short_u(arg): _INLINE_PUSH_ALIAS = re.compile(r"alias\..*\bpush\b", re.DOTALL | re.IGNORECASE) +_GIT_INCLUDE = re.compile(r"git\b.*?include", re.DOTALL | re.IGNORECASE) + + +def _includes_config_file(value): + """True when a `-c`/`--config-env` value sets `include.path` or any `includeIf.` key. + + Either one reads a config file the guard never reads, which may define an alias. Any + `includeIf.` key counts, since `--config-env` splits at the last `=` and a condition such as + `gitdir:[!=]**/` holds one, which cuts the key read here short of its `.path`. `_GIT_INCLUDE` + is the cheap pre-filter that sends a command holding one to the push check. + """ + key = _config_key(value) + return key == "include.path" or key.startswith("includeif.") + + _COMMAND_RUNNING_GIT_SUBS = frozenset( {"bisect", "submodule", "rebase", "filter-branch", "difftool", "mergetool"} ) @@ -2182,6 +2209,8 @@ def _unreadable_push(cmd): - a wrapper nested past the reading cap - a quoted word handed to a git command that runs it, one of `_COMMAND_RUNNING_GIT_SUBS` - a `-c` config value, an inline alias expanding to a push among them + - a config file an `include.path`/`includeIf` override pulls in, read when the git command it + overrides names no command git lists, so it may be an alias that file defines Any `-c`/`--config-env` on a push, or on a git command that runs one, counts too, an attached `-c` among them, which git rejects (see `_git_config_overrides`). Config such @@ -2217,6 +2246,15 @@ def _unreadable_push(cmd): continue if toks[j] == "push" and config: return "a push carrying a `-c`/`--config-env` override, which can change what it updates" + if ( + any(_includes_config_file(v) for v in values) + and not _is_shell_op(toks[j]) + and toks[j] not in _git_command_names() + ): + return ( + f"`git {toks[j]}` under an `include.path`/`includeIf` override, whose file " + "may define it as a push alias" + ) if toks[j] in _COMMAND_RUNNING_GIT_SUBS: args, _ = _collect_arglist(toks, j + 1) if config and any(a == "push" or _GIT_PUSH.search(a) for a in args): @@ -2235,6 +2273,9 @@ def _push_invocations(cmd): (`find -execdir`, `sudo -D`), or read startup files that change it. So is a push after a command that may move `HEAD` (see `_head_may_move`). That scan runs for each push on its own. A move written after a push leaves it alone unless it may run first (see `_push_scan_end`). + A push whose words hold a separator fused with a redirection is read twice. One reading ends + the push at that separator, and the other reads the token as a redirection, since the guard + cannot tell a fused separator from a quoted word or one inside an expansion. """ out = [] for text in [cmd]: @@ -2250,6 +2291,8 @@ def _push_invocations(cmd): batches.append((inner, outer_cd, outer_extra, True)) for body, leading_cd, extra, wrapped_cd in batches: first = len(out) + splits = [] + body_toks = outer_toks if body is text else _shell_tokens(body) for word, inv in _git_invocations_at(body): c_dirs, _work_tree, git_dir, _aliases, sub, args = inv if sub == "push": @@ -2266,13 +2309,19 @@ def _push_invocations(cmd): else: head_moves = whole_moves moved = extra > 0 or head_moves - out.append((c_dirs, None, leading_cd, moved or redirected or wrapped_cd, args)) + unreliable = moved or redirected or wrapped_cd + out.append((c_dirs, None, leading_cd, unreliable, args)) + j = _git_subcommand_index(body_toks, word) + split, _ = _collect_arglist(body_toks, j + 1, split_fused=True) + if split != args: + splits.append((c_dirs, None, leading_cd, unreliable, split)) unmatched = [a for _c, _w, _l, _u, a in out[first:]] for _opts, args in _git_subcommand_invocations(body, "push"): if args in unmatched: unmatched.remove(args) else: out.append(([], None, leading_cd, True, args)) + out.extend(splits) return out @@ -2306,9 +2355,13 @@ def _bare_push_branch(cwd, c_dirs, work_tree, leading_cd, cd_unreliable, head=Fa def _push_targets(cmd, cwd=None, current_branch=None): """Parse every push in the command into a list of (op, branch); op is delete | force | update. - A lone `:` refspec pushes every branch that exists on both sides. So it is read as an update - of every protected default and of each other branch named beside it. It deletes nothing, so - it leaves the aggregate delete flag as the other refspecs set it. A `HEAD`/`@` destination + Each refspec's op is read on its own. A `+src` refspec forces only its own branch and a `:dst` + one deletes only its own, while `--force` and `--delete` apply to every refspec in the push. A + refspec both forced and deleted yields both ops. A lone `:` refspec pushes every branch that + exists on both sides, and a branch pattern such as `refs/heads/*` may match any of them. So + each is read as writing every protected default and each other branch named beside it, with + its own ops. A pattern is also read as the branch its destination literally names, which a + ruleset pattern such as `release/*` matches. A `HEAD`/`@` destination updates the branch `HEAD` names, read the way a bare push's branch is. It is compared without case, since a case-insensitive filesystem resolves `head` to `HEAD`. A differently cased one is also read as the branch it literally names. A push the guard cannot read is an `unreadable` op, and a @@ -2319,7 +2372,7 @@ def _push_targets(cmd, cwd=None, current_branch=None): if unreadable: results.append(("unreadable", unreadable)) for c_dirs, work_tree, leading_cd, cd_unreliable, args in _push_invocations(cmd): - force = delete = push_all = mirror = tags_only = matching = False + force = delete = push_all = mirror = tags_only = False positionals = [] i = 0 while i < len(args): @@ -2345,18 +2398,28 @@ def _push_targets(cmd, cwd=None, current_branch=None): i += 1 # The positionals are the remote followed by any refspecs, and a lone positional is the remote, meaning a bare push. refspecs = positionals[1:] if len(positionals) >= 2 else [] - branches = [] + push_ops = {"force"} if force else set() + if delete: + push_ops.add("delete") + targets = [] + matched_ops = set() for rs in refspecs: + ops = set(push_ops) if rs.startswith("+"): - force = True + ops.add("force") rs = rs[1:] - matching = matching or rs == ":" - if rs.startswith(":") and rs != ":": - delete = True # `:dst` empty-source refspec deletes dst + if rs == ":": + matched_ops |= ops or {"update"} + continue + if rs.startswith(":"): + ops.add("delete") # `:dst` empty-source refspec deletes dst + ops = ops or {"update"} dst = rs.split(":", 1)[1] if ":" in rs else rs + if "*" in dst and (not dst.startswith("refs/") or dst.startswith("refs/heads/")): + matched_ops |= ops if dst.upper() in ("HEAD", "@"): if dst not in ("HEAD", "@"): - branches.append(dst) + targets.append((ops, dst)) b = current_branch if b is None: b = _bare_push_branch( @@ -2365,26 +2428,23 @@ def _push_targets(cmd, cwd=None, current_branch=None): if b is _UNRESOLVED: results.append(("unresolved", leading_cd or " ".join(c_dirs))) elif b: - branches.append(b) + targets.append((ops, b)) continue if dst.startswith("refs/heads/"): dst = dst[len("refs/heads/") :] elif dst.startswith("refs/"): continue # a tag or other non-branch ref if dst: - branches.append(dst) + targets.append((ops, dst)) if not refspecs and not delete: if mirror: # --mirror force-updates and prunes every ref: a force against the protected defaults # (a non-existent one just returns no rules and is skipped). - force = True - branches = list(_PROTECTED_DEFAULT_ORDER) + targets = [({"force"}, br) for br in _PROTECTED_DEFAULT_ORDER] elif push_all: - branches = list( - _PROTECTED_DEFAULT_ORDER - ) # updates every local branch, protected included + targets = [(push_ops or {"update"}, br) for br in _PROTECTED_DEFAULT_ORDER] elif tags_only: - branches = [] # tags only, no branch is updated + targets = [] # tags only, no branch is updated else: b = current_branch if b is None: @@ -2393,12 +2453,11 @@ def _push_targets(cmd, cwd=None, current_branch=None): results.append(("unresolved", leading_cd or " ".join(c_dirs))) continue if b: - branches = [b] - op = "delete" if delete else ("force" if force else "update") - results.extend((op, br) for br in branches) - if matching: - matched = [*_PROTECTED_DEFAULT_ORDER, *branches] - results.extend(("force" if force else "update", br) for br in matched) + targets = [(push_ops or {"update"}, b)] + results.extend((op, br) for ops, br in targets for op in sorted(ops)) + if matched_ops: + matched = [*_PROTECTED_DEFAULT_ORDER, *(br for _ops, br in targets)] + results.extend((op, br) for br in matched for op in sorted(matched_ops)) return results @@ -2464,7 +2523,11 @@ def _check_bypass_flags(cmd): def _check_push_bypass(cmd, cwd, origin, current_branch=None, rules_lookup=None): - """Deny a git push that would only succeed by bypassing an active branch rule.""" + """Deny a git push that would only succeed by bypassing an active branch rule. + + A force and a delete each write the branch too, so a pull_request rule denies them as it + denies an update. + """ targets = _push_targets(cmd, cwd, current_branch) if not targets: return ( @@ -2509,13 +2572,6 @@ def _check_push_bypass(cmd, cwd, origin, current_branch=None, rules_lookup=None) + _handoff(cmd) ) continue # an unknown-rules feature/other branch: nothing to bypass, let it through - if op == "update" and "pull_request" in rules: - return "deny", ( - f"This is a direct push to '{br}', whose branch rules require a pull request " - f"(rule: pull_request); it only lands by bypassing that rule with admin power. Use the " - f"protocol path - commit on a feature branch and open a PR (feature -> squash -> develop, " - f"or develop -> merge -> main)." + _handoff(cmd) - ) if op == "force" and (rules & {"non_fast_forward", "required_linear_history"}): return "deny", ( f"This force-pushes '{br}', whose rules forbid rewriting history " @@ -2527,6 +2583,13 @@ def _check_push_bypass(cmd, cwd, origin, current_branch=None, rules_lookup=None) f"This deletes '{br}', whose rules forbid deletion (rule: deletion)." + _handoff(cmd) ) + if "pull_request" in rules: + return "deny", ( + f"This push writes '{br}', whose branch rules require a pull request " + f"(rule: pull_request); it only lands by bypassing that rule with admin power. Use the " + f"protocol path - commit on a feature branch and open a PR (feature -> squash -> develop, " + f"or develop -> merge -> main)." + _handoff(cmd) + ) return "allow", "" @@ -3444,6 +3507,8 @@ def _forks_out_of_reach(toks): of narrowing that scan each closed the shapes they were shown and left the next one: a statement between the loop and its group's closer, a `disown` before a `wait`, a subshell the sequencing had already reaped. + A separator fused with a redirection, such as `&<`, is read by its separator part alone (see + `_leading_separator`). A command that forks anything away is read as bounding nothing. That costs a false deny on ` & wait`, which is a bound this rule cannot verify anyway. """ @@ -3452,10 +3517,13 @@ def _forks_out_of_reach(toks): return True if tok.rsplit("/", 1)[-1].rsplit("\\", 1)[-1].lower() in ("setsid", "setsid.exe"): return True - if not _is_separator(tok): + if _is_separator(tok): + run = tok.strip() + elif _opens_with_separator(tok): + run = _leading_separator(tok) + else: continue # The tokenizer fuses a run of operator characters, so one token can hold a closer and an `&`. - run = tok.strip() i = 0 while i < len(run): # `&&` is a separator rather than a background operator. @@ -3648,6 +3716,21 @@ def _opens_with_separator(tok): return tok[0] in ";|()\n" or tok.startswith("&&") or (tok[0] == "&" and tok[1:2] != ">") +def _leading_separator(tok): + """The separator part of an operator token that fuses a separator with a redirection. + + That is the text before its first `<` or `>`. The shell pairs a run of `&` from the left, so + an odd run ending at a `>` leaves its last `&` to the `&>` redirection. `)&>` is then a `)` + and an `&>`, while `&<` is a background `&` and a `<`. + """ + cut = min((i for i, c in enumerate(tok) if c in "<>"), default=len(tok)) + run = tok[:cut] + amps = len(run) - len(run.rstrip("&")) + if amps % 2 and tok[cut : cut + 1] == ">": + run = run[:-1] + return run + + def _unbounded_wait_loop(cmd, inherited_timeout=False, _depth=0, _budget=None): """The first unbounded wait loop in `cmd`, as ` ` text, or None when none. @@ -4025,8 +4108,8 @@ def classify( return dec, reason # The `_push_targets` helper 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): - if split_word: + if _GIT_PUSH.search(cmd) or _GIT_INCLUDE.search(cmd): + if split_word and _GIT_PUSH.search(cmd): return "deny", ( "This push sits in a command with a word split by a backslash-newline, which the shell joins " "into one word the guard cannot read, so a hidden cd could redirect the push. Write each " @@ -5148,6 +5231,216 @@ def classify( "deny", "the degraded path keeps the newline: a push on the next line is still read", ), + ( + "git push --force origin develop", + None, + {"develop": {"pull_request"}}, + "deny", + "a force push writes the branch too, so a pull_request rule alone denies it", + ), + ( + "git push -f", + "develop", + {"develop": {"pull_request"}}, + "deny", + "a bare force push to a pull_request branch is denied", + ), + ( + "git push origin :develop", + None, + {"develop": {"pull_request"}}, + "deny", + "a deletion writes the branch too, so a pull_request rule alone denies it", + ), + ( + "git push --delete origin develop", + None, + {"develop": {"pull_request"}}, + "deny", + "a --delete push to a pull_request branch is denied", + ), + ( + "git push origin +feature/x develop", + None, + {"feature/x": set(), "develop": {"pull_request"}}, + "deny", + "another refspec's + does not turn develop's update into a force the pull_request check skips", + ), + ( + "git push origin +feature/x develop", + None, + {"feature/x": set(), "develop": {"non_fast_forward"}}, + "allow", + "control: another refspec's + does not force develop, an ordinary update a no-force rule allows", + ), + ( + "git push origin :feature/z develop", + None, + {"feature/z": set(), "develop": {"pull_request"}}, + "deny", + "another refspec's deletion does not turn develop's update into a delete", + ), + ( + "git push origin :feature/z develop", + None, + {"feature/z": set(), "develop": {"deletion"}}, + "allow", + "control: another refspec's : does not delete develop, an ordinary update a no-deletion rule allows", + ), + ( + "git push origin develop +feature/x", + None, + {"feature/x": {"non_fast_forward"}, "develop": set()}, + "deny", + "the + on its own refspec still forces that branch", + ), + ( + "git push origin : +feature/y", + None, + {"main": set(), "master": set(), "develop": {"non_fast_forward"}, "feature/y": set()}, + "allow", + "control: a + on a named refspec does not force the branches a lone : matches", + ), + ( + "git push origin +: feature/y", + None, + {"main": set(), "master": set(), "develop": {"non_fast_forward"}, "feature/y": set()}, + "deny", + "a forced lone : forces every branch it matches", + ), + ( + "git push origin : '+refs/heads/*:refs/heads/*'", + None, + {"main": set(), "master": set(), "develop": {"non_fast_forward"}}, + "deny", + "a forced branch pattern forces every branch it matches", + ), + ( + "git push origin 'refs/heads/*:refs/heads/*'", + None, + {"main": set(), "master": set(), "develop": {"pull_request"}}, + "deny", + "a branch pattern updates the protected branches it matches", + ), + ( + "git push origin '+refs/heads/release/*:refs/heads/release/*'", + None, + {"main": set(), "master": set(), "develop": set(), "release/*": {"non_fast_forward"}}, + "deny", + "a branch pattern is still checked against the rules for the name it spells", + ), + ( + "git push origin 'refs/tags/*:refs/tags/*'", + None, + {"main": set(), "master": set(), "develop": {"pull_request"}}, + "allow", + "control: a tag pattern writes no branch", + ), + ( + "git -c include.path=/example/config p", + None, + {"develop": _CODE_RULES}, + "deny", + "an include.path override on a command git does not list may define it as a push alias", + ), + ( + "git -c include.path=/example/config p origin feature/x", + None, + {"feature/x": set()}, + "deny", + "the alias the included file defines may push anything, so the named branch does not vouch for it", + ), + ( + "git --config-env=include.path=EXAMPLE_CONFIG p", + None, + {"develop": _CODE_RULES}, + "deny", + "an include.path set through --config-env is read the same way", + ), + ( + "git -c includeIf.gitdir:/example/.path=/example/config p", + None, + {"develop": _CODE_RULES}, + "deny", + "an includeIf path is a config file the guard never reads too", + ), + ( + "git -c Include.Path=/example/config p", + None, + {"develop": _CODE_RULES}, + "deny", + "a config key is matched without case, as git matches it", + ), + ( + "bash -c 'git -c include.path=/example/config p'", + None, + {"develop": _CODE_RULES}, + "deny", + "an include override inside a wrapper body is read too", + ), + ( + "git push origin >';<' feature/x develop", + "feature/x", + {"feature/x": set(), "develop": _CODE_RULES}, + "deny", + "a quoted operator word is a redirection target, not a separator, so develop is still read", + ), + ( + "git push origin <<<';<' feature/y develop", + "feature/x", + {"feature/y": set(), "develop": _CODE_RULES}, + "deny", + "a quoted operator word as a here-string is not a separator either", + ), + ( + "git push ;$'\\';<' 2>\\' develop", + "feature/x", + {"feature/x": set(), "develop": _CODE_RULES}, + "deny", + "nor does one inside a $'...' quote", + ), + ( + "git commit `true;/dev/null'", + "allow", + "while the & of an &> redirection after a closer backgrounds nothing", + ), ( "timeout 600 bash -c 'case $x in a) foo ;& b) bar ;; esac; while true; do sleep 30; done'", "allow", @@ -8325,6 +8628,51 @@ def _nested_wrapper(body, depth): "deny", "a bare push run by another git command may run elsewhere, so it is unresolved", ), + ( + "git push ;