Publishing private tooling without leaking your life
The tools I would most like to open source are the ones I built for myself. They are also the ones soaked in personal detail: real file paths with my username, a server IP in a comment, my email in an example config, the actual names of the agents I talk to every day, a demo fixture built from my own data because that was the data at hand.
The obvious approach is to copy the folder, read through it, and fix what you find. I do not do that anymore, for a reason that has nothing to do with how careful I am.
#Deleting does not save you
Three facts, all from official documentation:
- After a history rewrite and force push, commits with sensitive data “may still be accessible ... directly via their SHA-1 hashes in cached views” (GitHub Docs).
- “Commits pushed to any repository in a network can be accessible from other repositories in that network, including the upstream repository”, and commits “can remain accessible in the repository network even after a fork is deleted” (GitHub Docs).
git filter-branch, the classic scrub-it-afterwards tool, carries an official warning that “its use is not recommended” (git documentation).
Truffle Security documented that this behavior follows from GitHub’s own documented fork architecture rather than from a bug. So the cost of a leak is not “notice it and remove it”. It is a support ticket, credential rotation, and a permanent uncertainty about who fetched what in the meantime. Which means the only strategy that pays is making sure the dirty commit never exists in the repository you push.
That reframes the whole job. The question stops being “did I find everything” and becomes “did anything private ever get into this history in the first place”.
#Clean room instead of sanitize patching
So the public repo starts empty: a new repository, git init, into which files are copied deliberately, one decision per file, with no .git directory coming along.
The difference is not cosmetic. Patching a copy means the history still holds every intermediate state, including the version before you noticed the hardcoded server address. A rewrite afterwards runs into the three facts above. Starting fresh means the history has exactly the commits you made after the content was already clean.
It also changes what you notice. Copying file by file forces a decision on every one of them, and the files that turn out to be unpublishable get caught by their own existence rather than by a grep. In my case that was the entire class of “example data”: fixtures, demo screenshots, sample outputs. They read as boring test material and are typically the most personal thing in a repo.
The rule I settled on: demo and example data is always synthetic, never my real data, not even anonymized. No training or health numbers, no screenshots of real dashboards, no pasted transcripts. Anonymized personal data is a category that keeps looking safe right up until someone correlates it, and the effort saved by reusing real numbers is minutes.
#The mechanical layer: greps that do not get tired
Judgment is the part that fails at midnight, so the first pass is mechanical. There is a forbidden strings list, and the grep over a candidate repo has to return zero hits before anything else happens. The categories:
- infrastructure identifiers: server IPs, hostnames, SSH connection strings
- local paths that encode the username, and cloud-sync folder names
- account identifiers: email addresses, user ids, subscription or customer numbers
- personal proper nouns: my own name outside the license and author line, place names tied to me, employer or university names
- internal vocabulary: the names of every persona in my agent setup, which are meaningless to outsiders and instantly deanonymizing in aggregate
That last one surprised me. Persona names are not secrets in any technical sense, but they are a fingerprint. A reader who sees the same unusual set of names in two different places has linked two identities, and I get nothing in exchange for it.
Then gitleaks runs twice, and the second run is the one that matters:
gitleaks detect --source . --no-git # the working tree as it stands
gitleaks detect --source . # every commit in the history
A clean working tree can sit on top of a dirty intermediate commit. That is precisely the case the first scan cannot see and the one the world can. On the last delta run against parallel-streams both came back clean, including the git-mode scan over the full history.
The mechanical layer has a known hole: gitleaks matches known secret patterns, so a home-grown token format walks straight through it. Run it anyway. The hole is what the next layer is for.
#The judgment layer: let a different instance grade it
The session that wrote the content is the worst possible reviewer of that content. It knows what it meant, so it reads what it meant.
So two gates run in a separate model instance that did not write anything: an adversarial audit prompted to attack the repo, and a legal review with its own lens (license, vendored third-party code, quotes, trademark use, terms of service gray areas).
I ran the full pipeline against the gate repository itself, and the audit gate found three medium issues in a small repo written by authors who knew in advance they would be audited: a timeline contradiction inside the worked example, a sweep report that would have refuted itself once published, and a process deviation that had been quietly worked around instead of documented. All fixed before the commit. The legal gate separately flagged a tagline that promised more than the limits section admits (“makes sure” became “built so that”).
Three findings under those conditions is the useful number here. It tells you to assume the pipeline misses things, and to run the gate every time instead of trusting the people who ran it once.
#Where the process actually caught me
Two catches are worth reporting in detail, because they are the ones I would not have found by being careful.
The colliding surname. A fictional character in a worked example had been given a surname that happened to collide with a private codename from my internal setup. No reader outside would ever have noticed, and I certainly did not while writing it. The grep did, because the grep does not know what a coincidence is. This is the whole argument for the mechanical layer in one anecdote: it catches the things that feel like nothing.
The auto-push hook. During a build session, two fresh commits went to their GitHub remote immediately, without any gate having run and without anyone issuing a push. The cause was a leftover .git/hooks/post-commit from an earlier backup setup, which had been sitting in the repo for days doing exactly what it was written to do.
The damage was limited by luck rather than by design. The content had been fully verified at the moment it was pushed (both gitleaks modes clean, forbidden strings zero), so nothing private left the machine. But the guardrail said no push before the gate, and the guardrail was bypassed by a mechanism nobody had thought to check.
What followed: the hook was disabled, and a sweep across all thirteen sub-repositories found the identical active hook in two more. Nothing was force pushed, because a reverting force push is another ungated remote operation and that decision belongs to a human. The gate was then re-run as a delta against exactly those commits, which came back clean. The permanent change is a rule that now runs before any commit in a publish repo: check .git/hooks first.
The general lesson: a publishing guardrail that lives in an agent’s instructions can be bypassed by anything in the environment that acts on its own. Instructions govern the agent, not the machine.
#The last gate is a human
The pipeline ends with a rule that no automation is allowed to touch: agents create and push repositories only as private, and never change a repository’s visibility. The flip from private to public is done manually by me, after reading the gate report.
Two reasons. The publish step is the one irreversible action in the chain, per the three facts at the top of this post. And the flip forces one last human read of the actual thing, rather than a read of a summary claiming the actual thing is fine. If new commits landed between the gate report and the flip, gitleaks runs again first.
#What this does not do
- It reduces risk and guarantees nothing. A process gets skipped on bad days, which is why the pipeline forces its evidence into the chat where I have to look at it.
- Secret scanners match known patterns. A custom token format needs the human gates.
- The audit gates are only as good as the model and the prompt behind them. Independence helps more than the prompt does.
- Nothing here un-pushes a commit. If a real secret ever leaves the machine, revoke and rotate first, before any history surgery. GitHub’s own documentation names that as step one.
The pipeline is written as a skill file for Claude Code, works as a system prompt section in any other agent harness, and reads as a plain human checklist if you do not want an agent involved at all. It needs git, gitleaks, and a second model instance for the two audit gates.
Repo: github.com/belschak/publish-gate. The inbound-facing counterpart, for auditing third party repos before you install them, is repo-audit.