https://github.com/jackchuka/git-customs
Client-side, cross-host pre-push gate that catches PII and secrets — using an engine you choose — before anything reaches a public remote.
https://github.com/jackchuka/git-customs
ai-tools audit git security
Last synced: 1 day ago
JSON representation
Client-side, cross-host pre-push gate that catches PII and secrets — using an engine you choose — before anything reaches a public remote.
- Host: GitHub
- URL: https://github.com/jackchuka/git-customs
- Owner: jackchuka
- License: mit
- Created: 2026-06-22T05:48:32.000Z (26 days ago)
- Default Branch: main
- Last Pushed: 2026-07-16T04:14:53.000Z (1 day ago)
- Last Synced: 2026-07-16T14:35:09.184Z (1 day ago)
- Topics: ai-tools, audit, git, security
- Language: Go
- Homepage:
- Size: 43.9 KB
- Stars: 1
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# git-customs
**A client-side, cross-host pre-push gate that catches PII and secrets — using
an engine you choose — before anything reaches a public remote.**
git-customs is a *router + pipe*, not a scanner: it decides **which repos** to
inspect (only public-bound pushes, on **any** host — GitHub, GitLab, Gitea,
self-hosted), builds the **diff**, and pipes it to a command **you configure**.
That command can be an LLM (`claude -p "…"`) — which catches free-form **PII**
that regex scanners miss — or a deterministic tool like `gitleaks`. git-customs
itself stays dumb and engine-agnostic.
Pushing to a private repo? It skips silently. Publishing a new public repo? It
scans the whole tree once. Everything in between just works.
## Why it exists (and what it is *not*)
It complements — does not replace — [GitHub push protection](https://docs.github.com/en/code-security/secret-scanning/introduction/about-push-protection).
Push protection is excellent and you should keep it on. But it is **server-side**
(your data already reached GitHub), **GitHub-only**, **secrets-only** (no PII),
and limited to known token types. git-customs covers the seams push protection
leaves:
- **Client-side / pre-upload** — a flagged diff never leaves your machine, not
even to a server.
- **Cross-host** — works on self-hosted GitLab/Gitea and anywhere else, where
push protection doesn't exist.
- **PII, not just secrets** — point it at an LLM and it catches names, emails,
addresses, and IDs that pattern matchers don't.
- **Bring your own engine** — claude, gitleaks, or any executable; not tied to
one vendor or service.
It is **not** org enforcement: like any local hook it is bypassable
(`--no-verify`) and isn't team-distributed. Treat it as a personal safety net,
with push protection as the server-side backstop.
## Install
Get the binary with Homebrew:
```sh
brew install jackchuka/tap/git-customs
```
Or with Go:
```sh
go install github.com/jackchuka/git-customs@latest
```
Then install the hook:
```sh
git customs install
```
`install` writes a `pre-push` hook to `~/.config/git-customs/hooks/` and points
your **global** `git config core.hooksPath` at it, so it runs in every repo on
the machine. A starter `~/.config/git-customs/config.toml` is created if absent.
Because the binary is named `git-customs` and sits on your `PATH`, git exposes
it as a native subcommand: `git customs install`, `git customs doctor`, etc.
## How a push flows
1. `pre-push` fires → git-customs reads the outgoing ref updates.
2. It resolves whether this repo should be inspected (see mapping below).
Skipped → the push proceeds untouched.
3. If inspected, it builds the patch of every **outgoing commit** (`git log -p`
over the pushed range, or the branch's full history on a first push) and
pipes it to your command on **stdin**. Scanning per-commit — not just the
endpoint tree diff — catches a secret that was added in one commit and
removed in a later one but still ships in the pushed history.
4. **Allow only if** the command exits `0` **and** its output is the all-clear
(empty, or your `clear_pattern`). Otherwise the push is **blocked** and the
command's output is shown.
This is fail-closed: a missing command, a non-zero exit, or a timeout blocks
the push.
When it inspects, git-customs prints its progress to stderr so you can see it
working (and know which check a slow command is stuck on):
```text
git-customs: inspecting push to github.com/acme/oss (2 checks)
git-customs: → gitleaks
git-customs: → claude
git-customs: ✓ clean — 2 checks passed
```
A skipped push (private repo, branch deletion) stays silent; `CUSTOMS_BYPASS=1`
prints a one-line `bypassed` notice so a skipped gate is never a surprise.
## Configuration — `~/.config/git-customs/config.toml`
Each command receives the diff on stdin. ANY command works — it is not tied to
AI. Commands are parsed with shell-style quoting, so a quoted argument may
contain spaces. The push is allowed only if **every** command exits 0 AND its
output is "clear" (empty, or exactly `clear_pattern`); the first that fails
blocks the push.
```toml
# Combine engines with `commands` — they run in order, all must pass:
commands = [
"gitleaks stdin --no-banner", # deterministic secrets
'claude -p "List PII (emails, phone numbers, IDs, addresses, names) in added lines of the diff on stdin. One per line. If none, output exactly: OK"',
]
timeout = "120s" # per command; exceeding it blocks (fail-closed)
clear_pattern = "OK" # empty output OR this exact string = clean
[repos]
public = true # run on public repos
private = false # skip private repos
# explicit per-repo overrides always win over the public/private alias:
"github.com/jackchuka/blog" = false # public, but never inspect
"github.com/acme/secret-oss" = true # private, but always inspect
[visibility]
skip_hosts = ["github.example-corp.com"] # always treated as private
skip_owners = ["my-private-org"]
```
The single-command shorthand still works when you only need one engine:
```toml
command = 'gitleaks stdin --no-banner' # used only when `commands` is empty
```
### Choosing an engine — reliability matters
git-customs only routes the diff; the engine decides what's a problem. **Use a
deterministic scanner (gitleaks) for the secret gate.** An LLM is *not* a
reliable hard gate: it is nondeterministic (the same diff can pass on one run
and flag on the next) and prompt-injectable (text in the diff can steer its
verdict). Reserve any AI command for **PII**, where no good deterministic tool
exists, and treat it as best-effort rather than a guarantee.
### How visibility is decided
Only when no explicit repo entry matches: git-customs asks `gh` for the true
visibility of GitHub remotes, and falls back to a host heuristic
(github.com / gitlab.com / bitbucket.org / codeberg.org are public) when `gh`
isn't available. Anything it can't confidently classify is treated as private
and skipped.
## Overriding a block
```sh
CUSTOMS_BYPASS=1 git push # skip inspection for this push
git push --no-verify # skip all hooks
```
## Coexisting with other hooks
A global `core.hooksPath` makes git read **every** hook type from one directory
and stop looking in `.git/hooks` — which would silently disable a repo's other
hooks. git-customs avoids that: it installs a **shim for every standard hook**.
- **`pre-push`** → runs the customs gate, then chains to the hook git-customs
shadowed (so an existing pre-push still runs).
- **Every other hook** (`pre-commit`, `commit-msg`, `post-checkout`, …) →
transparently delegates to the shadowed hook in shell, without even invoking
git-customs.
The shadowed hook is the one git would otherwise have run: the repo's own
`core.hooksPath` if it had set one (recorded at install time), else
`.git/hooks/`. So **lefthook and husky keep working** — lefthook writes
into `.git/hooks/`, and a `--here` install over husky's `.husky/` chains
through to it. Your pre-commit lint/format hooks fire exactly as before; only
`pre-push` gains the public-gate.
Caveat: a tool that sets its own **repo-local** `core.hooksPath` (husky does)
overrides the global one, so git-customs won't run in that repo. Run
`git customs doctor` to detect it, and `git customs install --here` to install
git-customs for that single repo as well — it records the prior `.husky/` path
and chains every hook through to it.
## Commands
| Command | Purpose |
|---|---|
| `git customs install [--here]` | Install the hook globally (or for the current repo with `--here`) |
| `git customs uninstall` | Remove the hook and restore prior `core.hooksPath` |
| `git customs doctor` | Check the command is on `PATH`, the hook is active, and warn about repo-local bypass |
## License
MIT