# zuka

Git hosting for agents. One Rust binary — bare repositories, SSH and HTTP git, a REST
API, MCP, and CI. No database, no Docker, no external services.

```
Status   Complete. M0–M7 shipped: standalone and multi-tenant both work.
Tests    253 unit · 59 live (real git, ssh and MCP against a real server)
Gate     cargo fmt --check · clippy -D warnings · cargo test · deno task test:live
```

Named for the sound of it rather than the meaning: two syllables, a rare opening
consonant, one plausible spelling after hearing it once. It sits near Swahili
*kuzuka* — to surface, to emerge — without claiming to be it.

---

## The idea

**Agents already know git. What they can't do is the GitHub part.**

Claude, Cursor and the rest run `git clone`, `commit` and `push` perfectly well. What
stops a non-developer shipping with them is everything bolted on top: creating the
repository, orgs and teams, deploy keys, permission matrices, pull requests, review,
Actions YAML.

So the split is strict:

| | |
|---|---|
| **git does the git** | Code moves over the wire protocol, HTTP or SSH. Nothing proprietary. |
| **the API does the rest** | Create a repo, register a key, read a file, check CI — over REST and MCP. |

**Nothing in the API writes to a repository.** No commit endpoint, no file endpoint,
no ref-moving endpoint. A second write path would mean two homes for the
fast-forward rule, two concurrency models and a merge story — git already has all
three. The write path is `git push`.

What's deliberately absent is the point: no organisations, no teams, no pull
requests, no review, no issues. The whole model is accounts, repositories, refs, keys
and runs.

---

## Install onto a host

```sh
sudo zuka setup --standalone --dry-run    # prints every file it would write
sudo zuka setup --standalone
```

It writes `/etc/zuka.env` and two systemd units — the service and maintenance
separately, because a long repack must not stall requests — then prints the remaining
commands rather than running them. Creating users and enabling units are steps
someone may reasonably want to do differently.

**`--domain` is optional, and does one thing:** it adds a Caddyfile so Caddy obtains
and renews TLS for that name, and sets the public URLs advertised in clone URLs.
Without it you get a service on `127.0.0.1:8790` with no TLS, which is right behind
an existing proxy or for a local trial.

```sh
sudo zuka setup --standalone --domain git.example.com
```

**`--isolated`** installs the multi-tenant shape: a control plane that holds no
repositories and gives every account its own Incus container. Code, CI and objects
for one account never touch another's filesystem. `--domain` is optional here too and
means exactly the same thing — the control plane is the only public listener, so it
is the only thing a certificate is for.

```sh
sudo zuka setup --isolated
curl -X POST localhost:8790/v1/accounts/alice     # returns immediately
```

Set a public URL if you have one: a tenant is only reachable *through* the control
plane, so left to itself it advertises its own container address and hands callers a
clone URL they cannot use.

Provisioning never blocks a request. The account records intent and a reconciler
drives it to `ready`; a request arriving early gets `429` with a `Retry-After`.
Tenants are stopped when idle and started again on demand.

The tenant binary must be **statically linked** — the control plane pushes it into a
container whose C library is not the host's:

```sh
cargo build --release --target x86_64-unknown-linux-musl
```

## Quickstart

Needs `git` ≥ 2.41 on the host.

```sh
cargo build --release
export ZUKA_DATA_DIR=/tmp/zuka && mkdir -p "$ZUKA_DATA_DIR"

TOKEN=$(./target/release/zuka token alice --scopes admin)   # shown once
./target/release/zuka
# [boot] listening on http://127.0.0.1:8790
# [ssh]  listening on ssh://127.0.0.1:2222
```

**Create and push**

```sh
curl -X POST localhost:8790/v1/repos \
  -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"name":"site"}'

git clone "http://x-access-token:$TOKEN@127.0.0.1:8790/alice/site.git"
cd site && echo hello > README.md
git add . && git commit -m "first" && git push origin HEAD:refs/heads/main
```

The git username is ignored; the password is your token.

**Use SSH instead**

```sh
./target/release/zuka key alice ~/.ssh/id_ed25519.pub --title laptop
git clone ssh://git@127.0.0.1:2222/alice/site.git
```

