disclosegate pre-push
the git hook: ref lines on stdin, as git sends them
- <remote> <url>
Status: 0.0.2, on npm — run it in
auditmode. The hook, the four rules, the config with its trust order,scan,install,init,doctorandcheckare written and covered by tests that run realgit pushcommands against a bare remote. What is still missing is evidence from real work: the false-positive rate on real pushes is unmeasured, so set"mode": "audit"— findings are printed, the push goes through — until 0.1.0. v0.1.0 waits for a week of the guard inauditmode on the maintainer's own repositories, every finding classified true or false and the result recorded here, dated (DG-14 in BACKLOG.md).
gitleaks, git-secrets and trufflehog find secrets: keys, tokens, passwords — strings with a shape and an entropy, the same for everyone. Use one of them; disclosegate does not try to.
What they do not find is internal detail, because it has no shape and differs per
person: your work address as the author of a commit to a personal project, a
Co-authored-by: trailer naming a colleague's corporate address, the name of a private
service or a client in a commit message, a home-directory path pasted into a README. None
of these is a credential, and every one of them is something a maintainer's own rule
forbids in public. disclosegate checks that rule — from a list only you hold — at the
last moment it can still be applied.
npm install -g disclosegate
cd your-repository
disclosegate init # ~/.disclosegate.json, placeholders only
disclosegate install # .git/hooks/pre-push
disclosegate doctor
Then set "mode": "audit" in ~/.disclosegate.json until 0.1.0 (see the status above).
disclosegate install once in each repository, or once into a global core.hooksPath.
Install it globally, not through npx: the hook remembers the script that installed
it, and npm prunes its npx cache, after which the hook falls back to a disclosegate
on PATH and, finding none, refuses the push rather than silently stop guarding.
Node 18 or later. No runtime dependency.
disclosegate install writes .git/hooks/pre-push — or into core.hooksPath when that
is set, so a global core.hooksPath covers every repository at once. The file carries a
marker comment; install never overwrites a hook without it unless given --force, which
moves the other hook to pre-push.before-disclosegate (it then no longer runs), and
uninstall removes only its own hook and puts the moved one back.
git runs the hook with the remote's name and URL, and one line per ref on stdin:
| The push | What is read |
|---|---|
| deletes a branch | nothing — a deletion publishes nothing |
| creates a branch | every commit no ref of that remote already has: git rev-list <local> --not --remotes=<remote> |
| updates a branch | <remote-sha>..<local-sha>; if this repository lacks the remote tip, the new-branch rule |
A finding refuses the whole push, and nothing reaches the remote:
disclosegate: 2 findings in 1 of 3 commits — push refused
email 4e1f0a2 author bo… (20 chars) not in publicEmails
term 4e1f0a2 deploy.txt:2 ni… (13 chars) term
Nothing has left this machine. Rewrite the commits, then push again:
...
git push --no-verify skips the hook, as it skips every pre-push hook — knowingly, once.
Two files, in a trust order.
The user file, ~/.disclosegate.json (or the path in DISCLOSEGATE_CONFIG), holds
the private lists. It must never live in a repository — what it lists is exactly
what must not be published — and disclosegate refuses to run with a user file inside the
repository it is checking. disclosegate init writes a template of placeholders with
comments, and never overwrites an existing file:
{
"publicEmails": ["you@personal.example", "*@users.noreply.example"],
"blockedDomains": ["work.example"],
"terms": ["internal-host.example", "/client-[a-z]+\\.example/i"],
"blockedNames": [],
"allowPaths": [],
"mode": "block",
"remotes": { "enforce": ["github.com/*"], "skip": [] }
}
The repository file, .disclosegate.json at the top of a repository, can be written
by anyone with commit access, so it may only tighten: add terms and
blockedDomains. It may also set allowPaths, globs such as test/fixtures/** for files
that have to quote a path. allowPaths exempts files from the path rule only — never from the email or term rules.
Every other key in it — publicEmails, mode, remotes — is ignored, a warning says
so on every run, and doctor lists it. The file's own lines are exempt from the terms
it adds — or the commit that adds it would be refused by it — but not from a term in the
user file or from any other rule; a term written there is public, so it fits a name that
is already out, never a private one.
mode is block (the default: a finding refuses the push) or audit (findings are
printed and the push goes through). remotes.enforce and remotes.skip are globs, *
the wildcard, matched against the remote URL normalised to host/path — so
github.com/* covers the SSH and HTTPS forms alike; skip wins, and no enforce list
means every remote. A missing user file is not an error: the path rule needs no list and
still runs, and doctor says what is missing.
| Rule | Reads | A finding when |
|---|---|---|
email |
author, committer, every trailer address (Co-authored-by:, Signed-off-by:, any Token: … <address>) |
the address is not in publicEmails (off while that list is empty); or its domain, or a parent of it, is in blockedDomains — whatever publicEmails says |
term |
commit messages, added lines, the names of files with added lines | an entry of terms matches: a plain string case-insensitively, /source/flags as a regular expression |
path |
commit messages, added lines | /Users/<name>/, /home/<name>/, C:\Users\<name>\ or a file:/// URL naming a path. Built in, always on; a URL such as https://example.com/home/about/ is not a home |
name |
author, committer and trailer names | the name is in blockedNames |
Findings are listed worst first: a blocked domain, an address outside the allowlist, a term, a name, a path. A file whose name carries a term is never printed by name; its lines are shown under a masked one.
Every finding has the commit's short sha, where it is (author, committer,
trailer Co-authored-by, message, file:line, file name), the rule, and the match
masked — its first two characters, an ellipsis and its length. The output of a hook
lands in terminals, CI logs and pasted issues, and a guard that printed what it found
would publish it itself. --show prints matches in full, and only when stdout is a
terminal; elsewhere it is ignored with a note. Paths under your home directory are shown
with ~.
--json gives the same findings for machines, masked by the same rule. Exit codes: 0
clean, or any result in audit mode; 1 findings in block mode; 2 a usage or
configuration error — which, in the hook, also refuses the push.
disclosegate scan # what a push would send now: @{upstream}..HEAD, or what no remote has
disclosegate scan --range main..HEAD
disclosegate scan --staged # the index, and the identity the next commit would carry
disclosegate scan --history # every commit reachable from any ref — the audit of a repository
disclosegate install [--force] | uninstall
disclosegate init # the template user file; refuses to overwrite
disclosegate doctor # config found, rules active, hook installed, remotes enforced
git, reads two files, and prints.doctor, which
shows counts and key names.Straight from the usage comment in bin/disclosegate.mjs — what disclosegate help prints.
disclosegate pre-pushthe git hook: ref lines on stdin, as git sends them
disclosegate scanthe same rules, by hand
disclosegate installwrite the pre-push hook, never over another tool's
disclosegate uninstallremove the hook, only if disclosegate wrote it
disclosegate initwrite a template user config of placeholders
disclosegate doctorconfig, active rules, hook, remote enforcement
disclosegate checkthis repository's own invariants (npm test)