Git Warden puts guard posts between AI agents and their Git remote: a Push Guard that checks every push with deterministic rules, and a Backup Guard that keeps an append-only backup of every branch and tag. The remote can be GitHub, GitLab, Gitea/Forgejo or a bare repo over SSH. Neither guard uses AI: every decision is a deterministic rule.
- Push Guard (
push-guard): agents push to it instead of the remote, it checks every push while the push is running, and only it holds a write credential for the remote. A clean push is forwarded with exactly the checked SHAs; ayellowfinding goes back to the agent with a message it can act on; aredfinding is rejected without details and waits for a human. - Backup Guard (
backup-guard): on a timer on a separate backup host, it runs git-everref against each remote with a read-only credential. A force push, deletion or moved tag upstream becomes a new lineage or a tombstone in the backup; nothing that a run has seen is ever lost.
Both guard posts are built. The Push Guard (push-guard): all rules of the spec, the pre-receive hook, the HTTP server, the human commands and replay, covered by offline tests against real temporary Git repositories, plus a live test in CI against GitHub over HTTPS and an SSH test against a local sshd (see Limits). The Backup Guard (backup-guard): preflight, bridge and backup setup, branch selection and the git-everref run, covered by tests with a stand-in everref and end to end with the real git-everref against a local remote (force push, deletion, moved tag, unreachable remote, an HTTP remote that requires a token, first runs killed at every stage). Neither is in production use yet.
Website and handbook: pihme.github.io/git-warden. Documentation: SPEC.md is the one authoritative document (design of both guard posts, decisions, the risk register and what Git Warden does not cover). Configuration and operation, the same structure for both guards: docs/push-guard.md (Push Guard, with the rules; reasoning per rule in docs/push-guard-rules.md) and docs/backup-guard.md (Backup Guard); overview in Configuration and operation.
agent ── git push ──> push-guard (wall) ── checked SHAs only ──> remote
│ pre-receive hook: ls-remote, rules, gitleaks
├ push.jsonl (every push, approval, reset)
└ pending/<repo>/<id>.bundle (red pushes)
- The agent pushes to
<push guard>/<name>.git. The guard's bare repository runspush-guard pre-receiveas its hook. - The hook reads the remote's refs fresh (
git ls-remote; the agent's idea of the old state is not trusted), fetches remote objects it lacks into the push's quarantine, and computes per ref the kind of change (create,delete,ff,non_ff,tag_move), the cumulative diff and the new commits. - All rules run; the verdict is the worst color found.
- green:
git push --porcelain --atomic <remote> <sha>:<ref> …with the guard's credential, never--force. Then the agent's push is accepted. If the remote rejects, the agent's push is rejected with the remote's message. - yellow: rejected with one line per finding (rule, ref, path, line, why). No human is involved; the agent fixes its change or leaves the part out.
- red: rejected with only
rejected: waiting for a human (push <id>). The new commits are kept as a bundle andnotify.commandwarns the owner. - internal error (scanner missing, remote unreachable, object missing, timeout): rejected with
internal error, try again later, logged, counted as red, but not warned as a violation.
- green:
timer ──> backup-guard run (backup host) ── read-only ──> remote
│ preflight: git, git-everref (version), config
├ repos/<name>/bridge ls-remote, git-everref add, git-everref run --all
├ repos/<name>/backup.git append-only: lineages, tombstones, journals
└ backup.jsonl one line per repo and run
- Preflight: the configuration loads,
gitandgit-everrefare found andgit-everref --versionanswers (and matches the pinned version if one is configured), at least one repo is configured. Otherwise the run fails closed before anything is touched. - Per repo it lists the remote's branches, protects new ones with
git-everref add origin/<branch> --remote backup(minusexclude_branches), and runsgit-everref run --all, which fetches the remote and records every protected branch and all tags. - A failed repo (remote unreachable, a branch everref refuses, a non-zero everref exit, a timeout) is logged in
backup.jsonland warned throughnotify.command; the other repos still run.
Git Warden calls a few external programs. It doesn't install or download any of them at runtime: they must already be on the host, found on PATH or at the configured path, and each runs as its own process.
| Program | Version | License | Needed for | If it is missing |
|---|---|---|---|---|
| Git | 2.42 or newer | GPL-2.0 | Both guards at runtime: every Git operation, and git http-backend for push-guard serve |
Neither guard starts: both check it in their preflight |
| gitleaks | 8.x (CI pins 8.30.1) | MIT | Push Guard at runtime: the secret scan (CONTENT-SECRET). Must be on PATH, or at gitleaks.path |
Required if CONTENT-SECRET is enabled, which is the default: serve and init-repo refuse to start, every push fails closed (internal error), and check-config reports it. Optional only where CONTENT-SECRET is switched off on purpose. Optional for the tests: the secret-scan tests are skipped |
| git-everref | v1.0.0 (pinned) | MIT | Backup Guard at runtime: records branches and tags in the backup. Must be on PATH, or at the path set as everref.path |
Required for the Backup Guard: every run fails closed in its preflight, and check-config reports it. Optional for the tests: the end-to-end tests are skipped |
| Go | 1.24 or newer | BSD-3-Clause | Building from source only | Use the release binaries |
Pinning is part of provisioning the host, not something Git Warden does at runtime. Install gitleaks and git-everref at a fixed version and check the download's SHA-256 when you set up the host, as CI does. For git-everref there is an optional script that does exactly that: scripts/install-everref.sh installs v1.0.0 for Linux amd64/arm64 and checks the tarball against SHA-256 values pinned in the script (details). The container image and the Nix flake bring git, gitleaks 8.30.1 and git-everref v1.0.0 along, pinned the same way. Licenses of these programs and of the code compiled into the binaries: THIRD_PARTY_NOTICES.md.
go build -o push-guard ./cmd/push-guard
go build -o backup-guard ./cmd/backup-guard
scripts/install-everref.sh # optional: pinned git-everref v1.0.0 into ~/.local/bin (SHA-256 checked)
go test ./... # offline; secret-scan tests skip without gitleaks, everref end-to-end tests without git-everrefCI installs pinned gitleaks and git-everref and sets GITLEAKS_REQUIRED=1 and EVERREF_REQUIRED=1, which turn those skips into failures.
The live test (TestLiveRemote) runs only when WARDEN_LIVE_REMOTE (an https:// URL) and WARDEN_LIVE_TOKEN_FILE are set. It pushes through a real guard to a fresh testrun-<UTC timestamp>-<run> branch on that remote: green create and fast-forward, red rewrite that leaves the remote alone, approval of that SHA going out with a lease, another writer moving the branch, and finally an allowed delete. CI runs it against this repository on pushes to main with the job's own token, and deletes leftover testrun-* branches older than a day. TestSSHRemote starts its own sshd (skipped if sshd or ssh-keygen is missing; CI installs it and sets SSH_REQUIRED=1).
Releases attach static push-guard-linux-amd64 and backup-guard-linux-amd64 binaries, install-everref.sh, LICENSE and THIRD_PARTY_NOTICES.md. Install a binary with install -m 0755 push-guard-linux-amd64 /usr/local/bin/push-guard (the same for backup-guard).
The Dockerfile builds one image with both guards and their prerequisites, provisioned at pinned versions: static push-guard and backup-guard (built with Go 1.27), Debian trixie's git 2.47 (the build fails below 2.42) with openssh-client, gitleaks 8.30.1 (the CI release, SHA-256 checked) and git-everref v1.0.0 (via scripts/install-everref.sh, SHA-256 checked). Base images are pinned by digest. It runs as the unprivileged user warden (uid 10001) and contains no configuration and no secrets. The image isn't published; build it yourself:
docker build -t git-warden --build-arg VERSION=$(git describe --tags --always) .Push Guard (the default command is push-guard serve --config /etc/warden --listen 0.0.0.0:8418):
docker run -d --name push-guard -p 8418:8418 \
-v /etc/warden:/etc/warden:ro \
-v warden-state:/var/lib/warden \
git-warden
docker run --rm -v /etc/warden:/etc/warden:ro git-warden push-guard check-config --config /etc/wardenBackup Guard, from a timer on the backup host (for example the systemd units with this as ExecStart):
docker run --rm \
-v /etc/warden-backup:/etc/warden-backup:ro \
-v warden-backup-state:/var/lib/warden-backup \
git-warden backup-guard run --config /etc/warden-backupIn the container:
- Set
state_dir: /var/lib/warden(Push Guard) or/var/lib/warden-backup(Backup Guard) indefaults.yamland keep it on a volume; the examples already do. The configuration mount stays read-only. - Every file the configuration points to (
agent.token_file,credential,known_hosts,gitleaks.toml) must be inside the mounted directory and readable by uid 10001. Give SSH remotes aknown_hostsfile there, since the container user has none of its own. - gitleaks is at
/usr/local/bin/gitleaksand git-everref at/usr/local/bin/git-everref, both onPATH, sogitleaks.pathandeverref.pathcan stay unset (or keep the examples' values). notify.commandruns inside the container: mount it in as well (the image hassh, but no mail or HTTP client besidesgit).- The guard repositories in the state volume have hooks that call
/usr/local/bin/push-guard, so keep using this image with that volume.
The same preflight runs at start as on a host: without a configuration, repos, an enabled rule or gitleaks, serve exits with the reason instead of starting.
Two kinds of flake. Both need flakes enabled (experimental-features = nix-command flakes).
The reference environment for build and test lives under nix/. nix/reference-env is the Go toolchain. nix/git-tools adds git and OpenSSH. nix/scanners adds gitleaks 8.30.1 and git-everref v1.0.0 and includes the other two. CI runs the tests in that shell:
nix develop ./nix/scanners -c go test ./...flake.nix at the repo root is the package people install (nixpkgs pinned in flake.lock), on Linux (x86_64, aarch64). nix develop there is that package's shell, on nixpkgs 26.05. The shells under nix/ track nixpkgs unstable.
nix build # ./result/bin/push-guard and ./result/bin/backup-guard; runs go test ./... in the sandbox
nix develop # shell with Go, git, OpenSSH, gitleaks 8.30.1 and git-everref v1.0.0
nix flake checkgitleaks and git-everref come from their pinned release tarballs with the same SHA-256 as CI and the Dockerfile, not from nixpkgs, so every environment runs the same versions. Git Warden's license (PolyForm Noncommercial) counts as unfree in Nix; the flake allows exactly this package. When you change go.mod or go.sum, update vendorHash in flake.nix (Nix prints the new value).
Each guard has its own configuration directory on its own host, with the same layout and the same names for what they share: a defaults.yaml for the whole guard and one folder per remote under repos/<name>/ (warden.yaml for the Push Guard, backup.yaml for the Backup Guard). Unknown keys are errors in both. The full reference, with the same structure for both guards (prerequisites, configuration, running it, commands, state, looking and restoring, warnings):
- Push Guard: docs/push-guard.md, with the rules and the rule reasoning in docs/push-guard-rules.md. Examples: examples/warden.
- Backup Guard: docs/backup-guard.md. Examples: examples/backup.
| Key | File | Meaning |
|---|---|---|
remote |
repo file | The remote: ssh://… or user@host:path (SSH), https://… or http://… (HTTPS), or a local repository as an absolute path or file:// URL. |
credential |
repo file | A file path, never the value: an SSH private key for SSH remotes, a file holding a token for HTTPS remotes. Required for SSH and HTTPS, not allowed for a local remote. Push Guard: write access; Backup Guard: read access only (rights per platform). |
credential_username |
repo file | HTTPS only: the user name sent with the token. Default x-access-token. |
known_hosts |
repo file | SSH only: a known_hosts file for this remote instead of the guard user's own (the system-wide file is then ignored as well). Unknown or changed host keys are always refused. |
notify.command |
defaults.yaml |
argv list; gets a warning as JSON on stdin. |
state_dir |
defaults.yaml |
Where the guard keeps its repositories, its JSON-lines log and its locks. Default <config>/state. |
timeout |
defaults.yaml |
Push Guard: per push (default 60s); Backup Guard: per repo and run (default 30m). |
gitleaks.path / everref.path |
defaults.yaml |
The external program: a bare name is looked up on PATH (the default), a path is relative to the configuration directory. |
Relative file paths are relative to the configuration directory. The credential never appears in a URL, on a command line or in a log: SSH runs as ssh -F none -i <key> -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes; for HTTPS an inline credential helper reads the token file when git asks. The Backup Guard passes the same environment to git-everref, so everref's own fetches use the same key or token (tested against a local HTTP server that requires the token).
The Push Guard writes to the remote, the Backup Guard only reads. Give each exactly that, and never reuse the Push Guard's credential for the Backup Guard.
| Credential | Push Guard (warden.yaml): read and write |
Backup Guard (backup.yaml): read only |
|---|---|---|
| GitHub deploy key (SSH) | Deploy key with Allow write access | Deploy key without it (deploy keys are read-only by default) |
| GitHub fine-grained personal access token (HTTPS) | Repository permission Contents: Read and write; Metadata: Read-only is always included | Contents: Read-only; Metadata: Read-only |
GitHub App installation token (HTTPS, user name x-access-token) |
Repository permission Contents: Read and write | Contents: Read-only |
| GitLab deploy key (SSH) | Deploy key with Grant write permissions to this key | Deploy key without it (read-only) |
| GitLab project access token (HTTPS, any non-blank user name) | Scope write_repository, role Developer (pushes to unprotected branches) or Maintainer (protected branches) |
Scope read_repository, with a role that may read the code (e.g. Reporter) |
| GitLab deploy token (HTTPS) | Not possible: deploy tokens have no scope for pushing to a repository | Scope read_repository; set credential_username to the token's user name (default format gitlab+deploy-token-<n>) |
Notes:
- With a GitHub personal access token, GitHub's documentation uses the account's user name as the Git user name; set
credential_usernameto it. - Changes to
.github/workflowsadditionally need the Workflows permission on GitHub. Leaving it out of the Push Guard's token means GitHub itself refuses workflow changes, even if a rule missed one. - The remote's own protections (protected branches, rulesets) still apply to the guard's credential.
Sources: GitHub deploy keys, personal access tokens, GitHub App permissions for Git access; GitLab deploy keys, project access tokens, token scopes, deploy tokens, roles and permissions.
push-guard check-config --config /etc/warden [--remote]
push-guard serve --config /etc/warden --listen 0.0.0.0:8418 # agents push over HTTP
push-guard init-repo --config /etc/warden hermetarium # or over SSH / a local path
push-guard approve --config /etc/warden hermetarium refs/heads/main 3f2a9c1
push-guard reset-streak --config /etc/warden hermetarium
push-guard replay --config /etc/warden hermetarium --git-dir /path/to/clone --branch mainserve and init-repo start only after a preflight: the wall's defaults.yaml loads, git is 2.42 or newer, at least one repo is configured, every repo has a rule enabled, and gitleaks 8.x is found for every repo with CONTENT-SECRET enabled (the default). The hook repeats the per-repo part on every push and fails closed. approve and reset-streak are the human's answers to a red push or a yellow streak; replay runs the rules over a branch's history without forwarding anything, to tune them before going live. Agents use the guard as their only remote (git remote set-url origin http://hermit:$TOKEN@warden.internal:8418/hermetarium.git). Details: docs/push-guard.md.
backup-guard check-config --config /etc/warden-backup [--remote]
backup-guard run --config /etc/warden-backup [repo...] # from a timer, e.g. every 15 minutesrun starts only after a preflight: the configuration loads (every repo's backup.yaml too), git is 2.42 or newer, git-everref is found with the major version of everref.version (default 1.0.0), and at least one repo is configured; a failed preflight is logged in backup.jsonl and warned. A repo whose previous run is still going is skipped as failed rather than waited for. A run that is killed or times out is picked up cleanly by the next one. Example systemd units: examples/backup/systemd. The backup of a repo is state_dir/repos/<name>/backup.git; look at it and restore with git-everref's own commands in state_dir/repos/<name>/bridge. Restoring to the remote is a human's step. Details: docs/backup-guard.md.
- Tested live against GitHub over HTTPS, and over SSH against a local
sshdthe test starts itself (fresh host and user keys, wrong and unknown host key, unauthorized key). GitLab and Gitea are not tested yet. META-UNSIGNEDchecks only that a signature is present (gpgsigheader). The wall has no keyring, so it doesn't verify signatures; with fewer thanlookbackcommits of history the rule stays quiet.CONTENT-PAGES-SCRIPTlooks at one added line at a time, so a tag split across lines is missed, and a host counts as known if its name appears anywhere in the old tree.push.jsonlis read in full on every push; fine for now, it will need rotation or an index later.- One Push Guard per agent:
agent.nameis per installation, and rate limits count all pushes of that installation. - No registry checks for new dependencies (slopsquatting, R17) and no AI: the Push Guard is rules only, with no semantic code review (see what is not covered).
- The Backup Guard only sees the states present at its runs: a state that exists only between two runs, or history rewritten before the first run, is not in the backup. everref v1.0.0 runs one
ls-remoteper new branch and one push per recorded event, so very large, busy repos are too slow for it for now. LFS objects and submodule targets are not backed up.
git-warden is developed agent-first. AI coding agents write all code, tests, and documentation, and review each other's changes, under human direction: specs, design decisions, and acceptance based on observed behaviour and test results. No human reads the code line by line. This is a deliberate choice. Quality rests on automated tests, CI, and independent agent review.
Evaluate the code against your own requirements before you depend on it. Found a problem? Open an issue.
PolyForm Noncommercial 1.0.0. Source-available, not OSI Open Source. Commercial use is not granted. Other licenses can be negotiated with the copyright holder.
Third-party software compiled in or run by Git Warden, and its licenses: THIRD_PARTY_NOTICES.md.