`--read-only` makes a key clone-only; `--repos a,b` confines it.

**Read without cloning**

```sh
A="Authorization: Bearer $TOKEN"; R=localhost:8790/v1/repos/alice/site
curl -H "$A" $R/refs
curl -H "$A" "$R/tree/src?ref=refs/heads/main"
curl -H "$A" "$R/raw/README.md"
curl -H "$A" "$R/search?q=TODO"
curl -H "$A" "$R/blame/src/main.rs"
curl -H "$A" "$R/compare?base=<sha>&head=<sha>"
```

The ref is always a **query parameter**, never a path segment — `feature/login` and
`src/main.rs` both contain slashes, so `/raw/{ref}/{path}` has no unique parse.

**Turn on CI**

```sh
ZUKA_CI_ENABLED=1 ./target/release/zuka
```

```toml
# .zuka.toml, in the repository
[run]
steps = ["cargo fmt --check", "cargo test"]
timeout_secs = 600
branches = ["refs/heads/main"]   # optional; omit to run everywhere
```

Push, then `curl -H "$A" $R/runs` and `$R/runs/{id}/logs`.

---

## For agents — MCP

```
POST /mcp     JSON-RPC 2.0: initialize · ping · tools/list · tools/call
```

The intended session: **`repo_create` → `key_add` → plain `git clone`/`commit`/`push`
→ `run_list`**. There is no tool that writes to a repository, and `initialize` says
so, so a model doesn't go hunting for one.

A tool failure that's the caller's fault comes back as a tool result with
`isError: true`, not a JSON-RPC error — the model should see it and adapt, not have
the call look broken.

<details>
<summary><b>18 tools</b></summary>

**Administration** — the part git can't do
| Tool | |
|---|---|
| `repo_create` | Create a repository. Returns both clone URLs. |
| `repo_list` `repo_get` `repo_delete` | |
| `key_add` `key_list` `key_remove` | Register an SSH key so the agent can then use git. |

**Discovery** — read without cloning
| Tool | |
|---|---|
| `ref_list` | Branches, tags, notes with tip commits. |
| `tree_list` | List a directory. |
| `file_read` | Read a text file. |
| `search` | Fixed-string search across the tree. |
| `commit_log` `commit_get` | History; optionally path-filtered. |
| `compare` | Diff from the merge base. |
| `blame` | Who last changed each line. |

**CI**
| Tool | |
|---|---|
| `run_list` `run_get` `run_logs` | Did the build pass, and why not. |

</details>

---

## API

Full contract: [`openapi.json`](openapi.json), also served at `/openapi.json`.

| | |
|---|---|
| `GET /healthz` | Mode, version, disk headroom, CI state. `503` below the reserve. |
| `GET /openapi.json` | This service's OpenAPI 3.1 document. |
| `GET /v1/account` | Identity, scopes, storage usage and limits. |
| `GET POST /v1/repos` | List, create (optionally `import_url`). |
| `GET PATCH DELETE /v1/repos/{account}/{repo}` | Read, update settings, soft-delete. |
| `GET .../refs?type=` | Branches, tags, notes. |
| `GET .../tree/{path}?ref=` | Directory listing. |
| `GET .../raw/{path}?ref=` | File bytes. |
| `GET .../commits?ref=&path=&limit=` | Log. |
| `GET .../commits/{sha}` | One commit and its changed paths. |
| `GET .../compare?base=&head=` | Diff from the merge base. |
| `GET .../search?q=&path=&ref=` | Content search. |
| `GET .../blame/{path}?ref=` | Line attribution. |
| `GET POST .../runs` · `GET PATCH .../runs/{id}` · `GET .../runs/{id}/logs` | CI. |
| `GET POST /v1/keys` · `DELETE /v1/keys/{id}` | SSH keys. |
| `POST /mcp` | MCP JSON-RPC. |
| `/{account}/{repo}.git/*` | Git smart HTTP. Protocol v2. |
| `ssh://git@host/{account}/{repo}.git` | Git over SSH, plus `git archive --remote`. |

