SafeGit makes protected Git changes require Safe multisig approval.
It creates an EIP-712 GitCommitApproval payload for a commit, collects Safe owner signatures, stores approval state in Postgres, and lets CI enforce a Safe Verified required check before merge.
SafeGit does not try to fake GitHub's native
Verifiedbadge. GitHub's badge supports GPG, SSH, and S/MIME signatures; Safe smart accounts validate signatures through Safe owner signatures / ERC-1271 patterns. SafeGit uses a GitHub required-check flow instead.
- Git wrapper CLI:
safegit add,commit,status,push, and other Git commands pass through togit - SafeGit overlay commands:
safegit setup,sign,approval status,verify, plus legacyrequestandattest - HTTP API for approval retrieval and signature submission
- Built-in browser approval page at
/approve/:approvalId - Postgres shared state for repos, approval requests, and signatures
- EIP-712 typed-data payload generation
- Signature recovery with
viem.recoverTypedDataAddress - Optional live Safe
getOwners()/getThreshold()validation via RPC - Docker Compose for local API + Postgres
- GitHub Action template for a
Safe Verifiedrequired check - pnpm-only package management
Developer runs safegit as a git wrapper
-> safegit add / commit delegate to git
-> safegit sign creates a commit approval request
-> Postgres shared approval state
-> SafeGit API + approval page
-> Safe owners sign EIP-712 typed data
-> safegit push verifies approval, then delegates to git push
-> GitHub Action can also run safegit verify
- Node.js 22+
- pnpm 11+
- Git
- Postgres 17+ or Docker Compose
- A Safe address and threshold
- Optional but recommended: RPC URL for the Safe's chain
SafeGit is pnpm-only.
Run these commands from the SafeGit CLI package directory, the directory that contains package.json and bin/safegit.js.
pnpm installCheck the local entrypoints before linking globally:
pnpm safegit -- --help
pnpm run safegit:server -- --helpLink the CLI globally when you want to run safegit init from arbitrary target repos:
pnpm link:global
hash -rCheck the global shims:
safegit --help
safegit-server --helpIf safegit migrate or safegit init fails with Cannot find module '.../bin/safegit.js', the global shim was linked from the wrong directory or an older checkout. Re-run pnpm link:global from the CLI package directory and clear the shell command cache with hash -r. On pnpm 11+, the underlying command is pnpm link --global .; the trailing . is required.
Without a global link, use the bin path directly from any target repo:
node /absolute/path/to/safegit/bin/safegit.js init --safe 0xYourSafeAddress --chain-id 11155111 --threshold 2docker compose up --buildThe API starts at:
http://127.0.0.1:8787
Postgres is published on the host at 127.0.0.1:15432 by default so it does not collide with an existing developer-machine Postgres on 5432. Override the host port with SAFEGIT_POSTGRES_HOST_PORT before running Docker Compose.
The API server runs migrations automatically on startup. Use safegit migrate directly for manual setup, hosted Postgres, CI/CD migrations, or future schema upgrades.
For CLI commands in another shell:
safegit env
safegit doctorThis creates .env in the current directory with the Docker Compose database URL, postgres://safegit:safegit_dev_password@127.0.0.1:15432/safegit, if SAFEGIT_DATABASE_URL is missing. safegit doctor validates the URL, DNS, and TCP connectivity. Use safegit env --database-url 'postgres://USER:PASSWORD@HOST:5432/DB' for a hosted or manually managed database. If an existing .env contains a stale or malformed value, use safegit env --force.
Optional live Safe validation:
export SAFEGIT_RPC_URL='https://ethereum-sepolia-rpc.publicnode.com'When SAFEGIT_RPC_URL is set on the API server, submitted signatures are checked against the live Safe owner set and threshold.
If you are using Docker Compose, the API does this automatically. If you are using a manually managed database, run:
safegit env --database-url 'postgres://USER:PASSWORD@HOST:5432/DB'
safegit doctor
safegit migratecd /path/to/your/repo
safegit env
safegit setup \
--safe 0xYourSafeAddress \
--chain-id 11155111 \
--threshold 2This writes .safegit.yml and stores the repo → Safe mapping in Postgres.
safegit init --safe ... remains as a legacy alias for setup. Plain safegit init delegates to git init.
safegit add path/to/file
safegit commit -m "describe the protected change"safegit add, safegit commit, safegit status, and other Git-shaped commands pass their original arguments to git. SafeGit does not reimplement Git's index, pathspec, hooks, editor, or transport behavior.
safegit signThis creates a GitCommitApproval EIP-712 payload for HEAD containing:
- repo host, owner, and name
- branch
- commit SHA
- tree SHA and parent SHAs
- author and committer
- Safe address
- chain ID
- approval ID
- creation and expiry timestamps
The command prints an approvalId and approvalUrl like:
{
"approvalId": "appr_5f4074bfbf53",
"approvalUrl": "http://127.0.0.1:8787/approve/appr_5f4074bfbf53"
}
The legacy safegit request --ref HEAD command is still available, but safegit sign is the normal wrapper flow.
http://127.0.0.1:8787/approve/<approvalId>
The page:
- loads the backend-provided EIP-712 payload
- connects an injected wallet
- calls
eth_signTypedData_v4 - posts
{ signer, signature }to the SafeGit API - marks the approval
approvedafter the configured threshold is reached
safegit approval status --ref HEADsafegit status delegates to git status.
Before enough signatures:
{
"status": "pending"
}After threshold:
{
"status": "approved"
}safegit pushsafegit push first checks that HEAD has an approved SafeGit approval. If approval is missing or pending, it exits before running git push. Once approved, it delegates to git push with the original arguments.
safegit verify --ref HEADverify exits non-zero unless the commit is approved. Use that as a GitHub required check.
If you collect a signature elsewhere, submit it manually:
safegit attest \
--approval-id appr_xxxxxx \
--signer 0xSignerAddress \
--signature 0xSignatureBytesOr through the API:
curl -X POST http://127.0.0.1:8787/api/approvals/appr_xxxxxx/signatures \
-H 'content-type: application/json' \
-d '{
"signer": "0xSignerAddress",
"signature": "0xSignatureBytes"
}'GET /healthz
GET /approve/:approvalId
GET /api/approvals/:approvalId
POST /api/approvals/:approvalId/signatures
SafeGit's approval page is intentionally small, but a public deployment should still sit behind basic controls.
Set SAFEGIT_API_TOKEN to require Authorization: Bearer <token> on all /api/* routes:
export SAFEGIT_API_TOKEN='replace-with-a-long-random-token'The browser approval page still renders at /approve/:approvalId. If API auth is enabled, signers can either paste the token into the page once or open:
https://safegit.example.com/approve/<approvalId>#token=<token>
Do not put this token in GitHub Actions logs, public issues, or screenshots.
By default, /api/* routes are limited to 60 requests per minute per IP. Tune with:
export SAFEGIT_RATE_LIMIT_WINDOW_MS=60000
export SAFEGIT_RATE_LIMIT_MAX=60Lock CORS to your deployed origin instead of *:
export SAFEGIT_CORS_ORIGIN='https://safegit.example.com'For production, terminate HTTPS at a reverse proxy such as Caddy, Nginx, Cloudflare Tunnel, Fly, Render, or a platform load balancer. Keep Postgres private, rotate SAFEGIT_API_TOKEN, and enable platform-level request logging/rate limits too. If the service is behind one trusted proxy, set:
export SAFEGIT_TRUST_PROXY=trueThis makes per-IP rate limiting use the forwarded client IP instead of the proxy's IP.
Set SAFEGIT_RPC_URL so submitted signatures are checked against live getOwners() / getThreshold() before they count:
export SAFEGIT_RPC_URL='https://ethereum-sepolia-rpc.publicnode.com'For rigorous historical validation, use an archival RPC and verify owner/threshold state at the relevant chain block for the protected change. The MVP validates current Safe ownership; that is enough for demos and most active approvals, not for backdated forensic guarantees.
The MVP uses a GitHub Action required check because it is simple and works today. A future GitHub App should add webhook-triggered check-runs, centralized repo configuration, audit pages, and secretless installation UX.
The repo includes:
action.yml
templates/github-action.yml
Example workflow:
name: Safe Verified
on: [pull_request]
jobs:
safe-verified:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: agent-cortex/safegit@main
with:
database-url: ${{ secrets.SAFEGIT_DATABASE_URL }}
ref: HEADThen require the Safe Verified check in GitHub branch protection.
safegit_repos— repo slug, host, owner, repo name, Safe address, chain ID, thresholdsafegit_approval_requests— commit SHA, branch, EIP-712 payload, message hash, status, expirysafegit_signatures— signer address, signature, timestamp
pnpm install --frozen-lockfile
pnpm test
pnpm pack --dry-runA GitHub App is not required to test SafeGit. The current MVP uses a GitHub Action required check.
A future GitHub App would improve production UX by creating check-runs directly from push/PR webhooks, centralizing repo configuration, and avoiding manual workflow setup.
- Do not commit private keys, RPC secrets, database passwords, or
.envfiles. - Use a real shared Postgres database for team usage; local DBs are only for demos.
- For production, run the API behind auth/rate limits.
- Enable
SAFEGIT_RPC_URLif you want live Safe owner/threshold validation. - For rigorous historical validation, verify the Safe owner set at the relevant block.
MIT