{% hero %}
# zuka

Git hosting your agent can actually drive.

One small Rust binary — bare repositories, SSH and HTTP git, a REST API, MCP,
and CI. No database, no Docker, no drama.

[See it running](https://zuka.worklyn.com) [Five-minute tour](#the-idea) [Quickstart](#quickstart)
{% /hero %}

{% callout type="note" title="Live, and building itself" %}
zuka runs at `https://zuka.worklyn.com` and is self-hosting: every push to its
own source triggers its own CI, which builds the binary that deploys the server
that ran the CI. It has already caught five real bugs in itself this way.
The gate: `cargo fmt --check` · `clippy -D warnings` · `cargo test` (301) ·
`deno task test:live` (69 tests against a real server, with real `git`).
{% /callout %}

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:

{% cards %}
{% card title="git does the git" %}
Code moves over the wire protocol, HTTP or SSH. Nothing proprietary.
{% /card %}
{% card title="the API does the rest" %}
Create a repo, register a key, read a file, check CI — over REST and MCP.
{% /card %}
{% /cards %}

**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.

---

## Your README is the website

Open a repository in a browser and you don't get a wall of filenames — you get
the README, rendered as a page, the way [Markdoc](https://markdoc.dev/) turns
Stripe's doc files into stripe.com/docs. The file listing is one tab away, not
the thing a visitor has to squint at first. This page you're reading *is* that
website.

The viewer is built for people who have never used a code host: the tabs say
**Overview · Files · History · Branches** instead of tree, log and refs; every
tab remembers which branch you were on; history reads like a chat feed with a
coloured initial for each author; statuses say *Added*, *Changed*, *Removed*.
A wrong link gets a sentence and a way home, not machine JSON.

Plain markdown already works. For readmes that want to be front pages, there's
a small dialect — a subset of Markdoc's syntax. A tag sits alone on its own
line; everything between tags is ordinary markdown:

```
{% hero %}
# Big friendly title
One-line pitch under it.

[Primary button](docs/start.md) [Second button](#below)
{% /hero %}

{% callout type="tip" title="Good to know" %}
Callouts come as note, tip, warn and danger.
{% /callout %}

{% cards %}
{% card title="Left" %}
Feature one.
{% /card %}
{% card title="Right" %}
Feature two.
{% /card %}
{% /cards %}

{% details summary="The long table nobody reads twice" %}
…hidden until tapped, no JavaScript involved…
{% /details %}
```

The rules, all of them:

- A tag line starts at column zero. Indented or fenced tags are shown, not run —
  which is how this README quotes them.
- Unknown tags vanish and their content stays, so a document written for a
  richer engine degrades to its words, never to tag soup.
- An unclosed tag is closed for you at the end. A typo can't eat the page.
- Relative links work: `[guide](docs/guide.md)` opens that file rendered as a
  page, `![shot](docs/shot.png)` serves the image, `[src](src/)` opens the
  listing. A folder of markdown becomes a small site with zero configuration.
- Headings get anchor ids, so `[jump](#the-idea)` works — this page uses them.
- Any `.md` file renders as a document; add `?plain=1` to see the raw text.

And because a README is written by anyone who can push — on a public
repository, a stranger — the whole surface stays paranoid: raw HTML is dropped,
link schemes pass an allow-list, every interpolated value is escaped, and the
page ships a CSP with **no script source at all**. The dialect adds wrappers,
not execution.

---

## 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.

{% callout type="warn" title="Static linking is not optional in --isolated" %}
The tenant binary is pushed into a container whose C library is not the host's:

```sh
cargo build --release --target x86_64-unknown-linux-musl
```
{% /callout %}

## 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","visibility":"public"}'

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. `visibility` defaults
to `private`; a public repository is clonable and browsable by anyone, writable
by no one anonymous. Open `http://127.0.0.1:8790/alice/site` and there's your
website.

**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>"
```

On `/v1` 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. The browser URLs accept the ambiguity for the sake of
readable links; the API does not.

**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="The 18 tools" %}

**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`, `visibility`). |
| `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`. |

The browser lives on the same origin, read-only, no credential required for
public repositories: `/{account}/{repo}` (the clone URL minus `.git`), plus
`/tree/{ref}/{path}`, `/blob/{ref}/{path}` (`?plain=1` for raw text),
`/raw/{ref}/{path}` (download), `/commits/{ref}` (`?from={sha}` pages older),
`/commit/{sha}` and `/refs`. `/` redirects to the repository named by
`ZUKA_HOME_REPO`.

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`, because git prints the body straight at the user. The
browser pages answer a missing page in HTML, because a person tapped that link.

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="All settings" %}

| 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_HOME_REPO` | — | `account/repo` featured at `/` in the browser. |
| `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_FILE_MB` | `2048` | `RLIMIT_FSIZE`. Independent of the memory limit. |
| `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_OPERATOR` | — | Account allowed to manage accounts. Unset ⇒ refused for all. |
| `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.

{% callout type="danger" title="Force-pushing does not delete" %}
A committed secret stays fetchable by sha until GC prunes it. Force-pushing
past it moves the ref, not the object.
{% /callout %}

---

## 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.

**Anonymous means read, and only through one door.** Exactly one function
grants anonymous access, it can only read, and a presented-but-rejected
credential stays rejected — an expired token never quietly degrades into an
anonymous visitor. The browser answers a private repository with the same page
as an absent one; the git wire answers both with a `401` challenge, so neither
surface can be used to enumerate names.

**The viewer renders hostile input inert.** Every value is escaped, markdown
drops raw HTML and rewrites link schemes against an allow-list, and pages carry
a CSP with no script source — so anything that survived the escaping would
still have nowhere to execute. Raw file bytes are served as an attachment in a
CSP sandbox, never rendered on the origin.

**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 what `--isolated` is for.

---

## 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, and it is the reason even the browser viewer's stylesheet URL is derived.

---

## Development

```sh
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test                 # 301 unit tests
deno task test:live        # 69 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, MCP and the browser
│   ├── transport.rs streaming CGI bridge
│   └── import.rs    SSRF-guarded cloning
├── http/          routing, limits, responses, auth, rate limiting
├── api/           REST facade
├── mcp/           MCP facade
├── web/           the browser viewer: escaping, the tag dialect, the pages
├── 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)
