Git ACL has no read-only tier: channel membership grants clone and push together
- Dominant language
- Rust
- Stars
- 32.7k
- Forks
- 4.3k
- Avg merge
- 1d 13h
- Merged PRs (30d)
- 253
Description
### Summary
On a hosted Builderlab relay, a repo's bound channel is its git ACL, and channel membership authorizes **clone, fetch and push together**. There is no way to grant read-only access to a repository. This makes an advisory reviewer — human or agent — impossible to express: the only way to let someone read the code is to also let them write it.
Also reporting two smaller things found while working around this: an announcement-edit failure that makes `repos bind` and `repos protect set` unusable on any repo older than the relay's timestamp window, and a git error message that reports the wrong cause.
Client: Buzz desktop **0.5.3** (CLI from the same bundle). Relay: hosted Builderlab, `/info` reports version 0.2.0.
---
### 1. No read-only tier on the git ACL
`buzz repos bind --help` states the model plainly:
> The `buzz-channel` tag on the announcement is the git ACL: the relay authorizes clone/fetch/push by membership in the bound channel.
So access is binary. In our case the repository is the company codebase and the would-be reader is a review bot whose entire job is to read a diff and give an opinion. Outside the channel it cannot fetch the diff at all, and reviews the pull-request description instead — which is worse than no review, because it looks like one. Inside the channel it can push to the repository.
Observed, from a non-member client:
```
$ git clone /git//
remote: repository not found
$ curl -s -o /dev/null -w '%{http_code}' '/git///info/refs?service=git-upload-pack'
401
```
and after adding that identity to the bound channel, the same clone succeeds.
**What we'd like:** a read-only grant. Either a per-member role on the bound channel (`reader`), or a second tag naming a channel whose members get read only — e.g. `buzz-channel-read` alongside `buzz-channel`.
**The workaround we're using, and a question about it.** `buzz repos protect set --ref 'refs/heads/*' --push owner` appears to give the same practical result: membership grants read, and the pre-receive hook refuses writes. We verified the hook is real and rule-driven rather than advisory, by setting `--require-patch` on a throwaway ref pattern and attempting a direct push:
```
remote: error: push denied by policy (HTTP 403)
remote: {"allowed":false,"denials":[{"ref_name":"refs/heads/zzz-probe",
"reason":"direct push denied: require-patch is set, submit a NIP-34 patch"}]}
! [remote rejected] -> zzz-probe (pre-receive hook declined)
```
Two questions:
- **Is `repos protect` intended as the read-only mechanism?** If so it would help to say so in the `repos bind` help text, because the natural reading today is that membership and write access are inseparable.
- **Is the repository owner deliberately exempt from `no-force-push` and `no-delete`?** We set `refs/heads/*` to `push:owner, no-force-push, no-delete`, and an owner force-push to a protected branch was **accepted**. That may well be intended — the owner outranks the rule — but combined with `protect list` happily echoing the rule back, it means an operator can believe force-push is disabled when it isn't. If it is intended, documenting it would prevent the wrong conclusion.
---
### 2. Bug: `repos bind` and `repos protect set` fail on any repo older than the timestamp window
Both commands edit the existing replaceable kind-30617 announcement and appear to re-sign it carrying its **original** `created_at`, which the relay then rejects as stale:
```
$ buzz --relay repos bind --id --channel
{"error":"relay_error","message":"relay error 400: invalid: event timestamp too far from server time","retryable":false}
```
The host clock was measured at **+0.07s** against `time.apple.com`, so this is not clock drift. Our announcement was 8 days old.
**Repro:** announce a repo, wait past the relay's `max_not_before_delta` window, then run `repos bind` or `repos protect set` against it.
**Workaround:** re-announce first with `repos create` using the identical `--id`, `--name` and `--clone`, which produces a fresh `created_at` on the same replaceable event, then immediately run `bind` / `protect set`. Note `repos create` rewrites the tag set, so an existing channel binding is dropped and must be re-applied after it — easy to miss, and it silently makes the repo unreachable again if you do.
This one had real cost: our repo predated the channel-binding model, so the relay was telling us to run `repos bind` while `repos bind` could not run.
---
### 3. Minor: git reports "repository not found" where the endpoint knows the real reason
Throughout the above, `git` consistently reported `remote: repository not found` while the underlying HTTP endpoint returned an honest `401` (missing authorization) or `403` with a reason string. A brand-new empty repo 404s identically to an unbound one, so from the git client the states "does not exist", "not authorized", and "exists but has no channel binding" are indistinguishable.
That ambiguity is what made this take days rather than minutes on our side — the first, wrong conclusion was that hosted data had been lost. Surfacing the endpoint's reason through the git error would be a large diagnosability win for a small change.
---
Happy to supply more detail on any of the three. Thanks for Buzz — the issue/PR/review loop otherwise worked end-to-end for us on the first real try.
Contributor guide
Research direction
Start with the `repos bind`, `repos protect set`, `repos protect list`, and git clone/fetch entry points described in the report. Reproduce the stale-announcement, protected-push, and 401/403 error cases, then trace how channel membership, protection rules, timestamps, and git errors are handled. Done means the requested read-only behavior and the two reported failures have defined, tested outcomes and the help or documentation matches them.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- git, rust
- Domain
- authorization, backend-api-design, cli
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100