Errors on `/v1` and `/mcp` are RFC 9457 `application/problem+json` with a stable
`type` URI — discriminate on `type`, never on `detail`. The git paths answer in
`text/plain` instead, because git prints the body straight at the user.

Lists return `{items, truncated}`. There is no cursor, and none is claimed.

### CLI

```
zuka                                   serve
zuka setup --standalone|--isolated     install onto this host [--domain d] [--dry-run]
zuka jobs                              maintenance daemon (own service unit)
zuka gc | sweep | fsck [--repair]      one-shot maintenance
zuka token <account> [--scopes s] [--repos r] [--days n] [--no-expiry]
zuka key   <account> <path.pub> [--title t] [--read-only] [--repos r]
zuka version
```

Scopes: `repo:read`, `repo:write`, `admin`, `ci`. Tokens expire in 90 days unless you
pass `--days` or opt out with `--no-expiry`, which warns. Credentials added while the
server runs take effect without a restart.

`fsck` exits non-zero on an orphan so a timer notices. It reports a repository with no
metadata but never deletes one — repair only removes records that describe nothing.

---

## Configuration

Everything has a working default; nothing is required.

<details>
<summary><b>All settings</b></summary>

| Variable | Default | |
|---|---|---|
| `ZUKA_BIND` | `127.0.0.1:8790` | HTTP listener. |
| `ZUKA_SSH_BIND` | `127.0.0.1:2222` | SSH listener; `off` disables. |
| `ZUKA_DATA_DIR` | `/var/lib/zuka` | Everything on disk. |
| `ZUKA_PUBLIC_URL` / `_SSH` | derived | Advertised clone URLs. |
| `ZUKA_PROBLEM_BASE` | `https://zuka.dev/problems` | Point error `type` URIs at your own docs. |
| **Limits** | | |
| `ZUKA_MAX_REPOS_PER_ACCOUNT` | `100` | `0` disables. |
| `ZUKA_MAX_REPO_MB` / `_ACCOUNT_MB` | `2048` / `10240` | `0` disables. |
| `ZUKA_RATE_PER_MINUTE` | `600` | Per credential, on `/v1` and `/mcp`. `0` disables. |
| `ZUKA_MAX_PACK_MB` | `512` | Largest push. Also sets `receive.maxInputSize`. |
| `ZUKA_MAX_BLOB_MB` | `32` | Largest blob served inline. |
| `ZUKA_MAX_BODY_MB` | `10` | Largest JSON body. |
| `ZUKA_PAGE_LIMIT` / `_MAX_PAGE_LIMIT` | `50` / `200` | |
| `ZUKA_MAX_CONCURRENT_GIT` | `8` | Concurrent git subprocesses. |
| `ZUKA_DISK_RESERVE_MB` | `2048` | Below this, writes return `507`. |
| **CI** | | |
| `ZUKA_CI_ENABLED` | `0` | Off by default; see below. |
| `ZUKA_CI_TIMEOUT_SECS` | `600` | Ceiling. A spec may lower it, never raise it. |
| `ZUKA_CI_MAX_CONCURRENT` | `2` | |
| `ZUKA_CI_LOG_MB` | `8` | Output cap per run. |
| `ZUKA_CI_MEMORY_MB` | `2048` | `RLIMIT_AS`. `0` disables. |
| `ZUKA_CI_MAX_PROCESSES` | `0` | `RLIMIT_NPROC` — per-UID, so only meaningful with a runner uid. |
| `ZUKA_CI_KEEP_RUNS` | `50` | Runs retained per repository. |
| **Maintenance** | | |
| `ZUKA_GC_INTERVAL_SECS` | `21600` | |
| `ZUKA_GC_PRUNE_GRACE` | `2.weeks.ago` | Objects younger than this are never pruned. |
| `ZUKA_DELETED_RETENTION_DAYS` | `7` | |
| **Import** | | |
| `ZUKA_IMPORT_ENABLED` | `1` | |
| `ZUKA_IMPORT_TIMEOUT_SECS` | `300` | |
| `ZUKA_IMPORT_ALLOW_PRIVATE` | `0` | Permit private/loopback sources. Only on a trusted network. |
| **Multi-tenant** | | |
| `ZUKA_MODE` | `standalone` | `standalone` · `control` · `tenant` |
| `ZUKA_INCUS_IMAGE` | `images:debian/12` | Image for a new tenant. |
| `ZUKA_INCUS_NETWORK` | `incusbr0` | Network a tenant joins. |
| `ZUKA_TENANT_BINARY` | this binary | Pushed into each container; must be static. |
| `ZUKA_TENANT_PORT` | `8790` | Port a tenant listens on inside its container. |
| `ZUKA_CONTROL_PUBLIC_KEYS` | — | Tenant only. Comma-separated; more than one allows key rotation. |
| `ZUKA_PROXY_TIMEOUT_SECS` | `300` | Ceiling on one proxied request. |

