worklyn / zukapublic
Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.
Get a copy:
git clone https://zuka.worklyn.com/worklyn/zuka.git
| 1 | {% hero %} |
| 2 | # zuka |
| 3 | |
| 4 | Git hosting your agent can actually drive. |
| 5 | |
| 6 | One small Rust binary — bare repositories, SSH and HTTP git, a REST API, MCP, |
| 7 | and CI. No database, no Docker, no drama. |
| 8 | |
| 9 | [See it running](https://zuka.worklyn.com) [Five-minute tour](#the-idea) [Quickstart](#quickstart) |
| 10 | {% /hero %} |
| 11 | |
| 12 | {% callout type="note" title="Live, and building itself" %} |
| 13 | zuka runs at `https://zuka.worklyn.com` and is self-hosting: every push to its |
| 14 | own source triggers its own CI, which builds the binary that deploys the server |
| 15 | that ran the CI. It has already caught five real bugs in itself this way. |
| 16 | The gate: `cargo fmt --check` · `clippy -D warnings` · `cargo test` (301) · |
| 17 | `deno task test:live` (69 tests against a real server, with real `git`). |
| 18 | {% /callout %} |
| 19 | |
| 20 | Named for the sound of it rather than the meaning: two syllables, a rare opening |
| 21 | consonant, one plausible spelling after hearing it once. It sits near Swahili |
| 22 | *kuzuka* — to surface, to emerge — without claiming to be it. |
| 23 | |
| 24 | --- |
| 25 | |
| 26 | ## The idea |
| 27 | |
| 28 | **Agents already know git. What they can't do is the GitHub part.** |
| 29 | |
| 30 | Claude, Cursor and the rest run `git clone`, `commit` and `push` perfectly well. |
| 31 | What stops a non-developer shipping with them is everything bolted on top: |
| 32 | creating the repository, orgs and teams, deploy keys, permission matrices, pull |
| 33 | requests, review, Actions YAML. |
| 34 | |
| 35 | So the split is strict: |
| 36 | |
| 37 | {% cards %} |
| 38 | {% card title="git does the git" %} |
| 39 | Code moves over the wire protocol, HTTP or SSH. Nothing proprietary. |
| 40 | {% /card %} |
| 41 | {% card title="the API does the rest" %} |
| 42 | Create a repo, register a key, read a file, check CI — over REST and MCP. |
| 43 | {% /card %} |
| 44 | {% /cards %} |
| 45 | |
| 46 | **Nothing in the API writes to a repository.** No commit endpoint, no file |
| 47 | endpoint, no ref-moving endpoint. A second write path would mean two homes for |
| 48 | the fast-forward rule, two concurrency models and a merge story — git already |
| 49 | has all three. The write path is `git push`. |
| 50 | |
| 51 | What's deliberately absent is the point: no organisations, no teams, no pull |
| 52 | requests, no review, no issues. The whole model is accounts, repositories, |
| 53 | refs, keys and runs. |
| 54 | |
| 55 | --- |
| 56 | |
| 57 | ## Your README is the website |
| 58 | |
| 59 | Open a repository in a browser and you don't get a wall of filenames — you get |
| 60 | the README, rendered as a page, the way [Markdoc](https://markdoc.dev/) turns |
| 61 | Stripe's doc files into stripe.com/docs. The file listing is one tab away, not |
| 62 | the thing a visitor has to squint at first. This page you're reading *is* that |
| 63 | website. |
| 64 | |
| 65 | The viewer is built for people who have never used a code host: the tabs say |
| 66 | **Overview · Files · History · Branches** instead of tree, log and refs; every |
| 67 | tab remembers which branch you were on; history reads like a chat feed with a |
| 68 | coloured initial for each author; statuses say *Added*, *Changed*, *Removed*. |
| 69 | A wrong link gets a sentence and a way home, not machine JSON. |
| 70 | |
| 71 | Plain markdown already works. For readmes that want to be front pages, there's |
| 72 | a small dialect — a subset of Markdoc's syntax. A tag sits alone on its own |
| 73 | line; everything between tags is ordinary markdown: |
| 74 | |
| 75 | ``` |
| 76 | {% hero %} |
| 77 | # Big friendly title |
| 78 | One-line pitch under it. |
| 79 | |
| 80 | [Primary button](docs/start.md) [Second button](#below) |
| 81 | {% /hero %} |
| 82 | |
| 83 | {% callout type="tip" title="Good to know" %} |
| 84 | Callouts come as note, tip, warn and danger. |
| 85 | {% /callout %} |
| 86 | |
| 87 | {% cards %} |
| 88 | {% card title="Left" %} |
| 89 | Feature one. |
| 90 | {% /card %} |
| 91 | {% card title="Right" %} |
| 92 | Feature two. |
| 93 | {% /card %} |
| 94 | {% /cards %} |
| 95 | |
| 96 | {% details summary="The long table nobody reads twice" %} |
| 97 | …hidden until tapped, no JavaScript involved… |
| 98 | {% /details %} |
| 99 | ``` |
| 100 | |
| 101 | The rules, all of them: |
| 102 | |
| 103 | - A tag line starts at column zero. Indented or fenced tags are shown, not run — |
| 104 | which is how this README quotes them. |
| 105 | - Unknown tags vanish and their content stays, so a document written for a |
| 106 | richer engine degrades to its words, never to tag soup. |
| 107 | - An unclosed tag is closed for you at the end. A typo can't eat the page. |
| 108 | - Relative links work: `[guide](docs/guide.md)` opens that file rendered as a |
| 109 | page, `` serves the image, `[src](src/)` opens the |
| 110 | listing. A folder of markdown becomes a small site with zero configuration. |
| 111 | - Headings get anchor ids, so `[jump](#the-idea)` works — this page uses them. |
| 112 | - Any `.md` file renders as a document; add `?plain=1` to see the raw text. |
| 113 | |
| 114 | And because a README is written by anyone who can push — on a public |
| 115 | repository, a stranger — the whole surface stays paranoid: raw HTML is dropped, |
| 116 | link schemes pass an allow-list, every interpolated value is escaped, and the |
| 117 | page ships a CSP with **no script source at all**. The dialect adds wrappers, |
| 118 | not execution. |
| 119 | |
| 120 | --- |
| 121 | |
| 122 | ## Install onto a host |
| 123 | |
| 124 | ```sh |
| 125 | sudo zuka setup --standalone --dry-run # prints every file it would write |
| 126 | sudo zuka setup --standalone |
| 127 | ``` |
| 128 | |
| 129 | It writes `/etc/zuka.env` and two systemd units — the service and maintenance |
| 130 | separately, because a long repack must not stall requests — then prints the |
| 131 | remaining commands rather than running them. Creating users and enabling units |
| 132 | are steps someone may reasonably want to do differently. |
| 133 | |
| 134 | **`--domain` is optional, and does one thing:** it adds a Caddyfile so Caddy |
| 135 | obtains and renews TLS for that name, and sets the public URLs advertised in |
| 136 | clone URLs. Without it you get a service on `127.0.0.1:8790` with no TLS, which |
| 137 | is right behind an existing proxy or for a local trial. |
| 138 | |
| 139 | ```sh |
| 140 | sudo zuka setup --standalone --domain git.example.com |
| 141 | ``` |
| 142 | |
| 143 | **`--isolated`** installs the multi-tenant shape: a control plane that holds no |
| 144 | repositories and gives every account its own Incus container. Code, CI and |
| 145 | objects for one account never touch another's filesystem. `--domain` is |
| 146 | optional here too and means exactly the same thing — the control plane is the |
| 147 | only public listener, so it is the only thing a certificate is for. |
| 148 | |
| 149 | ```sh |
| 150 | sudo zuka setup --isolated |
| 151 | curl -X POST localhost:8790/v1/accounts/alice # returns immediately |
| 152 | ``` |
| 153 | |
| 154 | Set a public URL if you have one: a tenant is only reachable *through* the |
| 155 | control plane, so left to itself it advertises its own container address and |
| 156 | hands callers a clone URL they cannot use. |
| 157 | |
| 158 | Provisioning never blocks a request. The account records intent and a |
| 159 | reconciler drives it to `ready`; a request arriving early gets `429` with a |
| 160 | `Retry-After`. Tenants are stopped when idle and started again on demand. |
| 161 | |
| 162 | {% callout type="warn" title="Static linking is not optional in --isolated" %} |
| 163 | The tenant binary is pushed into a container whose C library is not the host's: |
| 164 | |
| 165 | ```sh |
| 166 | cargo build --release --target x86_64-unknown-linux-musl |
| 167 | ``` |
| 168 | {% /callout %} |
| 169 | |
| 170 | ## Quickstart |
| 171 | |
| 172 | Needs `git` ≥ 2.41 on the host. |
| 173 | |
| 174 | ```sh |
| 175 | cargo build --release |
| 176 | export ZUKA_DATA_DIR=/tmp/zuka && mkdir -p "$ZUKA_DATA_DIR" |
| 177 | |
| 178 | TOKEN=$(./target/release/zuka token alice --scopes admin) # shown once |
| 179 | ./target/release/zuka |
| 180 | # [boot] listening on http://127.0.0.1:8790 |
| 181 | # [ssh] listening on ssh://127.0.0.1:2222 |
| 182 | ``` |
| 183 | |
| 184 | **Create and push** |
| 185 | |
| 186 | ```sh |
| 187 | curl -X POST localhost:8790/v1/repos \ |
| 188 | -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ |
| 189 | -d '{"name":"site","visibility":"public"}' |
| 190 | |
| 191 | git clone "http://x-access-token:$TOKEN@127.0.0.1:8790/alice/site.git" |
| 192 | cd site && echo hello > README.md |
| 193 | git add . && git commit -m "first" && git push origin HEAD:refs/heads/main |
| 194 | ``` |
| 195 | |
| 196 | The git username is ignored; the password is your token. `visibility` defaults |
| 197 | to `private`; a public repository is clonable and browsable by anyone, writable |
| 198 | by no one anonymous. Open `http://127.0.0.1:8790/alice/site` and there's your |
| 199 | website. |
| 200 | |
| 201 | **Use SSH instead** |
| 202 | |
| 203 | ```sh |
| 204 | ./target/release/zuka key alice ~/.ssh/id_ed25519.pub --title laptop |
| 205 | git clone ssh://git@127.0.0.1:2222/alice/site.git |
| 206 | ``` |
| 207 | |
| 208 | `--read-only` makes a key clone-only; `--repos a,b` confines it. |
| 209 | |
| 210 | **Read without cloning** |
| 211 | |
| 212 | ```sh |
| 213 | A="Authorization: Bearer $TOKEN"; R=localhost:8790/v1/repos/alice/site |
| 214 | curl -H "$A" $R/refs |
| 215 | curl -H "$A" "$R/tree/src?ref=refs/heads/main" |
| 216 | curl -H "$A" "$R/raw/README.md" |
| 217 | curl -H "$A" "$R/search?q=TODO" |
| 218 | curl -H "$A" "$R/blame/src/main.rs" |
| 219 | curl -H "$A" "$R/compare?base=<sha>&head=<sha>" |
| 220 | ``` |
| 221 | |
| 222 | On `/v1` the ref is always a **query parameter**, never a path segment — |
| 223 | `feature/login` and `src/main.rs` both contain slashes, so `/raw/{ref}/{path}` |
| 224 | has no unique parse. The browser URLs accept the ambiguity for the sake of |
| 225 | readable links; the API does not. |
| 226 | |
| 227 | **Turn on CI** |
| 228 | |
| 229 | ```sh |
| 230 | ZUKA_CI_ENABLED=1 ./target/release/zuka |
| 231 | ``` |
| 232 | |
| 233 | ```toml |
| 234 | # .zuka.toml, in the repository |
| 235 | [run] |
| 236 | steps = ["cargo fmt --check", "cargo test"] |
| 237 | timeout_secs = 600 |
| 238 | branches = ["refs/heads/main"] # optional; omit to run everywhere |
| 239 | ``` |
| 240 | |
| 241 | Push, then `curl -H "$A" $R/runs` and `$R/runs/{id}/logs`. |
| 242 | |
| 243 | --- |
| 244 | |
| 245 | ## For agents — MCP |
| 246 | |
| 247 | ``` |
| 248 | POST /mcp JSON-RPC 2.0: initialize · ping · tools/list · tools/call |
| 249 | ``` |
| 250 | |
| 251 | The intended session: **`repo_create` → `key_add` → plain |
| 252 | `git clone`/`commit`/`push` → `run_list`**. There is no tool that writes to a |
| 253 | repository, and `initialize` says so, so a model doesn't go hunting for one. |
| 254 | |
| 255 | A tool failure that's the caller's fault comes back as a tool result with |
| 256 | `isError: true`, not a JSON-RPC error — the model should see it and adapt, not |
| 257 | have the call look broken. |
| 258 | |
| 259 | {% details summary="The 18 tools" %} |
| 260 | |
| 261 | **Administration** — the part git can't do |
| 262 | | Tool | | |
| 263 | |---|---| |
| 264 | | `repo_create` | Create a repository. Returns both clone URLs. | |
| 265 | | `repo_list` `repo_get` `repo_delete` | | |
| 266 | | `key_add` `key_list` `key_remove` | Register an SSH key so the agent can then use git. | |
| 267 | |
| 268 | **Discovery** — read without cloning |
| 269 | | Tool | | |
| 270 | |---|---| |
| 271 | | `ref_list` | Branches, tags, notes with tip commits. | |
| 272 | | `tree_list` | List a directory. | |
| 273 | | `file_read` | Read a text file. | |
| 274 | | `search` | Fixed-string search across the tree. | |
| 275 | | `commit_log` `commit_get` | History; optionally path-filtered. | |
| 276 | | `compare` | Diff from the merge base. | |
| 277 | | `blame` | Who last changed each line. | |
| 278 | |
| 279 | **CI** |
| 280 | | Tool | | |
| 281 | |---|---| |
| 282 | | `run_list` `run_get` `run_logs` | Did the build pass, and why not. | |
| 283 | |
| 284 | {% /details %} |
| 285 | |
| 286 | --- |
| 287 | |
| 288 | ## API |
| 289 | |
| 290 | Full contract: [`openapi.json`](openapi.json), also served at `/openapi.json`. |
| 291 | |
| 292 | | | | |
| 293 | |---|---| |
| 294 | | `GET /healthz` | Mode, version, disk headroom, CI state. `503` below the reserve. | |
| 295 | | `GET /openapi.json` | This service's OpenAPI 3.1 document. | |
| 296 | | `GET /v1/account` | Identity, scopes, storage usage and limits. | |
| 297 | | `GET POST /v1/repos` | List, create (optionally `import_url`, `visibility`). | |
| 298 | | `GET PATCH DELETE /v1/repos/{account}/{repo}` | Read, update settings, soft-delete. | |
| 299 | | `GET .../refs?type=` | Branches, tags, notes. | |
| 300 | | `GET .../tree/{path}?ref=` | Directory listing. | |
| 301 | | `GET .../raw/{path}?ref=` | File bytes. | |
| 302 | | `GET .../commits?ref=&path=&limit=` | Log. | |
| 303 | | `GET .../commits/{sha}` | One commit and its changed paths. | |
| 304 | | `GET .../compare?base=&head=` | Diff from the merge base. | |
| 305 | | `GET .../search?q=&path=&ref=` | Content search. | |
| 306 | | `GET .../blame/{path}?ref=` | Line attribution. | |
| 307 | | `GET POST .../runs` · `GET PATCH .../runs/{id}` · `GET .../runs/{id}/logs` | CI. | |
| 308 | | `GET POST /v1/keys` · `DELETE /v1/keys/{id}` | SSH keys. | |
| 309 | | `POST /mcp` | MCP JSON-RPC. | |
| 310 | | `/{account}/{repo}.git/*` | Git smart HTTP. Protocol v2. | |
| 311 | | `ssh://git@host/{account}/{repo}.git` | Git over SSH, plus `git archive --remote`. | |
| 312 | |
| 313 | The browser lives on the same origin, read-only, no credential required for |
| 314 | public repositories: `/{account}/{repo}` (the clone URL minus `.git`), plus |
| 315 | `/tree/{ref}/{path}`, `/blob/{ref}/{path}` (`?plain=1` for raw text), |
| 316 | `/raw/{ref}/{path}` (download), `/commits/{ref}` (`?from={sha}` pages older), |
| 317 | `/commit/{sha}` and `/refs`. `/` redirects to the repository named by |
| 318 | `ZUKA_HOME_REPO`. |
| 319 | |
| 320 | Errors on `/v1` and `/mcp` are RFC 9457 `application/problem+json` with a |
| 321 | stable `type` URI — discriminate on `type`, never on `detail`. The git paths |
| 322 | answer in `text/plain`, because git prints the body straight at the user. The |
| 323 | browser pages answer a missing page in HTML, because a person tapped that link. |
| 324 | |
| 325 | Lists return `{items, truncated}`. There is no cursor, and none is claimed. |
| 326 | |
| 327 | ### CLI |
| 328 | |
| 329 | ``` |
| 330 | zuka serve |
| 331 | zuka setup --standalone|--isolated install onto this host [--domain d] [--dry-run] |
| 332 | zuka jobs maintenance daemon (own service unit) |
| 333 | zuka gc | sweep | fsck [--repair] one-shot maintenance |
| 334 | zuka token <account> [--scopes s] [--repos r] [--days n] [--no-expiry] |
| 335 | zuka key <account> <path.pub> [--title t] [--read-only] [--repos r] |
| 336 | zuka version |
| 337 | ``` |
| 338 | |
| 339 | Scopes: `repo:read`, `repo:write`, `admin`, `ci`. Tokens expire in 90 days |
| 340 | unless you pass `--days` or opt out with `--no-expiry`, which warns. |
| 341 | Credentials added while the server runs take effect without a restart. |
| 342 | |
| 343 | `fsck` exits non-zero on an orphan so a timer notices. It reports a repository |
| 344 | with no metadata but never deletes one — repair only removes records that |
| 345 | describe nothing. |
| 346 | |
| 347 | --- |
| 348 | |
| 349 | ## Configuration |
| 350 | |
| 351 | Everything has a working default; nothing is required. |
| 352 | |
| 353 | {% details summary="All settings" %} |
| 354 | |
| 355 | | Variable | Default | | |
| 356 | |---|---|---| |
| 357 | | `ZUKA_BIND` | `127.0.0.1:8790` | HTTP listener. | |
| 358 | | `ZUKA_SSH_BIND` | `127.0.0.1:2222` | SSH listener; `off` disables. | |
| 359 | | `ZUKA_DATA_DIR` | `/var/lib/zuka` | Everything on disk. | |
| 360 | | `ZUKA_PUBLIC_URL` / `_SSH` | derived | Advertised clone URLs. | |
| 361 | | `ZUKA_HOME_REPO` | — | `account/repo` featured at `/` in the browser. | |
| 362 | | `ZUKA_PROBLEM_BASE` | `https://zuka.dev/problems` | Point error `type` URIs at your own docs. | |
| 363 | | **Limits** | | | |
| 364 | | `ZUKA_MAX_REPOS_PER_ACCOUNT` | `100` | `0` disables. | |
| 365 | | `ZUKA_MAX_REPO_MB` / `_ACCOUNT_MB` | `2048` / `10240` | `0` disables. | |
| 366 | | `ZUKA_RATE_PER_MINUTE` | `600` | Per credential, on `/v1` and `/mcp`. `0` disables. | |
| 367 | | `ZUKA_MAX_PACK_MB` | `512` | Largest push. Also sets `receive.maxInputSize`. | |
| 368 | | `ZUKA_MAX_BLOB_MB` | `32` | Largest blob served inline. | |
| 369 | | `ZUKA_MAX_BODY_MB` | `10` | Largest JSON body. | |
| 370 | | `ZUKA_PAGE_LIMIT` / `_MAX_PAGE_LIMIT` | `50` / `200` | | |
| 371 | | `ZUKA_MAX_CONCURRENT_GIT` | `8` | Concurrent git subprocesses. | |
| 372 | | `ZUKA_DISK_RESERVE_MB` | `2048` | Below this, writes return `507`. | |
| 373 | | **CI** | | | |
| 374 | | `ZUKA_CI_ENABLED` | `0` | Off by default; see below. | |
| 375 | | `ZUKA_CI_TIMEOUT_SECS` | `600` | Ceiling. A spec may lower it, never raise it. | |
| 376 | | `ZUKA_CI_MAX_CONCURRENT` | `2` | | |
| 377 | | `ZUKA_CI_LOG_MB` | `8` | Output cap per run. | |
| 378 | | `ZUKA_CI_MEMORY_MB` | `2048` | `RLIMIT_AS`. `0` disables. | |
| 379 | | `ZUKA_CI_FILE_MB` | `2048` | `RLIMIT_FSIZE`. Independent of the memory limit. | |
| 380 | | `ZUKA_CI_MAX_PROCESSES` | `0` | `RLIMIT_NPROC` — per-UID, so only meaningful with a runner uid. | |
| 381 | | `ZUKA_CI_KEEP_RUNS` | `50` | Runs retained per repository. | |
| 382 | | **Maintenance** | | | |
| 383 | | `ZUKA_GC_INTERVAL_SECS` | `21600` | | |
| 384 | | `ZUKA_GC_PRUNE_GRACE` | `2.weeks.ago` | Objects younger than this are never pruned. | |
| 385 | | `ZUKA_DELETED_RETENTION_DAYS` | `7` | | |
| 386 | | **Import** | | | |
| 387 | | `ZUKA_IMPORT_ENABLED` | `1` | | |
| 388 | | `ZUKA_IMPORT_TIMEOUT_SECS` | `300` | | |
| 389 | | `ZUKA_IMPORT_ALLOW_PRIVATE` | `0` | Permit private/loopback sources. Only on a trusted network. | |
| 390 | | **Multi-tenant** | | | |
| 391 | | `ZUKA_MODE` | `standalone` | `standalone` · `control` · `tenant` | |
| 392 | | `ZUKA_OPERATOR` | — | Account allowed to manage accounts. Unset ⇒ refused for all. | |
| 393 | | `ZUKA_INCUS_IMAGE` | `images:debian/12` | Image for a new tenant. | |
| 394 | | `ZUKA_INCUS_NETWORK` | `incusbr0` | Network a tenant joins. | |
| 395 | | `ZUKA_TENANT_BINARY` | this binary | Pushed into each container; must be static. | |
| 396 | | `ZUKA_TENANT_PORT` | `8790` | Port a tenant listens on inside its container. | |
| 397 | | `ZUKA_CONTROL_PUBLIC_KEYS` | — | Tenant only. Comma-separated; more than one allows key rotation. | |
| 398 | | `ZUKA_PROXY_TIMEOUT_SECS` | `300` | Ceiling on one proxied request. | |
| 399 | |
| 400 | {% /details %} |
| 401 | |
| 402 | `ZUKA_ME_URL` is parsed and then **refuses to boot** — hosted auth isn't |
| 403 | implemented, and accepting a security-relevant setting while ignoring it is |
| 404 | worse than not accepting it. |
| 405 | |
| 406 | ### Maintenance |
| 407 | |
| 408 | Run `zuka jobs` as a second service unit — a long repack must not stall request |
| 409 | handling. |
| 410 | |
| 411 | ```ini |
| 412 | [Unit] |
| 413 | Description=zuka maintenance |
| 414 | After=zuka.service |
| 415 | [Service] |
| 416 | ExecStart=/usr/local/bin/zuka jobs |
| 417 | EnvironmentFile=/etc/zuka.env |
| 418 | User=zuka |
| 419 | Restart=always |
| 420 | [Install] |
| 421 | WantedBy=multi-user.target |
| 422 | ``` |
| 423 | |
| 424 | It garbage-collects (git's own auto-gc is disabled on every repo, so a repack |
| 425 | never fires *inside* a push), sweeps deleted repositories and old runs, and |
| 426 | fscks. |
| 427 | |
| 428 | What makes GC safe against a live clone is the **grace window**, not a lock — a |
| 429 | lock couldn't span the two processes. `upload-pack` can reference an object |
| 430 | between resolving and streaming it, so nothing younger than `GC_PRUNE_GRACE` is |
| 431 | pruned. A test clones a 12 MB repo while GC runs and checks the result byte for |
| 432 | byte. |
| 433 | |
| 434 | {% callout type="danger" title="Force-pushing does not delete" %} |
| 435 | A committed secret stays fetchable by sha until GC prunes it. Force-pushing |
| 436 | past it moves the ref, not the object. |
| 437 | {% /callout %} |
| 438 | |
| 439 | --- |
| 440 | |
| 441 | ## Security |
| 442 | |
| 443 | Each of these has a test that fails if it regresses. Several are here because |
| 444 | an earlier version got them wrong. |
| 445 | |
| 446 | **Names can't escape the data directory.** Ref names go through `gix-validate`, |
| 447 | never a hand-rolled check — `../../config` is a command-execution primitive via |
| 448 | `core.pager` and `core.sshCommand`. Tree paths reject any `.git` component |
| 449 | after Unicode normalisation, case folding, HFS-ignorable-codepoint stripping |
| 450 | and trailing dot/space removal. |
| 451 | |
| 452 | **Names are case-folded.** `Alice/Site` and `alice/site` are one resource. |
| 453 | Comparing the raw path segment once let a case-variant account reach another |
| 454 | account's repository on a case-insensitive filesystem while passing the |
| 455 | ownership check. |
| 456 | |
| 457 | **An unreadable repository is `404`, not `403`.** `403` enumerates private |
| 458 | repos. Confinement applies to destruction too: a token scoped to one repo |
| 459 | can't delete another. |
| 460 | |
| 461 | **Anonymous means read, and only through one door.** Exactly one function |
| 462 | grants anonymous access, it can only read, and a presented-but-rejected |
| 463 | credential stays rejected — an expired token never quietly degrades into an |
| 464 | anonymous visitor. The browser answers a private repository with the same page |
| 465 | as an absent one; the git wire answers both with a `401` challenge, so neither |
| 466 | surface can be used to enumerate names. |
| 467 | |
| 468 | **The viewer renders hostile input inert.** Every value is escaped, markdown |
| 469 | drops raw HTML and rewrites link schemes against an allow-list, and pages carry |
| 470 | a CSP with no script source — so anything that survived the escaping would |
| 471 | still have nowhere to execute. Raw file bytes are served as an attachment in a |
| 472 | CSP sandbox, never rendered on the origin. |
| 473 | |
| 474 | **Rewriting is allowed; protection is opt-in.** `rebase` and `--amend` are |
| 475 | ordinary git and blocking them protects nobody when there's one agent and no |
| 476 | reviewer — git's reflog is the recovery path. Set `protected_refs` on the |
| 477 | branch something deploys from; it's then enforced server-side over HTTP *and* |
| 478 | SSH, which matters because `--force` bypasses the client check. One Rust |
| 479 | function; the hook is a one-line `exec` into the binary. |
| 480 | |
| 481 | **SSH runs git and nothing else.** No shell, no pty, no subsystem. Three |
| 482 | services: `upload-pack`, `receive-pack`, `upload-archive`. The exec string is |
| 483 | parsed, never shelled, so `git-upload-pack '/a/b.git'; id` is a parse error. |
| 484 | The account comes from *which registered key matched*, never the SSH username. |
| 485 | Only `GIT_PROTOCOL` crosses into the child. |
| 486 | |
| 487 | **Only three endpoints route under `.git`.** The dumb protocol isn't served. A |
| 488 | duplicated `service` parameter is refused — this server would take the first |
| 489 | and the CGI the last, so a duplicate would let the authorization decision and |
| 490 | the running service disagree. |
| 491 | |
| 492 | **Import can't reach the local network.** The one place a caller-supplied URL |
| 493 | is fetched. Checked in two stages: shape (scheme, credentials) then *every |
| 494 | address the host resolves to* — checking the hostname alone is defeated by a |
| 495 | name that resolves to `169.254.169.254`. |
| 496 | |
| 497 | **Credentials are hashed, scoped, expiring and `0600`.** Token comparison has |
| 498 | no early return. Public keys match on raw bytes; `authorized_keys` option |
| 499 | prefixes like `command=` are refused. |
| 500 | |
| 501 | **Deletes are recoverable.** Soft-deleted to `tmp/deleted/`, swept on |
| 502 | retention. |
| 503 | |
| 504 | **Nothing buffers a pack.** |
| 505 | |
| 506 | ``` |
| 507 | before after |
| 508 | 200 MB push, server RSS ~360 MB 5 MB |
| 509 | 4 concurrent clones 1508 MB 5 MB |
| 510 | ``` |
| 511 | |
| 512 | The child is killed by **process group** on disconnect — `kill_on_drop` reaches |
| 513 | only the direct child, and `pack-objects` is a grandchild that otherwise kept |
| 514 | running for seconds after the client left. |
| 515 | |
| 516 | ### Multi-tenant isolation |
| 517 | |
| 518 | In `--isolated`, each account gets its own Incus container, unprivileged and |
| 519 | with nesting off. The control plane holds no repositories: a push through it |
| 520 | lands in the account's container and nowhere on the host. |
| 521 | |
| 522 | A tenant is told who is calling by an **Ed25519 assertion the control plane |
| 523 | signs**, not a shared secret. That matters because a tenant runs the account's |
| 524 | own CI, which is arbitrary code — anything symmetric in there would forge every |
| 525 | other tenant. The private key never leaves the control plane; tenants hold only |
| 526 | the public half, which forges nothing. |
| 527 | |
| 528 | The assertion authenticates one request, not an identity: single-use nonce, |
| 529 | 30-second window, bound to the method and path. It is deliberately *not* bound |
| 530 | to the body, because a push is a multi-gigabyte pack the proxy streams — the |
| 531 | nonce is what stops replay. |
| 532 | |
| 533 | Verified on a real host rather than asserted, in |
| 534 | [`infra/README.md`](../../infra/README.md): CI inside a tenant reports |
| 535 | `uname -n` = `zuka-alice`, and a second account gets `404` on the first's |
| 536 | repository and an empty repo list. |
| 537 | |
| 538 | ### CI is not a security boundary in standalone |
| 539 | |
| 540 | Steps run as the service user with rlimits, a scrubbed environment, a |
| 541 | process-group kill on timeout and a bounded log — but **no container and no |
| 542 | network namespace**. Anyone who can push can run code as that user. CI is off |
| 543 | by default and the boot log says so. |
| 544 | |
| 545 | A step allowlist was considered and rejected: `sh -c` defeats it in one |
| 546 | character. Enforcement is at the OS or it isn't enforcement. Multi-tenant |
| 547 | isolation is what `--isolated` is for. |
| 548 | |
| 549 | --- |
| 550 | |
| 551 | ## Renaming it |
| 552 | |
| 553 | The name lives in exactly one file. `src/brand.rs` derives the env prefix, data |
| 554 | directory, problem-type URIs, auth realm, git config namespace, CI filename and |
| 555 | hook shim from `CARGO_PKG_NAME`. |
| 556 | |
| 557 | Renaming is one line in `Cargo.toml` plus the `openapi.json` title — verified |
| 558 | by actually doing it. A unit test walks `src/` and fails if the name appears as |
| 559 | a literal anywhere outside `brand.rs`; it has already caught a hardcoded auth |
| 560 | realm, and it is the reason even the browser viewer's stylesheet URL is derived. |
| 561 | |
| 562 | --- |
| 563 | |
| 564 | ## Development |
| 565 | |
| 566 | ```sh |
| 567 | cargo fmt --check |
| 568 | cargo clippy --all-targets -- -D warnings |
| 569 | cargo test # 301 unit tests |
| 570 | deno task test:live # 69 live tests: spawns the binary, drives real git and ssh |
| 571 | ``` |
| 572 | |
| 573 | The live suite is the real proof — unit tests cover units, but only a real |
| 574 | clone and push exercise the wire protocol. It runs in CI rather than |
| 575 | self-skipping there. |
| 576 | |
| 577 | ``` |
| 578 | src/ |
| 579 | ├── brand.rs the one place the product name appears |
| 580 | ├── config.rs Config::from_env(); fails boot on a bad value |
| 581 | ├── error.rs Error -> RFC 9457 problem+json |
| 582 | ├── core/ domain operations shared by every facade |
| 583 | ├── git/ |
| 584 | │ ├── exec.rs the only place this service invokes git |
| 585 | │ ├── validate.rs security controls: names, refs, paths |
| 586 | │ ├── store.rs repositories, hooks, ref rules |
| 587 | │ ├── discover.rs the read core, shared by REST, MCP and the browser |
| 588 | │ ├── transport.rs streaming CGI bridge |
| 589 | │ └── import.rs SSRF-guarded cloning |
| 590 | ├── http/ routing, limits, responses, auth, rate limiting |
| 591 | ├── api/ REST facade |
| 592 | ├── mcp/ MCP facade |
| 593 | ├── web/ the browser viewer: escaping, the tag dialect, the pages |
| 594 | ├── ssh/ SSH server and exec-command parser |
| 595 | ├── ci/ spec, run store, runner, executor |
| 596 | ├── control/ accounts, provisioning, reconciler, streaming proxy |
| 597 | ├── setup.rs systemd units, Caddyfile, environment file |
| 598 | ├── jobs/ gc, sweep, fsck |
| 599 | ├── account/ identity, tokens, SSH keys |
| 600 | └── store/ metadata, quotas, change-aware file cache |
| 601 | ``` |
| 602 | |
| 603 | Two rules worth knowing before changing anything: |
| 604 | |
| 605 | - **git is the only writer.** No endpoint or tool modifies repository contents. |
| 606 | - **Facades translate; they don't decide.** Authorization lives on `Identity`, |
| 607 | git rules in `git/`, shared operations in `core/`. `api/` and `mcp/` reshape |
| 608 | payloads and nothing else — they had drifted copies of repo create and delete |
| 609 | once, and those copies had already diverged in four ways. |
| 610 | |
| 611 | More: [`SPEC.md`](SPEC.md) · [`ROADMAP.md`](ROADMAP.md) · [`AGENTS.md`](AGENTS.md) |