Wire-protocol push has no repo-specific write-authorization check -- plus a general sweep
Wire-protocol push has no repo-specific write-authorization check — plus a general sweep for the same class of bug
Background
While verifying aaronrene's collaborator access (write permission just
granted on muse and musehub), traced the actual wire-protocol push
handler end to end and found it has no repo-specific authorization check
at all — a materially different, more serious problem than anything being
checked at the time.
Confirmed, traced in code — not assumed
musehub/api/routes/wire.py::push_unpack_mpack (the real push handler)
depends only on:
claims: TokenClaims = Depends(require_valid_token),
require_valid_token (= require_signed_request,
musehub/auth/request_signing.py:289) verifies only that the request
carries a valid MSign signature from some registered identity — it has no
concept of which repo is being written to.
pusher_id/claims.handle is passed into the service function
(wire_push_unpack_mpack, musehub/services/musehub_wire_push.py:539) but
is never referenced again anywhere else in that 1707-line file — accepted
as a parameter, never checked against anything.
There is no _assert_writable-style helper anywhere in wire.py — only
_assert_readable (line 90), used for fetch/read paths. _guard_write_access
— the exact per-repo collaborator/owner check musehub/api/routes/musehub/ proposals.py:116 and issues.py correctly call before allowing a write —
appears in zero wire-protocol routes (confirmed via
grep -rln _guard_write_access musehub/).
In plain terms: any authenticated MuseHub identity can currently push to
any repo, public or private, regardless of collaborator status. This is
unrelated to the collaborator-invite work that surfaced it — collaborator
invites are still correct and necessary for MP creation and other REST-gated
actions (which do call _guard_write_access correctly); push simply
doesn't do what anyone would reasonably assume.
Why a sweep, not just a point fix
The REST entity routes (issues.py, proposals.py, labels.py,
releases.py, repos.py) went through an explicit auth-hardening pass
(Phase 3 of the "Quality Roadmap," 2026-04-07 — see ~/ecosystem/ QUALITY_ROADMAP.md) that added require_scope/_guard_write_access
enforcement everywhere. wire.py is a separate, older, custom binary
protocol router — it's plausible it was simply never included in that sweep,
which raises the real question: are there other routers/endpoints with the
same gap? This ticket exists to answer that definitively, not just patch
the one confirmed hole.
Goal
- Fix the confirmed gap:
push_unpack_mpack(and any sibling wire-protocol write endpoint — repair-object/snapshot/commit, force-push, tag push) enforces the same per-repo collaborator/owner check as the REST routes. - Sweep every write-capable route in the codebase (
grepfor@router.post/put/patch/deleteacross all ofmusehub/api/routes/) and confirm each one either calls_guard_write_access/require_scopeappropriately, or is deliberately public-write by design (rare — document any such case explicitly, don't just assume). - A regression test proves the wire push gap specifically: a collaborator with no write access (or no collaborator record at all) attempting to push must be rejected — today it would succeed.
Out of Scope
- Redesigning the wire protocol's binary format or transport — this is an authorization gap, not a protocol design issue.
- The org/quorum audit (musehub issue #130) — related in spirit (both are "is our permission model actually complete") but that's the CLI/ functionality-parity angle; this ticket is specifically about finding and closing authorization holes.
Phases
Phase 1 — Red: prove the gap with a real test
AUTHZ_01— A registered identity with no collaborator record on a private repo successfully pushes viapush_unpack_mpacktoday. Must pass (i.e., prove the vulnerability) against current code, then be inverted to a rejection assertion once fixed.AUTHZ_02— Same setup for a public repo — confirm whether public repos are even more exposed (anyone can push, not just anyone with an account) or whether some other check applies there.
Deliverable: the gap is proven with a real test, not just static analysis, before any fix lands.
Phase 2 — Fix the confirmed wire-protocol gap
- Add a per-repo write-authorization check to
push_unpack_mpackand every other wire-protocol write endpoint (repair-object/snapshot/ commit already partially check this per the earlier grep — verify those are actually correct and consistent, not just present). AUTHZ_01/02now correctly reject unauthorized pushes.
Deliverable: wire-protocol push is provably no longer bypassable.
Phase 3 — Full sweep
- Enumerate every write route across
musehub/api/routes/(REST + wire + coord + mcp write-tools) and record, per route: does it check collaborator/owner access, does it userequire_scope, or is it deliberately open? Produce this as a table in this issue, not just a pass/fail — a route relying onrequire_scopealone (recall:require_scopepasses humans unconditionally, it's an agent-capability gate, not a repo-ownership gate) needs a second look, not just a checkbox. - Any additional gaps found get their own fix + test, following the same Phase 1→Phase 2 pattern used here.
Deliverable: a complete, documented map of every write route's actual authorization behavior — no more "plausible, unverified" routes.
Acceptance Criteria
push_unpack_mpack(and siblings) reject a push from a non-collaborator.- Every write route in
musehub/api/routes/has a documented, verified authorization story — not assumed correct because it "probably" calls the right dependency. - Full regression suite green; no privilege-escalation regressions introduced by the fix itself (e.g. don't accidentally lock out legitimate owners).
Implementation Order
Phase 1 → Phase 2 → Phase 3. Fix the confirmed, live gap first (it's on staging right now); the broader sweep comes after the acute issue is closed.
musehub#131 follow-on — collaborator write access is unreachable:
accepted_atcan never be setStatus: DRAFT for review before posting Date: 2026-09-10 Author: aaronrene Severity: blocks all collaborator writes on every MuseHub repo; regression introduced by the #131 authorization fix Relationship to #131: the authorization check added in
security/131-wire-write-authzis correct. The gap is that the state it checks for has no setter.Summary
Every write gate in MuseHub authorizes a non-owner by requiring an accepted collaborator row:
accepted_atis documented as "Null until the invited user accepts the invitation."No code path in MuseHub sets
accepted_at. There is no accept route, no MCP write tool, and no CLI subcommand.invite_collaboratorcreates the row withinvited_at=nowand omitsaccepted_atentirely, so it isNULLon creation and staysNULLforever.Net effect: an invited collaborator — at any permission level, including
admin— can never obtain write access to any MuseHub repo. Only repo owners can write.Observed
aaronreneis anadmincollaborator ongabriel/musehub:Pushing a new, non-shared backup branch is rejected:
The 403 is correct behavior given the data: the row's
accepted_atisNULL, so the check cannot pass.Why there is no setter (verified on
origin/dev@8a1389ae)The collaborators router defines exactly four routes:
GETlist_collaboratorsPOSTinvite_collaboratorPUTupdate_collaborator_permissionDELETEremove_collaboratorThere is no accept route. Invitation creation never sets the field:
A tree-wide search for any write to
accepted_at(excluding the unrelatedMusehubIdentity.tos_accepted_at) finds only two occurrences, both of which are reads or the synthetic owner record:repos.py:1481—accepted_at=Nonefor the synthesized owner entryrepos.py:1502—accepted_at=collab.accepted_atpassthrough on readmuse hub collaboratorexposes onlylist,invite,update-permission,remove— noaccept.Blast radius
Everything gated on accepted-collaborator status is unreachable for non-owners:
api/routes/wire.py:116,:144services/musehub_wire_push.py:152,:223,:301api/routes/musehub/proposals.py:94api/routes/musehub/issues.py:83api/routes/musehub/labels.py:103api/routes/musehub/webhooks.py:39api/routes/musehub/collaborators.py:169,:255,:322services/musehub_repository.py:322,:434services/musehub_mcp_executor.py:1448What still works, because public repos take a different path: reading, cloning, creating issues, and commenting.
issues.py::_guard_write_accessonly restricts non-owners on private repos, so on a public repo any authenticated identity may create issues, comments, and proposals.The MP flow is still blocked end to end, because opening a proposal requires first pushing a branch, and push is exactly what 403s.
Regression timeline
This is new as of 2026-09-09. Before the #131 fix the wire push path had no repo-scoped write-authorization check at all — which is the vulnerability #131 correctly identified and closed. My feature branches (
feat/f7b-http-coverage@82eef2e1,feat/kd-6b@c04414e7) are on the server precisely because pushes succeeded under the pre-fix behavior.Adding the check without adding an accept path flipped collaborators from "can push, unauthorized" to "cannot push at all." Both states are wrong; the second is the safer of the two, and it is a one-endpoint fix.
Deadlock worth flagging
I cannot deliver this fix through the normal flow. Opening an MP requires pushing a branch, and pushing requires the write access this bug removes. So the unblock has to come from the owner side.
Proposed fix
Immediate unblock (owner action, one row per collaborator): set
accepted_aton the existingaaronrenerows forgabriel/musehubandgabriel/muse. This restores the handover without any code change.Proper fix (small, and it closes the design gap):
POST /repos/{repo_id}/collaborators/accept— callable only by the invited identity itself, settingaccepted_at = now()where it is currentlyNULL. Idempotent.muse hub collaborator acceptto the CLI, plus surface pending invitations incollaborator listoutput (the current JSON omitsacceptedAt, which is why an unacceptedadminrow looks fully provisioned).accepted_atsurvives a permission update.Suggested seven-tier coverage
NULL; idempotent; permission unchangedaccepted_atpreserved acrossupdate-permission; owner synthetic row unaffectedadminhas zero write capability on every gate listed aboveEvidence index
aaronreneisadminongabriel/musehubmuse hub collaborator list --jsonmuse push origin backup/aaron-local-dev-2026-09-01collaborators.pyatorigin/dev8a1389aeaccepted_atinvite_collaboratorat same refaccepted_atissues.py::_guard_write_accessat same refgabriel/musehubis publicmuse hub repo read --json→visibility: public