</details>

`ZUKA_ME_URL` is parsed and then **refuses to boot** — hosted auth isn't
implemented, and accepting a security-relevant setting while ignoring it is worse
than not accepting it.

### Maintenance

Run `zuka jobs` as a second service unit — a long repack must not stall request
handling.

```ini
[Unit]
Description=zuka maintenance
After=zuka.service
[Service]
ExecStart=/usr/local/bin/zuka jobs
EnvironmentFile=/etc/zuka.env
User=zuka
Restart=always
[Install]
WantedBy=multi-user.target
```

It garbage-collects (git's own auto-gc is disabled on every repo, so a repack never
fires *inside* a push), sweeps deleted repositories and old runs, and fscks.

What makes GC safe against a live clone is the **grace window**, not a lock — a lock
couldn't span the two processes. `upload-pack` can reference an object between
resolving and streaming it, so nothing younger than `GC_PRUNE_GRACE` is pruned. A
test clones a 12 MB repo while GC runs and checks the result byte for byte.

Worth stating plainly: **a committed secret stays fetchable by sha until GC prunes
it.** Force-pushing past it moves the ref, not the object.

---

## Security

Each of these has a test that fails if it regresses. Several are here because an
earlier version got them wrong.

**Names can't escape the data directory.** Ref names go through `gix-validate`, never
a hand-rolled check — `../../config` is a command-execution primitive via
`core.pager` and `core.sshCommand`. Tree paths reject any `.git` component after
Unicode normalisation, case folding, HFS-ignorable-codepoint stripping and trailing
dot/space removal.

**Names are case-folded.** `Alice/Site` and `alice/site` are one resource. Comparing
the raw path segment once let a case-variant account reach another account's
repository on a case-insensitive filesystem while passing the ownership check.

**An unreadable repository is `404`, not `403`.** `403` enumerates private repos.
Confinement applies to destruction too: a token scoped to one repo can't delete
another.

**Rewriting is allowed; protection is opt-in.** `rebase` and `--amend` are ordinary
git and blocking them protects nobody when there's one agent and no reviewer — git's
reflog is the recovery path. Set `protected_refs` on the branch something deploys
from; it's then enforced server-side over HTTP *and* SSH, which matters because
`--force` bypasses the client check. One Rust function; the hook is a one-line `exec`
into the binary.

**SSH runs git and nothing else.** No shell, no pty, no subsystem. Three services:
`upload-pack`, `receive-pack`, `upload-archive`. The exec string is parsed, never
shelled, so `git-upload-pack '/a/b.git'; id` is a parse error. The account comes from
*which registered key matched*, never the SSH username. Only `GIT_PROTOCOL` crosses
into the child.

**Only three endpoints route under `.git`.** The dumb protocol isn't served. A
duplicated `service` parameter is refused — this server would take the first and the
CGI the last, so a duplicate would let the authorization decision and the running
service disagree.

**Import can't reach the local network.** The one place a caller-supplied URL is
fetched. Checked in two stages: shape (scheme, credentials) then *every address the
host resolves to* — checking the hostname alone is defeated by a name that resolves
to `169.254.169.254`.

**Credentials are hashed, scoped, expiring and `0600`.** Token comparison has no
early return. Public keys match on raw bytes; `authorized_keys` option prefixes like
`command=` are refused.

