diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index d7bf9efb7..6bb1b9a68 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -5214,6 +5214,28 @@ A bare `Write-Output main$(calc):seed.txt` emits `mainPWNED-EXECUTED:seed.txt`. **Source:** found 2026-08-06 by a three-pass audit (inventory, adversarial attack, refutation) of the committed gate, prompted by a coordination message from a peer session generalising this gate. That message's own premise was overturned by measurement: the values it named are newline-unreachable here, and the reachable defect was one its proposed fix would not have closed. ## 1082. Rule 3c's deny text for a `--global` or `--system` disarm write names the wrong mechanism, and whether the write takes effect is not knowable from the command +> **THE WORDING IS FIXED 2026-08-27; the banner stays open for the archive pass. THE VERDICT DID NOT +> MOVE, which is the property this item most needed protecting.** Rule 3c had ZERO mentions of +> `--global`/`--system`, so it emitted the shared-config sentence for every scope. It now branches: a +> scoped write is told which file it actually writes, and why it is refused anyway (git falls back to +> that scope when the repository does not set the key). The repository-scope wording is UNCHANGED and +> pinned by its own control, so this did not trade one false sentence for another. +> +> **NOTHING IN THE NEW TEXT REASSURES**, and the tests pin the absence of four phrases rather than the +> presence of one. That is this row's round-4 lesson: segments are judged one at a time and `Write-Deny` +> exits on the first hit, so a reassurance prints over a real disarm when a LATER segment does a local +> write. +> +> **A QUALIFIER THIS ROW DOES NOT HAVE, measured 2026-08-27: whether a `--global` write takes effect +> depends on WHERE IT IS READ FROM, not only on the repository.** From a worktree `core.hooksPath` is +> set at worktree scope -- **75 of 76** `config.worktree` files set it -- so global loses to the more +> specific scope. **From the primary it is unset at every scope** (`--get` exit 1, against a control of +> `core.bare` exit 0), so a `--global` write there WOULD take effect. The row states only the first and +> reads as though it settled the question for the repository. Denying stays correct for both. +> +> **The multi-line residual this row names is NOT fixed here** and still needs its own item: segments +> are split on lines and the first hit exits, so a multi-line command is judged by its first segment. +> > 🔢 **Re-scored 2026-08-20 -> P3.** Value **4/10** · Difficulty **3/10** · _fill-in_. The wrong sentence is still the only one rule 3c emits, and the rule reaches it for a --global write because the only pre-deny exclusion at :981 is for reads, so a governed cwd resolves and denies with prose that names a mechanism the write does not use. The verdict must stay deny, so the work is a scope-aware wording plus a test that asserts the string rather than the verdict. _(was 4/10 · 3/10.)_ > diff --git a/scripts/hooks/worktree_gate.ps1 b/scripts/hooks/worktree_gate.ps1 index 1a8e0cff3..983181736 100644 --- a/scripts/hooks/worktree_gate.ps1 +++ b/scripts/hooks/worktree_gate.ps1 @@ -1417,13 +1417,54 @@ What to do instead: } if (-not $govCfg) { continue } + # BACKLOG #1082: SAY WHAT IS TRUE OF THE SCOPE ACTUALLY BEING WRITTEN. A --global or --system + # write does NOT land in this repository's config -- it lands in the per-user or machine-wide + # file -- so the sentence below was false for those scopes. THE VERDICT DOES NOT MOVE: git + # falls back to those scopes when the repository does not set the key, so the write can still + # disarm this checkout by inheritance, and whether it does is NOT knowable from the command. + # + # Measured 2026-08-27 and it depends on WHERE YOU READ FROM, which the row does not say: + # from a worktree core.hooksPath IS set at worktree scope (75 of 76 config.worktree files) + # -> a --global write of that key loses to the more specific scope + # from the primary UNSET at every scope (exit 1; control core.bare exit 0) + # -> a --global write of that key WOULD take effect + # Denying is the correct conservative default for both. + # + # NOTHING HERE MAY REASSURE. A round-4 candidate printed "This does NOT change the shared + # configuration" and three verifiers graded it BLOCKING: segments are judged one at a time and + # Write-Deny exits on the first hit, so on a multi-line command whose LATER segment does a + # local write, that sentence would print over a real disarm. + $scopeFlag = if ($seg.Scan -cmatch '(?:^|\s)--(?global|system)(?:\s|$)') { $Matches['sc'] } else { $null } + $opening = if ($scopeFlag) { + "BLOCKED: '$badKey' is on the disarm list, and --$scopeFlag writes your " + + $(if ($scopeFlag -eq 'global') { 'per-user' } else { 'machine-wide' }) + + " git configuration rather than this repository's.`n`nGit falls back to that scope when " + + "$($govCfg.Display) does not set the key itself, so the write can still disarm this " + + "checkout's hooks. Which case applies is not knowable from the command alone, so it is refused." + } + else { + "BLOCKED: setting '$badKey' would change the SHARED git configuration of $($govCfg.Display)." + } + # The mechanism paragraph is scope-aware for the same reason the opening is: the shared-.git + # sentence is TRUE of a repository-scope write and FALSE of --global/--system. + $mechanism = if ($scopeFlag) { + "This is refused on the REACH, not on where the file lands: a key inherited from the " + + "$(if ($scopeFlag -eq 'global') { 'per-user' } else { 'machine-wide' }) scope applies to " + + "every repository on this machine that does not override it, including every worktree of " + + "$($govCfg.Display)." + } + else { + "Every worktree of this repository shares one .git directory, so this is not a local " + + "change: it takes effect for all of them at once." + } Write-Deny -Rule "3c" -Detail "git config $badKey" -Reason @" -BLOCKED: setting '$badKey' would change the SHARED git configuration of $($govCfg.Display). +$opening + +$mechanism -Every worktree of this repository shares one .git directory, so this is not a local change: it takes -effect for all of them at once. Repointing core.hooksPath (or aliasing a command, or redirecting -core.worktree) disables the commit-time ledger, claim and secret-leak gates for every session on this -machine, and nothing would report that they had stopped running. +Repointing core.hooksPath (or aliasing a command, or redirecting core.worktree) disables the +commit-time ledger, claim and secret-leak gates, and nothing would report that they had stopped +running. What to do instead: * If a commit hook is failing, FIX THE CAUSE -- the hook output names it. Never route around a gate; diff --git a/tests/test_worktree_gate_scope_wording.py b/tests/test_worktree_gate_scope_wording.py new file mode 100644 index 000000000..78dc1ab5a --- /dev/null +++ b/tests/test_worktree_gate_scope_wording.py @@ -0,0 +1,95 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""BACKLOG #1082: rule 3c's deny text named a mechanism a ``--global``/``--system`` write does not use. + +**THE VERDICT WAS ALWAYS CORRECT AND DOES NOT MOVE HERE.** ``git config --global core.hooksPath `` +aimed at a governed repo is refused, and should be: git falls back to the per-user scope when the +repository does not set the key, so the write can still disarm the checkout **by inheritance**. An +earlier filing of this as a FALSE DENY was withdrawn by three independent verifiers who measured the +hook genuinely not firing afterwards. Flipping rule 3c to ALLOW would have been a fail-open. + +What was wrong is the SENTENCE. It said the write *"would change the SHARED git configuration of +"*, and it does not -- it writes ``~/.gitconfig`` or the machine-wide file. + +**EVERY TEST HERE ASSERTS THE EMITTED STRING, NOT THE VERDICT.** That is forced, not stylistic: the +verdict is identical before and after, so a row ending in a bare ``assert_denied`` cannot see this +defect at all, and a mutant that reinstated the old sentence would pass such a row unchanged. + +**NOTHING IN THE MESSAGE MAY REASSURE.** A round-4 candidate printed *"This does NOT change the shared +configuration"* and three verifiers graded it BLOCKING: ``Get-ScannableSegments`` splits on lines and +``Write-Deny`` exits on the first hit, so on a multi-line command whose LATER segment does a local +write, that reassurance would print over a real disarm. The negative rows below pin its absence. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from tests.test_worktree_gate import assert_denied, run_gate +from tests.test_worktree_gate_control_plane import repo, shell # noqa: F401 + +SCOPED = [ + ("global", "git config --global core.hooksPath /dev/null"), + ("system", "git config --system core.hooksPath /dev/null"), +] + +#: Phrases that must never appear in a scoped deny. The first is the false mechanism this item is +#: about; the rest are the reassurance shapes graded BLOCKING in round 4. +FORBIDDEN_IN_SCOPED = [ + "SHARED git configuration", + "does NOT change", + "is harmless", + "safe to run", +] + + +@pytest.mark.parametrize("scope,command", SCOPED) +def test_a_scoped_write_is_still_denied(repo: SimpleNamespace, scope: str, command: str) -> None: # noqa: F811 + """THE VERDICT ARM. Kept separate from the wording arms so a future change that fixed the sentence + by ALLOWING the write fails here rather than passing quietly.""" + assert run_gate(shell(command, cwd=repo.primary), repo.repos) is not None, ( + f"--{scope} disarm write was ALLOWED. The verdict must not move: git inherits from that scope " + "when the repository does not set the key, so the write can still disarm this checkout." + ) + + +@pytest.mark.parametrize("scope,command", SCOPED) +def test_a_scoped_deny_does_not_claim_the_repo_config_changed( + repo: SimpleNamespace, # noqa: F811 + scope: str, + command: str, +) -> None: + reason = assert_denied(run_gate(shell(command, cwd=repo.primary), repo.repos)) + for phrase in FORBIDDEN_IN_SCOPED: + assert phrase not in reason, ( + f"--{scope} deny text contains {phrase!r}. Either it names a mechanism this write does not " + "use, or it reassures -- and a reassurance prints over a real disarm when a later segment " + f"of a multi-line command does a local write.\n\n{reason}" + ) + + +@pytest.mark.parametrize("scope,command", SCOPED) +def test_a_scoped_deny_names_the_scope_and_the_inheritance( + repo: SimpleNamespace, # noqa: F811 + scope: str, + command: str, +) -> None: + """Say what IS true: which file is written, and why it is refused anyway.""" + reason = assert_denied(run_gate(shell(command, cwd=repo.primary), repo.repos)) + assert f"--{scope}" in reason, f"the deny text does not name the scope it refused\n\n{reason}" + assert "inherit" in reason.lower() or "falls back" in reason.lower(), ( + f"the deny text does not say WHY a write to another file is refused\n\n{reason}" + ) + + +def test_a_repository_scope_write_still_says_SHARED(repo: SimpleNamespace) -> None: # noqa: F811 + """THE CONTROL. The original sentence is CORRECT for a repository-scope write and must survive + unchanged -- otherwise this fix would have traded one false sentence for another.""" + reason = assert_denied( + run_gate(shell("git config core.hooksPath /dev/null", cwd=repo.primary), repo.repos) + ) + assert "SHARED git configuration" in reason, ( + f"the repository-scope wording was changed; it was already true and should not move\n\n{reason}" + ) diff --git a/tests/tooling_manifest.txt b/tests/tooling_manifest.txt index 433d8cd67..d7ed7ccfb 100644 --- a/tests/tooling_manifest.txt +++ b/tests/tooling_manifest.txt @@ -130,6 +130,7 @@ tests/test_worktree_gate_quote_straddle.py tests/test_worktree_gate_receipts.py tests/test_worktree_gate_remedy_families.py tests/test_worktree_gate_rule_agnostic_coverage.py +tests/test_worktree_gate_scope_wording.py tests/test_worktree_gate_shell_semantics.py tests/test_worktree_gate.py tests/test_worktree_new_cleanup_advice.py