TILLFORGE · PROTECTION

The push boundary, and why it fails closed.

GA

The 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.

01The boundary

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. If the dashboard can’t be reached, the push is checked against a signed copy of branch policy no more than 72 hours old, and refused if there is none (see When the dashboard is down).

CheckDefaultOn violation
Secret scanOn, every repoReject the push, report file:line
Protected branchDefault branch protectedReject force-push + delete
Commit identityOn · committerReject new commits and tags whose committer or tagger isn’t the pusher; author mode checks the author too
File sizeOn · 100 MiBReject new files over the limit, and say how to move them to Git LFS
Object checkOn, every repoReject malformed objects, such as a submodule name that points outside the repository
Signed commitsOff (opt-in per branch)Reject commits and tags not signed by a registered key; with hybrid, by a hybrid post-quantum key
Linear historyOff (opt-in per branch)Reject merge commits: pushed ones, and pull requests merged with the merge strategy (squash or rebase instead)
Approvals · checksEnforced at mergeBlock the merge until satisfied
02Secrets

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.
  • Your own secrets, by value. In a workspace with TillSecrets, a push is also checked for any current or old vault value, whatever its shape, and the admins are told. This check alone lets a push through, with a warning, when the vault can’t be reached. See Leaked values.
  • Audited allowlist for false positives. When a refused finding isn’t a secret (a test fixture, a sample key), an owner or admin allowlists it with a reason, in the Findings tab or with the CLI. The same rule, file content and line then pass the next time they’re pushed or committed; a changed file is scanned again. If the allowlist can’t be read, the push is refused as before. You can also mark a line with tillforge:allow-secret <reason> or a file with tillforge:allow-file-secrets <reason>. Every finding let through either way is recorded as allowlisted, with who and why, whether it arrived by push, by a dashboard commit or from an agent; an agent’s pull request also lists them for the reviewer. A TillSecrets value in an agent’s write is always refused.
  • TillDev’s own credentials. Besides other vendors’ keys, the scanner knows the prefixed credentials TillDev issues: API keys, TillSecrets and agent tokens, TillForge access, deploy and runner tokens, TillAuth client secrets, refresh tokens and SCIM tokens, and the rest. A finding names the product that issued it, so you know where to revoke it.
  • A private SSH key that lands is dead everywhere. When an allowlisted or mirrored finding is a private SSH key, the key is revoked in every workspace and refused from then on, for SSH and for commit signing. See Leaked keys.
bash
# 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-stamped
git push                                              # the same content now passes

# Changed your mind before pushing again? It is refused again.
tilldev forge allowlist acme-api <finding-id> --revoke
Imported history is scanned too
Pushing an existing repo for the first time scans its whole history, like any push. A pull mirror scans what each sync brings before its branches move: the upstream already has that history, so it can’t be refused, and each secret shows up as a mirrored finding to rotate (a private SSH key among them is revoked everywhere). If the scan or its report fails, the sync fails and the branches stay where they were.

Scans of a commit’s files

The boundary sees only what each push changes. A scan of a commit reads every file at it, so it also finds secrets committed before the boundary was on, or brought in some other way. Each push to the default branch, each green CI build and each release is scanned, and you can start one from the Findings tab or the CLI. After the first, a scan reads only the files changed since the last one and carries the rest over.

  • Already in history. Anything a scan finds was committed, so rotate it where it was issued. Each finding has a severity, and the commit gets a tilldev/secrets check that a branch rule can require.
  • Decisions follow the content. An owner or admin accepts a finding with a reason; later scans accept the same rule, file content and line wherever it appears, and recent scans are re-scored. An allow marker in the file, or an allowlist from the push boundary, counts the same way. Withdraw an acceptance with tilldev forge allowlist <repo> <finding-id> --revoke.
  • Visible where you ship. A build’s or release’s artifact record links to the scan of its commit, and new findings go to your security event stream.
  • Honest limits. A scan is marked partial when the repository passes a limit on files, bytes read or findings, and files too large to read are counted. TillSecrets values are matched at push, not in these scans.
bash
# Every file at the default branch head; exits 1 on an active finding.
tilldev forge secrets acme-api --wait
tilldev forge secrets acme-api --ref v2.4.0 --severity high

# Not a secret? Accept it with a reason (owner/admin, audited).
tilldev forge secrets accept acme-api <scan-id> <finding-id> \
  --reason "public test key from the vendor docs"
03Rules

Tune branch protection

A branch policy targets a --pattern (a glob) and layers rules on top of the safe defaults. Set required signed commits (any registered key, or hybrid post-quantum only), linear history, a required approval count, required status checks, and MFA-to-push — or, deliberately, relax force / delete on a non-default pattern.

bash
# 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" \
  --signed-with hybrid \    # only hybrid post-quantum signed commits
  --require-linear \        # no merge commits (rebase / squash only)
  --approvals 2 \           # required reviews before merge
  --checks ci,security \    # required status checks
  --merge-queue \           # merges wait in line; checks run on the merge result
  --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.)

The rule in force is recorded each time a covered branch moves and graded against the SLSA Source track: refusing force pushes and deletions reaches Level 2, adding any of these controls Level 3, and required approvals that are dismissed on new commits Level 4 for merged pull requests.

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. With --merge-queue, pull requests merge one at a time, and the required checks must pass on the merge result before the branch moves.

