The push boundary, and why it fails closed.
GAThe pre-receive boundary is where a secret is rejected, a policy enforced, and a force-push blocked — before anything lands in history. Owning that boundary is the whole security wedge: the beginner is covered without configuring anything, and no single mistake is unrecoverable.
Every push runs the same policy
Whether a push arrives over HTTPS or SSH, it stops at one boundary that runs before a ref moves. It fails closed — if the policy layer can’t reach a clean verdict, the push is rejected, never waved through — and it never blocks on the control plane: branch policy is cached, so a dashboard blip degrades to the built-in default-branch guard, not to “allow everything.”
| Check | Default | On violation |
|---|---|---|
| Secret scan | On, every repo | Reject the push, report file:line |
| Protected branch | Default branch protected | Reject force-push + delete |
| Author identity | On | Reject if author ≠ authenticated identity |
| Signed commits | Off (opt-in per branch) | Reject unsigned commits on the branch |
| Linear history | Off (opt-in per branch) | Reject non-fast-forward merges |
| Approvals · checks | Enforced at merge | Block the merge until satisfied |
Secret-reject at push, not alert
The boundary scans only the blobs a push introduces (bounded memory — a multi-GB push can’t exhaust it), and a match rejects the whole push with the file and line. The credential never enters history or the reflog, so there is nothing to scrub afterward.
- Reject, not alert. Alerting fires after the secret is already committed — where it lives forever. Rejecting means it was never written.
- Fails closed. A scanner error refuses the push rather than passing it through.
- Audited allowlist for false positives. A genuine non-secret (a test fixture, a sample key) is cleared with a per-repo, reason-stamped allowlist — recorded, not silent. You can also mark a single line inline for the scanner to skip.
# Secrets the boundary rejected, and clearing a false positive.
tilldev forge findings acme-api # what was rejected + file:line
tilldev forge allowlist acme-api <finding-id> \
--reason "test fixture, not a live key" # audited, reason-stampedTune branch protection
A branch policy targets a --pattern (a glob) and layers rules on top of the safe defaults. Set required signed commits, linear history, a required approval count, required status checks, and MFA-to-push — or, deliberately, relax force / delete on a non-default pattern.
# Tune a branch rule. Defaults are already safe — this makes them
# stricter (or, deliberately, looser on a pattern that isn't main).
tilldev forge policy ls acme-api
tilldev forge policy set acme-api --pattern "main" \
--require-signed \ # only signed commits on this branch
--require-linear \ # no merge commits (rebase / squash only)
--approvals 2 \ # required reviews before merge
--checks ci,security \ # required status checks
--require-mfa # MFA-to-push on this protected branch
# (force-push + delete of a protected branch are rejected by default;
# pass --allow-force / --allow-delete only if you truly mean it.)Pattern matching is total: a catch-all * crosses / so there is no gap a branch can slip through. The required approvals and checks here are the same gate the merge flow enforces.
One-click reflog recovery
“A beginner’s mistake is not fatal” is a hard requirement, not a hope. A server-side reflog — independent of any client reflog — records every ref change with its before and after OID, and garbage collection keeps recently-orphaned commits for a configurable window. So a deleted branch, a force-pushed-over ref, or a dangling commit is a one-click restore to the prior OID.
# A deleted branch or a force-pushed-over ref is recoverable.
tilldev forge reflog acme-api # every ref change, before/after OID
tilldev forge recover acme-api <reflog-id> # one-click restore to the prior OIDThe full posture behind these controls — sovereignty, isolation, and the CWE map — is in Security. Next: Pull requests for the merge gate, or back to the TillForge overview.