Protection rails fail open when a known repo is checked out at an unexpected path — make the mismatch loud #112

Open
opened 2026-07-20 17:13:35 +01:00 by allod-agent · 2 comments
Member

Make the git-protection rails — the allod change tool and the protected-refs-policy git hook — fail loud when a repo whose origin identifies it as a protected repo is checked out at a path that does not match its protected-branches entry, 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 to master in a repo that is supposed to be protected, with no refusal and no hook block.

Primary goals:

  • Detect the near-miss — when a repo's origin remote resolves to an owner/repo that a protected-branches entry covers, but the repo's actual $HOME-relative path is not that entry's path, treat it as a misconfiguration rather than as "unprotected."
  • Fail loud, fail closed — on that near-miss, allod change begin/record refuse with a message naming the expected vs actual path, and protected-refs-policy blocks the commit/push/rebase the same way it would on the protected branch itself.
  • Key off remote identity, not path — derive the repo from git remote get-url origin so 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.
  • Keep genuinely-unlisted repos silent — a repo whose remote matches no protected-branches entry stays unprotected exactly as today; only a positive remote-match with a path mismatch trips the guard.
  • One shared helper — both rails already read the same protected-branches file; 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/tools allod): protected_branch_for_dir (allod:129) builds the key with repo_lookup_key_for_dir (allod:119, ${main_repo#"$HOME"/}) and awk-matches it against ~/.config/git/protected-branches; a miss returns empty. refuse_protected_branch_record (allod:230) only refuses when that is non-empty, and change_begin (allod:268) skips the worktree/branch when it is empty — so a path miss means allod change record (allod:345) commits and pushes directly to master.
  • git-hooks/protected-refs-policy: repo="$(git rev-parse --show-toplevel)" (:4), repo_relative="${repo#"$HOME"/}" (:21, :35), then awk '$1 == repo && $2 == branch …' (:28, :42) against the same file; a miss lets pre-commit / pre-push / pre-rebase through.

The protected-branches file (allod/secrets git/protected-branches) lists canonical paths like work/allod/profiles, matching the inventory registry checkout (allod/inventory scripts/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-profilesrepo_relative is work/allod-profiles, matches nothing, and both rails silently fall open even though origin is unmistakably forge.anarch.diy/allod/profiles.git. This was hit in practice while implementing allod/profiles#3: in a flat checkout, allod change record on master drew 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 canonical owner/repo (strip host and trailing .git). Scan protected-branches; if some entry's path tail is that owner/repo while no entry equals the repo's actual repo_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 checkout field directly so the expected path has one source of truth rather than being inferred from protected-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.sh and tests/allod-change.sh with a near-miss fixture: a repo whose origin is a listed owner/repo but 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 — the allod change subcommands, git-hooks/protected-refs-policy, a shared near-miss helper, and the two test files above. Out: the registry-driven variant above; changing the protected-branches data or the workspace layout (the canonical layout is nested allod/<repo> and is already correct in the registry). No change to behavior for repos that are genuinely unlisted.

Make the git-protection rails — the `allod change` tool and the `protected-refs-policy` git hook — fail loud when a repo whose `origin` identifies it as a protected repo is checked out at a path that does not match its `protected-branches` entry, 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 to `master` in a repo that is supposed to be protected, with no refusal and no hook block. Primary goals: - **Detect the near-miss** — when a repo's `origin` remote resolves to an `owner/repo` that a `protected-branches` entry covers, but the repo's actual `$HOME`-relative path is not that entry's path, treat it as a misconfiguration rather than as "unprotected." - **Fail loud, fail closed** — on that near-miss, `allod change begin`/`record` refuse with a message naming the expected vs actual path, and `protected-refs-policy` blocks the commit/push/rebase the same way it would on the protected branch itself. - **Key off remote identity, not path** — derive the repo from `git remote get-url origin` so 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. - **Keep genuinely-unlisted repos silent** — a repo whose remote matches no `protected-branches` entry stays unprotected exactly as today; only a positive remote-match with a path mismatch trips the guard. - **One shared helper** — both rails already read the same `protected-branches` file; 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/tools` `allod`): `protected_branch_for_dir` (`allod:129`) builds the key with `repo_lookup_key_for_dir` (`allod:119`, `${main_repo#"$HOME"/}`) and `awk`-matches it against `~/.config/git/protected-branches`; a miss returns empty. `refuse_protected_branch_record` (`allod:230`) only refuses when that is non-empty, and `change_begin` (`allod:268`) skips the worktree/branch when it is empty — so a path miss means `allod change record` (`allod:345`) commits and pushes directly to `master`. - `git-hooks/protected-refs-policy`: `repo="$(git rev-parse --show-toplevel)"` (`:4`), `repo_relative="${repo#"$HOME"/}"` (`:21`, `:35`), then `awk '$1 == repo && $2 == branch …'` (`:28`, `:42`) against the same file; a miss lets pre-commit / pre-push / pre-rebase through. The `protected-branches` file (`allod/secrets` `git/protected-branches`) lists canonical paths like `work/allod/profiles`, matching the inventory registry checkout (`allod/inventory` `scripts/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_relative` is `work/allod-profiles`, matches nothing, and both rails silently fall open even though `origin` is unmistakably `forge.anarch.diy/allod/profiles.git`. This was hit in practice while implementing `allod/profiles#3`: in a flat checkout, `allod change record` on `master` drew 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 canonical `owner/repo` (strip host and trailing `.git`). Scan `protected-branches`; if some entry's path tail is that `owner/repo` while no entry equals the repo's actual `repo_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 `checkout` field directly so the expected path has one source of truth rather than being inferred from `protected-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.sh` and `tests/allod-change.sh` with a near-miss fixture: a repo whose `origin` is a listed `owner/repo` but 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` — the `allod` change subcommands, `git-hooks/protected-refs-policy`, a shared near-miss helper, and the two test files above. Out: the registry-driven variant above; changing the `protected-branches` data or the workspace layout (the canonical layout is nested `allod/<repo>` and is already correct in the registry). No change to behavior for repos that are genuinely unlisted.
Contributor

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:4 resolves the repo as git rev-parse --show-toplevel, and :21/:35 derive 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-branches and signing-required-branches. Every worktree allod change begin creates 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 record is 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 origin resolves identically from it (verified — core.hooksPath is 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) and allowed-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.

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:4` resolves the repo as `git rev-parse --show-toplevel`, and `:21`/`:35` derive 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-branches` and `signing-required-branches`. Every worktree `allod change begin` creates 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 record` is 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 origin` resolves identically from it (verified — `core.hooksPath` is 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`) and `allowed-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.
Author
Member

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 via GET /api/v1/repos/allod/<repo>/branches/master, which reports protected and user_can_push for a non-admin token: the eight repos listed in protected-branches all return protected: true, user_can_push: false, and memory and strategy return false/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-branches is 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/archetypes modules/agent-hooks.nix:9-11, which copies git-hooks/protected-refs-policy to ~/.config/git/ as a single standalone file with no sibling lib/ — so the "one shared helper" goal is a cross-repo packaging decision (second home.file entry, absolute store path, or accepted duplication with tests on both copies), not an implementation detail. And run_repo_hook (:98) resolves $repo/.git/hooks/$hook, which does not exist in a linked worktree because .git is a file there; repo-local hooks are therefore already silent in every worktree, while run_tracked_hook reading tracked .hooks is unaffected. Worth folding into the same fix.

Read-only status lookup is available to agents; enumerating or changing the rules is not — /branch_protections returns 403 without repo admin. Recorded in allod/memory git-workflow.md.

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 via `GET /api/v1/repos/allod/<repo>/branches/master`, which reports `protected` and `user_can_push` for a non-admin token: the eight repos listed in `protected-branches` all return `protected: true, user_can_push: false`, and `memory` and `strategy` return `false`/`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-branches` is 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/archetypes` `modules/agent-hooks.nix:9-11`, which copies `git-hooks/protected-refs-policy` to `~/.config/git/` as a single standalone file with no sibling `lib/` — so the "one shared helper" goal is a cross-repo packaging decision (second `home.file` entry, absolute store path, or accepted duplication with tests on both copies), not an implementation detail. And `run_repo_hook` (`:98`) resolves `$repo/.git/hooks/$hook`, which does not exist in a linked worktree because `.git` is a file there; repo-local hooks are therefore already silent in every worktree, while `run_tracked_hook` reading tracked `.hooks` is unaffected. Worth folding into the same fix. Read-only status lookup is available to agents; enumerating or changing the rules is not — `/branch_protections` returns 403 without repo admin. Recorded in `allod/memory` `git-workflow.md`.
Sign in to join this conversation.
No description provided.