**Deletes are recoverable.** Soft-deleted to `tmp/deleted/`, swept on retention.

**Nothing buffers a pack.**

```
                          before      after
200 MB push, server RSS   ~360 MB      5 MB
4 concurrent clones       1508 MB      5 MB
```

The child is killed by **process group** on disconnect — `kill_on_drop` reaches only
the direct child, and `pack-objects` is a grandchild that otherwise kept running for
seconds after the client left.

### Multi-tenant isolation

In `--isolated`, each account gets its own Incus container, unprivileged and with
nesting off. The control plane holds no repositories: a push through it lands in the
account's container and nowhere on the host.

A tenant is told who is calling by an **Ed25519 assertion the control plane signs**,
not a shared secret. That matters because a tenant runs the account's own CI, which
is arbitrary code — anything symmetric in there would forge every other tenant. The
private key never leaves the control plane; tenants hold only the public half, which
forges nothing.

The assertion authenticates one request, not an identity: single-use nonce, 30-second
window, bound to the method and path. It is deliberately *not* bound to the body,
because a push is a multi-gigabyte pack the proxy streams — the nonce is what stops
replay.

Verified on a real host rather than asserted, in
[`infra/README.md`](../../infra/README.md): CI inside a tenant reports
`uname -n` = `zuka-alice`, and a second account gets `404` on the first's
repository and an empty repo list.

### CI is not a security boundary in standalone

Steps run as the service user with rlimits, a scrubbed environment, a process-group
kill on timeout and a bounded log — but **no container and no network namespace**.
Anyone who can push can run code as that user. CI is off by default and the boot log
says so.

A step allowlist was considered and rejected: `sh -c` defeats it in one character.
Enforcement is at the OS or it isn't enforcement. Multi-tenant isolation is M6 and
isn't built.

---

## Renaming it

The name lives in exactly one file. `src/brand.rs` derives the env prefix, data
directory, problem-type URIs, auth realm, git config namespace, CI filename and hook
shim from `CARGO_PKG_NAME`.

Renaming is one line in `Cargo.toml` plus the `openapi.json` title — verified by
actually doing it. A unit test walks `src/` and fails if the name appears as a
literal anywhere outside `brand.rs`; it has already caught a hardcoded auth realm.

---

## Development

```sh
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test                 # 204 unit tests
deno task test:live        # 59 live tests: spawns the binary, drives real git and ssh
```

The live suite is the real proof — unit tests cover units, but only a real clone and
push exercise the wire protocol. It runs in CI rather than self-skipping there.

```
src/
├── brand.rs       the one place the product name appears
├── config.rs      Config::from_env(); fails boot on a bad value
├── error.rs       Error -> RFC 9457 problem+json
├── core/          domain operations shared by every facade
├── git/
│   ├── exec.rs      the only place this service invokes git
│   ├── validate.rs  security controls: names, refs, paths
│   ├── store.rs     repositories, hooks, ref rules
│   ├── discover.rs  the read core, shared by REST and MCP
│   ├── transport.rs streaming CGI bridge
│   └── import.rs    SSRF-guarded cloning
├── http/          routing, limits, responses, auth, rate limiting
├── api/           REST facade
├── mcp/           MCP facade
├── ssh/           SSH server and exec-command parser
├── ci/            spec, run store, runner, executor
├── control/       accounts, provisioning, reconciler, streaming proxy
├── setup.rs       systemd units, Caddyfile, environment file
├── jobs/          gc, sweep, fsck
├── account/       identity, tokens, SSH keys
└── store/         metadata, quotas, change-aware file cache
```

Two rules worth knowing before changing anything:

- **git is the only writer.** No endpoint or tool modifies repository contents.
- **Facades translate; they don't decide.** Authorization lives on `Identity`, git
  rules in `git/`, shared operations in `core/`. `api/` and `mcp/` reshape payloads
  and nothing else — they had drifted copies of repo create and delete once, and
  those copies had already diverged in four ways.

More: [`SPEC.md`](SPEC.md) · [`ROADMAP.md`](ROADMAP.md) · [`AGENTS.md`](AGENTS.md)