The rules hold for every way a branch moves, not only git push. A commit from the dashboard or API, a pull request merge, a merge-queue entry and an agent’s work branch all meet the same checks. --restrict-push-to limits all of them to the members you name. With --require-mfa, a merge or dashboard commit needs the person doing it to have signed in with MFA, as a push needs a token minted after MFA. A queue entry is checked as the person who queued it, both when it joins and again at its turn. As at push, a new branch only has to pass the named-members check.

04Commit identity

Commits name the person who pushed them

git lets anyone write any name and email into a commit. The boundary checks every commit a push brings against the person pushing it, so a teammate’s name can’t be put on work they didn’t do. Each repository picks how strict that is:

ModeWhat must be yours
committerThe committer of every new commit and the tagger of every new tag. The default: rebases, cherry-picks and patches from others keep their author.
authorThe author and the committer of every new commit, and the tagger of every new tag.
offNothing. Commits say whatever git was told.
bash
# Commit with an address TillForge knows is yours: your account email,
# or a commit email you verify once per workspace.
tilldev forge commit-email add dev@home.example    # emails a code, then asks for it
tilldev forge commit-email ls

# Per repository: committer (default), author, or off.
tilldev forge repos update acme-api --identity author
  • Your addresses. Your account email always counts. Add others (a work or personal address) under Signing keys → Commit emails or with tilldev forge commit-email add: TillForge emails a code, and the address counts once you enter it. An address another person in the workspace uses can’t be added. Verified commit emails also count for signed commits.
  • Machine credentials. A deploy token or a machine SSH key belongs to no person, so it may push commits under any address except one a person in the workspace uses.
  • Only new work is checked. Commits already in the repository are never re-checked, and the first push into an empty repository is exempt so existing history can be imported as it is.
  • A refusal says how to fix it. It lists the commits and the address each one names, and the command that makes you their committer (or author).

A repository admin changes the mode in the repository’s Settings → Commit identity or with tilldev forge repos update <repo> --identity committer|author|off. Every change is in the audit log.

05File size

Big files go to Git LFS, not history

A file committed to git stays in every clone forever, even after it’s deleted. So each repository has a file size limit, 100 MiB unless an admin changes it, and the boundary refuses a push that adds a file over it. The refusal lists each file and its size and gives the commands that move them to Git LFS, which keeps big files out of history.

bash
# Per repository: 1–2048 MiB (default 100), or off.
tilldev forge repos update acme-api --max-file-mb 25

# A refused push lists the files. Move them to Git LFS and push again:
git lfs install
git lfs migrate import --include="assets/*.psd"   # rewrites the commits you haven't pushed yet
  • Every door. Pushes over HTTPS and SSH, merges made in TillForge (whatever the strategy, so a pull request from a fork can’t bring one in), browser edits and API commits all hold to the same limit.
  • A warning first. A new file over half the limit is let in with a warning suggesting Git LFS.
  • Only new files count. Files already in the repository aren’t affected. The first push into an empty repository is checked too, so move big files to Git LFS before importing a history.
  • Hard ceilings. Whatever the limit, a single push is capped at 2 GiB, and so is each Git LFS object.

A repository admin changes the limit in Settings → File size limit or with tilldev forge repos update <repo> --max-file-mb <n>|off. Every change is in the audit log.

06Signed commits

Signatures that hold if either algorithm does

A branch can require every new commit and tag to carry a valid signature from an SSH or hybrid key registered in TillForge. PGP signatures are refused there, because TillForge can’t verify them. With --signed-with hybrid it accepts only hybrid post-quantum signatures: ML-DSA-65 (FIPS 204) and Ed25519 over the same bytes, and both must verify. A forger has to break both algorithms, so the signature holds while either one does.

bash
# Once per committer: make a hybrid key, register it, and have git sign with it.
tilldev forge signing-key new --git --global --sign-all

# Then require it on a branch.
tilldev forge policy set acme-api --pattern "main" --signed-with hybrid

# Check locally, as usual.
git log --show-signature -1
git verify-tag v1.4.0
  • Who counts. The key must be registered to a current member or collaborator of the workspace, and their account email or one of their verified commit emails must be the commit’s author or committer (the tagger, for a tag). A key stops counting when its owner leaves.
  • Your key stays with you. The private key is made and kept on your machine. git can’t read this key type itself, so the CLI points git at a small stand-in for ssh-keygen that handles hybrid keys and hands every other key to the real one. A signature is about 7 KB.
  • Nothing gets around it. Merges, squashes and rebases made in TillForge, browser edits and agent commits are signed by TillForge’s own hybrid key and show Verified · TillForge. Before a pull request merges, every commit it brings is checked against the target branch’s rule, whatever the merge strategy, and a refused merge lists them.
  • Every commit shows its verdict. Commits and tags carry a Verified, Unverified or Unsigned badge, and a PQ mark when the signature is hybrid.
Register keys before you require them
A branch that requires hybrid signatures refuses every push until someone who commits to it has registered a hybrid key. The policy screen and tilldev forge policy ls warn while no hybrid key counts.
07Recovery

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.

bash
# 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 OID
Recovery is a safe compare-and-swap
Restoring a ref writes with a compare-and-swap on the value the change left behind — so if someone has pushed again in the meantime, the recovery won’t silently clobber their newer work; it tells you, instead of losing a commit to fix losing a commit.

The 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.