Protection rails fail open when a known repo is checked out at an unexpected path — make the mismatch loud #112
Labels
No labels
bug
duplicate
enhancement
help wanted
invalid
question
wontfix
bug
duplicate
enhancement
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
allod/tools#112
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Make the git-protection rails — the
allod changetool and theprotected-refs-policygit hook — fail loud when a repo whoseoriginidentifies it as a protected repo is checked out at a path that does not match itsprotected-branchesentry, instead of silently treating it as unprotected. Today a non-canonical checkout path makes both rails miss the lookup and fall open, so an agent can commit and push straight tomasterin a repo that is supposed to be protected, with no refusal and no hook block.Primary goals:
originremote resolves to anowner/repothat aprotected-branchesentry covers, but the repo's actual$HOME-relative path is not that entry's path, treat it as a misconfiguration rather than as "unprotected."allod change begin/recordrefuse with a message naming the expected vs actual path, andprotected-refs-policyblocks the commit/push/rebase the same way it would on the protected branch itself.git remote get-url originso the guard survives directory renames and flat-vs-nested layouts; the path is the thing being validated, so it cannot also be the lookup key.protected-branchesentry stays unprotected exactly as today; only a positive remote-match with a path mismatch trips the guard.protected-branchesfile; put the near-miss check in a single shared place so the tool and the hook cannot diverge.Current state
Both rails key on the repo's
$HOME-relative path and silently treat a lookup miss as "not protected":allod(allod/toolsallod):protected_branch_for_dir(allod:129) builds the key withrepo_lookup_key_for_dir(allod:119,${main_repo#"$HOME"/}) andawk-matches it against~/.config/git/protected-branches; a miss returns empty.refuse_protected_branch_record(allod:230) only refuses when that is non-empty, andchange_begin(allod:268) skips the worktree/branch when it is empty — so a path miss meansallod change record(allod:345) commits and pushes directly tomaster.git-hooks/protected-refs-policy:repo="$(git rev-parse --show-toplevel)"(:4),repo_relative="${repo#"$HOME"/}"(:21,:35), thenawk '$1 == repo && $2 == branch …'(:28,:42) against the same file; a miss lets pre-commit / pre-push / pre-rebase through.The
protected-branchesfile (allod/secretsgit/protected-branches) lists canonical paths likework/allod/profiles, matching the inventory registry checkout (allod/inventoryscripts/repositories.json—"checkout": "allod/profiles"). In a canonically-provisioned VM the repo lives at~/work/allod/profiles, the key matches, and both rails fire. But when the same repo is checked out elsewhere — e.g. a flat~/work/allod-profiles—repo_relativeiswork/allod-profiles, matches nothing, and both rails silently fall open even thoughoriginis unmistakablyforge.anarch.diy/allod/profiles.git. This was hit in practice while implementingallod/profiles#3: in a flat checkout,allod change recordonmasterdrew no refusal and the hooks did not block.This violates two architecture principles at once: "a boundary that depends on cooperation is not a boundary" (the rail depends on the checkout cooperating by living at the expected path) and "fail loud; never fall back silently" (an unmatched-but-recognizable repo resolves to the least-safe default).
Proposed mechanism
Default (self-contained, needs only the file the rails already read): from
git remote get-url origin, derive the canonicalowner/repo(strip host and trailing.git). Scanprotected-branches; if some entry's path tail is thatowner/repowhile no entry equals the repo's actualrepo_relative, that is a near-miss → fail loud, printing the expected path (the matching entry) and the actual path, and refuse the mutation. A repo whose remote identity matches no entry stays unprotected as today.Stronger follow-up (tracked, not required here): validate the checkout path against the inventory registry
checkoutfield directly so the expected path has one source of truth rather than being inferred fromprotected-branches. That couples the rails to the registry and is out of scope for the first cut.Validation
Extend
tests/git-hooks/protected-refs-policy.shandtests/allod-change.shwith a near-miss fixture: a repo whoseoriginis a listedowner/repobut whose worktree sits at a non-registry path, asserting that a commit/push to the protected branch is refused and that the message names both paths. Keep an existing "genuinely unlisted repo → allowed" case to prove the guard does not over-fire.Scope
In:
allod/tools— theallodchange subcommands,git-hooks/protected-refs-policy, a shared near-miss helper, and the two test files above. Out: the registry-driven variant above; changing theprotected-branchesdata or the workspace layout (the canonical layout is nestedallod/<repo>and is already correct in the registry). No change to behavior for repos that are genuinely unlisted.Linked worktrees are a second instance of this fail-open, and one that is live today independent of checkout layout.
git-hooks/protected-refs-policy:4resolves the repo asgit rev-parse --show-toplevel, and:21/:35derive the lookup key as${repo#"$HOME"/}. Inside a linked worktree that toplevel is the worktree's own path, so the key matches no entry and both path-keyed policies go silent:protected-branchesandsigning-required-branches. Every worktreeallod change begincreates for a protected repo is therefore a rail-free zone for bare git — including the signing requirement on a repo whose default branch requires signed pushes.allod change recordis unaffected because it resolves identity via--git-common-dir, so today the tool is guarded and the hook is not.The remote-identity mechanism proposed here fixes this case for free: a linked worktree shares the common config, so
git remote get-url originresolves identically from it (verified —core.hooksPathis inherited through the same shared config). Worth adding a worktree fixture to the acceptance tests alongside the flat-checkout one, since the two share a cause but not a reproduction.Also worth recording: the same hook already keys
active-pr-branches(:48) andallowed-external-remotes(:62-68) off the remote URL, so this change makes the file internally consistent rather than introducing a new pattern.This is now a prerequisite for allod/tools#119 and a dependency of the concurrency work tracked in allod/tools#115, because unconditional worktree isolation would move every agent change into the unguarded zone until it lands.
Severity correction: the opening claim that an agent can "commit and push straight to
master... with no refusal and no hook block" overstates the push half. The forge protects the framework repos' default branches server-side, so the push is rejected regardless of what the local rails do. Verified per repo viaGET /api/v1/repos/allod/<repo>/branches/master, which reportsprotectedanduser_can_pushfor a non-admin token: the eight repos listed inprotected-branchesall returnprotected: true, user_can_push: false, andmemoryandstrategyreturnfalse/true— an exact match with the local file.So the real failure is: the commit lands locally and is discovered at push time by a server rejection, costing a manual reset. That is a local-state hazard and a defence-in-depth gap, not a path to unauthorised publication. It also means
protected-branchesis a hand-maintained mirror of the authoritative forge rules that can drift from them silently — arguably the more interesting problem underneath this issue, since the remote-identity mechanism proposed here still trusts the mirror.This lowers the issue's urgency, and it removes the arc-wide ordering constraint recorded in allod/tools#115 — see that issue's triage comment. It does not lower the review depth this change earns, which comes from its own blast radius: it touches the rail every commit and push in every repo passes through, it needs a decision about where a helper shared between a CLI and a standalone hook file lives, and the agent cannot validate the deployed result.
Two mechanical notes for whoever implements it. The hook reaches a VM through
allod/archetypesmodules/agent-hooks.nix:9-11, which copiesgit-hooks/protected-refs-policyto~/.config/git/as a single standalone file with no siblinglib/— so the "one shared helper" goal is a cross-repo packaging decision (secondhome.fileentry, absolute store path, or accepted duplication with tests on both copies), not an implementation detail. Andrun_repo_hook(:98) resolves$repo/.git/hooks/$hook, which does not exist in a linked worktree because.gitis a file there; repo-local hooks are therefore already silent in every worktree, whilerun_tracked_hookreading tracked.hooksis unaffected. Worth folding into the same fix.Read-only status lookup is available to agents; enumerating or changing the rules is not —
/branch_protectionsreturns 403 without repo admin. Recorded inallod/memorygit-workflow.md.