Skip to content

Measure claim scopes against the mainline, not every ref - #29

Merged
Deep-Sixed merged 3 commits into
mainfrom
claude/intelligent-knuth-b042bq
Sep 30, 2026
Merged

Deep-Sixed merged 3 commits into
mainfrom
claude/intelligent-knuth-b042bq

Conversation

@Deep-Sixed

@Deep-Sixed Deep-Sixed commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

Summary

This closes a way to get around claim scopes, found in review and reproduced with real pushes through the hook. An agent whose claim covers docs/** could land src/auth.py on its claimed branch:

  1. It pushes the commit straight to its claimed branch: rejected, needs out_of_scope_push.
  2. It pushes the same commit to an unclaimed branch, scratch: accepted, because unclaimed branches have no scopes.
  3. It pushes its claimed branch again: accepted, because the commit was already on a ref and so counted as nothing new.

The cause is that changed_paths treated every commit reachable from any existing ref as already governed, and one path list was used for two different checks.

Changes

  • git_objects.mainline_tips (new): the commits the hub's default branch (HEAD) and the protected refs point to. A missing ref is skipped, which only makes the check stricter.
  • gate.governed_operations now takes two path lists:
    • paths, measured against every ref as before, is used for protected paths. That baseline is sound because protected paths are checked on every push to every ref.
    • scope_paths, measured against the branch's previous value plus the mainline, is used for claim scopes. Merging main in still isn't charged to the claim. Merging another agent's branch, or first pushing to an unclaimed branch, now is. Counting another agent's branch against your claim was a deliberate choice.
  • prepare_push computes both lists the same way the hook does, so approve prepare still produces the hash the hook asks for. The existing check that the clone has every hub tip covers the mainline tips too.
  • README: "Changed paths" explains the two baselines. The threat-model line that described the old bypass as a limitation is replaced with the real remaining one: if the default branch isn't protected, changes pushed straight to it are never checked against a claim.

Integration with the reference-transaction hook (#30)

After main gained the two-phase push check (pre-receive records each update in ref_updates; the reference-transaction hook re-validates it at prepared), scope_paths has to be replayed like paths so operation hashes stay stable between the two phases:

  • Migration 007_ref_update_scope_paths.sql adds a nullable ref_updates.scope_paths JSON array.
  • pre-receive records scope_paths, and prepared re-validates with both recorded lists. A NULL (a row validated before the migration) is recomputed.
  • test_prepared_uses_the_paths_pre_receive_derived covers scope_paths too, and the migration-list tests include 006 and 007.

Tests

  • New: test_staging_work_on_another_branch_does_not_escape_the_claim. The staged commit is rejected, and approve prepare gives the same hash as the hook.
  • New: test_new_claimed_branch_is_measured_from_the_default_branch. With no protected refs, a new claimed branch is charged only for its own commits.
  • Changed: test_hook_holds_claimed_branch_to_its_scopes now expects that merging another agent's unprotected branch needs out_of_scope_push.
  • With the src/ change reverted, the first and third tests fail. The default-branch test passes either way; it guards against the fix being too strict.

Validation

  • pytest tests -q: 231 passed (after merging main).
  • pyright: 0 errors.
  • ruff check (the pinned 0.16.9): clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_014R4wfmENrMnt36KN1Kj6Fx
https://claude.ai/code/session_01Q8BpCkArA34hB1aSyVcnZp

The hook computed a push's changed paths as the commits reachable from
no existing ref, and used that one list for both protected paths and
claim scopes. For scopes that was a bypass: unclaimed branches carry no
scopes, so an agent could push an out-of-scope commit to an unclaimed
branch (accepted) and then fast-forward its claimed branch to it, which
then introduced "nothing new" and passed.

Scope checks now use their own baseline: the branch's previous value,
the hub's default branch (HEAD) and the protected refs. Anything else a
push brings into a claimed branch counts against the claim, including
another agent's branch. Merging the mainline in is still not charged.
Protected-path checks keep the every-ref baseline, which is sound
because they apply to every push on every ref. `approve prepare` uses
the same two baselines, so its hashes still match the hook's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014R4wfmENrMnt36KN1Kj6Fx
Deep-Sixed pushed a commit that referenced this pull request Sep 29, 2026
…ransaction binding

pre-receive now records both path lists it derives, the every-ref
`paths` for protected paths and the mainline `scope_paths` for claim
scopes, and the `prepared` stage replays both. The ref_updates migration
gains a scope_paths column accordingly.

New tests:
- An approved force-push that Git denies by config
  (receive.denyNonFastForwards, set by `git init --shared`) keeps its
  approval, and the retry consumes it.
- An update needing several approvals (protected ref + protected path)
  consumes all of them at `prepared` and restores all of them on abort.
- In a non-atomic push where main lands before a claimed branch, the
  claimed branch's `prepared` stage uses the paths pre-receive derived.
  With recomputed paths this push is aborted: the out-of-scope set
  shrinks once main has moved, so the approved hash no longer matches.

The branch's own copies of the scope tests (findings 3 and 4 in
test_governance_fixes.py) are removed: main carries them in
test_scope_enforcement.py, with #29's corrected expectation that
merging another agent's branch counts against the claim.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014R4wfmENrMnt36KN1Kj6Fx
#30 records each validated ref update's paths at pre-receive and replays
them at the reference transaction's `prepared` stage. This PR adds a
second list, scope_paths, measured against the mainline, so both are now
recorded and replayed: migration 007 adds ref_updates.scope_paths, and
evaluate_ref_update() computes whichever list it is not given. An update
validated before 007 (NULL) recomputes its scope paths.

test_prepared_uses_the_paths_pre_receive_derived pushes main and a claimed
branch together; it fails if `prepared` recomputes scope_paths, because
main landing first moves the mainline baseline.

README keeps main's "hooks" wording and this PR's mainline-baseline note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q8BpCkArA34hB1aSyVcnZp
@Deep-Sixed
Deep-Sixed marked this pull request as ready for review September 30, 2026 01:30
@Deep-Sixed
Deep-Sixed merged commit 2048e22 into main Sep 30, 2026
7 checks passed
Deep-Sixed pushed a commit that referenced this pull request Sep 30, 2026
Renumber 006_operation_policies.sql to 008, since 006 and 007 now hold
the ref-update transaction tables. Check the required views by name
before the schema fingerprint, as tables and triggers already are, so a
dropped policy view is reported as such.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q8BpCkArA34hB1aSyVcnZp
Deep-Sixed pushed a commit that referenced this pull request Sep 30, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q8BpCkArA34hB1aSyVcnZp
Deep-Sixed pushed a commit that referenced this pull request Sep 30, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q8BpCkArA34hB1aSyVcnZp
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants